Files
MobileGL/docs/Disaggregated/notes/perf-p15/DIRTY-MASK-PLAN.md
T

10 KiB

Per-context pipe dirty mask: plan (2026-10-09, PARKED)

Status: parked by the user on 2026-10-09 after the revised estimate (FCL ~0.012-0.03 ms/frame). The persistent-map flush goes first; revisit this for draw-heavy content. C8, the cross-context gap below, is approved as its own fix.

Replaces MGPipeTracker::Update's per-verb polling of ~25 frontend counters with a per-context mark word. Mutations OR in the subsystem bits they affect; the validate point visits only the marked bits and keeps today's leaf compare. A sharper form of PLAN-P15 stage 1; it is client-side only and the record stream stays byte-identical on every transport.

Expected savings (revised by the planning pass)

The pool this change can touch on FCL is ~0.04-0.06 ms/frame:

  • Tracker::Update 0.018-0.029;
  • CopyField 0.018-0.021;
  • part of MGPipeValidateForVerb self.
workload gain (ms/frame)
FCL ~0.012-0.03
rd12 ~0.10-0.15

The 0.3-0.5 of PLAN-P15 stage 1 also skipped ReadDrawBindings, MarkGpuWritesForDraw and the backend walks, which are out of scope here.

Design

  • GLContext gets:

    • Uint32 m_pipeDirty = ~0u;
    • an owner serial (TakePipeDirty(trackerSerial) returns all bits to a different tracker, so the verify shadow walk or a test tracker cannot steal marks);
    • m_seenShareClock.
  • Root check = mask 0. No separate counter.

  • Shutter groups are the visit unit, computed with today's formulas word for word:

    group bits covers
    GR 0, 1, 3 render state, pipeline state, patch
    GK 2 pixel pack
    GA 4 attrib defaults
    GV 5, 9, 10 VAO, vertex buffers + base instance, index
    GP 6, 7, 8 program walk
    GT 12, 13, 14 texture aggregates, bind generation, max unit
    GF 11 framebuffer
    GB 15, 16, 17 bind points

    Closure: GT and GB also compute GP, because they mix shader, opaqueUnits and programImages.

  • Share-group clock: in ShareGroupState, an atomic gen plus a context count.

    • Bumped by:
      • the unclassified-entry prologue;
      • classified bodies that write shared objects (Uniform*);
      • texture aggregates;
      • BumpSamplingResolutionGeneration;
      • link publish.
    • The bump is skipped while the group has one context; a later context starts with all bits.
    • Ordering: release fetch_add after the object writes, acquire load in the tracker.
    • Fan-out {6, 7, 8, 12, 14, 15}.
  • Pseudo-bit CV (bit 18) for EmitContextValues. Its own accumulator is consumed only when a verb class that reads context values runs.

Mark sites (chokepoints first; no per-call-site edits where a funnel exists)

site marks
M1 RenderState: sink from the GLContext constructor BumpVersions → GR; the bare ++m_version sites go through one BumpRenderVersion(); SetPixelStoreParam → bit 2
M2 BindingSlot change sink (MG_Util/Types.h), marks only on a real change FBO draw/read slots → 11; each VAO's index slot → 10
M3 TextureState NoteUnitTouched, bind generation, content/params notes → GT, plus CV when the high-water mark grows; sampling-resolution bump → GT + clock; SetActiveTextureUnit → CV
M4 BufferState bind-point change → GB by target; TouchBindPoint → CV
M5 VertexArrayState bind, delete, attribute change → GV
M6 FramebufferState attachment change → GF
M7 GLContext UseProgram / pipeline bind / pipeline delete → GP|GT|GB; XFB state → 17 + CV; attrib defaults → 4
M8 MGPipeNoteAggregate via M3-M6; clock bump for TextureContent/TextureParams/FramebufferAttachment
M9 BumpLinkObservableVersions → MGPipeNoteProgramObjectChanged() covers link, both joins and the binary failure path; the hot ProgramObject setters get no marks (a TLS read per uniform write)
M10 tracker carry pending base instance → GV

Export prologue. DECLARE_GL_FUNCTION_END* (Definitions.cpp) runs MGP_ENTRY_PROLOGUE(name) with a compile-time lookup in a new MG_Pipe/PipeDirtyEntries.def.

  • Default: mark all bits and bump the clock.
  • A kBodyMarks row means the body and funnels mark.
  • Hand-written exports that do not forward (glFinish, glFlush, debug stubs, ...) call the prologue themselves.
  • EGL/GLX/WGL/CGL entries mark all bits.

Events outside GL entries:

event coverage
MakeCurrent tracker Reset + mark all on the incoming context; after the TLS rebase, move this into CurrentGLContextSlot::operator=
surface resize not a tracker input (frontend default FB is a fixed placeholder); EGL mark-all covers it anyway
deletes that unbind mark-all, plus the M2/M5 sinks on the inner unbinds
buffer realloc no shutter reads buffer size; no change
GenerateMipmap mip growth entry mark-all; the inner path also goes through the M8/M3 funnels
context reset / new session MGPipeForgetEndedSession → Reset → all bits
backend in-verb writes the M3/M8 funnels
async link completion M9
other contexts in the group the share clock

Validate path

  • Update visit set: the context marks, the tracker carry, the share fan-out when the clock moved, and all bits until primed; mapped to groups and closed.
    • If the set is empty, return 0.
    • Otherwise compute, compare and latch the visited groups only.
  • Oracle (verify builds, PipeVerify armed): a shadow full walk F against the gated walk G.
    • It requires F == G, else Fatal{PipeDirtyUnderMark, "<bits>@<verb>"} with a ring of the last 8 entry names.
    • It honours VERIFY_FATAL=0.
  • Emission: wrapped in if (liveDirty). Fired bits, and therefore records, are unchanged.
  • CV accumulator: the existing generation and suppressor fast path stays as a second line of defence. VerbMaskReadsContextValues is precomputed per class.
  • Optional residual gating (C5) is limited to rows that depend only on the address or the context identity.
    • It is keyed on context id, the bound VAO lifetime id, a fill-plan rebuild epoch and an applier-reset / pin-release epoch.
    • GetProgramForDraw / GetProgramForDispatch are never skipped: CopyField joins pending links and refreshes the uniform mirror there.
    • The accumulator is cleared only inside fillOwed; under run-ahead, unbarriered verbs keep it.
    • Poison stamps still stamp every walk field.
  • Left alone: the run-ahead/barriered predicate, QuiesceApplierBeforeFill and the dual-block (ROLE_SPLIT_STATE) semantics.

Found during planning: a pre-existing cross-context gap

Shutters 11-14 compare per-context aggregate counters that a texture or renderbuffer change bumps only on the live context.

  • Effect: a change made through context B, with no verb on A in between, does not fire A's framebuffer or texture bits.
  • The mask keeps today's bytes exactly (visit only).
  • Closing the gap means mixing the share clock into shutters 11-14 (C8). That changes the wire for multi-context traces, so it is a separate decision.
  • FCL is single-context.

Tests

  • Existing, unedited: TrackerTest (TrackerWalk, TrackerShippedEmitter, TrackerAggregates) stays green; RecorderGoldenTest goldens are unchanged.
  • New TrackerDirtyMarks table test, one row per tracker input. Each row:
    • mutates through the real mutator, not the export, so mark-all cannot hide a gap;
    • asserts the fired bits match the full walk;
    • asserts the expected bits were marked.
  • New share-group cases:
    • deterministic: A walks, B writes a uniform on a shared program, A walks again and bit 8 must fire;
    • threaded: a hand-off between threads, checked by readback in ConcurrentContextsTest.
  • Red-once: drop each funnel's mark in turn and see its row fail.
  • CI:
    • integration-verify runs the oracle fatally.
    • Verify-only negative control MOBILEGL_PIPE_DIRTY_DROP=<site>, one site per class.
    • With FATAL=0, entry-prologue lists every entry still relying on mark-all.
    • gen_pipe_dirty_surface.py --check gains mark coverage.
    • A scanner CTest checks that every export has a .def row or the default.
  • Wire identity: spawn/inproc/TCP arms, spawn_lane_parity.py, the retrace legs, and PipeStats fire counts identical to the baseline.

Measurement

  • Host: PipeStats per-group visits, the TrackerRoot hit rate, fires equal to the baseline.
  • Device:
    • cpuhunt, FCL monolith on both backends + rd12, 3 interleaved reps.
    • Read from profiles: Tracker::Update, ValidateForVerb inclusive/self, CopyField, the prologue, and the TLS resolver for extra reads.
    • fps only as a no-regression check.

Commits

# commit size risk gain
C1 regroup Tracker.h into shutter groups; still visits all ~+250/-150 low 0
C2 mark plumbing M1-M10, prologues mark-all, .def, DROP knob, oracle in audit mode ~+500, 14 files medium 0 (prologue +~0.004)
C3 gate on, fatal oracle, share-group tests ~+150 medium most of the Update cut
C4 CV accumulator ~+40 low ~0.002 FCL
C5 residual gating (optional, measure first) ~+120 medium 0.005-0.01 FCL / 0.03-0.05 rd12
C6 classify the FCL between-draw entries, table test, scanner, CI controls ~+400 medium-high the actual win
C7 docs, SCOREBOARD small - -
C8 optional, separate approval: close the cross-context aggregate gap ~+60 wire change in multi-context traces correctness

C6 entry list:

  • draws (free);
  • BindVertexArray, BindBuffer, BindVertexBuffer;
  • float Uniform* / UniformMatrix* → {7, 8} + clock; Uniform1i → {7, 8, 12, 14};
  • UseProgram, BindFramebuffer;
  • Viewport, Scissor, PolygonMode, BlendFunc*;
  • Enable/Disable, once every capability path is confirmed to use SetCapability;
  • the depth, colour and stencil setters;
  • BindTexture, ActiveTexture, BindSampler.

Overlap with the TLS work (tlshoist):

  • Core.h / Core.cpp: separate hunks.
  • PipeFill.cpp: medium conflict around gPipeInputs and MGPipeNoteAggregate.
  • TrackerTest fixtures: use .Take().
  • Definitions.cpp macros and the GLImpl entry bodies: conflict if TLS hoists there.

Order:

  1. C1 can run now.
  2. C2-C5 after the TLS merge.
  3. C6 after the TLS per-entry hoist.