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; + } +}