wgpu pool: halve-on-failure in addSubBuffer extracts +35% VRAM

Many Vulkan drivers cap a single VkDeviceMemory allocation at exactly
maxStorageBufferBindingSize (NVIDIA: 2 GB on consumer GeForce) or
refuse big contiguous allocations once heap is fragmented. The old
addSubBuffer gave up at the first refusal, latching growth_disabled_
— so on a 4 GB GeForce we extracted 2 GB and called it done.

The wgpu-mem-probe tool (feab05650) showed the driver actually grants
~3 GB total across multiple sub-buffers — invariant under allocation
pattern (2+1+small, 3×1 GB, 6×512 MB, 12×256 MB all land at 3 GB).
The cap is the hardware/desktop, not the request size.

addSubBuffer now starts at last_growth_size_ (initially
per_sub_buffer_capacity_, decays as the driver refuses larger sizes)
and halves on failure inside a single call. Stops at a 64 MB floor;
below that the per-sub-buffer bookkeeping cost (free list, bind
groups) isn't worth it. growth_disabled_ now latches only when even
64 MB is refused — a true hardware ceiling, not just "the first
attempt didn't fit."

pool_can_fit gains a next_growth_size_bytes() accessor to stay
honest about how big a future sub-buffer can be after the driver
has refused larger sizes.

Measured (big federation, --streaming, close camera):
  pool capacity:  2048 MB → 2688 MB (2 GB + 512 MB + 128 MB)
  VRAM resident:  2155 MB → 2800 MB (whole scene fits, no eviction)
  avg fps:        42 → 53
  stream time:    2.5 ms → 0.1 ms (no churn — working set is stable)

On larger GPUs (8 / 16 / 24 GB workstations) the same code extracts
proportionally more (e.g. 4 × 2 GB on a 10 GB+ card).

The GL backend's higher "4+ GB resident" claim is overcommit into
host RAM — explicit Vulkan/wgpu memory management deliberately
doesn't paper over that, and the wgpu-mem-probe data confirms it
isn't recoverable.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Dion Moult
2026-05-28 17:31:31 +10:00
parent feab05650d
commit 0ec72482c2
3 changed files with 99 additions and 69 deletions
+64 -55
View File
@@ -37,6 +37,7 @@ void WgpuBufferPool::configure(WGPUInstance instance, WGPUDevice device,
device_ = device;
usage_ = usage;
per_sub_buffer_capacity_ = per_sub_buffer_capacity;
last_growth_size_ = per_sub_buffer_capacity;
label_prefix_ = label_prefix ? label_prefix : "";
}
@@ -49,78 +50,86 @@ void WgpuBufferPool::destroy() {
instance_ = nullptr;
usage_ = 0;
per_sub_buffer_capacity_ = 0;
last_growth_size_ = 0;
growth_disabled_ = false;
label_prefix_.clear();
}
bool WgpuBufferPool::addSubBuffer() {
if (!device_ || per_sub_buffer_capacity_ == 0) return false;
// A previous addSubBuffer at this capacity was refused — don't retry
// every alloc and re-log. The driver's per-allocation cap won't move
// without something freeing first, which only destroy() represents.
if (growth_disabled_) return false;
// wgpu-native classifies "Not enough memory left" as Validation, not
// OutOfMemory — so we push both filters (nested: OOM inner, Validation
// outer). Either firing means the driver refused the allocation.
wgpuDevicePushErrorScope(device_, WGPUErrorFilter_Validation);
wgpuDevicePushErrorScope(device_, WGPUErrorFilter_OutOfMemory);
// 64 MB floor: smaller sub-buffers aren't worth the per-allocation
// bookkeeping cost (one bind group per chunk, free-list overhead).
// If the driver won't grant even 64 MB the pool is genuinely at
// its ceiling; growth_disabled_ latches and future grow attempts
// skip the doomed retry.
constexpr uint64_t MIN_SUB_BUFFER_BYTES = 64ull * 1024 * 1024;
uint64_t try_size = last_growth_size_ > 0
? last_growth_size_
: per_sub_buffer_capacity_;
if (try_size < MIN_SUB_BUFFER_BYTES) try_size = MIN_SUB_BUFFER_BYTES;
char label[128];
std::snprintf(label, sizeof(label), "%s.sub%zu",
label_prefix_.c_str(), sub_pools_.size());
while (try_size >= MIN_SUB_BUFFER_BYTES) {
// wgpu-native classifies "Not enough memory left" as Validation,
// not OutOfMemory. Nested scopes: OOM inner, Validation outer.
wgpuDevicePushErrorScope(device_, WGPUErrorFilter_Validation);
wgpuDevicePushErrorScope(device_, WGPUErrorFilter_OutOfMemory);
WGPUBufferDescriptor desc = {};
desc.usage = usage_;
desc.size = per_sub_buffer_capacity_;
desc.label.data = label;
desc.label.length = std::strlen(label);
WGPUBuffer buf = wgpuDeviceCreateBuffer(device_, &desc);
char label[128];
std::snprintf(label, sizeof(label), "%s.sub%zu",
label_prefix_.c_str(), sub_pools_.size());
struct PopResult { bool done = false; bool error = false; };
auto pop = [&](PopResult& pr) {
WGPUPopErrorScopeCallbackInfo pcb = {};
pcb.mode = WGPUCallbackMode_AllowProcessEvents;
pcb.callback = [](WGPUPopErrorScopeStatus, WGPUErrorType type,
WGPUStringView, void* ud1, void* /*ud2*/) {
auto* p = static_cast<PopResult*>(ud1);
p->done = true;
p->error = (type != WGPUErrorType_NoError);
WGPUBufferDescriptor desc = {};
desc.usage = usage_;
desc.size = try_size;
desc.label.data = label;
desc.label.length = std::strlen(label);
WGPUBuffer buf = wgpuDeviceCreateBuffer(device_, &desc);
struct PopResult { bool done = false; bool error = false; };
auto pop = [&](PopResult& pr) {
WGPUPopErrorScopeCallbackInfo pcb = {};
pcb.mode = WGPUCallbackMode_AllowProcessEvents;
pcb.callback = [](WGPUPopErrorScopeStatus, WGPUErrorType type,
WGPUStringView, void* ud1, void* /*ud2*/) {
auto* p = static_cast<PopResult*>(ud1);
p->done = true;
p->error = (type != WGPUErrorType_NoError);
};
pcb.userdata1 = &pr;
wgpuDevicePopErrorScope(device_, pcb);
while (!pr.done) wgpuInstanceProcessEvents(instance_);
};
pcb.userdata1 = &pr;
wgpuDevicePopErrorScope(device_, pcb);
while (!pr.done) wgpuInstanceProcessEvents(instance_);
};
PopResult oom_pop, validation_pop;
pop(oom_pop);
pop(validation_pop);
PopResult oom_pop, validation_pop;
pop(oom_pop);
pop(validation_pop);
if (!buf || oom_pop.error || validation_pop.error) {
if (buf && !oom_pop.error && !validation_pop.error) {
SubPool sp;
sp.buffer = buf;
sp.capacity = try_size;
sp.used = 0;
sp.free_ranges.push_back({0, try_size});
sub_pools_.push_back(std::move(sp));
last_growth_size_ = try_size;
qInfo().noquote().nospace()
<< "[wgpu pool] added sub-buffer " << (sub_pools_.size() - 1)
<< " (" << (try_size / (1024 * 1024)) << " MB); pool total now "
<< (total_capacity_bytes() / (1024 * 1024)) << " MB";
return true;
}
if (buf) wgpuBufferRelease(buf);
// Log once — set growth_disabled_ so subsequent allocs don't
// re-try at this size. The pool runs at its hardware-limited
// ceiling from here; eviction handles the rest.
qInfo().noquote().nospace()
<< "[wgpu pool] driver refused sub-buffer " << sub_pools_.size()
<< " at " << (per_sub_buffer_capacity_ / (1024 * 1024))
<< " MB; pool capped at " << (total_capacity_bytes() / (1024 * 1024))
<< " MB across " << sub_pools_.size() << " sub-buffer(s) — growth disabled";
growth_disabled_ = true;
return false;
try_size /= 2;
}
SubPool sp;
sp.buffer = buf;
sp.capacity = per_sub_buffer_capacity_;
sp.used = 0;
sp.free_ranges.push_back({0, per_sub_buffer_capacity_});
sub_pools_.push_back(std::move(sp));
qInfo().noquote().nospace()
<< "[wgpu pool] added sub-buffer " << (sub_pools_.size() - 1)
<< " (" << (per_sub_buffer_capacity_ / (1024 * 1024)) << " MB); pool total now "
<< (total_capacity_bytes() / (1024 * 1024)) << " MB";
return true;
<< "[wgpu pool] driver refused growth even at "
<< (MIN_SUB_BUFFER_BYTES / (1024 * 1024)) << " MB; pool capped at "
<< (total_capacity_bytes() / (1024 * 1024))
<< " MB across " << sub_pools_.size() << " sub-buffer(s) — growth disabled";
growth_disabled_ = true;
return false;
}
WgpuBufferPool::Slice WgpuBufferPool::alloc(uint64_t size, uint64_t align) {