diff --git a/CMakeLists.txt b/CMakeLists.txt index 11c728d0..c0d0c9f9 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -298,6 +298,7 @@ set(SOURCE_FILES MobileGL/MG_State/GLState/ProgramState/ShaderCompileTask.cpp MobileGL/MG_State/GLState/ProgramState/ShaderObject.cpp MobileGL/MG_State/GLState/ProgramState/ShaderPreprocessCache.cpp + MobileGL/MG_State/GLState/ProgramState/ShaderCompileAdoptionMap.cpp MobileGL/MG_State/GLState/ProgramState/ProgramState.cpp MobileGL/MG_State/GLState/RenderState/RenderState.cpp MobileGL/MG_State/GLState/FramebufferState/FramebufferObject.cpp diff --git a/MobileGL/MG_State/GLState/Core.h b/MobileGL/MG_State/GLState/Core.h index 02d1dba4..dc7e72f9 100644 --- a/MobileGL/MG_State/GLState/Core.h +++ b/MobileGL/MG_State/GLState/Core.h @@ -156,6 +156,11 @@ namespace MobileGL { // Settles every compile and link this context still owns; see // ProgramState::JoinAllPendingWork. Called by glMaxShaderCompilerThreadsKHR(0). void JoinAllPendingShaderWork(); + // P1 stage 6: the per-context index of adoptable compile nodes, for its + // adoption counter. Diagnostics and tests only - no GL entry point reads it. + ShaderCompileAdoptionMap& GetShaderCompileAdoptionMap() { + return m_programState.GetShaderCompileAdoptionMap(); + } void UseProgram(Uint program); const SharedPtr& GetCurrentProgram(); // What a draw or dispatch actually executes: the program in use, or - when diff --git a/MobileGL/MG_State/GLState/ProgramState/ProgramState.cpp b/MobileGL/MG_State/GLState/ProgramState/ProgramState.cpp index 5a7d980c..5b28acec 100644 --- a/MobileGL/MG_State/GLState/ProgramState/ProgramState.cpp +++ b/MobileGL/MG_State/GLState/ProgramState/ProgramState.cpp @@ -87,7 +87,8 @@ namespace MobileGL::MG_State::GLState { Uint shaderId = 0; m_programShaderNameGenerator.Generate(1, &shaderId); EnsureIndexAvail(shaderId, m_shaderObjects); - auto shaderObject = MakeShared(stage, shaderId, m_shaderPreprocessCache); + auto shaderObject = + MakeShared(stage, shaderId, m_shaderPreprocessCache, m_shaderCompileAdoptionMap); if (shaderObject == nullptr) return 0; m_shaderObjects[shaderId] = shaderObject; return shaderId; @@ -153,11 +154,13 @@ namespace MobileGL::MG_State::GLState { auto& shaderObject = m_shaderObjects[shader]; if (shaderObject == nullptr || !shaderObject->GetDeleteStatus()) return; if (ShaderHasGLVisibleAttachment(shaderObject)) return; - // The name is about to go: nothing can observe this shader's compile any more, so a - // job still in flight for it is pure waste. Cancel-not-join - the job owns its - // inputs, so dropping the object out from under it is safe and the GL thread never - // blocks on a delete. - shaderObject->CancelCompile(); + // The name is about to go, so nothing can observe this shader's compile through THIS + // object any more and a job still in flight for it is pure waste - unless another + // shader object adopted the same node (stage 6) or a pending link pinned it, which is + // exactly what ReleaseCompileNode weighs before it cancels anything. Cancel-not-join + // either way: the job owns its inputs, so dropping the object out from under it is + // safe and the GL thread never blocks on a delete. + shaderObject->ReleaseCompileNode(); shaderObject.reset(); m_programShaderNameGenerator.Delete(shader); } diff --git a/MobileGL/MG_State/GLState/ProgramState/ProgramState.h b/MobileGL/MG_State/GLState/ProgramState/ProgramState.h index 425acd5e..f02b28ae 100644 --- a/MobileGL/MG_State/GLState/ProgramState/ProgramState.h +++ b/MobileGL/MG_State/GLState/ProgramState/ProgramState.h @@ -10,6 +10,7 @@ #include #include #include "ProgramObject.h" +#include "ShaderCompileAdoptionMap.h" #include "ShaderPreprocessCache.h" namespace MobileGL::MG_State::GLState { @@ -52,6 +53,11 @@ namespace MobileGL::MG_State::GLState { // CreateShader(). ShaderPreprocessCache& GetShaderPreprocessCache() { return *m_shaderPreprocessCache; } + // P1 stage 6, same deal: exposed for tests and diagnostics only. Its adoption counter + // is the one number that says how many glCompileShader calls this context turned into + // no work at all; nothing in the GL frontend branches on it. + ShaderCompileAdoptionMap& GetShaderCompileAdoptionMap() { return *m_shaderCompileAdoptionMap; } + private: Bool ShaderHasGLVisibleAttachment(const SharedPtr& shaderObject) const; // Frees the name slot and releases orphaned attached shaders; the immediate half @@ -82,6 +88,11 @@ namespace MobileGL::MG_State::GLState { // in-flight compile job may outlive the context). The FIRST-member declaration is // kept anyway - it costs nothing and documents the intent. SharedPtr m_shaderPreprocessCache = MakeShared(); + // P1 stage 6: the GL-thread-only index of adoptable compile nodes. Shared ownership + // for the same reason as the cache above - a ShaderObject held by a ProgramObject can + // outlive these tables, and its destructor releases a node - though unlike the cache + // no worker ever sees this one, which is why it carries no lock. + SharedPtr m_shaderCompileAdoptionMap = MakeShared(); Vector> m_programObjects; Vector> m_shaderObjects; diff --git a/MobileGL/MG_State/GLState/ProgramState/ShaderCompileAdoptionMap.cpp b/MobileGL/MG_State/GLState/ProgramState/ShaderCompileAdoptionMap.cpp new file mode 100644 index 00000000..b4f38401 --- /dev/null +++ b/MobileGL/MG_State/GLState/ProgramState/ShaderCompileAdoptionMap.cpp @@ -0,0 +1,90 @@ +// MobileGL - MobileGL/MG_State/GLState/ProgramState/ShaderCompileAdoptionMap.cpp +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header + +#include "ShaderCompileAdoptionMap.h" + +#include "ShaderCompileTask.h" + +namespace MobileGL::MG_State::GLState { + SharedPtr ShaderCompileAdoptionMap::FindAdoptable(const ShaderStage stage, + const Uint64 sourceHash, const String& source, + const Uint64 envFingerprint) { + const ShaderSourceKey key{.stage = stage, + .sourceHash = sourceHash, + .sourceLength = source.length(), + .envFingerprint = envFingerprint}; + + const auto it = m_entries.find(key); + if (it == m_entries.end()) return nullptr; + + SharedPtr node = it->second.lock(); + // Expired (every shader object that held it has released it), settled as Cancelled + // (the enqueue lost a race with teardown, or the body threw), or CANCELLATION + // REQUESTED but not yet settled (a releaser fired Cancel() while a worker was still + // inside RunBody(), so the node is stuck at Running until the body returns - see + // JobNode::Run: once m_cancelled is set, the node is DOOMED to end up Cancelled no + // matter how the body finishes, it just has not gotten there yet). All three can + // never publish artifacts a caller may rely on, so all three are misses. Only the + // first two are dead weight worth pruning from the index here - a cancellation- + // requested-but-still-running node is still reachable from its own (about to + // release) ShaderObject and will get pruned once it actually settles, so leave the + // entry alone and just refuse to hand this node out. + if (!node || node->IsCancelled()) { + m_entries.erase(it); + return nullptr; + } + if (node->IsCancellationRequested()) return nullptr; + + // Never let correctness ride on a 64-bit hash. Lengths already matched (they are part + // of the key), so this is a plain memcmp - and it is the ONLY thing that authorizes + // two GL shader names to share one compile. + if (*node->source != source) return nullptr; + + ++m_adoptionCount; + return node; + } + + void ShaderCompileAdoptionMap::Register(const SharedPtr& node) { + if (!node) return; + + SweepIfCrowded(); + // operator[] rather than a find/insert pair: an existing entry for this key is either + // a re-registration of the same source (the previous node expired or was cancelled) + // or an astronomically rare hash collision. The newcomer wins in both cases. + m_entries[ShaderSourceKey{.stage = node->stage, + .sourceHash = node->sourceHash, + .sourceLength = node->source->length(), + .envFingerprint = node->env->fingerprint}] = node; + } + + void ShaderCompileAdoptionMap::Clear() { + m_entries.clear(); + m_sweepThreshold = kMinSweepThreshold; + } + + void ShaderCompileAdoptionMap::SweepIfCrowded() { + if (m_entries.size() < m_sweepThreshold) return; + + // Collect first, erase after: FastSTL::unordered_map is open-addressed, so erasing + // through an iterator that the same loop is still advancing is not worth reasoning + // about on a path this cold. + Vector dead; + for (const auto& entry : m_entries) { + const SharedPtr node = entry.second.lock(); + if (!node || node->IsCancelled()) dead.push_back(entry.first); + } + for (const ShaderSourceKey& key : dead) { + m_entries.erase(key); + } + + // Amortization: after a sweep the map holds exactly the nodes still reachable from + // some shader object, so letting it double before the next sweep makes the whole + // scheme O(1) per Register() while keeping the map O(live nodes). + m_sweepThreshold = std::max(kMinSweepThreshold, m_entries.size() * 2); + } +} // namespace MobileGL::MG_State::GLState diff --git a/MobileGL/MG_State/GLState/ProgramState/ShaderCompileAdoptionMap.h b/MobileGL/MG_State/GLState/ProgramState/ShaderCompileAdoptionMap.h new file mode 100644 index 00000000..682ae3e3 --- /dev/null +++ b/MobileGL/MG_State/GLState/ProgramState/ShaderCompileAdoptionMap.h @@ -0,0 +1,97 @@ +// MobileGL - MobileGL/MG_State/GLState/ProgramState/ShaderCompileAdoptionMap.h +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header + +#pragma once +#include +#include + +namespace MobileGL::MG_State::GLState { + class ShaderCompileTask; + + // P1 stage 6: the per-context index of compile job nodes that a NEW shader object may + // adopt instead of enqueueing a duplicate of. + // + // Why it is not the P0b preprocess cache. That cache only helps once a compile has + // FINISHED - it memoizes the source-only half of the pipeline, and a worker consults it + // from inside the job body. Under asynchronous compilation the dominant shape is + // different: a shaderpack load hands N different shader objects byte-identical source + // within the same GL-thread burst (measured across bsl/complementary/bliss, ~21% of all + // Compile() calls are such cross-object duplicates), and all N are enqueued before any of + // them completes. Every one of those workers then misses the cache, runs the whole + // pipeline, and races the others to insert the same entry. This map closes that window on + // the GL thread, at enqueue: the second object through takes the FIRST object's node. + // + // What "adopt" means: the two shader objects end up holding the same SharedPtr in their + // m_compiled. They are two distinct GL names with two distinct info-log/COMPILE_STATUS + // queries, but both queries read one set of artifacts - which is exactly right, because + // the pipeline is a pure function of the key below and the full source text. Nothing is + // copied and no worker ever waits (P1 invariant I4 is untouched: this only ever REMOVES + // work from the pool). The single consume-once resource, the glslang parse, is already + // guarded for sharing by ShaderCompileTask::ClaimParsedShader's CAS, which stage 4 built + // for exactly this shape - one node, several links. + // + // ---- Threading: GL thread only, and therefore lock-free ---- + // Every entry point below is reached from glCompileShader (ShaderObject::Compile) and + // from nowhere else. That is one GL entry point on the application's context thread, so + // the map needs no mutex, unlike the preprocess cache which several workers hit at once. + // The weak pointers are the ONLY thing this class stores, precisely so it can never keep + // a node - or the artifacts a node owns - alive past its last real holder. + // + // ---- Lifetime and pruning ---- + // WeakPtr, never SharedPtr: the map is an index, not an owner. An entry whose node has + // been released by every shader object simply expires, and a node that was CANCELLED + // carries no result at all, so both are treated as misses and pruned where they are + // found. Pruning is otherwise amortized: Register() sweeps the whole map whenever it has + // grown past twice its size at the last sweep, which bounds the map at O(live nodes) + // without a per-call cost. + class ShaderCompileAdoptionMap { + public: + // Never sweep below this: a shaderpack burst is a few hundred distinct sources, and + // an entry is a key plus a weak pointer. + static constexpr SizeT kMinSweepThreshold = 256; + + // The adoptable node for this exact source under this exact environment, or null. + // + // A hit is honored only after the FULL source text has been compared byte for byte + // against the candidate node's own snapshot: the hash in the key is a lookup + // accelerator, never the answer (ShaderSourceKey). A node that has settled as + // Cancelled is never handed out - it published nothing, so adopting it would give the + // new object a compile that can never report anything but GL_FALSE. Nor is a node + // whose cancellation has merely been REQUESTED but not yet settled (still Running, + // with IsCancellationRequested() true): JobNode::Run forces such a node to Cancelled + // the moment its body returns regardless of how the body finished, so it is already + // doomed and handing it out would just move the same GL_FALSE-with-no-log outcome to + // a second, unrelated shader object. + // + // A COMPLETED node is adoptable, and deliberately so: the new object gets the right + // answer for zero work, which is the same deal the P0b cache offers one layer down. + SharedPtr FindAdoptable(ShaderStage stage, Uint64 sourceHash, const String& source, + Uint64 envFingerprint); + + // Indexes `node` as the adoptable one for its key. A key already present is + // overwritten: the newcomer is at least as fresh as whatever was there, and one entry + // per key keeps this a plain map. + void Register(const SharedPtr& node); + + void Clear(); + + // ---- diagnostics only; nothing in the GL frontend branches on these ---- + // Monotonic count of nodes handed out by FindAdoptable, i.e. of glCompileShader calls + // that did NOT enqueue a job because an equivalent one already existed. Tests read it + // as a delta across a burst. + Uint64 GetAdoptionCount() const { return m_adoptionCount; } + SizeT GetEntryCount() const { return m_entries.size(); } + + private: + void SweepIfCrowded(); + + UnorderedMap, ShaderSourceKeyHasher> m_entries; + SizeT m_sweepThreshold = kMinSweepThreshold; + Uint64 m_adoptionCount = 0; + }; +} // namespace MobileGL::MG_State::GLState diff --git a/MobileGL/MG_State/GLState/ProgramState/ShaderCompileTask.h b/MobileGL/MG_State/GLState/ProgramState/ShaderCompileTask.h index 5518a0e9..1d2353c5 100644 --- a/MobileGL/MG_State/GLState/ProgramState/ShaderCompileTask.h +++ b/MobileGL/MG_State/GLState/ProgramState/ShaderCompileTask.h @@ -118,13 +118,61 @@ namespace MobileGL::MG_State::GLState { // glDeleteShader. The detach makes the shader GL-invisible, so the delete frees its // name, and ReleaseShaderNameIfOrphaned would cancel a compile the enqueued link is // waiting on - turning a link that must report GL_TRUE into GL_FALSE. Set on the GL - // thread in Link()'s prologue, read on the GL thread by ShaderObject::CancelCompile. + // thread in Link()'s prologue, read on the GL thread by + // ShaderObject::ReleaseCompileNode - which from stage 6 weighs it together with the + // adopter count below, because a node can now have both kinds of observer at once. // // Never cleared: the worst case is one stale node compiling to completion for nobody, // which is exactly what the pre-stage-3 implementation always did. void MarkLinkReferenced() { m_linkReferenced.store(true, std::memory_order_release); } Bool IsLinkReferenced() const { return m_linkReferenced.load(std::memory_order_acquire); } + // ---- P1 stage 6: the adopter count ---- + // How many live ShaderObjects currently hold this node in their m_compiled. + // + // It exists because stage 6 lets a node be SHARED: before it, a node had exactly one + // shader object, so "this object stopped caring" and "nothing can observe this + // result" were the same statement and ShaderObject::CancelCompile could cancel + // unconditionally. Once two GL shader names hold one node, that cancel would kill the + // other one's pending compile - a compile that must still report GL_TRUE. So a cancel + // is now authorized by TWO conditions, both checked by the releaser: + // * this release brings the count to zero (no shader object is left), AND + // * IsLinkReferenced() is false (no enqueued link took the node into its snapshot). + // The second is the stage-4 pin, unchanged; the first is what stage 6 adds. + // + // ---- Why a plain Int and not an atomic ---- + // Every mutation is made from ShaderObject, and every ShaderObject mutation site is a + // GL entry point on the application's context thread: glCompileShader (adopt/create), + // glShaderSource with different text, glDeleteShader's orphan sweep, and + // ~ShaderObject. All of them are the SAME thread, so the count is never concurrently + // mutated and an atomic would only buy an unneeded lock prefix on the hottest compile + // path. Workers cannot touch it by construction: a job body's entire contract (see + // this class's header comment) is that it reads only the node's inputs and writes only + // `artifacts`, and a plain Int here makes that contract grep-checkable in a way an + // atomic would quietly hide. + // + // The CANCEL that the count authorizes still races the worker, and deliberately so - + // that is the settled cancel-not-join semantics from stage 3: JobNode::Cancel is + // cooperative and non-blocking, a node already running settles as Cancelled when its + // body returns, and a node that has already gone terminal ignores the request. + // Nothing about that changes here. + // + // Exactness under that race: ShaderObject::ReleaseCompileNode returns EARLY, without + // decrementing and without dropping its reference, when the node is already terminal + // (there is nothing left to stop). Terminality is sticky, so if a releaser observes a + // node as NON-terminal then no holder has ever taken that early return on it, and the + // count it reads is exactly the number of holders. If the worker finishes in the + // window between that observation and the Cancel(), the Cancel is a no-op on a + // terminal node - and the count was zero, so there was no other holder to harm. + void AddAdopter() { ++m_adopters; } + void ReleaseAdopter() { + MOBILEGL_ASSERT(m_adopters > 0, + "ShaderCompileTask adopter count underflow; a ShaderObject released a node it did not " + "hold (every release must pair with exactly one AddAdopter)"); + --m_adopters; + } + Int AdopterCount() const { return m_adopters; } + private: void RunBody() override; // The real body; RunBody wraps it so a throw becomes a GL-visible compile failure. @@ -132,5 +180,7 @@ namespace MobileGL::MG_State::GLState { mutable std::atomic m_parseClaimed{false}; std::atomic m_linkReferenced{false}; + // GL-thread-owned; see AddAdopter above for why this is not an atomic. + Int m_adopters = 0; }; } // namespace MobileGL::MG_State::GLState diff --git a/MobileGL/MG_State/GLState/ProgramState/ShaderObject.cpp b/MobileGL/MG_State/GLState/ProgramState/ShaderObject.cpp index 2cbf55c3..dc3d1ac4 100644 --- a/MobileGL/MG_State/GLState/ProgramState/ShaderObject.cpp +++ b/MobileGL/MG_State/GLState/ProgramState/ShaderObject.cpp @@ -26,16 +26,18 @@ namespace MobileGL::MG_State::GLState { // reason: it is computing the right answer for text this object still holds. if (SourceMatchesCompiledState(source)) return; // The text genuinely changed, so whatever a running job is computing is now about - // an old source. Drop it where it stands - it owns its own copy of that old string, - // so swapping the pointer below cannot race its storage. - CancelCompile(); + // an old source. Give up our claim on it - it owns its own copy of that old string, + // so swapping the pointer below cannot race its storage. Note "our claim", not "the + // job": another shader object may have adopted the same node and still be waiting for + // exactly this answer, which is what ReleaseCompileNode's count discipline protects. + ReleaseCompileNode(); m_source = MakeShared(source); InvalidateCompiledState(); } void ShaderObject::SetShaderSource(String&& source) { if (SourceMatchesCompiledState(source)) return; - CancelCompile(); + ReleaseCompileNode(); m_source = MakeShared(Move(source)); InvalidateCompiledState(); } @@ -59,33 +61,75 @@ namespace MobileGL::MG_State::GLState { // Errors and worker-side log lines are raised HERE, on the GL thread, at the first // join of the job that produced them - which for a single shader is trivially the // order a serial implementation would have produced them in. + // + // ApplyDeferredDiagnostics DRAINS, so a node shared by several shader objects + // (stage 6) replays its worker-side log line exactly once, at whichever object joins + // first. That is the honest report - one compile ran - and it is log text only: the + // GL-observable half of a failure, COMPILE_STATUS and the info log, lives in + // `artifacts` and every sharer reads the identical copy of it. MG_Util::Async::ApplyDeferredDiagnostics(*m_compiled); // A node that settled as Cancelled published nothing. Dropping it here is what keeps // the object's state machine to two reachable cases - "no job" and "a job that // completed" - so every reader below can treat a live node as authoritative. - if (!m_compiled->IsComplete()) m_compiled.reset(); + // + // Through DropCompileNode, not a bare reset: this object is letting the node go, so + // its adopter slot has to go with it. A node shared with another object stays alive + // and gets dropped once more when that object joins - once per holder, never twice + // for the same one, because DropCompileNode is null-guarded. + if (!m_compiled->IsComplete()) DropCompileNode(); + } + + void ShaderObject::AdoptCompileNode(SharedPtr node) const { + // Never overwrite a hold without giving its slot back first. + DropCompileNode(); + m_compiled = Move(node); + m_compiled->AddAdopter(); + // Re-arm the join gate: whether this node was just created or just adopted from + // another object, THIS object has not pulled its result yet. (An adopted node may + // already be terminal - the join then only replays what is left of its diagnostics.) + m_compileJoined = false; + } + + void ShaderObject::DropCompileNode() const { + if (!m_compiled) return; + m_compiled->ReleaseAdopter(); + m_compiled.reset(); } void ShaderObject::InvalidateCompiledState() { // The job node holds exactly what one Compile() produces, so discarding it IS the // invalidation - and it re-arms nothing, so the next Compile() genuinely recompiles. - m_compiled.reset(); + DropCompileNode(); } - void ShaderObject::CancelCompile() { - if (!m_compiled || m_compiled->IsTerminal()) return; - // Cooperative and non-blocking. A node that no worker has picked up settles - // immediately; one that is running is flagged and settles when its body returns, - // writing only into itself the whole time. + void ShaderObject::ReleaseCompileNode() { + if (!m_compiled) return; + // Already terminal: there is nothing left to stop, so this is not a release at all - + // the node and this object's claim on it both stay. That early return is older than + // stage 6 and it is load-bearing: ProgramState::ReleaseShaderNameIfOrphaned calls + // this on a shader whose name is going away but whose object a ProgramObject may + // still hold, and dropping a COMPLETED compile there would turn that program's link + // into GL_FALSE. + if (m_compiled->IsTerminal()) return; + // Two independent claimants have to be checked before a cancel, and this object is + // authorized to cancel only if BOTH say the result has become unobservable. // - // Unless a pending LINK is waiting on it. Cancelling is about discarding a result - // nothing can observe any more, and this object is no longer the only route to this - // one: an enqueued ProgramLinkTask holds the node as a dependency, and a cancel would - // turn its link into GL_FALSE. Reached by the ordinary link-then-detach-then-delete - // shader teardown - see ShaderCompileTask::MarkLinkReferenced. Dropping our own - // reference is still right; the link keeps the node alive and finishes it. - if (!m_compiled->IsLinkReferenced()) m_compiled->Cancel(); - m_compiled.reset(); + // 1. Other shader objects. From stage 6 a node can be SHARED by several GL shader + // names that were handed byte-identical source; cancelling here would turn a + // compile they must still see as GL_TRUE into GL_FALSE. Only the releaser that + // takes the count to zero - i.e. the last holder - may cancel. See + // ShaderCompileTask::AddAdopter for why a plain Int is sound here and for the + // exactness argument under the worker race. + // 2. A pending LINK. An enqueued ProgramLinkTask holds the node in its input snapshot + // and a cancel would turn its link into GL_FALSE; reached by the ordinary + // link-then-detach-then-delete shader teardown. See MarkLinkReferenced. Never + // cleared, so this is a one-way pin. + // + // The cancel itself is cooperative and non-blocking, exactly as before: a node no + // worker has picked up settles immediately, a running one is flagged and settles when + // its body returns, writing only into itself the whole time. + if (m_compiled->AdopterCount() == 1 && !m_compiled->IsLinkReferenced()) m_compiled->Cancel(); + DropCompileNode(); } void ShaderObject::Compile() { @@ -103,14 +147,6 @@ namespace MobileGL::MG_State::GLState { // source instead. Same result, one parse either way. if (HasMemoizedCompile()) return; - // The compile-environment snapshot is taken HERE, on the GL thread, and handed to - // the job. Everything the pipeline needs to know about the device comes through it, - // never through pActiveBackendObject - that is what makes the body movable. - m_compiled = MakeShared(m_stage, m_source, ShaderPreprocessCache::HashSource(*m_source), - MG_Util::ShaderTranspiler::GetCurrentCompileEnv(), - m_preprocessCache, m_externalIndex); - m_compileJoined = false; - // Two reasons to stay on this thread, one rule. Without the async flag the whole // path must be byte-identical to the synchronous implementation, and a cache-less // object is an internal shader that compiles and reads its status in the same @@ -119,7 +155,48 @@ namespace MobileGL::MG_State::GLState { // has to put compilation back on this thread even though the extension is still // advertised, and that is exactly what makes the GL_COMPLETION_STATUS_KHR the // extension mandates after a zero count (immediately GL_TRUE) fall out for free. - if (!m_preprocessCache || !MG_Util::Async::AsyncShaderCompileActive()) { + // + // Hoisted above the node construction because stage 6 keys off it too: this same + // answer decides whether the adoption map is consulted at all, so a + // glMaxShaderCompilerThreadsKHR(0) and a flag-off build both bypass sharing exactly + // as they bypass the pool, and their behaviour stays byte-identical to pre-stage-6. + const Bool runOnPool = m_preprocessCache && MG_Util::Async::AsyncShaderCompileActive(); + + // The compile-environment snapshot is taken HERE, on the GL thread, and handed to + // the job. Everything the pipeline needs to know about the device comes through it, + // never through pActiveBackendObject - that is what makes the body movable. + const SharedPtr env = + MG_Util::ShaderTranspiler::GetCurrentCompileEnv(); + const Uint64 sourceHash = ShaderPreprocessCache::HashSource(*m_source); + + // ---- P1 stage 6: adopt an equivalent compile instead of enqueueing a duplicate ---- + // ~21% of all glCompileShader calls in the shaderpack corpus are a DIFFERENT shader + // object handed byte-identical source. P0b's memo only pays off once one of them has + // finished; under async they are all enqueued in the same burst, so without this each + // one runs the whole pipeline on its own worker. The map hands back the node the + // first of them created - in flight or already complete - and this object simply + // holds it too. + if (runOnPool && m_adoptionMap) { + if (SharedPtr shared = + m_adoptionMap->FindAdoptable(m_stage, sourceHash, *m_source, env->fingerprint)) { + // Take the node's own source snapshot as ours. FindAdoptable just compared + // the two strings in full, so this changes nothing observable - but it is not + // optional: the layer-1 memo (HasMemoizedCompile) is a POINTER comparison + // against the node's snapshot, so leaving our own equal-but-distinct copy in + // place would make the very next glCompileShader on this object decide it had + // no memo and enqueue the duplicate this whole stage exists to avoid - and + // would make an identical glShaderSource re-source cancel a shared compile. + // It also collapses N copies of a ~100 KB shaderpack stage into one. + m_source = shared->source; + AdoptCompileNode(Move(shared)); + return; + } + } + + AdoptCompileNode(MakeShared(m_stage, m_source, sourceHash, env, m_preprocessCache, + m_externalIndex)); + + if (!runOnPool) { m_compiled->RunInline(); // Inline means the node is already terminal, so this join only replays // diagnostics; it is here so the synchronous and asynchronous paths publish @@ -127,6 +204,10 @@ namespace MobileGL::MG_State::GLState { EnsureCompileJoined(); return; } + // Registered BEFORE the post, so the very next glCompileShader in this burst can + // adopt it however fast a worker picks it up. Registration is an index entry only - + // the map holds a WeakPtr and never keeps a node alive. + if (m_adoptionMap) m_adoptionMap->Register(m_compiled); MG_Util::Async::ShaderCompilePool::Get().Post(m_compiled); } diff --git a/MobileGL/MG_State/GLState/ProgramState/ShaderObject.h b/MobileGL/MG_State/GLState/ProgramState/ShaderObject.h index bbe2814f..d6c8d159 100644 --- a/MobileGL/MG_State/GLState/ProgramState/ShaderObject.h +++ b/MobileGL/MG_State/GLState/ProgramState/ShaderObject.h @@ -10,6 +10,7 @@ #include #include #include +#include namespace MobileGL { namespace MG_State::GLState { @@ -31,13 +32,32 @@ namespace MobileGL { // add a round trip. Shared ownership rather than a raw pointer: a compile job // outlives neither the object nor the context deterministically, and the cache // has to stay alive for whoever is still reading it. + // + // `adoptionMap` is the same context's stage-6 index of adoptable compile nodes. + // It is non-null exactly when `preprocessCache` is (ProgramState hands both out + // together, and nobody else hands out either), which is what makes "no cache" + // keep meaning "compile inline, share nothing": an internal shader object has + // neither, so it neither adopts nor registers and its path is byte-identical to + // the pre-stage-6 one. GL-thread-only, so unlike the cache it carries no lock - + // shared ownership only because a ShaderObject may outlive the context's tables. ShaderObject(const ShaderStage stage, Uint externalIndex, - SharedPtr preprocessCache = nullptr) - : m_stage(stage), m_externalIndex(externalIndex), m_preprocessCache(Move(preprocessCache)) {} + SharedPtr preprocessCache = nullptr, + SharedPtr adoptionMap = nullptr) + : m_stage(stage), m_externalIndex(externalIndex), m_preprocessCache(Move(preprocessCache)), + m_adoptionMap(Move(adoptionMap)) {} // Cancel-not-join: the node owns its inputs, so an in-flight compile whose // object just went away is safe to abandon where it stands. Nothing can observe - // its result any more - this object was the only route to it. - ~ShaderObject() { CancelCompile(); } + // its result any more - unless another shader object adopted the same node, or a + // link pinned it, which is precisely what ReleaseCompileNode() checks. + ~ShaderObject() { + ReleaseCompileNode(); + // ReleaseCompileNode KEEPS a node that has already gone terminal - there is + // nothing left to stop, so it is not a release at all. This object is going + // away regardless, so hand the adopter slot back here. That is what keeps + // ShaderCompileTask::AdopterCount() exactly "how many live ShaderObjects hold + // this node" instead of merely an upper bound. + DropCompileNode(); + } ShaderObject(const ShaderObject&) = delete; ShaderObject& operator=(const ShaderObject&) = delete; @@ -45,10 +65,17 @@ namespace MobileGL { void SetShaderSource(const String& source); void SetShaderSource(String&& source); void Compile(); - // Drops a compile that is still in flight, without waiting for it. Called at the - // points where the object's compiled state stops being observable: a real source - // change, and the release of an orphaned shader name. - void CancelCompile(); + // Gives up this object's claim on its compile node, cancelling the node only if + // this object was its LAST claimant. Called at the points where the object's + // compiled state stops being observable through THIS name: a real source change, + // and the release of an orphaned shader name. + // + // Named for what it does rather than for what it used to do: before stage 6 a + // node had exactly one shader object, so giving up the claim and cancelling the + // compile were the same act and this was CancelCompile(). They are not the same + // act any more - see ShaderCompileTask::AddAdopter for the count discipline and + // its single-threadedness argument. Never waits, in either case. + void ReleaseCompileNode(); void MarkAsDeleted(); // The compile job node itself, for ProgramObject::Link()'s input snapshot. @@ -146,6 +173,16 @@ namespace MobileGL { } void InvalidateCompiledState(); + + // ---- the ONLY two writers of m_compiled (P1 stage 6) ---- + // Every adopter-count mutation lives in these two, which is what makes "exactly + // one AddAdopter per hold, exactly one ReleaseAdopter per hold" auditable rather + // than something review has to re-derive at each call site. DropCompileNode is + // null-guarded, so calling it on an object that already let go is a no-op and a + // double release is unrepresentable. + void AdoptCompileNode(SharedPtr node) const; + void DropCompileNode() const; + // ---- P0b layer 1: per-object no-op recompile ---- // True iff `candidate` is byte-identical to the source that produced (or is // producing) the compiled state this object currently holds. @@ -165,17 +202,31 @@ namespace MobileGL { // running compile cannot race its storage - and the layer-1 memo collapses to a // pointer comparison against the job's snapshot, because the setter only swaps // the pointer when the text genuinely differs. + // + // Not necessarily unique to this object from stage 6 on: adopting a node also + // takes that node's source snapshot (see Compile()), so N shader objects sharing + // one compile share one copy of the text. The string is immutable and shared- + // owned, so that is invisible to every reader. SharedPtr m_source = EmptySource(); // P0b layer 2: the owning context's cross-object memo, or null. Internally // locked, because several workers hit it at once. const SharedPtr m_preprocessCache; + // P1 stage 6: the owning context's index of adoptable compile nodes, or null. + // Touched only from Compile(), i.e. only on the GL thread, so it carries no lock. + const SharedPtr m_adoptionMap; Bool m_deleteStatus = false; // ---- Compile OUTPUT ---- pending OR completed; reachable only through Compiled(). // Mutable because the join is a read-side operation: a const getter has to be // able to settle an outstanding job before answering. + // + // SHARED from stage 6 on: several shader objects holding byte-identical source + // under the same CompileEnv point at one node. Every read below still goes + // through the same join gate, and a second joiner finds the node already + // terminal, so nothing about the read path changes - only the release path does + // (ReleaseCompileNode). mutable SharedPtr m_compiled; // Exactly-once latch for the pull above. Armed with every new job node, set by // the one join that consumes it. diff --git a/MobileGL/MG_State/GLState/ProgramState/ShaderPreprocessCache.h b/MobileGL/MG_State/GLState/ProgramState/ShaderPreprocessCache.h index 14cce385..6a5c8416 100644 --- a/MobileGL/MG_State/GLState/ProgramState/ShaderPreprocessCache.h +++ b/MobileGL/MG_State/GLState/ProgramState/ShaderPreprocessCache.h @@ -13,6 +13,7 @@ // Deliberately NOT ShaderObject.h: ShaderCompileTask.h needs this header, and ShaderObject.h // needs ShaderCompileTask.h. Only ShaderStage was ever used from there. #include +#include namespace MobileGL::MG_State::GLState { // Where the shared, source-only half of ShaderObject::Compile() stopped. The two @@ -112,29 +113,10 @@ namespace MobileGL::MG_State::GLState { } private: - struct Key { - ShaderStage stage = ShaderStage::Unknown; - Uint64 sourceHash = 0; - SizeT sourceLength = 0; - Uint64 envFingerprint = 0; - - Bool operator==(const Key& other) const { - return stage == other.stage && sourceHash == other.sourceHash && - sourceLength == other.sourceLength && envFingerprint == other.envFingerprint; - } - }; - - struct KeyHasher { - SizeT operator()(const Key& key) const { - // The source hash already spreads well; fold the two discriminators in so - // that same-hash-different-stage/length keys land in different buckets. - Uint64 mixed = key.sourceHash; - mixed ^= static_cast(key.sourceLength) + 0x9e3779b97f4a7c15ull + (mixed << 6) + (mixed >> 2); - mixed ^= static_cast(static_cast(key.stage)) * 0xff51afd7ed558ccdull; - mixed ^= key.envFingerprint + 0x9e3779b97f4a7c15ull + (mixed << 6) + (mixed >> 2); - return static_cast(mixed); - } - }; + // Shared with ShaderCompileAdoptionMap so the two per-context memos cannot key + // themselves on different notions of "the same compile" - see ShaderSourceKey.h. + using Key = ShaderSourceKey; + using KeyHasher = ShaderSourceKeyHasher; struct Entry { Key key; diff --git a/MobileGL/MG_State/GLState/ProgramState/ShaderSourceKey.h b/MobileGL/MG_State/GLState/ProgramState/ShaderSourceKey.h new file mode 100644 index 00000000..ec93fce1 --- /dev/null +++ b/MobileGL/MG_State/GLState/ProgramState/ShaderSourceKey.h @@ -0,0 +1,51 @@ +// MobileGL - MobileGL/MG_State/GLState/ProgramState/ShaderSourceKey.h +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header + +#pragma once +#include +#include + +namespace MobileGL::MG_State::GLState { + // The identity of "one glCompileShader's worth of input" - the tuple that decides + // whether two compiles must produce byte-identical results. Shared by the two + // per-context memos keyed on it, so that neither can drift from the other: + // * P0b's ShaderPreprocessCache, which memoizes the source-only half of a compile; + // * P1 stage 6's ShaderCompileAdoptionMap, which shares the job NODE itself. + // + // The 64-bit source hash is a LOOKUP ACCELERATOR ONLY. Every user of this key confirms + // a candidate hit with a full byte comparison of the stored source before honoring it, + // so a hash collision degrades to a miss and never to a wrong answer. That rule is not + // negotiable - see the memo-hazard notes on ShaderPreprocessCache. + // + // envFingerprint is part of the identity because the pipeline's compute local-size + // verdict is computed against CompileEnv's device limits: a memo must never be handed + // back under an environment other than the one it was computed against. + struct ShaderSourceKey { + ShaderStage stage = ShaderStage::Unknown; + Uint64 sourceHash = 0; + SizeT sourceLength = 0; + Uint64 envFingerprint = 0; + + Bool operator==(const ShaderSourceKey& other) const { + return stage == other.stage && sourceHash == other.sourceHash && + sourceLength == other.sourceLength && envFingerprint == other.envFingerprint; + } + }; + + struct ShaderSourceKeyHasher { + SizeT operator()(const ShaderSourceKey& key) const { + // The source hash already spreads well; fold the three discriminators in so + // that same-hash-different-stage/length/env keys land in different buckets. + Uint64 mixed = key.sourceHash; + mixed ^= static_cast(key.sourceLength) + 0x9e3779b97f4a7c15ull + (mixed << 6) + (mixed >> 2); + mixed ^= static_cast(static_cast(key.stage)) * 0xff51afd7ed558ccdull; + mixed ^= key.envFingerprint + 0x9e3779b97f4a7c15ull + (mixed << 6) + (mixed >> 2); + return static_cast(mixed); + } + }; +} // namespace MobileGL::MG_State::GLState diff --git a/MobileGL/MG_Test/Program/CMakeLists.txt b/MobileGL/MG_Test/Program/CMakeLists.txt index 7d7fef43..2a564f6b 100644 --- a/MobileGL/MG_Test/Program/CMakeLists.txt +++ b/MobileGL/MG_Test/Program/CMakeLists.txt @@ -60,6 +60,22 @@ target_link_libraries( ${LINK_LIBRARIES} ) +add_executable( + ShaderCompileAdoptionTest + ShaderCompileAdoptionTest.cpp +) + +target_include_directories(ShaderCompileAdoptionTest PRIVATE + ${MGL_ROOT}/include + ${MGL_ROOT}/MobileGL +) + +target_link_libraries( + ShaderCompileAdoptionTest PRIVATE + GTest::gtest_main + ${LINK_LIBRARIES} +) + add_executable( ParallelShaderCompileTest ParallelShaderCompileTest.cpp @@ -113,6 +129,9 @@ gtest_discover_tests(ProgramTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit) # compile pool so there is something in flight to race against. gtest_discover_tests(AsyncCompileTest DISCOVERY_TIMEOUT 60 PROPERTIES LABELS unit TIMEOUT 300) gtest_discover_tests(AsyncLinkTest DISCOVERY_TIMEOUT 60 PROPERTIES LABELS unit TIMEOUT 300) +# Same reason: the stage-6 cases keep a backlog in flight so a release really can race a +# worker, and the 48-object stress links every one of them. +gtest_discover_tests(ShaderCompileAdoptionTest DISCOVERY_TIMEOUT 60 PROPERTIES LABELS unit TIMEOUT 300) # Same reason: the GL_COMPLETION_STATUS_KHR cases saturate a one-worker pool on purpose. gtest_discover_tests(ParallelShaderCompileTest DISCOVERY_TIMEOUT 60 PROPERTIES LABELS unit TIMEOUT 300) gtest_discover_tests(AsyncTeardownTest DISCOVERY_TIMEOUT 60 PROPERTIES LABELS unit TIMEOUT 300) diff --git a/MobileGL/MG_Test/Program/ProgramTest.cpp b/MobileGL/MG_Test/Program/ProgramTest.cpp index 99e7fb42..d415f34c 100644 --- a/MobileGL/MG_Test/Program/ProgramTest.cpp +++ b/MobileGL/MG_Test/Program/ProgramTest.cpp @@ -20,6 +20,7 @@ #include "MG_Impl/GLImpl/Program/GL_Program.h" #include "MG_State/GLState/Core.h" #include "MG_State/GLState/ProgramState/ShaderPreprocessCache.h" +#include "MG_Util/Async/ShaderCompilePool.h" #include "MG_Util/ShaderTranspiler/ShaderCompiler.h" using namespace MobileGL; @@ -3006,8 +3007,19 @@ TEST_F(ProgramTest, TwoShaderObjectsWithIdenticalSourceLinkIndependently) { ASSERT_NE(objectA, nullptr); ASSERT_NE(objectB, nullptr); EXPECT_EQ(objectA->GetShaderSource(), objectB->GetShaderSource()); - // Independent parses despite the shared preprocess. - EXPECT_NE(objectA->GetCompiledShader(), objectB->GetCompiledShader()); + // P0b's layer 2 shares the PREPROCESS and never the parse: glslang's TShader is + // consume-once, so a memo hit still has to parse for itself. + // + // P1 stage 6 shares something stronger when it is active - the whole compile JOB, and + // therefore the single parse that job produced - and that sharing is made safe by + // ShaderCompileTask::ClaimParsedShader's CAS instead, exactly as it already was for one + // shader object attached to two programs. ShaderCompileAdoptionTest is where that is + // pinned down (it links both objects and compares the generated SPIR-V). So the + // one-parse-per-object assertion belongs to the non-adopting path; the two independent + // LINKS below are what both modes have to agree on, and they are the point of this case. + if (!MG_Util::Async::AsyncShaderCompileActive()) { + EXPECT_NE(objectA->GetCompiledShader(), objectB->GetCompiledShader()); + } EXPECT_NE(objectA->GetCompiledShader(), nullptr); EXPECT_NE(objectB->GetCompiledShader(), nullptr); diff --git a/MobileGL/MG_Test/Program/ShaderCompileAdoptionTest.cpp b/MobileGL/MG_Test/Program/ShaderCompileAdoptionTest.cpp new file mode 100644 index 00000000..342d4ff0 --- /dev/null +++ b/MobileGL/MG_Test/Program/ShaderCompileAdoptionTest.cpp @@ -0,0 +1,910 @@ +// MobileGL - MobileGL/MG_Test/Program/ShaderCompileAdoptionTest.cpp +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header + +// P1 stage 6: two shader objects handed byte-identical source share ONE compile job. +// +// The property under test is a conjunction, and every case here attacks one half of it: +// * the sharing itself - one job, one node, both GL names reporting the same answer, and +// two programs linking that one node to byte-identical SPIR-V; +// * that sharing did not make a cancel dangerous. Before this stage a node had exactly one +// shader object, so "this object stopped caring" and "nothing can observe this result" +// were the same statement and CancelCompile() cancelled unconditionally. They are not the +// same statement any more, and the four mutation paths that used to reach that cancel - +// re-source, delete, the orphan-name sweep, the destructor - are each covered below with +// a second object still holding the node. +// +// Like the other async suites, every case flips MG_Config::Features.AsyncShaderCompile itself +// and drives the real GL entry points, so the file behaves identically whether or not the +// suite was launched with MOBILEGL_ASYNC_SHADER_COMPILE=1. +// +// Adoption is decided ON THE GL THREAD, before anything is posted, so the counter assertions +// here are deterministic rather than timing-dependent: whether the first object's compile has +// already finished changes nothing about whether the second one adopts it. + +#include + +#include +#include +#include +#include + +#include "Config.h" +#include "Includes.h" +#include "Init.h" +#include "MG_Impl/GLImpl/Getter/GL_Getter.h" +#include "MG_Impl/GLImpl/Program/GL_Program.h" +#include "MG_State/GLState/Core.h" +#include "MG_State/GLState/ProgramState/ShaderCompileAdoptionMap.h" +#include "MG_State/GLState/ProgramState/ShaderCompileTask.h" +#include "MG_State/GLState/ProgramState/ShaderPreprocessCache.h" +#include "MG_Util/Async/ShaderCompilePool.h" +#include "MG_Util/ShaderTranspiler/CompileEnv.h" + +using namespace MobileGL; +using namespace MobileGL::MG_Impl::GLImpl; +using MobileGL::MG_State::GLState::ShaderCompileAdoptionMap; +using MobileGL::MG_State::GLState::ShaderCompileTask; +using MobileGL::MG_State::GLState::ShaderObject; +using MobileGL::MG_State::GLState::ShaderPreprocessCache; + +namespace { + class AsyncModeScope { + public: + explicit AsyncModeScope(const Bool async) : m_saved(MG_Config::Features.AsyncShaderCompile) { + MG_Config::Features.AsyncShaderCompile = + async ? MG_Config::QuirkOverride::ForceOn : MG_Config::QuirkOverride::ForceOff; + } + ~AsyncModeScope() { MG_Config::Features.AsyncShaderCompile = m_saved; } + AsyncModeScope(const AsyncModeScope&) = delete; + AsyncModeScope& operator=(const AsyncModeScope&) = delete; + + private: + const MG_Config::QuirkOverride m_saved; + }; + + // The suspension latch and the concurrency budget are PROCESS-wide, so a case that + // touches either has to put both back or it poisons every case after it in this binary. + class CompilerThreadScope { + public: + CompilerThreadScope() = default; + ~CompilerThreadScope() { + MG_Util::Async::SetAsyncShaderCompileSuspended(false); + MG_Util::Async::ShaderCompilePool::Get().SetMaxConcurrency( + MG_Util::Async::ShaderCompilePool::Get().GetThreadCount()); + } + CompilerThreadScope(const CompilerThreadScope&) = delete; + CompilerThreadScope& operator=(const CompilerThreadScope&) = delete; + }; + + const char* kVs = R"(#version 460 +layout(location = 0) in vec3 aPos; +uniform mat4 uModel; +uniform vec4 uColor; +out vec4 vColor; +void main() { + vColor = uColor; + gl_Position = uModel * vec4(aPos, 1.0); +} +)"; + + // Fails inside glslang rather than in the lexical pre-checks, so it exercises the same + // ParseFailed path a real broken shaderpack source takes. + const char* kBrokenFs = R"(#version 460 +layout(location = 0) out vec4 fragColor; +void main() { fragColor = thisIdentifierWasNeverDeclared; } +)"; + + // Big enough that a compile is not instantaneous, so a duplicate really would cost + // something. Templated on an index so every instance is a distinct source. + String MakeBulkySource(const int index) { + String source = "#version 460\nlayout(location = 0) out vec4 fragColor;\n"; + source += "uniform float uSeed" + std::to_string(index) + ";\n"; + source += "void main() {\n float acc = uSeed" + std::to_string(index) + ";\n"; + for (int i = 0; i < 220; ++i) { + source += " acc = acc * 1.0001 + sin(acc + " + std::to_string(i) + ".0) * cos(acc);\n"; + } + source += " fragColor = vec4(acc, acc, acc, 1.0);\n}\n"; + return source; + } + + // Heavy enough that a spinning GL thread can reliably observe the compile Running on a + // single-worker pool, for RunningCancelRequestedNodeIsNotAdopted below - MakeBulkySource + // is tuned for "not instantaneous", this one is tuned for "actually spin-observable". + String MakeVeryHeavySource(const int index) { + String source = "#version 460\nlayout(location = 0) out vec4 fragColor;\n"; + source += "uniform float uSeed" + std::to_string(index) + ";\n"; + source += "void main() {\n float acc = uSeed" + std::to_string(index) + ";\n"; + for (int i = 0; i < 4000; ++i) { + source += " acc = acc * 1.0001 + sin(acc + " + std::to_string(i) + ".0) * cos(acc);\n"; + } + source += " fragColor = vec4(acc, acc, acc, 1.0);\n}\n"; + return source; + } + + Uint64 AdoptionCount() { + return MG_State::pGLContext->GetShaderCompileAdoptionMap().GetAdoptionCount(); + } + + // A copy of the slot, never the reference: creating another shader can reallocate the + // context's object table. + SharedPtr Object(const GLuint shader) { + return MG_State::pGLContext->GetShaderObject(shader); + } + + // The node identity, WITHOUT joining - this is what "they share one job" means, and + // asking must not settle anything. + const ShaderCompileTask* NodeOf(const GLuint shader) { + const SharedPtr object = Object(shader); + return object ? object->CompiledNodeForLink().get() : nullptr; + } + + GLuint MakeShader(const GLenum type, const char* source) { + const GLuint shader = CreateShader(type); + ShaderSource(shader, 1, &source, nullptr); + return shader; + } + + GLuint MakeAndCompile(const GLenum type, const char* source) { + const GLuint shader = MakeShader(type, source); + CompileShader(shader); + return shader; + } + + GLint QueryCompileStatus(const GLuint shader) { + GLint status = GL_FALSE; + GetShaderiv(shader, GL_COMPILE_STATUS, &status); + return status; + } + + String QueryShaderInfoLog(const GLuint shader) { + GLint length = 0; + GetShaderiv(shader, GL_INFO_LOG_LENGTH, &length); + if (length <= 0) return String(); + std::vector buffer(static_cast(length)); + GLsizei written = 0; + GetShaderInfoLog(shader, length, &written, buffer.data()); + return String(buffer.data(), static_cast(written)); + } + + GLint QueryLinkStatus(const GLuint program) { + GLint status = GL_FALSE; + GetProgramiv(program, GL_LINK_STATUS, &status); + return status; + } + + // Content hash of a linked program's generated SPIR-V, through the state layer (there is + // no GL query for it). This is what catches a mis-shared parse: if the claim CAS on a + // SHARED node let two links both run mapIO over the same intermediate, the two programs + // would disagree here. + Vector SpirvDigest(const GLuint program) { + const auto& object = MG_State::pGLContext->GetProgramObject(program); + Vector digest; + if (!object) return digest; + for (const auto& module : object->GetGeneratedSpirv()) { + Uint64 hash = 1469598103934665603ull; + for (const unsigned word : module) { + hash = (hash ^ static_cast(word)) * 1099511628211ull; + } + digest.push_back(hash); + } + return digest; + } + + // Enqueues `count` distinct heavy compiles and reads nothing back, so the pool is left + // with a real backlog for the caller's mutations to race against. + void SaturatePool(const int count, Vector& sourceStorage) { + sourceStorage.reserve(sourceStorage.size() + static_cast(count)); + for (int i = 0; i < count; ++i) { + sourceStorage.push_back(MakeBulkySource(90000 + i)); + const char* text = sourceStorage.back().c_str(); + const GLuint shader = CreateShader(GL_FRAGMENT_SHADER); + ShaderSource(shader, 1, &text, nullptr); + CompileShader(shader); + } + } + + // Links `shader` against a freshly compiled vertex stage and returns the program. + GLuint LinkWith(const GLuint shader) { + const GLuint vs = MakeAndCompile(GL_VERTEX_SHADER, kVs); + const GLuint program = CreateProgram(); + AttachShader(program, vs); + AttachShader(program, shader); + LinkProgram(program); + return program; + } + + class ShaderCompileAdoptionTest : public ::testing::Test { + protected: + void SetUp() override { MobileGL::Initialize(); } + }; +} // namespace + +// --------------------------------------------------------------------------------------- +// The sharing itself +// --------------------------------------------------------------------------------------- + +// The headline: two GL shader names, byte-identical source, exactly one job. Both names must +// answer every query correctly, and the ONE parse they share must link into two separate +// programs with byte-identical SPIR-V - which is the stage-4 claim CAS being exercised on a +// shared node for the first time. +TEST_F(ShaderCompileAdoptionTest, TwoObjectsWithIdenticalSourceShareOneCompileJob) { + const AsyncModeScope async(true); + Vector backlog; + SaturatePool(32, backlog); + + const String source = MakeBulkySource(100); + const char* text = source.c_str(); + + const Uint64 before = AdoptionCount(); + const GLuint a = CreateShader(GL_FRAGMENT_SHADER); + ShaderSource(a, 1, &text, nullptr); + CompileShader(a); + const GLuint b = CreateShader(GL_FRAGMENT_SHADER); + ShaderSource(b, 1, &text, nullptr); + CompileShader(b); + + EXPECT_EQ(AdoptionCount() - before, 1u) << "the second glCompileShader must not enqueue a duplicate"; + ASSERT_NE(NodeOf(a), nullptr); + EXPECT_EQ(NodeOf(a), NodeOf(b)) << "both objects must hold the very same job node"; + + // Both names still answer for themselves. + EXPECT_EQ(QueryCompileStatus(a), GL_TRUE) << QueryShaderInfoLog(a); + EXPECT_EQ(QueryCompileStatus(b), GL_TRUE) << QueryShaderInfoLog(b); + EXPECT_EQ(QueryShaderInfoLog(a), QueryShaderInfoLog(b)); + EXPECT_TRUE(QueryShaderInfoLog(a).empty()); + + // One node, two links: exactly one of them wins ClaimParsedShader, the other re-parses, + // and the two must agree bit for bit. + const GLuint programA = LinkWith(a); + const GLuint programB = LinkWith(b); + ASSERT_EQ(QueryLinkStatus(programA), GL_TRUE); + ASSERT_EQ(QueryLinkStatus(programB), GL_TRUE); + const Vector digestA = SpirvDigest(programA); + const Vector digestB = SpirvDigest(programB); + ASSERT_EQ(digestA.size(), 2u); + EXPECT_EQ(digestA, digestB) << "a shared node linked twice produced different SPIR-V"; + EXPECT_GE(GetUniformLocation(programA, "uSeed100"), 0); + EXPECT_GE(GetUniformLocation(programB, "uSeed100"), 0); + EXPECT_EQ(GetError(), GL_NO_ERROR); +} + +// Adoption must also re-arm the adopter's layer-1 memo. It is a POINTER comparison against +// the node's own source snapshot, so an adopter that kept its own equal-but-distinct copy +// would decide on the very next glCompileShader that it had no memo and enqueue the exact +// duplicate this stage exists to remove - and an identical glShaderSource would cancel a +// compile another object is still waiting on. +TEST_F(ShaderCompileAdoptionTest, AdoptingAlsoArmsTheLayerOneMemo) { + const AsyncModeScope async(true); + Vector backlog; + SaturatePool(32, backlog); + + const String source = MakeBulkySource(110); + const char* text = source.c_str(); + + const GLuint a = CreateShader(GL_FRAGMENT_SHADER); + ShaderSource(a, 1, &text, nullptr); + CompileShader(a); + const GLuint b = CreateShader(GL_FRAGMENT_SHADER); + ShaderSource(b, 1, &text, nullptr); + CompileShader(b); + + const SharedPtr objectB = Object(b); + ASSERT_NE(objectB, nullptr); + EXPECT_TRUE(objectB->HasMemoizedCompile()) << "an adopted node must satisfy the layer-1 memo"; + + const ShaderCompileTask* shared = NodeOf(b); + const Uint64 before = AdoptionCount(); + for (int i = 0; i < 4; ++i) { + CompileShader(b); + EXPECT_EQ(NodeOf(b), shared) << "a repeat glCompileShader on an adopter must be a no-op"; + } + // A byte-identical re-source is a no-op too, so it must not disturb the shared node. + ShaderSource(b, 1, &text, nullptr); + EXPECT_EQ(NodeOf(b), shared); + EXPECT_EQ(AdoptionCount(), before) << "no-op calls must not even reach the adoption map"; + + EXPECT_EQ(QueryCompileStatus(a), GL_TRUE) << QueryShaderInfoLog(a); + EXPECT_EQ(QueryCompileStatus(b), GL_TRUE) << QueryShaderInfoLog(b); + EXPECT_EQ(GetError(), GL_NO_ERROR); +} + +// Different source, and same source in a different STAGE, are different keys. This is the +// guard against the map ever handing out a node that does not belong to the caller. +TEST_F(ShaderCompileAdoptionTest, DifferentSourceOrStageIsNotAdopted) { + const AsyncModeScope async(true); + + const String first = MakeBulkySource(120); + const String second = MakeBulkySource(121); + const char* firstText = first.c_str(); + const char* secondText = second.c_str(); + + const Uint64 before = AdoptionCount(); + const GLuint a = CreateShader(GL_FRAGMENT_SHADER); + ShaderSource(a, 1, &firstText, nullptr); + CompileShader(a); + const GLuint b = CreateShader(GL_FRAGMENT_SHADER); + ShaderSource(b, 1, &secondText, nullptr); + CompileShader(b); + EXPECT_EQ(AdoptionCount(), before) << "different text must not adopt"; + EXPECT_NE(NodeOf(a), NodeOf(b)); + + // The same text in two stages: the vertex/fragment pair below shares no node either, + // because the stage is part of the key. + const GLuint vsA = MakeAndCompile(GL_VERTEX_SHADER, kVs); + const GLuint vsB = MakeAndCompile(GL_VERTEX_SHADER, kVs); + EXPECT_EQ(NodeOf(vsA), NodeOf(vsB)) << "same stage, same text: must share"; + EXPECT_NE(NodeOf(vsA), NodeOf(a)); + + EXPECT_EQ(QueryCompileStatus(a), GL_TRUE) << QueryShaderInfoLog(a); + EXPECT_EQ(QueryCompileStatus(b), GL_TRUE) << QueryShaderInfoLog(b); + EXPECT_EQ(GetError(), GL_NO_ERROR); +} + +// A failed compile is shared exactly like a successful one, and both names must report the +// identical status and the identical log - the info log lives in the node's artifacts, so +// this is also the guard that a second joiner is not left with an empty one. +TEST_F(ShaderCompileAdoptionTest, AdoptedFailingCompileReportsTheIdenticalLogToBothObjects) { + const AsyncModeScope async(true); + Vector backlog; + SaturatePool(32, backlog); + + const Uint64 before = AdoptionCount(); + const GLuint a = MakeAndCompile(GL_FRAGMENT_SHADER, kBrokenFs); + const GLuint b = MakeAndCompile(GL_FRAGMENT_SHADER, kBrokenFs); + EXPECT_EQ(AdoptionCount() - before, 1u); + EXPECT_EQ(NodeOf(a), NodeOf(b)); + + EXPECT_EQ(QueryCompileStatus(a), GL_FALSE); + EXPECT_EQ(QueryCompileStatus(b), GL_FALSE); + const String logA = QueryShaderInfoLog(a); + EXPECT_FALSE(logA.empty()); + EXPECT_EQ(QueryShaderInfoLog(b), logA); + // GL models a failed compile as status + log, never as a GL error - which is what makes + // moving the work off-thread (and sharing it) legal at all. + EXPECT_EQ(GetError(), GL_NO_ERROR); +} + +// --------------------------------------------------------------------------------------- +// The four release paths, each with a second object still holding the node +// --------------------------------------------------------------------------------------- + +// glShaderSource with DIFFERENT text on one sharer. Its release must NOT cancel the node the +// other one is still waiting on; the re-sourced object gets a fresh compile of its own. +TEST_F(ShaderCompileAdoptionTest, ResourcingOneSharerLeavesTheOtherIntact) { + const AsyncModeScope async(true); + Vector backlog; + SaturatePool(48, backlog); + + const String shared = MakeBulkySource(200); + const char* sharedText = shared.c_str(); + const GLuint a = CreateShader(GL_FRAGMENT_SHADER); + ShaderSource(a, 1, &sharedText, nullptr); + CompileShader(a); + const GLuint b = CreateShader(GL_FRAGMENT_SHADER); + ShaderSource(b, 1, &sharedText, nullptr); + CompileShader(b); + const ShaderCompileTask* sharedNode = NodeOf(b); + ASSERT_NE(sharedNode, nullptr); + ASSERT_EQ(NodeOf(a), sharedNode); + + // Replace A's text while the shared compile is very probably still outstanding. + const String replacement = MakeBulkySource(201); + const char* replacementText = replacement.c_str(); + ShaderSource(a, 1, &replacementText, nullptr); + EXPECT_EQ(NodeOf(a), nullptr) << "a real source change must drop the object's node"; + EXPECT_EQ(NodeOf(b), sharedNode) << "B must still hold the shared node"; + + // B is untouched: the compile it is waiting on still publishes, and its artifacts are + // the ones that source really produces. + ASSERT_EQ(QueryCompileStatus(b), GL_TRUE) << QueryShaderInfoLog(b); + const GLuint programB = LinkWith(b); + ASSERT_EQ(QueryLinkStatus(programB), GL_TRUE); + EXPECT_GE(GetUniformLocation(programB, "uSeed200"), 0); + + // A gets a genuinely fresh compile of the new text. + CompileShader(a); + EXPECT_NE(NodeOf(a), sharedNode); + ASSERT_EQ(QueryCompileStatus(a), GL_TRUE) << QueryShaderInfoLog(a); + const GLuint programA = LinkWith(a); + ASSERT_EQ(QueryLinkStatus(programA), GL_TRUE); + EXPECT_GE(GetUniformLocation(programA, "uSeed201"), 0); + EXPECT_EQ(GetUniformLocation(programA, "uSeed200"), -1); + EXPECT_EQ(GetError(), GL_NO_ERROR); +} + +// glDeleteShader on one sharer. The name goes immediately (no wait for a worker) and the +// object is destroyed, so this covers the DESTRUCTOR release as well as the orphan sweep's. +TEST_F(ShaderCompileAdoptionTest, DeletingOneSharerLeavesTheOtherIntact) { + const AsyncModeScope async(true); + Vector backlog; + SaturatePool(48, backlog); + + const String source = MakeBulkySource(210); + const char* text = source.c_str(); + const GLuint a = CreateShader(GL_FRAGMENT_SHADER); + ShaderSource(a, 1, &text, nullptr); + CompileShader(a); + const GLuint b = CreateShader(GL_FRAGMENT_SHADER); + ShaderSource(b, 1, &text, nullptr); + CompileShader(b); + const ShaderCompileTask* sharedNode = NodeOf(b); + ASSERT_NE(sharedNode, nullptr); + ASSERT_EQ(NodeOf(a), sharedNode); + + DeleteShader(a); + EXPECT_EQ(IsShader(a), GL_FALSE) << "an unattached deleted shader's name goes immediately"; + EXPECT_EQ(NodeOf(b), sharedNode); + + ASSERT_EQ(QueryCompileStatus(b), GL_TRUE) << QueryShaderInfoLog(b); + const GLuint program = LinkWith(b); + ASSERT_EQ(QueryLinkStatus(program), GL_TRUE); + EXPECT_GE(GetUniformLocation(program, "uSeed210"), 0); + EXPECT_EQ(GetError(), GL_NO_ERROR); +} + +// The window DeletingOneSharerLeavesTheOtherIntact cannot reach: there, A's compile has +// always already finished (or not yet started) by the time B adopts, because the pool is +// merely BUSY with other backlog. Here A's OWN node is still Running - a worker is inside +// RunBody() for it - when the last holder releases it. ReleaseCompileNode fires Cancel(), +// but JobNode::Cancel on a Running node only sets the cancellation-REQUEST flag; the state +// stays Running until the worker's body returns and JobNode::Run forces the final transition +// to Cancelled (see JobNode::Run's tail: it takes Cancelled instead of Complete whenever +// m_cancelled is set, regardless of how the body finished). FindAdoptable must refuse a node +// in that in-between state - not just one already settled as Cancelled - or C inherits a +// doomed node and glGetShaderiv reports GL_FALSE with an empty info log for valid source. +TEST_F(ShaderCompileAdoptionTest, RunningCancelRequestedNodeIsNotAdopted) { + const AsyncModeScope async(true); + MG_Util::Async::ShaderCompilePool::Get().SetMaxConcurrency(1); + + const String source = MakeVeryHeavySource(310); + const char* text = source.c_str(); + const GLuint a = CreateShader(GL_FRAGMENT_SHADER); + ShaderSource(a, 1, &text, nullptr); + CompileShader(a); + + // Spin on the GL thread until the single worker is actually inside A's body. The source + // is sized to make that window observable rather than instantaneous. + const ShaderCompileTask* node = NodeOf(a); + ASSERT_NE(node, nullptr); + bool sawRunning = false; + for (int i = 0; i < 200000 && !node->IsTerminal(); ++i) { + if (node->State() == MG_Util::Async::JobState::Running) { + sawRunning = true; + break; + } + std::this_thread::sleep_for(std::chrono::microseconds(20)); + } + ASSERT_TRUE(sawRunning) << "could not observe A's compile Running; the synthetic source " + "needs to be heavier, or the pool did not have a free worker"; + + // A is the ONLY holder, so this release brings the adopter count to zero and (with no + // link pin) fires Cancel() on a node that is still Running. + DeleteShader(a); + ASSERT_EQ(node->State(), MG_Util::Async::JobState::Running) + << "the node already settled; the race window closed before the assertions below " + "could observe it - widen MakeVeryHeavySource's loop count"; + ASSERT_TRUE(node->IsCancellationRequested()); + ASSERT_FALSE(node->IsCancelled()) << "the window this test targets does not exist here"; + + // A brand-new shader name, byte-identical source, nothing wrong with it. + const GLuint c = CreateShader(GL_FRAGMENT_SHADER); + ShaderSource(c, 1, &text, nullptr); + CompileShader(c); + EXPECT_NE(NodeOf(c), node) << "C adopted a cancellation-requested, still-Running node"; + + ASSERT_EQ(QueryCompileStatus(c), GL_TRUE) + << "valid source reported GL_FALSE; info log: [" << QueryShaderInfoLog(c) << "]"; + EXPECT_EQ(GetError(), GL_NO_ERROR); + + MG_Util::Async::ShaderCompilePool::Get().SetMaxConcurrency( + MG_Util::Async::ShaderCompilePool::Get().GetThreadCount()); +} + +// The deferred half of glDeleteShader: A is ATTACHED, so the delete only flags it and the +// name is freed by ReleaseShaderNameIfOrphaned when the detach removes the last GL-visible +// attachment. That sweep is the other caller of the release path, and it must not cancel the +// node B is sharing. +TEST_F(ShaderCompileAdoptionTest, OrphanSweepOnOneSharerLeavesTheOtherIntact) { + const AsyncModeScope async(true); + Vector backlog; + SaturatePool(48, backlog); + + const String source = MakeBulkySource(220); + const char* text = source.c_str(); + const GLuint a = CreateShader(GL_FRAGMENT_SHADER); + ShaderSource(a, 1, &text, nullptr); + CompileShader(a); + const GLuint b = CreateShader(GL_FRAGMENT_SHADER); + ShaderSource(b, 1, &text, nullptr); + CompileShader(b); + const ShaderCompileTask* sharedNode = NodeOf(b); + ASSERT_NE(sharedNode, nullptr); + ASSERT_EQ(NodeOf(a), sharedNode); + + // Attach A, flag it for deletion (name survives), then detach: the sweep fires here, with + // NO link ever posted, so the stage-4 pin is NOT what is protecting the node - only the + // adopter count is. + const GLuint program = CreateProgram(); + AttachShader(program, a); + DeleteShader(a); + EXPECT_EQ(IsShader(a), GL_TRUE) << "an attached deleted shader keeps its name"; + DetachShader(program, a); + EXPECT_EQ(IsShader(a), GL_FALSE) << "the detach must free the flagged shader's name"; + EXPECT_EQ(NodeOf(b), sharedNode); + + ASSERT_EQ(QueryCompileStatus(b), GL_TRUE) << QueryShaderInfoLog(b); + const GLuint programB = LinkWith(b); + ASSERT_EQ(QueryLinkStatus(programB), GL_TRUE); + EXPECT_GE(GetUniformLocation(programB, "uSeed220"), 0); + EXPECT_EQ(GetError(), GL_NO_ERROR); +} + +// The same sweep, now with the stage-4 link pin also in play: A's program is LINKED (so the +// node is MarkLinkReferenced) and then A is detached and deleted, while B still shares the +// node. Both protections have to hold at once - the link must report GL_TRUE and B must +// still compile. +TEST_F(ShaderCompileAdoptionTest, OrphanSweepWithALinkPinnedSharedNodeHoldsBoth) { + const AsyncModeScope async(true); + Vector backlog; + SaturatePool(48, backlog); + + const String source = MakeBulkySource(230); + const char* text = source.c_str(); + const GLuint a = CreateShader(GL_FRAGMENT_SHADER); + ShaderSource(a, 1, &text, nullptr); + CompileShader(a); + const GLuint b = CreateShader(GL_FRAGMENT_SHADER); + ShaderSource(b, 1, &text, nullptr); + CompileShader(b); + const ShaderCompileTask* sharedNode = NodeOf(b); + ASSERT_NE(sharedNode, nullptr); + ASSERT_EQ(NodeOf(a), sharedNode); + + // The ordinary teardown order: link, then detach, then delete. No status read in between, + // so the link's own prologue is what joins the shared compile. + const GLuint vs = MakeAndCompile(GL_VERTEX_SHADER, kVs); + const GLuint program = CreateProgram(); + AttachShader(program, vs); + AttachShader(program, a); + LinkProgram(program); + DetachShader(program, a); + DeleteShader(a); + EXPECT_EQ(IsShader(a), GL_FALSE); + + ASSERT_EQ(QueryLinkStatus(program), GL_TRUE) << "the pinned shared compile must still publish"; + EXPECT_GE(GetUniformLocation(program, "uSeed230"), 0); + EXPECT_EQ(NodeOf(b), sharedNode); + ASSERT_EQ(QueryCompileStatus(b), GL_TRUE) << QueryShaderInfoLog(b); + EXPECT_EQ(GetError(), GL_NO_ERROR); +} + +// Every sharer released, in turn, with nothing pinning the node: the LAST release is the one +// that may cancel, and afterwards the map must not hand the cancelled node to anybody. The +// property asserted is the one that matters and it is timing-free: whatever happened to the +// old node, a later object with the same source must end up with a CORRECT compile. +TEST_F(ShaderCompileAdoptionTest, AfterEverySharerIsGoneTheNextCompileIsStillCorrect) { + const AsyncModeScope async(true); + const CompilerThreadScope compilerThreads; + // One worker and a deep backlog: a node posted now is overwhelmingly likely to still be + // queued when its last holder drops it, which is the state in which the cancel bites. + MG_Util::Async::ShaderCompilePool::Get().SetMaxConcurrency(1); + Vector backlog; + SaturatePool(48, backlog); + + const String source = MakeBulkySource(240); + const char* text = source.c_str(); + const GLuint a = CreateShader(GL_FRAGMENT_SHADER); + ShaderSource(a, 1, &text, nullptr); + CompileShader(a); + const GLuint b = CreateShader(GL_FRAGMENT_SHADER); + ShaderSource(b, 1, &text, nullptr); + CompileShader(b); + ASSERT_EQ(NodeOf(a), NodeOf(b)); + + DeleteShader(a); + DeleteShader(b); // the last holder: this one is authorized to cancel + + const GLuint c = CreateShader(GL_FRAGMENT_SHADER); + ShaderSource(c, 1, &text, nullptr); + CompileShader(c); + ASSERT_EQ(QueryCompileStatus(c), GL_TRUE) + << "a cancelled node must never be adopted - it can only ever report GL_FALSE. " + << QueryShaderInfoLog(c); + const GLuint program = LinkWith(c); + ASSERT_EQ(QueryLinkStatus(program), GL_TRUE); + EXPECT_GE(GetUniformLocation(program, "uSeed240"), 0); + EXPECT_EQ(GetError(), GL_NO_ERROR); +} + +// --------------------------------------------------------------------------------------- +// The bypasses: both must be byte-identical to the pre-stage-6 behaviour +// --------------------------------------------------------------------------------------- + +// The kill switch. With the flag off, compilation is synchronous and NOTHING is adopted - +// the map is not even consulted, so the counter cannot move. +TEST_F(ShaderCompileAdoptionTest, FlagOffAdoptsNothing) { + const AsyncModeScope async(false); + ASSERT_FALSE(MG_Util::Async::AsyncShaderCompileEnabled()); + + const String source = MakeBulkySource(300); + const char* text = source.c_str(); + + const Uint64 before = AdoptionCount(); + Vector shaders; + for (int i = 0; i < 6; ++i) { + const GLuint fs = CreateShader(GL_FRAGMENT_SHADER); + ShaderSource(fs, 1, &text, nullptr); + CompileShader(fs); + shaders.push_back(fs); + } + EXPECT_EQ(AdoptionCount(), before) << "the flag-off path must not consult the adoption map"; + for (SizeT i = 1; i < shaders.size(); ++i) { + EXPECT_NE(NodeOf(shaders[i]), NodeOf(shaders[0])) << "flag off means one node per object"; + } + for (const GLuint fs : shaders) { + EXPECT_EQ(QueryCompileStatus(fs), GL_TRUE) << QueryShaderInfoLog(fs); + } + EXPECT_EQ(GetError(), GL_NO_ERROR); +} + +// glMaxShaderCompilerThreadsKHR(0) puts compilation back on the application's thread even +// though the extension stays advertised. Adoption keys off the same predicate, so a +// suspended context shares nothing either - which is what keeps a subsequent +// GL_COMPLETION_STATUS_KHR immediately GL_TRUE without any reasoning about shared nodes. +TEST_F(ShaderCompileAdoptionTest, SuspendedCompilationAdoptsNothing) { + const AsyncModeScope async(true); + const CompilerThreadScope compilerThreads; + MaxShaderCompilerThreadsKHR(0); + ASSERT_TRUE(MG_Util::Async::IsAsyncShaderCompileSuspended()); + + const String source = MakeBulkySource(310); + const char* text = source.c_str(); + + const Uint64 before = AdoptionCount(); + Vector shaders; + for (int i = 0; i < 4; ++i) { + const GLuint fs = CreateShader(GL_FRAGMENT_SHADER); + ShaderSource(fs, 1, &text, nullptr); + CompileShader(fs); + shaders.push_back(fs); + GLint complete = GL_FALSE; + GetShaderiv(fs, GL_COMPLETION_STATUS_KHR, &complete); + EXPECT_EQ(complete, GL_TRUE) << "a zero compiler-thread count leaves nothing in flight"; + } + EXPECT_EQ(AdoptionCount(), before); + for (SizeT i = 1; i < shaders.size(); ++i) { + EXPECT_NE(NodeOf(shaders[i]), NodeOf(shaders[0])); + } + for (const GLuint fs : shaders) { + EXPECT_EQ(QueryCompileStatus(fs), GL_TRUE) << QueryShaderInfoLog(fs); + } + EXPECT_EQ(GetError(), GL_NO_ERROR); +} + +// --------------------------------------------------------------------------------------- +// Stress +// --------------------------------------------------------------------------------------- + +// The shaderpack shape: 48 objects over 6 distinct sources, all enqueued before anything is +// read, on a two-worker pool. 42 of the 48 compiles must simply vanish, and all 48 objects +// must still be individually correct - each with its own name, its own status, and its own +// link (which means 48 claims against 6 shared parses). +TEST_F(ShaderCompileAdoptionTest, StressFortyEightObjectsOverSixSources) { + const AsyncModeScope async(true); + const CompilerThreadScope compilerThreads; + MG_Util::Async::ShaderCompilePool::Get().SetMaxConcurrency(2); + + constexpr int kDistinct = 6; + constexpr int kDuplicates = 8; + Vector sources; + sources.reserve(kDistinct); + for (int i = 0; i < kDistinct; ++i) { + sources.push_back(MakeBulkySource(400 + i)); + } + + const Uint64 before = AdoptionCount(); + Vector shaders; + for (int duplicate = 0; duplicate < kDuplicates; ++duplicate) { + for (int i = 0; i < kDistinct; ++i) { + const char* text = sources[static_cast(i)].c_str(); + const GLuint fs = CreateShader(GL_FRAGMENT_SHADER); + ShaderSource(fs, 1, &text, nullptr); + CompileShader(fs); + shaders.push_back(fs); + } + } + const Uint64 adoptions = AdoptionCount() - before; + // The floor the stage contracts for, with room for any future scheduling slack... + ASSERT_GE(adoptions, 30u) << "48 objects over 6 sources adopted only " << adoptions << " times"; + // ...and the number this design actually produces, because the decision is made on the GL + // thread before anything is posted and therefore does not depend on the workers at all. + EXPECT_EQ(adoptions, static_cast(kDistinct * (kDuplicates - 1))); + + for (SizeT s = 0; s < shaders.size(); ++s) { + const GLuint fs = shaders[s]; + ASSERT_EQ(QueryCompileStatus(fs), GL_TRUE) << "shader " << s << ": " << QueryShaderInfoLog(fs); + const GLuint program = LinkWith(fs); + ASSERT_EQ(QueryLinkStatus(program), GL_TRUE) << "shader index " << s; + const String uniform = "uSeed" + std::to_string(400 + static_cast(s % kDistinct)); + EXPECT_GE(GetUniformLocation(program, uniform.c_str()), 0) << uniform; + } + EXPECT_EQ(GetError(), GL_NO_ERROR); +} + +// The adversarial interleaving, with duplicates everywhere: compile, query, re-source, +// re-compile, delete, all with the pool busy and most objects sharing nodes. Nothing here +// asserts timing - what it hunts for is a node cancelled out from under a sharer, which +// surfaces as a wrong status, a wrong uniform, or a crash. +TEST_F(ShaderCompileAdoptionTest, StressSharedNodesUnderResourceAndDelete) { + const AsyncModeScope async(true); + constexpr int kRounds = 6; + constexpr int kPerRound = 12; + + for (int round = 0; round < kRounds; ++round) { + Vector sources; + sources.reserve(4); + for (int i = 0; i < 4; ++i) { + sources.push_back(MakeBulkySource(round * 100 + i)); + } + const String replacement = MakeBulkySource(round * 100 + 50); + const char* replacementText = replacement.c_str(); + + Vector shaders; + for (int i = 0; i < kPerRound; ++i) { + const char* text = sources[static_cast(i % 4)].c_str(); + const GLuint fs = CreateShader(GL_FRAGMENT_SHADER); + ShaderSource(fs, 1, &text, nullptr); + CompileShader(fs); + shaders.push_back(fs); + } + + // Re-source a third of them onto ONE new shared source, so the survivors of each + // original node keep waiting on it while the movers pile onto a new one. + for (int i = 0; i < kPerRound; i += 3) { + ShaderSource(shaders[static_cast(i)], 1, &replacementText, nullptr); + CompileShader(shaders[static_cast(i)]); + } + // And delete another third outright, while their nodes are still shared. + for (int i = 1; i < kPerRound; i += 3) { + DeleteShader(shaders[static_cast(i)]); + } + + for (int i = 0; i < kPerRound; ++i) { + if (i % 3 == 1) continue; // deleted + const GLuint shader = shaders[static_cast(i)]; + ASSERT_EQ(QueryCompileStatus(shader), GL_TRUE) + << "round " << round << " shader " << i << ": " << QueryShaderInfoLog(shader); + const GLuint program = LinkWith(shader); + ASSERT_EQ(QueryLinkStatus(program), GL_TRUE) << "round " << round << " shader " << i; + const String expected = + "uSeed" + std::to_string(i % 3 == 0 ? round * 100 + 50 : round * 100 + (i % 4)); + EXPECT_GE(GetUniformLocation(program, expected.c_str()), 0) + << "round " << round << " shader " << i << " expected " << expected; + DeleteProgram(program); + } + for (int i = 0; i < kPerRound; ++i) { + if (i % 3 != 1) DeleteShader(shaders[static_cast(i)]); + } + EXPECT_EQ(GetError(), GL_NO_ERROR); + } +} + +// --------------------------------------------------------------------------------------- +// The map itself, driven directly +// --------------------------------------------------------------------------------------- +// Two of the map's rules cannot be forced deterministically through the GL surface - a +// cancelled node depends on beating a worker to it, and a CompileEnv re-capture needs a +// backend swap. Both are unconditional properties of the class, so they are asserted here +// against the class. + +namespace { + SharedPtr MakeNode(const String& text, const ShaderStage stage, + const SharedPtr& env) { + auto source = MakeShared(text); + const Uint64 hash = ShaderPreprocessCache::HashSource(*source); + return MakeShared(stage, source, hash, env, nullptr, 0); + } +} // namespace + +TEST(ShaderCompileAdoptionMapTest, RegisteredNodeIsAdoptedOnAnExactMatch) { + const auto& env = MG_Util::ShaderTranspiler::GetDefaultCompileEnv(); + ShaderCompileAdoptionMap map; + const String text = "#version 460\nvoid main() {}\n"; + const SharedPtr node = MakeNode(text, ShaderStage::Fragment, env); + map.Register(node); + + EXPECT_EQ(map.FindAdoptable(ShaderStage::Fragment, ShaderPreprocessCache::HashSource(text), text, + env->fingerprint), + node); + EXPECT_EQ(map.GetAdoptionCount(), 1u); + + // Every discriminator in the key is load-bearing. + EXPECT_EQ(map.FindAdoptable(ShaderStage::Vertex, ShaderPreprocessCache::HashSource(text), text, + env->fingerprint), + nullptr); + const String other = text + "\n"; + EXPECT_EQ(map.FindAdoptable(ShaderStage::Fragment, ShaderPreprocessCache::HashSource(other), other, + env->fingerprint), + nullptr); + EXPECT_EQ(map.GetAdoptionCount(), 1u) << "a miss must not count as an adoption"; +} + +// A memo must never be handed back under an environment other than the one it was computed +// against: the compute local-size verdict inside the pipeline reads CompileEnv's device +// limits, so a node captured under one backend's limits is not a valid answer under +// another's. The fingerprint is what enforces that, and it is part of the key. +TEST(ShaderCompileAdoptionMapTest, EnvFingerprintMismatchIsNotAdopted) { + const auto& env = MG_Util::ShaderTranspiler::GetDefaultCompileEnv(); + ShaderCompileAdoptionMap map; + const String text = "#version 460\nvoid main() {}\n"; + map.Register(MakeNode(text, ShaderStage::Fragment, env)); + + // A genuinely different environment: different device limits, hence a different + // fingerprint, hence a different key. + auto otherEnv = MakeShared(*env); + otherEnv->maxComputeWorkGroupInvocations = env->maxComputeWorkGroupInvocations + 1; + otherEnv->fingerprint = MG_Util::ShaderTranspiler::ComputeCompileEnvFingerprint(*otherEnv); + ASSERT_NE(otherEnv->fingerprint, env->fingerprint); + + EXPECT_EQ(map.FindAdoptable(ShaderStage::Fragment, ShaderPreprocessCache::HashSource(text), text, + otherEnv->fingerprint), + nullptr); + EXPECT_EQ(map.GetAdoptionCount(), 0u); +} + +// A node that settled as Cancelled published nothing, so adopting it would hand the new +// object a compile that can only ever report GL_FALSE. It must be a miss, and the dead entry +// must be pruned where it is found rather than waiting for the amortized sweep. +TEST(ShaderCompileAdoptionMapTest, CancelledNodeIsNotAdoptedAndIsPruned) { + const auto& env = MG_Util::ShaderTranspiler::GetDefaultCompileEnv(); + ShaderCompileAdoptionMap map; + const String text = "#version 460\nvoid main() {}\n"; + const SharedPtr node = MakeNode(text, ShaderStage::Fragment, env); + map.Register(node); + // Never posted, so this settles the node as Cancelled right here. + node->Cancel(); + ASSERT_TRUE(node->IsCancelled()); + ASSERT_EQ(map.GetEntryCount(), 1u); + + EXPECT_EQ(map.FindAdoptable(ShaderStage::Fragment, ShaderPreprocessCache::HashSource(text), text, + env->fingerprint), + nullptr); + EXPECT_EQ(map.GetEntryCount(), 0u) << "the dead entry must be pruned on the lookup that found it"; + EXPECT_EQ(map.GetAdoptionCount(), 0u); +} + +// The map is an index, never an owner: once the last real holder is gone the entry expires +// and is pruned, so a node's artifacts can never be kept alive by the map alone. +TEST(ShaderCompileAdoptionMapTest, ExpiredNodeIsNotAdoptedAndIsPruned) { + const auto& env = MG_Util::ShaderTranspiler::GetDefaultCompileEnv(); + ShaderCompileAdoptionMap map; + const String text = "#version 460\nvoid main() {}\n"; + { + map.Register(MakeNode(text, ShaderStage::Fragment, env)); + } + ASSERT_EQ(map.GetEntryCount(), 1u); + + EXPECT_EQ(map.FindAdoptable(ShaderStage::Fragment, ShaderPreprocessCache::HashSource(text), text, + env->fingerprint), + nullptr); + EXPECT_EQ(map.GetEntryCount(), 0u); +} + +// The amortized sweep keeps the index O(live nodes) instead of O(compiles ever issued). +TEST(ShaderCompileAdoptionMapTest, SweepReclaimsDeadEntries) { + const auto& env = MG_Util::ShaderTranspiler::GetDefaultCompileEnv(); + ShaderCompileAdoptionMap map; + // Every one of these dies immediately, so nothing but dead weight accumulates - and the + // map must not grow without bound because of it. + for (SizeT i = 0; i < ShaderCompileAdoptionMap::kMinSweepThreshold * 4; ++i) { + map.Register(MakeNode("#version 460\nvoid main() { float x" + std::to_string(i) + " = 0.0; }\n", + ShaderStage::Fragment, env)); + } + EXPECT_LE(map.GetEntryCount(), ShaderCompileAdoptionMap::kMinSweepThreshold) + << "expired entries must be reclaimed, not accumulated"; +}