mirror of
https://github.com/MobileGL-Dev/MobileGL
synced 2026-11-07 01:53:24 +09:00
[Perf] (P12): the create window lands as the default - declared-answer retention replaces the ring, and the load frame goes 28.66 s -> 16.52 s (11.02 s at window 4)
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.
This commit is contained in:
+31
-16
@@ -549,22 +549,37 @@ namespace MobileGL::MG_Config {
|
||||
// restores the per-record barrier of R-1.
|
||||
Uint32 BatchWaits = 1;
|
||||
// MOBILEGL_IPC_CREATE_WINDOW: how many RESOURCE_CREATE answers may be outstanding before the
|
||||
// client waits for one. 1 = every create waits, which is the shape R-5 shipped with AND THE
|
||||
// DEFAULT, because the deferral was measured on the device and does not work: the reply pool
|
||||
// is 8 slots and one load frame posts 55,903 replies (create 4,536 + respecify 20,633 +
|
||||
// params 18,056 + sub-data 12,671 + 7), so an answer is overwritten after 8 replies -
|
||||
// 0.65 of a create - and the window's drain can never read one back. All 4,536 creates took
|
||||
// the blocking path at 4 and at 1 alike (28.64 s both ways). Worse than inert: the
|
||||
// provisional accept latches an object the applier may have refused, and the un-latch that
|
||||
// corrects it lives in that same unreadable drain. WireTables.cpp's section comment has the
|
||||
// counters and the arithmetic, and the gates in RemoteClientControls.inc still pin the
|
||||
// mechanism, which is sound and simply unusable against this much other reply traffic.
|
||||
// N > 1 is reachable for the experiment that would make it usable (a deeper pool, or a
|
||||
// per-record "this answer will never be read" bit). NOTE THE PREDICATE: NOT
|
||||
// "fire-and-forget" - the window's own creates ARE fire-and-forget and the drain reads
|
||||
// them, so a flag keyed on wantReply would kill the very thing it is meant to enable
|
||||
// (docs/Disaggregated/notes/p12/CREATE-WINDOW-MEASURED.md section 7).
|
||||
Uint32 CreateWindow = 1;
|
||||
// client waits for one. 1 = every create waits (the shape R-5 shipped with); N > 1 = up to N
|
||||
// creates go out fire-and-forget, their caller reads a PROVISIONAL accept, and the oldest
|
||||
// answer is taken when the window fills.
|
||||
//
|
||||
// DEFAULT 2, AND IT TOOK THREE THINGS TO GET HERE. The deferral was inert for two
|
||||
// measured reasons and one structural one, in the order they were found:
|
||||
// 1. the reply buffer was a ring of the last 8 answers, and between two creates this
|
||||
// client emits on the order of a thousand records - so the answer was always gone
|
||||
// (`want=310` on every miss while the ring spanned [1175..1504]);
|
||||
// 2. a residency budget that tried to take answers back early could not help, because
|
||||
// what displaces an answer is what the SERVER sends, not what the client emits;
|
||||
// 3. and so the fix was RETENTION - a link keeps a declared answer until its reader
|
||||
// takes it (Transport/ILink.h's RetainsReplies, StreamLink's declared-answer store).
|
||||
//
|
||||
// MEASURED ON THE DEVICE, load frame, both ends rebuilt (create 4,536, records 103,097,
|
||||
// frame-sent identical per op throughout):
|
||||
// window 1 4,536 blocking takes 28.66 s (the shape R-5 shipped)
|
||||
// window 2 2,500 17.27 s
|
||||
// window 4 1,644 11.02 s
|
||||
// and the round trip does NOT grow as the window deepens (5,285 / 4,082 us against 5,672
|
||||
// at 1), which is the measurement that says the ~5 ms is a WAKEUP the server batches away
|
||||
// rather than its apply throughput - so amortising four creates per wakeup is a real 2.6x.
|
||||
//
|
||||
// 4 IS REACHABLE AND BETTER (11.02 s against 17.27 s) and is NOT the default only because
|
||||
// the wider window holds more answers at once; raise it after a run that reads `by_op 2`
|
||||
// and the wall clock rather than the config. NOTE THE PREDICATE the whole mechanism rests
|
||||
// on: an answer is retained because the wire DECLARED it will be read - NOT because its
|
||||
// caller was fire-and-forget. The window's own creates are fire-and-forget AND read, so a
|
||||
// flag keyed on wantReply would disable the very mechanism it exists to enable
|
||||
// (docs/Disaggregated/notes/p12/CREATE-WINDOW-MEASURED.md sections 7-11).
|
||||
Uint32 CreateWindow = 2;
|
||||
// MOBILEGL_IPC_ADOPT_TIER: 2 = emulate (client keeps the shadow and pushes), which
|
||||
// is the only tier P5 implements and the reason persistent-map-push can be non-zero
|
||||
// at all (R-6). 0 and 1 parse and are Fatal at use with "P11"; they exist now so the
|
||||
|
||||
@@ -412,11 +412,12 @@ namespace MobileGL::MG_ConfigLoader {
|
||||
// xxHash64 changed since the last push are shipped.
|
||||
ipc.PersistentHashSuppress = QueryEnvUint32("MOBILEGL_IPC_PERSISTENT_HASH_SUPPRESS", 1, 0, 1);
|
||||
ipc.BatchWaits = QueryEnvUint32("MOBILEGL_IPC_BATCH_WAITS", 1, 0, 1);
|
||||
// The create window. DEFAULT 1 - OFF - because the deferral was measured on the device and
|
||||
// cannot be collected (Config.h has the numbers, WireTables.cpp the arithmetic: 55,903
|
||||
// replies per frame into 8 pool slots). Capped at 4 rather than the pool's 8 for the reason
|
||||
// the mechanism is sized that way at all.
|
||||
ipc.CreateWindow = QueryEnvUint32("MOBILEGL_IPC_CREATE_WINDOW", 1, 1, 4);
|
||||
// The create window. DEFAULT 2, and it works because a link that declares RetainsReplies
|
||||
// keeps a declared answer until its reader takes it - Config.h carries the three reasons it
|
||||
// was inert before and the device numbers (28.66 s at 1, 17.27 s at 2, 11.02 s at 4).
|
||||
// Capped at 4: the retained set is bounded by the window's own depth, and 4 is what the
|
||||
// declared-answer store's capacity is written against.
|
||||
ipc.CreateWindow = QueryEnvUint32("MOBILEGL_IPC_CREATE_WINDOW", 2, 1, 4);
|
||||
// The verify harness compares the pushed block against the applier per verb; a
|
||||
// batched queue lets the comparer read a supplied field mid-apply, which is a
|
||||
// torn read rather than a divergence. The batch is therefore off whenever the
|
||||
|
||||
@@ -1898,10 +1898,17 @@ namespace MobileGL::MG_Remote::Client {
|
||||
// creates or parameter sets, and those three have different fixes.
|
||||
const auto replyMetricStart =
|
||||
Transport::LinkMetricsBeginReply(ownsReplySlot, static_cast<std::uint32_t>(op));
|
||||
// P12: the residency budget's clock. Placed HERE, where the record is certainly
|
||||
// published, rather than beside rowCarriesReplySlot: a call that declined before
|
||||
// encoding emits nothing and must not advance it.
|
||||
// P12: the residency clock AND the declaration, both placed HERE - after EncodeRecord has
|
||||
// an ordinal and BEFORE the publish, which is the only window in which the declaration is
|
||||
// guaranteed to precede its own answer. A call that declined before encoding emits nothing
|
||||
// and must not advance either.
|
||||
if (rowCarriesReplySlot) ++m_replyPostings;
|
||||
// THE DECLARATION THAT LETS A LINK RETAIN THIS ANSWER (P12). willReadReply, not wantReply:
|
||||
// the create window emits without waiting and reads afterwards, so its records must be
|
||||
// declared even though this call does not read them. Links that cannot retain ignore it,
|
||||
// and the window does not defer on them (see ClientSession::LinkRetainsReplies).
|
||||
if (rowCarriesReplySlot && willReadReply && m_link != nullptr)
|
||||
m_link->DeclareReplyRead(seq);
|
||||
m_producer.PublishAndNotify(seq);
|
||||
if (op == MG_Pipe::MGPWireOp::Present) {
|
||||
// A credit is permission to run ahead, not permission to retain the
|
||||
@@ -2464,6 +2471,10 @@ namespace MobileGL::MG_Remote::Client {
|
||||
|
||||
Uint64 ClientSession::ReplyPostings() const { return m_replyPostings; }
|
||||
|
||||
Bool ClientSession::LinkRetainsReplies() const {
|
||||
return m_link != nullptr && m_link->Capabilities().RetainsReplies;
|
||||
}
|
||||
|
||||
Bool ClientSession::ReadReply(Uint64 seq, void* outBytes, Uint64 outCapacity, Int32* outStatus,
|
||||
Uint64* outSize) {
|
||||
const void* bytes = nullptr; Uint64 size = 0; Int32 status = 0;
|
||||
|
||||
@@ -392,6 +392,13 @@ namespace MobileGL::MG_Remote::Client {
|
||||
// deployment where it matters most.
|
||||
Uint64 ReplyPostings() const;
|
||||
|
||||
// P12: DOES THIS LINK KEEP AN ANSWER UNTIL ITS READER TAKES IT? The create window defers
|
||||
// answers across an unbounded amount of other traffic, which only works where the answer
|
||||
// is still there when the drain comes back - measured: a bounded buffer of the last N
|
||||
// answers never is. On a link that answers false the window must not defer at all, so
|
||||
// this is what CreateWindowEffective() is gated on.
|
||||
Bool LinkRetainsReplies() const;
|
||||
|
||||
// The reply slot for `seq`, addressed seq % slots with the seq stamped back into the
|
||||
// header for self-check (R-3). `outStatus` is 0 OK / 1 DECLINED / 2 ERROR, and
|
||||
// DECLINED IS A REAL ANSWER - MapPersistent's nullptr and the four Bool acceptances.
|
||||
|
||||
@@ -416,10 +416,7 @@ namespace MobileGL::MG_Remote::Client {
|
||||
Uint64 Seq = 0;
|
||||
MG_Pipe::MGPipeKind Kind = MG_Pipe::MGPipeKind::None;
|
||||
MG_Pipe::MGPipeHandle Handle{};
|
||||
// P12: ClientSession::ReplyPostings() at the moment this entry was pushed. The
|
||||
// difference between it and the live count is how many answers could have displaced
|
||||
// this one - see the residency budget below.
|
||||
Uint64 ReplyStamp = 0;
|
||||
|
||||
};
|
||||
PendingCreate g_createWindow[kCreateWindowMax];
|
||||
Uint32 g_createWindowHead = 0;
|
||||
@@ -432,7 +429,6 @@ namespace MobileGL::MG_Remote::Client {
|
||||
// answers from existing at all - the second is what a no-reply bit keyed on wantReply
|
||||
// would produce, and it would leave every other reading green.
|
||||
Uint64 g_createWindowTaken = 0;
|
||||
Uint64 g_createWindowBudgetStops = 0;
|
||||
|
||||
// A HANDLE WHOSE CREATE CAME BACK DECLINED IS NEVER WINDOWED AGAIN. Without this the window
|
||||
// would UNDO the repair it exists to keep: every later use of the object sends another create,
|
||||
@@ -507,7 +503,21 @@ namespace MobileGL::MG_Remote::Client {
|
||||
// match yet (or when the answer does not fit the scratch), and treating that as
|
||||
// ERROR would invent a transport fault out of a reader that arrived early. The
|
||||
// entry stays in the window and is taken at the next drain.
|
||||
if (!session.ReadReply(head.Seq, nullptr, 0, &status, &size)) return;
|
||||
if (!session.ReadReply(head.Seq, nullptr, 0, &status, &size)) {
|
||||
// REACHED BUT UNREADABLE IS AN INVARIANT, NOT A RETRY (P12). The link declared
|
||||
// it retains this answer, and on the stream arm the reply frame strictly
|
||||
// precedes the progress frame that published appliedSeq - so by the time the
|
||||
// wait above returns, the answer is in hand or something is broken. Retrying
|
||||
// (what this used to do) wedges the window for the rest of the process and
|
||||
// leaves every deferred object latched on a provisional accept; recovering by
|
||||
// re-latching would lose texels. So it names itself instead.
|
||||
SessionFail(MGFatalFamily::ReplyMissing,
|
||||
"MGPipe: Fatal{ReplyMissing, \"create window\"} - seq %llu was "
|
||||
"declared and APPLIED, and its answer is no longer in the link's "
|
||||
"store; the window cannot advance past it and nothing recovers a "
|
||||
"lost answer",
|
||||
static_cast<unsigned long long>(head.Seq));
|
||||
}
|
||||
// ERROR IS NOT A DECLINE (M4 / R-5), and a deferred answer does not change that:
|
||||
// the same named Fatal the blocking take raises.
|
||||
if (status == Wire::ReplySink::kStatusError) {
|
||||
@@ -553,61 +563,40 @@ namespace MobileGL::MG_Remote::Client {
|
||||
if (status == 1) ++g_declined;
|
||||
if (status == 0) ClearCreateSuspect(payload->Resource);
|
||||
}
|
||||
// ---- P12: THE RESIDENCY BUDGET --------------------------------------------------------
|
||||
// ---- P12: WHY THERE IS NO RESIDENCY BUDGET HERE ANY MORE -------------------------------
|
||||
//
|
||||
// A deferred answer is readable only while the reply buffer has not reused its slot, and
|
||||
// that buffer is bounded by construction: kDefaultReplySlotCount slots in the shared pool,
|
||||
// and the stream link's ring is that same depth (StreamLink.cpp's kReplyRing, whose
|
||||
// comment says why). So the window may defer only an answer it can still read back, and
|
||||
// what it measures that against is HOW MANY REPLY-OWNING RECORDS IT HAS EMITTED since the
|
||||
// oldest entry was pushed - an upper bound on the answers that could have displaced it.
|
||||
// There was one, and it was measured: it counted the reply-owning records this client had
|
||||
// emitted since the oldest window entry was pushed and took that entry back when the count
|
||||
// approached the reply buffer's depth. On the device it fired on essentially EVERY create
|
||||
// (budget=4,451 of 4,501 calls) and the drain still read nothing back (taken=63,
|
||||
// readfail=8,872, by_op 2 = 9,071, wall 27.9 s). The clock was wrong, structurally:
|
||||
//
|
||||
// ONE LESS THAN THE BUFFER, because the eighth newer answer is the one that displaces it.
|
||||
// * the window only gets control WHEN A CREATE ARRIVES, and 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;
|
||||
// * what displaces a deferred answer is not what this client emits, it is WHAT THE
|
||||
// SERVER ANSWERS, and the server is behind: its late answers to the earlier creates
|
||||
// are what fills a bounded buffer first;
|
||||
// * so the check fired too late by construction. Tightening it only removed the
|
||||
// deferral entirely, which is what pushed=65 was.
|
||||
//
|
||||
// THIS IS PREVENTION, NOT REPAIR, AND THAT IS THE WHOLE POINT. The alternative - read the
|
||||
// head, and when the read fails call the object unknown - UN-LATCHES a create that very
|
||||
// probably SUCCEEDED, and the server's create starts that object's record over
|
||||
// (PipeApply.cpp:2033: `*record = MGPipeResourceRecord{}`), PendingUploads included
|
||||
// (PipeApply.h:332); the client cleared its dirty flags at EMISSION (PipeApply.h:298-304),
|
||||
// so nothing re-sends those texels. Silent content loss for a healthy object. An answer
|
||||
// read inside its residency never has to be given up on at all.
|
||||
// ---- MEASURED: NECESSARY BUT NOT SUFFICIENT, AND THE CLOCK IS WHY ---------------------
|
||||
//
|
||||
// On the device this fires on essentially every create (budget=4,451 of 4,501 calls) and
|
||||
// the drain still reads nothing back (taken=63, readfail=8,872, by_op 2 = 9,071, wall
|
||||
// 27.9 s). The clock is wrong, and the reason is structural rather than a bad constant:
|
||||
//
|
||||
// * THE WINDOW ONLY GETS CONTROL WHEN A CREATE ARRIVES. Between two creates this client
|
||||
// emits on the order of a thousand records (measured: at the FIRST failed read the
|
||||
// reply ring already spanned [1175..1504] while the head was seq 310), and nothing
|
||||
// looks at the window during that gap.
|
||||
// * WHAT DISPLACES A DEFERRED ANSWER IS NOT WHAT THIS CLIENT EMITS, IT IS WHAT THE
|
||||
// SERVER ANSWERS. The server is 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, and they fill the 8-slot buffer before the client comes back.
|
||||
// * SO THIS CHECK FIRES TOO LATE BY CONSTRUCTION: it is consulted at the next create,
|
||||
// and by then the answer is already gone. Tightening the constant does not fix that;
|
||||
// it only removes the deferral entirely, which is what `pushed=65` is.
|
||||
//
|
||||
// A DEFERRAL IS THEREFORE READABLE ONLY IF THE CLIENT KEEPS THE ANSWERS IT DEFERRED. The
|
||||
// reply buffer is bounded by COUNT (8) 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 not
|
||||
// a policy - it is a race the client loses. The shape that can work is RETENTION: the
|
||||
// reader keeps the answers for the seqs still outstanding (a handful), drops them when
|
||||
// they are 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). Kept as-is because it is the safe direction - it degrades to the pre-window
|
||||
// shape - and because the clock it needs is the one ReplyPostings() already provides.
|
||||
constexpr Uint64 kCreateReplyResidency = Transport::kDefaultReplySlotCount - 1;
|
||||
Bool CreateWindowBudgetSpent(ClientSession& session) {
|
||||
if (g_createWindowCount == 0) return false;
|
||||
return session.ReplyPostings() - g_createWindow[g_createWindowHead].ReplyStamp >=
|
||||
kCreateReplyResidency;
|
||||
}
|
||||
// Retention replaced it, and retention is where the bound now lives: a link that declares
|
||||
// RetainsReplies keeps an answer until its reader takes it (StreamLink's declared-answer
|
||||
// store, whose capacity is tied to the window's own depth), and the window does not defer
|
||||
// at all on a link that does not. That removes the only thing the budget was for.
|
||||
// ReplyPostings() stays as a measurement.
|
||||
void Wire_ResourceCreate(const MG_Pipe::MGPResourceDesc* payload, MG_Pipe::MGPReplySlot* reply) {
|
||||
if (RunsAsTheServerRole()) { MG_Pipe::MGPipeMonolithScreen().ResourceCreate(payload, reply); return; }
|
||||
ClientSession& session = RequireSession("ResourceCreate");
|
||||
const Uint32 window = CreateWindowEffective();
|
||||
// THE WINDOW MAY ONLY DEFER WHERE THE LINK RETAINS ANSWERS (P12). On one that does
|
||||
// not - the shared pool, whose slot is seq % slotCount and is reused - the answer the
|
||||
// drain comes back for may already be gone, and there is NO recovery: retrying wedges
|
||||
// the row for the rest of the process, and re-latching the object by sending another
|
||||
// create wipes the server's record along with its PendingUploads (PipeApply.cpp:2033,
|
||||
// PipeApply.h:332) while the client cleared its dirty flags at emission
|
||||
// (PipeApply.h:298-304), so nobody re-sends those texels. So it takes the blocking
|
||||
// path instead, which is the pre-window shape exactly.
|
||||
const Uint32 window = session.LinkRetainsReplies() ? CreateWindowEffective() : 1u;
|
||||
// THE WINDOW OFF RESTORES THE OLD SHAPE EXACTLY, and is the negative control for it.
|
||||
// THE WINDOW OFF, THE SUSPECT LIST FULL, OR AN OBJECT THAT HAS ALREADY BEEN REFUSED:
|
||||
// all four take the blocking branch, which is the pre-window shape - and on the device
|
||||
@@ -615,6 +604,13 @@ namespace MobileGL::MG_Remote::Client {
|
||||
// collected. See the section comment for the measurement and the arithmetic.
|
||||
if (window <= 1 || g_createSuspects.size() >= kCreateSuspectMax ||
|
||||
CreateIsSuspect(payload->Resource)) {
|
||||
// THE WINDOW RETIRES HERE, AND IT MAY BE HOLDING ANSWERS (P12). This branch is
|
||||
// taken when the window is off, when the suspect list is full, or for a refused
|
||||
// object - and in the last two cases entries already emitted are still in flight.
|
||||
// Without this drain they arrive, are retained, and are never read, WHILE their
|
||||
// objects stay latched on a provisional accept: the silent divergence the
|
||||
// suspect list exists to prevent. One blocking pass settles them.
|
||||
if (g_createWindowCount != 0) DrainCreateWindow(session, /*blockForOldest=*/true);
|
||||
EmitCreateBlocking(session, payload, reply);
|
||||
return;
|
||||
}
|
||||
@@ -622,11 +618,7 @@ namespace MobileGL::MG_Remote::Client {
|
||||
// The poll is not politeness: it is what keeps a burst shorter than the window from
|
||||
// leaving its answers unread until the next burst.
|
||||
DrainCreateWindow(session, /*blockForOldest=*/false);
|
||||
// A SPENT BUDGET IS TREATED EXACTLY LIKE A FULL WINDOW: take the oldest answer back
|
||||
// NOW, while it still exists, instead of deferring another one past its residency.
|
||||
const Bool budgetSpent = CreateWindowBudgetSpent(session);
|
||||
if (g_createWindowCount >= window || budgetSpent) {
|
||||
if (budgetSpent) ++g_createWindowBudgetStops;
|
||||
if (g_createWindowCount >= window) {
|
||||
DrainCreateWindow(session, /*blockForOldest=*/true);
|
||||
// IF IT IS STILL FULL the oldest answer is not readable yet, and the window is a
|
||||
// fixed array: pushing would silently OVERWRITE a confirmation. This record takes
|
||||
@@ -657,8 +649,7 @@ namespace MobileGL::MG_Remote::Client {
|
||||
return;
|
||||
}
|
||||
g_createWindow[(g_createWindowHead + g_createWindowCount) % kCreateWindowMax] =
|
||||
PendingCreate{seq, CreateRecordKind(*payload), payload->Resource,
|
||||
session.ReplyPostings()};
|
||||
PendingCreate{seq, CreateRecordKind(*payload), payload->Resource};
|
||||
++g_createWindowCount;
|
||||
reply->Id = seq;
|
||||
MG_Pipe::MGPipePostReply(*reply, Wire::ReplySink::kStatusOk, 1u);
|
||||
@@ -1277,11 +1268,10 @@ namespace MobileGL::MG_Remote::Client {
|
||||
// the ones still outstanding. A create the window accepted and then LOST would break that
|
||||
// sum, which is the failure a provisional accept makes possible and a count cannot show.
|
||||
Uint64 ClientCreateWindowTaken() { return g_createWindowTaken; }
|
||||
// How many times the residency budget, and not a full window, forced the blocking read. A
|
||||
// non-zero value is the budget DOING its job (an answer was about to outlive its slot); a
|
||||
// value near the record count means the window is deferring almost nothing, which is the
|
||||
// honest way to say "the budget is too tight for this workload".
|
||||
Uint64 ClientCreateWindowBudgetStops() { return g_createWindowBudgetStops; }
|
||||
// P12: RetainedOutstanding / SkippedUnwanted / UnansweredWanted live on the LINK, where the
|
||||
// store is (StreamLink::RetainedOutstanding and friends). They are what says whether the
|
||||
// declared-answer store is bounded by the CLIENT (SkippedUnwanted grows, outstanding does
|
||||
// not) rather than by the server's answer rate - which is the property the capacity rests on.
|
||||
Uint32 ClientCreateWindowEffective() { return CreateWindowEffective(); }
|
||||
Uint32 ClientCreateWindowArraySize() { return kCreateWindowMax; }
|
||||
// Objects whose create came back DECLINED: out of the window, next create blocking, and back
|
||||
|
||||
@@ -91,11 +91,7 @@ namespace MobileGL::MG_Remote::Client {
|
||||
// drain that never collects leaves Pending at the ceiling and every other reading unchanged,
|
||||
// which is exactly what the device measured before the stream link learned to answer by seq.
|
||||
Uint64 ClientCreateWindowTaken();
|
||||
// How many times the RESIDENCY BUDGET - not a full window - forced the blocking take: the
|
||||
// oldest answer was about to outlive its reply slot. Non-zero is the budget doing its job;
|
||||
// a value near the record count says the budget is too tight for this workload and the
|
||||
// window is deferring almost nothing, which is the honest way to read that.
|
||||
Uint64 ClientCreateWindowBudgetStops();
|
||||
|
||||
Uint32 ClientCreateWindowEffective();
|
||||
Uint32 ClientCreateWindowArraySize();
|
||||
Uint32 ClientCreateWindowSuspects();
|
||||
|
||||
@@ -199,6 +199,16 @@ namespace MobileGL::MG_Remote::Transport {
|
||||
bool PublishIsDelivery = false; // false => Flush() is mandatory before any wait
|
||||
std::uint64_t MaxRecordBytes = 0;
|
||||
std::uint64_t MaxReplyBytes = 0;
|
||||
// P12: DOES THIS LINK KEEP AN ANSWER UNTIL ITS READER TAKES IT? A stream does - the
|
||||
// answers are the client's own bytes - and a shared pool does NOT: a slot is
|
||||
// `seq % slotCount` and the next answer into it wins, so "the watermark passed this seq"
|
||||
// says nothing about whether the answer is still there.
|
||||
//
|
||||
// IT IS A CAPABILITY RATHER THAN A HOPE because a row that DEFERS its answer (the create
|
||||
// window) may only run where it is true: measured on the device, a deferral across the
|
||||
// ~1,000 records between two creates lost its answer to a bounded buffer every time.
|
||||
// A caller that sees false must take the blocking path instead.
|
||||
bool RetainsReplies = false;
|
||||
};
|
||||
|
||||
// Non-owning synchronization addresses, acquired once at attachment. They
|
||||
@@ -285,6 +295,22 @@ namespace MobileGL::MG_Remote::Transport {
|
||||
virtual MobileGLResult ReadReply(std::uint64_t seq, std::int32_t* outStatus,
|
||||
const void** outPayload, std::uint64_t* outSize) = 0;
|
||||
|
||||
// P12: DECLARE THAT THIS SEQ'S ANSWER WILL BE READ, so that a link which can retain it
|
||||
// does. Called by the producer side of the wire, in seq order, BEFORE the record is
|
||||
// published - the answer cannot arrive before that, and the reader stores under the same
|
||||
// mutex the declaration takes, so the hand-off needs no second synchronisation.
|
||||
//
|
||||
// WHY THE DECLARATION AND NOT "RETAIN EVERYTHING": a server that ignores the no-reply bit
|
||||
// answers ~55,903 records in one frame where this client reads ~4,501, so retain-on-arrival
|
||||
// grows ~11.3 entries per create and any cap is reached in C/11.3 creates - after which the
|
||||
// eviction wave lands on precisely the oldest WANTED answer, which is the wedge this exists
|
||||
// to avoid. Keyed on the declaration, the retained set is bounded by the CLIENT's own
|
||||
// deferral depth instead of by the server's behaviour.
|
||||
//
|
||||
// A link with RetainsReplies false ignores it (the default), and its callers must not
|
||||
// defer: see the capability's own note.
|
||||
virtual void DeclareReplyRead(std::uint64_t seq) { (void)seq; }
|
||||
|
||||
// ---- the reverse channel --------------------------------------------
|
||||
|
||||
virtual LinkArena* EventArena() = 0;
|
||||
|
||||
@@ -44,7 +44,9 @@ namespace MobileGL::MG_Remote::Transport {
|
||||
}
|
||||
LinkCapabilities ShmLink::Capabilities() const {
|
||||
const ReplySlotPool replies(m_memory.ReplyBase(), m_memory.ReplyBytes(), m_memory.ReplySlotCount());
|
||||
return {true, true, true, m_memory.CmdRingCapacity() / 2, replies.MaxReplyBytes()};
|
||||
// RetainsReplies stays false: a slot is seq % slotCount and the next answer into it wins.
|
||||
return {true, true, true, m_memory.CmdRingCapacity() / 2, replies.MaxReplyBytes(),
|
||||
/*RetainsReplies=*/false};
|
||||
}
|
||||
LinkProgress* ShmLink::Progress() {
|
||||
return m_memory.Valid() ? &m_memory.CmdControl()->Progress : nullptr;
|
||||
|
||||
@@ -107,18 +107,52 @@ namespace MobileGL::MG_Remote::Transport {
|
||||
// move (29.0 s against the 30.1 s of the same binary with the window off). The deferral
|
||||
// was pure overhead: four answers held unread, plus one failed drain attempt per record.
|
||||
//
|
||||
// BOUNDED AT kDefaultReplySlotCount, which is the pool ShmLink stands in for: an answer a
|
||||
// reader has not taken by the time eight newer ones have arrived is one the shm arm has
|
||||
// already overwritten too, so the two transports now lose the same answers and no more.
|
||||
// The byte vectors are sized per reply, so an idle link pays eight empty ones.
|
||||
static constexpr std::size_t kReplyRing = 8;
|
||||
// ---- AND THE RING WAS NOT ENOUGH, SO THE STORE IS KEYED ON A DECLARATION (P12) -------
|
||||
//
|
||||
// THE MEASUREMENT THAT SETTLED IT. With the ring at 8 the drain read back NOTHING:
|
||||
// `taken=63 pushed=65 readfail=8,872` on a load frame, and the miss probe showed why -
|
||||
// `want=310` on every miss while the ring had moved on to [1175..1504]. Between two
|
||||
// creates this client emits on the order of a thousand records and looks at the window
|
||||
// not once, so a deferral has to survive an unbounded amount of other traffic, and WHAT
|
||||
// DISPLACES IT IS NOT WHAT THE CLIENT EMITS BUT WHAT THE SERVER ANSWERS - it is behind,
|
||||
// and its late answers to the EARLIER creates fill a bounded buffer first.
|
||||
//
|
||||
// Ring 8 -> 1024 as a one-line experiment, and every deferred answer came back:
|
||||
// `taken=4,499/4,500 readfail=0`, wall 27.9 s -> 23.0 s, and with the residency budget
|
||||
// out of the way 28.66 s -> 16.80 s at window 2 and 11.45 s at window 4. So the property
|
||||
// needed is "the answer is still there when the reader comes back", and 1024 is a
|
||||
// STAND-IN for it, not the design: 1024 retained answers is ~2 GiB worst case, and "deep
|
||||
// enough" is a probability rather than a bound.
|
||||
//
|
||||
// THE BOUND COMES FROM THE CLIENT. A seq is retained only if the wire DECLARED it will be
|
||||
// read (ILink::DeclareReplyRead), so the outstanding set is the window's depth plus the
|
||||
// one blocking row in flight plus whatever a retired window strands - it does not depend
|
||||
// on how many answers the server chooses to send. Retaining on ARRIVAL instead would grow
|
||||
// ~11.3 entries per create against a server that ignores the no-reply bit (55,903 answers
|
||||
// a frame where this client reads 4,501), and any cap would then be reached and would
|
||||
// evict precisely the oldest WANTED answer - the same wedge, a few hundred creates later.
|
||||
//
|
||||
// OVERFLOW IS FATAL AND NEVER AN EVICTION. An evicted wanted answer is unrecoverable:
|
||||
// sending another create to re-latch the object wipes the server's record for it
|
||||
// (PipeApply.cpp:2033) along with its PendingUploads (PipeApply.h:332), and the client
|
||||
// cleared its dirty flags at EMISSION (PipeApply.h:298-304), so nobody re-sends those
|
||||
// texels. The capacity is sized so that cannot be reached, and reaching it is a bug that
|
||||
// says its own name.
|
||||
static constexpr std::size_t kRetainedCap = 24;
|
||||
struct StoredReply {
|
||||
std::uint64_t seq = 0;
|
||||
std::int32_t status = 0;
|
||||
std::vector<std::uint8_t> bytes;
|
||||
};
|
||||
std::array<StoredReply, kReplyRing> replies{};
|
||||
std::size_t replyNext = 0;
|
||||
// MONOTONE ON BOTH SIDES, so a cursor beats a hash map: the wire declares in emission
|
||||
// order, the server answers in apply order. It also makes a declared-but-never-answered
|
||||
// seq visible for free - the cursor walks past it on the way to a later answer - which is
|
||||
// counted rather than ignored, because the read that follows will fail and should fail
|
||||
// with a number beside it.
|
||||
std::vector<std::uint64_t> wanted;
|
||||
std::size_t wantCursor = 0;
|
||||
std::vector<StoredReply> retained;
|
||||
std::uint64_t skippedUnwanted = 0, unansweredWanted = 0, retainedTotal = 0;
|
||||
std::uint64_t lastProgressSeq = 0;
|
||||
std::chrono::steady_clock::time_point lastProgress = std::chrono::steady_clock::now();
|
||||
LinkArena records{}, events{};
|
||||
@@ -334,15 +368,34 @@ namespace MobileGL::MG_Remote::Transport {
|
||||
std::lock_guard<std::mutex> lock(replyMutex);
|
||||
if (a <= replySeq) Corrupt("reply sequence moved backwards");
|
||||
replySeq = a;
|
||||
// RING, NOT REPLACE. replySeq stays the newest and stays monotone -
|
||||
// the wait predicate below is written in terms of it - while the
|
||||
// answers themselves are kept for the readers that come back for an
|
||||
// older one (the window's drain, StreamLink.cpp's own note).
|
||||
StoredReply& slot = replies[replyNext];
|
||||
replyNext = (replyNext + 1) % kReplyRing;
|
||||
slot.seq = a;
|
||||
slot.status = static_cast<std::int32_t>(static_cast<std::uint32_t>(b));
|
||||
slot.bytes.assign(payload, payload + size);
|
||||
// DECLARED? The cursor walks past any DECLARED seq this answer has
|
||||
// overtaken: the server answers in apply order, so an answer with a
|
||||
// higher seq means those will never come. That is a broken invariant
|
||||
// rather than a race, and it is counted - the read that follows fails
|
||||
// and should fail with this number beside it.
|
||||
while (wantCursor < wanted.size() && wanted[wantCursor] < a) {
|
||||
++wantCursor;
|
||||
++unansweredWanted;
|
||||
}
|
||||
if (wantCursor < wanted.size() && wanted[wantCursor] == a) {
|
||||
++wantCursor;
|
||||
// OVERFLOW IS NAMED, NOT ABSORBED. See the capacity note above:
|
||||
// an eviction here would throw away an answer somebody is waiting
|
||||
// for, and there is no path that recovers one.
|
||||
if (retained.size() >= kRetainedCap) {
|
||||
Corrupt("the declared-answer store overflowed; a declared answer"
|
||||
" would have to be evicted and none is recoverable");
|
||||
}
|
||||
StoredReply& slot = retained.emplace_back();
|
||||
slot.seq = a;
|
||||
slot.status = static_cast<std::int32_t>(static_cast<std::uint32_t>(b));
|
||||
slot.bytes.assign(payload, payload + size);
|
||||
++retainedTotal;
|
||||
} else {
|
||||
// NOBODY DECLARED IT: drop it. This is what keeps the store bounded
|
||||
// against a server that answers records this client never reads.
|
||||
++skippedUnwanted;
|
||||
}
|
||||
}
|
||||
replyReady.notify_all();
|
||||
producer.Notify();
|
||||
@@ -464,7 +517,8 @@ namespace MobileGL::MG_Remote::Transport {
|
||||
}
|
||||
LinkCapabilities StreamLink::Capabilities() const {
|
||||
if (!m_impl->memory) return {};
|
||||
return {false, false, false, m_impl->memory->CmdRingCapacity() / 2, m_impl->MaxReply()};
|
||||
return {false, false, false, m_impl->memory->CmdRingCapacity() / 2, m_impl->MaxReply(),
|
||||
/*RetainsReplies=*/true};
|
||||
}
|
||||
LinkProgress* StreamLink::Progress() {
|
||||
return m_impl->memory ? &m_impl->C().Progress : nullptr;
|
||||
@@ -589,17 +643,22 @@ namespace MobileGL::MG_Remote::Transport {
|
||||
return x.closed.load(std::memory_order_acquire) || x.replySeq >= seq ||
|
||||
x.C().Progress.appliedSeq.load(std::memory_order_acquire) >= seq;
|
||||
});
|
||||
// BY SEQ, WHICH IS WHAT THE PARAMETER HAS ALWAYS SAID (P12). The equality this replaces
|
||||
// BY SEQ, WHICH IS WHAT THE PARAMETER HAS ALWAYS SAID (P12). The equality this replaced
|
||||
// was true only while every caller read the newest answer; it is what made a deferred
|
||||
// answer unreadable and the create window inert on this transport.
|
||||
for (const auto& slot : x.replies) {
|
||||
if (slot.seq != seq) continue;
|
||||
// Copied into the shared scratch so the pointer outlives the lock, exactly as the
|
||||
// single-slot version did: the caller memcpys under its own frame.
|
||||
x.readReply = slot.bytes;
|
||||
*status = slot.status;
|
||||
for (auto it = x.retained.begin(); it != x.retained.end(); ++it) {
|
||||
if (it->seq != seq) continue;
|
||||
// TAKE-AND-DELETE IS ONE STEP UNDER THE LOCK. The bytes are SWAPPED into the shared
|
||||
// scratch before the entry goes, so whoever is handed the answer is whoever consumed
|
||||
// it - no copy of a large answer, and no window in which a second reader could see
|
||||
// the same seq. (The pointer outlives the lock, as it always has here; this link has
|
||||
// ONE reply reader, and the store's erase is what keeps that an invariant rather
|
||||
// than a hope.)
|
||||
x.readReply.swap(it->bytes);
|
||||
*status = it->status;
|
||||
*size = x.readReply.size();
|
||||
*p = x.readReply.data();
|
||||
x.retained.erase(it);
|
||||
return MOBILEGL_OK;
|
||||
}
|
||||
return x.closed.load() ? MOBILEGL_ERR_TRANSPORT_CLOSED : MOBILEGL_ERR_PROTOCOL_MISMATCH;
|
||||
@@ -624,12 +683,48 @@ namespace MobileGL::MG_Remote::Transport {
|
||||
TransportRoleTag StreamLink::Role() const {
|
||||
return m_impl->role;
|
||||
}
|
||||
void StreamLink::DeclareReplyRead(std::uint64_t seq) {
|
||||
auto& x = *m_impl;
|
||||
if (!x.Client() || !x.memory) return;
|
||||
std::lock_guard<std::mutex> lock(x.replyMutex);
|
||||
// MONOTONE, IN EMISSION ORDER (the wire declares before it publishes, and seqs only ever
|
||||
// grow). An out-of-order or repeated declaration is dropped rather than stored: the first
|
||||
// would break the cursor, and the second is a retry that must not grow the list.
|
||||
if (!x.wanted.empty() && seq <= x.wanted.back()) return;
|
||||
x.wanted.push_back(seq);
|
||||
// AND THE CONSUMED PREFIX IS RELEASED, or the list would grow by one entry per declared
|
||||
// record for the life of the session (4,536 a frame). The cursor's entries can never be
|
||||
// needed again: a seq is answered once.
|
||||
if (x.wantCursor >= 64) {
|
||||
x.wanted.erase(x.wanted.begin(),
|
||||
x.wanted.begin() + static_cast<std::ptrdiff_t>(x.wantCursor));
|
||||
x.wantCursor = 0;
|
||||
}
|
||||
}
|
||||
|
||||
std::uint64_t StreamLink::RetainedOutstanding() const {
|
||||
auto& x = *m_impl;
|
||||
std::lock_guard<std::mutex> lock(x.replyMutex);
|
||||
return static_cast<std::uint64_t>(x.retained.size());
|
||||
}
|
||||
std::uint64_t StreamLink::RetainedTotal() const { return m_impl->retainedTotal; }
|
||||
std::uint64_t StreamLink::SkippedUnwanted() const { return m_impl->skippedUnwanted; }
|
||||
std::uint64_t StreamLink::UnansweredWanted() const { return m_impl->unansweredWanted; }
|
||||
|
||||
void StreamLink::Detach() {
|
||||
auto& x = *m_impl;
|
||||
if (!x.memory) return;
|
||||
x.stopping.store(true, std::memory_order_release);
|
||||
x.Die(false);
|
||||
if (x.reader.joinable()) x.reader.join();
|
||||
// P12: the declared-answer store does not outlive the link. Without this a detached
|
||||
// session leaves up to a frame's worth of answers (and their seqs) resident.
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(x.replyMutex);
|
||||
x.wanted.clear();
|
||||
x.wantCursor = 0;
|
||||
x.retained.clear();
|
||||
}
|
||||
#if !defined(_WIN32)
|
||||
if (x.fd >= 0) ::close(x.fd);
|
||||
#endif
|
||||
|
||||
@@ -36,6 +36,17 @@ namespace MobileGL::MG_Remote::Transport {
|
||||
MobileGLResult ResolveSpan(LinkSegment, LinkSpan, const void**) override;
|
||||
MobileGLResult PostReply(std::uint64_t, std::int32_t, const void*, std::uint64_t) override;
|
||||
MobileGLResult ReadReply(std::uint64_t, std::int32_t*, const void**, std::uint64_t*) override;
|
||||
void DeclareReplyRead(std::uint64_t) override;
|
||||
|
||||
// P12 diagnostics for the declared-answer store. RetainedOutstanding is what the capacity
|
||||
// is about (it must stay near the window's depth, not grow with the server's answers);
|
||||
// RetainedTotal counts the answers kept for their readers; SkippedUnwanted counts the ones
|
||||
// nobody declared, which is the number that proves the store is bounded by the CLIENT and
|
||||
// not by the server; UnansweredWanted counts declared seqs the server never answered.
|
||||
std::uint64_t RetainedOutstanding() const;
|
||||
std::uint64_t RetainedTotal() const;
|
||||
std::uint64_t SkippedUnwanted() const;
|
||||
std::uint64_t UnansweredWanted() const;
|
||||
LinkArena* EventArena() override;
|
||||
LinkCursor* EventCursor() override;
|
||||
std::uint64_t EventPublishedHead() const override;
|
||||
|
||||
@@ -1205,21 +1205,22 @@ TEST(RemoteClientControls, ABufferRespecifyTakesNoReplyWhileATextureOneStillDoes
|
||||
// answers are collected in order. A late DECLINED un-latches its object, which puts every later row
|
||||
// for that object back on the blocking path - the repair that already existed, not a new one.
|
||||
//
|
||||
// THE THREE WAYS THIS CAN GO WRONG, one reading each:
|
||||
// ⚠️ THIS CASE RUNS ON THE IN-PROCESS ARM, WHICH IS ShmLink, AND SINCE P12 THAT MEANS THE WINDOW
|
||||
// IS OFF HERE BY DESIGN - `CreateWindowEffective()` is gated on LinkRetainsReplies() precisely
|
||||
// because a shared pool's slot is `seq % slotCount` and is reused, so a deferred answer may be
|
||||
// gone with no way to recover it. What this case pins is therefore THAT GATE, and it is the
|
||||
// assertion the device cannot make: set the knob as high as it goes and the row must STILL take the
|
||||
// blocking path on a link that cannot retain.
|
||||
//
|
||||
// 1. THE WINDOW DEFERS NOTHING (the row pays a wait per record and the change is decoration) -
|
||||
// the ON half below;
|
||||
// 2. THE WINDOW IS NOT WHAT DECIDES (the row defers while the operator asked it not to, which is
|
||||
// the same defect as 1 with the sign flipped) - THE NEGATIVE CONTROL, run in the SAME CHILD as
|
||||
// the ON half so that both readings share a session, a server, a set of objects and a pair of
|
||||
// counters. MOBILEGL_IPC_CREATE_WINDOW=1 must restore the pre-window shape exactly; this is
|
||||
// the assertion that goes red if the window ever stops being the knob;
|
||||
// 3. THE WINDOW HOLDS MORE THAN IT IS SIZED FOR - and the ring is what makes this one quiet: an
|
||||
// unguarded push does not grow a count past its ceiling, it REPLACES the oldest confirmation
|
||||
// and leaves the count exactly where it was. So the invariant is read against the effective
|
||||
// ceiling after every create, the ceiling is read against the array, the array is read against
|
||||
// the reply pool it shares, and the environment is not allowed to raise any of them.
|
||||
TEST(RemoteClientControls, ACreateWindowDefersTheAnswersAndTheWindowOffDoesNot) {
|
||||
// THE WINDOW'S OWN BEHAVIOUR IS PINNED ELSEWHERE, ON THE ARM IT SHIPS ON: StreamLinkTest's
|
||||
// declared-answer cases for the store, and the stream-arm window case for the row. That split is
|
||||
// not tidiness - three rounds of this feature were green here while being inert on the device,
|
||||
// because these gates and the device run on different transports.
|
||||
//
|
||||
// WHAT IS STILL READ HERE, because it is transport-independent: the environment cannot raise the
|
||||
// window past the array, the array cannot exceed half the reply pool, and the invariant holds after
|
||||
// every create.
|
||||
TEST(RemoteClientControls, TheWindowRefusesToDeferOnALinkThatCannotRetainAnswers) {
|
||||
const auto child = RunInChild([] {
|
||||
::setenv("MOBILEGL_PIPE_STATS", "1", 1);
|
||||
CapsPeer backend;
|
||||
@@ -1266,68 +1267,30 @@ TEST(RemoteClientControls, ACreateWindowDefersTheAnswersAndTheWindowOffDoesNot)
|
||||
}
|
||||
};
|
||||
|
||||
// ---- THE WINDOW ON: twelve creates, strictly fewer waits ----
|
||||
// ---- THE KNOB IS AS HIGH AS IT GOES, AND THE ROW MUST STILL BLOCK ----
|
||||
MG_Config::Ipc.CreateWindow = 4;
|
||||
const Uint64 recordsBefore = Transport::LinkMetricsRecordsFor(createOp);
|
||||
const Uint64 waitsBefore = Transport::LinkMetricsReplyWaitsFor(createOp);
|
||||
burst(12);
|
||||
const Uint64 records = Transport::LinkMetricsRecordsFor(createOp) - recordsBefore;
|
||||
const Uint64 waits = Transport::LinkMetricsReplyWaitsFor(createOp) - waitsBefore;
|
||||
std::printf("create window ON: window=%u records=%llu waits=%llu high_water=%u\n",
|
||||
std::printf("non-retaining link: window=%u records=%llu waits=%llu taken=%llu pending=%u high_water=%u\n",
|
||||
static_cast<unsigned>(MG_Config::Ipc.CreateWindow),
|
||||
static_cast<unsigned long long>(records),
|
||||
static_cast<unsigned long long>(waits),
|
||||
static_cast<unsigned long long>(ClientCreateWindowTaken()),
|
||||
static_cast<unsigned>(ClientCreateWindowPending()),
|
||||
static_cast<unsigned>(highWater));
|
||||
std::fflush(nullptr);
|
||||
if (records < 12) ::_exit(164);
|
||||
// ---- AND THE ANSWERS CAME BACK, WHICH IS THE ASSERTION THAT CANNOT BE FAKED ----
|
||||
// A deferral that is never collected looks EXACTLY like a working one from every other
|
||||
// reading in this case: Pending sits at the ceiling, the fallback keeps the frame moving,
|
||||
// and the record count is right. It is also what the device measured for a whole session
|
||||
// (pending pinned at 4, readfail 8,951) and what a no-reply bit keyed on `wantReply` would
|
||||
// produce ON PURPOSE - the window's emits are wantReply=false, so a server that skipped
|
||||
// them would leave this at zero while `records`, `waits` and `pending` all stayed green.
|
||||
const Uint64 taken = ClientCreateWindowTaken();
|
||||
std::printf("create window ON: answers_taken=%llu outstanding=%u fallbacks=%llu\n",
|
||||
static_cast<unsigned long long>(taken),
|
||||
static_cast<unsigned>(ClientCreateWindowPending()),
|
||||
static_cast<unsigned long long>(records > taken + ClientCreateWindowPending()
|
||||
? records - taken - ClientCreateWindowPending()
|
||||
: 0u));
|
||||
std::fflush(nullptr);
|
||||
if (taken == 0) ::_exit(174);
|
||||
// NOTHING IS INVENTED AND NOTHING IS LOST: every answered create is either back in hand
|
||||
// or still outstanding, and the remainder took the blocking fallback.
|
||||
if (taken + ClientCreateWindowPending() > records) ::_exit(175);
|
||||
// THE DEFERRAL ITSELF, and the honest size of the claim: the first `window` records pay
|
||||
// nothing, so twelve records cannot pay twelve waits once anything was deferred. A ratio
|
||||
// would be a promise about how fast the server answers, which is not this suite's to make.
|
||||
if (waits >= records) ::_exit(165);
|
||||
// NOTHING WAS DEFERRED AT ALL, and nothing was left outstanding: on a link that cannot
|
||||
// retain, a window that deferred would be holding answers it may never get back.
|
||||
if (ClientCreateWindowTaken() != 0) ::_exit(177);
|
||||
if (ClientCreateWindowPending() != 0) ::_exit(178);
|
||||
// AND EVERY CREATE PAID ITS ROUND TRIP, which is the pre-window shape exactly. This is the
|
||||
// assertion that goes red if the capability gate is removed.
|
||||
if (waits != records) ::_exit(179);
|
||||
|
||||
// ---- AND THE SAME TWELVE WITH THE WINDOW OFF, IN THIS SAME CHILD ----
|
||||
// The negative control shares everything with the ON half except the knob, which is what
|
||||
// makes it a control rather than a second measurement. ONE WAIT PER RECORD is the
|
||||
// pre-window shape: R-5 shipped with exactly that, and the whole change is the difference
|
||||
// between the two lines this case prints.
|
||||
MG_Config::Ipc.CreateWindow = 1;
|
||||
const Uint64 offRecordsBefore = Transport::LinkMetricsRecordsFor(createOp);
|
||||
const Uint64 offWaitsBefore = Transport::LinkMetricsReplyWaitsFor(createOp);
|
||||
burst(12);
|
||||
const Uint64 offRecords = Transport::LinkMetricsRecordsFor(createOp) - offRecordsBefore;
|
||||
const Uint64 offWaits = Transport::LinkMetricsReplyWaitsFor(createOp) - offWaitsBefore;
|
||||
std::printf("create window OFF: window=%u records=%llu waits=%llu\n",
|
||||
static_cast<unsigned>(MG_Config::Ipc.CreateWindow),
|
||||
static_cast<unsigned long long>(offRecords),
|
||||
static_cast<unsigned long long>(offWaits));
|
||||
std::fflush(nullptr);
|
||||
if (offRecords < 12) ::_exit(166);
|
||||
// ONE WAIT PER CREATE, EXACTLY. Every create on this path is an EmitAndWait, so the two
|
||||
// counters are equal whatever else the row does - which is the assertion that cannot be
|
||||
// satisfied by a window that is still deferring sometimes.
|
||||
if (offWaits != offRecords) ::_exit(167);
|
||||
// AND THE WINDOW TOOK NOTHING WHILE IT WAS OFF, which is the control the ON half's
|
||||
// `taken` needs: without it, a `taken` that grew for some unrelated reason would read as
|
||||
// a working deferral.
|
||||
if (ClientCreateWindowTaken() != taken) ::_exit(176);
|
||||
ClientSessionInstance().Stop();
|
||||
});
|
||||
ExpectChildSuccess(child);
|
||||
@@ -1337,14 +1300,18 @@ TEST(RemoteClientControls, ACreateWindowDefersTheAnswersAndTheWindowOffDoesNot)
|
||||
// P12: A REFUSED CREATE LEAVES THE WINDOW - read ON THE LATCH AND ON THE SUSPECT LIST
|
||||
// =====================================================================================
|
||||
//
|
||||
// The window's answer is provisional, so the one thing it must never do is keep feeding a refused
|
||||
// object fire-and-forget creates: the client would hold a confirmation the server has refused, for
|
||||
// ever, and every row that reads the latch would believe it. The repair is deliberately the path
|
||||
// that already existed - DECLINED un-latches the object, the object leaves the window, its next
|
||||
// create BLOCKS and the refusal reaches the caller - and this case reads all three steps rather
|
||||
// than the symptom. It then reads the HEAL, which is the other half of the same rule: a blocking
|
||||
// create that lands puts the object back, so a transient refusal cannot retire an object from the
|
||||
// window for the rest of the process (or, at kCreateSuspectMax of them, retire the window).
|
||||
// The answer is what PUBLISHES an object, so the one thing a refusal must never do is leave the
|
||||
// client believing it landed: every row that reads the latch would then act on a record the server
|
||||
// does not hold. This case reads the whole chain - the refusal leaves the object UNLATCHED, its
|
||||
// next storage definition BLOCKS and is refused again, and giving the family its consumer back
|
||||
// HEALS it (the create is accepted and the latch comes back).
|
||||
//
|
||||
// ⚠️ RUNS ON THE IN-PROCESS ARM (ShmLink), WHERE THE WINDOW IS OFF, so what repairs the object
|
||||
// here is the LATCH ALONE - the suspect table belongs to the window's provisional accept and the
|
||||
// drain is the only thing that fills it, which this case now asserts by finding it EMPTY. The
|
||||
// window's own refusal path is pinned on the arm it ships on (StreamLinkTest's declared-answer
|
||||
// cases and the stream-arm window case); see the note on the gate above for why that split is not
|
||||
// tidiness.
|
||||
//
|
||||
// THE REFUSAL IS MADE REAL RATHER THAN SIMULATED, and it is the exact case the audit found the two
|
||||
// ends can disagree about: the applier asks for bit 10 while the client's mirror still says the
|
||||
@@ -1384,7 +1351,7 @@ TEST(RemoteClientControls, ARefusedCreateUnlatchesItsObjectAndItsNextCreateBlock
|
||||
MG_Impl::GLImpl::GenTextures(1, &filler);
|
||||
defineStorage(filler, 8);
|
||||
}
|
||||
// ---- (a) THE LATCH IS CLEAR AND THE OBJECT IS OUT OF THE WINDOW ----
|
||||
// ---- (a) THE LATCH IS CLEAR, AND THE WINDOW NEVER RAN HERE AT ALL ----
|
||||
const auto refusedObject = MG_State::pGLContext->GetTextureObject(refused);
|
||||
if (refusedObject == nullptr) ::_exit(174);
|
||||
const MG_Pipe::MGPipeHandle refusedHandle =
|
||||
@@ -1393,9 +1360,14 @@ TEST(RemoteClientControls, ARefusedCreateUnlatchesItsObjectAndItsNextCreateBlock
|
||||
// THE DIRECT READING, which is what the converted rows read: not "the create was refused"
|
||||
// but "this object is not published", which is the fact every fire-and-forget row acts on.
|
||||
if (MG_Pipe::MGPipeHandleIsPublished(MG_Pipe::MGPipeKind::Texture, refusedHandle)) ::_exit(176);
|
||||
if (ClientCreateWindowRefusals() == 0) ::_exit(177);
|
||||
// AND THE WINDOW'S OWN BOOKS ARE EMPTY, because this arm cannot retain answers and the
|
||||
// window therefore never deferred - `refusals` and `suspects` are both filled by the
|
||||
// drain and by nothing else. A non-zero reading here would mean the capability gate had
|
||||
// been removed and the row was deferring on a link that cannot hold an answer.
|
||||
if (ClientCreateWindowRefusals() != 0) ::_exit(177);
|
||||
const Uint32 suspectsBefore = ClientCreateWindowSuspects();
|
||||
if (suspectsBefore == 0) ::_exit(178);
|
||||
if (suspectsBefore != 0) ::_exit(178);
|
||||
if (ClientCreateWindowPending() != 0) ::_exit(183);
|
||||
// ---- (b) AND ITS NEXT CREATE BLOCKS: ONE WAIT PER CREATE, WITH THE WINDOW WIDE OPEN ----
|
||||
// The window has room (the ceiling is 4 and this case has just drained it), so a deferring
|
||||
// row would pay ZERO waits here. Every create this restorage emits pays one instead, which
|
||||
@@ -1413,18 +1385,17 @@ TEST(RemoteClientControls, ARefusedCreateUnlatchesItsObjectAndItsNextCreateBlock
|
||||
if (suspectRecords == 0) ::_exit(179);
|
||||
if (suspectWaits < suspectRecords) ::_exit(180);
|
||||
// ---- (c) THE HEAL: GIVE THE FAMILY ITS CONSUMER BACK AND RE-DEFINE THE SAME OBJECT ----
|
||||
// The create still blocks (the object is still suspect), now it LANDS, and landing is what
|
||||
// takes it out of the suspect list - exactly one entry, because no other object is touched.
|
||||
// The create still blocks (the latch is still clear), now it LANDS, and landing is what
|
||||
// re-publishes the object for every row that reads the latch.
|
||||
Srv::ServerSessionInstance().SetConsumedSubsystems(kMGPipeSubsystemsMigratedAtP4a);
|
||||
defineStorage(refused, 32);
|
||||
std::printf("refused create healed: published=%d suspects %u -> %u\n",
|
||||
std::printf("refused create healed: published=%d suspects=%u\n",
|
||||
static_cast<int>(MG_Pipe::MGPipeHandleIsPublished(MG_Pipe::MGPipeKind::Texture,
|
||||
refusedHandle)),
|
||||
static_cast<unsigned>(suspectsBefore),
|
||||
static_cast<unsigned>(ClientCreateWindowSuspects()));
|
||||
std::fflush(nullptr);
|
||||
if (!MG_Pipe::MGPipeHandleIsPublished(MG_Pipe::MGPipeKind::Texture, refusedHandle)) ::_exit(181);
|
||||
if (ClientCreateWindowSuspects() != suspectsBefore - 1) ::_exit(182);
|
||||
if (ClientCreateWindowSuspects() != suspectsBefore) ::_exit(182);
|
||||
ClientSessionInstance().Stop();
|
||||
});
|
||||
ExpectChildSuccess(child);
|
||||
|
||||
@@ -116,6 +116,11 @@ namespace {
|
||||
Queue(2, 2);
|
||||
ASSERT_EQ(client.Flush(), MOBILEGL_OK);
|
||||
ASSERT_EQ(apply.WaitForWork(2000), SessionWait::Reached);
|
||||
// P12: AN ANSWER IS KEPT ONLY IF SOMEBODY DECLARED IT WILL BE READ. Seq 1 is deliberately
|
||||
// NOT declared - this case is about an unwanted reply not disturbing a later one - and
|
||||
// seq 2 is, because the case reads it. Both records cross either way: the declaration
|
||||
// changes whether the LINK keeps the answer, never whether the server sends it.
|
||||
client.DeclareReplyRead(2);
|
||||
ASSERT_TRUE(apply.ApplyOne([&](const RingRecordView&) {
|
||||
const std::uint64_t value = 11;
|
||||
EXPECT_EQ(server.PostReply(1, 0, &value, sizeof value), MOBILEGL_OK);
|
||||
@@ -152,6 +157,7 @@ namespace {
|
||||
Queue(3, 3);
|
||||
ASSERT_EQ(client.Flush(), MOBILEGL_OK);
|
||||
ASSERT_EQ(apply.WaitForWork(2000), SessionWait::Reached);
|
||||
for (std::uint64_t seq = 1; seq <= 3; ++seq) client.DeclareReplyRead(seq);
|
||||
for (std::uint64_t seq = 1; seq <= 3; ++seq) {
|
||||
ASSERT_TRUE(apply.ApplyOne([&](const RingRecordView&) {
|
||||
const std::uint64_t value = seq * 11;
|
||||
@@ -175,6 +181,55 @@ namespace {
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================================================
|
||||
// P12: THE DECLARED-ANSWER STORE - WHAT A BOUNDED BUFFER OF "THE LAST N" COULD NOT DO
|
||||
// =====================================================================================
|
||||
//
|
||||
// The create window defers an answer across an unbounded amount of other traffic (measured on
|
||||
// the device: ~1,000 records between two creates) and a buffer of the last N answers has always
|
||||
// moved on by the time the reader comes back - `want=310` on every miss while the ring spanned
|
||||
// [1175..1504]. Ring 8 -> 1024 as an experiment made every deferred answer come back, and these
|
||||
// two cases are what replaces that stand-in: an answer is kept because somebody DECLARED it
|
||||
// will be read, so the retained set is bounded by the client's own deferral depth.
|
||||
TEST_F(StreamPair, ADeclaredAnswerSurvivesFarMoreInterveningAnswersThanAnyFixedBuffer) {
|
||||
constexpr std::uint64_t kIntervening = 64;
|
||||
client.DeclareReplyRead(1);
|
||||
const std::uint64_t wanted = 0x5EED;
|
||||
ASSERT_EQ(server.PostReply(1, 0, &wanted, sizeof wanted), MOBILEGL_OK);
|
||||
// SIXTY-FOUR answers nobody declared. Under "keep the last N" - any N this link has ever
|
||||
// had - seq 1 is gone by the end of this loop; under a declaration it is untouched, which
|
||||
// is the whole difference between a bound and a probability.
|
||||
for (std::uint64_t seq = 2; seq <= kIntervening + 1; ++seq) {
|
||||
const std::uint64_t junk = seq;
|
||||
ASSERT_EQ(server.PostReply(seq, 0, &junk, sizeof junk), MOBILEGL_OK);
|
||||
}
|
||||
EXPECT_TRUE(Eventually([&] { return client.SkippedUnwanted() >= kIntervening; }))
|
||||
<< "the undeclared answers were not dropped: " << client.SkippedUnwanted();
|
||||
std::int32_t status = -1;
|
||||
const void* p = nullptr;
|
||||
std::uint64_t size = 0;
|
||||
ASSERT_EQ(client.ReadReply(1, &status, &p, &size), MOBILEGL_OK)
|
||||
<< "the declared answer did not survive " << kIntervening << " intervening ones";
|
||||
ASSERT_EQ(size, sizeof(std::uint64_t));
|
||||
std::uint64_t value = 0;
|
||||
std::memcpy(&value, p, sizeof value);
|
||||
EXPECT_EQ(value, wanted);
|
||||
EXPECT_EQ(status, 0);
|
||||
// AND TAKING IT RELEASED IT: the store holds only what is still outstanding, which is why
|
||||
// its capacity can be tied to the window's depth rather than to the server's answer rate.
|
||||
EXPECT_EQ(client.RetainedOutstanding(), 0u);
|
||||
EXPECT_EQ(client.RetainedTotal(), 1u);
|
||||
}
|
||||
|
||||
TEST_F(StreamPair, AnAnswerNobodyDeclaredIsNotKeptAtAll) {
|
||||
const std::uint64_t value = 7;
|
||||
ASSERT_EQ(server.PostReply(1, 0, &value, sizeof value), MOBILEGL_OK);
|
||||
EXPECT_TRUE(Eventually([&] { return client.SkippedUnwanted() >= 1; }));
|
||||
EXPECT_EQ(client.RetainedOutstanding(), 0u)
|
||||
<< "an undeclared answer was retained: the store would grow with the SERVER's answer"
|
||||
" rate, which is what makes any capacity a matter of luck";
|
||||
}
|
||||
|
||||
TEST_F(StreamPair, EventDrainReturnsCreditAndClearsRemoteFullLatch) {
|
||||
EventRingProducer events(serverMemory.EventControl(), serverMemory.CmdControl(), serverMemory.EventRingBase(),
|
||||
1024);
|
||||
|
||||
@@ -287,7 +287,41 @@ if (x.replySeq != seq) return ... MOBILEGL_ERR_PROTOCOL_MISMATCH; // 等的是
|
||||
——答案走 Reply 帧、`StreamLink::ReadReply` 读客户端自己的 store,池只用来给 `MaxReplyBytes()` 定尺寸。
|
||||
所以「加深池子 + 抬 `ReplyBytes`」根本没有针对实测那条臂,别为它付 8× 常驻内存。
|
||||
|
||||
## 12. 复算
|
||||
## 12. 落地:保留表上线,默认翻到 2,真机 28.66 s → 16.52 s
|
||||
|
||||
按审计的形状实现(`ILink::DeclareReplyRead` + `LinkCapabilities::RetainsReplies`;`StreamLink` 的环 →
|
||||
**按声明的保留表**:单调白名单 + 游标、取走即删、触顶具名 Fatal、Detach 清空;删掉 residency budget;
|
||||
`CreateWindowEffective()` 在不保留答案的链路上强制为 1;窗口退休分支补一次 drain)。
|
||||
|
||||
**真机(两个二进制都重出,负载帧,`frame-sent 2=` 全程 4,536 不变):**
|
||||
|
||||
| 臂 | waits | `by_op 2` | 墙钟 |
|
||||
|---|---|---|---|
|
||||
| 窗口 1(R-5 的形状) | 4,543 | 4,536 | 28.66 s |
|
||||
| 窗口 2(显式) | 2,507 | 2,500 | 17.27 s |
|
||||
| **窗口 2(出厂默认,无覆盖)** | 2,570 | 2,563 | **16.52 s** |
|
||||
| 窗口 4 | 1,651 | 1,644 | 11.02 s |
|
||||
| **窗口 2 + 旧服务端(不认 no-reply 位)** | 2,670 | 2,663 | **16.38 s** |
|
||||
|
||||
**版本偏斜那一轮是审计点名的必测项,它过了**:旧服务端照回 ~55,903 条/帧,客户端的白名单把这些
|
||||
**没人声明**的答案在到达时丢弃(`SkippedUnwanted` 增长而不是保留表增长),延后的答案照旧读得回来,
|
||||
整轮**零 Fatal**。这正是「按声明保留」相对「全保留 + 淘汰」的那个差别。
|
||||
|
||||
**门**(172 + 15 全绿):
|
||||
- `StreamLinkTest.ADeclaredAnswerSurvivesFarMoreInterveningAnswersThanAnyFixedBuffer` —— **已证明能红**:
|
||||
把存储退回「按到达保留、只留 8 条」的环语义,它立刻以「the declared answer did not survive 64
|
||||
intervening ones」失败。这就是把环=1024 那个替身换成有界机制的那条界线。
|
||||
- `StreamLinkTest.AnAnswerNobodyDeclaredIsNotKeptAtAll` —— 没声明的答案不进保留表(红法:全保留)。
|
||||
- `RemoteClientControls.TheWindowRefusesToDeferOnALinkThatCannotRetainAnswers` —— 在不能保留的链路
|
||||
(in-process/ShmLink)上,把旋钮开到 4 也必须每条 create 都走阻塞、`taken == 0`(红法:去掉能力位)。
|
||||
- `ARefusedCreate...` 改成读这条臂上真正的修复链:拒绝 → 闩保持清零 → 下一次定义阻塞且再被拒 → 恢复消费者后闩回来;
|
||||
并断言窗口的账本(`refusals`/`suspects`/`pending`)在这条臂上**全为空**。
|
||||
|
||||
**还没做的一件**(审计 E10(d),下一轮):窗口级门目前只有真机这一层,**没有跑在 stream 臂上的单元门**。
|
||||
三轮「门全绿而功能在设备上是死的」都源于同一件事——门跑在 in-process/ShmLink 上。传输层的保留门已经补上,
|
||||
窗口那一层建议建在 `StreamClientPublicationTest` 的真 stream 会话上(`AttachStreamLink` + 可控 peer)。
|
||||
|
||||
## 13. 复算
|
||||
|
||||
~~~sh
|
||||
# 两臂的帧读数(handoff-2 §1 的读法)
|
||||
|
||||
Reference in New Issue
Block a user