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

This commit is contained in:
2026-08-10 13:00:20 -04:00
parent 94233ef928
commit b219992ee3
13 changed files with 1021 additions and 43 deletions
@@ -0,0 +1,250 @@
// MobileGL - MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/PrivateToEntryLocalPass.cpp
// Copyright (c) 2025-2026 MobileGL-Dev
// Licensed under the GNU Lesser General Public License v3.0:
// https://www.gnu.org/licenses/gpl-3.0.txt
// https://www.gnu.org/licenses/lgpl-3.0.txt
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
//
// Derived from SPIRV-Tools' PrivateToLocalPass (source/opt/private_to_local_pass.cpp,
// Copyright (c) 2017 Google Inc., Apache License 2.0). The one behavioral difference is
// the entry-point restriction in FindEntryLocalFunction; see the header for why.
#include "PrivateToEntryLocalPass.h"
#include "source/opt/ir_context.h"
#include "source/opt/type_manager.h"
#include "source/spirv_constant.h"
#include "source/util/make_unique.h"
#include <cassert>
#include <utility>
#include <vector>
#include <unordered_set>
namespace MobileGL {
namespace MG_Util {
namespace ShaderTranspiler {
namespace {
using spvtools::opt::BasicBlock;
using spvtools::opt::Function;
using spvtools::opt::Instruction;
using spvtools::opt::Operand;
constexpr uint32_t kVariableStorageClassInIdx = 0;
constexpr uint32_t kSpvTypePointerTypeIdInIdx = 1;
} // namespace
spvtools::opt::Pass::Status PrivateToEntryLocalPass::Process() {
// Private variables require the Shader capability; with Addresses the
// rewrite below is not guaranteed sound (variable pointers may escape).
if (context()->get_feature_mgr()->HasCapability(spv::Capability::Addresses)) {
return Status::SuccessWithoutChange;
}
std::vector<std::pair<Instruction*, Function*>> variablesToMove;
std::unordered_set<uint32_t> localizedVariables;
for (auto& inst : context()->types_values()) {
if (inst.opcode() != spv::Op::OpVariable) {
continue;
}
if (spv::StorageClass(inst.GetSingleWordInOperand(kVariableStorageClassInIdx)) !=
spv::StorageClass::Private) {
continue;
}
Function* targetFunction = FindEntryLocalFunction(inst);
if (targetFunction != nullptr) {
variablesToMove.push_back({&inst, targetFunction});
}
}
const bool modified = !variablesToMove.empty();
for (auto& p : variablesToMove) {
if (!MoveVariable(p.first, p.second)) {
return Status::Failure;
}
localizedVariables.insert(p.first->result_id());
}
if (get_module()->version() >= SPV_SPIRV_VERSION_WORD(1, 4)) {
// SPIR-V 1.4+ lists statically-used Private variables on OpEntryPoint;
// drop the ones that just stopped being Private. Dead code for the 1.3
// modules MobileGL emits, kept for robustness.
for (auto& entry : get_module()->entry_points()) {
std::vector<Operand> newOperands;
for (uint32_t i = 0; i < entry.NumInOperands(); ++i) {
// Execution model, function id and name are always kept.
if (i < 3 || !localizedVariables.count(entry.GetSingleWordInOperand(i))) {
newOperands.push_back(entry.GetInOperand(i));
}
}
if (newOperands.size() != entry.NumInOperands()) {
entry.SetInOperands(std::move(newOperands));
context()->AnalyzeUses(&entry);
}
}
}
return modified ? Status::SuccessWithChange : Status::SuccessWithoutChange;
}
Function* PrivateToEntryLocalPass::FindEntryLocalFunction(const Instruction& inst) const {
bool foundFirstUse = false;
Function* targetFunction = nullptr;
const uint32_t variableId = inst.result_id();
context()->get_def_use_mgr()->ForEachUser(
variableId, [&targetFunction, &foundFirstUse, variableId, this](Instruction* use) {
BasicBlock* currentBlock = context()->get_instr_block(use);
if (currentBlock == nullptr) {
// Module-scope users: OpName, decorations, the OpEntryPoint
// interface list. None of them pins the variable to a function,
// but a debug-info extended instruction would go stale after the
// move, so treat it as disqualifying.
if (use->opcode() == spv::Op::OpExtInst) {
foundFirstUse = true;
targetFunction = nullptr;
}
return;
}
if (!IsValidUse(use, variableId)) {
foundFirstUse = true;
targetFunction = nullptr;
return;
}
Function* currentFunction = currentBlock->GetParent();
if (!foundFirstUse) {
foundFirstUse = true;
targetFunction = currentFunction;
} else if (targetFunction != currentFunction) {
targetFunction = nullptr;
}
});
if (targetFunction != nullptr && !IsEntryPointFunction(targetFunction)) {
// The whole point of this derivative: a helper can be called more than
// once per invocation, and Function storage would reset the variable at
// every call.
return nullptr;
}
return targetFunction;
}
bool PrivateToEntryLocalPass::IsEntryPointFunction(Function* function) const {
for (auto& entry : get_module()->entry_points()) {
if (entry.GetSingleWordInOperand(1) == function->result_id()) {
return true;
}
}
return false;
}
bool PrivateToEntryLocalPass::IsValidUse(const Instruction* inst, uint32_t variableId) const {
// The cases here have to match the cases in UpdateUse: a use the rewrite
// does not know how to update disqualifies the variable.
switch (inst->opcode()) {
case spv::Op::OpLoad:
case spv::Op::OpImageTexelPointer: // treat like a load
return true;
case spv::Op::OpStore:
// Storing the variable's ADDRESS somewhere else escapes it.
return inst->GetOperand(1).AsId() != variableId;
case spv::Op::OpAccessChain:
return context()->get_def_use_mgr()->WhileEachUser(
inst, [this, inst](const Instruction* user) {
return IsValidUse(user, inst->result_id());
});
case spv::Op::OpName:
return true;
default:
return spvOpcodeIsDecoration(inst->opcode());
}
}
bool PrivateToEntryLocalPass::MoveVariable(Instruction* variable, Function* function) {
// Remove from the global section and re-insert at the head of the entry
// function's first block, Function-storage variables' one legal position.
variable->RemoveFromList();
std::unique_ptr<Instruction> var(variable); // take ownership
context()->ForgetUses(variable);
variable->SetInOperand(kVariableStorageClassInIdx,
{uint32_t(spv::StorageClass::Function)});
const uint32_t newTypeId = GetNewType(variable->type_id());
if (newTypeId == 0) {
return false;
}
variable->SetResultType(newTypeId);
context()->AnalyzeUses(variable);
context()->set_instr_block(variable, &*function->begin());
function->begin()->begin()->InsertBefore(std::move(var));
return UpdateUses(variable);
}
uint32_t PrivateToEntryLocalPass::GetNewType(uint32_t oldTypeId) {
auto* typeMgr = context()->get_type_mgr();
Instruction* oldTypeInst = get_def_use_mgr()->GetDef(oldTypeId);
const uint32_t pointeeTypeId =
oldTypeInst->GetSingleWordInOperand(kSpvTypePointerTypeIdInIdx);
const uint32_t newTypeId =
typeMgr->FindPointerToType(pointeeTypeId, spv::StorageClass::Function);
if (newTypeId != 0) {
context()->UpdateDefUse(context()->get_def_use_mgr()->GetDef(newTypeId));
}
return newTypeId;
}
bool PrivateToEntryLocalPass::UpdateUse(Instruction* inst, Instruction* user) {
// The cases here have to match the cases in IsValidUse.
switch (inst->opcode()) {
case spv::Op::OpLoad:
case spv::Op::OpStore:
case spv::Op::OpImageTexelPointer: // treat like a load
// Fine as-is: their type is the pointed-to type, which is unchanged.
break;
case spv::Op::OpAccessChain: {
context()->ForgetUses(inst);
const uint32_t newTypeId = GetNewType(inst->type_id());
if (newTypeId == 0) {
return false;
}
inst->SetResultType(newTypeId);
context()->AnalyzeUses(inst);
if (!UpdateUses(inst)) {
return false;
}
break;
}
case spv::Op::OpName:
case spv::Op::OpEntryPoint: // handled separately in Process()
break;
default:
assert(spvOpcodeIsDecoration(inst->opcode()) &&
"PrivateToEntryLocalPass: unexpected use opcode");
break;
}
(void)user;
return true;
}
bool PrivateToEntryLocalPass::UpdateUses(Instruction* inst) {
const uint32_t id = inst->result_id();
std::vector<Instruction*> uses;
context()->get_def_use_mgr()->ForEachUser(id,
[&uses](Instruction* use) { uses.push_back(use); });
for (Instruction* use : uses) {
if (!UpdateUse(use, inst)) {
return false;
}
}
return true;
}
spvtools::Optimizer::PassToken PrivateToEntryLocalPass::CreatePrivateToEntryLocalPass() {
return spvtools::Optimizer::PassToken(
spvtools::MakeUnique<PrivateToEntryLocalPass>());
}
} // namespace ShaderTranspiler
} // namespace MG_Util
} // namespace MobileGL
@@ -0,0 +1,55 @@
// MobileGL - MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/PrivateToEntryLocalPass.h
// Copyright (c) 2025-2026 MobileGL-Dev
// Licensed under the GNU Lesser General Public License v3.0:
// https://www.gnu.org/licenses/gpl-3.0.txt
// https://www.gnu.org/licenses/lgpl-3.0.txt
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
#pragma once
#include "spirv-tools/optimizer.hpp"
#include "source/opt/pass.h"
namespace MobileGL {
namespace MG_Util {
namespace ShaderTranspiler {
// AggressiveDCE treats every store to a Private global as an observable side
// effect while the entry point still contains any OpFunctionCall, so a dead
// vertex-input -> Private-shim chain (Iris rewrites unused legacy attributes
// into exactly this shape) survives the whole optimizer chain. Rewriting such
// a variable to Function storage unlocks ADCE without inlining anything.
//
// Upstream's PrivateToLocalPass does that rewrite for a Private variable used
// in ANY single function - which is unsound here: a Function-storage variable
// is recreated on every call, so a Private global that carries state across
// repeated calls of one helper (a memoized init flag, LCG rand state) would
// silently lose it. This derivative applies the same rewrite restricted to
// variables whose only using function is an entry point: an entry point runs
// once per invocation, so the two lifetimes are indistinguishable there.
//
// Derived from SPIRV-Tools' PrivateToLocalPass
// (source/opt/private_to_local_pass.cpp, Copyright (c) 2017 Google Inc.,
// Apache License 2.0).
class PrivateToEntryLocalPass final : public spvtools::opt::Pass {
public:
const char* name() const override { return "mobilegl-private-to-entry-local"; }
Status Process() override;
static spvtools::Optimizer::PassToken CreatePrivateToEntryLocalPass();
private:
// The single entry-point function every block-level use of the variable
// lives in, or nullptr when the uses span functions, include an opcode the
// rewrite cannot update, or belong to a non-entry function.
spvtools::opt::Function* FindEntryLocalFunction(const spvtools::opt::Instruction& inst) const;
bool IsEntryPointFunction(spvtools::opt::Function* function) const;
bool IsValidUse(const spvtools::opt::Instruction* inst, uint32_t variableId) const;
bool MoveVariable(spvtools::opt::Instruction* variable, spvtools::opt::Function* function);
uint32_t GetNewType(uint32_t oldTypeId);
bool UpdateUse(spvtools::opt::Instruction* inst, spvtools::opt::Instruction* user);
bool UpdateUses(spvtools::opt::Instruction* inst);
};
} // namespace ShaderTranspiler
} // namespace MG_Util
} // namespace MobileGL
@@ -0,0 +1,59 @@
// MobileGL - MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/StripUniformLocationsPass.cpp
// Copyright (c) 2025-2026 MobileGL-Dev
// Licensed under the GNU Lesser General Public License v3.0:
// https://www.gnu.org/licenses/gpl-3.0.txt
// https://www.gnu.org/licenses/lgpl-3.0.txt
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
#include "StripUniformLocationsPass.h"
#include "source/opt/ir_context.h"
#include "source/util/make_unique.h"
#include <vector>
namespace MobileGL {
namespace MG_Util {
namespace ShaderTranspiler {
spvtools::opt::Pass::Status StripUniformLocationsPass::Process() {
using spvtools::opt::Instruction;
std::vector<Instruction*> toKill;
for (auto& annotation : get_module()->annotations()) {
if (annotation.opcode() != spv::Op::OpDecorate) {
continue;
}
if (annotation.GetSingleWordInOperand(1) !=
static_cast<uint32_t>(spv::Decoration::Location)) {
continue;
}
Instruction* target =
get_def_use_mgr()->GetDef(annotation.GetSingleWordInOperand(0));
if (target == nullptr || target->opcode() != spv::Op::OpVariable) {
continue;
}
switch (spv::StorageClass(target->GetSingleWordInOperand(0))) {
case spv::StorageClass::UniformConstant:
case spv::StorageClass::Uniform:
case spv::StorageClass::StorageBuffer:
toKill.push_back(&annotation);
break;
default:
break;
}
}
for (Instruction* inst : toKill) {
context()->KillInst(inst);
}
return toKill.empty() ? Status::SuccessWithoutChange : Status::SuccessWithChange;
}
spvtools::Optimizer::PassToken StripUniformLocationsPass::CreateStripUniformLocationsPass() {
return spvtools::Optimizer::PassToken(
spvtools::MakeUnique<StripUniformLocationsPass>());
}
} // namespace ShaderTranspiler
} // namespace MG_Util
} // namespace MobileGL
@@ -0,0 +1,32 @@
// MobileGL - MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/StripUniformLocationsPass.h
// Copyright (c) 2025-2026 MobileGL-Dev
// Licensed under the GNU Lesser General Public License v3.0:
// https://www.gnu.org/licenses/gpl-3.0.txt
// https://www.gnu.org/licenses/lgpl-3.0.txt
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
#pragma once
#include "spirv-tools/optimizer.hpp"
#include "source/opt/pass.h"
namespace MobileGL {
namespace MG_Util {
namespace ShaderTranspiler {
// glslang's relaxed GL path keeps `layout(location = N) uniform ...` as a
// Location decoration on the UniformConstant/Uniform variable, which Vulkan
// forbids ([VUID-StandaloneSpirv-Location-06672]). Nothing downstream reads
// it: GL-side uniform locations come from the phase-A glslang reflection,
// Vulkan binding assignment goes by (kind, name), and SPIRV-Cross's ESSL
// resolves uniforms by name. Strip it so the driver-bound module is valid.
class StripUniformLocationsPass final : public spvtools::opt::Pass {
public:
const char* name() const override { return "mobilegl-strip-uniform-locations"; }
Status Process() override;
static spvtools::Optimizer::PassToken CreateStripUniformLocationsPass();
};
} // namespace ShaderTranspiler
} // namespace MG_Util
} // namespace MobileGL