不一致的 `perf annotate` 内存 load/store 时间报告
Inconsistent `perf annotate` memory load/store time reporting
我很难解读英特尔性能事件报告。
考虑以下主要 reads/writes 内存的简单程序:
#include <stdint.h>
#include <stdio.h>
volatile uint32_t a;
volatile uint32_t b;
int main() {
printf("&a=%p\n&b=%p\n", &a, &b);
for(size_t i = 0; i < 1000000000LL; i++) {
a ^= (uint32_t) i;
b += (uint32_t) i;
b ^= a;
}
return 0;
}
我在perf
下用gcc -O2
和运行编译它:
# gcc -g -O2 a.c
# perf stat -a ./a.out
&a=0x55a4bcf5f038
&b=0x55a4bcf5f034
Performance counter stats for 'system wide':
32,646.97 msec cpu-clock # 15.974 CPUs utilized
374 context-switches # 0.011 K/sec
1 cpu-migrations # 0.000 K/sec
1 page-faults # 0.000 K/sec
10,176,974,023 cycles # 0.312 GHz
13,010,322,410 instructions # 1.28 insn per cycle
1,002,214,919 branches # 30.699 M/sec
123,960 branch-misses # 0.01% of all branches
2.043727462 seconds time elapsed
# perf record -a ./a.out
&a=0x5589cc1fd038
&b=0x5589cc1fd034
[ perf record: Woken up 3 times to write data ]
[ perf record: Captured and wrote 0.997 MB perf.data (9269 samples) ]
# perf annotate
perf annotate
的结果(我为记忆 loads/stores 注释):
Percent│ for(size_t i = 0; i < 1000000000LL; i ++) {
│ xor %eax,%eax
│ nop
│ a ^= (uint32_t) i;
│28: mov a,%edx // 32-bit load
│ xor %eax,%edx
9.74 │ mov %edx,a // 32-bit store
│ b += (uint32_t) i;
12.12 │ mov b,%edx // 32-bit load
8.79 │ add %eax,%edx
│ for(size_t i = 0; i < 1000000000LL; i ++) {
│ add [=13=]x1,%rax
│ b += (uint32_t) i;
18.69 │ mov %edx,b // 32-bit store
│ b ^= a;
0.04 │ mov a,%ecx // 32-bit load
22.39 │ mov b,%edx // 32-bit load
8.92 │ xor %ecx,%edx
19.31 │ mov %edx,b // 32-bit store
│ for(size_t i = 0; i < 1000000000LL; i ++) {
│ cmp [=13=]x3b9aca00,%rax
│ ↑ jne 28
│ }
│ return 0;
│ }
│ xor %eax,%eax
│ add [=13=]x8,%rsp
│ ← retq
我的观察:
- 从1.28开始
insn per cycle
我断定程序主要是内存绑定
a
和 b
似乎位于同一缓存行中,彼此相邻。
我的问题:
- 不应该 CPU 时间对于各种内存加载和存储更一致吗?
- 为什么第一次内存加载(
mov a,%edx
)的CPU时间为零?
- 为什么第3次加载
mov a,%ecx
的时间是0.04%,而紧接着的mov b,%edx
是22.39%?
- 为什么有些指令需要0时间?循环由 14 条指令组成,因此每条指令都必须贡献一些可观察的时间。
备注:
OS:Linux 4.19.0-amd64,CPU:英特尔酷睿 i9-9900K,100% 空闲系统(也在 i7-7700 上测试,结果相同)。
不完全是“内存”限制,而是存储转发延迟的限制。 i9-9900K 和 i7-7700 的每个内核都具有完全相同的微体系结构,所以这不足为奇 :P https://en.wikichip.org/wiki/intel/microarchitectures/coffee_lake#Key_changes_from_Kaby_Lake。 (除了可能改进 Meltdown 的硬件缓解措施,并可能修复循环缓冲区 (LSD)。)
请记住,当 perf 事件计数器溢出并触发样本时,乱序的超标量 CPU 必须恰好选择一个正在运行的指令来“归咎于”对于此 cycles
事件。这通常是 ROB 中最老的未退休指令,或之后的指令。 非常怀疑 cycles
非常小规模的事件样本。
Perf 从不责怪产生结果缓慢的负载,通常是等待它的指令。 (在本例中为 xor
或 add
)。在这里,有时存储会消耗该异或的结果。这些不是缓存未命中负载; Skylake 上的存储转发延迟只有大约 3 到 5 个周期(可变,如果你不尽快尝试的话会更短:)所以你确实有大约每 3 到 5 个周期完成 2 个负载。
你有两条内存依赖链
- 最长的一个涉及
b
的两个RMW。这是两倍长,将成为循环的整体瓶颈。
- 另一个涉及
a
的一个 RMW(每次迭代都有一个额外的读取,可以与下一个 a ^= i;
的读取并行发生)。
i
的dep chain只涉及寄存器,可以运行遥遥领先; add [=18=]x1,%rax
没有计数也就不足为奇了。它的执行成本完全隐藏在等待加载的阴影中。
我有点惊讶 mov %edx,a
的数量如此之多。也许它有时必须等待 CPU 的单个存储数据端口上涉及 b
到 运行 的旧存储 uops。 (uops按照oldest-ready优先发送到端口。)
直到所有先前的 uops 都已执行,Uops 才能退出,因此它可能只是从循环底部的存储中获得一些偏差。 uops 以 4 个为一组退出,因此如果 mov %edx,b
确实退出,则已执行的 cmp/jcc、a
的 mov 加载和 xor %eax,%edx
可以随之退出。这些不是等待 b
的 dep 链的一部分,因此只要 b
商店准备退休,他们总是会坐在 ROB 中等待退休。 (这是关于 mov %edx,a
如何获得计数的猜测,尽管它不是真正瓶颈的一部分。)
存储地址 uops 应该全部 运行 远远领先于循环,因为它们不必等待先前的迭代:RIP 相对寻址 1 是马上就准备好了。它们可以 运行 在端口 7 上,或者与端口 2 或 3 的负载竞争。负载也一样:它们可以立即执行并检测它们正在等待的存储,负载缓冲区监视它并准备就绪在存储数据 uop 最终 运行.
之后报告数据何时准备就绪
据推测,前端最终会在分配加载缓冲区条目时出现瓶颈,这将限制后端中可以有多少微指令,而不是 ROB 或 RS 大小。
脚注 1:您的注释输出仅显示 a
而不是 a(%rip)
所以这很奇怪;不管你是不是以某种方式让它使用 32 位绝对,或者如果它只是一个反汇编怪癖未能显示 RIP-relative。
我很难解读英特尔性能事件报告。
考虑以下主要 reads/writes 内存的简单程序:
#include <stdint.h>
#include <stdio.h>
volatile uint32_t a;
volatile uint32_t b;
int main() {
printf("&a=%p\n&b=%p\n", &a, &b);
for(size_t i = 0; i < 1000000000LL; i++) {
a ^= (uint32_t) i;
b += (uint32_t) i;
b ^= a;
}
return 0;
}
我在perf
下用gcc -O2
和运行编译它:
# gcc -g -O2 a.c
# perf stat -a ./a.out
&a=0x55a4bcf5f038
&b=0x55a4bcf5f034
Performance counter stats for 'system wide':
32,646.97 msec cpu-clock # 15.974 CPUs utilized
374 context-switches # 0.011 K/sec
1 cpu-migrations # 0.000 K/sec
1 page-faults # 0.000 K/sec
10,176,974,023 cycles # 0.312 GHz
13,010,322,410 instructions # 1.28 insn per cycle
1,002,214,919 branches # 30.699 M/sec
123,960 branch-misses # 0.01% of all branches
2.043727462 seconds time elapsed
# perf record -a ./a.out
&a=0x5589cc1fd038
&b=0x5589cc1fd034
[ perf record: Woken up 3 times to write data ]
[ perf record: Captured and wrote 0.997 MB perf.data (9269 samples) ]
# perf annotate
perf annotate
的结果(我为记忆 loads/stores 注释):
Percent│ for(size_t i = 0; i < 1000000000LL; i ++) {
│ xor %eax,%eax
│ nop
│ a ^= (uint32_t) i;
│28: mov a,%edx // 32-bit load
│ xor %eax,%edx
9.74 │ mov %edx,a // 32-bit store
│ b += (uint32_t) i;
12.12 │ mov b,%edx // 32-bit load
8.79 │ add %eax,%edx
│ for(size_t i = 0; i < 1000000000LL; i ++) {
│ add [=13=]x1,%rax
│ b += (uint32_t) i;
18.69 │ mov %edx,b // 32-bit store
│ b ^= a;
0.04 │ mov a,%ecx // 32-bit load
22.39 │ mov b,%edx // 32-bit load
8.92 │ xor %ecx,%edx
19.31 │ mov %edx,b // 32-bit store
│ for(size_t i = 0; i < 1000000000LL; i ++) {
│ cmp [=13=]x3b9aca00,%rax
│ ↑ jne 28
│ }
│ return 0;
│ }
│ xor %eax,%eax
│ add [=13=]x8,%rsp
│ ← retq
我的观察:
- 从1.28开始
insn per cycle
我断定程序主要是内存绑定 a
和b
似乎位于同一缓存行中,彼此相邻。
我的问题:
- 不应该 CPU 时间对于各种内存加载和存储更一致吗?
- 为什么第一次内存加载(
mov a,%edx
)的CPU时间为零? - 为什么第3次加载
mov a,%ecx
的时间是0.04%,而紧接着的mov b,%edx
是22.39%? - 为什么有些指令需要0时间?循环由 14 条指令组成,因此每条指令都必须贡献一些可观察的时间。
备注:
OS:Linux 4.19.0-amd64,CPU:英特尔酷睿 i9-9900K,100% 空闲系统(也在 i7-7700 上测试,结果相同)。
不完全是“内存”限制,而是存储转发延迟的限制。 i9-9900K 和 i7-7700 的每个内核都具有完全相同的微体系结构,所以这不足为奇 :P https://en.wikichip.org/wiki/intel/microarchitectures/coffee_lake#Key_changes_from_Kaby_Lake。 (除了可能改进 Meltdown 的硬件缓解措施,并可能修复循环缓冲区 (LSD)。)
请记住,当 perf 事件计数器溢出并触发样本时,乱序的超标量 CPU 必须恰好选择一个正在运行的指令来“归咎于”对于此 cycles
事件。这通常是 ROB 中最老的未退休指令,或之后的指令。 非常怀疑 cycles
非常小规模的事件样本。
Perf 从不责怪产生结果缓慢的负载,通常是等待它的指令。 (在本例中为 xor
或 add
)。在这里,有时存储会消耗该异或的结果。这些不是缓存未命中负载; Skylake 上的存储转发延迟只有大约 3 到 5 个周期(可变,如果你不尽快尝试的话会更短:
你有两条内存依赖链
- 最长的一个涉及
b
的两个RMW。这是两倍长,将成为循环的整体瓶颈。 - 另一个涉及
a
的一个 RMW(每次迭代都有一个额外的读取,可以与下一个a ^= i;
的读取并行发生)。
i
的dep chain只涉及寄存器,可以运行遥遥领先; add [=18=]x1,%rax
没有计数也就不足为奇了。它的执行成本完全隐藏在等待加载的阴影中。
我有点惊讶 mov %edx,a
的数量如此之多。也许它有时必须等待 CPU 的单个存储数据端口上涉及 b
到 运行 的旧存储 uops。 (uops按照oldest-ready优先发送到端口。
直到所有先前的 uops 都已执行,Uops 才能退出,因此它可能只是从循环底部的存储中获得一些偏差。 uops 以 4 个为一组退出,因此如果 mov %edx,b
确实退出,则已执行的 cmp/jcc、a
的 mov 加载和 xor %eax,%edx
可以随之退出。这些不是等待 b
的 dep 链的一部分,因此只要 b
商店准备退休,他们总是会坐在 ROB 中等待退休。 (这是关于 mov %edx,a
如何获得计数的猜测,尽管它不是真正瓶颈的一部分。)
存储地址 uops 应该全部 运行 远远领先于循环,因为它们不必等待先前的迭代:RIP 相对寻址 1 是马上就准备好了。它们可以 运行 在端口 7 上,或者与端口 2 或 3 的负载竞争。负载也一样:它们可以立即执行并检测它们正在等待的存储,负载缓冲区监视它并准备就绪在存储数据 uop 最终 运行.
之后报告数据何时准备就绪据推测,前端最终会在分配加载缓冲区条目时出现瓶颈,这将限制后端中可以有多少微指令,而不是 ROB 或 RS 大小。
脚注 1:您的注释输出仅显示 a
而不是 a(%rip)
所以这很奇怪;不管你是不是以某种方式让它使用 32 位绝对,或者如果它只是一个反汇编怪癖未能显示 RIP-relative。