18 KiB
P5d round 3 - package C: client verb overhead (image-unit sweep, validate/fill, server stamp)
Worktree MobileGL-p5d-C, branch p5d-C, base 85362a85. Not built, not committed.
Patch: .../scratchpad/p5d/patches/C.patch (865 lines, 11 files, after the fix round below).
What changed, per file
1. The 32-unit image sweep per draw (brief item 1)
MG_State/GLState/TextureState/TextureState.h- newNoteImageUnitTouched(Int)/GetMaxTouchedImageUnit()and anInt m_maxTouchedImageUnit = -1member, all inside#if MOBILEGL_PIPE_PUSH(the private member joins the two push-only generations already there). A second mark, not a widening ofm_maxTouchedUnit(image units are their own array). It only ever grows, so a walk bounded by it over-approximates the units that hold a binding - the only direction a conservative GPU-write set may fail in.MG_State/GLState/Core.h- the twoGLContextpass-throughs, same guard.MG_Impl/GLImpl/Texture/GL_Texture.cpp-BindImageTexturefeeds the mark, same guard.MG_Remote/Client/GpuWritePending.cpp-MarkWritableImageBufferTextureswalksunit <= GetMaxTouchedImageUnit()instead ofunit < MAX_TEXTURE_IMAGE_UNITS. On the profiled workload (no image binding anywhere) the mark is -1, so the walk is one compare. Profile tie-in: 2.15% self / 3.59% inclusive of the GL thread,GetImageTextureBinding1.37 of it.MG_Impl/Pipe/ImageEmit.h- its comment claimed no such mark could exist without resizing the pull build's object. Updated, not deleted: the mark is push-only, and the emitter deliberately keeps its program-derived window (the narrower "which units can a shader read").
2. MGPipeValidateForVerb (brief item 2)
MG_Impl/Pipe/PipeFill.cpp- step 4's seven-termsuppliedconjunction was evaluated 63 times per verb; it reads nothing about the verb. It is now a memo (SuppliedFieldMask) keyed on the four facts it does read: the push mask,ApplierDerivesRenderStateFields(),contextValuesWireLive(passed in, so P5c rv's emission half and fill half still read the ONE answer), andP4aFamilyHasItsConsumer(kMGPipeP4aFamilySubsystems)- all four families ride that one signal by that function's own rule. The walk becomes one bit test per field; the expression is moved, not copied. Profile tie-in:MGPipeValidateForVerb4.11% self / 10.9% incl.;P4aFamilyHasItsConsumeralone is aCapsMirrorread taken 63x/verb for one answer under split.ParsePoisonOmissionKnob- the per-verbString == String(out-of-line even for two empty strings, which is every shipping configuration) is now a size compare inline, contents only when a non-empty value is involved. Re-parse condition unchanged.MG_Impl/Pipe/PipeFill.h-MGPipeResidualFillSuppliesField(field, pushMask, applierDerives, contextValuesWireLive), exported for the unit gate only, inMGPipeP4aFamilyEmits' shape.
3. MGPipeServerStampVerbBoundary (brief item 3)
MG_Backend/MGPipe/PipeInputs.cpp- the per-verb 63-fieldanswerablederivation is now aconstexprper-verb-class mask table (AnswerableMaskForClass/kAnswerableByClass), built from the same expression. The loop body isFilledGen[i] = serial & -bit- no lookups, no calls, no branch. Withdrawal semantics byte-identical, including the out-of-range verb (kNoFieldIsAnswerable, all 63 withdrawn), which is whatFieldIsInVerbClassanswered for it; that helper stays,MGPipeInputUnfreshReadstill uses it. Profile: 1.1% self of the apply thread, and on a lockstep frame that is client wait time one-for-one.
Red-once
| change | test |
|---|---|
| stamp table | FieldOwnershipTest.AServerStampMakesRecordSuppliedFieldsFreshAndWithdrawsTheRest (existing) - it recomputes answerable independently per field and compares. TheStickyExemptionIsCancelledByTheServerStamp covers the withdrawal of the seven forwards. |
| supplied-set memo | FieldOwnershipTest.TheResidualFillsSuppliedMemoReKeysOnEveryInputThatMovesAnAnswer (added; renamed and extended in the fix round below, which restates this row) - moves each of the three key inputs that can move an answer on its own and reads the answer back in between, in an order where a memo missing that input must return the stale answer. This is the only thing a memo can get wrong that the expression could not; which fields the fill copies has no other observable, because a push build routes every emission straight into the applier, which writes the same storage. |
| image-unit bound | SplitBufferSet.Row2TheSweepIsBoundedByTheImageUnitHighWaterMark (added) - pins both ends of the bound (mark at -1 walks nothing; a unit at the mark is found). Revert the bound and its second half goes red. |
| the mark's producer | ImageEmit.TheBindFeedsTheImageUnitHighWaterMark (added) - GL::BindImageTexture must move the mark, and an unbind must not shrink it. Drop NoteImageUnitTouched from GL_Texture.cpp and this goes red at the entry point instead of as a missed GPU write. |
| (adjusted) | SplitBufferSet.Row2AWritableImageBufferTextureIsMarkedByADraw now notes the unit, because it writes the binding straight into the slot and never goes through glBindImageTexture. |
G2/G14 name parity kept: the two new split/push-only names have pull-lane skip twins
(SplitBufferTest.cpp's MGL_SPLIT_ONLY_OR_SKIP list, ImageEmitTest.cpp's
MGL_IMAGE_EMIT_TEST_LIST, FieldOwnershipTest.cpp's #if !MOBILEGL_PIPE_PUSH block).
G1 / monolith impact
- Pull build (
MOBILEGL_PIPE_PUSH=OFF): no change at all.PipeFill.{h,cpp}is appended toSOURCE_FILESonly underMOBILEGL_PIPE_PUSH(CMakeLists.txt:519-523);PipeInputs.cpp's stamp is inside#if MOBILEGL_BUILD_DISAGGREGATED;GpuWritePending.cppis MG_Remote. The two MG_State files and the one MG_Impl call site are each#if MOBILEGL_PIPE_PUSH. G1 stays 0/0/0/0. - Push monolith:
TextureStategrows 4 bytes (m_maxTouchedImageUnit) andglBindImageTexturegains one compare. Behaviour identical - the member has no reader outside MG_Remote.PipeFill.cpp's memo and the knob fast path are behaviour-identical by inspection. - Behavioural delta worth naming:
MG_Remote::Client::ConsumerRefusals()will count fewer refusals, because the residual fill asksP4aFamilyHasItsConsumeronce per environment instead of 63 times per verb. It is a diagnostic counter; the one-timeMGLOG_Wper refused subsystem still fires, and no test asserts an exact value that the fill contributes to (SanityTest.…CountsTheRefusalasserts a delta> 0from the mint path;RemoteClientTest/CapsMirrorTestdriveServerConsumesdirectly).
What remains per draw in the validate point / draw emit (brief item 4, surveyed not cut)
wants()still callsP4aFamilyHasItsConsumerper emitted gate (~10/verb) and tests the dirty bit LAST, so a clean bit still pays the consumer read. Reordering the dirty test first is a pure win except thatServerConsumescounts and logs - "a refusal that happened cannot fail to be counted" is R-8's stated rule. Left alone deliberately; it needs R-8's owner to say whether the counter may move.ContextValuesWireLive()per verb = three loads (g_active,m_started, one acquire load). Left.RunsAsTheServerRole()at PipeFill.cpp:3037 is inside thetracker.FreshlyPrimed()arm - a make-current edge, not per draw. Not hot. (PipeFill.cpp:992's is the respecify path, also not per draw.) Package D owns the guard itself.EmitTables.cpp:ReadDrawBindings()re-resolves the element buffer's handle every draw (MGPipeResourceTrackerInstance().Find->MGPipeSlots().FindByLifetimeId, which under split also paysMGPipeRefuseAllocatorFromApplyThread- package D's guard).VertexInputEmit.h:257-274computed the same handle at validate, but only when bitNewIndexBufferwas dirty, so there is no already-computed value to reuse on a clean-index draw; a latch keyed on the VAO's index slot + the tracker generation would be needed. Left - it is a new latch with its own invalidation story and belongs with package B/D's tracker work, not in a cut described as "provably redundant".RequireSessionand theg_dropDrawEmissionordering are one global read each and E2's control order respectively; nothing else on that path is redundant.
Risks / not done
- Not built. No compiler has seen any of this. The constexpr stamp table
(
MakeAnswerableMaskTable, aconstexprfunction with loops overinline constexprgenerated tables) is the most likely place for a toolchain complaint. - The memo is a file-scope global, like
g_residualDue/g_omission/ the verify latches beside it; what keeps a single writer on all of them is the verb barrier. If a future change lets two roles fill concurrently, this global joins the list that has to move - stated in the code comment. - The image-unit mark's safety rests on
glBindImageTexturebeing the only writer ofm_imageTextureBindingsthat can add a binding. I checked: the other writer isTextureState::MarkTextureObjectForDeletion, which only unbinds. Tests that pokeGetImageTextureBinding(u)directly must note the unit - one such test existed (SplitBufferTest) and was updated;MG_IntegrationTesthas none. - Brief item 2 also asked about the residual fill's object-class copy cost (
GetBindingSlotfromCopyField, 1.73 self / 45% of it from the draw fill). That cost is the copy itself, not the predicate: those rows are BARRIER-PULLED and must be copied until P3b/P4b/P7/P8 give them twins. Nothing cut there; it is package E's question.
Fix round
Same worktree (MobileGL-p5d-C), still uncommitted, still not built. Patch regenerated:
11 files, 587/64. Every blocker and major addressed; all seven minors addressed too (six were
one- or two-line corrections, the seventh is the by-value return).
MAJOR - the memo's fourth key input is untested (FieldOwnershipTest.cpp:405)
The finding is right, and it is stronger than stated: that input is not merely untested, it is UNTESTABLE through this observable, and the suggested fix cannot work as written.
The reviewer proposed flipping the consumer signal around GetFramebufferBindingSlot,
GetTextureUnitObject, GetProgramForDraw or GetImageTextureBinding. Those are exactly the
five fields whose emitter belongs to a P4a family - and every one of them is a field
EmittedCallSuppliesTheWholeField() answers false for (PipeFill.cpp:2276-2290: their storage
is a frontend heap reference - a BindingSlot<FramebufferObject>, an ImageTextureBinding, a
TextureUnit, two SharedPtr<ProgramObject> - that no payload can carry, so the fill pulls them
whatever the consumer says). So supplied is false for all five in BOTH consumer states: the
consumer conjunct is dominated, an assertion pair over those fields would read
EXPECT_FALSE / EXPECT_FALSE, and a memo whose key dropped the signal would pass it - vacuous
in the other direction, which is no better.
What went in instead, in three parts:
PipeFill.cpp- a compile-time trip wire.NoP4aFamilyFieldIsWhollySupplied(), aconstexprwalk ofkMGPipeFieldEmittedBythroughSubsystemForEmitterandEmittedCallSuppliesTheWholeField, with astatic_assertover it. It fires on the day P3b/P4b/P7/P8 give one of those rows a twin the applier can write - which is the day the consumer signal becomes observable - and its message says to go write the pair. This is the durable guard; it sits next to the predicate and cannot rot.FieldOwnershipTest.cpp- step 4, which MOVES THE SIGNAL FOR REAL. Under monolith the signal isMGPipeGetResourceOps() != nullptr(PipeFill.cpp:1156-1171), andMG_Config::Transportis aconstexprMonolith in every lane but a split one, soMGPipeSetResourceOps(&ops)/(nullptr)moves it in the lane this case runs in, with no new include and no MG_Remote dependency. The case collects the WHOLE 63-field answer set in each state atkEverySubsystem(the only mask at which the fill reads the signal at all), asserts the signal really moved, namesGetFramebufferBindingSlotboth ways, and asserts the two answer sets are identical - with a failure message saying that a difference is correct behaviour and the instruction to rewrite the step into the move-and-read-back pair, namingCapsMirrorInstance().Adopt()and the SanityTest case that already drives it for the split arm. It restores the previous op table on the way out.- The name.
...ReKeysOnEveryInputbecomes...ReKeysOnEveryInputThatMovesAnAnswer, in both the push case and its pull-lane skip twin (G2/G14 parity kept). The finding that the old name and the red-once table oversold the coverage is accepted.
Red-once table correction for that row: the memo's case covers three of the four key inputs
by moving them; the fourth is covered by a domination assertion plus a static_assert, because
no observable distinguishes it today.
MINOR - the consumer read moved to every verb, counters move the wrong way (both reviewers)
Accepted, and fixed rather than merely documented. SuppliedFieldMask now reads the signal only
where the walk could reach it:
const Bool p4aConsumer = (pushMask & kMGPipeP4aFamilySubsystems) != 0 &&
P4aFamilyHasItsConsumer(kMGPipeP4aFamilySubsystems);
At a mask with no P4a bit, (pushMask & subsystem) != 0 short-circuits every P4a field in the
rebuild and P4aFamilyHasItsConsumer returns a constant true for every other subsystem, so no
field can reach the mirror - the guard is exact, not conservative. The key stays complete because
pushMask is itself in the key, so a mask that gains a P4a bit re-keys on the mask. Net effect:
ConsumerRefusals() and LastRefusedSubsystem() are now unmoved at the A/B lanes and in the
bring-up window (CallMask 0) where the baseline never touched the mirror, and strictly fewer
refusals elsewhere - which makes the report's "will count fewer refusals" line true in both
directions rather than only one. The reasoning is in the code comment at the read.
MINOR - SuppliedFieldMask returned a reference into a memo it rebuilds in place
Accepted. It now returns MGPipeFieldMask by value; the rebuild fills a local and publishes
it into g_fillPlan in one assignment at the end, so no caller can observe a partially rebuilt
mask and no rebuild can zero storage a caller holds. The call site at the walk takes
const MGPipeFieldMask supplied (a copy, 16 bytes, read 63 times - cheaper than the indirection
it replaces). The comment that cited g_residualDue as precedent now says why that precedent
covers the file-scope-ness but not the width.
MINOR - the stale-memo failure mode is stated wrong (PipeFill.h:78, test header)
Accepted; the reviewer is right. The walk stamps FilledGen[i] = filled.CurrentVerbSerial
whether or not it copied, so a wrongly-skipped field reads FRESH with the previous verb's
value - a silent stale binding slot, caught only by the verify lane, not
Fatal{UnmigratedPipeInput} (poison catches an unstamped read, not a stamped-but-uncopied
one). Corrected in the test case header and stated explicitly in PipeFill.h, where it is now
the reason the key case is the only guard.
MINOR - "32 slots" / "32 units wide" (TextureState.h, GpuWritePending.cpp)
Accepted, in both places. MAX_TEXTURE_IMAGE_UNITS is 192: the replaced sweep was 192
GetImageTextureBinding reads per draw, so the measured win is 6x larger than the comment
claimed, not smaller. GpuWritePending.cpp now names the constant and the count;
TextureState.h's folding argument now uses real image-unit numbers (a bind of unit 7 pushing
the texture scan out to eight slots, the last advertised unit pushing it to 191) instead of the
invented 32. The report's own prose never quoted 32, so nothing there needed the same fix.
MINOR - the G2 parity count comment is off by one (SplitBufferTest.cpp:552)
Accepted; the block declares eighteen names in each lane (diffed, 18 vs 18). Rather than write
EIGHTEEN and let it rot a third time - it read SIXTEEN against seventeen before this commit
and SEVENTEEN against eighteen after - the sentence now drops the count, says the list below is
the authority, and records that the number has never once matched.
MINOR - the new producer case binds image unit 4 (ImageEmitTest.cpp:582)
Accepted. The case now binds unit 1, like every neighbouring case, and - because even that
depends on the backend advertising two image units - it ASSERTs that the binding actually
landed (Ctx().GetImageTextureBinding(1).Texture != nullptr) before it reads the mark. A bind
refused at unit >= GetAdvertisedImageUnitCount() (GL_Texture.cpp:6694) now says so by name
instead of reading as a missing NoteImageUnitTouched.
Not changed, and why
Nothing was disputed on the merits. The reviewers' "checked and cleared" sections (stamp-table
equivalence, memo key completeness term by term, single-writer / no new concurrency hazard, the
image-unit mark's writers, G1 = 0 in the pull build, the new tests' typing,
ParsePoisonOmissionKnob's equivalence) are unaffected by this round: the stamp table, the knob
fast path, the image-unit mark and every #if guard are byte-identical to the reviewed patch.
The only disagreement is the one recorded under the major, and it is a disagreement about the
proposed FIX, not about the finding.
Risks, restated
- Still not built. This round adds one more
constexprfunction with a loop (NoP4aFamilyFieldIsWhollySupplied) beside the stamp table'sMakeAnswerableMaskTable, in the same standard-conforming shape; no toolchain has seen either. - The new test step depends on
MG_Config::Transport == Monolithto move the consumer signal and skips its body otherwise, so in a split-transport lane step 4 does not run. That is the right trade today: the assertion it would make there is the same dominated one, and thestatic_assertcovers every lane.