diff --git a/MobileGL/Config.h b/MobileGL/Config.h index 3feac0eb..1aebbe06 100644 --- a/MobileGL/Config.h +++ b/MobileGL/Config.h @@ -203,13 +203,14 @@ namespace MobileGL::MG_Config { // immediately stay serial by their own construction). Off by default; never // advertise it. QuirkOverride AsyncOptimisticShaderStatus = QuirkOverride::Auto; - // MOBILEGL_SHADER_CACHE: the two-level, in-memory shader translation memo - // (MG_Util/ShaderTranspiler/TranslationCache.h) - L1 memoizes a linked - // program's sanitized SPIR-V, L2 memoizes DirectGLES's emitted ESSL. Auto - // is ON; ForceOff turns BOTH levels off and makes every translation run - // from scratch. The escape hatch exists because a wrong cache hit is a - // silently miscompiled shader: if a device ever renders differently with - // the cache on, one run with this falsy says so. + // MOBILEGL_SHADER_CACHE: the three-level, in-memory shader translation memo + // (MG_Util/ShaderTranspiler/TranslationCache.h). The levels follow the GL + // entry points - L1c memoizes one glCompileShader's PARSE VERDICT, L1 a + // linked program's whole front end, L2 DirectGLES's emitted ESSL. Auto is + // ON; ForceOff turns ALL THREE off and makes every translation run from + // scratch. The escape hatch exists because a wrong cache hit is a silently + // miscompiled shader: if a device ever renders differently with the cache + // on, one run with this falsy says so. QuirkOverride ShaderTranslationCache = QuirkOverride::Auto; }; extern FeaturesTable Features; diff --git a/MobileGL/MG_State/GLState/ProgramState/ProgramSpirvTask.cpp b/MobileGL/MG_State/GLState/ProgramState/ProgramSpirvTask.cpp index d3499b91..4808947b 100644 --- a/MobileGL/MG_State/GLState/ProgramState/ProgramSpirvTask.cpp +++ b/MobileGL/MG_State/GLState/ProgramState/ProgramSpirvTask.cpp @@ -133,13 +133,18 @@ namespace MobileGL::MG_State::GLState { // * CAS-LOSER shaders (the re-parse in ShaderCompileTask::ClaimParsedShader, i.e. // the 2nd..Nth link of a shared shader): freed here in full. The handoff is their // ONLY owner. - // * CAS-WINNER shaders (the common case - one shader object linked into one - // program, which is every program of an Iris pack load): NOT freed here. The - // winner branch returns a COPY of ShaderCompileTask::artifacts.shader - // (ShaderCompileTask.cpp:320) and the node never releases its own reference, while - // phase A holds that node through in.shaders[i].compiled for its whole life - and - // phase A lives until PhaseAReleaser fires at the end of this body. So the - // refcount goes 2 -> 1 here and the arena dies where it would have died anyway. + // * L1c-HIT shaders (the compile published a verdict and never parsed, so the parse + // was made on demand by ClaimParsedShader): freed here in full, exactly like a + // CAS loser and for the same reason - the handoff is their only owner. This + // category did not exist before the translation memo's compile half, and it makes + // the clear below strictly more effective than the paragraph below describes. + // * CAS-WINNER shaders (one shader object linked into one program, whose compile + // MISSED L1c and therefore stored its parse): NOT freed here. The winner branch + // returns a COPY of ShaderCompileTask::artifacts.shader and the node never + // releases its own reference, while phase A holds that node through + // in.shaders[i].compiled for its whole life - and phase A lives until + // PhaseAReleaser fires at the end of this body. So the refcount goes 2 -> 1 here + // and the arena dies where it would have died anyway. // // Making it free the winner's arena too means releasing whatever pins the TShader // inside the compile node, and neither obvious route is safe as a drive-by: moving out diff --git a/MobileGL/MG_State/GLState/ProgramState/ProgramTranslationCache.h b/MobileGL/MG_State/GLState/ProgramState/ProgramTranslationCache.h index 544bd2c4..bbec5c3a 100644 --- a/MobileGL/MG_State/GLState/ProgramState/ProgramTranslationCache.h +++ b/MobileGL/MG_State/GLState/ProgramState/ProgramTranslationCache.h @@ -15,17 +15,25 @@ 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 + // A hit skips 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 + // TProgram is constructed at all - which is only possible because the GL query surface no + // longer reads one (see ProgramObject::UniformReflection and // ProgramLinkTask::SnapshotGlslangReflection). // + // IT DOES NOT SKIP THE PARSE, and no widening of this payload could: the parse belongs to + // glCompileShader, a different entry point one job earlier, and it has already run by the + // time a link looks this key up. Skipping it is L1c's job - the compile half of the memo, + // in MG_Util/ShaderTranspiler/TranslationCache.h. The two together are what make a + // repeated program build construct no glslang object of any kind; either one alone leaves + // roughly half the front end on the hot path (~322 us of parse against a ~650 us + // CTS-shaped program build, and 1.45-1.48x measured on device with L1 alone). + // // 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. + // modules would have left the link on the hot path to rebuild exactly the data the + // payload can carry. // // 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 diff --git a/MobileGL/MG_State/GLState/ProgramState/ShaderCompileTask.cpp b/MobileGL/MG_State/GLState/ProgramState/ShaderCompileTask.cpp index 41fdeb37..a421921c 100644 --- a/MobileGL/MG_State/GLState/ProgramState/ShaderCompileTask.cpp +++ b/MobileGL/MG_State/GLState/ProgramState/ShaderCompileTask.cpp @@ -12,6 +12,7 @@ #include #include #include +#include #include #include @@ -270,17 +271,80 @@ namespace MobileGL::MG_State::GLState { return; } - ShaderAttrib attrib{.shaderType = MG_Util::ConvertShaderStageToGLEnum(stage), - .sourceStr = shared.preprocessedSource, - .flags = 0, - .env = &compileEnv}; + const GLenum glShaderType = MG_Util::ConvertShaderStageToGLEnum(stage); + // Always 0 on both production parse paths; see the key inventory on + // ShaderParseVerdictKeyInputs for why it is in the key regardless. + constexpr Uint32 kShaderCompileFlags = 0; - auto result = ShaderCompiler::CompileShader(attrib); - if (result) { + // ---- L1c of the shader translation memo: the PARSE VERDICT ---------------------- + // Everything below this probe - the glslang parse itself - is what a hit skips. What + // a hit does NOT produce is a TShader, and that is deliberate rather than a + // limitation: the TShader is consume-once, so it could never have been shared, and + // nothing on the COMPILE side of GL reads it. GL_COMPILE_STATUS, the info log, + // GL_SHADER_SOURCE, attach/detach and reuse across programs are all answered from + // what the verdict and the source-only half already carry. + // + // The parse is not skipped, it is DEFERRED: ClaimParsedShader re-parses on demand + // when a link finds no stored parse. A link that hits L1 never asks, so the parse + // never happens at all; a link that misses pays exactly one parse, where the CAS + // loser has always paid it. See TranslationCache.h's L1c section. + const TranslationCacheKey parseKey = + ShaderTranslationCacheEnabled() + ? BuildShaderParseVerdictKey(ShaderParseVerdictKeyInputs{ + .frontendFingerprint = compileEnv.frontendFingerprint, + .shaderType = glShaderType, + .preprocessedSource = StringView(shared.preprocessedSource), + .shaderCompileFlags = kShaderCompileFlags}) + : TranslationCacheKey{}; + const ShaderParseVerdictPtr verdict = + parseKey.Valid() ? GetShaderParseVerdictCache().Find(parseKey) : nullptr; + + // The two branches produce exactly one thing between them - a verdict, plus a TShader + // only when this task actually parsed - and converge on one publish below. Keeping the + // publish common is what stops a hit and a miss from ever drifting on WHAT a compile + // makes observable. + Bool parsedOk = false; + String parseLog; + SharedPtr parsedShader; + + if (verdict) { + parsedOk = verdict->parsed; + parseLog = verdict->infoLog; + MGLOG_D("ShaderCompileTask: shader %u (stage %d) L1c hit - the glslang parse was skipped; " + "compileStatus = %d", + externalIndex, static_cast(stage), static_cast(parsedOk)); + } else { + const ShaderAttrib attrib{.shaderType = glShaderType, + .sourceStr = shared.preprocessedSource, + .flags = kShaderCompileFlags, + .env = &compileEnv}; + auto result = ShaderCompiler::CompileShader(attrib); + parsedOk = result.has_value(); + if (parsedOk) { + parsedShader = result.value(); + } else { + parseLog = result.error().log; + } + if (parseKey.Valid()) { + auto freshVerdict = MakeShared(); + freshVerdict->parsed = parsedOk; + // Empty on success by construction, matching what the publish below does with + // the artifacts' own log; the diagnostic the application reads on failure. + freshVerdict->infoLog = parseLog; + const SizeT verdictBytes = ShaderParseVerdictBytes(*freshVerdict); + GetShaderParseVerdictCache().Insert(parseKey, ShaderParseVerdictPtr(Move(freshVerdict)), + verdictBytes); + } + } + + if (parsedOk) { artifacts.compileStatus = true; - artifacts.shader = result.value(); + // NULL ON AN L1c HIT, and that is a supported state rather than an oversight: see + // ShaderCompileArtifacts::shader and ClaimParsedShader. + artifacts.shader = Move(parsedShader); // Copy, not move: `shared` may alias a cache entry that has to outlive us, and - // `fresh` is about to be handed to the cache. + // `fresh` is about to be handed to the cache. Populated on the hit path too - it + // is what ClaimParsedShader's deferred parse consumes. artifacts.preprocessedSource = shared.preprocessedSource; artifacts.explicitUniformLocations = shared.explicitUniformLocations; artifacts.explicitOpaqueBindings = shared.explicitOpaqueBindings; @@ -289,7 +353,7 @@ namespace MobileGL::MG_State::GLState { cache->Insert(stage, sourceHash, *source, compileEnv.fingerprint, Move(fresh)); } } else { - artifacts.infoLog = result.error().log; + artifacts.infoLog = Move(parseLog); // Deferred, not logged here, for two reasons. MGLOG from a pool thread interleaves // mid-line with the GL thread's own output and lands out of order relative to the // glCompileShader that caused it; diagnostics.logLines is replayed by the join, on @@ -332,10 +396,11 @@ namespace MobileGL::MG_State::GLState { } } - // Either another link already consumed the stored parse (and mapIO mutated its - // intermediate), or there never was one. Re-parse the preprocessed source through the - // identical configuration; that costs one glslang parse, which is what GenerateBinary - // used to spend here on EVERY link rather than only on reuse. + // Three ways to be here: another link already consumed the stored parse (and mapIO + // mutated its intermediate); the compile hit L1c and never parsed at all; or there + // simply never was one. All three want the same thing - parse the preprocessed source + // through the identical configuration. That costs one glslang parse, which is what + // GenerateBinary used to spend here on EVERY link rather than only when needed. // // The guard is not optional on this path: from stage 4 this runs on a pool worker, // and TShader::parse would leave that worker's TLS allocator pointing at a pool the @@ -351,7 +416,12 @@ namespace MobileGL::MG_State::GLState { .env = artifacts.env.get()}; auto result = ShaderCompiler::CompileShader(attrib); if (!result) { - // Should be unreachable: the same source parsed successfully at Compile(). + // Should be unreachable. This exact (stage, preprocessed source, front-end env) + // parsed successfully once - either at this node's own Compile(), or at the + // Compile() whose verdict L1c handed this node - and every input the parse reads + // is covered by that tuple. ConsumeShaders turns a null into a failed link with a + // named internal error rather than a crash, which is the right shape for a + // "cannot happen" that would otherwise be a silent miscompile. outReparseLog = result.error().log; return nullptr; } diff --git a/MobileGL/MG_State/GLState/ProgramState/ShaderCompileTask.h b/MobileGL/MG_State/GLState/ProgramState/ShaderCompileTask.h index 1d2353c5..ae641eb5 100644 --- a/MobileGL/MG_State/GLState/ProgramState/ShaderCompileTask.h +++ b/MobileGL/MG_State/GLState/ProgramState/ShaderCompileTask.h @@ -41,6 +41,20 @@ namespace MobileGL::MG_State::GLState { // re-parse in ClaimParsedShader() reproduces the original parse exactly, instead of // re-reading whatever the backend says now. SharedPtr env; + // The parse, WHEN THIS COMPILE ACTUALLY PARSED - and null otherwise, including when + // compileStatus is true. + // + // That combination is not a half-finished compile; it is an L1c hit. The translation + // memo's compile half (TranslationCache.h) knows this exact (stage, preprocessed + // source, front-end env) parses cleanly, so the verdict is published without running + // glslang. What a hit cannot hand over is the TShader itself: mapIO mutates its + // aliased intermediate at link, so a parse feeds exactly ONE link and could never + // have been shared between compiles. + // + // Nothing on the compile side of GL reads this - GL_COMPILE_STATUS, the info log, + // GL_SHADER_SOURCE, attach/detach and reuse across programs are all answered from the + // fields below. The one reader is ClaimParsedShader, which treats null as "parse it + // now", which is the same path the consume-once CAS loser has always taken. SharedPtr shader; // The source the parse actually consumed (after PreprocessShaderSource), kept for // ClaimParsedShader's re-parse so a later link never depends on the preprocessor @@ -53,8 +67,9 @@ namespace MobileGL::MG_State::GLState { }; // The unit of asynchronous shader compilation: one glCompileShader's worth of pure CPU - // work - preprocess, the two lexical rejections, the two lexical extractions, and the - // glslang parse - with every input it needs owned by the node itself. + // work - preprocess, the two lexical rejections, the two lexical extractions, and (unless + // the translation memo's compile half already knows the answer) the glslang parse - with + // every input it needs owned by the node itself. // // That ownership is the whole point. The node reads no GL-thread state (the source is a // SharedPtr snapshot, the device limits come from the CompileEnv snapshot, @@ -87,22 +102,29 @@ namespace MobileGL::MG_State::GLState { // ---- output: valid iff IsComplete(), immutable afterwards ---- ShaderCompileArtifacts artifacts; - // Hands out a link-consumable TShader, exactly once for the stored parse. + // Hands out a link-consumable TShader, parsing one on demand when this node has none. // - // glslang's mapIO mutates the TShader's aliased intermediate, so the parse this node - // produced may feed exactly ONE link; every later link (a relink, or the same shader - // attached to a second program) needs a fresh parse. The claim is a CAS on this - // shared node rather than a flag on the ShaderObject because from stage 4 the two - // callers can be two ProgramLinkTasks running on two workers: two programs sharing - // one shader, linked back to back. Copying the parse out and tracking consumed-ness - // per program would let both of them decide they were the first, run mapIO over the - // same intermediate twice, and ship silently corrupt SPIR-V. + // TWO WAYS TO GET HERE WITHOUT A STORED PARSE, and they share one implementation: + // * the CAS loser. glslang's mapIO mutates the TShader's aliased intermediate, so + // the parse this node produced may feed exactly ONE link; every later link (a + // relink, or the same shader attached to a second program) needs a fresh one. The + // claim is a CAS on this shared node rather than a flag on the ShaderObject + // because from stage 4 the two callers can be two ProgramLinkTasks on two + // workers: two programs sharing one shader, linked back to back. Copying the + // parse out and tracking consumed-ness per program would let both of them decide + // they were the first, run mapIO over the same intermediate twice, and ship + // silently corrupt SPIR-V. + // * an L1c HIT. The compile published a verdict without parsing at all (see + // ShaderCompileArtifacts::shader), so this call IS the parse - deferred out of + // glCompileShader to the first link that genuinely needs an AST. A link served + // from L1 never gets here, which is the whole point: that program's front end + // never constructs a glslang object of any kind. // - // The CAS loser re-parses artifacts.preprocessedSource against THIS node's own + // Either way the parse runs over artifacts.preprocessedSource against THIS node's own // CompileEnv (not against whatever the backend reports now), through the identical - // CompileShader path - so winner and loser produce byte-identical SPIR-V. Callable - // only once IsComplete() and compileStatus are true. Returns null only if that - // re-parse fails, and outReparseLog then carries its diagnostics. + // CompileShader path - so every claimant produces byte-identical SPIR-V. Callable + // only once IsComplete() and compileStatus are true. Returns null only if that parse + // fails, and outReparseLog then carries its diagnostics. // // Const because the claim is the node's own synchronization, not a mutation of its // published artifacts: a claim that is taken and then abandoned (its link was diff --git a/MobileGL/MG_Test/Program/ProgramTest.cpp b/MobileGL/MG_Test/Program/ProgramTest.cpp index faf5c84b..92862471 100644 --- a/MobileGL/MG_Test/Program/ProgramTest.cpp +++ b/MobileGL/MG_Test/Program/ProgramTest.cpp @@ -3010,21 +3010,38 @@ TEST_F(ProgramTest, TwoShaderObjectsWithIdenticalSourceLinkIndependently) { ASSERT_NE(objectA, nullptr); ASSERT_NE(objectB, nullptr); EXPECT_EQ(objectA->GetShaderSource(), objectB->GetShaderSource()); - // P0b's layer 2 shares the PREPROCESS and never the parse: glslang's TShader is - // consume-once, so a memo hit still has to parse for itself. + // WHAT THIS CASE IS ACTUALLY ABOUT: two GL shader names holding the same text must never + // end up feeding one TShader to two links, because mapIO mutates the aliased intermediate + // and the second link would get a corrupted one. There are now three mechanisms that keep + // that true, and which one is in play depends on the mode - so the assertion below is on + // the PARSES NOT BEING SHARED, never on where each object's parse came from: // - // P1 stage 6 shares something stronger when it is active - the whole compile JOB, and - // therefore the single parse that job produced - and that sharing is made safe by - // ShaderCompileTask::ClaimParsedShader's CAS instead, exactly as it already was for one - // shader object attached to two programs. ShaderCompileAdoptionTest is where that is - // pinned down (it links both objects and compares the generated SPIR-V). So the - // one-parse-per-object assertion belongs to the non-adopting path; the two independent - // LINKS below are what both modes have to agree on, and they are the point of this case. + // * P0b layer 2 shares the PREPROCESS and never the parse, so each object parses for + // itself. This was the only mechanism when the case was written. + // * P1 stage 6, when async is active, shares the whole compile JOB and therefore its + // single parse - made safe by ClaimParsedShader's CAS, exactly as it already was for + // one shader object attached to two programs. ShaderCompileAdoptionTest pins that + // down by linking both objects and comparing the generated SPIR-V. + // * The translation memo's compile half (L1c) recognises the second object's source and + // publishes its verdict WITHOUT parsing, so that object legitimately holds no TShader + // at all until a link asks ClaimParsedShader for one. Asserting a non-null parse here + // would be asserting that the parse had NOT been skipped - i.e. testing the absence + // of the optimisation rather than the invariant. + // + // So the pointer assertion applies only where the two objects are genuinely INDEPENDENT, + // i.e. where job adoption is not in play. What every mode has to agree on is the two + // independent LINKS below, and they are the real point of this case. if (!MG_Util::Async::AsyncShaderCompileActive()) { - EXPECT_NE(objectA->GetCompiledShader(), objectB->GetCompiledShader()); + const auto& shaderA = objectA->GetCompiledShader(); + const auto& shaderB = objectB->GetCompiledShader(); + // Either may legitimately hold NO parse: that is an L1c hit, where the AST is made on + // demand at link instead. So this asserts they are not the SAME non-null parse, and + // deliberately not that both have one - the latter would be asserting that the + // optimisation had not happened. + if (shaderA != nullptr && shaderB != nullptr) { + EXPECT_NE(shaderA, shaderB) << "two independent shader objects share one consume-once parse"; + } } - EXPECT_NE(objectA->GetCompiledShader(), nullptr); - EXPECT_NE(objectB->GetCompiledShader(), nullptr); GLuint programA = LinkVsFs(vsA, fsA, GL_TRUE); GLuint programB = LinkVsFs(vsB, fsB, GL_TRUE); diff --git a/MobileGL/MG_Util/ShaderTranspiler/TranslationCache.cpp b/MobileGL/MG_Util/ShaderTranspiler/TranslationCache.cpp index 2663afa9..dbc15fab 100644 --- a/MobileGL/MG_Util/ShaderTranspiler/TranslationCache.cpp +++ b/MobileGL/MG_Util/ShaderTranspiler/TranslationCache.cpp @@ -16,12 +16,16 @@ namespace MobileGL::MG_Util::ShaderTranspiler { // even if their inputs happened to serialize identically. constexpr Uint32 kSpirvKeyTag = 0x4d474c31u; // "MGL1" constexpr Uint32 kEsslKeyTag = 0x4d474c32u; // "MGL2" + constexpr Uint32 kParseVerdictKeyTag = 0x4d474c43u; // "MGLC" - L1c, the compile half // Bumped whenever the SHAPE of a key changes (a field added, a field's // meaning changed). It is in every blob, so a stale in-memory entry from a // previous shape cannot be honoured - and a future disk tier gets the same // protection for free. - constexpr Uint32 kKeyLayoutVersion = 1u; + // + // 2: L2 gained atomicCounterEsslBindingTop (wave3's atomic-counter block rebinding + // prints it into the emitted ESSL), and L1c was added. + constexpr Uint32 kKeyLayoutVersion = 2u; // The repo's existing cache epoch (MG_Config::CacheVersion, the seed // ProgramFactory::ComputeHash uses). Strictly redundant for an in-memory @@ -42,6 +46,21 @@ namespace MobileGL::MG_Util::ShaderTranspiler { // stages' SPIR-V out). constexpr SizeT kEsslCacheMaxEntries = 128; constexpr SizeT kEsslCacheMaxBytes = 12u * 1024u * 1024u; + + // ---- L1c caps ------------------------------------------------------ + // 256 entries / 8 MiB. Per-STAGE like L2, so twice L1's entry count again, and + // deliberately generous on count because an L1c entry's PAYLOAD is two words and a + // usually-empty string - all of an entry's weight is its key, i.e. the preprocessed + // source. 8 MiB is exactly ShaderPreprocessCache's budget, and for the same reason: + // these two store the same kind of thing (one copy of a shader's text) and neither + // should be the one that decides how much source a process keeps resident. + // + // Sized for REPETITION, like the other two. A CTS smoke case has fewer than ten + // distinct stages and fits many times over; an Iris pack load is hundreds of ~100 KB + // mostly-distinct stages that would not hit at any cap, so a bigger budget there buys + // nothing and costs resident memory on a phone. + constexpr SizeT kParseVerdictCacheMaxEntries = 256; + constexpr SizeT kParseVerdictCacheMaxBytes = 8u * 1024u * 1024u; } // namespace Bool ShaderTranslationCacheEnabled() { @@ -108,6 +127,26 @@ namespace MobileGL::MG_Util::ShaderTranspiler { return MakeTranslationCacheKey(builder); } + TranslationCacheKey BuildShaderParseVerdictKey(const ShaderParseVerdictKeyInputs& inputs) { + TranslationKeyBuilder builder; + AppendCommonKeyPrefix(builder, kParseVerdictKeyTag); + builder.Value(inputs.frontendFingerprint); + builder.Value(static_cast(inputs.shaderType)); + builder.Value(inputs.shaderCompileFlags); + builder.Text(inputs.preprocessedSource); + return MakeTranslationCacheKey(builder); + } + + SizeT ShaderParseVerdictBytes(const ShaderParseVerdict& verdict) { return verdict.infoLog.size(); } + + // Leaked for the same exit-order reason as the other two; see the note below. + BoundedTranslationCache& GetShaderParseVerdictCache() { + static auto* const kCache = new BoundedTranslationCache( + "ShaderTranslationCache L1c (GLSL->parse verdict)", kParseVerdictCacheMaxEntries, + kParseVerdictCacheMaxBytes); + return *kCache; + } + TranslationCacheKey BuildEsslTranslationKey(const EsslTranslationKeyInputs& inputs) { TranslationKeyBuilder builder; AppendCommonKeyPrefix(builder, kEsslKeyTag); @@ -167,7 +206,13 @@ namespace MobileGL::MG_Util::ShaderTranspiler { return *kCache; } - void ClearShaderTranslationCaches() { GetEsslTranslationCache().Clear(); } + void ClearShaderTranslationCaches() { + GetShaderParseVerdictCache().Clear(); + GetEsslTranslationCache().Clear(); + } - void LogShaderTranslationCacheStats() { GetEsslTranslationCache().LogStats(); } + void LogShaderTranslationCacheStats() { + GetShaderParseVerdictCache().LogStats(); + GetEsslTranslationCache().LogStats(); + } } // namespace MobileGL::MG_Util::ShaderTranspiler diff --git a/MobileGL/MG_Util/ShaderTranspiler/TranslationCache.h b/MobileGL/MG_Util/ShaderTranspiler/TranslationCache.h index 48b51e61..39a40f66 100644 --- a/MobileGL/MG_Util/ShaderTranspiler/TranslationCache.h +++ b/MobileGL/MG_Util/ShaderTranspiler/TranslationCache.h @@ -15,7 +15,7 @@ namespace MobileGL::MG_Util::ShaderTranspiler { // =========================================================================== - // The two-level shader translation memo. + // The three-level shader translation memo. // // MOTIVATION (measured). KHR-GL33.texture_swizzle.smoke_* builds 2592 programs // per case out of a handful of DISTINCT sources - the CTS template substitutes @@ -27,24 +27,41 @@ namespace MobileGL::MG_Util::ShaderTranspiler { // --[GlslangToSpv]--> SPIR-V --[SanitizeAndOptimizeBinary]--> SPIR-V' // --[backend SPIR-V pass chain]--> SPIR-V'' --[SPIRV-Cross]--> ESSL // - // L1 memoizes the segment from the parsed program to SPIR-V'; L2 memoizes the - // segment from SPIR-V' to the emitted backend payload. The two are kept apart - // on purpose: L1 is backend-agnostic (the same module feeds DirectGLES and - // DirectVulkan), while L2's key is made almost entirely of BACKEND capability - // bits, and folding them into one key would make every DirectGLES capability a - // reason to miss on the frontend half as well. + // THE LEVELS FOLLOW THE GL ENTRY POINTS, not the arrows above, and that is the + // key to reading this file: + // * L1c memoizes what one glCompileShader produces - the PARSE VERDICT; + // * L1 memoizes what one glLinkProgram produces - the whole front end from + // the link through SPIR-V'; + // * L2 memoizes the segment from SPIR-V' to the emitted backend payload. // - // 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. + // L1 could never have covered the parse, however wide its payload got, because + // the parse does not happen during glLinkProgram: it happens one entry point and + // one job earlier, and by the time a link consults L1 it has already been paid + // for. That is why the compile half is a separate level rather than a bigger + // payload - see the L1c section below for the measurement that forced it. + // + // L1c and L1 are both backend-agnostic (the same modules feed DirectGLES and + // DirectVulkan) and share one environment key, CompileEnv::frontendFingerprint. + // L2 is kept apart on purpose: its key is made almost entirely of BACKEND + // capability bits, and folding them in would make every DirectGLES capability a + // reason to miss on the front-end half as well. + // + // WITH BOTH FRONT-END LEVELS HIT, NO GLSLANG OBJECT IS CONSTRUCTED AT ALL - no + // TShader (L1c) and no TProgram (L1) - so the parse, the link and mapIO, + // GlslangToSpv, spirv-opt, buildReflection and the global-UBO routing are all + // skipped. On the L1 side 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. + // + // NEITHER LEVEL EVER CACHES A LIVE GLSLANG OBJECT GRAPH, and both had the option: + // TObjectReflection::type points into the TProgram's own pool allocator, so + // sharing a TProgram between ProgramObjects is an aliasing hazard, and mapIO + // mutates a TShader's aliased intermediate, so sharing a parse is a consume-once + // hazard. L1 sidesteps the first by storing the reflection as owned data; L1c + // sidesteps the second by storing only the VERDICT and letting the one link that + // actually needs an AST parse it on demand. // // CORRECTNESS RULE, non-negotiable. A wrong hit is a silently miscompiled // shader - far worse than a slow one. So: @@ -53,8 +70,8 @@ namespace MobileGL::MG_Util::ShaderTranspiler { // hash is a bucket selector only; a collision degrades to a miss. // * every input that can change the output is in the blob. Adding an input // to a translation step MEANS adding it to that level's key builder. - // * MOBILEGL_SHADER_CACHE=0 turns both levels off, so a field miscompile can - // be bisected against the cache in one run. + // * MOBILEGL_SHADER_CACHE=0 turns ALL THREE levels off, so a field miscompile + // can be bisected against the cache in one run. // // NO DISK TIER IN THIS CHANGE. Persistence needs its own invalidation story // (driver/vendor string, MobileGL build id, glslang and SPIRV-Cross revisions) @@ -66,7 +83,7 @@ namespace MobileGL::MG_Util::ShaderTranspiler { // the payloads are already plain data. // =========================================================================== - // The process-wide master switch, mirroring MOBILEGL_SHADER_CACHE. + // The process-wide master switch for ALL THREE levels, mirroring MOBILEGL_SHADER_CACHE. // QuirkOverride semantics: unset (Auto) is ON, an explicitly falsy value is // OFF. Read once from MG_Config::Features, so a worker never touches the // environment. @@ -314,7 +331,8 @@ namespace MobileGL::MG_Util::ShaderTranspiler { }; // ======================================================================= - // L1 - the FRONT END: parsed GLSL program -> sanitized SPIR-V modules. + // L1 - the LINK half of the front end: parsed GLSL program -> sanitized SPIR-V + // modules, plus the whole GL query surface. (The PARSE half is L1c, below.) // ======================================================================= // // The cached artifact is the module AFTER SanitizeAndOptimizeBinary, not the @@ -395,6 +413,111 @@ namespace MobileGL::MG_Util::ShaderTranspiler { // 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". + // ======================================================================= + // L1c - the COMPILE half of the front end: one glCompileShader's PARSE VERDICT. + // ======================================================================= + // + // WHY THIS EXISTS. L1 above memoizes one glLinkProgram. It skips the link, mapIO, + // GlslangToSpv, spirv-opt, buildReflection and the routing pass - but NOT the glslang + // parse, because the parse does not happen at glLinkProgram. It happens at + // glCompileShader, one job earlier, and by the time the link hits L1 the parse has + // already been paid for. Measured: the parse is ~322 us of a ~650 us CTS-shaped program + // build, and on a Mali Immortalis-G925 an L1-only build of + // KHR-GL33.texture_swizzle.smoke_access_idx_0_channel_idx_0 (2592 programs) ran 50.65 s + // against 75.16/72.68 s with the cache off - 1.45-1.48x, which is what "everything but + // the parse" buys. This level is the other half. + // + // WHAT IS MEMOIZED IS THE VERDICT, NOT THE PARSE. glCompileShader produces exactly three + // parse-derived things: GL_COMPILE_STATUS, the info log, and a glslang::TShader. The + // first two are a pure function of the key below. The third is CONSUME-ONCE - mapIO + // mutates its aliased intermediate at link - so it can be neither cached nor shared, and + // caching a live glslang object graph was rejected for L1 for exactly that reason. + // + // So a hit publishes the verdict and NO TShader at all, and the parse becomes LAZY: + // ShaderCompileTask::ClaimParsedShader already re-parses on demand when the node carries + // no stored parse, because stage 4 built that path for the CAS loser (one shader linked + // into a second program). A link that HITS L1 never calls it, so the parse never happens. + // A link that MISSES calls it and pays the parse there instead - the same single parse, + // moved, not duplicated. + // + // WHAT THIS DELIBERATELY IS NOT: an extension of ShaderCompileAdoptionMap. That map + // indexes LIVE compile nodes by WeakPtr, per context, so that a burst of shader objects + // handed byte-identical source shares one job. It structurally cannot serve this case: + // the CTS shape deletes its shader objects every iteration, so the node expires and the + // entry with it, and even a hit would hand over a parse whose single use the first link + // already consumed. Making it hold strong references would pin one glslang arena per + // distinct source for the life of the context - megabytes per shaderpack, and precisely + // the live-object-graph hazard this design avoids. + // + // BACKEND-AGNOSTIC, on the same contract as L1: the key carries + // CompileEnv::frontendFingerprint and never CompileEnv::fingerprint. + // + // IF THE KEY IS EVER WRONG, the two directions fail very differently, and it is worth + // knowing which one to fear: + // * a wrong `parsed = true` is CAUGHT. The stage holds no AST, so the first link that + // needs one re-parses - and that parse fails, ConsumeShaders reports "Internal error: + // re-parsing an attached for linking failed" and the link returns GL_FALSE. + // Wrong, loud, and named. + // * a wrong `parsed = false` is NOT caught. Nothing re-derives it, so a shader that + // would have compiled reports GL_COMPILE_STATUS false with a stale log. + // Neither is a silent MISCOMPILE - no wrong SPIR-V can be produced through this level, + // because it caches no translated output at all - but the second is the one that would + // reach an application as an inexplicable failure. Both are why the key carries the full + // source bytes and is compared in full. + struct ShaderParseVerdict { + // What ShaderCompileTask publishes as GL_COMPILE_STATUS. + Bool parsed = false; + // What it publishes as the info log. EMPTY whenever `parsed`, and that is a property + // of the pipeline rather than of glslang: RunCompilePipeline clears the log on a + // successful parse, so a successful compile's observable log is empty no matter what + // glslang wrote into it. Stored rather than assumed so the two cannot drift. + String infoLog; + }; + using ShaderParseVerdictPtr = SharedPtr; + + // WHAT IS IN THE KEY - the complete input set of ShaderCompiler::CompileShader, which is + // the only thing between this cache and the verdict: + // * frontendFingerprint - BuildTBuiltInResource is the one thing ParseShaderSource + // reads from the environment, and glslang both ENFORCES those limits at parse and + // expands several of them into built-in constants; + // * shaderType - it selects the EShLanguage parsed against, and it is also printed + // verbatim into the failure log this cache reproduces; + // * the FULL preprocessed source, byte for byte. This is the text ParseShaderSource is + // handed, and it also covers CompileShader's legacy-#version retry, which is a pure + // function of that text (RetargetLegacyVersionDirectiveTo460); + // * the ShaderCompileBits - CompileForOpenGL selects a different setEnvClient / + // setEnvTarget triple and skips setEnvInputVulkanRulesRelaxed, which changes both + // what parses and what the parse produces. Always 0 on both production paths; in the + // key so a future non-zero value cannot alias a parse made without it. + // + // WHAT IS DELIBERATELY OUT: + // * everything else ParseShaderSource touches, because all of it is a COMPILE-TIME + // CONSTANT: the 460/ECoreProfile default version, EShMsgDefault, forwardCompatible, + // the "#undef VULKAN" preamble, setNanMinMaxClamp/setInvertY/setAutoMapLocations/ + // setAutoMapBindings, and GLOBAL_UBO_NAME. A build that changes one of them is a + // different binary and cannot share an in-memory cache with the old one. + // * enableSpirvValidation. It is not an argument of CompileShader at all - the parse + // never reaches the SPIR-V validator. (L1 carries it because SanitizeAndOptimizeBinary + // does take it.) + // * backend identity and advertisedExtensions, on exactly L1's argument: the only + // front-end consumer of the extension list REWRITES THE SOURCE TEXT, and the + // preprocessed text is in this key verbatim - a strictly finer discriminator. + // * the ORIGINAL (pre-preprocess) source. The preprocessed text is what the parse + // consumes, so keying on the original would be both coarser in the wrong direction + // and redundant; ShaderPreprocessCache is the memo that keys on the original. + struct ShaderParseVerdictKeyInputs { + // CompileEnv::frontendFingerprint, NEVER CompileEnv::fingerprint. + Uint64 frontendFingerprint = 0; + GLenum shaderType = 0; + StringView preprocessedSource; + Uint32 shaderCompileFlags = 0; + }; + + TranslationCacheKey BuildShaderParseVerdictKey(const ShaderParseVerdictKeyInputs& inputs); + SizeT ShaderParseVerdictBytes(const ShaderParseVerdict& verdict); + + BoundedTranslationCache& GetShaderParseVerdictCache(); + // ======================================================================= // L2 - the BACK END: sanitized SPIR-V -> DirectGLES ESSL payload. // ======================================================================= @@ -489,9 +612,10 @@ namespace MobileGL::MG_Util::ShaderTranspiler { BoundedTranslationCache& GetEsslTranslationCache(); - // Drops both levels. Called from the same teardown that resets the glslang - // prewarm latch: nothing here holds a glslang object, so this is RSS hygiene - // rather than a correctness requirement. + // Drops L1c and L2 (L1 lives in MG_State and has its own + // ClearProgramTranslationCache). Called from the same teardown that resets the + // glslang prewarm latch: nothing here holds a glslang object, so this is RSS + // hygiene rather than a correctness requirement. void ClearShaderTranslationCaches(); // One MGLOG_D line per level. Called at teardown and cheap enough to call from