Files
MobileGL/MobileGL/MG_Test/ShaderTranspiler/TranslationCacheTest.cpp
T

1531 lines
77 KiB
C++

// 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 <gtest/gtest.h>
#include <atomic>
#include <functional>
#include <set>
#include <string>
#include <thread>
#include <vector>
#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<SpirvTranslationKeyInputs::Stage>& 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<Uint32>& 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<Uint32>& words) {
Uint64 hash = 1469598103934665603ull;
for (const Uint32 word : words) hash = (hash ^ static_cast<Uint64>(word)) * 1099511628211ull;
return hash;
}
Vector<Uint64> ProgramSpirvDigest(const GLuint program) {
Vector<Uint64> 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<Uint>(i));
out += ':' + std::to_string(object->GetActiveAttribType(static_cast<Uint>(i)));
out += ':' + std::to_string(object->GetActiveAttribArraySize(static_cast<Uint>(i)));
out += '\n';
}
const Int outputCount = object->GetActiveFragmentOutputCount();
for (Int i = 0; i < outputCount; ++i) {
out += object->GetActiveFragmentOutputName(static_cast<Uint>(i));
out += ':' + std::to_string(object->GetFragmentOutputLocation(static_cast<Uint>(i)));
out += ':' + std::to_string(object->GetFragmentOutputType(static_cast<Uint>(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<Uint32>& 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<Uint32> 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<Uint32> 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<TestPayload> cache("test", 8, 4096);
const TranslationCacheKey key = KeyFromText("alpha");
EXPECT_EQ(cache.Find(key), nullptr);
auto payload = MakeShared<TestPayload>(TestPayload{"emitted"});
cache.Insert(key, SharedPtr<const TestPayload>(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<TestPayload> cache("test", 8, 4096);
TranslationCacheKey stored;
stored.hash = 0xdead'beef'dead'beefull;
stored.blob = MakeShared<const String>("the real key bytes");
TranslationCacheKey colliding;
colliding.hash = stored.hash; // same bucket, deliberately
colliding.blob = MakeShared<const String>("DIFFERENT key bytes");
cache.Insert(stored, MakeShared<const TestPayload>(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<TestPayload> cache("test", 2, 1u << 20);
for (const char* name : {"a", "b", "c"}) {
auto payload = MakeShared<TestPayload>(TestPayload{name});
cache.Insert(KeyFromText(name), SharedPtr<const TestPayload>(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>(TestPayload{"a-again"});
cache.Insert(KeyFromText("a"), SharedPtr<const TestPayload>(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<TestPayload> cache("test", 64, (keyBytes + 64) * 2);
for (const char* name : {"aaaa", "bbbb", "cccc"}) {
auto payload = MakeShared<TestPayload>(TestPayload(String(64, name[0])));
cache.Insert(KeyFromText(name), SharedPtr<const TestPayload>(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<TestPayload> cache("test", 64, 128);
auto payload = MakeShared<TestPayload>(TestPayload(String(4096, 'x')));
cache.Insert(KeyFromText("huge"), SharedPtr<const TestPayload>(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<TestPayload> cache("test", 1, 1u << 20);
auto first = MakeShared<TestPayload>(TestPayload{"first"});
cache.Insert(KeyFromText("first"), SharedPtr<const TestPayload>(first), PayloadBytes(*first));
const auto held = cache.Find(KeyFromText("first"));
ASSERT_NE(held, nullptr);
auto second = MakeShared<TestPayload>(TestPayload{"second"});
cache.Insert(KeyFromText("second"), SharedPtr<const TestPayload>(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<String, Uint> forward;
forward.emplace("aPos", 0u);
forward.emplace("aNormal", 1u);
forward.emplace("aUv", 2u);
UnorderedMap<String, Uint> 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<Uint32> 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<SpirvTranslationKeyInputs::Stage> baseStages{
{GL_VERTEX_SHADER, vs}, {GL_FRAGMENT_SHADER, fs}};
const UnorderedMap<String, Uint> attribs{{"aPos", 3u}};
const UnorderedMap<String, Uint> fragOut{{"fragColor", 1u}};
const UnorderedMap<String, Uint> fragIndex{{"fragColor", 1u}};
const UnorderedMap<String, Uint> opaque{{"uTex", 5u}};
const Vector<String> xfbVaryings{"vPos", "gl_NextBuffer", "vUv"};
const Vector<String> 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<Pair<const char*, TranslationCacheKey>> 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<CompileEnv, CompileEnv> 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<SpirvTranslationKeyInputs::Stage> 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<SpirvTranslationKeyInputs::Stage> 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<SpirvTranslationKeyInputs::Stage> 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<Pair<const char*, std::function<void(CompileEnv&)>>> 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<Uint64> 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<Uint64> firstDigest = ProgramSpirvDigest(first);
const Vector<Uint64> 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<Uint64> uncached;
{
const CacheModeScope cacheOff(false);
uncached = ProgramSpirvDigest(LinkProgramFromSources(kVertexSource, fs));
}
ASSERT_EQ(uncached.size(), 2u);
const CacheModeScope cacheOn(true);
const Vector<Uint64> primed = ProgramSpirvDigest(LinkProgramFromSources(kVertexSource, fs));
const Vector<Uint64> 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<Uint32> spirv{0x07230203u, 0x00010300u, 0u, 32u, 0u};
const Vector<Uint32> otherSpirv{0x07230203u, 0x00010300u, 0u, 33u, 0u};
const std::set<String> xfbBlocks{"StageData"};
const UnorderedMap<String, Uint> imageFormats{{"gImage", 0x8236u /*GL_R32UI*/}};
const UnorderedMap<String, Int> storageBindings{{"Data", 3}};
const std::map<String, String> ioBlockRenames{{"TCSOutputBlock", "TCSOutputBlock_mgio1"}};
const std::map<String, String> otherIoBlockRenames{{"TCSOutputBlock", "TCSOutputBlock_mgio2"}};
const EsslTranslationKeyInputs base = BaselineEsslInputs(spirv);
const TranslationCacheKey baseKey = BuildEsslTranslationKey(base);
EXPECT_TRUE(BuildEsslTranslationKey(BaselineEsslInputs(spirv)) == baseKey);
Vector<Pair<const char*, TranslationCacheKey>> 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<EsslTranslationResult> cache("test", 8, 1u << 20);
const Vector<Uint32> spirv{1u, 2u, 3u};
const TranslationCacheKey key = BuildEsslTranslationKey(BaselineEsslInputs(spirv));
auto payload = MakeShared<EsslTranslationResult>();
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<Uint32> spirv = BuildFragmentSpirv();
ASSERT_FALSE(spirv.empty());
BoundedTranslationCache<EsslTranslationResult> 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<EsslTranslationResult>();
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<char>('a' + index % 26));
};
BoundedTranslationCache<TestPayload> cache("test", kDistinctKeys, 8u << 20);
std::atomic<Int> mismatches{0};
std::atomic<Int> nulls{0};
Vector<std::thread> 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<const TestPayload> value = cache.Find(key);
if (!value) {
auto fresh = MakeShared<TestPayload>(TestPayload{expensive(index)});
cache.Insert(key, SharedPtr<const TestPayload>(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<SizeT>(kDistinctKeys));
const TranslationCacheStats stats = cache.Stats();
EXPECT_EQ(stats.hits + stats.misses, static_cast<Uint64>(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<TestPayload> cache("test", 4, 1u << 20); // 4 slots for 32 keys
std::atomic<Int> mismatches{0};
Vector<std::thread> 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<const TestPayload> value = cache.Find(key);
if (!value) {
auto fresh = MakeShared<TestPayload>(TestPayload{expected});
cache.Insert(key, SharedPtr<const TestPayload>(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<String> sources;
for (Int v = 0; v < kVariants; ++v) sources.push_back(SwizzleLikeFragment(v == 0 ? "" : (v == 1 ? "i" : "u")));
Vector<GLuint> programs;
Vector<Int> 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<Vector<Uint64>> 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<Uint64> 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<Pair<const char*, std::function<void(ShaderParseVerdictKeyInputs&, const String&)>>> 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<TranslationCacheKey> 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<Uint64> 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<Uint64> 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);
}