From 6f8b7fbc4077aa2e74338684ef63adaa34dc08f6 Mon Sep 17 00:00:00 2001 From: BZLZHH Date: Sat, 8 Aug 2026 11:58:38 -0400 Subject: [PATCH] [Feat] (MG_State, MG_Util): async program linking on the job graph (P1 stage 4) glLinkProgram with the flag on snapshots its inputs in a GL-thread prologue (stage-sorted shaders with their compile nodes taken without joining, env, explicit locations/fragdata/xfb, draw-buffer count), then runs the whole link body - glslang link/mapIO, SPIR-V, reflection, routing tables - as a ProgramLinkTask that auto-posts when its last compile dependency settles (+1-guarded countdown; no worker ever waits on another job). The publish is one move of the LinkArtifacts block at the join, with the second version bump so nothing memoized during the pending window survives. The consume-once TShader claim moved onto the shared compile node as a CAS: two link jobs racing for one shader resolve to winner-takes-the-parse, loser re-parses the preprocessed source against the node's own env - identical SPIR-V pinned by test for 2 and for 12 sharing programs. Two deliberate corrections to the design's cancel matrix, both test-proven: attach/detach do NOT cancel a pending link (the snapshot isolates it, and glCreateShaderProgramv's link-then-detach would otherwise discard its own result before anyone read it); and a compile node a pending link depends on is pinned against the orphan-name sweep - the ordinary LWJGL teardown compile/attach/link/detach/delete used to cancel the dependency and turn a must-pass link into GL_FALSE. Continuations are now throw-contained per-item (a stage-3 leftover made load-bearing by the first real continuation), and the review's deadlock find is fixed: the dispatch loop no longer cancels a node while holding the pool mutex, since that cancel can run OnDepSettled -> Post -> same mutex. Explicit joins: the draw path (GetProgramForDraw, both the pipeline stage loop and the plain-UseProgram half) and the composite-link site; destroy paths cancel-not-join; COMPLETION_STATUS readers stay non-joining. Gates: 506/506 unit both flag states; AsyncCompile/AsyncLink/AsyncTeardown suites x10 repeats clean both states (teardown with 128 jobs in flight, then re-Initialize); full NVIDIA DirectGLES retrace flag on twice - result sets identical to flag off, zero new deltas. Compile-phase prefix-diff, flag on vs off: complementary-reimagined 5.21s -> 2.16s, BSL 1.72s -> 0.90s - past the design's final acceptance targets before the KHR extension is even advertised. Default remains OFF until stage 5+7. --- CMakeLists.txt | 1 + MobileGL/MG_State/GLState/Core.cpp | 30 +- .../GLState/ProgramState/ProgramLinkTask.cpp | 1225 ++++++++++++++++ .../GLState/ProgramState/ProgramLinkTask.h | 111 ++ .../GLState/ProgramState/ProgramObject.cpp | 1260 ++--------------- .../GLState/ProgramState/ProgramObject.h | 172 ++- .../GLState/ProgramState/ProgramState.cpp | 8 + .../ProgramState/ShaderCompileTask.cpp | 65 +- .../GLState/ProgramState/ShaderCompileTask.h | 67 +- .../GLState/ProgramState/ShaderObject.cpp | 48 +- .../GLState/ProgramState/ShaderObject.h | 19 +- MobileGL/MG_Test/Program/AsyncLinkTest.cpp | 749 ++++++++++ .../MG_Test/Program/AsyncTeardownTest.cpp | 133 ++ MobileGL/MG_Test/Program/CMakeLists.txt | 37 + MobileGL/MG_Test/Program/ProgramTest.cpp | 4 +- MobileGL/MG_Util/Async/JobNode.cpp | 34 +- MobileGL/MG_Util/Async/JobNode.h | 17 +- MobileGL/MG_Util/Async/ShaderCompilePool.cpp | 56 +- 18 files changed, 2752 insertions(+), 1284 deletions(-) create mode 100644 MobileGL/MG_State/GLState/ProgramState/ProgramLinkTask.cpp create mode 100644 MobileGL/MG_State/GLState/ProgramState/ProgramLinkTask.h create mode 100644 MobileGL/MG_Test/Program/AsyncLinkTest.cpp create mode 100644 MobileGL/MG_Test/Program/AsyncTeardownTest.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 5a7ece4a..11c728d0 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -294,6 +294,7 @@ set(SOURCE_FILES MobileGL/MG_State/GLState/TextureState/TextureUnit.cpp MobileGL/MG_State/GLState/TextureState/TextureState.cpp MobileGL/MG_State/GLState/ProgramState/ProgramObject.cpp + MobileGL/MG_State/GLState/ProgramState/ProgramLinkTask.cpp MobileGL/MG_State/GLState/ProgramState/ShaderCompileTask.cpp MobileGL/MG_State/GLState/ProgramState/ShaderObject.cpp MobileGL/MG_State/GLState/ProgramState/ShaderPreprocessCache.cpp diff --git a/MobileGL/MG_State/GLState/Core.cpp b/MobileGL/MG_State/GLState/Core.cpp index 3495ef40..706f1536 100644 --- a/MobileGL/MG_State/GLState/Core.cpp +++ b/MobileGL/MG_State/GLState/Core.cpp @@ -368,11 +368,35 @@ namespace MobileGL::MG_State { const SharedPtr& GLContext::GetProgramForDraw() { static const SharedPtr nullProgram = nullptr; const auto& currentProgram = m_programState.GetCurrentProgram(); - if (currentProgram) return currentProgram; + if (currentProgram) { + // P1 join site J1, plain glUseProgram half. The backends read a program's + // lifetimeId / backendStateVersion / UBO content version to decide whether + // their per-program caches are still valid, and none of those pass through + // ProgramObject's join gate - so a draw could sample a version, join later + // inside the same draw when it finally touched an artifact, and cache under a + // version the publish had already superseded. Settling here means every + // version a backend reads during a draw describes the program it is drawing. + // One null check in steady state. + currentProgram->JoinLink(); + return currentProgram; + } if (m_boundProgramPipeline == 0) return nullProgram; const auto& pipeline = GetBoundProgramPipeline(); if (!pipeline) return nullProgram; + // P1 join site J1. ComputeDrawProgramSignature() keys the composite cache on each + // stage program's lifetimeId and backendStateVersion - NON-artifact fields, so + // they do not pass through ProgramObject's join gate and a pending link would + // stay pending right through the signature. Since the version is bumped both at + // enqueue and at publish, the signature computed inside a pending window is one + // that will never be produced again: every draw would miss the cache and rebuild + // (and relink) the composite. Join first, so the signature describes settled + // programs. In steady state this is a null check per stage. + for (SizeT stage = 0; stage < static_cast(ShaderStage::ShaderStageCount); ++stage) { + const auto& stageProgram = pipeline->GetStageProgram(static_cast(stage)); + if (stageProgram) stageProgram->JoinLink(); + } + const auto signature = pipeline->ComputeDrawProgramSignature(); if (const auto& cached = pipeline->GetCachedDrawProgram(signature)) return cached; @@ -400,6 +424,10 @@ namespace MobileGL::MG_State { // A pipeline with no fragment stage still rasterises, so the default fragment // shader is wanted here even though the separable stage programs never get one. composite->Link(true); + // P1 join site J2. The draw that asked for this program is the very next thing to + // happen, so enqueueing the composite's link buys nothing and only moves the wait + // to whichever backend accessor happens to touch its artifacts first. + composite->JoinLink(); pipeline->SetCachedDrawProgram(signature, Move(composite)); return pipeline->GetCachedDrawProgram(signature); } diff --git a/MobileGL/MG_State/GLState/ProgramState/ProgramLinkTask.cpp b/MobileGL/MG_State/GLState/ProgramState/ProgramLinkTask.cpp new file mode 100644 index 00000000..2d9eda53 --- /dev/null +++ b/MobileGL/MG_State/GLState/ProgramState/ProgramLinkTask.cpp @@ -0,0 +1,1225 @@ +// MobileGL - MobileGL/MG_State/GLState/ProgramState/ProgramLinkTask.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 "ProgramLinkTask.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace { + // How many vertex input locations reflection may record. Backends consume this through + // GetActiveAttributeLocationMask()/GetAttribType(), so a value below the advertised + // GL_MAX_VERTEX_ATTRIBS would make a legal attribute location invisible to them -- DirectGLES would + // then never feed the shader that attribute's current value. Bounded by the state layer's storage + // capacity, which is also the width of the Uint32 masks backends build from it. + static MobileGL::Int GetReflectionVertexAttribLimit( + const MobileGL::MG_Util::ShaderTranspiler::CompileEnv& env) { + constexpr MobileGL::Int capacity = + static_cast(MobileGL::MG_State::GLState::VertexArrayObject::MAX_VERTEX_ATTRIBS); + if (!env.HasBackend()) return capacity; + + const MobileGL::Int backendLimit = env.params.MaxVertexAttribs; + if (backendLimit <= 0) return capacity; + return std::min(backendLimit, capacity); + } + + static MobileGL::String StripArrayElementSuffix(const MobileGL::String& name) { + const MobileGL::SizeT bracket = name.find('['); + return bracket == MobileGL::String::npos ? name : name.substr(0, bracket); + } + + static bool IsBuiltInPipelineOutput(const glslang::TObjectReflection& output) { + const auto* type = output.getType(); + return type && type->getQualifier().builtIn != glslang::EbvNone; + } + + static int GetVertexInputLocationSpan(GLenum glType) { + switch (glType) { + case GL_FLOAT_MAT2: + case GL_FLOAT_MAT2x3: + case GL_FLOAT_MAT2x4: + return 2; + case GL_FLOAT_MAT3: + case GL_FLOAT_MAT3x2: + case GL_FLOAT_MAT3x4: + return 3; + case GL_FLOAT_MAT4: + case GL_FLOAT_MAT4x2: + case GL_FLOAT_MAT4x3: + return 4; + default: + return 1; + } + } + + static GLenum GetVertexInputLocationType(GLenum glType) { + switch (glType) { + case GL_FLOAT_MAT2: + case GL_FLOAT_MAT3x2: + case GL_FLOAT_MAT4x2: + return GL_FLOAT_VEC2; + case GL_FLOAT_MAT3: + case GL_FLOAT_MAT2x3: + case GL_FLOAT_MAT4x3: + return GL_FLOAT_VEC3; + case GL_FLOAT_MAT4: + case GL_FLOAT_MAT2x4: + case GL_FLOAT_MAT3x4: + return GL_FLOAT_VEC4; + default: + return glType; + } + } + + // How many consecutive uniform locations a uniform occupies. Array uniforms (opaque + // or not) span one location per element so glUniform*v(count > 1) and + // glGetUniformLocation("arr[k]") can address elements individually; everything else + // spans a single location. TObjectReflection.size only carries the element count for + // non-block arrays, so prefer the TType, which is authoritative for both. + static MobileGL::Int GetUniformLocationSpan(const glslang::TObjectReflection& uniform) { + const glslang::TType* type = uniform.getType(); + if (type != nullptr && type->isSizedArray()) { + return std::max(1, type->getOuterArraySize()); + } + return std::max(1, uniform.size); + } + + static bool ComputeShaderDeclaresLocalSize(const MobileGL::String& source) { + bool inLineComment = false; + bool inBlockComment = false; + for (MobileGL::SizeT i = 0; i < source.length(); ++i) { + if (inLineComment) { + inLineComment = source[i] != '\n'; + continue; + } + if (inBlockComment) { + if (source[i] == '*' && i + 1 < source.length() && source[i + 1] == '/') { + inBlockComment = false; + ++i; + } + continue; + } + if (source[i] == '/' && i + 1 < source.length()) { + if (source[i + 1] == '/') { + inLineComment = true; + ++i; + continue; + } + if (source[i + 1] == '*') { + inBlockComment = true; + ++i; + continue; + } + } + if (source.compare(i, 11, "local_size_") == 0) { + return true; + } + } + return false; + } +} // namespace + +namespace MobileGL::MG_State::GLState { + namespace { + // The artifacts of a compile that ran to completion, or the never-compiled defaults. + // A node that was abandoned (cancelled at teardown, or whose body threw) published + // nothing, so it reads exactly like "never compiled" - which is the same collapse + // ShaderObject's join gate performs, and is what keeps the link's view of a shader + // identical whether it went through the object or through the snapshot. + const ShaderCompileArtifacts& CompiledArtifacts(const SharedPtr& node) { + static const ShaderCompileArtifacts empty; + return (node && node->IsComplete()) ? node->artifacts : empty; + } + + // GL type enum for a vertex-stage output symbol captured by transform + // feedback. Covers the scalar/vector/matrix float+integer types transform + // feedback may legally capture in GL 3.3. + Bool ResolveXfbSymbolType(const glslang::TType& type, GLenum& outType, GLint& outArraySize, + Uint32& outBytesPerElement) { + outArraySize = type.isArray() ? type.getOuterArraySize() : 1; + const Int columns = type.isMatrix() ? type.getMatrixCols() : 1; + const Int components = type.isMatrix() ? type.getMatrixRows() + : (type.isVector() ? type.getVectorSize() : 1); + const glslang::TBasicType basic = type.getBasicType(); + static constexpr GLenum kFloatTypes[5] = {0, GL_FLOAT, GL_FLOAT_VEC2, GL_FLOAT_VEC3, GL_FLOAT_VEC4}; + static constexpr GLenum kIntTypes[5] = {0, GL_INT, GL_INT_VEC2, GL_INT_VEC3, GL_INT_VEC4}; + static constexpr GLenum kUintTypes[5] = {0, GL_UNSIGNED_INT, GL_UNSIGNED_INT_VEC2, GL_UNSIGNED_INT_VEC3, + GL_UNSIGNED_INT_VEC4}; + static constexpr GLenum kDoubleTypes[5] = {0, GL_DOUBLE, GL_DOUBLE_VEC2, GL_DOUBLE_VEC3, + GL_DOUBLE_VEC4}; + if (type.isMatrix()) { + if (basic != glslang::EbtFloat && basic != glslang::EbtDouble) return false; + static constexpr GLenum kMatTypes[5][5] = { + {}, {}, + {0, 0, GL_FLOAT_MAT2, GL_FLOAT_MAT2x3, GL_FLOAT_MAT2x4}, + {0, 0, GL_FLOAT_MAT3x2, GL_FLOAT_MAT3, GL_FLOAT_MAT3x4}, + {0, 0, GL_FLOAT_MAT4x2, GL_FLOAT_MAT4x3, GL_FLOAT_MAT4}, + }; + static constexpr GLenum kDoubleMatTypes[5][5] = { + {}, {}, + {0, 0, GL_DOUBLE_MAT2, GL_DOUBLE_MAT2x3, GL_DOUBLE_MAT2x4}, + {0, 0, GL_DOUBLE_MAT3x2, GL_DOUBLE_MAT3, GL_DOUBLE_MAT3x4}, + {0, 0, GL_DOUBLE_MAT4x2, GL_DOUBLE_MAT4x3, GL_DOUBLE_MAT4}, + }; + if (columns < 2 || columns > 4 || components < 2 || components > 4) return false; + outType = basic == glslang::EbtDouble ? kDoubleMatTypes[columns][components] + : kMatTypes[columns][components]; + } else if (components >= 1 && components <= 4) { + switch (basic) { + case glslang::EbtFloat: outType = kFloatTypes[components]; break; + case glslang::EbtInt: outType = kIntTypes[components]; break; + case glslang::EbtUint: outType = kUintTypes[components]; break; + // A double-typed varying is capturable like any other; rejecting it here reported + // the varying as "not an output of the vertex stage", which it plainly was. + case glslang::EbtDouble: outType = kDoubleTypes[components]; break; + default: return false; + } + } else { + return false; + } + // GL 4.6 core 11.1.2.1: a double component occupies eight basic machine units, and + // counts as two components against the transform feedback limits. + const Uint32 bytesPerComponent = basic == glslang::EbtDouble ? 8u : 4u; + outBytesPerElement = static_cast(columns * components) * bytesPerComponent; + return true; + } + + // Extracts a geometry shader's per-invocation EmitVertex/EndPrimitive sequence + // when it is statically knowable (no emit inside selection/loop/switch). Vulkan + // transform feedback captures triangle strips in plain (i, i+1, i+2) order while + // GL decomposes odd strip triangles as (i+1, i, i+2) (GL 4.6 table 10.1); with + // the static strip lengths the capture buffer can be reordered after EndTF. + class GsEmitSequenceTraverser final : public glslang::TIntermTraverser { + public: + bool visitAggregate(glslang::TVisit, glslang::TIntermAggregate* node) override { + if (node->getOp() == glslang::EOpEmitVertex) { + ++emitCount; + hasEmit = true; + } else if (node->getOp() == glslang::EOpEndPrimitive) { + FlushStrip(); + } + return true; + } + bool visitSelection(glslang::TVisit, glslang::TIntermSelection*) override { + inControlFlow = true; + return true; + } + bool visitLoop(glslang::TVisit, glslang::TIntermLoop*) override { + inControlFlow = true; + return true; + } + bool visitSwitch(glslang::TVisit, glslang::TIntermSwitch*) override { + inControlFlow = true; + return true; + } + void FlushStrip() { + if (emitCount >= 3) { + stripTriangles.push_back(static_cast(emitCount - 2)); + } + emitCount = 0; + } + + Vector stripTriangles; + Uint32 emitCount = 0; + Bool hasEmit = false; + Bool inControlFlow = false; + }; + } // namespace + + void ProgramLinkTask::DeferLog(String line) { diagnostics.logLines.push_back(Move(line)); } + + void ProgramLinkTask::SubmitAfter(const Vector>& deps) { + // +1 for the guard this function releases itself. Without it, a dependency that + // settles on a worker between two OnTerminal() calls below could drive the counter to + // zero and post the job while the remaining edges are still being registered - the + // job would then run against a dependency that has not finished writing its + // artifacts. Store before any edge exists, so every decrement sees the final total. + m_remainingDeps.store(static_cast(deps.size()) + 1, std::memory_order_release); + + auto self = std::static_pointer_cast(shared_from_this()); + for (const auto& dep : deps) { + // Runs inline, right here, for a dependency that is already terminal (which + // Link()'s prologue tries not to hand us, but a compile can settle between the + // IsTerminal() check there and this line). + dep->OnTerminal([self] { self->OnDepSettled(); }); + } + OnDepSettled(); // release the guard; posts here iff every dependency already settled + } + + void ProgramLinkTask::OnDepSettled() { + // fetch_sub returning 1 means this call took the counter to zero, so exactly one + // caller ever posts. acq_rel so the posting thread sees every dependency's artifacts, + // which were published by their own terminal transitions. + if (m_remainingDeps.fetch_sub(1, std::memory_order_acq_rel) != 1) return; + + // Non-throwing by construction, and it has to be: this is a JobNode continuation, so + // on the pool side it runs inside an Asio handler. Post() contains its own allocation + // failures (it cancels the node rather than propagating), and shared_from_this() can + // only throw for a node that was never owned by a SharedPtr - which SubmitAfter's + // contract forbids. The catch is the backstop for both, and it CANCELS rather than + // swallowing: a link that is never posted is a GL thread blocked forever in + // EnsureLinkJoined(), which is a far worse failure than a link reported as not linked. + try { + MG_Util::Async::ShaderCompilePool::Get().Post(shared_from_this()); + } catch (...) { + Cancel(); + } + } + + // Pure CPU work only, on a pool worker. Everything this reads is an input the node owns; + // everything it writes is `artifacts` (and diagnostics). Do not add a GL/EGL call, a + // pActiveBackendObject read, or a pGLContext->RecordError() here - the first two are what + // CompileEnv exists to replace, and the third is why the deferred-diagnostics mechanism + // (and JobNode's debug assert on it) exists. + // + // This is the whole link. See the one-link-one-handler note in the class comment. + void ProgramLinkTask::RunBody() { + // glslang leaves this worker's TLS pool allocator pointing at the last arena it + // touched (a re-parse's TShader, or the TProgram's); reset it on the way out so an + // unrelated later job cannot allocate out of a pool the GL thread has since freed. + const GlslangThreadAllocatorGuard glslangGuard; + using namespace MG_Util::ShaderTranspiler; + + MOBILEGL_ASSERT(in.env != nullptr, "ProgramLinkTask: the CompileEnv snapshot is missing"); + const CompileEnv& env = *in.env; + + MGLOG_D("ProgramObject %u: Link body start, shaders to link: %zu", in.externalIndex, in.shaders.size()); + + Vector> shaders; + if (!ConsumeShaders(shaders)) return; + + // Merge the shaders' lexically extracted explicit uniform locations. The same + // uniform declared in several stages must agree on its location (config-A glslang + // enforced this at mapIO; the relaxed parse no longer sees the qualifiers). + for (const auto& shader : in.shaders) { + const ShaderCompileArtifacts& compiled = CompiledArtifacts(shader.compiled); + for (const auto& [name, location] : compiled.explicitUniformLocations) { + const auto [it, inserted] = artifacts.linkedExplicitUniformLocations.emplace(name, location); + if (!inserted && it->second != location) { + artifacts.infoLog = std::format( + "Uniform '{}' is declared with conflicting explicit locations ({} and {}) " + "across stages.", + name, it->second, location); + DeferLog(std::format("ProgramObject {}: Link failed - {}", in.externalIndex, artifacts.infoLog)); + return; + } + } + // Sampler/image layout(binding = N) initial units, likewise invisible to the + // relaxed parse. Stage order matches the old per-stage mapIO capture, so a + // name declared in several stages keeps the last stage's binding as before. + for (const auto& [name, binding] : compiled.explicitOpaqueBindings) { + artifacts.explicitOpaqueUniformBindings[name] = binding; + } + } + + ProgramAttrib attrib{.shaders = Move(shaders), + .explicitVertexInLocations = in.explicitAttribLocations, + .explicitFragmentOutLocations = in.explicitFragDataLocation, + .explicitFragmentOutIndices = in.explicitFragDataIndex, + .explicitOpaqueUniformBindings = &artifacts.explicitOpaqueUniformBindings}; + + MGLOG_D("ProgramObject %u: Calling ShaderCompiler::LinkProgram", in.externalIndex); + auto result = ShaderCompiler::LinkProgram(attrib); + if (result) { + artifacts.linkStatus = true; + artifacts.program = result.value(); + artifacts.linkedFragDataLocation = in.explicitFragDataLocation; + artifacts.linkedFragDataIndex = in.explicitFragDataIndex; + MGLOG_D("ProgramObject %u: LinkProgram succeeded, TProgram ptr %p", in.externalIndex, + artifacts.program.get()); + } else { + artifacts.infoLog = result.error().log; + DeferLog(std::format("ProgramObject {}: LinkProgram failed. InfoLog:\n{}", in.externalIndex, + artifacts.infoLog)); + return; + } + + // GL_GEOMETRY_INPUT_TYPE. A draw's primitive type has to be compatible with it + // (GL 4.6 core 11.3.1), so it is resolved for every link, not only a capturing one. + artifacts.gsInputPrimitive = GL_NONE; + if (const glslang::TIntermediate* gs = artifacts.program->getIntermediate(EShLangGeometry)) { + switch (gs->getInputPrimitive()) { + case glslang::ElgPoints: artifacts.gsInputPrimitive = GL_POINTS; break; + case glslang::ElgLines: artifacts.gsInputPrimitive = GL_LINES; break; + case glslang::ElgLinesAdjacency: artifacts.gsInputPrimitive = GL_LINES_ADJACENCY; break; + case glslang::ElgTriangles: artifacts.gsInputPrimitive = GL_TRIANGLES; break; + case glslang::ElgTrianglesAdjacency: artifacts.gsInputPrimitive = GL_TRIANGLES_ADJACENCY; break; + default: break; + } + } + + // SPIR-V must be generated BEFORE buildReflection touches artifacts.program: + // reflection's live-variable analysis mutates the intermediates in ways that + // change subsequent GlslangToSpv output (observed: catastrophic uniform + // misbinding on DirectVulkan for UBO-heavy content). The old two-link pipeline + // never ran buildReflection on the SPIR-V-producing program; this order keeps + // that property with the single link. The glUniform*-to-scratch routing + // tables, in contrast, are sized and keyed by reflection results, so they are + // built strictly AFTER DoReflection. (Everything else on the reflection + // surface - locations, sampler units, block bindings/sizes - was measured + // identical in either order.) + MGLOG_D("ProgramObject %u: Starting SPIR-V generation", in.externalIndex); + GenerateSpirv(); + + MGLOG_D("ProgramObject %u: Starting reflection", in.externalIndex); + if (!DoReflection(env)) { + DeferLog(std::format("ProgramObject {}: Link failed during reflection: {}", in.externalIndex, + artifacts.infoLog)); + return; + } + + MGLOG_D("ProgramObject %u: Building global-UBO routing tables", in.externalIndex); + BuildGlobalUboRouting(); + MGLOG_D("ProgramObject %u: Reflection done (linkStatus=%d)", in.externalIndex, (int)artifacts.linkStatus); + if (!ValidateFragmentOutputLocations()) { + return; + } + if (!ResolveTransformFeedbackVaryings()) { + artifacts.linkStatus = false; + DeferLog(std::format("ProgramObject {}: transform feedback varying resolution failed: {}", + in.externalIndex, artifacts.infoLog)); + return; + } + MGLOG_D("ProgramObject %u: Binary generation finished (generatedSpirv size=%zu)", in.externalIndex, + artifacts.generatedSpirv.size()); + } + + Bool ProgramLinkTask::ConsumeShaders(Vector>& outShaders) { + outShaders.assign(in.shaders.size(), nullptr); + + for (SizeT i = 0; i < in.shaders.size(); i++) { + const LinkShaderInput& input = in.shaders[i]; + const GLenum shaderType = MG_Util::ConvertShaderStageToGLEnum(input.stage); + const ShaderCompileArtifacts& compiled = CompiledArtifacts(input.compiled); + MGLOG_D("ProgramObject %u: Preparing shader[%zu] stage %s", in.externalIndex, i, + MG_Util::ConvertGLEnumToString(shaderType).c_str()); + + if (!compiled.compileStatus) { + artifacts.infoLog = + std::format("Linking a {} with compilation error, linking will now terminate. Shader error " + "log:\n{}\nShader src:\n{}", + MG_Util::ConvertGLEnumToString(shaderType), compiled.infoLog, + input.source ? *input.source : String()); + DeferLog(std::format("ProgramObject {}: Link failed - shader[{}] compile status false. InfoLog:\n{}", + in.externalIndex, i, artifacts.infoLog)); + return false; + } + if (input.stage == ShaderStage::Compute && + !ComputeShaderDeclaresLocalSize(input.source ? *input.source : String())) { + artifacts.infoLog = "Compute shader is missing a local_size layout declaration."; + DeferLog(std::format("ProgramObject {}: Link failed - {}", in.externalIndex, artifacts.infoLog)); + return false; + } + + String reparseLog; + outShaders[i] = input.compiled->ClaimParsedShader(reparseLog); + if (!outShaders[i]) { + // Only reachable when the consume-once re-parse of an already-compiled + // source fails, which no valid state transition produces. + artifacts.infoLog = std::format("Internal error: re-parsing an attached {} for linking failed:\n{}", + MG_Util::ConvertGLEnumToString(shaderType), reparseLog); + DeferLog(std::format("ProgramObject {}: Link failed - {}", in.externalIndex, artifacts.infoLog)); + return false; + } + // Deliberately no full-source dump here: a shaderpack stage runs to ~100 KB, and + // one MGLOG line per shader per link is unreadable even single-threaded. Use the + // transpiler dump paths when a specific source is actually needed. + MGLOG_D("ProgramObject %u: shader[%zu] compiled shader ptr %p, src len %zu", in.externalIndex, i, + outShaders[i].get(), input.source ? input.source->length() : 0u); + } + return true; + } + + Bool ProgramLinkTask::DoReflection(const MG_Util::ShaderTranspiler::CompileEnv& env) { + if (!artifacts.program) { + DeferLog(std::format("ProgramObject {}: DoReflection called but the linked program is null", + in.externalIndex)); + artifacts.linkStatus = false; + artifacts.infoLog = "DoReflection failed: no program."; + return false; + } + + MGLOG_D("ProgramObject %u: DoReflection - building reflection", in.externalIndex); + // GL-style reflection naming (GL CTS uniform_block relies on all four): + // - BasicArraySuffix: an array uniform is reported as "arr[0]" per the GL spec. + // - StrictArraySuffix: named-block struct arrays expand per element ("s[0].a", + // "s[1].a", ...) following ARB_program_interface_query rules. Default-block + // (loose) uniforms already expand per element without this option. + // - AllBlockVariables: every member of an active named block is active even when + // no shader statement reads it (ES 3.0/GL 3.3 named-block semantics). + // - SharedStd140UBO: a DECLARED uniform block is active even when no member is + // ever read (reflected from the linker objects). PreprocessShaderSource coerces + // every block to std140, so this covers all of them. + if (!artifacts.program->buildReflection(EShReflectionStrictArraySuffix | EShReflectionBasicArraySuffix | + EShReflectionAllBlockVariables | EShReflectionSharedStd140UBO)) { + artifacts.linkStatus = false; + artifacts.infoLog = "Build reflection failed."; + DeferLog(std::format("ProgramObject {}: DoReflection - buildReflection() returned false", + in.externalIndex)); + return false; + } + + // ---------- GL-facing index spaces (relaxed-parse cleanup) ---------- + // Blocks first: global-UBO membership drives the uniform filter below. The + // synthesized MGL_GLOBAL_UBO is a transpiler artifact - its members are GL + // default-block uniforms and the block itself must stay invisible to GL (it + // did not exist in the GL-client parse this replaces). + const Int tProgramBlockCount = artifacts.program->getNumUniformBlocks(); + artifacts.tProgramBlockIndexToGl.assign(tProgramBlockCount, -1); + artifacts.glBlockIndexToTProgram.clear(); + for (Int i = 0; i < tProgramBlockCount; i++) { + const auto& ubo = artifacts.program->getUniformBlock(i); + if (std::strstr(ubo.name.c_str(), MG_Util::ShaderTranspiler::GLOBAL_UBO_NAME) != nullptr) { + continue; + } + artifacts.tProgramBlockIndexToGl[i] = static_cast(artifacts.glBlockIndexToTProgram.size()); + artifacts.glBlockIndexToTProgram.push_back(i); + } + + // ------------ Uniforms (GL Plain) ---------------- + // The relaxed parse sweeps every DECLARED default-block uniform into + // MGL_GLOBAL_UBO whether or not any stage reads it. GL requires a + // declared-but-unreferenced default-block uniform to be inactive (absent from + // glGetActiveUniform, glGetUniformLocation == -1): filter global-UBO members no + // stage references. Named-block members keep GL's every-declared-member-is-active + // semantics, exactly as before. + const Int tProgramUniformCount = artifacts.program->getNumUniformVariables(); + artifacts.tProgramUniformIndexToGl.assign(tProgramUniformCount, -1); + artifacts.glUniformIndexToTProgram.clear(); + const auto isGlobalUboMember = [this](const glslang::TObjectReflection& uniform) { + return uniform.index >= 0 && uniform.index < static_cast(artifacts.tProgramBlockIndexToGl.size()) && + artifacts.tProgramBlockIndexToGl[uniform.index] < 0; + }; + for (Int i = 0; i < tProgramUniformCount; i++) { + const auto& uniform = artifacts.program->getUniform(i); + if (isGlobalUboMember(uniform) && uniform.stages == 0) { + MGLOG_D("ProgramObject %u: Reflection - dead default-block uniform '%s' filtered from the GL " + "surface", + in.externalIndex, uniform.name.c_str()); + continue; + } + artifacts.tProgramUniformIndexToGl[i] = static_cast(artifacts.glUniformIndexToTProgram.size()); + artifacts.glUniformIndexToTProgram.push_back(i); + } + artifacts.activeUniformCount = static_cast(artifacts.glUniformIndexToTProgram.size()); + MGLOG_D("ProgramObject %u: Reflection - active uniform count = %d (of %d reflected)", in.externalIndex, + artifacts.activeUniformCount, tProgramUniformCount); + + // Effective explicit location per TProgram uniform, from two sources: + // - the lexical side-channel for default-block uniforms - the relaxed parse + // dropped their layout(location = N) qualifiers when collecting them into + // MGL_GLOBAL_UBO, so reflection cannot provide them ("source-explicit"); + // - glslang's layoutLocation() for opaque uniforms, where the qualifier + // survives the relaxed parse (and mapIO auto-assigns the rest). + constexpr Uint kNoLocation = glslang::TQualifier::layoutLocationEnd; + Vector effectiveLocation(tProgramUniformCount, kNoLocation); + Vector locationIsSourceExplicit(tProgramUniformCount, false); + UnorderedMap structExplicitCursor; // declared root -> next member location + const auto findExplicitLocation = [this](const String& reflectedName) -> const Int* { + auto it = artifacts.linkedExplicitUniformLocations.find(reflectedName); + if (it == artifacts.linkedExplicitUniformLocations.end() && reflectedName.length() > 3 && + reflectedName.compare(reflectedName.length() - 3, 3, "[0]") == 0) { + it = artifacts.linkedExplicitUniformLocations.find( + reflectedName.substr(0, reflectedName.length() - 3)); + } + return it != artifacts.linkedExplicitUniformLocations.end() ? &it->second : nullptr; + }; + for (const Int i : artifacts.glUniformIndexToTProgram) { + const auto& uniform = artifacts.program->getUniform(i); + const glslang::TType* type = uniform.getType(); + const Bool inNamedBlock = uniform.index >= 0 && !isGlobalUboMember(uniform); + if (inNamedBlock) continue; // block members never take glUniform locations + + if (const Int* explicitLocation = findExplicitLocation(uniform.name)) { + effectiveLocation[i] = static_cast(*explicitLocation); + locationIsSourceExplicit[i] = true; + } else if (!artifacts.linkedExplicitUniformLocations.empty() && + uniform.name.find('.') != String::npos) { + // A struct uniform's explicit location spreads consecutively over its + // flattened members ("s.a", "s[1].b", ...) in reflection order. + const SizeT cut = uniform.name.find_first_of(".["); + const auto rootIt = artifacts.linkedExplicitUniformLocations.find(uniform.name.substr(0, cut)); + if (rootIt != artifacts.linkedExplicitUniformLocations.end()) { + auto [cursor, inserted] = + structExplicitCursor.emplace(rootIt->first, static_cast(rootIt->second)); + (void)inserted; + effectiveLocation[i] = cursor->second; + locationIsSourceExplicit[i] = true; + cursor->second += static_cast(GetUniformLocationSpan(uniform)); + } + } + if (effectiveLocation[i] == kNoLocation && type != nullptr && type->isOpaque()) { + effectiveLocation[i] = uniform.layoutLocation(); + } + if (locationIsSourceExplicit[i] && + effectiveLocation[i] + static_cast(GetUniformLocationSpan(uniform)) > kNoLocation) { + // Config A rejected out-of-range explicit locations at parse; keep them + // from growing the location table unboundedly. + artifacts.infoLog = std::format("Uniform '{}' explicit location {} is out of range.", uniform.name, + effectiveLocation[i]); + ProgramObject::ResetLinkArtifacts(artifacts); + return false; + } + } + + Int requiredUniformLocations = 0; + for (const Int i : artifacts.glUniformIndexToTProgram) { + auto& uniform = artifacts.program->getUniform(i); + const Uint location = effectiveLocation[i]; + const Int locationSpan = GetUniformLocationSpan(uniform); + requiredUniformLocations += locationSpan; + if (location != kNoLocation) { + artifacts.maxUniformLocation = std::max(artifacts.maxUniformLocation, location + locationSpan - 1); + } + artifacts.uniformNameMaxLength = std::max(artifacts.uniformNameMaxLength, (Int)uniform.name.length()); + artifacts.uniformLocations[uniform.name] = location; + MGLOG_D("ProgramObject %u: Reflection - uniform[%d] name='%s' effectiveLocation=%d", in.externalIndex, + i, uniform.name.c_str(), location); + } + + MGLOG_D("ProgramObject %u: Reflection - computed maxUniformLocation=%u uniformNameMaxLength=%d", + in.externalIndex, artifacts.maxUniformLocation, artifacts.uniformNameMaxLength); + + if (artifacts.maxUniformLocation + 1 < requiredUniformLocations) { + MGLOG_D("ProgramObject %u: Reflection - maxUniformLocation+1 (%u) < requiredUniformLocations (%d), " + "adjusting", + in.externalIndex, artifacts.maxUniformLocation + 1, requiredUniformLocations); + // This means we have fewer than enough gaps to fit + // unallocated uniforms + artifacts.maxUniformLocation = requiredUniformLocations - 1; + } + + // i-th elements refers to uniform at layout(location = i, ...) + artifacts.uniformIndexInTProgram.resize(artifacts.maxUniformLocation + 1, + glslang::TQualifier::layoutLocationEnd); + artifacts.uniformSamplerOrImageUnitIndex.resize(artifacts.maxUniformLocation + 1, -1); + + Vector unallocatedUniformIndex; + + // Pass 1: source-explicit locations. These are API contract + // (ARB_explicit_uniform_location), and an overlap between distinct uniforms is a + // link error - config A's mapIO rejected it ("Uniform location overlaps across + // stages"); the relaxed parse dropped the qualifiers, so it is enforced here. + for (const Int i : artifacts.glUniformIndexToTProgram) { + auto& uniform = artifacts.program->getUniform(i); + if (!locationIsSourceExplicit[i] || effectiveLocation[i] == kNoLocation) continue; + const Uint location = effectiveLocation[i]; + const Int locationSpan = GetUniformLocationSpan(uniform); + for (Int element = 0; element < locationSpan; ++element) { + const Int existing = artifacts.uniformIndexInTProgram[location + element]; + if (existing != glslang::TQualifier::layoutLocationEnd && existing != i) { + artifacts.infoLog = + std::format("Uniform location overlap: '{}' and '{}' both occupy location {}.", + artifacts.program->getUniform(existing).name, uniform.name, location + element); + ProgramObject::ResetLinkArtifacts(artifacts); + return false; + } + artifacts.uniformIndexInTProgram[location + element] = i; + } + MGLOG_D("ProgramObject %u: Reflection - assigned explicit-location uniform '%s' to locations " + "%u..%u (indexInTProgram=%d)", + in.externalIndex, uniform.name.c_str(), location, location + locationSpan - 1, i); + } + + // Pass 2: glslang-assigned locations (opaque uniforms under the relaxed parse). + // Implementation-chosen, so on a collision with an explicit location the uniform + // is demoted to the first-fit pass below instead of failing the link. + for (const Int i : artifacts.glUniformIndexToTProgram) { + auto& uniform = artifacts.program->getUniform(i); + if (locationIsSourceExplicit[i]) continue; + const Uint location = effectiveLocation[i]; + if (location == kNoLocation) { + unallocatedUniformIndex.emplace_back(i); + MGLOG_D("ProgramObject %u: Reflection - uniform '%s' is unallocated, will assign later", + in.externalIndex, uniform.name.c_str()); + continue; // will allocate unallocated uniforms later + } + const Int locationSpan = GetUniformLocationSpan(uniform); + Bool spanIsFree = location + locationSpan - 1 <= artifacts.maxUniformLocation; + for (Int element = 0; spanIsFree && element < locationSpan; ++element) { + spanIsFree = + artifacts.uniformIndexInTProgram[location + element] == glslang::TQualifier::layoutLocationEnd; + } + if (!spanIsFree) { + artifacts.uniformLocations[uniform.name] = kNoLocation; + unallocatedUniformIndex.emplace_back(i); + MGLOG_D("ProgramObject %u: Reflection - uniform '%s' auto location %u collides with an " + "explicit location, demoting to first-fit", + in.externalIndex, uniform.name.c_str(), location); + continue; + } + for (Int element = 0; element < locationSpan; ++element) { + artifacts.uniformIndexInTProgram[location + element] = i; + } + MGLOG_D("ProgramObject %u: Reflection - assigned uniform '%s' to locations %u..%u " + "(indexInTProgram=%d)", + in.externalIndex, uniform.name.c_str(), location, location + locationSpan - 1, i); + } + + SizeT locNeedle = 0; + std::sort(unallocatedUniformIndex.begin(), unallocatedUniformIndex.end(), [this](Int lhs, Int rhs) { + const auto& lhsUniform = artifacts.program->getUniform(lhs); + const auto& rhsUniform = artifacts.program->getUniform(rhs); + return lhsUniform.name < rhsUniform.name; + }); + for (auto index : unallocatedUniformIndex) { + auto& uniform = artifacts.program->getUniform(index); + const Int locationSpan = GetUniformLocationSpan(uniform); + Bool placed = false; + for (; locNeedle <= artifacts.maxUniformLocation; locNeedle++) { + bool hasRoom = locNeedle + locationSpan - 1 <= artifacts.maxUniformLocation; + for (Int element = 0; hasRoom && element < locationSpan; ++element) { + hasRoom = artifacts.uniformIndexInTProgram[locNeedle + element] == + glslang::TQualifier::layoutLocationEnd; + } + if (!hasRoom) continue; + // Found a vacant location at locNeedle + for (Int element = 0; element < locationSpan; ++element) { + artifacts.uniformIndexInTProgram[locNeedle + element] = index; + } + artifacts.uniformLocations[uniform.name] = locNeedle; + MGLOG_D("ProgramObject %u: Reflection - assigned unallocated uniform '%s' to locations %zu..%zu " + "(index %d)", + in.externalIndex, uniform.name.c_str(), locNeedle, locNeedle + locationSpan - 1, index); + locNeedle += locationSpan; + placed = true; + break; + } + if (!placed) { + // Explicit-location uniforms can fragment the space so no contiguous + // span is left; grow the table instead of leaving the uniform without + // a location (which would make it unsettable via glUniform*). + const SizeT base = artifacts.uniformIndexInTProgram.size(); + artifacts.uniformIndexInTProgram.resize(base + locationSpan, + glslang::TQualifier::layoutLocationEnd); + artifacts.uniformSamplerOrImageUnitIndex.resize(base + locationSpan, -1); + artifacts.maxUniformLocation = static_cast(base + locationSpan - 1); + for (Int element = 0; element < locationSpan; ++element) { + artifacts.uniformIndexInTProgram[base + element] = index; + } + artifacts.uniformLocations[uniform.name] = static_cast(base); + MGLOG_D("ProgramObject %u: Reflection - grew location table to place uniform '%s' at %zu..%zu", + in.externalIndex, uniform.name.c_str(), base, base + locationSpan - 1); + locNeedle = base + locationSpan; + } + } + + for (const Int i : artifacts.glUniformIndexToTProgram) { + auto& uniform = artifacts.program->getUniform(i); + const auto locationIt = artifacts.uniformLocations.find(uniform.name); + if (locationIt == artifacts.uniformLocations.end()) { + continue; + } + + const Uint location = locationIt->second; + if (location >= artifacts.uniformSamplerOrImageUnitIndex.size() || uniform.getType() == nullptr || + !uniform.getType()->isOpaque() || (!uniform.getType()->isTexture() && !uniform.getType()->isImage())) { + continue; + } + + // Reflection names an array "texs[0]" while the layout(binding = N) map from the IO + // resolver is keyed by the declared name ("texs"); look up both spellings. + auto explicitBinding = artifacts.explicitOpaqueUniformBindings.find(uniform.name); + if (explicitBinding == artifacts.explicitOpaqueUniformBindings.end() && uniform.name.length() > 3 && + uniform.name.compare(uniform.name.length() - 3, 3, "[0]") == 0) { + explicitBinding = artifacts.explicitOpaqueUniformBindings.find( + uniform.name.substr(0, uniform.name.length() - 3)); + } + const int initialUnit = explicitBinding != artifacts.explicitOpaqueUniformBindings.end() + ? static_cast(explicitBinding->second) + : 0; + const Int locationSpan = GetUniformLocationSpan(uniform); + for (Int element = 0; element < locationSpan && + location + element < artifacts.uniformSamplerOrImageUnitIndex.size(); ++element) { + artifacts.uniformSamplerOrImageUnitIndex[location + element] = + initialUnit + (explicitBinding != artifacts.explicitOpaqueUniformBindings.end() ? element : 0); + } + MGLOG_D("ProgramObject %u: Reflection - opaque uniform '%s' locations=%u..%u initialUnit=%d", + in.externalIndex, uniform.name.c_str(), location, location + locationSpan - 1, initialUnit); + } + + // ------------ attributes (vertex in) --------------- + Int inCount = artifacts.program->getNumPipeInputs(); + MGLOG_D("ProgramObject %u: Reflection - pipe input count (attributes) = %d", in.externalIndex, inCount); + + Int maxLoc = -1; + for (int i = 0; i < inCount; ++i) { + Int loc = (Int)artifacts.program->getPipeInput(i).layoutLocation(); + if (loc >= 0 && loc != glslang::TQualifier::layoutLocationEnd) { + const Int locationSpan = GetVertexInputLocationSpan(artifacts.program->getPipeInput(i).glDefineType); + maxLoc = std::max(maxLoc, loc + locationSpan - 1); + } + MGLOG_D("ProgramObject %u: Reflection - pipe input[%d] name='%s' layoutLocation=%d glType=%u", + in.externalIndex, i, artifacts.program->getPipeInput(i).name.c_str(), loc, + artifacts.program->getPipeInput(i).glDefineType); + } + + if (maxLoc < 0) { + maxLoc = std::max(0, inCount - 1); + } + + const GLint maxAttribs = GetReflectionVertexAttribLimit(env); + MGLOG_D("ProgramObject %u: Reflection - computed maxLoc=%d, using maxAttribs=%d", in.externalIndex, maxLoc, + maxAttribs); + + if (maxLoc >= maxAttribs) { + DeferLog(std::format("ProgramObject {}: ProgramLinkTask::DoReflection - required attrib location {} >= " + "GL_MAX_VERTEX_ATTRIBS ({}). Clamping.", + in.externalIndex, maxLoc, maxAttribs)); + maxLoc = maxAttribs - 1; + } + + artifacts.attribs.resize(maxLoc + 1); + artifacts.attribTypes.resize(maxLoc + 1); + + for (int i = 0; i < inCount; ++i) { + auto& inVar = artifacts.program->getPipeInput(i); + Int location = (Int)inVar.layoutLocation(); + // Builtins reflect under their SPIR-V names here; GL_ACTIVE_ATTRIBUTE_MAX_LENGTH + // must measure the GL spelling glGetActiveAttrib will report. + artifacts.attribInNameMaxLength = + std::max(artifacts.attribInNameMaxLength, + (Int)ProgramObject::NormalizeBuiltinPipeInputName(inVar.name).length()); + + if (location >= 0 && location < (int)artifacts.attribs.size()) { + const Int locationSpan = GetVertexInputLocationSpan(inVar.glDefineType); + const GLenum locationType = GetVertexInputLocationType(inVar.glDefineType); + for (Int locationOffset = 0; locationOffset < locationSpan; ++locationOffset) { + const Int expandedLocation = location + locationOffset; + if (expandedLocation < 0 || expandedLocation >= static_cast(artifacts.attribs.size())) { + break; + } + + artifacts.attribs[expandedLocation] = inVar.name; + artifacts.attribTypes[expandedLocation] = locationType; + MGLOG_D( + "ProgramObject %u: Reflection - got attrib '%s' at expanded location %d (baseLocation=%d glType=%u expandedType=%u)", + in.externalIndex, + inVar.name.c_str(), + expandedLocation, + location, + inVar.glDefineType, + static_cast(locationType)); + } + } + } + + // ---------- UBO ---------- + // GL-visible blocks only (MGL_GLOBAL_UBO was filtered out above). + const Int uboCount = static_cast(artifacts.glBlockIndexToTProgram.size()); + MGLOG_D("ProgramObject %u: Reflection - uniform block count (UBO) = %d", in.externalIndex, uboCount); + artifacts.uniformBlockBinding.resize(uboCount, -1); + for (Int i = 0; i < uboCount; i++) { + auto& ubo = artifacts.program->getUniformBlock(artifacts.glBlockIndexToTProgram[i]); + artifacts.uniformBlockNameMaxLength = + std::max(artifacts.uniformBlockNameMaxLength, (Int)ubo.name.length()); + artifacts.uniformBlockIndexByName[ubo.name] = i; + // if there's binding defined in shader as layout(binding = ...), + // retrieve it here + artifacts.uniformBlockBinding[i] = ubo.getBinding(); + MGLOG_D("ProgramObject %u: Reflection - UBO[%d] name='%s' size=%u binding=%d", in.externalIndex, i, + ubo.name.c_str(), ubo.size, ubo.getBinding()); + } + return true; + } + + void ProgramLinkTask::GenerateSpirv() { + /* As we passed first stage compilation/linking, + * we'll assume all the operations here should + * pass. We may be able to employ some optimizations + * here without the burden of error reporting. + */ + using namespace MG_Util::ShaderTranspiler; + MGLOG_D("ProgramObject %u: GenerateSpirv - start", in.externalIndex); + + // The shaders were parsed once, in the link-compatible (relaxed Vulkan-rules) + // configuration, and artifacts.program linked those parses - so artifacts.program IS + // the program the backends consume. Generate SPIR-V straight from its + // intermediates; the full re-parse + re-link that used to live here (one + // glslang pass per shader per link) is gone. + Vector shaderTypes(in.shaders.size()); + for (SizeT i = 0; i < in.shaders.size(); i++) { + shaderTypes[i] = MG_Util::ConvertShaderStageToGLEnum(in.shaders[i].stage); + } + + ProgramBinaryAttrib binaryAttrib{ + .shaderTypes = shaderTypes, + .program = *artifacts.program, + }; + MGLOG_D("ProgramObject %u: GenerateSpirv - requesting SPIR-V binary from program", in.externalIndex); + auto binaryResult = ShaderCompiler::GetSpirvBinaryFromProgram(binaryAttrib); + if (!binaryResult) { + DeferLog(std::format("ProgramObject {}: GenerateSpirv - GetSpirvBinaryFromProgram failed", + in.externalIndex)); + } + MOBILEGL_ASSERT(binaryResult, "GetSpirvBinaryFromProgram failed"); + artifacts.generatedSpirv = Move(binaryResult.value()); + MGLOG_D("ProgramObject %u: GenerateSpirv - generated %zu SPIR-V modules", in.externalIndex, + artifacts.generatedSpirv.size()); + + // Linked SPIR-V generated, sanitize and optimize it + for (auto& spv : artifacts.generatedSpirv) { + auto success = ShaderCompiler::SanitizeAndOptimizeBinary(spv, spv); + MOBILEGL_ASSERT(success, "SanitizeBinary failed"); + } + } + + void ProgramLinkTask::BuildGlobalUboRouting() { + using namespace MG_Util::ShaderTranspiler; + Vector shaderTypes(in.shaders.size()); + for (SizeT i = 0; i < in.shaders.size(); i++) { + shaderTypes[i] = MG_Util::ConvertShaderStageToGLEnum(in.shaders[i].stage); + } + + artifacts.uniformSizesInBytes.clear(); + artifacts.uniformOffsets.clear(); + artifacts.globalUboScratch.clear(); + // kInvalidUniformOffset marks locations that end up without global-UBO backing + // (e.g. the optimizer eliminated every use of the uniform); the fallback pass + // below gives those locations tail storage so glUniform* always has a target. + artifacts.uniformOffsets.resize(artifacts.maxUniformLocation + 1, ProgramObject::kInvalidUniformOffset); + artifacts.uniformSizesInBytes.resize(artifacts.maxUniformLocation + 1, 0); + for (SizeT i = 0; i < artifacts.generatedSpirv.size(); i++) { + auto& spv = artifacts.generatedSpirv[i]; + + auto shaderType = shaderTypes[i]; + MGLOG_D("ProgramObject %u: BuildGlobalUboRouting - parsing SPIR-V meta data for module %zu " + "(shaderType=%u, wordCount=%zu)", + in.externalIndex, i, shaderType, spv.size()); + SpvcSession session(spv, SessionUsageBit::Reflection); + auto result = session.ParseMetaData(); + if (result < 0) { + MGLOG_D("ProgramObject %u: BuildGlobalUboRouting - SpvcSession::ParseMetaData failed for module %zu, " + "err = %d%s", + in.externalIndex, i, result, + (result == SPVC_ERROR_INVALID_SPIRV ? ". Probably no global UBO?" : "")); + continue; + } else { + auto& meta = session.GetMetadata(); + auto size = meta.globalUboSize; + MGLOG_D("ProgramObject %u: BuildGlobalUboRouting - SPIR-V meta: uboSize=%zu plainUniformCount=%zu " + "plainUniformOffsets=%zu", + in.externalIndex, meta.globalUboSize, meta.plainUniformMemberSizesInBytes.size(), + meta.plainUniformOffsetsInUBO.size()); + if (size == 0) { + continue; + } + if (artifacts.globalUboScratch.size() < size) { + artifacts.globalUboScratch.resize(size); + } + for (const auto& [name, offset] : meta.plainUniformOffsetsInUBO) { + // SPIRV-Reflect leaf names never carry a "[0]" suffix; frontend + // reflection keys arrays as "arr[0]" (GL naming), so retry with the + // suffix before declaring the uniform unbacked. + auto locationIt = artifacts.uniformLocations.find(name); + if (locationIt == artifacts.uniformLocations.end()) { + locationIt = artifacts.uniformLocations.find(name + "[0]"); + } + if (locationIt == artifacts.uniformLocations.end()) { + MGLOG_D("ProgramObject %u: BuildGlobalUboRouting - uniform '%s' offset=%u but not found in " + "uniformLocations", + in.externalIndex, name.c_str(), offset); + continue; + } + const Uint baseLocation = locationIt->second; + if (!ProgramObject::IsValidUniformLocation(artifacts, static_cast(baseLocation))) { + continue; + } + + const Int uniformIndex = artifacts.uniformIndexInTProgram[baseLocation]; + const GLint arraySize = ProgramObject::GetUniformArraySizeByTIndex(artifacts, uniformIndex); + SizeT memberSize = 0; + const auto sizeIt = meta.plainUniformMemberSizesInBytes.find(name); + if (sizeIt != meta.plainUniformMemberSizesInBytes.end()) { + memberSize = sizeIt->second; + } + Uint arrayStride = 0; + const auto strideIt = meta.plainUniformArrayStridesInUBO.find(name); + if (strideIt != meta.plainUniformArrayStridesInUBO.end()) { + arrayStride = strideIt->second; + } + + // Array uniforms span one location per element (see DoReflection); + // give each element its real byte offset inside the UBO. + const GLint elementCount = (arraySize > 1 && arrayStride == 0) ? 1 : std::max(arraySize, 1); + for (GLint element = 0; element < elementCount; ++element) { + const Uint location = baseLocation + static_cast(element); + if (location > artifacts.maxUniformLocation || + artifacts.uniformIndexInTProgram[location] != uniformIndex) { + break; + } + artifacts.uniformOffsets[location] = offset + static_cast(element) * arrayStride; + const SizeT consumed = static_cast(element) * arrayStride; + artifacts.uniformSizesInBytes[location] = memberSize > consumed ? memberSize - consumed : 0; + } + MGLOG_D("ProgramObject %u: BuildGlobalUboRouting - uniform '%s' offset=%u stride=%u size=%zu assigned " + "to locations %u..%u", + in.externalIndex, name.c_str(), offset, arrayStride, memberSize, baseLocation, + baseLocation + static_cast(elementCount) - 1); + } + MGLOG_D("ProgramObject %u: BuildGlobalUboRouting - finished parsing module %zu metadata", + in.externalIndex, i); + } + } + + // Fallback pass: a linked program's active non-opaque uniforms must accept + // glUniform*/glGetUniform* even when the optimized SPIR-V no longer contains + // them (AggressiveDCE can remove a dead loop together with the only loads of a + // uniform -- or the entire global UBO, leaving the scratch unallocated). Hand + // such locations CPU-side storage at the (16-byte aligned) tail of the shadow + // buffer; backends bind at least the SPIR-V-declared UBO range, and the GPU + // never reads these bytes, so this only keeps the GL-visible state coherent. + for (Uint location = 0; location <= artifacts.maxUniformLocation; ++location) { + if (artifacts.uniformOffsets[location] != ProgramObject::kInvalidUniformOffset) continue; + if (!ProgramObject::IsValidUniformLocation(artifacts, static_cast(location))) continue; + const auto& uniform = artifacts.program->getUniform(artifacts.uniformIndexInTProgram[location]); + const glslang::TType* type = uniform.getType(); + if (type != nullptr && type->isOpaque()) continue; + if (uniform.index >= 0 && uniform.index < artifacts.program->getNumUniformBlocks() && + std::strstr(artifacts.program->getUniformBlock(uniform.index).name.c_str(), + MG_Util::ShaderTranspiler::GLOBAL_UBO_NAME) == nullptr) { + // Member of a named uniform block: not settable through glUniform*, so it + // needs no global-UBO shadow storage. + continue; + } + + // std140-style slot: the matrix upload paths write column vectors at + // 16-byte strides, so a matrix slot must cover cols * 16 bytes. + SizeT slotSize = MG_Util::GetGLTypeSize(uniform.glDefineType); + if (type != nullptr && type->isMatrix()) { + slotSize = static_cast(type->getMatrixCols()) * 16u; + } + slotSize = (slotSize + 15u) & ~static_cast(15u); + const SizeT slotOffset = (artifacts.globalUboScratch.size() + 15u) & ~static_cast(15u); + artifacts.globalUboScratch.resize(slotOffset + slotSize, 0); + artifacts.uniformOffsets[location] = static_cast(slotOffset); + artifacts.uniformSizesInBytes[location] = slotSize; + MGLOG_D("ProgramObject %u: BuildGlobalUboRouting - uniform '%s' location %u has no UBO backing in the " + "generated SPIR-V (optimized out?); allocated %zu fallback bytes at scratch offset %zu", + in.externalIndex, uniform.name.c_str(), location, slotSize, slotOffset); + } + } + + Bool ProgramLinkTask::ValidateFragmentOutputLocations() { + if (!artifacts.program) return false; + + UnorderedMap colorNumberOwners; + const Int outputCount = artifacts.program->getNumPipeOutputs(); + for (Int index = 0; index < outputCount; ++index) { + const auto& output = artifacts.program->getPipeOutput(index); + if (IsBuiltInPipelineOutput(output)) { + continue; + } + + const String outputName = StripArrayElementSuffix(output.name); + const auto explicitLocation = in.explicitFragDataLocation.find(outputName); + const Int location = explicitLocation != in.explicitFragDataLocation.end() + ? static_cast(explicitLocation->second) + : static_cast(output.layoutLocation()); + const Int span = std::max(output.size, 1); + + if (location < 0 || location + span > in.maxFragmentOutputColorNumber) { + artifacts.infoLog = + std::format("Fragment output '{}' location range [{}, {}) exceeds GL_MAX_DRAW_BUFFERS {}.", + outputName, location, location + span, in.maxFragmentOutputColorNumber); + DeferLog(std::format("ProgramObject {}: Link failed - {}", in.externalIndex, artifacts.infoLog)); + ProgramObject::ResetLinkArtifacts(artifacts); + return false; + } + + for (Int colorNumber = location; colorNumber < location + span; ++colorNumber) { + auto [owner, inserted] = colorNumberOwners.emplace(colorNumber, outputName); + if (!inserted) { + artifacts.infoLog = std::format("Fragment outputs '{}' and '{}' alias color number {}.", + owner->second, outputName, colorNumber); + DeferLog(std::format("ProgramObject {}: Link failed - {}", in.externalIndex, artifacts.infoLog)); + ProgramObject::ResetLinkArtifacts(artifacts); + return false; + } + } + } + + return true; + } + + Bool ProgramLinkTask::ResolveTransformFeedbackVaryings() { + artifacts.xfbVaryings.clear(); + artifacts.xfbStrides.clear(); + artifacts.xfbBufferMode = in.requestedXfbBufferMode; + artifacts.xfbVaryingNameMaxLength = 0; + artifacts.xfbNeedsScatteredCapture = false; + artifacts.xfbPackedStride = 0; + if (in.requestedXfbVaryings.empty()) { + return true; + } + + // Capture happens at the last vertex-processing stage (geometry, then + // tessellation evaluation, then vertex). + const glslang::TIntermediate* captureIntermediate = nullptr; + for (EShLanguage stage : {EShLangGeometry, EShLangTessEvaluation, EShLangVertex}) { + captureIntermediate = artifacts.program->getIntermediate(stage); + if (captureIntermediate != nullptr) { + break; + } + } + if (captureIntermediate == nullptr) { + artifacts.infoLog = + "Transform feedback varyings requested but the program has no vertex-processing stage."; + return false; + } + const glslang::TIntermAggregate* linkerObjects = captureIntermediate->findLinkerObjects(); + + const Bool interleaved = artifacts.xfbBufferMode == GL_INTERLEAVED_ATTRIBS; + Uint32 interleavedOffset = 0; + // ARB_transform_feedback3 lets an interleaved capture leave holes (gl_SkipComponents1..4) + // and move on to the next buffer (gl_NextBuffer). Both only affect where the following + // varyings land, so they are consumed here and never become XfbVaryings of their own - + // which also keeps them out of the name list a backend declares on its own driver. + Uint32 interleavedBufferIndex = 0; + Vector interleavedStrides; + for (SizeT i = 0; i < in.requestedXfbVaryings.size(); ++i) { + const String& name = in.requestedXfbVaryings[i]; + if (interleaved && name == "gl_NextBuffer") { + interleavedStrides.push_back(interleavedOffset); + interleavedOffset = 0; + ++interleavedBufferIndex; + artifacts.xfbNeedsScatteredCapture = true; + continue; + } + if (interleaved && name.size() == 18 && name.compare(0, 17, "gl_SkipComponents") == 0 && + name[17] >= '1' && name[17] <= '4') { + interleavedOffset += static_cast(name[17] - '0') * 4; + artifacts.xfbNeedsScatteredCapture = true; + continue; + } + for (SizeT j = 0; j < i; ++j) { + if (in.requestedXfbVaryings[j] == name) { + artifacts.infoLog = "Transform feedback varying '" + name + "' is specified more than once."; + return false; + } + } + + ProgramObject::XfbVarying varying; + varying.name = name; + Uint32 bytesPerElement = 0; + Bool resolved = false; + if (name == "gl_Position") { + varying.type = GL_FLOAT_VEC4; + varying.size = 1; + bytesPerElement = 16; + resolved = true; + } else if (name == "gl_PointSize") { + varying.type = GL_FLOAT; + varying.size = 1; + bytesPerElement = 4; + resolved = true; + } else if (linkerObjects != nullptr) { + for (const auto* node : linkerObjects->getSequence()) { + const glslang::TIntermSymbol* symbol = node->getAsSymbolNode(); + if (symbol == nullptr || symbol->getType().getQualifier().storage != glslang::EvqVaryingOut) { + continue; + } + if (symbol->getName() != name.c_str()) { + continue; + } + resolved = ResolveXfbSymbolType(symbol->getType(), varying.type, varying.size, bytesPerElement); + break; + } + } + if (!resolved) { + artifacts.infoLog = + "Transform feedback varying '" + name + "' is not an output of the vertex stage."; + return false; + } + + varying.byteSize = bytesPerElement * static_cast(varying.size); + varying.packedOffsetBytes = artifacts.xfbPackedStride; + artifacts.xfbPackedStride += varying.byteSize; + if (interleaved) { + varying.bufferIndex = interleavedBufferIndex; + varying.offsetBytes = interleavedOffset; + interleavedOffset += varying.byteSize; + } else { + varying.bufferIndex = static_cast(artifacts.xfbVaryings.size()); + varying.offsetBytes = 0; + } + artifacts.xfbVaryingNameMaxLength = + std::max(artifacts.xfbVaryingNameMaxLength, static_cast(name.size()) + 1); + artifacts.xfbVaryings.push_back(Move(varying)); + } + + constexpr Uint32 kMaxSeparateAttribs = 4; + constexpr Uint32 kMaxSeparateComponents = 4; + constexpr Uint32 kMaxInterleavedComponents = 64; + constexpr Uint32 kMaxTransformFeedbackBuffers = 4; + if (interleaved) { + interleavedStrides.push_back(interleavedOffset); + if (interleavedStrides.size() > kMaxTransformFeedbackBuffers) { + artifacts.infoLog = "Transform feedback capture uses more buffers than " + "GL_MAX_TRANSFORM_FEEDBACK_BUFFERS."; + return false; + } + for (const Uint32 stride : interleavedStrides) { + if (stride > kMaxInterleavedComponents * 4) { + artifacts.infoLog = "Transform feedback interleaved capture exceeds " + "GL_MAX_TRANSFORM_FEEDBACK_INTERLEAVED_COMPONENTS."; + return false; + } + } + artifacts.xfbStrides = Move(interleavedStrides); + } else { + if (artifacts.xfbVaryings.size() > kMaxSeparateAttribs) { + artifacts.infoLog = "Transform feedback separate capture exceeds " + "GL_MAX_TRANSFORM_FEEDBACK_SEPARATE_ATTRIBS."; + return false; + } + artifacts.xfbStrides.resize(artifacts.xfbVaryings.size()); + for (SizeT i = 0; i < artifacts.xfbVaryings.size(); ++i) { + if (artifacts.xfbVaryings[i].byteSize > kMaxSeparateComponents * 4) { + artifacts.infoLog = "Transform feedback varying '" + artifacts.xfbVaryings[i].name + + "' exceeds GL_MAX_TRANSFORM_FEEDBACK_SEPARATE_COMPONENTS."; + return false; + } + artifacts.xfbStrides[i] = artifacts.xfbVaryings[i].byteSize; + } + } + + ResolveGsTriangleStripCapture(captureIntermediate); + return true; + } + + void ProgramLinkTask::ResolveGsTriangleStripCapture(const glslang::TIntermediate* captureIntermediate) { + artifacts.gsStripTriangles.clear(); + artifacts.gsStripCaptureFixup = false; + if (captureIntermediate == nullptr || artifacts.program == nullptr) { + return; + } + if (artifacts.program->getIntermediate(EShLangGeometry) != captureIntermediate) { + return; + } + if (captureIntermediate->getOutputPrimitive() != glslang::ElgTriangleStrip) { + return; + } + GsEmitSequenceTraverser traverser; + const_cast(captureIntermediate)->getTreeRoot()->traverse(&traverser); + traverser.FlushStrip(); // the invocation end acts as an implicit EndPrimitive + if (!traverser.hasEmit || traverser.inControlFlow || traverser.stripTriangles.empty()) { + return; + } + artifacts.gsStripTriangles = Move(traverser.stripTriangles); + artifacts.gsStripCaptureFixup = true; + } +} // namespace MobileGL::MG_State::GLState diff --git a/MobileGL/MG_State/GLState/ProgramState/ProgramLinkTask.h b/MobileGL/MG_State/GLState/ProgramState/ProgramLinkTask.h new file mode 100644 index 00000000..7c857a6f --- /dev/null +++ b/MobileGL/MG_State/GLState/ProgramState/ProgramLinkTask.h @@ -0,0 +1,111 @@ +// MobileGL - MobileGL/MG_State/GLState/ProgramState/ProgramLinkTask.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 +#include +#include +#include + +namespace MobileGL::MG_State::GLState { + // One attached shader, as the link sees it: never the ShaderObject, always a snapshot. + // + // The ShaderObject is GL-thread-owned and may be re-sourced, detached or destroyed while + // this link is still queued; everything below is either immutable or independently owned, + // so none of that can reach the worker. + struct LinkShaderInput { + ShaderStage stage = ShaderStage::Unknown; + // For the compile-error diagnostic and the compute local_size check, both of which + // quote the ORIGINAL source rather than the preprocessed one. + SharedPtr source; + // The authoritative compiled state. Null, or non-Complete, both read as "this shader + // did not compile" - the same verdict ShaderObject's join gate produces. + SharedPtr compiled; + }; + + // The unit of asynchronous linking: one glLinkProgram's worth of pure CPU work - glslang + // link + mapIO, SPIR-V generation and optimization, the GL-facing reflection surface, the + // global-UBO routing tables, fragment-output validation and transform-feedback + // resolution - with every input it needs snapshotted at enqueue. + // + // Same ownership rule as ShaderCompileTask: the body reads nothing but `in` (all of it + // owned or immutable) and writes nothing but `artifacts`. No GL call, no + // pActiveBackendObject read, no pGLContext->RecordError(); the device limits arrive + // through the CompileEnv snapshot and diagnostics are deferred to the join. + // + // ONE LINK IS ONE HANDLER. RunBody() runs start to finish inside a single pool handler + // and is the only place `artifacts` is written. Do not split it across handlers to + // "pipeline" the reflection half: the intermediates that GlslangToSpv and buildReflection + // share are mutated in a strict order (see the GenerateSpirv-before-DoReflection comment + // in Run()), and a second handler would let a cancel land between them and publish a + // program whose SPIR-V and reflection describe different things. + class ProgramLinkTask final : public MG_Util::Async::JobNode { + public: + // ---- inputs, snapshotted on the GL thread in ProgramObject::Link()'s prologue ---- + struct Inputs { + Uint externalIndex = 0; // logs only + Vector shaders; // already stage-sorted + SharedPtr env; + // The four "takes effect at the next link" request maps. Snapshotted rather than + // referenced, which is precisely what makes glBindAttribLocation and friends + // legal to call over a pending link without cancelling it: the pending link keeps + // linking the inputs it was given. + UnorderedMap explicitAttribLocations; // glBindAttribLocation + UnorderedMap explicitFragDataLocation; // glBindFragDataLocation + UnorderedMap explicitFragDataIndex; // glBindFragDataLocationIndexed + Vector requestedXfbVaryings; // glTransformFeedbackVaryings + GLenum requestedXfbBufferMode = GL_INTERLEAVED_ATTRIBS; + Int maxFragmentOutputColorNumber = 8; // GL_MAX_DRAW_BUFFERS, stamped in by the entry point + } in; + + // ---- output: valid iff IsComplete(), immutable afterwards ---- + // Moved (never copied) into the ProgramObject by EnsureLinkJoined(). + ProgramObject::LinkArtifacts artifacts; + + // Posts this job once every compile in `deps` is terminal - and not one moment + // earlier, so the body never waits on anything (invariant I4: no job body may block + // on another job, or the pool could deadlock with all its workers waiting on each + // other). `deps` is the subset of the snapshot's compile nodes that were still + // in flight; an already-terminal one needs no edge. + // + // GL thread only, and only after the caller has stored a SharedPtr to this node: + // OnDepSettled takes shared_from_this(). + void SubmitAfter(const Vector>& deps); + + private: + void RunBody() override; + + // Runs when one dependency goes terminal - on whichever thread drove it there, which + // is a pool worker for a compile that finished on one. Non-throwing by construction; + // see the definition. + void OnDepSettled(); + + // ---- the link body, split exactly as ProgramObject::Link() had it ---- + // Each returns false to abort the link with `artifacts.infoLog` already set, which is + // GL's definition of a failed link: LINK_STATUS false plus a log, never a GL error. + Bool ConsumeShaders(Vector>& outShaders); + Bool DoReflection(const MG_Util::ShaderTranspiler::CompileEnv& env); + Bool ValidateFragmentOutputLocations(); + Bool ResolveTransformFeedbackVaryings(); + void ResolveGsTriangleStripCapture(const glslang::TIntermediate* captureIntermediate); + void GenerateSpirv(); + void BuildGlobalUboRouting(); + + // Worker-side MGLOG replacement: appended to diagnostics.logLines and replayed by the + // join, on the GL thread, where a serial implementation would have printed it. + // Logging straight from a worker interleaves mid-line with the GL thread's output and + // lands out of order relative to the glLinkProgram that caused it. + void DeferLog(String line); + + // Counts down to zero exactly once. Starts at deps + 1: the extra guard is released + // by SubmitAfter itself, so a dependency that settles while the edges are still being + // registered cannot post the job from under a half-built dependency list. + std::atomic m_remainingDeps{0}; + }; +} // namespace MobileGL::MG_State::GLState diff --git a/MobileGL/MG_State/GLState/ProgramState/ProgramObject.cpp b/MobileGL/MG_State/GLState/ProgramState/ProgramObject.cpp index cd3505db..0a0ca9db 100644 --- a/MobileGL/MG_State/GLState/ProgramState/ProgramObject.cpp +++ b/MobileGL/MG_State/GLState/ProgramState/ProgramObject.cpp @@ -7,134 +7,17 @@ // End of Source File Header #include "ProgramObject.h" +#include "ProgramLinkTask.h" #include -#include -#include +#include #include -#include -#include -#include #include -#include -#include const char* kDefaultFragmentShaderSource = R"(#version 460 core layout(location = 0) out vec4 FragColor; void main() {} )"; -namespace { - // How many vertex input locations reflection may record. Backends consume this through - // GetActiveAttributeLocationMask()/GetAttribType(), so a value below the advertised - // GL_MAX_VERTEX_ATTRIBS would make a legal attribute location invisible to them -- DirectGLES would - // then never feed the shader that attribute's current value. Bounded by the state layer's storage - // capacity, which is also the width of the Uint32 masks backends build from it. - static MobileGL::Int GetReflectionVertexAttribLimit( - const MobileGL::MG_Util::ShaderTranspiler::CompileEnv& env) { - constexpr MobileGL::Int capacity = - static_cast(MobileGL::MG_State::GLState::VertexArrayObject::MAX_VERTEX_ATTRIBS); - if (!env.HasBackend()) return capacity; - - const MobileGL::Int backendLimit = env.params.MaxVertexAttribs; - if (backendLimit <= 0) return capacity; - return std::min(backendLimit, capacity); - } - - static MobileGL::String StripArrayElementSuffix(const MobileGL::String& name) { - const MobileGL::SizeT bracket = name.find('['); - return bracket == MobileGL::String::npos ? name : name.substr(0, bracket); - } - - static bool IsBuiltInPipelineOutput(const glslang::TObjectReflection& output) { - const auto* type = output.getType(); - return type && type->getQualifier().builtIn != glslang::EbvNone; - } - - static int GetVertexInputLocationSpan(GLenum glType) { - switch (glType) { - case GL_FLOAT_MAT2: - case GL_FLOAT_MAT2x3: - case GL_FLOAT_MAT2x4: - return 2; - case GL_FLOAT_MAT3: - case GL_FLOAT_MAT3x2: - case GL_FLOAT_MAT3x4: - return 3; - case GL_FLOAT_MAT4: - case GL_FLOAT_MAT4x2: - case GL_FLOAT_MAT4x3: - return 4; - default: - return 1; - } - } - - static GLenum GetVertexInputLocationType(GLenum glType) { - switch (glType) { - case GL_FLOAT_MAT2: - case GL_FLOAT_MAT3x2: - case GL_FLOAT_MAT4x2: - return GL_FLOAT_VEC2; - case GL_FLOAT_MAT3: - case GL_FLOAT_MAT2x3: - case GL_FLOAT_MAT4x3: - return GL_FLOAT_VEC3; - case GL_FLOAT_MAT4: - case GL_FLOAT_MAT2x4: - case GL_FLOAT_MAT3x4: - return GL_FLOAT_VEC4; - default: - return glType; - } - } - - // How many consecutive uniform locations a uniform occupies. Array uniforms (opaque - // or not) span one location per element so glUniform*v(count > 1) and - // glGetUniformLocation("arr[k]") can address elements individually; everything else - // spans a single location. TObjectReflection.size only carries the element count for - // non-block arrays, so prefer the TType, which is authoritative for both. - static MobileGL::Int GetUniformLocationSpan(const glslang::TObjectReflection& uniform) { - const glslang::TType* type = uniform.getType(); - if (type != nullptr && type->isSizedArray()) { - return std::max(1, type->getOuterArraySize()); - } - return std::max(1, uniform.size); - } - - static bool ComputeShaderDeclaresLocalSize(const MobileGL::String& source) { - bool inLineComment = false; - bool inBlockComment = false; - for (MobileGL::SizeT i = 0; i < source.length(); ++i) { - if (inLineComment) { - inLineComment = source[i] != '\n'; - continue; - } - if (inBlockComment) { - if (source[i] == '*' && i + 1 < source.length() && source[i + 1] == '/') { - inBlockComment = false; - ++i; - } - continue; - } - if (source[i] == '/' && i + 1 < source.length()) { - if (source[i + 1] == '/') { - inLineComment = true; - ++i; - continue; - } - if (source[i + 1] == '*') { - inBlockComment = true; - ++i; - continue; - } - } - if (source.compare(i, 11, "local_size_") == 0) { - return true; - } - } - return false; - } -} namespace MobileGL::MG_State::GLState { static std::atomic s_nextProgramLifetimeId = 1; @@ -143,17 +26,66 @@ namespace MobileGL::MG_State::GLState { return s_nextProgramLifetimeId.fetch_add(1, std::memory_order_relaxed); } - // EnsureLinkJoined() is defined inline in ProgramObject.h (see the comment there for - // why: ~1200 call sites, no LTO). + ProgramObject::~ProgramObject() { CancelLink(); } - void ProgramObject::BumpLinkObservableVersions() { + // EnsureLinkJoined() is defined inline in ProgramObject.h (see the comment there for + // why: ~1200 call sites, no LTO). Only its blocking half lives here. + + void ProgramObject::JoinPendingLink() const { + MOBILEGL_ASSERT(!MG_Util::Async::ShaderCompilePool::IsPoolThread(), + "ProgramObject::EnsureLinkJoined() reached from a pool thread; a job body must never read " + "GL-thread-owned objects"); + + // Move the node out FIRST. The publish below runs GL-thread-only code that reads + // link output through Artifacts() (ApplyDeferredDiagnostics can reach + // pGLContext->RecordError, and a future reader might not be so careful), and with + // m_pendingLink still set that would re-enter this function. + const SharedPtr pending = Move(m_pendingLink); + m_pendingLink.reset(); + + pending->Wait(); + if (pending->IsComplete()) { + // ONE move, not thirty cross-thread field assignments: the artifacts block is + // exactly what a link produces, so moving it IS the publish. + m_artifacts = Move(pending->artifacts); + // The second bump. The first one happened at ENQUEUE so every backend memo read + // "stale" for the whole pending window; this one invalidates anything a backend + // may have cached DURING that window, when m_artifacts still held the previous + // link's output. Without it a memo taken mid-window would survive the publish + // and describe a program that no longer exists. + BumpLinkObservableVersions(); + } + // A node that settled as Cancelled published nothing, and m_artifacts still holds + // what Link()'s prologue left there: cleared, LINK_STATUS false, no info log. That is + // the correct answer for a link that was superseded or abandoned, and it is why no + // caller of CancelLink() has to repair anything afterwards. + + // Worker-side log lines and any deferred GL error are raised HERE, on the GL thread, + // at the first join of the job that produced them - which is where a serial + // implementation would have produced them. + MG_Util::Async::ApplyDeferredDiagnostics(*pending); + } + + Bool ProgramObject::IsPendingLinkTerminal() const { return m_pendingLink->IsTerminal(); } + + void ProgramObject::CancelLink() { + if (!m_pendingLink) 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. Either way nothing waits, and the node + // keeps its own inputs alive for as long as it needs them. + m_pendingLink->Cancel(); + m_pendingLink.reset(); + } + + void ProgramObject::BumpLinkObservableVersions() const { // Relinking regenerates the SPIR-V, so any backend-cached state keyed on // m_backendStateVersion (e.g. the content-hash memo) must be invalidated, // along with every link-derived backend cache (m_linkVersion) and the // last-uploaded-UBO gate (a relink resets uniforms to their initial values, // and that reset must reach the GPU). GL-THREAD ONLY: bumped once per link - // in Link()'s prologue (and by glProgramBinary's mandated failure), never - // from the link body - stage 4 moves that body onto a pool worker, and a + // in Link()'s prologue and at the publish, and by glProgramBinary's mandated + // failure - never from the link body, which runs on a pool worker: a // non-atomic ++ there against the draw path's reads would be exactly the // lost-invalidation memo hazard. ++m_backendStateVersion; @@ -161,16 +93,16 @@ namespace MobileGL::MG_State::GLState { MarkUBOContentDirty(); } - void ProgramObject::ResetLinkArtifacts() { - // Worker-safe pure clear: touches LinkArtifacts only. The link-observable - // version bumps live in BumpLinkObservableVersions() on the GL thread. + void ProgramObject::ResetLinkArtifacts(LinkArtifacts& artifacts) { + // Worker-safe pure clear: touches LinkArtifacts only, which is why the link body can + // call it on its own block. The link-observable version bumps live in + // BumpLinkObservableVersions() on the GL thread. // Deliberately NOT `artifacts = {}`: infoLog, linkedFragDataLocation/Index and the // geometry strip-capture pair live in LinkArtifacts but are not part of what this - // function has ever cleared, and Link()/MarkLinkFailedByProgramBinary() depend on - // that (both write infoLog immediately AFTER calling here). Stage 4 replaces this - // with a whole-struct reset in Link()'s prologue, where the ordering is explicit. - LinkArtifacts& artifacts = Artifacts(); + // function has ever cleared, and its callers depend on that (they write infoLog + // immediately AFTER calling here). Link()'s prologue does not use this - it assigns a + // whole default-constructed block, where the ordering is explicit. artifacts.program.reset(); artifacts.generatedSpirv.clear(); artifacts.uniformLocations.clear(); @@ -204,269 +136,8 @@ namespace MobileGL::MG_State::GLState { artifacts.linkStatus = false; } - namespace { - // GL type enum for a vertex-stage output symbol captured by transform - // feedback. Covers the scalar/vector/matrix float+integer types transform - // feedback may legally capture in GL 3.3. - Bool ResolveXfbSymbolType(const glslang::TType& type, GLenum& outType, GLint& outArraySize, - Uint32& outBytesPerElement) { - outArraySize = type.isArray() ? type.getOuterArraySize() : 1; - const Int columns = type.isMatrix() ? type.getMatrixCols() : 1; - const Int components = type.isMatrix() ? type.getMatrixRows() - : (type.isVector() ? type.getVectorSize() : 1); - const glslang::TBasicType basic = type.getBasicType(); - static constexpr GLenum kFloatTypes[5] = {0, GL_FLOAT, GL_FLOAT_VEC2, GL_FLOAT_VEC3, GL_FLOAT_VEC4}; - static constexpr GLenum kIntTypes[5] = {0, GL_INT, GL_INT_VEC2, GL_INT_VEC3, GL_INT_VEC4}; - static constexpr GLenum kUintTypes[5] = {0, GL_UNSIGNED_INT, GL_UNSIGNED_INT_VEC2, GL_UNSIGNED_INT_VEC3, - GL_UNSIGNED_INT_VEC4}; - static constexpr GLenum kDoubleTypes[5] = {0, GL_DOUBLE, GL_DOUBLE_VEC2, GL_DOUBLE_VEC3, - GL_DOUBLE_VEC4}; - if (type.isMatrix()) { - if (basic != glslang::EbtFloat && basic != glslang::EbtDouble) return false; - static constexpr GLenum kMatTypes[5][5] = { - {}, {}, - {0, 0, GL_FLOAT_MAT2, GL_FLOAT_MAT2x3, GL_FLOAT_MAT2x4}, - {0, 0, GL_FLOAT_MAT3x2, GL_FLOAT_MAT3, GL_FLOAT_MAT3x4}, - {0, 0, GL_FLOAT_MAT4x2, GL_FLOAT_MAT4x3, GL_FLOAT_MAT4}, - }; - static constexpr GLenum kDoubleMatTypes[5][5] = { - {}, {}, - {0, 0, GL_DOUBLE_MAT2, GL_DOUBLE_MAT2x3, GL_DOUBLE_MAT2x4}, - {0, 0, GL_DOUBLE_MAT3x2, GL_DOUBLE_MAT3, GL_DOUBLE_MAT3x4}, - {0, 0, GL_DOUBLE_MAT4x2, GL_DOUBLE_MAT4x3, GL_DOUBLE_MAT4}, - }; - if (columns < 2 || columns > 4 || components < 2 || components > 4) return false; - outType = basic == glslang::EbtDouble ? kDoubleMatTypes[columns][components] - : kMatTypes[columns][components]; - } else if (components >= 1 && components <= 4) { - switch (basic) { - case glslang::EbtFloat: outType = kFloatTypes[components]; break; - case glslang::EbtInt: outType = kIntTypes[components]; break; - case glslang::EbtUint: outType = kUintTypes[components]; break; - // A double-typed varying is capturable like any other; rejecting it here reported - // the varying as "not an output of the vertex stage", which it plainly was. - case glslang::EbtDouble: outType = kDoubleTypes[components]; break; - default: return false; - } - } else { - return false; - } - // GL 4.6 core 11.1.2.1: a double component occupies eight basic machine units, and - // counts as two components against the transform feedback limits. - const Uint32 bytesPerComponent = basic == glslang::EbtDouble ? 8u : 4u; - outBytesPerElement = static_cast(columns * components) * bytesPerComponent; - return true; - } - } // namespace - Bool ProgramObject::ResolveTransformFeedbackVaryings() { - Artifacts().xfbVaryings.clear(); - Artifacts().xfbStrides.clear(); - Artifacts().xfbBufferMode = m_requestedXfbBufferMode; - Artifacts().xfbVaryingNameMaxLength = 0; - Artifacts().xfbNeedsScatteredCapture = false; - Artifacts().xfbPackedStride = 0; - if (m_requestedXfbVaryings.empty()) { - return true; - } - // Capture happens at the last vertex-processing stage (geometry, then - // tessellation evaluation, then vertex). - const glslang::TIntermediate* captureIntermediate = nullptr; - for (EShLanguage stage : {EShLangGeometry, EShLangTessEvaluation, EShLangVertex}) { - captureIntermediate = Artifacts().program->getIntermediate(stage); - if (captureIntermediate != nullptr) { - break; - } - } - if (captureIntermediate == nullptr) { - Artifacts().infoLog = "Transform feedback varyings requested but the program has no vertex-processing stage."; - return false; - } - const glslang::TIntermAggregate* linkerObjects = captureIntermediate->findLinkerObjects(); - - const Bool interleaved = Artifacts().xfbBufferMode == GL_INTERLEAVED_ATTRIBS; - Uint32 interleavedOffset = 0; - // ARB_transform_feedback3 lets an interleaved capture leave holes (gl_SkipComponents1..4) - // and move on to the next buffer (gl_NextBuffer). Both only affect where the following - // varyings land, so they are consumed here and never become XfbVaryings of their own - - // which also keeps them out of the name list a backend declares on its own driver. - Uint32 interleavedBufferIndex = 0; - Vector interleavedStrides; - for (SizeT i = 0; i < m_requestedXfbVaryings.size(); ++i) { - const String& name = m_requestedXfbVaryings[i]; - if (interleaved && name == "gl_NextBuffer") { - interleavedStrides.push_back(interleavedOffset); - interleavedOffset = 0; - ++interleavedBufferIndex; - Artifacts().xfbNeedsScatteredCapture = true; - continue; - } - if (interleaved && name.size() == 18 && name.compare(0, 17, "gl_SkipComponents") == 0 && - name[17] >= '1' && name[17] <= '4') { - interleavedOffset += static_cast(name[17] - '0') * 4; - Artifacts().xfbNeedsScatteredCapture = true; - continue; - } - for (SizeT j = 0; j < i; ++j) { - if (m_requestedXfbVaryings[j] == name) { - Artifacts().infoLog = "Transform feedback varying '" + name + "' is specified more than once."; - return false; - } - } - - XfbVarying varying; - varying.name = name; - Uint32 bytesPerElement = 0; - Bool resolved = false; - if (name == "gl_Position") { - varying.type = GL_FLOAT_VEC4; - varying.size = 1; - bytesPerElement = 16; - resolved = true; - } else if (name == "gl_PointSize") { - varying.type = GL_FLOAT; - varying.size = 1; - bytesPerElement = 4; - resolved = true; - } else if (linkerObjects != nullptr) { - for (const auto* node : linkerObjects->getSequence()) { - const glslang::TIntermSymbol* symbol = node->getAsSymbolNode(); - if (symbol == nullptr || symbol->getType().getQualifier().storage != glslang::EvqVaryingOut) { - continue; - } - if (symbol->getName() != name.c_str()) { - continue; - } - resolved = ResolveXfbSymbolType(symbol->getType(), varying.type, varying.size, bytesPerElement); - break; - } - } - if (!resolved) { - Artifacts().infoLog = "Transform feedback varying '" + name + "' is not an output of the vertex stage."; - return false; - } - - varying.byteSize = bytesPerElement * static_cast(varying.size); - varying.packedOffsetBytes = Artifacts().xfbPackedStride; - Artifacts().xfbPackedStride += varying.byteSize; - if (interleaved) { - varying.bufferIndex = interleavedBufferIndex; - varying.offsetBytes = interleavedOffset; - interleavedOffset += varying.byteSize; - } else { - varying.bufferIndex = static_cast(Artifacts().xfbVaryings.size()); - varying.offsetBytes = 0; - } - Artifacts().xfbVaryingNameMaxLength = - std::max(Artifacts().xfbVaryingNameMaxLength, static_cast(name.size()) + 1); - Artifacts().xfbVaryings.push_back(Move(varying)); - } - - constexpr Uint32 kMaxSeparateAttribs = 4; - constexpr Uint32 kMaxSeparateComponents = 4; - constexpr Uint32 kMaxInterleavedComponents = 64; - constexpr Uint32 kMaxTransformFeedbackBuffers = 4; - if (interleaved) { - interleavedStrides.push_back(interleavedOffset); - if (interleavedStrides.size() > kMaxTransformFeedbackBuffers) { - Artifacts().infoLog = "Transform feedback capture uses more buffers than " - "GL_MAX_TRANSFORM_FEEDBACK_BUFFERS."; - return false; - } - for (const Uint32 stride : interleavedStrides) { - if (stride > kMaxInterleavedComponents * 4) { - Artifacts().infoLog = "Transform feedback interleaved capture exceeds " - "GL_MAX_TRANSFORM_FEEDBACK_INTERLEAVED_COMPONENTS."; - return false; - } - } - Artifacts().xfbStrides = Move(interleavedStrides); - } else { - if (Artifacts().xfbVaryings.size() > kMaxSeparateAttribs) { - Artifacts().infoLog = "Transform feedback separate capture exceeds " - "GL_MAX_TRANSFORM_FEEDBACK_SEPARATE_ATTRIBS."; - return false; - } - Artifacts().xfbStrides.resize(Artifacts().xfbVaryings.size()); - for (SizeT i = 0; i < Artifacts().xfbVaryings.size(); ++i) { - if (Artifacts().xfbVaryings[i].byteSize > kMaxSeparateComponents * 4) { - Artifacts().infoLog = "Transform feedback varying '" + Artifacts().xfbVaryings[i].name + - "' exceeds GL_MAX_TRANSFORM_FEEDBACK_SEPARATE_COMPONENTS."; - return false; - } - Artifacts().xfbStrides[i] = Artifacts().xfbVaryings[i].byteSize; - } - } - - ResolveGsTriangleStripCapture(captureIntermediate); - return true; - } - - namespace { - // Extracts a geometry shader's per-invocation EmitVertex/EndPrimitive sequence - // when it is statically knowable (no emit inside selection/loop/switch). Vulkan - // transform feedback captures triangle strips in plain (i, i+1, i+2) order while - // GL decomposes odd strip triangles as (i+1, i, i+2) (GL 4.6 table 10.1); with - // the static strip lengths the capture buffer can be reordered after EndTF. - class GsEmitSequenceTraverser final : public glslang::TIntermTraverser { - public: - bool visitAggregate(glslang::TVisit, glslang::TIntermAggregate* node) override { - if (node->getOp() == glslang::EOpEmitVertex) { - ++emitCount; - hasEmit = true; - } else if (node->getOp() == glslang::EOpEndPrimitive) { - FlushStrip(); - } - return true; - } - bool visitSelection(glslang::TVisit, glslang::TIntermSelection*) override { - inControlFlow = true; - return true; - } - bool visitLoop(glslang::TVisit, glslang::TIntermLoop*) override { - inControlFlow = true; - return true; - } - bool visitSwitch(glslang::TVisit, glslang::TIntermSwitch*) override { - inControlFlow = true; - return true; - } - void FlushStrip() { - if (emitCount >= 3) { - stripTriangles.push_back(static_cast(emitCount - 2)); - } - emitCount = 0; - } - - Vector stripTriangles; - Uint32 emitCount = 0; - Bool hasEmit = false; - Bool inControlFlow = false; - }; - } // namespace - - void ProgramObject::ResolveGsTriangleStripCapture(const glslang::TIntermediate* captureIntermediate) { - Artifacts().gsStripTriangles.clear(); - Artifacts().gsStripCaptureFixup = false; - if (captureIntermediate == nullptr || Artifacts().program == nullptr) { - return; - } - if (Artifacts().program->getIntermediate(EShLangGeometry) != captureIntermediate) { - return; - } - if (captureIntermediate->getOutputPrimitive() != glslang::ElgTriangleStrip) { - return; - } - GsEmitSequenceTraverser traverser; - const_cast(captureIntermediate)->getTreeRoot()->traverse(&traverser); - traverser.FlushStrip(); // the invocation end acts as an implicit EndPrimitive - if (!traverser.hasEmit || traverser.inControlFlow || traverser.stripTriangles.empty()) { - return; - } - Artifacts().gsStripTriangles = Move(traverser.stripTriangles); - Artifacts().gsStripCaptureFixup = true; - } bool ProgramObject::ShaderIsAttached(const SharedPtr& shader) { MGLOG_D("ProgramObject %u: ShaderIsAttached check for shader %p", m_externalIndex, shader.get()); @@ -477,6 +148,13 @@ namespace MobileGL::MG_State::GLState { return attached; } + // NO CancelLink here, nor in DetachShader below. Both only edit the attach lists, which + // a pending link does not read - it snapshotted (stage, source, compile node) per shader + // at enqueue and is isolated from every later mutation. GL agrees: attaching or detaching + // takes effect at the NEXT link and leaves the current LINK_STATUS alone, so cancelling + // would make `glLinkProgram; glAttachShader; glGetProgramiv(LINK_STATUS)` report FALSE + // for a link that succeeded - and would break glCreateShaderProgramv outright, since that + // is specified as link-then-detach and would discard its own link before anyone read it. bool ProgramObject::AttachShader(const SharedPtr& shader) { MGLOG_D("ProgramObject %u: AttachShader called for shader %p", m_externalIndex, shader.get()); if (ShaderIsAttached(shader)) { @@ -542,10 +220,24 @@ namespace MobileGL::MG_State::GLState { void ProgramObject::Link(Bool addDefaultFSIfMissingForRenderingPipelineProgram) { MGLOG_D("ProgramObject %u: Link start, shaders to link: %zu", m_externalIndex, m_shaders.size()); + // The last link wins. A link still in flight is computing an answer this call is + // about to replace, and nothing has observed it yet (an observation would have + // joined), so it is dropped where it stands - no wait. + CancelLink(); + + // Bumped at ENQUEUE, not at publish, and that ordering is the whole invalidation + // story: from this instant every backend memo keyed on m_backendStateVersion / + // m_linkVersion reads "stale", so nothing can keep using the PREVIOUS link's + // reflection while the new one is still being computed. (The publish bumps a second + // time, for anything cached during the pending window itself.) ++m_backendStateVersion; BumpLinkObservableVersions(); - ResetLinkArtifacts(); - Artifacts().infoLog.clear(); + // A whole-struct reset, unlike ResetLinkArtifacts(): during the pending window this + // is what every gated reader sees, so it has to be the complete "not linked" state - + // including the fields ResetLinkArtifacts deliberately preserves for its own callers. + m_artifacts = {}; + + // ---- GL-thread-owned mutations ---- // Remove detached shaders first for (const auto& detachedShader : m_detachedShaders) { RemoveShader(detachedShader); @@ -556,7 +248,7 @@ namespace MobileGL::MG_State::GLState { AddDefaultFragmentShaderIfMissing(); } if (m_shaders.empty()) { - Artifacts().infoLog = "No shader objects are attached to program."; + m_artifacts.infoLog = "No shader objects are attached to program."; MGLOG_E("ProgramObject %u: Link failed - no shader objects attached.", m_externalIndex); return; } @@ -566,163 +258,52 @@ namespace MobileGL::MG_State::GLState { return a->GetShaderStage() < b->GetShaderStage(); }); - // ---- end of the GL-thread prologue ---- + // ---- end of the GL-thread prologue: everything below is the snapshot ---- // Everything above mutates GL-thread-owned state (the attach lists, the version // counters, the default-FS fixup) and must stay on the calling thread. Everything - // below is a pure function of the snapshot taken here, which is what lets stage 4 - // lift it into a ProgramLinkTask. `env` is the first piece of that snapshot: the - // link's only window onto the backend. + // below is a pure function of what is copied into `in`, which is what lets the body + // run on a worker. Nothing here reads compile OUTPUT - taking the nodes without + // joining them is exactly what makes glLinkProgram not block on glCompileShader. + auto task = MakeShared(); + task->in.externalIndex = m_externalIndex; + task->in.env = MG_Util::ShaderTranspiler::GetCurrentCompileEnv(); + task->in.explicitAttribLocations = m_explicitAttribLocations; + task->in.explicitFragDataLocation = m_explicitFragDataLocation; + task->in.explicitFragDataIndex = m_explicitFragDataIndex; + task->in.requestedXfbVaryings = m_requestedXfbVaryings; + task->in.requestedXfbBufferMode = m_requestedXfbBufferMode; + task->in.maxFragmentOutputColorNumber = m_maxFragmentOutputColorNumber; - // P1 stage 3: linking is still synchronous, so every attached shader's compile has - // to be settled before the body below touches a single one of its artifacts. One - // loop up front rather than leaning on the per-accessor gate, deliberately: it lets - // all the outstanding compiles finish concurrently and blocks once at the end, - // instead of serializing them one join at a time down the loop below. - // - // Placed AFTER the prologue, not before it, so it joins exactly the shader set this - // link will read. Shaders removed by the detach pass above are not joined - the link - // never reads them, their objects are still alive, and whoever queries one next - // joins it then. + Vector> deps; + deps.reserve(m_shaders.size()); + task->in.shaders.reserve(m_shaders.size()); for (const auto& shader : m_shaders) { - shader->JoinCompile(); - } - const SharedPtr envPtr = - MG_Util::ShaderTranspiler::GetCurrentCompileEnv(); - const MG_Util::ShaderTranspiler::CompileEnv& env = *envPtr; - - Vector shaderTypes(m_shaders.size()); - Vector> shaders(m_shaders.size()); - - for (SizeT i = 0; i < m_shaders.size(); i++) { - shaderTypes[i] = MG_Util::ConvertShaderStageToGLEnum(m_shaders[i]->GetShaderStage()); - MGLOG_D("ProgramObject %u: Preparing shader[%zu] stage %s at %p", m_externalIndex, i, - MG_Util::ConvertGLEnumToString(shaderTypes[i]).c_str(), m_shaders[i].get()); - - if (!m_shaders[i]->GetCompileStatus()) { - Artifacts().infoLog = std::format("Linking a {} with compilation error, linking will now terminate. Shader error " - "log:\n{}\nShader src:\n{}", - MG_Util::ConvertGLEnumToString(shaderTypes[i]), m_shaders[i]->GetInfoLog(), - m_shaders[i]->GetShaderSource()); - MGLOG_E("ProgramObject %u: Link failed - shader[%zu] compile status false. InfoLog:\n%s", - m_externalIndex, i, Artifacts().infoLog.c_str()); - return; + const SharedPtr& node = shader->CompiledNodeForLink(); + if (node) { + // This link is now an observer of that node's result, and the ShaderObject is + // no longer the only route to it: without the marker, the ordinary + // link-then-detach-then-delete teardown would cancel a compile this link is + // waiting on and turn a successful link into GL_FALSE. + node->MarkLinkReferenced(); + if (!node->IsTerminal()) deps.push_back(node); } - if (m_shaders[i]->GetShaderStage() == ShaderStage::Compute && - !ComputeShaderDeclaresLocalSize(m_shaders[i]->GetShaderSource())) { - Artifacts().infoLog = "Compute shader is missing a local_size layout declaration."; - MGLOG_E("ProgramObject %u: Link failed - %s", m_externalIndex, Artifacts().infoLog.c_str()); - return; - } - String reparseLog; - shaders[i] = m_shaders[i]->TakeShaderForLink(reparseLog); - if (!shaders[i]) { - // Only reachable when the consume-once re-parse of an already-compiled - // source fails, which no valid state transition produces. - Artifacts().infoLog = std::format("Internal error: re-parsing an attached {} for linking failed:\n{}", - MG_Util::ConvertGLEnumToString(shaderTypes[i]), reparseLog); - MGLOG_E("ProgramObject %u: Link failed - %s", m_externalIndex, Artifacts().infoLog.c_str()); - return; - } - // Deliberately no full-source dump here: a shaderpack stage runs to ~100 KB, and - // one MGLOG line per shader per link is unreadable even single-threaded. Use the - // transpiler dump paths when a specific source is actually needed. - MGLOG_D("ProgramObject %u: shader[%zu] compiled shader ptr %p, src len %zu", m_externalIndex, i, - shaders[i].get(), m_shaders[i]->GetShaderSource().length()); + task->in.shaders.push_back({shader->GetShaderStage(), shader->GetShaderSourcePtr(), node}); } - // Merge the shaders' lexically extracted explicit uniform locations. The same - // uniform declared in several stages must agree on its location (config-A glslang - // enforced this at mapIO; the relaxed parse no longer sees the qualifiers). - for (const auto& shader : m_shaders) { - for (const auto& [name, location] : shader->GetExplicitUniformLocations()) { - const auto [it, inserted] = Artifacts().linkedExplicitUniformLocations.emplace(name, location); - if (!inserted && it->second != location) { - Artifacts().infoLog = std::format( - "Uniform '{}' is declared with conflicting explicit locations ({} and {}) " - "across stages.", - name, it->second, location); - MGLOG_E("ProgramObject %u: Link failed - %s", m_externalIndex, Artifacts().infoLog.c_str()); - return; - } - } - // Sampler/image layout(binding = N) initial units, likewise invisible to the - // relaxed parse. Stage order matches the old per-stage mapIO capture, so a - // name declared in several stages keeps the last stage's binding as before. - for (const auto& [name, binding] : shader->GetExplicitOpaqueBindings()) { - Artifacts().explicitOpaqueUniformBindings[name] = binding; - } - } + m_pendingLink = task; - MG_Util::ShaderTranspiler::ProgramAttrib attrib{.shaders = Move(shaders), - .explicitVertexInLocations = m_explicitAttribLocations, - .explicitFragmentOutLocations = m_explicitFragDataLocation, - .explicitFragmentOutIndices = m_explicitFragDataIndex, - .explicitOpaqueUniformBindings = - &Artifacts().explicitOpaqueUniformBindings}; - - MGLOG_D("ProgramObject %u: Calling ShaderCompiler::LinkProgram", m_externalIndex); - auto result = MG_Util::ShaderTranspiler::ShaderCompiler::LinkProgram(attrib); - if (result) { - Artifacts().linkStatus = true; - Artifacts().program = result.value(); - Artifacts().linkedFragDataLocation = m_explicitFragDataLocation; - Artifacts().linkedFragDataIndex = m_explicitFragDataIndex; - MGLOG_D("ProgramObject %u: LinkProgram succeeded, TProgram ptr %p", m_externalIndex, Artifacts().program.get()); - } else { - Artifacts().infoLog = result.error().log; - MGLOG_E("ProgramObject %u: LinkProgram failed. InfoLog:\n%s", m_externalIndex, Artifacts().infoLog.c_str()); + // Flag off: byte-identical to the synchronous implementation. RunInline() executes + // the same body on this thread and the join below publishes through the same code, so + // the two modes differ only in WHICH thread ran RunBody(). + if (!MG_Util::Async::AsyncShaderCompileEnabled()) { + task->RunInline(); + EnsureLinkJoined(); return; } - - // GL_GEOMETRY_INPUT_TYPE. A draw's primitive type has to be compatible with it - // (GL 4.6 core 11.3.1), so it is resolved for every link, not only a capturing one. - Artifacts().gsInputPrimitive = GL_NONE; - if (const glslang::TIntermediate* gs = Artifacts().program->getIntermediate(EShLangGeometry)) { - switch (gs->getInputPrimitive()) { - case glslang::ElgPoints: Artifacts().gsInputPrimitive = GL_POINTS; break; - case glslang::ElgLines: Artifacts().gsInputPrimitive = GL_LINES; break; - case glslang::ElgLinesAdjacency: Artifacts().gsInputPrimitive = GL_LINES_ADJACENCY; break; - case glslang::ElgTriangles: Artifacts().gsInputPrimitive = GL_TRIANGLES; break; - case glslang::ElgTrianglesAdjacency: Artifacts().gsInputPrimitive = GL_TRIANGLES_ADJACENCY; break; - default: break; - } - } - - // SPIR-V must be generated BEFORE buildReflection touches Artifacts().program: - // reflection's live-variable analysis mutates the intermediates in ways that - // change subsequent GlslangToSpv output (observed: catastrophic uniform - // misbinding on DirectVulkan for UBO-heavy content). The old two-link pipeline - // never ran buildReflection on the SPIR-V-producing program; this order keeps - // that property with the single link. The glUniform*-to-scratch routing - // tables, in contrast, are sized and keyed by reflection results, so they are - // built strictly AFTER DoReflection. (Everything else on the reflection - // surface - locations, sampler units, block bindings/sizes - was measured - // identical in either order.) - MGLOG_D("ProgramObject %u: Starting SPIR-V generation", m_externalIndex); - GenerateSpirv(); - - MGLOG_D("ProgramObject %u: Starting reflection", m_externalIndex); - if (!DoReflection(env)) { - MGLOG_E("ProgramObject %u: Link failed during reflection: %s", m_externalIndex, Artifacts().infoLog.c_str()); - return; - } - - MGLOG_D("ProgramObject %u: Building global-UBO routing tables", m_externalIndex); - BuildGlobalUboRouting(); - MGLOG_D("ProgramObject %u: Reflection done (linkStatus=%d)", m_externalIndex, (int)Artifacts().linkStatus); - if (!ValidateFragmentOutputLocations()) { - return; - } - if (!ResolveTransformFeedbackVaryings()) { - Artifacts().linkStatus = false; - MGLOG_E("ProgramObject %u: transform feedback varying resolution failed: %s", m_externalIndex, - Artifacts().infoLog.c_str()); - return; - } - MGLOG_D("ProgramObject %u: Binary generation finished (generatedSpirv size=%zu)", m_externalIndex, - Artifacts().generatedSpirv.size()); + task->SubmitAfter(deps); } + void ProgramObject::MarkAsDeleted() { MGLOG_D("ProgramObject %u: MarkAsDeleted called (was %s)", m_externalIndex, m_deleteStatus ? "deleted" : "not deleted"); @@ -740,565 +321,6 @@ namespace MobileGL::MG_State::GLState { return m_shaders; } - Bool ProgramObject::DoReflection(const MG_Util::ShaderTranspiler::CompileEnv& env) { - if (!Artifacts().program) { - MGLOG_E("ProgramObject %u: DoReflection called but the linked program is null", m_externalIndex); - Artifacts().linkStatus = false; - Artifacts().infoLog = "DoReflection failed: no program."; - return false; - } - - MGLOG_D("ProgramObject %u: DoReflection - building reflection", m_externalIndex); - // GL-style reflection naming (GL CTS uniform_block relies on all four): - // - BasicArraySuffix: an array uniform is reported as "arr[0]" per the GL spec. - // - StrictArraySuffix: named-block struct arrays expand per element ("s[0].a", - // "s[1].a", ...) following ARB_program_interface_query rules. Default-block - // (loose) uniforms already expand per element without this option. - // - AllBlockVariables: every member of an active named block is active even when - // no shader statement reads it (ES 3.0/GL 3.3 named-block semantics). - // - SharedStd140UBO: a DECLARED uniform block is active even when no member is - // ever read (reflected from the linker objects). PreprocessShaderSource coerces - // every block to std140, so this covers all of them. - if (!Artifacts().program->buildReflection(EShReflectionStrictArraySuffix | EShReflectionBasicArraySuffix | - EShReflectionAllBlockVariables | EShReflectionSharedStd140UBO)) { - Artifacts().linkStatus = false; - Artifacts().infoLog = "Build reflection failed."; - MGLOG_E("ProgramObject %u: DoReflection - buildReflection() returned false", m_externalIndex); - return false; - } - - // ---------- GL-facing index spaces (relaxed-parse cleanup) ---------- - // Blocks first: global-UBO membership drives the uniform filter below. The - // synthesized MGL_GLOBAL_UBO is a transpiler artifact - its members are GL - // default-block uniforms and the block itself must stay invisible to GL (it - // did not exist in the GL-client parse this replaces). - const Int tProgramBlockCount = Artifacts().program->getNumUniformBlocks(); - Artifacts().tProgramBlockIndexToGl.assign(tProgramBlockCount, -1); - Artifacts().glBlockIndexToTProgram.clear(); - for (Int i = 0; i < tProgramBlockCount; i++) { - const auto& ubo = Artifacts().program->getUniformBlock(i); - if (std::strstr(ubo.name.c_str(), MG_Util::ShaderTranspiler::GLOBAL_UBO_NAME) != nullptr) { - continue; - } - Artifacts().tProgramBlockIndexToGl[i] = static_cast(Artifacts().glBlockIndexToTProgram.size()); - Artifacts().glBlockIndexToTProgram.push_back(i); - } - - // ------------ Uniforms (GL Plain) ---------------- - // The relaxed parse sweeps every DECLARED default-block uniform into - // MGL_GLOBAL_UBO whether or not any stage reads it. GL requires a - // declared-but-unreferenced default-block uniform to be inactive (absent from - // glGetActiveUniform, glGetUniformLocation == -1): filter global-UBO members no - // stage references. Named-block members keep GL's every-declared-member-is-active - // semantics, exactly as before. - const Int tProgramUniformCount = Artifacts().program->getNumUniformVariables(); - Artifacts().tProgramUniformIndexToGl.assign(tProgramUniformCount, -1); - Artifacts().glUniformIndexToTProgram.clear(); - const auto isGlobalUboMember = [this](const glslang::TObjectReflection& uniform) { - return uniform.index >= 0 && uniform.index < static_cast(Artifacts().tProgramBlockIndexToGl.size()) && - Artifacts().tProgramBlockIndexToGl[uniform.index] < 0; - }; - for (Int i = 0; i < tProgramUniformCount; i++) { - const auto& uniform = Artifacts().program->getUniform(i); - if (isGlobalUboMember(uniform) && uniform.stages == 0) { - MGLOG_D("ProgramObject %u: Reflection - dead default-block uniform '%s' filtered from the GL " - "surface", - m_externalIndex, uniform.name.c_str()); - continue; - } - Artifacts().tProgramUniformIndexToGl[i] = static_cast(Artifacts().glUniformIndexToTProgram.size()); - Artifacts().glUniformIndexToTProgram.push_back(i); - } - Artifacts().activeUniformCount = static_cast(Artifacts().glUniformIndexToTProgram.size()); - MGLOG_D("ProgramObject %u: Reflection - active uniform count = %d (of %d reflected)", m_externalIndex, - Artifacts().activeUniformCount, tProgramUniformCount); - - // Effective explicit location per TProgram uniform, from two sources: - // - the lexical side-channel for default-block uniforms - the relaxed parse - // dropped their layout(location = N) qualifiers when collecting them into - // MGL_GLOBAL_UBO, so reflection cannot provide them ("source-explicit"); - // - glslang's layoutLocation() for opaque uniforms, where the qualifier - // survives the relaxed parse (and mapIO auto-assigns the rest). - constexpr Uint kNoLocation = glslang::TQualifier::layoutLocationEnd; - Vector effectiveLocation(tProgramUniformCount, kNoLocation); - Vector locationIsSourceExplicit(tProgramUniformCount, false); - UnorderedMap structExplicitCursor; // declared root -> next member location - const auto findExplicitLocation = [this](const String& reflectedName) -> const Int* { - auto it = Artifacts().linkedExplicitUniformLocations.find(reflectedName); - if (it == Artifacts().linkedExplicitUniformLocations.end() && reflectedName.length() > 3 && - reflectedName.compare(reflectedName.length() - 3, 3, "[0]") == 0) { - it = Artifacts().linkedExplicitUniformLocations.find(reflectedName.substr(0, reflectedName.length() - 3)); - } - return it != Artifacts().linkedExplicitUniformLocations.end() ? &it->second : nullptr; - }; - for (const Int i : Artifacts().glUniformIndexToTProgram) { - const auto& uniform = Artifacts().program->getUniform(i); - const glslang::TType* type = uniform.getType(); - const Bool inNamedBlock = uniform.index >= 0 && !isGlobalUboMember(uniform); - if (inNamedBlock) continue; // block members never take glUniform locations - - if (const Int* explicitLocation = findExplicitLocation(uniform.name)) { - effectiveLocation[i] = static_cast(*explicitLocation); - locationIsSourceExplicit[i] = true; - } else if (!Artifacts().linkedExplicitUniformLocations.empty() && - uniform.name.find('.') != String::npos) { - // A struct uniform's explicit location spreads consecutively over its - // flattened members ("s.a", "s[1].b", ...) in reflection order. - const SizeT cut = uniform.name.find_first_of(".["); - const auto rootIt = Artifacts().linkedExplicitUniformLocations.find(uniform.name.substr(0, cut)); - if (rootIt != Artifacts().linkedExplicitUniformLocations.end()) { - auto [cursor, inserted] = - structExplicitCursor.emplace(rootIt->first, static_cast(rootIt->second)); - (void)inserted; - effectiveLocation[i] = cursor->second; - locationIsSourceExplicit[i] = true; - cursor->second += static_cast(GetUniformLocationSpan(uniform)); - } - } - if (effectiveLocation[i] == kNoLocation && type != nullptr && type->isOpaque()) { - effectiveLocation[i] = uniform.layoutLocation(); - } - if (locationIsSourceExplicit[i] && - effectiveLocation[i] + static_cast(GetUniformLocationSpan(uniform)) > kNoLocation) { - // Config A rejected out-of-range explicit locations at parse; keep them - // from growing the location table unboundedly. - Artifacts().infoLog = std::format("Uniform '{}' explicit location {} is out of range.", uniform.name, - effectiveLocation[i]); - ResetLinkArtifacts(); - return false; - } - } - - Int requiredUniformLocations = 0; - for (const Int i : Artifacts().glUniformIndexToTProgram) { - auto& uniform = Artifacts().program->getUniform(i); - const Uint location = effectiveLocation[i]; - const Int locationSpan = GetUniformLocationSpan(uniform); - requiredUniformLocations += locationSpan; - if (location != kNoLocation) { - Artifacts().maxUniformLocation = std::max(Artifacts().maxUniformLocation, location + locationSpan - 1); - } - Artifacts().uniformNameMaxLength = std::max(Artifacts().uniformNameMaxLength, (Int)uniform.name.length()); - Artifacts().uniformLocations[uniform.name] = location; - MGLOG_D("ProgramObject %u: Reflection - uniform[%d] name='%s' effectiveLocation=%d", m_externalIndex, - i, uniform.name.c_str(), location); - } - - MGLOG_D("ProgramObject %u: Reflection - computed maxUniformLocation=%u uniformNameMaxLength=%d", - m_externalIndex, Artifacts().maxUniformLocation, Artifacts().uniformNameMaxLength); - - if (Artifacts().maxUniformLocation + 1 < requiredUniformLocations) { - MGLOG_D("ProgramObject %u: Reflection - maxUniformLocation+1 (%u) < requiredUniformLocations (%d), " - "adjusting", - m_externalIndex, Artifacts().maxUniformLocation + 1, requiredUniformLocations); - // This means we have fewer than enough gaps to fit - // unallocated uniforms - Artifacts().maxUniformLocation = requiredUniformLocations - 1; - } - - // i-th elements refers to uniform at layout(location = i, ...) - Artifacts().uniformIndexInTProgram.resize(Artifacts().maxUniformLocation + 1, glslang::TQualifier::layoutLocationEnd); - Artifacts().uniformSamplerOrImageUnitIndex.resize(Artifacts().maxUniformLocation + 1, -1); - - Vector unallocatedUniformIndex; - - // Pass 1: source-explicit locations. These are API contract - // (ARB_explicit_uniform_location), and an overlap between distinct uniforms is a - // link error - config A's mapIO rejected it ("Uniform location overlaps across - // stages"); the relaxed parse dropped the qualifiers, so it is enforced here. - for (const Int i : Artifacts().glUniformIndexToTProgram) { - auto& uniform = Artifacts().program->getUniform(i); - if (!locationIsSourceExplicit[i] || effectiveLocation[i] == kNoLocation) continue; - const Uint location = effectiveLocation[i]; - const Int locationSpan = GetUniformLocationSpan(uniform); - for (Int element = 0; element < locationSpan; ++element) { - const Int existing = Artifacts().uniformIndexInTProgram[location + element]; - if (existing != glslang::TQualifier::layoutLocationEnd && existing != i) { - Artifacts().infoLog = - std::format("Uniform location overlap: '{}' and '{}' both occupy location {}.", - Artifacts().program->getUniform(existing).name, uniform.name, location + element); - ResetLinkArtifacts(); - return false; - } - Artifacts().uniformIndexInTProgram[location + element] = i; - } - MGLOG_D("ProgramObject %u: Reflection - assigned explicit-location uniform '%s' to locations " - "%u..%u (indexInTProgram=%d)", - m_externalIndex, uniform.name.c_str(), location, location + locationSpan - 1, i); - } - - // Pass 2: glslang-assigned locations (opaque uniforms under the relaxed parse). - // Implementation-chosen, so on a collision with an explicit location the uniform - // is demoted to the first-fit pass below instead of failing the link. - for (const Int i : Artifacts().glUniformIndexToTProgram) { - auto& uniform = Artifacts().program->getUniform(i); - if (locationIsSourceExplicit[i]) continue; - const Uint location = effectiveLocation[i]; - if (location == kNoLocation) { - unallocatedUniformIndex.emplace_back(i); - MGLOG_D("ProgramObject %u: Reflection - uniform '%s' is unallocated, will assign later", - m_externalIndex, uniform.name.c_str()); - continue; // will allocate unallocated uniforms later - } - const Int locationSpan = GetUniformLocationSpan(uniform); - Bool spanIsFree = location + locationSpan - 1 <= Artifacts().maxUniformLocation; - for (Int element = 0; spanIsFree && element < locationSpan; ++element) { - spanIsFree = - Artifacts().uniformIndexInTProgram[location + element] == glslang::TQualifier::layoutLocationEnd; - } - if (!spanIsFree) { - Artifacts().uniformLocations[uniform.name] = kNoLocation; - unallocatedUniformIndex.emplace_back(i); - MGLOG_D("ProgramObject %u: Reflection - uniform '%s' auto location %u collides with an " - "explicit location, demoting to first-fit", - m_externalIndex, uniform.name.c_str(), location); - continue; - } - for (Int element = 0; element < locationSpan; ++element) { - Artifacts().uniformIndexInTProgram[location + element] = i; - } - MGLOG_D("ProgramObject %u: Reflection - assigned uniform '%s' to locations %u..%u " - "(indexInTProgram=%d)", - m_externalIndex, uniform.name.c_str(), location, location + locationSpan - 1, i); - } - - SizeT locNeedle = 0; - std::sort(unallocatedUniformIndex.begin(), unallocatedUniformIndex.end(), [this](Int lhs, Int rhs) { - const auto& lhsUniform = Artifacts().program->getUniform(lhs); - const auto& rhsUniform = Artifacts().program->getUniform(rhs); - return lhsUniform.name < rhsUniform.name; - }); - for (auto index : unallocatedUniformIndex) { - auto& uniform = Artifacts().program->getUniform(index); - const Int locationSpan = GetUniformLocationSpan(uniform); - Bool placed = false; - for (; locNeedle <= Artifacts().maxUniformLocation; locNeedle++) { - bool hasRoom = locNeedle + locationSpan - 1 <= Artifacts().maxUniformLocation; - for (Int element = 0; hasRoom && element < locationSpan; ++element) { - hasRoom = Artifacts().uniformIndexInTProgram[locNeedle + element] == - glslang::TQualifier::layoutLocationEnd; - } - if (!hasRoom) continue; - // Found a vacant location at locNeedle - for (Int element = 0; element < locationSpan; ++element) { - Artifacts().uniformIndexInTProgram[locNeedle + element] = index; - } - Artifacts().uniformLocations[uniform.name] = locNeedle; - MGLOG_D("ProgramObject %u: Reflection - assigned unallocated uniform '%s' to locations %zu..%zu " - "(index %d)", - m_externalIndex, uniform.name.c_str(), locNeedle, locNeedle + locationSpan - 1, index); - locNeedle += locationSpan; - placed = true; - break; - } - if (!placed) { - // Explicit-location uniforms can fragment the space so no contiguous - // span is left; grow the table instead of leaving the uniform without - // a location (which would make it unsettable via glUniform*). - const SizeT base = Artifacts().uniformIndexInTProgram.size(); - Artifacts().uniformIndexInTProgram.resize(base + locationSpan, glslang::TQualifier::layoutLocationEnd); - Artifacts().uniformSamplerOrImageUnitIndex.resize(base + locationSpan, -1); - Artifacts().maxUniformLocation = static_cast(base + locationSpan - 1); - for (Int element = 0; element < locationSpan; ++element) { - Artifacts().uniformIndexInTProgram[base + element] = index; - } - Artifacts().uniformLocations[uniform.name] = static_cast(base); - MGLOG_D("ProgramObject %u: Reflection - grew location table to place uniform '%s' at %zu..%zu", - m_externalIndex, uniform.name.c_str(), base, base + locationSpan - 1); - locNeedle = base + locationSpan; - } - } - - for (const Int i : Artifacts().glUniformIndexToTProgram) { - auto& uniform = Artifacts().program->getUniform(i); - const auto locationIt = Artifacts().uniformLocations.find(uniform.name); - if (locationIt == Artifacts().uniformLocations.end()) { - continue; - } - - const Uint location = locationIt->second; - if (location >= Artifacts().uniformSamplerOrImageUnitIndex.size() || uniform.getType() == nullptr || - !uniform.getType()->isOpaque() || (!uniform.getType()->isTexture() && !uniform.getType()->isImage())) { - continue; - } - - // Reflection names an array "texs[0]" while the layout(binding = N) map from the IO - // resolver is keyed by the declared name ("texs"); look up both spellings. - auto explicitBinding = Artifacts().explicitOpaqueUniformBindings.find(uniform.name); - if (explicitBinding == Artifacts().explicitOpaqueUniformBindings.end() && uniform.name.length() > 3 && - uniform.name.compare(uniform.name.length() - 3, 3, "[0]") == 0) { - explicitBinding = - Artifacts().explicitOpaqueUniformBindings.find(uniform.name.substr(0, uniform.name.length() - 3)); - } - const int initialUnit = - explicitBinding != Artifacts().explicitOpaqueUniformBindings.end() ? static_cast(explicitBinding->second) : 0; - const Int locationSpan = GetUniformLocationSpan(uniform); - for (Int element = 0; element < locationSpan && - location + element < Artifacts().uniformSamplerOrImageUnitIndex.size(); ++element) { - Artifacts().uniformSamplerOrImageUnitIndex[location + element] = - initialUnit + (explicitBinding != Artifacts().explicitOpaqueUniformBindings.end() ? element : 0); - } - MGLOG_D("ProgramObject %u: Reflection - opaque uniform '%s' locations=%u..%u initialUnit=%d", - m_externalIndex, uniform.name.c_str(), location, location + locationSpan - 1, initialUnit); - } - - // ------------ attributes (vertex in) --------------- - Int inCount = Artifacts().program->getNumPipeInputs(); - MGLOG_D("ProgramObject %u: Reflection - pipe input count (attributes) = %d", m_externalIndex, inCount); - - Int maxLoc = -1; - for (int i = 0; i < inCount; ++i) { - Int loc = (Int)Artifacts().program->getPipeInput(i).layoutLocation(); - if (loc >= 0 && loc != glslang::TQualifier::layoutLocationEnd) { - const Int locationSpan = GetVertexInputLocationSpan(Artifacts().program->getPipeInput(i).glDefineType); - maxLoc = std::max(maxLoc, loc + locationSpan - 1); - } - MGLOG_D("ProgramObject %u: Reflection - pipe input[%d] name='%s' layoutLocation=%d glType=%u", - m_externalIndex, i, Artifacts().program->getPipeInput(i).name.c_str(), loc, - Artifacts().program->getPipeInput(i).glDefineType); - } - - if (maxLoc < 0) { - maxLoc = std::max(0, inCount - 1); - } - - const GLint maxAttribs = GetReflectionVertexAttribLimit(env); - MGLOG_D("ProgramObject %u: Reflection - computed maxLoc=%d, using maxAttribs=%d", m_externalIndex, maxLoc, - maxAttribs); - - if (maxLoc >= maxAttribs) { - MGLOG_W("ProgramObject %u: ProgramObject::DoReflection - required attrib location %d >= " - "GL_MAX_VERTEX_ATTRIBS (%d). Clamping.", - m_externalIndex, maxLoc, maxAttribs); - maxLoc = maxAttribs - 1; - } - - Artifacts().attribs.resize(maxLoc + 1); - Artifacts().attribTypes.resize(maxLoc + 1); - - for (int i = 0; i < inCount; ++i) { - auto& inVar = Artifacts().program->getPipeInput(i); - Int location = (Int)inVar.layoutLocation(); - // Builtins reflect under their SPIR-V names here; GL_ACTIVE_ATTRIBUTE_MAX_LENGTH - // must measure the GL spelling glGetActiveAttrib will report. - Artifacts().attribInNameMaxLength = - std::max(Artifacts().attribInNameMaxLength, (Int)NormalizeBuiltinPipeInputName(inVar.name).length()); - - if (location >= 0 && location < (int)Artifacts().attribs.size()) { - const Int locationSpan = GetVertexInputLocationSpan(inVar.glDefineType); - const GLenum locationType = GetVertexInputLocationType(inVar.glDefineType); - for (Int locationOffset = 0; locationOffset < locationSpan; ++locationOffset) { - const Int expandedLocation = location + locationOffset; - if (expandedLocation < 0 || expandedLocation >= static_cast(Artifacts().attribs.size())) { - break; - } - - Artifacts().attribs[expandedLocation] = inVar.name; - Artifacts().attribTypes[expandedLocation] = locationType; - MGLOG_D( - "ProgramObject %u: Reflection - got attrib '%s' at expanded location %d (baseLocation=%d glType=%u expandedType=%u)", - m_externalIndex, - inVar.name.c_str(), - expandedLocation, - location, - inVar.glDefineType, - static_cast(locationType)); - } - } - } - - // ---------- UBO ---------- - // GL-visible blocks only (MGL_GLOBAL_UBO was filtered out above). - const Int uboCount = GetActiveUniformBlocksCount(); - MGLOG_D("ProgramObject %u: Reflection - uniform block count (UBO) = %d", m_externalIndex, uboCount); - Artifacts().uniformBlockBinding.resize(uboCount, -1); - for (Int i = 0; i < uboCount; i++) { - auto& ubo = Artifacts().program->getUniformBlock(Artifacts().glBlockIndexToTProgram[i]); - Artifacts().uniformBlockNameMaxLength = std::max(Artifacts().uniformBlockNameMaxLength, (Int)ubo.name.length()); - Artifacts().uniformBlockIndexByName[ubo.name] = i; - // if there's binding defined in shader as layout(binding = ...), - // retrieve it here - Artifacts().uniformBlockBinding[i] = ubo.getBinding(); - MGLOG_D("ProgramObject %u: Reflection - UBO[%d] name='%s' size=%u binding=%d", m_externalIndex, i, - ubo.name.c_str(), ubo.size, ubo.getBinding()); - } - return true; - } - - void ProgramObject::GenerateSpirv() { - /* As we passed first stage compilation/linking, - * we'll assume all the operations here should - * pass. We may be able to employ some optimizations - * here without the burden of error reporting. - */ - using namespace MG_Util::ShaderTranspiler; - MGLOG_D("ProgramObject %u: GenerateSpirv - start", m_externalIndex); - - // The shaders were parsed once, in the link-compatible (relaxed Vulkan-rules) - // configuration, and Artifacts().program linked those parses - so Artifacts().program IS the - // program the backends consume. Generate SPIR-V straight from its - // intermediates; the full re-parse + re-link that used to live here (one - // glslang pass per shader per link) is gone. - Vector shaderTypes(m_shaders.size()); - for (SizeT i = 0; i < m_shaders.size(); i++) { - shaderTypes[i] = MG_Util::ConvertShaderStageToGLEnum(m_shaders[i]->GetShaderStage()); - } - - ProgramBinaryAttrib binaryAttrib{ - .shaderTypes = shaderTypes, - .program = *Artifacts().program, - }; - MGLOG_D("ProgramObject %u: GenerateSpirv - requesting SPIR-V binary from program", m_externalIndex); - auto binaryResult = ShaderCompiler::GetSpirvBinaryFromProgram(binaryAttrib); - if (!binaryResult) { - MGLOG_E("ProgramObject %u: GenerateSpirv - GetSpirvBinaryFromProgram failed", m_externalIndex); - } - MOBILEGL_ASSERT(binaryResult, "GetSpirvBinaryFromProgram failed"); - Artifacts().generatedSpirv = Move(binaryResult.value()); - MGLOG_D("ProgramObject %u: GenerateSpirv - generated %zu SPIR-V modules", m_externalIndex, - Artifacts().generatedSpirv.size()); - - // Linked SPIR-V generated, sanitize and optimize it - for (auto& spv : Artifacts().generatedSpirv) { - auto success = ShaderCompiler::SanitizeAndOptimizeBinary(spv, spv); - MOBILEGL_ASSERT(success, "SanitizeBinary failed"); - } - } - - void ProgramObject::BuildGlobalUboRouting() { - using namespace MG_Util::ShaderTranspiler; - Vector shaderTypes(m_shaders.size()); - for (SizeT i = 0; i < m_shaders.size(); i++) { - shaderTypes[i] = MG_Util::ConvertShaderStageToGLEnum(m_shaders[i]->GetShaderStage()); - } - - Artifacts().uniformSizesInBytes.clear(); - Artifacts().uniformOffsets.clear(); - Artifacts().globalUboScratch.clear(); - // kInvalidUniformOffset marks locations that end up without global-UBO backing - // (e.g. the optimizer eliminated every use of the uniform); the fallback pass - // below gives those locations tail storage so glUniform* always has a target. - Artifacts().uniformOffsets.resize(Artifacts().maxUniformLocation + 1, kInvalidUniformOffset); - Artifacts().uniformSizesInBytes.resize(Artifacts().maxUniformLocation + 1, 0); - for (SizeT i = 0; i < Artifacts().generatedSpirv.size(); i++) { - auto& spv = Artifacts().generatedSpirv[i]; - - auto shaderType = shaderTypes[i]; - MGLOG_D("ProgramObject %u: BuildGlobalUboRouting - parsing SPIR-V meta data for module %zu " - "(shaderType=%u, wordCount=%zu)", - m_externalIndex, i, shaderType, spv.size()); - SpvcSession session(spv, SessionUsageBit::Reflection); - auto result = session.ParseMetaData(); - if (result < 0) { - MGLOG_D("ProgramObject %u: BuildGlobalUboRouting - SpvcSession::ParseMetaData failed for module %zu, " - "err = %d%s", - m_externalIndex, i, result, - (result == SPVC_ERROR_INVALID_SPIRV ? ". Probably no global UBO?" : "")); - continue; - } else { - auto& meta = session.GetMetadata(); - auto size = meta.globalUboSize; - MGLOG_D("ProgramObject %u: BuildGlobalUboRouting - SPIR-V meta: uboSize=%zu plainUniformCount=%zu " - "plainUniformOffsets=%zu", - m_externalIndex, meta.globalUboSize, meta.plainUniformMemberSizesInBytes.size(), - meta.plainUniformOffsetsInUBO.size()); - if (size == 0) { - continue; - } - if (Artifacts().globalUboScratch.size() < size) { - Artifacts().globalUboScratch.resize(size); - } - for (const auto& [name, offset] : meta.plainUniformOffsetsInUBO) { - // SPIRV-Reflect leaf names never carry a "[0]" suffix; frontend - // reflection keys arrays as "arr[0]" (GL naming), so retry with the - // suffix before declaring the uniform unbacked. - auto locationIt = Artifacts().uniformLocations.find(name); - if (locationIt == Artifacts().uniformLocations.end()) { - locationIt = Artifacts().uniformLocations.find(name + "[0]"); - } - if (locationIt == Artifacts().uniformLocations.end()) { - MGLOG_D("ProgramObject %u: BuildGlobalUboRouting - uniform '%s' offset=%u but not found in " - "uniformLocations", - m_externalIndex, name.c_str(), offset); - continue; - } - const Uint baseLocation = locationIt->second; - if (!IsValidUniformLocation(static_cast(baseLocation))) { - continue; - } - - const Int uniformIndex = Artifacts().uniformIndexInTProgram[baseLocation]; - const GLint arraySize = GetUniformArraySizeByTIndex(uniformIndex); - SizeT memberSize = 0; - const auto sizeIt = meta.plainUniformMemberSizesInBytes.find(name); - if (sizeIt != meta.plainUniformMemberSizesInBytes.end()) { - memberSize = sizeIt->second; - } - Uint arrayStride = 0; - const auto strideIt = meta.plainUniformArrayStridesInUBO.find(name); - if (strideIt != meta.plainUniformArrayStridesInUBO.end()) { - arrayStride = strideIt->second; - } - - // Array uniforms span one location per element (see DoReflection); - // give each element its real byte offset inside the UBO. - const GLint elementCount = (arraySize > 1 && arrayStride == 0) ? 1 : std::max(arraySize, 1); - for (GLint element = 0; element < elementCount; ++element) { - const Uint location = baseLocation + static_cast(element); - if (location > Artifacts().maxUniformLocation || Artifacts().uniformIndexInTProgram[location] != uniformIndex) { - break; - } - Artifacts().uniformOffsets[location] = offset + static_cast(element) * arrayStride; - const SizeT consumed = static_cast(element) * arrayStride; - Artifacts().uniformSizesInBytes[location] = memberSize > consumed ? memberSize - consumed : 0; - } - MGLOG_D("ProgramObject %u: BuildGlobalUboRouting - uniform '%s' offset=%u stride=%u size=%zu assigned " - "to locations %u..%u", - m_externalIndex, name.c_str(), offset, arrayStride, memberSize, baseLocation, - baseLocation + static_cast(elementCount) - 1); - } - MGLOG_D("ProgramObject %u: BuildGlobalUboRouting - finished parsing module %zu metadata", - m_externalIndex, i); - } - } - - // Fallback pass: a linked program's active non-opaque uniforms must accept - // glUniform*/glGetUniform* even when the optimized SPIR-V no longer contains - // them (AggressiveDCE can remove a dead loop together with the only loads of a - // uniform -- or the entire global UBO, leaving the scratch unallocated). Hand - // such locations CPU-side storage at the (16-byte aligned) tail of the shadow - // buffer; backends bind at least the SPIR-V-declared UBO range, and the GPU - // never reads these bytes, so this only keeps the GL-visible state coherent. - for (Uint location = 0; location <= Artifacts().maxUniformLocation; ++location) { - if (Artifacts().uniformOffsets[location] != kInvalidUniformOffset) continue; - if (!IsValidUniformLocation(static_cast(location))) continue; - const auto& uniform = Artifacts().program->getUniform(Artifacts().uniformIndexInTProgram[location]); - const glslang::TType* type = uniform.getType(); - if (type != nullptr && type->isOpaque()) continue; - if (uniform.index >= 0 && uniform.index < Artifacts().program->getNumUniformBlocks() && - std::strstr(Artifacts().program->getUniformBlock(uniform.index).name.c_str(), - MG_Util::ShaderTranspiler::GLOBAL_UBO_NAME) == nullptr) { - // Member of a named uniform block: not settable through glUniform*, so it - // needs no global-UBO shadow storage. - continue; - } - - // std140-style slot: the matrix upload paths write column vectors at - // 16-byte strides, so a matrix slot must cover cols * 16 bytes. - SizeT slotSize = MG_Util::GetGLTypeSize(uniform.glDefineType); - if (type != nullptr && type->isMatrix()) { - slotSize = static_cast(type->getMatrixCols()) * 16u; - } - slotSize = (slotSize + 15u) & ~static_cast(15u); - const SizeT slotOffset = (Artifacts().globalUboScratch.size() + 15u) & ~static_cast(15u); - Artifacts().globalUboScratch.resize(slotOffset + slotSize, 0); - Artifacts().uniformOffsets[location] = static_cast(slotOffset); - Artifacts().uniformSizesInBytes[location] = slotSize; - MGLOG_D("ProgramObject %u: BuildGlobalUboRouting - uniform '%s' location %u has no UBO backing in the " - "generated SPIR-V (optimized out?); allocated %zu fallback bytes at scratch offset %zu", - m_externalIndex, uniform.name.c_str(), location, slotSize, slotOffset); - } - } void ProgramObject::SetExplicitVertexInLocation(Uint index, const char* name) { MGLOG_D("ProgramObject %u: SetExplicitVertexInLocation called name='%s' index=%u", m_externalIndex, name, @@ -1322,46 +344,6 @@ namespace MobileGL::MG_State::GLState { name, colorIndex); } - Bool ProgramObject::ValidateFragmentOutputLocations() { - if (!Artifacts().program) return false; - - UnorderedMap colorNumberOwners; - const Int outputCount = Artifacts().program->getNumPipeOutputs(); - for (Int index = 0; index < outputCount; ++index) { - const auto& output = Artifacts().program->getPipeOutput(index); - if (IsBuiltInPipelineOutput(output)) { - continue; - } - - const String outputName = StripArrayElementSuffix(output.name); - const auto explicitLocation = m_explicitFragDataLocation.find(outputName); - const Int location = explicitLocation != m_explicitFragDataLocation.end() - ? static_cast(explicitLocation->second) - : static_cast(output.layoutLocation()); - const Int span = std::max(output.size, 1); - - if (location < 0 || location + span > m_maxFragmentOutputColorNumber) { - Artifacts().infoLog = std::format("Fragment output '{}' location range [{}, {}) exceeds GL_MAX_DRAW_BUFFERS {}.", - outputName, location, location + span, m_maxFragmentOutputColorNumber); - MGLOG_E("ProgramObject %u: Link failed - %s", m_externalIndex, Artifacts().infoLog.c_str()); - ResetLinkArtifacts(); - return false; - } - - for (Int colorNumber = location; colorNumber < location + span; ++colorNumber) { - auto [owner, inserted] = colorNumberOwners.emplace(colorNumber, outputName); - if (!inserted) { - Artifacts().infoLog = std::format("Fragment outputs '{}' and '{}' alias color number {}.", - owner->second, outputName, colorNumber); - MGLOG_E("ProgramObject %u: Link failed - %s", m_externalIndex, Artifacts().infoLog.c_str()); - ResetLinkArtifacts(); - return false; - } - } - } - - return true; - } Int ProgramObject::GetFragmentDataLocation(const char* name) { if (!Artifacts().program || !name) return -1; diff --git a/MobileGL/MG_State/GLState/ProgramState/ProgramObject.h b/MobileGL/MG_State/GLState/ProgramState/ProgramObject.h index 37dceae6..1c8db92e 100644 --- a/MobileGL/MG_State/GLState/ProgramState/ProgramObject.h +++ b/MobileGL/MG_State/GLState/ProgramState/ProgramObject.h @@ -14,9 +14,22 @@ #include namespace MobileGL::MG_State::GLState { + // The link job. Only ever held by SharedPtr here, so a forward declaration is enough - + // ProgramLinkTask.h includes THIS header (it outputs a LinkArtifacts), so including it + // back would be circular. The destructor is therefore out of line. + class ProgramLinkTask; + class ProgramObject { public: ProgramObject(Uint externalIndex) : m_externalIndex(externalIndex), m_lifetimeId(AllocateLifetimeId()) {} + // Cancel-not-join, exactly like ~ShaderObject: the link job owns its inputs, so an + // in-flight link whose program 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. + // Out of line because ProgramLinkTask is incomplete here. + ~ProgramObject(); + ProgramObject(const ProgramObject&) = delete; + ProgramObject& operator=(const ProgramObject&) = delete; + bool ShaderIsAttached(const SharedPtr& shader); // GL-visible attachment: in the attach list and not pending detach (glDetachShader // defers the actual removal to the next link). @@ -151,14 +164,7 @@ namespace MobileGL::MG_State::GLState { : -1; } - Bool IsValidUniformLocation(Int location) const { - if (location < 0 || location > static_cast(Artifacts().maxUniformLocation)) return false; - if (static_cast(location) >= Artifacts().uniformIndexInTProgram.size()) return false; - const Int uniformIndexInProgram = Artifacts().uniformIndexInTProgram[location]; - return uniformIndexInProgram != glslang::TQualifier::layoutLocationEnd && - uniformIndexInProgram >= 0 && - uniformIndexInProgram < static_cast(Artifacts().tProgramUniformIndexToGl.size()); - } + Bool IsValidUniformLocation(Int location) const { return IsValidUniformLocation(Artifacts(), location); } GLenum GetUniformType(Uint location) const { auto& uniform = Artifacts().program->getUniform(Artifacts().uniformIndexInTProgram[location]); @@ -176,12 +182,7 @@ namespace MobileGL::MG_State::GLState { // for both. GL 3.3 core uniforms are always sized. Takes a TProgram uniform index (the space // the artifacts' uniformIndexInTProgram stores). GLint GetUniformArraySizeByTIndex(Int tIndex) const { - const auto& uniform = Artifacts().program->getUniform(tIndex); - const glslang::TType* type = uniform.getType(); - if (type != nullptr && type->isSizedArray()) { - return type->getOuterArraySize(); - } - return uniform.size < 1 ? 1 : uniform.size; + return GetUniformArraySizeByTIndex(Artifacts(), tIndex); } GLint GetActiveUniformArraySize(Uint index) const { @@ -371,7 +372,7 @@ namespace MobileGL::MG_State::GLState { // can skip re-uploading an unchanged UBO on every draw. ~0u is reserved as the // backends' "never uploaded" sentinel, so skip over it on wrap. Uint32 GetUBOContentVersion() const { return m_uboContentVersion; } - void MarkUBOContentDirty() { + void MarkUBOContentDirty() const { if (++m_uboContentVersion == ~0u) m_uboContentVersion = 0; } Uint32 GetBackendStateVersion() const { return m_backendStateVersion; } @@ -439,8 +440,13 @@ namespace MobileGL::MG_State::GLState { // glProgramBinary always fails here (there is no format it could accept) and the // spec then requires the program's LINK_STATUS to read FALSE. void MarkLinkFailedByProgramBinary() { + // Before anything reads m_artifacts: a pending link would otherwise publish its + // (possibly successful) result over the failure this call is required to install + // - and Artifacts() below would be the thing that let it. Cancel-not-join: GL + // gives glProgramBinary no reason to wait for a link it is about to invalidate. + CancelLink(); BumpLinkObservableVersions(); - ResetLinkArtifacts(); + ResetLinkArtifacts(Artifacts()); Artifacts().infoLog = "No program binary format is supported."; } Bool GetValidateStatus() const { return m_validateStatus; } @@ -633,12 +639,68 @@ namespace MobileGL::MG_State::GLState { Uint32 xfbPackedStride = 0; }; - // Blocks until a pending link (P1 stage 4 onwards) has published its artifacts. - // Public because a few call sites have to join without reading anything - see the - // explicit-join list in the P1 design. Today there is never a pending link, so this - // is a no-op; it is wired up when glLinkProgram starts enqueueing. + // ---- artifacts-only helpers, shared with ProgramLinkTask ---- + // Static and taking the block explicitly, because from stage 4 the link BODY needs + // them while its artifacts still live on the job node, not on any ProgramObject. The + // member overloads above are the same functions read through the join gate. + + // Clears every field one link produces, EXCEPT infoLog, linkedFragDataLocation/Index + // and the geometry strip-capture pair. That exception is load-bearing: the callers + // that survive (glProgramBinary's mandated failure, and the link body's own mid-link + // aborts) write infoLog immediately AFTER calling here. Link()'s prologue does not + // use this at all - it assigns a whole default-constructed LinkArtifacts, where the + // ordering is explicit and nothing is exempt. + static void ResetLinkArtifacts(LinkArtifacts& artifacts); + + static Bool IsValidUniformLocation(const LinkArtifacts& artifacts, Int location) { + if (location < 0 || location > static_cast(artifacts.maxUniformLocation)) return false; + if (static_cast(location) >= artifacts.uniformIndexInTProgram.size()) return false; + const Int uniformIndexInProgram = artifacts.uniformIndexInTProgram[location]; + return uniformIndexInProgram != glslang::TQualifier::layoutLocationEnd && + uniformIndexInProgram >= 0 && + uniformIndexInProgram < static_cast(artifacts.tProgramUniformIndexToGl.size()); + } + + // Number of active array elements (GL_UNIFORM_SIZE / GL_ARRAY_SIZE); 1 for a non-array. + // glslang's TObjectReflection.size only carries the element count for a NON-block array; for + // a block array member it reports 1, so take the count from the TType, which is authoritative + // for both. GL 3.3 core uniforms are always sized. Takes a TProgram uniform index (the space + // the artifacts' uniformIndexInTProgram stores). + static GLint GetUniformArraySizeByTIndex(const LinkArtifacts& artifacts, Int tIndex) { + const auto& uniform = artifacts.program->getUniform(tIndex); + const glslang::TType* type = uniform.getType(); + if (type != nullptr && type->isSizedArray()) { + return type->getOuterArraySize(); + } + return uniform.size < 1 ? 1 : uniform.size; + } + + // Blocks until a pending link has published its artifacts. Public because a few call + // sites have to join without reading anything - see the explicit-join list (J1-J8) in + // the P1 design. GL thread only. void JoinLink() const { EnsureLinkJoined(); } + // Drops a link that is still in flight, without waiting for it. Called at the points + // where the pending link's result stops being the answer to "what did this program + // link to": a re-link supersedes it, glProgramBinary must force LINK_STATUS false, + // and a destroyed program has no observers left. + // + // Deliberately NOT called by the "takes effect at the next link" setters + // (glBindAttribLocation, glBindFragDataLocation(Indexed), glTransformFeedbackVaryings, + // glProgramParameteri) NOR by glAttachShader/glDetachShader. Every one of those is + // defined by GL to leave the CURRENT link result alone, and the pending link already + // snapshotted its own inputs at enqueue, so it is computing exactly the answer GL + // requires. Cancelling on any of them would make + // glLinkProgram(p); ; glGetProgramiv(p, GL_LINK_STATUS) + // report FALSE for a link that succeeded - and for the attach/detach pair it would + // additionally break glCreateShaderProgramv, which detaches immediately after linking. + void CancelLink(); + + // MUST NOT JOIN - this is what GL_COMPLETION_STATUS_KHR reads when the extension + // surface lands. "No job at all" counts as complete: there is nothing outstanding to + // wait for. + Bool IsLinkComplete() const { return m_pendingLink == nullptr || IsPendingLinkTerminal(); } + void SetTransformFeedbackVaryings(Vector&& names, GLenum bufferMode) { m_requestedXfbVaryings = Move(names); m_requestedXfbBufferMode = bufferMode; @@ -685,19 +747,22 @@ namespace MobileGL::MG_State::GLState { private: // ---- The one and only join gate for link output (P1 invariant I5) ---- // Blocks until a pending link has finished and its LinkArtifacts have been - // published into m_artifacts. Today no link is ever pending - glLinkProgram still - // runs the whole body inline - so this is an unconditional no-op, and the whole - // Artifacts() indirection compiles away. It exists NOW so that the ~120 readers of - // link output are already routed through it when stage 4 makes it block: the edit - // that turns links asynchronous then touches this function and nothing else. + // published into m_artifacts. It exists so that the ~120 readers of link output are + // routed through it by the compiler rather than by review: m_artifacts is private + // and Artifacts() is the only spelling that reaches it. // - // Defined inline (not in ProgramObject.cpp) on purpose: this is called from every - // Artifacts() read - ~1200 call sites project-wide - and the project never builds - // with LTO (MOBILEGL_ENABLE_LTO=OFF), so an out-of-line empty body would leave a - // real cross-TU call at every one of them instead of folding away. Stage 4's - // version, which actually blocks, moves the wait itself out-of-line behind a - // `m_pendingLink` check that stays inline here. - void EnsureLinkJoined() const {} + // The fast path - no pending link - is one predictable branch and stays inline: it + // runs on every Artifacts() read (~1200 call sites project-wide) and the project + // never builds with LTO (MOBILEGL_ENABLE_LTO=OFF), so an out-of-line body would be a + // real cross-TU call at every one of them. The blocking half is out of line. + void EnsureLinkJoined() const { + if (m_pendingLink) JoinPendingLink(); + } + void JoinPendingLink() const; + // ProgramLinkTask is incomplete here, so IsLinkComplete()'s non-joining peek at the + // node's state goes through this out-of-line helper. + Bool IsPendingLinkTerminal() const; + LinkArtifacts& Artifacts() { EnsureLinkJoined(); return m_artifacts; @@ -707,28 +772,10 @@ namespace MobileGL::MG_State::GLState { return m_artifacts; } - void ResetLinkArtifacts(); - // GL-thread-only companion to ResetLinkArtifacts (see its definition). - void BumpLinkObservableVersions(); - // Builds the GL-facing reflection surface from the linked TProgram. Returns false - // (with the artifacts' infoLog set and link artifacts reset) when reflection itself fails or an - // explicit-uniform-location conflict makes the link invalid. - Bool DoReflection(const MG_Util::ShaderTranspiler::CompileEnv& env); - // Resolves the requested transform feedback varyings against the linked - // vertex stage; fails the link (GL semantics) on unknown or duplicate - // names or exceeded capture limits. - Bool ResolveTransformFeedbackVaryings(); - void ResolveGsTriangleStripCapture(const glslang::TIntermediate* captureIntermediate); - // The former GenerateBinary, split around DoReflection's data dependencies: - // SPIR-V must be generated BEFORE buildReflection touches the linked TProgram (its - // live-variable analysis mutates the intermediates enough to change - // GlslangToSpv output), while the glUniform*-to-global-UBO routing tables are - // sized and keyed by reflection results (maxUniformLocation, uniformLocations) - // and so must run AFTER it. - void GenerateSpirv(); - void BuildGlobalUboRouting(); + // GL-thread-only companion to ResetLinkArtifacts (see its definition). Const because + // the publish half of the join calls it; see the mutable counters below. + void BumpLinkObservableVersions() const; void AddDefaultFragmentShaderIfMissing(); - Bool ValidateFragmentOutputLocations(); static Uint64 AllocateLifetimeId(); @@ -762,7 +809,10 @@ namespace MobileGL::MG_State::GLState { Bool m_binaryRetrievableHint = false; Bool m_separable = false; Bool m_validateStatus = true; - Uint32 m_backendStateVersion = 0; + // Mutable, like m_artifacts and for the same reason: publishing a pending link is a + // READ-side operation (the first gated getter is what pulls the result in), and the + // publish has to bump these. Still GL-thread-only - a worker never touches them. + mutable Uint32 m_backendStateVersion = 0; // Backend-owned content-hash memo (see GetBackendHashMemo): valid only while // m_backendStateVersion matches. Several slots, not one: a backend may resolve the same @@ -778,12 +828,20 @@ namespace MobileGL::MG_State::GLState { mutable Array m_backendHashMemoSlots{}; mutable SizeT m_backendHashMemoNextSlot = 0; mutable Uint32 m_backendHashMemoVersion = ~0u; - Uint32 m_uboContentVersion = 0; - Uint32 m_linkVersion = 0; + mutable Uint32 m_uboContentVersion = 0; + mutable Uint32 m_linkVersion = 0; // ---- Link OUTPUT ---- // Written by the link and by the post-link setters GL allows (glUniform1i's sampler // unit, glUniformBlockBinding). Reachable only through Artifacts(); see LinkArtifacts. - LinkArtifacts m_artifacts; + // + // Mutable because publishing is a READ-side operation: a const getter has to be able + // to settle an outstanding link before answering it. + mutable LinkArtifacts m_artifacts; + + // The link job, from enqueue until the first observable read pulls its result. Null + // means m_artifacts is already the answer - which is the state every reader outside + // the pending window sees, and the whole reason the gate above is one branch. + mutable SharedPtr m_pendingLink; }; } // namespace MobileGL::MG_State::GLState diff --git a/MobileGL/MG_State/GLState/ProgramState/ProgramState.cpp b/MobileGL/MG_State/GLState/ProgramState/ProgramState.cpp index a16a1e6c..2f529e5b 100644 --- a/MobileGL/MG_State/GLState/ProgramState/ProgramState.cpp +++ b/MobileGL/MG_State/GLState/ProgramState/ProgramState.cpp @@ -39,6 +39,14 @@ namespace MobileGL::MG_State::GLState { void ProgramState::DestroyProgramSlot(const Uint program) { auto& programObject = m_programObjects[program]; + // P1 join site J4/J5 (glDeleteProgram, and the deferred destroy UseProgram performs + // when a deletion-flagged program stops being current). The program's name is about + // to go, so nothing can observe its link any more: cancel-not-join, so a delete never + // blocks the GL thread on a worker. Explicit rather than left to ~ProgramObject, + // because the reset below only destroys the object if this table held the last + // reference - a program still bound as current, or still referenced by a pipeline, + // outlives it, and its link should stop the moment the name does. + programObject->CancelLink(); // Snapshot the attachments: deleting the program is a detach point for shaders // that were flagged with glDeleteShader while still attached. const Vector> attachedShaders = programObject->GetAttachedShaders(); diff --git a/MobileGL/MG_State/GLState/ProgramState/ShaderCompileTask.cpp b/MobileGL/MG_State/GLState/ProgramState/ShaderCompileTask.cpp index 33670779..70165f11 100644 --- a/MobileGL/MG_State/GLState/ProgramState/ShaderCompileTask.cpp +++ b/MobileGL/MG_State/GLState/ProgramState/ShaderCompileTask.cpp @@ -185,24 +185,16 @@ namespace { return result; } - // glslang has no "detach this thread" API in the vendored revision (there is no - // InitThread/DetachThread pair any more; thread attachment is implicit through - // thread_local state, and glslang::InitializeProcess() is process-wide, refcounted and - // mutex-guarded, so it needs no per-worker counterpart). - // - // What DOES need undoing is the thread pool allocator: TShader::parse sets the calling - // thread's TLS allocator to the shader's own pool and never restores it. Left pointing - // there, the next allocation this worker makes - in an unrelated job, or in glslang code - // reached from a different object - would come out of a pool the GL thread may already - // have deleted with the TShader. SetThreadPoolAllocator(nullptr) reverts the thread to - // its own thread_local default and is the documented idiom. A scope guard, so it also - // runs when a body throws. - struct GlslangThreadAllocatorGuard { - ~GlslangThreadAllocatorGuard() { glslang::SetThreadPoolAllocator(nullptr); } - }; } // namespace namespace MobileGL::MG_State::GLState { + // glslang has no "detach this thread" API in the vendored revision (there is no + // InitThread/DetachThread pair any more; thread attachment is implicit through + // thread_local state, and glslang::InitializeProcess() is process-wide, refcounted and + // mutex-guarded, so it needs no per-worker counterpart). The pool allocator is the part + // that needs undoing; see the declaration in ShaderCompileTask.h. + GlslangThreadAllocatorGuard::~GlslangThreadAllocatorGuard() { glslang::SetThreadPoolAllocator(nullptr); } + // Pure CPU work only. Everything this reads is either an input the node owns or a // process-wide constant; everything it writes is `artifacts`. Do not add a GL/EGL call, // a pActiveBackendObject read, or a pGLContext->RecordError() here - the first two are @@ -300,4 +292,47 @@ namespace MobileGL::MG_State::GLState { } } } + + SharedPtr ShaderCompileTask::ClaimParsedShader(String& outReparseLog) const { + MOBILEGL_ASSERT(IsComplete(), + "ShaderCompileTask::ClaimParsedShader() on a job that has not completed; its artifacts " + "are still being written"); + + if (artifacts.shader) { + // The whole race, in one instruction. Acquire-release because the winner is about + // to hand the TShader to glslang's linker on a possibly different thread from the + // one that parsed it - the node's terminal transition already published the + // parse, and this orders the two claimants against each other. + Bool expected = false; + if (m_parseClaimed.compare_exchange_strong(expected, true, std::memory_order_acq_rel, + std::memory_order_acquire)) { + return artifacts.shader; + } + } + + // Either another link already consumed the stored parse (and mapIO mutated its + // intermediate), or there never was one. Re-parse the preprocessed source through the + // identical configuration; that costs one glslang parse, which is what GenerateBinary + // used to spend here on EVERY link rather than only on reuse. + // + // The guard is not optional on this path: from stage 4 this runs on a pool worker, + // and TShader::parse would leave that worker's TLS allocator pointing at a pool the + // GL thread is about to free. (ProgramLinkTask::RunBody holds one too; they nest + // harmlessly - both just reset the thread to its own default.) + const GlslangThreadAllocatorGuard glslangGuard; + using namespace MG_Util::ShaderTranspiler; + ShaderAttrib attrib{.shaderType = MG_Util::ConvertShaderStageToGLEnum(stage), + .sourceStr = artifacts.preprocessedSource, + .flags = 0, + // Re-parse against the SAME environment the original parse used, + // not against whatever the backend reports now. + .env = artifacts.env.get()}; + auto result = ShaderCompiler::CompileShader(attrib); + if (!result) { + // Should be unreachable: the same source parsed successfully at Compile(). + outReparseLog = result.error().log; + return nullptr; + } + return result.value(); + } } // namespace MobileGL::MG_State::GLState diff --git a/MobileGL/MG_State/GLState/ProgramState/ShaderCompileTask.h b/MobileGL/MG_State/GLState/ProgramState/ShaderCompileTask.h index 9fa78088..5518a0e9 100644 --- a/MobileGL/MG_State/GLState/ProgramState/ShaderCompileTask.h +++ b/MobileGL/MG_State/GLState/ProgramState/ShaderCompileTask.h @@ -13,6 +13,23 @@ #include namespace MobileGL::MG_State::GLState { + // glslang has no "detach this thread" API in the vendored revision, but TShader::parse + // leaves the calling thread's TLS pool allocator pointing at the shader's own pool and + // never restores it. Left there, the next allocation this thread makes - in an unrelated + // job, or in glslang code reached from a different object - would come out of a pool the + // GL thread may already have deleted with the TShader. SetThreadPoolAllocator(nullptr) + // reverts the thread to its own thread_local default and is the documented idiom. + // + // A scope guard, so it also runs when a body throws. Declared here rather than kept + // file-local because stage 4 gave it a second user: ProgramLinkTask's body parses (the + // claim-CAS loser's re-parse), links and emits SPIR-V, all on a pool thread. + struct GlslangThreadAllocatorGuard { + GlslangThreadAllocatorGuard() = default; + ~GlslangThreadAllocatorGuard(); + GlslangThreadAllocatorGuard(const GlslangThreadAllocatorGuard&) = delete; + GlslangThreadAllocatorGuard& operator=(const GlslangThreadAllocatorGuard&) = delete; + }; + // Everything one glCompileShader PRODUCES, in one block. // // This is exactly the set a single run of the compile pipeline writes, which is what @@ -21,21 +38,16 @@ namespace MobileGL::MG_State::GLState { // it in, and the GL thread reads it through ShaderObject's join gate. struct ShaderCompileArtifacts { // The CompileEnv snapshot this compile ran against. Held so the consume-once - // re-parse in TakeShaderForLink() reproduces the original parse exactly, instead of + // re-parse in ClaimParsedShader() reproduces the original parse exactly, instead of // re-reading whatever the backend says now. SharedPtr env; SharedPtr shader; // The source the parse actually consumed (after PreprocessShaderSource), kept for - // TakeShaderForLink's re-parse so a later link never depends on the preprocessor + // ClaimParsedShader's re-parse so a later link never depends on the preprocessor // being deterministic across backend-state changes. String preprocessedSource; UnorderedMap explicitUniformLocations; UnorderedMap explicitOpaqueBindings; - // GL-thread-owned, and the one field here a worker never touches: TakeShaderForLink - // flips it after the join. Stage 4 replaces it with an atomic claim on this node, - // because two ProgramLinkTasks for two programs sharing this shader can then race - // for the parse on two workers. - Bool shaderConsumedByLink = false; String infoLog; Bool compileStatus = false; }; @@ -75,9 +87,50 @@ namespace MobileGL::MG_State::GLState { // ---- output: valid iff IsComplete(), immutable afterwards ---- ShaderCompileArtifacts artifacts; + // Hands out a link-consumable TShader, exactly once for the stored parse. + // + // glslang's mapIO mutates the TShader's aliased intermediate, so the parse this node + // produced may feed exactly ONE link; every later link (a relink, or the same shader + // attached to a second program) needs a fresh parse. The claim is a CAS on this + // shared node rather than a flag on the ShaderObject because from stage 4 the two + // callers can be two ProgramLinkTasks running on two workers: two programs sharing + // one shader, linked back to back. Copying the parse out and tracking consumed-ness + // per program would let both of them decide they were the first, run mapIO over the + // same intermediate twice, and ship silently corrupt SPIR-V. + // + // The CAS loser re-parses artifacts.preprocessedSource against THIS node's own + // CompileEnv (not against whatever the backend reports now), through the identical + // CompileShader path - so winner and loser produce byte-identical SPIR-V. Callable + // only once IsComplete() and compileStatus are true. Returns null only if that + // re-parse fails, and outReparseLog then carries its diagnostics. + // + // Const because the claim is the node's own synchronization, not a mutation of its + // published artifacts: a claim that is taken and then abandoned (its link was + // cancelled) costs one extra re-parse later and nothing else. + SharedPtr ClaimParsedShader(String& outReparseLog) const; + + // Sticky marker for "a ProgramLinkTask has this node in its input snapshot". + // + // It exists to keep a cancel from eating a result someone still needs. A pending link + // holds its dependencies by SharedPtr, so the NODE always outlives the ShaderObject - + // but Cancel() is not about lifetime, it discards the result. The reachable sequence + // is the ordinary one: compile, attach, glLinkProgram (enqueued), glDetachShader, + // 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. + // + // 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); } + private: void RunBody() override; // The real body; RunBody wraps it so a throw becomes a GL-visible compile failure. void RunCompilePipeline(); + + mutable std::atomic m_parseClaimed{false}; + std::atomic m_linkReferenced{false}; }; } // namespace MobileGL::MG_State::GLState diff --git a/MobileGL/MG_State/GLState/ProgramState/ShaderObject.cpp b/MobileGL/MG_State/GLState/ProgramState/ShaderObject.cpp index 9fe1e05e..4ca44a55 100644 --- a/MobileGL/MG_State/GLState/ProgramState/ShaderObject.cpp +++ b/MobileGL/MG_State/GLState/ProgramState/ShaderObject.cpp @@ -77,7 +77,14 @@ namespace MobileGL::MG_State::GLState { // 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. - m_compiled->Cancel(); + // + // 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(); } @@ -91,7 +98,7 @@ namespace MobileGL::MG_State::GLState { // 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 - // TakeShaderForLink's on-demand re-parse needs - a real recompile would have handed + // 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. if (HasMemoizedCompile()) return; @@ -119,43 +126,6 @@ namespace MobileGL::MG_State::GLState { MG_Util::Async::ShaderCompilePool::Get().Post(m_compiled); } - SharedPtr ShaderObject::TakeShaderForLink(String& outReparseLog) { - EnsureCompileJoined(); - // Unreachable through the GL frontend: callers gate on GetCompileStatus(). - if (!m_compiled) return nullptr; - - // Mutating the node's artifacts from here is legal precisely because the node is - // terminal by now: no worker will ever touch it again, and this thread is the GL - // thread. Stage 4, where two link JOBS can reach the same node concurrently, - // replaces this flag with an atomic claim on the node. - ShaderCompileArtifacts& compiled = m_compiled->artifacts; - if (compiled.shader && !compiled.shaderConsumedByLink) { - compiled.shaderConsumedByLink = true; - return compiled.shader; - } - - // The stored parse already fed a link, whose mapIO mutated its intermediate. - // Re-parse the preprocessed source through the identical configuration; this - // costs one glslang parse, which is exactly what GenerateBinary used to spend - // here on EVERY link rather than only on reuse. - using namespace MG_Util::ShaderTranspiler; - ShaderAttrib attrib{.shaderType = MG_Util::ConvertShaderStageToGLEnum(m_stage), - .sourceStr = compiled.preprocessedSource, - .flags = 0, - // Re-parse against the SAME environment the original parse used, - // not against whatever the backend reports now. - .env = compiled.env.get()}; - auto result = ShaderCompiler::CompileShader(attrib); - if (!result) { - // Should be unreachable: the same source parsed successfully at Compile(). - outReparseLog = result.error().log; - MGLOG_E("ShaderObject::TakeShaderForLink: re-parse of shader %d failed:\n%s", m_externalIndex, - outReparseLog.c_str()); - return nullptr; - } - return result.value(); - } - void ShaderObject::MarkAsDeleted() { m_deleteStatus = true; } diff --git a/MobileGL/MG_State/GLState/ProgramState/ShaderObject.h b/MobileGL/MG_State/GLState/ProgramState/ShaderObject.h index 3c6d5f11..bbe2814f 100644 --- a/MobileGL/MG_State/GLState/ProgramState/ShaderObject.h +++ b/MobileGL/MG_State/GLState/ProgramState/ShaderObject.h @@ -51,14 +51,17 @@ namespace MobileGL { void CancelCompile(); void MarkAsDeleted(); - // Hands out a link-consumable TShader. glslang's mapIO mutates the TShader's - // aliased intermediate, so the parse stored by Compile() may feed exactly one - // link; every later link (relink, or the same shader attached to a second - // program) gets a fresh parse of the stored preprocessed source through the - // byte-identical CompileShader path (including the legacy-460 retry). Only - // callable while GetCompileStatus() is true. Returns null only if that - // re-parse fails - outReparseLog then carries its diagnostics. - SharedPtr TakeShaderForLink(String& outReparseLog); + // The compile job node itself, for ProgramObject::Link()'s input snapshot. + // DELIBERATELY DOES NOT JOIN, and that is the entire point of stage 4: the link + // takes the node as a dependency and is posted only once the node is terminal, + // so glLinkProgram never blocks on glCompileShader. Null means this object has + // never been compiled (or its last compile was abandoned), which the link reads + // as COMPILE_STATUS false - the same verdict the joining path produces. + // + // The caller must MarkLinkReferenced() whatever it keeps: from here on the node's + // result has an observer this object knows nothing about (see the marker's + // comment in ShaderCompileTask.h). + const SharedPtr& CompiledNodeForLink() const { return m_compiled; } Uint GetExternalIndex() const { return m_externalIndex; } ShaderStage GetShaderStage() const { return m_stage; } diff --git a/MobileGL/MG_Test/Program/AsyncLinkTest.cpp b/MobileGL/MG_Test/Program/AsyncLinkTest.cpp new file mode 100644 index 00000000..70545549 --- /dev/null +++ b/MobileGL/MG_Test/Program/AsyncLinkTest.cpp @@ -0,0 +1,749 @@ +// MobileGL - MobileGL/MG_Test/Program/AsyncLinkTest.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 4: glLinkProgram enqueues a ProgramLinkTask behind its shaders' compiles, and +// every observable read of link output joins. +// +// Like AsyncCompileTest, every case here drives the real GL entry points and flips +// MG_Config::Features.AsyncShaderCompile itself rather than reading the environment - so one +// binary can assert the property that actually matters (the async and synchronous paths are +// indistinguishable through the GL surface) regardless of how the suite was launched. + +#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_Impl/GLImpl/Program/GL_ProgramPipeline.h" +#include "MG_State/GLState/Core.h" +#include "MG_Util/Async/ShaderCompilePool.h" + +using namespace MobileGL; +using namespace MobileGL::MG_Impl::GLImpl; + +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; + }; + + 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); +} +)"; + + const char* kFs = R"(#version 460 +in vec4 vColor; +layout(location = 0) out vec4 fragColor; +uniform float uAlpha; +void main() { fragColor = vec4(vColor.rgb, vColor.a * uAlpha); } +)"; + + // A vertex shader that captures something transform feedback can name. + const char* kXfbVs = R"(#version 460 +layout(location = 0) in vec3 aPos; +out vec3 vWorld; +void main() { + vWorld = aPos * 2.0; + gl_Position = vec4(aPos, 1.0); +} +)"; + + const char* kBrokenFs = R"(#version 460 +layout(location = 0) out vec4 fragColor; +void main() { fragColor = thisIdentifierWasNeverDeclared; } +)"; + + // Big enough that neither the compile nor the link is instantaneous, so the pool has a + // real backlog to race against. Templated on an index so every instance is distinct + // source text (no P0b memo hit). + 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; + } + + GLuint MakeShader(const GLenum type, const char* source) { + const GLuint shader = CreateShader(type); + ShaderSource(shader, 1, &source, nullptr); + CompileShader(shader); + return shader; + } + + GLint QueryLinkStatus(const GLuint program) { + GLint status = GL_FALSE; + GetProgramiv(program, GL_LINK_STATUS, &status); + return status; + } + + String QueryProgramInfoLog(const GLuint program) { + GLint length = 0; + GetProgramiv(program, GL_INFO_LOG_LENGTH, &length); + if (length <= 0) return String(); + std::vector buffer(static_cast(length)); + GLsizei written = 0; + GetProgramInfoLog(program, length, &written, buffer.data()); + return String(buffer.data(), static_cast(written)); + } + + // The non-joining view of the program, i.e. what GL_COMPLETION_STATUS_KHR will report. + Bool LinkIsSettled(const GLuint program) { + const auto& object = MG_State::pGLContext->GetProgramObject(program); + return object == nullptr || object->IsLinkComplete(); + } + + // Enqueues `count` distinct heavy compiles without reading anything back, so the pool is + // left with a real backlog for the caller to race against. + Vector SaturatePool(const int count, Vector& sourceStorage) { + Vector shaders; + shaders.reserve(static_cast(count)); + sourceStorage.reserve(sourceStorage.size() + static_cast(count)); + for (int i = 0; i < count; ++i) { + sourceStorage.push_back(MakeBulkySource(20000 + i)); + const char* text = sourceStorage.back().c_str(); + const GLuint shader = CreateShader(GL_FRAGMENT_SHADER); + ShaderSource(shader, 1, &text, nullptr); + CompileShader(shader); + shaders.push_back(shader); + } + return shaders; + } + + // Content hash of a linked program's generated SPIR-V, through the state layer (there is + // no GL query for it). Joins, like every other artifact read. + 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; + } + + class AsyncLinkTest : public ::testing::Test { + protected: + void SetUp() override { MobileGL::Initialize(); } + }; +} // namespace + +// --------------------------------------------------------------------------------------- +// The consume-once claim +// --------------------------------------------------------------------------------------- + +// The stage-4 headline risk: two programs share one shader and are linked back to back, so +// two ProgramLinkTasks race for that shader's single glslang parse. Exactly one may win the +// claim; the loser must re-parse the same preprocessed source against the same CompileEnv. +// If either half of that is wrong the two programs get DIFFERENT SPIR-V for the same shader, +// which is the silent-corruption class this whole mechanism exists to prevent. +TEST_F(AsyncLinkTest, TwoProgramsSharingAShaderGenerateIdenticalSpirv) { + for (const Bool async : {false, true}) { + const AsyncModeScope scope(async); + + const GLuint vs = MakeShader(GL_VERTEX_SHADER, kVs); + const GLuint fs = MakeShader(GL_FRAGMENT_SHADER, kFs); + + // Both links enqueued before either result is read: with the flag on this is the + // window in which two workers can hold the same node at once. + const GLuint programA = CreateProgram(); + AttachShader(programA, vs); + AttachShader(programA, fs); + LinkProgram(programA); + + const GLuint programB = CreateProgram(); + AttachShader(programB, vs); + AttachShader(programB, fs); + LinkProgram(programB); + + ASSERT_EQ(QueryLinkStatus(programA), GL_TRUE) << QueryProgramInfoLog(programA); + ASSERT_EQ(QueryLinkStatus(programB), GL_TRUE) << QueryProgramInfoLog(programB); + + const Vector digestA = SpirvDigest(programA); + const Vector digestB = SpirvDigest(programB); + ASSERT_EQ(digestA.size(), 2u) << "async=" << async; + EXPECT_EQ(digestA, digestB) + << "the claim winner and the re-parsing loser must produce identical SPIR-V (async=" << async << ")"; + + // And the two programs really are usable independently. + EXPECT_GE(GetUniformLocation(programA, "uColor"), 0); + EXPECT_GE(GetUniformLocation(programB, "uColor"), 0); + EXPECT_EQ(GetError(), GL_NO_ERROR); + } +} + +// The same property many ways at once, with the pool loaded: N programs over the SAME shader +// pair, all enqueued before anything is read, so one claim winner is racing N-1 re-parsers. +// Every program must come out byte-identical. +// +// The shader pair has to be identical across the programs for this to mean anything: glslang +// links the stages together, so a stage's SPIR-V is legitimately a function of the WHOLE +// program (mapIO's cross-stage location assignment, live-variable analysis). Comparing one +// shared vertex shader across programs with different fragment stages would compare things +// that are allowed to differ. +TEST_F(AsyncLinkTest, ManyProgramsSharingOneShaderPairAgreeOnTheirSpirv) { + const AsyncModeScope async(true); + Vector backlog; + SaturatePool(48, backlog); + + constexpr int kPrograms = 12; + const GLuint vs = MakeShader(GL_VERTEX_SHADER, kVs); + const GLuint fs = MakeShader(GL_FRAGMENT_SHADER, kFs); + + Vector programs; + for (int i = 0; i < kPrograms; ++i) { + const GLuint program = CreateProgram(); + AttachShader(program, vs); + AttachShader(program, fs); + LinkProgram(program); + programs.push_back(program); + } + + Vector reference; + for (int i = 0; i < kPrograms; ++i) { + const GLuint program = programs[static_cast(i)]; + ASSERT_EQ(QueryLinkStatus(program), GL_TRUE) << "program " << i << ": " << QueryProgramInfoLog(program); + const Vector digest = SpirvDigest(program); + ASSERT_EQ(digest.size(), 2u); + if (i == 0) { + reference = digest; + } else { + EXPECT_EQ(digest, reference) << "SPIR-V differs in program " << i; + } + EXPECT_GE(GetUniformLocation(program, "uColor"), 0) << "program " << i; + EXPECT_GE(GetUniformLocation(program, "uAlpha"), 0) << "program " << i; + } + EXPECT_EQ(GetError(), GL_NO_ERROR); +} + +// --------------------------------------------------------------------------------------- +// Mutation over a pending link (the cancel matrix) +// --------------------------------------------------------------------------------------- + +// The last link wins. A re-link over a pending one cancels it and enqueues afresh; the +// result the application eventually reads must be the SECOND link's. +TEST_F(AsyncLinkTest, RelinkOverAPendingLinkPublishesTheSecondLink) { + const AsyncModeScope async(true); + Vector backlog; + SaturatePool(48, backlog); + + const GLuint vs = MakeShader(GL_VERTEX_SHADER, kVs); + const String firstSource = MakeBulkySource(7001); + const char* firstText = firstSource.c_str(); + const GLuint fs = CreateShader(GL_FRAGMENT_SHADER); + ShaderSource(fs, 1, &firstText, nullptr); + CompileShader(fs); + + const GLuint program = CreateProgram(); + AttachShader(program, vs); + AttachShader(program, fs); + LinkProgram(program); + + // Swap the fragment shader's source and relink, all without ever reading the first + // link's status - so the first link is very probably still queued or running. + const String secondSource = MakeBulkySource(7002); + const char* secondText = secondSource.c_str(); + ShaderSource(fs, 1, &secondText, nullptr); + CompileShader(fs); + LinkProgram(program); + + ASSERT_EQ(QueryLinkStatus(program), GL_TRUE) << QueryProgramInfoLog(program); + EXPECT_GE(GetUniformLocation(program, "uSeed7002"), 0); + EXPECT_EQ(GetUniformLocation(program, "uSeed7001"), -1); + EXPECT_EQ(GetError(), GL_NO_ERROR); +} + +// The take-effect-at-next-link setters must NOT disturb a pending link: the pending link +// snapshotted its own inputs at enqueue, so +// glLinkProgram; glTransformFeedbackVaryings; glGetProgramiv(LINK_STATUS) +// has to report the FIRST link - which captured nothing. +TEST_F(AsyncLinkTest, TransformFeedbackVaryingsOverAPendingLinkReportsTheFirstLink) { + const AsyncModeScope async(true); + Vector backlog; + SaturatePool(48, backlog); + + const GLuint vs = MakeShader(GL_VERTEX_SHADER, kXfbVs); + const GLuint fs = MakeShader(GL_FRAGMENT_SHADER, kFs); + const GLuint program = CreateProgram(); + AttachShader(program, vs); + AttachShader(program, fs); + LinkProgram(program); + + const char* varyings[] = {"vWorld"}; + TransformFeedbackVaryings(program, 1, varyings, GL_INTERLEAVED_ATTRIBS); + + ASSERT_EQ(QueryLinkStatus(program), GL_TRUE) << QueryProgramInfoLog(program); + GLint captured = -1; + GetProgramiv(program, GL_TRANSFORM_FEEDBACK_VARYINGS, &captured); + EXPECT_EQ(captured, 0) << "the pending link must publish the request set it snapshotted, not a later one"; + + // And the request does take effect at the NEXT link. + LinkProgram(program); + ASSERT_EQ(QueryLinkStatus(program), GL_TRUE) << QueryProgramInfoLog(program); + GetProgramiv(program, GL_TRANSFORM_FEEDBACK_VARYINGS, &captured); + EXPECT_EQ(captured, 1); + EXPECT_EQ(GetError(), GL_NO_ERROR); +} + +// glBindAttribLocation is the same family and must likewise leave a pending link alone. +TEST_F(AsyncLinkTest, BindAttribLocationOverAPendingLinkDoesNotDisturbIt) { + const AsyncModeScope async(true); + Vector backlog; + SaturatePool(48, backlog); + + const GLuint vs = MakeShader(GL_VERTEX_SHADER, kVs); + const GLuint fs = MakeShader(GL_FRAGMENT_SHADER, kFs); + const GLuint program = CreateProgram(); + AttachShader(program, vs); + AttachShader(program, fs); + LinkProgram(program); + + BindAttribLocation(program, 5, "aPos"); + ASSERT_EQ(QueryLinkStatus(program), GL_TRUE) << QueryProgramInfoLog(program); + EXPECT_EQ(GetAttribLocation(program, "aPos"), 0) << "the first link's layout(location = 0) must survive"; + + LinkProgram(program); + ASSERT_EQ(QueryLinkStatus(program), GL_TRUE) << QueryProgramInfoLog(program); + EXPECT_EQ(GetError(), GL_NO_ERROR); +} + +// glAttachShader after glLinkProgram is defined to leave the current link status alone (it +// takes effect at the next link). It must therefore NOT cancel a pending link - the failure +// mode being guarded here is a program that linked fine reporting GL_FALSE. +TEST_F(AsyncLinkTest, AttachShaderOverAPendingLinkKeepsTheLinkResult) { + const AsyncModeScope async(true); + Vector backlog; + SaturatePool(48, backlog); + + const GLuint vs = MakeShader(GL_VERTEX_SHADER, kVs); + const GLuint fs = MakeShader(GL_FRAGMENT_SHADER, kFs); + const GLuint program = CreateProgram(); + AttachShader(program, vs); + AttachShader(program, fs); + LinkProgram(program); + + // A second, unrelated fragment shader attached over the pending link. (Attaching two + // shaders of one stage is legal; only the next link would have to reconcile them.) + const GLuint extraFs = MakeShader(GL_FRAGMENT_SHADER, kBrokenFs); + AttachShader(program, extraFs); + + EXPECT_EQ(QueryLinkStatus(program), GL_TRUE) << QueryProgramInfoLog(program); + EXPECT_GE(GetUniformLocation(program, "uAlpha"), 0); + EXPECT_EQ(GetError(), GL_NO_ERROR); +} + +// The link-then-detach-then-delete teardown every LWJGL/Blaze3D-shaped app performs. The +// detach makes the shader GL-invisible, so glDeleteShader frees its name and would otherwise +// cancel a compile the enqueued link is still waiting on - flipping a link that must report +// GL_TRUE to GL_FALSE. Runs with the pool saturated so the compiles really are outstanding. +TEST_F(AsyncLinkTest, DetachAndDeleteShadersOverAPendingLinkKeepsTheLinkResult) { + const AsyncModeScope async(true); + Vector backlog; + SaturatePool(48, backlog); + + const String source = MakeBulkySource(7400); + const char* text = source.c_str(); + const GLuint vs = MakeShader(GL_VERTEX_SHADER, kVs); + const GLuint fs = CreateShader(GL_FRAGMENT_SHADER); + ShaderSource(fs, 1, &text, nullptr); + CompileShader(fs); + + const GLuint program = CreateProgram(); + AttachShader(program, vs); + AttachShader(program, fs); + LinkProgram(program); + + DetachShader(program, vs); + DetachShader(program, fs); + DeleteShader(vs); + DeleteShader(fs); + EXPECT_EQ(IsShader(vs), GL_FALSE); + EXPECT_EQ(IsShader(fs), GL_FALSE); + + ASSERT_EQ(QueryLinkStatus(program), GL_TRUE) << QueryProgramInfoLog(program); + EXPECT_GE(GetUniformLocation(program, "uSeed7400"), 0); + EXPECT_EQ(GetError(), GL_NO_ERROR); +} + +// glCreateShaderProgramv is specified as create-source-compile-create-attach-LINK-detach, so +// it is the in-tree caller that exercises the detach-immediately-after-link ordering. It +// self-joins through its status queries (design join site J7) and needs no edit of its own - +// this is the guard that says so. +TEST_F(AsyncLinkTest, CreateShaderProgramvLinksUnderAsync) { + const AsyncModeScope async(true); + Vector backlog; + SaturatePool(48, backlog); + + const char* sources[] = {kVs}; + const GLuint program = CreateShaderProgramv(GL_VERTEX_SHADER, 1, sources); + ASSERT_NE(program, 0u); + EXPECT_EQ(QueryLinkStatus(program), GL_TRUE) << QueryProgramInfoLog(program); + EXPECT_GE(GetUniformLocation(program, "uColor"), 0); + EXPECT_EQ(GetError(), GL_NO_ERROR); +} + +// glProgramBinary over a pending link: no format is supported, so the spec requires +// LINK_STATUS to read FALSE afterwards. The pending link must not publish over that. +TEST_F(AsyncLinkTest, ProgramBinaryOverAPendingLinkForcesLinkFalse) { + const AsyncModeScope async(true); + Vector backlog; + SaturatePool(48, backlog); + + const GLuint vs = MakeShader(GL_VERTEX_SHADER, kVs); + const GLuint fs = MakeShader(GL_FRAGMENT_SHADER, kFs); + const GLuint program = CreateProgram(); + AttachShader(program, vs); + AttachShader(program, fs); + LinkProgram(program); + + const GLuint dummy = 0; + ProgramBinary(program, 0, &dummy, static_cast(sizeof(dummy))); + EXPECT_EQ(GetError(), GL_INVALID_ENUM); + + EXPECT_EQ(QueryLinkStatus(program), GL_FALSE) << "glProgramBinary must win over the pending link"; + EXPECT_FALSE(QueryProgramInfoLog(program).empty()); + EXPECT_EQ(GetError(), GL_NO_ERROR); +} + +// glDeleteProgram over a pending link. The name goes away immediately - no wait for a worker +// - and the abandoned job must neither crash nor keep anything observable alive. +TEST_F(AsyncLinkTest, DeleteProgramWhileALinkIsPending) { + const AsyncModeScope async(true); + Vector backlog; + SaturatePool(48, backlog); + + Vector doomed; + Vector sources; + const GLuint vs = MakeShader(GL_VERTEX_SHADER, kVs); + for (int i = 0; i < 16; ++i) { + sources.push_back(MakeBulkySource(7500 + i)); + const char* text = sources.back().c_str(); + const GLuint fs = CreateShader(GL_FRAGMENT_SHADER); + ShaderSource(fs, 1, &text, nullptr); + CompileShader(fs); + + const GLuint program = CreateProgram(); + AttachShader(program, vs); + AttachShader(program, fs); + LinkProgram(program); + doomed.push_back(program); + } + for (const GLuint program : doomed) { + DeleteProgram(program); + EXPECT_EQ(IsProgram(program), GL_FALSE) << "an unused deleted program's name goes immediately"; + } + EXPECT_EQ(GetError(), GL_NO_ERROR); + + // The context still works afterwards: the abandoned links did not take the pool, the + // preprocess cache or the glslang process state down with them. + const GLuint fs = MakeShader(GL_FRAGMENT_SHADER, kFs); + const GLuint program = CreateProgram(); + AttachShader(program, vs); + AttachShader(program, fs); + LinkProgram(program); + EXPECT_EQ(QueryLinkStatus(program), GL_TRUE) << QueryProgramInfoLog(program); + EXPECT_EQ(GetError(), GL_NO_ERROR); +} + +// --------------------------------------------------------------------------------------- +// The join gates +// --------------------------------------------------------------------------------------- + +// glLinkProgram must return before the work is done, and the first observable read must +// join. Observed through the state machine rather than through timing, so it can never be a +// false red: with a saturated pool at least one of the just-enqueued links has to be +// unsettled at the moment we ask; skipped if the machine drained everything first. +TEST_F(AsyncLinkTest, LinkProgramReturnsBeforeTheWorkIsDone) { + const AsyncModeScope async(true); + constexpr int kPrograms = 32; + + const GLuint vs = MakeShader(GL_VERTEX_SHADER, kVs); + Vector programs; + Vector sources; + for (int i = 0; i < kPrograms; ++i) { + sources.push_back(MakeBulkySource(7600 + i)); + const char* text = sources.back().c_str(); + const GLuint fs = CreateShader(GL_FRAGMENT_SHADER); + ShaderSource(fs, 1, &text, nullptr); + CompileShader(fs); + const GLuint program = CreateProgram(); + AttachShader(program, vs); + AttachShader(program, fs); + LinkProgram(program); + programs.push_back(program); + } + + int unsettled = 0; + for (const GLuint program : programs) { + if (!LinkIsSettled(program)) ++unsettled; + } + if (unsettled == 0) { + GTEST_SKIP() << "the pool drained every link before the first observation; nothing to prove here"; + } + + for (const GLuint program : programs) { + EXPECT_EQ(QueryLinkStatus(program), GL_TRUE) << QueryProgramInfoLog(program); + EXPECT_TRUE(LinkIsSettled(program)) << "reading LINK_STATUS must have joined"; + } + EXPECT_EQ(GetError(), GL_NO_ERROR); +} + +// With the flag off, a link is finished by the time glLinkProgram returns. This is the guard +// that keeps the default shippable. +TEST_F(AsyncLinkTest, LinkIsFullySynchronousWithAsyncOff) { + const AsyncModeScope async(false); + ASSERT_FALSE(MG_Util::Async::AsyncShaderCompileEnabled()); + + const GLuint vs = MakeShader(GL_VERTEX_SHADER, kVs); + const GLuint fs = MakeShader(GL_FRAGMENT_SHADER, kFs); + const GLuint program = CreateProgram(); + AttachShader(program, vs); + AttachShader(program, fs); + LinkProgram(program); + EXPECT_TRUE(LinkIsSettled(program)); + EXPECT_EQ(QueryLinkStatus(program), GL_TRUE) << QueryProgramInfoLog(program); + EXPECT_EQ(GetError(), GL_NO_ERROR); +} + +// P1 join site J1: the composite draw program for a pipeline is cached against a signature +// built from each stage program's lifetime id and backend state version - NON-artifact +// fields, which do not pass through the join gate. GetProgramForDraw has to settle the stage +// programs first, or the signature describes a link generation that no longer exists and the +// composite is rebuilt on every draw. +TEST_F(AsyncLinkTest, DrawThroughAPipelineWithAPendingStageProgramJoinsFirst) { + const AsyncModeScope async(true); + Vector backlog; + SaturatePool(48, backlog); + + // Built by hand rather than through glCreateShaderProgramv: that entry point detaches the + // shader immediately after linking, so the next link would remove it and leave the stage + // program with nothing attached to composite from. + const GLuint vs = MakeShader(GL_VERTEX_SHADER, kVs); + const GLuint vsProgram = CreateProgram(); + ProgramParameteri(vsProgram, GL_PROGRAM_SEPARABLE, GL_TRUE); + AttachShader(vsProgram, vs); + LinkProgram(vsProgram); + ASSERT_EQ(QueryLinkStatus(vsProgram), GL_TRUE) << QueryProgramInfoLog(vsProgram); + + GLuint pipeline = 0; + GenProgramPipelines(1, &pipeline); + ASSERT_NE(pipeline, 0u); + // Bind before UseProgramStages: glGenProgramPipelines only reserves the name, and the + // first bind is what turns it into an object glUseProgramStages can find. + BindProgramPipeline(pipeline); + UseProgramStages(pipeline, GL_VERTEX_SHADER_BIT, vsProgram); + ASSERT_EQ(GetError(), GL_NO_ERROR); + + // Re-link the stage program and immediately ask for the draw program, without reading + // the link's status in between: the pending link is what J1 has to settle. + LinkProgram(vsProgram); + const SharedPtr drawProgram = MG_State::pGLContext->GetProgramForDraw(); + ASSERT_NE(drawProgram, nullptr); + EXPECT_TRUE(LinkIsSettled(vsProgram)) << "GetProgramForDraw must have joined the stage program"; + EXPECT_TRUE(drawProgram->GetLinkStatus()) << drawProgram->GetInfoLog(); + + // Asking again with nothing changed must hit the composite cache, which is only possible + // if the signature was computed against settled programs both times. + const SharedPtr again = MG_State::pGLContext->GetProgramForDraw(); + EXPECT_EQ(again.get(), drawProgram.get()) << "the composite draw program must be cached across draws"; + EXPECT_EQ(GetError(), GL_NO_ERROR); +} + +// --------------------------------------------------------------------------------------- +// Diagnostics +// --------------------------------------------------------------------------------------- + +// A link whose fragment shader failed to compile has to reproduce that shader's log verbatim +// inside the program info log, whichever thread produced it - and the failure must be +// reported as LINK_STATUS plus a log, never as a GL error. +TEST_F(AsyncLinkTest, FailingLinkLogIsIdenticalAcrossModes) { + String syncLog; + { + const AsyncModeScope scope(false); + const GLuint vs = MakeShader(GL_VERTEX_SHADER, kVs); + const GLuint fs = MakeShader(GL_FRAGMENT_SHADER, kBrokenFs); + const GLuint program = CreateProgram(); + AttachShader(program, vs); + AttachShader(program, fs); + LinkProgram(program); + ASSERT_EQ(QueryLinkStatus(program), GL_FALSE); + syncLog = QueryProgramInfoLog(program); + EXPECT_FALSE(syncLog.empty()); + EXPECT_EQ(GetError(), GL_NO_ERROR); + } + { + const AsyncModeScope scope(true); + const GLuint vs = MakeShader(GL_VERTEX_SHADER, kVs); + const GLuint fs = MakeShader(GL_FRAGMENT_SHADER, kBrokenFs); + const GLuint program = CreateProgram(); + AttachShader(program, vs); + AttachShader(program, fs); + LinkProgram(program); + EXPECT_EQ(QueryLinkStatus(program), GL_FALSE); + EXPECT_EQ(QueryProgramInfoLog(program), syncLog); + EXPECT_EQ(GetError(), GL_NO_ERROR); + } +} + +// A program with nothing attached fails in the GL-thread prologue, before any job exists. +// That path has to reach the same info log in both modes. +TEST_F(AsyncLinkTest, LinkWithNoShadersFailsIdenticallyInBothModes) { + String syncLog; + for (const Bool async : {false, true}) { + const AsyncModeScope scope(async); + const GLuint program = CreateProgram(); + LinkProgram(program); + EXPECT_EQ(QueryLinkStatus(program), GL_FALSE); + const String log = QueryProgramInfoLog(program); + EXPECT_FALSE(log.empty()); + if (!async) { + syncLog = log; + } else { + EXPECT_EQ(log, syncLog); + } + EXPECT_TRUE(LinkIsSettled(program)) << "a prologue failure leaves no job pending"; + } + EXPECT_EQ(GetError(), GL_NO_ERROR); +} + +// --------------------------------------------------------------------------------------- +// End to end +// --------------------------------------------------------------------------------------- + +// The shape a shaderpack load actually has: compile N shaders, link M programs, read +// NOTHING until the end, then query everything. This is the only shape in which the pool has +// many compiles and many links in flight simultaneously, with the link jobs chained behind +// compile jobs that are themselves still queued. +TEST_F(AsyncLinkTest, PackShapedBurstCompilesLinksAndQueriesEverything) { + const AsyncModeScope async(true); + constexpr int kShaders = 24; + constexpr int kPrograms = 24; + + Vector sources; + Vector vertexShaders; + Vector fragmentShaders; + for (int i = 0; i < kShaders; ++i) { + vertexShaders.push_back(MakeShader(GL_VERTEX_SHADER, kVs)); + sources.push_back(MakeBulkySource(8000 + i)); + const char* text = sources.back().c_str(); + const GLuint fs = CreateShader(GL_FRAGMENT_SHADER); + ShaderSource(fs, 1, &text, nullptr); + CompileShader(fs); + fragmentShaders.push_back(fs); + } + + Vector programs; + for (int i = 0; i < kPrograms; ++i) { + const GLuint program = CreateProgram(); + AttachShader(program, vertexShaders[static_cast(i % kShaders)]); + AttachShader(program, fragmentShaders[static_cast(i % kShaders)]); + LinkProgram(program); + programs.push_back(program); + } + + for (int i = 0; i < kPrograms; ++i) { + const GLuint program = programs[static_cast(i)]; + ASSERT_EQ(QueryLinkStatus(program), GL_TRUE) << "program " << i << ": " << QueryProgramInfoLog(program); + EXPECT_GE(GetUniformLocation(program, "uColor"), 0) << "program " << i; + EXPECT_GE(GetUniformLocation(program, ("uSeed" + std::to_string(8000 + i % kShaders)).c_str()), 0) + << "program " << i; + EXPECT_EQ(GetAttribLocation(program, "aPos"), 0) << "program " << i; + EXPECT_EQ(SpirvDigest(program).size(), 2u) << "program " << i; + } + EXPECT_EQ(GetError(), GL_NO_ERROR); +} + +// The adversarial interleaving: link, query a previous one, re-source, re-link, delete, all +// with the pool busy. Nothing here asserts timing - what it hunts for is a missed join, a +// consumed-twice parse, or a use of an abandoned node, all of which surface as a wrong +// status, a missing uniform, or a crash. +TEST_F(AsyncLinkTest, StressLinkQueryRelinkDeleteInterleaved) { + const AsyncModeScope async(true); + constexpr int kRounds = 5; + constexpr int kPerRound = 10; + + for (int round = 0; round < kRounds; ++round) { + Vector sources; + Vector programs; + Vector fragmentShaders; + const GLuint vs = MakeShader(GL_VERTEX_SHADER, kVs); + + for (int i = 0; i < kPerRound; ++i) { + sources.push_back(MakeBulkySource(round * 1000 + 300 + i)); + const char* text = sources.back().c_str(); + const GLuint fs = CreateShader(GL_FRAGMENT_SHADER); + ShaderSource(fs, 1, &text, nullptr); + CompileShader(fs); + fragmentShaders.push_back(fs); + + const GLuint program = CreateProgram(); + AttachShader(program, vs); + AttachShader(program, fs); + LinkProgram(program); + programs.push_back(program); + + // Query a PREVIOUS program while this one is still outstanding: the join has to + // settle exactly the program asked about and no other. + if (i > 0) { + const GLuint earlier = programs[static_cast(i - 1)]; + EXPECT_EQ(QueryLinkStatus(earlier), GL_TRUE) << QueryProgramInfoLog(earlier); + } + } + + // Re-source half of them mid-flight and relink over the pending link. + for (int i = 0; i < kPerRound; i += 2) { + sources.push_back(MakeBulkySource(round * 1000 + 700 + i)); + const char* text = sources.back().c_str(); + ShaderSource(fragmentShaders[static_cast(i)], 1, &text, nullptr); + CompileShader(fragmentShaders[static_cast(i)]); + LinkProgram(programs[static_cast(i)]); + } + + for (int i = 0; i < kPerRound; ++i) { + const GLuint program = programs[static_cast(i)]; + ASSERT_EQ(QueryLinkStatus(program), GL_TRUE) + << "round " << round << " program " << i << ": " << QueryProgramInfoLog(program); + const String expected = + "uSeed" + std::to_string(round * 1000 + (i % 2 == 0 ? 700 + i : 300 + i)); + EXPECT_GE(GetUniformLocation(program, expected.c_str()), 0) + << "round " << round << " program " << i << " expected " << expected; + DeleteProgram(program); + } + for (const GLuint fs : fragmentShaders) DeleteShader(fs); + DeleteShader(vs); + EXPECT_EQ(GetError(), GL_NO_ERROR); + } +} diff --git a/MobileGL/MG_Test/Program/AsyncTeardownTest.cpp b/MobileGL/MG_Test/Program/AsyncTeardownTest.cpp new file mode 100644 index 00000000..42094738 --- /dev/null +++ b/MobileGL/MG_Test/Program/AsyncTeardownTest.cpp @@ -0,0 +1,133 @@ +// MobileGL - MobileGL/MG_Test/Program/AsyncTeardownTest.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 4, item S6: MobileGL::Destroy() with compile AND link jobs still in flight. +// +// This is the one cancellation path in the whole design that WAITS, and the order it waits +// in is load-bearing: in-flight jobs own their own inputs and are safe against everything +// teardown does EXCEPT glslang's process globals and the TShader/TProgram objects hanging off +// pGLContext - both of which DestroyImpl is about to free. StopAndDrain() therefore runs +// first, before pGLContext.reset() and before glslang::FinalizeProcess(). +// +// ITS OWN BINARY, deliberately. ShaderCompilePool::StopAndDrain() is a one-way latch: from +// the first eglTerminate onwards every job in the process runs inline on the calling thread. +// Sharing a binary with AsyncCompileTest/AsyncLinkTest would silently turn every case +// declared after this one synchronous, and they would keep passing while testing nothing. + +#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_Util/Async/ShaderCompilePool.h" + +using namespace MobileGL; +using namespace MobileGL::MG_Impl::GLImpl; + +namespace { + const char* kVs = R"(#version 460 +layout(location = 0) in vec3 aPos; +uniform vec4 uColor; +out vec4 vColor; +void main() { + vColor = uColor; + gl_Position = vec4(aPos, 1.0); +} +)"; + + 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; + } + + GLuint MakeShader(const GLenum type, const char* source) { + const GLuint shader = CreateShader(type); + ShaderSource(shader, 1, &source, nullptr); + CompileShader(shader); + return shader; + } +} // namespace + +// Fills the pool with compiles, chains links behind them, and tears the library down without +// reading a single result. Nothing here can assert on the jobs' outcomes - by design there is +// no one left to ask - so what it asserts is that teardown COMPLETES: it must not hang +// (StopAndDrain joining a worker that is itself waiting on something), must not crash (a +// worker inside glslang while FinalizeProcess frees its symbol tables, or a link job reading +// a shader node the GL thread has dropped), and must leave the process able to come back up. +TEST(AsyncTeardown, DestroyWithCompilesAndLinksInFlight) { + // After Initialize(), not before: MG_ConfigLoader::Init() re-reads the whole feature + // block from the environment and would overwrite the override. + MobileGL::Initialize(); + MG_Config::Features.AsyncShaderCompile = MG_Config::QuirkOverride::ForceOn; + ASSERT_TRUE(MG_Util::Async::AsyncShaderCompileEnabled()); + + constexpr int kCount = 64; + Vector sources; + Vector shaders; + Vector programs; + sources.reserve(kCount); + + // Bare compiles first, so the pool has a backlog the links below will queue behind. + for (int i = 0; i < kCount; ++i) { + sources.push_back(MakeBulkySource(30000 + i)); + const char* text = sources.back().c_str(); + const GLuint fs = CreateShader(GL_FRAGMENT_SHADER); + ShaderSource(fs, 1, &text, nullptr); + CompileShader(fs); + shaders.push_back(fs); + } + + // Then links, each chained behind a compile that is very probably still outstanding: at + // the moment Destroy() runs there are queued compiles, running compiles, links waiting on + // a dependency edge, and links already handed to the pool. + const GLuint vs = MakeShader(GL_VERTEX_SHADER, kVs); + for (int i = 0; i < kCount; ++i) { + const GLuint program = CreateProgram(); + AttachShader(program, vs); + AttachShader(program, shaders[static_cast(i)]); + LinkProgram(program); + programs.push_back(program); + } + + // No status read anywhere above - the jobs are genuinely in flight. + MobileGL::Destroy(); + + // Back up again. The pool stays stopped for the rest of the process (a one-way latch), so + // this second life is synchronous - which is exactly the documented behaviour, and it has + // to still be a WORKING one. + MobileGL::Initialize(); + const GLuint vs2 = MakeShader(GL_VERTEX_SHADER, kVs); + const char* fsSource = R"(#version 460 +in vec4 vColor; +layout(location = 0) out vec4 fragColor; +void main() { fragColor = vColor; } +)"; + const GLuint fs2 = MakeShader(GL_FRAGMENT_SHADER, fsSource); + const GLuint program = CreateProgram(); + AttachShader(program, vs2); + AttachShader(program, fs2); + LinkProgram(program); + + GLint status = GL_FALSE; + GetProgramiv(program, GL_LINK_STATUS, &status); + EXPECT_EQ(status, GL_TRUE) << "the library must be usable after a teardown that drained jobs in flight"; + EXPECT_GE(GetUniformLocation(program, "uColor"), 0); + EXPECT_EQ(GetError(), GL_NO_ERROR); +} diff --git a/MobileGL/MG_Test/Program/CMakeLists.txt b/MobileGL/MG_Test/Program/CMakeLists.txt index 55da30a5..2173f844 100644 --- a/MobileGL/MG_Test/Program/CMakeLists.txt +++ b/MobileGL/MG_Test/Program/CMakeLists.txt @@ -44,6 +44,41 @@ target_link_libraries( ${LINK_LIBRARIES} ) +add_executable( + AsyncLinkTest + AsyncLinkTest.cpp +) + +target_include_directories(AsyncLinkTest PRIVATE + ${MGL_ROOT}/include + ${MGL_ROOT}/MobileGL +) + +target_link_libraries( + AsyncLinkTest PRIVATE + GTest::gtest_main + ${LINK_LIBRARIES} +) + +# Its own binary on purpose: this one calls MobileGL::Destroy(), and ShaderCompilePool's +# stop is a one-way latch for the whole process - every case declared after it in the same +# binary would silently run its compiles and links inline. +add_executable( + AsyncTeardownTest + AsyncTeardownTest.cpp +) + +target_include_directories(AsyncTeardownTest PRIVATE + ${MGL_ROOT}/include + ${MGL_ROOT}/MobileGL +) + +target_link_libraries( + AsyncTeardownTest PRIVATE + GTest::gtest_main + ${LINK_LIBRARIES} +) + target_include_directories(ProgramTest PRIVATE ${MGL_ROOT}/include ${MGL_ROOT}/MobileGL @@ -61,3 +96,5 @@ gtest_discover_tests(ProgramTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit) # Heavier than the rest of the unit suite by design: several cases deliberately saturate the # 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) +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 d5d0fb25..99e7fb42 100644 --- a/MobileGL/MG_Test/Program/ProgramTest.cpp +++ b/MobileGL/MG_Test/Program/ProgramTest.cpp @@ -2934,7 +2934,7 @@ TEST_F(ProgramTest, RecompileWithIdenticalSourceKeepsCompiledStateAndStillLinks) EXPECT_TRUE(ShaderHasMemoizedCompile(vs)); // A first link consumes the stored TShader; the redundant recompile below must not - // disturb the preprocessed source that TakeShaderForLink re-parses from. + // disturb the preprocessed source that ClaimParsedShader re-parses from. GLuint firstProgram = LinkVsFs(vs, fs, GL_TRUE); EXPECT_GE(GetUniformLocation(firstProgram, "uColor"), 0); @@ -2959,7 +2959,7 @@ TEST_F(ProgramTest, RecompileWithIdenticalSourceKeepsCompiledStateAndStillLinks) EXPECT_EQ(String(sourceBuffer.data(), static_cast(written)), String(kP0bVs)); // A second program built from the same, redundantly recompiled shaders links and - // reflects - i.e. TakeShaderForLink's re-parse path survived the no-op. + // reflects - i.e. ClaimParsedShader's re-parse path survived the no-op. GLuint secondProgram = LinkVsFs(vs, fs, GL_TRUE); EXPECT_GE(GetUniformLocation(secondProgram, "uColor"), 0); EXPECT_GE(GetUniformLocation(secondProgram, "uModel"), 0); diff --git a/MobileGL/MG_Util/Async/JobNode.cpp b/MobileGL/MG_Util/Async/JobNode.cpp index 011d201e..1329ea97 100644 --- a/MobileGL/MG_Util/Async/JobNode.cpp +++ b/MobileGL/MG_Util/Async/JobNode.cpp @@ -16,6 +16,30 @@ namespace MobileGL::MG_Util::Async { Bool IsTerminalState(const JobState state) { return state == JobState::Complete || state == JobState::Cancelled; } + + // Job BODIES have been contained since stage 1 (JobNode::Run); continuations were + // not, and stage 4 introduces the first real ones. A continuation runs on whichever + // thread drove the node terminal - for a compile that finished on a worker, that is + // inside an Asio handler, where an escaping exception means thread_pool::run() + // rethrows and the process terminates. It would also skip every continuation after + // it in the list, stranding unrelated dependents. + // + // Containing it here is a backstop, not the contract: a continuation cannot be + // repaired from the outside (the dispatcher has no idea what the callback was for), + // so the registrar still owns "this cannot fail". See JobNode::OnTerminal. + void RunContinuation(const std::function& continuation) { + if (!continuation) return; + try { + continuation(); + } catch (const std::exception& e) { + MGLOG_E("JobNode: a terminal continuation threw (%s); it has been contained, but whatever it " + "was going to do did not happen", + e.what()); + } catch (...) { + MGLOG_E("JobNode: a terminal continuation threw a non-std exception; it has been contained, " + "but whatever it was going to do did not happen"); + } + } } // namespace Bool JobNode::IsTerminal() const { return IsTerminalState(m_state.load(std::memory_order_acquire)); } @@ -47,9 +71,10 @@ namespace MobileGL::MG_Util::Async { m_cv.notify_all(); // Run continuations OUTSIDE the lock: a continuation is free to call back into this // node (IsComplete, State) and, in the link-dependency case, to post the dependent - // job to the pool from whichever thread drove this node terminal. + // job to the pool from whichever thread drove this node terminal. Individually + // contained, so one broken dependent cannot strand the rest of the list. for (auto& continuation : continuations) { - if (continuation) continuation(); + RunContinuation(continuation); } return true; } @@ -122,7 +147,10 @@ namespace MobileGL::MG_Util::Async { return; } } - fn(); + // Already terminal: the caller's thread runs it, through the same guard the deferred + // path uses. OnTerminal is reached from Link()'s GL-thread prologue as well as from a + // worker, and glLinkProgram is not a place an exception may escape from either. + RunContinuation(fn); } void ApplyDeferredDiagnostics(JobNode& node) { diff --git a/MobileGL/MG_Util/Async/JobNode.h b/MobileGL/MG_Util/Async/JobNode.h index a7f4aef4..127b2886 100644 --- a/MobileGL/MG_Util/Async/JobNode.h +++ b/MobileGL/MG_Util/Async/JobNode.h @@ -52,7 +52,12 @@ namespace MobileGL::MG_Util::Async { // Running -> Cancelled (cancelled mid-run, or RunBody() threw) // Complete and Cancelled are terminal and the node is immutable afterwards, so every // reader that observed IsTerminal() may read the outputs without further synchronization. - class JobNode { + // + // enable_shared_from_this because a dependency edge outlives its registrar: a node that + // posts itself from another node's continuation (ProgramLinkTask::OnDepSettled) has to + // hand the pool a strong reference from inside itself. Every JobNode is therefore created + // through MakeShared - a stack-allocated one may not use SubmitAfter-style chaining. + class JobNode : public std::enable_shared_from_this { public: JobNode() = default; virtual ~JobNode() = default; @@ -88,6 +93,16 @@ namespace MobileGL::MG_Util::Async { // `fn` runs on the calling thread before OnTerminal returns. Exactly-once in both // directions: the callback is either handed to the finishing thread or run inline, // never both. + // + // A continuation must not throw. It is dispatched from whichever thread drove this + // node terminal, which on the pool side is an Asio handler - an exception escaping + // one propagates out of thread_pool::run() and terminates the process. The dispatcher + // contains a throw anyway (see RunContinuation) so that one broken continuation + // cannot strand the others, but the continuation itself is where the guarantee + // belongs: whoever registers one owns the "and it cannot fail" argument, because the + // dispatcher can only log, never repair. ProgramLinkTask::OnDepSettled is the worked + // example - it catches internally and cancels itself, because a link that is never + // posted is a joiner blocked forever. void OnTerminal(std::function fn); // Runs the body on the calling thread. The synchronous path (async disabled, diff --git a/MobileGL/MG_Util/Async/ShaderCompilePool.cpp b/MobileGL/MG_Util/Async/ShaderCompilePool.cpp index 1284100c..4dd53bc7 100644 --- a/MobileGL/MG_Util/Async/ShaderCompilePool.cpp +++ b/MobileGL/MG_Util/Async/ShaderCompilePool.cpp @@ -136,7 +136,15 @@ namespace MobileGL::MG_Util::Async { // out by a concurrent StopAndDrain between the decision and the dispatch: asio::post // only enqueues, it never runs the handler on the calling thread, so it cannot // re-enter this mutex. - void DispatchLocked() { + // + // A node asio::post fails to hand off is appended to `toCancel` instead of being + // Cancel()'d here: Cancel() runs the node's OnTerminal continuations inline (stage 4 + // added ProgramLinkTask::OnDepSettled as a real one), and a continuation is free to + // call ShaderCompilePool::Post() again. Every caller of DispatchLocked holds `mutex` + // (a plain, non-recursive std::mutex) - Cancel()'ing in here would let that + // re-entrant Post() deadlock on the very lock this frame already owns. The caller + // drains `toCancel` after releasing the lock. + void DispatchLocked(Vector>& toCancel) { while (!queue.empty() && inFlight < maxConcurrency && !stopped.load(std::memory_order_acquire)) { // Copy rather than move into the handler: if asio::post throws (it allocates) // the local SharedPtr is still valid, so the node can be settled instead of @@ -150,7 +158,7 @@ namespace MobileGL::MG_Util::Async { asio::post(*pool, [this, node]() mutable { RunOnWorker(Move(node)); }); } catch (...) { --inFlight; - node->Cancel(); + toCancel.push_back(Move(node)); } } } @@ -159,14 +167,23 @@ namespace MobileGL::MG_Util::Async { tl_isPoolThread = true; // A node that was already handed to Asio when StopAndDrain ran still arrives // here; cancelling it first turns the dispatch into a state transition instead of - // a full compile, so the drain's join() returns promptly. + // a full compile, so the drain's join() returns promptly. This Cancel() runs + // before `mutex` is ever taken in this frame, so it is not subject to the + // re-entrancy hazard DispatchLocked's comment describes. if (stopped.load(std::memory_order_acquire)) node->Cancel(); node->Run(); node.reset(); - const std::lock_guard lock(mutex); - --inFlight; - DispatchLocked(); + Vector> toCancel; + { + const std::lock_guard lock(mutex); + --inFlight; + DispatchLocked(toCancel); + } + // Outside the lock: see DispatchLocked's comment. + for (const auto& n : toCancel) { + if (n) n->Cancel(); + } } }; @@ -199,10 +216,17 @@ namespace MobileGL::MG_Util::Async { } void ShaderCompilePool::SetMaxConcurrency(const Uint n) { - const std::lock_guard lock(m_impl->mutex); - m_impl->maxConcurrency = std::clamp(n, 1u, m_impl->threadCount); - // Raising the budget releases whatever the old one was holding back. - if (m_impl->pool) m_impl->DispatchLocked(); + Vector> toCancel; + { + const std::lock_guard lock(m_impl->mutex); + m_impl->maxConcurrency = std::clamp(n, 1u, m_impl->threadCount); + // Raising the budget releases whatever the old one was holding back. + if (m_impl->pool) m_impl->DispatchLocked(toCancel); + } + // Outside the lock: see DispatchLocked's comment. + for (const auto& n2 : toCancel) { + if (n2) n2->Cancel(); + } } void ShaderCompilePool::Post(SharedPtr node) { @@ -220,13 +244,15 @@ namespace MobileGL::MG_Util::Async { // exception-safe and SharedPtr's move constructor is noexcept, so a throwing // push_back never consumed it; and DispatchLocked contains its own asio::post // failures rather than propagating them (see above). Keep it that way. + Bool enqueued = false; + Vector> toCancel; try { const std::lock_guard lock(m_impl->mutex); if (!m_impl->stopped.load(std::memory_order_acquire) && !InProcessTeardown()) { if (!m_impl->pool) m_impl->pool = MakeUnique(m_impl->threadCount); m_impl->queue.push_back(Move(node)); - m_impl->DispatchLocked(); - return; + m_impl->DispatchLocked(toCancel); + enqueued = true; } } catch (...) { MGLOG_E("ShaderCompilePool::Post: enqueue failed; cancelling the job so its joiner " @@ -234,6 +260,12 @@ namespace MobileGL::MG_Util::Async { if (node) node->Cancel(); return; } + // Outside the lock: see DispatchLocked's comment - a Cancel() here may run a + // continuation (e.g. ProgramLinkTask::OnDepSettled) that calls back into Post(). + for (const auto& n : toCancel) { + if (n) n->Cancel(); + } + if (enqueued) return; // A stopped pool is a synchronous pool, not a black hole: the node still runs, just // on the caller's thread. Everything downstream already handles "terminal by the time