From da0953845bb624e71fbe39844052201ed92d2867 Mon Sep 17 00:00:00 2001 From: kuba Date: Wed, 7 Oct 2026 10:51:57 +0200 Subject: [PATCH] [SpirvTranslator/Vk] Cheaper guest buffer loads and f16 conversion, gated on device probes (#1035) * [SpirvTranslator] Load dword-aligned 32-bit formatted buffer components as words A formatted untyped buffer load decodes the descriptor format at run time, reading every component byte by byte through the generic layout select chain. For dword-aligned 32-bit data formats (32, 32_32, 32_32_32, 32_32_32_32) with a raw-bit number format (UINT, SINT, FLOAT) a component is one buffer word; that branch loads it directly and applies the same swizzle and bounds rules. The descriptor is uniform, so the branch does not diverge. UE GPU-skinning vertex shaders fetch bone data this way; Silent Hill: The Short Message's menu goes from 1.3 to 2.0 FPS. (cherry picked from commit 20540cd153855964d817daf065de3e243fd547bd) * [SpirvTranslator] Convert f16 with the native pack/unpack when the host measured it exact Every packed-f16 (VOP3P) lane widens its operands to f32 and rounds the result back, and both conversions were the branchless integer sequences in EmitHalfToFloat / EmitFloatToHalf: about twenty scalar ops each, because GLSL UnpackHalf2x16 / PackHalf2x16 have no guaranteed rounding or subnormal behaviour without float-controls execution modes. A device capability bit does not settle it either - MoltenVK on Apple silicon reports shaderRoundingModeRTEFloat16 and shaderSignedZeroInfNanPreserveFloat16 yet not shaderDenormPreserveFloat16 - so only a measurement can. ShaderCompileRequest.NativeHalfConversionExact, which the host sets only after measuring the two ext instructions against these sequences on the running device, swaps both helpers for the single ext instruction the driver lowers to one hardware convert. It defaults to false, so an unmeasured device keeps the exact emulation. Silent Hill f2 volumetric fog dispatch: 30.7 -> 21.9 ms per dispatch in replay, output unchanged. (cherry picked from commit 10f76eeac0d4306172e41ecff86b737f6a3c9634) (cherry picked from commit 7f18cc96ae58ac5fd47da6562c4ba3abe22ccb4a) (cherry picked from commit 972f658033f8339effe42c5d305af87bcbab93f0) * [Vk] Probe f16 conversion at device setup and report the verdict A one-time compute pass at device setup measures the device's GLSL UnpackHalf2x16 / PackHalf2x16 against the translator's own f16 conversion before any guest shader is compiled, and only a clean measurement lets the translator use them (ShaderCompileRequest.NativeHalfConversionExact). The vectors cover all 65536 f16 bit patterns, every f16 value widened to f32, the midpoint between adjacent f16 values and that midpoint one f32 ulp either way, the overflow edge, signed zeroes, infinities, NaNs and f32 subnormals, and each is measured in both directions: 643200 comparisons. Every finite value, zero and infinity must be bit-exact, sign included. A NaN only has to stay a NaN, because neither side defines its payload: the emulation picks sign | 0x7C00 | 0x200 narrowing and carries the f16 payload widening, while the measured hardware convert canonicalizes to a positive quiet NaN. On an Apple M4 that is the only disagreement - 2314 of 643200, every one a negative NaN whose sign the hardware cleared - and f16 subnormals convert exactly despite shaderDenormPreserveFloat16 being false. The probe never fails device setup: any error leaves the exact emulation in place and says so on the reported line. SHARPEMU_NATIVE_HALF=0 forces the emulation, =1 takes native without measuring. The result is one line: [VK][F16] native half conversion exact=True mismatches=0 tested=643200 The flag reaches the translator through IShaderPipelineHost, so it is process-constant and cannot mix two translations of one program; the saved driver pipeline cache keys its entries by the module it was given, and the Metal host does not probe and keeps the emulation. Offline replays run the same probe, so a case translates the way the game would. (cherry picked from commit 6a343683d890b4c6d123c84fdacb41e718ec4c19) (cherry picked from commit 65ca663aa2bfb1fcc69709c73ab6189b07ead3c1) (cherry picked from commit 003b1216bd311e5c5fb1d48ca01a66efa3fc51de) * [SpirvTranslator] Read an unaligned buffer word as a funnel shift of two words The byte-wise decode of a formatted buffer load read each byte of a component from its own bounds-checked word load: four loads, four range tests and a byte assembly for a value that spans at most two words. One funnel shift of those two words is the same value, including out of range, where a word reads zero on both paths and so contributes zero bytes. Every sub-dword buffer load goes through the same helper. The decode is the branch a formatted load takes when its address is not dword aligned, so its size also sets the register pressure of the aligned branch next to it. Silent Hill: The Short Message's volumetric fog dispatch (0xD5DF9DC46C4E35EA, 8 formatted loads in its light loop) goes from 8.68 to 7.28 ms in offline replay, and the module from 394 to 360 KiB. An experiment that dropped the byte-wise branch entirely measured 7.35 ms, so the funnel shift is worth all of what the branch cost. (cherry picked from commit 0c996ffab38144d795517e68cb2ca9d8d48e8988) * [SpirvTranslator] Load a guest buffer word unchecked where the device zeroes out-of-range reads Every guest buffer word is read through a range test, an address clamp and a zero select: three operations whose only purpose is to produce the zero the hardware returns past the end of a V#. A device with robustBufferAccess2 that honours it returns that zero itself, so the three are pure overhead there - dropping them measured about 12% of Silent Hill's volumetric fog dispatch, 482 checked words in one program. ShaderCompileRequest.ZeroOutOfBoundsBufferReads turns them off and defaults to false: an out-of-range read is undefined without the promise, so only a host that measured the device may set it. It is set from a device probe, never from a capability bit, because MoltenVK runs on Metal, which has no hardware bounds checking. (cherry picked from commit 2ba626a0ac7157f93f348cfe7573ff1092762d8c) * [Vk] Probe out-of-range buffer reads at device setup and report the verdict A one-time compute pass binds 1 KiB of a 4 KiB allocation that carries a sentinel everywhere and reads all 4 KiB back through the descriptor: the words inside the range must still read their sentinel and the words past it must read zero. Only a clean measurement lets the translator drop the range test, address clamp and zero select it puts around every guest buffer word (ShaderCompileRequest.ZeroOutOfBoundsBufferReads). The probe only runs where the device enabled robustBufferAccess2, because an out-of-range read is undefined without it and can fault the device; everywhere else it reports the reason and the checks stay. On this Apple M4 through MoltenVK the feature is not supported at all, so nothing changes: [VK][OOB] out-of-range buffer reads zero=False mismatches=unprobed tested=0 note=robustBufferAccess2 is not enabled on this device SHARPEMU_ZERO_OOB_READS=0 keeps the checks, =1 drops them without measuring. The flag reaches the translator through IShaderPipelineHost, so it is process-constant and cannot mix two translations of one program. (cherry picked from commit e81d924349042acc1122a66d4c767268bd6ef2a4) --- .../Metal/MetalCommandStreamHost.Rendering.cs | 2 + .../Gpu/Pipelines/IShaderPipelineHost.cs | 8 + .../Gpu/Pipelines/ShaderProgramCache.cs | 18 +- .../Gpu/Vulkan/NativeHalfConversionProbe.cs | 392 ++++++++++++++++++ .../Gpu/Vulkan/ZeroOutOfBoundsReadProbe.cs | 242 +++++++++++ .../VulkanVideoPresenter.Device.Setup.cs | 72 ++++ .../VulkanVideoPresenter.Pipelines.cs | 4 + .../VulkanVideoPresenter.ShaderPrewarm.cs | 3 +- .../VideoOut/VulkanVideoPresenter.cs | 16 + .../Gen5SpirvTranslator.Alu.cs | 36 +- .../Gen5SpirvTranslator.Resources.cs | 7 + .../Gen5SpirvTranslator.cs | 135 ++++-- .../SpirvFixedShaders.cs | 131 ++++++ .../ShaderCompileRequest.cs | 16 + .../Gpu/Pipelines/ShaderPrewarmListTests.cs | 4 +- .../NativeHalfConversionProbeDeviceTests.cs | 64 +++ .../Gen5Float16ArithmeticTests.cs | 73 ++++ .../Resources/BufferBoundsCheckTests.cs | 58 +++ .../Resources/BufferFormatLoadTests.cs | 59 +++ .../Resources/BufferUnalignedWordTests.cs | 44 ++ 20 files changed, 1344 insertions(+), 40 deletions(-) create mode 100644 src/SharpEmu.Libs/Gpu/Vulkan/NativeHalfConversionProbe.cs create mode 100644 src/SharpEmu.Libs/Gpu/Vulkan/ZeroOutOfBoundsReadProbe.cs create mode 100644 tests/SharpEmu.Libs.Tests/Gpu/Vulkan/NativeHalfConversionProbeDeviceTests.cs create mode 100644 tests/SharpEmu.ShaderCompiler.Tests/Resources/BufferBoundsCheckTests.cs create mode 100644 tests/SharpEmu.ShaderCompiler.Tests/Resources/BufferFormatLoadTests.cs create mode 100644 tests/SharpEmu.ShaderCompiler.Tests/Resources/BufferUnalignedWordTests.cs diff --git a/src/SharpEmu.Libs/Gpu/Metal/MetalCommandStreamHost.Rendering.cs b/src/SharpEmu.Libs/Gpu/Metal/MetalCommandStreamHost.Rendering.cs index 1e5b709..d179b07 100644 --- a/src/SharpEmu.Libs/Gpu/Metal/MetalCommandStreamHost.Rendering.cs +++ b/src/SharpEmu.Libs/Gpu/Metal/MetalCommandStreamHost.Rendering.cs @@ -166,6 +166,8 @@ internal sealed partial class MetalCommandStreamHost : IRenderHost, IShaderPipel // Metal has no 64-bit workgroup atomics here; keep the non-atomic 32-bit pair. bool IShaderPipelineHost.SharedInt64AtomicsEnabled => false; + // The Metal backend runs no f16 conversion probe, so it keeps the exact emulation. + bool IShaderPipelineHost.NativeHalfConversionExact => false; RenderHostLimits IShaderPipelineHost.Limits => new(MaxDimension, MaxDimension, MaxDimension, MaxDimension); diff --git a/src/SharpEmu.Libs/Gpu/Pipelines/IShaderPipelineHost.cs b/src/SharpEmu.Libs/Gpu/Pipelines/IShaderPipelineHost.cs index a120cba..998efcc 100644 --- a/src/SharpEmu.Libs/Gpu/Pipelines/IShaderPipelineHost.cs +++ b/src/SharpEmu.Libs/Gpu/Pipelines/IShaderPipelineHost.cs @@ -46,6 +46,14 @@ internal interface IShaderPipelineHost bool PerVertexPixelInputsSupported => true; bool ClipDistanceEnabled => false; + // True when this device's GLSL UnpackHalf2x16 / PackHalf2x16 were measured bit-exact + // against the translator's own f16 conversion. False for every host that did not measure it. + bool NativeHalfConversionExact => false; + + // True only when the host measured that a storage-buffer read past its descriptor range + // returns zero on this device; the translator then stops bounds-checking every guest word. + bool ZeroOutOfBoundsBufferReads => false; + RenderHostLimits Limits { get; } // The sample counts a pipeline without attachments can rasterize at. diff --git a/src/SharpEmu.Libs/Gpu/Pipelines/ShaderProgramCache.cs b/src/SharpEmu.Libs/Gpu/Pipelines/ShaderProgramCache.cs index 34f61c3..5972ae1 100644 --- a/src/SharpEmu.Libs/Gpu/Pipelines/ShaderProgramCache.cs +++ b/src/SharpEmu.Libs/Gpu/Pipelines/ShaderProgramCache.cs @@ -593,6 +593,8 @@ internal sealed class ShaderProgramCache { var enableGraphicsSubgroups = _host.GraphicsSubgroupOperationsEnabled; var sharedInt64Atomics = _host.SharedInt64AtomicsEnabled; + var nativeHalfConversion = _host.NativeHalfConversionExact; + var zeroOutOfBoundsReads = _host.ZeroOutOfBoundsBufferReads; switch (source.Stage) { case ShaderStage.Vertex: @@ -605,6 +607,8 @@ internal sealed class ShaderProgramCache ScratchDwords = info.ScratchDwords, EnableGraphicsSubgroupOperations = enableGraphicsSubgroups, SupportsSharedInt64Atomics = sharedInt64Atomics, + NativeHalfConversionExact = nativeHalfConversion, + ZeroOutOfBoundsBufferReads = zeroOutOfBoundsReads, RequiredVertexOutputCount = options.RequiredVertexOutputCount, VertexInputs = entry.VertexInputs, PositionExportControl = info.PositionExportControl, @@ -632,6 +636,8 @@ internal sealed class ShaderProgramCache ScratchDwords = info.ScratchDwords, EnableGraphicsSubgroupOperations = enableGraphicsSubgroups, SupportsSharedInt64Atomics = sharedInt64Atomics, + NativeHalfConversionExact = nativeHalfConversion, + ZeroOutOfBoundsBufferReads = zeroOutOfBoundsReads, PixelOutputs = options.PixelOutputs, PixelInputEnable = options.PixelInputEnable, PixelCustomInterpolationMask = info.CustomInterpolationMask, @@ -643,7 +649,7 @@ internal sealed class ShaderProgramCache default: return BuildComputeRequest(entry.Plan, resources, layout, options.ComputeInfo!, options.ComputeSystemRegisters, - sharedInt64Atomics, _host.ExecGuardElisionEnabled); + sharedInt64Atomics, _host.ExecGuardElisionEnabled, nativeHalfConversion, zeroOutOfBoundsReads); } } @@ -659,9 +665,12 @@ internal sealed class ShaderProgramCache usesDispatchThreadLimits: usesDispatchThreadLimits); private static ShaderCompileRequest BuildComputeRequest(ShaderResourcePlan plan, SpecializedResourceInfo resources, BindingLayout layout, - ComputeInputInfo info, Gen5ComputeSystemRegisters? systemRegisters, bool sharedInt64Atomics, bool execGuardElision) => + ComputeInputInfo info, Gen5ComputeSystemRegisters? systemRegisters, bool sharedInt64Atomics, bool execGuardElision, + bool nativeHalfConversion, bool zeroOutOfBoundsReads) => new(plan, resources, layout) { + NativeHalfConversionExact = nativeHalfConversion, + ZeroOutOfBoundsBufferReads = zeroOutOfBoundsReads, WaveSize = info.WaveSize, EnableExecGuardElision = info.WaveSize != 64 || execGuardElision, TraceDeviceAddressFaults = SharpEmu.HLE.GpuMemory.GuestGpuMemoryHook.TraceEnabled, @@ -675,7 +684,8 @@ internal sealed class ShaderProgramCache }; internal static bool TryCompilePrewarm(ComputePrewarmRecord record, ShaderCodeCapture code, IGuestGpuBackend compiler, - bool sharedInt64Atomics, bool execGuardElision, out IGuestCompiledShader? compiled, out BindingLayout? layout, out string error) + bool sharedInt64Atomics, bool execGuardElision, bool nativeHalfConversion, bool zeroOutOfBoundsReads, + out IGuestCompiledShader? compiled, out BindingLayout? layout, out string error) { compiled = null; layout = null; @@ -692,7 +702,7 @@ internal sealed class ShaderProgramCache layout = AllocateLayout(program, plan, resources, record.UserDataBase, record.UserDataCount, record.PushDataCursor, record.Info.DispatchThreadDimensions); var request = BuildComputeRequest(plan, resources, layout, record.Info, record.SystemRegisters, - sharedInt64Atomics, execGuardElision); + sharedInt64Atomics, execGuardElision, nativeHalfConversion, zeroOutOfBoundsReads); return compiler.TryCompileProgram(request, out compiled, out error) && compiled is not null; } catch (Exception exception) diff --git a/src/SharpEmu.Libs/Gpu/Vulkan/NativeHalfConversionProbe.cs b/src/SharpEmu.Libs/Gpu/Vulkan/NativeHalfConversionProbe.cs new file mode 100644 index 0000000..c8dbb1e --- /dev/null +++ b/src/SharpEmu.Libs/Gpu/Vulkan/NativeHalfConversionProbe.cs @@ -0,0 +1,392 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using System.Numerics; +using System.Runtime.InteropServices; +using SharpEmu.Libs.Gpu.Buffers; +using SharpEmu.Libs.Gpu.Scheduling; +using SharpEmu.ShaderCompiler.Vulkan; +using Silk.NET.Vulkan; + +namespace SharpEmu.Libs.Gpu.Vulkan; + +// The CPU mirror of the two integer f16 conversion sequences the Gen5 translator emits by +// default, plus the vectors the device probe measures the native GLSL ext instructions with. +// Kept next to the probe because it is the reference the probe compares against: native is +// only used when it reproduces these results bit for bit. +internal static class HalfConversionReference +{ + // Mirrors Gen5SpirvTranslator.EmitHalfToFloat: the f32 bits of the f16 in the low 16 bits. + public static uint HalfToFloatBits(uint halfBits) + { + var sign = (halfBits & 0x8000u) << 16; + var exponent = (halfBits >> 10) & 0x1Fu; + var mantissa = halfBits & 0x3FFu; + + var normal = ((exponent + 112u) << 23) | (mantissa << 13); + var infinityNan = 0x7F80_0000u | (mantissa << 13); + + var highBit = (uint)BitOperations.Log2(mantissa | 1u); + var shift = (int)(23u - highBit); + var subFraction = (mantissa << shift) & 0x7F_FFFFu; + var subnormal = mantissa != 0 ? (((highBit + 103u) << 23) | subFraction) : 0u; + + var magnitude = exponent == 0 ? subnormal : exponent == 31 ? infinityNan : normal; + return sign | magnitude; + } + + // Mirrors Gen5SpirvTranslator.EmitFloatToHalf: the f16 bits, round to nearest even. + // NaN collapses to one quiet payload (sign | 0x7C00 | 0x200) by construction. + public static uint FloatToHalfBits(uint bits) + { + var sign = (bits >> 16) & 0x8000u; + var absolute = bits & 0x7FFF_FFFFu; + + var isInfinityNan = absolute >= 0x7F80_0000u; + var isNan = absolute > 0x7F80_0000u; + var infinityNan = sign | 0x7C00u | (isNan ? 0x200u : 0u); + + var exponent = absolute >> 23; + var mantissa = absolute & 0x7F_FFFFu; + var significand = mantissa | 0x80_0000u; + + var roundBit = (significand >> 13) & 1u; + var rounded = (significand + 0xFFFu + roundBit) >> 13; + var halfExponent = exponent - 112u; + var normalBits = (halfExponent << 10) + (rounded - 0x400u); + var normal = exponent >= 113u ? normalBits >= 0x7C00u ? 0x7C00u : normalBits : 0u; + + var distance = 126u - exponent; + var shift = (int)(distance > 25u ? 25u : distance); + var shiftMask = (1u << shift) - 1u; + var halfWay = 1u << (shift - 1); + var lowBits = significand & shiftMask; + var quotient = significand >> shift; + var roundUp = lowBits > halfWay || (lowBits == halfWay && (quotient & 1u) != 0u); + var subnormal = quotient + (roundUp ? 1u : 0u); + + var finite = exponent <= 112u ? subnormal : normal; + return isInfinityNan ? infinityNan : sign | finite; + } + + // Every f16 bit pattern, every f16 value widened to f32, the midpoint between adjacent + // f16 values and that midpoint one f32 ulp either way (the rounding decisions), the + // overflow boundary, signed zeroes, infinities, NaNs and f32 subnormals. Padded to a + // multiple of 64 so the probe dispatch needs no bounds check. Each entry is measured + // twice: as the f16 in its low 16 bits, and as an f32 bit pattern. + public static uint[] BuildTestVectors() + { + var values = new List(1 << 19); + for (uint bits = 0; bits <= 0xFFFFu; bits++) + { + values.Add(bits); + values.Add(HalfToFloatBits(bits)); + } + + // 0x7BFF is the largest finite f16; pair it with its successor to cover the overflow edge. + for (uint bits = 0; bits <= 0x7BFFu; bits++) + { + var low = (double)BitConverter.UInt32BitsToSingle(HalfToFloatBits(bits)); + var high = (double)BitConverter.UInt32BitsToSingle(HalfToFloatBits(bits + 1)); + var midpoint = BitConverter.SingleToUInt32Bits((float)(low + (high - low) / 2)); + for (var offset = -1; offset <= 1; offset++) + { + var candidate = (uint)(midpoint + offset); + values.Add(candidate); + values.Add(candidate | 0x8000_0000u); + } + } + + float[] specials = + [ + 0f, -0f, 1f, -1f, 65504f, -65504f, 65520f, -65520f, 65536f, -65536f, + 1e30f, -1e30f, float.MaxValue, float.MinValue, float.Epsilon, -float.Epsilon, + float.PositiveInfinity, float.NegativeInfinity, + 5.96046448e-8f, 6.09755516e-5f, -6.09755516e-5f, 3.05175781e-5f, 2.98023224e-8f, + ]; + foreach (var value in specials) + { + values.Add(BitConverter.SingleToUInt32Bits(value)); + } + + uint[] rawSpecials = + [ + 0x7F80_0001u, 0xFF80_0001u, 0x7FC0_0000u, 0xFFC0_0000u, 0x7FFF_FFFFu, 0xFFFF_FFFFu, + 0x0000_0001u, 0x8000_0001u, 0x007F_FFFFu, 0x807F_FFFFu, 0x0080_0000u, + ]; + values.AddRange(rawSpecials); + + while (values.Count % 64 != 0) + { + values.Add(0); + } + + return [.. values]; + } + + // The probe's match rule. Every finite value, every zero and every infinity must be + // bit-exact, sign included. A NaN only has to stay a NaN: the payload and the sign of a + // NaN are not defined by either side. The emulation picks its own payload for f32->f16 + // (sign | 0x7C00 | 0x200) and carries the f16 payload through for f16->f32, while the + // measured hardware convert canonicalizes to a positive quiet NaN and drops the sign + // (Apple M4: every one of the 2314 disagreements out of 643200 is a negative NaN whose + // sign the hardware cleared). Nothing a shader computes from a NaN can see that + // difference short of inspecting the bits, and the alternative is paying ~20 integer + // ops on every f16 conversion in the program to define a NaN payload the ISA leaves open. + public static bool Matches(uint expected, uint actual, bool asHalf) + { + if (expected == actual) + { + return true; + } + + return asHalf + ? IsHalfNan(expected) && IsHalfNan(actual) + : IsFloatNan(expected) && IsFloatNan(actual); + } + + private static bool IsHalfNan(uint bits) => (bits & 0x7C00u) == 0x7C00u && (bits & 0x3FFu) != 0; + + private static bool IsFloatNan(uint bits) => (bits & 0x7F80_0000u) == 0x7F80_0000u && (bits & 0x7F_FFFFu) != 0; +} + +// Runs the fixed f16 conversion probe once per device: the native GLSL UnpackHalf2x16 / +// PackHalf2x16 are used by the translator only when this says the device reproduces the +// emulator's own conversion. Never fatal: a failure leaves the exact emulation in place. +internal static unsafe class NativeHalfConversionProbe +{ + private const int ReportedExamples = 6; + + // Note carries the reason the probe could not run at all; Detail the first few disagreements. + public readonly record struct Result(bool Exact, int Mismatches, int Tested, string? Note, string? Detail = null); + + // SHARPEMU_NATIVE_HALF=0 forces the emulation, =1 forces native without probing. + public static bool? ReadOverride() => + Environment.GetEnvironmentVariable("SHARPEMU_NATIVE_HALF") switch + { + "0" => false, + "1" => true, + _ => null, + }; + + public static Result Run(GpuDeviceInfo device, SubmissionScheduler scheduler, Func beginCommand) + { + var vectors = HalfConversionReference.BuildTestVectors(); + try + { + var results = Measure(device, scheduler, beginCommand, vectors); + var mismatches = 0; + var examples = new List(ReportedExamples); + for (var index = 0; index < vectors.Length; index++) + { + var wide = HalfConversionReference.HalfToFloatBits(vectors[index] & 0xFFFFu); + if (!HalfConversionReference.Matches(wide, results[2 * index], asHalf: false)) + { + mismatches++; + if (examples.Count < ReportedExamples) + { + examples.Add($"h2f(0x{vectors[index] & 0xFFFF:X4})=0x{results[2 * index]:X8} want 0x{wide:X8}"); + } + } + + var narrow = HalfConversionReference.FloatToHalfBits(vectors[index]); + if (!HalfConversionReference.Matches(narrow, results[2 * index + 1], asHalf: true)) + { + mismatches++; + if (examples.Count < ReportedExamples) + { + examples.Add($"f2h(0x{vectors[index]:X8})=0x{results[2 * index + 1]:X4} want 0x{narrow:X4}"); + } + } + } + + return new Result( + mismatches == 0, + mismatches, + 2 * vectors.Length, + null, + examples.Count == 0 ? null : string.Join("; ", examples)); + } + catch (Exception exception) + { + return new Result(false, -1, 2 * vectors.Length, exception.Message); + } + } + + private static uint[] Measure( + GpuDeviceInfo device, + SubmissionScheduler scheduler, + Func beginCommand, + uint[] vectors) + { + var vk = device.Vk; + var inputBytes = (ulong)vectors.Length * sizeof(uint); + var outputBytes = 2 * inputBytes; + + using var input = new GpuBuffer(device, scheduler, GpuBufferUsage.Upload, 0, GpuBuffer.AllFlags, inputBytes); + using var output = new GpuBuffer(device, scheduler, GpuBufferUsage.Download, 0, GpuBuffer.AllFlags, outputBytes); + MemoryMarshal.AsBytes(vectors).CopyTo(input.Mapped); + input.Flush(0, inputBytes); + output.Mapped.Clear(); + output.Flush(0, outputBytes); + + var bindings = stackalloc DescriptorSetLayoutBinding[2]; + for (uint index = 0; index < 2; index++) + { + bindings[index] = new DescriptorSetLayoutBinding + { + Binding = index, + DescriptorType = DescriptorType.StorageBuffer, + DescriptorCount = 1, + StageFlags = ShaderStageFlags.ComputeBit, + }; + } + + var layoutInfo = new DescriptorSetLayoutCreateInfo + { + SType = StructureType.DescriptorSetLayoutCreateInfo, + BindingCount = 2, + PBindings = bindings, + }; + Require(vk.CreateDescriptorSetLayout(device.Device, &layoutInfo, null, out var setLayout), "descriptor layout"); + + DescriptorPool pool = default; + PipelineLayout pipelineLayout = default; + Pipeline pipeline = default; + try + { + var poolSize = new DescriptorPoolSize { Type = DescriptorType.StorageBuffer, DescriptorCount = 2 }; + var poolInfo = new DescriptorPoolCreateInfo + { + SType = StructureType.DescriptorPoolCreateInfo, + MaxSets = 1, + PoolSizeCount = 1, + PPoolSizes = &poolSize, + }; + Require(vk.CreateDescriptorPool(device.Device, &poolInfo, null, out pool), "descriptor pool"); + + var boundLayout = setLayout; + var allocateInfo = new DescriptorSetAllocateInfo + { + SType = StructureType.DescriptorSetAllocateInfo, + DescriptorPool = pool, + DescriptorSetCount = 1, + PSetLayouts = &boundLayout, + }; + Require(vk.AllocateDescriptorSets(device.Device, &allocateInfo, out DescriptorSet set), "descriptor set"); + + var infos = stackalloc DescriptorBufferInfo[2]; + infos[0] = new DescriptorBufferInfo(input.Handle, 0, inputBytes); + infos[1] = new DescriptorBufferInfo(output.Handle, 0, outputBytes); + var writes = stackalloc WriteDescriptorSet[2]; + for (uint binding = 0; binding < 2; binding++) + { + writes[binding] = new WriteDescriptorSet + { + SType = StructureType.WriteDescriptorSet, + DstSet = set, + DstBinding = binding, + DescriptorCount = 1, + DescriptorType = DescriptorType.StorageBuffer, + PBufferInfo = infos + binding, + }; + } + + vk.UpdateDescriptorSets(device.Device, 2, writes, 0, null); + + var pipelineLayoutInfo = new PipelineLayoutCreateInfo + { + SType = StructureType.PipelineLayoutCreateInfo, + SetLayoutCount = 1, + PSetLayouts = &boundLayout, + }; + Require(vk.CreatePipelineLayout(device.Device, &pipelineLayoutInfo, null, out pipelineLayout), "pipeline layout"); + + var spirv = SpirvFixedShaders.CreateHalfConversionProbe(); + ShaderModule module; + fixed (byte* code = spirv) + { + var moduleInfo = new ShaderModuleCreateInfo + { + SType = StructureType.ShaderModuleCreateInfo, + CodeSize = (nuint)spirv.Length, + PCode = (uint*)code, + }; + Require(vk.CreateShaderModule(device.Device, &moduleInfo, null, out module), "shader module"); + } + + ReadOnlySpan entryPoint = "main\0"u8; + Silk.NET.Vulkan.Result created; + fixed (byte* entry = entryPoint) + { + var pipelineInfo = new ComputePipelineCreateInfo + { + SType = StructureType.ComputePipelineCreateInfo, + Layout = pipelineLayout, + Stage = new PipelineShaderStageCreateInfo + { + SType = StructureType.PipelineShaderStageCreateInfo, + Stage = ShaderStageFlags.ComputeBit, + Module = module, + PName = entry, + }, + }; + created = vk.CreateComputePipelines(device.Device, default, 1, &pipelineInfo, null, out pipeline); + } + + vk.DestroyShaderModule(device.Device, module, null); + Require(created, "compute pipeline"); + + var command = beginCommand(); + vk.CmdBindPipeline(command, PipelineBindPoint.Compute, pipeline); + var boundSet = set; + vk.CmdBindDescriptorSets(command, PipelineBindPoint.Compute, pipelineLayout, 0, 1, &boundSet, 0, null); + vk.CmdDispatch(command, (uint)vectors.Length / 64, 1, 1); + var barrier = new BufferMemoryBarrier2 + { + SType = StructureType.BufferMemoryBarrier2, + SrcAccessMask = AccessFlags2.ShaderWriteBit, + DstAccessMask = AccessFlags2.HostReadBit, + SrcQueueFamilyIndex = Vk.QueueFamilyIgnored, + DstQueueFamilyIndex = Vk.QueueFamilyIgnored, + Buffer = output.Handle, + Offset = 0, + Size = outputBytes, + }; + VulkanSynchronization.PipelineBarrier(vk, command, + PipelineStageFlags.ComputeShaderBit, PipelineStageFlags.HostBit, DependencyFlags.None, + 0, null, 1, &barrier, 0, null); + scheduler.FlushAndWait(); + + output.Invalidate(0, outputBytes); + return MemoryMarshal.Cast(output.Mapped).ToArray(); + } + finally + { + if (pipeline.Handle != 0) + { + vk.DestroyPipeline(device.Device, pipeline, null); + } + + if (pipelineLayout.Handle != 0) + { + vk.DestroyPipelineLayout(device.Device, pipelineLayout, null); + } + + if (pool.Handle != 0) + { + vk.DestroyDescriptorPool(device.Device, pool, null); + } + + vk.DestroyDescriptorSetLayout(device.Device, setLayout, null); + } + } + + private static void Require(Silk.NET.Vulkan.Result result, string operation) + { + if (result != Silk.NET.Vulkan.Result.Success) + { + throw new InvalidOperationException($"The f16 conversion probe could not create its {operation}: {result}"); + } + } +} diff --git a/src/SharpEmu.Libs/Gpu/Vulkan/ZeroOutOfBoundsReadProbe.cs b/src/SharpEmu.Libs/Gpu/Vulkan/ZeroOutOfBoundsReadProbe.cs new file mode 100644 index 0000000..9caec72 --- /dev/null +++ b/src/SharpEmu.Libs/Gpu/Vulkan/ZeroOutOfBoundsReadProbe.cs @@ -0,0 +1,242 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using System.Runtime.InteropServices; +using SharpEmu.Libs.Gpu.Buffers; +using SharpEmu.Libs.Gpu.Scheduling; +using SharpEmu.ShaderCompiler.Vulkan; +using Silk.NET.Vulkan; + +namespace SharpEmu.Libs.Gpu.Vulkan; + +// Measures whether a storage-buffer read past the end of its descriptor range reads zero on this +// device. Every guest buffer word the translator emits carries a range test, an address clamp and +// a zero select, which exist only to produce that zero; with robustBufferAccess2 the device owes +// it already and the three operations are pure overhead. The capability bit alone does not settle +// it — MoltenVK runs on Metal, which has no hardware bounds checking — so the probe reads a bound +// range it deliberately undershoots and checks the words past it. Never run on a device that did +// not enable robustBufferAccess2: an out-of-range read there is undefined and can fault. +internal static unsafe class ZeroOutOfBoundsReadProbe +{ + // The descriptor binds this many words of a larger allocation; the words past it must read + // zero, and the words inside it must still read the sentinel the probe wrote. + private const uint BoundWords = 256; + private const uint ProbeWords = 1024; + private const uint Sentinel = 0xA5C3_F00Du; + + public readonly record struct Result(bool Zeroed, int Mismatches, int Tested, string? Note); + + // SHARPEMU_ZERO_OOB_READS=0 keeps the per-access checks, =1 drops them without measuring. + public static bool? ReadOverride() => + Environment.GetEnvironmentVariable("SHARPEMU_ZERO_OOB_READS") switch + { + "0" => false, + "1" => true, + _ => null, + }; + + public static Result Run(GpuDeviceInfo device, SubmissionScheduler scheduler, Func beginCommand) + { + try + { + var results = Measure(device, scheduler, beginCommand); + var mismatches = 0; + for (var index = 0u; index < ProbeWords; index++) + { + var expected = index < BoundWords ? Sentinel + index : 0u; + if (results[index] != expected) + { + mismatches++; + } + } + + return new Result(mismatches == 0, mismatches, (int)ProbeWords, null); + } + catch (Exception exception) + { + return new Result(false, -1, (int)ProbeWords, exception.Message); + } + } + + private static uint[] Measure(GpuDeviceInfo device, SubmissionScheduler scheduler, Func beginCommand) + { + var vk = device.Vk; + var allocationBytes = (ulong)ProbeWords * sizeof(uint); + var boundBytes = (ulong)BoundWords * sizeof(uint); + var outputBytes = allocationBytes; + + // The allocation is larger than the bound range and carries a sentinel everywhere, so a + // device that simply ignores the range reads the sentinel back instead of zero. + using var input = new GpuBuffer(device, scheduler, GpuBufferUsage.Upload, 0, GpuBuffer.AllFlags, allocationBytes); + using var output = new GpuBuffer(device, scheduler, GpuBufferUsage.Download, 0, GpuBuffer.AllFlags, outputBytes); + var words = new uint[ProbeWords]; + for (var index = 0u; index < ProbeWords; index++) + { + words[index] = Sentinel + index; + } + + MemoryMarshal.AsBytes(words).CopyTo(input.Mapped); + input.Flush(0, allocationBytes); + output.Mapped.Clear(); + output.Flush(0, outputBytes); + + var bindings = stackalloc DescriptorSetLayoutBinding[2]; + for (uint index = 0; index < 2; index++) + { + bindings[index] = new DescriptorSetLayoutBinding + { + Binding = index, + DescriptorType = DescriptorType.StorageBuffer, + DescriptorCount = 1, + StageFlags = ShaderStageFlags.ComputeBit, + }; + } + + var layoutInfo = new DescriptorSetLayoutCreateInfo + { + SType = StructureType.DescriptorSetLayoutCreateInfo, + BindingCount = 2, + PBindings = bindings, + }; + Require(vk.CreateDescriptorSetLayout(device.Device, &layoutInfo, null, out var setLayout), "descriptor layout"); + + DescriptorPool pool = default; + PipelineLayout pipelineLayout = default; + Pipeline pipeline = default; + try + { + var poolSize = new DescriptorPoolSize { Type = DescriptorType.StorageBuffer, DescriptorCount = 2 }; + var poolInfo = new DescriptorPoolCreateInfo + { + SType = StructureType.DescriptorPoolCreateInfo, + MaxSets = 1, + PoolSizeCount = 1, + PPoolSizes = &poolSize, + }; + Require(vk.CreateDescriptorPool(device.Device, &poolInfo, null, out pool), "descriptor pool"); + + var boundLayout = setLayout; + var allocateInfo = new DescriptorSetAllocateInfo + { + SType = StructureType.DescriptorSetAllocateInfo, + DescriptorPool = pool, + DescriptorSetCount = 1, + PSetLayouts = &boundLayout, + }; + Require(vk.AllocateDescriptorSets(device.Device, &allocateInfo, out DescriptorSet set), "descriptor set"); + + var infos = stackalloc DescriptorBufferInfo[2]; + infos[0] = new DescriptorBufferInfo(input.Handle, 0, boundBytes); + infos[1] = new DescriptorBufferInfo(output.Handle, 0, outputBytes); + var writes = stackalloc WriteDescriptorSet[2]; + for (uint binding = 0; binding < 2; binding++) + { + writes[binding] = new WriteDescriptorSet + { + SType = StructureType.WriteDescriptorSet, + DstSet = set, + DstBinding = binding, + DescriptorCount = 1, + DescriptorType = DescriptorType.StorageBuffer, + PBufferInfo = infos + binding, + }; + } + + vk.UpdateDescriptorSets(device.Device, 2, writes, 0, null); + + var pipelineLayoutInfo = new PipelineLayoutCreateInfo + { + SType = StructureType.PipelineLayoutCreateInfo, + SetLayoutCount = 1, + PSetLayouts = &boundLayout, + }; + Require(vk.CreatePipelineLayout(device.Device, &pipelineLayoutInfo, null, out pipelineLayout), "pipeline layout"); + + var spirv = SpirvFixedShaders.CreateUncheckedBufferCopyProbe(); + ShaderModule module; + fixed (byte* code = spirv) + { + var moduleInfo = new ShaderModuleCreateInfo + { + SType = StructureType.ShaderModuleCreateInfo, + CodeSize = (nuint)spirv.Length, + PCode = (uint*)code, + }; + Require(vk.CreateShaderModule(device.Device, &moduleInfo, null, out module), "shader module"); + } + + ReadOnlySpan entryPoint = "main\0"u8; + Silk.NET.Vulkan.Result created; + fixed (byte* entry = entryPoint) + { + var pipelineInfo = new ComputePipelineCreateInfo + { + SType = StructureType.ComputePipelineCreateInfo, + Layout = pipelineLayout, + Stage = new PipelineShaderStageCreateInfo + { + SType = StructureType.PipelineShaderStageCreateInfo, + Stage = ShaderStageFlags.ComputeBit, + Module = module, + PName = entry, + }, + }; + created = vk.CreateComputePipelines(device.Device, default, 1, &pipelineInfo, null, out pipeline); + } + + vk.DestroyShaderModule(device.Device, module, null); + Require(created, "compute pipeline"); + + var command = beginCommand(); + vk.CmdBindPipeline(command, PipelineBindPoint.Compute, pipeline); + var boundSet = set; + vk.CmdBindDescriptorSets(command, PipelineBindPoint.Compute, pipelineLayout, 0, 1, &boundSet, 0, null); + vk.CmdDispatch(command, ProbeWords / 64, 1, 1); + var barrier = new BufferMemoryBarrier2 + { + SType = StructureType.BufferMemoryBarrier2, + SrcAccessMask = AccessFlags2.ShaderWriteBit, + DstAccessMask = AccessFlags2.HostReadBit, + SrcQueueFamilyIndex = Vk.QueueFamilyIgnored, + DstQueueFamilyIndex = Vk.QueueFamilyIgnored, + Buffer = output.Handle, + Offset = 0, + Size = outputBytes, + }; + VulkanSynchronization.PipelineBarrier(vk, command, + PipelineStageFlags.ComputeShaderBit, PipelineStageFlags.HostBit, DependencyFlags.None, + 0, null, 1, &barrier, 0, null); + scheduler.FlushAndWait(); + + output.Invalidate(0, outputBytes); + return MemoryMarshal.Cast(output.Mapped).ToArray(); + } + finally + { + if (pipeline.Handle != 0) + { + vk.DestroyPipeline(device.Device, pipeline, null); + } + + if (pipelineLayout.Handle != 0) + { + vk.DestroyPipelineLayout(device.Device, pipelineLayout, null); + } + + if (pool.Handle != 0) + { + vk.DestroyDescriptorPool(device.Device, pool, null); + } + + vk.DestroyDescriptorSetLayout(device.Device, setLayout, null); + } + } + + private static void Require(Silk.NET.Vulkan.Result result, string operation) + { + if (result != Silk.NET.Vulkan.Result.Success) + { + throw new InvalidOperationException($"The out-of-range read probe could not create its {operation}: {result}"); + } + } +} diff --git a/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.Device.Setup.cs b/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.Device.Setup.cs index da40015..74d3dca 100644 --- a/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.Device.Setup.cs +++ b/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.Device.Setup.cs @@ -8,6 +8,7 @@ using SharpEmu.Libs.Gpu.Buffers; using SharpEmu.Libs.Gpu.Pipelines; using SharpEmu.Libs.Gpu.Rendering; using SharpEmu.Libs.Gpu.Scheduling; +using SharpEmu.Libs.Gpu.Vulkan; using Silk.NET.Core; using Silk.NET.Core.Native; using Silk.NET.Vulkan; @@ -88,6 +89,8 @@ internal static unsafe partial class VulkanVideoPresenter CreateSwapchain(); CreateCommandResources(); CreateGuestDrawResources(); + ProbeNativeHalfConversion(); + ProbeZeroOutOfBoundsReads(); _vulkanReady = true; AttachGuestGpuMemory(); Console.Error.WriteLine( @@ -714,6 +717,73 @@ internal static unsafe partial class VulkanVideoPresenter return compute ?? general; } + // Measures GLSL UnpackHalf2x16 / PackHalf2x16 against the translator's own f16 conversion + // once per device, before any guest shader is compiled, so the answer is constant for the + // process. The spec does not promise it: MoltenVK reports RTE rounding and signed-zero / + // Inf / NaN preservation for f16 yet not denorm preservation, so only the device can say. + // Never fatal - anything that goes wrong leaves the per-access checks in place. The probe + // reads out of range on purpose, so it only runs where the device promised a zero: without + // robustBufferAccess2 an out-of-range read is undefined and may fault the device. + private void ProbeZeroOutOfBoundsReads() + { + if (Interlocked.Exchange(ref _zeroOutOfBoundsReadsProbed, 1) != 0) + { + return; + } + + var forced = ZeroOutOfBoundsReadProbe.ReadOverride(); + if (forced is not null) + { + Volatile.Write(ref _zeroOutOfBoundsReads, forced.Value ? 1 : 0); + Console.Error.WriteLine( + $"[VK][OOB] out-of-range buffer reads zero={forced.Value} mismatches=unprobed tested=0 " + + "(SHARPEMU_ZERO_OOB_READS)"); + return; + } + + if (!_robustBufferAccess2Enabled) + { + Console.Error.WriteLine( + "[VK][OOB] out-of-range buffer reads zero=False mismatches=unprobed tested=0 " + + "note=robustBufferAccess2 is not enabled on this device"); + return; + } + + var result = ZeroOutOfBoundsReadProbe.Run(_deviceInfo, _scheduler, CurrentRecordingBuffer); + Volatile.Write(ref _zeroOutOfBoundsReads, result.Zeroed ? 1 : 0); + Console.Error.WriteLine( + $"[VK][OOB] out-of-range buffer reads zero={result.Zeroed} " + + $"mismatches={result.Mismatches} tested={result.Tested}" + + (result.Note is null ? string.Empty : $" note={result.Note}")); + } + + // Never fatal - anything that goes wrong leaves the exact emulation in place. + private void ProbeNativeHalfConversion() + { + if (Interlocked.Exchange(ref _nativeHalfConversionProbed, 1) != 0) + { + return; + } + + var forced = NativeHalfConversionProbe.ReadOverride(); + if (forced is not null) + { + Volatile.Write(ref _nativeHalfConversionExact, forced.Value ? 1 : 0); + Console.Error.WriteLine( + $"[VK][F16] native half conversion exact={forced.Value} mismatches=unprobed tested=0 " + + "(SHARPEMU_NATIVE_HALF)"); + return; + } + + var result = NativeHalfConversionProbe.Run(_deviceInfo, _scheduler, CurrentRecordingBuffer); + Volatile.Write(ref _nativeHalfConversionExact, result.Exact ? 1 : 0); + Console.Error.WriteLine( + $"[VK][F16] native half conversion exact={result.Exact} " + + $"mismatches={result.Mismatches} tested={result.Tested}" + + (result.Note is null ? string.Empty : $" note={result.Note}") + + (result.Detail is null ? string.Empty : $" first={result.Detail}")); + } + private bool _supportsFragmentShaderBarycentric; private bool _supportsPerVertexPixelInputs; private const string FragmentShaderBarycentricExtensionName = "VK_KHR_fragment_shader_barycentric"; @@ -1054,6 +1124,8 @@ internal static unsafe partial class VulkanVideoPresenter maintenance8Features.PNext = null; robustness2Features.RobustBufferAccess2 = supportsRobustBufferAccess2 && supportedFeatures.RobustBufferAccess; + // The feature only reaches the device when the robustness2 struct is chained. + _robustBufferAccess2Enabled = robustness2Features.RobustBufferAccess2 && supportsRobustness2; robustness2Features.RobustImageAccess2 = supportsRobustImageAccess2; robustness2Features.NullDescriptor = supportsNullDescriptor; robustness2Features.PNext = supportsMaintenance8 ? &maintenance8Features : null; diff --git a/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.Pipelines.cs b/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.Pipelines.cs index caa42db..2e305b9 100644 --- a/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.Pipelines.cs +++ b/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.Pipelines.cs @@ -92,6 +92,10 @@ internal static unsafe partial class VulkanVideoPresenter bool IShaderPipelineHost.PerVertexPixelInputsSupported => _supportsPerVertexPixelInputs; bool IShaderPipelineHost.ClipDistanceEnabled => _supportsShaderClipDistance; + bool IShaderPipelineHost.NativeHalfConversionExact => NativeHalfConversionExact; + + bool IShaderPipelineHost.ZeroOutOfBoundsBufferReads => ZeroOutOfBoundsBufferReads; + RenderHostLimits IShaderPipelineHost.Limits => _renderHostLimits; SampleCountFlags IShaderPipelineHost.NoAttachmentSampleCounts => _noAttachmentSampleCounts; diff --git a/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.ShaderPrewarm.cs b/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.ShaderPrewarm.cs index 43a040b..fd749b8 100644 --- a/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.ShaderPrewarm.cs +++ b/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.ShaderPrewarm.cs @@ -162,7 +162,8 @@ internal static unsafe partial class VulkanVideoPresenter bool execGuardElision) { if (!ShaderProgramCache.TryCompilePrewarm( - record, code, compiler, sharedInt64Atomics, execGuardElision, out var compiled, out var layout, out var error)) + record, code, compiler, sharedInt64Atomics, execGuardElision, NativeHalfConversionExact, + ZeroOutOfBoundsBufferReads, out var compiled, out var layout, out var error)) { NoteShaderPrewarmFailure(record, error); return; diff --git a/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.cs b/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.cs index c9a8d52..a862abe 100644 --- a/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.cs +++ b/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.cs @@ -97,6 +97,22 @@ internal static unsafe partial class VulkanVideoPresenter (ShaderStageFlags)Volatile.Read(ref _nativeSubgroupShaderStages), Environment.GetEnvironmentVariable("SHARPEMU_GRAPHICS_SUBGROUPS")); + private static int _nativeHalfConversionExact; + private static int _nativeHalfConversionProbed; + private static int _zeroOutOfBoundsReads; + private static int _zeroOutOfBoundsReadsProbed; + private static bool _robustBufferAccess2Enabled; + + // Set once per process by the device-setup probe: GLSL UnpackHalf2x16 / PackHalf2x16 produced + // exactly what the translator's integer f16 conversion produces, for every test vector. False + // until then, so a device that is never probed keeps the exact emulation. + internal static bool NativeHalfConversionExact => Volatile.Read(ref _nativeHalfConversionExact) != 0; + + // Set once per process by the device-setup probe: a storage-buffer read past the end of its + // descriptor range returned zero on this device, so the translator's own range test, address + // clamp and zero select on every guest buffer word are redundant. False until then. + internal static bool ZeroOutOfBoundsBufferReads => Volatile.Read(ref _zeroOutOfBoundsReads) != 0; + private static void SetNativeSubgroupCapabilities(uint subgroupSize, ShaderStageFlags supportedStages) { Volatile.Write(ref _nativeSubgroupShaderStages, (int)supportedStages); diff --git a/src/SharpEmu.ShaderCompiler.Vulkan/Gen5SpirvTranslator.Alu.cs b/src/SharpEmu.ShaderCompiler.Vulkan/Gen5SpirvTranslator.Alu.cs index fee4353..9281f00 100644 --- a/src/SharpEmu.ShaderCompiler.Vulkan/Gen5SpirvTranslator.Alu.cs +++ b/src/SharpEmu.ShaderCompiler.Vulkan/Gen5SpirvTranslator.Alu.cs @@ -1660,10 +1660,17 @@ public static partial class Gen5SpirvTranslator } // Packed f16 (VOP3P) arithmetic. Each source register holds two f16 values, - // one per result lane. Every f16<->f32 conversion is done with the explicit - // integer sequences below (EmitHalfToFloat / EmitFloatToHalf) instead of - // GLSL UnpackHalf2x16 / PackHalf2x16, whose subnormal and rounding behaviour - // is implementation-defined without float-controls execution modes. The two + // one per result lane. Every f16<->f32 conversion goes through + // EmitHalfToFloat / EmitFloatToHalf, which default to the explicit integer + // sequences below rather than GLSL UnpackHalf2x16 / PackHalf2x16, whose + // subnormal and rounding behaviour is implementation-defined without + // float-controls execution modes. The host switches those two helpers to the + // native ext instructions (ShaderCompileRequest.NativeHalfConversionExact) only + // after measuring them bit-exact against these sequences on the running device, + // because a device capability bit does not promise that: MoltenVK on Apple + // silicon reports shaderRoundingModeRTEFloat16 and + // shaderSignedZeroInfNanPreserveFloat16 yet not shaderDenormPreserveFloat16. + // The two // lanes are computed independently: each operand half is widened exactly to // f32, op_sel/op_sel_hi pick the source half and neg_lo/neg_hi negate it, the // op runs in f32, and the result is rounded back to f16 with round-to-nearest- @@ -2038,8 +2045,18 @@ public static partial class Gen5SpirvTranslator // Widens an f16 value held in the low 16 bits of `halfBits` to an f32 bit // pattern, exactly (subnormals normalised, Inf/NaN and signed zero preserved). // Mirrors the branchless HalfToFloat reference validated against System.Half. + // With NativeHalfConversionExact the same value comes from GLSL UnpackHalf2x16, + // which the driver lowers to one hardware convert instead of ~20 integer ops. private uint EmitHalfToFloat(uint halfBits) { + if (_nativeHalfConversionExact) + { + var unpacked = Ext(62, _vec2Type, BitwiseAnd(halfBits, UInt(0xFFFF))); + return Bitcast( + _uintType, + _module.AddInstruction(SpirvOp.CompositeExtract, _floatType, unpacked, 0)); + } + var sign = ShiftLeftLogical(BitwiseAnd(halfBits, UInt(0x8000)), UInt(16)); var exponent = BitwiseAnd(ShiftRightLogical(halfBits, UInt(10)), UInt(0x1F)); var mantissa = BitwiseAnd(halfBits, UInt(0x3FF)); @@ -2069,8 +2086,19 @@ public static partial class Gen5SpirvTranslator // Narrows an f32 bit pattern to an f16 value in the low 16 bits, rounding to // nearest even (subnormals, overflow-to-Inf and NaN/Inf handled). Mirrors the // branchless FloatToHalf reference validated exhaustively against System.Half. + // With NativeHalfConversionExact the same value comes from GLSL PackHalf2x16. private uint EmitFloatToHalf(uint bits) { + if (_nativeHalfConversionExact) + { + var pair = _module.AddInstruction( + SpirvOp.CompositeConstruct, + _vec2Type, + Bitcast(_floatType, bits), + Float(0)); + return BitwiseAnd(Ext(58, _uintType, pair), UInt(0xFFFF)); + } + var sign = BitwiseAnd(ShiftRightLogical(bits, UInt(16)), UInt(0x8000)); var absolute = BitwiseAnd(bits, UInt(0x7FFF_FFFF)); diff --git a/src/SharpEmu.ShaderCompiler.Vulkan/Gen5SpirvTranslator.Resources.cs b/src/SharpEmu.ShaderCompiler.Vulkan/Gen5SpirvTranslator.Resources.cs index 9d682a8..c0fbe00 100644 --- a/src/SharpEmu.ShaderCompiler.Vulkan/Gen5SpirvTranslator.Resources.cs +++ b/src/SharpEmu.ShaderCompiler.Vulkan/Gen5SpirvTranslator.Resources.cs @@ -111,6 +111,8 @@ public static partial class Gen5SpirvTranslator _stage == Gen5SpirvStage.Pixel && request.Program.Instructions.Any(static instruction => instruction.Control is Gen5ExportControl { ValidMask: true }); _enableGraphicsSubgroupOperations = _stage == Gen5SpirvStage.Compute || request.EnableGraphicsSubgroupOperations; + _nativeHalfConversionExact = request.NativeHalfConversionExact; + _zeroOutOfBoundsBufferReads = request.ZeroOutOfBoundsBufferReads; _waveLaneCount = request.WaveSize == 64 ? 64u : 32u; _localSizeX = Math.Max(request.LocalSizeX, 1); _localSizeY = Math.Max(request.LocalSizeY, 1); @@ -416,6 +418,11 @@ public static partial class Gen5SpirvTranslator // A bounds-checked dword of a storage block; outside the block reads zero. private uint LoadBlockWord(uint block, uint dwordIndex) { + if (_zeroOutOfBoundsBufferReads) + { + return Load(_uintType, BlockWordPointer(block, dwordIndex)); + } + var length = _module.AddInstruction(SpirvOp.ArrayLength, _uintType, block, 0); var inRange = _module.AddInstruction(SpirvOp.ULessThan, _boolType, dwordIndex, length); var safeIndex = _module.AddInstruction(SpirvOp.Select, _uintType, inRange, dwordIndex, UInt(0)); diff --git a/src/SharpEmu.ShaderCompiler.Vulkan/Gen5SpirvTranslator.cs b/src/SharpEmu.ShaderCompiler.Vulkan/Gen5SpirvTranslator.cs index 4c19aa0..3f593d9 100644 --- a/src/SharpEmu.ShaderCompiler.Vulkan/Gen5SpirvTranslator.cs +++ b/src/SharpEmu.ShaderCompiler.Vulkan/Gen5SpirvTranslator.cs @@ -70,6 +70,8 @@ public static partial class Gen5SpirvTranslator private readonly IReadOnlyList _pixelOutputBindings; private readonly bool _usesPixelValidMask; private readonly bool _enableGraphicsSubgroupOperations; + private readonly bool _nativeHalfConversionExact; + private readonly bool _zeroOutOfBoundsBufferReads; private readonly uint _waveLaneCount; private readonly bool _emulateWave64; @@ -3920,21 +3922,85 @@ public static partial class Gen5SpirvTranslator ShiftRightLogical(descriptorWord3, UInt(12)), UInt(0x7F)); var (dataFormat, numberFormat) = DecodeGfx10BufferFormat(unifiedFormat); + var one = Gfx10FormatOne(numberFormat); - var canonical = new uint[4]; - var componentBounds = new uint[4]; - for (var component = 0; component < canonical.Length; component++) - { - canonical[component] = LoadGfx10BufferFormatComponent( - bindingIndex, - byteAddress, - dataFormat, - numberFormat, - component, - out componentBounds[component]); - } + // Dword-aligned 32-bit components whose number format keeps the raw bits (most + // vertex streams) load one word per component; every other format takes the + // generic byte-wise decode. The descriptor is uniform, so the branch never diverges. + var wordCount = UInt(0); + wordCount = SelectUInt(dataFormat, 4, UInt(1), wordCount); + wordCount = SelectUInt(dataFormat, 11, UInt(2), wordCount); + wordCount = SelectUInt(dataFormat, 13, UInt(3), wordCount); + wordCount = SelectUInt(dataFormat, 14, UInt(4), wordCount); + var rawBits = _module.AddInstruction( + SpirvOp.LogicalOr, _boolType, + _module.AddInstruction(SpirvOp.IEqual, _boolType, numberFormat, UInt(7)), + _module.AddInstruction( + SpirvOp.LogicalOr, _boolType, + _module.AddInstruction(SpirvOp.IEqual, _boolType, numberFormat, UInt(4)), + _module.AddInstruction(SpirvOp.IEqual, _boolType, numberFormat, UInt(5)))); + var wordPath = _module.AddInstruction( + SpirvOp.LogicalAnd, _boolType, + _module.AddInstruction( + SpirvOp.LogicalAnd, _boolType, + _module.AddInstruction(SpirvOp.INotEqual, _boolType, wordCount, UInt(0)), + rawBits), + _module.AddInstruction( + SpirvOp.IEqual, _boolType, BitwiseAnd(byteAddress, UInt(3)), UInt(0))); - // Only selected memory components contribute to the shared bounds check. + EmitConditional( + wordPath, + () => + { + var dwordAddress = ShiftRightLogical(byteAddress, UInt(2)); + var canonical = new uint[4]; + var componentBounds = new uint[4]; + for (uint component = 0; component < 4; component++) + { + var address = component == 0 ? dwordAddress : IAdd(dwordAddress, UInt(component)); + var present = _module.AddInstruction( + SpirvOp.ULessThan, _boolType, UInt(component), wordCount); + canonical[component] = _module.AddInstruction( + SpirvOp.Select, _uintType, present, + LoadBufferWord(bindingIndex, address), + component == 3 ? one : UInt(0)); + componentBounds[component] = _module.AddInstruction( + SpirvOp.LogicalOr, _boolType, + _module.AddInstruction(SpirvOp.LogicalNot, _boolType, present), + IsBufferWordInRange(bindingIndex, address)); + } + + StoreBufferFormatComponents(descriptorWord3, vectorData, componentCount, one, canonical, componentBounds); + }, + () => + { + var canonical = new uint[4]; + var componentBounds = new uint[4]; + for (var component = 0; component < canonical.Length; component++) + { + canonical[component] = LoadGfx10BufferFormatComponent( + bindingIndex, + byteAddress, + dataFormat, + numberFormat, + component, + out componentBounds[component]); + } + + StoreBufferFormatComponents(descriptorWord3, vectorData, componentCount, one, canonical, componentBounds); + }); + } + + // Applies the descriptor swizzle to the memory components; only selected memory + // components contribute to the shared bounds check. + private void StoreBufferFormatComponents( + uint descriptorWord3, + uint vectorData, + uint componentCount, + uint one, + uint[] canonical, + uint[] componentBounds) + { var selectors = new uint[componentCount]; var inBounds = _module.ConstantBool(true); for (uint destination = 0; destination < componentCount; destination++) @@ -3954,7 +4020,6 @@ public static partial class Gen5SpirvTranslator inBounds = _module.AddInstruction(SpirvOp.LogicalAnd, _boolType, inBounds, selectedInBounds); } - var one = Gfx10FormatOne(numberFormat); for (uint destination = 0; destination < componentCount; destination++) { var selector = selectors[destination]; @@ -4628,23 +4693,27 @@ public static partial class Gen5SpirvTranslator whenTrue, whenFalse); + // The four bytes at a byte address span at most two words, so one funnel shift of those + // two words is the whole value. Reading each byte from its own bounds-checked word load + // (four loads, four range tests and a byte assembly) costs twice as much for the same + // result: a word outside the buffer reads zero either way, so the bytes it would have + // contributed stay zero, and the formatted-load fallback does this per component. private uint LoadUnalignedBufferWord(int bindingIndex, uint byteAddress) { - var result = UInt(0); - for (uint index = 0; index < 4; index++) - { - var address = index == 0 - ? byteAddress - : IAdd(byteAddress, UInt(index)); - var dwordAddress = ShiftRightLogical(address, UInt(2)); - var bitOffset = ShiftLeftLogical(BitwiseAnd(address, UInt(3)), UInt(3)); - var value = BitwiseAnd( - ShiftRightLogical(LoadBufferWord(bindingIndex, dwordAddress), bitOffset), - UInt(0xFF)); - result = BitwiseOr(result, ShiftLeftLogical(value, UInt(index * 8))); - } - - return result; + var dwordAddress = ShiftRightLogical(byteAddress, UInt(2)); + var bitOffset = ShiftLeftLogical(BitwiseAnd(byteAddress, UInt(3)), UInt(3)); + var low = LoadBufferWord(bindingIndex, dwordAddress); + var high = LoadBufferWord(bindingIndex, IAdd(dwordAddress, UInt(1))); + // A shift by the word width is undefined, so the aligned case keeps the low word. + var spanning = BitwiseOr( + ShiftRightLogical(low, bitOffset), + ShiftLeftLogical(high, _module.AddInstruction(SpirvOp.ISub, _uintType, UInt(32), bitOffset))); + return _module.AddInstruction( + SpirvOp.Select, + _uintType, + IsNotZero(bitOffset), + spanning, + low); } private uint LoadSubdwordBufferValue( @@ -7441,6 +7510,14 @@ public static partial class Gen5SpirvTranslator private uint LoadBufferWord(int binding, uint dwordAddress) { + // With the device measured to return zero past the end of a descriptor range, the + // range test, the address clamp and the zero select only reproduce what the read + // already does. The guest expects zero there, and so the load stands alone. + if (_zeroOutOfBoundsBufferReads) + { + return Load(_uintType, BufferWordPointer(binding, dwordAddress)); + } + var inRange = IsBufferWordInRange(binding, dwordAddress); var safeAddress = _module.AddInstruction( SpirvOp.Select, diff --git a/src/SharpEmu.ShaderCompiler.Vulkan/SpirvFixedShaders.cs b/src/SharpEmu.ShaderCompiler.Vulkan/SpirvFixedShaders.cs index 560daa9..062d0ad 100644 --- a/src/SharpEmu.ShaderCompiler.Vulkan/SpirvFixedShaders.cs +++ b/src/SharpEmu.ShaderCompiler.Vulkan/SpirvFixedShaders.cs @@ -420,6 +420,137 @@ public static class SpirvFixedShaders return module.Build(); } + // The f16 conversion probe: for input[i] at binding 0, write two dwords at binding 1, + // output[2i] = the f32 bits of GLSL UnpackHalf2x16(input[i] & 0xFFFF).x and + // output[2i + 1] = GLSL PackHalf2x16(vec2(asfloat(input[i]), 0)) & 0xFFFF. The host + // compares both against the integer sequences the translator emits by default, and + // only then lets the translator use these two ext instructions. 64 threads per group, + // one per value; the input is padded to a multiple of 64 so no thread is out of range. + // Copies binding 0 to binding 1 with no bounds check at all, so a read past the descriptor + // range reaches the device and the caller can see what it returns. + public static byte[] CreateUncheckedBufferCopyProbe() + { + var module = new SpirvModuleBuilder(); + module.AddCapability(SpirvCapability.Shader); + + var voidType = module.TypeVoid(); + var uintType = module.TypeInt(32, signed: false); + var uvec3Type = module.TypeVector(uintType, 3); + var runtimeArray = module.TypeRuntimeArray(uintType); + module.AddDecoration(runtimeArray, SpirvDecoration.ArrayStride, 4); + var bufferStruct = module.TypeStruct(runtimeArray); + module.AddDecoration(bufferStruct, SpirvDecoration.Block); + module.AddMemberDecoration(bufferStruct, 0, SpirvDecoration.Offset, 0); + var bufferPtrType = module.TypePointer(SpirvStorageClass.StorageBuffer, bufferStruct); + var uintStoragePtr = module.TypePointer(SpirvStorageClass.StorageBuffer, uintType); + + uint MakeBuffer(uint binding, string name) + { + var variable = module.AddGlobalVariable(bufferPtrType, SpirvStorageClass.StorageBuffer); + module.AddName(variable, name); + module.AddDecoration(variable, SpirvDecoration.DescriptorSet, 0); + module.AddDecoration(variable, SpirvDecoration.Binding, binding); + return variable; + } + + var inputVar = MakeBuffer(0, "probe_bounded_input"); + var outputVar = MakeBuffer(1, "probe_output"); + var inputUvec3Ptr = module.TypePointer(SpirvStorageClass.Input, uvec3Type); + var gidVar = module.AddGlobalVariable(inputUvec3Ptr, SpirvStorageClass.Input); + module.AddName(gidVar, "gid"); + module.AddDecoration(gidVar, SpirvDecoration.BuiltIn, (uint)SpirvBuiltIn.GlobalInvocationId); + + var functionType = module.TypeFunction(voidType); + var main = module.BeginFunction(voidType, functionType); + module.AddName(main, "main"); + module.AddLabel(); + var gid = module.AddInstruction(SpirvOp.Load, uvec3Type, gidVar); + var id = module.AddInstruction(SpirvOp.CompositeExtract, uintType, gid, 0); + var sourcePtr = module.AddInstruction( + SpirvOp.AccessChain, uintStoragePtr, inputVar, module.Constant(uintType, 0), id); + var value = module.AddInstruction(SpirvOp.Load, uintType, sourcePtr); + var destinationPtr = module.AddInstruction( + SpirvOp.AccessChain, uintStoragePtr, outputVar, module.Constant(uintType, 0), id); + module.AddStatement(SpirvOp.Store, destinationPtr, value); + module.AddStatement(SpirvOp.Return); + module.EndFunction(); + + module.AddExecutionMode(main, SpirvExecutionMode.LocalSize, 64, 1, 1); + module.AddEntryPoint(SpirvExecutionModel.GLCompute, main, "main", [gidVar, inputVar, outputVar]); + return module.Build(); + } + + public static byte[] CreateHalfConversionProbe() + { + var module = new SpirvModuleBuilder(); + module.AddCapability(SpirvCapability.Shader); + var glsl = module.ImportExtInst("GLSL.std.450"); + + var voidType = module.TypeVoid(); + var uintType = module.TypeInt(32, signed: false); + var floatType = module.TypeFloat(32); + var vec2Type = module.TypeVector(floatType, 2); + var uvec3Type = module.TypeVector(uintType, 3); + var runtimeArray = module.TypeRuntimeArray(uintType); + module.AddDecoration(runtimeArray, SpirvDecoration.ArrayStride, 4); + var bufferStruct = module.TypeStruct(runtimeArray); + module.AddDecoration(bufferStruct, SpirvDecoration.Block); + module.AddMemberDecoration(bufferStruct, 0, SpirvDecoration.Offset, 0); + var bufferPtrType = module.TypePointer(SpirvStorageClass.StorageBuffer, bufferStruct); + var uintStoragePtr = module.TypePointer(SpirvStorageClass.StorageBuffer, uintType); + + uint MakeBuffer(uint binding, string name) + { + var variable = module.AddGlobalVariable(bufferPtrType, SpirvStorageClass.StorageBuffer); + module.AddName(variable, name); + module.AddDecoration(variable, SpirvDecoration.DescriptorSet, 0); + module.AddDecoration(variable, SpirvDecoration.Binding, binding); + return variable; + } + + var inputVar = MakeBuffer(0, "probe_input"); + var outputVar = MakeBuffer(1, "probe_output"); + var inputUvec3Ptr = module.TypePointer(SpirvStorageClass.Input, uvec3Type); + var gidVar = module.AddGlobalVariable(inputUvec3Ptr, SpirvStorageClass.Input); + module.AddName(gidVar, "gid"); + module.AddDecoration(gidVar, SpirvDecoration.BuiltIn, (uint)SpirvBuiltIn.GlobalInvocationId); + + uint UInt(uint value) => module.Constant(uintType, value); + var zeroFloat = module.ConstantFloat(floatType, 0f); + + var functionType = module.TypeFunction(voidType); + var main = module.BeginFunction(voidType, functionType); + module.AddName(main, "main"); + module.AddLabel(); + var gid = module.AddInstruction(SpirvOp.Load, uvec3Type, gidVar); + var id = module.AddInstruction(SpirvOp.CompositeExtract, uintType, gid, 0); + var valuePtr = module.AddInstruction(SpirvOp.AccessChain, uintStoragePtr, inputVar, UInt(0), id); + var value = module.AddInstruction(SpirvOp.Load, uintType, valuePtr); + + var halfBits = module.AddInstruction(SpirvOp.BitwiseAnd, uintType, value, UInt(0xFFFF)); + var unpacked = module.AddInstruction(SpirvOp.ExtInst, vec2Type, glsl, 62, halfBits); + var widened = module.AddInstruction(SpirvOp.CompositeExtract, floatType, unpacked, 0); + var widenedBits = module.AddInstruction(SpirvOp.Bitcast, uintType, widened); + + var asFloat = module.AddInstruction(SpirvOp.Bitcast, floatType, value); + var pair = module.AddInstruction(SpirvOp.CompositeConstruct, vec2Type, asFloat, zeroFloat); + var packed = module.AddInstruction(SpirvOp.ExtInst, uintType, glsl, 58, pair); + var narrowed = module.AddInstruction(SpirvOp.BitwiseAnd, uintType, packed, UInt(0xFFFF)); + + var lowIndex = module.AddInstruction(SpirvOp.IMul, uintType, id, UInt(2)); + var highIndex = module.AddInstruction(SpirvOp.IAdd, uintType, lowIndex, UInt(1)); + var lowPtr = module.AddInstruction(SpirvOp.AccessChain, uintStoragePtr, outputVar, UInt(0), lowIndex); + module.AddStatement(SpirvOp.Store, lowPtr, widenedBits); + var highPtr = module.AddInstruction(SpirvOp.AccessChain, uintStoragePtr, outputVar, UInt(0), highIndex); + module.AddStatement(SpirvOp.Store, highPtr, narrowed); + module.AddStatement(SpirvOp.Return); + module.EndFunction(); + + module.AddExecutionMode(main, SpirvExecutionMode.LocalSize, 64, 1, 1); + module.AddEntryPoint(SpirvExecutionModel.GLCompute, main, "main", [gidVar, inputVar, outputVar]); + return module.Build(); + } + // Read and clear binding 0. Write the count and 64-bit page addresses to binding 1. // Use 64 threads per group, with one thread per 32-page word. public static byte[] CreateFaultBufferProcess() diff --git a/src/SharpEmu.ShaderCompiler/ShaderCompileRequest.cs b/src/SharpEmu.ShaderCompiler/ShaderCompileRequest.cs index 32d8b11..8414596 100644 --- a/src/SharpEmu.ShaderCompiler/ShaderCompileRequest.cs +++ b/src/SharpEmu.ShaderCompiler/ShaderCompileRequest.cs @@ -171,6 +171,22 @@ public sealed class ShaderCompileRequest // 64-bit atomics are emitted as real 64-bit atomics instead of a pair of // 32-bit ones, which is not atomic as a pair. public bool SupportsSharedInt64Atomics { get; init; } + + // True only when the host measured GLSL UnpackHalf2x16 / PackHalf2x16 to be bit-exact + // against the emulator's own f16 conversion on this device, for every half bit pattern + // and every rounding boundary. The packed-f16 paths then convert with those two ext + // instructions instead of the branchless integer sequences, which is what a driver + // lowers to one hardware convert. Default false: the spec leaves f16 rounding and + // subnormal behaviour to the implementation, so the exact emulation is the only safe + // default and native is opted into by a device probe, never by a capability bit. + public bool NativeHalfConversionExact { get; init; } + + // True only when the host measured that a storage-buffer read past the end of its descriptor + // range reads zero on this device (robustBufferAccess2 that the device actually honours). + // Every guest buffer word is then loaded directly: the range test, the address clamp and the + // zero select around it exist only to produce the zero the device already returns. Default + // false, because an out-of-range read is undefined without that promise. + public bool ZeroOutOfBoundsBufferReads { get; init; } public Gen5ComputeSystemRegisters? ComputeSystemRegisters { get; init; } public IReadOnlyList PixelOutputs { get; init; } = []; diff --git a/tests/SharpEmu.Libs.Tests/Gpu/Pipelines/ShaderPrewarmListTests.cs b/tests/SharpEmu.Libs.Tests/Gpu/Pipelines/ShaderPrewarmListTests.cs index cacde02..2996fa8 100644 --- a/tests/SharpEmu.Libs.Tests/Gpu/Pipelines/ShaderPrewarmListTests.cs +++ b/tests/SharpEmu.Libs.Tests/Gpu/Pipelines/ShaderPrewarmListTests.cs @@ -60,8 +60,8 @@ public sealed class ShaderPrewarmListTests : IDisposable var (record, code) = Assert.Single(reloaded.LoadedComputes()); Assert.True( ShaderProgramCache.TryCompilePrewarm( - record, code, new FakeShaderCompiler(Compile), host.SharedInt64AtomicsEnabled, host.ExecGuardElisionEnabled, - out var compiled, out var layout, out var error), + record, code, new FakeShaderCompiler(Compile), host.SharedInt64AtomicsEnabled, host.ExecGuardElisionEnabled, host.NativeHalfConversionExact, + host.ZeroOutOfBoundsBufferReads, out var compiled, out var layout, out var error), error); Assert.NotNull(layout); Assert.Equal(runtime, Assert.IsType(compiled).Spirv); diff --git a/tests/SharpEmu.Libs.Tests/Gpu/Vulkan/NativeHalfConversionProbeDeviceTests.cs b/tests/SharpEmu.Libs.Tests/Gpu/Vulkan/NativeHalfConversionProbeDeviceTests.cs new file mode 100644 index 0000000..61f9648 --- /dev/null +++ b/tests/SharpEmu.Libs.Tests/Gpu/Vulkan/NativeHalfConversionProbeDeviceTests.cs @@ -0,0 +1,64 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using SharpEmu.Libs.Gpu.Vulkan; +using SharpEmu.Libs.Tests.Gpu.Images; +using Silk.NET.Vulkan; +using Xunit; +using Xunit.Abstractions; + +namespace SharpEmu.Libs.Tests.Gpu.Vulkan; + +public sealed class NativeHalfConversionProbeDeviceTests(HeadlessVulkanFixture fixture, ITestOutputHelper output) + : IClassFixture +{ + // The probe is what decides whether the translator may use GLSL UnpackHalf2x16 / + // PackHalf2x16, so the gate is that it completes and reports a count - not that this + // device passes it. The verdict goes to the test output, where a port to another GPU + // can read it. + [Fact] + public void ProbeRunsAndReportsWhetherNativeHalfConversionIsExact() + { + var vulkan = fixture.Vulkan; + if (!GatePrerequisites.Ready(vulkan)) return; + using var harness = new ImageTestHarness(vulkan); + + var result = default(NativeHalfConversionProbe.Result); + harness.Run(() => result = NativeHalfConversionProbe.Run( + harness.Device, + harness.Scheduler, + () => new CommandBuffer(harness.Scheduler.Current.Handle))); + + output.WriteLine( + $"[VK][F16] native half conversion exact={result.Exact} mismatches={result.Mismatches} " + + $"tested={result.Tested} device={vulkan.DeviceName} first={result.Detail}"); + Assert.Null(result.Note); + Assert.True(result.Tested >= 2 * 0x1_0000, $"tested={result.Tested}"); + Assert.InRange(result.Mismatches, 0, result.Tested); + Assert.Equal(result.Mismatches == 0, result.Exact); + harness.AssertNoValidationMessages(); + } + + // Every test vector must agree with System.Half, which is what proves the integer + // sequences the probe compares against are the right reference in the first place. + [Fact] + public void ReferenceConversionsMatchSystemHalfForEveryTestVector() + { + foreach (var value in HalfConversionReference.BuildTestVectors()) + { + var half = BitConverter.UInt16BitsToHalf((ushort)value); + var widened = BitConverter.SingleToUInt32Bits((float)half); + var wide = HalfConversionReference.HalfToFloatBits(value & 0xFFFFu); + Assert.True( + HalfConversionReference.Matches(widened, wide, asHalf: false), + $"half 0x{value & 0xFFFF:X4} widened to 0x{wide:X8}, System.Half says 0x{widened:X8}"); + + var single = BitConverter.UInt32BitsToSingle(value); + var narrowed = BitConverter.HalfToUInt16Bits((Half)single); + var narrow = HalfConversionReference.FloatToHalfBits(value); + Assert.True( + HalfConversionReference.Matches(narrowed, narrow, asHalf: true), + $"float 0x{value:X8} narrowed to 0x{narrow:X4}, System.Half says 0x{narrowed:X4}"); + } + } +} diff --git a/tests/SharpEmu.ShaderCompiler.Tests/Gen5Float16ArithmeticTests.cs b/tests/SharpEmu.ShaderCompiler.Tests/Gen5Float16ArithmeticTests.cs index 7d4fb4a..8147011 100644 --- a/tests/SharpEmu.ShaderCompiler.Tests/Gen5Float16ArithmeticTests.cs +++ b/tests/SharpEmu.ShaderCompiler.Tests/Gen5Float16ArithmeticTests.cs @@ -138,6 +138,79 @@ public sealed class Gen5Float16ArithmeticTests error); } + // The native GLSL conversions are used only when the host measured them bit-exact on the + // device; otherwise the branchless integer sequences (FindUMsb = GLSL 75) stay in place. + [Theory] + [InlineData(false)] + [InlineData(true)] + public void PackedFloat16UsesNativeHalfConversionOnlyWhenTheHostMeasuredItExact(bool nativeExact) + { + var program = Decode( + [ + (0x33u << 26) | (0x10u << 16) | 1u, + 0xFFu | (258u << 9), + 0x00002C00u, + SEndpgm, + ]); + + Assert.Equal("VPkMulF16", program.Instructions[0].Opcode); + var (plan, resources, layout) = ResourceTestProgram.Prepare(program, SharpEmu.ShaderCompiler.Resources.ShaderStage.Compute, 0, 0); + var request = new ShaderCompileRequest(plan, resources, layout) + { + NativeHalfConversionExact = nativeExact, + }; + + Assert.True(Gen5SpirvTranslator.TryCompileProgram(request, out var shader, out var error), error); + var glsl = ReadGlslExtOpcodes(shader.Spirv); + + // 62 = UnpackHalf2x16, 58 = PackHalf2x16, 75 = FindUMsb. + if (nativeExact) + { + Assert.Contains(62u, glsl); + Assert.Contains(58u, glsl); + } + else + { + Assert.DoesNotContain(62u, glsl); + Assert.DoesNotContain(58u, glsl); + Assert.Contains(75u, glsl); + } + } + + // The GLSL.std.450 instruction number of every ExtInst in the module. + private static IReadOnlyList ReadGlslExtOpcodes(byte[] spirv) + { + var opcodes = new List(); + foreach (var words in ReadInstructionWords(spirv)) + { + if ((ushort)words[0] == (ushort)SpirvOp.ExtInst && words.Length >= 5) + { + opcodes.Add(words[4]); + } + } + + return opcodes; + } + + private static IEnumerable ReadInstructionWords(byte[] spirv) + { + Assert.Equal(0x07230203u, BinaryPrimitives.ReadUInt32LittleEndian(spirv)); + for (var offset = 5 * sizeof(uint); offset < spirv.Length;) + { + var header = BinaryPrimitives.ReadUInt32LittleEndian(spirv.AsSpan(offset)); + var wordCount = checked((int)(header >> 16)); + Assert.InRange(wordCount, 1, (spirv.Length - offset) / sizeof(uint)); + var words = new uint[wordCount]; + for (var index = 0; index < wordCount; index++) + { + words[index] = BinaryPrimitives.ReadUInt32LittleEndian(spirv.AsSpan(offset + index * sizeof(uint))); + } + + yield return words; + offset += wordCount * sizeof(uint); + } + } + private static Gen5ShaderProgram Decode(IReadOnlyList words) { var memory = new TestCpuMemory(ShaderAddress, words.Count * sizeof(uint)); diff --git a/tests/SharpEmu.ShaderCompiler.Tests/Resources/BufferBoundsCheckTests.cs b/tests/SharpEmu.ShaderCompiler.Tests/Resources/BufferBoundsCheckTests.cs new file mode 100644 index 0000000..03e7c03 --- /dev/null +++ b/tests/SharpEmu.ShaderCompiler.Tests/Resources/BufferBoundsCheckTests.cs @@ -0,0 +1,58 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using SharpEmu.ShaderCompiler.Vulkan; +using Xunit; +using static SharpEmu.ShaderCompiler.Tests.Resources.ResourceTestProgram; + +namespace SharpEmu.ShaderCompiler.Tests.Resources; + +// A guest buffer word is bounds-checked in the shader unless the host measured that the device +// returns zero past the end of a descriptor range itself. +public sealed class BufferBoundsCheckTests +{ + [Theory] + [InlineData(false)] + [InlineData(true)] + public void BufferWordsAreCheckedUnlessTheDeviceZeroesOutOfRangeReads(bool deviceZeroes) + { + var program = Program(ScalarBufferLoad(0, 4, 20, count: 4), EndProgram(8)); + var (plan, resources, layout) = Prepare(program, SharpEmu.ShaderCompiler.Resources.ShaderStage.Compute, 0, 8); + var request = new ShaderCompileRequest(plan, resources, layout) + { + ZeroOutOfBoundsBufferReads = deviceZeroes, + }; + + Assert.True(Gen5SpirvTranslator.TryCompileProgram(request, out var shader, out var error), error); + + // One OpArrayLength is one range test. Without the device promise every word carries one. + var checks = CountOpcode(shader.Spirv, SpirvOp.ArrayLength); + if (deviceZeroes) + { + Assert.Equal(0, checks); + } + else + { + Assert.True(checks >= 4, $"expected a range test per word, found {checks}"); + } + } + + private static int CountOpcode(byte[] spirv, SpirvOp opcode) + { + var words = new uint[spirv.Length / 4]; + Buffer.BlockCopy(spirv, 0, words, 0, spirv.Length); + var count = 0; + for (var offset = 5; offset < words.Length;) + { + var wordCount = (int)(words[offset] >> 16); + if ((ushort)(words[offset] & 0xFFFF) == (ushort)opcode) + { + count++; + } + + offset += wordCount; + } + + return count; + } +} diff --git a/tests/SharpEmu.ShaderCompiler.Tests/Resources/BufferFormatLoadTests.cs b/tests/SharpEmu.ShaderCompiler.Tests/Resources/BufferFormatLoadTests.cs new file mode 100644 index 0000000..436f830 --- /dev/null +++ b/tests/SharpEmu.ShaderCompiler.Tests/Resources/BufferFormatLoadTests.cs @@ -0,0 +1,59 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using System.Diagnostics; +using SharpEmu.ShaderCompiler.Vulkan; +using Xunit; +using static SharpEmu.ShaderCompiler.Tests.Resources.ResourceTestProgram; + +namespace SharpEmu.ShaderCompiler.Tests.Resources; + +// A formatted untyped load decodes the descriptor format at run time. Dword-aligned 32-bit +// raw-bit formats take a word-per-component branch; every other format the byte-wise decode. +public sealed class BufferFormatLoadTests +{ + [Theory] + [InlineData(1u)] + [InlineData(3u)] + [InlineData(4u)] + public void FormattedLoadBranchesBetweenTheWordAndByteDecodes(uint dwords) + { + var request = Request(Program(BufferLoad(0, 4, dwords: dwords, formatted: true), EndProgram(8))); + Assert.True(Gen5SpirvTranslator.TryCompileProgram(request, out var shader, out var error), error); + + var module = new SpirvModuleInspector(shader.Spirv); + // The word branch and the byte-wise branch join at one selection merge. + Assert.Contains((ushort)SpirvOp.SelectionMerge, module.Opcodes); + Assert.Contains((ushort)SpirvOp.BitFieldUExtract, module.Opcodes); + ValidateWhenAvailable(shader.Spirv); + } + + private static void ValidateWhenAvailable(byte[] code) + { + var sdk = Environment.GetEnvironmentVariable("VULKAN_SDK"); + var executable = string.IsNullOrWhiteSpace(sdk) ? null + : Path.Combine(sdk, OperatingSystem.IsWindows() ? "Bin/spirv-val.exe" : "bin/spirv-val"); + if (executable is null || !File.Exists(executable)) + { + return; + } + + var path = Path.GetTempFileName(); + try + { + File.WriteAllBytes(path, code); + var start = new ProcessStartInfo(executable) { UseShellExecute = false, RedirectStandardError = true, CreateNoWindow = true }; + start.ArgumentList.Add("--target-env"); + start.ArgumentList.Add("vulkan1.2"); + start.ArgumentList.Add(path); + using var process = Process.Start(start)!; + var error = process.StandardError.ReadToEnd(); + process.WaitForExit(); + Assert.True(process.ExitCode == 0, error); + } + finally + { + File.Delete(path); + } + } +} diff --git a/tests/SharpEmu.ShaderCompiler.Tests/Resources/BufferUnalignedWordTests.cs b/tests/SharpEmu.ShaderCompiler.Tests/Resources/BufferUnalignedWordTests.cs new file mode 100644 index 0000000..aed5eb6 --- /dev/null +++ b/tests/SharpEmu.ShaderCompiler.Tests/Resources/BufferUnalignedWordTests.cs @@ -0,0 +1,44 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using SharpEmu.ShaderCompiler.Vulkan; +using Xunit; +using static SharpEmu.ShaderCompiler.Tests.Resources.ResourceTestProgram; + +namespace SharpEmu.ShaderCompiler.Tests.Resources; + +// The byte-wise decode of a formatted buffer load reads the four bytes of a component out of +// the two words they span, not out of four separately bounds-checked words. +public sealed class BufferUnalignedWordTests +{ + [Fact] + public void ByteWiseComponentDecodeReadsTwoBoundsCheckedWords() + { + var request = Request(Program(BufferLoad(0, 4, dwords: 1, formatted: true), EndProgram(8))); + Assert.True(Gen5SpirvTranslator.TryCompileProgram(request, out var shader, out var error), error); + + // One bounds-checked buffer word load is one OpArrayLength. A formatted load decodes + // four components whatever the opcode's width; the byte-wise branch now reads two words + // per component instead of four, which is eight of these instead of sixteen. + Assert.Equal(24, CountOpcode(shader.Spirv, SpirvOp.ArrayLength)); + } + + private static int CountOpcode(byte[] spirv, SpirvOp opcode) + { + var words = new uint[spirv.Length / 4]; + Buffer.BlockCopy(spirv, 0, words, 0, spirv.Length); + var count = 0; + for (var offset = 5; offset < words.Length;) + { + var wordCount = (int)(words[offset] >> 16); + if ((ushort)(words[offset] & 0xFFFF) == (ushort)opcode) + { + count++; + } + + offset += wordCount; + } + + return count; + } +}