diff --git a/src/SharpEmu.ShaderCompiler.Vulkan/Gen5SpirvTranslator.Alu.cs b/src/SharpEmu.ShaderCompiler.Vulkan/Gen5SpirvTranslator.Alu.cs index 3a41b8d..ae770cd 100644 --- a/src/SharpEmu.ShaderCompiler.Vulkan/Gen5SpirvTranslator.Alu.cs +++ b/src/SharpEmu.ShaderCompiler.Vulkan/Gen5SpirvTranslator.Alu.cs @@ -147,10 +147,7 @@ public static partial class Gen5SpirvTranslator break; } case "VCvtU32F32": - result = _module.AddInstruction( - SpirvOp.ConvertFToU, - _uintType, - GetFloatSource(instruction, 0)); + result = ConvertFloatToUnsignedSaturated(GetFloatSource(instruction, 0)); break; case "VCvtU16F16": { @@ -185,9 +182,7 @@ public static partial class Gen5SpirvTranslator source = Ext(8, _floatType, source); } - result = Bitcast( - _uintType, - _module.AddInstruction(SpirvOp.ConvertFToS, _intType, source)); + result = ConvertFloatToSignedSaturated(source); break; } case "VCvtF32I32": @@ -4440,14 +4435,15 @@ public static partial class Gen5SpirvTranslator _module.AddInstruction(operation, _floatType, left, right)); } + // v_min_f32 and v_max_f32 return the other operand when one is NaN. GLSL FMin and FMax + // leave that undefined, and Metal's fast math returns the NaN. private uint EmitFloatExtBinary( Gen5ShaderInstruction instruction, uint operation) => EmitFloatResult( instruction, - Ext( + NanIgnoringMinMax( operation, - _floatType, GetFloatSource(instruction, 0), GetFloatSource(instruction, 1))); @@ -4455,14 +4451,62 @@ public static partial class Gen5SpirvTranslator Gen5ShaderInstruction instruction, uint operation) { - var first = Ext( + var first = NanIgnoringMinMax( operation, - _floatType, GetFloatSource(instruction, 0), GetFloatSource(instruction, 1)); return EmitFloatResult( instruction, - Ext(operation, _floatType, first, GetFloatSource(instruction, 2))); + NanIgnoringMinMax(operation, first, GetFloatSource(instruction, 2))); + } + + private uint NanIgnoringMinMax(uint operation, uint left, uint right) + { + var result = Ext(operation, _floatType, left, right); + result = _module.AddInstruction(SpirvOp.Select, _floatType, IsNanBits(right), left, result); + return _module.AddInstruction(SpirvOp.Select, _floatType, IsNanBits(left), right, result); + } + + // NaN tested on the bits, which fast math cannot assume away. + private uint IsNanBits(uint value) => + _module.AddInstruction( + SpirvOp.UGreaterThan, + _boolType, + BitwiseAnd(Bitcast(_uintType, value), UInt(0x7FFF_FFFF)), + UInt(0x7F80_0000)); + + // v_cvt_u32_f32 and v_cvt_i32_f32 saturate and convert NaN to zero; SPIR-V leaves + // out-of-range conversions undefined. + private uint ConvertFloatToUnsignedSaturated(uint value) + { + var bits = Bitcast(_uintType, value); + var converted = _module.AddInstruction(SpirvOp.ConvertFToU, _uintType, value); + converted = _module.AddInstruction( + SpirvOp.Select, _uintType, + _module.AddInstruction(SpirvOp.UGreaterThanEqual, _boolType, bits, UInt(0x4F80_0000)), + UInt(uint.MaxValue), + converted); + // Negative values, including negative NaN, have the sign bit set. + converted = _module.AddInstruction( + SpirvOp.Select, _uintType, + _module.AddInstruction(SpirvOp.UGreaterThanEqual, _boolType, bits, UInt(0x8000_0000)), + UInt(0), + converted); + return _module.AddInstruction(SpirvOp.Select, _uintType, IsNanBits(value), UInt(0), converted); + } + + private uint ConvertFloatToSignedSaturated(uint value) + { + var bits = Bitcast(_uintType, value); + var magnitude = BitwiseAnd(bits, UInt(0x7FFF_FFFF)); + var converted = Bitcast(_uintType, _module.AddInstruction(SpirvOp.ConvertFToS, _intType, value)); + var negative = _module.AddInstruction(SpirvOp.UGreaterThanEqual, _boolType, bits, UInt(0x8000_0000)); + converted = _module.AddInstruction( + SpirvOp.Select, _uintType, + _module.AddInstruction(SpirvOp.UGreaterThanEqual, _boolType, magnitude, UInt(0x4F00_0000)), + _module.AddInstruction(SpirvOp.Select, _uintType, negative, UInt(0x8000_0000), UInt(0x7FFF_FFFF)), + converted); + return _module.AddInstruction(SpirvOp.Select, _uintType, IsNanBits(value), UInt(0), converted); } private uint EmitIntegerBinary( diff --git a/tests/SharpEmu.Libs.Tests/Gpu/Vulkan/FloatNanSemanticsDeviceTests.cs b/tests/SharpEmu.Libs.Tests/Gpu/Vulkan/FloatNanSemanticsDeviceTests.cs new file mode 100644 index 0000000..3c5345d --- /dev/null +++ b/tests/SharpEmu.Libs.Tests/Gpu/Vulkan/FloatNanSemanticsDeviceTests.cs @@ -0,0 +1,67 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using System.Buffers.Binary; +using SharpEmu.Libs.Gpu.Buffers; +using SharpEmu.Libs.Tests.Gpu.Images; +using SharpEmu.ShaderCompiler; +using SharpEmu.ShaderCompiler.Resources; +using SharpEmu.ShaderCompiler.Vulkan; +using Xunit; +using static SharpEmu.ShaderCompiler.Tests.Resources.ResourceTestProgram; + +namespace SharpEmu.Libs.Tests.Gpu.Vulkan; + +// v_cvt_u32_f32 and v_cvt_i32_f32 saturate and turn NaN into zero, and v_min_f32 and +// v_max_f32 return the other operand when one is NaN. UE's motion blur turns a NaN velocity +// into a sample loop bound this way; with host-defined results the loop never ends. +public sealed class FloatNanSemanticsDeviceTests(HeadlessVulkanFixture fixture) : IClassFixture +{ + private const uint QuietNan = 0x7FC0_0000; + private const uint NegativeNan = 0xFFC0_0000; + private const uint PositiveInfinity = 0x7F80_0000; + + [Theory] + [InlineData(QuietNan, 0x4000_0000u, 0u, 0u, 0x4000_0000u, 0x4000_0000u)] + [InlineData(NegativeNan, 0x4080_0000u, 0u, 0u, 0x4080_0000u, 0x4080_0000u)] + [InlineData(0x4000_0000u, QuietNan, 2u, 2u, 0x4000_0000u, 0x4000_0000u)] + [InlineData(PositiveInfinity, 0x3F80_0000u, uint.MaxValue, 0x7FFF_FFFFu, 0x3F80_0000u, PositiveInfinity)] + [InlineData(0xBF80_0000u, 0x3F80_0000u, 0u, 0xFFFF_FFFFu, 0xBF80_0000u, 0x3F80_0000u)] + [InlineData(0x4F95_02F9u, 0x3F80_0000u, uint.MaxValue, 0x7FFF_FFFFu, 0x3F80_0000u, 0x4F95_02F9u)] + [InlineData(0xCF32_D05Eu, 0x3F80_0000u, 0u, 0x8000_0000u, 0xCF32_D05Eu, 0x3F80_0000u)] + [InlineData(0x406C_CCCDu, 0x3F80_0000u, 3u, 3u, 0x3F80_0000u, 0x406C_CCCDu)] + public void ConversionsAndMinMaxFollowTheGuestNanRules( + uint value, uint other, uint unsignedResult, uint signedResult, uint minimum, uint maximum) + { + var vulkan = fixture.Vulkan; + if (!GatePrerequisites.Ready(vulkan, shaderInt64: true)) return; + var program = Program( + MoveVectorFromScalar(0, 1, 9), + Vop1(4, "VCvtU32F32", 2, Gen5Operand.Scalar(8)), + Vop1(8, "VCvtI32F32", 3, Gen5Operand.Scalar(8)), + Vop2(12, "VMinF32", 5, Gen5Operand.Scalar(8), Gen5Operand.Vector(1)), + Vop2(16, "VMaxF32", 6, Gen5Operand.Scalar(8), Gen5Operand.Vector(1)), + BufferAccess(20, "BufferStoreDword", 4, offset: 0, vectorData: 2), + BufferAccess(28, "BufferStoreDword", 4, offset: 4, vectorData: 3), + BufferAccess(36, "BufferStoreDword", 4, offset: 8, vectorData: 5), + BufferAccess(44, "BufferStoreDword", 4, offset: 12, vectorData: 6), + EndProgram(52)); + var (plan, resources, layout) = Prepare(program); + var request = new ShaderCompileRequest(plan, resources, layout) { LocalSizeX = 1, ThreadCountX = 1 }; + Assert.True(Gen5SpirvTranslator.TryCompileProgram(request, out var shader, out var error), error); + using var harness = new ImageTestHarness(vulkan); + using var runner = new LayoutComputeRunner(harness, request, shader.Spirv); + var result = runner.CreateBuffer(64); + var registers = new uint[256]; + registers[6] = 64; + registers[8] = value; + registers[9] = other; + harness.Run(() => runner.Dispatch(registers, + new Dictionary { [DescriptorBindingKind.Buffers] = [result] }, 1)); + var bytes = runner.ReadBack(result, 0, 4 * sizeof(uint)); + Assert.Equal( + [unsignedResult, signedResult, minimum, maximum], + Enumerable.Range(0, 4).Select(index => BinaryPrimitives.ReadUInt32LittleEndian(bytes.AsSpan(index * sizeof(uint)))).ToArray()); + harness.AssertNoValidationMessages(); + } +} diff --git a/tests/SharpEmu.Libs.Tests/Gpu/Vulkan/WaveMaxReductionDeviceTests.cs b/tests/SharpEmu.Libs.Tests/Gpu/Vulkan/WaveMaxReductionDeviceTests.cs new file mode 100644 index 0000000..b97025d --- /dev/null +++ b/tests/SharpEmu.Libs.Tests/Gpu/Vulkan/WaveMaxReductionDeviceTests.cs @@ -0,0 +1,95 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using System.Buffers.Binary; +using SharpEmu.HLE; +using SharpEmu.Libs.Gpu.Buffers; +using SharpEmu.Libs.Tests.Gpu.Images; +using SharpEmu.ShaderCompiler; +using SharpEmu.ShaderCompiler.Resources; +using SharpEmu.ShaderCompiler.Vulkan; +using Xunit; +using static SharpEmu.ShaderCompiler.Tests.Resources.ResourceTestProgram; + +namespace SharpEmu.Libs.Tests.Gpu.Vulkan; + +// The wave-wide maximum UE's motion blur takes of its per-lane sample counts: row_shr 1, 2, +// 4 and 8 through DPP, v_permlanex16_b32 across the row pairs, then v_readlane_b32 of lanes +// 31 and 63. The result bounds a sample loop, so a wrong maximum can hang the GPU. +public sealed class WaveMaxReductionDeviceTests(HeadlessVulkanFixture fixture) : IClassFixture +{ + private static readonly uint[] Reduction = + [ + 0x280404FA, 0xFF011102, // v_max_u32_dpp v2, v2, v2 row_shr:1 + 0x280404FA, 0xFF011202, // row_shr:2 + 0x280404FA, 0xFF011402, // row_shr:4 + 0x280404FA, 0xFF011802, // row_shr:8 + 0xD7781004, 0x03058302, // v_permlanex16_b32 v4, v2, -1, -1 + 0x28040902, // v_max_u32 v2, v2, v4 + 0xD760000A, 0x00013F02, // v_readlane_b32 s10, v2, 31 + 0xD760000B, 0x00017F02, // v_readlane_b32 s11, v2, 63 + 0x84940B0A, // s_max_u32 s20, s10, s11 + ]; + + [Theory] + [InlineData(0x7E040300u, 63u)] // v_mov_b32 v2, v0: the maximum is in the last lane + [InlineData(0x4C0400BFu, 63u)] // v_sub_nc_u32 v2, 63, v0: the maximum is in lane 0 + public void ReductionYieldsTheWaveMaximum(uint prepareValue, uint expected) + { + var vulkan = fixture.Vulkan; + if (!GatePrerequisites.Ready(vulkan, shaderInt64: true)) return; + uint[] words = + [ + prepareValue, + .. Reduction, + 0x7E060214, // v_mov_b32 v3, s20 + 0x340A0082, // v_lshlrev_b32 v5, 2, v0 + 0xE0701000, 0x80010305, // buffer_store_dword v3, v5, s[4:7], 0 offen + 0xBF810000, // s_endpgm + ]; + var bytes = new byte[words.Length * sizeof(uint)]; + for (var index = 0; index < words.Length; index++) + { + BinaryPrimitives.WriteUInt32LittleEndian(bytes.AsSpan(index * sizeof(uint)), words[index]); + } + + var context = new CpuContext(new InstructionMemory(bytes), Generation.Gen5); + Assert.True(Gen5ShaderTranslator.TryDecodeProgram(context, 0x1000, out var program, out var decodeError), decodeError); + var (plan, resources, layout) = Prepare(program); + var request = new ShaderCompileRequest(plan, resources, layout) + { + LocalSizeX = 64, ThreadCountX = 64, WaveSize = 64, + }; + Assert.True(Gen5SpirvTranslator.TryCompileProgram(request, out var shader, out var error), error); + using var harness = new ImageTestHarness(vulkan); + using var runner = new LayoutComputeRunner(harness, request, shader.Spirv); + var output = runner.CreateBuffer(256); + var registers = new uint[256]; + registers[6] = 256; + harness.Run(() => runner.Dispatch(registers, + new Dictionary { [DescriptorBindingKind.Buffers] = [output] }, 1)); + var result = runner.ReadBack(output, 0, 256); + for (var lane = 0; lane < 64; lane++) + { + Assert.Equal(expected, BinaryPrimitives.ReadUInt32LittleEndian(result.AsSpan(lane * sizeof(uint)))); + } + + harness.AssertNoValidationMessages(); + } + + private sealed class InstructionMemory(byte[] bytes) : ICpuMemory + { + public bool TryRead(ulong address, Span destination) + { + if (address < 0x1000 || address - 0x1000 + (ulong)destination.Length > (ulong)bytes.Length) + { + return false; + } + + bytes.AsSpan((int)(address - 0x1000), destination.Length).CopyTo(destination); + return true; + } + + public bool TryWrite(ulong address, ReadOnlySpan source) => false; + } +}