Intel64及IA-32架构优化指南——3.6 优化存储器访问
3.6 优化存储器访问
本小节讨论了优化代码和数据存储器访问的准则。最重要的建议是:
● 在可用的执行带宽内执行加载和存储操作。
● 允许投机执行的转运[译者注:forward]过程。
● 允许存储转运处理。
● 数据对齐,注意数据布局和栈对齐。
● 将代码和数据分别放在独立的页。
● 增强数据的局部性
● 使用预取和Cache的控制指令。
● 提升代码的局部性并且分支目标对齐。
● 利用写绑定。
对齐和转运问题在基于Intel NetBurst微架构上的处理器中处于巨大延迟的最通常源头之一。
3.6.1 加载和存储执行带宽
一般来说,加载和存储在一个工作量中是最频繁的操作,一个工作量中达到40%的指令执行加载或存储的目的是很普遍的。每一代微架构提供了多个缓存来支持执行加载和存储操作,当有指令在流动中时。
软件可以通过不超过发布或缓存的机器限制来最大化存储器性能。在Intel Core微架构中,只有20个存储和32个加载可以立即处于流动中。在代号名为Nehalem的Intel微架构中,有32个存储缓存和48个加载缓存。由于每个周期只有一个加载可以发布,因此对两个数组进行操作的算法受制于每隔一个周期的一次操作,除非你使用编程把戏来减少存储器使用量。
Intel Core Duo和Intel Core Solo处理器具有更少的缓存。不过通常的启发也应用到这些处理器中。
3.6.1.1 在代号名为Sandy Bridge的Intel微架构中利用加载带宽
相比于先前微架构具有一个加载端口(端口2),代号名为Sandy Bridge的Intel微架构可以从端口2和端口3加载数据。从而,每个周期可以执行两个加载操作,加倍了代码的加载吞吐。这提升了读许多数据但不需要非常频繁地将结果写到存储器(端口3也处理地址存储操作)的代码。为了利用这带宽,数据必须驻留在L1数据Cache,要么数据应该按顺序访问,以允许硬件预取器及时地将数据带到L1数据Cache中。
考虑以下对一个数组所有元素相加的C代码的例子:
int buff[BUFF_SIZE]; int sum = 0; for(int i = 0; i < BUFF_SIZE; i++){ sum += buff[i]; }
替换1是由Intel编译器为此C代码所生成的汇编代码,使用了用于代号名为Nehalem的Intel微架构的优化标志。编译器使用Intel SSE指令向量化执行。在这个代码中,每次ADD操作使用了先前ADD操作的结果。这限制了每个周期一次加载和ADD操作的吞吐。替换2是用于代号名为Sandy Bridge的Intel微架构的优化,通过允许它来使用额外的加载带宽。这个代码通过使用两个寄存器来对数组值求和来移除ADD操作之间的依赖性。两次加载和两次ADD操作可以在每个周期内被执行。
例3-35:在代号名为sandy Bridge的Intel微架构中优化加载端口带宽
; 寄存器的依赖性抑制了PADD执行 xor eax, eax pxor xmm0, xmm0 lea rsi, buff loop_start: paddd xmm0, [rsi + 4 * rax] paddd xmm0, [rsi + 4 * rax + 16] paddd xmm0, [rsi + 4 * rax + 32] paddd xmm0, [rsi + 4 * rax + 48] paddd xmm0, [rsi + 4 * rax + 64] paddd xmm0, [rsi + 4 * rax + 80] paddd xmm0, [rsi + 4 * rax + 96] paddd xmm0, [rsi + 4 * rax + 112] add eax, 32 cmp eax, BUFF_SIZE jl loop_start sum_partials: movdqa xmm1, xmm0 psrldq xmm1, 8 paddd xmm0, xmm1 movdqa xmm2, xmm0 psrldq xmm2, 4 paddd xmm0, xmm2 movd [sum], xmm0 ; 减少寄存器的依赖性来允许两个加载端口提供PADD执行 xor eax, eax pxor xmm0, xmm0 pxor xmm1, xmm1 lea rsi, buff loop_start: paddd xmm0, [rsi + 4 * rax] paddd xmm1, [rsi + 4 * rax + 16] paddd xmm0, [rsi + 4 * rax + 32] paddd xmm1, [rsi + 4 * rax + 48] paddd xmm0, [rsi + 4 * rax + 64] paddd xmm1, [rsi + 4 * rax + 80] paddd xmm0, [rsi + 4 * rax + 96] paddd xmm1, [rsi + 4 * rax + 112] add eax, 32 cmp eax, BUFF_SIZE jl loop_start sum_partials: paddd xmm0, xmm1 movdqa xmm1, xmm0 psrldq xmm1, 8 paddd xmm0, xmm1 movdqa xmm2, xmm0 psrldq xmm2, 4 paddd xmm0, xmm2 movd [sum], xmm0
3.6.1.2 在代号名为Sandy Bridge的Intel微架构中的L1D Cache延迟
来自L1D Cache的加载延迟可能会变(见表2-8)。最好的情况是4个周期,如果将下列情况应用到对通用目的寄存器的加载操作
● 一个寄存器或
● 一个基地址寄存器加上一个偏移比2048小。
考虑例3-36中的指针追踪代码。
例3-36:在指针追踪代码中的索引与指针的比较
; 通过索引的遍历 ; C代码:index = buffer.m_buffer[index].next_index; ; ASM例子 loop: shl rbx, 6 mov rbx, [rbx + rcx + 0x20] dec rax cmp rax, -1 jne loop ; 通过指针遍历 ; C代码例子:node = node->pNext ; ASM例子 loop: mov rdx, [rdx] dec rax cmp rax, -1 jne loop
上边通过遍历一个索引实现了指针追踪。编译器然后生成了下面所展示的使用基地址+索引+一个偏移的存储器寻址的代码。下边展示了编译器所生成的指针解引用代码并仅使用一个基地址寄存器。
在代号名为Sandy Bridge的Intel微架构以及先前的微架构中下边的代码比上边的更快。
3.6.1.3 处理L1D Cache段冲突
在代号名为Sandy Bridge的Intel微架构中,L1D Cache[译者注:L1数据Cache]的内部组织可以呈现两个加载微操作,其地址具有一个段冲突的情况什么时候会发生。当一个段冲突在两次加载操作之间呈现时,最近的那一个将会被延迟直到冲突被解决。当两个并发的加载操作,其线性地址具有相同的位2~5,但它们并不来自于Cache中的相同组(位6~12).
只要代码受加载带宽束缚,那么段冲突就应该被解决。某些段冲突并不导致任何性能下降,由于它们被其它性能限制的问题所隐藏。消除这样的段冲突并不提升性能。
下列例子描述了段冲突以及如何修改代码来避免它们。它使用了大小为Cache行大小倍数的两个源数组。当从A加载一个元素和B的一个相对应的元素时,这些元素在它们的Cache行中具有相同的偏移并因而可能会发生段冲突。
例3-37:L1D Cache中的段冲突以及补救
// C代码 int A[128]; int B[128]; int C[128]; for(i = 0; i < 128; i += 4){ C[i] = A[i] + B[i]; // 从A[i]和B[i]加载冲突 C[i + 1] = A[i + 1] + B[i + 1]; C[i + 2] = A[i + 2] + B[i + 2]; C[i + 3] = A[i + 3] + B[i + 3]; }
; 有段冲突的代码 xor rcx, rcx lea r11, A lea r12, B lea r13, C loop: lea esi, [rcx * 4] movsxd rsi, esi mov edi, [r11 + rsi * 4] add edi, [r12 + rsi * 4] mov r8, [r11 + rsi * 4 + 4] add r8, [r12 + rbi * 4 + 4] mov r9, [r11 + rsi * 4 + 8] add r9, [r12 + rsi * 4 + 8] mov r10, [r11 + rsi * 4 + 12] add r10, [r12 + rsi * 4 + 12] mov [r13 + rsi * 4], edi inc ecx mov [r13 + rsi * 4 + 4], r8 mov [r13 + rsi * 4 + 8], r9 mov [r13 + rsi * 4 + 12], r10 cmp ecx, LEN jb loop ; 不带段冲突的代码 xor rcx, rcx lea r11, A lea r12, B lea r13, C loop: lea esi, [rcx * 4] movsxd rsi, esi mov edi, [r11 + rsi * 4] mov r8, [r11 + rsi * 4 + 4] add edi, [r12 + rsi * 4] add r8, [r12 + rsi * 4 + 4] mov r9, [r11 + rsi * 4 + 8] mov r10, [r11 + rsi * 4 + 12] add r9, [r12 + rsi * 4 + 8] add r10, [r12 + rsi * 4 + 12] inc ecx mov [r13 + rsi * 4], edi mov [r13 + rsi * 4 + 4], r8 mov [r13 + rsi * 4 + 8], r9 mov [r13 + rsi * 4 + 12], r10 cmp ecx, LEN jb loop
3.6.2 最小化寄存器满溢
当一个代码片段所具有的活动变量超过处理器能保持的通用目的寄存器的个数,那么一个普遍的方法是将这些活动变量的一部分保持到存储器中。这个方法被称为寄存器满溢。L1D Cache延迟的效果会对这代码的性能产生消极影响。如果寄存器满溢的地址使用了更慢的寻址模式,那么这种负面影响会更明显。
一种可选用的方法是将通用目的寄存器溢到XMM寄存器中。这个方法即便在先前的处理器中也可能有性能提升。下面的例子展示了如何将一个寄存器溢到一个XMM寄存器中而不是到存储器。
例3-38:使用XMM寄存器来代替存储器,用于寄存器满溢
; 寄存器溢到存储器 loop: mov rdx, [rsp + 0x18] movdqa xmm0, [rdx] movdqa xmm1, [rsp + 0x20] pcmpeqd xmm1, xmm0 pmovmskb eax, xmm1 test eax, eax jne end_loop movzx rcx, [rbx + 0x60] add qword ptr[rsp + 0x18], 0x10 add rdi, 0x4 movzx rdx, di sub rcx, 0x4 add rsi, 0x1d0 cmp rdx, rcx jle loop ; 寄存器溢到XMM movq xmm4, [rsp + 0x18] mov rcx, 0x10 movq xmm5, rcx loop: movq rdx, xmm4 movdqa xmm0, [rdx] movdqa xmm1, [rsp + 0x20] pcmpeqd xmm1, xmm0 pmovmskb eax, xmm1 test eax, eax jne end_loop movzx rc, [rbx + 0x60] padd xmm4, xmm5 add rdi, 0x4 movzx rdx, di sub rcx, 0x4 add rsi, 0x1d0 cmp rdx, rcx jle loop
3.6.3 增强投机执行与存储器非歧义性
在Intel Core微架构之前,当代码同时含有存储和加载时,在存储的地址被解决之前加载不能被发布。这个规则确保了加载依赖于先前存储的正确处理。
Intel Core微架构含有这么一个机制,它允许某些加载投机地更早地被发布。处理器稍后检查加载地址与一个存储是否重叠。如果地址确实重叠,那么处理器重新执行这两条指令。
例3-39描述了这么一个情景,编译器无法确定“Ptr->Array”在循环期间没有改变。从而,编译器不能将“Ptr->Array”保持在一个寄存器中作为不变量,而是必须在每次迭代时不断地读取它。尽管这个情景可以通过重写代码,要求指针的地址是不变量以采用软件来修正,不过存储器非歧义性可以不必重写代码来提供性能增益。
例3-39:未知地址的加载受存储阻塞
// C代码 struct AA { AA **array; }; void nullify_array(AA *Ptr, DWORD Index, AA *ThisPtr) { while(Ptr->Array[--index] != ThisPtr) { Ptr->Array[Index] = NULL; } }
; 汇编代码 nullify_loop: mov dword ptr [eax], 0 mov edx, dword ptr [edi] sub ecx, 4 cmp dword ptr [ecx + edx], esi lea eax, [ecx + edx] jne nullify_loop
3.6.4 对齐
数据的对齐涉及到所有类型的变量:
● 动态分配的变量
● 一个数据结构的成员
● 全局或局部变量
● 在栈上的形参传递
非对齐的数据访问会遭到严重的性能处罚。这对于Cache行断裂尤其如此。Cache行的大小在奔腾4以及其它最近的Intel处理器中是64字节,包括基于Intel Core微架构的处理器。
对64字节非对齐的一次数据访问会导致两次存储器访问并需要几个微操作来执行(而不是一个)。横跨64字节边界的访问可能会遭到一个很大的性能处罚,每个延迟的成本一般在具有更长流水线的机器上会更大。
双精度浮点操作数是8字节对齐的,比起非8字节对齐的操作数具有更佳性能,由于它们不太会受Cache以及MOB断裂的处罚。对一个存储器操作数的浮点操作要求操作数从存储器加载。这会引起一个额外的微操作,对前端带宽会有较少的负面影响。此外,存储器操作数可能会导致一次数据Cache失败而引起处罚。
汇编/编译器编码规则46:以自然操作数大小地址边界对齐数据。如果数据将以向量指令加载和存储被访问,那么在16字节边界对齐数据。
为了获得最佳性能,根据下列规则对齐数据:
● 在任一地址处对齐8位数据
● 在含有一个对齐的4字节字范围内对齐16位数据
● 对齐32位数据,使得其基地址是四的倍数
● 对齐64位数据,使得其基地址是八的倍数
● 对齐80位数据,使得其基地址是十六的倍数
● 对齐128位数据,使得其基地址是十六的倍数
应该对齐一个64字节或更大的数据结构或数组,使得其基地址是64的倍数。以根据大小降序排序的数据对于帮助自然对齐是一个启发。只要16字节的边界(以及Cache行)不被跨越,自然对齐并不是严格所必要的(尽管强制执行这点是一个容易的方法)。
例3-40展示了会导致一个Cache行断裂的代码类型。该代码加载两个DWORD数组的地址。029E70FEH并不是4字节对齐的地址,这样在这个地址处的4字节访问将从这个地址所包含的Cache行获得2个字节,然后从029E700H起始处的Cache行获得2个字节。在具有64字节Cache行的处理器上,一个类似的Cache行断裂将会以每8次迭代发生。
例3-40:引起Cache行断裂的代码
mov esi, 029e70feh mov edi, 05be5260h Blockmove: mov eax, DWOR PTR[esi] mov ebx, DWORD PTR[esi + 4] mov DWORD PTR[edi], eax mov DWORD PTR[edi+4], ebx add esi, 8 add edi, 8 sub edx, 1 jnz Blockmove
图3-2展示了访问一条跨越Cache行边界的数据元素的情景
在基于Intel NetBurst微架构处理器上代码对齐不是太重要。对分支目标的对齐以最大化取被cache的指令仅当不执行踪迹Cache外的指令时才会有效果。
代码的对齐对于奔腾M,Intel Core Duo以及Intel Core 2 Duo处理器来说会是一个问题。分支目标的对齐将提升解码器的吞吐。
3.6.5 存储转运
处理器的存储系统在存储隐退后只将存储发送到存储器(包括Cache)。然而,数据存储可以从一个Cache被转运到一个后续的对同一个地址的加载以产生更短的存储-加载延迟。
对于存储转运有两种要求。如果违背了这些要求,那么存储转运将不会发生并且加载必须从Cache获得其数据(从而存储必须将其数据先写回到Cache)。这会遭受与底层微架构的流水线深度密切相关的处罚。
第一个要求是要适合存储转运数据的大小和对齐。这个约束很可能对整个应用程序的性能具有很大影响。一般来说,由于违背此约束的性能处罚可以被防止。存储到加载的转运约束会根据微架构的不同而有所不同。在3.6.5.1小节中详细讨论了引起存储转运拖延的编码缺陷的几个例子以及对这些缺陷的解决。第二个要求是在3.6.5.2小节中所讨论的数据的可用性。一个好的实现是要消除冗余的加载操作。
在一个寄存器中保存一个临时标量变量并一直不把它写到存储器是有可能的。通常而言,这样的变量不能使用间接指针去访问。将一个变量搬移到一个寄存器消除了所有对那个变量的加载和存储并且也消除了与存储转运相关联的潜在问题。然而,这也增加了寄存器压力。
加载指令倾向于开启计算链[译者注:比如一般的计算模型都是“输入”->“处理”->“输出”]。由于无序引擎基于数据依赖,所以加载指令在引擎能以高效率来执行的能力中扮演了一个非常重要的角色。应该给予消除加载一个高优先级。
如果一个变量在它被存储时以及在它被再次使用之间没有改变,那么那个被存储的寄存器可以直接被拷贝或使用。如果寄存器压力太大,或者在此存储和第二个加载之前调用一个不可见的函数,那么不可能消除第二次加载。
汇编/编译器编码规则47:只要可能,在寄存器中传递形参而不是在栈上。在栈上传递实参需要在一次加载之后跟一次重新加载。此序列会以硬件来优化,通过将这个值直接从存储器次序缓存[译者注:MOB]提供给加载,而不需要访问数据Cache,如果没有违背存储转运约束;而浮点值在转运中会受到一个比较严重的延迟。在(最好是XMM)寄存器中传递浮点实参应该能够节省这个长的延迟操作。
形参传递协定会限制选择哪些形参在寄存器中传递,哪些在栈上传递。然而,这些限制可以被克服,如果编译器具有对整个二进制的编译进行控制(使用整个程序的优化)。
3.6.5.1 对大小和对齐的存储加载转运约束
用于存储转运的数据大小和对齐约束应用于基于Intel NetBurst微架构、Intel Core微架构、Intel Core 2 Duo微架构、Intel Core Solo以及奔腾 M的处理器。对于违背存储转运约束的性能处罚对于比起Intel NetBurst微架构来具有更短流水线的机器而言,会更少。
存储转运限制根据每种微架构都会有所不同。Intel NetBurst微架构对代码生成上加入了比起Intel Core微架构更多的限制,以允许存储转运能让流水线顺畅运行而不会遭受拖延。为Intel NetBurst微架构修正存储转运问题通常也避免了在奔腾M、Intel Core Duo以及Intel Core 2 Duo处理器上的问题。在基于Intel NetBurst微架构上对存储转运的大小和对齐的约束在图3-3中展示。
下列规则能帮助满足存储转运的大小和对齐限制:
汇编/编译器编码规则48:从一次存储转运的一次加载必须具有相同的起始地址点并从而与存储数据具有相同的对齐。
汇编/编译器编码规则49:对从一次存储所转运过来的加载的数据必须完整地包含在存储数据内。
从一次存储所转运的一次加载在处理之前必须等待存储数据写入到存储缓存,但对于不相关的加载不需要等待。
汇编/编译器编码规则50:如果需要萃取已存储数据的一个非对齐部分,那么读出完全包含那个数据的最小对齐部分并对数据进行必要性的移位/掩膜。这个比遭致存储转运失败的处罚要更好些。
汇编/编译器编码规则51:通过使用一单次大量的读以及所需要的寄存器拷贝来避免在对某个存储区域的大量存储之后对同一存储区域的若干次少量的读。
例3-41描绘了几种存储转运的情况,在这些情况中在大数据存储之后紧跟着小数据加载。前三个加载操作展示了规则51中所描述的情景。然而,最后的加载操作从存储转运获得数据而不会有问题。
例3-41:展示大数据存储之后加载小数据的情景
mov [EBP], 'abcd' mov AL, [EBP] ; 不被阻塞——相同对齐 mov BL, [EBP + 1] ; 被阻塞 mov CL, [EBP + 2] ; 被阻塞 mov DL, [EBP + 3] ; 被阻塞 mov AL, [EBP] ; 不被阻塞——相同对齐。注意,传递更早被阻塞的加载
例3-42描述了一个存储转运场景,在这个场景中一个大数据加载后跟着几个小数据加载。加载操作所需要的数据不能被转运,因为所有需要被转运的数据不包含在存储缓存(Store buffer)中。在对同一个存储区域的小数据存储之后避免大数据加载。
例3-42:在小数据存储之后的大数据加载的非转运例子
mov [EBP], 'a' mov [EBP + 1], 'b' mov [EBP + 2], 'c' mov [EBP + 3], 'd' mov EAX, [EBP] ; 被阻塞 ; 头4个小存储可以被合并在一单个DWORD存储中来防止这种非转运情景
例3-43描述了一个被拖延的存储转运情景,这可能会出现在编译器生成的代码中。有时,一个编译器生成如例3-43中所展示的类似的代码来处理对栈的一个满溢字节并将字节转换为一个整型值。
例3-43:
mov DWORD PTR [esp + 10h], 00000000h mov BYTE PTR [esp + 10h], bl mov eax, DWORD PTR [esp + 10h] ; 拖延 and eax, 0xff ; 转换回字节值
例3-44提供了两个可替代的方法来避免例3-43中所展示的非转运情景。
; A. 当满溢被忽略时,使用MOVZ指令来避免在小数据存储之后的大数据加载 movz eax, bl ; 代替最后三条指令 ; B. 使用MOVZ指令并处理满溢到栈 mov DWORD PTR [esp + 10h], 00000000h mov BYTE PTR [esp + 10h], bl movz eax, BYTE PTR [esp + 10h] ; 不被阻塞
当在存储器位置之间搬移少于64位的数据时,64位或128位SIMD寄存器搬移会更高效(如果对齐的话)并且可以用来避免不对齐的加载。尽管浮点寄存器允许一次搬移64位的搬移,但浮点指令不应该为这种目的而被使用,由于数据可能会不经意地被修改。
作为一个额外的例子,考虑例3-45中的情况。
例3-45:大数据和小数据加载拖延
; A.一个大数据加载的拖延 mov mem, eax ; 将双字存储到地址“MEM” mov mem + 4, ebx ; 将双字存储到“MEM + 4” fld mem ; 在地址“MEM”处加载四字,拖延 ; B.小数据加载拖延 fstp mem ; 将四字存储到地址“MEM” mov bx, mem+2 ; 在地址“MEM+2”处加载字,拖延 mov cx, mem+4 ; 在地址“MEM+4”处加载字,拖延
在第一种情况(A)下,在一系列对同一存储区域(在存储地址MEM的起始处)的小数据加载之后有一次大数据加载。大数据加载将会拖延。
FLD必须等到存储在它能访问所有它所需要的数据之前写入到存储器。该延迟也会与其它数据类型发生(例如,当字节或字被存储,然后字或双字从同一存储区域读出时)。
在第二种情况(B)下,在对同一存储区域(在存储地址MEM起始处)的一次大数据存储之后的一系列小数据加载。小数据加载将会拖延。
字加载必须等到四字存储在它们可以访问它们所需要的数据之前写入到存储器。这个延迟也会与其它数据类型发生(比如,当双字或字被存储然后字或字节从同一存储区域读出时)。这可以通过将存储尽可能地挪到远离加载的地方来避免。
对于基于Intel Core微架构的存储转运限制在表3-2中列出。
3.6.5.2 对数据可用性上的存储转运限制
要被存储的值必须在加载操作可以被完成之前可用。如果此限制被违背,那么加载的执行将被延迟到数据可用为止。这个延迟导致某些执行资源被不必要地使用,并且会有相当大的但又不确定的延迟。然而,这个问题的整个影响比起违反所需要的大小和对齐来说要小很多。
在基于Intel NetBurst微架构上的处理器中,硬件预测加载什么时候依赖于并从先前的存储中获取它们的所转运的数据。这些预测能很大地提升性能。然而,如果一次加载在它所依赖的存储之后很快被调度,或者如果延迟生成要被存储的数据,那么会有一个比较大的处罚。
有若干种情况数据通过存储器来传递,而且存储可能需要与加载分开:
● 满溢,在一个栈帧中保存及恢复寄存器
● 形参传递
● 全局的以及易挥发的变量
● 在整数与浮点数之间的转换
● 当编译器不分析被内联的代码时,强制涉及带有内联代码的接口的变量放入存储器中,创建更多存储器变量并阻止了对冗余加载的消除。
汇编/编译器编码规则52:对将变量放置在寄存器的分配划分优先级,就好比在寄存器分配以及为形参传递中那样,以最小化存储转运问题的可能性和影响。在不会遭受其它处罚的情况下尽量这么做。尽量不要存储转运由一个很长延迟的指令所生成的数据——比如,MUL或DIV。避免存储转运具有最短存储-加载距离的变量数据。避免存储转运具有许多和/或具有长依赖链的变量数据,而且尤其包含在一个循环携带的依赖链的一个存储转运。
下面展示了一个循环携带依赖链的例子。
例3-46:循环携带依赖链
for(i=0; i<MAX; i++){ a[i] = b[i] * foo; foo = a[i] / 3; } // foo是一个循环携带的依赖
汇编/编译器编码规则53:尽可能早地计算存储地址来避免存储阻塞加载。
3.6.6 数据布局优化
用户/源代码编写规则6:填充定义在源代码中的数据结构,使得每个数据元素对齐到一个自然操作数大小的地址边界。
如果操作数打包为一条SIMD指令,那么对齐到打包的元素大小(64位或128位)。
通过提供在结构体以及数组内的填充来对齐数据。程序员可以重新组织结构以及数组以最小化通过填充造成的存储空间浪费。然而,编译器可能没有这种自由。比如C编程语言,指定了结构体元素在存储空间中的分配次序。更多信息,见4.4小节。
例3-47展示了一个数据结构可能如何被重新安排以减少其尺寸。
例3-47:重新安排一个数据结构
struct unpacked{ /* 由于字节填充会占用20个字节*/ int a; char b; int c; char d; int e; }; struct packed{ /* 占用16字节 int a; int c; int e; char b; char d; };
64字节的Cache行大小可能影响流应用(例如,多媒体)。这些对数据的引用和使用在废弃它之前仅仅一次。在一个Cache行内稀少地使用数据的数据访问会导致系统存储带宽的低效利用。例如,结构体的数组可以被分解为若干个数组来达成更好的打包,正如例3-48中所展示的。
例3-48:分解一个数组
struct{ /* 1600个字节 */ int a, c, e; char b, d; }array_of_struct[100]; struct{ /* 1400个字节 */ int a[100], c[100], e[100]; char b[100], d[100]; }struct_of_array; struct{ /* 1200个字节 */ int a, c, e; }hybrid_struct_of_array_ace[100]; struct{ /* 200个字节 */ char b, d; }hybrid_struct_of_array_bd[100];
这种优化的效率依赖于使用模式。如果结构体的元素都被一起访问,但数组的访问模式是随机的,那么ARRAY_OF_STRUCT避免了不必要的预取,即使它浪费了存储空间。
然而,如果数组的访问模式展示出了位置(比如,如果正在扫过数组索引),那么具有硬件预取的处理器将从STRUCT_OF_ARRAY预取数据,即使结构体的元素被一起访问。
当结构体的元素不以相同的频率被访问,诸如当元素A被访问十次后再访问其它条目,那么STRUCT_OF_ARRAY不仅节省了存储空间,而且它也防止了不必要的数据项B,C,D和E的预取。
使用STRUCT_OF_ARRAY也允许程序员和编译器使用SIMD数据类型。
注意,STRUCT_OF_ARRAY可能会有需要更多独立存储流引用的劣势。这可能需要使用更多的预取以及额外的地址生成计算。它对DRAM页访问效率也会有影响。一种替换方式是,HYBRID_STRUCT_OF_ARRAY混合两种方式。在这种情况下只生成并引用2个独立的地址流:1个给HYBRID_STRUCT_OF_ARRAY_ACE,1个给HYBRID_STRUCT_OF_ARRAY_BD。第二种替换方式也防止了预取不必要的数据——假定(1)变量A,C和E总是一起使用,并且(2)变量B和D总是一起使用,但不与A,C,E同时使用。
混杂方式确保了:
● 比起STRUCT_OF_ARRAY更简单的/更少的地址生成
● 更少的流,以减少DRAM页缺失
● 由于更少的流会有更少的预取
● 高效的Cache行对并发使用的数据元素的打包
汇编/编译器编码规则54:设法安排数据结构使得它们允许顺序访问。
如果数据被安排到一组流中,那么自动的硬件预取器会去预取应用程序所需要的数据,从而减少有效的存储延迟。如果数据以一种非顺序方式被访问,那么自动硬件预取器将无法预取数据。预取器能组织多达八条并发流。见第7小节获得关于硬件预取器的更多信息。
在Intel Core 2 Duo、Intel Core Duo、Intel Core Solo、奔腾4、Intel Xeon以及奔腾M处理器中,存储器一致性维持在64字节的Cache行上(而不是早期处理器上的32字节Cache行)。这会增加错误共享的几率。
用户/源代码编写规则7:在基于Intel NetBurst微架构的处理器上,在一个Cache行(64字节)内以及在一个128字节的扇区内堤防错误共享。
3.6.7 栈对齐
避免栈对齐问题的最简单的方法是一直保持栈对齐。比如,支持8位、16位、32位以及64位的数据量的一种语言,但从不使用80位数据量,可以要求栈总是在一个64位边界对齐。
汇编/编译器编码规则55:如果64位数据作为一个形参或在栈上分配来被传递,那么确保栈是在8字节边界对齐的。
做到这个需要使用一个通用目的寄存器(诸如EBP)作为一个帧指针。这其中的权衡是在引起非对齐的64位引用(如果栈没有对齐)与引起额外通用目的寄存器满溢(如果栈对齐)之间。注意,仅当一个非对齐的访问满溢到一个Cache行时才会引起性能处罚。这意味着八个空间连续的非对齐访问总有一次会受到处罚。
频繁使用64位数据的一个例程可以通过将代码放置在例3-49中所描述的函数中的头尾来避免栈不对齐。
例3-49:动态栈对齐
prologue: subl esp, 4 ; 保存帧指针 movl [esp], ebp movl ebp, esp andl ebp, 0xfffffffc ; 新帧指针 movl [ebp], esp ; 保存老栈指针 subl esp, FRAMESIZE ; 分配空间 ; ⋯⋯被调者保存等等 epilogue: ; ⋯⋯被调者回复等等 movl esp, [ebp] ; 恢复栈指针 movl ebp, [esp] ; 恢复帧指针 addl esp, 4 ret
如果出于某些原因无法以64字节对齐栈,那么该例程应该访问形参并将它保存到一个寄存器或已知的对齐存储空间中,从而只遭受一次处罚。
3.6.8 Cache中的性能限制与别名化
存在一些情况下,具有一个给定跨度的地址会与存储层级中的某些资源产生竞争。
一般来说,Cache实现具有多路组相联,每一路由多组Cache行(在某些情况下是扇区)组成。与一个Cache中每一路的同一组相竞争的多个存储器引用会导致一个性能问题。有应用于特定架构的别名条件。注意,第一级Cache行是64个字节。从而最低6位有效位在别名比较时不被考虑。对于基于Intel NetBurst微架构的的处理器,数据被加载到一个128字节扇区的第二级Cache中,因此最低7位有效位在别名比较时不被考虑。
3.6.8.1 在组相联的Cache中的性能限制
如果在一个给定Cache的每一路中被映射到同一组的显著的存储器引用的个数超过了Cache的路数,那么会引发性能限制。应用于第一级数据Cache和第二级Cache的条件由以下列出:
● L1组冲突——多个引用映射到同一个第一级Cache组。冲突条件是由Cache的字节大小所确定的一个跨度,这个跨度被路数所划分。这些相互竞争的存储器引用会导致过度的Cache失败,仅当未解决的存储器引用的个数超过了工作组中的路数:
——在具有家族编码15,模型编码为0,1或2的一个CPUID签名的奔腾4和Intel Xeon处理器上;对于超过4个同时竞争的模2K字节地址的存储器引用,会有超过第一级Cache的失败。
——在具有家族编码15,模型编码为3的一个CPUID签名的奔腾4和Intel Xeon处理器上;对于超过8个同时竞争的模2K字节地址的存储器引用,会有超过第一级Cache的失败。
——在Intel Core 2 Duo,Intel Core Duo,Intel Core Solo以及奔腾M处理器上,对于超过8个同时竞争的模4K字节地址的存储器引用,会有超过第一级Cache失败。
● L2组冲突——多个引用映射到同一个二级Cache组。冲突条件也由Cache的大小或路数决定:
——在奔腾4和Intel Xeon处理器上,对于超过8个同时竞争的引用会有一个超过二级Cache的失败。会引发性能问题的跨度大小为32K字节、64K字节或128K字节,依赖于第二级Cache的大小。
——在奔腾M处理器上,会导致性能问题的跨度大小为128K字节或256K字节,依赖于第二级Cache的大小。在Intel Core 2 Duo、Intel Core Duo、Intel Core Solo处理器上,会引发性能问题的跨度大小为256K字节,如果同时访问的个数超过了L2 Cache的路相连的个数。
3.6.8.2 在奔腾M、Intel Core Solo、Intel Core Duo以及Intel Core 2 Duo处理器的别名情况
奔腾M、Intel Core Solo、Intel Core Duo以及Intel Core 2 Duo处理器具有以下别名情况:
● 存储转运——如果对一个地址的存储后面跟着从同一个地址的加载,那么这个加载将不会进行,直到存储数据可用。如果一个存储后面跟着一个加载并且它俩的地址相差4K字节的倍数,那么加载拖延,直到存储操作完成。
汇编/编译器编码规则56:避免让一个存储后面跟着一个相差4K字节倍数的地址的非依赖的加载。同时,布局数据或次序计算来避免让分离的64K字节的倍数的线性地址的Cache行在同一个工作组中。避免让超过4个分离的2K字节的某个倍数的Cache行在同一个第一级Cache工作组中,并避免让超过8个分离的4K字节的某个倍数的Cache行在同一个第一级的Cache工作组中。
当声明多个引用同一索引的数组,并且每个是一个64K字节倍数的(可能以STRUCT_OF_ARRAY数据布局发生),对它们进行填充以避免连续地声明它们。字节填充可以通过干涉其它变量的声明或是人工增加维度来实现。
用户/源代码编码规则8:考虑使用一个特殊的带有地址偏移性能的库来避免别名化。
实现一个存储器分配器来避免别名化的一个方式为分配比足够的空间更多的空间进行填充。比如,分配68KB的结构体而不是64KB来避免64K字节的别名化,或者让分配器填充并返回作为128字节倍数的随机的偏移(一个Cache行的大小)。
用户/源代码编码规则9:当填充变量声明来避免别名化时,最大的利益来自在第二级Cache行处避免别名化,建议128字节的偏移或更多。
当代码访问两个不同存储器位置,并且两个存储器位置之间相差4K字节,那么4K字节存储器别名发生。4K字节别名化情景会在一个存储器拷贝例程中出现,源缓存的地址与目的缓存的地址有一个常量偏移,并且常量偏移正巧是从一次迭代到下一个的递增字节的倍数。
例3-50展示了在一个循环中的每次迭代的对存储器16字节拷贝的例程。如果在源缓存(EAX)和目的缓存(EDX)之间的偏移(模4096)相差16、32、48、64、80字节;加载不得不等待直到存储在它们能继续之前隐退。例如在偏移16字节处,下一次迭代的加载是4K字节别名化的当前迭代存储,因而循环必须等待直到存储完成,使得整个循环被串行化。所需等待时间的量以更大偏移递减,直到96字节的偏移解决了这个问题(由于到加载带有相同地址的时刻没有未决的存储)。
Intel Core微架构提供了一个性能监测事件,允许软件调整来探测别名化条件的发生。
例3-50:跨循环迭代的加载和存储之间的别名化
LP: movaps xmm0, [eax+eax] movaps [edx + ecx], xmm0 add ecx, 16 jnz lp
3.6.9 混合代码与数据
Intel处理器的攻击性的预取和预译码具有两个相关效果:
● 根据Intel架构的处理器要求,自修改代码能正确地工作,但会遭到一个严重的性能处罚。尽可能避免自修改代码。
● 将可写数据放在代码段中可能无法区分自修改代码。代码段中的可写数据可能会遭到与自修改代码相同的性能处罚。
汇编/编译器编码规则57:如果(希望是只读的)数据必须在与代码同一个页上发生,那么避免将它立即放在一个间接跳转的后面。例如,跟在一个与其最可能的目标在一起的间接跳转后面,将数据放在一个无条件分支的后面。
调整建议1:在很少情况下一个性能问题会因为将一个代码页上的执行数据作为指令而引发。当执行跟在一个间接分支之后时,该间接分支没驻留在踪迹Cache中,这很可能就会发生。如果这很清楚会导致一个性能问题,那么设法将数据搬移到其它地方,或是在该间接分支之后立即插入一条非法的操作码或是PAUSE指令。注意,后两种方法在某些情况下会降低性能。
汇编/编译器编码规则58:总是将代码和数据放到独立的页。尽可能避免自修改代码。如果代码被修改,设法将它一次性做完并确保执行修改的代码与正被修改的代码在独立的4K字节页上或是在独立的对齐的1K字节子页上。
3.6.9.1 自修改代码
在奔腾III处理器以及更早的实现上正确运行的自修改代码(SMC)在后续处理器的实现中运行正确。SMC和跨修改代码(当在一个多处理器系统中的多个处理器正在写一个代码页时)应该被避免,当需要高性能时。
软件应该避免在正在执行的同一1K字节的子页写一个代码页或在正被写入的同一2K字节的子页取代码。此外,与另一个处理器共享直接包含的一个页或投机执行的代码作为一个代码页,会触发一个引起机器的整个流水线以及踪迹Cache被清除的SMC条件。这是由于自修改代码条件。
动态代码不需要引起SMC条件,如果所写入的代码在一个数据页作为代码被访问之前填充完了这个页。动态修改的代码(比如,从目标修改)可能会遭受SMC条件并应该尽可能地去避免。通过引入间接分支以及使用数据页上的数据表(而不是代码页),使用寄存器间接调用来避免条件。
3.6.9.2 位置独立的代码
位置独立的代码经常需要获得指令指针的值。例3-51a展示了通过发布一条CALL而不需要匹配一个RET将IP的值放入ECX寄存器的技术。例3-51b展示了一个可替换的技术通过使用一个配对的CALL/RET对将IP的值放入到ECX中。
例3-51:指令指针查询技术
; a)使用调用而没有返回来获得IP,不破坏RSB call _label ;返回被压栈的地址为下一条指令的IP _label: pop ECX ;这条指令的IP现在被放入ECX ; b)使用配对的call/ret对 call _lblcx ... ; ECX现在包含了这条指令的IP ... _lblcx: mov ecx, [esp] ret
3.6.10 写绑定
写绑定(WC)以两种方式提升了性能:
● 对于对第一级Cache的写失败,它允许在为了Cache行从更外层的Cache/存储器层级处的所有权而被读(RFO)之前对同一Cache行进行多个存储。然后该行的剩余部分被读,并且没有被写入的字节与所返回的行的未被修改的字节绑定。
● 写绑定允许多个写被汇编并进一步在Cache层级中作为一个单元被进一步写出。这节省了端口以及总线交通。节省交通对于避免对未被Cache的存储器的部分写尤其重要。
(在奔腾4和带有家族编码为15,模型编码为3的Intel Xeon处理器上)有六个写绑定缓存;这些缓存的其中两个可以被写出到更高层的Cache级并完全释放用于对其它写失败。只有四个写绑定缓存确保可同时使用。写绑定应用于存储器类型WC;它并不应用于存储器类型UC。
在Intel Core Duo以及Intel Core Solo处理器中的每个处理器核心中有六个写绑定缓存。在基于Intel Core微架构的处理器中每个核具有八个写绑定缓存。
汇编/编译器编码规则59:如果一个内部循环写超过四个数组(四个不同的Cache行),那么应用循环分裂来打破循环体,使得只有四个数组在每个结果循环的每次迭代中被写。
写绑定缓存被用于存储所有存储器类型。它们对于未被cache的存储器的写尤其重要:对同一Cache行的不同部分的写可以被组成为一单个的、整个Cache行的总线事务,而不是作为若干次部分写跨过总线(由于它们不被cache)。避免部分写会对总线带宽的所绑定的图形应用会有非常明显的影响,由于图形缓存处于不被cache的存储器中。对不被cache的存储器的独立的写以及对写回存储器的写分为独立的几个阶段可以确保写绑定缓存可以在被其它写交通逐出之前被填充。以及发现,消除部分写事务对某些应用的20%的次序具有很大影响。因为Cache行是64个字节,一次对总线写63个字节将导致8个部分总线事务。
当对同时在两个线程上执行的函数进行编码时,减少允许在一个内部循环的写的次数将帮助充分利用写绑定存储缓存。对于超线程技术的写绑定缓存的建议可以见第8章。
存储次序和可见性对于写绑定也是重要的问题。当为一次先前未被写的Cache行而对一个写绑定缓存的写发生时,将会有一次为所有权的读(RFO)。如果一次后续的写正好发生在另一个写绑定缓存上,那么那个Cache行可能引起一个独立的RFO。后续的对第一个Cache行的写以及写绑定缓存将会被延迟直到第二个RFO已经被服务以确保写适当的次序上的可见性。如果写的存储器类型是写绑定的,那么将不会有RFO,由于该行不被cache,并从而没有这样的延迟。对于写绑定的详细细节见第10章。
3.6.11 位置增强
位置增强能减少源于一个更外层的Cache/存储器层级中的子系统的数据交通。这确定了一个事实,就来自一个更外层的周期数而言的访问成本将比来自一个内部层级的更昂贵。一般,访问一个给定的Cache层级(或存储器系统)的周期成本对于不同的微架构、处理器实现以及平台组件而有所不同。通过位置可能足以辨别相关数据访问成本趋势而不需要遵从周期成本数值的一张大表,列出每个位置,每个处理器/平台实现等等。通常的趋势一般为,从一个更外层的子系统的访问成本可能比访问Cache/存储器层级中的最内层数据访问大约昂贵3-10倍,假定数据访问并行度相似。
因而,位置提升应该从明显的数据交通位置开始。部分A“应用程序性能工具”描述了可以用来确定为任一负荷的明显的数据交通位置的某些技术。
即使最后层Cache的Cache失败率可能与Cache引用数量的相关性较低,不过处理器一般花费相当大的执行时间来等待Cache失败被服务。通过增强一个程序的位置来减少Cache失败是一个关键优化。这可以采取机种形式:
● 阻断适应于Cache的数组超过部分的迭代(目的是后面对数据块[或数据瓦片]的应用将会是Cache命中的引用)
● 循环互换来避免跨Cache行或页边界
● 循环斜交使得访问是连续的
对最后层级Cache的位置增强可以用顺序数据访问模式以利用硬件预取来实现。这也可以采取几种方式:
● 对一个稀疏多维的数组变换为一个一维数组,使得存储器引用以一种顺序的,小跨度的模式发生,这对硬件预取友好。
● 最优瓦片大小和形状选择能进一步提升临时数据位置,通过提升最后层Cache中的命中率并减少由硬件预取的行动所导致的存储器交通(见7.6.11小节)。
避免违背位置增强操作的技术是很重要的。过度使用锁前缀在访问存储器时会遭到非常大的延迟,不管数据是在Cache中还是在系统存储器中。
用户/源编码规则10:诸如分块、循环互换、循环斜交以及打包的优化技术通过编译器能完成最好。优化数据结构,要么适应一半的第一层Cache,要么是在第二层Cache中;在编译器中打开循环优化来增强嵌套循环的位置。
为第一层Cache的一半的优化就每次数据访问的周期成本而言将带来最大的性能受益。如果第一层Cache的一半对于实践太小,那么优化第二层Cache。如果为一个两者之间的点来优化(比如,为整个第一级Cache)将可能不会带来相对于提升第二级Cache来说很可观的优化。
3.6.12 最小化总线延迟
每个总线事务包括做请求的负荷与总裁。如果读和写交替,那么总线读与总线写的平均延迟将会更长。将读与写分为几个阶段能减少总线事务的平均延迟。这是因为涉及后续跟在一次写之后的一次读,或一次读后面跟着一次写的事务发生的数量被减少。
用户/源编码规则11:如果在总线上混有读和写,那么改变代码以这些总线事务分成独立的读阶段和写阶段可以帮助提升性能。
注意,然而总线上读和写操作的次序与它在程序中所出现的并不相同。
取数据的一条Cache行的总线延迟会由于访问数据引用的跨度的一个功能而有所不同。通常来说,随着相继Cache失败的跨度值增加,总线延迟也会增加。独立地来说,增加总线队列深度(一个给定事务类型的显著的总线请求的个数)的一个功能,总线延迟也会增加。这两个趋势的组合会是高度非线性的,在大跨度的总线延迟和带宽密集的情况下,数据并行访问的总线系统的有效吞吐会严重少于小跨度、带宽密集的情况。
为了有效地减少存储器交通的每次访问的成本或分批处理未经处理的存储器延迟,软件应该控制其Cache失败模式以迎合对小跨度Cache失败的更高关注。
用户/源编码规则12:为了实现总线延迟的有效分批,软件应该迎合会导致更高关注Cache失败模式的数据访问模式,Cache失败的跨度远小于硬件预取触发上限的一半。
3.6.13 非临时存储总线交通
峰值系统总线带宽由几种类型的总线活动所共享,包括读(从存储器),为(一个Cache行的)所有权的读,以及写。如果一次写出64字节到总线,那么总线写事务的数据传输率会更高。
一般来说,写到写回(WB)存储器的总线写必须与为所有权读(RFO)交通共享系统总线带宽。非临时存储不需要RFO交通;它们需要在意管理访问模式以确保64字节能被一次逐出(而不是逐出若干次8字节块)。
尽管出于非临时存储目的的完整的64字节的总线写的数据带宽两倍于对WB存储器的总线写,不过传输8字节块仍然浪费总线请求带宽并带来明显更低的数据带宽。不同之处在例3-52和3-53中描述。
例3-52:使用非临时存储以及64字节总线写事务
#define STRIDESIZE 256 lea ecx, p64byte_Aligned mov edx, ARRAY_LEN xor eax, eax slloop: movntps XMMWORD ptr [ecx + eax], xmm0 movntps XMMWORD ptr [ecx + eax + 16], xmm0 movntps XMMWORD ptr [ecx + eax + 32], xmm0 movntps XMMWORD ptr [ecx + eax + 48], xmm0 ; 64字节被写入一个总线事务上 add eax, STRIDESIZE cmp eax, edx jl slloop
例3-53:临时存储以及部分总线写事务
#define STRIDESIZE 256 lea ecx, p64byte_Aligned mov edx, ARRAY_LEN xor eax, eax slloop: movntps XMMWORD ptr [ecx + eax], xmm0 movntps XMMWORD ptr [ecx + eax + 16], xmm0 movntps XMMWORD ptr [ecx + eax + 32], xmm0 ; 存储48个字节导致6个总线部分事务 add tax, STRIDESIZE cmp eax, edx jl slloop