CPU热点分析:调用链是怎么重建的
Perfetto trace采集好之后,数据管道里流的是一堆原始的cpu_slice和调用栈帧。怎么从这些数据里提取出有意义的CPU热点和完整调用链?这就是本文要拆解的问题。
CPU热点分析的目标很明确:告诉开发者"CPU时间花在哪了"。但这个"在哪"有三个层次:一是哪个进程/线程占CPU最多,二是哪个函数被采样到的次数最多,三是这个函数的完整调用链是什么。第三层最难,也是最有价值的。
从Perfetto的原始数据说起
Perfetto采集CPU数据靠的是linux.perf数据源。它的工作方式是定时中断目标进程,记录当前PC指针和调用栈。这些数据在trace里对应三张表:
perf_sample:每次采样的记录,包含callsite_id和utidstack_profile_callsite:调用栈节点,每个节点有id、frame_id和parent_idstack_profile_frame:具体的函数帧信息,name字段就是函数名
三张表的关系是一条链:perf_sample.callsite_id → stack_profile_callsite.id → stack_profile_callsite.frame_id → stack_profile_frame.name,然后通过parent_id向上回溯。
SmartInspector的CpuMixin里有两条独立的采集路径,分别对应"宏观CPU使用率"和"微观CPU热点函数"。
宏观CPU使用率:从sched表算出来的
先看一个容易混淆的设计决策:SmartInspector的collect_cpu_usage()不是从perf_sample表算的,而是从sched表。
src/smartinspector/collector/cpu.py:
python
def collect_cpu_usage(self) -> dict:
tp = self._open()
# 获取trace的时间边界
bounds = tp.query("SELECT start_ts, end_ts FROM trace_bounds")
# 检测CPU核心数
cpu_rows = tp.query("SELECT COUNT(DISTINCT cpu) AS num_cpus FROM sched")
# 从sched表统计每个线程的CPU时间
rows = tp.query("""
SELECT
process.name AS process_name,
process.pid,
thread.name AS thread_name,
thread.tid,
COUNT(*) AS switches,
SUM(sched.dur) AS total_dur_ns
FROM sched
JOIN thread ON sched.utid = thread.utid
JOIN process ON thread.upid = process.upid
GROUP BY process.name, process.pid, thread.name, thread.tid
ORDER BY total_dur_ns DESC
LIMIT 20
""")
这里用sched表而不是perf_sample表是有原因的。perf_sample只有指定了target_process才会采集(linux.perf数据源需要配置target_cmdline),而sched表是所有trace都有的------它是Linux内核的调度事件,不需要任何额外配置。
这意味着即使没有指定目标进程(比如第一次用SmartInspector,只是想快速看一下设备状态),collect_cpu_usage()也能给出每个进程的CPU占比。数据可用性优先于数据精度,这是SmartInspector在降级设计上的一贯思路。
CPU百分比的计算陷阱
算CPU百分比时有个坑:分母是trace_dur_ns * num_cpus,不是trace_dur_ns。
python
total_wall_ns = trace_dur_ns * num_cpus
cpu_pct = round(dur_ns / total_wall_ns * 100, 1)
sched.dur是一个线程在一次调度中的运行时间(纳秒)。在8核机器上,trace时长10秒,总墙钟时间 = 10s × 8 = 80s。一个线程如果用了5s的CPU时间,那它的CPU占用率是5/80 = 6.25%,不是5/10 = 50%。
不乘核心数会怎样?在4核设备上,四个线程各占100%的单核时间,实际CPU使用率应该是100%(满载),但不乘核心数的话会显示400%------这显然是错的。最初SmartInspector就犯过这个错误,后来在trace_dur_ns * num_cpus这个修复合入后才修正。
核心数的动态检测
python
cpu_rows = tp.query("SELECT COUNT(DISTINCT cpu) AS num_cpus FROM sched")
不硬编码核心数,而是从sched表里数DISTINCT cpu。这在热插拔核心的设备上特别重要------某些小核在低负载时会被关闭,如果硬编码8核但实际只有4核在工作,算出来的百分比会偏低。
微观CPU热点:perf_sample和调用链重建
宏观CPU使用率回答"哪个进程吃了CPU",微观热点回答"这个进程的CPU时间花在了哪个函数上"。后者才是开发者真正关心的。
第一步:找热点函数
collect_cpu_hotspots()的第一条SQL:
python
rows = tp.query("""
SELECT
spf.name AS function_name,
t.name AS thread_name,
ps.callsite_id,
COUNT(*) AS sample_count,
SUM(COUNT(*)) OVER () AS total_samples
FROM perf_sample ps
JOIN stack_profile_callsite spc ON ps.callsite_id = spc.id
JOIN stack_profile_frame spf ON spc.frame_id = spf.id
JOIN thread t ON ps.utid = t.utid
WHERE ps.callsite_id IS NOT NULL
GROUP BY spf.name, t.name, ps.callsite_id
ORDER BY sample_count DESC
LIMIT 20
""")
这里有个微妙之处:GROUP BY spf.name, t.name, ps.callsite_id。为什么不只是GROUP BY spf.name?
因为同一个函数可能在不同的调用路径下出现。比如HashMap.get()可能在onCreate()的初始化链里被调用100次,也可能在onDraw()的渲染链里被调用50次。如果只按函数名分组,你只知道HashMap.get()被采样到150次,但不知道这150次分别来自哪里。
按callsite_id分组就能区分不同的调用栈位置。每个callsite_id代表调用栈里的一个唯一节点,即使函数名相同,callsite_id不同就意味着调用路径不同。
SUM(COUNT(*)) OVER ()是窗口函数,计算总采样数,用于算每个热点的百分比:
python
pct = round(r.sample_count / r.total_samples * 100, 1)
第二步:预加载调用栈映射表
找到热点函数后,需要重建每个函数的完整调用链。SmartInspector没有每次都查SQL,而是先一次性把所有callsite和frame的映射关系加载到内存:
python
callsite_map: dict[int, tuple[str, int | None]] = {}
cs_rows = tp.query("""
SELECT spc.id, spf.name, spc.parent_id
FROM stack_profile_callsite spc
JOIN stack_profile_frame spf ON spc.frame_id = spf.id
""")
for r in cs_rows:
callsite_map[r.id] = (r.name, r.parent_id)
这个设计决策值得展开说。
为什么不直接在循环里查SQL? 因为每次热点函数需要向上回溯最多15层调用栈,20个热点就是300次SQL查询。TraceProcessor是通过stdin/stdout与shell进程通信的,每次查询有毫秒级的通信开销,300次查询可能要花几秒。
预加载的代价是什么?stack_profile_callsite表可能有几万行。但在实际测试中,即使是一个10秒的trace,调用栈表也不会超过10万行,Python dict完全装得下。加载一次后,所有回溯都是内存操作,纳秒级完成。
这是一个典型的空间换时间的trade-off:用几MB的内存换来数量级的查询速度提升。在分析工具的场景下,这个选择是正确的------用户可以等trace采集10秒,但不愿意等分析结果出来10秒。
第三步:链式回溯重建调用链
python
callchain = []
callsite_id = r.callsite_id
visited = set()
max_depth = 15
for _ in range(max_depth):
if callsite_id is None or callsite_id in visited:
break
visited.add(callsite_id)
entry = callsite_map.get(callsite_id)
if entry is None:
break
callchain.append(entry[0]) # frame name
callsite_id = entry[1] # parent_id
这段代码做的是从叶子节点(被采样的函数)沿着parent_id向根节点回溯,最终得到的callchain数组是从叶子到根的完整调用路径。
两个防御性设计:
-
visited集合防止循环引用:理论上调用栈是树结构不应该有环,但Perfetto的trace数据并不总是干净的------尤其是在动态加载(dex2oat、JNI)的场景下,调用栈可能出现异常。不检查的话,死循环会把分析卡死。 -
max_depth = 15:限制回溯深度。Java调用栈很少超过15层(如果超过了,通常说明设计上有问题),这个限制在防止异常数据的同时也控制了输出大小。
最终输出的数据结构:
python
{
"function": "HashMap.get",
"thread": "main",
"samples": 150,
"pct": 12.3,
"callchain": ["HashMap.get", "MyAdapter.onBindViewHolder", "RecyclerView.onBind", ...]
}
调度阻塞分析:sched_blocked_reason表
CPU热点告诉你"谁在跑",但性能问题往往不是"谁在跑"导致的,而是"谁在等"。一个线程的CPU占用率高,不代表它很忙------它可能只是不断被唤醒又立刻阻塞,在futex_wait上空转。
SmartInspector的SchedMixin专门查了sched_blocked_reason表:
python
# src/smartinspector/collector/sched.py
br_rows = tp.query("""
SELECT
t.name AS comm,
br.blocked_reason,
br.io_wait,
COUNT(*) AS occurrences
FROM sched_blocked_reason br
JOIN thread t ON br.utid = t.utid
GROUP BY t.name, br.blocked_reason, br.io_wait
ORDER BY occurrences DESC
LIMIT 10
""")
sched_blocked_reason是Linux内核4.12+才有的ftrace事件(sched/sched_blocked_reason),它在线程被阻塞时记录阻塞原因。blocked_reason字段的值是内核里的符号名,比如futex_wait_queue_me、folio_wait_bit_common、epoll_wait等。
SmartInspector的确定性分析层(agents/deterministic.py)做了符号名到人类可读描述的映射:
python
BLOCKED_FN_MEANING: dict[str, str] = {
"futex_wait_queue_me": "等待锁释放 (futex)",
"futex_wait": "等待锁释放 (futex)",
"folio_wait_bit_common": "等待磁盘IO (页缓存)",
"wait_woken": "等待被唤醒",
"msleep": "内核主动睡眠",
"rpmh_write_batch": "等待硬件资源电源管理",
"sde_encoder_helper_wait_for_irq": "等待显示硬件中断",
"spi_geni_transfer_one": "等待SPI总线传输 (通常为触控IC)",
"do_writepages": "等待磁盘写入",
"journal_commit": "等待文件系统日志提交",
"bio_wait": "等待块IO完成",
"binder_thread_read": "等待Binder IPC回复",
}
这些映射不是随便写的------每一个都是从实际分析中积累的。sde_encoder_helper_wait_for_irq是高通显示驱动的中断等待,spi_geni_transfer_one是高通SPI总线的传输等待,常见于触控IC通信。这些内核符号名对普通开发者来说毫无意义,但映射后就能知道"主线程在等显示硬件"或"在等触控IC回复"。
io_wait字段:区分锁等待和IO等待
sched_blocked_reason表的io_wait字段是个布尔值,标记这次阻塞是否涉及IO操作。SmartInspector直接透传这个字段,让下游分析层判断:
python
blocked_reasons.append({
"comm": r.comm,
"reason": r.blocked_reason,
"io_wait": bool(r.io_wait),
"occurrences": r.occurrences,
})
区分"等锁"和"等IO"的意义在于优化方向完全不同:锁等待要查锁竞争(synchronized、ReentrantLock的粒度),IO等待要查磁盘/网络(是否在主线程做IO操作)。
线程状态分析:__intrinsic_thread_state的进与退
光知道一个线程被阻塞了还不够------对于一个持续200ms的SI$block切片,你需要知道这200ms里线程到底在干什么:是一直在Sleeping,还是大部分时间在Running但偶尔被抢占?
SmartInspector的ThreadMixin做了这件事------对每个SI$切片,查询该切片时间范围内的线程状态分布:
python
# src/smartinspector/collector/thread.py
state_rows = tp.query(f"""
SELECT
state,
SUM(dur) AS total_ns,
blocked_function,
io_wait,
waker_utid
FROM __intrinsic_thread_state
WHERE utid = {main_utid}
AND ts < {slice_end}
AND ts + dur > {slice_ts}
GROUP BY state, blocked_function, io_wait, waker_utid
ORDER BY total_ns DESC
""")
__intrinsic_thread_state是Perfetto 30+版本引入的内部表,它比thread_state表更精确------thread_state记录的是状态切换事件(从一个状态到另一个状态),而__intrinsic_thread_state直接记录每个状态的时间区间,SQL查询时不需要手动计算区间重叠。
但这个表不是所有Perfetto版本都有,所以SmartInspector做了fallback:
python
has_intrinsic_ts = self._check_intrinsic_thread_state(tp)
if not has_intrinsic_ts:
return self._collect_thread_state_fallback(tp, main_utid, slice_rows)
Fallback用的是传统的thread_state表,SQL里需要手动处理区间重叠:
python
state_rows = tp.query(f"""
SELECT
state,
SUM(
MIN(
CASE WHEN dur < 0 THEN {slice_end} ELSE ts + dur END,
{slice_end}
) -
MAX(ts, {slice_ts})
) AS state_dur_ns
FROM thread_state
WHERE utid = {main_utid}
AND ts < {slice_end}
AND (dur < 0 OR ts + dur > {slice_ts})
GROUP BY state
""")
这里有几个tricky的地方:
-
dur < 0的处理 :thread_state表里dur = -1表示"当前状态仍在持续"(即trace结束时线程仍处于该状态)。这时候要把区间的结束时间当作slice_end。 -
区间重叠计算 :
MIN(实际结束, slice结束) - MAX(实际开始, slice开始)是标准的区间交集公式。这比__intrinsic_thread_state的查询复杂得多,也更容易出错。 -
为什么用
thread_state而不是__intrinsic_thread_state:兼容性。Perfetto的版本迭代很快,不是所有用户都能用最新版本。thread_state是稳定接口,虽然查询复杂但兼容性好。
状态分布到结论的转化
拿到每个状态的时间占比后,确定性分析层做了分类:
python
# agents/deterministic.py
for ts in thread_states:
dominant = ts.get("dominant_state", "unknown")
if dominant in ("Sleeping", "DiskSleep"):
blocked_slices.append((name, dominant, dur, dist, ts))
elif dominant == "Running" and dur > 5:
running_slices.append((name, dur, dist, ts))
分类逻辑很简单:
- Sleeping/DiskSleep主导 → 这段慢不是因为代码写得差,而是线程被挂起了(等锁、等IO、等硬件)
- Running主导且超过5ms → 线程确实在执行代码,但代码本身慢
这个区分直接决定了优化方向。Running主导的问题要去看算法复杂度和数据结构,Sleeping主导的问题要去看阻塞源(主线程IO、锁粒度、Binder调用)。
系统级CPU指标:sys_stats数据源
除了进程/线程级别的分析,SmartInspector还采集了系统级的CPU指标------CPU空闲时间、各核心频率、fork率。这些数据来自Perfetto的linux.sys_stats数据源,对应采集配置:
python
# perfetto.py 中的采集配置
"data_sources: { config { name: \"linux.sys_stats\" sys_stats_config {"
" stat_period_ms: 1000"
" stat_counters: STAT_CPU_TIMES"
" stat_counters: STAT_FORK_COUNT"
" cpufreq_period_ms: 1000"
"} } }"
采集间隔1秒,STAT_CPU_TIMES记录各CPU核心的user/system/idle时间分布,STAT_FORK_COUNT记录进程创建速率。
SysMixin的查询把这些counter数据提取出来:
python
# src/smartinspector/collector/sys.py
# CPU空闲时间
cpu_rows = tp.query("""
SELECT c.ts, c.value AS cpu_util
FROM counter c
JOIN cpu_counter_track cct ON c.track_id = cct.id
WHERE cct.name = 'cpuidle_time'
ORDER BY c.ts ASC
""")
# CPU频率(按核心分组)
freq_rows = tp.query("""
SELECT
cct.cpu,
c.ts,
c.value AS freq_khz
FROM counter c
JOIN cpu_counter_track cct ON c.track_id = cct.id
WHERE cct.name = 'cpufreq'
ORDER BY cct.cpu, c.ts ASC
""")
# Fork率
fork_rows = tp.query("""
SELECT c.ts, c.value AS fork_count
FROM counter c
JOIN cpu_counter_track cct ON c.track_id = cct.id
WHERE cct.name = 'num_forks'
ORDER BY c.ts ASC
""")
CPU频率数据按核心分组(ORDER BY cct.cpu, c.ts),在多核异构的ARM处理器上很有用------你可以看到大核(比如2.84GHz的Gold核心)和小核(比如1.8GHz的Silver核心)各自的频率变化。如果发现大核一直没被调度到,可能说明线程优先级不够高,系统把任务都丢给了小核。
Fork率是个容易被忽略的指标。num_forks飙升意味着短时间内大量进程/线程被创建,这在Android上常见于:WebView初始化(chromium会fork一堆进程)、多进程应用、频繁的AsyncTask/线程池创建。如果fork率和卡顿帧时间吻合,基本可以锁定是进程创建导致的系统抖动。
数据流:从采集到分析
梳理一下CPU相关数据的完整流向:
scss
PerfettoCollector.summarize()
├── CpuMixin.collect_cpu_hotspots() → summary.cpu_hotspots
├── CpuMixin.collect_cpu_usage() → summary.cpu_usage
├── SchedMixin.collect_sched() → summary.scheduling
│ └── blocked_reasons
├── ThreadMixin.collect_thread_state() → summary.thread_state
├── SysMixin.collect_sys_stats() → summary.sys_stats
└── PerfSummary.to_json() → perf_summary (JSON string)
↓
determinsitic.compute_hints(perf_json)
├── _identify_cpu_hotspots() → [CPU热点] 摘要
├── _analyze_thread_state() → [线程状态分析] 摘要
└── 其他分析模块
↓
perf_analyzer.analyze_perf()
└── LLM + hints → 最终分析报告
确定性预计算层(deterministic.py)在LLM之前就把所有数值计算做完了:CPU百分比、状态分布、阻塞原因翻译。LLM只负责"组织语言"------把预计算结论转化为可读的报告。
这个分层设计的核心考量是成本和可靠性。数值计算用代码做,保证准确;文字组织用LLM做,保证可读。让LLM算百分比(比如"12.3%的CPU时间花在HashMap.get"),它经常算错;但让它写"主线程的CPU时间集中在HashMap查找操作上,建议检查Adapter的数据结构选择",这是它擅长的。
小结
CPU热点分析从Perfetto原始数据到最终报告,经过了四个层次的加工:
- 宏观使用率 (
sched表):进程/线程级CPU占比,不依赖target_process - 微观热点函数 (
perf_sample表):采样频率最高的函数 + 完整调用链重建 - 调度阻塞分析 (
sched_blocked_reason表):线程为什么被阻塞,是等锁还是等IO - 线程状态分析 (
__intrinsic_thread_state/thread_state表):切片内的状态分布,区分"代码慢"和"被挂起" - 系统级指标 (
linux.sys_stats):CPU频率、空闲时间、fork率
每一层都有自己的一套SQL查询和数据结构设计,也都有自己的降级策略------perf_sample没有就用sched,__intrinsic_thread_state没有就fallback到thread_state。这些降级不是锦上添花,是在真实设备碎片化环境下的生存必需。
本文基于 SmartInspector 项目的真实开发经验撰写,所有代码片段均来自项目源码。