[SpirvTranslator] Follow guest NaN and saturation rules for float ops

v_min_f32 and v_max_f32 return the other operand when one of them is NaN, and
v_cvt_u32_f32 and v_cvt_i32_f32 saturate an out-of-range value to the target
type's limit and turn NaN into zero. The GLSL FMin and FMax instructions and
SPIR-V's ConvertFToU and ConvertFToS all leave those cases undefined, so a
host driver is free to hand back the NaN or an arbitrary integer, and Metal's
fast math does return the NaN.

The minimum and maximum now select the other operand when either side is NaN,
and the two conversions clamp at the type's limits and return zero for NaN.
NaN is tested on the value's bits rather than with an ordered comparison, so
fast math cannot assume the test away.

Device tests cover this and the wave-wide maximum that Unreal's motion blur
computes with DPP row_shr, v_permlanex16_b32 and v_readlane_b32 across a
64-lane wave.
This commit is contained in:
kuba 2026-09-29 23:19:26 +02:00
parent 4d65ff6d51
commit a02e8636ef
3 changed files with 218 additions and 12 deletions

View File

@ -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(

View File

@ -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<HeadlessVulkanFixture>
{
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, GpuBuffer[]> { [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();
}
}

View File

@ -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<HeadlessVulkanFixture>
{
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, GpuBuffer[]> { [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<byte> 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<byte> source) => false;
}
}