From 1eeeb44d946fd7640b718cbb99d346117df6827e Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Thu, 20 Aug 2026 12:00:01 -0400 Subject: [PATCH] [Perf] (ProgramState): serve a whole linked program from translation cache L1, skipping the link entirely --- CMakeLists.txt | 1 + MobileGL/Init.cpp | 3 + .../GLState/ProgramState/ProgramLinkTask.cpp | 141 +++++++++++++----- .../GLState/ProgramState/ProgramLinkTask.h | 21 +++ .../GLState/ProgramState/ProgramSpirvTask.cpp | 66 ++++---- .../ProgramState/ProgramTranslationCache.cpp | 82 ++++++++++ .../ProgramState/ProgramTranslationCache.h | 61 ++++++++ .../ShaderTranspiler/TranslationCache.cpp | 47 ++---- .../ShaderTranspiler/TranslationCache.h | 51 ++++--- 9 files changed, 348 insertions(+), 125 deletions(-) create mode 100644 MobileGL/MG_State/GLState/ProgramState/ProgramTranslationCache.cpp create mode 100644 MobileGL/MG_State/GLState/ProgramState/ProgramTranslationCache.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 32614803..a7e02e80 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -389,6 +389,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/ProgramTranslationCache.cpp MobileGL/MG_State/GLState/ProgramState/ProgramSpirvTask.cpp MobileGL/MG_State/GLState/ProgramState/ShaderCompileTask.cpp MobileGL/MG_State/GLState/ProgramState/ShaderObject.cpp diff --git a/MobileGL/Init.cpp b/MobileGL/Init.cpp index 2e4497d3..68870532 100644 --- a/MobileGL/Init.cpp +++ b/MobileGL/Init.cpp @@ -18,6 +18,7 @@ #include #include #include +#include #include #include @@ -79,6 +80,8 @@ namespace MobileGL { // fordebug build gets one line per level saying how the run went. MG_Util::ShaderTranspiler::LogShaderTranslationCacheStats(); MG_Util::ShaderTranspiler::ClearShaderTranslationCaches(); + MG_State::GLState::LogProgramTranslationCacheStats(); + MG_State::GLState::ClearProgramTranslationCache(); MG_Backend::gBackendFunctionsTable = {}; g_isInitialized = false; if (logLifecycle) { diff --git a/MobileGL/MG_State/GLState/ProgramState/ProgramLinkTask.cpp b/MobileGL/MG_State/GLState/ProgramState/ProgramLinkTask.cpp index 51517e7a..6ccabe7a 100644 --- a/MobileGL/MG_State/GLState/ProgramState/ProgramLinkTask.cpp +++ b/MobileGL/MG_State/GLState/ProgramState/ProgramLinkTask.cpp @@ -8,6 +8,8 @@ #include "ProgramLinkTask.h" +#include + #include #include #include @@ -373,6 +375,20 @@ namespace MobileGL::MG_State::GLState { MGLOG_D("ProgramObject %u: Link body start, shaders to link: %zu", in.externalIndex, in.shaders.size()); + if (!ValidateAttachedShaders()) return; + + // The two merges below read the COMPILE snapshots only - no parsed shader - so they + // run before the L1 probe, which needs the merged opaque bindings in its key. + MergeShaderSideChannels(); + if (!artifacts.infoLog.empty()) return; // a conflicting explicit uniform location + + // ---- L1 of the shader translation memo ---- + // Everything below this point - the parse, the link, mapIO, GlslangToSpv, spirv-opt, + // buildReflection and the global-UBO routing - is what a hit skips. See + // ProgramTranslationCache.h. + spirvHandoff.spirvCacheKey = BuildSpirvCacheKey(env); + if (TryPublishFromTranslationCache()) return; + Vector> shaders; if (!ConsumeShaders(shaders)) return; @@ -399,30 +415,6 @@ namespace MobileGL::MG_State::GLState { } } - // 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, @@ -570,7 +562,14 @@ namespace MobileGL::MG_State::GLState { spirvHandoff.reflection.uniformReflection = artifacts.uniformReflection; spirvHandoff.reflection.blockReflection = artifacts.blockReflection; spirvHandoff.reflection.tProgramBlockIndexToGl = artifacts.tProgramBlockIndexToGl; - spirvHandoff.spirvCacheKey = BuildSpirvCacheKey(env); + // Phase B pairs this with its own SpirvArtifacts to insert the completed front end. + // A COPY, because the GL-thread join moves `artifacts` out of this node before phase B + // runs - and with the TProgram dropped, because a memo must never hold a glslang arena. + if (spirvHandoff.spirvCacheKey.Valid()) { + auto forCache = MakeShared(artifacts); + forCache->program.reset(); + spirvHandoff.linkArtifactsForCache = Move(forCache); + } spirvHandoff.ready = true; MGLOG_D("ProgramObject %u: phase A done, %zu module(s) handed to the SPIR-V job", in.externalIndex, spirvHandoff.shaderTypes.size()); @@ -579,12 +578,9 @@ namespace MobileGL::MG_State::GLState { // The L1 key. Every input below is one that can change the SPIR-V this program // generates; see the key inventory on SpirvTranslationKeyInputs. // - // Deliberately NOT keyed on: the transform-feedback request - // (ResolveTransformFeedbackVaryings only READS the linked intermediates - it sets no - // XFB qualifier, and the ESSL capture rename happens in the backend, behind L2's own - // key), the fragment-output count limit (a link-failure gate, never an emission input), - // and reflection (verified non-mutating on this glslang pin; see the ordering note in - // RunBody). + // Deliberately NOT keyed on: nothing that only steers a BACKEND transpile - see the + // classification on CompileEnv::frontendFingerprint, and L2's own key in + // MG_Util/ShaderTranspiler/TranslationCache.h. MG_Util::ShaderTranspiler::TranslationCacheKey ProgramLinkTask::BuildSpirvCacheKey( const MG_Util::ShaderTranspiler::CompileEnv& env) const { using namespace MG_Util::ShaderTranspiler; @@ -617,12 +613,78 @@ namespace MobileGL::MG_State::GLState { keyInputs.explicitFragmentOutLocations = &in.explicitFragDataLocation; keyInputs.explicitFragmentOutIndices = &in.explicitFragDataIndex; keyInputs.explicitOpaqueUniformBindings = &artifacts.explicitOpaqueUniformBindings; + // In the key ONLY because the payload now carries the reflection: transform feedback + // is resolved by reading the linked intermediates and never perturbs the generated + // SPIR-V, but it does shape xfbVaryings / xfbStrides / xfbBufferMode / + // gsStripTriangles, and maxFragmentOutputColorNumber decides whether the link is + // rejected at all. Widening a payload means widening the key. + keyInputs.requestedXfbVaryings = &in.requestedXfbVaryings; + keyInputs.xfbBufferMode = static_cast(in.requestedXfbBufferMode); + keyInputs.maxFragmentOutputColorNumber = in.maxFragmentOutputColorNumber; return BuildSpirvTranslationKey(keyInputs); } - Bool ProgramLinkTask::ConsumeShaders(Vector>& outShaders) { - outShaders.assign(in.shaders.size(), nullptr); + // The link rejections that need nothing but the compile snapshots. They run before the + // L1 memo is consulted, so a hit can never paper over a program that must fail to link. + // The two lexical side channels the relaxed parse cannot provide, merged across stages: + // explicit default-block uniform locations (which must agree, or the link fails) and + // sampler/image layout(binding = N) initial units. Reads the COMPILE snapshots only, so + // it is legal - and necessary - before any shader is parsed: the merged bindings are part + // of the L1 memo key. + void ProgramLinkTask::MergeShaderSideChannels() { + // 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; + } + } + } + + // An L1 hit: the entire front end, published without constructing a TShader or a + // TProgram. Everything here is a copy out of plain owned data - `link.program` is null in + // the payload by construction, and nothing reads it any more. + Bool ProgramLinkTask::TryPublishFromTranslationCache() { + if (!spirvHandoff.spirvCacheKey.Valid()) return false; + const ProgramTranslationResultPtr hit = + GetProgramTranslationCache().Find(spirvHandoff.spirvCacheKey); + if (!hit) return false; + + artifacts = hit->link; + 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); + } + // An ALIASING SharedPtr: it points at the payload's SpirvArtifacts while sharing + // ownership of the whole payload, so phase B publishes them without a second copy and + // without any chance of the entry being evicted from under it. + spirvHandoff.cachedSpirv = + SharedPtr(hit, &hit->spirv); + spirvHandoff.ready = true; + MGLOG_D("ProgramObject %u: L1 cache hit - the whole front end was reused; no parse, no " + "link, no SPIR-V generation", + in.externalIndex); + return true; + } + + Bool ProgramLinkTask::ValidateAttachedShaders() { // GL 4.6 core 7.3: a compute shader may only be linked with other compute shaders - // the compute pipeline has no other stages to link against, so a program that mixes // them must fail to link (KHR-GL43.compute_shader.api-program). @@ -644,8 +706,6 @@ namespace MobileGL::MG_State::GLState { 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) { // The compile log LEADS the quoted source, and that order is load-bearing: @@ -664,6 +724,17 @@ namespace MobileGL::MG_State::GLState { in.externalIndex, i, artifacts.infoLog)); return false; } + } + return true; + } + + 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); + MGLOG_D("ProgramObject %u: Preparing shader[%zu] stage %s", in.externalIndex, i, + MG_Util::ConvertGLEnumToString(shaderType).c_str()); String reparseLog; outShaders[i] = input.compiled->ClaimParsedShader(reparseLog); if (!outShaders[i]) { diff --git a/MobileGL/MG_State/GLState/ProgramState/ProgramLinkTask.h b/MobileGL/MG_State/GLState/ProgramState/ProgramLinkTask.h index 96f27b8f..fe4e6bdc 100644 --- a/MobileGL/MG_State/GLState/ProgramState/ProgramLinkTask.h +++ b/MobileGL/MG_State/GLState/ProgramState/ProgramLinkTask.h @@ -130,6 +130,14 @@ namespace MobileGL::MG_State::GLState { // without preprocessed source - in which case phase B simply translates. MG_Util::ShaderTranspiler::TranslationCacheKey spirvCacheKey; + // Set on an L1 HIT: phase B publishes these SpirvArtifacts verbatim instead of + // generating anything. Null on a miss. + SharedPtr cachedSpirv; + // Set on a MISS: the LinkArtifacts phase B has to pair with its own SpirvArtifacts + // to insert the completed front end. Copied here rather than read off the node, + // because the GL-thread join MOVES `artifacts` out before phase B runs. + SharedPtr linkArtifactsForCache; + // 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; @@ -156,7 +164,20 @@ namespace MobileGL::MG_State::GLState { // ---- 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. + // The two link-rejection gates that need no parsed shader: a compute stage mixed + // with any other, and an attached shader that failed to compile. Split out of + // ConsumeShaders so they still run - in the same order, with the same diagnostics - + // BEFORE the L1 memo is consulted, rather than behind a hit that would skip them. + // The two lexical side channels the relaxed parse cannot provide, merged across + // stages. Reads the compile snapshots only, so it runs before any parse - the merged + // opaque bindings are part of the L1 memo key. Sets artifacts.infoLog and leaves + // linkStatus false when two stages disagree on an explicit uniform location. + void MergeShaderSideChannels(); + Bool ValidateAttachedShaders(); Bool ConsumeShaders(Vector>& outShaders); + // Publishes a whole front end straight out of the L1 memo: no TShader, no TProgram, + // no SPIR-V generation. Returns false on a miss. + Bool TryPublishFromTranslationCache(); // The L1 memo key for the SPIR-V this program is about to generate, or an invalid // key when the cache is off or a stage has no preprocessed source to key on. diff --git a/MobileGL/MG_State/GLState/ProgramState/ProgramSpirvTask.cpp b/MobileGL/MG_State/GLState/ProgramState/ProgramSpirvTask.cpp index 13b00fbb..d3499b91 100644 --- a/MobileGL/MG_State/GLState/ProgramState/ProgramSpirvTask.cpp +++ b/MobileGL/MG_State/GLState/ProgramState/ProgramSpirvTask.cpp @@ -12,6 +12,7 @@ #include #include #include +#include #include #include @@ -96,11 +97,25 @@ namespace MobileGL::MG_State::GLState { // and `diagnostics`, and this node is the sole reader of the handoff. ProgramLinkTask::SpirvHandoff& handoff = m_phaseA->spirvHandoff; const Uint externalIndex = m_phaseA->in.externalIndex; - if (!handoff.ready || !handoff.reflection.program) { + if (!handoff.ready) { // Phase A did not reach its tail (it failed the link, or was cancelled mid-body). // Publish nothing; spirvStatus stays false. return; } + // A TProgram is required only to GENERATE. A link served from the L1 memo has none by + // construction - that is the entire point of the widened payload - and its SPIR-V and + // routing tables arrive ready-made in cachedSpirv. + if (!handoff.cachedSpirv && !handoff.reflection.program) return; + + // An L1 hit already carries everything this phase would have produced. Publish it + // and stop: no GlslangToSpv, no spirv-opt, no routing pass. + if (handoff.cachedSpirv) { + artifacts = *handoff.cachedSpirv; + MGLOG_D("ProgramObject %u: L1 cache hit - %zu SPIR-V module(s) and the global-UBO " + "routing reused", + externalIndex, artifacts.generatedSpirv.size()); + return; + } MGLOG_D("ProgramObject %u: Starting SPIR-V generation", externalIndex); const Bool deferOutputValidationForDirectVulkan = @@ -138,6 +153,23 @@ namespace MobileGL::MG_State::GLState { MGLOG_D("ProgramObject %u: Building global-UBO routing tables", externalIndex); BuildGlobalUboRouting(handoff, externalIndex); + + // The completed front end goes into the L1 memo HERE, where both halves exist: phase + // A's LinkArtifacts (carried in the handoff) and this phase's SpirvArtifacts. + // + // Only a clean run is memoized. A failed optimizer run leaves a module as whatever the + // chain got to before it gave up, and that is exactly the binary no other program + // should ever be handed. + if (artifacts.spirvStatus && handoff.spirvCacheKey.Valid() && handoff.linkArtifactsForCache) { + auto payload = MakeShared(); + payload->link = *handoff.linkArtifactsForCache; + payload->link.program.reset(); // belt and braces: never memoize a glslang arena + payload->spirv = artifacts; + const SizeT payloadBytes = ProgramTranslationResultBytes(*payload); + GetProgramTranslationCache().Insert(handoff.spirvCacheKey, + ProgramTranslationResultPtr(Move(payload)), + payloadBytes); + } MGLOG_D("ProgramObject %u: Binary generation finished (generatedSpirv size=%zu)", externalIndex, artifacts.generatedSpirv.size()); } @@ -153,28 +185,6 @@ namespace MobileGL::MG_State::GLState { using namespace MG_Util::ShaderTranspiler; MGLOG_D("ProgramObject %u: GenerateSpirv - start", externalIndex); - // L1 of the shader translation memo. The segment this short-circuits is the whole - // of GlslangToSpv plus the 11-pass SanitizeAndOptimizeBinary chain, for every stage - // of the program at once - ~136 us per stage on the RelWithDebInfo host measurement. - // The key was built at the tail of phase A (ProgramLinkTask::BuildSpirvCacheKey) and - // covers every input that can move these bytes; see TranslationCache.h. - // - // Note what a HIT does NOT skip: the glslang parse and link, which already happened - // in phase A because the frontend's whole GL query surface is built out of the - // TProgram they produce. - auto& spirvCache = GetSpirvTranslationCache(); - const TranslationCacheKey& cacheKey = handoff.spirvCacheKey; - if (cacheKey.Valid()) { - if (const SpirvTranslationResultPtr hit = spirvCache.Find(cacheKey); - hit && hit->modules.size() == handoff.shaderTypes.size()) { - artifacts.generatedSpirv = hit->modules; - artifacts.spirvStatus = true; - MGLOG_D("ProgramObject %u: GenerateSpirv - L1 cache hit, %zu module(s) reused", - externalIndex, artifacts.generatedSpirv.size()); - return; - } - } - // 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 @@ -218,16 +228,6 @@ namespace MobileGL::MG_State::GLState { } } artifacts.spirvStatus = allOptimized; - - // Only a clean run is memoized. A failed optimizer run leaves `spv` as whatever the - // chain got to before it gave up, and that is exactly the binary no other program - // should ever be handed. - if (allOptimized && cacheKey.Valid()) { - auto payload = MakeShared(); - payload->modules = artifacts.generatedSpirv; - const SizeT payloadBytes = SpirvTranslationResultBytes(*payload); - spirvCache.Insert(cacheKey, SpirvTranslationResultPtr(Move(payload)), payloadBytes); - } } void ProgramSpirvTask::BuildGlobalUboRouting(const ProgramLinkTask::SpirvHandoff& handoff, diff --git a/MobileGL/MG_State/GLState/ProgramState/ProgramTranslationCache.cpp b/MobileGL/MG_State/GLState/ProgramState/ProgramTranslationCache.cpp new file mode 100644 index 00000000..5749993f --- /dev/null +++ b/MobileGL/MG_State/GLState/ProgramState/ProgramTranslationCache.cpp @@ -0,0 +1,82 @@ +// MobileGL - MobileGL/MG_State/GLState/ProgramState/ProgramTranslationCache.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 "ProgramTranslationCache.h" + +namespace MobileGL::MG_State::GLState { + namespace { + // ---- L1 caps: 48 entries / 24 MiB ---- + // + // Both numbers moved when the payload grew from "the SPIR-V modules" to "the whole + // front end". An entry is now the stages' preprocessed source (the key), the SPIR-V, + // the reflection snapshot and the global-UBO shadow - roughly twice what it was - so + // the byte budget doubled and the entry count came down to keep the worst case in the + // same place on a phone. + // + // The shape of the choice has not changed: this cache exists for REPETITION, not + // coverage. A KHR-GL33.texture_swizzle smoke case builds 2592 programs out of fewer + // than ten distinct ones, so a handful of entries serves it completely; an Iris + // shaderpack load is ~300-600 MOSTLY DISTINCT programs that would never hit however + // large the cache is, so a bigger cap there buys nothing and costs resident memory. + // 48 is comfortably above the distinct-program count of every repetition workload + // measured, and 24 MiB bounds the pathological case - a pack whose ~100 KB stages + // really are re-linked - at roughly three times the existing 8 MiB + // ShaderPreprocessCache budget, which is the other memo on this path. + constexpr SizeT kMaxEntries = 48; + constexpr SizeT kMaxBytes = 24u * 1024u * 1024u; + + SizeT StringsBytes(const Vector& values) { + SizeT bytes = 0; + for (const String& value : values) bytes += value.size() + sizeof(String); + return bytes; + } + + SizeT ResourcesBytes(const Vector& records) { + SizeT bytes = records.size() * sizeof(ProgramObject::ResourceReflection); + for (const auto& record : records) bytes += record.name.size(); + return bytes; + } + } // namespace + + // Approximate on purpose: it feeds a budget, not an allocator. It counts the things that + // actually scale with shader size - the SPIR-V, the reflection names, the UBO shadow - + // and ignores per-entry fixed overhead. + SizeT ProgramTranslationResultBytes(const ProgramTranslationResult& result) { + SizeT bytes = 0; + for (const auto& module : result.spirv.generatedSpirv) bytes += module.size() * sizeof(unsigned); + bytes += result.spirv.uniformOffsets.size() * sizeof(Uint); + bytes += result.spirv.globalUboScratch.size(); + bytes += ResourcesBytes(result.link.uniformReflection); + bytes += ResourcesBytes(result.link.blockReflection); + bytes += ResourcesBytes(result.link.pipeInputReflection); + bytes += ResourcesBytes(result.link.pipeOutputReflection); + bytes += StringsBytes(result.link.attribs); + bytes += StringsBytes(result.link.xfbInterfaceNames); + bytes += result.link.infoLog.size(); + return bytes; + } + + MG_Util::ShaderTranspiler::BoundedTranslationCache& + GetProgramTranslationCache() { + // DELIBERATELY LEAKED - see the same note on the L2 cache in + // MG_Util/ShaderTranspiler/TranslationCache.cpp. A function-local static OBJECT + // registers its destructor at first use, and first use here is a ShaderCompilePool + // worker; ShaderCompilePool's own atexit drain sentinel is registered strictly + // earlier, and exit handlers run in reverse order - so the cache would be destroyed + // while workers were still inserting into it. A function-local static POINTER is + // trivially destructible and registers no exit handler at all. + static auto* const kCache = + new MG_Util::ShaderTranspiler::BoundedTranslationCache( + "ShaderTranslationCache L1 (GLSL->front end)", kMaxEntries, kMaxBytes); + return *kCache; + } + + void ClearProgramTranslationCache() { GetProgramTranslationCache().Clear(); } + + void LogProgramTranslationCacheStats() { GetProgramTranslationCache().LogStats(); } +} // namespace MobileGL::MG_State::GLState diff --git a/MobileGL/MG_State/GLState/ProgramState/ProgramTranslationCache.h b/MobileGL/MG_State/GLState/ProgramState/ProgramTranslationCache.h new file mode 100644 index 00000000..544bd2c4 --- /dev/null +++ b/MobileGL/MG_State/GLState/ProgramState/ProgramTranslationCache.h @@ -0,0 +1,61 @@ +// MobileGL - MobileGL/MG_State/GLState/ProgramState/ProgramTranslationCache.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 { + // =================================================================================== + // L1 of the shader translation memo: THE WHOLE FRONT END of one glLinkProgram. + // + // A hit skips the glslang parse, the glslang link and mapIO, GlslangToSpv, the 11-pass + // SanitizeAndOptimizeBinary chain, buildReflection, and the global-UBO routing pass. No + // TShader and no TProgram is constructed at all - which is only possible because the GL + // query surface no longer reads one (see ProgramObject::UniformReflection and + // ProgramLinkTask::SnapshotGlslangReflection). + // + // WHY THE PAYLOAD IS THE WHOLE THING rather than just the SPIR-V: the frontend answers + // glGetActiveUniform, glGetProgramResource*, glGetUniformLocation and the rest out of + // LinkArtifacts, and glUniform*/glGetUniform* out of SpirvArtifacts. Caching only the + // modules would have left the parse and the link on the hot path to rebuild exactly the + // data the payload can carry - and the parse alone is ~48% of a CTS-shaped program build. + // + // WHY IT LIVES HERE AND NOT IN MG_Util: the payload is a ProgramObject::LinkArtifacts + // plus a ProgramObject::SpirvArtifacts, and MG_Util must not depend on MG_State. The + // KEY is plain bytes and stays in MG_Util (BuildSpirvTranslationKey), so both layers + // agree on exactly one definition of "the same front-end input". + // + // EVERYTHING IN THE PAYLOAD IS PLAIN OWNED DATA. `link.program` is null by construction: + // the whole point is that a hit never has a glslang arena to point into. Both structs + // were audited field by field - the only member that ever pointed into glslang-owned + // memory was `program` itself, and TUniformInitializer / XfbVarying, which look like + // glslang types, are std::string + std::vector aggregates. + struct ProgramTranslationResult { + // program == nullptr, always. Asserted at insert. + ProgramObject::LinkArtifacts link; + ProgramObject::SpirvArtifacts spirv; + }; + using ProgramTranslationResultPtr = SharedPtr; + + SizeT ProgramTranslationResultBytes(const ProgramTranslationResult& result); + + // Process-global, and safe to be: the FRONT-END environment fingerprint is in the key + // (see CompileEnv::frontendFingerprint), so a program built under one context's glslang + // limits can never be handed to a context with different ones - while two contexts on + // DIFFERENT GPUs that agree on those limits deliberately share entries. + // + // Global rather than per-context because the producer runs on a ShaderCompilePool worker + // and must not reach MG_State::pGLContext. + MG_Util::ShaderTranspiler::BoundedTranslationCache& + GetProgramTranslationCache(); + + void ClearProgramTranslationCache(); + void LogProgramTranslationCacheStats(); +} // namespace MobileGL::MG_State::GLState diff --git a/MobileGL/MG_Util/ShaderTranspiler/TranslationCache.cpp b/MobileGL/MG_Util/ShaderTranspiler/TranslationCache.cpp index 86154d4e..38161f46 100644 --- a/MobileGL/MG_Util/ShaderTranspiler/TranslationCache.cpp +++ b/MobileGL/MG_Util/ShaderTranspiler/TranslationCache.cpp @@ -34,22 +34,6 @@ namespace MobileGL::MG_Util::ShaderTranspiler { builder.Value(MG_Config::CacheVersion); } - // ---- L1 caps ------------------------------------------------------- - // 64 entries / 12 MiB. - // - // The win this cache exists for is REPETITION, not coverage: a CTS smoke - // case compiles a handful of distinct sources 2592 times, and a handful of - // entries serves it completely. The opposite workload - an Iris shaderpack - // load - is ~300-600 MOSTLY DISTINCT programs, which would never hit no - // matter how large the cache is, so a large cap there buys nothing and - // costs resident memory on a phone. 64 entries is comfortably above the - // distinct-source count of every repetition workload measured, and the - // 12 MiB ceiling bounds the pathological case (a pack whose ~100 KB stages - // ARE re-linked) at the same order as the existing 8 MiB - // ShaderPreprocessCache budget. - constexpr SizeT kSpirvCacheMaxEntries = 64; - constexpr SizeT kSpirvCacheMaxBytes = 12u * 1024u * 1024u; - // ---- L2 caps ------------------------------------------------------- // 128 entries / 12 MiB. Same reasoning, twice the entry count: L2 is keyed // per STAGE rather than per program, so the same program population needs @@ -84,6 +68,11 @@ namespace MobileGL::MG_Util::ShaderTranspiler { Bytes(words.data(), words.size() * sizeof(Uint32)); } + void TranslationKeyBuilder::TextList(const Vector& values) { + Value(static_cast(values.size())); + for (const String& value : values) Text(value); + } + void TranslationKeyBuilder::NameSet(const std::set& names) { Value(static_cast(names.size())); for (const String& name : names) Text(name); @@ -112,15 +101,13 @@ namespace MobileGL::MG_Util::ShaderTranspiler { builder.NameMap(inputs.explicitFragmentOutLocations ? *inputs.explicitFragmentOutLocations : kEmpty); builder.NameMap(inputs.explicitFragmentOutIndices ? *inputs.explicitFragmentOutIndices : kEmpty); builder.NameMap(inputs.explicitOpaqueUniformBindings ? *inputs.explicitOpaqueUniformBindings : kEmpty); + static const Vector kNoXfb; + builder.TextList(inputs.requestedXfbVaryings ? *inputs.requestedXfbVaryings : kNoXfb); + builder.Value(inputs.xfbBufferMode); + builder.Value(inputs.maxFragmentOutputColorNumber); return MakeTranslationCacheKey(builder); } - SizeT SpirvTranslationResultBytes(const SpirvTranslationResult& result) { - SizeT bytes = 0; - for (const auto& module : result.modules) bytes += module.size() * sizeof(Uint32); - return bytes; - } - TranslationCacheKey BuildEsslTranslationKey(const EsslTranslationKeyInputs& inputs) { TranslationKeyBuilder builder; AppendCommonKeyPrefix(builder, kEsslKeyTag); @@ -172,25 +159,13 @@ namespace MobileGL::MG_Util::ShaderTranspiler { // A function-local static POINTER is trivially destructible, so no exit handler is // registered for it at all. ClearShaderTranslationCaches() is what releases the memory // at a controlled point (eglTerminate), after the pool has been drained. - BoundedTranslationCache& GetSpirvTranslationCache() { - static auto* const kCache = new BoundedTranslationCache( - "ShaderTranslationCache L1 (GLSL->SPIR-V)", kSpirvCacheMaxEntries, kSpirvCacheMaxBytes); - return *kCache; - } - BoundedTranslationCache& GetEsslTranslationCache() { static auto* const kCache = new BoundedTranslationCache( "ShaderTranslationCache L2 (SPIR-V->ESSL)", kEsslCacheMaxEntries, kEsslCacheMaxBytes); return *kCache; } - void ClearShaderTranslationCaches() { - GetSpirvTranslationCache().Clear(); - GetEsslTranslationCache().Clear(); - } + void ClearShaderTranslationCaches() { GetEsslTranslationCache().Clear(); } - void LogShaderTranslationCacheStats() { - GetSpirvTranslationCache().LogStats(); - GetEsslTranslationCache().LogStats(); - } + void LogShaderTranslationCacheStats() { GetEsslTranslationCache().LogStats(); } } // namespace MobileGL::MG_Util::ShaderTranspiler diff --git a/MobileGL/MG_Util/ShaderTranspiler/TranslationCache.h b/MobileGL/MG_Util/ShaderTranspiler/TranslationCache.h index 8140ab82..4ebda910 100644 --- a/MobileGL/MG_Util/ShaderTranspiler/TranslationCache.h +++ b/MobileGL/MG_Util/ShaderTranspiler/TranslationCache.h @@ -34,13 +34,17 @@ namespace MobileGL::MG_Util::ShaderTranspiler { // bits, and folding them into one key would make every DirectGLES capability a // reason to miss on the frontend half as well. // - // WHAT IS DELIBERATELY NOT MEMOIZED: the glslang parse and the glslang link. - // Both produce a TShader/TProgram, and the frontend's whole GL query surface - // (ProgramObject::LinkArtifacts, BuildGlobalUboRouting) is built by asking that - // TProgram questions - so skipping them means caching a live glslang object - // graph and sharing it between ProgramObjects, which is a different change with - // its own aliasing and consume-once hazards. See the report in the branch - // history; the parse is ~50% of the per-stage cost and is the next campaign. + // AN L1 HIT SKIPS THE WHOLE FRONT END - the glslang parse, the link and mapIO, + // GlslangToSpv, spirv-opt, buildReflection and the global-UBO routing. No + // TShader and no TProgram is constructed. That is possible because the payload + // is the whole front-end OUTPUT (LinkArtifacts + SpirvArtifacts, both plain + // owned data) rather than the SPIR-V alone, and because the GL query surface no + // longer reads a live TProgram to answer anything - see + // ProgramObject::UniformReflection and ProgramLinkTask::SnapshotGlslangReflection. + // Caching the live glslang object graph instead would have been the other way to + // get here, and was rejected: TObjectReflection::type points into the TProgram's + // own pool allocator, so sharing one between ProgramObjects is an aliasing and + // consume-once hazard. // // CORRECTNESS RULE, non-negotiable. A wrong hit is a silently miscompiled // shader - far worse than a slow one. So: @@ -109,6 +113,9 @@ namespace MobileGL::MG_Util::ShaderTranspiler { // std::set is already ordered, but it gets the same length prefix. void NameSet(const std::set& names); + // ORDER-SENSITIVE, unlike NameMap: a transform-feedback capture list is a sequence, + // and gl_NextBuffer / gl_SkipComponentsN make its order load-bearing. + void TextList(const Vector& values); const String& Blob() const { return m_blob; } String Take() { return Move(m_blob); } @@ -351,13 +358,6 @@ namespace MobileGL::MG_Util::ShaderTranspiler { // vertex stage's outputs, so a stage's SPIR-V is NOT a function of that // stage's source alone. A per-stage key here would be exactly the silent // miscompile this cache must never produce. - struct SpirvTranslationResult { - // One module per stage, in the same order as ProgramLinkTask's - // spirvHandoff.shaderTypes. - Vector> modules; - }; - using SpirvTranslationResultPtr = SharedPtr; - struct SpirvTranslationKeyInputs { struct Stage { GLenum type = 0; @@ -374,17 +374,26 @@ namespace MobileGL::MG_Util::ShaderTranspiler { const UnorderedMap* explicitOpaqueUniformBindings = nullptr; Uint32 shaderCompileFlags = 0; Bool enableSpirvValidation = false; + // ---- inputs that only matter because the PAYLOAD now carries the reflection ---- + // When the payload was SPIR-V alone these were provably irrelevant: transform + // feedback is resolved by READING the linked intermediates and never writes an XFB + // qualifier, and the fragment-output limit is a link-failure gate, so neither can + // move a single word of the generated module. Both DO shape LinkArtifacts + // (xfbVaryings / xfbStrides / xfbBufferMode / gsStripTriangles, and whether the link + // is rejected at all), so widening the payload to the whole front end pulled them + // into the key. Widening a payload means widening the key. + const Vector* requestedXfbVaryings = nullptr; + Uint32 xfbBufferMode = 0; + Int32 maxFragmentOutputColorNumber = 0; }; TranslationCacheKey BuildSpirvTranslationKey(const SpirvTranslationKeyInputs& inputs); - SizeT SpirvTranslationResultBytes(const SpirvTranslationResult& result); - // Process-global, and safe to be: the CompileEnv fingerprint is in the key, so - // a module computed under one context's limits can never be handed to another - // context with different ones. Global rather than per-context because the - // producer (ProgramSpirvTask) runs on a pool worker and must not reach - // MG_State::pGLContext. - BoundedTranslationCache& GetSpirvTranslationCache(); + // The L1 PAYLOAD and its cache instance live in + // MG_State/GLState/ProgramState/ProgramTranslationCache.h, not here: the payload is a + // whole ProgramObject::LinkArtifacts + SpirvArtifacts, and MG_Util must not depend on + // MG_State. Only the key - which is plain bytes - is built here, so both layers agree on + // one definition of "the same front-end input". // ======================================================================= // L2 - the BACK END: sanitized SPIR-V -> DirectGLES ESSL payload.