391 lines
13 KiB
C#
391 lines
13 KiB
C#
// Copyright (C) 2026 SharpEmu Emulator Project
|
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
|
|
|
using SharpEmu.HLE;
|
|
using SharpEmu.HLE.GpuMemory;
|
|
using SharpEmu.Libs.Gpu.Scheduling;
|
|
using SharpEmu.Libs.Tests.Gpu.Scheduling;
|
|
using SharpEmu.Libs.Tests.Memory.GpuMemory;
|
|
using Xunit;
|
|
using static SharpEmu.Libs.Tests.Gpu.Scheduling.SchedulingTestSupport;
|
|
|
|
namespace SharpEmu.Libs.Tests.Gpu.Vulkan;
|
|
|
|
[Collection(SchedulingStateCollection.Name)]
|
|
public sealed class HeadlessSchedulerTests : IClassFixture<HeadlessVulkanFixture>
|
|
{
|
|
// Models the presenter loop: relay commands first, then one unit of guest work.
|
|
private sealed class GpuWorker : IDisposable
|
|
{
|
|
private readonly object _gate = new();
|
|
private readonly Queue<Action> _guestWork = new();
|
|
private readonly Thread _thread;
|
|
private bool _stop;
|
|
|
|
public GpuWorker(HeadlessVulkan vulkan)
|
|
{
|
|
Relay = new GpuWorkerRelay(Wake);
|
|
Device = new LoggingTickDevice(vulkan.NewTickDevice());
|
|
Scheduler = new SubmissionScheduler(Device, new RecordingRenderingState());
|
|
_thread = new Thread(Run) { IsBackground = true };
|
|
_thread.Start();
|
|
}
|
|
|
|
public GpuWorkerRelay Relay { get; }
|
|
|
|
public LoggingTickDevice Device { get; }
|
|
|
|
public SubmissionScheduler Scheduler { get; }
|
|
|
|
public int ThreadId => _thread.ManagedThreadId;
|
|
|
|
public void Post(Action work)
|
|
{
|
|
lock (_gate)
|
|
{
|
|
_guestWork.Enqueue(work);
|
|
Monitor.PulseAll(_gate);
|
|
}
|
|
}
|
|
|
|
public void RunOnWorker(Action work)
|
|
{
|
|
using var done = new ManualResetEventSlim();
|
|
Post(() =>
|
|
{
|
|
work();
|
|
done.Set();
|
|
});
|
|
done.Wait();
|
|
}
|
|
|
|
// Stop the scheduler on the worker before detaching it.
|
|
public void Close(GuestGpuMemory? memory)
|
|
{
|
|
Relay.StopAcceptingWork();
|
|
Relay.RunPendingCommands();
|
|
if (Scheduler.Active)
|
|
{
|
|
Scheduler.Finish();
|
|
Scheduler.WaitForAllPriorityOperations();
|
|
}
|
|
|
|
Scheduler.Shutdown();
|
|
memory?.AttachGpuQueue(null, null);
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
lock (_gate)
|
|
{
|
|
_stop = true;
|
|
Monitor.PulseAll(_gate);
|
|
}
|
|
|
|
_thread.Join();
|
|
Scheduler.Dispose();
|
|
}
|
|
|
|
private void Wake()
|
|
{
|
|
lock (_gate)
|
|
{
|
|
Monitor.PulseAll(_gate);
|
|
}
|
|
}
|
|
|
|
private void Run()
|
|
{
|
|
Relay.BindCurrentThread();
|
|
for (;;)
|
|
{
|
|
Action? guest;
|
|
lock (_gate)
|
|
{
|
|
while (!_stop && !Relay.HasPendingCommands && _guestWork.Count == 0)
|
|
{
|
|
Monitor.Wait(_gate);
|
|
}
|
|
|
|
if (_stop)
|
|
{
|
|
return;
|
|
}
|
|
|
|
Relay.RunPendingCommands();
|
|
_guestWork.TryDequeue(out guest);
|
|
}
|
|
|
|
guest?.Invoke();
|
|
}
|
|
}
|
|
}
|
|
|
|
// Records, per store call, the thread and how far the GPU was known to be.
|
|
private sealed class ObservingStores : IGuestBufferStore, IGuestImageStore
|
|
{
|
|
public SubmissionScheduler? Scheduler { get; set; }
|
|
|
|
public List<(int Thread, ulong CompletedTick)> Calls { get; } = new();
|
|
|
|
public bool MarkCpuWrite(ulong address, ulong size)
|
|
{
|
|
lock (Calls)
|
|
{
|
|
Calls.Add((Environment.CurrentManagedThreadId, Scheduler?.Timeline.CompletedTick ?? 0));
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
public bool DownloadToCpu(ulong address, ulong size) => false;
|
|
|
|
public void Unregister(ulong address, ulong size)
|
|
{
|
|
}
|
|
}
|
|
|
|
private readonly HeadlessVulkan? _vulkan;
|
|
|
|
public HeadlessSchedulerTests(HeadlessVulkanFixture fixture, Xunit.Abstractions.ITestOutputHelper output)
|
|
{
|
|
_vulkan = fixture.Vulkan;
|
|
output.WriteLine(_vulkan is null ? "headless vulkan: unavailable, tests skipped" : $"headless vulkan: {_vulkan.DeviceName}");
|
|
}
|
|
|
|
[Fact]
|
|
public void SubmitAdvancesTheTimelineAndWaitReturnsAfterRetirement()
|
|
{
|
|
if (_vulkan is null) return;
|
|
var device = new LoggingTickDevice(_vulkan.NewTickDevice());
|
|
using var scheduler = new SubmissionScheduler(device, new RecordingRenderingState());
|
|
scheduler.Begin(new SubmissionContext());
|
|
|
|
Assert.Equal(1UL, scheduler.Submit());
|
|
scheduler.Wait(1);
|
|
|
|
Assert.True(scheduler.IsTickComplete(1));
|
|
Assert.Equal(1UL, _vulkan.ReadSemaphore(device.TimelineHandle));
|
|
scheduler.BeginCommand();
|
|
Assert.Equal(2UL, scheduler.Flush());
|
|
scheduler.FlushAndWait();
|
|
Assert.Equal(3UL, scheduler.Timeline.CompletedTick);
|
|
Assert.Equal(4UL, scheduler.CurrentTick);
|
|
}
|
|
|
|
[Fact]
|
|
public void UrgentWorkRunsOnThePriorityThreadAfterRetirementAndDeferredWorkFollows()
|
|
{
|
|
if (_vulkan is null) return;
|
|
using var scheduler = new SubmissionScheduler(_vulkan.NewTickDevice(), new RecordingRenderingState());
|
|
scheduler.Begin(new SubmissionContext());
|
|
var order = new List<string>();
|
|
var urgentThread = 0;
|
|
scheduler.QueuePriorityCompletionAction(() =>
|
|
{
|
|
urgentThread = Environment.CurrentManagedThreadId;
|
|
lock (order)
|
|
{
|
|
order.Add("urgent");
|
|
}
|
|
});
|
|
scheduler.QueueCompletionAction(() => order.Add("deferred"));
|
|
|
|
scheduler.Submit();
|
|
|
|
Assert.True(WaitUntil(() => order.Count == 1));
|
|
Assert.NotEqual(Environment.CurrentManagedThreadId, urgentThread);
|
|
scheduler.Wait(1);
|
|
scheduler.RunCompletedOperations();
|
|
Assert.Equal(new[] { "urgent", "deferred" }, order);
|
|
}
|
|
|
|
[Fact]
|
|
public void ShutdownWithInFlightWorkSubmitsTheOpenBufferAndReleasesTheDevice()
|
|
{
|
|
if (_vulkan is null) return;
|
|
var device = new LoggingTickDevice(_vulkan.NewTickDevice());
|
|
var scheduler = new SubmissionScheduler(device, new RecordingRenderingState());
|
|
scheduler.Begin(new SubmissionContext());
|
|
scheduler.Flush();
|
|
scheduler.Flush();
|
|
scheduler.Flush();
|
|
var ran = false;
|
|
scheduler.QueueCompletionAction(() => ran = true);
|
|
|
|
scheduler.Dispose();
|
|
|
|
Assert.True(ran);
|
|
Assert.False(scheduler.PriorityWorkerAlive);
|
|
Assert.Equal(new[] { "submit 1", "submit 2", "submit 3", "submit 4" }, device.Log.Where(e => e.StartsWith("submit", StringComparison.Ordinal)));
|
|
}
|
|
|
|
[Fact]
|
|
public void TwoSchedulersShareTheQueueWithIndependentTimelines()
|
|
{
|
|
if (_vulkan is null) return;
|
|
var rendererDevice = new LoggingTickDevice(_vulkan.NewTickDevice());
|
|
var presentDevice = new LoggingTickDevice(_vulkan.NewTickDevice());
|
|
using var renderer = new SubmissionScheduler(rendererDevice, new RecordingRenderingState());
|
|
using var present = new SubmissionScheduler(presentDevice, new RecordingRenderingState());
|
|
renderer.Begin(new SubmissionContext());
|
|
|
|
renderer.Flush();
|
|
present.BeginCommand();
|
|
Assert.Equal(1UL, present.Submit());
|
|
Assert.Equal(2UL, renderer.Submit());
|
|
renderer.Wait(2);
|
|
present.Wait(1);
|
|
|
|
Assert.Equal(2UL, _vulkan.ReadSemaphore(rendererDevice.TimelineHandle));
|
|
Assert.Equal(1UL, _vulkan.ReadSemaphore(presentDevice.TimelineHandle));
|
|
Assert.False(present.Active);
|
|
Assert.NotEqual(rendererDevice.TimelineHandle, presentDevice.TimelineHandle);
|
|
}
|
|
|
|
[Fact]
|
|
public void BundleWaitsOnAnEarlierSignalOfAnotherTimeline()
|
|
{
|
|
if (_vulkan is null) return;
|
|
using var scheduler = new SubmissionScheduler(_vulkan.NewTickDevice(), new RecordingRenderingState());
|
|
scheduler.Begin(new SubmissionContext());
|
|
var label = _vulkan.CreateTimelineSemaphore();
|
|
try
|
|
{
|
|
var signal = new SubmitBundle();
|
|
signal.AddSignal(label, 5);
|
|
scheduler.Flush(signal);
|
|
var wait = new SubmitBundle();
|
|
wait.AddWait(label, 5);
|
|
var tick = scheduler.Flush(wait);
|
|
scheduler.Wait(tick);
|
|
|
|
Assert.Equal(5UL, _vulkan.ReadSemaphore(label));
|
|
Assert.Equal((1, 0), (wait.WaitCount, wait.SignalCount));
|
|
}
|
|
finally
|
|
{
|
|
_vulkan.DestroySemaphore(label);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void RelayRunsSynchronousCallsOnTheWorkerAndInlineFromIt()
|
|
{
|
|
if (_vulkan is null) return;
|
|
using var worker = new GpuWorker(_vulkan);
|
|
|
|
var callThread = 0;
|
|
worker.Relay.RunOnGpuQueue(() => callThread = Environment.CurrentManagedThreadId);
|
|
Assert.Equal(worker.ThreadId, callThread);
|
|
|
|
var inlineThread = 0;
|
|
worker.RunOnWorker(() => worker.Relay.RunOnGpuQueue(() => inlineThread = Environment.CurrentManagedThreadId));
|
|
Assert.Equal(worker.ThreadId, inlineThread);
|
|
Assert.False(worker.Relay.HasPendingCommands);
|
|
}
|
|
|
|
[Fact]
|
|
public void PlainRelayCallbackDoesNotWaitForGpuRetirement()
|
|
{
|
|
if (_vulkan is null) return;
|
|
using var worker = new GpuWorker(_vulkan);
|
|
worker.RunOnWorker(() =>
|
|
{
|
|
worker.Scheduler.Begin(new SubmissionContext());
|
|
worker.Scheduler.Submit();
|
|
});
|
|
|
|
var ran = false;
|
|
worker.Relay.RunOnGpuQueue(() => ran = true);
|
|
|
|
Assert.True(ran);
|
|
Assert.Equal(new[] { "submit 1" }, worker.Device.Log);
|
|
}
|
|
|
|
[Fact]
|
|
public void UnmapThroughTheRelaySubmitsTheOpenBufferWaitsItsTickAndCompletesUrgentWorkFirst()
|
|
{
|
|
if (_vulkan is null) return;
|
|
using var worker = new GpuWorker(_vulkan);
|
|
var stores = new ObservingStores { Scheduler = worker.Scheduler };
|
|
using var memory = new GuestGpuMemory(new RecordingAddressSpace());
|
|
memory.AttachStores(stores, stores);
|
|
memory.Register(0x10000, 0x1000, GuestPageProtection.Read | GuestPageProtection.Write);
|
|
memory.AttachGpuQueue(worker.Relay, worker.Scheduler);
|
|
var order = new List<string>();
|
|
worker.RunOnWorker(() =>
|
|
{
|
|
worker.Scheduler.Begin(new SubmissionContext());
|
|
worker.Scheduler.QueuePriorityCompletionAction(() =>
|
|
{
|
|
lock (order)
|
|
{
|
|
order.Add("urgent");
|
|
}
|
|
});
|
|
});
|
|
Assert.DoesNotContain(worker.Device.Log, entry => entry.StartsWith("submit", StringComparison.Ordinal));
|
|
|
|
memory.Unregister(0x10000, 0x1000);
|
|
|
|
Assert.Contains("submit 1", worker.Device.Log);
|
|
Assert.Equal(new[] { "urgent" }, order);
|
|
var call = Assert.Single(stores.Calls);
|
|
Assert.Equal(worker.ThreadId, call.Thread);
|
|
Assert.True(call.CompletedTick >= 1);
|
|
Assert.Equal(2UL, worker.Scheduler.CurrentTick);
|
|
Assert.False(memory.Covers(0x10000, 0x1000));
|
|
memory.AttachGpuQueue(null, null);
|
|
}
|
|
|
|
[Fact]
|
|
public void UnmapRacingClosureNeverReleasesBeforeRetirement()
|
|
{
|
|
if (_vulkan is null) return;
|
|
using var fatal = new FatalScope();
|
|
var onWorker = 0;
|
|
var afterDetach = 0;
|
|
for (var iteration = 0; iteration < 12; iteration++)
|
|
{
|
|
using var worker = new GpuWorker(_vulkan);
|
|
var stores = new ObservingStores { Scheduler = worker.Scheduler };
|
|
using var memory = new GuestGpuMemory(new RecordingAddressSpace());
|
|
memory.AttachStores(stores, stores);
|
|
memory.Register(0x10000, 0x1000, GuestPageProtection.Read | GuestPageProtection.Write);
|
|
memory.AttachGpuQueue(worker.Relay, worker.Scheduler);
|
|
worker.RunOnWorker(() =>
|
|
{
|
|
worker.Scheduler.Begin(new SubmissionContext());
|
|
worker.Scheduler.Submit();
|
|
});
|
|
|
|
using var closed = new ManualResetEventSlim();
|
|
worker.Post(() =>
|
|
{
|
|
worker.Close(memory);
|
|
closed.Set();
|
|
});
|
|
memory.Unregister(0x10000, 0x1000);
|
|
closed.Wait();
|
|
|
|
var call = Assert.Single(stores.Calls);
|
|
Assert.True(call.CompletedTick >= 1, $"iteration {iteration}: release before retirement");
|
|
if (call.Thread == worker.ThreadId)
|
|
{
|
|
onWorker++;
|
|
}
|
|
else
|
|
{
|
|
Assert.Equal(Environment.CurrentManagedThreadId, call.Thread);
|
|
afterDetach++;
|
|
}
|
|
|
|
Assert.False(memory.Covers(0x10000, 0x1000));
|
|
}
|
|
|
|
Assert.Equal(12, onWorker + afterDetach);
|
|
Assert.Empty(fatal.Messages);
|
|
}
|
|
}
|