《What every programmer should know about memory》-What Programmers Can Do译
Posted fanchenxinok
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了《What every programmer should know about memory》-What Programmers Can Do译相关的知识,希望对你有一定的参考价值。
原文PDF: http://futuretech.blinkenlights.nl/misc/cpumemory.pdf
一二章参考博文:https://www.oschina.net/translate/what-every-programmer-should-know-about-memory-part1?lang=chs&p=6
目的在边学习边翻译让自己理解的更加深刻。
6.程序员可以做什么
通过前面章节的学习我们清晰的认识到程序员有很多机会影响程序的性能,可以是正面的也可以是负面的。当然这仅仅是内存相关操作的影响。从最低级别的物理内存访问,L1级别的缓存,到操作系统功能,这些影响内存的操作,我们将会从头阐述其影响程序性能的机会。
6.1 绕过缓存
当生成的数据没有(快速的)被再次使用,内存存储操作首先读取完整的缓存行,然后修改缓存的数据,会损失性能。该操作将可能再次需要的数据从缓存中逐出,而近期不会使用的数据反而没有被逐出。这种情况对于大型数据结构尤为明显,例如数组矩阵,填充的数据在随后才会被访问。在数组的最后一个元素被填充到缓存之前,数组的第一个元素可能由于缓存满了而被逐出,造成写缓存失效。
对于这种情况和相类似的情况,处理器提供了“non-temporal”写操作。本文的“non-temporal”意思是对于没有马上被用到的数据就没有缓存的必要。这些“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);
这些指令对处理大量数据的效率很高。数据从内存中加载,经过一次或多次处理后直接写回内存。数据“流”过处理器,因此函数名带有“stream”。
内存地址必须分别按8或者16字节对齐。代码中使用多种扩展表示可能替换_mm_store_*函数名。章节A.1中的矩阵相乘代码中,我们并没有这么做,因为写入的数值在随后短时间内会别再使用。这里展示的使用“stream”指令的例子没有多大用处。这段代码的更多信息参见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数据。写合并的逻辑将看到四条生成的movntdq指令,并且只有最后一条指令执行后才发出写内存命令。总之,这段代码序列不仅避免了在写之前读缓存行,也避免了用可能不会很快用到的数据污染缓存。在某些情况下,这样做有巨大的优势。一个每天都会用到该技术的代码是C运行时的memset函数,面对大型数据块时,该函数应该用上所述的代码序列。
一些架构定制了专门的解决方案。PowerPC架构定义了dcbz指令,该指令可以用来清除整个缓存行。该指令并没有真正绕过缓存,因为结果也会分配一个缓存行,只是是空的没用从内存中读取数据。相比“non-temporal”存储指令,该指令功能有限,因为该指令仅仅将缓存行清为全0,污染了缓存(这种情况数据是non-temporal),但是获得这结果不需要写结合逻辑。
为了了解“non-temporal”指令的作用,我们将看到一个新测试,测试对于一个二维数组的写入。编译器将矩阵在内存中排布,最左边(第一个)索引是指在内存中按顺序排列的所有元素的行。第二个索引代表行中的元素。测试程序按两种方式迭代整个数组:(1)增加内循环的列数,(2)增加内循环的行数。图6.1展示了两种迭代方式的行为。
图6.1 矩阵迭代方式
我们测试了初始化3000x3000矩阵所需的时间。为了了解内存的行为,我们使用了不用缓存的存储指令。在IA-32处理器上使用“non-temporal hint”。另一个使用普通的存储操作作为比较。结果如表6.1所示。
表6.1 矩阵初始化时间
对于使用缓存的普通写操作,我们看到了预期结果:如果按顺序使用内存,我们会得到更好的结果,整个操作0.048s换算成大约750MB/s,相比之下,或多或少的随机访问需要0.127s(大约280MB/s)。由于矩阵比较大,缓存基本上是失效的。
这里我们主要感兴趣的部分是绕过缓存的写操作。令人惊讶的是,这里的顺序访问与使用缓存的情况一样快。产生这个结果的原因是处理器执行了上面提到的写合并。此外,对“non-temporal”写的内存顺序访问规则是宽松的:程序需要显式地插入内存屏障(x86和x86-64处理器用sfence指令)。这意味着处理器对写回数据自由度更高,从而尽可能得使用可用带宽。
在内部循环中按列访问的情况是不同的。非缓存访问的结果明显慢于缓存访问(0.16s,大约225MB/s)。在这里,我们可以看到没有写合并是可能的,每个内存单元必须单独寻址。这需要不断地在RAM芯片中选择新的行,会有相应的延迟。结果比缓存运行差25%。
在读取方面,处理器到目前为止都缺乏支持,除了使用“non-temporal”访问(NTA)预取指令的弱提示外。读取方面,没有和写合并等效的操作,这对于内存映射I/O这样的非缓存内存尤其糟糕。带有SSE4.1扩展的英特尔处理器引入了NTA加载。它们使用少量的stream load buffer;每个buffer包含一个缓存行。对于某个缓存行,第一条movntdqa指令将加载缓存行的到一个buffer中,可能会替换另一个缓存行。随后以16字节对齐的方式访问同一个缓存行中的内容将在buffer中访问,花销很小。除非有其他原因,否则缓存行不会被加载到缓存中,这样就可以在不污染缓存的情况下加载大量内存。编译器提供了一个内在的指令:
#include <smmintrin.h>
__m128i _mm_stream_load_si128 (__m128i *p);
这个内在的指令应该被多次使用直到读取缓存行的每个字节,并将块(16字节)的地址作为参数传递。只有这样下个缓存行才启动。因为有一些流读buffer可以一次从两个内存位置读取。(不理解???)
我们应该从这个实验中得到的是:现代cpu很好地优化了非缓存写,甚至是读访问,只要它们是顺序访问的。当处理只被使用一次的大型数据结构时,这个知识点很有用。第二点,缓存能够减少部分(不是全部)随机内存访问的开销。这个例子中的随机内存访问受到RAM访问实现的影响慢70%。在RAM实现发生变化之前,应该尽可能避免随机访问。
在预取一节中,我们将再次关注non-temporal标志。
6.2 缓存访问
希望提高程序性能的程序员会发现,最好关注影响一级缓存的变更,因为这些变更可能会产生最好的结果。我们将先讨论一级缓存,然后再进一步讨论其他层级。显然,对一级缓存的所有优化都会影响其他高级别的缓存性能。所有内存访问的主题都是相同的:改进局部性(空间和时间)并对齐代码和数据。
6.2.1 优化一级数据缓存的访问
在3.3章节,我们已经看到L1d缓存如何有效的提高性能。本节我们将展示什么样的代码变更有助于提高L1d缓存的性能。继续上一节的内容,我们首先集中讨论按顺序访问内存的优化。如3.3节中的数字所示,当顺序访问内存时,处理器会自动预取数据。
使用的示例代码是一个矩阵乘法。我们使用两个由1000 × 1000元素组成的二维方阵。对于那些忘记数学的人,给定两个矩阵A和B元素aij和bij,0≤i,j < N的乘积是:
直截了当的C实现是这样的:
for (i = 0; i < N; ++i)
for (j = 0; j < N; ++j)
for (k = 0; k < N; ++k)
res[i][j] += mul1[i][k] * mul2[k][j];
mul1和mul2是两个输入矩阵。res假设初始化为全0的矩阵。这是简单而友好的实现。显然我们图6.1已经将问题解释清楚了。当mul1被顺序访问时,内循环将增加mul2的行数。这意味着mul1的处理方式类似于图6.1中的左矩阵,而mul2的处理方式类似于右矩阵。这肯定不好。
有一种很容易尝试的补救方法。由于矩阵中的每个元素都要被访问多次,所以在使用第二个矩阵mul2之前,有必要对它进行重新排列(数学术语为“转置”)。
转置之后(传统上用上标“T”表示),我们现在依次遍历两个矩阵。就C代码而言,它现在看起来像这样:
double tmp[N][N];
for (i = 0; i < N; ++i)
for (j = 0; j < N; ++j)
tmp[i][j] = mul2[j][i];
for (i = 0; i < N; ++i)
for (j = 0; j < N; ++j)
for (k = 0; k < N; ++k)
res[i][j] += mul1[i][k] * tmp[j][k];
我们创建一个临时变量来存储转置矩阵。这需要额外的内存,但是这开销有望得到抵消,因为每列1000次非顺序访问的代价更大(至少在现代硬件上是这样)。该进行一些性能测试了。在2666 MHz的Intel Core 2上运行的结果是(以时钟周期计算):
通过简单的矩阵变换可以达到76.6%的提速,对这1000个非顺序访问进行复制操作真的很麻烦。
下一个问题是,这是否是我们能做到的最好的情况呢。我们当然需要一种不需要额外拷贝的替代方法。我们不会总是能够执行复制操作,可能矩阵太大或可用内存太小。
寻找替代实现应该从仔细检查所涉及的数学和原始实现所执行的操作开始。简单的数学知识让我们看到,只要每个加数恰好出现一次,结果矩阵中每个元素的加法运算的顺序是无关紧要的,这样我们可以寻找到一种解决方案,就是重新对原始代码的内循环执行的加操作进行排序。
现在让我们检查原始代码执行中的实际问题。mul2元素的访问顺序是:(0,0),(1,0),…(n - 1,0)、(0,1),(1,1), . . . .元素(0,0)和(0,1)在同一缓存行中,但是当内部循环完成一轮时,这个缓存行已经被移除很久了。本例的三个矩阵,每一轮内循环需要1000个缓存行(Core 2处理器需要64个字节)。这加起来比L1d的32k要多得多。
但是如果我们在执行内部循环的同时处理中间循环的两次迭代会怎么样呢?在本例中,我们使用了来自高速缓存行的两个双精度值,它保证位于L1d中。我们把脱靶率降低了一半。这确实是有进步,但是,它仍然可能没有我们可以获得的那么好的性能,这取决于缓存行大小。Core 2处理器的L1d高速缓存行大小为64字节。实际值可以通过运行时使用
sysconf (_SC_LEVEL1_DCACHE_LINESIZE)
或者使用getconf命令行工具获得,程序可以根据特定的高速缓存行大小进行编译。sizeof(double)是8个字节的情况下,充分利用缓存行,我们应该对中间循环展开8次。继续这个思路,为了有效地使用res矩阵,即同时写8个结果,我们也应该展开外循环8次。我们假设缓存行的大小是64,但是代码在32字节缓存行的系统上也可以很好地工作,因为这两个缓存行的利用率也是100%。一般来说,最好在编译时使用getconf工具硬编码缓存行大小,如下所示:
gcc -DCLS=$(getconf LEVEL1_DCACHE_LINESIZE) ...
如果二进制文件是通用的,那么应该使用最大的缓存行。对于非常小的L1ds,这可能意味着不是所有的数据都能缓存,但这样的处理器无论如何都不适合高性能程序。我们得到的代码看起来像这样:
#define SM (CLS / sizeof (double))
for (i = 0; i < N; i += SM)
for (j = 0; j < N; j += SM)
for (k = 0; k < N; k += SM)
for (i2 = 0, rres = &res[i][j],
rmul1 = &mul1[i][k]; i2 < SM;
++i2, rres += N, rmul1 += N)
for (k2 = 0, rmul2 = &mul2[k][j];
k2 < SM; ++k2, rmul2 += N)
for (j2 = 0; j2 < SM; ++j2)
rres[j2] += rmul1[k2] * rmul2[j2];
代码看起来很吓人,从某种程度上说,确实如此,但这只是因为代码中使用了一些技巧。最明显的变化是我们现在有了6个嵌套循环。外层循环的迭代间隔为SM(缓存行大小除以sizeof(double))。这将乘法分解成几个更小的问题,这些问题可以用更多的缓存局部性来处理。内部循环遍历外部循环中缺失的索引。这里还是有三个循环。这里唯一棘手的部分是k2和j2循环的顺序不同。这样做是因为,在实际的计算中,表达式中只有rmul1依赖于k2,但rmul2和rres依赖于j2。
代码中剩下的复杂性是由于gcc在优化数组索引时不是很聪明。引入的其他变量rres、rmul1和rmul2通过从内循环中尽可能深入地提取公共表达式来优化代码。C和c++语言的默认别名规则并不能帮助编译器做出这些决定(除非使用了限制,否则所有的指针访问都是别名的潜在来源)。这就是为什么Fortran仍然是数字编程的首选语言:它使编写快速代码变得更容易。
表6.2 矩阵相乘的时间
这个优化方案的开销见表6.2。通过避免复制,我们又获得了6.1%的性能。另外,我们不需要任何额外的内存。输入矩阵可以是任意大的,只要结果矩阵也能放进内存。这是我们目前需要实现的通解。
表6.2中还有一列没有解释。现在大多数处理器都包含对向量化的特殊支持。通常作为多媒体扩展,这些特殊指令允许在同一时间处理2,4,8,或更多的值。这些操作通常是SIMD(单指令,多数据)操作,由其他操作扩展以获得正确形式的数据。英特尔处理器提供的SSE2指令可以在一次操作中处理两个double值。指令参考手册列出了提供访问这些SSE2指令的内在函数。如果使用了这些内在函数,程序运行速度会再快7.3%(相对于原始程序)。其结果是运行时间仅为原始代码的10%。换算成人们能认出的数字,我们从318 MFLOPS上升到3.35 GFLOPS。由于我们只对使用内存效率感兴趣,程序代码参见A.1小节。
需要注意的是,在上一个版本的代码中,mul2仍然存在一些缓存问题;预取仍然不起作用。但是如果不对矩阵进行转置就不能解决这个问题。也许缓存预取单元会变得更聪明,能够识别模式,那么就不需要额外的更改了。对于2.66 GHz处理器,单线程代码能达到3.19 GFLOPS 也不错了。
在矩阵乘法的例子中,我们优化了加载的缓存行的使用。缓存行的所有字节总是被用到。我们只是确保他们在缓存行被逐出前被使用。当然这只是一个特例。
更常见的情况是,数据结构填充了一个或多个高速缓存行,而程序在任何时候只使用几个成员。在图3.11中,我们已经看到了如果只使用大尺寸结构数据的少数成员的影响。
图6.2展示了使用这个众所周知的程序执行另一组基准测试的结果。这一次同一个链表中的两个元素值相加。一种情况,两个元素在同一缓存行中;另一种情况,一个元素在链表元素的第一行缓存中,第二个元素在最后一个缓存行中。
图6.2 在多个高速缓存行上分布
不出所料,在所有情况下,如果工作集大小在L1d缓存的范围内,则没有消极的影响。一旦L1d缓存不够用,就会在进程中使用两条而不是一条缓存行。红线表示链表数据在内存中顺序排列时的数据。我们通常看到的两个阶梯:L2缓存是足够时有17%的性能损失,必须使用主内存时大概有27%的损失。
在随机内存访问的情况下,数据看起来相对于红线有点不同。满足L2缓存的工作集的性能损失在25%到35%之间。超出L2缓存这个数字就会降至10%左右。这并不是因为损失变得更小,而是因为实际内存访问的开销更大。数据还表明,在某些情况下,元素之间的距离确实很重要。Random 4 CLs曲线展示了更高的损失,因为使用了第1和第4个缓存行。
比较数据结构和缓存行的布局,一种简单的方法是使用pahole程序。这个程序检查在二进制文件中定义的数据结构。考虑一个包含如下定义的程序:
struct foo {
int a;
long fill[7];
int b;
};
当在64位机器上编译时,pahole的输出包含输出信息(以及其他内容)如图6.3所示。输出告诉了我们很多信息。首先,它表明数据结构占用了不止一个缓存行。该工具假设当前使用的处理器的缓存行大小,但是可以使用命令行参数重写该值。特别是当结构的大小几乎超过缓存行的限制,并且分配了许多这种类型的对象时,寻求一种方法来压缩该结构是有意义的。也许一些元素可以有更小的类型,或者一些标志字段实际上是可以使用单个位表示的。
图6.3 pahole工具的输出信息
在这个例子中,压缩很容易实现,程序中也暗示了这一点。输出信息显示在第一个元素之后有一个4字节的间隙(漏洞)。间隙是由结构体和填充元素的字节对齐需要造成的。很容易注意到元素b大小是4个字节,很适合来补这个间隙。这样的话,就没有了间隙,整个数据结构都可以放在一个缓存行里。pahole工具可以执行这个优化。如果使用"--reorganize"参数且结构体名称被添加到命令的尾部,那么工具的输出就是使用缓存行优化后的结构体。除了通过移动元素来填充间隙,工具还可以结合补丁和间隙来优化位域。详见【4】。
当然,理想的情况是有一个刚好足够大的间隙来容纳尾部元素。为了使这种优化有效,需要对象本身与缓存行对齐。我们一会儿就会讲到。
pahole工具的输出可以让我们很容易地查看元素是否需要重新排序,以便那些一起使用的元素也存储在一起。使用pahole工具,可以很容易地确定哪些元素在同一缓存行上,以及何时必须重新排列元素来实现这一目标。这不是一个自动的过程,但是这个工具可以提供很大的帮助。
单个结构元素的位置和它们的使用方式也很重要。正如我们在第3.5.2节中所看到的,在缓存行末尾带有关键字的代码的性能更差。这意味着,程序员应该总是遵循以下的两条规则:
1,始终将最可能是关键字的元素移到结构的开头
2,在访问数据结构时,元素的访问顺序不是由场景决定的,请按照元素在结构中定义的顺序访问。
对于较小的数据结构,这意味着元素应该按照它们可能被访问的顺序排列。必须以灵活的方式来应用其他优化,如填充间隙。对于更大的数据结构,每个缓存行大小的块应该按照规则排列。
但是,如果对象本身没有按预期对齐,那么重新排列元素就不值得花费时间。对象的对齐方式由数据类型的对齐要求决定。每种基本类型都有自己的对齐要求。对于结构化类型,其任何元素的最大对齐需求决定了结构的对齐。这几乎总是比缓存行的小的。这意味着即使结构体的成员对齐到相同的缓存行中,分配的对象也可能没有与缓存行大小相匹配的对齐方式。有两种方法可以确保对象在结构体布局设计时可以使用的对齐方式:
(1) 对象可以通过明确的对齐要求进行分配。对于动态分配,调用malloc只会分配与最苛刻的标准类型(通常是long double)匹配的对象。但是,可以使用posix_memalign请求更高的对齐。
#include <stdlib.h>
int posix_memalign(void **memptr,size_t align,size_t size);
函数返回一个指向新分配内存的指针,保存为指针变量memptr的值。内存块的大小为size字节,并以align字节对齐。对于编译器创建的目标对象(在.data中,.bss等)可以使用一个变量属性:
struct strtype variable
__attribute((aligned(64)));
这个例子的variable按64字节对齐而不考虑strtype结构体的对齐要求。这适用于全局变量和自动变量。
对于数组,此方法可能不像预期那样奏效。只有数组的第一个元素才会对齐,除非每个数组元素的大小是对齐值的倍数。它还意味着必须对每个变量进行适当的注释。posix_memalign的使用也不是完全没有开销的,因为对齐需求通常会导致碎片和/或更高的内存消耗。
(2)用户定义类型的对齐要求可以通过使用type属性更改:
struct strtype {
...members...
} __attribute((aligned(64)));
这样允许编译器以适当的对齐方式分配所有对象,包括数组。但是,程序员必须为动态分配的对象请求合适的对齐边界。这里同样必须使用posix_memalign。使用gcc提供的alignof操作符并将该值作为第二个参数传递给posix_memalign。
本节前面提到的多媒体扩展几乎总是要求内存按对齐方式访问。例如,对16字节对齐的内存进行访问的地址应该是16字节对齐的。x86和x86-64处理器有一些特殊的内存操作变体,可以处理非对齐访问,但速度较慢。对于大多数RISC架构来说,这种硬对齐要求并不是什么新鲜事,因为它们需要对所有内存访问进行完全对齐。即使一个体系结构支持非对齐访问,这有时也比使用适当的对齐要慢,尤其是非对齐访问导致加载或存储使用两个缓存行而不是一个。
图6.4 非对齐访问的开销
图6.4展示了非对齐内存访问的影响。测试例子还是熟知的在(顺序或者随机的)访问内存时对数据元素进行加操作。测试对齐链表元素和刻意不对齐的链表元素。图表展示了非对齐访问带来的程序性能损失比例(相对于对齐访问来说)。顺序访问的效果比随机访问的效果更明显,因为在随机访问的情况下,非对齐访问的成本被通常较高的内存访问成本部分抵消了。在顺序访问的情况下,对于适合L2缓存大小的工作集,性能降速大约是300%。这是因为L1缓存的性能降低了。一些增量操作现在涉及两条高速缓存行,在list元素上操作通常需要读取两条高速缓存行。L1和L2之间的连接太拥挤了。
对于非常大的工作集,非对齐访问的性能下降仍然在20%到30%之间,这样大小工作集的对齐访问时间很长,所以20%到30%的损失影响还是很大的。这张图表表明,必须认真对待对齐问题。即使体系结构支持非对齐访问,也不能认为“它们与对齐访问一样好”。
不过对齐需求也会带来一些负面影响。如果自动变量有对齐要求,编译器必须确保它在所有情况下都满足。这并不简单,因为编译器无法控制调用点及其处理堆栈的方式。这个问题可以用两种方法来处理:
1. 生成的代码自动对齐堆栈,如有必要填充间隙。这需要代码来检查对齐、创建对齐,撤消对齐。
2. 要求所有的调用者进行栈对齐。
所有常用的应用程序二进制接口ABIs遵循第二条规则。如果被调用者需要对齐而调用者违反了规则,那么程序很可能会失败。然而,保持对齐完整并不简单。
函数中使用的栈帧的大小不一定是对齐字节的倍数。这意味着如果从这个栈帧调用其他函数,就需要填充。最大的区别是,在大多数情况下,编译器知道栈帧的大小,因此,它知道如何调整栈指针,以确保从该栈帧调用的任何函数对齐。事实上,大多数编译器只是简单地把栈帧的大小四舍五入,然后就完成了。
如果使用可变长度数组(VLAs)或alloca,则不能使用这种简单的方法来处理对齐。在这种情况下,栈帧的大小只有在运行时才知道。这就可能需要主动的对齐控制,从而使生成代码(稍微)变慢。
在某些架构中,只有多媒体扩展需要严格对齐;对于普通的数据类型,这些架构上的堆栈总是最低限度地对齐,对于32位和64位架构通常分别是4字节或8字节对齐。在这些系统上,强制执行对齐操作会产生不必要的开销。这意味着,在这种情况下,如果我们知道它从不依赖于严格的对齐要求,我们可能想要摆脱它。没有多媒体操作的尾部函数(不调用其他函数的函数)不需要对齐。只调用不需要对齐的函数的函数也不需要对齐。如果可以确定足够大的函数集,则程序可能希望放宽对齐要求。对于x86二进制文件,gcc支持放宽对堆栈对齐的要求:
-mpreferred-stack-boundary=2
如果这个操作设置参数是N,栈需要按2的N次方个字节对齐。因此如果设置为2,栈由默认的16字节对齐变为4字节对齐。这意味着这种情况通常不需要额外的对齐操作,因为正常的堆栈push和pop操作在四字节边界上。这个特定于机器的选项可以帮助减少代码大小,并提高执行速度。但它不能应用于许多其他体系结构。即使对于x86-64,它通常也不适用,因为x86-64 ABI要求在SSE寄存器中传递浮点参数,而SSE指令要求完全16字节对齐。然而,只要这个选项可用,它就会产生明显的差异。
数据结构影响缓存效率,但结构元素的有效放置和对齐并不是其中唯一的因素。如果使用一个结构数组,则整个结构定义都会影响性能。记得图3.11展示的结果吧:在本例中,数组元素中未使用的数据数量不断增加。导致预取的效率越来越低,对于大数据集,程序的效率也越来越低。
对于大型工作集,尽可能使用可用的缓存是很重要的。为此,可能需要重新排列数据结构。虽然程序员更容易将概念上属于同一数据结构的所有数据放在一起,但这可能不是获得最佳性能的最佳方法。假设我们有如下的数据结构:
struct order {
double price;
bool paid;
const char *buyer[5];
long buyer_id;
};
进一步假设这些记录数据被保存在一个大型的数组中,并且频繁得将所有未支付账单的预期付款加起来(只对price成员操作)。在这个场景中,用于buyer和buyer_id字段的内存被不必要地加载到缓存中。从图3.11中的数据判断,程序的执行情况将比实际情况差5倍。
最好将订单数据结构分成两部分,将前两个字段存储在一个结构中,其他字段存储在其他地方。这种更改肯定会增加程序的复杂性,但性能的提高可以证明这种变更是合理的。
最后,让我们考虑另一种缓存优化,虽然它也适用于其他缓存,但主要是在L1d访问中体现。如图3.8所示,增加缓存的关联性有利于正常操作。缓存越大,关联性通常越高。L1d缓存太大,不能完全关联,但相对于L2缓存又不够大,和L2缓存没有相同的关联性。如果工作集中的许多对象都属于相同的缓存集,这可能是个问题。如果过度使用一个集合而被逐出,那么即使大部分缓存都未使用,程序也会经历延迟。这些缓存脱靶有时被称为冲突脱靶。由于L1d缓存寻址使用虚拟地址,这实际上是程序员可以控制的东西。如果一起使用的变量也被存储在一起,那么它们落入同一集合的可能性就会最小化。图6.5显示了问题发生的速度。
图6.5 缓存关联性的影响
图中用熟悉的例子(NPAD=15)进行测试.。x轴表示两个链表元素的间隔,以空链表元素作单位。换句话说,间隔值为2表示下个元素和上一个元素相差128字节。所有元素都以相同的距离分布在虚拟地址空间中。y轴显示链表的总长度。只使用了1到16个元素,这意味着总的工作集大小是64到1024字节。z轴显示遍历每个链表元素所需的平均循环次数。
对图中显示的结果不应该感到惊讶。如果使用的元素很少,所有的数据都在L1d缓存范围内,每个列表元素的访问时间只有3个周期。对于链表元素的所有排列也是如此:虚拟地址被很好地映射到L1d,几乎没有冲突。在这个图中有两个特殊的间隔值的情况是不同的。如果间隔是4096字节的倍数(即64个元素的距离),并且链表的长度大于8,则每个链表元素的平均循环次数会戏剧性的增加。在这些情况下,所有的条目都在同一个集合中,一旦链表长度大于关联性,从L1d刷新的条目,在下一轮必须从L2重新读取。这导致每个列表元素大约需要10个周期。
根据这个图,我们可以确定使用的处理器有一个L1d缓存,其关联性为8,总大小为32 kb。这意味着,如果有必要,可以使用测试来确定这些值。同样的也可以测量L2缓存,但更复杂且更大,因为L2缓存使用物理地址进行索引。
程序员希望这个数据能作为集合关联性是值得关注的指示。在现实世界中,将数据设置在2的幂次边界上的情况经常发生,但这正是容易导致上述影响和性能下降的情况发生。非对齐访问可能会增加冲突脱靶的概率,因为每次访问都可能需要额外的缓存行。
图6.6 AMD上L1d缓存的bank地址
如果执行了此优化,还可以进行另一个相关的优化。AMD的处理器至少将L1d实现为几个独立的bank。L1d每个周期可以接收两个数据字,但这两个字必须存储在不同的bank或具有相同索引的bank。bank地址编码在虚拟地址的低位中,如图6.6所示。如果一起使用的变量也被存储在一起,那么它们存在不同bank或具有相同索引的同一bank的可能性很高。
6.2.2 优化一级指令缓存的访问
好的L1i使用的代码需要和L1d使用相似的技术。问题是程序员通常不直接影响L1i,除非用汇编语言写代码。如果使用编译器,程序员可以通过引导编译器来创建更好的代码布局来间接地影响L1i缓存。
代码有线性跳转的优势。处理器可以在跳转的周期内从内存预取。跳转扰乱了预期的效果,因为:
(1) 跳转目标可能不是静态定义的。
(2)即使是静态的,也有可能所有缓存脱靶导致内存获取需要很长时间。
这些问题在代码执行的时候产生的停滞可能会严重影响性能。这也是为什么如今的处理器那么致力于分支预测技术(BP)。高度定制化的分支预测单元尽可能早的在跳转前预测跳转的目标位置,这样处理器可以开始加载目标位置的指令到缓存。他们结合静态和动态的规则有助于在执行是确定好模式。
相对于指令缓存来说,尽可能快得将数据放入缓存尤为重要。正如3.1节中提到的,指令在执行之前必须被解码,为了提高速度(这在x86和x86-64上很重要),指令实际上是以解码的形式缓存的而不是以从内存中读取的字节/字的形式。
为了有效使用L1i缓存,程序员应该至少关注代码生成的以下方面:
1. 尽可能地减少代码占用。这必须通过循环展开和内联等优化来平衡。
2. 代码的执行应该是线性的,没有“气泡”(气泡形象化地描述了处理器流水线上的执行空闲等待,当执行必须等待资源时出现这些空闲等待。欲了解更多细节,请参阅处理器设计方面的文献)。
3. 对有意义的代码进行对齐。
现在,我们将看看一些编译器技术,从这些方面入手优化程序。
编译器可以选择使能优化的等级。可以单独使能特定的优化选项。许多优化在级别较高的优化选项使能时(gcc的-O2和-O3)处理循环优化和函数内联。一般来说,这些都是很好的优化。如果以这些方式优化的代码占程序总执行时间的很大一部分,则总体性能可以得到提高。尤其是函数内联,它允许编译器一次优化更大的代码块,从而能够生成更好地利用处理器管道架构的机器码。当可以将程序的较大部分视为单个单元时,对代码和数据的处理(清除死代码或值范围传播等)更有效。
较大的代码尺寸意味着对L1i(以及L2和更高级别)缓存的压力更高。这会导致性能下降。代码越小越快。幸运的是,gcc有一个优化选项来实现这一点。编译器通过-Os选项优化代码体积。已知会增加代码体积的优化被禁用。使用这个选项通常会产生令人吃惊的结果。特别是当编译器不能真正利用循环展开和内联的优势时,这个选项具有巨大的优势。
内联也可以单独控制。编译器具有限制内联的选项;可以由程序员控制。-finline-limit 选项限制内联函数的大小,超过这个大小的函数不能内联。如果一个函数在多个地方被调用,那么将其全部内联将导致代码体积的激增。假设函数incand在两个函数f1和f2中被调用。f1和f2顺序执行。
表6.3 内联Vs非内联
表6.3展示了在两个函数中没有内联和内联的情况下生成的代码是什么样子的。如果函数inlcand同时内联在f1和f2中,则生成的代码的总大小为f1 + f2 +2×inlcand大小。如果没有发生内联,则总大小比大小incland小。如果f1和f2短时间相继被调用,需要更多的L1i和L2缓存。PS:如果inlcand没有内联,代码可能仍然在L1i中,不需要再次解码。而且,分支预测单元会在预测跳转方面做得更好,因为可以在缓存中看到代码。如果编译器默认的内联函数大小上限对程序来说不是最好的,那么应该设置更小的值。
不过,在某些情况下,内联总是有意义的。如果一个函数只被调用一次,那么它还不如内联。这使编译器有机会执行更多的优化(比如值范围传播,可能会显著改进代码)。这种内联可能会受到选择限制的阻碍。对于这种情况,GCC有一个选项来指定函数总是内联的。添加always_inline函数属性将指示编译器总是执行内联操作。
同样,如果一个函数即使足够小也不应该内联时,可以使用noinline功能属性实现。使用该功能属性意味着对于在其他地方经常调用的小函数也不能内联。如果L1i缓存内容可以重用,使得额外函数调用的附加成本减小了,并且总占用空间减少了。分支预测单元现在都实现的很好。如果内联可以实现更有效的优化,那么情况就不同了。这必须根据具体情况来决定。
如果总是使用内联代码,那么always_inline属性效果很好。但如果不是这样呢? 如果内联函数只是偶尔被调用:
void fct(void) {
... code block A ...
if (condition)
inlfct()
... code block C ...
}
按这样的代码序列生成的机器码通常与源代码的结构相匹配。这意味着首先是代码块A,然后是条件跳转,如果条件的计算结果为false,则跳转向前。接下来是为内联的inlfct生成的代码,最后是代码块C。这看起来很合理,但有一个问题。
如果条件经常为false,执行不是线性的。中间有一大块未使用的代码,这不仅会因为预取而污染L1i缓存,还会导致分支预测的问题。如果分支预测错误,条件表达式的效率可能会非常低。
这是一个普遍的问题,并不是内联函数所特有的。当使用条件执行时,它是不平衡的(即,表达式导致一个结果的可能性远大于另一个结果),就有可能出现错误的静态分支预测,从而在管道中产生间隔。可以通过告知编译器将不太经常执行的代码移出主代码路径来避免。在这种情况下,为if语句生成的条件分支将跳转到一个不是按原来顺序排列的位置,如下图所示。
上图展示了一个简单的代码布局。如果B代码块是由函数inlfct内联而生成的,由于条件判断I而经常不能被执行而直接绕过,处理器预取将会将很少机会执行到的B代码块也放到缓存行中。代码块重新排列可以改变这个局面,如图的下半部分展示。经常执行的代码在内存中线性排布,而不经常执行的代码被移到不会影响预取和L1i缓存效率的地方。
gcc提供两种实现方式。第一种,编译器在重新编译代码时可以考虑分析输出,并根据配置文件布局代码块。我们将在第7节中看到它如何实现。第二种方法是通过显式分支预测。gcc承认__builtin_expect:
long __builtin_expect(long EXP, long C);
这个构造告诉编译器,表达式EXP最有可能为C值,返回值是EXP。__builtin_expect被用于条件表达式中。大多数情况下,条件表达式基本上都被用于布尔表达式的上下文中,在这种情况下,定义两个helper宏要方便得多:
#define unlikely(expr) __builtin_expect(!!(expr), 0)
#define likely(expr) __builtin_expect(!!(expr), 1)
这些宏定义可以这样使用:
if (likely(a > 1))
如果程序员使用这些宏,然后使用-freorder-blocks优化选项,gcc将重新排序块,如上图所示。该选项在-O2时启用,而在-Os时禁用。还有另一个gcc选项可以重新排序块(-freorder-blocks-andpartition),但它的用处有限,因为它不能用于异常处理。
小循环还有一个很大的优点,至少在某些处理器上是这样。英特尔酷睿2的前端有一个叫做循环流检测器(LSD)的特殊功能。如果一个循环没有超过18指令(没有调用子程序),只需要4条16字节的解码取指令,最多4条分支指令,执行超过64次,循环有时被锁在指令队列,因此循环可以再次使用时更快。例如,这适用于通过外部循环多次进入的内部小循环。即使没有这种专门的硬件,紧凑的循环也有优势。
内联并不是对L1i进行优化的唯一方式。另一种方式是对齐,就像数据一样。这里有明显的区别:代码是一个线性的blob,它不能被任意放置在地址空间中,也不能在编译器生成代码时被程序员直接影响。不过,有一些方面是程序员可以控制的。对齐每一条指令没有任何意义。目标是让指令流是顺序的。因此,位置战略上的调整才有意义。为了确定在哪里添加对齐,有必要了解下位置调整有哪些优势。如果一条指令在缓存行的开始位置,意味着预取的缓存行效率最大化。对于指令来说,这也意味着解码器更有效。很容易看到,如果一个指令在高速缓存行的末端被执行,处理器必须准备读取一个新的高速缓存行并解码指令。有一些事情可能会出错(比如缓存行脱靶),通常来说,这意味着在缓存行末尾的一条指令执行起来不如在缓存行开始位置的那条指令有效。
将这一点与后续推论结合起来,即如果只是将控制转移到有问题的指令(因此预取是无效的),则问题最为严重,我们得出了代码对齐最有用的最终结论:
• 在函数开始时;
• 在基本块的开始,只能通过跳转到达;
• 在某种程度上,在循环的开始处。
在前两种情况下,对齐的代价很小。如果我们选择在缓存行的开始位置,执行的收益在新位置优化预取和解码。编译器通过插入一系列no-op(无操作)指令来完成对齐,以填补对齐代码所造成的空空隙。这种“死代码”占用了一点空间,但通常不会影响性能。
第三种情况略有不同:对齐每个循环的开头可能会产生性能问题。问题是循环的开始通常是在其他代码之后。不幸的情况是前一条指令和对齐的循环开始部分之间会有一个间隙。与前两个情况不同,这种差距不可能完全消失。在执行前一条指令之后,必须执行循环中的第一条指令。这意味着,在前一条指令之后,要么必须有许多无操作指令来填补空白,要么必须无条件跳转到循环的开始。两种可能性都不是免费的。特别是在循环本身不经常执行的情况下,no-ops或跳转所花费的时间比对齐循环节省的时间还多。
程序员可以通过三种方式影响代码的对齐。很显然如果代码是汇编语言写的,函数和所有的指令都可以严格对齐。汇编器为所有的架构提供.align伪指令实现对齐。对于高级语言,必须告诉编译器对齐要求。与数据类型和变量不同,不可能在代码中完成,而是使用编译器选项:
-falign-functions=N
该选项指示编译器将所有函数对齐到大于N的下一个2的幂边界,这意味着将创建一个至多N个字节的间隙。对于小函数使用大的N值是一种浪费。对于很少执行的代码也是一样的。后者在库中经常发生,这些库可以包含流行和不流行的接口。明智地选择N值可以加快速度或通过避免对齐来节省内存。通过使用1作为N的值或使用-fno-align-functions选项来关闭所有对齐。
对于第二种情况的对齐——开始的基本块没有顺序到达——可以用不同的选项控制:
-falign-jumps=N
所有其他细节都是相同的,同样的关于内存浪费的警告也适用。
第三种情况也有自己的选择:
-falign-loops=N
同样的细节和警告也适用于此。不仅如此,如前所述,对齐需要运行时的开销,因为如果按顺序到达对齐的地址,就必须执行no-ops或跳转指令。
GCC还有另一个控制对齐的选项,这里提到它只是为了完整性。-faligne-labels对代码中的每个标签进行对齐(基本上是每个基本块的开始)。除了少数例外情况外,这在所有情况下都会减慢代码速度,因此不提倡使用。
6.2.3 优化二级和更高的缓存访问
前面讨论的关于一级缓存的任何优化同样适用于二级和更高级别的缓存。最高级别的缓存还有两点补充:
(1)缓存脱靶总是非常昂贵的。而L1脱靶通常会在L2和更高的缓存中获得,这样就限制了性能的损失,显然最后一个级别的缓存没有退路。
(2)L2和更高级别的缓存通常由多个核和/或超线程共享。因此,每个执行单元可用的有效缓存大小通常小于总缓存大小。
为了避免缓存脱靶的开销,工作集大小应该与缓存大小匹配。如果数据只被用一次,这显然是不必要的,因为缓存无论如何都是无效的。我们讨论的是数据集被用不止一次的情况。在这种情况下,如果工作集太大而不能放入缓存中,就会产生大量的缓存缺失,即使预取成功,也会减慢程序的速度。
即使数据集太大,程序也必须执行它的工作。程序员的工作就是尽量减少缓存脱靶。对于最后一级缓存,这是可能的——就像L1缓存一样——通过在更小的块中工作。这与第50页的优化矩阵乘法非常相似。不同之处在于,对于最后一层缓存,要处理的数据块可能更大。如果L1也需要优化,那么代码将变得更加复杂。想象一个矩阵乘法,其中数据集(两个输入矩阵和输出矩阵)不适合放在最后一层缓存中。在这种情况下,可能需要同时优化L1和最后一级缓存。
尽管处理器更新换代,L1缓存行的大小通常是不变的;即使有变化,差异也很小。假设更大的尺寸并不是什么大问题。在具有较小缓存大小的处理器上,将使用两条或多条缓存行而不是一条。在任何情况下,硬编码缓存行大小并为此优化代码是合理的。
对于更高级别的缓存,这些缓存的大小可能相差很大。相差八倍以上也并不少见。不可能将更大的缓存作为默认值,因为这意味着代码在所有机器上的性能都很差,除了那些拥有最大缓存的机器。相反的选择也很糟糕:假设最小的缓存意味着丢弃87%或更多的缓存。这很不合理。从图3.14中可以看出,使用大型缓存会对程序的速度产生巨大的影响。
这意味着代码必须根据缓存行大小动态调整自己。这是特定于程序的优化。这里我们只能说,程序员应该正确地计算程序的需求。不仅数据集本身需要,更高级别的缓存还用于其他目的;例如,所有执行的指令都是从缓存加载的。如果使用库函数,则缓存的使用量可能会增加很多。这些库函数可能还需要它们自己的数据,这进一步减少了可用内存。
一旦我们有了内存需求的公式,我们就可以将其与缓存大小进行比较。如前所述,缓存可以与多个其他核心共享。目前,唯一不需要硬编码就能获得正确信息的方法是通过/sys文件系统。在表5.2中,我们看到了内核发布的关于硬件的内容。程序必须找到目录:
/sys/devices/system/cpu/cpu*/cache
对于最后一级缓存。这可以通过该目录中的级别文件中的最高数值来识别。确定目录后,程序应该读取该目录中的size文件的内容,并将数值除以文件shared_cpu_map中的位掩码中设置的位数。
用这种方法计算的值有一个安全的下限。有时候,程序对其他线程或进程的行为了解得更多一些。如果这些线程在共享缓存的核心或超线程上调度,并且已知缓存的使用不会耗尽总缓存大小的一部分,那么计算的限制可能太低而不是最优的。是否应该使用超过公平份额的缓存取决于具体情况。程序员必须做出选择,或者允许用户做出决定。
6.2.4 优化TLB的使用
TLB用法有两种优化。第一个优化是减少程序必须使用的页面数量。这样自然会减少TLB的脱靶。第二个优化是通过减少必须分配的更高级别目录表数量来降低TLB查找的成本。更少的表意味着使用更少的内存,自然而然目录查找具有更高缓存命中率。
第一个优化与最小化页错误密切相关。我们将在第7.5节中详细讨论这个主题。页错误通常是一次性的开销,然而,因为TLB缓存通常很小,且经常刷新,因此TLB失败则是永久的代价。页错误的代价比TLB脱靶代价大,但是,如果一个程序运行的时间足够长,并且程序的某些部分执行得足够频繁,那么TLB脱靶甚至可能超过页错误成本。因此,重要的是不仅要从页错误的角度,而且要从TLB脱靶的角度来考虑页面优化。不同之处在于,页错误优化只需要对代码和数据进行页对齐,而TLB优化在任何时候都需要尽可能少地使用TLB条目。
第二个TLB优化更难控制。必要的页目录数量取决于进程的虚拟地址空间中使用的地址范围的分布。地址空间中不同的位置意味着更多的目录。一个复杂的问题是地址空间布局随机化(ASLR)导致了这些情况。堆栈、DSOs、堆和可能的可执行文件的加载地址在运行时被随机化,以防止机器攻击者猜测函数或变量的地址。
只有性能最大化至关重要时,才应该关闭ASLR。除了少数极端情况外,额外目录的成本非常低,因此大多数情况都无需执行此步骤。内核在任何时候都可以执行的一种可能的优化是确保单个映射不会跨越两个目录之间的地址空间边界。这将以最小的方式限制ASLR,但不足以实质上削弱它。
程序员直接受此影响的唯一方式是显式地请求地址空间区域。当使用mmap和MAP_FIXED时,会发生这种情况。以这种方式分配新的地址空间是非常危险的,而且很少有人这样做。但是,如果使用它,地址可以自由选择,程序员应该知道最后一层页目录的边界,并适当地选择请求的地址。
6.3 预取
预取的目的是为了隐藏内存访问的延时。命令管线和无序执行(OOO)如今的处理器虽然有能力隐藏一部分延时,但最多也仅仅是对命中缓存的访问来说。为了覆盖主内存访问的延迟,命令队列必须非常长。一些没有OOO的处理器试图通过增加内核数量来进行补偿,但这是一个糟糕方法,除非所有正在使用的代码都是并行的。
预取可以进一步帮助隐藏延迟。处理器自己执行预取,由某些事件触发(硬件预取)或由程序显式请求(软件预取)。
6.3.1 硬件预取
CPU触发硬件预取通常是在特定的情况下连续两个或多个缓存脱靶。这些脱靶的缓存可能是在后面或前面的缓存行。在旧的实现中,只能识别对相邻缓存行的缓存脱靶。在现代硬件中,不相邻的也能被识别,这意味着跳过固定数量的缓存行被识别为一种模式并得到适当处理。
如果每次缓存脱靶都会触发硬件预取将会影响性能。随机内存访问模式(例如对全局变量的访问)是非常常见的,因此产生的预取对FSB带宽造成大的浪费。这就是为什么至少需要两次缓存脱靶才启动硬件预取。如今的处理器都希望有一个以上的内存访问流。处理器尝试自动将每个缓存脱靶分配给这样的流,如果达到阈值,就启动硬件预取。今天的cpu可以跟踪8到16个单独的流用于更高级别的缓存。
负责模式识别的单元与各自的缓存相关联。L1d和L1i缓存可以有一个预取单元。很可能有一个L2和更高级别缓存的预取单元。L2和更高的预取单元与使用相同缓存的所有其他核心和超线程共享。因此,8到16个单独的流的数量很快就没有了。
预取有一个很大的缺点:它不能跨越页边界。原因显而易见,cpu支持分页请求。如果允许预取器跨页边界,则访问可能会触发一个使页可用的OS事件。对性
以上是关于《What every programmer should know about memory》-What Programmers Can Do译的主要内容,如果未能解决你的问题,请参考以下文章
《What every programmer should know about memory》-What Programmers Can Do译
《What every programmer should know about memory》-What Programmers Can Do译
《What every programmer should know about memory》-Virtual Memory译
《What every programmer should know about memory》-NUMA Support译
《What every programmer should know about memory》-CPU Caches译
8 Traits of an Experienced Programmer that every beginner programmer should know