为什么添加 if(!memcmp()) 会加速一个循环,该循环在一个巨大的字节数组中随机短步前进?

Why does adding an if(!memcmp()) speed up a loop that makes random short strides through a huge byte array?

抱歉,我一直没看懂这里的规矩。我已经删除了我 post 编辑的所有重复 post。这是第一个相关问题。 请不要将此 post 标记为与我的另一个 () 重复,即使代码有些相似,但它们提出的差异很大 questions.These 也是我在同一天。类似的post已经被“误判”重复,然后关闭。可能是我没有把这个问题说清楚。非常希望得到答案,所以重新post编辑了一下。希望大家看清楚问题,万分感谢!

在下面的C代码中,我在第一次测试的循环中加入了if语句,执行次数完全一样。从理论上讲,它应该更慢。即使分支预测可以使它们的性能几乎相同,但它实际上变得更快了很多。这是什么原理?我尝试在 Mac 和 Linux 环境中分别使用 clang 和 gcc 编译器来 运行 ,并尝试了各种优化级别。为了不影响缓存,我让快的先执行,但是有冗余代码的循环执行得更快

如果您认为我的描述不可信,请将以下代码编译到您的计算机中并运行。希望有人能帮我解答这个问题,谢谢

C代码:

#include <stdio.h>
#include <time.h>
#include <stdlib.h>
#include <string.h>

#define TLen 300000000
#define SLen 10

int main(int argc, const char * argv[]) {
    srandom((unsigned)time(NULL));
    
    // An array to increase the index,
    // the range of its elements is 1-256
    int rand_arr[128];
    for (int i = 0; i < 128; ++i)
        rand_arr[i] = random()%256+1;
    
    // A random text(very long), the range of its elements is 0-127
    char *tex = malloc((sizeof *tex) * TLen);
    for (int i = 0; i < TLen; ++i)
        tex[i] = random()%128;
    
    // A random string(very short))
    char *str = malloc((sizeof *str) * SLen);
    for (int i = 0; i < SLen; ++i)
        str[i] = random()%128;
    
    // The first testing
    clock_t start = clock();
    for (int i = 0; i < TLen; ){
        if (!memcmp(tex+i, str, SLen)) printf("yes!\n");
        i += rand_arr[tex[i]];
    }
    clock_t end = clock();
    printf("No.1: %lf s\n", ((double)(end - start)) / CLOCKS_PER_SEC);
    
    // The second testing
    start = clock();
    for (int i = 0; i < TLen; ){
        i += rand_arr[tex[i]];
    }
    end = clock();
    printf("No.2: %lf s\n", ((double)(end - start)) / CLOCKS_PER_SEC);
    
    return 0;
}

我已经运行上百次了,几乎都是这个比例。以下是Linux中的一个测试的代表性结果:

No.1: 0.110000 s
No.2: 0.140000 s

关键瓶颈是作为从旧 i 到新 i 的依赖链的一部分跨越 tex[] 时缓存未命中的加载使用延迟。来自小 rand_array[] 的查找将命中缓存,并且只会将大约 5 个周期的 L1d 加载使用延迟添加到循环携带的依赖链中,这使得除了通过 i 的延迟之外的任何事情都更不可能发生与循环的整体速度有关。 ( 在您之前的问题中讨论了延迟 dep 链和随机步幅增量。)

memcmp(tex+i, str, SLen) 可能 充当预取 如果 tex+i 接近缓存行的末尾。如果您使用 -O0 进行编译,您将调用 glibc memcmp,因此在 glibc memcmp 中完成的 SSE 16 字节或 AVX2 32 字节加载会跨越缓存行边界并拉入下一行。 (IIRC,glibc memcmp 检查并避免 page 交叉,但不是高速缓存行拆分,因为这些在现代 CPUs 上相对便宜。单步进入它可以看到。进入第二次调用以避免惰性动态链接,或使用 -fno-plt).

编译

可能触及一些本应被随机跨步跳过的高速缓存行有助于硬件预取更加积极并将其检测为顺序访问模式。 32 字节的 AVX 加载是缓存行的一半,因此它很可能会进入下一个缓存行。

因此,L2 缓存看到的更预测的table 缓存行请求序列是 memcmp 版本速度更快的合理解释。(英特尔 CPUs 将主要的预取器放在 L2 中。)

如果你优化编译,那10字节的memcmp内联并且在内部循环中只触及8字节。 (如果它们匹配,然后 它跳转到另一个块,在那里它检查最后 2 个。但它极不可能发生。

这可能就是为什么 -O0 在我的系统上比 -O3 快的原因,在 i7-6700k Skylake 和 DDR4-2666 DRAM 上使用 GCC11.1 , 在 Arch GNU/Linux 上。 (您的问题没有详细说明您的数字来自哪个编译器和选项,或者哪个硬件。)

$ gcc -O3 array-stride.c
$ taskset -c 3 perf stat --all-user -etask-clock,context-switches,cpu-migrations,page-faults,cycles,instructions,uops_issued.any,uops_executed.thread,mem_load_retired.l1_hit,mem_load_retired.l1_miss -r 2 ./a.out 
No.1: 0.041710 s
No.2: 0.063072 s
No.1: 0.040457 s
No.2: 0.061166 s

 Performance counter stats for './a.out' (2 runs):

          1,843.71 msec task-clock                #    0.999 CPUs utilized            ( +-  0.14% )
                 0      context-switches          #    0.000 /sec                   
                 0      cpu-migrations            #    0.000 /sec                   
            73,300      page-faults               #   39.757 K/sec                    ( +-  0.00% )
     6,607,078,798      cycles                    #    3.584 GHz                      ( +-  0.06% )
    18,614,303,386      instructions              #    2.82  insn per cycle           ( +-  0.00% )
    19,407,689,229      uops_issued.any           #   10.526 G/sec                    ( +-  0.02% )
    21,970,261,576      uops_executed.thread      #   11.916 G/sec                    ( +-  0.02% )
     5,408,090,087      mem_load_retired.l1_hit   #    2.933 G/sec                    ( +-  0.00% )
         3,861,687      mem_load_retired.l1_miss  #    2.095 M/sec                    ( +-  2.11% )

           1.84537 +- 0.00135 seconds time elapsed  ( +-  0.07% )

$ grep . /sys/devices/system/cpu/cpufreq/policy*/energy_performance_preference
/sys/devices/system/cpu/cpufreq/policy0/energy_performance_preference:balance_performance
  ... same on all 8 logical cores

perf counter数据基本没有意义;这是 whole 运行 时间,而不是计时区域(1.8 秒中大约有 0.1 秒),所以这里花费的大部分时间都在 glibc random(3),它使用“非线性加性反馈随机数生成器,使用默认的 table 大小为 31 的长整数 ”。也在大 malloc 区域的页面错误中。

它只是在两个构建之间的增量方面很有趣,但是定时区域之外的循环仍然贡献了许多额外的微指令,所以它仍然没有人们想要的那么有趣。


对比gcc -O0: No.1 更快,No.2 正如预期的那样慢一点,-O0 将 store/reload 放入涉及 i 的依赖链中。

$ gcc -O0 array-stride.c
$ taskset -c 3 perf stat --all-user -etask-clock,context-switches,cpu-migrations,page-faults,cycles,instructions,uops_issued.any,uops_executed.thread,mem_load_retired.l1_hit,mem_load_retired.l1_miss -r 2 ./a.out 
No.1: 0.028402 s
No.2: 0.076405 s
No.1: 0.028079 s
No.2: 0.069492 s

 Performance counter stats for './a.out' (2 runs):

          1,979.57 msec task-clock                #    0.999 CPUs utilized            ( +-  0.04% )
                 0      context-switches          #    0.000 /sec                   
                 0      cpu-migrations            #    0.000 /sec                   
            66,656      page-faults               #   33.672 K/sec                    ( +-  0.00% )
     7,252,728,414      cycles                    #    3.664 GHz                      ( +-  0.02% )
    20,507,166,672      instructions              #    2.83  insn per cycle           ( +-  0.01% )
    22,268,130,378      uops_issued.any           #   11.249 G/sec                    ( +-  0.00% )
    25,117,638,171      uops_executed.thread      #   12.688 G/sec                    ( +-  0.00% )
     6,640,523,801      mem_load_retired.l1_hit   #    3.355 G/sec                    ( +-  0.01% )
         3,350,518      mem_load_retired.l1_miss  #    1.693 M/sec                    ( +-  1.39% )

         1.9810591 +- 0.0000934 seconds time elapsed  ( +-  0.00% )

请记住此分析是针对 运行 时间,而不是定时区域,因此 2.83 IPC 不适用于定时区域。


乱序执行隐藏了独立工作的成本

运行ning 宏融合 cmp/je uop 的实际吞吐量成本不会增加任何瓶颈,这要归功于乱序执行。甚至整个 call memcmp@plt 并设置参数。瓶颈是延迟,不是前端,也不是后端的加载端口,而且 OoO exec window 足够深,可以隐藏 memcmp 工作。另请参阅以下内容以了解现代 CPUs。 (是的,需要大量阅读才能理解它们。)

通过 i 的循环携带依赖性变为 i -> tex[i] -> 间接返回到 i 以进行下一次迭代,单调递增。 tex[] 太大而无法放入缓存,硬件预取无法跟上这些步伐。

因此 DRAM 带宽可能是限制因素,如果 HW 预取未检测到并锁定到连续或每隔一个缓存行的某种顺序访问模式,甚至是 DRAM 延迟。

实际分支预测完美,因此它可以在加载结果到达时执行(并确认预测),与等待从同一位置开始的符号扩展字节加载的内容并行。


and the execution times are exactly the same. ... it actually becomes a lot faster.

嗯?您的时间显示不完全相同。是的,memcmp.

它确实变得更快

In theory, it should be slower.

仅当您的理论过于简单而无法模拟现代 CPU 时。不减慢它的速度很容易解释,因为在等待加载延迟的同时,有多余的无序执行吞吐量来完成独立的工作。

  • What considerations go into predicting latency for operations on modern superscalar processors and how can I calculate them by hand?
  • How many CPU cycles are needed for each assembly instruction?(这不是性能的工作方式;CPUs 重叠多条指令的执行;没有单一的数字成本可以分配给每条指令,然后将它们相加)。

也可能与 -O0 性能相关:

  • - Sandybridge-family 存储转发具有可变延迟,不要过早尝试实际上可以让您实现 更好 延迟,即减少瓶颈。但我认为 -O0 memcmp 更快的主要原因是更大负载的预取效应。

作为参考,来自 GCC on Godbolt 的 with-memcmp 内部循环与我在桌面上进行的基准测试相匹配。

# gcc11.1 -O0 -fPIE
.L10:                                           # do{
        mov     eax, DWORD PTR -16[rbp]
        movsx   rdx, eax
        mov     rax, QWORD PTR -32[rbp]
        lea     rcx, [rdx+rax]
        mov     rax, QWORD PTR -40[rbp]
        mov     edx, 10
        mov     rsi, rax
        mov     rdi, rcx
        call    memcmp@PLT
        test    eax, eax
        jne     .L9                             # normally taken jump over puts
        lea     rax, .LC0[rip]
        mov     rdi, rax
        call    puts@PLT
.L9:
        mov     eax, DWORD PTR -16[rbp]
        movsx   rdx, eax
        mov     rax, QWORD PTR -32[rbp]
        add     rax, rdx                       # strange not using an addressing mode, but ok
        movzx   eax, BYTE PTR [rax]
        movsx   eax, al                        # GCC -O0 is dumb,
        cdqe                                   # really dumb.
        mov     eax, DWORD PTR -576[rbp+rax*4]   # look up in rand_array[]
        add     DWORD PTR -16[rbp], eax          # i += ...
.L8:
        cmp     DWORD PTR -16[rbp], 299999999
        jle     .L10                             # }while(i<300000000)

对比-O3 代码:

# RBP holds  char *tex at at this point
.L10:                                        # do{
        movsx   r12, r13d                     # sign-extend i
        mov     rax, QWORD PTR [rbx]          # str[0..7] gets reloaded because alias analysis and the possible call to puts defeated the optimizer.  Avoiding malloc for it may have helped.
        add     r12, rbp                      # i+tex to avoid indexed addressing modes later?  Probably not optimal
        cmp     QWORD PTR [r12], rax          # first 8 bytes of the memcmp
        je      .L18                          # code at .L18 checks next 2 and maybe does puts, before jumping back
.L5:
        movsx   rax, BYTE PTR [r12]           # sign-extending byte load to pointer width, of tex[i]
        add     r13d, DWORD PTR [rsp+rax*4]   # i += look up in rand_array[]
        cmp     r13d, 299999999
        jle     .L10                          # }while(i < 300000000)

由于 je .L18 从未被采用,这是每次迭代 7 微指令,因此 Skylake 可以舒适地table 在每次迭代不到 2 个周期的情况下发出它。

即使有 L1d 缓存命中,通过 i (R13D) 的循环携带依赖性是:

  • 1 个周期:movsx r12, r13d
  • 1 个周期:add r12, rbp
  • ~5 周期加载使用延迟:movsx rax, BYTE PTR [r12]。 (不是 4c,那个乐观的特例 。)
  • ~5 周期加载使用延迟加上 1 周期 ALU 延迟:add r13d, DWORD PTR [rsp+rax*4]

所以总共有大约 13 个周期延迟 最佳 情况下 L1d 命中,在前端留下大量空闲“插槽”,以及空闲的后端执行单元,即使调用实际的 glibc memcmp 也没什么大不了的。

(当然 -O0 代码比较嘈杂,所以管道区域中的一些空闲槽已经用完了,但是 dep 链甚至更长,因为 -O0 代码保持 i 在内存中:Why does clang produce inefficient asm with -O0 (for this simple floating point sum)?)


CPU 内存瓶颈循环中的频率,尤其是在 Skylake 上

init 循环为 CPU 提供了足够的时间在定时区域之前达到最大 turbo,并通过首先触摸分配的页面来预出错。

如果在等待来自缓存未加载的传入数据期间有更多的工作,更少的停滞,则保持更高的 CPU 频率也可能会产生影响。参见

以上结果,我用EPP = balance_performance.

测试

我还用 performance 进行了测试,-O0(libc memcmp)仍然比 -O3(内联 memcmp)快,但是所有 4 个循环确实加速了一些(with/without memcmp,和优化与否)。

compiler/CPU No.1 if(memcmp) No.2 plain
-O0 / balance_performance 0.028079 s 0.069492 s
-O0 / performance 0.026898 s 0.053805 s
-O3 / balance_performance 0.040457 s 0.061166 s
-O3 / performance 0.035621 s 0.047475 s

-O0 的更快效果仍然存在,并且即使在 EPP=performance 时也非常显着,所以我们知道它不是 只是 时钟速度的问题。从之前的实验来看,performance 即使在内存受限的情况下,即使在其他设置的时钟频率从 4.2 降至 2.7 GHz 的情况下,performance 也能保持最大睿频。因此调用 memcmp 很可能有助于触发更好的预取以减少平均缓存未命中延迟。

不幸的是,我没有为 PRNG 使用固定种子,因此就访问的整个缓存行的模式而言,由于预取器的随机性是好还是坏,可能会有一些变化。我只是为每个取了一个特定的 运行(一对中的第二个由 perf stat -r 2 启动,因此它应该受系统波动的影响更小。)

看起来 performance 对 2 号循环(其中进行的次数较少)和 -O3 版本(再次进行的次数较少)产生了更大的影响,这与 Skylake 降低时钟速度一致,当内核几乎只在内存上出现瓶颈时,而不是 运行 在 EPP 设置中 performance.

以外的许多其他指令

简答,纯推测:在第一次定时循环中,tex指向的缓冲区在初始化后仍在缓存中。 printf 做了很多复杂的事情,包括内核调用,并且任何缓存行现在很可能被其他数据填充。

第二个定时循环现在必须从 RAM 中重新读取数据。

尝试交换循环,and/or将printf延迟到两次测量之后。

哦,对 memcmp 的调用可能会导致缓冲区溢出。