The three things that made the deferral inert are all fixed, in the order they were
found: the reply ring could not hold an answer across the ~1,000 records between two
creates; a residency budget could not help because what displaces an answer is what
the SERVER sends, not what the client emits; and so the mechanism had to be retention.
WHAT LANDED
* ILink::DeclareReplyRead + LinkCapabilities::RetainsReplies. The wire declares a
seq will be read BEFORE it publishes, so the declaration cannot race its own
answer; a link with the capability false ignores it, and the window does not
defer on one (CreateWindowEffective() is gated on it).
* StreamLink's ring of the last 8 answers becomes a declared-answer store: a
monotone whitelist plus a cursor, answers kept until their reader TAKES them
(take-and-delete under one lock, bytes swapped into the scratch), and a capacity
of 24 that is FATAL to exceed rather than evicting. An evicted wanted answer is
unrecoverable - re-latching by sending another create wipes the server's record
(PipeApply.cpp:2033) and its PendingUploads (PipeApply.h:332) while the client
cleared its dirty flags at emission (PipeApply.h:298-304) - so the capacity is
sized so it cannot be reached and reaching it says its own name.
* An answer nobody declared is DROPPED, which is what keeps the store bounded by the
client's deferral depth instead of by the server's answer rate. Retain-on-arrival
grows ~11.3 entries per create against a server that ignores the no-reply bit.
* A read that fails after WaitForApplied has returned Reached is now Fatal{ReplyMissing}
naming the seq: on the stream arm the reply frame strictly precedes the progress
frame that publishes appliedSeq, so that combination is a broken invariant, and
retrying it (what the row used to do) wedges the window for the rest of the
process while leaving every deferred object latched on a provisional accept.
* The residency budget is deleted (WireTables.cpp keeps its history), the window
retirement branch drains once before falling back - it could otherwise strand the
answers it was holding - and Detach clears the store.
MEASURED ON THE DEVICE, load frame, both ends rebuilt, frame-sent identical per op
(create 4,536, records 103,097) throughout:
window 1 (R-5's shape) 4,536 takes 28.66 s
window 2 explicit 2,500 17.27 s
window 2 AS THE SHIPPED DEFAULT 2,563 16.52 s
window 4 1,644 11.02 s
window 2 against an OLD SERVER 2,663 16.38 s (zero Fatals)
The last row is the version-skew case the design review named as a must: a server
built before the no-reply bit answers ~55,903 records a frame, the whitelist drops
every answer nobody declared, and the deferral still reads its own back.
GATES (172 + 15 green), and the important one is proved to go red:
* ADeclaredAnswerSurvivesFarMoreInterveningAnswersThanAnyFixedBuffer - reverting the
store to "keep the last 8 arrivals" fails it immediately with "the declared answer
did not survive 64 intervening ones". That is the line between the ring-1024
stand-in and a bounded mechanism.
* AnAnswerNobodyDeclaredIsNotKeptAtAll - red if everything is retained.
* TheWindowRefusesToDeferOnALinkThatCannotRetainAnswers - on the in-process (ShmLink)
arm the knob at 4 must still block every create, red if the capability gate goes.
* The refused-create case now reads the repair that exists on THAT arm (the latch),
and asserts the window's own books are empty there.
NOT DONE, and named: the window has no unit gate on the STREAM arm yet - only the
device runs. Three rounds of this feature were green in the lanes while inert on the
device because the lanes and the device run different transports; the transport-level
retention gates close that for the store, and the window-level one should be built on
StreamClientPublicationTest's real stream session next.
The second one-line experiment, and the one that decides whether the retention design
is worth building. Ring 1024 plus the residency budget DISABLED (its constant raised
out of reach), W=2 and W=4:
arm waits rtt_mean by_op 2 wall
W=1 (today's default) 4,543 5,672 us 4,536 28.66 s
budget on + ring 8 9,078 2,598 us 9,071 27.04 s
ring 1024 + budget 7 3,450 5,367 us 3,443 23.04 s
ring 1024 + budget OFF W=2 2,560 4,916 us 2,553 16.80 s
ring 1024 + budget OFF W=4 1,691 4,224 us 1,684 11.45 s
rtt_mean does NOT grow (4,916 / 4,224 us, both BELOW the 5,672 us baseline) while the
waits fall by 2x and 2.7x and the wall follows. So the ~5 ms is a WAKEUP round trip and
the server drains a batch per wakeup - which is exactly what makes W pay, and it is the
question the audit listed as undecidable from the repo and the watershed for whether
this is worth doing at all. It is.
AND IT BEATS THE HANDOFF'S OWN FLOOR: handoff-2 section 0.4 put the window's landing at
15-20 s, treating the client's ~13.4 s CPU as ADDITIVE with the waits. This frame turns
out to be wait-dominated, so amortising four creates per wakeup lands at 11.45 s, 3.5 s
below that estimate.
1024 IS A STAND-IN, NOT THE DESIGN: it works only because 1024 happens to be deep enough
for this workload, and 1024 retained answers is ~2 GiB worst case (a ReadPixels answer can
be 2 MiB). Retention gives the same property - the answer is still there - with a bound
tied to the client's own deferral depth, which is why the implementation the audit
specified is what gets built, not "make the ring 1024".
Also corrects the note's section 7 path 1 (audit F15): on the stream arm SEG_REPLY and the
Hello's ReplySlotCount have nothing to do with answers - they travel as Reply frames and
StreamLink::ReadReply reads the client's own store - so "deepen the pool" does not address
the arm that was measured, and should not be paid for.
Reverted (ring 8, budget 7, probes deleted); 172 + 13 green.
One-line experiment before writing the retention table: StreamLink's kReplyRing 8 -> 1024,
nothing else changed, same load frame on the same device.
ring = 8 (today) ring = 1024
readfail 8,872 0
taken 63 4,499 / 4,500
blocked (fell back) 4,435 0
by_op 2 9,071 3,443
wall 27.9 s 23.04 s
frame-sent create 4,536, records 103,097 identical per op
TWO CONCLUSIONS. First, eviction was the ONLY cause: with a buffer deep enough, every
deferred answer was read back and not one create fell through to the blocking path.
The wedge (want=310) and the useless budget (budget=4,451) were two faces of "eight
slots is not enough". Second, what brakes now is the BUDGET, not the buffer: by_op 2 =
3,443 matches the number of times the budget forced an early read (3,395), i.e. every
one of those is a real blocking wait. Its constant is 7, sized for the 8-slot pool, so
against a 1,024-deep buffer it became the only throttle and the wall only moved 27.9 ->
23.0 s.
WHAT IT MEANS FOR THE DESIGN. Once the answers are RETAINED until read, correctness no
longer depends on how long one survives in a bounded buffer, so the budget must be
sized to the RETENTION CAPACITY rather than to the pool - or be replaced by "retained
means safe". by_op 2 = records/W then puts W=2 at ~2,268 waits and the wall at ~15 s,
which is the goal's own success criterion.
Experiment reverted (ring back to 8, probes deleted); 172 + 13 + 22 + 103 green.
Steps 1 and 2 of the plan the design review set out, landed and measured on the
device. Step 1 answers the question step 2 depended on; step 2 answers the one the
whole line depended on, and the answer is that the window's shape is wrong.
STEP 1 - CAN AN ANSWER BE LOST IN DELIVERY? NO. ReplyPool now counts what the link
took (PostedReplies) and what it refused (FailedReplies), and a non-OK result is
NAMED instead of swallowed: it checked only BUFFER_TOO_SMALL and let
TRANSPORT_CLOSED vanish with no line and no counter, so "the server never sent it"
and "it was sent and the buffer overwrote it" were the same symptom with opposite
fixes. Measured: posted=4501 failed=0. The twenty-answer gap between the server's
tally and the client's was a sampling difference, not a drop - and the server's
m_answered counts ATTEMPTS (incremented before PostReply), which is why it could
never have settled it.
STEP 2 - DOES A RESIDENCY BUDGET FIX THE WINDOW? NO.
Wire_ResourceCreate now measures how many reply-owning records the client has
emitted since the oldest window entry was pushed (ClientSession::ReplyPostings, a
new clock) and takes the oldest answer back as soon as that approaches the reply
buffer's depth, instead of deferring past the point where the answer can still
exist. On the device:
P12win call=4501 cfg=2 eff=2 pending=2 taken=63 pushed=65 blocked=4435
budget=4451 readfail=8872 posts=54377
kind=frame-op frame=14 wait_replies=9078 by_op=2=9071 wall 27.87 s
budget=4451: it fires on essentially EVERY create, the window stops deferring
(pushed=65), and the drain still reads nothing back. THE REASON IS STRUCTURAL, not
a constant:
* the window only gets control WHEN A CREATE ARRIVES. Between two creates this
client emits on the order of a thousand records - at the FIRST failed read the
reply ring already spanned [1175..1504] while the head was seq 310 - and nothing
looks at the window in that gap;
* what displaces a deferred answer is not what this client emits, it is WHAT THE
SERVER ANSWERS. The server is the one behind (one answer per ~5 ms wakeup while
the client runs ahead), so the replies arriving during the gap are its LATE
ANSWERS TO THE EARLIER CREATES - they fill the 8 slots first;
* so the check fires too late by construction. Tightening it does not fix that; it
only removes the deferral entirely, which is what pushed=65 is.
WHAT THAT LEAVES. A deferral is readable only if the client KEEPS the answers it
deferred. The buffer is bounded by COUNT and the client cannot bound how many
answers the server will post between two of its creates, so hoping the buffer still
holds one is a race the client loses - which is also why the earlier two diagnoses
(reply-pool churn, then FIFO-with-no-way-out) were both describing symptoms of the
same thing. The shape that can work is retention: the reader keeps the answers for
the seqs still outstanding (a handful), drops them when read, and reports a LOSS
when a read asks for one it no longer has. That is a transport change - StreamLink's
ring becomes a retention map with a cap and a lost counter - and it is the next step.
BOTH STEPS STAY: step 1 is a net gain on its own, and step 2 is the safe direction
(it can only degrade the window to no-deferral, i.e. the pre-window shape) and its
clock is the one the retention design needs. MOBILEGL_IPC_CREATE_WINDOW stays at 1.
172/172 and 13/13 after the probes were removed. The measurements and the revised
conclusion are in notes/p12/CREATE-WINDOW-MEASURED.md section 9.
The create window could not collect its deferred answers. The previous commit
diagnosed that as the reply pool being 8 slots deep against 55,903 replies in one
frame, and left a no-reply bit as the way out. This lands that bit, measures it on
the device, and reports that the diagnosis was half wrong.
WHAT LANDED. ClientSession::EmitAndWaitTails takes a second question beside
wantReply: willReadReply. They are not the same question and the window is the case
that separates them - it emits with wantReply=false and READS the answer later, so
its records must still be answered. kRecNoReply (RingRecordFlags bit 5, which
aliases kOptional in the call-flag space; the flag-space asserts were extended and
the alias named) is stamped when willReadReply is false, and the decoder skips
PostReply while keeping every acceptance counter - "nobody reads the answer" is not
"nothing happened". The two illegal combinations are Fatal by name.
Three call sites say it: SetTextureParams for a confirmed object, the respecify
row's fire-and-forget half, and sub-data where MGPipeSubDataWantsItsReply decides.
MEASURED ON THE DEVICE (aikjs4s8cys8ba7l, rebuilt APK + client, both halves new):
server: skipped=50,001 answered=4,521 (a frame's replies: 55,903 -> 4,543)
client: Reply frames received seen=4,501, latest seq=97,181
frame-sent: identical per op, records untouched
create row: by_op 2 = 9,070 (was 4,536) - because the drain's blocking take is
now counted, so the row's number stays honest instead of reading as
"create stopped waiting" while the waiting merely moved
AND THE WINDOW STILL READ BACK NOTHING: readfail 8,951 -> 8,872, i.e. -0.9%.
THE REAL CAUSE, from the miss probe:
P12miss n=1 want=310 replySeq=1504 ring=[1175..1504]
P12miss n=9001 want=310 replySeq=102915 ring=[102866..102915]
want=310 every time. The drain is STRICTLY FIFO and a failed read has no way out -
it retries the same head for ever. Any reply buffer is bounded by design (8 pool
slots, or the 8-frame ring the stream link now keeps), so with 4,536 deferred
answers at least one is overwritten before it is read, and that one wedges the
window permanently. Cutting the churn by two orders of magnitude does not help: it
turns a certainty into a certainty. That is the constraint, not the depth.
THE FIX IS NAMED AND NOT TAKEN (the goal this work ran under stops here on purpose).
On a failed read of the head, abandon it: MGPipeNoteHandleUnpublished +
NoteCreateSuspect + pop - the DECLINE path reused for "unknown". The window then
self-heals through the blocking path and the object merely gets slower. It needs its
own gate: a failed read must let the window ADVANCE.
Gates, all green (172 + 13 + 22 + 103):
PipeWireCodecTest.TheNoReplyBitSkipsTheAnswerAndKeepsTheAcceptanceRecord - the skip,
the kept tally, the answered/skipped split, and the negative control that a record
WITHOUT the bit is still answered (a server that stopped answering everything
would otherwise pass)
RemoteClientControls.ACreateWindowDefersTheAnswersAndTheWindowOffDoesNot - now also
asserts the answers CAME BACK (answers_taken=11, outstanding=1, fallbacks=0 against
12 records). RED-ONCE PROVED: giving the window's creates the literal
"fire-and-forget => skip" predicate makes this case fail.
RingTest - the sixth and last flag alias, and the hand-written exhaustiveness list
extended (with a note that it is only as exhaustive as that list).
Server side: AnsweredRecords()/SkippedAnswers() - the number that made this
debuggable, since a wire change whose effect is invisible is undebuggable.
Client-first is safe: an old server ignores bit 5 and replies as before, so the two
cross-version directions degrade to today's behaviour - this is "both ends must
upgrade to take EFFECT", not "to avoid breaking". Hit that in practice first.
An independent review of the one design door the measurement left open (stop sending
the ~51,000 answers nobody reads, so the 8-slot pool stops churning and the create
window becomes collectable). Verdict: feasible, worth doing, and the proposal AS
WRITTEN would disable the thing it exists to enable.
THE CATCH. The windowed create is wantReply=false AND its answer is read -
WireTables.cpp's drain reads exactly those seqs. A bit keyed on "fire-and-forget"
therefore stops the server answering the creates the window is waiting for: the
window goes from "cannot be collected" to "there is nothing to collect", and the
readfail counter that is the only way to diagnose it disappears with it. It fails
silently, not loudly. The predicate has to be per-record "this answer will never be
read" - true of params / respecify-escape / sub-data, false of a windowed create -
carried as a separate input from wantReply (willReadReply), with
willReadReply && !rowCarriesReplySlot rejected rather than ignored.
VERIFIED HERE, not taken on trust (the reviewer's file:line claims, checked):
- the flag space is RingRecordFlags (Ring.h:281-285), bits 0-4 taken, BIT 5 FREE,
and the header's own table (PipeWire.inc:35-65) says "bit 5 kOptional vs (nothing)
no ring counterpart" - so a new ring bit aliases kOptional, which is exactly what
the assert at PipeWireCodec.cpp:142-145 refuses;
- the three static_asserts that will trip are real and deliberate
(PipeWireCodec.cpp:106, :142-145, RingTest.cpp:740);
- WireFingerprint mixes payload-layout, catalogue, render-state digests + OpCount,
none of which a ring bit moves - so no protocol revision bump is required, and
both cross-version directions degrade to today's behaviour. That makes this
"must upgrade both ends to take EFFECT", not "must upgrade both ends not to
BREAK" - one notch cheaper than handoff-2 section 3.2's protocol surface;
- StreamLink.cpp:564-571 is the independent progress flush (>= 64 records or
>= 1 ms) and :577 is PostReply piggybacking SendProgress on the reply - so
skipping replies coarsens progress granularity. Bounded, but unmeasured.
AND IT CORRECTED TWO OF MY OWN NUMBERS. The frame's reply count is 55,903, not
59,673: op 4 ResourceDestroy carries kNone in the catalogue (PipeWire.inc:179) and
PostReply Fatals on a row with no kReplySlot, so it never answers - I had added its
3,770 records to the total. The derived figures move with it (12.3 replies per create,
0.65 of a create of survival, 6,988 pool churns), and every copy is fixed here:
Config.h, ConfigLoader.cpp, WireTables.cpp's section comment, CONTRACT-P5E.md,
CREATE-WINDOW-MEASURED.md.
TWO MORE THINGS THE FOLLOW-UP MUST CARRY, both recorded in the note's section 7:
- LinkMetricsBeginReply counts ownsReplySlot, so once the flag lands by_op 2 falls
to ~0 - and section 1 of the note tells the reader to use by_op 2 to check whether
the window engaged. That check becomes a FALSE GREEN; the flag needs a
server-side "how many times did this op answer" counter beside it.
- the ~112,000 sendmsg/frame the flag removes are on the apply thread, so the
flag's own value is a measurable claim that nobody has measured.
172/172 and 13/13 after the comment corrections.
handoff-2 section 3.3 asks for a gate on the one scope that empties the applier's
object records while the frontend objects live on (MGPipeApplierReleaseObjectRecords,
"latent" because its only caller in the tree is MG_Test). The gate was expected green
for a CHANGED descriptor: the respecify is refused, RespecifyOnce turns the refusal
into a create plus one retry. It is not green.
MEASURED:
dropped-records probe: creates=0 applier_records=0 published=1
Nothing is emitted at all. The refusal can only reach the emitter through an answer
the row READS, and P12 made both rows answer themselves for a "confirmed" object:
the respecify is fire-and-forget (MGPipeResourceRespecifyWantsItsReply) and the
params row writes a PROVISIONAL kStatusOk (Wire_SetTextureParams). "Confirmed" is
MGPipeHandleIsPublished, which answers "did a create GO OUT" - and it stays true
after the records are dropped, which is the scope's whole definition. The only row
left that reads the real answer is create, and create is only sent when the latch is
clear - the one thing that stops being true.
So section 3.1's "clearing the latch is not enough" is the smaller half: nothing
clears the latch either, and nothing can until the applier can say "I hold nothing for
this handle" - section 3.2's third option, the reverse channel, i.e. the protocol
surface the handoff hoped to avoid paying for. That hope was priced on the trigger
being unreachable; it is still unreachable (no production caller), so the call stands,
but the cost of a production caller is now known to be the whole thing.
THE CASE IS WRITTEN AS A DEBT RATHER THAN A SPEC, and says so in its name and its
comment: it pins today's three numbers, so whoever lands the signal sees THIS case go
red and knows the repair started working. Removing the heal from RespecifyOnce would
not make it red - nothing reaches the heal - which is exactly why the assertion is on
the applier's table and the latch rather than on a count (handoff-2 section 3.3: "a
count cannot show this class of error").
Also rewritten: notes/p12/UNPUBLISH-GUARD-SIGNALS.md sections 4 and 6 now carry this
as 4.2 (sections 2 and 3 stand: neither caps generation nor FreshlyPrimed is a signal).
172/172.
The window was the handoff's remaining item: let up to N resource_create answers be
outstanding, read a provisional accept, collect the answers in order, and un-latch an
object whose late answer is DECLINED. It is a sound mechanism and the gates below pin
it - and on the device it does nothing at all, which is why the default is 1.
MEASURED, same device, same frame, same 101,421,396 B staged, same binary:
MOBILEGL_IPC_CREATE_WINDOW=4 by_op 2 = 4,536 wall 28.64 s
MOBILEGL_IPC_CREATE_WINDOW=1 by_op 2 = 4,536 wall 28.64 s
(the handoff's pre-window baseline: 31.3 s)
by_op 2 counts the creates that took the BLOCKING path (LinkMetricsBeginReply is
called with ownsReplySlot = rowCarriesReplySlot && wantReply), so it not moving means
no create deferred. A temporary counter in the row said why: pushed=24, blocked=4,476,
took=20, readfail=8,951, pending pinned at 4 - the drain READ the answers back and
failed every time.
WHY, IN THE PROTOCOL'S OWN ARITHMETIC. An answer is readable only until its pool slot
is reused, and the pool is kDefaultReplySlotCount = 8. The server answers EVERY
reply-owning record, not only the rows that wait, because it cannot know which those
are (ClientSession.cpp:1897-1900: "The server posts either way"). This frame posts
59,673 of them: create 4,536 + respecify 20,633 + params 18,056 + sub-data 12,671 +
the rest. 8 slots against 59,673 replies churns 7,459 times per frame, i.e. 13.2
replies between two creates, so an answer is overwritten after 8 replies - 0.61 of a
create. The deferral is not slow, it is UNREADABLE, and no window depth fixes that.
AND IT IS NOT NEUTRAL: with the window on, the provisional accept latches an object
the applier may have refused, and the un-latch that corrects it lives in that same
unreadable drain. A refusal would become an object the client believes is published -
the silent divergence the suspect list exists to prevent. So the machinery is kept
(one experiment away from usable: a deeper pool, or a "do not answer me" bit, both
wire changes) and the default is off.
Worktree note: the window implementation itself was uncommitted in the tree at
82ed1df9 (MOBILEGL-CS-HANDOFF-2.md section 2, "can compile, gates mostly green, never
run on a device"). This commit lands it in the state the measurement supports. The
row also gains the heal the suspect list was missing - a BLOCKING create that lands
takes its object back out of it, or kCreateSuspectMax recoveries would retire the
window for the rest of the process - and the clamp that ties the environment's value
to the array, with a static_assert tying the array to half the reply pool so "someone
raises kCreateWindowMax to 8 and steps on the pool" is a compile error.
Gates (RemoteClientControls.inc), all green, 171/171 RemoteClientTest:
ACreateWindowDefersTheAnswersAndTheWindowOffDoesNot - window on = 12 records 0 waits;
the SAME CHILD turns the knob to 1 and it is 12 records 12 waits (the negative
control); pending <= effective after every create; 99 clamps to the array; the
array is <= half the pool
ARefusedCreateUnlatchesItsObjectAndItsNextCreateBlocks - a real refusal (the
server's consumed mask narrowed) clears the latch, +1 suspect, the object's next
create BLOCKS (2 records 2 waits), and restoring the consumer HEALS it (latch
back, suspect -1)
Docs: CONTRACT-P5E.md section 2.5 rewritten (it still said all three rows keep
kWaitReply), plus
docs/Disaggregated/notes/p12/CREATE-WINDOW-MEASURED.md (the numbers, the arithmetic,
the picture evidence: both arms SSIM 0.977) and
docs/Disaggregated/notes/p12/UNPUBLISH-GUARD-SIGNALS.md (handoff section 3: neither
caps generation nor FreshlyPrimed is a signal for "the applier dropped its records",
and the residual gap is one line - the dedup at TextureEmit.h:838 does not read the
latch, so an UNCHANGED descriptor after a drop produces no record and therefore no
refusal to self-heal from).
- The predicate is one function again: MGPipeResourceRespecifyWantsItsReply (PipeRoute.h) is read
by the wire emitter, so the two halves of this row cannot disagree about whether an answer is
owed. The buffer half never owes one (both call sites discard the Bool, PipeFill.cpp:1105/:1142);
the texture half owes one exactly until MGPipeHandleIsPublished, and that latch is set only by an
ACCEPTED create - which EmitResourceRespecify already sends, blocking, when it is clear
(TextureEmit.h:869-879). So the object pays once, at first use, and every record after it is
fire-and-forget.
- MEASURED on the device, same frame, same 101,421,396 B staged, stock power policy: waits
25,154 -> 4,543 and wall 130.9 s -> 31.3 s. The op split is the proof of what changed and what
did not: waits by_op 3=20611 -> absent (only 2=4536, 5=6, 9=1 remain), while the RECORDS sent
are unchanged - frame-sent still shows 3=20633 and 103,094 records total. The round trips went
away; the work did not.
- It also lands on an independent prediction: an audit run beside this work (mgl-device/scratch/
scheme-review.md) computed 4,543 remaining waits and a 24-37 s credible range (midpoint ~30 s)
from the archived logs, a host-inproc round trip of 35.4 us and a measured 43.1 MiB/s link.
Measured: 31.3 s, at the midpoint.
- Cumulative on that frame since this work started: 43,232 waits / 221.5 s -> 4,543 / 31.3 s (7.1x).
What is left is the create row (4,536 waits, ~23 s) - the confirmation itself, not waste - plus
hard floors of ~13.4 s client CPU and ~2.2 s of transfer. Going below that means batching the
create row, which keeps real answers.
- TWO DEBTS, STATED: (1) the invalidation guard for the one silent-loss case is NOT wired; the
audit confirmed independently that no production path can drop a texture record today, and if
one appears the notice it needs is protocol-surface (a tenth reverse callback). (2) No SSIM or
visual check has been taken since these rows stopped asking - frame-sent agreement is strong
evidence but it is not a picture.
- Two stale controls in older cases were fixed rather than left: the item-B gate now names the
CREATE row (its old total-based control only moved because a respecify was still waiting), and
the by-op measurement asserts records-sent for the rows that deliberately stopped waiting.
RemoteClientTest 169/169; red-once for the predicate is forcing it back to "always wait".
- The two halves of this row are not the same row. PipeFill.cpp:1105 and :1142 both call
MGPipeRouteResourceRespecify(desc, nullptr) and drop the Bool, so for a buffer the wait bought a
value that was thrown away. It goes fire-and-forget with the provisional-accept shape
resource_subdata has had since item B.
- The texture half keeps its answer ON PURPOSE: there the Bool is the self-heal trigger AND the
gate on the acceptance mirror, and TextureEmit.h:885-888 (ID-18 M3) says advancing that mirror on
an unconfirmed answer is how the next identical call gets suppressed "against a record that was
never stored". That half needs the provisional/really-accepted distinction plumbed through the
reply mailbox before it can stop waiting; it is not a flag flip.
- MEASURED, same frame, same 101,421,396 B staged: waits 25,176 -> 25,154 and by_op 3=20633 ->
3=20611. So the buffer half is worth 22 waits (0.1%) and the remaining 20,611 are texture
respecifies - which is what says the next step is the mirror design, not another flag. Wall
134.4 s -> 130.9 s.
- The ERROR-is-not-a-decline case moves to the texture half: a row that reads no answer cannot fold
ERROR into a decision, so the rule it exists for belongs to the half that still waits (the
transport fault is caught at the next row that does). New case
ABufferRespecifyTakesNoReplyWhileATextureOneStillDoes pins both directions; red-once with the
branch removed is exit 155. RemoteClientTest 169/169.
- 2.9 established that a record's ~5 ms is mostly the DEVICE waking out of a deep idle state.
That points at a knob that is already wired: how long the server's apply thread spins before
parking. Raised from 50 us to 2000 us (via the Activity's existing `env` extra, which
ServerEnvironment.apply parses as KEY=VALUE;KEY=VALUE and which does not scrub
MOBILEGL_IPC_SPIN_US), the same 43,232-record frame goes 221.5 s -> 182.8 s (-17%), with
rtt_mean 5,040 -> 4,145 us. Pinning the device and disabling its deep idle states is worth
166.5 s (-25%) but needs root; the spin needs neither root nor a rebuild.
- The server log proves the knob landed (`mgl-srv-apply started ... spin 2000 us`) rather than
leaving the reader to trust an env var, and the note carries the exact am start line.
- So the load stall now has three ordered levers with numbers behind them: batch the waits
(client-only, not built), keep the server hot (this commit, usable today, trades device CPU for
wall clock on a frame that is 94% waiting), and pin the device for measurement discipline.
- Three one-variable experiments on the same device, same workload (the 43k-record frame): a 40x
larger client spin moves the per-wait mean by ~10% (so it is not the client park/wake); the
same frames over adb forward - a link whose raw RTT is several times lower - by ~25-30% (so it
is not the link, which is what the handoff saw in 3.6 and could not account for); and pinning
the device (governor performance on all eight cores, cpuoff_l / clusteroff_l / mcusysoff /
s2idle disabled) by 26% per wait and 25% of the whole frame: 221.5 s -> 166.5 s for 43,218
records and 100.5 MB staged, against 43,232 and 101.4 MB in the baseline.
- The mechanism: the server's apply thread parks between records, the SoC drops into a deep idle
state, and every record pays the exit latency - milliseconds, once per record, which is the
entire shape of the frame. It also matches the server profile the handoff took (65% socket
syscalls, ~15% doorbell spin: waiting and waking, not working).
- Stated where it matters: the handoff's 249 s was taken on an UNPINNED device, so its absolute
numbers carry this idle tax - the project has a pinning discipline for exactly this reason.
- The lever list is now ordered: batch the waits (K <= 8 by the reply-slot pool, client-only),
keep the server hot between records (its own spin budget, which the APK does not wire yet), and
measure split numbers on a pinned device. The device was restored to stock afterwards.
- What was open: the handoff's 4.3 called ResourceCreate and SetTextureParams "a second problem of
the same kind, needing its own analysis" and left it there; 5.4 said the per-op split of the
55,428 waits was never quantified. The measurement committed beside this answers both.
- The ruling (CONTRACT-P5E.md 2.5): ResourceCreate, ResourceRespecify and SetTextureParams stay
kWaitReply, because each answer carries a fact about the SERVER's record table that the client's
own tracker provably cannot know. RespecifyOnce says it outright - "the applier's REFUSAL is the
only signal that says 'I hold nothing for this handle'", which is the scope
MGPipeApplierReleaseObjectRecords leaves for a served context whose frontend objects live on;
EmitTextureParams uses its own refusal as the same self-heal trigger; PublishCreate may not take
the publication latch on a refused create (D-I1/c0b) or the death path emits a resource_destroy
for a record that does not exist. Unlike the upload half these are not "the emission gate already
answered it" rows, so removing their waits removes the signal.
- The numbers, both shapes a stitcher produces: an atlas stitch goes 64 -> 0 reply waits; a texture
per sprite goes 384 -> 320 with ResourceSubData now zero and the remainder all three of those
rows. So item B hits exactly the shape it was aimed at, and what is left is per-resource /
per-call rather than per-chunk.
- notes/p12/DEVICELOST-AND-UPLOAD-WAITS.md gains 2.4 with the table and the ruling, and its "second
problem of the same kind" entry is closed rather than carried; MEASUREMENTS.md records the two
shapes beside the 1 -> 0 single-upload number.
- CONTRACT-P5E.md 2.5 said "the TEXTURE half keeps its wait because DrainTextureSubData clears the
level's dirty flag on the accepted reply (D-D5; trailing)". The trailing is paid, so the clause
now states the three facts that make that answer DERIVABLE on the wire arm (the emission gate IS
the consumer question the applier's own acceptance belt asks; what remains is a loud bug class;
a client-cancelled record still answers "not accepted"), where the wait moves to (SEG_STAGE),
that MGPipeSubDataWantsItsReply stays the one place that decides, and the gate with its red-once.
ResourceCreate and SetTextureParams are named as analyzed separately rather than swept in.
- notes/p12/DEVICELOST-AND-UPLOAD-WAITS.md: 2.3 now carries the G1 A/B this machine ran - .text
13872002 bytes on both sides, 31746 symbols, 0 added / 0 removed, no per-symbol difference -
instead of deferring it to CI, and 3 trades its "G1 not run" item for the device tooling
(mgl-device/: launch-mc.py, gl-aliases.sh, start-phone-server.sh) the next round needs.
- notes/p12/DEVICELOST-AND-UPLOAD-WAITS.md: the two items, their root causes with file:line anchors,
the five cases that pin them, the red each one produced when the fix was taken back out, and the
four things this round did NOT do (device end-to-end, local G1, the ResourceCreate /
SetTextureParams pair, and the sandbox's missing /dev/dri - which reds the two EventForfeitPeer
lanes for reasons that have nothing to do with the change).
- p12/README.md: a line in the summary and the new file in this directory's table.
- MEASUREMENTS.md: the P12 conclusion and row now carry the number the gate pins - one reply wait
per pure texture upload becomes zero - with the device wall clock named as the measurement still
owed rather than left implied.
- README, ARCHITECTURE, ROADMAP, MEASUREMENTS and CURRENT_STAGE_PROGRESS are rewritten as indexes
(84 KB -> 15 KB): what the project is, where it stands, and where each detail lives. ROADMAP is one
plain sentence per phase; MEASUREMENTS is the five conclusions that matter plus one line per phase;
CURRENT_STAGE_PROGRESS is the P12 goal, a checklist and the next steps.
- ARCHITECTURE's body moves unchanged into ten design/ files, keeping §1-§17 and appendices A/B, so
`ARCHITECTURE.md §N` references in code resolve through the table in ARCHITECTURE.md.
- guide/ holds build-and-run, code map, glossary and the engineering discipline (from README and
ROADMAP), and the device pinning record that used to sit in devices/.
- The open-debt table moves to notes/DEBTS.md; the detailed current-stage tables (with decision IDs,
the open line items and evidence locations) move into notes/p12/README.md. notes/README.md maps
every moved path and section.
- The documentation citation lint in test.yml also covers design/ and guide/, which describe the
current tree as the top-level files did.
- ROADMAP, MEASUREMENTS and ARCHITECTURE are rewritten as summaries (333 KB of index text -> 84 KB).
ROADMAP's phase table is one line per phase plus a link; MEASUREMENTS keeps headline numbers under
its old §1-§13 numbering (plus §14-§16 for P6.5, P7 and P12); ARCHITECTURE keeps every decided rule
under the same §1-§17 / appendix numbering, so the `ARCHITECTURE.md §N` references in code still
land, and §17 becomes an index of the phase landing shapes.
- CURRENT_STAGE_PROGRESS now describes the current stage (the P12 on-screen-server subset) and absorbs
CURRENT_STAGE_STATUS, which is removed. The P7 status tables, the P6 closing snapshot and the P6.5
first-wave snapshot move to notes/p7, notes/p6 and notes/p65.
- Each phase gets notes/<phase>/README.md: a short summary, then the verbatim material that left
the index documents (its ROADMAP row, its MEASUREMENTS section with subsection numbers kept, its
ARCHITECTURE §17 part, milestones) and a file index. P8-P11 and P13 get their planned scope;
the full open-question list moves to notes/OPEN-QUESTIONS.md.
- The phase reports that sat at the top level move into their phase folders with unchanged names:
P5D-INPROC-PERFORMANCE, P5E-RUNAHEAD, P5F-WIRE-COMPLETENESS, P6-SPAWN-PLAN, P6-CONTRACT-DRAFT,
P6-ENDSTATE-REVIEW. Their relative links, CONTRACT-P6.md's links to them and one tool docstring
are updated; notes/README.md maps every old path and section number to its new place.
- 71 files under notes/recovered/ that were byte-identical copies of phase-folder files are removed.
- Corrections made while condensing: the server process reads Transport as Spawn (CONTRACT-P6 §3.1
amended the old "child forces monolith" rule); both backends now publish kCapRunAheadApply;
MOBILEGL_IPC_RESPAWN is a named refusal and MOBILEGL_IPC_IDLE_EXIT_S is not parsed; the Redmi
pin row is in tools/device_bench/pin_device.sh.
check_doc_citations over docs/Disaggregated/*.md: 0 problems. No relative link is newly broken.
- unit 2605/2605, integration-spawn/tcp and the three Magma two-process lanes green, hygiene 12/12, census 79 / ratchet 171 unchanged, revision 3 pinned.
- Major 1 red without its fix ("Detach answered TimedOut in 3000 ms after 200 of 200 streamed records"), major 2 red without its fix (all four expectations), both green restored.
- SplitLogPaths / spawn_lane_parity / integration-gpu reds are registration artifacts of this container's CMake 3.28 (lane environment missing) and 4.4 (lane environment flattened into one variable; gate-review-d04ac7d1-cmake4.log); the four PersistentCoherentMapScenario and three F1WireScenario cases behave identically at cb0404f2 and at this head when the binary is run directly with the same environment. No CMake file changed since cb0404f2.