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
+30 -9
View File
@@ -97,10 +97,18 @@ public:
// Per-sub-buffer count, for diagnostics / logging.
size_t sub_buffer_count() const { return sub_pools_.size(); }
uint64_t per_sub_buffer_capacity_bytes() const { return per_sub_buffer_capacity_; }
// Best estimate of the size a *future* sub-buffer would land at:
// last_growth_size_ if we've ever grown (or just been configured),
// else the configured per_sub_buffer_capacity. After the driver
// refuses a size, halve-on-failure in addSubBuffer pushes this down
// so callers' "can this chunk fit via growth?" check stays honest.
uint64_t next_growth_size_bytes() const {
return last_growth_size_ > 0 ? last_growth_size_ : per_sub_buffer_capacity_;
}
// Whether the pool can still attempt to add a sub-buffer. Flips to
// false the first time addSubBuffer is refused — eviction callers
// need this to know whether a future alloc could rescue them by
// growing, or whether eviction is the only path.
// false the first time addSubBuffer is refused even at the floor
// size — eviction callers need this to know whether a future alloc
// could rescue them, or whether eviction is the only path.
bool can_grow() const { return !growth_disabled_ && per_sub_buffer_capacity_ > 0; }
private:
@@ -112,12 +120,18 @@ private:
std::vector<FreeRange> free_ranges;
};
// Append a new sub-buffer at per_sub_buffer_capacity_, wrapped in an
// OOM/Validation error scope so a failed allocation doesn't take the
// device down. Returns false on driver OOM (caller should treat as
// "pool is at its hardware-limited maximum"). After a failure, sets
// growth_disabled_ so subsequent allocs don't keep retrying (and
// log-spamming) at the same size that just refused.
// Append a new sub-buffer to the pool. Starts at last_growth_size_
// (initially per_sub_buffer_capacity_) and halves on driver refusal
// before giving up — many Vulkan drivers cap single VkDeviceMemory
// allocations at a couple GB (e.g. NVIDIA: maxStorageBufferBindingSize
// is exactly 2 GB on consumer GeForce cards) or refuse big contiguous
// allocations once heap is fragmented, but happily grant smaller ones.
// Halving turns "stop at first refused 2 GB" into "2 GB + 1 GB + …",
// which on a 4 GB card lets us reach 3 GB total instead of 2 GB.
// Wrapped in OOM/Validation error scopes so failed attempts don't
// take the device down. Returns true on success at some size
// ≥ MIN_SUB_BUFFER_BYTES; false only when even the minimum size is
// refused, at which point growth_disabled_ latches.
bool addSubBuffer();
std::vector<SubPool> sub_pools_;
@@ -126,6 +140,13 @@ private:
WGPUDevice device_ = nullptr;
WGPUBufferUsage usage_ = 0;
uint64_t per_sub_buffer_capacity_ = 0;
// The largest size addSubBuffer last *succeeded* at, in bytes.
// Starts at per_sub_buffer_capacity_ (the probe's discovered max)
// and decays as the driver refuses larger allocations. Future grow
// attempts start from here rather than re-trying the max every
// time — once the driver has refused 2 GB, retrying 2 GB on every
// subsequent grow is wasted work.
uint64_t last_growth_size_ = 0;
bool growth_disabled_ = false;
std::string label_prefix_;
};