3. ARMv8流水线与微架构入门

性能调优这件事,说白了就是回答三个问题:瓶颈在哪?为什么慢?改哪里最有效? 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
}
注意: 不同ARM核的事件编号可能不同。比如Cortex-A72和Cortex-A76的TLB缺失事件号就不一样。我曾经在移植代码时直接用了A72的事件号,结果在A76上跑出来全是0——查了两天才发现是事件号没改。建议每次换平台都查一下TRM(Technical Reference Manual)。

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);
经验之谈: 测量时要注意两点。第一,被测代码段要跑足够多次(比如1000次以上),单次测量的计数太小,误差会很大。第二,别忘了考虑缓存预热——我第一次测的时候没预热,结果L1命中率只有60%,吓得我以为芯片坏了。其实是因为缓存里全是冷数据。

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;
}
我的调试习惯: 每次优化前后都跑一遍这个测量框架,把数据记下来。时间长了,你就能建立起"这个指标对应什么性能水平"的直觉。比如我看到L1命中率低于95%,就知道代码局部性有问题;看到分支预测错误率超过5%,就知道该考虑用查表代替条件分支了。

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性能测量流程 1. PMU初始化 2. 选择测量事件 3. 执行被测代码 4. 读取PMU计数器 5. 计算性能指标 关键指标 • L1命中率 • L2命中率 • TLB缺失率 • 分支预测错误率 • IPC(每周期指令数) • 缓存缺失延迟 • TLB遍历开销 • 分支冲刷惩罚 • 内存带宽利用率 • 指令混合比例

嗯,这一章的内容就到这里。PMU是个好东西,但用好它需要积累。我建议你从今天开始,每次写代码都顺手跑一下PMU测量,慢慢你就能建立起"代码长什么样,硬件表现就什么样"的直觉。这种直觉,比看一百篇优化文章都管用。