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

联系方式:

1. 微信:Lxh_Chat

2. 邮箱:939958092@qq.com
  1. Linux 性能调优系列(一) 建立科学的性能分析思路
  2. Linux 性能调优系列(二) 掌握基础性能监控命令
  3. Linux 性能调优系列(三) sysstat实现性能数据持久采集
  4. Linux 性能调优系列(四) 别只会用 sysstat,PCP 才是 Linux 性能监控的进阶玩法
  5. Linux 性能调优系列(五) 给你的服务器做一次全面体检
  6. Linux 性能调优系列(六) 想调内核参数?先搞懂 /proc、sysctl 和 sysfs
  7. Linux 性能调优系列(七) 还在手动改 sysctl?TuneD 智能性能调优实战来了
  8. Linux 性能调优系列(八) 进程太能吃资源怎么办?用 ulimit 和 systemd 给它加个"上限"
  9. Linux 性能调优系列(九) cgroup v2实战:防止一个烂进程干崩整台服务器
  10. Linux 性能调优系列(十) CPU 100% 到底是谁在忙?用 perf 把"元凶"揪出来
  11. Linux 性能调优系列(十一) 程序为什么卡住了?strace 和 ltrace 带你找到答案
  12. Linux 性能调优系列(十二) 服务器又卡了?SystemTap 带你杀到内核看看为啥
  13. Linux 性能调优系列(十三) 从 SystemTap 到 eBPF,Linux 性能追踪进入新时代
  14. Linux 性能调优系列(十四) CPU 这么忙,到底是谁决定“让谁先跑”?一文搞懂 Linux 调度器
  15. Linux 性能调优系列(十五) CPU 到底该先给谁?搞懂 Linux 实时调度
  16. Linux 性能调优系列(十六) Linux CPU 隔离实战,为什么实时线程绑核后延迟还会抖?
  17. Linux 性能调优系列(十七) CPU 占用不高却跑的慢?深挖 CPU 缓存性能瓶颈
  18. Linux 性能调优系列(十八) 程序申请了1GB内存,为什么系统却说它没用?
  19. Linux 性能调优系列(十九) 服务器硬盘慢得像蜗牛?先别急着换硬盘,这四招先把瓶颈找出来
  20. Linux 性能调优系列(二十) 磁盘 I/O 调度器怎么选?一文搞懂 mq-deadline、kyber、bfq 和 none
  21. Linux 性能调优系列(二十一) 磁盘为什么慢?从文件系统到 I/O 调优,一次讲透
  22. Linux 性能调优系列(二十二) Linux 网络调优别乱改 sysctl!先找瓶颈再调 TCP 缓冲区
  23. Linux 性能调优系列(二十三) MTU 从 1500 到 9000,巨帧真的能提升性能吗?
  24. Linux 性能调优系列(二十四) 从 CPU 省电到网卡卸载,聊聊 Linux 网络调优
  25. Linux 性能调优系列(二十五) 聊聊VirtIO、TuneD、virtio-mem 调优

前面几篇,我们把 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