From b219992ee37b6a188ab99ad925f9d459bc8da50c Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Mon, 10 Aug 2026 13:00:20 -0400 Subject: [PATCH] [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 --- CMakeLists.txt | 2 + MobileGL/Config.h | 5 + .../DirectVulkan/Renderer/ProgramFactory.cpp | 34 +- MobileGL/MG_Test/Program/ProgramUtilTest.cpp | 320 ++++++++++++++++++ MobileGL/MG_Util/Async/ShaderCompilePool.cpp | 10 +- MobileGL/MG_Util/Async/ShaderCompilePool.h | 7 + .../ShaderTranspiler/ShaderCompiler.cpp | 264 +++++++++++++-- .../MG_Util/ShaderTranspiler/ShaderCompiler.h | 21 ++ .../SpirvPasses/PrivateToEntryLocalPass.cpp | 250 ++++++++++++++ .../SpirvPasses/PrivateToEntryLocalPass.h | 55 +++ .../SpirvPasses/StripUniformLocationsPass.cpp | 59 ++++ .../SpirvPasses/StripUniformLocationsPass.h | 32 ++ .../app/src/trace/cpp/trace_replay_core.cpp | 5 + 13 files changed, 1021 insertions(+), 43 deletions(-) create mode 100644 MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/PrivateToEntryLocalPass.cpp create mode 100644 MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/PrivateToEntryLocalPass.h create mode 100644 MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/StripUniformLocationsPass.cpp create mode 100644 MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/StripUniformLocationsPass.h diff --git a/CMakeLists.txt b/CMakeLists.txt index b54bb624..58d3fe3d 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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 diff --git a/MobileGL/Config.h b/MobileGL/Config.h index a84c072a..88949a94 100644 --- a/MobileGL/Config.h +++ b/MobileGL/Config.h @@ -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; diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/ProgramFactory.cpp b/MobileGL/MG_Backend/DirectVulkan/Renderer/ProgramFactory.cpp index 5cdd71ff..bfb89d13 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/ProgramFactory.cpp +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/ProgramFactory.cpp @@ -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(shaderStage), + programExternalIndex, + static_cast(result), + diagnostic != nullptr ? diagnostic->position.index : 0, + diagnostic != nullptr && diagnostic->error != nullptr ? diagnostic->error : ""); + } 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}; diff --git a/MobileGL/MG_Test/Program/ProgramUtilTest.cpp b/MobileGL/MG_Test/Program/ProgramUtilTest.cpp index edac6f91..0c2025ac 100644 --- a/MobileGL/MG_Test/Program/ProgramUtilTest.cpp +++ b/MobileGL/MG_Test/Program/ProgramUtilTest.cpp @@ -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& 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 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 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 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 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 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 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 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(i), + raw.begin() + static_cast(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 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& 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& spirv) { + constexpr unsigned kOpDecorate = 71, kOpVariable = 59, kDecorationLocation = 30; + constexpr unsigned kStorageUniformConstant = 0, kStorageUniform = 2; + std::set 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 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 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 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 optimized; + ASSERT_TRUE(ShaderCompiler::SanitizeAndOptimizeBinary(raw, optimized)); + EXPECT_FALSE(AnyLocationOnUniformStorage(optimized)); + EXPECT_EQ(ShaderCompiler::SpirvValidationFailureCount(), failuresBefore) + << "the stripped module must validate clean"; +} diff --git a/MobileGL/MG_Util/Async/ShaderCompilePool.cpp b/MobileGL/MG_Util/Async/ShaderCompilePool.cpp index 31201879..315f1734 100644 --- a/MobileGL/MG_Util/Async/ShaderCompilePool.cpp +++ b/MobileGL/MG_Util/Async/ShaderCompilePool.cpp @@ -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 diff --git a/MobileGL/MG_Util/Async/ShaderCompilePool.h b/MobileGL/MG_Util/Async/ShaderCompilePool.h index 24a5f1c7..0452a1a4 100644 --- a/MobileGL/MG_Util/Async/ShaderCompilePool.h +++ b/MobileGL/MG_Util/Async/ShaderCompilePool.h @@ -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; diff --git a/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.cpp b/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.cpp index 095fa235..a5392127 100644 --- a/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.cpp +++ b/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.cpp @@ -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 +#include #include #include +#include +#include #include +#include 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 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 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(std::tolower(static_cast(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 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& 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( + 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& inputBinary, + Vector& 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& inputBinary, Vector& 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& inputBinary, Vector& 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& inputBinary, Vector& 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& inputBinary, Vector& 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& inputBinary, Vector& 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& inputBinary, Vector& 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& inputBinary, Vector& 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& inputBinary, Vector& 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& inputBinary, Vector& 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(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; } diff --git a/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.h b/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.h index bd1a5964..895c20ce 100644 --- a/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.h +++ b/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.h @@ -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 diff --git a/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/PrivateToEntryLocalPass.cpp b/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/PrivateToEntryLocalPass.cpp new file mode 100644 index 00000000..74196cf4 --- /dev/null +++ b/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/PrivateToEntryLocalPass.cpp @@ -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 +#include +#include +#include + +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> variablesToMove; + std::unordered_set 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 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 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 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()); + } + } // namespace ShaderTranspiler + } // namespace MG_Util +} // namespace MobileGL diff --git a/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/PrivateToEntryLocalPass.h b/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/PrivateToEntryLocalPass.h new file mode 100644 index 00000000..556a3275 --- /dev/null +++ b/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/PrivateToEntryLocalPass.h @@ -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 diff --git a/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/StripUniformLocationsPass.cpp b/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/StripUniformLocationsPass.cpp new file mode 100644 index 00000000..6469f943 --- /dev/null +++ b/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/StripUniformLocationsPass.cpp @@ -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 + +namespace MobileGL { + namespace MG_Util { + namespace ShaderTranspiler { + spvtools::opt::Pass::Status StripUniformLocationsPass::Process() { + using spvtools::opt::Instruction; + + std::vector toKill; + for (auto& annotation : get_module()->annotations()) { + if (annotation.opcode() != spv::Op::OpDecorate) { + continue; + } + if (annotation.GetSingleWordInOperand(1) != + static_cast(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()); + } + } // namespace ShaderTranspiler + } // namespace MG_Util +} // namespace MobileGL diff --git a/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/StripUniformLocationsPass.h b/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/StripUniformLocationsPass.h new file mode 100644 index 00000000..53892ba1 --- /dev/null +++ b/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/StripUniformLocationsPass.h @@ -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 diff --git a/android-plugin/app/src/trace/cpp/trace_replay_core.cpp b/android-plugin/app/src/trace/cpp/trace_replay_core.cpp index 9ecde6fc..6add5697 100644 --- a/android-plugin/app/src/trace/cpp/trace_replay_core.cpp +++ b/android-plugin/app/src/trace/cpp/trace_replay_core.cpp @@ -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);