避免 RAW 危害造成性能损失

作者:Darryl Gove

写后读 (RAW) 操作为何会导致性能损失,如何识别、修复和避免 RAW 危害。


2014 年 2 月发布


简介

RAW 危害是一种可能影响所有类型处理器的性能问题。RAW 危害的代价之大令人震惊。许多情况下,可以轻松避免这些危害。要理解此问题,我们需要对处理器的工作方式稍加了解。

想对本文发表评论吗?请将链接发布在 Facebook 的 OTN Garage 页面上。有类似文章要分享?请将其发布在 Facebook 或 Twitter 上,我们来进行讨论。

RAW 危害是一个写后读问题。应用程序将一个值存入内存,随后希望从内存重新加载该值。这是一种常见情况,所以大多数芯片都包含可以加速此操作的硬件。如果这种硬件不存在,就会影响这种常见形式的代码。但在有些情况下这种硬件无法工作,这将导致处理器由于 RAW 危害而停滞。

将数据存储到内存

处理器存储一个值时,不会立即将该值发送到内存。内存被划分成一些称作缓存行 的块 — 通常 64 个字节。要将值存储到缓存行,需要从内存提取整个缓存行,还需要更新存储的字节,然后需要将修改后的缓存行写回内存。此过程需要一些时间:主要是从内存提取缓存行所需的时间。

为了确保处理器不会停下来等待从内存提取缓存行,设有一个结构来保存所有待处理的存储操作列表。此结构可称作存储队列存储缓冲区。执行存储操作时,将存储的值添加到此列表,直到从内存提取完缓存行后,才会将该值移至缓存行中。一旦提取了缓存行,存储即可继续,将修改后的缓存行写出到缓存,最终写入内存。该值可能在存储队列中保留很多个周期,等待从内存中提取缓存行。

绕过值

将值存储到某个地址,稍后从该地址加载,如果处理器必须等待从内存提取缓存行完成,加载可能需要较长时间才到获得该值。因此大多数处理器有一种绕行操作,加载操作在从内存提取值之前检查存储队列中的最新数据,而不必等待。

如果绕行操作起作用,加载就可以从存储队列快速获取该值。如果之前曾经将值存储到该地址,但由于某种原因绕行操作无法起作用时,就会发生 RAW 危害。这种情况下,加载必须等待存储的值到达缓存。这可能需要很多个周期。

导致绕行操作无法正常工作的原因有很多。确切情况将取决于绕行硬件的复杂程度。硬件越复杂,可以避免的情况就越多。

如果将一个值存储到某个地址,随后从该地址加载一个与此次存储相同大小的值,绕行硬件通常应该能够处理这种情况。例如,如果将一个整数存储到内存中的某个地址,后续从该地址加载一个整数时,硬件应该能够绕过这个值。

有些情况下,绕行可能不起作用。如果绕行不起作用,就会发生停滞,直至存储的数据在缓存中可用。这种停滞的代价通常与从内存加载相同。下面列出部分硬件可能无法在先期存储到后期加载中绕过某值的情况:

  • 绕过同一地址的多次存储。尽管存储队列可能按时间排序,但如果同一地址发生多次存储,硬件可能无法确定后续加载时绕过哪个存储值。
  • 存储和加载不同大小的数据,例如,如果某地址存储一个 4 字节整数值,后续加载尝试从该地址提取一个字节。
  • 存储和加载部分数据,例如,如果将 4 个字节存储到连续地址,后续加载尝试将这 4 个字节作为一个整数读取。
  • 其他硬件限制。硬件对绕行操作可能有一些其他限制。例如,可能有一个绕行操作起作用的时间“窗口”,但在此窗口过后,绕行操作无法再成功,加载需要等待该值在缓存中可用。

造成 RAW 危害的常见情况

有些情况下,在开发人员看来合理的代码可能会对硬件造成 RAW 危害。一个常见的代码模式是当开发人员需要操作字节并将字节串连成整数时。此操作可能会将缓冲区中 4 个不对齐的字节数据加载成一个整数,也可能在小字节序硬件中模拟大字节序加载。考虑清单 1 中所示代码段:

int misaligned_load_int(char* ptr)
{
  int temp;
  memcpy(&temp, ptr, sizeof(int));
  return temp;
}

清单 1

ptr 指向的内存并不保证对齐,因此在无法处理不对齐加载的硬件上,调用 memcpy() 也可能变成等同于清单 2 中代码的一系列字节加载和存储。事实上,许多开发人员可能会编写清单 2 中所示代码,以执行相同的操作。

int misaligned_load_int(char* ptr)
{
  int temp;
  ((char*)&temp)[0] = ptr[0];
  ((char*)&temp)[1] = ptr[1];
  ((char*)&temp)[2] = ptr[2];
  ((char*)&temp)[3] = ptr[3];
  return temp;
}

清单 2

从清单 2 中代码可以看出,编译器将生成 4 个加载字节指令、4 个存储字节指令以及一个整数加载的序列。这种情况下,我们要存储相同大小的数据,然后尝试加载另一种大小的数据;因此,硬件可能无法绕过存储的值进行加载。

另一种情况下代码也可能导致潜在的 RAW 危害,即编译器无法直接使用存储到内存的值,需要重新加载代码以确保正确。当将某个变量声明为 volatile 或者可能有另一个指针可能以别名方式指向该地址时,就会发生这种情况。考虑清单 3 中的代码:

void update(int *value1, int *value2)
{
  *value1++;
  *value2++;
  return *value1;
}

清单 3

在清单 3 中,需要执行到 value1 的存储,因为编译器不知道 value1value2 是否指向同一位置。如果这两个值保存在同一位置,则更新 value2 将更改读取 value1 所返回的结果。

有几个原因令这种情况非常有趣。大多数硬件应该能够正常绕过 value1 存储,到达后面的加载。因此,尽管代码存在潜在的 RAW 危害,但大多数处理器都能够正确处理。但如果 value1value2 确实 指向内存中的同一位置,那么存储队列中可能有两个存储是到同一位置,绕行操作可能不起作用。

修复 RAW 危害

RAW 危害的许多情况都有简单修复办法。修复办法通常是避免加载刚刚已存储到内存的值。有时,编译器可以自动完成此操作,有时则需要开发人员干预。

如果 RAW 危害是由不同大小导致的,则需要开发人员使用逻辑操作重新编码,而不是通过内存传递数据。如果需要使用字节大小的存储操作(如整数)访问存储的数据,可以加载字节,然后在寄存器中合并,而不是在内存中合并。可以针对大字节序硬件重写清单 2 中所示的 misaligned_load_int() 示例,如清单 4 所示:

int misaligned_load_int(char* ptr)
{
  int temp;
  temp = (ptr[3]<<24) + (ptr[2]<<16) + (ptr[1]<<8) + ptr[0];
  return temp;
}

清单 4

如果仍以前面清单 3 中的 update() 为例,可以手动检查这两个位置是否不同并相应地处理这两种情况,如清单 5 所示。这种代码序列会引入测试两个指针并基于结果进行分支处理的开销。因此,如果两个指针不同,运行速度可能比原始代码慢。但如果处理器遭受 RAW 危害且两个指针频繁指向同一位置,分支的代价可能低于 RAW 危害的代价。

void update(int *value1, int *value2)
{
  if (value1==value2)
  {
    *value1+=2;
    return value1;
  }
  else
  {
    int temp = *value1++;
    *value2++;
    return temp;
  }
}

清单 5

识别 RAW 危害

概括来说,RAW 危害看起来像是花在加载指令上的时间。通常难以判断花在加载上的时间是由于 RAW 危害,而不是由于令加载费时的某个其他原因,例如缓存未命中。

通过反汇编,可以发现一些线索。对于导致 RAW 危害的原因,必须先前有到同一位置的存储。如果数据保存在堆栈上,反汇编将清晰指示该地址离堆栈指针的偏移相同。

但是,检查反汇编可能需要一些耐心。许多处理器会提供硬件计数器,指示是否发生 RAW 危害。因此,这可能是一种确定是否发生 RAW 危害的便捷方式。结合使用性能计数器与监测工具通常可指明代码中发生 RAW 危害的确切位置。

清单 6 是对其中一个示例代码片段使用 Oracle Solaris Studio Performance Analyzer 的示例。在 Oracle SPARC T4 处理器上已经使用名为 RAW_hit_st_buf 的硬件性能计数器(对 RAW 危害事件进行计数)对该应用程序进行了监测。

$ collect -h RAW_hit_st_buf ./a.out
$ er_print test.1.er
(er_print) metrics e.RAW_hit_st_buf
(er_print) src  misaligned_load_int_bad2
   Excl.
   RAW_hit_st_buf
   Events
...
                    42. int misaligned_load_int_bad2(char* ptr)
            0       43. {
                    44.   int temp;
            0       45.   ((char*)&temp)[0] = ptr[0];
            0       46.   ((char*)&temp)[1] = ptr[1];
            0       47.   ((char*)&temp)[2] = ptr[2];
            0       48.   ((char*)&temp)[3] = ptr[3];
            0       49.   return temp;
    100000302       50. }

清单 6

监测指示代码中出现 RAW 危害问题的位置。

包含 RAW 危害的代码示例

清单 7 中的代码包含存在 RAW 危害的例程示例,以及避免了 RAW 危害的替代版本代码。请注意,该代码使用 Oracle Solaris 调用 gethrtime(),这是一种低开销的获取高分辨率时间戳的办法。可移植性更强的办法是使用 gettimeofday() 等调用,但这样做的开销更大。

#include <stdio.h> 
#include <sys/time.h> 
#include <string.h> 

void tick() 
{ 
  hrtime_t now = gethrtime(); 
  static hrtime_t then = 0; 
  if (then>0) printf("Elapsed = %f ns\n", 1.0*(now-then)/100000000.0100000000.0); 
  then = now; 
} 

int update_bad(int *value1, int *value2) 
{ 
  (*value1)+=1; 
  (*value2)+=1; 
  return *value1; 
} 

int update_good1(int *value1, int *value2) 
{ 
  if (value1==value2) 
  { 
    (*value1)+=2; 
    return *value1; 
  } 
  else 
  { 
    int temp = *value1+=1; 
    (*value2)+=1; 
    return temp; 
  } 
} 

int misaligned_load_int_bad1(char* ptr) 
{ 
  int temp; 
  memcpy(&temp, ptr, sizeof(int)); 
  return temp; 
} 

int misaligned_load_int_bad2(char* ptr) 
{ 
  int temp; 
  ((char*)&temp)[0] = ptr[0]; 
  ((char*)&temp)[1] = ptr[1]; 
  ((char*)&temp)[2] = ptr[2]; 
  ((char*)&temp)[3] = ptr[3]; 
  return temp; 
} 

int misaligned_load_int_good(char* ptr) 
{ 
  int temp; 
  temp = (ptr[3]<<24) + (ptr[2]<<16) + (ptr[1]<<8) + ptr[0]; 
  return temp; 
} 

#define COUNT 100000000 
#define COUNT2 300000000 

void main() 
{ 
  char buffer[1000]; 
  tick(); 
  printf("Misaligned load v1 (bad) memcpy()\n"); 
  for(int i=0;i<COUNT;i++) misaligned_load_int_bad1(&buffer[1]); 
  tick(); 
  printf("Misaligned load v2 (bad) byte copy\n"); 
  for(int i=0;i<COUNT;i++) misaligned_load_int_bad2(&buffer[1]); 
  tick(); 
  printf("Misaligned load good\n"); 
  for(int i=0;i<COUNT;i++) misaligned_load_int_good(&buffer[1]); 
  tick(); 
  int value1, value2; 
  printf("\nUpdate bad -- different addresses\n"); 
  for(int i=0;i<COUNT2;i++) update_bad(&value1,&value2); 
  tick(); 
  printf("Update bad -- same address\n"); 
  for(int i=0;i<COUNT2;i++) update_bad(&value1,&value1); 
  tick(); 
  printf("Update good -- different addresses\n"); 
  for(int i=0;i<COUNT2;i++) update_good1(&value1,&value2); 
  tick(); 
  printf("Update good -- same address\n"); 
  for(int i=0;i<COUNT2;i++) update_good1(&value1,&value1); 
  tick(); 
}

清单 7

与所有微基准测试的情况一样,您需要小心,避免编译器因不知道代码是无意义的而对其彻底优化,或者以其他方式对其优化,从而破坏代码试图展示的效果。

对于清单 7 中的代码,有两处优化,编译器绝对不能执行。需要关闭函数内联;如果启用,编译器将几乎肯定会认为代码未执行任何有用的工作,于是会清除所有这些循环。需要禁用的第二个优化是识别 memcpy() 函数调用。如果编译器识别 memcpy() 调用,可能会将其替换为更高效的代码,甚至可能会生成代码来消除 RAW 危害。

对于 Oracle Solaris Studio 编译器,在优化级别 -O 不会启用函数内联,而标志 -xbuiltin=%none 会告诉编译器不要用内联代码替换对 memcpy() 的调用。清单 8 显示了在旧的 Oracle Solaris x86 系统上编译和运行微基准测试的结果:

$ cc -O -xbuiltin=%none raw.c 
$ ./a.out 
Misaligned load v1 (bad) memcpy()
Elapsed = 24.73803324.738033 s 
Misaligned load v2 (bad) byte copy
Elapsed = 11.100576 s 
Misaligned load good 
Elapsed = 4.348355 s 

Update bad -- different addresses 
Elapsed = 13.26493713.264937 s 
Update bad -- same address 
Elapsed = 17.670419 s 
Update good -- different addresses 
Elapsed = 13.88809413.888094 s 
Update good -- same address 
Elapsed = 13.58768513.587685 s 

清单 8

清单 8 的结果显示使用 memcpy() 处理不对齐数据的代码速度极慢 — 所需时间大约是替代它的逻辑操作的 6 倍:24 秒对 4 秒。部分是因为 memcpy() 函数调用的代价,但也有 RAW 危害的原因。使用字节加载和存储的代码结果还是存在 RAW 危害,所需时间大约是逻辑操作的 3 倍:11 秒对 4 秒。

清单 8 中更新两个内存位置的代码的结果显示了有趣的但也在预料之中的行为。如果两个位置不同,原始代码只是比包括检查潜在 RAW 危害的开销的代码略快:13.3 秒对 13.9 秒。但如果两个地址相同,原始代码速度将明显下降:性能由 13.3 秒降至 17.7 秒。替代代码比原始代码多一些开销,但可以避免两个指针指示同一位置时的速度下降;原始代码需要 13.3 秒,包含解决方法的代码大约需要 13.6 秒。在此需要认识到的重点一点是,新代码也许会稍慢一些,但避免了潜在的显著速度下降。

总结

RAW 危害可以成为造成处理器停滞的显著原因。但在许多情况下可以解决或避免的。

另请参见

关于作者

Darryl Gove 是 Oracle Solaris Studio 团队的首席高级软件工程师,负责优化在当前和未来处理器上运行的应用程序和基准测试。他还著有《Multicore Application Programming》、《Solaris Application Programming》和《The Developer's Edge》。他的博客地址为 http://blogs.oracle.com/d/

修订版 1.0,2014 年 2 月 21 日

关注我们:
博客 | Facebook | Twitter | YouTube