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

问题描述 投票:2回答:3

我想编写一个加载基准测试,它使用编译时已知的步幅跨越给定的内存区域,在尽可能少的非加载指令的情况下包裹在区域的末尾(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个总uop / 4个uop周期,即,我们受到uop吞吐量的4宽最大宽度的约束,而不是负载吞吐量。

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

assembly optimization x86 nasm
3个回答
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型号);并且我假设生成一个适合该大小的循环将是最佳的。


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的情况下循环中间部分是有意义的。


1
投票

如果使用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 % 16n*3 % 16一样。并且n*7 % 32在32次迭代后重复。即,当乘数和模数相对为素数时,线性同余生成器探索小于模数的每个值。

这些选项都不是理想选择,但这是我现在建议的最佳选择。

© www.soinside.com 2019 - 2024. All rights reserved.