From ca76f7c8c8c88fcedc6dd81efd0610ff4ecbe9e0 Mon Sep 17 00:00:00 2001 From: kuba Date: Wed, 30 Sep 2026 13:59:51 +0200 Subject: [PATCH] [Gpu] Age cached images by frame, not by guest submission The image collector runs after every completed guest submission as well as at each flip, and every run advanced the age it compares against. A title submits dozens of command buffers per frame, so "unused for 16 ticks" meant a fraction of a frame: every texture sampled once a frame was deleted and recreated, and Superliminal uploaded 3,591 textures (2.2 GB) every 5 seconds. Deleting a GPU-written image the cache cannot read back also dropped its contents. Only a flip now advances the age. Collection after a submission still runs, so memory pressure is relieved during long stretches without a flip. Superliminal's per-frame uploads went from ~55 to none, and its intro scene from 12 to 45 fps. --- .../Gpu/Images/GuestImageCache.Collection.cs | 10 ++++-- .../VulkanVideoPresenter.CommandStream.cs | 4 +-- .../VulkanVideoPresenter.RenderLoop.cs | 4 +-- .../Images/GuestImageCacheTests.Readback.cs | 32 +++++++++++++++++++ 4 files changed, 44 insertions(+), 6 deletions(-) diff --git a/src/SharpEmu.Libs/Gpu/Images/GuestImageCache.Collection.cs b/src/SharpEmu.Libs/Gpu/Images/GuestImageCache.Collection.cs index c68ce45..9b8e456 100644 --- a/src/SharpEmu.Libs/Gpu/Images/GuestImageCache.Collection.cs +++ b/src/SharpEmu.Libs/Gpu/Images/GuestImageCache.Collection.cs @@ -8,10 +8,16 @@ namespace SharpEmu.Libs.Gpu.Images; // Garbage collection by recency and memory pressure, and the scheduled readback flush. public sealed partial class GuestImageCache { - public void RunGarbageCollector() + public void RunGarbageCollector() => RunGarbageCollector(endsFrame: true); + + // Image ages are counted in frames. Collection also runs after guest submissions, so memory + // pressure is relieved during long stretches without a flip, but only a frame advances the + // age: a title submits dozens of command buffers per frame, and counting those made every + // texture it samples once per frame look stale, so it was deleted and uploaded again. + public void RunGarbageCollector(bool endsFrame) { using var held = _lock.Hold(); - var tick = _collectionTick++; + var tick = endsFrame ? _collectionTick++ : _collectionTick; if (_totalUsedMemory < _collectionStartBytes) { return; diff --git a/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.CommandStream.cs b/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.CommandStream.cs index e366a3f..1d74473 100644 --- a/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.CommandStream.cs +++ b/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.CommandStream.cs @@ -305,7 +305,7 @@ internal static unsafe partial class VulkanVideoPresenter CollectCompletedGuestSubmissions(waitForOldest: false); } - public void RunGarbageCollector() => RunGuestCacheCollection(); + public void RunGarbageCollector() => RunGuestCacheCollection(endsFrame: false); public void EmitGlobalBarrier() { @@ -536,7 +536,7 @@ internal static unsafe partial class VulkanVideoPresenter } FlushBatchedGuestCommands(); - RunGuestCacheCollection(); + RunGuestCacheCollection(endsFrame: true); EnsureGuestSubmissionCapacity(); long version; lock (_gate) diff --git a/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.RenderLoop.cs b/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.RenderLoop.cs index fdc9d67..307d6f9 100644 --- a/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.RenderLoop.cs +++ b/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.RenderLoop.cs @@ -26,13 +26,13 @@ internal static unsafe partial class VulkanVideoPresenter } } - private void RunGuestCacheCollection() + private void RunGuestCacheCollection(bool endsFrame) { ProcessGuestCacheReadbacks(); using (RenderPhaseProfile.Measure(RenderPhaseProfile.Phase.ImageCollect)) { - _imageCache.RunGarbageCollector(); + _imageCache.RunGarbageCollector(endsFrame); } using (RenderPhaseProfile.Measure(RenderPhaseProfile.Phase.BufferCollect)) diff --git a/tests/SharpEmu.Libs.Tests/Gpu/Images/GuestImageCacheTests.Readback.cs b/tests/SharpEmu.Libs.Tests/Gpu/Images/GuestImageCacheTests.Readback.cs index ca38dc7..5e7baf3 100644 --- a/tests/SharpEmu.Libs.Tests/Gpu/Images/GuestImageCacheTests.Readback.cs +++ b/tests/SharpEmu.Libs.Tests/Gpu/Images/GuestImageCacheTests.Readback.cs @@ -93,6 +93,38 @@ public sealed partial class GuestImageCacheTests harness.Shutdown(); } + // A title submits many command buffers per frame; collection after each of them must not age + // the textures it samples once a frame, or they are deleted and uploaded again every frame. + [Fact] + public void GarbageCollector_AgesImagesByFrameNotBySubmission() + { + if (!GatePrerequisites.Ready(_vulkan)) return; + using var harness = new CacheHarness(_vulkan); + var address = harness.MapBacked(0x400000, ReadWrite); + var request = Color32(address + 0x330000); + ResourceSlotIdentifier[] images = [harness.Find(ref request)]; + harness.Worker.Run(() => + { + harness.Images.SetCollectionThresholds(0, ulong.MaxValue, ulong.MaxValue, 1); + harness.Images.ResetRecency(images, 1); + for (var submission = 0; submission < 64; submission++) + { + harness.Images.RunGarbageCollector(endsFrame: false); + } + }); + Assert.True(harness.Images.Contains(images[0])); + + harness.Worker.Run(() => + { + for (var frame = 0; frame < 64; frame++) + { + harness.Images.RunGarbageCollector(endsFrame: true); + } + }); + Assert.False(harness.Images.Contains(images[0])); + harness.Shutdown(); + } + [Fact] public void ScheduledReadback_PublishesAfterTheTickAndKeepsTheImage() {