[Agc] Queue the suspend point instead of draining the command stream

A suspend point inserts GPU commands that drain the graphics pipe. The call
that inserts it does not wait for those commands, and the hardware allows one
suspend point to be in flight, but the queue implemented it by blocking the
calling thread until every accepted submission had been processed.

The frame boundary is now a queued submission of its own, ordered behind the
graphics work it ends, and processing it flushes what the frame recorded. The
caller returns immediately for the first boundary and only waits when a second
one arrives while the first is still pending. Shutdown still drains, because no
slice will run a queued boundary once the queue stops accepting work, and a
cancelled queue drops pending boundaries along with the rest.

Measured on Octopath Traveler II: the call went from 223 ms to 72 ms, and the
guest render thread spent 38 s instead of 107 s of its 170 s inside it.
This commit is contained in:
kuba 2026-09-27 00:47:16 +02:00
parent b93e4d642a
commit d1c6fd14bf
3 changed files with 81 additions and 15 deletions

View File

@ -29,6 +29,8 @@ public sealed class CommandStreamQueue
public const int ComputeQueueCount = 56;
public const int QueueCount = 1 + ComputeQueueCount;
public const int AllBlockedRetryMilliseconds = 100;
// The hardware lets one suspend point be in flight; a second one waits for the first.
public const int MaxBoundariesInFlight = 1;
private readonly ICommandStreamHost _host;
private readonly object _gate = new();
@ -43,6 +45,7 @@ public sealed class CommandStreamQueue
private ulong _submitId;
private ulong _lastCompletedGpuTick;
private int _doneCount;
private int _pendingBoundaries;
private IdleOutcome _outcome = IdleOutcome.Completed;
private Thread? _processingThread;
@ -191,28 +194,57 @@ public sealed class CommandStreamQueue
_queues[submission.QueueId].AddLast(submission);
_submissionCount++;
if (submission.Kind != CommandSubmissionKind.FlipPreparation)
if (submission.Kind is not (CommandSubmissionKind.FlipPreparation or CommandSubmissionKind.FrameBoundary))
SubmissionFlowProfile.Record(SubmissionFlowProfile.EventKind.Enqueued, submission.QueueId,
submission.SubmissionId, submission.Address, submission.DwordCount, _submissionCount);
Monitor.PulseAll(_gate);
}
// Marks the frame boundary; from another thread it first waits for the queue to drain.
// Marks the frame boundary. A suspend point inserts commands that drain the graphics
// pipe; it does not wait for them, and the hardware allows one to be in flight, so the
// boundary is queued behind the submissions it ends and only a second one waits.
public IdleOutcome Done()
{
var outcome = IdleOutcome.Completed;
if (_processingThread != Thread.CurrentThread)
if (_processingThread == Thread.CurrentThread)
{
outcome = WaitForIdle();
lock (_gate)
{
_graphicsDone = true;
_doneCount++;
}
return IdleOutcome.Completed;
}
lock (_gate)
{
_graphicsDone = true;
_doneCount++;
}
if (_outcome != IdleOutcome.Completed)
{
return _outcome;
}
return outcome;
_graphicsDone = true;
if (!_accepting)
{
// Shutdown: no slice will run a queued boundary, so drain as before.
while (_outcome == IdleOutcome.Completed && (_processing || _submissionCount != 0))
{
Monitor.Wait(_gate);
}
_doneCount++;
return _outcome;
}
_pendingBoundaries++;
EnqueueLocked(new CommandSubmission(CommandSubmissionKind.FrameBoundary, 0, 0, 0, 0, null));
while (_outcome == IdleOutcome.Completed && _pendingBoundaries > MaxBoundariesInFlight)
{
Monitor.Wait(_gate);
}
return _outcome;
}
}
// Returns once nothing is pending; a cancelled or failed queue reports that instead.
@ -348,7 +380,7 @@ public sealed class CommandStreamQueue
_nextQueue = (selected + 1) % QueueCount;
_processing = true;
_processingThread = Thread.CurrentThread;
if (!submission.Started && submission.Kind != CommandSubmissionKind.FlipPreparation)
if (!submission.Started && submission.Kind is not (CommandSubmissionKind.FlipPreparation or CommandSubmissionKind.FrameBoundary))
SubmissionFlowProfile.Record(SubmissionFlowProfile.EventKind.ProcessingStarted, submission.QueueId,
submission.SubmissionId, submission.Address, submission.DwordCount, _submissionCount);
}
@ -388,6 +420,12 @@ public sealed class CommandStreamQueue
}
else
{
if (submission.Kind == CommandSubmissionKind.FrameBoundary)
{
_pendingBoundaries--;
_doneCount++;
}
foreach (var queue in _queues)
{
if (queue.First is { } head)
@ -415,6 +453,14 @@ public sealed class CommandStreamQueue
return true;
}
// A suspend point drains the graphics pipe: submit what the frame recorded.
if (submission.Kind == CommandSubmissionKind.FrameBoundary)
{
SlicesRun++;
_host.Flush();
return true;
}
var firstSlice = !submission.Started;
var processor = GetInterpreter(submission.QueueId);
if (firstSlice && submission.ResetInterpreter)
@ -526,6 +572,14 @@ public sealed class CommandStreamQueue
if (queue.First is { } head && head.Value.Blocked)
{
_submissionCount -= queue.Count;
foreach (var dropped in queue)
{
if (dropped.Kind == CommandSubmissionKind.FrameBoundary)
{
_pendingBoundaries--;
}
}
queue.Clear();
_outcome = IdleOutcome.Cancelled;
}
@ -630,5 +684,6 @@ public sealed class CommandStreamQueue
}
_submissionCount = 0;
_pendingBoundaries = 0;
}
}

View File

@ -9,6 +9,8 @@ public enum CommandSubmissionKind
Compute,
// A video-out export flip queued behind the graphics submissions before it.
FlipPreparation,
// A suspend point queued behind the graphics submissions of the frame it ends.
FrameBoundary,
}
public enum SubmissionProgress

View File

@ -318,19 +318,28 @@ public sealed class CommandStreamQueueTests
}
[Fact]
public async Task Done_FromAnotherThreadWaitsForTheQueueAndCountsFrames()
public async Task Done_QueuesTheBoundaryAndOnlyWaitsForASecondOne()
{
var (host, queue) = NewQueue();
Enqueue(host, queue, Graphics, 1, CreateInstanceCountPacket(1));
var done = Task.Run(queue.Done);
await Task.WhenAny(done, Task.Delay(100));
Assert.False(done.IsCompleted);
// The first suspend point returns without draining the accepted submissions.
Assert.Equal(IdleOutcome.Completed, await Task.Run(queue.Done).WaitAsync(TimeSpan.FromSeconds(2)));
Assert.Equal(0, queue.FrameNumber);
Assert.True(queue.HasPending);
// The second one waits until the first boundary has been processed.
var second = Task.Run(queue.Done);
await Task.WhenAny(second, Task.Delay(100));
Assert.False(second.IsCompleted);
Assert.Equal(SliceResult.Completed, queue.ProcessOne());
Assert.Equal(IdleOutcome.Completed, await done.WaitAsync(TimeSpan.FromSeconds(2)));
Assert.Equal(SliceResult.Completed, queue.ProcessOne());
Assert.Equal(IdleOutcome.Completed, await second.WaitAsync(TimeSpan.FromSeconds(2)));
Assert.Equal(1, queue.FrameNumber);
// The next graphics submission starts from a reset processor.
Assert.Equal(SliceResult.Completed, queue.ProcessOne());
Assert.Equal(2, queue.FrameNumber);
Enqueue(host, queue, Graphics, 2, CreateInstanceCountPacket(2));
Assert.Equal(SliceResult.Completed, queue.ProcessOne());
Assert.Equal(2UL, queue.GetInterpreter(0).SubmitId);