- ROADMAP, MEASUREMENTS and ARCHITECTURE are rewritten as summaries (333 KB of index text -> 84 KB). ROADMAP's phase table is one line per phase plus a link; MEASUREMENTS keeps headline numbers under its old §1-§13 numbering (plus §14-§16 for P6.5, P7 and P12); ARCHITECTURE keeps every decided rule under the same §1-§17 / appendix numbering, so the `ARCHITECTURE.md §N` references in code still land, and §17 becomes an index of the phase landing shapes. - CURRENT_STAGE_PROGRESS now describes the current stage (the P12 on-screen-server subset) and absorbs CURRENT_STAGE_STATUS, which is removed. The P7 status tables, the P6 closing snapshot and the P6.5 first-wave snapshot move to notes/p7, notes/p6 and notes/p65. - Each phase gets notes/<phase>/README.md: a short summary, then the verbatim material that left the index documents (its ROADMAP row, its MEASUREMENTS section with subsection numbers kept, its ARCHITECTURE §17 part, milestones) and a file index. P8-P11 and P13 get their planned scope; the full open-question list moves to notes/OPEN-QUESTIONS.md. - The phase reports that sat at the top level move into their phase folders with unchanged names: P5D-INPROC-PERFORMANCE, P5E-RUNAHEAD, P5F-WIRE-COMPLETENESS, P6-SPAWN-PLAN, P6-CONTRACT-DRAFT, P6-ENDSTATE-REVIEW. Their relative links, CONTRACT-P6.md's links to them and one tool docstring are updated; notes/README.md maps every old path and section number to its new place. - 71 files under notes/recovered/ that were byte-identical copies of phase-folder files are removed. - Corrections made while condensing: the server process reads Transport as Spawn (CONTRACT-P6 §3.1 amended the old "child forces monolith" rule); both backends now publish kCapRunAheadApply; MOBILEGL_IPC_RESPAWN is a named refusal and MOBILEGL_IPC_IDLE_EXIT_S is not parsed; the Redmi pin row is in tools/device_bench/pin_device.sh. check_doc_citations over docs/Disaggregated/*.md: 0 problems. No relative link is newly broken.
device_bench/p6 — the device A/B session runner
Two scripts that run a paired performance comparison on a real device and turn its evidence into markdown tables. They exist because the quantity a disaggregation A/B reads on (tenths of a millisecond of client CPU per frame) is smaller than the drift a hand-driven session accumulates between the CPU samples, the pin evidence and the library's own counters — and those three come from three different mechanisms. Collected by hand they are minutes apart; collected by one process they are one window.
| file | role |
|---|---|
ab_session.py |
collects: reboot, pin, launch each arm in order, sample per-thread CPU, pull role logs, verify the arm |
render_ab.py |
renders: session-summary.json → the markdown tables a report needs |
The split is deliberate. The renderer reads only what the runner recorded, so it cannot influence what was collected.
Usage
# One arm is TRANSPORT[:BACKEND[:CASE[:REPEATS]]]; repeat a spec to interleave it.
python tools/device_bench/p6/ab_session.py \
--tree /c/Users/me/mobilegl-snapshot \
--out /c/Users/me/evidence \
--apk /c/Users/me/evidence/trace-debug-signed.apk \
--package top.mobilegl.plugin.trace \
--reboot --fan-level 2 --repeats 3 --session my-session \
--arm monolith:DirectGLES:minecraft-1.21.4-rd12-odinlite-in-world:3 \
--arm inproc:DirectGLES:minecraft-1.21.4-rd12-odinlite-in-world:3 \
--arm spawn:DirectGLES:minecraft-1.21.4-rd12-odinlite-in-world:3
python tools/device_bench/p6/render_ab.py /c/Users/me/evidence/my-session/session-summary.json
--tree is the tree the APK and the runner come from. Point it at a snapshot, not the shared
tree, when anything else is editing the repository: a build that picks up a half-written source file
measures nothing, and the failure is invisible in the result. --apk must be signed with a key the
device already trusts for that package — the Gradle release build has no signing config locally
(SIGNING_* are CI secrets), so it has to be signed by hand first:
apksigner sign --ks ~/.android/debug.keystore --ks-pass pass:android \
--ks-key-alias androiddebugkey --key-pass pass:android --out signed.apk unsigned.apk
--package must match the APK's application id. A development build usually needs a suffix
(-Pmobilegl.applicationIdSuffix=.mine) so it can sit beside an existing install rather than
replacing it: two APKs signed by different keys cannot share an id, and adb install -r fails with
INSTALL_FAILED_UPDATE_INCOMPATIBLE. The runner then needs MOBILEGL_TRACE_PACKAGE set to the same
id (see run_android_retrace_local.py's note beside BACKENDS), which ab_session.py does itself.
What the protocol guarantees
The whole point of the script is that these are not optional:
- Reboot-clean.
--rebootreboots, waits forsys.boot_completed, and compares the boot id before and after. An unchanged id means no reboot happened, which is reported as a failure rather than recorded as a clean window. - Pinned frequencies, with evidence. Every arm runs
pin_device.sh <serial> pinbefore and... checkafter; a non-PINNEDexit voids the arm (status: UNPROVEN), it does not degrade it. Un-pinned per-thread CPU is not comparable across arms. - One thermal window. All arms in one session, interleaved by the order the
--armflags are given, with a cool-down between them so a later arm is not measured hotter than an earlier one. - Arm proof. Each arm is checked against the library's OWN private log: the transport marker, the
split knobs in the
Config: IPCline, and forspawnthespawn ARMED - the server role runs in pid Nline — which only the client writes after launch, connect and handshake all succeeded. A spawn lane that silently fell back to monolith passes every other check and fails this one.
Evidence layout
<out>/<session>/
session-summary.json # session metadata: boot ids, fan, APK hash, per-arm summaries
<NN>-<case>-<backend>-<transport>-r<N>/
arm-summary.json # this arm: status, problems, cpu, stats, benchmark runs
pin-before.txt pin-after.txt
mobilegl.client.log mobilegl.server.log # per role
benchmark-run1..N.json # per-frame wall + CPU series per repeat
benchmark.json result.json logcat.txt runner.stdout.txt runner.stderr.txt
Two asymmetries of the spawn shape that the renderer handles
Both are structural — the client and the server really are different processes — and both produce a zero that looks like a measurement if read carelessly:
-
srv/srvparklive in the server log. The apply thread publishes them into its own process's PipeStats. Underinprocboth roles are threads of one process, so the client's summary line carries all four numbers; underspawnthe client printssrv=0 srvpark=0, which means "another process", not "never waited".render_ab.pyreads the server pair frommobilegl.server.log. -
The frame count lives in the server log too.
frames=is bumped byPipeStats::OnPresent, which the backend calls on Present — and underspawnpresent is applied in the server process, so the client'sframes=0 window=0. That zero is the same shape assrv=0. The per-frame rates therefore divide by whichever log counted the frames, and the rendered table names which one that was.
Building the APK
gradle --no-daemon -p <tree>/android-plugin :app:assembleTraceRelease \
-Pmobilegl.buildDisaggregated=ON -Pmobilegl.buildDisaggregatedInproc=ON \
-Pmobilegl.pipePush=ON -Pmobilegl.debuggableRelease=true \
-Pmobilegl.applicationIdSuffix=.mine -Pmobilegl.apkSuffix=mine
-Pmobilegl.debuggableRelease=true is not optional: without it the release APK is not run-as-able,
trace-replay-ci.sh cannot pull the private logs, and every arm produces no library log — which is
indistinguishable from a run that produced none.
Pitfalls this tooling works around
--transportis ignored in--benchmarkmode.run_android_retrace_local.pyapplies--transportonly on itsrun_casepath;run_benchmark_casepassesargs.envalone. So--benchmark --transport spawnruns monolith under a spawn label. This script passes the transport as an explicit--env MOBILEGL_TRANSPORT=…and verifies it from the library's log.MOBILEGL_RETRACE_USE_PBUFFERmust be in the CI script's environment, not passed as--env—trace-replay-ci.shreads it itself to decide the surface shape. Without it thespawnarm dies withFatal{UnmigratedSurface, "AndroidNativeWindow@P12"}, because anANativeWindow*means nothing in the server's process (Rule H).MOBILEGL_TRACE_SKIP_INSTALL=1keeps the script's ownadb installout of the window. The session installs once itself and waits fordex2oatto exit first: anam_kill … due to installPackageLIlanding inside an arm measures the installer.- A stale
benchmark.jsonis worse than none. The runner's result directory is keyed by case and backend, not by arm, so a failed arm can leave the previous arm's file whereread_benchmarkwill pick it up. The directory is deleted before each arm.
Known limit
Under spawn, the client's windowed fields (frames=, window=, draws/f, wrec/f,
bytes/f[…]) are structurally zero: PipeStats::OnPresent runs in the server process (the apply
thread applies the present record), so the spawn client never counts frames. Dividing any counter
by the client's frames= is undefined. The renderer therefore takes the frame denominator from the
log that actually counted frames (normally the server's), and names the source in the table.
What is valid from the client log is the run-total gauges (cli, clipark, maxrec,
ringwraps, …): PublishGauge is a store, not an add, so the client's terminal Shutdown() line
reports true run totals. Symmetrically, the server log's cli=0 and the client log's srv=0 mean
"the other process", not "never waited".
The server's own byte/record/wait classes are available since PipeStats::Init() runs in
ServerMain.cpp (P6 closeout; before that the server log carried no summary line at all and srv
had to be reported as unavailable).