From d4247db6c3c93f1c9370995c309320aa536bef67 Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Sat, 22 Aug 2026 00:57:06 -0400 Subject: [PATCH] [Fix, Test] (ShaderTranspiler, GLImpl, ProgramState, DirectVulkan): keep fp64 where the backend consumes it natively --- MobileGL/MG_Backend/BackendObject.h | 24 +++ .../DirectGLES/BackendObject_DirectGLES.cpp | 11 +- .../BackendObject_DirectVulkan.cpp | 57 +++--- .../Renderer/VertexInputStateFactory.cpp | 10 +- .../DirectVulkan/Renderer/VulkanRenderer.cpp | 10 +- .../MG_Impl/GLImpl/Program/GL_Program.cpp | 163 ++++++++++++++---- .../Scenarios/DoublePrecisionScenario.cpp | 57 +++--- .../GLState/ProgramState/ProgramLinkTask.cpp | 11 +- .../GLState/ProgramState/ProgramObject.cpp | 37 +++- .../GLState/ProgramState/ProgramObject.h | 58 +++++-- .../GLState/ProgramState/ProgramSpirvTask.cpp | 40 ++++- .../GLState/ProgramState/ProgramSpirvTask.h | 3 +- .../ShaderTranspiler/DemoteFloat64Test.cpp | 84 +++++++++ .../FlattenFloat64StorageBlockTest.cpp | 42 +++++ .../ShaderTranspiler/TranslationCacheTest.cpp | 47 +++++ MobileGL/MG_Util/SelfTest/DriverPost.cpp | 64 ++++--- .../MG_Util/ShaderTranspiler/CompileEnv.h | 20 ++- .../ShaderTranspiler/ShaderCompiler.cpp | 97 +++++++++-- .../MG_Util/ShaderTranspiler/ShaderCompiler.h | 26 ++- .../SpirvPasses/DemoteFloat64Pass.h | 18 +- .../FlattenFloat64StorageBlockPass.h | 8 +- .../ShaderTranspiler/TranslationCache.cpp | 7 +- .../ShaderTranspiler/TranslationCache.h | 48 ++++-- 23 files changed, 761 insertions(+), 181 deletions(-) diff --git a/MobileGL/MG_Backend/BackendObject.h b/MobileGL/MG_Backend/BackendObject.h index e6037847..189e075a 100644 --- a/MobileGL/MG_Backend/BackendObject.h +++ b/MobileGL/MG_Backend/BackendObject.h @@ -445,12 +445,36 @@ namespace MobileGL { const Uint32 bit = PerLayerFramebufferAttachmentBit(target); return bit != 0 && (PerLayerFramebufferAttachmentTargets & bit) != 0; } + // Whether this backend can CONSUME a shader module that still declares 64-bit floats, + // i.e. whether `double` survives the transpile instead of being narrowed to `float` + // (ShaderTranspiler::DemoteFloat64Pass). Detected, never assumed: + // * DirectVulkan sets it from VkPhysicalDeviceFeatures::shaderFloat64, the feature + // VUID-VkShaderModuleCreateInfo-pCode-08740 requires before a module declaring + // OpCapability Float64 may be created at all. lavapipe has it; Adreno and Mali + // both report VK_FALSE, so no real mobile device does. + // * DirectGLES can NEVER have it. GLSL ES has no 64-bit float type in any version + // or extension, so SPIRV-Cross cannot emit one ("FP64 not supported in ES + // profile") and the demotion there is mathematically mandatory, always. + // Defaults to false so a backend that never sets it - and the no-backend case, which + // is what standalone shader compiles and the unit tests run under - keeps the + // demotion, which is the behaviour that works everywhere. + Bool SupportsShaderFloat64 = false; // Whether glVertexAttribLFormat / glVertexArrayAttribLFormat can be honoured, i.e. // whether a 64-bit vertex attribute can actually reach a shader unconverted. Detected, // never assumed: DirectVulkan needs VkPhysicalDeviceFeatures::shaderFloat64 (the // attribute travels as its 32-bit word pair, so no VK_FORMAT_R64* is required, but the // bitcast result is Float64); DirectGLES can never have it, ESSL having no fp64 type at // all. Defaults to false so a backend that never sets it gets the conservative answer. + // + // INDEPENDENT of SupportsShaderFloat64, and it has to be: this flag decides a VkFormat + // from the VAO ATTRIBUTE alone, which does not know what type the shader declared, and + // glVertexAttribFormat(GL_DOUBLE) feeding a plain `in vec4` is both legal and common + // (KHR-GL43.vertex_attrib_binding.basic-input-case4/5, advanced-bindingUpdate). A + // backend with native fp64 that still cannot FETCH 64 bits keeps this false and relies + // on the per-MODULE rule in ShaderCompiler::SanitizeAndOptimizeBinary instead: a vertex + // module that declares a 64-bit float INPUT is demoted whole, so the two shader-side + // halves (PackDoubleVertexInputsPass and VertexInputStateFactory::ToVkVertexFormat) + // still see one consistent world. Bool SupportsFloat64VertexAttributes = false; SizeT MaxShaderStorageBlockSize = 128 * 1024 * 1024; Uint32 SubgroupSize = 0; diff --git a/MobileGL/MG_Backend/DirectGLES/BackendObject_DirectGLES.cpp b/MobileGL/MG_Backend/DirectGLES/BackendObject_DirectGLES.cpp index d9aec60c..abaf31d7 100644 --- a/MobileGL/MG_Backend/DirectGLES/BackendObject_DirectGLES.cpp +++ b/MobileGL/MG_Backend/DirectGLES/BackendObject_DirectGLES.cpp @@ -1331,9 +1331,14 @@ namespace MobileGL::MG_Backend::DirectGLES { DynParams::PerLayerFramebufferAttachmentBit(TextureTarget::TextureCubeMapArray); } } - // Not a driver question and never will be: OpenGL ES has no double-precision vertex format - // and ESSL has no fp64 type to consume one with, so a 64-bit vertex attribute has nowhere to - // land on this backend regardless of what the driver underneath happens to support. + // Not a driver question and never will be: GLSL ES has no 64-bit float type in ANY version + // or extension, so SPIRV-Cross cannot emit one ("FP64 not supported in ES profile") and a + // module that still declared Float64 would never reach the driver at all. The demotion is + // mathematically mandatory here, on every device, forever - which is why this stays false + // regardless of what the driver underneath happens to support. + m_dynamicParameters.SupportsShaderFloat64 = false; + // Follows the line above, and must: OpenGL ES has no double-precision vertex format and no + // fp64 type to consume one with, so a 64-bit vertex attribute has nowhere to land here. m_dynamicParameters.SupportsFloat64VertexAttributes = false; m_dynamicParameters.MaxDrawBuffers = m_GLESCapabilities.MaxDrawBuffers; m_dynamicParameters.MaxColorAttachments = m_GLESCapabilities.MaxColorAttachments; diff --git a/MobileGL/MG_Backend/DirectVulkan/BackendObject_DirectVulkan.cpp b/MobileGL/MG_Backend/DirectVulkan/BackendObject_DirectVulkan.cpp index 855d3aaf..a9b54e6a 100644 --- a/MobileGL/MG_Backend/DirectVulkan/BackendObject_DirectVulkan.cpp +++ b/MobileGL/MG_Backend/DirectVulkan/BackendObject_DirectVulkan.cpp @@ -562,12 +562,13 @@ namespace MobileGL::MG_Backend::DirectVulkan { if (MG_Util::Async::AsyncShaderCompileEnabled()) { extensions.push_back(E_GL_KHR_parallel_shader_compile); } - // GL_ARB_gpu_shader_fp64 is opt-in (MOBILEGL_ADVERTISE_FP64). Every `double` in a - // shader compiles and runs already - it is narrowed to 32 bits before the module - // reaches this backend - so an application that simply uses doubles needs nothing - // advertised. What the extension additionally promises is 64-bit PRECISION, which no - // mobile GPU has and the narrowing cannot fake, so advertising it by default would - // make an application that checks the string take a path MobileGL cannot honour. + // GL_ARB_gpu_shader_fp64 is opt-in (MOBILEGL_ADVERTISE_FP64), and stays opt-in even on a + // device that HAS shaderFloat64. Every `double` in a shader compiles and runs either way + // - narrowed to 32 bits where the device has no 64-bit floats, kept whole where it does - + // so an application that simply uses doubles needs nothing advertised. What the extension + // additionally promises is the whole GL_ARB_gpu_shader_fp64 SURFACE (glUniform*d + // conformance, the fp64 built-ins, the state queries), and turning the string on is a + // decision about all of it rather than about the shader path alone. if (MG_Config::Features.AdvertiseFp64) { extensions.push_back(E_GL_ARB_gpu_shader_fp64); } @@ -965,26 +966,34 @@ namespace MobileGL::MG_Backend::DirectVulkan { DynParams::PerLayerFramebufferAttachmentBit(TextureTarget::TextureCubeMapArray); } } - // Never, on any device, and no longer for the reason it used to be. It used to track - // shaderFloat64 because a `dvec3` input needed the Float64 capability to exist in the - // module at all; a 64-bit vertex FETCH was already impossible (VK_FORMAT_R64*_SFLOAT is - // optional and lavapipe reports zero bufferFeatures for all four), so the attribute - // arrived as its 32-bit word pair and PackDoubleVertexInputsPass bitcast it back. + // The device feature the whole fp64 story hangs off. With it, a module keeps its + // OpCapability Float64 and real doubles reach the driver; without it the transpile + // narrows every 64-bit float to 32 (ShaderTranspiler::DemoteFloat64Pass), because + // VUID-VkShaderModuleCreateInfo-pCode-08740 forbids the capability outright and no + // pipeline could be built from such a module. lavapipe reports it; Adreno and Mali both + // report VK_FALSE, so on every real mobile device this is false and the demotion runs + // exactly as it always has. + m_dynamicParameters.SupportsShaderFloat64 = m_vulkanCaps.SupportsShaderFloat64; + // Never, on any device, and DELIBERATELY NOT COUPLED to the line above even though it + // once tracked the same feature. It used to, because a `dvec` input needed Float64 to + // exist in the module at all; a 64-bit vertex FETCH was already impossible + // (VK_FORMAT_R64*_SFLOAT is optional and lavapipe reports zero bufferFeatures for all + // four), so the attribute arrived as its 32-bit word pair and PackDoubleVertexInputsPass + // bitcast it back. // - // The shader half of that is gone: every 64-bit float is narrowed before any module - // reaches a backend (ShaderTranspiler::DemoteFloat64Pass), so there is no `double` input - // left to bitcast INTO, and feeding a UINT-formatted attribute to what is now a `float` - // input would be silent garbage. Reconstructing the value would mean decoding the - // IEEE-754 double bit pattern in the shader - software fp64, which is precisely what the - // demotion exists to avoid - and on Espryt it would additionally need the ES driver to - // fetch 2N uint components where the application declared N doubles, which a dvec3 or - // dvec4 cannot even express within one attribute location. + // Re-coupling it does not work, and the reason is worth recording because it is not + // obvious: this flag decides the VkFormat from the VAO ATTRIBUTE alone, and the attribute + // does not know what the shader declared. glVertexAttribFormat(GL_DOUBLE) against a plain + // `in vec4` is not only legal but the common case + // (KHR-GL43.vertex_attrib_binding.basic-input-case4 does exactly that, and case5 adds + // normalized=GL_TRUE), and advanced-bindingUpdate feeds a dvec3 the same way - GL defines + // all of them as "doubles in memory, converted to float". Turning the flag on turns the + // narrowing OFF for every one of them and the attributes come back unfetched. // - // So glVertexAttribLFormat / glVertexAttribLPointer are declined here exactly as they - // already were on Espryt and on every real mobile device (Adreno and Mali both report - // shaderFloat64 == VK_FALSE), and for the same visible reason. A `dvec3` INPUT still - // compiles and draws - it is a `vec3` after demotion - as long as the application feeds - // it with glVertexAttribPointer(GL_FLOAT) rather than 64-bit data. + // What keeps the two halves honest instead is a per-MODULE decision: a vertex module that + // declares a 64-bit float INPUT is demoted whole, even where the backend has native fp64, + // so `dvec` inputs are `vec` inputs on this backend exactly as they always were. See + // ShaderCompiler::SanitizeAndOptimizeBinary. m_dynamicParameters.SupportsFloat64VertexAttributes = false; m_dynamicParameters.MaxShaderStorageBlockSize = std::min(m_vulkanCaps.MaxShaderStorageBlockSize, kMaxAdvertisedShaderStorageBlockSize); diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/VertexInputStateFactory.cpp b/MobileGL/MG_Backend/DirectVulkan/Renderer/VertexInputStateFactory.cpp index 713aad1f..62b68c6d 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/VertexInputStateFactory.cpp +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/VertexInputStateFactory.cpp @@ -112,10 +112,12 @@ namespace MobileGL::MG_Backend::DirectVulkan { ToVkVertexFormat(attr.Type, attr.Size, attr.Normalized, attr.IsInteger, attr.IsBgra, attr.IsLong); VertexStreamConversion conversion = VertexStreamConversion::None; // Gated on the SAME flag ToVkVertexFormat gates its 64-bit path on, and that is - // load-bearing rather than belt-and-braces: the narrowing is only correct because - // DemoteFloat64Pass already turned the shader's `dvec` input into a `vec`, and that - // pass runs precisely when the backend declares no 64-bit vertex support. With the - // flag set, a dvec3/dvec4 is declined by ToVkVertexFormat AND left 64-bit in the + // load-bearing rather than belt-and-braces: the narrowing is only correct because the + // shader's `dvec` input is a `vec` by the time the pipeline is built, and what + // guarantees that is the flag being clear. It is clear on every backend today, and a + // program with a 64-bit float vertex input is demoted WHOLE for the same reason even + // where the device has native fp64 (ProgramSpirvTask::GenerateSpirv). With the flag + // set, a dvec3/dvec4 would be declined by ToVkVertexFormat AND left 64-bit in the // module, so a float32 stream would be fed to a Float64 input. const Bool narrowFloat64Arrays = MG_Backend::pActiveBackendObject == nullptr || diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.cpp b/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.cpp index d4baee7e..93227887 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.cpp +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.cpp @@ -972,10 +972,12 @@ namespace MobileGL::MG_Backend::DirectVulkan { } } - // The fetch half of the fp64 demotion the shader side already does unconditionally - // (DemoteFloat64Pass): the source bytes are ordinary IEEE-754 doubles, so a GL_DOUBLE array is - // deinterleaved into a tightly packed float32 stream rather than dropped. `normalized` is not - // consulted - GL ignores it for floating-point array types. + // The fetch half of the 64-bit vertex narrowing, whose shader half is guaranteed by + // SupportsFloat64VertexAttributes staying false on this backend: any program with a Float64 + // vertex INPUT is demoted whole, native fp64 or not, so the input is always a 32-bit one. The + // source bytes are ordinary IEEE-754 doubles, so a GL_DOUBLE array is deinterleaved into a + // tightly packed float32 stream rather than dropped. `normalized` is not consulted - GL + // ignores it for floating-point array types. static Bool ConvertFloat64VertexStreamToFloat32( const MG_State::GLState::VertexAttribute& attribute, const Uint8* sourceData, diff --git a/MobileGL/MG_Impl/GLImpl/Program/GL_Program.cpp b/MobileGL/MG_Impl/GLImpl/Program/GL_Program.cpp index 9097eb78..fa237f13 100644 --- a/MobileGL/MG_Impl/GLImpl/Program/GL_Program.cpp +++ b/MobileGL/MG_Impl/GLImpl/Program/GL_Program.cpp @@ -880,6 +880,10 @@ namespace MobileGL::MG_Impl::GLImpl { // demotion makes a dmat4 a mat4 in the shader and a mat4-shaped slot here - but because it // is ROUTED differently: the caller's component-by-component EbtDouble branch has to widen // each float back to the queried type, and it undoes the same padding itself. + // Float matrices only, in both senses: a DOUBLE matrix never comes through here, whether its + // program was demoted (components are floats, the query is not) or kept its doubles (the + // column stride is a dvec4's, and the caller's converting branch already walks it component + // by component with the right one). Bool TryGatherFloatMatrixColumns(const TypeFactsRef ttype, const char* pBase, void* params) { if (!ttype.isMatrix || ttype.isDouble) return false; const Int columns = ttype.matrixCols; @@ -892,11 +896,12 @@ namespace MobileGL::MG_Impl::GLImpl { } // Bytes a uniform actually occupies in the global UBO. It is the tight GL type size for - // everything except a float matrix, whose padded columns make it wider. The rule itself - // lives on ProgramObject, because the pipeline composite's uniform refresh needs the same - // one and two copies of a layout rule is one too many. - SizeT UniformStorageSpanInBytes(const TypeFactsRef ttype, SizeT tightSize) { - return MG_State::GLState::ProgramObject::UniformStorageSpanInBytes(ttype, tightSize); + // everything except a matrix, whose padded columns make it wider, and a `double` on a + // program whose modules were demoted, where it is half. The rule itself lives on + // ProgramObject, because the pipeline composite's uniform refresh needs the same one and + // two copies of a layout rule is one too many. + SizeT UniformStorageSpanInBytes(const TypeFactsRef ttype, SizeT tightSize, const Bool nativeFloat64) { + return MG_State::GLState::ProgramObject::UniformStorageSpanInBytes(ttype, tightSize, nativeFloat64); } void GetUniform_State(GLuint program, GLint location, void* params) { @@ -929,7 +934,8 @@ namespace MobileGL::MG_Impl::GLImpl { auto size = programObject->GetUniformSizesInBytes(location); char* pUBO = (char*)programObject->MapUBO(); const auto& ttype = programObject->GetUniformTypeFacts(location); - const SizeT span = UniformStorageSpanInBytes(ttype, size); + const Bool nativeFloat64 = programObject->UsesNativeFloat64(); + const SizeT span = UniformStorageSpanInBytes(ttype, size, nativeFloat64); if (pUBO == nullptr || offset == MG_State::GLState::ProgramObject::kInvalidUniformOffset || offset + span > programObject->GetUBOSize()) { MGLOG_E_ONCE("%s: uniform at program %u location %d has no backing storage; returning nothing", __func__, @@ -939,9 +945,9 @@ namespace MobileGL::MG_Impl::GLImpl { if (!TryGatherFloatMatrixColumns(ttype, pUBO + offset, params)) { // Never more than the uniform actually occupies. `size` is the GL type size, - // which for a `double` uniform is twice its storage - every 64-bit float is - // narrowed before the module reaches a backend, so the slot holds floats. The - // typed entry points (glGetUniformdv and friends) go through + // which on a DEMOTED program is twice a `double` uniform's storage - its 64-bit + // floats were narrowed before the module reached a backend, so the slot holds + // floats. The typed entry points (glGetUniformdv and friends) go through // GetUniformScalar_State, which converts component by component; this raw // copy has no type to convert with, so it is bounded rather than converted. Memcpy(params, pUBO + offset, std::min(size, span)); @@ -983,7 +989,8 @@ namespace MobileGL::MG_Impl::GLImpl { auto size = programObject->GetUniformSizesInBytes(location); char* pUBO = static_cast(programObject->MapUBO()); const auto& ttype = programObject->GetUniformTypeFacts(location); - const SizeT span = UniformStorageSpanInBytes(ttype, size); + const Bool nativeFloat64 = programObject->UsesNativeFloat64(); + const SizeT span = UniformStorageSpanInBytes(ttype, size, nativeFloat64); if (pUBO == nullptr || offset == MG_State::GLState::ProgramObject::kInvalidUniformOffset || offset + span > programObject->GetUBOSize()) { MGLOG_E_ONCE("%s: uniform at program %u location %d has no backing storage; returning nothing", __func__, @@ -995,28 +1002,38 @@ namespace MobileGL::MG_Impl::GLImpl { if (TryGatherFloatMatrixColumns(ttype, pUBO + offset, params)) return; } - // A double-precision uniform is the one case where the stored component type differs - // from the DECLARED one for a non-opaque uniform: the shader's 64-bit floats are - // narrowed to 32 bits before the module reaches a backend + // A double-precision uniform is the one case where the stored component type can differ + // from the DECLARED one for a non-opaque uniform: on a DEMOTED program the shader's + // 64-bit floats were narrowed to 32 before the module reached the backend // (ShaderTranspiler::DemoteFloat64Pass), so what is in the global UBO is a float per // component, laid out exactly like the float-typed twin of this uniform - std140 // 16-byte column stride for a matrix included. Reading it as a GLdouble would return - // two components reinterpreted as one. Read component by component and let GL's + // two components reinterpreted as one. A program that KEPT its doubles stores real ones + // at the dvec4 column stride instead, so the width and the stride both move; everything + // else about this walk is the same. Read component by component either way and let GL's // conversion rules (7.6: round to nearest for the integer queries) apply; the value - // widens back to the queried type, having lost precision at the glUniform*d that - // stored it and not here. + // widens back to the queried type, having lost precision - where it lost any - at the + // glUniform*d that stored it and not here. if (ttype.isDouble) { const Int columns = ttype.isMatrix ? ttype.matrixCols : 1; const Int rows = ttype.isMatrix ? ttype.matrixRows : (ttype.isVector ? ttype.vectorSize : 1); - // std140 gives every matrix column its own 16-byte slot; a non-matrix is one - // tightly packed run and never reaches the stride at all. - const SizeT columnStride = 4 * sizeof(GLfloat); + // A non-matrix is one tightly packed run and never reaches the stride at all. + const SizeT columnStride = + MG_State::GLState::ProgramObject::UniformMatrixColumnStride(ttype, nativeFloat64); + const SizeT componentSize = nativeFloat64 ? sizeof(GLdouble) : sizeof(GLfloat); for (Int column = 0; column < columns; ++column) { for (Int row = 0; row < rows; ++row) { - GLfloat component = 0.0f; - Memcpy(&component, pUBO + offset + column * columnStride + row * sizeof(GLfloat), - sizeof(component)); + GLdouble component = 0.0; + if (nativeFloat64) { + Memcpy(&component, pUBO + offset + column * columnStride + row * componentSize, + sizeof(GLdouble)); + } else { + GLfloat narrow = 0.0f; + Memcpy(&narrow, pUBO + offset + column * columnStride + row * componentSize, + sizeof(narrow)); + component = static_cast(narrow); + } if constexpr (std::is_integral_v) { // Rounded to the nearest integer and clamped into the queried type's // range, so a negative double read through glGetUniformuiv is 0 @@ -1287,17 +1304,45 @@ namespace MobileGL::MG_Impl::GLImpl { } } - // glUniform*d / glUniformMatrix*dv. Neither needs a layout of its own any more: the - // transpile chain narrows every 64-bit float in the shader to 32 bits + // Whether the program a uniform write is about to land in stores 64-bit floats at their + // declared width. Answered off the PROGRAM, never off the live backend: it describes the + // modules that were actually built for it, and a backend with native fp64 still demotes a + // program whose vertex stage declares a Float64 input (see ProgramSpirvTask::GenerateSpirv). + // Nullptr - no current program, or a name that is not a program - answers false and lets the + // callee record the same error it always did. + Bool CurrentProgramUsesNativeFloat64() { + if (MG_State::pGLContext == nullptr) return false; + const auto& programObject = MG_State::pGLContext->GetProgramForUniform(); + return programObject != nullptr && programObject->UsesNativeFloat64(); + } + + Bool NamedProgramUsesNativeFloat64(GLuint program) { + const auto& programObject = TryToGetProgramObject(program); + return programObject != nullptr && programObject->GetLinkStatus() && programObject->UsesNativeFloat64(); + } + + // glUniform*d / glUniformMatrix*dv. On a DEMOTED program neither needs a layout of its own: + // the transpile chain narrowed every 64-bit float in the shader to 32 // (ShaderTranspiler::DemoteFloat64Pass) and the global UBO is laid out by reflecting that // demoted module, so a double uniform's storage IS a float uniform's - same offset, same // 4-byte components, same std140 column padding for matrices. Narrowing here, at the one // place the 64-bit value enters, and then handing the bytes to the ordinary float upload - // path is what keeps the two in step; a separate double-shaped layout here would write + // path is what keeps the two in step; a separate double-shaped layout there would write // 8-byte components into 4-byte slots and silently address the wrong ones. // - // The narrowing is the same static_cast the shader's own arithmetic now performs, so the + // The narrowing is the same static_cast the demoted shader's own arithmetic performs, so the // value the shader reads is the value glUniform*d was given, at float precision. + // + // On a program that KEPT its doubles the reverse is true and for the same reason: its global + // UBO really does hold 8-byte components, so narrowing would leave a float bit pattern in the + // low half of a double slot - which is not a precision loss but a garbage value. The 64-bit + // values go through unchanged then, and the upload path is width-agnostic (it is templated on + // the component type and bounded by the uniform's own slot span). + // + // Note TryToGetProgramObject / GetProgramForUniform run TWICE on this path, once for the + // width question and once inside the call below. That is a lookup and a join on an entry + // point no shader pack uses; the alternative is duplicating both functions' whole validation + // sequence here, which is the thing that must not drift. template void UniformvNarrowed_State(GLint location, GLsizei count, const GLdouble* value) { if (value == nullptr || count <= 0) { @@ -1306,6 +1351,10 @@ namespace MobileGL::MG_Impl::GLImpl { Uniformv_State(location, count, reinterpret_cast(value)); return; } + if (location != -1 && CurrentProgramUsesNativeFloat64()) { + Uniformv_State(location, count, value); + return; + } Vector narrowed(static_cast(count) * ItemCount); for (SizeT i = 0; i < narrowed.size(); ++i) narrowed[i] = static_cast(value[i]); Uniformv_State(location, count, narrowed.data()); @@ -1317,6 +1366,10 @@ namespace MobileGL::MG_Impl::GLImpl { ProgramUniformv_State(program, location, count, reinterpret_cast(value)); return; } + if (location != -1 && NamedProgramUsesNativeFloat64(program)) { + ProgramUniformv_State(program, location, count, value); + return; + } Vector narrowed(static_cast(count) * ItemCount); for (SizeT i = 0; i < narrowed.size(); ++i) narrowed[i] = static_cast(value[i]); ProgramUniformv_State(program, location, count, narrowed.data()); @@ -1368,15 +1421,63 @@ namespace MobileGL::MG_Impl::GLImpl { } } - // glUniformMatrix*dv / glProgramUniformMatrix*dv. Narrowed to the float form and handed - // straight to it: after DemoteFloat64Pass a `dmat4` uniform is a `mat4` in the shader and a - // mat4-shaped slot in the global UBO, columns padded to a vec4 and all. Everything else - // about the call - transpose handling, the array-element walk, the opaque-uniform refusal - - // is then the one implementation both spellings share. + // glUniformMatrix*dv / glProgramUniformMatrix*dv on a program that KEPT its doubles. Same + // walk as UniformMatrixfv_Object down to the last branch, and deliberately a copy of it + // rather than a template over the component type: the two differ in exactly one number that + // is not derivable from the component type alone - std140 pads a double matrix's column out + // to a dvec4 (32 bytes) unless the column is a dvec2, which is already 16 - and folding that + // into the float version would put a per-call branch on the hot glUniformMatrix4fv path + // Minecraft calls thousands of times a frame for a case no shader pack ever takes. + template + void UniformMatrixdvNative_Object(Program& programObject, GLint location, GLsizei count, GLboolean transpose, + const GLdouble* value, Int columns, Int rows, + const String& ownerDescription) { + const SizeT columnStride = rows <= 2 ? 2 * sizeof(GLdouble) : 4 * sizeof(GLdouble); + const SizeT componentCount = static_cast(columns) * static_cast(rows); + GLdouble column[4] = {}; + for (GLint matrix = 0; matrix < count; ++matrix) { + if (matrix > 0 && !programObject.UniformLocationsAliasSameUniform(location, location + matrix)) break; + if (!programObject.IsValidUniformLocation(location + matrix)) { + RecordInvalidUniformLocationError("glUniformMatrixdv", location + matrix, ownerDescription); + return; + } + if (programObject.IsUniformOpaqueAtLocation(location + matrix)) { + MG_State::pGLContext->RecordError( + ErrorCode::InvalidOperation, + MakeUnique("MG_Impl/GLImpl", "glUniformMatrixdv", + "Opaque uniforms cannot be set with matrix Uniform calls.")); + return; + } + const GLdouble* source = value + static_cast(matrix) * componentCount; + for (Int c = 0; c < columns; ++c) { + for (Int r = 0; r < rows; ++r) { + column[r] = transpose == GL_TRUE ? source[r * columns + c] : source[c * rows + r]; + } + const SizeT byteOffset = static_cast(c) * columnStride; + switch (rows) { + case 2: Uniform_State<2>(programObject, location + matrix, column, byteOffset); break; + case 3: Uniform_State<3>(programObject, location + matrix, column, byteOffset); break; + default: Uniform_State<4>(programObject, location + matrix, column, byteOffset); break; + } + } + } + } + + // glUniformMatrix*dv / glProgramUniformMatrix*dv. On a DEMOTED program this narrows to the + // float form and hands it straight over: after DemoteFloat64Pass a `dmat4` uniform is a + // `mat4` in the shader and a mat4-shaped slot in the global UBO, columns padded to a vec4 + // and all. Everything else about the call - transpose handling, the array-element walk, the + // opaque-uniform refusal - is then the one implementation both spellings share. A program + // that kept its doubles gets the same walk at double width and the wider column stride. template void UniformMatrixdv_Object(Program& programObject, GLint location, GLsizei count, GLboolean transpose, const GLdouble* value, Int columns, Int rows) { if (value == nullptr || count <= 0) return; + if (programObject.UsesNativeFloat64()) { + UniformMatrixdvNative_Object(programObject, location, count, transpose, value, columns, rows, + "the current program object"); + return; + } const SizeT componentCount = static_cast(columns) * static_cast(rows); Vector narrowed(static_cast(count) * componentCount); for (SizeT i = 0; i < narrowed.size(); ++i) narrowed[i] = static_cast(value[i]); diff --git a/MobileGL/MG_IntegrationTest/Scenarios/DoublePrecisionScenario.cpp b/MobileGL/MG_IntegrationTest/Scenarios/DoublePrecisionScenario.cpp index 4028cd7d..5a931749 100644 --- a/MobileGL/MG_IntegrationTest/Scenarios/DoublePrecisionScenario.cpp +++ b/MobileGL/MG_IntegrationTest/Scenarios/DoublePrecisionScenario.cpp @@ -6,25 +6,29 @@ // SPDX-License-Identifier: LGPL-3.0-only // End of Source File Header // -// Scenario - GLSL DOUBLES, RUN AT SINGLE PRECISION. +// Scenario - GLSL DOUBLES, AT WHATEVER PRECISION THE BACKEND CAN GIVE. // // No mobile GPU has 64-bit floats. Adreno and Mali both report shaderFloat64 == VK_FALSE, so -// Magma cannot build a module that declares the Float64 capability, and ESSL has no fp64 type -// at all, so SPIRV-Cross refuses the module outright on Espryt ("FP64 not supported in ES -// profile") and the program never reaches the driver. MobileGL therefore narrows every 64-bit -// float in a shader to 32 bits (ShaderTranspiler::DemoteFloat64Pass) rather than declining the -// shader: `double` compiles and runs everywhere, at float precision. +// Magma cannot build a module that declares the Float64 capability there, and ESSL has no fp64 +// type at all, so SPIRV-Cross refuses the module outright on Espryt ("FP64 not supported in ES +// profile") and the program never reaches the driver. On every such backend MobileGL narrows +// every 64-bit float in a shader to 32 bits (ShaderTranspiler::DemoteFloat64Pass) rather than +// declining the shader: `double` compiles and runs everywhere, at float precision. Where the +// backend DOES consume 64-bit floats - lavapipe is the one that does - the narrowing is skipped +// and the doubles reach the driver whole. // -// The narrowing is only half a contract. The other half is the API side: the global UBO is -// laid out by reflecting the DEMOTED module, so glUniform*d has to store a float where the -// shader reads a float, glGetUniform*v has to read one back, and a dmat4's columns are now -// std140-padded like any other matrix's. Every one of those is a byte offset that fails -// silently - the uniform simply reads as something else - so the cases below set values -// through the API and have the SHADER report what it saw. +// Either way it is only half a contract. The other half is the API side: the global UBO is laid +// out by reflecting whichever module was produced, so glUniform*d has to store the width the +// shader reads, glGetUniform*v has to read that width back, and a matrix's columns are +// std140-padded to a vec4 or a dvec4 to match. Every one of those is a byte offset that fails +// silently - the uniform simply reads as something else - so the cases below set values through +// the API and have the SHADER report what it saw. // -// What is deliberately NOT asserted: that the values are exact to double precision. They are -// not, and cannot be. Every expectation here is the float value of the double that was set, -// which is the whole point. +// WHY ALMOST EVERY EXPECTATION HERE IS A FLOAT VALUE, and why that is not an accident of the +// demotion: the shader reports through a `float` SSBO, and every value chosen is exact in +// float32, so the same number is correct in both regimes and the assertions test the LAYOUT +// rather than the precision. Exactly one case (GetUniformdvReadsBackWhatWasStored) uses a value +// that is not - 0.1 - and it names both answers explicitly. #include #include @@ -574,12 +578,24 @@ void main() { glUseProgram(0); // The readback has to undo exactly what the write did - the same std140 column - // padding, the same 4-byte components - or a dmat4 comes back with its columns - // shifted and nothing else in the API would say so. + // padding, the same component width - or a dmat4 comes back with its columns + // shifted and nothing else in the API would say so. Every value below except the + // scalar is exact in float32, so those expectations pin the LAYOUT and hold in + // either regime; the scalar is the one that also pins the PRECISION. GLdouble readScalar = 0.0; glGetUniformdv(m_program, scalar, &readScalar); - EXPECT_DOUBLE_EQ(readScalar, static_cast(static_cast(0.1))) - << "the value is what a float can hold, not the double that was passed in"; + // 0.1 is not representable in float32, so what comes back names the regime: a + // backend without native fp64 narrowed it at the glUniform1d above (the module's own + // doubles were demoted, so its storage is 4 bytes per component), and one with it + // stored the double whole. Both are correct; asserting only the narrow answer would + // fail the moment fp64 stops being emulated, and asserting only the wide one would + // fail on every mobile device there is. + if (readScalar == 0.1) { + SUCCEED() << "this backend consumes 64-bit floats natively; the double survived whole"; + } else { + EXPECT_DOUBLE_EQ(readScalar, static_cast(static_cast(0.1))) + << "the value is what a float can hold, not the double that was passed in"; + } GLdouble readVector[3] = {}; glGetUniformdv(m_program, vector, readVector); @@ -593,7 +609,8 @@ void main() { EXPECT_DOUBLE_EQ(readMatrix[i], 100.0 + i) << "dmat4 component " << i; } - // The float query sees the same storage through the type it is actually stored as. + // The float query sees the same storage through a narrower type, and answers the + // same float either way: GL 4.6 core 7.6 converts on the way out. GLfloat readFloat = 0.0f; glGetUniformfv(m_program, scalar, &readFloat); EXPECT_FLOAT_EQ(readFloat, static_cast(0.1)); diff --git a/MobileGL/MG_State/GLState/ProgramState/ProgramLinkTask.cpp b/MobileGL/MG_State/GLState/ProgramState/ProgramLinkTask.cpp index 80d35d77..1580e63a 100644 --- a/MobileGL/MG_State/GLState/ProgramState/ProgramLinkTask.cpp +++ b/MobileGL/MG_State/GLState/ProgramState/ProgramLinkTask.cpp @@ -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); diff --git a/MobileGL/MG_State/GLState/ProgramState/ProgramObject.cpp b/MobileGL/MG_State/GLState/ProgramState/ProgramObject.cpp index 687387ea..c3cdbc25 100644 --- a/MobileGL/MG_State/GLState/ProgramState/ProgramObject.cpp +++ b/MobileGL/MG_State/GLState/ProgramState/ProgramObject.cpp @@ -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 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, 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(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(location)), + GetUniformSizesInBytes(static_cast(location)), nativeFloat64); const SizeT columnStride = columns > 0 ? slotSpan / static_cast(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) 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(offset) + static_cast(column) * columnStride; - const SizeT writeSize = static_cast(componentsPerColumn) * sizeof(Uint32); + const SizeT writeSize = static_cast(componentsPerColumn) * componentSize; if (byteOffset + writeSize > uboSize) break; const SizeT firstComponent = static_cast(element) * componentsPerElement + static_cast(column) * componentsPerColumn; for (Int component = 0; component < componentsPerColumn; ++component) { const SizeT source = firstComponent + static_cast(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(init.floatValues[source]); std::memcpy(destination, &value, sizeof(value)); } else { diff --git a/MobileGL/MG_State/GLState/ProgramState/ProgramObject.h b/MobileGL/MG_State/GLState/ProgramState/ProgramObject.h index f9e33a26..7104ab61 100644 --- a/MobileGL/MG_State/GLState/ProgramState/ProgramObject.h +++ b/MobileGL/MG_State/GLState/ProgramState/ProgramObject.h @@ -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(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(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 ---- diff --git a/MobileGL/MG_State/GLState/ProgramState/ProgramSpirvTask.cpp b/MobileGL/MG_State/GLState/ProgramState/ProgramSpirvTask.cpp index 4808947b..f2c1021d 100644 --- a/MobileGL/MG_State/GLState/ProgramState/ProgramSpirvTask.cpp +++ b/MobileGL/MG_State/GLState/ProgramState/ProgramSpirvTask.cpp @@ -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 diff --git a/MobileGL/MG_State/GLState/ProgramState/ProgramSpirvTask.h b/MobileGL/MG_State/GLState/ProgramState/ProgramSpirvTask.h index e48fa3dd..d3bc4a3a 100644 --- a/MobileGL/MG_State/GLState/ProgramState/ProgramSpirvTask.h +++ b/MobileGL/MG_State/GLState/ProgramState/ProgramSpirvTask.h @@ -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 diff --git a/MobileGL/MG_Test/ShaderTranspiler/DemoteFloat64Test.cpp b/MobileGL/MG_Test/ShaderTranspiler/DemoteFloat64Test.cpp index b1b6dfd0..41851041 100644 --- a/MobileGL/MG_Test/ShaderTranspiler/DemoteFloat64Test.cpp +++ b/MobileGL/MG_Test/ShaderTranspiler/DemoteFloat64Test.cpp @@ -379,6 +379,90 @@ TEST_F(DemoteFloat64Test, TheSharedChainDemotesToo) { EXPECT_FALSE(ShaderCompiler::ModuleDeclaresFloat64(output)) << Disassemble(output); } +// --------------------------------------------------------------------------- +// The capability gate. A backend that consumes 64-bit floats itself gets none of this. +// --------------------------------------------------------------------------- + +namespace { + // Everything kWideVertexSource has except the 64-bit vertex INPUT, which is what the + // whole-program demotion falls back for. A fragment stage, so there is no input to have. + constexpr const char* kWideFragmentSource = R"(#version 460 core +layout(std140, binding = 0) uniform Blk { + float a; + double d; + dvec2 v2; + dvec4 v4; + dmat4 m4; + double arr[3]; +}; +layout(location = 0) uniform double uScale; +layout(location = 0) in vec3 inNormal; +layout(location = 0) out float fOut; +void main() { + double s = d * uScale + a; + s += v2.x + v4.y + m4[0].z + arr[0] + arr[1] + arr[2] + 0.5lf; + fOut = float(s) + inNormal.x; +} +)"; +} // namespace + +// THE NEGATIVE CONTROL for the whole change: the identical module through the identical entry +// point answers both ways, and the only thing that moved is the capability argument. +TEST_F(DemoteFloat64Test, TheSharedChainKeepsFloat64WhenTheBackendConsumesIt) { + const Vector input = CompileToSpirv(GL_FRAGMENT_SHADER, kWideFragmentSource); + ASSERT_FALSE(input.empty()); + ASSERT_TRUE(DeclaresFloat64Capability(input)); + ASSERT_GT(CountFloatTypesOfWidth(input, 64), 0u); + + Vector native; + ASSERT_TRUE(ShaderCompiler::SanitizeAndOptimizeBinary(input, native, true, true, true)); + EXPECT_TRUE(DeclaresFloat64Capability(native)) << Disassemble(native); + EXPECT_GT(CountFloatTypesOfWidth(native, 64), 0u) << Disassemble(native); + EXPECT_TRUE(ShaderCompiler::ModuleDeclaresFloat64(native)); + + Vector demoted; + ASSERT_TRUE(ShaderCompiler::SanitizeAndOptimizeBinary(input, demoted, true, true, false)); + EXPECT_FALSE(DeclaresFloat64Capability(demoted)) << Disassemble(demoted); + EXPECT_EQ(CountFloatTypesOfWidth(demoted, 64), 0u) << Disassemble(demoted); + EXPECT_FALSE(ShaderCompiler::ModuleDeclaresFloat64(demoted)); + + EXPECT_NE(native, demoted); +} + +// The exception the vertex path needs, at the level ProgramSpirvTask asks it: no backend here can +// FETCH 64 bits, so a stage that declares a Float64 input is demoted whole even where the rest of +// its doubles could have survived. +TEST_F(DemoteFloat64Test, AFloat64VertexInputIsRecognisedAndOnlyOnAVertexStage) { + const Vector vertexWithDoubleInput = CompileToSpirv(GL_VERTEX_SHADER, kWideVertexSource); + ASSERT_FALSE(vertexWithDoubleInput.empty()); + EXPECT_TRUE(ShaderCompiler::ModuleDeclaresFloat64VertexInput(vertexWithDoubleInput)); + + // Doubles everywhere but the inputs: the same verdict must be false, or nothing would ever + // take the native path. + const Vector fragmentWithDoubles = CompileToSpirv(GL_FRAGMENT_SHADER, kWideFragmentSource); + ASSERT_FALSE(fragmentWithDoubles.empty()); + EXPECT_FALSE(ShaderCompiler::ModuleDeclaresFloat64VertexInput(fragmentWithDoubles)); + + // A vertex stage whose doubles are all internal is fine too - it is the INPUT that cannot be + // fed, not the stage. + const String vertexWithoutDoubleInput = R"(#version 460 core +layout(location = 0) uniform double uScale; +layout(location = 0) in vec3 inPos; +layout(location = 0) out float vOut; +void main() { + double s = double(inPos.x) * uScale + 0.5lf; + vOut = float(s); + gl_Position = vec4(float(s)); +} +)"; + const Vector internalOnly = CompileToSpirv(GL_VERTEX_SHADER, vertexWithoutDoubleInput); + ASSERT_FALSE(internalOnly.empty()); + EXPECT_TRUE(ShaderCompiler::ModuleDeclaresFloat64(internalOnly)); + EXPECT_FALSE(ShaderCompiler::ModuleDeclaresFloat64VertexInput(internalOnly)); + + EXPECT_FALSE(ShaderCompiler::ModuleDeclaresFloat64VertexInput({})); +} + // 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 {}; diff --git a/MobileGL/MG_Test/ShaderTranspiler/FlattenFloat64StorageBlockTest.cpp b/MobileGL/MG_Test/ShaderTranspiler/FlattenFloat64StorageBlockTest.cpp index 0a3f5574..64b09d20 100644 --- a/MobileGL/MG_Test/ShaderTranspiler/FlattenFloat64StorageBlockTest.cpp +++ b/MobileGL/MG_Test/ShaderTranspiler/FlattenFloat64StorageBlockTest.cpp @@ -303,3 +303,45 @@ void main() { // reflects and what glUniform*d then writes into. EXPECT_EQ(MemberOffsetsOf(output, structId), (Vector{0, 4, 8})) << Disassemble(output); } + +// --------------------------------------------------------------------------- +// The capability-gated half: a backend that consumes 64-bit floats natively gets neither pass. +// --------------------------------------------------------------------------- + +// The flatten exists to preserve a byte layout ACROSS a narrowing. Where nothing narrows there is +// nothing to preserve and the driver lays the block out itself - so the block keeps its seven +// members at the offsets glslang computed, and the doubles in it are still doubles. +TEST_F(FlattenFloat64StorageBlockTest, TheNativePathLeavesTheBlockAndItsDoublesAlone) { + const Vector input = CompileToSpirv(GL_COMPUTE_SHADER, kStd140BlockSource); + ASSERT_FALSE(input.empty()); + const Uint32 inputStructId = StructIdNamed(input, "Wide"); + ASSERT_NE(inputStructId, 0u); + const Vector inputOffsets = MemberOffsetsOf(input, inputStructId); + + Vector output; + ASSERT_TRUE(ShaderCompiler::SanitizeAndOptimizeBinary(input, output, true, true, true)); + ASSERT_FALSE(output.empty()); + + const Uint32 structId = StructIdNamed(output, "Wide"); + ASSERT_NE(structId, 0u) << Disassemble(output); + EXPECT_EQ(MemberTypesOf(output, structId).size(), 7u) + << "the block must not be flattened when nothing is narrowing it\n" + << Disassemble(output); + EXPECT_EQ(MemberOffsetsOf(output, structId), inputOffsets) << Disassemble(output); + EXPECT_GT(CountFloatTypesOfWidth(output, 64), 0u) << Disassemble(output); +} + +// And the control: the SAME module through the SAME entry point with the bit clear is flattened +// exactly as it always was. This is the pair that pins "capability-false is byte-for-byte the old +// behaviour" at the level the device A/B checks. +TEST_F(FlattenFloat64StorageBlockTest, TheDemotedPathIsUnchangedByTheCapabilityArgument) { + const Vector input = CompileToSpirv(GL_COMPUTE_SHADER, kStd140BlockSource); + ASSERT_FALSE(input.empty()); + + Vector explicitlyDemoted; + ASSERT_TRUE(ShaderCompiler::SanitizeAndOptimizeBinary(input, explicitlyDemoted, true, true, false)); + // The four-argument spelling every existing caller uses, which must keep meaning "demote". + const Vector defaulted = Sanitize(input); + EXPECT_EQ(explicitlyDemoted, defaulted); + EXPECT_EQ(CountFloatTypesOfWidth(defaulted, 64), 0u) << Disassemble(defaulted); +} diff --git a/MobileGL/MG_Test/ShaderTranspiler/TranslationCacheTest.cpp b/MobileGL/MG_Test/ShaderTranspiler/TranslationCacheTest.cpp index 9c3ced4d..b3b391a6 100644 --- a/MobileGL/MG_Test/ShaderTranspiler/TranslationCacheTest.cpp +++ b/MobileGL/MG_Test/ShaderTranspiler/TranslationCacheTest.cpp @@ -491,6 +491,14 @@ TEST_F(TranslationCacheTest, L1KeyMovesWithEveryInputThatMovesTheSpirv) { v.enableSpirvValidation = true; variants.emplace_back("enableSpirvValidation", BuildSpirvTranslationKey(v)); } + { // CompileEnv::ConsumesFloat64Natively(): the fp64 tail of SanitizeAndOptimizeBinary is + // skipped under it, so the SAME GLSL yields modules with real doubles under one answer + // and demoted, storage-block-flattened ones under the other. The one backend capability + // bit in this key, and the only one allowed in without changing what glslang produces. + SpirvTranslationKeyInputs v = base; + v.nativeFloat64 = true; + variants.emplace_back("nativeFloat64", BuildSpirvTranslationKey(v)); + } // ---- inputs the WIDENED payload pulled into the key ---- // They cannot move a word of the generated SPIR-V, but they do shape the reflection the // payload now carries, so they have to split the key. This is the group that would go @@ -599,6 +607,45 @@ TEST_F(TranslationCacheTest, TwoBackendsCompilingTheSameGlslShareOneL1Entry) { EXPECT_TRUE(BuildSpirvTranslationKey(onA) == BuildSpirvTranslationKey(onB)); } +// The ONE capability bit that breaks that sharing, and the two halves of why it is placed where +// it is. It must NOT move the front-end fingerprint - glslang parses, reflects and generates a +// `double` identically under it, and L1c (the parse-verdict memo) keys on that same fingerprint +// and would take a false miss per backend for nothing. It MUST move the L1 key, because L1's +// payload is the module AFTER SanitizeAndOptimizeBinary and the fp64 tail of that chain is +// exactly what this bit gates. +TEST_F(TranslationCacheTest, NativeFloat64IsOutOfTheFrontendFingerprintAndInsideTheL1Key) { + CompileEnv none; // no backend at all + CompileEnv emulated; // a backend without the feature + CompileEnv nativeEnv; // a backend with it + emulated.backend = BackendType::DirectVulkan; + nativeEnv.backend = BackendType::DirectVulkan; + nativeEnv.params.SupportsShaderFloat64 = true; + + // No backend answers FALSE: the demoted module is the one that works everywhere, so a + // standalone compile gets it. + EXPECT_FALSE(none.ConsumesFloat64Natively()); + EXPECT_FALSE(emulated.ConsumesFloat64Natively()); + EXPECT_TRUE(nativeEnv.ConsumesFloat64Natively()); + + EXPECT_EQ(ComputeFrontendCompileEnvFingerprint(emulated), ComputeFrontendCompileEnvFingerprint(nativeEnv)) + << "the fp64 capability leaked into the front-end fingerprint"; + EXPECT_NE(ComputeCompileEnvFingerprint(emulated), ComputeCompileEnvFingerprint(nativeEnv)) + << "the whole-environment fingerprint has to notice it - it is a DynamicBackendParameters " + "field, hashed by object representation"; + + const Vector stages{{GL_VERTEX_SHADER, kVertexSource}, + {GL_FRAGMENT_SHADER, kFragmentSource}}; + SpirvTranslationKeyInputs demoted = BaselineSpirvInputs(stages); + demoted.frontendFingerprint = ComputeFrontendCompileEnvFingerprint(emulated); + demoted.nativeFloat64 = emulated.ConsumesFloat64Natively(); + SpirvTranslationKeyInputs kept = BaselineSpirvInputs(stages); + kept.frontendFingerprint = ComputeFrontendCompileEnvFingerprint(nativeEnv); + kept.nativeFloat64 = nativeEnv.ConsumesFloat64Natively(); + + EXPECT_FALSE(BuildSpirvTranslationKey(demoted) == BuildSpirvTranslationKey(kept)) + << "one L1 entry would then describe two different module sets"; +} + // The other direction, one case per input that was KEPT. Each is a limit the front end // really consumes - everything BuildTBuiltInResource copies into TBuiltInResource, plus the // two inputs to the reflection vertex-attrib limit - so each must still split the key. diff --git a/MobileGL/MG_Util/SelfTest/DriverPost.cpp b/MobileGL/MG_Util/SelfTest/DriverPost.cpp index 26d31e48..5e1942d6 100644 --- a/MobileGL/MG_Util/SelfTest/DriverPost.cpp +++ b/MobileGL/MG_Util/SelfTest/DriverPost.cpp @@ -51,19 +51,21 @@ namespace MobileGL::MG_Util::SelfTest { }; // Both backends' fp64 rows end the same way, and the sentence they end with depends on - // a config flag rather than on anything either backend probes: the demotion is what - // makes doubles work, but GL_ARB_gpu_shader_fp64 promises the PRECISION the demotion - // cannot deliver, so the string is opt-in and the row has to say which way it went. + // a config flag rather than on anything either backend probes: doubles WORK on every + // backend, but GL_ARB_gpu_shader_fp64 additionally promises 64-bit PRECISION, which only + // a backend that consumes fp64 natively actually has. The string is opt-in either way - + // advertising it is a decision about the whole extension's surface, not just about + // precision - so the row has to say which way it went. String AppendFp64AdvertisementNote(String detail) { if (MG_Config::Features.AdvertiseFp64) { return Move(detail) + ". GL_ARB_gpu_shader_fp64 IS advertised (MOBILEGL_ADVERTISE_FP64): an application " - "that checks the string will believe it has 64-bit precision, and it does not"; + "that checks the string will believe it has 64-bit precision, which is true only " + "where the row above says native"; } return Move(detail) + - ". GL_ARB_gpu_shader_fp64 is not advertised, because the precision it promises is the " - "one thing the demotion cannot provide; set MOBILEGL_ADVERTISE_FP64=1 to advertise it " - "anyway"; + ". GL_ARB_gpu_shader_fp64 is not advertised by default; set MOBILEGL_ADVERTISE_FP64=1 " + "to advertise it anyway"; } struct ReportBuilder { @@ -2321,27 +2323,35 @@ namespace MobileGL::MG_Util::SelfTest { "unsupported; a GL_TEXTURE_CUBE_MAP_ARRAY texture gets no image at all, so sampling " "one reads nothing and glFramebufferTextureLayer on one is declined"); } - // Reported whichever way the device answers, because MobileGL no longer follows the - // device here: every 64-bit float is narrowed to 32 bits before any module reaches this - // backend (DemoteFloat64Pass), so the Float64 capability is never declared and a device - // that HAS the feature gains nothing from it. The device's own answer is still worth - // printing - it is the reason the demotion is unconditional. - builder.Pass("fp64", AppendFp64AdvertisementNote( - format("demoted to fp32 (device shaderFloat64 = {}) - every double / dvec / " - "dmat in a shader is narrowed to 32 bits before pipeline creation, so " - "such shaders BUILD AND RUN at single precision on every device " - "instead of failing to create a shader module on the ones without the " - "feature. A block containing a double is re-laid-out for the narrowed " - "members, so an application that hard-codes std140 offsets computed " - "for doubles must query them instead", - features.shaderFloat64 == VK_TRUE ? "supported" : "unsupported"))); + // MobileGL follows the device here: shaderFloat64 decides whether a module keeps its + // 64-bit floats or has them narrowed before pipeline creation (DemoteFloat64Pass). Adreno + // and Mali both report VK_FALSE, so the demoted row is what a real phone prints; lavapipe + // reports VK_TRUE and gets real doubles. + if (features.shaderFloat64 == VK_TRUE) { + builder.Pass("fp64", AppendFp64AdvertisementNote( + "native (device shaderFloat64 = supported) - every double / dvec / dmat in " + "a shader keeps its declared width, blocks keep the layout glslang computed " + "for them, and glUniform*d stores 8-byte components. The one exception is a " + "VERTEX stage that declares a 64-bit float INPUT: there is no 64-bit vertex " + "FETCH here, so such a program is narrowed whole exactly as it would be on a " + "device without the feature")); + } else { + builder.Pass("fp64", AppendFp64AdvertisementNote( + "demoted to fp32 (device shaderFloat64 = unsupported) - every double / dvec " + "/ dmat in a shader is narrowed to 32 bits before pipeline creation, so such " + "shaders BUILD AND RUN at single precision instead of failing to create a " + "shader module. A block containing a double is re-laid-out for the narrowed " + "members, so an application that hard-codes std140 offsets computed for " + "doubles must query them instead")); + } builder.Warn("64-bit vertex attributes", - "narrowed to float32; there is no 64-bit shader input left to feed after the fp64 " - "demotion above, and no VK_FORMAT_R64*_SFLOAT vertex fetch to feed it with on most " - "devices anyway. glVertexAttribLFormat succeeds, its state is queryable, and an " - "ENABLED 64-bit array IS fetched - the source doubles are deinterleaved into a " - "float32 stream at draw, so values outside float32's range or precision are " - "rounded rather than exact"); + "narrowed to float32 on every device, whatever the row above says: there is no " + "VK_FORMAT_R64*_SFLOAT vertex fetch here, and the format is chosen from the VAO " + "attribute, which does not know what type the shader declared - which is why a " + "vertex stage with a 64-bit float INPUT is narrowed whole even where fp64 is native. " + "glVertexAttribLFormat succeeds, its state is queryable, and an ENABLED 64-bit array " + "IS fetched - the source doubles are deinterleaved into a float32 stream at draw, so " + "values outside float32's range or precision are rounded rather than exact"); Bool shaderDrawParameters = false; if (vkGetPhysicalDeviceFeatures2Fn != nullptr && properties.apiVersion >= VK_API_VERSION_1_1) { diff --git a/MobileGL/MG_Util/ShaderTranspiler/CompileEnv.h b/MobileGL/MG_Util/ShaderTranspiler/CompileEnv.h index 2bc67d33..769c9859 100644 --- a/MobileGL/MG_Util/ShaderTranspiler/CompileEnv.h +++ b/MobileGL/MG_Util/ShaderTranspiler/CompileEnv.h @@ -117,8 +117,14 @@ namespace MobileGL::MG_Util::ShaderTranspiler { // preprocessed text is in the L1 key verbatim, a strictly finer discriminator // than the extension list. (E_GL_ARB_gpu_shader_fp64 is never read by the front // end at all: MOBILEGL_ADVERTISE_FP64 only adds it to the extension STRING the - // application queries, and DemoteFloat64Pass runs unconditionally either way, so - // fp64 GLSL translates identically with the flag on or off.) + // application queries, and glslang parses `double` the same way either way.) + // * params.SupportsShaderFloat64, i.e. ConsumesFloat64Natively(). glslang produces + // the SAME SPIR-V under it - a `double` parses, reflects and generates as a + // 64-bit float regardless - so it is not a front-end input and putting it here + // would also cost L1c (the parse-verdict memo, which keys on this fingerprint and + // is genuinely independent of it) a false miss per backend. It DOES change what + // SanitizeAndOptimizeBinary produces, and L1's payload is post-Sanitize, so it + // rides in L1's key as a field of its own; see SpirvTranslationKeyInputs. // * the other ~50 DynamicBackendParameters fields: read by the GL getters and by // the backends, never by the parse, the link or reflection. // * maxComputeWorkGroupInvocations - and ONLY this one; its two former companions @@ -135,6 +141,16 @@ namespace MobileGL::MG_Util::ShaderTranspiler { Uint64 frontendFingerprint = 0; // set by CaptureCompileEnv() Bool HasBackend() const { return backend != BackendType::Unknown; } + // Whether the backend this env was captured against can CONSUME a module that still + // declares 64-bit floats - the one thing that decides whether the transpile keeps + // `double` or narrows it (FlattenFloat64StorageBlockPass + DemoteFloat64Pass). + // + // The no-backend case answers FALSE, deliberately opposite to IsExtensionAdvertised's + // permissive fallback: an extension the frontend cannot gate against is best assumed + // present, but a hardware capability nothing has declared must be assumed absent. The + // demoted module is the one that works everywhere, so it is what a standalone compile + // (an internal shader object, a unit test) gets. + Bool ConsumesFloat64Natively() const { return HasBackend() && params.SupportsShaderFloat64; } // Matches the historical rule exactly: with no active backend every extension counts // as advertised, because the frontend then has nothing to gate against. Bool IsExtensionAdvertised(GLExtension extension) const { diff --git a/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.cpp b/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.cpp index 9d3e0096..c8355f24 100644 --- a/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.cpp +++ b/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.cpp @@ -701,6 +701,57 @@ namespace MobileGL { return false; } + namespace { + // The leaf-width test behind ModuleDeclaresFloat64VertexInput, and it is a LEAF + // test rather than a shape test on purpose: a `dmat4` input is an OpTypeMatrix of + // OpTypeVector of OpTypeFloat 64, and it is as unfetchable as a bare `double`. + Bool TypeHoldsFloat64(const spvtools::opt::analysis::Type* type) { + if (type == nullptr) return false; + if (const auto* scalar = type->AsFloat()) return scalar->width() == 64; + if (const auto* vector = type->AsVector()) return TypeHoldsFloat64(vector->element_type()); + if (const auto* matrix = type->AsMatrix()) return TypeHoldsFloat64(matrix->element_type()); + if (const auto* array = type->AsArray()) return TypeHoldsFloat64(array->element_type()); + return false; + } + } // namespace + + Bool ShaderCompiler::ModuleDeclaresFloat64VertexInput(const Vector& spirv) { + if (spirv.empty()) { + return false; + } + std::unique_ptr context = spvtools::BuildModule( + SPV_ENV_VULKAN_1_1, MakeSpirvMessageConsumer("ModuleDeclaresFloat64VertexInput"), + spirv.data(), spirv.size()); + if (!context) { + return false; + } + // Vertex only. Every other stage's inputs come from another stage's outputs, which + // MobileGL never re-formats, so a 64-bit varying between two stages is the driver's + // business and not this question's. + auto entryPoints = context->module()->entry_points(); + if (entryPoints.begin() == entryPoints.end()) return false; + const spvtools::opt::Instruction& entryPoint = *entryPoints.begin(); + if (static_cast(entryPoint.GetSingleWordInOperand(0)) != + spv::ExecutionModel::Vertex) { + return false; + } + auto* typeManager = context->get_type_mgr(); + auto* defUseManager = context->get_def_use_mgr(); + for (const spvtools::opt::Instruction& variable : context->module()->types_values()) { + if (variable.opcode() != spv::Op::OpVariable || variable.NumInOperands() < 1) continue; + if (static_cast(variable.GetSingleWordInOperand(0)) != + spv::StorageClass::Input) { + continue; + } + const spvtools::opt::Instruction* pointerType = defUseManager->GetDef(variable.type_id()); + if (pointerType == nullptr || pointerType->NumInOperands() < 2) continue; + if (TypeHoldsFloat64(typeManager->GetType(pointerType->GetSingleWordInOperand(1)))) { + return true; + } + } + return false; + } + Bool ShaderCompiler::ModuleReadsLocatedInput(const Vector& spirv) { if (spirv.empty()) { return false; @@ -751,7 +802,8 @@ namespace MobileGL { bool ShaderCompiler::SanitizeAndOptimizeBinary(const Vector& inputBinary, Vector& outputBinary, const bool validateOutput, - const bool enableSpirvValidation) { + const bool enableSpirvValidation, + const bool nativeFloat64) { using namespace spvtools; Optimizer optimizer(SPV_ENV_VULKAN_1_1); @@ -788,16 +840,25 @@ 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. + // The fp64 tail, and the ONE part of this chain that is not the same on every + // backend. Both passes are skipped when the backend can consume Float64 itself + // (`nativeFloat64`, i.e. VkPhysicalDeviceFeatures::shaderFloat64 on DirectVulkan): + // there is nothing to emulate then, and narrowing would only throw away precision + // the driver was willing to give. That is DirectVulkan-on-lavapipe today and + // nothing else - Adreno and Mali both report shaderFloat64 == VK_FALSE, and + // DirectGLES can never qualify because GLSL ES has no fp64 type for SPIRV-Cross to + // emit at all, so on every real mobile device this branch is not taken and the two + // passes run exactly as they always have. + // + // Demoting here - in the one chain every module goes through, at link - is what + // makes `double` compile at all where the hardware has none, and makes it behave + // the SAME across both backends of such a device, which matters because the GL + // frontend's uniform storage is per PROGRAM rather than per call: the glUniform*d + // shadow narrows to float 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. // ...but demoting a double that lives in a SHADER STORAGE BLOCK also repacks that // block, and the bytes an application put in the buffer do not move with it. This // runs first and takes those blocks out of the demotion's hands: each becomes a @@ -806,10 +867,16 @@ namespace MobileGL { // and only the VALUES narrow. Gated on a block actually holding a 64-bit float, so // every other module pays one types_values() walk and nothing else, and it declines // (leaving the block for the demotion to handle the old way) on any shape it cannot - // re-address exactly. See FlattenFloat64StorageBlockPass.h. - optimizer.RegisterPass( - FlattenFloat64StorageBlockPass::CreateFlattenFloat64StorageBlockPass()); - optimizer.RegisterPass(DemoteFloat64Pass::CreateDemoteFloat64Pass()); + // re-address exactly. See FlattenFloat64StorageBlockPass.h. It is skipped with the + // demotion rather than kept: its whole purpose is to preserve the byte layout ACROSS + // a narrowing that is no longer happening, and flattening a block a native driver + // would have laid out correctly by itself only costs the shader its index + // arithmetic. + if (!nativeFloat64) { + optimizer.RegisterPass( + FlattenFloat64StorageBlockPass::CreateFlattenFloat64StorageBlockPass()); + optimizer.RegisterPass(DemoteFloat64Pass::CreateDemoteFloat64Pass()); + } return RunOptimizerChecked("SanitizeAndOptimizeBinary", optimizer, inputBinary, outputBinary, validateOutput, enableSpirvValidation); diff --git a/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.h b/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.h index a7d7a63f..2b0d7506 100644 --- a/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.h +++ b/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.h @@ -23,10 +23,22 @@ namespace MobileGL { static Result> CompileShader(const ShaderAttrib& attrib); static Result> LinkProgram(const ProgramAttrib& attrib); static Result>> GetSpirvBinaryFromProgram(const ProgramBinaryAttrib& attrib); + // `nativeFloat64` is the caller's FINAL verdict, not a capability read: true means + // the two fp64 passes at the tail of the chain are skipped and real doubles reach + // the driver. False - which is DirectGLES always, every mobile device, and the + // no-backend default - runs the chain exactly as it always has. It is the ONE + // argument of this function that changes the output bytes, which is why it is + // also a field of the L1 memo's key. + // + // Production sets it in ProgramSpirvTask::GenerateSpirv, which takes the verdict + // for the WHOLE program (CompileEnv::ConsumesFloat64Natively() minus the + // 64-bit-vertex-input exception) before touching any module. Do not re-derive it + // per module: the global UBO is one buffer every stage reads. static bool SanitizeAndOptimizeBinary(const Vector& inputBinary, Vector& outputBinary, bool validateOutput = true, - bool enableSpirvValidation = false); + bool enableSpirvValidation = false, + bool nativeFloat64 = false); // Demotes DrawIndex/BaseInstance/BaseVertex builtins to plain Private globals // (mg_DrawID/mg_BaseInstance/mg_BaseVertex) so SPIRV-Cross can emit ESSL. // Only for backends without native draw-parameter support (DirectGLES). @@ -432,6 +444,18 @@ namespace MobileGL { // what the backends report: no mobile driver can build such a module. static Bool ModuleDeclaresFloat64(const Vector& spirv); + // True when the module is a VERTEX stage that declares a 64-bit float INPUT + // variable - `in double`, `in dvec2`, `in dmat3` and so on. + // + // Asked only on a backend with native fp64, and it is what keeps that backend's + // vertex path consistent. No backend here can FETCH 64 bits (VK_FORMAT_R64*_SFLOAT + // is optional and lavapipe advertises none of them), and the format is chosen from + // the VAO attribute, which does not know what the shader declared - so a module + // that keeps a Float64 input would be fed a narrowed float32 stream, or a packed + // uint pair with no matching format. Such a module is demoted WHOLE instead, which + // is exactly what every other backend does to it. + static Bool ModuleDeclaresFloat64VertexInput(const Vector& spirv); + // True when the module declares an Input variable carrying a Location - i.e. a // user-defined varying or a per-patch input, as opposed to a built-in. // diff --git a/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/DemoteFloat64Pass.h b/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/DemoteFloat64Pass.h index 6cff9599..912292bb 100644 --- a/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/DemoteFloat64Pass.h +++ b/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/DemoteFloat64Pass.h @@ -29,6 +29,16 @@ namespace MobileGL { // Espryt path never even reaches the driver. Demotion is what makes `double` in an // application's GLSL compile and run everywhere, at fp32 precision. // + // WHEN IT RUNS AT ALL. This pass is CAPABILITY-GATED at its one production caller, + // ShaderCompiler::SanitizeAndOptimizeBinary: a backend that can consume Float64 itself + // (DynamicBackendParameters::SupportsShaderFloat64, i.e. shaderFloat64 on DirectVulkan + // - lavapipe today and nothing else) skips it, and the module keeps its doubles. + // DirectGLES can never qualify, and neither can any real mobile device, so everything + // below still describes what happens there - which is everywhere that ships. The one + // exception that survives the capability: a VERTEX stage declaring a 64-bit float + // INPUT demotes the whole program regardless, because no backend here can FETCH 64 + // bits (see ProgramSpirvTask::GenerateSpirv). + // // 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 @@ -67,9 +77,11 @@ namespace MobileGL { // Index 0 of the same case PASSES by accident, for the same reason - writing 0.0f into // the low half of 1.0 leaves it unchanged - so a partial pass here is not progress. // Fixing it means carrying a double in the DEFAULT UNIFORM block without re-deriving - // its layout, and that block's routing is built by reflecting the module this pass - // produces, so the representation change ripples into every glUniform*d. Deliberately - // not attempted. compute_shader.fp64-case2 passes today and any attempt has to keep it + // its layout - which is precisely what the capability gate now does where the backend + // allows it: fp64-case1 PASSES on DirectVulkan/lavapipe (measured) and still fails on + // Espryt and on every device without shaderFloat64, where this pass runs. There is no + // fix for the demoted path itself; the value simply does not fit. + // compute_shader.fp64-case2 passes in both regimes and any attempt has to keep it // green. // // SHADER STORAGE BLOCKS ARE NO LONGER IN THAT LIST, and the two cases that used to be diff --git a/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/FlattenFloat64StorageBlockPass.h b/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/FlattenFloat64StorageBlockPass.h index 0962e719..c21dfd5f 100644 --- a/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/FlattenFloat64StorageBlockPass.h +++ b/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/FlattenFloat64StorageBlockPass.h @@ -18,7 +18,13 @@ namespace MobileGL { // Rewrites a SHADER STORAGE BLOCK that contains a 64-bit float into a flat // `uint` word array, and turns every access to it into address arithmetic over // that array. The application's byte layout survives exactly; the VALUES are - // still narrowed to 32-bit floats, because that is all any target here has. + // still narrowed to 32-bit floats, because that is all the target has. + // + // Registered ONLY on the demoting path, immediately before DemoteFloat64Pass, and + // capability-gated with it (ShaderCompiler::SanitizeAndOptimizeBinary). Where the + // backend consumes 64-bit floats itself there is no narrowing for this to preserve a + // layout across, and flattening a block the driver would have laid out correctly by + // itself would only cost the shader its index arithmetic. // // WHY THIS EXISTS. DemoteFloat64Pass rewrites `double` to `float` in place and // lets SPIRV-Cross re-derive the block's packing from the declared types, because diff --git a/MobileGL/MG_Util/ShaderTranspiler/TranslationCache.cpp b/MobileGL/MG_Util/ShaderTranspiler/TranslationCache.cpp index 53bfd30d..3f7f43ab 100644 --- a/MobileGL/MG_Util/ShaderTranspiler/TranslationCache.cpp +++ b/MobileGL/MG_Util/ShaderTranspiler/TranslationCache.cpp @@ -30,7 +30,11 @@ namespace MobileGL::MG_Util::ShaderTranspiler { // key (that map is an output of mapIO, not an input to it), and L1c's PAYLOAD gained // the explicit uniform locations - so a blob written under 3 describes a differently // shaped answer at both levels even where the bytes would have matched. - constexpr Uint32 kKeyLayoutVersion = 4u; + // 5: L1 gained nativeFloat64. SanitizeAndOptimizeBinary's fp64 tail is now capability- + // gated, so one L1 key shape can describe two materially different module sets (real + // doubles vs demoted-and-flattened) and a blob written under 4 says nothing about + // which one it holds. + constexpr Uint32 kKeyLayoutVersion = 5u; // The repo's existing cache epoch (MG_Config::CacheVersion, the seed // ProgramFactory::ComputeHash uses). Strictly redundant for an in-memory @@ -123,6 +127,7 @@ namespace MobileGL::MG_Util::ShaderTranspiler { builder.Value(inputs.frontendFingerprint); builder.Value(inputs.shaderCompileFlags); builder.Value(static_cast(inputs.enableSpirvValidation)); + builder.Value(static_cast(inputs.nativeFloat64)); builder.Value(static_cast(inputs.stages.size())); for (const auto& stage : inputs.stages) { builder.Value(static_cast(stage.type)); diff --git a/MobileGL/MG_Util/ShaderTranspiler/TranslationCache.h b/MobileGL/MG_Util/ShaderTranspiler/TranslationCache.h index cc17ee69..e3713945 100644 --- a/MobileGL/MG_Util/ShaderTranspiler/TranslationCache.h +++ b/MobileGL/MG_Util/ShaderTranspiler/TranslationCache.h @@ -341,22 +341,26 @@ namespace MobileGL::MG_Util::ShaderTranspiler { // // The cached artifact is the module AFTER SanitizeAndOptimizeBinary, not the // raw GlslangToSpv output. That is a deliberate choice and it is safe: - // SanitizeAndOptimizeBinary is a fixed 11-pass spirv-opt chain with no - // arguments but the module, and its two remaining parameters (`validateOutput`, - // `enableSpirvValidation`) only decide whether the OUTPUT is handed to the - // validator and logged - RunOptimizerChecked runs the optimizer first and - // identically either way. Nothing between GlslangToSpv and Sanitize reads - // backend state. So caching after Sanitize saves the 96 us/stage the chain - // costs on top of the 40 us GlslangToSpv, and gives the backends exactly the - // bytes they would have got. + // SanitizeAndOptimizeBinary is a fixed spirv-opt chain whose only + // output-changing argument is `nativeFloat64` (below), and whose two other + // parameters (`validateOutput`, `enableSpirvValidation`) only decide whether + // the OUTPUT is handed to the validator and logged - RunOptimizerChecked runs + // the optimizer first and identically either way. Nothing between GlslangToSpv + // and Sanitize reads backend state. So caching after Sanitize saves the 96 + // us/stage the chain costs on top of the 40 us GlslangToSpv, and gives the + // backends exactly the bytes they would have got. // - // L1 IS BACKEND-AGNOSTIC BY CONTRACT. Two contexts on different GPUs compiling - // the same GLSL share one L1 entry: nothing that merely steers a BACKEND - // transpile (backend identity, GLES/Vulkan capability bits, driver extension - // strings, GPU vendor) is allowed in this key - all of that lives in L2's key, - // where it belongs. What IS here is the subset of the environment that changes - // what glslang itself produces; see CompileEnv::frontendFingerprint for the - // field-by-field classification and the evidence behind each call. + // L1 IS BACKEND-AGNOSTIC BY CONTRACT, WITH EXACTLY ONE DECLARED EXCEPTION. + // Two contexts on different GPUs compiling the same GLSL share one L1 entry: + // nothing that merely steers a BACKEND transpile (backend identity, GLES/Vulkan + // capability bits, driver extension strings, GPU vendor) is allowed in this key + // - all of that lives in L2's key, where it belongs. What IS here is the subset + // of the environment that changes what glslang itself produces (see + // CompileEnv::frontendFingerprint for the field-by-field classification), PLUS + // `nativeFloat64`, the one capability bit that reaches INSIDE + // SanitizeAndOptimizeBinary and therefore changes the cached bytes themselves. + // A capability bit belongs in this key if and only if it does that; anything + // that only changes what a backend does with the finished module still does not. // // WHAT IS IN THE KEY (each one is an input that can change the modules): // * CompileEnv::frontendFingerprint - the glslang resource limits @@ -376,7 +380,16 @@ namespace MobileGL::MG_Util::ShaderTranspiler { // * the ShaderCompileBits the parse ran under (always 0 in production; in // the key so a future non-zero value cannot alias); // * the SPIR-V validation switch (byte-identical output either way, but it - // costs one byte to be sure). + // costs one byte to be sure); + // * nativeFloat64 - CompileEnv::ConsumesFloat64Natively(). The fp64 tail of + // SanitizeAndOptimizeBinary (FlattenFloat64StorageBlockPass + + // DemoteFloat64Pass) is skipped when the backend can build a pipeline from + // a module that still declares OpCapability Float64, so the SAME GLSL + // produces MATERIALLY DIFFERENT modules under the two answers - one with + // real doubles, one narrowed to 32 bits with its storage blocks flattened. + // Not folded into frontendFingerprint on purpose: glslang produces the same + // thing either way, so it is not a front-end input, and L1c shares that + // fingerprint and would take a false miss per backend for nothing. // // The key is a PROGRAM-level key, not a per-stage one, and that is forced: // glslang's mapIO resolves a fragment stage's input Locations against the @@ -398,6 +411,9 @@ namespace MobileGL::MG_Util::ShaderTranspiler { const UnorderedMap* explicitFragmentOutIndices = nullptr; Uint32 shaderCompileFlags = 0; Bool enableSpirvValidation = false; + // CompileEnv::ConsumesFloat64Natively() - the fp64 tail of the sanitize chain. The + // one backend capability bit in this key; see the note above for why it has to be. + Bool nativeFloat64 = false; // ---- inputs that only matter because the PAYLOAD now carries the reflection ---- // When the payload was SPIR-V alone these were provably irrelevant: transform // feedback is resolved by READING the linked intermediates and never writes an XFB