The browser stutter is a blocked WebKit main thread, not the display stack
scope: generic · severity: finding · confidence: proven · subsystem: browser
The question — Epiphany playing YouTube stutters on this device. Two sessions attributed it to the compositor: “the window updates at 12-15 fps”, “the ceiling is TextureMapper compositing 237/315 layers”, “~25 ms per composited frame”. Where is the time actually lost?
The answer — the frame clock is 60 Hz. Between stalls p50 = 16-17 ms
in every arm ever measured here. What the user sees as stutter is a small
number of discrete multi-second blocks of the WebKit main thread, three to
four per 40 s, during media-pipeline activity.
The partitioning instrument is two probes in the same page at the same time:
| probe | needs | max stall |
|---|---|---|
requestAnimationFrame |
compositor delivering frames | 2762 ms |
setTimeout chain |
main thread only | 2291 ms |
setTimeout needs no compositor, no GPU, no DPU and no panel. It freezes with
the same period and nearly the same magnitude. The display stack cannot be
the cause of these freezes.
What this rules out — each measured on this device, not argued:
- Compositor / TextureMapper / tile size / layer count. The
setTimeoutresult above. Also: idlem.youtube.com– the same 315 composited layers, same tile size, same page – runsp50 17 / p99 20 / max 22 mswith zero frames over 33 ms. A compositor that can hold a perfect 60 Hz on the page while idle is not what drops 3 seconds while playing. - Thermal throttling. The coolest arm stalled worst: 67.7 C, cooling state 0 (never engaged), GPU 257 MHz, and a 1221 ms stall. An arm pinned at 1.03 GHz and actively stepping (74.8 C, cool state 3->6) had zero stalls over 100 ms. See the-msm8998-thermal-trip-is-a-cliff – that bug is real and worth fixing, but it is not this.
- Video resolution. 1440p / 1080p / 720p: 39.2 / 42.2 / 39.9 fps, p50 19.5 / 19.7 / 20.7 ms. Halving the decoded pixels twice changed nothing.
- CPU clock. No monotonic relation: 1804 MHz -> 50.2 fps, 2208 -> 43.7, 1574 -> 39.9, 576 -> 39.2, 499 -> 42.2.
- GPU runtime-PM autosuspend.
power/control=onvsauto, alternated twice:runtime_suspended_timedelta was 0 in every arm – the GPU never suspended during playback, soautosuspend_delay_ms=250was never in the frame path. Two identicalautoarms differed more (40 vs 7 stalls) thanondiffered fromauto. - Swap and memory pressure. Across a 40 s window containing three
multi-second stalls:
pswpin,pswpout,pgmajfaultidentical before and after; global PSI memorysome totalunchanged. 610 MB was in swap and none of it moved. - cgroup
memory.highthrottling (the decaying-penalty shape is its signature, so it was checked):memory.high=max,memory.events high=0,memory.pressure total=0. - JSC garbage collection as the large stalls.
JSC_logGC=1shows real pauses, but bounded: EdenCollectionp=138.59 ms, cycle 211.7 ms; FullCollectionp=92.19 ms, cycle 176.9 ms. GC explains the 100-400 ms tier, not the 2-3 s tier.
What is established about the block — the main thread sleeps, it does not
compute. Sampling every thread of every WebKit process at 100 ms while the page
stalled: main thread __futex_wait 176 samples and do_sys_poll 401 vs R
169, with other threads in R. Aggregate main-thread state in a separate run
was S=100%. So it is waiting on a lock or a peer, with ~23% of samples in
__futex_wait against ~28% of wall time lost – it is blocked, not busy.
The stalls come in a repeating decaying series, e.g.
rAF 2153, 916, 596, 415 | 2281, 935, 592, 243 | 2139 and
setTimeout 1428, 657, 487, 265 | 1452, 664, 461 | 1370, 627, 380,
period roughly 13 s.
They track media-pipeline activity, not successful playback: a page whose
player never started (readyState 0, paused) but which was churning to load
media stalled 3898 ms, while a page whose player sat cued and idle had none.
Named: the stall is software rasterisation. Correlating the page’s own stall timestamps with a 50 ms whole-process thread timeline (every thread of every WebKit process plus phoc, intersected with the stall windows) gives the same signature in 5 of 5 stalls:
| thread | during stall | baseline |
|---|---|---|
| WebKitWebProcess main | 76-91% | 69.9% |
| SkiaCPUWorker | 32-46% | 10.5% |
| ThreadedCompositor | 22-36% (down) | 54.7% |
SkiaCPUWorker runs 3-4.5x above baseline in every stall while the compositor
thread goes quiet waiting on it. A large synchronous software raster job is
blocking the main thread. Aggregate sampling cannot see this – the stalls are
~15% of wall time and idle threads swamp the histogram – and sampling the
wrong pid is easy: name-matching finds the bwrap wrappers, whose main thread
never burns CPU. That mistake produced an earlier, wrong “main thread is
S=100%, blocked on a futex” reading; the page’s web process is the one owning a
ThreadedCompositor thread.
And CPU rasterisation is itself a workaround. It is on because
WEBKIT_SKIA_ENABLE_CPU_RENDERING=1 avoids a540 rendering faults. Measured
A/B on the same page: GPU rasterisation cuts the worst stall from 2049 ms to
568 ms with zero GPU faults, but renders visibly corrupt icons (the
like/save/flag glyphs fill with dithered garbage) with page state held
identical across arms via a DOM scrollIntoView before every capture.
FD_MESA_DEBUG=noubwc and sysmem do not fix it; noubwc makes it worse.
UBWC is not involved, despite being an attractive theory for blocky
multicolour garbage: ubwc_ok = is_a6xx(screen) in freedreno_resource.c, and
a5xx never registers the modifier is_format_supported hook, so a540
advertises linear only and cannot select a UBWC layout. a5xx also cannot render
to UBWC – fd5_gmem.c emit_mrt() hardcodes RB_MRT_FLAG_BUFFER to zero,
under a “when we support UBWC” comment. Any A/B of FD_MESA_DEBUG on the
browser also cannot explain corruption in the phosh panel, which is a different
process that never saw the variable.
So browser smoothness turns on correct GPU rasterisation on a540 in freedreno, not on a compositor or kernel knob. Until that lands the choice is correct-and-stuttery (CPU raster) or smooth-and-corrupt (GPU raster).
Still open — what holds the lock. Hardware decode is not obviously it
(the software-decode arm stalled too, though that arm is confounded: its video
never played). Next: userspace stacks on the main thread during a stall
(perf record -g on the WebProcess, or gdb attach at a stall), and the
GStreamer/MSE append path.
How it was established, and what would overturn it — both_start.js /
both_read.js (rAF + setTimeout in one page, read through the remote
inspector). Any arm that shows setTimeout clean while rAF stalls would
move the cause back to the compositor. Note that every browser number taken
before this ran under WAYLAND_DEBUG=1 – see
wayland-debug-is-not-a-free-instrument.
