From bfa7987ca6a9cf043f7c4b1860b8783e89ace2ff Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Fri, 2 Oct 2026 23:09:58 -0400 Subject: [PATCH] [Fix] (Magma): decline programs whose final SPIR-V fails validation instead of handing the module to the driver --- MobileGL/Config.h | 5 + MobileGL/ConfigLoader.cpp | 1 + .../DirectVulkan/Renderer/PipelineFactory.cpp | 9 + .../DirectVulkan/Renderer/ProgramFactory.cpp | 127 ++++++++++-- .../DirectVulkan/Renderer/ProgramFactory.h | 44 +++++ .../DirectVulkan/Renderer/VulkanRenderer.cpp | 8 +- .../DirectVulkan/Renderer/WireDeclines.def | 8 + .../DirectVulkan/Renderer/WireDraw.inc | 12 ++ MobileGL/MG_Test/Pipeline/CMakeLists.txt | 1 + .../Pipeline/DriverModuleScreenTest.cpp | 183 ++++++++++++++++++ README.md | 1 + 11 files changed, 380 insertions(+), 19 deletions(-) create mode 100644 MobileGL/MG_Test/Pipeline/DriverModuleScreenTest.cpp diff --git a/MobileGL/Config.h b/MobileGL/Config.h index c47ce30f..c28548d4 100644 --- a/MobileGL/Config.h +++ b/MobileGL/Config.h @@ -130,6 +130,11 @@ namespace MobileGL::MG_Config { Bool AdvertiseFp64 = false; // MOBILEGL_MAGMA_R11G11B10F_FALLBACK: use fallback format for R11G11B10F on Vulkan. Bool MagmaR11G11B10FFallback = false; + // MOBILEGL_MAGMA_ALLOW_INVALID_SPIRV: hand a shader module that fails SPIR-V validation to + // the driver anyway. Off by default: Magma declines such a program (no shader module, its + // draws refused by name), because a driver that accepts invalid SPIR-V may fault the GPU + // into device loss. For diagnosing a driver that copes with a specific module only. + Bool MagmaAllowInvalidSpirv = false; // MOBILEGL_MAGMA_FRAMESINFLIGHT: requested Magma frames in flight, defaulting to 3. Uint32 MagmaFramesInFlight = 3; // MOBILEGL_MAGMA_MAX_DRAWS_PER_COMMAND_BUFFER: GL draws and dispatches Magma records into diff --git a/MobileGL/ConfigLoader.cpp b/MobileGL/ConfigLoader.cpp index fd554e3a..4809329c 100644 --- a/MobileGL/ConfigLoader.cpp +++ b/MobileGL/ConfigLoader.cpp @@ -348,6 +348,7 @@ namespace MobileGL::MG_ConfigLoader { features.MagmaDeriveNumSubgroups = QueryEnvQuirkOverride("MOBILEGL_MAGMA_DERIVE_NUM_SUBGROUPS"); features.AdvertiseFp64 = QueryEnvFlag("MOBILEGL_ADVERTISE_FP64"); features.MagmaR11G11B10FFallback = QueryEnvFlag("MOBILEGL_MAGMA_R11G11B10F_FALLBACK"); + features.MagmaAllowInvalidSpirv = QueryEnvFlag("MOBILEGL_MAGMA_ALLOW_INVALID_SPIRV"); features.MagmaFramesInFlight = QueryEnvUint32("MOBILEGL_MAGMA_FRAMESINFLIGHT", 3, 1, 64); features.MagmaMaxDrawsPerCommandBuffer = QueryEnvUint32("MOBILEGL_MAGMA_MAX_DRAWS_PER_COMMAND_BUFFER", 16384, 0, 1u << 24); diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/PipelineFactory.cpp b/MobileGL/MG_Backend/DirectVulkan/Renderer/PipelineFactory.cpp index b8a9a9f0..faacc799 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/PipelineFactory.cpp +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/PipelineFactory.cpp @@ -391,6 +391,15 @@ namespace MobileGL::MG_Backend::DirectVulkan { VkPipeline PipelineFactory::CreatePipeline(const PipelineCreatePayload& payload) const { MOBILEGL_ASSERT(payload.stages != nullptr && !payload.stages->empty(), "PipelineFactory: stages are empty"); + // The assert compiles out of shipping builds, and a program declined for an invalid + // module (VkProgramObject::invalidSpirvDeclined) has no stages at all: a caller that + // forgot to refuse it must get a skipped draw, not a stageCount=0 pipeline. + if (payload.stages == nullptr || payload.stages->empty()) { + MGLOG_E_ONCE("PipelineFactory::CreatePipeline: refusing a pipeline with no shader stages " + "(programHash=0x%llx); its draws are skipped", + static_cast(payload.programHash)); + return VK_NULL_HANDLE; + } MOBILEGL_ASSERT(payload.vertexInputState != nullptr, "PipelineFactory: vertexInputState is null"); MOBILEGL_ASSERT(payload.pipelineLayout != VK_NULL_HANDLE, "PipelineFactory: pipelineLayout is null"); MOBILEGL_ASSERT(payload.renderPass != VK_NULL_HANDLE, "PipelineFactory: renderPass is null"); diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/ProgramFactory.cpp b/MobileGL/MG_Backend/DirectVulkan/Renderer/ProgramFactory.cpp index 9ca6539e..54a5db70 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/ProgramFactory.cpp +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/ProgramFactory.cpp @@ -12,7 +12,9 @@ #include "MG_Util/ShaderTranspiler/ShaderCompiler.h" #include "MG_Util/ShaderTranspiler/SpvcSession.h" #include "MG_Util/ShaderTranspiler/Types.h" +#include #include +#include #include #include #include @@ -2273,8 +2275,84 @@ namespace MobileGL::MG_Backend::DirectVulkan { return false; } } + + // ScreenModuleForDriver's memory. Keyed by content, never by program: the same bytes get + // the same answer whichever program, variant or session carries them. + struct DriverModuleVerdictCache { + struct Entry { + Uint64 wordCount = 0; + Bool valid = true; + String reason; + }; + // Generous for any one application's distinct stage modules; past it the whole table + // is dropped rather than tracked per entry - a re-validation is the only cost. + static constexpr SizeT kMaxEntries = 8192; + std::mutex mutex; + UnorderedMap entries; + std::atomic validatorRuns{0}; + }; + + DriverModuleVerdictCache& GetDriverModuleVerdictCache() { + // Leaked on purpose: screened from session threads that may outlive static destruction. + static auto* cache = new DriverModuleVerdictCache(); + return *cache; + } } // namespace + ProgramFactory::DriverModuleVerdict ProgramFactory::ScreenModuleForDriver(const Vector& spirv, + Bool allowInvalid, String* outReason) { + if (outReason != nullptr) outReason->clear(); + if (spirv.empty()) return DriverModuleVerdict::Valid; + + auto& cache = GetDriverModuleVerdictCache(); + const Uint64 key = XXH64(spirv.data(), spirv.size() * sizeof(Uint), 0); + Bool valid = true; + String reason; + Bool known = false; + { + const std::lock_guard lock(cache.mutex); + const auto it = cache.entries.find(key); + if (it != cache.entries.end() && it->second.wordCount == spirv.size()) { + known = true; + valid = it->second.valid; + reason = it->second.reason; + } + } + if (!known) { + // Outside the lock: two threads screening the same new module both validate, and + // agree; nothing waits on another session's spirv-val. + MG_Util::ShaderTranspiler::ShaderCompiler::PrepareSpirvValidation(); + cache.validatorRuns.fetch_add(1, std::memory_order_relaxed); + spv_context context = spvContextCreate(GetSpirvTargetEnv(spirv)); + spv_validator_options options = spvValidatorOptionsCreate(); + spvValidatorOptionsSetFriendlyNames(options, true); + spv_const_binary_t binary = {spirv.data(), spirv.size()}; + spv_diagnostic diagnostic = nullptr; + const spv_result_t result = spvValidateWithOptions(context, options, &binary, &diagnostic); + valid = result == SPV_SUCCESS; + if (!valid) { + reason = diagnostic != nullptr && diagnostic->error != nullptr ? diagnostic->error : ""; + // The process-wide counter the validating test lanes assert on. + MG_Util::ShaderTranspiler::ShaderCompiler::NoteSpirvValidationFailure(); + } + spvDiagnosticDestroy(diagnostic); + spvValidatorOptionsDestroy(options); + spvContextDestroy(context); + + const std::lock_guard lock(cache.mutex); + if (cache.entries.size() >= DriverModuleVerdictCache::kMaxEntries) cache.entries.clear(); + cache.entries[key] = DriverModuleVerdictCache::Entry{spirv.size(), valid, reason}; + } + + if (valid) return DriverModuleVerdict::Valid; + if (outReason != nullptr) *outReason = Move(reason); + return allowInvalid ? DriverModuleVerdict::AdmittedInvalid : DriverModuleVerdict::DeclinedInvalid; + } + + Uint64 ProgramFactory::DriverModuleValidatorRuns() { + return GetDriverModuleVerdictCache().validatorRuns.load(std::memory_order_relaxed); + } + // A shader that assigns gl_FragDepth (SPIR-V DepthReplacing) supplies depth itself // instead of taking the pipeline's interpolated Z, so a driver that varies the vertex // position math between pipelines cannot desynchronize it; the blended depth-write @@ -3814,27 +3892,42 @@ namespace MobileGL::MG_Backend::DirectVulkan { const Bool remapOk = RemapDescriptorBindingsForVulkan(moduleSpirvs, m_maxBindings, moduleSpirvs); MOBILEGL_ASSERT(remapOk, "ProgramFactory::GetOrCreateProgram: descriptor binding remap failed"); + // Last look at the exact bytes the driver receives, in EVERY build rather than only in + // DEBUG or with MOBILEGL_ENABLE_SPIRV_VALIDATION armed, and for every stage BEFORE any + // module is created. By here the descriptor bindings have been remapped and the layout + // about to be reflected describes the remapped module, so there is no module left that + // is both valid and consistent with it to fall back to (the recovery that exists lives + // one step earlier, at the clip/XFB fixups). What is left is to keep the bytes away from + // the driver: a driver is not a validating entry point, and an invalid module it accepts + // can fault the GPU into device loss (a frontend translation once put a sampler inside + // the default uniform block, and Adreno page-faulted reading its "descriptor" out of + // uniform-buffer bytes). One invalid stage declines the whole program: no shader module + // of it is created, and every draw or dispatch of it is refused by name + // (VkProgramObject::invalidSpirvDeclined). MOBILEGL_MAGMA_ALLOW_INVALID_SPIRV hands the + // bytes over anyway, for diagnosing a driver that copes. for (SizeT i = 0; i < moduleCount; ++i) { + String reason; + const DriverModuleVerdict verdict = ScreenModuleForDriver( + moduleSpirvs[i], MG_Config::Features.MagmaAllowInvalidSpirv, &reason); + if (verdict == DriverModuleVerdict::DeclinedInvalid) { + MGLOG_E_ONCE("ProgramFactory: declining program %u - its stage %d module fails SPIR-V validation (%s); " + "no shader module is created and its draws are refused. " + "MOBILEGL_MAGMA_ALLOW_INVALID_SPIRV=1 hands such modules to the driver anyway.", + program.GetExternalIndex(), static_cast(stages[i]), reason.c_str()); + entry.invalidSpirvDeclined = true; + break; + } + if (verdict == DriverModuleVerdict::AdmittedInvalid) { + MGLOG_E_ONCE("ProgramFactory: handing vkCreateShaderModule an INVALID module for program %u stage %d " + "because MOBILEGL_MAGMA_ALLOW_INVALID_SPIRV is set (%s)", + program.GetExternalIndex(), static_cast(stages[i]), reason.c_str()); + } + } + + for (SizeT i = 0; i < moduleCount && !entry.invalidSpirvDeclined; ++i) { auto& moduleSpv = moduleSpirvs[i]; if (moduleSpv.empty()) continue; - // Last look at the exact bytes the driver receives, in EVERY build rather than only - // in DEBUG or with MOBILEGL_ENABLE_SPIRV_VALIDATION armed. This one only reports: - // by here the descriptor bindings have been remapped and the layout about to be - // reflected describes the remapped module, so there is no module left that is both - // valid and consistent with it to fall back to. The recovery lives one step earlier, - // at the clip/XFB fixups (see the revert there) - which is where a transform can - // introduce a reference to a delisted interface variable, the failure this whole - // guard exists for. Anything that reaches this line names itself in the log of a - // shipping build instead of dying anonymously inside the driver. - SpirvValidationFailure finalFailure{}; - if (!ValidateTransformedSpirv(moduleSpv, stages[i], program.GetExternalIndex(), &finalFailure)) { - MGLOG_E_ONCE("ProgramFactory: handing vkCreateShaderModule an INVALID module for program %u stage %d - " - "a backend transform after the clip/XFB fixups broke it (%s)", - program.GetExternalIndex(), static_cast(stages[i]), - finalFailure.message.c_str()); - } - // Does the stage the driver will treat as the last pre-rasterization one actually // carry Xfb? Asked of the FINAL bytes, so it answers for whatever the whole transform // chain produced - a rewound clip/XFB backstop, a capture pass that resolved no diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/ProgramFactory.h b/MobileGL/MG_Backend/DirectVulkan/Renderer/ProgramFactory.h index 5858a485..eae37ffd 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/ProgramFactory.h +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/ProgramFactory.h @@ -229,6 +229,11 @@ namespace MobileGL::MG_Backend::DirectVulkan { // the cache entry, so every later draw of the same program variant reads the same // answer instead of re-deciding it. Bool pointSizeCapabilityUnsupported = false; + // One of the program's final modules fails SPIR-V validation, so none of its modules + // was created and every draw or dispatch of it is refused by name (the ProgramInvalidSpirv / + // ComputeProgramInvalidSpirv wire declines) - see ProgramFactory::ScreenModuleForDriver. + // Sticky like the flag above: it lives on the cache entry, so the one log line is honest. + Bool invalidSpirvDeclined = false; Bool needsPassthroughTessControl = false; // ...and the pass-through this renderer can synthesize carries gl_Position and // nothing else, so it is only correct when the evaluation stage's inputs are @@ -289,6 +294,11 @@ namespace MobileGL::MG_Backend::DirectVulkan { storageBlockIndexByBinding = std::move(other.storageBlockIndexByBinding); hasStorageImages = other.hasStorageImages; declinedDescriptors = other.declinedDescriptors; + // The three refusal flags travel like every other field: a robin-hood insert swaps + // entries, and a flag left behind would refuse (or admit) the wrong program. + invalidSpirvDeclined = other.invalidSpirvDeclined; + xfbCaptureDeclined = other.xfbCaptureDeclined; + pointSizeCapabilityUnsupported = other.pointSizeCapabilityUnsupported; globalUboBinding = other.globalUboBinding; activeVertexInputLocationMask = other.activeVertexInputLocationMask; vertexInputTypes = other.vertexInputTypes; @@ -310,6 +320,9 @@ namespace MobileGL::MG_Backend::DirectVulkan { other.pipelineLayout = VK_NULL_HANDLE; other.hasStorageImages = false; other.declinedDescriptors = false; + other.invalidSpirvDeclined = false; + other.xfbCaptureDeclined = false; + other.pointSizeCapabilityUnsupported = false; other.globalUboBinding = -1; other.activeVertexInputLocationMask = 0; other.activeFragmentOutputLocationMask = 0; @@ -353,6 +366,11 @@ namespace MobileGL::MG_Backend::DirectVulkan { storageBlockIndexByBinding = std::move(other.storageBlockIndexByBinding); hasStorageImages = other.hasStorageImages; declinedDescriptors = other.declinedDescriptors; + // The three refusal flags travel like every other field: a robin-hood insert swaps + // entries, and a flag left behind would refuse (or admit) the wrong program. + invalidSpirvDeclined = other.invalidSpirvDeclined; + xfbCaptureDeclined = other.xfbCaptureDeclined; + pointSizeCapabilityUnsupported = other.pointSizeCapabilityUnsupported; globalUboBinding = other.globalUboBinding; activeVertexInputLocationMask = other.activeVertexInputLocationMask; vertexInputTypes = other.vertexInputTypes; @@ -374,6 +392,9 @@ namespace MobileGL::MG_Backend::DirectVulkan { other.pipelineLayout = VK_NULL_HANDLE; other.hasStorageImages = false; other.declinedDescriptors = false; + other.invalidSpirvDeclined = false; + other.xfbCaptureDeclined = false; + other.pointSizeCapabilityUnsupported = false; other.globalUboBinding = -1; other.activeVertexInputLocationMask = 0; other.activeFragmentOutputLocationMask = 0; @@ -504,6 +525,29 @@ namespace MobileGL::MG_Backend::DirectVulkan { // VkRenderPassManager::OnPresent's sweep. void OnFrameBoundary(); + // What the last look at a module's final bytes - the exact words vkCreateShaderModule + // would receive - decided. + enum class DriverModuleVerdict : Uint8 { + Valid, // validates: handed to the driver + DeclinedInvalid, // fails validation: never handed over, the program's work is declined + AdmittedInvalid, // fails validation, handed over anyway (MOBILEGL_MAGMA_ALLOW_INVALID_SPIRV) + }; + // Validates `spirv` for the Vulkan environment its version word names and turns the + // result into a verdict. A driver is not a validating entry point: Adreno page-faulted + // into device loss on a module that carried a sampler inside a uniform block, Mali + // SIGSEGVs inside pipeline creation. So an invalid module is declined unless + // `allowInvalid` (the escape hatch) says to hand it over regardless. + // + // The validator's answer is remembered per module content (64-bit hash plus word count), + // process-wide: a program variant, an evicted-then-rebuilt entry and two programs that + // share a stage all re-screen the same bytes, and only the first pays for spirv-val. + // `outReason` receives the validator's message for an invalid module. + static DriverModuleVerdict ScreenModuleForDriver(const Vector& spirv, Bool allowInvalid, + String* outReason = nullptr); + // How many times ScreenModuleForDriver actually ran the validator (cache misses), for + // the tests that pin the cache. + static Uint64 DriverModuleValidatorRuns(); + static VkShaderStageFlagBits ToVkStage(ShaderStage stage); static VkFormat ConvertSpirvImageFormatToVkFormat(SpvImageFormat format); static SamplerNumericDomain UniformTypeToSamplerNumericDomain(GLenum glType); diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.cpp b/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.cpp index 267de68a..9d510a8e 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.cpp +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.cpp @@ -6835,7 +6835,7 @@ void main() { // A refused program cannot reach here today - the full path refuses before it ever // records a snapshot - but declining the fast path costs one compare and means the // refusal does not depend on that ordering staying true. - if (programObj.pointSizeCapabilityUnsupported) { + if (programObj.pointSizeCapabilityUnsupported || programObj.invalidSpirvDeclined) { return false; } @@ -7318,7 +7318,7 @@ void main() { // The build already said why, once, naming the program and the stage. Refusing here - // before any pipeline is built from it - is what makes that message a decline rather // than a note attached to invalid usage the driver still receives. - if (programObj.pointSizeCapabilityUnsupported) { + if (programObj.pointSizeCapabilityUnsupported || programObj.invalidSpirvDeclined) { return false; } // For the snapshot's memoised entry pointer: if anything below inserts into the @@ -7778,6 +7778,8 @@ void main() { } ProgramFactory::CompileOptionFlags transformFlags = 0; const auto& programObj = m_programFactory->GetOrCreateProgram(program, transformFlags); + // No shader module of a program that failed validation exists; the build said why, once. + if (programObj.invalidSpirvDeclined) return; if (!frame.isCommandRecording) { m_frameContext.BeginCommandRecording(); @@ -7840,6 +7842,8 @@ void main() { } ProgramFactory::CompileOptionFlags transformFlags = 0; const auto& programObj = m_programFactory->GetOrCreateProgram(program, transformFlags); + // No shader module of a program that failed validation exists; the build said why, once. + if (programObj.invalidSpirvDeclined) return; if (!frame.isCommandRecording) { m_frameContext.BeginCommandRecording(); diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/WireDeclines.def b/MobileGL/MG_Backend/DirectVulkan/Renderer/WireDeclines.def index ab39a53d..dc3423fe 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/WireDeclines.def +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/WireDeclines.def @@ -126,3 +126,11 @@ MGL_WIRE_DECLINE(MipmapDepthStencilAspect) // is missing, or the three group-count words lie outside it or are misaligned. MGL_WIRE_DECLINE(DispatchIndirectBufferUnbound) MGL_WIRE_DECLINE(DispatchIndirectRange) + +// ---- ProgramFactory::ScreenModuleForDriver ------------------------------------------------ +// One of the bound program's final modules fails SPIR-V validation, so no shader module of it was +// created (VkProgramObject::invalidSpirvDeclined): SetupWireDraw refuses the draw, and the two +// dispatch arms the dispatch. A driver handed the module could fault the GPU into device loss. +// MOBILEGL_MAGMA_ALLOW_INVALID_SPIRV=1 builds the module anyway, and then neither row moves. +MGL_WIRE_DECLINE(ProgramInvalidSpirv) +MGL_WIRE_DECLINE(ComputeProgramInvalidSpirv) diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/WireDraw.inc b/MobileGL/MG_Backend/DirectVulkan/Renderer/WireDraw.inc index 5d28103d..1059e53b 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/WireDraw.inc +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/WireDraw.inc @@ -230,6 +230,10 @@ flags |= ProgramFactory::CompileOptionBit::ZeroBaseVertex; programObject = &m_programFactory->GetOrCreateProgram(program, flags); } + if (programObject->invalidSpirvDeclined) { + MGL_WIRE_DECLINE_AT(ProgramInvalidSpirv, "a module of the program fails SPIR-V validation and was never built"); + return false; + } if (programObject->pointSizeCapabilityUnsupported) { MGL_WIRE_DECLINE_AT(PointSizeCapability, "the program writes gl_PointSize and the device cannot express it"); return false; @@ -775,6 +779,10 @@ return; } const auto& programObj = m_programFactory->GetOrCreateProgram(program, {}); + if (programObj.invalidSpirvDeclined) { + MGL_WIRE_DECLINE_AT(ComputeProgramInvalidSpirv, "a module of the compute program fails SPIR-V validation and was never built"); + return; + } if (!m_uniformManager->PrepareWireTextureResources(program, programObj)) { MGL_WIRE_DECLINE_AT(ComputeTextureResourcesUnprepared, "a sampled/storage image could not be made ready for the dispatch"); return; @@ -847,6 +855,10 @@ return; } const auto& programObj = m_programFactory->GetOrCreateProgram(program, {}); + if (programObj.invalidSpirvDeclined) { + MGL_WIRE_DECLINE_AT(ComputeProgramInvalidSpirv, "a module of the compute program fails SPIR-V validation and was never built"); + return; + } if (!m_uniformManager->PrepareWireTextureResources(program, programObj)) { MGL_WIRE_DECLINE_AT(ComputeTextureResourcesUnprepared, "a sampled/storage image could not be made ready for the dispatch"); return; diff --git a/MobileGL/MG_Test/Pipeline/CMakeLists.txt b/MobileGL/MG_Test/Pipeline/CMakeLists.txt index 34ebeb9b..d51f949a 100644 --- a/MobileGL/MG_Test/Pipeline/CMakeLists.txt +++ b/MobileGL/MG_Test/Pipeline/CMakeLists.txt @@ -5,6 +5,7 @@ add_executable( PipelineQuirkTest.cpp PassthroughTessControlTest.cpp ViewportIndexReflectionTest.cpp + DriverModuleScreenTest.cpp ) target_include_directories(PipelineQuirkTest PRIVATE diff --git a/MobileGL/MG_Test/Pipeline/DriverModuleScreenTest.cpp b/MobileGL/MG_Test/Pipeline/DriverModuleScreenTest.cpp new file mode 100644 index 00000000..81064d1a --- /dev/null +++ b/MobileGL/MG_Test/Pipeline/DriverModuleScreenTest.cpp @@ -0,0 +1,183 @@ +// MobileGL - MobileGL/MG_Test/Pipeline/DriverModuleScreenTest.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 +// +// ProgramFactory::ScreenModuleForDriver is the last look Magma takes at a module's bytes before +// vkCreateShaderModule. A driver is not a validating entry point: a module that carried a sampler +// inside the default uniform block (a frontend translation defect) was accepted by Adreno, which +// then page-faulted on a descriptor read out of uniform-buffer bytes and lost the device. The +// screen used to report such a module and hand it over anyway; it must DECLINE it, so the GPU +// never sees it, unless MOBILEGL_MAGMA_ALLOW_INVALID_SPIRV asks otherwise. +// +// The invalid module here is that exact shape, assembled by hand so it stays invalid whatever +// the frontend does: a Block-decorated uniform struct with a sampled-image member. + +#include + +#include +#include + +#include "Includes.h" +#include "Init.h" + +#include +#include +#include + +#include "spirv-tools/libspirv.hpp" + +using namespace MobileGL; +using MobileGL::MG_Backend::DirectVulkan::ProgramFactory; +using Verdict = ProgramFactory::DriverModuleVerdict; + +namespace { + + // Shared by both modules: a fragment shader whose default uniform block holds a vec4 and, + // in the invalid one, a sampler2D next to it. + String UniformBlockFragment(Bool withSampler) { + String text = + "OpCapability Shader\n" + "OpMemoryModel Logical GLSL450\n" + "OpEntryPoint Fragment %main \"main\" %out\n" + "OpExecutionMode %main OriginUpperLeft\n" + "OpDecorate %out Location 0\n" + "OpDecorate %Block Block\n" + "OpMemberDecorate %Block 0 Offset 0\n" + "OpDecorate %ubo DescriptorSet 0\n" + "OpDecorate %ubo Binding 0\n" + "%void = OpTypeVoid\n" + "%fn = OpTypeFunction %void\n" + "%float = OpTypeFloat 32\n" + "%v4 = OpTypeVector %float 4\n" + "%int = OpTypeInt 32 1\n" + "%zero = OpConstant %int 0\n" + "%img = OpTypeImage %float 2D 0 0 0 1 Unknown\n" + "%simg = OpTypeSampledImage %img\n"; + text += withSampler ? "%Block = OpTypeStruct %v4 %simg\n" : "%Block = OpTypeStruct %v4\n"; + text += + "%pBlock = OpTypePointer Uniform %Block\n" + "%ubo = OpVariable %pBlock Uniform\n" + "%pv4u = OpTypePointer Uniform %v4\n" + "%pv4o = OpTypePointer Output %v4\n" + "%out = OpVariable %pv4o Output\n" + "%main = OpFunction %void None %fn\n" + "%entry = OpLabel\n" + "%ptr = OpAccessChain %pv4u %ubo %zero\n" + "%color = OpLoad %v4 %ptr\n" + "OpStore %out %color\n" + "OpReturn\n" + "OpFunctionEnd\n"; + return text; + } + + Vector Assemble(const String& text) { + spvtools::SpirvTools tools(SPV_ENV_VULKAN_1_1); + std::vector words; + EXPECT_TRUE(tools.Assemble(text, &words)) << text; + return Vector(words.begin(), words.end()); + } + + // A realistic module out of the real frontend, for the cost measurement. + Vector CompileFragment(const String& source) { + using namespace MG_Util::ShaderTranspiler; + ShaderAttrib shaderAttrib{.shaderType = GL_FRAGMENT_SHADER, .sourceStr = source}; + auto shaderResult = ShaderCompiler::CompileShader(shaderAttrib); + EXPECT_TRUE(shaderResult) << (shaderResult ? String{} : shaderResult.error().log); + if (!shaderResult) return {}; + ProgramAttrib programAttrib{.shaders = {shaderResult.value()}}; + auto programResult = ShaderCompiler::LinkProgram(programAttrib); + EXPECT_TRUE(programResult) << (programResult ? String{} : programResult.error().log); + if (!programResult) return {}; + ProgramBinaryAttrib binaryAttrib{.shaderTypes = {GL_FRAGMENT_SHADER}, .program = *programResult.value()}; + auto binaryResult = ShaderCompiler::GetSpirvBinaryFromProgram(binaryAttrib); + if (!binaryResult || binaryResult->empty()) return {}; + const auto& words = binaryResult->front(); + return Vector(words.begin(), words.end()); + } + + class DriverModuleScreenTest : public ::testing::Test { + protected: + void SetUp() override { MobileGL::Initialize(); } + }; + +} // namespace + +// The headline: the sampler-in-uniform-block module never reaches the driver. Red before the +// screen acted on its verdict - it answered AdmittedInvalid, i.e. "logged, handed over anyway". +TEST_F(DriverModuleScreenTest, ASamplerInsideTheDefaultUniformBlockIsDeclined) { + const Vector invalid = Assemble(UniformBlockFragment(/*withSampler=*/true)); + ASSERT_FALSE(invalid.empty()); + String reason; + EXPECT_EQ(ProgramFactory::ScreenModuleForDriver(invalid, /*allowInvalid=*/false, &reason), + Verdict::DeclinedInvalid); + EXPECT_FALSE(reason.empty()) << "a decline must carry the validator's reason for its log line"; +} + +// The positive control: the same block without the sampler validates and is handed over. A screen +// that declined everything would pass the case above and fail this one. +TEST_F(DriverModuleScreenTest, TheSameBlockWithoutTheSamplerPasses) { + const Vector valid = Assemble(UniformBlockFragment(/*withSampler=*/false)); + ASSERT_FALSE(valid.empty()); + String reason = "stale"; + EXPECT_EQ(ProgramFactory::ScreenModuleForDriver(valid, /*allowInvalid=*/false, &reason), Verdict::Valid); + EXPECT_TRUE(reason.empty()); +} + +// The escape hatch: with MOBILEGL_MAGMA_ALLOW_INVALID_SPIRV the invalid module is handed over, +// and the verdict says it was invalid rather than pretending it validated. +TEST_F(DriverModuleScreenTest, TheEscapeHatchAdmitsAnInvalidModuleAndSaysSo) { + const Vector invalid = Assemble(UniformBlockFragment(/*withSampler=*/true)); + EXPECT_EQ(ProgramFactory::ScreenModuleForDriver(invalid, /*allowInvalid=*/true), Verdict::AdmittedInvalid); + const Vector valid = Assemble(UniformBlockFragment(/*withSampler=*/false)); + EXPECT_EQ(ProgramFactory::ScreenModuleForDriver(valid, /*allowInvalid=*/true), Verdict::Valid); +} + +// The verdict is remembered by content: re-screening the same bytes does not run spirv-val again, +// and a different module does. Also reports what one validation costs next to a cached answer. +TEST_F(DriverModuleScreenTest, TheVerdictIsCachedPerModuleContent) { + const Vector module = CompileFragment(R"(#version 330 core +uniform sampler2D tex0; +uniform sampler2D tex1; +uniform vec4 tint; +uniform mat4 colorMatrix; +in vec2 uv; +in vec4 vertexColor; +out vec4 fragColor; +vec3 tonemap(vec3 c) { return c / (c + vec3(1.0)); } +void main() { + vec4 base = texture(tex0, uv) * vertexColor; + vec4 detail = texture(tex1, uv * 4.0); + vec4 c = colorMatrix * mix(base, base * detail, 0.5) * tint; + for (int i = 0; i < 4; ++i) c.rgb += 0.01 * sin(c.gbr * float(i + 1)); + fragColor = vec4(tonemap(c.rgb), c.a); +} +)"); + ASSERT_FALSE(module.empty()); + // A copy with one changed word in the bound field of the header: different bytes, same + // validity, so it must miss the cache. + Vector sibling = module; + sibling[3] += 1; + + const Uint64 runsBefore = ProgramFactory::DriverModuleValidatorRuns(); + const auto t0 = std::chrono::steady_clock::now(); + const Verdict first = ProgramFactory::ScreenModuleForDriver(module, false); + const auto t1 = std::chrono::steady_clock::now(); + const Verdict second = ProgramFactory::ScreenModuleForDriver(module, false); + const auto t2 = std::chrono::steady_clock::now(); + EXPECT_EQ(first, Verdict::Valid); + EXPECT_EQ(second, first); + EXPECT_EQ(ProgramFactory::DriverModuleValidatorRuns(), runsBefore + 1) << "the second screen re-validated"; + + EXPECT_EQ(ProgramFactory::ScreenModuleForDriver(sibling, false), Verdict::Valid); + EXPECT_EQ(ProgramFactory::DriverModuleValidatorRuns(), runsBefore + 2) << "different bytes shared a verdict"; + + const auto us = [](auto a, auto b) { + return static_cast(std::chrono::duration_cast(b - a).count()); + }; + std::printf("[ screen ] %zu-word module: validate %lld us, cached %lld us\n", module.size(), us(t0, t1), + us(t1, t2)); +} diff --git a/README.md b/README.md index 5be39685..fac07295 100644 --- a/README.md +++ b/README.md @@ -166,6 +166,7 @@ MobileGL supports runtime configuration via environment variables. | `MOBILEGL_MAGMA_DISABLE_SUBGROUP` | Disable Vulkan shader subgroup support. | `0`, `1` | `0` | | `MOBILEGL_ADVERTISE_FP64` | Advertise `GL_ARB_gpu_shader_fp64`. GLSL `double`/`dvec`/`dmat` compile and run either way - they are narrowed to 32 bits - so this only changes whether an application is told it has 64-bit precision, which it does not. | `0`, `1` | `0` | | `MOBILEGL_MAGMA_R11G11B10F_FALLBACK` | Use Magma's R11G11B10F format fallback. | `0`, `1` | `0` | +| `MOBILEGL_MAGMA_ALLOW_INVALID_SPIRV` | Hand a shader module that fails SPIR-V validation to the driver anyway. By default Magma declines such a program (no shader module is created, its draws and dispatches are refused and logged by name), because a driver that accepts invalid SPIR-V can fault the GPU into device loss. Diagnostic use only. | `0`, `1` | `0` | | `MOBILEGL_MAGMA_FRAMESINFLIGHT` | Set Magma frames in flight. | Integer `1`–`64` | `3` | | `MOBILEGL_MAGMA_MAX_DRAWS_PER_COMMAND_BUFFER` | Draws and dispatches Magma records into one command buffer before it submits the buffer mid-frame and continues on a fresh one. `0` never splits. | Integer `0`–`16777216` | `16384` | | `MOBILEGL_MAGMA_DESCRIPTOR_TRIM_FRAMES` | Frames a frame slot's descriptor pools must stay under a quarter full before Magma frees the slot's cached descriptor sets and grown pools. `0` never trims. | Integer `0`–`1048576` | `120` |