[GPU] Run the command stream on its own thread and speed up draw execution

ASTRO BOT crowd scene: ~25 FPS -> ~55-58 FPS.

- The PM4 command stream is interpreted on its own thread. Draws, dispatches,
  barriers, end-of-pipe writes and the interpreter's stores are handed to the
  render thread in stream order through a short queue (64 items); callbacks
  that return a result wait for the work before them.
- Graphics program resolution runs on a worker thread ahead of the draws, from
  copy-on-write register bank snapshots, and is validated against the GPU
  ownership state before use.
- The resource materialization cache rebases buffer base addresses instead of
  missing on every moved descriptor.
- DCC metadata fills that cannot be absorbed run on the host, so binding the
  surface no longer waits for a GPU readback.
- Cheaper per-draw work: cached pipeline lookups no longer wait on program
  preparation, redundant dynamic state is skipped, image bindings and target
  requests are memoized, fewer allocations.
- Guest thread stack/TLS slots are chosen and mapped under one lock, so two
  threads can no longer share a stack.
- Wave lowering split into native wave32, native wave64 and emulated wave64.
This commit is contained in:
Astellou 2026-10-08 23:50:28 +02:00 committed by ParantezTech
parent 3f84f59065
commit 593cec957d
73 changed files with 6651 additions and 915 deletions

View File

@ -5604,6 +5604,11 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
return false;
}
// Map does not reject a range that is already mapped: it reuses and zero-fills it. Two threads
// creating guest threads at once could both find the same slot free and share one stack, so
// choosing and mapping a slot happen under one lock.
private static readonly object GuestThreadRegionGate = new();
private static bool TryMapGuestThreadRegion(
IVirtualMemory virtualMemory,
ulong baseAddress,
@ -5611,6 +5616,20 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
ProgramHeaderFlags protection,
out ulong mappedBase,
out string? error)
{
lock (GuestThreadRegionGate)
{
return TryMapGuestThreadRegionLocked(virtualMemory, baseAddress, size, protection, out mappedBase, out error);
}
}
private static bool TryMapGuestThreadRegionLocked(
IVirtualMemory virtualMemory,
ulong baseAddress,
ulong size,
ProgramHeaderFlags protection,
out ulong mappedBase,
out string? error)
{
for (int i = 0; i < GuestThreadRegionSlots; i++)
{
@ -5645,6 +5664,17 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
IVirtualMemory virtualMemory,
out ulong tlsBase,
out string? error)
{
lock (GuestThreadRegionGate)
{
return TryMapGuestThreadTlsRegionLocked(virtualMemory, out tlsBase, out error);
}
}
private static bool TryMapGuestThreadTlsRegionLocked(
IVirtualMemory virtualMemory,
out ulong tlsBase,
out string? error)
{
for (int i = 0; i < GuestThreadRegionSlots; i++)
{

View File

@ -23,6 +23,21 @@ public static partial class AgcExports
internal sealed record RetainedTargetlessDraw(RegisterBanks Banks, TargetlessDrawArguments Arguments);
// A draw or dispatch the translation deferred, with the banks it was issued under.
internal abstract record DeferredWork;
// Takes the deferred work of a command stream interpreted on its own thread.
internal interface IDeferredWorkSink
{
// True on the thread that interprets the stream; elsewhere the translation runs work itself.
bool IsProducerThread { get; }
// The non-draw items handed over so far; a draw's resolution waits until they ran.
long Gate { get; }
void EnqueueDeferred(DeferredWork work, bool isDraw);
}
// The snapshots the submit-time prepass captured for one submission.
private sealed record SubmittedGeometrySnapshots(
Dictionary<ulong, SubmittedIndexSnapshot>? IndexSnapshots,
@ -160,9 +175,18 @@ public static partial class AgcExports
{
_pipelines = new ShaderPipelineCache(_context, pipelineHost, GuestGpu.Current, CreateShaderHeaderRegistry(_context));
_executor = new RenderExecutor(renderHost, _pipelines);
if (host is IConcurrentResolveHost concurrentHost)
{
_parallel = new ParallelProgramPrefetch(_executor, renderHost, concurrentHost);
_executor.Prefetch = _parallel;
}
}
}
// Resolves the programs of deferred draws ahead of them, for a host that answers reads from
// another thread; draws are deferred whenever it exists.
private readonly ParallelProgramPrefetch? _parallel;
// The context bank of the queue whose slice runs now; the Metal host rebuilds its records from it.
internal ContextRegisters? CurrentContextRegisters => _current?.TypedRegisters?.Context;
@ -196,17 +220,194 @@ public static partial class AgcExports
}
}
private sealed record PendingDraw(
ulong SubmitId,
SubmittedDcbState State,
RegisterBanks Banks,
bool Indexed,
DrawIndexedArguments IndexedArguments,
DrawAutoArguments AutoArguments,
SubmittedVertexSnapshot? VertexSnapshot,
SubmittedIndexSnapshot? IndexSnapshot,
ParallelProgramPrefetch.Job? Job) : DeferredWork;
private sealed record PendingDispatch(
ulong SubmitId,
SubmittedDcbState State,
RegisterBanks Banks,
uint GroupsX,
uint GroupsY,
uint GroupsZ,
uint DispatchInitiator,
ulong IndirectArgumentsAddress) : DeferredWork;
// With the command stream on its own thread, its draws and dispatches reach the render
// thread through the sink, which runs them with RunDeferred in stream order.
internal IDeferredWorkSink? Sink { get; set; }
private bool Producing => Sink is { IsProducerThread: true };
// The queue state of the work the render thread runs now; _current belongs to the thread
// interpreting the stream.
private SubmittedDcbState? _executing;
public void RunDeferred(DeferredWork work)
{
switch (work)
{
case PendingDraw draw:
{
var executing = _executing;
_executing = draw.State;
RecordKnownColorTargets(draw.State, draw.Banks);
if (_parallel is not null)
{
_parallel.Current = draw.Job;
}
try
{
RunExecutorDraw(draw.SubmitId, draw.State, draw.Banks, draw.Indexed, draw.IndexedArguments, draw.AutoArguments,
clearSnapshots: false);
}
finally
{
_executing = executing;
if (_parallel is not null)
{
_parallel.Current = null;
}
}
break;
}
case PendingDispatch dispatch:
{
var executing = _executing;
_executing = dispatch.State;
try
{
ExecuteDispatch(_executor!, dispatch.SubmitId, dispatch.Banks, dispatch.GroupsX, dispatch.GroupsY, dispatch.GroupsZ,
dispatch.DispatchInitiator, dispatch.IndirectArgumentsAddress);
}
finally
{
_executing = executing;
}
break;
}
default:
throw _host.Fatal($"The deferred work is unknown: {work.GetType().Name}.");
}
}
// The resolution worker, so the render thread can order it with the stream's other work.
internal ParallelProgramPrefetch? Prefetch => _parallel;
private readonly Queue<PendingDraw> _pendingDraws = new();
public bool HasPendingDraws => _pendingDraws.Count != 0;
private bool _draining;
// Runs every deferred draw in issue order with the banks and snapshots it was issued with.
// A draw calls back into the host, whose entry points drain too: those calls run inside
// the draw and must not start the next one.
public void DrainPendingDraws()
{
if (_draining)
{
return;
}
_draining = true;
try
{
DrainPendingDrawsCore();
}
finally
{
_draining = false;
}
}
private void DrainPendingDrawsCore()
{
while (_pendingDraws.TryDequeue(out var pending))
{
var current = _current;
var state = pending.State;
_current = state;
state.CurrentVertexSnapshot = pending.VertexSnapshot;
state.CurrentIndexSnapshot = pending.IndexSnapshot;
if (_parallel is not null)
{
_parallel.Current = pending.Job;
}
try
{
RunExecutorDraw(pending.SubmitId, state, pending.Banks, pending.Indexed, pending.IndexedArguments, pending.AutoArguments);
}
finally
{
_current = current;
if (_parallel is not null)
{
_parallel.Current = null;
}
}
}
// What follows a drain may change guest memory and cache state the worker reads.
_parallel?.WaitIdle();
}
private bool TryRunExecutorDraw(ulong submitId, SubmittedDcbState state, bool indexed, in DrawIndexedArguments indexedArguments, in DrawAutoArguments autoArguments)
{
if (_executor is not { } executor)
if (_executor is null)
{
return false;
}
var banks = RequireTypedRegisters(state);
RecordKnownColorTargets(state, banks);
var producing = Producing;
if (!producing)
{
// A draw run by the render thread records its targets there.
RecordKnownColorTargets(state, banks);
}
state.FrameDrawCount++;
state.SawIndexedDraw |= indexed;
if (_parallel is not null || producing)
{
var snapshot = banks.Snapshot();
var pending = new PendingDraw(submitId, state, snapshot, indexed, indexedArguments, autoArguments,
state.CurrentVertexSnapshot, state.CurrentIndexSnapshot, _parallel?.Submit(snapshot, producing ? Sink!.Gate : 0));
if (producing)
{
Sink!.EnqueueDeferred(pending, isDraw: true);
}
else
{
_pendingDraws.Enqueue(pending);
}
state.CurrentIndexSnapshot = null;
state.CurrentVertexSnapshot = null;
return true;
}
RunExecutorDraw(submitId, state, banks, indexed, indexedArguments, autoArguments);
return true;
}
private void RunExecutorDraw(ulong submitId, SubmittedDcbState state, RegisterBanks banks, bool indexed, in DrawIndexedArguments indexedArguments, in DrawAutoArguments autoArguments,
bool clearSnapshots = true)
{
var executor = _executor!;
var drawStarted = DcbParseProfile.Begin();
try
{
@ -222,17 +423,19 @@ public static partial class AgcExports
finally
{
DcbParseProfile.RecordDraw(drawStarted);
state.CurrentIndexSnapshot = null;
state.CurrentVertexSnapshot = null;
// The snapshot fields belong to the interpreting thread when it is another one.
if (clearSnapshots)
{
state.CurrentIndexSnapshot = null;
state.CurrentVertexSnapshot = null;
}
}
return true;
}
// Keeps the banks the draw was issued under; the flip replays it into the display buffer.
internal void RetainTargetlessDraw(RegisterBanks banks, in TargetlessDrawArguments arguments)
{
var state = RequireCurrent();
var state = _executing ?? RequireCurrent();
if (RenderTrace.Enabled)
{
RenderTrace.Write($"TargetlessRetention submit={arguments.SubmitId} replaced={state.RetainedTargetlessDraw is not null} previousSubmit={state.RetainedTargetlessDraw?.Arguments.SubmitId} export=0x{banks.Shader.Vertex.ExportAddress:X16} pixel=0x{banks.Shader.Pixel.Address:X16}");
@ -363,22 +566,35 @@ public static partial class AgcExports
{
var banks = RequireTypedRegisters(state);
state.FrameDispatchCount++;
var executorStarted = DcbParseProfile.Begin();
try
if (Producing)
{
executor.Dispatch(submitId, banks, endX, endY, endZ, dispatchInitiator, indirectArgumentsAddress);
}
finally
{
DcbParseProfile.RecordDispatch(executorStarted);
var snapshot = banks.Snapshot();
Sink!.EnqueueDeferred(new PendingDispatch(submitId, state, snapshot, endX, endY, endZ, dispatchInitiator, indirectArgumentsAddress),
isDraw: false);
return;
}
ExecuteDispatch(executor, submitId, banks, endX, endY, endZ, dispatchInitiator, indirectArgumentsAddress);
return;
}
throw _host.Fatal("The command stream has no render executor.");
}
private static void ExecuteDispatch(RenderExecutor executor, ulong submitId, RegisterBanks banks, uint endX, uint endY, uint endZ,
uint dispatchInitiator, ulong indirectArgumentsAddress)
{
var executorStarted = DcbParseProfile.Begin();
try
{
executor.Dispatch(submitId, banks, endX, endY, endZ, dispatchInitiator, indirectArgumentsAddress);
}
finally
{
DcbParseProfile.RecordDispatch(executorStarted);
}
}
// The retained draw runs into the display buffer with the target words it was last bound with.
private void ReplayRetainedTargetlessDraw(RenderExecutor executor, SubmittedDcbState state, int handle, int displayBufferIndex)
{

View File

@ -333,6 +333,7 @@ public sealed unsafe class GuestBufferCache : IGuestBufferStore, IDisposable
throw SubmissionScheduler.Fatal("The host DMA write range is invalid.");
}
GpuWritebackEpoch.Advance();
if (!_backing.TryWriteBacking(guestAddress, data))
{
throw SubmissionScheduler.Fatal($"Could not write the required direct backing: addr=0x{guestAddress:X16} size=0x{data.Length:X16}");
@ -413,26 +414,71 @@ public sealed unsafe class GuestBufferCache : IGuestBufferStore, IDisposable
if (images.OverlapsDccMetadata(guestAddress, size) && TryWriteFillToBacking(guestAddress, size, value))
{
_gpuModifiedRanges.Remove(guestAddress, size);
var firstPage = guestAddress & ~(TrackerLayout.PageBytes - 1);
for (var page = firstPage; page < guestAddress + size; page += TrackerLayout.PageBytes)
{
if (!_gpuModifiedRanges.Overlaps(page, TrackerLayout.PageBytes))
{
_tracker.ClearGpuDirtyPages(page, TrackerLayout.PageBytes);
}
}
Interlocked.Increment(ref _gpuModifiedVersion);
ClearGpuDirtyPagesOfRemovedRange(guestAddress, size);
}
}
// After a range left the GPU-modified set: the pages inside it are clean in one tracker walk,
// and only a page the range shares at either end can still hold another GPU-modified range.
private void ClearGpuDirtyPagesOfRemovedRange(ulong guestAddress, ulong size)
{
const ulong pageBytes = TrackerLayout.PageBytes;
var end = guestAddress + size;
var firstPage = guestAddress & ~(pageBytes - 1);
var lastPage = (end - 1) & ~(pageBytes - 1);
var startPartial = guestAddress != firstPage;
var endPartial = end != lastPage + pageBytes;
var innerBegin = startPartial ? firstPage + pageBytes : firstPage;
var innerEnd = endPartial ? lastPage : end;
if (innerBegin < innerEnd)
{
_tracker.ClearGpuDirtyPages(innerBegin, innerEnd - innerBegin);
}
if (startPartial)
{
ClearGpuDirtyPageIfUnmodified(firstPage);
}
if (endPartial && (lastPage != firstPage || !startPartial))
{
ClearGpuDirtyPageIfUnmodified(lastPage);
}
}
private void ClearGpuDirtyPageIfUnmodified(ulong page)
{
if (!_gpuModifiedRanges.Overlaps(page, TrackerLayout.PageBytes))
{
_tracker.ClearGpuDirtyPages(page, TrackerLayout.PageBytes);
}
}
public void FillDccMetadata(ulong guestAddress, ulong size, uint value)
{
if (guestAddress == 0 || (guestAddress & 3) != 0 || size == 0 || (size & 3) != 0 || size > ulong.MaxValue - guestAddress ||
HasGpuDirtyBytes(guestAddress, size) || RequireImageCache().QueryRegion(guestAddress, size).ImageBytes)
HasGpuDirtyBytes(guestAddress, size))
{
FillBuffer(guestAddress, size, value, false);
return;
}
// Images over the range read it again; only GPU-owned image bytes need the general fill.
// The buffer copy is filled on the GPU rather than uploaded from the written bytes.
var images = RequireImageCache();
var region = images.QueryRegion(guestAddress, size);
if (region.GpuImageBytes)
{
FillBuffer(guestAddress, size, value, false);
return;
}
if (region.ImageBytes)
{
images.InvalidateMemory(guestAddress, size);
}
var (destination, destinationOffset) = ObtainBuffer(guestAddress, size, false, false, FindBuffer(guestAddress, size));
destination.Fill(destinationOffset, size, value);
if (!TryWriteFillToBacking(guestAddress, size, value))
@ -443,21 +489,31 @@ public sealed unsafe class GuestBufferCache : IGuestBufferStore, IDisposable
private bool TryWriteFillToBacking(ulong guestAddress, ulong size, uint value)
{
var values = new uint[(int)Math.Min(size / sizeof(uint), 4096)];
Array.Fill(values, value);
var bytes = MemoryMarshal.AsBytes<uint>(values);
for (ulong offset = 0; offset < size;)
// Metadata fills repeat every frame: the pattern buffer is pooled.
var count = (int)Math.Min(size / sizeof(uint), 4096);
var values = System.Buffers.ArrayPool<uint>.Shared.Rent(count);
try
{
var chunk = (int)Math.Min(size - offset, (ulong)bytes.Length);
if (!_backing.TryWriteBacking(guestAddress + offset, bytes[..chunk]))
values.AsSpan(0, count).Fill(value);
var bytes = MemoryMarshal.AsBytes(values.AsSpan(0, count));
for (ulong offset = 0; offset < size;)
{
return false;
var chunk = (int)Math.Min(size - offset, (ulong)bytes.Length);
GpuWritebackEpoch.Advance();
if (!_backing.TryWriteBacking(guestAddress + offset, bytes[..chunk]))
{
return false;
}
offset += (ulong)chunk;
}
offset += (ulong)chunk;
return true;
}
finally
{
System.Buffers.ArrayPool<uint>.Shared.Return(values);
}
return true;
}
public const ulong MaxHostCopyWords = 64 * 1024;
@ -1241,6 +1297,7 @@ public sealed unsafe class GuestBufferCache : IGuestBufferStore, IDisposable
var copies = pending.Copies;
readback.Complete(pending.Ticket, (index, bytes) =>
{
GpuWritebackEpoch.Advance();
if (!_backing.TryWriteBacking(copies[index].Address, bytes))
{
throw SubmissionScheduler.Fatal($"Could not write the required direct backing: addr=0x{copies[index].Address:X16} size=0x{(ulong)bytes.Length:X16}");
@ -1249,6 +1306,7 @@ public sealed unsafe class GuestBufferCache : IGuestBufferStore, IDisposable
foreach (var copy in copies)
{
_gpuModifiedRanges.Remove(copy.Address, copy.Size);
Interlocked.Increment(ref _gpuModifiedVersion);
}
_tracker.ClearGpuDirtyPages(pending.WindowBegin, pending.WindowEnd - pending.WindowBegin);
@ -1456,6 +1514,7 @@ public sealed unsafe class GuestBufferCache : IGuestBufferStore, IDisposable
foreach (var copy in copies)
{
_gpuModifiedRanges.Remove(copy.Address, copy.Size);
Interlocked.Increment(ref _gpuModifiedVersion);
}
return;
@ -1492,6 +1551,7 @@ public sealed unsafe class GuestBufferCache : IGuestBufferStore, IDisposable
foreach (var copy in copies)
{
_gpuModifiedRanges.Remove(copy.Address, copy.Size);
Interlocked.Increment(ref _gpuModifiedVersion);
}
}
@ -1590,6 +1650,7 @@ public sealed unsafe class GuestBufferCache : IGuestBufferStore, IDisposable
readback.Read(pieces, waitTick, (index, bytes) =>
{
GpuWritebackEpoch.Advance();
if (!_backing.TryWriteBacking(copies[index].Address, bytes))
{
throw SubmissionScheduler.Fatal($"Could not write the required direct backing: addr=0x{copies[index].Address:X16} size=0x{(ulong)bytes.Length:X16}");
@ -1622,6 +1683,7 @@ public sealed unsafe class GuestBufferCache : IGuestBufferStore, IDisposable
var placement = copy.Placement;
var offset = baseOffset + placement.DataOffset;
_download.Invalidate(offset, placement.DataSize);
GpuWritebackEpoch.Advance();
if (!_backing.TryWriteBacking(copy.Address, _download.Mapped.Slice((int)offset, (int)placement.DataSize)))
{
throw SubmissionScheduler.Fatal($"Could not write the required direct backing: addr=0x{copy.Address:X16} size=0x{placement.DataSize:X16}");
@ -1713,20 +1775,79 @@ public sealed unsafe class GuestBufferCache : IGuestBufferStore, IDisposable
profileScope.SwitchPhase(isWritten
? RenderPhaseProfile.Phase.BufferDirtySyncWritten
: isTexelBuffer ? RenderPhaseProfile.Phase.BufferDirtySyncTexel : RenderPhaseProfile.Phase.BufferDirtySyncUpload);
var copies = new List<BufferCopy>();
var totalSize = 0UL;
GpuBuffer? source = null;
_tracker.ForEachUploadRange(
guestAddress,
size,
isWritten,
(address, bytes) =>
var collector = _uploadCollector;
if (collector is null || collector.InUse)
{
// The first call on a thread keeps its collector; a nested one gets its own.
collector = new UploadCollector();
_uploadCollector ??= collector;
}
collector.Begin(this, buffer, guestAddress, size);
try
{
return SynchronizeBufferCore(collector, buffer, guestAddress, size, isWritten, isTexelBuffer, preserveCpuWriteHotPages, startedAt);
}
finally
{
collector.End();
}
}
// The ranges of one synchronization. The delegates are bound once, so a call that has dirty
// pages to upload allocates no closure and no list.
private sealed class UploadCollector
{
private GuestBufferCache _cache = null!;
private GpuBuffer _buffer = null!;
private ulong _guestAddress;
private ulong _size;
public readonly List<BufferCopy> Copies = new();
public ulong TotalSize;
public GpuBuffer? Source;
public bool InUse;
public readonly Action<ulong, ulong> Collect;
public readonly Action Upload;
public UploadCollector()
{
Collect = (address, bytes) =>
{
copies.Add(new BufferCopy(totalSize, buffer.Offset(address), bytes));
totalSize += bytes;
},
() => source = _uploader.PrepareSource(buffer.CpuAddress, CollectionsMarshal.AsSpan(copies), totalSize, guestAddress, size),
preserveCpuWriteHotPages);
Copies.Add(new BufferCopy(TotalSize, _buffer.Offset(address), bytes));
TotalSize += bytes;
};
Upload = () => Source = _cache._uploader.PrepareSource(_buffer.CpuAddress, CollectionsMarshal.AsSpan(Copies), TotalSize, _guestAddress, _size);
}
public void Begin(GuestBufferCache cache, GpuBuffer buffer, ulong guestAddress, ulong size)
{
InUse = true;
_cache = cache;
_buffer = buffer;
_guestAddress = guestAddress;
_size = size;
}
public void End()
{
InUse = false;
_cache = null!;
_buffer = null!;
Copies.Clear();
TotalSize = 0;
Source = null;
}
}
[ThreadStatic] private static UploadCollector? _uploadCollector;
private bool SynchronizeBufferCore(UploadCollector collector, GpuBuffer buffer, ulong guestAddress, ulong size, bool isWritten,
bool isTexelBuffer, bool preserveCpuWriteHotPages, long startedAt)
{
var copies = collector.Copies;
_tracker.ForEachUploadRange(guestAddress, size, isWritten, collector.Collect, collector.Upload, preserveCpuWriteHotPages);
var source = collector.Source;
var totalSize = collector.TotalSize;
if (source != null)
{
buffer.NoteGpuWrite();

View File

@ -26,7 +26,7 @@ public sealed partial class GpuCommandInterpreter
}
// A compare-swap loop applies once per try and suspends while the compare fails.
if (!atomic.TryApply(_host.TryReadGuest, _host.Memory, out var priorValue, out _, out var comparePassed))
if (!atomic.TryApply(_host.TryReadGuestOperand, _host.Memory, out var priorValue, out _, out var comparePassed))
{
throw _host.Fatal($"The atomic address cannot be accessed: address=0x{atomic.Address:X16} size={atomic.ByteCount}.");
}
@ -68,7 +68,7 @@ public sealed partial class GpuCommandInterpreter
if (semaphore.IsSignal)
{
if (!Packets.MemorySemaphorePacket.TrySignal(_host.TryReadGuest, _host.Memory, semaphore.Address, semaphore.WriteSignal, out _))
if (!Packets.MemorySemaphorePacket.TrySignal(_host.TryReadGuestOperand, _host.Memory, semaphore.Address, semaphore.WriteSignal, out _))
{
throw _host.Fatal($"The semaphore cannot be signaled: address=0x{semaphore.Address:X16}.");
}
@ -77,7 +77,7 @@ public sealed partial class GpuCommandInterpreter
}
// A wait takes one token, or suspends until a signal adds one.
if (!Packets.MemorySemaphorePacket.TryConsume(_host.TryReadGuest, _host.Memory, semaphore.Address, out var priorValue))
if (!Packets.MemorySemaphorePacket.TryConsume(_host.TryReadGuestOperand, _host.Memory, semaphore.Address, out var priorValue))
{
throw _host.Fatal($"The semaphore cannot be read: address=0x{semaphore.Address:X16}.");
}

View File

@ -82,8 +82,8 @@ public sealed partial class GpuCommandInterpreter
var next = packet.PacketAddress + (ulong)packet.Length * sizeof(uint);
var expectedHeader = PacketHeader.Make(centroidPacketDwords, PacketOpcode.SetContextRegister);
if (packet.Remaining < packet.Length + centroidPacketDwords ||
ReadDword(next) != expectedHeader ||
RegisterBankLayout.Normalize(ReadDword(next + sizeof(uint))) != ContextRegisterOffset.PaScCentroidPriority0)
ReadDword(next, RenderPhaseProfile.CommandReadKind.Payload) != expectedHeader ||
RegisterBankLayout.Normalize(ReadDword(next + sizeof(uint), RenderPhaseProfile.CommandReadKind.Payload)) != ContextRegisterOffset.PaScCentroidPriority0)
{
throw _host.Fatal($"The sample location packet is not followed by the centroid priority packet: header=0x{packet.Header:X8} address=0x{packet.PacketAddress:X16} next=0x{next:X16}.");
}
@ -102,16 +102,16 @@ public sealed partial class GpuCommandInterpreter
var address = packet.PacketAddress;
var registerHeader = PacketHeader.Make(3, PacketOpcode.SetContextRegister);
if (ReadDword(address + 40) != registerHeader || ReadDword(address + 44) != RegisterBankLayout.DepthInfo ||
ReadDword(address + 52) != registerHeader || ReadDword(address + 56) != RegisterBankLayout.DepthView ||
ReadDword(address + 64) != registerHeader || ReadDword(address + 68) != RegisterBankLayout.HtileDataBase ||
ReadDword(address + 76) != registerHeader || ReadDword(address + 80) != RegisterBankLayout.HtileSurface ||
ReadDword(address + 88) != PacketHeader.Make(2, PacketOpcode.Nop))
if (ReadDword(address + 40, RenderPhaseProfile.CommandReadKind.Payload) != registerHeader || ReadDword(address + 44, RenderPhaseProfile.CommandReadKind.Payload) != RegisterBankLayout.DepthInfo ||
ReadDword(address + 52, RenderPhaseProfile.CommandReadKind.Payload) != registerHeader || ReadDword(address + 56, RenderPhaseProfile.CommandReadKind.Payload) != RegisterBankLayout.DepthView ||
ReadDword(address + 64, RenderPhaseProfile.CommandReadKind.Payload) != registerHeader || ReadDword(address + 68, RenderPhaseProfile.CommandReadKind.Payload) != RegisterBankLayout.HtileDataBase ||
ReadDword(address + 76, RenderPhaseProfile.CommandReadKind.Payload) != registerHeader || ReadDword(address + 80, RenderPhaseProfile.CommandReadKind.Payload) != RegisterBankLayout.HtileSurface ||
ReadDword(address + 88, RenderPhaseProfile.CommandReadKind.Payload) != PacketHeader.Make(2, PacketOpcode.Nop))
{
return;
}
var sizeXy = ReadDword(address + 92);
var sizeXy = ReadDword(address + 92, RenderPhaseProfile.CommandReadKind.Payload);
if (sizeXy != 0)
{
TypedRegisters.CompositeDepthSizeXy = sizeXy;

View File

@ -28,7 +28,7 @@ public sealed partial class GpuCommandInterpreter
return;
}
var value = is64Bit ? ReadQword(address) : ReadDword(address);
var value = ReadWaitOperand(address, is64Bit);
if (!WaitOperation.TryCompare(value, reference, mask, compareFunction, out var satisfied))
{
throw _host.Fatal($"The wait compare function is unknown: function={compareFunction} address=0x{address:X16}.");

View File

@ -394,7 +394,12 @@ public sealed partial class GpuCommandInterpreter
{
RenderPhaseProfile.RecordCommandRead(readKind, sizeof(uint));
Span<byte> bytes = stackalloc byte[sizeof(uint)];
if (!_host.TryReadGuest(address, bytes))
// The packets themselves are command data; anything else is an operand earlier work may write.
var read = readKind is RenderPhaseProfile.CommandReadKind.Header or RenderPhaseProfile.CommandReadKind.Payload or
RenderPhaseProfile.CommandReadKind.RegisterTable
? _host.TryReadGuest(address, bytes)
: _host.TryReadGuestOperand(address, bytes);
if (!read)
{
throw _host.Fatal($"The command stream cannot read guest memory: address=0x{address:X16} size=4.");
}
@ -406,7 +411,7 @@ public sealed partial class GpuCommandInterpreter
{
RenderPhaseProfile.RecordCommandRead(RenderPhaseProfile.CommandReadKind.Operand64, sizeof(ulong));
Span<byte> bytes = stackalloc byte[sizeof(ulong)];
if (!_host.TryReadGuest(address, bytes))
if (!_host.TryReadGuestOperand(address, bytes))
{
throw _host.Fatal($"The command stream cannot read guest memory: address=0x{address:X16} size=8.");
}
@ -414,10 +419,24 @@ public sealed partial class GpuCommandInterpreter
return BinaryPrimitives.ReadUInt64LittleEndian(bytes);
}
private ulong ReadWaitOperand(ulong address, bool is64Bit)
{
RenderPhaseProfile.RecordCommandRead(is64Bit ? RenderPhaseProfile.CommandReadKind.Operand64 : RenderPhaseProfile.CommandReadKind.Operand32,
is64Bit ? sizeof(ulong) : sizeof(uint));
Span<byte> bytes = stackalloc byte[sizeof(ulong)];
var size = is64Bit ? sizeof(ulong) : sizeof(uint);
if (!_host.TryReadGuestWaitOperand(address, bytes[..size]))
{
throw _host.Fatal($"The command stream cannot read guest memory: address=0x{address:X16} size={size}.");
}
return is64Bit ? BinaryPrimitives.ReadUInt64LittleEndian(bytes) : BinaryPrimitives.ReadUInt32LittleEndian(bytes);
}
internal void ReadBytes(ulong address, Span<byte> destination)
{
RenderPhaseProfile.RecordCommandRead(RenderPhaseProfile.CommandReadKind.Other, destination.Length);
if (!_host.TryReadGuest(address, destination))
if (!_host.TryReadGuestOperand(address, destination))
{
throw _host.Fatal($"The command stream cannot read guest memory: address=0x{address:X16} size={destination.Length}.");
}

View File

@ -84,6 +84,14 @@ public interface ICommandStreamHost
// Reads guest memory the GPU may have written; the host synchronizes GPU-owned pages first.
bool TryReadGuest(ulong address, Span<byte> destination);
// Reads a packet operand (a label, a predicate, indirect arguments): memory earlier work may
// write, unlike the packets themselves. A host that defers work completes it first.
bool TryReadGuestOperand(ulong address, Span<byte> destination) => TryReadGuest(address, destination);
// Reads the label a wait polls. A stale value only suspends the wait until a retry, so a host
// that runs work behind the stream need not complete it first.
bool TryReadGuestWaitOperand(ulong address, Span<byte> destination) => TryReadGuestOperand(address, destination);
// Runs commands other threads posted to this worker. Called before every packet.
void RunPendingCommands();

View File

@ -338,8 +338,15 @@ public sealed class ScreenViewportRegisters
public ScreenViewportRegisters Copy()
{
var copy = (ScreenViewportRegisters)MemberwiseClone();
var copy = CopySharingViewports();
copy.Viewports = (ViewportRegisters[])Viewports.Clone();
return copy;
}
// A copy that shares the viewport array, the largest part, until RegisterBanks copies it.
public ScreenViewportRegisters CopySharingViewports()
{
var copy = (ScreenViewportRegisters)MemberwiseClone();
copy.ClipRectangleLeft = (int[])ClipRectangleLeft.Clone();
copy.ClipRectangleTop = (int[])ClipRectangleTop.Clone();
copy.ClipRectangleRight = (int[])ClipRectangleRight.Clone();
@ -388,6 +395,9 @@ public sealed class ContextRegisters
public EnhancedQualityAntialiasingRegisters EnhancedQualityAntialiasing;
public ShaderInterfaceRegisters ShaderInterface = new();
// A copy that shares every nested part; RegisterBanks copies a part before writing it.
public ContextRegisters ShallowCopy() => (ContextRegisters)MemberwiseClone();
public uint RenderTargetMaskForSlot(uint slot) => (RenderTargetMask >> (int)(slot * 4)) & 0xFu;
public ContextRegisters Copy()

View File

@ -1,6 +1,8 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using SharpEmu.Libs.Gpu.Images;
namespace SharpEmu.Libs.Gpu.GpuCommands.Registers;
// The three typed register banks of one queue, with the state the register writers carry between packets.
@ -9,15 +11,31 @@ public sealed class RegisterBanks
private ContextRegisters? _savedContext;
private uint? _savedCompositeDepthSizeXy;
public RegisterBanks(Func<string, Exception> fatal) => Fatal = fatal;
public RegisterBanks(Func<string, Exception> fatal)
: this(fatal, new ContextRegisters(), new ShaderProgramRegisters(), new UserConfigRegisters())
{
}
// Banks over given parts, so a copy or a snapshot does not first build empty ones.
private RegisterBanks(
Func<string, Exception> fatal,
ContextRegisters context,
ShaderProgramRegisters shader,
UserConfigRegisters userConfig)
{
Fatal = fatal;
Context = context;
Shader = shader;
UserConfig = userConfig;
}
public Func<string, Exception> Fatal { get; }
public ContextRegisters Context { get; private set; } = new();
public ContextRegisters Context { get; private set; }
public ShaderProgramRegisters Shader { get; private set; } = new();
public ShaderProgramRegisters Shader { get; private set; }
public UserConfigRegisters UserConfig { get; private set; } = new();
public UserConfigRegisters UserConfig { get; private set; }
public bool ContextPushed => _savedContext is not null;
@ -31,11 +49,8 @@ public sealed class RegisterBanks
// A copy of the live banks; the saved context and the draw-time markers are not part of it.
public RegisterBanks Clone()
{
var clone = new RegisterBanks(Fatal)
var clone = new RegisterBanks(Fatal, Context.Copy(), Shader.Copy(), UserConfig.Copy())
{
Context = Context.Copy(),
Shader = Shader.Copy(),
UserConfig = UserConfig.Copy(),
CompositeDepthSizeXy = CompositeDepthSizeXy,
UserDataMarker = UserDataMarker,
IndexTypeAndSize = IndexTypeAndSize,
@ -43,9 +58,281 @@ public sealed class RegisterBanks
return clone;
}
// The parts a snapshot shares with these banks; each is copied before its next write here.
private bool _contextShared;
private bool _vertexShared;
private bool _pixelShared;
private bool _computeShared;
private bool _userConfigShared;
// A read-only copy that shares every part with the live banks until they next write it: a
// deferred draw takes one per draw, and between draws only a few parts change.
public RegisterBanks Snapshot()
{
var snapshot = new RegisterBanks(Fatal, Context, Shader.ShareStages(), UserConfig)
{
CompositeDepthSizeXy = CompositeDepthSizeXy,
UserDataMarker = UserDataMarker,
IndexTypeAndSize = IndexTypeAndSize,
};
_contextShared = _vertexShared = _pixelShared = _computeShared = _userConfigShared = true;
_pixelScalarsShared = _hullScalarsShared = _geometryScalarsShared = _legacyVertexScalarsShared = _exportScalarsShared = _computeScalarsShared = true;
return snapshot;
}
// Called by the register writers before they store into a part.
public void PrepareContextWrite()
{
if (_contextShared)
{
Context = Context.ShallowCopy();
_contextShared = false;
SetContextPartsShared(true);
}
}
// The context is copied without its nested parts, and a part only when a writer asks for it
// through these accessors: a draw usually rewrites a few plain fields.
private bool _screenViewportShared;
private bool _viewportsShared;
private bool _sampleLocationsShared;
private bool _shaderInterfaceShared;
private bool _blendControlsShared;
private bool _colorTargetsShared;
private bool _colorClearWord1Shared;
private bool _unmodeledShared;
private void SetContextPartsShared(bool shared) =>
_screenViewportShared = _viewportsShared = _sampleLocationsShared = _shaderInterfaceShared =
_blendControlsShared = _colorTargetsShared = _colorClearWord1Shared = _unmodeledShared = shared;
private void OwnContext(ContextRegisters context)
{
Context = context;
_contextShared = false;
SetContextPartsShared(false);
}
public ScreenViewportRegisters ScreenViewportForWrite
{
get
{
PrepareContextWrite();
if (_screenViewportShared)
{
Context.ScreenViewport = Context.ScreenViewport.CopySharingViewports();
_screenViewportShared = false;
}
return Context.ScreenViewport;
}
}
public ViewportRegisters[] ViewportsForWrite
{
get
{
var screenViewport = ScreenViewportForWrite;
if (_viewportsShared)
{
screenViewport.Viewports = (ViewportRegisters[])screenViewport.Viewports.Clone();
_viewportsShared = false;
}
return screenViewport.Viewports;
}
}
public SampleLocationRegisters SampleLocationsForWrite
{
get
{
PrepareContextWrite();
if (_sampleLocationsShared)
{
Context.SampleLocations = Context.SampleLocations.Copy();
_sampleLocationsShared = false;
}
return Context.SampleLocations;
}
}
public ShaderInterfaceRegisters ShaderInterfaceForWrite
{
get
{
PrepareContextWrite();
if (_shaderInterfaceShared)
{
Context.ShaderInterface = Context.ShaderInterface.Copy();
_shaderInterfaceShared = false;
}
return Context.ShaderInterface;
}
}
public BlendRegisters[] BlendControlsForWrite
{
get
{
PrepareContextWrite();
if (_blendControlsShared)
{
Context.BlendControls = (BlendRegisters[])Context.BlendControls.Clone();
_blendControlsShared = false;
}
return Context.BlendControls;
}
}
public ColorTargetWords[] ColorTargetsForWrite
{
get
{
PrepareContextWrite();
if (_colorTargetsShared)
{
Context.ColorTargets = (ColorTargetWords[])Context.ColorTargets.Clone();
_colorTargetsShared = false;
}
return Context.ColorTargets;
}
}
public uint[] ColorClearWord1ForWrite
{
get
{
PrepareContextWrite();
if (_colorClearWord1Shared)
{
Context.ColorClearWord1 = (uint[])Context.ColorClearWord1.Clone();
_colorClearWord1Shared = false;
}
return Context.ColorClearWord1;
}
}
public Dictionary<uint, uint> UnmodeledTableRegistersForWrite
{
get
{
PrepareContextWrite();
if (_unmodeledShared)
{
Context.UnmodeledTableRegisters = new Dictionary<uint, uint>(Context.UnmodeledTableRegisters);
_unmodeledShared = false;
}
return Context.UnmodeledTableRegisters;
}
}
public void PrepareUserConfigWrite()
{
if (_userConfigShared)
{
UserConfig = UserConfig.Copy();
_userConfigShared = false;
}
}
// COMPUTE_DISPATCH_INITIATOR, the first register of the compute range.
private const uint ComputeRangeStart = 0x200;
// A stage is copied without its user scalar sets, and a set only when its window is written:
// a draw usually rewrites one set of one or two stages.
private bool _pixelScalarsShared;
private bool _hullScalarsShared;
private bool _geometryScalarsShared;
private bool _legacyVertexScalarsShared;
private bool _exportScalarsShared;
private bool _computeScalarsShared;
private const uint UserScalarWindow = UserScalarRegisters.Capacity;
private static bool Overlaps(uint first, uint last, uint windowStart) =>
first < windowStart + UserScalarWindow && last >= windowStart;
// The shader bank's offsets: pixel below SPI_SHADER_PGM_LO_VS, the geometry stages up to the
// compute range, compute from there.
public void PrepareShaderWrite(uint offset, uint count)
{
var last = offset + Math.Max(count, 1) - 1;
if (offset < ShaderRegisterOffset.SpiShaderPgmLoVs)
{
if (_pixelShared)
{
Shader.Pixel = Shader.Pixel.ShallowCopy();
_pixelShared = false;
}
if (_pixelScalarsShared && Overlaps(offset, last, ShaderRegisterOffset.SpiShaderUserDataPs0))
{
Shader.Pixel.UserScalars = Shader.Pixel.UserScalars.Copy();
_pixelScalarsShared = false;
}
}
if (offset < ComputeRangeStart && last >= ShaderRegisterOffset.SpiShaderPgmLoVs)
{
if (_vertexShared)
{
Shader.Vertex = Shader.Vertex.ShallowCopy();
_vertexShared = false;
}
var vertex = Shader.Vertex;
if (_hullScalarsShared && Overlaps(offset, last, ShaderRegisterOffset.SpiShaderUserDataHs0))
{
vertex.HullUserScalars = vertex.HullUserScalars.Copy();
_hullScalarsShared = false;
}
if (_geometryScalarsShared && Overlaps(offset, last, ShaderRegisterOffset.SpiShaderUserDataGs0))
{
vertex.GeometryUserScalars = vertex.GeometryUserScalars.Copy();
_geometryScalarsShared = false;
}
if (_legacyVertexScalarsShared && Overlaps(offset, last, ShaderRegisterOffset.SpiShaderUserDataVs0))
{
vertex.LegacyVertexUserScalars = vertex.LegacyVertexUserScalars.Copy();
_legacyVertexScalarsShared = false;
}
if (_exportScalarsShared && Overlaps(offset, last, ShaderRegisterOffset.SpiShaderUserDataEs0))
{
vertex.ExportUserScalars = vertex.ExportUserScalars.Copy();
_exportScalarsShared = false;
}
}
if (last >= ComputeRangeStart)
{
if (_computeShared)
{
Shader.Compute = Shader.Compute.ShallowCopy();
_computeShared = false;
}
if (_computeScalarsShared && Overlaps(offset, last, ShaderRegisterOffset.ComputeUserData0))
{
Shader.Compute.UserScalars = Shader.Compute.UserScalars.Copy();
_computeScalarsShared = false;
}
}
}
public void Reset()
{
Context = new ContextRegisters();
_contextShared = _vertexShared = _pixelShared = _computeShared = _userConfigShared = false;
_pixelScalarsShared = _hullScalarsShared = _geometryScalarsShared = _legacyVertexScalarsShared = _exportScalarsShared = _computeScalarsShared = false;
OwnContext(new ContextRegisters());
Shader = new ShaderProgramRegisters();
UserConfig = new UserConfigRegisters();
_savedContext = null;
@ -61,7 +348,7 @@ public sealed class RegisterBanks
switch (operation)
{
case ContextStateOperation.Clear:
Context = new ContextRegisters();
OwnContext(new ContextRegisters());
CompositeDepthSizeXy = null;
break;
case ContextStateOperation.Push:
@ -73,14 +360,14 @@ public sealed class RegisterBanks
throw Fatal("The context state is not pushed.");
}
Context = _savedContext;
OwnContext(_savedContext);
CompositeDepthSizeXy = _savedCompositeDepthSizeXy;
_savedContext = null;
_savedCompositeDepthSizeXy = null;
break;
case ContextStateOperation.PushClear:
Push();
Context = new ContextRegisters();
OwnContext(new ContextRegisters());
CompositeDepthSizeXy = null;
break;
default:

View File

@ -28,6 +28,7 @@ public static class RegisterWriteTable
public static uint WriteContextPacket(RegisterBanks banks, in PacketContext packet, uint offset, ReadOnlySpan<uint> values)
{
banks.PrepareContextWrite();
var consumed = WritePacket(banks, in packet, offset, values, Context, ContextIndirect, "context", emptyIsHandled: true);
if (Rendering.RenderTrace.Enabled)
{
@ -43,6 +44,7 @@ public static class RegisterWriteTable
// A shader packet without values has no writer; the other banks accept it.
public static uint WriteShaderPacket(RegisterBanks banks, in PacketContext packet, uint offset, ReadOnlySpan<uint> values)
{
banks.PrepareShaderWrite(offset, (uint)values.Length);
var consumed = WritePacket(banks, in packet, offset, values, Shader, ShaderIndirect, "shader", emptyIsHandled: false);
if (Rendering.RenderTrace.Enabled)
{
@ -52,15 +54,19 @@ public static class RegisterWriteTable
return consumed;
}
public static uint WriteUserConfigPacket(RegisterBanks banks, in PacketContext packet, uint offset, ReadOnlySpan<uint> values) =>
WritePacket(banks, in packet, offset, values, UserConfig, UserConfigIndirect, "user-config", emptyIsHandled: true);
public static uint WriteUserConfigPacket(RegisterBanks banks, in PacketContext packet, uint offset, ReadOnlySpan<uint> values)
{
banks.PrepareUserConfigWrite();
return WritePacket(banks, in packet, offset, values, UserConfig, UserConfigIndirect, "user-config", emptyIsHandled: true);
}
public static void WriteContextEntry(RegisterBanks banks, uint offset, uint value, ulong tableAddress)
{
banks.PrepareContextWrite();
if (offset < ContextIndirect.Length && ContextIndirect[offset] is null)
{
// Keep unknown in-bank table values so a later renderer implementation can decode them.
banks.Context.UnmodeledTableRegisters[offset] = value;
banks.UnmodeledTableRegistersForWrite[offset] = value;
}
else
{
@ -88,6 +94,7 @@ public static class RegisterWriteTable
public static void WriteShaderEntry(RegisterBanks banks, uint offset, uint value, ulong tableAddress)
{
banks.PrepareShaderWrite(offset, 1);
WriteEntry(banks, offset, value, ShaderIndirect, "shader", tableAddress);
if (Rendering.RenderTrace.Enabled)
TracePixelRegisterWrite(banks, offset, value, tableAddress, "table");
@ -104,8 +111,11 @@ public static class RegisterWriteTable
$"value=0x{value:X8} shader=0x{banks.Shader.Pixel.Address:X16}");
}
public static void WriteUserConfigEntry(RegisterBanks banks, uint offset, uint value, ulong tableAddress) =>
public static void WriteUserConfigEntry(RegisterBanks banks, uint offset, uint value, ulong tableAddress)
{
banks.PrepareUserConfigWrite();
WriteEntry(banks, offset, value, UserConfigIndirect, "user-config", tableAddress);
}
private static uint WritePacket(
RegisterBanks banks,

View File

@ -98,8 +98,8 @@ internal static partial class RegisterWriters
{
indirect[offset] = static (banks, register, value) =>
{
banks.Context.ShaderInterface.PixelInterpolatorSettings[register - SpiPsInputCntl0] = value;
banks.Context.ShaderInterface.PixelInterpolatorWritten |= 1u << (int)(register - SpiPsInputCntl0);
banks.ShaderInterfaceForWrite.PixelInterpolatorSettings[register - SpiPsInputCntl0] = value;
banks.ShaderInterfaceForWrite.PixelInterpolatorWritten |= 1u << (int)(register - SpiPsInputCntl0);
};
}
@ -160,7 +160,7 @@ internal static partial class RegisterWriters
for (var offset = PaScAaSampleLocations0; offset < PaScAaSampleLocations0 + SampleLocationRegisters.LocationCount; offset++)
{
indirect[offset] = static (banks, register, value) => banks.Context.SampleLocations.Locations[register - PaScAaSampleLocations0] = value;
indirect[offset] = static (banks, register, value) => banks.SampleLocationsForWrite.Locations[register - PaScAaSampleLocations0] = value;
}
foreach (var offset in new[]
@ -175,36 +175,36 @@ internal static partial class RegisterWriters
indirect[offset] = IgnoreEntry;
}
indirect[SpiVsOutConfig] = static (banks, _, value) => banks.Context.ShaderInterface.VertexOutputConfiguration = value;
indirect[SpiVsOutConfig] = static (banks, _, value) => banks.ShaderInterfaceForWrite.VertexOutputConfiguration = value;
indirect[DbRenderOverride] = static (banks, _, value) => banks.Context.DepthRenderOverride = DepthRenderOverrideRegisters.Decode(value);
indirect[SpiShaderPosFormat] = static (banks, _, value) => banks.Context.ShaderInterface.PositionExportFormat = value;
indirect[SpiShaderIdxFormat] = static (banks, _, value) => banks.Context.ShaderInterface.IndexExportFormat = value;
indirect[PaClVsOutCntl] = static (banks, _, value) => banks.Context.ShaderInterface.VertexOutputControl = value;
indirect[GeNggSubgroupCntl] = static (banks, _, value) => banks.Context.ShaderInterface.PrimitiveShaderSubgroupControl = value;
indirect[VgtGsInstanceCnt] = static (banks, _, value) => banks.Context.ShaderInterface.GeometryInstanceCount = value;
indirect[VgtGsOnchipCntl] = static (banks, _, value) => banks.Context.ShaderInterface.GeometryOnChipControl = value;
indirect[VgtHosMaxTessLevel] = static (banks, _, value) => banks.Context.ShaderInterface.MaxTessellationLevel = value;
indirect[VgtHosMinTessLevel] = static (banks, _, value) => banks.Context.ShaderInterface.MinTessellationLevel = value;
indirect[GeMaxOutputPerSubgroup] = static (banks, _, value) => banks.Context.ShaderInterface.MaxOutputPerSubgroup = value;
indirect[VgtEsgsRingItemSize] = static (banks, _, value) => banks.Context.ShaderInterface.ExportRingItemSize = value;
indirect[VgtGsMaxVertOut] = static (banks, _, value) => banks.Context.ShaderInterface.GeometryMaxVerticesOut = value;
indirect[VgtPrimitiveIdEn] = static (banks, _, value) => banks.Context.ShaderInterface.PrimitiveIdEnable = value;
indirect[VgtReuseOff] = static (banks, _, value) => banks.Context.ShaderInterface.VertexReuseOff = value;
indirect[SpiShaderPosFormat] = static (banks, _, value) => banks.ShaderInterfaceForWrite.PositionExportFormat = value;
indirect[SpiShaderIdxFormat] = static (banks, _, value) => banks.ShaderInterfaceForWrite.IndexExportFormat = value;
indirect[PaClVsOutCntl] = static (banks, _, value) => banks.ShaderInterfaceForWrite.VertexOutputControl = value;
indirect[GeNggSubgroupCntl] = static (banks, _, value) => banks.ShaderInterfaceForWrite.PrimitiveShaderSubgroupControl = value;
indirect[VgtGsInstanceCnt] = static (banks, _, value) => banks.ShaderInterfaceForWrite.GeometryInstanceCount = value;
indirect[VgtGsOnchipCntl] = static (banks, _, value) => banks.ShaderInterfaceForWrite.GeometryOnChipControl = value;
indirect[VgtHosMaxTessLevel] = static (banks, _, value) => banks.ShaderInterfaceForWrite.MaxTessellationLevel = value;
indirect[VgtHosMinTessLevel] = static (banks, _, value) => banks.ShaderInterfaceForWrite.MinTessellationLevel = value;
indirect[GeMaxOutputPerSubgroup] = static (banks, _, value) => banks.ShaderInterfaceForWrite.MaxOutputPerSubgroup = value;
indirect[VgtEsgsRingItemSize] = static (banks, _, value) => banks.ShaderInterfaceForWrite.ExportRingItemSize = value;
indirect[VgtGsMaxVertOut] = static (banks, _, value) => banks.ShaderInterfaceForWrite.GeometryMaxVerticesOut = value;
indirect[VgtPrimitiveIdEn] = static (banks, _, value) => banks.ShaderInterfaceForWrite.PrimitiveIdEnable = value;
indirect[VgtReuseOff] = static (banks, _, value) => banks.ShaderInterfaceForWrite.VertexReuseOff = value;
indirect[VgtMultiPrimIbResetIndex] = static (banks, _, value) => banks.Context.PrimitiveResetIndex = value;
indirect[VgtTessDistribution] = static (banks, _, value) => banks.Context.ShaderInterface.TessellationDistribution = value;
indirect[VgtTessDistribution] = static (banks, _, value) => banks.ShaderInterfaceForWrite.TessellationDistribution = value;
indirect[VgtShaderStagesEn] = static (banks, _, value) => banks.Context.ShaderStages = value;
indirect[VgtLsHsConfig] = static (banks, _, value) => banks.Context.ShaderInterface.LocalHullConfiguration = value;
indirect[VgtGsOutPrimType] = static (banks, _, value) => banks.Context.ShaderInterface.GeometryOutputPrimitiveType = value;
indirect[VgtTfParam] = static (banks, _, value) => banks.Context.ShaderInterface.TessellationFactorParameter = value;
indirect[SpiShaderZFormat] = static (banks, _, value) => banks.Context.ShaderInterface.DepthExportFormat = value;
indirect[VgtLsHsConfig] = static (banks, _, value) => banks.ShaderInterfaceForWrite.LocalHullConfiguration = value;
indirect[VgtGsOutPrimType] = static (banks, _, value) => banks.ShaderInterfaceForWrite.GeometryOutputPrimitiveType = value;
indirect[VgtTfParam] = static (banks, _, value) => banks.ShaderInterfaceForWrite.TessellationFactorParameter = value;
indirect[SpiShaderZFormat] = static (banks, _, value) => banks.ShaderInterfaceForWrite.DepthExportFormat = value;
indirect[SpiShaderColFormat] = ShaderColorFormatEntry;
indirect[SpiPsInputEna] = static (banks, _, value) => banks.Context.ShaderInterface.PixelInputEnable = value;
indirect[SpiPsInputAddr] = static (banks, _, value) => banks.Context.ShaderInterface.PixelInputAddress = value;
indirect[SpiPsInControl] = static (banks, _, value) => banks.Context.ShaderInterface.PixelInputControl = value;
indirect[SpiBarycCntl] = static (banks, _, value) => banks.Context.ShaderInterface.BarycentricControl = value;
indirect[DbShaderControl] = static (banks, _, value) => banks.Context.ShaderInterface.DepthShaderControl = DepthShaderControlRegisters.Decode(value);
indirect[CbShaderMask] = static (banks, _, value) => banks.Context.ShaderInterface.ColorShaderMask = value;
indirect[PaScShaderControl] = static (banks, _, value) => banks.Context.ShaderInterface.ScanShaderControl = value;
indirect[SpiPsInputEna] = static (banks, _, value) => banks.ShaderInterfaceForWrite.PixelInputEnable = value;
indirect[SpiPsInputAddr] = static (banks, _, value) => banks.ShaderInterfaceForWrite.PixelInputAddress = value;
indirect[SpiPsInControl] = static (banks, _, value) => banks.ShaderInterfaceForWrite.PixelInputControl = value;
indirect[SpiBarycCntl] = static (banks, _, value) => banks.ShaderInterfaceForWrite.BarycentricControl = value;
indirect[DbShaderControl] = static (banks, _, value) => banks.ShaderInterfaceForWrite.DepthShaderControl = DepthShaderControlRegisters.Decode(value);
indirect[CbShaderMask] = static (banks, _, value) => banks.ShaderInterfaceForWrite.ColorShaderMask = value;
indirect[PaScShaderControl] = static (banks, _, value) => banks.ShaderInterfaceForWrite.ScanShaderControl = value;
indirect[CbTargetMask] = static (banks, _, value) => banks.Context.RenderTargetMask = value;
indirect[PaScScreenScissorTl] = ScreenScissorTopLeftEntry;
indirect[PaScScreenScissorBr] = ScreenScissorBottomRightEntry;
@ -248,7 +248,7 @@ internal static partial class RegisterWriters
indirect[DbStencilRefMaskBack] = StencilMaskBackEntry;
indirect[PaClClipCntl] = static (banks, _, value) => banks.Context.Clip = ClipControlRegisters.Decode(value);
indirect[PaSuLineCntl] = LineControlEntry;
indirect[PaClVteCntl] = static (banks, _, value) => banks.Context.ScreenViewport.TransformControl = value;
indirect[PaClVteCntl] = static (banks, _, value) => banks.ScreenViewportForWrite.TransformControl = value;
indirect[PaScModeCntl0] = static (banks, _, value) => banks.Context.ScanMode = ScanModeRegisters.Decode(value);
indirect[PaScAaConfig] = static (banks, _, value) => banks.Context.AntialiasingConfig = AntialiasingConfigRegisters.Decode(value);
indirect[PaScCentroidPriority0] = CentroidPriorityEntry;
@ -369,8 +369,8 @@ internal static partial class RegisterWriters
throw PacketError(banks, "The pixel interpolator packet has an invalid count", in packet, offset, values.Length);
}
values.CopyTo(banks.Context.ShaderInterface.PixelInterpolatorSettings);
banks.Context.ShaderInterface.PixelInterpolatorWritten |= (uint)((1ul << values.Length) - 1);
values.CopyTo(banks.ShaderInterfaceForWrite.PixelInterpolatorSettings);
banks.ShaderInterfaceForWrite.PixelInterpolatorWritten |= (uint)((1ul << values.Length) - 1);
return (uint)values.Length;
}
@ -410,7 +410,7 @@ internal static partial class RegisterWriters
// One location at any slot, or all sixteen from the first slot.
private static uint SampleLocationsPacket(RegisterBanks banks, in PacketContext packet, uint offset, ReadOnlySpan<uint> values)
{
var locations = banks.Context.SampleLocations.Locations;
var locations = banks.SampleLocationsForWrite.Locations;
if (values.Length == 1)
{
locations[offset - PaScAaSampleLocations0] = values[0];
@ -506,19 +506,19 @@ internal static partial class RegisterWriters
private static void HardwareScreenOffsetEntry(RegisterBanks banks, uint offset, uint value)
{
banks.Context.ScreenViewport.HardwareOffsetX = RegisterField.Get(value, 0, 0x1FF);
banks.Context.ScreenViewport.HardwareOffsetY = RegisterField.Get(value, 16, 0x1FF);
banks.ScreenViewportForWrite.HardwareOffsetX = RegisterField.Get(value, 0, 0x1FF);
banks.ScreenViewportForWrite.HardwareOffsetY = RegisterField.Get(value, 16, 0x1FF);
}
private static void WindowOffsetEntry(RegisterBanks banks, uint offset, uint value)
{
banks.Context.ScreenViewport.WindowOffsetX = RegisterField.Coordinate16(value);
banks.Context.ScreenViewport.WindowOffsetY = RegisterField.Coordinate16(value >> 16);
banks.ScreenViewportForWrite.WindowOffsetX = RegisterField.Coordinate16(value);
banks.ScreenViewportForWrite.WindowOffsetY = RegisterField.Coordinate16(value >> 16);
}
private static void ClipRectangleEntry(RegisterBanks banks, uint offset, uint value)
{
var viewport = banks.Context.ScreenViewport;
var viewport = banks.ScreenViewportForWrite;
if (offset == PaScClipRectRule)
{
viewport.ClipRectangleRule = (ushort)(value & 0xFFFFu);
@ -598,7 +598,7 @@ internal static partial class RegisterWriters
private static void CentroidPriorityEntry(RegisterBanks banks, uint offset, uint value)
{
var locations = banks.Context.SampleLocations;
var locations = banks.SampleLocationsForWrite;
switch (offset)
{
case PaScCentroidPriority0:
@ -612,7 +612,7 @@ internal static partial class RegisterWriters
private static void ShaderColorFormatEntry(RegisterBanks banks, uint offset, uint value)
{
var modes = banks.Context.ShaderInterface.TargetOutputModes;
var modes = banks.ShaderInterfaceForWrite.TargetOutputModes;
for (var index = 0; index < modes.Length; index++)
{
modes[index] = (byte)((value >> (index * 4)) & 0xFu);
@ -621,19 +621,19 @@ internal static partial class RegisterWriters
private static void ScreenScissorTopLeftEntry(RegisterBanks banks, uint offset, uint value)
{
banks.Context.ScreenViewport.ScreenScissorLeft = RegisterField.Coordinate16(value);
banks.Context.ScreenViewport.ScreenScissorTop = RegisterField.Coordinate16(value >> 16);
banks.ScreenViewportForWrite.ScreenScissorLeft = RegisterField.Coordinate16(value);
banks.ScreenViewportForWrite.ScreenScissorTop = RegisterField.Coordinate16(value >> 16);
}
private static void ScreenScissorBottomRightEntry(RegisterBanks banks, uint offset, uint value)
{
banks.Context.ScreenViewport.ScreenScissorRight = RegisterField.Coordinate16(value);
banks.Context.ScreenViewport.ScreenScissorBottom = RegisterField.Coordinate16(value >> 16);
banks.ScreenViewportForWrite.ScreenScissorRight = RegisterField.Coordinate16(value);
banks.ScreenViewportForWrite.ScreenScissorBottom = RegisterField.Coordinate16(value >> 16);
}
private static void GenericScissorTopLeftEntry(RegisterBanks banks, uint offset, uint value)
{
var viewport = banks.Context.ScreenViewport;
var viewport = banks.ScreenViewportForWrite;
viewport.GenericScissorLeft = RegisterField.Coordinate15(value);
viewport.GenericScissorTop = RegisterField.Coordinate15(value >> 16);
viewport.GenericScissorWindowOffsetEnable = !RegisterField.Bit(value, 31);
@ -641,13 +641,13 @@ internal static partial class RegisterWriters
private static void GenericScissorBottomRightEntry(RegisterBanks banks, uint offset, uint value)
{
banks.Context.ScreenViewport.GenericScissorRight = RegisterField.Coordinate15(value);
banks.Context.ScreenViewport.GenericScissorBottom = RegisterField.Coordinate15(value >> 16);
banks.ScreenViewportForWrite.GenericScissorRight = RegisterField.Coordinate15(value);
banks.ScreenViewportForWrite.GenericScissorBottom = RegisterField.Coordinate15(value >> 16);
}
private static void WindowScissorTopLeftEntry(RegisterBanks banks, uint offset, uint value)
{
var viewport = banks.Context.ScreenViewport;
var viewport = banks.ScreenViewportForWrite;
viewport.WindowScissorLeft = RegisterField.Coordinate15(value);
viewport.WindowScissorTop = RegisterField.Coordinate15(value >> 16);
viewport.WindowScissorWindowOffsetEnable = !RegisterField.Bit(value, 31);
@ -655,13 +655,13 @@ internal static partial class RegisterWriters
private static void WindowScissorBottomRightEntry(RegisterBanks banks, uint offset, uint value)
{
banks.Context.ScreenViewport.WindowScissorRight = RegisterField.Coordinate15(value);
banks.Context.ScreenViewport.WindowScissorBottom = RegisterField.Coordinate15(value >> 16);
banks.ScreenViewportForWrite.WindowScissorRight = RegisterField.Coordinate15(value);
banks.ScreenViewportForWrite.WindowScissorBottom = RegisterField.Coordinate15(value >> 16);
}
private static void ViewportScissorTopLeftEntry(RegisterBanks banks, uint offset, uint value)
{
ref var viewport = ref banks.Context.ScreenViewport.Viewports[(offset - PaScViewportScissor0Tl) / 2];
ref var viewport = ref banks.ViewportsForWrite[(offset - PaScViewportScissor0Tl) / 2];
viewport.ScissorLeft = RegisterField.Coordinate15(value);
viewport.ScissorTop = RegisterField.Coordinate15(value >> 16);
viewport.ScissorWindowOffsetEnable = !RegisterField.Bit(value, 31);
@ -669,21 +669,21 @@ internal static partial class RegisterWriters
private static void ViewportScissorBottomRightEntry(RegisterBanks banks, uint offset, uint value)
{
ref var viewport = ref banks.Context.ScreenViewport.Viewports[(offset - PaScViewportScissor0Br) / 2];
ref var viewport = ref banks.ViewportsForWrite[(offset - PaScViewportScissor0Br) / 2];
viewport.ScissorRight = RegisterField.Coordinate15(value);
viewport.ScissorBottom = RegisterField.Coordinate15(value >> 16);
}
private static void ViewportZMinEntry(RegisterBanks banks, uint offset, uint value) =>
banks.Context.ScreenViewport.Viewports[(offset - PaScViewportZMin0) / 2].MinDepth = RegisterField.AsFloat(value);
banks.ViewportsForWrite[(offset - PaScViewportZMin0) / 2].MinDepth = RegisterField.AsFloat(value);
private static void ViewportZMaxEntry(RegisterBanks banks, uint offset, uint value) =>
banks.Context.ScreenViewport.Viewports[(offset - PaScViewportZMax0) / 2].MaxDepth = RegisterField.AsFloat(value);
banks.ViewportsForWrite[(offset - PaScViewportZMax0) / 2].MaxDepth = RegisterField.AsFloat(value);
private static void ViewportScaleOffsetEntry(RegisterBanks banks, uint offset, uint value)
{
var relative = offset - PaClViewportXScale;
ref var viewport = ref banks.Context.ScreenViewport.Viewports[relative / ViewportFieldCount];
ref var viewport = ref banks.ViewportsForWrite[relative / ViewportFieldCount];
var number = RegisterField.AsFloat(value);
switch (relative % ViewportFieldCount)
{
@ -718,7 +718,7 @@ internal static partial class RegisterWriters
private static void GuardBandEntry(RegisterBanks banks, uint offset, uint value)
{
var viewport = banks.Context.ScreenViewport;
var viewport = banks.ScreenViewportForWrite;
var number = RegisterField.AsFloat(value);
switch (offset)
{
@ -760,7 +760,7 @@ internal static partial class RegisterWriters
}
private static void BlendControlEntry(RegisterBanks banks, uint offset, uint value) =>
banks.Context.BlendControls[offset - CbBlend0Control] = BlendRegisters.Decode(value);
banks.BlendControlsForWrite[offset - CbBlend0Control] = BlendRegisters.Decode(value);
private static void DepthZInfoEntry(RegisterBanks banks, uint offset, uint value)
{
@ -804,7 +804,7 @@ internal static partial class RegisterWriters
}
private static ref ColorTargetWords ColorTarget(RegisterBanks banks, uint offset, uint first, uint stride) =>
ref banks.Context.ColorTargets[(offset - first) / stride];
ref banks.ColorTargetsForWrite[(offset - first) / stride];
private static void ColorBaseLowEntry(RegisterBanks banks, uint offset, uint value)
{
@ -878,7 +878,7 @@ internal static partial class RegisterWriters
}
private static void ColorClearWord1Entry(RegisterBanks banks, uint offset, uint value) =>
banks.Context.ColorClearWord1[(offset - CbColor0ClearWord1) / ColorSlotStride] = value;
banks.ColorClearWord1ForWrite[(offset - CbColor0ClearWord1) / ColorSlotStride] = value;
private static void ColorDccBaseLowEntry(RegisterBanks banks, uint offset, uint value)
{

View File

@ -199,6 +199,9 @@ public sealed class VertexStageRegisters
public UserScalarRegisters LegacyVertexUserScalars = new();
public UserScalarRegisters ExportUserScalars = new();
// The same registers sharing the user scalar sets, which a writer then copies one by one.
public VertexStageRegisters ShallowCopy() => (VertexStageRegisters)MemberwiseClone();
public VertexStageRegisters Copy()
{
var copy = (VertexStageRegisters)MemberwiseClone();
@ -217,6 +220,8 @@ public sealed class PixelStageRegisters
public PixelResource2 Resource2;
public UserScalarRegisters UserScalars = new();
public PixelStageRegisters ShallowCopy() => (PixelStageRegisters)MemberwiseClone();
public PixelStageRegisters Copy()
{
var copy = (PixelStageRegisters)MemberwiseClone();
@ -253,6 +258,8 @@ public sealed class ComputeStageRegisters
public byte SharedVectorRegisters;
public UserScalarRegisters UserScalars = new();
public ComputeStageRegisters ShallowCopy() => (ComputeStageRegisters)MemberwiseClone();
public ComputeStageRegisters Copy()
{
var copy = (ComputeStageRegisters)MemberwiseClone();
@ -264,6 +271,9 @@ public sealed class ComputeStageRegisters
// The shader bank: the program registers of every stage and their user scalars.
public sealed class ShaderProgramRegisters
{
// A bank holding the same stage objects, for a snapshot that shares them.
public ShaderProgramRegisters ShareStages() => (ShaderProgramRegisters)MemberwiseClone();
public VertexStageRegisters Vertex = new();
public PixelStageRegisters Pixel = new();
public ComputeStageRegisters Compute = new();

View File

@ -31,10 +31,13 @@ public sealed unsafe partial class CachedImage
SubresourceRange = new ImageSubresourceRange(ViewFormatRules.FullAspects(Backing.Format), baseLevel, levelCount, baseLayer, layerCount),
};
// What a state that needs no barrier returns, so the common case allocates nothing. Callers only read it.
private static readonly List<ImageMemoryBarrier2> NoBarriers = [];
// Barriers for the requested state; a repeated write always gets a barrier.
public (List<ImageMemoryBarrier2> Barriers, PipelineStageFlags SourceStages) GetBarriers(ImageLayout layout, AccessFlags access, PipelineStageFlags stage, SubresourceRange? range)
{
var barriers = new List<ImageMemoryBarrier2>();
List<ImageMemoryBarrier2>? barriers = null;
PipelineStageFlags sourceStages = 0;
if ((access & (WriteAccess | AccessFlags.ColorAttachmentWriteBit | AccessFlags.DepthStencilAttachmentWriteBit)) != 0)
{
@ -83,7 +86,7 @@ public sealed unsafe partial class CachedImage
var repeatedWrite = (state.Access & WriteAccess) != 0;
if (state.Layout != layout || state.Access != access || repeatedWrite)
{
barriers.Add(MakeBarrier(state, layout, access, level, 1, layer, 1));
(barriers ??= []).Add(MakeBarrier(state, layout, access, level, 1, layer, 1));
sourceStages |= state.Stage;
states[index] = new ImageAccessState(stage, access, layout);
}
@ -101,15 +104,15 @@ public sealed unsafe partial class CachedImage
var repeatedWrite = (state.Access & WriteAccess) != 0;
if (state.Layout == layout && state.Access == access && !repeatedWrite)
{
return (barriers, sourceStages);
return (NoBarriers, sourceStages);
}
barriers.Add(MakeBarrier(state, layout, access, 0, Vk.RemainingMipLevels, 0, Vk.RemainingArrayLayers));
(barriers ??= []).Add(MakeBarrier(state, layout, access, 0, Vk.RemainingMipLevels, 0, Vk.RemainingArrayLayers));
sourceStages |= state.Stage;
}
Backing.State = new ImageAccessState(stage, access, layout);
return (barriers, sourceStages);
return (barriers ?? NoBarriers, sourceStages);
}
public void Transition(ImageLayout layout, AccessFlags access, SubresourceRange? range, CommandBuffer command)

View File

@ -303,6 +303,7 @@ public sealed unsafe class ColorToMultisampleDepthBlit : IDisposable
vk.CmdBeginRenderPass(command, &begin, SubpassContents.Inline);
vk.CmdBindDescriptorSets(command, PipelineBindPoint.Graphics, _pipelineLayout, 0, 1, &set, 0, null);
vk.CmdBindPipeline(command, PipelineBindPoint.Graphics, cached.Pipeline);
Vulkan.GraphicsDynamicStateEpoch.Advance();
var viewport = new Viewport(0.0f, 0.0f, extent.Width, extent.Height, 0.0f, 1.0f);
var scissor = new Rect2D(new Offset2D(0, 0), extent);
vk.CmdSetViewport(command, 0, 1, &viewport);

View File

@ -35,6 +35,25 @@ public sealed partial class GuestImageCache
private void InvalidateLookups() => _lookupGeneration++;
// Changes whenever an image a lookup could find is created, removed or replaced, so a caller
// that remembers a lookup's result can tell when it may have changed.
public ulong LookupGeneration => _lookupGeneration;
// What a remembered lookup still does on reuse: the image counts as used now. False when the
// image is gone.
public bool TryTouchRemembered(ResourceSlotIdentifier imageIdentifier)
{
using var held = _lock.Hold();
if (_slots.TryGet(imageIdentifier) is not { Registered: true } image)
{
return false;
}
image.LastAccessTick = _scheduler.CurrentTick;
TouchImage(image);
return true;
}
private static int LookupSlot(in ImageRequest request, bool exactFormat)
{
var hash = HashCode.Combine(request.Description.Data.Address, request.Description.Data.Size, request.Description.PixelFormat,

View File

@ -287,13 +287,20 @@ public sealed partial class GuestImageCache
public bool IsMetadataCleared(ulong address, uint slice) => IsMetadataCleared(address, slice, out _);
// DCC metadata of a bound target, or the range of a DCC fill seen before its target: both are
// filled on the host, which keeps them CPU-readable instead of GPU-written.
public bool OverlapsDccMetadata(ulong address, ulong size)
{
using var held = _lock.Hold();
foreach (var (start, metadata) in _surfaceMetadata)
{
if (metadata.Kind == SurfaceMetadataKind.Dcc && metadata.Size != 0 &&
address < start + metadata.Size && start < address + size)
var extent = metadata.Kind switch
{
SurfaceMetadataKind.Dcc => metadata.Size,
SurfaceMetadataKind.PendingDcc => metadata.FillSize,
_ => 0UL,
};
if (extent != 0 && address < start + extent && start < address + size)
{
return true;
}

View File

@ -761,6 +761,7 @@ public sealed unsafe partial class GuestImageCache
_scheduler.QueuePriorityCompletionAction(() =>
{
download.Invalidate(offset, range.Size);
GpuWritebackEpoch.Advance();
if (!backing.TryWriteBacking(range.Address, download.Mapped.Slice((int)offset, (int)range.Size)))
{
throw SubmissionScheduler.Fatal($"The image readback could not be written to guest memory: address=0x{range.Address:X16} size=0x{range.Size:X}.");

View File

@ -76,8 +76,56 @@ public static partial class ImageRequestBuilders
public static bool SupportsDccFixedClear(Format format) => PackedClearValue.SupportsDccFixedColor(format);
// Every draw rebuilds its targets' requests (a whole mip layout) from the same register words,
// twice: once for the attachments and once for the pixel outputs. The build reads nothing but
// its arguments, so a result is reused while they are equal.
private struct ColorTargetMemo
{
public bool Valid;
public ColorTargetWords Words;
public uint TargetMask;
public uint DrawLayerOffset;
public bool IgnoreTargetMask;
public ColorTargetResolution? Result;
}
private const int TargetMemoCount = 8;
[ThreadStatic]
private static ColorTargetMemo[]? _colorTargetMemos;
private static int TargetMemoSlot(ulong address) => (int)((address >> 12) ^ (address >> 20)) & (TargetMemoCount - 1);
// Builds the request for a bound color target. Null when the slot carries no target.
public static ColorTargetResolution? ColorTarget(in ColorTargetWords words, uint targetMask, uint drawLayerOffset, bool ignoreTargetMask)
{
if (Rendering.RenderTrace.Enabled)
{
return ColorTargetCore(in words, targetMask, drawLayerOffset, ignoreTargetMask);
}
var memos = _colorTargetMemos ??= new ColorTargetMemo[TargetMemoCount];
ref var memo = ref memos[TargetMemoSlot(words.BaseAddress)];
if (memo.Valid && memo.TargetMask == targetMask && memo.DrawLayerOffset == drawLayerOffset &&
memo.IgnoreTargetMask == ignoreTargetMask && memo.Words.Equals(words))
{
return memo.Result;
}
var result = ColorTargetCore(in words, targetMask, drawLayerOffset, ignoreTargetMask);
memo = new ColorTargetMemo
{
Valid = true,
Words = words,
TargetMask = targetMask,
DrawLayerOffset = drawLayerOffset,
IgnoreTargetMask = ignoreTargetMask,
Result = result,
};
return result;
}
private static ColorTargetResolution? ColorTargetCore(in ColorTargetWords words, uint targetMask, uint drawLayerOffset, bool ignoreTargetMask)
{
var mask = targetMask & 0xF;
if (ignoreTargetMask && words.BaseAddress != 0 && mask == 0)
@ -235,8 +283,7 @@ public static partial class ImageRequestBuilders
pitch = TileGeometry.TexturePitch(transferFormat, width, GuestTileMode.Linear);
}
var mipSpans = new TileLevelSpan[TiledSurfaceLayout.MaxLevels];
var mipPadded = new TilePaddedSize[TiledSurfaceLayout.MaxLevels];
var (mipSpans, mipPadded) = MipLayoutScratch.Rent();
TiledSurfaceLayout? volumeLayout = null;
ulong size;
ulong backingSize = 0;

View File

@ -59,8 +59,28 @@ public static partial class ImageRequestBuilders
}
// Builds the request for the bound depth target. Null when no depth or stencil state is active.
public static DepthTargetResolution? DepthTarget(in DepthTargetWords depthWords, IImageFormatSupport device) =>
DepthTargetCore(in depthWords, device, copyMode: false, writeBuffer: false);
// Reused while the words and the device are the same, as ColorTarget's requests are.
public static DepthTargetResolution? DepthTarget(in DepthTargetWords depthWords, IImageFormatSupport device)
{
if (Rendering.RenderTrace.Enabled)
{
return DepthTargetCore(in depthWords, device, copyMode: false, writeBuffer: false);
}
if (_depthTargetMemo is { } memo && ReferenceEquals(memo.Device, device) && memo.Words.Equals(depthWords))
{
return memo.Result;
}
var result = DepthTargetCore(in depthWords, device, copyMode: false, writeBuffer: false);
_depthTargetMemo = new DepthTargetMemo(depthWords, device, result);
return result;
}
private sealed record DepthTargetMemo(DepthTargetWords Words, IImageFormatSupport Device, DepthTargetResolution? Result);
[ThreadStatic]
private static DepthTargetMemo? _depthTargetMemo;
// Builds the source or destination image used by DB_RENDER_OVERRIDE depth/stencil copies.
public static DepthTargetResolution? DepthTargetCopy(in DepthTargetWords depthWords, IImageFormatSupport device, bool writeBuffer) =>

View File

@ -89,8 +89,7 @@ public static partial class ImageRequestBuilders
return;
}
var spans = new TileLevelSpan[TiledSurfaceLayout.MaxLevels];
var padded = new TilePaddedSize[TiledSurfaceLayout.MaxLevels];
var (spans, padded) = MipLayoutScratch.Rent();
TileGeometry.TryGetTextureSize(
description.GuestFormat, description.Extent.Width, description.Extent.Height, description.Resources.Levels, description.TileMode,
out _, spans, padded);

View File

@ -48,7 +48,7 @@ public enum SamplerBorderColor : uint
public sealed unsafe class SamplerStore : IDisposable
{
private readonly GpuDeviceInfo _device;
private readonly Dictionary<(uint, uint, uint, uint, bool), Sampler> _samplers = new();
private readonly System.Collections.Concurrent.ConcurrentDictionary<(uint, uint, uint, uint, bool), Sampler> _samplers = new();
private readonly object _gate = new();
public SamplerStore(GpuDeviceInfo device) => _device = device;
@ -57,16 +57,22 @@ public sealed unsafe class SamplerStore : IDisposable
public Sampler GetSampler(in SamplerDescriptorWords words, bool integerView)
{
var key = (words[0], words[1], words[2], words[3], integerView);
// Every sampler binding of every draw asks; only the first of each kind creates.
if (_samplers.TryGetValue(key, out var found))
{
return found;
}
lock (_gate)
{
var key = (words[0], words[1], words[2], words[3], integerView);
if (_samplers.TryGetValue(key, out var existing))
{
return existing;
}
var sampler = CreateSampler(words, integerView);
_samplers.Add(key, sampler);
_samplers[key] = sampler;
return sampler;
}
}

View File

@ -233,8 +233,7 @@ public sealed class TextureTransferLayout
layout.Pitch = TileGeometry.TexturePitch(format, width, tile);
if (tile == GuestTileMode.Linear)
{
var levelSpans = new TileLevelSpan[TiledSurfaceLayout.MaxLevels];
var paddedSizes = new TilePaddedSize[TiledSurfaceLayout.MaxLevels];
var (levelSpans, paddedSizes) = MipLayoutScratch.Rent();
TileGeometry.TryGetTextureSize(format, width, height, levels, tile, out _, levelSpans, paddedSizes);
for (uint level = 0; level < levels; level++)
{

View File

@ -21,6 +21,24 @@ public struct TileLevelSpan
public readonly record struct TilePaddedSize(uint Width, uint Height);
// The per-level output arrays of the layout queries, kept per thread: every draw that resolves a
// target or a texture needs them for a moment and reads them before the next query.
internal static class MipLayoutScratch
{
[ThreadStatic] private static TileLevelSpan[]? _spans;
[ThreadStatic] private static TilePaddedSize[]? _padded;
// Zeroed, as a new array would be, so a level the query leaves out reads as empty.
public static (TileLevelSpan[] Spans, TilePaddedSize[] Padded) Rent()
{
var spans = _spans ??= new TileLevelSpan[TiledSurfaceLayout.MaxLevels];
var padded = _padded ??= new TilePaddedSize[TiledSurfaceLayout.MaxLevels];
Array.Clear(spans);
Array.Clear(padded);
return (spans, padded);
}
}
public enum TileBlockKind : uint
{
Standard256B,

View File

@ -36,6 +36,9 @@ internal interface IShaderPipelineHost
bool ComputeWave64Supported { get; }
// True when a 64-invocation wave64 compute workgroup runs as one 64-lane host subgroup.
bool ComputeWave64SubgroupNative => false;
bool GraphicsSubgroupOperationsEnabled { get; }
// The device supports shaderSharedInt64Atomics, so LDS 64-bit atomics can be

View File

@ -45,6 +45,13 @@ public sealed class PipelineStaticParameters : IEquatable<PipelineStaticParamete
public PipelineStaticParameters()
{
Reset();
}
// The state of a new instance, for a lookup object reused across draws.
public void Reset()
{
Array.Clear(_bytes);
DepthClipEnable = true;
Samples = 1;
ColorCount = 1;
@ -146,6 +153,15 @@ public sealed class PipelineRenderingState : IEquatable<PipelineRenderingState>
public Format StencilFormat { get; set; } = Format.Undefined;
public uint ColorCount { get; set; }
// The state of a new instance, for a lookup object reused across draws.
public void Reset()
{
Array.Clear(ColorFormats);
DepthFormat = Format.Undefined;
StencilFormat = Format.Undefined;
ColorCount = 0;
}
public bool Equals(PipelineRenderingState? other) =>
other is not null && ColorCount == other.ColorCount && DepthFormat == other.DepthFormat && StencilFormat == other.StencilFormat &&
ColorFormats.AsSpan().SequenceEqual(other.ColorFormats);
@ -179,6 +195,15 @@ public sealed class PipelineVertexInputState : IEquatable<PipelineVertexInputSta
public byte BindingCount { get; set; }
public byte AttributeCount { get; set; }
// The state of a new instance, for a lookup object reused across draws.
public void Reset()
{
Array.Clear(Bindings);
Array.Clear(Attributes);
BindingCount = 0;
AttributeCount = 0;
}
public bool Equals(PipelineVertexInputState? other) =>
other is not null && BindingCount == other.BindingCount && AttributeCount == other.AttributeCount &&
Bindings.AsSpan().SequenceEqual(other.Bindings) && Attributes.AsSpan().SequenceEqual(other.Attributes);
@ -207,8 +232,9 @@ public sealed class PipelineVertexInputState : IEquatable<PipelineVertexInputSta
public sealed class GraphicsPipelineKey : IEquatable<GraphicsPipelineKey>
{
public required PipelineRenderingState Rendering { get; init; }
public ulong VertexProgramId { get; init; }
public ulong PixelProgramId { get; init; }
// Settable for the lookup key reused across draws; a key in the cache is never changed.
public ulong VertexProgramId { get; set; }
public ulong PixelProgramId { get; set; }
public required PipelineVertexInputState VertexInput { get; init; }
public required PipelineStaticParameters StaticParameters { get; init; }

View File

@ -27,6 +27,9 @@ internal sealed partial class ShaderPipelineCache : IShaderPipelineProvider
private readonly Dictionary<GraphicsPipelineKey, PipelineHandle> _graphicsPipelines = new();
private readonly Dictionary<ComputePipelineKey, PipelineHandle> _computePipelines = new();
private readonly object _gate = new();
// The pipeline maps: a lookup takes this lock or _gate, an addition both; a cached draw so does
// not wait for the program preparation a resolving thread holds _gate for.
private readonly object _pipelineGate = new();
private readonly bool _strictShaders = Environment.GetEnvironmentVariable("SHARPEMU_STRICT_COMPUTE") != "0";
private readonly HashSet<(ShaderStage Stage, ulong Hash, uint CodeSize)> _reportedShaderSkips = [];
@ -70,6 +73,11 @@ internal sealed partial class ShaderPipelineCache : IShaderPipelineProvider
return new ShaderSource(registered, hash, userData, userDataBase, stage);
}
// Program resolution runs on the render thread and on a thread resolving draws ahead of it;
// the program, decode and materialization caches it updates are used under this lock only.
// A pipeline lookup takes _pipelineGate instead, so it does not wait for a resolution.
private readonly object _resolveGate = new();
public GraphicsPrograms GetGraphicsPrograms(
VertexStageRegisters vertex,
PixelStageRegisters pixel,
@ -78,6 +86,21 @@ internal sealed partial class ShaderPipelineCache : IShaderPipelineProvider
ReadOnlySpan<ColorComponentMap> targetExportMapping,
bool pixelActive,
bool depthBound)
{
lock (_resolveGate)
{
return GetGraphicsProgramsCore(vertex, pixel, shaderInterface, context, targetExportMapping, pixelActive, depthBound);
}
}
private GraphicsPrograms GetGraphicsProgramsCore(
VertexStageRegisters vertex,
PixelStageRegisters pixel,
ShaderInterfaceRegisters shaderInterface,
ContextRegisters context,
ReadOnlySpan<ColorComponentMap> targetExportMapping,
bool pixelActive,
bool depthBound)
{
using var profileScope = RenderPhaseProfile.MeasureDetail(RenderPhaseProfile.Phase.ProgramPreparation);
var vertexSource = PrepareSource(
@ -209,44 +232,53 @@ internal sealed partial class ShaderPipelineCache : IShaderPipelineProvider
shaderInterface.VertexOutputControl, clipSpace);
}
// One past the highest parameter location the pixel program reads, resolved as its translator does.
private static uint ReadVertexOutputCount(Gen5ShaderProgram pixelProgram, PixelInputInfo info)
{
var attributes = pixelProgram.Instructions
// The interpolated attributes a pixel program reads, ascending. A program never changes, and this
// was rebuilt with LINQ on every draw.
private static readonly System.Runtime.CompilerServices.ConditionalWeakTable<Gen5ShaderProgram, uint[]> InterpolatedAttributeCache = new();
private static uint[] InterpolatedAttributesOf(Gen5ShaderProgram program) =>
InterpolatedAttributeCache.GetValue(program, static program => program.Instructions
.Select(static instruction => instruction.Control)
.OfType<Gen5InterpolationControl>()
.Select(static control => control.Attribute)
.Distinct()
.Order()
.ToArray();
.ToArray());
// One past the highest parameter location the pixel program reads, resolved as its translator does.
private static uint ReadVertexOutputCount(Gen5ShaderProgram pixelProgram, PixelInputInfo info)
{
var attributes = InterpolatedAttributesOf(pixelProgram);
if (attributes.Length == 0)
{
return 0;
}
var controls = new uint[32];
Span<uint> controls = stackalloc uint[32];
for (var index = 0u; index < (uint)controls.Length; index++)
{
controls[index] = index < info.InputCount && index < (uint)info.InterpolatorSettings.Length
controls[(int)index] = index < info.InputCount && index < (uint)info.InterpolatorSettings.Length
? info.InterpolatorSettings[index]
: index;
}
return Gen5PixelInputMapping.ResolveLocations(controls, attributes).Max() + 1;
}
private static uint InterpolatedAttributeCount(Gen5ShaderProgram program)
{
var maxAttribute = -1;
foreach (var instruction in program.Instructions)
// Attributes are distinct and below 32, so a program never has more than 32 of them.
Span<uint> locations = attributes.Length <= 32 ? stackalloc uint[attributes.Length] : new uint[attributes.Length];
Gen5PixelInputMapping.ResolveLocations(controls, attributes, locations);
var highest = 0u;
foreach (var location in locations)
{
if (instruction.Control is Gen5InterpolationControl interpolation)
{
maxAttribute = Math.Max(maxAttribute, (int)interpolation.Attribute);
}
highest = Math.Max(highest, location);
}
return (uint)(maxAttribute + 1);
return highest + 1;
}
// The attribute list is ascending, so its last entry is the highest.
private static uint InterpolatedAttributeCount(Gen5ShaderProgram program)
{
var attributes = InterpolatedAttributesOf(program);
return attributes.Length == 0 ? 0 : attributes[^1] + 1;
}
// The bound colour slots in order; each output mode names the kind the pixel program exports.
@ -321,6 +353,20 @@ internal sealed partial class ShaderPipelineCache : IShaderPipelineProvider
uint dimensionX,
uint dimensionY,
uint dimensionZ)
{
lock (_resolveGate)
{
return GetComputeProgramCore(compute, shaderInterface, dispatchInitiator, dimensionX, dimensionY, dimensionZ);
}
}
private ComputeProgram GetComputeProgramCore(
ComputeStageRegisters compute,
ShaderInterfaceRegisters shaderInterface,
uint dispatchInitiator,
uint dimensionX,
uint dimensionY,
uint dimensionZ)
{
using var profileScope = RenderPhaseProfile.MeasureDetail(RenderPhaseProfile.Phase.ProgramPreparation);
var source = PrepareSource(compute.Address, ShaderStage.Compute, "compute", compute.UserScalars, compute.UserScalarCount, probeWrittenRegisters: false, userDataBase: 0);
@ -417,6 +463,28 @@ internal sealed partial class ShaderPipelineCache : IShaderPipelineProvider
ShaderProgram pixelProgram)
{
using var profileScope = RenderPhaseProfile.MeasureDetail(RenderPhaseProfile.Phase.PipelineCreation);
// The lookup fills objects this thread reuses for every draw; only a new pipeline gets its own.
var lookup = _graphicsLookup ??= new GraphicsPipelineKey
{
Rendering = new PipelineRenderingState(),
VertexInput = new PipelineVertexInputState(),
StaticParameters = new PipelineStaticParameters(),
};
lookup.Rendering.Reset();
lookup.VertexInput.Reset();
lookup.StaticParameters.Reset();
FillGraphicsState(colors, in depth, vertexInput, pixelInput, context, in rendering, topology, primitiveRestartEnabled, disableBlending,
vertexProgram, pixelProgram, _host.NoAttachmentSampleCounts, lookup.Rendering, lookup.StaticParameters, lookup.VertexInput);
lookup.VertexProgramId = vertexProgram.Id;
lookup.PixelProgramId = pixelInput is not null ? pixelProgram.Id : 0;
lock (_pipelineGate)
{
if (_graphicsPipelines.TryGetValue(lookup, out var cached))
{
return cached;
}
}
var description = BuildGraphicsDescription(
colors, in depth, vertexInput, pixelInput, context, in rendering, topology, primitiveRestartEnabled, disableBlending,
vertexProgram, pixelProgram, _host.NoAttachmentSampleCounts);
@ -437,12 +505,19 @@ internal sealed partial class ShaderPipelineCache : IShaderPipelineProvider
}
var created = _host.CreateGraphicsPipeline(description);
_graphicsPipelines.Add(key, created);
lock (_pipelineGate)
{
_graphicsPipelines.Add(key, created);
}
ShaderCacheCounters.CountGraphicsPipeline();
return created;
}
}
[ThreadStatic]
private static GraphicsPipelineKey? _graphicsLookup;
// The full static state of one graphics pipeline from the draw's targets, registers and programs.
internal static GraphicsPipelineDescription BuildGraphicsDescription(
ReadOnlySpan<ColorTargetState> colors,
@ -457,6 +532,43 @@ internal sealed partial class ShaderPipelineCache : IShaderPipelineProvider
ShaderProgram vertexProgram,
ShaderProgram pixelProgram,
SampleCountFlags noAttachmentSampleCounts)
{
var renderingState = new PipelineRenderingState();
var parameters = new PipelineStaticParameters();
var vertexState = new PipelineVertexInputState();
var (vertexStage, pixelStage) = FillGraphicsState(colors, in depth, vertexInput, pixelInput, context, in rendering, topology,
primitiveRestartEnabled, disableBlending, vertexProgram, pixelProgram, noAttachmentSampleCounts, renderingState, parameters, vertexState);
return new GraphicsPipelineDescription
{
Rendering = renderingState,
VertexInput = vertexState,
VertexInfo = vertexInput,
VertexProgram = vertexProgram,
VertexStage = vertexStage,
PixelInfo = pixelInput,
PixelProgram = pixelProgram,
PixelStage = pixelStage,
StaticParameters = parameters,
};
}
// Writes the pipeline state into objects in their initial state; returns the stage programs.
private static (ShaderProgramInfo VertexStage, ShaderProgramInfo? PixelStage) FillGraphicsState(
ReadOnlySpan<ColorTargetState> colors,
in DepthAttachmentState depth,
VertexInputInfo vertexInput,
PixelInputInfo? pixelInput,
ContextRegisters context,
in RenderingState rendering,
PrimitiveTopology topology,
bool primitiveRestartEnabled,
bool disableBlending,
ShaderProgram vertexProgram,
ShaderProgram pixelProgram,
SampleCountFlags noAttachmentSampleCounts,
PipelineRenderingState renderingState,
PipelineStaticParameters parameters,
PipelineVertexInputState vertexState)
{
if (colors.Length > PipelineStaticParameters.ColorAttachmentCount)
{
@ -477,8 +589,8 @@ internal sealed partial class ShaderPipelineCache : IShaderPipelineProvider
var vertexStage = vertexInput.Stage.Program ?? throw SubmissionScheduler.Fatal("The vertex stage has no program.");
var pixelStage = pixelActive ? pixelInput!.Stage.Program ?? throw SubmissionScheduler.Fatal("The pixel stage has no program.") : null;
var colorCount = (uint)colors.Length;
var parameters = new PipelineStaticParameters { ColorCount = colorCount };
var renderingState = new PipelineRenderingState { ColorCount = colorCount };
parameters.ColorCount = colorCount;
renderingState.ColorCount = colorCount;
for (var index = 0; index < colors.Length; index++)
{
ref readonly var color = ref colors[index];
@ -564,18 +676,8 @@ internal sealed partial class ShaderPipelineCache : IShaderPipelineProvider
parameters.SetBlendBypass(index, ((context.ColorTargets[slot].Info >> 16) & 0x1) != 0);
}
return new GraphicsPipelineDescription
{
Rendering = renderingState,
VertexInput = BuildVertexInputState(vertexInput),
VertexInfo = vertexInput,
VertexProgram = vertexProgram,
VertexStage = vertexStage,
PixelInfo = pixelInput,
PixelProgram = pixelProgram,
PixelStage = pixelStage,
StaticParameters = parameters,
};
FillVertexInputState(vertexInput, vertexState);
return (vertexStage, pixelStage);
}
internal static GraphicsPipelineKey KeyOf(GraphicsPipelineDescription description) => new()
@ -587,18 +689,15 @@ internal sealed partial class ShaderPipelineCache : IShaderPipelineProvider
StaticParameters = description.StaticParameters,
};
private static PipelineVertexInputState BuildVertexInputState(VertexInputInfo info)
private static void FillVertexInputState(VertexInputInfo info, PipelineVertexInputState state)
{
if (info.Buffers.Length > VertexInputInfo.MaxBuffers || info.Attributes.Length > VertexInputInfo.MaxBuffers)
{
throw SubmissionScheduler.Fatal($"The vertex input is too large: buffers={info.Buffers.Length} attributes={info.Attributes.Length}.");
}
var state = new PipelineVertexInputState
{
BindingCount = (byte)info.Buffers.Length,
AttributeCount = (byte)info.Attributes.Length,
};
state.BindingCount = (byte)info.Buffers.Length;
state.AttributeCount = (byte)info.Attributes.Length;
for (var binding = 0; binding < info.Buffers.Length; binding++)
{
var buffer = info.Buffers[binding];
@ -615,8 +714,6 @@ internal sealed partial class ShaderPipelineCache : IShaderPipelineProvider
state.Attributes[index] = new PipelineVertexAttribute(attribute.OffsetBytes, (byte)attribute.BufferIndex);
}
return state;
}
public PipelineHandle CreateComputePipeline(ComputeInputInfo input, ShaderProgram program)
@ -629,6 +726,14 @@ internal sealed partial class ShaderPipelineCache : IShaderPipelineProvider
var stage = input.Stage.Program ?? throw SubmissionScheduler.Fatal("The compute stage has no program.");
var key = new ComputePipelineKey(program.Id);
lock (_pipelineGate)
{
if (_computePipelines.TryGetValue(key, out var cached))
{
return cached;
}
}
lock (_gate)
{
if (_computePipelines.TryGetValue(key, out var cached))
@ -642,7 +747,11 @@ internal sealed partial class ShaderPipelineCache : IShaderPipelineProvider
}
var created = _host.CreateComputePipeline(new ComputePipelineDescription { Input = input, Program = program, Stage = stage });
_computePipelines.Add(key, created);
lock (_pipelineGate)
{
_computePipelines.Add(key, created);
}
ShaderCacheCounters.CountComputePipeline();
return created;
}
@ -659,6 +768,15 @@ internal sealed partial class ShaderPipelineCache : IShaderPipelineProvider
var stage = input.Stage.Program ?? throw SubmissionScheduler.Fatal("The compute stage has no program.");
var key = new ComputePipelineKey(program.Id);
lock (_pipelineGate)
{
if (_computePipelines.TryGetValue(key, out var cached))
{
handle = cached;
return true;
}
}
lock (_gate)
{
if (_computePipelines.TryGetValue(key, out var cached))
@ -674,7 +792,11 @@ internal sealed partial class ShaderPipelineCache : IShaderPipelineProvider
return false;
}
_computePipelines.Add(key, created);
lock (_pipelineGate)
{
_computePipelines.Add(key, created);
}
ShaderCacheCounters.CountComputePipeline();
handle = created;
return true;

View File

@ -1,6 +1,7 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Runtime.InteropServices;
using SharpEmu.HLE;
using SharpEmu.Libs.Gpu.Rendering;
using SharpEmu.Libs.Gpu.Scheduling;
@ -68,6 +69,37 @@ public sealed class ProgramKey : IEquatable<ProgramKey>
public override int GetHashCode() => HashCode.Combine(Stage, Hash, UserDataCount, CodeSize, StaticState.Length);
}
// A program key as a lookup sees it: the static state words stay in the caller's list, so a draw
// that finds its program does not build a key object or copy the words.
internal readonly ref struct ProgramKeyProbe(ShaderStage stage, ulong hash, uint userDataCount, uint codeSize, ReadOnlySpan<uint> staticState)
{
public readonly ShaderStage Stage = stage;
public readonly ulong Hash = hash;
public readonly uint UserDataCount = userDataCount;
public readonly uint CodeSize = codeSize;
public readonly ReadOnlySpan<uint> StaticState = staticState;
}
internal sealed class ProgramKeyComparer : IEqualityComparer<ProgramKey>, IAlternateEqualityComparer<ProgramKeyProbe, ProgramKey>
{
public static readonly ProgramKeyComparer Instance = new();
public bool Equals(ProgramKey? x, ProgramKey? y) => x is null ? y is null : x.Equals(y);
public int GetHashCode(ProgramKey key) => key.GetHashCode();
public bool Equals(ProgramKeyProbe alternate, ProgramKey key) =>
alternate.Stage == key.Stage && alternate.Hash == key.Hash && alternate.UserDataCount == key.UserDataCount &&
alternate.CodeSize == key.CodeSize && alternate.StaticState.SequenceEqual(key.StaticState);
// The same value as ProgramKey.GetHashCode.
public int GetHashCode(ProgramKeyProbe alternate) =>
HashCode.Combine(alternate.Stage, alternate.Hash, alternate.UserDataCount, alternate.CodeSize, alternate.StaticState.Length);
public ProgramKey Create(ProgramKeyProbe alternate) =>
new(alternate.Stage, alternate.Hash, alternate.UserDataCount, alternate.CodeSize, alternate.StaticState.ToArray());
}
// One compiled module of a program entry for one specialization and push-data start.
internal sealed class ProgramPermutation
{
@ -102,7 +134,7 @@ internal sealed class ShaderProgramCache
private readonly CpuContext _context;
private readonly IGuestGpuBackend _compiler;
private readonly IShaderPipelineHost _host;
private readonly Dictionary<ProgramKey, ProgramSourceEntry> _programs = new();
private readonly Dictionary<ProgramKey, ProgramSourceEntry> _programs = new(ProgramKeyComparer.Instance);
private readonly Dictionary<(ulong Hash, uint CodeSize), Gen5ShaderProgram> _decoded = new();
private readonly Dictionary<(ulong Hash, uint CodeSize), ShaderCodeCapture> _codeCaptures = new();
private readonly List<uint> _staticState = new(StageStaticKey.MaxWords);
@ -216,13 +248,14 @@ internal sealed class ShaderProgramCache
private ShaderProgram GetOrCompileCore(ShaderSource source, StageCompileOptions options, ref uint pushDataCursor, out ShaderStageResources stage)
{
ProgramKey key;
ProgramKey? key = null;
ProgramSourceEntry? entry;
using (RenderPhaseProfile.MeasureDetail(RenderPhaseProfile.Phase.ProgramCacheLookup))
{
BuildStaticState(source.Stage, options);
key = new ProgramKey(source.Stage, source.Hash, (uint)source.UserData.Length, source.CodeSize, _staticState.ToArray());
_programs.TryGetValue(key, out entry);
var probe = new ProgramKeyProbe(source.Stage, source.Hash, (uint)source.UserData.Length, source.CodeSize,
CollectionsMarshal.AsSpan(_staticState));
_programs.GetAlternateLookup<ProgramKeyProbe>().TryGetValue(probe, out entry);
}
var sourceWasCached = entry is not null;
if (RenderTrace.Enabled && RenderTrace.Pipeline())
@ -246,12 +279,14 @@ internal sealed class ShaderProgramCache
if (entry is null)
{
entry = CreateEntry(source, options);
key = BuildProgramKey(source);
_programs.Add(key, entry);
ShaderCacheCounters.CountProgram();
}
var snapshot = new ResourceSnapshot();
var specialization = new ResourceSpecialization();
// Both are assigned by the materialization that follows; a failed one throws.
ResourceSnapshot snapshot = null!;
ResourceSpecialization specialization = null!;
var captureIndirectImageFailure = _spirvDumpEnabled ? ShaderPermutationDump.CreateFailureCapture(source) : null;
if (Diagnostics.GpuReadTrace.Enabled)
{
@ -295,7 +330,7 @@ internal sealed class ShaderProgramCache
}
}
var permutation = CompilePermutation(source, options, entry, specialization, pushDataCursor, key, sourceWasCached);
var permutation = CompilePermutation(source, options, entry, specialization, pushDataCursor, key ?? BuildProgramKey(source), sourceWasCached);
entry.Permutations.Add(permutation);
ShaderCacheCounters.CountPermutation();
stage = CreateStageResources(permutation.Program, snapshot, source, options);
@ -308,6 +343,10 @@ internal sealed class ShaderProgramCache
return permutation.Handle;
}
// The key of the entry the static state was just built for.
private ProgramKey BuildProgramKey(ShaderSource source) =>
new(source.Stage, source.Hash, (uint)source.UserData.Length, source.CodeSize, _staticState.ToArray());
private static ShaderStageResources CreateStageResources(
ShaderProgramInfo program, ResourceSnapshot snapshot, ShaderSource source, StageCompileOptions options)
{
@ -646,7 +685,7 @@ internal sealed class ShaderProgramCache
default:
return BuildComputeRequest(entry.Plan, resources, layout, options.ComputeInfo!, options.ComputeSystemRegisters,
sharedInt64Atomics, _host.ExecGuardElisionEnabled, signedZeroInfNanPreserve);
sharedInt64Atomics, _host.ExecGuardElisionEnabled, signedZeroInfNanPreserve, _host.ComputeWave64SubgroupNative);
}
}
@ -663,10 +702,11 @@ internal sealed class ShaderProgramCache
private static ShaderCompileRequest BuildComputeRequest(ShaderResourcePlan plan, SpecializedResourceInfo resources, BindingLayout layout,
ComputeInputInfo info, Gen5ComputeSystemRegisters? systemRegisters, bool sharedInt64Atomics, bool execGuardElision,
bool signedZeroInfNanPreserve) =>
bool signedZeroInfNanPreserve, bool hostWave64Supported = false) =>
new(plan, resources, layout)
{
WaveSize = info.WaveSize,
HostWave64Supported = info.WaveSize == 64 && hostWave64Supported,
EnableExecGuardElision = info.WaveSize != 64 || execGuardElision,
TraceDeviceAddressFaults = SharpEmu.HLE.GpuMemory.GuestGpuMemoryHook.TraceEnabled,
ScratchDwords = info.ScratchDwords,
@ -681,7 +721,7 @@ internal sealed class ShaderProgramCache
internal static bool TryCompilePrewarm(ComputePrewarmRecord record, ShaderCodeCapture code, IGuestGpuBackend compiler,
bool sharedInt64Atomics, bool execGuardElision, bool signedZeroInfNanPreserve, out IGuestCompiledShader? compiled,
out BindingLayout? layout, out string error)
out BindingLayout? layout, out string error, bool hostWave64Supported = false)
{
compiled = null;
layout = null;
@ -698,7 +738,7 @@ internal sealed class ShaderProgramCache
layout = AllocateLayout(program, plan, resources, record.UserDataBase, record.UserDataCount, record.PushDataCursor,
record.Info.DispatchThreadDimensions);
var request = BuildComputeRequest(plan, resources, layout, record.Info, record.SystemRegisters,
sharedInt64Atomics, execGuardElision, signedZeroInfNanPreserve);
sharedInt64Atomics, execGuardElision, signedZeroInfNanPreserve, hostWave64Supported);
return compiler.TryCompileProgram(request, out compiled, out error) && compiled is not null;
}
catch (Exception exception)

View File

@ -0,0 +1,282 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using SharpEmu.Libs.Gpu.GpuCommands.Registers;
using SharpEmu.Libs.Gpu.Scheduling;
namespace SharpEmu.Libs.Gpu.Rendering;
// Resolves the programs of deferred draws on a worker thread, in issue order, while the render
// thread interprets and records. The render thread takes a result for the draw it runs only when
// the resolution completed, used the inputs the draw derives and still matches the host's
// GPU-ownership state; otherwise it resolves the draw itself.
public sealed class ParallelProgramPrefetch : IProgramPrefetch, IDisposable
{
public sealed class Job(RegisterBanks banks, long gate)
{
internal readonly RegisterBanks Banks = banks;
// The count of stream items other than draws that must have run before the job resolves.
internal readonly long Gate = gate;
internal GraphicsProgramInputs Inputs;
internal GraphicsPrograms? Programs;
internal object? Assumptions;
// 0 queued, 1 resolved, 2 abandoned.
internal volatile int State;
}
private readonly RenderExecutor _executor;
private readonly IRenderHost _host;
private readonly IConcurrentResolveHost _concurrent;
private readonly object _lock = new();
private readonly Queue<Job> _queue = new();
private readonly Thread _thread;
private int _outstanding;
private long _finished;
private long _executedGate;
// The gate the worker sleeps on; long.MaxValue while it does not.
private long _sleepingOnGate = long.MaxValue;
private bool _paused;
private bool _running;
private bool _stopping;
public ParallelProgramPrefetch(RenderExecutor executor, IRenderHost host, IConcurrentResolveHost concurrent)
{
_executor = executor;
_host = host;
_concurrent = concurrent;
_thread = new Thread(Run) { IsBackground = true, Name = "SharpEmu program resolve" };
_thread.Start();
}
// The job of the draw the render thread runs now; set around each deferred draw.
public Job? Current { get; set; }
public long Submitted { get; private set; }
// A gate above zero holds the job until AdvanceGate reaches it: with the command stream on its own
// thread, draws are submitted ahead of the work before them, which may change what they read.
public Job Submit(RegisterBanks banks, long gate = 0)
{
var job = new Job(banks, gate);
lock (_lock)
{
_queue.Enqueue(job);
Submitted++;
_outstanding++;
Monitor.PulseAll(_lock);
}
return job;
}
// Returns once the worker has finished every submitted job: it reads guest memory and cache
// state, which the render thread changes only with the worker idle.
public void WaitIdle()
{
if (Volatile.Read(ref _outstanding) == 0)
{
return;
}
lock (_lock)
{
while (_outstanding != 0)
{
Monitor.Wait(_lock);
}
}
}
// The render thread ran this many non-draw items: jobs gated on them may resolve.
public void AdvanceGate(long executed)
{
Interlocked.Exchange(ref _executedGate, executed);
// Only a worker asleep on a gate this reaches needs a wake: one per item costs the render
// thread a kernel call each.
if (Volatile.Read(ref _sleepingOnGate) <= executed)
{
lock (_lock)
{
Monitor.PulseAll(_lock);
}
}
}
// Returns once the worker finished the first count jobs it was given, so a non-draw item runs
// with no resolution of the draws before it still reading.
public void WaitFinished(long count)
{
if (Volatile.Read(ref _finished) >= count)
{
return;
}
lock (_lock)
{
while (_finished < count)
{
Monitor.Wait(_lock);
}
}
}
// Holds the worker between jobs, for host work that changes what a resolution reads.
public void Pause()
{
lock (_lock)
{
_paused = true;
while (_running)
{
Monitor.Wait(_lock);
}
}
}
public void Resume()
{
lock (_lock)
{
_paused = false;
Monitor.PulseAll(_lock);
}
}
public bool TryTakeGraphics(RegisterBanks banks, in GraphicsProgramInputs inputs, out GraphicsPrograms programs)
{
programs = null!;
var job = Current;
if (job is null || !ReferenceEquals(job.Banks, banks))
{
return false;
}
Current = null;
WaitFor(job);
if (job.State != 1 || !job.Inputs.Equals(inputs) || !_concurrent.IsStillCurrent(job.Assumptions!))
{
return false;
}
programs = job.Programs!;
return true;
}
private void WaitFor(Job job)
{
var spinner = new SpinWait();
for (var spin = 0; spin < 64 && job.State == 0; spin++)
{
spinner.SpinOnce(sleep1Threshold: -1);
}
if (job.State != 0)
{
return;
}
lock (_lock)
{
while (job.State == 0)
{
Monitor.Wait(_lock);
}
}
}
private void Run()
{
SubmissionScheduler.ThrowOnFatal = true;
while (true)
{
Job job;
lock (_lock)
{
while (_queue.Count == 0 && !_stopping)
{
Monitor.Wait(_lock);
}
if (_stopping)
{
return;
}
job = _queue.Peek();
if (job.Gate > Volatile.Read(ref _executedGate))
{
// The gate usually opens within a few items: spin before sleeping.
Monitor.Exit(_lock);
try
{
var spinner = new SpinWait();
for (var spin = 0; spin < 50 && job.Gate > Volatile.Read(ref _executedGate); spin++)
{
spinner.SpinOnce(sleep1Threshold: -1);
}
}
finally
{
Monitor.Enter(_lock);
}
}
while ((_paused || job.Gate > Volatile.Read(ref _executedGate)) && !_stopping)
{
Interlocked.Exchange(ref _sleepingOnGate, _paused ? long.MaxValue : job.Gate);
if (!_paused && job.Gate <= Volatile.Read(ref _executedGate))
{
break;
}
Monitor.Wait(_lock, 50);
}
Volatile.Write(ref _sleepingOnGate, long.MaxValue);
if (_stopping)
{
return;
}
_queue.Dequeue();
_running = true;
}
Resolve(job);
lock (_lock)
{
_outstanding--;
_finished++;
_running = false;
Monitor.PulseAll(_lock);
}
}
}
private void Resolve(Job job)
{
_concurrent.BeginConcurrentResolve();
try
{
job.Inputs = RenderExecutor.ProgramInputsOf(job.Banks, _host.FormatSupport, _host.Fatal);
job.Programs = _executor.ResolveGraphicsPrograms(job.Banks, in job.Inputs);
job.Assumptions = _concurrent.EndConcurrentResolve();
job.State = 1;
}
catch (Exception)
{
// The render thread resolves the draw itself, and fails there if the error is real.
job.Assumptions = _concurrent.EndConcurrentResolve();
job.State = 2;
}
}
public void Dispose()
{
lock (_lock)
{
_stopping = true;
Monitor.PulseAll(_lock);
}
}
}

View File

@ -714,15 +714,19 @@ public sealed partial class RenderExecutor
var hash = input.Stage.Program!.Hash;
if (!_host.TryClearImageFromBuffer(address, clear.Size, clear.PackedClear))
{
// A metadata fill may run before its target is bound; the store keeps it pending and the dispatch runs.
// A metadata fill may run before its target is bound; the store keeps it pending. The fill
// itself then runs on the host when the range is known DCC metadata: run as a dispatch it
// leaves the metadata GPU-written, and every later bind of the surface waits for the GPU to
// read it back (Astro Bot fills several surfaces this way each frame).
var registered = _host.TryAbsorbDccFill(address, clear.Size, clear.PackedClear);
var filledOnHost = !registered && _host.TryFillDccMetadata(address, clear.Size, clear.PackedClear);
if (RenderTrace.Enabled && RenderTrace.MetadataClear())
{
RenderTrace.Write(
$"{(registered ? "Tracked" : "Deferred")} a metadata clear: shader=0x{hash:X16} address=0x{address:X16} size=0x{clear.Size:X16} value=0x{clear.PackedClear:X8}");
$"{(registered ? "Tracked" : filledOnHost ? "Filled on the host" : "Deferred")} a metadata clear: shader=0x{hash:X16} address=0x{address:X16} size=0x{clear.Size:X16} value=0x{clear.PackedClear:X8}");
}
return registered;
return registered || filledOnHost;
}
if (RenderTrace.Enabled && RenderTrace.ImageClear())

View File

@ -0,0 +1,115 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using SharpEmu.Libs.Gpu.Buffers;
using SharpEmu.Libs.Gpu.GpuCommands.Registers;
using SharpEmu.Libs.Gpu.Images;
namespace SharpEmu.Libs.Gpu.Rendering;
// What a draw's program resolution reads besides the stage and context registers: the export
// mapping of every bound color slot, whether the pixel stage runs and whether a depth target is
// bound. All three follow from the register banks alone.
public struct GraphicsProgramInputs : IEquatable<GraphicsProgramInputs>
{
[System.Runtime.CompilerServices.InlineArray(RenderingState.ColorAttachmentCapacity)]
public struct ExportMappings
{
private ColorComponentMap _element0;
}
public ExportMappings Mapping;
public bool PixelActive;
public bool DepthBound;
public static GraphicsProgramInputs Create()
{
var inputs = new GraphicsProgramInputs();
((Span<ColorComponentMap>)inputs.Mapping).Fill(ColorComponentMap.Identity);
return inputs;
}
public readonly bool Equals(GraphicsProgramInputs other) =>
PixelActive == other.PixelActive && DepthBound == other.DepthBound &&
((ReadOnlySpan<ColorComponentMap>)Mapping).SequenceEqual(other.Mapping);
public override readonly bool Equals(object? obj) => obj is GraphicsProgramInputs other && Equals(other);
public override readonly int GetHashCode()
{
var hash = new HashCode();
foreach (var map in (ReadOnlySpan<ColorComponentMap>)Mapping)
{
hash.Add(map.Packed);
}
hash.Add(PixelActive);
hash.Add(DepthBound);
return hash.ToHashCode();
}
}
// The host side of resolving programs on a thread other than the render thread. The resolving
// thread records what it assumed of the host's GPU-ownership state; the render thread checks,
// when it takes the result, that the assumptions still hold.
public interface IConcurrentResolveHost
{
// On the resolving thread, around one resolution.
void BeginConcurrentResolve();
object EndConcurrentResolve();
// On the render thread.
bool IsStillCurrent(object assumptions);
}
// Program resolutions computed ahead of their draws. A draw takes one only when it was resolved
// from the same register banks with the same inputs; otherwise it resolves its programs itself.
public interface IProgramPrefetch
{
bool TryTakeGraphics(RegisterBanks banks, in GraphicsProgramInputs inputs, out GraphicsPrograms programs);
}
public sealed partial class RenderExecutor
{
public IProgramPrefetch? Prefetch { get; set; }
// The inputs ResolveShaderPrograms derives from the targets TryResolveDrawTargets binds, from the
// registers alone: the target requests and the pixel and depth activity, without the images.
public static GraphicsProgramInputs ProgramInputsOf(RegisterBanks banks, IImageFormatSupport formats, Func<string, Exception> fatal)
{
var inputs = GraphicsProgramInputs.Create();
var context = banks.Context;
Span<ColorComponentMap> mapping = inputs.Mapping;
for (var slot = 0u; slot < ContextRegisters.ColorTargetCount; slot++)
{
if (slot != 0 && (context.RenderTargetMaskForSlot(slot) == 0 || context.ColorTargets[slot].BaseAddress == 0))
{
continue;
}
if (ColorTargetResolver.Resolve(context, slot, DrawLayerOffset, ignoreTargetMask: false, out var resolvedSlot) is { } resolution)
{
mapping[(int)resolvedSlot] = resolution.ExportMapping;
}
}
inputs.DepthBound = DepthTargetResolver.Resolve(context, formats, fatal) is not null;
inputs.PixelActive = HasActivePixelShader(banks);
return inputs;
}
// The programs of a draw with these banks and inputs, as ResolveShaderPrograms resolves them.
public GraphicsPrograms ResolveGraphicsPrograms(RegisterBanks banks, in GraphicsProgramInputs inputs)
{
var context = banks.Context;
return _pipelines.GetGraphicsPrograms(
banks.Shader.Vertex,
banks.Shader.Pixel,
context.ShaderInterface,
context,
inputs.Mapping,
inputs.PixelActive,
inputs.DepthBound);
}
}

View File

@ -35,7 +35,8 @@ public sealed partial class RenderExecutor
throw _host.Fatal($"The vertex input has too many buffers: count={buffers.Length} max={VertexInputInfo.MaxBuffers}.");
}
Span<VertexBufferRange> ranges = stackalloc VertexBufferRange[VertexInputInfo.MaxBuffers];
// Sized to this draw's buffers: a MaxBuffers span is over a kilobyte, zeroed on every draw.
Span<VertexBufferRange> ranges = stackalloc VertexBufferRange[buffers.Length];
var rangeCount = 0;
foreach (ref readonly var vertex in buffers.AsSpan())
{
@ -54,7 +55,7 @@ public sealed partial class RenderExecutor
}
ranges[..rangeCount].Sort(static (left, right) => left.BaseAddress.CompareTo(right.BaseAddress));
Span<VertexBufferRange> merged = stackalloc VertexBufferRange[VertexInputInfo.MaxBuffers];
Span<VertexBufferRange> merged = stackalloc VertexBufferRange[buffers.Length];
var mergedCount = 0;
for (var i = 0; i < rangeCount; i++)
{

View File

@ -667,29 +667,22 @@ public sealed partial class RenderExecutor
private void ResolveShaderPrograms(RegisterBanks banks, ref DrawState state)
{
using var profileScope = RenderPhaseProfile.MeasureDetail(RenderPhaseProfile.Phase.DrawProgramResolution);
var context = banks.Context;
Span<ColorComponentMapArray> mappingStorage = stackalloc ColorComponentMapArray[1];
Span<ColorComponentMap> targetExportMapping = mappingStorage[0];
targetExportMapping.Fill(ColorComponentMap.Identity);
var inputs = GraphicsProgramInputs.Create();
Span<ColorComponentMap> targetExportMapping = inputs.Mapping;
foreach (ref readonly var color in BoundColors(ref state))
{
targetExportMapping[(int)color.Slot] = color.Resolution.ExportMapping;
}
state.Programs = _pipelines.GetGraphicsPrograms(
banks.Shader.Vertex,
banks.Shader.Pixel,
context.ShaderInterface,
context,
targetExportMapping,
state.PixelActive,
state.Depth.HasTarget);
}
inputs.PixelActive = state.PixelActive;
inputs.DepthBound = state.Depth.HasTarget;
if (Prefetch is { } prefetch && prefetch.TryTakeGraphics(banks, in inputs, out var prefetched))
{
state.Programs = prefetched;
return;
}
[System.Runtime.CompilerServices.InlineArray(RenderingState.ColorAttachmentCapacity)]
private struct ColorComponentMapArray
{
private ColorComponentMap _element0;
state.Programs = ResolveGraphicsPrograms(banks, in inputs);
}
private static void TraceDrawState(ulong submitId, RegisterBanks banks, in DrawCall draw, in DrawState state)

View File

@ -19,7 +19,29 @@ public readonly record struct RenderingAttachment(
bool HasDepth,
bool DepthClear,
bool HasStencil,
bool StencilClear);
bool StencilClear)
{
// Compared by handle value: the generated comparison goes through the object comparer of ImageView
// and boxes both views, which a draw pays for every attachment it re-checks.
public bool Equals(RenderingAttachment other) =>
View.Handle == other.View.Handle &&
Layout == other.Layout &&
Format == other.Format &&
ClearWord0 == other.ClearWord0 &&
ClearWord1 == other.ClearWord1 &&
ClearWord2 == other.ClearWord2 &&
ClearWord3 == other.ClearWord3 &&
IsClear == other.IsClear &&
HasDepth == other.HasDepth &&
DepthClear == other.DepthClear &&
HasStencil == other.HasStencil &&
StencilClear == other.StencilClear;
public override int GetHashCode() => HashCode.Combine(
View.Handle,
HashCode.Combine(Layout, Format, ClearWord0, ClearWord1, ClearWord2, ClearWord3),
HashCode.Combine(IsClear, HasDepth, DepthClear, HasStencil, StencilClear));
}
[InlineArray(RenderingState.ColorAttachmentCapacity)]
public struct RenderingColorAttachments

View File

@ -0,0 +1,16 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
namespace SharpEmu.Libs.Gpu.Scheduling;
// Counts the stores of GPU data into guest memory: buffer and image downloads, and buffer transfers
// the caches complete there. Work done ahead of the render thread from guest memory is still
// current while the count has not moved.
internal static class GpuWritebackEpoch
{
private static long _value;
public static long Value => Volatile.Read(ref _value);
public static void Advance() => Interlocked.Increment(ref _value);
}

View File

@ -88,9 +88,21 @@ public sealed class SubmissionScheduler : IGpuTickScheduler, IDisposable
internal bool PriorityWorkerAlive => _priorityThread.IsAlive;
// Set on a thread that does work ahead of the render thread: there a fatal condition only
// abandons that work, which the render thread then does itself and fails on if it is real.
[ThreadStatic]
internal static bool ThrowOnFatal;
public sealed class AbandonedWorkException(string message) : Exception(message);
// Report the fatal error, then return an exception to stop the caller.
internal static Exception Fatal(string message)
{
if (ThrowOnFatal)
{
throw new AbandonedWorkException(message);
}
Console.Error.WriteLine($"[GPU][FATAL] {message}");
OnFatal(message);
return new InvalidOperationException(message);

View File

@ -0,0 +1,16 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
namespace SharpEmu.Libs.Gpu.Vulkan;
// Advanced by every graphics pipeline bind or dynamic state change outside the guest draw path:
// such a pipeline may fix the state the guest pipelines leave dynamic, so the dynamic state a
// guest draw last set no longer holds in the command buffer.
internal static class GraphicsDynamicStateEpoch
{
private static long _value;
public static long Value => Volatile.Read(ref _value);
public static void Advance() => Interlocked.Increment(ref _value);
}

View File

@ -130,6 +130,37 @@ internal static unsafe partial class VulkanVideoPresenter
// A draw-local copy used when the guest samples a depth aspect while the
// same image is attached for writes. It retires with the submission.
public bool IsFeedbackSnapshot;
// Taken from the presenter's pool; goes back to it when its submission retires.
public bool Pooled;
public bool InPool;
// Back to the state of a new object, so a reused one never carries a previous binding.
public void Reset()
{
Address = 0;
ImageIdentifier = default;
Request = default;
CachedImage = null;
MipLevel = 0;
Image = default;
View = default;
Layout = ImageLayout.ShaderReadOnlyOptimal;
Sampler = default;
SamplerState = default;
IsStorage = false;
IsHostMovie = false;
HostMoviePlane = -1;
HostMovieFrameSerial = 0;
NeedsUpload = false;
StagingBuffer = default;
StagingMemory = default;
Width = 0;
Height = 0;
RowLength = 0;
DestinationSelect = 0;
MipViews = [];
IsFeedbackSnapshot = false;
}
}
private sealed partial class Presenter

View File

@ -176,9 +176,6 @@ internal static unsafe partial class VulkanVideoPresenter
private EndOfPipe _endOfPipe = null!;
private AgcExports.CommandStreamTranslation _translation = null!;
private ICpuMemory _guestMemory = null!;
private long _lastCommandStreamProgressTicks;
private static readonly long _blockedRetryTicks =
CommandStreamQueue.AllBlockedRetryMilliseconds * System.Diagnostics.Stopwatch.Frequency / 1000L;
public CommandStreamQueue CommandStream => _commandStream;
@ -188,7 +185,6 @@ internal static unsafe partial class VulkanVideoPresenter
_guestMemory = guest;
_endOfPipe = new EndOfPipe(_endOfPipeEvents, this);
_translation = AgcExports.CreateCommandStreamTranslation(guest, this);
_lastCommandStreamProgressTicks = System.Diagnostics.Stopwatch.GetTimestamp();
}
public void EnqueueCommandStream(uint queue, ulong address, uint dwordCount, ulong submissionId, object? geometrySnapshots)
@ -204,25 +200,42 @@ internal static unsafe partial class VulkanVideoPresenter
}
}
// Runs slices until the budget ends or nothing is runnable; blocked heads retry every 100 ms.
// The command stream thread interprets the submissions; this thread runs what it hands over
// until the budget ends or nothing is queued. Null until the first render tick.
private CommandThread? _commandThread;
private Action? _betweenCommandItems;
private bool OnCommandThread => _commandThread is { IsProducerThread: true };
// Items run in batches; completions are collected between them so blocked waits retry.
private const int CommandItemBatch = 256;
private void RunCommandStreamSlices(long renderWorkDeadline)
{
var slices = 0;
while (slices < _maxGuestWorkPerRender)
if (_commandThread is null)
{
_commandThread = new CommandThread(_commandStream, _guestMemory, RunDeferredWork, _translation.Prefetch);
_betweenCommandItems = RunRelayBetweenCommandItems;
_translation.Sink = _commandThread;
_commandThread.Start();
}
var thread = _commandThread;
for (;;)
{
using (RenderPhaseProfile.Measure(RenderPhaseProfile.Phase.Collect))
{
CollectCompletedGuestSubmissions(waitForOldest: false);
}
SliceResult result;
int ran;
using (RenderPhaseProfile.Measure(RenderPhaseProfile.Phase.CommandStream))
{
var aliasAccess = _guestBacking?.TryEnterBackingAliasAccess() == true;
_backingAliasAccess = aliasAccess;
try
{
result = _commandStream.ProcessOne();
ran = thread.RunBatch(renderWorkDeadline, CommandItemBatch, _betweenCommandItems!);
}
finally
{
@ -234,64 +247,162 @@ internal static unsafe partial class VulkanVideoPresenter
}
}
switch (result)
{
case SliceResult.NoWork:
return;
case SliceResult.AllBlocked:
RetryBlockedCommandStreamIfDue();
return;
case SliceResult.BlockedWithoutProgress:
if (!_commandStream.HasUnblockedPending)
{
RetryBlockedCommandStreamIfDue();
return;
}
break;
default:
_lastCommandStreamProgressTicks = System.Diagnostics.Stopwatch.GetTimestamp();
break;
}
slices++;
if (System.Diagnostics.Stopwatch.GetTimestamp() >= renderWorkDeadline)
if (ran == 0 || System.Diagnostics.Stopwatch.GetTimestamp() >= renderWorkDeadline)
{
return;
}
}
}
private void RetryBlockedCommandStreamIfDue()
private void RunDeferredWork(AgcExports.DeferredWork work)
{
var now = System.Diagnostics.Stopwatch.GetTimestamp();
if (now - _lastCommandStreamProgressTicks < _blockedRetryTicks)
using var translationScope = RenderPhaseProfile.MeasureDetail(RenderPhaseProfile.Phase.CommandDrawTranslation);
var started = System.Diagnostics.Stopwatch.GetTimestamp();
try
{
return;
_translation.RunDeferred(work);
}
finally
{
Interlocked.Add(ref _perfDrawTicks, System.Diagnostics.Stopwatch.GetTimestamp() - started);
}
_commandStream.RetryBlocked();
_lastCommandStreamProgressTicks = now;
}
// Milliseconds until the blocked heads are due for a retry; null when none is blocked.
private int? BlockedRetryWaitMilliseconds()
// Commands other threads post to the GPU worker run between items, with the resolution
// worker held: they change the caches it reads.
private void RunRelayBetweenCommandItems()
{
if (!_vulkanReady || !_commandStream.HasPending || _commandStream.HasUnblockedPending)
if (_relay.HasPendingCommands)
{
return null;
RunRelayCommandsWithPrefetchHeld();
}
var remaining = _blockedRetryTicks - (System.Diagnostics.Stopwatch.GetTimestamp() - _lastCommandStreamProgressTicks);
return remaining <= 0 ? 0 : (int)Math.Max(1, remaining * 1000 / System.Diagnostics.Stopwatch.Frequency);
}
ICpuMemory ICommandStreamHost.Memory => _guestMemory;
private void RunRelayCommandsWithPrefetchHeld()
{
var prefetch = _commandThread is not null ? _translation.Prefetch : null;
prefetch?.Pause();
try
{
using var relayScope = RenderPhaseProfile.MeasureDetail(RenderPhaseProfile.Phase.QueueRelay);
_relay.RunPendingCommands();
}
finally
{
prefetch?.Resume();
}
}
// The interpreter's own stores (labels, atomics, semaphores) follow every deferred draw.
private DrainingWriteMemory? _commandMemory;
ICpuMemory ICommandStreamHost.Memory => _commandMemory ??= new DrainingWriteMemory(_guestMemory, DrainDraws, this);
// On the command stream thread a store is queued behind the work before it and reads see
// the queued stores; elsewhere the deferred draws run first.
private sealed class DrainingWriteMemory(ICpuMemory inner, Action drain, Presenter presenter) : ICpuMemory, ICpuMemoryWrapper
{
public ICpuMemory Inner => inner;
public bool TryRead(ulong virtualAddress, Span<byte> destination) =>
presenter.OnCommandThread
? presenter._commandThread!.ReadOverlay(virtualAddress, destination)
: inner.TryRead(virtualAddress, destination);
public bool TryWrite(ulong virtualAddress, ReadOnlySpan<byte> source)
{
if (presenter.OnCommandThread)
{
presenter._commandThread!.PostWrite(virtualAddress, source);
return true;
}
drain();
return inner.TryWrite(virtualAddress, source);
}
public bool TryCompare(ulong virtualAddress, ReadOnlySpan<byte> expected, out bool equal)
{
if (presenter.OnCommandThread)
{
var bytes = expected.ToArray();
var compared = false;
var result = presenter._commandThread!.Run(() => TryCompare(virtualAddress, bytes, out compared));
equal = compared;
return result;
}
drain();
return inner.TryCompare(virtualAddress, expected, out equal);
}
public bool TryCopy(ulong destinationAddress, ulong sourceAddress, ulong length)
{
if (presenter.OnCommandThread)
{
return presenter._commandThread!.Run(() => TryCopy(destinationAddress, sourceAddress, length));
}
drain();
return inner.TryCopy(destinationAddress, sourceAddress, length);
}
public bool TryScanCString(ulong address, byte needle, bool findLast, ulong maxLength, out ulong match) =>
inner.TryScanCString(address, needle, findLast, maxLength, out match);
public bool CanRead(ulong address, ulong size) => inner.CanRead(address, size);
public string DescribeReadRange(ulong address, ulong size) => inner.DescribeReadRange(address, size);
}
// A read the render thread runs after everything queued before it, for the command thread.
private bool ReadOnRenderThread(ulong address, Span<byte> destination, bool operand)
{
var buffer = new byte[destination.Length];
var read = _commandThread!.Run(() => operand ? TryReadGuestOperand(address, buffer) : TryReadGuest(address, buffer));
buffer.CopyTo(destination);
return read;
}
// Command memory and polled labels: GPU-written pages go through the render thread, the
// rest is read here with the stream's queued stores applied.
private bool ReadOnCommandThread(ulong address, Span<byte> destination)
{
_commandThread!.WaitForPendingRange(address, (ulong)destination.Length);
return _bufferCache.MayHaveGpuDirtyPages(address, (ulong)destination.Length)
? ReadOnRenderThread(address, destination, operand: false)
: _commandThread!.ReadOverlay(address, destination);
}
public bool TryReadGuestWaitOperand(ulong address, Span<byte> destination) =>
OnCommandThread ? ReadOnCommandThread(address, destination) : TryReadGuestOperand(address, destination);
public bool TryReadGuestOperand(ulong address, Span<byte> destination)
{
if (OnCommandThread)
{
return ReadOnRenderThread(address, destination, operand: true);
}
DrainDraws();
return TryReadGuest(address, destination);
}
public bool TryReadGuest(ulong address, Span<byte> destination)
{
if (OnCommandThread)
{
return ReadOnCommandThread(address, destination);
}
using (RenderPhaseProfile.MeasureDetail(RenderPhaseProfile.Phase.CommandMemorySync))
{
// GPU-written command memory is downloaded first: deferred draws are recorded before it.
if (_translation is { HasPendingDraws: true } && _bufferCache.MayHaveGpuDirtyPages(address, (ulong)destination.Length))
{
DrainDraws();
}
if (!_bufferCache.TrySynchronizeCpuRead(address, (ulong)destination.Length,
SharpEmu.HLE.GuestMemory.GuestMemoryProfile.ReadbackSource.CommandMemoryRead))
{
@ -305,27 +416,66 @@ internal static unsafe partial class VulkanVideoPresenter
public void RunPendingCommands()
{
// With the stream on its own thread the render thread runs them between its items.
if (OnCommandThread)
{
return;
}
if (_relay.HasPendingCommands)
{
DrainDraws();
}
using var relayScope = RenderPhaseProfile.MeasureDetail(RenderPhaseProfile.Phase.QueueRelay);
_relay.RunPendingCommands();
}
public void BeginSubmission(int queueId, ulong submissionId, object? geometrySnapshots)
{
if (OnCommandThread)
{
// The translation state belongs to this thread; the queue context to the render thread.
_translation.BeginSubmission(queueId, submissionId, geometrySnapshots, _commandStream.GetInterpreter(queueId));
_commandThread!.Post(() => BeginSubmissionContext(queueId, submissionId));
return;
}
DrainDraws();
BeginSubmissionContext(queueId, submissionId);
_translation.BeginSubmission(queueId, submissionId, geometrySnapshots, _commandStream.GetInterpreter(queueId));
}
private void BeginSubmissionContext(int queueId, ulong submissionId)
{
using var contextScope = RenderPhaseProfile.MeasureDetail(RenderPhaseProfile.Phase.QueueContext);
_activeGuestQueue = new VulkanGuestQueueIdentity(_commandQueueNames[queueId], submissionId);
BindSubmissionContext(_activeGuestQueue);
_ = CurrentRecordingBuffer();
_translation.BeginSubmission(queueId, submissionId, geometrySnapshots, _commandStream.GetInterpreter(queueId));
}
public void Flush()
{
if (OnCommandThread)
{
_commandThread!.Post(Flush);
return;
}
DrainDraws();
using var flushScope = RenderPhaseProfile.MeasureDetail(RenderPhaseProfile.Phase.Flush);
FlushBatchedGuestCommands();
}
public void FlushAndWait()
{
if (OnCommandThread)
{
_commandThread!.Run(FlushAndWait);
return;
}
DrainDraws();
using var waitScope = RenderPhaseProfile.MeasureDetail(RenderPhaseProfile.Phase.CommandGpuWait);
_ = CurrentRecordingBuffer();
_scheduler.FlushAndWait();
@ -334,16 +484,40 @@ internal static unsafe partial class VulkanVideoPresenter
public void SynchronizeGpu()
{
if (OnCommandThread)
{
_commandThread!.Run(SynchronizeGpu);
return;
}
DrainDraws();
using var waitScope = RenderPhaseProfile.MeasureDetail(RenderPhaseProfile.Phase.CommandGpuWait);
_ = CurrentRecordingBuffer();
_scheduler.Finish();
CollectCompletedGuestSubmissions(waitForOldest: false);
}
public void RunGarbageCollector() => RunGuestCacheCollection(endsFrame: false);
public void RunGarbageCollector()
{
if (OnCommandThread)
{
_commandThread!.Post(RunGarbageCollector);
return;
}
DrainDraws();
RunGuestCacheCollection(endsFrame: false);
}
public void EmitGlobalBarrier()
{
if (OnCommandThread)
{
_commandThread!.Post(EmitGlobalBarrier);
return;
}
DrainDraws();
// Inside a rendering scope whose draws only wrote attachments, the barrier matters only
// to what runs after the scope: everything before the scope is ordered by the barrier
// BeginRendering records, and a draw that stores to memory ends a deferred scope first.
@ -380,6 +554,13 @@ internal static unsafe partial class VulkanVideoPresenter
public void FillBuffer(ulong address, ulong size, uint value, bool isGds)
{
if (OnCommandThread)
{
_commandThread!.PostRange(() => FillBuffer(address, size, value, isGds), address, isGds ? 0 : size);
return;
}
DrainDraws();
using var transferScope = RenderPhaseProfile.MeasureDetail(RenderPhaseProfile.Phase.CommandMemoryTransfer);
// Transfers the buffer cache completes in guest memory leave the rendering scope open;
// its GPU paths end the scope before they record.
@ -389,6 +570,13 @@ internal static unsafe partial class VulkanVideoPresenter
public void CopyBuffer(ulong destination, ulong source, ulong size, bool destinationIsGds, bool sourceIsGds)
{
if (OnCommandThread)
{
_commandThread!.PostRange(() => CopyBuffer(destination, source, size, destinationIsGds, sourceIsGds), destination, destinationIsGds ? 0 : size);
return;
}
DrainDraws();
using var transferScope = RenderPhaseProfile.MeasureDetail(RenderPhaseProfile.Phase.CommandMemoryTransfer);
// Transfers the buffer cache completes in guest memory leave the rendering scope open;
// its GPU paths end the scope before they record.
@ -396,11 +584,30 @@ internal static unsafe partial class VulkanVideoPresenter
_bufferCache.CopyBuffer(destination, source, size, destinationIsGds, sourceIsGds);
}
public void ReadGds(Span<uint> destination, uint wordOffset, uint wordCount) =>
public void ReadGds(Span<uint> destination, uint wordOffset, uint wordCount)
{
if (OnCommandThread)
{
var words = new uint[destination.Length];
_commandThread!.Run(() => ReadGds(words, wordOffset, wordCount));
words.CopyTo(destination);
return;
}
DrainDraws();
EndOfPipe.ReadGdsWords(_bufferCache.GdsBuffer.Mapped, destination, wordOffset, wordCount);
}
public void RecordEndOfPipe(in EndOfPipeWrite write)
{
if (OnCommandThread)
{
var queued = write;
_commandThread!.Post(() => RecordEndOfPipe(in queued));
return;
}
DrainDraws();
using var completionScope = RenderPhaseProfile.MeasureDetail(RenderPhaseProfile.Phase.CommandEndOfPipe);
_ = BeginBatchedGuestCommands();
var buffer = _scheduler.Current;
@ -456,10 +663,26 @@ internal static unsafe partial class VulkanVideoPresenter
}
}
public void TriggerInterrupt(int eventId, uint contextId) => _endOfPipeEvents.TriggerInterrupt(eventId, contextId);
public void TriggerInterrupt(int eventId, uint contextId)
{
if (OnCommandThread)
{
_commandThread!.Post(() => TriggerInterrupt(eventId, contextId));
return;
}
DrainDraws();
_endOfPipeEvents.TriggerInterrupt(eventId, contextId);
}
public bool HasFlipSlot()
{
if (OnCommandThread)
{
return _commandThread!.Run(HasFlipSlot);
}
DrainDraws();
lock (_gate)
{
return _pendingGuestImagePresentations.Count < MaxPendingGuestFlipVersions;
@ -468,17 +691,47 @@ internal static unsafe partial class VulkanVideoPresenter
public ulong PrepareFlip(int handle, int index, int flipMode, long flipArgument)
{
if (OnCommandThread)
{
return _commandThread!.Run(() => PrepareFlip(handle, index, flipMode, flipArgument));
}
DrainDraws();
_ = BeginBatchedGuestCommands();
return _endOfPipe.PrepareVideoOutFlip(_scheduler.Current, handle, index, flipMode, flipArgument);
}
public bool IsFlipDone(int handle, int index) => VideoOutExports.IsFlipDone(handle, index);
public bool IsFlipDone(int handle, int index)
{
if (OnCommandThread)
{
return _commandThread!.Run(() => IsFlipDone(handle, index));
}
public void PrepareCpuFlip(int handle, int index, ulong requestId) =>
DrainDraws();
return VideoOutExports.IsFlipDone(handle, index);
}
public void PrepareCpuFlip(int handle, int index, ulong requestId)
{
if (OnCommandThread)
{
_commandThread!.Run(() => PrepareCpuFlip(handle, index, requestId));
return;
}
DrainDraws();
CaptureFlip(handle, index, requestId, flipMode: 0, flipArg: 0);
}
public void DrawIndexed(ulong submitId, in DrawIndexedArguments arguments)
{
if (OnCommandThread)
{
_translation.DrawIndexed(submitId, in arguments);
return;
}
using var translationScope = RenderPhaseProfile.MeasureDetail(RenderPhaseProfile.Phase.CommandDrawTranslation);
var started = System.Diagnostics.Stopwatch.GetTimestamp();
try
@ -493,6 +746,12 @@ internal static unsafe partial class VulkanVideoPresenter
public void DrawAuto(ulong submitId, in DrawAutoArguments arguments)
{
if (OnCommandThread)
{
_translation.DrawAuto(submitId, in arguments);
return;
}
using var translationScope = RenderPhaseProfile.MeasureDetail(RenderPhaseProfile.Phase.CommandDrawTranslation);
var started = System.Diagnostics.Stopwatch.GetTimestamp();
try
@ -521,6 +780,13 @@ internal static unsafe partial class VulkanVideoPresenter
public void DispatchDirect(ulong submitId, uint groupsX, uint groupsY, uint groupsZ, uint dispatchInitiator, ulong indirectArgumentsAddress = 0)
{
if (OnCommandThread)
{
_translation.Dispatch(submitId, groupsX, groupsY, groupsZ, dispatchInitiator, indirectArgumentsAddress);
return;
}
DrainDraws();
using var translationScope = RenderPhaseProfile.MeasureDetail(RenderPhaseProfile.Phase.CommandDispatchTranslation);
var started = System.Diagnostics.Stopwatch.GetTimestamp();
try
@ -533,13 +799,48 @@ internal static unsafe partial class VulkanVideoPresenter
}
}
public void OnQueueReset(int queueId) => _translation.QueueReset(queueId);
public void OnQueueReset(int queueId)
{
if (OnCommandThread)
{
_translation.QueueReset(queueId);
return;
}
DrainDraws();
_translation.QueueReset(queueId);
}
// Deferred draws run before anything else the stream asks of the host: everything but a
// draw may record, read or write what they use. Their time counts as draw time. Completion
// callbacks reach some of the same entry points from the GPU priority worker; they are not
// ordered with the stream and leave the draws to the render thread.
private void DrainDraws()
{
// Hosts built without a command stream have no translation.
if (_translation is not { HasPendingDraws: true } || !_relay.IsGpuQueueThread)
{
return;
}
using var translationScope = RenderPhaseProfile.MeasureDetail(RenderPhaseProfile.Phase.CommandDrawTranslation);
var started = System.Diagnostics.Stopwatch.GetTimestamp();
try
{
_translation.DrainPendingDraws();
}
finally
{
Interlocked.Add(ref _perfDrawTicks, System.Diagnostics.Stopwatch.GetTimestamp() - started);
}
}
public Exception Fatal(string message) => SubmissionScheduler.Fatal(message);
// The flip packet: translation first, then the request, then the capture in stream order.
public int SubmitFlipFromGpu(RecordingBuffer buffer, int handle, int index, int flipMode, long flipArg, out ulong requestId)
{
DrainDraws();
_ = buffer;
_translation.PrepareFlip(handle, index);
var result = VideoOutExports.TryReserveFlipRequest(handle, index, flipMode, flipArg, gpuQueued: true, out requestId);
@ -563,11 +864,16 @@ internal static unsafe partial class VulkanVideoPresenter
// The interpreter must suspend before capture so this worker can present queued frames.
public void WaitForSubmitSlot() => throw SubmissionScheduler.Fatal("The GPU worker cannot wait for a flip slot. Suspend the command stream before capture.");
public void CompleteFlip(ulong requestId) => VideoOutExports.CompleteFlip(requestId);
public void CompleteFlip(ulong requestId)
{
DrainDraws();
VideoOutExports.CompleteFlip(requestId);
}
// Copies the display surface through the store; presentation shows the copy once its tick retires.
internal void CaptureFlip(int handle, int index, ulong requestId, int flipMode, long flipArg)
{
DrainDraws();
// A no-buffer flip has no image to capture; its completion still retires in order.
if (VideoOutExports.ReleaseNoBufferFlip(index, requestId))
{
@ -586,7 +892,7 @@ internal static unsafe partial class VulkanVideoPresenter
FlushBatchedGuestCommands();
RunGuestCacheCollection(endsFrame: true);
EnsureGuestSubmissionCapacity();
EnsureGuestSubmissionCapacity(collectNow: true);
long version;
lock (_gate)
{

View File

@ -0,0 +1,511 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
namespace SharpEmu.Libs.VideoOut;
using System.Diagnostics;
using SharpEmu.HLE;
using SharpEmu.Libs.Agc;
using SharpEmu.Libs.Gpu.GpuCommands;
using SharpEmu.Libs.Gpu.Rendering;
using SharpEmu.Libs.Gpu.Scheduling;
internal static partial class VulkanVideoPresenter
{
// The command stream is interpreted on its own thread, which hands its work to the render thread
// in stream order. Draws, dispatches, barriers, completions and the interpreter's own stores are
// queued; a callback that returns a result waits for the render thread to run everything before it.
private sealed class CommandThread : AgcExports.IDeferredWorkSink
{
// The thread runs this far ahead of the render thread at most: enough to keep it busy, and
// no more, since a GPU round trip (a readback, a label wait) waits for everything queued.
// Measured in Astro Bot: 4096 items halved the frame rate of a crowd scene, 16 starved the
// render thread, 32 to 64 kept it busy.
private const int MaxOutstanding = 64;
private enum Kind : byte
{
Deferred,
Call,
Write,
}
private sealed class Item
{
public Kind Kind;
public bool IsDraw;
public AgcExports.DeferredWork? Work;
public Action? Action;
public ulong Address;
public byte[]? Data;
// The guest range a queued fill or copy writes; zero for other items.
public ulong RangeSize;
// Resolution jobs submitted before this non-draw item; they finish before it runs.
public long JobsBefore;
public bool Sync;
public volatile bool Done;
public Exception? Failure;
}
private readonly CommandStreamQueue _queue;
private readonly ICpuMemory _memory;
private readonly Action<AgcExports.DeferredWork> _runDeferred;
private readonly ParallelProgramPrefetch? _prefetch;
private readonly Thread _thread;
private readonly object _lock = new();
private List<Item> _incoming = new();
private List<Item> _draining = new();
private int _drainIndex;
private int _outstanding;
// What the producer sleeps on: room in the queue, or its synchronous item.
private volatile bool _waitingForSpace;
private volatile bool _waitingForSync;
private volatile bool _consumerIdle;
private volatile bool _stopping;
private volatile Exception? _failure;
private long _enqueuedNonDraw;
private long _executedNonDraw;
// The interpreter's stores the render thread has not applied yet, oldest first; reads on
// this thread see them over guest memory.
private readonly object _writeLock = new();
private readonly Queue<Item> _pendingWrites = new();
private volatile int _pendingWriteCount;
// The destinations of queued fills and copies; a read on this thread that overlaps one waits
// for the render thread to run it.
private readonly List<Item> _pendingRanges = new();
private volatile int _pendingRangeCount;
private static readonly long RetryTicks = CommandStreamQueue.AllBlockedRetryMilliseconds * Stopwatch.Frequency / 1000L;
public CommandThread(CommandStreamQueue queue, ICpuMemory memory, Action<AgcExports.DeferredWork> runDeferred, ParallelProgramPrefetch? prefetch)
{
_queue = queue;
_memory = memory;
_runDeferred = runDeferred;
_prefetch = prefetch;
_thread = new Thread(Run) { IsBackground = true, Name = "SharpEmu command stream" };
}
public void Start() => _thread.Start();
public bool IsProducerThread => Environment.CurrentManagedThreadId == _thread.ManagedThreadId;
public long Gate => _enqueuedNonDraw;
public bool HasWork => Volatile.Read(ref _outstanding) != 0 || _failure is not null;
// Read by the render loop before it waits, so a queued item wakes it.
public bool ConsumerIdle
{
set => _consumerIdle = value;
}
public bool IsAlive => _thread.IsAlive;
// The producer side, on the command stream thread.
public void EnqueueDeferred(AgcExports.DeferredWork work, bool isDraw) =>
Enqueue(new Item { Kind = Kind.Deferred, Work = work, IsDraw = isDraw });
public void Post(Action action) => Enqueue(new Item { Kind = Kind.Call, Action = action });
public void Run(Action action)
{
var item = new Item { Kind = Kind.Call, Action = action, Sync = true };
Enqueue(item);
WaitFor(item);
}
public T Run<T>(Func<T> function)
{
T result = default!;
Run(() => { result = function(); });
return result;
}
public void PostWrite(ulong address, ReadOnlySpan<byte> data)
{
var item = new Item { Kind = Kind.Write, Address = address, Data = data.ToArray() };
lock (_writeLock)
{
_pendingWrites.Enqueue(item);
_pendingWriteCount = _pendingWrites.Count;
}
Enqueue(item);
}
// A fill or copy the render thread runs in order; reads of its destination wait for it.
public void PostRange(Action action, ulong address, ulong size)
{
var item = new Item { Kind = Kind.Call, Action = action, Address = address, RangeSize = size };
if (size != 0)
{
lock (_writeLock)
{
_pendingRanges.Add(item);
_pendingRangeCount = _pendingRanges.Count;
}
}
Enqueue(item);
}
// Runs every queued item when one of them is a fill or copy into this range.
public void WaitForPendingRange(ulong address, ulong size)
{
if (_pendingRangeCount != 0 && OverlapsPendingRange(address, size))
{
Run(static () => { });
}
}
private bool OverlapsPendingRange(ulong address, ulong size)
{
lock (_writeLock)
{
foreach (var pending in _pendingRanges)
{
if (address < pending.Address + pending.RangeSize && pending.Address < address + size)
{
return true;
}
}
return false;
}
}
// Guest memory as the stream sees it: with its own stores applied, queued or not.
public bool ReadOverlay(ulong address, Span<byte> destination)
{
WaitForPendingRange(address, (ulong)destination.Length);
if (_pendingWriteCount == 0)
{
return _memory.TryRead(address, destination);
}
lock (_writeLock)
{
if (!_memory.TryRead(address, destination))
{
return false;
}
var end = address + (ulong)destination.Length;
foreach (var write in _pendingWrites)
{
var writeEnd = write.Address + (ulong)write.Data!.Length;
if (write.Address >= end || writeEnd <= address)
{
continue;
}
var start = Math.Max(address, write.Address);
var stop = Math.Min(end, writeEnd);
write.Data.AsSpan((int)(start - write.Address), (int)(stop - start))
.CopyTo(destination[(int)(start - address)..]);
}
return true;
}
}
private void Enqueue(Item item)
{
ThrowIfFailed();
if (!item.IsDraw)
{
item.JobsBefore = _prefetch?.Submitted ?? 0;
_enqueuedNonDraw++;
}
if (Volatile.Read(ref _outstanding) >= MaxOutstanding)
{
WaitForSpace();
}
lock (_lock)
{
_incoming.Add(item);
}
Interlocked.Increment(ref _outstanding);
if (_consumerIdle || item.Sync)
{
Presenter.WakeRenderThread();
}
}
private void WaitForSpace()
{
lock (_lock)
{
// The flag is set before the check: the render thread reads it after it changes what is checked.
for (;;)
{
_waitingForSpace = true;
Interlocked.MemoryBarrier();
if (Volatile.Read(ref _outstanding) < MaxOutstanding / 2 || _failure is not null)
{
break;
}
Monitor.Wait(_lock, 10);
}
_waitingForSpace = false;
}
ThrowIfFailed();
}
private void WaitFor(Item item)
{
var spinner = new SpinWait();
for (var spin = 0; spin < 100 && !item.Done; spin++)
{
spinner.SpinOnce(sleep1Threshold: -1);
}
if (!item.Done)
{
lock (_lock)
{
for (;;)
{
_waitingForSync = true;
Interlocked.MemoryBarrier();
if (item.Done || _failure is not null)
{
break;
}
Monitor.Wait(_lock, 10);
}
_waitingForSync = false;
}
}
if (item.Failure is { } failure)
{
throw new InvalidOperationException("A command stream callback failed on the render thread.", failure);
}
if (!item.Done)
{
ThrowIfFailed();
}
}
// The consumer side, on the render thread. Runs queued items until the deadline or until
// none is left; returns how many ran.
public int RunBatch(long deadline, int maxItems, Action betweenItems)
{
if (_drainIndex == _draining.Count)
{
_draining.Clear();
_drainIndex = 0;
lock (_lock)
{
(_incoming, _draining) = (_draining, _incoming);
}
if (_draining.Count == 0)
{
ThrowIfFailed();
return 0;
}
}
var ran = 0;
while (_drainIndex < _draining.Count)
{
var item = _draining[_drainIndex];
_draining[_drainIndex++] = null!;
Execute(item);
ran++;
var outstanding = Interlocked.Decrement(ref _outstanding);
// One pulse per wait: the producer sets its flag again if it keeps waiting.
var wakeForSync = item.Sync && _waitingForSync;
var wakeForSpace = _waitingForSpace && outstanding < MaxOutstanding / 2;
if (wakeForSync || wakeForSpace)
{
if (wakeForSync)
{
_waitingForSync = false;
}
if (wakeForSpace)
{
_waitingForSpace = false;
}
lock (_lock)
{
Monitor.PulseAll(_lock);
}
}
betweenItems();
if (ran >= maxItems || ((ran & 31) == 0 && Stopwatch.GetTimestamp() >= deadline))
{
break;
}
}
return ran;
}
private void Execute(Item item)
{
if (item.IsDraw)
{
_runDeferred(item.Work!);
return;
}
_prefetch?.WaitFinished(item.JobsBefore);
try
{
switch (item.Kind)
{
case Kind.Deferred:
_runDeferred(item.Work!);
break;
case Kind.Call:
item.Action!();
break;
case Kind.Write:
ApplyWrite(item);
break;
}
}
catch (Exception exception) when (item.Sync)
{
item.Failure = exception;
}
catch (Exception exception)
{
_failure ??= exception;
throw;
}
finally
{
_executedNonDraw++;
if (item.RangeSize != 0)
{
lock (_writeLock)
{
_pendingRanges.Remove(item);
_pendingRangeCount = _pendingRanges.Count;
}
}
_prefetch?.AdvanceGate(_executedNonDraw);
if (item.Sync)
{
item.Done = true;
}
}
}
private void ApplyWrite(Item item)
{
lock (_writeLock)
{
var written = _memory.TryWrite(item.Address, item.Data);
if (!ReferenceEquals(_pendingWrites.Peek(), item))
{
throw SubmissionScheduler.Fatal("The command stream stores ran out of order.");
}
_pendingWrites.Dequeue();
_pendingWriteCount = _pendingWrites.Count;
if (!written)
{
throw SubmissionScheduler.Fatal(
$"The command stream cannot write guest memory: address=0x{item.Address:X16} size={item.Data!.Length}.");
}
}
}
public void ThrowIfFailed()
{
if (_failure is { } failure)
{
throw new InvalidOperationException("The command stream thread failed.", failure);
}
}
// Ends the thread after its current slice; the render thread keeps running its items
// meanwhile, since the slice may wait for one.
public void Stop(Action runItems)
{
_stopping = true;
_queue.Wake();
var deadline = Stopwatch.GetTimestamp() + 5 * Stopwatch.Frequency;
while (_thread.IsAlive && Stopwatch.GetTimestamp() < deadline)
{
runItems();
_thread.Join(1);
}
runItems();
}
private void Run()
{
try
{
var lastProgress = Stopwatch.GetTimestamp();
while (!_stopping)
{
var result = _queue.ProcessOne();
switch (result)
{
case SliceResult.NoWork:
_ = _queue.WaitForWork(8);
break;
case SliceResult.AllBlocked:
WaitBlocked(ref lastProgress);
break;
case SliceResult.BlockedWithoutProgress:
if (!_queue.HasUnblockedPending)
{
WaitBlocked(ref lastProgress);
}
break;
default:
lastProgress = Stopwatch.GetTimestamp();
break;
}
}
}
catch (Exception exception)
{
_failure ??= exception;
Presenter.WakeRenderThread();
}
}
// Blocked heads retry when a GPU completion or a presentation unblocks them, or every
// retry interval without progress.
private void WaitBlocked(ref long lastProgress)
{
var remaining = RetryTicks - (Stopwatch.GetTimestamp() - lastProgress);
if (remaining <= 0)
{
_queue.RetryBlocked();
lastProgress = Stopwatch.GetTimestamp();
return;
}
_ = _queue.WaitForRetryInterval((int)Math.Max(1, remaining * 1000 / Stopwatch.Frequency));
}
}
}

View File

@ -0,0 +1,134 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
namespace SharpEmu.Libs.VideoOut;
using SharpEmu.Libs.Gpu.Rendering;
using SharpEmu.Libs.Gpu.Scheduling;
internal static unsafe partial class VulkanVideoPresenter
{
private sealed partial class Presenter : IConcurrentResolveHost
{
// This partial serves the guest readers of program resolution on a thread resolving draws
// ahead of the render thread. That thread never synchronizes GPU-owned memory: a word the
// GPU owns abandons the resolution, which the render thread then does itself. Every
// ownership answer it relied on is recorded and checked again when the result is taken.
private enum ConcurrentQuery : byte
{
DirtyBytes,
CleanWord,
Resident,
ResidentClean,
}
private sealed class ConcurrentAssumptions(long bufferVersion, long writebackEpoch)
{
public readonly long BufferVersion = bufferVersion;
public readonly long WritebackEpoch = writebackEpoch;
public readonly List<(ConcurrentQuery Query, ulong Address, ulong Size, bool Answer)> Answers = new();
public bool ReadsImageOwnership;
public void Record(ConcurrentQuery query, ulong address, ulong size, bool answer)
{
Answers.Add((query, address, size, answer));
ReadsImageOwnership |= query is ConcurrentQuery.CleanWord or ConcurrentQuery.ResidentClean;
}
}
// Set only on the resolving thread, around one resolution.
[ThreadStatic]
private static ConcurrentAssumptions? _concurrentAssumptions;
void IConcurrentResolveHost.BeginConcurrentResolve() =>
_concurrentAssumptions = new ConcurrentAssumptions(_bufferCache.GpuModifiedVersion, GpuWritebackEpoch.Value);
object IConcurrentResolveHost.EndConcurrentResolve()
{
var assumptions = _concurrentAssumptions ?? throw new InvalidOperationException("No concurrent resolution is open.");
_concurrentAssumptions = null;
return assumptions;
}
// GPU data stored into guest memory since the resolution may have changed what it read;
// otherwise the answers stand while the ownership they came from has not changed.
bool IConcurrentResolveHost.IsStillCurrent(object assumptionsObject)
{
var assumptions = (ConcurrentAssumptions)assumptionsObject;
if (assumptions.WritebackEpoch != GpuWritebackEpoch.Value)
{
return false;
}
if (assumptions.BufferVersion == _bufferCache.GpuModifiedVersion && !assumptions.ReadsImageOwnership)
{
return true;
}
foreach (var (query, address, size, answer) in assumptions.Answers)
{
var now = query switch
{
ConcurrentQuery.DirtyBytes => _bufferCache.HasGpuDirtyBytes(address, size),
ConcurrentQuery.CleanWord => IsGpuOwnedForCleanRead(address, size),
ConcurrentQuery.Resident => IsGpuOwnedForResidentRead(address, size, clean: false),
_ => IsGpuOwnedForResidentRead(address, size, clean: true),
};
if (now != answer)
{
return false;
}
}
return true;
}
private bool IsGpuOwnedForCleanRead(ulong address, ulong size) =>
(_bufferCache.MayHaveGpuDirtyPages(address, size) && _bufferCache.HasGpuDirtyPages(address, size)) ||
_bufferCache.HasGpuDirtyBytes(address, size) ||
_imageCache.HasGpuModifiedImageBytes(address, size);
private bool IsGpuOwnedForResidentRead(ulong address, ulong size, bool clean) =>
_bufferCache.HasGpuDirtyBytes(address, size) ||
(clean && (_bufferCache.HasGpuDirtyPages(address, size) || _imageCache.HasGpuModifiedImageBytes(address, size)));
private bool TryReadGuestWordConcurrent(ulong address, out uint word, ConcurrentAssumptions assumptions)
{
word = 0;
if (!_guestMemory.CanRead(address, sizeof(uint)))
{
return false;
}
var dirty = _bufferCache.HasGpuDirtyBytes(address, sizeof(uint));
assumptions.Record(ConcurrentQuery.DirtyBytes, address, sizeof(uint), dirty);
if (dirty)
{
throw new SubmissionScheduler.AbandonedWorkException($"The resolution reads GPU-owned memory: address=0x{address:X16}.");
}
Span<byte> bytes = stackalloc byte[sizeof(uint)];
if (!_guestMemory.TryRead(address, bytes))
{
return false;
}
word = System.Buffers.Binary.BinaryPrimitives.ReadUInt32LittleEndian(bytes);
return true;
}
private bool TryReadResidentGuestBytesConcurrent(ulong address, Span<byte> destination, bool clean, ConcurrentAssumptions assumptions)
{
var size = (ulong)destination.Length;
if (!_guestMemory.CanRead(address, size))
{
return false;
}
var owned = IsGpuOwnedForResidentRead(address, size, clean);
assumptions.Record(clean ? ConcurrentQuery.ResidentClean : ConcurrentQuery.Resident, address, size, owned);
return !owned && _guestMemory.TryRead(address, destination);
}
}
}

View File

@ -111,21 +111,81 @@ internal static unsafe partial class VulkanVideoPresenter
Atomic: image.Atomic);
private static readonly System.Collections.Concurrent.ConcurrentDictionary<string, byte> _tracedTextureBindings = new();
// The last lookup of one image of one program. A draw binds the same descriptor as the
// previous draw of its program most of the time; while no image the lookup could find has
// changed (the cache's lookup generation), the request and the image found are the same.
private struct ImageBindingMemo
{
public uint[]? Words;
public ulong Generation;
public TextureRequestResolution Resolution;
public ResourceSlotIdentifier Found;
public readonly bool Matches(uint[] words, ulong generation) =>
Words is not null && Generation == generation && words.AsSpan().SequenceEqual(Words);
}
private Dictionary<ShaderProgramInfo, ImageBindingMemo[]>? _imageBindingMemos;
private ShaderProgramInfo? _lastMemoProgram;
private ImageBindingMemo[] _lastMemos = [];
// A draw's images resolve one after another for one program, so the last program's memos
// are kept at hand.
private ref ImageBindingMemo ImageBindingMemoOf(ShaderProgramInfo program, int index)
{
if (!ReferenceEquals(program, _lastMemoProgram) || index >= _lastMemos.Length)
{
var all = _imageBindingMemos ??= new Dictionary<ShaderProgramInfo, ImageBindingMemo[]>(ReferenceEqualityComparer.Instance);
if (!all.TryGetValue(program, out var memos) || index >= memos.Length)
{
Array.Resize(ref memos, Math.Max(index + 1, program.Resources?.Info.Images.Count ?? 0));
all[program] = memos;
}
_lastMemoProgram = program;
_lastMemos = memos;
}
return ref _lastMemos[index];
}
// Render-state discovery for one shader image; the view is acquired later with the draw.
private TextureResource ResolveImageBinding(ImageResource image, uint[] words, ShaderProgramInfo program, int index)
{
ref var memo = ref ImageBindingMemoOf(program, index);
if (words.Length < 4)
{
throw SubmissionScheduler.Fatal($"An image descriptor is too short: image={index} words={words.Length} hash=0x{program.Hash:X16}.");
}
var storage = image.ResourceClass == ShaderCompiler.Resources.ImageResourceClass.Storage;
var resolution = ImageRequestBuilders.Texture(words, ShapeOf(image));
_ = BeginBatchedGuestCommands();
var request = resolution.Request;
var imageIdentifier = _imageCache.FindImage(ref request, resolution.ExactFormat);
resolution = resolution with { Request = request };
TextureRequestResolution resolution;
ResourceSlotIdentifier imageIdentifier;
if (memo.Matches(words, _imageCache.LookupGeneration) && _imageCache.TryTouchRemembered(memo.Found))
{
resolution = memo.Resolution;
imageIdentifier = memo.Found;
}
else
{
resolution = ImageRequestBuilders.Texture(words, ShapeOf(image));
var request = resolution.Request;
imageIdentifier = _imageCache.FindImage(ref request, resolution.ExactFormat);
resolution = resolution with { Request = request };
// An empty range or DCC metadata needs the full lookup on every draw.
memo = !ImageDescription.IsEmptyRange(request.Description.Data) &&
_imageCache.GetImage(imageIdentifier).Description.DccSliceSize == 0
? new ImageBindingMemo
{
Words = (uint[])words.Clone(),
Generation = _imageCache.LookupGeneration,
Resolution = resolution,
Found = imageIdentifier,
}
: default;
}
imageIdentifier = ImageRequestBuilders.ValidateTextureOwner(_imageCache, imageIdentifier, resolution);
BindImage(imageIdentifier, storage);
var descriptor = new TextureDescriptorWords(words);
@ -151,16 +211,47 @@ internal static unsafe partial class VulkanVideoPresenter
Console.Error.WriteLine(line);
}
}
return new TextureResource
var texture = RentTextureResource();
texture.Address = descriptor.BaseAddress;
texture.ImageIdentifier = imageIdentifier;
texture.Request = resolution.Request;
texture.IsStorage = storage;
texture.DestinationSelect = words[3] & 0xFFFu;
texture.Width = descriptor.Width;
texture.Height = descriptor.Height;
return texture;
}
// Binding descriptions are large (an image request carries its whole mip layout) and one is made
// per shader image per draw, so they are reused once the submission that read them has retired.
// Only the render thread rents and returns them.
// Sized for the bindings of every draw still in flight (a few frames of a thousand draws each);
// a smaller cap drops returns during busy scenes and the pool allocates again.
private const int MaxPooledTextureResources = 32768;
private Stack<TextureResource>? _texturePool;
private TextureResource RentTextureResource()
{
if (_texturePool is not null && _texturePool.TryPop(out var texture))
{
Address = descriptor.BaseAddress,
ImageIdentifier = imageIdentifier,
Request = request,
IsStorage = storage,
DestinationSelect = words[3] & 0xFFFu,
Width = descriptor.Width,
Height = descriptor.Height,
};
texture.InPool = false;
return texture;
}
return new TextureResource { Pooled = true };
}
private void ReturnTextureResource(TextureResource texture)
{
_texturePool ??= new Stack<TextureResource>();
if (!texture.Pooled || texture.InPool || _texturePool.Count >= MaxPooledTextureResources)
{
return;
}
texture.Reset();
texture.InPool = true;
_texturePool.Push(texture);
}
// Compare bits stay only on depth-compare samplers; a forced point sampler drops its filters.
@ -768,8 +859,11 @@ internal static unsafe partial class VulkanVideoPresenter
throw SubmissionScheduler.Fatal($"A stage does not belong to the bind point: stage={stage.Program.Stage} bindPoint={bindPoint}.");
}
foreach (var binding in stage.Layout.Descriptors)
// Indexed loops over the layout's lists: a foreach on IReadOnlyList allocates an enumerator per draw.
var countedBindings = stage.Layout.Descriptors;
for (var bindingIndex = 0; bindingIndex < countedBindings.Count; bindingIndex++)
{
var binding = countedBindings[bindingIndex];
writeCount++;
var count = (int)DescriptorWriter.DescriptorCount(binding);
if (ImageDescriptorBinding.ResourceClass(binding.Kind) != ShaderCompiler.Resources.ImageResourceClass.None || binding.Kind == DescriptorBindingKind.Samplers)
@ -862,8 +956,11 @@ internal static unsafe partial class VulkanVideoPresenter
var occurrences = occurrenceScratch[..descriptors.Images.Length];
occurrences.Clear();
foreach (var binding in stage.Layout.Descriptors)
var bindings = stage.Layout.Descriptors;
for (var bindingIndex = 0; bindingIndex < bindings.Count; bindingIndex++)
{
var binding = bindings[bindingIndex];
var resources = binding.Resources;
var write = new WriteDescriptorSet
{
SType = StructureType.WriteDescriptorSet,
@ -875,8 +972,9 @@ internal static unsafe partial class VulkanVideoPresenter
var imageStart = imageIndex;
if (ImageDescriptorBinding.ResourceClass(binding.Kind) != ShaderCompiler.Resources.ImageResourceClass.None)
{
foreach (var resource in binding.Resources)
for (var resourceIndex = 0; resourceIndex < resources.Count; resourceIndex++)
{
var resource = resources[resourceIndex];
imageInfos[imageIndex++] = ImageInfo(descriptors.Images[(int)resource], occurrences[(int)resource]++, program, (int)resource);
}
}
@ -885,8 +983,9 @@ internal static unsafe partial class VulkanVideoPresenter
switch (binding.Kind)
{
case DescriptorBindingKind.Buffers:
foreach (var resource in binding.Resources)
for (var resourceIndex = 0; resourceIndex < resources.Count; resourceIndex++)
{
var resource = resources[resourceIndex];
var view = descriptors.Buffers[(int)resource];
if (view.Buffer.Handle == 0)
{
@ -925,8 +1024,9 @@ internal static unsafe partial class VulkanVideoPresenter
}
case DescriptorBindingKind.Samplers:
foreach (var resource in binding.Resources)
for (var resourceIndex = 0; resourceIndex < resources.Count; resourceIndex++)
{
var resource = resources[resourceIndex];
var sampler = descriptors.Samplers[(int)resource];
if (sampler.Handle == 0)
{

View File

@ -613,6 +613,10 @@ internal static unsafe partial class VulkanVideoPresenter
subgroupSizeControl.MinSubgroupSize <= RdnaSubgroupSize &&
subgroupSizeControl.MaxSubgroupSize >= RdnaSubgroupSize &&
(subgroupSizeControl.RequiredSubgroupSizeStages & ShaderStageFlags.ComputeBit) != 0;
_canRequireComputeSubgroup64 =
subgroupSizeControl.MinSubgroupSize <= Wave64SubgroupSize &&
subgroupSizeControl.MaxSubgroupSize >= Wave64SubgroupSize &&
(subgroupSizeControl.RequiredSubgroupSizeStages & ShaderStageFlags.ComputeBit) != 0;
_maxComputeWorkgroupSubgroups = subgroupSizeControl.MaxComputeWorkgroupSubgroups;
// MoltenVK reports a zero OpArrayLength for storage buffers bound through push
// descriptors, which turns every bounds-checked load into zero and drops every store.
@ -945,6 +949,7 @@ internal static unsafe partial class VulkanVideoPresenter
var supportsNullDescriptor = robustness2Features.NullDescriptor;
var supportsRobustness2 = supportsRobustImageAccess2 || supportsNullDescriptor;
_canRequireComputeSubgroup32 &= vulkan13Features.SubgroupSizeControl;
_canRequireComputeSubgroup64 &= vulkan13Features.SubgroupSizeControl;
SetSharedInt64AtomicsCapability(supportsSharedInt64Atomics);
if (!supportsSharedInt64Atomics)
{
@ -1157,7 +1162,7 @@ internal static unsafe partial class VulkanVideoPresenter
SType = StructureType.PhysicalDeviceVulkan13Features,
DynamicRendering = true,
Synchronization2 = true,
SubgroupSizeControl = _canRequireComputeSubgroup32,
SubgroupSizeControl = _canRequireComputeSubgroup32 || _canRequireComputeSubgroup64,
PNext = renderingChain,
};
var features2 = new PhysicalDeviceFeatures2

View File

@ -275,8 +275,12 @@ internal static unsafe partial class VulkanVideoPresenter
{
RecycleHostBuffer(texture.StagingBuffer, texture.StagingMemory);
}
ReturnTextureResource(texture);
}
resources.Textures = [];
foreach (var snapshot in resources.FeedbackSnapshots)
{
RetireFeedbackSnapshot(snapshot);

View File

@ -108,9 +108,23 @@ internal static unsafe partial class VulkanVideoPresenter
retireBuffers));
}
private void EnsureGuestSubmissionCapacity()
// Collecting completed submissions polls the GPU timeline twice and may wait for the priority
// worker. Every draw used to pay that; completed work only frees resources and runs deferred
// callbacks, so a collection a few hundred microseconds later changes nothing but how long
// those resources stay reserved. A full in-flight queue still collects, and waits, at once.
private static readonly long CollectIntervalTicks = System.Diagnostics.Stopwatch.Frequency / 4000;
private long _lastCollectTicks;
private void EnsureGuestSubmissionCapacity(bool collectNow = false)
{
using var profileScope = RenderPhaseProfile.MeasureDetail(RenderPhaseProfile.Phase.SubmissionCapacity);
var now = System.Diagnostics.Stopwatch.GetTimestamp();
if (!collectNow && _pendingGuestSubmissions.Count < MaxInFlightGuestSubmissions && now - _lastCollectTicks < CollectIntervalTicks)
{
return;
}
_lastCollectTicks = now;
CollectCompletedGuestSubmissions(waitForOldest: false);
if (_pendingGuestSubmissions.Count >= MaxInFlightGuestSubmissions)
{

View File

@ -67,23 +67,43 @@ internal static unsafe partial class VulkanVideoPresenter
uint IShaderPipelineHost.MaxPushDescriptors => _maxPushDescriptors;
// Wave64 compute runs natively when the device's subgroup is that wide; smaller devices emulate it.
bool IShaderPipelineHost.ComputeWave64SubgroupNative =>
Volatile.Read(ref _nativeSubgroupSize) >= 64 && (_canRequireComputeSubgroup64 || Volatile.Read(ref _nativeSubgroupSize) == 64);
bool IShaderPipelineHost.ComputeWave64Supported => Volatile.Read(ref _nativeSubgroupSize) >= 64;
// Only a 64-invocation wave64 workgroup is translated for either host subgroup width. Every
// other compute translation maps a guest wave to 32-lane host subgroups, and a 64-lane host
// subgroup (AMD's default) left lanes 32..63 inactive: a wave64 8x8x8 group lost rows 4..7.
// Wave32 compute maps a guest wave to 32-lane host subgroups, and a 64-lane host subgroup
// (AMD's default) left lanes 32..63 inactive, so those pipelines are pinned to 32. Wave64
// compute is pinned to 64 instead when the host runs it natively.
private const uint RdnaSubgroupSize = 32;
private bool _canRequireComputeSubgroup32;
private uint _maxComputeWorkgroupSubgroups;
private const uint Wave64SubgroupSize = 64;
private bool _canRequireComputeSubgroup64;
// A native wave64 translation reads one guest wave as one host subgroup, so the driver must
// not pick 32-lane subgroups for it (RDNA drivers may do so for compute).
private bool RequiresComputeSubgroup64(ComputeInputInfo input)
{
return _canRequireComputeSubgroup64 && input.WaveSize == 64 &&
((IShaderPipelineHost)this).ComputeWave64SubgroupNative;
}
private bool RequiresComputeSubgroup32(ComputeInputInfo input)
{
var invocations = (ulong)Math.Max(input.ThreadsX, 1) * Math.Max(input.ThreadsY, 1) * Math.Max(input.ThreadsZ, 1);
return _canRequireComputeSubgroup32 &&
!(input.WaveSize == 64 && invocations == 64) &&
!(input.WaveSize == 64 && ((IShaderPipelineHost)this).ComputeWave64SubgroupNative) &&
invocations <= (ulong)_maxComputeWorkgroupSubgroups * RdnaSubgroupSize;
}
// The subgroup width a compute pipeline must be pinned to, or 0 to leave it to the driver.
private uint ComputeRequiredSubgroupSize(ComputeInputInfo input) =>
RequiresComputeSubgroup64(input) ? Wave64SubgroupSize
: RequiresComputeSubgroup32(input) ? RdnaSubgroupSize
: 0u;
bool IShaderPipelineHost.GraphicsSubgroupOperationsEnabled => GraphicsSubgroupOperationsEnabled;
bool IShaderPipelineHost.SharedInt64AtomicsEnabled => SharedInt64AtomicsEnabled;
@ -101,6 +121,11 @@ internal static unsafe partial class VulkanVideoPresenter
// A range the GPU wrote is downloaded first, so the word is what the guest CPU would read.
public bool TryReadGuestWord(ulong address, out uint word)
{
if (_concurrentAssumptions is { } assumptions)
{
return TryReadGuestWordConcurrent(address, out word, assumptions);
}
using var profile = ResourceMaterializationProfile.Measure(ResourceMaterializationProfile.Phase.GuestRead);
word = 0;
var synchronized = false;
@ -170,9 +195,9 @@ internal static unsafe partial class VulkanVideoPresenter
return false;
}
if ((_bufferCache.MayHaveGpuDirtyPages(address, sizeof(uint)) && _bufferCache.HasGpuDirtyPages(address, sizeof(uint))) ||
_bufferCache.HasGpuDirtyBytes(address, sizeof(uint)) ||
_imageCache.HasGpuModifiedImageBytes(address, sizeof(uint)))
var owned = IsGpuOwnedForCleanRead(address, sizeof(uint));
_concurrentAssumptions?.Record(ConcurrentQuery.CleanWord, address, sizeof(uint), owned);
if (owned)
{
return false;
}
@ -190,6 +215,11 @@ internal static unsafe partial class VulkanVideoPresenter
// The same ownership rules as the two word readers, checked once for a whole range.
public bool TryReadResidentGuestBytes(ulong address, Span<byte> destination, bool clean)
{
if (_concurrentAssumptions is { } assumptions)
{
return TryReadResidentGuestBytesConcurrent(address, destination, clean, assumptions);
}
var size = (ulong)destination.Length;
if (clean || !IsCleanReadPage(address, size))
{
@ -967,15 +997,16 @@ internal static unsafe partial class VulkanVideoPresenter
Pipeline pipeline;
try
{
var pin = ComputeRequiredSubgroupSize(description.Input);
var requiredSubgroupSize = new PipelineShaderStageRequiredSubgroupSizeCreateInfo
{
SType = StructureType.PipelineShaderStageRequiredSubgroupSizeCreateInfo,
RequiredSubgroupSize = RdnaSubgroupSize,
RequiredSubgroupSize = pin,
};
var stageInfo = new PipelineShaderStageCreateInfo
{
SType = StructureType.PipelineShaderStageCreateInfo,
PNext = RequiresComputeSubgroup32(description.Input) ? &requiredSubgroupSize : null,
PNext = pin != 0 ? &requiredSubgroupSize : null,
Stage = ShaderStageFlags.ComputeBit,
Module = computeModule,
PName = entryPoint,
@ -1099,6 +1130,7 @@ internal static unsafe partial class VulkanVideoPresenter
var device = _device;
var cacheSource = GetGuestPipelineCacheSource(ComputeCacheKey(description.Stage.Hash, computeModule.Handle));
var vk = _vk;
var requiredSubgroupSize = ComputeRequiredSubgroupSize(description.Input);
var started = new PendingComputePipeline
{
SetLayout = setLayout,
@ -1122,7 +1154,7 @@ internal static unsafe partial class VulkanVideoPresenter
// Importing a MoltenVK cache compiles its MSL libraries.
// Keep that work inside the same bounded compiler slot.
var cache = ResolveGuestPipelineCache(cacheSource);
return CompileComputePipeline(vk, device, cache, computeModule, layout);
return CompileComputePipeline(vk, device, cache, computeModule, layout, requiredSubgroupSize);
}
finally
{
@ -1146,14 +1178,21 @@ internal static unsafe partial class VulkanVideoPresenter
Device device,
PipelineCache cache,
ShaderModule module,
PipelineLayout layout)
PipelineLayout layout,
uint requiredSubgroupSize)
{
var entryPoint = (byte*)SilkMarshal.StringToPtr("main");
try
{
var subgroupInfo = new PipelineShaderStageRequiredSubgroupSizeCreateInfo
{
SType = StructureType.PipelineShaderStageRequiredSubgroupSizeCreateInfo,
RequiredSubgroupSize = requiredSubgroupSize,
};
var stageInfo = new PipelineShaderStageCreateInfo
{
SType = StructureType.PipelineShaderStageCreateInfo,
PNext = requiredSubgroupSize != 0 ? &subgroupInfo : null,
Stage = ShaderStageFlags.ComputeBit,
Module = module,
PName = entryPoint,

View File

@ -357,6 +357,7 @@ internal static unsafe partial class VulkanVideoPresenter
_commandBuffer,
PipelineBindPoint.Graphics,
isHdr ? _hdrPqPipeline : _hdrPipeline);
SharpEmu.Libs.Gpu.Vulkan.GraphicsDynamicStateEpoch.Advance();
var descriptorSet = isHdr
? _hdrPqDescriptorSets[imageIndex]
: _hdrDescriptorSets[imageIndex];

View File

@ -46,38 +46,50 @@ internal static unsafe partial class VulkanVideoPresenter
using var profileScope = RenderPhaseProfile.Measure(RenderPhaseProfile.Phase.Idle);
var gpuWorkInFlight = _pendingGuestSubmissions.Count > 0 ||
Array.Exists(_frameInFlight, static pending => pending);
var blockedRetryWait = BlockedRetryWaitMilliseconds();
lock (_gate)
// The command stream thread retries its blocked heads itself and wakes this thread for its
// items; before it starts, queued submissions start it on the next tick.
var commandThread = _commandThread;
if (commandThread is not null)
{
if (_closed ||
Volatile.Read(ref _presenterCloseRequested) ||
_relay.HasPendingCommands ||
(_vulkanReady && _commandStream.HasUnblockedPending) ||
blockedRetryWait == 0 ||
HasReadyPresentationLocked())
{
return;
}
commandThread.ConsumerIdle = true;
}
var waitMilliseconds = gpuWorkInFlight ? 1 : 8;
if (blockedRetryWait is { } retryWait)
try
{
lock (_gate)
{
waitMilliseconds = Math.Min(waitMilliseconds, retryWait);
}
if (_closed ||
Volatile.Read(ref _presenterCloseRequested) ||
_relay.HasPendingCommands ||
(commandThread is not null ? commandThread.HasWork : _vulkanReady && _commandStream.HasPending) ||
HasReadyPresentationLocked())
{
return;
}
var waitPhase = _commandStream.HasPending
? RenderPhaseProfile.Phase.IdleBlockedCommands
: _pendingGuestImagePresentations.Count > 0 || _pendingVideoPresentations.Count > 0 ||
(_latestPresentation is { } latest && latest.Sequence != _presentedSequence)
? RenderPhaseProfile.Phase.IdlePendingPresentation
: RenderPhaseProfile.Phase.IdleNoQueuedWork;
using var waitProfile = RenderPhaseProfile.MeasureDetail(waitPhase);
SubmissionFlowProfile.Record(SubmissionFlowProfile.EventKind.WaitStarted,
detail: waitMilliseconds);
var signaled = System.Threading.Monitor.Wait(_gate, waitMilliseconds);
// The result describes the monitor wait, not the arrival of runnable guest work.
SubmissionFlowProfile.Record(signaled ? SubmissionFlowProfile.EventKind.WaitSignaled
: SubmissionFlowProfile.EventKind.WaitTimedOut, detail: (int)waitPhase);
var waitMilliseconds = gpuWorkInFlight ? 1 : 8;
var waitPhase = _commandStream.HasPending
? RenderPhaseProfile.Phase.IdleBlockedCommands
: _pendingGuestImagePresentations.Count > 0 || _pendingVideoPresentations.Count > 0 ||
(_latestPresentation is { } latest && latest.Sequence != _presentedSequence)
? RenderPhaseProfile.Phase.IdlePendingPresentation
: RenderPhaseProfile.Phase.IdleNoQueuedWork;
using var waitProfile = RenderPhaseProfile.MeasureDetail(waitPhase);
SubmissionFlowProfile.Record(SubmissionFlowProfile.EventKind.WaitStarted,
detail: waitMilliseconds);
var signaled = System.Threading.Monitor.Wait(_gate, waitMilliseconds);
// The result describes the monitor wait, not the arrival of runnable guest work.
SubmissionFlowProfile.Record(signaled ? SubmissionFlowProfile.EventKind.WaitSignaled
: SubmissionFlowProfile.EventKind.WaitTimedOut, detail: (int)waitPhase);
}
}
finally
{
if (commandThread is not null)
{
commandThread.ConsumerIdle = false;
}
}
}
@ -117,9 +129,16 @@ internal static unsafe partial class VulkanVideoPresenter
return;
}
using (RenderPhaseProfile.MeasureDetail(RenderPhaseProfile.Phase.QueueRelay))
if (_commandThread is not null)
{
_relay.RunPendingCommands();
RunRelayCommandsWithPrefetchHeld();
}
else
{
using (RenderPhaseProfile.MeasureDetail(RenderPhaseProfile.Phase.QueueRelay))
{
_relay.RunPendingCommands();
}
}
if (_deviceLost)
{
@ -388,6 +407,7 @@ internal static unsafe partial class VulkanVideoPresenter
_commandBuffer,
PipelineBindPoint.Graphics,
_barycentricPipeline);
SharpEmu.Libs.Gpu.Vulkan.GraphicsDynamicStateEpoch.Advance();
_vk.CmdDraw(_commandBuffer, 3, 1, 0, 0);
_vk.CmdEndRenderPass(_commandBuffer);
waitStage = PipelineStageFlags.ColorAttachmentOutputBit;

View File

@ -12,6 +12,7 @@ using SharpEmu.Libs.Gpu.GpuCommands.Registers;
using SharpEmu.Libs.Gpu.Images;
using SharpEmu.Libs.Gpu.Pipelines;
using SharpEmu.Libs.Gpu.Rendering;
using SharpEmu.Libs.Gpu.Vulkan;
using SharpEmu.Libs.Gpu.Scheduling;
using SharpEmu.Libs.Gpu.Vulkan;
using Silk.NET.Core;
@ -280,14 +281,21 @@ internal static unsafe partial class VulkanVideoPresenter
_boundGraphicsPipeline = null;
}
// The acquisition below reads the attachment back into its result and keeps no reference to it,
// so one render-thread instance serves every draw.
private ColorAttachment? _colorAttachmentScratch;
ColorAttachmentAcquisition IRenderHost.AcquireColorAttachment(in ColorTargetState target)
{
var attachment = new ColorAttachment
{
ImageIdentifier = target.Image,
Request = target.Resolution.Request,
Resolution = target.Resolution,
};
var attachment = _colorAttachmentScratch ??= new ColorAttachment();
attachment.ImageIdentifier = target.Image;
attachment.Request = target.Resolution.Request;
attachment.Resolution = target.Resolution;
attachment.Image = null!;
attachment.View = default;
attachment.Layout = default;
attachment.Clear = false;
attachment.ClearValue = default;
AcquireColorAttachment(attachment);
return new ColorAttachmentAcquisition(
attachment.ImageIdentifier,
@ -644,29 +652,80 @@ internal static unsafe partial class VulkanVideoPresenter
_vk.CmdClearDepthStencilImage(command, image.Backing.Handle, ImageLayout.TransferDstOptimal, &value, 1, &vkRange);
}
// The dynamic state the last guest draw set, and where it holds: the command buffer and its
// tick (a new command buffer starts without state), and no other graphics pipeline since.
private DynamicDrawState _lastDynamicState;
private CommandBuffer _lastDynamicStateBuffer;
private ulong _lastDynamicStateTick;
private long _lastDynamicStateEpoch = -1;
public void SetDynamicState(in DynamicDrawState state)
{
using var profileScope = RenderPhaseProfile.MeasureDetail(RenderPhaseProfile.Phase.DrawDynamicStateRecording);
var command = BeginBatchedGuestCommands();
var viewport = new Viewport(state.ViewportX, state.ViewportY, state.ViewportWidth, state.ViewportHeight, state.ViewportMinDepth, state.ViewportMaxDepth);
_vk.CmdSetViewport(command, 0, 1, &viewport);
var scissor = new Rect2D(
new Offset2D(state.Scissor.Left, state.Scissor.Top),
new Extent2D((uint)(state.Scissor.Right - state.Scissor.Left), (uint)(state.Scissor.Bottom - state.Scissor.Top)));
_vk.CmdSetScissor(command, 0, 1, &scissor);
_vk.CmdSetLineWidth(command, state.LineWidth);
var blendConstants = stackalloc float[4] { state.BlendRed, state.BlendGreen, state.BlendBlue, state.BlendAlpha };
_vk.CmdSetBlendConstants(command, blendConstants);
_vk.CmdSetDepthTestEnable(command, state.DepthTestEnabled);
_vk.CmdSetDepthWriteEnable(command, state.DepthWriteEnabled);
_vk.CmdSetDepthCompareOp(command, state.DepthCompare);
_vk.CmdSetDepthBiasEnable(command, state.DepthBiasEnabled);
if (state.DepthBiasEnabled)
// Every guest pipeline keeps these states dynamic, so they persist across its binds;
// only what changed since the last draw in this command buffer is set again.
var known = command.Handle == _lastDynamicStateBuffer.Handle && _scheduler.CurrentTick == _lastDynamicStateTick &&
GraphicsDynamicStateEpoch.Value == _lastDynamicStateEpoch;
ref readonly var last = ref _lastDynamicState;
if (!known || state.ViewportX != last.ViewportX || state.ViewportY != last.ViewportY ||
state.ViewportWidth != last.ViewportWidth || state.ViewportHeight != last.ViewportHeight ||
state.ViewportMinDepth != last.ViewportMinDepth || state.ViewportMaxDepth != last.ViewportMaxDepth)
{
var viewport = new Viewport(state.ViewportX, state.ViewportY, state.ViewportWidth, state.ViewportHeight, state.ViewportMinDepth, state.ViewportMaxDepth);
_vk.CmdSetViewport(command, 0, 1, &viewport);
}
if (!known || state.Scissor != last.Scissor)
{
var scissor = new Rect2D(
new Offset2D(state.Scissor.Left, state.Scissor.Top),
new Extent2D((uint)(state.Scissor.Right - state.Scissor.Left), (uint)(state.Scissor.Bottom - state.Scissor.Top)));
_vk.CmdSetScissor(command, 0, 1, &scissor);
}
if (!known || state.LineWidth != last.LineWidth)
{
_vk.CmdSetLineWidth(command, state.LineWidth);
}
if (!known || state.BlendRed != last.BlendRed || state.BlendGreen != last.BlendGreen ||
state.BlendBlue != last.BlendBlue || state.BlendAlpha != last.BlendAlpha)
{
var blendConstants = stackalloc float[4] { state.BlendRed, state.BlendGreen, state.BlendBlue, state.BlendAlpha };
_vk.CmdSetBlendConstants(command, blendConstants);
}
if (!known || state.DepthTestEnabled != last.DepthTestEnabled)
{
_vk.CmdSetDepthTestEnable(command, state.DepthTestEnabled);
}
if (!known || state.DepthWriteEnabled != last.DepthWriteEnabled)
{
_vk.CmdSetDepthWriteEnable(command, state.DepthWriteEnabled);
}
if (!known || state.DepthCompare != last.DepthCompare)
{
_vk.CmdSetDepthCompareOp(command, state.DepthCompare);
}
if (!known || state.DepthBiasEnabled != last.DepthBiasEnabled)
{
_vk.CmdSetDepthBiasEnable(command, state.DepthBiasEnabled);
}
// The factors only matter while the bias is on; they are set when it turns on or changes.
if (state.DepthBiasEnabled && (!known || !last.DepthBiasEnabled ||
state.DepthBiasConstantFactor != last.DepthBiasConstantFactor || state.DepthBiasClamp != last.DepthBiasClamp ||
state.DepthBiasSlopeFactor != last.DepthBiasSlopeFactor))
{
_vk.CmdSetDepthBias(command, state.DepthBiasConstantFactor, _supportsDepthBiasClamp ? state.DepthBiasClamp : 0f, state.DepthBiasSlopeFactor);
}
if (state.StencilTestEnabled)
if (state.StencilTestEnabled && (!known || !last.StencilTestEnabled ||
state.FrontStencil != last.FrontStencil || state.BackStencil != last.BackStencil))
{
_vk.CmdSetStencilCompareMask(command, StencilFaceFlags.FaceFrontBit, state.FrontStencil.CompareMask);
_vk.CmdSetStencilCompareMask(command, StencilFaceFlags.FaceBackBit, state.BackStencil.CompareMask);
@ -686,6 +745,11 @@ internal static unsafe partial class VulkanVideoPresenter
colorWriteEnable.CmdSetColorWriteEnable(command, state.ColorWriteCount, enables);
}
_lastDynamicState = state;
_lastDynamicStateBuffer = command;
_lastDynamicStateTick = _scheduler.CurrentTick;
_lastDynamicStateEpoch = GraphicsDynamicStateEpoch.Value;
}

View File

@ -120,6 +120,8 @@ internal static unsafe partial class VulkanVideoPresenter
// Drain accepted submissions before closing the relay.
// Cancel blocked submissions if a full retry cycle makes no progress.
_commandStream.StopAccepting();
// The command stream thread ends after its slice; the rest drains here.
_commandThread?.Stop(() => RunCommandStreamSlices(long.MaxValue));
var outcome = _commandStream.DrainForShutdown(cancelBlockedOnNoProgress: true);
FlushBatchedGuestCommands();
Console.Error.WriteLine(

View File

@ -163,7 +163,8 @@ internal static unsafe partial class VulkanVideoPresenter
{
if (!ShaderProgramCache.TryCompilePrewarm(
record, code, compiler, sharedInt64Atomics, execGuardElision,
_supportsShaderSignedZeroInfNanPreserveFloat32, out var compiled, out var layout, out var error))
_supportsShaderSignedZeroInfNanPreserveFloat32, out var compiled, out var layout, out var error,
((IShaderPipelineHost)this).ComputeWave64SubgroupNative))
{
NoteShaderPrewarmFailure(record, error);
return;
@ -181,7 +182,7 @@ internal static unsafe partial class VulkanVideoPresenter
setLayout = CreateDescriptorSetLayout(bindings, out _, out _);
pipelineLayout = CreatePipelineLayout(setLayout, ShaderStageFlags.ComputeBit);
module = CreateShaderModule(payload);
var pipeline = CompileComputePipeline(_vk, _device, _pipelineCache, module, pipelineLayout);
var pipeline = CompileComputePipeline(_vk, _device, _pipelineCache, module, pipelineLayout, ComputeRequiredSubgroupSize(record.Info));
_vk.DestroyPipeline(_device, pipeline, null);
Interlocked.Increment(ref _shaderPrewarmCompiled);
}

View File

@ -52,39 +52,7 @@ public static partial class Gen5SpirvTranslator
}
var value = GetRawSource(instruction, 0);
if (_subgroupInvocationIdInput != 0)
{
if (_emulateWave64)
{
value = BroadcastFirstWave64Active(value);
}
else
{
// SPIR-V's BroadcastFirst uses the first host-active
// invocation. Guest EXEC is modeled as data, so obtain the
// guest-active mask explicitly and broadcast from its first
// set lane instead. This also updates the private SGPR copy
// for lanes that are currently disabled and may be restored
// by a later saveexec sequence.
var activeLanes = _module.AddInstruction(
SpirvOp.GroupNonUniformBallot,
_uvec4Type,
UInt(3),
Load(_boolType, _exec));
var activeLow = _module.AddInstruction(
SpirvOp.CompositeExtract,
_uintType,
activeLanes,
0);
var firstActiveLane = Ext(73, _uintType, activeLow);
value = _module.AddInstruction(
SpirvOp.GroupNonUniformBroadcast,
_uintType,
UInt(3),
value,
firstActiveLane);
}
}
value = BroadcastFirstWaveActive(value);
StoreS(instruction.Destinations[0].Value, value);
return true;
@ -3422,9 +3390,7 @@ public static partial class Gen5SpirvTranslator
var left = GetRawSource64(instruction, 0);
if (instruction.Opcode.EndsWith("SaveexecB64", StringComparison.Ordinal))
{
var oldExec = _emulateWave64 && _subgroupInvocationIdInput != 0
? LoadS64(126)
: BooleanToWaveMask(Load(_boolType, _exec));
var oldExec = CurrentExecMask();
var notLeft = _module.AddInstruction(SpirvOp.Not, _ulongType, left);
var newExec = instruction.Opcode switch
{
@ -3493,12 +3459,7 @@ public static partial class Gen5SpirvTranslator
return false;
}
if (!_emulateWave64 && _waveLaneCount != 64)
{
newExec = BitwiseAnd64(
newExec,
_module.Constant64(_ulongType, 0xFFFF_FFFFUL));
}
newExec = NormalizeExecMask(newExec);
StoreS64(destination, oldExec);
StoreS64(126, newExec);
@ -4958,52 +4919,6 @@ public static partial class Gen5SpirvTranslator
private uint GuestLaneSelect(uint selector) =>
BitwiseAnd(selector, UInt(_waveLaneCount == 64 ? 63u : RdnaWaveLaneCount - 1));
// An emulated wave64 spans two host subgroups, so a subgroup broadcast cannot reach
// the other half. The selected guest lane publishes through workgroup scratch, like
// BroadcastFirstWave64Active.
private uint BroadcastWave64Lane(uint value, uint lane) =>
ExchangeWave64Value(_module.AddInstruction(SpirvOp.IEqual, _boolType, GuestWaveLane(), lane), value);
private uint BroadcastFirstWave64Active(uint value)
{
var lane = GuestWaveLane();
var upperHalf = ShiftRightLogical(lane, UInt(5));
var activeInHalf = OwnHalfBallot(Load(_boolType, _exec));
var halfHasActive = IsNotZero(activeInHalf);
var firstInHalf = _module.AddInstruction(
SpirvOp.Select,
_uintType,
halfHasActive,
Ext(73, _uintType, activeInHalf),
UInt(0));
var halfBase = BitwiseAnd(Load(_uintType, _subgroupInvocationIdInput), UInt(~31u));
var halfValue = _module.AddInstruction(
SpirvOp.GroupNonUniformBroadcast,
_uintType,
UInt(3),
value,
IAdd(halfBase, firstInHalf));
var exchange = BeginWave64Exchange();
EmitConditional(IsHalfWaveLeader(lane), () =>
{
Store(Wave64ExchangePointer(exchange, upperHalf), halfValue);
Store(
Wave64ExchangePointer(exchange, IAdd(UInt(2), upperHalf)),
_module.AddInstruction(SpirvOp.Select, _uintType, halfHasActive, UInt(1), UInt(0)));
});
EmitWave64Barrier();
var lowerValue = Load(_uintType, Wave64ExchangePointer(exchange, UInt(0)));
var upperValue = Load(_uintType, Wave64ExchangePointer(exchange, UInt(1)));
var lowerActive = IsNotZero(Load(_uintType, Wave64ExchangePointer(exchange, UInt(2))));
var upperActive = IsNotZero(Load(_uintType, Wave64ExchangePointer(exchange, UInt(3))));
return _module.AddInstruction(
SpirvOp.Select,
_uintType,
_module.AddInstruction(SpirvOp.LogicalAnd, _boolType, LogicalNot(lowerActive), upperActive),
upperValue,
lowerValue);
}
private void StoreCarryOut(
Gen5ShaderInstruction instruction,
uint carry)
@ -5038,20 +4953,15 @@ public static partial class Gen5SpirvTranslator
var sourceValue = GetRawSource(instruction, 0);
var selectedLane = BitwiseAnd(GetRawSource(instruction, 1), UInt(LaneSelectMask));
if (_emulateWave64)
if (_stage == Gen5SpirvStage.Compute && _waveLowering is EmulatedWave64Lowering)
{
// The selected guest lane can belong to another host subgroup.
// Read it even when the guest execution mask disables that lane.
StoreS(destination, BroadcastWave64Lane(sourceValue, selectedLane));
StoreS(destination, BroadcastWaveLane(sourceValue, selectedLane));
}
else if (_subgroupInvocationIdInput != 0 && _stage == Gen5SpirvStage.Compute)
{
var broadcast = _module.AddInstruction(
SpirvOp.GroupNonUniformBroadcast,
_uintType,
UInt(3),
sourceValue,
selectedLane);
var broadcast = BroadcastWaveLane(sourceValue, selectedLane);
StoreS(destination, ReadLaneSpillSlot(instruction, selectedLane, broadcast));
}
else

View File

@ -0,0 +1,27 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
namespace SharpEmu.ShaderCompiler.Vulkan;
public static partial class Gen5SpirvTranslator
{
private sealed partial class CompilationContext
{
private uint BroadcastFirstWaveActive(uint value) =>
_waveLowering.BroadcastFirstActive(value);
private uint BroadcastWaveLane(uint value, uint lane) =>
_waveLowering.BroadcastLane(value, lane);
private uint BroadcastWaveValue(uint writer, uint value, uint lane) =>
_waveLowering.BroadcastValue(writer, value, lane);
private uint CurrentExecMask() =>
_waveLowering.CurrentExecMask();
private uint NormalizeExecMask(uint mask) =>
_waveLowering.NormalizeExecMask(mask);
}
}

View File

@ -0,0 +1,578 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using SharpEmu.ShaderCompiler.Resources;
namespace SharpEmu.ShaderCompiler.Vulkan;
public static partial class Gen5SpirvTranslator
{
private sealed partial class CompilationContext
{
private sealed class EmulatedWave64Lowering : IWaveLowering
{
private const uint ExchangeSlotCount = 4;
private const uint ExchangeDwordCount = ExchangeSlotCount * 2;
private const uint GuestWaveLaneCount = 64;
private static readonly bool HalfWaveMasks = !string.Equals(
Environment.GetEnvironmentVariable("SHARPEMU_WAVE64_HALF_MASKS"),
"0",
StringComparison.Ordinal);
private readonly CompilationContext _context;
private uint _exchange;
private uint _exchangeElementPointer;
private uint _exchangeOffset;
private uint _exchangeParity;
// Several guest waves share one workgroup. Each gets its own exchange area plus an
// arrival counter, and its two halves meet at that counter instead of at a workgroup
// barrier, which other waves (possibly in other blocks) would never reach.
private readonly uint _waveCount;
private readonly bool _multiWave;
private readonly uint _waveStride;
private uint _rendezvousEpoch;
private Ir.Gen5Wave64HalfMaskPlan? _halfMaskPlan;
private bool _halfMaskPlanBuilt;
private bool _synchronizeSharedMemory;
private SharedMemoryPhase _sharedMemoryPhase;
private uint? _emittingPc;
private enum SharedMemoryPhase { None, Read, Write }
public EmulatedWave64Lowering(CompilationContext context)
{
_context = context;
var threads = (ulong)context._localSizeX * context._localSizeY * context._localSizeZ;
_waveCount = (uint)Math.Max((threads + GuestWaveLaneCount - 1) / GuestWaveLaneCount, 1);
_multiWave = _waveCount > 1;
_waveStride = _multiWave ? ExchangeDwordCount + 1 : ExchangeDwordCount;
}
private uint ExchangeTotalDwords => _waveCount * _waveStride;
public bool RequiresCrossSubgroupSynchronization => true;
private bool UsesExchange => _context.UsesSubgroupOperations();
public uint ReserveLdsDwords(uint guestDwordCount) =>
UsesExchange && guestDwordCount < LdsDwordCount
? guestDwordCount + ExchangeTotalDwords
: guestDwordCount;
public void DeclareResources()
{
var context = _context;
if (!UsesExchange)
{
return;
}
if (context._lds != 0)
{
// Metal exposes 32 KiB of threadgroup memory on the Apple
// GPUs we target. Reuse the tail of guest LDS so the
// cross-subgroup bridge stays within that limit.
_exchange = context._lds;
_exchangeElementPointer = context._ldsElementPointer;
_exchangeOffset = context._ldsDwordMask + 1 < LdsDwordCount
? context._ldsDwordMask + 1
: LdsDwordCount - ExchangeTotalDwords;
return;
}
var exchangeArrayType = context._module.TypeArray(context._uintType, ExchangeTotalDwords);
_exchangeElementPointer = context._module.TypePointer(SpirvStorageClass.Workgroup, context._uintType);
_exchange = context._module.AddGlobalVariable(
context._module.TypePointer(SpirvStorageClass.Workgroup, exchangeArrayType),
SpirvStorageClass.Workgroup);
_exchangeOffset = 0;
context._module.AddName(_exchange, "wave64Exchange");
context._interfaces.Add(_exchange);
}
public void DeclareFunctionResources()
{
if (!UsesExchange)
{
return;
}
_exchangeParity = _context.DeclareFunctionUint(
_context._module.Constant(_context._uintType, 0));
_context._module.AddName(_exchangeParity, "wave64ExchangeParity");
if (_multiWave)
{
_rendezvousEpoch = _context.DeclareFunctionUint(
_context._module.Constant(_context._uintType, 0));
_context._module.AddName(_rendezvousEpoch, "wave64RendezvousEpoch");
}
}
public void InitializeMasks()
{
var context = _context;
if (context._subgroupInvocationIdInput != 0)
{
context.StoreS64(106, BothHalvesOfOwnBallot(context._module.ConstantBool(false)));
context.StoreS64(126, BothHalvesOfOwnBallot(context._module.ConstantBool(true)));
InitializeRendezvous();
return;
}
context.StoreS64(106, context._module.Constant64(context._ulongType, 0));
context.StoreS64(126, context._module.Constant64(context._ulongType, 1));
}
public void BeginBlock()
{
_synchronizeSharedMemory = HalfMaskPlan() is null;
_sharedMemoryPhase = SharedMemoryPhase.None;
}
public void BeforeInstruction(Gen5ShaderInstruction instruction)
{
var context = _context;
_emittingPc = instruction.Pc;
if (HalfMaskPlan() is { } plan)
{
if (plan.ExactPairsBefore.TryGetValue(instruction.Pc, out var exactPairs))
{
EmitExactWaveMasks(exactPairs);
}
if (plan.SharedMemoryBarriersBefore.Contains(instruction.Pc))
{
Barrier();
}
}
if (!_synchronizeSharedMemory)
{
return;
}
var nextPhase = instruction.Control is Gen5DataShareControl { Gds: false }
? instruction.Opcode.StartsWith("DsRead", StringComparison.Ordinal) ? SharedMemoryPhase.Read
: instruction.Opcode.StartsWith("DsWrite", StringComparison.Ordinal) ? SharedMemoryPhase.Write
: SharedMemoryPhase.None
: SharedMemoryPhase.None;
if (instruction.Opcode == "SBarrier")
{
_sharedMemoryPhase = SharedMemoryPhase.None;
}
if (nextPhase != SharedMemoryPhase.None)
{
if (_sharedMemoryPhase != SharedMemoryPhase.None && _sharedMemoryPhase != nextPhase)
{
Barrier();
}
_sharedMemoryPhase = nextPhase;
}
}
public void EndBlock()
{
if (_synchronizeSharedMemory && _sharedMemoryPhase != SharedMemoryPhase.None)
{
Barrier();
}
}
public uint SubgroupAny(uint condition) =>
_context._subgroupInvocationIdInput == 0
? condition
: _context.IsNotZero64(BooleanToWaveMask(condition));
public uint CurrentLaneBit()
{
var context = _context;
if (context._subgroupInvocationIdInput == 0)
{
return context._module.Constant64(context._ulongType, 1);
}
return context.ShiftLeftLogical64(
context._module.Constant64(context._ulongType, 1),
context._module.AddInstruction(
SpirvOp.UConvert,
context._ulongType,
context.GuestWaveLane()));
}
public uint BooleanToWaveMask(uint condition)
{
var context = _context;
if (context._subgroupInvocationIdInput == 0)
{
return context.BooleanToLaneMask(condition);
}
var lane = context.GuestWaveLane();
var owned = OwnHalfBallot(condition);
var exchange = BeginExchange();
context.EmitConditional(IsHalfWaveLeader(lane), () =>
context.Store(ExchangePointer(exchange, context.ShiftRightLogical(lane, context.UInt(5))), owned));
Barrier();
return context.Pair64(
context.Load(context._uintType, ExchangePointer(exchange, context.UInt(0))),
context.Load(context._uintType, ExchangePointer(exchange, context.UInt(1))));
}
public uint BroadcastFirstActive(uint value)
{
var context = _context;
if (context._subgroupInvocationIdInput == 0)
{
return value;
}
var lane = context.GuestWaveLane();
var upperHalf = context.ShiftRightLogical(lane, context.UInt(5));
var activeInHalf = OwnHalfBallot(context.Load(context._boolType, context._exec));
var halfHasActive = context.IsNotZero(activeInHalf);
var firstInHalf = context._module.AddInstruction(
SpirvOp.Select,
context._uintType,
halfHasActive,
context.Ext(73, context._uintType, activeInHalf),
context.UInt(0));
var halfBase = context.BitwiseAnd(
context.Load(context._uintType, context._subgroupInvocationIdInput),
context.UInt(~31u));
var halfValue = context._module.AddInstruction(
SpirvOp.GroupNonUniformBroadcast,
context._uintType,
context.UInt(3),
value,
context.IAdd(halfBase, firstInHalf));
var exchange = BeginExchange();
context.EmitConditional(IsHalfWaveLeader(lane), () =>
{
context.Store(ExchangePointer(exchange, upperHalf), halfValue);
context.Store(
ExchangePointer(exchange, context.IAdd(context.UInt(2), upperHalf)),
context._module.AddInstruction(
SpirvOp.Select,
context._uintType,
halfHasActive,
context.UInt(1),
context.UInt(0)));
});
Barrier();
var lowerValue = context.Load(context._uintType, ExchangePointer(exchange, context.UInt(0)));
var upperValue = context.Load(context._uintType, ExchangePointer(exchange, context.UInt(1)));
var lowerActive = context.IsNotZero(context.Load(context._uintType, ExchangePointer(exchange, context.UInt(2))));
var upperActive = context.IsNotZero(context.Load(context._uintType, ExchangePointer(exchange, context.UInt(3))));
return context._module.AddInstruction(
SpirvOp.Select,
context._uintType,
context._module.AddInstruction(
SpirvOp.LogicalAnd,
context._boolType,
context.LogicalNot(lowerActive),
upperActive),
upperValue,
lowerValue);
}
public uint BroadcastLane(uint value, uint lane) =>
ExchangeValue(
_context._module.AddInstruction(
SpirvOp.IEqual,
_context._boolType,
_context.GuestWaveLane(),
lane),
value);
public uint BroadcastValue(uint writer, uint value, uint lane) => ExchangeValue(writer, value);
public uint WaveMaskAny(uint register, uint laneActive) =>
_context._subgroupInvocationIdInput != 0
? _context.IsNotZero64(_context.LoadS64(register))
: SubgroupAny(_context.Load(_context._boolType, laneActive));
public uint CurrentExecMask() =>
_context._subgroupInvocationIdInput != 0
? _context.LoadS64(126)
: BooleanToWaveMask(_context.Load(_context._boolType, _context._exec));
public uint NormalizeExecMask(uint mask) => mask;
public void StoreWaveMask(uint register, uint condition)
{
var context = _context;
if (context._waveLaneCount == 32)
{
context.StoreS(register, context.Narrow(BooleanToWaveMask(condition)));
return;
}
if (context._subgroupInvocationIdInput != 0 &&
_emittingPc is { } pc &&
HalfMaskPlan() is { } plan &&
plan.HalfMaskWrites.TryGetValue(pc, out var halfRegister) &&
halfRegister == register)
{
context.StoreS64(register, BooleanToHalfWaveMask(condition));
return;
}
context.StoreS64(register, BooleanToWaveMask(condition));
}
private uint OwnHalfBallot(uint condition)
{
var context = _context;
var ballot = context._module.AddInstruction(
SpirvOp.GroupNonUniformBallot,
context._uvec4Type,
context.UInt(3),
condition);
return context._module.AddInstruction(
SpirvOp.VectorExtractDynamic,
context._uintType,
ballot,
context.ShiftRightLogical(
context.Load(context._uintType, context._subgroupInvocationIdInput),
context.UInt(5)));
}
private uint BooleanToHalfWaveMask(uint condition)
{
var context = _context;
var owned = context._module.AddInstruction(SpirvOp.UConvert, context._ulongType, OwnHalfBallot(condition));
return context._module.AddInstruction(
SpirvOp.Select,
context._ulongType,
context._module.AddInstruction(
SpirvOp.UGreaterThanEqual,
context._boolType,
context.GuestWaveLane(),
context.UInt(32)),
context.ShiftLeftLogical64(owned, context._module.Constant64(context._ulongType, 32)),
owned);
}
private uint BothHalvesOfOwnBallot(uint condition)
{
var context = _context;
var owned = context._module.AddInstruction(SpirvOp.UConvert, context._ulongType, OwnHalfBallot(condition));
return context.BitwiseOr64(
owned,
context.ShiftLeftLogical64(owned, context._module.Constant64(context._ulongType, 32)));
}
private uint IsHalfWaveLeader(uint lane) =>
_context._module.AddInstruction(
SpirvOp.IEqual,
_context._boolType,
_context.BitwiseAnd(lane, _context.UInt(31)),
_context.UInt(0));
private uint BeginExchange()
{
var context = _context;
var parity = context.Load(context._uintType, _exchangeParity);
context.Store(_exchangeParity, context.BitwiseXor(parity, context.UInt(1)));
return context.IAdd(
WaveExchangeBase(),
context._module.AddInstruction(
SpirvOp.IMul,
context._uintType,
parity,
context.UInt(ExchangeSlotCount)));
}
private uint WaveExchangeBase()
{
var context = _context;
if (!_multiWave)
{
return context.UInt(_exchangeOffset);
}
var wave = context.ShiftRightLogical(
context.Load(context._uintType, context._localInvocationIndexInput),
context.UInt(6));
return context.IAdd(
context.UInt(_exchangeOffset),
context._module.AddInstruction(SpirvOp.IMul, context._uintType, wave, context.UInt(_waveStride)));
}
// Zeroes each wave's arrival counter. Runs once at entry, where the whole workgroup is
// converged, so the workgroup barrier is safe here.
private void InitializeRendezvous()
{
if (!_multiWave || !UsesExchange)
{
return;
}
var context = _context;
context.EmitConditional(
context._module.AddInstruction(
SpirvOp.IEqual,
context._boolType,
context.GuestWaveLane(),
context.UInt(0)),
() => context.Store(
ExchangePointer(WaveExchangeBase(), context.UInt(ExchangeDwordCount)),
context.UInt(0)));
WorkgroupBarrier();
}
// Both halves of the wave arrive, then wait until the counter shows two arrivals per
// rendezvous so far. The halves of one wave are co-resident in the workgroup, so the
// wait always ends.
private void WaveRendezvous()
{
var context = _context;
var counter = ExchangePointer(WaveExchangeBase(), context.UInt(ExchangeDwordCount));
context.EmitConditional(
IsHalfWaveLeader(context.GuestWaveLane()),
() => context._module.AddInstruction(
SpirvOp.AtomicIAdd,
context._uintType,
counter,
context.UInt(2),
context.UInt(0x108),
context.UInt(1)));
var epoch = context.IAdd(context.Load(context._uintType, _rendezvousEpoch), context.UInt(1));
context.Store(_rendezvousEpoch, epoch);
var target = context._module.AddInstruction(SpirvOp.IMul, context._uintType, epoch, context.UInt(2));
var header = context._module.AllocateId();
var continueLabel = context._module.AllocateId();
var mergeLabel = context._module.AllocateId();
context._module.AddStatement(SpirvOp.Branch, header);
context._module.AddLabel(header);
var arrived = context._module.AddInstruction(
SpirvOp.AtomicLoad,
context._uintType,
counter,
context.UInt(2),
context.UInt(0x102));
var done = context._module.AddInstruction(
SpirvOp.UGreaterThanEqual,
context._boolType,
arrived,
target);
context._module.AddStatement(SpirvOp.LoopMerge, mergeLabel, continueLabel, 0);
context._module.AddStatement(SpirvOp.BranchConditional, done, mergeLabel, continueLabel);
context._module.AddLabel(continueLabel);
context._module.AddStatement(SpirvOp.Branch, header);
context._module.AddLabel(mergeLabel);
}
private uint ExchangePointer(uint exchange, uint slot) =>
_context._module.AddInstruction(
SpirvOp.AccessChain,
_exchangeElementPointer,
_exchange,
_context.IAdd(exchange, slot));
private uint ExchangeValue(uint writer, uint value)
{
var context = _context;
var exchange = BeginExchange();
context.EmitConditional(writer, () => context.Store(ExchangePointer(exchange, context.UInt(0)), value));
Barrier();
return context.Load(context._uintType, ExchangePointer(exchange, context.UInt(0)));
}
private void EmitExactWaveMasks(IReadOnlyList<uint> pairs)
{
var context = _context;
var lane = context.GuestWaveLane();
var upperHalf = context.ShiftRightLogical(lane, context.UInt(5));
var lowerHalf = context._module.AddInstruction(SpirvOp.IEqual, context._boolType, upperHalf, context.UInt(0));
const int pairsPerExchange = (int)ExchangeSlotCount / 2;
for (var start = 0; start < pairs.Count; start += pairsPerExchange)
{
var count = Math.Min(pairsPerExchange, pairs.Count - start);
var owned = new uint[count];
for (var index = 0; index < count; index++)
{
var pair = pairs[start + index];
owned[index] = context._module.AddInstruction(
SpirvOp.Select,
context._uintType,
lowerHalf,
context.LoadS(pair),
context.LoadS(pair + 1));
}
var exchange = BeginExchange();
context.EmitConditional(IsHalfWaveLeader(lane), () =>
{
for (var index = 0; index < count; index++)
{
context.Store(
ExchangePointer(exchange, context.IAdd(context.UInt((uint)index * 2), upperHalf)),
owned[index]);
}
});
Barrier();
for (var index = 0; index < count; index++)
{
var pair = pairs[start + index];
context.StoreS(pair, context.Load(context._uintType, ExchangePointer(exchange, context.UInt((uint)index * 2))));
context.StoreS(pair + 1, context.Load(context._uintType, ExchangePointer(exchange, context.UInt((uint)index * 2 + 1))));
}
}
}
private Ir.Gen5Wave64HalfMaskPlan? HalfMaskPlan()
{
if (_halfMaskPlanBuilt)
{
return _halfMaskPlan;
}
_halfMaskPlanBuilt = true;
var context = _context;
if (HalfWaveMasks && UsesExchange && context._subgroupInvocationIdInput != 0)
{
var sharedFlatPcs = context._request.Memory.Entries
.Where(static memory => memory.AddressSpace is FlatAddressSpace.Shared or FlatAddressSpace.SharedOrPrivate)
.Select(static memory => memory.Pc)
.ToHashSet();
_halfMaskPlan = Ir.Gen5Wave64HalfMaskAnalysis.Analyze(context._request.Program, sharedFlatPcs);
}
return _halfMaskPlan;
}
private void Barrier()
{
if (_multiWave)
{
// The rendezvous needs the exchange area, declared only with subgroup operations.
if (UsesExchange)
{
WaveRendezvous();
}
return;
}
WorkgroupBarrier();
}
private void WorkgroupBarrier()
{
var workgroup = _context.UInt(2);
_context._module.AddStatement(
SpirvOp.ControlBarrier,
workgroup,
workgroup,
_context.UInt(0x108));
}
}
}
}

View File

@ -0,0 +1,156 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
namespace SharpEmu.ShaderCompiler.Vulkan;
public static partial class Gen5SpirvTranslator
{
private sealed partial class CompilationContext
{
// A guest Wave32 maps directly to one 32-lane host subgroup. This is the
// former implicit/default lowering, made explicit so every guest wave size
// has a concrete lowering strategy.
private sealed class NativeWave32Lowering : IWaveLowering
{
private readonly CompilationContext _context;
public NativeWave32Lowering(CompilationContext context)
{
_context = context;
}
public bool RequiresCrossSubgroupSynchronization => false;
public uint ReserveLdsDwords(uint guestDwordCount) => guestDwordCount;
public void DeclareResources() { }
public void DeclareFunctionResources() { }
public void BeginBlock() { }
public void BeforeInstruction(Gen5ShaderInstruction instruction) { }
public void EndBlock() { }
public void InitializeMasks()
{
var context = _context;
if (context._subgroupInvocationIdInput != 0)
{
StoreWaveMask(106, context._module.ConstantBool(false));
StoreWaveMask(126, context._module.ConstantBool(true));
return;
}
context.StoreS64(106, context._module.Constant64(context._ulongType, 0));
context.StoreS64(126, context._module.Constant64(context._ulongType, 1));
}
public uint BooleanToWaveMask(uint condition)
{
var context = _context;
if (context._subgroupInvocationIdInput == 0)
{
return context._module.AddInstruction(
SpirvOp.Select,
context._ulongType,
condition,
CurrentLaneBit(),
context._module.Constant64(context._ulongType, 0));
}
var ballot = context._module.AddInstruction(
SpirvOp.GroupNonUniformBallot,
context._uvec4Type,
context.UInt(3),
condition);
var low = context._module.AddInstruction(
SpirvOp.CompositeExtract,
context._uintType,
ballot,
0);
return context._module.AddInstruction(SpirvOp.UConvert, context._ulongType, low);
}
public uint SubgroupAny(uint condition) =>
_context._subgroupInvocationIdInput == 0
? condition
: _context._module.AddInstruction(
SpirvOp.GroupNonUniformAny,
_context._boolType,
_context.UInt(3),
condition);
public uint CurrentLaneBit()
{
var context = _context;
if (context._subgroupInvocationIdInput == 0)
{
return context._module.Constant64(context._ulongType, 1);
}
var shifted = context.ShiftLeftLogical64(
context._module.Constant64(context._ulongType, 1),
context._module.AddInstruction(
SpirvOp.UConvert,
context._ulongType,
context.GuestWaveLane()));
return context._module.AddInstruction(
SpirvOp.Select,
context._ulongType,
context.IsCurrentLaneInRdnaWave(),
shifted,
context._module.Constant64(context._ulongType, 0));
}
public uint BroadcastFirstActive(uint value)
{
var context = _context;
if (context._subgroupInvocationIdInput == 0)
{
return value;
}
var activeLanes = context._module.AddInstruction(
SpirvOp.GroupNonUniformBallot,
context._uvec4Type,
context.UInt(3),
context.Load(context._boolType, context._exec));
var activeLow = context._module.AddInstruction(
SpirvOp.CompositeExtract,
context._uintType,
activeLanes,
0);
var firstActiveLane = context.Ext(73, context._uintType, activeLow);
return context._module.AddInstruction(
SpirvOp.GroupNonUniformBroadcast,
context._uintType,
context.UInt(3),
value,
firstActiveLane);
}
public uint BroadcastLane(uint value, uint lane) =>
_context._subgroupInvocationIdInput == 0
? value
: _context._module.AddInstruction(
SpirvOp.GroupNonUniformBroadcast,
_context._uintType,
_context.UInt(3),
value,
lane);
public uint BroadcastValue(uint writer, uint value, uint lane) =>
_context.ShuffleLane(value, lane);
public uint WaveMaskAny(uint register, uint laneActive) =>
SubgroupAny(_context.Load(_context._boolType, laneActive));
public uint CurrentExecMask() =>
BooleanToWaveMask(_context.Load(_context._boolType, _context._exec));
public uint NormalizeExecMask(uint mask) =>
_context.BitwiseAnd64(mask, _context._module.Constant64(_context._ulongType, 0xFFFF_FFFFUL));
public void StoreWaveMask(uint register, uint condition) =>
_context.StoreS(register, _context.Narrow(BooleanToWaveMask(condition)));
}
}
}

View File

@ -0,0 +1,160 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
namespace SharpEmu.ShaderCompiler.Vulkan;
public static partial class Gen5SpirvTranslator
{
private sealed partial class CompilationContext
{
// One guest wave is one 64-lane host subgroup (ballot.x = lanes 0..31, ballot.y = lanes
// 32..63). It has no workgroup exchange, LDS reservation, half-mask plan or
// cross-subgroup barrier state.
private sealed class NativeWave64Lowering : IWaveLowering
{
private readonly CompilationContext _context;
public NativeWave64Lowering(CompilationContext context)
{
_context = context;
}
public bool RequiresCrossSubgroupSynchronization => false;
public uint ReserveLdsDwords(uint guestDwordCount) => guestDwordCount;
public void DeclareResources() { }
public void DeclareFunctionResources() { }
public void BeginBlock() { }
public void BeforeInstruction(Gen5ShaderInstruction instruction) { }
public void EndBlock() { }
public void InitializeMasks()
{
var context = _context;
if (context._subgroupInvocationIdInput != 0)
{
StoreWaveMask(106, context._module.ConstantBool(false));
StoreWaveMask(126, context._module.ConstantBool(true));
}
else
{
context.StoreS64(106, context._module.Constant64(context._ulongType, 0));
context.StoreS64(126, context._module.Constant64(context._ulongType, 1));
}
}
public uint BooleanToWaveMask(uint condition)
{
var context = _context;
if (context._subgroupInvocationIdInput == 0)
{
return context._module.AddInstruction(
SpirvOp.Select,
context._ulongType,
condition,
CurrentLaneBit(),
context._module.Constant64(context._ulongType, 0));
}
return BallotToMask(condition);
}
// The host subgroup is at least 64 wide: lanes 0..31 are ballot.x and lanes 32..63 ballot.y.
private uint BallotToMask(uint condition)
{
var context = _context;
var ballot = context._module.AddInstruction(
SpirvOp.GroupNonUniformBallot,
context._uvec4Type,
context.UInt(3),
condition);
var low = context._module.AddInstruction(SpirvOp.CompositeExtract, context._uintType, ballot, 0);
var high = context._module.AddInstruction(SpirvOp.CompositeExtract, context._uintType, ballot, 1);
return context.BitwiseOr64(
context._module.AddInstruction(SpirvOp.UConvert, context._ulongType, low),
context.ShiftLeftLogical64(
context._module.AddInstruction(SpirvOp.UConvert, context._ulongType, high),
context._module.Constant64(context._ulongType, 32)));
}
public uint SubgroupAny(uint condition) =>
_context._subgroupInvocationIdInput == 0
? condition
: _context._module.AddInstruction(
SpirvOp.GroupNonUniformAny,
_context._boolType,
_context.UInt(3),
condition);
public uint CurrentLaneBit()
{
var context = _context;
if (context._subgroupInvocationIdInput == 0)
{
return context._module.Constant64(context._ulongType, 1);
}
var shifted = context.ShiftLeftLogical64(
context._module.Constant64(context._ulongType, 1),
context._module.AddInstruction(
SpirvOp.UConvert,
context._ulongType,
context.GuestWaveLane()));
return shifted;
}
public uint BroadcastFirstActive(uint value)
{
var context = _context;
if (context._subgroupInvocationIdInput == 0)
{
return value;
}
var activeLanes = BallotToMask(context.Load(context._boolType, context._exec));
var activeLow = context._module.AddInstruction(SpirvOp.UConvert, context._uintType, activeLanes);
var activeHigh = context._module.AddInstruction(
SpirvOp.UConvert,
context._uintType,
context.ShiftRightLogical64(activeLanes, context._module.Constant64(context._ulongType, 32)));
var firstActiveLane = context._module.AddInstruction(
SpirvOp.Select,
context._uintType,
context._module.AddInstruction(SpirvOp.IEqual, context._boolType, activeLow, context.UInt(0)),
context.IAdd(context.Ext(73, context._uintType, activeHigh), context.UInt(32)),
context.Ext(73, context._uintType, activeLow));
return context._module.AddInstruction(
SpirvOp.GroupNonUniformBroadcast,
context._uintType,
context.UInt(3),
value,
firstActiveLane);
}
public uint BroadcastLane(uint value, uint lane) =>
_context._subgroupInvocationIdInput == 0
? value
: _context._module.AddInstruction(
SpirvOp.GroupNonUniformBroadcast,
_context._uintType,
_context.UInt(3),
value,
lane);
public uint BroadcastValue(uint writer, uint value, uint lane) =>
_context.ShuffleLane(value, lane);
public uint WaveMaskAny(uint register, uint laneActive) =>
SubgroupAny(_context.Load(_context._boolType, laneActive));
public uint CurrentExecMask() =>
BooleanToWaveMask(_context.Load(_context._boolType, _context._exec));
public uint NormalizeExecMask(uint mask) => mask;
public void StoreWaveMask(uint register, uint condition) =>
_context.StoreS64(register, BooleanToWaveMask(condition));
}
}
}

View File

@ -116,7 +116,23 @@ public static partial class Gen5SpirvTranslator
_localSizeY = Math.Max(request.LocalSizeY, 1);
_localSizeZ = Math.Max(request.LocalSizeZ, 1);
_physicalAxisOfLogical = ComputeWorkgroupAxisOrder(_localSizeX, _localSizeY, _localSizeZ);
_emulateWave64 = _stage == Gen5SpirvStage.Compute && _waveLaneCount == 64 && (ulong)_localSizeX * _localSizeY * _localSizeZ == 64;
// A wave64 runs as one 64-lane host subgroup when the host pins it to one
// (HostWave64Supported). Hosts without 64-lane subgroups bridge the two 32-lane
// halves through workgroup memory.
if (_waveLaneCount == 32)
{
_waveLowering = new NativeWave32Lowering(this);
}
else if (request.HostWave64Supported)
{
_waveLowering = new NativeWave64Lowering(this);
_guestLaneFromSubgroup = true;
}
else
{
_waveLowering = new EmulatedWave64Lowering(this);
}
_requiredVertexOutputCount = request.RequiredVertexOutputCount;
_pixelInputEnable = request.PixelInputEnable;
_pixelInputAddress = request.PixelInputAddress;
@ -1909,21 +1925,7 @@ public static partial class Gen5SpirvTranslator
});
var firstValue = LoadV(destination);
uint broadcast;
if (_emulateWave64)
{
broadcast = ExchangeWave64Value(isFirstActive, firstValue);
}
else if (_stage == Gen5SpirvStage.Compute || _enableGraphicsSubgroupOperations)
{
// Every active lane receives the first lane's old counter, in graphics stages too.
broadcast = _module.AddInstruction(SpirvOp.GroupNonUniformShuffle, _uintType, UInt(3), firstValue, firstLane);
}
else
{
// Without subgroup operations a graphics invocation is its own wave of one lane.
broadcast = firstValue;
}
var broadcast = BroadcastWaveValue(isFirstActive, firstValue, firstLane);
var validResult = LogicalAnd(inBounds, IsNotZero64(activeMask));
StoreV(destination, _module.AddInstruction(SpirvOp.Select, _uintType, validResult, broadcast, UInt(0)));

View File

@ -0,0 +1,34 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
namespace SharpEmu.ShaderCompiler.Vulkan;
public static partial class Gen5SpirvTranslator
{
private sealed partial class CompilationContext
{
private interface IWaveLowering
{
bool RequiresCrossSubgroupSynchronization { get; }
uint ReserveLdsDwords(uint guestDwordCount);
void DeclareResources();
void DeclareFunctionResources();
void InitializeMasks();
void BeginBlock();
void BeforeInstruction(Gen5ShaderInstruction instruction);
void EndBlock();
uint BooleanToWaveMask(uint condition);
uint SubgroupAny(uint condition);
uint CurrentLaneBit();
uint BroadcastFirstActive(uint value);
uint BroadcastLane(uint value, uint lane);
uint BroadcastValue(uint writer, uint value, uint lane);
uint WaveMaskAny(uint register, uint laneActive);
uint CurrentExecMask();
uint NormalizeExecMask(uint mask);
void StoreWaveMask(uint register, uint condition);
}
}
}

View File

@ -17,8 +17,6 @@ public static partial class Gen5SpirvTranslator
// so a smaller array is safe.
private const uint PrivateLdsDwordCount = 2048;
private const uint RdnaWaveLaneCount = 32;
private const uint Wave64ExchangeSlotCount = 4;
private const uint Wave64ExchangeDwordCount = Wave64ExchangeSlotCount * 2;
internal static SpirvImageFormat DecodeStorageImageFormat(
uint dataFormat,
@ -71,7 +69,8 @@ public static partial class Gen5SpirvTranslator
private readonly bool _usesPixelValidMask;
private readonly bool _enableGraphicsSubgroupOperations;
private readonly uint _waveLaneCount;
private readonly bool _emulateWave64;
private readonly IWaveLowering _waveLowering;
private bool _guestLaneFromSubgroup;
// Safety valve for the PC-dispatcher loop. Each iteration executes one
// GCN basic block; a correctly-translated shader always reaches its
@ -195,12 +194,6 @@ public static partial class Gen5SpirvTranslator
private uint _workGroupIdInput;
private uint _pushConstantUintPointer;
private uint _subgroupInvocationIdInput;
private uint _wave64Exchange;
private uint _wave64ExchangeElementPointer;
private uint _wave64ExchangeOffset;
private uint _wave64ExchangeParity;
private Ir.Gen5Wave64HalfMaskPlan? _halfMaskPlan;
private uint? _emittingPc;
private uint _glsl;
private enum ImageComponentKind
@ -421,8 +414,8 @@ public static partial class Gen5SpirvTranslator
var active = Load(_boolType, _programActive);
if (_maxDispatcherSteps > 0)
{
var steps = IAdd(Load(_uintType, _iterationGuard), UInt(1));
Store(_iterationGuard, steps);
var steps = IAdd(Load(_uintType, IterationGuard()), UInt(1));
Store(IterationGuard(), steps);
var withinLimit = _module.AddInstruction(
SpirvOp.ULessThan,
_boolType,
@ -625,7 +618,13 @@ public static partial class Gen5SpirvTranslator
_scc = Flag(false);
_vcc = Flag(false);
_exec = Flag(true);
_reachedPixelExport = Flag(false);
// Only the title-shader trace reads it; elsewhere it would be a write-only Private
// global, and AMD's driver crashes linking stages that carry an unused one.
if (TraceTitleShaderState)
{
_reachedPixelExport = Flag(false);
}
if (_usesPixelValidMask)
{
_pixelValidMaskActive = Flag(true);
@ -635,22 +634,7 @@ public static partial class Gen5SpirvTranslator
SpirvStorageClass.Private,
_module.Constant(_uintType, 0));
_programActive = Flag(true);
if (UsesWave64Exchange())
{
_wave64ExchangeParity = _module.AddGlobalVariable(_privateUintPointer, SpirvStorageClass.Private, UInt(0));
_interfaces.Add(_wave64ExchangeParity);
_module.AddName(_wave64ExchangeParity, "wave64ExchangeParity");
}
if (_maxDispatcherSteps > 0)
{
_iterationGuard = _module.AddGlobalVariable(
_privateUintPointer,
SpirvStorageClass.Private,
_module.Constant(_uintType, 0));
_interfaces.Add(_iterationGuard);
_module.AddName(_iterationGuard, "pcGuard");
}
_waveLowering.DeclareFunctionResources();
foreach (var slot in FindLaneSpillSlots())
{
var variable = _module.AddGlobalVariable(_privateUintPointer, SpirvStorageClass.Private, _module.Constant(_uintType, 0));
@ -668,7 +652,11 @@ public static partial class Gen5SpirvTranslator
_interfaces.Add(_scc);
_interfaces.Add(_vcc);
_interfaces.Add(_exec);
_interfaces.Add(_reachedPixelExport);
if (_reachedPixelExport != 0)
{
_interfaces.Add(_reachedPixelExport);
}
if (_pixelValidMaskActive != 0)
{
_interfaces.Add(_pixelValidMaskActive);
@ -681,7 +669,7 @@ public static partial class Gen5SpirvTranslator
DeclareLayoutBindings();
DeclareScratch();
DeclareLds();
DeclareWave64Scratch();
_waveLowering.DeclareResources();
DeclareStageInterface();
return;
}
@ -709,43 +697,6 @@ public static partial class Gen5SpirvTranslator
_module.AddName(_scratch, "scratch");
}
private void DeclareWave64Scratch()
{
if (!UsesWave64Exchange())
{
return;
}
// Metal exposes 32 KiB of threadgroup memory on the Apple GPUs we
// target. Some PS5 compute shaders legitimately request all of it,
// so allocating another workgroup variable for the wave64 bridge
// makes pipeline creation fail. Reuse the final dwords of the
// existing LDS allocation in that case. The translator already
// bounds guest LDS accesses to this fixed allocation; keeping the
// bridge inside it preserves the host limit and still provides the
// cross-subgroup rendezvous needed to model one 64-lane guest wave.
if (_lds != 0)
{
_wave64Exchange = _lds;
_wave64ExchangeElementPointer = _ldsElementPointer;
_wave64ExchangeOffset = _ldsDwordMask + 1 < LdsDwordCount
? _ldsDwordMask + 1
: LdsDwordCount - Wave64ExchangeDwordCount;
return;
}
var exchangeArrayType = _module.TypeArray(_uintType, Wave64ExchangeDwordCount);
_wave64ExchangeElementPointer = _module.TypePointer(SpirvStorageClass.Workgroup, _uintType);
_wave64Exchange = _module.AddGlobalVariable(
_module.TypePointer(SpirvStorageClass.Workgroup, exchangeArrayType),
SpirvStorageClass.Workgroup);
_wave64ExchangeOffset = 0;
_module.AddName(_wave64Exchange, "wave64Exchange");
_interfaces.Add(_wave64Exchange);
}
private bool UsesWave64Exchange() => _emulateWave64 && UsesSubgroupOperations();
private uint ComputeLdsGuestDwordCount()
{
var declared = _request.LocalDataShareDwords;
@ -776,9 +727,7 @@ public static partial class Gen5SpirvTranslator
? ComputeLdsGuestDwordCount()
: PrivateLdsDwordCount;
_ldsDwordMask = dwordCount - 1;
var arrayDwordCount = UsesWave64Exchange() && dwordCount < LdsDwordCount
? dwordCount + Wave64ExchangeDwordCount
: dwordCount;
var arrayDwordCount = _waveLowering.ReserveLdsDwords(dwordCount);
var ldsArrayType = _module.TypeArray(_uintType, arrayDwordCount);
var ldsPointer = _module.TypePointer(storageClass, ldsArrayType);
@ -1176,32 +1125,16 @@ public static partial class Gen5SpirvTranslator
}
Store(_scc, _module.ConstantBool(false));
Store(_reachedPixelExport, _module.ConstantBool(false));
if (_reachedPixelExport != 0)
{
Store(_reachedPixelExport, _module.ConstantBool(false));
}
if (_pixelValidMaskActive != 0)
{
Store(_pixelValidMaskActive, _module.ConstantBool(true));
}
if (_subgroupInvocationIdInput != 0 && _emulateWave64)
{
StoreS64(106, BothHalvesOfOwnBallot(_module.ConstantBool(false)));
StoreS64(126, BothHalvesOfOwnBallot(_module.ConstantBool(true)));
}
else if (_subgroupInvocationIdInput != 0)
{
StoreWaveMask(106, _module.ConstantBool(false));
StoreWaveMask(126, _module.ConstantBool(true));
}
else
{
// Graphics stages emulate one logical wave lane. Keep the
// guest-visible VCC/EXEC scalar pairs synchronized with the
// internal booleans: shaders commonly save EXEC from s126:s127
// and restore it after divergent work. Initializing only _exec
// left those registers at zero, so the first restore disabled
// every fragment before its color export.
StoreS64(106, _module.Constant64(_ulongType, 0));
StoreS64(126, _module.Constant64(_ulongType, 1));
}
_waveLowering.InitializeMasks();
// Primitive (NGG) vertex programs rebuild EXEC from the merged wave info in s3:
// vertex count in bits 0-7, primitive count in bits 8-15. Left at zero, EXEC
@ -1440,8 +1373,6 @@ public static partial class Gen5SpirvTranslator
StoreS(register.Value, value);
}
private enum SharedMemoryPhase { None, Read, Write }
private bool TryEmitBlock(
IReadOnlyList<ShaderBlock> blocks,
int blockIndex,
@ -1463,51 +1394,18 @@ public static partial class Gen5SpirvTranslator
{
error = string.Empty;
var block = blocks[blockIndex];
var halfMaskPlan = HalfMaskPlan();
// One guest wave can span two host subgroups. Keep its shared-memory phases ordered.
// The half-mask plan inserts its own barriers; other wave64 programs also
// need LDS phase ordering when reads and writes span basic blocks.
var synchronizeSharedMemory = _emulateWave64 && halfMaskPlan is null;
var sharedMemoryPhase = SharedMemoryPhase.None;
_waveLowering.BeginBlock();
for (var index = block.StartIndex; index < block.EndIndex; index++)
{
var instruction = _request.Program.Instructions[index];
if (halfMaskPlan is not null)
{
if (halfMaskPlan.ExactPairsBefore.TryGetValue(instruction.Pc, out var exactPairs))
{
EmitExactWaveMasks(exactPairs);
}
if (halfMaskPlan.SharedMemoryBarriersBefore.Contains(instruction.Pc))
{
EmitWave64Barrier();
}
}
_waveLowering.BeforeInstruction(instruction);
if (IsBranch(instruction.Opcode) || instruction.Opcode == "SEndpgm")
{
continue;
}
if (synchronizeSharedMemory)
{
var nextPhase = instruction.Control is Gen5DataShareControl { Gds: false }
? instruction.Opcode.StartsWith("DsRead", StringComparison.Ordinal) ? SharedMemoryPhase.Read
: instruction.Opcode.StartsWith("DsWrite", StringComparison.Ordinal) ? SharedMemoryPhase.Write : SharedMemoryPhase.None
: SharedMemoryPhase.None;
if (instruction.Opcode == "SBarrier") sharedMemoryPhase = SharedMemoryPhase.None;
if (nextPhase != SharedMemoryPhase.None)
{
if (sharedMemoryPhase != SharedMemoryPhase.None && sharedMemoryPhase != nextPhase) EmitWave64Barrier();
sharedMemoryPhase = nextPhase;
}
}
_execKnownFull = IsExecKnownFull(instruction.Pc);
_emittingPc = instruction.Pc;
var emitted = TryEmitInstruction(instruction, out error);
_emittingPc = null;
_execKnownFull = false;
if (!emitted)
{
@ -1521,7 +1419,7 @@ public static partial class Gen5SpirvTranslator
CapturePixelExec(instruction);
}
if (synchronizeSharedMemory && sharedMemoryPhase != SharedMemoryPhase.None) EmitWave64Barrier();
_waveLowering.EndBlock();
return true;
}
@ -1908,8 +1806,8 @@ public static partial class Gen5SpirvTranslator
if (_maxDispatcherSteps > 0)
{
var steps = IAdd(Load(_uintType, _iterationGuard), UInt(1));
Store(_iterationGuard, steps);
var steps = IAdd(Load(_uintType, IterationGuard()), UInt(1));
Store(IterationGuard(), steps);
var withinLimit = _module.AddInstruction(SpirvOp.ULessThan, _boolType, steps, UInt((uint)_maxDispatcherSteps));
again = _module.AddInstruction(SpirvOp.LogicalAnd, _boolType, again, withinLimit);
}
@ -2063,8 +1961,8 @@ public static partial class Gen5SpirvTranslator
{
// The dispatcher's safety valve, counted per loop iteration: a mistranslated
// exit ends the invocation instead of wedging the GPU queue.
var steps = IAdd(Load(_uintType, _iterationGuard), UInt(1));
Store(_iterationGuard, steps);
var steps = IAdd(Load(_uintType, IterationGuard()), UInt(1));
Store(IterationGuard(), steps);
var withinLimit = _module.AddInstruction(SpirvOp.ULessThan, _boolType, steps, UInt((uint)_maxDispatcherSteps));
again = _module.AddInstruction(SpirvOp.LogicalAnd, _boolType, again, withinLimit);
}
@ -2079,7 +1977,7 @@ public static partial class Gen5SpirvTranslator
// the block was skipped. Preserve that diagnostic safety budget.
if (_maxDispatcherSteps > 0)
{
Store(_iterationGuard, IAdd(Load(_uintType, _iterationGuard), UInt(1)));
Store(IterationGuard(), IAdd(Load(_uintType, IterationGuard()), UInt(1)));
}
_module.AddStatement(SpirvOp.Branch, entryMerge);
_module.AddLabel(entryMerge);
@ -2657,15 +2555,7 @@ public static partial class Gen5SpirvTranslator
});
var firstValue = LoadV(destination);
uint broadcast;
if (_emulateWave64)
{
broadcast = ExchangeWave64Value(isFirstActive, firstValue);
}
else
{
broadcast = ShuffleLane(firstValue, firstLane);
}
var broadcast = BroadcastWaveValue(isFirstActive, firstValue, firstLane);
var validResult = _module.AddInstruction(
SpirvOp.LogicalAnd,
@ -6347,7 +6237,10 @@ public static partial class Gen5SpirvTranslator
return true;
}
Store(_reachedPixelExport, _module.ConstantBool(true));
if (_reachedPixelExport != 0)
{
Store(_reachedPixelExport, _module.ConstantBool(true));
}
var values = new uint[4];
for (var component = 0; component < 4; component++)
@ -7469,6 +7362,13 @@ public static partial class Gen5SpirvTranslator
UInt(0),
dwordAddress);
private uint DeclareFunctionUint(uint initializer)
{
var variable = _module.AddGlobalVariable(_privateUintPointer, SpirvStorageClass.Private, initializer);
_interfaces.Add(variable);
return variable;
}
// Without subgroup operations each invocation is a one-lane wave, so a V_WRITELANE to
// lane N > 0 used to be dropped and V_READLANE returned lane 0. Compilers spill SGPRs
// (EXEC included) to VGPR lanes like this; restoring the wrong EXEC made waterfall
@ -7476,7 +7376,7 @@ public static partial class Gen5SpirvTranslator
private List<(uint Register, uint Lane)> FindLaneSpillSlots()
{
var slots = new List<(uint Register, uint Lane)>();
if (_emulateWave64)
if (_stage == Gen5SpirvStage.Compute && _waveLowering is EmulatedWave64Lowering)
{
return slots;
}
@ -7686,6 +7586,24 @@ public static partial class Gen5SpirvTranslator
// Declared on first use when Private. AMD's compiler keeps an unused 4 KiB private
// array as a named .bss global: two stages then fail to link, and the driver copies
// the NOBITS section as file data and reads past the end of the ELF.
private uint IterationGuard()
{
if (_iterationGuard == 0)
{
_iterationGuard = _module.AddGlobalVariable(
_privateUintPointer,
SpirvStorageClass.Private,
_module.Constant(_uintType, 0));
_interfaces.Add(_iterationGuard);
_module.AddName(_iterationGuard, "pcGuard");
}
return _iterationGuard;
}
private static readonly bool TraceTitleShaderState =
Environment.GetEnvironmentVariable("SHARPEMU_TRACE_TITLE_SHADER_STATE") == "1";
private uint PackedHalfRegisters()
{
if (_packedHalfRegisters != 0)
@ -7956,18 +7874,19 @@ public static partial class Gen5SpirvTranslator
_module.AddInstruction(SpirvOp.LogicalNot, _boolType, value);
private uint SubgroupAny(uint condition) =>
_subgroupInvocationIdInput == 0
? condition
: _emulateWave64
? IsNotZero64(BooleanToWaveMask(condition))
: _module.AddInstruction(
SpirvOp.GroupNonUniformAny,
_boolType,
UInt(3),
condition);
_waveLowering.SubgroupAny(condition);
private uint GuestWaveLane()
{
// A native wave64 is one host subgroup: its lane is the subgroup lane, the same one
// ballot and shuffle address, whatever order the driver gave the invocations.
if (_waveLaneCount == 64 && _guestLaneFromSubgroup && _subgroupInvocationIdInput != 0)
{
return BitwiseAnd(
Load(_uintType, _subgroupInvocationIdInput),
UInt(63));
}
if (_waveLaneCount == 64 && _localInvocationIndexInput != 0)
{
return BitwiseAnd(
@ -8017,29 +7936,8 @@ public static partial class Gen5SpirvTranslator
? value
: _module.AddInstruction(SpirvOp.GroupNonUniformShuffle, _uintType, UInt(3), value, lane);
private uint CurrentLaneBit()
{
if (_subgroupInvocationIdInput == 0)
{
return _module.Constant64(_ulongType, 1);
}
var maskedLane = GuestWaveLane();
var shifted = ShiftLeftLogical64(
_module.Constant64(_ulongType, 1),
_module.AddInstruction(
SpirvOp.UConvert,
_ulongType,
maskedLane));
return _emulateWave64
? shifted
: _module.AddInstruction(
SpirvOp.Select,
_ulongType,
IsCurrentLaneInRdnaWave(),
shifted,
_module.Constant64(_ulongType, 0));
}
private uint CurrentLaneBit() =>
_waveLowering.CurrentLaneBit();
private uint IsCurrentLaneInRdnaWave() =>
_module.AddInstruction(
@ -8056,193 +7954,11 @@ public static partial class Gen5SpirvTranslator
CurrentLaneBit(),
_module.Constant64(_ulongType, 0));
private uint BooleanToWaveMask(uint condition)
{
if (_subgroupInvocationIdInput == 0)
{
return BooleanToLaneMask(condition);
}
if (_emulateWave64)
{
var lane = GuestWaveLane();
var owned = OwnHalfBallot(condition);
var exchange = BeginWave64Exchange();
EmitConditional(IsHalfWaveLeader(lane), () =>
Store(Wave64ExchangePointer(exchange, ShiftRightLogical(lane, UInt(5))), owned));
EmitWave64Barrier();
return Pair64(
Load(_uintType, Wave64ExchangePointer(exchange, UInt(0))),
Load(_uintType, Wave64ExchangePointer(exchange, UInt(1))));
}
var ballot = _module.AddInstruction(
SpirvOp.GroupNonUniformBallot,
_uvec4Type,
UInt(3),
condition);
var low = _module.AddInstruction(
SpirvOp.CompositeExtract,
_uintType,
ballot,
0);
var widened = _module.AddInstruction(SpirvOp.UConvert, _ulongType, low);
if (_waveLaneCount != 64)
{
return widened;
}
return _module.AddInstruction(
SpirvOp.Select,
_ulongType,
_module.AddInstruction(
SpirvOp.UGreaterThanEqual,
_boolType,
GuestWaveLane(),
UInt(32)),
ShiftLeftLogical64(
widened,
_module.Constant64(_ulongType, 32)),
widened);
}
private uint OwnHalfBallot(uint condition)
{
var ballot = _module.AddInstruction(
SpirvOp.GroupNonUniformBallot,
_uvec4Type,
UInt(3),
condition);
var component = ShiftRightLogical(Load(_uintType, _subgroupInvocationIdInput), UInt(5));
// Avoid dynamic extraction of subgroup ballot words: on the NVIDIA
// device tests it produced zero masks. Select the same word explicitly.
var result = _module.AddInstruction(SpirvOp.CompositeExtract, _uintType, ballot, 0);
for (uint index = 1; index < 4; index++)
{
var word = _module.AddInstruction(SpirvOp.CompositeExtract, _uintType, ballot, index);
result = _module.AddInstruction(SpirvOp.Select, _uintType,
_module.AddInstruction(SpirvOp.IEqual, _boolType, component, UInt(index)), word, result);
}
return result;
}
private uint BooleanToHalfWaveMask(uint condition)
{
var owned = _module.AddInstruction(SpirvOp.UConvert, _ulongType, OwnHalfBallot(condition));
return _module.AddInstruction(
SpirvOp.Select,
_ulongType,
_module.AddInstruction(SpirvOp.UGreaterThanEqual, _boolType, GuestWaveLane(), UInt(32)),
ShiftLeftLogical64(owned, _module.Constant64(_ulongType, 32)),
owned);
}
private uint BothHalvesOfOwnBallot(uint condition)
{
var owned = _module.AddInstruction(SpirvOp.UConvert, _ulongType, OwnHalfBallot(condition));
return BitwiseOr64(owned, ShiftLeftLogical64(owned, _module.Constant64(_ulongType, 32)));
}
private uint IsHalfWaveLeader(uint lane) =>
_module.AddInstruction(SpirvOp.IEqual, _boolType, BitwiseAnd(lane, UInt(31)), UInt(0));
private uint BeginWave64Exchange()
{
var parity = Load(_uintType, _wave64ExchangeParity);
Store(_wave64ExchangeParity, BitwiseXor(parity, UInt(1)));
return IAdd(
UInt(_wave64ExchangeOffset),
_module.AddInstruction(SpirvOp.IMul, _uintType, parity, UInt(Wave64ExchangeSlotCount)));
}
private uint Wave64ExchangePointer(uint exchange, uint slot) =>
_module.AddInstruction(
SpirvOp.AccessChain,
_wave64ExchangeElementPointer,
_wave64Exchange,
IAdd(exchange, slot));
private uint ExchangeWave64Value(uint writer, uint value)
{
var exchange = BeginWave64Exchange();
EmitConditional(writer, () => Store(Wave64ExchangePointer(exchange, UInt(0)), value));
EmitWave64Barrier();
return Load(_uintType, Wave64ExchangePointer(exchange, UInt(0)));
}
private void EmitExactWaveMasks(IReadOnlyList<uint> pairs)
{
var lane = GuestWaveLane();
var upperHalf = ShiftRightLogical(lane, UInt(5));
var lowerHalf = _module.AddInstruction(SpirvOp.IEqual, _boolType, upperHalf, UInt(0));
const int pairsPerExchange = (int)Wave64ExchangeSlotCount / 2;
for (var start = 0; start < pairs.Count; start += pairsPerExchange)
{
var count = Math.Min(pairsPerExchange, pairs.Count - start);
var owned = new uint[count];
for (var index = 0; index < count; index++)
{
var pair = pairs[start + index];
owned[index] = _module.AddInstruction(SpirvOp.Select, _uintType, lowerHalf, LoadS(pair), LoadS(pair + 1));
}
var exchange = BeginWave64Exchange();
EmitConditional(IsHalfWaveLeader(lane), () =>
{
for (var index = 0; index < count; index++)
{
Store(Wave64ExchangePointer(exchange, IAdd(UInt((uint)index * 2), upperHalf)), owned[index]);
}
});
EmitWave64Barrier();
for (var index = 0; index < count; index++)
{
var pair = pairs[start + index];
StoreS(pair, Load(_uintType, Wave64ExchangePointer(exchange, UInt((uint)index * 2))));
StoreS(pair + 1, Load(_uintType, Wave64ExchangePointer(exchange, UInt((uint)index * 2 + 1))));
}
}
}
private uint BooleanToWaveMask(uint condition) =>
_waveLowering.BooleanToWaveMask(condition);
private uint WaveMaskAny(uint register, uint laneActive) =>
_emulateWave64 && _subgroupInvocationIdInput != 0
? IsNotZero64(LoadS64(register))
: SubgroupAny(Load(_boolType, laneActive));
private static readonly bool HalfWaveMasks = !string.Equals(
Environment.GetEnvironmentVariable("SHARPEMU_WAVE64_HALF_MASKS"), "0", StringComparison.Ordinal);
private bool _halfMaskPlanBuilt;
private Ir.Gen5Wave64HalfMaskPlan? HalfMaskPlan()
{
if (_halfMaskPlanBuilt)
{
return _halfMaskPlan;
}
_halfMaskPlanBuilt = true;
if (HalfWaveMasks && UsesWave64Exchange() && _subgroupInvocationIdInput != 0)
{
var sharedFlatPcs = _request.Memory.Entries
.Where(static memory => memory.AddressSpace is FlatAddressSpace.Shared or FlatAddressSpace.SharedOrPrivate)
.Select(static memory => memory.Pc)
.ToHashSet();
_halfMaskPlan = Ir.Gen5Wave64HalfMaskAnalysis.Analyze(_request.Program, sharedFlatPcs);
}
return _halfMaskPlan;
}
private void EmitWave64Barrier()
{
var workgroup = UInt(2);
_module.AddStatement(
SpirvOp.ControlBarrier,
workgroup,
workgroup,
UInt(0x108));
}
_waveLowering.WaveMaskAny(register, laneActive);
// A wave-mask SGPR (VCC/EXEC) consumed as a per-lane predicate — the
// condition of VCndmask, a VCC/EXEC branch, or the derived _vcc/_exec
@ -8269,27 +7985,8 @@ public static partial class Gen5SpirvTranslator
// In wave32 a lane mask fills its register alone and the next one keeps its value.
// That includes VCC and EXEC: compilers use VCC_HI (s107) as an ordinary SGPR.
private void StoreWaveMask(uint register, uint condition)
{
if (_waveLaneCount == 32)
{
StoreS(register, Narrow(BooleanToWaveMask(condition)));
return;
}
if (_emulateWave64 &&
_subgroupInvocationIdInput != 0 &&
_emittingPc is { } pc &&
HalfMaskPlan() is { } plan &&
plan.HalfMaskWrites.TryGetValue(pc, out var halfRegister) &&
halfRegister == register)
{
StoreS64(register, BooleanToHalfWaveMask(condition));
return;
}
StoreS64(register, BooleanToWaveMask(condition));
}
private void StoreWaveMask(uint register, uint condition) =>
_waveLowering.StoreWaveMask(register, condition);
private void EmitExecConditional(Action emit)
{

View File

@ -9,8 +9,18 @@ public static class Gen5PixelInputMapping
ReadOnlySpan<uint> controls,
ReadOnlySpan<uint> activeInputs)
{
Span<bool> usedLocations = stackalloc bool[32];
var locations = new uint[activeInputs.Length];
ResolveLocations(controls, activeInputs, locations);
return locations;
}
// Fills `locations` (one slot per active input) without allocating.
public static void ResolveLocations(
ReadOnlySpan<uint> controls,
ReadOnlySpan<uint> activeInputs,
Span<uint> locations)
{
Span<bool> usedLocations = stackalloc bool[32];
for (var index = 0; index < activeInputs.Length; index++)
{
@ -39,7 +49,5 @@ public static class Gen5PixelInputMapping
usedLocations[(int)location] = true;
}
}
return locations;
}
}

View File

@ -359,7 +359,20 @@ public sealed class BindingLayout : IEquatable<BindingLayout>
public static uint NativeBindingIndex(ShaderStage stage, DescriptorBindingKind kind) =>
(uint)kind + (stage == ShaderStage.Pixel ? (uint)DescriptorBindingKind.Count : 0);
public DescriptorBinding? Find(DescriptorBindingKind kind) => Descriptors.FirstOrDefault(binding => binding.Kind == kind);
// Called for every bound stage of every draw: an indexed loop allocates no closure or enumerator.
public DescriptorBinding? Find(DescriptorBindingKind kind)
{
var descriptors = Descriptors;
for (var index = 0; index < descriptors.Count; index++)
{
if (descriptors[index].Kind == kind)
{
return descriptors[index];
}
}
return null;
}
// The user-data registers live at program entry: those some path reads before it
// writes them, found by liveness over the control-flow graph, in ascending order.

View File

@ -162,6 +162,8 @@ public sealed class ShaderCompileRequest
public IReadOnlyDictionary<int, uint> WrittenRangeSlotByMemoryIndex { get; }
public uint WaveSize { get; init; } = 32;
// True when the pipeline runs on a 64-lane host subgroup, so one subgroup is one guest Wave64.
public bool HostWave64Supported { get; init; }
public bool EnableExecGuardElision { get; init; } = true;
public uint ScratchDwords { get; init; }
public bool EnableGraphicsSubgroupOperations { get; init; } = true;

View File

@ -0,0 +1,236 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using SharpEmu.Libs.Gpu.GpuCommands;
using SharpEmu.Libs.Gpu.GpuCommands.Registers;
using Xunit;
using static SharpEmu.Libs.Gpu.GpuCommands.Registers.ContextRegisterOffset;
using static SharpEmu.Libs.Gpu.GpuCommands.Registers.ShaderRegisterOffset;
using static SharpEmu.Libs.Gpu.GpuCommands.Registers.UserConfigRegisterOffset;
namespace SharpEmu.Libs.Tests.Gpu.GpuCommands.Registers;
// A snapshot shares the live banks' parts until the live banks write them; it never changes.
public sealed class RegisterBanksSnapshotTests
{
private const ulong PacketAddress = 0x1_0000_1000;
private static RegisterBanks NewBanks() => new(static message => new InvalidOperationException(message));
private static PacketContext Packet(uint opcode, int valueCount) =>
new(PacketHeader.Make((uint)valueCount + 2, opcode), PacketAddress, 0, 100, 100);
private static void WriteContext(RegisterBanks banks, uint offset, params uint[] values) =>
RegisterWriteTable.WriteContextPacket(banks, Packet(PacketOpcode.SetContextRegister, values.Length), offset, values);
private static void WriteShader(RegisterBanks banks, uint offset, params uint[] values) =>
RegisterWriteTable.WriteShaderPacket(banks, Packet(PacketOpcode.SetShaderRegister, values.Length), offset, values);
private static void WriteUserConfig(RegisterBanks banks, uint offset, params uint[] values) =>
RegisterWriteTable.WriteUserConfigPacket(banks, Packet(PacketOpcode.SetUserConfigRegister, values.Length), offset, values);
[Fact]
public void WritesAfterASnapshotLeaveItUnchanged()
{
var banks = NewBanks();
WriteContext(banks, DbDepthControl, 0x0000_0006);
WriteShader(banks, SpiShaderPgmLoPs, 0x1000);
WriteShader(banks, SpiShaderUserDataPs0, 11, 12);
WriteShader(banks, SpiShaderPgmLoEs, 0x2000);
WriteShader(banks, ComputePgmLo, 0x3000);
WriteUserConfig(banks, VgtPrimitiveType, 4);
var snapshot = banks.Snapshot();
WriteContext(banks, DbDepthControl, 0x0000_0000);
WriteShader(banks, SpiShaderPgmLoPs, 0x5000);
WriteShader(banks, SpiShaderUserDataPs0, 21, 22);
WriteShader(banks, SpiShaderPgmLoEs, 0x6000);
WriteShader(banks, ComputePgmLo, 0x7000);
WriteUserConfig(banks, VgtPrimitiveType, 6);
Assert.True(snapshot.Context.DepthTarget.DepthTestEnabled);
Assert.False(banks.Context.DepthTarget.DepthTestEnabled);
Assert.Equal(0x1000ul << 8, snapshot.Shader.Pixel.Address);
Assert.Equal(0x5000ul << 8, banks.Shader.Pixel.Address);
Assert.Equal([11u, 12u], snapshot.Shader.Pixel.UserScalars.Values[..2]);
Assert.Equal([21u, 22u], banks.Shader.Pixel.UserScalars.Values[..2]);
Assert.Equal(0x2000ul << 8, snapshot.Shader.Vertex.ExportAddress);
Assert.Equal(0x6000ul << 8, banks.Shader.Vertex.ExportAddress);
Assert.Equal(0x3000ul << 8, snapshot.Shader.Compute.Address);
Assert.Equal(0x7000ul << 8, banks.Shader.Compute.Address);
Assert.Equal(4u, snapshot.UserConfig.PrimitiveType);
Assert.Equal(6u, banks.UserConfig.PrimitiveType);
}
[Fact]
public void AWriteCopiesOnlyThePartItStoresInto()
{
var banks = NewBanks();
var snapshot = banks.Snapshot();
WriteShader(banks, SpiShaderUserDataPs0, 7);
Assert.NotSame(snapshot.Shader.Pixel, banks.Shader.Pixel);
Assert.Same(snapshot.Shader.Vertex, banks.Shader.Vertex);
Assert.Same(snapshot.Shader.Compute, banks.Shader.Compute);
Assert.Same(snapshot.Context, banks.Context);
Assert.Same(snapshot.UserConfig, banks.UserConfig);
}
[Fact]
public void AUserScalarWriteCopiesOnlyItsSet()
{
var banks = NewBanks();
WriteShader(banks, SpiShaderUserDataGs0, 1);
WriteShader(banks, SpiShaderUserDataHs0, 2);
var snapshot = banks.Snapshot();
WriteShader(banks, SpiShaderUserDataGs0, 3);
WriteShader(banks, SpiShaderPgmLoPs, 0x4000);
Assert.NotSame(snapshot.Shader.Vertex, banks.Shader.Vertex);
Assert.NotSame(snapshot.Shader.Vertex.GeometryUserScalars, banks.Shader.Vertex.GeometryUserScalars);
Assert.Same(snapshot.Shader.Vertex.HullUserScalars, banks.Shader.Vertex.HullUserScalars);
Assert.Equal(1u, snapshot.Shader.Vertex.GeometryUserScalars.Values[0]);
Assert.Equal(3u, banks.Shader.Vertex.GeometryUserScalars.Values[0]);
Assert.NotSame(snapshot.Shader.Pixel, banks.Shader.Pixel);
Assert.Same(snapshot.Shader.Pixel.UserScalars, banks.Shader.Pixel.UserScalars);
// The set copied after the snapshot is the live banks' own: a later write does not copy it again.
var geometry = banks.Shader.Vertex.GeometryUserScalars;
WriteShader(banks, SpiShaderUserDataGs0, 4);
Assert.Same(geometry, banks.Shader.Vertex.GeometryUserScalars);
Assert.Equal(1u, snapshot.Shader.Vertex.GeometryUserScalars.Values[0]);
}
[Fact]
public void ContextStateOperationsAfterASnapshotLeaveItUnchanged()
{
var banks = NewBanks();
WriteContext(banks, DbDepthControl, 0x0000_0006);
var snapshot = banks.Snapshot();
banks.ApplyContextState(ContextStateOperation.PushClear);
WriteContext(banks, DbDepthControl, 0x0000_0000);
banks.ApplyContextState(ContextStateOperation.Pop);
WriteContext(banks, DbDepthControl, 0x0000_0000);
Assert.True(snapshot.Context.DepthTarget.DepthTestEnabled);
Assert.False(banks.Context.DepthTarget.DepthTestEnabled);
}
// Every context register, through the table path and the packet path, against a snapshot
// taken after a first write: the context's nested parts are copied only by their writers.
[Fact]
public void EveryContextRegisterWriteLeavesASnapshotUnchanged()
{
var changed = 0;
for (var offset = 0u; offset < RegisterBankLayout.ContextRegisterCount; offset++)
{
foreach (var packet in new[] { false, true })
{
var banks = NewBanks();
WriteContext(banks, DbDepthControl, 0x0000_0006);
var snapshot = banks.Snapshot();
var before = Describe(snapshot.Context);
foreach (var value in new[] { 0xFFFF_FFFFu, 0x1234_5678u })
{
try
{
if (packet)
WriteContext(banks, offset, value);
else
RegisterWriteTable.WriteContextEntry(banks, offset, value, PacketAddress);
}
catch (InvalidOperationException)
{
// A register whose writer refuses a single value.
}
}
Assert.True(before == Describe(snapshot.Context), $"offset=0x{offset:X} packet={packet}");
if (Describe(banks.Context) != before)
changed++;
}
}
// The description sees the writes: most registers store something.
Assert.True(changed > 400, $"changed={changed}");
}
private static string Describe(object? value)
{
var text = new System.Text.StringBuilder();
Describe(value, text);
return text.ToString();
}
private static void Describe(object? value, System.Text.StringBuilder text)
{
switch (value)
{
case null:
text.Append("null;");
return;
case System.Collections.IDictionary dictionary:
foreach (var key in dictionary.Keys.Cast<object>().OrderBy(key => key))
text.Append(key).Append('=').Append(dictionary[key]).Append(',');
text.Append(';');
return;
case Array array:
text.Append('[');
foreach (var element in array)
Describe(element, text);
text.Append(']');
return;
}
var type = value.GetType();
if (type.IsPrimitive || type.IsEnum || value is string)
{
text.Append(value).Append(';');
return;
}
text.Append('{');
foreach (var field in type.GetFields(System.Reflection.BindingFlags.Instance |
System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic))
{
text.Append(field.Name).Append(':');
Describe(field.GetValue(value), text);
}
text.Append('}');
}
[Fact]
public void AContextWriteCopiesOnlyTheNestedPartsItStoresInto()
{
var banks = NewBanks();
var snapshot = banks.Snapshot();
WriteContext(banks, DbDepthControl, 0x0000_0006);
WriteContext(banks, PaClVteCntl, 0x43F);
Assert.NotSame(snapshot.Context, banks.Context);
Assert.NotSame(snapshot.Context.ScreenViewport, banks.Context.ScreenViewport);
Assert.Same(snapshot.Context.ScreenViewport.Viewports, banks.Context.ScreenViewport.Viewports);
Assert.Same(snapshot.Context.ShaderInterface, banks.Context.ShaderInterface);
Assert.Same(snapshot.Context.ColorTargets, banks.Context.ColorTargets);
Assert.Same(snapshot.Context.UnmodeledTableRegisters, banks.Context.UnmodeledTableRegisters);
}
[Fact]
public void ASecondSnapshotSharesAgainUntilTheNextWrite()
{
var banks = NewBanks();
var first = banks.Snapshot();
WriteContext(banks, DbDepthControl, 0x0000_0006);
var second = banks.Snapshot();
WriteContext(banks, DbDepthControl, 0x0000_0000);
Assert.False(first.Context.DepthTarget.DepthTestEnabled);
Assert.True(second.Context.DepthTarget.DepthTestEnabled);
Assert.False(banks.Context.DepthTarget.DepthTestEnabled);
}
}

View File

@ -99,6 +99,24 @@ public sealed partial class GuestImageCacheTests
harness.Shutdown();
}
// A fill seen before its target leaves its range known as DCC metadata, so the next fill of it
// runs on the host and the range stays CPU-readable.
[Fact]
public void APendingDccFill_MarksItsRangeAsDccMetadata()
{
if (!GatePrerequisites.Ready(_vulkan)) return;
using var fatal = new FatalScope();
using var harness = new CacheHarness(_vulkan);
var address = harness.MapBacked(0x20000, ReadWrite);
var metadata = address + 0x10000;
Assert.False(harness.Images.OverlapsDccMetadata(metadata, 0x100));
Assert.False(harness.Images.TryAbsorbDccFill(metadata, 0x100, 0x00000000u));
Assert.True(harness.Images.OverlapsDccMetadata(metadata + 0x80, 0x10));
Assert.False(harness.Images.OverlapsDccMetadata(metadata + 0x100, 0x10));
}
[Fact]
public void DccFills_RemainGuestProducersUntilDescriptorDiscovery()
{
@ -451,6 +469,35 @@ public sealed partial class GuestImageCacheTests
harness.Shutdown();
}
// The pages inside a filled range are clean after it; a page it shares with another GPU-written
// range keeps that range's state.
[Fact]
public void DirtyDccFill_KeepsASharedBoundaryPageDirty()
{
if (!GatePrerequisites.Ready(_vulkan)) return;
using var harness = new CacheHarness(_vulkan);
const ulong Page = SharpEmu.HLE.GpuMemory.TrackerLayout.PageBytes;
var memory = harness.MapBacked(8 * Page, ReadWrite);
var metadata = memory + Page + Page / 2;
var size = 3 * Page;
var neighbour = metadata - 0x100;
harness.Images.RegisterDccMetadataForTest(metadata, size);
harness.Worker.Run(() =>
{
_ = harness.Cache.ObtainBuffer(neighbour, 0x100, isWritten: true);
_ = harness.Cache.ObtainBuffer(metadata, size, isWritten: true);
});
harness.Worker.Run(() => harness.Cache.FillBuffer(metadata, size, 0x00000000, isGds: false));
Assert.False(harness.Cache.HasGpuDirtyBytes(metadata, size));
Assert.False(harness.Cache.HasGpuDirtyPages(memory + 2 * Page, 2 * Page));
Assert.False(harness.Cache.HasGpuDirtyPages(memory + 4 * Page, Page));
Assert.True(harness.Cache.HasGpuDirtyPages(memory + Page, Page));
Assert.True(harness.Cache.HasGpuDirtyBytes(neighbour, 0x100));
harness.Shutdown();
}
[Fact]
public void CleanDccFill_WritesBothCopiesWithoutMarkingTheGpuCopyNewer()
{

View File

@ -266,6 +266,9 @@ public sealed class ShaderPipelineCacheTests : IDisposable
executor.DrawAuto(1, Banks(), Auto(3));
executor.DrawAuto(2, Banks(), Auto(3));
executor.DrawAuto(3, Banks(PrimitiveTriangleStrip), Auto(4));
// The lookup key is reused across draws: after another pipeline, the first is found again.
executor.DrawAuto(4, Banks(), Auto(3));
executor.DrawAuto(5, Banks(PrimitiveTriangleStrip), Auto(4));
Assert.Equal(2, guest.Host.GraphicsPipelines.Count);
Assert.Equal(2, cache.GraphicsPipelineCount);

View File

@ -279,9 +279,25 @@ public sealed class RenderExecutorComputeTests : IDisposable
Assert.Contains($"clear_image {MetadataAddress:X} 1000 {ClearValue:X8}", _host.Calls);
Assert.Contains($"absorb_dcc {MetadataAddress:X} 1000 {ClearValue:X8}", _host.Calls);
Assert.Contains($"fill_dcc {MetadataAddress:X} 1000 {ClearValue:X8}", _host.Calls);
AssertDispatched(4, 1, 1);
}
// A pending surface's metadata the host knows is filled there: as a dispatch it would be GPU-written
// and read back at every later bind.
[Fact]
public void PendingMetadataTheHostKnows_IsFilledOnTheHost()
{
_host.HostFillableDcc.Add(MetadataAddress);
ConfigureClearKernel(256);
_executor.Dispatch(1, Banks(), 256, 1, 1, ClearInitiator);
Assert.Contains($"absorb_dcc {MetadataAddress:X} 1000 {ClearValue:X8}", _host.Calls);
Assert.Contains($"fill_dcc {MetadataAddress:X} 1000 {ClearValue:X8}", _host.Calls);
AssertNotDispatched();
Assert.Contains("reset_bindings", _host.Calls);
}
[Fact]
public void RegisteredDccAddress_ConsumesTheFillAndRecordsNothing()
{

View File

@ -22,9 +22,21 @@ public sealed class RenderExecutorDrawTests : IDisposable
private readonly RenderExecutor _executor;
private readonly FatalScope _fatal = new();
public RenderExecutorDrawTests() => _executor = new RenderExecutor(_host, _pipelines);
private readonly InputsCheckingPrefetch _prefetch;
public void Dispose() => _fatal.Dispose();
public RenderExecutorDrawTests()
{
_executor = new RenderExecutor(_host, _pipelines);
_prefetch = new InputsCheckingPrefetch(_host);
_executor.Prefetch = _prefetch;
}
// Every draw of these tests also checks that its program inputs follow from its registers.
public void Dispose()
{
_fatal.Dispose();
Assert.Empty(_prefetch.Mismatches);
}
private void AssertOrder(params string[] prefixes)
{

View File

@ -0,0 +1,87 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using SharpEmu.Libs.Gpu.GpuCommands.Registers;
using SharpEmu.Libs.Gpu.Rendering;
using SharpEmu.Libs.Tests.Gpu.Scheduling;
using Xunit;
using static SharpEmu.Libs.Tests.Gpu.Rendering.RenderExecutorFixtures;
namespace SharpEmu.Libs.Tests.Gpu.Rendering;
// A draw takes the programs a prefetch resolved from its banks, or resolves them itself.
[Collection(SchedulingStateCollection.Name)]
public sealed class RenderExecutorPrefetchTests : IDisposable
{
private readonly RecordingRenderHost _host = new();
private readonly FakePipelineProvider _pipelines = new();
private readonly RenderExecutor _executor;
private readonly FatalScope _fatal = new();
public RenderExecutorPrefetchTests() => _executor = new RenderExecutor(_host, _pipelines);
public void Dispose() => _fatal.Dispose();
// Resolves ahead with the executor, as a worker would, and hands the result back once.
private sealed class AheadPrefetch(RenderExecutor executor, IRenderHost host) : IProgramPrefetch
{
private RegisterBanks? _banks;
private GraphicsProgramInputs _inputs;
private GraphicsPrograms? _programs;
public int Taken { get; private set; }
public int Declined { get; private set; }
public void Resolve(RegisterBanks banks)
{
_banks = banks;
_inputs = RenderExecutor.ProgramInputsOf(banks, host.FormatSupport, host.Fatal);
_programs = executor.ResolveGraphicsPrograms(banks, in _inputs);
}
public bool TryTakeGraphics(RegisterBanks banks, in GraphicsProgramInputs inputs, out GraphicsPrograms programs)
{
if (_programs is not null && ReferenceEquals(banks, _banks) && inputs.Equals(_inputs))
{
programs = _programs;
_programs = null;
Taken++;
return true;
}
programs = null!;
Declined++;
return false;
}
}
[Fact]
public void ADrawTakesTheProgramsResolvedAheadFromItsBanks()
{
var prefetch = new AheadPrefetch(_executor, _host);
_executor.Prefetch = prefetch;
var banks = Banks();
prefetch.Resolve(banks);
var resolutions = _pipelines.Calls.Count(call => call.StartsWith("get_graphics_programs", StringComparison.Ordinal));
_executor.DrawAuto(1, banks, Auto(3));
Assert.Equal(1, prefetch.Taken);
Assert.Equal(resolutions, _pipelines.Calls.Count(call => call.StartsWith("get_graphics_programs", StringComparison.Ordinal)));
Assert.Contains(_host.Calls, call => call.StartsWith("draw ", StringComparison.Ordinal));
}
[Fact]
public void ADrawWithOtherBanksResolvesItsOwnPrograms()
{
var prefetch = new AheadPrefetch(_executor, _host);
_executor.Prefetch = prefetch;
prefetch.Resolve(Banks());
_executor.DrawAuto(1, Banks(), Auto(3));
Assert.Equal((0, 1), (prefetch.Taken, prefetch.Declined));
Assert.Equal(2, _pipelines.Calls.Count(call => call.StartsWith("get_graphics_programs", StringComparison.Ordinal)));
}
}

View File

@ -108,6 +108,9 @@ internal sealed class RecordingRenderHost : IRenderHost
public HashSet<ulong> RegisteredDcc { get; } = new();
// Metadata the host knows and fills itself although its surface is not registered yet.
public HashSet<ulong> HostFillableDcc { get; } = new();
public HashSet<ulong> ClearableImages { get; } = new();
public Func<ulong, ulong, ulong>? ClampOverride { get; set; }
@ -391,7 +394,7 @@ internal sealed class RecordingRenderHost : IRenderHost
public bool TryFillDccMetadata(ulong address, ulong size, uint fillValue)
{
Calls.Add($"fill_dcc {address:X} {size:X} {fillValue:X8}");
return RegisteredDcc.Contains(address);
return RegisteredDcc.Contains(address) || HostFillableDcc.Contains(address);
}
public bool HostCopyAccepted { get; set; } = true;
@ -405,6 +408,33 @@ internal sealed class RecordingRenderHost : IRenderHost
public Exception Fatal(string message) => new RenderExecutorFatalException(message);
}
// Takes nothing, but checks that the inputs a draw resolves its programs with are the ones its
// register banks alone give: a prefetch computed ahead of the draw would use those.
internal sealed class InputsCheckingPrefetch(IRenderHost host) : IProgramPrefetch
{
public List<string> Mismatches { get; } = new();
public int Checks { get; private set; }
public bool TryTakeGraphics(RegisterBanks banks, in GraphicsProgramInputs inputs, out GraphicsPrograms programs)
{
Checks++;
var expected = RenderExecutor.ProgramInputsOf(banks, host.FormatSupport, host.Fatal);
if (!expected.Equals(inputs))
{
Mismatches.Add(
$"draw pixelActive={inputs.PixelActive}/{expected.PixelActive} depthBound={inputs.DepthBound}/{expected.DepthBound} " +
$"mapping=[{Describe(inputs.Mapping)}]/[{Describe(expected.Mapping)}]");
}
programs = null!;
return false;
}
private static string Describe(ReadOnlySpan<ColorComponentMap> mapping) =>
string.Join(",", mapping.ToArray().Select(map => map.Packed));
}
// Hands back the configured programs and records every pipeline request.
internal sealed class FakePipelineProvider : IShaderPipelineProvider
{

View File

@ -92,6 +92,41 @@ public sealed class PresenterImageBindingTests : IClassFixture<HeadlessVulkanFix
harness.Shutdown();
}
[Fact]
public void RepeatedBinding_ReusesTheLookupUntilTheImageIsRemoved()
{
if (!GatePrerequisites.Ready(_vulkan)) return;
using var fatal = new FatalScope();
using var presenter = new PresenterUnderTest(_vulkan);
var harness = presenter.Harness;
var address = harness.MapBacked(0x10000, ReadWrite);
harness.Write(address, new byte[0x10000]);
presenter.Run(() =>
{
var resource = new ImageResource
{
ResourceClass = PlanImageResourceClass.Sampled,
NumericClass = ImageNumericClass.Float,
Dimension = ImageDimension.Dim2D,
Read = true,
};
var program = new ShaderProgramInfo();
var words = RegisterWords.Texture(address, GuestPixelFormat.Bits32Float, 48, 24);
var first = (ResourceSlotIdentifier)GetFieldValue(presenter.InvokeMethod("ResolveImageBinding", resource, words, program, 0)!, "ImageIdentifier");
var second = (ResourceSlotIdentifier)GetFieldValue(presenter.InvokeMethod("ResolveImageBinding", resource, words, program, 0)!, "ImageIdentifier");
Assert.Equal(first, second);
// A removed image is not served again: the binding looks the descriptor up afresh.
var generation = harness.Images.LookupGeneration;
typeof(GuestImageCache).GetMethod("DeleteImage", InstanceMembers)!.Invoke(harness.Images, [first]);
Assert.NotEqual(generation, harness.Images.LookupGeneration);
var third = (ResourceSlotIdentifier)GetFieldValue(presenter.InvokeMethod("ResolveImageBinding", resource, words, program, 0)!, "ImageIdentifier");
Assert.True(harness.Images.TryGetImage(third, out var image) && image.Registered);
presenter.RenderHost.ResetBindings();
});
harness.Shutdown();
}
[Theory]
[InlineData(true, 1u, false)]
[InlineData(true, 2u, true)]

View File

@ -0,0 +1,250 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Buffers.Binary;
using System.Text;
using SharpEmu.ShaderCompiler.Vulkan;
using Xunit;
using static SharpEmu.ShaderCompiler.Tests.Resources.ResourceTestProgram;
namespace SharpEmu.ShaderCompiler.Tests;
// Checks the SPIR-V each wave lowering (NativeWave32, NativeWave64, EmulatedWave64) produces for the
// same guest programs: which subgroup instructions appear, how a ballot is split into a 64-bit mask,
// and whether the workgroup exchange / rendezvous machinery exists.
public sealed class Gen5WaveLoweringTests
{
public enum Lowering { Wave32, NativeWave64, EmulatedWave64 }
private sealed record Module(
IReadOnlyList<(SpirvOp Op, uint[] Operands)> Instructions,
IReadOnlyList<string> Names)
{
public int Count(SpirvOp op) => Instructions.Count(instruction => instruction.Op == op);
// CompositeExtract of a ballot result: its literal index is the last operand.
public IReadOnlyList<uint> BallotExtractIndices()
{
var ballots = Instructions.Where(i => i.Op == SpirvOp.GroupNonUniformBallot).Select(i => i.Operands[1]).ToHashSet();
return Instructions
.Where(i => i.Op == SpirvOp.CompositeExtract && ballots.Contains(i.Operands[2]))
.Select(i => i.Operands[^1])
.ToList();
}
}
private static Module Parse(byte[] spirv)
{
var instructions = new List<(SpirvOp, uint[])>();
var names = new List<string>();
for (var offset = 20; offset < spirv.Length;)
{
var word = BinaryPrimitives.ReadUInt32LittleEndian(spirv.AsSpan(offset));
var length = (int)(word >> 16);
var op = (SpirvOp)(word & 0xFFFF);
var operands = new uint[length - 1];
for (var index = 0; index < operands.Length; index++)
{
operands[index] = BinaryPrimitives.ReadUInt32LittleEndian(spirv.AsSpan(offset + 4 + index * 4));
}
instructions.Add((op, operands));
if (op == SpirvOp.Name)
{
var text = new List<byte>();
for (var index = 1; index < operands.Length; index++)
{
text.AddRange(BitConverter.GetBytes(operands[index]));
}
names.Add(Encoding.UTF8.GetString(text.ToArray()).TrimEnd('\0'));
}
offset += length * 4;
}
return new Module(instructions, names);
}
private static Module Compile(Gen5ShaderProgram program, Lowering lowering, uint threads = 64)
{
var (plan, resources, layout) = Prepare(program);
var configured = new ShaderCompileRequest(plan, resources, layout)
{
WaveSize = lowering == Lowering.Wave32 ? 32u : 64u,
HostWave64Supported = lowering == Lowering.NativeWave64,
LocalSizeX = threads,
};
Assert.True(Gen5SpirvTranslator.TryCompileProgram(configured, out var shader, out var error), error);
return Parse(shader.Spirv);
}
// v_cmp_lt_u32 vcc, v0, v1 : a per-lane compare whose result becomes a wave mask.
private static Gen5ShaderProgram CompareProgram() => Program(
Vopc(0, "VCmpLtU32", Operand(5), 1),
EndProgram(4));
private static Gen5ShaderProgram ReadFirstLaneProgram() => Program(
ReadFirstLane(0, 4, 0),
EndProgram(4));
private static Gen5ShaderProgram ReadLaneProgram(uint lane) => Program(
ReadLane(0, 4, 0, lane),
EndProgram(4));
private static Gen5ShaderProgram DataShareProgram(bool barrierBetween) =>
Program(
new[]
{
DataShare(0, "DsWriteB32", false, [Gen5Operand.Vector(0), Gen5Operand.Vector(1)], [], 0, 0),
}
.Concat(barrierBetween
? new[] { new Gen5ShaderInstruction(4, Gen5ShaderEncoding.Sopp, "SBarrier", [0u], [], [], null) }
: [])
.Concat(new[]
{
DataShare(8, "DsReadB32", false, [Gen5Operand.Vector(0)], [2u], 0, 0),
EndProgram(16),
})
.ToArray());
[Theory]
[InlineData(Lowering.Wave32)]
[InlineData(Lowering.NativeWave64)]
[InlineData(Lowering.EmulatedWave64)]
public void EveryLoweringCompilesTheBasicWavePrograms(Lowering lowering)
{
foreach (var program in new[] { CompareProgram(), ReadFirstLaneProgram(), ReadLaneProgram(0), ReadLaneProgram(33), DataShareProgram(false), DataShareProgram(true) })
{
Compile(program, lowering);
}
}
[Fact]
public void Wave32_BallotUsesOnlyTheLowWord()
{
var module = Compile(CompareProgram(), Lowering.Wave32);
Assert.True(module.Count(SpirvOp.GroupNonUniformBallot) > 0);
Assert.All(module.BallotExtractIndices(), index => Assert.Equal(0u, index));
}
[Fact]
public void NativeWave64_BallotCombinesBothWords()
{
var module = Compile(CompareProgram(), Lowering.NativeWave64);
var indices = module.BallotExtractIndices();
Assert.Contains(0u, indices);
Assert.Contains(1u, indices);
}
[Fact]
public void OnlyTheEmulatedLoweringDeclaresTheExchange()
{
foreach (var lowering in new[] { Lowering.Wave32, Lowering.NativeWave64 })
{
var module = Compile(ReadFirstLaneProgram(), lowering);
Assert.DoesNotContain("wave64Exchange", module.Names);
Assert.DoesNotContain("wave64ExchangeParity", module.Names);
}
var emulated = Compile(ReadFirstLaneProgram(), Lowering.EmulatedWave64);
Assert.Contains("wave64ExchangeParity", emulated.Names);
Assert.Contains("wave64Exchange", emulated.Names);
}
[Fact]
public void EmulatedWave64_NeedsNoExchangeWithoutSubgroupOperations()
{
var module = Compile(Program(Vop2(0, "VAddI32", 1, Operand(1), Gen5Operand.Vector(0)), EndProgram(4)), Lowering.EmulatedWave64);
Assert.DoesNotContain("wave64ExchangeParity", module.Names);
}
[Theory]
[InlineData(Lowering.Wave32, 64u)]
[InlineData(Lowering.NativeWave64, 64u)]
[InlineData(Lowering.NativeWave64, 256u)]
public void NativeLoweringsAddNoBarriersAroundSharedMemory(Lowering lowering, uint threads)
{
var module = Compile(DataShareProgram(false), lowering, threads);
Assert.Equal(0, module.Count(SpirvOp.ControlBarrier));
}
[Theory]
[InlineData(false, 2)]
[InlineData(true, 2)]
public void EmulatedWave64_OrdersSharedMemoryPhasesWithinOneWave(bool explicitBarrier, int expectedBarriers)
{
var module = Compile(DataShareProgram(explicitBarrier), Lowering.EmulatedWave64, threads: 64);
Assert.Equal(expectedBarriers, module.Count(SpirvOp.ControlBarrier));
}
[Fact]
public void EmulatedWave64_MultiWaveGroupsUseNoWorkgroupBarrierForSharedMemoryPhases()
{
var module = Compile(DataShareProgram(false), Lowering.EmulatedWave64, threads: 128);
Assert.Equal(0, module.Count(SpirvOp.ControlBarrier));
}
[Fact]
public void EmulatedWave64_MultiWaveExchangeMeetsAtAnArrivalCounter()
{
var single = Compile(ReadFirstLaneProgram(), Lowering.EmulatedWave64, threads: 64);
var multi = Compile(ReadFirstLaneProgram(), Lowering.EmulatedWave64, threads: 256);
Assert.DoesNotContain("wave64RendezvousEpoch", single.Names);
Assert.Contains("wave64RendezvousEpoch", multi.Names);
Assert.True(multi.Count(SpirvOp.AtomicIAdd) > single.Count(SpirvOp.AtomicIAdd));
}
[Theory]
[InlineData(Lowering.Wave32)]
[InlineData(Lowering.NativeWave64)]
public void NativeLoweringsNeverDeclareARendezvous(Lowering lowering)
{
var module = Compile(ReadFirstLaneProgram(), lowering, threads: 256);
Assert.DoesNotContain("wave64RendezvousEpoch", module.Names);
Assert.Equal(0, module.Count(SpirvOp.AtomicIAdd));
}
[Fact]
public void HostWave64SupportDoesNotChangeAWave32Shader()
{
var program = ReadFirstLaneProgram();
var (plan, resources, layout) = Prepare(program);
byte[] Compile32(bool hostWave64)
{
var configured = new ShaderCompileRequest(plan, resources, layout)
{
WaveSize = 32,
HostWave64Supported = hostWave64,
LocalSizeX = 64,
};
Assert.True(Gen5SpirvTranslator.TryCompileProgram(configured, out var shader, out var error), error);
return shader.Spirv.ToArray();
}
Assert.Equal(Compile32(false), Compile32(true));
}
[Theory]
[InlineData(Lowering.Wave32)]
[InlineData(Lowering.NativeWave64)]
[InlineData(Lowering.EmulatedWave64)]
public void ReadLaneBroadcastsFromTheRequestedLane(Lowering lowering)
{
var module = Compile(ReadLaneProgram(33), lowering);
Assert.True(
module.Count(SpirvOp.GroupNonUniformBroadcast) > 0 || module.Count(SpirvOp.GroupNonUniformShuffle) > 0 ||
module.Count(SpirvOp.Store) > 0);
}
[Theory]
[InlineData(Lowering.Wave32)]
[InlineData(Lowering.NativeWave64)]
[InlineData(Lowering.EmulatedWave64)]
public void LoweringsAreDeterministic(Lowering lowering)
{
var first = Compile(CompareProgram(), lowering);
var second = Compile(CompareProgram(), lowering);
Assert.Equal(first.Instructions.Count, second.Instructions.Count);
}
}

View File

@ -153,6 +153,26 @@ public sealed class ResourceMaterializationCacheTests
Assert.Equal(0, cache.Hits);
}
private static ShaderResourcePlan PlanWithSpareUserData() =>
ShaderResourcePlan.Extract(DirectImageTableTests.CreateWaveIndexedDescriptorProgram(), ShaderStage.Compute, Hash, 0, 4);
[Fact]
public void AUserDataWordTheProgramNeverReadsDoesNotSplitTheEntry()
{
var plan = PlanWithSpareUserData();
var heap = new Heap();
var cache = new ResourceMaterializationCache();
Assert.True(Run(cache, plan, heap, [0x1000, 0, 5, 6], out var first, out _));
Assert.True(Run(cache, plan, heap, [0x1000, 0, 7, 8], out var second, out _));
Assert.Equal((1, 1), (cache.Hits, cache.Misses));
// The hit carries the second draw's user data and leaves the first draw's snapshot alone.
Assert.Equal(new uint[] { 0x1000, 0, 7, 8 }, second.UserData);
Assert.Equal(new uint[] { 0x1000, 0, 5, 6 }, first.UserData);
Assert.Same(first.Images, second.Images);
Assert.True(Run(cache, plan, heap, [0x1000, 0, 5, 6], out var third, out _));
Assert.Same(first, third);
}
[Fact]
public void ADifferentDrawKeyIsADifferentEntry()
{
@ -264,6 +284,276 @@ public sealed class ResourceMaterializationCacheTests
Assert.Equal(0, cache.TableRefreshes);
}
// A V# taken verbatim from s[4:7], the way titles pass per-draw constants.
private static ShaderResourcePlan DirectBufferPlan() =>
Extract(Program(BufferLoad(0, 4), EndProgram(8)), userDataCount: 8);
// The same V#, whose base pair s[4:5] is also the address of a scalar load.
private static ShaderResourcePlan SteeringBufferPlan() =>
Extract(Program(ScalarLoad(0, 4, 16), Vop1(8, "VMovB32", 0, Gen5Operand.Scalar(16)), BufferStore(12, 4), EndProgram(20)),
userDataCount: 8);
private static bool RunWords(ResourceMaterializationCache cache, ShaderResourcePlan plan, TestWordMemory memory, uint[] userData,
out ResourceSnapshot snapshot)
{
snapshot = new ResourceSnapshot();
var specialization = new ResourceSpecialization();
return cache.Materialize(plan, Inputs(userData, memory.Read, memory.Read), (address, destination, _) =>
{
for (var offset = 0; offset < destination.Length; offset += 4)
{
if (!memory.Read(address + (ulong)offset, out var word))
return false;
BitConverter.TryWriteBytes(destination[offset..], word);
}
return true;
}, ref snapshot, ref specialization, out _);
}
private static uint[] Reference(ShaderResourcePlan plan, TestWordMemory memory, uint[] userData)
{
var snapshot = new ResourceSnapshot();
var specialization = new ResourceSpecialization();
Assert.True(ResourceMaterializer.Materialize(plan, Inputs(userData, memory.Read, memory.Read), ref snapshot, ref specialization));
return snapshot.Buffers[0];
}
private static uint[] LinearConstants(int draw) => [0, 0, 0, 0, 0x10000u + (uint)draw * 0x100u, 0, 256, 0x2_0000];
[Fact]
public void ALinearlyAllocatedBufferBaseStopsMissingOnceConfirmed()
{
var plan = DirectBufferPlan();
var memory = new TestWordMemory();
var cache = new ResourceMaterializationCache();
for (var draw = 0; draw < 16; draw++)
{
var userData = LinearConstants(draw);
Assert.True(RunWords(cache, plan, memory, userData, out var snapshot));
Assert.Equal(Reference(plan, memory, userData), snapshot.Buffers[0]);
Assert.Equal(userData, snapshot.UserData);
}
// One miss to build, three to confirm the word; every later draw is a hit.
Assert.Equal((12, 4), (cache.Hits, cache.Misses));
}
[Fact]
public void InterleavedObjectsConfirmTheirOwnBases()
{
// Constants V# in s[4:7], a second buffer in s[8:11] whose size tells the two objects apart.
var plan = Extract(Program(BufferLoad(0, 4), BufferLoad(8, 8), EndProgram(16)), userDataCount: 12);
var memory = new TestWordMemory();
var cache = new ResourceMaterializationCache();
for (var draw = 0; draw < 32; draw++)
{
uint[] userData = [0, 0, 0, 0, 0x10000u + (uint)draw * 0x100u, 0, 256, 0x2_0000, 0x40000, 0, draw % 2 == 0 ? 256u : 512u, 0x2_0000];
Assert.True(RunWords(cache, plan, memory, userData, out var snapshot));
var expected = new ResourceSnapshot();
var expectedSpecialization = new ResourceSpecialization();
Assert.True(ResourceMaterializer.Materialize(plan, Inputs(userData, memory.Read, memory.Read), ref expected, ref expectedSpecialization));
Assert.Equal(expected.Buffers.Select(buffer => buffer.ToArray()), snapshot.Buffers.Select(buffer => buffer.ToArray()));
}
Assert.True(cache.Hits >= 20, $"hits={cache.Hits} misses={cache.Misses}");
}
// Pointer s[0:1] moves on every draw; the program loads four constants through it.
private static uint[] PointerConstants(int draw) => [0x10000u + (uint)draw * 0x100u, 0, 0, 0, 0x30000, 0, 256, 0x2_0000, 0];
[Fact]
public void AMovingScalarLoadPointerStopsMissingOnceConfirmed()
{
var (plan, _, _) = Prepare(FlattenedReadReuseTests.RepeatedReadProgram(4), userDataCount: 9);
// The same four words repeat every 0x100 bytes, so every pointer finds the same constants.
var memory = new TestWordMemory { Base = 0x10000, Words = Enumerable.Range(0, 0x1000).Select(index => (uint)(index % 64) * 3 + 1).ToArray() };
var cache = new ResourceMaterializationCache();
for (var draw = 0; draw < 16; draw++)
{
var userData = PointerConstants(draw);
Assert.True(RunWords(cache, plan, memory, userData, out var snapshot));
var expected = new ResourceSnapshot();
var expectedSpecialization = new ResourceSpecialization();
Assert.True(ResourceMaterializer.Materialize(plan, Inputs(userData, memory.Read, memory.Read), ref expected, ref expectedSpecialization));
Assert.Equal(expected.FlattenedResourceTable, snapshot.FlattenedResourceTable);
Assert.Equal(expected.Buffers.Select(buffer => buffer.ToArray()), snapshot.Buffers.Select(buffer => buffer.ToArray()));
}
Assert.Equal((12, 4), (cache.Hits, cache.Misses));
// Other constants behind the next pointer: the draw reads them instead of reusing the table.
var moved = PointerConstants(30);
memory.At(moved[0] + 8) = 0xBEEF;
Assert.True(RunWords(cache, plan, memory, moved, out var reread));
Assert.Equal(0xBEEFu, reread.FlattenedResourceTable[2]);
Assert.Equal(12, cache.Hits);
}
[Fact]
public void ARebasedHitLeavesTheCachedSnapshotAlone()
{
var plan = DirectBufferPlan();
var memory = new TestWordMemory();
var cache = new ResourceMaterializationCache();
for (var draw = 0; draw < 4; draw++)
Assert.True(RunWords(cache, plan, memory, LinearConstants(draw), out _));
Assert.True(RunWords(cache, plan, memory, LinearConstants(3), out var built));
Assert.True(RunWords(cache, plan, memory, LinearConstants(9), out var patched));
Assert.Equal(0x10900u, patched.Buffers[0][0]);
Assert.Equal(0x10300u, built.Buffers[0][0]);
Assert.NotSame(built.Buffers, patched.Buffers);
Assert.True(RunWords(cache, plan, memory, LinearConstants(3), out var again));
Assert.Equal(0x10300u, again.Buffers[0][0]);
}
[Fact]
public void AReadThatMovesWithTheBaseAndKeepsItsBytesIsReused()
{
var plan = SteeringBufferPlan();
var memory = new TestWordMemory { Base = 0x10000, Words = Enumerable.Repeat(0x1234u, 0x1000).ToArray() };
var cache = new ResourceMaterializationCache();
for (var draw = 0; draw < 16; draw++)
{
var userData = LinearConstants(draw);
Assert.True(RunWords(cache, plan, memory, userData, out var snapshot));
var expected = new ResourceSnapshot();
var expectedSpecialization = new ResourceSpecialization();
Assert.True(ResourceMaterializer.Materialize(plan, Inputs(userData, memory.Read, memory.Read), ref expected, ref expectedSpecialization));
Assert.Equal(expected.Buffers[0], snapshot.Buffers[0]);
Assert.Equal(expected.FlattenedResourceTable, snapshot.FlattenedResourceTable);
}
Assert.Equal((12, 4), (cache.Hits, cache.Misses));
// The moved read now finds other bytes at the new base: the draw must not reuse the table.
var changed = LinearConstants(20);
memory.At(changed[4]) = 0x5678;
Assert.True(RunWords(cache, plan, memory, changed, out var refreshed));
Assert.Equal(0x5678u, refreshed.FlattenedResourceTable[0]);
Assert.Equal(12, cache.Hits);
}
[Fact]
public void MovedConstantsThatChangeEveryDrawAreReadAgain()
{
var plan = SteeringBufferPlan();
var memory = new TestWordMemory { Base = 0x10000, Words = Enumerable.Range(0, 0x1000).Select(index => (uint)index * 7).ToArray() };
var cache = new ResourceMaterializationCache();
for (var draw = 0; draw < 16; draw++)
{
var userData = LinearConstants(draw);
Assert.True(RunWords(cache, plan, memory, userData, out var snapshot));
Assert.Equal(Reference(plan, memory, userData), snapshot.Buffers[0]);
var expected = new ResourceSnapshot();
var expectedSpecialization = new ResourceSpecialization();
Assert.True(ResourceMaterializer.Materialize(plan, Inputs(userData, memory.Read, memory.Read), ref expected, ref expectedSpecialization));
Assert.Equal(expected.FlattenedResourceTable, snapshot.FlattenedResourceTable);
}
// The constants differ at every base, so nothing is reused as is; once the base is
// confirmed, the moved table is evaluated again instead of the whole walk.
Assert.Equal(0, cache.Hits);
Assert.True(cache.TableRefreshes >= 10, $"refreshes={cache.TableRefreshes} misses={cache.Misses}");
}
// The V# comes from a descriptor table at s[0:1]; its constants are read through it.
private static ShaderResourcePlan MemoryDescriptorPlan() =>
Extract(Program(ScalarLoad(0, 0, 8, 4), ScalarBufferLoad(4, 8, 12, 4), Vop1(8, "VMovB32", 0, Gen5Operand.Scalar(12)),
BufferLoad(12, 8), EndProgram(16)), userDataCount: 2);
// The title rewrites the table every draw: a freshly allocated base, and new constants there.
private static void WriteDraw(TestWordMemory memory, int draw)
{
var bufferBase = 0x10000u + (uint)draw * 0x100u;
memory.At(0x1000) = bufferBase;
memory.At(0x1004) = 0;
memory.At(0x1008) = 256;
memory.At(0x100C) = 0x2_0000;
for (var word = 0u; word < 4; word++)
memory.At(bufferBase + word * 4) = (uint)draw * 10 + word;
}
[Fact]
public void ABaseRewrittenInGuestMemoryStopsMissingOnceConfirmed()
{
var plan = MemoryDescriptorPlan();
var memory = new TestWordMemory { Base = 0x1000, Words = new uint[0x8000] };
var cache = new ResourceMaterializationCache();
uint[] userData = [0x1000, 0];
for (var draw = 0; draw < 16; draw++)
{
WriteDraw(memory, draw);
Assert.True(RunWords(cache, plan, memory, userData, out var snapshot));
var expected = new ResourceSnapshot();
var expectedSpecialization = new ResourceSpecialization();
Assert.True(ResourceMaterializer.Materialize(plan, Inputs(userData, memory.Read, memory.Read), ref expected, ref expectedSpecialization));
Assert.Equal(expected.Buffers.Select(buffer => buffer.ToArray()), snapshot.Buffers.Select(buffer => buffer.ToArray()));
Assert.Equal(expected.FlattenedResourceTable, snapshot.FlattenedResourceTable);
}
// One miss to build, three to confirm the base; the later draws patch it.
Assert.True(cache.Hits >= 10, $"hits={cache.Hits} misses={cache.Misses}");
// Another descriptor word changed with the base: the draw is materialized again.
WriteDraw(memory, 20);
memory.At(0x1008) = 512;
var hits = cache.Hits;
Assert.True(RunWords(cache, plan, memory, userData, out var resized));
Assert.Equal(hits, cache.Hits);
Assert.Equal(512u, resized.Buffers[0][2]);
}
private static void AssertMatchesFullWalk(ShaderResourcePlan plan, TestWordMemory memory, uint[] userData, ResourceSnapshot snapshot)
{
var expected = new ResourceSnapshot();
var expectedSpecialization = new ResourceSpecialization();
Assert.True(ResourceMaterializer.Materialize(plan, Inputs(userData, memory.Read, memory.Read), ref expected, ref expectedSpecialization));
Assert.Equal(expected.Buffers.Select(buffer => buffer.ToArray()), snapshot.Buffers.Select(buffer => buffer.ToArray()));
Assert.Equal(expected.FlattenedResourceTable, snapshot.FlattenedResourceTable);
}
[Fact]
public void ATableConstantBesideARewrittenBaseIsCopiedForTheDraw()
{
// The V# and a constant both come from the table at s[0:1]; the constant changes every draw.
var plan = Extract(Program(ScalarLoad(0, 0, 8, 4), ScalarLoad(4, 0, 16, 1, immediateOffset: 16),
ScalarBufferLoad(8, 8, 12, 4), Vop1(12, "VMovB32", 0, Gen5Operand.Scalar(12)),
Vop1(16, "VMovB32", 1, Gen5Operand.Scalar(16)), BufferLoad(20, 8), EndProgram(24)), userDataCount: 2);
var memory = new TestWordMemory { Base = 0x1000, Words = new uint[0x8000] };
var cache = new ResourceMaterializationCache();
uint[] userData = [0x1000, 0];
for (var draw = 0; draw < 16; draw++)
{
WriteDraw(memory, draw);
memory.At(0x1010) = (uint)draw * 3 + 1;
Assert.True(RunWords(cache, plan, memory, userData, out var snapshot));
AssertMatchesFullWalk(plan, memory, userData, snapshot);
}
Assert.True(cache.Hits >= 10, $"hits={cache.Hits} misses={cache.Misses}");
}
[Fact]
public void AlternatingObjectsWithRewrittenBasesRebaseTheirOwnVariant()
{
// Two objects share the draw key and differ in their buffer size; both move their base.
var plan = MemoryDescriptorPlan();
var memory = new TestWordMemory { Base = 0x1000, Words = new uint[0x8000] };
var cache = new ResourceMaterializationCache();
uint[] userData = [0x1000, 0];
for (var draw = 0; draw < 32; draw++)
{
WriteDraw(memory, draw);
memory.At(0x1008) = draw % 2 == 0 ? 256u : 512u;
Assert.True(RunWords(cache, plan, memory, userData, out var snapshot));
AssertMatchesFullWalk(plan, memory, userData, snapshot);
}
Assert.True(cache.Hits >= 20, $"hits={cache.Hits} misses={cache.Misses}");
}
[Fact]
public void AFullGenerationKeepsRecentEntries()
{