[英]Writing a strided x86 benchmark
我想编写一个负载基准,以已知的编译时跨度跨给定的内存区域,并以尽可能少的非负载指令包装在区域的末尾(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周期依赖链。
我们可以稍微展开一下,以将循环终止检查成本降低到接近零,并分解依赖关系链(此处展开4倍):
.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 uops周期,即,我们受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,有一个循环而不是完全展开可能更快(在指令获取和解码成本与循环开销之间进行折衷)。 对于较新的Intel 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:
之前and
需要发生的add
数量不是规则的,它将是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
,然后根据需要加倍循环出口,最终达到32nd add
。
尽管在您的31.9的情况下看起来毫无意义,但在类似数百+ = SIZE div STRIDE的情况下循环中间部分是有意义的。
如果使用AVX2收集来生成载荷,则可以将SIMD用于添加+与。 但是,当尝试测量有关非聚集负载的任何内容时,这可能并不是您想要的!
如果您的区域大小为2 ^ 16..19,则可以使用add ax, dx
(DX = stride以避免LCP停顿)免费获得2 ^ 16的环绕效果。 使用eax
作为缩放索引。 使用lea di, [eax + STRIDE * n]
等在展开的循环中,这样可以节省足够的微指令,使您每个时钟运行2个负载而不会在前端造成瓶颈。 但是, 部分寄存器合并依赖项(在Skylake上)将创建多个循环传送的dep链,如果需要避免重复使用它们,则会以32位模式用尽寄存器。
您甚至可以考虑映射低64k的虚拟内存(在Linux上设置vm.mmap_min_addr=0
),并在32位代码中使用16位寻址模式。 仅读取16位寄存器避免了仅写入16位的麻烦。 最好在鞋帮16中放入垃圾。
为了在没有16位寻址模式的情况下做得更好,您需要创建知道无法发生换行的条件 。 这允许使用[reg + STRIDE * n]
寻址模式展开。
您可以编写一个正常的展开循环,该循环在接近折返点时会中断(例如,当ecx + STRIDE*n > bufsize
),但是如果bufsize / STRIDE在Skylake上大于约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次迭代后重复32次。 也就是说,当乘数和模数是相对质数时,线性同余生成器会探索每个小于模数的值。
这些选项都不是理想的,但这是我目前可以建议的最佳选择。
声明:本站的技术帖子网页,遵循CC BY-SA 4.0协议,如果您需要转载,请注明本站网址或者原文地址。任何问题请咨询:yoyou2525@163.com.