acdream/tests/AcDream.App.Tests/Rendering/GpuResourceRetirementTransactionTests.cs
Erik e946b46f75 feat(render): Campaign V slice V4b - move the mesh arena onto IGpuBuffer
The shared vertex/index arena is the largest single GPU allocation acdream
makes (384 MiB + 128 MiB) and the one the Vulkan backend has the most specific
plan for (campaign doc section 4.3). This slice swaps the resource handle type
underneath it and changes nothing else: the reclaimable-range allocator, the
growth quanta, the budgeted incremental grow-and-copy, the retirement-ledger
gating, the abort ticket, the LRU that drives eviction, and the 896 MiB
dual-generation physical ceiling are all untouched. That is deliberate - those
are the semantics section 4.3 says the Vulkan arena must mirror exactly, so
preserving them is the point of the slice rather than an incidental constraint.

What moved:

- GlobalMeshBuffer's two GL buffer objects became IGpuBuffer, allocated through
  IGpuDevice.CreateBuffer with DeviceLocal residency and Vertex-or-Index plus
  both transfer usages (the arena is simultaneously a draw source and both ends
  of its own migration, which is exactly why GpuBufferUsage is a flags enum).
- UploadMesh's two hand-rolled BufferSubData sites became IGpuBuffer.Upload.
  The old code staged indices through GL_COPY_WRITE_BUFFER specifically so an
  upload could not mutate whichever VAO a preceding render pass left bound;
  Upload stages through a neutral binding point of the backend's choosing, so
  that property now comes for free instead of by hand.
- AdvanceMigration's CopyBufferSubData became IGpuBuffer.CopyTo - a device-side
  copy, which the Vulkan backend will record as vkCmdCopyBuffer. The live
  prefix still never round-trips through system memory.
- BeginMigration/CommitMigration/AbortMigration/Dispose now carry IGpuBuffer in
  the migration record and the abort ticket instead of raw uint names, so the
  ticket's identity check is a resource identity rather than a number that goes
  stale the moment the buffer is deleted.

What deliberately did not move. A VAO has no RHI verb - Vulkan bakes vertex
input into the pipeline - and WbDrawDispatcher, EnvCellRenderer and
ParticleRenderer still bind VAO/VBO/IBO with raw GL until V4c hands them the
pass encoder. So GlobalMeshBuffer keeps its GL handle for the vertex array and
its attribute layout, and VBO/IBO became computed properties that publish the
backing GL name of the buffer the arena now owns as an IGpuBuffer. One private
RequireGlBuffer helper is the single place that reaches through the interface,
and it disappears with those consumers. ObjectMeshManager therefore needed no
upload-path change at all - it reads those same three properties.

Two decisions worth recording.

First, arena deletes do not route through IGpuBuffer.Dispose. The arena already
gates every delete behind its own GpuRetirementLedger and decrements its
physical-capacity accounting in the same retirement stage; Dispose would defer
the physical free through the device queue a second time, so the accounting
would run ahead of real GPU residency and could admit a migration that breaches
the 896 MiB ceiling. GlGpuBuffer gains DeleteRetired for callers that have
already proved flight safety, and GlobalMeshBuffer composes it into a release
whose four stages match TrackedGlResource.CreateRetryableBufferDeletion exactly
- precondition, mutation-with-validation, byte accounting, resource-count
accounting - so a driver failure re-issues only the delete and never
double-counts.

Second, two corrections in the GL backend, both required to keep this port
behaviour-preserving rather than merely compiling. GlGpuBuffer's glBufferData
usage hint now follows residency (DeviceLocal -> StaticDraw), which is what the
arena has always requested; the host-writable rings and texture table keep
DynamicDraw and are unaffected. And a failed allocation now releases the GL
name it had already created - GL_OUT_OF_MEMORY is a real outcome for a 384 MiB
growth destination, and the previous code leaked the name on that path.

Plumbing: the device reaches the arena through WbMeshAdapter and
ObjectMeshManager. Their constructors became internal because IGpuDevice is an
internal type by the pinned contract, matching what V4a did for BitmapFont,
DebugLineRenderer and TextRenderer; both classes stay public and every caller
already lives inside AcDream.App or its InternalsVisibleTo test assemblies. The
unused public GlobalMeshBuffer(GL) convenience constructor is gone - it could
not supply a device and had no callers.

Gates. Release build green with TreatWarningsAsErrors. App tests 3,843 passed /
3 skipped, exactly the slice baseline; complete Release suite 8,906 passed / 5
skipped. Offline pixel gate against 79ee2361: 25 differing pixels of 563,200
(fraction 4.44e-05), against a same-commit control captured immediately
afterwards of 24 - the change is indistinguishable from capture noise and sits
40x under the 0.001 threshold. An earlier gate run was discarded rather than
interpreted: its client log showed real ScrollUp/ScrollDown input reaching the
offline window, which zoomed the camera, and a camera-motion difference is not
a rendering result.

No divergence-register row: this slice changes no retail-facing behaviour.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 20:02:50 +02:00

388 lines
12 KiB
C#

using AcDream.App.Rendering;
using AcDream.App.Rendering.Gpu;
using AcDream.App.Rendering.Wb;
using AcDream.App.Tests.Rendering.Gpu;
using Silk.NET.OpenGL;
namespace AcDream.App.Tests.Rendering;
public sealed class GpuResourceRetirementTransactionTests
{
[Theory]
[InlineData(0)]
[InlineData(1)]
[InlineData(2)]
[InlineData(3)]
public void Release_RetryResumesAtFirstUncommittedStage(int failingStage)
{
int[] calls = new int[4];
bool failed = false;
Action[] stages = Enumerable.Range(0, calls.Length)
.Select<int, Action>(stage => () =>
{
calls[stage]++;
if (stage == failingStage && !failed)
{
failed = true;
throw new InvalidOperationException($"stage {stage}");
}
})
.ToArray();
var release = new RetryableGpuResourceRelease(stages);
Assert.Throws<InvalidOperationException>(release.Run);
Assert.Equal(failingStage, release.CompletedStageCount);
release.Run();
Assert.True(release.IsComplete);
for (int stage = 0; stage < calls.Length; stage++)
Assert.Equal(stage == failingStage ? 2 : 1, calls[stage]);
}
[Fact]
public void Ledger_QueueInsertionFailureRetainsReleaseForPublicationRetry()
{
var queue = new FailBeforeAcceptQueue();
var ledger = new GpuRetirementLedger(queue);
int releases = 0;
Assert.Throws<InvalidOperationException>(() =>
ledger.Retire(new RetryableGpuResourceRelease(() => releases++)));
Assert.Equal(1, ledger.AwaitingPublicationCount);
Assert.Equal(0, releases);
ledger.RetryPendingPublications();
Assert.Equal(0, ledger.AwaitingPublicationCount);
Assert.Single(queue.Actions);
queue.Actions.Single()();
Assert.Equal(1, releases);
}
[Fact]
public void Ledger_ImmediateCallbackFailureRetainsCommittedStageCursor()
{
var ledger = new GpuRetirementLedger(ImmediateGpuResourceRetirementQueue.Instance);
int first = 0;
int second = 0;
bool failSecond = true;
var release = new RetryableGpuResourceRelease(
() => first++,
() =>
{
second++;
if (failSecond)
{
failSecond = false;
throw new InvalidOperationException("second stage");
}
});
Assert.Throws<InvalidOperationException>(() => ledger.Retire(release));
Assert.Equal(1, ledger.AwaitingPublicationCount);
Assert.Equal(1, release.CompletedStageCount);
ledger.RetryPendingPublications();
Assert.Equal(0, ledger.AwaitingPublicationCount);
Assert.Equal(1, first);
Assert.Equal(2, second);
}
[Fact]
public void Release_ReentrantDrainDoesNotReplayActiveStage()
{
RetryableGpuResourceRelease? release = null;
int active = 0;
int tail = 0;
release = new RetryableGpuResourceRelease(
() =>
{
active++;
release!.Run();
},
() => tail++);
release.Run();
Assert.True(release.IsComplete);
Assert.Equal(1, active);
Assert.Equal(1, tail);
}
[Fact]
public void Release_PostMutationValidationFailureDoesNotReplayMutationStage()
{
int mutations = 0;
int validations = 0;
int accounting = 0;
var release = new RetryableGpuResourceRelease(
() => mutations++,
() =>
{
validations++;
if (validations == 1)
throw new InvalidOperationException("post-mutation validation");
},
() => accounting++);
Assert.Throws<InvalidOperationException>(release.Run);
release.Run();
Assert.Equal(1, mutations);
Assert.Equal(2, validations);
Assert.Equal(1, accounting);
Assert.True(release.IsComplete);
}
[Fact]
public void Ledger_RetryAttemptsEveryPendingPublicationDespiteOneFailure()
{
var queue = new FailFirstNQueue(3);
var ledger = new GpuRetirementLedger(queue);
Assert.Throws<InvalidOperationException>(() =>
ledger.Retire(new RetryableGpuResourceRelease(() => { })));
Assert.Throws<InvalidOperationException>(() =>
ledger.Retire(new RetryableGpuResourceRelease(() => { })));
AggregateException error = Assert.Throws<AggregateException>(
ledger.RetryPendingPublications);
Assert.Single(error.InnerExceptions);
Assert.Equal(1, ledger.AwaitingPublicationCount);
Assert.Single(queue.Actions);
ledger.RetryPendingPublications();
Assert.Equal(0, ledger.AwaitingPublicationCount);
Assert.Equal(2, queue.Actions.Count);
}
[Fact]
public void Ledger_RetrySpecificPublicationDoesNotRepublishOtherRelease()
{
var queue = new FailFirstNQueue(2);
var ledger = new GpuRetirementLedger(queue);
var first = new RetryableGpuResourceRelease(() => { });
var second = new RetryableGpuResourceRelease(() => { });
Assert.Throws<InvalidOperationException>(() => ledger.Retire(first));
Assert.Throws<InvalidOperationException>(() => ledger.Retire(second));
ledger.RetryPendingPublication(first);
Assert.Equal(1, ledger.AwaitingPublicationCount);
Assert.Single(queue.Actions);
ledger.RetryPendingPublication(second);
Assert.Equal(0, ledger.AwaitingPublicationCount);
Assert.Equal(2, queue.Actions.Count);
}
[Fact]
public void Ledger_BatchOwnsEveryReleaseBeforePublishingFirst()
{
var queue = new FailFirstNQueue(1);
var ledger = new GpuRetirementLedger(queue);
var first = new RetryableGpuResourceRelease(() => { });
var second = new RetryableGpuResourceRelease(() => { });
Assert.Throws<AggregateException>(() => ledger.RetireMany([first, second]));
Assert.Equal(1, ledger.AwaitingPublicationCount);
Assert.Single(queue.Actions);
ledger.RetryPendingPublications();
Assert.Equal(2, queue.Actions.Count);
}
[Fact]
public void GlQueue_PersistentNextPassRetryDoesNotStarveOrdinaryWork()
{
var device = new QueueOnlyGraphicsDevice();
int retryCalls = 0;
int ordinaryCalls = 0;
Action<GL>? retry = null;
retry = _ =>
{
retryCalls++;
device.QueueGLActionForNextPass(retry!);
};
device.QueueGLActionForNextPass(retry);
device.QueueGLAction(_ => ordinaryCalls++);
device.ProcessGLQueue();
Assert.Equal(1, retryCalls);
Assert.Equal(1, ordinaryCalls);
Assert.True(device.HasPendingGLWork);
}
[Fact]
public void GlQueue_ReportsPendingWorkUntilBothGenerationsDrain()
{
var device = new QueueOnlyGraphicsDevice();
device.QueueGLAction(_ => { });
Assert.True(device.HasPendingGLWork);
device.ProcessGLQueue();
Assert.False(device.HasPendingGLWork);
}
/// <summary>
/// Campaign V slice V4b: the mesh arena's staged store is an
/// <see cref="IGpuBuffer"/> rather than a raw GL name, so the abort ticket
/// retains a resource identity. The invariant under test is unchanged — the
/// owner may only forget the migration once every release stage converged.
/// </summary>
private static IGpuBuffer StagedArenaBuffer(string name, long sizeBytes) =>
new RecordingGpuBuffer(new GpuBufferDescription(
name,
sizeBytes,
GpuBufferUsage.Vertex | GpuBufferUsage.TransferDestination,
GpuMemoryResidency.DeviceLocal));
[Fact]
public void MigrationAbortTicket_RetainsBufferUntilEveryReleaseStageConverges()
{
int deleteCalls = 0;
int accountingCalls = 0;
bool failAccounting = true;
IGpuBuffer staged = StagedArenaBuffer("staged-37", 4096);
var ticket = new GlobalMeshMigrationAbortTicket(
buffer: staged,
capacityBytes: 4096,
new RetryableGpuResourceRelease(
() => deleteCalls++,
() =>
{
if (failAccounting)
{
failAccounting = false;
throw new InvalidOperationException("injected accounting failure");
}
accountingCalls++;
}));
Assert.Throws<InvalidOperationException>(ticket.Advance);
Assert.False(ticket.IsComplete);
Assert.Same(staged, ticket.Buffer);
Assert.Equal(4096, ticket.CapacityBytes);
Assert.Equal(1, deleteCalls);
Assert.Equal(0, accountingCalls);
ticket.Advance();
ticket.Advance();
Assert.True(ticket.IsComplete);
Assert.Equal(1, deleteCalls);
Assert.Equal(1, accountingCalls);
}
[Fact]
public void MigrationAbortTicket_DeleteValidationFailureRetriesBeforeAccounting()
{
int deleteCalls = 0;
int accountingCalls = 0;
bool failDeleteValidation = true;
var ticket = new GlobalMeshMigrationAbortTicket(
buffer: StagedArenaBuffer("staged-41", 8192),
capacityBytes: 8192,
new RetryableGpuResourceRelease(
() =>
{
deleteCalls++;
if (failDeleteValidation)
{
failDeleteValidation = false;
throw new InvalidOperationException("injected GL delete validation failure");
}
},
() => accountingCalls++));
Assert.Throws<InvalidOperationException>(ticket.Advance);
Assert.False(ticket.IsComplete);
Assert.Equal(1, deleteCalls);
Assert.Equal(0, accountingCalls);
ticket.Advance();
Assert.True(ticket.IsComplete);
Assert.Equal(2, deleteCalls);
Assert.Equal(1, accountingCalls);
}
[Fact]
public void GlobalMeshVaoAccounting_CreateAndRetryableDeleteBalanceExactlyOnce()
{
int baseline = GpuMemoryTracker.VaoCount;
bool allocationOutstanding = true;
GlobalMeshVaoAccounting.TrackAllocation();
try
{
Assert.Equal(baseline + 1, GpuMemoryTracker.VaoCount);
var release = new RetryableGpuResourceRelease(
() => { },
() =>
{
GlobalMeshVaoAccounting.TrackDeallocation();
allocationOutstanding = false;
});
release.Run();
release.Run();
Assert.Equal(baseline, GpuMemoryTracker.VaoCount);
}
finally
{
if (allocationOutstanding)
GlobalMeshVaoAccounting.TrackDeallocation();
}
}
[Fact]
public void GlobalMeshVaoAccounting_InitializationRollbackReturnsToBaseline()
{
int baseline = GpuMemoryTracker.VaoCount;
GlobalMeshVaoAccounting.TrackAllocation();
GlobalMeshVaoAccounting.TrackDeallocation();
Assert.Equal(baseline, GpuMemoryTracker.VaoCount);
}
private sealed class FailBeforeAcceptQueue : IGpuResourceRetirementQueue
{
private bool _fail = true;
public List<Action> Actions { get; } = [];
public void Retire(Action release)
{
if (_fail)
{
_fail = false;
throw new InvalidOperationException("queue insertion");
}
Actions.Add(release);
}
}
private sealed class FailFirstNQueue(int failures) : IGpuResourceRetirementQueue
{
private int _remaining = failures;
public List<Action> Actions { get; } = [];
public void Retire(Action release)
{
if (_remaining-- > 0)
throw new InvalidOperationException("synthetic queue publication failure");
Actions.Add(release);
}
}
private sealed class QueueOnlyGraphicsDevice : OpenGLGraphicsDevice
{
public QueueOnlyGraphicsDevice()
: base()
{
}
}
}