编写一个跨步的x86基准测试

时间:2017-12-24 00:15:19

标签: assembly optimization x86 nasm

我想编写一个加载基准测试,它跨越给定的内存区域,使用编译时已知的步幅,包含在区域的末尾(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

然而,这对于处理包装步幅的addand操作没有帮助。例如,上面的benmark报告了一个含L1区域的0.875个周期/负载,但我们知道正确的答案是0.5(每个周期两个负载)。 0.875来自15个总uops / 4个uop周期,即我们受到uop吞吐量的4宽最大宽度的限制,而不是负载吞吐量。

关于如何有效地展开循环以消除步幅计算的大部分成本的任何想法?

3 个答案:

答案 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 < STRIDEn = (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的数量不是常规的,它将是nn+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 % 16n*5 % 16一样。并且n*3 % 16在32次迭代后重复。即,当乘数和模数相对为素数时,线性同余生成器探索小于模数的每个值。

这些选项都不是理想选择,但这是我现在所能推荐的最好选择。