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。我一般按这个步骤来:
- 读取PMCR_EL0,确认支持的计数器数量
- 复位计数器,写PMCR_EL0的P位和C位
- 选择事件类型,配置PMEVTYPERn_EL0
- 使能计数器,写PMCNTENSET_EL0
- 全局使能,写PMCR_EL0的E位
- 读取计数值,读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秒就溢出了。怎么办?用溢出中断。
配置溢出中断的步骤:
- 设置中断使能,写PMINTENSET_EL0
- 配置中断控制器,使能PMU中断(通常是IRQ 23或由GIC配置)
- 编写中断服务程序,读取PMOVSR_EL0判断哪个计数器溢出
- 清除溢出标志,写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怎么玩了。
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看看数据。