sharpemu/tests/SharpEmu.Libs.Tests/Gpu/Images/ImageTestHarness.cs

546 lines
23 KiB
C#

// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Diagnostics.CodeAnalysis;
using System.Runtime.InteropServices;
using System.Security.Cryptography;
using SharpEmu.HLE;
using SharpEmu.HLE.Host;
using SharpEmu.Libs.Gpu.Buffers;
using SharpEmu.Libs.Gpu.Images;
using SharpEmu.Libs.Gpu.Scheduling;
using SharpEmu.Libs.Tests.Gpu.Buffers;
using SharpEmu.Libs.Tests.Gpu.Vulkan;
using Silk.NET.Vulkan;
using Xunit;
using VkBuffer = Silk.NET.Vulkan.Buffer;
namespace SharpEmu.Libs.Tests.Gpu.Images;
// Guest memory for image tests: one byte array at a fixed guest base, reachable only through the backing alias.
internal sealed class ArrayBackedSpace : IGuestBackedSpace
{
public const ulong Base = 0x2_0000_0000;
public byte[] Bytes { get; } = new byte[16 * 1024 * 1024];
private bool Contains(ulong address, int length) => address >= Base && address - Base + (ulong)length <= (ulong)Bytes.Length;
public bool TryHoldRange(ulong address, ulong size) => false;
public bool TryHoldRangeAtOrAbove(ulong searchStart, ulong size, ulong alignment, out ulong address)
{
address = 0;
return false;
}
public bool TryMapBacked(ulong address, ulong size, ulong backingOffset, GuestPageProtection protection, out HostViewFailure failure)
{
failure = HostViewFailure.None;
return false;
}
public bool TryUnmapBacked(ulong address, ulong size) => false;
public bool TryClearBacking(ulong offset, ulong size) => false;
public bool IsBackedView(ulong address) => Contains(address, 1);
public bool IsBackedRange(ulong address, ulong size) => size <= int.MaxValue && Contains(address, (int)size);
public bool TryWriteBacking(ulong address, ReadOnlySpan<byte> data)
{
if (!Contains(address, data.Length))
{
return false;
}
data.CopyTo(Bytes.AsSpan((int)(address - Base)));
return true;
}
public bool TryReadBacking(ulong address, Span<byte> data)
{
if (!Contains(address, data.Length))
{
return false;
}
Bytes.AsSpan((int)(address - Base), data.Length).CopyTo(data);
return true;
}
}
// Locates the reference compiled shader blobs; the require switch turns a missing blob into a failure.
internal static class ReferenceShaders
{
public const string DirectoryVariable = "SHARPEMU_TEST_REFERENCE_SPIRV_DIR";
public const string RequireVariable = "SHARPEMU_TEST_REQUIRE_REFERENCE_SPIRV";
public static readonly (string Name, string Sha256)[] Blobs =
[
("gpu_tiler_shaders/gpu_tiler_standard256.spv", "8ee8a2073f23adbdb4cd5c2a00e207f707d3e11daace06fba2841ee86d22c450"),
("gpu_tiler_shaders/gpu_tiler_standard4.spv", "8519949e64c4bd73443d0310c84849e2b26689d4d3f9a9af494ba49fa59da41f"),
("gpu_tiler_shaders/gpu_tiler_standard4_3d.spv", "b570ca8738bb946024f763f633911f6ab6a0bcb08818a3171ef25a1fc1cfd197"),
("gpu_tiler_shaders/gpu_tiler_standard64.spv", "661f32d7e85b6640e8c629cfe37541ec3271384f38687a5a6190c1e437d7bb82"),
("gpu_tiler_shaders/gpu_tiler_standard64_3d.spv", "6372c8dee9505189203d547f692b01c4367090a793e3cca7134b16f9a779fba9"),
("gpu_tiler_shaders/gpu_tiler_prt.spv", "399a0f95b4ccebb620a3597161315c87078bca07d14449ae059a5db818443c79"),
("gpu_tiler_shaders/gpu_tiler_prt_3d.spv", "aa728d680d94c6cf688ed30f2a683483c266ec784dd499205604ba2b102c9f6c"),
("gpu_tiler_shaders/gpu_tiler_render_target.spv", "55809eb0c2d9069322d8929ff2699df0a886bfcc60e9127c7550c7b13c9179cf"),
("gpu_tiler_shaders/gpu_tiler_depth.spv", "dc26f2109e519a022c6fee88b9f0bc2f847f3b098111e365bd781f8b0963cb1d"),
("gpu_tiler_shaders/gpu_tiler_promote_d16.spv", "94121064ffaf15595928336936f2cc361153b235562ea69238aad777307175cb"),
("gpu_tiler_shaders/gpu_tiler_demote_d16.spv", "c69870c10d50b920c17aeffdf25a45abcbda99517582aa6f2b247961739ab4a8"),
("gpu_tiler_shaders/gpu_tiler_swap_bgra16.spv", "10198e6afe21eafeb22fc79becd5d0efc1243800935eb8437f93c8711c1f71b3"),
("gpu_blit_shaders/gpu_blit_color_to_ms_depth.spv", "f05f63f81f5727b279b12afe6e38622a71a8db1c4cb82d65e78499ebb157282a"),
("gpu_blit_shaders/gpu_blit_fs_triangle.spv", "d82030b903f72a9a34ffee685725ca3a9adecaebd2ad3785ff6d08e7959cddbf"),
];
public static bool Required => Environment.GetEnvironmentVariable(RequireVariable) == "1";
public static string? Directory => Environment.GetEnvironmentVariable(DirectoryVariable);
// Null when the blob is absent and not required; a hash mismatch always fails.
public static byte[]? TryLoad(string name)
{
var expected = Array.Find(Blobs, blob => blob.Name == name).Sha256;
Assert.False(string.IsNullOrEmpty(expected), $"The reference blob is not in the recorded table: {name}");
var directory = Directory;
if (string.IsNullOrWhiteSpace(directory))
{
Assert.False(Required, $"Set {DirectoryVariable} to the reference shader directory for the required gate.");
return null;
}
var path = Path.Combine(directory, name);
if (!File.Exists(path))
{
Assert.False(Required, $"The required reference blob is missing: {path}");
return null;
}
var bytes = File.ReadAllBytes(path);
var actual = Convert.ToHexStringLower(SHA256.HashData(bytes));
Assert.True(expected == actual, $"The reference blob hash changed: {name} expected={expected} actual={actual}");
return bytes;
}
}
// A missing device or feature skips a test; the required switch turns that into a failure.
internal static class GatePrerequisites
{
// Set SHARPEMU_TEST_REQUIRE_DEVICE=1 to fail, instead of skip, when a device or feature is missing.
public const string RequireDeviceVariable = "SHARPEMU_TEST_REQUIRE_DEVICE";
public static bool DeviceRequired => Environment.GetEnvironmentVariable(RequireDeviceVariable) == "1";
public static bool Ready([NotNullWhen(true)] HeadlessVulkan? vulkan, bool sampleRateShading = false, bool samplerAnisotropy = false, bool referenceSpirv = false, bool shaderInt64 = false)
{
var missing = vulkan is null ? "a Vulkan device"
: sampleRateShading && !vulkan.SampleRateShading ? "the sampleRateShading device feature"
: samplerAnisotropy && !vulkan.SamplerAnisotropy ? "the samplerAnisotropy device feature"
: referenceSpirv && !vulkan.SupportsSpirv16 ? "a Vulkan 1.3 device for the SPIR-V 1.6 reference modules"
: shaderInt64 && !vulkan.ShaderInt64 ? "the shaderInt64 device feature"
: null;
if (missing is null)
{
return true;
}
Assert.False(DeviceRequired || ReferenceShaders.Required, $"The required gate cannot run without {missing}.");
return false;
}
}
// A worker, a scheduler, the guest array, a stream ring, the tiler and the blit for one test.
internal sealed unsafe class ImageTestHarness : IDisposable
{
private readonly List<GpuBuffer> _retired = new();
private readonly List<CachedImage> _images = new();
public ImageTestHarness(HeadlessVulkan vulkan)
{
Vulkan = vulkan;
Worker = new CacheWorker(vulkan);
Worker.Run(() => Worker.Scheduler.Begin(new SubmissionContext { QueueName = "image.test", SubmissionId = 1 }));
Stream = new GpuRingBuffer(Device, Scheduler, GpuBufferUsage.Stream, 4 * 1024 * 1024);
Tiler = new GpuTiler(Device, Scheduler, Stream);
Blit = new ColorToMultisampleDepthBlit(Device, Scheduler);
}
public HeadlessVulkan Vulkan { get; }
public CacheWorker Worker { get; }
public SubmissionScheduler Scheduler => Worker.Scheduler;
public GpuDeviceInfo Device => Vulkan.DeviceInfo;
public Vk Vk => Vulkan.Vk;
public ArrayBackedSpace Guest { get; } = new();
public GpuRingBuffer Stream { get; }
public GpuTiler Tiler { get; }
public ColorToMultisampleDepthBlit Blit { get; }
public void Run(Action work) => Worker.Run(work);
// Finishes the recorded work, then fails on any validation-layer message of the test.
public void AssertNoValidationMessages()
{
Finish();
Vulkan.AssertNoValidationMessages();
}
public T Run<T>(Func<T> work) => Worker.Run(work);
public void Finish() => Run(() => Scheduler.Finish());
// Host-visible storage-capable buffer holding the data; kept until the harness is disposed.
public GpuBuffer Upload(ReadOnlySpan<byte> data, ulong minimumSize = 0)
{
var size = Math.Max((ulong)data.Length, minimumSize);
size = (size + 3) & ~3UL;
var buffer = new GpuBuffer(Device, Scheduler, GpuBufferUsage.Upload, 0, GpuBuffer.AllFlags, Math.Max(size, 4));
buffer.Mapped.Clear();
buffer.Write(0, data);
lock (_retired)
{
_retired.Add(buffer);
}
return buffer;
}
public GpuBuffer CreateDeviceLocalBuffer(ulong size)
{
var buffer = new GpuBuffer(Device, Scheduler, GpuBufferUsage.DeviceLocal, 0, GpuBuffer.AllFlags, Math.Max((size + 3) & ~3UL, 4));
lock (_retired)
{
_retired.Add(buffer);
}
return buffer;
}
// Copies a device range into a download buffer, finishes the tick and returns the bytes.
public byte[] ReadBack(VkBuffer source, ulong offset, ulong size) => Run(() =>
{
var aligned = (size + 3) & ~3UL;
using var download = new GpuBuffer(Device, Scheduler, GpuBufferUsage.Download, 0, GpuBuffer.AllFlags, aligned);
var command = new CommandBuffer(Scheduler.Current.Handle);
var before = new BufferMemoryBarrier
{
SType = StructureType.BufferMemoryBarrier,
SrcAccessMask = AccessFlags.MemoryWriteBit | AccessFlags.ShaderWriteBit | AccessFlags.TransferWriteBit | AccessFlags.HostWriteBit,
DstAccessMask = AccessFlags.TransferReadBit,
SrcQueueFamilyIndex = Vk.QueueFamilyIgnored,
DstQueueFamilyIndex = Vk.QueueFamilyIgnored,
Buffer = source,
Offset = offset,
Size = aligned,
};
Vk.CmdPipelineBarrier(command, PipelineStageFlags.AllCommandsBit | PipelineStageFlags.HostBit, PipelineStageFlags.TransferBit, 0, 0, null, 1, &before, 0, null);
var region = new BufferCopy(offset, 0, aligned);
Vk.CmdCopyBuffer(command, source, download.Handle, 1, &region);
var after = before with { Buffer = download.Handle, Offset = 0, SrcAccessMask = AccessFlags.TransferWriteBit, DstAccessMask = AccessFlags.HostReadBit };
Vk.CmdPipelineBarrier(command, PipelineStageFlags.TransferBit, PipelineStageFlags.HostBit, 0, 0, null, 1, &after, 0, null);
Scheduler.Finish();
download.Invalidate(0, aligned);
return download.Mapped[..(int)size].ToArray();
});
// Images live until the harness has finished all recorded work; a failing assertion cannot destroy one early.
public CachedImage CreateImage(ImageDescription description)
{
var image = Run(() => new CachedImage(Device, Scheduler, Guest, description));
lock (_images)
{
_images.Add(image);
}
return image;
}
public void UploadImage(CachedImage image, ReadOnlySpan<byte> data, BufferImageCopy[] copies)
{
var upload = Upload(data);
Run(() => image.UploadFromBuffer(copies, upload.Handle, 0, upload.Size));
}
public byte[] ReadImage(CachedImage image, BufferImageCopy[] copies, ulong size) => Run(() =>
{
var aligned = (size + 3) & ~3UL;
using var download = new GpuBuffer(Device, Scheduler, GpuBufferUsage.Download, 0, GpuBuffer.AllFlags, aligned);
image.DownloadToBuffer(copies, download.Handle, 0, aligned);
Scheduler.Finish();
download.Invalidate(0, aligned);
return download.Mapped[..(int)size].ToArray();
});
public static BufferImageCopy[] WholeImageCopies(in ImageDescription description, ulong sliceStride)
{
var layers = description.TransferLayers;
var volume = description.IsVolume;
var copies = new List<BufferImageCopy>();
ulong offset = 0;
for (uint level = 0; level < description.Resources.Levels; level++)
{
var width = Math.Max(description.Extent.Width >> (int)level, 1);
var height = Math.Max(description.Extent.Height >> (int)level, 1);
var depth = volume ? Math.Max(description.Extent.Depth >> (int)level, 1) : layers;
for (uint z = 0; z < depth; z++)
{
copies.Add(new BufferImageCopy
{
BufferOffset = offset,
ImageSubresource = new ImageSubresourceLayers(ViewFormatRules.FullAspects(description.PixelFormat) & ~ImageAspectFlags.StencilBit, level, volume ? 0 : z, 1),
ImageOffset = new Offset3D(0, 0, volume ? (int)z : 0),
ImageExtent = new Extent3D(width, height, 1),
});
offset += (ulong)width * height * description.BytesPerBlock;
}
}
return copies.ToArray();
}
public static ulong WholeImageBytes(in ImageDescription description)
{
var layers = description.TransferLayers;
ulong total = 0;
for (uint level = 0; level < description.Resources.Levels; level++)
{
var width = Math.Max(description.Extent.Width >> (int)level, 1);
var height = Math.Max(description.Extent.Height >> (int)level, 1);
var depth = description.IsVolume ? Math.Max(description.Extent.Depth >> (int)level, 1) : layers;
total += (ulong)width * height * depth * description.BytesPerBlock;
}
return total;
}
public static byte[] Pattern(int length, uint seed)
{
var bytes = new byte[length];
var state = seed * 2654435761u + 1;
for (var index = 0; index < length; index++)
{
state ^= state << 13;
state ^= state >> 17;
state ^= state << 5;
bytes[index] = (byte)state;
}
return bytes;
}
public void Dispose()
{
Run(() =>
{
Scheduler.Finish();
Scheduler.WaitForAllPriorityOperations();
foreach (var image in _images)
{
image.Dispose();
}
_images.Clear();
Blit.Dispose();
Tiler.Dispose();
Stream.Dispose();
foreach (var buffer in _retired)
{
buffer.Dispose();
}
_retired.Clear();
Scheduler.Shutdown();
});
Worker.Dispose();
}
}
// Runs one compute module with the tiler's binding layout; both the reference and the Sharp modules use it.
internal sealed unsafe class TilerComputeRunner : IDisposable
{
private readonly ImageTestHarness _harness;
private readonly DescriptorSetLayout _setLayout;
private readonly PipelineLayout _pipelineLayout;
private readonly DescriptorPool _pool;
private readonly List<Pipeline> _pipelines = new();
public TilerComputeRunner(ImageTestHarness harness)
{
_harness = harness;
var vk = harness.Vk;
var device = harness.Device.Device;
var bindings = stackalloc DescriptorSetLayoutBinding[3];
bindings[0] = new DescriptorSetLayoutBinding(0, DescriptorType.StorageBuffer, 1, ShaderStageFlags.ComputeBit);
bindings[1] = new DescriptorSetLayoutBinding(1, DescriptorType.StorageBuffer, 1, ShaderStageFlags.ComputeBit);
bindings[2] = new DescriptorSetLayoutBinding(2, DescriptorType.UniformBuffer, 1, ShaderStageFlags.ComputeBit);
var layoutInfo = new DescriptorSetLayoutCreateInfo { SType = StructureType.DescriptorSetLayoutCreateInfo, BindingCount = 3, PBindings = bindings };
Require(vk.CreateDescriptorSetLayout(device, &layoutInfo, null, out _setLayout), "vkCreateDescriptorSetLayout");
var push = new PushConstantRange(ShaderStageFlags.ComputeBit, 0, TileTransferArguments.Size);
fixed (DescriptorSetLayout* layout = &_setLayout)
{
var pipelineLayoutInfo = new PipelineLayoutCreateInfo
{
SType = StructureType.PipelineLayoutCreateInfo,
SetLayoutCount = 1,
PSetLayouts = layout,
PushConstantRangeCount = 1,
PPushConstantRanges = &push,
};
Require(vk.CreatePipelineLayout(device, &pipelineLayoutInfo, null, out _pipelineLayout), "vkCreatePipelineLayout");
}
var sizes = stackalloc DescriptorPoolSize[2];
sizes[0] = new DescriptorPoolSize(DescriptorType.StorageBuffer, 2 * 1024);
sizes[1] = new DescriptorPoolSize(DescriptorType.UniformBuffer, 1024);
var poolInfo = new DescriptorPoolCreateInfo { SType = StructureType.DescriptorPoolCreateInfo, MaxSets = 1024, PoolSizeCount = 2, PPoolSizes = sizes };
Require(vk.CreateDescriptorPool(device, &poolInfo, null, out _pool), "vkCreateDescriptorPool");
}
private static void Require(Result result, string operation)
{
if (result != Result.Success)
{
throw new InvalidOperationException($"{operation} failed with {result}");
}
}
// Specialization values are 4-byte constants in constant identifier order; empty means none.
public Pipeline CreatePipeline(byte[] spirv, ReadOnlySpan<uint> specialization)
{
var vk = _harness.Vk;
var device = _harness.Device.Device;
ShaderModule module;
fixed (byte* code = spirv)
{
var moduleInfo = new ShaderModuleCreateInfo { SType = StructureType.ShaderModuleCreateInfo, CodeSize = (nuint)spirv.Length, PCode = (uint*)code };
Require(vk.CreateShaderModule(device, &moduleInfo, null, out module), "vkCreateShaderModule");
}
var entries = stackalloc SpecializationMapEntry[Math.Max(specialization.Length, 1)];
for (var index = 0; index < specialization.Length; index++)
{
entries[index] = new SpecializationMapEntry((uint)index, (uint)index * 4, 4);
}
var values = stackalloc uint[Math.Max(specialization.Length, 1)];
specialization.CopyTo(new Span<uint>(values, Math.Max(specialization.Length, 1)));
var specializationInfo = new SpecializationInfo
{
MapEntryCount = (uint)specialization.Length,
PMapEntries = entries,
DataSize = (nuint)(specialization.Length * 4),
PData = values,
};
var entry = (byte*)Marshal.StringToHGlobalAnsi("main");
Pipeline pipeline;
Result created;
try
{
var info = new ComputePipelineCreateInfo
{
SType = StructureType.ComputePipelineCreateInfo,
Stage = new PipelineShaderStageCreateInfo
{
SType = StructureType.PipelineShaderStageCreateInfo,
Stage = ShaderStageFlags.ComputeBit,
Module = module,
PName = entry,
PSpecializationInfo = specialization.IsEmpty ? null : &specializationInfo,
},
Layout = _pipelineLayout,
};
created = vk.CreateComputePipelines(device, default, 1, &info, null, out pipeline);
}
finally
{
Marshal.FreeHGlobal((nint)entry);
vk.DestroyShaderModule(device, module, null);
}
Require(created, "vkCreateComputePipelines");
_pipelines.Add(pipeline);
return pipeline;
}
// Records one dispatch on the worker; arguments go to binding 2 and to the push range.
public void Dispatch(Pipeline pipeline, GpuBuffer input, GpuBuffer output, GpuBuffer arguments, ulong argumentsOffset, in TileTransferArguments push, uint groupsX, uint groupsY, uint groupsZ)
{
var vk = _harness.Vk;
var device = _harness.Device.Device;
var setLayout = _setLayout;
var allocateInfo = new DescriptorSetAllocateInfo { SType = StructureType.DescriptorSetAllocateInfo, DescriptorPool = _pool, DescriptorSetCount = 1, PSetLayouts = &setLayout };
Require(vk.AllocateDescriptorSets(device, &allocateInfo, out var set), "vkAllocateDescriptorSets");
var infos = stackalloc DescriptorBufferInfo[3];
infos[0] = new DescriptorBufferInfo(input.Handle, 0, input.Size);
infos[1] = new DescriptorBufferInfo(output.Handle, 0, output.Size);
infos[2] = new DescriptorBufferInfo(arguments.Handle, argumentsOffset, TileTransferArguments.Size);
var writes = stackalloc WriteDescriptorSet[3];
for (uint index = 0; index < 3; index++)
{
writes[index] = new WriteDescriptorSet
{
SType = StructureType.WriteDescriptorSet,
DstSet = set,
DstBinding = index,
DescriptorCount = 1,
DescriptorType = index == 2 ? DescriptorType.UniformBuffer : DescriptorType.StorageBuffer,
PBufferInfo = infos + index,
};
}
vk.UpdateDescriptorSets(device, 3, writes, 0, null);
var command = new CommandBuffer(_harness.Scheduler.Current.Handle);
var barriers = stackalloc BufferMemoryBarrier[3];
for (var index = 0; index < 3; index++)
{
barriers[index] = new BufferMemoryBarrier
{
SType = StructureType.BufferMemoryBarrier,
SrcAccessMask = AccessFlags.HostWriteBit | AccessFlags.MemoryWriteBit,
DstAccessMask = index == 1 ? AccessFlags.ShaderReadBit | AccessFlags.ShaderWriteBit : index == 2 ? AccessFlags.UniformReadBit : AccessFlags.ShaderReadBit,
SrcQueueFamilyIndex = Vk.QueueFamilyIgnored,
DstQueueFamilyIndex = Vk.QueueFamilyIgnored,
Buffer = infos[index].Buffer,
Offset = infos[index].Offset,
Size = infos[index].Range,
};
}
vk.CmdPipelineBarrier(command, PipelineStageFlags.AllCommandsBit | PipelineStageFlags.HostBit, PipelineStageFlags.ComputeShaderBit, 0, 0, null, 3, barriers, 0, null);
vk.CmdBindPipeline(command, PipelineBindPoint.Compute, pipeline);
vk.CmdBindDescriptorSets(command, PipelineBindPoint.Compute, _pipelineLayout, 0, 1, &set, 0, null);
fixed (TileTransferArguments* pointer = &push)
{
vk.CmdPushConstants(command, _pipelineLayout, ShaderStageFlags.ComputeBit, 0, TileTransferArguments.Size, pointer);
}
vk.CmdDispatch(command, groupsX, groupsY, groupsZ);
var after = barriers[1] with { SrcAccessMask = AccessFlags.ShaderWriteBit, DstAccessMask = AccessFlags.TransferReadBit | AccessFlags.MemoryReadBit };
vk.CmdPipelineBarrier(command, PipelineStageFlags.ComputeShaderBit, PipelineStageFlags.AllCommandsBit, 0, 0, null, 1, &after, 0, null);
}
public void Dispose()
{
var vk = _harness.Vk;
var device = _harness.Device.Device;
_harness.Run(() => _harness.Scheduler.Finish());
foreach (var pipeline in _pipelines)
{
vk.DestroyPipeline(device, pipeline, null);
}
vk.DestroyDescriptorPool(device, _pool, null);
vk.DestroyPipelineLayout(device, _pipelineLayout, null);
vk.DestroyDescriptorSetLayout(device, _setLayout, null);
}
}