Files
MobileGL/docs/Disaggregated/notes/perf-p15/PRESENT-WAIT.md
T

20 KiB

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/.