[SpirvTranslator] Cut emulated-wave64 and private-LDS overhead in translated shaders (#1034)

* [SpirvTranslator] Keep pure scalar lane spills in private slots under emulated wave64

A wave64 program run as a 64-thread workgroup on a 32-lane host emulates
v_readlane with an LDS exchange and a workgroup barrier. Compilers use
constant-lane v_writelane/v_readlane pairs on a VGPR as SGPR spill slots;
when that VGPR is written only by constant-lane writelanes and read only by
constant-lane readlanes, each (vgpr, lane) pair is a private scalar, so the
readlane loads the slot instead of exchanging through LDS. This halves the
heaviest compute dispatch in Silent Hill: The Short Message's menu.

(cherry picked from commit 9915b88e68b6b69d6b74ea931a22eb0589c56f1a)

* [SpirvTranslator] Zero private LDS lazily through a written-dword bitmap

Graphics stages model LDS as a per-invocation private array that must
read zero where the invocation has not written. The array carried a zero
initializer, but Metal keeps a dynamically indexed private array in stack
memory, so every invocation cleared all of it: an NGG vertex shader (the
PS5 default, with in-shader culling through LDS) cleared 8 KiB per vertex.

The array is now uninitialized and a zero-initialized bitmap of written
dwords (1/32 of the size) records which slots were addressed; LdsPointer
zeroes a slot the first time it is addressed, so loads, stores and atomics
keep the zero-read semantics. Compute LDS stays workgroup memory.
Silent Hill: The Short Message's menu, dominated by ~45M shadow-pass
vertices per frame, goes from about 2.0 to 3.1 FPS.

(cherry picked from commit 5738b8574397cf2be08cd4e1518b33057c8867b5)

* [SpirvTranslator] Test the lane's mask bit in one register under emulated wave64

VCC and EXEC keep a per-lane boolean that every mask write refreshes, and the
refresh composed the 64-bit mask from its two registers, built a 64-bit lane
bit and masked them. A host without 64-bit integer arithmetic (Apple GPUs
through MoltenVK) emulates each of those with a pair of 32-bit operations. The
bit lives in exactly one of the two registers, so selecting that register and
shifting inside it is the same test in 32-bit arithmetic.

Only the emulated-wave64 path changes; wave32 already tested one register, and
a native wave64 host keeps the existing form, which also clears the bit for
lanes outside the guest wave.

(cherry picked from commit 1b2651047fdf232304142c82168e487244dda4ef)

* [SpirvTranslator] Let s_barrier publish only what the guest waited for

Every compute s_barrier became an OpControlBarrier with AcquireRelease
over uniform, workgroup and image memory, on the assumption that the
guest had drained vector memory first. s_barrier only orders execution;
what it publishes is whatever the preceding s_waitcnt drained. A barrier
that follows an LDS-only wait (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, all LDS-only.

The translator now tracks, per block, whether any wait drained vector
memory - SOPP vmcnt(0), or the GFX10 SOPK vscnt(0)/vmcnt(0) forms, since
stores are counted by vscnt there - and a barrier after an LDS-only wait
carries workgroup memory only. A drained counter stays drained for the
rest of the block, and a barrier with no wait in its block keeps the
full semantics (the wait may be in a block this one cannot be ordered
against).

(cherry picked from commit 1f81df59bc07d05f2baa6283da95f4901dada444)
This commit is contained in:
kuba 2026-10-07 10:51:52 +02:00 committed by GitHub
parent 30321142cb
commit db32c9d882
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 606 additions and 14 deletions

View File

@ -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.

View File

@ -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<uint>();
var rejected = new HashSet<uint>();
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);

View File

@ -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, uint>();
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<uint> 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<byte> 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<byte> 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;
}
}
}

View File

@ -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.");
}
}

View File

@ -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<uint>
{
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,

View File

@ -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<uint>();
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<uint> 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<byte> 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<byte> 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;
}
}
}