# P15 handoff (living document) The plan and its measurements are in [PLAN-P15.md](PLAN-P15.md); the audit is in [PARITY-AUDIT.md](PARITY-AUDIT.md). **Target** (PLAN §0.1, revised by the user 2026-10-08): - CPU scenes: FCL presenting-thread CPU at or below dev monolith (Magma <= 1.79, Espryt <= 2.14 ms/frame), dev measured in the same session, ~5 % noise. - GPU scene (Iris + BSL): fps within ~5 % of MobileGlues on both backends. ## RESUME HERE (2026-10-09 evening, end of the long P15 run) **Rules from the user (2026-10-09):** - A fresh agent per clear task boundary. - Mechanical jobs (bench A/Bs that follow the existing scripts, builds, log/profile parsing, CI watching) go to sonnet/haiku subagents. Design and implementation stay on opus. - No minSdk raise for the shipped build: API 29 is a SEPARATE artifact (its own library, plugin APK and FCL-embedded copy). - No record bypass: monolith = record arm. - TLS (superseded 2026-10-09): API 29 did not remove the TLS cost (native TLSDESC in a dlopen'ed library costs about the same as emulated TLS), so portable hot-path TLS elimination is approved for all API levels. No TLS-model attributes or API-level gates as the mechanism. - No new Android API usage. Keep MobileGL platform-agnostic unless a platform path is measured and has no portable alternative. - ASurfaceControl, frame-rate hints and ADPF are deferred. - Optimization is driven by Perfetto traces (2026-10-09). Results are reported as a per-frame timeline: phases in order with offsets within a typical frame, the waits, and the critical path. Do not report them as ms tables. simpleperf is for function-level zoom-in inside a phase. **Where the code is (worktree `.claude/worktrees/p15`, branch `p15impl`):** - `origin/feat/disaggregated` = db346f28 plus the pushes listed in SCOREBOARD. - Local on `p15impl`, not pushed: - 14d63881, docs. - 8d548d5b, window-scoped freqcheck. - ca0a3b74, a cherry-pick of the coordinator's LTO commit 190580f2 on `build-opt`. The coordinator pushes 190580f2, so drop ca0a3b74 when rebasing. - a0015bad, Magma 2a + 2b. Its message carries the m2ab result, or says it is pending. - `wip/espryt-depthcopy-measured` (the two Espryt BSL GPU items rebased onto origin 9f610167, with the device numbers in its message; the old `wip/espryt-depthcopy` eb7572ff is the unmeasured original). **Measured 2026-10-09 on device, NOT landed: no gain on BSL.** x = with both items, b = without, both LTO builds of origin 9f610167; gpuhunt, 3 VALID reps each, one arm per cooldown: - BSL: x 51.4 fps (0.921 of MobileGlues 55.8), b 51.9 (0.930); GPU 100 % busy in all runs, GPU 19.45 vs 19.29 ms/frame (MobileGlues 17.88). The target of 0.95 is not reached; the items are neutral. - Vanilla cpuhunt check (Espryt monolith): x 248.5 fps / 2.87 ms GL CPU, b 254.0 / 2.83 (-2.2 % fps, within noise; no CPU regression worth the name, but no gain). - Engaged: the simpleperf profile of the BSL render thread shows `BlitDepthTexture2D` 0.100 ms/frame in b and gone in x; `FramebufferImpl::SyncToBackendByHandle` 0.178 -> 0.162. The saved CPU is hidden by the GPU-bound frame, and the GPU time per frame did not move, so the Espryt BSL gap is not in these items. See SCOREBOARD (ebbsl). Original description: - (1) Depth glCopyImageSubData goes to the driver (ES 3.2 / EXT/OES_copy_image), with the scratch-FBO depth blit as fallback. Knob MOBILEGL_ESPRYT_NATIVE_DEPTH_COPY. - (2) A per-point attachment shadow on the Espryt FBO twin skips an attach call for a point that already holds the same surface. It is keyed on the surface handle {slot, gen} plus driver id, level, layer, call and re-mint generation; texture-side syncs still run. Knob MOBILEGL_ESPRYT_ATTACHMENT_SHADOW. - Tests: DepthCopyImageScenario (2 cases) and AttachmentReattachScenario (4 cases), both registered on the split arms. Red-once: a shifted native copy fails all DepthCopyImage cases on DirectGLES; dropping Level from the shadow key fails the level case; keying on the driver id alone fails the recycled-name case. - Host suites: 1672 DirectGLES ctest entries (monolith + Split) pass serially in WSL (362 skipped by design), including DepthCopyImage, AttachmentReattach, PassOrder and every blit case. - `wip/api29-switch` (42922d21, on p15impl): `-Pmobilegl.androidApi=29` in build.gradle and the plugin's build.gradle.kts. - It sets minSdk 29 and therefore ANDROID_PLATFORM android-29; the plugin gets the `.api29` id suffix and `-api29` version suffix. The CMake API-guard comment is updated. Default stays 26. - Verified: the API 29 lib has 95 R_AARCH64_TLSDESC relocs and a TLS segment. __emutls remains only for 3 libc++abi variables. - No A/B yet. The next agent runs the H2 A/B: API 26 vs 29, monolith + inproc, both backends, MobileGlues control. - Nothing in the tree needs a compile-time API gate yet, so none was added. - The FCL-embedded 29 copy needs a matching FCL-side switch (FCL's own minSdk). **The m2ab device job was still running at hand-off. Do not interrupt it.** - What it is: - Started as a background chain: `bash p15/m2ab_job.sh > p15/runs/m2ab_job.out; bash p15/espg_job.sh`. - `espg_job.sh` is parked: it only echoes "espg parked". The real Espryt BSL job is `espg_job.sh.next`. - m2ab_job waits for the device lock, cools to battery ≤ 33.0 °C, then runs `PROFILE=cpuhunt devjob2.sh p15/runs/m2ab.log bash p15/m2ab_session.sh`. - Arms: cpuhunt H2, 3 interleaved reps, PF=1. Both libs are LTO builds of the same tree: - `n` = `libs/p15en`, without 2a/2b; monolith with sp=1, plus inproc; - `e` = `libs/p15e`, with 2a/2b (it also carries the Espryt items, which do nothing on Magma); monolith with sp=1, plus inproc; - MobileGlues. - Results: - per run in `p15/runs/m2ab/-r/` (fps.txt, valid.txt, perf.data on sp=1 runs); - the summary is appended to `p15/runs/m2ab.log` (look for the `n=` lines). - Device restore: the session script runs `fcl_scene.sh restore` and `fcl_p14.sh restore`, and devjob2's exit trap stops the bench session and releases the lock. - Interim at hand-off (GL CPU ms/frame): | arm | n (without 2a/2b) | e (with 2a/2b) | |---|---|---| | monolith r1 / r2 | 2.39 / 2.48 | 2.29 / 2.45 | | inproc GL thread | 1.20 / 1.20 | 1.17 | | inproc fps | 343.5 / 371.6 | 363.8 | - When it ends: 1. Summarize with `fclsum.py runs/m2ab`. Judge 2a/2b on monolith GL CPU and on the inproc apply thread (threads.txt `mgl-srv-apply`). 2. Build the profile caches offline: `prof_prep_offline.sh p15e|p15en runs/c1c2/x-DirectVulkan-r1`. Compare RenameBusyWireStore, VkBufferObject::Create, SweepDeferredWireReleases and IsSubmitIndexComplete. 3. If neutral-or-better: amend a0015bad's message with the numbers. Push the p15impl commits WITHOUT ca0a3b74, i.e. cherry-pick onto origin after the coordinator pushes 190580f2. Then CI. - Host suites for 2a/2b: done and at baseline (92 environment unit failures, 2 ColdStart). - The one extra unit failure seen once, CompositorRecovery.ACompositorOnTheServerWindowRecovers..., is a pre-existing load flake: under full CPU load it fails 6/20 without the change and 2/20 with it. **Hot-path TLS cut (2026-10-09 night, worktree `.claude/worktrees/tlshoist`, branch `tlshoist` on origin e70925d3, local, not pushed):** - c0a9b2cd pGLContext and gPipeInputs become constant-initialized pointer slots (no TLS init wrapper or guard; the owning SharedPtr lives in a second thread_local, released before the slot is cleared at thread exit). 36f55af8 the applier lookup cache is one block. 6191cbf2 Magma session read and built-slot lookup inline. abb22eef Espryt bound serial and per-context cache share one block (epoch bump closes ABA). d4a00835 owner release order + SanityTest fix. - Host: unit 92 failures, the same set as the baseline; integration 2 (Spawn.ColdStart.ClearThenReadPixels on both backends), as the baseline. Split-OFF NDK arm64 and the plugin LTO build compile. - Device (SCOREBOARD tls): Magma +6.3 % fps, GL CPU -0.14 ms; Espryt +0.9 %, -0.05 ms. TLS resolver 0.058 -> 0.034 (Magma), 0.049 -> 0.035 (Espryt) ms/frame. - Remaining emutls per frame (~3-4k): pGLContext ~2.3k, gPipeInputs 0.6-1.1k, Magma session 0.6k (GetActiveRenderPass), ResolveAttributeFromBinding note bumps, SetupWireDraw. Each further cut is ~0.002 ms; not done. - Perfetto traces of a second A/B (tlspf) are in `runs/tlspf/*/p15.pftrace` for the present-wait work. **LTO and the server check (coordinator):** - libMobileGLServer.so is built and packaged by the LTO-ON assemblePluginRelease. Its only libMobileGL import, `mobilegl_server_main`, is exported by the LTO lib. - The spawn-transport smoke was **NOT run**. It needs the LTO-ON plugin APK (`libs/p15lto/plugin.apk`) installed, because the FCL mgdebug install has no libMobileGLServer.so. **Open items, in order:** 1. CI fix: the reds of Test run 37925410578 on db346f28 (integration-split-strict, the two DirectVulkan retrace reds, skip census). Then push the LTO commit (190580f2 on `build-opt`) and the local p15impl commits (without ca0a3b74), and confirm CI is green. 2. `userguide/` at the repo root, an end-user guide for every setup: which setup to use, how to build, integrate and run it. Covers: - Android FCL-embedded; - the plugin APK (inproc, spawn with `MOBILEGL_IPC_SURFACE=server`, TCP); - the API 26 and API 29 artifacts; - Linux/anland; - the Windows WGL drop-in; - build options, user-facing knobs and troubleshooting. 3. DONE 2026-10-09: Espryt BSL GPU items (`wip/espryt-depthcopy-measured`): device A/B measured, no gain, not landed (see above and SCOREBOARD ebbsl). The Espryt BSL gap to MobileGlues (0.92) is still open; the next lead is the GPU side (render-stage trace of Espryt vs MobileGlues passes), not driver calls. 4. Small CPU batch: ValidateForVerb residue, Tracker::Update (0.018, mostly inherent), EmitGlobalConstants (0.026). Expected ≤0.03 ms in total. - DONE on host, branch `cpubatch` (2026-10-09). Measured on device 2026-10-09 (SCOREBOARD egc): EmitGlobalConstants 0.022 -> 0.014 ms/frame on both backends, consistent over 3 reps; whole-frame fps inside noise. Line-level profile of fbm2 h-r1..r3 (both backends, p15h syms) and m2ab e-DirectVulkan r1..r3 (LTO). - EmitGlobalConstants, cut: the (ShaderCso, Version) key is per handle (the applier keeps one block per record), so switching back to a program whose uniforms did not move sends nothing, and a moved one sends a span against its own held image instead of the whole block. The span is the range the frontend wrote (ProgramObject::MarkUBOContentDirtyRange / TakeUBODirtyRange), so the client shadow and its two-ended diff scan are gone. The scan plus shadow copies were ~0.0065 ms/frame; with the skipped switch-back sends, expect ~0.007-0.010 ms/frame. - Tracker::Update: inherent, skipped. ~30 ns per verb over ~25 frontend counters; the only real cut is the epoch skip (PLAN stage 1), which is not a small item. - ValidateForVerb residue: skipped. The largest named piece (~0.008 ms/frame) is the MOBILEGL_PIPE_POISON stamp walk, armed by MOBILEGL_BUILD_DISAGGREGATED. The bench libraries are the plugin build (split ON); the FCL-embedded build (split OFF, INFO) compiles it out. The rest (prologue, singleton guards, gates) is spread below 0.002 each. 5. DONE 2026-10-09: API 29 build plus its A/B (`wip/api29-switch`, 42922d21). Result in SCOREBOARD (api29): - cpuhunt vanilla, 3 interleaved reps, 27 of 27 VALID. fps api29/api26: Magma monolith 1.041, Magma inproc 1.044, Espryt monolith 1.008, Espryt inproc 1.015; GL-thread CPU -3 % / -7 % / -0.6 % / -3.4 %. - The gain is not TLS: simpleperf puts `tlsdesc_resolver_dynamic` (api29) at the same 0.05 ms/frame, about 2 % of the render thread, as `__emutls_get_address` (api26), because a dlopen'ed library takes the dynamic resolver. - 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. 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 | |---|---|---| | on-CPU ms/frame | 3.08 | 2.63 | | off-CPU ms/frame | 0.89 | 1.09 | | of which in Present | 0.82 | 1.03 | - The blocked stack is the same on both backends: `queueBuffer`'s producer throttle (`Fence::waitForever`) on the previous frame's GPU-completion fence. - So frame time = CPU + present wait. CPU cuts reach fps only if the wait does not absorb them. - The GPU is only ~17 % busy at cpuhunt, so the fence signals late rather than the GPU running long. Candidates: - work submitted only at swap (~1 batch/frame); - the GPU waiting on the compositor's buffer release. - Trace (both backends, monolith + inproc, MobileGlues control): - MobileGL submit/flush points; - kgsl GPU busy spans and frequency; - fence create/signal times; - SurfaceFlinger latch/release. - Decide whether earlier submission (or another portable change) shrinks the wait. 5c. QUEUED 2026-10-10, after the quick before/after fps run: - Capture: one Perfetto run per arm (Espryt monolith base vs pwgate+pmflush; Magma monolith base vs pwgate+pmflush+extra 2), traces in scratchpad p15/runs/pwlat. - Derive offline: a rough per-frame timeline, and the input-to-present latency from the SF frametimeline. - Write it up in docs/Disaggregated/pwait/: what the levers do, the effect on fps and latency, the knob, and the FIFO caveat (extra images add latency only with vsync on). 6. Smaller follow-ups: - DONE 2026-10-09: `bench_session.sh stop` now runs `pin_clocks.sh restore` first (verified over a 17-session run: max clocks back to stock after every session, daemons running, no session file). The scratchpad `devjob2.sh` / `fcl_bench.sh` hard-code the p15 worktree's copy of the harness; point them at a tree that has this commit. - The `Fatal{UnmigratedSurface, "AndroidNativeWindow@P12"}` text and the tests asserting on it still point at P12. The rule is permanent: a client window cannot cross processes, so windowed spawn uses `MOBILEGL_IPC_SURFACE=server`. Reword the message to say that. - DONE 2026-10-09: the windowed out-of-process smoke of the LTO plugin APK passes on both backends. The plugin is debug-signed and replaced the installed one, with the user's OK. Setup: - server: the plugin's `MobileGLDisplayActivity`, i.e. the `:mglwin` in-process server on `tcp://127.0.0.1:40613`; - client: FCL with `MOBILEGL_TRANSPORT=spawn`, `MOBILEGL_IPC_CONTROL=tcp://127.0.0.1:40613` and `MOBILEGL_IPC_SURFACE=server`. ServerOwned 1280x800 window, Espryt and Magma both in-world. A plain spawned child has no display (`NoServerDisplay`). Evidence is in the coordinator scratchpad, `p15/runs/spawn_smoke2/`. - Open from that run: every run logs `MG_Remote server: shared image 1 could not be sent on the aux socket (rc=3)`. Rendering continues, apparently on the non-zero-copy fallback. Find out why the loopback-TCP aux-socket send fails. **Harness rules (all in scratchpad `p15/`; the tools live in `tools/device_bench/disagg/`):** - H2 = `fcl_bench.sh`, driven by `fclab2.sh` for interleaved arms; summaries come from `fclsum.py`. - Every session goes through `devjob2.sh` (device lock, bench session, restore on exit). Profiles: - `PROFILE=cpuhunt` for CPU work (CPU 1.5/1.25 GHz, GPU 903); - `PROFILE=gpuhunt` for BSL (CPU 2.4/1.8, GPU 578). - BSL: **one arm per cooldown** (battery ≤ 33.0 °C between runs), arms interleaved across cooldowns. At gpuhunt the scene hits thermal limits by the second back-to-back rep. - Validity: `freqcheck.py` judges clocks over the measurement windows only (measure_raw.txt spans) and thermal over the whole run. - Stop a session on thr>0, a thermal_pwrlevel below the pin, or "max capped". Discard INVALID runs and go on. Never stop on tpl alone: it is 6 by construction at gpuhunt. - Build simpleperf binary caches only after a session, or offline with `prof_prep_offline.sh` (donor cache plus the build-id entry in build_id_list). Never adb-pull during a session. - Never edit a bash script that a running job will still read (bash reads scripts incrementally). - TaskStop does not reliably kill child bash processes on this host: kill the PIDs, and only the inner session processes, so devjob's cleanup runs. - Libraries live in `scratchpad/p15/libs//lib/arm64-v8a/libMobileGL.so`, with symbols in `syms//` and runs in `runs//`. Tags: | tag | contents | |---|---| | p15e | LTO + 2a/2b + Espryt items | | p15en | p15e without 2a/2b | | p15lto / p15off | LTO ON / OFF, both with 2a/2b | | p15a29 | API 29 | | p15h | the pipe batch | | p15y | 1c | | p15t | before 1c | - Profile tools: `topinc.py`, `callers.py`, `callees.py`, `layerfns.py`, `layerdiff.py`, `diffprof.py`, `appside.py`, `pftstate.py` (Perfetto SQL), `threadstate.py`. **Device state at hand-off:** - At hand-off the m2ab session was still running; it restores FCL, the scene and the bench session itself. - The device lock is released by devjob2 when m2ab ends. - The plugin installs `top.mobilegl.plugin.*.trace` from earlier phases are still on the device. ## Open: Espryt small-store write rename (branch `pmflush`, 2026-10-09, host-verified, needs device) **Not a p15 regression.** MONOLITH-DIFF's top Espryt row (`FlushPendingRangesFrom` driver 0.091 + libc 0.025, "dev 0") is the rename the doc's own caveat lists: dev's `FlushPendingRangesNow` costs 0.086 + 0.019 in the same profiles. Both are the same three-tier ladder, unchanged since dev f973008c. Inclusive: dev 0.066 / p15 0.078 (feat-esp-1), 0.106 on the newer api29ab e26 run. So the drain is real cost that dev pays too. **What the drain is.** Under it, 0.080 of 0.106 ms/frame is one Adreno driver routine reached from `glCopyBufferSubData` (dispatch slot 0x670 -> 0x3250c0 -> 0x2ee9d0, with heap allocations inside). So it is the upload-ring tier: one GPU copy job per queued partial range. The 1.21.5 in-world trace (5998912 calls, last 252 frames) shows the shape: 162 glBufferSubData a frame. 122 of them are partial writes from offset 0, mostly 96 or 192 bytes into 288-byte vertex stores, and ~3 KB into 6.7 KB ones. That is Minecraft's per-format immediate vertex buffer. Magma already renames these stores (`RenameBusyWireStore`, S0 M4); `WriteWireBuffer` costs 0.031 ms/frame there, so Magma needs nothing. **Change.** `RenameSmallStoreForDrain` (Managers.cpp) runs before the draw-time drain. For a store <= 64 KiB whose unwritten bytes are <= 8 KiB, it replaces the queue with [0, store). The pinned ladder then takes its tier 1, the whole-buffer orphan-map: no copy job, and recorded draws keep the old storage. It refuses when the store holds bytes the host copy does not have: `serverGpuWritten`, queued resident writes, persistent/immutable/imported stores, or split staged coverage short of the whole store. On the trace this renames 108 of 122 partial writes a frame, at +101 KiB of host memcpy a frame; a plain 64 KiB bound would cost +811 KiB, because of 128-byte writes into a 53 KiB index store. The ladder bodies and the G5 pins are untouched. **Tests.** EsprytFlushLadder has 3 new unit cases. There are 2 new scenarios, one per backend on every arm: AtomicCounterScenario.APartialSubDataBetweenDispatchesKeepsTheShaderWrittenNeighbour and CrossFrameBufferScenario.APartialSubDataBetweenTwoDrawsOfOneFrameKeepsTheFirstDrawsBytes. The skip census mirrors their siblings' skipped lanes. Red-once: dropping the `serverGpuWritten` refusal reds the atomic case on DirectGLES.Spawn, which reads back 16 instead of 32; monolith and inproc pass because the frontend reads GPU writes back before a partial CPU write. **Suite results.** WSL archlinux, clang Release, lavapipe, env MOBILEGL_ITEST_REQUIRE_GPU=1, suites run one at a time: - unit 2907/2907; - integration 5300/5300 on the labels gpu, split, magma-all-split, magma-full-split, monolith-control, cold-start, cpu-mip-split, clientarrays-split and remint-fallback-split; - 354/354 spawn+TCP buffer, SSBO, atomic, persistent, XFB and index scenarios; - 18/18 Espryt retraces (OpenRA, 1.21.4-in-world, 1.21.11-main-menu, sodium, create-instancing, Iris BSL; each monolith, SPLIT and SPAWN); - both Android arm64 shapes compile (disaggregated ON and OFF, android-26). A first run with the dozen ICD (REQUIRE_GPU=ON in the cache) failed 1488 DirectVulkan cases. That is the ICD, not this change: there were 0 DirectGLES failures. **Host blocker for retrace evidence (pre-existing).** Espryt on llvmpipe segfaults in the 1.21.5 fixture at call 75682 (a glDrawElements right after vertex buffer 8 is deleted and recreated). This happens on the base tree too. Magma completes (SSIM 0.985 on dozen). So Espryt cannot be checked for SSIM on this trace on the host. **Device ask (Perfetto first, per the 2026-10-09 rule).** FCL MC 1.21.5 vanilla bench scene, monolith, cpuhunt pinned, base = origin e70925d3 vs pmflush, same LTO settings. Run Espryt (the arm under test) and Magma (control, expected identical). Capture a Perfetto trace per arm with: - sched_switch/wakeup, cpu_frequency; - atrace gfx/view/hal; - kgsl gpu_frequency and GPU busy/slices; - dma-fence signals; - SurfaceFlinger frame timeline. Report one typical frame as a timeline: render thread runnable/running/blocked, queueBuffer/dequeueBuffer waits, the GPU slice and its end against the next frame's throttle wait. The question is whether removing ~100 copy jobs a frame shortens the GPU slice and with it the ~0.8-1.0 ms queueBuffer wait (a shorter frame), or only cuts CPU (a longer wait, the same frame). simpleperf zoom: `FlushPendingRangesFrom` inclusive and its callees, expected to drop from ~0.106 towards ~0.03 ms/frame (the orphan-map plus the extra memcpy). Also check the driver's map path (slot 0x550 -> 0x1db8a0) does not grow into new allocations per rename. Correctness: SSIM/golden on the device retrace of minecraft-1.21.5-vanilla-fcl plus the Iris/BSL cases, Espryt. ## Present-wait levers A + B (branch `pwgate`, worktree `.claude/worktrees/pwgate`, 2026-10-09) Built on origin/feat/disaggregated a759b4b7. Host-only so far; the device plan is below. Mechanism: PRESENT-WAIT.md (p15/pwait): every other frame renders into the buffer the display just released, and the whole frame batch was gated behind that buffer's release fence. **Commits (local, not pushed):** - 168451bc Magma: submit a frame's off-screen work ahead of the swapchain acquire wait. - 66adc7aa Espryt: flush once per frame ahead of the window buffer's first use. - dd3e608a Tests: WindowPassGateScenario covers frames split around the window pass. - cdd2160f Magma: MOBILEGL_MAGMA_EXTRA_SWAPCHAIN_IMAGES asks for swapchain images beyond the surface minimum. **Lever A, Magma (no knob).** - `FrameData::acquiredImageReferenced` means the open recording uses the acquired swapchain image. It is reset at every acquire (`WaitAndAcquireNextImage`, `AcquireNextImageInCurrentSlot`). - `SubmitPendingCommandBuffer` waits on `imageAvailableSemaphore` only when the recording references the image, or when the caller forces the wait (`ParkActiveTarget`'s wait-out). It marks the semaphore consumed only when it waited. Present's own submission still waits whenever the semaphore is unconsumed. - `SubmitAheadOfAcquiredImage()` runs before the frame's first use of the acquired image. It flushes what is recorded so far without the wait, then marks the image referenced. It is called from: - `SetupDraw` when the draw framebuffer is the default one, next to `SplitOversizedRecording`, before anything of the draw is recorded. Splitting later in setup lands after the draw's descriptor bind, because the attachment pre-resolve reads the default framebuffer's read index. That was measured as VUID-vkCmdDraw-None-08600 plus an llvmpipe segfault. - `ClearWireFramebuffer` for the default framebuffer. - `ResolveWireImage` for the acquired colour image. This is the net under blits, copies, readbacks and shared-image presents. - `Present`. A frame that never touched the window submits its work unwaited, and the present's own submission carries the wait, possibly with no command buffer. - Inside a draw's preparation (`m_wirePreparationDepth != 0`) it only marks the image; it never submits. - Wait stage: every acquire wait now uses `FrameContext::kAcquireWaitStages` (COLOR_ATTACHMENT_OUTPUT | TRANSFER). Before, mid-frame flushes waited at ALL_COMMANDS and the present submit at TOP_OF_PIPE, which waits for nothing. - `TransitionWireImage` adds those stages to the source scope when a default colour image leaves PRESENT_SRC_KHR or UNDEFINED. - Engagement proof: one latched log line, `DirectVulkan: a frame's off-screen work goes to the queue ahead of its window pass ...`. - Acquire timing is unchanged: the next image is still acquired at the end of Present. - Acquiring later cannot move the gate. The buffer queue's free list is FIFO, so a later dequeue gets the same buffer, or a more recently released one, with the same release time R. - It would also break the buffer-age contract: `CurrentDrawBufferAge` answers for the image acquired at the previous present. - So no device A/B was spent on it. **Sync-correctness argument (Magma).** - One wait per signal. Each acquire signals imageAvailableSemaphore once. Exactly one submission waits on it: the first whose recording references the image, or the present's, or the park wait-out. All three check and set `imageAvailableSemaphoreConsumed`. The next acquire on the slot comes after the slot's fence wait, so by then the semaphore is unsignaled with no pending wait. - Recordings that never reference the acquired image carry no wait. That is correct because none of their commands access it. - Reads of `m_defaultFramebufferImageIndex` that name the previously presented image touch a different image. - The old code did not order those reads against an acquire either. - Every first access of an acquired image is a layout transition out of PRESENT_SRC_KHR or UNDEFINED: - `TransitionWireImage`, whose source scope includes kAcquireWaitStages; - `TransitionToPresent`, whose source scope is ALL_COMMANDS. - The semaphore wait's second scope covers kAcquireWaitStages in its own batch and in every later batch. So the transition is ordered after the presentation engine's release, even when the use is in a later submission than the wait. Through the transition's ALL_COMMANDS destination, everything after it is ordered too. - renderFinished is signaled by the present submission. That is last in submission order, so its first scope covers the off-screen submissions too. - Unchanged by this work: - Frames in flight: split submissions take pooled fences and advance `lastSubmitIndex`, and the present's slot wait covers them. - Wire render-pass guard: `FlushPendingCommands` ends the open pass. - Pass-barrier coalescing: barriers order across submissions. - Deferred clears: the default framebuffer never defers. - Not machine-checked. lavapipe syncval does not flag a broken acquire chain. - Positive control: removing the source-stage fix while keeping the narrowed wait still gave 0 hazards. - It never flagged the old present submit's TOP_OF_PIPE either. - The argument above rests on the spec. **Lever A, Espryt (no knob).** - `FlushAheadOfWindowBufferUse()` issues one `glFlush` per frame, just before `BindCurrentFBO` or `SyncAndBindFramebufferByHandle` moves the driver's binding to framebuffer 0. A thread-local flag limits it to once per frame and is reset at the present's swap. - Window surfaces only: a pbuffer has no lazy dequeue. - Not issued on MobileGL's own scoped binding restores, or when the binding is already 0. A frame that renders straight into framebuffer 0 has nothing to flush ahead. - No wait and no glFinish. Adreno dequeues lazily at the first default-framebuffer use. - Host coverage is pixels only, because the harness has no window surface. **Lever B (knob, default 0).** - `MOBILEGL_MAGMA_EXTRA_SWAPCHAIN_IMAGES` (0-16) requests `max(frames-in-flight hint, surface minImageCount + extra)` images, clamped to maxImageCount. - Logged as `Set minImageCount = N (surface min M, max X, extra K)`. - Documented in userguide/configuration.md and the README knob table. - Cost per extra image at 2560x1600: one RGBA colour buffer (~16 MB) plus the default framebuffer's same-size depth/stencil image. - Vulkan only. Espryt's EGL buffer count needs a platform API, so it is deferred. **Host verification.** Setup: WSL archlinux, clang, CMake 3.31.10, `~/mgl-pwgate-ci31`, lavapipe, suites run serially. | Check | Result | Baseline | |---|---|---| | Unit | 2904 tests, 92 failed (ServerSpawn / PeerLatch / FuzzArm2) | same 92, environmental | | Integration: `integration-gpu`, `-magma-all-split`, `-magma-full-split`, minus spawn and tcp, `MOBILEGL_ITEST_REQUIRE_GPU=1` | 3883 tests, 2 failed (both Spawn.ColdStart ClearThenReadPixels) | same 2 | | Syncval (submit-time, shader heuristic): WindowPassGate + CrossFrameBuffer + DefaultFramebufferAcrossSwap.ADraw*, monolith and inproc | 0 hazards, 0 VUIDs; also clean with EXTRA_SWAPCHAIN_IMAGES=2 and FRAMES_IN_FLIGHT=2 | — | | CI gates `magma_runahead_checks.py negative`, `validation`, `magma_cache_checks.py` | rc 0 | — | | EXTRA_SWAPCHAIN_IMAGES=2 | 85 present-shaped Magma entries pass; minImageCount 4+2 = 6 on the headless surface | — | | Android arm64 plugin release (`assemblePluginRelease`), API 26 and API 29 | both rc 0 | — | - The integration run includes WindowPassGateScenario on DirectGLES and DirectVulkan (monolith, Split, Split.Full), plus the `DirectVulkan.WindowPassGate.` arming entry. - Verb tables are unchanged, so split_coverage was not needed. - The spawn and tcp lanes fail on this host for every scenario: TcpServer.Start fails, and spawn bring-up aborts with CapsBeforeFirstSnapshot. - Pre-existing, not from this work: DefaultFramebufferAcrossSwap.AReadbackAfterASwap reads an image already handed to presentation, which syncval reports as WRITE_AFTER_PRESENT. That is why the new scenario reads back before its swaps. **Device plan (not run; another agent has the phone).** - Libs: both are API 26 default plugin builds. - base: origin/feat/disaggregated a759b4b7. - A: pwgate cdd2160f. - Setup: same FCL, MC 1.21.5 vanilla frozen world, cpuhunt clocks, FCL pinned to cpu2-6. - Validity rules: `fcl_bench.sh` VALID, freq and thermal sampled every 1-2 s, throttled runs dropped. - Perfetto (PF=1): `p15_perfetto.cfg` with every `kgsl/*` ftrace event removed (not on this kernel). Keep: - sched (switch, waking, wakeup, blocked_reason), cpu_frequency and cpu_idle; - atrace `gfx` + `view` for the FCL package and surfaceflinger (gfx is what creates the `GPU completion` / `HWC release` FenceMonitor threads); - process_stats and `android.surfaceflinger.frametimeline`; - 5 s duration. - Arms, 3 interleaved reps each: 1. Espryt monolith: base, A. 2. Espryt inproc: base, A. 3. Magma monolith: base, A, A+`MOBILEGL_MAGMA_EXTRA_SWAPCHAIN_IMAGES=1`, A+`=2`. 4. Magma inproc: base, A, A+1, A+2. - Engagement proof: - Magma A: logcat has the latched "goes to the queue ahead of its window pass" line, and the trace shows QueueSubmit per frame rising from ~1.33 to ~2.3 (one submit just before the blit's draw, one at present). - Extra images: logcat shows `Set minImageCount = 6/7 (surface min 5 ...)`. - Espryt A has no marker. Take one extra `gpu.renderstages` capture per Espryt arm (it works on this device); it should show A's off-screen stages running before R. - Analysis, with the scratchpad `p15/pwait/` scripts: - `cycle.py