Mobile Computing · Mobile Performance

Attributing Jank to a Specific Pipeline Stage

A method for turning a frame timeline into one responsible stage, why the naive comparison of durations misleads, and how to distinguish queue latency from work latency.

A dropped frame has one cause, but a profile shows dozens of things that were happening. The method that matters is the one whose completion was on the critical path to presentation, and the critical path is not the same as the longest method. Attribution is therefore a scheduling problem, not a sorting problem, and it needs a timeline rather than a summary.

Why the longest method is usually not the answer

A profile lists call durations, and the instinct is to sort by duration and fix the top entry. That is wrong whenever the frame’s work overlaps with anything else. A 12 ms method that runs while the render thread is also busy contributes to the frame’s length only to the extent that it is not covered by the other stage. A 4 ms method that runs at the very end of the chain, after everything else has finished, sets the frame’s completion time directly.

The distinction is between time that is exposed and time that is hidden. Total frame length equals the sum of the durations on the critical path, and the critical path is the longest chain through the dependency graph, not the sum of all work. On a two-stage pipeline where the main thread produces commands and the render thread consumes them, the critical path within a frame is the main thread’s chain, then the render thread’s chain, then the GPU’s, in sequence, because each depends on the previous. Within each stage, the critical path is the longest chain of dependent calls, which a flat profile does not show.

Reading a timeline from the deadline backwards

The productive direction is backwards from the missed vsync. Start at the presentation timestamp of the frame that was late, subtract one period, and ask what had to finish by that moment. Then walk earlier: the compositor’s snapshot required a complete buffer, which required the GPU to have finished, which required the render thread to have submitted, which required the main thread to have recorded commands. Each of those requirements is a constraint with a timestamp, and the stage whose timestamp is closest to its own deadline is the one that ran out of time.

Three gaps carry most of the diagnostic value.

The gap between the vsync callback and the start of the main thread’s traversal is queue latency: the main thread was busy with something else when the frame callback became due. It points at a long task earlier on the queue, described in The Main Thread Message Queue and Why a Single Long Task Delays Frames.

The gap between the end of the main thread’s work and the start of the render thread’s work is a buffer or handoff problem, not a work problem. The render thread was waiting for a buffer, which means the buffer queue was empty and the app is one buffer short, or the render thread was descheduled because the CPU was oversubscribed.

The gap between the end of the GPU’s work and the presentation timestamp is either waiting for the next vsync, which is healthy, or waiting for the compositor, which is not the app’s problem and cannot be fixed by app code.

Instrumenting without distorting

The attribution depends on timestamps the app emits at the boundaries of its own stages. Two rules keep the instrumentation honest.

First, the instrumentation must be cheaper than the resolution it needs. A trace event that writes a string and allocates a record costs microseconds, which is acceptable for per-frame boundaries and unacceptable inside a loop. Trace points belong at stage boundaries, not around every call.

Second, timestamps must come from the same clock the platform’s own events use, or the comparison is meaningless. A monotonic clock is required, and the platform’s tracing infrastructure already uses one; reading the wall clock and comparing it to a trace event mixes a clock that can jump with one that cannot.

// Stage boundaries for one frame. Sections are opened and closed around
// the work, so the trace shows where the time went inside the frame.
private val tracing = Trace.isEnabled()

fun onFrameStart(vsyncNanos: Long) {
    frameStartNanos = vsyncNanos
    if (tracing) Trace.beginSection("frame")
}

fun onTraversalDone() {
    if (tracing) Trace.endSection()      // closes "frame" at the last draw
}

fun measureAndLayout(container: View, specW: Int, specH: Int) {
    if (tracing) Trace.beginSection("layout")
    try {
        container.measure(specW, specH)
        container.layout(0, 0, container.measuredWidth, container.measuredHeight)
    } finally {
        if (tracing) Trace.endSection()  // closed even if layout throws
    }
}

The finally matters because an unbalanced section corrupts the trace for every later frame, and the trace is exactly the artefact being used to diagnose the problem. The pattern that matters beyond that is that section names are stable and few: a hundred distinct names produce a trace that cannot be aggregated, while five produce one that can be summed per frame and compared across frames.

The two distributions that must be separated

Averages hide the phenomenon. Jank is a tail event, and a mean frame time of 8 ms is compatible with 1% of frames taking 40 ms. The distribution to look at is the per-frame presentation interval, expressed in periods, and the statistic is the fraction of frames at two or more periods.

Two populations exist inside that distribution and they need different remedies. Isolated spikes, where one frame in hundreds is late, are usually caused by something that happens rarely: a collection, a class load, a disk read, a binder call that had to wait. Sustained jank, where most frames are late, is a throughput problem in one stage, and fixing it requires making that stage cheaper rather than finding an outlier.

Separating them is a matter of plotting the frame intervals over time rather than aggregating. A trace where spikes are uniformly scattered indicates an external event; a trace where spikes cluster around a screen transition indicates the transition’s own cost; a trace where every frame during scrolling is late indicates the scroll path.

Correlation with the work, not with the symptom

Once a stage is suspected, the test is to change only that stage’s cost and observe whether the jank moves. Removing a layout pass, pre-building a string, caching a decode: each change is a hypothesis about the critical path. If the jank persists with the same distribution, the stage was not on the critical path, and the profile’s top entry was a red herring.

The confound to control is the refresh rate. On a variable-rate display the period itself changes in response to the app’s behaviour, so a change that makes the app faster can lower the period and produce the same number of dropped frames. Comparing frame counts across a change requires recording the refresh rate alongside the interval, or pinning the rate for the duration of the comparison.

The second confound is thermal state. A device that has been running a graphics-heavy workload for a while has reduced its clocks, so the same work takes longer. A comparison between two builds run back to back is therefore biased against the second, and a comparison between a cold device and a warm one is biased in the other direction. This is treated in Thermal Throttling and Its Effect on Sustained Frame Rate.

When the responsible stage is not in the app

The frame timeline includes stages the app does not own: composition, the system’s own surfaces, and the display’s scanout. When the critical path runs through one of them, app-side optimization cannot help, and the useful conclusion is to stop optimizing rather than to keep trying.

Two cases are common enough to recognize. A frame that is ready before the compositor’s deadline but is presented one period later than expected indicates that the compositor chose a different frame, usually because another surface was also updating and the compositor had to composite both. A frame whose GPU work is attributed to the app but whose cost is dominated by a texture upload that the app triggered indirectly through a first-use path is app-caused but not app-visible in the app’s own trace sections.

The third case is a frame that is late only when the app is not the foreground app, which points at scheduling rather than at work: the app’s threads receive less CPU because the scheduler has decided another process is more important. Measuring under those conditions measures the scheduler.

A worked attribution

Consider a scroll that drops one frame in five, with no single stage exceeding the period on the dropped frames. The timeline shows: vsync to main-thread start, 2 ms; main thread work, 7 ms; render thread, 4 ms; GPU, 6 ms. The sum is 19 ms against a 16.7 ms period, yet no stage is over. The explanation is that the stages do not overlap perfectly: the render thread’s work cannot start until the main thread’s is done within a frame, so the in-frame chain is 17 ms, and the GPU’s 6 ms then runs into the next frame’s window. The frame is late by the amount the in-frame chain exceeds the period, which is small, and the fix is either to shorten the main thread’s work or to allow the GPU to overlap with the next frame’s main thread work by having enough buffers.

With three buffers, the GPU’s work for frame N overlaps the main thread’s work for frame N+1, and the constraint becomes the per-stage maximum rather than the sum. The same code then presents every frame. The attribution in that case is not “the app is too slow” but “the app is exactly at the boundary and has no slack”, and the honest measurement is the margin, not the mean.

References

  1. Slow rendering — Android Developers
  2. Perfetto trace analysis documentation — Perfetto project
  3. Tracing documentation: systrace and atrace — Android Open Source Project