[Fix, Test] (ShaderTranspiler, GLImpl, ProgramState, DirectVulkan): keep fp64 where the backend consumes it natively

This commit is contained in:
2026-08-22 00:57:06 -04:00
parent e4f41e0fd3
commit d4247db6c3
23 changed files with 761 additions and 181 deletions
@@ -787,9 +787,11 @@ namespace MobileGL::MG_State::GLState {
// The L1 key. Every input below is one that can change the SPIR-V this program
// generates; see the key inventory on SpirvTranslationKeyInputs.
//
// Deliberately NOT keyed on: nothing that only steers a BACKEND transpile - see the
// Deliberately NOT keyed on: anything that only steers a BACKEND transpile - see the
// classification on CompileEnv::frontendFingerprint, and L2's own key in
// MG_Util/ShaderTranspiler/TranslationCache.h.
// MG_Util/ShaderTranspiler/TranslationCache.h. The single capability bit that IS here
// (nativeFloat64) earns its place by changing SanitizeAndOptimizeBinary's own output,
// which is what the payload stores.
MG_Util::ShaderTranspiler::TranslationCacheKey ProgramLinkTask::BuildSpirvCacheKey(
const MG_Util::ShaderTranspiler::CompileEnv& env) const {
using namespace MG_Util::ShaderTranspiler;
@@ -805,6 +807,11 @@ namespace MobileGL::MG_State::GLState {
// value cannot alias a module parsed without it.
keyInputs.shaderCompileFlags = 0;
keyInputs.enableSpirvValidation = in.enableSpirvValidation;
// The one BACKEND capability bit in this key, and it has to be here: it reaches inside
// SanitizeAndOptimizeBinary, whose output is what the payload holds. Read from the same
// env snapshot ProgramSpirvTask hands the chain, so the key and the bytes can never
// disagree.
keyInputs.nativeFloat64 = env.ConsumesFloat64Natively();
keyInputs.stages.reserve(in.shaders.size());
for (const LinkShaderInput& shader : in.shaders) {
const ShaderCompileArtifacts& compiled = CompiledArtifacts(shader.compiled);
@@ -155,6 +155,10 @@ namespace MobileGL::MG_State::GLState {
Uint8* const scratch = m_spirv.globalUboScratch.data();
const SizeT uboSize = m_spirv.globalUboScratch.size();
// Read straight off m_spirv, not through UsesNativeFloat64(): this runs INSIDE the
// phase-B publish, where the join gate is not re-entrant. Same reason the scratch above
// is taken directly.
const Bool nativeFloat64 = m_spirv.nativeFloat64;
for (const auto& init : initializers) {
// Scalars per array ELEMENT. A matrix element carries cols * rows of them, laid
@@ -165,12 +169,13 @@ namespace MobileGL::MG_State::GLState {
const Int elements = init.arraySize;
if (componentsPerElement <= 0 || elements <= 0) continue;
// EbtDouble belongs with the floats now, not with the skipped types: every 64-bit
// float in a shader is narrowed to 32 bits before the module reaches a backend
// EbtDouble belongs with the floats, not with the skipped types. On a DEMOTED
// program its 64-bit floats were narrowed to 32 before the module reached a backend
// (ShaderTranspiler::DemoteFloat64Pass), so a `uniform double d = 1.5;` has exactly
// the 32-bit shadow encoding a `uniform float` does - and glslang already folded its
// value into floatValues, which is a vector<double> either way. Leaving it out meant
// the initializer was silently dropped and the uniform came up zero.
// the 32-bit shadow encoding a `uniform float` does; on a program that kept them it
// has an 8-byte one, which the store width below picks up. glslang folded the value
// into floatValues, a vector<double>, in both cases. Leaving it out meant the
// initializer was silently dropped and the uniform came up zero.
const Bool isFloat = init.basicType == glslang::EbtFloat ||
init.basicType == glslang::EbtFloat16 ||
init.basicType == glslang::EbtDouble;
@@ -195,22 +200,36 @@ namespace MobileGL::MG_State::GLState {
// std140 pads every column of a float matrix out to a vec4, so the columns of
// a mat3 are 16 bytes apart even though each carries 12. The slot's own span
// states the stride the rest of the pipeline agreed on rather than guessing it.
const SizeT slotSpan = GetUniformStorageSpanInBytes(static_cast<Uint>(location));
// The static form, with the width taken from m_spirv directly: the member
// overload asks UsesNativeFloat64(), which joins phase B - and phase B is what
// is publishing right now.
const SizeT slotSpan =
UniformStorageSpanInBytes(GetUniformTypeFacts(static_cast<Uint>(location)),
GetUniformSizesInBytes(static_cast<Uint>(location)), nativeFloat64);
const SizeT columnStride =
columns > 0 ? slotSpan / static_cast<SizeT>(columns) : slotSpan;
const Int componentsPerColumn = columns > 0 ? rows : componentsPerElement;
const Int columnCount = columns > 0 ? columns : 1;
// A `double` initializer on a program that KEPT its doubles lands in an 8-byte
// component, not a 4-byte one; every other basic type - and every double on a
// demoted program - stays one 32-bit word. glslang folded the value into
// floatValues (a vector<double>) either way, so only the store width moves.
const Bool isWideDouble = init.basicType == glslang::EbtDouble && nativeFloat64;
const SizeT componentSize = isWideDouble ? sizeof(Double) : sizeof(Uint32);
for (Int column = 0; column < columnCount; ++column) {
const SizeT byteOffset = static_cast<SizeT>(offset) + static_cast<SizeT>(column) * columnStride;
const SizeT writeSize = static_cast<SizeT>(componentsPerColumn) * sizeof(Uint32);
const SizeT writeSize = static_cast<SizeT>(componentsPerColumn) * componentSize;
if (byteOffset + writeSize > uboSize) break;
const SizeT firstComponent = static_cast<SizeT>(element) * componentsPerElement +
static_cast<SizeT>(column) * componentsPerColumn;
for (Int component = 0; component < componentsPerColumn; ++component) {
const SizeT source = firstComponent + static_cast<SizeT>(component);
Uint8* const destination = scratch + byteOffset + component * sizeof(Uint32);
if (isFloat) {
Uint8* const destination = scratch + byteOffset + component * componentSize;
if (isWideDouble) {
const Double value = init.floatValues[source];
std::memcpy(destination, &value, sizeof(value));
} else if (isFloat) {
const Float value = static_cast<Float>(init.floatValues[source]);
std::memcpy(destination, &value, sizeof(value));
} else {
@@ -565,26 +565,47 @@ namespace MobileGL::MG_State::GLState {
: kInvalidUniformOffset;
}
Uint GetUniformSizesInBytes(Uint location) const { return MG_Util::GetGLTypeSize(GetUniformType(location)); }
// Bytes a uniform actually occupies in the global UBO, which is not its GL type size,
// for two reasons. std140 pads each column of a matrix out to a vec4, so a mat3 spans
// 48 bytes even though only 36 of them carry components. And every 64-bit float in a
// shader is narrowed to 32 bits before the module reaches a backend
// (ShaderTranspiler::DemoteFloat64Pass) - the global UBO is laid out by reflecting that
// demoted module - so a `double` uniform occupies exactly what its float-typed twin
// would, half its GL type size, and a `dmat4` is padded like any other matrix. Anything
// reading or writing a whole uniform's storage - a bounds check, a copy between two
// programs' shadows - wants this rather than GetUniformSizesInBytes.
static SizeT UniformStorageSpanInBytes(const TypeFacts& type, SizeT tightSize) {
if (type.isMatrix) {
return static_cast<SizeT>(type.matrixCols) * 4 * sizeof(Float);
// std140 column stride of a matrix uniform in the global UBO: every column is padded out
// to the base alignment of a vec4 for 32-bit components, and of a dvec4 for 64-bit ones -
// except that a 2-ROW double column is a dvec2, whose base alignment is already 16.
// (GL 4.6 core 7.6.2.2 rules 2-4; SPIRV-Cross derives the same numbers, which is what
// makes this agree with the reflected module.)
static SizeT UniformMatrixColumnStride(const TypeFacts& type, const Bool nativeFloat64) {
if (type.isDouble && nativeFloat64) {
return type.matrixRows <= 2 ? 2 * sizeof(GLdouble) : 4 * sizeof(GLdouble);
}
if (type.isDouble) {
return 4 * sizeof(Float);
}
// Bytes a uniform actually occupies in the global UBO, which is not its GL type size,
// for two reasons. std140 pads each column of a matrix out to a vec4 (or a dvec4), so a
// mat3 spans 48 bytes even though only 36 of them carry components. And a 64-bit float
// may have been narrowed to 32 before the module reached the backend
// (ShaderTranspiler::DemoteFloat64Pass) - the global UBO is laid out by reflecting
// whichever module was produced - so on a DEMOTED program a `double` uniform occupies
// exactly what its float-typed twin would, half its GL type size, and a `dmat4` is padded
// like any other 32-bit matrix. On a program that kept its doubles it occupies the full
// GL type size and its matrix columns are twice as far apart. `nativeFloat64` is the
// program's own SpirvArtifacts flag, never a live backend read: it describes the modules
// that were actually built. Anything reading or writing a whole uniform's storage - a
// bounds check, a copy between two programs' shadows - wants this rather than
// GetUniformSizesInBytes.
static SizeT UniformStorageSpanInBytes(const TypeFacts& type, SizeT tightSize,
const Bool nativeFloat64 = false) {
if (type.isMatrix) {
return static_cast<SizeT>(type.matrixCols) * UniformMatrixColumnStride(type, nativeFloat64);
}
if (type.isDouble && !nativeFloat64) {
return tightSize / 2;
}
return tightSize;
}
// Whether this program's modules KEPT their 64-bit floats. Joins phase B, like every
// other question about the global UBO's layout - and it is one: it decides how wide a
// `double` uniform's slot is.
Bool UsesNativeFloat64() const { return Spirv().nativeFloat64; }
SizeT GetUniformStorageSpanInBytes(Uint location) const {
return UniformStorageSpanInBytes(GetUniformTypeFacts(location), GetUniformSizesInBytes(location));
return UniformStorageSpanInBytes(GetUniformTypeFacts(location), GetUniformSizesInBytes(location),
UsesNativeFloat64());
}
// ---- "written since link": the per-location dirty set the pipeline composite mirrors from ----
@@ -1323,6 +1344,15 @@ namespace MobileGL::MG_State::GLState {
// not drawable, which the backends already express through their link-status
// gates.
Bool spirvStatus = false;
// Whether these modules KEPT their 64-bit floats instead of being narrowed to 32
// (ShaderTranspiler::DemoteFloat64Pass). Decided per PROGRAM, never per module - the
// global UBO is one buffer all stages read, so two stages disagreeing about whether a
// `uniform double` occupies 4 or 8 bytes would put every uniform after it at a
// different offset in each. Recorded here rather than re-derived from the backend
// because it is the layout THESE modules were built with: it is what the routing
// table's offsets mean, and glUniform*d / glGetUniform*v have to write and read the
// width the shader actually declares.
Bool nativeFloat64 = false;
};
// ---- artifacts-only helpers, shared with ProgramLinkTask ----
@@ -122,7 +122,14 @@ namespace MobileGL::MG_State::GLState {
m_phaseA->in.env != nullptr && m_phaseA->in.env->backend == BackendType::DirectVulkan;
const Bool enableSpirvValidation = m_phaseA->in.enableSpirvValidation;
artifacts.enableSpirvValidation = enableSpirvValidation;
GenerateSpirv(handoff, externalIndex, deferOutputValidationForDirectVulkan, enableSpirvValidation);
// Whether this backend consumes 64-bit floats itself. Read off the SNAPSHOT, like every
// other environment question this node asks: a worker may not touch
// MG_Backend::pActiveBackendObject, and the answer has to be the one the L1 key was built
// with (ProgramLinkTask::BuildSpirvCacheKey reads the same env) or a memo written under
// one answer could be handed back under the other.
const Bool nativeFloat64 = m_phaseA->in.env != nullptr && m_phaseA->in.env->ConsumesFloat64Natively();
GenerateSpirv(handoff, externalIndex, deferOutputValidationForDirectVulkan, enableSpirvValidation,
nativeFloat64);
// GlslangToSpv was the only consumer of the parsed ASTs; everything after this point
// works on the SPIR-V and on the TProgram's own self-contained reflection pool. Drop
// them here rather than at the end of the body, which is ~87% of this node's runtime
@@ -181,7 +188,7 @@ namespace MobileGL::MG_State::GLState {
void ProgramSpirvTask::GenerateSpirv(const ProgramLinkTask::SpirvHandoff& handoff, const Uint externalIndex,
const Bool deferOutputValidationForDirectVulkan,
const Bool enableSpirvValidation) {
const Bool enableSpirvValidation, const Bool nativeFloat64) {
/* As we passed first stage compilation/linking,
* we'll assume all the operations here should
* pass. We may be able to employ some optimizations
@@ -209,12 +216,39 @@ namespace MobileGL::MG_State::GLState {
MGLOG_D("ProgramObject %u: GenerateSpirv - generated %zu SPIR-V modules", externalIndex,
artifacts.generatedSpirv.size());
// The fp64 verdict, taken ONCE for the whole program and before any module is touched.
//
// Per program rather than per module, and that is forced by the global UBO: all stages
// read one buffer whose layout is derived by reflecting the modules, so a vertex stage
// that stored a `uniform double` as 4 bytes next to a fragment stage that stored it as 8
// would put every uniform after it somewhere different in each, and the routing table
// (one offset per location) could only describe one of them.
//
// The exception itself is the vertex INPUT: no backend here can fetch a 64-bit attribute,
// and VertexInputStateFactory picks the format from the VAO attribute without ever seeing
// what the shader declared, so a Float64 input would meet a narrowed float32 stream. One
// such stage demotes the whole program, which is exactly what every backend without
// native fp64 does to it anyway.
Bool keepFloat64 = nativeFloat64;
if (keepFloat64) {
for (const auto& spv : artifacts.generatedSpirv) {
if (ShaderCompiler::ModuleDeclaresFloat64VertexInput(spv)) {
keepFloat64 = false;
MGLOG_D("ProgramObject %u: a vertex stage declares a 64-bit float input; demoting the "
"whole program despite native fp64",
externalIndex);
break;
}
}
}
artifacts.nativeFloat64 = keepFloat64;
// Linked SPIR-V generated, sanitize and optimize it
Bool allOptimized = true;
{
for (auto& spv : artifacts.generatedSpirv) {
auto success = ShaderCompiler::SanitizeAndOptimizeBinary(
spv, spv, !deferOutputValidationForDirectVulkan, enableSpirvValidation);
spv, spv, !deferOutputValidationForDirectVulkan, enableSpirvValidation, keepFloat64);
if (!success) {
// The one genuine phase-B failure mode: one of the seven optimizer passes
// reported failure, so `spv` is whatever the run left behind. A fordebug
@@ -66,7 +66,8 @@ namespace MobileGL::MG_State::GLState {
void RunBody() override;
void GenerateSpirv(const ProgramLinkTask::SpirvHandoff& handoff, Uint externalIndex,
Bool deferOutputValidationForDirectVulkan, Bool enableSpirvValidation);
Bool deferOutputValidationForDirectVulkan, Bool enableSpirvValidation,
Bool nativeFloat64);
void BuildGlobalUboRouting(const ProgramLinkTask::SpirvHandoff& handoff, Uint externalIndex);
// Worker-side MGLOG replacement, replayed by the join on the GL thread. Same reason as