mirror of
https://github.com/MobileGL-Dev/MobileGL
synced 2026-09-08 04:08:32 +09:00
[Perf] (ShaderTranspiler, ProgramState): memoize the glslang parse verdict so a repeated compile skips the parse
This commit is contained in:
+8
-7
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<Uint32>(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<ShaderParseVerdict>& GetShaderParseVerdictCache() {
|
||||
static auto* const kCache = new BoundedTranslationCache<ShaderParseVerdict>(
|
||||
"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
|
||||
|
||||
@@ -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 <stage> 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<const ShaderParseVerdict>;
|
||||
|
||||
// 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<ShaderParseVerdict>& GetShaderParseVerdictCache();
|
||||
|
||||
// =======================================================================
|
||||
// L2 - the BACK END: sanitized SPIR-V -> DirectGLES ESSL payload.
|
||||
// =======================================================================
|
||||
@@ -489,9 +612,10 @@ namespace MobileGL::MG_Util::ShaderTranspiler {
|
||||
|
||||
BoundedTranslationCache<EsslTranslationResult>& 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
|
||||
|
||||
Reference in New Issue
Block a user