mirror of
https://github.com/MobileGL-Dev/MobileGL
synced 2026-09-08 04:08:32 +09:00
1531 lines
77 KiB
C++
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);
|
|
}
|