Linux 性能调优系列(十七): CPU 占用不高却跑的慢?深挖 CPU 缓存性能瓶颈
1 | 作者:李晓辉 |
前面几篇,我们把 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。
访问延迟:数量级要有概念
| 层级 | 时钟周期(量级) | 时间(量级) |
|---|---|---|
| L1 | 4~5 | 约 1 ns |
| L2 | 12~14 | 约 3~4 ns |
| L3 | 几十 | 约 10~20 ns |
| 主内存 | 数百 | 约 50~100 ns |
这些数字随 CPU 型号变化,记住量级关系就够了:主内存比 L1 慢几十到上百倍。
在自己的机器上看缓存拓扑
lscpu:先看总览
1 | [root@localhost ~]# lscpu |
重点看 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 | [root@localhost ~]# lscpu -e |
CPU 2 和 CPU 3 的 L1、L2 编号不同(私有),L3 编号都是 1(共享)。
sysfs:想看细节的话
1 | [root@localhost ~]# grep -H . /sys/devices/system/cpu/cpu0/cache/index*/{level,type,size,ways_of_associativity,coherency_line_size,shared_cpu_list} |
shared_cpu_list 直接告诉你这块缓存被哪些 CPU 共享。
lshw:能看,但没必要
1 | [root@localhost ~]# lshw -class memory | more |
它也能列出缓存,但输出里同一份 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 | struct counters { |
这和我们前面讲的绑核有关联:线程在不同核心之间迁移,缓存行就要跟着”搬家”;多线程频繁改相邻数据,缓存行就要来回”抢”。想定位伪共享,可以用
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 | dnf install valgrind -y |
运行
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 rate | L1 数据缺失数 ÷ 数据访问总数 |
LL miss rate | LL 缺失数 ÷ 总访存次数(指令加数据) |
注意最后一行: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-loads | L1 数据缓存加载次数 |
L1-dcache-load-misses | L1 数据缓存加载缺失次数 |
LLC-loads | 末级缓存加载次数 |
LLC-load-misses | 末级缓存加载缺失,请求去了主内存 |
dTLB-load-misses | 数据 TLB 缺失 |
在 Intel 大小核 CPU 上,事件名会带前缀,形如
cpu_core/LLC-load-misses/和cpu_atom/LLC-load-misses/,分别对应性能核和效率核,这是正常的。
示例:统计 tar 的缓存行为
1 | perf stat -e instructions,cycles \ |
下面是一次示例输出(数字仅作演示,请换成你自己机器上的实测):
1 | 196,624,236 instructions |
怎么读这组数据:
- 4800 万次 L1 数据加载,其中 7.84%(约 377 万次)没命中 L1,被送往下一级;
- 这 377 万次请求里,L2 又拦下了大部分,最终只有约 74 万次到达 L3;
- 到达 L3 的请求里有 32.88%(约 24 万次)没命中,需要访问主内存;
- 折算下来,约 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 | // cache_test.c |
编译用 -O1,不要用 -O3,因为高优化级别下编译器可能自动交换循环顺序,把你要演示的差异抹掉。
1 | gcc -O1 -g -o cache_test cache_test.c |
你应该观察到:
- 按行(mode 0):每 16 个
int才缺失一次(64 字节 ÷ 4 字节),Cachegrind 里D1 miss rate理论上约 6.25%; - 按列(mode 1):几乎每次访问都落在新的缓存行上,
D1 miss rate接近 100%,LLC 缺失和运行时间都明显更高。步长 16 KiB 还是 2 的幂,会叠加上一节讲的组冲突。
具体数值以你的机器为准,重点是看差距。
调优建议
- 别只看 CPU 使用率。 怀疑内存瓶颈时,先看 IPC 和缓存缺失,再决定要不要深挖;
- 绑核是保护缓存的一种手段:线程不迁移,热数据留在私有缓存里;
- NUMA 多插槽机器,让进程尽量使用本节点的内存;
- 写程序时:尽量顺序、连续地访问内存,避免 2 的幂步长,多线程写的数据用对齐隔开,避免伪共享;
- 工具分工:开发测试用 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 |
课后思考
- 为什么缓存停顿的时间在
top里仍然算 CPU 忙?你会用什么指标识别这种情况? - 一个 32 KiB、8 路、64 字节缓存行的 L1d 有多少组?步长多少字节的访问会反复落在同一组?
- 两个线程分别对
counters.a和counters.b做自增,为什么它们明明不共享变量,性能却比各自用独立缓存行差很多? - Cachegrind 和 perf 给出的 LL 缺失率为什么不能直接比较?
- 前面学过的绑核、NUMA 绑定和 CPU 隔离,各自分别在保护缓存的哪一环?
参考手册
1 | man lscpu |
