Files
MobileGL/docs/Disaggregated/notes/p2/scout-tracker.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

54 KiB
Raw Blame History

Scout: what the P2 frontend state tracker must do, and what already exists

Worktree: C:/Users/geekerwan/AndroidStudioProjects/FoldCraftLauncher/MobileGL-disagg, branch feat/disaggregated @ e7a6a72f ([Docs] (Disaggregated): record the P1 landing and what its verify lane found). All paths below are relative to that worktree root. Every line number was opened and read.

Design context the tracker has to satisfy, in the repo's own words:

  • docs/Disaggregated/ARCHITECTURE.md:147 — "前端 state tracker(MG_Impl/Pipe/Tracker,P2 起)".
  • docs/Disaggregated/ARCHITECTURE.md:149-151 — the push happens at the validate moment before a verb, not in the GL setter; per-setter push is explicitly rejected because Blaze3D brackets every batch with glEnable/glDisable(GL_BLEND).
  • docs/Disaggregated/ARCHITECTURE.md:153 — eight validate entries generated from PipeCalls.def's kCtxVerb/kCtxObject rows: ValidateForDraw (20 draw entries), ValidateForDispatch, ValidateForClear, ValidateForBlitOrCopy, ValidateForTextureOp, ValidateForReadback, ValidateForXfbSpan, ValidateForQuery. Note this is eight, while the landed P1 fill machinery has nine classes (kProgramOp was split out later — MobileGL/MG_Pipe/FillPoints.def:106-109). The tracker either adopts nine or has to explain the merge.
  • docs/Disaggregated/ARCHITECTURE.md:552 — MobileGL/MG_Impl/Pipe/ is where Tracker, SlotAllocator, CsoCache, HostResolve, CompositeResolver land at P2+. Today that directory contains only PipeFill.h / PipeFill.cpp.
  • docs/Disaggregated/ROADMAP.md:18 — P2 deliverable is MG_Impl/Pipe/Tracker (dirty bits, 5 aggregate generations, suppressor skeleton) + first gen_pipe_dirty_surface.py mapping round as a gate; acceptance includes "两台设备配对逐线程 CPU p50/p99 不差且 tracker 绝对 ns 在上限内".

1. The P1 fill machinery the tracker replaces or wraps

1.1 The fill point macro — MobileGL/MG_Impl/Pipe/PipeFill.h

  • PipeFill.h:16 — the whole header is under #if MOBILEGL_PIPE_PUSH; PipeFill.h:52 defines MGP_FILL(Verb) → MGPipeFillForVerb(MGPipeVerb::Verb); PipeFill.h:54 makes it ((void)0) in the pull build, so the pull build is byte-identical to a tree without it (verified in P1 acceptance, docs/Disaggregated/MEASUREMENTS.md:133).
  • PipeFill.h:25 — void MGPipeFillForVerb(MGPipeVerb verb).
  • PipeFill.h:34 — void MGPipeLeaveVerb(): ends the verb in flight without starting another. Nothing in the GL entry points calls it; it exists for MG_Test/ScopedPipeVerb.h (a real 79-line header, present).
  • PipeFill.h:42 — MGPipeSetPoisonOmission(const char* verb, const char* field): negative control B.
  • PipeFill.h:49 — SnapshotFromGLContext(PipeInputs&, const MGPipeFieldMask&), verify builds only; the comment says this is "the branch that survives P13".
  • Build wiring: CMakeLists.txt:28 (MOBILEGL_PIPE_PUSH option), CMakeLists.txt:29 (MOBILEGL_PIPE_VERIFY), CMakeLists.txt:462-464 (VERIFY forces PUSH), CMakeLists.txt:471 (MobileGL/MG_Impl/Pipe/PipeFill.cpp appended only under PUSH), CMakeLists.txt:548-552 (the -D defines).

1.2 The filler and the per-class copy loop — MobileGL/MG_Impl/Pipe/PipeFill.cpp

MGPipeFillForVerb is PipeFill.cpp:566-613. In order:

  1. PipeFill.cpp:568 ParsePoisonOmissionKnob() (string compare per fill, PipeFill.cpp:310-327).
  2. PipeFill.cpp:570 ArmVerify() under verify; PipeFill.cpp:574-577 warns in a push-without-verify build if the runtime knob is set.
  3. PipeFill.cpp:584 ++filled.CurrentVerbSerial — the poison stamp. Starts at 1 so FilledGen == 0 means "never filled" (PipeFill.cpp:581-583).
  4. PipeFill.cpp:586 SetVerb, PipeFill.cpp:588 SetIdentity (writes m_live + m_contextIdentity), PipeFill.cpp:589 early return when no context is live.
  5. PipeFill.cpp:590 picks the mask: kMGPipeClassFieldMask[kMGPipeVerbClass[verb]].
  6. PipeFill.cpp:591-609 the per-class copy loop: iterate all 63 field ids, skip those outside the mask, stamp-only for sticky fields (PipeFill.cpp:595-599: if (filled.FilledGen[i] == 0) filled.FilledGen[i] = 1;), otherwise CopyField (PipeFill.cpp:604) then filled.FilledGen[i] = filled.CurrentVerbSerial unless the pair is the armed omission (PipeFill.cpp:607).
  7. PipeFill.cpp:611 EntryCompare(inputs, mask) under verify.

MGPipeLeaveVerb is PipeFill.cpp:555-563: bumps the serial (so every stamp goes stale) and sets the verb back to kVerbCount.

CopyField is the whole per-field copy switch, PipeFill.cpp:36-266. It is a flat switch over MGPipeInputField that calls the same-named GLContext accessor for each field — P1 brief D4's "no derivation logic is re-implemented here". The loops inside it are what makes a draw fill expensive; see §5.1.

Notable copy shapes worth knowing before rewriting them:

  • PipeFill.cpp:64-73 GetBufferBindingSlot: copies pointers to the live slots for every GlobalBufferTargets entry (14 targets, MG_State/GLState/BufferState/BufferState.h:15-19); the Index target is deliberately left null because GLContext derives it through the bound VAO's element-buffer slot — a derivation no FillPoints.def row can copy.
  • PipeFill.cpp:74-80 GetBufferBindingPoint: stores the base address of each target's binding-point row for the 4 BufferBindPointTargets (BufferState.h:20-21), each row being 84 wide (BufferState.h:29).
  • PipeFill.cpp:129-133 / PipeFill.cpp:205-208: GetImageTextureBinding / GetTextureUnitObject store the base pointer of the 192-entry unit arrays (MG_State/GLState/TextureState/TextureState.h:42, MAX_TEXTURE_IMAGE_UNITS = 192) — O(1), not O(192).
  • PipeFill.cpp:183-185 GetRenderStateParameters: a whole-struct value copy of RenderStateParameters (1168 bytes, docs/Disaggregated/MEASUREMENTS.md:86).
  • PipeFill.cpp:255-264: the seven forwarded fields fall through with no copy.

1.3 Poison stamping and the read check

  • MobileGL/MG_Backend/MGPipe/PipeInputs.h:21-26 derives MOBILEGL_PIPE_POISON (on under DEBUG log level, or MOBILEGL_BUILD_DISAGGREGATED, or MOBILEGL_PIPE_VERIFY).
  • PipeInputs.h:44-49 MGP_INPUT_CHECK(Field) → MGPipeInputFieldIsFresh(m_filled, Field) else MGPipeInputPoisonFatalForVerb(Field, m_currentVerb).
  • MobileGL/MG_Pipe/generated/PipeFilled.inc:302-304 — struct MGPipeFilledState { Uint64 CurrentVerbSerial; Uint64 FilledGen[63]; }, i.e. 512 bytes of stamps carried inside PipeInputs in poison builds (PipeInputs.h:669-671).
  • PipeFilled.inc:316-320 MGPipeInputFieldIsFresh: gen != 0 && (sticky || gen == CurrentVerbSerial).
  • PipeFilled.inc:96 static_assert(kMGPipeInputFieldCount == 63, ...); PipeFilled.inc:167 kMGPipeInputFieldSticky[]; PipeFilled.inc:236 kMGPipeInputFieldFilledBy[].
  • MobileGL/MG_Backend/MGPipe/PipeInputs.cpp:20 MGPipeVerbName, :25 MGPipeInputPoisonFatalForVerb, :29 MGPipeFindInputField, :37 MGPipeFindVerb.

1.4 The verify hooks

  • Entry compare: PipeFill.cpp:412-422 EntryCompare → SnapshotFromGLContext (PipeFill.cpp:427-437) → MGPipeVerifyInputs → ReportDivergence(..., "entry"). PipeFill.cpp:337 notes the entry arm is tautological until P2 gives the first arm a real filler — i.e. the tracker is exactly what makes this comparison meaningful.
  • Compare-at-read: PipeInputs.h:62-63 MGP_INPUT_VERIFY_READ → MGPipeVerifyReadHook (PipeFill.cpp:439-463). This is the arm that is real in P1: it re-reads the whole field from the live context at every backend read and compares. PipeFill.cpp:455-458 uses an InHook flag so the re-read cannot recurse.
  • PipeFill.cpp:350 static_assert(MOBILEGL_PIPE_POISON, ...) — the read hook relies on the poison covering reads before the first fill.
  • Negative controls: MOBILEGL_PIPE_VERIFY_CORRUPT perturbs the snapshot arm (PipeFill.cpp:417-419, injector at MobileGL/MG_Backend/MGPipe/PipeInputs.cpp:172); MOBILEGL_PIPE_POISON_OMIT withholds one (verb, field) stamp (PipeFill.cpp:293-331, PipeFill.cpp:493-511).
  • Comparator API: PipeInputs.h:250 MGPipeInputsFieldEqual, PipeInputs.h:258 MGPipeVerifyInputs (exported with default visibility so CI can nm -D it — PipeInputs.h:252-257), PipeInputs.h:264 MGPipeApplyVerifyCorruption.
  • Field-wise (never memcmp) equality lists live in MobileGL/MG_Pipe/PipeFields.def; PipeFields.def:230-247 is RenderStateParameters (65 members), :249-251 PixelStoreParameters, :253-258 PerBufferBlendState / StencilFaceState. PipeFields.def:9-19 explains why padding is excluded and that gen_pipe.py asserts each list names exactly the struct's direct members.

1.5 MobileGL/MG_Pipe/FillPoints.def — 69 verbs, 9 classes, the may-read masks

  • FillPoints.def:35-104 MGP_FILL_VERB_LIST: 69 rows, one per function-pointer member of MG_Backend::GLFunctionsTable in declaration order; gen_pipe.py parses that struct and refuses any other row set (FillPoints.def:17-20). Present and SetSwapInterval go through BackendObject virtuals and are not verbs (FillPoints.def:21-22).
  • Class distribution in that list: 20 kDraw (FillPoints.def:36-55), 9 kClear (:56-64), 5 kBlitOrCopy (:65-69), 2 kTextureOp (:70, :78), 3 kReadback (:71-73), 2 kDispatch (:74-75), 6 kXfbSpan (:98-103), 1 kProgramOp (ShaderStorageBlockBinding, :80), the rest kQuery.
  • FillPoints.def:108-109 MGP_FILL_CLASS_LIST: the nine classes. The comment says ARCHITECTURE.md names eight and kProgramOp is split out because ShaderStorageBlockBinding is the one non-draw verb that syncs Espryt's render state and textures.
  • FillPoints.def:114-294 MGP_FILL_FIELD_LIST: the may-read table. kDraw owns 47 rows (:116-162), kDispatch 15 (:165-182), kClear 18 (:184-201), kBlitOrCopy 22 (:203-231), kTextureOp 10 (:233-248), kReadback 17 (:250-269), kXfbSpan 8 (:271-278), kProgramOp 11 (:280-290), kQuery 1 (:294).
  • Generated masks: MobileGL/MG_Pipe/generated/PipeFillPoints.inc:270-272 struct MGPipeFieldMask { Uint64 Words[2]; }, :274-277 MGPipeFieldMaskHas, :279-298 kMGPipeClassFieldMask with per-class counts in comments: kDraw 54 (47 own + 7 sticky), kDispatch 22, kClear 25, kBlitOrCopy 29, kTextureOp 17, kReadback 24, kXfbSpan 15, kProgramOp 18, kQuery 8.
  • PipeFillPoints.inc:95 static_assert(kMGPipeVerbCount == 69); :183 static_assert(kMGPipeVerbClassCount == 9); :197 kMGPipeVerbClass[].
  • FillPoints.def:24-27 states the discipline the tracker inherits: a Fatal{UnmigratedPipeInput} is fixed by adding a (class, field) row, never by marking a field sticky.

83 MGP_FILL statements over these 69 verbs (FillPoints.def:21, and docs/Disaggregated/MEASUREMENTS.md:110). Verified by grep: MG_Impl/GLImpl/Drawing/GL_Drawing.cpp 36, Framebuffer/GL_Framebuffer.cpp 11, Query/GL_Query.cpp 18, Texture/GL_Texture.cpp 8, Sync/GL_Sync.cpp 6, Getter/GL_Getter.cpp 3, Program/GL_Program.cpp 1 = 83.

1.6 MobileGL/MG_Pipe/PipeMutation.h and the three push-on-mutation hooks

  • PipeMutation.h:10-11 double include guard; :29 gated on MOBILEGL_PIPE_PUSH; :35 declares MGPipeNoteFrontendMutation(MGPipeInputField); :37-38 the MGP_NOTE_MUTATION(Field) macro; :40 ((void)0) in pull.
  • Semantics (PipeMutation.h:12-28): refreshes one field's value in the pushed block when the field is in the current verb's mask, value only, never the stamp, so a withheld stamp (negative control B) stays withheld.
  • Implementation PipeFill.cpp:479-491: no-op if no live context, if no verb has filled (CurrentVerb() == kVerbCount), if the field is sticky (no storage), or if the field is outside the class mask; otherwise CopyField.
  • The three hook sites (the only MGP_NOTE_MUTATION spellings in the tree outside the header/comment):
    • MobileGL/MG_State/GLState/TextureState/TextureState.h:85 — MGP_NOTE_MUTATION(GetMaxTouchedTextureUnit) in NoteUnitTouched's high-water-mark branch.
    • MobileGL/MG_State/GLState/TextureState/TextureState.h:98 — MGP_NOTE_MUTATION(GetTextureBindGeneration) in NoteUnitTouched's bindingChanged branch.
    • MobileGL/MG_State/GLState/TextureState/TextureState.h:111 — MGP_NOTE_MUTATION(GetTextureBindGeneration) in BumpTextureBindGeneration().
    • MobileGL/MG_State/GLState/TextureState/TextureState.h:133 — MGP_NOTE_MUTATION(GetSamplingResolutionGeneration) in BumpSamplingResolutionGeneration(). (Three fields, four statements. docs/Disaggregated/MEASUREMENTS.md:121-127 tabulates the three fields and argues the hooks sit on the counters, not on the ~40 writer sites, because every backend-writes-frontend path funnels through them; it explicitly says BufferObject/ProgramObject/VertexArrayObject backend writes move no pushed field because those are handle-class read points.)
  • Why it exists: PipeFill.cpp:466-478 and docs/Disaggregated/MEASUREMENTS.md:117 — Magma writes frontend objects inside its own verb (synthesising a fallback texture for an unbound sampler, materialising a queued clear, overriding a sampler filter), which moved values the boundary had already copied; 8 DirectVulkan cases + 2 traces aborted with Fatal{PipeVerifyDiffer, "GetSamplingResolutionGeneration@Draw*", where=read}. MEASUREMENTS.md:117 says push-on-mutation "正是 P2 tracker 需要的形状" — the tracker must keep this refresh path, or absorb it into the dirty walk.

1.7 MobileGL/MG_Backend/MGPipe/PipeInputs.h — the 63 fields and their storage

  • PipeInputs.h:73-129 MGP_INPUT_STORAGE_LIST: 56 (field, member) rows. PipeInputs.h:135 kMGPipeForwardedFieldCount = 7; PipeInputs.h:240-241 asserts 56 + 7 == 63.
  • PipeInputs.h:136-137 asserts the forwarded set is the sticky set (an eighth sticky row without a forwarder is a compile error, not a test failure).
  • PipeInputs.h:144-153 documents the three storage classes V / O / F.
  • PipeInputs.h:155-157: every non-forwarded accessor is MGP_INPUT_CHECK → MGP_INPUT_VERIFY_READ → the storage; both compile away in a plain push build, so the accessor is a plain load.
  • Identity/liveness (not fields): PipeInputs.h:189 IsLive() (forwarded, defined PipeFill.cpp:514), :191 ContextIdentity(), :193 CurrentVerb(), :195 FilledState().
  • Storage members: V block PipeInputs.h:621-665, O block PipeInputs.h:671-680.
  • PipeInputs.h:236 inline PipeInputs gPipeInputs{} — the single global; MobileGL/MG_Pipe/PipeInputsSwitch.h:19-21 maps MGB_CTX / MGB_CTX_LIVE / MGB_CTX_IDENTITY onto it under push and onto pGLContext under pull (PipeInputsSwitch.h:24-26). PipeInputsSwitch.h:15-16: the pull arm is the only place outside MG_State/MG_Impl that may spell pGLContext, and purity gate C greps for it.
  • PipeInputs.h:244 static_assert(sizeof(PipeInputs) < 20 * 1024).

2. The frontend mutator surface the tracker must observe

2.1 scripts/gen_pipe_dirty_surface.py — what it enumerates and what it says today

Read in full (202 lines).

  • Purpose (gen_pipe_dirty_surface.py:11-19): MGPipe replaces "the backend rediscovers what changed" with "the frontend says what changed", which only works if every frontend mutation a backend can observe bumps an aggregate generation; the failure mode is silent and one-directional (a forgotten bump renders stale, and no purity gate can see it), so the surface must be enumerated mechanically.
  • Scope (:34): MobileGL/MG_Impl/GLImpl only, .cpp and .h (:149).
  • What counts as a mutator (:37-40): pGLContext-> followed by a method whose name starts with Add|Set|Mark|Bump|Allocate|Truncate|Record|Notify|Begin|End.
  • What counts as reaching the backend (:41): gBackendFunctionsTable.GL.<x> or pActiveBackendObject-><x>.
  • Function bodies are found textually by brace matching (:42-43, :88-103); comments and string literals are masked first (:46-85) — the same MaskCommentsAndQuotedText idiom the repo prefers.
  • Classification (:115-134): a function containing both a mutator and a backend call is an IMMEDIATE PUBLISH POINT; everything else is DEFERRED — "published by the NEXT verb, and it is exactly those that need an aggregate generation rather than an inline push" (:117-119).
  • Every mutator is printed UNMAPPED (:178, :193-194): "the aggregate-generation mapping file lands in P1, and this report is what it has to cover." The mapping file does not exist in this tree — grep for it finds nothing.
  • Known limits it declares itself (:195-197): a mutator inside a lambda is attributed to the enclosing function, and a mutation published through a helper the entry point calls reads as deferred.

Run in this tree (python scripts/gen_pipe_dirty_surface.py --summary), output reproduced exactly:

dirty-surface: 41 files scanned under MG_Impl/GLImpl
dirty-surface: 926 mutator calls in total, 73 distinct mutators
dirty-surface: 92 of them sit in 36 IMMEDIATE PUBLISH POINTS - functions that also reach the backend - across 7 distinct mutators
dirty-surface: the remaining 834 are DEFERRED: nothing reaches the backend in the same function, so the next verb publishes them, and each one needs an aggregate generation

This matches docs/Disaggregated/MEASUREMENTS.md:81 and docs/Disaggregated/ARCHITECTURE.md:172 exactly — the numbers have not drifted.

The 7 immediate-publish mutators (marked (immediate) in the report): RecordError (836 of the 926 calls, i.e. 90% of the whole surface is one error-reporting call), SetTransformFeedbackPaused (2), BeginTransformFeedback (1), EndTransformFeedback (1), MarkTransformFeedbackObjectForDeletion (1), SetActiveTextureUnit (1), SetPatchVertices (1).

The remaining 66 distinct mutators are all 1–5 calls each: the whole Set* render-state family (SetBlendColor, SetBlendFunc/Indexed, SetBlendEquation/Indexed, SetColorMask/Indexed, SetDepthFunc, SetDepthMask, SetDepthRange/Indexed, SetStencilFunc/Mask/Op, SetScissorBox/Indexed, SetViewport/Indexed, SetCullFaceMode, SetFrontFaceMode, SetProvokingVertexMode, SetPolygonMode, SetPolygonOffset/Clamped, SetLineWidth, SetPointSize, SetPointFadeThresholdSize, SetPointSpriteCoordOrigin, SetLogicOp, SetClipControl, SetHint, SetSampleCoverage, SetSampleMaskValue, SetMinSampleShadingValue, SetPrimitiveRestartIndex, SetClearColor/Depth/Stencil, SetClampReadColor, SetCapability, SetCapabilityIndexed, SetPixelStoreParam ×3, SetPatchDefaultInnerLevel, SetPatchDefaultOuterLevel, SetCurrentVertexAttributeFloat ×5 / Int / Uint, SetNamedTransformFeedbackBinding ×2, BumpTextureBindGeneration ×2), the nine Mark*ForDeletion calls, BeginConditionalRender / EndConditionalRender, and six AddTransformFeedback* accounting calls.

Is it a gate? No — informational today. .github/workflows/test.yml:1528 runs python3 scripts/gen_pipe_dirty_surface.py --summary in the pipe-gates job, and .github/workflows/test.yml:1524-1526 says verbatim: "Informational: the frontend mutation surface an MGPipe aggregate generation has to cover. It becomes a gate in P2, when the mapping file exists to diff against (ROADMAP.md:18 puts the first mapping round in P2, not P1)." ARCHITECTURE.md:172 still says "P1 起成为门" — the doc and the workflow disagree; the workflow is what runs. Making it a gate is P2 work: ROADMAP.md:18 lists "gen_pipe_dirty_surface.py 首轮映射成门".

2.2 Aggregate generations that already exist in the frontend

There is no m_any*Generation anywhere in the tree (grepped m_any[A-Za-z]*Generation across MobileGL/ — zero hits). The five aggregates of ARCHITECTURE.md:164-168 (VertexArrayState::m_anyVaoAttributeGeneration, FramebufferState::m_anyAttachmentGeneration, TextureState::m_anyTextureContentGeneration, TextureState::m_anyTextureParamsGeneration, BufferState::m_anyBufferChangeGeneration) are all new. What exists is:

Context-level counters (all are pushed PipeInputs fields):

Counter Getter Bumped by Read by (backend)
render-state version m_version (Uint16) RenderState::GetVersion() MG_State/GLState/RenderState/RenderState.h:20, .cpp:53-55; exposed as GLContext::GetRenderStateParametersVersion() Core.h:202, Core.cpp:786-787 RenderState::BumpVersions() RenderState.h:159-162 — called from ~25 setters in RenderState.cpp (:178, :214, :233, :244, :313 (macro), :346, :357, :452, :468, :512, :538, :564, :583, :599, :611, :622, :663, :680, :691, :783, :798, :812, :886, :897, :908). RenderState.cpp:374 documents one deliberate NON-bump (clip-distance enable). DirectGLES.cpp:2028 (Espryt SyncRenderState early-out), VulkanRenderer.cpp:5920 (Magma dynamic tail)
pipeline-state version m_pipelineStateVersion (Uint16) RenderState::GetPipelineStateVersion() RenderState.h:22, .cpp:57-59; GLContext::GetPipelineStateVersion() Core.h:204, Core.cpp:782-783 same BumpVersions() RenderState.h:160-161 — bumped together with m_version. RenderState.h:165-171 explains the split: only pipeline-baked state, so a glViewport must not evict a cached pipeline. VulkanRenderer.cpp:4985 (GetOrCreatePipeline), :6122, :6973 (fast-path snapshot)
texture bind generation m_textureBindGeneration (Uint64) TextureState::GetTextureBindGeneration() TextureState.h:102; GLContext::GetTextureBindGeneration() Core.h:130 TextureState::NoteUnitTouched's bindingChanged branch TextureState.h:96-99; TextureState::BumpTextureBindGeneration() TextureState.h:109-112; TextureState.cpp:142 (delete-unbind); TextureObject.cpp:96; TextureUnit.cpp:39 DirectGLES.cpp:1417; VulkanRenderer.cpp:6123, :6527, :6651, :6974
sampling-resolution generation m_samplingResolutionGeneration (Uint64) TextureState::GetSamplingResolutionGeneration() TextureState.h:130; GLContext::… Core.h:136-138 TextureState::BumpSamplingResolutionGeneration() TextureState.h:131-134, reached from SamplerObject.cpp:34 (every SamplerObject::BumpVersion) and TextureObject.cpp:37 DirectGLES.cpp:1520, Managers.cpp:3647, :3776, :3847, :4738; VulkanRenderer.cpp:6305, :6533, :6670, :7000
texture context id m_contextId (Uint64) TextureState::GetContextId() TextureState.h:141; GLContext::GetTextureContextId() Core.h:143 allocated once per context (TextureState.h:144 AllocateContextId()); never bumped. Exists because both generations restart at 0 in a new context and a recreated context can reuse the heap address (Core.h:140-142, TextureState.h:136-140). DirectGLES.cpp:1416, :1517; Managers.cpp:3646, :3775, :3846, :4737
transform-feedback generation m_transformFeedbackGeneration (Uint64) GLContext::GetTransformFeedbackGeneration() Core.h:334; storage Core.h:518 Core.h:312 (= ++m_transformFeedbackNextGeneration on every BeginTransformFeedback); swapped on bind at Core.cpp:1301 / :1324 VulkanRenderer.cpp:11348
bound-XFB lifetime id GLContext::GetBoundTransformFeedbackLifetimeId() Core.h:441 set on bind pushed field (D21 counter-slot rekey)
max touched texture unit (high-water mark, not a generation) TextureState::GetMaxTouchedUnit() TextureState.h:101; GLContext::GetMaxTouchedTextureUnit() Core.h:126 NoteUnitTouched TextureState.h:78-86 prefix bound for the unit walk in both backends

Per-object versions (NOT pushed fields — the backends reach them through the O-class pointers/SharedPtrs, see §3):

Version Declared Bumped Backend read sites
VertexArrayObject::GetConfigVersion() (Uint32) VertexArrayState/VertexArrayObject.h:101 VertexArrayObject.cpp:299, :305, :311 7
FramebufferObject::GetObjectVersion() (Uint16) FramebufferState/FramebufferObject.h:150 FramebufferObject.cpp:192, :203 (macro), :215 10
BufferObject::GetChangeSerial() (Uint64) BufferState/BufferObject.h:218, storage :264 BufferObject.cpp:45, 52, 61, 75, 82, 304, 368 15
ITextureObject::GetContentVersion() (Uint64) TextureState/TextureObject.h:65 TextureObject.cpp:285, 354, 365; TextureObject2DCube.cpp:51, 62 10
ITextureObject::GetShapeVersion() (Uint64) TextureObject.h:71 TextureObject.cpp:29 3
ITextureObject::GetTextureParamsVersion() (Uint16) TextureObject.h:61 TextureObject.cpp:101,126,140,154,203,210,227,238,258,294,303; TextureObject.h:182 15
SamplerObject::GetVersion() (Uint16) SamplerState/SamplerObject.h:52, .cpp:249 SamplerObject::BumpVersion() .cpp:27-35, called from 14 setters (.cpp:41,48,55,62,69,76,83,90,97,104,111,180,197,214) — (feeds the sampling-resolution generation)
ProgramObject::GetLinkVersion() ProgramState/ProgramObject.h:793 ProgramObject.cpp:328 4
ProgramObject::GetBackendStateVersion() ProgramObject.h:790 ProgramObject.cpp:327, :493; ProgramObject.h:838, :1025 9
ProgramObject::GetImageUnitVersion() ProgramObject.h:855 ProgramObject.h:848 2
ProgramObject::GetBlockBindingVersion() ProgramObject.h:1013 ProgramObject.h:1026, :1048 1
ProgramObject::GetUBOContentVersion() ProgramObject.h:740 ProgramObject.h:742 (wraps at ~0u) 2
ProgramObject::GetUniformWriteSetVersion() ProgramObject.h:601 ProgramObject.h:625 0 backend readers — only ProgramPipelineObject.h:137-140 folds it into a signature. Worth flagging: ARCHITECTURE.md:163 names "uniform-write-set 版本" as a NEW_SHADER_BINDINGS shutter source, so the tracker would be its first real consumer.
GetLifetimeId() on Buffer/Framebuffer/Program/Renderbuffer/Sampler/Texture/VAO BufferObject.h:215, FramebufferObject.h:159, ProgramObject.h:1262, RenderbufferObject.h:52, SamplerObject.h:57, TextureObject.h:83/:161, VertexArrayObject.h:66 on construction 38 backend read sites

Counts of readers were taken by grepping MobileGL/MG_Backend/ and MobileGL/MG_Impl/; MG_Impl reads none of these version getters — every one is consumed by a backend memo. That is the tracker's opening: today the reader re-derives; P2 moves the derivation to the writer side.

ARCHITECTURE.md:174 flags the wrap hazard: three of these are Uint16 (RenderState::m_version, m_pipelineStateVersion, FramebufferObject::m_objectVersion, plus SamplerObject/TextureObject params) and must be widened at the tracker boundary in the tracker's own m_lastPushed[], without touching MG_State; a wrap there is harmless (one extra re-push, never a missed push) and is swallowed by the set-hash suppressor.


3. Value-class vs object-class split of the 63 fields

The classification is in MobileGL/MG_Pipe/Coverage.def (sticky/F list) plus the accessor layout of PipeInputs.h. Counts: 49 V + 7 O + 7 F = 63.

3.1 O-class — 7 fields, the ones that hand out live frontend memory

These are what a real disaggregated boundary has to handle-ify; today they leak the address space.

Field Accessor + line Storage What it returns today
GetBoundVertexArray PipeInputs.h:477-481 SharedPtr<VertexArrayObject> m_boundVertexArray :671 a SharedPtr copy taken at fill time; the pointee is the live frontend VAO, so every read through it (GetConfigVersion, attribute arrays) is a live pull
GetProgramForDraw PipeInputs.h:540-543 SharedPtr<ProgramObject> m_programForDraw :677 SharedPtr copy; live ProgramObject
GetProgramForDispatch PipeInputs.h:535-538 :676 SharedPtr copy
GetTransformFeedbackProgram PipeInputs.h:545-548 :678 SharedPtr copy (const accessor)
GetBufferBindingSlot(BufferTarget) PipeInputs.h:485-494 BindingSlot<BufferObject>* m_bufferBindingSlot[15] :672 a non-const reference into the live GLContext (return *m_bufferBindingSlot[index]). A target the fill left null (anything outside GlobalBufferTargets, incl. Index) is MGPipeInputPoisonFatalForVerb at :490-492.
GetBufferBindingPoint(BufferTarget, Uint) PipeInputs.h:495-508 BindingSlotRange1D<BufferObject>* m_bufferBindingPointBase[15] :673 base[index] — a live reference; the base is the row start, rows are 84 wide (PipeInputs.h:503-505)
GetFramebufferBindingSlot(FramebufferTarget) PipeInputs.h:509-518 BindingSlot<FramebufferObject>* m_framebufferBindingSlot[] :674 live reference; null → poison fatal :513-516
GetImageTextureBinding(Int) ×2 (mutable + const) PipeInputs.h:519-533 ImageTextureBinding* m_imageTextureBindingBase :675 base[unit] into the live 192-entry array
GetTextureUnitObject(Int) PipeInputs.h:549-557 TextureUnit* m_textureUnitBase :679 base[unit] into the live 192-entry array

(That table lists 9 accessors over 7 distinct field ids — GetImageTextureBinding has two overloads, and GetBufferBindingSlot/GetBufferBindingPoint are separate ids. Precisely: the O-block in PipeInputs.h:476-557 covers field ids GetBoundVertexArray, GetBufferBindingSlot, GetBufferBindingPoint, GetFramebufferBindingSlot, GetImageTextureBinding, GetProgramForDispatch, GetProgramForDraw, GetTransformFeedbackProgram, GetTextureUnitObject = 9 ids; PipeInputs.h:148-149 describes the class as "a SharedPtr copy, or a raw pointer to the live GLContext-owned slot/array … Identity is what phase C turns into a handle".)

The load-bearing consequence for P2: the verify comparator treats O fields by identity only (PipeInputs.h:248-249: "V by value … O by identity, F always equal"). So a texture whose content moved, a VAO whose config version bumped, or a binding slot whose bound object changed is invisible to the P1 verify lane as long as the pointer is the same. The tracker's dirty walk over object-class state has no oracle in the current harness — ARCHITECTURE.md:502 calls this out ("dirty 位触发得太少" is the dangerous direction) and :170 explains why polling per-object versions cannot substitute for the five new aggregates (there is no existing aggregate that answers "did any bound texture move", which is exactly why Magma resorts to a lossy sampledContentSum).

Backend read volume through the O accessors (grep MGB_CTX-><name>( under MG_Backend/): GetBufferBindingSlot 29, GetFramebufferBindingSlot 19, GetTextureUnitObject 19, GetBoundVertexArray 12, GetBufferBindingPoint 9, GetProgramForDraw 7, GetImageTextureBinding 7, GetProgramForDispatch 3, GetTransformFeedbackProgram 3.

3.2 F-class — 7 fields, forwarded and sticky

Declared PipeInputs.h:558-572, defined PipeFill.cpp:517-553. They carry no MGP_INPUT_CHECK and no MGP_INPUT_VERIFY_READ — the declared exception to "every accessor body" (PipeInputs.h:565-570), because a forward is a live call and InvalidateCompileEnv is reached from backend init before any verb has filled.

Field Signature Definition Null-context behaviour Sticky rationale (Coverage.def:115-122)
GetBufferBindingPointCount SizeT(BufferTarget) const :566 PipeFill.cpp:517-520 returns 0 "keyed by target: a constexpr capacity table, not verb state"
GetProgramObject const SharedPtr<ProgramObject>&(Uint) :567 PipeFill.cpp:522-525 returns a static null SharedPtr (NullShared<T> PipeFill.cpp:281-285) "keyed by GL name: an object lookup"
GetTextureObject const SharedPtr<ITextureObject>&(Uint) :568 PipeFill.cpp:527-530 static null SharedPtr "keyed by GL name: an object lookup"
HasOpenTransformFeedbackSpan Bool(Uint64) const :569 PipeFill.cpp:532-535 false "keyed by lifetime id"
InvalidateCompileEnv void() :570 PipeFill.cpp:537-539 no-op "reverse channel: a write into the frontend"
ValidateProgramName Bool(Uint) const :571 PipeFill.cpp:541-544 false "keyed by GL name: a name-table lookup"
RecordError void(ErrorCode, UniquePtr<ErrorInfo>) :574 PipeFill.cpp:546-553 MGLOG_E_ONCE and drop "reverse channel: a write into the frontend"

Coverage.def:97-99 maps the pseudo-calls: InvalidateCompileEnv and ValidateProgramName → kClientResolved (the frontend answers itself), RecordError → kReverseChannel (one of the ten MGPipeCallbacks).

3.3 V-class — the remaining 47 field ids

Everything in PipeInputs.h:198-474. Storage PipeInputs.h:621-665. Grouped by shape, because the tracker's dirty granularity has to line up with these:

  • Whole-struct: GetRenderStateParameters → RenderStateParameters m_renderState (:645, 1168 B).
  • Per-index arrays (kMGMaxDrawBuffers = 8, MGPipeValueTypes.h:30): m_blendEquation[8][2] :624, m_blendFunc[8][4] :625, m_colorMask[8] :632, m_capabilityIndexed.Blend[8] (:181).
  • Per-viewport arrays (MAX_VIEWPORTS = 16, MGPipeValueTypes.h:244): m_depthRange[16] :637, m_viewportIndexed[16] :661, m_capabilityIndexed.ScissorTest[16] (:182).
  • Per-attribute (MAX_VERTEX_ATTRIBS = 32, VertexArrayObject.h:24): m_currentVertexAttribute[32] :634.
  • Per-capability (CapabilityInputCount = 35, MGPipeValueTypes.h:168-204): m_capability[35] :662.
  • Per-buffer-target (BufferTargetCount = 15, BufferObject.h:15-31): m_touchedBindingPointCount[15] :627.
  • Pack/unpack pair: m_pixelStore[2] :641. Stencil pair: m_stencil[2] :655.
  • Scalars/vectors (the rest): active texture unit, blend colour, XFB name, clamp-read colour, clear colour/depth/stencil, cull face mode, depth func/mask, line width, logic op, max touched unit, min sample shading, patch inner/outer/vertices, pipeline & render-state versions, polygon mode/offset factor/units, primitive restart index, provoking vertex, sampling-resolution / texture-bind / texture-context / XFB generations & counters, scissor box, viewport, XFB active/paused flags, bound-XFB lifetime id.

GetBoundTransformFeedbackName (PipeInputs.h:234-238, storage :626) is a dead field: Coverage.def:34-35 marks it "dead: no backend reads it since D21; kept for inventory row 594", and MEASUREMENTS.md:109 confirms 62 of 63 accessors are actually called. It is still copied on every kXfbSpan fill.


4. Where a per-draw dirty walk sits relative to the existing validate paths

4.1 The shape of a draw today, MobileGL/MG_Impl/GLImpl/Drawing/GL_Drawing.cpp

A GL draw entry point is thin and calls a *_Backend wrapper. glDrawElements is GL_Drawing.cpp:1219-1225:

1219  void DrawElements(GLenum mode, GLsizei count, GLenum type, const void* indices) {
1220      if (!ValidatePrimitiveModeEnum(__func__, mode)) return;
1221      if (!PrepareCurrentProgramForDraw(__func__)) return;
1222      if (!ValidatePrimitiveModeForBackend(__func__, mode)) return;
1223      AccountTransformFeedbackPrimitives(mode, count);
1224      DrawElements_Backend(mode, count, type, indices);
1225  }

glDrawArrays is the same minus the index-type check (GL_Drawing.cpp:1165-1171); glDrawElementsBaseVertex adds a count check (:1155-1163); MultiDrawElementsBaseVertex additionally walks the whole count[] array before issuing anything (:1194-1213, and :1201-1206 explains why the whole call is rejected before any sub-draw). glClear is bare: GL_Drawing.cpp:1215-1217.

The *_Backend wrapper is where MGP_FILL sits, e.g. GL_Drawing.cpp:535-542:

535  void DrawElements_Backend(GLenum mode, GLsizei count, GLenum type, const void* indices) {
536  #ifdef TRACY_ENABLE
537      ZoneScopedC(TRACY_ZONECOLOR_BACKEND);
538  #endif
539      if (ConditionalRenderDiscardsCommand()) return;
540      MGP_FILL(DrawElements);
541      MG_Backend::gBackendFunctionsTable.GL.DrawElements(mode, count, type, indices);
542  }

So MGP_FILL is already exactly one statement before the table call, after the last early return — PipeFill.h:10-15 states this contract, and GL_Drawing.cpp:514-524 explains why ConditionalRenderDiscardsCommand() gates the wrapper rather than the entry point (a discarded draw must still raise its argument errors). These 21 wrappers (Clear_Backend :526, then :535, :544, :554, :565, :574, :583, :593, :603, :612, :623, :634, :645, :655, :667, :678, :689, :699, :707, :718, :727) are the natural home for ValidateForDraw/ValidateForClear: one call site per verb class, already past every early return, already the boundary the pull/push switch is drawn at.

4.2 What the frontend already does per draw, before the backend

This is the work the tracker will sit beside, and it is not free:

  • PrepareCurrentProgramForDraw GL_Drawing.cpp:108-113. :104-107 calls it "the one funnel every drawing command passes through" and says the order is load-bearing: validate first, then publish the sample count, which reads the DRAW framebuffer binding. It calls GetProgramForDraw() (:109) and PublishDrawFramebufferSampleCount (:111 → :101 program->WriteReservedNumSamples(...) after ResolveDrawFramebufferSampleCount()). This is already a per-draw frontend write into a frontend object.
  • ValidatePrimitiveModeForBackend GL_Drawing.cpp:278-…: checks pActiveBackendObject (:283-289), reads GetBoundVertexArray() and rejects the default VAO in core profile (:291-298), reads GetProgramForDraw() again (:300).
  • AccountTransformFeedbackPrimitives GL_Drawing.cpp:173-239: reads IsTransformFeedbackActive (:174), IsTransformFeedbackPaused (:177), GetTransformFeedbackProgram (:207), walks GetTransformFeedbackBufferCount() binding points via GetBufferBindingPoint(BufferTarget::TransformFeedback, i) (:220-221), and issues up to six Add* mutations (:178, :183, :213, :232, :233, :238). :234-238 notes the instanced and indirect entry points never call it. Every one of these Add* calls shows up in the dirty-surface report as DEFERRED.
  • ConditionalRenderDiscardsCommand GL_Drawing.cpp:522-524.

So a draw already touches GetProgramForDraw twice, GetBoundVertexArray once, the XFB state four ways, and does a framebuffer sample-count resolve — before MGP_FILL copies 47 fields and before the backend re-reads all of it through MGB_CTX. The tracker's opportunity is to fold the fill, this prologue and the backends' memo probes into one walk; its risk is becoming a fourth pass.

4.3 The other seven classes' fill points

For completeness (each is a candidate ValidateFor* site): Framebuffer/GL_Framebuffer.cpp:620, 634 (blit), :646, 657, 668, 679 (ClearNamedFramebuffer*), :2739, 2746, 2753, 2760 (ClearBuffer*), :3008 (ReadPixels); Texture/GL_Texture.cpp:1080 (readback), :1624 (GenerateMipmap), :4030, 4047, 4469 (copies), :5080, 6463 (GetTexImage/GetTextureImage), :6668 (BindImageTexture); Program/GL_Program.cpp:3402 (ShaderStorageBlockBinding, the sole kProgramOp); Query/GL_Query.cpp 18 sites; Sync/GL_Sync.cpp 6 sites; Getter/GL_Getter.cpp:1177, 1358, 2269. GL_Drawing.cpp:764, 817 are the two dispatches, :839 PatchParameteri, :910, 932, 946 the barriers, :1269, 1352, 1385, 1400, 1606, 1638 the XFB span verbs.


5. The measured per-draw cost baseline P2 must beat

5.1 What the fill costs today (derived from the code, not measured)

A kDraw fill (PipeFill.cpp:591-609) iterates 63 field ids, tests a 2-word bitmask each (PipeFillPoints.inc:274-277), and executes 47 CopyField switch arms (PipeFillPoints.inc:280-281: "kDraw: 54 fields (47 own + 7 sticky)"). Inside those arms, per draw:

  • one RenderStateParameters struct copy — 1168 B (MEASUREMENTS.md:86), PipeFill.cpp:184;
  • 8×2 blend equations + 8×4 blend factors + 8 colour masks (PipeFill.cpp:47-57, :99-103);
  • 32 CurrentVertexAttributeValue copies (PipeFill.cpp:107-111);
  • 16 depth ranges + 16 indexed viewports (:118-122, :224-228);
  • 35 capability bools + 8 + 16 indexed capability bools (:229-245);
  • 14 buffer-binding-slot pointers + 4 binding-point base pointers + 4 touched counts (:64-86);
  • 4 SharedPtr copies with atomic refcount traffic: m_boundVertexArray, m_programForDraw, plus m_transformFeedbackProgram on XFB classes (PipeFill.cpp:62, :178, :219).

Plus, in a poison build, 47 Uint64 stamp stores into FilledGen[] (PipeFill.cpp:607) — but note MOBILEGL_PIPE_POISON is off in a plain push Release/INFO build without MOBILEGL_BUILD_DISAGGREGATED (PipeInputs.h:21-26), so the shipping push build pays the copy and not the stamps.

Order-of-magnitude: ~1.5–2 KB memcpy-equivalent plus ~47 switch dispatches and 2–4 atomic refcount pairs per draw, unconditionally, for a verb that in steady state reads ~8 fields. That gap is the P2 thesis.

5.2 docs/Disaggregated/MEASUREMENTS.md §3 — the device baseline (:46-76)

Method (:48): MOBILEGL_PIPE_STATS=1 through the retrace channel's --env, trace APK built from 7ef7c7e5, spike OFF, last complete 120-frame window. acc/draw and gate numbers are software-deterministic (identical on both devices; they count code paths, not hardware) — only wall-clock varies.

trace (frames) backend draws/f acc/draw buf B/f tex B/f (box/rect) ubo-global B/f ubo-named B/f gates hit/miss
minecraft-1.21.4-in-world (360) Espryt 91.6 9.28 13.5 K 635 K (185 box / 0 rect) 16.7 K 0 ers 9257/2577, etl 10538/1296, eub 10720/1114
minecraft-1.21.4-in-world (360) Magma 91.6 8.56 13.5 K 39.9 K (97 box / 89 rect) 16.7 K 0 mfp 0/10994, mpm 9240/1754, mdt 9120/1874
…fabric-iris-bsl-in-world (120, memo cold) Espryt 23.2 21.04 32.6 K 8.8 K 1.8 K 0 ers 1958/1843, etl 722/3079
…fabric-iris-bsl-in-world (120, memo cold) Magma 23.2 11.26 313 K 256 K 1.8 K 0 (vtxc 1.7 K) mpm 1890/895, mdt 1573/1212
improved-transparency-minecraft-26.3 (1200) Espryt 1320 8.44 333 K 0 0 0 ers 156925/2791, etl 148606/11110, eub 148246/11470
improved-transparency-minecraft-26.3 (1200) Magma 1320 6.53 173 K 0 0 331 K mfp 21360/137036, mpm 134421/2615, mdt 156611/1785
…neoforge-create-indirect-in-world both — — — — — — fails on both devices (§5), <120 frames

MEASUREMENTS.md:64 is the sentence P2 is judged against: "真机稳态动态 accessor 成本是每 draw 6.5–9.3 次" (the low end of the 10–25 prediction; llvmpipe's 15.5/20.7 are memo-cold). "推送要打败的是 ~8 次 accessor + memo 探测,不是 124/169 的静态调用点数。GO/NO-GO 的 tracker 绝对 ns 上限从这里定。"

Other §3 readings: :65 Magma repacks 331 KB of named UBO bytes per frame in the 26.3 world while Espryt binds directly (0) — the first real host-payload number; :66 union-box vs rect-list is a 16× texel-byte difference (635 K vs 40 K) on the same 185 emissions.

Repro command, MEASUREMENTS.md:70-76:

ANDROID_SERIAL=<serial> MSYS_NO_PATHCONV=1 \
python3 tools/trace_replay/run_android_retrace_local.py \
  --case minecraft-1.21.4-in-world --backend DirectGLES \
  --env MOBILEGL_PIPE_STATS=1 --env MOBILEGL_PIPE_STATS_PERIOD=120
# grep 'MGPipe stats:' in the result dir's mobilegl.log, take the last complete window

Harness traps that bite a P2 measurement run: MEASUREMENTS.md:92 (the trace app never reaches DestroyImpl, so MOBILEGL_PIPE_STATS_FILE JSON is never written on device — only the periodic log line; a run shorter than one period produces nothing), :93 (both devices share one .trace-work/android-retrace-result root which is rmtreed per invocation — must run serially from one tree), :94 (MSYS_NO_PATHCONV=1), :96 (create-indirect is broken on dev@81b17c0b too, not this branch).

5.3 docs/Disaggregated/MEASUREMENTS.md §6 — scale (:102-111)

quantity value source
backend pGLContext-> arrow sites 277 (Espryt 113, Magma 164) plan said 293, from a stale vendored inventory
non-arrow lines 58 (Espryt 9, Magma 49; 43 of Magma's are per-verb MOBILEGL_ASSERT) as planned
PipeInputs fields 63 plan said 61; GetBoundTransformFeedbackLifetimeId and HasOpenTransformFeedbackSpan are post-D21 read points
distinct accessors the backends call 62 (Espryt 32, Magma 56) GetBoundTransformFeedbackName has no reader, kept as an annotated dead row
fill points 83 MGP_FILL, 69 verbs, 9 classes MG_Pipe/FillPoints.def
SyncPersistentMappedRange / SyncGpuWrites 20 / 6 as planned

(The task brief's "63 fields, 61" phrasing: 63 is current, 61 was the plan's stale figure — MEASUREMENTS.md:108.)

Related §7 findings the tracker must not re-open (MEASUREMENTS.md:113-127): the verify lane found 9 missing fill rows across 79 retraces and 818 integration-verify cases, and says 8 of them are static over-approximation (the code path is reachable but the lane never got there) — "P2 收紧填充表时先复查这 8 行" (:115). An over-approximated (class, field) pair permanently disables that pair's poison, so tightening the table is P2's job and these 8 rows are the first place to look.

5.4 MobileGL/MG_Util/Metrics/PipeStats.{h,cpp} — the counters P2 must extend

Header purpose, PipeStats.h:15-23: sizes two things the tree cannot answer by reading — bytes per frame across the boundary, and accessor calls + memo-gate probes per draw, "the load-bearing number: … the real steady state is believed to be 10-25 accessor calls per backend per draw. Without a dynamic counter the P2 verdict stays a guess."

Cost when off, PipeStats.h:25-36: g_pipeStatsEnabled is a plain global (PipeStats.h:139, defined PipeStats.cpp:104) latched once at Init() from MG_Config::Features.PipeStats (PipeStats.cpp:257); every site is written if (Enabled()) Add...(...) (PipeStats.h:29), so off costs one hot-global load and a predicted branch. Counters are relaxed atomics (PipeStats.cpp:111-120) because texture/buffer staging is multi-threaded.

ByteClass (PipeStats.h:46-77): StageBuffer, StageTexture, StageUboGlobal, StageUboNamed, StageVertexClient, StageIndexClient, StageIndirectCmd, PersistentMapPush, ResidualValueBlock. PipeStats.h:73-75: ResidualValueBlock is a placeholder that stays 0 until P2 — P2 is the phase that must make it non-zero.

CallClass (PipeStats.h:81-102): Draws (the denominator; PipeStats.cpp:74-75 — "A dispatch is not a draw and is not counted"), AccessorCalls, TextureUploadEmissions, TextureUploadBoxEmissions, TextureUploadRectEmissions, TextureUploadJobs.

How acc/draw is actually counted — critical for writing an honest P2 target. PipeStats.h:85-88 and the site inventory PipeStats.cpp:76-88: AccessorCalls is a set of static tallies at ~10 hot entry points, not a wrapper around all call sites. Each instrumented function adds the number of GLContext accessor calls its own body executed on the path taken, and each tally sits after the last early return that would skip those reads. Covered: PrepareForDraw's own reads, SyncRenderState, CaptureDrawTextureSyncKeys/CurrentUnitBindingsEpoch, SyncNeccessaryTextures' walk, TrySetupDrawFastPath, GetOrCreatePipeline, ApplyDynamicDrawStateTail. Not covered: reads inside the callees those functions invoke (buffer/VAO/FBO/program sync, and the pipeline payload builder's ~40 reads on a memo miss), and every non-draw entry point. So the number is a LOWER BOUND.

The actual tally sites (constants are the per-path read counts):

  • Espryt: DirectGLES.cpp:1421 (+2), :1524 (+3), :1575-1576 (variable, the unit walk), :2043 (+1, render-state gate hit), :2053 (+3, gate miss), :2977 (+2, in PrepareForDraw, alongside :2976 Draws +1); Managers.cpp:4461-4467 (texture emissions/jobs).
  • Magma: VulkanRenderer.cpp:5009 (+1 pipeline memo hit), :5016 (+1 miss), :5274 (+15), :5927 (+1 dynamic-tail hit), :5934 (+2 miss), :6416 (+6), :6449 (+1, alongside :6448 Draws +1), :6462 (+3); VkTextureManager.cpp:3174-3178; UniformManager.cpp:2088 (StageUboNamed bytes).

Gate (PipeStats.h:107-122) — the six memo gates, each counted exactly once per probe (PipeStats.cpp:91), hit == short-circuited, miss == did the work:

  • EsprytRenderState — DirectGLES.cpp SyncRenderState render-state-version early-out (probe at DirectGLES.cpp:2042/:2048)
  • EsprytTextureSyncList — SyncNeccessaryTextures six-value key compare (DirectGLES.cpp:1558/:1573)
  • EsprytUnitBindingsEpoch — the (context, max unit, bind generation) shutter over the unit walk (DirectGLES.cpp:1426/:1431)
  • MagmaDrawFastPath — TrySetupDrawFastPath whole-snapshot fast path (VulkanRenderer.cpp:6453/:6458)
  • MagmaPipelineMemo — GetOrCreatePipeline (VulkanRenderer.cpp:5008/:5015)
  • MagmaDynamicTail — ApplyDynamicDrawStateTail version+extent tail gate (VulkanRenderer.cpp:5926/:5933)

The reading caveat P2 must respect, PipeStats.cpp:93-99: three of the instrumented functions (SyncRenderState, the texture-key capture, SyncNeccessaryTextures) are also reached from non-draw call sites — Clear, readbacks, the DSA by-name entries — which the Draws counter deliberately does not count. So acc/draw is the per-draw steady-state number only in a draw-dominated window; in a clear/readback-dominated window it is inflated by exactly those probes, and "the gate hit/miss pairs are the honest reading there."

Other API: PipeStats.h:129 kPayloadHistogramBuckets = 24 and RecordDrawPayloadBytes (:154) are implemented and unit-tested but called by nothing (PipeStats.h:124-128: MGPipe emits no records yet — "the first generator to emit records only has to add the one call"). PipeStats.h:132 kDefaultSummaryFramePeriod = 120, overridable via MOBILEGL_PIPE_STATS_PERIOD (PipeStats.cpp:258-260). PipeStats.h:159 OnPresent() folds the frame into run totals and every period emits the summary line. PipeStats.h:179 FormatWindowLine() is documented PURE (calling it twice returns the same text and steals nothing), with AdvanceSummaryWindow() (:183) as the separate window-closing call — PipeStats.cpp:366-393 computes window deltas against g_windowBase*. PipeStats.cpp:224-231 emits it via MGLOG_I with an explicit justification for breaking the MGLOG_D rule.

5.5 What P2 therefore has to measure

Concretely, the brief should ask for:

  1. acc/draw per backend, before vs after, on the same three traces in §5.2 — same last-complete-120-frame-window protocol, same devices, serial runs. The target is: strictly below Espryt 9.28 / Magma 8.56 (vanilla in-world) and 8.44 / 6.53 (26.3), on a like-for-like tally. Caveat to state up front: the tracker will move reads out of the instrumented functions, so the existing static tallies will under-report the new world unless P2 re-audits the tally constants (DirectGLES.cpp:1421, 1524, 2043, 2053, 2977; VulkanRenderer.cpp:5009, 5016, 5274, 5927, 5934, 6416, 6449, 6462). A tracker that scores 0 acc/draw by relocating the reads has proved nothing.
  2. The six gate hit/miss pairs — the honest reading per PipeStats.cpp:93-99, and the one that survives the relocation problem. mfp 0/10994 (Magma fast path never hits in the vanilla world) and mfp 21360/137036 (13% in 26.3) are the two most interesting existing numbers.
  3. A new counter class for the tracker itself — absolute ns per validate, per class. ROADMAP.md:18's acceptance is "两台设备配对逐线程 CPU p50/p99 不差且 tracker 绝对 ns 在上限内", and MEASUREMENTS.md:64 says the ns ceiling is derived from the ~8 acc/draw figure. PipeStats has no timing counter today; RecordDrawPayloadBytes + the 24-bucket histogram (PipeStats.h:129) is the nearest existing machinery and is currently dead.
  4. ResidualValueBlock bytes/frame — the placeholder at PipeStats.h:73-75 that P2 is supposed to make real, sized against ResidualValueBlock = 1248 B of which RenderStateParameters is 1168 (MEASUREMENTS.md:86).
  5. The fill cost it replaces — there is no counter for it today. Adding a ByteClass or CallClass for "fields copied at the verb boundary" would make the 47-copies-for-8-reads gap (§5.1) a measured number rather than an argument.

6. Open questions the implementers will hit

  1. Eight validate entries or nine? ARCHITECTURE.md:153 says eight; the landed FillPoints.def:108-109 has nine classes because kProgramOp was split out for ShaderStorageBlockBinding. Pick one and update the other.
  2. The dirty-surface mapping file has no format yet. The script prints UNMAPPED for all 73 mutators (gen_pipe_dirty_surface.py:193-194) and nothing in the tree defines what it maps to. It also has three self-declared blind spots (:195-197): lambdas, helper-published mutations, and — worth adding — it only scans MG_Impl/GLImpl, so the four MGP_NOTE_MUTATION sites in MG_State/GLState/TextureState/TextureState.h are outside its scan root entirely.
  3. RecordError is 90% of the surface and is a reverse channel, not state (Coverage.def:99, PipeInputs.h:574). The mapping file should exempt it explicitly rather than let it dominate the count; that leaves 72 real mutators / 90 real calls.
  4. The 8 over-approximated fill rows (MEASUREMENTS.md:115) must be re-checked before the fill table is tightened, or the poison stays permanently disabled on those pairs.
  5. Object-class dirty has no oracle. The verify comparator compares O fields by identity only (PipeInputs.h:248-249), so under-triggering a NEW_SAMPLER_VIEWS/NEW_FRAMEBUFFER dirty bit is invisible to today's harness. ARCHITECTURE.md:502 names this as the dangerous direction and describes the retention mode (保留模式) verify needs for consume-and-clear groups. That extension is P2 work, not something the P1 lane already provides.
  6. Push-on-mutation vs the dirty walk. The four MGP_NOTE_MUTATION statements exist because backends write frontend state inside their own verb (PipeFill.cpp:466-478). A tracker that computes dirty state once at the validate point has the same exposure; MEASUREMENTS.md:117 says push-on-mutation is "正是 P2 tracker 需要的形状", so keep it rather than replace it.