sharpemu/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.Comman...

512 lines
17 KiB
C#

// 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));
}
}
}