sharpemu/tests/SharpEmu.Libs.Tests/Gpu/Buffers/CacheHarness.cs

313 lines
9.7 KiB
C#

// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using SharpEmu.Core.Memory;
using SharpEmu.HLE;
using SharpEmu.HLE.GpuMemory;
using SharpEmu.HLE.Host;
using SharpEmu.Libs.Gpu.Buffers;
using SharpEmu.Libs.Gpu.Images;
using SharpEmu.Libs.Gpu.Scheduling;
using SharpEmu.Libs.Tests.Gpu.Scheduling;
using SharpEmu.Libs.Tests.Gpu.Vulkan;
using SharpEmu.Libs.Tests.Memory.HostViews;
using Silk.NET.Vulkan;
using Xunit;
namespace SharpEmu.Libs.Tests.Gpu.Buffers;
// Models the render loop: relay commands first, then one unit of guest work.
// Scheduler callbacks a test supplies when a presenter under test must see the ticks.
internal sealed record SchedulerHooks(IRenderingState Rendering, Action<SubmitBundle>? PrepareSubmit, Action<ulong>? Submitted);
internal sealed class CacheWorker : IDisposable
{
private readonly object _gate = new();
private readonly Queue<Action> _guestWork = new();
private readonly Thread _thread;
private bool _stop;
public CacheWorker(HeadlessVulkan vulkan, SchedulerHooks? hooks = null)
{
Relay = new GpuWorkerRelay(Wake);
Device = new LoggingTickDevice(vulkan.NewTickDevice());
Scheduler = new SubmissionScheduler(Device, hooks?.Rendering ?? new RecordingRenderingState(), hooks?.PrepareSubmit, hooks?.Submitted);
_thread = new Thread(Run) { IsBackground = true };
_thread.Start();
}
public GpuWorkerRelay Relay { get; }
public LoggingTickDevice Device { get; }
public SubmissionScheduler Scheduler { get; }
public void Post(Action work)
{
lock (_gate)
{
_guestWork.Enqueue(work);
Monitor.PulseAll(_gate);
}
}
public void Run(Action work)
{
Exception? failure = null;
using var done = new ManualResetEventSlim();
Post(() =>
{
try
{
work();
}
catch (Exception exception)
{
failure = exception;
}
done.Set();
});
done.Wait();
if (failure != null)
{
throw failure;
}
}
public T Run<T>(Func<T> work)
{
T result = default!;
Run(new Action(() => result = work()));
return result;
}
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();
}
}
}
// Backed guest memory, the manager with its ledger, the worker and the cache under test.
internal sealed class CacheHarness : IDisposable
{
private const ulong GuestBase = 0x2_0000_0000;
private const ulong BackingBytes = 32UL * 1024 * 1024;
private readonly IHostMemory _host = HostViewTestSupport.PlatformMemory;
private readonly IHostViewMemory _views;
private readonly HeadlessVulkan _vulkan;
private readonly Action<string> _previousPageFatal = PageGuard.OnFatal;
private ulong _nextBackingOffset;
private bool _shutDown;
private bool _shutdownFailed;
public CacheHarness(
HeadlessVulkan vulkan,
Func<PhysicalVirtualMemory, IGuestBackedSpace>? backing = null,
bool readbackLinearImages = false,
ulong backingBytes = BackingBytes,
SchedulerHooks? hooks = null,
bool startScheduler = true,
IHostViewMemory? viewHost = null)
{
_vulkan = vulkan;
_views = viewHost ?? HostViewMemory.Create();
PageGuard.OnFatal = message => throw new SchedulerFatalException(message);
Memory = new PhysicalVirtualMemory(viewHost: _views, backingBytes: backingBytes);
Gpu = new GuestGpuMemory(Memory);
Worker = new CacheWorker(vulkan, hooks);
if (startScheduler)
{
Worker.Run(() => Worker.Scheduler.Begin(new SubmissionContext { QueueName = "cache.test", SubmissionId = 1 }));
}
var space = backing?.Invoke(Memory) ?? Memory;
Cache = new GuestBufferCache(vulkan.DeviceInfo, Worker.Scheduler, Worker.Relay, Gpu.Pages, Memory, space);
Images = new GuestImageCache(vulkan.DeviceInfo, Worker.Scheduler, Gpu.Pages, Cache, space, readbackLinearImages);
Cache.ImageCache = Images;
Gpu.AttachStores(Cache, Images);
Gpu.AttachGpuQueue(Worker.Relay, Worker.Scheduler);
}
public PhysicalVirtualMemory Memory { get; }
public GuestGpuMemory Gpu { get; }
public CacheWorker Worker { get; }
public GuestBufferCache Cache { get; }
public GuestImageCache Images { get; }
public IGuestBufferStore Store => Cache;
public IGuestImageStore ImageStore => Images;
public HeadlessVulkan Vulkan => _vulkan;
public SubmissionScheduler Scheduler => Worker.Scheduler;
// A backed view inside the guest window, registered with the manager under the given protection.
public ulong MapBacked(ulong size, GuestPageProtection protection)
{
var hole = HostViewTestSupport.AlignUp(size, _views.Granularity);
Assert.True(Memory.TryHoldRangeAtOrAbove(GuestBase, hole, _views.Granularity, out var address));
Assert.True(Memory.TryMapBacked(address, size, _nextBackingOffset, protection, out _));
_nextBackingOffset += hole;
Gpu.Register(address, size, protection);
return address;
}
// Two backed granules around one that stays a bare hole: registered, but without backing.
public (ulong First, ulong Granule, ulong Last) MapBackedSandwich()
{
var granule = _views.Granularity;
Assert.True(Memory.TryHoldRangeAtOrAbove(GuestBase, 3 * granule, granule, out var first));
Assert.True(Memory.TryMapBacked(first, granule, _nextBackingOffset, GuestPageProtection.Read | GuestPageProtection.Write, out _));
Assert.True(Memory.TryMapBacked(first + 2 * granule, granule, _nextBackingOffset + granule, GuestPageProtection.Read | GuestPageProtection.Write, out _));
_nextBackingOffset += 2 * granule;
Gpu.Register(first, granule, GuestPageProtection.Read | GuestPageProtection.Write);
Gpu.Register(first + 2 * granule, granule, GuestPageProtection.Read | GuestPageProtection.Write);
return (first, granule, first + 2 * granule);
}
public ulong MapPrivate(ulong size)
{
Assert.True(Memory.TryAllocateAtOrAbove(GuestBase, size, executable: false, 0x4000, out var address));
Gpu.Register(address, size, GuestPageProtection.Read | GuestPageProtection.Write);
return address;
}
public HostPageProtection Protection(ulong address)
{
Assert.True(_host.Query(address, out var info));
return info.Protection;
}
public byte[] Read(ulong address, int size)
{
var bytes = new byte[size];
Assert.True(Memory.TryRead(address, bytes));
return bytes;
}
public void Write(ulong address, byte[] bytes) => Assert.True(Memory.TryWriteBacking(address, bytes));
// Copies a device range into a fresh download buffer and waits for it.
public byte[] ReadBack(GpuBuffer buffer, ulong offset, ulong size) => Worker.Run(() =>
{
using var download = new GpuBuffer(_vulkan.DeviceInfo, Scheduler, GpuBufferUsage.Download, 0, GpuBuffer.AllFlags, size);
download.CopyFrom(Scheduler.Current, buffer, offset, 0, size, AccessFlags.MemoryWriteBit, AccessFlags.None, AccessFlags.MemoryReadBit | AccessFlags.MemoryWriteBit, AccessFlags.HostReadBit);
Scheduler.Finish();
download.Invalidate(0, size);
return download.Mapped[..(int)size].ToArray();
});
// A test that shut the stores down through another path marks the harness as done.
public void MarkShutDown() => _shutDown = true;
public void Shutdown()
{
Worker.Run(ShutdownOnWorker);
_vulkan.AssertNoValidationMessages();
}
private void ShutdownOnWorker()
{
_shutDown = true;
Worker.Relay.StopAcceptingWork();
Worker.Relay.RunPendingCommands();
try
{
Images.Shutdown();
Cache.Shutdown();
}
catch (Exception)
{
_shutdownFailed = true;
throw;
}
finally
{
Gpu.AttachStores(null, null);
if (Scheduler.Active)
{
Scheduler.Finish();
Scheduler.WaitForAllPriorityOperations();
}
Scheduler.Shutdown();
Gpu.AttachGpuQueue(null, null);
}
}
// A failed test still tears down in order so the guard fatal does not hide the assertion.
public void Dispose()
{
if (!_shutDown)
{
try
{
Shutdown();
}
catch (Exception)
{
}
}
Images.Dispose();
Cache.Dispose();
Worker.Dispose();
if (_shutdownFailed)
{
PageGuard.OnFatal = _ => { };
}
Gpu.Dispose();
Memory.Dispose();
PageGuard.OnFatal = _previousPageFatal;
}
}