每周性能技巧 #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 日。

RISCCISC 之争最终以平局收场:现代处理器会把指令分解成由后端执行单元处理的微操作。理解这些单元如何执行指令,可以帮助我们洞察如何优化受后端限制的关键函数。本篇会演示如何使用 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

在这条时间线中,前两条指令加载 a0b0。这两个操作都可以立即发生。但只有当 b0 的值在寄存器中可用后,x[b0] 的加载才能发生,也就是延迟 5 个周期之后。x[b1] 的加载也必须等 b1 的值再经过 5 个周期后可用。

这个程序有两处可以并行执行加载:a0b0 这一对,以及 a1b1 这一对(注意:llvm-mcaorla1 的内存加载 uop 起始建模并不准确)。由于处理器按程序顺序 retire 指令,即便我们同时有并行加载在进行,也预计 profile 权重会出现在 a0b1b2 的加载上。

如果查看这个 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:测量也有投资回报率

下一节