Files
MobileGL/docs/Disaggregated/notes/p5/scout-gpu-writes-and-persistent-map-push.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

589 lines
42 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# P5 scout — GPU-written tracking and the persistent-map push
Tree read: `/home/swung/w7/pipe`, branch `feat/disaggregated` @ `a29807cc` (P4a landed). READ ONLY —
nothing was edited, built or run. Every line number below was opened in this tree at that commit.
Repo-relative paths are under `MobileGL/` unless they start with `docs/` or `tools/`.
P5's row is `docs/Disaggregated/ROADMAP.md:21`. The design of record for this subject is
`docs/Disaggregated/ARCHITECTURE.md` sections 8.1 (lines 275-290), 8.2 (292-294) and 12 (479-502).
---
# PART 1 — MarkGpuWritten / SyncGpuWrites
## 1.1 What they mean today
`BufferObject::MarkGpuWritten()` — `MG_State/GLState/BufferState/BufferObject.cpp:364-367`. Three lines,
and it sets **two** flags:
```
m_hasDefinedContent = true;
m_gpuWritePending = true;
```
Declared `BufferState/BufferObject.h:185`; the flag's own comment is at `BufferObject.h:267`
("Set by MarkGpuWritten, cleared by SyncGpuWrites once the shadow is refreshed"). The contract at
`BufferObject.h:180-184`: "the flag only says a GPU write is outstanding" — whether the answer is a pull-back
or a wait is the backend's business.
`BufferObject::SyncGpuWrites()` — `BufferObject.cpp:369-395`:
* `:370` early-out when `!m_gpuWritePending`;
* `:372-374` clears the flag **unconditionally**, with the reason stated: "without a readback op the shadow
can never catch up, and retrying on every subsequent read would only repeat the same no-op";
* `:375-387` under `MOBILEGL_PIPE_PUSH` + `MGPipeResourceSubsystemEnabled()`: `MGPipeEmitResourceReadback(*this)`
(`:384`) and return. The comment at `:376-381` is the one P5 has to honour verbatim: the answer comes back
through `OnBufferWriteback`, and "in monolith the whole sequence is synchronous inside the applier, so the
caller sees the reconciled shadow **on return**".
* `:389-394` legacy arm: `g_bufferBackendOps->ReadbackFromGpu(*this)`.
## 1.2 The six producer sites (all backend-side today)
`ARCHITECTURE.md:280` says "6 处 `MarkGpuWritten`". They are:
Espryt (3), all in `MG_Backend/DirectGLES/DirectGLES.cpp`:
| line | function | what it means |
|---|---|---|
| `DirectGLES.cpp:556-572` | `MarkShaderStorageBuffersGpuWritten` | every SSBO binding point, once the points are bound and the draw/dispatch is about to go out. Push arm `MarkBufferGpuWritten(obj)` `:566`; pull arm `obj->MarkGpuWritten()` `:570` |
| `DirectGLES.cpp:573-620` | `SyncAtomicCounterBuffers` | every bound atomic counter (`:616` push / `:618` pull). Reason at `:611-614`: every conformance case reads the increment back with `glMapBufferRange`/`glGetBufferSubData` |
| `DirectGLES.cpp:2588-2606` | `MarkWritableImageBufferTexturesGpuWritten` | buffer textures on image units, **only when `Access != GL_READ_ONLY`** (`:2578-2581`), and deliberately from the draw/dispatch preparation and not from `glBindImageTexture`'s eager sync (`:2583-2586`) |
Magma (3):
| line | function | what it means |
|---|---|---|
| `MG_Backend/DirectVulkan/Renderer/UniformManager.cpp:1073-1076` | `ResolveStorageTexelBufferDescriptor` | `EnsureGpuResidentStorage()` then `MarkGpuWritten()`, again only when `Access != GL_READ_ONLY` |
| `UniformManager.cpp:1229-1231` | `ResolveStorageBufferDescriptor` | SSBO block: `EnsureGpuResidentStorage()` then unconditional `MarkGpuWritten()` |
| `Renderer/VulkanRenderer.cpp:11617-11618` | `BeginXfbCaptureForDraw` | the XFB capture targets — "the capture is a GPU write like any shader's" |
**The push-arm funnel is one function**: `BufferImpl::MarkBufferGpuWritten` —
`MG_Backend/DirectGLES/Managers.cpp:2509-2540`. Three things there are contract:
* `:2511-2514` with the subsystem off it falls straight through to `bufferObject->MarkGpuWritten()`;
* `:2516-2528` with the subsystem on and either the handle or `gMGPipeCallbacks.OnGpuWritten` missing it
**deliberately does not fall back** — it logs `MGLOG_E_ONCE` and drops the announcement, so the A/B can see it;
* `:2530-2539` the announcement is **one** range of `{0, kMGPipeWholeBuffer}`, never zero ranges, and the
comment at `:2533-2536` states the other half of the contract: the client's `OnGpuWritten` must set **both**
`m_hasDefinedContent` and `m_gpuWritePending`, or the next `resource_respecify` carries
`HasDefinedContent = 0`, `Ops_H_Respecify` orphans instead of uploading, and the shader-written contents
are dropped with nothing saying so.
Handle lookup is `HandleOfBuffer` — `Managers.cpp:2491-2502`, which goes through the twin table's single-entry
front memo (its comment names `MarkBufferGpuWritten` as one of the three hot callers).
## 1.3 The client half that already exists
`MG_Impl/Pipe/ResourceTracker.h:583-604` — `MGPipeClientOnGpuWritten`:
* `:584-592` **asserts** `rangeCount == 1 && ranges != nullptr`, and says why in as many words:
"zero will mean *a fully narrowed set — nothing is dirty* at P8/P9", so a zero-range announcement must not
be read as "mark the whole buffer".
* `:593-599` an unresolvable handle is a dropped `MarkGpuWritten`, i.e. "a stale shadow read back as if it
were current" — `MGLOG_E_ONCE`.
* `:600` `buffer->MarkGpuWritten()`.
Installed by `MGPipeInstallClientResourceCallbacks` — `ResourceTracker.h:606-614`, and only over a slot the
backend did not claim.
`ResourceTracker.h:577-581` states the direction: `OnGpuWritten` is a **NARROWING** channel — the client
builds a conservative pending set at its own emission points and the callback only ever *removes* from it —
but "P3a's implementation marks exactly what the three Espryt `MarkGpuWritten` sites mark today ... The
narrowing itself is P8/P9's."
## 1.4 The consumers — where `SyncGpuWrites` is called
`docs/Disaggregated/MEASUREMENTS.md:111` records the census as "`SyncPersistentMappedRange` / `SyncGpuWrites`
= 20 / 6, 与计划一致". The six **backend** consumers:
* `MG_Backend/DirectGLES/Managers.cpp:4922` — `SyncFloat64AttributeAsFloat32` (legacy-memos arm only)
* `MG_Backend/DirectGLES/DirectGLES.cpp:6186` — the primitive-restart rewrite's CPU read of the source EBO
* `MG_Backend/DirectGLES/MultiDraw.cpp:512` — same shape, multi-draw rebase
* `MG_Backend/DirectVulkan/Renderer/VulkanRenderer.cpp:3541`, `:4012`, `:4346`
Frontend consumers (not in that 6 — they are inside the object / `MG_Impl`):
* `BufferObject.cpp:451` and `:466` — the two "no resident op" fallbacks inside `LandBytesIntoResidentStore`
* `BufferObject.cpp:515` — `FillSubData` (a clear is ordered after earlier GPU writes)
* `BufferObject.cpp:547` — `CopyDataFrom`, on the **source**
* `BufferObject.cpp:561` — `AcquireMemory` (`glMapBuffer`)
* `BufferObject.cpp:639` — `AcquireMemoryRange`, skipped for the one map shape that looks at nothing
(non-persistent write map with an invalidate bit over an adopted store — `:626-638`)
* `MG_Impl/GLImpl/Buffer/GL_Buffer.cpp:957` and `:995` — `GetBufferSubData_State` / `GetNamedBufferSubData_State`
## 1.5 What "client-side conservative MarkGpuWritten" has to become
The whole of today's machinery runs **backend-side**: all six producers read backend-visible state
(`MGB_CTX->GetBufferBindingPoint(...)` for Espryt, descriptor resolution for Magma). In a split build the
backend is the server, so every one of those six is on the wrong side of the line for the decision the
**client** has to make synchronously at `SyncGpuWrites` (`BufferObject.cpp:369`) when the app calls
`glMapBuffer` or `glGetBufferSubData`. Asking the server "did you write this?" is a round trip the design
forbids in steady state.
So P5 owes a client-side pending set. Five concrete consequences:
1. **The set is built from state the client already owns.** Each of the six sites has a client-side twin:
SSBO binding points, atomic-counter binding points, image-unit bindings whose `Access != GL_READ_ONLY`,
XFB targets. `ARCHITECTURE.md:575` already names the component:
`MG_Remote/Client/... GpuWritePending [P5+]`.
2. **Two producers are NEW at P5 and are not among the six.** `ARCHITECTURE.md:508` lists them under the
zero-round-trip column: `glReadPixels` into a pack PBO becomes *fire-and-forget + client-side
`MarkGpuWritten`* ("严格优于 monolith 的无条件停等"), and `glEndTransformFeedback` drops its unbounded
fence wait and instead sets `MarkGpuWritten` on the capture targets. Both are client edits with no server
participation.
3. **The `rangeCount == 1` assert at `ResourceTracker.h:587-592` has to be revisited on the same day.**
It is the exact statement "a zero-range announcement is illegal"; the narrowing channel needs zero to
become legal and to mean "nothing dirty". Two readings: (a) P5 keeps the assert because P5 only *adds*
the client set and leaves the server announcement at whole-buffer, deferring the narrowing to P8/P9 as
`ResourceTracker.h:579-581` says; (b) P5 has to relax it because a client that already marked the buffer
conservatively will get a server announcement it must be allowed to answer with zero. **Reading (a) is
what the docs say**, and the ROADMAP row does not mention narrowing — the brief should say so explicitly,
because a package that reads `ResourceTracker.h:577-581` alone will assume it owns the narrowing too.
4. **`SyncGpuWrites` clearing the flag unconditionally (`BufferObject.cpp:372-374`) does not survive a
transport unchanged.** Today there are two states (pending / not pending) and the clear is safe because
the readback is synchronous inside the applier. Under split there is a third state — *the readback was
emitted and the answer has not arrived* — which this function cannot express: if it clears and the reply
is deferred, the shadow is silently stale for the object's life. `ARCHITECTURE.md:509` lists
"GPU-write pending 的 buffer 首次 CPU 读" among the **unavoidable** blocking points
("monolith 本来就 `glFinish()`"), so the intended shape is *block until `OnBufferWriteback` lands*,
matching P5's "阻塞 `read_pixels`" row. The reverse-channel drain points the client must service while
blocking are enumerated at `ARCHITECTURE.md:472`: `glGetError`, `glGetQueryObject*`, `glClientWaitSync`,
`glGetSynciv`, `eglSwapBuffers`, `glMapBuffer*`/`glGetBufferSubData`/`glCopyBufferSubData`, **and every
iteration of every wait loop**.
5. `ARCHITECTURE.md:292-294` (section 8.2) is the ordering rule that makes this correctness and not
performance: every `WritebackFromBackend` is followed by `BumpBufferMutationEpoch()`, and under split the
epoch bump must be applied by the server *after* the writeback and *before* any later command that reads
that handle — "反向通道需要与正向通道相同的有序保证". `PipeApply.cpp:1815-1846`
(`MGPipeApplyResourceReadback`) already states its half: no serial moves, and the hook is called
synchronously so the writeback and the bump have both happened before the caller reads.
---
# PART 2 — The persistent-map path, and what "block-granularity push" means
## 2.1 What P3a landed
**Three adoption entry points**, all of which call the same emitter under push:
| site | trigger | line |
|---|---|---|
| `BufferObject::TryAdoptLargeStorage()` | every `Respecify` / `AllocateImmutableStorage` of a store `>= 16 MiB` | `BufferObject.cpp:228-244`; threshold `kLargeBufferAdoptBytes = 16u*1024u*1024u` at `:229`; emitter at `:238` |
| `BufferObject::EnsureGpuResidentStorage()` | backend-initiated (Magma calls it on the SSBO / texel-buffer / XFB paths just before `MarkGpuWritten`) | `BufferObject.cpp:592-614`; emitter at `:603` |
| `BufferObject::AcquireMemoryRange()` | the app's own `glMapBufferRange(PERSISTENT\|WRITE)` without `FLUSH_EXPLICIT` | `BufferObject.cpp:645-664`; emitter at `:657` |
All three are guarded against a live mapping (`:233`, `:600-602`) — adoption releases the CPU shadow and a
live mapping may *be* that shadow, so freeing it under the application is a use-after-free
(`BufferObject.cpp:594-599`). `TryAdoptLargeStorage` is additionally gated by
`MG_Config::Features.DisableLargeBufferAdoption` (`BufferObject.cpp:232`, declared `Config.h:208`, env
`MOBILEGL_DISABLE_LARGE_BUFFER_ADOPTION` at `ConfigLoader.cpp:229`).
**`SyncPersistentMappedRange()`** — `BufferObject.cpp:341-353`. It is the per-draw push for the **emulated**
(non-adopted) persistent map only. Early-outs in order: not mapped `:342`; **GPU-resident `:346`** ("this is
the whole point of the persistent-map path — the per-draw whole-buffer re-upload that used to run here is
gone"); not `Persistent` `:347`; not `Write` `:348`; `FlushExplicit` `:349`; empty range `:350`. Otherwise it
calls `NotifySubData(mappedRange)` `:352`, which under push emits `resource_subdata` (`BufferObject.cpp:82-94`).
**`FlushMemoryRange` → `NotifyFlushMappedRange`** — `BufferObject.cpp:97-115`, emitting
`MGPipeEmitResourceFlushRange` at `:107` and carrying the application's **real, unnormalised**
`Flags<BufferMappingAccessBit>` (`:103-106`; emitter `MG_Impl/Pipe/PipeFill.cpp:736-748`; applier
`MG_Pipe/PipeApply.cpp:1787-1813`, with `:1808-1812` repeating that merging the flags would take the upload
shape decision away from the side that pays for it).
**Wire + ops**:
* `MG_Pipe/PipeCalls.def:84` — `X(MapPersistent, MGPHandleOnly, kScreen, kReplySlot|kOptional)`
* `PipeCalls.def:85` — `X(UnmapPersistent, MGPHandleOnly, kScreen, kOptional)`
* `PipeCalls.def:137` — `X(ResourceFlushRange, MGPFlushRange, kCtxObject, kNone)`
* `PipeApply.h:95-96` — `void* (*MapPersistent)(MGPipeHandle res, Uint64 size, const void* seedBytes);`
and `void (*UnmapPersistent)(MGPipeHandle res);`
* applier `PipeApply.cpp:1893-1927` / `:1929-1953`; client emitter `PipeFill.cpp:772-786`.
* **`UnmapPersistent` has no producer and that is deliberate** — `PipeFill.cpp:764-771`: there is no backend
unmap hook to convert, `PipeResource::ReleasePersistentMap()` (from `RedefineStorage`, `BufferObject.cpp:~155`)
tells the backend nothing, and emitting one would be new behaviour under D-J. `MGPipeApplyUnmapPersistent`
is the only one of the four buffer-only calls with a kind discriminator, and it Fatals on a mistyped kind
(`PipeApply.cpp:1938-1946`).
## 2.2 The three Espryt persistent-mapped rings
`Managers.cpp:871-882` declares `struct PersistentRing` (store + `retired` + `frameMarks` + the immutable
knobs). The three instances:
* `g_uboRing` — `Managers.cpp:883`, "Global-UBO ring", alignment taken from
`GL_UNIFORM_BUFFER_OFFSET_ALIGNMENT` at store-creation time (`fixedAlignment == 0`, see `:877-879`).
* `g_unpackRing` — `Managers.cpp:884-890`, "Texture unpack ring".
* `g_uploadRing` — `Managers.cpp:899-906`, "Buffer upload ring", 4 MiB initial / 64 MiB max / 64-byte
alignment (`:830-832`). Its rationale at `:890-898` is the WAR story in one paragraph: Mali's
`glBufferSubData` resolves the hazard by **blocking in the call** (`osup_sync_object_wait`) until every
referencing job retires, which under 26.3's per-frame UBO and chunk-mesh SubData streams serialized whole
frames (~1 fps while chunks stream); staging into this ring and issuing `glCopyBufferSubData` keeps the
hazard on the GPU timeline where it is just job ordering.
Allocation discipline: monotone `head`/`tail` linear cursors (`Managers.cpp:838-844`), `RingAllocate`
`:3320`, slow path `:3343`, `RingOnPresent` `:3427`, and the three per-present drains at
`Managers.cpp:3482/3500/3502` called from `DirectGLES.cpp:12460-12462`.
## 2.3 The >=16 MiB adoption story, and why P5 breaks it
`BufferObject.cpp:206-227` is the primary source for the motivation and is worth quoting to the brief in
full: Minecraft 26.3 streams chunk meshes into 128 MB vertex arenas with plain `glNamedBufferSubData` — the
one write API with no synchronization hint — and on Mali every route that hands the driver a write into a
busy **mutable** store either parks the calling thread (`glBufferSubData`, and `glMapBufferRange` **even with
`GL_MAP_UNSYNCHRONIZED_BIT`**) or ghost-copies the whole destination on a driver worker (~167 ms per touched
arena, the recurring in-world hiccup). Backing the arenas with immutable stores also kills the ghost but
eagerly commits every arena's full extent (+hundreds of MB), which LMK'd the device.
Measured: `MEASUREMENTS.md:87` — "≥16 MiB 可变 store 定义时采纳为 coherent persistent map 后 **p99 163→21 ms、
稳态 40→115 fps、省 ~400 MB**". Restated at `ARCHITECTURE.md:481` with "**整个 monolith 改造期一动不动**
(D-B4),只有 IPC 那一步会打破它", and `MEASUREMENTS.md:471` calls the number "没动过的东西是否真的没动"
的最直接读数. The P3a and P4a A/Bs both re-read it and both stayed in the 21-26 ms band:
`MEASUREMENTS.md:465` (3) (25.457 → 26.322, +3.4%) and `MEASUREMENTS.md:598` (25.297 → 26.841, +6.1%).
**Why a split build cannot keep it.** `MGPipeApplyMapPersistent` returns a raw `void*` that the client stores
as the store's base (`m_resource.AdoptPersistentMap(base)`, `BufferObject.cpp:238` / `:605` / `:658`). Across a
process boundary that pointer is meaningless. `ARCHITECTURE.md:483-490` is the tier table; **P5 runs at tier
T2** (`ARCHITECTURE.md:490`: "`AcquirePersistentMap` 返回 `nullptr`,前端已在三处容忍" — those three places
are exactly `BufferObject.cpp:238`, `:603-606`, `:657-660`) **and at T2 the client-side push is mandatory**
(`ARCHITECTURE.md:496`: "client 侧 persistent map 推送三件套(T2 档强制,P5)"). T0/T1 are P11's
(`ROADMAP.md:28`, `MEASUREMENTS.md:23-34` for the spike-B evidence).
**One genuine ambiguity for the brief.** Under `inproc` the server *is* the same process, so `MapPersistent`
could legitimately return a usable pointer and adoption would still work. Two readings:
* (a) P5's inproc keeps adoption alive and only exercises the push through a forced-T2 negative control;
* (b) P5 forces T2 even under inproc, so inproc and spawn are the same code path.
**(b) is the reading the gates force**, and this is the single most decision-shaped thing in this report: the
P5 exit gate says "`persistent-map-push` 出数" (`ROADMAP.md:21`), and that counter is **structurally zero if
adoption survives** — `PipeStats.cpp:68-70` says so ("today a persistent map is a permanent address space
donation ... so there is no push to count"). `InProcessTransport.h:11-16` argues the same way from the other
end ("It is not a test double ... `inproc` is a delivery mode of its own"). The knob that would express (a)
as a negative control, `MOBILEGL_IPC_ADOPT_TIER` (`ARCHITECTURE.md:494`, `:615`), **does not exist anywhere
in the tree** — a full-tree grep finds it only in those two doc lines. Evidence that would settle it beyond
doubt: whether the brief wants `LargeArenaAdoptionScenario`
(`MG_IntegrationTest/Scenarios/LargeArenaAdoptionScenario.cpp:450`) to stay green under inproc — it asserts
an adoption happens, and it cannot be green at T2 and produce `persistent-map-push` bytes at the same time.
## 2.4 What has to cross, at what granularity
`ARCHITECTURE.md:496-500` is the spec, three items. Restated concretely with the tree's own line numbers.
### (1) A `hasLiveHostWrites` bit — zero new record kinds
`ARCHITECTURE.md:498`: the server only needs to know "does this resource have a live host writer right now",
which is what `IsBufferDrawClean` is trying to say, so the bit rides on the `ResourceRespecify` /
`ResourceSubData` payload.
The **applier-side field already exists**: `MGPipeResourceRecord::HasLiveHostWrites`, `PipeApply.h:225`, with
the comment "ALWAYS FALSE IN P3a, AND WRITTEN BY NOBODY ... a verify build pins that it is false, so that
phase cannot land a silent semantic change under it."
The **wire field does not**. `MGPResourceDesc` (`MG_Pipe/MGPipeTypes.h:283-317`, `MGP_ASSERT_POD(..., 88)` at
`:318`) carries `HasDefinedContent` at `:301` and `ImageBindableHint` at `:302` but no live-host-writes bit;
`MGPSubData` (`MGPipeTypes.h:997-1010`, `MGP_ASSERT_POD(..., 72)` at `:1011`) likewise. **Both structs have
free padding** — `MGPResourceDesc`'s `Uint16 Pad0` at `:303` and `Uint32 Pad1` at `:313`; `MGPSubData`'s
`Uint8 Pad0[3]` at `:1004` and `Uint32 Pad1` at `:1008` — so P5 can take a byte out of a pad and the size
asserts do not move. **But**: `MGPipeTypes.h:304-310` defines the storage-defining-vs-metadata rule, and a
new bit is a **metadata** field (a respecify that moves only it must not force a reallocation ack or clear
`PendingUploads`); it has to be added to the field list stated beside `MGPipeResourceRespecifyNeedsAck`
(P4a ID-18 M4), or the first push of the bit costs a spurious reallocation.
**The pins P5 must lift, all four of them:**
* `PinNoLiveHostWrites` — `PipeApply.cpp:839-850` (verify body) / `:852` (no-op body), called at
`:883` (`ApplyBufferWrite`), `:1630` (`resource_respecify`), `:1804` (`resource_flush_range`),
`:1832` (`resource_readback`) and `:1908` (`map_persistent`). `PipeApply.cpp:1904-1907` names
`map_persistent` as **"THE ONE CALL A LATER PHASE ATTACHES THE PRODUCER TO"**.
* `Managers.cpp:2669-2674` — `MOBILEGL_ASSERT(!record->HasLiveHostWrites, "...P3a has no producer for it")`
inside `IsBufferDrawCleanByHandle`, plus the live `if (record->HasLiveHostWrites) return false;` at `:2674`.
* `Managers.cpp:2675-2681` — the **last frontend read in that function**, `frontend->IsMapped()`. Its comment
at `:2643-2657` is written directly to P5: "The frontend read retires the moment P5 gives
`HasLiveHostWrites` a producer, and it is the last frontend read in this function."
* The regression that comment records is the one P5 will re-commit if it gets this wrong: the first cut of
the handle arm answered `HasLiveHostWrites` alone, an emulated persistent map read **draw-clean forever**,
`SyncPersistentMappedRange` was never reached again, and the frame drew the last uploaded bytes with no
diagnostic (`Managers.cpp:2650-2655`; unit gate `MG_Test/SanityTest.cpp:4583`, narrative `:4566-4582`).
### (2) The dirty bytes, in blocks
`ARCHITECTURE.md:499`, restated concretely:
* **The set**: the tracker keeps `m_livePersistentMaps` = buffers that are `Persistent` + `Write` +
**not** `FlushExplicit` + **not** `GpuResident`. That predicate is literally
`SyncPersistentMappedRange`'s early-out chain (`BufferObject.cpp:346-349`) read as a membership test.
`m_livePersistentMaps` **does not exist in the tree** — the only hit is that doc line.
* **The trigger**: at every validate point, for every member reachable by *this* operation — VAO / index /
indirect / UBO / SSBO / atomic / XFB target, i.e. "the union of the backend's 20
`SyncPersistentMappedRange` sites".
* **The block size**: `MOBILEGL_IPC_PERSISTENT_BLOCK_KB`, **default 64** (`ARCHITECTURE.md:499` and the env
inventory at `:615`), i.e. **64 KiB blocks**. The env var does not exist in the tree either
(`ConfigLoader.cpp` has no such parse; the only `MOBILEGL_IPC_*` mention anywhere is in the docs).
* **Keyed on**: the buffer's `MGPipeHandle` + the block index within the mapped span (offset =
blockIndex × blockBytes). The natural delivery vehicle is the existing `resource_subdata` record — no new
record kind — which the client already splits by a destination-box cap through
`MGPipeForEachSubDataRecordRange` (`PipeFill.cpp:694-713`; a record's offset caps at 2^31-1, `:707-712`).
* **Phase 1** (P5): conservative — treat the whole mapped span as dirty and split it into blocks.
**Phase 2**: precise — shadow-in-shm 64 KiB dirty bits with `memcmp` first
(`MOBILEGL_IPC_SHADOW_SHM`, default 1 from Phase 2, `ARCHITECTURE.md:615`).
`ARCHITECTURE.md:499` grants Phase 2 the right to be pulled forward if Phase 1 is unacceptable on the
Create/Flywheel fixtures, and calls it "计划里唯一允许因测量改变阶段顺序的地方".
**The 20 sites, enumerated** (I count **21 call lines** in non-test code, not 20 — caveat below):
Espryt (9): `DirectGLES.cpp:361` (`ResolveIndirectCommandBytes`), `:6185` (primitive-restart source),
`:6439`, `:6440`, `:6541`, `:6542` (indirect-count draw + parameter buffer, two pairs);
`Managers.cpp:2817`, `:2994`; `MultiDraw.cpp:511`.
Magma (12): `DirectVulkan.cpp:281`, `:472`, `:796`; `UniformManager.cpp:2024`;
`VulkanRenderer.cpp:3542`, `:3621`, `:4013`, `:7428`, `:12423`, `:12424`; `VkBufferManager.cpp:628`, `:679`.
Caveat, flagged rather than resolved: `MEASUREMENTS.md:111` records 20/6 as "与计划一致", and
`Managers.cpp:5047-5048` speaks of "the eleven Espryt `SyncPersistentMappedRange` / `SyncGpuWrites` sites"
where my Espryt count is 9 + 3 = 12. Both published counts are one below mine, so one specific line is
excluded by a convention I could not find — most likely `DirectGLES.cpp:361`, which is a shared helper
rather than a draw-path site (**guess**). The evidence that would settle it is the per-site attribution table
§5.7 that `ARCHITECTURE.md:290` refers to ("20 处 `SyncPersistentMappedRange` + 6 处 `SyncGpuWrites` 按 §5.7
逐站点归属") — I did not find such a table in the tree, and the brief writer should not assume it exists.
### (3) The ordering property the push must preserve
In monolith, `SyncPersistentMappedRange` → `NotifySubData` → `MGPipeEmitResourceSubData` is a memcpy on the
same thread as the draw that immediately follows, so the bytes the app wrote before the draw call are the
bytes the draw sees. Under split, the block push and the draw both travel on `SEG_CMD`, in order, so the ring
preserves it — **provided the push is emitted at the validate point and not lazily**. A push that is deferred
past the draw record is exactly the C-1 regression above, re-committed at the transport layer.
`MOBILEGL_COHERENT_AS_FLUSH` keeps working in split mode unchanged (`ARCHITECTURE.md:502`): the two
`coherent_as_flush: true` Create fixtures take the same buffer path in split and monolith, which is the only
reason a by-name comparison between them means anything.
---
# PART 3 — The `persistent-map-push` counter
**It already exists and does not need to be added. It needs to be WIRED.**
* Enum member: `MG_Util/Metrics/PipeStats.h:72` — `PersistentMapPush`, comment at `:71`
("Bytes pushed because a persistently mapped range was published to the backend").
* Long name: `PipeStats.cpp:171` — `"persistent-map-push"`, in `kByteClassNames`.
* **Short name: `pmap`** — `PipeStats.cpp:~203`, `kByteClassShort` index 7 (the array reads
`"buf","tex","ubog","ubon","vtxc","idxc","icmd","pmap","resid"`). That is the token that appears inside the
summary line's `bytes/f[...]` bracket.
* Test pin: `MG_Test/Util/PipeStatsTest.cpp:289` —
`EXPECT_STREQ(PS::NameOf(PS::ByteClass::PersistentMapPush), "persistent-map-push")`.
* **Not wired, and the non-wiring is itself a contract**: `PipeStats.cpp:68-70` —
"`persistent-map-push` Not wired in P0: today a persistent map is a permanent address space donation
(D4/D-B4) that survives the whole monolith track, so there is no push to count until the IPC track breaks
it." Repeated at `ARCHITECTURE.md:619`, which adds that the site inventory at `PipeStats.cpp:16-100`
"那份清单是契约" — **so P5 must edit `PipeStats.cpp:68-70` in the same commit that adds the `AddBytes`
call, or the inventory is lying.** Related: `PipeStats.cpp:24-27` already says `stage-buffer` explicitly
does **not** cover "bytes an app writes THROUGH a persistent map ... see `persistent-map-push`", so the two
classes must not double-count.
**Good news for G1**: `PersistentMapPush` sits at `PipeStats.h:72`, i.e. **before** the
`#if MOBILEGL_PIPE_PUSH` block that opens at `:75` (guarding `CsoBlobBytes` at `:84`). It is already in both
the pull and the push build, so wiring it resizes no array and moves no pull symbol.
## `mpr` today, and how it differs
`mpr` is a **different axis entirely** — a CallClass, not a ByteClass:
* `CallClass::MapPersistentRoundtrips` — **`PipeStats.h:141`**, inside the `#if MOBILEGL_PIPE_PUSH` block
that opens at `:117`; the rationale comment runs `PipeStats.h:130-140`.
**Doc correction for the brief**: `ARCHITECTURE.md:492` cites `PipeStats.h:126` for this — that line
number is stale at `a29807cc`; it is `:141`.
* Long name `"map-persistent-roundtrips"` (`PipeStats.cpp:~178`), printed as `mpr=` at `PipeStats.cpp:443`.
* **Counted at the client emitter**, `PipeFill.cpp:781-784`, behind `PipeStats::Enabled()`; the applier
bumps its own copy first and unconditionally at `PipeApply.cpp:1900`.
* **Definition: every `MapPersistent` EMISSION — mint OR decline.** The reasoning is spelled out three times
(`PipeApply.cpp:1893-1901`, `PipeFill.cpp:776-780`, `ARCHITECTURE.md:492`): counted as "round trips
actually taken" it is 0 by construction in monolith and could never go red for the reason it exists;
counted as attempts it is the same number in both modes, equals exactly "one per storage definition", and a
regression that acquires per-draw shows up on the first window.
* Gates: `StorageBufferRegrowScenario.cpp:255`
(`NStorageDefinitionsCostNMapPersistentRoundtripsNotOnePerDraw`) and `LargeArenaAdoptionScenario.cpp:450`
(`AnAdoptionCostsExactlyOneMapPersistentRoundtrip`). Both skip outside their own ctest lane
(`StorageBufferRegrowScenario.cpp:172`, `LargeArenaAdoptionScenario.cpp:226`).
* Device readings on file (`MEASUREMENTS.md:465` (4)): 26.3 = 8 on both devices (first window 4, then 2+2 —
all ≥16 MiB store definitions); sodium 1; rd12 and create-instancing 0; the P2 arm always 0.
**The difference P5 must publish, in one sentence**: `mpr` counts *acquisition attempts* (calls, one per
storage definition, unchanged by the split), while `persistent-map-push` counts *bytes the client had to ship
because the acquisition was declined*. They are **anti-correlated**: at tier T2 every `MapPersistent`
declines, `mpr` stays exactly where it is today, and `pmap` goes from 0 to whatever the Phase-1 conservative
push costs. A P5 run where `pmap` is still 0 has not reached T2 — see §2.3's ambiguity.
---
# PART 4 — `PersistentCoherentMapScenario`
**IT DOES NOT EXIST.** A full-tree grep for `PersistentCoherentMap` returns exactly two hits, both docs:
`docs/Disaggregated/ARCHITECTURE.md:500` and `docs/Disaggregated/ROADMAP.md:21`. There is no such file in
`MG_IntegrationTest/Scenarios/` (85 scenario files; none matches) and no such name in
`MG_IntegrationTest/CMakeLists.txt`. **P5 must write it.** The gate cannot "pass unchanged under inproc"
because there is nothing to pass.
**The spec, all of it, is `ARCHITECTURE.md:500`**: map `PERSISTENT|WRITE|COHERENT`, write through the
pointer, **make no other GL call**, draw, read back and check. "门从第一天就有" — it is meant to exist before
the push does.
**Shapes to copy from:**
* `MG_Test/SanityTest.cpp:4583` —
`TEST(DirectGLESBufferDrawProbe, ALiveHostMapKeepsTheHandleArmProbeDirtyBetweenTwoDraws)`, the unit-level
version of exactly this hazard, with the narrative at `:4566-4582` and an `EsprytSlotTablesEnabled()` skip
at `:4590`. It builds the applier record by hand "because this case is about the PROBE".
* `Scenarios/LargeArenaAdoptionScenario.cpp` (adoption + `mpr`), `Scenarios/StorageBufferRegrowScenario.cpp`
(per-definition roundtrips), `Scenarios/ResidentIndexScenario.cpp:40` (names `SyncPersistentMappedRange`
and the acquire/memo interaction), `Scenarios/CrossFrameBufferScenario.cpp` plus `StreamedArenaScenario`
(the recycle cases `MEASUREMENTS.md:523` names as the hedge for the two-arm flush ladder).
**A trap to write into the scenario on day one.** A scenario-sized buffer is far under the 16 MiB
`kLargeBufferAdoptBytes` (`BufferObject.cpp:229`), so `TryAdoptLargeStorage` never fires — but
`AcquireMemoryRange`'s own adoption **does** fire for `PERSISTENT|WRITE` without `FLUSH_EXPLICIT`
(`BufferObject.cpp:655-661`), subject only to whether the backend mints. So the same test lands in the
**adopted** arm or the **emulated** arm depending on the driver (`glBufferStorageEXT` presence) and on the
build, and those two arms exercise completely different code (`SyncPersistentMappedRange` returns at `:346`
in one and pushes at `:352` in the other). `MOBILEGL_DISABLE_LARGE_BUFFER_ADOPTION` does **not** separate
them — it is read only at `BufferObject.cpp:232`, inside `TryAdoptLargeStorage`. The scenario must assert
which arm it is in, or it will silently test the wrong one on half the devices.
**What it would newly catch under inproc**: the client pushes blocks at validate points and the server
applies them on `mgl-srv-apply`; a push that loses the race with its draw record turns the scenario red.
That is exactly why the ROADMAP puts this gate in P5 and not in P11.
---
# PART 5 — WAR / flush hazards that get harder when write and use are on different threads
All three named hazards are instances of the same shape: **a shadow of driver/GPU state, kept on the thread
that will use it.** Split moves the *use* to `mgl-srv-apply` and leaves the *write* on the app thread. Two of
the three get harder; the third is a role-isolation item, not a threading one.
## 5.1 The Mali SubData WAR stall and the three-tier flush ladder — **gets harder**
The ladder is documented at `Managers.cpp:1047-1076` and implemented twice:
`FlushPendingRangesFrom(twin, hostBase, size)` at `Managers.cpp:1051` (the handle arm) and
`FlushPendingRangesNow` at `Managers.cpp:1316` (the pull arm, byte-frozen against `5cb826b0` by G1 —
`MEASUREMENTS.md:523` records why there are two, and ID-15 supersedes ID-13 on it; the in-tree note is at
`Managers.cpp:1162+`).
Tiers, with their live conditions:
1. `glMapBufferRange(WRITE | INVALIDATE_BUFFER-or-RANGE)` + memcpy, taken when `wholeBuffer` **or**
`size >= kInvalidateRangeMinBytes` = **128 KiB** (`Managers.cpp:1045`, applied `:1131-1143`).
2. staging ring + `glCopyBufferSubData` (`:1144-1152`) — `MOBILEGL_ESPRYT_DISABLE_INVALIDATE_FLUSH` forces
this tier as the map path's negative control (`:1069-1070`).
3. direct `glBufferSubData` (`:1153`), potentially stalling.
Stakes on file: 9 × 128 MB of ghosting per frame ≈ 380 ms, the measured 2-4 fps (`Managers.cpp:1064-1070`).
**Why split makes it harder, three distinct edges:**
* **The bytes are the client's.** Both arms read `hostBase`, which on the handle arm is the lambda
`liveHostBase` re-evaluated **at every use** (`Managers.cpp:2744-2748`), precisely because two ordinary
events move or free what an earlier base pointed at — a shadow resize (`PipeResource::ResizeShadow` is
reserve+resize, so a grow past the reserve reallocates) and persistent-map adoption (the shadow is cleared
and shrunk) — `Managers.cpp:2739-2743`. **Under split the server cannot hold a pointer into the client's
shadow at all**, so "re-read at every use" necessarily becomes "snapshot into `SEG_STAGE` at emission", and
the window between snapshot and apply is new.
* **Tier 1's `INVALIDATE_RANGE` is a claim that the old bytes are dead**, and it is only true if the bytes
being copied are the newest. `Managers.cpp:1126-1129` records that this edge has already drawn blood once:
widening the map to page bounds "looked free and was not — the widened bytes clobbered GPU-written data
(an SSBO counter beside the app's SubData) with the stale shadow". A late or widened snapshot taken on a
different thread does the same thing, and unlike tier 3 it fails **silently** rather than by stalling.
* **The flush granularity is deliberately *not* the union.** `Managers.cpp:1072-1076`: ranges are flushed as
queued because "bytes, not flush calls, are the cost axis here, and collapsing a scattered flush into its
union re-copied nearly whole chunk-mesh arenas every frame". The 64 KiB block push of §2.4 is a *different*
granularity policy landing on the same buffers, and the brief should say which one wins when both are live
on one arena.
## 5.2 The `g_uploadRing` reset asymmetry — **real, but probably not a live bug**
`OnBackendContextDestroyed` (`Managers.cpp:2583-2596`) calls `ResetRingForNewContext` for **`g_uboRing`
(`:2594`)** and **`g_unpackRing` (`:2595`)** and **not for `g_uploadRing`** (declared `Managers.cpp:899-906`).
Two readings:
* **(i) a latent bug** — the upload ring keeps a dead context's `id` and `mappedPtr`.
* **(ii) harmless, and the eager pair is merely redundant** — `RingAvailable` (`Managers.cpp:3288-3299`)
lazily resets any ring whose `store.contextGeneration != g_bufferContextGeneration` (`:3293-3295`).
**(ii) is right, and I checked it rather than guessing**: every `RingAllocate(g_uploadRing, ...)` is guarded
by a `ringUsable` computed from `UploadRingUsableNow()` → `RingAvailable(g_uploadRing)` in the same function
— `:1103` guards `:1144`, `:1211` guards `:1221`, `:1379` guards `:1420`, `:1454` guards `:1464`;
`UploadRingUsableNow` itself at `:1035` and `:1322` (one per arm). `ResetRingForNewContext`
(`Managers.cpp:912-925`) also deliberately **keeps** `store.generation` across the wipe, because frame
serials survive context recreation and a restarted counter could revalidate a stale per-program slot cache.
**Why it still matters for P5.** `g_bufferContextGeneration` is **server state**, and the re-open mechanism
(`BumpBufferMutationEpoch()` at `Managers.cpp:2573` and `:2587`) is **server-local**. A client that memoises
"I already pushed blocks 3..7 of this arena" has no way to learn that the server's ES context died and its
rings were dropped. Whatever epoch the client keys its push memo on must be re-opened by a **server event on
the reverse channel**, not by a client-local counter. There is no callback for this in `MGPipeCallbacks`
today — the ten are fixed and `static_assert`-pinned at `MGPipeCallbacks.h:56-58` — so P5 either folds it
into `OnCapsInvalidated`/`OnSurfaceChanged` or pays the eleventh-callback price that assert exists to make
visible.
## 5.3 `ScopedDefaultUnpackState::s_synced` — **a role-isolation item, not a threading one**
`Managers.cpp:5430-5491`. The flag is a **process-global `static inline Bool`** at `:5490`, alongside five
process-global value shadows (`:5491-5496`). `EnsureShadowSynced` (`:5452-5470`) pins the driver to the GL
defaults (4/0/0/0/0/0) exactly **once per process**, and thereafter `Apply` (`:5472-5480`) is pure
compare-and-set. The class comment (`:5424-5429`) states the invariant it rests on: "The backend unpack state
is set ONLY by MobileGL's own save/restore helpers ... all of which restore to the resting default, so the
shadow stays accurate."
Two consequences:
* **The role-isolation audit undercounts.** `ARCHITECTURE.md:578` says MGPipe brings the process globals a
role split must duplicate down from four (`pGLContext`, `gBackendFunctionsTable`, `pActiveBackendObject`,
`pDefaultFramebufferInfo`) to **two**, and that this is "`inproc` 从'成本可疑的实验'变成'可交付形态'的直接
原因". `s_synced` plus its five value shadows is a **sixth** process global of exactly that kind, unnamed
in that audit. In P5's reduced path the client role should never touch GL, so this is latent rather than
live (**guess** — the P5 purity gates do not exist yet, so I could not check); it belongs on the brief's
list regardless, because it is the shape those gates are meant to catch.
* **It is never reset on context death.** `OnBackendContextDestroyed` (`Managers.cpp:2583-2596`) resets the
rings and the binding caches and not this. Benign today, because a lost context returns the driver to the
GL defaults the shadow already claims; it stops being benign the day a server re-attaches to a context
whose unpack state something else moved.
## 5.4 One more that the brief did not name, and that belongs on the list
`IsBufferDrawClean` / `IsBufferDrawCleanByHandle` do **unlocked emptiness probes** of two containers the
apply side mutates: `resource->pendingResidentWrites` (`Managers.cpp:2668`, `:2707`) and
`resource->pendingRanges` (`:2676`, `:2712-2713`), both flagged in-comment as "the same unlocked emptiness
probe `EnsureBufferResource`'s replay branch uses" (`:2711-2712`). Today the probe and the mutation are both
on the context thread. With `mgl-srv-io` and `mgl-srv-apply` as two threads (`ROADMAP.md:21`) that is no
longer automatic, and `syncedChangeSerial` is already an `atomic` read with acquire ordering (`:2684`) while
these two are not.
---
# Appendix — one-line answers
1. `MarkGpuWritten` = `BufferObject.cpp:364`; `SyncGpuWrites` = `:369`. Six backend producers
(`DirectGLES.cpp:570` / `:618` / `:2603`, `UniformManager.cpp:1075` / `:1231`, `VulkanRenderer.cpp:11618`),
funnelled under push through `Managers.cpp:2509`, answered client-side at `ResourceTracker.h:583`.
P5 must build the set **client-side** at its own draw/dispatch emission points, plus two new producers
(`glReadPixels`→PBO and `glEndTransformFeedback`, `ARCHITECTURE.md:508`), and must give `SyncGpuWrites`
either a third state or a block.
2. `MapPersistent` = `PipeCalls.def:84` / `PipeApply.cpp:1893`; `UnmapPersistent` = `:85` / `:1929`
(**no producer**, `PipeFill.cpp:764-771`); `ResourceFlushRange` = `:137` / `PipeApply.cpp:1787`.
Three rings `g_uboRing` / `g_unpackRing` / `g_uploadRing` at `Managers.cpp:883` / `:884` / `:899`.
Adoption at 16 MiB (`BufferObject.cpp:229`), p99 163→21 ms (`MEASUREMENTS.md:87`). Split runs at T2
(`ARCHITECTURE.md:490`), push mandatory, **64 KiB blocks** keyed on `{MGPipeHandle, block index}`,
shipped as existing `resource_subdata` records, plus a `hasLiveHostWrites` metadata bit that exists in
`PipeApply.h:225` but **not** on the wire (`MGPipeTypes.h:283` / `:997` — use the pads at `:303` / `:1004`).
3. The counter **exists and is unwired**: `ByteClass::PersistentMapPush`, `PipeStats.h:72`, short name
`pmap`, inventory disclaimer at `PipeStats.cpp:68-70` that P5 must edit. `mpr` is a *call* class
(`PipeStats.h:141`, **not** `:126` as `ARCHITECTURE.md:492` says) counting acquisition attempts; it is
unchanged by P5 and anti-correlated with `pmap`.
4. `PersistentCoherentMapScenario` **does not exist** — docs only. Spec at `ARCHITECTURE.md:500`; nearest
relative `SanityTest.cpp:4583`; the trap is that a small buffer still adopts via `AcquireMemoryRange`
(`BufferObject.cpp:655`), so the scenario must pin which arm it is testing.
5. Hardest across threads: the flush ladder's `hostBase` snapshot (`Managers.cpp:1047-1076`, `:2739-2748`,
`:1126-1129`) and the client's push memo versus the server-local `g_bufferContextGeneration` /
`BumpBufferMutationEpoch` (`Managers.cpp:2573` / `:2587` / `:3293`). `s_synced` (`Managers.cpp:5490`) is a
role-isolation item. Bonus: the unlocked `pendingRanges` / `pendingResidentWrites` probes
(`Managers.cpp:2668` / `:2676` / `:2707` / `:2712`).