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
|
|
@ -294,6 +294,44 @@ export namespace Crafter {
|
|||
inline static VkDeviceSize directWriteBudget = 0;
|
||||
static void CacheUploadStrategy();
|
||||
|
||||
// ─── Fence-keyed deferred resource deletion (issue #101) ────────
|
||||
// Since #40 dropped the per-frame vkQueueWaitIdle, frames are
|
||||
// pipelined: a buffer the CPU is "done" with may still be read by the
|
||||
// GPU for up to framesInFlight-1 more frames. Destroying it
|
||||
// immediately (as Clear() does) is then a use-after-free. Callers that
|
||||
// free a buffer which may still be in flight — VulkanBuffer::Resize's
|
||||
// reallocate path, VulkanBuffer::DeferredClear — hand the handles here
|
||||
// instead, tagged with the current frameCounter; ReclaimDeletions frees
|
||||
// an entry only once framesInFlight frames have elapsed, by which point
|
||||
// single-queue submission order guarantees all GPU work that could
|
||||
// reference it has completed.
|
||||
struct PendingDeletion {
|
||||
std::uint64_t retireAfter; // frameCounter value at enqueue time
|
||||
VkBuffer buffer;
|
||||
VkDeviceMemory memory;
|
||||
};
|
||||
// Monotonic recorded-frame index, bumped once per Window::Render. Not
|
||||
// the same as Window::frameCounter (which is per-window and also drives
|
||||
// the acquire-semaphore slot) — this one is global so Mesh/Device code
|
||||
// can enqueue too, not just the window loop.
|
||||
inline static std::uint64_t frameCounter = 0;
|
||||
// = Window::numFrames; set at window init. The number of frames that
|
||||
// must elapse after an enqueue before the resource is safe to free.
|
||||
// Zero until set, which only makes ReclaimDeletions more eager — and it
|
||||
// is never called before a Window (which sets this) starts rendering.
|
||||
inline static std::uint8_t framesInFlight = 0;
|
||||
inline static std::vector<PendingDeletion> deletionQueue;
|
||||
// Tag {buffer, memory} for deletion after framesInFlight more frames.
|
||||
// A VK_NULL_HANDLE buffer is ignored (nothing to free).
|
||||
static void EnqueueDeletion(VkBuffer buffer, VkDeviceMemory memory);
|
||||
// Free every entry whose retire frame has been reached
|
||||
// (retireAfter + framesInFlight <= frameCounter). Call once per frame
|
||||
// after waiting that frame's fence.
|
||||
static void ReclaimDeletions();
|
||||
// Free every queued entry unconditionally. Call only after a wait-idle
|
||||
// (resize / teardown), when no in-flight GPU work can reference them.
|
||||
static void DrainDeletions();
|
||||
|
||||
// ─── Wayland key repeat ────────────────────────────────────────
|
||||
// TickKeyRepeats fires onRawKeyDown / onRawKeyHold / onTextInput on
|
||||
// the focused window for whichever key is currently repeating.
|
||||
|
|
|
|||
|
|
@ -155,6 +155,18 @@ namespace Crafter {
|
|||
buffer = VK_NULL_HANDLE;
|
||||
}
|
||||
|
||||
// Like Clear(), but hands the destroy+free to Device's fence-keyed
|
||||
// deletion queue instead of doing it immediately. Use when the buffer
|
||||
// may still be read by an in-flight frame's GPU work — destroying it
|
||||
// now would be a use-after-free, since frames are pipelined up to
|
||||
// framesInFlight deep (issue #101). The handle is nulled immediately so
|
||||
// this VulkanBuffer no longer owns it; vkFreeMemory (deferred) implicitly
|
||||
// unmaps mapped memory, so no explicit vkUnmapMemory is needed here.
|
||||
void DeferredClear() {
|
||||
Device::EnqueueDeletion(buffer, memory);
|
||||
buffer = VK_NULL_HANDLE;
|
||||
}
|
||||
|
||||
void Resize(VkBufferUsageFlags2 usageFlags, VkMemoryPropertyFlags memoryPropertyFlags, std::uint32_t count, VkMemoryPropertyFlags preferredPropertyFlags = 0) {
|
||||
// Reuse the existing allocation in place when the request still fits
|
||||
// and the fixed-at-create properties match: usage flags are
|
||||
|
|
@ -172,8 +184,12 @@ namespace Crafter {
|
|||
size = requestedSize;
|
||||
return;
|
||||
}
|
||||
// Defer the old allocation's destruction: an in-flight frame may
|
||||
// still reference it (the #63 hazard, no longer masked by a
|
||||
// per-frame wait-idle since #40). DeferredClear nulls the handle,
|
||||
// so the Create below starts from a clean slate.
|
||||
if(buffer != VK_NULL_HANDLE) {
|
||||
Clear();
|
||||
DeferredClear();
|
||||
}
|
||||
Create(usageFlags, memoryPropertyFlags, count, preferredPropertyFlags);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue