[Feat, Perf, Test] (MG_Util, MG_Backend, MG_Test): test-lane SPIR-V validation latch; entry-local Private rewrite, in-chain rect lowering and uniform-Location strip

This commit is contained in:
2026-08-10 13:00:20 -04:00
parent 94233ef928
commit b219992ee3
13 changed files with 1021 additions and 43 deletions
+2
View File
@@ -205,6 +205,8 @@ set(SOURCE_FILES
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/PackDoubleVertexInputsPass.cpp
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/RebaseInstanceIndexPass.cpp
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/NormalizeRectCoordinatesPass.cpp
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/PrivateToEntryLocalPass.cpp
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/StripUniformLocationsPass.cpp
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/StripUboMemberRelaxedPrecisionPass.cpp
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/StripNoPerspectivePass.cpp
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/EmulateNoPerspectivePass.cpp
+5
View File
@@ -66,6 +66,11 @@ namespace MobileGL::MG_Config {
// - DISPLAY: X11 session variable, not MobileGL configuration.
// - MOBILEGL_LOG_FILE_PATH: log-file init runs before MG_ConfigLoader::Init
// (see MG_Util/Debug/Log.cpp).
// - MOBILEGL_VALIDATE_SPIRV: test suites like SpirvPassTest exercise
// ShaderCompiler without ever running MobileGL::Initialize(), and every
// Initialize() re-runs MG_ConfigLoader::Init, which would clobber a
// programmatic override stored here (see ShaderCompiler.cpp,
// SpirvValidationEnabled).
struct FeaturesTable {
// MOBILEGL_DISABLE_TIMERQUERY: do not advertise or use GPU timer queries.
Bool DisableTimerQuery = false;
@@ -372,6 +372,20 @@ namespace MobileGL::MG_Backend::DirectVulkan {
spv_diagnostic diagnostic = nullptr;
const spv_result_t result = spvValidateWithOptions(context, options, &binary, &diagnostic);
if (result != SPV_SUCCESS) {
// MGLOG_I, not E: at the INFO compile level of the CI/test lanes that arm
// the validation switch, MGLOG_E is compiled out (Log.h orders
// DEBUG < WARN < ERROR < INFO) and the VUID would never reach a log. The
// latch is what a test harness asserts on.
MG_Util::ShaderTranspiler::ShaderCompiler::NoteSpirvValidationFailure();
MGLOG_I(
"ProgramFactory::ValidateTransformedSpirv: validation failed for stage=%d program=%u result=%d index=%zu msg=%s",
static_cast<Int>(shaderStage),
programExternalIndex,
static_cast<Int>(result),
diagnostic != nullptr ? diagnostic->position.index : 0,
diagnostic != nullptr && diagnostic->error != nullptr ? diagnostic->error : "<null>");
}
MOBILEGL_ASSERT(
result == SPV_SUCCESS,
"ProgramFactory::ValidateTransformedSpirv: validation failed for stage=%d program=%u result=%d line=%zu column=%zu index=%zu msg=%s",
@@ -1263,8 +1277,10 @@ namespace MobileGL::MG_Backend::DirectVulkan {
}
spvtools::Optimizer optimizer(SPV_ENV_VULKAN_1_3);
spvtools::OptimizerOptions options;
// Matches the position-fix pass: this build of spirv-tools asserts rather than
// reporting, so validation stays off in the shipping path.
// Always off: the optimizer's input validator conflates "input invalid" with
// "transform failed", and this call site fails open. Validating lanes check the
// FINAL module via ValidateTransformedSpirv, which latches instead of rerouting
// control flow.
options.set_run_validator(false);
optimizer.SetMessageConsumer([](spv_message_level_t, const char*, const spv_position_t&,
const char* message) {
@@ -1304,7 +1320,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
spvtools::Optimizer optimizer(SPV_ENV_VULKAN_1_3);
spvtools::OptimizerOptions options;
options.set_run_validator(false);
options.set_run_validator(false); // see TransformSpirvForExplicitLod0Sampling
optimizer.SetMessageConsumer([](spv_message_level_t, const char*, const spv_position_t&,
const char* message) {
MGLOG_E("Vulkan: xfb capture pass: %s", message != nullptr ? message : "");
@@ -1334,7 +1350,11 @@ namespace MobileGL::MG_Backend::DirectVulkan {
spvtools::Optimizer optimizer(SPV_ENV_VULKAN_1_3);
spvtools::OptimizerOptions options;
options.set_run_validator(false);
options.set_run_validator(false); // see TransformSpirvForExplicitLod0Sampling
optimizer.SetMessageConsumer([](spv_message_level_t, const char*, const spv_position_t&,
const char* message) {
MGLOG_E("Vulkan: position fix pass: %s", message != nullptr ? message : "");
});
optimizer.RegisterPass(CreateGlToVulkanPositionFixPass(transformFlags));
const Bool success = optimizer.Run(input.data(), input.size(), &output, options);
@@ -2525,6 +2545,12 @@ namespace MobileGL::MG_Backend::DirectVulkan {
#if MOBILEGL_LOG_ACTIVE_LEVEL <= MOBILEGL_LOG_LEVEL_DEBUG
ValidateTransformedSpirv(moduleSpv, shaders[i]->GetShaderStage(), program.GetExternalIndex());
#else
// Final module the driver receives; also checked in the INFO-level CI/test
// lanes, where the DEBUG gate above is compiled out.
if (MG_Util::ShaderTranspiler::ShaderCompiler::SpirvValidationEnabled()) {
ValidateTransformedSpirv(moduleSpv, shaders[i]->GetShaderStage(), program.GetExternalIndex());
}
#endif
VkShaderModuleCreateInfo smci{VK_STRUCTURE_TYPE_SHADER_MODULE_CREATE_INFO};
@@ -2759,3 +2759,323 @@ void main() {
}
EXPECT_GE(checked, 7u) << "expected all seven declared inputs to be present in the raw module";
}
namespace {
// Storage-class census of module-scope OpVariables plus an OpFunctionCall count -
// everything the dead-interface-elimination tests need to see, nothing more.
struct SpirvVariableCensus {
SizeT inputCount = 0;
SizeT outputCount = 0;
SizeT privateCount = 0;
SizeT functionCallCount = 0;
};
SpirvVariableCensus TakeVariableCensus(const Vector<Uint32>& spirv) {
constexpr unsigned kOpVariable = 59, kOpFunctionCall = 57;
constexpr unsigned kStorageClassInput = 1, kStorageClassPrivate = 6, kStorageClassOutput = 3;
SpirvVariableCensus census;
for (SizeT i = 5; i < spirv.size();) { // 5-word header
const unsigned wordCount = spirv[i] >> 16;
const unsigned opcode = spirv[i] & 0xFFFFu;
if (wordCount == 0 || i + wordCount > spirv.size()) break;
if (opcode == kOpVariable && wordCount >= 4) {
switch (spirv[i + 3]) {
case kStorageClassInput: ++census.inputCount; break;
case kStorageClassOutput: ++census.outputCount; break;
case kStorageClassPrivate: ++census.privateCount; break;
default: break;
}
} else if (opcode == kOpFunctionCall) {
++census.functionCallCount;
}
i += wordCount;
}
return census;
}
// The exact Iris shim shape that shipped an invalid module for a month: a declared
// vertex input whose only use is the initializer of a file-scope global nothing ever
// reads, in a shader whose main() still contains calls (which is what used to make
// ADCE keep the whole chain alive).
constexpr const char* kDeadPrivateChainVertexSource = R"(#version 460 core
in vec3 a_Position;
in vec2 mc_midTexCoord;
out vec4 v_Color;
vec4 iris_MidTex = vec4(mc_midTexCoord * (1.0 / 32768.0), 0.0, 1.0);
vec4 helperTint();
void main() {
v_Color = helperTint();
gl_Position = vec4(a_Position, 1.0);
}
vec4 helperTint() { return vec4(1.0); }
)";
Vector<Uint32> CompileVertexToRawSpirv(const String& source) {
using namespace MG_Util::ShaderTranspiler;
ShaderAttrib shaderAttrib{.shaderType = GL_VERTEX_SHADER, .sourceStr = source};
auto shaderResult = ShaderCompiler::CompileShader(shaderAttrib);
if (!shaderResult) {
ADD_FAILURE() << shaderResult.error().log;
return {};
}
ProgramAttrib programAttrib{.shaders = {shaderResult.value()}};
auto programResult = ShaderCompiler::LinkProgram(programAttrib);
if (!programResult) {
ADD_FAILURE() << programResult.error().log;
return {};
}
ProgramBinaryAttrib binaryAttrib{.shaderTypes = {GL_VERTEX_SHADER},
.program = *programResult.value()};
auto binaryResult = ShaderCompiler::GetSpirvBinaryFromProgram(binaryAttrib);
if (!binaryResult || binaryResult->size() != 1u) {
ADD_FAILURE() << (binaryResult ? "unexpected module count"
: binaryResult.error().log);
return {};
}
return binaryResult->front();
}
struct SpirvValidationScope {
bool previous;
explicit SpirvValidationScope(bool enabled)
: previous(MG_Util::ShaderTranspiler::ShaderCompiler::SpirvValidationEnabled()) {
MG_Util::ShaderTranspiler::ShaderCompiler::SetSpirvValidationEnabled(enabled);
}
~SpirvValidationScope() {
MG_Util::ShaderTranspiler::ShaderCompiler::SetSpirvValidationEnabled(previous);
}
};
} // namespace
TEST_F(ProgramUtilTest, DeadPrivateChainVertexInputIsEliminatedFromOptimizedBinary) {
using namespace MG_Util::ShaderTranspiler;
const Vector<Uint32> raw = CompileVertexToRawSpirv(kDeadPrivateChainVertexSource);
ASSERT_FALSE(raw.empty());
const SpirvVariableCensus before = TakeVariableCensus(raw);
// Preconditions that make this module exercise the ADCE conservatism gate: the dead
// input is present, its Private sink is present, and main() still contains a call.
// Four Inputs, not two: the frontend always emits gl_VertexIndex/gl_InstanceIndex
// built-ins alongside a_Position and mc_midTexCoord.
ASSERT_EQ(before.inputCount, 4u)
<< "expected a_Position, mc_midTexCoord, gl_VertexIndex and gl_InstanceIndex in the raw module";
ASSERT_GE(before.privateCount, 1u);
ASSERT_GE(before.functionCallCount, 1u)
<< "helperTint() was inlined by the frontend; this test no longer covers the "
<< "entry-point-with-calls shape it exists for";
Vector<Uint32> optimized;
ASSERT_TRUE(ShaderCompiler::SanitizeAndOptimizeBinary(raw, optimized));
const SpirvVariableCensus after = TakeVariableCensus(optimized);
EXPECT_EQ(after.inputCount, 1u)
<< "mc_midTexCoord feeds only a never-read Private global and must not reach the driver";
spvtools::SpirvTools tools(SPV_ENV_VULKAN_1_1);
String validatorMessages;
tools.SetMessageConsumer([&validatorMessages](spv_message_level_t, const char*,
const spv_position_t&, const char* message) {
if (message != nullptr) validatorMessages += String(message) + "\n";
});
EXPECT_TRUE(tools.Validate(optimized)) << validatorMessages;
}
TEST_F(ProgramUtilTest, DeclaredButUnwrittenOutputSurvivesOptimization) {
using namespace MG_Util::ShaderTranspiler;
// Chocapic-class packs declare varyings some variants never write while the paired
// fragment shader still reads them. The OpVariable (and its Location) must survive the
// chain on both backends: Espryt's ESSL link would otherwise fail with "varying not
// declared in vertex shader", and Magma's stage-interface contract breaks the same way.
// ADCE guarantees this only while remove_outputs stays false - this test freezes that.
const Vector<Uint32> raw = CompileVertexToRawSpirv(R"(#version 460 core
in vec3 a_Position;
out vec4 v_Written;
out vec4 v_NeverWritten;
void main() {
v_Written = vec4(1.0);
gl_Position = vec4(a_Position, 1.0);
}
)");
ASSERT_FALSE(raw.empty());
// v_Written, v_NeverWritten, and the gl_PerVertex block are all Output-storage variables.
const SpirvVariableCensus before = TakeVariableCensus(raw);
ASSERT_GE(before.outputCount, 3u);
Vector<Uint32> optimized;
ASSERT_TRUE(ShaderCompiler::SanitizeAndOptimizeBinary(raw, optimized));
EXPECT_EQ(TakeVariableCensus(optimized).outputCount, before.outputCount)
<< "a declared-but-unwritten output was deleted; a fragment stage reading it now "
<< "fails to link (ES) or breaks the Vulkan stage interface";
}
TEST_F(ProgramUtilTest, ValidationLatchFlagsInvalidModuleWithoutChangingResults) {
using namespace MG_Util::ShaderTranspiler;
// Only LIVE inputs, so the chain cannot heal the module by deleting them: both
// survive to the output, undecorated, and the output is invalid SPIR-V.
Vector<Uint32> raw = CompileVertexToRawSpirv(R"(#version 460 core
in vec3 a_Position;
in vec4 a_Color;
out vec4 v_Color;
void main() {
v_Color = a_Color;
gl_Position = vec4(a_Position, 1.0);
}
)");
ASSERT_FALSE(raw.empty());
// Strip every Input Location decoration - the exact defect class the
// TMglGlslIoResolver used to ship ([VUID-StandaloneSpirv-Location-04916]).
constexpr unsigned kOpDecorate = 71, kOpVariable = 59;
constexpr unsigned kDecorationLocation = 30, kStorageClassInput = 1;
std::set<unsigned> inputIds;
for (SizeT i = 5; i < raw.size();) {
const unsigned wordCount = raw[i] >> 16;
const unsigned opcode = raw[i] & 0xFFFFu;
ASSERT_GT(wordCount, 0u);
if (i + wordCount > raw.size()) break;
if (opcode == kOpVariable && wordCount >= 4 && raw[i + 3] == kStorageClassInput) {
inputIds.insert(raw[i + 2]);
}
i += wordCount;
}
SizeT strippedCount = 0;
for (SizeT i = 5; i < raw.size();) {
const unsigned wordCount = raw[i] >> 16;
const unsigned opcode = raw[i] & 0xFFFFu;
if (wordCount == 0 || i + wordCount > raw.size()) break;
if (opcode == kOpDecorate && wordCount >= 4 && raw[i + 2] == kDecorationLocation &&
inputIds.count(raw[i + 1]) != 0) {
raw.erase(raw.begin() + static_cast<std::ptrdiff_t>(i),
raw.begin() + static_cast<std::ptrdiff_t>(i + wordCount));
++strippedCount;
continue; // do not advance: the next instruction moved into place
}
i += wordCount;
}
ASSERT_GE(strippedCount, 2u) << "expected to strip both live inputs' Location decorations";
Vector<Uint32> optimized;
{
// The armed lane: control flow is IDENTICAL to shipping (the wrapper still
// succeeds - fail-open call sites downstream must not see a different world),
// and the failure latch is the signal. This is the catch that took a device
// bisect to find when the validator was off everywhere.
SpirvValidationScope validationOn(true);
const Uint64 failuresBefore = ShaderCompiler::SpirvValidationFailureCount();
EXPECT_TRUE(ShaderCompiler::SanitizeAndOptimizeBinary(raw, optimized));
EXPECT_GT(ShaderCompiler::SpirvValidationFailureCount(), failuresBefore)
<< "an invalid optimized module must bump the validation-failure latch";
}
{
// The shipping configuration: same result, no validation, latch untouched.
SpirvValidationScope validationOff(false);
const Uint64 failuresBefore = ShaderCompiler::SpirvValidationFailureCount();
EXPECT_TRUE(ShaderCompiler::SanitizeAndOptimizeBinary(raw, optimized));
EXPECT_EQ(ShaderCompiler::SpirvValidationFailureCount(), failuresBefore);
}
}
namespace {
// OpTypeImage: result id (+1), sampled type (+2), dim (+3). Dim::Rect == 4.
SizeT CountRectImageTypes(const Vector<Uint32>& spirv) {
constexpr unsigned kOpTypeImage = 25, kDimRect = 4;
SizeT count = 0;
for (SizeT i = 5; i < spirv.size();) {
const unsigned wordCount = spirv[i] >> 16;
const unsigned opcode = spirv[i] & 0xFFFFu;
if (wordCount == 0 || i + wordCount > spirv.size()) break;
if (opcode == kOpTypeImage && wordCount >= 4 && spirv[i + 3] == kDimRect) {
++count;
}
i += wordCount;
}
return count;
}
// True when any OpDecorate Location targets a UniformConstant/Uniform-storage
// variable ([VUID-StandaloneSpirv-Location-06672]).
bool AnyLocationOnUniformStorage(const Vector<Uint32>& spirv) {
constexpr unsigned kOpDecorate = 71, kOpVariable = 59, kDecorationLocation = 30;
constexpr unsigned kStorageUniformConstant = 0, kStorageUniform = 2;
std::set<unsigned> locatedIds;
for (SizeT i = 5; i < spirv.size();) {
const unsigned wordCount = spirv[i] >> 16;
const unsigned opcode = spirv[i] & 0xFFFFu;
if (wordCount == 0 || i + wordCount > spirv.size()) break;
if (opcode == kOpDecorate && wordCount >= 4 && spirv[i + 2] == kDecorationLocation) {
locatedIds.insert(spirv[i + 1]);
}
i += wordCount;
}
for (SizeT i = 5; i < spirv.size();) {
const unsigned wordCount = spirv[i] >> 16;
const unsigned opcode = spirv[i] & 0xFFFFu;
if (wordCount == 0 || i + wordCount > spirv.size()) break;
if (opcode == kOpVariable && wordCount >= 4 &&
(spirv[i + 3] == kStorageUniformConstant || spirv[i + 3] == kStorageUniform) &&
locatedIds.count(spirv[i + 2]) != 0) {
return true;
}
i += wordCount;
}
return false;
}
} // namespace
TEST_F(ProgramUtilTest, RectangleSamplerModuleLeavesTheChainVulkanLegal) {
using namespace MG_Util::ShaderTranspiler;
// Dim::Rect is invalid under every Vulkan environment; the lowering used to run
// only in the backends, i.e. AFTER the chain whose output the validating lanes
// check. It now runs inside the chain, so the driver-bound bytes are rect-free.
const Vector<Uint32> raw = CompileVertexToRawSpirv(R"(#version 460 core
in vec3 a_Position;
uniform sampler2DRect uRect;
out vec4 v_Color;
void main() {
v_Color = texture(uRect, a_Position.xy);
gl_Position = vec4(a_Position, 1.0);
}
)");
ASSERT_FALSE(raw.empty());
ASSERT_GE(CountRectImageTypes(raw), 1u) << "glslang no longer emits Dim::Rect for sampler2DRect";
SpirvValidationScope validationOn(true);
const Uint64 failuresBefore = ShaderCompiler::SpirvValidationFailureCount();
Vector<Uint32> optimized;
ASSERT_TRUE(ShaderCompiler::SanitizeAndOptimizeBinary(raw, optimized));
EXPECT_EQ(CountRectImageTypes(optimized), 0u);
EXPECT_EQ(ShaderCompiler::SpirvValidationFailureCount(), failuresBefore)
<< "a rectangle module must leave the chain valid, not latched as a failure";
}
TEST_F(ProgramUtilTest, ExplicitSamplerLocationIsStrippedFromTheOptimizedBinary) {
using namespace MG_Util::ShaderTranspiler;
// glslang's relaxed GL path keeps layout(location=N) on the UniformConstant
// variable, which Vulkan forbids; nothing downstream reads it (GL locations come
// from phase-A reflection, Vulkan bindings go by name).
const Vector<Uint32> raw = CompileVertexToRawSpirv(R"(#version 460 core
in vec3 a_Position;
layout(location = 5) uniform sampler2D uTex;
out vec4 v_Color;
void main() {
v_Color = texture(uTex, a_Position.xy);
gl_Position = vec4(a_Position, 1.0);
}
)");
ASSERT_FALSE(raw.empty());
ASSERT_TRUE(AnyLocationOnUniformStorage(raw))
<< "glslang no longer keeps the explicit uniform location; the strip pass may be obsolete";
SpirvValidationScope validationOn(true);
const Uint64 failuresBefore = ShaderCompiler::SpirvValidationFailureCount();
Vector<Uint32> optimized;
ASSERT_TRUE(ShaderCompiler::SanitizeAndOptimizeBinary(raw, optimized));
EXPECT_FALSE(AnyLocationOnUniformStorage(optimized));
EXPECT_EQ(ShaderCompiler::SpirvValidationFailureCount(), failuresBefore)
<< "the stripped module must validate clean";
}
+7 -3
View File
@@ -54,9 +54,7 @@ namespace MobileGL::MG_Util::Async {
// calling eglTerminate, which is the norm for a test binary and legal
// for an application. Registered here, during main, so it runs before
// the destructors of statics constructed at load time.
if (ShaderCompilePool* pool = g_processPool.load(std::memory_order_acquire)) {
pool->StopAndDrain();
}
ShaderCompilePool::StopAndDrainProcessPoolAtExit();
});
});
}
@@ -345,4 +343,10 @@ namespace MobileGL::MG_Util::Async {
// eglInitialize to get its worker threads back, the re-arm belongs in
// MobileGL::Initialize(), next to glslang::InitializeProcess().
}
void ShaderCompilePool::StopAndDrainProcessPoolAtExit() {
if (ShaderCompilePool* pool = g_processPool.load(std::memory_order_acquire)) {
pool->StopAndDrain();
}
}
} // namespace MobileGL::MG_Util::Async
@@ -109,6 +109,13 @@ namespace MobileGL::MG_Util::Async {
// but they share glslang's process globals, which teardown is about to free.
void StopAndDrain();
// StopAndDrain() on the process-wide pool if one was ever created; never creates
// one. For extra atexit sentinels owned by other subsystems (the SPIR-V validation
// switch registers one after forcing spirv-tools' lazy function-local tables into
// existence, so the drain is sequenced before those tables' destructors - a worker
// mid-Validate would otherwise touch freed memory during process exit).
static void StopAndDrainProcessPoolAtExit();
Uint GetThreadCount() const;
Uint GetMaxConcurrency() const;
@@ -22,6 +22,8 @@
#include "SpirvPasses/PackDoubleVertexInputsPass.h"
#include "SpirvPasses/RebaseInstanceIndexPass.h"
#include "SpirvPasses/NormalizeRectCoordinatesPass.h"
#include "SpirvPasses/PrivateToEntryLocalPass.h"
#include "SpirvPasses/StripUniformLocationsPass.h"
#include "SpirvPasses/StripUboMemberRelaxedPrecisionPass.h"
#include "SpirvPasses/StripNoPerspectivePass.h"
#include "SpirvPasses/EmulateNoPerspectivePass.h"
@@ -30,9 +32,13 @@
#include "ShaderSourceProcessor.h"
#include <MG_Backend/BackendObjects.h>
#include <MG_Util/Async/ShaderCompilePool.h>
#include <MG_Util/Converters/GLToStr/GLEnumConverter.h>
#include <MG_Util/Converters/GLToGlslang/ProgramEnumConverter.h>
#include <atomic>
#include <cctype>
#include <cstdlib>
#include <mutex>
namespace MobileGL {
namespace MG_Util {
@@ -354,16 +360,215 @@ namespace MobileGL {
return allSpirv;
}
// -1 unresolved, 0 off, 1 on. Resolved once from MOBILEGL_VALIDATE_SPIRV on first
// use. A live getenv rather than an MG_Config::Features field, for the same reason
// Config.h already exempts MOBILEGL_LOG_FILE_PATH: suites like SpirvPassTest never
// run MobileGL::Initialize(), and every Initialize() re-runs MG_ConfigLoader::Init,
// which would clobber a programmatic override stored in the feature table.
static std::atomic<int> g_validateSpirv{-1};
// Total validation failures observed this process. This latch - not the wrappers'
// return values - is the test-lane signal: validation must never change what a
// wrapper returns, or the validating lanes would render differently from the
// shipping configuration (fail-open call sites would silently substitute an
// earlier-stage module).
static std::atomic<Uint64> g_spirvValidationFailures{0};
namespace {
// Test lanes (desktop/CI/WSL) validate by default; device builds do not -
// validation costs real time per module, and on device the driver is the
// final validator anyway. MOBILEGL_VALIDATE_SPIRV overrides in either
// direction, using the ConfigLoader truthy rule.
constexpr bool kValidateSpirvDefault =
#if defined(__ANDROID__)
false;
#else
true;
#endif
bool IsTruthySpirvEnvValue(const char* value) {
if (value == nullptr || value[0] == '\0') {
return false;
}
String lowered(value);
for (auto& c : lowered) {
c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
}
return lowered != "0" && lowered != "false";
}
// spirv-tools' validator lazily constructs function-local static tables on
// its first run, which on this codebase happens on a ShaderCompilePool
// worker. Function-local statics are destroyed in reverse construction
// order, so those tables would die BEFORE the pool's own atexit sentinel
// (registered at first pool use) gets to drain the workers - and a worker
// mid-Validate would then read freed memory during process exit. Pin the
// order instead: force the tables into existence now, then register a
// second drain handler; being registered after the tables' destructors, it
// runs before them.
void PinValidatorTablesForProcessExit() {
static std::once_flag pinnedOnce;
std::call_once(pinnedOnce, [] {
spvtools::SpirvTools tools(SPV_ENV_VULKAN_1_1);
Vector<Uint32> warmup;
// The module is shaped to reach BOTH lazily-constructed tables in
// the vendored validate_id.cpp: a type-generating operand pins
// InstructionCanHaveTypeOperand's allow-set, and the OpExtInst use
// of the TYPELESS %glsl import is the one path into
// InstructionRequiresTypeOperand's deny-set (its call site is
// guarded on a referenced def with no result type). A straight-line
// module without it leaves the deny-set to be built later on a pool
// worker, re-creating the exit-order hazard for that one table.
if (tools.Assemble("OpCapability Shader\n"
"%glsl = OpExtInstImport \"GLSL.std.450\"\n"
"OpMemoryModel Logical GLSL450\n"
"OpEntryPoint GLCompute %main \"main\"\n"
"OpExecutionMode %main LocalSize 1 1 1\n"
"%void = OpTypeVoid\n"
"%fn = OpTypeFunction %void\n"
"%float = OpTypeFloat 32\n"
"%c = OpConstant %float 1\n"
"%main = OpFunction %void None %fn\n"
"%entry = OpLabel\n"
"%abs = OpExtInst %float %glsl FAbs %c\n"
"OpReturn\n"
"OpFunctionEnd\n",
&warmup)) {
tools.Validate(warmup);
}
std::atexit(+[] {
// Flip validation off first: a validator table this warmup does
// not know about (a future spirv-tools bump) would still be
// destroyed before this handler, and workers must stop entering
// Validate before the drain waits for them.
g_validateSpirv.store(0, std::memory_order_release);
Async::ShaderCompilePool::StopAndDrainProcessPoolAtExit();
});
});
}
spvtools::MessageConsumer MakeSpirvMessageConsumer(const char* site) {
return [site](spv_message_level_t level, const char* /*source*/,
const spv_position_t& position, const char* message) {
const char* text = message ? message : "";
switch (level) {
case SPV_MSG_FATAL:
case SPV_MSG_INTERNAL_ERROR:
case SPV_MSG_ERROR:
// MGLOG_I, deliberately: at the INFO compile level of every
// CI/WSL/retrace build, MGLOG_E and MGLOG_W are compiled out
// (Log.h orders DEBUG < WARN < ERROR < INFO) and the VUID
// would never reach a log.
MGLOG_I("[spirv] %s: %s (word index %zu)", site, text, position.index);
break;
default:
MGLOG_D("[spirv] %s: %s", site, text);
break;
}
};
}
// Validation is decoupled from control flow on purpose: a failure logs and
// bumps the latch, and the caller proceeds exactly as the shipping (non-
// validating) configuration would. Tests assert on the latch delta.
void ValidateOrLatch(const char* site, const Vector<Uint32>& binary) {
if (!ShaderCompiler::SpirvValidationEnabled()) {
return;
}
spvtools::SpirvTools tools(SPV_ENV_VULKAN_1_1);
tools.SetMessageConsumer(MakeSpirvMessageConsumer(site));
if (!tools.Validate(binary)) {
MGLOG_I("[spirv] %s: produced a module that fails validation (failure #%llu)",
site,
static_cast<unsigned long long>(
ShaderCompiler::NoteSpirvValidationFailure()));
}
}
// Shared tail for every Optimizer wrapper in this file. The optimizer's own
// input validator stays off even in validating lanes, for two reasons: its
// failure is indistinguishable from a transform failure (Optimizer::Run
// returns false before BuildModule), and the FIRST wrapper's input is
// glslang output that is legitimately not Vulkan-clean yet. What gets
// validated is each wrapper's OUTPUT - the only bytes a driver can ever
// receive. The message consumer is installed unconditionally: without one,
// spirv-tools drops pass diagnostics on the floor.
bool RunOptimizerChecked(const char* site, spvtools::Optimizer& optimizer,
const Vector<Uint32>& inputBinary,
Vector<uint32_t>& outputBinary) {
spvtools::OptimizerOptions options;
options.set_run_validator(false);
optimizer.SetMessageConsumer(MakeSpirvMessageConsumer(site));
if (!optimizer.Run(inputBinary.data(), inputBinary.size(), &outputBinary, options)) {
return false;
}
ValidateOrLatch(site, outputBinary);
return true;
}
} // namespace
bool ShaderCompiler::SpirvValidationEnabled() {
int state = g_validateSpirv.load(std::memory_order_acquire);
if (state < 0) {
const char* env = std::getenv("MOBILEGL_VALIDATE_SPIRV");
const bool resolved = env != nullptr ? IsTruthySpirvEnvValue(env) : kValidateSpirvDefault;
int expected = -1;
g_validateSpirv.compare_exchange_strong(expected, resolved ? 1 : 0,
std::memory_order_acq_rel);
state = g_validateSpirv.load(std::memory_order_acquire);
if (state == 1) {
PinValidatorTablesForProcessExit();
}
}
return state == 1;
}
void ShaderCompiler::SetSpirvValidationEnabled(bool enabled) {
g_validateSpirv.store(enabled ? 1 : 0, std::memory_order_release);
if (enabled) {
PinValidatorTablesForProcessExit();
}
}
Uint64 ShaderCompiler::NoteSpirvValidationFailure() {
return g_spirvValidationFailures.fetch_add(1, std::memory_order_relaxed) + 1;
}
Uint64 ShaderCompiler::SpirvValidationFailureCount() {
return g_spirvValidationFailures.load(std::memory_order_relaxed);
}
bool ShaderCompiler::SanitizeAndOptimizeBinary(const Vector<Uint32>& inputBinary,
Vector<uint32_t>& outputBinary) {
using namespace spvtools;
OptimizerOptions options;
options.set_run_validator(false);
Optimizer optimizer(SPV_ENV_VULKAN_1_1);
// ADCE refuses to treat a Private global as deletable while the entry point
// still contains any OpFunctionCall (IsLocalVar -> IsEntryPointWithNoCalls), so
// a dead vertex input feeding a never-read Private shim used to survive the
// whole chain (the Chocapic13 shadow.vsh mc_midTexCoord/iris_MidTex case).
// Rewriting entry-point-owned Private variables to Function storage first
// satisfies ADCE without inlining: over 521 real Iris modules the rewrite
// captured 17 of the 21 extra dead interface variables exhaustive inlining
// would, while shrinking the corpus 8% - inlining grew it 20% with a 5.3x
// worst-case module and no additional GPU-side benefit.
optimizer.RegisterPass(PrivateToEntryLocalPass::CreatePrivateToEntryLocalPass());
// Keep the one-arg overload: remove_outputs must stay false, forever. Output
// variables on the entry-point interface are ADCE's only unconditional live
// roots; XFB capture resolves varyings by OpName after this chain, and the
// VS-out/FS-in interface contract on both backends depends on declared outputs
// surviving even when never stored.
optimizer.RegisterPass(CreateAggressiveDCEPass(false));
// Complementary to ADCE, not redundant: ADCE can never delete or delist an
// Output (see above), so never-written outputs are trimmed from the
// OpEntryPoint operand list here.
optimizer.RegisterPass(CreateRemoveUnusedInterfaceVariablesPass());
// The two module-legality repairs, so the chain's output - the bytes every
// consumer downstream sees - is valid Vulkan SPIR-V. Rect lowering used to
// live only in the backends; a validating lane would flag every rectangle
// module long before the backend got the chance to fix it, and the backend
// calls remain as no-ops on the now rect-free modules.
optimizer.RegisterPass(NormalizeRectCoordinatesPass::CreateNormalizeRectCoordinatesPass());
optimizer.RegisterPass(StripUniformLocationsPass::CreateStripUniformLocationsPass());
optimizer.RegisterPass(FlattenInterfaceStructPass::CreateFlattenInterfaceStructPass());
optimizer.RegisterPass(RenameSamplerFunctionParameterPass::CreateRenameSamplerFunctionParameterPass());
optimizer.RegisterPass(
@@ -371,104 +576,88 @@ namespace MobileGL {
optimizer.RegisterPass(EliminateFloatEqualsZeroPass::CreateEliminateFloatEqualsZeroPass());
optimizer.RegisterPass(DecomposeWorkgroupVec3Pass::CreateDecomposeWorkgroupVec3Pass());
return optimizer.Run(inputBinary.data(), inputBinary.size(), &outputBinary, options);
return RunOptimizerChecked("SanitizeAndOptimizeBinary", optimizer, inputBinary,
outputBinary);
}
bool ShaderCompiler::LowerDrawParametersForEssl(const Vector<Uint32>& inputBinary,
Vector<uint32_t>& outputBinary) {
using namespace spvtools;
OptimizerOptions options;
options.set_run_validator(false);
Optimizer optimizer(SPV_ENV_VULKAN_1_1);
optimizer.RegisterPass(LowerDrawParametersPass::CreateLowerDrawParametersPass());
return optimizer.Run(inputBinary.data(), inputBinary.size(), &outputBinary, options);
return RunOptimizerChecked("LowerDrawParametersForEssl", optimizer, inputBinary,
outputBinary);
}
bool ShaderCompiler::PackDoubleVertexInputsForVulkan(const Vector<Uint32>& inputBinary,
Vector<uint32_t>& outputBinary) {
using namespace spvtools;
OptimizerOptions options;
options.set_run_validator(false);
Optimizer optimizer(SPV_ENV_VULKAN_1_1);
optimizer.RegisterPass(PackDoubleVertexInputsPass::CreatePackDoubleVertexInputsPass());
return optimizer.Run(inputBinary.data(), inputBinary.size(), &outputBinary, options);
return RunOptimizerChecked("PackDoubleVertexInputsForVulkan", optimizer, inputBinary,
outputBinary);
}
bool ShaderCompiler::StripUboMemberRelaxedPrecisionForEssl(const Vector<Uint32>& inputBinary,
Vector<uint32_t>& outputBinary) {
using namespace spvtools;
OptimizerOptions options;
options.set_run_validator(false);
Optimizer optimizer(SPV_ENV_VULKAN_1_1);
optimizer.RegisterPass(
StripUboMemberRelaxedPrecisionPass::CreateStripUboMemberRelaxedPrecisionPass());
return optimizer.Run(inputBinary.data(), inputBinary.size(), &outputBinary, options);
return RunOptimizerChecked("StripUboMemberRelaxedPrecisionForEssl", optimizer,
inputBinary, outputBinary);
}
bool ShaderCompiler::StripNoPerspectiveForEssl(const Vector<Uint32>& inputBinary,
Vector<uint32_t>& outputBinary) {
using namespace spvtools;
OptimizerOptions options;
options.set_run_validator(false);
Optimizer optimizer(SPV_ENV_VULKAN_1_1);
optimizer.RegisterPass(StripNoPerspectivePass::CreateStripNoPerspectivePass());
return optimizer.Run(inputBinary.data(), inputBinary.size(), &outputBinary, options);
return RunOptimizerChecked("StripNoPerspectiveForEssl", optimizer, inputBinary,
outputBinary);
}
bool ShaderCompiler::EmulateNoPerspectiveForEssl(const Vector<Uint32>& inputBinary,
Vector<uint32_t>& outputBinary) {
using namespace spvtools;
OptimizerOptions options;
options.set_run_validator(false);
Optimizer optimizer(SPV_ENV_VULKAN_1_1);
optimizer.RegisterPass(EmulateNoPerspectivePass::CreateEmulateNoPerspectivePass());
return optimizer.Run(inputBinary.data(), inputBinary.size(), &outputBinary, options);
return RunOptimizerChecked("EmulateNoPerspectiveForEssl", optimizer, inputBinary,
outputBinary);
}
bool ShaderCompiler::LowerRectImages(const Vector<Uint32>& inputBinary,
Vector<uint32_t>& outputBinary) {
using namespace spvtools;
OptimizerOptions options;
options.set_run_validator(false);
Optimizer optimizer(SPV_ENV_VULKAN_1_1);
optimizer.RegisterPass(NormalizeRectCoordinatesPass::CreateNormalizeRectCoordinatesPass());
return optimizer.Run(inputBinary.data(), inputBinary.size(), &outputBinary, options);
return RunOptimizerChecked("LowerRectImages", optimizer, inputBinary, outputBinary);
}
bool ShaderCompiler::RebaseInstanceIndexForVulkan(const Vector<Uint32>& inputBinary,
Vector<uint32_t>& outputBinary) {
using namespace spvtools;
OptimizerOptions options;
options.set_run_validator(false);
Optimizer optimizer(SPV_ENV_VULKAN_1_1);
optimizer.RegisterPass(RebaseInstanceIndexPass::CreateRebaseInstanceIndexPass());
return optimizer.Run(inputBinary.data(), inputBinary.size(), &outputBinary, options);
return RunOptimizerChecked("RebaseInstanceIndexForVulkan", optimizer, inputBinary,
outputBinary);
}
bool ShaderCompiler::DecoratePositionInvariantForVulkan(const Vector<Uint32>& inputBinary,
Vector<uint32_t>& outputBinary) {
using namespace spvtools;
OptimizerOptions options;
options.set_run_validator(false);
Optimizer optimizer(SPV_ENV_VULKAN_1_1);
optimizer.RegisterPass(DecoratePositionInvariantPass::CreateDecoratePositionInvariantPass());
return optimizer.Run(inputBinary.data(), inputBinary.size(), &outputBinary, options);
return RunOptimizerChecked("DecoratePositionInvariantForVulkan", optimizer, inputBinary,
outputBinary);
}
bool ShaderCompiler::UseUnformattedFloatStorageImagesForVulkan(
@@ -598,6 +787,9 @@ namespace MobileGL {
}
outputBinary.insert(outputBinary.begin() + static_cast<std::ptrdiff_t>(capabilityInsertOffset),
addedCapabilities.begin(), addedCapabilities.end());
// Hand-rolled word walk, so no Optimizer wrapper ever sees this rewrite;
// check the modified module explicitly in validating lanes.
ValidateOrLatch("UseUnformattedFloatStorageImagesForVulkan", outputBinary);
return true;
}
@@ -101,6 +101,27 @@ namespace MobileGL {
// it, the second eglInitialize of a process comes back up unwarmed and with
// no way left to warm it.
static void ResetPrewarmLatch();
// Test-environment SPIR-V validation. When enabled, every Optimizer wrapper
// in this file validates its OUTPUT binary - the bytes a driver can actually
// receive - and a failure logs the VUID (via MGLOG_I; see the consumer for
// why not MGLOG_E) and bumps the failure latch below WITHOUT changing the
// wrapper's return value: control flow must stay identical between the
// validating and shipping configurations, or fail-open call sites would make
// the two render differently. Resolved lazily from MOBILEGL_VALIDATE_SPIRV;
// defaults on for desktop/CI/WSL builds and off for device (__ANDROID__)
// builds. The setter wins over the environment and is safe to call from test
// fixtures at any time.
static bool SpirvValidationEnabled();
static void SetSpirvValidationEnabled(bool enabled);
// The test-lane enforcement signal: total validation failures observed this
// process. Tests snapshot it, run the operation under scrutiny, and assert
// on the delta. NoteSpirvValidationFailure is for validation done outside
// this file (ProgramFactory::ValidateTransformedSpirv); it returns the new
// total.
static Uint64 SpirvValidationFailureCount();
static Uint64 NoteSpirvValidationFailure();
};
} // namespace ShaderTranspiler
} // namespace MG_Util
@@ -0,0 +1,250 @@
// MobileGL - MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/PrivateToEntryLocalPass.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
//
// Derived from SPIRV-Tools' PrivateToLocalPass (source/opt/private_to_local_pass.cpp,
// Copyright (c) 2017 Google Inc., Apache License 2.0). The one behavioral difference is
// the entry-point restriction in FindEntryLocalFunction; see the header for why.
#include "PrivateToEntryLocalPass.h"
#include "source/opt/ir_context.h"
#include "source/opt/type_manager.h"
#include "source/spirv_constant.h"
#include "source/util/make_unique.h"
#include <cassert>
#include <utility>
#include <vector>
#include <unordered_set>
namespace MobileGL {
namespace MG_Util {
namespace ShaderTranspiler {
namespace {
using spvtools::opt::BasicBlock;
using spvtools::opt::Function;
using spvtools::opt::Instruction;
using spvtools::opt::Operand;
constexpr uint32_t kVariableStorageClassInIdx = 0;
constexpr uint32_t kSpvTypePointerTypeIdInIdx = 1;
} // namespace
spvtools::opt::Pass::Status PrivateToEntryLocalPass::Process() {
// Private variables require the Shader capability; with Addresses the
// rewrite below is not guaranteed sound (variable pointers may escape).
if (context()->get_feature_mgr()->HasCapability(spv::Capability::Addresses)) {
return Status::SuccessWithoutChange;
}
std::vector<std::pair<Instruction*, Function*>> variablesToMove;
std::unordered_set<uint32_t> localizedVariables;
for (auto& inst : context()->types_values()) {
if (inst.opcode() != spv::Op::OpVariable) {
continue;
}
if (spv::StorageClass(inst.GetSingleWordInOperand(kVariableStorageClassInIdx)) !=
spv::StorageClass::Private) {
continue;
}
Function* targetFunction = FindEntryLocalFunction(inst);
if (targetFunction != nullptr) {
variablesToMove.push_back({&inst, targetFunction});
}
}
const bool modified = !variablesToMove.empty();
for (auto& p : variablesToMove) {
if (!MoveVariable(p.first, p.second)) {
return Status::Failure;
}
localizedVariables.insert(p.first->result_id());
}
if (get_module()->version() >= SPV_SPIRV_VERSION_WORD(1, 4)) {
// SPIR-V 1.4+ lists statically-used Private variables on OpEntryPoint;
// drop the ones that just stopped being Private. Dead code for the 1.3
// modules MobileGL emits, kept for robustness.
for (auto& entry : get_module()->entry_points()) {
std::vector<Operand> newOperands;
for (uint32_t i = 0; i < entry.NumInOperands(); ++i) {
// Execution model, function id and name are always kept.
if (i < 3 || !localizedVariables.count(entry.GetSingleWordInOperand(i))) {
newOperands.push_back(entry.GetInOperand(i));
}
}
if (newOperands.size() != entry.NumInOperands()) {
entry.SetInOperands(std::move(newOperands));
context()->AnalyzeUses(&entry);
}
}
}
return modified ? Status::SuccessWithChange : Status::SuccessWithoutChange;
}
Function* PrivateToEntryLocalPass::FindEntryLocalFunction(const Instruction& inst) const {
bool foundFirstUse = false;
Function* targetFunction = nullptr;
const uint32_t variableId = inst.result_id();
context()->get_def_use_mgr()->ForEachUser(
variableId, [&targetFunction, &foundFirstUse, variableId, this](Instruction* use) {
BasicBlock* currentBlock = context()->get_instr_block(use);
if (currentBlock == nullptr) {
// Module-scope users: OpName, decorations, the OpEntryPoint
// interface list. None of them pins the variable to a function,
// but a debug-info extended instruction would go stale after the
// move, so treat it as disqualifying.
if (use->opcode() == spv::Op::OpExtInst) {
foundFirstUse = true;
targetFunction = nullptr;
}
return;
}
if (!IsValidUse(use, variableId)) {
foundFirstUse = true;
targetFunction = nullptr;
return;
}
Function* currentFunction = currentBlock->GetParent();
if (!foundFirstUse) {
foundFirstUse = true;
targetFunction = currentFunction;
} else if (targetFunction != currentFunction) {
targetFunction = nullptr;
}
});
if (targetFunction != nullptr && !IsEntryPointFunction(targetFunction)) {
// The whole point of this derivative: a helper can be called more than
// once per invocation, and Function storage would reset the variable at
// every call.
return nullptr;
}
return targetFunction;
}
bool PrivateToEntryLocalPass::IsEntryPointFunction(Function* function) const {
for (auto& entry : get_module()->entry_points()) {
if (entry.GetSingleWordInOperand(1) == function->result_id()) {
return true;
}
}
return false;
}
bool PrivateToEntryLocalPass::IsValidUse(const Instruction* inst, uint32_t variableId) const {
// The cases here have to match the cases in UpdateUse: a use the rewrite
// does not know how to update disqualifies the variable.
switch (inst->opcode()) {
case spv::Op::OpLoad:
case spv::Op::OpImageTexelPointer: // treat like a load
return true;
case spv::Op::OpStore:
// Storing the variable's ADDRESS somewhere else escapes it.
return inst->GetOperand(1).AsId() != variableId;
case spv::Op::OpAccessChain:
return context()->get_def_use_mgr()->WhileEachUser(
inst, [this, inst](const Instruction* user) {
return IsValidUse(user, inst->result_id());
});
case spv::Op::OpName:
return true;
default:
return spvOpcodeIsDecoration(inst->opcode());
}
}
bool PrivateToEntryLocalPass::MoveVariable(Instruction* variable, Function* function) {
// Remove from the global section and re-insert at the head of the entry
// function's first block, Function-storage variables' one legal position.
variable->RemoveFromList();
std::unique_ptr<Instruction> var(variable); // take ownership
context()->ForgetUses(variable);
variable->SetInOperand(kVariableStorageClassInIdx,
{uint32_t(spv::StorageClass::Function)});
const uint32_t newTypeId = GetNewType(variable->type_id());
if (newTypeId == 0) {
return false;
}
variable->SetResultType(newTypeId);
context()->AnalyzeUses(variable);
context()->set_instr_block(variable, &*function->begin());
function->begin()->begin()->InsertBefore(std::move(var));
return UpdateUses(variable);
}
uint32_t PrivateToEntryLocalPass::GetNewType(uint32_t oldTypeId) {
auto* typeMgr = context()->get_type_mgr();
Instruction* oldTypeInst = get_def_use_mgr()->GetDef(oldTypeId);
const uint32_t pointeeTypeId =
oldTypeInst->GetSingleWordInOperand(kSpvTypePointerTypeIdInIdx);
const uint32_t newTypeId =
typeMgr->FindPointerToType(pointeeTypeId, spv::StorageClass::Function);
if (newTypeId != 0) {
context()->UpdateDefUse(context()->get_def_use_mgr()->GetDef(newTypeId));
}
return newTypeId;
}
bool PrivateToEntryLocalPass::UpdateUse(Instruction* inst, Instruction* user) {
// The cases here have to match the cases in IsValidUse.
switch (inst->opcode()) {
case spv::Op::OpLoad:
case spv::Op::OpStore:
case spv::Op::OpImageTexelPointer: // treat like a load
// Fine as-is: their type is the pointed-to type, which is unchanged.
break;
case spv::Op::OpAccessChain: {
context()->ForgetUses(inst);
const uint32_t newTypeId = GetNewType(inst->type_id());
if (newTypeId == 0) {
return false;
}
inst->SetResultType(newTypeId);
context()->AnalyzeUses(inst);
if (!UpdateUses(inst)) {
return false;
}
break;
}
case spv::Op::OpName:
case spv::Op::OpEntryPoint: // handled separately in Process()
break;
default:
assert(spvOpcodeIsDecoration(inst->opcode()) &&
"PrivateToEntryLocalPass: unexpected use opcode");
break;
}
(void)user;
return true;
}
bool PrivateToEntryLocalPass::UpdateUses(Instruction* inst) {
const uint32_t id = inst->result_id();
std::vector<Instruction*> uses;
context()->get_def_use_mgr()->ForEachUser(id,
[&uses](Instruction* use) { uses.push_back(use); });
for (Instruction* use : uses) {
if (!UpdateUse(use, inst)) {
return false;
}
}
return true;
}
spvtools::Optimizer::PassToken PrivateToEntryLocalPass::CreatePrivateToEntryLocalPass() {
return spvtools::Optimizer::PassToken(
spvtools::MakeUnique<PrivateToEntryLocalPass>());
}
} // namespace ShaderTranspiler
} // namespace MG_Util
} // namespace MobileGL
@@ -0,0 +1,55 @@
// MobileGL - MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/PrivateToEntryLocalPass.h
// 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
#pragma once
#include "spirv-tools/optimizer.hpp"
#include "source/opt/pass.h"
namespace MobileGL {
namespace MG_Util {
namespace ShaderTranspiler {
// AggressiveDCE treats every store to a Private global as an observable side
// effect while the entry point still contains any OpFunctionCall, so a dead
// vertex-input -> Private-shim chain (Iris rewrites unused legacy attributes
// into exactly this shape) survives the whole optimizer chain. Rewriting such
// a variable to Function storage unlocks ADCE without inlining anything.
//
// Upstream's PrivateToLocalPass does that rewrite for a Private variable used
// in ANY single function - which is unsound here: a Function-storage variable
// is recreated on every call, so a Private global that carries state across
// repeated calls of one helper (a memoized init flag, LCG rand state) would
// silently lose it. This derivative applies the same rewrite restricted to
// variables whose only using function is an entry point: an entry point runs
// once per invocation, so the two lifetimes are indistinguishable there.
//
// Derived from SPIRV-Tools' PrivateToLocalPass
// (source/opt/private_to_local_pass.cpp, Copyright (c) 2017 Google Inc.,
// Apache License 2.0).
class PrivateToEntryLocalPass final : public spvtools::opt::Pass {
public:
const char* name() const override { return "mobilegl-private-to-entry-local"; }
Status Process() override;
static spvtools::Optimizer::PassToken CreatePrivateToEntryLocalPass();
private:
// The single entry-point function every block-level use of the variable
// lives in, or nullptr when the uses span functions, include an opcode the
// rewrite cannot update, or belong to a non-entry function.
spvtools::opt::Function* FindEntryLocalFunction(const spvtools::opt::Instruction& inst) const;
bool IsEntryPointFunction(spvtools::opt::Function* function) const;
bool IsValidUse(const spvtools::opt::Instruction* inst, uint32_t variableId) const;
bool MoveVariable(spvtools::opt::Instruction* variable, spvtools::opt::Function* function);
uint32_t GetNewType(uint32_t oldTypeId);
bool UpdateUse(spvtools::opt::Instruction* inst, spvtools::opt::Instruction* user);
bool UpdateUses(spvtools::opt::Instruction* inst);
};
} // namespace ShaderTranspiler
} // namespace MG_Util
} // namespace MobileGL
@@ -0,0 +1,59 @@
// MobileGL - MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/StripUniformLocationsPass.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
#include "StripUniformLocationsPass.h"
#include "source/opt/ir_context.h"
#include "source/util/make_unique.h"
#include <vector>
namespace MobileGL {
namespace MG_Util {
namespace ShaderTranspiler {
spvtools::opt::Pass::Status StripUniformLocationsPass::Process() {
using spvtools::opt::Instruction;
std::vector<Instruction*> toKill;
for (auto& annotation : get_module()->annotations()) {
if (annotation.opcode() != spv::Op::OpDecorate) {
continue;
}
if (annotation.GetSingleWordInOperand(1) !=
static_cast<uint32_t>(spv::Decoration::Location)) {
continue;
}
Instruction* target =
get_def_use_mgr()->GetDef(annotation.GetSingleWordInOperand(0));
if (target == nullptr || target->opcode() != spv::Op::OpVariable) {
continue;
}
switch (spv::StorageClass(target->GetSingleWordInOperand(0))) {
case spv::StorageClass::UniformConstant:
case spv::StorageClass::Uniform:
case spv::StorageClass::StorageBuffer:
toKill.push_back(&annotation);
break;
default:
break;
}
}
for (Instruction* inst : toKill) {
context()->KillInst(inst);
}
return toKill.empty() ? Status::SuccessWithoutChange : Status::SuccessWithChange;
}
spvtools::Optimizer::PassToken StripUniformLocationsPass::CreateStripUniformLocationsPass() {
return spvtools::Optimizer::PassToken(
spvtools::MakeUnique<StripUniformLocationsPass>());
}
} // namespace ShaderTranspiler
} // namespace MG_Util
} // namespace MobileGL
@@ -0,0 +1,32 @@
// MobileGL - MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/StripUniformLocationsPass.h
// 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
#pragma once
#include "spirv-tools/optimizer.hpp"
#include "source/opt/pass.h"
namespace MobileGL {
namespace MG_Util {
namespace ShaderTranspiler {
// glslang's relaxed GL path keeps `layout(location = N) uniform ...` as a
// Location decoration on the UniformConstant/Uniform variable, which Vulkan
// forbids ([VUID-StandaloneSpirv-Location-06672]). Nothing downstream reads
// it: GL-side uniform locations come from the phase-A glslang reflection,
// Vulkan binding assignment goes by (kind, name), and SPIRV-Cross's ESSL
// resolves uniforms by name. Strip it so the driver-bound module is valid.
class StripUniformLocationsPass final : public spvtools::opt::Pass {
public:
const char* name() const override { return "mobilegl-strip-uniform-locations"; }
Status Process() override;
static spvtools::Optimizer::PassToken CreateStripUniformLocationsPass();
};
} // namespace ShaderTranspiler
} // namespace MG_Util
} // namespace MobileGL
@@ -131,6 +131,11 @@ bool LoadMobileGL(const Request& request, std::string& error) {
setenv("MOBILEGL_BACKEND_TYPE", request.backend.c_str(), 1);
setenv("MOBILEGL_TRACE_LIBRARY", request.mobileGlLibrary.c_str(), 1);
setenv("MOBILEGL_TRACE_SKIP_AUTODESTROY", "1", 1);
// Retrace is a test lane on every platform, including the Android AVD one where
// MobileGL's __ANDROID__ default would leave validation off. No overwrite: an outer
// MOBILEGL_VALIDATE_SPIRV=0 must keep working as the escape hatch, and retracing the
// exact shipping pipeline must stay possible.
setenv("MOBILEGL_VALIDATE_SPIRV", "1", 0);
setenv("MOBILEGL_TRACE_SURFACE", request.usePbuffer ? "pbuffer" : "window", 1);
if (request.backend == "DirectVulkan") {
setenv("MOBILEGL_MAGMA_R11G11B10F_FALLBACK", "1", 1);