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