【问题标题】:Writing a strided x86 benchmark编写跨步 x86 基准测试
【发布时间】:2018-06-06 00:40:13
【问题描述】:

我想编写一个负载基准测试,它以编译时已知的跨度跨过给定的内存区域,在该区域的末尾包装(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 操作没有帮助。例如,对于包含 L1 的区域,上述基准报告为 0.875 个周期/负载,但我们知道正确答案是 0.5(每个周期两个负载)。 0.875 来自 15 总 uops / 4 uops 周期,即,我们受限于 uop 吞吐量的 4-wide 最大宽度,而不是负载吞吐量。

知道如何有效地展开循环以消除步幅计算的大部分成本吗?

【问题讨论】:

  • 我想 CPU 最终会与负载并行调度这些指令。
  • @OliverCharlesworth - 有时是的,但它无法隐藏原始示例中计算的延迟,当负载每个吞吐量为 1 个周期时,但通过 ecx 的依赖链需要 2 个周期,所以当区域适合 L1 或 L2 时,实际上是支配循环的算术。展开的版本更好,但我仍然希望指令更​​少(特别是,并非所有拱门都足够宽以并行安排所有其他指令)。
  • 我不确定您是否会受到延迟而不是吞吐量的阻碍 - 因为每次写入 al 都会造成 WAW 危害。
  • 对于某些步幅,您可以循环直到and ecx,MASK 为零,然后使用迭代计数器(如果这是基准测试并且步幅与掩码达到有趣的重复模式)。对于手动编译时跨步,您可以展开循环,直到需要第一个掩码(之前只做add)......然后这个展开的循环与前一个and 上的另一个add 将很快和不准确的猜测足以循环,永远不会离开设计区域,但需要数学证明/验证。
  • @Ped7g - 这是一个非常好的观点。事实上,我选择STRIDE 总是一个较大的素数,所以它总是相对于迭代计数相对素数,所以我可以循环直到ecx 变为零(我需要仔细选择我的展开计数,以便它实际上发生在在迭代结束时,我认为展开因子是迭代计数的除数就足够了)。尴尬的部分是它使代码变得更简单,因为它有一个精确命中的显式迭代计数(没有“尾部处理”循环),但我也许可以适应这种策略。

标签: assembly optimization x86 nasm


【解决方案1】:

对于“绝对最大的疯狂”;您可以要求操作系统在许多虚拟地址上映射相同的页面(例如,相同的 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 型号);我假设生成一个适合该大小的循环是最佳的。

【讨论】:

  • 是的,我也想到了同样的想法(实际上你只需要一点重复映射,然后你可以将它与彼得提到的展开和过度运行缓冲区的方法结合起来:溢出是被您映射的额外重复页面捕获)。我不清楚这是否与性能无关(例如,某些缓存可能一次只能为 L1 中的任何行保留一个虚拟映射),所以我问了about it here
  • 请注意,Skylake 的循环缓冲区 (LSD) 被混合了罕见的部分寄存器合并错误的微码更新禁用。如此有效SKL, SKX, and KBL don't have an LSD, just uop cache. Wikichip says it's re-enabled in Coffee Lake。很可能值得制作一个适合 uop 缓存的循环;虽然 movzx 具有绝对或 RIP-relative 寻址模式小于 8 字节,所以前端长度预解码 + 解码瓶颈可能正好是 2 IPC,匹配最大负载吞吐量。
  • 顺便说一句,使循环足够小以从 LSD 运行并不总是一种胜利(出于非基准测试目的),因为它们将运行如此多的迭代,以至于展开是值得整个代码大小的成本。 uop 缓存相当不错,尤其是在 SKL 及更高版本中。但是对于小循环来说,28 uop 通常就足够了。 (这是任何具有 LSD 的 CPU 中最小的。SKL 是 64 微指令,无论是否启用 HT。)
  • @PeterCordes:LSD 会引起奇怪的(“致幻的”)部分注册效应…… ;-)
【解决方案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:

在需要and之前发生的add的数量是不规则的,它将是nn+1,所以展开循环的结尾加倍,以ecx &lt; 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 的情况下循环中间部分是有意义的。

【讨论】:

    【解决方案3】:

    如果您使用 AVX2 收集来生成负载微指令,则可以使用 SIMD 进行加法 + AND。不过,这可能不是您在尝试测量有关非收集负载的任何内容时想要的!

    如果您的区域大小为 2^16..19,您可以使用add ax, dx(使用 DX=stride 以避免 LCP 停顿)免费获得 2^16 的换行。使用eax 作为缩放索引。在展开循环中使用lea di, [eax + STRIDE * n] 等,这可以节省足够的微指令,让您每个时钟运行 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 &gt; 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 次迭代后重复。即,当乘数和模数互质时,线性同余生成器会探索小于模数的每个值。

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

    【讨论】:

      猜你喜欢
      • 2016-02-05
      • 1970-01-01
      • 2010-10-05
      • 2016-03-08
      相关资源
      最近更新 更多