Files
MobileGL/docs/Disaggregated/notes/p5d/reports/D.md
T
swung0x48 aba329cc53 [Docs] (Disaggregated): P6's plan and contract draft; the working notes move from a local directory into the repository
Two things land together: the plan and contract draft P6 needs before it starts, and the
working notes that until now existed on exactly one machine.

1. P6's plan and contract draft (neither started; not one line of code)

     docs/Disaggregated/P6-SPAWN-PLAN.md      the package plan
     docs/Disaggregated/P6-CONTRACT-DRAFT.md  the contract draft (moves to
                                              MG_Remote/CONTRACT-P6.md when c6 lands)

   The three findings that mattered, all written into the documents:

   a. P6 is SMALLER than its roadmap cell suggests. The transport primitives are already in
      the tree and already tested: SocketDoorbell including hangup detection and the two
      lost-wakeup fences (Doorbell.cpp:211-360, four cases in FdPassingTest), SCM_RIGHTS fd
      passing, ShmSegment::Adopt, and the SurfaceOp / WindowKind schema already defined in
      protocol.fbs. inproc uses a real second shm mapping rather than an alias, and
      SessionRings.h:22-28 says that was done so today is not the first time it runs. P6 is
      assembly plus a control plane plus a process, not "write a socket transport".

   b. The evidence for "P6 is only a transport swap" has EXPIRED. It was bought by P5c, whose
      audit found 59 direct memory accesses in a tree that already looked clean. P5e changed
      the tree again. So the first package, a6, is a read-only audit that writes no code, and
      every contract row that depends on it is marked PENDING a6 rather than decided in
      advance.

   c. P6 does not get a game running on the phone. ANativeWindow* is meaningless in the child,
      and protocol.fbs:186-196's own comment says Android transfers the window out of band -
      that band is P12. P6 lands pbuffer / surfaceless / offscreen, which is what HeadlessGL,
      the split-equivalent lane and trace replay need, and which is exactly what P6's own exit
      gate measures. A real window reaching the server is a named refusal. This gets its own
      section because "P6 is done" would otherwise read as "the game runs now".

   The contract adds rule G: a role may not name a process-local handle. It extends P5c's
   rule E (a role may not name the other role's memory) from memory to identity - EGLDisplay /
   EGLSurface / EGLContext / ANativeWindow* / fd number / pid / mapped address either cross as
   a token this contract defines or they do not cross, and a descriptor crosses only through
   SCM_RIGHTS, where the kernel renumbers it.

   What P5e left for P6 gets its own section. The latch semantics are already designed
   (ARCHITECTURE.md:504) and are copied unchanged. What is new is that after run-ahead the
   client no longer waits while the peer process can DIE: the client holds records that were
   published and will never be applied, and Android's freezer suspends the child without
   closing anything. So slow and dead must be distinguishable WITHOUT a timeout threshold.
   That is P6's only new design; everything else is carriage. It shares a predicate with E1's
   outstanding re-specification (ID-122), and the two should be designed together.

   Two gates carried over from P5e's lessons: G2 requires integration-spawn's test-name set to
   match integration-split's name for name, because a differing set means the lane is quietly
   running less; and the arm-proof gate requires every entry to record the child pid and
   transport=spawn in its OWN private log - ID-124 established that this kind of false green
   really happens, and a spawn lane that actually ran monolith would pass every other gate.
   S2's shape must be settled before it is written, and the document says why: E1 is the
   cautionary case.

2. The working notes move into the repository (docs/Disaggregated/notes/, 415 .md files)

   ROADMAP / CURRENT_STAGE_PROGRESS / MEASUREMENTS cite ruling numbers, audit line numbers and
   the provenance of gate figures from ~/w7/notes/, a directory that exists on one machine and
   is under no version control. A record cited hundreds of times that lives only locally is not
   a record, so it moves in whole, with its directory structure intact.

   Only .md moves. Scripts, run logs, census and gate result JSON, patches, APKs and nm dumps
   stay where they are: they are evidence DATA rather than documents - large, regenerated per
   run, and mostly carrying absolute paths and device serials that mean something only on the
   machine that produced them. The mapping and this split are written in
   docs/Disaggregated/notes/README.md.

   Two exceptions are recorded there too: P6's BRIEF has been promoted to a first-class
   document and keeps no copy under notes/, so the two cannot drift apart; and recovered/ holds
   material salvaged from failed workflow runs, which may duplicate the like-named phase
   directories and is kept because a few of those were the only complete version at the time.

   CI's citation lint takes docs/Disaggregated/*.md only (non-recursive), and notes/ is
   deliberately outside it: those notes' file:line citations were written against their own
   code heads, and resolving them against today's HEAD would fail wholesale - which is not an
   error, it is their correct state as a historical record. To check one, use the revision it
   declares (--rev).

3. Inconsistencies fixed in passing

   - README.md's status line still said P5e was in progress while the stage table already said
     closed.
   - CURRENT_STAGE_PROGRESS's next-step item 1 still listed the exit gate as pending, though
     (a) and (b) were run in the previous commit; rewritten to closed plus the one item that
     actually remains (E1).
   - ROADMAP's P6 row still had the pre-phase plan in three of its cells; it now carries the
     package order, the scope boundary and the two new gates.

Checked with check_doc_citations.py: 125 citations across docs/Disaggregated/*.md, 0 problems.
2026-09-18 23:31:39 -04:00

223 lines
18 KiB
Markdown

# P5d round 3, package D - role guards and TLS made nearly free
Worktree `MobileGL-p5d-D`, branch `p5d-D`, base `85362a85`. Not built, not committed, not pushed.
Patch `scratchpad/p5d/patches/D.patch` (618 lines, 11 files). **What changed, per file:**
### `MG_Remote/Server/ServerLoop.{h,cpp}` - item 1, the point of the package
- New `Server::Detail::g_applyThreadKey` (`inline std::atomic<Uint64>`, header) + `Detail::CurrentThreadKey()`.
On aarch64 (`__has_builtin(__builtin_thread_pointer)`) the key is the thread pointer - one
`mrs TPIDR_EL0`, no call; elsewhere `std::this_thread::get_id()` memcpy'd into a `Uint64`. x86_64
deliberately stays on the fallback: the builtin only grew x86 support in clang 17 / GCC 11, and older
toolchains accept `__has_builtin` for it and then fail in codegen - a build break on the desktop
ctest lane for a target nobody profiles.
- `OnApplyThread()` is now **inline in the header**: `key != 0 && key == CurrentThreadKey()`. Gone:
`ServerLoopInstance()`'s function-static guard byte, the acquire loads of `m_running` and of
`atomic<std::thread::id>`, `std::this_thread::get_id()`, the out-of-line `pthread_equal`, and the
cross-library PLT hop for every caller that includes the header.
- `m_applyThreadId` is **deleted**. The key is published as `ApplyThreadMain`'s first statement and
cleared **inside the same `m_controlMutex` critical section that clears `m_running`** (the C2 exit
block), so the two move together and the "live but exiting apply thread" window is byte-for-byte the
old one. `Stop()` no longer clears an identity after the join.
- Relaxed on both sides. The one dangerous shape - a new thread reusing the exited apply thread's TLS
block (hence its thread pointer) while still reading the pre-clear key - cannot happen: the clear is
sequenced before that thread's exit, the exit releases the TLS block under libc's own lock and the
receiving thread takes the same lock, a happens-before chain a relaxed load must respect by
coherence. A stale *zero* is harmless. The argument is written out at the declaration.
### `MG_Impl/Pipe/SlotAllocator.{h,cpp}` and `MG_State/.../MipmapStorage.{h,cpp}` - item 2
`g_reverseAnnouncementScopeDepth`, `g_frontendKeyedRegistryScopeDepth` and
`g_textureLegacyArmScopeDepth` stop being `thread_local`. The three scopes increment/decrement only
when `OnApplyThread()` is true, each keeping that decision in a new `Bool m_counted` so the destructor
undoes exactly what the constructor did (re-asking would leak a count across the apply thread's exit).
**Single-writer proof, in the files:** each counter's only reader in the tree is its apply-thread
guard - `MGPipeRefuseAllocatorFromApplyThread` (`SlotAllocator.cpp:30-31`),
`RefuseLegacyTextureArmFromApplyThread` (`MipmapStorage.cpp:44`) - and both return before they look
unless the transport is active **and** `OnApplyThread()`; I grepped every `::Active()` call site and
those three are all of them. So the apply thread is sole writer and sole reader, and a monolith process
never reads them. `Active()` on the apply thread is unchanged; off it, it is now always false, and both
headers say a future reader has to account for that.
### `MG_State/GLState/BufferState/BufferObject.cpp` - item 3
The Fatal body is split out as `FatalLegacyBufferArmFromApplyThread` (`[[noreturn]]`).
`SyncPersistentMappedRange` called `RefuseLegacyBufferArmFromApplyThread` and then re-asked
`PushIsArmed()`/`OnServerRole()` for an early-out whose condition was *identical* - so its `return` was
unreachable, the guard having already aborted. It now asks once; no accessor makes more than one
`OnApplyThread()` call. `PushIsArmed()` is a read of `MG_Config::Transport`; left alone.
### `MG_Remote/Client/ClientSession.cpp` - item 3
`RefusePipeInputsTouchWhileApplierOwnsIt` reordered cheapest-first: `Transport == Monolith`,
`Ipc.BatchWaits != 0`, then `OnApplyThread()`, `Started()`, `BarrierArmed()`,
`ApplyThreadIsInsideApplier()`, and the `thread_local` `InBarrierWait()` **last**. The chain is a pure
conjunction (every row returns "no violation"), so which touches abort is unchanged; only the cost of
answering "no" moves. With the shipping default `MOBILEGL_IPC_BATCH_WAITS=1` it is two loads on a path
`MGPipeValidateForVerb` reaches per verb (PipeFill.cpp:542, :1951, :2789).
### `MG_Remote/Client/WireTables.cpp`, `.../PersistentMapTracker.cpp`
Comments only. `RunsAsTheServerRole()` and `PersistentMapTracker::OnServerRole()` stay out of line
**on purpose**: inlining them would put a server header inside `WireTables.h` (whose comment says it
exists so PipeFill need not include one) and inside `PersistentMapTracker.h` (which MG_State's
`BufferObject.cpp` includes). One PLT hop is that boundary's price; the four-part predicate behind it
- the measured cost - is gone either way. `IsLivePersistentMap`'s row order is *not* touched: it is
contractually the same chain as `SyncPersistentMappedRange`'s, and item 1 already made its one guarded
accessor (`IsMapped`) cheaper than the reorder would have.
## Why (profile at head 56a77348)
`OnApplyThread()` 2.66% self / 3.64% incl **on the client thread** for an answer that is "no" every
time, plus `@plt` 6.1% self on the apply thread (item 1). `__emutls_get_address` 7.7% and the **top
symbol** on the monolith GL thread, 7.4% on the apply thread, 32.7% of it exactly these scopes
(FrontendKeyedRegistry ctor 18.75 + dtor 13.95, ReverseAnnouncement 8.4), built at ~20 sites in
DirectGLES.cpp including inside `StateBackendObjectRegistry::HandleOf` per draw (item 2). Every cycle
cut on the apply thread shortens the client's R-1 wait one-for-one, so both pay on both threads.
## Red-once
- **Item 1**: `MG_Test/Wire/ServerLoopTest.cpp:291-324` - a posted control request records
`OnApplyThread()` inside itself (`EXPECT_TRUE`) and the test thread asserts `EXPECT_FALSE` after;
publish the key late, clear it early or compare the wrong identity and it goes red. Backed by the 5
`MGL_BUFFER_GUARD_TEST` + 9 `MGL_TEXTURE_GUARD_TEST` cases (`RemoteClientTest.cpp:1841-1912`), each
needing `OnApplyThread()` true inside `RunOnApplyThread`.
- **Item 2**: three cases **added** to `RemoteClientTest.cpp` after the buffer guard block:
`AnAllocatorProbeFromTheApplyThreadOutsideEveryScopeIsFatalByName` (the control),
`AnAllocatorProbeInsideAnExemptionScopeOnTheApplyThreadIsAllowed` (red if the increment is dropped)
and `AnExemptionScopeHeldOnTheGLThreadDoesNotExemptTheApplyThread` - **the one that pins the
single-writer argument**: the scope is open on the GL thread across the posted request and the apply
thread's probe must still abort; make the counter unconditional again and it goes green while a real
violation is silently exempted.
- **Item 3, ClientSession**: pure reorder, no new test; `ClientPipeInputsFillWhileTheApplierOwnsIt
IsFatalByName` (batch off -> Fatal), `...IsAllowedWhenBatchingIsOn` and
`...WithTheApplierIdleIsAllowed` already drive both sides of the row I moved to the front.
**BufferObject**: `BufferSyncPersistentMappedRangeFromTheApplyThreadIsFatalByName` still demands
`Fatal{RoleViolation, "buffer-legacy-arm"}` from that path.
## G1 / monolith impact
**G1 = 0/0/0/0.** Nothing reaches the pull build: MG_Remote and `MG_Test/Wire` are not compiled there
(`MG_Test/CMakeLists.txt:105`), `SlotAllocator.cpp` is a `MOBILEGL_PIPE_PUSH` file (disagg forces push
on, `CMakeLists.txt:496-500`), and every hunk in `BufferObject.cpp`, `MipmapStorage.h` and
`MipmapStorage.cpp` - the new `m_counted` member included - sits inside `#if MOBILEGL_BUILD_DISAGGREGATED`.
**Push build under `Transport=Monolith`**: identical by inspection - no `ServerLoop` runs, so the key
is 0 and `OnApplyThread()` is false exactly as the old predicate was; the scopes count nothing and
`Active()` is false, which the only reader never reaches (first line returns on Monolith). G14: three
names added, none removed. G2: the added names sit in a disagg-only suite beside names already there.
## Risks
1. `__builtin_thread_pointer` is the one new intrinsic; if unavailable the `#if` falls back and only
the speedup is lost. **Not compiled** (no-build rule), so this is inspection only.
2. The relaxed-load recycling argument leans on libc synchronising TLS-block reuse; it is written out
at the declaration so a reviewer can reject it, and an acquire load would cost one `ldar` and keep
every other saving. 3. `Active()` is now always false off the apply thread - harmless today (one
reader, proven), latent for a future second reader; both headers say so.
## What I did not do (item 4 inventory)
- `g_inBarrierWait` (`ClientSession.cpp:117`) - ~900+ set/clear pairs per frame. **Must stay TLS** (the
file's note: a second GL thread would read the first one's wait as its own), so single-writer fails;
only mitigated by moving its read to the end of the guard chain.
- `t_activeDecoder` (`PipeWireCodec.cpp:359`; read :1289/:1292, saved/restored :1385-1393) - per
applied record, so it *is* hot. **Not changed**: its comment says a unit suite drives several
decoders from several test threads, so the apply thread is not its only writer.
- `g_reply` (`PipeRoute.cpp:61`) - the monolith adapter's one-deep reply mailbox, per reply-slot row
rather than per draw, and per-thread by design (R-1 bounds the in-flight depth per thread). **Kept.**
- Cold, off the per-draw path: `tl_initializing` (`Init.cpp:31`), `tl_isPoolThread`
(`ShaderCompilePool.cpp:30`), `t_current` (GLX/WGL), the `static thread_local Vector<GLuint> names`
scratch buffers (`GL_ProgramPipeline.cpp:74,83`, `GL_Sampler.cpp:309,342`), `paddedUbo`
(`UniformManager.cpp:2072`, DirectVulkan); `DirectGLES.cpp:12828` already says why the owner stamp is
not thread_local.
- Also not done: inlining `RunsAsTheServerRole` / `OnServerRole` (layering), reordering
`IsLivePersistentMap` (contract) - both argued in the code comments. No build, no ctest, no device
run (the brief forbids all three): every claim is by inspection, no number was re-measured.
## Fix round
Both reviewers returned `apply-with-minors`, no blockers. Every major and every minor below is
addressed in code except the two that are explicitly integration notes. Still not built, not
committed, not pushed; no attribution lines. Patch regenerated (770 lines, 12 files).
### MAJOR (both reviewers) - the key clear at the `ServerLoop.cpp` C2 exit had no red-once. **Fixed.**
Both reviewers made the same argument and it is correct: the old predicate was
`m_running && id == mine`, so an uncleared identity was harmless by construction; the new one has no
second term, and delete-the-store left every test in the tree green. New case in
`MG_Test/Wire/ServerLoopTest.cpp:829` (right after the C2 `AForwarderCallAfterTheLoopStopped...`
case), `AThreadCreatedAfterTheLoopStoppedIsNotMistakenForTheApplyThread`, in three parts:
- `ASSERT_NE(Detail::g_applyThreadKey, 0)` while the loop is parked, so the post-Stop `EXPECT_EQ`
cannot pass for the wrong reason (a key that was never published is also 0);
- `EXPECT_EQ(Detail::g_applyThreadKey, 0)` after `fixture.Stop()` - reviewer 1's deterministic half;
it pins the **presence** of the store on every platform;
- reviewer 2's half, which pins **why it matters**: 8 sequentially created-and-joined
`std::thread`s, each asserting `ServerLoop::OnApplyThread()` false. With the store removed the
first thread to inherit the exited apply thread's TLS block (hence its thread pointer) answers
true and the case goes red for its own reason. It is second, not alone, because TLS-block reuse is
likely, not guaranteed - the case says so in its own comment.
**Position** (reviewer 1's second half). I did not add a `Detach()`-time hook: it would put a third D
hunk into `ServerLoop.cpp` and deepen the overlap with package T, for a property no unit case can
reach anyway. Instead the C2 comment now names the hazard concretely (`ServerLoop.cpp:458-472`):
`ServerLoopTest` has no GL context, so `CreateBackend` never runs and the
`~BackendObject_DirectGLES` -> `ReleaseEGLResources` self-post does not exist here; move the store
above `m_backend.reset()` and that post misses `RunOnApplyThread`'s re-entrancy arm
(`ServerLoop.cpp:576`, `if (OnApplyThread()) return work(user);`) while `m_running` is still true,
publishes into the one-slot mailbox and waits on `m_controlDone` for an answer only that thread could
give. The comment says in as many words: do not move this on the strength of a green ctest.
### MINOR (both) - the `std::thread::id` fallback copies an object representation with no padding check. **Fixed.**
`ServerLoop.h:178`: added `static_assert(std::has_unique_object_representations_v<std::thread::id>)`
beside the size assert, plus `#include <type_traits>`. I chose this over
`std::hash<std::thread::id>{}(id) | 1u`: the hash is well-defined, but `| 1` is injective only
because today's `pthread_t` happens to be pointer-aligned, and on MSVC (an FNV hash of the id) it
would fold two distinct threads onto one key with no diagnostic - a guard answering **true off the
apply thread**, which is worse than the failure being fixed. A padding implementation is now a build
error, which is the loud-not-silent direction CONTRACT-P5C rule E asks for. The comment beside it
names the concrete consequence both reviewers derived (lost re-entrancy shortcut -> `m_controlDone`
deadlock; exemption scopes stop counting -> `MGPipeRefuseAllocatorFromApplyThread` aborts at the
per-draw `HandleOf` probes).
### MINOR (reviewer 2) - the selector macro is unguarded, never `#undef`'d and not forceable from the build. **Fixed.**
`MOBILEGL_SERVER_APPLY_THREAD_POINTER` -> `MOBILEGL_DETAIL_APPLY_THREAD_POINTER`
(`ServerLoop.h:150-161`): `#ifndef`-guarded, always **defined** (to 0 when unavailable) and tested
with `#if`, not `#ifdef`. `-DMOBILEGL_DETAIL_APPLY_THREAD_POINTER=0` now really disables the builtin
path - the escape hatch for risk 1 the package named itself - and no longer collides with the
header's own define. The nested `#if defined(__aarch64__) && defined(__has_builtin)` /
`#if __has_builtin(...)` shape is kept (GCC < 10 must not evaluate the inner line).
### MINOR (reviewer 2) - `Active()` is silently thread-conditional in public headers. **Fixed by renaming.**
`MGPipeReverseAnnouncementScope::Active()`, `MGPipeFrontendKeyedRegistryScope::Active()` and
`MGPipeTextureLegacyArmScope::Active()` are now `ActiveOnApplyThread()` (declarations
`SlotAllocator.h:205`, `:229`, `MipmapStorage.h:70`; definitions `SlotAllocator.cpp:83`, `:101`,
`MipmapStorage.cpp:94`; the three readers `SlotAllocator.cpp:28-29`, `MipmapStorage.cpp:44`; the
comment at `RemoteClientTest.cpp:1861`). I re-grepped the tree myself: those are all of them, so this
is a closed rename with no external caller. Chose the rename over an `MGLOG_E_ONCE`/assert inside the
query because the guard reads it on the apply thread on the per-draw path, where a diagnostic is
either free and useless or costly and hot; a name that carries the precondition costs nothing and
cannot be ignored by the future P4b/P7 reader the finding is about. Both headers' comments now say
why the name is spelled that way.
### MINOR (reviewer 1) - `docs/Disaggregated/ROADMAP.md:69`'s G4 citation went stale. **Fixed, but OUTSIDE `D.patch`.**
Confirmed: `MobileGLResult ServerLoop::RunOnApplyThread(` is at `:543` at baseline `85362a85` and at
`:570` in this worktree (the fix round's C2 comment moved it a further 15 lines), and the old `:598`
end maps line-for-line to `:625`. The cell now reads `ServerLoop.cpp:570-625`. **`ROADMAP.md` is not under `MobileGL/`**, so the mandated
`git diff HEAD -- MobileGL` cannot carry it; it is delivered as a separate one-line patch beside it,
`scratchpad/p5d/patches/D-docs.patch`. Integrator: apply both.
`python scripts/check_doc_citations.py docs/Disaggregated/ROADMAP.md` -> 56 citations, 0 problems.
Note for the integrator: package T also moves `RunOnApplyThread`, so this cell needs re-checking once
T lands - the gate will not catch it (it only resolves the file and counts lines).
### MINOR (both) - D's `ServerLoop.cpp` hunk overlaps the C2 block package T rewrites. **No code change; integration note, as both reviewers asked.**
Accepted as stated, and I deliberately did **not** shrink the hunk: the key clear has to be inside
the same `lock_guard(m_controlMutex)` critical section as `m_running.store(false)`, or the window in
which a live-but-exiting apply thread answers true moves - which is the property item 1's equivalence
argument rests on. Land **D before T**, or rebase T onto D, and at the merge check that T's
`m_controlPosted` clear and D's `Detail::g_applyThreadKey.store(0)` both end up inside that same
section beside `m_running.store(false)`. The new ServerLoopTest case above is what makes a merge that
drops the clear fail loudly instead of landing green. The `ServerLoop.h` overlap (D prepends the key
block, T adds park counters) is textual only - different regions of the file.
### Findings I did not act on
None outstanding, and nothing in either review was disputed. The two items left uncoded are the two
both reviewers themselves scoped as "flag it to the integrator" (overlap ordering) and the one the
patch boundary cannot carry (ROADMAP, delivered separately).
### Re-verification after the fix round
- `python scripts/check_include_closure.py` -> 4 probes, 0 skipped, 0 problems (unchanged; the rename
and the `<type_traits>` include add no header->header edge).
- `python scripts/check_doc_citations.py docs/Disaggregated/ROADMAP.md` -> 0 problems.
- Doc-citation sweep over every file this package touches: the four `ClientSession.cpp` citations in
`ROADMAP.md` / `CONTRACT-P5C.md` are all at or below `:880` and both D hunks start at `:996`, so
none moved. No document names `Scope::Active()`, so the rename breaks no citation.
- G1 unchanged and still 0/0/0/0: the new test is in `MG_Test/Wire` (not in the pull build), the
rename and the new comments are all inside `#if MOBILEGL_BUILD_DISAGGREGATED`, and `<type_traits>`
enters only `MG_Remote/Server/ServerLoop.h`, which the pull build does not compile.
- Still no build, no ctest, no device run (the brief forbids all three). Every claim here is by
inspection or by a read-only script.