繁体   English   中英

编写跨步的x86基准测试

[英]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

但是,这对于处理跨步的addand操作没有帮助。 例如,上面的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数量不是规则的,它将是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 ,然后根据需要加倍循环出口,最终达到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 % 16n*3 % 16 并且n*7 % 32在32次迭代后重复32次。 也就是说,当乘数和模数是相对质数时,线性同余生成器会探索每个小于模数的值。

这些选项都不是理想的,但这是我目前可以建议的最佳选择。

暂无
暂无

声明:本站的技术帖子网页,遵循CC BY-SA 4.0协议,如果您需要转载,请注明本站网址或者原文地址。任何问题请咨询:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM