CPU热点分析:调用链是怎么重建的

CPU热点分析:调用链是怎么重建的

Perfetto trace采集好之后,数据管道里流的是一堆原始的cpu_slice和调用栈帧。怎么从这些数据里提取出有意义的CPU热点和完整调用链?这就是本文要拆解的问题。

CPU热点分析的目标很明确:告诉开发者"CPU时间花在哪了"。但这个"在哪"有三个层次:一是哪个进程/线程占CPU最多,二是哪个函数被采样到的次数最多,三是这个函数的完整调用链是什么。第三层最难,也是最有价值的。

从Perfetto的原始数据说起

Perfetto采集CPU数据靠的是linux.perf数据源。它的工作方式是定时中断目标进程,记录当前PC指针和调用栈。这些数据在trace里对应三张表:

  • perf_sample:每次采样的记录,包含callsite_idutid
  • stack_profile_callsite:调用栈节点,每个节点有idframe_idparent_id
  • stack_profile_frame:具体的函数帧信息,name字段就是函数名

三张表的关系是一条链:perf_sample.callsite_idstack_profile_callsite.idstack_profile_callsite.frame_idstack_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,而是先一次性把所有callsiteframe的映射关系加载到内存:

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数组是从叶子到根的完整调用路径。

两个防御性设计:

  1. visited集合防止循环引用:理论上调用栈是树结构不应该有环,但Perfetto的trace数据并不总是干净的------尤其是在动态加载(dex2oat、JNI)的场景下,调用栈可能出现异常。不检查的话,死循环会把分析卡死。

  2. 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_mefolio_wait_bit_commonepoll_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的地方:

  1. dur < 0的处理thread_state表里dur = -1表示"当前状态仍在持续"(即trace结束时线程仍处于该状态)。这时候要把区间的结束时间当作slice_end

  2. 区间重叠计算MIN(实际结束, slice结束) - MAX(实际开始, slice开始)是标准的区间交集公式。这比__intrinsic_thread_state的查询复杂得多,也更容易出错。

  3. 为什么用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原始数据到最终报告,经过了四个层次的加工:

  1. 宏观使用率sched表):进程/线程级CPU占比,不依赖target_process
  2. 微观热点函数perf_sample表):采样频率最高的函数 + 完整调用链重建
  3. 调度阻塞分析sched_blocked_reason表):线程为什么被阻塞,是等锁还是等IO
  4. 线程状态分析__intrinsic_thread_state/thread_state表):切片内的状态分布,区分"代码慢"和"被挂起"
  5. 系统级指标linux.sys_stats):CPU频率、空闲时间、fork率

每一层都有自己的一套SQL查询和数据结构设计,也都有自己的降级策略------perf_sample没有就用sched__intrinsic_thread_state没有就fallback到thread_state。这些降级不是锦上添花,是在真实设备碎片化环境下的生存必需。


本文基于 SmartInspector 项目的真实开发经验撰写,所有代码片段均来自项目源码。

相关推荐
未来龙皇小蓝4 小时前
SpringBoot API日志系统设计-02:线程池异步化与RabbitMQ解耦
数据库·spring boot·后端·性能优化·rabbitmq·java-rabbitmq
kyriewen1118 小时前
你等的Babel编译,够喝三杯咖啡了——用Rust重写的SWC,只需眨个眼
开发语言·前端·javascript·后端·性能优化·rust·前端框架
vivo互联网技术1 天前
下一代图片格式 AVIF 在 vivo 社区的落地实践
前端·性能优化·图片压缩·avif
小四的小六1 天前
WebView 性能优化实战:从首屏1.5秒到300毫秒
性能优化·webview
顾昂_2 天前
Web 性能优化完全指南
前端·面试·性能优化
空中海2 天前
03 渲染机制、性能优化与现代 React
javascript·react.js·性能优化
我是唐青枫2 天前
内存为什么越来越高?C#.NET GC 详解:分代回收、LOH、终结器与性能优化实战
性能优化·c#·.net
全球通史2 天前
从零复现:YOLO缺陷检测模型 TensorRT 全量化部署到 Jetson Orin Nano Super(FP32/FP16/INT8 三路对比)
yolo·性能优化