mirror of
https://github.com/MobileGL-Dev/MobileGL
synced 2026-09-07 19:58:32 +09:00
Audit of every memoization implementation; sixteen verified defects fixed: DirectGLES backend: - Broadcast draw-buffer memo: cleared at MakeCurrent/DestroyEGLContext like its sibling shadows; its identity+version key is only monotonic within one GLContext, so a library teardown + re-init could false-hit on a recycled FBO address. - Backend texture id re-mint (RecreateBackendTexture) now bumps an attachment generation that the SyncCurrentFBO gate and every FBO twin compare, so driver FBOs re-attach instead of keeping the deleted texture name; the attachment walk re-enters until the generation is quiescent (a walk itself can re-mint). - Buffer id re-mint (persistent-map adoption, immutable-store retire) now bumps a generation the VAO twin sync compares, forcing a full re-emit of the baked glVertexAttribPointer / element-array bindings that frontend versions cannot see. - VAO element-array sync memo: bound-object identity joins the wrapping Uint16 slot version (same pairing the ResolvedDrawBuffers IBO memo already uses). DirectVulkan backend: - EBO slice memo gains the mapped-buffer guard its vertex-binding sibling has: a shadow-backed persistent map mutates with no epoch bump, so a hit must decline. - VkClearManager::MergeClearPayload keeps colorEncoding/colorInt/colorUint with the color, so deferred glClearBufferiv/uiv no longer degrade to all-zero float. - GetOrCreateComputePipeline no longer memoizes a failed creation (same contract as PipelineFactory): a transient driver failure was permanently disabling every dispatch of that program. - Explicit-LOD-0 verdict memo keys on the sampling-resolution generation; sampler filter/aniso/LOD setters bump only that counter, so the old key served a stale verdict (wrong SPIR-V variant) after glTexParameter/glSamplerParameter changes. - SetupDraw fast path declines instead of re-arming on a moved sampling-resolution generation (the snapshot bakes the LOD verdict into its pipeline), and recomputes the XfbCapture bit so the first draw after glBeginTransformFeedback cannot bind the undecorated variant and silently capture nothing. - VertexInputStateFactory eviction epoch is drawn from a process-wide source: VAO state-pointer memos outlive the factory across renderer recreation, and a fresh factory restarting at epoch 1 would dereference a dead factory's entry. - Cached render passes re-read the live renderbuffer clear payload at begin (the clear VALUE is not in the pass hash; the entry's inline snapshot replayed the creation-time color and dropped the newly queued one). - FramebufferObject gains a never-reused lifetime id, keyed into the render-pass fast-path memo and the SetupDraw snapshot beside the raw pointer + Uint16 version pair, which address reuse plus fresh version counts could equal. - SyncTextureResource's preserved-content image goes through the deferred-release ring on both failure paths instead of a synchronous destructor under the GPU. MG_State frontend: - Layer-1 compile memo is env-disciplined like layers 2/3: a node computed against a dead CompileEnv (e.g. pre-capability fallback limits) no longer answers glCompileShader forever once the environment's content changes. - Pipeline composite cache rebuilds from each stage program's last-link shader snapshot (new LinkedShaderRef list + pinned link inputs) instead of the live attach list and current compile nodes: post-link glAttachShader/glCompileShader must not leak into the composite while the (lifetimeId, linkVersion) signature still hits - GL's "as last linked" rule. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MsqQQF7ugn7MqZXcmnmz1z
237 lines
14 KiB
C++
237 lines
14 KiB
C++
// MobileGL - MobileGL/MG_State/GLState/ProgramState/ShaderObject.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 "ShaderObject.h"
|
|
#include "ShaderPreprocessCache.h"
|
|
#include <MG_Util/Async/ShaderCompilePool.h>
|
|
#include <MG_Util/Converters/MGToGL/ProgramEnumConverter.h>
|
|
#include <MG_Util/ShaderTranspiler/CompileEnv.h>
|
|
#include <MG_Util/ShaderTranspiler/ShaderCompiler.h>
|
|
#include <MG_Util/ShaderTranspiler/Types.h>
|
|
|
|
namespace MobileGL::MG_State::GLState {
|
|
void ShaderObject::SetShaderSource(const String& source) {
|
|
// P0b layer 1. glShaderSource always REPLACES the source, but replacing it with a
|
|
// byte-identical one cannot change what a compile would produce: the whole
|
|
// pipeline (preprocess -> lexical checks -> glslang parse) is a pure function of
|
|
// (stage, source, CompileEnv). So keeping the compiled state is not an optimization
|
|
// that changes observable behaviour - the COMPILE_STATUS, the info log and the
|
|
// reflection a caller can query are exactly what a real recompile would have
|
|
// rebuilt, byte for byte. A compile still IN FLIGHT is left running for the same
|
|
// 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. 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;
|
|
ReleaseCompileNode();
|
|
m_source = MakeShared<const String>(Move(source));
|
|
InvalidateCompiledState();
|
|
}
|
|
|
|
Bool ShaderObject::SourceMatchesCompiledState(const String& candidate) const {
|
|
// The memo is armed exactly while a job exists that was built from the string this
|
|
// object still points at - pending or finished, success or failure.
|
|
if (!HasMemoizedCompile()) return false;
|
|
if (candidate.length() != m_source->length()) return false;
|
|
// Never let correctness ride on a hash: the answer is the full text comparison.
|
|
// (The stored hash on the node is a cache-lookup accelerator, not a substitute.)
|
|
return candidate == *m_source;
|
|
}
|
|
|
|
void ShaderObject::JoinPendingCompile() const {
|
|
MOBILEGL_ASSERT(!MG_Util::Async::ShaderCompilePool::IsPoolThread(),
|
|
"ShaderObject::EnsureCompileJoined() reached from a pool thread; a job body must never read "
|
|
"GL-thread-owned objects");
|
|
m_compiled->Wait();
|
|
m_compileJoined = true;
|
|
// 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.
|
|
//
|
|
// 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;
|
|
// A new compile is a new story: whatever the optimistic getters promised about the
|
|
// previous node does not carry over.
|
|
m_optimisticAnswerLatched = false;
|
|
}
|
|
|
|
void ShaderObject::DropCompileNode() const {
|
|
if (!m_compiled) return;
|
|
m_compiled->ReleaseAdopter();
|
|
m_compiled.reset();
|
|
// No node means IsCompileComplete() is trivially true and the truthful answers are
|
|
// "not compiled"; a stale latch would keep reporting a compile that no longer
|
|
// exists as GL_TRUE.
|
|
m_optimisticAnswerLatched = false;
|
|
}
|
|
|
|
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.
|
|
DropCompileNode();
|
|
}
|
|
|
|
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.
|
|
//
|
|
// 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() {
|
|
// 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.
|
|
// Hoisted above the memo check because the memo must be env-disciplined too (below).
|
|
const SharedPtr<const MG_Util::ShaderTranspiler::CompileEnv> env =
|
|
MG_Util::ShaderTranspiler::GetCurrentCompileEnv();
|
|
|
|
// P0b layer 1, as a tri-state: the memo is "the node in m_compiled was built from
|
|
// the string m_source still points at". SetShaderSource only swaps that pointer when
|
|
// the text actually differs, so this is a pointer compare, and it covers Pending as
|
|
// well as Complete - a second glCompileShader on an in-flight object is a no-op, not
|
|
// a duplicate job racing to write the same fields.
|
|
//
|
|
// The failure case is covered too: the info log stays queryable because nothing is
|
|
// cleared. And if the stored TShader already fed a link, the no-op leaves
|
|
// preprocessedSource and both side-channel maps intact, which is precisely what
|
|
// ClaimParsedShader's on-demand re-parse needs - a real recompile would have handed
|
|
// the next link a fresh parse, the no-op hands it a fresh re-parse of the identical
|
|
// source instead. Same result, one parse either way.
|
|
//
|
|
// The environment joins the check (ShaderSourceKey.h's memo-hazard rule: a memo
|
|
// must never be handed back under an environment other than the one it was
|
|
// computed against). Layers 2 and 3 key on the fingerprint, but this memo sits
|
|
// ABOVE both, so without this compare a node computed against a dead environment
|
|
// - e.g. a compute shader rejected against the pre-capability fallback limits -
|
|
// would keep answering forever while a fresh object with byte-identical source
|
|
// compiles fine. The fingerprint is a content hash, so a republish of identical
|
|
// capabilities still hits.
|
|
if (HasMemoizedCompile() && m_compiled->env != nullptr && m_compiled->env->fingerprint == env->fingerprint) {
|
|
return;
|
|
}
|
|
|
|
// 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
|
|
// breath (see the constructor comment) - a job would only add a round trip.
|
|
// AsyncShaderCompileActive(), not ...Enabled(): a glMaxShaderCompilerThreadsKHR(0)
|
|
// 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.
|
|
//
|
|
// 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();
|
|
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
|
|
// through the identical code.
|
|
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);
|
|
}
|
|
|
|
void ShaderObject::MarkAsDeleted() {
|
|
m_deleteStatus = true;
|
|
}
|
|
} // namespace MobileGL::MG_State::GLState
|