Files
sharpemu/tools/SharpEmu.Tools.GpuConformance/Program.cs
T
João Victor Amorim 3005babab8 [AGC] Emit v_pk_fma_f16 with exact single rounding (#420)
Completes the fused-FMA slice deferred by the VOP3P first slice (#145).
v_pk_fma_f16 previously failed emission loudly because an f32
multiply-add followed by an f16 pack rounds twice; the pinned miss is
fma(0x4100, 0x7522, 0x04EA) = 0x7A6B fused vs 0x7A6A via f32.

The f32 product of two f16 values is exact, so only the addition needs
correcting: compute sum = RN(product + addend), recover the exact
residual with Knuth 2Sum, and if the sum is inexact with an even
significand, step one ulp towards the true value. That is round-to-odd,
and rounding the f32 result to f16 with round-to-nearest-even then
matches a true fused f16 FMA exactly (24 significand bits >= 11 + 2).
Inf/NaN inputs turn the residual into NaN, the ordered compare skips the
parity fix, and IEEE special behaviour passes through unchanged. The
op_sel/op_sel_hi/neg_lo/neg_hi source modifiers apply to src2 through
the existing operand path; clamp stays rejected like the other packed
ops.

Every op in the 2Sum chain is decorated NoContraction: without it the
AMD RDNA3 Windows driver folds the sequence, collapses the residual to
zero, and the midpoint case decays to the double-rounded result. This
was caught by running the emitted shader on a real device (see below).

Verification:
- A mirror of the emitted sequence was checked against an exact
  integer reference (every finite f16 is m * 2^-24, so a*b + c is an
  exact Int128 multiple of 2^-48, rounded once to f16 RNE) across 34M
  cases: directed midpoint pins, random sweeps over all operand
  classes, tiny-addend midpoint stress, subnormal products, and
  Inf/NaN propagation. 0 mismatches.
- ShaderDump gains a pk-f16 program covering all five packed opcodes,
  both fma modifier paths, and the pinned constants; all programs
  decode and emit.
- The executable exec program now computes the pinned fma and its
  negated-addend twin (0x7A6B7A6B / 0x7A6A7A6A, straddling an f16
  midpoint) and stores them at offsets 20/24; GpuConformance checks
  both on device. All values match on an AMD Radeon RX 7700 XT.
2026-07-19 03:24:42 +03:00

426 lines
15 KiB
C#

// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
// Executes the SharpEmu-emitted "exec" conformance shader on a real Vulkan
// device and compares the buffer results against CPU-computed expected values.
//
// The shader (exec-cs.spv, produced by SharpEmu.Tools.ShaderDump) was
// translated by SharpEmu from hand-assembled Gen5 instruction words and stores
// results to guestBuffers[0]:
// [0] v_fmac_f32 -> fma(1.5f, 2.25f, 10.0f)
// [1] v_mul_hi_i32 -> high 32 bits of (int)0x7FFFFFFF * (int)0x00010003
// [2] v_mul_lo_i32 -> low 32 bits of the same product
// [3] store attempted with EXEC=0 -> must NOT land (sentinel remains)
// [4] store after EXEC restored -> 1.5f (0x3FC00000)
// [5] v_pk_fma_f16 fma(2.5h, 21024h, 7.496e-5h) -> 0x7A6B packed; the exact
// sum sits just above an f16 midpoint, so a double-rounded f32
// multiply-add would give 0x7A6A instead
// [6] the same fma with the addend negated -> 0x7A6A packed (just below the
// same midpoint), pinning the opposite rounding direction
// Every other word of the buffer must still hold the sentinel afterwards.
//
// Creating the compute pipeline doubles as a driver-acceptance check for the
// emitted SPIR-V; the dispatch then verifies the arithmetic numerically.
//
// Usage: SharpEmu.Tools.GpuConformance <path-to-exec-cs.spv>
using Silk.NET.Core.Native;
using Silk.NET.Vulkan;
const uint Sentinel = 0xCAFEBABE;
// Must match the 64-byte global-memory binding ShaderDump constructs for the
// exec program.
const ulong BufferSize = 64;
var expectedFma = BitConverter.SingleToUInt32Bits(
MathF.FusedMultiplyAdd(1.5f, 2.25f, 10.0f));
var product = (long)0x7FFFFFFF * 0x00010003;
var expectedHi = (uint)(product >> 32);
var expectedLo = (uint)product;
var expectedRestored = BitConverter.SingleToUInt32Bits(1.5f);
// v_pk_fma_f16 of (0x4100, 0x7522, 0x04EA) per lane: the exact product
// 2.5 * 21024 = 52560 is an f16 tie (between 0x7A6A and 0x7A6B), so the tiny
// addend decides the rounding direction under a single fused rounding.
const uint ExpectedPkFma = 0x7A6B_7A6B;
const uint ExpectedPkFmaNeg = 0x7A6A_7A6A;
unsafe
{
var spvPath = args.Length > 0
? args[0]
: throw new InvalidOperationException(
"usage: SharpEmu.Tools.GpuConformance <path-to-exec-cs.spv>");
var code = File.ReadAllBytes(spvPath);
var vk = Vk.GetApi();
var appName = (byte*)SilkMarshal.StringToPtr("SharpEmuGpuConformance");
var appInfo = new ApplicationInfo
{
SType = StructureType.ApplicationInfo,
PApplicationName = appName,
ApiVersion = Vk.Version13,
};
var instanceInfo = new InstanceCreateInfo
{
SType = StructureType.InstanceCreateInfo,
PApplicationInfo = &appInfo,
};
Check(vk.CreateInstance(in instanceInfo, null, out var instance), "vkCreateInstance");
uint deviceCount = 0;
vk.EnumeratePhysicalDevices(instance, &deviceCount, null);
if (deviceCount == 0)
{
Console.WriteLine("no Vulkan devices found");
return;
}
var physicalDevices = new PhysicalDevice[deviceCount];
fixed (PhysicalDevice* pDevices = physicalDevices)
{
vk.EnumeratePhysicalDevices(instance, &deviceCount, pDevices);
}
// Prefer the first discrete GPU; fall back to the first device.
var physical = physicalDevices[0];
foreach (var candidate in physicalDevices)
{
vk.GetPhysicalDeviceProperties(candidate, out var props);
if (props.DeviceType == PhysicalDeviceType.DiscreteGpu)
{
physical = candidate;
break;
}
}
vk.GetPhysicalDeviceProperties(physical, out var chosenProps);
Console.WriteLine(
$"executing on: {SilkMarshal.PtrToString((nint)chosenProps.DeviceName)}");
uint familyCount = 0;
vk.GetPhysicalDeviceQueueFamilyProperties(physical, &familyCount, null);
var families = new QueueFamilyProperties[familyCount];
fixed (QueueFamilyProperties* pFamilies = families)
{
vk.GetPhysicalDeviceQueueFamilyProperties(physical, &familyCount, pFamilies);
}
uint? computeFamilyFound = null;
for (uint index = 0; index < familyCount; index++)
{
if (families[index].QueueFlags.HasFlag(QueueFlags.ComputeBit))
{
computeFamilyFound = index;
break;
}
}
var computeFamily = computeFamilyFound
?? throw new InvalidOperationException("device has no compute-capable queue family");
// The emitted SPIR-V declares the Int64 capability.
vk.GetPhysicalDeviceFeatures(physical, out var supportedFeatures);
if (!supportedFeatures.ShaderInt64)
{
throw new InvalidOperationException(
"device does not support shaderInt64, which the emitted SPIR-V requires");
}
var priority = 1f;
var queueInfo = new DeviceQueueCreateInfo
{
SType = StructureType.DeviceQueueCreateInfo,
QueueFamilyIndex = computeFamily,
QueueCount = 1,
PQueuePriorities = &priority,
};
var features = new PhysicalDeviceFeatures { ShaderInt64 = true };
var deviceInfo = new DeviceCreateInfo
{
SType = StructureType.DeviceCreateInfo,
QueueCreateInfoCount = 1,
PQueueCreateInfos = &queueInfo,
PEnabledFeatures = &features,
};
Check(vk.CreateDevice(physical, in deviceInfo, null, out var device), "vkCreateDevice");
vk.GetDeviceQueue(device, computeFamily, 0, out var queue);
// Storage buffer, host-visible so the CPU can prefill and read back.
var bufferInfo = new BufferCreateInfo
{
SType = StructureType.BufferCreateInfo,
Size = BufferSize,
Usage = BufferUsageFlags.StorageBufferBit,
SharingMode = SharingMode.Exclusive,
};
Check(vk.CreateBuffer(device, in bufferInfo, null, out var buffer), "vkCreateBuffer");
vk.GetBufferMemoryRequirements(device, buffer, out var requirements);
vk.GetPhysicalDeviceMemoryProperties(physical, out var memoryProperties);
uint memoryType = uint.MaxValue;
for (var index = 0; index < memoryProperties.MemoryTypeCount; index++)
{
var flags = memoryProperties.MemoryTypes[index].PropertyFlags;
if ((requirements.MemoryTypeBits & (1u << index)) != 0 &&
flags.HasFlag(MemoryPropertyFlags.HostVisibleBit) &&
flags.HasFlag(MemoryPropertyFlags.HostCoherentBit))
{
memoryType = (uint)index;
break;
}
}
if (memoryType == uint.MaxValue)
{
throw new InvalidOperationException(
"no host-visible, host-coherent memory type available for the readback buffer");
}
var allocateInfo = new MemoryAllocateInfo
{
SType = StructureType.MemoryAllocateInfo,
AllocationSize = requirements.Size,
MemoryTypeIndex = memoryType,
};
Check(vk.AllocateMemory(device, in allocateInfo, null, out var memory), "vkAllocateMemory");
Check(vk.BindBufferMemory(device, buffer, memory, 0), "vkBindBufferMemory");
void* mapped;
Check(vk.MapMemory(device, memory, 0, BufferSize, 0, &mapped), "vkMapMemory");
var words = (uint*)mapped;
for (var index = 0; index < (int)(BufferSize / sizeof(uint)); index++)
{
words[index] = Sentinel;
}
// SharpEmu emits all guest buffers as one descriptor array at set 0,
// binding 0; this conformance shader uses a single buffer.
ShaderModule module;
fixed (byte* pCode = code)
{
var moduleInfo = new ShaderModuleCreateInfo
{
SType = StructureType.ShaderModuleCreateInfo,
CodeSize = (nuint)code.Length,
PCode = (uint*)pCode,
};
Check(vk.CreateShaderModule(device, in moduleInfo, null, out module), "vkCreateShaderModule");
}
var layoutBinding = new DescriptorSetLayoutBinding
{
Binding = 0,
DescriptorType = DescriptorType.StorageBuffer,
DescriptorCount = 1,
StageFlags = ShaderStageFlags.ComputeBit,
};
var setLayoutInfo = new DescriptorSetLayoutCreateInfo
{
SType = StructureType.DescriptorSetLayoutCreateInfo,
BindingCount = 1,
PBindings = &layoutBinding,
};
Check(
vk.CreateDescriptorSetLayout(device, in setLayoutInfo, null, out var setLayout),
"vkCreateDescriptorSetLayout");
var pipelineLayoutInfo = new PipelineLayoutCreateInfo
{
SType = StructureType.PipelineLayoutCreateInfo,
SetLayoutCount = 1,
PSetLayouts = &setLayout,
};
Check(
vk.CreatePipelineLayout(device, in pipelineLayoutInfo, null, out var pipelineLayout),
"vkCreatePipelineLayout");
var entryName = (byte*)SilkMarshal.StringToPtr("main");
var pipelineInfo = new ComputePipelineCreateInfo
{
SType = StructureType.ComputePipelineCreateInfo,
Stage = new PipelineShaderStageCreateInfo
{
SType = StructureType.PipelineShaderStageCreateInfo,
Stage = ShaderStageFlags.ComputeBit,
Module = module,
PName = entryName,
},
Layout = pipelineLayout,
};
Check(
vk.CreateComputePipelines(device, default, 1, in pipelineInfo, null, out var pipeline),
"vkCreateComputePipelines");
Console.WriteLine("driver accepted the SPIR-V (pipeline created)");
var poolSize = new DescriptorPoolSize
{
Type = DescriptorType.StorageBuffer,
DescriptorCount = 1,
};
var poolInfo = new DescriptorPoolCreateInfo
{
SType = StructureType.DescriptorPoolCreateInfo,
MaxSets = 1,
PoolSizeCount = 1,
PPoolSizes = &poolSize,
};
Check(vk.CreateDescriptorPool(device, in poolInfo, null, out var pool), "vkCreateDescriptorPool");
var setAllocateInfo = new DescriptorSetAllocateInfo
{
SType = StructureType.DescriptorSetAllocateInfo,
DescriptorPool = pool,
DescriptorSetCount = 1,
PSetLayouts = &setLayout,
};
Check(vk.AllocateDescriptorSets(device, in setAllocateInfo, out var descriptorSet), "vkAllocateDescriptorSets");
var descriptorBuffer = new DescriptorBufferInfo
{
Buffer = buffer,
Offset = 0,
Range = BufferSize,
};
var write = new WriteDescriptorSet
{
SType = StructureType.WriteDescriptorSet,
DstSet = descriptorSet,
DstBinding = 0,
DstArrayElement = 0,
DescriptorCount = 1,
DescriptorType = DescriptorType.StorageBuffer,
PBufferInfo = &descriptorBuffer,
};
vk.UpdateDescriptorSets(device, 1, in write, 0, null);
var commandPoolInfo = new CommandPoolCreateInfo
{
SType = StructureType.CommandPoolCreateInfo,
QueueFamilyIndex = computeFamily,
};
Check(vk.CreateCommandPool(device, in commandPoolInfo, null, out var commandPool), "vkCreateCommandPool");
var commandBufferInfo = new CommandBufferAllocateInfo
{
SType = StructureType.CommandBufferAllocateInfo,
CommandPool = commandPool,
Level = CommandBufferLevel.Primary,
CommandBufferCount = 1,
};
Check(vk.AllocateCommandBuffers(device, in commandBufferInfo, out var commandBuffer), "vkAllocateCommandBuffers");
var beginInfo = new CommandBufferBeginInfo
{
SType = StructureType.CommandBufferBeginInfo,
};
Check(vk.BeginCommandBuffer(commandBuffer, in beginInfo), "vkBeginCommandBuffer");
vk.CmdBindPipeline(commandBuffer, PipelineBindPoint.Compute, pipeline);
vk.CmdBindDescriptorSets(
commandBuffer,
PipelineBindPoint.Compute,
pipelineLayout,
0,
1,
in descriptorSet,
0,
null);
vk.CmdDispatch(commandBuffer, 1, 1, 1);
var barrier = new MemoryBarrier
{
SType = StructureType.MemoryBarrier,
SrcAccessMask = AccessFlags.ShaderWriteBit,
DstAccessMask = AccessFlags.HostReadBit,
};
vk.CmdPipelineBarrier(
commandBuffer,
PipelineStageFlags.ComputeShaderBit,
PipelineStageFlags.HostBit,
0,
1,
in barrier,
0,
null,
0,
null);
Check(vk.EndCommandBuffer(commandBuffer), "vkEndCommandBuffer");
var submitInfo = new SubmitInfo
{
SType = StructureType.SubmitInfo,
CommandBufferCount = 1,
PCommandBuffers = &commandBuffer,
};
Check(vk.QueueSubmit(queue, 1, in submitInfo, default), "vkQueueSubmit");
Check(vk.QueueWaitIdle(queue), "vkQueueWaitIdle");
var results = new (string Name, uint Actual, uint Expected)[]
{
("v_fmac_f32 fma(1.5, 2.25, 10.0)", words[0], expectedFma),
("v_mul_hi_i32 hi(0x7FFFFFFF*0x10003)", words[1], expectedHi),
("v_mul_lo_i32 lo(0x7FFFFFFF*0x10003)", words[2], expectedLo),
("exec=0 store suppressed (offset 12 sentinel)", words[3], Sentinel),
("store after exec restore (offset 16)", words[4], expectedRestored),
("v_pk_fma_f16 fused rounds up at midpoint", words[5], ExpectedPkFma),
("v_pk_fma_f16 neg addend rounds down", words[6], ExpectedPkFmaNeg),
};
var failures = 0;
foreach (var (name, actual, expected) in results)
{
var status = actual == expected ? "PASS" : "FAIL";
if (actual != expected)
{
failures++;
}
Console.WriteLine($"{status} {name}: gpu=0x{actual:X8} expected=0x{expected:X8}");
}
var totalWords = (int)(BufferSize / sizeof(uint));
var trailingClobbered = 0;
for (var index = results.Length; index < totalWords; index++)
{
if (words[index] != Sentinel)
{
trailingClobbered++;
Console.WriteLine(
$"FAIL trailing word [{index}] clobbered: gpu=0x{words[index]:X8} expected=0x{Sentinel:X8}");
}
}
failures += trailingClobbered;
if (trailingClobbered == 0)
{
Console.WriteLine(
$"PASS trailing words [{results.Length}..{totalWords - 1}] intact (sentinel)");
}
Console.WriteLine(failures == 0
? "RESULT: all values match"
: $"RESULT: {failures} mismatch(es)");
vk.DestroyCommandPool(device, commandPool, null);
vk.DestroyDescriptorPool(device, pool, null);
vk.DestroyPipeline(device, pipeline, null);
vk.DestroyPipelineLayout(device, pipelineLayout, null);
vk.DestroyDescriptorSetLayout(device, setLayout, null);
vk.DestroyShaderModule(device, module, null);
vk.UnmapMemory(device, memory);
vk.FreeMemory(device, memory, null);
vk.DestroyBuffer(device, buffer, null);
vk.DestroyDevice(device, null);
vk.DestroyInstance(instance, null);
Environment.ExitCode = failures == 0 ? 0 : 1;
static void Check(Result result, string what)
{
if (result != Result.Success)
{
throw new InvalidOperationException($"{what} failed: {result}");
}
}
}