2. 性能监控单元(PMU):PMU寄存器详解、事件计数、周期计数、溢出中断配置

性能监控单元,说白了就是ARMv8架构里内置的一套硬件计数器。你想想看,CPU内部到底在忙什么?Cache命中率怎么样?分支预测准不准?这些数据,PMU都能给你。

我刚开始接触PMU时,总觉得它就是个高级点的计时器。后来做一次性能抖动排查,才发现这玩意儿是真能救命。那次线上服务每隔几分钟就卡一下,用PMU抓周期计数和Cache miss事件,最后定位到是某段代码触发了大量TLB刷新的问题。

2.1 PMU寄存器体系

ARMv8的PMU寄存器,核心就那么几个。我习惯把它们分成三类:控制类、计数类、状态类。

寄存器 类型 描述
PMCR_EL0 控制 PMU控制寄存器,控制全局使能、复位、分频等
PMCNTENSET_EL0 控制 计数器使能置位寄存器
PMCNTENCLR_EL0 控制 计数器使能清除寄存器
PMEVTYPERn_EL0 控制 事件类型选择寄存器,n从0到5
PMEVCNTRn_EL0 计数 事件计数器,n从0到5
PMCCNTR_EL0 计数 周期计数器,64位
PMOVSR_EL0 状态 溢出状态寄存器
PMINTENSET_EL0 控制 中断使能置位寄存器

这里有个细节,PMCCNTR_EL0是64位的周期计数器,而PMEVCNTRn_EL0是32位的。为什么周期计数器要64位?因为现代CPU主频动不动就2GHz以上,32位计数器几秒就溢出了,你想想看,这能忍?

关键点:ARMv8 PMU最多支持6个事件计数器(PMEVCNTR0-5)加1个周期计数器(PMCCNTR)。实际数量由硬件实现决定,可以通过PMCR_EL0的N字段读取。

2.2 事件计数配置流程

配置事件计数,说白了就是告诉PMU:你帮我数一下,CPU执行了多少次L1 Cache miss。我一般按这个步骤来:

  1. 读取PMCR_EL0,确认支持的计数器数量
  2. 复位计数器,写PMCR_EL0的P位和C位
  3. 选择事件类型,配置PMEVTYPERn_EL0
  4. 使能计数器,写PMCNTENSET_EL0
  5. 全局使能,写PMCR_EL0的E位
  6. 读取计数值,读PMEVCNTRn_EL0或PMCCNTR_EL0

举个例子,我想统计L1数据Cache miss次数:

// 使用PMU事件计数器0统计L1数据Cache miss
// 事件号0x03对应L1D_CACHE_REFILL

// 1. 复位计数器
MRS x0, PMCR_EL0
ORR x0, x0, #0x3          // 置位P和C位
MSR PMCR_EL0, x0

// 2. 选择事件类型:L1数据Cache refill
MOV x0, #0x03
MSR PMEVTYPER0_EL0, x0

// 3. 使能计数器0
MRS x0, PMCNTENSET_EL0
ORR x0, x0, #0x1
MSR PMCNTENSET_EL0, x0

// 4. 全局使能
MRS x0, PMCR_EL0
ORR x0, x0, #0x1          // 置位E位
MSR PMCR_EL0, x0

// 5. 执行待测代码段
// ...

// 6. 读取计数值
MRS x0, PMEVCNTR0_EL0

我的经验:配置完PMU后,建议先跑一段空循环验证计数器是否正常工作。我曾经遇到过硬件不支持某个事件,配置后计数器纹丝不动,排查了半天才发现是事件号不对。

2.3 周期计数与性能评估

周期计数,就是统计CPU跑了多少个时钟周期。这个指标太重要了。你想想看,一段代码执行了1000个周期,另一段执行了2000个周期,谁快谁慢一目了然。

我习惯用周期计数配合事件计数做CPI(Cycles Per Instruction)计算:

// 同时读取周期计数和指令计数
MRS x0, PMCCNTR_EL0       // 读取周期计数
MRS x1, PMEVCNTR0_EL0     // 读取指令计数(假设配置了指令事件)

// CPI = 周期数 / 指令数
// 如果周期数远大于指令数,说明存在大量stall

实际项目中,我遇到过一段代码CPI高达5.0的情况。正常优化良好的代码CPI应该在1.0左右。后来用PMU逐段排查,发现是某处循环内频繁访问非对齐内存,导致总线stall。调整数据结构对齐后,CPI降到了1.2。

2.4 溢出中断配置

32位的事件计数器,最大计数值是2^32-1。以2GHz主频为例,如果统计的是周期数,大约2秒就溢出了。怎么办?用溢出中断。

配置溢出中断的步骤:

  1. 设置中断使能,写PMINTENSET_EL0
  2. 配置中断控制器,使能PMU中断(通常是IRQ 23或由GIC配置)
  3. 编写中断服务程序,读取PMOVSR_EL0判断哪个计数器溢出
  4. 清除溢出标志,写PMOVSR_EL0清除对应位
// 使能计数器0的溢出中断
MRS x0, PMINTENSET_EL0
ORR x0, x0, #0x1
MSR PMINTENSET_EL0, x0

// 中断服务程序(伪代码)
void pmu_irq_handler() {
    uint64_t ovsr = read_sysreg(PMOVSR_EL0);
    
    if (ovsr & 0x1) {
        // 计数器0溢出
        uint32_t count = read_sysreg(PMEVCNTR0_EL0);
        total_count += (1ULL << 32);  // 加上溢出部分
        total_count += count;
        
        // 清除溢出标志
        write_sysreg(ovsr & ~0x1, PMOVSR_EL0);
    }
    
    if (ovsr & (1ULL << 31)) {
        // 周期计数器溢出(PMCCNTR是64位,一般不会溢出)
        // 处理逻辑类似
    }
}

注意:中断服务程序要尽量短。PMU中断频率可能很高,如果处理时间过长,会丢失后续溢出事件。我建议在ISR中只做计数累加和标志清除,统计工作放到后台任务处理。

2.5 PMU知识体系总览

下面这张图,是我自己总结的PMU核心逻辑。你看一遍,基本就明白PMU怎么玩了。

ARMv8 PMU 核心架构与配置流程 PMCR_EL0 控制寄存器 PMCNTENSET_EL0 计数器使能 PMEVTYPERn_EL0 事件类型选择 PMINTENSET_EL0 中断使能 PMEVCNTRn_EL0 事件计数器(32位) PMCCNTR_EL0 周期计数器(64位) PMEVCNTRn_EL0 事件计数器(32位) PMOVSR_EL0 溢出状态寄存器 检测哪个计数器溢出 触发PMU中断(IRQ) 配置 计数 溢出 中断

2.6 常见事件速查

ARMv8定义了大量PMU事件。我挑几个最常用的列出来,这些在实际调优中出场率最高:

事件号 事件名称 说明
0x00 SW_INCR 软件增量事件,用于软件触发计数
0x01 L1I_CACHE_REFILL L1指令Cache缺失
0x03 L1D_CACHE_REFILL L1数据Cache缺失
0x08 INST_RETIRED 退休指令数
0x11 CPU_CYCLES CPU周期数(与PMCCNTR相同)
0x1B BR_MIS_PRED 分支预测失败
0x70 BUS_ACCESS 总线访问次数

我的习惯:调优时我一般同时监控三个事件:INST_RETIRED、CPU_CYCLES、L1D_CACHE_REFILL。这三个组合能快速判断代码是计算密集型还是内存密集型。如果CPI高且Cache miss多,优先优化数据布局;如果CPI高但Cache miss少,重点看分支预测和指令流水线。

2.7 避坑指南

做PMU调试这些年,踩过的坑不少。分享几个典型的:

  • 计数器不递增:检查是否在EL0下访问。PMU寄存器在EL0可访问需要配置PMUSERENR_EL0寄存器。我遇到过在用户态程序里读PMCCNTR一直返回0,折腾半天才发现是内核没开放权限。
  • 事件号不对:不同厂商的ARMv8实现,事件号可能不同。比如某款芯片的L1 Cache miss事件号是0x03,另一款可能是0x04。一定要查芯片手册。
  • 中断丢失:32位计数器溢出频率很高。如果中断处理太慢,会丢失溢出事件,导致统计值偏小。我建议用链式计数,把两个计数器串联起来,扩展计数范围。
  • 性能开销:PMU本身有少量性能开销。在极端场景下,频繁读写PMU寄存器会影响被测代码的性能。我一般会在测试代码前后各读一次,中间不打扰。

重要提醒:PMU是调试工具,不是生产环境标配。在产品代码中长时间开启PMU会增加功耗。我建议只在性能调优阶段使用,上线前关闭所有PMU计数器。

嗯,PMU这部分内容就这些。说白了就是配置、计数、读溢出这三板斧。但真正用好它,需要你对CPU微架构有深入理解。下次遇到性能问题,别急着猜,先上PMU看看数据。