From 8acc0812057134be1552ea5c00e01dfbd412a981 Mon Sep 17 00:00:00 2001 From: kuba Date: Wed, 7 Oct 2026 11:36:27 +0200 Subject: [PATCH] [GPU/HLE] Cut per-draw and per-call managed allocations on hot paths (#1054) * [GPU/HLE] Cut per-draw and per-call allocations on hot paths * [HLE] Keep nested tracker uploads from sharing rollback scratch --- .../GpuMemory/GuestPageTracker.cs | 96 +++++++++++++--- src/SharpEmu.HLE/GpuMemory/TrackedRegion.cs | 20 ++-- .../Gpu/Buffers/GuestBufferCache.cs | 53 ++++++--- .../Gpu/Images/CachedImage.Transfers.cs | 17 +-- .../Kernel/KernelMemoryCompatExports.cs | 104 +++++++++++++----- .../Kernel/KernelPthreadCompatExports.cs | 22 +++- .../Resources/ScalarGraphDiskCache.cs | 35 ++++-- .../Memory/GpuMemory/GuestPageTrackerTests.cs | 25 +++++ 8 files changed, 277 insertions(+), 95 deletions(-) diff --git a/src/SharpEmu.HLE/GpuMemory/GuestPageTracker.cs b/src/SharpEmu.HLE/GpuMemory/GuestPageTracker.cs index 0ac893a..d9eef56 100644 --- a/src/SharpEmu.HLE/GpuMemory/GuestPageTracker.cs +++ b/src/SharpEmu.HLE/GpuMemory/GuestPageTracker.cs @@ -242,50 +242,108 @@ public sealed class GuestPageTracker } } - // Region locks stay held across uploadFunc only for a written range, which turns GPU-dirty. + // Receives the CPU-dirty runs an upload copies, then performs the upload while the regions are held. + public interface IUploadRangeSink + { + void Range(ulong address, ulong size); + + void Upload(); + } + + private struct DelegateUploadSink(Action rangeFunc, Action uploadFunc) : IUploadRangeSink + { + public readonly void Range(ulong address, ulong size) => rangeFunc(address, size); + + public readonly void Upload() => uploadFunc(); + } + + // Forwards a region's upload runs to the sink and remembers the runs it cleared, for rollback. + private struct RegionUploadVisitor(TSink sink, List<(TrackedRegion Region, ulong Address, ulong Size)> cleared) + : TrackedRegion.ICpuUploadVisitor + where TSink : struct, IUploadRangeSink + { + public TSink Sink = sink; + + public readonly void Cleared(TrackedRegion region, ulong address, ulong size) => cleared.Add((region, address, size)); + + public void Upload(ulong address, ulong size) => Sink.Range(address, size); + } + + // Uploads run on every bound buffer of every draw, so the bookkeeping lists are reused per thread. + [ThreadStatic] + private static List? _scratchHeld; + + [ThreadStatic] + private static List<(TrackedRegion Region, ulong Address, ulong Size)>? _scratchCleared; + public void ForEachUploadRange(ulong vaddr, ulong size, bool isWritten, Action rangeFunc, Action uploadFunc, bool preserveCpuWriteHotPages = true) + { + var sink = new DelegateUploadSink(rangeFunc, uploadFunc); + ForEachUploadRange(vaddr, size, isWritten, ref sink, preserveCpuWriteHotPages); + } + + // Region locks stay held across the upload only for a written range, which turns GPU-dirty. + public void ForEachUploadRange(ulong vaddr, ulong size, bool isWritten, ref TSink sink, bool preserveCpuWriteHotPages = true) + where TSink : struct, IUploadRangeSink { RejectUploadCallbackReentry(); VisitRegions(vaddr, size, create: true, static (_, _, _) => false); var previousOwner = _uploadOwner; _uploadOwner = this; - var held = new List(); - var cleared = new List<(TrackedRegion Region, ulong Address, ulong Size)>(); + // Another tracker may upload from this callback. It needs its own rollback + // state; the reentry guard only rejects reentry into this tracker. + var held = previousOwner is null + ? _scratchHeld ??= new List() + : new List(); + var cleared = previousOwner is null + ? _scratchCleared ??= new List<(TrackedRegion Region, ulong Address, ulong Size)>() + : new List<(TrackedRegion Region, ulong Address, ulong Size)>(); + held.Clear(); + cleared.Clear(); try { using (GpuMemoryAccessProfile.Measure(GpuMemoryAccessProfile.Operation.UploadTracking, size)) { - VisitRegions(vaddr, size, create: false, (region, offset, bytes) => + var visitor = new RegionUploadVisitor(sink, cleared); + var preserveHotPages = !isWritten && preserveCpuWriteHotPages; + for (var (index, offset, remaining) = (vaddr / BlockBytes, vaddr % BlockBytes, size); remaining != 0; index++, offset = 0) { + var bytes = Math.Min(BlockBytes - offset, remaining); + remaining -= bytes; + if (Volatile.Read(ref _regions[index]) is not { } region) + { + continue; + } + region.Lock.Enter(); held.Add(region); - var address = region.BaseAddress + offset; - region.ForEachCpuUploadRange( - preserveHotPages: !isWritten && preserveCpuWriteHotPages, - address, - bytes, - (runAddress, runSize) => cleared.Add((region, runAddress, runSize)), - rangeFunc); + region.ForEachCpuUploadRange(preserveHotPages, region.BaseAddress + offset, bytes, ref visitor); if (!isWritten) { region.Lock.Exit(); - held.Remove(region); + held.RemoveAt(held.Count - 1); } + } - return false; - }); + sink = visitor.Sink; } - uploadFunc(); + sink.Upload(); if (isWritten) { - VisitRegions(vaddr, size, create: false, (region, offset, bytes) => + for (var (index, offset, remaining) = (vaddr / BlockBytes, vaddr % BlockBytes, size); remaining != 0; index++, offset = 0) { + var bytes = Math.Min(BlockBytes - offset, remaining); + remaining -= bytes; + if (Volatile.Read(ref _regions[index]) is not { } region) + { + continue; + } + region.ChangeState(WriteOrigin.Gpu, enable: true, region.BaseAddress + offset, bytes); region.Lock.Exit(); held.Remove(region); - return false; - }); + } } } catch @@ -333,6 +391,8 @@ public sealed class GuestPageTracker region.Lock.Exit(); } + held.Clear(); + cleared.Clear(); _uploadOwner = previousOwner; } } diff --git a/src/SharpEmu.HLE/GpuMemory/TrackedRegion.cs b/src/SharpEmu.HLE/GpuMemory/TrackedRegion.cs index 7dfe95c..e435736 100644 --- a/src/SharpEmu.HLE/GpuMemory/TrackedRegion.cs +++ b/src/SharpEmu.HLE/GpuMemory/TrackedRegion.cs @@ -144,20 +144,24 @@ public sealed class TrackedRegion } } + // Receives the runs one upload clears and the runs it copies; a struct visitor keeps the walk allocation-free. + public interface ICpuUploadVisitor + { + void Cleared(TrackedRegion region, ulong address, ulong size); + + void Upload(ulong address, ulong size); + } + // Hot read-only pages stay writable and dirty, so each obtain observes current CPU bytes. - public void ForEachCpuUploadRange( - bool preserveHotPages, - ulong address, - ulong size, - Action visitCleared, - Action visitUpload) + public void ForEachCpuUploadRange(bool preserveHotPages, ulong address, ulong size, ref TVisitor visitor) + where TVisitor : struct, ICpuUploadVisitor { var (start, end) = GetPageRange(address, size); var upload = new PageMask(_cpuDirty, start, end); var cleared = preserveHotPages ? upload & ~_hotCpuWrites : upload; foreach (var (runStart, runEnd) in cleared) { - visitCleared(BaseAddress + (ulong)runStart * PageBytes, (ulong)(runEnd - runStart) * PageBytes); + visitor.Cleared(this, BaseAddress + (ulong)runStart * PageBytes, (ulong)(runEnd - runStart) * PageBytes); _cpuDirty.UnsetRange(runStart, runEnd); _recentCpuUploads.SetRange(runStart, runEnd); } @@ -165,7 +169,7 @@ public sealed class TrackedRegion UpdateCpuProtection(track: true); foreach (var (runStart, runEnd) in upload) { - visitUpload(BaseAddress + (ulong)runStart * PageBytes, (ulong)(runEnd - runStart) * PageBytes); + visitor.Upload(BaseAddress + (ulong)runStart * PageBytes, (ulong)(runEnd - runStart) * PageBytes); } } diff --git a/src/SharpEmu.Libs/Gpu/Buffers/GuestBufferCache.cs b/src/SharpEmu.Libs/Gpu/Buffers/GuestBufferCache.cs index 8bdef93..33a6253 100644 --- a/src/SharpEmu.Libs/Gpu/Buffers/GuestBufferCache.cs +++ b/src/SharpEmu.Libs/Gpu/Buffers/GuestBufferCache.cs @@ -455,11 +455,15 @@ public sealed unsafe class GuestBufferCache : IGuestBufferStore, IDisposable } } + // The fill pattern staged per thread; DCC fills arrive with every compressed target clear. + [ThreadStatic] + private static uint[]? _fillPattern; + private bool TryWriteFillToBacking(ulong guestAddress, ulong size, uint value) { - var values = new uint[(int)Math.Min(size / sizeof(uint), 4096)]; - Array.Fill(values, value); - var bytes = MemoryMarshal.AsBytes(values); + var values = (_fillPattern ??= new uint[4096]).AsSpan(0, (int)Math.Min(size / sizeof(uint), 4096)); + values.Fill(value); + var bytes = MemoryMarshal.AsBytes(values); for (ulong offset = 0; offset < size;) { var chunk = (int)Math.Min(size - offset, (ulong)bytes.Length); @@ -2043,20 +2047,11 @@ public sealed unsafe class GuestBufferCache : IGuestBufferStore, IDisposable profileScope.SwitchPhase(isWritten ? RenderPhaseProfile.Phase.BufferDirtySyncWritten : isTexelBuffer ? RenderPhaseProfile.Phase.BufferDirtySyncTexel : RenderPhaseProfile.Phase.BufferDirtySyncUpload); - var copies = new List(); - var totalSize = 0UL; - GpuBuffer? source = null; - _tracker.ForEachUploadRange( - guestAddress, - size, - isWritten, - (address, bytes) => - { - copies.Add(new BufferCopy(totalSize, buffer.Offset(address), bytes)); - totalSize += bytes; - }, - () => source = _uploader.PrepareSource(buffer.CpuAddress, CollectionsMarshal.AsSpan(copies), totalSize, guestAddress, size), - preserveCpuWriteHotPages); + var copies = _syncCopies ??= new List(); + copies.Clear(); + var sink = new UploadSink(_uploader, buffer, copies, guestAddress, size); + _tracker.ForEachUploadRange(guestAddress, size, isWritten, ref sink, preserveCpuWriteHotPages); + var source = sink.Source; if (source != null) { buffer.NoteGpuWrite(); @@ -2098,7 +2093,7 @@ public sealed unsafe class GuestBufferCache : IGuestBufferStore, IDisposable 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); + BufferUploadProfile.Record(guestAddress, size, copies.Count, sink.TotalSize, hotBytes, elapsedTicks); } if (isTexelBuffer) @@ -2115,6 +2110,28 @@ public sealed unsafe class GuestBufferCache : IGuestBufferStore, IDisposable return false; } + // The copies of one synchronization; the tracker forbids nesting, so one list per thread is reused. + [ThreadStatic] + private static List? _syncCopies; + + // Collects a buffer's CPU-dirty runs as copies, then stages them while the tracker holds the regions. + private struct UploadSink(GuestBufferUploader uploader, GpuBuffer buffer, List copies, ulong guestAddress, ulong size) + : GuestPageTracker.IUploadRangeSink + { + public ulong TotalSize { get; private set; } + + public GpuBuffer? Source { get; private set; } + + public void Range(ulong address, ulong bytes) + { + copies.Add(new BufferCopy(TotalSize, buffer.Offset(address), bytes)); + TotalSize += bytes; + } + + public void Upload() => + Source = uploader.PrepareSource(buffer.CpuAddress, CollectionsMarshal.AsSpan(copies), TotalSize, guestAddress, size); + } + private GpuBuffer? FindOwner(ulong guestAddress, ulong size) { return _registry.TryGetRegisteredBuffer(_registry.FindContainingBuffer(guestAddress, size)); diff --git a/src/SharpEmu.Libs/Gpu/Images/CachedImage.Transfers.cs b/src/SharpEmu.Libs/Gpu/Images/CachedImage.Transfers.cs index 6984196..e46bc1d 100644 --- a/src/SharpEmu.Libs/Gpu/Images/CachedImage.Transfers.cs +++ b/src/SharpEmu.Libs/Gpu/Images/CachedImage.Transfers.cs @@ -40,9 +40,12 @@ public sealed unsafe partial class CachedImage }; // Barriers for the requested state; a repeated write always gets a barrier. + private static readonly List NoBarriers = new(0); + public (List Barriers, PipelineStageFlags SourceStages) GetBarriers(ImageLayout layout, AccessFlags access, PipelineStageFlags stage, SubresourceRange? range) { - var barriers = new List(); + // Most requests need no barrier; the list exists only once one does. Callers never add to it. + List? barriers = null; PipelineStageFlags sourceStages = 0; if ((access & (WriteAccess | AccessFlags.ColorAttachmentWriteBit | AccessFlags.DepthStencilAttachmentWriteBit)) != 0) { @@ -95,7 +98,7 @@ public sealed unsafe partial class CachedImage } else if (state.Layout != layout || state.Access != access || repeatedWrite) { - barriers.Add(MakeBarrier(state, layout, access, level, 1, layer, 1)); + (barriers ??= new List(1)).Add(MakeBarrier(state, layout, access, level, 1, layer, 1)); sourceStages |= state.Stage; states[index] = new ImageAccessState(stage, access, layout); } @@ -113,21 +116,21 @@ public sealed unsafe partial class CachedImage var repeatedWrite = (state.Access & WriteAccess) != 0; if (state.Layout == layout && state.Access == access && !repeatedWrite) { - return (barriers, sourceStages); + return (barriers ?? NoBarriers, sourceStages); } if (IsReadAfterRead(state, layout, access)) { Backing.State = new ImageAccessState(state.Stage | stage, state.Access | access, layout); - return (barriers, sourceStages); + return (barriers ?? NoBarriers, sourceStages); } - barriers.Add(MakeBarrier(state, layout, access, 0, Vk.RemainingMipLevels, 0, Vk.RemainingArrayLayers)); + (barriers ??= new List(1)).Add(MakeBarrier(state, layout, access, 0, Vk.RemainingMipLevels, 0, Vk.RemainingArrayLayers)); sourceStages |= state.Stage; } Backing.State = new ImageAccessState(stage, access, layout); - return (barriers, sourceStages); + return (barriers ?? NoBarriers, sourceStages); } // The layout every subresource in the range is in, or null when they differ. @@ -217,7 +220,7 @@ public sealed unsafe partial class CachedImage private void RecordBarriers(CommandBuffer command, PipelineStageFlags sourceStages, PipelineStageFlags destinationStages, BufferMemoryBarrier2* bufferBarrier, List imageBarriers) { - var images = imageBarriers.ToArray(); + var images = System.Runtime.InteropServices.CollectionsMarshal.AsSpan(imageBarriers); fixed (ImageMemoryBarrier2* imagePointer = images) { VulkanSynchronization.PipelineBarrier(_device.Vk, diff --git a/src/SharpEmu.Libs/Kernel/KernelMemoryCompatExports.cs b/src/SharpEmu.Libs/Kernel/KernelMemoryCompatExports.cs index 5093b89..725c244 100644 --- a/src/SharpEmu.Libs/Kernel/KernelMemoryCompatExports.cs +++ b/src/SharpEmu.Libs/Kernel/KernelMemoryCompatExports.cs @@ -2358,41 +2358,53 @@ public static partial class KernelMemoryCompatExports positionBefore = -1; } - var buffer = GC.AllocateUninitializedArray(requested); - var read = stream.Read(buffer, 0, requested); - if (read > 0 && useBinkCompletionShim) - { - // The patched NumFrames field is what tells the guest "this - // movie is fully consumed" - hold that specific read until the - // host has actually finished showing it, so guest-side game - // logic can't race ahead of what's still on screen. - if (completionShim.Patch(positionBefore, buffer.AsSpan(0, read))) - { - HostMovieBridge.WaitForHostPlaybackToFinish(stream.Name); - } - } - if (read > 0 && !ctx.Memory.TryWrite(bufferAddress, buffer.AsSpan(0, read))) - { - return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT; - } - - long positionAfter; + // Guest reads are frequent and mostly small: a pooled buffer spares each one an allocation. + var pooled = requested <= MaxPooledReadBytes; + var buffer = pooled ? ArrayPool.Shared.Rent(requested) : GC.AllocateUninitializedArray(requested); try { - positionAfter = stream.Position; + var read = stream.Read(buffer, 0, requested); + if (read > 0 && useBinkCompletionShim) + { + // The patched NumFrames field is what tells the guest "this + // movie is fully consumed" - hold that specific read until the + // host has actually finished showing it, so guest-side game + // logic can't race ahead of what's still on screen. + if (completionShim.Patch(positionBefore, buffer.AsSpan(0, read))) + { + HostMovieBridge.WaitForHostPlaybackToFinish(stream.Name); + } + } + if (read > 0 && !ctx.Memory.TryWrite(bufferAddress, buffer.AsSpan(0, read))) + { + return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT; + } + + long positionAfter; + try + { + positionAfter = stream.Position; + } + catch (IOException) + { + positionAfter = -1; + } + + LogIoTrace( + "read", + stream.Name, + $"fd={fd} req={requested} read={read} pos={positionBefore}->{positionAfter} preview='{PreviewIoBytes(buffer, read, 64)}' hex={PreviewIoHex(buffer, read, 32)} guest_tail={PreviewGuestHex(ctx, bufferAddress + (ulong)Math.Max(read, 0), 32)}"); + + ctx[CpuRegister.Rax] = unchecked((ulong)read); + return (int)OrbisGen2Result.ORBIS_GEN2_OK; } - catch (IOException) + finally { - positionAfter = -1; + if (pooled) + { + ArrayPool.Shared.Return(buffer); + } } - - LogIoTrace( - "read", - stream.Name, - $"fd={fd} req={requested} read={read} pos={positionBefore}->{positionAfter} preview='{PreviewIoBytes(buffer, read, 64)}' hex={PreviewIoHex(buffer, read, 32)} guest_tail={PreviewGuestHex(ctx, bufferAddress + (ulong)Math.Max(read, 0), 32)}"); - - ctx[CpuRegister.Rax] = unchecked((ulong)read); - return (int)OrbisGen2Result.ORBIS_GEN2_OK; } [SysAbiExport( @@ -7206,9 +7218,41 @@ public static partial class KernelMemoryCompatExports } // Read once: file-heavy titles call LogIoTrace for every resolved path. + private const int MaxPooledReadBytes = 1 << 20; + private static readonly bool _logIo = string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_LOG_IO"), "1", StringComparison.Ordinal); + // Builds an I/O trace detail only while SHARPEMU_LOG_IO is on: its holes (byte previews of + // every read) are otherwise never evaluated. + [System.Runtime.CompilerServices.InterpolatedStringHandler] + private ref struct IoTraceDetail + { + private System.Runtime.CompilerServices.DefaultInterpolatedStringHandler _inner; + + public IoTraceDetail(int literalLength, int formattedCount, out bool enabled) + { + enabled = _logIo; + _inner = enabled ? new System.Runtime.CompilerServices.DefaultInterpolatedStringHandler(literalLength, formattedCount) : default; + } + + public void AppendLiteral(string value) => _inner.AppendLiteral(value); + + public void AppendFormatted(T value) => _inner.AppendFormatted(value); + + public void AppendFormatted(T value, string? format) => _inner.AppendFormatted(value, format); + + public string ToStringAndClear() => _inner.ToStringAndClear(); + } + + private static void LogIoTrace(string operation, string path, IoTraceDetail detail) + { + if (_logIo) + { + LogIoTrace(operation, path, detail.ToStringAndClear()); + } + } + private static void LogIoTrace(string operation, string path, string detail) { if (!_logIo) diff --git a/src/SharpEmu.Libs/Kernel/KernelPthreadCompatExports.cs b/src/SharpEmu.Libs/Kernel/KernelPthreadCompatExports.cs index 10b66b4..6bb804b 100644 --- a/src/SharpEmu.Libs/Kernel/KernelPthreadCompatExports.cs +++ b/src/SharpEmu.Libs/Kernel/KernelPthreadCompatExports.cs @@ -1054,12 +1054,7 @@ public static class KernelPthreadCompatExports } if (canCooperativelyBlock && waiter is not null && - GuestThreadExecution.RequestCurrentThreadBlock( - ctx, - "pthread_mutex_lock", - waiter.WakeKey, - () => CompleteBlockedMutexLock(ctx, mutexAddress, resolvedAddress, state, waiter), - () => TryGrantBlockedMutexLock(ctx, mutexAddress, resolvedAddress, state, waiter))) + RequestBlockedMutexLock(ctx, mutexAddress, resolvedAddress, state, waiter)) { TracePthreadMutex(ctx, "lock-block", mutexAddress, resolvedAddress, state, currentThreadId, (int)OrbisGen2Result.ORBIS_GEN2_OK); return (int)OrbisGen2Result.ORBIS_GEN2_OK; @@ -1070,6 +1065,21 @@ public static class KernelPthreadCompatExports return hostResult; } + // The continuations capture the lock's arguments in a closure; keeping them out of + // PthreadMutexLockCore spares every uncontended lock that allocation. + private static bool RequestBlockedMutexLock( + CpuContext ctx, + ulong mutexAddress, + ulong resolvedAddress, + PthreadMutexState state, + PthreadMutexWaiter waiter) => + GuestThreadExecution.RequestCurrentThreadBlock( + ctx, + "pthread_mutex_lock", + waiter.WakeKey, + () => CompleteBlockedMutexLock(ctx, mutexAddress, resolvedAddress, state, waiter), + () => TryGrantBlockedMutexLock(ctx, mutexAddress, resolvedAddress, state, waiter)); + private static int PthreadMutexUnlockCore(CpuContext ctx, ulong mutexAddress, bool requireOwner) { if (mutexAddress == 0) diff --git a/src/SharpEmu.ShaderCompiler/Resources/ScalarGraphDiskCache.cs b/src/SharpEmu.ShaderCompiler/Resources/ScalarGraphDiskCache.cs index 4f8e10f..826bf14 100644 --- a/src/SharpEmu.ShaderCompiler/Resources/ScalarGraphDiskCache.cs +++ b/src/SharpEmu.ShaderCompiler/Resources/ScalarGraphDiskCache.cs @@ -62,14 +62,7 @@ internal static class ScalarGraphDiskCache Directory.CreateDirectory(directory); // Keep a bounded cache across compiler versions too. No live // analysis objects or guest memory are retained by the cache. - var entries = new DirectoryInfo(directory).GetFiles("*.graph").OrderBy(f => f.LastWriteTimeUtc).ToArray(); - var total = entries.Sum(f => f.Length) + stream.Length + 32; - foreach (var entry in entries) - { - if (total <= MaxCacheBytes) break; - total -= entry.Length; - entry.Delete(); - } + EvictOldest(directory, stream.Length + 32); var temporary = path + "." + Guid.NewGuid().ToString("N") + ".tmp"; try { @@ -79,6 +72,8 @@ internal static class ScalarGraphDiskCache file.Write(bytes); } File.Move(temporary, path, true); + _entries!.Enqueue((path, stream.Length + 32)); + _totalBytes += stream.Length + 32; } finally { File.Delete(temporary); } } @@ -89,6 +84,30 @@ internal static class ScalarGraphDiskCache return graph; } + // The entries of the cache directory, oldest first, and their total size. The directory is + // listed once per process: a new build misses on every shader, and listing it per miss cost + // more than the analysis it saved. Caller holds WriteLock. + private static string? _entriesDirectory; + private static Queue<(string Path, long Bytes)>? _entries; + private static long _totalBytes; + + private static void EvictOldest(string directory, long incoming) + { + if (_entries is null || _entriesDirectory != directory) + { + var files = new DirectoryInfo(directory).GetFiles("*.graph").OrderBy(f => f.LastWriteTimeUtc).ToArray(); + _entries = new Queue<(string Path, long Bytes)>(files.Select(f => (f.FullName, f.Length))); + _totalBytes = files.Sum(f => f.Length); + _entriesDirectory = directory; + } + + while (_totalBytes + incoming > MaxCacheBytes && _entries.TryDequeue(out var entry)) + { + _totalBytes -= entry.Bytes; + File.Delete(entry.Path); + } + } + internal static string Key(Gen5ShaderProgram program, uint userDataBase, uint userDataCount, IReadOnlySet? fixedFunctionVertexLoads, uint waveSize) { diff --git a/tests/SharpEmu.Libs.Tests/Memory/GpuMemory/GuestPageTrackerTests.cs b/tests/SharpEmu.Libs.Tests/Memory/GpuMemory/GuestPageTrackerTests.cs index 58a8fec..d9e0f0c 100644 --- a/tests/SharpEmu.Libs.Tests/Memory/GpuMemory/GuestPageTrackerTests.cs +++ b/tests/SharpEmu.Libs.Tests/Memory/GpuMemory/GuestPageTrackerTests.cs @@ -739,6 +739,31 @@ public sealed class GuestPageTrackerTests : IDisposable Release(address, Page); } + [NativePageProtectionFact] + public void NestedUploadOnAnotherTrackerKeepsTheOuterRollbackState() + { + using var other = new GuestPageTrackerTests(); + var address = Allocate(1); + var otherAddress = other.Allocate(1); + Assert.Throws(() => + _tracker.ForEachUploadRange(address, Page, false, NoRange, () => + { + other._tracker.ForEachUploadRange(otherAddress, Page, false, NoRange, NoUpload); + throw new InvalidOperationException("outer upload failed"); + })); + + Assert.True(_tracker.HasCpuDirtyPages(address, Page)); + Assert.True(IsWritable(address)); + Assert.False(other._tracker.HasCpuDirtyPages(otherAddress, Page)); + var uploaded = 0; + _tracker.ForEachUploadRange(address, Page, false, (_, _) => uploaded++, NoUpload); + Assert.Equal(1, uploaded); + _tracker.UntrackMemory(address, Page); + Release(address, Page); + other._tracker.UntrackMemory(otherAddress, Page); + other.Release(otherAddress, Page); + } + [NativePageProtectionTheory] [InlineData(false, true)] [InlineData(true, true)]