acdream/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanFrameFlightTests.cs
Erik fb9c6693dc feat(render): Campaign V slice V6a - Vulkan memory, buffers, rings and the frame timeline
The first of V6's three commits, and the half of the Vulkan backend that has
nothing to do with drawing: where memory comes from, how per-frame data reaches
the GPU, and what makes it safe to reuse either.

Plan sections: 4.2 (bindings layer and the no-VMA decision), 4.3 (memory:
arena, staging ring, per-frame data), 4.8 (sync and the frame).

The allocator is hand-rolled, roughly as 4.2 sizes it. Silk ships no VMA, and a
third-party binding would be a native binary to carry across win-x64, linux-x64
and CI lavapipe for an allocation profile that is genuinely tame: two mesh arena
buffers, one staging ring, a per-flight ring buffer each, a few render targets
and a texture pool. What a custom allocator buys instead is exact accounting -
every byte is attributable to a memory type and a block - which is what
GpuMemoryTracker will want and what VMA would obscure.

Placement, block policy and heap choice are pure types with no Vulkan handle in
sight: VulkanMemoryBlockFreeList is first-fit with coalescing on release,
VulkanMemoryTypePool decides when a request is large enough to warrant a block
of its own, and VulkanMemoryTypeSelection maps each GpuMemoryResidency onto a
preference order of property masks. VulkanDeviceMemoryAllocator turns their
answers into vkAllocateMemory and one persistent vkMapMemory per host-visible
block. That split is deliberate: an allocator's real failure modes are
arithmetic - a mis-coalesced neighbour, an alignment that eats a block's tail, a
double release that quietly corrupts the used-byte count - and arithmetic does
not need a GPU to be wrong. Twenty-two tests cover exactly those.

The HostWritable row of the selection table is the campaign's CPU win stated as
data. It prefers a memory type that is both DEVICE_LOCAL and HOST_VISIBLE -
resizable BAR, present on the RX 9070 XT - so per-frame data is written once,
straight into memory the GPU reads, and falls back to ordinary host-visible
coherent memory when no such type exists. GpuCapabilityRecord's
SupportsPersistentlyMappedRings is the first capability that is true on this
backend and false on GL.

Mapping is per block, never per allocation, because Vulkan permits a memory
object to be mapped once - mapping per buffer would need one VkDeviceMemory per
buffer, which is precisely the allocation-count explosion the design exists to
avoid.

VulkanRingBufferState is markedly simpler than its GL sibling, and the
difference IS the point. GlRingBufferState has to track a dirty watermark and
prove its upload never overlaps an in-flight read, because a ring allocation
there writes into a managed array that is later copied into a GL buffer. Here
the allocation hands back memory the GPU reads directly: there is no upload step
to track. What is left is a cursor.

VulkanUploadQueue accumulates transfers rather than issuing them, for two
reasons that both come from Vulkan rather than from taste: copies must be
recorded into a command buffer, and they must be recorded outside a
dynamic-rendering block. So requests queue and drain at the one moment both hold
- immediately before a pass begins - which is the direct analogue of the GL
backend's flush-before-every-draw discipline at the granularity Vulkan needs.
The drain emits one batched buffer barrier for the whole batch, one of the four
to six 4.8 budgets per frame.

Staging exhaustion falls back to a temporary dedicated buffer retired through
the ledger. Section 4.3 already specifies that for oversized uploads; extending
it to "the ring is full of unretired frames" is the same shape and is a policy
rather than a workaround - the transfer stays correct and ordered, it just costs
one allocation.

VulkanFrameFlightController is the mechanical port 4.8 promised. GL's array of
fences becomes one timeline semaphore whose value is the frame serial, "has this
slot retired?" becomes "is the counter at least serial minus two?", and the
SortedDictionary retirement ledger keeps its keys because those keys were
already frame serials. One subtlety is worth stating: a release is filed against
the frame currently being RECORDED, not the last one completed, because commands
already recorded into the open frame may still read the resource. A test pins
that, since getting it wrong frees memory a pending command buffer reads and the
symptom would appear somewhere else entirely.

Frame acquire ordering is the other subtlety. TryBeginFrame waits on the flight
slot BEFORE acquiring its swapchain image, so the slot's acquire semaphore is
provably idle - signalling a semaphore a pending submit still waits on is the
classic Vulkan deadlock. When the acquire fails the serial is still signalled
through an empty submit, because a serial that never completes makes every later
frame wait forever.

The device is a partial class split along the V6 commit boundary: everything
here is memory and frames, while textures and the descriptor table (V6b) and
pipelines, passes and readback (V6c) throw with the slice named rather than
returning something that fails later and further away. Nothing constructs this
device yet - VulkanBringUpHost still presents its clear colour - so the GL path
executes not one new statement.

VK_EXT_debug_utils naming arrives with the allocator rather than at V6c, because
every resource wants a name from birth and the campaign has already spent days
on defects only visible from outside the API. It stays optional: absent
extension means every call is a no-op and no call site checks.

Gates: Release build clean, App suite 4014 passed / 3 skipped (3981 baseline
plus 33 new). One Issue181WallPressEquilibriumTests failure in the full run is
the known #250 zero-allocation flake and passes on a single run. Offline pixel
gate against the parent is a tripwire here - the backend is dark and no GL code
path changed - and is reported with the slice.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 07:36:15 +02:00

281 lines
9.6 KiB
C#

using System;
using System.Collections.Generic;
using AcDream.App.Rendering.Gpu.Vk;
namespace AcDream.App.Tests.Rendering.Gpu.Vk;
/// <summary>
/// Campaign V slice V6a — the frame timeline, the per-slot ring, and the
/// staging ring (plan §4.3 and §4.8).
///
/// These three carry the invariant that everything else in the Vulkan backend
/// rests on: memory handed to the GPU is not reused, and memory freed by the CPU
/// is not destroyed, until the frame that could still be reading it has
/// completed. A timeline semaphore makes that one number, which is exactly what
/// makes it testable — <see cref="FakeTimeline"/> is the number.
/// </summary>
public sealed class VulkanFrameFlightTests
{
private sealed class FakeTimeline : IVulkanTimelineApi
{
public ulong Value { get; set; }
public List<ulong> Waits { get; } = [];
public ulong CurrentValue => Value;
public void Wait(ulong value)
{
Waits.Add(value);
// A real wait blocks until the GPU gets there; the fake models a
// GPU that is always just far enough ahead.
Value = Math.Max(Value, value);
}
}
// ── flight controller ────────────────────────────────────────────────────
[Fact]
public void Flights_FirstFramesDoNotWaitBecauseNoSlotIsOccupiedYet()
{
var timeline = new FakeTimeline();
var flights = new VulkanFrameFlightController(timeline, framesInFlight: 2);
Assert.Equal(1, flights.BeginFrame());
flights.EndFrame();
Assert.Equal(2, flights.BeginFrame());
flights.EndFrame();
Assert.Empty(timeline.Waits);
}
[Fact]
public void Flights_ThirdFrameWaitsForTheFirstToComplete()
{
var timeline = new FakeTimeline();
var flights = new VulkanFrameFlightController(timeline, framesInFlight: 2);
flights.BeginFrame();
flights.EndFrame();
flights.BeginFrame();
flights.EndFrame();
Assert.Equal(3, flights.BeginFrame());
Assert.Equal([1ul], timeline.Waits);
}
[Fact]
public void Flights_SerialsMapOntoSlotsRoundRobin()
{
var flights = new VulkanFrameFlightController(new FakeTimeline(), framesInFlight: 2);
Assert.Equal(0, flights.SlotIndexOf(1));
Assert.Equal(1, flights.SlotIndexOf(2));
Assert.Equal(0, flights.SlotIndexOf(3));
Assert.Equal(1, flights.SlotIndexOf(4));
}
[Fact]
public void Flights_RetirementIsKeyedToTheOpenFrameNotTheCompletedOne()
{
var timeline = new FakeTimeline();
var flights = new VulkanFrameFlightController(timeline, framesInFlight: 2);
var released = new List<string>();
flights.BeginFrame(); // serial 1 open
flights.Retire(() => released.Add("during-1"));
flights.EndFrame();
// Frame 1 has been submitted but the GPU has not finished it, so the
// release must not run: commands recorded into frame 1 may still read
// the resource.
timeline.Value = 0;
flights.BeginFrame(); // serial 2 open; retirements run here
Assert.Empty(released);
flights.EndFrame();
timeline.Value = 1;
flights.RunRetirements();
Assert.Equal(["during-1"], released);
}
[Fact]
public void Flights_ResourcesReleasedBetweenFramesBelongToTheNextFrame()
{
var timeline = new FakeTimeline();
var flights = new VulkanFrameFlightController(timeline, framesInFlight: 2);
var released = new List<string>();
flights.BeginFrame();
flights.EndFrame();
flights.Retire(() => released.Add("between"));
// Filed against serial 2, which has not even opened.
timeline.Value = 1;
flights.RunRetirements();
Assert.Empty(released);
timeline.Value = 2;
flights.RunRetirements();
Assert.Equal(["between"], released);
}
[Fact]
public void Flights_WaitForSubmittedWorkDrainsEverythingPending()
{
var timeline = new FakeTimeline();
var flights = new VulkanFrameFlightController(timeline, framesInFlight: 2);
var released = new List<string>();
flights.BeginFrame();
flights.Retire(() => released.Add("a"));
flights.EndFrame();
flights.Retire(() => released.Add("b"));
flights.WaitForSubmittedWork();
Assert.Equal(["a", "b"], released);
Assert.Equal(0, flights.PendingRetirementCount);
}
[Fact]
public void Flights_OpeningTwoFramesAtOnceIsRejected()
{
var flights = new VulkanFrameFlightController(new FakeTimeline(), framesInFlight: 2);
flights.BeginFrame();
Assert.Throws<InvalidOperationException>(() => flights.BeginFrame());
}
[Fact]
public void Flights_DisposeRunsRemainingReleasesSoNothingLeaks()
{
var flights = new VulkanFrameFlightController(new FakeTimeline(), framesInFlight: 2);
var released = new List<string>();
flights.BeginFrame();
flights.Retire(() => released.Add("pending"));
flights.Dispose();
Assert.Equal(["pending"], released);
}
// ── per-frame ring ───────────────────────────────────────────────────────
[Fact]
public void Ring_AllocatesForwardWithAlignmentAndTracksThePeak()
{
var ring = new VulkanRingBufferState(1024);
Assert.Equal(0ul, ring.Allocate(10, 1));
Assert.Equal(256ul, ring.Allocate(16, 256));
Assert.Equal(272ul, ring.AllocatedBytes);
Assert.Equal(272ul, ring.PeakAllocatedBytes);
ring.Reset();
Assert.Equal(0ul, ring.AllocatedBytes);
// The peak survives the reset: it is the number that says whether the
// per-slot capacity is right.
Assert.Equal(272ul, ring.PeakAllocatedBytes);
}
[Fact]
public void Ring_OverCapacityThrowsRatherThanTruncating()
{
var ring = new VulkanRingBufferState(64);
InvalidOperationException error = Assert.Throws<InvalidOperationException>(() => ring.Allocate(65, 1));
// The message must name the capacity, because the fix is always
// "raise it" and the person reading has no other source for the number.
Assert.Contains("64 bytes", error.Message, StringComparison.Ordinal);
}
[Fact]
public void Ring_ZeroSizedAllocationIsLegalAndMovesNothing()
{
var ring = new VulkanRingBufferState(64);
Assert.Equal(0ul, ring.Allocate(0, 16));
Assert.Equal(0ul, ring.AllocatedBytes);
}
// ── staging ring ─────────────────────────────────────────────────────────
[Fact]
public void Staging_HandsOutBytesUntilTheRingIsFullOfUnretiredFrames()
{
var staging = new VulkanStagingRingState(1024);
Assert.True(staging.TryAllocate(512, 4, serial: 1, out ulong first));
Assert.True(staging.TryAllocate(512, 4, serial: 1, out ulong second));
Assert.Equal(0ul, first);
Assert.Equal(512ul, second);
// Nothing has retired, so there is genuinely nowhere to put this.
Assert.False(staging.TryAllocate(1, 4, serial: 1, out _));
}
[Fact]
public void Staging_ReclaimsSpaceWhenTheFrameThatTookItCompletes()
{
var staging = new VulkanStagingRingState(1024);
Assert.True(staging.TryAllocate(1024, 4, serial: 1, out _));
Assert.False(staging.TryAllocate(16, 4, serial: 2, out _));
staging.Release(completedSerial: 1);
Assert.Equal(0ul, staging.LiveBytes);
Assert.True(staging.TryAllocate(16, 4, serial: 2, out _));
}
[Fact]
public void Staging_ReleasesOnlyFramesTheGpuHasActuallyFinished()
{
var staging = new VulkanStagingRingState(1024);
Assert.True(staging.TryAllocate(256, 4, serial: 1, out _));
Assert.True(staging.TryAllocate(256, 4, serial: 2, out _));
staging.Release(completedSerial: 1);
Assert.Equal(256ul, staging.LiveBytes);
Assert.Equal(1, staging.PendingSegmentCount);
}
[Fact]
public void Staging_WrapsRatherThanRefusingWhenTheTailIsFree()
{
var staging = new VulkanStagingRingState(1024);
Assert.True(staging.TryAllocate(768, 4, serial: 1, out _));
staging.Release(completedSerial: 1);
// 512 does not fit in the 256 bytes left at the end, so it wraps to 0
// and the wasted tail is charged to this segment.
Assert.True(staging.TryAllocate(512, 4, serial: 2, out ulong wrapped));
Assert.Equal(0ul, wrapped);
Assert.Equal(768ul, staging.LiveBytes);
}
[Fact]
public void Staging_RefusesAPayloadLargerThanTheWholeRing()
{
var staging = new VulkanStagingRingState(1024);
// The caller's answer to this is a temporary dedicated staging buffer,
// not a bigger ring — see VulkanUploadQueue.
Assert.False(staging.TryAllocate(2048, 4, serial: 1, out _));
}
[Fact]
public void Staging_MergesConsecutiveRequestsFromTheSameFrame()
{
var staging = new VulkanStagingRingState(1024);
Assert.True(staging.TryAllocate(16, 4, serial: 1, out _));
Assert.True(staging.TryAllocate(16, 4, serial: 1, out _));
Assert.True(staging.TryAllocate(16, 4, serial: 2, out _));
Assert.Equal(2, staging.PendingSegmentCount);
}
}