EN

Android Perfetto 系列 11:把 UI 判断写成可复跑的 SQL

Word count: 7.7kReading time: 34 min
2026/05/04
loading

拿到一份卡顿 Trace,很多人的第一反应是打开 Perfetto UI,框一段时间,看主线程、RenderThread、CPU 状态,再截几张图写结论。这个办法能帮你处理眼前问题,但很难回答后面的追问:换一份 Trace 后是不是同样结论?换一台设备后是不是同样结论?下个版本修完后,怎么确认问题没有回来?

第 11 篇讲 PerfettoSQL 与 Trace Processor。目标很朴素:把 UI 里的判断改成能复查的查询,再用 Python 批量跑多份 Trace。读者不需要先变成 SQL 专家,只要把 Trace 当成一组带时间戳的表,再把“看图得出的判断”翻译成一段能反复执行的查询。

文章会带你从一份卡顿 Trace 走完整条 SQL 化的路径:先认识 Trace Processor,把 Trace 当成 thread、process、slice 几张表;再用一段完整查询示范怎么把窗口固定下来、不只靠 main 找主线程、不把 Runnable 当 Running,并把它放到命令行里跑;接着用 Python API 批量跑多份 Trace,把临时查询沉淀成稳定指标和 Trace Summary,回答”修完后问题有没有回来”这类回归问题;最后列几个容易踩的坑。

从截图结论到可复查查询

03 讲过 Perfetto UI 的基本操作,04 讲过大 Trace 怎么打开,09、10 讲过 CPU 与 Binder 的一些 SQL 示例。本文不重复这些内容,只补一件事:分析结果怎么从“这次看起来像”变成“下次还能按同一把尺子再量一遍”。

直接读这篇也可以,但最好先准备四样东西:一份 .perfetto-trace,目标 App 包名,UI 里能框出的目标时间窗口,以及本机可运行的 trace_processor。后面的 SQL 都围绕这四个输入展开。

从手工判断到自动化报告,可以拆成这几步:

1
2
3
4
5
Perfetto UI 建立判断
-> PerfettoSQL 把判断写成查询
-> Trace Processor 在命令行执行查询
-> Python 批量处理多份 Trace
-> Trace Summary 或自定义报告输出稳定结果

工程上常见的使用方式有三类:

  • 临时排查:UI 里发现某段可疑,再用 SQL 查成时间、线程、进程、持续时间和原始证据。
  • 专项分析:同一类问题要看几十份 Trace,例如滑动卡顿、点击响应、Camera 打开慢,输出同一组诊断字段。
  • 回归检测:每个版本都跑同一组查询,把 before/after 结果写进报告或看板。

UI 依然很有价值。SQL 的职责是把 UI 里的判断保存下来,方便复查和批量比较。

Trace Processor 是什么

Trace Processor 是 Perfetto 的分析引擎。它负责把 .perfetto-trace、Chrome trace、simpleperf protobuf 等文件解析成统一的 SQL 表。Perfetto UI 里的很多轨道,也是基于这些表和视图查询出来的。

系列第 04 篇用过 trace_processor_shell --httpd 打开大 Trace。官方文档现在把面向用户的命令写成 trace_processor:下载下来的文件是一个 Python 包装脚本,首次运行时会按平台拉取并缓存原生 Trace Processor 可执行文件。

先把命令装好。这段命令下载 trace_processor,并确认它能进入交互式 SQL 环境。

1
2
3
curl -LO https://get.perfetto.dev/trace_processor
chmod +x ./trace_processor
./trace_processor interactive trace.perfetto-trace

旧文档和旧脚本里经常会看到直接运行 ./trace_processor trace.perfetto-trace 的写法。当前官方文档仍然支持这种默认交互模式,也推荐用子命令把用途写清楚。

命令行批处理时,重点看 query。它不会打开 UI,而是加载 Trace、执行 SQL、把结果打印出来:

1
2
./trace_processor query trace.perfetto-trace \
"SELECT ts, dur, name FROM slice LIMIT 5;"

大 Trace 仍然可以让 UI 使用本地 Trace Processor,减轻浏览器解析压力。当前子命令写法是:

1
./trace_processor server http trace.perfetto-trace

这个命令只启动本地 HTTP RPC server。浏览器里的 Perfetto UI 还需要按输出的地址和端口连接到这个本地 Trace Processor,才会把解析工作交给本机进程。

trace_processor_shell--httpd 这类旧入口还有兼容层;新脚本尽量写成子命令。团队里最怕同一个动作有两种写法,半年后没人知道哪个才是当前推荐路径。

把 Trace 当成几张表

PerfettoSQL 是 Trace Processor 提供的 SQL 方言。入门阶段不用背完整 schema,先从几类常用表开始:

类型 常见表 用途
时间片 slice 一段有开始时间和持续时间的事件,例如 ATrace、Track Event、部分导入或派生出来的时间片
计数器 countercounter_track 随时间变化的数值,例如 CPU 频率、内存、功耗、业务计数
线程与进程 threadprocess 线程名、进程名、tid、pid、utid、upid
轨道 trackthread_trackprocess_track slice/counter 等事件的归属关系;UI 可视轨道不一定和这些表一一对应
调度状态 thread_statesched thread_state 记录线程状态,sched 记录线程实际占用 CPU 的 Running 区间
CPU 采样 perf_samplecpu_profile_stack_sample CPU profiling 的采样事实表
调用栈维表 stack_profile_* callsite、frame、mapping 等栈信息,通常要和采样表一起查
完整性 stats 数据丢失、解析异常、buffer 问题等采集质量信息

这里最容易混淆的是 tid/pidutid/upid。系统里的 tid/pid 会复用,同一份 Trace 里也可能出现多个同名线程。Trace Processor 用 utid/upid 表示本次 Trace 内唯一的线程和进程实体。写脚本时优先用 utid/upid,不要只靠线程名。

时间列也要早记住:tsdur 通常按纳秒理解。临时查询里用 / 1e6 转成毫秒没问题;写进脚本后,更推荐引入 time.conversion,用 time_to_ms()time_from_ms() 把单位写在 SQL 里。counter.value 没有统一单位,必须结合 counter_track、具体模块和采集配置解释;CPU 频率、内存、power rail、业务计数不能直接按同一套 AVG 口径比较。

还有一类表要特别分清:thread_slice 这类名字来自 slices.with_context 标准库模块提供的视图,不是原始 schema 表。它提前把 slice、track、thread、process 关联好,所以才能直接查 process_namethread_nameis_main_thread。复制这类查询时,前面的 INCLUDE PERFETTO MODULE ... 不能漏。

调度状态也依赖采集配置。要分析 thread_state、Running、Runnable 和 wakeup,Trace 里至少要有 ftrace sched 相关事件,尤其是 sched/sched_switchsched/sched_wakeupsched/sched_waking。只看 Running 主要依赖 switch;要解释 wakeup 与 Runnable 的关系,还需要 wakeup/waking。缺这些事件时,只能写“证据不足”,不能写“没有调度问题”。

CPU profiling 也有类似边界。perf_samplecpu_profile_stack_sample 是采样事实,不是执行全量记录;采样频率、采样窗口、unwind、symbolization 和 lost sample 统计都会影响结论。没有采样表,只能写“未采集 CPU profile”,不能写“没有 CPU 热点”。

一次完整分析怎么写成 SQL

用一个常见场景串起来:UI 里发现某个目标窗口内主线程有明显停顿,现在要把这次判断写成可复查的查询。这个窗口可以来自 UI 选区、Track Event 标记、输入事件,或者某个 frame/CUJ 的起止时间。

采集质量先决定结论语气

写任何结论前,先看采集质量。stats 里既有普通计数,也有错误和数据丢失。做自动化门禁时,不要把所有非零项都当成失败;先把会直接限制结论的项列出来,再按数据源决定是复抓、降级,还是只影响某一类指标。

官方统计项表里的类型会写成 kErrorkDataLosskInfo,但 stats.severity 的查询值是小写字符串。这条查询保留原始 name/idx/severity/source/value,同时给出影响范围提示;不要在通用入口里把所有 errordata_loss 都写成整份 Trace fatal。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
SELECT
name,
idx,
severity,
source,
value,
CASE
WHEN severity = 'data_loss' AND LOWER(name) LIKE '%ftrace%' THEN 'sched_or_ftrace_degraded'
WHEN LOWER(name) LIKE '%frame_timeline%' THEN 'frame_timeline_degraded'
WHEN LOWER(name) LIKE '%track_event%' THEN 'track_event_degraded'
WHEN severity IN ('error', 'data_loss') THEN 'metric_dependency_check_required'
ELSE 'configuration_or_parser_context'
END AS metric_impact_hint
FROM stats
WHERE value != 0
AND (
severity IN ('error', 'data_loss')
OR LOWER(name) LIKE '%loss%'
OR LOWER(name) LIKE '%drop%'
OR LOWER(name) LIKE '%parse%'
)
ORDER BY severity, source, name, idx;

如果结果里出现 data_loss、parser error、buffer overrun、packet loss 相关内容,后面的判断都要降低语气。ftrace loss 会直接影响 sched/thread_state/wakeup 判断,但不一定让 App Track Event 全部不可用;FrameTimeline 缺失会影响帧指标,但不一定影响 Binder 或内存查询。报告里要保留 stats 明细,再由每个指标决定复抓、剔除或降级。

需要做采集配置优化时,再放宽到 value != 0 查看完整 stats,因为 buffer 大小、写入量、ftrace 计数这类 info 项也能解释为什么丢。第 12 篇会专门讲完整的数据丢失分类;这一篇先把它放进自动化入口。

如果要做帧指标,还要额外检查 FrameTimeline 数据是否存在。长 slice 不是慢帧口径,FrameTimeline 的 actual/expected frame、jank type、overrun 才能回答帧层面的回归问题:

1
2
3
4
5
SELECT 'actual' AS table_name, COUNT(*) AS row_count
FROM actual_frame_timeline_slice
UNION ALL
SELECT 'expected' AS table_name, COUNT(*) AS row_count
FROM expected_frame_timeline_slice;

这条查询返回 0 不一定是 Trace 坏了,可能是 Android 版本、采集配置或场景不支持。报告里要把它写成“帧指标不可用”,不要退回到用 RenderThread 长 slice 代替慢帧结论。

先把目标窗口固定下来

目标窗口不要停留在“UI 里框了一段”。脚本需要明确的 start_ts/end_ts,后面的长切片、调度状态、帧指标都要引用同一个窗口。最稳的来源是 App 自己打的 Trace.beginSection() 或 Track Event 标记:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
INCLUDE PERFETTO MODULE slices.with_context;
INCLUDE PERFETTO MODULE time.conversion;

SELECT
'app_marker' AS window_source,
name AS window_name,
ts AS start_ts,
ts + dur AS end_ts,
time_to_ms(ts) AS start_ms,
time_to_ms(ts + dur) AS end_ms,
time_to_ms(dur) AS duration_ms,
process_name,
thread_name
FROM thread_slice
WHERE process_name = 'com.example.app'
AND name = 'feed_scroll'
AND dur > 0
ORDER BY ts
LIMIT 10;

如果窗口来自某个 frame、CUJ、输入事件或 UI 选区,也要先把它落成同样的字段。比如先从 FrameTimeline 里挑出目标帧,再把 tsts + dur 作为窗口。查询返回 0 行时,报告应该写 metric_status=unavailabledegrade_reason=missing_frame_timeline,而不是退回到 RenderThread 长 slice 当帧指标:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
INCLUDE PERFETTO MODULE time.conversion;

SELECT
'frame_timeline' AS window_source,
name AS window_name,
ts AS start_ts,
ts + dur AS end_ts,
time_to_ms(ts) AS start_ms,
time_to_ms(ts + dur) AS end_ms,
time_to_ms(dur) AS duration_ms
FROM actual_frame_timeline_slice
WHERE dur > 0
ORDER BY dur DESC
LIMIT 10;

后面的示例为了缩短篇幅,仍然用 target_window CTE 写死一个窗口。实际脚本里应从 metadata 或前置查询传入 window_source/window_name/start_ts/end_ts,并把这些字段写进报告。

不要只靠 main 找线程

很多示例喜欢直接写 thread.name = 'main'。这在单个 App 里看起来方便,放到系统 Trace 里就容易误伤,因为每个 App 都可能有一个 main。先把目标进程里的线程列出来,目的是确认 upid/utid

1
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
27
28
29
INCLUDE PERFETTO MODULE time.conversion;

WITH candidate_processes AS (
SELECT
p.name AS process_name,
p.upid,
p.pid,
COUNT(th.utid) AS thread_count,
MAX(th.is_main_thread) AS has_main_thread,
time_to_ms(MIN(th.start_ts)) AS first_thread_start_ms
FROM process p
JOIN thread th USING (upid)
WHERE p.name GLOB 'com.example.app*'
GROUP BY p.name, p.upid, p.pid
)
SELECT
cp.process_name,
cp.upid,
cp.pid,
cp.thread_count,
cp.has_main_thread,
cp.first_thread_start_ms,
th.name AS thread_name,
th.utid,
th.tid,
th.is_main_thread
FROM thread th
JOIN candidate_processes cp USING (upid)
ORDER BY cp.process_name, th.is_main_thread DESC, th.name;

com.example.app 换成自己的包名前缀。多进程 App 可能有 :remote:push、WebView sandbox、isolated process;UI 渲染问题优先选承载 Activity、FrameTimeline 或 App marker 的进程,跨进程业务路径就保留多个 upid,报告里不要合并成一个“App 总耗时”。

长切片只能列可疑点

PerfettoSQL 标准库已经提供了预先关联好的视图,能少写很多容易错的 JOIN。这段 SQL 引入 slices.with_contexttime.conversion,查询目标窗口内超过 5ms 的线程切片。

target_window 里的时间是 Trace 时间戳,单位仍然是纳秒。实际使用时,把它替换成 UI 选区、Track Event 或输入事件算出来的起止时间。

1
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
27
28
29
30
31
32
33
34
35
36
INCLUDE PERFETTO MODULE slices.with_context;
INCLUDE PERFETTO MODULE time.conversion;

WITH target_window(start_ts, end_ts) AS (
VALUES (123000000000, 123500000000)
),
target_slices AS (
SELECT
s.ts,
s.dur,
s.upid,
s.utid,
s.name,
s.thread_name,
s.process_name,
MAX(0, MIN(s.ts + s.dur, w.end_ts) - MAX(s.ts, w.start_ts)) AS overlap_dur
FROM thread_slice s
JOIN target_window w
WHERE s.process_name = 'com.example.app'
AND (s.is_main_thread = 1 OR s.thread_name = 'RenderThread')
AND s.dur > 0
AND s.ts < w.end_ts
AND s.ts + s.dur > w.start_ts
)
SELECT
upid,
utid,
time_to_ms(ts) AS ts_ms,
time_to_ms(overlap_dur) AS overlap_ms,
time_to_ms(dur) AS original_dur_ms,
name,
thread_name,
process_name
FROM target_slices
WHERE overlap_dur > time_from_ms(5)
ORDER BY overlap_ms DESC;

这条查询对应 UI 里的“找主线程长任务”。它不能直接给根因,也不能直接替代慢帧指标;它只是把目标窗口里的可疑点列出来:哪个线程、哪个切片、在窗口内占了多久、原始切片有多长。

Runnable 不是 Running

一个长切片可能是代码执行了很久,也可能是切片跨过了等待时间。要继续看调度状态。thread_state.state = 'Running' 对应实际占用 CPU 的区间;RR+ 这类状态表示线程可运行但没有在 CPU 上跑,其中 R+ 通常代表被抢占后仍处于 runnable。

做调度分析前先检查同一窗口内有没有 thread_state/sched 数据。目标线程匹配不到,或者 stats 里有 ftrace loss/drop/error 时,报告要输出 insufficient_sched_evidencesched_evidence_grade=weak

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
WITH target_window(start_ts, end_ts) AS (
VALUES (123000000000, 123500000000)
),
target_threads AS (
SELECT th.utid
FROM thread th
JOIN process p USING (upid)
WHERE p.name = 'com.example.app'
AND (th.is_main_thread = 1 OR th.name = 'RenderThread')
)
SELECT 'thread_state_rows' AS check_name, COUNT(*) AS row_count
FROM thread_state ts
JOIN target_threads t USING (utid)
JOIN target_window w
WHERE ts.ts < w.end_ts
AND ts.ts + ts.dur > w.start_ts
UNION ALL
SELECT 'sched_rows' AS check_name, COUNT(*) AS row_count
FROM sched s
JOIN target_threads t USING (utid)
JOIN target_window w
WHERE s.ts < w.end_ts
AND s.ts + s.dur > w.start_ts;

这段查询仍然限制在同一个目标窗口内,把主线程与 RenderThread 的 Running、Runnable、SD 拆开统计:

1
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
27
28
29
30
31
32
33
34
35
36
37
38
39
INCLUDE PERFETTO MODULE time.conversion;

WITH target_window(start_ts, end_ts) AS (
VALUES (123000000000, 123500000000)
),
target_threads AS (
SELECT th.utid, p.name AS process_name, th.name AS thread_name
FROM thread th
JOIN process p USING (upid)
WHERE p.name = 'com.example.app'
AND (th.is_main_thread = 1 OR th.name = 'RenderThread')
),
state_in_window AS (
SELECT
t.process_name,
t.thread_name,
CASE
WHEN ts.state = 'Running' THEN 'running'
WHEN ts.state GLOB 'R*' THEN 'runnable'
WHEN ts.state = 'S' THEN 'interruptible_sleep'
WHEN ts.state = 'D' THEN 'uninterruptible_blocked'
ELSE 'other_' || ts.state
END AS state_bucket,
MAX(0, MIN(ts.ts + ts.dur, w.end_ts) - MAX(ts.ts, w.start_ts)) AS overlap_dur
FROM thread_state ts
JOIN target_threads t USING (utid)
JOIN target_window w
WHERE ts.dur > 0
AND ts.ts < w.end_ts
AND ts.ts + ts.dur > w.start_ts
)
SELECT
process_name,
thread_name,
state_bucket,
time_to_ms(SUM(overlap_dur)) AS overlap_ms
FROM state_in_window
GROUP BY process_name, thread_name, state_bucket
ORDER BY thread_name, overlap_ms DESC;

S 常见于正常 sleep 或 futex 等待,D 更接近不可中断等待;需要定责时再结合 thread_state 的 blocked reason、io_wait、waker 字段和 schema 版本确认。点击响应、滑动首帧、启动首帧这类场景里,长 Runnable 只能说明 CPU 竞争或调度延迟也要一起看。它还不能单独定责:线程 running time 是线程维度,CPU busy 和 cluster 解释要另查 sched.cpu、CPU idle、CPU frequency、capacity,并限制在同一窗口内。

把查询放到命令行

同一段 SQL 可以放进 queries/slow_slices.sql,再用 Trace Processor 执行:

1
2
3
./trace_processor query \
-f queries/slow_slices.sql \
trace.perfetto-trace

到这一步,分析已经从截图变成了文本文件。文本文件可以评审、复用、放进 CI,也可以发给同事确认。

Python API 批量分析

一份 Trace 可以用 UI 慢慢看,多份 Trace 就该让脚本跑。脚本要完成四件事:读取 Trace,读取同名 .json 元数据文件,跑质量门禁,按同一个目标窗口输出稳定字段。重点看 TraceProcessor(trace=...)tp.query(...) 和报告列名。

1
pip install perfetto

回归平台里不要依赖“首次运行临时下载”。CI 应该固定 Python perfetto 包版本,预热或缓存 native Trace Processor,并把 trace_processor --version、TraceConfig id、SQL package version 写入 metadata。否则一次工具升级就可能让 before/after 的解析口径漂移。

1
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
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
import csv
import json
from pathlib import Path

from perfetto.trace_processor import TraceProcessor

APP_PROCESS = "com.example.app"

QUALITY_DETAIL_QUERY = """
SELECT
name,
idx,
severity,
source,
value
FROM stats
WHERE value != 0
AND (
severity IN ('error', 'data_loss')
OR LOWER(name) LIKE '%loss%'
OR LOWER(name) LIKE '%drop%'
OR LOWER(name) LIKE '%parse%'
)
ORDER BY severity, source, name, idx;
"""

FRAME_TIMELINE_QUERY = """
SELECT
(SELECT COUNT(*) FROM actual_frame_timeline_slice) AS actual_count,
(SELECT COUNT(*) FROM expected_frame_timeline_slice) AS expected_count;
"""

SLOW_SLICE_QUERY_TEMPLATE = """
INCLUDE PERFETTO MODULE slices.with_context;
INCLUDE PERFETTO MODULE time.conversion;

WITH target_window(start_ts, end_ts) AS (
VALUES ({start_ts}, {end_ts})
),
target_slices AS (
SELECT
s.process_name,
s.upid,
s.thread_name,
s.utid,
s.name AS slice_name,
MAX(0, MIN(s.ts + s.dur, w.end_ts) - MAX(s.ts, w.start_ts)) AS overlap_dur
FROM thread_slice s
JOIN target_window w
WHERE s.process_name = {process_name}
AND (s.is_main_thread = 1 OR s.thread_name = 'RenderThread')
AND s.dur > 0
AND s.ts < w.end_ts
AND s.ts + s.dur > w.start_ts
)
SELECT
process_name,
upid,
thread_name,
utid,
slice_name,
COUNT(*) AS slice_count,
time_to_ms(SUM(overlap_dur)) AS overlap_total_ms,
time_to_ms(MAX(overlap_dur)) AS max_overlap_ms
FROM target_slices
WHERE overlap_dur > time_from_ms(5)
GROUP BY process_name, upid, thread_name, utid, slice_name
ORDER BY overlap_total_ms DESC
LIMIT 20;
"""

STATE_QUERY_TEMPLATE = """
INCLUDE PERFETTO MODULE time.conversion;

WITH target_window(start_ts, end_ts) AS (
VALUES ({start_ts}, {end_ts})
),
target_threads AS (
SELECT th.utid, p.name AS process_name, th.name AS thread_name
FROM thread th
JOIN process p USING (upid)
WHERE p.name = {process_name}
AND (th.is_main_thread = 1 OR th.name = 'RenderThread')
),
state_in_window AS (
SELECT
t.process_name,
t.thread_name,
ts.utid,
CASE
WHEN ts.state = 'Running' THEN 'running_ms'
WHEN ts.state GLOB 'R*' THEN 'runnable_ms'
WHEN ts.state = 'S' THEN 'sleeping_ms'
WHEN ts.state = 'D' THEN 'uninterruptible_blocked_ms'
ELSE 'other_state_ms'
END AS state_bucket,
MAX(0, MIN(ts.ts + ts.dur, w.end_ts) - MAX(ts.ts, w.start_ts)) AS overlap_dur
FROM thread_state ts
JOIN target_threads t USING (utid)
JOIN target_window w
WHERE ts.dur > 0
AND ts.ts < w.end_ts
AND ts.ts + ts.dur > w.start_ts
)
SELECT
process_name,
thread_name,
utid,
state_bucket,
time_to_ms(SUM(overlap_dur)) AS overlap_ms
FROM state_in_window
GROUP BY process_name, thread_name, utid, state_bucket;
"""

def sql_string(value):
return "'" + value.replace("'", "''") + "'"

def load_metadata(trace_path):
metadata_path = trace_path.with_suffix(".json")
if not metadata_path.exists():
return {}
return json.loads(metadata_path.read_text())

def quality_summary(rows, frame_row):
data_loss_sources = []
ftrace_loss_count = 0
for row in rows:
name = row.name or ""
severity = row.severity or ""
if severity == "data_loss":
data_loss_sources.append(f"{row.source}:{name}[{row.idx}]")
if "ftrace" in name.lower() and (
severity in ("error", "data_loss")
or "loss" in name.lower()
or "drop" in name.lower()
):
ftrace_loss_count += 1

frame_available = (frame_row.actual_count or 0) > 0 and (frame_row.expected_count or 0) > 0
if ftrace_loss_count:
grade = "weak"
reason = "ftrace_loss"
elif data_loss_sources:
grade = "needs_metric_scope_check"
reason = "data_loss"
elif not frame_available:
grade = "frame_metrics_unavailable"
reason = "missing_frame_timeline"
else:
grade = "strong"
reason = ""

return {
"quality_grade": grade,
"degrade_reason": reason,
"data_loss_sources": ";".join(data_loss_sources),
"ftrace_loss_count": ftrace_loss_count,
"frame_timeline_available": frame_available,
}

with open("report.csv", "w", newline="") as fp:
writer = csv.DictWriter(
fp,
fieldnames=[
"trace_name",
"group",
"scenario",
"window_source",
"window_name",
"window_start_ms",
"window_dur_ms",
"quality_grade",
"degrade_reason",
"data_loss_sources",
"ftrace_loss_count",
"frame_timeline_available",
"process_name",
"upid",
"thread_name",
"utid",
"slice_name",
"slice_count",
"overlap_total_ms",
"max_overlap_ms",
"running_ms",
"runnable_ms",
"sleeping_ms",
"uninterruptible_blocked_ms",
],
)
writer.writeheader()

for trace_path in Path("traces").glob("*.perfetto-trace"):
metadata = load_metadata(trace_path)
process_name = sql_string(metadata.get("process_name", APP_PROCESS))
start_ts = int(metadata["window_start_ts"])
end_ts = int(metadata["window_end_ts"])

slow_slice_query = SLOW_SLICE_QUERY_TEMPLATE.format(
start_ts=start_ts,
end_ts=end_ts,
process_name=process_name,
)
state_query = STATE_QUERY_TEMPLATE.format(
start_ts=start_ts,
end_ts=end_ts,
process_name=process_name,
)

with TraceProcessor(trace=str(trace_path)) as tp:
quality_rows = list(tp.query(QUALITY_DETAIL_QUERY))
frame_row = next(iter(tp.query(FRAME_TIMELINE_QUERY)))
slow_rows = list(tp.query(slow_slice_query))
state_rows = list(tp.query(state_query))

quality = quality_summary(quality_rows, frame_row)
state_by_thread = {}
for row in state_rows:
state_by_thread.setdefault((row.utid, row.thread_name), {})[row.state_bucket] = row.overlap_ms or 0

if not slow_rows:
slow_rows = [None]

for row in slow_rows:
states = state_by_thread.get((row.utid, row.thread_name), {}) if row else {}
writer.writerow({
"trace_name": trace_path.name,
"group": metadata.get("group", "unknown"),
"scenario": metadata.get("scenario", "unknown"),
"window_source": metadata.get("window_source", "metadata"),
"window_name": metadata.get("window_name", ""),
"window_start_ms": round(start_ts / 1_000_000, 3),
"window_dur_ms": round((end_ts - start_ts) / 1_000_000, 3),
**quality,
"process_name": row.process_name if row else metadata.get("process_name", APP_PROCESS),
"upid": row.upid if row else "",
"thread_name": row.thread_name if row else "",
"utid": row.utid if row else "",
"slice_name": row.slice_name if row else "",
"slice_count": row.slice_count if row else 0,
"overlap_total_ms": round(row.overlap_total_ms, 3) if row else 0,
"max_overlap_ms": round(row.max_overlap_ms, 3) if row else 0,
"running_ms": round(states.get("running_ms", 0), 3),
"runnable_ms": round(states.get("runnable_ms", 0), 3),
"sleeping_ms": round(states.get("sleeping_ms", 0), 3),
"uninterruptible_blocked_ms": round(states.get("uninterruptible_blocked_ms", 0), 3),
})

这个脚本仍然是起点,但它已经有长期脚本需要的三个特征:同一份窗口化 SQL、同一组阈值、同一类输出。QUALITY_DETAIL_QUERY 保留 stats 明细,CSV 用 quality_grade/degrade_reason/data_loss_sources 表示降级原因;SLOW_SLICE_QUERY_TEMPLATE 只看目标窗口内主线程和 RenderThread,不会把整份 Trace 的后台线程长任务误写成 UI 卡顿证据。

Trace 数量上来以后,手写循环可以换成官方 BatchTraceProcessor。它仍然复用同一份 PerfettoSQL,但返回结果时会按 Trace 分开,或者用 query_and_flatten() 合成一张带来源信息的表:

1
pip install perfetto pandas
1
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
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
import glob
from perfetto.batch_trace_processor.api import BatchTraceProcessor

QUERY = """
INCLUDE PERFETTO MODULE slices.with_context;
INCLUDE PERFETTO MODULE time.conversion;

WITH target_window(start_ts, end_ts) AS (
VALUES (123000000000, 123500000000)
),
target_slices AS (
SELECT
process_name,
upid,
thread_name,
utid,
name AS slice_name,
MAX(0, MIN(ts + dur, w.end_ts) - MAX(ts, w.start_ts)) AS overlap_dur
FROM thread_slice
JOIN target_window w
WHERE process_name = 'com.example.app'
AND (is_main_thread = 1 OR thread_name = 'RenderThread')
AND ts < w.end_ts
AND ts + dur > w.start_ts
)
SELECT
process_name,
upid,
thread_name,
utid,
slice_name,
COUNT(*) AS slice_count,
time_to_ms(SUM(overlap_dur)) AS overlap_total_ms
FROM target_slices
WHERE overlap_dur > time_from_ms(5)
GROUP BY process_name, upid, thread_name, utid, slice_name
ORDER BY overlap_total_ms DESC
LIMIT 20;
"""

files = glob.glob("traces/*.perfetto-trace")

with BatchTraceProcessor(files) as btp:
result = btp.query_and_flatten(QUERY)
print(result)

这类批处理更适合实验室回归和大批量现场 Trace。代价是内存和并发要受控:一次加载上百份大 Trace 时,先从少量样本试跑,确认机器内存和查询耗时,再扩大规模。

从临时查询到稳定指标

临时 SQL 用来探索,稳定指标用来长期比较。把查询放进工程前,至少补齐四组约束:

  • 输出契约:列名、单位、排序规则要固定。dur > time_from_ms(5) 比裸写 5e6 更适合长期脚本;列名写成 overlap_total_ms,读报告的人才知道它已经和目标窗口做过交集。
  • 身份归属:多进程场景使用 utid/upid,线程名只适合读,不适合当唯一标识。mainRenderThreadBinder:* 都可能重复。
  • 目标窗口:点击、启动、滑动首帧、Camera open 这类问题不能只看整份 Trace 的总量。报告里要带 window_source/window_name/window_start_ms/window_dur_ms
  • 数据质量:每份 Trace 都要带 stats 明细和降级字段。quality_grade/degrade_reason/missing_sources/fallback_used 比一个总数更适合做回归门禁。

复杂 JOIN 优先找标准库视图。thread_sliceprocess_slicethread_or_process_slice 能减少低级错误。长期项目优先考虑 Trace Summary;官方已经把 v2 summary 作为更适合结构化输出的方向,旧的 v1 metrics 不适合作为新方案起点。

一个实用标准是:查询结果能不能直接放进版本对比。如果还需要人手工解释“这一列上次叫 A,这次叫 B”,它就还不是稳定指标。

还有一条容易被忽略:指标要写出失败时的解释口径。比如“5ms 以上主线程 slice 总耗时”只能回答切片里有多少长任务,它不能独立证明卡顿;“Runnable 总耗时”只能说明线程有等待 CPU 的迹象,它也不能替代 CPU 频率、负载和调度器证据。FrameTimeline 缺失时,报告应写 metric_status=unavailablefallback_used=falsedegrade_reason=missing_frame_timeline;ftrace loss 命中时,调度类指标应写 sched_evidence_grade=weak

同一目标窗口里的 CPU frequency、CPU idle、thermal、cluster/CPU placement 更适合放在报告的“解释变量”里。它们通常不单独定责,但能解释为什么同样的业务切片在某次 Trace 里变慢。

多 Trace 才能回答回归问题

单份 Trace 适合定位一个现场,多份 Trace 才能回答“优化有没有稳定变好”。很多性能报告的问题不在 Trace 本身,而在证据组织:截图很多,结论下得重,但没有固定配置、没有样本数、没有 data loss 检查、没有 before/after 统计口径。

多 Trace 分析要先固定输入契约。最小集合是 trace_name/scenario/group/device/build/config_idduration_ms/thermal_state/trace_processor_version/sql_package_version 用来排除环境和工具漂移。每一批 Trace 至少要带这些字段:

字段 例子 用途
trace_name after-run03.perfetto-trace 追溯原始文件
scenario feed_scroll_120hz 区分场景
group before / after 做版本对比
device Pixel_8 按机型聚合
build UP1A.xxx 定位系统版本
config_id ui_jank_v4 确认 TraceConfig 一致
duration_ms 30000 判断采集窗口
thermal_state nominal 排除温控干扰
trace_processor_version v49.0 排除解析器或 stdlib 变化
sql_package_version perf-v3 确认查询逻辑一致

这些字段可以来自文件名、metadata、测试平台,也可以放进 case 包里的 metadata.json。缺少这些字段时,数字很难解释。

批量脚本的最小输出可以是一张能贴进问题单的 CSV:

1
2
3
4
5
trace_name,group,scenario,window_name,quality_grade,degrade_reason,ftrace_loss_count,frame_timeline_available,thread_name,slice_name,slice_count,overlap_total_ms,runnable_ms,conclusion_level,next_action
before-run01.perfetto-trace,before,feed_scroll_120hz,feed_scroll,strong,,0,true,main,inflate,4,38.41,2.10,info,compare_after
before-run02.perfetto-trace,before,feed_scroll_120hz,feed_scroll,strong,,0,true,RenderThread,DrawFrame,3,51.02,4.33,info,compare_after
after-run01.perfetto-trace,after,feed_scroll_120hz,feed_scroll,weak,ftrace_loss,1,true,main,inflate,5,71.82,18.40,needs_retrace,recollect_trace
after-run02.perfetto-trace,after,feed_scroll_120hz,feed_scroll,strong,,0,true,RenderThread,DrawFrame,2,25.77,1.92,improved,keep_monitoring

质量问题不要只输出一个总数。ftrace loss 会直接影响 sched/thread_state/wakeup 结论,但不一定让 App Track Event 完全不可用;FrameTimeline 缺失会影响帧指标,但不一定影响 Binder 或内存分析。报告里至少要保留 name/source/idx 或按数据源聚合,回归门禁再按指标依赖决定复抓、剔除或降级。

回归判定也不要只比较平均值。建议同时保留中位数、p95、最差值和每次运行的原始值:

指标 方向 判定建议
FrameTimeline overrun / jank rate 越低越好 p95_frame_overrun_msworst_frame_overrun_msjank_countjank_ratejank_type 分布;valid_frame_count 用来表示样本量
launch duration 越低越好 单独定义起止点,不要和帧时长混在一起
jank count / jank rate 越低越好 同时保留总帧数、有效帧数、窗口时长和比例
slow binder count 越低越好 看总数,也看是否集中在同一阶段
CPU Running time 不一定 要和场景动作、帧窗口一起看
RSS/PSS/未释放 native allocation 通常越低越好 看峰值、增长斜率和是否回落
battery current / power rail 不一定 同设备同条件 A/B 才有意义

5 次 before/after 只能帮你发现明显退化,不能证明 1% 或 2% 的变化。报告里要写清样本数和环境条件;样本数不足、温控状态不同、质量门禁不干净时,结论要降级。

Trace Summary 放长期指标

直接 SELECT 很灵活,但输出 schema 会跟着查询变化。接平台、看板、CI 时,工具更需要稳定结构。Trace Summary 的设计目标就是把指标写进统一的 TraceSummary protobuf。

当前命令行入口是 summarize 子命令。如果前面下载的是 trace_processor wrapper,就沿用同一个入口。

下面这个最小 spec.textproto 用内存指标展示 Trace Summary 的结构。这里选内存只是为了让例子短一点;等长切片、帧、Binder 这类指标的 SQL 稳定后,也可以按同样方式迁进去。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
metric_spec {
id: "memory_per_process"
dimensions: "process_name"
value: "avg_rss_and_swap"
unit: BYTES
polarity: LOWER_IS_BETTER
query {
table {
table_name: "memory_rss_and_swap_per_process"
}
referenced_modules: "linux.memory.process"
group_by {
column_names: "process_name"
aggregates {
column_name: "rss_and_swap"
op: DURATION_WEIGHTED_MEAN
result_column_name: "avg_rss_and_swap"
}
}
}
}

然后运行这个 summary spec,并请求 memory_per_process 这个 metric:

1
2
3
./trace_processor summarize \
--metrics-v2 memory_per_process \
trace.perfetto-trace spec.textproto

源码树里的 trace_processor_shell summarize 也能表达同一件事。旧版 --summary --summary-spec ... --summary-metrics-v2 ... 形式仍受支持,但新脚本优先使用子命令写法,读者对照官方文档时不容易走岔。

Trace Summary 适合长期指标:内存、启动、帧、Binder、CPU、功耗这类需要跨版本长期跟踪的数据。临时排障仍然可以直接写 SQL。帧指标迁进去时,App FrameTimeline、SurfaceFlinger/display 侧指标要分开输出,不要把 RenderThread 长 slice 当成 frame duration。

一个 summary spec 至少要说明 metric id、维度和值。单个 metric_spec 可以直接声明 unitpolarity,例如内存值用 BYTES,方向是 LOWER_IS_BETTER;模板场景再用 value_column_specs 给多个 value column 分别声明单位和方向。消费方拿到 protobuf 后,就知道该怎么排序、标红和比较。

另一个适合平台化的能力是 metadata_query_id。如果 spec 同时定义一个返回 key / value 的 metadata query,命令行可以加 --metadata-query,Python API 也可以传 metadata_query_id。这适合把 device、build、scenario、config_id 这类字段随 summary 一起输出,后续聚合时不用再猜文件名。

项目里可以先用 Python + CSV 起步;当字段稳定、消费方固定、指标需要长期维护时,再把关键指标迁到 Trace Summary。

容易踩的坑

  • 时间列如 ts/dur 默认按纳秒理解。报告字段写成 dur_ms/overlap_ms,脚本里用 time_to_ms()time_from_ms() 防住单位误读;counter/value 类字段另看 track 和模块定义。
  • mainRenderThread 不唯一。先过滤 process_name/upid,再过滤 thread_name/utid;报告里输出 upid/utid,防住线程名重复。
  • pid/tid 会复用。脚本里优先使用 upid/utid;需要回到系统日志时,再把 tid/pid 作为辅助字段。
  • UI 里的可视轨道不一定对应单张原始表。报告里写清数据来源,例如 thread_slicethread_stateactual_frame_timeline_slice,防止把展示层理解成 schema。
  • RR+ 是 Runnable,Running 才是实际运行。报告里拆成 running_ms/runnable_ms/sleeping_ms/uninterruptible_blocked_ms,不要合成“线程忙”。
  • stats 有数据丢失时,不要写过强结论。保留 name/source/idxdegrade_reason,按指标依赖判断影响范围。
  • Schema 会演进。批处理脚本里遇到错误时,先用 PRAGMA table_info(table_name); 确认列是否存在;报告里保留 trace_processor_version/sql_package_version
  • before 和 after 的 TraceConfig 不一致时,不要比较指标。报告里输出 config_id,不一致就先复抓。
  • 只保留 report、不保留原始 Trace、config、SQL 和 metadata,后续无法复跑。最低限度也要保留 trace 文件名、配置 id、SQL 版本和窗口来源。

总结

不要把截图当成结论。截图适合发现问题,SQL、metadata、质量门禁和可复跑输出,才让这个判断具备回归证据。

下一次拿到 Trace,可以按这个顺序做:先查 stats,再固定目标窗口和 upid/utid,然后把长 slice、Running/Runnable、帧指标和降级原因写成 CSV 或 Trace Summary。SQL 让判断可复查,但不能替代采集质量和场景定义;窗口错了、Trace 丢了、配置变了,查询写得再漂亮也只能得到不可信的数字。

参考文档

  1. Trace Processor
  2. Trace Processor Python API
  3. Getting Started with PerfettoSQL
  4. PerfettoSQL standard library
  5. PerfettoSQL Prelude tables
  6. Trace Processor Stats
  7. Trace Summarization
  8. Batch Trace Processor

关于我 && 博客

欢迎关注 Android Performance

CATALOG
  1. 1. 从截图结论到可复查查询
  2. 2. Trace Processor 是什么
  3. 3. 把 Trace 当成几张表
  4. 4. 一次完整分析怎么写成 SQL
    1. 4.1. 采集质量先决定结论语气
    2. 4.2. 先把目标窗口固定下来
    3. 4.3. 不要只靠 main 找线程
    4. 4.4. 长切片只能列可疑点
    5. 4.5. Runnable 不是 Running
    6. 4.6. 把查询放到命令行
  5. 5. Python API 批量分析
  6. 6. 从临时查询到稳定指标
  7. 7. 多 Trace 才能回答回归问题
  8. 8. Trace Summary 放长期指标
  9. 9. 容易踩的坑
  10. 10. 总结
  11. 11. 参考文档
  12. 12. 关于我 && 博客