每周性能技巧 #99:用 llvm-mca 照亮处理器核心
本节阅读量:
本文翻译自 Abseil 官网的 Performance Tip of the Week #99: Illuminating the processor core with llvm-mca。
原文发布于 2025 年 9 月 29 日,作者 Chris Kennelly,更新于 2025 年 10 月 7 日。
RISC 与 CISC 之争最终以平局收场:现代处理器会把指令分解成由后端执行单元处理的微操作。理解这些单元如何执行指令,可以帮助我们洞察如何优化受后端限制的关键函数。本篇会演示如何使用 llvm-mca 分析函数,并从模拟结果中识别性能洞见。
预备知识:Varint 优化
llvm-mca 是 Machine Code Analyzer 的缩写,是 LLVM 中的一个工具。它使用的资料与编译器做指令调度决策时使用的资料相同。这确保了编译器优化中的改进会自动流向 llvm-mca,使其保持代表性。反过来说,该工具的准确性也取决于 LLVM 对处理器设计的内部建模,因此某些微架构代际的特殊细节可能会被省略。
它也会静态建模处理器行为,因此不会考虑缓存未命中、分支预测失败和其他动态属性。
考虑 Protobuf 的 VarintSize64 方法:
1
2
3
4
5
6
7
8
9
10
11
12
13
|
size_t CodedOutputStream::VarintSize64(uint64_t value) {
#if PROTOBUF_CODED_STREAM_H_PREFER_BSR
// 显式 OR 0x1,以避免调用 absl::countl_zero(0);
// 在没有 clz 指令的平台上,这需要一个分支来检查。
uint32_t log2value = (std::numeric_limits<uint64_t>::digits - 1) -
absl::countl_zero(value | 0x1);
return static_cast<size_t>((log2value * 9 + (64 + 9)) / 64);
#else
uint32_t clz = absl::countl_zero(value);
return static_cast<size_t>(
((std::numeric_limits<uint64_t>::digits * 9 + 64) - (clz * 9)) / 64);
#endif
}
|
这个函数计算一个编码后的整数会在 Protobuf wire format 中占用多少字节。它先通过寻找输入的 log2 大小来计算表示该值所需的 bit 数,然后近似除以 7。输入大小可以通过 absl::countl_zero 函数计算。
不过,根据处理器是否有 lzcnt(Leading Zero Count)指令,或者该操作是否需要改用 bsr(Bit Scan Reverse)指令,这里有两种可能实现。
在 absl::countl_zero 内部,我们需要检查参数是否为 0,因为 __builtin_clz(Count Leading Zeros)建模的是 x86 的 bsr(Bit Scan Reverse)指令行为,并且当输入为 0 时行为未指定。| 0x1 通过让参数以编译器可追踪的方式保证非零,避免了分支。
当 lzcnt 可用时,编译器会把 absl::countl_zero 中的 x == 0 ? 32 : __builtin_clz(x) 优化为无分支的 lzcnt。这使得 | 0x1 不再必要。
编译后,根据 lzcnt 指令是否可用,会得到两段不同的汇编序列。
bsr(-march=ivybridge):
1
2
3
4
5
|
orq $1, %rdi
bsrq %rdi, %rax
leal (%rax,%rax,8), %eax
addl $73, %eax
shrl $6, %eax
|
lzcnt(-march=haswell):
1
2
3
4
5
|
lzcntq %rdi, %rax
leal (%rax,%rax,8), %ecx
movl $640, %eax
subl %ecx, %eax
shrl $6, %eax
|
分析代码
现在可以使用 Compiler Explorer 将这些序列交给 llvm-mca,并得到它们在模拟 Skylake 处理器(-mcpu=skylake)上单次调用(-iterations=1)时的执行分析,同时包含 -timeline:
bsr(-march=ivybridge):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
Iterations: 1
Instructions: 5
Total Cycles: 10
Total uOps: 5
Dispatch Width: 6
uOps Per Cycle: 0.50
IPC: 0.50
Block RThroughput: 1.0
Timeline view:
Index 0123456789
[0,0] DeER . . orq $1, %rdi
[0,1] D=eeeER . bsrq %rdi, %rax
[0,2] D====eER . leal (%rax,%rax,8), %eax
[0,3] D=====eER. addl $73, %eax
[0,4] D======eER shrl $6, %eax
|
lzcnt(-march=haswell):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
Iterations: 1
Instructions: 5
Total Cycles: 9
Total uOps: 5
Dispatch Width: 6
uOps Per Cycle: 0.56
IPC: 0.56
Block RThroughput: 1.0
Timeline view:
Index 012345678
[0,0] DeeeER . lzcntq %rdi, %rax
[0,1] D===eER . leal (%rax,%rax,8), %ecx
[0,2] DeE---R . movl $640, %eax
[0,3] D====eER. subl %ecx, %eax
[0,4] D=====eER shrl $6, %eax
|
也可以通过命令行获得这些结果:
1
|
clang file.cpp -O3 --target=x86_64 -S -o - | llvm-mca -mcpu=skylake -iterations=1 -timeline
|
输出分为两部分:第一部分提供代码的一些汇总统计,第二部分覆盖执行“时间线”。时间线详细展示了指令如何流过处理器中的执行流水线。它有三列,每条指令单独占一行:
- 第一列是一对数字,第一个数字是迭代编号,第二个数字是指令索引。在上例中只有一次迭代,即编号 0,所以每行变化的只有指令索引。
- 第三列是指令。
- 第二列是时间线。该列中的每个字符代表一个周期,字符表示该周期中指令正在发生什么。
时间线以周期计数。每条指令会经历几个步骤:
D:指令被处理器 dispatch;现代桌面或服务器处理器每周期可以 dispatch 多条指令。智能手机中使用的 Cortex-A55 这类 Arm 小核心限制更多。
=:指令等待执行。在这里,指令等待前序指令的结果可用。其他情况下,也可能是处理器后端存在瓶颈。
e:指令正在执行。
E:指令输出已可用。
-:指令已经完成执行,正在等待 retire。指令通常按程序顺序 retire,也就是按它们出现在程序中的顺序。指令会等到前序指令也 retire 后才 retire。在 Cortex-A55 等某些架构上,时间线中没有 R 阶段,因为某些指令会乱序 retire。
R:指令已经 retire,不再占用执行资源。
输出很长,但我们可以从中提取几个高层洞见:
lzcnt 实现执行更快(9 周期),快于 bsr 实现(10 周期)。这既可以从 Total Cycles 汇总中看到,也可以从时间线中看到。
- 这个例程很简单:除
movl 外,指令彼此顺序依赖(在时间线视图中,每对指令的 E 完成和 e 开始垂直对齐)。
0x1 的按位 or 会让 bsrq 的输入晚 1 个周期可用,从而贡献了更长的执行成本。
- 注意,虽然
lzcnt 实现中的 movl 会立即开始,但它不能在前序指令 retire 之前 retire,因为 retire 按程序顺序进行。
- 两个序列都是 5 条指令,但
lzcnt 实现具有更高的指令级并行性(ILP),因为 mov 没有依赖。这说明,数指令条数并不一定能告诉我们周期成本。
llvm-mca 很灵活,也可以建模其他处理器:
- AMD Zen3,成本差异更明显(8 周期对 12 周期)。
- Arm Neoverse-V2,服务器 CPU,差异为 7 对 9 周期。
- Arm Cortex-A55,智能手机中常见的小核心,差异为 8 对 10 周期。注意更小的 dispatch width 如何导致指令的
D 阶段开始得更晚。
吞吐量与延迟
设计微基准时,我们有时希望区分吞吐量微基准和延迟微基准。如果某次基准迭代的输入不依赖上一次迭代,处理器可以并行执行多次迭代。通常,对于预期在循环中执行的代码,我们更关心吞吐量;对于被内联在许多位置、夹杂在其他逻辑之间的代码,我们更关心延迟。
可以用 llvm-mca 建模紧密循环中某段代码块的执行。对 lzcnt 版本指定 -iterations=100 后,会得到一组非常不同的结果,因为一次迭代的执行可以与下一次重叠:
1
2
3
4
5
6
7
8
9
|
Iterations: 100
Instructions: 500
Total Cycles: 134
Total uOps: 500
Dispatch Width: 6
uOps Per Cycle: 3.73
IPC: 3.73
Block RThroughput: 1.0
|
通过获得高 ILP,我们能在 134 个周期内执行 100 次迭代(1.34 周期/元素)。
获得最佳性能有时意味着牺牲基本块延迟,以换取更高吞吐量。在 protobuf 的 VarintSize 实现内部,我们使用了向量化版本来实现更高吞吐量,尽管其延迟更差。
循环单次迭代处理 32 个元素需要 29 个周期,即 0.91 周期/元素;但 100 次迭代(3200 个元素)只需要 1217 个周期,即 0.38 周期/元素,约快 3 倍。这展示了设置成本被摊销后的高吞吐量。
理解依赖链
查看 CPU profile 时,我们通常跟踪指令何时 retire。成本会归因到那些需要更久才能 retire 的指令。假设我们 profile 一个以伪随机方式访问内存的小函数:
1
2
3
4
5
6
7
8
9
10
11
|
unsigned Chains(unsigned* x) {
unsigned a0 = x[0];
unsigned b0 = x[1];
unsigned a1 = x[a0];
unsigned b1 = x[b0];
unsigned b2 = x[b1];
return a1 | b2;
}
|
llvm-mca 会把内存加载建模为 L1 命中:加载开始执行后,需要 5 个周期才能让加载值可用。下面输出用源码做了注释,以便阅读。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
Iterations: 1
Instructions: 6
Total Cycles: 19
Total uOps: 9
Dispatch Width: 6
uOps Per Cycle: 0.47
IPC: 0.32
Block RThroughput: 3.0
Timeline view:
012345678
Index 0123456789
[0,0] DeeeeeER . . . movl (%rdi), %ecx // ecx = a0 = x[0]
[0,1] DeeeeeER . . . movl 4(%rdi), %eax // eax = b0 = x[1]
[0,2] D=====eeeeeER . . movl (%rdi,%rax,4), %eax // eax = b1 = x[b0]
[0,3] D==========eeeeeER. movl (%rdi,%rax,4), %eax // eax = b2 = x[b1]
[0,4] D==========eeeeeeER orl (%rdi,%rcx,4), %eax // eax |= a1 = x[a0]
[0,5] .DeeeeeeeE--------R retq
|
在这条时间线中,前两条指令加载 a0 和 b0。这两个操作都可以立即发生。但只有当 b0 的值在寄存器中可用后,x[b0] 的加载才能发生,也就是延迟 5 个周期之后。x[b1] 的加载也必须等 b1 的值再经过 5 个周期后可用。
这个程序有两处可以并行执行加载:a0 和 b0 这一对,以及 a1 和 b1 这一对(注意:llvm-mca 对 orl 中 a1 的内存加载 uop 起始建模并不准确)。由于处理器按程序顺序 retire 指令,即便我们同时有并行加载在进行,也预计 profile 权重会出现在 a0、b1 和 b2 的加载上。
如果查看这个 profile,我们可能会因为某个内存间接访问出现在 profile 中,而尝试优化它。比如神奇地把 a0 替换成常量:
1
2
3
4
5
6
7
8
9
10
11
|
unsigned Chains(unsigned* x) {
unsigned a0 = 0;
unsigned b0 = x[1];
unsigned a1 = x[a0];
unsigned b1 = x[b0];
unsigned b2 = x[b1];
return a1 | b2;
}
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
Iterations: 1
Instructions: 5
Total Cycles: 19
Total uOps: 8
Dispatch Width: 6
uOps Per Cycle: 0.42
IPC: 0.26
Block RThroughput: 2.5
Timeline view:
012345678
Index 0123456789
[0,0] DeeeeeER . . . movl 4(%rdi), %eax
[0,1] D=====eeeeeER . . movl (%rdi,%rax,4), %eax
[0,2] D==========eeeeeER. movl (%rdi,%rax,4), %eax
[0,3] D==========eeeeeeER orl (%rdi), %eax
[0,4] .DeeeeeeeE--------R retq
|
虽然我们消除了 CPU profile 中看到的“昂贵”加载,但实际上没有改变由 3 次加载组成的 b 链主导的关键路径总长度。时间线视图显示了函数的关键路径;只有缩短关键路径持续时间,性能才会改善。
优化 CRC32C
CRC32C 是常见哈希函数,现代架构提供了专门指令来计算它。对于短输入,我们主要是在处理奇数个字节。对于大输入,我们受限于每隔几个输入字节就反复调用 crc32q(x86)或类似指令。通过检查这种重复调用,可以观察处理器如何执行它:
1
2
3
4
5
6
7
8
9
|
uint32_t BlockHash() {
asm volatile("# LLVM-MCA-BEGIN");
uint32_t crc = 0;
for (int i = 0; i < 16; ++i) {
crc = _mm_crc32_u64(crc, i);
}
asm volatile("# LLVM-MCA-END" : "+r"(crc));
return crc;
}
|
这个函数并不计算任何有用哈希,但它让我们看到一个 crc32q 输出如何紧接着被下一个 crc32q 作为输入使用。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
|
Iterations: 1
Instructions: 32
Total Cycles: 51
Total uOps: 32
Dispatch Width: 6
uOps Per Cycle: 0.63
IPC: 0.63
Block RThroughput: 16.0
Instruction Info:
[1]: #uOps
[2]: Latency
[3]: RThroughput
[4]: MayLoad
[5]: MayStore
[6]: HasSideEffects (U)
[1] [2] [3] [4] [5] [6] Instructions:
1 0 0.17 xorl %eax, %eax
1 3 1.00 crc32q %rax, %rax
1 1 0.25 movl $1, %ecx
1 3 1.00 crc32q %rcx, %rax
...
Resources:
[0] - SKLDivider
[1] - SKLFPDivider
[2] - SKLPort0
[3] - SKLPort1
[4] - SKLPort2
[5] - SKLPort3
[6] - SKLPort4
[7] - SKLPort5
[8] - SKLPort6
[9] - SKLPort7
Resource pressure per iteration:
[0] [1] [2] [3] [4] [5] [6] [7] [8] [9]
- - 4.00 18.00 - 1.00 - 5.00 6.00 -
Resource pressure by instruction:
[0] [1] [2] [3] [4] [5] [6] [7] [8] [9] Instructions:
- - - - - - - - - - xorl %eax, %eax
- - - 1.00 - - - - - - crc32q %rax, %rax
- - - - - - - - 1.00 - movl $1, %ecx
- - - 1.00 - - - - - - crc32q %rcx, %rax
- - - - - - - 1.00 - - movl $2, %ecx
- - - 1.00 - - - - - - crc32q %rcx, %rax
...
- - - - - - - - 1.00 - movl $15, %ecx
- - - 1.00 - - - - - - crc32q %rcx, %rax
- - - - - 1.00 - 1.00 1.00 - retq
Timeline view:
0123456789 0123456789 0
Index 0123456789 0123456789 0123456789
[0,0] DR . . . . . . . . . . xorl %eax, %eax
[0,1] DeeeER . . . . . . . . . crc32q %rax, %rax
[0,2] DeE--R . . . . . . . . . movl $1, %ecx
[0,3] D===eeeER . . . . . . . . . crc32q %rcx, %rax
[0,4] DeE-----R . . . . . . . . . movl $2, %ecx
[0,5] D======eeeER . . . . . . . . crc32q %rcx, %rax
...
[0,30] . DeE---------------------------------------R . movl $15, %ecx
[0,31] . D========================================eeeER crc32q %rcx, %rax
|
根据 Instruction Info 表,crc32q 延迟为 3,吞吐量为 1:每个时钟周期都可以在 port 1(表中的 [3])上开始处理一次新调用,但结果需要 3 个周期才能可用。
指令会分解为单独的微操作(uop)。Resources 部分列出了处理器执行流水线(通常称为 port)。每个周期都可以向这些 port 发出 uop。这里存在约束:没有哪个 port 能接受所有类型的 uop,并且每个周期能 dispatch 到处理器流水线的 uop 数量也有上限。
对于函数中的指令,指令数与执行的 uop 数是一一对应的,因此二者相等(32)。处理器有多个后端用于处理 uop。从资源压力表可以看到,crc32 必须在 port 1 上执行,而 movl 可以在 port 0、1、5、6 中任一 port 上执行。
在时间线视图中可以看到,对于背靠背序列,第二个 crc32q 直到第一个 crc32q 完成后的几个周期内都无法真正开始处理。这说明我们没有充分利用 port 1 的能力,因为它的吞吐量表明每周期都可以向它 dispatch 一条指令。
如果把 BlockHash 重构为计算 3 条并行流,并用模拟的组合函数合并(代码中用按位或作为正确逻辑所需组合方式的占位),就可以用更少时钟周期完成同样的工作:
1
2
3
4
5
6
7
8
9
10
|
uint32_t ParallelBlockHash(const char* p) {
uint32_t crc0 = 0, crc1 = 0, crc2 = 0;
for (int i = 0; i < 5; ++i) {
crc0 = _mm_crc32_u64(crc0, 3 * i + 0);
crc1 = _mm_crc32_u64(crc1, 3 * i + 1);
crc2 = _mm_crc32_u64(crc2, 3 * i + 2);
}
crc0 = _mm_crc32_u64(crc0, 15);
return crc0 | crc1 | crc2;
}
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
|
Iterations: 1
Instructions: 36
Total Cycles: 22
Total uOps: 36
Dispatch Width: 6
uOps Per Cycle: 1.64
IPC: 1.64
Block RThroughput: 16.0
Timeline view:
0123456789
Index 0123456789 01
[0,0] DR . . . .. xorl %eax, %eax
[0,1] DR . . . .. xorl %ecx, %ecx
[0,2] DeeeER . . .. crc32q %rcx, %rcx
[0,3] DeE--R . . .. movl $1, %esi
[0,4] D----R . . .. xorl %edx, %edx
[0,5] D=eeeER . . .. crc32q %rsi, %rdx
[0,6] .DeE--R . . .. movl $2, %esi
[0,7] .D=eeeER . . .. crc32q %rsi, %rax
[0,8] .DeE---R . . .. movl $3, %esi
[0,9] .D==eeeER . . .. crc32q %rsi, %rcx
[0,10] .DeE----R . . .. movl $4, %esi
[0,11] .D===eeeER. . .. crc32q %rsi, %rdx
...
[0,32] . DeE-----------R.. movl $15, %esi
[0,33] . D==========eeeER. crc32q %rsi, %rcx
[0,34] . D============eER. orl %edx, %eax
[0,35] . D=============eER orl %ecx, %eax
|
该实现调用 crc32q 的次数相同,但代码块端到端延迟从 51 周期降到 22 周期。时间线视图显示,处理器可以每周期发出一条 crc32 指令。
这种建模可以通过 absl::ComputeCrc32c 的微基准结果得到验证。真实实现使用多条流,并正确组合它们。消融这些流会导致回退,从而验证该技巧的价值。
1
2
3
4
5
6
7
|
name CYCLES/op CYCLES/op vs base
BM_Calculate/0 5.007 ± 0% 5.008 ± 0% ~ (p=0.149 n=6)
BM_Calculate/1 6.669 ± 1% 8.012 ± 0% +20.14% (p=0.002 n=6)
BM_Calculate/100 30.82 ± 0% 30.05 ± 0% -2.49% (p=0.002 n=6)
BM_Calculate/2048 285.6 ± 0% 644.8 ± 0% +125.78% (p=0.002 n=6)
BM_Calculate/10000 906.7 ± 0% 3633.8 ± 0% +300.78% (p=0.002 n=6)
BM_Calculate/500000 37.77k ± 0% 187.69k ± 0% +396.97% (p=0.002 n=6)
|
如果为 ParallelBlockHash 创建第 4 条流,llvm-mca 会显示整体延迟没有变化,因为瓶颈已经在 port 1 的吞吐量上。继续展开会增加组合流的额外开销,并让预取更困难,却不会真正改善性能。
为了改善性能,许多快速 CRC32C 实现会使用其他处理器特性。像 carryless multiply 指令(x86 上的 pclmulqdq)这样的指令,可以用于实现另一条并行流。这样可以通过使用处理器其他 port 提取额外 ILP,而不会恶化 crc32 所用 port 上的瓶颈。
局限性
虽然 llvm-mca 在许多场景中很有用,但它的建模有局限:
- 内存访问被建模为 L1 命中。在真实世界中,当需要访问 L2、L3,甚至主内存时,停顿可能长得多。
- 它不能建模分支预测器行为。
- 它不建模取指和解码步骤。
- 它的分析质量取决于 LLVM 的处理器模型。如果这些模型不能准确建模处理器,模拟结果就可能与真实处理器不同。
例如,许多 ARM 处理器模型并不完整,而 llvm-mca 会选择一个它估计可作为良好替代的处理器模型;这对编译器启发式通常没问题,因为只有当差异会导致生成不同代码时才重要,但它可能让手工优化工作跑偏。
结语
理解处理器如何执行和 retire 指令,可以为优化函数提供强有力的洞见。llvm-mca 让我们得以窥视处理器内部,从而理解瓶颈和未被充分利用的资源。
扩展阅读
上一篇:每周性能技巧 #97:良性生态循环
上一节
下一篇:每周性能技巧 #98:测量也有投资回报率
下一节