1
2
3
4
5
6
7
作者:李晓辉

联系方式:

1. 微信:Lxh_Chat

2. 邮箱:939958092@qq.com

前面几篇,我们把 CPU 调度、实时调度、绑核、隔离、中断绑定、tuned cpu-partitioning 和 tuna 都走了一遍。这些手段的共同目标是:让关键任务稳定地拿到 CPU。

但还有一类现象,调度和隔离都救不了:

业务延迟很高,吞吐上不去,top 里 CPU 看着也不闲,可是 CPU 好像一直在”磨洋工”。

这时候先别怀疑调度器。你要问的是:CPU 拿到了,它在干活,还是在等数据? CPU 访问 L1 缓存只要几个时钟周期,访问主内存却要几百个周期。缓存一旦大量缺失,再强的核心也只能停在那里等内存返回。

有一个很多人没意识到的事实:

CPU 因等内存而停顿的时间,在 top 里仍然算”忙”。

%CPU 只回答”CPU 有没有被占着”,不回答”占着的时候在不在干活”。这一篇我们就来补上这块认知。

CPU 为什么需要多级缓存

现代 x86 处理器采用分层存储,越靠近核心越快、越小,越往下越大、越慢:

flowchart LR
    R["寄存器"] --> L1["L1 缓存<br/>每核私有<br/>几十 KB"]
    L1 --> L2["L2 缓存<br/>每核或每组核<br/>几百 KB 到几 MB"]
    L2 --> L3["L3 / LLC<br/>多核共享<br/>几十 MB"]
    L3 --> M["主内存 DRAM<br/>几十 GB 以上"]

各层的特点

L1 缓存,每个物理核心私有,分两块:

  • L1i:存放要执行的指令;
  • L1d:存放程序运算用到的数据。

L2 缓存,设计上有两种:每核独立,或者几个核心组成一个小集群共享一份。

L3 缓存,也叫 LLC(Last-Level Cache,末级缓存),是缓存和主内存之间的最后一道缓冲,容量最大,延迟也最高。Intel 的 L3 通常由同一个插槽上的核心共享,但别把这当成通用规律:AMD 的 L3 是按核心复合体(CCX/CCD)划分的,一个插槽里可能有好几块互不共享的 L3。

一个典型的大小核例子

以 Intel Core i7-1365U 这类大小核处理器为例:

flowchart TB
    subgraph P["P-Core 性能核 × 2"]
        P1["每核私有 L1i / L1d"] --> P2["每核独立 L2"]
    end
    subgraph E["E-Core 效率核 × 8,4 核一组"]
        E1["每核私有 L1i / L1d"] --> E2["每组 4 核共享一份 L2"]
    end
    P2 --> L3["L3 / LLC:全部核心共享 12 MiB"]
    E2 --> L3
    L3 --> M["主内存"]

这也解释了 lscpu 里的数字:L2 合计 6.5 MiB,是 2 个 P 核各自的 L2 加上 2 组 E 核共享的 L2 加起来的总和。lscpu 默认给的是总量,看不出大小核各自的结构。另外 P 核支持超线程,所以 2 个 P 核加 8 个 E 核正好是 12 个逻辑 CPU。

访问延迟:数量级要有概念

层级时钟周期(量级)时间(量级)
L14~5约 1 ns
L212~14约 3~4 ns
L3几十约 10~20 ns
主内存数百约 50~100 ns

这些数字随 CPU 型号变化,记住量级关系就够了:主内存比 L1 慢几十到上百倍。

在自己的机器上看缓存拓扑

lscpu:先看总览

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
[root@localhost ~]# lscpu
Architecture: x86_64
CPU op-mode(s): 32-bit, 64-bit
Address sizes: 45 bits physical, 48 bits virtual
Byte Order: Little Endian
CPU(s): 4
On-line CPU(s) list: 0-3
Vendor ID: GenuineIntel
BIOS Vendor ID: GenuineIntel
Model name: 11th Gen Intel(R) Core(TM) i7-11800H @ 2.30GHz
BIOS Model name: 11th Gen Intel(R) Core(TM) i7-11800H @ 2.30GHz CPU @ 2.3GHz
BIOS CPU family: 2
CPU family: 6
Model: 141
Thread(s) per core: 1
Core(s) per socket: 2
Socket(s): 2
Stepping: 1
BogoMIPS: 4608.00
Flags: fpu vme de pse tsc msr pae mce cx8 apic sep mtrr pge mca cmov pat pse36 clflush mmx fxsr sse sse2 ss ht syscall nx pdpe1gb rdtscp lm constant_tsc arch_perf
mon rep_good nopl xtopology tsc_reliable nonstop_tsc cpuid tsc_known_freq pni pclmulqdq vmx ssse3 fma cx16 pcid sse4_1 sse4_2 x2apic movbe popcnt tsc_deadl
ine_timer aes xsave avx f16c rdrand hypervisor lahf_lm abm 3dnowprefetch ssbd ibrs ibpb stibp ibrs_enhanced tpr_shadow ept vpid ept_ad fsgsbase tsc_adjust
bmi1 avx2 smep bmi2 erms invpcid avx512f avx512dq rdseed adx smap avx512ifma clflushopt clwb avx512cd sha_ni avx512bw avx512vl xsaveopt xsavec xgetbv1 xsav
es user_shstk arat vnmi avx512vbmi umip pku ospke avx512_vbmi2 gfni vaes vpclmulqdq avx512_vnni avx512_bitalg avx512_vpopcntdq rdpid movdiri movdir64b fsrm
avx512_vp2intersect md_clear flush_l1d arch_capabilities
Virtualization features:
Virtualization: VT-x
Hypervisor vendor: VMware
Virtualization type: full
Caches (sum of all):
L1d: 192 KiB (4 instances)
L1i: 128 KiB (4 instances)
L2: 5 MiB (4 instances)
L3: 48 MiB (2 instances)
NUMA:
NUMA node(s): 1
NUMA node0 CPU(s): 0-3
Vulnerabilities:
Gather data sampling: Unknown: Dependent on hypervisor status
Indirect target selection: Mitigation; Aligned branch/return thunks
Itlb multihit: KVM: Vulnerable
L1tf: Not affected
Mds: Not affected
Meltdown: Not affected
Mmio stale data: Not affected
Old microcode: Not affected
Reg file data sampling: Not affected
Retbleed: Not affected
Spec rstack overflow: Not affected
Spec store bypass: Mitigation; Speculative Store Bypass disabled via prctl
Spectre v1: Mitigation; usercopy/swapgs barriers and __user pointer sanitization
Spectre v2: Mitigation; Enhanced / Automatic IBRS; IBPB conditional; PBRSB-eIBRS SW sequence; BHI SW loop, KVM SW loop
Srbds: Not affected
Tsa: Not affected
Tsx async abort: Not affected
Vmscape: Not affected
[root@localhost ~]#

重点看 L1d、L1i、L2、L3 几行。注意括号里的 instances,它表示这一级有几份物理缓存。我前面文章里的虚拟机,L3 是 48 MiB (2 instances),对应 2 个 socket 各一份。

lscpu -C:按缓存级别列出,推荐

1
lscpu -C

这个输出能看到每一级缓存的单份大小、总大小、路数(WAYS)、组数(SETS)和缓存行大小(COHERENCY-SIZE)。后面讲缓存行和关联性时,我们直接用它来读数据。

lscpu -e:看谁和谁共享缓存

之前讲 SMT 时用过 lscpu -e,其中有一列 L1d:L1i:L2:L3,冒号分隔的是每一级缓存的编号。编号相同,就说明这些逻辑 CPU 共享同一块缓存。

1
2
3
4
5
6
7
[root@localhost ~]# lscpu -e
CPU NODE SOCKET CORE L1d:L1i:L2:L3 ONLINE
0 0 0 0 0:0:0:0 yes
1 0 0 1 1:1:1:0 yes
2 0 1 2 2:2:2:1 yes
3 0 1 3 3:3:3:1 yes

CPU 2 和 CPU 3 的 L1、L2 编号不同(私有),L3 编号都是 1(共享)。

sysfs:想看细节的话

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
[root@localhost ~]# grep -H . /sys/devices/system/cpu/cpu0/cache/index*/{level,type,size,ways_of_associativity,coherency_line_size,shared_cpu_list}
/sys/devices/system/cpu/cpu0/cache/index0/level:1
/sys/devices/system/cpu/cpu0/cache/index1/level:1
/sys/devices/system/cpu/cpu0/cache/index2/level:2
/sys/devices/system/cpu/cpu0/cache/index3/level:3
/sys/devices/system/cpu/cpu0/cache/index0/type:Data
/sys/devices/system/cpu/cpu0/cache/index1/type:Instruction
/sys/devices/system/cpu/cpu0/cache/index2/type:Unified
/sys/devices/system/cpu/cpu0/cache/index3/type:Unified
/sys/devices/system/cpu/cpu0/cache/index0/size:48K
/sys/devices/system/cpu/cpu0/cache/index1/size:32K
/sys/devices/system/cpu/cpu0/cache/index2/size:1280K
/sys/devices/system/cpu/cpu0/cache/index3/size:24576K
/sys/devices/system/cpu/cpu0/cache/index0/ways_of_associativity:12
/sys/devices/system/cpu/cpu0/cache/index1/ways_of_associativity:8
/sys/devices/system/cpu/cpu0/cache/index2/ways_of_associativity:20
/sys/devices/system/cpu/cpu0/cache/index3/ways_of_associativity:12
/sys/devices/system/cpu/cpu0/cache/index0/coherency_line_size:64
/sys/devices/system/cpu/cpu0/cache/index1/coherency_line_size:64
/sys/devices/system/cpu/cpu0/cache/index2/coherency_line_size:64
/sys/devices/system/cpu/cpu0/cache/index3/coherency_line_size:64
/sys/devices/system/cpu/cpu0/cache/index0/shared_cpu_list:0
/sys/devices/system/cpu/cpu0/cache/index1/shared_cpu_list:0
/sys/devices/system/cpu/cpu0/cache/index2/shared_cpu_list:0
/sys/devices/system/cpu/cpu0/cache/index3/shared_cpu_list:0-1

shared_cpu_list 直接告诉你这块缓存被哪些 CPU 共享。

lshw:能看,但没必要

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
[root@localhost ~]# lshw -class memory | more
*-firmware
description: BIOS
vendor: Phoenix Technologies LTD
physical id: 0
version: 6.00
date: 02/17/2026
size: 86KiB
capabilities: isa pci pcmcia pnp apm upgrade shadowing escd cdboot bootselect edd int5printscreen int9keyboard int14serial int17printer int10video acpi smartbattery biosbootspec
ification netboot
*-cache:0
description: L1 cache
physical id: 0
slot: L1
size: 16KiB
capacity: 16KiB
capabilities: asynchronous internal write-back
configuration: level=1
*-cache:1
description: L1 cache
physical id: 1
slot: L1
size: 16KiB
capacity: 16KiB
capabilities: asynchronous internal write-back
configuration: level=1
...

它也能列出缓存,但输出里同一份 L3 可能出现多次,容易让人误以为有多份硬件。日常用上面几个命令就够了。

缓存行:CPU 搬数据的最小单位

CPU 和缓存之间传输数据,不是一个字节一个字节搬的,而是按缓存行(Cache Line)。x86 上通常是 64 字节(lscpu -C 的 COHERENCY-SIZE 可以验证,其他架构可能是 128 字节)。

哪怕你只想读 1 个字节,硬件也会把它所在的整条 64 字节缓存行都加载进来。

这个特性直接决定了程序的访问模式好不好:

  • 顺序访问:读完一个 int,后面 15 个 int 已经在缓存里了,白捡;硬件预取器还会提前把后面的行也拉进来;
  • 跳跃访问:每次访问都落在新的缓存行上,几乎每次都要去下一级拿数据。

命中、缺失与”内存墙”

缓存命中(hit):要的数据就在当前这一级,直接读,很快。

缓存缺失(miss):当前这一级没有,要向下一级请求,一路可能走到主内存。数据取回后,整条 64 字节缓存行被装入本级缓存,这个动作叫缓存行填充(line fill),完成后 CPU 才能继续。

在等数据的这段时间里,CPU 的流水线是停着的。这就是所谓的内存墙。

flowchart LR
    A["CPU 要读数据"] --> B{"L1 有吗?"}
    B -- 有 --> H["命中,几个周期"]
    B -- 没有 --> C{"L2 有吗?"}
    C -- 有 --> H2["命中,十几个周期"]
    C -- 没有 --> D{"L3 有吗?"}
    D -- 有 --> H3["命中,几十个周期"]
    D -- 没有 --> M["访问主内存<br/>数百个周期,CPU 停顿"]

再强调一遍开头那个关键点:停顿的这些周期,内核仍然把它记作该 CPU 在运行进程,所以 top、mpstat 里它就是”忙”的。判断 CPU 是否真的在干活,要看 IPC(每周期执行的指令数),不能只看使用率。

多核一致性:缓存侦听与伪共享

多核机器上,同一块内存的数据可能同时被几个核心加载到各自私有的缓存里,于是有了好几份副本。一旦某个核心修改了它,其他核心手里的副本就过期了。硬件必须保证大家看到的数据一致,这套机制就是缓存一致性协议(常见的是 MESI),其中”监听别人的修改并让自己的副本失效”这一步叫侦听(Snoop)。

sequenceDiagram
    participant C0 as CPU 0 的缓存
    participant IC as 互联与 L3
    participant C1 as CPU 1 的缓存
    C0->>C0: 修改缓存行 X,状态变为 Modified
    C0->>IC: 通知其他核心,X 的旧副本失效
    IC->>C1: 使 X 无效
    C1->>C1: 本地副本标记为 Invalid
    C1->>IC: 再读 X,触发缓存缺失
    IC-->>C1: 返回最新数据

真共享与伪共享

这里有两种情况,后一种更常见,也更隐蔽:

  • 真共享:多个线程频繁修改同一个变量。这是逻辑上必须同步的,开销难免;
  • 伪共享(False Sharing):多个线程各自修改不同的变量,但这些变量恰好落在同一条 64 字节缓存行里。逻辑上互不相干,硬件却把整条缓存行在核心之间来回抢,性能白白下降。
1
2
3
4
5
6
7
8
9
10
struct counters {
long a; // 线程 1 频繁写
long b; // 线程 2 频繁写,和 a 在同一条缓存行里
};

// 修复:让它们各占一条缓存行
struct counters {
long a __attribute__((aligned(64)));
long b __attribute__((aligned(64)));
};

这和我们前面讲的绑核有关联:线程在不同核心之间迁移,缓存行就要跟着”搬家”;多线程频繁改相邻数据,缓存行就要来回”抢”。想定位伪共享,可以用 perf c2c,在裸金属上效果最好。

缓存写策略:直写与回写

CPU 修改缓存里的数据,什么时候同步到主内存?有两种策略。

直写(Write-Through)

每次写,同时更新缓存和主内存。

  • 优点:主内存永远是最新的;
  • 缺点:每次写都占用内存总线,性能差,所以很少用于普通内存。

回写(Write-Back)

只更新缓存,并把这条缓存行标记为脏(dirty),不立刻写主内存。等这条缓存行因为空间不足被驱逐时,才把它写回去。

  • 优点:同一条缓存行被多次修改,只需要写回一次,大幅降低内存流量。这是 x86 普通内存的默认方式;
  • 缺点:缓存和内存之间短暂不一致,要靠前面讲的一致性协议保证多核看到的数据正确。

设备寄存器怎么办:不缓存

很多资料把设备寄存器(MMIO)说成”强制直写”,这是不准确的。MMIO 区域通常被映射为不可缓存(Uncacheable),显存之类的区域会用写合并(Write-Combining)。原因很简单:设备寄存器的值随时可能被硬件改变,读它必须真的去读设备,经过缓存就会读到旧值。

缓存关联性:数据能放在缓存的哪几个位置

一个内存地址的数据,可以放在缓存里的哪些位置?这就是关联性。

  • 直接映射:每个地址只能放在唯一一个位置。电路简单,但不同地址容易互相挤掉,产生冲突缺失,哪怕缓存还有大把空位;
  • 全关联:可以放在任意位置。理论上没有冲突缺失,但需要同时比较所有标签,电路复杂、功耗高,只用在 TLB 之类很小的结构里;
  • N 路组相联:现代 CPU 的通用方案。缓存分成许多组(set),每组有 N 个位置(N 路),地址决定它属于哪一组,组内 N 个位置任选一个。

它们其实是同一个模型的不同取值:直接映射是 1 路,全关联是只有 1 组。

一个能解释很多”怪现象”的例子

假设 L1d 是 32 KiB、8 路、缓存行 64 字节,那么组数 = 32768 ÷ (64 × 8) = 64 组。地址里决定组号的是第 6~11 位,也就是说,两个地址相差 4096 字节的整数倍,就会落进同一组。

如果程序按 4096 字节(或 16 KiB 这样的 2 的幂)的步长访问数据,所有数据都挤在同一组的 8 个位置里,第 9 个访问就会把最早的挤出去。缓存总容量再大,也用不上。

这就是为什么二维数组按列遍历、步长恰好是 2 的幂时,性能会格外糟糕。

缓存效率与前面学过的调优

缓存命中率往往是决定程序实际速度的关键因素之一,主频和核数只是纸面参数。

回头看老李前面几篇,很多手段的深层收益都和缓存有关:

前面学过的手段缓存层面的收益
CPU 亲和性(绑核)线程不跨核迁移,热数据留在该核的 L1/L2,不必重新填充
CPU 隔离别的任务不在这个核上运行,缓存里的东西不会被冲掉
中断绑定中断处理代码不在业务核上执行,不污染它的缓存
NUMA 绑定内存和 CPU 在同一节点,缓存缺失时的代价更小

工具怎么选

flowchart TD
    A["想分析缓存行为"] --> B{"在什么环境?"}
    B -- "虚拟机,没开 vPMU" --> C["valgrind cachegrind<br/>软件仿真,只能看相对差异"]
    B -- "裸金属,或虚拟机已开 vPMU" --> D["perf<br/>读取硬件计数器,反映真实情况"]
    C --> E["开发测试阶段,对比优化前后"]
    D --> F["生产环境排查"]

valgrind cachegrind:软件仿真缓存

Cachegrind 是软件仿真工具,它不读取 CPU 的硬件性能计数器,而是自己模拟一套缓存来统计。

  • 优点:不依赖硬件,虚拟机里也能跑;
  • 缺点:程序会慢几十倍;而且它只模拟 I1、D1 和 LL 三块,不模拟硬件预取、TLB 和多核一致性,所以顺序访问的缺失率会比真机偏高。它只适合做优化前后的相对对比,不能当作真实硬件的绝对数值。

安装与编译

1
2
3
4
5
dnf install valgrind -y

# 带上调试符号,才能看到源码行级别的统计
# 优化级别尽量和生产一致,下面的小实验用 -O1,原因后面说
gcc -O1 -g -o cache_test cache_test.c

运行

1
valgrind --tool=cachegrind --cache-sim=yes ./cache_test 0

新版 Valgrind 里 --cache-sim 默认就是 yes,写上只是更明确;版本比较老的话需要显式写。运行结束后会生成 cachegrind.out.<pid>,可以用 cg_annotate 解析到源码行:

1
cg_annotate cachegrind.out.<pid>

输出字段

字段含义
I refs、I1 misses指令访问总数、L1 指令缓存缺失数
D refs、D1 misses数据访问总数、L1 数据缓存缺失数
LL refs访问末级缓存的次数,也就是 L1 缺失后继续往下的请求数
LL misses末级缓存也没有,只能去主内存的次数
D1 miss rateL1 数据缺失数 ÷ 数据访问总数
LL miss rateLL 缺失数 ÷ 总访存次数(指令加数据)

注意最后一行:Cachegrind 的 LL miss rate 分母是总访存次数,和下面 perf 里”占 LLC 访问的百分比”不是同一个口径,不要拿两个工具的百分比直接比较,比的应该是绝对次数和优化前后的变化。

perf:读硬件性能计数器

perf 读取 CPU 内置的性能监控单元(PMU),开销很小,反映的是真实硬件行为,适合在裸金属生产环境使用。

安装与权限

1
dnf install perf -y

用 root 运行最省事。普通用户会受 /proc/sys/kernel/perf_event_paranoid 限制。

虚拟机里的限制

虚拟机必须由虚拟化层开启 vPMU(虚拟性能计数器),否则缓存相关事件会显示 <not supported>。在 VMware 里对应的是虚拟机设置中的”虚拟化 CPU 性能计数器”。物理机没有这个限制。

查看可用事件

1
perf list hwcache

常用的几个:

事件含义
L1-dcache-loadsL1 数据缓存加载次数
L1-dcache-load-missesL1 数据缓存加载缺失次数
LLC-loads末级缓存加载次数
LLC-load-misses末级缓存加载缺失,请求去了主内存
dTLB-load-misses数据 TLB 缺失

在 Intel 大小核 CPU 上,事件名会带前缀,形如 cpu_core/LLC-load-misses/ 和 cpu_atom/LLC-load-misses/,分别对应性能核和效率核,这是正常的。

示例:统计 tar 的缓存行为

1
2
3
4
perf stat -e instructions,cycles \
-e L1-dcache-loads,L1-dcache-load-misses \
-e LLC-loads,LLC-load-misses \
tar -cf backup.tar /etc

下面是一次示例输出(数字仅作演示,请换成你自己机器上的实测):

1
2
3
4
5
6
7
8
9
10
196,624,236      instructions
224,882,255 cycles # 0.87 insn per cycle

48,017,596 L1-dcache-loads
3,766,268 L1-dcache-load-misses # 7.84% of all L1-dcache accesses

738,174 LLC-loads
242,715 LLC-load-misses # 32.88% of all LL-cache accesses

0.069417020 seconds time elapsed

怎么读这组数据:

  1. 4800 万次 L1 数据加载,其中 7.84%(约 377 万次)没命中 L1,被送往下一级;
  2. 这 377 万次请求里,L2 又拦下了大部分,最终只有约 74 万次到达 L3;
  3. 到达 L3 的请求里有 32.88%(约 24 万次)没命中,需要访问主内存;
  4. 折算下来,约 0.5% 的访问穿透了全部缓存,这就是多级缓存的过滤效果。

这里有两点要提醒:

  • perf stat 默认统计的是用户态加内核态。tar 这种程序大量时间花在系统调用和文件 I/O 上,所以 IPC 0.87 并不能直接证明它受内存限制,IPC 只是一个线索,不是结论;
  • LLC-load-misses 一般只统计需求加载,不包含预取,所以”0.5% 落到主内存”是个近似值。

想快速多看几项,可以加 -d:

1
perf stat -d tar -cf backup.tar /etc

动手实验:顺序访问和跨步访问差多少

光看概念没感觉,写一个小程序,对比”按行遍历”和”按列遍历”同一个二维数组:

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
// cache_test.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

#define N 4096 // 4096 x 4096 x 4 字节 = 64 MiB,远大于 L3

int main(int argc, char **argv) {
int mode = (argc > 1) ? atoi(argv[1]) : 0; // 0 按行,1 按列
int *a = malloc((size_t)N * N * sizeof(int));
memset(a, 1, (size_t)N * N * sizeof(int));
long sum = 0;

if (mode == 0) {
for (int i = 0; i < N; i++)
for (int j = 0; j < N; j++)
sum += a[(size_t)i * N + j]; // 连续访问
} else {
for (int j = 0; j < N; j++)
for (int i = 0; i < N; i++)
sum += a[(size_t)i * N + j]; // 步长 16 KiB 跳着访问
}
printf("%ld\n", sum);
free(a);
return 0;
}

编译用 -O1,不要用 -O3,因为高优化级别下编译器可能自动交换循环顺序,把你要演示的差异抹掉。

1
2
3
4
5
6
7
8
9
10
gcc -O1 -g -o cache_test cache_test.c

# 用 cachegrind 对比(虚拟机里可用,会比较慢)
valgrind --tool=cachegrind ./cache_test 0
valgrind --tool=cachegrind ./cache_test 1

# 用 perf 对比(裸金属,或虚拟机开启 vPMU)
# 顺便用上前面学的绑核,让数据更稳定
taskset -c 2 perf stat -e cycles,instructions,L1-dcache-loads,L1-dcache-load-misses,LLC-loads,LLC-load-misses ./cache_test 0
taskset -c 2 perf stat -e cycles,instructions,L1-dcache-loads,L1-dcache-load-misses,LLC-loads,LLC-load-misses ./cache_test 1

你应该观察到:

  • 按行(mode 0):每 16 个 int 才缺失一次(64 字节 ÷ 4 字节),Cachegrind 里 D1 miss rate 理论上约 6.25%;
  • 按列(mode 1):几乎每次访问都落在新的缓存行上,D1 miss rate 接近 100%,LLC 缺失和运行时间都明显更高。步长 16 KiB 还是 2 的幂,会叠加上一节讲的组冲突。

具体数值以你的机器为准,重点是看差距。

调优建议

  1. 别只看 CPU 使用率。 怀疑内存瓶颈时,先看 IPC 和缓存缺失,再决定要不要深挖;
  2. 绑核是保护缓存的一种手段:线程不迁移,热数据留在私有缓存里;
  3. NUMA 多插槽机器,让进程尽量使用本节点的内存;
  4. 写程序时:尽量顺序、连续地访问内存,避免 2 的幂步长,多线程写的数据用对齐隔开,避免伪共享;
  5. 工具分工:开发测试用 Cachegrind 做相对对比;生产排查优先用 perf,虚拟机里先确认 vPMU。

常见误区

误区一:CPU 使用率低,所以 CPU 不是瓶颈。
反过来才对:等内存的时间也算使用率,使用率高不代表 CPU 在高效工作。

误区二:IPC 低就一定是缓存问题。
只是线索。分支预测失败、系统调用、I/O 等待都会拉低 IPC。

误区三:缓存总容量大就不会冲突。
组相联结构下,访问模式不当照样发生冲突缺失。

误区四:Cachegrind 的数值就是真机的数值。
它是仿真,不含预取等机制,只适合做相对对比。

误区五:虚拟机里 perf 缓存事件为 0,说明没有缓存缺失。
是没开 vPMU,计数器根本不可用,应该是 <not supported>。

误区六:设备寄存器用直写模式。
通常是不可缓存。

命令速查

目的命令
缓存总览lscpu
每级缓存的路数、组数、缓存行大小lscpu -C
看哪些 CPU 共享哪块缓存lscpu -e,或 sysfs 的 shared_cpu_list
软件仿真缓存valgrind --tool=cachegrind --cache-sim=yes ./app
解析仿真结果cg_annotate cachegrind.out.<pid>
列出缓存硬件事件perf list hwcache
统计缓存事件perf stat -e L1-dcache-load-misses,LLC-load-misses ./app
快速多看几项perf stat -d ./app
定位伪共享perf c2c record 和 perf c2c report

课后思考

  1. 为什么缓存停顿的时间在 top 里仍然算 CPU 忙?你会用什么指标识别这种情况?
  2. 一个 32 KiB、8 路、64 字节缓存行的 L1d 有多少组?步长多少字节的访问会反复落在同一组?
  3. 两个线程分别对 counters.a 和 counters.b 做自增,为什么它们明明不共享变量,性能却比各自用独立缓存行差很多?
  4. Cachegrind 和 perf 给出的 LL 缺失率为什么不能直接比较?
  5. 前面学过的绑核、NUMA 绑定和 CPU 隔离,各自分别在保护缓存的哪一环?

参考手册

1
2
3
4
5
man lscpu
man valgrind
man perf-stat
man perf-list
man perf-c2c