perf(buffer): reuse staging via a per-frame-in-flight ring in UploadDeviceLocal (#120)
The staged branch of VulkanBuffer::UploadDeviceLocal did a full Create (create+getreq+alloc+map) + DeferredClear (later free) per call. Mesh::Refit / RecordProceduralBuild hit this every frame for deforming meshes on no-/small-BAR hardware — a device-memory alloc/free cycle per mesh per frame, on exactly the hardware the staged path targets. Replace it with a persistent per-buffer staging ring sized to a high-water mark, reallocated (via Resize, which defers the outgrown allocation) only on growth — mirroring the TLAS instance/metadata reuse. The ring is a per-frame-in-flight ring, not a single shared buffer: the vkCmdCopyBuffer still reads staging after the call returns, and with frames pipelined framesInFlight deep, overwriting one shared buffer next frame would clobber data the previous frame's copy is still reading. Indexing by frameCounter % framesInFlight gives each in-flight frame its own slot, reused only after framesInFlight frames elapse — the same window the #101 deletion queue relies on for GPU completion. The ring is grown lazily, on first entry into the staged branch, so ReBAR/UMA hardware (which always takes the direct-write branch) never constructs a staging allocation it does not use — keeping this a runtime no-op there and a straight upgrade on non-resizable-BAR machines. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
faf1e5f5e8
commit
85ca08047d
1 changed files with 47 additions and 9 deletions
|
|
@ -280,21 +280,47 @@ namespace Crafter {
|
||||||
srcStageMask = VK_PIPELINE_STAGE_HOST_BIT;
|
srcStageMask = VK_PIPELINE_STAGE_HOST_BIT;
|
||||||
} else {
|
} else {
|
||||||
Resize(usageFlags | VK_BUFFER_USAGE_TRANSFER_DST_BIT, VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT, count);
|
Resize(usageFlags | VK_BUFFER_USAGE_TRANSFER_DST_BIT, VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT, count);
|
||||||
VulkanBuffer<T, true> staging;
|
// Persistent staging instead of a Create+DeferredClear per call
|
||||||
|
// (issue #120): on no-/small-BAR hardware Mesh::Refit /
|
||||||
|
// RecordProceduralBuild hit this branch every frame for deforming
|
||||||
|
// meshes, so a fresh alloc+map+free cycle per upload was pure
|
||||||
|
// churn on exactly the hardware this path targets. Keep a small
|
||||||
|
// ring of staging buffers, each grown to a high-water mark and
|
||||||
|
// reused — reallocated (by Resize, which defers the outgrown
|
||||||
|
// allocation) only when a larger upload arrives.
|
||||||
|
//
|
||||||
|
// The ring is grown lazily, on first entry into this staged
|
||||||
|
// branch, so ReBAR/UMA hardware (which always takes the direct
|
||||||
|
// `if` branch above) never constructs a staging allocation it
|
||||||
|
// does not use.
|
||||||
|
//
|
||||||
|
// Why a ring and not one shared buffer: the vkCmdCopyBuffer below
|
||||||
|
// still reads the staging buffer after this call returns — the
|
||||||
|
// reason the old code used DeferredClear. With frames pipelined
|
||||||
|
// framesInFlight deep, overwriting one shared staging buffer next
|
||||||
|
// frame would clobber data the previous frame's copy is still
|
||||||
|
// reading. Indexing by frameCounter % framesInFlight gives each
|
||||||
|
// in-flight frame its own slot; a slot is rewritten only after
|
||||||
|
// framesInFlight frames have elapsed — the exact window after
|
||||||
|
// which single-queue submission order (the same guarantee the
|
||||||
|
// #101 deletion queue relies on) ensures that copy has completed.
|
||||||
|
const std::uint32_t ringSize =
|
||||||
|
Device::framesInFlight ? Device::framesInFlight : 1;
|
||||||
|
if (stagingRing.size() < ringSize) {
|
||||||
|
stagingRing.resize(ringSize);
|
||||||
|
}
|
||||||
|
VulkanBuffer<T, true>& staging =
|
||||||
|
stagingRing[Device::frameCounter % ringSize];
|
||||||
// SHADER_DEVICE_ADDRESS: Create always queries the buffer device
|
// SHADER_DEVICE_ADDRESS: Create always queries the buffer device
|
||||||
// address (and allocates with the device-address bit); the
|
// address (and allocates with the device-address bit); the
|
||||||
// staging buffer's own address is otherwise unused.
|
// staging buffer's own address is otherwise unused. Resize reuses
|
||||||
staging.Create(VK_BUFFER_USAGE_TRANSFER_SRC_BIT | VK_BUFFER_USAGE_SHADER_DEVICE_ADDRESS_BIT, VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT, count);
|
// the existing allocation (and its mapped pointer) when the
|
||||||
|
// upload still fits the slot's high-water capacity.
|
||||||
|
staging.Resize(VK_BUFFER_USAGE_TRANSFER_SRC_BIT | VK_BUFFER_USAGE_SHADER_DEVICE_ADDRESS_BIT, VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT, count);
|
||||||
std::memcpy(staging.value, src, bytes);
|
std::memcpy(staging.value, src, bytes);
|
||||||
staging.FlushDevice();
|
staging.FlushDevice();
|
||||||
VkBufferCopy region { .srcOffset = 0, .dstOffset = 0, .size = bytes };
|
VkBufferCopy region { .srcOffset = 0, .dstOffset = 0, .size = bytes };
|
||||||
vkCmdCopyBuffer(cmd, staging.buffer, buffer, 1, ®ion);
|
vkCmdCopyBuffer(cmd, staging.buffer, buffer, 1, ®ion);
|
||||||
// The queued copy still reads the staging buffer after this call
|
|
||||||
// returns, so a plain Clear() would be a use-after-free; hand it
|
|
||||||
// to the fence-keyed queue (#101/#102), which frees it once the
|
|
||||||
// copy's frame clears its fence. DeferredClear nulls the handle,
|
|
||||||
// so `staging`'s destructor here is a no-op.
|
|
||||||
staging.DeferredClear();
|
|
||||||
srcAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT;
|
srcAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT;
|
||||||
srcStageMask = VK_PIPELINE_STAGE_TRANSFER_BIT;
|
srcStageMask = VK_PIPELINE_STAGE_TRANSFER_BIT;
|
||||||
}
|
}
|
||||||
|
|
@ -387,6 +413,17 @@ namespace Crafter {
|
||||||
vkInvalidateMappedMemoryRanges(Device::device, 1, &range);
|
vkInvalidateMappedMemoryRanges(Device::device, 1, &range);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Persistent per-frame-in-flight staging ring for the staged
|
||||||
|
// UploadDeviceLocal path (issue #120). Empty — and so holding zero
|
||||||
|
// host-visible allocations — until the first upload that actually
|
||||||
|
// stages, which only happens on no-/small-BAR hardware (ReBAR/UMA always
|
||||||
|
// takes the direct-write branch and never touches this, so the lazy ring
|
||||||
|
// keeps the change a no-op there). Sized to Device::framesInFlight so
|
||||||
|
// each in-flight frame copies from its own slot; each slot grows to a
|
||||||
|
// high-water mark via Resize and is reused across frames rather than
|
||||||
|
// reallocated every call — mirroring the TLAS instance/metadata reuse.
|
||||||
|
std::vector<VulkanBuffer<T, true>> stagingRing;
|
||||||
|
|
||||||
VulkanBuffer(VulkanBuffer&& other) {
|
VulkanBuffer(VulkanBuffer&& other) {
|
||||||
buffer = other.buffer;
|
buffer = other.buffer;
|
||||||
memory = other.memory;
|
memory = other.memory;
|
||||||
|
|
@ -397,6 +434,7 @@ namespace Crafter {
|
||||||
memoryPropertyFlagsChosen = other.memoryPropertyFlagsChosen;
|
memoryPropertyFlagsChosen = other.memoryPropertyFlagsChosen;
|
||||||
other.buffer = VK_NULL_HANDLE;
|
other.buffer = VK_NULL_HANDLE;
|
||||||
address = other.address;
|
address = other.address;
|
||||||
|
stagingRing = std::move(other.stagingRing);
|
||||||
if constexpr(Mapped) {
|
if constexpr(Mapped) {
|
||||||
VulkanBufferMappedConditional<T, true>::value = other.VulkanBufferMappedConditional<T, true>::value;
|
VulkanBufferMappedConditional<T, true>::value = other.VulkanBufferMappedConditional<T, true>::value;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue