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:
parent
c3d7f52891
commit
cb012d7068
6 changed files with 312 additions and 1 deletions
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -452,6 +452,11 @@ Window::Window(std::uint32_t width, std::uint32_t height) : width(width), height
|
|||
Device::CheckVkResult(vkCreateSemaphore(Device::device, &semaphoreCreateInfo, nullptr, &imageAcquiredSemaphores[i]));
|
||||
}
|
||||
|
||||
// The deferred-deletion queue (issue #101) retires a resource after this
|
||||
// many recorded frames — exactly the depth the per-frame fences (#40)
|
||||
// allow in flight. Set it here, where numFrames is in scope.
|
||||
Device::framesInFlight = numFrames;
|
||||
|
||||
// Per-frame info structs: everything that never varies between frames is
|
||||
// set here once. Render() patches the barriers' image/oldLayout and the
|
||||
// sync handles that follow currentBuffer/frameCounter (submitInfo's
|
||||
|
|
@ -542,6 +547,9 @@ void Window::Resize(std::uint32_t newWidth, std::uint32_t newHeight) {
|
|||
// Caller (configure handler / WM_SIZE) runs between frames, but be
|
||||
// defensive: ensure no in-flight commands reference the old swapchain.
|
||||
Device::CheckVkResult(vkQueueWaitIdle(Device::queue));
|
||||
// The queue is idle, so every deferred resource is now safe to free
|
||||
// regardless of its retire frame (issue #101).
|
||||
Device::DrainDeletions();
|
||||
|
||||
#ifdef CRAFTER_GRAPHICS_WINDOW_WAYLAND
|
||||
if (wpViewport) {
|
||||
|
|
@ -768,6 +776,7 @@ void Window::Render() {
|
|||
imageAcquired, (VkFence)nullptr, ¤tBuffer);
|
||||
if (acquire == VK_ERROR_OUT_OF_DATE_KHR) {
|
||||
Device::CheckVkResult(vkQueueWaitIdle(Device::queue));
|
||||
Device::DrainDeletions();
|
||||
RecreateSwapchainAndImages();
|
||||
onResize.Invoke();
|
||||
acquire = vkAcquireNextImageKHR(Device::device, swapChain, UINT64_MAX,
|
||||
|
|
@ -786,6 +795,12 @@ void Window::Render() {
|
|||
Device::CheckVkResult(vkWaitForFences(Device::device, 1, &waitFences[currentBuffer], VK_TRUE, UINT64_MAX));
|
||||
Device::CheckVkResult(vkResetFences(Device::device, 1, &waitFences[currentBuffer]));
|
||||
|
||||
// Now that a frame's fence has been waited, free any deferred resources
|
||||
// whose retire frame has been reached (issue #101). frameCounter is bumped
|
||||
// at end of frame, so it still holds this frame's index here — exactly the
|
||||
// value entries enqueued this frame are tagged with.
|
||||
Device::ReclaimDeletions();
|
||||
|
||||
Device::CheckVkResult(vkBeginCommandBuffer(drawCmdBuffers[currentBuffer], &cmdBufInfo));
|
||||
|
||||
// On an image's first use after (re)creating the swapchain it is still in
|
||||
|
|
@ -902,6 +917,7 @@ void Window::Render() {
|
|||
// frame. The wait-idle here also re-settles every per-frame fence to a
|
||||
// signaled state, so the next Render()'s fence wait passes through.
|
||||
Device::CheckVkResult(vkQueueWaitIdle(Device::queue));
|
||||
Device::DrainDeletions();
|
||||
RecreateSwapchainAndImages();
|
||||
onResize.Invoke();
|
||||
} else {
|
||||
|
|
@ -911,6 +927,10 @@ void Window::Render() {
|
|||
// command-buffer reuse, so the CPU is free to acquire/record frame N+1
|
||||
// while the GPU is still executing frame N.
|
||||
frameCounter++;
|
||||
// Advance the global deferred-deletion clock too (issue #101). Kept
|
||||
// separate from the per-window frameCounter above (which also keys the
|
||||
// acquire-semaphore slot) so non-window code can enqueue against it.
|
||||
Device::frameCounter++;
|
||||
}
|
||||
|
||||
#ifdef CRAFTER_TIMING
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue