leaq 是那么慢还是有其他原因导致较小的装配列表比较长的装配列表慢?

Is leaq THAT slow or there is another reason smaller assembly listing is slower than the longer one?

我不知道任何真正的程序集,但可以阅读 GCC -S 输出来评估给定 C 代码的实际成本。

这个问题与分析和基准测试无关,而是教育性的。我需要有人向我解释为什么 [1] 片段不比第二个片段快。

嗯,以前是这样想的:"yeah, some operations like MUL are pretty expensive, but if one assembly is X times bigger than another, it should be slower"。

在我遇到那两个之前,这是真的:

unsigned char bytes[4] = {0, 0, 0, 5};

// 1
int32_t val = *((int32_t*)bytes);      
/* produces:
        leaq    -16(%rbp), %rax
        movl    (%rax), %eax
        movl    %eax, -4(%rbp)
        movl    [=10=], %eax
*/

// 2   
val = bytes[3] |                               
      (bytes[2] << 8) |                        
      (bytes[1] << 16) |
      (bytes[0] << 24);
/* produces: 
        movzbl  -13(%rbp), %eax
        movzbl  %al, %eax
        movzbl  -14(%rbp), %edx
        movzbl  %dl, %edx
        sall    , %edx
        orl     %eax, %edx
        movzbl  -15(%rbp), %eax
        movzbl  %al, %eax
        sall    , %eax
        orl     %eax, %edx
        movzbl  -16(%rbp), %eax
        movzbl  %al, %eax
        sall    , %eax
        orl     %edx, %eax
        movl    %eax, -4(%rbp)
        movl    [=10=], %eax
*/

基准测试显示第二个快 5-10%。 这里发生了什么?

我能想象的和 "reason" 唯一的显着区别是 LEAQ 是非常慢的东西。 最后 2 行是相同的,所以可能 MOV 价格太高以至于 1 个额外的 MOV 比成吨的指令还差。

这是我用来测量执行时间的:

#include <stdio.h>
#include <time.h>
#include <stdint.h>

#define REPETITIONS 32
#define ITERATIONS 90000

#define CODE1                   \
  for (int i = 0; i < ITERATIONS; ++i) {    \
    val = *((int32_t*)bytes);           \
  }

#define CODE2                   \
  for (int i = 0; i < ITERATIONS; ++i) {    \
    val = bytes[3] |                \
      (bytes[2] << 8) |             \
      (bytes[1] << 16) |            \
      (bytes[0] << 24);             \
  }

int main(void) {
  clock_t minTs = 999999999, ts;
  unsigned char bytes[4] = {0, 0, 0, 5};    
  int32_t val;                  

  for (int i = 0; i < REPETITIONS; ++i) {
    ts = clock();

    CODE1; // Or CODE2

    ts = clock() - ts;
    if (ts < minTs) minTs = ts;
  }

  printf("ts: %ld\n", minTs);

  return val;
}


更新: 事实证明,结果是特定于硬件的,因此虽然 [1] 在我的笔记本电脑 (x64 i5-4260U) 上速度较慢,但​​在我的 PC 上速度较快(但只有很小的一部分,如 5%)。

更新:额外 loads/stores 可以减少 Sandybridge-family 上的存储转发延迟,因此 CODE2 中的额外工作可能会加速循环携带的依赖链两个循环都有瓶颈。 (因为-O0将循环计数器保存在内存中)。

这个效果是present in all SnB-family CPUs, including the Sandybridge I had when writing this answer, the OP's Haswell, the Broadwell in , and the Skylake in


你应该做的

如果您对您的代码如何编译为 asm 感到好奇,请将您的代码放在无法优化的函数中。在 this godbolt link,您可以看到 gcc 5.1 和更新版本(在 -O3 -march=native -mtune=native)如何通过字节的 ORing 组合在一起并使用 movbe(移动大端)在加载时动态 bwap . icc、clang 和较旧的 gcc 发出加载单个字节并将它们移位/或到位的指令。

令我失望的是,即使我颠倒顺序执行小端加载而不是大端加载,编译器也没有看穿字节的 ORing。 (请参阅 Godbolt 上的本机、大端和小端函数。)即使将类型更改为 uint32_t 和 uint8_t 对 gcc >= 5.1 以外的编译器也没有帮助。

显然,在启用优化的情况下,编译器会丢弃仅设置未使用变量的循环。他们只是调用 clock 两次,printf,然后将答案立即移动到 eax。如果你真的想对某些东西进行基准测试,请将它与将使用常量数据调用它的函数分开编译。这样,您就可以拥有将其工作作为函数参数的简单代码,并且它无法内联到向其传递常量数据的调用方。

此外,gcc 将 main 视为 "cold" 函数,并没有像普通函数那样对其进行大量优化。在大多数程序中,main 不包含内部循环。


为什么 -O0 asm 这么慢?

显然代码很糟糕 来自 -O0,存储到内存,甚至递增内存中的循环计数器 。弄清楚为什么它 运行 比我预期的还要慢,CODE1 每个时钟不到一个 insn 的原因仍然有点有趣。

您没有显示任何一段代码的整个循环。可能删除循环体仍然会留下一个缓慢的循环。我认为循环本身就是问题所在,而且效率非常低,以至于 CPU 有时间在 CODE2 中执行所有额外指令而不会出现任何减速。


TL;DR:两个循环都受到 add , -0x24(%rbp) 的瓶颈,这会增加内存中的循环计数器。循环携带的依赖链中的 6 个周期延迟解释了为什么两个循环都以相同的速度出现瓶颈。

我不确切知道为什么 CODE2 中的额外指令以某种方式帮助接近每次迭代理论最大值 6 个周期,但这不是任何人编写的代码中应该出现的瓶颈。将循环计数器保存在寄存器中,并且不要将相同地址的读-修改-写指令放入循环携带的依赖链中。 (.)


有关我对代码所做的更改,请参阅 godbolt。 (由于 ITERATIONS 增加了 100 倍,因此 运行 时间主导了启动开销的噪音。)为了前 3 个函数的好处,link 启用了优化。

godbolt 没有 C 模式,只有 C++,而且我在本地从 gcc 4.9.2 得到的循环比 godbolt 显示的要好。 (g++ 完全按照编写的方式实现了一个 for(){} 循环,顶部有一个 cmp/jcc,底部有一个 jmp。gcc 即使在 -O0 也使用 do{} while(count++ < ITERATIONS); 结构,其中底部只有一个 cmp/jle。

I do not know any real assembly, but can read GCC -S output to evaluate actual costs of given C code.

Well, used to think like: "yeah, some operations like MUL are pretty expensive, but if one assembly is X times bigger than another, it should be slower".

首先要寻找的是吞吐量和延迟瓶颈。在 Intel 上,这意味着每个时钟吞吐量为 4 微指令,或者如果长依赖链是一个限制,则更少。然后是每个执行端口的瓶颈。例如。每个时钟两个内存操作,其中最多一个是存储。每个时钟最多 mul,因为 3 个 ALU 端口中只有一个具有整数乘法器。

请参阅 Agner Fog's site 了解优化指南、微架构文档和 table 指令 latency/throughputs / uops / 可以 运行 使用的端口。

将循环计数器保存在内存中会严重阻碍您的循环。在 SandyBridge(我的系统)和 Haswell(你的)上,Agner Fog 的 table 具有 add 的延迟,内存目的地为 6 个时钟。它不可能 运行 比每次迭代每 6 个时钟一次迭代更快。循环中有 6 条指令,即每个周期 1 个 insn。

实际上,我得到的吞吐量比这要少。也许存储作为 add 的读-修改-写操作的一部分有时会被循环中的另一个 loads/stores 延迟。 IDK 为什么 CODE2 稍微快一点,这很奇怪。也许它以不同的方式排序,因此循环携带依赖性 add 延迟较少。

循环 body 使用 lea 和 32 位加载显然更快。 IDK 为什么您认为 lea 很慢。

这不是对齐/uop 缓存问题。循环应该以任何方式从循环缓冲区流出,即使在 32B 代码块中有超过 18 微指令(这意味着它不能进入​​微指令缓存)。当我们的每时钟 insn 如此低时,前端瓶颈(除了分支错误预测,我们没有)不会成为问题。前端可以轻松地保持大量 uops 排队等待调度程序调度。

来自 perf report,对每条指令采用的分析时钟:CODE1 的内部循环。计数不是周期精确的。我们可能看到 CPU 卡在 add , mem 之后的指令上,我确信这是循环携带依赖性的瓶颈。它需要在下一次迭代时将存储转发到负载,这仍然需要 6 个时钟。

   ###### CODE1 inner loop, profiling on cycles
 13.97 │400680:   lea    -0x10(%rbp),%rax
       │400684:   mov    (%rax),%eax
       │400686:   mov    %eax,-0x2c(%rbp)
       │400689:   addl   [=10=]x1,-0x24(%rbp)
 13.84 │40068d:   cmpl   [=10=]x89543f,-0x24(%rbp)
 72.19 │400694: ↑ jle    400680 <code1+0x4a>
       ## end of the loop
        400696:   callq  4004e0 <clock@plt>
        40069b:   sub    -0x18(%rbp),%rax

       #CODE2
 15.08 │400738:   movzbl -0xd(%rbp),%eax
  0.88 │40073c:   movzbl %al,%eax
  0.05 │40073f:   movzbl -0xe(%rbp),%edx
       │400743:   movzbl %dl,%edx
 13.91 │400746:   shl    [=11=]x8,%edx
  0.70 │400749:   or     %eax,%edx
  0.05 │40074b:   movzbl -0xf(%rbp),%eax
       │40074f:   movzbl %al,%eax
 12.70 │400752:   shl    [=11=]x10,%eax
  0.60 │400755:   or     %eax,%edx
  0.09 │400757:   movzbl -0x10(%rbp),%eax
  0.05 │40075b:   movzbl %al,%eax
 13.03 │40075e:   shl    [=11=]x18,%eax
  0.70 │400761:   or     %edx,%eax
  0.14 │400763:   mov    %eax,-0x2c(%rbp)
  0.09 │400766:   addl   [=11=]x1,-0x24(%rbp)
 13.63 │40076a:   cmpl   [=11=]x89543f,-0x24(%rbp)
 28.29 │400771: ↑ jle    400738 <code2+0x4a>
     ## end of the loop
        400773: → callq  4004e0 <clock@plt>
        400778:   sub    -0x18(%rbp),%rax 

哇,太搞笑了。 gcc 在使用 movzbl.

从 8 位内存位置加载 %eax 后执行冗余 movzbl %al, %eax

那么在每次迭代 6 个时钟中,CPU 能否处理加载组合字节的所有繁忙工作?是的。

  • 4x movzx reg, mem:4 个加载端口微指令。 (p2/p3)
  • 4x movzx reg, reg:任何 ALU 端口 (p015) 为 4 微指令
  • 3x shl reg, imm:ALU 端口 3 微指令 p0/p5
  • 3x or reg, reg:任何 ALU 端口 (p015) 3 微指令
  • 1x mov mem, reg:1 个 uop 融合域:1 个存储数据 (p4),1 个存储地址 (p23)
  • 1x add mem, imm:2 个融合域。未融合:1 个 ALU uop (p015)、1 个加载 uop (p23)、1 个存储数据 (p4)、1 个存储地址 (p23)
  • 1x cmp mem, imm:p015 1 个 uop,p23 1 个。
  • 1x jle:p5 1 微指令。 (由于 imm 和 mem,不能与 cmp 宏融合)

total fused-domain uops: 4+4+3+3+1+2+1+1 = 19. 这适合 28uop 循环流缓冲区,避免了 uop-cache 瓶颈的任何可能性,并且可以发出在 5 个时钟内。 (每个周期 4 个,最后一个周期只发出 3 个)。

加载微指令:4 + 1 + 1 = 6。存储微指令:2。

ALU 微指令:4+3+3+1+1+1 = 13。SnB 的 3 个 ALU 微指令端口可以在 4.33 个时钟内处理。大多数 uops 可以 运行 在任何端口上,因此没有一个端口是瓶颈。 (Haswell 有第 4 个 ALU 端口,p6。它有一个更容易的时间。但是 ALU uops 不是瓶颈。)

循环体的延迟无关紧要,因为下一次迭代不会读取任何结果。每次迭代都会读取一些数据并将其存储,这与上一次迭代所做的无关。很多循环都是这样。这种循环通常每次从不同的地址加载和存储到不同的地址,但是 CPU 只是按照它告诉的去做。

无论如何,即使每个循环中的依赖链花费超过 6 个时钟,来自多个迭代的工作也可以进行。一次迭代中的任何事情都不必等待前一次, 除了 带有内存目标的循环计数器增量。

所以 CODE2 循环中的所有工作根本不是瓶颈。

对于 SnB/HSW,根据 Agner Fog 的 table,具有内存目标的 add-immediate 是 2 uops,而内存目标上的 inc 是 3,这很奇怪。我想知道这是不是一个错误,或者当在内存目标上使用 inc 而不是 add 时,英特尔 CPU 是否真的更慢?


测试时间(来自 gcc 4.9.2)。我没有看到任何明显的东西可以解释为什么 CODE2 更接近每 6 个时钟一次迭代的理论最大值。我唯一的猜测是 jle 之后的 call 混淆了 CODE1,但 CODE1 不是?也许是 uops 上的性能记录

桑迪布里奇 i5 (2500k):

## CODE1 ##
 Performance counter stats for './a.out 1' (4 runs):

        589.117019      task-clock (msec)         #    0.999 CPUs utilized            ( +-  1.30% )
     2,068,118,847      cycles                    #    3.511 GHz                      ( +-  0.48% )
     1,729,505,746      instructions              #    0.84  insns per cycle        
                                                  #    0.86  stalled cycles per insn  ( +-  0.01% )
     2,018,533,639      uops_issued_any           # 3426.371 M/sec                    ( +-  0.02% )
     5,648,003,370      uops_dispatched_thread    # 9587.235 M/sec                    ( +-  2.51% )
     3,170,497,726      uops_retired_all          # 5381.779 M/sec                    ( +-  0.01% )
     2,018,126,488      uops_retired_retire_slots # 3425.680 M/sec                    ( +-  0.01% )
     1,491,267,343      stalled-cycles-frontend   #   72.11% frontend cycles idle     ( +-  0.66% )
        27,192,780      stalled-cycles-backend    #    1.31% backend  cycles idle     ( +- 68.75% )

       0.589651097 seconds time elapsed                                          ( +-  1.32% )

看到 uops_dispatched_thread 与 uops_retired_all 不匹配是非常不寻常的。它们通常是相同的,并且等于循环中指令的未融合微指令数。融合域 uops_issued_any 和 uops_retired_retire_slots 通常都是相等的,在本例中就是这样。也许内存目标 ALU 操作在分派与 retired_all 中的计数不同? (微融合)。我想我之前的测试只有looked at micro-fusion of loads.

我不认为它发出的是不需要的微指令。 (这不是分支预测错误问题;我检查过,两个版本都有 0.00% 的分支预测错误。(288M 分支只有 ~10k)。)

## CODE2 ##
peter@tesla:~/src/SO$ ocperf.py stat -r4 -e task-clock,cycles,instructions,uops_issued.any,uops_dispatched.thread,uops_retired.all,uops_retired.retire_slots,stalled-cycles-frontend,stalled-cycles-backend ./a.out 2
perf stat -r4 -e task-clock,cycles,instructions,cpu/event=0xe,umask=0x1,name=uops_issued_any/,cpu/event=0xb1,umask=0x1,name=uops_dispatched_thread/,cpu/event=0xc2,umask=0x1,name=uops_retired_all/,cpu/event=0xc2,umask=0x2,name=uops_retired_retire_slots/,stalled-cycles-frontend,stalled-cycles-backend ./a.out 2
CODE2: ts: 16499
CODE2: ts: 16535
CODE2: ts: 16517
CODE2: ts: 16529

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

        543.886795      task-clock (msec)         #    0.999 CPUs utilized            ( +-  1.01% )
     2,007,623,288      cycles                    #    3.691 GHz                      ( +-  0.01% )
     5,185,222,133      instructions              #    2.58  insns per cycle        
                                                  #    0.11  stalled cycles per insn  ( +-  0.00% )
     5,474,164,892      uops_issued_any           # 10064.898 M/sec                    ( +-  0.00% )
     7,586,159,182      uops_dispatched_thread    # 13948.048 M/sec                    ( +-  0.00% )
     6,626,044,316      uops_retired_all          # 12182.764 M/sec                    ( +-  0.00% )
     5,473,742,754      uops_retired_retire_slots # 10064.121 M/sec                    ( +-  0.00% )
       566,873,826      stalled-cycles-frontend   #   28.24% frontend cycles idle     ( +-  0.03% )
         3,744,569      stalled-cycles-backend    #    0.19% backend  cycles idle     ( +-  2.32% )

       0.544190971 seconds time elapsed                                          ( +-  1.01% )

在融合域中,发布 & retired_slots 匹配 CODE2。