CN

Android Perfetto Series 11: PerfettoSQL, Trace Processor, and Regression Dete...

Word count: 8.3kReading time: 51 min
2026/05/04
loading

Selecting a window in Perfetto UI, inspecting the main thread, RenderThread, and CPU states, then taking screenshots works well for one investigation. When I open another trace or switch devices, I want to know whether I can calculate comparable, checkable numbers for the same kind of problem.

Part 11 covers PerfettoSQL and Trace Processor. The aim is straightforward: turn judgments made in the UI into queries that others can check, then use Python to run them across multiple traces. You do not need to become a SQL expert first. Treat a trace as a set of timestamped tables, and translate your visual assessment into a query you can run repeatedly.

The article follows one path: understand Trace Processor, express an assessment as a query over a defined time window, run it from the command line, process multiple traces with Python, and finally turn the results into stable metrics and Trace Summary output. Each step includes SQL you can reuse.

Version note (reviewed October 2026): SQL/CLI contracts were checked against Perfetto v58.3 source, and Python APIs against perfetto==0.58.2. get.perfetto.dev is a rolling entry point; the wrapper retrieved for this review pins native v58.2. Record the source revision, Python package, and actual binary version separately. Check --version and the relevant subcommand’s --help before treating an unsupported option as a trace-data problem.

Perfetto Series Catalog

  1. Android Perfetto Series Catalog
  2. Android Perfetto Series 1: Introduction to Perfetto
  3. Android Perfetto Series 2: Capturing Perfetto Traces
  4. Android Perfetto Series 3: Familiarizing with the Perfetto View
  5. Android Perfetto Series 4: Opening Large Traces via Command Line
  6. Android Perfetto Series 5: Choreographer-based Rendering Flow
  7. Android Perfetto Series 6: Why 120Hz? Advantages and Challenges
  8. Android Perfetto Series 7: MainThread and RenderThread Deep Dive
  9. Android Perfetto Series 8: Understanding Vsync and Performance Analysis
  10. Android Perfetto Series 9: Interpreting CPU Information
  11. Android Perfetto Series 10: Binder Scheduling and Lock Contention
  12. Android Perfetto Series 11: PerfettoSQL, Trace Processor and Regression Detection
  13. Android Perfetto Series 12: Trace Dataflow and Data Loss
  14. Android Perfetto Series 13: Perfetto SDK, Track Event and App Field Traces
  15. Android Perfetto Series 14: heapprofd and Memory Profiling
  16. Android Perfetto Series 15: Boot Traces and Long-running Field Tracing
  17. Android Perfetto Series 16: GPU, Power Counters and Hardware Bottlenecks
  18. Android Perfetto Series 17: Scenario Automation and Platform Tracing
  19. Android Perfetto Series 18: Input Response Latency
  20. Video (Bilibili) - Android Perfetto Basics and Case Studies
  21. Video (Bilibili) - Android Perfetto: Trace Graph Types - AOSP, WebView, Flutter + OEM System Optimization

From Screenshot Conclusions to Queries Others Can Check

Part 03 covered basic Perfetto UI operations, Part 04 explained how to open large traces, and Parts 09 and 10 included SQL examples for CPU and Binder analysis. This article does not repeat those topics. It adds one thing: how to move from “this looks like the cause this time” to “we can measure it by the same standard next time.”

You can start here directly, but it helps to have four things ready: a .perfetto-trace file, the target app’s package name, a target time window you can select in the UI, and a working local trace_processor. The SQL below revolves around these four inputs.

The path from manual assessment to an automated report breaks down into these steps:

1
2
3
4
5
Form an assessment in Perfetto UI
-> Express the assessment as a PerfettoSQL query
-> Execute the query with Trace Processor on the command line
-> Process multiple traces in batches with Python
-> Produce stable results with Trace Summary or a custom report

Turning UI assessments into repeatable queries

Diagram: fix the window, target, and quality conditions before using SQL results for batch comparisons.

There are three common engineering uses:

  • Ad hoc investigation: find a suspicious interval in the UI, then use SQL to extract its time, thread, process, duration, and underlying evidence.
  • Domain-specific analysis: inspect dozens of traces for the same class of problem, such as scrolling jank, tap response, or slow camera opening, and produce the same diagnostic fields.
  • Regression detection: run the same queries for every version and put before/after results into reports or dashboards.

The UI remains valuable. SQL preserves the assessments made there so they can be checked and compared in bulk.

What Is Trace Processor?

Trace Processor is Perfetto’s analysis engine. It parses Perfetto protobuf traces, Chrome JSON traces, and other supported formats into a unified set of SQL tables. Many tracks in Perfetto UI are also obtained by querying these tables and views.

Part 04 used trace_processor_shell --httpd to open large traces. Current official documentation calls the user-facing command trace_processor: the downloaded file is a Python wrapper that retrieves and caches the native Trace Processor executable for your platform on its first run.

First, install the command. These macOS/Linux shell commands download the wrapper, print its version, and enter the interactive environment. Python 3 and network access for the initial native-tool download are required.

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

Older documentation and scripts often use ./trace_processor trace.perfetto-trace directly. This default interactive mode still works, but new scripts should use explicit subcommands to make their purpose clear.

For command-line batch processing, focus on query. It loads the trace, executes SQL, and prints the results without opening the UI:

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

For large traces, the UI can still use a local Trace Processor to reduce parsing work in the browser. The subcommand syntax in the checked version is:

1
./trace_processor server http trace.perfetto-trace

This command only starts the local HTTP RPC server. Perfetto UI in the browser must still connect to that local Trace Processor at the address and port shown in the output before the local process takes over parsing.

Older entry points such as trace_processor_shell and --httpd retain compatibility support; prefer subcommands in new scripts. Two different ways of performing the same action can leave a team unsure which one is recommended six months later.

SQL and parsing run in the local process after connection; timeline rendering remains in the browser. Connecting to an existing HTTP RPC service shares its loaded state. Passing both addr and trace to Python can also load data into that service, so it is not a read-only connection. Independent batch jobs should create and close their own processors.

Think of a Trace as a Set of Tables

PerfettoSQL is the SQL dialect provided by Trace Processor. You do not need to memorize the entire schema to begin. Start with a few common table types:

Type Common tables Purpose
Slices slice Events with a start time and duration, such as ATrace, Track Event, and some imported or derived slices
Counters counter, counter_track Values that change over time, such as CPU frequency, memory, power, and application counters
Threads and processes thread, process Thread names, process names, tid, pid, utid, and upid
Tracks track, thread_track, process_track Ownership of events such as slices and counters; visible UI tracks do not necessarily map one-to-one to these tables
Scheduling states thread_state, sched thread_state records thread states; sched records the Running intervals when a thread actually occupies a CPU
CPU samples perf_sample, cpu_profile_stack_sample Sample fact tables for CPU profiling
Stack dimension tables stack_profile_* Stack information such as callsites, frames, and mappings, usually queried together with sample tables
Integrity stats Collection-quality information, including data loss, parsing errors, and buffer problems

The easiest distinction to miss is tid/pid versus utid/upid. System tids and pids can be reused, and a single trace can contain multiple threads with the same name. Trace Processor uses utid/upid to identify unique thread and process entities within that trace. Prefer utid/upid in scripts rather than relying solely on thread names.

Learn the time units early: ts and dur are generally in nanoseconds. The two conversion directions have different precision. In time.conversion, time_from_ms() performs multiplication and is exact for expressing thresholds; dur > time_from_ms(5) is easier to read than a bare 5e6. By contrast, time_to_ms() performs integer division ($nanos / (1000 * 1000), returning LONG), truncating 1.6 ms to 1. It is suitable for whole-millisecond display or coarse bucketing. Keep raw nanoseconds for computation and use ROUND(dur / 1e6, 3) for displayed durations, rather than discarding submillisecond differences. Three decimals still round submicrosecond differences, so displayed values must not decide full-window coverage. counter.value has no universal unit: interpret it using counter_track, the relevant module, and the collection configuration. CPU frequency, memory, power rails, and application counters cannot all be compared using the same AVG definition.

Another distinction matters: names such as thread_slice refer to views provided by the slices.with_context standard-library module, not raw schema tables. The module joins slices, tracks, threads, and processes for you, which is why you can query process_name, thread_name, and is_main_thread directly. When copying these queries, do not omit the preceding INCLUDE PERFETTO MODULE ... statement.

Classify SQL failures before changing anything. If INCLUDE PERFETTO MODULE fails or reports a missing module, your local Trace Processor is often too old and its standard library does not yet include that module; run ./trace_processor --version before deciding to upgrade. For no such table or no such column, first suspect a missing INCLUDE or schema evolution, and check column names with PRAGMA table_info(table_name);. A successful query returning zero rows is a third case: the data source may not have been collected, or the window may be wrong. That is not a syntax problem; return to stats and the collection configuration. These three cases need different responses, so do not immediately rewrite the SQL itself.

Scheduling states also depend on the collection configuration. To analyze thread_state, Running, Runnable, and wakeups, the trace needs ftrace scheduling events, particularly sched/sched_switch, sched/sched_wakeup, and sched/sched_waking. Running analysis mainly depends on switch events; explaining the relationship between wakeups and Runnable also requires wakeup/waking events. If these events are missing, the conclusion is “insufficient evidence,” not “no scheduling problem.”

CPU profiling has similar limits. perf_sample and cpu_profile_stack_sample contain sampled observations, not a complete execution record. Sampling frequency, sampling window, unwinding, symbolization, and lost-sample statistics all affect the conclusion. If no usable samples are available, report that fact and distinguish missing collection, unmatched scope, permissions, and parsing problems; it does not establish the absence of CPU hotspots.

Expressing a Complete Analysis in SQL

Consider a common scenario: you notice a clear main-thread stall within a target window in the UI and want to turn that assessment into a query others can check. The window may come from a UI selection, Track Event marker, input event, or the start and end of a frame/CUJ.

Collection Quality Determines the Strength of the Conclusion

Check collection quality before writing any conclusion. stats includes ordinary counters as well as errors and data loss. An automated gate should not treat every nonzero item as failure. First list the items that directly limit the conclusion, then decide per data source whether to recollect, downgrade confidence, or restrict the impact to particular metrics.

The official statistics definitions use types such as kError, kDataLoss, and kInfo, but query values in stats.severity are lowercase strings. This query retains the original name/idx/severity/source/value fields and adds an impact hint. A generic entry point should not classify every error or data_loss item as fatal to the entire trace.

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
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%'
OR name IN ('ftrace_setup_errors', 'traced_buf_chunks_overwritten',
'traced_buf_chunks_discarded', 'traced_flushes_failed',
'traced_final_flush_failed')
)
ORDER BY severity, source, name, idx;

If the results include data_loss, parser errors, buffer overruns, or packet loss, qualify subsequent conclusions. ftrace loss directly affects sched/thread_state/wakeup assessments, but does not necessarily invalidate all app Track Events. Missing FrameTimeline data affects frame metrics, but may not affect Binder or memory queries. Preserve the detailed stats in the report, then decide for each metric whether to recollect, exclude, or downgrade.

When optimizing the collection configuration, broaden the filter to value != 0 and inspect the full stats table. Informational items such as buffer size, write volume, and ftrace counters can also explain why data was lost. Part 12 covers the full classification of data loss; here, the aim is to make the check part of the automation entry point.

For frame metrics, also check whether FrameTimeline data exists. Long slices are not a definition of slow frames. FrameTimeline’s actual/expected frames, jank types, and overruns are the data needed to answer frame-level regression questions:

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;

When these tables exist and the query succeeds, it returns two rows. A row_count = 0 does not necessarily mean the trace is broken; the Android version, collection configuration, or scenario may not support that data. Metrics requiring actual/expected pairing are unavailable in that case. Single-sided statistics need their own definition; long RenderThread slices cannot substitute for slow-frame evidence.

Fix the Target Window First

The target window cannot remain “a range selected in the UI.” A script needs explicit start_ts/end_ts values, and subsequent long-slice, scheduling-state, and frame queries must use the same window. The most reliable source is a Trace.beginSection() or Track Event marker emitted by the app itself:

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

SELECT
'app_marker' AS window_source,
name AS window_name,
upid,
utid,
ts AS start_ts,
ts + dur AS end_ts,
time_to_ms(ts) AS start_ms,
time_to_ms(ts + dur) AS end_ms,
ROUND(dur / 1e6, 3) 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;

Keep raw integer nanoseconds for windows from frames, CUJs, input events, or UI selections. The next query scopes candidate actual frames to a confirmed upid in this trace. Replace NULL with that integer, not an ID copied from another trace. No rows can mean an unset ID, mismatched filtering, unclosed frames, or missing data. Record no_valid_target_frames while investigating rather than always claiming trace-wide FrameTimeline loss. An app frame’s lifetime is not automatically input-to-display response latency.

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

WITH target_process(upid) AS (VALUES (NULL))
SELECT
'frame_timeline' AS window_source,
name AS window_name,
id AS frame_slice_id,
upid,
ts AS start_ts,
ts + dur AS end_ts,
time_to_ms(ts) AS start_ms,
time_to_ms(ts + dur) AS end_ms,
ROUND(dur / 1e6, 3) AS duration_ms
FROM actual_frame_timeline_slice
WHERE upid = (SELECT upid FROM target_process)
AND dur > 0
ORDER BY dur DESC
LIMIT 10;

To keep the examples short, the queries below still hardcode a window in a target_window CTE. Production scripts should pass window_source/window_name/start_ts/end_ts from metadata or a preceding query and include those fields in the report.

Windows use half-open intervals [start_ts, end_ts). Convert UI-relative display times into the trace’s raw time domain first; equal timestamps in different files usually do not identify the same activity. Fill target_process with a confirmed per-trace upid in the long-slice and scheduling queries below. Empty rows or zero counts from an unset placeholder are not measurements.

Do Not Find Threads by main Alone

Many examples simply use thread.name = 'main'. That looks convenient for one app, but can select unrelated threads in a system trace because every app may have a main thread. First list the threads in the target process to establish their upid/utid identities:

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 = 'com.example.app' OR 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;

A NULL first_thread_start_ms means that the trace did not record the thread’s creation time, commonly because the thread already existed when recording began. Do not treat NULL as zero or infer that the thread does not exist.

Replace com.example.app with the target full process name; the query additionally accepts colon-suffixed subprocesses without matching names such as com.example.application. Multiprocess apps may use :remote or :push; WebView sandboxes and isolated processes may use different names and need separate attribution. For UI rendering issues, prioritize the process hosting the Activity, FrameTimeline, or app marker. For a cross-process application path, retain multiple upid values rather than collapsing them into one “total app duration.”

Long Slices Identify Suspects Only

The PerfettoSQL standard library provides prejoined views that avoid many error-prone JOINs. This SQL includes slices.with_context and time.conversion to find thread slices longer than 5 ms within the target window.

The times in target_window are trace timestamps, still in nanoseconds. In practice, replace them with start and end times derived from a UI selection, Track Event, or input 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
37
INCLUDE PERFETTO MODULE slices.with_context;
INCLUDE PERFETTO MODULE time.conversion;

WITH target_window(start_ts, end_ts) AS (
VALUES (123000000000, 123500000000)
),
target_process(upid) AS (VALUES (NULL)),
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.upid = (SELECT upid FROM target_process)
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,
ROUND(overlap_dur / 1e6, 3) AS overlap_ms,
ROUND(dur / 1e6, 3) AS original_dur_ms,
name,
thread_name,
process_name
FROM target_slices
WHERE overlap_dur > time_from_ms(5)
ORDER BY overlap_ms DESC;

This query corresponds to “find long main-thread tasks” in the UI. It cannot directly establish a root cause or replace slow-frame metrics. It lists suspicious work in the target window: the thread, the slice, its overlap with the window, and the original slice duration.

Runnable Is Not Running

A long slice may contain prolonged execution, or it may span time spent waiting. Inspect scheduling states next. thread_state.state = 'Running' represents intervals actually occupying a CPU. States such as R and R+ mean the thread is runnable but is not executing on a CPU; R+ typically means it remains runnable after preemption.

Before scheduling analysis, check whether thread_state/sched data exists within the same window. If the target thread cannot be matched, or stats contain ftrace loss/drop/error, report insufficient_sched_evidence or sched_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
24
25
26
WITH target_window(start_ts, end_ts) AS (
VALUES (123000000000, 123500000000)
),
target_process(upid) AS (VALUES (NULL)),
target_threads AS (
SELECT th.utid
FROM thread th
JOIN process p USING (upid)
WHERE p.upid = (SELECT upid FROM target_process)
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.dur > 0
AND 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.dur > 0
AND s.ts < w.end_ts
AND s.ts + s.dur > w.start_ts;

The next query remains within the same target window and separates Running, Runnable, S, and D for the main thread and RenderThread:

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
WITH target_window(start_ts, end_ts) AS (
VALUES (123000000000, 123500000000)
),
target_process(upid) AS (VALUES (NULL)),
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.upid = (SELECT upid FROM target_process)
AND (th.is_main_thread = 1 OR th.name = 'RenderThread')
),
state_in_window AS (
SELECT
t.utid,
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 GLOB 'D*' THEN 'uninterruptible_blocked'
ELSE 'other_' || COALESCE(ts.state, 'unknown')
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
utid,
process_name,
thread_name,
state_bucket,
ROUND(SUM(overlap_dur) / 1e6, 3) AS overlap_ms
FROM state_in_window
GROUP BY utid, process_name, thread_name, state_bucket
ORDER BY thread_name, overlap_ms DESC;

S commonly occurs during normal sleep or futex waits; D and variants with flags (such as D|K) belong to the uninterruptible-wait category. Before attributing a cause, examine the blocked reason, io_wait, and waker fields in thread_state, together with the schema version. In scenarios such as tap response, the first scrolling frame, or the first launch frame, a long Runnable interval means CPU contention or scheduling delay also needs investigation, but cannot establish the cause alone. Thread running time is a thread-level metric. To explain CPU utilization and cluster behavior, separately inspect sched.cpu, CPU idle, CPU frequency, and capacity within the same window.

Move the Query to the Command Line

Save the same SQL as queries/slow_slices.sql and run it with Trace Processor:

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

At this point, the analysis has moved from screenshots to a text file. A text file can be reviewed, reused, included in CI, and sent to colleagues for verification.

Batch Analysis with the Python API

You can inspect one trace carefully in the UI; multiple traces call for a script. The script needs to do four things: read the trace, read its matching .json metadata file, run quality gates, and emit stable fields for the same target window. Pay particular attention to TraceProcessor(trace=...), tp.query(...), and the report’s column names.

1
python3 -m pip install perfetto==0.58.2

The program below requires TRACE_PROCESSOR_BIN to contain the absolute path of a native executable whose version you have verified. Save its --version output separately; the Python package version does not establish the native version. Each foo.perfetto-trace reads foo.json from the same directory, not one shared metadata.json. Supply process_name, the per-file target_upid, and integer-nanosecond window_start_ts/window_end_ts in the raw trace time domain.

A regression platform should not depend on downloading tools on first use. CI should pin the Python perfetto package version and prewarm or cache the native Trace Processor. TraceProcessorConfig(bin_path=...) can point directly to a preinstalled binary, avoiding an on-demand download on the CI machine’s first run. Record trace_processor --version, the TraceConfig id, and the SQL package version in metadata. Otherwise, a tool upgrade can change the parsing assumptions between before and 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
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
import csv
import json
import os
from importlib.metadata import version
from pathlib import Path

from perfetto.trace_processor import TraceProcessor, TraceProcessorConfig

TP_BIN = Path(os.environ["TRACE_PROCESSOR_BIN"])
if not TP_BIN.is_absolute() or not TP_BIN.is_file():
raise ValueError("TRACE_PROCESSOR_BIN must be an existing absolute file path")
TP_CONFIG = TraceProcessorConfig(bin_path=str(TP_BIN))
PYTHON_PACKAGE_VERSION = version("perfetto")

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%'
OR name IN ('ftrace_setup_errors', 'traced_buf_chunks_overwritten',
'traced_buf_chunks_discarded', 'traced_flushes_failed',
'traced_final_flush_failed')
)
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.upid = {target_upid}
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,
ROUND(SUM(overlap_dur) / 1e6, 3) AS overlap_total_ms,
ROUND(MAX(overlap_dur) / 1e6, 3) 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, upid, utid, slice_name
LIMIT 20;
"""

STATE_QUERY_TEMPLATE = """
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.upid = {target_upid}
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_ns'
WHEN ts.state GLOB 'R*' THEN 'runnable_ns'
WHEN ts.state = 'S' THEN 'sleeping_ns'
WHEN ts.state GLOB 'D*' THEN 'uninterruptible_blocked_ns'
WHEN ts.state IS NULL THEN 'unknown_state_ns'
ELSE 'other_state_ns'
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,
SUM(overlap_dur) AS overlap_ns
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")
return json.loads(metadata_path.read_text(encoding="utf-8"))

def required_int(metadata, key):
value = metadata[key]
if type(value) is not int or not 0 <= value < 2**63:
raise ValueError(f"{key} must be a nonnegative signed-64-bit JSON integer")
return value

def quality_summary(rows, frame_row):
data_loss_sources = []
ftrace_issue_rows = 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_issue_rows += 1

frame_available = (frame_row.actual_count or 0) > 0 and (frame_row.expected_count or 0) > 0
if ftrace_issue_rows:
grade = "weak"
reason = "ftrace_stats_findings"
elif data_loss_sources:
grade = "needs_metric_scope_check"
reason = "data_loss"
elif rows:
grade = "needs_metric_scope_check"
reason = "stats_findings" # Parser errors, setup notices, and overwrite counters
elif not frame_available:
grade = "frame_metrics_unavailable"
reason = "missing_frame_timeline"
else:
grade = "no_selected_stats_findings"
reason = ""

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

files = sorted(Path("traces").glob("*.perfetto-trace"))
if not files:
raise ValueError("No input traces")

quality_dir = Path("quality")
quality_dir.mkdir(exist_ok=True)

with open("report.partial.csv", "w", newline="", encoding="utf-8") as fp:
writer = csv.DictWriter(
fp,
fieldnames=[
"trace_name",
"group",
"scenario",
"window_source",
"window_name",
"window_start_ts",
"window_end_ts",
"window_start_ms",
"window_dur_ms",
"python_package_version",
"metric_status",
"target_thread_count",
"observed_slice_count",
"quality_grade",
"degrade_reason",
"data_loss_sources",
"ftrace_issue_rows",
"frame_timeline_available",
"sched_coverage",
"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",
"other_state_ms",
"unknown_state_ms",
],
)
writer.writeheader()

for trace_path in files:
metadata = load_metadata(trace_path)
process_name = metadata["process_name"]
if not isinstance(process_name, str) or not process_name:
raise ValueError("process_name must be a nonempty string")
target_upid = required_int(metadata, "target_upid")
start_ts = required_int(metadata, "window_start_ts")
end_ts = required_int(metadata, "window_end_ts")
if end_ts <= start_ts:
raise ValueError(f"Invalid target window: {trace_path}")

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

with TraceProcessor(trace=str(trace_path), config=TP_CONFIG) as tp:
bounds = next(iter(tp.query("SELECT start_ts, end_ts FROM trace_bounds")))
if start_ts < bounds.start_ts or end_ts > bounds.end_ts:
raise ValueError(f"Window outside trace bounds: {trace_path}")
identity = list(tp.query(
f"SELECT upid FROM process WHERE upid = {target_upid} "
f"AND name = {sql_string(process_name)}"
))
if len(identity) != 1:
raise ValueError(f"Process identity mismatch: {trace_path}")
scope_row = next(iter(tp.query(f"""
INCLUDE PERFETTO MODULE slices.with_context;
WITH target_threads AS (
SELECT utid FROM thread
WHERE upid = {target_upid}
AND (is_main_thread = 1 OR name = 'RenderThread')
)
SELECT
(SELECT COUNT(*) FROM target_threads) AS thread_count,
(SELECT COUNT(*) FROM thread_slice
WHERE utid IN (SELECT utid FROM target_threads)
AND dur > 0 AND ts < {end_ts}
AND ts + dur > {start_ts}) AS observed_slice_count;
""")))
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_dir / f"{trace_path.stem}.stats.json").write_text(json.dumps(
[{key: getattr(row, key) for key in ("name", "idx", "severity", "source", "value")}
for row in quality_rows], ensure_ascii=False, indent=2), encoding="utf-8")
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_ns

if slow_rows:
metric_status = "observed_top20_slow_slice_groups"
elif scope_row.thread_count == 0:
metric_status = "missing_target_threads"
elif scope_row.observed_slice_count == 0:
metric_status = "no_observed_closed_slices"
else:
metric_status = "no_slow_slices_in_observed_data"
no_slow_observed = metric_status == "no_slow_slices_in_observed_data"
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 {}
# thread_state intervals for one utid are disjoint; sum exact ns.
covered_ns = sum(states.values())
full_coverage = bool(states) and covered_ns == end_ts - start_ts
sched_coverage = "full" if full_coverage else ("partial" if states else "missing")
fill_zero = full_coverage and not quality_rows and not states.get("unknown_state_ns", 0)
def state_value(key):
value = states.get(key, 0 if fill_zero else None)
return round(value / 1e6, 3) if value is not None 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_ts": start_ts,
"window_end_ts": end_ts,
"window_start_ms": round(start_ts / 1_000_000, 3),
"window_dur_ms": round((end_ts - start_ts) / 1_000_000, 3),
"python_package_version": PYTHON_PACKAGE_VERSION,
"metric_status": metric_status,
"target_thread_count": scope_row.thread_count,
"observed_slice_count": scope_row.observed_slice_count,
**quality,
"sched_coverage": sched_coverage,
"process_name": row.process_name if row else process_name,
"upid": row.upid if row else target_upid,
"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 if no_slow_observed else ""),
"overlap_total_ms": round(row.overlap_total_ms, 3) if row else (0 if no_slow_observed else ""),
"max_overlap_ms": round(row.max_overlap_ms, 3) if row else (0 if no_slow_observed else ""),
"running_ms": state_value("running_ns"),
"runnable_ms": state_value("runnable_ns"),
"sleeping_ms": state_value("sleeping_ns"),
"uninterruptible_blocked_ms": state_value("uninterruptible_blocked_ns"),
"other_state_ms": state_value("other_state_ns"),
"unknown_state_ms": state_value("unknown_state_ns"),
})

Path("report.partial.csv").replace("report.csv")
print("Completed: report.csv")

foo.json must contain the required fields and JSON-integer timestamps. Missing fields, identity mismatches, or out-of-bounds windows stop the script. frame_timeline_available only means both frame tables have trace-wide records, not that a target process/window is covered; no_selected_stats_findings only means the selected stats did not warn. sched_coverage sums disjoint intervals for one utid in exact nanoseconds. Absent states become zero only with full coverage, no selected quality warnings, and no unknown state. Partial observations remain partial totals. A no-slow-slice summary has no thread identity and leaves scheduling blank. Deduplicate repeated scheduling columns by utid within each trace; utids are not cross-trace identities.

Only a successful current invocation with its completion message establishes the new report.csv. Failure leaves report.partial.csv; an existing report.csv may belong to an earlier run. ftrace_issue_rows counts matching stats rows, not lost events.

This script is still a starting point, but already has three properties needed for long-lived scripts: the same windowed SQL, the same thresholds, and the same output structure. quality/*.stats.json preserves the queried stats details, while the CSV expresses reasons for downgrading through quality_grade/degrade_reason/data_loss_sources. SLOW_SLICE_QUERY_TEMPLATE only considers the main thread and RenderThread in the target window, avoiding the mistake of treating long background-thread tasks elsewhere in the trace as evidence of UI jank.

Use the official BatchTraceProcessor to run common queries in parallel. Here, each trace derives its own window from exactly one main-thread feed_scroll marker, avoiding reuse of another file’s absolute timestamps. Missing or multiple markers require per-file window selection as in the metadata workflow above. Source columns in query_and_flatten() depend on the resolver; the pinned package’s filesystem resolver supplies _path, but arbitrary custom resolvers need not do so.

1
python3 -m pip install perfetto==0.58.2 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
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
import glob
import os
from pathlib import Path
import pandas as pd
from perfetto.batch_trace_processor.api import BatchTraceProcessor, BatchTraceProcessorConfig
from perfetto.trace_processor import TraceProcessorConfig

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

WITH candidate_windows AS (
SELECT upid, ts AS start_ts, ts + dur AS end_ts
FROM thread_slice
WHERE process_name = 'com.example.app'
AND is_main_thread = 1 AND name = 'feed_scroll' AND dur > 0
),
target_window AS (
SELECT * FROM candidate_windows
WHERE (SELECT COUNT(*) FROM candidate_windows) = 1
),
target_slices AS (
SELECT
s.process_name,
s.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 s
JOIN target_window w ON s.upid = w.upid
WHERE dur > 0
AND (is_main_thread = 1 OR thread_name = 'RenderThread')
AND ts < w.end_ts
AND ts + dur > w.start_ts
),
grouped AS (
SELECT
process_name,
upid,
thread_name,
utid,
slice_name,
COUNT(*) AS slice_count,
ROUND(SUM(overlap_dur) / 1e6, 3) 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, upid, utid, slice_name
LIMIT 20
)
SELECT 'observed_top20_slow_slice_groups' AS metric_status, * FROM grouped
UNION ALL
SELECT
CASE (SELECT COUNT(*) FROM candidate_windows)
WHEN 0 THEN 'missing_marker'
WHEN 1 THEN 'no_slow_slices_in_observed_data'
ELSE 'ambiguous_marker'
END,
NULL, NULL, NULL, NULL, NULL, NULL, NULL
WHERE NOT EXISTS (SELECT 1 FROM grouped);
"""

files = sorted(glob.glob("traces/*.perfetto-trace"))
if not files:
raise ValueError("No input traces")
tp_bin = Path(os.environ["TRACE_PROCESSOR_BIN"])
if not tp_bin.is_absolute() or not tp_bin.is_file():
raise ValueError("TRACE_PROCESSOR_BIN must be an existing absolute file path")
config = BatchTraceProcessorConfig(
tp_config=TraceProcessorConfig(bin_path=str(tp_bin))
)
outputs = []
for offset in range(0, len(files), 4):
with BatchTraceProcessor(files[offset:offset + 4], config=config) as btp:
outputs.append(btp.query_and_flatten(QUERY))
result = pd.concat(outputs, ignore_index=True)
print(result) # The filesystem resolver adds _path to identify each trace.

This example closes each batch after at most four traces; four is a tunable starting point, not a memory guarantee. Parsed data for the current batch remains resident, so limiting load concurrency does not replace smaller batches. Load/query failures raise by default. Switching to INCREMENT_STAT drops failed traces, requiring explicit failure counts so the sample population cannot shrink silently.

From Ad Hoc Queries to Stable Metrics

The batch example demonstrates API mechanics; it does not replace per-file metadata and quality checks in the earlier program. Preserve no-result files through metric_status. no_slow_slices_in_observed_data means no threshold exceedance among observed slices, not that the application had no jank. Top 20 is a display limit, not the full set of slice groups.

Ad hoc SQL is for exploration; stable metrics are for long-term comparison. Before integrating a query into an engineering workflow, define at least four sets of constraints:

  • Output contract: fix column names, units, and sort order. dur > time_from_ms(5) is better suited to long-lived scripts than a bare 5e6. A name such as overlap_total_ms tells the reader that the value has already been intersected with the target window.
  • Identity: use utid/upid for multiprocess scenarios. Thread names are readable labels, not unique identifiers; main, RenderThread, and Binder:* can all repeat.
  • Target window: taps, launches, the first scrolling frame, and camera opening cannot be assessed solely through whole-trace totals. Include window_source/window_name/window_start_ms/window_dur_ms in the report.
  • Data quality: every trace needs detailed stats and downgrade fields. quality_grade/degrade_reason/missing_sources/fallback_used serves a regression gate better than a single total.

For complex JOINs, look for standard-library views first. thread_slice, process_slice, and thread_or_process_slice reduce basic mistakes. Prefer Trace Summary for long-term projects: the official direction is v2 summary for structured output, rather than starting a new solution around the older v1 metrics.

A useful test is whether the query result can go directly into a version comparison. If someone still has to explain that “this column was called A last time and B this time,” it is not yet a stable metric.

Another easily missed requirement is how to interpret a metric when something goes wrong. “Cumulative duration of main-thread slices over 5 ms” only describes long tasks among the slices; it cannot independently prove jank. “Total Runnable time” only indicates that the thread spent time waiting for CPU and does not replace CPU frequency, load, or scheduler evidence. If FrameTimeline is missing, the report should say metric_status=unavailable, fallback_used=false, and degrade_reason=missing_frame_timeline. If ftrace loss is detected, scheduling metrics should carry sched_evidence_grade=weak.

Nested slices overlap in time. SUM(overlap_dur) is a cumulative slice quantity, not nonoverlapping wall-clock time or CPU time. Nor can parent and child slices simply be added together to calculate thread utilization.

CPU frequency, CPU idle, thermal conditions, and cluster/CPU placement within the same target window fit better as explanatory variables in the report. They usually do not establish a cause on their own, but can explain why the same application slice is slower in one trace.

Regression Questions Require Multiple Traces

A single trace helps locate a problem in one captured occurrence. Multiple traces are needed to answer whether an optimization improves performance consistently. Many performance reports suffer less from the traces themselves than from how the evidence is organized: lots of screenshots and strong conclusions, but no fixed configuration, sample count, data-loss check, or consistent before/after statistics.

For multiple-trace analysis, first fix the input contract. The minimum set is trace_name/scenario/group/device/build/config_id; duration_ms/thermal_state/trace_processor_version/sql_package_version helps rule out environmental and tool drift. Each batch should include at least these fields:

Field Example Purpose
trace_name after-run03.perfetto-trace Locate the original file
scenario feed_scroll_120hz Distinguish scenarios
group before / after Compare versions
device Pixel_8 Aggregate by device model
build UP1A.xxx Identify the system version
config_id ui_jank_v4 Confirm consistent TraceConfig
duration_ms 30000 Assess the collection window
thermal_state nominal Rule out thermal interference
trace_processor_version Actual --version output Rule out parser or standard-library changes
sql_package_version perf-v3 Confirm consistent query logic

These fields can come from filenames, metadata, or a test platform, or be stored in the case package’s metadata.json. Without them, the numbers are difficult to interpret.

This CSV uses hypothetical values and adds conclusion_level/next_action after manual review. Its quality_grade values match those produced by the script above:

1
2
3
4
5
trace_name,group,scenario,window_name,quality_grade,degrade_reason,ftrace_issue_rows,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,no_selected_stats_findings,,0,true,main,inflate,4,38.41,2.10,info,compare_after
before-run02.perfetto-trace,before,feed_scroll_120hz,feed_scroll,no_selected_stats_findings,,0,true,RenderThread,DrawFrame,3,51.02,4.33,info,compare_after
after-run01.perfetto-trace,after,feed_scroll_120hz,feed_scroll,weak,ftrace_stats_findings,1,true,main,inflate,5,71.82,18.40,needs_retrace,recollect_trace
after-run02.perfetto-trace,after,feed_scroll_120hz,feed_scroll,no_selected_stats_findings,,0,true,RenderThread,DrawFrame,2,25.77,1.92,needs_more_runs,collect_more_runs

Do not reduce quality problems to one total. ftrace loss directly affects sched/thread_state/wakeup conclusions, but does not necessarily invalidate all app Track Events. Missing FrameTimeline data affects frame metrics, but may not affect Binder or memory analysis. Retain at least name/source/idx or aggregate by data source, then let regression gates decide whether to recollect, exclude, or downgrade according to each metric’s dependencies.

Do not judge regressions solely by averages, either. Keep the median, p95, worst value, and raw value from each run:

Metric Direction Suggested assessment
FrameTimeline overrun / jank rate Lower is better Inspect p95_frame_overrun_ms, worst_frame_overrun_ms, jank_count, jank_rate, and the jank_type distribution; use valid_frame_count to state the sample size
launch duration Lower is better Define its start and end separately; do not mix it with frame duration
jank count / jank rate Lower is better Retain total frame count, valid frame count, window duration, and proportion
slow binder count Lower is better Examine both the count and whether events cluster in the same phase
CPU Running time Depends Interpret alongside scenario actions and frame windows
RSS/PSS/unfreed native allocations Usually lower is better Inspect peaks, growth slope, and whether values fall again
battery current / power rail Depends Meaningful only for A/B comparisons on the same device under the same conditions

Five runs per group alone cannot guarantee detection of a 1% or 2% change; detectable effects depend on variability, study design, and interval estimates. State sample counts and environmental conditions explicitly. Downgrade conclusions when samples are insufficient, thermal states differ, or quality gates are not clean.

Use Trace Summary for Long-Term Metrics

Direct SELECT queries are flexible, but their output schema changes with the query. Platforms, dashboards, and CI need a stable structure. Trace Summary is designed to place metrics into a unified TraceSummary protobuf.

The current command-line entry point is the summarize subcommand. If you downloaded the trace_processor wrapper earlier, keep using that entry point.

The following minimal spec.textproto demonstrates Trace Summary using a memory metric. Memory is chosen simply to keep the example short. Once the SQL for long slices, frames, Binder, or other metrics stabilizes, those can be migrated in the same way.

Grouping by upid + process_name keeps different lifetimes of identically named processes separate. This calculates a duration-weighted mean of RSS+swap over intervals observed by the module. It is not PSS, a peak, or simultaneous total memory across app processes, and it is not yet clipped to an activity window. Sampling gaps and coverage still need separate reporting.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
metric_spec {
id: "memory_per_process"
dimensions: "upid"
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: "upid"
column_names: "process_name"
aggregates {
column_name: "rss_and_swap"
op: DURATION_WEIGHTED_MEAN
result_column_name: "avg_rss_and_swap"
}
}
}
}

Run this summary spec and request the memory_per_process metric:

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

This version prints textproto by default. Use --format binary and redirect output for binary protobuf. A common protobuf structure does not freeze metric semantics: comparisons still require a fixed spec, module revision, window, and missing-data policy. A upid is meaningful only within its trace.

The source-tree command trace_processor_shell summarize expresses the same operation. The older --summary --summary-spec ... --summary-metrics-v2 ... form remains supported, but new scripts should prefer subcommands so they are easier to follow alongside the official documentation.

Trace Summary suits long-term metrics: memory, launch, frames, Binder, CPU, and power data tracked across versions. For ad hoc troubleshooting, direct SQL remains useful. When migrating frame metrics, output app FrameTimeline metrics separately from SurfaceFlinger/display metrics; do not treat long RenderThread slices as frame duration.

A summary spec must at least define the metric id, dimensions, and value. An individual metric_spec can declare unit and polarity directly—for example, BYTES for memory with LOWER_IS_BETTER. For templates, value_column_specs can declare units and directions separately for multiple value columns. These are presentation hints, not automatic regression decisions; consumers still define thresholds, comparability, and significance.

Another capability useful for platform integration is metadata_query_id. If the spec also defines a metadata query returning key / value, the command line can use --metadata-query, and the Python API can pass metadata_query_id. This carries fields such as device, build, scenario, and config_id alongside the summary, so later aggregation does not have to guess from filenames.

Start with Python + CSV if that fits the project. Once fields stabilize, consumers are established, and metrics need long-term maintenance, migrate the key metrics to Trace Summary.

Common Pitfalls

  • Treat time columns such as ts/dur as nanoseconds by default. Use report names such as dur_ms/overlap_ms, and time_from_ms() for thresholds to avoid unit mistakes. For durations, use ROUND(x / 1e6, 3), not time_to_ms(), which truncates to whole milliseconds. Check track and module definitions separately for counter/value fields.
  • A slice with dur = -1 is an event that remained open when the trace ended. Handle it before intersecting windows or calculating SUM(dur); the examples in this article filter with dur > 0. If the target event itself is unclosed, explain it separately rather than counting it as an ordinary long slice.
  • main and RenderThread are not unique. Filter process_name/upid first, then thread_name/utid. Include upid/utid in reports to avoid collisions between thread names.
  • pid/tid can be reused. Prefer upid/utid in scripts, retaining tid/pid as auxiliary fields when correlating with system logs.
  • A visible UI track does not necessarily correspond to a single raw table. State the data source in the report—for example, thread_slice, thread_state, or actual_frame_timeline_slice—to avoid confusing the presentation layer with the schema.
  • R and R+ mean Runnable; only Running means actual execution. Separate running_ms/runnable_ms/sleeping_ms/uninterruptible_blocked_ms in reports instead of merging them into “thread busy.”
  • If stats reports data loss, avoid strong conclusions. Keep name/source/idx and degrade_reason, and assess the impact according to metric dependencies.
  • Schemas evolve. When a batch script fails, first use PRAGMA table_info(table_name); to confirm that columns exist. Retain trace_processor_version/sql_package_version in the report.
  • If a TraceConfig change affects a metric’s data sources, filtering, sampling, or time range, the results are not directly comparable. Retain config_id and the actual configuration; establish that the difference is irrelevant or recollect before comparing.
  • Keeping only the report, without the original trace, configuration, SQL, and metadata, prevents later reproduction. At minimum, retain the trace filename, configuration id, SQL version, and window source.

Conclusion

Screenshots can mark the problem window. Regression comparisons also need the window source, collection quality, SQL version, and original trace. Without those inputs, different readers may calculate different durations from the same image.

For the next trace you receive, follow this order: check stats, fix the target window and upid/utid, then write long slices, Running/Runnable, frame metrics, and downgrade reasons into CSV or Trace Summary. SQL makes an assessment checkable, but cannot replace collection quality or scenario definition. With the wrong window, lost trace data, or changed configuration, even elegant queries produce untrustworthy numbers.

My open-source project SmartPerfetto connects this workflow to its analysis process: trace-quality checks, scenario-specific PerfettoSQL, and conclusions linked to evidence. Capabilities vary by version; see the historical Six-Week Update and the current repository documentation for scope.

References

  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

Original time-conversion baseline: b23c67be1159e3f0b0657bd97be9e139e0d8ee2c (2026-09-19). The October 2026 review also checked v58.3 source and the Python 0.58.2 distribution.

About Me and the Blog

Follow Android Performance.

CATALOG
  1. 1. Perfetto Series Catalog
  2. 2. From Screenshot Conclusions to Queries Others Can Check
  3. 3. What Is Trace Processor?
  4. 4. Think of a Trace as a Set of Tables
  5. 5. Expressing a Complete Analysis in SQL
    1. 5.1. Collection Quality Determines the Strength of the Conclusion
    2. 5.2. Fix the Target Window First
    3. 5.3. Do Not Find Threads by main Alone
    4. 5.4. Long Slices Identify Suspects Only
    5. 5.5. Runnable Is Not Running
    6. 5.6. Move the Query to the Command Line
  6. 6. Batch Analysis with the Python API
  7. 7. From Ad Hoc Queries to Stable Metrics
  8. 8. Regression Questions Require Multiple Traces
  9. 9. Use Trace Summary for Long-Term Metrics
  10. 10. Common Pitfalls
  11. 11. Conclusion
  12. 12. References
  13. 13. About Me and the Blog