代码改变世界

程序员可以做点什么 Part 1

2010-08-09 15:17  Robbin  阅读(3733)  评论(5编辑  收藏  举报

嗯,还好继续翻译完这个系列,原文链接:http://lwn.net/Articles/255364/

6 程序员可以做点什么

在前面的章节介绍之后,很明显程序员有很多机会来影响一个程序的性能,不管是正面的还是负面的。这里只讨论和内存相关的操作。我们会完整的讨论这些方方面面,从最底层物理内存存取和L1缓存开始,直到操作系统功能对内存处理的影响为止。


6.1 绕过缓存

当数据产生后但没有(立即)被再次使用,那么内存写操作会首先读入完整的缓存行并修改缓存数据对性能是有害的。这个操作刷新的缓存数据可能是那种不会被立刻使用(但可能稍后被用到的)。这对于大的数据结构更明显,如矩阵,它会被填充完毕后再被使用。在矩阵最后一个元素被填充完之前第一个元素就被从缓存中逐出,导致写缓存效率太低。

对于这种及类似的情况,处理器提供了对非暂时(non-temporal)写操作的支持。非暂时在这里意味着数据不会很快被再次使用,所以没必要对其缓存。这些非暂时写操作不会读入缓存行并修改,而是将新的内容直接写入到内存中。

这听上去代价昂贵,但并非不可避免。处理器会尝试使用写合并(见3.3.3节)来填充整个缓存行。如果成功则完全不再需要内存读操作。如x86和x86-64体系结构上,gcc提供了一系列内置函数:

#include <emmintrin.h>
void _mm_stream_si32(int *p, int a);
void _mm_stream_si128(int *p, __m128i a);
void _mm_stream_pd(double *p, __m128d a);
#include <xmmintrin.h>void _mm_stream_pi(__m64 *p, __m64 a);
void _mm_stream_ps(float *p, __m128 a);
#include <ammintrin.h>void _mm_stream_sd(double *p, __m128d a);
void _mm_stream_ss(float *p, __m128 a);

这些指令如果在一趟中处理大量数据情景中使用的话会非常高效。数据从内存载入,使用一或多步来处理,然后写回到内存中去。数据“流”过处理器,这些指令因此而得名。

内存地址必须8字节或16字节对齐。代码中使用多媒体扩展时可以使用这些非暂时版本函数替换正常的_mm_store_*内部函数。在9.1节中矩阵相乘的代码中我们没有使用它是因为写入的值会很快被再次使用。这也是一个使用流函数效果不彰的例子。6.2.1节更详细介绍了这段代码。

处理器的写合并缓冲可以将对特定缓存的写操作延后一小会儿。如果必要它可以将对一个缓存行的修改的所有操作重新排序使之满足写合并。下面显示如何实现这个目的的例子:

#include <emmintrin.h>
void setbytes(char *p, int c)
{
  __m128i i = _mm_set_epi8(c, c, c, c,
                                      c, c, c, c,
                                      c, c, c, c,              
                                      c, c, c, c);
  _mm_stream_si128((__m128i *)&p[0], i);
  _mm_stream_si128((__m128i *)&p[16], i);
  _mm_stream_si128((__m128i *)&p[32], i);
  _mm_stream_si128((__m128i *)&p[48], i);
}

假设指针p正确对齐了,对这个函数的调用会将这个地址的缓存行全部设为c。写合并逻辑会检查到生成的4个movntdq指令并仅在最后一个指令执行时才写入到内存。也就是说,这避免了在缓存行写完之前被读入,也避免了缓存被不会立即使用的数据所污染。在一定情形下这会有巨大的好处。一个使用此技巧的例子就是时刻被使用的C库函数memset,它在处理大数据块时,应该使用上面的手法。

一些架构提供了具体的解决方案。PowerPC架构定义了dcbz指令,它可以用于清理一整个缓存行。指令不会真的绕过缓存,因为缓存行还是会被指令结果影响,但不会从内存读取数据。它比非临时存取指令更受限,缓存行只能被设为全0且会污染缓存(当数据是非暂时的),但为了得到结果不再需要写合并逻辑。

为了检查非暂时指令的效果我们会使用一个新的测试来测量写矩阵,它用一个二维数组来组织数据。编译器决定矩阵在内存中的布局,使左边(第一个)的元素所在行的所有元素连续的排列在内存中。右边(第二个)的数据是相邻的。测试程序以两种方式遍历矩阵:首先在内循环中增加列数,然后在内循环中增加行数。得到的结果显示在图6.1中。

Figure 6.1: Matrix Access Pattern

图6.1 矩阵存取模式

我们测量初始化一个3000×3000的矩阵所需的时间。要观察内存操作,我们使用不会使用缓存的存取指令。IA-32处理器上的“非暂时提示”可以用于此。为了对比我们也测量了普通的写操作。结果显示在表6.1。

Inner Loop Increment
RowColumn
Normal 0.048s 0.127s
Non-Temporal 0.048s 0.160s

表6.1: 矩阵初始化计时

正常的写操作按照我们预期的方式使用缓存:如果内存顺序访问则我们得到的结果很好,整个操作使用0.048s也就意味着750MB/S,而差不多随机存取的情况要消耗0.127s(大约280MB/S)。矩阵过大导致缓存不是那么有效。

这里我们主要感兴趣的部分是绕过缓存的写操作。可能令人吃惊的是这里顺序存取操作和使用缓存的情况一样快。这里的原因是上面介绍的处理器执行了写合并。另外,对非暂时写的内存排序原则可以解释为:程序需要显示的插入内存屏障(x86和x86-64处理器使用sfence)。这意味着处理器有足够的自由写回(write back)数据也因此有尽可能大的可用带宽。

在内循环中按列宽存取的情况中结果是不同的。结果会显著慢于使用缓存存取的情况(0.16s,大约225MB/S)。我们可以看到没有写合并且每个内存单元都有不同地址。这就需要每次都选择RAM芯片上新行以及全相连的延迟。这要比使用缓存要慢25%。

在读方面,直到最近,处理器对使用非暂时预取(NPA)指令有些弱提示之外仍然缺少必要支持。读操作也没有和写合并对等的功能,这对于像内存映射I/O这样为缓存内存尤其糟糕。Intel使用SSE4.1扩展引入了NTA读。它们使用一小组读的流缓冲来实现;每个缓存包含一个缓存行。一个缓存行的第一个movntdqa指令会将整个缓存行读入缓存,可能会替换另一个缓存行。后续的16字节对齐存取这个缓存行会有这个读缓存来提供服务已实现最小开销。除非有其它理由,缓存行不会再次被导入到缓存中去,因而读取大规模内存数据时不会污染缓存。编译器提供了一个内部函数来实现此功能:

#include <smmintrin.h>
__m128i _mm_stream_load_si128 (__m128i *p);

这个内部函数需要被多次调用,以16字节块的地址作为参数,直到所有的缓存行被读过。然后再处理下一个缓存行。因为只有少量的读缓存可能会出现一次从两处内存地址读入。

我们还有从这个实验中去掉的是现代CPU可以非常好的优化顺序的未缓存写和(直到最近)读操作。这方面的知识在处理只使用一次大数据结构时很有用。其次,缓存可以帮助降低一些(但不是全部)的随机内存存取开销。随机存取在这个例子中由于RAM存取实现导致要慢上70%。除非实现改变,只有可能就应该避免随机存取。

在介绍预取的章节中我们会再次关注非暂时(non-temporal)标志。