From 9be5d95440248e628461b0c5aaf9b379e5d69405 Mon Sep 17 00:00:00 2001 From: BZLZHH Date: Thu, 6 Aug 2026 11:20:52 -0400 Subject: [PATCH] [Perf] (MG_Backend): give DirectGLES unit bindings an epoch the sampler churn cannot fake The texture-binding memos added earlier keyed on the frontend texture-bind generation, and 26.2-style unit switching defeats them: glBindSampler bumps the generation even when it re-binds the sampler the unit already carries, so a frame that cycles active units re-ran the full two-pass, eleven-slot alias resolution and the unbind walks on every draw. mc_sampler_churn sat at 1674 ns/op against the native driver's 239 - the worst multiplier left on this backend - with about half the time in two virtual calls per binding slot. The units now carry an epoch: a snapshot of each touched unit's slot objects and sampler object, compared by weak_ptr OWNERSHIP rather than raw pointer - a held weak_ptr pins its control block, so a freed-and-recycled object can never owner-equal its predecessor, which is the ABA hole a pointer key would have and the reason version keying was rejected (WithTemporarilyBoundNamedTexture bumps slot versions without touching the bind generation). The (context id, bind generation, high-water mark) triple gates the snapshot walk to at most once per draw; the epoch moves only when a binding really changed. Both per-draw memos key on the epoch plus the sampling-resolution generation, which carries what the epoch cannot see: a default texture's image appearing, and every completeness input. Two smaller memos ride along: the per-unit sampler-registry lookup (owner-keyed, misses never cached - the backend object may be created later in the same draw), and the pending-vertex-attribute mask, whose first version scanned all 32 slots and put +10% on the VAO-cycling case before being restricted to the program's active locations. ns per op, DriverBench on a GTX 1660 SUPER, isolated A/B, all nine cases on both backends: mc_sampler_churn 1673 -> 732, mc_use_program 4513 -> 4279, mc_state_toggle 2365 -> 2247, everything else within noise and nothing worse. 7.0x native to 3.1x on the churn case. Unit tests 421/421. --- MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp | 220 +++++++++++++++--- 1 file changed, 190 insertions(+), 30 deletions(-) diff --git a/MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp b/MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp index 3b1e42da..ea14020c 100644 --- a/MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp +++ b/MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp @@ -28,6 +28,7 @@ #include #include #include +#include #include #include #include @@ -51,6 +52,14 @@ namespace MobileGL::MG_Backend::DirectGLES { static SharedPtr g_rawDepthFetchSamplerState; static SharedPtr g_rawDepthFetchSamplerBackend; + // Two objects are the same binding iff they share a control block. Raw addresses lie + // (a freed object's heap slot is reused), but a held weak_ptr pins the control block, + // so no later object can ever owner-equal a snapshot of its predecessor. + template + static Bool OwnerEquals(const WeakPtr& snapshot, const SharedPtr& current) { + return !snapshot.owner_before(current) && !current.owner_before(snapshot); + } + static Bool IsDualSourceBlendFactor(BlendFactor v) { switch (v) { case BlendFactor::Src1Color: @@ -780,11 +789,35 @@ namespace MobileGL::MG_Backend::DirectGLES { const Uint32 activeAttribMask = program->GetActiveAttributeLocationMask(); if (activeAttribMask == 0) return; - constexpr Uint32 maxVertexAttribs = - static_cast(MG_State::GLState::VertexArrayObject::MAX_VERTEX_ATTRIBS); - for (Uint32 location = 0; location < maxVertexAttribs; ++location) { - if ((activeAttribMask & (1u << location)) == 0) continue; - if (vao->GetAttribute(location).Enabled) continue; + // Which of the program's attributes lack an enabled array cannot change without + // the VAO's config version moving (Enable/DisableAttribute bump it), the VAO + // changing, or the program's active mask changing, so the per-draw Enabled probes + // reduce to three compares. The rebuild visits only the ACTIVE locations - a VAO + // switch per draw (vanilla cycles section VAOs) pays the same probes the unmemoed + // walk did, not a full 32-slot scan. Owner-equality, not the raw pointer: a freed + // VAO's heap slot can be reused, and the held weak_ptr pins the control block so + // a successor can never alias it. + static WeakPtr s_pendingMaskVao; + static Uint32 s_pendingMaskConfigVersion = 0; + static Uint32 s_pendingMaskActiveMask = 0; + static Uint32 s_pendingMask = 0; + const Uint32 configVersion = vao->GetConfigVersion(); + if (configVersion != s_pendingMaskConfigVersion || activeAttribMask != s_pendingMaskActiveMask || + !OwnerEquals(s_pendingMaskVao, vao)) { + Uint32 pending = 0; + for (Uint32 remaining = activeAttribMask; remaining != 0; remaining &= remaining - 1) { + const Uint32 location = static_cast(std::countr_zero(remaining)); + if (!vao->GetAttribute(location).Enabled) pending |= (1u << location); + } + s_pendingMaskVao = vao; + s_pendingMaskConfigVersion = configVersion; + s_pendingMaskActiveMask = activeAttribMask; + s_pendingMask = pending; + } + if (s_pendingMask == 0) return; + + for (Uint32 remaining = s_pendingMask; remaining != 0; remaining &= remaining - 1) { + const Uint32 location = static_cast(std::countr_zero(remaining)); const auto& currentValue = MG_State::pGLContext->GetCurrentVertexAttribute(location); const auto typeInfo = MG_State::GLState::ClassifyVertexAttribType(program->GetAttribType(location)); @@ -831,10 +864,92 @@ namespace MobileGL::MG_Backend::DirectGLES { return backendObj; } + // Identity snapshot of what one texture unit has bound: the object in every binding + // slot plus the unit's sampler object. This - not the context's texture bind + // generation - is what the per-draw texture memos key on: the generation also bumps + // on REDUNDANT re-binds (glBindSampler of the sampler the unit already carries, which + // 26.2 issues around every texture-unit switch), so a generation-keyed memo re-derives + // everything on draws that changed nothing. The snapshot compares the bindings + // themselves, so only a real change can invalidate it. What it deliberately does NOT + // cover, and its users must key on separately: + // * membership/completeness flips with no binding moving - a default texture's image + // appearing or vanishing, any texture shape or sampler parameter change - all bump + // the sampling-resolution generation (BumpShapeVersion / SamplerObject::BumpVersion + // are their only writers and bump it unconditionally); + // * everything unit bindings say nothing about: the touched-unit high-water mark, + // the frontend context identity, the backend ES context generation, and (for the + // resolution memo) the program keys that arbitrate aliased targets. + struct UnitBindingsSnapshot { + Array, (SizeT)TextureTarget::TextureTargetCount> + slotObjects{}; + WeakPtr samplerObject{}; + }; + + static void CaptureUnitBindings(Int maxTouchedUnit, Vector& out) { + out.resize(static_cast(maxTouchedUnit + 1)); + for (Int unit = 0; unit <= maxTouchedUnit; ++unit) { + auto& textureUnit = MG_State::pGLContext->GetTextureUnitObject(unit); + auto& snapshot = out[static_cast(unit)]; + const auto& slots = textureUnit.GetAllBindingSlots(); + for (SizeT i = 0; i < slots.size(); ++i) { + snapshot.slotObjects[i] = slots[i].GetBoundObject(); + } + snapshot.samplerObject = textureUnit.GetSamplerObject(); + } + } + + static Bool UnitBindingsUnchanged(Int maxTouchedUnit, const Vector& snapshots) { + if (snapshots.size() != static_cast(maxTouchedUnit + 1)) return false; + for (Int unit = 0; unit <= maxTouchedUnit; ++unit) { + auto& textureUnit = MG_State::pGLContext->GetTextureUnitObject(unit); + const auto& snapshot = snapshots[static_cast(unit)]; + const auto& slots = textureUnit.GetAllBindingSlots(); + for (SizeT i = 0; i < slots.size(); ++i) { + if (!OwnerEquals(snapshot.slotObjects[i], slots[i].GetBoundObject())) return false; + } + if (!OwnerEquals(snapshot.samplerObject, textureUnit.GetSamplerObject())) return false; + } + return true; + } + + static Vector g_observedUnitBindings; + static Uint64 g_observedUnitBindingsContextId = 0; + static Uint64 g_observedUnitBindingsGeneration = 0; + static Int g_observedUnitBindingsMaxUnit = -1; + static Uint64 g_unitBindingsEpoch = 0; + + // Epoch of the touched units' bindings: moves exactly when WHAT is bound changes, + // never on a redundant re-bind. Memos snapshot the returned value instead of holding + // their own UnitBindingsSnapshot, so the owner-compare walk runs at most once per + // draw no matter how many memos key on it. The observation triple gates the walk: + // while (context id, bind generation, high-water mark) are unchanged nothing can + // have moved, because every path that changes a binding bumps the generation. A + // given epoch value names one observed (context, high-water mark, bindings) state - + // a context switch, a high-water-mark move or a bindings change each recapture and + // bump - so epoch equality alone proves the bindings a consumer resolved against + // are the bindings on the units now. + static Uint64 CurrentUnitBindingsEpoch(Int maxTouchedUnit) { + const Uint64 contextId = MG_State::pGLContext->GetTextureContextId(); + const Uint64 bindGeneration = MG_State::pGLContext->GetTextureBindGeneration(); + if (g_observedUnitBindingsContextId == contextId && g_observedUnitBindingsMaxUnit == maxTouchedUnit && + g_observedUnitBindingsGeneration == bindGeneration) { + return g_unitBindingsEpoch; + } + if (g_observedUnitBindingsContextId != contextId || g_observedUnitBindingsMaxUnit != maxTouchedUnit || + !UnitBindingsUnchanged(maxTouchedUnit, g_observedUnitBindings)) { + CaptureUnitBindings(maxTouchedUnit, g_observedUnitBindings); + ++g_unitBindingsEpoch; + } + g_observedUnitBindingsContextId = contextId; + g_observedUnitBindingsMaxUnit = maxTouchedUnit; + g_observedUnitBindingsGeneration = bindGeneration; + return g_unitBindingsEpoch; + } + // Work list behind SyncNeccessaryTextures' per-draw unit walk. WHICH textures the // touched units hold is a pure function of the unit bindings, so the GLContext identity // (a never-reused id, not the heap address a recreated context can land on again), the - // texture bind generation and the touched-unit high-water mark are a complete key - + // unit-bindings snapshot and the touched-unit high-water mark are a complete key - // WHAT each entry then has to do is still decided per draw by the version compares // inside the sync calls, which is why texture content, shape and parameter changes need // no key here. @@ -854,9 +969,10 @@ namespace MobileGL::MG_Backend::DirectGLES { static Vector g_unitTextureSyncList; static Bool g_unitTextureSyncListValid = false; static Uint64 g_unitTextureSyncListContextId = 0; - static Uint64 g_unitTextureSyncListBindGeneration = 0; static Int g_unitTextureSyncListMaxUnit = -1; static Uint g_unitTextureSyncListContextGeneration = 0; + static Uint64 g_unitTextureSyncListEpoch = 0; + static Uint64 g_unitTextureSyncListSamplingGeneration = 0; // Sibling memo for the draw FBO's texture attachments (see the use site in // SyncNeccessaryTextures for the key derivation and the borrow rules, which are the @@ -881,12 +997,19 @@ namespace MobileGL::MG_Backend::DirectGLES { // Units past the frontend's high-water mark have provably-empty slots. const Int maxTouchedUnit = MG_State::pGLContext->GetMaxTouchedTextureUnit(); - const Uint64 bindGeneration = MG_State::pGLContext->GetTextureBindGeneration(); + const Uint64 samplingGeneration = MG_State::pGLContext->GetSamplingResolutionGeneration(); + const Uint64 unitBindingsEpoch = CurrentUnitBindingsEpoch(maxTouchedUnit); + // The epoch survives redundant re-binds; the sampling-resolution generation + // covers the one membership input the epoch cannot see - a default texture's + // image appearing or vanishing flips IsUndefinedDefaultTexture with no binding + // moving. Its cost is a spare rebuild whenever any texture shape or sampler + // parameter actually changes, which real frames do at load time, not per draw. if (g_unitTextureSyncListValid && g_unitTextureSyncListContextId == MG_State::pGLContext->GetTextureContextId() && - g_unitTextureSyncListBindGeneration == bindGeneration && g_unitTextureSyncListMaxUnit == maxTouchedUnit && - g_unitTextureSyncListContextGeneration == g_textureContextGeneration) { + g_unitTextureSyncListContextGeneration == g_textureContextGeneration && + g_unitTextureSyncListEpoch == unitBindingsEpoch && + g_unitTextureSyncListSamplingGeneration == samplingGeneration) { for (const auto& entry : g_unitTextureSyncList) { entry.backend->SyncTextureParamsToBackend(*entry.slot); entry.backend->SyncBuiltinSamplerToBackend(*entry.slot); @@ -908,9 +1031,10 @@ namespace MobileGL::MG_Backend::DirectGLES { } } g_unitTextureSyncListContextId = MG_State::pGLContext->GetTextureContextId(); - g_unitTextureSyncListBindGeneration = bindGeneration; g_unitTextureSyncListMaxUnit = maxTouchedUnit; g_unitTextureSyncListContextGeneration = g_textureContextGeneration; + g_unitTextureSyncListEpoch = unitBindingsEpoch; + g_unitTextureSyncListSamplingGeneration = samplingGeneration; g_unitTextureSyncListValid = true; } @@ -1831,6 +1955,38 @@ namespace MobileGL::MG_Backend::DirectGLES { return fullyResolved; } + // Per-unit memo of the sampler-registry lookup, NOT of the resulting unit binding + // (BackendSamplerObject::Bind already dedups against g_boundSamplersCache). The registry + // pairs a live frontend sampler with one backend object for the frontend's whole + // lifetime: GetOrCreate replaces a pairing only when the frontend expired, entries are + // erased only when the frontend expired, and a unit's sampler cannot expire while the + // unit holds it - so owner-equality of the unit's current sampler against the snapshot + // proves the cached pointer is exactly what Find would return. A miss result is never + // cached: the backend object may not exist yet when the unit pass runs (the program + // pass creates it later in the same draw), and a cached miss would keep skipping the + // bind after it appears. + struct UnitSamplerLookupMemo { + WeakPtr frontend{}; + SamplerImpl::BackendSamplerObject* backend = nullptr; + }; + static Array + g_unitSamplerLookupMemos; + + static SamplerImpl::BackendSamplerObject* ResolveUnitSamplerBackend( + Int unit, const SharedPtr& samplerObject) { + auto& memo = g_unitSamplerLookupMemos[static_cast(unit)]; + if (memo.backend && OwnerEquals(memo.frontend, samplerObject)) { + return memo.backend; + } + auto* backendSamplerSlot = SamplerImpl::g_backendSamplerObjects.Find(samplerObject.get()); + if (backendSamplerSlot && *backendSamplerSlot) { + memo.frontend = samplerObject; + memo.backend = backendSamplerSlot->get(); + return memo.backend; + } + return nullptr; + } + // Puts each touched unit's frontend sampler object on the backend unit. Deliberately // NOT part of the memo below: BindCurrentProgramWithResources rewrites the sampler of // every unit its program samples right after this runs (raw-depth-fetch substitution, @@ -1841,9 +1997,8 @@ namespace MobileGL::MG_Backend::DirectGLES { for (Int unit = 0; unit <= maxTouchedUnit; ++unit) { const auto& samplerObject = MG_State::pGLContext->GetTextureUnitObject(unit).GetSamplerObject(); if (samplerObject) { - auto* backendSamplerSlot = SamplerImpl::g_backendSamplerObjects.Find(samplerObject.get()); - if (backendSamplerSlot && *backendSamplerSlot) { - (*backendSamplerSlot)->Bind(unit); + if (auto* backendSampler = ResolveUnitSamplerBackend(unit, samplerObject)) { + backendSampler->Bind(unit); } } else { // Symmetric with the bind above: a sampler object left on the unit by an earlier @@ -1862,10 +2017,14 @@ namespace MobileGL::MG_Backend::DirectGLES { // ResolveAndBindUnitTextures is a pure function of: // * the GLContext identity - a never-reused id, since the counters below restart at 0 in // a new context and a recreated one can land on the old heap address; - // * its texture bind generation - glBindTexture, glBindTextureUnit, glBindTextures, - // glBindSampler, the delete-unbind in MarkTextureObjectForDeletion, the sampler swap in - // TextureUnit::SetSamplerObject (which is how glDeleteSamplers unbinds), and a default - // texture gaining or losing an image all bump it; + // * the per-unit bindings - which texture object every slot holds and which sampler + // object each unit carries, keyed as TextureImpl::CurrentUnitBindingsEpoch rather + // than as the texture bind generation: the generation also bumps on redundant + // re-binds (26.2 re-binds the unit's own sampler around every texture-unit switch), + // which would force a full re-resolution per draw for an answer that cannot have + // moved. The one input the old generation key covered that the epoch does not - a + // default texture gaining or losing an image with no bind moving - bumps the + // sampling-resolution generation below (SetInternalFormat -> BumpShapeVersion); // * the touched-unit high-water mark (units above it have provably-empty slots); // * the program that arbitrates two real textures aliased onto one native target - // identity, plus the lifetime id because a freed program can be replaced at the same @@ -1890,7 +2049,7 @@ namespace MobileGL::MG_Backend::DirectGLES { Bool valid = false; Uint64 glContextId = 0; Int maxTouchedUnit = -1; - Uint64 bindGeneration = 0; + Uint64 unitBindingsEpoch = 0; Uint64 samplingResolutionGeneration = 0; const void* program = nullptr; Uint64 programLifetimeId = 0; @@ -1919,9 +2078,10 @@ namespace MobileGL::MG_Backend::DirectGLES { auto& memo = g_resolvedTextureBindingMemo; const SizeT shadowBytes = static_cast(maxTouchedUnit + 1) * sizeof(TextureImpl::g_boundTexturesCache[0]); + const Uint64 unitBindingsEpoch = TextureImpl::CurrentUnitBindingsEpoch(maxTouchedUnit); const Bool keysMatch = memo.valid && memo.glContextId == MG_State::pGLContext->GetTextureContextId() && memo.maxTouchedUnit == maxTouchedUnit && - memo.bindGeneration == MG_State::pGLContext->GetTextureBindGeneration() && + memo.unitBindingsEpoch == unitBindingsEpoch && memo.samplingResolutionGeneration == MG_State::pGLContext->GetSamplingResolutionGeneration() && memo.program == static_cast(currentProgram.get()) && @@ -1938,7 +2098,7 @@ namespace MobileGL::MG_Backend::DirectGLES { if (ResolveAndBindUnitTextures(currentProgram, maxTouchedUnit)) { memo.glContextId = MG_State::pGLContext->GetTextureContextId(); memo.maxTouchedUnit = maxTouchedUnit; - memo.bindGeneration = MG_State::pGLContext->GetTextureBindGeneration(); + memo.unitBindingsEpoch = unitBindingsEpoch; memo.samplingResolutionGeneration = MG_State::pGLContext->GetSamplingResolutionGeneration(); memo.program = currentProgram.get(); memo.programLifetimeId = currentProgram ? currentProgram->GetLifetimeId() : 0; @@ -2119,19 +2279,19 @@ namespace MobileGL::MG_Backend::DirectGLES { GetRawDepthFetchSampler()->Bind(unit); MGLOG_D("Using raw depth fetch sampler on unit %d.", unit); } else if (samplerObject) { - auto* backendSamplerSlot = - SamplerImpl::g_backendSamplerObjects.Find(samplerObject.get()); - auto& backendObj = backendSamplerSlot - ? *backendSamplerSlot - : SamplerImpl::g_backendSamplerObjects.GetOrCreate(samplerObject); - if (!backendObj) { - backendObj = MakeShared(); + auto* backendSampler = ResolveUnitSamplerBackend(unit, samplerObject); + if (!backendSampler) { + auto& backendObj = SamplerImpl::g_backendSamplerObjects.GetOrCreate(samplerObject); + if (!backendObj) { + backendObj = MakeShared(); + } + backendSampler = backendObj.get(); } - backendObj->SyncToBackend(samplerObject); + backendSampler->SyncToBackend(samplerObject); // Syncing the object's parameters is not the same as putting it on the // unit: without this the driver kept sampling with the texture's own // parameters and every sampler object was inert. - backendObj->Bind(unit); + backendSampler->Bind(unit); } else { SamplerImpl::UnbindSampler(unit); }