383 lines
16 KiB
C#
383 lines
16 KiB
C#
// Copyright (C) 2026 SharpEmu Emulator Project
|
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
|
|
|
using System.Buffers.Binary;
|
|
using Xunit;
|
|
using Xunit.Abstractions;
|
|
|
|
namespace SharpEmu.ShaderCompiler.Metal.Tests;
|
|
|
|
/// <summary>
|
|
/// Tier 2 and 3: the emitted MSL must compile with the OS runtime Metal compiler, and
|
|
/// the executable fixtures must produce bit-exact results on the GPU, including EXEC
|
|
/// masking and dispatcher control flow. These tests no-op (with a note) on hosts
|
|
/// without a Metal device so the suite stays green on Windows/Linux CI; the golden
|
|
/// and structural tiers still run everywhere.
|
|
/// </summary>
|
|
public sealed class MetalRuntimeTests(ITestOutputHelper output)
|
|
{
|
|
[Fact]
|
|
public void AllFixturesCompileWithTheRuntimeMetalCompiler()
|
|
{
|
|
if (SkipWithoutMetalDevice("compile validation")) return;
|
|
|
|
foreach (var fixture in Gen5ComputeFixtures.All)
|
|
{
|
|
var shader = Gen5ComputeFixtures.CompileRequestOrThrow(fixture);
|
|
Assert.True(
|
|
MetalNative.TryCompileLibrary(shader.Source, out _, out var error),
|
|
$"[{fixture.Name}] Metal rejected the emitted MSL: {error}\n{shader.Source}");
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void ExecStoreProgramExecutesWithExecMasking()
|
|
{
|
|
if (SkipWithoutMetalDevice("the execution test")) return;
|
|
|
|
// Sentinel-filled buffer: any dword the program does not store must survive.
|
|
const uint Sentinel = 0xDEADBEEFu;
|
|
var buffer = new byte[64];
|
|
for (var offset = 0; offset < buffer.Length; offset += sizeof(uint))
|
|
{
|
|
BinaryPrimitives.WriteUInt32LittleEndian(buffer.AsSpan(offset), Sentinel);
|
|
}
|
|
|
|
var result = ExecuteRequestOrThrow(Gen5ComputeFixtures.ExecStore, buffer);
|
|
|
|
// Reference results computed with the same semantics the program encodes.
|
|
var fmac = BitConverter.SingleToUInt32Bits(MathF.FusedMultiplyAdd(1.5f, 2.25f, 10.0f));
|
|
var mulHiSigned = (uint)(((long)0x7FFFFFFF * 0x00010003) >> 32);
|
|
var mulLoSigned = unchecked(0x7FFFFFFFu * 0x00010003u);
|
|
var movBits = BitConverter.SingleToUInt32Bits(1.5f);
|
|
|
|
Assert.Equal(fmac, ReadDword(result, 0));
|
|
Assert.Equal(mulHiSigned, ReadDword(result, 4));
|
|
Assert.Equal(mulLoSigned, ReadDword(result, 8));
|
|
Assert.Equal(Sentinel, ReadDword(result, 12)); // EXEC=0: the store must not land.
|
|
Assert.Equal(movBits, ReadDword(result, 16));
|
|
for (var offset = 20; offset < result.Length; offset += sizeof(uint))
|
|
{
|
|
Assert.Equal(Sentinel, ReadDword(result, offset));
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void LoopProgramIteratesThroughTheDispatcher()
|
|
{
|
|
if (SkipWithoutMetalDevice("the execution test")) return;
|
|
|
|
var result = ExecuteRequestOrThrow(Gen5ComputeFixtures.Loop, new byte[16]);
|
|
|
|
// 5 + 4 + 3 + 2 + 1, accumulated across five dispatcher round trips.
|
|
Assert.Equal(15u, ReadDword(result, 0));
|
|
}
|
|
|
|
[Fact]
|
|
public void PixelShaderCompilesWithTheRuntimeMetalCompiler()
|
|
{
|
|
if (SkipWithoutMetalDevice("compile validation")) return;
|
|
|
|
var shader = Gen5ComputeFixtures.CompilePixelOrThrow();
|
|
Assert.True(
|
|
MetalNative.TryCompileLibrary(shader.Source, out _, out var error),
|
|
$"[pixel] Metal rejected the emitted MSL: {error}\n{shader.Source}");
|
|
}
|
|
|
|
[Fact]
|
|
public void VertexShaderCompilesWithTheRuntimeMetalCompiler()
|
|
{
|
|
if (SkipWithoutMetalDevice("compile validation")) return;
|
|
|
|
var shader = Gen5ComputeFixtures.CompileVertexOrThrow(requiredVertexOutputCount: 2);
|
|
Assert.True(
|
|
MetalNative.TryCompileLibrary(shader.Source, out _, out var error),
|
|
$"[vertex] Metal rejected the emitted MSL: {error}\n{shader.Source}");
|
|
}
|
|
|
|
[Fact]
|
|
public void FixedShadersCompileWithTheRuntimeMetalCompiler()
|
|
{
|
|
if (SkipWithoutMetalDevice("compile validation")) return;
|
|
|
|
var sources = new (string Name, string Source)[]
|
|
{
|
|
("fullscreen", MslFixedShaders.CreateFullscreenVertex(3)),
|
|
("copy", MslFixedShaders.CreateCopyFragment()),
|
|
("present", MslFixedShaders.CreatePresentFragment()),
|
|
("solid", MslFixedShaders.CreateSolidFragment(0.25f, 0.5f, 0.75f, 1f)),
|
|
("attribute", MslFixedShaders.CreateAttributeFragment(1)),
|
|
("depth-only", MslFixedShaders.CreateDepthOnlyFragment()),
|
|
};
|
|
foreach (var (name, source) in sources)
|
|
{
|
|
Assert.True(
|
|
MetalNative.TryCompileLibrary(source, out _, out var error),
|
|
$"[{name}] Metal rejected the fixed shader: {error}\n{source}");
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void LdsRoundTripExecutesThroughThreadgroupMemory()
|
|
{
|
|
if (SkipWithoutMetalDevice("the execution test")) return;
|
|
|
|
var result = ExecuteRequestOrThrow(Gen5ComputeFixtures.Lds, new byte[16]);
|
|
|
|
// Written to LDS, barriered, read back, stored to the buffer.
|
|
Assert.Equal(0x1234u, ReadDword(result, 0));
|
|
}
|
|
|
|
[Fact]
|
|
public void Wave64CrossLaneEmitsValidMsl()
|
|
{
|
|
if (SkipWithoutMetalDevice("compile validation")) return;
|
|
|
|
// A wave64 program with a cross-lane op emits the threadgroup-scratch
|
|
// bridge and its barriers; the runtime Metal compiler must accept it.
|
|
var shader = Gen5ComputeFixtures.CompileRequestOrThrow(
|
|
Gen5ComputeFixtures.Wave64Broadcast, waveLaneCount: 64, localSizeX: 64);
|
|
Assert.Contains("sharpemu_wave_scratch", shader.Source);
|
|
Assert.Contains("threadgroup_barrier", shader.Source);
|
|
Assert.True(
|
|
MetalNative.TryCompileLibrary(shader.Source, out _, out var error),
|
|
$"Metal rejected the wave64 MSL: {error}\n{shader.Source}");
|
|
}
|
|
|
|
[Fact]
|
|
public void Wave64BroadcastExecutesAcrossBothHalvesWithoutDeadlock()
|
|
{
|
|
if (SkipWithoutMetalDevice("the execution test")) return;
|
|
|
|
// 64 threads = one guest wave (two 32-wide simdgroups). The read-first-
|
|
// lane bridge takes a threadgroup_barrier reached by all 64 lanes; if
|
|
// the two halves did not rendezvous this would deadlock (the command
|
|
// buffer would never complete). Every lane holds 42, so the broadcast
|
|
// result is 42 — proving the bridge runs to completion and returns the
|
|
// published value.
|
|
var result = ExecuteRequestOrThrow(Gen5ComputeFixtures.Wave64Broadcast, new byte[16],
|
|
waveLaneCount: 64, localSizeX: 64, threadsPerThreadgroup: 64);
|
|
|
|
Assert.Equal(42u, ReadDword(result, 0));
|
|
}
|
|
|
|
[Fact]
|
|
public void TypedLoadFixtureReturnsInstructionFormatComponents()
|
|
{
|
|
if (SkipWithoutMetalDevice("the execution test")) return;
|
|
|
|
const uint Sentinel = 0xDEADBEEFu;
|
|
var buffer = new byte[32];
|
|
for (var offset = 0; offset < buffer.Length; offset += sizeof(uint))
|
|
{
|
|
BinaryPrimitives.WriteUInt32LittleEndian(buffer.AsSpan(offset), Sentinel);
|
|
}
|
|
|
|
var first = BitConverter.SingleToUInt32Bits(1.5f);
|
|
var second = BitConverter.SingleToUInt32Bits(-2.25f);
|
|
BinaryPrimitives.WriteUInt32LittleEndian(buffer.AsSpan(0), first);
|
|
BinaryPrimitives.WriteUInt32LittleEndian(buffer.AsSpan(4), second);
|
|
|
|
var result = ExecuteRequestOrThrow(Gen5ComputeFixtures.TypedLoad, buffer);
|
|
|
|
// The descriptor says unorm bytes; the instruction's 32_32 float format wins.
|
|
Assert.Equal(first, ReadDword(result, 16));
|
|
Assert.Equal(second, ReadDword(result, 20));
|
|
Assert.Equal(Sentinel, ReadDword(result, 8));
|
|
Assert.Equal(Sentinel, ReadDword(result, 12));
|
|
Assert.Equal(Sentinel, ReadDword(result, 24));
|
|
Assert.Equal(Sentinel, ReadDword(result, 28));
|
|
}
|
|
|
|
[Fact]
|
|
public void TypedStoreFixtureEncodesComponentsAtTheirFormatOffsets()
|
|
{
|
|
if (SkipWithoutMetalDevice("the execution test")) return;
|
|
|
|
const uint Sentinel = 0xDEADBEEFu;
|
|
var buffer = new byte[32];
|
|
for (var offset = 0; offset < buffer.Length; offset += sizeof(uint))
|
|
{
|
|
BinaryPrimitives.WriteUInt32LittleEndian(buffer.AsSpan(offset), Sentinel);
|
|
}
|
|
|
|
var result = ExecuteRequestOrThrow(Gen5ComputeFixtures.TypedStore, buffer);
|
|
|
|
// Format 8_8 unorm: 1.0 and 0.5 encode to 0xFF and 0x80 in their own bytes; the rest is untouched.
|
|
Assert.Equal(0xDEAD80FFu, ReadDword(result, 16));
|
|
for (var offset = 0; offset < result.Length; offset += sizeof(uint))
|
|
{
|
|
if (offset != 16) Assert.Equal(Sentinel, ReadDword(result, offset));
|
|
}
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(true, false, 1u, 28u, 0xFACu, true)]
|
|
[InlineData(true, false, 2u, 24u, 0xFACu, true)]
|
|
[InlineData(true, false, 2u, 28u, 0xFACu, false)]
|
|
[InlineData(false, false, 1u, 28u, 0xFACu, true)]
|
|
[InlineData(false, false, 2u, 28u, 4u | (4u << 3), true)]
|
|
[InlineData(false, false, 2u, 28u, 0xFACu, false)]
|
|
[InlineData(true, true, 1u, 28u, 0xFACu, true)]
|
|
[InlineData(true, true, 2u, 24u, 0xFACu, true)]
|
|
[InlineData(true, true, 2u, 28u, 0xFACu, false)]
|
|
public void FormattedAccessBoundsUseOnlyRequiredComponents(
|
|
bool typed, bool store, uint componentCount, uint offset, uint selectors, bool inBounds)
|
|
{
|
|
if (SkipWithoutMetalDevice("the component bounds test")) return;
|
|
|
|
var buffer = new byte[32];
|
|
for (var position = 0; position < buffer.Length; position += 4)
|
|
BinaryPrimitives.WriteUInt32LittleEndian(buffer.AsSpan(position), 0xDEADBEEF);
|
|
var expected = (byte[])buffer.Clone();
|
|
var words = new List<uint> { 0xBE8B03FF, (77u << 12) | selectors };
|
|
if (store)
|
|
{
|
|
words.AddRange([0x7E0402FF, 0x3F800000, 0x7E0602FF, 0x40000000]);
|
|
}
|
|
var opcode = componentCount - 1 + (store ? 4u : 0u);
|
|
words.Add(typed ? 0xE8000000 | (77u << 19) | (opcode << 16) | offset
|
|
: 0xE0000000 | (opcode << 18) | offset);
|
|
words.Add(0x80020200);
|
|
for (uint component = 0; component < componentCount; component++)
|
|
{
|
|
if (store)
|
|
{
|
|
if (inBounds)
|
|
BinaryPrimitives.WriteUInt32LittleEndian(expected.AsSpan((int)(offset + component * 4)),
|
|
component == 0 ? 0x3F800000u : 0x40000000u);
|
|
}
|
|
else
|
|
{
|
|
words.Add(0xE0700000 | (component * 4));
|
|
words.Add(0x80020000 | ((2 + component) << 8));
|
|
BinaryPrimitives.WriteUInt32LittleEndian(expected.AsSpan((int)component * 4), inBounds ? 0xDEADBEEFu : 0u);
|
|
}
|
|
}
|
|
words.Add(0xBF810000);
|
|
var fixture = new Gen5ComputeFixture("component-bounds", words.ToArray(), StoreScalarResourceBase: 8, StoreBackingBytes: 32);
|
|
Assert.Equal(expected, ExecuteRequestOrThrow(fixture, buffer));
|
|
}
|
|
|
|
// ---- the request path: the argument buffer replaces the uniforms and per-slot buffers ----
|
|
|
|
[Fact]
|
|
public void AllFixturesCompileThroughTheArgumentBuffer()
|
|
{
|
|
if (SkipWithoutMetalDevice("compile validation")) return;
|
|
|
|
foreach (var fixture in Gen5ComputeFixtures.All)
|
|
{
|
|
var shader = Gen5ComputeFixtures.CompileRequestOrThrow(fixture);
|
|
Assert.True(
|
|
MetalNative.TryCompileLibrary(shader.Source, out _, out var error),
|
|
$"[{fixture.Name}] Metal rejected the request-path MSL: {error}\n{shader.Source}");
|
|
}
|
|
|
|
var pixel = Gen5ComputeFixtures.CompileStageRequestOrThrow(Gen5ComputeFixtures.PixelWords, Resources.ShaderStage.Pixel);
|
|
Assert.True(MetalNative.TryCompileLibrary(pixel.Source, out _, out var pixelError), $"[pixel] {pixelError}\n{pixel.Source}");
|
|
var vertex = Gen5ComputeFixtures.CompileStageRequestOrThrow(Gen5ComputeFixtures.VertexWords, Resources.ShaderStage.Vertex, requiredVertexOutputCount: 2);
|
|
Assert.True(MetalNative.TryCompileLibrary(vertex.Source, out _, out var vertexError), $"[vertex] {vertexError}\n{vertex.Source}");
|
|
}
|
|
|
|
[Fact]
|
|
public void ExecStoreProgramExecutesThroughTheArgumentBuffer()
|
|
{
|
|
if (SkipWithoutMetalDevice("the execution test")) return;
|
|
|
|
const uint Sentinel = 0xDEADBEEFu;
|
|
var buffer = new byte[64];
|
|
for (var offset = 0; offset < buffer.Length; offset += sizeof(uint))
|
|
{
|
|
BinaryPrimitives.WriteUInt32LittleEndian(buffer.AsSpan(offset), Sentinel);
|
|
}
|
|
|
|
var result = ExecuteRequestOrThrow(Gen5ComputeFixtures.ExecStore, buffer);
|
|
|
|
var fmac = BitConverter.SingleToUInt32Bits(MathF.FusedMultiplyAdd(1.5f, 2.25f, 10.0f));
|
|
var mulHiSigned = (uint)(((long)0x7FFFFFFF * 0x00010003) >> 32);
|
|
var mulLoSigned = unchecked(0x7FFFFFFFu * 0x00010003u);
|
|
var movBits = BitConverter.SingleToUInt32Bits(1.5f);
|
|
|
|
Assert.Equal(fmac, ReadDword(result, 0));
|
|
Assert.Equal(mulHiSigned, ReadDword(result, 4));
|
|
Assert.Equal(mulLoSigned, ReadDword(result, 8));
|
|
Assert.Equal(Sentinel, ReadDword(result, 12));
|
|
Assert.Equal(movBits, ReadDword(result, 16));
|
|
}
|
|
|
|
[Fact]
|
|
public void LoopProgramIteratesThroughTheArgumentBuffer()
|
|
{
|
|
if (SkipWithoutMetalDevice("the execution test")) return;
|
|
|
|
var result = ExecuteRequestOrThrow(Gen5ComputeFixtures.Loop, new byte[16]);
|
|
Assert.Equal(15u, ReadDword(result, 0));
|
|
}
|
|
|
|
[Fact]
|
|
public void TypedLoadFixtureReturnsInstructionFormatComponentsThroughTheArgumentBuffer()
|
|
{
|
|
if (SkipWithoutMetalDevice("the execution test")) return;
|
|
|
|
const uint Sentinel = 0xDEADBEEFu;
|
|
var buffer = new byte[32];
|
|
for (var offset = 0; offset < buffer.Length; offset += sizeof(uint))
|
|
{
|
|
BinaryPrimitives.WriteUInt32LittleEndian(buffer.AsSpan(offset), Sentinel);
|
|
}
|
|
|
|
var first = BitConverter.SingleToUInt32Bits(1.5f);
|
|
var second = BitConverter.SingleToUInt32Bits(-2.25f);
|
|
BinaryPrimitives.WriteUInt32LittleEndian(buffer.AsSpan(0), first);
|
|
BinaryPrimitives.WriteUInt32LittleEndian(buffer.AsSpan(4), second);
|
|
|
|
var result = ExecuteRequestOrThrow(Gen5ComputeFixtures.TypedLoad, buffer);
|
|
|
|
Assert.Equal(first, ReadDword(result, 16));
|
|
Assert.Equal(second, ReadDword(result, 20));
|
|
Assert.Equal(Sentinel, ReadDword(result, 8));
|
|
}
|
|
|
|
private static byte[] ExecuteRequestOrThrow(Gen5ComputeFixture fixture, byte[] buffer,
|
|
uint waveLaneCount = 32, uint localSizeX = 32, uint threadsPerThreadgroup = 1)
|
|
{
|
|
var shader = Gen5ComputeFixtures.CompileRequestOrThrow(fixture, waveLaneCount, localSizeX);
|
|
if (!MetalNative.TryCompileLibrary(shader.Source, out var library, out var compileError))
|
|
{
|
|
throw new InvalidOperationException($"[{fixture.Name}] {compileError}\n{shader.Source}");
|
|
}
|
|
|
|
// The user data registers hold zero; bounds come from the argument buffer lengths.
|
|
var pushData = new byte[Resources.PushData.ByteSize];
|
|
if (!MetalNative.TryExecuteWithArgumentBuffer(
|
|
library,
|
|
shader.EntryPoint,
|
|
buffer,
|
|
pushData,
|
|
shader.ArgumentLayout ?? throw new InvalidOperationException($"[{fixture.Name}] no argument layout"),
|
|
threadsPerThreadgroup,
|
|
out var result,
|
|
out var runError))
|
|
{
|
|
throw new InvalidOperationException($"[{fixture.Name}] {runError}");
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
// Without a Metal device the test skips, unless the required gate runs on a Mac.
|
|
private bool SkipWithoutMetalDevice(string activity)
|
|
{
|
|
if (MetalNative.IsAvailable) return false;
|
|
Assert.False(
|
|
OperatingSystem.IsMacOS() && Environment.GetEnvironmentVariable("SHARPEMU_TEST_REQUIRE_DEVICE") == "1",
|
|
$"The required gate cannot run {activity} without a Metal device.");
|
|
output.WriteLine($"No Metal device on this host; {activity} skipped.");
|
|
return true;
|
|
}
|
|
|
|
private static uint ReadDword(byte[] buffer, int offset) =>
|
|
BinaryPrimitives.ReadUInt32LittleEndian(buffer.AsSpan(offset));
|
|
}
|