sharpemu/tests/SharpEmu.ShaderCompiler.Met.../MetalRuntimeTests.cs

266 lines
10 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 (!MetalNative.IsAvailable)
{
output.WriteLine("No Metal device on this host; compile validation skipped.");
return;
}
foreach (var fixture in Gen5ComputeFixtures.All)
{
var shader = Gen5ComputeFixtures.CompileOrThrow(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 (!MetalNative.IsAvailable)
{
output.WriteLine("No Metal device on this host; execution test skipped.");
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 = ExecuteOrThrow(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 (!MetalNative.IsAvailable)
{
output.WriteLine("No Metal device on this host; execution test skipped.");
return;
}
var result = ExecuteOrThrow(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 (!MetalNative.IsAvailable)
{
output.WriteLine("No Metal device on this host; compile validation skipped.");
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 (!MetalNative.IsAvailable)
{
output.WriteLine("No Metal device on this host; compile validation skipped.");
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 (!MetalNative.IsAvailable)
{
output.WriteLine("No Metal device on this host; compile validation skipped.");
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 (!MetalNative.IsAvailable)
{
output.WriteLine("No Metal device on this host; execution test skipped.");
return;
}
var result = ExecuteOrThrow(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 (!MetalNative.IsAvailable)
{
output.WriteLine("No Metal device on this host; compile validation skipped.");
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.CompileOrThrow(
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 (!MetalNative.IsAvailable)
{
output.WriteLine("No Metal device on this host; execution test skipped.");
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 shader = Gen5ComputeFixtures.CompileOrThrow(
Gen5ComputeFixtures.Wave64Broadcast, waveLaneCount: 64, localSizeX: 64);
Assert.True(
MetalNative.TryCompileLibrary(shader.Source, out var library, out var compileError),
$"{compileError}\n{shader.Source}");
var buffer = new byte[16];
var uniforms = new byte[16 + sizeof(uint)];
// Dispatch limit 64: all 64 lanes active (see WriteDispatchLimit contract).
BinaryPrimitives.WriteUInt32LittleEndian(uniforms.AsSpan(0), 64);
BinaryPrimitives.WriteUInt32LittleEndian(uniforms.AsSpan(4), 1);
BinaryPrimitives.WriteUInt32LittleEndian(uniforms.AsSpan(8), 1);
BinaryPrimitives.WriteUInt32LittleEndian(uniforms.AsSpan(16), (uint)buffer.Length);
Assert.True(
MetalNative.TryExecuteThreadgroup(
library,
shader.EntryPoint,
buffer,
uniforms,
dataIndex: 0,
uniformsIndex: (nuint)shader.GlobalMemoryBindings.Count,
threadsPerThreadgroup: 64,
out var result,
out var runError),
runError);
Assert.Equal(42u, ReadDword(result, 0));
}
private static byte[] ExecuteOrThrow(Gen5ComputeFixture fixture, byte[] buffer)
{
var shader = Gen5ComputeFixtures.CompileOrThrow(fixture);
if (!MetalNative.TryCompileLibrary(shader.Source, out var library, out var compileError))
{
throw new InvalidOperationException($"[{fixture.Name}] {compileError}\n{shader.Source}");
}
// Per the translator contract, global buffers occupy indices
// 0..count-1 and SharpEmuUniforms sits at index count; the executable
// fixtures bind exactly one data buffer.
var bufferCount = shader.GlobalMemoryBindings.Count;
Assert.Equal(1, bufferCount);
// SharpEmuUniforms: dispatch limit (one thread), reserved, then the
// byte length of each bound buffer (the struct's array never has fewer
// than one entry).
var uniforms = new byte[16 + (Math.Max(bufferCount, 1) * sizeof(uint))];
BinaryPrimitives.WriteUInt32LittleEndian(uniforms.AsSpan(0), 1);
BinaryPrimitives.WriteUInt32LittleEndian(uniforms.AsSpan(4), 1);
BinaryPrimitives.WriteUInt32LittleEndian(uniforms.AsSpan(8), 1);
BinaryPrimitives.WriteUInt32LittleEndian(uniforms.AsSpan(16), (uint)buffer.Length);
if (!MetalNative.TryExecuteSingleThread(
library,
shader.EntryPoint,
buffer,
uniforms,
dataIndex: 0,
uniformsIndex: (nuint)bufferCount,
out var result,
out var runError))
{
throw new InvalidOperationException($"[{fixture.Name}] {runError}");
}
return result;
}
private static uint ReadDword(byte[] buffer, int offset) =>
BinaryPrimitives.ReadUInt32LittleEndian(buffer.AsSpan(offset));
}