Compare commits

...

6 Commits

Author SHA1 Message Date
kuba 4e75d4456d [Tests] Enable portable Vulkan devices and release fixture slabs 2026-10-06 23:59:17 +02:00
kuba 9786042729 [GPU] Read clean command bytes without faulting on neighbouring GPU data 2026-10-06 23:19:01 +02:00
kuba 2c0ab66c5a [GPU] Read guest buffers back from unified memory without a copy
A CPU read of GPU-written guest memory recorded a copy at the tail of
the current command buffer and waited for it, so it also waited for
every draw recorded after the writer. In Silent Hill's title menu most
of those reads targeted buffers whose writer had retired long before,
and UE's render thread spent a third of its time in them.

On a device with device-local, host-visible, coherent memory (Apple
GPUs), guest buffers are now allocated there. A readback whose writers
retired copies straight out of the buffer's mapping; one whose writer
is submitted but still running waits for that submission alone and then
copies; the command worker submits a writer that is still recording
and reads after it. A guest-thread wait also marks its window hot, so
the next GPU write there is submitted right after its command.
SHARPEMU_UNIFIED_BUFFERS=0 keeps device-local buffers.
2026-10-06 22:18:30 +02:00
kuba 2e2fc464c2 [GPU] Re-issue a stale pending readback instead of reading synchronously on the worker
A guest read of GPU-written memory records a copy and waits for its tick
off the queue. When another GPU write lands first, the completion fell
back to a synchronous read on the command worker, which stalled it until
the GPU drained (up to half of SH's readbacks). The guest thread now
re-issues the pending read and waits again; only the fourth attempt falls
back to the synchronous read.

(cherry picked from commit 17bf5bb958ebfd4dd980dbf241cbe9e77fffd938)
2026-10-06 04:07:10 +02:00
kuba b167a565d0 [GPU] Let guest reads of GPU-written memory wait on the main queue off the worker
A guest thread reading memory the GPU wrote hands the download to the
command worker, which recorded the copies, submitted and then waited for
the whole GPU before handing the bytes back - unless a dedicated readback
queue existed, where the download ran asynchronously and only the guest
thread waited. MoltenVK exposes one queue family in-process, so on macOS
every such read idled the worker for tens of milliseconds, several times
per frame in Silent Hill: The Short Message's menu (a quarter of the
worker's time).

Without a readback queue the worker now copies the pieces into a buffer
of their own, submits without waiting and returns a pending download
carrying the submission tick; the guest thread waits for that tick on the
timeline (safe from any thread) and completes the read back on the
worker, as the readback-queue path does. The worker's GPU wait time drops
to zero; SHARPEMU_MAIN_QUEUE_PENDING_READS=0 restores the synchronous
path.

(cherry picked from commit 6c9ad44bcea7f9396a7564621fcbda3841834052)
2026-10-06 04:07:10 +02:00
kuba 2239f01987 [Vk] Run async readbacks on a second general queue family when no dedicated one exists
The async readback queue was only taken from a family without graphics,
so drivers that expose several general families (each its own hardware
queue) kept every guest readback on the main queue and the command
worker. Any other family with transfer support now qualifies after the
dedicated transfer and compute families.

(cherry picked from commit 7b04d6185dfc1711f0fbdf5c75c842a065acd1fa)
2026-10-06 04:07:10 +02:00
10 changed files with 517 additions and 13 deletions

View File

@ -149,6 +149,9 @@ public unsafe class GpuBuffer : IDisposable
public bool IsCoherent { get; }
// The host mapping of the whole buffer, or null for memory the host cannot map.
public byte* MappedPointer => _mapped;
public GpuBufferUsage Usage { get; }
public ulong CpuAddress { get; }
@ -339,6 +342,7 @@ public unsafe class GpuBuffer : IDisposable
var (required, preferred, avoided) = usage switch
{
GpuBufferUsage.DeviceLocal => (MemoryPropertyFlags.None, MemoryPropertyFlags.DeviceLocalBit, MemoryPropertyFlags.None),
GpuBufferUsage.Unified => (MemoryPropertyFlags.HostVisibleBit | MemoryPropertyFlags.HostCoherentBit | MemoryPropertyFlags.DeviceLocalBit, MemoryPropertyFlags.HostCachedBit, MemoryPropertyFlags.None),
GpuBufferUsage.Upload => (MemoryPropertyFlags.HostVisibleBit, MemoryPropertyFlags.HostCoherentBit, MemoryPropertyFlags.DeviceLocalBit),
GpuBufferUsage.Download => (MemoryPropertyFlags.HostVisibleBit, MemoryPropertyFlags.HostCoherentBit | MemoryPropertyFlags.HostCachedBit, MemoryPropertyFlags.DeviceLocalBit),
_ => (MemoryPropertyFlags.HostVisibleBit, MemoryPropertyFlags.HostCoherentBit | MemoryPropertyFlags.DeviceLocalBit, MemoryPropertyFlags.None),

View File

@ -9,4 +9,6 @@ public enum GpuBufferUsage : byte
Upload,
Download,
Stream,
// Device-local memory the host can map: guest buffers on unified-memory devices, read back without a copy.
Unified,
}

View File

@ -41,6 +41,7 @@ public sealed unsafe class GuestBufferCache : IGuestBufferStore, IDisposable
private readonly SubmissionScheduler _scheduler;
private readonly IGpuQueueRelay _relay;
private readonly GuestBufferUploader _uploader;
private readonly bool _unifiedBuffers;
private readonly IGuestBackedSpace _backing;
private readonly BdaFaultProcessor _faults;
private readonly GpuBuffer _gds;
@ -72,6 +73,7 @@ public sealed unsafe class GuestBufferCache : IGuestBufferStore, IDisposable
_relay = relay;
_backing = backing;
_faults = new BdaFaultProcessor(device, scheduler, this, CachingPageBits, CachingPageCount);
_unifiedBuffers = UnifiedBuffersEnabled && HasUnifiedMemoryType(device);
_gds = new GpuBuffer(device, scheduler, GpuBufferUsage.Stream, 0, GpuBuffer.AllFlags, GdsBufferSize);
_bdaPageTable = new GpuBuffer(device, scheduler, GpuBufferUsage.DeviceLocal, 0, GpuBuffer.AllFlags, BdaPageTableSize);
_tracker = new GuestPageTracker(pages);
@ -326,6 +328,18 @@ public sealed unsafe class GuestBufferCache : IGuestBufferStore, IDisposable
return (owner, owner.Offset(guestAddress));
}
// The command worker can read the stable backing alias without changing CPU page
// permissions. A GPU counter elsewhere on the same page is not a dependency of
// the packet bytes being decoded. Actual GPU-written bytes retain the slow path.
private static readonly bool CommandBackingReadsEnabled =
Environment.GetEnvironmentVariable("SHARPEMU_COMMAND_BACKING_READS") != "0";
public bool TryReadCommandBacking(ulong address, Span<byte> destination) =>
CommandBackingReadsEnabled && destination.Length != 0 &&
!_gpuModifiedRanges.Overlaps(address, (ulong)destination.Length) &&
ImageCache is { } images && !images.HasGpuModifiedImageBytes(address, (ulong)destination.Length) &&
_backing.TryReadBacking(address, destination);
public void WriteHostMemory(ulong guestAddress, ReadOnlySpan<byte> data)
{
if (guestAddress == 0 || data.IsEmpty || (ulong)data.Length > ulong.MaxValue - guestAddress)
@ -896,24 +910,45 @@ public sealed unsafe class GuestBufferCache : IGuestBufferStore, IDisposable
$"unsupported buffer readback from an asynchronous GPU completion, addr=0x{guestAddress:X16} size=0x{size:X16}");
}
if (!_relay.IsGpuQueueThread && AsyncReadback is { } readback && GuestReadsAwaitOffQueue)
if (!_relay.IsGpuQueueThread && GuestReadsAwaitOffQueue && (AsyncReadback is not null || MainQueuePendingReads))
{
PendingDownload? pending = null;
if (_relay.TryRunOnGpuQueue(() => pending = BeginReadMemoryOnGpu(guestAddress, size, isWrite, source, allowPending: true)))
// A GPU write that lands after the copy was recorded makes the read stale. Retrying
// synchronously stalls the command worker until the GPU drains, so re-issue the read and
// wait here again; only the last attempt falls back to the synchronous read.
for (var attempt = 1; ; attempt++)
{
PendingDownload? pending = null;
if (!_relay.TryRunOnGpuQueue(() => pending = BeginReadMemoryOnGpu(guestAddress, size, isWrite, source, allowPending: true)))
{
return AwaitShutdown();
}
if (pending is null)
{
return true;
}
readback.Wait(pending.Ticket);
if (_relay.TryRunOnGpuQueue(() => CompleteReadMemoryOnGpu(pending, retrySynchronously: true)))
if (pending.Ticket is { } ticket)
{
(AsyncReadback ?? throw SubmissionScheduler.Fatal("The pending readback lost its queue.")).Wait(ticket);
}
else
{
_scheduler.WaitForSubmittedTick(pending.MainQueueTick);
}
var finalAttempt = attempt >= PendingReadAttempts;
var applied = false;
if (!_relay.TryRunOnGpuQueue(() => applied = CompleteReadMemoryOnGpu(pending, retrySynchronously: finalAttempt)))
{
return AwaitShutdown();
}
if (applied || finalAttempt)
{
return true;
}
}
return AwaitShutdown();
}
var onQueue = _relay.IsGpuQueueThread;
@ -1144,9 +1179,14 @@ public sealed unsafe class GuestBufferCache : IGuestBufferStore, IDisposable
public static string TakeAsyncReadbackReport() => FormattableString.Invariant(
$"[PERF][ASYNC_READBACK] applied={Interlocked.Exchange(ref _reportedApplied, 0)} retried={Interlocked.Exchange(ref _reportedRetried, 0)} eager_started={Interlocked.Exchange(ref _reportedEagerStarted, 0)} eager_used={Interlocked.Exchange(ref _reportedEagerUsed, 0)} eager_stale={Interlocked.Exchange(ref _reportedEagerStale, 0)}");
// Ticket is set when the readback queue carries the download; otherwise the main queue
// does, into MainQueueBuffer, and MainQueueTick is the submission the guest thread waits for.
private sealed record PendingDownload
{
public required VulkanAsyncReadback.Ticket Ticket { get; init; }
public VulkanAsyncReadback.Ticket? Ticket { get; init; }
public ulong MainQueueTick { get; init; }
public GpuBuffer? MainQueueBuffer { get; init; }
public ulong[] MainQueueOffsets { get; init; } = [];
public required List<DownloadPiece> Copies { get; init; }
public required ulong[] WriteTicks { get; init; }
public required ulong WindowBegin { get; init; }
@ -1156,6 +1196,9 @@ public sealed unsafe class GuestBufferCache : IGuestBufferStore, IDisposable
public required bool IsWrite { get; init; }
public required GuestMemoryProfile.ReadbackSource Source { get; init; }
public required long Started { get; init; }
// Read from the buffers' own host mappings once the writer tick retires; no copy was recorded.
public bool Mapped { get; init; }
}
private PendingDownload? BeginReadMemoryOnGpu(ulong guestAddress, ulong size, bool isWrite, GuestMemoryProfile.ReadbackSource source,
@ -1179,6 +1222,53 @@ public sealed unsafe class GuestBufferCache : IGuestBufferStore, IDisposable
{
NoteHotReadback(guestAddress, size);
}
if (copies.Count != 0 && _unifiedBuffers && AreMapped(copies, out var writer))
{
if (writer == 0 || _scheduler.IsTickComplete(writer))
{
ApplyMappedRead(copies, windowBegin, windowEnd, guestAddress, size, isWrite, readbackStarted, source);
return null;
}
if (allowPending && writer < _scheduler.CurrentTick)
{
// A guest thread had to wait for this writer: submit later writes to the window right
// after their command, so the next wait ends with them instead of with their whole batch.
NoteHotReadback(guestAddress, size);
// The writer is already submitted: wait for it alone, not for the work queued after it.
var mappedTicks = new ulong[copies.Count];
for (var index = 0; index < copies.Count; index++)
{
mappedTicks[index] = copies[index].Buffer.LastGpuWriteTick;
copies[index].Buffer.RetainForeignRead();
}
return new PendingDownload
{
Mapped = true,
MainQueueTick = writer,
Copies = copies,
WriteTicks = mappedTicks,
WindowBegin = windowBegin,
WindowEnd = windowEnd,
GuestAddress = guestAddress,
Size = size,
IsWrite = isWrite,
Source = source,
Started = readbackStarted,
};
}
if (!allowPending)
{
// On the command worker: submit the writer if it is still recording, then read the mapping.
_scheduler.Wait(writer);
ApplyMappedRead(copies, windowBegin, windowEnd, guestAddress, size, isWrite, readbackStarted, source);
return null;
}
}
if (copies.Count != 0 && allowPending && TryBeginDownloadAsync(copies, out var ticket, out var writeTicks))
{
foreach (var copy in copies)
@ -1201,6 +1291,31 @@ public sealed unsafe class GuestBufferCache : IGuestBufferStore, IDisposable
};
}
if (copies.Count != 0 && allowPending &&
TryBeginDownloadOnMainQueue(copies, out var mainTick, out var mainBuffer, out var mainOffsets, out var mainWriteTicks))
{
foreach (var copy in copies)
{
copy.Buffer.RetainForeignRead();
}
return new PendingDownload
{
MainQueueTick = mainTick,
MainQueueBuffer = mainBuffer,
MainQueueOffsets = mainOffsets,
Copies = copies,
WriteTicks = mainWriteTicks,
WindowBegin = windowBegin,
WindowEnd = windowEnd,
GuestAddress = guestAddress,
Size = size,
IsWrite = isWrite,
Source = source,
Started = readbackStarted,
};
}
if (copies.Count != 0)
{
DownloadBufferMemory(copies);
@ -1216,9 +1331,75 @@ public sealed unsafe class GuestBufferCache : IGuestBufferStore, IDisposable
return null;
}
// Without a readback queue the main queue copies the pieces into a buffer of their own and
// submits at once; the guest thread then waits for that submission instead of the worker
// waiting for the whole GPU (a guest read of GPU-written memory otherwise idled the worker
// for tens of milliseconds, several times per frame in Silent Hill: The Short Message).
private bool TryBeginDownloadOnMainQueue(List<DownloadPiece> copies, out ulong tick, out GpuBuffer buffer, out ulong[] offsets, out ulong[] writeTicks)
{
tick = 0;
buffer = null!;
offsets = [];
writeTicks = [];
if (!MainQueuePendingReads || copies.Count == 0)
{
return false;
}
var ticks = new ulong[copies.Count];
var placements = new ulong[copies.Count];
var total = 0UL;
for (var index = 0; index < copies.Count; index++)
{
var written = copies[index].Buffer.LastGpuWriteTick;
if (written == 0)
{
return false;
}
ticks[index] = written;
placements[index] = total;
total += (copies[index].Size + BufferDownloadBatchPlanner.Alignment - 1) & ~(BufferDownloadBatchPlanner.Alignment - 1);
}
if (total > AsyncReadbackLimit)
{
return false;
}
buffer = new GpuBuffer(_device, _scheduler, GpuBufferUsage.Download, 0, BufferUsageFlags.TransferDstBit, total);
for (var index = 0; index < copies.Count; index++)
{
buffer.CopyFrom(
_scheduler.Current, copies[index].Buffer, copies[index].SourceOffset, placements[index], copies[index].Size,
AccessFlags.MemoryWriteBit, AccessFlags.None, AccessFlags.MemoryReadBit | AccessFlags.MemoryWriteBit, AccessFlags.HostReadBit);
}
tick = _scheduler.Flush();
offsets = placements;
writeTicks = ticks;
return true;
}
private static readonly bool MainQueuePendingReads =
Environment.GetEnvironmentVariable("SHARPEMU_MAIN_QUEUE_PENDING_READS") != "0";
// Pending reads a guest thread re-issues before it lets the worker read synchronously.
private const int PendingReadAttempts = 4;
private bool CompleteReadMemoryOnGpu(PendingDownload pending, bool retrySynchronously)
{
using var readbackScope = GuestMemoryProfile.Measure(GuestMemoryProfile.Operation.BufferReadback);
if (pending.Mapped)
{
return CompleteMappedRead(pending, retrySynchronously);
}
if (pending.Ticket is null)
{
return CompleteMainQueueRead(pending, retrySynchronously);
}
var readback = AsyncReadback ?? throw SubmissionScheduler.Fatal("The pending readback lost its queue.");
try
{
@ -1269,6 +1450,155 @@ public sealed unsafe class GuestBufferCache : IGuestBufferStore, IDisposable
}
}
// Guest buffers live in host-visible device memory on unified-memory devices (Apple GPUs), so a
// readback can copy straight out of the buffer once the last GPU write to it retired. That skips
// the copy command at the tail of the queue and the wait for every draw recorded after the writer.
// SHARPEMU_UNIFIED_BUFFERS=0 keeps device-local buffers and copy readbacks.
private static readonly bool UnifiedBuffersEnabled =
Environment.GetEnvironmentVariable("SHARPEMU_UNIFIED_BUFFERS") != "0";
private static bool HasUnifiedMemoryType(GpuDeviceInfo device)
{
const MemoryPropertyFlags unified = MemoryPropertyFlags.DeviceLocalBit | MemoryPropertyFlags.HostVisibleBit | MemoryPropertyFlags.HostCoherentBit;
for (uint index = 0; index < device.MemoryTypeCount; index++)
{
if ((device.GetMemoryTypeFlags(index) & unified) == unified)
{
return true;
}
}
return false;
}
private static bool AreMapped(List<DownloadPiece> copies, out ulong writer)
{
writer = 0;
foreach (var copy in copies)
{
if (copy.Buffer.MappedPointer == null || !copy.Buffer.IsCoherent)
{
return false;
}
writer = Math.Max(writer, copy.Buffer.LastGpuWriteTick);
}
return true;
}
private void ApplyMappedRead(List<DownloadPiece> copies, ulong windowBegin, ulong windowEnd, ulong guestAddress, ulong size,
bool isWrite, long started, GuestMemoryProfile.ReadbackSource source)
{
foreach (var copy in copies)
{
var bytes = new ReadOnlySpan<byte>(copy.Buffer.MappedPointer + copy.SourceOffset, checked((int)copy.Size));
if (!_backing.TryWriteBacking(copy.Address, bytes))
{
throw SubmissionScheduler.Fatal($"Could not write the required direct backing: addr=0x{copy.Address:X16} size=0x{copy.Size:X16}");
}
_gpuModifiedRanges.Remove(copy.Address, copy.Size);
}
_tracker.ClearGpuDirtyPages(windowBegin, windowEnd - windowBegin);
if (isWrite)
{
_tracker.MarkCpuDirtyPages(guestAddress, size);
}
MappedReadbacks++;
RecordReadback(windowBegin, windowEnd, isWrite, copies, started, source);
}
// Readbacks served from a buffer's own mapping, without a copy command.
internal long MappedReadbacks { get; private set; }
internal bool UnifiedBuffers => _unifiedBuffers;
private bool CompleteMappedRead(PendingDownload pending, bool retrySynchronously)
{
try
{
if (!IsPendingDownloadCurrent(pending))
{
if (retrySynchronously)
{
PendingReadbacksRetried++;
Interlocked.Increment(ref _reportedRetried);
ReadMemoryOnGpu(pending.GuestAddress, pending.Size, pending.IsWrite, pending.Source);
}
return false;
}
PendingReadbacksApplied++;
Interlocked.Increment(ref _reportedApplied);
ApplyMappedRead(pending.Copies, pending.WindowBegin, pending.WindowEnd, pending.GuestAddress, pending.Size,
pending.IsWrite, pending.Started, pending.Source);
return true;
}
finally
{
foreach (var copy in pending.Copies)
{
copy.Buffer.ReleaseForeignRead();
}
}
}
private bool CompleteMainQueueRead(PendingDownload pending, bool retrySynchronously)
{
var buffer = pending.MainQueueBuffer ?? throw SubmissionScheduler.Fatal("The pending readback lost its buffer.");
try
{
if (!IsPendingDownloadCurrent(pending))
{
if (retrySynchronously)
{
PendingReadbacksRetried++;
Interlocked.Increment(ref _reportedRetried);
ReadMemoryOnGpu(pending.GuestAddress, pending.Size, pending.IsWrite, pending.Source);
}
return false;
}
PendingReadbacksApplied++;
Interlocked.Increment(ref _reportedApplied);
var copies = pending.Copies;
buffer.Invalidate(0, buffer.Size);
for (var index = 0; index < copies.Count; index++)
{
var bytes = buffer.Mapped.Slice((int)pending.MainQueueOffsets[index], (int)copies[index].Size);
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{copies[index].Size:X16}");
}
_gpuModifiedRanges.Remove(copies[index].Address, copies[index].Size);
}
_tracker.ClearGpuDirtyPages(pending.WindowBegin, pending.WindowEnd - pending.WindowBegin);
if (pending.IsWrite)
{
_tracker.MarkCpuDirtyPages(pending.GuestAddress, pending.Size);
}
RecordReadback(pending.WindowBegin, pending.WindowEnd, pending.IsWrite, copies, pending.Started, pending.Source);
return true;
}
finally
{
// The guest thread waited for the submission, so the GPU is done with the buffer.
buffer.Dispose();
foreach (var copy in pending.Copies)
{
copy.Buffer.ReleaseForeignRead();
}
}
}
private bool IsPendingDownloadCurrent(PendingDownload pending)
{
var total = 0UL;
@ -1686,7 +2016,7 @@ public sealed unsafe class GuestBufferCache : IGuestBufferStore, IDisposable
}
var bufferIdentifier = _registry.AllocateBuffer(new GpuBuffer(
_device, _scheduler, GpuBufferUsage.DeviceLocal, overlap.Begin,
_device, _scheduler, _unifiedBuffers ? GpuBufferUsage.Unified : GpuBufferUsage.DeviceLocal, overlap.Begin,
GpuBuffer.AllFlags | BufferUsageFlags.ShaderDeviceAddressBit, overlap.End - overlap.Begin, allowSlab: true), overlap.Begin, overlap.End - overlap.Begin);
foreach (var oldId in overlapping)
{

View File

@ -11,6 +11,8 @@ public interface IGuestImageCache
{
ImageRegionInfo QueryRegion(ulong address, ulong size);
bool HasGpuModifiedImageBytes(ulong address, ulong size) => QueryRegion(address, size).GpuImageBytes;
bool ClearMetadata(ulong address);
bool OverlapsDccMetadata(ulong address, ulong size);

View File

@ -272,6 +272,18 @@ public sealed class SubmissionScheduler : IGpuTickScheduler, IDisposable
_lastMemoryDrainTick = nextTick;
}
// Waits for a tick that has already been submitted; safe from any thread, since it only
// waits on the timeline and records nothing.
public void WaitForSubmittedTick(ulong tick)
{
if (tick >= CurrentTick)
{
throw Fatal($"Cannot wait off the queue for a tick that is still recording: {tick} >= {CurrentTick}.");
}
_timeline.Wait(tick);
}
public void Wait(ulong tick)
{
if (tick > CurrentTick)

View File

@ -270,6 +270,7 @@ internal static unsafe partial class VulkanVideoPresenter
public bool TryReadGuest(ulong address, Span<byte> destination)
{
if (_bufferCache.TryReadCommandBacking(address, destination)) return true;
using (RenderPhaseProfile.MeasureDetail(RenderPhaseProfile.Phase.CommandMemorySync))
{
if (!_bufferCache.TrySynchronizeCpuRead(address, (ulong)destination.Length,

View File

@ -684,6 +684,7 @@ internal static unsafe partial class VulkanVideoPresenter
}
uint? compute = null;
uint? general = null;
for (uint index = 0; index < count; index++)
{
var flags = families[index].QueueFlags;
@ -701,9 +702,16 @@ internal static unsafe partial class VulkanVideoPresenter
{
compute ??= index;
}
// MoltenVK exposes only general families, one queue each, every one its own
// Metal command queue: a second one still runs readbacks beside the main queue.
if ((flags & (QueueFlags.TransferBit | QueueFlags.GraphicsBit | QueueFlags.ComputeBit)) != 0)
{
general ??= index;
}
}
return compute;
return compute ?? general;
}
private bool _supportsFragmentShaderBarycentric;

View File

@ -48,6 +48,91 @@ public sealed class GuestBufferAsyncReadbackTests(HeadlessVulkanFixture fixture)
harness.Shutdown();
}
// Without a readback queue the main queue carries the download and the guest thread waits
// for that submission; the worker records the copies and moves on.
[Fact]
public void AGuestReadWithoutAReadbackQueueStillWaitsOffTheQueueThread()
{
if (_vulkan is null || !GatePrerequisites.Ready(_vulkan)) return;
using var harness = new CacheHarness(_vulkan);
using var fatal = new FatalScope();
Assert.Null(harness.Cache.AsyncReadback);
var address = harness.MapBacked(0x10000, ReadWrite);
GpuWrite(harness, address, 0x55667788);
Assert.True(harness.Cache.HasGpuDirtyBytes(address, 0x100));
Assert.True(harness.Cache.DownloadToCpu(address + 8, 4));
Assert.False(harness.Cache.HasGpuDirtyBytes(address, 0x100));
Assert.Equal((1L, 0L), (harness.Cache.PendingReadbacksApplied, harness.Cache.PendingReadbacksRetried));
Assert.Equal(0x55667788u, BitConverter.ToUInt32(harness.Read(address + 0xF0, 4)));
// A second write after the first read is picked up by the next read, not served stale.
GpuWrite(harness, address, 0x99AABBCCu);
Assert.True(harness.Cache.DownloadToCpu(address, 4));
Assert.Equal(0x99AABBCCu, BitConverter.ToUInt32(harness.Read(address + 0x40, 4)));
Assert.Equal((2L, 0L), (harness.Cache.PendingReadbacksApplied, harness.Cache.PendingReadbacksRetried));
harness.Shutdown();
}
// On a unified-memory device a read of a retired GPU write copies straight out of the buffer's
// own mapping: no copy command, no pending readback.
[Fact]
public void AReadOfARetiredWriteComesFromTheUnifiedMapping()
{
if (_vulkan is null || !GatePrerequisites.Ready(_vulkan)) return;
using var harness = new CacheHarness(_vulkan);
using var fatal = new FatalScope();
if (!harness.Cache.UnifiedBuffers)
{
harness.Shutdown();
return;
}
var address = harness.MapBacked(0x10000, ReadWrite);
GpuWrite(harness, address, 0x0BADF00D);
harness.Worker.Run(() => harness.Scheduler.Wait(harness.Scheduler.Flush()));
Assert.True(harness.Cache.DownloadToCpu(address + 8, 4));
Assert.False(harness.Cache.HasGpuDirtyBytes(address, 0x100));
Assert.Equal(0x0BADF00Du, BitConverter.ToUInt32(harness.Read(address + 0x80, 4)));
Assert.Equal(1L, harness.Cache.MappedReadbacks);
Assert.Equal(0L, harness.Cache.PendingReadbacksApplied);
harness.Shutdown();
}
// A read while the writer is submitted but still running waits for that submission only, then
// reads the mapping; a write recorded after the read was issued is picked up by the next read.
[Fact]
public void AReadOfASubmittedWriteWaitsForItAndReadsTheUnifiedMapping()
{
if (_vulkan is null || !GatePrerequisites.Ready(_vulkan)) return;
using var harness = new CacheHarness(_vulkan);
using var fatal = new FatalScope();
if (!harness.Cache.UnifiedBuffers)
{
harness.Shutdown();
return;
}
var address = harness.MapBacked(0x10000, ReadWrite);
GpuWrite(harness, address, 0x12345678);
SubmitBatch(harness);
Assert.True(harness.Cache.DownloadToCpu(address, 4));
Assert.Equal(0x12345678u, BitConverter.ToUInt32(harness.Read(address + 0xFC, 4)));
Assert.Equal(1L, harness.Cache.MappedReadbacks);
Assert.False(harness.Cache.HasGpuDirtyBytes(address, 0x100));
GpuWrite(harness, address, 0x9ABCDEF0);
SubmitBatch(harness);
Assert.True(harness.Cache.DownloadToCpu(address, 4));
Assert.Equal(0x9ABCDEF0u, BitConverter.ToUInt32(harness.Read(address + 0x10, 4)));
harness.Shutdown();
}
private static void GpuWrite(CacheHarness harness, ulong address, uint value) => harness.Worker.Run(() =>
{
var (buffer, offset) = harness.Cache.ObtainBuffer(address, 0x100, isWritten: true);

View File

@ -23,6 +23,53 @@ public sealed class GuestBufferCacheTests : IClassFixture<HeadlessVulkanFixture>
{
private const ulong Page = GuestBufferCache.CachingPageSize;
[Fact]
public void CommandBackingReadRejectsGpuImageBytesButAllowsCleanPageNeighbours()
{
if (!GatePrerequisites.Ready(_vulkan)) return;
using var harness = new CacheHarness(_vulkan!);
var address = harness.MapBacked(Page, ReadWrite);
harness.Write(address + 64, new byte[] { 1, 2, 3, 4 });
var request = Color32(address, 4);
var id = harness.Acquire(ref request);
harness.Worker.Run(() =>
{
Assert.True(harness.Images.TryClearImageFromBuffer(address, 16, 0x22222222));
Assert.True(harness.Image(id).IsGpuModified);
var tick = harness.Scheduler.CurrentTick;
Span<byte> bytes = stackalloc byte[4];
Assert.False(harness.Cache.TryReadCommandBacking(address, bytes));
Assert.True(harness.Cache.TryReadCommandBacking(address + 64, bytes));
Assert.Equal(new byte[] { 1, 2, 3, 4 }, bytes.ToArray());
Assert.Equal(tick, harness.Scheduler.CurrentTick);
});
}
[Fact]
public void CommandBytesNextToPendingGpuCounterDoNotWaitOrUnprotectThePage()
{
if (!GatePrerequisites.Ready(_vulkan)) return;
using var harness = new CacheHarness(_vulkan!);
var address = harness.MapBacked(Page, ReadWrite);
harness.Write(address + 16, new byte[] { 1, 2, 3, 4 });
harness.Worker.Run(() =>
{
var (buffer, offset) = harness.Cache.ObtainBuffer(address, 4, isWritten: true);
buffer.Fill(offset, 4, 123);
var tick = harness.Scheduler.CurrentTick;
var protection = harness.Protection(address);
Span<byte> bytes = stackalloc byte[4];
Assert.True(harness.Cache.TryReadCommandBacking(address + 16, bytes));
Assert.Equal(new byte[] { 1, 2, 3, 4 }, bytes.ToArray());
Assert.False(harness.Cache.TryReadCommandBacking(address, bytes));
Assert.False(harness.Cache.TryReadCommandBacking(address - 2, bytes));
Assert.Equal(tick, harness.Scheduler.CurrentTick);
Assert.Equal(protection, harness.Protection(address));
Assert.True(harness.Cache.HasGpuDirtyPages(address, 4));
});
}
[Fact]
public void UnalignedImageObtainUploadsTheWholeDirtyPageToItsBufferOwner()
{

View File

@ -161,6 +161,7 @@ internal sealed unsafe class HeadlessVulkan : IDisposable
public void Dispose()
{
Vk.DeviceWaitIdle(Device);
_deviceInfo?.Slabs.Destroy();
Vk.DestroyDevice(Device, null);
if (_debugUtils is { } debugUtils)
{
@ -215,22 +216,33 @@ internal sealed unsafe class HeadlessVulkan : IDisposable
var appInfo = new ApplicationInfo { SType = StructureType.ApplicationInfo, ApiVersion = apiVersion };
var validation = Environment.GetEnvironmentVariable(ValidationVariable) == "1";
var layers = validation ? SilkMarshal.StringArrayToPtr(new[] { "VK_LAYER_KHRONOS_validation" }) : 0;
var extensions = validation ? SilkMarshal.StringArrayToPtr(new[] { ExtDebugUtils.ExtensionName }) : 0;
uint instanceExtensionCount = 0;
vk.EnumerateInstanceExtensionProperties((byte*)null, &instanceExtensionCount, null);
var instanceExtensions = new ExtensionProperties[instanceExtensionCount];
fixed (ExtensionProperties* pointer = instanceExtensions)
vk.EnumerateInstanceExtensionProperties((byte*)null, &instanceExtensionCount, pointer);
var portable = instanceExtensions.Any(extension =>
SilkMarshal.PtrToString((nint)extension.ExtensionName) == "VK_KHR_portability_enumeration");
var instanceNames = new List<string>();
if (validation) instanceNames.Add(ExtDebugUtils.ExtensionName);
if (portable) instanceNames.Add("VK_KHR_portability_enumeration");
var extensions = instanceNames.Count != 0 ? SilkMarshal.StringArrayToPtr(instanceNames.ToArray()) : 0;
var instanceInfo = new InstanceCreateInfo
{
SType = StructureType.InstanceCreateInfo,
Flags = portable ? InstanceCreateFlags.EnumeratePortabilityBitKhr : 0,
PApplicationInfo = &appInfo,
EnabledLayerCount = validation ? 1u : 0u,
PpEnabledLayerNames = (byte**)layers,
EnabledExtensionCount = validation ? 1u : 0u,
EnabledExtensionCount = (uint)instanceNames.Count,
PpEnabledExtensionNames = (byte**)extensions,
};
var created = vk.CreateInstance(&instanceInfo, null, out var instance);
if (validation)
{
SilkMarshal.Free(layers);
SilkMarshal.Free(extensions);
}
if (extensions != 0) SilkMarshal.Free(extensions);
if (created != Result.Success)
{
@ -368,6 +380,7 @@ internal sealed unsafe class HeadlessVulkan : IDisposable
vulkan13Features.PNext = &barycentricFeatures;
}
var extensionNames = new List<string>();
if (HasDeviceExtensions(vk, physical, ["VK_KHR_portability_subset"])) extensionNames.Add("VK_KHR_portability_subset");
if (dynamicRendering) extensionNames.AddRange(RenderingExtensionNames);
if (barycentric) extensionNames.Add(barycentricExtension);
const string fillRectangleExtension = "VK_NV_fill_rectangle";