【问题标题】:Why does adding an "if" to a C++ code make it significantly faster?为什么在 C++ 代码中添加“if”会显着加快速度?
【发布时间】:2022-01-07 11:49:43
【问题描述】:

注意:问题底部有更新)

看看这个精简的 small_vector 基准测试:

#include <cstdlib>

#define NOINLINE __attribute__((noinline))

class Array {
public:
    Array() : m_data(m_buffer), m_size(0) {}
    ~Array() {
        // if (m_data != m_buffer) free(m_data);
    }

    void append(int v) {
        if (m_size >= 3) expand();
        m_data[m_size++] = v;
    }

private:
    int *m_data;
    int m_size;
    int m_buffer[3];

    NOINLINE void expand() {}
};

NOINLINE
void add(Array &array) {
    array.append(11);
    array.append(22);
    array.append(33);
}

int main() {
    for (int i = 0; i < 1000000000; i++) {
        Array array;
        add(array);
    }
}

(使用 NOINLINE 是因为编译器决定内联原始 small_vector 代码的方式)

如果这段代码是用 clang 11 编译的,如果我取消注释 ~Array 中的注释行,它会变得更快(注意,free 调用永远不会执行)。在我的机器(i7 8750)上,差异是 18%。在quick-bench.com 上,差异更小,为 5.3%。

我知道这是一个微基准测试,可能会发生疯狂的事情,但是:add 是一个 37 条指令、120 字节的代码,所以它不是那么小。并且在两个版本中都是一样的。唯一的区别是main,也没有什么不同,只是编译的循环有点不同。然而,性能差异很大,具有更多指令/分支的版本运行速度更快。如果我运行perf stat -d -d -d,我看不到任何可疑的东西(分支/缓存未命中没有显着差异,但insn/循环差异仍然很大:2.4 vs 3.12):

       3939.23 msec task-clock                #    1.000 CPUs utilized
            10      context-switches          #    0.003 K/sec
             0      cpu-migrations            #    0.000 K/sec
           107      page-faults               #    0.027 K/sec
   13345669446      cycles                    #    3.388 GHz                      (38.26%)
   32029499558      instructions              #    2.40  insn per cycle           (45.98%)
    6005787151      branches                  # 1524.610 M/sec                    (45.99%)
         71062      branch-misses             #    0.00% of all branches          (46.09%)
    6000238616      L1-dcache-loads           # 1523.202 M/sec                    (46.20%)
        180237      L1-dcache-load-misses     #    0.00% of all L1-dcache accesses  (46.30%)
         35516      LLC-loads                 #    0.009 M/sec                    (30.87%)
         13655      LLC-load-misses           #   38.45% of all LL-cache accesses  (30.87%)
not supported       L1-icache-loads
        545548      L1-icache-load-misses                                         (30.87%)
    6003584439      dTLB-loads                # 1524.051 M/sec                    (30.86%)
          5290      dTLB-load-misses          #    0.00% of all dTLB cache accesses  (30.76%)
          4583      iTLB-loads                #    0.001 M/sec                    (30.65%)
          4222      iTLB-load-misses          #   92.12% of all iTLB cache accesses  (30.55%)
not supported       L1-dcache-prefetches
not supported       L1-dcache-prefetch-misses

   3.939756460 seconds time elapsed

   3.939678000 seconds user
   0.000000000 seconds sys

快速

       3316.00 msec task-clock                #    1.000 CPUs utilized
             5      context-switches          #    0.002 K/sec
             0      cpu-migrations            #    0.000 K/sec
           110      page-faults               #    0.033 K/sec
   11235910328      cycles                    #    3.388 GHz                      (38.24%)
   35013565821      instructions              #    3.12  insn per cycle           (45.96%)
    7002622651      branches                  # 2111.770 M/sec                    (45.96%)
         59596      branch-misses             #    0.00% of all branches          (46.02%)
    7001546754      L1-dcache-loads           # 2111.446 M/sec                    (46.14%)
        143554      L1-dcache-load-misses     #    0.00% of all L1-dcache accesses  (46.26%)
         20608      LLC-loads                 #    0.006 M/sec                    (30.88%)
          3562      LLC-load-misses           #   17.28% of all LL-cache accesses  (30.88%)
not supported       L1-icache-loads
        431694      L1-icache-load-misses                                         (30.88%)
    7003243717      dTLB-loads                # 2111.958 M/sec                    (30.88%)
          3296      dTLB-load-misses          #    0.00% of all dTLB cache accesses  (30.82%)
          2836      iTLB-loads                #    0.855 K/sec                    (30.70%)
          3436      iTLB-load-misses          #  121.16% of all iTLB cache accesses  (30.58%)
not supported       L1-dcache-prefetches
not supported       L1-dcache-prefetch-misses

   3.316414943 seconds time elapsed

   3.312479000 seconds user
   0.003995000 seconds sys

注意:如果我在main 中手动展开循环到此:

    for (int i = 0; i < 250000000; i++) {
        {Array array; add(array);}
        {Array array; add(array);}
        {Array array; add(array);}
        {Array array; add(array);}
    }

性能差异保持不变。

您知道造成这种差异的原因是什么吗?有什么提示可以检查perf 事件吗?

作为参考,这里是 asm 列表:

慢主

  401190:   55                      push   rbp
  401191:   41 56                   push   r14
  401193:   53                      push   rbx
  401194:   48 83 ec 20             sub    rsp,0x20
  401198:   bd 00 ca 9a 3b          mov    ebp,0x3b9aca00
  40119d:   4c 8d 74 24 14          lea    r14,[rsp+0x14]
  4011a2:   48 8d 5c 24 08          lea    rbx,[rsp+0x8]
  4011a7:   66 0f 1f 84 00 00 00    nop    WORD PTR [rax+rax*1+0x0]
  4011ae:   00 00 
  4011b0:   4c 89 74 24 08          mov    QWORD PTR [rsp+0x8],r14
  4011b5:   c7 44 24 10 00 00 00    mov    DWORD PTR [rsp+0x10],0x0
  4011bc:   00 
  4011bd:   48 89 df                mov    rdi,rbx
  4011c0:   e8 4b ff ff ff          call   401110 <add(Array&)>
  4011c5:   83 c5 ff                add    ebp,0xffffffff
  4011c8:   75 e6                   jne    4011b0 <main+0x20>
  4011ca:   31 c0                   xor    eax,eax
  4011cc:   48 83 c4 20             add    rsp,0x20
  4011d0:   5b                      pop    rbx
  4011d1:   41 5e                   pop    r14
  4011d3:   5d                      pop    rbp
  4011d4:   c3                      ret    

快速主

  4011b0:   55                      push   rbp
  4011b1:   41 56                   push   r14
  4011b3:   53                      push   rbx
  4011b4:   48 83 ec 20             sub    rsp,0x20
  4011b8:   bd 00 ca 9a 3b          mov    ebp,0x3b9aca00
  4011bd:   48 8d 5c 24 14          lea    rbx,[rsp+0x14]
  4011c2:   4c 8d 74 24 08          lea    r14,[rsp+0x8]
  4011c7:   eb 0c                   jmp    4011d5 <main+0x25>
  4011c9:   0f 1f 80 00 00 00 00    nop    DWORD PTR [rax+0x0]
  4011d0:   83 c5 ff                add    ebp,0xffffffff
  4011d3:   74 26                   je     4011fb <main+0x4b>
  4011d5:   48 89 5c 24 08          mov    QWORD PTR [rsp+0x8],rbx
  4011da:   c7 44 24 10 00 00 00    mov    DWORD PTR [rsp+0x10],0x0
  4011e1:   00 
  4011e2:   4c 89 f7                mov    rdi,r14
  4011e5:   e8 46 ff ff ff          call   401130 <add(Array&)>
  4011ea:   48 8b 7c 24 08          mov    rdi,QWORD PTR [rsp+0x8]
  4011ef:   48 39 df                cmp    rdi,rbx
  4011f2:   74 dc                   je     4011d0 <main+0x20>
  4011f4:   e8 37 fe ff ff          call   401030 <free@plt>
  4011f9:   eb d5                   jmp    4011d0 <main+0x20>
  4011fb:   31 c0                   xor    eax,eax
  4011fd:   48 83 c4 20             add    rsp,0x20
  401201:   5b                      pop    rbx
  401202:   41 5e                   pop    r14
  401204:   5d                      pop    rbp
  401205:   c3                      ret    

添加

(慢速和快速相同)

  401130:   53                      push   rbx
  401131:   48 89 fb                mov    rbx,rdi
  401134:   8b 4f 08                mov    ecx,DWORD PTR [rdi+0x8]
  401137:   83 f9 03                cmp    ecx,0x3
  40113a:   7c 0b                   jl     401147 <add(Array&)+0x17>
  40113c:   48 89 df                mov    rdi,rbx
  40113f:   e8 cc 00 00 00          call   401210 <Array::expand()>
  401144:   8b 4b 08                mov    ecx,DWORD PTR [rbx+0x8]
  401147:   48 8b 03                mov    rax,QWORD PTR [rbx]
  40114a:   8d 51 01                lea    edx,[rcx+0x1]
  40114d:   89 53 08                mov    DWORD PTR [rbx+0x8],edx
  401150:   48 63 c9                movsxd rcx,ecx
  401153:   c7 04 88 0b 00 00 00    mov    DWORD PTR [rax+rcx*4],0xb
  40115a:   8b 4b 08                mov    ecx,DWORD PTR [rbx+0x8]
  40115d:   83 f9 03                cmp    ecx,0x3
  401160:   7c 0e                   jl     401170 <add(Array&)+0x40>
  401162:   48 89 df                mov    rdi,rbx
  401165:   e8 a6 00 00 00          call   401210 <Array::expand()>
  40116a:   8b 4b 08                mov    ecx,DWORD PTR [rbx+0x8]
  40116d:   48 8b 03                mov    rax,QWORD PTR [rbx]
  401170:   8d 51 01                lea    edx,[rcx+0x1]
  401173:   89 53 08                mov    DWORD PTR [rbx+0x8],edx
  401176:   48 63 c9                movsxd rcx,ecx
  401179:   c7 04 88 16 00 00 00    mov    DWORD PTR [rax+rcx*4],0x16
  401180:   8b 4b 08                mov    ecx,DWORD PTR [rbx+0x8]
  401183:   83 f9 03                cmp    ecx,0x3
  401186:   7c 0e                   jl     401196 <add(Array&)+0x66>
  401188:   48 89 df                mov    rdi,rbx
  40118b:   e8 80 00 00 00          call   401210 <Array::expand()>
  401190:   8b 4b 08                mov    ecx,DWORD PTR [rbx+0x8]
  401193:   48 8b 03                mov    rax,QWORD PTR [rbx]
  401196:   8d 51 01                lea    edx,[rcx+0x1]
  401199:   89 53 08                mov    DWORD PTR [rbx+0x8],edx
  40119c:   48 63 c9                movsxd rcx,ecx
  40119f:   c7 04 88 21 00 00 00    mov    DWORD PTR [rax+rcx*4],0x21
  4011a6:   5b                      pop    rbx
  4011a7:   c3                      ret    

更新

我设法使差异更大,将main 更改为这个(这只是添加了mallocfree 调用):

void *d;
int main() {
    d = malloc(1);
    for (int i = 0; i < 1000000000; i++) {
        Array array;
        add(array);
    }
    free(d);
}

有了这个main,慢版变得更慢了,慢版和快版的差距是~30%!

【问题讨论】:

  • 您的 CPU 是 Skylake 系列,因此 JCC 错误可能是前端吞吐量较低的原因。值得检查idq.mite_uops 事件以查看uops_issued.any 的很大一部分是否来自旧版解码而不是uop 缓存。如果是这样,请寻找任何跨越或触及 32 字节边界底部的分支。 32-byte aligned routine does not fit the uops cache。 (和/或尝试编译器/汇编器选项来避免问题:How can I mitigate the impact of the Intel jcc erratum on gcc?
  • @EOF: 1) 我更喜欢反汇编,因为它有 intel 语法,而且代码对齐也是可见的。 2)是的,(-03)。 3)我会检查 coz,谢谢,但我认为加速与更改有关(我不确定在这种情况下因果意味着什么,但我看到 coz 也使用这个术语,所以我想我会如果我阅读文档了解含义)。
  • gcc -masm=intel -S 使用与 objdump -d -Mintel 相同的 GAS .intel_syntax noprefix。但是,是的,在这种情况下,反汇编实际上是好的,因为一些微架构效果与代码对齐有关。回复:“因果关系”:这只是一个英语/科学词,而不是基准术语。 “因果关系”的意思是“一件事是由另一件事引起的”,而不是“这些事情一起发生,由第三件事引起”。 (您可能听说过“相关性不是因果关系”;EOF 建议排除基准方法错误以实际检查因果关系)。
  • 彼得,再次感谢! @EOF我不确定我们是否可以说加速是严格随意地与更改相关的。但是,尽管如此,我们有两个几乎相同的程序,它们应该以相似的速度运行。我很好奇速度差异的原因。
  • clang 10+ 支持-mbranches-within-32B-boundaries。所以你可以试试快速排除JCC勘误。

标签: c++ performance assembly x86 x86-64


【解决方案1】:

问题来自于两个结合的问题

  • JCC 勘误表
  • 堆栈中局部变量的对齐方式

确实,我可以在我的 i5-9600KF Skylake 处理器上重现该问题,与@PeterCordes 在 cmets 中指出的快速版本相比,慢版本的事件 idq.mite_uops 相当大。使用 Clang 标志 -mbranches-within-32B-boundaries 似乎可以解决这个问题,因为 idq.mite_uops 会小得多。但是,这还不足以消除两个版本之间的差异。

此外,像 mov DWORD PTR [rbx+0x8],edxmov ecx,DWORD PTR [rdi+0x8] 这样的指令在慢速版本中明显变慢。这三个mov DWORD PTR [rax+rcx*4],xxx 在两个版本中都需要大量时间,尤其是在较慢的版本中。可以注意到rax+rcx*4 指向主函数的堆栈。事实证明,rbxrbx+0x8 在慢速版本中指向两个不同的缓存行,而在快速版本中它们指向同一缓存行。此外,rax+rcx*4 在两个版本中都指向rbx+0x8 的同一缓存行。使用 Clang 标志 -mstackrealign 修复了我机器上两个版本之间的差异(结果时间比快速版本慢但比慢速版本快)。但是,对于具有此标志的两个版本,对齐方式都非常糟糕。更好的替代方法是简单地将int *m_data 替换为alignas(64) int *m_data(使用this 以获得更便携的代码)。

            flags / config                       |  "slow"  |  "fast"
nothing                                          |   2,950  |   2,418
-mbranches-within-32B-boundaries                 |   2,868  |   2,472
-mstackrealign                                   |   2,669  |   2,833
-mstackrealign -mbranches-within-32B-boundaries  |   2,701  |   2,725
alignas(64)                                      |   2,585  |   2,583
-mbranches-within-32B-boundaries alignas(64)     |   2,497  |   2,499

【讨论】:

  • 在第一个成员上使用alignas(64) 为类提供 64 字节对齐对于 6-int 结构来说似乎是多余的(假设指针的宽度为 2 个整数);它小于 32 字节,因此 alignas(32) 将确保单个此类结构永远不会跨缓存行边界拆分。
  • (我忘记了这个问题的细节,但是这里有多线程吗?如果没有,我不认为hardware_destructive_interference_size 是相关的。那将是 SnB-family 上的 128对于像相邻行 L2 预取这样的东西,如果编译器选择定义它;他们不会这样做,因为如果按预期使用它会成为 ABI 的一部分,但这会破坏让它与当前硬件相关的目的。)
  • 感谢您的回答!我尝试对齐,它确实加快了慢速版本。但是,我对此并不完全确定。如果我故意错位m_data(例如,在int *m_data 之前添加alignas(64) char pad[8]; 成员),程序仍然运行得很快。我尝试了所有的填充 N*8 (N=0..7),它总是跑得很快。另外,必要的数据应该一直在缓存中。如果将缓存连续写入与同一缓存行相反的不同缓存行,是否有任何已知的惩罚?
  • @PeterCordes 感谢您提供的信息。实际上 32 字节对齐就足够了。对于hardware_destructive_interference_size,这只是因为这是我知道的在 C++ 中以可移植方式获得多个缓存行大小的唯一方法。我虽然在所有 x64 架构上都是 64。
  • @geza 商店转发等复杂的事情可能会起作用。不过,我没有成功找到使用perf 硬件事件进行这种硬件优化的证据。我最初也在考虑依赖问题。问题是我没有找到任何硬件计数器准确地清楚地显示 Skylake 问题的根源......我发现最好的是有很多说明停止(在后端 AFAIR 中)并且减速的来源来自内存 mov 指令。
猜你喜欢
  • 2011-01-28
  • 1970-01-01
  • 2019-05-13
  • 2020-05-21
  • 1970-01-01
  • 2017-10-07
  • 1970-01-01
  • 1970-01-01
  • 2020-05-15
相关资源
最近更新 更多