8 min read

AI-Assisted Performance Work: Reading Traces Together

A Perfetto trace won't fit in a context window and summarising it destroys the signal. Querying it with SQL will. The loop that makes a model useful on real performance work, what it reliably gets wrong, and the part that doesn't compress at all.

AIPerformancePerfettoAndroidWorkflow
HR

Hessam Rastegari

Senior Android Developer · 12 years shipping Android

TL;DR — Don't paste the trace; paste the query results. Perfetto's trace processor exposes SQL, and writing that SQL is exactly what a model is good at. The loop: it writes a query → you run it → you paste back twenty rows → it proposes the next query, never a conclusion. Because it only ever sees extracted facts, verification is looking at a number already on your screen. It reliably gets three things wrong: reading correlation as causation, optimising a slice that doesn't matter, and inventing table names. And it cannot tell you whether the fix is worth shipping.

Why the obvious approach fails

A 10-second Perfetto capture is tens of megabytes of protobuf. It doesn't fit in a context window, and every way of shrinking it destroys what you needed:

  • A screenshot loses the timings and the tree structure — you're asking a model to eyeball a flame chart.
  • "Summarise the trace" is the whole problem restated. The summary is the analysis.
  • The exported JSON is still enormous and mostly noise.

The failure is trying to hand over the artefact. You don't need to. Performance analysis is a sequence of questions, and the trace already has a query interface.

The trace processor is the interface

Perfetto ships trace_processor, which loads a trace into an in-memory SQL database. You can use it from the UI (the Query (SQL) page) or the shell:

trace_processor_shell trace.perfetto-trace
# or serve it and use the UI against your local instance
trace_processor_shell --http trace.perfetto-trace

That changes the shape of the problem completely. Instead of "read this trace", the task becomes "write SQL against a documented schema" — high-volume, mechanical, exactly the thing models do well, and instantly verifiable because you run it.

The loop

1. Describe the symptom, ask for a query

Perfetto trace, Android app, jank on scroll.
Give me a trace_processor SQL query for the 20
longest slices on the main thread, with total and
mean duration in ms. Query only.
select s.name,
       count(*)          as n,
       sum(s.dur)/1e6    as total_ms,
       max(s.dur)/1e6    as max_ms
from slice s
join thread_track tt on s.track_id = tt.id
join thread t using(utid)
where t.is_main_thread = 1
group by s.name
order by total_ms desc
limit 20;

2. Run it, paste back the rows

Twenty rows. A few hundred bytes of ground truth instead of 40MB of guesswork.

name                          n    total_ms  max_ms
Choreographer#doFrame        312     4820     186
inflate                       48     1910     142
Lock contention on ...        96      880      47
nativeDecodeStream            12      640      98

3. Ask for the next question, not the answer

Don't conclude yet. Given these, what's the next
query that would distinguish between (a) inflation
on the main thread and (b) lock contention behind it?

This is the discipline that makes the whole thing work. A model asked to conclude from four rows will produce a confident, well-written, frequently wrong diagnosis. A model asked for the next query produces something you can run in eight seconds and check.

4. Repeat until the data says something

Typically four to six rounds. Useful follow-ups it will suggest:

-- Who is holding the lock the main thread waits on?
select s.name, s.dur/1e6 as ms, t.name as thread
from slice s
join thread_track tt on s.track_id = tt.id
join thread t using(utid)
where s.name like 'Lock contention%'
order by s.dur desc limit 20;

-- Frames over budget, with their janky reason
select ts, dur/1e6 as ms, jank_type
from actual_frame_timeline_slice
where dur > 16600000
order by dur desc limit 20;

-- Binder calls blocking the main thread
select s.name, s.dur/1e6 as ms
from slice s
join thread_track tt on s.track_id = tt.id
join thread t using(utid)
where t.is_main_thread = 1 and s.name like 'binder%'
order by s.dur desc limit 20;

actual_frame_timeline_slice in particular is worth knowing — it's Android's own jank classification, and it saves you inferring frame boundaries by hand.

Why this is safe in a way most AI code work isn't

The usual risk with generated output is that verification is expensive. Here it's inverted twice over:

  • The model never sees the trace, so it cannot hallucinate about it. It can only be wrong about what to ask, and a wrong query costs eight seconds and produces obviously useless rows.
  • Every claim it makes is about numbers already on your screen. "Inflation dominates at 1.9s total" is checkable by looking two lines up.

That's the cleanest verification story available in AI-assisted work: the model does the part it's good at (schema recall, query construction, pattern naming), and the ground truth never leaves your machine.

Where it's genuinely strong

Schema recall. Nobody remembers the join from slice through thread_track to thread between one performance investigation and the next. This alone removes twenty minutes of documentation-reading from every session.

Naming unfamiliar slices. nativeDecodeStream, Lock contention on a monitor lock, bindApplication, ResourcesManager#getResources — knowing what emits them and what they usually mean is exactly the kind of broad, shallow knowledge a model has and most engineers have in patches.

Recognising shapes. "A binder transaction inside your measure pass means a synchronous IPC on the main thread during layout — look for a PackageManager or Settings read in a composable." That's a real inference from the structure, and it's frequently right.

Writing the benchmark afterwards. Once you have a hypothesis, "write a Macrobenchmark that isolates this" is a well-specified task with a cheap check.

Where it's wrong — reliably

1. Correlation read as causation. Two slices adjacent in time become "the decode is causing the frame drop", stated with complete confidence. Sometimes true, often the reverse, occasionally both are downstream of a third thing. It has no way to tell, and it will not say so.

2. Optimising the wrong magnitude. It will suggest a thoughtful improvement to a 0.2ms slice while a 40ms inflate sits directly above it in the tree. It has no sense of your frame budget or which numbers matter. You keep the sense of scale.

3. Inventing table and column names. trace_processor has a real, versioned schema. A join that looks plausible and references frame_slice will fail — check the error, paste it back, move on. Cheap failure, but it means you can't accept a query as evidence of anything until it runs.

4. Assuming a desktop-profiling mental model. It will sometimes reach for concepts that don't apply to Android's rendering pipeline. Anything about frames, vsync, or the RenderThread deserves a second look.

What doesn't compress at all

Deciding the fix is worth shipping.

The model can establish that the main thread blocked for 40ms on a disk read during screen open. It cannot tell you that this screen is opened twice a month by internal users, that the fix touches the module a migration is halfway through, or that the 12ms you'd win is invisible next to the 200ms network call before it.

That judgement is the actual performance skill. Everything above just gets you to the point where you can exercise it faster — which is the honest version of the win: the investigation compresses, the decision doesn't.

The rule

Give it the schema and the numbers, never the artefact — and let it propose the next query, never the conclusion. Perfetto's SQL interface turns a 40MB blob into a series of small, checkable questions, which is precisely the shape of problem where a model earns its place. Keep the sense of scale and the decision to yourself; hand over the schema recall and the typing.

Keep reading