From 9a8369296e4a59507e913e16ba862a2de8781bc2 Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Sun, 6 Sep 2026 09:36:08 -0400 Subject: [PATCH] [Fix] (Espryt): sweep the twin table on object churn again, refuse to run an arm the operator disabled, and give the hot lookups back their array probe - The handle arm took only ONE of the registry's two sweep drivers. The map arm sweeps every 64 first-time insertions BECAUSE object churn, not draw count, is what makes the sweep urgent: a CTS-shaped case runs ~10 per-draw ticks, so the 1024-tick draw-path driver alone spans ~100 cases' worth of dead, gigabyte-sized twins. BackendSlotTable now carries the same kCreationGCInterval = 64 creation tick, swept before the entry reference exists for the same reason the map arm sweeps there. Without this the slice REGRESSED the memory it was supposed to leave unchanged. - Fatal{PipeLegacyMemosDisabled} now aborts. It logged and then returned false, which fell straight into the legacy arm the operator had just made unreachable: a green run measured on the wrong arm, and the exact lever HandleRecycleScenario's arms are selected with. It is also resolved at backend context creation now, not on the first twin lookup, so a process that twins nothing still learns its knobs leave it with no arm at all. - EsprytSlotTablesEnabled() becomes an inline latch over an out-of-line resolver. It is consulted on every Find/GetOrCreate/HandleOf/ForEachLive/CollectGarbage*, i.e. several times per draw, and as a cross-TU call with no LTO that was a PLT call per lookup. - HandleOf keeps a one-entry lifetimeId -> handle memo, so the three per-draw resolution paths whose TwinLookupMemos this slice deleted go back to an integer compare plus an array index instead of the allocator's ByLifetimeId hash - which is the "direct slot indexing" the memo removal was traded for. It cannot serve a stale answer: a lifetime id is never handed out twice, and FindByHandle compares Gen anyway. - GetOrCreate(nullptr) returns a parking slot instead of dereferencing null in a release build; the map arm inserted a null key and SyncTextureObjectToBackend documents relying on that tolerance. - ReclaimDeadSlots moves the twin out before it writes the entry, so a twin destructor that re-entered GetOrCreate and grew m_slots could not make the writes land in freed memory. - The framebuffer binding-slot cache is gated on kMGPipeSubsystemEsprytSlots rather than on the compile-time MOBILEGL_PIPE_PUSH, so MOBILEGL_PIPE_PUSH=0 stays the faithful all-subsystems-pull control ConfigLoader.cpp documents. The poison bypass stays closed on the arm that ships. - MGB_TWIN_KIND_PARAM/ARG stop leaking into every TU that includes Managers.h: the twelve declaration and definition sites name a TwinRegistry alias template that swallows the kind in the pull build, and the one remaining macro is #undef'd after the class. - The twin lookup inside BindCurrentFBO stops shadowing the framebuffer binding slot in a function whose whole subject is which "slot" is meant. --- MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp | 50 ++++-- MobileGL/MG_Backend/DirectGLES/Managers.cpp | 39 +++-- MobileGL/MG_Backend/DirectGLES/Managers.h | 34 ++-- MobileGL/MG_Backend/DirectGLES/SlotTables.h | 146 ++++++++++++++++-- 4 files changed, 220 insertions(+), 49 deletions(-) diff --git a/MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp b/MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp index dc1e7c37..4905b4a7 100644 --- a/MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp +++ b/MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp @@ -148,23 +148,31 @@ namespace MobileGL::MG_Backend::DirectGLES { // exactly the pointer compare below. using FbBindingSlot = std::remove_reference_tGetFramebufferBindingSlot(FramebufferTarget::Draw))>; -#if !MOBILEGL_PIPE_PUSH +#if !MOBILEGL_PIPE_PUSH || MOBILEGL_PIPE_LEGACY_MEMOS static const void* g_fbSlotCacheContext = nullptr; static Array g_fbSlotCache = {}; #endif - // P2 step e4. Under push this is an ORDINARY read of pushed state and the cache above does - // not exist, which closes the P1 accessor bypass: the cached raw pointer ran the checked - // accessor once per context change and then handed out the pointee forever, so at all five - // call sites the per-verb poison stamp (MGP_INPUT_CHECK) and the verify read-hook - // (MGP_INPUT_VERIFY_READ) were skipped. A verb that legitimately never fills + // P2 step e4. On the {slot, gen} arm this is an ORDINARY read of pushed state and the cache + // above is not consulted, which closes the P1 accessor bypass: the cached raw pointer ran + // the checked accessor once per context change and then handed out the pointee forever, so + // at all five call sites the per-verb poison stamp (MGP_INPUT_CHECK) and the verify + // read-hook (MGP_INPUT_VERIFY_READ) were skipped. A verb that legitimately never fills // GetFramebufferBindingSlot could not be caught here, and a verify build compared the field - // only where the slow accessor was used. In the pull build MGB_CTX is the live GLContext, - // there is no poison to bypass and the frontend getter still linear-scans, so the cache is - // exactly the code it was. + // only where the slow accessor was used. + // + // The cache stays on the LEGACY arm, gated on the same subsystem bit as the rest of this + // slice, so that MOBILEGL_PIPE_PUSH=0 keeps being the faithful all-subsystems-pull control + // ConfigLoader.cpp documents - "reproduces P1's behaviour exactly" has to include this + // path, or the integrator's A/B measures e4 on both arms and attributes it to neither. In + // the pull build MGB_CTX is the live GLContext, there is no poison to bypass and the + // frontend getter still linear-scans, so the cache is exactly the code it was. static inline FbBindingSlot& GetFramebufferBindingSlotChecked(FramebufferTarget target) { #if MOBILEGL_PIPE_PUSH - return MGB_CTX->GetFramebufferBindingSlot(target); -#else + if (EsprytSlotTablesEnabled()) { + return MGB_CTX->GetFramebufferBindingSlot(target); + } +#endif +#if !MOBILEGL_PIPE_PUSH || MOBILEGL_PIPE_LEGACY_MEMOS const void* ctx = MGB_CTX_IDENTITY; if (ctx != g_fbSlotCacheContext) { auto& live = *MGB_CTX; @@ -174,6 +182,9 @@ namespace MobileGL::MG_Backend::DirectGLES { g_fbSlotCacheContext = ctx; } return *g_fbSlotCache[SizeT(target)]; +#else + // No legacy arm compiled: EsprytSlotTablesEnabled() is unconditionally true above. + return MGB_CTX->GetFramebufferBindingSlot(target); #endif } @@ -3007,9 +3018,11 @@ namespace MobileGL::MG_Backend::DirectGLES { FramebufferImpl::BackendFramebufferObject* twin = nullptr; #if MOBILEGL_PIPE_PUSH if (EsprytSlotTablesEnabled()) { - auto* slot = FramebufferImpl::g_backendFramebufferObjects.Find(currentFBO.get()); - if (slot && *slot) { - twin = slot->get(); + // Not "slot": the enclosing scope's `slot` is the framebuffer BINDING slot, + // and this one is the twin table's entry. + auto* twinEntry = FramebufferImpl::g_backendFramebufferObjects.Find(currentFBO.get()); + if (twinEntry && *twinEntry) { + twin = twinEntry->get(); } } else #endif @@ -10156,6 +10169,15 @@ namespace MobileGL::MG_Backend::DirectGLES { static Bool InitDisplayAndContext(EGLint surfaceBit, NativeWindowType window = static_cast(0)) { DestroyEGLContext(); +#if MOBILEGL_PIPE_PUSH + // Resolve the twin-table arm HERE, at backend startup, rather than leaving it to the + // first twin lookup deep inside the first draw: Fatal{PipeLegacyMemosDisabled} has to + // reach an operator who set MOBILEGL_PIPE_PUSH and MOBILEGL_PIPE_LEGACY_MEMOS into a + // combination that leaves no arm at all, including in a process that goes on to twin + // nothing. The call is idempotent and latched. + (void)EsprytSlotTablesEnabled(); +#endif + g_Display = g_EGLFuncs.eglGetDisplay(EGL_DEFAULT_DISPLAY); if (g_Display == EGL_NO_DISPLAY) return false; diff --git a/MobileGL/MG_Backend/DirectGLES/Managers.cpp b/MobileGL/MG_Backend/DirectGLES/Managers.cpp index 7171a2a0..b6dfca5c 100644 --- a/MobileGL/MG_Backend/DirectGLES/Managers.cpp +++ b/MobileGL/MG_Backend/DirectGLES/Managers.cpp @@ -171,20 +171,30 @@ namespace MobileGL::MG_Backend::DirectGLES { } #if MOBILEGL_PIPE_PUSH - Bool EsprytSlotTablesEnabled() { - // Latched once, not read per call: the two arms of StateBackendObjectRegistry keep - // their twins in different containers, so an answer that changed mid-run would strand - // every twin already built (and, for the driver ids those twins own, leak them). - static const Bool enabled = [] { + Bool ResolveEsprytSlotTablesArm() { + // Resolved once and latched by the inline EsprytSlotTablesEnabled() in SlotTables.h: + // the two arms of StateBackendObjectRegistry keep their twins in different containers, + // so an answer that changed mid-run would strand every twin already built (and, for + // the driver ids those twins own, leak them). InitDisplayAndContext() forces the + // resolution at backend context creation, so the trap below fires before the first + // draw rather than on the first twin lookup - a short-lived process that never twins + // anything used to never learn its knobs left it with no arm at all. + { const Bool bitSet = (MG_Config::Features.PipePush & MG_Pipe::kMGPipeSubsystemEsprytSlots) != 0; #if MOBILEGL_PIPE_LEGACY_MEMOS if (!bitSet && !MG_Config::Features.PipeLegacyMemos) { // The operator asked for the handle arm to be OFF and the legacy arm to be - // unreachable at the same time, which leaves no arm at all. Say so at startup - // rather than silently running the thing they turned off (ARCHITECTURE.md 9.6). + // unreachable at the same time, which leaves no arm at all. This is a Fatal{}, + // and a Fatal{} in this codebase STOPS (MG_Impl/Pipe/PipeFill.cpp's BadKnob and + // its verify trap are both MGLOG_F + abort). Returning here instead would run + // the very arm the operator disabled and hand back a green result measured on + // it - which is exactly the lever HandleRecycleScenario's arms are selected + // with, so a mis-set A/B would be scored silently against the wrong arm + // (ARCHITECTURE.md 9.6). MGLOG_F("MGPipe: Fatal{PipeLegacyMemosDisabled, \"kMGPipeSubsystemEsprytSlots " "is clear but MOBILEGL_PIPE_LEGACY_MEMOS=0\"}"); + std::abort(); } return bitSet; #else @@ -196,8 +206,7 @@ namespace MobileGL::MG_Backend::DirectGLES { } return true; #endif - }(); - return enabled; + } } #endif @@ -2775,7 +2784,7 @@ namespace MobileGL::MG_Backend::DirectGLES { return true; } - StateBackendObjectRegistry + TwinRegistry g_backendVertexArrayObjects; } // namespace VertexArrayImpl @@ -5103,7 +5112,7 @@ namespace MobileGL::MG_Backend::DirectGLES { Array, MG_State::GLState::TextureState::MAX_TEXTURE_IMAGE_UNITS> g_boundTexturesCache; - StateBackendObjectRegistry g_backendTextureObjects; + TwinRegistry g_backendTextureObjects; } // namespace TextureImpl namespace FramebufferImpl { @@ -5856,7 +5865,7 @@ namespace MobileGL::MG_Backend::DirectGLES { return m_backendColorSlots[index]; } - StateBackendObjectRegistry + TwinRegistry g_backendFramebufferObjects; Array g_fboSyncedSlotVersions = {0}; // Tracks the bound FBO's object version (bumped on any attachment/drawbuffer change) @@ -6167,7 +6176,7 @@ namespace MobileGL::MG_Backend::DirectGLES { // context never answers GL_NO_ERROR, and the build runs on the thread that would // then spin forever. constexpr Int kMaxDrainedProgramErrors = 32; - StateBackendObjectRegistry g_backendProgramObjects; + TwinRegistry g_backendProgramObjects; BackendProgramObjectImpl::BackendProgramObjectImpl() { #ifdef TRACY_ENABLE @@ -8685,7 +8694,7 @@ namespace MobileGL::MG_Backend::DirectGLES { } Array g_boundSamplersCache; - StateBackendObjectRegistry g_backendSamplerObjects; + TwinRegistry g_backendSamplerObjects; } // namespace SamplerImpl namespace RenderbufferImpl { @@ -8797,7 +8806,7 @@ namespace MobileGL::MG_Backend::DirectGLES { MGLOG_D("RBO %u sync completed. backend ID %u", stateRBOObject->GetExternalIndex(), m_backendRBOId); } - StateBackendObjectRegistry + TwinRegistry g_backendRenderbufferObjects; } // namespace RenderbufferImpl } // namespace MobileGL::MG_Backend::DirectGLES diff --git a/MobileGL/MG_Backend/DirectGLES/Managers.h b/MobileGL/MG_Backend/DirectGLES/Managers.h index ce17d7a6..61edde38 100644 --- a/MobileGL/MG_Backend/DirectGLES/Managers.h +++ b/MobileGL/MG_Backend/DirectGLES/Managers.h @@ -285,14 +285,15 @@ namespace MobileGL::MG_Backend::DirectGLES { // The kind is a template parameter ONLY in the push build. G1 requires the pull build's // symbol set to be byte-for-byte the pre-P2 one, and a third template argument changes // every instantiation's mangled name - so in the pull build the parameter, like the arm it - // selects, does not exist. MGB_TWIN_KIND_ARG spells the same thing at the six declarations - // and six definitions. + // selects, does not exist. The macro below spells that one difference; it is #undef'd + // straight after the class, and the twelve declaration and definition sites name the + // registry through the TwinRegistry alias instead, which swallows the kind in the pull + // build. (An alias template may have a parameter it does not use, and an alias emits no + // symbol of its own, so the pull build's mangled names are unchanged.) #if MOBILEGL_PIPE_PUSH #define MGB_TWIN_KIND_PARAM , MG_Pipe::MGPipeKind kKind -#define MGB_TWIN_KIND_ARG(kind) , kind #else #define MGB_TWIN_KIND_PARAM -#define MGB_TWIN_KIND_ARG(kind) #endif template @@ -499,6 +500,19 @@ namespace MobileGL::MG_Backend::DirectGLES { #endif }; +#undef MGB_TWIN_KIND_PARAM + + // One spelling for the twin registry at every declaration and definition site. In the push + // build the kind is the registry's third template argument; in the pull build the alias + // drops it, so the mangled name is the pre-P2 two-argument one. +#if MOBILEGL_PIPE_PUSH + template + using TwinRegistry = StateBackendObjectRegistry; +#else + template + using TwinRegistry = StateBackendObjectRegistry; +#endif + namespace BufferImpl { const GLenum TempBufferTarget = GL_ARRAY_BUFFER; @@ -902,7 +916,7 @@ namespace MobileGL::MG_Backend::DirectGLES { Uint64 m_syncedBufferIdGeneration = 0; }; - extern StateBackendObjectRegistry + extern TwinRegistry g_backendVertexArrayObjects; // Shadowed glBindVertexArray: every backend VAO bind goes through here so a @@ -1223,7 +1237,7 @@ namespace MobileGL::MG_Backend::DirectGLES { void ActivateTextureUnit(Uint unit); void UnbindTexture(Uint unit, GLenum target); - extern StateBackendObjectRegistry + extern TwinRegistry g_backendTextureObjects; SharedPtr& SyncTextureObjectToBackend( const SharedPtr& textureObject, @@ -1314,7 +1328,7 @@ namespace MobileGL::MG_Backend::DirectGLES { Uint64 m_syncedBackendIdGeneration = 0; }; - extern StateBackendObjectRegistry + extern TwinRegistry g_backendFramebufferObjects; // True when the read buffer names a fixed-point (norm/snorm) attachment that the // backend actually stores in a floating-point format. GL clamps a read from a @@ -1832,7 +1846,7 @@ namespace MobileGL::MG_Backend::DirectGLES { // skip redundant rebinds. Reset to 0 wherever glUseProgram(0) is issued or the // ES context is recreated. extern Uint g_lastUsedBackendProgramId; - extern StateBackendObjectRegistry + extern TwinRegistry g_backendProgramObjects; // Points one shader storage block of an ALREADY-LINKED backend program at @@ -1932,7 +1946,7 @@ namespace MobileGL::MG_Backend::DirectGLES { extern Array g_boundSamplersCache; - extern StateBackendObjectRegistry + extern TwinRegistry g_backendSamplerObjects; } // namespace SamplerImpl @@ -1959,7 +1973,7 @@ namespace MobileGL::MG_Backend::DirectGLES { Int m_cacheSamples = 0; }; - extern StateBackendObjectRegistry + extern TwinRegistry g_backendRenderbufferObjects; } // namespace RenderbufferImpl } // namespace MobileGL::MG_Backend::DirectGLES diff --git a/MobileGL/MG_Backend/DirectGLES/SlotTables.h b/MobileGL/MG_Backend/DirectGLES/SlotTables.h index 187d2ce5..61e40b73 100644 --- a/MobileGL/MG_Backend/DirectGLES/SlotTables.h +++ b/MobileGL/MG_Backend/DirectGLES/SlotTables.h @@ -44,13 +44,44 @@ // what bumps Gen, which is precisely the ABA defence: a slot is only ever handed out again // after it was freed. When e2 lands, ReclaimDeadSlots() becomes the fallback path of an // explicit Destroy(handle) and the sweep call sites go away. +// +// Because the sweep is still the only death signal, this table carries BOTH of the drivers +// the registry it replaces carries, and for the same reasons: +// * the draw-path tick (kGCInterval = 1024 CollectGarbageIfNeeded calls), and +// * the CREATION tick (kCreationGCInterval = 64 first-time insertions), because object CHURN +// rather than draw count is what makes the sweep urgent - a CTS-shaped case runs ~10 +// per-draw ticks, so 1024 of them span ~100 cases' worth of dead, gigabyte-sized objects. +// Dropping the second one would have made this table's memory behaviour strictly WORSE than +// the map it replaces, which is the opposite of what the slice is for. +// +// P3+ DEBT, recorded rather than hidden: this header is under MG_Backend/ and it MINTS +// handles (MGPipeSlots().Acquire below) off a frontend SharedPtr's GetLifetimeId(). +// MGPipeHandles.h:13-16 says a handle is minted by the CLIENT and never by the server, and +// under a real split neither the frontend object nor its lifetime id exists on this side of +// the wire. This is monolith glue: the minting and the lifetimeId -> handle resolution both +// belong on the client, and the backend should receive the handle in the verb payload. It is +// NOT part of "Track H done" and check_include_closure.py does not probe MG_Backend headers, +// so nothing catches it automatically. namespace MobileGL::MG_Backend::DirectGLES { #if MOBILEGL_PIPE_PUSH + // Reads the config, logs, and traps when the operator left no arm at all. Cold: called + // exactly once per process, from the latch below and from backend context creation. + Bool ResolveEsprytSlotTablesArm(); + // True when this process runs the {slot, gen} arm. Fixed for the life of the process: the // two arms hold their twins in different containers, so flipping mid-run would strand them. - Bool EsprytSlotTablesEnabled(); + // + // INLINE on purpose. Every Find / GetOrCreate / HandleOf / ForEachLive / CollectGarbage* + // on the twin tables consults it, i.e. it is on the per-draw path several times per draw. + // As an out-of-line function in Managers.cpp (no LTO in any shipped configuration) that was + // a call through the PLT per lookup; here the caller sees a guard-variable load and a + // perfectly-predicted branch, and the arm dispatch folds into the caller. + inline Bool EsprytSlotTablesEnabled() { + static const Bool enabled = ResolveEsprytSlotTablesArm(); + return enabled; + } template class BackendSlotTable { @@ -75,13 +106,34 @@ namespace MobileGL::MG_Backend::DirectGLES { // object's lifetime id, so two calls for the same live object always land on the same // slot, and a successor object at the same heap address never does. BackendPtr& GetOrCreate(const StatePtr& stateObj) { - MOBILEGL_ASSERT(stateObj != nullptr, "State object must not be null"); + // No assert on null here, unlike the map arm: null is TOLERATED, so a DEBUG build + // must not trap where the release build quietly does the documented thing. + if (stateObj == nullptr) { + // The registry this replaces inserted a null key and handed back ITS twin slot + // (DirectGLES.cpp's SyncTextureObjectToBackend documents relying on exactly + // that tolerance), so a release build never dereferenced null here. Keep the + // shape: one per-table parking slot, never live, never swept, never handed a + // handle. A null object has no identity and therefore cannot have a twin. + m_nullTwin.reset(); + return m_nullTwin; + } + + // Sweep BEFORE the entry reference below exists, for the same reason the map arm + // does it here: EntryAt may grow m_slots and move every element, so a reference + // taken first would not survive it. The sweep is owed from an earlier creation + // rather than triggered by this one. + if (m_creationTick >= kCreationGCInterval) { + m_creationTick = 0; + ReclaimDeadSlots(); + } + const MG_Pipe::MGPipeHandle handle = MG_Pipe::MGPipeSlots().Acquire(kKind, stateObj->GetLifetimeId()); MOBILEGL_ASSERT(!MG_Pipe::MGPipeHandleIsNull(handle), "MGPipe slot space of kind %u is exhausted", static_cast(kKind)); Entry& entry = EntryAt(handle.Slot); + const Bool firstInsertion = !entry.Live || entry.Gen != handle.Gen; if (entry.Live && entry.Gen != handle.Gen) { // The slot was reclaimed and handed to a new object: the twin at it describes // driver ids the new state object never made. @@ -90,6 +142,20 @@ namespace MobileGL::MG_Backend::DirectGLES { entry.Gen = handle.Gen; entry.Live = true; entry.stateRef = stateObj; + if (firstInsertion) { + // A slot this table has never held (or held for a previous owner). Nothing + // tells the backend that a texture or renderbuffer was DELETED - the twin, and + // the driver storage it owns, lives until a collection - and + // CollectGarbageIfNeeded is ticked only from the per-draw sync paths, which a + // CTS-shaped workload runs about ten times per case. 1024 of those ticks then + // span ~100 cases, so ~100 cases' worth of dead (and, for this suite, + // gigabyte-sized) objects would stay allocated at once. Object CHURN rather + // than draw count is what makes the sweep urgent, so a twin the table has + // never seen ticks it too - and it does so on the path that is about to + // allocate, which is exactly when the memory is needed. + ++m_creationTick; + } + RememberHandle(stateObj->GetLifetimeId(), handle); return entry.backend; } @@ -118,7 +184,12 @@ namespace MobileGL::MG_Backend::DirectGLES { // memo stores instead of a raw pointer, a GL name or a bare lifetime id. MG_Pipe::MGPipeHandle HandleOf(const StateObject* stateObj) const { if (stateObj == nullptr) return MG_Pipe::kMGPipeNullHandle; - return MG_Pipe::MGPipeSlots().FindByLifetimeId(kKind, stateObj->GetLifetimeId()); + const Uint64 lifetimeId = stateObj->GetLifetimeId(); + if (lifetimeId == m_memoLifetimeId) return m_memoHandle; + const MG_Pipe::MGPipeHandle handle = + MG_Pipe::MGPipeSlots().FindByLifetimeId(kKind, lifetimeId); + RememberHandle(lifetimeId, handle); + return handle; } // Drop the twin of every slot whose frontend object is gone and return the slot to the @@ -127,13 +198,25 @@ namespace MobileGL::MG_Backend::DirectGLES { if (m_isCollecting) return; m_isCollecting = true; for (SizeT slot = 0; slot < m_slots.size(); ++slot) { - Entry& entry = m_slots[slot]; - if (!entry.Live || !entry.stateRef.expired()) continue; - entry.backend.reset(); - entry.stateRef.reset(); - entry.Live = false; + Uint32 gen = 0; + // The twin's destructor is a driver call and could, in principle, re-enter + // GetOrCreate on this table and resize m_slots. So NOTHING that outlives the + // destructor may be a reference into m_slots: the twin is moved out into a + // local, the entry is finished with, and only then is the local released. + BackendPtr dead; + { + Entry& entry = m_slots[slot]; + if (!entry.Live || !entry.stateRef.expired()) continue; + gen = entry.Gen; + dead = std::move(entry.backend); + entry.backend.reset(); + entry.stateRef.reset(); + entry.Live = false; + } MG_Pipe::MGPipeSlots().Free( - kKind, MG_Pipe::MGPipeHandle{static_cast(slot), entry.Gen}); + kKind, MG_Pipe::MGPipeHandle{static_cast(slot), gen}); + if (m_memoHandle.Slot == static_cast(slot)) ForgetHandle(); + dead.reset(); } m_isCollecting = false; } @@ -142,10 +225,20 @@ namespace MobileGL::MG_Backend::DirectGLES { ++m_gcTick; if (m_gcTick < kGCInterval) return; m_gcTick = 0; + m_creationTick = 0; ReclaimDeadSlots(); } - void CollectGarbageNow() { ReclaimDeadSlots(); } + void CollectGarbageNow() { + m_creationTick = 0; + ReclaimDeadSlots(); + } + + // Test-only introspection: how many first-time insertions are owed before the + // creation-driven sweep fires. Reading it is what lets a test pin the CADENCE rather + // than only the effect of an explicit CollectGarbageNow(). + Uint32 CreationTickForTest() const { return m_creationTick; } + static constexpr Uint32 CreationGCIntervalForTest() { return kCreationGCInterval; } // fn(const StatePtr& state, const BackendPtr& twin) over every live, still-owned entry. // Replaces the registry's begin()/end(), whose iterator exposed the raw frontend @@ -176,12 +269,45 @@ namespace MobileGL::MG_Backend::DirectGLES { return m_slots[slot]; } + void RememberHandle(Uint64 lifetimeId, MG_Pipe::MGPipeHandle handle) const { + m_memoLifetimeId = lifetimeId; + m_memoHandle = handle; + } + void ForgetHandle() const { + m_memoLifetimeId = 0; + m_memoHandle = MG_Pipe::kMGPipeNullHandle; + } + static constexpr Uint32 kGCInterval = 1024; + // Creations are far rarer than draws, so this counts in a much smaller unit than + // kGCInterval does. Same value the map arm uses, so the two arms sweep at the same + // cadence under the same workload. + static constexpr Uint32 kCreationGCInterval = 64; // Indexed by MGPipeHandle::Slot; [0] is the reserved slot and is never live. Vector m_slots; Uint32 m_gcTick = 0; + Uint32 m_creationTick = 0; Bool m_isCollecting = false; + // Handed back by GetOrCreate for a null state object. Never live, never swept. + BackendPtr m_nullTwin; + + // ONE-entry resolution memo, lifetimeId -> handle. The three per-draw resolution paths + // (ResolveVaoTwin, SyncCurrentProgram, BindCurrentFBO) ask the SAME table for the SAME + // object every draw, so this turns the steady state back into an integer compare plus + // one array index - which is what the deleted TwinLookupMemos bought and what D13 + // promises ("direct slot indexing - the memo existed only to avoid the hash probe"). + // Without it every resolution went through the allocator's ByLifetimeId hash. + // + // It cannot serve a stale answer, by two independent arguments: + // * the key is a lifetime id, which MG_State never hands out twice, so a recycled + // heap address cannot hit this memo the way it could hit an address-keyed one; and + // * even a hit for a slot that has since been freed and re-handed is caught, because + // the caller resolves the handle through FindByHandle, which compares Gen. + // Cleared anyway when the sweep frees the memoised slot. 0 is never a live lifetime id + // (MG_State's counters start at 1), so a zeroed memo is a guaranteed miss. + mutable Uint64 m_memoLifetimeId = 0; + mutable MG_Pipe::MGPipeHandle m_memoHandle = MG_Pipe::kMGPipeNullHandle; }; #endif // MOBILEGL_PIPE_PUSH