[Perf] (MG_State): adopt in-flight compile jobs across shader objects (P1 stage 6)

~21% of a shaderpack's glCompileShader calls hand different shader objects
byte-identical source; the P0b cache only helps after one finishes, so under
async two workers would run the whole pipeline twice. Now the GL thread
consults a per-context (stage, hash, length, envFingerprint) -> weak-node
map at enqueue and ADOPTS the in-flight (or completed) node instead of
posting a duplicate - a hit is honored only after a full byte comparison
(the hash never decides), a cancel-requested or settled-cancelled node is
never adopted, and no worker ever waits.

Sharing a node makes the unconditional cancel wrong, so release is now
adopter-counted: a plain GL-thread Int (every mutation site is a GL entry
point; the single-threadedness argument and the terminal-early-out that
keeps the count exact are in the header), and the cancel fires only at
count zero AND with no pending link pinning the node (the stage-4
MarkLinkReferenced precedence). Adoption also re-points the object's source
at the node's snapshot so the layer-1 memo's pointer compare stays armed -
without that, an adopter's next glCompileShader would re-enqueue the very
duplicate this stage removes. Both guards are negative-control-proven: each
removed guard fails exactly its own tests. Count discipline was proven with
a temporary hard-abort on underflow/leak across the full suite and retrace
corpus - zero hits.

18 new tests (13 GL-surface incl. shared-node re-source/delete/orphan-sweep
isolation, shared failure logs, 48-over-6 stress with a deterministic
adoption count, flag-off and KHR-suspended zero-adoption guards; 5 direct
map cases incl. fingerprint mismatch and cancelled/expired pruning).
Gates: 538/538 unit both flag states, async suites x5 no flakes, NVIDIA
DirectGLES retrace identical sets both states. Timing: 2-worker
(Android-shaped) 1-3% faster consistently on complementary and BSL;
4-worker unchanged - the win this stage exists for lands where CPU is
scarce.
This commit is contained in:
BZLZHH
2026-08-08 20:17:51 -04:00
parent d98f72447d
commit dcf918b9ee
14 changed files with 1431 additions and 68 deletions
+5
View File
@@ -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<ProgramObject>& GetCurrentProgram();
// What a draw or dispatch actually executes: the program in use, or - when
@@ -87,7 +87,8 @@ namespace MobileGL::MG_State::GLState {
Uint shaderId = 0;
m_programShaderNameGenerator.Generate(1, &shaderId);
EnsureIndexAvail(shaderId, m_shaderObjects);
auto shaderObject = MakeShared<ShaderObject>(stage, shaderId, m_shaderPreprocessCache);
auto shaderObject =
MakeShared<ShaderObject>(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);
}
@@ -10,6 +10,7 @@
#include <Includes.h>
#include <MG_Util/Miscellany/IndexGenerator.h>
#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>& 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<ShaderPreprocessCache> m_shaderPreprocessCache = MakeShared<ShaderPreprocessCache>();
// 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<ShaderCompileAdoptionMap> m_shaderCompileAdoptionMap = MakeShared<ShaderCompileAdoptionMap>();
Vector<SharedPtr<ProgramObject>> m_programObjects;
Vector<SharedPtr<ShaderObject>> m_shaderObjects;
@@ -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<ShaderCompileTask> 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<ShaderCompileTask> 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<ShaderCompileTask>& 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<ShaderSourceKey> dead;
for (const auto& entry : m_entries) {
const SharedPtr<ShaderCompileTask> 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
@@ -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 <Includes.h>
#include <MG_State/GLState/ProgramState/ShaderSourceKey.h>
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<ShaderCompileTask> 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<ShaderCompileTask>& 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<ShaderSourceKey, WeakPtr<ShaderCompileTask>, ShaderSourceKeyHasher> m_entries;
SizeT m_sweepThreshold = kMinSweepThreshold;
Uint64 m_adoptionCount = 0;
};
} // namespace MobileGL::MG_State::GLState
@@ -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<Bool> m_parseClaimed{false};
std::atomic<Bool> 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
@@ -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<const String>(source);
InvalidateCompiledState();
}
void ShaderObject::SetShaderSource(String&& source) {
if (SourceMatchesCompiledState(source)) return;
CancelCompile();
ReleaseCompileNode();
m_source = MakeShared<const String>(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<ShaderCompileTask> 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<ShaderCompileTask>(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<const MG_Util::ShaderTranspiler::CompileEnv> 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<ShaderCompileTask> 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<ShaderCompileTask>(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);
}
@@ -10,6 +10,7 @@
#include <Includes.h>
#include <MG_State/GLState/ProgramState/ShaderStage.h>
#include <MG_State/GLState/ProgramState/ShaderCompileTask.h>
#include <MG_State/GLState/ProgramState/ShaderCompileAdoptionMap.h>
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<ShaderPreprocessCache> preprocessCache = nullptr)
: m_stage(stage), m_externalIndex(externalIndex), m_preprocessCache(Move(preprocessCache)) {}
SharedPtr<ShaderPreprocessCache> preprocessCache = nullptr,
SharedPtr<ShaderCompileAdoptionMap> 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<ShaderCompileTask> 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<const String> 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<ShaderPreprocessCache> 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<ShaderCompileAdoptionMap> 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<ShaderCompileTask> m_compiled;
// Exactly-once latch for the pull above. Armed with every new job node, set by
// the one join that consumes it.
@@ -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 <MG_State/GLState/ProgramState/ShaderStage.h>
#include <MG_State/GLState/ProgramState/ShaderSourceKey.h>
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<Uint64>(key.sourceLength) + 0x9e3779b97f4a7c15ull + (mixed << 6) + (mixed >> 2);
mixed ^= static_cast<Uint64>(static_cast<Int>(key.stage)) * 0xff51afd7ed558ccdull;
mixed ^= key.envFingerprint + 0x9e3779b97f4a7c15ull + (mixed << 6) + (mixed >> 2);
return static_cast<SizeT>(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;
@@ -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 <Includes.h>
#include <MG_State/GLState/ProgramState/ShaderStage.h>
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<Uint64>(key.sourceLength) + 0x9e3779b97f4a7c15ull + (mixed << 6) + (mixed >> 2);
mixed ^= static_cast<Uint64>(static_cast<Int>(key.stage)) * 0xff51afd7ed558ccdull;
mixed ^= key.envFingerprint + 0x9e3779b97f4a7c15ull + (mixed << 6) + (mixed >> 2);
return static_cast<SizeT>(mixed);
}
};
} // namespace MobileGL::MG_State::GLState