[Perf] (ShaderTranspiler, ProgramState): memoize the glslang parse verdict so a repeated compile skips the parse

This commit is contained in:
2026-08-20 18:53:57 -04:00
parent a8228ca287
commit 5daf7bf093
8 changed files with 380 additions and 88 deletions
@@ -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
@@ -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
@@ -12,6 +12,7 @@
#include <MG_Util/Converters/MGToGL/ProgramEnumConverter.h>
#include <MG_Util/ShaderTranspiler/ShaderCompiler.h>
#include <MG_Util/ShaderTranspiler/ShaderSourceProcessor.h>
#include <MG_Util/ShaderTranspiler/TranslationCache.h>
#include <MG_Util/ShaderTranspiler/Types.h>
#include <glslang/Include/PoolAlloc.h>
@@ -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<glslang::TShader> 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<Int>(stage), static_cast<Int>(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<ShaderParseVerdict>();
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;
}
@@ -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<const MG_Util::ShaderTranspiler::CompileEnv> 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<glslang::TShader> 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<const String> 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