fix(rendering): bound portal resource lifetime

Separate logical ownership, render publication, and GPU retirement across live entities, landblocks, particles, textures, mesh arenas, portal/UI teardown, and per-frame scratch storage. Add bounded DAT/texture caches, upload budgets, three-frame fence retirement, exact-incarnation appearance reconciliation, frame pacing, and extensive lifetime conformance coverage.\n\nThe seven-destination connected route now cuts peak working/private memory roughly in half, returns Caul to 125-153 FPS locally, and produces no WER or AMD reset.\n\nCo-authored-by: OpenAI Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-18 21:35:16 +02:00
parent 3971997689
commit 749e8ceeb1
225 changed files with 29107 additions and 3914 deletions

View file

@ -0,0 +1,338 @@
using AcDream.App.Rendering;
namespace AcDream.App.Tests.Rendering;
public sealed class StandaloneBindlessTextureCacheTests
{
[Fact]
public void SharedSurfaceRemainsLiveUntilFinalEmitterOwnerLeaves()
{
var backend = new FakeBackend();
var retirements = new DeferredRetirementQueue();
using var cache = CreateCache(backend, retirements);
StandaloneBindlessTextureResource resource = Resource(1, bytes: 4);
cache.AddAndAcquire(10, resource);
Assert.True(cache.TryAcquire(20, 1, out StandaloneBindlessTextureResource? shared));
Assert.Same(resource, shared);
cache.ReleaseOwner(10);
Assert.Equal(1, cache.ActiveResourceCount);
Assert.Equal(0, cache.UnownedEntryCount);
Assert.Empty(retirements.Actions);
cache.ReleaseOwner(20);
Assert.Equal(0, cache.ActiveResourceCount);
Assert.Equal(1, cache.UnownedEntryCount);
Assert.Empty(retirements.Actions);
Assert.True(cache.TryAcquire(30, 1, out StandaloneBindlessTextureResource? reused));
Assert.Same(resource, reused);
Assert.Equal(0, cache.UnownedEntryCount);
Assert.Empty(retirements.Actions);
}
[Fact]
public void UnownedCountBoundEvictsOldestAndDefersPhysicalRetirement()
{
var backend = new FakeBackend();
var retirements = new DeferredRetirementQueue();
using var cache = CreateCache(
backend,
retirements,
unownedBudgetBytes: 1_000,
maximumUnownedCount: 2);
AddAndRelease(cache, ownerId: 10, Resource(1, bytes: 4));
AddAndRelease(cache, ownerId: 20, Resource(2, bytes: 4));
AddAndRelease(cache, ownerId: 30, Resource(3, bytes: 4));
Assert.Equal(3, cache.EntryCount);
Assert.Empty(retirements.Actions);
cache.Tick();
Assert.Equal(2, cache.EntryCount);
Assert.Equal(2, cache.UnownedEntryCount);
Assert.Single(retirements.Actions);
Assert.Empty(backend.Events);
Assert.False(cache.TryAcquire(40, 1, out _));
Assert.True(cache.TryAcquire(40, 2, out _));
retirements.Drain();
Assert.Equal(["nonresident:1", "delete:1"], backend.Events);
}
[Fact]
public void UnownedByteBoundNeverEvictsALiveEmitterTexture()
{
var backend = new FakeBackend();
var retirements = new DeferredRetirementQueue();
using var cache = CreateCache(
backend,
retirements,
unownedBudgetBytes: 8,
maximumUnownedCount: 10);
cache.AddAndAcquire(10, Resource(1, bytes: 8));
cache.AddAndAcquire(20, Resource(2, bytes: 8));
cache.ReleaseOwner(10);
Assert.Equal(2, cache.EntryCount);
Assert.Equal(8, cache.UnownedBytes);
Assert.Empty(retirements.Actions);
cache.AddAndAcquire(30, Resource(3, bytes: 8));
cache.ReleaseOwner(30);
Assert.Equal(3, cache.EntryCount);
Assert.Empty(retirements.Actions);
cache.Tick();
Assert.Equal(2, cache.EntryCount);
Assert.Equal(8, cache.UnownedBytes);
Assert.Single(retirements.Actions);
Assert.True(cache.TryAcquire(40, 2, out _));
Assert.False(cache.TryAcquire(40, 1, out _));
}
[Fact]
public void RepeatedAcquireByOneEmitterIsIdempotent()
{
var backend = new FakeBackend();
using var cache = CreateCache(backend, new DeferredRetirementQueue());
StandaloneBindlessTextureResource resource = Resource(1, bytes: 4);
cache.AddAndAcquire(10, resource);
Assert.True(cache.TryAcquire(10, 1, out _));
Assert.True(cache.TryAcquire(10, 1, out _));
Assert.Equal(1, cache.OwnerCount);
Assert.Equal(1, cache.ActiveResourceCount);
cache.ReleaseOwner(10);
Assert.Equal(0, cache.OwnerCount);
Assert.Equal(0, cache.ActiveResourceCount);
Assert.Equal(1, cache.UnownedEntryCount);
}
[Fact]
public void PortalScaleOwnerReleaseQueuesOnlyThePerFrameEvictionBudget()
{
var backend = new FakeBackend();
var retirements = new DeferredRetirementQueue();
using var cache = CreateCache(
backend,
retirements,
unownedBudgetBytes: 1_000,
maximumUnownedCount: 2);
for (uint id = 1; id <= 100; id++)
AddAndRelease(cache, ownerId: id, Resource(id, bytes: 4));
Assert.Empty(retirements.Actions);
cache.Tick();
Assert.Single(retirements.Actions);
cache.Tick(maximumEvictions: 3);
Assert.Equal(4, retirements.Actions.Count);
Assert.Equal(96, cache.EntryCount);
}
[Fact]
public void FailedRetirementQueueAdmissionRetainsReleaseForPublicationRetry()
{
var backend = new FakeBackend();
var retirements = new DeferredRetirementQueue { FailNextAdmission = true };
using var cache = CreateCache(
backend,
retirements,
unownedBudgetBytes: 1_000,
maximumUnownedCount: 1);
AddAndRelease(cache, ownerId: 10, Resource(1, bytes: 4));
AddAndRelease(cache, ownerId: 20, Resource(2, bytes: 4));
Assert.Throws<InvalidOperationException>(() => cache.Tick());
Assert.Equal(1, cache.EntryCount);
Assert.Equal(1, cache.UnownedEntryCount);
Assert.Equal(1, cache.AwaitingRetirementPublicationCount);
cache.Tick();
Assert.Equal(1, cache.EntryCount);
Assert.Single(retirements.Actions);
Assert.Equal(0, cache.AwaitingRetirementPublicationCount);
}
[Fact]
public void DeleteFailureDoesNotRepeatCommittedResidencyRelease()
{
var backend = new FakeBackend { FailDeleteSurfaceIdOnce = 1 };
var retirements = new DeferredRetirementQueue();
using var cache = CreateCache(
backend,
retirements,
unownedBudgetBytes: 1_000,
maximumUnownedCount: 1);
AddAndRelease(cache, ownerId: 10, Resource(1, bytes: 4));
AddAndRelease(cache, ownerId: 20, Resource(2, bytes: 4));
cache.Tick();
Assert.Throws<InvalidOperationException>(() => retirements.DrainOne());
retirements.Drain();
Assert.Equal(1, backend.Events.Count(static e => e == "nonresident:1"));
Assert.Equal(2, backend.Events.Count(static e => e == "delete:1"));
}
[Fact]
public void DisposeMakesEveryHandleNonResidentBeforeDeletingAnyTexture()
{
var backend = new FakeBackend();
var cache = CreateCache(backend, new DeferredRetirementQueue());
cache.AddAndAcquire(10, Resource(1, bytes: 4));
cache.AddAndAcquire(20, Resource(2, bytes: 4));
cache.Dispose();
Assert.Equal(
["nonresident:1", "nonresident:2", "delete:1", "delete:2"],
backend.Events);
}
[Fact]
public void FailedResidencyReleaseNeverDeletesThatBackingTexture()
{
var backend = new FakeBackend { FailNonResidentSurfaceId = 1 };
var cache = CreateCache(backend, new DeferredRetirementQueue());
cache.AddAndAcquire(10, Resource(1, bytes: 4));
cache.AddAndAcquire(20, Resource(2, bytes: 4));
AggregateException failure = Assert.Throws<AggregateException>(() => cache.Dispose());
Assert.Contains("surface 1", failure.ToString());
Assert.DoesNotContain("delete:1", backend.Events);
Assert.Contains("delete:2", backend.Events);
}
[Fact]
public void DisposeRetriesOnlyTheFailedDeleteStage()
{
var backend = new FakeBackend { FailDeleteSurfaceIdOnce = 1 };
var cache = CreateCache(backend, new DeferredRetirementQueue());
cache.AddAndAcquire(10, Resource(1, bytes: 4));
cache.AddAndAcquire(20, Resource(2, bytes: 4));
Assert.Throws<AggregateException>(() => cache.Dispose());
cache.Dispose();
Assert.Equal(1, backend.Events.Count(static e => e == "nonresident:1"));
Assert.Equal(1, backend.Events.Count(static e => e == "nonresident:2"));
Assert.Equal(2, backend.Events.Count(static e => e == "delete:1"));
Assert.Equal(1, backend.Events.Count(static e => e == "delete:2"));
}
[Fact]
public void BackendDisposeReentryObservesActiveTransactionWithoutRepeatingStages()
{
var backend = new FakeBackend();
StandaloneBindlessTextureCache? cache = null;
backend.OnMutation = _ => cache!.Dispose();
cache = CreateCache(backend, new DeferredRetirementQueue());
cache.AddAndAcquire(10, Resource(1, bytes: 4));
cache.Dispose();
Assert.Equal(["nonresident:1", "delete:1"], backend.Events);
cache.Dispose();
Assert.Equal(["nonresident:1", "delete:1"], backend.Events);
}
private static StandaloneBindlessTextureCache CreateCache(
FakeBackend backend,
DeferredRetirementQueue retirements,
long unownedBudgetBytes = 64,
int maximumUnownedCount = 8) =>
new(
backend,
retirements,
unownedBudgetBytes,
maximumUnownedCount);
private static void AddAndRelease(
StandaloneBindlessTextureCache cache,
uint ownerId,
StandaloneBindlessTextureResource resource)
{
cache.AddAndAcquire(ownerId, resource);
cache.ReleaseOwner(ownerId);
}
private static StandaloneBindlessTextureResource Resource(uint surfaceId, long bytes) =>
new()
{
SurfaceId = surfaceId,
Name = surfaceId,
Handle = surfaceId + 1_000UL,
Bytes = bytes,
};
private sealed class DeferredRetirementQueue : IGpuResourceRetirementQueue
{
public bool FailNextAdmission { get; set; }
public Queue<Action> Actions { get; } = new();
public void Retire(Action release)
{
if (FailNextAdmission)
{
FailNextAdmission = false;
throw new InvalidOperationException("retirement queue full");
}
Actions.Enqueue(release);
}
public void Drain()
{
while (Actions.TryDequeue(out Action? release))
release();
}
public void DrainOne()
{
Assert.True(Actions.TryDequeue(out Action? release));
try
{
release();
}
catch
{
Actions.Enqueue(release);
throw;
}
}
}
private sealed class FakeBackend : IStandaloneBindlessTextureBackend
{
public uint FailNonResidentSurfaceId { get; init; }
public uint FailDeleteSurfaceIdOnce { get; set; }
public List<string> Events { get; } = [];
public Action<string>? OnMutation { get; set; }
public void MakeNonResident(StandaloneBindlessTextureResource resource)
{
Events.Add($"nonresident:{resource.SurfaceId}");
OnMutation?.Invoke("nonresident");
if (resource.SurfaceId == FailNonResidentSurfaceId)
throw new InvalidOperationException($"Could not release surface {resource.SurfaceId}.");
}
public void Delete(StandaloneBindlessTextureResource resource)
{
Events.Add($"delete:{resource.SurfaceId}");
OnMutation?.Invoke("delete");
if (resource.SurfaceId == FailDeleteSurfaceIdOnce)
{
FailDeleteSurfaceIdOnce = 0;
throw new InvalidOperationException($"Could not delete surface {resource.SurfaceId}.");
}
}
}
}