[Fix, Test] (ShaderTranspiler, DirectGLES): make every array-of-storage-blocks index a constant for ESSL

This commit is contained in:
2026-08-20 23:08:38 -04:00
parent 50efa4410a
commit f7dfa01c18
8 changed files with 1087 additions and 0 deletions
+1
View File
@@ -300,6 +300,7 @@ set(SOURCE_FILES
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/StripNoPerspectivePass.cpp
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/EmulateNoPerspectivePass.cpp
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/LegalizeFragmentOutputIndexPass.cpp
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/LegalizeStorageBlockArrayIndexPass.cpp
MobileGL/MG_Util/BackendLoaders/OpenGL/Loader.cpp
MobileGL/MG_Util/BackendLoaders/Vulkan/Loader.cpp
@@ -5405,6 +5405,21 @@ namespace MobileGL::MG_Backend::DirectGLES {
effectiveSpirv = &outputIndexSpirv;
}
// Same rule, different resource, every stage: GL 4.3 lets an array of storage
// blocks be indexed with any dynamically-uniform expression, GLSL ES keeps the
// ES 3.1 constant-expression rule, and the Qualcomm compiler enforces it
// ("indexing into an SSBO array using a non-constant expression is not
// permitted") - losing the stage, the program, and every dispatch that used
// it, while the frontend keeps reporting the link glslang performed. Fold or
// lower the index here, on the ESSL path only: the same module is legal for
// DirectVulkan, which binds the array as one descriptor array.
Vector<unsigned int> blockArrayIndexSpirv;
if (MG_Util::ShaderTranspiler::ShaderCompiler::LegalizeStorageBlockArrayIndexingForEssl(
*effectiveSpirv, blockArrayIndexSpirv, enableSpirvValidation) &&
!blockArrayIndexSpirv.empty()) {
effectiveSpirv = &blockArrayIndexSpirv;
}
MG_Util::ShaderTranspiler::SpvcSession spvcSession(*effectiveSpirv,
MG_Util::ShaderTranspiler::SessionUsageBit::Transpile);
@@ -12,6 +12,7 @@ add_executable(
UniquifyIoBlockNamesTest.cpp
LowerViewportIndexTest.cpp
ClampMultisampleFetchTest.cpp
LegalizeStorageBlockArrayIndexTest.cpp
)
target_include_directories(SpirvPassTest PRIVATE
@@ -0,0 +1,251 @@
// MobileGL - MobileGL/MG_Test/ShaderTranspiler/LegalizeStorageBlockArrayIndexTest.cpp
// Copyright (c) 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 <gtest/gtest.h>
#define SPV_ENABLE_UTILITY_CODE
#include "glslang/SPIRV/spirv.hpp11"
#undef SPV_ENABLE_UTILITY_CODE
#include "Includes.h"
#include <MG_Util/ShaderTranspiler/ShaderCompiler.h>
#include <MG_Util/ShaderTranspiler/Types.h>
#include <spirv-tools/libspirv.hpp>
#include <set>
#include <vector>
using namespace MobileGL;
using MobileGL::MG_Util::ShaderTranspiler::ShaderCompiler;
namespace {
constexpr SizeT kSpirvHeaderWordCount = 5u;
template <typename Visitor>
void ForEachInstruction(const Vector<Uint32>& spirv, Visitor&& visit) {
for (SizeT offset = kSpirvHeaderWordCount; offset < spirv.size();) {
const Uint32 wordCount = spirv[offset] >> 16u;
if (wordCount == 0u || offset + wordCount > spirv.size()) break;
visit(static_cast<spv::Op>(spirv[offset] & 0xffffu), &spirv[offset], wordCount);
offset += wordCount;
}
}
Vector<Uint32> CompileCompute(const String& source) {
using namespace MobileGL::MG_Util::ShaderTranspiler;
ShaderAttrib shaderAttrib{.shaderType = GL_COMPUTE_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_COMPUTE_SHADER}, .program = *programResult.value()};
auto binaryResult = ShaderCompiler::GetSpirvBinaryFromProgram(binaryAttrib);
EXPECT_TRUE(binaryResult) << (binaryResult ? String{} : binaryResult.error().log);
if (!binaryResult || binaryResult->empty()) return {};
return binaryResult->front();
}
bool Validates(const Vector<Uint32>& spirv) {
spvtools::SpirvTools tools(SPV_ENV_VULKAN_1_1);
tools.SetMessageConsumer(
[](spv_message_level_t, const char*, const spv_position_t& position, const char* message) {
ADD_FAILURE() << "spirv-val at word " << position.index << ": " << message;
});
return tools.Validate(spirv);
}
Uint32 CountOpcode(const Vector<Uint32>& spirv, spv::Op wanted) {
Uint32 count = 0u;
ForEachInstruction(spirv, [&](spv::Op opcode, const Uint32*, Uint32) {
if (opcode == wanted) ++count;
});
return count;
}
// Test-side reference walker, deliberately independent of the production detection so a
// bug in the pass cannot hide behind the same helper: true when some access chain rooted
// at an array-of-storage-blocks variable carries a non-constant FIRST index, which is
// exactly what the Qualcomm ES compiler refuses.
bool HasDynamicBlockArrayIndex(const Vector<Uint32>& spirv) {
std::set<Uint32> blockStructs; // OpTypeStruct ids decorated Block / BufferBlock
std::set<Uint32> constants; // OpConstant / OpConstantNull result ids
std::set<Uint32> blockArrayTypes; // OpTypeArray ids whose element is such a struct
std::set<Uint32> blockArrayPointers;// OpTypePointer ids pointing at one of those arrays
std::set<Uint32> blockArrayVars; // OpVariable ids of one of those pointer types
ForEachInstruction(spirv, [&](spv::Op opcode, const Uint32* words, Uint32 wordCount) {
switch (opcode) {
case spv::Op::OpDecorate:
if (wordCount >= 3u) {
const auto decoration = static_cast<spv::Decoration>(words[2]);
if (decoration == spv::Decoration::Block ||
decoration == spv::Decoration::BufferBlock) {
blockStructs.insert(words[1]);
}
}
break;
case spv::Op::OpConstant:
if (wordCount >= 3u) constants.insert(words[2]);
break;
case spv::Op::OpConstantNull:
if (wordCount >= 3u) constants.insert(words[2]);
break;
case spv::Op::OpTypeArray:
// OpTypeArray <result> <element type> <length>
if (wordCount >= 4u && blockStructs.count(words[2]) != 0u) {
blockArrayTypes.insert(words[1]);
}
break;
case spv::Op::OpTypePointer:
// OpTypePointer <result> <storage class> <pointee>
if (wordCount >= 4u && blockArrayTypes.count(words[3]) != 0u) {
blockArrayPointers.insert(words[1]);
}
break;
case spv::Op::OpVariable:
// OpVariable <result type> <result> <storage class>
if (wordCount >= 4u && blockArrayPointers.count(words[1]) != 0u) {
blockArrayVars.insert(words[2]);
}
break;
default:
break;
}
});
bool dynamic = false;
ForEachInstruction(spirv, [&](spv::Op opcode, const Uint32* words, Uint32 wordCount) {
if (opcode != spv::Op::OpAccessChain && opcode != spv::Op::OpInBoundsAccessChain) return;
// OpAccessChain <result type> <result> <base> <index 0> ...
if (wordCount < 5u) return;
if (blockArrayVars.count(words[3]) == 0u) return;
if (constants.count(words[4]) != 0u) return;
dynamic = true;
});
return dynamic;
}
// `for (i = 0; i < 4; ++i)` over an array of storage blocks - the shape
// KHR-GL43.shader_storage_buffer_object.basic-stdLayout-case1 uses. Foldable: the
// induction variable is a literal after unrolling.
constexpr const char* kLoopIndexedBlockArray = R"(#version 450 core
layout(local_size_x = 1) in;
layout(std430, binding = 0) buffer Blk { uint data[4]; } g_blocks[4];
layout(std430, binding = 8) buffer Out { uint data[4]; } g_out;
void main() {
for (int i = 0; i < 4; ++i) {
g_out.data[i] = g_blocks[i].data[0];
}
}
)";
// A uniform-sourced index - the shape
// KHR-GL43.shader_storage_buffer_object.advanced-indirectAddressing-case2 uses. Nothing
// can fold it, so the switch/select lowering is what has to carry it.
constexpr const char* kUniformIndexedBlockArray = R"(#version 450 core
layout(local_size_x = 1) in;
layout(std430, binding = 0) buffer Blk { uint data[4]; } g_blocks[4];
layout(std430, binding = 8) buffer Out { uint value; } g_out;
uniform int g_index;
void main() {
g_blocks[g_index].data[0] = 7u;
g_out.value = g_blocks[g_index].data[1];
}
)";
// The positive control from the device run: dynamic addressing through an array MEMBER of
// ONE block is legal ES and must not be rewritten.
constexpr const char* kArrayMemberInsideOneBlock = R"(#version 450 core
layout(local_size_x = 1) in;
layout(std430, binding = 0) buffer Blk { uint data[4]; } g_block;
layout(std430, binding = 8) buffer Out { uint value; } g_out;
uniform int g_index;
void main() {
g_out.value = g_block.data[g_index];
}
)";
// A block array indexed only with literals is already legal ES.
constexpr const char* kConstantIndexedBlockArray = R"(#version 450 core
layout(local_size_x = 1) in;
layout(std430, binding = 0) buffer Blk { uint data[4]; } g_blocks[4];
layout(std430, binding = 8) buffer Out { uint value; } g_out;
void main() {
g_out.value = g_blocks[2].data[0] + g_blocks[3].data[1];
}
)";
} // namespace
TEST(LegalizeStorageBlockArrayIndexPass, FoldsALoopIndexedBlockArray) {
const Vector<Uint32> input = CompileCompute(kLoopIndexedBlockArray);
ASSERT_FALSE(input.empty());
EXPECT_TRUE(HasDynamicBlockArrayIndex(input));
Vector<Uint32> output;
ASSERT_TRUE(ShaderCompiler::LegalizeStorageBlockArrayIndexingForEssl(input, output, true));
ASSERT_FALSE(output.empty());
// Either half of the legalization is an acceptable outcome here - what the ES driver
// cares about is only that no dynamic subscript survives.
EXPECT_FALSE(HasDynamicBlockArrayIndex(output));
EXPECT_TRUE(Validates(output));
}
TEST(LegalizeStorageBlockArrayIndexPass, LowersAUniformIndexedWriteToASwitchAndAReadToSelects) {
const Vector<Uint32> input = CompileCompute(kUniformIndexedBlockArray);
ASSERT_FALSE(input.empty());
EXPECT_TRUE(HasDynamicBlockArrayIndex(input));
EXPECT_EQ(CountOpcode(input, spv::Op::OpSwitch), 0u);
Vector<Uint32> output;
ASSERT_TRUE(ShaderCompiler::LegalizeStorageBlockArrayIndexingForEssl(input, output, true));
ASSERT_FALSE(output.empty());
EXPECT_FALSE(HasDynamicBlockArrayIndex(output));
// One switch for the store, and one select per element past the first for the load.
EXPECT_EQ(CountOpcode(output, spv::Op::OpSwitch), 1u);
EXPECT_EQ(CountOpcode(output, spv::Op::OpSelect), 3u);
EXPECT_TRUE(Validates(output));
}
TEST(LegalizeStorageBlockArrayIndexPass, LeavesADynamicMemberOfOneBlockByteIdentical) {
const Vector<Uint32> input = CompileCompute(kArrayMemberInsideOneBlock);
ASSERT_FALSE(input.empty());
EXPECT_FALSE(HasDynamicBlockArrayIndex(input));
Vector<Uint32> output;
ASSERT_TRUE(ShaderCompiler::LegalizeStorageBlockArrayIndexingForEssl(input, output, true));
EXPECT_EQ(output, input);
}
TEST(LegalizeStorageBlockArrayIndexPass, LeavesAConstantIndexedBlockArrayByteIdentical) {
const Vector<Uint32> input = CompileCompute(kConstantIndexedBlockArray);
ASSERT_FALSE(input.empty());
EXPECT_FALSE(HasDynamicBlockArrayIndex(input));
Vector<Uint32> output;
ASSERT_TRUE(ShaderCompiler::LegalizeStorageBlockArrayIndexingForEssl(input, output, true));
EXPECT_EQ(output, input);
}
TEST(LegalizeStorageBlockArrayIndexPass, IsIdempotent) {
const Vector<Uint32> input = CompileCompute(kUniformIndexedBlockArray);
ASSERT_FALSE(input.empty());
Vector<Uint32> once;
ASSERT_TRUE(ShaderCompiler::LegalizeStorageBlockArrayIndexingForEssl(input, once, true));
ASSERT_FALSE(once.empty());
Vector<Uint32> twice;
ASSERT_TRUE(ShaderCompiler::LegalizeStorageBlockArrayIndexingForEssl(once, twice, true));
EXPECT_EQ(twice, once);
}
@@ -41,6 +41,7 @@
#include "SpirvPasses/StripNoPerspectivePass.h"
#include "SpirvPasses/EmulateNoPerspectivePass.h"
#include "SpirvPasses/LegalizeFragmentOutputIndexPass.h"
#include "SpirvPasses/LegalizeStorageBlockArrayIndexPass.h"
#include "spirv-tools/libspirv.h"
#include "spirv-tools/optimizer.hpp"
#include "source/opt/build_module.h"
@@ -933,6 +934,80 @@ namespace MobileGL {
return true;
}
bool ShaderCompiler::LegalizeStorageBlockArrayIndexingForEssl(
const Vector<Uint32>& inputBinary, Vector<uint32_t>& outputBinary,
const bool enableSpirvValidation) {
using namespace spvtools;
// Detection gates everything: a module that declares no array of storage
// blocks, or indexes one only with constants - every shader but a handful -
// pays one BuildModule and is handed back byte for byte, so the folding chain
// can never perturb a shader that did not need it.
if (!LegalizeStorageBlockArrayIndexPass::BinaryHasDynamicStorageBlockArrayIndexing(
inputBinary)) {
outputBinary = inputBinary;
return true;
}
// Stock passes do the real work, exactly as in the fragment-output
// legalization. The only bespoke member of the chain is the loop-control hint
// the stock unroller demands (see the pass header); with it set, the
// `for (i = 0; i < 4; ++i) arr[i]...` shape folds to literals here and the
// fallback below never runs.
Optimizer folder(SPV_ENV_VULKAN_1_1);
// First, because both the unroller and the marking pass below read the
// induction variable as an OpPhi, and glslang emits it as loads and stores of
// a Function variable.
folder.RegisterPass(CreateLocalMultiStoreElimPass());
folder.RegisterPass(LegalizeStorageBlockArrayIndexPass::CreateMarkLoopsForUnrollPass());
folder.RegisterPass(CreateLoopUnrollPass(true));
// Fold the unrolled induction values into the access chains, then clear out
// what constant conditions leave behind.
folder.RegisterPass(CreateCCPPass());
folder.RegisterPass(CreateSimplificationPass());
folder.RegisterPass(CreateDeadBranchElimPass());
folder.RegisterPass(CreateBlockMergePass());
Vector<uint32_t> folded;
if (!RunOptimizerChecked("LegalizeStorageBlockArrayIndexingForEssl.fold", folder,
inputBinary, folded, true, enableSpirvValidation) ||
folded.empty()) {
// Fail open onto the fallback rather than onto the illegal module.
folded = inputBinary;
}
if (!LegalizeStorageBlockArrayIndexPass::BinaryHasDynamicStorageBlockArrayIndexing(
folded)) {
outputBinary = folded;
return true;
}
// Genuinely dynamic (uniform-derived, non-constant trip count, ...): lower it.
Optimizer lowerer(SPV_ENV_VULKAN_1_1);
lowerer.RegisterPass(
LegalizeStorageBlockArrayIndexPass::CreateLowerToConstantSwitchPass());
// The chains the lowering replaced are dead now; remove_outputs must stay
// false here for the same reason it does in SanitizeAndOptimizeBinary.
lowerer.RegisterPass(CreateAggressiveDCEPass(false));
if (!RunOptimizerChecked("LegalizeStorageBlockArrayIndexingForEssl.lower", lowerer, folded,
outputBinary, true, enableSpirvValidation) ||
outputBinary.empty()) {
outputBinary = folded;
return true;
}
if (LegalizeStorageBlockArrayIndexPass::BinaryHasDynamicStorageBlockArrayIndexing(
outputBinary)) {
// MGLOG_W, latched, for the same reason the fragment-output one is: this
// runs per shader compile and shader packs compile lazily mid-session.
MGLOG_W_ONCE("[spirv] LegalizeStorageBlockArrayIndexingForEssl: an array of storage "
"blocks is still indexed dynamically; a strict ES driver will reject "
"this shader");
}
return true;
}
bool ShaderCompiler::LowerRectImages(const Vector<Uint32>& inputBinary,
Vector<uint32_t>& outputBinary,
const bool enableSpirvValidation) {
@@ -156,6 +156,20 @@ namespace MobileGL {
static bool LegalizeFragmentOutputIndexingForEssl(const Vector<Uint32>& inputBinary,
Vector<uint32_t>& outputBinary,
bool enableSpirvValidation = false);
// Makes every index into an ARRAY OF SHADER STORAGE BLOCKS a constant integral
// expression. GL 4.3 allows any dynamically-uniform index there; the Qualcomm
// ES compiler enforces the ES 3.1 constant-expression rule and refuses the whole
// stage ("indexing into an SSBO array using a non-constant expression is not
// permitted"), which loses the program while the frontend still reports
// GL_LINK_STATUS = TRUE. Same two halves as the fragment-output legalization:
// fold the loop-derived indices, then lower whatever is genuinely dynamic to a
// switch over the array's range. DirectGLES transpile path only - Vulkan has no
// such restriction and must keep seeing one descriptor array. Copies the input
// through untouched when no block array is indexed dynamically, which is every
// shader but a handful. See LegalizeStorageBlockArrayIndexPass.
static bool LegalizeStorageBlockArrayIndexingForEssl(const Vector<Uint32>& inputBinary,
Vector<uint32_t>& outputBinary,
bool enableSpirvValidation = false);
// Rebases loads of the InstanceIndex builtin to (InstanceIndex - BaseInstance) so
// shaders see GL's zero-based gl_InstanceID. Vertex shaders only; DirectVulkan
// backend only (glslang's relaxed mode aliases gl_InstanceID to gl_InstanceIndex,
@@ -0,0 +1,610 @@
// MobileGL - MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/LegalizeStorageBlockArrayIndexPass.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 "LegalizeStorageBlockArrayIndexPass.h"
#include "spirv.hpp"
#include "source/opt/basic_block.h"
#include "source/opt/build_module.h"
#include "source/opt/constants.h"
#include "source/opt/decoration_manager.h"
#include "source/opt/def_use_manager.h"
#include "source/opt/function.h"
#include "source/opt/instruction.h"
#include "source/opt/ir_builder.h"
#include "source/opt/ir_context.h"
#include "source/opt/loop_descriptor.h"
#include "source/opt/module.h"
#include "source/opt/type_manager.h"
#include "source/util/make_unique.h"
#include <unordered_map>
#include <unordered_set>
#include <vector>
namespace MobileGL {
namespace MG_Util {
namespace ShaderTranspiler {
namespace {
using spvtools::MakeUnique;
using spvtools::opt::BasicBlock;
using spvtools::opt::Function;
using spvtools::opt::Instruction;
using spvtools::opt::InstructionBuilder;
using spvtools::opt::IRContext;
using spvtools::opt::Operand;
// GL_MAX_*_SHADER_STORAGE_BLOCKS is 16 on the devices MobileGL targets, and
// each lowered element costs one basic block per write, so a module claiming
// more than this is refused rather than exploded. The largest array in the
// conformance suite is 8.
constexpr uint32_t kMaxLoweredArrayLength = 32;
// One CFG-changing rewrite per round (analyses are dropped after each), so
// the round budget bounds the work on a pathological module.
constexpr int kMaxLoweringRounds = 256;
// Full unrolling copies the body once per iteration, and nothing in the stock
// unroller bounds that. Past this count the loop is left alone and the switch
// lowering, whose cost is the array length rather than the trip count, takes
// it instead. A loop over an array of storage blocks iterates at most
// GL_MAX_*_SHADER_STORAGE_BLOCKS times in any shader that is not already
// broken.
constexpr size_t kMaxUnrolledIterations = 64;
struct DynamicIndexUse {
Instruction* accessChain = nullptr;
uint32_t arrayLength = 0;
};
bool HasDecoration(IRContext* context, uint32_t id, spv::Decoration kind) {
for (Instruction* decoration : context->get_decoration_mgr()->GetDecorationsFor(id, false)) {
if (decoration->opcode() != spv::Op::OpDecorate ||
decoration->NumInOperands() < 2) {
continue;
}
if (static_cast<spv::Decoration>(decoration->GetSingleWordInOperand(1)) == kind) {
return true;
}
}
return false;
}
// Every variable that is an ARRAY OF STORAGE BLOCKS, mapped to that array's
// length. Two spellings are accepted because both reach here depending on the
// SPIR-V version glslang targets: StorageBuffer + Block (1.3, what MobileGL
// asks for) and Uniform + BufferBlock (the pre-1.3 encoding). A UNIFORM block
// array - Uniform + Block - is deliberately NOT collected; see the header.
//
// A length that is not a plain OpConstant (a spec constant) maps to 0: still
// detected as illegal ESSL, never lowered.
std::unordered_map<uint32_t, uint32_t> CollectStorageBlockArrays(IRContext* context) {
std::unordered_map<uint32_t, uint32_t> blockArrays;
auto* defUseMgr = context->get_def_use_mgr();
auto* constantMgr = context->get_constant_mgr();
for (Instruction& inst : context->module()->types_values()) {
if (inst.opcode() != spv::Op::OpVariable) {
continue;
}
const auto storageClass =
static_cast<spv::StorageClass>(inst.GetSingleWordInOperand(0));
if (storageClass != spv::StorageClass::StorageBuffer &&
storageClass != spv::StorageClass::Uniform) {
continue;
}
Instruction* pointerType = defUseMgr->GetDef(inst.type_id());
if (pointerType == nullptr || pointerType->opcode() != spv::Op::OpTypePointer) {
continue;
}
Instruction* pointeeType = defUseMgr->GetDef(pointerType->GetSingleWordInOperand(1));
if (pointeeType == nullptr || pointeeType->opcode() != spv::Op::OpTypeArray) {
continue;
}
Instruction* elementType = defUseMgr->GetDef(pointeeType->GetSingleWordInOperand(0));
if (elementType == nullptr || elementType->opcode() != spv::Op::OpTypeStruct) {
continue;
}
const bool isStorageBlock =
storageClass == spv::StorageClass::StorageBuffer
? HasDecoration(context, elementType->result_id(), spv::Decoration::Block)
: HasDecoration(context, elementType->result_id(),
spv::Decoration::BufferBlock);
if (!isStorageBlock) {
continue;
}
uint32_t arrayLength = 0;
const spvtools::opt::analysis::Constant* lengthConstant =
constantMgr->FindDeclaredConstant(pointeeType->GetSingleWordInOperand(1));
if (lengthConstant != nullptr && lengthConstant->AsIntConstant() != nullptr) {
arrayLength = lengthConstant->AsIntConstant()->GetU32BitValue();
}
blockArrays.emplace(inst.result_id(), arrayLength);
}
return blockArrays;
}
// "Constant integral expression" in the ESSL sense: an OpConstant (or the
// zero an OpConstantNull stands for). A spec constant is deliberately NOT
// one - SPIRV-Cross prints it as an identifier, which is exactly what the
// driver rejects.
bool IsConstantIndex(IRContext* context, uint32_t indexId) {
Instruction* def = context->get_def_use_mgr()->GetDef(indexId);
return def != nullptr && (def->opcode() == spv::Op::OpConstant ||
def->opcode() == spv::Op::OpConstantNull);
}
// Access chains that index an array of storage blocks with a non-constant.
// Only the FIRST index is considered: it is the one that selects the block,
// and it is the only one ESSL constrains here. Indices inside the block - the
// member selector and any array subscript below it - are legal however they
// are computed, and chains rooted at another access chain are already inside
// one element.
std::vector<DynamicIndexUse> CollectDynamicIndexUses(IRContext* context) {
std::vector<DynamicIndexUse> uses;
const std::unordered_map<uint32_t, uint32_t> blockArrays =
CollectStorageBlockArrays(context);
if (blockArrays.empty()) {
return uses;
}
for (Function& function : *context->module()) {
for (BasicBlock& block : function) {
for (Instruction& inst : block) {
if (inst.opcode() != spv::Op::OpAccessChain &&
inst.opcode() != spv::Op::OpInBoundsAccessChain) {
continue;
}
if (inst.NumInOperands() < 2) {
continue;
}
const auto arrayIt = blockArrays.find(inst.GetSingleWordInOperand(0));
if (arrayIt == blockArrays.end()) {
continue;
}
if (IsConstantIndex(context, inst.GetSingleWordInOperand(1))) {
continue;
}
uses.push_back({&inst, arrayIt->second});
}
}
}
return uses;
}
// The block index operand of |accessChain| replaced by the constant |element|,
// built at the builder's insertion point. Every later index is copied through
// unchanged: `arr[idx].data[j]` keeps its (legal) dynamic member subscript.
Instruction* CloneChainWithConstantIndex(InstructionBuilder& builder, IRContext* context,
Instruction* accessChain, uint32_t constantIndexId) {
std::vector<Operand> operands;
operands.reserve(accessChain->NumInOperands());
for (uint32_t i = 0; i < accessChain->NumInOperands(); ++i) {
if (i == 1) {
operands.push_back({SPV_OPERAND_TYPE_ID, {constantIndexId}});
} else {
operands.push_back(accessChain->GetInOperand(i));
}
}
return builder.AddInstruction(MakeUnique<Instruction>(context, accessChain->opcode(),
accessChain->type_id(),
context->TakeNextId(), operands));
}
// The id of |element| as a constant of the same integer type as |indexId|.
uint32_t ConstantLikeIndex(IRContext* context, uint32_t indexId, uint32_t element) {
Instruction* indexDef = context->get_def_use_mgr()->GetDef(indexId);
const spvtools::opt::analysis::Type* indexType =
context->get_type_mgr()->GetType(indexDef->type_id());
const spvtools::opt::analysis::Constant* constant =
context->get_constant_mgr()->GetConstant(indexType, {element});
return context->get_constant_mgr()->GetDefiningInstruction(constant)->result_id();
}
// A 32-bit integer is the only index this pass lowers: OpSwitch matches its
// literals against the selector's width, and every ESSL block-array index is
// an int or uint.
bool IsLowerableIndexType(IRContext* context, uint32_t indexId) {
Instruction* indexDef = context->get_def_use_mgr()->GetDef(indexId);
if (indexDef == nullptr) {
return false;
}
const spvtools::opt::analysis::Type* type =
context->get_type_mgr()->GetType(indexDef->type_id());
const spvtools::opt::analysis::Integer* integer =
type != nullptr ? type->AsInteger() : nullptr;
return integer != nullptr && integer->width() == 32;
}
// The condition type OpSelect needs for |resultTypeId|. Before SPIR-V 1.4 a
// scalar bool may not select between vectors, so a vector result needs a bool
// vector of the same width - built by broadcasting the scalar comparison.
// Anything that is neither scalar nor vector (a matrix or struct element) is
// refused: pre-1.4 OpSelect cannot express it either.
bool TryGetSelectConditionType(IRContext* context, uint32_t resultTypeId,
uint32_t* conditionTypeId, uint32_t* dimension) {
auto* typeMgr = context->get_type_mgr();
const spvtools::opt::analysis::Type* resultType = typeMgr->GetType(resultTypeId);
if (resultType == nullptr) {
return false;
}
spvtools::opt::analysis::Bool boolType;
if (resultType->AsVector() != nullptr) {
const uint32_t count = resultType->AsVector()->element_count();
spvtools::opt::analysis::Vector boolVector(&boolType, count);
*conditionTypeId = typeMgr->GetTypeInstruction(&boolVector);
*dimension = count;
return *conditionTypeId != 0;
}
if (resultType->AsInteger() != nullptr || resultType->AsFloat() != nullptr ||
resultType->AsBool() != nullptr) {
*conditionTypeId = typeMgr->GetTypeInstruction(&boolType);
*dimension = 1;
return *conditionTypeId != 0;
}
return false;
}
// Whether fully unrolling |loop| is bounded work. The trip count is read the
// same way the stock unroller reads it, so a loop this declines to measure is
// one CanPerformUnroll would refuse anyway - the hint would be inert on it,
// and the fallback lowering is what handles it. Requires the induction
// variable to already be an OpPhi, which is why this runs after ssa-rewrite.
bool IsBoundedUnrollCandidate(spvtools::opt::Loop* loop) {
const spvtools::opt::BasicBlock* condition = loop->FindConditionBlock();
if (condition == nullptr) {
return false;
}
const Instruction* induction = loop->FindConditionVariable(condition);
if (induction == nullptr || induction->opcode() != spv::Op::OpPhi) {
return false;
}
size_t iterations = 0;
if (!loop->FindNumberOfIterations(induction, &*condition->ctail(), &iterations)) {
return false;
}
return iterations <= kMaxUnrolledIterations;
}
} // namespace
bool LegalizeStorageBlockArrayIndexPass::BinaryHasDynamicStorageBlockArrayIndexing(
const std::vector<uint32_t>& binary) {
if (binary.empty()) {
return false;
}
std::unique_ptr<IRContext> context = spvtools::BuildModule(
SPV_ENV_VULKAN_1_1,
[](spv_message_level_t, const char*, const spv_position_t&, const char*) {},
binary.data(), binary.size());
if (!context) {
return false;
}
return !CollectDynamicIndexUses(context.get()).empty();
}
spvtools::opt::Pass::Status LegalizeStorageBlockArrayIndexPass::Process() {
return m_mode == Mode::MarkLoopsForUnroll ? MarkLoopsForUnroll() : LowerToConstantSwitch();
}
spvtools::opt::Pass::Status LegalizeStorageBlockArrayIndexPass::MarkLoopsForUnroll() {
auto* irContext = context();
const std::vector<DynamicIndexUse> uses = CollectDynamicIndexUses(irContext);
if (uses.empty()) {
return Status::SuccessWithoutChange;
}
bool modified = false;
for (const DynamicIndexUse& use : uses) {
BasicBlock* block = irContext->get_instr_block(use.accessChain);
if (block == nullptr) {
continue;
}
Function* function = block->GetParent();
if (function == nullptr) {
continue;
}
spvtools::opt::LoopDescriptor* loops = irContext->GetLoopDescriptor(function);
for (spvtools::opt::Loop* loop = (*loops)[block->id()]; loop != nullptr;
loop = loop->GetParent()) {
if (!IsBoundedUnrollCandidate(loop)) {
continue;
}
Instruction* mergeInst = loop->GetHeaderBlock()->GetLoopMergeInst();
// Only a bare `None` control is promoted, and only when no extra
// literal (PartialCount, PeelCount, ...) follows it: the unroller
// tests the control word for equality with Unroll, so ORing the bit
// into a control that already carries something - DontUnroll above
// all - would neither unroll nor mean what it says.
if (mergeInst == nullptr || mergeInst->NumOperands() != 3 ||
mergeInst->GetSingleWordOperand(2) !=
static_cast<uint32_t>(spv::LoopControlMask::MaskNone)) {
continue;
}
mergeInst->SetOperand(
2, {static_cast<uint32_t>(spv::LoopControlMask::Unroll)});
modified = true;
}
}
if (!modified) {
return Status::SuccessWithoutChange;
}
MGLOG_D("[spirv] storage-block array index: marked enclosing loops for full unrolling");
return Status::SuccessWithChange;
}
spvtools::opt::Pass::Status LegalizeStorageBlockArrayIndexPass::LowerToConstantSwitch() {
auto* irContext = context();
bool modified = false;
// Access chains this pass has already refused, so a shape it cannot rewrite
// exactly cannot spin the round loop.
std::unordered_set<uint32_t> declined;
for (int round = 0; round < kMaxLoweringRounds; ++round) {
const std::vector<DynamicIndexUse> uses = CollectDynamicIndexUses(irContext);
bool progressed = false;
for (const DynamicIndexUse& use : uses) {
if (declined.count(use.accessChain->result_id()) != 0) {
continue;
}
const LoweringOutcome outcome = LowerOneChain(use.accessChain, use.arrayLength);
if (outcome == LoweringOutcome::Declined) {
declined.insert(use.accessChain->result_id());
continue;
}
if (outcome == LoweringOutcome::Changed) {
modified = true;
progressed = true;
// A store rewrite splits the block it sat in; every cached
// analysis (and the instruction list this loop is walking) is
// stale from here on. Recollect from scratch.
break;
}
}
if (!progressed) {
break;
}
}
if (!modified) {
return Status::SuccessWithoutChange;
}
return Status::SuccessWithChange;
}
LegalizeStorageBlockArrayIndexPass::LoweringOutcome
LegalizeStorageBlockArrayIndexPass::LowerOneChain(Instruction* accessChain, uint32_t arrayLength) {
auto* irContext = context();
if (arrayLength == 0 || arrayLength > kMaxLoweredArrayLength) {
MGLOG_D("[spirv] storage-block array index: array length %u is not lowerable",
arrayLength);
return LoweringOutcome::Declined;
}
if (!IsLowerableIndexType(irContext, accessChain->GetSingleWordInOperand(1))) {
return LoweringOutcome::Declined;
}
std::vector<Instruction*> stores;
std::vector<Instruction*> loads;
bool unsupportedUse = false;
irContext->get_def_use_mgr()->ForEachUser(accessChain, [&](Instruction* user) {
switch (user->opcode()) {
case spv::Op::OpName:
case spv::Op::OpDecorate:
case spv::Op::OpDecorateId:
return;
case spv::Op::OpStore:
// Only as the pointer. A pointer stored as a *value* is not a storage
// block write and cannot be redirected element-wise.
if (user->GetSingleWordInOperand(0) == accessChain->result_id()) {
stores.push_back(user);
} else {
unsupportedUse = true;
}
return;
case spv::Op::OpLoad:
// Memory operands (Volatile, Aligned, ...) would be dropped by the
// per-element rebuild, so a load carrying any is refused instead.
if (user->NumInOperands() == 1) {
loads.push_back(user);
} else {
unsupportedUse = true;
}
return;
default:
// A pointer passed to a function, copied, chained further, used by an
// atomic, or measured by OpArrayLength cannot be resolved to one
// element here.
unsupportedUse = true;
return;
}
});
if (unsupportedUse) {
MGLOG_D("[spirv] storage-block array index: chain %%%u has a use this pass cannot "
"rewrite",
accessChain->result_id());
return LoweringOutcome::Declined;
}
if (!loads.empty()) {
return LowerLoad(accessChain, arrayLength, loads.front());
}
if (!stores.empty()) {
return LowerStore(accessChain, arrayLength, stores.front());
}
// No uses left: the chain itself is what detection is still seeing.
irContext->KillInst(accessChain);
irContext->InvalidateAnalysesExceptFor(IRContext::kAnalysisNone);
return LoweringOutcome::Changed;
}
// switch (idx) { case 0: arr[0]... = v; break; case 1: arr[1]... = v; break; ... }
//
// The block holding the store is split at the store, and the tail becomes the
// switch's merge block, so whatever followed the store still runs exactly once on
// every path. An index outside [0, length) reaches the default target, which is
// the merge block: nothing is stored, which is what indexing a block array out of
// range already meant.
LegalizeStorageBlockArrayIndexPass::LoweringOutcome
LegalizeStorageBlockArrayIndexPass::LowerStore(Instruction* accessChain, uint32_t arrayLength,
Instruction* store) {
auto* irContext = context();
BasicBlock* block = irContext->get_instr_block(store);
if (block == nullptr) {
return LoweringOutcome::Declined;
}
// Splitting a loop header keeps the label - and so the back edge's target -
// on the first half while the OpLoopMerge moves to the second, which is not
// a loop any more. Refuse instead of producing that.
if (block->GetLoopMergeInst() != nullptr) {
MGLOG_D("[spirv] storage-block array index: store sits in a loop header, declining");
return LoweringOutcome::Declined;
}
Function* function = block->GetParent();
if (function == nullptr) {
return LoweringOutcome::Declined;
}
const uint32_t indexId = accessChain->GetSingleWordInOperand(1);
const uint32_t valueId = store->GetSingleWordInOperand(1);
std::vector<Operand> memoryOperands;
for (uint32_t i = 2; i < store->NumInOperands(); ++i) {
memoryOperands.push_back(store->GetInOperand(i));
}
const uint32_t mergeLabelId = irContext->TakeNextId();
block->SplitBasicBlock(irContext, mergeLabelId, BasicBlock::iterator(store));
// |store| now heads the merge block; the per-element stores replace it.
irContext->KillInst(store);
std::vector<std::pair<Operand::OperandData, uint32_t>> targets;
targets.reserve(arrayLength);
BasicBlock* insertAfter = block;
for (uint32_t element = 0; element < arrayLength; ++element) {
const uint32_t caseLabelId = irContext->TakeNextId();
auto caseBlock = MakeUnique<BasicBlock>(MakeUnique<Instruction>(
irContext, spv::Op::OpLabel, 0, caseLabelId, std::initializer_list<Operand>{}));
caseBlock->SetParent(function);
BasicBlock* casePtr = function->InsertBasicBlockAfter(std::move(caseBlock), insertAfter);
// The builders below register what they add, but this label was built by
// hand: without this the OpSwitch would name a target the def-use manager
// has never seen, which a consistency-checking build calls out.
irContext->AnalyzeDefUse(casePtr->GetLabelInst());
irContext->set_instr_block(casePtr->GetLabelInst(), casePtr);
InstructionBuilder caseBuilder(
irContext, casePtr,
IRContext::kAnalysisDefUse | IRContext::kAnalysisInstrToBlockMapping);
const uint32_t constantId = ConstantLikeIndex(irContext, indexId, element);
Instruction* elementChain =
CloneChainWithConstantIndex(caseBuilder, irContext, accessChain, constantId);
std::vector<Operand> storeOperands;
storeOperands.push_back({SPV_OPERAND_TYPE_ID, {elementChain->result_id()}});
storeOperands.push_back({SPV_OPERAND_TYPE_ID, {valueId}});
for (const Operand& memoryOperand : memoryOperands) {
storeOperands.push_back(memoryOperand);
}
caseBuilder.AddInstruction(
MakeUnique<Instruction>(irContext, spv::Op::OpStore, 0, 0, storeOperands));
caseBuilder.AddBranch(mergeLabelId);
targets.push_back({Operand::OperandData{element}, caseLabelId});
insertAfter = casePtr;
}
InstructionBuilder switchBuilder(
irContext, block, IRContext::kAnalysisDefUse | IRContext::kAnalysisInstrToBlockMapping);
switchBuilder.AddSwitch(indexId, mergeLabelId, targets, mergeLabelId);
if (irContext->get_def_use_mgr()->NumUsers(accessChain) == 0) {
irContext->KillInst(accessChain);
}
irContext->InvalidateAnalysesExceptFor(IRContext::kAnalysisNone);
MGLOG_D("[spirv] storage-block array index: lowered a dynamic write to a %u-way switch",
arrayLength);
return LoweringOutcome::Changed;
}
// A read needs no control flow: load every element through a constant index and
// pick with OpSelect. Reading the elements the shader did not ask for is safe -
// every one of them is a storage block this stage already declares, and an ES
// driver bounds-checks a storage buffer read that lands outside what is bound.
LegalizeStorageBlockArrayIndexPass::LoweringOutcome
LegalizeStorageBlockArrayIndexPass::LowerLoad(Instruction* accessChain, uint32_t arrayLength,
Instruction* load) {
auto* irContext = context();
uint32_t conditionTypeId = 0;
uint32_t dimension = 0;
if (!TryGetSelectConditionType(irContext, load->type_id(), &conditionTypeId, &dimension)) {
MGLOG_D("[spirv] storage-block array index: element type is not selectable, declining");
return LoweringOutcome::Declined;
}
const uint32_t boolTypeId = irContext->get_type_mgr()->GetBoolTypeId();
const uint32_t indexId = accessChain->GetSingleWordInOperand(1);
InstructionBuilder builder(
irContext, load, IRContext::kAnalysisDefUse | IRContext::kAnalysisInstrToBlockMapping);
uint32_t selectedId = 0;
for (uint32_t element = 0; element < arrayLength; ++element) {
const uint32_t constantId = ConstantLikeIndex(irContext, indexId, element);
Instruction* elementChain =
CloneChainWithConstantIndex(builder, irContext, accessChain, constantId);
Instruction* elementLoad = builder.AddLoad(load->type_id(), elementChain->result_id());
if (element == 0) {
// Element 0 is the else-arm of the whole ladder, so an out-of-range
// index reads it - an undefined element for an undefined index.
selectedId = elementLoad->result_id();
continue;
}
Instruction* isElement =
builder.AddBinaryOp(boolTypeId, spv::Op::OpIEqual, indexId, constantId);
uint32_t conditionId = isElement->result_id();
if (dimension > 1) {
std::vector<uint32_t> components(dimension, conditionId);
conditionId = builder.AddCompositeConstruct(conditionTypeId, components)->result_id();
}
selectedId = builder
.AddSelect(load->type_id(), conditionId, elementLoad->result_id(),
selectedId)
->result_id();
}
irContext->ReplaceAllUsesWith(load->result_id(), selectedId);
irContext->KillInst(load);
irContext->InvalidateAnalysesExceptFor(IRContext::kAnalysisNone);
MGLOG_D("[spirv] storage-block array index: lowered a dynamic read to %u constant-indexed "
"loads",
arrayLength);
return LoweringOutcome::Changed;
}
spvtools::Optimizer::PassToken
LegalizeStorageBlockArrayIndexPass::CreateMarkLoopsForUnrollPass() {
return spvtools::Optimizer::PassToken(
MakeUnique<LegalizeStorageBlockArrayIndexPass>(Mode::MarkLoopsForUnroll));
}
spvtools::Optimizer::PassToken
LegalizeStorageBlockArrayIndexPass::CreateLowerToConstantSwitchPass() {
return spvtools::Optimizer::PassToken(
MakeUnique<LegalizeStorageBlockArrayIndexPass>(Mode::LowerToConstantSwitch));
}
} // namespace ShaderTranspiler
} // namespace MG_Util
} // namespace MobileGL
@@ -0,0 +1,120 @@
// MobileGL - MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/LegalizeStorageBlockArrayIndexPass.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 "source/opt/pass.h"
#include "spirv-tools/optimizer.hpp"
#include <Includes.h>
#include <vector>
namespace MobileGL {
namespace MG_Util {
namespace ShaderTranspiler {
// GL 4.3 lets an ARRAY OF SHADER STORAGE BLOCKS be indexed with any
// dynamically-uniform expression (GL 4.6 core / GLSL 4.30 4.1.9). GLSL ES keeps
// the stricter ES 3.1 rule - the index must be a *constant integral expression* -
// and the Qualcomm ES compiler enforces it to the letter:
//
// '[' : indexing into an SSBO array using a non-constant expression is not
// permitted
//
// glslang keeps the whole array as ONE SPIR-V variable, so SPIRV-Cross prints
// `layout(binding = N, std430) buffer Blk { ... } arr[4];` plus `arr[i]` verbatim
// and the stage never compiles. The backend program then links nothing and every
// draw or dispatch that uses it is a silent no-op, which reads back as "the buffer
// was never written" rather than as an error - the frontend has already published
// GL_LINK_STATUS = TRUE from glslang's own link.
//
// Verified on the device: an Adreno 830 ES probe with no MobileGL in the loop
// rejects the non-constant subscript with AND without GL_EXT_gpu_shader5 (which
// the driver does advertise), and accepts a constant one. So the ES 3.2
// "dynamically uniform" relaxation is not a way out - every index really has to
// become a compile-time constant.
//
// Two modes, used as two halves of one legalization in
// ShaderCompiler::LegalizeStorageBlockArrayIndexingForEssl - the same shape, for
// the same reasons, as LegalizeFragmentOutputIndexPass:
//
// MarkLoopsForUnroll - `for (int i = 0; i < 4; ++i) arr[i].x = ...` is the
// common shape, and full unrolling turns its index into a literal at no cost
// in emitted code. spirv-opt's CreateLoopUnrollPass only touches loops whose
// OpLoopMerge carries the Unroll control, so this mode sets that hint on
// exactly the loops that enclose an offending access chain, and only when
// their trip count is known and small. Must run AFTER ssa-rewrite: both the
// trip-count check and the unroller need the induction variable as an OpPhi.
//
// LowerToConstantSwitch - the fallback for a genuinely dynamic index
// (uniform-sourced, which is what the CTS indirect-addressing and resource-max
// cases use). A write through such a chain becomes an OpSwitch over the
// array's range with one constant-indexed store per case; a read becomes one
// constant-indexed load per element combined with OpSelect. This is what ANGLE
// does for the same ES 3.1 rule.
//
// Storage blocks only. A UNIFORM block array is a different namespace with its own
// (less strictly enforced) rule and no observed failure, so it is deliberately left
// alone rather than lowered on speculation.
//
// DirectGLES transpile path only: the original module is legal for Vulkan, which
// has no such restriction, and DirectVulkan must keep seeing the array as one
// descriptor array.
//
// The pass DECLINES - leaving the module untouched rather than half-transforming
// it - whenever it meets a shape it cannot rewrite exactly: a pointer handed to a
// function or chained further, an atomic or an OpArrayLength through the chain, a
// load carrying memory operands, a spec-constant array length, an index that is
// not a 32-bit integer, or a store sitting in a loop header block (splitting there
// would move the OpLoopMerge away from the back edge's target).
class LegalizeStorageBlockArrayIndexPass final : public spvtools::opt::Pass {
public:
enum class Mode {
MarkLoopsForUnroll,
LowerToConstantSwitch,
};
explicit LegalizeStorageBlockArrayIndexPass(Mode mode) : m_mode(mode) {}
const char* name() const override {
return m_mode == Mode::MarkLoopsForUnroll
? "mobilegl-mark-storage-block-array-index-loops"
: "mobilegl-lower-storage-block-array-index";
}
Status Process() override;
static spvtools::Optimizer::PassToken CreateMarkLoopsForUnrollPass();
static spvtools::Optimizer::PassToken CreateLowerToConstantSwitchPass();
// The detection half, on a serialized module: true when an array of storage
// blocks is indexed with anything but an OpConstant. Cheap enough to gate the
// whole legalization on (one BuildModule, no serialization) and used again
// after the folding chain to decide whether the fallback has to run at all.
static bool BinaryHasDynamicStorageBlockArrayIndexing(const std::vector<uint32_t>& binary);
private:
enum class LoweringOutcome {
// The shape is not one this pass can rewrite exactly; the module keeps
// the illegal chain rather than a half-transform of it.
Declined,
Changed,
};
Status MarkLoopsForUnroll();
Status LowerToConstantSwitch();
LoweringOutcome LowerOneChain(spvtools::opt::Instruction* accessChain, uint32_t arrayLength);
LoweringOutcome LowerStore(spvtools::opt::Instruction* accessChain, uint32_t arrayLength,
spvtools::opt::Instruction* store);
LoweringOutcome LowerLoad(spvtools::opt::Instruction* accessChain, uint32_t arrayLength,
spvtools::opt::Instruction* load);
Mode m_mode;
};
} // namespace ShaderTranspiler
} // namespace MG_Util
} // namespace MobileGL