第三章 PMU实战:用PMU测量缓存、TLB与分支预测
性能调优这件事,说白了就是回答三个问题:瓶颈在哪?为什么慢?改哪里最有效? ARMv8的PMU(Performance Monitor Unit)就是帮你回答这些问题的硬件探针。这一章,我们拿它来测三个最核心的微架构指标——L1/L2缓存命中率、TLB缺失率、分支预测错误率。
我个人习惯,拿到一个新平台,第一件事就是跑一遍PMU基线数据。为什么?因为只有知道硬件实际表现,才能判断优化方向对不对。你想想看,如果缓存命中率已经99%了,你还花两周去优化缓存,那不是白费劲吗?
3.1 PMU基础配置流程
在ARMv8里,PMU的使用分三步走:使能、选择事件、读取计数。我直接给出一段可用的初始化代码,咱们边看边讲。
// PMU初始化示例(AArch64)
void pmu_init(void) {
// 1. 使能PMU计数器
asm volatile("msr PMCR_EL0, %0" : : "r" (0x1)); // E=1, 使能所有计数器
// 2. 清除溢出标志
asm volatile("msr PMOVSCLR_EL0, %0" : : "r" (0xFFFFFFFF));
// 3. 使能周期计数
asm volatile("msr PMCNTENSET_EL0, %0" : : "r" (0x80000000));
// 4. 设置计数器0-3的事件选择
asm volatile("msr PMEVTYPER0_EL0, %0" : : "r" (0x11)); // L1D_CACHE_REFILL
asm volatile("msr PMEVTYPER1_EL0, %0" : : "r" (0x03)); // L1D_CACHE
asm volatile("msr PMEVTYPER2_EL0, %0" : : "r" (0x13)); // TLB_MISS
asm volatile("msr PMEVTYPER3_EL0, %0" : : "r" (0x12)); // BR_MIS_PRED
}
3.2 L1/L2缓存命中率测量
缓存命中率的公式很简单:命中率 = 1 - (缺失次数 / 总访问次数)。但PMU里没有直接给"命中次数"这个事件,你得用两个事件算出来。
| 指标 | PMU事件 | 事件号(Cortex-A系列典型值) |
|---|---|---|
| L1数据缓存访问 | L1D_CACHE | 0x03 |
| L1数据缓存缺失 | L1D_CACHE_REFILL | 0x11 |
| L2缓存访问 | L2D_CACHE | 0x16 |
| L2缓存缺失 | L2D_CACHE_REFILL | 0x17 |
实际测量时,我一般这样写:
// 测量L1数据缓存命中率
uint64_t l1_access, l1_refill;
float l1_hit_rate;
// 在代码段前后分别读取计数器
pmu_reset();
pmu_start();
// ---- 被测代码段 ----
your_function();
// --------------------
l1_access = pmu_read(1); // 计数器1: L1D_CACHE
l1_refill = pmu_read(0); // 计数器0: L1D_CACHE_REFILL
l1_hit_rate = 1.0 - (float)l1_refill / (float)l1_access;
printf("L1D Hit Rate: %.2f%%\n", l1_hit_rate * 100);
3.3 TLB缺失率测量
TLB缺失对性能的影响,往往比缓存缺失更隐蔽。为什么?因为一次TLB缺失可能引发多级页表遍历,那开销可不是几十个周期能打住的。
我记得有一次优化一个数据库引擎,发现某个查询路径特别慢。用perf看了一圈,缓存命中率都正常,就是TLB缺失率高得离谱——达到了15%。后来一查,是因为那个模块用了大量随机内存访问,导致TLB条目频繁被踢出。
// TLB缺失率测量
uint64_t tlb_miss, tlb_walk;
float tlb_miss_rate;
// TLB缺失事件(事件号0x13)
tlb_miss = pmu_read(2);
// 有些核还支持TLB遍历次数(事件号0x34)
// 这个值能反映页表遍历的开销
tlb_walk = pmu_read(4);
tlb_miss_rate = (float)tlb_miss / (float)iterations;
printf("TLB Miss Rate: %.2f per iteration\n", tlb_miss_rate);
printf("TLB Walk Cycles: %lu\n", tlb_walk);
TLB缺失的典型阈值:
- 低于0.1%:正常,不用管
- 0.1% - 1%:需要关注,可能有优化空间
- 高于1%:严重问题,必须优化
注意:这个阈值跟页大小有关。如果用2MB大页,TLB覆盖范围大,缺失率自然会低很多。
3.4 分支预测错误率测量
分支预测错误,是超标量处理器最头疼的问题之一。一次预测错误,流水线里十几条指令全得冲刷掉。我见过最夸张的情况,一个排序算法在乱序数据上跑,分支预测错误率高达20%,性能直接腰斩。
// 分支预测错误率测量
uint64_t br_mispred, br_inst;
float br_mispred_rate;
// 分支预测错误(事件号0x12)
br_mispred = pmu_read(3);
// 分支指令总数(事件号0x0A)
br_inst = pmu_read(5);
br_mispred_rate = (float)br_mispred / (float)br_inst * 100;
printf("Branch Misprediction Rate: %.2f%%\n", br_mispred_rate);
这里有个坑,我踩过不止一次:分支预测错误率跟数据分布强相关。你拿有序数据测出来1%,换随机数据可能就变成15%。所以测量时一定要用真实数据,别用测试数据糊弄自己。
3.5 综合测量框架
实际项目中,我习惯把这三个指标放在一起测。这样能一眼看出性能瓶颈到底在哪。
// 综合性能测量
typedef struct {
float l1d_hit_rate;
float l2_hit_rate;
float tlb_miss_rate;
float br_mispred_rate;
} perf_metrics_t;
perf_metrics_t measure_performance(void (*func)(void)) {
perf_metrics_t metrics;
// 配置所有计数器
pmu_init_all();
// 预热
for (int i = 0; i < 10; i++) func();
// 正式测量
pmu_reset();
pmu_start();
func();
pmu_stop();
// 读取并计算
uint64_t l1_access = pmu_read(1);
uint64_t l1_refill = pmu_read(0);
uint64_t l2_access = pmu_read(6);
uint64_t l2_refill = pmu_read(7);
uint64_t tlb_miss = pmu_read(2);
uint64_t br_mispred = pmu_read(3);
uint64_t br_inst = pmu_read(5);
metrics.l1d_hit_rate = 1.0 - (float)l1_refill / l1_access;
metrics.l2_hit_rate = 1.0 - (float)l2_refill / l2_access;
metrics.tlb_miss_rate = (float)tlb_miss / l1_access;
metrics.br_mispred_rate = (float)br_mispred / br_inst * 100;
return metrics;
}
3.6 PMU事件选择与数据解读
最后,我整理了一张常用PMU事件表。这张表是我多年调试经验的总结,不是从手册上抄的——手册上事件太多,真正常用的就这几个。
| 事件名称 | 事件号 | 测量内容 | 典型优化场景 |
|---|---|---|---|
| L1D_CACHE | 0x03 | L1数据缓存访问次数 | 数据局部性分析 |
| L1D_CACHE_REFILL | 0x11 | L1数据缓存缺失次数 | 缓存行对齐优化 |
| L2D_CACHE | 0x16 | L2缓存访问次数 | 数据预取效果评估 |
| L2D_CACHE_REFILL | 0x17 | L2缓存缺失次数 | 内存访问模式优化 |
| TLB_MISS | 0x13 | TLB缺失次数 | 大页使用决策 |
| BR_MIS_PRED | 0x12 | 分支预测错误次数 | 分支消除优化 |
| CPU_CYCLES | 0x11 | CPU周期数 | 整体性能基线 |
| INST_RETIRED | 0x08 | 退休指令数 | IPC计算 |
数据解读这块,我个人的经验是:别只看绝对值,要看相对变化。比如L1命中率从98%降到95%,看起来只降了3%,但实际性能可能下降30%以上。因为缺失的那3%访问,每次都要去L2甚至内存里取,延迟差了一个数量级。
快速诊断指南:
- L1命中率 < 90% → 检查数据局部性,考虑循环分块
- L2命中率 < 80% → 检查数据预取,考虑软件预取指令
- TLB缺失率 > 1% → 考虑使用大页(2MB/1GB)
- 分支预测错误率 > 5% → 考虑用查表、条件移动指令替代分支
嗯,这一章的内容就到这里。PMU是个好东西,但用好它需要积累。我建议你从今天开始,每次写代码都顺手跑一下PMU测量,慢慢你就能建立起"代码长什么样,硬件表现就什么样"的直觉。这种直觉,比看一百篇优化文章都管用。