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。
我不知道任何真正的程序集,但可以阅读 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
你应该做的
如果您对您的代码如何编译为 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
.
%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。