编写一个跨平台的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

但是,这对于addand操作处理包装的步伐没有帮助。 例如,上述benmark报告一个L1包含区域的0.875次循环/负载,但我们知道正确的答案是0.5(每个循环两次负载)。 0.875来自15个总uops / 4个uops周期,也就是说,我们受uop吞吐量的4个最大宽度的约束,而不是负载吞吐量。

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


对于“绝对最大的疯狂”; 您可以要求操作系统在许多虚拟地址上映射相同的页面(例如,在虚拟地址0x100000000,0x11000000,0x12000000,0x13000000 ......处出现相同的16MB的RAM),以避免需要关注包装; 并且您可以使用自我生成的代码来避免所有其他事情。 基本上,生成如下所示的指令的代码:

    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:

发生之前and发生的add量不规则,它会是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的情况下循环中间部分是有意义的。


如果您使用AVX2收集来生成加载uops,则可以使用SIMD进行加+ AND。 虽然这可能不是你想要测量任何关于非聚集负载的东西,但是!

如果您的区域大小为2 ^ 16..19,则可以使用add ax, dx (以DX =步幅来避免LCP失速)以免费换行2 ^ 16。 使用eax作为缩放索引。 通过展开循环中的lea di, [eax + STRIDE * n]等,这可以节省足够的uops,让您在每个时钟运行2个负载,而不会在前端出现瓶颈。 但是部分寄存器合并依赖关系(在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,则不能很好地预测。

您可以简单地在每次迭代中仅执行一次“与”屏蔽,并放宽工作集恰好为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 。 在32次迭代之后, n*7 % 32重复。 即线性同余发生器在乘数和模量相对为素数时探测每个小于模量的值。

这些选项都不是理想的,但这是我现在可以提出的最好的选择。

链接地址: http://www.djcxy.com/p/85933.html

上一篇: Writing a strided x86 benchmark

下一篇: Significant FMA performance anomaly experienced in the Intel Broadwell processor