mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-10 09:48:32 +00:00
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:
@@ -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 = ≺
|
||||
wgpuDevicePopErrorScope(device_, pcb);
|
||||
while (!pr.done) wgpuInstanceProcessEvents(instance_);
|
||||
};
|
||||
pcb.userdata1 = ≺
|
||||
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) {
|
||||
|
||||
@@ -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_;
|
||||
};
|
||||
|
||||
@@ -3588,11 +3588,11 @@ void WgpuViewportWindow::driveStreamingLoads() {
|
||||
// avoids wasted evict-then-fail loops.
|
||||
auto pool_can_fit = [&](uint64_t bytes) -> bool {
|
||||
if (pool_.largest_free_run_bytes() >= bytes) return true;
|
||||
// Growth might still rescue us — but only if growth hasn't been
|
||||
// refused at this size already. After a refusal, eviction is the
|
||||
// sole path; the eviction loop must run until largest_free_run
|
||||
// catches up.
|
||||
if (pool_.can_grow() && pool_.per_sub_buffer_capacity_bytes() >= bytes) return true;
|
||||
// Growth might still rescue us. Use next_growth_size_bytes()
|
||||
// rather than per_sub_buffer_capacity_bytes() — after a refusal
|
||||
// at e.g. 2 GB, halve-on-failure pushes the next achievable
|
||||
// sub-buffer down to 1 GB; saying "fits if ≤2 GB" would lie.
|
||||
if (pool_.can_grow() && pool_.next_growth_size_bytes() >= bytes) return true;
|
||||
return false;
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user