// Copyright (C) 2026 SharpEmu Emulator Project // SPDX-License-Identifier: GPL-2.0-or-later using System.Runtime.InteropServices; using SharpEmu.HLE; using SharpEmu.HLE.GpuMemory; using SharpEmu.HLE.GuestMemory; using SharpEmu.Libs.Gpu.Scheduling; using SharpEmu.Libs.Kernel; using SharpEmu.Libs.VideoOut; using SharpEmu.Libs.Gpu.Vulkan; using Silk.NET.Vulkan; namespace SharpEmu.Libs.Gpu.Buffers; public readonly record struct DownloadPiece(GpuBuffer Buffer, ulong SourceOffset, ulong Address, ulong Size); public readonly record struct OverlapSpan(int First, int Last, ulong Begin, ulong End, bool HasStreamLeap); // Guest memory mirrored in device buffers: upload on use, download on CPU fault, page table for BDA. public sealed unsafe class GuestBufferCache : IGuestBufferStore, IDisposable { public const int CachingPageBits = 14; public const ulong CachingPageSize = 1UL << CachingPageBits; public const ulong CachingPageCount = 1UL << (40 - CachingPageBits); public const ulong BdaPageTableSize = CachingPageCount * sizeof(ulong); public static readonly ResourceSlotIdentifier NullBufferId = new(0, 1); private const ulong MiB = 1024 * 1024; private const ulong GdsBufferSize = 64 * 1024; private readonly record struct PlannedDownload(GpuBuffer Buffer, ulong Address, BufferDownloadPlacement Placement); private enum ShutdownOutcome { Pending, Drained, Failed, } private readonly GpuDeviceInfo _device; private readonly SubmissionScheduler _scheduler; private readonly IGpuQueueRelay _relay; private readonly GuestBufferUploader _uploader; private readonly IGuestBackedSpace _backing; private readonly BdaFaultProcessor _faults; private readonly GpuBuffer _gds; private readonly GpuBuffer _bdaPageTable; private readonly GuestBufferRegistry _registry = new(CachingPageSize, PageOwnerTable.AddressSpaceSize); private readonly SpanSet _gpuModifiedRanges = new(); private long _gpuModifiedVersion; private readonly GuestPageTracker _tracker; private readonly GpuRingBuffer _staging; private readonly GpuRingBuffer _stream; private readonly GpuRingBuffer _download; private readonly GpuRingBuffer _deviceRing; private readonly object _shutdownGate = new(); private readonly BufferRetirementPolicy _retirementPolicy = new(); private ShutdownOutcome _outcome; private bool _faultProcessPending; private bool _disposed; public GuestBufferCache( GpuDeviceInfo device, SubmissionScheduler scheduler, IGpuQueueRelay relay, PageGuard pages, ICpuMemory guest, IGuestBackedSpace backing) { _device = device; _scheduler = scheduler; _relay = relay; _backing = backing; _faults = new BdaFaultProcessor(device, scheduler, this, CachingPageBits, CachingPageCount); _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); _staging = new GpuRingBuffer(device, scheduler, GpuBufferUsage.Upload, 512 * MiB); _uploader = new GuestBufferUploader(device, scheduler, guest, _staging); _stream = new GpuRingBuffer(device, scheduler, GpuBufferUsage.Stream, 64 * MiB); _download = new GpuRingBuffer(device, scheduler, GpuBufferUsage.Download, 32 * MiB); _deviceRing = new GpuRingBuffer(device, scheduler, GpuBufferUsage.DeviceLocal, 128 * MiB); StreamOffsetAlignment = device.MinUniformBufferOffsetAlignment; _gds.Mapped.Clear(); _gds.Flush(0, _gds.Size); var nullId = _registry.AllocateBuffer(new GpuBuffer(device, scheduler, GpuBufferUsage.DeviceLocal, 0, GpuBuffer.AllFlags, 16), 0, 16); if (nullId != NullBufferId) { throw SubmissionScheduler.Fatal("The null buffer occupies the wrong slot."); } } public IGuestImageCache? ImageCache { get; set; } public GpuBuffer GdsBuffer => _gds; public GpuBuffer BdaPageTableBuffer => _bdaPageTable; public GpuBuffer FaultBuffer => _faults.FaultBuffer; public ulong TotalUsedMemory => _registry.RegisteredBytes; public int BufferCount => _registry.RegisteredCount; // The stream fast path aligns to this; the presenter raises it to its descriptor alignment. public ulong StreamOffsetAlignment { get; set; } public void ForEachBuffer(Action visit) { for (var index = 0; index < _registry.RegisteredCount; index++) { visit(_registry.GetBuffer(_registry.GetRegisteredIdentifier(index))); } } public GpuBuffer GetBuffer(ResourceSlotIdentifier bufferIdentifier) => _registry.GetBuffer(bufferIdentifier); public GpuRingBuffer GetUtilityBuffer(GpuBufferUsage usage) => usage switch { GpuBufferUsage.Upload => _staging, GpuBufferUsage.Stream => _stream, GpuBufferUsage.Download => _download, GpuBufferUsage.DeviceLocal => _deviceRing, _ => throw SubmissionScheduler.Fatal("The utility buffer usage is invalid."), }; // A CPU write fault: true when the range is tracked and any GPU data reached guest memory. bool IGuestBufferStore.MarkCpuWrite(ulong address, ulong size) { var tracked = _tracker.InvalidateRegion(address, size, out var needsGpuFlush); var completed = !needsGpuFlush || ReadMemoryOrAwaitShutdown(address, size, isWrite: true, GuestMemoryProfile.ReadbackSource.CpuWriteInvalidation); if (GuestGpuMemoryHook.Traces(address, size)) GuestGpuMemoryHook.Trace(address, size, $"buffer-write tracked={tracked} completed={completed}"); return tracked && completed; } public bool TrySynchronizeCpuRead(ulong address, ulong size) => TrySynchronizeCpuRead(address, size, GuestMemoryProfile.ReadbackSource.CpuReadSynchronization); public bool TrySynchronizeCpuRead(ulong address, ulong size, GuestMemoryProfile.ReadbackSource source) => !_tracker.MayHaveGpuDirtyPages(address, size) || !_tracker.HasGpuDirtyPages(address, size) || ReadMemoryOrAwaitShutdown(address, size, isWrite: false, source); // Lock-free on the GPU queue thread; see GuestPageTracker.MayHaveGpuDirtyPages. public bool MayHaveGpuDirtyPages(ulong address, ulong size) => _tracker.MayHaveGpuDirtyPages(address, size); // A CPU read fault: GPU-dirty pages download through the worker first. public bool DownloadToCpu(ulong address, ulong size) { var tracked = _tracker.HasRegion(address, size); var dirty = tracked && _tracker.HasGpuDirtyPages(address, size); var completed = tracked && (!dirty || ReadMemoryOrAwaitShutdown(address, size, isWrite: false, GuestMemoryProfile.ReadbackSource.StoreDownload)); if (GuestGpuMemoryHook.Traces(address, size)) GuestGpuMemoryHook.Trace(address, size, $"buffer-read tracked={tracked} gpu_dirty={dirty} completed={completed}"); return completed; } public void InvalidateMemory(ulong guestAddress, ulong size) { if (!IsValidRange(guestAddress, size)) { throw SubmissionScheduler.Fatal("The memory invalidation range is invalid."); } _tracker.InvalidateRegion(guestAddress, size, () => ReadMemory(guestAddress, size, isWrite: true)); } public void ReadMemory(ulong guestAddress, ulong size, bool isWrite = false) { if (!ReadMemoryOrAwaitShutdown(guestAddress, size, isWrite)) { throw SubmissionScheduler.Fatal($"Cannot download buffer data after a failed shutdown: addr=0x{guestAddress:X16} size=0x{size:X16}"); } } public ResourceSlotIdentifier FindBuffer(ulong guestAddress, ulong size) { using var profileScope = RenderPhaseProfile.MeasureDetail(RenderPhaseProfile.Phase.BufferCacheLookup); if (guestAddress == 0) { return NullBufferId; } if (!IsValidRange(guestAddress, size)) { throw SubmissionScheduler.Fatal("The buffer lookup range is invalid."); } var owner = _registry.FindContainingBuffer(guestAddress, size); if (owner.IsValid) { return owner; } return CreateBuffer(guestAddress, size); } public (GpuBuffer Buffer, ulong Offset) ObtainBuffer(ulong guestAddress, ulong size, bool isWritten, bool isTexelBuffer = false, ResourceSlotIdentifier bufferIdentifier = default) { using var profileScope = RenderPhaseProfile.MeasureDetail(RenderPhaseProfile.Phase.BufferAcquisitionChecks); var command = _scheduler.Current; if (command.IsInvalid || !IsValidRange(guestAddress, size)) { throw SubmissionScheduler.Fatal("A buffer request requires a command buffer that is recording."); } if (!isWritten && !_tracker.HasGpuDirtyPages(guestAddress, size) && _tracker.HasCpuDirtyPages(guestAddress, size) && (size <= CachingPageSize || (!isTexelBuffer && _tracker.IsCpuWriteHotRange(guestAddress, size)))) { using var streamProfile = RenderPhaseProfile.MeasureDetail(RenderPhaseProfile.Phase.BufferStreamUpload); if (_stream.TryMap(size, out var streamOffset, StreamOffsetAlignment, allowWait: false) && _backing.TryReadBacking(guestAddress, _stream.Mapped.Slice((int)streamOffset, (int)size))) { _stream.Commit(); return (_stream, streamOffset); } } // A GPU write into memory without backing could never download later; refuse it now. if (isWritten && !_backing.IsBackedRange(guestAddress, size)) { throw SubmissionScheduler.Fatal($"Could not write the required direct backing: addr=0x{guestAddress:X16} size=0x{size:X16}"); } var buffer = _registry.TryGetRegisteredBuffer(bufferIdentifier); if (buffer == null || !buffer.IsInBounds(guestAddress, size)) { bufferIdentifier = FindBuffer(guestAddress, size); buffer = _registry.GetBuffer(bufferIdentifier); } TouchBuffer(bufferIdentifier); // A persistent upload must track the next CPU write instead of uploading unchanged hot pages. // Streaming keeps hot pages writable; the fallback must restore protection before copying. _ = SynchronizeBuffer(buffer, guestAddress, size, isWritten, isTexelBuffer, preserveCpuWriteHotPages: false); if (isWritten) { buffer.NoteGpuWrite(buffer.Offset(guestAddress), size); if (Diagnostics.DccWriterTrace.Enabled) { Diagnostics.DccWriterTrace.Record(RequireImageCache(), guestAddress, size); Diagnostics.DccWriterTrace.RecordWrite(guestAddress, size); } if (!_gpuModifiedRanges.Contains(guestAddress, size)) { _gpuModifiedRanges.Add(guestAddress, size); Interlocked.Increment(ref _gpuModifiedVersion); } NoteHotWindowWrite(guestAddress, size); } return (buffer, buffer.Offset(guestAddress)); } public (GpuBuffer Buffer, ulong Offset) ObtainBufferForImage(ulong guestAddress, ulong size) { if (!IsValidRange(guestAddress, size)) { throw SubmissionScheduler.Fatal("The image source range is invalid."); } var cpuModified = _tracker.HasCpuDirtyPages(guestAddress, size); var gpuModified = _tracker.HasGpuDirtyPages(guestAddress, size); var hasDirtyBufferSource = _gpuModifiedRanges.Overlaps(guestAddress, size); _tracker.ValidateGpuDirtyOwnership(_gpuModifiedRanges, guestAddress, size, "image source"); var owner = FindOwner(guestAddress, size); if (hasDirtyBufferSource && owner == null) { if (!IsRegionRegistered(guestAddress, size)) { throw SubmissionScheduler.Fatal("The GPU-dirty image source has no device buffer."); } owner = _registry.GetBuffer(FindBuffer(guestAddress, size)); } if (owner != null && !cpuModified && (!gpuModified || hasDirtyBufferSource)) { TouchBuffer(owner); return (owner, owner.Offset(guestAddress)); } if (hasDirtyBufferSource && owner == null) { throw SubmissionScheduler.Fatal("Cannot find the device buffer that owns the GPU-dirty image source."); } if (!_staging.TryMap(size, out var stageOffset, 16)) { throw SubmissionScheduler.Fatal( $"Cannot reserve image staging space: address=0x{guestAddress:X16} size=0x{size:X16} capacity=0x{_staging.Size:X16} tick={_scheduler.CurrentTick}."); } if (!_backing.TryReadBacking(guestAddress, _staging.Mapped.Slice((int)stageOffset, (int)size)) && !KernelMemoryCompatExports.TryReadPrtBacking(_backing, guestAddress, _staging.Mapped.Slice((int)stageOffset, (int)size))) { throw SubmissionScheduler.Fatal( $"Could not read the mapped guest image backing: address=0x{guestAddress:X16} size=0x{size:X16} range_backed={_backing.IsBackedRange(guestAddress, size)} first_byte_backed={_backing.IsBackedRange(guestAddress, 1)} last_byte_backed={_backing.IsBackedRange(guestAddress + size - 1, 1)} tick={_scheduler.CurrentTick}."); } _staging.Commit(); hasDirtyBufferSource = _gpuModifiedRanges.Overlaps(guestAddress, size); owner = FindOwner(guestAddress, size); if (hasDirtyBufferSource && owner == null) { throw SubmissionScheduler.Fatal("The GPU-dirty image source lost its device buffer owner."); } if (owner == null || (_tracker.HasGpuDirtyPages(guestAddress, size) && !hasDirtyBufferSource)) { return (_staging, stageOffset); } TouchBuffer(owner); // Tracking reports whole dirty pages, which can extend beyond the image's // staging slice. Upload those pages through the normal buffer uploader so // every byte marked clean is copied from its actual guest backing. _ = SynchronizeBuffer(owner, guestAddress, size, false, false); return (owner, owner.Offset(guestAddress)); } public void WriteHostMemory(ulong guestAddress, ReadOnlySpan data) { if (guestAddress == 0 || data.IsEmpty || (ulong)data.Length > ulong.MaxValue - guestAddress) { throw SubmissionScheduler.Fatal("The host DMA write range is invalid."); } GpuWritebackEpoch.Advance(); if (!_backing.TryWriteBacking(guestAddress, data)) { throw SubmissionScheduler.Fatal($"Could not write the required direct backing: addr=0x{guestAddress:X16} size=0x{data.Length:X16}"); } // Registered buffers are ordered and disjoint: walk only the ones the write overlaps. var end = guestAddress + (ulong)data.Length; for (var index = _registry.FindFirstOverlappingIndex(guestAddress); index < _registry.RegisteredCount && _registry.GetRegisteredAddress(index) < end; index++) { var address = _registry.GetRegisteredAddress(index); var bufferIdentifier = _registry.GetRegisteredIdentifier(index); var buffer = _registry.GetBuffer(bufferIdentifier); var begin = Math.Max(guestAddress, address); var rangeEnd = Math.Min(end, address + buffer.Size); if (begin >= rangeEnd) { continue; } WriteDataBuffer(buffer, begin, data.Slice((int)(begin - guestAddress), (int)(rangeEnd - begin))); TouchBuffer(bufferIdentifier); } } public void FillBuffer(ulong guestAddress, ulong size, uint value, bool isGds) { if ((guestAddress & 3) != 0 || size == 0 || (size & 3) != 0 || size > ulong.MaxValue - guestAddress) { throw SubmissionScheduler.Fatal("The fill range must be aligned to four bytes."); } if (isGds) { if (guestAddress > _gds.Size || size > _gds.Size - guestAddress) { throw SubmissionScheduler.Fatal("The GDS fill range is outside the buffer."); } _gds.Fill(guestAddress, size, value); return; } if (guestAddress == 0) { throw SubmissionScheduler.Fatal("The fill memory address is invalid."); } var images = RequireImageCache(); _ = images.ClearMetadata(guestAddress); var region = images.QueryRegion(guestAddress, size); if (!HasGpuDirtyBytes(guestAddress, size) && !region.GpuImageBytes) { if (region.ImageBytes) { images.InvalidateMemory(guestAddress, size); } // Labels and small clears dominate; fill a stack chunk once and repeat it. Span values = stackalloc uint[(int)Math.Min(size / sizeof(uint), 1024UL)]; values.Fill(value); var bytes = MemoryMarshal.AsBytes(values); for (ulong offset = 0; offset < size;) { var chunk = (int)Math.Min(size - offset, (ulong)bytes.Length); WriteHostMemory(guestAddress + offset, bytes[..chunk]); offset += (ulong)chunk; } return; } images.InvalidateMemoryFromGpu(guestAddress, size); var bufferIdentifier = FindBuffer(guestAddress, size); var (destination, destinationOffset) = ObtainBuffer(guestAddress, size, true, true, bufferIdentifier); destination.Fill(destinationOffset, size, value); if (images.OverlapsDccMetadata(guestAddress, size) && TryWriteFillToBacking(guestAddress, size, value)) if (Diagnostics.DccWriterTrace.Enabled) { Diagnostics.DccWriterTrace.CurrentKind = "fill"; } { _gpuModifiedRanges.Remove(guestAddress, size); Interlocked.Increment(ref _gpuModifiedVersion); ClearGpuDirtyPagesOfRemovedRange(guestAddress, size); } } // After a range left the GPU-modified set: the pages inside it are clean in one tracker walk, // and only a page the range shares at either end can still hold another GPU-modified range. private void ClearGpuDirtyPagesOfRemovedRange(ulong guestAddress, ulong size) { const ulong pageBytes = TrackerLayout.PageBytes; var end = guestAddress + size; var firstPage = guestAddress & ~(pageBytes - 1); var lastPage = (end - 1) & ~(pageBytes - 1); var startPartial = guestAddress != firstPage; var endPartial = end != lastPage + pageBytes; var innerBegin = startPartial ? firstPage + pageBytes : firstPage; var innerEnd = endPartial ? lastPage : end; if (innerBegin < innerEnd) { _tracker.ClearGpuDirtyPages(innerBegin, innerEnd - innerBegin); } if (startPartial) { ClearGpuDirtyPageIfUnmodified(firstPage); } if (endPartial && (lastPage != firstPage || !startPartial)) { ClearGpuDirtyPageIfUnmodified(lastPage); } } private void ClearGpuDirtyPageIfUnmodified(ulong page) { if (!_gpuModifiedRanges.Overlaps(page, TrackerLayout.PageBytes)) { _tracker.ClearGpuDirtyPages(page, TrackerLayout.PageBytes); } } public void FillDccMetadata(ulong guestAddress, ulong size, uint value) { if (guestAddress == 0 || (guestAddress & 3) != 0 || size == 0 || (size & 3) != 0 || size > ulong.MaxValue - guestAddress || HasGpuDirtyBytes(guestAddress, size)) { FillBuffer(guestAddress, size, value, false); return; } // Images over the range read it again; only GPU-owned image bytes need the general fill. // The buffer copy is filled on the GPU rather than uploaded from the written bytes. var images = RequireImageCache(); var region = images.QueryRegion(guestAddress, size); if (region.GpuImageBytes) { FillBuffer(guestAddress, size, value, false); return; } if (region.ImageBytes) { images.InvalidateMemory(guestAddress, size); } var (destination, destinationOffset) = ObtainBuffer(guestAddress, size, false, false, FindBuffer(guestAddress, size)); destination.Fill(destinationOffset, size, value); if (!TryWriteFillToBacking(guestAddress, size, value)) { FillBuffer(guestAddress, size, value, false); } } private bool TryWriteFillToBacking(ulong guestAddress, ulong size, uint value) { // Metadata fills repeat every frame: the pattern buffer is pooled. var count = (int)Math.Min(size / sizeof(uint), 4096); var values = System.Buffers.ArrayPool.Shared.Rent(count); try { values.AsSpan(0, count).Fill(value); var bytes = MemoryMarshal.AsBytes(values.AsSpan(0, count)); for (ulong offset = 0; offset < size;) { var chunk = (int)Math.Min(size - offset, (ulong)bytes.Length); GpuWritebackEpoch.Advance(); if (!_backing.TryWriteBacking(guestAddress + offset, bytes[..chunk])) { return false; } offset += (ulong)chunk; } return true; } finally { System.Buffers.ArrayPool.Shared.Return(values); } } public const ulong MaxHostCopyWords = 64 * 1024; public bool TryCopyWordsOnHost(ulong destination, ulong source, ulong sourceWords, ulong words) { if (destination == 0 || source == 0 || sourceWords == 0 || words == 0 || ((destination | source) & 3) != 0 || words > MaxHostCopyWords || sourceWords > MaxHostCopyWords) { return false; } var size = words * sizeof(uint); var sourceSize = Math.Min(sourceWords, words) * sizeof(uint); if (size > ulong.MaxValue - destination || sourceSize > ulong.MaxValue - source || (source < destination + size && destination < source + sourceSize)) { return false; } var images = RequireImageCache(); var sourceRegion = images.QueryRegion(source, sourceSize); var destinationRegion = images.QueryRegion(destination, size); if (HasGpuDirtyBytes(source, sourceSize) || HasGpuDirtyBytes(destination, size) || sourceRegion.GpuImageBytes || destinationRegion.GpuImageBytes) { return false; } var pattern = System.Buffers.ArrayPool.Shared.Rent((int)sourceSize); var chunk = System.Buffers.ArrayPool.Shared.Rent((int)Math.Min(size, 64UL * 1024)); try { if (!_backing.TryReadBacking(source, pattern.AsSpan(0, (int)sourceSize))) { return false; } if (destinationRegion.ImageBytes) { images.InvalidateMemory(destination, size); } var chunkLength = (ulong)(chunk.Length - chunk.Length % sizeof(uint)); for (ulong offset = 0; offset < size;) { var length = (int)Math.Min(size - offset, chunkLength); for (var filled = 0; filled < length;) { var patternOffset = (int)((offset + (ulong)filled) % sourceSize); var copied = Math.Min(length - filled, (int)sourceSize - patternOffset); pattern.AsSpan(patternOffset, copied).CopyTo(chunk.AsSpan(filled, copied)); filled += copied; } WriteHostMemory(destination + offset, chunk.AsSpan(0, length)); offset += (ulong)length; } return true; } finally { System.Buffers.ArrayPool.Shared.Return(pattern); System.Buffers.ArrayPool.Shared.Return(chunk); } } public void CopyBuffer(ulong dstVaddr, ulong srcVaddr, ulong size, bool dstGds, bool srcGds) { var dstMemory = !dstGds; var srcMemory = !srcGds; if ((dstMemory && dstVaddr == 0) || (srcMemory && srcVaddr == 0) || size == 0 || ((dstGds || srcGds) && ((dstVaddr | srcVaddr | size) & 3) != 0) || size > ulong.MaxValue - dstVaddr || size > ulong.MaxValue - srcVaddr || (dstGds && srcGds) || (dstGds && (dstVaddr > _gds.Size || size > _gds.Size - dstVaddr)) || (srcGds && (srcVaddr > _gds.Size || size > _gds.Size - srcVaddr))) { throw SubmissionScheduler.Fatal( $"The buffer copy range is invalid: src=0x{srcVaddr:X16} dst=0x{dstVaddr:X16} size=0x{size:X16} src_gds={(srcGds ? 1 : 0)} dst_gds={(dstGds ? 1 : 0)}"); } var images = RequireImageCache(); var srcRegion = srcMemory ? images.QueryRegion(srcVaddr, size) : default; var dstRegion = dstMemory ? images.QueryRegion(dstVaddr, size) : default; if (srcMemory && dstMemory && !HasGpuDirtyBytes(srcVaddr, size) && !HasGpuDirtyBytes(dstVaddr, size) && !srcRegion.GpuImageBytes && !dstRegion.GpuImageBytes) { if (dstRegion.ImageBytes) { images.InvalidateMemory(dstVaddr, size); } var bytes = System.Buffers.ArrayPool.Shared.Rent((int)Math.Min(size, 64UL * 1024)); try { for (ulong offset = 0; offset < size;) { var chunk = (int)Math.Min(size - offset, (ulong)bytes.Length); if (!_backing.TryReadBacking(srcVaddr + offset, bytes.AsSpan(0, chunk))) { throw SubmissionScheduler.Fatal("The host DMA source has no direct backing."); } WriteHostMemory(dstVaddr + offset, bytes.AsSpan(0, chunk)); offset += (ulong)chunk; } } finally { System.Buffers.ArrayPool.Shared.Return(bytes); } return; } var command = _scheduler.Current; if (dstMemory) { images.InvalidateMemoryFromGpu(dstVaddr, size); } var srcId = srcMemory ? FindBuffer(srcVaddr, size) : default; var dstId = dstMemory ? FindBuffer(dstVaddr, size) : default; var (src, srcOffset) = srcMemory ? ObtainBuffer(srcVaddr, size, false, true, srcId) : (_gds, srcVaddr); var (dst, dstOffset) = dstMemory ? ObtainBuffer(dstVaddr, size, true, true, dstId) : (_gds, dstVaddr); if (ReferenceEquals(src, dst) && srcOffset < dstOffset + size && dstOffset < srcOffset + size) { if (Diagnostics.DccWriterTrace.Enabled) { Diagnostics.DccWriterTrace.CurrentKind = $"copy_from_0x{srcVaddr:X}"; } throw SubmissionScheduler.Fatal("The resolved Vulkan copy ranges overlap."); } dst.CopyFrom(command, src, srcOffset, dstOffset, size); } // Buffers are ordered and disjoint: the last one starting before the query end is the only candidate. public bool IsRegionRegistered(ulong guestAddress, ulong size) { if (!IsValidRange(guestAddress, size)) { throw SubmissionScheduler.Fatal("The registered-region query is invalid."); } return _registry.HasOverlap(guestAddress, size); } public bool HasGpuDirtyPages(ulong guestAddress, ulong size) => _tracker.HasGpuDirtyPages(guestAddress, size); public bool HasGpuDirtyBytes(ulong guestAddress, ulong size) => _gpuModifiedRanges.Overlaps(guestAddress, size); public long GpuModifiedVersion => Volatile.Read(ref _gpuModifiedVersion); public bool HasCpuDirtyPages(ulong guestAddress, ulong size) => _tracker.HasCpuDirtyPages(guestAddress, size); public void ProcessFaultBuffer() => _faults.ProcessFaultBuffer(); // Uploads every mapped range before a BDA draw; the fault pass runs at the next collection. // Every device-address program prepares all GPU-mapped memory; visiting each registered // buffer per dispatch cost ~17 % of the Demon's Souls render thread. The same work is done // in two cheaper parts: the recency touch, a no-op after the first one in a retirement tick, // runs once per tick (and when the mapping changes; new buffers register with the current // tick), and uploads only visit buffers in blocks the tracker does not know to be clean. // Hot pages stay dirty and writable, so every preparation still re-uploads them. private ulong _bdaTouchTick = ulong.MaxValue; private ulong _bdaTouchMapping; public static ulong MappingKey(IReadOnlyCollection spans) { var mapping = (ulong)spans.Count; foreach (var span in spans) mapping = (mapping ^ span.Address ^ (span.Size << 17)) * 0x100000001B3UL; return mapping; } public void PrepareBda(IEnumerable mapped) { var spans = mapped as IReadOnlyCollection ?? mapped.ToList(); PrepareBda(spans, MappingKey(spans)); } public void PrepareBda(IReadOnlyCollection mapped, ulong mapping) { var traceAddress = GuestGpuMemoryHook.TraceAddress; var traceCovered = false; if (traceAddress != 0) { foreach (var span in mapped) { if (traceAddress != 0 && GuestGpuMemoryHook.Traces(span.Address, span.Size)) traceCovered = true; SynchronizeBuffersInRange(span.Address, span.Size); } } else { var spans = mapped; if (_retirementPolicy.CurrentTick != _bdaTouchTick || mapping != _bdaTouchMapping) { foreach (var span in spans) TouchBuffersInRange(span.Address, span.Size); _bdaTouchTick = _retirementPolicy.CurrentTick; _bdaTouchMapping = mapping; } var epoch = _tracker.CpuDirtyEpoch; if (epoch != _bdaSweepEpoch || mapping != _bdaSweepMapping) { foreach (var span in spans) _tracker.ForEachPossiblyCpuDirtyRange(span.Address, span.Size, _uploadDirtyBuffersInRange ??= UploadDirtyBuffersInRange); _bdaSweepEpoch = epoch; _bdaSweepMapping = mapping; } } if (traceAddress != 0) GuestGpuMemoryHook.Trace(traceAddress, 1, $"device-address-preparation covered={traceCovered} registered={IsRegionRegistered(traceAddress, 1)} submission_tick={_scheduler.CurrentTick} collection_tick={_retirementPolicy.CurrentTick}"); _faultProcessPending = true; } private Action? _uploadDirtyBuffersInRange; private long _bdaSweepEpoch = -1; private ulong _bdaSweepMapping; private void TouchBuffersInRange(ulong guestAddress, ulong size) { var end = guestAddress + size; var index = _registry.FindFirstOverlappingIndex(guestAddress); for (; index < _registry.RegisteredCount && _registry.GetRegisteredAddress(index) < end; index++) TouchBuffer(_registry.GetRegisteredIdentifier(index)); } // The upload half of SynchronizeBuffersInRange, for a range that may hold CPU-dirty pages. private void UploadDirtyBuffersInRange(ulong guestAddress, ulong size) { var end = guestAddress + size; var index = _registry.FindFirstOverlappingIndex(guestAddress); for (; index < _registry.RegisteredCount && _registry.GetRegisteredAddress(index) < end; index++) { var buffer = _registry.GetBuffer(_registry.GetRegisteredIdentifier(index)); var start = Math.Max(buffer.CpuAddress, guestAddress); var finish = Math.Min(buffer.CpuAddress + buffer.Size, end); if (start < finish && _tracker.MayHaveCpuDirtyPages(start, finish - start)) _ = SynchronizeBuffer(buffer, start, finish - start, false, false, preserveCpuWriteHotPages: false); } } public void SynchronizeBuffersInRange(ulong guestAddress, ulong size) { var end = guestAddress + size; var index = _registry.FindFirstOverlappingIndex(guestAddress); for (; index < _registry.RegisteredCount && _registry.GetRegisteredAddress(index) < end; index++) { var identifier = _registry.GetRegisteredIdentifier(index); var buffer = _registry.GetBuffer(identifier); var start = Math.Max(buffer.CpuAddress, guestAddress); var finish = Math.Min(buffer.CpuAddress + buffer.Size, end); if (start < finish) { if (GuestGpuMemoryHook.Traces(start, finish - start)) GuestGpuMemoryHook.Trace(start, finish - start, $"device-address-touch buffer={identifier} submission_tick={_scheduler.CurrentTick} collection_tick={_retirementPolicy.CurrentTick}"); // Clean buffers remain in use through their device addresses. TouchBuffer(identifier); // Device-address reads reuse persistent buffers; track writes after each upload. // A range without CPU-dirty pages has nothing to upload (the same early exit // SynchronizeBuffer takes for this read-only call), and the block summary // answers that without a lock for the common all-clean case. if (_tracker.HasCpuDirtyPages(start, finish - start)) _ = SynchronizeBuffer(buffer, start, finish - start, false, false, preserveCpuWriteHotPages: false); } } } // Tests use lower thresholds to check collection without large allocations. internal void SetCollectionThresholds(ulong collectionThreshold, ulong criticalThreshold) { _retirementPolicy.SetThresholds(collectionThreshold, criticalThreshold); } // Runs before the image readback flush and both collectors; the order matches the render loop. public void ProcessPendingFaultBuffer() { if (_faultProcessPending) { _faultProcessPending = false; ProcessFaultBuffer(); } } public void RunGarbageCollector() { using var foreignRead = _device.Slabs.BeginForeignRead(); ProcessPendingFaultBuffer(); if (!_retirementPolicy.TryBeginCollection(_registry.RegisteredBytes, out var retirement)) { return; } var dirtyBuffers = new List(); var copies = new List(); var retireCount = 0; _registry.VisitRetirementCandidates(retirement.LatestEligibleTick, bufferIdentifier => { var buffer = _registry.TryGetRegisteredBuffer(bufferIdentifier); if (buffer == null) { throw SubmissionScheduler.Fatal("The recency queue contains a deleted buffer."); } _tracker.ValidateGpuDirtyOwnership(_gpuModifiedRanges, buffer.CpuAddress, buffer.Size, "garbage collection"); var dirty = _tracker.HasGpuDirtyPages(buffer.CpuAddress, buffer.Size); if (dirty && !retirement.DownloadDirtyBuffers) { return false; } if (GuestGpuMemoryHook.Traces(buffer.CpuAddress, buffer.Size)) GuestGpuMemoryHook.Trace(buffer.CpuAddress, buffer.Size, $"device-address-collection buffer={bufferIdentifier} dirty={dirty} aggressive={retirement.DownloadDirtyBuffers} cutoff_tick={retirement.LatestEligibleTick} collection_tick={_retirementPolicy.CurrentTick - 1} used_bytes={_registry.RegisteredBytes}"); if (dirty) { CollectDirtyPieces(buffer, copies, "garbage collection"); dirtyBuffers.Add(bufferIdentifier); } else { _tracker.UntrackMemory(buffer.CpuAddress, buffer.Size); DeleteBuffer(bufferIdentifier); } return ++retireCount == retirement.MaximumBufferCount; }); if (dirtyBuffers.Count == 0) { return; } if (copies.Count == 0) { throw SubmissionScheduler.Fatal("Dirty buffers have no download ranges."); } DownloadBufferMemory(copies); foreach (var bufferIdentifier in dirtyBuffers) { ReleaseDownloaded(bufferIdentifier); } } // Teardown: every GPU result reaches guest memory and every page returns to its guest protection. public void Shutdown() { var drained = false; try { AbandonEagerReadbacks(); if (_scheduler.Active) { _scheduler.Finish(); } using var foreignRead = _device.Slabs.BeginForeignRead(); var copies = new List(); var dirtyBuffers = new List(); foreach (var bufferIdentifier in _registry.SnapshotRegisteredIdentifiers()) { var buffer = _registry.GetBuffer(bufferIdentifier); if (_tracker.HasGpuDirtyPages(buffer.CpuAddress, buffer.Size)) { CollectDirtyPieces(buffer, copies, "shutdown"); dirtyBuffers.Add(bufferIdentifier); } } if (copies.Count != 0) { DownloadBufferMemory(copies); } foreach (var bufferIdentifier in dirtyBuffers) { ReleaseDownloaded(bufferIdentifier); } foreach (var bufferIdentifier in _registry.SnapshotRegisteredIdentifiers()) { var buffer = _registry.GetBuffer(bufferIdentifier); _tracker.UntrackMemory(buffer.CpuAddress, buffer.Size); Unregister(bufferIdentifier); _registry.CompleteRetirement(bufferIdentifier); } if (_scheduler.Active) { _scheduler.Finish(); } Dispose(); drained = true; } finally { lock (_shutdownGate) { _outcome = drained ? ShutdownOutcome.Drained : ShutdownOutcome.Failed; Monitor.PulseAll(_shutdownGate); } } } public void Dispose() { if (_disposed) { return; } _disposed = true; _registry.Dispose(); _deviceRing.Dispose(); _download.Dispose(); _stream.Dispose(); _staging.Dispose(); _bdaPageTable.Dispose(); _gds.Dispose(); _faults.Dispose(); } // False only when the store closed and its drain failed; the caller then declines the fault. private bool ReadMemoryOrAwaitShutdown(ulong guestAddress, ulong size, bool isWrite, GuestMemoryProfile.ReadbackSource source = GuestMemoryProfile.ReadbackSource.ExplicitReadback) { if (!_relay.IsGpuQueueThread && SubmissionScheduler.InDeferredOperation) { throw SubmissionScheduler.Fatal( $"unsupported buffer readback from an asynchronous GPU completion, addr=0x{guestAddress:X16} size=0x{size:X16}"); } if (!_relay.IsGpuQueueThread && AsyncReadback is { } readback && GuestReadsAwaitOffQueue) { for (var attempt = 0; ; attempt++) { var allowPending = attempt < MaxAsyncReadbackAttempts; var markHot = attempt == 0 && !isWrite; PendingDownload? pending = null; if (!_relay.TryRunOnGpuQueue(() => pending = BeginReadMemoryOnGpu(guestAddress, size, isWrite, source, allowPending, markHot))) { return AwaitShutdown(); } if (pending is null) { return true; } readback.Wait(pending.Ticket); var complete = false; if (!_relay.TryRunOnGpuQueue(() => complete = CompleteReadMemoryOnGpu(pending, consumed: true))) { return AwaitShutdown(); } if (complete) { return true; } } } var onQueue = _relay.IsGpuQueueThread; if (_relay.TryRunOnGpuQueue(() => { if (!onQueue || isWrite || !TryUseEagerReadback(guestAddress, size)) { _ = BeginReadMemoryOnGpu(guestAddress, size, isWrite, source, allowPending: false, markHot: onQueue); } })) { return true; } return AwaitShutdown(); } private const ulong ReadbackWindowBytes = 512 * 1024; private const long HotWindowLifetime = 512; private const int MaxEagerReadbacks = 4; private const int MaxAsyncReadbackAttempts = 4; private const int EagerStrikeLimit = 3; private const long EagerMuteBatches = 4096; private readonly Dictionary _hotWindows = new(); private readonly HashSet _eagerCandidates = new(); private readonly List _eagerDownloads = new(); private long _batchSerial; private static long _reportedEagerStarted; private static long _reportedEagerUsed; private static long _reportedEagerStale; private static long _reportedEagerMuted; private readonly record struct HotWindow(ulong Address, ulong Size, long LastHit, int Strikes = 0, long MutedUntil = 0); internal long EagerReadbacksStarted { get; private set; } internal long EagerReadbacksUsed { get; private set; } private void NoteHotReadback(ulong guestAddress, ulong size) { var key = guestAddress & ~(ReadbackWindowBytes - 1); _hotWindows[key] = _hotWindows.TryGetValue(key, out var hot) ? hot with { Address = guestAddress, Size = size, LastHit = _batchSerial } : new HotWindow(guestAddress, size, _batchSerial); } internal long EagerReadbacksWasted { get; private set; } private void NoteEagerOutcome(ulong key, bool wasted) { if (wasted) { EagerReadbacksWasted++; } if (!_hotWindows.TryGetValue(key, out var hot)) { return; } if (!wasted) { if (hot.Strikes != 0) { _hotWindows[key] = hot with { Strikes = 0 }; } return; } var strikes = hot.Strikes + 1; _hotWindows[key] = strikes >= EagerStrikeLimit ? hot with { Strikes = 0, MutedUntil = _batchSerial + EagerMuteBatches } : hot with { Strikes = strikes }; if (strikes >= EagerStrikeLimit) { Interlocked.Increment(ref _reportedEagerMuted); } } private void NoteHotWindowWrite(ulong guestAddress, ulong size) { if (_hotWindows.Count == 0 || size == 0) { return; } var last = (guestAddress + size - 1) & ~(ReadbackWindowBytes - 1); for (var key = guestAddress & ~(ReadbackWindowBytes - 1); key <= last; key += ReadbackWindowBytes) { if (_hotWindows.TryGetValue(key, out var hot) && _batchSerial - hot.LastHit <= HotWindowLifetime && _batchSerial >= hot.MutedUntil) { _eagerCandidates.Add(key); HotWritePending |= hot.Strikes == 0; } } } public bool HotWritePending { get; private set; } public void OnBatchSubmitted() { _batchSerial++; HotWritePending = false; if (AsyncReadback is not { } readback) { return; } for (var index = _eagerDownloads.Count - 1; index >= 0; index--) { var pending = _eagerDownloads[index]; if (readback.IsComplete(pending.Ticket)) { _eagerDownloads.RemoveAt(index); _ = CompleteReadMemoryOnGpu(pending, consumed: false); } } if ((_batchSerial & 1023) == 0) { foreach (var key in _hotWindows.Where(pair => _batchSerial - pair.Value.LastHit > HotWindowLifetime && _batchSerial >= pair.Value.MutedUntil) .Select(pair => pair.Key).ToList()) { _hotWindows.Remove(key); } } if (_eagerCandidates.Count == 0) { return; } List? settled = null; foreach (var key in _eagerCandidates) { if (_eagerDownloads.Count >= MaxEagerReadbacks) { break; } if (!_hotWindows.TryGetValue(key, out var hot) || _batchSerial - hot.LastHit > HotWindowLifetime || _batchSerial < hot.MutedUntil || StartEagerReadback(hot) != EagerStart.Deferred) { (settled ??= []).Add(key); } } if (settled is null) { return; } foreach (var key in settled) { _eagerCandidates.Remove(key); } } private enum EagerStart { Started, Deferred, Nothing, } private EagerStart StartEagerReadback(HotWindow hot) { if (FindOwner(hot.Address, hot.Size) is not { } buffer) { return EagerStart.Nothing; } if (OverlapsEagerReadback(hot.Address, hot.Size)) { return EagerStart.Deferred; } var copies = CollectReadbackWindow(buffer, hot.Address, hot.Size, out var windowBegin, out var windowEnd); if (copies.Count == 0) { return EagerStart.Nothing; } if (!TryBeginDownloadAsync(copies, out var ticket, out var writeTicks)) { return EagerStart.Deferred; } foreach (var copy in copies) { copy.Buffer.RetainForeignRead(); } _eagerDownloads.Add(new PendingDownload { HotKey = hot.Address & ~(ReadbackWindowBytes - 1), Ticket = ticket, Copies = copies, WriteTicks = writeTicks, WindowBegin = windowBegin, WindowEnd = windowEnd, GuestAddress = hot.Address, Size = hot.Size, IsWrite = false, Eager = true, Source = GuestMemoryProfile.ReadbackSource.ShaderResourceRead, Started = System.Diagnostics.Stopwatch.GetTimestamp(), }); EagerReadbacksStarted++; Interlocked.Increment(ref _reportedEagerStarted); return EagerStart.Started; } private bool OverlapsEagerReadback(ulong guestAddress, ulong size) { foreach (var pending in _eagerDownloads) { if (guestAddress < pending.WindowEnd && pending.WindowBegin < guestAddress + size) { return true; } } return false; } private PendingDownload? TakeEagerReadback(ulong guestAddress, ulong size) { for (var index = _eagerDownloads.Count - 1; index >= 0; index--) { var pending = _eagerDownloads[index]; if (guestAddress >= pending.WindowBegin && size <= pending.WindowEnd - pending.WindowBegin && guestAddress - pending.WindowBegin <= pending.WindowEnd - pending.WindowBegin - size) { _eagerDownloads.RemoveAt(index); NoteHotReadback(guestAddress, size); return pending; } } return null; } private bool TryUseEagerReadback(ulong guestAddress, ulong size) { if (AsyncReadback is not { } readback || TakeEagerReadback(guestAddress, size) is not { } pending) { return false; } readback.Wait(pending.Ticket); return CompleteReadMemoryOnGpu(pending with { GuestAddress = guestAddress, Size = size }, consumed: true); } private void AbandonEagerReadbacks() { if (AsyncReadback is not { } readback) { return; } foreach (var pending in _eagerDownloads) { readback.Wait(pending.Ticket); readback.Complete(pending.Ticket, null); foreach (var copy in pending.Copies) { copy.Buffer.ReleaseForeignRead(); } } _eagerDownloads.Clear(); _eagerCandidates.Clear(); } private static readonly bool GuestReadsAwaitOffQueue = Environment.GetEnvironmentVariable("SHARPEMU_ASYNC_GUEST_READBACK") != "0"; private bool AwaitShutdown() { lock (_shutdownGate) { while (_outcome == ShutdownOutcome.Pending) { Monitor.Wait(_shutdownGate); } return _outcome == ShutdownOutcome.Drained; } } internal long PendingReadbacksApplied { get; private set; } internal long PendingReadbacksRetried { get; private set; } private static long _reportedApplied; private static long _reportedRetried; private static long _reportedStalePieces; private static long _reportedPublishedPieces; internal long StalePiecesKept { get; private set; } public static string TakeAsyncReadbackReport() => FormattableString.Invariant( $"[PERF][ASYNC_READBACK] applied={Interlocked.Exchange(ref _reportedApplied, 0)} retried={Interlocked.Exchange(ref _reportedRetried, 0)} published_pieces={Interlocked.Exchange(ref _reportedPublishedPieces, 0)} stale_pieces={Interlocked.Exchange(ref _reportedStalePieces, 0)} eager_started={Interlocked.Exchange(ref _reportedEagerStarted, 0)} eager_used={Interlocked.Exchange(ref _reportedEagerUsed, 0)} eager_stale={Interlocked.Exchange(ref _reportedEagerStale, 0)} eager_muted={Interlocked.Exchange(ref _reportedEagerMuted, 0)}"); private sealed record PendingDownload { public required VulkanAsyncReadback.Ticket Ticket { get; init; } public required List Copies { get; init; } public required ulong[] WriteTicks { get; init; } public required ulong WindowBegin { get; init; } public required ulong WindowEnd { get; init; } public required ulong GuestAddress { get; init; } public required ulong Size { get; init; } public required bool IsWrite { get; init; } public bool Eager { get; init; } public ulong HotKey { get; init; } public required GuestMemoryProfile.ReadbackSource Source { get; init; } public required long Started { get; init; } } private PendingDownload? BeginReadMemoryOnGpu(ulong guestAddress, ulong size, bool isWrite, GuestMemoryProfile.ReadbackSource source, bool allowPending, bool markHot = false) { using var readbackScope = GuestMemoryProfile.Measure(GuestMemoryProfile.Operation.BufferReadback); using var foreignRead = _device.Slabs.BeginForeignRead(); var readbackStarted = GuestMemoryProfile.ReadbackDetailsEnabled ? System.Diagnostics.Stopwatch.GetTimestamp() : 0; if (isWrite && !IsRegionRegistered(guestAddress, size)) { return null; } if (!isWrite && allowPending && TakeEagerReadback(guestAddress, size) is { } eager) { return eager with { GuestAddress = guestAddress, Size = size, IsWrite = isWrite, Source = source }; } var copies = CollectReadbackWindow(guestAddress, size, out var windowBegin, out var windowEnd); if (markHot && copies.Count != 0) { NoteHotReadback(guestAddress, size); } if (copies.Count != 0 && allowPending && TryBeginDownloadAsync(copies, out var ticket, out var writeTicks)) { foreach (var copy in copies) { copy.Buffer.RetainForeignRead(); } return new PendingDownload { Ticket = ticket, Copies = copies, WriteTicks = writeTicks, WindowBegin = windowBegin, WindowEnd = windowEnd, GuestAddress = guestAddress, Size = size, IsWrite = isWrite, Source = source, Started = readbackStarted, }; } if (copies.Count != 0) { DownloadBufferMemory(copies); _tracker.ClearGpuDirtyPages(windowBegin, windowEnd - windowBegin); } if (isWrite) { _tracker.MarkCpuDirtyPages(guestAddress, size); } RecordReadback(windowBegin, windowEnd, isWrite, copies, readbackStarted, source); return null; } private bool CompleteReadMemoryOnGpu(PendingDownload pending, bool consumed) { using var readbackScope = GuestMemoryProfile.Measure(GuestMemoryProfile.Operation.BufferReadback); var readback = AsyncReadback ?? throw SubmissionScheduler.Fatal("The pending readback lost its queue."); try { var copies = pending.Copies; var current = new bool[copies.Count]; var published = 0; for (var index = 0; index < copies.Count; index++) { current[index] = IsPieceCurrent(pending, index); if (current[index]) { published++; } } readback.Complete(pending.Ticket, published == 0 ? null : (index, bytes) => { if (!current[index]) { return; } GpuWritebackEpoch.Advance(); 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{(ulong)bytes.Length:X16}"); } }); for (var index = 0; index < copies.Count; index++) { if (current[index]) { _gpuModifiedRanges.Remove(copies[index].Address, copies[index].Size); Interlocked.Increment(ref _gpuModifiedVersion); } } for (var index = 0; index < copies.Count; index++) { if (current[index]) { ClearGpuDirtyPagesOfRemovedRange(copies[index].Address, copies[index].Size); } } var stale = copies.Count - published; if (pending.Eager) { NoteEagerOutcome(pending.HotKey, stale != 0); } if (stale != 0) { StalePiecesKept += stale; Interlocked.Add(ref _reportedStalePieces, stale); } Interlocked.Add(ref _reportedPublishedPieces, published); var clean = pending.IsWrite ? !_tracker.HasGpuDirtyPages(pending.GuestAddress, pending.Size) : !HasGpuDirtyBytes(pending.GuestAddress, pending.Size); if (!consumed) { if (pending.Eager && stale != 0) { Interlocked.Increment(ref _reportedEagerStale); } return clean; } if (!clean) { PendingReadbacksRetried++; Interlocked.Increment(ref _reportedRetried); if (pending.Eager) { Interlocked.Increment(ref _reportedEagerStale); } return false; } PendingReadbacksApplied++; Interlocked.Increment(ref _reportedApplied); if (pending.Eager) { EagerReadbacksUsed++; Interlocked.Increment(ref _reportedEagerUsed); } if (pending.IsWrite) { _tracker.MarkCpuDirtyPages(pending.GuestAddress, pending.Size); } RecordReadback(pending.WindowBegin, pending.WindowEnd, pending.IsWrite, copies, pending.Started, pending.Source); return true; } finally { foreach (var copy in pending.Copies) { copy.Buffer.ReleaseForeignRead(); } } } private bool IsPieceCurrent(PendingDownload pending, int index) { var copy = pending.Copies[index]; return copy.Buffer.LastGpuWriteTickIn(copy.SourceOffset, copy.Size) == pending.WriteTicks[index] && _gpuModifiedRanges.Contains(copy.Address, copy.Size) && ReferenceEquals(FindOwner(copy.Address, copy.Size), copy.Buffer); } private static void RecordReadback(ulong windowBegin, ulong windowEnd, bool isWrite, List copies, long started, GuestMemoryProfile.ReadbackSource source) { if (!GuestMemoryProfile.ReadbackDetailsEnabled) { return; } var downloadedBytes = 0UL; foreach (var copy in copies) downloadedBytes += copy.Size; GuestMemoryProfile.RecordBufferReadback(windowBegin, windowEnd - windowBegin, isWrite, downloadedBytes, System.Diagnostics.Stopwatch.GetTimestamp() - started, source); } // The GPU-modified ranges to download for a read of the range, widened to a window so // nearby CPU reads share one GPU drain. private List CollectReadbackWindow(ulong guestAddress, ulong size, out ulong windowBegin, out ulong windowEnd) => CollectReadbackWindow(_registry.GetBuffer(FindBuffer(guestAddress, size)), guestAddress, size, out windowBegin, out windowEnd); private List CollectReadbackWindow(GpuBuffer buffer, ulong guestAddress, ulong size, out ulong windowBegin, out ulong windowEnd) { const ulong windowSize = ReadbackWindowBytes; var bufferEnd = buffer.CpuAddress + buffer.Size; windowBegin = Math.Max(guestAddress & ~(windowSize - 1), buffer.CpuAddress); windowEnd = Math.Min(Math.Max(windowBegin + windowSize, guestAddress + size), bufferEnd); var copies = new List(); _tracker.ForEachDownloadRange( windowBegin, windowEnd - windowBegin, clear: false, (address, bytes) => _tracker.ValidateGpuDirtyPages(_gpuModifiedRanges, address, bytes, "memory invalidation"), (address, bytes) => { foreach (var range in _gpuModifiedRanges.GetOverlappingRanges(address, bytes)) { copies.Add(new DownloadPiece(buffer, buffer.Offset(range.Address), range.Address, range.Size)); } }); return copies; } private void CollectDirtyPieces(GpuBuffer buffer, List copies, string operation) { _tracker.ForEachDownloadRange( buffer.CpuAddress, buffer.Size, clear: false, (address, bytes) => _tracker.ValidateGpuDirtyPages(_gpuModifiedRanges, address, bytes, operation), (address, bytes) => { foreach (var range in _gpuModifiedRanges.GetOverlappingRanges(address, bytes)) { copies.Add(new DownloadPiece(buffer, range.Address - buffer.CpuAddress, range.Address, range.Size)); } }); } private void ReleaseDownloaded(ResourceSlotIdentifier bufferIdentifier) { var buffer = _registry.GetBuffer(bufferIdentifier); _tracker.ClearGpuDirtyPages(buffer.CpuAddress, buffer.Size); if (_tracker.HasGpuDirtyPages(buffer.CpuAddress, buffer.Size) || _gpuModifiedRanges.Overlaps(buffer.CpuAddress, buffer.Size)) { throw SubmissionScheduler.Fatal("Buffer collection left GPU-owned memory."); } _tracker.UntrackMemory(buffer.CpuAddress, buffer.Size); Unregister(bufferIdentifier); _registry.CompleteRetirement(bufferIdentifier); } private void WriteDataBuffer(GpuBuffer buffer, ulong address, ReadOnlySpan source) { while (!source.IsEmpty) { var chunk = (int)Math.Min((ulong)source.Length, _staging.Size); var offset = _staging.Copy(source[..chunk], 4); buffer.CopyFrom(_scheduler.Current, _staging, offset, buffer.Offset(address), (ulong)chunk, AccessFlags.HostWriteBit); source = source[chunk..]; address += (ulong)chunk; } } private void Register(ResourceSlotIdentifier bufferIdentifier) => UpdateRegistration(bufferIdentifier, insert: true); private void Unregister(ResourceSlotIdentifier bufferIdentifier) => UpdateRegistration(bufferIdentifier, insert: false); private void UpdateRegistration(ResourceSlotIdentifier bufferIdentifier, bool insert) { var buffer = _registry.GetBuffer(bufferIdentifier); if (!PageOwnerTable.TryGetPageRange(buffer.CpuAddress, buffer.Size, out var first, out var lastExclusive)) { throw SubmissionScheduler.Fatal("The buffer is outside the page table."); } var sizePages = lastExclusive - first; if (GuestGpuMemoryHook.Traces(buffer.CpuAddress, buffer.Size)) GuestGpuMemoryHook.Trace(buffer.CpuAddress, buffer.Size, $"device-address-registration insert={insert} buffer={bufferIdentifier} submission_tick={_scheduler.CurrentTick} collection_tick={_retirementPolicy.CurrentTick}"); if (insert) { _registry.RegisterBuffer(bufferIdentifier, _retirementPolicy.CurrentTick); var addresses = new ulong[sizePages]; for (ulong page = 0; page < sizePages; page++) { addresses[page] = buffer.DeviceAddress + (page << CachingPageBits); } WriteDataBuffer(_bdaPageTable, first * sizeof(ulong), MemoryMarshal.AsBytes(addresses)); } else { _registry.BeginRetirement(bufferIdentifier); _bdaPageTable.Fill(first * sizeof(ulong), sizePages * sizeof(ulong), 0); } } private void TouchBuffer(GpuBuffer buffer) { var identifier = _registry.FindContainingBuffer(buffer.CpuAddress, buffer.Size); if (identifier.IsValid && ReferenceEquals(_registry.GetBuffer(identifier), buffer)) { TouchBuffer(identifier); } } private void TouchBuffer(ResourceSlotIdentifier identifier) => _registry.MarkBufferUsed(identifier, _retirementPolicy.CurrentTick); private void DeleteBuffer(ResourceSlotIdentifier bufferIdentifier) { if (_registry.TryGetRegisteredBuffer(bufferIdentifier) == null) { return; } Unregister(bufferIdentifier); if (_scheduler.Active) { _scheduler.QueueCompletionAction(() => _registry.CompleteRetirement(bufferIdentifier)); } else { _registry.CompleteRetirement(bufferIdentifier); } } // Set when a second queue can copy readbacks; null keeps every readback on the main queue. internal VulkanAsyncReadback? AsyncReadback { get; set; } private const ulong AsyncReadbackLimit = 64UL << 20; // Packs pieces into the download ring, waits for the copy, then writes each through the backing alias. private void DownloadBufferMemory(List copies) { if (TryDownloadAsync(copies)) { foreach (var copy in copies) { _gpuModifiedRanges.Remove(copy.Address, copy.Size); Interlocked.Increment(ref _gpuModifiedVersion); } return; } var batch = new List(); var planner = new BufferDownloadBatchPlanner(_download.Size); foreach (var piece in copies) { var copy = piece; while (copy.Size != 0) { var placement = planner.Append(copy.SourceOffset, copy.Size, copy.Buffer.Size); batch.Add(new PlannedDownload(copy.Buffer, copy.Address, placement)); copy = copy with { SourceOffset = copy.SourceOffset + placement.DataSize, Address = copy.Address + placement.DataSize, Size = copy.Size - placement.DataSize, }; if (planner.IsFull) { FlushDownloads(batch, planner.PackedSize); planner.Reset(); } } } if (batch.Count != 0) { FlushDownloads(batch, planner.PackedSize); } foreach (var copy in copies) { _gpuModifiedRanges.Remove(copy.Address, copy.Size); Interlocked.Increment(ref _gpuModifiedVersion); } } private bool TryBeginDownloadAsync(List copies, out VulkanAsyncReadback.Ticket ticket, out ulong[] writeTicks) { ticket = null!; writeTicks = []; if (AsyncReadback is not { } readback || copies.Count == 0) { return false; } var waitTick = 0UL; var total = 0UL; var ticks = new ulong[copies.Count]; for (var index = 0; index < copies.Count; index++) { var written = copies[index].Buffer.LastGpuWriteTickIn(copies[index].SourceOffset, copies[index].Size); if (written == 0) { return false; } ticks[index] = written; waitTick = Math.Max(waitTick, written); total += copies[index].Size; } if (total > AsyncReadbackLimit) { return false; } if (waitTick >= _scheduler.CurrentTick) { _scheduler.Flush(); } var pieces = new ReadbackPiece[copies.Count]; for (var index = 0; index < pieces.Length; index++) { pieces[index] = new ReadbackPiece(copies[index].Buffer, copies[index].SourceOffset, copies[index].Size); } if (!readback.TryBegin(pieces, waitTick, out var started) || started is null) { return false; } ticket = started; writeTicks = ticks; return true; } // Copies the pieces on the readback queue after only the tick that last wrote their // buffers, instead of appending the copy to the main queue and draining all of it. private bool TryDownloadAsync(List copies) { if (AsyncReadback is not { } readback || copies.Count == 0) { return false; } var waitTick = 0UL; var total = 0UL; foreach (var copy in copies) { var written = copy.Buffer.LastGpuWriteTickIn(copy.SourceOffset, copy.Size); // A GPU-modified range with no recorded writer: keep the conservative path. if (written == 0) { return false; } waitTick = Math.Max(waitTick, written); total += copy.Size; } if (total > AsyncReadbackLimit) { return false; } // The last writer is still in the buffer being recorded; submit it (without // waiting) so the readback queue has a signal to wait for. if (waitTick >= _scheduler.CurrentTick) { _scheduler.Flush(); } var pieces = new ReadbackPiece[copies.Count]; for (var index = 0; index < pieces.Length; index++) { pieces[index] = new ReadbackPiece(copies[index].Buffer, copies[index].SourceOffset, copies[index].Size); } readback.Read(pieces, waitTick, (index, bytes) => { GpuWritebackEpoch.Advance(); 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{(ulong)bytes.Length:X16}"); } }); return true; } private void FlushDownloads(List batch, ulong packedSize) { if (!_download.TryMap(packedSize, out var baseOffset, BufferDownloadBatchPlanner.Alignment)) { throw SubmissionScheduler.Fatal("The download ring could not map the batch."); } foreach (var copy in batch) { var placement = copy.Placement; _download.CopyFrom( _scheduler.Current, copy.Buffer, placement.SourceOffset, baseOffset + placement.DestinationOffset, placement.TransferSize, AccessFlags.MemoryWriteBit, AccessFlags.None, AccessFlags.MemoryReadBit | AccessFlags.MemoryWriteBit, AccessFlags.HostReadBit); } _download.Commit(); var completionTick = _scheduler.CurrentTick; _scheduler.Finish(); _scheduler.WaitForPriorityOperations(completionTick); foreach (var copy in batch) { var placement = copy.Placement; var offset = baseOffset + placement.DataOffset; _download.Invalidate(offset, placement.DataSize); GpuWritebackEpoch.Advance(); if (!_backing.TryWriteBacking(copy.Address, _download.Mapped.Slice((int)offset, (int)placement.DataSize))) { throw SubmissionScheduler.Fatal($"Could not write the required direct backing: addr=0x{copy.Address:X16} size=0x{placement.DataSize:X16}"); } } batch.Clear(); } private OverlapSpan ResolveOverlaps(ulong guestAddress, ulong size) { var range = new BufferMergeRange(guestAddress, guestAddress + size); var first = _registry.FindFirstOverlappingIndex(range.Begin); var last = first; for (; last < _registry.RegisteredCount && _registry.GetRegisteredAddress(last) < range.End; last++) { var buffer = _registry.GetBuffer(_registry.GetRegisteredIdentifier(last)); if (range.IncludeBuffer(buffer.CpuAddress, buffer.CpuAddress + buffer.Size, buffer.StreamScore)) { first = _registry.FindFirstOverlappingIndex(range.Begin); if (first < _registry.RegisteredCount) { range.IncludeEarlierBuffer(_registry.GetRegisteredAddress(first)); } } } return new OverlapSpan(first, last, range.Begin, range.End, range.HasStreamExpansion); } private void MergeOverlappingBuffer(ResourceSlotIdentifier newBufferIdentifier, ResourceSlotIdentifier overlappingBufferIdentifier, bool accumulateStreamScore) { var newBuffer = _registry.GetBuffer(newBufferIdentifier); var overlap = _registry.GetBuffer(overlappingBufferIdentifier); if (GuestGpuMemoryHook.Traces(overlap.CpuAddress, overlap.Size)) GuestGpuMemoryHook.Trace(overlap.CpuAddress, overlap.Size, $"device-address-merge old={overlappingBufferIdentifier} replacement={newBufferIdentifier} submission_tick={_scheduler.CurrentTick}"); if (accumulateStreamScore) { newBuffer.AddStreamScore(overlap.StreamScore + 1); } newBuffer.CopyFrom(_scheduler.Current, overlap, 0, overlap.CpuAddress - newBuffer.CpuAddress, overlap.Size); DeleteBuffer(overlappingBufferIdentifier); } private ResourceSlotIdentifier CreateBuffer(ulong guestAddress, ulong size) { if (_scheduler.Current.IsInvalid) { throw SubmissionScheduler.Fatal("Buffer creation requires a command buffer that is recording."); } var end = (guestAddress + size + CachingPageSize - 1) & ~(CachingPageSize - 1); guestAddress &= ~(CachingPageSize - 1); size = end - guestAddress; var overlap = ResolveOverlaps(guestAddress, size); var overlapping = new List(); for (var index = overlap.First; index < overlap.Last; index++) { overlapping.Add(_registry.GetRegisteredIdentifier(index)); } var bufferIdentifier = _registry.AllocateBuffer(new GpuBuffer( _device, _scheduler, 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) { MergeOverlappingBuffer(bufferIdentifier, oldId, !overlap.HasStreamLeap); } Register(bufferIdentifier); return bufferIdentifier; } private bool SynchronizeBuffer(GpuBuffer buffer, ulong guestAddress, ulong size, bool isWritten, bool isTexelBuffer, bool preserveCpuWriteHotPages = true) { using var profileScope = RenderPhaseProfile.MeasureDetail(RenderPhaseProfile.Phase.BufferDirtySynchronization); var startedAt = BufferUploadProfile.Enabled ? System.Diagnostics.Stopwatch.GetTimestamp() : 0; var nothingToUpload = isWritten ? _gpuModifiedRanges.Contains(guestAddress, size) : !_tracker.MayHaveCpuDirtyPages(guestAddress, size); if (nothingToUpload) { if (BufferUploadProfile.Enabled) BufferUploadProfile.Record(guestAddress, size, 0, 0, 0, System.Diagnostics.Stopwatch.GetTimestamp() - startedAt); return isTexelBuffer && SynchronizeTexelFromImage(buffer, guestAddress, size); } profileScope.SwitchPhase(isWritten ? RenderPhaseProfile.Phase.BufferDirtySyncWritten : isTexelBuffer ? RenderPhaseProfile.Phase.BufferDirtySyncTexel : RenderPhaseProfile.Phase.BufferDirtySyncUpload); var collector = _uploadCollector; if (collector is null || collector.InUse) { // The first call on a thread keeps its collector; a nested one gets its own. collector = new UploadCollector(); _uploadCollector ??= collector; } collector.Begin(this, buffer, guestAddress, size); try { return SynchronizeBufferCore(collector, buffer, guestAddress, size, isWritten, isTexelBuffer, preserveCpuWriteHotPages, startedAt); } finally { collector.End(); } } // The ranges of one synchronization. The delegates are bound once, so a call that has dirty // pages to upload allocates no closure and no list. private sealed class UploadCollector { private GuestBufferCache _cache = null!; private GpuBuffer _buffer = null!; private ulong _guestAddress; private ulong _size; public readonly List Copies = new(); public ulong TotalSize; public GpuBuffer? Source; public bool InUse; public readonly Action Collect; public readonly Action Upload; public UploadCollector() { Collect = (address, bytes) => { Copies.Add(new BufferCopy(TotalSize, _buffer.Offset(address), bytes)); TotalSize += bytes; }; Upload = () => Source = _cache._uploader.PrepareSource(_buffer.CpuAddress, CollectionsMarshal.AsSpan(Copies), TotalSize, _guestAddress, _size); } public void Begin(GuestBufferCache cache, GpuBuffer buffer, ulong guestAddress, ulong size) { InUse = true; _cache = cache; _buffer = buffer; _guestAddress = guestAddress; _size = size; } public void End() { InUse = false; _cache = null!; _buffer = null!; Copies.Clear(); TotalSize = 0; Source = null; } } [ThreadStatic] private static UploadCollector? _uploadCollector; private bool SynchronizeBufferCore(UploadCollector collector, GpuBuffer buffer, ulong guestAddress, ulong size, bool isWritten, bool isTexelBuffer, bool preserveCpuWriteHotPages, long startedAt) { var copies = collector.Copies; _tracker.ForEachUploadRange(guestAddress, size, isWritten, collector.Collect, collector.Upload, preserveCpuWriteHotPages); var source = collector.Source; var totalSize = collector.TotalSize; if (source != null) { foreach (var copy in copies) { buffer.NoteGpuWrite(copy.DstOffset, copy.Size); } var command = _scheduler.Current; command.EndRendering(); var native = new CommandBuffer(command.Handle); var vk = _device.Vk; var before = new BufferMemoryBarrier2 { SType = StructureType.BufferMemoryBarrier2, SrcAccessMask = AccessFlags2.MemoryReadBit | AccessFlags2.MemoryWriteBit | AccessFlags2.TransferReadBit | AccessFlags2.TransferWriteBit, DstAccessMask = AccessFlags2.TransferWriteBit, SrcQueueFamilyIndex = Vk.QueueFamilyIgnored, DstQueueFamilyIndex = Vk.QueueFamilyIgnored, Buffer = buffer.Handle, Offset = 0, Size = buffer.Size, }; VulkanSynchronization.PipelineBarrier(vk, native, PipelineStageFlags.AllCommandsBit, PipelineStageFlags.TransferBit, DependencyFlags.ByRegionBit, 0, null, 1, &before, 0, null); var regions = CollectionsMarshal.AsSpan(copies); fixed (BufferCopy* pointer = regions) { vk.CmdCopyBuffer(native, source.Handle, buffer.Handle, (uint)regions.Length, pointer); } var after = before; after.SrcAccessMask = AccessFlags2.TransferWriteBit; after.DstAccessMask = AccessFlags2.MemoryReadBit | AccessFlags2.MemoryWriteBit; VulkanSynchronization.PipelineBarrier(vk, native, PipelineStageFlags.TransferBit, PipelineStageFlags.AllCommandsBit, DependencyFlags.ByRegionBit, 0, null, 1, &after, 0, null); } if (BufferUploadProfile.Enabled) { var elapsedTicks = System.Diagnostics.Stopwatch.GetTimestamp() - startedAt; ulong hotBytes = 0; foreach (var copy in copies) hotBytes += _tracker.CountCpuWriteHotBytes(buffer.CpuAddress + copy.DstOffset, copy.Size); BufferUploadProfile.Record(guestAddress, size, copies.Count, totalSize, hotBytes, elapsedTicks); } return isTexelBuffer && SynchronizeTexelFromImage(buffer, guestAddress, size); } private bool SynchronizeTexelFromImage(GpuBuffer buffer, ulong guestAddress, ulong size) { var copiedFromImage = RequireImageCache().TrySynchronizeBufferFromImage(buffer, guestAddress, size); if (copiedFromImage) { buffer.NoteGpuWrite(buffer.Offset(guestAddress), size); } return copiedFromImage; } private GpuBuffer? FindOwner(ulong guestAddress, ulong size) { return _registry.TryGetRegisteredBuffer(_registry.FindContainingBuffer(guestAddress, size)); } private IGuestImageCache RequireImageCache() => ImageCache ?? throw SubmissionScheduler.Fatal("The image cache is not connected."); private static bool IsValidRange(ulong guestAddress, ulong size) => guestAddress != 0 && size != 0 && new GuestSpan(guestAddress, size).IsValid; }