值得花时间对齐AVX-256内存存储吗?

19
根据《Intel® 64 和 IA-32 架构优化参考手册》的 B.4(“Sandy Bridge 微架构性能调整技术”)第 B.4.5.2 小节(“辅助操作”),跨越两个页面的 32 字节 AVX 存储指令需要一个代价大约为 150 个时钟周期的辅助操作。
我在堆管理器中使用 YMM 寄存器复制大小固定的小型内存块,从 32 到 128 字节,这些块被 16 字节对齐。该堆管理器之前使用 XMM 寄存器和 movdqa,我希望将其“升级”为 YMM,而不改变从 16 字节到 32 字节的对齐方式。因此,我使用 vmovdqu ymm0, ymmword ptr [rcx],然后使用 vmovdqu ymmword ptr [rdx], ymm0 等等。
如果我正确理解了 Intel 的有关页面大小的文档,如果我在跨越 4K 页面边界进行 32 字节存储,我将受到 150 个时钟周期的惩罚。
但由于这些块已经通过 16 字节进行了对齐,我命中交叉页面存储的几率是 16/4096 = 1/256。如果我们统计推算,在每次 32 字节存储中,我在 Sandy Bridge 上获得 1/256*150 (=0.5859375) 个时钟周期的惩罚。
这并不是太多,而且肯定比分支检查对齐更便宜,或者由于将对齐从 16 字节更改为 32 字节而导致的内存浪费。
我的问题如下: 1. 我的计算正确吗? 2. 对于小型固定大小的内存复制例程(32-128 字节),是否值得对齐 AVX-256 存储,考虑到命中惩罚的几率很低? 3. 是否有处理器比 Sandy Bridge 的非对齐 32 字节存储惩罚更高,例如 AMD 或其他 Intel 微架构?

2
好问题!我并没有现成的答案,也没有时间去研究和撰写一个好的回答,但我相信会有其他人回答。同时,我想问一下为什么你要编写自己的代码进行内存复制,而不是使用像Agner Fog的库这样已经针对您所选择的指令集进行了优化的东西呢? - Cody Gray
2
我明白了。是的,GPL可能会成为一个问题。但是x86-32和x86-64之间并没有太大的区别,因此似乎绝大多数优化的32位代码可以轻松地移植到64位版本中,免费获得性能提升。当然,您仍然需要编写新代码来针对全新的指令集,例如AVX-256,但它们在实际应用中的普及率仍然非常低,因此考虑发布这些版本可能甚至都不值得。只有在您控制的后端服务器上运行时才需要这样做。 - Cody Gray
3
如果您只是复制大小为32-128字节的小块,还有一件事情需要担心 - 转发延迟。如果用户使用更小的存储器写入内存,然后调用使用SIMD的优化复制,存储器转发将失败,您将面临相当大的惩罚。因此,我实际上不建议尝试手动SIMD优化小的内存复制,除非您知道您正在处理冷数据。 - Mysticial
1
@Johan:只有Intel SnB/IvB(和所有AMD CPU)将AVX256负载/存储拆分为两个128位缓存访问。Intel Haswell及更高版本执行256位传输到/从L1D缓存。因此,使用AVX进行复制应该更快,除非您在L2或L3缓存或主内存上受到瓶颈限制。(或者,如果您仅偶尔使用256位AVX进行非常短的突发操作,则上部电源关闭效应可能会对您产生影响(http://www.agner.org/optimize/blog/read.php?i=415#415)。我不知道它是否适用于简单的负载/存储还是仅适用于ALU。) - Peter Cordes
1
@Maxim:请注意,在Skylake及以后的CPU上,页面分割惩罚要小得多。 (大约只有5或10个周期而不是150个)。但在Haswell / Broadwell及更早版本中仍然很高,因此在便宜时避免仍然很值得。您说您的内存管理器的最小块大小为32字节。为什么不能将它们全部对齐到32字节,而不会浪费太多内存? (也许是个傻问题;我没有认真看内存分配器代码) - Peter Cordes
显示剩余17条评论
1个回答

12

是否值得努力对齐[...]?

是的,绝对值得,并且成本也很低。

您可以轻松地对未对齐的块进行对齐写入,而无需跳转。
例如:

//assume rcx = length of block, assume length > 8.
//assume rdx = pointer to block
xor rax,rax
mov r9,rdx         //remember r9 for later
sub rcx,8           
mov [rdx],rax      //start with an unaligned write
and rdx,not(7)     //force alignment
lea r8,[rdx+rcx]   //finish with unaligned tail write
xor r9,rdx         //Get the misaligned byte count.
sub rcx,r9
jl @tail           //jl and fuse with sub
@loop:
  mov [rdx],rax    //all writes in this block are aligned.
  lea rdx,[rdx+8]  
  sub rcx,8
  jns @loop
@tail 
mov [r8],rax       //unaligned tail write

我相信你可以从未展开的例子推断出一个优化的AVX2例子。
对齐只是一个简单的问题,需要使用misalignment= start and not(alignmentsize -1)。然后可以使用misalignmentcount = start xor misalingment来获取未对齐字节数量。
这些都不需要跳转。我相信你可以将其翻译成AVX。
下面的FillChar代码比标准库快3倍左右。请注意,我使用了跳转,测试表明这样做更快。
{$ifdef CPUX64}
procedure FillChar(var Dest; Count: NativeInt; Value: Byte);
//rcx = dest
//rdx=count
//r8b=value
asm
              .noframe
              .align 16
              movzx r8,r8b           //There's no need to optimize for count <= 3
              mov rax,$0101010101010101
              mov r9d,edx
              imul rax,r8            //fill rax with value.
              cmp edx,59             //Use simple code for small blocks.
              jl  @Below32
@Above32:     mov r11,rcx
              rep mov r8b,7          //code shrink to help alignment.
              lea r9,[rcx+rdx]       //r9=end of array
              sub rdx,8
              rep mov [rcx],rax      //unaligned write to start of block
              add rcx,8              //progress 8 bytes 
              and r11,r8             //is count > 8? 
              jz @tail
@NotAligned:  xor rcx,r11            //align dest
              lea rdx,[rdx+r11]
@tail:        test r9,r8             //and 7 is tail aligned?
              jz @alignOK
@tailwrite:   mov [r9-8],rax         //no, we need to do a tail write
              and r9,r8              //and 7
              sub rdx,r9             //dec(count, tailcount)
@alignOK:     mov r10,rdx
              and edx,(32+16+8)      //count the partial iterations of the loop
              mov r8b,64             //code shrink to help alignment.
              mov r9,rdx
              jz @Initloop64
@partialloop: shr r9,1              //every instruction is 4 bytes
              lea r11,[rip + @partial +(4*7)] //start at the end of the loop
              sub r11,r9            //step back as needed
              add rcx,rdx            //add the partial loop count to dest
              cmp r10,r8             //do we need to do more loops?
              jmp r11                //do a partial loop
@Initloop64:  shr r10,6              //any work left?
              jz @done               //no, return
              mov rdx,r10
              shr r10,(19-6)         //use non-temporal move for > 512kb
              jnz @InitFillHuge
@Doloop64:    add rcx,r8
              dec edx
              mov [rcx-64+00H],rax
              mov [rcx-64+08H],rax
              mov [rcx-64+10H],rax
              mov [rcx-64+18H],rax
              mov [rcx-64+20H],rax
              mov [rcx-64+28H],rax
              mov [rcx-64+30H],rax
              mov [rcx-64+38H],rax
              jnz @DoLoop64
@done:        rep ret
              //db $66,$66,$0f,$1f,$44,$00,$00 //nop7
@partial:     mov [rcx-64+08H],rax
              mov [rcx-64+10H],rax
              mov [rcx-64+18H],rax
              mov [rcx-64+20H],rax
              mov [rcx-64+28H],rax
              mov [rcx-64+30H],rax
              mov [rcx-64+38H],rax
              jge @Initloop64        //are we done with all loops?
              rep ret
              db $0F,$1F,$40,$00
@InitFillHuge:
@FillHuge:    add rcx,r8
              dec rdx
              db $48,$0F,$C3,$41,$C0 // movnti  [rcx-64+00H],rax
              db $48,$0F,$C3,$41,$C8 // movnti  [rcx-64+08H],rax
              db $48,$0F,$C3,$41,$D0 // movnti  [rcx-64+10H],rax
              db $48,$0F,$C3,$41,$D8 // movnti  [rcx-64+18H],rax
              db $48,$0F,$C3,$41,$E0 // movnti  [rcx-64+20H],rax
              db $48,$0F,$C3,$41,$E8 // movnti  [rcx-64+28H],rax
              db $48,$0F,$C3,$41,$F0 // movnti  [rcx-64+30H],rax
              db $48,$0F,$C3,$41,$F8 // movnti  [rcx-64+38H],rax
              jnz @FillHuge
@donefillhuge:mfence
              rep ret
              db $0F,$1F,$44,$00,$00  //db $0F,$1F,$40,$00
@Below32:     and  r9d,not(3)
              jz @SizeIs3
@FillTail:    sub   edx,4
              lea   r10,[rip + @SmallFill + (15*4)]
              sub   r10,r9
              jmp   r10
@SmallFill:   rep mov [rcx+56], eax
              rep mov [rcx+52], eax
              rep mov [rcx+48], eax
              rep mov [rcx+44], eax
              rep mov [rcx+40], eax
              rep mov [rcx+36], eax
              rep mov [rcx+32], eax
              rep mov [rcx+28], eax
              rep mov [rcx+24], eax
              rep mov [rcx+20], eax
              rep mov [rcx+16], eax
              rep mov [rcx+12], eax
              rep mov [rcx+08], eax
              rep mov [rcx+04], eax
              mov [rcx],eax
@Fallthough:  mov [rcx+rdx],eax  //unaligned write to fix up tail
              rep ret

@SizeIs3:     shl edx,2           //r9 <= 3  r9*4
              lea r10,[rip + @do3 + (4*3)]
              sub r10,rdx
              jmp r10
@do3:         rep mov [rcx+2],al
@do2:         mov [rcx],ax
              ret
@do1:         mov [rcx],al
              rep ret
@do0:         rep ret
end;
{$endif}

这不是很多,而且肯定比分支检查对齐要便宜
我认为这些检查非常便宜(见上文)。请注意,您可能会遇到病态情况,因为块经常跨越行。

关于混合使用AVX和SSE代码
在英特尔上,混合使用AVX和(传统的,即非VEX编码)SSE指令会产生300多个周期的惩罚。
如果您使用AVX2指令写入内存,则在应用程序的其余部分中使用SSE代码将会受到惩罚,而Delphi 64专门使用SSE进行浮点运算。
在这种情况下使用AVX2代码会导致严重的延迟。光是出于这个原因,我建议您不要考虑AVX2。

不需要AVX2
使用64位通用寄存器进行写操作就可以饱和内存总线。
同时进行读写操作时,128位的读写也可以轻松饱和总线。
这在旧处理器上是正确的,在超出L1缓存范围时也是正确的,但在最新的处理器上不是。

为什么混合使用AVX和SSE(传统)代码会有惩罚?
英特尔写道:

最初,处理器处于干净状态(1),其中Intel SSE和Intel AVX指令可以无惩罚地执行。当执行256位的Intel AVX指令时,处理器标记为Dirty Upper状态(2)。在此状态下,执行Intel SSE指令会保存所有YMM寄存器的上128位,并将状态更改为Saved Dirty Upper状态(3)。下次执行Intel AVX指令时,所有YMM寄存器的上128位将被恢复,处理器回到状态(2)。这些保存和恢复操作具有很高的惩罚。频繁执行这些转换会导致显着的性能损失。

还有一个问题是黑硅的问题。AVX2代码使用大量硬件,所有这些硅都被点亮会消耗很多电力,从而影响热头程。执行AVX2代码时,CPU会降速,有时甚至低于正常的非Turbo阈值。通过关闭256位AVX的电路,CPU可以实现更高的Turbo时钟,因为热头程更好。关闭AVX2电路的开关没有看到256位代码的时间较长(675微秒),并且开关上看到了AVX2代码。混合两者会导致电路的开关,需要很多周期。


1
“而且如果我们要使用多个较小的存储器,我们仍然需要分支,不是吗?” 不需要跳转,只需执行以下操作:mov [rdx],rax; mov [rdx + 8],rax; mov [rdx + 16],rax; mov [rdx + 24],rax 来模拟未对齐的单个AVX2写入。 - Johan
3
@MaximMasiutin曾经说过可以使用普通字长来饱和一切,但这是5年前(在Sandy Bridge上)的事情。当然,事情从那时起已经发生了变化。所以他并没有错,只是对于你的Kaby Lake已经过时了。现在,即使你全力使用SIMD,有时候也不可能用一个单核心饱和内存总线。而且,即使使用256位AVX,在四通道内存中,你也需要2个或更多核心。 - Mysticial
2
如果分支是一个问题,那么基准测试没有被正确实现。每个东西都应该展开到足够的程度,以至于分支不重要。 - Mysticial
1
你可以使用 jljb 代替 js / jns 吗?在 Intel SnB-family CPU 上,js / jns 无法与 sub 实现宏融合,但是 jljb 可以,因此它们的解码和执行更加高效。 - Peter Cordes
1
@PeterCordes,已完成。感谢您的提示,我不知道融合存在限制。 - Johan
显示剩余13条评论

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接