Docs (P15): present-wait timeline; the display release gates the frame, not the GPU.

This commit is contained in:
2026-10-10 04:14:02 -04:00
parent a759b4b72d
commit d0d808fc80
2 changed files with 359 additions and 1 deletions
+1 -1
View File
@@ -149,7 +149,7 @@ The plan and its measurements are in [PLAN-P15.md](PLAN-P15.md); the audit is in
- Open: whether a static TLS model (initial-exec) on the hot variables can remove the 0.05 ms in a dlopen'ed lib on
bionic; and what actually makes the api29 Magma build faster (not isolated).
- The shipped build stays API 26. The FCL-embedded 29 copy still needs FCL's own minSdk switch.
5b. IN PROGRESS 2026-10-09, offline (no new capture): present-wait timeline from the existing P15 traces (fbm, c4, c1c2: sched, kgsl submit/retire, gfx atrace, SF frametimeline). Confirm against the TLS agent's base-vs-change Perfetto traces on e70925d3.
5b. DONE 2026-10-09 (offline), see [PRESENT-WAIT.md](PRESENT-WAIT.md). Cause: the display buffer release (~every other frame renders into the buffer just handed back) gates the whole single-batch frame, then ~0.8 ms GPU start latency + 0.5 ms execution; the next queueBuffer throttles on it. MobileGL CPU is not on that path. Options: more Magma swapchain images, gate only the window pass, present off the GL thread. Original scope: present-wait timeline from the existing P15 traces (fbm, c4, c1c2: sched, kgsl submit/retire, gfx atrace, SF frametimeline). Confirm against the TLS agent's base-vs-change Perfetto traces on e70925d3.
- Finding (egc profiles, e70925d3, cpuhunt, monolith): render-thread on-CPU + off-CPU = frame time.
| | Espryt | Magma |
|---|---|---|
@@ -0,0 +1,358 @@
# P15 present wait on FCL: per-frame timeline
Offline analysis of the existing P15 Perfetto traces. Scene: Minecraft 1.21.5 vanilla, frozen world,
cpuhunt clocks (CPU 1497/1248 MHz, GPU 903 MHz, FCL pinned to cpu2-6), display at 165 Hz (6.06 ms
vsync). All 35 runs used are VALID. Frames counted: Espryt monolith 11049 (fbm/y, c4/v, c4/w), Magma
monolith 10938 (c1c2/t, c1c2/x, fbm/y), Magma inproc 10079 (c1c2/t, c1c2/x), Espryt inproc 4639
(c1/u), MobileGlues 14398 (c1c2, fbm, c4).
## Answer
The producer throttle in `queueBuffer` waits for the previous frame's GPU-completion fence. That fence
is late because of the display, not because the GPU is busy:
1. About every other frame (45-56 %) renders into the window buffer that the display has just handed
back. Its release fence is still pending, and it signals only at the display's next commit-done
(0.24 ms after the `crtc_event` kernel thread runs).
2. The whole frame's GPU work waits behind that fence. MobileGL submits the frame as one batch at the
swap (Espryt: one driver flush at `eglSwapBuffers`; Magma: the frame's first `vkQueueSubmit`
carries the acquire-semaphore wait with `ALL_COMMANDS`).
3. Once the buffer is released, the batch still needs about 1.3 ms before its fence signals: about
0.8 ms of GPU start latency plus 0.5 ms of execution.
4. Meanwhile the CPU has already rendered the next frame. Its `queueBuffer` throttles on that late
fence.
How the candidates come out:
- **(b) The wait for the compositor's buffer release is the trigger.** 99.4-99.6 % of all throttle
time follows such a gated frame. Frames whose buffer was already released cause no wait (0.00
ms/frame on every arm).
- **(a) Late, single-batch submission is an amplifier.** It puts the whole frame, not just the final
window pass, behind the gate.
- **(d) Something else: a fixed GPU start latency of about 0.8 ms** whenever a batch becomes runnable
on an idle GPU. It does not depend on how long the GPU was idle (0.3 ms or 4 ms), and it does not
count as GPU busy. It is the larger part of the time after the release.
- **(c) SurfaceFlinger GPU composition is ruled out.** `hasClientComposition` is 0 on every
SurfaceFlinger frame in every run, so composition is device-only and SurfaceFlinger does no GPU
work.
The GPU is idle about 4 ms per burst and runs about 0.5 ms per frame. That is the 15 %
`gpu_busy_percentage` the frequency sampler records.
## What the throttle waits on (mechanism)
- FCL's SurfaceView is a BLAST surface: the BufferQueue and its consumer (BLASTBufferQueue) live in
the FCL process. Both EGL and the Android Vulkan swapchain connect as `NATIVE_WINDOW_API_EGL`, so
`BufferQueueProducer::queueBuffer` ends with `lastQueuedFence->waitForever` (the `waitForever`
slice) on the previous queued buffer's acquire fence, which is its GPU-completion fence.
- The display runs at 165 Hz. SurfaceFlinger commits about 10.1-10.3 ms ahead of the target vsync
(`beginFrame ... vsyncIn 10.1ms`), and the HWC commit takes 3.2-4.1 ms. One FCL buffer per vsync is
latched (about 813 per 5 s). Every other queued frame is dropped by replacement in the BufferQueue,
which runs in async (MAILBOX / swap-interval-0) mode.
- BLAST keeps 2-3 buffers in flight to SurfaceFlinger (the `BufferTX` counter). Magma's swapchain has
5 images: MAILBOX, `max(FIF hint 3, surface minImageCount 5)`. That leaves the app 2 buffers: one
being rendered and one queued, which may be dropped. The Espryt and MobileGlues EGL surfaces show
the same A/B alternation, so they also leave the app 2 buffers (inferred, not read from the
driver).
- Each vsync, SurfaceFlinger hands back the buffer leaving the screen, with a release fence that
signals when the display flips. The free list is FIFO, so the app's next dequeue takes that buffer.
This is a gated frame (A). The frame after it (B) gets a dropped buffer, which is already free.
- GPU timing comes from libgui's `FenceMonitor` threads in the FCL process. They exist because atrace
`gfx` is on:
- `GPU completion` gives one `waiting for GPU completion N` slice per queued fence; its end is the
fence signal time E;
- `HWC release` does the same per dequeued buffer; its end is the release time R, and a
`... has signaled` instant marks a buffer that was already free.
- They are cross-checked against kernel-thread scheduling:
- `kgsl_hwsched` runs at R (median offset -0.00 ms): the kgsl sync object is satisfied and the
batch is dispatched;
- `kgsl-events` runs 0.12-0.16 ms before E (the fence signal);
- `kgsl-events` is also what wakes the presenting thread out of `waitForever` in 100 % of long
waits, with a 60-70 us runnable latency (clocks pinned, no frequency changes in the window).
## Espryt monolith: the typical cycle
Anchor: swap entry of a gated frame A = 0. Values are medians, with p10..p90 in brackets. Frame A is
followed by frame B; about 1.5 frames per vsync. Medians of different events do not compose exactly.
Per frame pair, B's throttle ends when A's fence signals. `kgsl-events` wakes the presenting
thread directly (+0.06 ms), so W1_B lands at or slightly before the FenceMonitor's E_A, which is
about 0.1 ms late.
```
Espryt monolith
t(ms) -3 -2 -1 0 1 2 3 4 5
|---------|---------|---------|---------|---------|---------|---------|---------|--
render thr ============A cpu========d===S ============B cpu========d===S~~~~~
display v R
GPU (FCL) .........A held........+++st+++##A####B###
fence A E
(d = dequeue, S = swap entry, ~ = queueBuffer throttle, v = crtc_event, R = buffer release,
. = A's batch submitted but held by the release fence, st = GPU start latency, # = GPU execution)
```
1. **-2.90** [-3.44..-2.47]: the previous swap returns and A's CPU frame starts on the render thread.
2. **-0.43** [-0.58..-0.38]: the first default-framebuffer use makes the Adreno EGL dequeue lazily.
A gets buffer X, whose release is pending.
3. **0.00**: `eglSwapBuffersWithDamageKHR(A)`. The driver flushes A's whole frame as one batch, and
kgsl holds it behind X's release fence. The GPU stays idle.
4. **0.09 to 0.10**: `queueBuffer(A)` throttles on the previous frame's fence. That fence has already
signaled, so there is no wait.
5. **0.19**: the swap returns and B's CPU frame starts.
6. **1.97** [0.85..4.66]: `crtc_event` runs (display commit-done).
7. **2.27** [1.11..4.98]: R_A, X is released. `kgsl_hwsched` dispatches A's batch at the same
instant.
8. **2.27 to about 3.1**: GPU start latency of about 0.8 ms, for dispatch and power-up (not GPU busy).
9. **2.71**: B dequeues a buffer that is already free.
10. **3.09**: `eglSwapBuffers(B)`. B's batch queues behind A's in the in-order context.
11. **About 3.1 to 3.58**: A executes on the GPU (0.5 ms).
12. **3.20** [2.69..4.11]: `queueBuffer(B)` starts throttling on E_A.
13. **3.58** [2.42..6.31]: E_A. `kgsl-events` signals, and the render thread is runnable after 60
us.
14. **About 3.6**: `queueBuffer(B)` returns. B's batch then runs back to back with A (E_B is about
E_A + 0.5), and the GPU idles about 4 ms until the next release.
The throttle is 0.61 ms/frame on average. 58 % of the frames that follow a gated frame wait, with a
median of 2.0 ms [0.35..3.35]. The two halves of the wait:
- **0.27: display before release.** B is already throttling before the display has released A's
buffer.
- **0.33: GPU after release.** About 0.20 of this is start latency and about 0.13 is A's execution.
Critical path for B's wait: display commit-done (`crtc_event`), then +0.24 ms to the release fence of
A's buffer, then the kgsl sync object, then `kgsl_hwsched` dispatch, then about 0.8 ms of GPU start,
then about 0.5 ms of A's execution, then `kgsl-events` signals E_A, then the render thread wakes
(+60 us) and `queueBuffer(B)` returns. MobileGL CPU work is not on this path.
Worked example: `fbm/y-DirectGLES-r1`, frames 648-649, from `win.py`, offsets from swap 648:
- crtc_event 1.874;
- HWC release thread wakes 2.054, R 2.09;
- `kgsl_hwsched` 2.082;
- swap 649 at 2.471, throttle from 2.57;
- `kgsl_hwsched` (retire) 3.247, `kgsl-events` 3.317;
- the render thread resumes at 3.38 (a 0.82 ms wait);
- 649 completes at 4.056, 0.48 ms after 648, back to back.
## Magma monolith
```
Magma monolith
t(ms) -3 -2 -1 0 1 2 3 4 5
|---------|---------|---------|---------|---------|---------|---------|---------|--
render thr d=========A cpu=========S d=========B cpu==========S~~~~wait~~~~
display v R
GPU (FCL) .........A held.........++++st+++##A####B###
fence A E
```
1. **-2.36**: `AcquireNextImageKHR` runs right after the previous present. A gets X, whose release is
pending; `HWC release` waits on it.
2. **-2.36 to 0**: A's CPU frame. All GPU work is recorded and nothing is submitted. There are 1.33
`QueueSubmit` per frame: the first comes 2.34 ms after the previous present, and the last 0.07 ms
before `QueuePresentKHR`.
3. **0**: `QueueSubmit`, then `QueuePresentKHR(A)`. The first submit of the frame carries the
image-available semaphore wait with `VK_PIPELINE_STAGE_ALL_COMMANDS_BIT`
(`SubmitPendingCommandBuffer` / `FrameContext::GetSubmitInfo`, `imageAvailableSemaphoreConsumed`),
so the whole frame is gated. `queueBuffer(A)` has no wait.
4. **0.20**: B acquires a buffer that is already free.
5. **2.11**: `crtc_event` runs.
6. **2.41** [1.64..5.46]: R_A. `kgsl_hwsched` dispatches A's batch.
7. **2.66 to 2.75**: B submits and presents, then throttles on E_A.
8. **3.79** [3.06..6.88]: E_A, which is R + 1.39 (about 0.87 start latency + 0.52 execution). B's
throttle ends about here.
The throttle is 0.71 ms/frame: display before release 0.26, GPU after release 0.45. 85 % of the
frames after a gated frame wait, with a median of 1.21 ms. Magma waits more per frame than Espryt
because it is faster on CPU: B reaches its throttle about 0.45 ms earlier relative to the release.
## Inproc arms (the apply thread presents)
```
Magma inproc (apply thread)
t(ms) -3 -2 -1 0 1 2 3 4 5
|---------|---------|---------|---------|---------|---------|---------|---------|--
apply thr =d==A cpu====S d===B cpu===S~~~~~~~~~~~~~~wait~~~~~~~~~~~~~~~
display v R
GPU (FCL) .............A held............++++st+++###A#####B###
fence A E
Espryt inproc (apply thread)
apply thr ======A cpu=d====S ======B cpu==d==S~~~~~~~~~~wait~~~~~~~~~~~
display v R
GPU (FCL) ...........A held...........++++st+++##A####B###
fence A E
```
Magma inproc:
1. **-1.21**: acquire, release pending.
2. **0**: A's submit and present on `mgl-srv-apply`.
3. **0.20**: B acquires a free buffer.
4. **1.41 to 1.52**: B presents, then throttles on E_A.
5. **2.82**: `crtc_event` runs.
6. **3.12**: R_A.
7. **4.59**: E_A, which is R + 1.46 (0.90 start latency + 0.56 execution). B's throttle ends here, a
3.0 ms wait in the median waiting pair.
The throttle is 1.28 ms/frame: display before release 0.69, GPU after release 0.58. 96 % of the
frames after a gated frame wait. Espryt inproc has the same shape: throttle 1.08 ms/frame
(0.54 + 0.53), with R at 2.79 and E_A at 4.20.
The chain reaches the GL thread (Thread-14) too. It sleeps 1.3-1.6 ms/frame, and 0.7-1.1 ms/frame of
those sleeps end within 0.3 ms of the apply thread's throttle ending. So the chain is: display, then
GPU, then the apply thread's `queueBuffer`, then the present credit, then the GL thread.
This explains the HANDOFF's "about 0.3 ms per frame beyond the apply CPU is unexplained". It is not
CPU. The apply thread's frame is its CPU plus this throttle, and the throttle grows as the presenting
thread gets faster, because the release is locked to the display and 1.3 ms of GPU path is chained
after it. Present credit 2 helps for the same reason (+3-7 % measured in HANDOFF): it lets the GL
thread run on while the apply thread sits in the throttle.
## MobileGlues control
```
MobileGlues
render thr =====A cpu==d=S =====B cpu==d=S ~~~~~~~~~~~wait~~~~~~~~~~~
display v R
GPU (FCL) .............A held............++++st+++##A####B###
fence A E
```
MobileGlues has the identical mechanism: the same lazy dequeue just before the swap, the same
whole-frame batch at `eglSwapBuffers`, and the same 47 % gated frames. Its offsets:
1. **-0.24**: A dequeues.
2. **0**: A swaps. `queueBuffer` comes 0.29 ms into `eglSwapBuffers`, which spends about 0.3 ms
before queueing.
3. **1.60**: B dequeues.
4. **2.08**: B throttles.
5. **3.13**: R_A.
6. **4.48**: E_A, which is R + 1.35 (0.87 start latency + 0.48 execution).
The throttle is 1.12 ms/frame: display before release 0.59, GPU after release 0.52. That matches the
1.4 ms/frame off-CPU seen in simpleperf. MobileGlues waits more than MobileGL monolith only because
its CPU frame is shorter (1.95 ms against 2.9-3.4 ms), so B reaches the gate earlier. Its GPU per
frame is the same: 0.48 ms back to back, against 0.50-0.56 for MobileGL.
## Why CPU cuts are partly absorbed
Across the arms, frame time is the presenting thread's CPU plus the throttle, and the throttle grows
as the CPU shrinks:
- Espryt monolith: CPU 3.35 + wait 0.61 = 4.00;
- Magma monolith: CPU 2.86 + wait 0.71 = 3.60;
- Espryt inproc: CPU 2.04 + wait 1.08 = 3.18;
- MobileGlues: CPU 1.95 + wait 1.12 = 3.07;
- Magma inproc: CPU 1.53 + wait 1.28 = 2.93.
CPU comes from `sched` over the trace window. At the monolith operating point about 80 % of a CPU
saving reaches frame time (Espryt to Magma: 0.49 less CPU gives 0.40 less frame). At the
inproc/MobileGlues point only about a third does (MobileGlues to Magma inproc: 0.42 less CPU gives
0.14 less frame).
The e70925d3 simpleperf numbers fit the same trend: Espryt has 3.08 ms on CPU and 0.82 off CPU in
Present; Magma has 2.63 and 1.03. The faster CPU waits more.
## Options (portable), with the effect read off the timeline
The effects come from `sim.py`. It is an in-order GPU queue model replayed on the measured CPU
schedule: start latency + execution from `gexec.py`, and each frame's measured release time. Its base
scenario reproduces the measured throttle within 0.01-0.03 ms/frame on all five arms. The results are
first-order: the CPU schedule is held fixed, so phase feedback is not modelled.
1. **One or two more swapchain images (Magma).** Request `surface minImageCount + 1` (or + 2) instead
of `max(FIF, minImageCount)` = 5. Then the buffer the display has just released goes to the back
of the FIFO free list and is reused one (or two) frames later, by which time its release fence has
usually signaled.
- In the current traces, a buffer reused k frames later at the CPU-bound cadence would still be
gated in:
- Magma monolith: 16 % of frames at k=1, 3 % at k=2 (50 % today);
- Espryt monolith: 21 % at k=1, 0 % at k=2;
- Magma inproc: 40 % at k=1, 23 % at k=2, 8 % at k=3.
- First-order effect: Magma monolith's throttle goes from 0.71 to about 0.0 ms/frame, so the frame
drops from 3.60 ms toward the CPU's 2.9 ms. Treat this as an upper bound; the faster schedule
reuses buffers sooner.
- Inproc needs +2 or +3.
- Cost: one 2560x1600 RGBA buffer (about 16 MB) per image. MAILBOX adds no latency.
- This is a real trade-off (memory), so A/B the count.
- Espryt has no NDK-public way to set an EGL window's buffer count. The private `ANativeWindow`
perform call or ASurfaceControl would need the user's greenlight under the platform-agnostic
rule.
2. **Gate only the window pass (split the frame's submission).**
- Magma:
- submit the offscreen work recorded so far without the image-available semaphore, at least once
before the swapchain image is first touched;
- attach the acquire wait only to the submit that contains the first swapchain-image access (the
final blit and present transition), not to the frame's first submit.
- Espryt: `glFlush` at the first default-framebuffer bind of a frame, just before the driver's lazy
dequeue. The offscreen work is then submitted with no release sync object in front of it.
- Effect: throttle 0.62 to 0.49 (Espryt monolith), 0.72 to 0.54 (Magma monolith), 1.31 to 1.09
(Magma inproc), 1.09 to 0.91 (Espryt inproc).
- The gain is small because the 0.8 ms GPU start latency is paid again for the final pass after
the release.
- It is the only portable lever for Espryt. With option 1 on top, the model gives about 0 on all
arms.
3. **Take the throttle off the GL thread.**
- Inproc: present credit 2 (already measured at +3-7 %; it costs a frame of latency, so it is the
user's decision).
- Magma monolith: the equivalent is a present thread that issues `vkQueuePresentKHR` off the GL
thread, with the same latency trade.
- Espryt monolith cannot do this: `eglSwapBuffers` stays on the context thread.
4. Not recommended:
- keeping the GPU awake to hide the 0.8 ms start latency (a power trade, not portable);
- anything in SurfaceFlinger or vsync.
## What a fresh e70925d3 trace should confirm
Run the scripts in `p15/pwait/` on the new run directories:
- `cycle.py <label> <runs...>`
- `pairs.py`
- `gexec.py`
- `sim.py <label> <start> <exec> <runs...>`
Expected on base e70925d3:
- gated frames about 50 %;
- throttle about 0.8 ms/frame (Espryt) and about 1.0 (Magma), matching simpleperf's Present off-CPU;
- 99+ % of the throttle after gated frames;
- R 0.24 ms after `crtc_event`, with `kgsl_hwsched` at R;
- E - R about 1.3-1.4 ms for gated frames, with back-to-back execution about 0.5 ms.
For a CPU-only change (the TLS agent's): frame time should drop by about 0.8 x the CPU saving at the
monolith point, with the throttle rising by the rest. If it does not rise, the model is wrong.
For the change in option 1:
- `HWC release fence N has signaled` should appear at 85 % or more of `AcquireNextImageKHR`;
- gated frames below 20 %;
- throttle below 0.2 ms/frame (monolith).
For the change in option 2: E - R for gated frames should fall from about 1.35 to about 0.9 ms (start
latency + final pass), and back-to-back execution should not change.
Config notes:
- The `kgsl/adreno_cmdbatch_*`, `adreno_syncobj_*` and `kgsl_waittimestamp_*` ftrace events in
`perfetto.cfg` are not recorded on this kernel (`ftrace_setup_errors` = 43 in every trace). Drop
them, or list `/sys/kernel/tracing/events/kgsl/` for the kgsl hardware-scheduler event names.
- `gpu.renderstages` does work on this device: `runs/gpu-bsl/*/gpu.pftrace` has Render/Surface/Blit
stages. One extra capture with it would split the 0.8 ms start latency from execution directly. Its
overhead should be A/B'd before using it in a perf capture.
- Keep atrace `gfx`: the `GPU completion` / `HWC release` FenceMonitor threads only exist while it is
on.
- MobileGL emits no atrace markers. A marker at the frame's flush/submit points and at the first
default-framebuffer bind would make option 2 directly visible.
## Files
All under scratchpad `p15/pwait/`:
- `tp.py`: trace_processor wrapper, cached;
- `pw.py`: per-frame alignment of S, D, R, W0/W1 and E;
- `cycle.py`: attribution and the cycle medians;
- `pairs.py`, `gexec.py`, `gapcost.py`, `reuse.py`, `wakelat.py`, `glchain.py`;
- `sim.py`: the counterfactuals;
- `win.py`, `seq.py`: raw windows;
- `diag.py`, `mkdiag.py`: the diagrams;
- outputs in `out/`.