我想编写一个加载基准测试,它跨越给定的内存区域,使用编译时已知的步幅,包含在区域的末尾(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个总uops / 4个uop周期,即我们受到uop吞吐量的4宽最大宽度的限制,而不是负载吞吐量。
关于如何有效地展开循环以消除步幅计算的大部分成本的任何想法?
答案 0 :(得分:2)
“绝对最大的疯狂”;您可以要求操作系统在许多虚拟地址映射相同的页面(例如,因此相同的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型号);并且我假设生成一个适合该大小的循环将是最佳的。
答案 1 :(得分:2)
关于那个数学。证明...在展开循环开始时,如果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的情况下循环中间部分是有意义的。
答案 2 :(得分:1)
如果使用AVX2聚集生成加载uops,则可以使用SIMD进行加+ AND。但是,在尝试测量关于非聚集负载的任何事情时,这可能不是你想要的!
如果您的区域大小为2 ^ 16..19,您可以使用add ax, dx
(使用DX =步幅以避免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缓冲的强大功能,我认为您需要展开{{1}}才能重复该模式。对于不适合L1的缓冲区大小,此展开因子太大而无法容纳在uop缓存中,甚至不适合L1I。我查看了几个小的测试用例,例如lcm(stride, bufsize/stride) = stride * bufsize/stride = bufsize
,它在16次迭代后重复,就像n*7 % 16
和n*5 % 16
一样。并且n*3 % 16
在32次迭代后重复。即,当乘数和模数相对为素数时,线性同余生成器探索小于模数的每个值。
这些选项都不是理想选择,但这是我现在所能推荐的最好选择。