From a7bfef8f5997ccb3d08d44d1601d3e9512e4c99a Mon Sep 17 00:00:00 2001 From: thibaut Date: Mon, 26 Oct 2026 04:01:59 +0100 Subject: [PATCH 1/5] perf(shader): keep native f16 conversion NaN results when a startup probe matches RDNA Native OpFConvert and UnpackHalf2x16 round and handle denormals as RDNA does under RoundingModeRTE and DenormPreserve 16, but Vulkan leaves the NaN they return to the driver, so every conversion rebuilds RDNA's NaN with integer selects (about 55 words per f32 to f16 conversion, 29 per f16 to f32). ProbeFloat16Conversions runs when the device is created: v_cvt_f32_f16 over all 65,536 halves and v_cvt_f16_f32, v_cvt_pkrtz_f16_f32 and a round trip over every NaN payload class, signalling NaNs, denormals, ties and overflow, under four float modes, each compiled with the rebuilt NaN and with the native result, inputs read from a buffer. SpirvTarget::float16WidenNans and float16NarrowNans are set per direction only when every result matches; both are in the recompile cache key. APS5_NO_NATIVE_F16_NANS skips the probe. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PHJtpJ4hw3KWJhobipK8ia --- core/libs/prx/libSceAgcDriver/CMakeLists.txt | 1 + .../include/Float16ConversionProbe.hpp | 38 +++ .../Execution/src/Float16ConversionProbe.cpp | 135 +++++++++ .../Execution/src/VulkanDevice.cpp | 24 ++ ...river_float16_conversion_probe_tests.cmake | 7 + .../execution/Float16ConversionProbe.cpp | 266 ++++++++++++++++++ core/shader/recompiler/CacheKey.hpp | 2 + core/shader/recompiler/Recompiler.cpp | 2 + core/shader/recompiler/Recompiler.hpp | 2 + .../include/SpirvBackend/SpirvEmitter.hpp | 2 + .../SpirvBackend/SpirvEmitterState.hpp | 2 + .../src/SpirvAlu/SpirvAluEmitterMath.cpp | 1 + .../SpirvBackend/src/SpirvEmitter.cpp | 2 + .../SpirvBackend/src/SpirvModuleEmitter.cpp | 2 +- docs/dev/ARCHITECTURE.md | 4 + docs/dev/BUILD.md | 2 +- docs/dev/ENVIRONMENT.md | 1 + 17 files changed, 491 insertions(+), 2 deletions(-) create mode 100644 core/libs/prx/libSceAgcDriver/Execution/include/Float16ConversionProbe.hpp create mode 100644 core/libs/prx/libSceAgcDriver/Execution/src/Float16ConversionProbe.cpp create mode 100644 core/libs/prx/libSceAgcDriver/tests/cmake/agc_driver_float16_conversion_probe_tests.cmake create mode 100644 core/libs/prx/libSceAgcDriver/tests/execution/Float16ConversionProbe.cpp diff --git a/core/libs/prx/libSceAgcDriver/CMakeLists.txt b/core/libs/prx/libSceAgcDriver/CMakeLists.txt index 2e3005278207..409957ffc30a 100644 --- a/core/libs/prx/libSceAgcDriver/CMakeLists.txt +++ b/core/libs/prx/libSceAgcDriver/CMakeLists.txt @@ -413,6 +413,7 @@ set(agcDriverSources ${CMAKE_CURRENT_SOURCE_DIR}/Execution/src/VulkanDevice.cpp ${CMAKE_CURRENT_SOURCE_DIR}/Execution/src/FloatFlushProbe.cpp ${CMAKE_CURRENT_SOURCE_DIR}/Execution/src/FloatProbeTolerance.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/Execution/src/Float16ConversionProbe.cpp ${CMAKE_CURRENT_SOURCE_DIR}/Execution/src/AspectFit.cpp ${CMAKE_CURRENT_SOURCE_DIR}/Execution/src/PresentationScaler.cpp ${CMAKE_CURRENT_SOURCE_DIR}/Execution/src/PresentationPass.cpp diff --git a/core/libs/prx/libSceAgcDriver/Execution/include/Float16ConversionProbe.hpp b/core/libs/prx/libSceAgcDriver/Execution/include/Float16ConversionProbe.hpp new file mode 100644 index 000000000000..7b63af8e5269 --- /dev/null +++ b/core/libs/prx/libSceAgcDriver/Execution/include/Float16ConversionProbe.hpp @@ -0,0 +1,38 @@ +#ifndef CORE_LIBS_PRX_LIBSCEAGCDRIVER_EXECUTION_INCLUDE_FLOAT16CONVERSIONPROBE_HPP +#define CORE_LIBS_PRX_LIBSCEAGCDRIVER_EXECUTION_INCLUDE_FLOAT16CONVERSIONPROBE_HPP + +#include "Recompiler.hpp" + +#include +#include +#include + +namespace AgcDriver { + +class VulkanDevice; + +struct Float16ConversionMismatch { + std::uint32_t input = 0; + std::uint32_t emulated = 0; + std::uint32_t native = 0; +}; + +struct Float16ConversionProbeDirection { + bool probed = false; + std::uint32_t mismatches = 0; + std::uint32_t compared = 0; + std::optional first; +}; + +struct Float16ConversionProbeResult { + Float16ConversionProbeDirection widen; + Float16ConversionProbeDirection narrow; +}; + +std::vector Float16NarrowProbeInputs(); + +Float16ConversionProbeResult ProbeFloat16Conversions(VulkanDevice& device, const ShaderRecompiler::SpirvTarget& target); + +} + +#endif diff --git a/core/libs/prx/libSceAgcDriver/Execution/src/Float16ConversionProbe.cpp b/core/libs/prx/libSceAgcDriver/Execution/src/Float16ConversionProbe.cpp new file mode 100644 index 000000000000..fa1cf2a0bc24 --- /dev/null +++ b/core/libs/prx/libSceAgcDriver/Execution/src/Float16ConversionProbe.cpp @@ -0,0 +1,135 @@ +#include "prx/libSceAgcDriver/Execution/include/Float16ConversionProbe.hpp" +#include "prx/libSceAgcDriver/Execution/include/VulkanDevice.hpp" + +#include + +#include +#include +#include +#include + +namespace AgcDriver { +namespace { + +using ShaderRecompiler::ShaderStage; + +constexpr std::uint32_t Threads = 32; +constexpr std::uint32_t Halves = 1u << 16u; + +alignas(256) constexpr std::array WidenCode{ + 0x34020082u, 0x8f098708u, 0x4a020209u, 0xe0301000u, 0x80000401u, 0xbf8c3f70u, 0x7e0a1704u, 0xe0701000u, + 0x80010501u, 0xbf810000u, +}; + +alignas(256) constexpr std::array NarrowCode{ + 0x34020082u, 0x8f098708u, 0x4a020209u, 0xe0301000u, 0x80000401u, 0xbf8c3f70u, 0x7e0a1504u, 0xe0701000u, + 0x80010501u, 0xbf810000u, +}; + +alignas(256) constexpr std::array RoundTripCode{ + 0x34020082u, 0x8f098708u, 0x4a020209u, 0xe0301000u, 0x80000401u, 0xbf8c3f70u, 0x7e0a1504u, 0x7e0c1705u, + 0xe0701000u, 0x80010601u, 0xbf810000u, +}; + +alignas(256) constexpr std::array NarrowTowardZeroCode{ + 0x34020082u, 0x8f098708u, 0x4a020209u, 0xe0301000u, 0x80000401u, 0xbf8c3f70u, 0x5e0a0904u, 0xe0701000u, + 0x80010501u, 0xbf810000u, +}; + +std::array BufferDescriptor(const void* data, std::uint32_t bytes) { + const auto address = reinterpret_cast(data); + return {static_cast(address), static_cast((address >> 32u) & 0xffffu), bytes, 0x31016facu}; +} + +bool Supported(const ShaderRecompiler::SpirvTarget& target, spv::Capability capability) { + return std::find(target.supportedCapabilities.begin(), target.supportedCapabilities.end(), static_cast(capability)) != target.supportedCapabilities.end(); +} + +} + +std::vector Float16NarrowProbeInputs() { + std::vector inputs; + constexpr std::array nanLow{0x0000u, 0x0001u, 0x1000u, 0x1fffu}; + constexpr std::array mantissas{0x000000u, 0x000001u, 0x000fffu, 0x001000u, 0x001001u, 0x001fffu, 0x002000u, 0x003000u, 0x400000u, 0x7fe000u, 0x7ff000u, 0x7fefffu, 0x7ff001u, 0x7fffffu, 0x155555u, 0x2aaaaau}; + for (const std::uint32_t sign : {0u, 0x80000000u}) { + for (std::uint32_t top = 0; top < 1024u; ++top) { + for (const std::uint32_t low : nanLow) { + if (top != 0u || low != 0u) inputs.push_back(sign | 0x7f800000u | (top << 13u) | low); + } + } + for (std::uint32_t exponent = 0; exponent < 256u; ++exponent) { + for (const std::uint32_t mantissa : mantissas) inputs.push_back(sign | (exponent << 23u) | mantissa); + } + for (std::uint32_t exponent = 100u; exponent < 114u; ++exponent) { + for (std::uint32_t bit = 0; bit < 23u; ++bit) { + for (const std::uint32_t mantissa : {1u << bit, (3u << bit) & 0x7fffffu, (1u << bit) - 1u}) inputs.push_back(sign | (exponent << 23u) | mantissa); + } + } + } + inputs.resize((inputs.size() + Threads - 1u) / Threads * Threads, 0u); + return inputs; +} + +namespace { + +std::vector Run(VulkanDevice& device, ShaderRecompiler::SpirvTarget target, bool widen, bool narrow, const std::optional& floatMode, std::span code, std::span input) { + std::vector output(input.size(), 0xdeadbeefu); + std::vector userData(8, 0u); + const auto inputDescriptor = BufferDescriptor(input.data(), static_cast(input.size_bytes())); + const auto outputDescriptor = BufferDescriptor(output.data(), static_cast(output.size() * 4u)); + std::copy(inputDescriptor.begin(), inputDescriptor.end(), userData.begin()); + std::copy(outputDescriptor.begin(), outputDescriptor.end(), userData.begin() + 4); + const std::array memory{{{reinterpret_cast(code.data()), std::as_bytes(code)}}}; + const ShaderRecompiler::ShaderComputeStageInfo compute{{Threads, 1, 1}, 0u, {true, false, false}, false, 1}; + target.float16WidenNans = widen; + target.float16NarrowNans = narrow; + ShaderRecompiler::RecompileRequest request{ + {ShaderStage::Compute, reinterpret_cast(code.data()), code, 0, {}}, + {32, 0, userData, compute, std::nullopt, std::nullopt, memory}, + target, + {0, 0, 0, 128} + }; + request.useCache = false; + request.context.floatMode = floatMode; + const auto result = ShaderRecompiler::Recompile(request); + device.Dispatch(result, static_cast(input.size() / Threads), 1, 1, {}, reinterpret_cast(code.data())); + device.WaitIdle(); + return output; +} + +void Compare(Float16ConversionProbeDirection& direction, VulkanDevice& device, const ShaderRecompiler::SpirvTarget& target, bool widen, bool narrow, std::span code, std::span input) { + const std::array, 4> modes{std::nullopt, ShaderRecompiler::ShaderFloatMode{0xf0u, true, false, false}, ShaderRecompiler::ShaderFloatMode{0x00u, false, true, false}, ShaderRecompiler::ShaderFloatMode{0xf4u, true, false, true}}; + for (const auto& mode : modes) { + const auto emulated = Run(device, target, false, false, mode, code, input); + const auto native = Run(device, target, widen, narrow, mode, code, input); + for (std::size_t lane = 0; lane < input.size(); ++lane) { + ++direction.compared; + if (emulated[lane] == native[lane]) continue; + ++direction.mismatches; + if (!direction.first) direction.first = Float16ConversionMismatch{input[lane], emulated[lane], native[lane]}; + } + } +} + +} + +Float16ConversionProbeResult ProbeFloat16Conversions(VulkanDevice& device, const ShaderRecompiler::SpirvTarget& target) { + Float16ConversionProbeResult result; + if (target.float16DenormPreserve) { + result.widen.probed = true; + std::vector halves(Halves); + for (std::uint32_t lane = 0; lane < Halves; ++lane) halves[lane] = lane | ((lane ^ 0xa5a5u) << 16u); + Compare(result.widen, device, target, true, false, WidenCode, halves); + } + if (Supported(target, spv::CapabilityFloat16) && Supported(target, spv::CapabilityRoundingModeRTE) && Supported(target, spv::CapabilityDenormPreserve)) { + result.narrow.probed = true; + const bool widen = result.widen.probed && result.widen.mismatches == 0u; + const auto singles = Float16NarrowProbeInputs(); + Compare(result.narrow, device, target, widen, true, NarrowCode, singles); + Compare(result.narrow, device, target, widen, true, NarrowTowardZeroCode, singles); + Compare(result.narrow, device, target, widen, true, RoundTripCode, singles); + } + return result; +} + +} diff --git a/core/libs/prx/libSceAgcDriver/Execution/src/VulkanDevice.cpp b/core/libs/prx/libSceAgcDriver/Execution/src/VulkanDevice.cpp index f93841574434..d9e9d679b07f 100644 --- a/core/libs/prx/libSceAgcDriver/Execution/src/VulkanDevice.cpp +++ b/core/libs/prx/libSceAgcDriver/Execution/src/VulkanDevice.cpp @@ -12,6 +12,7 @@ #include "prx/libSceAgcDriver/Graphics/include/DeferredDestruction.hpp" #include "prx/libSceAgcDriver/Execution/include/VulkanDevice.hpp" #include "prx/libSceAgcDriver/Graphics/include/MemoryAccounting.hpp" +#include "prx/libSceAgcDriver/Execution/include/Float16ConversionProbe.hpp" #include "prx/libSceAgcDriver/Execution/include/FloatFlushProbe.hpp" #include "prx/libSceAgcDriver/Execution/include/Driver.hpp" #include "prx/libSceAgcDriver/Execution/include/Driver/Shaders/ShaderRegistry.hpp" @@ -484,6 +485,8 @@ struct VulkanDevice::State { bool float16DenormPreserve = false; bool float32FlushToZeroCandidate = false; bool float32FlushToZero = false; + bool float16WidenNans = false; + bool float16NarrowNans = false; bool float32DenormPreserve = false; bool probingFloatControls = false; bool demoteToHelperInvocation = false; @@ -2517,6 +2520,25 @@ VulkanDevice::VulkanDevice(const PresentationWindow* window, bool compileOnly) : state->float32DenormPreserve = probe.mismatches == 0u; std::fprintf(stderr, "[float-controls] f32 denormal preserve: %s (%u of %u probe results on normal values differ from the default mode by more than the Vulkan precision bounds, %.0f ms)\n", state->float32DenormPreserve ? "requested" : "not requested", probe.mismatches, probe.compared, std::chrono::duration(std::chrono::steady_clock::now() - probeStart).count()); } + if (std::getenv("APS5_NO_NATIVE_F16_NANS") == nullptr) { + const auto probeStart = std::chrono::steady_clock::now(); + state->probingFloatControls = true; + const auto probe = ProbeFloat16Conversions(*this, Target()); + state->probingFloatControls = false; + state->float16WidenNans = probe.widen.probed && probe.widen.mismatches == 0u; + state->float16NarrowNans = probe.narrow.probed && probe.narrow.mismatches == 0u; + const auto describe = [](const Float16ConversionProbeDirection& direction, bool native) { + if (!direction.probed) return std::string("emulated (no native conversion)"); + std::string text = std::string(native ? "native" : "emulated") + " (" + std::to_string(direction.mismatches) + " of " + std::to_string(direction.compared) + " probe results differ"; + if (direction.first) { + char first[96]; + std::snprintf(first, sizeof(first), ", first 0x%08x: 0x%08x emulated, 0x%08x native", direction.first->input, direction.first->emulated, direction.first->native); + text += first; + } + return text + ")"; + }; + std::fprintf(stderr, "[float-controls] f16 to f32 NaN results: %s; f32 to f16 NaN results: %s (%.0f ms)\n", describe(probe.widen, state->float16WidenNans).c_str(), describe(probe.narrow, state->float16NarrowNans).c_str(), std::chrono::duration(std::chrono::steady_clock::now() - probeStart).count()); + } // A device without a window is usually replaced by the window's device before the title draws // (Driver::AttachWindow): its replay starts at its first draw or dispatch instead, so the // journal is replayed once, on the device the title draws with. @@ -4344,6 +4366,8 @@ ShaderRecompiler::SpirvTarget VulkanDevice::Target() const { target.fusedExactArithmetic = state->fusedExactArithmetic; target.hostInterpolationFirst = state->hostInterpolationFirst; target.float32DenormPreserve = state->float32DenormPreserve; + target.float16WidenNans = state->float16WidenNans; + target.float16NarrowNans = state->float16NarrowNans; return target; } diff --git a/core/libs/prx/libSceAgcDriver/tests/cmake/agc_driver_float16_conversion_probe_tests.cmake b/core/libs/prx/libSceAgcDriver/tests/cmake/agc_driver_float16_conversion_probe_tests.cmake new file mode 100644 index 000000000000..9a523cff684e --- /dev/null +++ b/core/libs/prx/libSceAgcDriver/tests/cmake/agc_driver_float16_conversion_probe_tests.cmake @@ -0,0 +1,7 @@ +if(BUILD_TESTING) + add_test(NAME agc_driver_float16_conversion_probe_spirv COMMAND agc_driver_float16_conversion_probe_tests --verify-spirv) + set_tests_properties(agc_driver_float16_conversion_probe_spirv PROPERTIES ENVIRONMENT "ANYPS5_NO_SHADER_CACHE=1" SKIP_RETURN_CODE 77 WORKING_DIRECTORY "${CMAKE_SOURCE_DIR}") + if(WIN32) + set_tests_properties(agc_driver_float16_conversion_probe_spirv PROPERTIES ENVIRONMENT_MODIFICATION "PATH=path_list_prepend:$") + endif() +endif() diff --git a/core/libs/prx/libSceAgcDriver/tests/execution/Float16ConversionProbe.cpp b/core/libs/prx/libSceAgcDriver/tests/execution/Float16ConversionProbe.cpp new file mode 100644 index 000000000000..a629b8549f7b --- /dev/null +++ b/core/libs/prx/libSceAgcDriver/tests/execution/Float16ConversionProbe.cpp @@ -0,0 +1,266 @@ +#include "prx/libSceAgcDriver/Execution/include/Float16ConversionProbe.hpp" +#include "prx/libSceAgcDriver/Execution/include/VulkanDevice.hpp" +#include "prx/libSceAgcDriver/Graphics/include/Draw.hpp" +#include "Recompiler.hpp" +#include "VulkanTestDevice.hpp" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +using AgcDriver::Graphics::Require; +using ShaderRecompiler::ShaderStage; + +constexpr std::uint32_t Threads = 32; +constexpr std::uint32_t Halves = 1u << 16u; +constexpr std::uint32_t RandomSingles = 1u << 18u; + +alignas(256) constexpr std::array WidenCode{ + 0x34020082u, 0x8f098708u, 0x4a020209u, 0xe0301000u, 0x80000401u, 0xbf8c3f70u, 0x7e0a1704u, 0xe0701000u, + 0x80010501u, 0xbf810000u, +}; + +alignas(256) constexpr std::array NarrowCode{ + 0x34020082u, 0x8f098708u, 0x4a020209u, 0xe0301000u, 0x80000401u, 0xbf8c3f70u, 0x7e0a1504u, 0xe0701000u, + 0x80010501u, 0xbf810000u, +}; + +alignas(256) constexpr std::array RoundTripCode{ + 0x34020082u, 0x8f098708u, 0x4a020209u, 0xe0301000u, 0x80000401u, 0xbf8c3f70u, 0x7e0a1504u, 0x7e0c1705u, + 0xe0701000u, 0x80010601u, 0xbf810000u, +}; + +alignas(256) constexpr std::array NarrowTowardZeroCode{ + 0x34020082u, 0x8f098708u, 0x4a020209u, 0xe0301000u, 0x80000401u, 0xbf8c3f70u, 0x5e0a0904u, 0xe0701000u, + 0x80010501u, 0xbf810000u, +}; + +std::uint32_t WidenBits(std::uint32_t half) { + const std::uint32_t sign = (half & 0x8000u) << 16u; + const std::uint32_t exponent = (half >> 10u) & 0x1fu; + const std::uint32_t mantissa = half & 0x3ffu; + if (exponent == 0x1fu) return sign | 0x7f800000u | (mantissa << 13u); + if (exponent != 0u) return sign | ((exponent + 112u) << 23u) | (mantissa << 13u); + return sign | std::bit_cast(static_cast(mantissa) * 0x1p-24f); +} + +std::uint32_t NarrowBits(std::uint32_t single) { + const std::uint32_t sign = (single >> 16u) & 0x8000u; + const std::uint32_t magnitude = single & 0x7fffffffu; + if (magnitude > 0x7f800000u) return sign | 0x7e00u | ((magnitude & 0x7fffffu) >> 13u); + if (magnitude >= 0x477ff000u) return sign | 0x7c00u; + if (magnitude < 0x38800000u) return sign | static_cast(std::nearbyint(static_cast(std::bit_cast(magnitude)) * 0x1p24)); + std::uint32_t bits = (magnitude >> 13u) - (112u << 10u); + const std::uint32_t remainder = magnitude & 0x1fffu; + if (remainder > 0x1000u || (remainder == 0x1000u && (bits & 1u) != 0u)) ++bits; + return sign | bits; +} + +bool IsNan32(std::uint32_t bits) { + return (bits & 0x7fffffffu) > 0x7f800000u; +} + +bool IsNan16(std::uint32_t bits) { + return (bits & 0x7fffu) > 0x7c00u; +} + +std::string Hex(std::uint32_t value) { + char text[16]; + std::snprintf(text, sizeof(text), "0x%08x", value); + return text; +} + +std::array BufferDescriptor(const void* data, std::uint32_t bytes) { + const auto address = reinterpret_cast(data); + return {static_cast(address), static_cast((address >> 32u) & 0xffffu), bytes, 0x31016facu}; +} + +ShaderRecompiler::RecompileRequest Request(std::span code, const ShaderRecompiler::SpirvTarget& target, std::span userData, std::span memory) { + const ShaderRecompiler::ShaderComputeStageInfo compute{{Threads, 1, 1}, 0u, {true, false, false}, false, 1}; + ShaderRecompiler::RecompileRequest request{ + {ShaderStage::Compute, reinterpret_cast(code.data()), code, 0, {}}, + {32, 0, userData, compute, std::nullopt, std::nullopt, memory}, + target, + {0, 0, 0, 128} + }; + request.useCache = false; + request.context.floatMode = ShaderRecompiler::ShaderFloatMode{0xf0u, true, false, false}; + return request; +} + +std::vector Run(AgcDriver::VulkanDevice& device, ShaderRecompiler::SpirvTarget target, bool widen, bool narrow, std::span code, std::span input) { + std::vector output(input.size(), 0xdeadbeefu); + std::vector userData(8, 0u); + const auto inputDescriptor = BufferDescriptor(input.data(), static_cast(input.size_bytes())); + const auto outputDescriptor = BufferDescriptor(output.data(), static_cast(output.size() * 4u)); + std::copy(inputDescriptor.begin(), inputDescriptor.end(), userData.begin()); + std::copy(outputDescriptor.begin(), outputDescriptor.end(), userData.begin() + 4); + const std::array memory{{{reinterpret_cast(code.data()), std::as_bytes(code)}}}; + target.float16WidenNans = widen; + target.float16NarrowNans = narrow; + const auto result = ShaderRecompiler::Recompile(Request(code, target, userData, memory)); + device.Dispatch(result, static_cast(input.size() / Threads), 1, 1, {}, reinterpret_cast(code.data())); + device.WaitIdle(); + return output; +} + +struct Spirv { + std::uint32_t selects = 0; + std::uint32_t floatConverts = 0; +}; + +Spirv Scan(std::span words) { + Spirv scan; + for (std::size_t cursor = 5; cursor < words.size();) { + const auto count = words[cursor] >> 16u; + const auto opcode = words[cursor] & 0xffffu; + Require(count != 0 && cursor + count <= words.size(), "f16 conversion probe: malformed SPIR-V"); + if (opcode == spv::OpSelect) ++scan.selects; + if (opcode == spv::OpFConvert) ++scan.floatConverts; + cursor += count; + } + return scan; +} + +void VerifySpirv() { + constexpr std::array capabilities{ + spv::CapabilityShader, spv::CapabilityGroupNonUniform, spv::CapabilityGroupNonUniformBallot, + spv::CapabilityInt64, spv::CapabilityPhysicalStorageBufferAddresses, spv::CapabilityStorageBuffer8BitAccess, + spv::CapabilityDenormPreserve, spv::CapabilitySignedZeroInfNanPreserve, spv::CapabilityFloat16, + spv::CapabilityRoundingModeRTE, spv::CapabilityInt16, + }; + constexpr std::array extensions{ + "SPV_KHR_physical_storage_buffer", "SPV_KHR_8bit_storage", "SPV_KHR_float_controls", + "SPV_KHR_storage_buffer_storage_class", "SPV_KHR_16bit_storage", + }; + std::vector input(Threads), output(Threads); + std::vector userData(8, 0u); + const auto inputDescriptor = BufferDescriptor(input.data(), static_cast(input.size() * 4u)); + const auto outputDescriptor = BufferDescriptor(output.data(), static_cast(output.size() * 4u)); + std::copy(inputDescriptor.begin(), inputDescriptor.end(), userData.begin()); + std::copy(outputDescriptor.begin(), outputDescriptor.end(), userData.begin() + 4); + ShaderRecompiler::SpirvTarget target{}; + target.vulkanVersion = 0x00403000u; + target.spirvVersion = 0x00010600u; + target.subgroupSize = 32; + target.bdaAbiVersion = 1; + target.supportedCapabilities = capabilities; + target.supportedExtensions = extensions; + target.maxWorkgroupSize = {1024, 1024, 64}; + target.maxWorkgroupInvocations = 1024; + target.maxWorkgroupSharedMemoryBytes = 49152; + target.float16DenormPreserve = true; + const auto scan = [&](std::span code, bool native) { + const std::array memory{{{reinterpret_cast(code.data()), std::as_bytes(code)}}}; + target.float16WidenNans = native; + target.float16NarrowNans = native; + const auto words = ShaderRecompiler::Recompile(Request(code, target, userData, memory)).spirv.Copy(); + return Scan(words); + }; + const auto widenEmulated = scan(WidenCode, false); + const auto widenNative = scan(WidenCode, true); + Require(widenNative.selects + 1u == widenEmulated.selects, "f16 conversion probe: v_cvt_f32_f16 with float16WidenNans has " + std::to_string(widenNative.selects) + " selects, without " + std::to_string(widenEmulated.selects) + ", expected one fewer"); + const auto narrowEmulated = scan(NarrowCode, false); + const auto narrowNative = scan(NarrowCode, true); + Require(narrowEmulated.floatConverts == 1u && narrowNative.floatConverts == 1u, "f16 conversion probe: v_cvt_f16_f32 does not use one OpFConvert"); + Require(narrowNative.selects + 1u == narrowEmulated.selects, "f16 conversion probe: v_cvt_f16_f32 with float16NarrowNans has " + std::to_string(narrowNative.selects) + " selects, without " + std::to_string(narrowEmulated.selects) + ", expected one fewer"); +} + +struct Tally { + std::uint32_t compared = 0; + std::uint32_t quietNan = 0; + std::uint32_t signallingNan = 0; + std::uint32_t other = 0; + std::optional> first; + + std::uint32_t Mismatches() const { return quietNan + signallingNan + other; } + + void Add(std::uint32_t input, bool nan, bool quiet, std::uint32_t expected, std::uint32_t actual) { + ++compared; + if (expected == actual) return; + if (nan && quiet) ++quietNan; + else if (nan) ++signallingNan; + else ++other; + if (!first) first = std::array{input, expected, actual}; + } + + std::string Describe() const { + std::string text = std::to_string(Mismatches()) + " of " + std::to_string(compared) + " differ (quiet NaN " + std::to_string(quietNan) + ", signalling NaN " + std::to_string(signallingNan) + ", not NaN " + std::to_string(other) + ")"; + if (first) text += ", first " + Hex((*first)[0]) + ": emulated " + Hex((*first)[1]) + ", native " + Hex((*first)[2]); + return text; + } +}; + +void Widen(AgcDriver::VulkanDevice& device, const ShaderRecompiler::SpirvTarget& target) { + std::vector input(Halves); + for (std::uint32_t lane = 0; lane < Halves; ++lane) input[lane] = lane | ((lane ^ 0xa5a5u) << 16u); + const auto emulated = Run(device, target, false, false, WidenCode, input); + const auto native = Run(device, target, true, false, WidenCode, input); + Tally tally; + for (std::uint32_t lane = 0; lane < Halves; ++lane) { + Require(emulated[lane] == WidenBits(lane), "f16 conversion probe: emulated v_cvt_f32_f16 of " + Hex(lane) + " is " + Hex(emulated[lane]) + ", expected " + Hex(WidenBits(lane))); + tally.Add(lane, IsNan16(lane), (lane & 0x200u) != 0u, emulated[lane], native[lane]); + } + std::printf("v_cvt_f32_f16 native vs emulated: %s; the device %s the native result\n", tally.Describe().c_str(), target.float16WidenNans ? "uses" : "does not use"); + Require(!target.float16WidenNans || tally.Mismatches() == 0u, "f16 conversion probe: the device uses native f16 to f32 NaN results that differ from RDNA's"); +} + +void Narrow(AgcDriver::VulkanDevice& device, const ShaderRecompiler::SpirvTarget& target) { + auto input = AgcDriver::Float16NarrowProbeInputs(); + std::uint32_t state = 0x9e3779b9u; + for (std::uint32_t index = 0; index < RandomSingles; ++index) { + state = state * 1664525u + 1013904223u; + input.push_back(state); + } + for (const auto& [code, name] : {std::pair{std::span(NarrowCode), "v_cvt_f16_f32"}, std::pair{std::span(NarrowTowardZeroCode), "v_cvt_pkrtz_f16_f32"}, std::pair{std::span(RoundTripCode), "v_cvt_f16_f32 then v_cvt_f32_f16"}}) { + const auto emulated = Run(device, target, false, false, code, input); + const auto native = Run(device, target, target.float16WidenNans, true, code, input); + Tally tally; + for (std::size_t lane = 0; lane < input.size(); ++lane) { + if (code.data() == NarrowCode.data()) { + Require((emulated[lane] & 0xffffu) == NarrowBits(input[lane]), "f16 conversion probe: emulated v_cvt_f16_f32 of " + Hex(input[lane]) + " is " + Hex(emulated[lane]) + ", expected " + Hex(NarrowBits(input[lane]))); + } + if (code.data() == RoundTripCode.data()) { + Require(emulated[lane] == WidenBits(NarrowBits(input[lane])), "f16 conversion probe: emulated round trip of " + Hex(input[lane]) + " is " + Hex(emulated[lane]) + ", expected " + Hex(WidenBits(NarrowBits(input[lane])))); + } + tally.Add(input[lane], IsNan32(input[lane]), (input[lane] & 0x400000u) != 0u, emulated[lane], native[lane]); + } + std::printf("%s native vs emulated: %s; the device %s the native result\n", name, tally.Describe().c_str(), target.float16NarrowNans ? "uses" : "does not use"); + Require(!target.float16NarrowNans || tally.Mismatches() == 0u, std::string("f16 conversion probe: the device uses native ") + name + " results that differ from RDNA's"); + } +} + +} + +int main(int argc, char** argv) { + try { + if (argc == 2 && std::string_view(argv[1]) == "--verify-spirv") { + VerifySpirv(); + std::puts("f16 conversions drop their NaN repairs only when the target says the native conversion keeps RDNA's NaN bits"); + return 0; + } + const auto device = OpenVulkanTestDevice(); + if (!device) return VulkanTestSkipped; + const auto target = device->Target(); + const bool widen = target.float16DenormPreserve; + const bool narrow = TargetHasCapability(target, spv::CapabilityFloat16) && TargetHasCapability(target, spv::CapabilityRoundingModeRTE) && TargetHasCapability(target, spv::CapabilityDenormPreserve); + if (widen) Widen(*device, target); + if (narrow) Narrow(*device, target); + std::printf("f16 conversion probe tests passed (%s, %s)\n", widen ? "f16 to f32 compared" : "no native f16 to f32", narrow ? "f32 to f16 compared" : "no native f32 to f16"); + return 0; + } catch (const std::exception& error) { + std::cerr << error.what() << '\n'; + return 1; + } +} diff --git a/core/shader/recompiler/CacheKey.hpp b/core/shader/recompiler/CacheKey.hpp index 116703feeccd..2901cae7ce0c 100644 --- a/core/shader/recompiler/CacheKey.hpp +++ b/core/shader/recompiler/CacheKey.hpp @@ -379,6 +379,8 @@ class RecompileCacheKey { append(key, value.meshWindowBuffer); append(key, value.hostInterpolationFirst); append(key, value.shaderImageMinLod); + append(key, value.float16WidenNans); + append(key, value.float16NarrowNans); } }; diff --git a/core/shader/recompiler/Recompiler.cpp b/core/shader/recompiler/Recompiler.cpp index 737751d6140b..04ac0c70533f 100644 --- a/core/shader/recompiler/Recompiler.cpp +++ b/core/shader/recompiler/Recompiler.cpp @@ -690,6 +690,8 @@ CompiledVariant emitVariant(const RecompileRequest& request, IrProgram& program) targetOptions.deviceScopeLdsMemoryBarriers = request.target.deviceScopeLdsMemoryBarriers; targetOptions.ldsLockFences = request.target.ldsLockFences; targetOptions.meshWindowBuffer = request.target.meshWindowBuffer; + targetOptions.float16WidenNans = request.target.float16WidenNans; + targetOptions.float16NarrowNans = request.target.float16NarrowNans; constexpr SpirvEmitter spirvEmitter; CompiledShaderArtifact result; diff --git a/core/shader/recompiler/Recompiler.hpp b/core/shader/recompiler/Recompiler.hpp index aca3fdd71b7d..13d647b0d6ef 100644 --- a/core/shader/recompiler/Recompiler.hpp +++ b/core/shader/recompiler/Recompiler.hpp @@ -362,6 +362,8 @@ struct SpirvTarget { bool meshWindowBuffer = false; bool hostInterpolationFirst = false; bool shaderImageMinLod = false; + bool float16WidenNans = false; + bool float16NarrowNans = false; }; struct BindingLayout { diff --git a/core/shader/recompiler/SpirvBackend/include/SpirvBackend/SpirvEmitter.hpp b/core/shader/recompiler/SpirvBackend/include/SpirvBackend/SpirvEmitter.hpp index 5938cf31fc26..d048df156317 100644 --- a/core/shader/recompiler/SpirvBackend/include/SpirvBackend/SpirvEmitter.hpp +++ b/core/shader/recompiler/SpirvBackend/include/SpirvBackend/SpirvEmitter.hpp @@ -46,6 +46,8 @@ struct SpirvTargetOptions { bool deviceScopeLdsMemoryBarriers = false; bool ldsLockFences = false; bool meshWindowBuffer = false; + bool float16WidenNans = false; + bool float16NarrowNans = false; }; struct SpirvEmitInfo { diff --git a/core/shader/recompiler/SpirvBackend/include/SpirvBackend/SpirvEmitterState.hpp b/core/shader/recompiler/SpirvBackend/include/SpirvBackend/SpirvEmitterState.hpp index 3adafd732ebe..9c4ea590fb82 100644 --- a/core/shader/recompiler/SpirvBackend/include/SpirvBackend/SpirvEmitterState.hpp +++ b/core/shader/recompiler/SpirvBackend/include/SpirvBackend/SpirvEmitterState.hpp @@ -134,6 +134,8 @@ struct SpirvEmitterState { bool waveLdsMemoryBarriers = false; bool deviceScopeLdsMemoryBarriers = false; bool ldsLockFences = false; + bool float16WidenNans = false; + bool float16NarrowNans = false; std::unordered_set sharedLaneValues; std::uint32_t laneHalf = 0; std::uint32_t spirvVersion = 0x00010300u; diff --git a/core/shader/recompiler/SpirvBackend/src/SpirvAlu/SpirvAluEmitterMath.cpp b/core/shader/recompiler/SpirvBackend/src/SpirvAlu/SpirvAluEmitterMath.cpp index a823f9b4b765..f6413785cf90 100644 --- a/core/shader/recompiler/SpirvBackend/src/SpirvAlu/SpirvAluEmitterMath.cpp +++ b/core/shader/recompiler/SpirvBackend/src/SpirvAlu/SpirvAluEmitterMath.cpp @@ -1855,6 +1855,7 @@ static std::uint32_t EmitNativeF32ToF16BitsRte(SpirvEmitterState& state, std::ui const auto pair = state.module.AllocateId(); state.module.AddFunction(spv::OpCompositeConstruct, state.module.Type(spv::OpTypeVector, f16, 2u), pair, half, state.module.Constant(spv::OpConstant, f16, 0u)); const auto bits = Unary(state, spv::OpBitcast, u32, pair); + if (state.float16NarrowNans) return bits; const auto source = Unary(state, spv::OpBitcast, u32, value); const auto magnitude = Binary(state, spv::OpBitwiseAnd, u32, source, ConstantU32(state, 0x7fffffffu)); if (nanPayloadUnused) return Select(state, u32, Binary(state, spv::OpUGreaterThan, TypeBool(state), magnitude, ConstantU32(state, 0x7f800000u)), ConstantU32(state, 0x7e00u), bits); diff --git a/core/shader/recompiler/SpirvBackend/src/SpirvEmitter.cpp b/core/shader/recompiler/SpirvBackend/src/SpirvEmitter.cpp index 591fac5fd98f..2c4aa0d4b72f 100644 --- a/core/shader/recompiler/SpirvBackend/src/SpirvEmitter.cpp +++ b/core/shader/recompiler/SpirvBackend/src/SpirvEmitter.cpp @@ -285,6 +285,8 @@ std::vector SpirvEmitter::Emit(const IrProgram& program, const Sh state.fusedExactArithmetic = target.fusedExactArithmetic; state.deviceScopeLdsMemoryBarriers = target.deviceScopeLdsMemoryBarriers; state.ldsLockFences = target.ldsLockFences; + state.float16WidenNans = target.float16WidenNans; + state.float16NarrowNans = target.float16NarrowNans; state.hostSubgroupSize = target.subgroupSize; if (target.largePixelFunctionLdsBuffer && program.Resources().stage == IrShaderStage::Pixel && state.requirements.functionLds && state.requirements.functionLdsDwords > target.pixelFunctionStorageDwords) { state.pixelFunctionLdsBuffer = true; diff --git a/core/shader/recompiler/SpirvBackend/src/SpirvModuleEmitter.cpp b/core/shader/recompiler/SpirvBackend/src/SpirvModuleEmitter.cpp index 5460e4850ffe..392288848776 100644 --- a/core/shader/recompiler/SpirvBackend/src/SpirvModuleEmitter.cpp +++ b/core/shader/recompiler/SpirvBackend/src/SpirvModuleEmitter.cpp @@ -1763,7 +1763,7 @@ static std::uint32_t UnpackF16BitsToF32(SpirvEmitterState& state, std::uint32_t const auto value = state.module.AllocateId(); state.module.AddFunction(spv::OpExtInst, TypeF32Vector(state, 2), unpacked, GlslStd450(state), GLSLstd450UnpackHalf2x16, bits); state.module.AddFunction(spv::OpCompositeExtract, f32, value, unpacked, 0u); - if (!nanBits) return value; + if (!nanBits || state.float16WidenNans) return value; const auto signAndPayload = Binary(state, spv::OpShiftRightArithmetic, u32, Binary(state, spv::OpShiftLeftLogical, u32, bits, ConstantU32(state, 16u)), ConstantU32(state, 3u)); const auto nan = Unary(state, spv::OpBitcast, f32, Binary(state, spv::OpBitwiseOr, u32, signAndPayload, ConstantU32(state, 0x7f800000u))); return Select(state, f32, Unary(state, spv::OpIsNan, TypeBool(state), value), nan, value); diff --git a/docs/dev/ARCHITECTURE.md b/docs/dev/ARCHITECTURE.md index b8c6189bfab3..b9756e2a1a88 100644 --- a/docs/dev/ARCHITECTURE.md +++ b/docs/dev/ARCHITECTURE.md @@ -229,3 +229,7 @@ A device with `shaderDenormFlushToZeroFloat32` and an f32 denormal behaviour ind ### Hardware f32 denormal preserve A device with `shaderDenormPreserveFloat32` and an f32 denormal behaviour independent of the other widths runs a second probe after the flush probe (`ProbeFloat32DenormPreserve`): one program applies `v_log_f32`, `v_sqrt_f32`, `v_rsq_f32`, `v_rcp_f32` and `v_exp_f32` to 32 lanes of normal values whose results are normal, zero or NaN, compiled once with `DenormPreserve 32` declared and once without, both with the emulated flush. The mode only changes how denormals are handled, but Vulkan does not require these operations to be correctly rounded, so a driver may pick a different sequence under the mode. For each of the 256 results, `Float32ResultsAgree` builds the interval that the Vulkan precision bound of its instruction allows around the exact result, and accepts the two results when they are no further apart than the interval is wide, since each compile may land anywhere inside it: 2.5 ULP for `v_rcp_f32` (`OpFDiv`), 2 for `v_rsq_f32`, 3 + 2ยท|x| for `v_exp_f32`, 3 for `v_log_f32`, and an absolute 2^-21 for `v_log_f32` inputs in [0.5, 2.0]. `v_sqrt_f32` inherits its bound from `1.0 / inversesqrt()`, so its interval runs from 2.5 ULP below the reciprocal of the top of the 2 ULP `inversesqrt` interval to 2.5 ULP above the reciprocal of its bottom. The ULP is that of the exact value, so an interval that crosses a power of 2 holds more representable values on its lower side. NaN only matches NaN. When any result falls outside its bound, the driver miscompiles the preserving form and `SpirvTarget::float32DenormPreserve` stays off; the device then keeps its default f32 denormal handling. RADV on Vega (GCN 5, Mesa 26.0.8) is such a driver: under `DenormPreserve 32` its `v_log_f32` lowering selects the denormal scale with a `v_cndmask_b32` that reads two SGPRs, which GFX9 cannot do, and `log2(2.0)` returns 25.0 in some lanes. AMD's proprietary driver on RDNA2 instead returns correctly rounded `v_rcp_f32` results under the mode, 1 ULP from its default ones, which stays inside the bound. `APS5_NO_HW_F32_PRESERVE` skips the probe and the mode. + +### Native f16 conversion NaN results + +A device with native f16 conversions (`Float16`, `RoundingModeRTE` and `DenormPreserve` for 16-bit floats) also runs a probe when it is created (`ProbeFloat16Conversions`). `OpFConvert` and `UnpackHalf2x16` round and handle denormals as RDNA does under those modes, but Vulkan leaves the NaN they return to the driver, so the emitter rebuilds RDNA's NaN: sign and payload kept from the f16 input by `v_cvt_f32_f16`, quiet bit set and payload truncated by `v_cvt_f16_f32`. The probe compiles `v_cvt_f32_f16` over the 65,536 halves, and `v_cvt_f16_f32`, `v_cvt_pkrtz_f16_f32` and a `v_cvt_f16_f32` then `v_cvt_f32_f16` round trip over every f32 NaN payload class, signalling NaNs, infinities, denormals, ties and overflow, with no float mode, with every denormal kept, with every denormal flushed and with a directed f16 rounding mode, each twice: with the rebuilt NaN and with the native result. Inputs come from a buffer, so the driver cannot fold them. `SpirvTarget::float16WidenNans` and `float16NarrowNans` are set per direction when every result matches, and the emitter then returns the native result. `APS5_NO_NATIVE_F16_NANS` skips the probe. diff --git a/docs/dev/BUILD.md b/docs/dev/BUILD.md index b2f12324e456..23d9995a08ae 100644 --- a/docs/dev/BUILD.md +++ b/docs/dev/BUILD.md @@ -219,7 +219,7 @@ On Windows MinGW frees `thread_local` objects before their destructors run, so t ## Compute pipeline replay -Build `agc_compute_pipeline_replay` in the full build with `-DANYPS5_ENABLE_SPIRV_TOOLS=ON`; replay validates the captured module before calling Vulkan. Set `APS5_DUMP_COMPUTE_PIPELINES` to a dedicated empty directory before starting a title to save each uncached compute pipeline's final SPIR-V, descriptor layouts, push constant ranges, specialization values, subgroup size and creation flags before compilation. Files are named `compute-/capture.aps5cp`; identical records are deduplicated. The pipelines of the start-up f32 denormal flush probe are not captured. At 128 records or 256 MiB in total, capture turns itself off for the rest of the run after a `[compute-capture] optional capture disabled` line and the title keeps running; a single record over 36 MiB (at most 8,388,608 SPIR-V words, or 32 MiB of shader code) is an error. Capture files contain shader code and device metadata, but no guest buffers or textures; review them before sharing. +Build `agc_compute_pipeline_replay` in the full build with `-DANYPS5_ENABLE_SPIRV_TOOLS=ON`; replay validates the captured module before calling Vulkan. Set `APS5_DUMP_COMPUTE_PIPELINES` to a dedicated empty directory before starting a title to save each uncached compute pipeline's final SPIR-V, descriptor layouts, push constant ranges, specialization values, subgroup size and creation flags before compilation. Files are named `compute-/capture.aps5cp`; identical records are deduplicated. The pipelines of the start-up f32 denormal flush and f16 conversion probes are not captured. At 128 records or 256 MiB in total, capture turns itself off for the rest of the run after a `[compute-capture] optional capture disabled` line and the title keeps running; a single record over 36 MiB (at most 8,388,608 SPIR-V words, or 32 MiB of shader code) is an error. Capture files contain shader code and device metadata, but no guest buffers or textures; review them before sharing. Set `APS5_DUMP_COMPUTE_MIN_WORDS` to capture only modules at or above that SPIR-V word count, for example `100000` when investigating large NVIDIA compilations. The default is `0` (all modules); values outside `0` through `8388608` fail. The filter applies only when capture is enabled. diff --git a/docs/dev/ENVIRONMENT.md b/docs/dev/ENVIRONMENT.md index 1e13b52662cb..f45c991f7c82 100644 --- a/docs/dev/ENVIRONMENT.md +++ b/docs/dev/ENVIRONMENT.md @@ -48,6 +48,7 @@ The code reads environment names through `getenv`, `IsEnvironmentSet`, `kibSetti | `APS5_NO_FAILURE_MEMO` | presence | `core/shader/recompiler/Recompiler.cpp` | | rebuilds a program whose compilation failed instead of reusing the memoized failure | | `APS5_NO_IMAGE_TABLES` | presence | `core/shader/recompiler/Optimization/src/ResourceTracker.cpp` | | disables image tables, leaving table-loaded descriptors unplanned | | `APS5_NO_MASKED_RESULT_MEMO` | presence | `core/shader/recompiler/Recompiler.cpp` | | materializes every result memo miss instead of patching the buffer descriptors, user data and flattened SRT of a cached result whose snapshot differs only in those words | +| `APS5_NO_NATIVE_F16_NANS` | presence | `core/libs/prx/libSceAgcDriver/Execution/src/VulkanDevice.cpp` | | skips the startup f16 conversion probe and keeps the NaN repairs of every native f16 to f32 and f32 to f16 conversion | | `APS5_NO_PERF_DISK_PLANS` | presence | `core/shader/recompiler/Recompiler.cpp` | | disables the disk cache of planning results | | `APS5_NO_PERF_DOMINATOR_TREE` | presence | `core/shader/recompiler/ControlFlow/src/Structurizer.cpp` | | computes the structurizer's dominator and post-dominator sets with the set fixpoint instead of a dominator tree, walks every selection construct instead of testing its members by dominance, and tests every block against each construct when checking the structured constructs instead of walking the construct | | `APS5_NO_PERF_DOM_INTERSECTION` | presence | `core/shader/recompiler/ControlFlow/src/Structurizer.cpp` | | computes dominator intersections into a fresh vector instead of intersecting in place | From b1a0d573e13b1917a48fe0c6a9ddc2ef58b559ec Mon Sep 17 00:00:00 2001 From: thibaut Date: Mon, 26 Oct 2026 04:02:04 +0100 Subject: [PATCH 2/5] feat(agc): let agc_shader_replay target native f16 conversion NaN results --f16-widen-nans and --f16-narrow-nans set float16WidenNans and float16NarrowNans on a saved request's target, as --robust2 does for its feature, so the SPIR-V of a device whose probe matched can be measured. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PHJtpJ4hw3KWJhobipK8ia --- .../prx/libSceAgcDriver/tools/ShaderReplay.cpp | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/core/libs/prx/libSceAgcDriver/tools/ShaderReplay.cpp b/core/libs/prx/libSceAgcDriver/tools/ShaderReplay.cpp index a70908fb66bd..248f5aafb3a7 100644 --- a/core/libs/prx/libSceAgcDriver/tools/ShaderReplay.cpp +++ b/core/libs/prx/libSceAgcDriver/tools/ShaderReplay.cpp @@ -55,6 +55,8 @@ bool g_deviceScopeLdsMemoryBarriers = false; bool g_ldsLockFences = false; bool g_shaderFma = false; bool g_code = false; +bool g_float16WidenNans = false; +bool g_float16NarrowNans = false; bool Replay(const char* path) { auto request = ShaderRecompiler::RequestSerializer{}.Deserialize(ReadText(path)); @@ -69,6 +71,8 @@ bool Replay(const char* path) { request.request.target.supportedCapabilities = request.supportedCapabilities; request.request.target.supportedExtensions = request.supportedExtensions; } + if (g_float16WidenNans) request.request.target.float16WidenNans = true; + if (g_float16NarrowNans) request.request.target.float16NarrowNans = true; std::printf("%s: %zu code words, %zu user data, %zu memory regions, wave%u\n", path, request.request.shader.code.size(), request.request.context.userData.size(), request.request.context.memory.size(), request.request.context.waveSize); if (request.request.context.compute.has_value()) { const auto& compute = *request.request.context.compute; @@ -198,7 +202,7 @@ bool Replay(const char* path) { int main(int argc, char** argv) { if (argc < 2) { - std::fprintf(stderr, "usage: agc_shader_replay [--dis] [--asm] [--cfg] [--mem] [--spv] [--prepare] [--materialize] [--code] [--maintenance8] [--robust2] [--wave-lds-memory-barriers] [--device-scope-lds-memory-barriers] [--lds-lock-fences] [--shader-fma] ...\n the driver writes shader_
.req files when APS5_DUMP_SHADERS is set; shader_
.prepare.req holds a request whose preparation failed before any resource capture, so replay it with --prepare\n"); + std::fprintf(stderr, "usage: agc_shader_replay [--dis] [--asm] [--cfg] [--mem] [--spv] [--prepare] [--materialize] [--code] [--maintenance8] [--robust2] [--wave-lds-memory-barriers] [--device-scope-lds-memory-barriers] [--lds-lock-fences] [--shader-fma] [--f16-widen-nans] [--f16-narrow-nans] ...\n the driver writes shader_
.req files when APS5_DUMP_SHADERS is set; shader_
.prepare.req holds a request whose preparation failed before any resource capture, so replay it with --prepare\n"); return 2; } int failures = 0; @@ -243,6 +247,14 @@ int main(int argc, char** argv) { g_shaderFma = true; continue; } + if (std::string(argv[i]) == "--f16-widen-nans") { + g_float16WidenNans = true; + continue; + } + if (std::string(argv[i]) == "--f16-narrow-nans") { + g_float16NarrowNans = true; + continue; + } if (std::string(argv[i]) == "--mem") { g_memory = true; continue; From 81b6f2822b1f798300f42bb6a0cc199e72345e49 Mon Sep 17 00:00:00 2001 From: thibaut Date: Tue, 27 Oct 2026 20:24:50 +0100 Subject: [PATCH 3/5] feat(shader): save the f16 conversion NaN fields in recompile requests SpirvTarget::float16WidenNans and float16NarrowNans shape the emitted SPIR-V, but the request serializer did not write them, so a request saved on a device whose probe matched replayed with the emulated NaN rebuild. Format version 54 writes both flags after the shader image minimum LOD flag; versions 53 and older read them as false. agc_shader_replay's --f16-widen-nans and --f16-narrow-nans still force them on. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PHJtpJ4hw3KWJhobipK8ia --- .../libSceAgcDriver/tests/ShaderMemory.cpp | 30 ++++-- .../ControlFlow/src/RequestSerializer.cpp | 10 +- .../tests/RequestSerializerTests.cpp | 100 ++++++++++++++---- docs/dev/ARCHITECTURE.md | 2 +- 4 files changed, 112 insertions(+), 30 deletions(-) diff --git a/core/libs/prx/libSceAgcDriver/tests/ShaderMemory.cpp b/core/libs/prx/libSceAgcDriver/tests/ShaderMemory.cpp index effb0b5af54f..db2d9bb195a1 100644 --- a/core/libs/prx/libSceAgcDriver/tests/ShaderMemory.cpp +++ b/core/libs/prx/libSceAgcDriver/tests/ShaderMemory.cpp @@ -8049,7 +8049,7 @@ void verifyPixelRequestSerialization() { minimal.context.pixel->layerExported = true; minimal.context.pixel->ditheredAlphaToCoverage = true; const auto encoded = serializer.Serialize(minimal); - require(requestPrefix(encoded, 8u) == "NVNQQTUAAAA=", "new requests did not use serialization version 53"); + require(requestPrefix(encoded, 8u) == "NVNQQTYAAAA=", "new requests did not use serialization version 54"); auto withBarycentric = minimal; withBarycentric.target.fragmentBarycentricBuiltins = true; require(serializer.Deserialize(serializer.Serialize(withBarycentric)).request.target.fragmentBarycentricBuiltins && !serializer.Deserialize(encoded).request.target.fragmentBarycentricBuiltins, "barycentric built-ins were lost in serialization"); @@ -8170,7 +8170,7 @@ void verifyPixelRequestSerialization() { for (std::size_t bytes = 0; bytes < 45u; ++bytes) { expectFailure([&] { static_cast(serializer.Deserialize(requestPrefix(encoded, mappingOffset + bytes))); }, "truncated data", "a truncated pixel mapping, packing, dual-source, alpha-to-coverage, stencil reference export flag, primitive class, layer export, dither or front-face flag, quad pixel mask or quad sample mask was accepted"); } - for (const auto unsupported : {"NVNQQQAAAAA=", "NVNQQTIAAAA=", "NVNQQTYAAAA="}) { + for (const auto unsupported : {"NVNQQQAAAAA=", "NVNQQTIAAAA=", "NVNQQTcAAAA="}) { expectFailure([&] { static_cast(serializer.Deserialize(unsupported)); }, "serialization version", "an unsupported request version was accepted"); } static constexpr std::string_view version18Request{ @@ -8289,9 +8289,9 @@ void verifyDepthExportClamp() { const auto both = RequestSerializer{}.Deserialize(RequestSerializer{}.Serialize(tail)); require(both.request.target.largePixelFunctionLdsBuffer && both.request.target.pixelFunctionStorageDwords == 2048u && both.request.context.pixel->depthExportMin == 0.25f && both.request.context.pixel->depthExportMax == 0.75f, - "depth export: a version 53 request lost the bounds or the version 45 target tail"); + "depth export: a version 54 request lost the bounds or the version 45 target tail"); auto bytes = decodeBase64(RequestSerializer{}.Serialize(tail)); - require(bytes.size() > 8u && bytes[4] == 53u, "depth export: a depth exporting request did not use serialization version 53"); + require(bytes.size() > 8u && bytes[4] == 54u, "depth export: a depth exporting request did not use serialization version 54"); static constexpr std::array boundBytes{0x00u, 0x00u, 0x80u, 0x3eu, 0x00u, 0x00u, 0x40u, 0x3fu}; const auto found = std::search(bytes.begin(), bytes.end(), boundBytes.begin(), boundBytes.end()); require(found != bytes.end() && std::search(found + 1, bytes.end(), boundBytes.begin(), boundBytes.end()) == bytes.end(), @@ -8300,6 +8300,8 @@ void verifyDepthExportClamp() { bytes.pop_back(); bytes.pop_back(); bytes.pop_back(); + bytes.pop_back(); + bytes.pop_back(); for (const std::uint8_t legacyVersion : {48u, 47u, 46u}) { if (legacyVersion == 47u) bytes.pop_back(); bytes[4] = legacyVersion; @@ -8319,7 +8321,7 @@ void verifyDepthExportClamp() { ditheredPixel.ditheredAlphaSamples = 2u; dithered.context.pixel = ditheredPixel; auto ditheredBytes = decodeBase64(RequestSerializer{}.Serialize(dithered)); - require(ditheredBytes.size() > 8u && ditheredBytes[4] == 53u, "a two-sample dithered request did not use serialization version 53"); + require(ditheredBytes.size() > 8u && ditheredBytes[4] == 54u, "a two-sample dithered request did not use serialization version 54"); if (depthExport) { const auto bounds = std::search(ditheredBytes.begin(), ditheredBytes.end(), boundBytes.begin(), boundBytes.end()); require(bounds != ditheredBytes.end(), "a two-sample dithered depth exporting request lacks the clamp bounds"); @@ -8328,6 +8330,8 @@ void verifyDepthExportClamp() { ditheredBytes.pop_back(); ditheredBytes.pop_back(); ditheredBytes.pop_back(); + ditheredBytes.pop_back(); + ditheredBytes.pop_back(); ditheredBytes[4] = 48u; const auto main48 = RequestSerializer{}.Deserialize(encodeBase64(ditheredBytes)); require(main48.request.context.pixel->ditheredAlphaToCoverage && main48.request.context.pixel->ditheredAlphaSamples == 2u && main48.request.context.pixel->depthExportEnable == depthExport && @@ -8395,6 +8399,8 @@ void verifyVertexInfoVersion19() { auto version20Vertex = flagged; version20Vertex.pop_back(); version20Vertex.pop_back(); + version20Vertex.pop_back(); + version20Vertex.pop_back(); version20Vertex.resize(version20Vertex.size() - sizeof(std::uint32_t)); version20Vertex.pop_back(); version20Vertex.pop_back(); @@ -8411,6 +8417,8 @@ void verifyVertexInfoVersion19() { auto legacy = plain; legacy.pop_back(); legacy.pop_back(); + legacy.pop_back(); + legacy.pop_back(); legacy.resize(legacy.size() - sizeof(std::uint32_t)); legacy.pop_back(); legacy.pop_back(); @@ -8488,7 +8496,7 @@ void verifyVertexInfoVersion19() { const auto version43Bytes = [&](std::vector bytes) { require(bytes.end() - bytes.begin() > mergeOffset + static_cast(mergeBytes) && std::equal(plainPixel.begin() + mergeOffset, plainPixel.begin() + mergeOffset + static_cast(mergeBytes), bytes.begin() + mergeOffset), "a replay fixture does not hold the default color target merge after the sample order"); bytes.erase(bytes.begin() + mergeOffset, bytes.begin() + mergeOffset + static_cast(mergeBytes)); - bytes.resize(bytes.size() - sizeof(std::uint32_t) - 6u); + bytes.resize(bytes.size() - sizeof(std::uint32_t) - 8u); return bytes; }; auto version42 = version43Bytes(plainPixel); @@ -8650,11 +8658,13 @@ void verifyDepthRemapRequests() { const auto plainBytes = decodeBase64(plain); const auto remappedBytes = decodeBase64(remapped); constexpr std::size_t pointTailBytes = 13u; - require(plainBytes.size() == remappedBytes.size() && plainBytes.size() > 17u + pointTailBytes && std::equal(plainBytes.begin(), plainBytes.end() - 17 - pointTailBytes, remappedBytes.begin()) && std::equal(plainBytes.end() - 10 - pointTailBytes, plainBytes.end(), remappedBytes.end() - 10 - pointTailBytes), "the depth remap flag and mode are not the 6 bytes before the f32 flush and denormal preserve flags, the point tail, the fused arithmetic flag, the pixel function LDS flag, the pixel function storage size, the barycentric built-in flag, the tessellation evaluation subgroup flag, the host interpolation first flag and the shader image minimum LOD flag of a vertex request"); + require(plainBytes.size() == remappedBytes.size() && plainBytes.size() > 19u + pointTailBytes && std::equal(plainBytes.begin(), plainBytes.end() - 19 - pointTailBytes, remappedBytes.begin()) && std::equal(plainBytes.end() - 12 - pointTailBytes, plainBytes.end(), remappedBytes.end() - 12 - pointTailBytes), "the depth remap flag and mode are not the 6 bytes before the f32 flush and denormal preserve flags, the point tail, the fused arithmetic flag, the pixel function LDS flag, the pixel function storage size, the barycentric built-in flag, the tessellation evaluation subgroup flag, the host interpolation first flag, the shader image minimum LOD flag and the f16 conversion NaN flags of a vertex request"); for (const std::uint8_t legacyVersion : {32u, 34u}) { auto legacyBytes = remappedBytes; legacyBytes.pop_back(); legacyBytes.pop_back(); + legacyBytes.pop_back(); + legacyBytes.pop_back(); legacyBytes.resize(legacyBytes.size() - sizeof(std::uint32_t)); legacyBytes.pop_back(); legacyBytes.pop_back(); @@ -8670,6 +8680,8 @@ void verifyDepthRemapRequests() { auto version35 = remappedBytes; version35.pop_back(); version35.pop_back(); + version35.pop_back(); + version35.pop_back(); version35.resize(version35.size() - sizeof(std::uint32_t)); version35.pop_back(); version35.pop_back(); @@ -8723,8 +8735,8 @@ void verifyMergedHullConfiguration() { require(!serializer.Deserialize(separate).request.graphics->tessellation->mergedHull, "a separate hull configuration was read as merged"); constexpr std::size_t pointTailBytes = 13u; auto legacy = decodeBase64(merged); - require(legacy.size() > 58u + pointTailBytes && legacy[4] == 53u, "a merged hull request did not use serialization version 53"); - legacy.resize(legacy.size() - sizeof(std::uint32_t) - 5u - pointTailBytes); + require(legacy.size() > 60u + pointTailBytes && legacy[4] == 54u, "a merged hull request did not use serialization version 54"); + legacy.resize(legacy.size() - sizeof(std::uint32_t) - 7u - pointTailBytes); legacy.pop_back(); legacy.pop_back(); require(legacy[legacy.size() - 52u] == 1u, "the merged hull flag is not the last byte of the tessellation configuration"); diff --git a/core/shader/recompiler/ControlFlow/src/RequestSerializer.cpp b/core/shader/recompiler/ControlFlow/src/RequestSerializer.cpp index f14fda01ecbc..efde6359f154 100644 --- a/core/shader/recompiler/ControlFlow/src/RequestSerializer.cpp +++ b/core/shader/recompiler/ControlFlow/src/RequestSerializer.cpp @@ -898,7 +898,7 @@ std::string RequestSerializer::Serialize(const RecompileRequest& request) const std::string buffer; Writer writer(buffer); writer.WriteU32(0x41505335u); - writer.WriteU32(53u); + writer.WriteU32(54u); writeShaderBinary(writer, request.shader); writeGuestContext(writer, request.context); writeSpirvTarget(writer, request.target); @@ -943,6 +943,8 @@ std::string RequestSerializer::Serialize(const RecompileRequest& request) const writer.WriteBool(request.target.tessellationEvaluationSubgroups); writer.WriteBool(request.target.hostInterpolationFirst); writer.WriteBool(request.target.shaderImageMinLod); + writer.WriteBool(request.target.float16WidenNans); + writer.WriteBool(request.target.float16NarrowNans); return base64Encode(buffer); } @@ -951,7 +953,7 @@ DeserializedRequest RequestSerializer::Deserialize(std::string_view text) const Reader reader(decoded); if (reader.ReadU32() != 0x41505335u) throw std::runtime_error("invalid recompile request signature"); const auto version = reader.ReadU32(); - if (version < 1u || version > 53u || version == 50u) throw std::runtime_error("unsupported recompile request serialization version"); + if (version < 1u || version > 54u || version == 50u) throw std::runtime_error("unsupported recompile request serialization version"); DeserializedRequest result{}; result.request.shader = readShaderBinary(reader, result.shaderCode, result.shaderHeader); result.request.context = readGuestContext(reader, result, version); @@ -1014,6 +1016,10 @@ DeserializedRequest RequestSerializer::Deserialize(std::string_view text) const if (version >= 51u) result.request.target.tessellationEvaluationSubgroups = reader.ReadBool(); if (version >= 52u) result.request.target.hostInterpolationFirst = reader.ReadBool(); if (version >= 53u) result.request.target.shaderImageMinLod = reader.ReadBool(); + if (version >= 54u) { + result.request.target.float16WidenNans = reader.ReadBool(); + result.request.target.float16NarrowNans = reader.ReadBool(); + } if (version >= 21u && reader.Remaining() != 0u) throw std::runtime_error("RequestSerializer: unexpected trailing data"); return result; } diff --git a/core/shader/recompiler/tests/RequestSerializerTests.cpp b/core/shader/recompiler/tests/RequestSerializerTests.cpp index 135e1969a129..243e2699ff63 100644 --- a/core/shader/recompiler/tests/RequestSerializerTests.cpp +++ b/core/shader/recompiler/tests/RequestSerializerTests.cpp @@ -316,7 +316,7 @@ int main() { Require(request.graphics->draw.indexAddress == 0xabcdef3400u && request.graphics->draw.indexCount == 57u && request.graphics->draw.indexElementBytes == 4u && request.graphics->draw.instanceCount == 9u); Require(request.target.float16DenormPreserve == preserve); auto upgraded = bytes; - upgraded[4] = 53u; + upgraded[4] = 54u; InsertMeshBarycentricFlag(upgraded); if (fixture.pixel) upgraded.insert(upgraded.begin() + 258u, {0u, 0u, 0u}); if (fixture.pixel) InsertPixelDefaultsAfterFrontFace(upgraded); @@ -329,6 +329,8 @@ int main() { upgraded.push_back(1u); upgraded.push_back(0u); upgraded.push_back(0u); + upgraded.push_back(0u); + upgraded.push_back(0u); Require(serializer.Serialize(request) == EncodeBase64(upgraded)); auto truncated = bytes; truncated.pop_back(); @@ -363,7 +365,7 @@ int main() { layerRejected = std::string_view(error.what()) == "RequestSerializer: truncated data"; } Require(layerRejected); - layered[4] = 53u; + layered[4] = 54u; InsertMeshBarycentricFlag(layered); layered.insert(layered.begin() + 259u, {0u, 0u}); InsertPixelDefaultsAfterFrontFace(layered); @@ -376,6 +378,8 @@ int main() { layered.push_back(1u); layered.push_back(0u); layered.push_back(0u); + layered.push_back(0u); + layered.push_back(0u); Require(serializer.Serialize(layerReplay.request) == EncodeBase64(layered)); } } @@ -396,7 +400,7 @@ int main() { Require(replay.request.context.pixel->rasterPrimitive == ShaderRecompiler::RasterPrimitive::Rectangle); Require(replay.request.context.vertex->paClVsOutCntl == 0x00050a05u); Require(replay.request.target.float16DenormPreserve == preserve); - bytes[4] = 53u; + bytes[4] = 54u; InsertMeshBarycentricFlag(bytes); bytes.insert(bytes.begin() + 259u, {0u, 0u}); InsertPixelDefaultsAfterFrontFace(bytes); @@ -408,6 +412,8 @@ int main() { bytes.push_back(1u); bytes.push_back(0u); bytes.push_back(0u); + bytes.push_back(0u); + bytes.push_back(0u); Require(serializer.Serialize(replay.request) == EncodeBase64(bytes)); auto truncated23 = DecodeBase64(encoded23); @@ -533,7 +539,7 @@ int main() { if (fixture.pixel) main38.insert(main38.begin() + 259u, {0u, 0u}); if (fixture.pixel) InsertPixelDefaultsAfterFrontFace(main38, false, false); auto upgraded = main38; - upgraded[4] = 53u; + upgraded[4] = 54u; if (fixture.pixel) { const auto tail = PixelTailDefaults(true, true); upgraded.insert(upgraded.begin() + 317u, tail.begin(), tail.end()); @@ -548,6 +554,8 @@ int main() { upgraded.push_back(1u); upgraded.push_back(0u); upgraded.push_back(0u); + upgraded.push_back(0u); + upgraded.push_back(0u); Require(serializer.Serialize(request) == EncodeBase64(upgraded)); const auto replay38 = serializer.Deserialize(EncodeBase64(main38)); Require(replay38.request.graphics.has_value() && replay38.request.graphics->tessellation.has_value() == fixture.tessellation); @@ -563,8 +571,16 @@ int main() { pointed.context.vertex->pointSize = 3.0f; pointed.context.vertex->pointSizeMin = 1.0f; pointed.context.vertex->pointSizeMax = 8.0f; - auto version53 = DecodeBase64(serializer.Serialize(pointed)); - Require(version53.size() > 19u && version53[4] == 53u && version53.back() == 0u && version53[version53.size() - 2u] == 0u); + auto version54 = DecodeBase64(serializer.Serialize(pointed)); + Require(version54.size() > 21u && version54[4] == 54u && version54[version54.size() - 1u] == 0u && version54[version54.size() - 2u] == 0u && version54[version54.size() - 3u] == 0u && version54[version54.size() - 4u] == 0u); + const auto replay54 = serializer.Deserialize(EncodeBase64(version54)); + Require(replay54.request.graphics->tessellation->mergedHull == merged && replay54.request.context.vertex->pointList && replay54.request.context.vertex->pointSize == 3.0f && replay54.request.context.vertex->pointSizeMin == 1.0f && replay54.request.context.vertex->pointSizeMax == 8.0f); + auto version53 = version54; + version53.resize(version53.size() - 2u); + version53[4] = 53u; + const auto replay53 = serializer.Deserialize(EncodeBase64(version53)); + Require(replay53.request.graphics->tessellation->mergedHull == merged && replay53.request.context.vertex->pointList && !replay53.request.target.float16WidenNans && !replay53.request.target.float16NarrowNans); + Require(serializer.Serialize(replay53.request) == EncodeBase64(version54)); auto version52 = version53; version52.pop_back(); version52[4] = 52u; @@ -590,7 +606,7 @@ int main() { version43.pop_back(); version43[4] = 43u; const auto replay43 = serializer.Deserialize(EncodeBase64(version43)); - Require(serializer.Serialize(replay43.request) == EncodeBase64(version53)); + Require(serializer.Serialize(replay43.request) == EncodeBase64(version54)); Require(replay43.request.graphics->tessellation->mergedHull == merged && replay43.request.context.vertex->pointList && replay43.request.context.vertex->pointSize == 3.0f && replay43.request.context.vertex->pointSizeMin == 1.0f && replay43.request.context.vertex->pointSizeMax == 8.0f); auto version42 = version43; version42.resize(version42.size() - 13u); @@ -617,7 +633,7 @@ int main() { const auto replay25 = serializer.Deserialize(EncodeBase64(main25)); Require(!fixture.pixel || (replay25.request.context.pixel->layerExported == layer && replay25.request.context.pixel->ditheredAlphaToCoverage == dither && replay25.request.context.pixel->reversedBlend == 0u)); Require(replay25.request.target.float16DenormPreserve == preserve && (!fixture.tessellation || replay25.request.graphics->tessellation->primitiveGroupSize == 37u)); - main25[4] = 53u; + main25[4] = 54u; InsertMeshBarycentricFlag(main25); if (fixture.pixel) { main25.insert(main25.begin() + 260u, 0u); @@ -632,6 +648,8 @@ int main() { main25.push_back(1u); main25.push_back(0u); main25.push_back(0u); + main25.push_back(0u); + main25.push_back(0u); Require(serializer.Serialize(replay25.request) == EncodeBase64(main25)); } } @@ -649,10 +667,12 @@ int main() { const auto replay = serializer.Deserialize(encoded); Require(replay.request.target.float16DenormPreserve == preserve && replay.request.target.float32FlushToZero == !preserve && replay.request.target.float32DenormPreserve == preserve && replay.request.target.largePixelFunctionLdsBuffer == !preserve && replay.request.target.pixelFunctionStorageDwords == request.target.pixelFunctionStorageDwords); auto bytes = DecodeBase64(encoded); - Require(bytes.size() >= 8u && bytes[4] == 53u && bytes[5] == 0u && bytes[6] == 0u && bytes[7] == 0u); + Require(bytes.size() >= 8u && bytes[4] == 54u && bytes[5] == 0u && bytes[6] == 0u && bytes[7] == 0u); auto version40 = bytes; version40.pop_back(); version40.pop_back(); + version40.pop_back(); + version40.pop_back(); version40.resize(version40.size() - sizeof(std::uint32_t)); version40.pop_back(); version40.pop_back(); @@ -663,7 +683,7 @@ int main() { const auto replay40 = serializer.Deserialize(EncodeBase64(version40)); Require(!replay40.request.target.float32DenormPreserve && replay40.request.target.float32FlushToZero == !preserve); auto future = bytes; - future[4] = 54u; + future[4] = 55u; bool futureRejected = false; try { static_cast(serializer.Deserialize(EncodeBase64(future))); @@ -711,6 +731,8 @@ int main() { EraseDefaultTargetMerge(version42, orderOffset + static_cast(sizeof(pixel.sampleOrder))); version42.pop_back(); version42.pop_back(); + version42.pop_back(); + version42.pop_back(); version42.resize(version42.size() - 4u); version42.pop_back(); version42.pop_back(); @@ -736,6 +758,8 @@ int main() { auto version35 = DecodeBase64(serializer.Serialize(request)); version35.pop_back(); version35.pop_back(); + version35.pop_back(); + version35.pop_back(); version35.resize(version35.size() - sizeof(std::uint32_t)); version35.pop_back(); version35.pop_back(); @@ -787,7 +811,7 @@ int main() { for (const std::uint8_t version : {32u, 33u, 34u, 35u, 36u, 37u, 38u}) { pointRequest.target.spannedWaves = spanned; auto legacy = DecodeBase64(serializer.Serialize(pointRequest)); - legacy.resize(legacy.size() - (version >= 36u ? 24u : version >= 35u ? 25u : 30u)); + legacy.resize(legacy.size() - (version >= 36u ? 26u : version >= 35u ? 27u : 32u)); legacy[4] = version; const auto replayLegacy = serializer.Deserialize(EncodeBase64(legacy)); Require(replayLegacy.request.target.spannedWaves == spanned); @@ -799,7 +823,7 @@ int main() { Require(!replayLegacy.request.context.vertex->pointList && replayLegacy.request.context.vertex->pointSize == 1.0f); Require(replayLegacy.request.context.vertex->pointSizeMin == 0.0f && replayLegacy.request.context.vertex->pointSizeMax == 0.0f); const auto upgradedLegacy = DecodeBase64(serializer.Serialize(replayLegacy.request)); - Require(upgradedLegacy[4] == 53u && upgradedLegacy.size() == legacy.size() + (version >= 36u ? 24u : version >= 35u ? 25u : 30u)); + Require(upgradedLegacy[4] == 54u && upgradedLegacy.size() == legacy.size() + (version >= 36u ? 26u : version >= 35u ? 27u : 32u)); } } @@ -812,6 +836,8 @@ int main() { EraseDefaultTargetMerge(legacy43, mergeOffset); legacy43.pop_back(); legacy43.pop_back(); + legacy43.pop_back(); + legacy43.pop_back(); legacy43.resize(legacy43.size() - 4u); legacy43.pop_back(); legacy43.pop_back(); @@ -829,7 +855,7 @@ int main() { pixel.sampleOrder = {}; pointRequest.context.pixel = pixel; auto legacy34 = DecodeBase64(serializer.Serialize(pointRequest)); - legacy34.resize(legacy34.size() - 30u); + legacy34.resize(legacy34.size() - 32u); const auto stencil34 = std::search(legacy34.begin(), legacy34.end(), pixel.sampleLocations.begin(), pixel.sampleLocations.begin() + 4) + pixel.sampleLocations.size(); const auto legacyPixelTail = static_cast(1u + sizeof(pixel.sampleOrder)); Require(legacy34.end() - stencil34 > legacyPixelTail && std::all_of(stencil34, stencil34 + legacyPixelTail, [](std::uint8_t value) { return value == 0u; })); @@ -844,7 +870,9 @@ int main() { request.target.fusedExactArithmetic = fused; request.target.largePixelFunctionLdsBuffer = false; auto bytes = DecodeBase64(serializer.Serialize(request)); - Require(serializer.Deserialize(EncodeBase64(bytes)).request.target.fusedExactArithmetic == fused && bytes[bytes.size() - 10u] == (fused ? 1u : 0u) && bytes[bytes.size() - 9u] == 0u); + Require(serializer.Deserialize(EncodeBase64(bytes)).request.target.fusedExactArithmetic == fused && bytes[bytes.size() - 12u] == (fused ? 1u : 0u) && bytes[bytes.size() - 11u] == 0u); + bytes.pop_back(); + bytes.pop_back(); bytes.pop_back(); bytes.pop_back(); bytes.pop_back(); @@ -892,6 +920,8 @@ int main() { version46Dither.pop_back(); version46Dither.pop_back(); version46Dither.pop_back(); + version46Dither.pop_back(); + version46Dither.pop_back(); version46Dither[4] = 46u; Require(rejectsDither(version46Dither)); version46Dither[ditherByte] = 1u; @@ -906,8 +936,9 @@ int main() { domain.target.tessellationEvaluationSubgroups = subgroups; const auto encoded = serializer.Serialize(domain); auto bytes = DecodeBase64(encoded); - Require(bytes.size() > 9u && bytes[4] == 53u && bytes.back() == 0u && bytes[bytes.size() - 2u] == 0u && bytes[bytes.size() - 3u] == (subgroups ? 1u : 0u)); + Require(bytes.size() > 11u && bytes[4] == 54u && bytes[bytes.size() - 1u] == 0u && bytes[bytes.size() - 2u] == 0u && bytes[bytes.size() - 3u] == 0u && bytes[bytes.size() - 4u] == 0u && bytes[bytes.size() - 5u] == (subgroups ? 1u : 0u)); Require(serializer.Deserialize(encoded).request.target.tessellationEvaluationSubgroups == subgroups); + bytes.resize(bytes.size() - 2u); bytes.pop_back(); bytes[4] = 52u; Require(serializer.Deserialize(EncodeBase64(bytes)).request.target.tessellationEvaluationSubgroups == subgroups); @@ -926,10 +957,12 @@ int main() { const auto replay49 = serializer.Deserialize(EncodeBase64(bytes)); Require(replay49.request.shader.stage == ShaderRecompiler::ShaderStage::TessellationEvaluation && replay49.request.target.tessellationEvaluationSubgroups); auto upgraded = bytes; - upgraded[4] = 53u; + upgraded[4] = 54u; upgraded.push_back(1u); upgraded.push_back(0u); upgraded.push_back(0u); + upgraded.push_back(0u); + upgraded.push_back(0u); Require(serializer.Serialize(replay49.request) == EncodeBase64(upgraded)); for (auto skipped : {bytes, upgraded}) { skipped[4] = 50u; @@ -949,7 +982,8 @@ int main() { const auto replay = serializer.Deserialize(hostFirstEncoded[hostFirst]); Require(replay.request.target.hostInterpolationFirst == hostFirst && serializer.Serialize(replay.request) == hostFirstEncoded[hostFirst]); auto bytes = DecodeBase64(hostFirstEncoded[hostFirst]); - Require(bytes[4] == 53u && bytes.back() == 0u && bytes[bytes.size() - 2u] == (hostFirst ? 1u : 0u)); + Require(bytes[4] == 54u && bytes[bytes.size() - 3u] == 0u && bytes[bytes.size() - 4u] == (hostFirst ? 1u : 0u)); + bytes.resize(bytes.size() - 2u); bytes.pop_back(); bytes[4] = 52u; Require(serializer.Deserialize(EncodeBase64(bytes)).request.target.hostInterpolationFirst == hostFirst); @@ -962,12 +996,42 @@ int main() { for (const bool minLod : {false, true}) { request.target.shaderImageMinLod = minLod; auto bytes = DecodeBase64(serializer.Serialize(request)); - Require(serializer.Deserialize(EncodeBase64(bytes)).request.target.shaderImageMinLod == minLod && bytes[4] == 53u && bytes.back() == (minLod ? 1u : 0u)); + Require(serializer.Deserialize(EncodeBase64(bytes)).request.target.shaderImageMinLod == minLod && bytes[4] == 54u && bytes[bytes.size() - 3u] == (minLod ? 1u : 0u)); + bytes.resize(bytes.size() - 2u); bytes.pop_back(); bytes[4] = 52u; Require(!serializer.Deserialize(EncodeBase64(bytes)).request.target.shaderImageMinLod); } request.target.shaderImageMinLod = false; + request.target.fusedExactArithmetic = false; + for (const bool widen : {false, true}) { + for (const bool narrow : {false, true}) { + request.target.float16WidenNans = widen; + request.target.float16NarrowNans = narrow; + const auto encoded = serializer.Serialize(request); + const auto replay = serializer.Deserialize(encoded); + Require(replay.request.target.float16WidenNans == widen && replay.request.target.float16NarrowNans == narrow); + Require(serializer.Serialize(replay.request) == encoded); + const auto bytes = DecodeBase64(encoded); + Require(bytes[4] == 54u && bytes[bytes.size() - 2u] == (widen ? 1u : 0u) && bytes[bytes.size() - 1u] == (narrow ? 1u : 0u)); + auto version53 = bytes; + version53.resize(version53.size() - 2u); + version53[4] = 53u; + const auto replay53 = serializer.Deserialize(EncodeBase64(version53)); + Require(!replay53.request.target.float16WidenNans && !replay53.request.target.float16NarrowNans); + auto truncated = bytes; + truncated.pop_back(); + bool truncatedRejected = false; + try { + static_cast(serializer.Deserialize(EncodeBase64(truncated))); + } catch (const std::runtime_error& error) { + truncatedRejected = std::string_view(error.what()) == "RequestSerializer: truncated data"; + } + Require(truncatedRejected); + } + } + request.target.float16WidenNans = false; + request.target.float16NarrowNans = false; return 0; } diff --git a/docs/dev/ARCHITECTURE.md b/docs/dev/ARCHITECTURE.md index b9756e2a1a88..670eb73e1111 100644 --- a/docs/dev/ARCHITECTURE.md +++ b/docs/dev/ARCHITECTURE.md @@ -232,4 +232,4 @@ A device with `shaderDenormPreserveFloat32` and an f32 denormal behaviour indepe ### Native f16 conversion NaN results -A device with native f16 conversions (`Float16`, `RoundingModeRTE` and `DenormPreserve` for 16-bit floats) also runs a probe when it is created (`ProbeFloat16Conversions`). `OpFConvert` and `UnpackHalf2x16` round and handle denormals as RDNA does under those modes, but Vulkan leaves the NaN they return to the driver, so the emitter rebuilds RDNA's NaN: sign and payload kept from the f16 input by `v_cvt_f32_f16`, quiet bit set and payload truncated by `v_cvt_f16_f32`. The probe compiles `v_cvt_f32_f16` over the 65,536 halves, and `v_cvt_f16_f32`, `v_cvt_pkrtz_f16_f32` and a `v_cvt_f16_f32` then `v_cvt_f32_f16` round trip over every f32 NaN payload class, signalling NaNs, infinities, denormals, ties and overflow, with no float mode, with every denormal kept, with every denormal flushed and with a directed f16 rounding mode, each twice: with the rebuilt NaN and with the native result. Inputs come from a buffer, so the driver cannot fold them. `SpirvTarget::float16WidenNans` and `float16NarrowNans` are set per direction when every result matches, and the emitter then returns the native result. `APS5_NO_NATIVE_F16_NANS` skips the probe. +A device with native f16 conversions (`Float16`, `RoundingModeRTE` and `DenormPreserve` for 16-bit floats) also runs a probe when it is created (`ProbeFloat16Conversions`). `OpFConvert` and `UnpackHalf2x16` round and handle denormals as RDNA does under those modes, but Vulkan leaves the NaN they return to the driver, so the emitter rebuilds RDNA's NaN: sign and payload kept from the f16 input by `v_cvt_f32_f16`, quiet bit set and payload truncated by `v_cvt_f16_f32`. The probe compiles `v_cvt_f32_f16` over the 65,536 halves, and `v_cvt_f16_f32`, `v_cvt_pkrtz_f16_f32` and a `v_cvt_f16_f32` then `v_cvt_f32_f16` round trip over every f32 NaN payload class, signalling NaNs, infinities, denormals, ties and overflow, with no float mode, with every denormal kept, with every denormal flushed and with a directed f16 rounding mode, each twice: with the rebuilt NaN and with the native result. Inputs come from a buffer, so the driver cannot fold them. `SpirvTarget::float16WidenNans` and `float16NarrowNans` are set per direction when every result matches, and the emitter then returns the native result. Both are part of the cache key and of the serialized request (version 49), so a saved request replays with the conversions of the device that wrote it. `APS5_NO_NATIVE_F16_NANS` skips the probe. From 62179fc53c60b1d869f96f00d490a54766640e40 Mon Sep 17 00:00:00 2001 From: thibaut Date: Mon, 26 Oct 2026 10:41:16 +0100 Subject: [PATCH 4/5] fix(shader): widen the nearest f16 of a native toward-zero pack with integer arithmetic The toward-zero pack corrects the nearest-even half when it widens back above the input. Once the f32 to f16 NaN rebuild is skipped, that half is the OpFConvert result itself and RADV and NVIDIA fold the f32 -> f16 -> f32 pair, so the correction disappeared (140,763 of 280,480 probe inputs rounded to nearest even). NoContraction on the widening is enough on RADV but not on NVIDIA. With float16NarrowNans the half is now widened with integer operations (exact multiply for denormals), which no driver can fold; without it the NaN rebuild still separates the two conversions and the module is unchanged. Co-authored-by: Lander0606 <109523124+Lander0606@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PHJtpJ4hw3KWJhobipK8ia --- .../execution/Float16ConversionProbe.cpp | 8 ++++++++ .../src/SpirvAlu/SpirvAluEmitterMath.cpp | 20 ++++++++++++++----- 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/core/libs/prx/libSceAgcDriver/tests/execution/Float16ConversionProbe.cpp b/core/libs/prx/libSceAgcDriver/tests/execution/Float16ConversionProbe.cpp index a629b8549f7b..6a753b6029d0 100644 --- a/core/libs/prx/libSceAgcDriver/tests/execution/Float16ConversionProbe.cpp +++ b/core/libs/prx/libSceAgcDriver/tests/execution/Float16ConversionProbe.cpp @@ -118,18 +118,24 @@ std::vector Run(AgcDriver::VulkanDevice& device, ShaderRecompiler struct Spirv { std::uint32_t selects = 0; std::uint32_t floatConverts = 0; + std::uint32_t widenings = 0; }; Spirv Scan(std::span words) { Spirv scan; + std::vector f32Types; + std::vector widenings; for (std::size_t cursor = 5; cursor < words.size();) { const auto count = words[cursor] >> 16u; const auto opcode = words[cursor] & 0xffffu; Require(count != 0 && cursor + count <= words.size(), "f16 conversion probe: malformed SPIR-V"); if (opcode == spv::OpSelect) ++scan.selects; if (opcode == spv::OpFConvert) ++scan.floatConverts; + if (opcode == spv::OpTypeFloat && count >= 3u && words[cursor + 2u] == 32u) f32Types.push_back(words[cursor + 1u]); + if (opcode == spv::OpFConvert && count >= 4u && std::ranges::find(f32Types, words[cursor + 1u]) != f32Types.end()) widenings.push_back(words[cursor + 2u]); cursor += count; } + scan.widenings = static_cast(widenings.size()); return scan; } @@ -175,6 +181,8 @@ void VerifySpirv() { const auto narrowNative = scan(NarrowCode, true); Require(narrowEmulated.floatConverts == 1u && narrowNative.floatConverts == 1u, "f16 conversion probe: v_cvt_f16_f32 does not use one OpFConvert"); Require(narrowNative.selects + 1u == narrowEmulated.selects, "f16 conversion probe: v_cvt_f16_f32 with float16NarrowNans has " + std::to_string(narrowNative.selects) + " selects, without " + std::to_string(narrowEmulated.selects) + ", expected one fewer"); + const auto towardZero = scan(NarrowTowardZeroCode, true); + Require(towardZero.widenings == 0u, "f16 conversion probe: v_cvt_pkrtz_f16_f32 with float16NarrowNans widens " + std::to_string(towardZero.widenings) + " f16 values to f32 with OpFConvert, which drivers fold with the narrowing"); } struct Tally { diff --git a/core/shader/recompiler/SpirvBackend/src/SpirvAlu/SpirvAluEmitterMath.cpp b/core/shader/recompiler/SpirvBackend/src/SpirvAlu/SpirvAluEmitterMath.cpp index f6413785cf90..40f938af7b0f 100644 --- a/core/shader/recompiler/SpirvBackend/src/SpirvAlu/SpirvAluEmitterMath.cpp +++ b/core/shader/recompiler/SpirvBackend/src/SpirvAlu/SpirvAluEmitterMath.cpp @@ -1036,12 +1036,22 @@ static bool NativeF16Rte(const SpirvEmitterState& state); static std::uint32_t EmitF32ToF16BitsTowardZero(SpirvEmitterState& state, std::uint32_t value) { if (!NativeF16Rte(state)) return EmitF32ToF16RtzBits(state, value); const auto u32 = TypeU32(state); - const auto f16 = state.module.Type(spv::OpTypeFloat, 16u); const auto nearest = EmitF32ToF16BitsRte(state, value); - const auto half = state.module.AllocateId(); - state.module.AddFunction(spv::OpCompositeExtract, f16, half, Unary(state, spv::OpBitcast, state.module.Type(spv::OpTypeVector, f16, 2u), nearest), 0u); - const auto widened = Unary(state, spv::OpFConvert, TypeF32(state), half); - const auto away = Binary(state, spv::OpFOrdGreaterThan, TypeBool(state), EmitFAbsValue(state, widened), EmitFAbsValue(state, value)); + if (!state.float16NarrowNans) { + const auto f16 = state.module.Type(spv::OpTypeFloat, 16u); + const auto half = state.module.AllocateId(); + state.module.AddFunction(spv::OpCompositeExtract, f16, half, Unary(state, spv::OpBitcast, state.module.Type(spv::OpTypeVector, f16, 2u), nearest), 0u); + const auto widenedHalf = Unary(state, spv::OpFConvert, TypeF32(state), half); + const auto awayHalf = Binary(state, spv::OpFOrdGreaterThan, TypeBool(state), EmitFAbsValue(state, widenedHalf), EmitFAbsValue(state, value)); + return Select(state, u32, awayHalf, Binary(state, spv::OpISub, u32, nearest, ConstantU32(state, 1u)), nearest); + } + const auto magnitude = Binary(state, spv::OpBitwiseAnd, u32, nearest, ConstantU32(state, 0x7fffu)); + const auto normal = Binary(state, spv::OpShiftLeftLogical, u32, Binary(state, spv::OpIAdd, u32, magnitude, ConstantU32(state, 0x1c000u)), ConstantU32(state, 13u)); + const auto denormal = Unary(state, spv::OpBitcast, u32, Exact(state, Binary(state, spv::OpFMul, TypeF32(state), Unary(state, spv::OpConvertUToF, TypeF32(state), magnitude), ConstantF32(state, 0x33800000u)))); + const auto finite = Select(state, u32, Binary(state, spv::OpIEqual, TypeBool(state), magnitude, ConstantU32(state, 0x7c00u)), ConstantU32(state, 0x7f800000u), normal); + const auto widened = Select(state, u32, Binary(state, spv::OpULessThan, TypeBool(state), magnitude, ConstantU32(state, 0x400u)), denormal, finite); + const auto source = Binary(state, spv::OpBitwiseAnd, u32, Unary(state, spv::OpBitcast, u32, value), ConstantU32(state, 0x7fffffffu)); + const auto away = Binary(state, spv::OpUGreaterThan, TypeBool(state), widened, source); return Select(state, u32, away, Binary(state, spv::OpISub, u32, nearest, ConstantU32(state, 1u)), nearest); } From 674016744723b1856bd34f01f4fdcd208cd885e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0?= Date: Wed, 28 Oct 2026 11:26:39 +0100 Subject: [PATCH 5/5] docs(shader): name serialization version 54 for the f16 conversion NaN fields Co-Authored-By: Claude Sonnet 5.5 --- docs/dev/ARCHITECTURE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/dev/ARCHITECTURE.md b/docs/dev/ARCHITECTURE.md index 670eb73e1111..01ef7299bf8a 100644 --- a/docs/dev/ARCHITECTURE.md +++ b/docs/dev/ARCHITECTURE.md @@ -232,4 +232,4 @@ A device with `shaderDenormPreserveFloat32` and an f32 denormal behaviour indepe ### Native f16 conversion NaN results -A device with native f16 conversions (`Float16`, `RoundingModeRTE` and `DenormPreserve` for 16-bit floats) also runs a probe when it is created (`ProbeFloat16Conversions`). `OpFConvert` and `UnpackHalf2x16` round and handle denormals as RDNA does under those modes, but Vulkan leaves the NaN they return to the driver, so the emitter rebuilds RDNA's NaN: sign and payload kept from the f16 input by `v_cvt_f32_f16`, quiet bit set and payload truncated by `v_cvt_f16_f32`. The probe compiles `v_cvt_f32_f16` over the 65,536 halves, and `v_cvt_f16_f32`, `v_cvt_pkrtz_f16_f32` and a `v_cvt_f16_f32` then `v_cvt_f32_f16` round trip over every f32 NaN payload class, signalling NaNs, infinities, denormals, ties and overflow, with no float mode, with every denormal kept, with every denormal flushed and with a directed f16 rounding mode, each twice: with the rebuilt NaN and with the native result. Inputs come from a buffer, so the driver cannot fold them. `SpirvTarget::float16WidenNans` and `float16NarrowNans` are set per direction when every result matches, and the emitter then returns the native result. Both are part of the cache key and of the serialized request (version 49), so a saved request replays with the conversions of the device that wrote it. `APS5_NO_NATIVE_F16_NANS` skips the probe. +A device with native f16 conversions (`Float16`, `RoundingModeRTE` and `DenormPreserve` for 16-bit floats) also runs a probe when it is created (`ProbeFloat16Conversions`). `OpFConvert` and `UnpackHalf2x16` round and handle denormals as RDNA does under those modes, but Vulkan leaves the NaN they return to the driver, so the emitter rebuilds RDNA's NaN: sign and payload kept from the f16 input by `v_cvt_f32_f16`, quiet bit set and payload truncated by `v_cvt_f16_f32`. The probe compiles `v_cvt_f32_f16` over the 65,536 halves, and `v_cvt_f16_f32`, `v_cvt_pkrtz_f16_f32` and a `v_cvt_f16_f32` then `v_cvt_f32_f16` round trip over every f32 NaN payload class, signalling NaNs, infinities, denormals, ties and overflow, with no float mode, with every denormal kept, with every denormal flushed and with a directed f16 rounding mode, each twice: with the rebuilt NaN and with the native result. Inputs come from a buffer, so the driver cannot fold them. `SpirvTarget::float16WidenNans` and `float16NarrowNans` are set per direction when every result matches, and the emitter then returns the native result. Both are part of the cache key and of the serialized request (version 54), so a saved request replays with the conversions of the device that wrote it. `APS5_NO_NATIVE_F16_NANS` skips the probe.