diff --git a/src/SharpEmu.ShaderCompiler.Vulkan/Gen5SpirvTranslator.Alu.cs b/src/SharpEmu.ShaderCompiler.Vulkan/Gen5SpirvTranslator.Alu.cs index fca2dca..fee4353 100644 --- a/src/SharpEmu.ShaderCompiler.Vulkan/Gen5SpirvTranslator.Alu.cs +++ b/src/SharpEmu.ShaderCompiler.Vulkan/Gen5SpirvTranslator.Alu.cs @@ -5009,7 +5009,14 @@ public static partial class Gen5SpirvTranslator var sourceValue = GetRawSource(instruction, 0); var selectedLane = BitwiseAnd(GetRawSource(instruction, 1), UInt(LaneSelectMask)); - if (_emulateWave64) + if (_emulateWave64 && instruction.Sources[0].Kind == Gen5OperandKind.VectorRegister && + TryGetConstantLane(instruction, out var spillLane) && + _laneSpillSlots.TryGetValue((instruction.Sources[0].Value, spillLane), out var spillSlot)) + { + // A pure scalar spill slot: every invocation already holds the wave-uniform value. + StoreS(destination, Load(_uintType, spillSlot)); + } + else if (_emulateWave64) { // The selected guest lane can belong to another host subgroup. // Read it even when the guest execution mask disables that lane. diff --git a/src/SharpEmu.ShaderCompiler.Vulkan/Gen5SpirvTranslator.cs b/src/SharpEmu.ShaderCompiler.Vulkan/Gen5SpirvTranslator.cs index f1e7529..4c19aa0 100644 --- a/src/SharpEmu.ShaderCompiler.Vulkan/Gen5SpirvTranslator.cs +++ b/src/SharpEmu.ShaderCompiler.Vulkan/Gen5SpirvTranslator.cs @@ -172,6 +172,7 @@ public static partial class Gen5SpirvTranslator private uint _storageBlockPointer; private uint _storageUintPointer; private uint _lds; + private uint _ldsWritten; private uint _ldsElementPointer; private uint _lds64ElementPointer; private uint _ldsDwordMask; @@ -772,14 +773,24 @@ public static partial class Gen5SpirvTranslator var ldsPointer = _module.TypePointer(storageClass, ldsArrayType); _ldsElementPointer = _module.TypePointer(storageClass, _uintType); _lds64ElementPointer = _module.TypePointer(storageClass, _ulongType); - _lds = storageClass == SpirvStorageClass.Workgroup - ? _module.AddGlobalVariable(ldsPointer, storageClass) - : _module.AddGlobalVariable( - ldsPointer, - storageClass, - _module.ConstantNull(ldsArrayType)); + // A private array must read zero where this invocation has not written, but + // zero-initializing it costs every invocation the whole array: Metal keeps a + // dynamically indexed private array in stack memory, so an NGG vertex shader + // cleared 8 KiB per vertex. A written-dword bitmap clears 1/32 of that, and + // LdsPointer zeroes a slot the first time it is addressed. + _lds = _module.AddGlobalVariable(ldsPointer, storageClass); _module.AddName(_lds, "lds"); _interfaces.Add(_lds); + if (storageClass == SpirvStorageClass.Private) + { + var bitmapType = _module.TypeArray(_uintType, (arrayDwordCount + 31) / 32); + _ldsWritten = _module.AddGlobalVariable( + _module.TypePointer(SpirvStorageClass.Private, bitmapType), + SpirvStorageClass.Private, + _module.ConstantNull(bitmapType)); + _module.AddName(_ldsWritten, "ldsWritten"); + _interfaces.Add(_ldsWritten); + } } internal static SpirvImageFormat DecodeStorageImageFormat( @@ -1457,6 +1468,8 @@ public static partial class Gen5SpirvTranslator // need LDS phase ordering when reads and writes span basic blocks. var synchronizeSharedMemory = _emulateWave64 && halfMaskPlan is null; var sharedMemoryPhase = SharedMemoryPhase.None; + // A wait in another block may or may not have run before this one. + _vectorMemoryWaited = null; for (var index = block.StartIndex; index < block.EndIndex; index++) { var instruction = _request.Program.Instructions[index]; @@ -2134,10 +2147,15 @@ public static partial class Gen5SpirvTranslator { return true; } + if (instruction.Opcode == "SWaitcnt") + { + NoteWaitCount(instruction); + return true; + } + if (instruction.Opcode is "SNop" or "SSetregB32" or - "SWaitcnt" or "SInstPrefetch" or "STtraceData" or // Wave scheduling priority hint; no effect on results. @@ -2154,11 +2172,17 @@ public static partial class Gen5SpirvTranslator { if (_stage == Gen5SpirvStage.Compute) { - // s_waitcnt vmcnt(0) + s_barrier also publishes buffer and image - // stores to the workgroup: AcquireRelease over uniform, workgroup - // and image memory. + // s_barrier orders execution; what it publishes is whatever the guest + // waited for first. s_waitcnt vmcnt(0) before it means buffer and image + // stores are being published to the workgroup, so the barrier carries + // AcquireRelease over uniform, workgroup and image memory. A barrier that + // only follows lgkmcnt(0) publishes LDS, and ordering device memory there + // costs a cache flush the guest never asked for: UE's FFT bloom + // convolution barriers 48 times per group, every one of them lgkmcnt-only. var workgroup = UInt(2); - var semantics = UInt(0x8 | 0x40 | 0x100 | 0x800); + var semantics = UInt(_vectorMemoryWaited == false + ? 0x8u | 0x100u + : 0x8u | 0x40u | 0x100u | 0x800u); _module.AddStatement( SpirvOp.ControlBarrier, workgroup, @@ -2683,11 +2707,30 @@ public static partial class Gen5SpirvTranslator var index = BitwiseAnd( ShiftRightLogical(addressWithOffset, UInt(2)), UInt(_ldsDwordMask)); - return _module.AddInstruction( + var pointer = _module.AddInstruction( SpirvOp.AccessChain, _ldsElementPointer, _lds, index); + if (_ldsWritten != 0) + { + var wordPointer = _module.AddInstruction( + SpirvOp.AccessChain, + _privateUintPointer, + _ldsWritten, + ShiftRightLogical(index, UInt(5))); + var bit = ShiftLeftLogical(UInt(1), BitwiseAnd(index, UInt(31))); + var word = Load(_uintType, wordPointer); + var unwritten = _module.AddInstruction( + SpirvOp.IEqual, _boolType, BitwiseAnd(word, bit), UInt(0)); + EmitConditional(unwritten, () => + { + Store(pointer, UInt(0)); + Store(wordPointer, BitwiseOr(word, bit)); + }); + } + + return pointer; } private void StoreLds(uint pointer, uint value) @@ -7466,7 +7509,7 @@ public static partial class Gen5SpirvTranslator var slots = new List<(uint Register, uint Lane)>(); if (_emulateWave64) { - return slots; + return FindPureLaneSpillSlots(); } var ownsLaneZero = !UsesSubgroupOperations(); @@ -7492,6 +7535,70 @@ public static partial class Gen5SpirvTranslator return slots; } + // Under wave64 emulation a lane read normally rendezvous with the other host subgroup through + // LDS and a workgroup barrier. A VGPR written only by constant-lane V_WRITELANE holds spilled + // scalars, which are wave-uniform, so every invocation can keep each written lane itself and a + // constant-lane V_READLANE needs no exchange. V_WRITELANE still updates the VGPR, so other + // reads of it are unaffected. Relative VGPR addressing could alias any register, so programs + // that use it keep the exchange. + private List<(uint Register, uint Lane)> FindPureLaneSpillSlots() + { + var slots = new List<(uint Register, uint Lane)>(); + var instructions = _request.Program.Instructions; + if (instructions.Any(static instruction => instruction.Opcode.StartsWith("VMovrel", StringComparison.Ordinal))) + { + return slots; + } + + var candidates = new HashSet(); + var rejected = new HashSet(); + foreach (var instruction in instructions) + { + var isWritelane = instruction.Opcode == "VWritelaneB32"; + var isReadlane = instruction.Opcode == "VReadlaneB32"; + for (var index = 0; index < instruction.Destinations.Count; index++) + { + var operand = instruction.Destinations[index]; + if (operand.Kind != Gen5OperandKind.VectorRegister) + { + continue; + } + + if (isWritelane && index == 0 && TryGetConstantLane(instruction, out _)) + { + candidates.Add(operand.Value); + } + else + { + rejected.Add(operand.Value); + } + } + + // A variable lane read cannot use a slot; it keeps reading the VGPR, which V_WRITELANE + // still writes, so ordinary vector reads of the register stay exact as well. + if (isReadlane && instruction.Sources.Count > 0 && + instruction.Sources[0].Kind == Gen5OperandKind.VectorRegister && + !TryGetConstantLane(instruction, out _)) + { + rejected.Add(instruction.Sources[0].Value); + } + } + + foreach (var instruction in instructions) + { + if (instruction.Opcode == "VWritelaneB32" && + TryGetVectorDestination(instruction, out var register) && + candidates.Contains(register) && !rejected.Contains(register) && + TryGetConstantLane(instruction, out var lane) && + !slots.Contains((register, lane))) + { + slots.Add((register, lane)); + } + } + + return slots; + } + // The lane select operand (source 1) of V_READLANE/V_WRITELANE when it is a constant. private bool TryGetConstantLane(Gen5ShaderInstruction instruction, out uint lane) { @@ -7716,6 +7823,25 @@ public static partial class Gen5SpirvTranslator { if (_waveLaneCount != 32) { + // The lane's bit lives in one of the two mask registers, so select that register + // and shift inside it. Composing the 64-bit mask and masking it with a 64-bit + // lane bit costs several times as much on a host that emulates 64-bit integers, + // and VCC and EXEC refresh their lane flag on every mask write. + if (_subgroupInvocationIdInput != 0 && _emulateWave64) + { + var lane = GuestWaveLane(); + var word = _module.AddInstruction( + SpirvOp.Select, + _uintType, + _module.AddInstruction(SpirvOp.ULessThan, _boolType, lane, UInt(32)), + LoadS(lowRegister), + LoadS(lowRegister + 1)); + return IsNotZero( + BitwiseAnd( + ShiftRightLogical(word, BitwiseAnd(lane, UInt(31))), + UInt(1))); + } + return IsWaveMaskActive(LoadS64(lowRegister)); } @@ -8218,6 +8344,35 @@ public static partial class Gen5SpirvTranslator return _halfMaskPlan; } + // Null until a wait is seen in the current block; then true once any wait in the block + // drained vector memory (vmcnt(0), or vscnt(0), which is what covers stores on GFX10), + // which is how the guest publishes buffer and image stores before a barrier. It stays + // true for the rest of the block: a later LDS-only wait does not unpublish them. + private bool? _vectorMemoryWaited; + + private void NoteWaitCount(Gen5ShaderInstruction instruction) + { + if (instruction.Words.Count == 0) + { + return; + } + + var word = instruction.Words[0]; + if (instruction.Encoding == Gen5ShaderEncoding.Sopk) + { + // SOPK waits name one counter: 0x17 vscnt, 0x18 vmcnt, 0x19 expcnt, 0x1A lgkmcnt. + var counter = (word >> 23) & 0x1Fu; + var drainsVectorMemory = counter is 0x17 or 0x18 && (word & 0xFFFFu) == 0; + _vectorMemoryWaited = _vectorMemoryWaited == true || drainsVectorMemory; + return; + } + + // SOPP S_WAITCNT packs vmcnt across bits [3:0] and [15:14]. + var immediate = word & 0xFFFFu; + var vectorCount = (immediate & 0xFu) | (((immediate >> 14) & 0x3u) << 4); + _vectorMemoryWaited = _vectorMemoryWaited == true || vectorCount == 0; + } + private void EmitWave64Barrier() { var workgroup = UInt(2); diff --git a/tests/SharpEmu.ShaderCompiler.Tests/Gen5BarrierMemoryScopeTests.cs b/tests/SharpEmu.ShaderCompiler.Tests/Gen5BarrierMemoryScopeTests.cs new file mode 100644 index 0000000..eb1f718 --- /dev/null +++ b/tests/SharpEmu.ShaderCompiler.Tests/Gen5BarrierMemoryScopeTests.cs @@ -0,0 +1,149 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using System.Buffers.Binary; +using SharpEmu.HLE; +using SharpEmu.ShaderCompiler.Tests.Resources; +using SharpEmu.ShaderCompiler.Vulkan; +using Xunit; + +namespace SharpEmu.ShaderCompiler.Tests; + +// What s_barrier publishes is whatever the guest waited for before it. +public sealed class Gen5BarrierMemoryScopeTests +{ + private const ulong ShaderAddress = 0x1_0000_0000; + private const uint Barrier = 0xBF8A0000; + private const uint EndProgram = 0xBF810000; + private const uint WaitLgkmOnly = 0xBF8CC07F; // s_waitcnt lgkmcnt(0) + private const uint WaitVectorMemory = 0xBF8C0F70; // s_waitcnt vmcnt(0) + + private const uint AcquireRelease = 0x8; + private const uint UniformMemory = 0x40; + private const uint WorkgroupMemory = 0x100; + private const uint ImageMemory = 0x800; + + [Theory] + [InlineData(WaitLgkmOnly, AcquireRelease | WorkgroupMemory)] + [InlineData(WaitVectorMemory, AcquireRelease | UniformMemory | WorkgroupMemory | ImageMemory)] + public void BarrierPublishesWhatTheWaitCountWaitedFor(uint wait, uint expected) + { + var program = Decode([wait, Barrier, EndProgram]); + Assert.Equal("SBarrier", program.Instructions[1].Opcode); + + var request = ResourceTestProgram.Request(program, userDataCount: 0); + Assert.True(Gen5SpirvTranslator.TryCompileProgram(request, out var shader, out var error), error); + Assert.Equal(expected, ControlBarrierSemantics(shader.Spirv)); + } + + private const uint WaitStoresZeroSopk = 0xBBFD0000; // s_waitcnt_vscnt null, 0 + private const uint WaitLgkmZeroSopk = 0xBD7D0000; // s_waitcnt_lgkmcnt null, 0 + + // GFX10 tracks stores with vscnt, and a drained counter stays drained for the block. + [Theory] + [InlineData(new[] { WaitStoresZeroSopk, WaitLgkmOnly }, AcquireRelease | UniformMemory | WorkgroupMemory | ImageMemory)] + [InlineData(new[] { WaitVectorMemory, WaitLgkmOnly }, AcquireRelease | UniformMemory | WorkgroupMemory | ImageMemory)] + [InlineData(new[] { WaitLgkmZeroSopk }, AcquireRelease | WorkgroupMemory)] + public void StoreWaitsCountAndStayCountedAcrossLaterWaits(uint[] waits, uint expected) + { + var program = Decode([.. waits, Barrier, EndProgram]); + var request = ResourceTestProgram.Request(program, userDataCount: 0); + Assert.True(Gen5SpirvTranslator.TryCompileProgram(request, out var shader, out var error), error); + Assert.Equal(expected, ControlBarrierSemantics(shader.Spirv)); + } + + // A barrier with no wait before it in its block keeps the conservative semantics: the + // publish may have happened in a block the translator cannot order against this one. + [Fact] + public void BarrierWithoutAWaitCountKeepsDeviceMemoryOrdering() + { + var program = Decode([Barrier, EndProgram]); + var request = ResourceTestProgram.Request(program, userDataCount: 0); + Assert.True(Gen5SpirvTranslator.TryCompileProgram(request, out var shader, out var error), error); + Assert.Equal( + AcquireRelease | UniformMemory | WorkgroupMemory | ImageMemory, + ControlBarrierSemantics(shader.Spirv)); + } + + // The value of the memory-semantics operand of the module's single OpControlBarrier. + private static uint ControlBarrierSemantics(byte[] spirv) + { + var words = new uint[spirv.Length / 4]; + Buffer.BlockCopy(spirv, 0, words, 0, spirv.Length); + var constants = new Dictionary(); + uint? semanticsId = null; + for (var offset = 5; offset < words.Length;) + { + var wordCount = (int)(words[offset] >> 16); + var opcode = (ushort)(words[offset] & 0xFFFF); + if (opcode == (ushort)SpirvOp.Constant && wordCount == 4) + { + constants[words[offset + 2]] = words[offset + 3]; + } + else if (opcode == (ushort)SpirvOp.ControlBarrier) + { + Assert.Null(semanticsId); + semanticsId = words[offset + 3]; + } + + offset += wordCount; + } + + Assert.NotNull(semanticsId); + return constants[semanticsId.Value]; + } + + private static Gen5ShaderProgram Decode(IReadOnlyList words) + { + var memory = new TestCpuMemory(ShaderAddress, words.Count * sizeof(uint)); + var bytes = new byte[words.Count * sizeof(uint)]; + for (var index = 0; index < words.Count; index++) + { + BinaryPrimitives.WriteUInt32LittleEndian(bytes.AsSpan(index * sizeof(uint)), words[index]); + } + + Assert.True(memory.TryWrite(ShaderAddress, bytes)); + var context = new CpuContext(memory, Generation.Gen5); + Assert.True(Gen5ShaderTranslator.TryDecodeProgram(context, ShaderAddress, out var program, out var error), error); + return program; + } + + private sealed class TestCpuMemory(ulong baseAddress, int size) : ICpuMemory + { + private readonly byte[] _storage = new byte[size]; + + public bool TryRead(ulong virtualAddress, Span destination) + { + if (!TryResolve(virtualAddress, destination.Length, out var offset)) + { + return false; + } + + _storage.AsSpan(offset, destination.Length).CopyTo(destination); + return true; + } + + public bool TryWrite(ulong virtualAddress, ReadOnlySpan source) + { + if (!TryResolve(virtualAddress, source.Length, out var offset)) + { + return false; + } + + source.CopyTo(_storage.AsSpan(offset, source.Length)); + return true; + } + + private bool TryResolve(ulong address, int length, out int offset) + { + offset = 0; + if (address < baseAddress || address - baseAddress > int.MaxValue) + { + return false; + } + + offset = (int)(address - baseAddress); + return offset <= _storage.Length - length; + } + } +} diff --git a/tests/SharpEmu.ShaderCompiler.Tests/Gen5PrivateLdsTests.cs b/tests/SharpEmu.ShaderCompiler.Tests/Gen5PrivateLdsTests.cs new file mode 100644 index 0000000..94906c6 --- /dev/null +++ b/tests/SharpEmu.ShaderCompiler.Tests/Gen5PrivateLdsTests.cs @@ -0,0 +1,105 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using SharpEmu.ShaderCompiler.Resources; +using SharpEmu.ShaderCompiler.Tests.Resources; +using SharpEmu.ShaderCompiler.Vulkan; +using Xunit; +using static SharpEmu.ShaderCompiler.Tests.Resources.ResourceTestProgram; + +namespace SharpEmu.ShaderCompiler.Tests; + +// Graphics stages model LDS as a per-invocation private array that reads zero where the +// invocation has not written. A written-dword bitmap provides that instead of a zero +// initializer over the whole array. +public sealed class Gen5PrivateLdsTests +{ + private const uint PrivateStorageClass = 6; + private const uint WorkgroupStorageClass = 4; + + [Fact] + public void VertexStageLdsIsLazilyZeroedThroughAWrittenBitmap() + { + var spirv = Compile(ShaderStage.Vertex); + + var module = new SpirvModuleInspector(spirv); + var lds = module.Names.Single(pair => pair.Value == "lds").Key; + var written = module.Names.Single(pair => pair.Value == "ldsWritten").Key; + Assert.Equal(PrivateStorageClass, module.VariableStorageClasses[lds]); + Assert.Equal(PrivateStorageClass, module.VariableStorageClasses[written]); + Assert.False(HasInitializer(spirv, lds)); + Assert.True(HasInitializer(spirv, written)); + ValidateWhenAvailable(spirv); + } + + [Fact] + public void ComputeLdsStaysWorkgroupMemoryWithoutABitmap() + { + var spirv = Compile(ShaderStage.Compute); + + var module = new SpirvModuleInspector(spirv); + var lds = module.Names.Single(pair => pair.Value == "lds").Key; + Assert.Equal(WorkgroupStorageClass, module.VariableStorageClasses[lds]); + Assert.DoesNotContain("ldsWritten", module.Names.Values); + } + + private static byte[] Compile(ShaderStage stage) + { + var program = Program( + Vop2(0, "VLshlrevB32", 3, Operand(2), Gen5Operand.Vector(0)), + DataShare(4, "DsWriteB32", false, [Gen5Operand.Vector(3), Gen5Operand.Vector(0)], []), + DataShare(12, "DsReadB32", false, [Gen5Operand.Vector(3)], [5], 4), + EndProgram(20)); + var request = Request(program, stage); + Assert.True(Gen5SpirvTranslator.TryCompileProgram(request, out var shader, out var error), error); + return 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 System.Diagnostics.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 = System.Diagnostics.Process.Start(start)!; + var error = process.StandardError.ReadToEnd(); + process.WaitForExit(); + Assert.True(process.ExitCode == 0, error); + } + finally + { + File.Delete(path); + } + } + + // OpVariable carries an initializer operand after its storage class. + private static bool HasInitializer(byte[] spirv, uint variable) + { + var words = new uint[spirv.Length / 4]; + Buffer.BlockCopy(spirv, 0, words, 0, spirv.Length); + for (var offset = 5; offset < words.Length;) + { + var wordCount = (int)(words[offset] >> 16); + if ((SpirvOp)(words[offset] & 0xFFFF) == SpirvOp.Variable && words[offset + 2] == variable) + { + return wordCount > 4; + } + + offset += Math.Max(wordCount, 1); + } + + throw new InvalidOperationException($"Variable {variable} is not declared."); + } +} diff --git a/tests/SharpEmu.ShaderCompiler.Tests/Gen5ScalarLaneTransferTests.cs b/tests/SharpEmu.ShaderCompiler.Tests/Gen5ScalarLaneTransferTests.cs index 1e4bd1e..6908d81 100644 --- a/tests/SharpEmu.ShaderCompiler.Tests/Gen5ScalarLaneTransferTests.cs +++ b/tests/SharpEmu.ShaderCompiler.Tests/Gen5ScalarLaneTransferTests.cs @@ -322,6 +322,56 @@ public sealed class Gen5ScalarLaneTransferTests Assert.Equal(expectSlot, text.Contains("v86_lane3", StringComparison.Ordinal)); } + [Theory] + [InlineData(false, true)] + [InlineData(true, false)] + public void EmulatedWave64KeepsPureScalarSpillsWithoutAnExchange(bool vectorWrite, bool expectSlot) + { + // Wave64 on a 32-lane host reads another lane through LDS and a workgroup barrier. A VGPR + // that only V_WRITELANE writes holds wave-uniform spilled scalars, so its constant-lane + // reads need no exchange; once an ordinary instruction writes it, the exchange returns. + const ulong shaderAddress = 0x1000; + var memory = new TestCpuMemory(shaderAddress, 0x100); + var words = new List + { + 0xD7610056, 0x0001047E, // v_writelane_b32 v86, s126, 2 + 0xD7610056, 0x0001067F, // v_writelane_b32 v86, s127, 3 + }; + if (vectorWrite) + { + words.Add(0x7EAC0280); // v_mov_b32 v86, 0 + } + + words.AddRange( + [ + 0xD7600000, 0x00010556, // v_readlane_b32 s0, v86, 2 + 0xD7600001, 0x00010756, // v_readlane_b32 s1, v86, 3 + 0xBF810000, // s_endpgm + ]); + var shader = new byte[words.Count * sizeof(uint)]; + for (var index = 0; index < words.Count; index++) + { + BinaryPrimitives.WriteUInt32LittleEndian(shader.AsSpan(index * sizeof(uint)), words[index]); + } + + Assert.True(memory.TryWrite(shaderAddress, shader)); + var ctx = new CpuContext(memory, Generation.Gen5); + Assert.True(Gen5ShaderTranslator.TryDecodeProgram(ctx, shaderAddress, out var program, out var decodeError), decodeError); + var (plan, resources, layout) = ResourceTestProgram.Prepare(program); + var request = new ShaderCompileRequest(plan, resources, layout) + { + LocalSizeX = 64, + ThreadCountX = 64, + WaveSize = 64, + }; + Assert.True(Gen5SpirvTranslator.TryCompileProgram(request, out var compiled, out var compileError), compileError); + + var text = System.Text.Encoding.ASCII.GetString(compiled.Spirv); + Assert.Equal(expectSlot, text.Contains("v86_lane2", StringComparison.Ordinal)); + Assert.Equal(expectSlot, text.Contains("v86_lane3", StringComparison.Ordinal)); + Assert.Equal(!expectSlot, ReadSpirvOpcodes(compiled.Spirv).Contains((ushort)SpirvOp.ControlBarrier)); + } + private static Gen5ShaderInstruction ScalarInstruction( uint pc, Gen5ShaderEncoding encoding, diff --git a/tests/SharpEmu.ShaderCompiler.Tests/Gen5WaveMaskLaneFlagTests.cs b/tests/SharpEmu.ShaderCompiler.Tests/Gen5WaveMaskLaneFlagTests.cs new file mode 100644 index 0000000..88295cc --- /dev/null +++ b/tests/SharpEmu.ShaderCompiler.Tests/Gen5WaveMaskLaneFlagTests.cs @@ -0,0 +1,126 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using System.Buffers.Binary; +using SharpEmu.HLE; +using SharpEmu.ShaderCompiler; +using SharpEmu.ShaderCompiler.Resources; +using SharpEmu.ShaderCompiler.Tests.Resources; +using SharpEmu.ShaderCompiler.Vulkan; +using Xunit; + +namespace SharpEmu.ShaderCompiler.Tests; + +// VCC and EXEC carry a per-lane boolean that every write to their registers refreshes. +public sealed class Gen5WaveMaskLaneFlagTests +{ + private const ulong ShaderAddress = 0x1_0000_0000; + + [Fact] + public void EmulatedWave64TestsTheLaneBitWithoutComposingTheMask() + { + var program = Decode( + [ + 0x7C020300, // v_cmp_lt_f32 vcc, v0, v1 (declares the lane input and writes a mask) + 0xBEFE0300, // s_mov_b32 exec_lo, s0 + 0xBF810000, // s_endpgm + ]); + Assert.Equal("VCmpLtF32", program.Instructions[0].Opcode); + + var (plan, resources, layout) = ResourceTestProgram.Prepare(program, ShaderStage.Compute, 0, 0, 0); + // A 64-thread group is the wave64-on-32-lanes case the translator emulates. + var request = new ShaderCompileRequest(plan, resources, layout) + { + WaveSize = 64, + LocalSizeX = 8, + LocalSizeY = 8, + LocalSizeZ = 1, + }; + Assert.True(Gen5SpirvTranslator.TryCompileProgram(request, out var shader, out var error), error); + + // The lane's bit lives in one of the two mask registers, so the refresh selects that + // register and shifts inside it. Composing the 64-bit mask and masking it with a 64-bit + // lane bit left 27 of these for this two-instruction program; the remaining six build + // the mask value itself, which stays 64-bit because the guest register pair is. + Assert.Equal(6, Count64BitBitwiseAnd(shader.Spirv)); + } + + private static int Count64BitBitwiseAnd(byte[] spirv) + { + var words = new uint[spirv.Length / 4]; + Buffer.BlockCopy(spirv, 0, words, 0, spirv.Length); + var wideTypes = new HashSet(); + var count = 0; + for (var offset = 5; offset < words.Length;) + { + var wordCount = (int)(words[offset] >> 16); + var opcode = (ushort)(words[offset] & 0xFFFF); + if (opcode == (ushort)SpirvOp.TypeInt && words[offset + 2] == 64) + { + wideTypes.Add(words[offset + 1]); + } + else if (opcode == (ushort)SpirvOp.BitwiseAnd && wideTypes.Contains(words[offset + 1])) + { + count++; + } + + offset += wordCount; + } + + return count; + } + + private static Gen5ShaderProgram Decode(IReadOnlyList words) + { + var memory = new TestCpuMemory(ShaderAddress, words.Count * sizeof(uint)); + var bytes = new byte[words.Count * sizeof(uint)]; + for (var index = 0; index < words.Count; index++) + { + BinaryPrimitives.WriteUInt32LittleEndian(bytes.AsSpan(index * sizeof(uint)), words[index]); + } + + Assert.True(memory.TryWrite(ShaderAddress, bytes)); + var context = new CpuContext(memory, Generation.Gen5); + Assert.True(Gen5ShaderTranslator.TryDecodeProgram(context, ShaderAddress, out var program, out var error), error); + return program; + } + + private sealed class TestCpuMemory(ulong baseAddress, int size) : ICpuMemory + { + private readonly byte[] _storage = new byte[size]; + + public bool TryRead(ulong virtualAddress, Span destination) + { + if (!TryResolve(virtualAddress, destination.Length, out var offset)) + { + return false; + } + + _storage.AsSpan(offset, destination.Length).CopyTo(destination); + return true; + } + + public bool TryWrite(ulong virtualAddress, ReadOnlySpan source) + { + if (!TryResolve(virtualAddress, source.Length, out var offset)) + { + return false; + } + + source.CopyTo(_storage.AsSpan(offset, source.Length)); + return true; + } + + private bool TryResolve(ulong address, int length, out int offset) + { + offset = 0; + if (address < baseAddress || address - baseAddress > int.MaxValue) + { + return false; + } + + offset = (int)(address - baseAddress); + return offset <= _storage.Length - length; + } + } +}