// MobileGL - MobileGL/MG_Test/ShaderTranspiler/TranslationCacheTest.cpp // Copyright (c) 2025-2026 MobileGL-Dev // Licensed under the GNU Lesser General Public License v3.0: // https://www.gnu.org/licenses/gpl-3.0.txt // https://www.gnu.org/licenses/lgpl-3.0.txt // SPDX-License-Identifier: LGPL-3.0-only // End of Source File Header // The two-level shader translation memo (MG_Util/ShaderTranspiler/TranslationCache.h). // // A wrong hit here is a silently miscompiled shader, so the cases below are weighted // heavily towards the KEY rather than towards the plumbing: for each level there is one // case per input that can change the output, asserting that moving that input alone moves // the key. That is the test that catches an under-specified key, which is the only way // this feature can produce a wrong answer. // // The rest covers the memo contract itself: FIFO eviction under both budgets, a hash // collision degrading to a miss rather than to a wrong payload, the MOBILEGL_SHADER_CACHE // escape hatch, and concurrent lookups/inserts over overlapping keys agreeing with the // single-threaded answer. #include #include #include #include #include #include #include #include "Config.h" #include "Includes.h" #include "Init.h" #include "MG_Impl/GLImpl/Program/GL_Program.h" #include "MG_State/GLState/Core.h" #include "MG_State/GLState/ProgramState/ProgramTranslationCache.h" #include "MG_Util/ShaderTranspiler/CompileEnv.h" #include "MG_Util/ShaderTranspiler/ShaderCompiler.h" #include "MG_Util/ShaderTranspiler/SpvcSession.h" #include "MG_Util/ShaderTranspiler/TranslationCache.h" #include "MG_Util/ShaderTranspiler/Types.h" using namespace MobileGL; using namespace MobileGL::MG_Util::ShaderTranspiler; namespace { // Restores MOBILEGL_SHADER_CACHE's field on the way out, the same shape // AsyncSpirvPhaseTest's AsyncModeScope uses for its own toggle. class CacheModeScope { public: explicit CacheModeScope(const Bool enabled) : m_saved(MG_Config::Features.ShaderTranslationCache) { MG_Config::Features.ShaderTranslationCache = enabled ? MG_Config::QuirkOverride::ForceOn : MG_Config::QuirkOverride::ForceOff; } ~CacheModeScope() { MG_Config::Features.ShaderTranslationCache = m_saved; } CacheModeScope(const CacheModeScope&) = delete; CacheModeScope& operator=(const CacheModeScope&) = delete; private: const MG_Config::QuirkOverride m_saved; }; // Synchronous links, so a case can read the L1 counters straight after LinkProgram // instead of having to join a phase-B job first. class SyncCompileScope { public: SyncCompileScope() : m_saved(MG_Config::Features.AsyncShaderCompile) { MG_Config::Features.AsyncShaderCompile = MG_Config::QuirkOverride::ForceOff; } ~SyncCompileScope() { MG_Config::Features.AsyncShaderCompile = m_saved; } SyncCompileScope(const SyncCompileScope&) = delete; SyncCompileScope& operator=(const SyncCompileScope&) = delete; private: const MG_Config::QuirkOverride m_saved; }; struct TestPayload { String text; }; TranslationCacheKey KeyFromText(const String& text) { TranslationKeyBuilder builder; builder.Text(text); return MakeTranslationCacheKey(builder); } SizeT PayloadBytes(const TestPayload& payload) { return payload.text.size(); } // ---- L1 fixtures ---- const char* kVertexSource = R"(#version 460 layout(location = 0) in vec3 aPos; out vec3 vPos; void main() { vPos = aPos; gl_Position = vec4(aPos, 1.0); } )"; const char* kFragmentSource = R"(#version 460 in vec3 vPos; layout(location = 0) out vec4 fragColor; uniform vec3 uTint; void main() { fragColor = vec4(uTint * vPos, 1.0); } )"; // Same shape as the KHR-GL33.texture_swizzle.smoke_* template: one substituted type, so // a case can build "the same shader again" and "a different shader" from one function. String SwizzleLikeFragment(const String& basicType) { return "#version 460\n" "in vec3 vPos;\n" "layout(location = 0) out " + basicType + "vec4 fragColor;\n" "uniform sampler2D uTex;\n" "void main() {\n" " vec4 s = texture(uTex, vPos.xy);\n" " fragColor = " + basicType + "vec4(s);\n" "}\n"; } SpirvTranslationKeyInputs BaselineSpirvInputs(const Vector& stages) { SpirvTranslationKeyInputs inputs; inputs.frontendFingerprint = 0x1234'5678'9abc'def0ull; inputs.stages = stages; inputs.shaderCompileFlags = 0; inputs.enableSpirvValidation = false; inputs.xfbBufferMode = GL_INTERLEAVED_ATTRIBS; inputs.maxFragmentOutputColorNumber = 8; return inputs; } EsslTranslationKeyInputs BaselineEsslInputs(const Vector& spirv) { EsslTranslationKeyInputs inputs; inputs.spirv = &spirv; inputs.shaderType = GL_FRAGMENT_SHADER; inputs.viewportIndexLoweringArmed = false; inputs.supportsNoperspectiveInterpolation = false; inputs.maxColorTextureSamples = 4; inputs.maxIntegerSamples = 1; inputs.maxDepthTextureSamples = 4; inputs.advertisedMaxSamples = 4; inputs.esslVersion = 320; inputs.enableSpirvValidation = false; return inputs; } Uint64 DigestOf(const Vector& words) { Uint64 hash = 1469598103934665603ull; for (const Uint32 word : words) hash = (hash ^ static_cast(word)) * 1099511628211ull; return hash; } Vector ProgramSpirvDigest(const GLuint program) { Vector digest; const auto& object = MG_State::pGLContext->GetProgramObject(program); if (!object) return digest; for (const auto& module : object->GetGeneratedSpirv()) digest.push_back(DigestOf(module)); return digest; } // Everything the GL query surface says about a linked program, as one string. Used to // assert that a program served from the L1 memo - which never built a TProgram - answers // identically to the one that was parsed. String ReflectionFingerprint(const GLuint program) { const auto& object = MG_State::pGLContext->GetProgramObject(program); if (!object) return String(); String out; const Uint uniformCount = object->GetUniformCount(); for (Uint i = 0; i < uniformCount; ++i) { out += object->GetActiveUniformName(i); out += ':' + std::to_string(object->GetActiveUniformType(i)); out += ':' + std::to_string(object->GetActiveUniformArraySize(i)); out += ':' + std::to_string(object->GetActiveUniformBlockIndex(i)); out += ':' + std::to_string(object->GetActiveUniformOffset(i)); out += ':' + std::to_string(object->GetActiveUniformArrayStride(i)); out += ':' + std::to_string(object->GetActiveUniformMatrixStride(i)); out += ':' + std::to_string(object->GetActiveUniformIsRowMajor(i)); out += '\n'; } const Int attribCount = object->GetActiveAttributesCount(); for (Int i = 0; i < attribCount; ++i) { out += object->GetActiveAttribName(static_cast(i)); out += ':' + std::to_string(object->GetActiveAttribType(static_cast(i))); out += ':' + std::to_string(object->GetActiveAttribArraySize(static_cast(i))); out += '\n'; } const Int outputCount = object->GetActiveFragmentOutputCount(); for (Int i = 0; i < outputCount; ++i) { out += object->GetActiveFragmentOutputName(static_cast(i)); out += ':' + std::to_string(object->GetFragmentOutputLocation(static_cast(i))); out += ':' + std::to_string(object->GetFragmentOutputType(static_cast(i))); out += '\n'; } return out; } GLuint MakeShader(const GLenum type, const String& source) { const GLuint shader = MG_Impl::GLImpl::CreateShader(type); const char* text = source.c_str(); MG_Impl::GLImpl::ShaderSource(shader, 1, &text, nullptr); MG_Impl::GLImpl::CompileShader(shader); return shader; } // One program per call, with FRESH shader objects every time - which is exactly the CTS // shape this cache exists for (2592 glCreateShader/glLinkProgram pairs over a handful of // distinct sources), and what makes the second link a genuine L1 lookup rather than a // reuse of an already-parsed object. GLuint LinkProgramFromSources(const String& vertexSource, const String& fragmentSource) { const GLuint vs = MakeShader(GL_VERTEX_SHADER, vertexSource); const GLuint fs = MakeShader(GL_FRAGMENT_SHADER, fragmentSource); const GLuint program = MG_Impl::GLImpl::CreateProgram(); MG_Impl::GLImpl::AttachShader(program, vs); MG_Impl::GLImpl::AttachShader(program, fs); MG_Impl::GLImpl::LinkProgram(program); return program; } // The real SPIRV-Cross emission, standing in for the DirectGLES member function the L2 // cache actually wraps. Same emitter, same options; what a unit test cannot reach is the // capability-gated SPIR-V pass chain around it, which needs a live ES driver to be // meaningful (and whose gates are covered exhaustively by the key cases instead). Bool EmitEssl(const Vector& spirv, const Uint version, String& outEssl) { SpvcSession session(spirv, SessionUsageBit::Transpile); spvc_compiler_options options; if (session.CreateOptions(&options) != SPVC_SUCCESS) return false; spvc_compiler_options_set_uint(options, SPVC_COMPILER_OPTION_GLSL_VERSION, version); spvc_compiler_options_set_bool(options, SPVC_COMPILER_OPTION_GLSL_ES, SPVC_TRUE); spvc_compiler_options_set_bool(options, SPVC_COMPILER_OPTION_GLSL_VULKAN_SEMANTICS, SPVC_FALSE); session.SetOptions(options); const char* result = nullptr; session.Compile(&result); if (!result) return false; outEssl = result; return true; } Vector BuildFragmentSpirv() { ShaderAttrib attrib{.shaderType = GL_FRAGMENT_SHADER, .sourceStr = kFragmentSource}; auto shader = ShaderCompiler::CompileShader(attrib); if (!shader) return {}; ProgramAttrib programAttrib{.shaders = {shader.value()}}; auto program = ShaderCompiler::LinkProgram(programAttrib); if (!program) return {}; ProgramBinaryAttrib binaryAttrib{.shaderTypes = {GL_FRAGMENT_SHADER}, .program = *program.value()}; auto binary = ShaderCompiler::GetSpirvBinaryFromProgram(binaryAttrib); if (!binary || binary->empty()) return {}; Vector sanitized; if (!ShaderCompiler::SanitizeAndOptimizeBinary(binary->front(), sanitized)) return {}; return sanitized; } class TranslationCacheTest : public ::testing::Test { protected: void SetUp() override { MobileGL::Initialize(); } }; } // namespace // ========================================================================================= // The memo contract: eviction, collisions, lifetime // ========================================================================================= TEST_F(TranslationCacheTest, HitReturnsTheStoredPayload) { BoundedTranslationCache cache("test", 8, 4096); const TranslationCacheKey key = KeyFromText("alpha"); EXPECT_EQ(cache.Find(key), nullptr); auto payload = MakeShared(TestPayload{"emitted"}); cache.Insert(key, SharedPtr(payload), PayloadBytes(*payload)); const auto hit = cache.Find(KeyFromText("alpha")); ASSERT_NE(hit, nullptr); EXPECT_EQ(hit->text, "emitted"); const TranslationCacheStats stats = cache.Stats(); EXPECT_EQ(stats.hits, 1u); EXPECT_EQ(stats.misses, 1u); EXPECT_EQ(stats.inserts, 1u); } // The single most important property in the whole file. A 64-bit hash is a bucket // selector; if it were ever trusted on its own, two different shaders sharing a hash // would swap payloads and one of them would be silently miscompiled. TEST_F(TranslationCacheTest, HashCollisionDegradesToMissNotToAWrongPayload) { BoundedTranslationCache cache("test", 8, 4096); TranslationCacheKey stored; stored.hash = 0xdead'beef'dead'beefull; stored.blob = MakeShared("the real key bytes"); TranslationCacheKey colliding; colliding.hash = stored.hash; // same bucket, deliberately colliding.blob = MakeShared("DIFFERENT key bytes"); cache.Insert(stored, MakeShared(TestPayload{"stored payload"}), 14); EXPECT_EQ(cache.Find(colliding), nullptr); ASSERT_NE(cache.Find(stored), nullptr); EXPECT_EQ(cache.Find(stored)->text, "stored payload"); } TEST_F(TranslationCacheTest, EvictionIsFifoUnderTheEntryCap) { BoundedTranslationCache cache("test", 2, 1u << 20); for (const char* name : {"a", "b", "c"}) { auto payload = MakeShared(TestPayload{name}); cache.Insert(KeyFromText(name), SharedPtr(payload), PayloadBytes(*payload)); } EXPECT_EQ(cache.EntryCount(), 2u); EXPECT_EQ(cache.Find(KeyFromText("a")), nullptr) << "the oldest entry should have been evicted"; ASSERT_NE(cache.Find(KeyFromText("b")), nullptr); ASSERT_NE(cache.Find(KeyFromText("c")), nullptr); EXPECT_EQ(cache.Stats().evictions, 1u); // And a re-insert after the eviction works, i.e. the index and the list stayed in step. auto revived = MakeShared(TestPayload{"a-again"}); cache.Insert(KeyFromText("a"), SharedPtr(revived), PayloadBytes(*revived)); const auto hit = cache.Find(KeyFromText("a")); ASSERT_NE(hit, nullptr); EXPECT_EQ(hit->text, "a-again"); } TEST_F(TranslationCacheTest, EvictionIsFifoUnderTheByteBudget) { // Room for two entries by bytes, but the entry cap is generous - so the byte budget is // the one that has to bind. const SizeT keyBytes = KeyFromText("aaaa").Bytes(); BoundedTranslationCache cache("test", 64, (keyBytes + 64) * 2); for (const char* name : {"aaaa", "bbbb", "cccc"}) { auto payload = MakeShared(TestPayload(String(64, name[0]))); cache.Insert(KeyFromText(name), SharedPtr(payload), PayloadBytes(*payload)); } EXPECT_EQ(cache.EntryCount(), 2u); EXPECT_EQ(cache.Find(KeyFromText("aaaa")), nullptr); EXPECT_NE(cache.Find(KeyFromText("cccc")), nullptr); EXPECT_LE(cache.StoredBytes(), (keyBytes + 64) * 2); } TEST_F(TranslationCacheTest, AnEntryLargerThanTheWholeBudgetIsNotCached) { BoundedTranslationCache cache("test", 64, 128); auto payload = MakeShared(TestPayload(String(4096, 'x'))); cache.Insert(KeyFromText("huge"), SharedPtr(payload), PayloadBytes(*payload)); EXPECT_EQ(cache.EntryCount(), 0u); EXPECT_EQ(cache.Stats().rejectedOversize, 1u); EXPECT_EQ(cache.Find(KeyFromText("huge")), nullptr); } // A hit hands out shared ownership, so a reader still holding a payload when the entry is // evicted keeps reading valid memory. This is what makes the memo safe once compiles run // on pool workers. TEST_F(TranslationCacheTest, APayloadOutlivesTheEvictionOfItsEntry) { BoundedTranslationCache cache("test", 1, 1u << 20); auto first = MakeShared(TestPayload{"first"}); cache.Insert(KeyFromText("first"), SharedPtr(first), PayloadBytes(*first)); const auto held = cache.Find(KeyFromText("first")); ASSERT_NE(held, nullptr); auto second = MakeShared(TestPayload{"second"}); cache.Insert(KeyFromText("second"), SharedPtr(second), PayloadBytes(*second)); EXPECT_EQ(cache.Find(KeyFromText("first")), nullptr); EXPECT_EQ(held->text, "first"); // still readable } // ska::flat_hash_map iterates in insertion/capacity order, so a key builder that walked a // map directly would produce different bytes for the same map depending on how it was // filled - a pure loss (spurious misses), and one that is invisible without this case. TEST_F(TranslationCacheTest, NameMapsSerializeCanonically) { UnorderedMap forward; forward.emplace("aPos", 0u); forward.emplace("aNormal", 1u); forward.emplace("aUv", 2u); UnorderedMap reverse; reverse.emplace("aUv", 2u); reverse.emplace("aNormal", 1u); reverse.emplace("aPos", 0u); TranslationKeyBuilder a; a.NameMap(forward); TranslationKeyBuilder b; b.NameMap(reverse); EXPECT_EQ(a.Blob(), b.Blob()); // ... and a value change still moves it. reverse["aPos"] = 7u; TranslationKeyBuilder c; c.NameMap(reverse); EXPECT_NE(a.Blob(), c.Blob()); } // Length-prefixing: "ab" + "c" must not serialize to the same bytes as "a" + "bc". TEST_F(TranslationCacheTest, TextAppendsCannotRunIntoEachOther) { TranslationKeyBuilder a; a.Text("ab"); a.Text("c"); TranslationKeyBuilder b; b.Text("a"); b.Text("bc"); EXPECT_NE(a.Blob(), b.Blob()); } TEST_F(TranslationCacheTest, L1AndL2KeysNeverAlias) { const Vector spirv{1u, 2u, 3u}; const TranslationCacheKey l2 = BuildEsslTranslationKey(BaselineEsslInputs(spirv)); const TranslationCacheKey l1 = BuildSpirvTranslationKey(BaselineSpirvInputs({{GL_FRAGMENT_SHADER, "source"}})); EXPECT_FALSE(l1 == l2); } // ========================================================================================= // L1 key composition - one case per input that can change the produced SPIR-V // ========================================================================================= TEST_F(TranslationCacheTest, L1KeyMovesWithEveryInputThatMovesTheSpirv) { const String vs = kVertexSource; const String fs = kFragmentSource; const Vector baseStages{ {GL_VERTEX_SHADER, vs}, {GL_FRAGMENT_SHADER, fs}}; const UnorderedMap attribs{{"aPos", 3u}}; const UnorderedMap fragOut{{"fragColor", 1u}}; const UnorderedMap fragIndex{{"fragColor", 1u}}; const UnorderedMap opaque{{"uTex", 5u}}; const Vector xfbVaryings{"vPos", "gl_NextBuffer", "vUv"}; const Vector xfbVaryingsReordered{"vUv", "gl_NextBuffer", "vPos"}; const SpirvTranslationKeyInputs base = BaselineSpirvInputs(baseStages); const TranslationCacheKey baseKey = BuildSpirvTranslationKey(base); // Identical inputs -> identical key. Everything below is measured against this. EXPECT_TRUE(BuildSpirvTranslationKey(BaselineSpirvInputs(baseStages)) == baseKey); Vector> variants; { // the environment fingerprint (glslang resource limits, backend identity, // advertised extension set, compute limits) SpirvTranslationKeyInputs v = base; v.frontendFingerprint ^= 1ull; variants.emplace_back("frontendFingerprint", BuildSpirvTranslationKey(v)); } { // a stage's source text const String otherFs = SwizzleLikeFragment("i"); SpirvTranslationKeyInputs v = BaselineSpirvInputs({{GL_VERTEX_SHADER, vs}, {GL_FRAGMENT_SHADER, otherFs}}); variants.emplace_back("stage source", BuildSpirvTranslationKey(v)); } { // a stage's TYPE, with the text unchanged SpirvTranslationKeyInputs v = BaselineSpirvInputs({{GL_VERTEX_SHADER, vs}, {GL_COMPUTE_SHADER, fs}}); variants.emplace_back("stage type", BuildSpirvTranslationKey(v)); } { // the SET of stages - mapIO resolves a fragment stage's Locations against the // vertex stage's outputs, which is why this key is per PROGRAM and not per stage SpirvTranslationKeyInputs v = BaselineSpirvInputs({{GL_FRAGMENT_SHADER, fs}}); variants.emplace_back("stage set", BuildSpirvTranslationKey(v)); } { // stage ORDER SpirvTranslationKeyInputs v = BaselineSpirvInputs({{GL_FRAGMENT_SHADER, fs}, {GL_VERTEX_SHADER, vs}}); variants.emplace_back("stage order", BuildSpirvTranslationKey(v)); } { // glBindAttribLocation SpirvTranslationKeyInputs v = base; v.explicitVertexInLocations = &attribs; variants.emplace_back("explicitVertexInLocations", BuildSpirvTranslationKey(v)); } { // glBindFragDataLocation SpirvTranslationKeyInputs v = base; v.explicitFragmentOutLocations = &fragOut; variants.emplace_back("explicitFragmentOutLocations", BuildSpirvTranslationKey(v)); } { // glBindFragDataLocationIndexed SpirvTranslationKeyInputs v = base; v.explicitFragmentOutIndices = &fragIndex; variants.emplace_back("explicitFragmentOutIndices", BuildSpirvTranslationKey(v)); } // NOT the merged layout(binding = N) opaque units, which used to be a variant here: that // map is an OUTPUT of mapIO (TMglGlslIoResolver writes it and never reads it), so it is a // pure function of the stage sources this key already carries in full. It was dropped from // SpirvTranslationKeyInputs with the glslang-capture migration; kKeyLayoutVersion moved to // 4 so no blob written under the old shape can be honoured. { // ShaderCompileBits (0 on both production parse paths; keyed so a future value // cannot alias a module parsed without it) SpirvTranslationKeyInputs v = base; v.shaderCompileFlags = 1u; variants.emplace_back("shaderCompileFlags", BuildSpirvTranslationKey(v)); } { // MOBILEGL_ENABLE_SPIRV_VALIDATION SpirvTranslationKeyInputs v = base; v.enableSpirvValidation = true; variants.emplace_back("enableSpirvValidation", BuildSpirvTranslationKey(v)); } { // CompileEnv::ConsumesFloat64Natively(): the fp64 tail of SanitizeAndOptimizeBinary is // skipped under it, so the SAME GLSL yields modules with real doubles under one answer // and demoted, storage-block-flattened ones under the other. The one backend capability // bit in this key, and the only one allowed in without changing what glslang produces. SpirvTranslationKeyInputs v = base; v.nativeFloat64 = true; variants.emplace_back("nativeFloat64", BuildSpirvTranslationKey(v)); } { // CompileEnv::DemotesTessellationPointSize(): phase B rewrites the cached modules // under it (the point-size demotion), so one key shape would describe two module // sets - built-in kept vs carried as a varying with the capability stripped. SpirvTranslationKeyInputs v = base; v.demoteTessellationPointSize = true; variants.emplace_back("demoteTessellationPointSize", BuildSpirvTranslationKey(v)); } { // ... and its geometry twin, keyed separately because the ES loader really does // probe the two extension families independently. SpirvTranslationKeyInputs v = base; v.demoteGeometryPointSize = true; variants.emplace_back("demoteGeometryPointSize", BuildSpirvTranslationKey(v)); } // ---- inputs the WIDENED payload pulled into the key ---- // They cannot move a word of the generated SPIR-V, but they do shape the reflection the // payload now carries, so they have to split the key. This is the group that would go // stale first if the payload ever grew again without the key following it. { // glTransformFeedbackVaryings: shapes xfbVaryings / xfbStrides / gsStripTriangles SpirvTranslationKeyInputs v = base; v.requestedXfbVaryings = &xfbVaryings; variants.emplace_back("requestedXfbVaryings", BuildSpirvTranslationKey(v)); } { // ... and its ORDER, which gl_NextBuffer / gl_SkipComponentsN make load-bearing SpirvTranslationKeyInputs v = base; v.requestedXfbVaryings = &xfbVaryingsReordered; variants.emplace_back("requestedXfbVaryings order", BuildSpirvTranslationKey(v)); } { // GL_INTERLEAVED_ATTRIBS vs GL_SEPARATE_ATTRIBS SpirvTranslationKeyInputs v = base; v.xfbBufferMode = GL_SEPARATE_ATTRIBS; variants.emplace_back("xfbBufferMode", BuildSpirvTranslationKey(v)); } { // GL_MAX_DRAW_BUFFERS: decides whether the link is REJECTED at all SpirvTranslationKeyInputs v = base; v.maxFragmentOutputColorNumber = 4; variants.emplace_back("maxFragmentOutputColorNumber", BuildSpirvTranslationKey(v)); } for (const auto& [name, key] : variants) { EXPECT_FALSE(key == baseKey) << "moving " << name << " did not move the L1 key"; } // Pairwise distinct too: two different inputs must not collapse onto one key. for (SizeT i = 0; i < variants.size(); ++i) { for (SizeT j = i + 1; j < variants.size(); ++j) { EXPECT_FALSE(variants[i].second == variants[j].second) << variants[i].first << " and " << variants[j].first << " produce the same L1 key"; } } } // ========================================================================================= // L1 backend-agnosticism: the environment inputs that were REMOVED from the key // ========================================================================================= namespace { // Two environments that differ in every way that only steers a BACKEND, and in no way // that reaches glslang. Pair BackendOnlyDifferentEnvs() { CompileEnv a; CompileEnv b; // (1) backend identity - both HAVE a backend, they are just different ones a.backend = BackendType::DirectGLES; b.backend = BackendType::DirectVulkan; // (2) the advertised extension vector, including the fp64 flag's own extension a.advertisedExtensions = {E_GL_ARB_gpu_shader_fp64, E_GL_KHR_debug}; b.advertisedExtensions = {}; // (3) the compute INVOCATION limit, and deliberately not the work-group size or // count any more. Those two used to sit here on the grounds that // ValidateComputeLocalSizeLimits was their only consumer; wave3 (cb155c5b) made // BuildTBuiltInResource read them, and glslang expands both into built-in constants // (gl_MaxComputeWorkGroupSize / gl_MaxComputeWorkGroupCount), so they are now // front-end inputs and belong in TheFrontendFingerprintMovesWithEveryFrontendLimit // instead - which is where they moved. The invocation limit is the one that really // still stops at the pre-parse gate: glslang has no built-in constant for it and // BuildTBuiltInResource does not read it. a.maxComputeWorkGroupInvocations = 128; b.maxComputeWorkGroupInvocations = 2048; // (4) a spread of DynamicBackendParameters fields the front end never reads. // MaxTextureImageUnits used to be here and is NOT any more: the GL 4.6 API-surface wave // made BuildTBuiltInResource read it (gl_MaxTextureImageUnits expands from it), so it // moved to TheFrontendFingerprintMovesWithEveryFrontendLimit. That migration is the // third one this helper has survived; check BuildTBuiltInResource before adding a field // here. a.params.MaxColorTextureSamples = 1; b.params.MaxColorTextureSamples = 8; a.params.MaxTextureSize = 4096; b.params.MaxTextureSize = 16384; a.params.MaxViewports = 1; b.params.MaxViewports = 16; a.params.MaxUniformBufferBindings = 24; b.params.MaxUniformBufferBindings = 84; a.params.MaxRenderbufferSize = 4096; b.params.MaxRenderbufferSize = 16384; return {a, b}; } } // namespace // THE case that pins L1's backend-agnosticism. Everything moved here is something that // only steers a backend transpile, and L2 already keys on the ones that matter there. // The old whole-environment fingerprint moves; the front-end one must not. TEST_F(TranslationCacheTest, TheFrontendFingerprintIgnoresBackendOnlyDifferences) { const auto [a, b] = BackendOnlyDifferentEnvs(); EXPECT_NE(ComputeCompileEnvFingerprint(a), ComputeCompileEnvFingerprint(b)) << "the whole-environment fingerprint is supposed to notice these; if it does not, " "this case is no longer testing anything"; EXPECT_EQ(ComputeFrontendCompileEnvFingerprint(a), ComputeFrontendCompileEnvFingerprint(b)) << "a backend-only difference leaked into the front-end fingerprint"; } // ... and the same thing one level up: the two environments must produce ONE L1 entry. TEST_F(TranslationCacheTest, TwoBackendsCompilingTheSameGlslShareOneL1Entry) { const auto [a, b] = BackendOnlyDifferentEnvs(); const String vs = kVertexSource; const String fs = kFragmentSource; const Vector stages{{GL_VERTEX_SHADER, vs}, {GL_FRAGMENT_SHADER, fs}}; SpirvTranslationKeyInputs onA = BaselineSpirvInputs(stages); onA.frontendFingerprint = ComputeFrontendCompileEnvFingerprint(a); SpirvTranslationKeyInputs onB = BaselineSpirvInputs(stages); onB.frontendFingerprint = ComputeFrontendCompileEnvFingerprint(b); EXPECT_TRUE(BuildSpirvTranslationKey(onA) == BuildSpirvTranslationKey(onB)); } // The ONE capability bit that breaks that sharing, and the two halves of why it is placed where // it is. It must NOT move the front-end fingerprint - glslang parses, reflects and generates a // `double` identically under it, and L1c (the parse-verdict memo) keys on that same fingerprint // and would take a false miss per backend for nothing. It MUST move the L1 key, because L1's // payload is the module AFTER SanitizeAndOptimizeBinary and the fp64 tail of that chain is // exactly what this bit gates. TEST_F(TranslationCacheTest, NativeFloat64IsOutOfTheFrontendFingerprintAndInsideTheL1Key) { CompileEnv none; // no backend at all CompileEnv emulated; // a backend without the feature CompileEnv nativeEnv; // a backend with it emulated.backend = BackendType::DirectVulkan; nativeEnv.backend = BackendType::DirectVulkan; nativeEnv.params.SupportsShaderFloat64 = true; // No backend answers FALSE: the demoted module is the one that works everywhere, so a // standalone compile gets it. EXPECT_FALSE(none.ConsumesFloat64Natively()); EXPECT_FALSE(emulated.ConsumesFloat64Natively()); EXPECT_TRUE(nativeEnv.ConsumesFloat64Natively()); EXPECT_EQ(ComputeFrontendCompileEnvFingerprint(emulated), ComputeFrontendCompileEnvFingerprint(nativeEnv)) << "the fp64 capability leaked into the front-end fingerprint"; EXPECT_NE(ComputeCompileEnvFingerprint(emulated), ComputeCompileEnvFingerprint(nativeEnv)) << "the whole-environment fingerprint has to notice it - it is a DynamicBackendParameters " "field, hashed by object representation"; const Vector stages{{GL_VERTEX_SHADER, kVertexSource}, {GL_FRAGMENT_SHADER, kFragmentSource}}; SpirvTranslationKeyInputs demoted = BaselineSpirvInputs(stages); demoted.frontendFingerprint = ComputeFrontendCompileEnvFingerprint(emulated); demoted.nativeFloat64 = emulated.ConsumesFloat64Natively(); SpirvTranslationKeyInputs kept = BaselineSpirvInputs(stages); kept.frontendFingerprint = ComputeFrontendCompileEnvFingerprint(nativeEnv); kept.nativeFloat64 = nativeEnv.ConsumesFloat64Natively(); EXPECT_FALSE(BuildSpirvTranslationKey(demoted) == BuildSpirvTranslationKey(kept)) << "one L1 entry would then describe two different module sets"; } // The second and third capability bits under the same placement rule as nativeFloat64: // out of the front-end fingerprint (glslang produces the same thing either way), inside // the L1 key (phase B's point-size demotion rewrites the cached modules under them). The // accessor direction is pinned too, because it is INVERTED relative to the params field // and a swap of the arms would disable the device repair with every rendering test green. TEST_F(TranslationCacheTest, PointSizeDemotionBitsAreOutOfTheFrontendFingerprintAndInsideTheL1Key) { CompileEnv none; // no backend at all: never demote, standalone compiles stay standard CompileEnv hosting; // a backend that hosts the built-in CompileEnv demoting; // a backend that cannot hosting.backend = BackendType::DirectVulkan; demoting.backend = BackendType::DirectVulkan; demoting.params.SupportsTessellationPointSize = false; demoting.params.SupportsGeometryPointSize = false; EXPECT_FALSE(none.DemotesTessellationPointSize()); EXPECT_FALSE(none.DemotesGeometryPointSize()); EXPECT_FALSE(hosting.DemotesTessellationPointSize()); EXPECT_FALSE(hosting.DemotesGeometryPointSize()); EXPECT_TRUE(demoting.DemotesTessellationPointSize()); EXPECT_TRUE(demoting.DemotesGeometryPointSize()); EXPECT_EQ(ComputeFrontendCompileEnvFingerprint(hosting), ComputeFrontendCompileEnvFingerprint(demoting)) << "the point-size capability leaked into the front-end fingerprint"; EXPECT_NE(ComputeCompileEnvFingerprint(hosting), ComputeCompileEnvFingerprint(demoting)) << "the whole-environment fingerprint has to notice it - it is a DynamicBackendParameters " "field, hashed by object representation"; const Vector stages{{GL_VERTEX_SHADER, kVertexSource}, {GL_FRAGMENT_SHADER, kFragmentSource}}; SpirvTranslationKeyInputs demotedKey = BaselineSpirvInputs(stages); demotedKey.frontendFingerprint = ComputeFrontendCompileEnvFingerprint(demoting); demotedKey.demoteTessellationPointSize = demoting.DemotesTessellationPointSize(); demotedKey.demoteGeometryPointSize = demoting.DemotesGeometryPointSize(); SpirvTranslationKeyInputs keptKey = BaselineSpirvInputs(stages); keptKey.frontendFingerprint = ComputeFrontendCompileEnvFingerprint(hosting); keptKey.demoteTessellationPointSize = hosting.DemotesTessellationPointSize(); keptKey.demoteGeometryPointSize = hosting.DemotesGeometryPointSize(); EXPECT_FALSE(BuildSpirvTranslationKey(demotedKey) == BuildSpirvTranslationKey(keptKey)) << "one L1 entry would then describe two different module sets"; } // The other direction, one case per input that was KEPT. Each is a limit the front end // really consumes - everything BuildTBuiltInResource copies into TBuiltInResource, plus the // two inputs to the reflection vertex-attrib limit - so each must still split the key. // KEEP THIS LIST IN STEP WITH BuildTBuiltInResource: a limit that becomes env-derived there // and is not added here is a silent miscompile with no failing test to catch it, which is // precisely how the compute work-group cases below arrived. TEST_F(TranslationCacheTest, TheFrontendFingerprintMovesWithEveryFrontendLimit) { const CompileEnv base; const Uint64 baseline = ComputeFrontendCompileEnvFingerprint(base); const Vector>> mutations{ {"params.MaxImageUnits", [](CompileEnv& e) { e.params.MaxImageUnits += 1; }}, {"params.MaxDrawBuffers", [](CompileEnv& e) { e.params.MaxDrawBuffers += 1; }}, {"params.MaxVertexImageUniforms", [](CompileEnv& e) { e.params.MaxVertexImageUniforms += 1; }}, {"params.MaxGeometryImageUniforms", [](CompileEnv& e) { e.params.MaxGeometryImageUniforms += 1; }}, {"params.MaxFragmentImageUniforms", [](CompileEnv& e) { e.params.MaxFragmentImageUniforms += 1; }}, {"params.MaxComputeImageUniforms", [](CompileEnv& e) { e.params.MaxComputeImageUniforms += 1; }}, {"params.MaxCombinedImageUniforms", [](CompileEnv& e) { e.params.MaxCombinedImageUniforms += 1; }}, {"params.MaxVertexAttribs", [](CompileEnv& e) { e.params.MaxVertexAttribs += 1; }}, // Env-derived since wave3's cb155c5b: BuildTBuiltInResource copies all seven of // these into TBuiltInResource, and glslang expands each into a built-in constant a // compute shader can read (gl_MaxComputeTextureImageUnits, // gl_MaxComputeWorkGroupSize, gl_MaxComputeWorkGroupCount). A module that reads one // compiles to different SPIR-V under two different values, so each must split the // key - one case per COMPONENT, because a per-axis difference is exactly the shape // real drivers produce (z = 64 on ES against 1024 elsewhere). {"params.MaxComputeTextureImageUnits", [](CompileEnv& e) { e.params.MaxComputeTextureImageUnits += 1; }}, // wave4's 4fc3531d: glslang rejects gl_ClipDistance[i] past this at parse AND expands // gl_MaxClipDistances from it, so it is both a compile gate and a baked constant. {"params.MaxClipDistances", [](CompileEnv& e) { e.params.MaxClipDistances += 1; }}, // The GL 4.6 API-surface wave: six more TBuiltInResource fields that used to be stock // glslang literals. The cull pair is the MaxClipDistances story exactly (parse gate plus // gl_MaxCullDistances / gl_MaxCombinedClipAndCullDistances); the texture-image-unit three // and MaxSamples are baked constants (gl_MaxTextureImageUnits, // gl_MaxVertexTextureImageUnits, gl_MaxCombinedTextureImageUnits, gl_MaxSamples - the // last of which also sizes gl_SampleMask[]). {"params.MaxCullDistances", [](CompileEnv& e) { e.params.MaxCullDistances += 1; }}, {"params.MaxCombinedClipAndCullDistances", [](CompileEnv& e) { e.params.MaxCombinedClipAndCullDistances += 1; }}, {"params.MaxTextureImageUnits", [](CompileEnv& e) { e.params.MaxTextureImageUnits += 1; }}, {"params.MaxVertexTextureImageUnits", [](CompileEnv& e) { e.params.MaxVertexTextureImageUnits += 1; }}, {"params.MaxCombinedTextureImageUnits", [](CompileEnv& e) { e.params.MaxCombinedTextureImageUnits += 1; }}, {"params.MaxSamples", [](CompileEnv& e) { e.params.MaxSamples += 1; }}, {"maxComputeWorkGroupSize[0]", [](CompileEnv& e) { e.maxComputeWorkGroupSize[0] += 1; }}, {"maxComputeWorkGroupSize[1]", [](CompileEnv& e) { e.maxComputeWorkGroupSize[1] += 1; }}, {"maxComputeWorkGroupSize[2]", [](CompileEnv& e) { e.maxComputeWorkGroupSize[2] += 1; }}, {"maxComputeWorkGroupCount[0]", [](CompileEnv& e) { e.maxComputeWorkGroupCount[0] += 1; }}, {"maxComputeWorkGroupCount[1]", [](CompileEnv& e) { e.maxComputeWorkGroupCount[1] += 1; }}, {"maxComputeWorkGroupCount[2]", [](CompileEnv& e) { e.maxComputeWorkGroupCount[2] += 1; }}, // HasBackend(): with no backend the reflection attrib limit falls back to the // storage capacity rather than the driver's number, so the bit is load-bearing. {"HasBackend", [](CompileEnv& e) { e.backend = BackendType::DirectGLES; }}, }; Vector seen{baseline}; for (const auto& [name, mutate] : mutations) { CompileEnv env = base; mutate(env); const Uint64 moved = ComputeFrontendCompileEnvFingerprint(env); EXPECT_NE(moved, baseline) << "moving " << name << " did not move the front-end fingerprint"; for (const Uint64 previous : seen) { EXPECT_NE(moved, previous) << name << " collides with an earlier front-end limit"; } seen.push_back(moved); } } // ========================================================================================= // L1 end to end, through the real GL entry points // ========================================================================================= // The headline case: the second program with byte-identical sources reuses the first // program's modules instead of running GlslangToSpv and the 11-pass sanitize chain again, // and the modules it gets are the same bytes. TEST_F(TranslationCacheTest, L1MemoizesASecondProgramWithIdenticalSources) { const SyncCompileScope sync; const CacheModeScope cacheOn(true); const String fs = SwizzleLikeFragment(""); const TranslationCacheStats before = MG_State::GLState::GetProgramTranslationCache().Stats(); const GLuint first = LinkProgramFromSources(kVertexSource, fs); const TranslationCacheStats afterFirst = MG_State::GLState::GetProgramTranslationCache().Stats(); const GLuint second = LinkProgramFromSources(kVertexSource, fs); const TranslationCacheStats afterSecond = MG_State::GLState::GetProgramTranslationCache().Stats(); GLint firstStatus = GL_FALSE; GLint secondStatus = GL_FALSE; MG_Impl::GLImpl::GetProgramiv(first, GL_LINK_STATUS, &firstStatus); MG_Impl::GLImpl::GetProgramiv(second, GL_LINK_STATUS, &secondStatus); ASSERT_EQ(firstStatus, GL_TRUE); ASSERT_EQ(secondStatus, GL_TRUE); EXPECT_EQ(afterFirst.misses - before.misses, 1u) << "the first link must be a miss"; EXPECT_EQ(afterFirst.hits - before.hits, 0u); EXPECT_EQ(afterSecond.hits - afterFirst.hits, 1u) << "the second link must be a hit"; EXPECT_EQ(afterSecond.misses - afterFirst.misses, 0u); const Vector firstDigest = ProgramSpirvDigest(first); const Vector secondDigest = ProgramSpirvDigest(second); ASSERT_EQ(firstDigest.size(), 2u); EXPECT_EQ(firstDigest, secondDigest); // The payload is the WHOLE front end, so the reflection has to survive it too - the // program served from the memo never built a TProgram to answer these from. EXPECT_EQ(ReflectionFingerprint(first), ReflectionFingerprint(second)); EXPECT_FALSE(ReflectionFingerprint(second).empty()); } // A hit publishes a program that never had a glslang::TProgram at all. Everything GL can ask // about it has to come out of the payload; if any accessor still needed the parse this would // answer differently from the program that was parsed. TEST_F(TranslationCacheTest, AProgramServedFromTheMemoAnswersTheWholeQuerySurface) { const SyncCompileScope sync; const CacheModeScope cacheOn(true); const String fs = SwizzleLikeFragment(""); const GLuint parsed = LinkProgramFromSources(kVertexSource, fs); const TranslationCacheStats afterFirst = MG_State::GLState::GetProgramTranslationCache().Stats(); const GLuint fromMemo = LinkProgramFromSources(kVertexSource, fs); const TranslationCacheStats afterSecond = MG_State::GLState::GetProgramTranslationCache().Stats(); ASSERT_EQ(afterSecond.hits - afterFirst.hits, 1u) << "the second link was not a hit"; const auto& parsedObject = MG_State::pGLContext->GetProgramObject(parsed); const auto& memoObject = MG_State::pGLContext->GetProgramObject(fromMemo); ASSERT_NE(parsedObject, nullptr); ASSERT_NE(memoObject, nullptr); EXPECT_EQ(ReflectionFingerprint(parsed), ReflectionFingerprint(fromMemo)); EXPECT_EQ(parsedObject->GetUniformCount(), memoObject->GetUniformCount()); EXPECT_EQ(parsedObject->GetActiveAttributesCount(), memoObject->GetActiveAttributesCount()); EXPECT_EQ(parsedObject->GetActiveFragmentOutputCount(), memoObject->GetActiveFragmentOutputCount()); EXPECT_EQ(parsedObject->GetActiveUniformBlocksCount(), memoObject->GetActiveUniformBlocksCount()); // The uniform shadow (phase B's half of the payload) has to arrive as well. for (Uint location = 0; location <= parsedObject->GetMaxUniformLocation(); ++location) { EXPECT_EQ(parsedObject->GetUniformOffset(location), memoObject->GetUniformOffset(location)) << "uniform offset at location " << location; } } // glGetFragDataLocation on a program served from the memo. // // Split out from the case above because it caught a REAL bug that case did not: every // accessor it checks had already been moved onto the owned reflection snapshot, but // GetFragmentDataLocation still opened with `if (!Artifacts().program) return -1` and then // walked the live TProgram's pipe outputs. On a hit there is no TProgram - that is the whole // point of the memo - so the guard fired and the function reported "this program has no such // fragment output" for an output that plainly exists. The failure mode was silent and // asymmetric: the FIRST program with a given source answered correctly and every later one // answered -1, so nothing that linked a program once could see it. // // Both the explicit-request path (glBindFragDataLocation, answered from // linkedFragDataLocation) and the shader-declared path (layout(location = 0), answered from // the pipe-output snapshot) are checked, because only the second one reads the field that // used to come off the TProgram. TEST_F(TranslationCacheTest, AProgramServedFromTheMemoStillAnswersGetFragDataLocation) { const SyncCompileScope sync; const CacheModeScope cacheOn(true); const String fs = SwizzleLikeFragment(""); const GLuint parsed = LinkProgramFromSources(kVertexSource, fs); const TranslationCacheStats afterFirst = MG_State::GLState::GetProgramTranslationCache().Stats(); const GLuint fromMemo = LinkProgramFromSources(kVertexSource, fs); const TranslationCacheStats afterSecond = MG_State::GLState::GetProgramTranslationCache().Stats(); ASSERT_EQ(afterSecond.hits - afterFirst.hits, 1u) << "the second link was not a hit"; const Int parsedLocation = MG_Impl::GLImpl::GetFragDataLocation(parsed, "fragColor"); const Int memoLocation = MG_Impl::GLImpl::GetFragDataLocation(fromMemo, "fragColor"); EXPECT_EQ(parsedLocation, 0) << "the parsed program's own answer moved; this case is testing " "the wrong thing"; EXPECT_EQ(memoLocation, parsedLocation) << "a program served from the L1 memo lost its fragment output location"; // A name that is not an output must still be -1 from both, so the case cannot pass by // making the accessor answer everything. EXPECT_EQ(MG_Impl::GLImpl::GetFragDataLocation(fromMemo, "notAnOutput"), -1); } // The modules a hit hands out must be the modules a from-scratch translation would have // produced. Without this the case above would still pass if the cache returned garbage. TEST_F(TranslationCacheTest, L1HitsAgreeWithACacheDisabledTranslation) { const SyncCompileScope sync; const String fs = SwizzleLikeFragment("u"); Vector uncached; { const CacheModeScope cacheOff(false); uncached = ProgramSpirvDigest(LinkProgramFromSources(kVertexSource, fs)); } ASSERT_EQ(uncached.size(), 2u); const CacheModeScope cacheOn(true); const Vector primed = ProgramSpirvDigest(LinkProgramFromSources(kVertexSource, fs)); const Vector hit = ProgramSpirvDigest(LinkProgramFromSources(kVertexSource, fs)); EXPECT_EQ(primed, uncached); EXPECT_EQ(hit, uncached); } TEST_F(TranslationCacheTest, L1DoesNotMemoizeAcrossDifferentSources) { const SyncCompileScope sync; const CacheModeScope cacheOn(true); // Prime with one, then link a different one: a miss, not a hit. (void)LinkProgramFromSources(kVertexSource, SwizzleLikeFragment("")); const TranslationCacheStats before = MG_State::GLState::GetProgramTranslationCache().Stats(); (void)LinkProgramFromSources(kVertexSource, SwizzleLikeFragment("i")); const TranslationCacheStats after = MG_State::GLState::GetProgramTranslationCache().Stats(); EXPECT_EQ(after.hits - before.hits, 0u); EXPECT_EQ(after.misses - before.misses, 1u); } // The escape hatch. MOBILEGL_SHADER_CACHE falsy must make every link translate again - // no lookup at all, so a field miscompile can be bisected against the feature in one run. TEST_F(TranslationCacheTest, TheEscapeHatchDisablesL1Entirely) { const SyncCompileScope sync; const String fs = SwizzleLikeFragment(""); { // prime the cache with the switch ON, so a later hit would be available const CacheModeScope cacheOn(true); (void)LinkProgramFromSources(kVertexSource, fs); } const CacheModeScope cacheOff(false); const TranslationCacheStats before = MG_State::GLState::GetProgramTranslationCache().Stats(); const GLuint program = LinkProgramFromSources(kVertexSource, fs); const TranslationCacheStats after = MG_State::GLState::GetProgramTranslationCache().Stats(); GLint status = GL_FALSE; MG_Impl::GLImpl::GetProgramiv(program, GL_LINK_STATUS, &status); EXPECT_EQ(status, GL_TRUE) << "the program must still link with the cache off"; EXPECT_EQ(after.hits, before.hits) << "no lookup may happen with the cache disabled"; EXPECT_EQ(after.misses, before.misses); EXPECT_EQ(after.inserts, before.inserts); } // ========================================================================================= // L2 key composition - one case per gate the DirectGLES pass chain arms // ========================================================================================= TEST_F(TranslationCacheTest, L2KeyMovesWithEveryGateThatSteersTheEsslChain) { const Vector spirv{0x07230203u, 0x00010300u, 0u, 32u, 0u}; const Vector otherSpirv{0x07230203u, 0x00010300u, 0u, 33u, 0u}; const std::set xfbBlocks{"StageData"}; const UnorderedMap imageFormats{{"gImage", 0x8236u /*GL_R32UI*/}}; const UnorderedMap storageBindings{{"Data", 3}}; const std::map ioBlockRenames{{"TCSOutputBlock", "TCSOutputBlock_mgio1"}}; const std::map otherIoBlockRenames{{"TCSOutputBlock", "TCSOutputBlock_mgio2"}}; const EsslTranslationKeyInputs base = BaselineEsslInputs(spirv); const TranslationCacheKey baseKey = BuildEsslTranslationKey(base); EXPECT_TRUE(BuildEsslTranslationKey(BaselineEsslInputs(spirv)) == baseKey); Vector> variants; { // the module itself EsslTranslationKeyInputs v = base; v.spirv = &otherSpirv; variants.emplace_back("spirv", BuildEsslTranslationKey(v)); } { // stage: gates LowerDrawParametersForEssl and SplitArrayVertexInputsForEssl (vertex) // and LegalizeFragmentOutputIndexingForEssl (fragment) EsslTranslationKeyInputs v = base; v.shaderType = GL_VERTEX_SHADER; variants.emplace_back("shaderType", BuildEsslTranslationKey(v)); } { // arms LowerViewportIndexForEssl EsslTranslationKeyInputs v = base; v.viewportIndexLoweringArmed = true; variants.emplace_back("viewportIndexLoweringArmed", BuildEsslTranslationKey(v)); } { // arms EmulateNoPerspectiveForEssl EsslTranslationKeyInputs v = base; v.supportsNoperspectiveInterpolation = true; variants.emplace_back("supportsNoperspectiveInterpolation", BuildEsslTranslationKey(v)); } { // arms WidenImageFormatsForEssl - a driver WITH GL_NV_image_formats keeps the declared // rg32f/r8ui/... image formats, one without has them re-declared in a core carrier and // every access to them masked, so the two get materially different ESSL from one module. EsslTranslationKeyInputs v = base; v.supportsExtendedImageFormats = true; variants.emplace_back("supportsExtendedImageFormats", BuildEsslTranslationKey(v)); } { // arms AND parameterizes ClampMultisampleFetchesForEssl EsslTranslationKeyInputs v = base; v.maxColorTextureSamples = 1; variants.emplace_back("maxColorTextureSamples", BuildEsslTranslationKey(v)); } { EsslTranslationKeyInputs v = base; v.maxIntegerSamples = 4; variants.emplace_back("maxIntegerSamples", BuildEsslTranslationKey(v)); } { EsslTranslationKeyInputs v = base; v.maxDepthTextureSamples = 1; variants.emplace_back("maxDepthTextureSamples", BuildEsslTranslationKey(v)); } { // the ceiling the three above are compared against EsslTranslationKeyInputs v = base; v.advertisedMaxSamples = 8; variants.emplace_back("advertisedMaxSamples", BuildEsslTranslationKey(v)); } { // the argument to FlattenXfbInterfaceBlocksForEssl EsslTranslationKeyInputs v = base; v.xfbCaptureBlockNames = &xfbBlocks; variants.emplace_back("xfbCaptureBlockNames", BuildEsslTranslationKey(v)); } { // the argument to BakeImageFormatsForEssl - live glBindImageTexture state EsslTranslationKeyInputs v = base; v.glFormatByUniformName = &imageFormats; variants.emplace_back("glFormatByUniformName", BuildEsslTranslationKey(v)); } { // SpvcSession::SetShaderStorageBlockBinding EsslTranslationKeyInputs v = base; v.storageBlockBindingOverrides = &storageBindings; variants.emplace_back("storageBlockBindingOverrides", BuildEsslTranslationKey(v)); } { // SPVC_COMPILER_OPTION_GLSL_VERSION (ResolveBackendEsslVersion) EsslTranslationKeyInputs v = base; v.esslVersion = 300; variants.emplace_back("esslVersion", BuildEsslTranslationKey(v)); } { // SpvcSession::SetAtomicCounterBlockBindings - printed into the layout(binding=) // qualifier of every synthesized counter block, so it changes the emitted text. EsslTranslationKeyInputs v = base; v.atomicCounterEsslBindingTop = 6; variants.emplace_back("atomicCounterEsslBindingTop", BuildEsslTranslationKey(v)); } { // the two arguments to UniquifyIoBlockNamesForEssl. Separate cases, because a stage // that CONSUMES a block renames it after the previous stage while one that PRODUCES // it renames after itself - so the same block name legitimately maps to different // spellings in the two maps, and a key that folded them together would let a // consumer's plan be served to a producer. EsslTranslationKeyInputs v = base; v.inputBlockRenames = &ioBlockRenames; variants.emplace_back("inputBlockRenames", BuildEsslTranslationKey(v)); } { EsslTranslationKeyInputs v = base; v.outputBlockRenames = &ioBlockRenames; variants.emplace_back("outputBlockRenames", BuildEsslTranslationKey(v)); } { // ...and a DIFFERENT target spelling for the same block name must not share either. EsslTranslationKeyInputs v = base; v.outputBlockRenames = &otherIoBlockRenames; variants.emplace_back("outputBlockRenames(other target)", BuildEsslTranslationKey(v)); } { // the two arguments to StripIoBlockLocationsForEssl, and separate cases for the same // reason the rename maps are: a stage strips the blocks it CONSUMES only when the // producer is in this program and the ones it PRODUCES only when the consumer is, so // the two directions are independently armed and a key that folded them together // would serve a fragment stage's ESSL to a vertex stage that needs the opposite. EsslTranslationKeyInputs v = base; v.stripInputBlockLocations = true; variants.emplace_back("stripInputBlockLocations", BuildEsslTranslationKey(v)); } { EsslTranslationKeyInputs v = base; v.stripOutputBlockLocations = true; variants.emplace_back("stripOutputBlockLocations", BuildEsslTranslationKey(v)); } { EsslTranslationKeyInputs v = base; v.enableSpirvValidation = true; variants.emplace_back("enableSpirvValidation", BuildEsslTranslationKey(v)); } for (const auto& [name, key] : variants) { EXPECT_FALSE(key == baseKey) << "moving " << name << " did not move the L2 key"; } for (SizeT i = 0; i < variants.size(); ++i) { for (SizeT j = i + 1; j < variants.size(); ++j) { EXPECT_FALSE(variants[i].second == variants[j].second) << variants[i].first << " and " << variants[j].first << " produce the same L2 key"; } } } // The value SIDE of L2: the payload has to carry the flattened-block report, not just the // text. A payload that dropped it would silently un-rename every transform-feedback // capture on a hit. TEST_F(TranslationCacheTest, L2PayloadCarriesTheFlattenedXfbBlockReport) { BoundedTranslationCache cache("test", 8, 1u << 20); const Vector spirv{1u, 2u, 3u}; const TranslationCacheKey key = BuildEsslTranslationKey(BaselineEsslInputs(spirv)); auto payload = MakeShared(); payload->essl = "#version 320 es\nvoid main() {}\n"; payload->flattenedXfbBlockNames = {"StageData", "OtherBlock"}; cache.Insert(key, EsslTranslationResultPtr(payload), EsslTranslationResultBytes(*payload)); const auto hit = cache.Find(BuildEsslTranslationKey(BaselineEsslInputs(spirv))); ASSERT_NE(hit, nullptr); EXPECT_EQ(hit->essl, payload->essl); EXPECT_EQ(hit->flattenedXfbBlockNames, payload->flattenedXfbBlockNames); } // The real emitter behind the real key: two lookups over the same module and the same // capability snapshot run SPIRV-Cross once and return the same text; moving one capability // bit runs it again. TEST_F(TranslationCacheTest, L2RunsTheEmitterOncePerDistinctKey) { const Vector spirv = BuildFragmentSpirv(); ASSERT_FALSE(spirv.empty()); BoundedTranslationCache cache("test", 8, 4u << 20); Int emitCount = 0; const auto translate = [&](const EsslTranslationKeyInputs& inputs) -> String { const TranslationCacheKey key = BuildEsslTranslationKey(inputs); if (const auto hit = cache.Find(key)) return hit->essl; auto payload = MakeShared(); EXPECT_TRUE(EmitEssl(*inputs.spirv, inputs.esslVersion, payload->essl)); ++emitCount; cache.Insert(key, EsslTranslationResultPtr(payload), EsslTranslationResultBytes(*payload)); return payload->essl; }; EsslTranslationKeyInputs inputs = BaselineEsslInputs(spirv); const String first = translate(inputs); const String second = translate(inputs); EXPECT_FALSE(first.empty()); EXPECT_EQ(first, second); EXPECT_EQ(emitCount, 1) << "the second lookup must not have reached SPIRV-Cross"; // A capability bit moves -> the emitter runs again. (esslVersion is the one this unit // test can observe in the OUTPUT as well as in the key.) inputs.esslVersion = 300; const String downlevel = translate(inputs); EXPECT_EQ(emitCount, 2); EXPECT_NE(downlevel, first); // ... and a gate that only steers the SPIR-V pass chain still moves the key, so the // emitter runs again even though this stand-in ignores the bit. inputs = BaselineEsslInputs(spirv); inputs.viewportIndexLoweringArmed = true; (void)translate(inputs); EXPECT_EQ(emitCount, 3); } // ========================================================================================= // Thread safety // ========================================================================================= // Several threads racing Find/compute/Insert over OVERLAPPING keys. Two workers that miss // on the same key both compute it - deliberately, because waiting on each other inside a // job body is what deadlocks ShaderCompilePool - so the property under test is not "the // work happened once" but "every payload handed out equals the single-threaded answer". TEST_F(TranslationCacheTest, ConcurrentLookupsOverOverlappingKeysAgreeWithTheSerialAnswer) { constexpr Int kDistinctKeys = 24; constexpr Int kThreads = 8; constexpr Int kRoundsPerThread = 200; const auto expensive = [](const Int index) { return String("payload-") + std::to_string(index) + String(64, static_cast('a' + index % 26)); }; BoundedTranslationCache cache("test", kDistinctKeys, 8u << 20); std::atomic mismatches{0}; std::atomic nulls{0}; Vector threads; threads.reserve(kThreads); for (Int t = 0; t < kThreads; ++t) { threads.emplace_back([&, t] { for (Int round = 0; round < kRoundsPerThread; ++round) { const Int index = (round * 7 + t * 3) % kDistinctKeys; const TranslationCacheKey key = KeyFromText("key-" + std::to_string(index)); SharedPtr value = cache.Find(key); if (!value) { auto fresh = MakeShared(TestPayload{expensive(index)}); cache.Insert(key, SharedPtr(fresh), PayloadBytes(*fresh)); value = fresh; } if (!value) { nulls.fetch_add(1, std::memory_order_relaxed); } else if (value->text != expensive(index)) { mismatches.fetch_add(1, std::memory_order_relaxed); } } }); } for (std::thread& thread : threads) thread.join(); EXPECT_EQ(mismatches.load(), 0) << "a worker was handed a payload belonging to another key"; EXPECT_EQ(nulls.load(), 0); EXPECT_LE(cache.EntryCount(), static_cast(kDistinctKeys)); const TranslationCacheStats stats = cache.Stats(); EXPECT_EQ(stats.hits + stats.misses, static_cast(kThreads) * kRoundsPerThread); } // The same race with eviction turned up so hard that entries are constantly being erased // under the readers - the shape that would catch a Find() that handed back a pointer into // the entry list instead of shared ownership. TEST_F(TranslationCacheTest, ConcurrentLookupsStaySafeWhileEvictionChurns) { constexpr Int kDistinctKeys = 32; constexpr Int kThreads = 8; constexpr Int kRoundsPerThread = 400; BoundedTranslationCache cache("test", 4, 1u << 20); // 4 slots for 32 keys std::atomic mismatches{0}; Vector threads; threads.reserve(kThreads); for (Int t = 0; t < kThreads; ++t) { threads.emplace_back([&, t] { for (Int round = 0; round < kRoundsPerThread; ++round) { const Int index = (round + t) % kDistinctKeys; const String expected = "payload-" + std::to_string(index); const TranslationCacheKey key = KeyFromText("key-" + std::to_string(index)); SharedPtr value = cache.Find(key); if (!value) { auto fresh = MakeShared(TestPayload{expected}); cache.Insert(key, SharedPtr(fresh), PayloadBytes(*fresh)); value = fresh; } // Held across further cache traffic on purpose: the payload must stay valid // even after its entry has been evicted by another thread. std::this_thread::yield(); if (value->text != expected) mismatches.fetch_add(1, std::memory_order_relaxed); } }); } for (std::thread& thread : threads) thread.join(); EXPECT_EQ(mismatches.load(), 0); EXPECT_LE(cache.EntryCount(), 4u); } // And the production shape: many links of a handful of distinct sources across the real // compile pool, with L1 live. Every program built from the same sources must end up with // the same SPIR-V, whichever worker won the race to translate it. TEST_F(TranslationCacheTest, ConcurrentLinksOfSharedSourcesProduceIdenticalSpirv) { const CacheModeScope cacheOn(true); constexpr Int kVariants = 3; constexpr Int kProgramsPerVariant = 8; Vector sources; for (Int v = 0; v < kVariants; ++v) sources.push_back(SwizzleLikeFragment(v == 0 ? "" : (v == 1 ? "i" : "u"))); Vector programs; Vector variantOf; for (Int round = 0; round < kProgramsPerVariant; ++round) { for (Int v = 0; v < kVariants; ++v) { programs.push_back(LinkProgramFromSources(kVertexSource, sources[v])); variantOf.push_back(v); } } Vector> expected(kVariants); for (SizeT i = 0; i < programs.size(); ++i) { GLint status = GL_FALSE; MG_Impl::GLImpl::GetProgramiv(programs[i], GL_LINK_STATUS, &status); ASSERT_EQ(status, GL_TRUE); const Vector digest = ProgramSpirvDigest(programs[i]); ASSERT_EQ(digest.size(), 2u); const Int v = variantOf[i]; if (expected[v].empty()) { expected[v] = digest; } else { EXPECT_EQ(digest, expected[v]) << "variant " << v << " program " << i; } } // The three variants must not have collapsed onto one another. EXPECT_NE(expected[0], expected[1]); EXPECT_NE(expected[1], expected[2]); } // ========================================================================================= // L1c - the COMPILE half: the parse verdict memo // ========================================================================================= // // Same weighting as the two levels above: mostly the KEY, because an under-specified key // here means a shader that reports GL_TRUE for a source that does not actually parse (or // the reverse), which is the one way this level can produce a wrong answer. namespace { ShaderParseVerdictKeyInputs BaselineParseVerdictInputs(const StringView source) { ShaderParseVerdictKeyInputs inputs; inputs.frontendFingerprint = 0x0f1e'2d3c'4b5a'6978ull; inputs.shaderType = GL_FRAGMENT_SHADER; inputs.preprocessedSource = source; inputs.shaderCompileFlags = 0; return inputs; } // A source no other case in this file uses, so the process-global L1c cache cannot be // pre-warmed by a neighbour and turn a "must miss" assertion green by accident. String UniqueFragment(const String& tag) { return "#version 460\n" "in vec3 vPos;\n" "layout(location = 0) out vec4 fragColor;\n" "uniform vec3 uTint_" + tag + ";\n" "void main() {\n" " fragColor = vec4(uTint_" + tag + " * vPos, 1.0);\n" "}\n"; } Bool ShaderHasParse(const GLuint shader) { const auto& object = MG_State::pGLContext->GetShaderObject(shader); return object != nullptr && object->GetCompiledShader() != nullptr; } String ShaderInfoLog(const GLuint shader) { const auto& object = MG_State::pGLContext->GetShaderObject(shader); return object ? object->GetInfoLog() : String(); } GLint ShaderCompileStatus(const GLuint shader) { GLint status = GL_FALSE; MG_Impl::GLImpl::GetShaderiv(shader, GL_COMPILE_STATUS, &status); return status; } } // namespace // One case per input in the key inventory on ShaderParseVerdictKeyInputs. Each must move the // key on its own, and none may collide with another - the same discipline the L1 and L2 key // cases follow, and the test that catches an input someone forgot to add. TEST_F(TranslationCacheTest, TheParseVerdictKeyMovesWithEveryInput) { const String source = UniqueFragment("keyinputs"); const String otherSource = UniqueFragment("keyinputs_other"); const TranslationCacheKey baseline = BuildShaderParseVerdictKey(BaselineParseVerdictInputs(source)); const Vector>> mutations{ {"frontendFingerprint", [](ShaderParseVerdictKeyInputs& i, const String&) { i.frontendFingerprint ^= 1ull; }}, {"shaderType", [](ShaderParseVerdictKeyInputs& i, const String&) { i.shaderType = GL_VERTEX_SHADER; }}, {"preprocessedSource", [](ShaderParseVerdictKeyInputs& i, const String& other) { i.preprocessedSource = other; }}, {"shaderCompileFlags", [](ShaderParseVerdictKeyInputs& i, const String&) { i.shaderCompileFlags = 1u; }}, }; Vector seen{baseline}; for (const auto& [name, mutate] : mutations) { ShaderParseVerdictKeyInputs inputs = BaselineParseVerdictInputs(source); mutate(inputs, otherSource); const TranslationCacheKey moved = BuildShaderParseVerdictKey(inputs); EXPECT_FALSE(moved == baseline) << "moving " << name << " did not move the L1c key"; for (const TranslationCacheKey& previous : seen) { EXPECT_FALSE(moved == previous) << name << " collides with an earlier L1c input"; } seen.push_back(moved); } } // L1c inherits L1 backend-agnosticism by contract, so it gets L1 own case: two environments // that differ in every way that only steers a BACKEND must share one entry. TEST_F(TranslationCacheTest, TheParseVerdictKeyIgnoresBackendOnlyDifferences) { const auto [a, b] = BackendOnlyDifferentEnvs(); const String source = UniqueFragment("agnostic"); ShaderParseVerdictKeyInputs onA = BaselineParseVerdictInputs(source); onA.frontendFingerprint = ComputeFrontendCompileEnvFingerprint(a); ShaderParseVerdictKeyInputs onB = BaselineParseVerdictInputs(source); onB.frontendFingerprint = ComputeFrontendCompileEnvFingerprint(b); EXPECT_TRUE(BuildShaderParseVerdictKey(onA) == BuildShaderParseVerdictKey(onB)); } // The headline behaviour: a second shader object handed the same source does not parse. // // Synchronous, deliberately. Under async, ShaderCompileAdoptionMap would hand the second // object the FIRST one whole compile node and no second compile would run at all - a // different (and older) optimisation, which would make this case pass without L1c existing. TEST_F(TranslationCacheTest, ASecondCompileOfTheSameSourceSkipsTheParse) { const SyncCompileScope sync; const CacheModeScope cacheOn(true); const String fs = UniqueFragment("skipparse"); const TranslationCacheStats before = GetShaderParseVerdictCache().Stats(); const GLuint first = MakeShader(GL_FRAGMENT_SHADER, fs); const TranslationCacheStats afterFirst = GetShaderParseVerdictCache().Stats(); const GLuint second = MakeShader(GL_FRAGMENT_SHADER, fs); const TranslationCacheStats afterSecond = GetShaderParseVerdictCache().Stats(); EXPECT_EQ(afterFirst.misses - before.misses, 1u) << "the first compile must be a miss"; EXPECT_EQ(afterSecond.hits - afterFirst.hits, 1u) << "the second compile must be an L1c hit"; EXPECT_EQ(afterSecond.misses - afterFirst.misses, 0u); // Everything glCompileShader makes observable is identical... EXPECT_EQ(ShaderCompileStatus(first), GL_TRUE); EXPECT_EQ(ShaderCompileStatus(second), GL_TRUE); EXPECT_EQ(ShaderInfoLog(second), ShaderInfoLog(first)); // ...and the second one really did skip the parse. This is the assertion that fails if // the level silently stops working; the counters above would still look right if the // publish path had been changed to parse anyway. EXPECT_TRUE(ShaderHasParse(first)) << "the first compile was supposed to parse"; EXPECT_FALSE(ShaderHasParse(second)) << "the second compile parsed anyway; L1c did nothing"; } // A failed compile is memoized too, and a hit has to reproduce the diagnostic the // application would have read - not merely the GL_FALSE. An empty log on a failed compile is // the least debuggable thing this driver can hand back, so it gets its own assertion. TEST_F(TranslationCacheTest, AFailedCompileReproducesItsInfoLogFromTheMemo) { const SyncCompileScope sync; const CacheModeScope cacheOn(true); const String broken = "#version 460\n" "layout(location = 0) out vec4 fragColor;\n" "void main() { fragColor = this_function_does_not_exist(); }\n"; const GLuint first = MakeShader(GL_FRAGMENT_SHADER, broken); const TranslationCacheStats afterFirst = GetShaderParseVerdictCache().Stats(); const GLuint second = MakeShader(GL_FRAGMENT_SHADER, broken); const TranslationCacheStats afterSecond = GetShaderParseVerdictCache().Stats(); EXPECT_EQ(ShaderCompileStatus(first), GL_FALSE); EXPECT_EQ(ShaderCompileStatus(second), GL_FALSE); EXPECT_FALSE(ShaderInfoLog(first).empty()); EXPECT_EQ(ShaderInfoLog(second), ShaderInfoLog(first)) << "a memoized compile failure lost the diagnostic the application reads"; // The second compile is served by SOME memo - which one depends on whether the // per-context preprocess cache got there first (it records ParseFailed and short-circuits // ahead of L1c). Either way what must not happen is a second parse, so assert on the // thing both routes guarantee rather than on which route ran. EXPECT_EQ(afterSecond.misses - afterFirst.misses, 0u) << "the second compile of a known-bad source reached the parse again"; EXPECT_FALSE(ShaderHasParse(second)); } // The deferred parse, which is the half of this design that could quietly produce a wrong // program: a stage whose compile hit L1c holds no AST, so a link that MISSES L1 has to parse // it on demand - and the module it produces must be the one a from-scratch build produces. // // The shape forces exactly that: the same vertex source is linked into two programs with // DIFFERENT fragment stages, so the second link vertex stage hits L1c (same source) while // the program-level key misses (different fragment source). TEST_F(TranslationCacheTest, AStageServedFromL1cStillLinksWhenTheProgramKeyMisses) { const SyncCompileScope sync; const String fsA = UniqueFragment("deferred_a"); const String fsB = UniqueFragment("deferred_b"); // The reference: the whole chain with every memo off. Vector uncachedB; { const CacheModeScope cacheOff(false); uncachedB = ProgramSpirvDigest(LinkProgramFromSources(kVertexSource, fsB)); } ASSERT_EQ(uncachedB.size(), 2u); const CacheModeScope cacheOn(true); // Program 1 populates L1c for kVertexSource. const GLuint programA = LinkProgramFromSources(kVertexSource, fsA); GLint statusA = GL_FALSE; MG_Impl::GLImpl::GetProgramiv(programA, GL_LINK_STATUS, &statusA); ASSERT_EQ(statusA, GL_TRUE); const TranslationCacheStats l1Before = MG_State::GLState::GetProgramTranslationCache().Stats(); const TranslationCacheStats l1cBefore = GetShaderParseVerdictCache().Stats(); const GLuint programB = LinkProgramFromSources(kVertexSource, fsB); const TranslationCacheStats l1After = MG_State::GLState::GetProgramTranslationCache().Stats(); const TranslationCacheStats l1cAfter = GetShaderParseVerdictCache().Stats(); ASSERT_EQ(l1cAfter.hits - l1cBefore.hits, 1u) << "the shared vertex stage was supposed to hit L1c"; ASSERT_EQ(l1After.misses - l1Before.misses, 1u) << "the program key was supposed to miss"; GLint statusB = GL_FALSE; MG_Impl::GLImpl::GetProgramiv(programB, GL_LINK_STATUS, &statusB); EXPECT_EQ(statusB, GL_TRUE) << "the deferred parse failed to produce a linkable stage"; EXPECT_EQ(ProgramSpirvDigest(programB), uncachedB) << "a stage parsed lazily at link time produced different SPIR-V from one parsed at compile time"; } // MOBILEGL_SHADER_CACHE=0 has to reach this level too. It is the switch that isolated the // static-destruction-order heap corruption in the first place, so a level it does not cover // is a level that cannot be bisected against. TEST_F(TranslationCacheTest, TheEscapeHatchDisablesL1c) { const SyncCompileScope sync; const CacheModeScope cacheOff(false); const String fs = UniqueFragment("escapehatch"); const TranslationCacheStats before = GetShaderParseVerdictCache().Stats(); const GLuint first = MakeShader(GL_FRAGMENT_SHADER, fs); const GLuint second = MakeShader(GL_FRAGMENT_SHADER, fs); const TranslationCacheStats after = GetShaderParseVerdictCache().Stats(); EXPECT_EQ(after.hits - before.hits, 0u); EXPECT_EQ(after.misses - before.misses, 0u) << "the cache was consulted with the escape hatch set"; EXPECT_EQ(after.inserts - before.inserts, 0u); // With the level off, BOTH compiles parse - which is the pre-L1c behaviour exactly. EXPECT_TRUE(ShaderHasParse(first)); EXPECT_TRUE(ShaderHasParse(second)); } // The whole point, end to end and in the CTS shape: N programs from fresh shader objects // over one pair of sources. After the first, no program constructs a glslang object at all - // no parse (L1c) and no link, mapIO, GlslangToSpv or reflection (L1). TEST_F(TranslationCacheTest, TheCtsShapeStopsParsingAfterTheFirstProgram) { const SyncCompileScope sync; const CacheModeScope cacheOn(true); const String fs = UniqueFragment("ctsshape"); constexpr Uint kPrograms = 8; const GLuint firstProgram = LinkProgramFromSources(kVertexSource, fs); GLint firstStatus = GL_FALSE; MG_Impl::GLImpl::GetProgramiv(firstProgram, GL_LINK_STATUS, &firstStatus); ASSERT_EQ(firstStatus, GL_TRUE); const Vector firstDigest = ProgramSpirvDigest(firstProgram); const TranslationCacheStats l1Before = MG_State::GLState::GetProgramTranslationCache().Stats(); const TranslationCacheStats l1cBefore = GetShaderParseVerdictCache().Stats(); for (Uint i = 1; i < kPrograms; ++i) { const GLuint program = LinkProgramFromSources(kVertexSource, fs); GLint status = GL_FALSE; MG_Impl::GLImpl::GetProgramiv(program, GL_LINK_STATUS, &status); ASSERT_EQ(status, GL_TRUE) << "program " << i; EXPECT_EQ(ProgramSpirvDigest(program), firstDigest) << "program " << i; } const TranslationCacheStats l1After = MG_State::GLState::GetProgramTranslationCache().Stats(); const TranslationCacheStats l1cAfter = GetShaderParseVerdictCache().Stats(); // Two stages per program, and every one of them served from the memo. EXPECT_EQ(l1cAfter.hits - l1cBefore.hits, 2u * (kPrograms - 1)); EXPECT_EQ(l1cAfter.misses - l1cBefore.misses, 0u) << "a stage reached the glslang parse again"; EXPECT_EQ(l1After.hits - l1Before.hits, kPrograms - 1); EXPECT_EQ(l1After.misses - l1Before.misses, 0u); }