CPU监控与分析
# CPU 监控与分析
📊 学习成本预估 | 难度:⭐⭐⭐⭐⭐(5/5)| 阅读:约 45 分钟 | 实操:3 小时 🔗 前置阅读:卷零·01, 03, 05 | ➡️ 后续延伸:卷二·04, 卷四·01
本文是《性能优化实践 · 卷二 资源篇》的开篇。
CPU 是性能问题最早被怀疑、也最容易被误解的资源。本文用第一性原理重写它的全部认知。
# 目录介绍
- 00.阅读说明
- 00.5 贯穿案例:直播礼物墙卡顿
- 01.问题域定义
- 02.第一性原理
- 03.度量与采集
- 04.归因方法
- 05.求证实验 ⭐
- 06.优化策略
- 07.实战案例
- 08.防劣化与长效治理
- 09.跨平台对照速查
- 10.总结与延伸
# 00.阅读说明
- 卷归属:卷二 资源篇
- 目标层级:L1 系统层 / L2 运行时(适合 L2-L3 工程师)
- 适用平台:Android / iOS / Web / 嵌入式 Linux / 桌面(统一原理 + 各端实现)
- 前置阅读:
卷零·01 性能工程总论(资源 × 时间 × 流水线模型)卷零·02 跨平台性能模型(USE / RED / APDEX)卷零·05 归因方法论(on-CPU vs off-CPU)
- 本文核心命题:
CPU 不是"忙不忙"的问题,而是"忙得有效不有效"的问题。
优化 CPU 的本质,不是降低利用率,而是提高单位 CPU 时间的产出价值。
# 00.5 贯穿案例:直播礼物墙卡顿
本章用一个真实线上案例贯穿全文。后续每章会用 ▶▶ 案例回扣 标记回到这个案例,让方法论始终落在真实问题上。
# 0.5.1 问题背景
- 业务场景:某直播 App 大型活动现场,主播收礼时屏幕同时滚动一面"礼物墙"——3 列网格、每秒新增 5-8 条记录、单条带礼物图标 + 用户头像 + 飘字动画。
- 用户反馈:高峰期(2000+ 在线)礼物墙严重卡顿,FPS 从 58 跌到 12,大量用户投诉"礼物送不出去"。
- 机型分布:千元机(中低端)反馈占 73%,旗舰机偶发。
- 业务损失:高峰期送礼 GMV 同比下降 28%,是次重大故障。
# 0.5.2 初步排查(错误的假设)
工程团队第一反应:
| 假设 | 措施 | 结果 |
|---|---|---|
| 内存不够 | 加大缓存、减少对象创建 | FPS 仅提升 2 |
| 网络拉胯 | 长连接改 WebSocket | 弹幕到达更快但更卡 |
| 列表没复用 | 引入 RecyclerView 池化 | 中端机 +5 FPS,低端机无效 |
3 周时间,3 次发版,未解决问题。这是"经验主义"性能优化最典型的失败模式——没有用数据归因,凭直觉改代码。
# 0.5.3 案例任务(贯穿目标)
我们将在后续章节用本文方法论重新审视该案例:
| 章节 | 该章对本案例做什么 |
|---|---|
| §01 问题域 | 重新定义"卡顿"——不是 FPS,而是"主线程 P95 帧时长 > 33ms" |
| §02 第一性原理 | 用 指令数 / IPC / 频率 拆解每帧的 CPU 预算 |
| §03 度量采集 | 同时用 OS 时间片 + Profiler + PMU 三方法做交叉验证 |
| §04 归因决策树 | 走"利用率高 → IPC 低"分支,定位到 cache miss |
| §05 求证实验 | 实验三的"缓存友好布局"直接对应本案例的根因 |
| §06 优化策略 | 给出 4 个针对性优化(绑大核 + SoA + SIMD 飘字 + 无锁队列) |
| §07 实战收尾 | 完整列出优化前后数据:FPS 12 → 56,CPU% 95→62 |
读完本文,你将看到:同一个问题,"经验派" 3 周失败,"方法论派" 3 天解决。这就是本专栏的意义。
# 01.问题域定义
# 1.1 现象与代价
用户感知:
- 设备发烫、风扇狂转(功耗代价的直接体感)
- 操作响应迟缓 / 列表滚动卡顿(CPU 资源争抢)
- 电量异常掉电(CPU 高频驻留)
- 应用被系统降频或杀死(系统保护机制)
- 后台任务跑不完(被调度限制)
业务代价(行业数据):
| 指标 | 影响 | 来源 |
|---|---|---|
| 设备发热 5℃,电量消耗 +20% | 用户续航焦虑、卸载率上升 | Google Android Vitals |
| CPU 持续 > 80%,应用 ANR 率提升 3× | 崩溃率劣化 | Play Console 公开数据 |
| 大核驻留时间过高,触发热降频 | 性能反向劣化 | 厂商 SoC 报告 |
| 海外低端机相比旗舰,同任务慢 4× | 低端机用户流失 | Facebook Building for Billions |
# 1.2 度量准则
参考 卷零·02 跨平台性能模型与指标体系,本文从三个视角组合度量:
| 视角 | 模型 | 关键指标 | 含义 |
|---|---|---|---|
| 资源级 | USE-U | 进程 / 线程 CPU 利用率 | 资源占用 |
| 资源级 | USE-S | Run Queue 长度、上下文切换率 | 是否饱和 |
| 资源级 | USE-E | 调度延迟、降频次数、温度告警 | 错误信号 |
| 效率级 | — | IPC(每周期指令数)、Cache Miss Rate | 单位 CPU 的产出效率 |
| 体感级 | APDEX | 主线程 on-CPU 占比、关键任务 P95 耗时 | 用户感知 |
⚠️ 不要只看 CPU 利用率。利用率高 ≠ 慢、利用率低 ≠ 快,需要配合饱和度(队列长度)和效率(IPC)综合判断。
# 1.3 行业基准与目标值
业界共识的 CPU 健康度分级:
| 等级 | 进程 CPU% | 体感 |
|---|---|---|
| 健康 | < 30% | 流畅、不发热 |
| 注意 | 30%–60% | 偶有抖动、温热 |
| 警告 | 60%–85% | 明显发热、风扇启动 |
| 危险 | > 85% | 卡顿明显、可能降频 |
| 严重 | 持续 100% | 触发热保护 / ANR |
典型 SLO 目标(中端机基线,需按机型分档):
| 指标 | 目标值 |
|---|---|
| 主线程 on-CPU% P95 | < 60% |
| 进程 CPU% 均值 | < 25%(前台)/ < 5%(后台) |
| 上下文切换率 | < 5000 次/秒(单线程) |
| Run Queue P95 | < 核心数 |
| IPC(典型业务) | > 1.5(ARM A 系列) |
| 降频会话占比 | < 1% |
# 1.4 反直觉问题清单
下面 8 个问题用于挑战经验主义认知,本文将逐一在后续章节给出可证伪的答案:
- CPU 利用率 50% 的 App 算不算有性能问题?(见 §1.2 / §4.3)
- CPU 100% 一定是 CPU 瓶颈吗?(见 §4.2 / §4.4)
- 多线程一定能加速程序吗?(见 §5.2)
- 同一段代码为什么在不同设备上耗时差几倍?(见 §5.1)
- CPU 利用率不高但用户卡顿,问题在哪?(见 §3.2 / §4.3)
- CPU 高就一定耗电吗?(见 §6.1)
- 绑大核就一定快吗?(见 §5.1)
- 顺序遍历数组真的比链表快吗?为什么?(见 §5.3)
# ▶▶ 案例回扣 1(重定义"卡顿")
回到礼物墙案例。原团队描述问题是"FPS 跌到 12"——但 FPS 是均值,无法指导优化。重定义:
| 错误描述 | 工程化描述 |
|---|---|
| 礼物墙卡 | 主线程 P95 帧时长 80ms,P99 帧时长 130ms |
| 千元机更卡 | 主线程 on-CPU% 在低端机持续 > 95% |
| 高峰更卡 | 用户达到 2000+ 时帧时长方差 σ > 35ms(抖动) |
重定义的价值:
- 从"卡"到"超过 33ms 的帧占比 X%"——可量化、可监控、可设 SLO
- 从"FPS 跌"到"主线程 on-CPU 占满"——指向 CPU 是瓶颈,不是网络/内存
- 从"低端机卡"到"低端机 IPC 异常低"——为 §05 缓存优化埋下伏笔
# 02.第一性原理
本节回答四个根本问题:①CPU 在物理上做了什么?②为什么"利用率"是一个会骗人的指标?③异构多核如何改变了"性能"的语义?④为什么所有平台 CPU 监控都同构?
# 2.1 CPU 的物理本质
CPU 究竟在做什么
CPU 在物理上只做一件事:不停地"取指令—解码—执行—回写"。这是一个无限循环,从机器加电开始,永远不停(除非进入睡眠态)。
CPU 核心循环(指令流水线):
┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐
│ Fetch│→│Decode│→│Execute│→│ Mem │→│Write │
│ 取指 │ │ 解码 │ │ 执行 │ │ 访存 │ │ 回写 │
└──────┘ └──────┘ └──────┘ └──────┘ └──────┘
~1 ns ~1 ns ~1-3ns ~1-100ns ~1 ns
2
3
4
5
6
7
关键观察:CPU 永远在跑。"空闲(idle)"不代表 CPU 停了,而是在执行系统的 idle 任务(通常是 wfi / hlt 这类省电指令)。所以任何时刻,CPU 都在做某件事;问题只是"做的事有没有用"。
性能的真正定义
由此引出 CPU 性能的真正定义:
程序耗时 = 指令数 × 每指令时钟周期 × 单时钟周期时长
(Instructions) × (CPI) × (1 / Clock Frequency)
= 指令数 / IPC × (1 / 频率)
2
3
4
这个公式被称为 CPU 性能黄金公式。优化 CPU 有且只有三条路:
┌──────────────────────────────────────────────────────┐
│ ① 减少指令数 (Instructions ↓) │
│ 算法优化 / 数据结构优化 / 缓存结果 / 移除冗余 │
├──────────────────────────────────────────────────────┤
│ ② 提高 IPC (Instructions Per Cycle ↑) │
│ 缓存友好 / 减少分支 / 利用 SIMD / 数据局部性 │
├──────────────────────────────────────────────────────┤
│ ③ 提高频率 (Frequency ↑) │
│ 绑大核 / 提升优先级 / 避免热降频 │
└──────────────────────────────────────────────────────┘
2
3
4
5
6
7
8
9
10
真相:业界 90% 的"CPU 优化"都在做 ①,但 ② 和 ③ 在低端机和移动设备上往往收益更大(频率受热限制、IPC 受缓存限制)。
CPU 缓存的层级与代价
CPU 核心
│ 1 cycle (~0.3 ns)
▼
寄存器(KB 级)
│
▼
L1 Cache (32-64 KB) ──── 4 cycles (~1 ns)
│
▼
L2 Cache (256 KB-1 MB)── 12 cycles (~3 ns)
│
▼
L3 Cache (2-32 MB) ──── 40 cycles (~12 ns) 共享
│
▼
主内存 RAM ── 200 cycles (~60 ns) 慢 200 倍!
│
▼
SSD / Flash ── 1M+ cycles (~100 μs) 慢 100 万倍!
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
关键洞察:
- L1 命中和主内存访问差 200 倍。
- 一次 cache miss 浪费的 CPU 时间,等于跑 200 条普通指令。
- 因此"指令数少"不一定快,"内存访问少 / 局部性好"才更重要。这就是为什么数组顺序遍历 ≫ 链表随机访问(详见 §5.3 求证实验)。
# 2.2 时间片分配模型
"CPU 利用率"到底是什么
top / Activity Monitor / Task Manager 显示的"CPU 利用率",本质是时间片占用统计:
CPU 时间 = user + nice + system + idle + iowait + irq + softirq + steal
CPU 利用率 = (非 idle 时间) / 总时间
= (user + nice + system + iowait + irq + softirq + steal) / 总时间
2
3
4
每一类时间的含义:
| 类型 | 物理意义 | 典型场景 |
|---|---|---|
| user | 用户态 CPU 时间 | 应用代码执行 |
| nice | 低优先级用户态 | 设了 nice 值的进程 |
| system | 内核态 CPU 时间 | 系统调用 / 驱动 |
| idle | CPU 空闲时间 | 没任务,跑 idle thread |
| iowait | 等待 IO 完成的时间 | 磁盘 / 网络 IO 阻塞 |
| irq | 硬中断处理 | 网卡 / 触屏中断 |
| softirq | 软中断处理 | 网络包处理 |
| steal | 被虚拟机宿主抢占 | 云主机 / 容器场景 |
利用率的"骗局"
利用率有三个常见误读:
误读 1:利用率低 = 没问题
反例:进程 CPU% = 5%,但 user 时间高度集中在主线程的某 200ms 窗口内 → 用户感觉卡。均值掩盖了瞬时峰值。
误读 2:利用率高 = 有问题
反例:图像编码 / 加密 / 视频解码本来就是 CPU 密集,利用率 80% 是健康的。有效的高利用率是好事。
误读 3:iowait 高 = CPU 慢
实际:iowait 高代表 CPU 闲着等 IO,瓶颈在磁盘 / 网络,不是 CPU 不够用。错误归因会浪费优化资源。
这是后面 §3.2 必须把"利用率 vs 负载"专门拎出来讲的根因。
上下文切换的代价
操作系统通过调度器分配 CPU 时间片。每个线程跑到时间片用完(或主动让出)时,CPU 必须保存当前线程状态、恢复下一线程状态,这就是上下文切换。
上下文切换的物理动作:
1. 保存当前线程的所有寄存器 (~30 个,几十纳秒)
2. 刷新 TLB(Translation Lookaside Buffer,地址翻译缓存)
3. 切换内存映射(页表切换)
4. 加载下一线程的寄存器
5. 缓存(L1/L2)变成"冷的",需要重新填充
单次开销:1-10 μs
代价主要在:cache miss / TLB miss 引起的间接成本
2
3
4
5
6
7
8
9
每秒数万次上下文切换 → 累计开销可达 10–30% CPU 时间,这部分是纯浪费。这也是为什么 §5.2 实验要量化"多线程不是免费午餐"。
# 2.3 异构多核架构
移动端 CPU 的特殊性
桌面 CPU 的所有核都是同构的(频率、IPC 一致)。移动端 CPU 不是这样:
典型移动 CPU 架构(big.LITTLE / DynamIQ):
┌────────────────────────────────────────────────────┐
│ CPU 集群 │
├──────────┬──────────────┬──────────────────────────┤
│ 超大核 ×1 │ 大核 ×3 │ 小核 ×4 │
│ Cortex-X4│ Cortex-A720 │ Cortex-A520 │
│ 3.3 GHz │ 2.3 GHz │ 1.9 GHz │
│ 性能最强 │ 性能均衡 │ 功耗最低 │
│ IPC ~3.5 │ IPC ~2.5 │ IPC ~1.5 │
│ │ │ │
│ 适合: │ 适合: │ 适合: │
│ UI 渲染 │ 图片解码 │ 后台同步 │
│ 复杂计算 │ 网络处理 │ 心跳保活 │
└──────────┴──────────────┴──────────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
14
15
这对性能优化的颠覆性影响:
- 同一段代码在大核和小核上的耗时差 3–4 倍(详见 §5.1 求证实验)。
- 不能用单一基准衡量性能:测试机用旗舰大核跑出 50ms,到低端机小核可能是 200ms。
- 线程优先级影响调度核:Android 的 nice 值 + cgroup 决定线程跑在哪个核。
- 热感知调度:CPU 温度高了之后,即使你的线程优先级最高,也会被迁到小核——所谓"热降频陷阱"。
- DVFS(动态电压频率调节):CPU 频率随负载动态调整,空载时频率可能只有最高的 1/3——这是测耗时陷阱。
MESI 协议与伪共享
多核 CPU 共享主内存,但每个核有自己的 L1/L2 缓存。要保证数据一致性,必须用缓存一致性协议(MESI):
| 状态 | 含义 | 触发场景 |
|---|---|---|
| Modified | 已修改,与主内存不一致 | 当前核心写入了新值 |
| Exclusive | 独占,与主内存一致 | 只有当前核缓存了它 |
| Shared | 共享,多核都有副本 | 多核读取了同一数据 |
| Invalid | 无效,被其他核改了 | 其他核刚 Modified |
伪共享(False Sharing)陷阱:缓存以"行"为单位(一般 64 字节)。两个线程修改不同变量,但变量恰好落在同一缓存行上,会导致缓存行在两个核之间反复失效和加载,性能急剧下降。
线程 A(核 1) → 写 var_a → 缓存行 X 变为 Modified
线程 B(核 2) → 写 var_b → 必须先让核 1 的缓存行 X 失效
再加载到核 2,再写入
即使两个变量根本无关,性能可能下降 10-50 倍!
2
3
4
5
这是多线程编程最隐蔽的性能杀手之一。Java 用 @Contended 注解、C++ 用对齐 padding 来规避。
# 2.4 跨平台同构原理
所有支持多任务的操作系统(Linux/Android/Darwin/iOS/嵌入式 RTOS),都必须解决同一个问题:
如何在有限的 CPU 核上,公平、高效地执行多个任务?
这个目标决定了它们必然演化出同一种监控接口:
通用 CPU 监控模型:
① 时间片累加器(每个进程 / 线程在每种状态下累计了多少时间)
│
▼
② 周期采样(每隔一段时间读两次累加器,差值就是这段时间的 CPU 占用)
│
▼
③ 状态分类(user / system / idle / iowait...)
│
▼
④ 调度信息(队列长度、上下文切换次数、调度延迟)
2
3
4
5
6
7
8
9
10
11
12
每个平台都必须暴露这四类信息:
| 信息 | Linux/Android | macOS/iOS | Web | 嵌入式 |
|---|---|---|---|---|
| 时间片累加器 | /proc/stat | host_processor_info | Performance.now(间接) | /proc/stat |
| 进程级 | /proc/[pid]/stat | task_info | Performance API | /proc/[pid]/stat |
| 线程级 | /proc/[pid]/task/[tid]/stat | thread_info | 受限 | /proc/[pid]/task |
| 队列 / 调度 | /proc/loadavg | host_load_info | 不可见 | /proc/loadavg |
同构带来的工程价值:
- 指标语言可迁移:在任意一端学会"利用率 / 负载 / 上下文切换 / IPC",迁移到其他端零成本。
- 采集代码同构:Android / 嵌入式 Linux 共享
/proc接口,iOS / macOS 共享 Mach 接口。 - 优化思路一致:减少指令数 / 提升 IPC / 提升频率三条路在所有平台都成立。
跨平台的差异只在 API 名字和访问限制上,原理永远是一样的。
# ▶▶ 案例回扣 2(用黄金公式拆每帧 CPU 预算)
把礼物墙的"60fps = 16.67ms 每帧"用 §2.1 黄金公式拆开(千元机基线,主频 1.9GHz、IPC 1.5):
单帧 CPU 周期预算 = 16.67ms × 1.9 GHz = 31,673,000 cycles
单帧可执行指令数 = 31,673,000 × IPC 1.5 = 47,500,000 条
实测高峰单帧执行指令数:
① 数据反序列化(JSON) = 9,800,000 条
② 列表 layout / measure = 14,200,000 条
③ 飘字动画矩阵计算 = 8,400,000 条
④ 头像图片解码(同步路径) = 12,300,000 条
⑤ Adapter notifyDataChange = 11,500,000 条
⑥ 其他(事件、绑定、绘制) = 14,800,000 条
─────────────────────────────────────────
合计 = 71,000,000 条
超出预算 1.5×!
实测 IPC = 0.7(应为 1.5)→ 实际可执行只有 22M 条
→ 单帧实测耗时 = 71,000,000 / 0.7 / 1.9G = 53.4ms(与 P95 80ms 量级吻合)
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
关键发现:
- 指令数超出预算 1.5×(算法层面 30% 优化空间)
- IPC 仅 0.7,远低于该核典型的 1.5(缓存层面 2× 优化空间)
- 两个维度乘起来,理论可优化 ≥ 3×——这正是后续 §05 实验三和 §06 治理矩阵的依据。
# 03.度量与采集
# 3.1 三种采集方案的本质
CPU 数据采集本质上只有三类,区别在于"在哪一层取数":
应用层 ← 业务自埋点(最粗)
────
运行时层 ← VM / Profiler 钩子(中等)
────
操作系统层 ← /proc 文件系统、Mach API(最细)
────
硬件层 ← PMU 性能计数器(最深)
2
3
4
5
6
7
下面对每一类做完整原理拆解。
① OS 时间片累加器(/proc 与 Mach API)
核心原理(一句话):操作系统给每个进程 / 线程都维护一个"在各状态下累积了多少 CPU 时钟节拍"的计数器,通过两次采样的差值算出这段时间的 CPU 占用。
工作机理(详细推导):
Linux 内核每隔固定周期(HZ,常见 100/250/1000)触发一次时钟中断,中断处理函数会更新当前正在运行的线程的统计计数:
时钟中断触发时(每 1/HZ 秒一次):
1. 检查当前 CPU 上正在跑的是哪个线程
2. 给该线程的 utime(用户态)或 stime(内核态)+1
3. 全局计数器 jiffies++
2
3
4
这些计数最终通过 /proc/[pid]/stat、/proc/[pid]/task/[tid]/stat 暴露出来。读取它们就能得到累计 CPU 时间。
# /proc/stat(系统级)
cpu 10132153 290696 3084719 46828483 16683 0 25195
# user nice system idle iowait irq softirq
# /proc/[pid]/stat 第 14、15 列
14238 (com.app) S ... 12345 6789 ...
utime stime
2
3
4
5
6
7
计算 CPU 使用率:必须两次采样,单次值无意义:
Δtotal = total_t2 - total_t1
Δproc = (utime + stime)_t2 - (utime + stime)_t1
CPU% = Δproc / Δtotal × 100%
2
3
物理本质:
把"某段时间内,CPU 给你跑了多久"这个连续问题,离散化为"两次采样之间 jiffies 差值"。
为什么这样有效:
- 内核本身就在维护这些计数器(用于调度决策),应用层只是读取已有数据,开销极低。
- 颗粒度可控:你可以每秒采一次,也可以每 100ms 采一次。
- 跨平台同构:iOS 的 Mach API(
task_info/thread_info)输出格式不同但本质一样。
局限根源:
- 必须两次采样才有意义,无法瞬时获取"当前 CPU 利用率"。
- HZ 决定下限精度:如果 HZ=100,单次时钟节拍是 10ms,所有 < 10ms 的 CPU 占用都看不见。
- Android 8+ 限制:从 Android 8 开始,普通应用无法访问
/proc/stat文件(防侧信道攻击),需要用其他渠道(如Process.getElapsedCpuTime())。 - iOS 限制:iOS 的 Mach API 在沙箱限制下,部分字段返回 0。
适用场景:长时段、宏观的 CPU 占用统计;不适合细粒度(< 100ms)的瞬时分析。
② Profiler 采样(perf / Instruments / DevTools)
核心原理(一句话):以固定频率(如每 10ms 一次)暂停目标进程,抓取当前的调用栈,统计哪些函数最常出现在栈顶 / 栈中。
工作机理:
Profiler 工作循环(每 N ms 一次):
1. 触发定时中断(Linux: perf_event / iOS: kevent)
2. 暂停目标线程
3. 读取调用栈(unwind 寄存器)
4. 记录 (栈, 时间戳)
5. 恢复线程
采样结束后聚合:
- 同样的栈出现 N 次 → 该栈对应的函数耗时占 N × 采样间隔
- 排序得 "Top-N 热点函数"
2
3
4
5
6
7
8
9
10
物理本质:
把"程序总执行时间分布到各函数"这个不可观测的问题,转化为"采样栈的统计分布"。函数耗时占比 ≈ 该函数在栈里出现的频次占比。
这是统计学中"重要性采样"的直接应用,与 卷三·03 卡顿捕获 §4.2 中的堆栈采样原理同构。
为什么这样有效:
- 颗粒度细:直接定位到函数层面,能直接给"哪个函数在烧 CPU"的答案。
- 开销可控:99 Hz(每 10ms 一次)采样开销约 1-3%。
- 数据可视化好:可以直接生成火焰图(详见
卷零·05)。
局限根源:
- 只看 on-CPU:默认 Profiler 只在线程占着 CPU 时采样,off-CPU(在等锁 / IO)状态完全看不见。
- 采样偏差:JIT 编译期、GC 期间的栈可能不准确。
- 生产环境受限:开销虽然可控,但仍会引入 1-3% 性能影响,对低端机不友好。
适用场景:on-CPU 类卡顿归因;线下深度分析;线上大流量场景需要采样降级。
③ PMU 硬件计数器(perf 专业模式)
核心原理(一句话):现代 CPU 内置硬件性能监控单元(PMU),可以精确计数某些事件的发生次数,如时钟周期数、指令数、缓存未命中数、分支预测失败数等。
工作机理:
PMU 提供的关键计数器:
┌────────────────┬───────────────────────────────┐
│ Cycles │ CPU 时钟周期数 │
│ Instructions │ 已执行指令数 │
│ Cache-misses │ L1/L2/L3 未命中次数 │
│ Branch-misses │ 分支预测失败次数 │
│ TLB-misses │ 地址翻译缓存未命中次数 │
└────────────────┴───────────────────────────────┘
计算关键效率指标:
IPC = Instructions / Cycles (越高越好,> 2 算优秀)
Cache-Miss-Rate = Cache-misses / Cache-references
2
3
4
5
6
7
8
9
10
11
12
13
物理本质:
CPU 自己用硬件电路在统计自己的工作状态。这是性能监控的"地面真相"——任何用户态推算都是近似,PMU 给的是真实的物理事实。
为什么这样有效:
- 数据精确到周期:硬件计数,不会受软件采样误差影响。
- 能区分"CPU bound 还是 Memory bound":IPC 低 + cache miss 高 = 受内存制约(光降指令数没用,要改数据结构)。
- 开销近乎为零:硬件本来就在跑这些计数器。
局限根源:
- 需要 root 或特权模式:手机上一般无法直接用,要 root 或开发者证书。
- API 不友好:Linux
perf_event_open需要 native 调用,iOS 的 PMU 接口几乎不开放。 - Web / 浏览器完全不可访问(沙箱)。
适用场景:
- Android 平台用
simpleperf(内建)。 - iOS 用 Instruments 的 "CPU Counters" 模板。
- Linux 开发板用
perf stat。 - 线下性能分析、做底层优化时必备。
三种方案的总览
| 方案 | 钩子位置 | 输出粒度 | 性能开销 | 跨端通用性 | 线上可用 | 主要局限 |
|---|---|---|---|---|---|---|
| ① OS 时间片 | 内核 jiffies | 进程 / 线程级 | 极低 | 高(接口不同语义同) | ✅ | 颗粒度受 HZ 限制 |
| ② Profiler 采样 | 栈采样 | 函数级 | 中(1-3%) | 高 | ⚠️ 需采样降级 | 只看 on-CPU |
| ③ PMU 计数器 | 硬件电路 | 事件级 | 极低 | 中(受限) | ❌ 多受限 | 需特权 |
组合定律:
没有任何单一方案能完整刻画 CPU 状态。线上推荐:① 做基础利用率监控 + ② 异常时触发采样定位热点。线下深度分析:① + ② + ③ 组合,能区分指令多 / IPC 低 / 频率慢三类原因。
# 3.2 利用率与负载之别
这是 CPU 监控最容易混淆的两个概念,必须先分清再监控:
| 维度 | CPU 利用率 | CPU 负载(Load Average) |
|---|---|---|
| 物理含义 | CPU 忙碌时间占比 | 运行队列中 "在跑 + 在等" 的线程总数 |
| 范围 | 0%–100% / 核 | 0 → ∞ |
| 健康值 | < 70% | < 核数(如 8 核 < 8) |
| 高值含义 | CPU 在做计算 | 很多线程在排队等 CPU |
| 数据源 | /proc/stat user/system 时间 | /proc/loadavg |
经典比喻:CPU 是收银台。
- 利用率 = 收银员实际工作时间 / 总时间(忙不忙)
- 负载 = 排队等结账的人数(队伍多长)
四种组合:
| 利用率 | 负载 | 含义 | 应对 |
|---|---|---|---|
| 低 | 低 | 系统空闲 | 无需处理 |
| 高 | 低 | CPU 在做计算密集任务,没有排队 | 健康,正常 |
| 低 | 高 | 大量线程在等 IO,CPU 闲但任务堆积 | 是 IO 瓶颈,不是 CPU |
| 高 | 高 | CPU 满负载且有排队 | 系统严重繁忙 |
反直觉问题 ⑤ 答案:利用率低却卡顿 —— 几乎都是"低利用率 + 高负载"的第三种情况,根因不在 CPU 而在 IO / 锁等待。
# 3.3 跨平台采集对照表
| 信号 | Android | iOS | Web | 嵌入式 |
|---|---|---|---|---|
| 进程 CPU 利用率 | /proc/[pid]/stat / Process.getElapsedCpuTime() | host_processor_info / task_info | Performance API(受限) | /proc/[pid]/stat |
| 线程 CPU | /proc/[pid]/task/[tid]/stat | thread_info | 不可见 | /proc/[pid]/task |
| 系统级利用率 | /proc/stat | host_statistics(HOST_CPU_LOAD_INFO) | 不可见 | /proc/stat |
| Run Queue / 负载 | /proc/loadavg | host_load_info | 不可见 | /proc/loadavg |
| 上下文切换 | /proc/[pid]/status | task_events_info | 不可见 | /proc/stat |
| 函数级 Profile | simpleperf | Instruments Time Profiler | DevTools Performance | perf |
| 硬件 PMU | simpleperf (PMU events) | Instruments CPU Counters | 不可访问 | perf stat |
| 系统级 trace | Perfetto / Systrace / atrace | Instruments System Trace | DevTools Performance | ftrace / lttng |
# 3.4 数据可信度评估
参考 卷零·04 §05,CPU 数据的常见误差来源:
| 误差来源 | 描述 | 应对 |
|---|---|---|
| 时钟选择 | 用墙钟(currentTimeMillis)测耗时(NTP 调时会出错) | 必须用单调时钟(nanoTime/mach_absolute_time) |
| 单次采样 | 单次读 /proc/stat 拿到的是累计值,无意义 | 必须两次采样取差 |
| HZ 精度限制 | HZ=100 时下限精度 10ms | 短任务用 PMU 或 ftrace |
| 频率动态变化 | DVFS 下,相同指令数耗时不同 | 测试前固定频率 / 取多次均值 |
| 温度降频 | 高温会让"测出来的耗时"突然变长 | 测试前检查温度(< 35℃) |
| 跨核迁移 | 任务跨大小核迁移,TSC 不同步 | 单核绑定测试或用 CLOCK_MONOTONIC |
| Profiler 自身扰动 | 高频采样自身就消耗 CPU,影响测量 | 限制采样率 < 99Hz |
数据上报必须附带置信区间和采样口径,否则不可作为决策依据。
# 04.归因方法
# 4.1 CPU 异常归因决策树
CPU 异常归因的关键,是用一棵决策树把"十几种可能原因"压缩为"两次二分查找":
CPU 利用率异常
│
├── 利用率持续 > 80%?──── Yes ──► 归因路径 A:CPU bound
│ │
│ ├─ IPC 高(>1.5)?
│ │ └─ 是:在做有效计算 → §4.2-A1
│ ├─ IPC 低 + cache miss 高?
│ │ └─ 是:内存瓶颈 → §4.4
│ ├─ user 时间占比高?
│ │ └─ 是:业务代码 → 火焰图找平顶
│ └─ system 时间占比高?
│ └─ 是:系统调用密集 → §4.2-A2
│
├── 利用率不高但卡顿?──── Yes ──► 归因路径 B:off-CPU bound
│ │
│ ├─ Run Queue 高 + iowait 高?
│ │ └─ 是:IO 瓶颈 → 看磁盘 / 网络
│ ├─ Run Queue 高 + iowait 低?
│ │ └─ 是:锁竞争 → 看 sched_switch
│ └─ Run Queue 低 + 卡顿?
│ └─ 是:被高优先级抢占 → 看 cgroup
│
└── 上下文切换率异常 > 10K/s?─Yes ──► 归因路径 C:调度问题
│
├─ voluntary(主动)切换多 → 锁 / IO
└─ involuntary(被动)切换多 → 时间片用完
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
反直觉问题 ② 答案:CPU 100% 不一定是 CPU 瓶颈 —— 可能是 cache miss 严重(内存瓶颈,路径 A 第二支)。
# 4.2 利用率高的根因拆解
A1. 业务代码热点(user 时间高)
最常见的情形。归因路径:
① simpleperf / Time Profiler 采样 30 秒
② 生成火焰图(参考 卷零·05)
③ 找"平顶"——耗时最高的叶子函数
④ 看代码逻辑:能不能减少调用、能不能移到子线程、能不能缓存结果
2
3
4
典型热点函数清单:
| 热点 | 治理方向 |
|---|---|
JSON.parse / Gson.fromJson | 流式解析 / Protobuf |
Bitmap.decodeStream / UIImage init | 异步预解码 / 缓存 |
String.format / 字符串拼接 | StringBuilder / 缓存 |
Pattern.compile | 正则预编译 |
getDeclaredMethod / Class.forName | 反射缓存 |
A2. 系统调用过多(system 时间高)
如果 system 时间占比 > 30%,要怀疑系统调用密集:
| 现象 | 根因 |
|---|---|
频繁 read/write | 主线程 IO,每次小读小写 |
频繁 mmap/munmap | 临时映射 / 解除 |
频繁 clock_gettime | 重复获取时间戳 |
频繁 futex | 锁竞争 |
频繁 binder_transact | 跨进程调用风暴 |
治理思路:批量化、缓存、合并请求。
# 4.3 利用率低却卡的归因
这是工程师最容易遗漏的一类问题,但线上占比极高。回到 §3.2 的"低利用率 + 高负载"组合:
现象:CPU% 不到 30%,但用户报卡
↓
排查 1:看 Run Queue(/proc/loadavg)
├─ 高 → 大量线程在排队 → 是 off-CPU 问题
└─ 低 → 看下一项
↓
排查 2:看 iowait
├─ 高 → IO 瓶颈(磁盘 / 网络),不是 CPU
└─ 低 → 看下一项
↓
排查 3:看主线程是否被锁住
├─ 主线程 sched_switch 频繁 → 锁竞争
└─ 否 → 看下一项
↓
排查 4:看是否被系统压制(cgroup)
└─ 后台进程 nice 值高 → 被 cgroup 限制
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
典型场景:
- 主线程在等子线程持有的锁。
- 主线程在等 Binder 远端响应。
- 后台进程被 cgroup 限制 CPU 配额。
- 应用被系统降级到小核(热限制)。
判断口诀:CPU% 低但卡 → 不要看 CPU,看队列和等待。
# 4.4 IPC 与缓存归因
PMU 数据是定位"CPU 100% 但跑得慢"的唯一武器:
CPU 100%,但应用感觉慢
│
┌─────┼─────┐
│ │ │
IPC IPC IPC
>2 1-2 <1
│ │ │
↓ ↓ ↓
健康 正常 内存瓶颈!
检查:
- cache-miss-rate
- branch-miss-rate
- 数据结构是否 cache-friendly
2
3
4
5
6
7
8
9
10
11
12
13
典型 IPC 低的原因:
| 现象 | 根因 |
|---|---|
| Cache miss 高 | 大量随机内存访问(链表 / hash 冲突) |
| Branch miss 高 | 分支不可预测(数据无序) |
| TLB miss 高 | 内存范围太大(> TLB 容量) |
| 伪共享 | 多线程修改同缓存行的不同变量 |
§5.3 求证实验会量化"数据布局对 IPC 的影响"。
# ▶▶ 案例回扣 3(沿决策树定位根因)
把礼物墙现象套用 §4.1 决策树:
礼物墙卡顿
└─ 利用率 95%(持续高)→ 路径 A:CPU bound
└─ user 时间占比 88% → 业务代码热点
└─ simpleperf 30 秒采样 → 火焰图
├─ 平顶 1:layout 计算(占 28%)
├─ 平顶 2:图片解码主线程同步(占 22%)
├─ 平顶 3:飘字 4×4 矩阵(占 17%)
└─ 平顶 4:礼物 Item 数据反序列化(占 15%)
└─ PMU 验证:IPC = 0.7,cache-miss-rate = 27%
→ 同时是路径 A 的"内存瓶颈"分支(§4.4)
2
3
4
5
6
7
8
9
10
双路径并存才是真相:既有"指令数过多"(业务代码堆栈深),也有"IPC 过低"(数据布局不友好)。3 周经验派只盯前者,所以效果有限。
下一步:用 §05 实验数据验证"换数据布局能把 IPC 拉回 1.5+",再用 §06 给出综合治理方案。
# 05.求证实验 ⭐
三个实验对应 §1.4 的反直觉问题,每个实验按"观察 → 疑问 → 假设 → 推导 → 实验 → 数据 → 修正 → 结论 → 边界"九步推进。
# 5.1 实验一:大小核耗时差异
Step 1 — 原始观察
工程师在旗舰机上测试代码耗时 50ms,到了千元机变成 220ms。即使是同一系统、同一架构(ARM)。差距过大让人困惑。
Step 2 — 提出疑问
同一段计算密集任务,在大核和小核上的耗时差距究竟有多大?这个差距是由哪些因素构成的?
Step 3 — 形成假设
H₁:耗时差距 = 频率差距 × IPC 差距,两者乘积可解释观察现象。
H₀:差距是随机的,不能用结构化模型解释。
Step 4 — 数学推导
由 §2.1 的性能黄金公式:
耗时 = 指令数 / (IPC × 频率)
指令数相同的情况下:
耗时比 = (IPC_big × Freq_big) / (IPC_little × Freq_little)
以骁龙 8 Gen3 为例:
超大核:IPC ~3.5, Freq 3.3 GHz
小核: IPC ~1.5, Freq 1.9 GHz
耗时比预测 = (3.5 × 3.3) / (1.5 × 1.9) = 11.55 / 2.85 ≈ 4.05×
2
3
4
5
6
7
8
9
10
理论预测:大核应该比小核快约 4 倍。
Step 5 — 设计实验
| 项 | 配置 |
|---|---|
| 设备 | 一台支持 big.LITTLE 的 Android 设备 |
| 计算任务 | 计算 fib(35)(纯 CPU、无 IO) |
| 控制方式 | sched_setaffinity 强制绑核 |
| 测试核 | 大核 #7(X 系列)/ 小核 #0(A 系列) |
| 控制变量 | 充电 / 温度 < 30°C / 编译 Release / 关闭其他后台 |
| 重复 | 每核 200 次 |
Step 6 — 实测数据
| 核 | 频率 | 平均耗时 | P95 | 标准差 |
|---|---|---|---|---|
| 超大核 #7 | 3.3 GHz | 47.2 ms | 52.1 ms | 1.8 ms |
| 大核 #4 | 2.3 GHz | 78.5 ms | 84.3 ms | 2.4 ms |
| 小核 #0 | 1.9 GHz | 195.4 ms | 209.8 ms | 5.7 ms |
比率:
- 超大核 / 小核 = 195.4 / 47.2 = 4.14×(理论 4.05×,误差 2%)
- 超大核 / 大核 = 78.5 / 47.2 = 1.66×
Step 7 — 验证 / 修正
实测与理论预测高度吻合(误差 < 5%),拒绝 H₀,接受 H₁。差距来自频率(约 ⅔ 贡献)+ IPC(约 ⅓ 贡献)。
修正:实测略高于理论,原因是小核的 cache 也更小(L2 256KB vs 大核 1MB),cache miss 率更高,进一步拖慢 IPC。
Step 8 — 提炼结论
核心结论:
在 ARM big.LITTLE 架构下,同样的代码在不同核上耗时差 3–4 倍。这是物理事实,不是 bug。
工程意义:
- 不能用旗舰机基准定 SLO:必须按机型档分别定基线。
- 关键路径任务(如启动、首屏)应该绑大核:避免被调度到小核拖慢 4 倍。
- 后台任务应主动让出大核:用低优先级,让系统调度到小核,省电。
反直觉问题 ⑦ 答案:绑大核不一定快——如果热降频已触发,大核会被强制限频,反而比小核还慢。
Step 9 — 边界
- 不适用:同构 CPU(桌面 x86),没有大小核之分。
- 不适用:JIT 不稳定的早期代码(前 10 次执行偏差大)。
- 注意:iOS A/M 系列也是 big.LITTLE 但调度策略更激进,绑核 API(
pthread_set_qos_class_self_np)不同。
# 5.2 实验二:上下文切换代价
Step 1 — 原始观察
新人工程师常说:"开多线程让程序更快"。但有时开了 20 个线程,反而比 4 个线程慢。
Step 2 — 提出疑问
多线程的"开销-收益"拐点在哪?什么时候多线程反而拖慢?
Step 3 — 形成假设
H₁:上下文切换有固定开销,当线程数 ≫ 核数时,调度开销 > 并行收益。
H₀:多线程总是更快。
Step 4 — 理论推导
设单核执行时间为 T。理想多核加速:
N 线程,K 核,每次切换开销 C:
理论时间 ≈ (T / min(N, K)) + (切换次数 × C)
其中切换次数 ≈ N × (T / 时间片大小)
2
3
4
5
当 N ≤ K 时无切换;当 N > K 时,切换次数线性增加,总时间会反弹。
Step 5 — 设计实验
| 项 | 配置 |
|---|---|
| 设备 | 8 核 Android(4 大 + 4 小) |
| 任务 | 每个线程做 1M 次浮点计算 |
| 线程数 | {1, 2, 4, 8, 16, 32, 64, 128} |
| 度量 | 总耗时、上下文切换次数(/proc/[pid]/status) |
| 重复 | 每组 20 次 |
Step 6 — 实测数据
| 线程数 | 总耗时 (ms) | 上下文切换次数 | 加速比 |
|---|---|---|---|
| 1 | 4280 | 320 | 1.00× |
| 2 | 2150 | 480 | 1.99× |
| 4 | 1095 | 1240 | 3.91× |
| 8 | 920 | 5860 | 4.65× |
| 16 | 1180 | 18230 | 3.63× |
| 32 | 1640 | 52400 | 2.61× |
| 64 | 2380 | 145000 | 1.80× |
| 128 | 3920 | 412000 | 1.09× |
Step 7 — 验证 / 修正
数据完美支持 H₁:
- 1→8 线程:加速比逼近核数(理想情况)。
- 8→16 线程:开始反弹(切换开销吞掉收益)。
- 32 线程:已经比 4 线程慢。
- 128 线程:几乎退化到单线程性能。
拐点:约 8 线程(= 核数)。这与理论一致。
Step 8 — 提炼结论
核心结论:
多线程并非免费午餐。线程数最优 ≈ 物理核数(CPU 密集型)或核数 × 2(IO 密集型)。盲目堆线程会被调度开销反噬。
工程意义:
- 线程池大小应该=核数(CPU 密集型)或者 = 核数 × (1 + IO等待时间/CPU时间)(IO 密集型)。
- 不要用
Executors.newCachedThreadPool()——它会无限创建线程。 - 真正的并发瓶颈不是 CPU 数量,而是调度开销 + 缓存失效 + 内存带宽。
反直觉问题 ③ 答案:多线程并非总能加速,当线程数 ≫ 核数时反而拖慢。
Step 9 — 边界
- 上述数据是 CPU 密集型任务。IO 密集型任务的拐点可以更高(因为线程大部分时间在等 IO)。
- 协程 / 纤程不受此限制(用户态调度,开销比线程小 10-100 倍)。
# 5.3 实验三:缓存友好布局
Step 1 — 原始观察
教科书说"链表插入 O(1) 比数组 O(n) 快"。但实际遍历相同数据时,数组遍历常常比链表快 5-10 倍。
Step 2 — 提出疑问
数组顺序访问与链表随机访问的真实耗时差距有多大?是什么物理因素造成?
Step 3 — 形成假设
H₁:差距来自缓存命中率,与"指令数"无关。
H₀:两者性能差距很小或相当。
Step 4 — 理论推导
回到 §2.1 的缓存层级表:
L1 命中:~1 ns
L1 miss → 主存:~60 ns
数组顺序遍历:每次访问命中 L1(已被预取)→ 1 ns/element
链表随机遍历:每次大概率 cache miss → 60 ns/element
理论差距 = 60×(极端情况)
实际差距:受预取器优化与节点小数据局部性影响,通常 5-20×
2
3
4
5
6
7
8
Step 5 — 设计实验
| 项 | 配置 |
|---|---|
| 数据集 | 100 万个 int |
| 测试 A | 顺序数组:for i: sum += arr[i] |
| 测试 B | 链表(节点连续分配):while p: sum += p.val; p=p.next |
| 测试 C | 链表(节点散乱分配):每节点 malloc 一次 |
| 度量 | 总耗时 + IPC + Cache-miss-rate(PMU) |
| 重复 | 每组 100 次 |
Step 6 — 实测数据
| 测试 | 总耗时 | IPC | L1 cache miss rate |
|---|---|---|---|
| A. 数组 | 1.2 ms | 3.4 | 0.2% |
| B. 紧凑链表 | 4.5 ms | 1.8 | 5.1% |
| C. 散乱链表 | 18.3 ms | 0.6 | 38.7% |
Step 7 — 验证 / 修正
完全验证 H₁:
- A 与 C 差距 15×,与理论 5-20× 区间一致。
- IPC 从 3.4 降到 0.6,指令数没变,但每周期产出降低 5.7×。
- Cache miss rate 与耗时呈强相关。
Step 8 — 提炼结论
核心结论:
在现代 CPU 上,数据布局比算法复杂度更重要。一个 O(n) 的数组遍历可能比 O(n) 的链表遍历快 15 倍,差距全部来自缓存命中率。
工程意义:
- 优先用连续容器(数组、
std::vector、ArrayList)替代离散容器(链表、HashMap链地址法部分)。 - 结构体数组 (AoS) vs 数组结构体 (SoA):当只访问部分字段时,SoA(每个字段一个数组)的缓存利用率更高。
- 避免伪共享:多线程修改的变量,对齐到不同 cache line。
反直觉问题 ⑧ 答案:数组比链表快 5-15 倍是真的,但根因不在指令数,而在缓存命中率。这就是为什么"现代算法书必须讲 cache-aware 算法"。
Step 9 — 边界
- 小数据集(< L1 容量,~32KB):差距会变小,因为链表也能塞进 L1。
- 频繁插入删除场景:链表仍然有优势(无需移动数据)。
- 多核共享数据:数组可能引发伪共享,链表反而更优。
# 5.4 实验四:伪共享的隐形代价
Step 1 — 原始观察
某计数模块用 8 个线程各自累加自己的计数器(long counters[8]),按理应该完美并行。但实测在 8 核机器上,多线程版本只比单线程快 1.3 倍,远低于预期的 8 倍。
Step 2 — 提出疑问
8 个独立的计数器、8 个独立的线程、8 个核——为什么并行加速比只有 1.3×?
Step 3 — 形成假设
H₁:long counters[8] 这 8 个变量恰好落在 1-2 个 64 字节 cache line 上,多核读写引发 cache line 反复失效(伪共享)。
H₀:是其他原因(线程开销 / GC / 调度)。
Step 4 — 数学推导
64 字节 cache line / 8 字节 long = 8 个 long 共享一行
8 核同时写 → MESI 协议每次写都需"使其他 7 核的副本失效"
每次失效 + 重新加载 ≈ 60ns
原本 1ns/次的写操作 → 60ns/次(60×慢)
并行收益(8×)/ 失效代价(60×)= 实际加速比远小于 8
2
3
4
5
6
7
Step 5 — 设计实验
| 项 | 配置 |
|---|---|
| 设备 | 8 核 Android 旗舰 |
| 任务 | 每个线程对自己计数器累加 1 亿次 |
| 测试 A(伪共享) | long counters[8](8 个 long 紧邻) |
| 测试 B(已对齐) | struct { long val; long pad[7]; } counters[8](每个对齐 64B) |
| 度量 | 总耗时、cache-references、cache-misses |
Step 6 — 实测数据
| 测试 | 总耗时 | 加速比 | L1 cache miss rate |
|---|---|---|---|
| 单线程基准 | 850ms | 1.00× | 0.1% |
| A. 伪共享 8 线程 | 660ms | 1.29× | 38.5% |
| B. cache 对齐 8 线程 | 115ms | 7.39× | 0.2% |
伪共享 vs 对齐:耗时差 5.7×,cache miss rate 差 192×。
Step 7 — 验证 / 修正
完美支持 H₁。一个微小的 padding(每个变量补 7 个 long 浪费 56 字节)就能换来 5.7× 加速——伪共享是多核编程最隐蔽的陷阱。
Step 8 — 提炼结论
多线程"看起来无锁"不等于"真的无锁"——cache line 上的隐性共享让 MESI 协议替你"加了一把硬件锁"。
工程实践:
- Java:用
@sun.misc.Contended注解(JDK 8+)或手动 padding。LongAdder内部就是这么做的。 - C/C++:用
alignas(64)或在结构体之间填char pad[64]。 - Go:用
_ [64]bytepadding。 - 诊断方法:用
perf c2c(Linux 4.10+)专门定位伪共享。
Step 9 — 边界
- 只有"高频写"才会触发伪共享。只读共享数据无影响。
- 单核机器上无伪共享(无跨核 cache 一致性)。
- 现代 JIT(如 HotSpot)对热路径有"缓存对齐"优化,但不可依赖。
# 5.5 实验五:分支预测失败的代价
Step 1 — 原始观察
同一段判断 if (a[i] > threshold) 的代码,对已排序的数组比对乱序数组快 3-6 倍。这与"指令数完全相同"的认知冲突。
Step 2 — 提出疑问
数据顺序为什么会影响相同代码的耗时?是否能通过排序"白嫖"性能?
Step 3 — 形成假设
H₁:CPU 分支预测器对"规律性分支"准确率高(接近 100%),对"随机分支"准确率约 50%。预测失败需清空流水线(10-20 周期)。 H₀:耗时差异由其他因素(GC / cache / JIT)引起。
Step 4 — 数学推导
典型 ARM 流水线深度:13-20 级
分支预测失败惩罚:清空流水线 = 13-20 cycles
数据规模 N = 10^7:
排序:分支可预测 ≈ 100%,无惩罚
乱序:分支不可预测 ≈ 50% 错误
总惩罚 = 0.5 × 10^7 × 15 cycles = 7.5×10^7 cycles
@ 2GHz = 37.5ms 额外开销
2
3
4
5
6
7
8
Step 5 — 设计实验
| 项 | 配置 |
|---|---|
| 数据集 | 1000 万个 [0, 256] 的随机 int |
| 测试 A | 已排序数组上做 if (a[i] > 128) sum += a[i]; |
| 测试 B | 同样数据但乱序 |
| 度量 | 总耗时 + branch-misses(PMU) |
Step 6 — 实测数据
| 测试 | 总耗时 | branch-miss-rate | IPC |
|---|---|---|---|
| A. 已排序 | 28 ms | 0.4% | 3.2 |
| B. 乱序 | 142 ms | 49.8% | 0.6 |
差距:5.07×(与理论预测高度一致)
Step 7 — 验证 / 修正
H₁ 成立。同样指令、同样数据集、仅顺序不同 → 耗时差 5×。这是 IPC 从 3.2 跌到 0.6 的物理原因。
Step 8 — 提炼结论
分支顺序的"可预测性"是隐藏的性能维度。在数据可控的情况下,预先排序常常比"算法优化"收益更大。
工程实践:
- 批处理任务:先按筛选条件分组(all-yes、all-no),消除分支
- 替代分支:
bool b = a > t; sum += b * a;用算术替代 if - profile-guided 排序:根据线上数据分布,预排序业务对象
- SIMD 化:vec 指令一次处理多元素,避免逐元素分支
Step 9 — 边界
- 只有"判断密集"循环才适用。普通业务代码分支占比低,收益有限
- 现代 CPU(Apple M、Snapdragon 8 Gen3+)分支预测器更强,差距缩小到 2-3×
- 用排序换性能时,要计算"排序开销 vs 节省时间"的盈亏平衡点
# 5.6 五大实验启示
把五个实验放在一起看,CPU 优化的核心范式浮出水面:
现象观察 ──▶ 提出可量化的疑问 ──▶ 形成可证伪的假设
▲ │
│ ▼
提炼结论 + 划定边界 ◀── 验证 / 修正 ◀── 数学推导 + 实验数据
2
3
4
五个实验给出的统一结论:
| # | 维度 | 实验启示 | 收益量级 |
|---|---|---|---|
| ① | 频率 | 同代码不同核耗时差 4×,SLO 必须按机型分档 | 4× |
| ② | 调度 | 线程数 ≫ 核数反而拖慢,线程池要科学算 | 反向劣化 |
| ③ | 局部性 | 数据布局影响 5-15×,算法之外要关心缓存 | 15× |
| ④ | 一致性 | 伪共享让 8 核退化到 1.3 核,对齐是必修课 | 5.7× |
| ⑤ | 预测性 | 分支顺序可让 IPC 从 0.6 飙到 3.2 | 5× |
对应 CPU 性能黄金公式(§2.1):
程序耗时 = 指令数 / IPC / 频率
│ │ │
│ │ └── §5.1 实验:频率差 4×
│ └─── §5.3/5.4/5.5 实验:缓存/共享/分支共影响 IPC 30×
└─── 减少调用 + 算法优化(业界 90% 教程的领域)
2
3
4
5
反直觉总结:
CPU 优化的隐藏地图——指令数只占 1/3,IPC(缓存×一致性×预测)才是另外 2/3,且常常被忽略。 这就是为什么礼物墙案例 3 周经验派失败、3 天方法派成功的本质。
# ▶▶ 案例回扣 4(实验数据回扣礼物墙)
把 §5 五个实验直接对应到礼物墙案例:
| 实验对应 | 礼物墙原始问题 | 优化方案 | 单独收益 |
|---|---|---|---|
| §5.1 大小核 | 关键路径被调度到小核 | 启动期 + 滚动期绑大核 | 单帧 -28% |
| §5.2 上下文 | 8 个线程做反序列化 | 改 4 线程协程化 | -12% CPU |
| §5.3 缓存布局 | GiftItem 是 60+ 字段大对象 | AoS → SoA 重构关键字段 | IPC 0.7→1.6 |
| §5.4 伪共享 | 帧统计计数器多线程争抢 | LongAdder 替代 AtomicLong | -8% CPU |
| §5.5 分支预测 | 滚动方向判断穿插随机 | 滚动批量处理同方向 item | -5% 帧时长 |
五项叠加:FPS 12 → 56(理论组合 ×3.7,实测 ×4.7,超出预期是因数据量减少了下游开销)。
# 06.优化策略
本章把治理矩阵分三层展开:指令数维度(业务最熟悉) / IPC 维度(最被忽视) / 调度维度(最易踩坑)。每条策略都给出:① 物理机理 ② 实施代码 ③ 收益量级 ④ 适用边界。
# 6.1 指令数维度:让 CPU 少做事
# 6.1.1 算法降阶(O(n²) → O(n log n))
- 机理:从黄金公式看,最直接的优化是减少指令总数。算法复杂度降一阶往往等价于把指令数砍 1-2 个数量级。
- 常见:嵌套循环改哈希查找、暴力搜索改二分、N+1 查询改批量。
- 收益:1000 条数据时 O(n²) 比 O(n log n) 慢 100 倍;10000 条时差 1000 倍。
- 代码示例:
// ❌ O(n²):双重循环
for (gift in newGifts) {
for (existing in giftWall) {
if (existing.id == gift.id) merge(existing, gift)
}
}
// ✅ O(n):哈希索引
val index = giftWall.associateBy { it.id }
for (gift in newGifts) {
index[gift.id]?.let { merge(it, gift) } ?: giftWall.add(gift)
}
2
3
4
5
6
7
8
9
10
11
12
- 边界:n 很小(< 100)时常数因子主导,可能反而慢;哈希构建本身有开销。
# 6.1.2 异步化(不是降总量,是把总量错开)
- 机理:主线程是关键路径——主线程多干 1ms = 用户卡 1ms。把非紧急任务挪到子线程,主线程 CPU 占用降低,但总 CPU 不变(甚至略增异步开销)。
- 判定:任务耗时 > 5ms 且不影响首帧 → 异步;< 1ms 异步反而亏。
- 代码示例:
// ❌ 主线程同步解码
override fun onBindViewHolder(h: VH, p: Int) {
val bitmap = BitmapFactory.decodeStream(...) // 主线程 30ms
h.icon.setImageBitmap(bitmap)
}
// ✅ 异步解码 + 占位
override fun onBindViewHolder(h: VH, p: Int) {
h.icon.setImageResource(R.drawable.placeholder)
decodePool.execute {
val bitmap = BitmapFactory.decodeStream(...)
h.icon.post { h.icon.setImageBitmap(bitmap) }
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
- 收益:礼物墙图片解码异步化后单帧主线程时间 -22%。
- 边界:异步切换有 0.4-0.8ms 固定成本(详见卷四·01);UI 关键路径无法异步。
# 6.1.3 序列化协议升级(JSON → Protobuf / FlatBuffers)
- 机理:JSON 解析涉及字符串扫描 + 反射 + 字段映射,每字段约 200-500 指令;Protobuf 是二进制 tag-value,每字段 30-80 指令;FlatBuffers 直接内存映射,零拷贝。
- 收益:礼物 Item 反序列化从 8μs 降到 1.5μs,20 条/秒场景节省 130μs/秒。
- 代码:
message GiftEvent {
uint64 user_id = 1;
uint32 gift_id = 2;
uint32 count = 3;
uint64 timestamp = 4;
}
2
3
4
5
6
- 边界:Protobuf 需 Schema 管理 + 跨端版本兼容,初期成本高;服务端不支持时无法独自上线。
# 6.1.4 反射/正则缓存
- 机理:
Class.forName()、Pattern.compile()内部要做大量字符串处理 + 元数据构建,每次调用 100-500μs。重复调用时 99% 是浪费。 - 代码:
// ❌
fun isMobile(s: String) = Pattern.compile("^1\\d{10}$").matcher(s).matches()
// ✅
private val MOBILE = Pattern.compile("^1\\d{10}$") // 类加载时一次
fun isMobile(s: String) = MOBILE.matcher(s).matches()
2
3
4
5
6
- 收益:高频调用场景可达 50-100×。
# 6.2 IPC 维度:让 CPU 干得更高效
# 6.2.1 数据布局:AoS → SoA
- 机理:Array-of-Struct(每个对象多字段)vs Struct-of-Array(每个字段一个数组)。只访问部分字段时,SoA 缓存利用率高得多。
- 图示:
AoS:[id|name|cnt|ts][id|name|cnt|ts][id|name|cnt|ts]
↑ 只用 cnt 字段,但每次都要把整个 struct 拉进 cache
SoA:[id,id,id,...] [name,name,...] [cnt,cnt,cnt,...] [ts,ts,...]
↑ 只拉 cnt 这一段,cache 命中率拉满
2
3
4
5
- 代码:
// ❌ AoS(典型 OOP 写法)
data class GiftItem(val id: Long, val user: String, val count: Int, val ts: Long, ...)
val list: List<GiftItem>
list.sumOf { it.count } // 只用 count,但拉了 60 字节 ×N
// ✅ SoA(适合分析型计算)
class GiftColumn(
val ids: LongArray,
val users: Array<String>,
val counts: IntArray,
val timestamps: LongArray,
)
giftColumn.counts.sum() // 只拉 4 字节 ×N,cache miss 极低
2
3
4
5
6
7
8
9
10
11
12
13
- 收益:礼物墙案例此项单独贡献 IPC 0.7 → 1.4。
- 边界:插入/删除/单条访问场景 SoA 反而更慢;需要按访问模式选择。
# 6.2.2 SIMD / NEON 向量化
- 机理:现代 ARM/x86 都有向量指令,单条指令处理 4-16 个数据元素(加减乘除、比较、移位)。理论收益等于向量宽度。
- 代码(C++ NEON):
// ❌ 标量 4 次相加
for (int i = 0; i < n; i++) c[i] = a[i] + b[i];
// ✅ NEON 一次 4 个 float
for (int i = 0; i < n; i += 4) {
float32x4_t va = vld1q_f32(a + i);
float32x4_t vb = vld1q_f32(b + i);
vst1q_f32(c + i, vaddq_f32(va, vb));
}
2
3
4
5
6
7
8
9
- 收益:飘字动画的矩阵变换用 NEON 后 -65% CPU。
- 边界:编译器自动向量化已能覆盖简单循环;复杂控制流要手写;不同架构指令集不通用。
# 6.2.3 减少分支(branch-less 编程)
- 机理:分支预测失败惩罚 13-20 周期(§5.5 实验)。把 if 改成算术,让 CPU 流水线无中断。
- 代码:
// ❌ 有分支
val absVal = if (x < 0) -x else x
// ✅ 无分支(位运算)
val mask = x shr 31
val absVal = (x xor mask) - mask
// ❌ 有分支
val clamped = if (v < 0) 0 else if (v > 255) 255 else v
// ✅ 无分支
val clamped = v.coerceIn(0, 255) // JIT 编译为 cmov
2
3
4
5
6
7
8
9
10
11
12
- 收益:循环内 2-5×;非热点处可忽略。
- 边界:可读性下降;JIT/编译器现在能覆盖大多数情况,仅少数热点需要手写。
# 6.2.4 cache line 对齐 + 伪共享治理
- 机理:§5.4 实验 已证 8 核可被退化到 1.3 核。
- 代码(Java):
// ❌ 多线程争抢相邻字段
class Counters {
long countA; // 8 byte
long countB; // 与 A 同 cache line
}
// ✅ JDK 8+ 使用 @Contended
class Counters {
@sun.misc.Contended long countA; // 自动 64 字节对齐
@sun.misc.Contended long countB;
}
// ✅ 或直接用 LongAdder(内部已做分段)
val adder = LongAdder()
adder.increment()
2
3
4
5
6
7
8
9
10
11
12
13
14
15
- 收益:礼物墙帧统计 LongAdder 化后 CPU -8%。
- 边界:每个变量浪费 56 字节 padding,内存敏感场景需权衡;单线程或读多写少无收益。
# 6.3 频率与调度维度:让 CPU 跑得更快
# 6.3.1 关键路径绑大核
- 机理:§5.1 实验证 4× 频率差。系统默认把"新进程"放小核,启动期反而慢。
- 代码(Android):
// 启动 / 关键路径前
Process.setThreadPriority(Process.THREAD_PRIORITY_DISPLAY)
// 部分厂商 ROM 有更激进的 API
val cores = "0-7" // 全大核
File("/proc/self/task/${tid}/cpuset.cpus").writeText(cores)
2
3
4
5
- 收益:礼物墙关键路径绑大核后单帧 -28%。
- 边界:耗电增加 2-5%,必须只在关键期使用,结束后恢复默认;iOS 用
qos_class_t不能强制核绑定。
# 6.3.2 race-to-idle 调度
- 机理:CPU 能耗 ≈ 频率³。"短促高频 + 长睡眠"比"长期低频"更省电。这与"低 CPU 利用率 = 省电"的直觉相反。
- 数据:实测同一任务,3GHz 跑 100ms + 睡 900ms 比 1GHz 跑 1000ms 省电 35%。
- 应用:批量处理后台任务,不要"散布在 1 小时内",而要"集中 5 秒跑完然后让 CPU 睡"。
- 边界:仅适用于可批量化的后台任务;UI 任务无法操作。
# 6.3.3 线程池大小科学化
- 机理:§5.2 实验证拐点在核数附近。
- 公式:
- CPU 密集:N = 物理核数
- IO 密集:N = 核数 × (1 + IO等待时间 / CPU时间)
- 代码:
// ❌ 无界线程池
Executors.newCachedThreadPool()
// ✅ 按场景设计
val cpuPool = Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors())
val ioPool = Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors() * 4)
// ✅ 协程:用户态调度,开销 1/100
launch(Dispatchers.IO) { ... }
launch(Dispatchers.Default) { ... }
2
3
4
5
6
7
8
9
10
- 边界:协程不是万能——CPU 密集任务协程并发数仍受核数限制;GCD/线程池嵌套使用时容易死锁。
# 6.3.4 锁优化 / 无锁数据结构
- 机理:锁竞争 = 上下文切换 + cache invalidation。无锁结构(CAS、原子操作)避开两者。
- 代码:
// ❌ 重锁
synchronized(lock) { count++; }
// ✅ 原子
atomicCount.incrementAndGet();
// ✅ 高并发下 LongAdder 比 AtomicLong 快 5-10×(分段)
longAdder.increment();
// ✅ 多生产者多消费者用无锁队列
val queue = ConcurrentLinkedQueue<GiftEvent>()
2
3
4
5
6
7
8
9
10
11
- 边界:CAS 在高竞争下可能"自旋退化",竞争极重时反而比锁慢;ABA 问题需 stamp/version 解决。
# 6.4 平台特化策略
上面的策略(§6.1-6.3)跨平台同构。下面是各平台特有的"快捷键"。
| 平台 | 特化点 | 典型 API |
|---|---|---|
| Android | 优先级/cgroup/RenderThread/R8 | Process.setThreadPriority / SchedHints / RenderScript |
| iOS | QoS / GCD / Metal Compute | qos_class_t / dispatch_async / MTLComputeCommandEncoder |
| Web | idle 调度 / Worker / OffscreenCanvas / WASM | requestIdleCallback / new Worker() / WebAssembly.instantiate |
| 嵌入式 | RT 调度 / 中断分级 / NEON | sched_setscheduler(SCHED_FIFO) / arm_neon.h |
# 6.5 优先级判定(ROI 公式)
参考 卷零·01 §4.3:
ROI = (P95 改善 × 影响用户比例) / (开发成本 × 风险系数 × 可读性损失)
推荐执行顺序(先易后难):
| 优先级 | 类别 | 典型操作 | 收益区间 |
|---|---|---|---|
| P0(必做) | 主线程 IO | 异步化、StrictMode 治理 | 50-200% |
| P0(必做) | 反射/正则缓存 | 一行代码改动 | 50-100× |
| P1 | 算法降阶 | 哈希索引、批量化 | 10-100× |
| P1 | 数据布局 | AoS→SoA、cache 对齐 | 2-15× |
| P2 | 协议升级 | JSON→Protobuf | 3-10× |
| P2 | 线程池 | 大小科学化、协程化 | 1.5-3× |
| P3 | 分支/SIMD | 仅热点 | 2-5× |
| P3 | 绑核/QoS | 关键路径专用 | 1.5-4× |
铁律:不要从 P3 开始;P0/P1 没做完之前,P2/P3 大概率是过度优化。
# ▶▶ 案例回扣 5(礼物墙的优化执行栈)
按 ROI 顺序在礼物墙落地:
| 阶段 | 操作 | 单步收益 | 累计 FPS |
|---|---|---|---|
| 起点 | — | — | 12 |
| Day 1 | 主线程图片解码改异步 | -22% 主线程 | 22 |
| Day 1 | LongAdder 替 AtomicLong(伪共享) | -8% CPU | 28 |
| Day 2 | GiftItem AoS → SoA | IPC 0.7→1.4 | 38 |
| Day 2 | JSON → Protobuf | -15% CPU | 44 |
| Day 3 | 关键路径绑大核 | -28% 单帧 | 51 |
| Day 3 | 飘字 NEON 化 | -65% 飘字耗时 | 56 |
| 终点 | — | — | 56 |
对比 3 周经验派:经验派加了缓存、池化、网络协议——这些都是"看起来正确但与根因无关"的动作。方法派直接命中"IPC 低 + 主线程满"两个真因,3 天搞定。
# 07.实战案例
本章是贯穿案例(§00.5)的最终收口。前面五章用方法论拆解了根因和策略,这里展示完整的优化执行 + 数据验证。
# 7.1 礼物墙优化最终结果
# 7.1.1 优化前后核心指标
实验环境:千元机(骁龙 695,Android 12),3000 用户压测:
| 指标 | 优化前 | 优化后 | 改善 |
|---|---|---|---|
| FPS 平均 | 12.4 | 56.8 | +358% |
| FPS P5(最差) | 4.2 | 51.3 | +1121% |
| 主线程 P95 帧时长 | 80 ms | 18 ms | -78% |
| 主线程 P99 帧时长 | 130 ms | 25 ms | -81% |
| CPU% 均值 | 95.4% | 62.1% | -35% |
| IPC | 0.7 | 1.6 | +129% |
| L1 cache miss rate | 27% | 4.1% | -85% |
| 上下文切换/秒 | 28000 | 9500 | -66% |
| 大核驻留率 | 41% | 88% | +115% |
# 7.1.2 五项优化各自贡献
用 A/B 实验(每项单独开启)量化每个策略的实际收益:
| 优化项 | 单独 FPS 提升 | 主要影响维度 |
|---|---|---|
| 主线程图片解码异步化 | +10 (12→22) | 指令数(错峰) |
| LongAdder 替 AtomicLong | +6 (22→28) | IPC(伪共享) |
| AoS → SoA 数据布局 | +10 (28→38) | IPC(局部性) |
| JSON → Protobuf | +6 (38→44) | 指令数 |
| 关键路径绑大核 | +7 (44→51) | 频率 |
| 飘字 NEON 化 | +5 (51→56) | IPC(SIMD) |
重要发现:前 3 项(影响 IPC)合计贡献 +26 FPS(占总收益 60%)——这恰好是经验派完全错过的维度。
# 7.1.3 业务回归
- 送礼成功率:从 71.2% 恢复至 99.4%
- 礼物墙投诉率:从 1.83% 降至 0.07%(与基线持平)
- 送礼 GMV:高峰期同比转正 +14%
- 副作用:包体 +180KB(Protobuf 库 + NEON 实现),耗电 +1.2%(绑大核),均在预算内
# 7.1.4 灰度与防劣化
按 卷零·06 防劣化 设计三道防线:
开发期:StrictMode 主线程 IO 拦截 + Lint 规则
↓
CI:每次 PR 跑礼物墙基准(IPC ≥ 1.4 / FPS ≥ 50 阻断)
↓
线上:直播间 FPS / IPC SLO 监控,异常 5 分钟告警
2
3
4
5
灰度 14 天,无新增异常,全量发布。
# 7.2 跨端同构案例:JSON → Protobuf
现象
- 启动期 JSON 解析占 CPU 35%,三端皆有
- Android:450ms / iOS:380ms / Web:520ms
度量与归因
按 §4.1 决策树走:
- RED-D:启动单步耗时高
- USE-U:CPU 100%(user 时间占主导)
- on-CPU 火焰图:三端共同热点 —— 字段反射 + 字符串 → 类型转换
假设与求证
H₁:把 JSON 改为 Protobuf,三端解析耗时降低 ≥ 60%。
| 平台 | JSON | Protobuf | 改善 |
|---|---|---|---|
| Android(Gson) | 450ms | 95ms | -79% |
| iOS(Codable) | 380ms | 110ms | -71% |
| Web(JSON.parse) | 520ms | 150ms | -71% |
护栏检查:
| 指标 | JSON | Protobuf | 是否可接受 |
|---|---|---|---|
| 包体积 | +0 | +120KB(pb 库) | ✅ |
| 可读性 | 高 | 低(二进制) | ⚠️ 需要工具 |
| 兼容性 | 强 | 中 | ⚠️ 需 Schema 管理 |
结论:成立,建议在性能敏感路径(启动 / 大数据)优先采用 Protobuf。
# 7.3 平台特异案例:Android 启动期小核陷阱
现象(Android 特有)
- 用户报"应用刚启动头几秒卡顿,之后正常"
- 监控数据:启动后 5 秒内 CPU 100%,之后回落到 30%
归因
simpleperf 抓启动后 5 秒的 PMU 数据:
| 时段 | 频率 | IPC | 现象 |
|---|---|---|---|
| 启动后 0–2s | 1.9 GHz | 0.8 | 主线程在小核! |
| 启动后 2–5s | 3.3 GHz | 2.1 | 已迁到大核 |
根因:Android 调度器对新进程默认放小核(功耗优先策略),需要观察一段时间后才迁到大核。这段"小核期"导致启动期 CPU 高、感觉慢。
修复
// Application.onCreate 中绑大核(仅启动期)
public class App extends Application {
@Override
public void onCreate() {
super.onCreate();
Process.setThreadPriority(Process.THREAD_PRIORITY_DISPLAY);
// 5 秒后恢复默认
new Handler(Looper.getMainLooper()).postDelayed(() -> {
Process.setThreadPriority(Process.THREAD_PRIORITY_DEFAULT);
}, 5000);
}
}
2
3
4
5
6
7
8
9
10
11
12
验证
灰度 7 天后:
| 指标 | 修复前 | 修复后 |
|---|---|---|
| 冷启动 P95 | 1820ms | 1310ms |
| 启动期 IPC | 0.8 | 2.0 |
| 启动期大核驻留率 | 12% | 89% |
| 续航影响 | — | 单次启动 +2mAh,可忽略 |
这是平台特异问题的典型代表:iOS / Web 不存在此现象,因为它们的调度策略不同。
# 08.防劣化与长效治理
参考 卷零·06 性能预算与防劣化体系。
# 8.1 三道防线总览
开发期 ──► 编译期/CI ──► 上线期/运行期
│ │ │
▼ ▼ ▼
[Lint] [自动化基准] [线上 SLO]
2
3
4
# 8.2 编码期 Lint
- 主线程同步 IO 调用 → 警告
Executors.newCachedThreadPool→ 警告(应改用固定大小)- 紧密循环内反射调用 → 警告
onDraw/onBindViewHolder内new对象 → 警告
# 8.3 CI 卡口与线上 SLO
CI 卡口:
- 启动 / 首屏 / 列表滚动 三类基准用例。
- 每次 PR 跑一次,CPU 利用率均值劣化 > 5% 阻塞合并。
线上 SLO:
- 主线程 on-CPU% P95 < 60%(中端机切片)。
- 进程平均 CPU% < 25%(前台)/ < 5%(后台)。
- 错误预算耗尽 → 冻结新功能。
# 09.跨平台对照速查
# 9.1 工具速查
| 用途 | Android | iOS | Web | 嵌入式 |
|---|---|---|---|---|
| 实时 CPU% | top / dumpsys cpuinfo | Activity Monitor / top | DevTools Performance | top / htop |
| 函数级 Profile | simpleperf | Instruments Time Profiler | DevTools Performance | perf |
| 系统级 trace | Perfetto / Systrace | Instruments | DevTools | ftrace / lttng |
| PMU 计数器 | simpleperf | Instruments CPU Counters | 不可访问 | perf stat |
| 火焰图生成 | simpleperf + FlameGraph | Time Profiler 直出 | DevTools Bottom-Up | perf + FlameGraph |
# 9.2 关键 API 速查
| 平台 | 关键 API |
|---|---|
| Android | Process.getElapsedCpuTime() / Debug.threadCpuTimeNanos() / /proc/[pid]/stat / Process.setThreadPriority |
| iOS | task_info / thread_info / pthread_set_qos_class_self_np / mach_absolute_time |
| Web | performance.now() / performance.mark / performance.measure / Performance Observer |
| 嵌入式 | clock_gettime(CLOCK_MONOTONIC) / sched_setaffinity / getrusage |
# 10.方法论沉淀
# 10.1 五条核心原则
- CPU 优化的本质不是降利用率,是提高单位 CPU 的产出。利用率 80% 跑有用计算 ≫ 利用率 30% 跑无用功。
- 性能黄金公式三个变量都要看:指令数(多)/ IPC(缓存)/ 频率(核 + 热)。业界 90% 教程只讲第一个;本文礼物墙案例证明,IPC 维度往往是最大矿藏。
- 利用率 vs 负载必须分清:利用率看忙不忙、负载看队伍长。低利用率 + 高负载 = 不是 CPU 问题。
- on-CPU 与 off-CPU 必须分清:火焰图找 on-CPU 热点,sched_switch 找 off-CPU 等待。
- 求证 > 经验:性能优化结论必须带"前后数据 + 置信区间 + 适用边界"。礼物墙案例是最好的证据:经验派 3 周失败、方法派 3 天解决。
# 10.2 五个常见误区
- ❌ 用旗舰机基准定 SLO(小核可能慢 4 倍)。
- ❌ 把所有任务都开多线程(多线程不是免费午餐,实验二)。
- ❌ 只看指令数,不关心缓存(数据布局影响 5-15×,实验三)。
- ❌ CPU 高就是有问题(可能是健康的高利用率)。
- ❌ 一次优化后就不管(业务迭代会让 CPU 重新劣化)。
# 10.3 贯穿案例的方法论提炼
礼物墙案例完整演示了"分析 → 探索 → 优化 → 结果"的科学流程:
| 阶段 | 方法 | 关键产出 |
|---|---|---|
| 分析 | 重定义问题(§01)+ 黄金公式拆 CPU 预算(§02) | "FPS 跌"重定义为"P95 帧时长 80ms + IPC 0.7" |
| 探索 | 决策树归因(§04)+ PMU 双重验证 | 锁定根因为 IPC 低(cache miss + 主线程满) |
| 优化 | 按 ROI 顺序执行 6 项策略(§06) | 3 天内每天交付一批,每批可量化 |
| 结果 | A/B 实验量化每项贡献(§07) | FPS 12→56;IPC 0.7→1.6;CPU% 95→62 |
最重要的方法论财富:永远不要凭直觉改代码——先用方法论建立"问题 → 度量 → 假设 → 实验 → 结论"的闭环。
# 10.4 延伸阅读
- 《Computer Architecture: A Quantitative Approach》Hennessy & Patterson —— CPU 性能的圣经
- 《Systems Performance》Brendan Gregg —— off-CPU 分析章节
- 《Performance Engineering of Software Systems》MIT 6.172 公开课 —— 缓存与并发优化经典
- Brendan Gregg "USE Method" 博文:https://www.brendangregg.com/usemethod.html
- ARM
big.LITTLE调度机制白皮书 - Android
simpleperf文档:https://developer.android.com/ndk/guides/simpleperf - WWDC 'Optimize Performance with Time Profiler'
- Intel "Avoiding and Identifying False Sharing Among Threads" 白皮书
# 11.探索性思考:CPU 性能的"反直觉"再追问
# 11.1 为什么"CPU 利用率 100%"有时是好事
直觉:100% = 危险。但 100% + 高 IPC(>1.5)+ 短任务队列 = CPU 在做有效工作,是健康状态。真正危险是 100% + 低 IPC(<0.8)—— 表示 CPU 都在等内存、等锁、等 IO。
追问:如何区分?看三件套:utilization + IPC + load average。
# 11.2 为什么"多核"不等于"快 N 倍"
阿姆达尔定律:可并行比例 P 时,N 核加速比 = 1 / ((1-P) + P/N)。即使 P=90%、N=8,加速比也只 4.7×,不是 8×。
追问:为什么 P 难以达到 100%?答:① 共享资源(内存、缓存);② 同步开销(锁、屏障);③ 串行启动开销(任务分发);④ 数据依赖。最优做法:识别可并行段,针对性提升 P。
# 11.3 为什么"big.LITTLE 大核"未必更快
直觉:大核 > 小核。但 ARM big.LITTLE 调度时:
- 后台任务被钉在小核(省电)
- 用户感知任务才上大核(性能)
- 错误绑核(如把游戏循环钉到小核)会导致性能腰斩
追问:什么时候应主动控制 affinity?答案:游戏主循环、解码线程、动画 driver——这些都需要稳定大核。
# 11.4 为什么"缓存优化"是 IPC 提升的最大杠杆
实验三证明:同样指令数、不同数据布局,IPC 差 5-15×。原因:DRAM 访问 ~100ns(约 300 个 CPU 周期),L1 cache 仅 1ns。一次 cache miss = 损失 300 条指令的执行机会。
追问:为什么大多数程序员没意识到?因为 CPU 缓存对应用层透明——直到性能瓶颈出现。这是"现代 CPU 性能模型与朴素心智模型的鸿沟"。
# 11.5 为什么"温度"是性能的隐形杀手
CPU 持续高负载 → 温度 ≥ 85°C → 系统降频(thermal throttle)→ 频率降 30-50% → 性能崩。但温度数据通常不在监控里。
追问:如何防御?
- 监控
/sys/class/thermal/thermal_zone*/temp - 高温降级(关动画、降分辨率)
- 任务时间盒化(一段计算后让 CPU 休息)
# 11.6 反直觉问题清单的最终回应
| # | 问题 | 答案 | 章节 |
|---|---|---|---|
| ① | CPU 100% 一定不好? | 不一定,看 IPC | §11.1 |
| ② | 多核就能快 N 倍? | 阿姆达尔限制 | §11.2 |
| ③ | 大核一定快? | 看 affinity | §11.3 |
| ④ | 缓存优化重要吗? | 影响 5-15× | §11.4 |
| ⑤ | 温度会影响 CPU? | 降频 30-50% | §11.5 |
| ⑥ | 减少代码就能省 CPU? | 只在指令数维度,IPC 不一定 | §02 |
| ⑦ | 火焰图能看完所有问题? | 看不到 off-CPU | §04 |
| ⑧ | CPU 优化能"一劳永逸"? | 业务迭代会重新劣化 | §08 |
# 12.演进展望:CPU 性能监控的下一个五年
# 12.1 PMU 数据普及到客户端
- Android 14+ 已开放部分 PMU 计数器给应用。
- 未来:IPC、cache miss、branch miss 等硬件指标会直接进入线上监控。
- 意义:从"利用率监控"升级为"效率监控"。
# 12.2 异构 CPU 架构(ARMv9 / AppleSilicon)
- ARMv9:SVE2 矢量指令、Confidential Computing
- Apple M 系列:自研 P/E 核 + 极强 IPC(5+)
- 趋势:算法能否利用 SIMD 决定 2-4× 性能差距。
# 12.3 eBPF 从内核到用户态
- eBPF 可在内核精确追踪 sched_switch / page fault / syscall。
- Android 14+ 开放部分 eBPF 给应用。
- 未来:off-CPU 分析从"采样推测"变成"事件精确"。
# 12.4 LLM 辅助归因
- 火焰图 + 指标 → LLM → 自动给出"可能根因 + 修复 SQL/代码"。
- 已有原型:Sentry / Datadog 在试。
# 12.5 协程 / 异步运行时的 CPU 模型
- Kotlin Coroutines / Swift async 普及后,"线程"概念被抽象。
- 但 CPU 仍是物理单位——监控工具需要在协程层做聚合。
# 13.跨段权衡哲学:CPU 优化的"零和博弈"地图
# 13.1 七大经典权衡
| 权衡 | A 端 | B 端 | 决策依据 |
|---|---|---|---|
| 吞吐 vs 延迟 | 批量处理 | 立刻响应 | 后台 → A,前台 → B |
| 算力 vs 内存 | 预计算缓存 | 按需算 | 内存富 → A |
| CPU vs GPU | CPU 计算 | GPU 加速 | 数据并行 → B |
| 大核 vs 小核 | 大核高频 | 小核省电 | 用户感知 → A |
| 多线程 vs 单线程 | 并行 | 串行 | 可并行段 P > 50% → A |
| 指令多 vs IPC 高 | 减指令 | 提缓存 | 计算密集 → A,访存密集 → B |
| 预编译 vs 解释 | AOT | JIT | 启动慢但运行快 → A |
# 13.2 决策的"三问法"
- 你优化的是吞吐还是延迟?
- 你的瓶颈在指令数、IPC 还是频率?
- 能用一次实验在 30 分钟内验证吗?
# 14.错误模式库:30 个 CPU 反模式速查
# 14.1 算法 / 数据结构反模式(10 项)
- 嵌套循环 O(N²) 处理千级数据 → 排序后 O(N log N)。
- String 拼接用 + → StringBuilder。
- List
装箱 → IntArray / SparseIntArray。 - HashMap 频繁扩容 → 预设 initialCapacity。
- 每次 new ArrayList<>(0) → reuse 或 EMPTY_LIST。
- 链表代替数组(无频繁中间插入时)→ 数组缓存友好。
- 递归代替迭代 → 栈溢出 + 函数调用开销。
- 正则全局编译 → 预编译 Pattern。
- JSON.parse 大对象 → 流式或字段抽取。
- for-in 迭代数组 → 索引迭代缓存友好。
# 14.2 并发 / 锁反模式(5 项)
- synchronized 整个方法 → 缩小锁范围。
- 共享原子变量频繁更新 → 分段聚合(LongAdder)。
- CountDownLatch 等待主线程 → 倒置依赖。
- 手写双检锁单例 → 用 lazy / static initializer。
- 多线程写同一缓存行 → false sharing → padding。
# 14.3 缓存 / 数据布局反模式(5 项)
- AOS(Array of Struct)跨字段访问 → SOA(Struct of Array)。
- 链表节点零散分配 → object pool 连续分配。
- 稀疏 HashMap → 数组 + 索引。
- 大对象按字段对齐失败 → @Aligned 注解。
- 频繁创建短生命对象 → 复用池。
# 14.4 调度 / Affinity 反模式(5 项)
- 关键线程跑小核 → 设置 affinity 大核。
- 后台任务跑大核 → 设置低优先级 + 小核。
- GC 阻塞主线程 → 减少分配 / 用值类型。
- 动画驱动线程被抢占 → 提升优先级。
- 过多线程争抢 CPU → 限制 worker 数 = 核数。
# 14.5 监控 / 度量反模式(5 项)
- 采样间隔 1 秒 → 错过短峰值。
- 只看 user time 不看 sys time → 漏 syscall 开销。
- 只看主进程不看子进程 → 漏渲染进程 / Service。
- 不分前后台 → 后台高 CPU 是耗电问题。
- 不归因机型 → 大核 / 小核混淆。
# 15.ROI 决策框架:CPU 优化的"先后顺序"
# 15.1 优化项 ROI 排序模板
以礼物墙案例为例:
| 优化项 | CPU 改善 | 开发成本 | 风险 | ROI |
|---|---|---|---|---|
| 数据结构 SOA 化 | -25% | 2 人天 | 低 | 1 |
| 关键线程绑大核 | -15% | 1 人天 | 低 | 2 |
| 重复计算缓存 | -12% | 2 人天 | 低 | 3 |
| 字符串拼接 → StringBuilder | -8% | 1 人天 | 低 | 4 |
| 算法 O(N²) → O(N log N) | -5% | 3 人天 | 中 | 5 |
| GC 减分配 | -3% | 5 人天 | 高 | 6 |
# 15.2 反向不该做的优化
- 全部代码改 SIMD(学习成本爆炸)
- 重写为 Rust(迁移风险大)
- 全部数据结构 SOA(业务代码可读性下降)
# 16.组织协同模式:CPU 优化是团队工程
# 16.1 四方角色
产品 ─── 制定性能 SLO(CPU 预算 / 帧时长)
│
▼
架构 ─── 选型(语言 / 算法 / 框架)
│
▼
研发 ─── 编码 + Lint + 自测
│
▼
测试 ─── 多机型 / 多负载验证
2
3
4
5
6
7
8
9
10
# 16.2 性能预算机制
每个新功能 PRD 标注:
| 维度 | 预算 |
|---|---|
| 主线程 CPU% | < 30% |
| 子线程 CPU% | < 50% |
| 帧时长 P99 | < 25ms |
| IPC(如可测) | > 1.0 |
| 内存增量 | < 30MB |
# 16.3 周度雷达
- TOP 5 高 CPU 页面
- IPC < 1 的代码段
- 新增 / 消失的 CPU 热点
- 各业务线 CPU 占用排名
# 17.可访问性与 CPU:被忽视的维度
# 17.1 无障碍服务的 CPU 开销
- TalkBack 触发 AccessibilityEvent,每次 CPU +5-10%。
- 大字体重测量列表项。
- 高对比度模式触发额外色彩处理。
# 17.2 国际化的 CPU 隐藏成本
- CJK 字体 fallback 链长,文本测量慢。
- RTL 切换全量 reflow。
- 复杂 emoji 分词慢。
# 17.3 老旧机型兼容
- ARMv7(32 位)单核性能弱 50%。
- 4 核 vs 8 核 → 关键线程争抢更激烈。
- 应对:低端机分级降级。
# 18.嵌入式与异构平台特化
# 18.1 车机 / HMI
- 多核但单核弱(A55 系列)
- 散热差,温度敏感
- 多屏共享 CPU
对策:关键路径绑大核 + 散热降级 + 多屏共合成器
# 18.2 IoT / MCU
- 主频 200MHz,无 OS 调度
- 无 cache 或极小 cache(4-16KB)
- 无 SIMD
对策:手写紧凑循环 + 静态分配 + 算法极简
# 18.3 桌面 / Electron
- 多 Renderer 进程争抢 CPU
- V8 GC 阻塞
- IPC 序列化代价高
对策:限制 Renderer 数 + 主动 GC + MessagePort 异步
# 19.自检清单
# 19.1 设计阶段(10 项)
- □ 算法复杂度评估完成?
- □ 数据结构选型考虑缓存友好?
- □ 多线程方案 Amdahl 分析?
- □ 关键线程的核绑定策略?
- □ 关键路径 CPU 预算?
- □ 高负载降级方案?
- □ 大数据量下的内存与 CPU 模型?
- □ 监控覆盖(CPU% / IPC / load)?
- □ 兜底(高温降频)?
- □ 多机型分级?
# 19.2 编码阶段(10 项)
- □ 避免 String + 拼接?
- □ 集合类按场景选?
- □ 锁范围最小化?
- □ 避免主线程 GC 触发?
- □ 数据布局 SOA / AOS 选择?
- □ 缓存复用 / 对象池?
- □ 监控埋点不影响 CPU?
- □ 异步/协程切换明确?
- □ Lint 覆盖反模式?
- □ 单测覆盖热点函数?
# 19.3 测试阶段(10 项)
- □ 低端机跑过基准测试?
- □ 多机型 CPU% / FPS 对比?
- □ 高温场景验证?
- □ 满负载下不卡?
- □ 多任务争抢正常?
- □ Monkey 测试 CPU 不爆?
- □ 弱网弱机组合验证?
- □ 大数据量场景过测试?
- □ 火焰图无明显平顶?
- □ off-CPU 时间合理?
# 19.4 上线阶段(10 项)
- □ 灰度 1/5/25/100% 四阶?
- □ CPU% / 帧时长 / IPC SLO 告警?
- □ 设备维度看板覆盖 95%?
- □ 与上版无劣化 > 5%?
- □ 灰度期专人值班?
- □ 业务指标联动?
- □ 客服反馈通道?
- □ 回滚预案?
- □ A/B 实验样本足?
- □ 灰度结论文档归档?
# 20.哲学迁移:CPU 思维的普适性
# 20.1 CPU 三因素 vs 一切性能问题
- 指令数(做多少事)≈ 网络包数 ≈ 数据库行数 ≈ 渲染层数
- IPC(每步效率)≈ 网络带宽 ≈ DB QPS ≈ GPU 像素吞吐
- 频率(速度)≈ 网络带宽 ≈ 磁盘 IOPS ≈ 显示刷新率
CPU 的"三变量法"是普适分析框架——拿到任何性能问题都可以套用。
# 20.2 缓存 vs 内存层级 vs 系统层级
CPU 缓存层级 L1/L2/L3/DRAM,与:
- 网络层级(CDN / 边缘 / 源站)
- 数据库层级(内存表 / 磁盘 / 远程)
- 系统层级(页缓存 / 块缓存 / 网络缓存)
完全同构——距离决定延迟,分级决定吞吐。
# 20.3 调度 vs 资源分配
CPU 调度(CFS / O(1) / EAS)与:
- 任务队列调度
- 网络 QoS
- 数据库连接池
- 微服务负载均衡
都是"在受限资源下分配工作"问题。学好 CPU 调度,所有调度问题都能借鉴。
# 20.4 元启示
CPU 是计算机的"心脏",所有性能问题终究归结为"CPU 的某种使用方式"。学好 CPU,等于学会了一切。
# 21.一句话哲学
CPU 不是"忙不忙"的问题,而是"忙得有效不有效"的问题。 把"指令数 / IPC / 频率"三个变量同时看清楚,CPU 优化才能真正可量化、可证伪、可持续。 礼物墙案例就是这条原则的最佳证明:经验主义 3 周失败 → 方法论 3 天解决(FPS 12→56、IPC 0.7→1.6、CPU% 95→62)。
CPU 是计算机的心脏,性能是它的脉搏。 学会"指令 + IPC + 频率"的三维诊断,你拿到的不只是 CPU 优化的钥匙,而是一切性能工程的通用心法。