优化代码内存访问

以下内容总结自《Intel® 64 and IA-32 Architectures Optimization Reference Manual》

本文内容讨论针对Intel处理器优化代码内存访问的相关技术。主要内容如下:

1 加载和存储执行带宽

通常,加载和存储是代码执行中最频繁的操作,高达 40% 的加载和存储指令并不少见。每一代微架构都提供了多个缓冲区来支持在有指令运行时执行加载和存储操作。这些缓冲区由 Sandy Bridge 和 Ivy Bridge 微架构的 128 位组成。 在 Haswell、Broadwell 和 Skylake Client 微架构中,大小增加到 256 位; 以及 Skylake Server、Cascade Lake、Cascade Lake Advanced Performance 和 Ice Lake 客户端微架构中的 512 位。 为了最大限度地提高性能,最好使用平台中可用的最大宽度。

1.1 在 Sandy Bridge 微架构中利用加载带宽

虽然先前的微架构只有一个加载端口(端口 2),但 Sandy Bridge 微架构可以从端口 2 和端口 3 加载。因此,每个周期可以执行两次加载操作,并使代码的加载吞吐量翻倍。 这改进了读取大量数据并且不需要经常将结果写入内存的代码(端口 3 也处理存储地址操作)。 为了利用此带宽,数据必须保留在 L1 数据缓存中,否则应按顺序访问,从而使硬件预取器能够及时将数据带到 L1 数据缓存中。

考虑以下计算数组所有元素和的 C 代码示例:

int buff[BUFF_SIZE];
int sum = 0;

for (i=0;i<BUFF_SIZE;i++){ 
  sum+=buff[i];
}

示例 1-1 是英特尔编译器为此 C 代码生成的汇编代码。 编译器使用英特尔 SSE 指令对执行进行矢量化。 在此代码中,每个 ADD 操作都使用前一个 ADD 操作的结果。 这将吞吐量限制为每个周期一个加载和 ADD 操作。 示例 1-2 针对 Sandy Bridge 微架构进行了优化,使其能够使用额外的加载带宽。 该代码通过使用两个寄存器来对数组值求和,从而消除了 ADD 操作之间的依赖性。 每个周期可以执行两次加载和两次添加操作。

示例 1-1

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

示例 1-2

  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

1.2 Sandy Bridge 微架构中的 L1D 缓存延迟

L1D 缓存的加载延迟可能会有所不同, 最好的情况是 4 个周期,这适用于使用以下方法之一对通用寄存器进行加载操作:

  • 一个寄存器。
  • 一个基址寄存器加上一个小于 2048 的偏移量。

考虑示例中的指针跟踪代码示例。

示例 1-3: Traversing through indexes

// C code example
index = buffer.m_buff[index].next_index; 
// ASM example
loop:
  shl rbx, 6
  mov rbx, 0x20(rbx+rcx) 
  dec rax
  cmp rax, -1
  jne loop

示例 1-4: Traversing through pointers

// C code example
node = node->pNext;
// ASM example 
loop:
  mov rdx, [rdx] 
  dec rax
  cmp rax, -1 
  jne loop

示例 1-3 通过遍历索引实现指针追踪。 然后编译器生成所示的代码,使用带有偏移量的 base+index 寻址内存。 示例 1-4 显示了编译器从指针解引用代码生成的代码,并且仅使用了一个基址寄存器。在 Sandy Bridge 微架构和之前的微架构中,代码 2 比代码 1 要快。

1.3 处理 L1D 缓存库冲突

在 Sandy Bridge 微架构中,L1D 缓存的内部组织会出现两个加载地址,可能存在库冲突的微操作的情况。当两个加载操作之间存在冲突时,最近的一个将被延迟,直到冲突解决。当两个同时加载操作具有相同的线性地址的第 2-5 位但它们不是来自高速缓存中的同一组(第 6-12 位)时,就会发生库冲突。

只有当代码受加载带宽约束时,才应处理库冲突。一些库冲突不会导致任何性能下降,因为它们被其他性能限制隐藏,消除这种库冲突并不能提高性能。

以下示例演示了库冲突以及如何修改代码并避免它们。它使用两个源数组,其大小是缓存行大小的倍数。当从 A 加载一个元素并从 B 加载对应元素时,这些元素在它们的缓存行中具有相同的偏移量,因此可能会发生存储库冲突。 L1D 缓存库冲突不适用于 Haswell 微架构。

示例 1-5:C Code

int A[128];
int B[128];
int C[128];
for (i=0;i<128;i+=4){
  C[i]=A[i]+B[i]; // the loads from A[i] and B[i] collide
  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];
}

示例 1-6: Code with Bank Conflicts

  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 r8d, [r11+rsi*4+4]
  add r8d, [r12+rsi*4+4]
  mov r9d, [r11+rsi*4+8]
  add r9d, [r12+rsi*4+8]
  mov r10d, [r11+rsi*4+12]
  add r10d, [r12+rsi*4+12]

  mov [r13+rsi*4], edi
  inc ecx
  mov [r13+rsi*4+4], r8d
  mov [r13+rsi*4+8], r9d
  mov [r13+rsi*4+12], r10d
  cmp ecx, LEN
  jb loop

示例 1-7: Code without Bank Conflicts

 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 r8d, [r11+rsi*4+4]
  add edi, [r12+rsi*4]
  add r8d, [r12+rsi*4+4]
  mov r9d, [r11+rsi*4+8]
  mov r10d, [r11+rsi*4+12]
  add r9d, [r12+rsi*4+8]
  add r10d, [r12+rsi*4+12]
  
  inc ecx
  mov [r13+rsi*4], edi
  mov [r13+rsi*4+4], r8d
  mov [r13+rsi*4+8], r9d
  mov [r13+rsi*4+12], r10d
  cmp ecx, LEN
  jb loop

2 尽量减少寄存器溢出

当一段代码的实时变量多于处理器可以保存在通用寄存器中的数量时,一种常见的方法是将一些变量保存在内存中。 这种方法称为寄存器溢出。 L1D 缓存延迟的影响会对该代码的性能产生负面影响。 如果寄存器溢出的地址使用较慢的寻址模式,效果会更加明显。

一种选择是将通用寄存器溢出到 XMM 寄存器。 这种方法也可能提高前几代处理器的性能。 以下示例显示如何将寄存器溢出到 XMM 寄存器而不是内存。

示例 2-1:Register spills into memory

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

Register spills into 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 rcx, [rbx+0x60]

  padd xmm4, xmm5
  add rdi, 0x4
  movzx rdx, di
  sub rcx, 0x4
  add rsi, 0x1d0
  cmp rdx, rcx
  jle loop

3 增强推测执行和内存消歧

在 Intel Core 微架构之前,当代码同时包含存储和加载时,在知道旧存储的地址之前无法发出加载。此规则确保正确处理对先前存储的加载依赖关系。

Intel Core 微架构包含一种机制,允许在存在较旧的未知存储的情况下推测性地执行某些加载。处理器稍后检查加载地址是否与执行加载时地址未知的旧存储重叠。如果地址确实重叠,则处理器重新执行加载和所有后续指令。

示例代码说明了编译器无法确定”Ptr->Array”在循环期间不会改变的情况。因此,编译器不能将”Ptr->Array”作为不变量保存在寄存器中,并且必须在每次迭代中再次读取它。虽然这种情况可以通过重写代码以要求指针地址不变在软件中修复,但内存消歧在不重写代码的情况下提高了性能。

示例 3-1:Loads Blocked by Stores of Unknown Address

// C code
struct AA {
  AA ** Array;
};
void nullify_array ( AA *Ptr, DWORD Index, AA *ThisPtr)
{
  while ( Ptr->Array[--Index] != ThisPtr )
  {
    Ptr->Array[Index] = NULL ;
  } ;
} ;

// Assembly sequence
  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

4 存储转发

处理器的内存系统仅在存储失效后将存储发送到内存(包括缓存)。但是,存储数据可以从同一地址从存储转发到后续加载,以缩短存储加载延迟。

存储转发有两种要求。如果违反了这些要求,存储转发将无法发生,加载必须从缓存中获取数据(因此存储必须先将其数据写回缓存)。这会带来很大程度上与底层微架构的管道深度有关的惩罚。

第一个要求与存储转发数据的大小和对齐方式有关。 此限制可能会对整体应用程序性能产生很大影响。 通常,可以防止因违反此限制而导致的性能损失。 存储到加载转发限制因一种微架构而异。 第 4.1 节“存储到加载转发对大小和对齐的限制”中详细讨论了几个导致存储转发停滞的编码缺陷示例以及这些缺陷的解决方案。 第二个要求是数据的可用性,在第 4.2 节“数据可用性的存储转发限制”中进行了讨论。 一个好的做法是消除冗余的加载操作。

可以将临时标量变量保存在寄存器中,而永远不要将其写入内存。 通常,这样的变量不能使用间接指针访问。 将变量移动到寄存器会消除该变量的所有加载和存储,并消除与存储转发相关的潜在问题。 然而,它也增加了套准压力。

加载指令倾向于启动计算链。 由于乱序引擎是基于数据依赖的,因此加载指令对引擎的高速执行能力起着重要作用。 消除加载指令应该是高度优先的。

如果一个变量从存储到再次使用之间没有变化,则可以复制或直接使用之前存储的寄存器。 如果寄存器压力太大,或者在存储和第二次加载之前调用了一个看不见的函数,则可能无法消除第二次加载。

尽可能在寄存器中而不是在堆栈中传递参数。 在堆栈上传递参数需要存储然后重新加载。 虽然此序列在硬件中通过直接从内存顺序缓冲区向加载提供值而在硬件中进行了优化,如果存储转发限制允许,则无需访问数据缓存,但浮点值会在转发过程中产生显着延迟。 在(最好是 XMM)寄存器中传递浮点参数应该可以节省这种长延迟操作。

参数传递约定可能会限制哪些参数在堆栈上传递,哪些参数在寄存器中传递。 但是,如果编译器可以控制整个二进制文件的编译(使用整个程序优化),则可以克服这些限制。

4.1 Store-to-Load-Forwarding 大小和对齐限制

存储转发的数据大小和对齐限制适用于基于 Intel Core 微架构、Intel Core 2 Duo、Intel Core Solo 和 Pentium M 处理器的处理器。 对于较短的流水线机器,违反存储转发限制的性能损失较小。

存储转发限制因每个微架构而异。 以下规则有助于满足存储转发的大小和对齐限制:

  • 规则1:从存储转发的加载必须具有相同的地址起点,因此与存储数据具有相同的对齐方式。
  • 规则2:从存储转发的加载数据必须完全包含在存储数据中。

从存储转发的加载必须等待存储的数据写入存储缓冲区才能继续,但其他不相关的加载不需要等待。

  • 规则3:如果需要提取存储数据的未对齐部分,请读出完全包含数据的最小对齐部分,并根据需要 shift/mask 数据。 这比招致存储转发失败的惩罚要好。
  • 规则4:通过根据需要使用单个大型读取和注册副本,避免在将大型存储到同一内存区域之后进行几次小型加载。

示例 4-1 描述了几种存储转发情况,其中小加载跟随大存储。 前三个加载操作说明了规则 4 中描述的情况。但是,最后一个加载操作从存储转发中获取数据没有问题。

示例 4-1:Situations Showing Small Loads After Large Store

mov [EBP],‘abcd’
mov AL, [EBP] ; Not blocked - same alignment
mov BL, [EBP + 1] ; Blocked
mov CL, [EBP + 2] ; Blocked
mov DL, [EBP + 3] ; Blocked
mov AL, [EBP] ; Not blocked - same alignment
              ; n.b. passes older blocked loads

示例 4-2 说明了存储转发情况,其中大加载跟随几个小存储。 加载操作所需的数据无法转发,因为需要转发的所有数据都没有包含在存储缓冲区中。 在小存储到同一内存区域后避免大加载。

示例 4-2:Non-forwarding Example of Large Load After Small Store

mov [EBP], ‘a’
mov [EBP + 1], ‘b’
mov [EBP + 2], ‘c’
mov [EBP + 3], ‘d’
mov EAX, [EBP] ; Blocked
    ; The first 4 small store can be consolidated into
    ; a single DWORD store to prevent this non-forwarding
    ; situation.

示例 4-3 说明了可能出现在编译器生成的代码中的停滞存储转发情况。 有时,编译器会生成类似于示例 3 中所示的代码来处理溢出的字节到堆栈并将字节转换为整数值。

示例 4-3:A Non-forwarding Situation in Compiler Generated Code

mov DWORD PTR [esp+10h], 00000000h
mov BYTE PTR [esp+10h], bl
mov eax, DWORD PTR [esp+10h] ; Stall
and eax, 0xff ; Converting back to byte value

示例 4-5 提供了两种替代方案来避免示例 3 中所示的非转发情况。

示例 4-5:Two Ways to Avoid Non-forwarding Situation in Example 3

; A. Use MOVZ instruction to avoid large load after small
; store, when spills are ignored.
movz eax, bl ; Replaces the last three instructions
; B. Use MOVZ instruction and handle spills to the stack
mov DWORD PTR [esp+10h], 00000000h
mov BYTE PTR [esp+10h], bl
movz eax, BYTE PTR [esp+10h] ; Not blocked

在内存位置之间移动小于 64 位的数据时,64 位或 128 位 SIMD 寄存器移动效率更高(如果对齐),可用于避免未对齐的加载。 尽管浮点寄存器允许一次移动 64 位,但浮点指令不应用于此目的,因为数据可能会被无意修改。

示例 4-5:Large and Small Load Stalls

; A. Large load stall
mov mem, eax ; Store dword to address “MEM"
mov mem + 4, ebx ; Store dword to address “MEM + 4"
fld mem ; Load qword at address “MEM", stalls
; B. Small Load stall
fstp mem ; Store qword to address “MEM"
mov bx, mem+2 ; Load word at address “MEM + 2", stalls
mov cx, mem+4 ; Load word at address “MEM + 4", stalls

在第一种情况 (A) 中,在对同一内存区域(从内存地址 MEM 开始)进行一系列小存储之后,会出现大加载。 大加载将停止。

FLD 必须等待存储写入内存,然后才能访问所需的所有数据。 这种停顿也可能发生在其他数据类型中(例如,当存储字节或字,然后从同一内存区域读取字或双字时)。

在第二种情况 (B) 中,在大存储到同一内存区域(从内存地址 MEM 开始)之后,会有一系列小加载。 小加载将停止。

字加载必须等待四字存储写入内存,然后才能访问所需的数据。 这种停顿也可能发生在其他数据类型中(例如,当存储双字或字,然后从同一内存区域读取字或字节时)。 这可以通过将商店尽可能远离加载来避免。

4.2

要存储的值必须在加载操作完成之前可用。 如果违反此限制,加载的执行将被延迟,直到数据可用。 这种延迟会导致一些执行资源被不必要地使用,这可能会导致相当大但不确定的延迟。 然而,这个问题的整体影响远小于违反尺寸和对齐要求的影响。

在现代微架构中,硬件预测加载何时依赖并从之前的存储中获取数据。 这些预测可以显着提高性能。 但是,如果在它所依赖的存储之后过早地安排加载,或者如果要存储的数据的生成被延迟,则可能会产生重大损失。

数据通过内存传递有几种情况,可能需要将存储与加载分开:

  • 溢出、保存和恢复堆栈帧中的寄存器。
  • 参数传递。
  • 全局变量和 volatile 变量。
  • 整数和浮点之间的类型转换。
  • 当编译器不分析内联代码时,强制内联代码接口中涉及的变量位于内存中,从而创建更多内存变量并防止消除冗余负载。

如果可以在不招致其他惩罚的情况下,请优先考虑将变量分配给寄存器,例如在寄存器分配和参数传递中,以最大限度地减少存储转发问题的可能性和影响。 尽量不要存储转发从长延迟指令生成的数据 – 例如,MUL 或 DIV。 避免为具有最短存储加载距离的变量存储转发数据。 避免为具有许多 and/or 长依赖链的变量存储转发数据,尤其是避免在循环携带的依赖链上包含存储转发。示例 4-6 展示了一个循环携带的依赖链的例子。

示例 4-6:Loop-carried Dependence Chain

for ( i = 0; i < MAX; i++ ) {
  a[i] = b[i] * foo;
  foo = a[i] / 3;
} // foo is a loop-carried dependence.

尽早计算存储地址以避免存储块加载。

5 数据布局优化

填充源代码中定义的数据结构,以便每个数据元素都与自然操作数大小的地址边界对齐。如果操作数打包在 SIMD 指令中,则与打包元素大小(64 位或 128 位)对齐。

通过在结构和数组内部提供填充来对齐数据。 程序员可以重新组织结构和数组,以尽量减少填充浪费的内存量。 但是,编译器可能没有这种自由。 例如,C 编程语言指定结构元素在内存中的分配顺序。

示例 5-1 显示了如何重新排列数据结构以减小其大小。

示例 5-1:Rearranging a Data Structure

struct unpacked { /* Fits in 20 bytes due to padding */
  int a;
  char b;
  int c;
  char d;
  int e;
};
struct packed { /* Fits in 16 bytes */
  int a;
  int c;
  int e;
  char b;
  char d;
}

64 字节的高速缓存行大小会影响流应用程序(例如多媒体)。 这些在丢弃数据之前仅引用和使用一次数据。 稀疏地利用高速缓存行内的数据的数据访问会导致系统内存带宽的利用效率降低。 例如,可以将结构数组分解为多个数组以实现更好的打包,如例 5-2 所示。

示例 5-2:Decomposing an Array

struct { /* 1600 bytes */
  int a, c, e;
  char b, d;
} array_of_struct [100];
struct { /* 1400 bytes */
  int a[100], c[100], e[100];
  char b[100], d[100];
} struct_of_array;
struct { /* 1200 bytes */
  int a, c, e;
} hybrid_struct_of_array_ace[100];
struct { /* 200 bytes */
  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 页面缺失。
  • 由于流更少,预取更少。
  • 同时使用的数据元素的高效缓存行打包。

尝试安排数据结构,使它们允许顺序访问。如果将数据排列成一组流,则自动硬件预取器可以预取应用程序需要的数据,从而减少有效的内存延迟。 如果以非顺序方式访问数据,则自动硬件预取器无法预取数据。 预取器最多可以识别八个并发流。当心高速缓存行(64 字节)内的错误共享。

6 堆栈对齐

当内存引用拆分缓存线时,会发生对堆栈的未对齐访问的性能损失。这意味着八个空间上连续的未对齐四字访问中有一个总是受到惩罚,类似于四个连续的、未对齐的双四字访问中的一个。

当数据对象超过系统的默认堆栈对齐方式时,对齐堆栈可能是有益的。例如,在32/64位Linux和64位Windows上,默认堆栈对齐为16字节,而32位Windows为4字节。

确保堆栈在与寄存器宽度匹配的最大多字节粒度数据类型边界处对齐。对齐堆栈通常需要使用额外的寄存器来跟踪未知数量的填充区域。在导致跨越缓存线的未对齐内存引用和导致额外的通用寄存器溢出之间存在权衡。实现动态堆栈对齐的汇编级技术可能取决于编译器和特定的操作系统环境。

示例 6-1:Examples of Dynamical Stack Alignment

// 32-bit environment
push ebp ; save ebp
mov  ebp, esp ; ebp now points to incoming parameters
andl esp, $-<N> ;align esp to N byte boundary
sub  esp, $<stack_size>; reserve space for new stack frame
. ; parameters must be referenced off of ebp
mov  esp, ebp ; restore esp
pop  ebp ; restore ebp

// 64-bit environment
sub  esp, $<stack_size +N>
mov  r13, $<offset_of_aligned_section_in_stack>
andl r13, $-<N> ; r13 point to aligned section in stack
. ;use r13 as base for aligned data

如果由于某种原因无法将堆栈对齐64位,则例程应访问该参数并将其保存到寄存器或已知的对齐存储器中,从而只会导致一次惩罚。

7 缓存中的容量限制和别名

在某些情况下,具有给定步幅的地址将竞争内存层次结构中的某些资源。 通常,缓存被实现为具有多种方式的集合关联性,每种方式由多组缓存行(或某些情况下的扇区)组成。 多个内存引用在缓存中竞争同一组的每个方式可能会导致容量问题。 有适用于特定微架构的别名条件。 请注意,一级缓存行是 64 字节。 因此,在别名比较中不考虑最低有效 6 位。

8 混合代码和数据

英特尔处理器对指令的主动预取和预解码有两个相关影响:

  • 根据英特尔体系结构处理器的要求,自修改代码可以正常工作,但会导致严重的性能损失。尽可能避免自我修改代码。
  • 在代码段中放置可写数据可能无法与自修改代码区分开来。代码段中的可写数据可能会受到与自修改代码相同的性能损失。

如果(希望是只读的)数据必须与代码出现在同一页上,请避免将其直接放在间接跳转之后。例如,跟随一个间接跳转及其最可能的目标,并将数据放在一个无条件分支之后。

在极少数情况下,将代码页上的数据作为指令执行可能会导致性能问题。当执行遵循不驻留在跟踪缓存中的间接分支时,很可能会发生这种情况。如果这明显导致性能问题,请尝试将数据移到其他位置,或在间接分支后立即插入非法操作码或暂停指令。请注意,后两种备选方案在某些情况下可能会降低性能。

始终将代码和数据放在单独的页面上。尽可能避免自我修改代码。如果要修改代码,请尝试一次完成所有操作,并确保执行修改的代码和被修改的代码位于单独的4kb页面或单独对齐的1kb子页面上。

8.1 自修改代码(Self-modifying Code)

在 Pentium III 处理器和之前的实现上正确运行的自修改代码(SMC)将在后续实现上正确运行。当需要高性能时,应避免SMC和交叉修改代码(当多处理器系统中的多个处理器写入代码页时)。

软件应避免写入正在执行的同一个1KB子页面中的代码页,或获取正在写入的同一个2KB子页面中的代码。此外,将包含直接或推测执行代码的页面作为数据页面与另一个处理器共享可能会触发SMC条件,从而导致机器的整个管道和跟踪缓存被清除。这是由于自修改代码条件造成的。

如果写入的代码在作为代码访问数据页之前填充了该页,则动态代码不必导致SMC情况。动态修改的代码(例如,来自目标修复)可能会受到SMC条件的影响,应尽可能避免。通过引入间接分支和使用register间接调用在数据页(而不是代码页)上使用数据表来避免这种情况。

8.2 位置无关代码

位置无关的代码通常需要获取指令指针的值。示例8-1a显示了一种通过发出不带匹配RET的调用将IP值放入ECX寄存器的技术。示例8-1b显示了另一种使用匹配的CALL/RET对将IP值放入ECX寄存器的技术。

示例 8-1:Instruction Pointer Query Techniques

a) Using call without return to obtain IP does not corrupt the RSB
    call _label; return address pushed is the IP of next instruction
_label:
    pop ECX; IP of this instruction is now put into ECX
b) Using matched call/ret pair
    call _lblcx;
    ... ; ECX now contains IP of this instruction
    ...
_lblcx
    mov ecx, [esp];
    ret

9 写组合

写组合(WC)通过两种方式提高性能:

  • 在一级缓存的写未命中时,它允许在缓存线从缓存/内存层次结构的更外层读取所有权(RFO)之前,对同一缓存线进行多个存储。然后读取行的其余部分,并将尚未写入的字节与返回行中未修改的字节组合。
  • 写入组合允许在高速缓存层次结构中将多个写入组合并作为一个单元进一步写入。 这节省了端口和总线流量。节省流量对于避免部分写入未缓存的内存尤为重要。

基于英特尔 Core 微架构的处理器在每个内核中有八个写入组合缓冲区。 从 Nehalem 微架构开始,有 10 个缓冲区可用于写入组合。 从 Ice Lake 客户端微架构开始,有 12 个缓冲区可用于写入组合。

如果内部循环写入超过四个数组(四个不同的缓存行),则应用循环分裂来分解循环体,以便在每个结果循环的每次迭代中只写入四个数组。

写组合缓冲区用于所有内存类型的存储。 它们对于对未缓存内存的写入特别重要:对同一缓存行的不同部分的写入可以分组到单个完整的缓存行总线事务中,而不是像多个部分写入那样通过总线(因为它们没有被缓存) . 避免部分写入会对受总线带宽限制的图形应用程序产生重大影响,其中图形缓冲区位于未缓存的内存中。 将对未缓存内存的写入和对回写内存的写入分离到单独的阶段可以确保写入组合缓冲区可以在被其他写入流量驱逐之前填满。 已发现消除部分写入事务对某些应用程序的性能影响约为 20%。 因为高速缓存行是 64 字节,所以写入总线 63 字节将导致部分总线事务。

在编写同时在两个线程上执行的函数时,减少内部循环中允许的写入次数将有助于充分利用写入组合存储缓冲区。

存储顺序和可见性也是写入组合的重要问题。 当对先前未写入的高速缓存行的写入组合缓冲区进行写入时,将发生读取所有权 (RFO)。 如果后续写入发生在另一个写入组合缓冲区,则可能会为该高速缓存行导致单独的 RFO。 对第一个高速缓存行和写入组合缓冲区的后续写入将被延迟,直到第二个 RFO 得到服务,以保证写入的正确排序可见性。 如果写入的内存类型是写入组合,则不会有 RFO,因为该行没有被缓存,并且没有这样的延迟。

10 局部增强

局部性增强可以减少来自缓存/内存层次结构中的外部子系统的数据流量。这是为了解决这样一个事实,即从外部层面的周期计数来看,访问成本将比从内部层面的成本更高。通常,访问给定缓存级别(或内存系统)的周期成本因不同的微体系结构、处理器实现和平台组件而异。按地区识别相对数据访问成本趋势可能就足够了,而不是按照每个地区、每个处理器/平台实施列出的周期成本的大型数值表,等。一般趋势是,假设数据访问并行度相似,从外部子系统访问数据的成本可能比从缓存/内存层次结构中的直接内部级别访问数据的成本大约高3-10倍。

即使最后一级缓存的缓存未命中率相对于缓存引用的数量可能较低,处理器通常会花费相当大一部分执行时间等待缓存未命中得到服务。通过增强程序的局部性来减少缓存未命中是一个关键的优化。这可以采取几种形式:

  • 阻塞以迭代将适合缓存的数组的一部分(目的是对数据块 [或 tile] 的后续引用将成为缓存命中引用)。
  • 循环交换以避免跨越高速缓存行或页面边界。
  • 循环倾斜以使访问连续。

可以通过对数据访问模式进行排序以利用硬件预取来实现对最后一级缓存的局部性增强。 这也可以采取多种形式:

  • 将稀疏填充的多维数组转换为一维数组,以便内存引用以对硬件预取友好的顺序、小步幅模式发生。
  • 最佳切片大小和形状选择可以通过提高最后一级缓存的命中率和减少硬件预取操作导致的内存流量来进一步改善时间数据局部性。

避免对局部性增强技术起作用的操作很重要。 在访问内存时,无论数据是在缓存中还是在系统内存中,大量使用锁定前缀都会导致很大的延迟。

阻塞、循环交换、循环倾斜和打包等优化技术最好由编译器完成。 优化数据结构以适应一级缓存的一半或二级缓存; 在编译器中打开循环优化以增强嵌套循环的局部性。

优化一半的一级缓存将在每次数据访问的周期成本方面带来最大的性能优势。 如果一级缓存的一半太小不实用,则针对二级缓存进行优化。 针对中间的一点进行优化(例如,针对整个一级缓存)可能不会比针对二级缓存的优化带来实质性的改进。

11 非临时存储总线流量

峰值系统总线带宽由几种类型的总线活动共享,包括读取(从内存)、读取所有权(缓存行)和写入。 如果一次将 64 个字节写入总线,则总线写事务的数据传输率会更高。

通常,写入回写 (WB) 内存的总线必须与读取所有权 (RFO) 流量共享系统总线带宽。 非临时存储不需要 RFO 流量; 它们确实需要小心管理访问模式,以确保一次收回 64 个字节(而不是收回多个块)。

尽管由于非临时存储而导致的完整 64 字节总线写入的数据带宽是总线写入 WB 内存的两倍,但传输多个块会浪费总线请求带宽并提供显着降低的数据带宽。 这种差异在示例 11-1 和 11-2 中进行了描述。

示例 11-1:Using Non-temporal Stores and 64-byte Bus Write Transactions

#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 bytes is written in one bus transaction
add eax, STRIDESIZE
cmp eax, edx
jl slloop

示例 11-2:On-temporal Stores and Partial Bus Write Transactions

#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

; Storing 48 bytes results in several bus partial transactions
add eax, STRIDESIZE
cmp eax, edx
jl slloop

《优化代码内存访问》有一个想法

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注