编写一个跨步的x86基准测试
Posted
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了编写一个跨步的x86基准测试相关的知识,希望对你有一定的参考价值。
我想编写一个加载基准测试,它使用编译时已知的步幅跨越给定的内存区域,在尽可能少的非加载指令的情况下包裹在区域的末尾(2的幂)。
例如,给定一个4099的步幅,以及rdi
中的迭代计数和rsi
中指向内存区域的指针,“工作”的东西是:
%define STRIDE 4099
%define SIZE 128 * 1024
%define MASK (SIZE - 1)
xor ecx, ecx
.top:
mov al, [rsi + rcx]
add ecx, STRIDE
and ecx, MASK
dec rdi
jnz .top
问题是有4个非加载指令只是为了支持单个加载,处理步幅加法,屏蔽和循环终止检查。此外,还有一个通过ecx
进行的2周期依赖链。
我们可以将这一点展开以将循环终止检查成本降低到接近零,并打破依赖链(这里展开4x):
.top:
lea edx, [ecx + STRIDE * 0]
and edx, MASK
movzx eax, BYTE [rsi + rdx]
lea edx, [ecx + STRIDE * 1]
and edx, MASK
movzx eax, BYTE [rsi + rdx]
lea edx, [ecx + STRIDE * 2]
and edx, MASK
movzx eax, BYTE [rsi + rdx]
lea edx, [ecx + STRIDE * 3]
and edx, MASK
movzx eax, BYTE [rsi + rdx]
add ecx, STRIDE * 4
dec rdi
jnz .top
然而,这对add
和and
操作处理包装步幅没有帮助。例如,上面的benmark报告了一个含L1区域的0.875个周期/负载,但我们知道正确的答案是0.5(每个周期两个负载)。 0.875来自15个总uop / 4个uop周期,即,我们受到uop吞吐量的4宽最大宽度的约束,而不是负载吞吐量。
关于如何有效地展开循环以消除步幅计算的大部分成本的任何想法?
因为“绝对最大的疯狂”;您可以要求操作系统在许多虚拟地址映射相同的页面(例如,因此相同的16 MiB RAM出现在虚拟地址0x100000000,0x11000000,0x12000000,0x13000000,...),以避免需要关心包装;并且您可以使用自生成代码来避免其他一切。基本上,生成如下所示的指令的代码:
movzx eax, BYTE [address1]
movzx ebx, BYTE [address2]
movzx ecx, BYTE [address3]
movzx edx, BYTE [address4]
movzx esi, BYTE [address5]
movzx edi, BYTE [address6]
movzx ebp, BYTE [address7]
movzx eax, BYTE [address8]
movzx ebx, BYTE [address9]
movzx ecx, BYTE [address10]
movzx edx, BYTE [address11]
...
movzx edx, BYTE [address998]
movzx esi, BYTE [address999]
ret
当然,所使用的所有地址都将由生成指令的代码计算。
注意:根据具体的CPU,可能会更快地进行循环而不是完全展开(在指令获取和解码成本与循环开销之间存在折衷)。对于更新的英特尔CPU,有一种称为环路流检测器的设计,旨在避免对小于特定大小的环路进行提取和解码(其大小取决于CPU型号);并且我假设生成一个适合该大小的循环将是最佳的。
关于那个数学。证明...在展开循环的开始,如果ecx < STRIDE
和n = (SIZE div STRIDE)
,SIZE不能被STRIDE整除,那么(n-1)*STRIDE < SIZE
,即n-1次迭代是安全的,没有掩蔽。第n次迭代可能并且可能不需要掩蔽(取决于初始ecx
)。如果第n次迭代不需要掩码,则第(n + 1)次将需要掩码。
结果是,你可以设计这样的代码
xor ecx, ecx
jmp loop_entry
unrolled_loop:
and ecx, MASK ; make ecx < STRIDE again
jz terminate_loop
loop_entry:
movzx eax, BYTE [rsi+rcx]
add ecx, STRIDE
movzx eax, BYTE [rsi+rcx]
add ecx, STRIDE
movzx eax, BYTE [rsi+rcx]
add ecx, STRIDE
... (SIZE div STRIDE)-1 times
movzx eax, BYTE [rsi+rcx]
add ecx, STRIDE
;after (SIZE div STRIDE)-th add ecx,STRIDE
cmp ecx, SIZE
jae unrolled_loop
movzx eax, BYTE [rsi+rcx]
add ecx, STRIDE
; assert( ecx >= SIZE )
jmp unrolled_loop
terminate_loop:
需要在add
之前发生的and
的数量不规则,它将是n
或n+1
,因此展开的循环的结束已加倍,以ecx < STRIDE
值开始每个展开的循环。
我不善于使用nasm宏来决定是否可以通过某种宏魔法展开它。
还有一个问题是,这是否可以宏编辑到不同的寄存器,如
xor ecx, ecx
...
loop_entry:
lea rdx,[rcx + STRIDE*4]
movzx eax, BYTE [rsi + rcx]
movzx eax, BYTE [rsi + rcx + STRIDE]
movzx eax, BYTE [rsi + rcx + STRIDE*2]
movzx eax, BYTE [rsi + rcx + STRIDE*3]
add ecx, STRIDE*8
movzx eax, BYTE [rsi + rdx]
movzx eax, BYTE [rsi + rdx + STRIDE]
movzx eax, BYTE [rsi + rdx + STRIDE*2]
movzx eax, BYTE [rsi + rdx + STRIDE*3]
add edx, STRIDE*8
...
then the final part can be filled with simple
movzx eax, BYTE [rsi + rcx]
add ecx, STRIDE
... until the n-th ADD state is reached, then jae loop final
;after (SIZE div STRIDE)-th add ecx,STRIDE
cmp ecx, SIZE
jae unrolled_loop
movzx eax, BYTE [rsi + rcx]
add ecx, STRIDE
; assert( ecx >= SIZE )
jmp unrolled_loop
内部的“安全”部分也可以循环一些,如果你的例子中SIZE div STRIDE = 31.97657965357404,那么内部8次movzx
可以循环3次... 3 * 8 = 24,然后7次非 - 简单的线条达到31x add
,然后双重循环出口跟随最终到达第32个add
根据需要。
虽然在31.9的情况下它看起来毫无意义,但在类似数百+ = SIZE div STRIDE的情况下循环中间部分是有意义的。
如果使用AVX2聚集生成加载uops,则可以使用SIMD进行add + AND。但是,在尝试测量非聚集负载时,这可能不是您想要的!
如果您的区域大小是2 ^ 16..19,您可以使用add ax, dx
(使用DX = stride以避免LCP停顿)以2 ^ 16免费包装。使用eax
作为缩放索引。使用lea di, [eax + STRIDE * n]
等在一个展开的循环中,这可以节省足够的uops,让你每个时钟运行2个负载,而不会在前端出现瓶颈。但是partial-register merging dependencies (on Skylake)会创建多个循环传输的dep链,如果你需要避免重用它们,你就会在32位模式下用完寄存器。
您甚至可以考虑映射低64k的虚拟内存(在Linux上设置vm.mmap_min_addr=0
)和在32位代码中使用16位寻址模式。只读16位寄存器避免了只需写16位的复杂性;最好在上层16结束垃圾。
要在没有16位寻址模式的情况下做得更好,您需要创建条件,让您知道包装不会发生。这允许展开[reg + STRIDE * n]
寻址模式。
您可以编写一个正常的展开循环,当接近环绕点时(即ecx + STRIDE*n > bufsize
时)会爆发,但如果在Skylake上bufsize / STRIDE大于22,则无法预测。
您可以在每次迭代时只进行一次AND屏蔽,并放宽工作集正好为2 ^ n个字节的约束。也就是说,如果你为你的负载预留了足够的空间来超越STRIDE * n - 1
,并且你对缓存行为没有问题,那就去做吧。
如果您仔细选择展开因子,您可以控制每次环绕发生的位置。但是,凭借主要的步幅和2缓冲的力量,我认为你需要展开lcm(stride, bufsize/stride) = stride * bufsize/stride = bufsize
重复模式。对于不适合L1的缓冲区大小,此展开因子太大而不适合uop缓存,甚至不适合L1I。我看了几个小的测试用例,比如n*7 % 16
,它在16次迭代后重复,就像n*5 % 16
和n*3 % 16
一样。并且n*7 % 32
在32次迭代后重复。即,当乘数和模数相对为素数时,线性同余生成器探索小于模数的每个值。
这些选项都不是理想选择,但这是我现在建议的最佳选择。
以上是关于编写一个跨步的x86基准测试的主要内容,如果未能解决你的问题,请参考以下文章