[Feat, Test] (MG_Util): demote every 64-bit float in a shader to 32 bits, with the block layout re-derived

This commit is contained in:
2026-08-12 06:11:50 -04:00
parent 2fced2241b
commit 532836c058
7 changed files with 1174 additions and 0 deletions
+1
View File
@@ -276,6 +276,7 @@ set(SOURCE_FILES
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/RenameBuiltinShadowingFunctionsPass.cpp
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/DecomposeWorkgroupVec3Pass.cpp
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/DecoratePositionInvariantPass.cpp
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/DemoteFloat64Pass.cpp
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/LowerDrawParametersPass.cpp
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/PackDoubleVertexInputsPass.cpp
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/SplitArrayVertexInputsPass.cpp
@@ -3,6 +3,7 @@ cmake_minimum_required(VERSION 3.14)
add_executable(
SpirvPassTest
SpirvPassTest.cpp
DemoteFloat64Test.cpp
)
target_include_directories(SpirvPassTest PRIVATE
@@ -0,0 +1,486 @@
// MobileGL - MobileGL/MG_Test/ShaderTranspiler/DemoteFloat64Test.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 <gtest/gtest.h>
#include <string>
#include <vector>
#include "Includes.h"
#include "Init.h"
#include <MG_Util/ShaderTranspiler/ShaderCompiler.h>
#include <MG_Util/ShaderTranspiler/SpvcSession.h>
#include <MG_Util/ShaderTranspiler/Types.h>
#include <spirv-tools/libspirv.hpp>
using namespace MobileGL;
using MobileGL::MG_Util::ShaderTranspiler::ShaderCompiler;
namespace {
// The test-side reference walker, deliberately independent of the production code: a bug in
// the pass must not be able to hide behind the same helper. Counts OpTypeFloat declarations of
// a given width and collects the Offset literal of every OpMemberDecorate, in module order.
constexpr Uint32 kSpirvHeaderWordCount = 5;
constexpr Uint32 kOpTypeFloat = 22;
constexpr Uint32 kOpName = 5;
constexpr Uint32 kOpMemberDecorate = 72;
constexpr Uint32 kOpFConvert = 115;
constexpr Uint32 kOpCapability = 17;
constexpr Uint32 kDecorationOffset = 35;
constexpr Uint32 kCapabilityFloat64 = 10;
template <typename Visitor>
void ForEachInstruction(const Vector<Uint32>& spirv, Visitor&& visit) {
for (SizeT i = kSpirvHeaderWordCount; i < spirv.size();) {
const Uint32 wordCount = spirv[i] >> 16;
const Uint32 opcode = spirv[i] & 0xFFFFu;
if (wordCount == 0 || i + wordCount > spirv.size()) break;
visit(opcode, &spirv[i], wordCount);
i += wordCount;
}
}
Uint32 CountFloatTypesOfWidth(const Vector<Uint32>& spirv, Uint32 width) {
Uint32 count = 0;
ForEachInstruction(spirv, [&](Uint32 opcode, const Uint32* words, Uint32 wordCount) {
if (opcode == kOpTypeFloat && wordCount >= 3 && words[2] == width) ++count;
});
return count;
}
Uint32 CountFConverts(const Vector<Uint32>& spirv) {
Uint32 count = 0;
ForEachInstruction(spirv, [&](Uint32 opcode, const Uint32*, Uint32) {
if (opcode == kOpFConvert) ++count;
});
return count;
}
Bool DeclaresFloat64Capability(const Vector<Uint32>& spirv) {
Bool found = false;
ForEachInstruction(spirv, [&](Uint32 opcode, const Uint32* words, Uint32 wordCount) {
if (opcode == kOpCapability && wordCount >= 2 && words[1] == kCapabilityFloat64) found = true;
});
return found;
}
// Byte offset of every member of the struct named `blockName`, in member order.
Vector<Uint32> CollectOffsetsOf(const Vector<Uint32>& spirv, const String& blockName) {
Uint32 structId = 0;
ForEachInstruction(spirv, [&](Uint32 opcode, const Uint32* words, Uint32 wordCount) {
if (opcode != kOpName || wordCount < 3 || structId != 0) return;
const char* text = reinterpret_cast<const char*>(&words[2]);
const SizeT maxBytes = (wordCount - 2) * sizeof(Uint32);
if (std::strncmp(text, blockName.c_str(), maxBytes) == 0) structId = words[1];
});
if (structId == 0) return {};
std::map<Uint32, Uint32> offsetByMember;
ForEachInstruction(spirv, [&](Uint32 opcode, const Uint32* words, Uint32 wordCount) {
if (opcode == kOpMemberDecorate && wordCount >= 5 && words[1] == structId &&
words[3] == kDecorationOffset) {
offsetByMember[words[2]] = words[4];
}
});
Vector<Uint32> offsets;
for (const auto& [member, offset] : offsetByMember) offsets.push_back(offset);
return offsets;
}
// Everything the production pipeline does to a source before the pass sees it.
Vector<Uint32> CompileToSpirv(GLenum stage, const String& source) {
using namespace MG_Util::ShaderTranspiler;
ShaderAttrib shaderAttrib{.shaderType = stage, .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 = {stage}, .program = *programResult.value()};
auto binaryResult = ShaderCompiler::GetSpirvBinaryFromProgram(binaryAttrib);
EXPECT_TRUE(binaryResult) << (binaryResult ? String{} : binaryResult.error().log);
if (!binaryResult || binaryResult->empty()) return {};
return binaryResult->front();
}
String Disassemble(const Vector<Uint32>& spirv) {
spvtools::SpirvTools tools(SPV_ENV_VULKAN_1_1);
String text;
tools.Disassemble(spirv, &text);
return text;
}
// A vertex shader that exercises every shape the pass has to handle at once: a block with
// double / dvec2 / dvec3 / dvec4 / dmat4 members between two floats (so a shifted offset would
// be visible), a default-block double uniform, a 64-bit vertex input, a double-typed array, an
// implicit float->double conversion and an explicit double->float one.
const char* kWideVertexSource = R"(#version 460 core
layout(std140, binding = 0) uniform Blk {
float a;
double d;
dvec2 v2;
dvec3 v3;
dvec4 v4;
dmat4 m4;
double arr[3];
float z;
};
layout(location = 0) uniform double uScale;
layout(location = 0) in dvec3 inPos;
layout(location = 1) in vec3 inNormal;
layout(location = 0) out float vOut;
void main() {
double s = d * uScale + a;
dvec3 p = inPos * v3 + v2.xyx + v4.xyz + dvec3(m4[0].xyz);
s += p.x + p.y + p.z + arr[0] + arr[1] + arr[2] + z + 0.5lf;
vOut = float(s) + inNormal.x;
gl_Position = vec4(float(s));
}
)";
} // namespace
class DemoteFloat64Test : public ::testing::Test {
protected:
void SetUp() override {
MobileGL::Initialize();
ShaderCompiler::SetSpirvValidationEnabled(true);
m_validationFailuresAtStart = ShaderCompiler::SpirvValidationFailureCount();
}
void TearDown() override {
// The wrapper validates its OUTPUT on every run, so this covers every demotion the test
// performed without any of them having to say so.
EXPECT_EQ(ShaderCompiler::SpirvValidationFailureCount(), m_validationFailuresAtStart)
<< "the demoted module did not survive spirv-val";
}
Uint64 m_validationFailuresAtStart = 0;
};
TEST_F(DemoteFloat64Test, DemotesEveryWidthAndDropsTheCapability) {
const Vector<Uint32> input = CompileToSpirv(GL_VERTEX_SHADER, kWideVertexSource);
ASSERT_FALSE(input.empty());
ASSERT_EQ(CountFloatTypesOfWidth(input, 64), 1u) << Disassemble(input);
ASSERT_TRUE(DeclaresFloat64Capability(input));
Vector<Uint32> output;
ASSERT_TRUE(ShaderCompiler::DemoteFloat64ToFloat32(input, output));
EXPECT_EQ(CountFloatTypesOfWidth(output, 64), 0u) << Disassemble(output);
// And exactly one 32-bit float type survives: the merge has to happen, or spirv-val rejects
// the second declaration.
EXPECT_EQ(CountFloatTypesOfWidth(output, 32), 1u) << Disassemble(output);
EXPECT_FALSE(DeclaresFloat64Capability(output));
}
TEST_F(DemoteFloat64Test, RederivesTheStd140LayoutOfADemotedUniformBlock) {
const String source = R"(#version 460 core
layout(std140, binding = 0) uniform Blk {
float a;
double d;
dvec2 v2;
dvec3 v3;
dvec4 v4;
dmat4 m4;
double arr[3];
float z;
};
layout(location = 0) out float vOut;
void main() {
vOut = float(d + v2.x + v3.y + v4.z + m4[2].w + arr[1] + a + z);
gl_Position = vec4(vOut);
}
)";
const Vector<Uint32> input = CompileToSpirv(GL_VERTEX_SHADER, source);
ASSERT_FALSE(input.empty());
// What glslang laid out for the 64-bit members, which is what an application computing
// std140 by hand would also get.
EXPECT_EQ(CollectOffsetsOf(input, "Blk"), (Vector<Uint32>{0, 8, 16, 32, 64, 96, 224, 272}))
<< Disassemble(input);
Vector<Uint32> output;
ASSERT_TRUE(ShaderCompiler::DemoteFloat64ToFloat32(input, output));
// std140 for the demoted members: float at 4, vec2 at 8, vec3 at 16 (aligned like a vec4),
// vec4 at 32, mat4 at 48 with a 16-byte column stride, the array at 112 with the std140
// 16-byte element stride, and the trailing float at 160. This is what SPIRV-Cross has to be
// able to re-derive for GLSL ES, which has no member layout(offset=) to fall back on.
EXPECT_EQ(CollectOffsetsOf(output, "Blk"), (Vector<Uint32>{0, 4, 8, 16, 32, 48, 112, 160}))
<< Disassemble(output);
const String text = Disassemble(output);
EXPECT_NE(text.find("MatrixStride 16"), String::npos) << text;
EXPECT_NE(text.find("ArrayStride 16"), String::npos) << text;
}
TEST_F(DemoteFloat64Test, RederivesTheStd430LayoutOfADemotedStorageBlock) {
const String source = R"(#version 460 core
layout(std430, binding = 0) buffer Ssbo {
double head;
dvec4 wide;
double tail[4];
};
layout(location = 0) out float vOut;
void main() {
vOut = float(head + wide.w + tail[3]);
gl_Position = vec4(vOut);
}
)";
const Vector<Uint32> input = CompileToSpirv(GL_VERTEX_SHADER, source);
ASSERT_FALSE(input.empty());
EXPECT_EQ(CollectOffsetsOf(input, "Ssbo"), (Vector<Uint32>{0, 32, 64})) << Disassemble(input);
Vector<Uint32> output;
ASSERT_TRUE(ShaderCompiler::DemoteFloat64ToFloat32(input, output));
// std430, so the array packs at its element size rather than being rounded to 16: float at 0,
// vec4 at 16, float[4] at 32 with a 4-byte stride. A storage block must NOT come out std140,
// which is the whole reason the packing is chosen per storage class.
EXPECT_EQ(CollectOffsetsOf(output, "Ssbo"), (Vector<Uint32>{0, 16, 32})) << Disassemble(output);
EXPECT_NE(Disassemble(output).find("ArrayStride 4"), String::npos) << Disassemble(output);
}
TEST_F(DemoteFloat64Test, LeavesTheLayoutOfABlockWithoutDoublesAlone) {
const String source = R"(#version 460 core
layout(std140, binding = 0) uniform Blk {
float a;
vec3 v3;
mat4 m4;
};
layout(std140, binding = 1) uniform Wide {
float w;
double d;
};
layout(location = 0) out float vOut;
void main() {
vOut = float(a + v3.y + m4[1].z + float(d) + w);
gl_Position = vec4(vOut);
}
)";
const Vector<Uint32> input = CompileToSpirv(GL_VERTEX_SHADER, source);
ASSERT_FALSE(input.empty());
const Vector<Uint32> before = CollectOffsetsOf(input, "Blk");
ASSERT_FALSE(before.empty());
Vector<Uint32> output;
ASSERT_TRUE(ShaderCompiler::DemoteFloat64ToFloat32(input, output));
// Only the block that actually narrowed is re-laid-out. Touching the other one would be
// churn at best, and a disagreement with glslang's own layout at worst.
EXPECT_EQ(CollectOffsetsOf(output, "Blk"), before) << Disassemble(output);
EXPECT_EQ(CollectOffsetsOf(output, "Wide"), (Vector<Uint32>{0, 4})) << Disassemble(output);
}
TEST_F(DemoteFloat64Test, FoldsTheConversionsThatBecameIdentities) {
const Vector<Uint32> input = CompileToSpirv(GL_VERTEX_SHADER, kWideVertexSource);
ASSERT_FALSE(input.empty());
ASSERT_GT(CountFConverts(input), 0u) << "the fixture no longer converts between the two widths";
Vector<Uint32> output;
ASSERT_TRUE(ShaderCompiler::DemoteFloat64ToFloat32(input, output));
// SPIR-V requires the two component widths of an OpFConvert to differ, so every one of them
// has to be gone: both sides are 32 bits now.
EXPECT_EQ(CountFConverts(output), 0u) << Disassemble(output);
}
TEST_F(DemoteFloat64Test, NarrowsDoubleConstantsToTheirFloatValue) {
const String source = R"(#version 460 core
layout(location = 0) out float outValue;
void main() {
double d = 0.5lf;
outValue = float(d * 0.25lf);
gl_Position = vec4(0.0);
}
)";
const Vector<Uint32> input = CompileToSpirv(GL_VERTEX_SHADER, source);
ASSERT_FALSE(input.empty());
Vector<Uint32> output;
ASSERT_TRUE(ShaderCompiler::DemoteFloat64ToFloat32(input, output));
// A 64-bit literal is two words wide and a 32-bit one is a single word, so a constant left
// unconverted is not merely imprecise - it is an unparseable instruction. Disassembling both
// values proves the re-encode produced the right number, not just the right width.
const String text = Disassemble(output);
EXPECT_NE(text.find("OpConstant %float 0.5"), String::npos) << text;
EXPECT_NE(text.find("OpConstant %float 0.25"), String::npos) << text;
}
TEST_F(DemoteFloat64Test, LeavesAModuleWithoutDoublesByteIdentical) {
const String source = R"(#version 460 core
layout(location = 0) in vec4 inPos;
void main() { gl_Position = inPos; }
)";
const Vector<Uint32> input = CompileToSpirv(GL_VERTEX_SHADER, source);
ASSERT_FALSE(input.empty());
Vector<Uint32> output;
ASSERT_TRUE(ShaderCompiler::DemoteFloat64ToFloat32(input, output));
// The pass reports SuccessWithoutChange here, and SPIRV-Tools asserts (in assert-enabled
// builds) that such a run round-trips byte-identically.
EXPECT_EQ(output, input);
}
TEST_F(DemoteFloat64Test, DeclinesAModuleThatBitcastsAcrossTheWidthBoundary) {
// packDouble2x32 is defined only for a 64-bit result: there is no 32-bit answer to give, and
// narrowing one side of the surrounding OpBitcast alone produces a module spirv-val rejects.
// The contract is that such a module comes back untouched rather than broken.
const String source = R"(#version 460 core
#extension GL_ARB_gpu_shader_fp64 : require
layout(location = 0) uniform uvec2 uPacked;
layout(location = 0) out float outValue;
void main() {
double d = packDouble2x32(uPacked);
outValue = float(d);
gl_Position = vec4(0.0);
}
)";
const Vector<Uint32> input = CompileToSpirv(GL_VERTEX_SHADER, source);
ASSERT_FALSE(input.empty());
ASSERT_EQ(CountFloatTypesOfWidth(input, 64), 1u) << Disassemble(input);
Vector<Uint32> output;
ASSERT_TRUE(ShaderCompiler::DemoteFloat64ToFloat32(input, output));
EXPECT_EQ(output, input) << Disassemble(output);
EXPECT_TRUE(ShaderCompiler::ModuleDeclaresFloat64(output));
}
TEST_F(DemoteFloat64Test, ModuleDeclaresFloat64AnswersBothWays) {
const Vector<Uint32> wide = CompileToSpirv(GL_VERTEX_SHADER, kWideVertexSource);
ASSERT_FALSE(wide.empty());
EXPECT_TRUE(ShaderCompiler::ModuleDeclaresFloat64(wide));
Vector<Uint32> demoted;
ASSERT_TRUE(ShaderCompiler::DemoteFloat64ToFloat32(wide, demoted));
EXPECT_FALSE(ShaderCompiler::ModuleDeclaresFloat64(demoted));
EXPECT_FALSE(ShaderCompiler::ModuleDeclaresFloat64({}));
}
TEST_F(DemoteFloat64Test, TheSharedChainDemotesToo) {
// Production never calls the pass on its own: it reaches it through the one chain every
// module goes through at link, on both backends.
const Vector<Uint32> input = CompileToSpirv(GL_VERTEX_SHADER, kWideVertexSource);
ASSERT_FALSE(input.empty());
Vector<Uint32> output;
ASSERT_TRUE(ShaderCompiler::SanitizeAndOptimizeBinary(input, output));
EXPECT_FALSE(ShaderCompiler::ModuleDeclaresFloat64(output)) << Disassemble(output);
}
// The payoff on the Espryt path: SPIRV-Cross throws "FP64 not supported in ES profile" for every
// one of these before demotion, so the program simply could not be transpiled at all.
class DemoteFloat64EsslTest : public DemoteFloat64Test, public ::testing::WithParamInterface<const char*> {};
INSTANTIATE_TEST_SUITE_P(
Shapes, DemoteFloat64EsslTest,
::testing::Values(
// A double that never reaches an interface: locals and literals only.
R"(#version 460 core
layout(location = 0) out float vOut;
void main() {
double s = 0.5lf;
for (int i = 0; i < 3; ++i) s = s * 1.5lf + 0.25lf;
vOut = float(s);
gl_Position = vec4(float(s));
}
)",
// A default-block double uniform: the glUniform*d path, and the block MobileGL lays out
// itself.
R"(#version 460 core
layout(location = 0) uniform double uScale;
layout(location = 1) uniform dvec3 uOffset;
layout(location = 2) uniform dmat4 uTransform;
layout(location = 0) out float vOut;
void main() {
dvec3 p = uOffset * uScale + dvec3(uTransform[1].xyz);
vOut = float(p.x + p.y + p.z);
gl_Position = vec4(float(p.x));
}
)",
// An application-declared std140 block whose members are 64-bit.
R"(#version 460 core
layout(std140, binding = 0) uniform Blk {
float a;
double d;
dvec2 v2;
dvec3 v3;
dvec4 v4;
dmat4 m4;
double arr[3];
float z;
};
layout(location = 0) out float vOut;
void main() {
double s = d + v2.x + v3.y + v4.z + m4[2].w + arr[1] + a + z;
vOut = float(s);
gl_Position = vec4(float(s));
}
)",
// A 64-bit vertex input, which is what glVertexAttribLFormat feeds.
R"(#version 460 core
layout(location = 0) in dvec3 inPos;
layout(location = 2) in double inWeight;
layout(location = 0) out float vOut;
void main() {
vOut = float(inPos.x + inPos.y + inPos.z + inWeight);
gl_Position = vec4(vOut);
}
)",
// An std430 storage block, whose double members pack differently again.
R"(#version 460 core
layout(std430, binding = 0) buffer Ssbo {
double head;
dvec4 wide;
double tail[4];
};
layout(location = 0) out float vOut;
void main() {
double s = head + wide.w + tail[3];
vOut = float(s);
gl_Position = vec4(float(s));
}
)"));
TEST_P(DemoteFloat64EsslTest, TheDemotedModuleCanBeEmittedAsEssl) {
using namespace MG_Util::ShaderTranspiler;
const Vector<Uint32> input = CompileToSpirv(GL_VERTEX_SHADER, GetParam());
ASSERT_FALSE(input.empty());
Vector<Uint32> output;
ASSERT_TRUE(ShaderCompiler::SanitizeAndOptimizeBinary(input, output));
SpvcSession session(output, SessionUsageBit::Transpile);
spvc_compiler_options options;
ASSERT_EQ(session.CreateOptions(&options), SPVC_SUCCESS);
spvc_compiler_options_set_uint(options, SPVC_COMPILER_OPTION_GLSL_VERSION, 320);
spvc_compiler_options_set_bool(options, SPVC_COMPILER_OPTION_GLSL_ES, SPVC_TRUE);
spvc_compiler_options_set_bool(options, SPVC_COMPILER_OPTION_GLSL_VULKAN_SEMANTICS, SPVC_FALSE);
ASSERT_EQ(session.SetOptions(options), SPVC_SUCCESS);
auto essl = ShaderCompiler::DecompileShader(session);
ASSERT_TRUE(essl) << essl.error().log;
EXPECT_NE(essl->find("#version 320 es"), String::npos) << *essl;
// ESSL has no 64-bit float spelling at all, so any of these in the output is SPIRV-Cross
// having emitted something no ES driver will compile.
EXPECT_EQ(essl->find("double"), String::npos) << *essl;
EXPECT_EQ(essl->find("dvec"), String::npos) << *essl;
EXPECT_EQ(essl->find("dmat"), String::npos) << *essl;
}
TEST_F(DemoteFloat64Test, RejectsGarbageInput) {
const Vector<Uint32> notSpirv{0xdeadbeefu, 0u, 0u, 0u, 0u};
Vector<Uint32> output;
EXPECT_FALSE(ShaderCompiler::DemoteFloat64ToFloat32(notSpirv, output));
}
@@ -18,6 +18,7 @@
#include "SpirvPasses/RenameBuiltinShadowingFunctionsPass.h"
#include "SpirvPasses/DecomposeWorkgroupVec3Pass.h"
#include "SpirvPasses/DecoratePositionInvariantPass.h"
#include "SpirvPasses/DemoteFloat64Pass.h"
#include "SpirvPasses/LowerDrawParametersPass.h"
#include "SpirvPasses/PackDoubleVertexInputsPass.h"
#include "SpirvPasses/SplitArrayVertexInputsPass.h"
@@ -578,6 +579,37 @@ namespace MobileGL {
return false;
}
Bool ShaderCompiler::ModuleDeclaresFloat64(const Vector<Uint32>& spirv) {
if (spirv.empty()) {
// Same reasoning as ModuleDeclaresBufferTextureSampler: a stage that produced
// no SPIR-V is not a verdict about 64-bit floats, and parsing it would push a
// spurious diagnostic through the message consumer.
return false;
}
std::unique_ptr<spvtools::opt::IRContext> context = spvtools::BuildModule(
SPV_ENV_VULKAN_1_1, MakeSpirvMessageConsumer("ModuleDeclaresFloat64"), spirv.data(),
spirv.size());
if (!context) {
return false;
}
for (const spvtools::opt::Instruction& type : context->types_values()) {
if (type.opcode() == spv::Op::OpTypeFloat && type.NumInOperands() >= 1 &&
type.GetSingleWordInOperand(0) == 64) {
return true;
}
}
return false;
}
bool ShaderCompiler::DemoteFloat64ToFloat32(const Vector<Uint32>& inputBinary,
Vector<uint32_t>& outputBinary) {
using namespace spvtools;
Optimizer optimizer(SPV_ENV_VULKAN_1_1);
optimizer.RegisterPass(DemoteFloat64Pass::CreateDemoteFloat64Pass());
return RunOptimizerChecked("DemoteFloat64ToFloat32", optimizer, inputBinary, outputBinary);
}
bool ShaderCompiler::SanitizeAndOptimizeBinary(const Vector<Uint32>& inputBinary,
Vector<uint32_t>& outputBinary) {
using namespace spvtools;
@@ -616,6 +648,17 @@ namespace MobileGL {
RenameBuiltinShadowingFunctionsPass::CreateRenameBuiltinShadowingFunctionsPass());
optimizer.RegisterPass(EliminateFloatEqualsZeroPass::CreateEliminateFloatEqualsZeroPass());
optimizer.RegisterPass(DecomposeWorkgroupVec3Pass::CreateDecomposeWorkgroupVec3Pass());
// No mobile GPU has 64-bit floats: Adreno and Mali both report shaderFloat64 ==
// VK_FALSE, and ESSL has no fp64 type for SPIRV-Cross to emit. Demoting here - in
// the one chain every module goes through, on both backends, at link - is what
// makes `double` compile at all, and makes it behave the SAME everywhere, which
// matters because the GL frontend's uniform storage cannot be per-backend: the
// glUniform*d shadow narrows to float unconditionally to match this. Runs last so
// no earlier pass ever has to reason about a width it will not see in the output;
// in particular it runs before the backends' PackDoubleVertexInputsPass, whose
// OpBitcast this one would otherwise decline on. Costs one types_values() walk on
// the overwhelming majority of modules, which declare no 64-bit float at all.
optimizer.RegisterPass(DemoteFloat64Pass::CreateDemoteFloat64Pass());
return RunOptimizerChecked("SanitizeAndOptimizeBinary", optimizer, inputBinary,
outputBinary);
@@ -100,6 +100,12 @@ namespace MobileGL {
// reinterpretation paths (for example, R32F storage accessed as r32ui).
static bool UseUnformattedFloatStorageImagesForVulkan(
const Vector<Uint32>& inputBinary, Vector<uint32_t>& outputBinary);
// Rewrites every 64-bit float in the module to a 32-bit one, preserving every
// block offset and stride exactly (see DemoteFloat64Pass). Already part of
// SanitizeAndOptimizeBinary, which is where production reaches it; exposed
// separately so a test can drive the demotion on its own.
static bool DemoteFloat64ToFloat32(const Vector<Uint32>& inputBinary,
Vector<uint32_t>& outputBinary);
static Result<String> DecompileShader(SpvcSession& session);
// Parses one trivial shader in each configuration the production path can
@@ -159,6 +165,12 @@ namespace MobileGL {
// check exists so that failure can be reported as the missing capability it is,
// naming the shader, rather than as a driver info log nobody sees.
static Bool ModuleDeclaresBufferTextureSampler(const Vector<Uint32>& spirv);
// True when the module still declares a 64-bit float type. After
// SanitizeAndOptimizeBinary that can only mean DemoteFloat64Pass declined the
// module (see its header for the two operations that make it decline), which is
// what the backends report: no mobile driver can build such a module.
static Bool ModuleDeclaresFloat64(const Vector<Uint32>& spirv);
};
} // namespace ShaderTranspiler
} // namespace MG_Util
@@ -0,0 +1,547 @@
// MobileGL - MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/DemoteFloat64Pass.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 "DemoteFloat64Pass.h"
#include "spirv.hpp"
#include "source/latest_version_glsl_std_450_header.h"
#include "source/opt/def_use_manager.h"
#include "source/opt/instruction.h"
#include "source/opt/ir_context.h"
#include "source/opt/module.h"
#include "source/util/make_unique.h"
#include <algorithm>
#include <cstring>
#include <map>
#include <string>
#include <unordered_map>
#include <unordered_set>
#include <vector>
namespace MobileGL {
namespace MG_Util {
namespace ShaderTranspiler {
namespace {
using spvtools::opt::IRContext;
using spvtools::opt::Instruction;
using spvtools::opt::Operand;
constexpr Uint32 kFloat64Width = 64;
constexpr Uint32 kFloat32Width = 32;
// OpTypeFloat <id> <Width>. SPIR-V 1.6 added an optional FP Encoding operand after
// it; the width stays operand 0 either way, and an encoded (non-IEEE) float is not
// something glslang can emit for `double`, so it is left alone by the width guard.
Bool IsFloatTypeOfWidth(const Instruction& type, Uint32 width) {
return type.opcode() == spv::Op::OpTypeFloat && type.NumInOperands() >= 1 &&
type.GetSingleWordInOperand(0) == width;
}
// Exactly the types spirv-val forbids a second declaration of. Aggregates - arrays
// and structs - are excluded on purpose: the spec permits duplicates of those, and
// merging them would take one of the two OpNames and one of the two ArrayStride /
// Offset decoration sets with it.
Bool IsDuplicableType(spv::Op opcode) {
switch (opcode) {
case spv::Op::OpTypeFloat:
case spv::Op::OpTypeVector:
case spv::Op::OpTypeMatrix:
case spv::Op::OpTypePointer:
case spv::Op::OpTypeFunction: return true;
default: return false;
}
}
Uint32 RoundUp(Uint32 value, Uint32 alignment) {
if (alignment == 0) return value;
return (value + alignment - 1) / alignment * alignment;
}
// Re-derives the std140 / std430 layout of a block whose members have just become
// 32 bits wide, and writes it back as Offset / ArrayStride / MatrixStride.
//
// Why the layout is recomputed rather than preserved. Preserving it - leaving each
// member at the byte offset glslang picked for the 64-bit type and letting the
// freed 4 bytes become padding - keeps the application's byte layout intact and is
// the obvious first choice, but it does not survive contact with the Espryt path:
// SPIRV-Cross has to print an ESSL block, GLSL ES has no member `layout(offset=)`
// (no ARB_enhanced_layouts), so it refuses any block whose declared offsets are not
// exactly what std140 or std430 computes - "Buffer block cannot be expressed as any
// of std430, std140, scalar". Every shader with a double in a block would fail to
// transpile at all, which is the case this whole demotion exists to fix. Padding
// members back in cannot rescue it either: a dmat4 member carries MatrixStride 32
// and std140 demands 16 for the demoted mat4, and no amount of padding BETWEEN
// members changes a stride INSIDE one.
//
// What recomputing costs: a block laid out for 64-bit members changes its
// driver-visible byte layout, so an application that hard-codes std140 offsets
// computed for doubles addresses the wrong bytes. Applications that query their
// offsets are unaffected, and MobileGL's own default-uniform block is unaffected by
// construction - the frontend builds its uniform routing by reflecting THIS module
// (ProgramSpirvTask::BuildGlobalUboRouting), so glUniform*d writes wherever the
// demoted shader reads.
class BlockRelayout {
public:
BlockRelayout(IRContext* irContext, Bool std140)
: m_irContext(irContext), m_std140(std140) {}
// Size and alignment of `typeId`, applying every stride decoration it implies
// on the way down. Zero size means "not a type this layout knows how to
// describe"; the caller then leaves the block alone rather than guessing.
struct Extent {
Uint32 size = 0;
Uint32 alignment = 0;
};
Extent Measure(Uint32 typeId) {
const auto memo = m_extents.find(typeId);
if (memo != m_extents.end()) return memo->second;
const Extent extent = MeasureUncached(typeId);
m_extents.emplace(typeId, extent);
return extent;
}
private:
Extent MeasureUncached(Uint32 typeId) {
const Instruction* type = m_irContext->get_def_use_mgr()->GetDef(typeId);
if (type == nullptr) return {};
switch (type->opcode()) {
case spv::Op::OpTypeInt:
case spv::Op::OpTypeFloat: {
const Uint32 bytes = type->GetSingleWordInOperand(0) / 8;
return {bytes, bytes};
}
case spv::Op::OpTypeBool: return {4, 4};
case spv::Op::OpTypeVector: {
const Extent component = Measure(type->GetSingleWordInOperand(0));
if (component.size == 0) return {};
const Uint32 count = type->GetSingleWordInOperand(1);
// A three-component vector aligns like a four-component one.
return {component.size * count,
component.alignment * (count == 3 ? 4 : count)};
}
case spv::Op::OpTypeMatrix: {
const Extent column = Measure(type->GetSingleWordInOperand(0));
if (column.size == 0) return {};
const Uint32 stride = MatrixOrArrayStride(column.alignment);
return {stride * type->GetSingleWordInOperand(1), stride};
}
case spv::Op::OpTypeArray:
case spv::Op::OpTypeRuntimeArray: {
const Extent element = Measure(type->GetSingleWordInOperand(0));
if (element.size == 0) return {};
const Uint32 alignment = MatrixOrArrayStride(element.alignment);
const Uint32 stride = RoundUp(element.size, alignment);
SetTypeDecoration(typeId, spv::Decoration::ArrayStride, stride);
if (type->opcode() == spv::Op::OpTypeRuntimeArray) {
// An unsized array contributes its stride and nothing more; the
// block's size is whatever the application bound.
return {stride, alignment};
}
return {stride * ArrayLength(type->GetSingleWordInOperand(1)), alignment};
}
case spv::Op::OpTypeStruct: return MeasureStruct(*type);
default: return {};
}
}
Extent MeasureStruct(const Instruction& structType) {
Uint32 cursor = 0;
Uint32 alignment = m_std140 ? 16u : 1u;
for (Uint32 member = 0; member < structType.NumInOperands(); ++member) {
const Uint32 memberTypeId = structType.GetSingleWordInOperand(member);
const Extent extent = Measure(memberTypeId);
if (extent.size == 0) return {};
const Uint32 offset = RoundUp(cursor, extent.alignment);
SetMemberDecoration(structType.result_id(), member, spv::Decoration::Offset, offset);
// A matrix member carries the stride between its columns on the MEMBER,
// not on the type, so it has to be (re)stated here - including for a
// matrix reached through an array.
const Instruction* memberType =
m_irContext->get_def_use_mgr()->GetDef(PeelArrays(memberTypeId));
if (memberType != nullptr && memberType->opcode() == spv::Op::OpTypeMatrix) {
const Extent column = Measure(memberType->GetSingleWordInOperand(0));
SetMemberDecoration(structType.result_id(), member,
spv::Decoration::MatrixStride,
MatrixOrArrayStride(column.alignment));
}
cursor = offset + extent.size;
alignment = std::max(alignment, extent.alignment);
}
return {RoundUp(cursor, alignment), alignment};
}
// std140 rounds every array and matrix stride up to a four-component vector.
Uint32 MatrixOrArrayStride(Uint32 elementAlignment) const {
return m_std140 ? RoundUp(elementAlignment, 16) : elementAlignment;
}
Uint32 PeelArrays(Uint32 typeId) const {
const Instruction* type = m_irContext->get_def_use_mgr()->GetDef(typeId);
while (type != nullptr && (type->opcode() == spv::Op::OpTypeArray ||
type->opcode() == spv::Op::OpTypeRuntimeArray)) {
type = m_irContext->get_def_use_mgr()->GetDef(type->GetSingleWordInOperand(0));
}
return type != nullptr ? type->result_id() : 0;
}
Uint32 ArrayLength(Uint32 lengthConstantId) const {
const Instruction* length = m_irContext->get_def_use_mgr()->GetDef(lengthConstantId);
if (length == nullptr || length->opcode() != spv::Op::OpConstant ||
length->NumInOperands() < 1) {
return 1;
}
return length->GetSingleWordInOperand(0);
}
void SetTypeDecoration(Uint32 targetId, spv::Decoration decoration, Uint32 value) {
for (Instruction& annotation : m_irContext->annotations()) {
if (annotation.opcode() != spv::Op::OpDecorate) continue;
if (annotation.GetSingleWordInOperand(0) != targetId) continue;
if (static_cast<spv::Decoration>(annotation.GetSingleWordInOperand(1)) != decoration) {
continue;
}
annotation.SetInOperand(2, {value});
return;
}
}
void SetMemberDecoration(Uint32 structId, Uint32 member, spv::Decoration decoration,
Uint32 value) {
for (Instruction& annotation : m_irContext->annotations()) {
if (annotation.opcode() != spv::Op::OpMemberDecorate) continue;
if (annotation.GetSingleWordInOperand(0) != structId) continue;
if (annotation.GetSingleWordInOperand(1) != member) continue;
if (static_cast<spv::Decoration>(annotation.GetSingleWordInOperand(2)) != decoration) {
continue;
}
annotation.SetInOperand(3, {value});
return;
}
}
IRContext* m_irContext = nullptr;
Bool m_std140 = true;
std::unordered_map<Uint32, Extent> m_extents;
};
} // namespace
spvtools::opt::Pass::Status DemoteFloat64Pass::Process() {
auto* irContext = context();
auto* defUseMgr = irContext->get_def_use_mgr();
// --- 1. the leaf types -------------------------------------------------------
std::vector<Instruction*> float64Types;
for (Instruction& type : irContext->types_values()) {
if (IsFloatTypeOfWidth(type, kFloat64Width)) {
float64Types.push_back(&type);
}
}
if (float64Types.empty()) return Status::SuccessWithoutChange;
std::unordered_set<Uint32> float64TypeIds;
for (const Instruction* type : float64Types) {
float64TypeIds.insert(type->result_id());
}
// Every type a *value* can have that is 64-bit float underneath: the scalar itself
// plus the vectors and matrices built from it. The types-and-values section is in
// declaration order and SPIR-V forbids a type from forward-referencing another, so
// one forward walk is already the complete transitive closure. Aggregates and
// pointers are deliberately not included: no operand of the instructions checked
// below is ever an aggregate or a pointer.
std::unordered_set<Uint32> float64ValueTypeIds = float64TypeIds;
for (Instruction& type : irContext->types_values()) {
if (type.opcode() != spv::Op::OpTypeVector && type.opcode() != spv::Op::OpTypeMatrix) {
continue;
}
if (float64ValueTypeIds.count(type.GetSingleWordInOperand(0)) != 0) {
float64ValueTypeIds.insert(type.result_id());
}
}
// Every type that has a 64-bit float anywhere underneath it, which is exactly the
// set of blocks whose layout has to be re-derived once the leaves narrow. Computed
// now, before the rewrite makes a demoted float indistinguishable from one that was
// always 32 bits; the same single forward walk is a complete closure.
std::unordered_set<Uint32> wideTypeIds = float64TypeIds;
for (Instruction& type : irContext->types_values()) {
const auto contains = [&](Uint32 operand) {
return wideTypeIds.count(type.GetSingleWordInOperand(operand)) != 0;
};
switch (type.opcode()) {
case spv::Op::OpTypeVector:
case spv::Op::OpTypeMatrix:
case spv::Op::OpTypeArray:
case spv::Op::OpTypeRuntimeArray:
if (contains(0)) wideTypeIds.insert(type.result_id());
break;
case spv::Op::OpTypePointer:
if (contains(1)) wideTypeIds.insert(type.result_id());
break;
case spv::Op::OpTypeStruct:
for (Uint32 member = 0; member < type.NumInOperands(); ++member) {
if (contains(member)) {
wideTypeIds.insert(type.result_id());
break;
}
}
break;
default: break;
}
}
const auto valueIsFloat64 = [&](Uint32 id) {
const Instruction* def = defUseMgr->GetDef(id);
return def != nullptr && float64ValueTypeIds.count(def->type_id()) != 0;
};
// --- 2. decline before touching anything ------------------------------------
// These are the operations that mean "the 64 bits themselves", not "a wide float".
// Narrowing one side of them produces a module spirv-val rejects, and rebuilding
// the value would mean emulating fp64 in software. Bail out with the module
// byte-identical instead; the caller still has its "module declares Float64"
// diagnostic for it.
Uint32 glslStd450SetId = 0;
for (const Instruction& import : irContext->ext_inst_imports()) {
if (import.GetInOperand(0).AsString() == "GLSL.std.450") {
glslStd450SetId = import.result_id();
break;
}
}
const char* declineReason = nullptr;
irContext->module()->ForEachInst(
[&](Instruction* inst) {
if (declineReason != nullptr) return;
switch (inst->opcode()) {
case spv::Op::OpBitcast: {
// "Total bit width of Result Type and Operand must match" - true
// today, false the moment one of the two sides halves.
const Bool resultIs64 = float64ValueTypeIds.count(inst->type_id()) != 0;
const Bool operandIs64 = valueIsFloat64(inst->GetSingleWordInOperand(0));
if (resultIs64 != operandIs64) {
declineReason = "an OpBitcast across the 64-bit boundary "
"(doubleBitsToUint64 / uint64BitsToDouble / "
"packDouble2x32)";
}
break;
}
case spv::Op::OpExtInst: {
if (glslStd450SetId == 0 ||
inst->GetSingleWordInOperand(0) != glslStd450SetId) {
break;
}
const Uint32 extOpcode = inst->GetSingleWordInOperand(1);
if (extOpcode == GLSLstd450PackDouble2x32 ||
extOpcode == GLSLstd450UnpackDouble2x32) {
declineReason = "GLSL.std.450 PackDouble2x32 / UnpackDouble2x32, "
"which are defined only for a 64-bit float";
}
break;
}
default: break;
}
},
/*run_on_debug_line_insts=*/false);
if (declineReason != nullptr) {
MGLOG_D("DemoteFloat64Pass: declined - the module uses %s; its 64-bit floats are "
"left in place",
declineReason);
return Status::SuccessWithoutChange;
}
// --- 3. re-encode the literals ----------------------------------------------
// A 64-bit float constant carries two literal words and a 32-bit one carries a
// single word, so the value has to be narrowed before the type changes underneath
// it - afterwards there is no way left to tell how wide the literal was meant to
// be. Composite constants hold <id>s, not literals, and need nothing.
for (Instruction& value : irContext->types_values()) {
if (value.opcode() != spv::Op::OpConstant && value.opcode() != spv::Op::OpSpecConstant) {
continue;
}
if (float64TypeIds.count(value.type_id()) == 0) continue;
if (value.NumInOperands() < 1) continue;
const Operand& literal = value.GetInOperand(0);
if (literal.words.size() != 2) continue;
const Uint64 bits =
(static_cast<Uint64>(literal.words[1]) << 32) | static_cast<Uint64>(literal.words[0]);
double wide = 0.0;
std::memcpy(&wide, &bits, sizeof(wide));
// Deliberately the ordinary narrowing conversion: a magnitude no float can
// hold becomes an infinity, which is the same answer the demoted arithmetic
// around it would produce.
const float narrow = static_cast<float>(wide);
Uint32 narrowedBits = 0;
std::memcpy(&narrowedBits, &narrow, sizeof(narrowedBits));
Operand narrowedLiteral = literal;
narrowedLiteral.words = {narrowedBits};
value.SetInOperands({std::move(narrowedLiteral)});
}
// --- 4. the demotion itself --------------------------------------------------
// In place, so every composite type, every pointer, every struct member offset and
// every debug name that referred to the 64-bit type keeps referring to the same
// <id>. This is the whole reason the pass does not build parallel types.
for (Instruction* type : float64Types) {
type->SetInOperand(0, {kFloat32Width});
}
// The cached analysis::Type objects were built against the old widths.
irContext->InvalidateAnalyses(IRContext::kAnalysisTypes);
// --- 5. the conversions that just became identities --------------------------
// `float(someDouble)` and `double(someFloat)` are both OpFConvert, and SPIR-V
// requires the two component widths to differ. Both sides are 32 bits now, so each
// one is replaced by its operand. Walking in module order means a chain of them
// resolves in a single sweep: by the time the second is reached its operand has
// already been rewritten to the ultimate source.
const auto componentWidth = [&](Uint32 typeId) -> Uint32 {
const Instruction* def = defUseMgr->GetDef(typeId);
if (def == nullptr) return 0;
if (def->opcode() == spv::Op::OpTypeVector) {
def = defUseMgr->GetDef(def->GetSingleWordInOperand(0));
}
if (def == nullptr || def->opcode() != spv::Op::OpTypeFloat) return 0;
return def->GetSingleWordInOperand(0);
};
std::vector<Instruction*> identityConversions;
irContext->module()->ForEachInst(
[&](Instruction* inst) {
if (inst->opcode() != spv::Op::OpFConvert) return;
const Instruction* operandDef = defUseMgr->GetDef(inst->GetSingleWordInOperand(0));
if (operandDef == nullptr) return;
const Uint32 resultWidth = componentWidth(inst->type_id());
if (resultWidth == 0 || resultWidth != componentWidth(operandDef->type_id())) {
return;
}
identityConversions.push_back(inst);
},
/*run_on_debug_line_insts=*/false);
for (Instruction* conversion : identityConversions) {
irContext->ReplaceAllUsesWith(conversion->result_id(),
conversion->GetSingleWordInOperand(0));
irContext->KillInst(conversion);
}
// --- 6. the capability -------------------------------------------------------
std::vector<Instruction*> deadCapabilities;
for (Instruction& capability : irContext->capabilities()) {
if (static_cast<spv::Capability>(capability.GetSingleWordInOperand(0)) ==
spv::Capability::Float64) {
deadCapabilities.push_back(&capability);
}
}
for (Instruction* capability : deadCapabilities) {
irContext->KillInst(capability);
}
// --- 7. merge what the rewrite made into a duplicate -------------------------
// `double` and `float` are now the same declaration, and so is every vector,
// matrix, pointer and function type spelled in terms of them. Walking the section
// in declaration order and replacing each duplicate the moment it is found means
// the later types are already canonical by the time they are keyed: SPIR-V forbids
// a type from forward-referencing another, so every operand of the instruction
// being looked at has been through this loop already.
std::map<std::vector<Uint32>, Uint32> keptTypeByShape;
for (Instruction* type = &*irContext->types_values_begin(); type != nullptr;) {
Instruction* next = type->NextNode();
if (!IsDuplicableType(type->opcode())) {
type = next;
continue;
}
std::vector<Uint32> shape{static_cast<Uint32>(type->opcode())};
for (Uint32 i = 0; i < type->NumInOperands(); ++i) {
const Operand& operand = type->GetInOperand(i);
shape.insert(shape.end(), operand.words.begin(), operand.words.end());
}
const auto kept = keptTypeByShape.find(shape);
if (kept == keptTypeByShape.end()) {
keptTypeByShape.emplace(std::move(shape), type->result_id());
type = next;
continue;
}
irContext->KillNamesAndDecorates(type->result_id());
irContext->ReplaceAllUsesWith(type->result_id(), kept->second);
irContext->KillInst(type);
type = next;
}
// --- 8. re-derive the layout of every block that held a 64-bit float -----------
// See BlockRelayout for why this is a recomputation and not a preservation. Only
// blocks that actually narrowed are touched: relaying out an untouched block would
// be pure churn, and would risk disagreeing with glslang over a layout that was
// already correct.
for (Instruction& variable : irContext->types_values()) {
if (variable.opcode() != spv::Op::OpVariable) continue;
const auto storageClass =
static_cast<spv::StorageClass>(variable.GetSingleWordInOperand(0));
if (storageClass != spv::StorageClass::Uniform &&
storageClass != spv::StorageClass::StorageBuffer &&
storageClass != spv::StorageClass::PushConstant) {
continue;
}
const Instruction* pointerType = defUseMgr->GetDef(variable.type_id());
if (pointerType == nullptr) continue;
// An arrayed block (`uniform Blk { ... } blocks[4];`) is a pointer to an array
// of the struct; the layout lives on the struct either way.
const Instruction* blockType = defUseMgr->GetDef(pointerType->GetSingleWordInOperand(1));
while (blockType != nullptr && (blockType->opcode() == spv::Op::OpTypeArray ||
blockType->opcode() == spv::Op::OpTypeRuntimeArray)) {
blockType = defUseMgr->GetDef(blockType->GetSingleWordInOperand(0));
}
if (blockType == nullptr || blockType->opcode() != spv::Op::OpTypeStruct) continue;
if (wideTypeIds.count(blockType->result_id()) == 0) continue;
// A storage block packs std430, a uniform block std140. Before SPIR-V 1.3 a
// storage block was a Uniform-storage variable whose struct carried
// BufferBlock, so the decoration decides rather than the storage class alone.
Bool isStorageBlock = storageClass == spv::StorageClass::StorageBuffer;
for (const Instruction& annotation : irContext->annotations()) {
if (annotation.opcode() != spv::Op::OpDecorate) continue;
if (annotation.GetSingleWordInOperand(0) != blockType->result_id()) continue;
if (static_cast<spv::Decoration>(annotation.GetSingleWordInOperand(1)) ==
spv::Decoration::BufferBlock) {
isStorageBlock = true;
}
}
BlockRelayout relayout(irContext, /*std140=*/!isStorageBlock);
if (relayout.Measure(blockType->result_id()).size == 0) {
// A member shape the layout rules here do not describe. Leaving the block
// at its 64-bit offsets keeps the module valid for Vulkan; SPIRV-Cross will
// decline it for ESSL, which is the same outcome as before the demotion.
MGLOG_D("DemoteFloat64Pass: block %%%u contains a member this pass cannot lay "
"out; its 64-bit offsets are left in place",
blockType->result_id());
}
}
irContext->InvalidateAnalysesExceptFor(IRContext::kAnalysisNone);
return Status::SuccessWithChange;
}
spvtools::Optimizer::PassToken DemoteFloat64Pass::CreateDemoteFloat64Pass() {
return spvtools::Optimizer::PassToken(MakeUnique<DemoteFloat64Pass>());
}
} // namespace ShaderTranspiler
} // namespace MG_Util
} // namespace MobileGL
@@ -0,0 +1,84 @@
// MobileGL - MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/DemoteFloat64Pass.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>
namespace MobileGL {
namespace MG_Util {
namespace ShaderTranspiler {
// Rewrites every 64-bit float in the module to a 32-bit one: `OpTypeFloat 64` becomes
// `OpTypeFloat 32` in place, so every vector, matrix, array, struct, pointer and
// function type that named it keeps its <id> and every decoration attached to it, and
// only the meaning of the leaf type changes. The double literals are re-encoded, the
// now-width-preserving OpFConvert pairs collapse to their operand, and the Float64
// capability goes away.
//
// Why demote at all: no mobile GPU has it. Adreno and Mali both report
// VkPhysicalDeviceFeatures::shaderFloat64 == VK_FALSE (the Magma POST has a row for it),
// so a module declaring Float64 cannot become a pipeline there; and ESSL has no 64-bit
// float type at all, so SPIRV-Cross throws "FP64 not supported in ES profile" and the
// Espryt path never even reaches the driver. Demotion is what makes `double` in an
// application's GLSL compile and run everywhere, at fp32 precision.
//
// BLOCK LAYOUT IS RE-DERIVED, NOT PRESERVED, and that was not the first choice - see
// BlockRelayout in the .cpp for the measurement that forced it. Preserving the 64-bit
// offsets (float + 4 bytes of padding in each slot) keeps the application's byte layout
// intact and is what a Vulkan-only implementation would do, but GLSL ES has no member
// `layout(offset=)`, so SPIRV-Cross recomputes std140/std430 from the declared types
// and refuses any block whose stated offsets disagree - "Buffer block cannot be
// expressed as any of std430, std140, scalar". Every shader with a double in a block
// would then fail to transpile for Espryt at all, which is the case this demotion
// exists to fix. Nor can padding members rescue it: a dmat4 member carries
// MatrixStride 32 and std140 requires 16 for the demoted mat4, and padding BETWEEN
// members cannot change a stride INSIDE one.
//
// What re-deriving costs, stated plainly: a block that held 64-bit members changes its
// driver-visible byte layout, so an application that hard-codes std140 offsets it
// computed for doubles addresses the wrong bytes. Applications that query their offsets
// are unaffected. MobileGL's own default-uniform block is unaffected by construction:
// the frontend builds its uniform routing by reflecting the module this pass produced
// (ProgramSpirvTask::BuildGlobalUboRouting), so glUniform*d - which narrows to float
// for the same reason - writes exactly where the demoted shader reads. Blocks with no
// 64-bit member anywhere are never touched.
//
// Declines (leaves the module byte-identical, so the caller's existing "this module
// still declares Float64" failure path reports it) when the module contains an
// operation whose validity depends on the operand really being 64 bits wide:
// - OpBitcast across the boundary - packDouble2x32 / doubleBitsToUint64 and friends,
// where SPIR-V requires both sides to have the same total bit width;
// - GLSL.std.450 PackDouble2x32 / UnpackDouble2x32, which are defined only for a
// 64-bit float result/operand.
//
// ORDERING: must run before PackDoubleVertexInputsPass, which introduces exactly the
// OpBitcast this pass declines on. After demotion no 64-bit vertex input is left, so
// that pass becomes a no-op rather than a conflict.
//
// The in-place rewrite creates duplicate type declarations by construction - a module
// that had both `double` and `float` ends up with two `OpTypeFloat 32`, and spirv-val
// rejects that ("Duplicate non-aggregate type declarations are not allowed") - so the
// pass merges them itself afterwards. Deliberately NOT by registering spvtools'
// RemoveDuplicates alongside it: that pass also merges structurally identical STRUCTS
// and calls KillNamesAndDecorates on the loser, which would silently delete the OpName
// of one of two distinct-but-identically-shaped interface blocks - and OpName is how
// MobileGL resolves block and varying names. Only the non-aggregate types spirv-val
// actually forbids duplicates of are merged here; arrays and structs are left alone,
// which also keeps a `double[]`'s ArrayStride from being merged into a `float[]`'s.
class DemoteFloat64Pass : public spvtools::opt::Pass {
public:
const char* name() const override { return "mobilegl-demote-float64"; }
Status Process() override;
static spvtools::Optimizer::PassToken CreateDemoteFloat64Pass();
};
} // namespace ShaderTranspiler
} // namespace MG_Util
} // namespace MobileGL