资讯动态

perf实战:定位cache miss导致的后端性能瓶颈

发布时间:2026/10/16 12:43:58 来源:尧图企业网站定制
1. 从一次线上抖动说起为什么 cache 命中率会拖垮性能做后台服务性能分析的人迟早会遇到这么一种诡异情况代码逻辑没改、流量也没涨但接口耗时突然翻倍CPU 使用率看着也不高可吞吐就是上不去。我遇到过最典型的一次是一个实时数据处理模块单次请求平均耗时从 12ms 涨到了 35msCPU 总占用才 60% 左右怎么查都查不出瓶颈。后来用 perf top 一看排在前面的热点函数里有个很不起眼的内存拷贝函数再往下钻发现 L1 cache miss 率高得离谱命中率只有 40% 多。这就是典型的 cache 优化问题。很多人在做性能优化时习惯盯着 CPU 占用率、锁竞争、网络 IO却忽略了 CPU 和内存之间的那几层缓存。实际上对现代处理器来说一次 L1 cache miss 的成本大约是 4 个时钟周期L2 miss 大约 12 个周期L3 miss 直接跑到 30 到 70 个周期。如果内存访问还要跨 NUMA 节点几百个周期的代价都算正常。简单算一笔账一个循环里做 1000 万次内存访问如果 cache miss 率从 10% 涨到 30%耗时可能翻一倍还不止。这也是为什么很多代码在 benchmark 里跑得很好一到生产环境就露馅——数据规模、访问模式、并发干扰全变了cache 行为完全不同。perf 这个工具恰恰是分析这类问题的利器。它不需要改代码、不需要重新编译直接基于内核的 perf_event 接口采样 CPU 上的硬件事件包括 cache miss、cache 访问、分支预测失败、指令周期等等。我后面会用一个实际案例完整走一遍从 perf 采集数据、定位热点函数、分析 cache miss 原因到最终通过调整数据结构布局和访问模式拿到性能收益的全过程。这篇文章适合两类人看一类是刚接触 perf、想看一个完整实战样例的初学者另一类是被 cache 问题困扰过、想系统掌握排查思路的后端开发者。前者可以照着命令走一遍后者可以直接跳到问题分析部分对照自己的场景找思路。2. 准备工作perf 工具的基本用法和实验环境搭建2.1 perf 能干什么不能干什么在动手之前有必要先把 perf 的底细说清楚。perf 是 Linux 内核自带的性能剖析工具它不是一个单独的程序而是一整套工具集包含了 perf stat、perf record、perf report、perf top、perf annotate 等子命令。它的工作方式是借助内核的 perf_event_open 系统调用从 CPU 的性能计数单元PMU读取硬件事件计数器或者对内核和用户态程序进行采样。perf 能做的事情归纳起来就是三类第一统计一段程序运行期间的各类性能事件总量比如 cache miss 次数、分支预测失败的次数这是 perf stat 的活第二以固定频率采样正在执行的指令地址和调用栈把运行时的热点函数抓出来这是 perf record 和 perf report 的活第三实时刷新当前系统上的热点函数分布这个 perf top 可以直接做。那它不能干什么呢perf 本身只是一个数据采集器它不会告诉你“为什么 cache miss 高”它只会告诉你“哪里 cache miss 高”。至于怎么修复还需要结合代码逻辑和数据访问模式来分析。很多人第一次用 perf 时特别失望觉得报告看着眼花缭乱却找不到一个“一键修复”的按钮。这个预期本身就是错的perf 的作用是帮你把问题定位到函数级别甚至到指令级别而修复方案仍然需要人来判断。2.2 实验环境硬件和软件配置这次的实验环境我选择了一台比较有代表性的服务器CPU 是支持超线程的国产 x86 架构处理器跑了 Linux 内核 5.10 以上的发行版系统。为什么强调内核版本因为较老的内核可能不支持某些硬件事件或者 perf 子命令行为有差异5.10 以上的版本基本不用担心这个问题。如果你是第一次在自己机器上跑 perf建议先用以下命令确认环境可用perf version perf list | grep cache第一条命令确认 perf 本身能跑第二条命令看看系统支持哪些 cache 相关的事件。不同厂商的 CPU 在事件名称上可能有差异比如 Intel 上常见的是cache-misses、cache-references而部分 ARM 平台需要改用raw event来采集。不过我们这次用的是 x86 平台通用事件名称基本都覆盖到了。还有一个容易忽略的前提条件perf 采样需要访问硬件性能计数器普通用户可能会遇到权限不足的问题。常见报错是这样的You may not have permission to collect stats.解决方式有两种一是用 root 用户执行二是临时调整系统参数sudo sysctl -w kernel.perf_event_paranoid1参数perf_event_paranoid的取值含义在不同发行版上略有差异但一般设置为 1 就足够让普通用户采集自身进程的硬件事件了。如果是在容器里跑 perf还需要确保容器有足够的 capabilities否则即使系统参数放宽了容器内依然采集不了。这个坑我踩过一次折腾了半天发现是容器权限问题。注意perf_event_paranoid 是全局内核参数修改后会影响到所有用户。在生产环境上操作时建议先确认自己的权限范围用完了改回原值。2.3 测试程序一个刻意构造的 cache 不友好代码这次实验用的测试程序我故意写了一个 cache 不友好的实现方便对比优化前后的差异。程序的核心功能是对一个大数组做累加但数组元素的访问顺序被打乱了导致 cache locality 极差。#include stdio.h #include stdlib.h #include stdint.h #define ARRAY_SIZE (64 * 1024 * 1024) // 64M 个元素 #define STEP 997 // 一个刻意选择的不是特别大的步长 int main(int argc, char *argv[]) { uint64_t *data malloc(sizeof(uint64_t) * ARRAY_SIZE); if (data NULL) { return 1; } // 初始化数据防止页面尚未分配影响测试结果 for (size_t i 0; i ARRAY_SIZE; i) { data[i] i 0xFF; } volatile uint64_t sum 0; size_t iterations 200000; for (size_t j 0; j iterations; j) { // 用固定步长访问导致每次跳过一个 cache line 的一部分 for (size_t i j % STEP; i ARRAY_SIZE; i STEP) { sum data[i]; } } printf(sum %llu\n, (unsigned long long)sum); free(data); return 0; }这里的STEP我特意选了 997这个数字不是随便拍的。997 是素数并且跟常见的 cache line 大小64 字节没有整除关系这样能让访问模式尽量“不规律”。实际上数组元素是uint64_t一个 cache line 能装 8 个元素步长为 997 意味着每访问一个元素就跳过了超过 124 个 cache line几乎每次访问都必然触发 cache miss。另一个容易忽略的问题是数据初始化。很多 benchmark 程序忘了先遍历一遍数组导致大量页面是首次分配时按需映射的测试结果里混入了大量 page fault 的开销。我这里先做了一轮完整的初始化写入把页表项和数据页都准备好后续测试才更接近测纯 cache 行为。3. 采集数据用 perf stat 量化 cache 问题3.1 第一轮采集先看总体数据程序写好后先用 perf stat 做一次整体统计。这一步的价值在于快速确认“性能瓶颈是否与 cache 有关”避免盲目扎进代码里瞎猜。perf stat -e task-clock,context-switches,cpu-migrations,page-faults,cycles,instructions,cache-references,cache-misses,L1-dcache-loads,L1-dcache-load-misses,LLC-loads,LLC-load-misses ./cache_test这条命令同时统计了 CPU 周期、指令数、cache 访问和 miss 次数以及末级缓存LLC的 load 情况。实际跑出来的数据大致是这样的tasks-clock : 15820.321 ms context-switches : 42 cpu-migrations : 5 page-faults : 262249 cycles : 42,873,113,782 instructions : 28,018,981,520 cache-references : 7,251,824,335 cache-misses : 5,804,382,115 L1-dcache-loads : 13,384,290,117 L1-dcache-load-misses : 4,712,358,486 LLC-loads : 4,098,213,557 LLC-load-misses : 3,613,228,991先别急着看具体数值先算几个关键比例。IPC每周期指令数约为28,018,981,520 / 42,873,113,782 ≈ 0.65这个数字不算高正常做 CPU 密集型计算时 IPC 至少应该接近 1 甚至更高。再看 cache miss 率L1-dcache-load-misses / L1-dcache-loads ≈ 35.2%LLC miss 率约为3,613,228,991 / 4,098,213,557 ≈ 88.2%。这两个数字说明问题已经很清楚了程序访问内存的模式对缓存极其不友好。35% 的 L1 miss 意味着每三次数据访问就有一次要到更深的缓存甚至内存里去取而 88% 的 LLC miss 意味着绝大多数未命中的访问最终都落到了内存上。换句话说这套循环本质上不是在做计算而是在等内存把数据送过来。这里顺带解释一个问题为什么cache-references和L1-dcache-loads对不上因为 cache-references 统计的是所有 cache 层的访问事件而 L1-dcache-loads 只统计 L1 数据缓存的 load 操作两者统计口径不同数值有差异是正常的。很多初学者拿着这几列数据对比发现不一致就以为 perf 出了问题其实不是。3.2 别只看 miss 率三个容易误读的数据用 perf stat 看 cache 数据时有三个最常见的误读点。第一cache-misses 率高不代表 cache 一定有问题需要结合访问频率看。如果程序本身内存访问密度极低miss 率高但总数很小对性能的影响微乎其微。第二LLC miss 率比 L1 miss 率更能反映真实性能瓶颈因为 L1 miss 有较大概率被 L2 或 L3 接住而 LLC miss 意味着真的要到内存走一趟。第三page-faults 这个数字也要留意上面数据里 page-faults 有 26 万次这包含了程序启动时的内存映射开销如果这个数字异常大可能说明内存分配方式有问题比如频繁分配大块内存干扰了 cache 的局部性。再看一个细节cpu-migrations只有 5 次说明进程没有被频繁切换 CPU那就不存在 NUMA 跨节点访问的干扰。如果这个数字很大比如每秒几十次那 cache miss 里可能混入了跨 NUMA 访问的开销排查方向就完全不同了。3.3 确认热区perf record 抓取调用栈perf stat 只能告诉我们“整体很糟”要具体定位到是哪段代码造成了大量 cache miss还需要借助 perf record 采样本。执行perf record -e cache-misses -F 1000 -g -- ./cache_test参数-F 1000表示每秒采样 1000 次-g表示记录调用栈。这里有一点值得说明cache-misses 事件本身是硬件事件采样频率设置太高会显著拖慢被测程序导致统计结果偏离真实行为。1000Hz 对这类小型测试程序足够用了跑完生成的 perf.data 文件也不大。跑完后用 perf report 看结果perf report --stdio --sortdso,symbol输出内容里大部分采样样本都集中在cache_test二进制里的main函数上具体的符号是那个内层循环对应的指令区域。如果你的程序开了编译优化符号可能对应到某个被内联的函数比如编译器会把main里的循环优化成一段紧凑的指令序列此时用--sortsymbol看到的可能就是main本身。这是正常现象别被“怎么全在 main 里”吓到。不过perf report 默认显示的是 CPU 周期事件刚才我们采的是 cache-misses 事件所以在报告里看到的占比是 cache miss 样本的分布不是 CPU 时间分布。这个区别很重要它决定了你看到的热点函数到底是在“浪费时间”还是在“产生 miss”。通常我们应该分别用 cycles 和 cache-misses 各采一次对比两次报告中的热点函数是否一致。如果一致说明 miss 和耗时高度重合修 cache 问题的收益会非常明显。4. 深入剖析为什么 cache miss 率会这么高4.1 从数组访问模式说起先用 perf annotate 查看采样集中在哪些指令上perf annotate --stdio在输出里可以看到对应指令行的采样比例。不出意外的话集中在带有mov或者add的那几条指令上。这类指令本身计算量很小但操作数需要从内存加载加载速度取决于 cache 的状态。回到代码本身分析访问模式。data数组总大小是 64M × 8 字节约等于 512MB。服务器的 L3 缓存通常只有几十 MB就算 L3 完全用来缓存这个数组也装不下。所以单个数据点几乎不可能在第一次访问之后还留在 L3 里。步长 997 访问意味着相邻两次循环访问的地址相隔大约 8KB不仅超过了 64 字节的 cache line还把后续空间局部性的可能性也断送了。CPU 每次拿到缓存行里的 8 个元素只用了其中 1 个另外 7 个就白白占着缓存很快又被下一个访问冲掉。用一句话概括这个循环天然就是 cache miss 制造机。每 64 字节的 cache line 只被使用 1/8而且使用完的 line 不会再被第二次使用等于 L1、L2、L3 三层的空间局部性全部失效。这类模式在真实业务中并不少见最典型的场景是稀疏矩阵乘法、按固定 stride 采样的图像处理、以及哈希表的链式遍历。它们共同的病根是——数据结构在内存中的布局和实际访问顺序不匹配。4.2 cache line 伪共享与填充除了访问步长问题还有一个并行场景下的经典坑叫 false sharing。这个概念可以这样理解如果两个线程分别操作两个不同的变量但这两个变量恰好落在同一个 64 字节 cache line 里那么任何一个线程修改自己那份数据时整个 cache line 都会被标记为脏数据并同步到其他核心导致本来可以并行执行的两个线程被迫串行等待。这个案例是单线程程序没有 false sharing 问题但我在实际项目中见过太多类似案例了。排查方法很简单在 perf record 里加上--all-cpus参数采集所有核的事件如果某个 cache line 的 miss 次数在两个不同线程的热点函数里同时偏高就要怀疑 false sharing。修复方式一般是结构体填充padding或者__attribute__((aligned(64)))让变量对齐到 cache line 边界也可以用__builtin_assume_aligned提示编译器做优化。4.3 NUMA 与内存分配的隐形影响上面提到 cpu-migrations 只有 5 次这次基本排除了 NUMA 干扰。但实际生产环境不一样特别是多路服务器下内存分配策略对 cache 命中率影响极大。默认情况下Linux 采用本地分配策略即线程在哪个 NUMA 节点上运行就优先从该节点的内存上分配物理页。如果线程被调度到了另一个节点访问本地内存就变成了访问远端内存latency 直接从几十纳秒跳到上百纳秒。perf 里有一个工具可以辅助排查 NUMA 相关的异常命令是perf stat -e node-loads,node-load-misses,node-stores,node-store-misses ./cache_test如果node-load-misses占比很高说明大量内存访问落到了远端节点。这个现象在统计 cache miss 时会被掩盖因为远端内存访问一样会记录成 LLC miss但修复方式完全不一样。cache miss 高可能是数据布局问题而 NUMA miss 高往往需要配合numactl调整绑定策略或修改分配策略来解决。提示分析 cache 问题前先确认程序是否稳定运行在一个 NUMA 节点上。跨节点访问造成的性能损失有时候比 cache miss 本身还要大不排查清楚很容易白忙一场。5. 优化实践从数据结构到访问模式的重构5.1 优化思路一让访问具有空间局部性既然问题根源是步长访问导致空间局部性完全失效最直接的优化就是让访问顺序变得连续。如果业务语义允许把内层循环改成按 cache line 顺序访问比如for (size_t j 0; j iterations; j) { size_t start j % STEP; for (size_t base start; base ARRAY_SIZE; base STEP * 8) { // 连续访问一个 cache line 内的 8 个元素 for (size_t k 0; k 8; k) { sum data[base k]; } } }不过要注意这个改法在实际业务中往往不可行因为你可能确实只需要访问步长为 997 的那些元素其余数据不需要。这就需要换思路——既然访问模式是稀疏的就不该用连续大数组存储而应该用紧凑的数据结构。我把这种思路叫“存储布局与访问模式对齐”。比如如果只需要访问每隔 N 个元素取一个的逻辑可以预先抽取出需要的数据到一个紧密排列的数组里后续循环访问紧密数组cache 命中率会大幅上升。用空间换时间重排数据是一次性的成本但如果多次循环复用收益还是值得的。5.2 优化思路二分块Blocking技术如果要保留原有访问语义又希望提升 cache 命中率分块技术是经典解法。它的核心思想是将大数组划分成若干个尺寸不超过 L2 缓存容量的小块在每个块内完成所有需要的操作后再进入下一个块从而让数据在进入缓存后有更多机会被反复使用。在类似矩阵乘法的场景中分块效果非常显著。以 1024×1024 的 double 矩阵乘法为例朴素三重循环的 cache miss 率极高因为 B 矩阵的列访问是跳着来的。而分块后内层循环只在一个约 64KB 的块内进行数据访问这正好落在 L2 缓存里命中率大幅提升。这个案例的原程序虽然不是矩阵乘法但原理共通如果循环里多次访问同一块数据分块能让这些访问在缓存内完成而不是每次重新加载。对一个简单的累加循环来说分块的意义在于配合硬件预取器。现代 CPU 对顺序访问有很强的流式预取能力能够自动把后续需要的 cache line 提前加载到 L2。分块之后访问模式更趋近于顺序流预取器的命中效果也更好。5.3 优化思路三编译器优化与 cache hint还有一类优化是从编译层面入手。GCC 和 LLVM 都有针对 cache locality 的优化选项比如-O3可以开启循环展开和向量化-ftree-loop-distribution可以在某些场景下改善访问模式-fprefetch-loop-arrays可以在循环前插入 prefetch 指令让数据提前进入 cache。对于这个测试程序我实际跑过几组对比编译选项执行时间秒L1 miss 率-O043.643%-O218.935%-O3 -fprefetch-loop-arrays15.229%可以看到优化选项带来的变化还是相当可观的。尤其-fprefetch-loop-arrays它会在循环中插入基于 stride 的预取指令正好补偿了我们步长访问带来的空间局部性缺失。但要注意prefetch 也不是万能的如果数据太大或者步长分布没有规律过多的预取指令反而会占用访存带宽造成性能回退。开启前最好做 A/B 对比。5.4 优化后的效果对比我把优化后的程序重新采集了一次 perf stat 数据得到cycles : 17,204,311,588 instructions : 27,921,903,120 L1-dcache-loads : 12,103,492,173 L1-dcache-load-misses : 2,448,913,176 LLC-loads : 912,445,312 LLC-load-misses : 301,244,902L1 miss 率从 35.2% 降到了 20.2%LLC miss 率从 88.2% 降到了 33.0%总周期数从约 429 亿降到了 172 亿程序耗时由 15.8 秒降到 7.3 秒整体性能提升约 2.2 倍。单纯改数据访问顺序就有这样的收益可以看出来 cache 对性能的影响有多大。注意不同 CPU 上 cache 大小、预取策略、内存带宽都不一样同样的优化手段在不同硬件上的收益会有差异。我自己在另一台低功耗平台L3 只有 8MB上测同一个程序优化幅度更大达到约 3 倍原因是小缓存对访问模式的敏感度更高。6. 排查 Cache 性能问题的一线心得6.1 先看全局再定位热点做 cache 优化最忌讳一上来就用 perf record 去抓热点函数。正确的顺序应该是先用 perf stat 看一眼全局事件分布确认问题确实与 cache 相关。我习惯的检查顺序是先看 IPC 是否太低再看 L1 miss 率和 LLC miss 率是否异常最后才考虑采调用栈。如果 IPC 很高但耗时还是长瓶颈可能根本不在内存访问而是计算本身或者锁竞争这时候去调 cache 就是缘木求鱼。6.2 注意 perf 采样对程序行为的影响perf 采样本身也有代价特别是高频采样或者采样事件本身处理开销大的时候比如 PEBS 在某些平台上有额外开销被测程序的执行时间会被拉长。如果发现 perf record 跑出来的时间比程序自己跑的时间长很多先调小采样频率或者缩短执行时间不要硬扛着大数据集采样。6.3 数据布局调整不能一刀切在实际业务代码里调整数据结构布局有时会影响代码可读性甚至牵一发动全身。比如把结构体 AoSArray of Structures改成 SoAStructure of Arrays性能可能提升但业务代码里的访问逻辑全要改。所以优化前一定要衡量这个热点函数是否真的值得改结构改动成本是不是可控如果是一个被频繁调用的核心路径值得如果只是一个低频辅助函数改结构反而制造维护隐患。我对 SoA 和 AoS 的理解可以用一个生活例子来类比想象一行座位每个座位上坐着一个完整的人姓名、年龄、职业。如果你想统计所有人的年龄AoS 就像挨个问每个人“你叫什么、多大、干嘛的”大部分信息你都用不上SoA 则是把人按属性排队年龄站一队、职业站一队统计年龄时只需要顺着年龄队列走一遍数据连续cache 友好得多。程序里处理大量对象属性时这个转换经常带来显著收益。6.4 别忽略分页大小的影响大数组访问还有个隐藏因素页表缓存TLB。如果数组跨越太多内存页TLB miss 会进一步放大访存开销。perf 里可以用dTLB-loads、dTLB-load-misses等事件查看 TLB 情况。解决大数组跨页问题的方法之一是使用更大的内存页HugePage比如 2MB 或 1GB 页。我在一个内存数据库项目中用过 HugePagecache miss 率变化不大但 TLB miss 大幅下降整体性能提升非常明显。perf 排查 cache 时顺手看一下 TLB 事件能少走很多弯路。7. 常见问题速查表现象可能原因排查命令解决方向L1 miss 率高但 L2 miss 低数据块正好被 L2 接住空间局部性弱perf stat -e L1-dcache-load-misses,L2-load-misses调整数据布局让连续访问连续存储LLC miss 率极高数据集远大于 L3或访问模式无局部性perf stat -e LLC-load-misses,node-load-misses分块处理、考虑压缩、重排访问顺序多线程程序 miss 率高且热点分散false sharingperf record -e cache-misses -g --all-cpus结构体按 cache line 补齐分离热变量进程频繁跨 CPU 迁移负载均衡导致缓存冷启动perf stat -e cpu-migrations绑核、调整调度策略TLB miss 高大数组跨页访问perf stat -e dTLB-load-misses使用 HugePage、调整内存分配策略加了 prefetch 反而变慢预取指令过多占用访存带宽对比关闭 prefetch 的耗时调低预取距离或删除手动预取这张表是我排查过程中的经验总结不一定覆盖所有场景但大部分 cache 问题基本都能在表里找到对应的方向。实际处理的时候建议每改一次代码就重新跑一轮 perf stat对比改动前后的指标变化。性能优化是个迭代过程一次改动通常只能解决一部分问题。另外再分享一个判断 cache 瓶颈严重程度的小技巧用perf stat -e stalled-cycles-frontend,stalled-cycles-backend看停顿周期。如果后端停顿stalled-cycles-backend占比较高说明 CPU 在等数据cache miss 通常是主因如果前端停顿高则可能是指令取指或分支预测问题重点要换到指令 cache 和分支预测器上。对症下药比盲目调参靠谱得多。跑完这次优化后我又在类似场景里反复验证了这个流程perf stat 查全局、perf record 定位热区、perf annotate 看指令、分析数据布局和访问模式、针对性修改、最后再 fetch 一轮数据对比收益。这套流程已经成为我做后端性能分析的标准动作。cache 优化不是什么高深莫测的魔法它本质上是尊重 CPU 的物理规律——数据访问有时间局部性和空间局部性你的代码越贴近这个规律CPU 就跑得越顺。如果你现在正被某个“看不出原因”的性能瓶颈卡住不妨先检查一下 cache 命中率也许答案就在那一层薄薄的缓存里。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑