fix(device): fence-keyed deferred resource-deletion queue (#101)

Since #40 dropped the per-frame vkQueueWaitIdle, frames are pipelined:
a resource the CPU is done with may still be read by the GPU for up to
numFrames-1 more frames. VulkanBuffer::Resize's destroy-and-recreate
path was therefore a live use-after-free (#63), no longer masked by the
wait-idle.

Device gains a monotonic, frame-counter-keyed deletion queue:
EnqueueDeletion tags {buffer, memory} with the current frameCounter;
ReclaimDeletions (called per frame after the fence wait) frees entries
once framesInFlight frames have elapsed; DrainDeletions frees everything
after a wait-idle. VulkanBuffer::DeferredClear hands handles to the queue
and nulls the handle, and Resize uses it instead of immediate Clear().

Window::Render sets framesInFlight at init, reclaims after the per-image
fence wait, bumps Device::frameCounter once per frame, and drains on the
resize / OUT_OF_DATE wait-idle paths. The destructor keeps immediate
Clear() (callers destroying mid-flight remain responsible, unchanged).

Adds the DeferredDeletion test: drives the retire timing on a real
headless device with real buffers, stepping Device::frameCounter to pin
the exact reclaim frame, asserting validation stays silent.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
catbot 2026-06-17 13:21:12 +00:00
commit cb012d7068
6 changed files with 312 additions and 1 deletions

View file

@ -1000,4 +1000,39 @@ bool Device::PreferDirectDeviceWrite(VkDeviceSize size) {
// directWriteBudget caches the size-independent decision (CacheUploadStrategy):
// 0 -> never direct, max -> ReBAR/UMA so any size, else the small-window cap.
return directWriteBudget != 0 && size <= directWriteBudget;
}
void Device::EnqueueDeletion(VkBuffer buffer, VkDeviceMemory memory) {
// Nothing to free for an already-null handle — avoids parking dead
// entries that ReclaimDeletions would have to skip over.
if (buffer == VK_NULL_HANDLE) return;
deletionQueue.push_back({ frameCounter, buffer, memory });
}
void Device::ReclaimDeletions() {
// Free every entry whose retire frame has been reached, compacting the
// survivors down in place. An entry tagged at frame F retires at
// F + framesInFlight: by the time the CPU has begun that many later
// frames (each gated by a per-image fence wait), single-queue submission
// order guarantees all GPU work from frame F is complete.
std::size_t kept = 0;
for (PendingDeletion& entry : deletionQueue) {
if (entry.retireAfter + framesInFlight <= frameCounter) {
vkDestroyBuffer(device, entry.buffer, nullptr);
vkFreeMemory(device, entry.memory, nullptr);
} else {
deletionQueue[kept++] = entry;
}
}
deletionQueue.resize(kept);
}
void Device::DrainDeletions() {
// Unconditional: the caller has just wait-idled, so no in-flight GPU work
// can still reference any queued resource.
for (PendingDeletion& entry : deletionQueue) {
vkDestroyBuffer(device, entry.buffer, nullptr);
vkFreeMemory(device, entry.memory, nullptr);
}
deletionQueue.clear();
}