为什么32字节的循环对齐使代码更快?

gez*_*eza 12 performance benchmarking gcc x86-64 clang

看看这段代码:

one.cpp:

bool test(int a, int b, int c, int d);

int main() {
        volatile int va = 1;
        volatile int vb = 2;
        volatile int vc = 3;
        volatile int vd = 4;

        int a = va;
        int b = vb;
        int c = vc;
        int d = vd;

        int s = 0;
        __asm__("nop"); __asm__("nop"); __asm__("nop"); __asm__("nop");
        __asm__("nop"); __asm__("nop"); __asm__("nop"); __asm__("nop");
        __asm__("nop"); __asm__("nop"); __asm__("nop"); __asm__("nop");
        __asm__("nop"); __asm__("nop"); __asm__("nop"); __asm__("nop");
        for (int i=0; i<2000000000; i++) {
                s += test(a, b, c, d);
        }

        return s;
}
Run Code Online (Sandbox Code Playgroud)

two.cpp:

bool test(int a, int b, int c, int d) {
        // return a == d || b == d || c == d;
        return false;
}
Run Code Online (Sandbox Code Playgroud)

nopone.cpp中有16 秒.您可以对它们进行注释/取消注释,以更改循环在16到32之间的入口点的对齐.我已经使用它们编译它们g++ one.cpp two.cpp -O3 -mtune=native.

这是我的问题:

  1. 32对齐版本比16对齐版本更快.在Sandy Bridge,差异是20%; 在Haswell,8%.为什么有区别?
  2. 对于32对齐版本,代码在Sandy Bridge上运行速度相同,无论哪个返回语句都在two.cpp中.我认为该return false版本至少应该更快一点.但不,速度完全相同!
  3. 如果我volatile从one.cpp中删除s,代码变慢(Haswell:之前:~2.17秒,之后:~2.38秒).这是为什么?但这只会发生,当循环对齐到32时.

32对齐版本更快的事实对我来说很奇怪,因为英特尔®64和IA-32架构优化参考手册说明(第3-9页):

汇编/编译器编码规则12.(M影响,H一般性)所有分支目标应为16字节对齐.

另一个小问题:是否有任何技巧,使只有这个循环32对齐(使代码的其余部分可继续使用16字节对齐)?

注意:我已经尝试过编译器gcc 6,gcc 7和clang 3.9,结果相同.


这是带有volatile的代码(对于16/32对齐的代码是相同的,只是地址不同):

0000000000000560 <main>:
 560:   41 57                   push   r15
 562:   41 56                   push   r14
 564:   41 55                   push   r13
 566:   41 54                   push   r12
 568:   55                      push   rbp
 569:   31 ed                   xor    ebp,ebp
 56b:   53                      push   rbx
 56c:   bb 00 94 35 77          mov    ebx,0x77359400
 571:   48 83 ec 18             sub    rsp,0x18
 575:   c7 04 24 01 00 00 00    mov    DWORD PTR [rsp],0x1
 57c:   c7 44 24 04 02 00 00    mov    DWORD PTR [rsp+0x4],0x2
 583:   00 
 584:   c7 44 24 08 03 00 00    mov    DWORD PTR [rsp+0x8],0x3
 58b:   00 
 58c:   c7 44 24 0c 04 00 00    mov    DWORD PTR [rsp+0xc],0x4
 593:   00 
 594:   44 8b 3c 24             mov    r15d,DWORD PTR [rsp]
 598:   44 8b 74 24 04          mov    r14d,DWORD PTR [rsp+0x4]
 59d:   44 8b 6c 24 08          mov    r13d,DWORD PTR [rsp+0x8]
 5a2:   44 8b 64 24 0c          mov    r12d,DWORD PTR [rsp+0xc]
 5a7:   0f 1f 44 00 00          nop    DWORD PTR [rax+rax*1+0x0]
 5ac:   66 2e 0f 1f 84 00 00    nop    WORD PTR cs:[rax+rax*1+0x0]
 5b3:   00 00 00 
 5b6:   66 2e 0f 1f 84 00 00    nop    WORD PTR cs:[rax+rax*1+0x0]
 5bd:   00 00 00 
 5c0:   44 89 e1                mov    ecx,r12d
 5c3:   44 89 ea                mov    edx,r13d
 5c6:   44 89 f6                mov    esi,r14d
 5c9:   44 89 ff                mov    edi,r15d
 5cc:   e8 4f 01 00 00          call   720 <test(int, int, int, int)>
 5d1:   0f b6 c0                movzx  eax,al
 5d4:   01 c5                   add    ebp,eax
 5d6:   83 eb 01                sub    ebx,0x1
 5d9:   75 e5                   jne    5c0 <main+0x60>
 5db:   48 83 c4 18             add    rsp,0x18
 5df:   89 e8                   mov    eax,ebp
 5e1:   5b                      pop    rbx
 5e2:   5d                      pop    rbp
 5e3:   41 5c                   pop    r12
 5e5:   41 5d                   pop    r13
 5e7:   41 5e                   pop    r14
 5e9:   41 5f                   pop    r15
 5eb:   c3                      ret    
 5ec:   0f 1f 40 00             nop    DWORD PTR [rax+0x0]
Run Code Online (Sandbox Code Playgroud)

没有不稳定:

0000000000000560 <main>:
 560:   55                      push   rbp
 561:   31 ed                   xor    ebp,ebp
 563:   53                      push   rbx
 564:   bb 00 94 35 77          mov    ebx,0x77359400
 569:   48 83 ec 08             sub    rsp,0x8
 56d:   66 0f 1f 84 00 00 00    nop    WORD PTR [rax+rax*1+0x0]
 574:   00 00 
 576:   66 2e 0f 1f 84 00 00    nop    WORD PTR cs:[rax+rax*1+0x0]
 57d:   00 00 00 
 580:   b9 04 00 00 00          mov    ecx,0x4
 585:   ba 03 00 00 00          mov    edx,0x3
 58a:   be 02 00 00 00          mov    esi,0x2
 58f:   bf 01 00 00 00          mov    edi,0x1
 594:   e8 47 01 00 00          call   6e0 <test(int, int, int, int)>
 599:   0f b6 c0                movzx  eax,al
 59c:   01 c5                   add    ebp,eax
 59e:   83 eb 01                sub    ebx,0x1
 5a1:   75 dd                   jne    580 <main+0x20>
 5a3:   48 83 c4 08             add    rsp,0x8
 5a7:   89 e8                   mov    eax,ebp
 5a9:   5b                      pop    rbx
 5aa:   5d                      pop    rbp
 5ab:   c3                      ret    
 5ac:   0f 1f 40 00             nop    DWORD PTR [rax+0x0]
Run Code Online (Sandbox Code Playgroud)

Pet*_*des 7

这并不能回答第 2 点(return a == d || b == d || c == d;return false)。这仍然是一个可能有趣的问题,因为它必须将多个指令行编译为微指令缓存。

\n
\n
\n

32 对齐版本更快这一事实对我来说很奇怪,因为 [Intel 手册说要对齐到 16]

\n
\n

优化指南建议是一个非常通用的指南,绝对并不意味着更大的值永远没有帮助。通常情况下不会,并且填充到 32 可能弊大于利。(I-cache 未命中、ITLB 未命中以及要从磁盘加载的更多代码字节)。

\n

事实上,很少需要 16B 对齐,尤其是在具有 uop 缓存的 CPU 上。对于可以从循环缓冲区运行的小循环,其对齐通常完全无关。

\n

(Skylake 微代码更新禁用了循环缓冲区,以解决部分寄存器 AH 合并错误 SKL150。这会给跨越 32 字节边界的微小循环带来问题,每 2 个时钟只运行一次迭代,而不是每个时钟运行一次迭代。您可能会从 Haswell 上的 6 uop 循环中获得1.5 个时钟,或者在具有较旧微代码的 SKL 上获得。直到 Ice Lake 才重新启用 LSD,在 Kaby/Coffee/Comet Lake 中损坏,它们与 SKL/SKX 的微架构相同。)

\n

另一个 SKL 勘误解决方法造成了另一个更糟糕的代码对齐坑:如何减轻 Intel jcc 勘误对 gcc 的影响?

\n
\n

作为一个广泛的建议,16B 仍然不错,但它并没有告诉您了解几个特定 CPU 上的一个特定情况所需的一切。

\n

编译器通常默认对齐循环分支和函数入口点,但通常不对齐其他分支目标。执行 NOP(和代码膨胀)的成本通常大于未对齐的非循环分支目标的可能成本。

\n
\n

代码对齐有一些直接和一些间接的影响。直接影响包括 Intel SnB 系列上的 uop 缓存。例如,请参阅涉及 Intel SnB 系列 CPU 上微编码指令的循环的分支对齐

\n

Intel 优化手册的另一部分详细介绍了 uop 缓存的工作原理:

\n
\n

2.3.2.2 解码后的 ICache

\n
    \n
  • Way(uop 缓存行)中的所有微操作表示在代码中静态连续的指令,并且其 EIP 在相同的对齐 32 字节区域内。(我认为这意味着\n延伸超过边界的指令进入包含其开始而不是结束的块的uop高速缓存。跨越指令必须\n到达某个地方,并且将运行\n指令的分支目标地址是insn 的开头,因此将其放入该块的行中最有用)。
  • \n
  • 多微操作指令不能跨路分割。
  • \n
  • 打开 MSROM 的指令会消耗整个 Way。
  • \n
  • 每路最多允许有两个分支。
  • \n
  • 一对宏融合指令保留为一个微操作。
  • \n
\n
\n

另请参阅Agner Fog 的微体系结构指南。他补充道:

\n
\n
    \n
  • 无条件跳转或调用总是结束 \xce\xbcop 缓存行
  • \n
  • 许多其他内容可能与这里无关。
  • \n
\n
\n

另外,如果您的代码不适合微指令缓存,它就无法从循环缓冲区运行。

\n
\n

对齐的间接影响包括:

\n
    \n
  • 更大/更小的代码大小(L1I 缓存未命中,TLB)。与您的测试不相关
  • \n
  • 哪些分支在 BTB(分支目标缓冲区)中彼此别名。
  • \n
\n\n
\n

如果我从 one.cpp 中删除volatiles,代码会变得更慢。这是为什么?

\n
\n

较大的指令将最后一条指令推入跨 32B 边界的循环中:

\n
 59e:   83 eb 01                sub    ebx,0x1\n 5a1:   75 dd                   jne    580 <main+0x20>\n
Run Code Online (Sandbox Code Playgroud)\n

因此,如果您不是从循环缓冲区 (LSD) 运行,则在没有volatileuop 缓存提取周期之一的情况下,仅获取 1 uop。

\n

如果 sub/jne 宏融合,这可能不适用。我认为只有跨越 64B 边界才会破坏宏融合。

\n

而且,这些都不是真实的地址。你检查过链接后的地址是什么吗?如果文本部分的对齐方式小于 64B,则链接后可能存在 64B 边界。

\n

同样与 32 字节边界相关的是,JCC 勘误表会在 Skylake CPU 上禁用分支(包括宏融合 ALU+JCC)包含该行最后一个字节的块的 uop 缓存。\n 如何减轻以下影响gcc 上的 Intel jcc 勘误表?

\n
\n

抱歉,我还没有实际测试过这个具体案例。关键是,当您因诸如在紧密循环内有call/之类的东西而成为前端瓶颈时,对齐就变得非常重要并且可能变得极其复杂。是否跨越边界对于所有未来的指令都会受到影响。不要期望它很简单。如果您读过我的其他答案,您就会知道我通常不是那种会说“它太复杂而无法完全解释”的人,但对齐可以是这样。ret

\n

另请参见一个目标文件中的代码对齐正在影响另一目标文件中函数的性能

\n

在你的情况下,确保微小的函数内联。 如果您的代码库在单独的.c文件中而不是在.h可以内联的位置中具有任何重要的微小函数,请使用链接时优化。 或者更改您的代码以将它们放入.h.

\n