[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.
This commit is contained in:
kuba 2026-09-30 13:59:51 +02:00
parent 0675cfa892
commit ca76f7c8c8
4 changed files with 44 additions and 6 deletions

View File

@ -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;

View File

@ -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)

View File

@ -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))

View File

@ -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()
{