diff --git a/CMakeLists.txt b/CMakeLists.txt index 3ff0e1a3..b54bb624 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -296,6 +296,7 @@ set(SOURCE_FILES 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/ProgramSpirvTask.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/ProgramState/ProgramLinkTask.cpp b/MobileGL/MG_State/GLState/ProgramState/ProgramLinkTask.cpp index 7d9c8bad..04d987f7 100644 --- a/MobileGL/MG_State/GLState/ProgramState/ProgramLinkTask.cpp +++ b/MobileGL/MG_State/GLState/ProgramState/ProgramLinkTask.cpp @@ -428,18 +428,28 @@ namespace MobileGL::MG_State::GLState { } // ---- past this point the link cannot fail any more ---- - MGLOG_D("ProgramObject %u: Starting SPIR-V generation", in.externalIndex); - GenerateSpirv(); - - MGLOG_D("ProgramObject %u: Building global-UBO routing tables", in.externalIndex); - { - // TEMP-STAGE-PROBE: "spvc-routing" - the SPIRV-Cross session per SPIR-V module. - const MG_Util::Debug::TempStageProbeScope tempStageProbeSpvcRouting( - MG_Util::Debug::kTempStageProbeSpvcRouting); - BuildGlobalUboRouting(); + // Everything left is SPIR-V work, and it belongs to phase B. Hand it what it needs + // and stop: from the join's point of view this program is now fully linked. + // + // The TShaders move rather than copy - `attrib` borrowed them into the TProgram as + // raw pointers and this node is now their owner of record, for as long as phase B + // (which holds this node) needs the intermediates hanging off them. + spirvHandoff.shaders = Move(attrib.shaders); + spirvHandoff.shaderTypes.resize(in.shaders.size()); + for (SizeT i = 0; i < in.shaders.size(); i++) { + spirvHandoff.shaderTypes[i] = MG_Util::ConvertShaderStageToGLEnum(in.shaders[i].stage); } - MGLOG_D("ProgramObject %u: Binary generation finished (generatedSpirv size=%zu)", in.externalIndex, - artifacts.generatedSpirv.size()); + // Copied, not referenced: `artifacts` is MOVED out of this node by the join, and + // phase B runs after that. Measured at ~20 us per program, which is noise against the + // ~450 ms phase B spends on the same program. + spirvHandoff.reflection.program = artifacts.program; + spirvHandoff.reflection.uniformLocations = artifacts.uniformLocations; + spirvHandoff.reflection.uniformIndexInTProgram = artifacts.uniformIndexInTProgram; + spirvHandoff.reflection.tProgramUniformIndexToGl = artifacts.tProgramUniformIndexToGl; + spirvHandoff.reflection.maxUniformLocation = artifacts.maxUniformLocation; + spirvHandoff.ready = true; + MGLOG_D("ProgramObject %u: phase A done, %zu module(s) handed to the SPIR-V job", in.externalIndex, + spirvHandoff.shaderTypes.size()); } Bool ProgramLinkTask::ConsumeShaders(Vector>& outShaders) { @@ -888,206 +898,6 @@ namespace MobileGL::MG_State::GLState { 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); - // TEMP-STAGE-PROBE: "spirv-gen" - GlslangToSpv for every stage of this program. - auto binaryResult = [&] { - const MG_Util::Debug::TempStageProbeScope tempStageProbeSpirvGen( - MG_Util::Debug::kTempStageProbeSpirvGen); - return 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 - { - // TEMP-STAGE-PROBE: "spirv-null" - the same Optimizer::Run with ZERO passes, - // on the pre-optimize binary: pure BuildModule + serialize + IRContext - // teardown. Its device/desktop share ratio against "spirv-opt" is the - // allocator-pathology discriminator. Costs one extra plumbing round per - // module; diagnostic build only. - const MG_Util::Debug::TempStageProbeScope tempStageProbeSpirvNull( - MG_Util::Debug::kTempStageProbeSpirvNull); - for (auto& spv : artifacts.generatedSpirv) { - Vector nullOut; - (void)ShaderCompiler::TempProbeNullOptimizeBinary(spv, nullOut); - } - } - { - // TEMP-STAGE-PROBE: "spirv-opt" - the spirv-tools optimizer run over every module. - const MG_Util::Debug::TempStageProbeScope tempStageProbeSpirvOpt( - MG_Util::Debug::kTempStageProbeSpirvOpt); - 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; diff --git a/MobileGL/MG_State/GLState/ProgramState/ProgramLinkTask.h b/MobileGL/MG_State/GLState/ProgramState/ProgramLinkTask.h index 4b959d11..f18099d1 100644 --- a/MobileGL/MG_State/GLState/ProgramState/ProgramLinkTask.h +++ b/MobileGL/MG_State/GLState/ProgramState/ProgramLinkTask.h @@ -29,10 +29,16 @@ namespace MobileGL::MG_State::GLState { 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. + // PHASE A of one glLinkProgram: the half that decides what GL can be asked about the + // program - glslang link + mapIO, the GL-facing reflection surface, fragment-output + // validation and transform-feedback resolution - with every input it needs snapshotted at + // enqueue. + // + // Every one of the eight ways a link can fail lives here, so once this node has published + // through EnsureLinkJoined() the program's LINK_STATUS, info log and entire query surface + // are FINAL and truthful. SPIR-V generation, spirv-opt and the global-UBO routing tables + // moved to ProgramSpirvTask, which chains behind this node and is joined by only five + // getters (see ProgramObject::EnsureSpirvJoined). // // 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 @@ -70,6 +76,37 @@ namespace MobileGL::MG_State::GLState { // Moved (never copied) into the ProgramObject by EnsureLinkJoined(). ProgramObject::LinkArtifacts artifacts; + // ---- output: everything ProgramSpirvTask needs to run without this node's + // artifacts, filled at the tail of a successful RunBody() ---- + // + // THIS IS NOT `artifacts` AND MUST NOT BE MERGED INTO IT. The GL thread MOVES + // `artifacts` out of this node at the join, and phase B runs on a worker afterwards - + // so phase B may read `spirvHandoff` and `in` (neither is ever touched by the join) + // and this node's JobState, and nothing else on it. Reading `artifacts` or + // `diagnostics` from phase B would race the publish. + struct SpirvHandoff { + // MANDATORY, and the reason this struct exists at all: TProgram::addShader stores + // a RAW TShader*, and for the one-shader-per-stage case getIntermediate() returns + // the TShader's own intermediate rather than a copy. These used to die when + // RunBody() returned, which was safe only because nothing called getIntermediate() + // afterwards. GlslangToSpv does exactly that, so phase B has to own them. + Vector> shaders; + // GL enum per entry of `in.shaders`, in the same order (GetSpirvBinaryFromProgram + // walks it to pick the intermediates). + Vector shaderTypes; + // The reflection slice BuildGlobalUboRouting consumes: {program, uniformLocations, + // uniformIndexInTProgram, tProgramUniformIndexToGl, maxUniformLocation}. Carried + // as a LinkArtifacts with only those five fields set, so the routing pass can keep + // calling ProgramObject::IsValidUniformLocation / GetUniformArraySizeByTIndex + // unchanged. The SharedPtr copy of `program` is also what keeps the TProgram alive + // for phase B after the join has moved `artifacts` away. + ProgramObject::LinkArtifacts reflection; + + // The one flag phase B tests before doing anything: false means this link never + // reached the tail of RunBody (it failed, or was cancelled mid-body). + Bool ready = false; + } spirvHandoff; + // 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 @@ -96,8 +133,6 @@ namespace MobileGL::MG_State::GLState { 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. diff --git a/MobileGL/MG_State/GLState/ProgramState/ProgramObject.cpp b/MobileGL/MG_State/GLState/ProgramState/ProgramObject.cpp index 0e9752ad..e097b82f 100644 --- a/MobileGL/MG_State/GLState/ProgramState/ProgramObject.cpp +++ b/MobileGL/MG_State/GLState/ProgramState/ProgramObject.cpp @@ -8,6 +8,7 @@ #include "ProgramObject.h" #include "ProgramLinkTask.h" +#include "ProgramSpirvTask.h" #include #include #include @@ -68,12 +69,50 @@ namespace MobileGL::MG_State::GLState { Bool ProgramObject::IsPendingLinkTerminal() const { return m_pendingLink->IsTerminal(); } + Bool ProgramObject::IsPendingSpirvTerminal() const { return m_pendingSpirv->IsTerminal(); } + + void ProgramObject::JoinPendingSpirv() const { + MOBILEGL_ASSERT(!MG_Util::Async::ShaderCompilePool::IsPoolThread(), + "ProgramObject::EnsureSpirvJoined() reached from a pool thread; a job body must never read " + "GL-thread-owned objects"); + + // Move the node out FIRST, for the same reason JoinPendingLink does: everything below + // runs GL-thread-only code that reads program state, and with m_pendingSpirv still set + // that would re-enter this function. + const SharedPtr pending = Move(m_pendingSpirv); + m_pendingSpirv.reset(); + + pending->Wait(); + if (pending->IsComplete()) { + m_spirv = Move(pending->artifacts); + } + // A node that settled as Cancelled published nothing, so m_spirv stays empty with + // spirvStatus false: linked, queryable, not drawable. Nothing to repair. + + // The THIRD version bump of this link (enqueue, phase-A publish, phase-B publish), and + // it is mandatory for exactly the reason the phase-A one is (see JoinPendingLink): a + // backend memo taken during the A->B window - when the program was already answering + // as linked but had no SPIR-V and no uniform shadow - must not survive the arrival of + // either. The memos at risk are keyed on (lifetimeId, backendStateVersion). + BumpLinkObservableVersions(); + + MG_Util::Async::ApplyDeferredDiagnostics(*pending); + } + void ProgramObject::CancelLink() { + // Phase B first: it is chained behind phase A, so cancelling A would otherwise run A's + // continuation and post a node this call is about to abandon anyway. Cancelling it up + // front makes that continuation a no-op. + // + // Cooperative and non-blocking, both of them. 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 each + // node keeps its own inputs alive for as long as it needs them. + if (m_pendingSpirv) { + m_pendingSpirv->Cancel(); + m_pendingSpirv.reset(); + } 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(); } @@ -103,8 +142,12 @@ namespace MobileGL::MG_State::GLState { // 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. + // Phase-B output (generatedSpirv / uniformOffsets / globalUboScratch) is NOT cleared + // here and is not in LinkArtifacts at all: the link body calls this on its own block, + // where no phase-B output exists yet. The two GL-thread callers that also have to + // discard phase-B output say so themselves (MarkLinkFailedByProgramBinary clears + // m_spirv; Link()'s prologue assigns a fresh one). artifacts.program.reset(); - artifacts.generatedSpirv.clear(); artifacts.uniformLocations.clear(); artifacts.glUniformIndexToTProgram.clear(); artifacts.tProgramUniformIndexToGl.clear(); @@ -117,9 +160,6 @@ namespace MobileGL::MG_State::GLState { artifacts.uniformBlockIndexByName.clear(); artifacts.uniformBlockBinding.clear(); artifacts.shaderStorageBlockBinding.clear(); - artifacts.uniformOffsets.clear(); - artifacts.uniformSizesInBytes.clear(); - artifacts.globalUboScratch.clear(); artifacts.attribs.clear(); artifacts.attribTypes.clear(); artifacts.activeUniformCount = 0; @@ -238,6 +278,7 @@ namespace MobileGL::MG_State::GLState { // 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 = {}; + m_spirv = {}; // ---- GL-thread-owned mutations ---- // Remove detached shaders first @@ -292,17 +333,33 @@ namespace MobileGL::MG_State::GLState { task->in.shaders.push_back({shader->GetShaderStage(), shader->GetShaderSourcePtr(), node}); } + // Phase B of the same link: SPIR-V generation, spirv-opt and the global-UBO routing + // tables. Created here, alongside phase A, so that from this instant the program has + // BOTH pending nodes and every cancel site (this prologue, ~ProgramObject, + // glProgramBinary's failure) drops both through the one CancelLink(). + auto spirvTask = MakeShared(); m_pendingLink = task; + m_pendingSpirv = spirvTask; // Flag off - or glMaxShaderCompilerThreadsKHR(0), see AsyncShaderCompileActive(): // 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(). + // bodies on this thread, in the same order, and the join below publishes through the + // same code, so the two modes differ only in WHICH thread ran them. + // + // Deliberately NOT expressed as SubmitAfter here: its continuation posts to the pool, + // and in this mode the pool is merely unused rather than stopped - the work would + // silently move off-thread in the one mode whose whole contract is that it does not. if (!MG_Util::Async::AsyncShaderCompileActive()) { task->RunInline(); - EnsureLinkJoined(); + spirvTask->RunInlineAfter(task); + EnsureSpirvJoined(); return; } + // The chain edge FIRST, while phase A is still Pending, so registering it is a plain + // list append rather than an inline continuation on this thread. If SubmitAfter below + // then fails to post phase A it cancels it, and that cancel fires this edge, which + // cancels phase B - nothing is left stranded either way. + spirvTask->SubmitAfter(task); task->SubmitAfter(deps); } diff --git a/MobileGL/MG_State/GLState/ProgramState/ProgramObject.h b/MobileGL/MG_State/GLState/ProgramState/ProgramObject.h index 9540e55e..bb0d1dfc 100644 --- a/MobileGL/MG_State/GLState/ProgramState/ProgramObject.h +++ b/MobileGL/MG_State/GLState/ProgramState/ProgramObject.h @@ -18,6 +18,9 @@ namespace MobileGL::MG_State::GLState { // 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; + // Phase B of the same link: SPIR-V generation, spirv-opt and the global-UBO routing + // tables. Chained behind the ProgramLinkTask, forward-declared for the same reason. + class ProgramSpirvTask; class ProgramObject { public: @@ -303,7 +306,8 @@ namespace MobileGL::MG_State::GLState { // Sentinel for a uniform location without global-UBO backing storage (should not // survive linking: GenerateBinary falls back to tail-allocated scratch storage). static constexpr Uint kInvalidUniformOffset = ~0u; - Uint GetUniformOffset(Uint location) const { return Artifacts().uniformOffsets[location]; } + // PHASE B (joins the SPIR-V job; see EnsureSpirvJoined). + Uint GetUniformOffset(Uint location) const { return Spirv().uniformOffsets[location]; } Uint GetUniformSizesInBytes(Uint location) const { return MG_Util::GetGLTypeSize(GetUniformType(location)); } Int GetAttributeLocation(const String& name) { @@ -381,9 +385,11 @@ namespace MobileGL::MG_State::GLState { const String& GetActiveAttribName(Uint index) const { return NormalizeBuiltinPipeInputName(Artifacts().program->getPipeInput(static_cast(index)).name); } - void* MapUBO() { return Artifacts().globalUboScratch.data(); } - const void* GetUBOData() const { return Artifacts().globalUboScratch.data(); } - Uint GetUBOSize() const { return static_cast(Artifacts().globalUboScratch.size()); } + // PHASE B, all three (see EnsureSpirvJoined): the shadow buffer's layout is decided + // by the OPTIMIZED SPIR-V, so it does not exist until the SPIR-V job has settled. + void* MapUBO() { return Spirv().globalUboScratch.data(); } + const void* GetUBOData() const { return Spirv().globalUboScratch.data(); } + Uint GetUBOSize() const { return static_cast(Spirv().globalUboScratch.size()); } // Content version of the CPU-side global-UBO shadow: writers bump it so backends // 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. @@ -463,6 +469,11 @@ namespace MobileGL::MG_State::GLState { CancelLink(); BumpLinkObservableVersions(); ResetLinkArtifacts(Artifacts()); + // ResetLinkArtifacts is a LinkArtifacts-only operation (the link body calls it on + // its own block, where no phase-B output exists yet), so the phase-B half is + // cleared here. CancelLink() above already dropped the pending SPIR-V job, so + // this cannot be racing a publish. + m_spirv = {}; Artifacts().infoLog = "No program binary format is supported."; } Bool GetValidateStatus() const { return m_validateStatus; } @@ -571,8 +582,15 @@ namespace MobileGL::MG_State::GLState { return Artifacts().shaderStorageBlockBinding; } - Vector>& GetGeneratedSpirv() { return Artifacts().generatedSpirv; } - const Vector>& GetGeneratedSpirv() const { return Artifacts().generatedSpirv; } + // PHASE B (see EnsureSpirvJoined). Empty for a program whose SPIR-V job was + // cancelled; GetSpirvStatus() below is how a backend tells that apart from a program + // that never linked. + Vector>& GetGeneratedSpirv() { return Spirv().generatedSpirv; } + const Vector>& GetGeneratedSpirv() const { return Spirv().generatedSpirv; } + // Whether phase B produced usable SPIR-V. Joins, like the four getters above: a + // backend asks this exactly where it used to ask GetLinkStatus(), i.e. right before + // it builds or draws with the program. + Bool GetSpirvStatus() const { return Spirv().spirvStatus; } // The linked glslang reflection itself, for the ONE consumer that needs resource // lists no typed getter above exposes: the GL program-interface query layer @@ -620,7 +638,6 @@ namespace MobileGL::MG_State::GLState { // without going through the gate. struct LinkArtifacts { SharedPtr program; - Vector> generatedSpirv; // Attributes (Vertex in) Vector attribs; @@ -664,11 +681,6 @@ namespace MobileGL::MG_State::GLState { // SetShaderStorageBlockBinding for why this one is by name and not by index. UnorderedMap shaderStorageBlockBinding; - // Need to be reflected after linking of SPIR-V binary - Vector uniformOffsets; - Vector uniformSizesInBytes; - Vector globalUboScratch; - Uint activeUniformCount = 0; Uint maxUniformLocation = 0; Int uniformNameMaxLength = 0; @@ -697,6 +709,35 @@ namespace MobileGL::MG_State::GLState { Uint32 xfbPackedStride = 0; }; + // ---- everything phase B of a link produces, in one movable block ---- + // + // The membership rule is the same mechanical one LinkArtifacts uses: this is exactly + // what ProgramSpirvTask writes, which is what makes moving it THE publish. It is + // deliberately NOT part of LinkArtifacts, and that separation is what routes the five + // readers of SPIR-V-derived data through their own join gate by compiler rather than + // by review - m_spirv is private and Spirv() is the only spelling that reaches it. + // + // Why these three and nothing else: `generatedSpirv` has no GL-thread reader at all + // (every consumer is a backend draw/prepare path), and `uniformOffsets` + + // `globalUboScratch` are the ONLY things glUniform*/glGetUniform* need that are + // derived from the OPTIMIZED SPIR-V rather than from glslang reflection - spirv-opt + // runs in place and can delete a uniform, or the whole global UBO, so the offsets + // cannot be lifted out of glslang's reflection instead. + struct SpirvArtifacts { + Vector> generatedSpirv; + // Byte offset of each uniform location inside globalUboScratch, or + // kInvalidUniformOffset. Sized maxUniformLocation + 1 by the routing pass. + Vector uniformOffsets; + Vector globalUboScratch; + // False for a program whose SPIR-V was never produced (phase B cancelled at + // teardown or by a relink) or whose optimizer run failed. GL has no way to + // retract a LINK_STATUS it already reported true, so such a program stays + // "linked" and every reflection answer it has given stays correct - it is simply + // not drawable, which the backends already express through their link-status + // gates. + Bool spirvStatus = false; + }; + // ---- 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 @@ -736,9 +777,20 @@ namespace MobileGL::MG_State::GLState { // 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. + // + // PHASE A ONLY. After this returns, LINK_STATUS and the whole GL query surface are + // final and truthful, but the SPIR-V and the uniform shadow may still be in flight. void JoinLink() const { EnsureLinkJoined(); } - // Drops a link that is still in flight, without waiting for it. Called at the points + // Both phases. The draw path uses this, and must: the backends sample lifetimeId / + // backendStateVersion / the UBO content version OUTSIDE the gate, so a draw that + // joined only phase A would sample a version, join phase B later inside the same draw + // (through GetGeneratedSpirv), and memoize under a version the phase-B publish had + // already superseded - the exact lost-invalidation hazard J1 exists to prevent. + void JoinLinkAndSpirv() const { EnsureSpirvJoined(); } + + // Drops BOTH phases of a link that is still in flight, without waiting for either. + // 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. @@ -757,7 +809,15 @@ namespace MobileGL::MG_State::GLState { // 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(); } + // + // BOTH phases, deliberately: an application that polls GL_COMPLETION_STATUS_KHR and + // then draws must not be told "done" while the SPIR-V is still being generated, or + // the draw it was cleared for is the thing that blocks. + Bool IsLinkComplete() const { return IsPhaseALinkComplete() && IsSpirvComplete(); } + // Phase A alone, for the callers that only care about the query surface (and for the + // tests that pin the two phases apart). + Bool IsPhaseALinkComplete() const { return m_pendingLink == nullptr || IsPendingLinkTerminal(); } + Bool IsSpirvComplete() const { return m_pendingSpirv == nullptr || IsPendingSpirvTerminal(); } void SetTransformFeedbackVaryings(Vector&& names, GLenum bufferMode) { m_requestedXfbVaryings = Move(names); @@ -824,6 +884,21 @@ namespace MobileGL::MG_State::GLState { // node's state goes through this out-of-line helper. Bool IsPendingLinkTerminal() const; + // ---- the second join gate: phase-B (SPIR-V) output only ---- + // Phase A FIRST, always. Two reasons: the phase-B publish replays the uniform writes + // that were buffered during its window, and those need the phase-A reflection to + // validate against; and a caller that reaches a phase-B getter without having settled + // phase A would otherwise leave the link half-published. + // + // Same inline/out-of-line split as the phase-A gate, for the same reason: the five + // getters behind this one include the per-draw uniform upload path. + void EnsureSpirvJoined() const { + if (m_pendingLink) JoinPendingLink(); + if (m_pendingSpirv) JoinPendingSpirv(); + } + void JoinPendingSpirv() const; + Bool IsPendingSpirvTerminal() const; + LinkArtifacts& Artifacts() { EnsureLinkJoined(); return m_artifacts; @@ -832,6 +907,14 @@ namespace MobileGL::MG_State::GLState { EnsureLinkJoined(); return m_artifacts; } + SpirvArtifacts& Spirv() { + EnsureSpirvJoined(); + return m_spirv; + } + const SpirvArtifacts& Spirv() const { + EnsureSpirvJoined(); + return m_spirv; + } // GL-thread-only companion to ResetLinkArtifacts (see its definition). Const because // the publish half of the join calls it; see the mutable counters below. @@ -899,10 +982,17 @@ namespace MobileGL::MG_State::GLState { // 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; + // Phase-B output. Same mutability argument as m_artifacts, reached only through + // Spirv(). + mutable SpirvArtifacts m_spirv; // 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; + // The SPIR-V job, chained behind m_pendingLink. Null means m_spirv is already the + // answer. A program can be in the window where m_pendingLink is already null (phase A + // published, the query surface is live) while this is still set. + mutable SharedPtr m_pendingSpirv; }; } // namespace MobileGL::MG_State::GLState diff --git a/MobileGL/MG_State/GLState/ProgramState/ProgramSpirvTask.cpp b/MobileGL/MG_State/GLState/ProgramState/ProgramSpirvTask.cpp new file mode 100644 index 00000000..982d6432 --- /dev/null +++ b/MobileGL/MG_State/GLState/ProgramState/ProgramSpirvTask.cpp @@ -0,0 +1,319 @@ +// MobileGL - MobileGL/MG_State/GLState/ProgramState/ProgramSpirvTask.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 "ProgramSpirvTask.h" + +#include // GlslangThreadAllocatorGuard +#include +#include // TEMP-STAGE-PROBE +#include +#include +#include + +#include + +namespace MobileGL::MG_State::GLState { + void ProgramSpirvTask::DeferLog(String line) { diagnostics.logLines.push_back(Move(line)); } + + void ProgramSpirvTask::SubmitAfter(const SharedPtr& phaseA) { + MOBILEGL_ASSERT(phaseA != nullptr, "ProgramSpirvTask::SubmitAfter: the phase-A node is missing"); + m_phaseA = phaseA; + + auto self = std::static_pointer_cast(shared_from_this()); + // ONE dependency, so no counter and no guard slot: the whole race + // ProgramLinkTask::SubmitAfter's +1 exists to close (a dependency settling while the + // remaining edges are still being registered) cannot arise with a single edge. + // + // Runs inline, right here, if phase A is already terminal. + phaseA->OnTerminal([self, phaseA] { + // "Dependency did not complete, publish nothing" - the same collapse + // ProgramLinkTask::CompiledArtifacts() performs for an abandoned compile. Note + // this reads the HANDOFF, never phaseA->artifacts: the GL thread may already be + // moving those out (see the class comment). + if (!phaseA->IsComplete() || !phaseA->spirvHandoff.ready) { + self->Cancel(); + return; + } + // A cancel that landed before phase A settled (relink, glDeleteProgram, teardown). + // Posting would only make a worker pick up a node that immediately falls out of + // Run() again. + if (self->IsCancellationRequested()) { + self->Cancel(); + 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, and the catch below CANCELS rather than swallowing - a + // phase B that is never posted is a GL thread blocked forever in + // EnsureSpirvJoined(), which is far worse than a program reported as not drawable. + try { + MG_Util::Async::ShaderCompilePool::Get().Post(self); + } catch (...) { + self->Cancel(); + } + }); + } + + void ProgramSpirvTask::RunInlineAfter(const SharedPtr& phaseA) { + MOBILEGL_ASSERT(phaseA != nullptr, "ProgramSpirvTask::RunInlineAfter: the phase-A node is missing"); + MOBILEGL_ASSERT(phaseA->IsTerminal(), + "ProgramSpirvTask::RunInlineAfter: phase A has not settled; the inline path must run the " + "two bodies in order on the same thread"); + m_phaseA = phaseA; + RunInline(); + } + + // Pure CPU work only, on a pool worker (or on the GL thread in the inline mode). + // Everything this reads is either owned by this node or published by a terminal phase A; + // everything it writes is `artifacts` (and diagnostics). Same prohibitions as + // ProgramLinkTask::RunBody - no GL/EGL call, no pActiveBackendObject read, no + // pGLContext->RecordError(). + void ProgramSpirvTask::RunBody() { + // glslang leaves this worker's TLS pool allocator pointing at the last arena it + // touched; reset it on the way out so an unrelated later job cannot allocate out of a + // pool that has since been freed. Declared FIRST so it is destroyed LAST - the phase-A + // release below drops the TShaders (and their pools) and must happen inside it. + const GlslangThreadAllocatorGuard glslangGuard; + using namespace MG_Util::ShaderTranspiler; + + // Drop phase A - and with it the TShaders, the TProgram reference and phase A's whole + // input snapshot - the moment this body is done, rather than at some later join. For a + // pack load that is the difference between W glslang arenas alive and all of them. + struct PhaseAReleaser { + SharedPtr& node; + ~PhaseAReleaser() { node.reset(); } + } const phaseAReleaser{m_phaseA}; + + if (!m_phaseA) return; + const ProgramLinkTask::SpirvHandoff& handoff = m_phaseA->spirvHandoff; + const Uint externalIndex = m_phaseA->in.externalIndex; + if (!handoff.ready || !handoff.reflection.program) { + // Phase A did not reach its tail (it failed the link, or was cancelled mid-body). + // Publish nothing; spirvStatus stays false. + return; + } + + // TEMP-STAGE-PROBE: "spirvtask-total" (whole phase-B body, superset of spirv-gen / + // spirv-null / spirv-opt / spvc-routing). + const MG_Util::Debug::TempStageProbeScope tempStageProbeSpirvTask( + MG_Util::Debug::kTempStageProbeSpirvTaskTotal); + + MGLOG_D("ProgramObject %u: Starting SPIR-V generation", externalIndex); + GenerateSpirv(handoff, externalIndex); + + MGLOG_D("ProgramObject %u: Building global-UBO routing tables", externalIndex); + { + // TEMP-STAGE-PROBE: "spvc-routing" - the SPIRV-Cross session per SPIR-V module. + const MG_Util::Debug::TempStageProbeScope tempStageProbeSpvcRouting( + MG_Util::Debug::kTempStageProbeSpvcRouting); + BuildGlobalUboRouting(handoff, externalIndex); + } + MGLOG_D("ProgramObject %u: Binary generation finished (generatedSpirv size=%zu)", externalIndex, + artifacts.generatedSpirv.size()); + } + + void ProgramSpirvTask::GenerateSpirv(const ProgramLinkTask::SpirvHandoff& handoff, const Uint externalIndex) { + /* 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", externalIndex); + + // The shaders were parsed once, in the link-compatible (relaxed Vulkan-rules) + // configuration, and the handoff's program linked those parses - so it IS the program + // the backends consume. Generate SPIR-V straight from its intermediates, which the + // handoff's TShaders keep alive. + ProgramBinaryAttrib binaryAttrib{ + .shaderTypes = handoff.shaderTypes, + .program = *handoff.reflection.program, + }; + MGLOG_D("ProgramObject %u: GenerateSpirv - requesting SPIR-V binary from program", externalIndex); + // TEMP-STAGE-PROBE: "spirv-gen" - GlslangToSpv for every stage of this program. + auto binaryResult = [&] { + const MG_Util::Debug::TempStageProbeScope tempStageProbeSpirvGen( + MG_Util::Debug::kTempStageProbeSpirvGen); + return ShaderCompiler::GetSpirvBinaryFromProgram(binaryAttrib); + }(); + if (!binaryResult) { + DeferLog(std::format("ProgramObject {}: GenerateSpirv - GetSpirvBinaryFromProgram failed", externalIndex)); + MOBILEGL_ASSERT(binaryResult, "GetSpirvBinaryFromProgram failed"); + return; // spirvStatus stays false: linked, but not drawable. + } + artifacts.generatedSpirv = Move(binaryResult.value()); + MGLOG_D("ProgramObject %u: GenerateSpirv - generated %zu SPIR-V modules", externalIndex, + artifacts.generatedSpirv.size()); + + // Linked SPIR-V generated, sanitize and optimize it + { + // TEMP-STAGE-PROBE: "spirv-null" - the same Optimizer::Run with ZERO passes, + // on the pre-optimize binary: pure BuildModule + serialize + IRContext + // teardown. Its device/desktop share ratio against "spirv-opt" is the + // allocator-pathology discriminator. Costs one extra plumbing round per + // module; diagnostic build only. + const MG_Util::Debug::TempStageProbeScope tempStageProbeSpirvNull( + MG_Util::Debug::kTempStageProbeSpirvNull); + for (auto& spv : artifacts.generatedSpirv) { + Vector nullOut; + (void)ShaderCompiler::TempProbeNullOptimizeBinary(spv, nullOut); + } + } + Bool allOptimized = true; + { + // TEMP-STAGE-PROBE: "spirv-opt" - the spirv-tools optimizer run over every module. + const MG_Util::Debug::TempStageProbeScope tempStageProbeSpirvOpt( + MG_Util::Debug::kTempStageProbeSpirvOpt); + for (auto& spv : artifacts.generatedSpirv) { + auto success = ShaderCompiler::SanitizeAndOptimizeBinary(spv, spv); + if (!success) { + // The one genuine phase-B failure mode: one of the seven optimizer passes + // reported failure, so `spv` is whatever the run left behind. A fordebug + // build trips the assert below; a release build used to hand that binary + // to the backend regardless. It no longer does - the program keeps its + // (truthful) LINK_STATUS and its whole query surface, and the routing + // tables below still give every settable uniform storage so glUniform* + // and glGetUniform* keep working, but spirvStatus stays false and the + // backends refuse to build or draw with it. + allOptimized = false; + DeferLog(std::format("ProgramObject {}: SanitizeAndOptimizeBinary failed; the program is linked " + "and queryable but not drawable", + externalIndex)); + } + MOBILEGL_ASSERT(success, "SanitizeBinary failed"); + } + } + artifacts.spirvStatus = allOptimized; + } + + void ProgramSpirvTask::BuildGlobalUboRouting(const ProgramLinkTask::SpirvHandoff& handoff, + const Uint externalIndex) { + using namespace MG_Util::ShaderTranspiler; + // The phase-A reflection slice this pass keys off. Carried in the handoff rather than + // read off the phase-A node's artifacts, which the join has very likely already moved. + const ProgramObject::LinkArtifacts& reflection = handoff.reflection; + + 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(reflection.maxUniformLocation + 1, ProgramObject::kInvalidUniformOffset); + for (SizeT i = 0; i < artifacts.generatedSpirv.size(); i++) { + auto& spv = artifacts.generatedSpirv[i]; + + auto shaderType = i < handoff.shaderTypes.size() ? handoff.shaderTypes[i] : GLenum{0}; + MGLOG_D("ProgramObject %u: BuildGlobalUboRouting - parsing SPIR-V meta data for module %zu " + "(shaderType=%u, wordCount=%zu)", + 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", + 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", + 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 = reflection.uniformLocations.find(name); + if (locationIt == reflection.uniformLocations.end()) { + locationIt = reflection.uniformLocations.find(name + "[0]"); + } + if (locationIt == reflection.uniformLocations.end()) { + MGLOG_D("ProgramObject %u: BuildGlobalUboRouting - uniform '%s' offset=%u but not found in " + "uniformLocations", + externalIndex, name.c_str(), offset); + continue; + } + const Uint baseLocation = locationIt->second; + if (!ProgramObject::IsValidUniformLocation(reflection, static_cast(baseLocation))) { + continue; + } + + const Int uniformIndex = reflection.uniformIndexInTProgram[baseLocation]; + const GLint arraySize = ProgramObject::GetUniformArraySizeByTIndex(reflection, uniformIndex); + 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 > reflection.maxUniformLocation || + reflection.uniformIndexInTProgram[location] != uniformIndex) { + break; + } + artifacts.uniformOffsets[location] = offset + static_cast(element) * arrayStride; + } + MGLOG_D("ProgramObject %u: BuildGlobalUboRouting - uniform '%s' offset=%u stride=%u assigned " + "to locations %u..%u", + externalIndex, name.c_str(), offset, arrayStride, baseLocation, + baseLocation + static_cast(elementCount) - 1); + } + MGLOG_D("ProgramObject %u: BuildGlobalUboRouting - finished parsing module %zu metadata", + 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 <= reflection.maxUniformLocation; ++location) { + if (artifacts.uniformOffsets[location] != ProgramObject::kInvalidUniformOffset) continue; + if (!ProgramObject::IsValidUniformLocation(reflection, static_cast(location))) continue; + const auto& uniform = reflection.program->getUniform(reflection.uniformIndexInTProgram[location]); + const glslang::TType* type = uniform.getType(); + if (type != nullptr && type->isOpaque()) continue; + if (uniform.index >= 0 && uniform.index < reflection.program->getNumUniformBlocks() && + std::strstr(reflection.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); + 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", + externalIndex, uniform.name.c_str(), location, slotSize, slotOffset); + } + } +} // namespace MobileGL::MG_State::GLState diff --git a/MobileGL/MG_State/GLState/ProgramState/ProgramSpirvTask.h b/MobileGL/MG_State/GLState/ProgramState/ProgramSpirvTask.h new file mode 100644 index 00000000..c983e388 --- /dev/null +++ b/MobileGL/MG_State/GLState/ProgramState/ProgramSpirvTask.h @@ -0,0 +1,77 @@ +// MobileGL - MobileGL/MG_State/GLState/ProgramState/ProgramSpirvTask.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 + +namespace MobileGL::MG_State::GLState { + // PHASE B of one glLinkProgram: GlslangToSpv, spirv-opt, and the SPIRV-Cross pass that + // builds the glUniform*-to-scratch routing tables. Chained behind exactly one + // ProgramLinkTask and joined by exactly five ProgramObject getters (GetGeneratedSpirv, + // GetUniformOffset, MapUBO, GetUBOData, GetUBOSize), so ~120 other getters and the whole + // GL query surface stay on the phase-A gate and answer without waiting for any of this. + // + // ---- what this node may read, and what it may not ---- + // It holds the phase-A node by SharedPtr and reads `phaseA->spirvHandoff` plus + // `phaseA->in`. It must NEVER read `phaseA->artifacts` or `phaseA->diagnostics`: the GL + // thread MOVES the artifacts out of the node at the phase-A join and DRAINS the + // diagnostics there, and both of those can happen while this body runs. The handoff exists + // precisely so this node has a copy of everything it needs that the join does not touch. + // (The general JobNode rule - a terminal node is immutable, so its outputs need no further + // synchronization - covers everything except the two members the join consumes.) + // + // ---- lifetime ---- + // The handoff owns the Vector>, and that is mandatory rather + // than tidy: glslang::TProgram stores raw TShader* and, for the one-shader-per-stage case, + // BORROWS each stage's TIntermediate from its TShader. GlslangToSpv reads exactly those + // intermediates. Before the split the shaders died when ProgramLinkTask::RunBody returned, + // which was safe only because nothing called getIntermediate() afterwards. + // + // ---- failure ---- + // A cancel (relink, teardown, program destruction) or an optimizer failure publishes + // spirvStatus = false rather than a half-built program. GL cannot retract a LINK_STATUS it + // already reported true, so such a program stays linked and fully queryable; it is just + // not drawable, which the backends express through their existing link-status gates. + class ProgramSpirvTask final : public MG_Util::Async::JobNode { + public: + // ---- output: valid iff IsComplete(), immutable afterwards ---- + // Moved (never copied) into the ProgramObject by EnsureSpirvJoined(). + ProgramObject::SpirvArtifacts artifacts; + + // Posts this job when `phaseA` goes terminal - and not one moment earlier, so the body + // never waits on anything (invariant I4: no job body may block on another job). A + // single dependency needs no counter, just the one continuation; it runs inline right + // here if `phaseA` is already terminal, which is the same case + // ProgramLinkTask::SubmitAfter already reasons about. + // + // GL thread only, and only after the caller has stored a SharedPtr to this node: the + // continuation takes shared_from_this(). + void SubmitAfter(const SharedPtr& phaseA); + + // The async-off / glMaxShaderCompilerThreadsKHR(0) path: run the body on the calling + // thread, right now, against an ALREADY-TERMINAL phase A. Deliberately not routed + // through SubmitAfter, whose continuation would Post() to a pool that is merely + // unused rather than stopped - that would move the work off-thread in the one mode + // whose contract is "byte-identical to the synchronous implementation". + void RunInlineAfter(const SharedPtr& phaseA); + + private: + void RunBody() override; + + void GenerateSpirv(const ProgramLinkTask::SpirvHandoff& handoff, Uint externalIndex); + void BuildGlobalUboRouting(const ProgramLinkTask::SpirvHandoff& handoff, Uint externalIndex); + + // Worker-side MGLOG replacement, replayed by the join on the GL thread. Same reason as + // ProgramLinkTask::DeferLog. + void DeferLog(String line); + + SharedPtr m_phaseA; + }; +} // namespace MobileGL::MG_State::GLState diff --git a/MobileGL/MG_Test/Program/AsyncLinkTest.cpp b/MobileGL/MG_Test/Program/AsyncLinkTest.cpp index 70545549..070b20d1 100644 --- a/MobileGL/MG_Test/Program/AsyncLinkTest.cpp +++ b/MobileGL/MG_Test/Program/AsyncLinkTest.cpp @@ -117,11 +117,26 @@ void main() { fragColor = thisIdentifierWasNeverDeclared; } } // The non-joining view of the program, i.e. what GL_COMPLETION_STATUS_KHR will report. + // BOTH phases: a program whose SPIR-V job is still in flight is not finished, even though + // its whole GL query surface already answers. Bool LinkIsSettled(const GLuint program) { const auto& object = MG_State::pGLContext->GetProgramObject(program); return object == nullptr || object->IsLinkComplete(); } + // Phase A alone: the half that decides LINK_STATUS, the info log, and every reflection + // query. This is what a read of LINK_STATUS is required to settle. + Bool PhaseALinkIsSettled(const GLuint program) { + const auto& object = MG_State::pGLContext->GetProgramObject(program); + return object == nullptr || object->IsPhaseALinkComplete(); + } + + // Phase B alone: SPIR-V + the uniform shadow's layout. + Bool SpirvIsSettled(const GLuint program) { + const auto& object = MG_State::pGLContext->GetProgramObject(program); + return object == nullptr || object->IsSpirvComplete(); + } + // 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) { @@ -516,11 +531,63 @@ TEST_F(AsyncLinkTest, LinkProgramReturnsBeforeTheWorkIsDone) { for (const GLuint program : programs) { EXPECT_EQ(QueryLinkStatus(program), GL_TRUE) << QueryProgramInfoLog(program); - EXPECT_TRUE(LinkIsSettled(program)) << "reading LINK_STATUS must have joined"; + // PHASE A only. Reading LINK_STATUS settles the half that decides it, and no more - + // the SPIR-V job may well still be running, which is the entire point of the split. + EXPECT_TRUE(PhaseALinkIsSettled(program)) << "reading LINK_STATUS must have joined phase A"; } EXPECT_EQ(GetError(), GL_NO_ERROR); } +// The other half of the previous case, and the property the two-phase split exists for: +// LINK_STATUS is answerable without the SPIR-V, so a run of LINK_STATUS reads over a +// backlog must leave SPIR-V jobs outstanding rather than draining them one by one. +TEST_F(AsyncLinkTest, ReadingLinkStatusDoesNotSettleTheSpirvJob) { + const AsyncModeScope async(true); + MG_Util::Async::ShaderCompilePool::Get().SetMaxConcurrency(1); + constexpr int kPrograms = 24; + + const GLuint vs = MakeShader(GL_VERTEX_SHADER, kVs); + Vector programs; + Vector sources; + for (int i = 0; i < kPrograms; ++i) { + sources.push_back(MakeBulkySource(7900 + 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 spirvOutstanding = 0; + for (int i = 0; i < kPrograms; ++i) { + const GLuint program = programs[static_cast(i)]; + EXPECT_EQ(QueryLinkStatus(program), GL_TRUE) << QueryProgramInfoLog(program); + EXPECT_TRUE(PhaseALinkIsSettled(program)) << "reading LINK_STATUS must have joined phase A"; + // Reflection has to answer here too, out of phase A and with no further join. + const String uniformName = "uSeed" + std::to_string(7900 + i); + EXPECT_GE(GetUniformLocation(program, uniformName.c_str()), 0) << uniformName; + if (!SpirvIsSettled(program)) ++spirvOutstanding; + } + EXPECT_GT(spirvOutstanding, 0) << "the whole GL query surface was answered and yet every SPIR-V job had " + "already been drained - the reads are joining phase B"; + + // And the SPIR-V gate really is a gate: touching it settles the job. + for (const GLuint program : programs) { + const auto& object = MG_State::pGLContext->GetProgramObject(program); + ASSERT_NE(object, nullptr); + EXPECT_GT(object->GetGeneratedSpirv().size(), 0u); + EXPECT_TRUE(SpirvIsSettled(program)); + EXPECT_TRUE(LinkIsSettled(program)); + } + EXPECT_EQ(GetError(), GL_NO_ERROR); + MG_Util::Async::ShaderCompilePool::Get().SetMaxConcurrency( + MG_Util::Async::ShaderCompilePool::Get().GetThreadCount()); +} + // 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) { diff --git a/MobileGL/MG_Test/Program/ParallelShaderCompileTest.cpp b/MobileGL/MG_Test/Program/ParallelShaderCompileTest.cpp index d6c8333f..45de28e1 100644 --- a/MobileGL/MG_Test/Program/ParallelShaderCompileTest.cpp +++ b/MobileGL/MG_Test/Program/ParallelShaderCompileTest.cpp @@ -239,7 +239,15 @@ TEST_F(ParallelShaderCompileTest, ProgramCompletionStatusReportsFalseWithoutJoin for (const GLuint program : programs) { EXPECT_EQ(QueryLinkStatus(program), GL_TRUE); - EXPECT_EQ(QueryProgramCompletion(program), GL_TRUE) << "GL_LINK_STATUS must have joined"; + // GL_COMPLETION_STATUS_KHR spans BOTH phases of a link, so reading GL_LINK_STATUS - + // which is answered out of phase A - is no longer enough to turn it GL_TRUE. That is + // deliberate: an application that polls completion and then draws must not be told + // "done" while the SPIR-V is still being generated, or the draw it was cleared for is + // the thing that blocks. Settling both phases is what makes the query true. + const auto& object = MG_State::pGLContext->GetProgramObject(program); + ASSERT_NE(object, nullptr); + object->JoinLinkAndSpirv(); + EXPECT_EQ(QueryProgramCompletion(program), GL_TRUE) << "a full join must have settled both phases"; } EXPECT_EQ(GetError(), GL_NO_ERROR); } diff --git a/MobileGL/MG_Util/Debug/TempStageProbe.h b/MobileGL/MG_Util/Debug/TempStageProbe.h index e69cac4e..d569cdf5 100644 --- a/MobileGL/MG_Util/Debug/TempStageProbe.h +++ b/MobileGL/MG_Util/Debug/TempStageProbe.h @@ -37,6 +37,7 @@ namespace MobileGL::MG_Util::Debug { kTempStageProbeSpvcRouting, kTempStageProbeCompileTaskTotal, kTempStageProbeLinkTaskTotal, + kTempStageProbeSpirvTaskTotal, kTempStageProbeStageCount }; @@ -52,6 +53,7 @@ namespace MobileGL::MG_Util::Debug { "spvc-routing", "compiletask-total[superset]", "linktask-total[superset]", + "spirvtask-total[superset]", }; inline std::atomic tempStageProbeMicros[kTempStageProbeStageCount] = {};