From 502c29fbc2806c7d79f2b831b552c7742e3f77dd Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Thu, 28 May 2026 12:21:52 +1000 Subject: [PATCH] wgpu: probed-size pool replaces per-chunk createBuffer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drops the per-machine "guess the OOM ceiling" budget knob in favour of a single buffer pool whose capacity is *probed* at device-init time. The runtime answers the question: descend from min(maxBufferSize, 4 GB) through OOM error scopes, accept the largest size that allocates cleanly. On a desktop wgpu-native v29 box this lands at 2 GB; on browser-class platforms it'll land at 256 MB – 1 GB depending on the implementation. Same code path either way. Architecture: - WgpuBufferPool (new): single WGPUBuffer + free-list sub-allocator with adjacent-range coalescing and first-fit. 256 B alignment for storage-binding offsets. - Chunks now hold (pool_vertex_offset, pool_vertex_size) and (pool_index_offset, pool_index_size) instead of per-chunk WGPUBuffer handles. Load = pool.alloc + queueWriteBuffer. Unload = pool.free. - Bind groups bind pool_.buffer() at the chunk's specific (offset, size) for both the vertex and index storage bindings. - Eviction queries pool.largest_free_run_bytes() instead of a tracked budget; the two-phase LRU/distance evictor's policy is unchanged. What this fixes: - No more gpu-alloc-rs fragmentation OOM: one VkDeviceMemory block instead of N per-chunk blocks with rounding overhead. On the test dataset (~3 GB on disk, 562 k visible instances) the wgpu backend now runs through to render without OOM at any point. - No --streaming-vram-mb knob, no hardcoded budget constant, no per-machine calibration. The pool size adapts to whatever the runtime grants. Notes: - Error scope probing: wgpu-native v29 classifies "Not enough memory left" as WGPUErrorType_Validation, not OutOfMemory. We push both filters (nested) and treat either firing as probe failure. - The 4 GB probe cap is principled, not magic: above that, wgpu-native's advertised maxBufferSize is sometimes a sentinel (1 TB) that just forces wasteful halving steps. 4 GB is the largest buffer any realistic WebGPU implementation will grant a single allocation today. - Pool destroy()/release happens after model release in shutdown() so the underlying buffer outlives every bind group that references it. Follow-ups: spatial chunking (task #22) for finer eviction granularity; cull perf needs work at 100+ models / 1M+ instances (separate from streaming concerns). Co-Authored-By: Claude Opus 4.7 --- src/ifcviewer-wgpu-minimal/main.cpp | 14 - src/ifcviewer-wgpu/WgpuBufferPool.cpp | 130 +++++++++ src/ifcviewer-wgpu/WgpuBufferPool.h | 86 ++++++ src/ifcviewer-wgpu/WgpuModelGpuData.h | 44 +-- src/ifcviewer-wgpu/WgpuViewportWindow.cpp | 313 ++++++++++++++++------ src/ifcviewer-wgpu/WgpuViewportWindow.h | 64 +++-- 6 files changed, 503 insertions(+), 148 deletions(-) create mode 100644 src/ifcviewer-wgpu/WgpuBufferPool.cpp create mode 100644 src/ifcviewer-wgpu/WgpuBufferPool.h diff --git a/src/ifcviewer-wgpu-minimal/main.cpp b/src/ifcviewer-wgpu-minimal/main.cpp index 324be20e61..3601611f3c 100644 --- a/src/ifcviewer-wgpu-minimal/main.cpp +++ b/src/ifcviewer-wgpu-minimal/main.cpp @@ -62,10 +62,6 @@ int main(int argc, char* argv[]) { "Enable streaming sidecar load. Reads metadata-only at load time; " "vertex chunks are deferred and loaded on demand as they become " "frustum-visible. Required for scenes that exceed GPU memory."}); - parser.addOption({"streaming-vram-mb", - "Set the streaming residency budget in MB (default 1900). Tune " - "down to test browser-class memory ceilings; tune up if your GPU " - "+ driver are happy with larger allocations.", "mb"}); parser.process(app); auto* viewport = new WgpuViewportWindow; @@ -74,16 +70,6 @@ int main(int argc, char* argv[]) { if (parser.isSet("web-limits")) viewport->web_limits_ = true; if (parser.isSet("bvh")) viewport->bvh_enabled_ = true; if (parser.isSet("streaming")) viewport->streaming_enabled_ = true; - if (parser.isSet("streaming-vram-mb")) { - bool ok = false; - const uint64_t mb = parser.value("streaming-vram-mb").toULongLong(&ok); - if (ok && mb > 0) { - viewport->streaming_vram_budget_bytes_ = mb * 1024ull * 1024ull; - } else { - qWarning() << "--streaming-vram-mb: failed to parse" - << parser.value("streaming-vram-mb"); - } - } QWidget* container = QWidget::createWindowContainer(viewport); container->setMinimumSize(320, 240); diff --git a/src/ifcviewer-wgpu/WgpuBufferPool.cpp b/src/ifcviewer-wgpu/WgpuBufferPool.cpp new file mode 100644 index 0000000000..72d1cb5662 --- /dev/null +++ b/src/ifcviewer-wgpu/WgpuBufferPool.cpp @@ -0,0 +1,130 @@ +/******************************************************************************** + * * + * This file is part of IfcOpenShell. * + * * + * IfcOpenShell is free software: you can redistribute it and/or modify * + * it under the terms of the Lesser GNU General Public License as published by * + * the Free Software Foundation, either version 3.0 of the License, or * + * (at your option) any later version. * + * * + * IfcOpenShell is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * Lesser GNU General Public License for more details. * + * * + * You should have received a copy of the Lesser GNU General Public License * + * along with this program. If not, see . * + * * + ********************************************************************************/ + +#include "WgpuBufferPool.h" + +#include +#include + +WgpuBufferPool::~WgpuBufferPool() { + destroy(); +} + +bool WgpuBufferPool::init(WGPUDevice device, uint64_t capacity_bytes, + WGPUBufferUsage usage, const char* label) { + destroy(); + if (capacity_bytes == 0) return false; + + WGPUBufferDescriptor desc = {}; + desc.usage = usage; + desc.size = capacity_bytes; + if (label) { + desc.label.data = label; + desc.label.length = std::strlen(label); + } + buffer_ = wgpuDeviceCreateBuffer(device, &desc); + if (!buffer_) return false; + + capacity_ = capacity_bytes; + used_ = 0; + free_ranges_.clear(); + free_ranges_.push_back({0, capacity_bytes}); + return true; +} + +void WgpuBufferPool::destroy() { + if (buffer_) { + wgpuBufferRelease(buffer_); + buffer_ = nullptr; + } + capacity_ = 0; + used_ = 0; + free_ranges_.clear(); +} + +bool WgpuBufferPool::alloc(uint64_t size, uint64_t align, uint64_t* out_offset) { + if (size == 0 || align == 0) return false; + // First-fit: scan free ranges, pick the first that fits with alignment. + for (size_t i = 0; i < free_ranges_.size(); ++i) { + const FreeRange& r = free_ranges_[i]; + const uint64_t aligned = (r.offset + (align - 1)) & ~(align - 1); + const uint64_t pad = aligned - r.offset; + if (pad >= r.size) continue; // alignment alone won't fit + if (size > r.size - pad) continue; // payload won't fit + + // Split the range. Three resulting pieces: + // [r.offset, aligned) -> pre-pad, returned to free list + // [aligned, aligned + size) -> the allocation (claimed) + // [aligned + size, r.offset + r.size) -> post-pad, returned to free list + const uint64_t post_off = aligned + size; + const uint64_t post_size = (r.offset + r.size) - post_off; + + // Mutate in place: replace the matched range with the pre-pad + // (or erase it if there's no pre-pad), then optionally insert + // the post-pad immediately after. + if (pad == 0 && post_size == 0) { + free_ranges_.erase(free_ranges_.begin() + i); + } else if (pad == 0) { + free_ranges_[i] = {post_off, post_size}; + } else if (post_size == 0) { + free_ranges_[i] = {r.offset, pad}; + } else { + free_ranges_[i] = {r.offset, pad}; + free_ranges_.insert(free_ranges_.begin() + i + 1, {post_off, post_size}); + } + + used_ += size; // pre-/post-pad remain in free_ranges_, not used_ + *out_offset = aligned; + return true; + } + return false; +} + +void WgpuBufferPool::free(uint64_t offset, uint64_t size) { + if (size == 0) return; + assert(offset + size <= capacity_); + + // Find insertion point: first range whose offset > released offset. + size_t i = 0; + while (i < free_ranges_.size() && free_ranges_[i].offset < offset) ++i; + free_ranges_.insert(free_ranges_.begin() + i, {offset, size}); + used_ -= size; + + // Coalesce with right neighbour first (so subsequent left-coalesce + // sees the merged range). + if (i + 1 < free_ranges_.size() + && free_ranges_[i].offset + free_ranges_[i].size == free_ranges_[i + 1].offset) { + free_ranges_[i].size += free_ranges_[i + 1].size; + free_ranges_.erase(free_ranges_.begin() + i + 1); + } + // Coalesce with left neighbour. + if (i > 0 + && free_ranges_[i - 1].offset + free_ranges_[i - 1].size == free_ranges_[i].offset) { + free_ranges_[i - 1].size += free_ranges_[i].size; + free_ranges_.erase(free_ranges_.begin() + i); + } +} + +uint64_t WgpuBufferPool::largest_free_run_bytes() const { + uint64_t m = 0; + for (const auto& r : free_ranges_) { + if (r.size > m) m = r.size; + } + return m; +} diff --git a/src/ifcviewer-wgpu/WgpuBufferPool.h b/src/ifcviewer-wgpu/WgpuBufferPool.h new file mode 100644 index 0000000000..1b4499436b --- /dev/null +++ b/src/ifcviewer-wgpu/WgpuBufferPool.h @@ -0,0 +1,86 @@ +/******************************************************************************** + * * + * This file is part of IfcOpenShell. * + * * + * IfcOpenShell is free software: you can redistribute it and/or modify * + * it under the terms of the Lesser GNU General Public License as published by * + * the Free Software Foundation, either version 3.0 of the License, or * + * (at your option) any later version. * + * * + * IfcOpenShell is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * Lesser GNU General Public License for more details. * + * * + * You should have received a copy of the Lesser GNU General Public License * + * along with this program. If not, see . * + * * + ********************************************************************************/ + +#ifndef WGPUBUFFERPOOL_H +#define WGPUBUFFERPOOL_H + +#include + +#include +#include + +// Single-buffer sub-allocator. Owns one WGPUBuffer of fixed capacity and +// hands out byte ranges within it. Replaces the per-chunk +// wgpuDeviceCreateBuffer/Release pattern, which on wgpu-native triggers +// gpu-alloc-rs fragmentation (one VkDeviceMemory per buffer with +// rounding overhead) and OOMs the device well below physical VRAM. +// +// Lifetime model: alloc/free are immediate. WebGPU guarantees that +// queue.writeBuffer to a just-freed range is correctly serialised against +// any prior submitted GPU reads — we never need to fence frees ourselves. +// +// Allocator: sorted free list with adjacent-range coalescing, first-fit. +// Adequate for the chunk workload (a few hundred allocations of broadly +// similar size); revisit if a workload demonstrates worst-case behaviour. +class WgpuBufferPool { +public: + WgpuBufferPool() = default; + ~WgpuBufferPool(); + + WgpuBufferPool(const WgpuBufferPool&) = delete; + WgpuBufferPool& operator=(const WgpuBufferPool&) = delete; + + // Allocate the underlying buffer at the given capacity. `usage` must + // include CopyDst (alloc'd ranges are populated via queueWriteBuffer). + // Returns false if creation failed (caller can retry at smaller size). + bool init(WGPUDevice device, uint64_t capacity_bytes, + WGPUBufferUsage usage, const char* label); + void destroy(); + + // Sub-allocate a range of `size` bytes, aligned to `align` (must be + // a power of two; typical: 256 for storage-buffer binding offsets). + // On success returns true and writes the byte offset to *out_offset. + // On failure (no free range fits) returns false; *out_offset is + // unchanged. + bool alloc(uint64_t size, uint64_t align, uint64_t* out_offset); + // Free a previously-allocated range. (offset, size) must exactly + // match a prior alloc(); freeing a partial range is unsupported. + void free(uint64_t offset, uint64_t size); + + WGPUBuffer buffer() const { return buffer_; } + uint64_t capacity_bytes() const { return capacity_; } + uint64_t used_bytes() const { return used_; } + uint64_t free_bytes() const { return capacity_ - used_; } + // Largest contiguous free run. Useful for evictor heuristics ("can + // this allocation even fit, ever, without eviction?"). + uint64_t largest_free_run_bytes() const; + +private: + struct FreeRange { uint64_t offset; uint64_t size; }; + + // Sorted by offset, non-overlapping, non-adjacent (coalesced on + // every free). Empty when the pool is fully allocated. + std::vector free_ranges_; + + WGPUBuffer buffer_ = nullptr; + uint64_t capacity_ = 0; + uint64_t used_ = 0; +}; + +#endif // WGPUBUFFERPOOL_H diff --git a/src/ifcviewer-wgpu/WgpuModelGpuData.h b/src/ifcviewer-wgpu/WgpuModelGpuData.h index 124fda14c9..bebd57d435 100644 --- a/src/ifcviewer-wgpu/WgpuModelGpuData.h +++ b/src/ifcviewer-wgpu/WgpuModelGpuData.h @@ -63,24 +63,31 @@ struct WgpuModelGpuData { }; static_assert(sizeof(VisibleDrawGpu) == 16, "VisibleDrawGpu must be 16 bytes"); - // Per-chunk state. Each chunk owns its vertex_storage (sized ≤ 128 MB) - // plus a small set of per-frame buffers (visible_draws, prefix_sums, - // uniform) and a bind group that pulls in the chunk's vertex_storage - // alongside the model-shared index/mesh/instance buffers. Rendering - // issues one drawcall per non-empty chunk. + // Per-chunk state. Each chunk references a vertex range and an + // index range inside WgpuViewportWindow::pool_, plus a small set of + // per-frame buffers (visible_draws, prefix_sums, uniform) and a bind + // group that binds the pool ranges alongside the model-shared + // mesh/instance storage. Rendering issues one drawcall per non-empty + // chunk. // // Streaming (task #16): a chunk may be marked is_resident=false; its - // vertex_storage + bind_group are then null until the streaming loader - // brings it in. Other per-chunk buffers (visible_draws etc.) stay - // allocated regardless because cull still needs them. Non-streaming - // path always sets is_resident=true so existing code is unchanged. + // pool ranges (pool_*_size == 0) and bind_group are then unclaimed + // until the streaming loader brings it in. Other per-chunk buffers + // (visible_draws etc.) stay allocated regardless because cull still + // needs them. Non-streaming path always sets is_resident=true and + // populates pool ranges at applyCachedModel time. struct Chunk { - WGPUBuffer vertex_storage = nullptr; - // Per-chunk index buffer (was previously per-model). Each chunk - // covers a contiguous range of mesh ids, so its indices form a - // contiguous slice of the model's overall index data. Storing - // per-chunk lets streaming defer index loading alongside vertices. - WGPUBuffer index_buffer = nullptr; + // Pool-allocated vertex bytes. When resident, pool_vertex_size > 0 + // and the range [pool_vertex_offset, pool_vertex_offset + pool_vertex_size) + // in WgpuViewportWindow::pool_ holds this chunk's vertex_storage. + // When non-resident, both are 0. + uint64_t pool_vertex_offset = 0; + uint64_t pool_vertex_size = 0; + // Pool-allocated index bytes. Same lifetime as the vertex range — + // either both resident or both freed. + uint64_t pool_index_offset = 0; + uint64_t pool_index_size = 0; + WGPUBuffer visible_draws_buffer = nullptr; WGPUBuffer prefix_sums_buffer = nullptr; WGPUBuffer per_chunk_uniform = nullptr; @@ -183,8 +190,11 @@ struct WgpuModelGpuData { bool hidden = false; }; -// Release every wgpu handle in `m` and clear its size mirrors. Safe to call +class WgpuBufferPool; + +// Release every wgpu handle in `m` (including per-chunk and per-model pool +// ranges via `pool.free()`) and clear its size mirrors. Safe to call // repeatedly; idempotent on already-released entries. -void releaseWgpuModelGpuData(WgpuModelGpuData& m); +void releaseWgpuModelGpuData(WgpuModelGpuData& m, WgpuBufferPool& pool); #endif // WGPUMODELGPUDATA_H diff --git a/src/ifcviewer-wgpu/WgpuViewportWindow.cpp b/src/ifcviewer-wgpu/WgpuViewportWindow.cpp index 1a269c6a82..b5e40922af 100644 --- a/src/ifcviewer-wgpu/WgpuViewportWindow.cpp +++ b/src/ifcviewer-wgpu/WgpuViewportWindow.cpp @@ -132,11 +132,19 @@ static WGPUBuffer createBufferWithData(WGPUDevice device, WGPUQueue queue, return buf; } -void releaseWgpuModelGpuData(WgpuModelGpuData& m) { +void releaseWgpuModelGpuData(WgpuModelGpuData& m, WgpuBufferPool& pool) { for (auto& c : m.chunks) { if (c.bind_group) { wgpuBindGroupRelease(c.bind_group); c.bind_group = nullptr; } - if (c.vertex_storage) { wgpuBufferRelease(c.vertex_storage); c.vertex_storage = nullptr; } - if (c.index_buffer) { wgpuBufferRelease(c.index_buffer); c.index_buffer = nullptr; } + if (c.pool_vertex_size > 0) { + pool.free(c.pool_vertex_offset, c.pool_vertex_size); + c.pool_vertex_offset = 0; + c.pool_vertex_size = 0; + } + if (c.pool_index_size > 0) { + pool.free(c.pool_index_offset, c.pool_index_size); + c.pool_index_offset = 0; + c.pool_index_size = 0; + } if (c.visible_draws_buffer) { wgpuBufferRelease(c.visible_draws_buffer); c.visible_draws_buffer = nullptr; } if (c.prefix_sums_buffer) { wgpuBufferRelease(c.prefix_sums_buffer); c.prefix_sums_buffer = nullptr; } if (c.per_chunk_uniform) { wgpuBufferRelease(c.per_chunk_uniform); c.per_chunk_uniform = nullptr; } @@ -545,7 +553,7 @@ void WgpuViewportWindow::applyCachedModelStreaming(uint32_t model_id, // Replace any existing state for this id. auto it = models_gpu_.find(model_id); if (it != models_gpu_.end()) { - releaseWgpuModelGpuData(it->second); + releaseWgpuModelGpuData(it->second, pool_); models_gpu_.erase(it); } @@ -795,7 +803,7 @@ void WgpuViewportWindow::applyCachedModel(uint32_t model_id, SidecarData data) { // Replace any existing state for this id. auto it = models_gpu_.find(model_id); if (it != models_gpu_.end()) { - releaseWgpuModelGpuData(it->second); + releaseWgpuModelGpuData(it->second, pool_); models_gpu_.erase(it); } @@ -912,26 +920,43 @@ void WgpuViewportWindow::applyCachedModel(uint32_t model_id, SidecarData data) { c.index_first_u32 = plan.index_first_u32; c.index_count = plan.index_count; - const char* vs_label = (ci == 0) ? "model.chunk0.vertex_storage" - : "model.chunkN.vertex_storage"; - c.vertex_storage = createBufferWithData( - device_, queue_, - data.vertices.data() + plan.source_byte_offset, - plan.byte_count, - WGPUBufferUsage_Storage, - vs_label); + // Vertex bytes: claim a pool range, upload via queueWriteBuffer. + // Storage binding offsets must align to 256 B (WebGPU spec floor + // — minStorageBufferOffsetAlignment); WgpuBufferPool inserts the + // necessary pre-pad. Failure here is fatal for the model: a fresh + // applyCachedModel can't proceed without VRAM, so we bail with + // a clear log and let the caller see it as a load failure. + if (!pool_.alloc(plan.byte_count, 256, &c.pool_vertex_offset)) { + qWarning().noquote().nospace() + << "[wgpu] pool OOM: chunk " << ci << " needed " + << plan.byte_count << " B for vertices, pool free=" + << pool_.free_bytes() << " B; aborting model load"; + releaseWgpuModelGpuData(m, pool_); + return; + } + c.pool_vertex_size = plan.byte_count; + wgpuQueueWriteBuffer(queue_, pool_.buffer(), + c.pool_vertex_offset, + data.vertices.data() + plan.source_byte_offset, + plan.byte_count); m.vram_bytes_vbo += plan.byte_count; - // Per-chunk index slice. base mesh's ebo_byte_offset gives us the - // chunk's start; chunk's index_count tells us how far the slice runs. + // Per-chunk index slice — same dance, from the model's indices[]. const size_t chunk_index_bytes = size_t(plan.index_count) * sizeof(uint32_t); if (chunk_index_bytes > 0) { - c.index_buffer = createBufferWithData( - device_, queue_, - data.indices.data() + plan.index_first_u32, - chunk_index_bytes, - WGPUBufferUsage_Storage | WGPUBufferUsage_Index, - "model.chunk.index_buffer"); + if (!pool_.alloc(chunk_index_bytes, 256, &c.pool_index_offset)) { + qWarning().noquote().nospace() + << "[wgpu] pool OOM: chunk " << ci << " needed " + << chunk_index_bytes << " B for indices, pool free=" + << pool_.free_bytes() << " B; aborting model load"; + releaseWgpuModelGpuData(m, pool_); + return; + } + c.pool_index_size = chunk_index_bytes; + wgpuQueueWriteBuffer(queue_, pool_.buffer(), + c.pool_index_offset, + data.indices.data() + plan.index_first_u32, + chunk_index_bytes); m.vram_bytes_ebo += chunk_index_bytes; } } @@ -1094,13 +1119,13 @@ void WgpuViewportWindow::applyCachedModel(uint32_t model_id, SidecarData data) { void WgpuViewportWindow::removeModel(uint32_t model_id) { auto it = models_gpu_.find(model_id); if (it == models_gpu_.end()) return; - releaseWgpuModelGpuData(it->second); + releaseWgpuModelGpuData(it->second, pool_); models_gpu_.erase(it); if (isExposed()) requestUpdate(); } void WgpuViewportWindow::resetScene() { - for (auto& [mid, m] : models_gpu_) releaseWgpuModelGpuData(m); + for (auto& [mid, m] : models_gpu_) releaseWgpuModelGpuData(m, pool_); models_gpu_.clear(); if (isExposed()) requestUpdate(); } @@ -1257,6 +1282,16 @@ bool WgpuViewportWindow::initWgpu() { device_ = dreq.device; queue_ = wgpuDeviceGetQueue(device_); + // ---- Probe streaming pool capacity ---------------------------------- + // Ask the device for the largest single buffer it'll actually give us. + // Replaces the per-machine "guess the OOM ceiling" knob: now the + // runtime answers the question. Failure here is fatal — without any + // pool we can't load chunks. + if (!probeAndCreatePool()) { + qWarning() << "wgpu: streaming pool probe failed; cannot start"; + return false; + } + // ---- Pick a surface format ------------------------------------------- WGPUSurfaceCapabilities caps = {}; if (wgpuSurfaceGetCapabilities(surface_, adapter_, &caps) != WGPUStatus_Success @@ -1276,6 +1311,86 @@ bool WgpuViewportWindow::initWgpu() { return true; } +bool WgpuViewportWindow::probeAndCreatePool() { + // Discover the largest single buffer the runtime will grant. We + // descend from the device's advertised maxBufferSize because the + // adapter promises that much per binding, but the underlying + // allocator (gpu-alloc-rs on Vulkan, Metal heap manager, browser + // internals) may refuse anything above an undocumented per-system + // ceiling. The probe answers the question honestly. + // + // Each attempt is wrapped in an OOM error scope so a failed + // allocation doesn't surface to onUncapturedError as a noisy + // validation warning — the scope captures the OOM cleanly and we + // simply halve and retry. + + WGPULimits device_limits = {}; + wgpuDeviceGetLimits(device_, &device_limits); + + // 64 MB lower bound: below this the viewer is unusable for any real + // dataset, so we'd rather fail init than limp along. + constexpr uint64_t MIN_POOL_CAPACITY = 64ull * 1024 * 1024; + // 4 GB starting cap: this is the largest single buffer the WebGPU + // ecosystem realistically supports today (browsers stay well below; + // desktop drivers vary). Asking for the device's full advertised + // maxBufferSize first is wasteful — on wgpu-native it can be 1 TB + // (a sentinel meaning "no spec floor"), which always fails and + // forces ~10 halving steps before we land somewhere sensible. + constexpr uint64_t MAX_PROBE_START = 4ull * 1024 * 1024 * 1024; + uint64_t try_size = std::min(device_limits.maxBufferSize, + MAX_PROBE_START); + if (try_size < MIN_POOL_CAPACITY) try_size = MIN_POOL_CAPACITY; + + const WGPUBufferUsage pool_usage = WGPUBufferUsage_Storage + | WGPUBufferUsage_CopyDst; + + while (try_size >= MIN_POOL_CAPACITY) { + // wgpu-native classifies "Not enough memory left" as Validation, + // not OutOfMemory — so we need both filters. Nested scopes: OOM + // inner (matches first), Validation outer (catches the rest). + wgpuDevicePushErrorScope(device_, WGPUErrorFilter_Validation); + wgpuDevicePushErrorScope(device_, WGPUErrorFilter_OutOfMemory); + + const bool init_ok = pool_.init(device_, try_size, pool_usage, + "ifcviewer-wgpu.streaming_pool"); + + 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(ud1); + p->done = true; + p->error = (type != WGPUErrorType_NoError); + }; + pcb.userdata1 = ≺ + wgpuDevicePopErrorScope(device_, pcb); + while (!pr.done) wgpuInstanceProcessEvents(instance_); + }; + PopResult oom_pop, validation_pop; + pop(oom_pop); + pop(validation_pop); + + if (init_ok && !oom_pop.error && !validation_pop.error) { + qInfo().noquote() + << "wgpu: streaming pool capacity =" + << (try_size / (1024 * 1024)) << "MB" + << "(device maxBufferSize =" + << (device_limits.maxBufferSize / (1024 * 1024)) << "MB)"; + return true; + } + // Tear down the failed pool and halve. The init may have left + // the buffer handle in an invalid state — destroy() releases it. + pool_.destroy(); + try_size /= 2; + } + + qWarning() << "wgpu: pool probe found no allocatable size >=" + << (MIN_POOL_CAPACITY / (1024 * 1024)) << "MB"; + return false; +} + // ----------------------------------------------------------------------------- // Surface creation — platform-specific native handle plumbing. // ----------------------------------------------------------------------------- @@ -3169,16 +3284,20 @@ void WgpuViewportWindow::buildChunkBindGroup(WgpuModelGpuData& m, size_t chunk_i wgpuBindGroupRelease(c.bind_group); c.bind_group = nullptr; } - if (!c.vertex_storage || !c.index_buffer || !c.visible_draws_buffer - || !c.prefix_sums_buffer || !c.per_chunk_uniform + if (c.pool_vertex_size == 0 || c.pool_index_size == 0 + || !c.visible_draws_buffer || !c.prefix_sums_buffer || !c.per_chunk_uniform || !m.mesh_storage || !m.instance_storage) { return; } WGPUBindGroupEntry entries[7] = {}; + // vertices and indices live in the shared pool buffer at chunk-specific + // (offset, size) ranges; the other entries are still per-chunk small + // buffers (visible_draws/prefix_sums/uniform) or per-model (mesh/instance). entries[0].binding = 0; - entries[0].buffer = c.vertex_storage; - entries[0].size = WGPU_WHOLE_SIZE; + entries[0].buffer = pool_.buffer(); + entries[0].offset = c.pool_vertex_offset; + entries[0].size = c.pool_vertex_size; entries[1].binding = 1; entries[1].buffer = m.mesh_storage; entries[1].size = WGPU_WHOLE_SIZE; @@ -3186,8 +3305,9 @@ void WgpuViewportWindow::buildChunkBindGroup(WgpuModelGpuData& m, size_t chunk_i entries[2].buffer = m.instance_storage; entries[2].size = WGPU_WHOLE_SIZE; entries[3].binding = 3; - entries[3].buffer = c.index_buffer; - entries[3].size = WGPU_WHOLE_SIZE; + entries[3].buffer = pool_.buffer(); + entries[3].offset = c.pool_index_offset; + entries[3].size = c.pool_index_size; entries[4].binding = 4; entries[4].buffer = c.visible_draws_buffer; entries[4].size = WGPU_WHOLE_SIZE; @@ -3226,14 +3346,18 @@ bool WgpuViewportWindow::loadChunkBytesAndUploadGpu(WgpuModelGpuData& m, size_t << " size=" << c.vertex_byte_size << ")"; return false; } - c.vertex_storage = createBufferWithData( - device_, queue_, - vbytes.data(), vbytes.size(), - WGPUBufferUsage_Storage, - "model.chunk.vertex_storage_streamed"); - if (!c.vertex_storage) return false; - m.vram_bytes_vbo += vbytes.size(); - streaming_vram_resident_bytes_ += vbytes.size(); + // Claim a pool range for the vertex bytes and upload. + if (!pool_.alloc(vbytes.size(), 256, &c.pool_vertex_offset)) { + // No room — caller (driveStreamingLoads) should have evicted + // first. This branch is a safety net for the very-first-frame + // case where pool eviction may not have caught up. + return false; + } + c.pool_vertex_size = vbytes.size(); + wgpuQueueWriteBuffer(queue_, pool_.buffer(), + c.pool_vertex_offset, + vbytes.data(), vbytes.size()); + m.vram_bytes_vbo += vbytes.size(); // Index slice. The byte-range read comes from // streaming_index_section_offset + index_first_u32 * 4 (set by @@ -3249,21 +3373,26 @@ bool WgpuViewportWindow::loadChunkBytesAndUploadGpu(WgpuModelGpuData& m, size_t << "[wgpu stream] failed to read index chunk " << chunk_idx << " (first=" << c.index_first_u32 << " count=" << c.index_count << ")"; - // Release the vertex buffer we just allocated so we don't leak. - wgpuBufferRelease(c.vertex_storage); - c.vertex_storage = nullptr; - m.vram_bytes_vbo -= vbytes.size(); - streaming_vram_resident_bytes_ -= vbytes.size(); + // Return the vertex slice to the pool so we don't leak. + pool_.free(c.pool_vertex_offset, c.pool_vertex_size); + c.pool_vertex_offset = 0; + c.pool_vertex_size = 0; + m.vram_bytes_vbo -= vbytes.size(); return false; } const size_t ibytes = idx.size() * sizeof(uint32_t); - c.index_buffer = createBufferWithData( - device_, queue_, - idx.data(), ibytes, - WGPUBufferUsage_Storage | WGPUBufferUsage_Index, - "model.chunk.index_buffer_streamed"); - m.vram_bytes_ebo += ibytes; - streaming_vram_resident_bytes_ += ibytes; + if (!pool_.alloc(ibytes, 256, &c.pool_index_offset)) { + pool_.free(c.pool_vertex_offset, c.pool_vertex_size); + c.pool_vertex_offset = 0; + c.pool_vertex_size = 0; + m.vram_bytes_vbo -= vbytes.size(); + return false; + } + c.pool_index_size = ibytes; + wgpuQueueWriteBuffer(queue_, pool_.buffer(), + c.pool_index_offset, + idx.data(), ibytes); + m.vram_bytes_ebo += ibytes; } buildChunkBindGroup(m, chunk_idx); @@ -3280,19 +3409,17 @@ void WgpuViewportWindow::unloadChunk(WgpuModelGpuData& m, size_t chunk_idx) { wgpuBindGroupRelease(c.bind_group); c.bind_group = nullptr; } - if (c.vertex_storage) { - const uint64_t vsz = c.vertex_byte_size; - wgpuBufferRelease(c.vertex_storage); - c.vertex_storage = nullptr; - m.vram_bytes_vbo -= vsz; - streaming_vram_resident_bytes_ -= vsz; + if (c.pool_vertex_size > 0) { + m.vram_bytes_vbo -= c.pool_vertex_size; + pool_.free(c.pool_vertex_offset, c.pool_vertex_size); + c.pool_vertex_offset = 0; + c.pool_vertex_size = 0; } - if (c.index_buffer) { - const uint64_t isz = c.index_count * sizeof(uint32_t); - wgpuBufferRelease(c.index_buffer); - c.index_buffer = nullptr; - m.vram_bytes_ebo -= isz; - streaming_vram_resident_bytes_ -= isz; + if (c.pool_index_size > 0) { + m.vram_bytes_ebo -= c.pool_index_size; + pool_.free(c.pool_index_offset, c.pool_index_size); + c.pool_index_offset = 0; + c.pool_index_size = 0; } // Clear per-frame visibility so the chunk doesn't get re-rendered or // re-evicted on the same frame; cull will set it again next time @@ -3340,18 +3467,27 @@ void WgpuViewportWindow::driveStreamingLoads() { // Per-frame load budget. Caps first-frame stall on a fresh load — at // 4 chunks/frame × 60fps we ingest 240 chunks/sec, fast enough that - // a 100-model scene fully resides in ~1s. Bigger scenes are bounded - // by the VRAM budget below: the residency set never grows past - // streaming_vram_budget_bytes_, so we stay clear of the wgpu-native - // allocator's OOM ceiling (~2.4 GB on this box, default budget 1.9). + // a 100-model scene fully resides in ~1s. The hard ceiling on total + // residency is the pool capacity (probed at startup); when the pool + // can't fit a candidate, the evictors below free closer-fitting + // ranges until it does. constexpr int MAX_STREAMING_LOADS_PER_FRAME = 4; int loads = 0; bool more_pending = false; + // The pool needs `need` contiguous bytes free for both the vertex and + // index allocations a load requires. Fragmentation matters: a chunk + // may fit total-free-bytes but not largest_free_run_bytes(). We don't + // try to predict fragmentation perfectly — the load will simply fail + // and trigger another eviction round next frame. + auto pool_can_fit = [&](uint64_t bytes) -> bool { + return pool_.largest_free_run_bytes() >= bytes; + }; + // Phase-1 evictor: drop the LRU non-visible resident chunk. Skips // chunks stamped on streaming_frame_idx_ to avoid yanking what cull - // just marked visible. Returns bytes freed (0 ⇒ no candidate). - auto evict_one_lru = [&]() -> uint64_t { + // just marked visible. Returns true iff a chunk was evicted. + auto evict_one_lru = [&]() -> bool { WgpuModelGpuData* victim_m = nullptr; size_t victim_ci = 0; uint64_t victim_lru = std::numeric_limits::max(); @@ -3367,10 +3503,9 @@ void WgpuViewportWindow::driveStreamingLoads() { } } } - if (!victim_m) return 0; - const uint64_t before = streaming_vram_resident_bytes_; + if (!victim_m) return false; unloadChunk(*victim_m, victim_ci); - return before - streaming_vram_resident_bytes_; + return true; }; // Phase-2 evictor: when every resident chunk is visible-this-frame @@ -3378,8 +3513,8 @@ void WgpuViewportWindow::driveStreamingLoads() { // eye visible chunk — provided it is farther than the candidate we // want to load. Without the distance check this would loop forever // swapping pairs; with it, residency monotonically converges to the - // closest visible chunks that fit the budget. - auto evict_farthest_than = [&](float candidate_dist2) -> uint64_t { + // closest visible chunks that fit the pool. + auto evict_farthest_than = [&](float candidate_dist2) -> bool { WgpuModelGpuData* victim_m = nullptr; size_t victim_ci = 0; float victim_dist2 = candidate_dist2; @@ -3395,10 +3530,9 @@ void WgpuViewportWindow::driveStreamingLoads() { } } } - if (!victim_m) return 0; - const uint64_t before = streaming_vram_resident_bytes_; + if (!victim_m) return false; unloadChunk(*victim_m, victim_ci); - return before - streaming_vram_resident_bytes_; + return true; }; for (auto& [mid, m] : models_gpu_) { @@ -3415,18 +3549,23 @@ void WgpuViewportWindow::driveStreamingLoads() { } // Make room. Phase 1: drop LRU non-visible. Phase 2: if still - // over budget, drop the farthest-from-eye visible chunk that - // is strictly farther than the candidate we want to load. + // pool-tight, drop the farthest-from-eye visible chunk that is + // strictly farther than the candidate we want to load. The + // largest-free-run check is conservative — pool may have N MB + // free across many small holes that no chunk can use. const uint64_t need = c.vertex_byte_size + c.index_count * sizeof(uint32_t); - while (streaming_vram_resident_bytes_ + need - > streaming_vram_budget_bytes_) { - if (evict_one_lru() > 0) continue; - if (evict_farthest_than(chunk_center_dist2(c)) > 0) continue; + while (!pool_can_fit(c.vertex_byte_size) + || (c.index_count > 0 + && !pool_can_fit(c.index_count * sizeof(uint32_t))) + || pool_.free_bytes() < need) { + if (evict_one_lru()) continue; + if (evict_farthest_than(chunk_center_dist2(c))) continue; break; // nothing left we're willing to evict } - if (streaming_vram_resident_bytes_ + need - > streaming_vram_budget_bytes_) { + if (!pool_can_fit(c.vertex_byte_size) + || (c.index_count > 0 + && !pool_can_fit(c.index_count * sizeof(uint32_t)))) { // Candidate is farther than every resident — skip it. // We'll come back to it if it gets closer. more_pending = true; @@ -3840,7 +3979,7 @@ void WgpuViewportWindow::wheelEvent(QWheelEvent* event) { void WgpuViewportWindow::shutdown() { // Release per-model buffers before the device they were created from. - for (auto& [mid, m] : models_gpu_) releaseWgpuModelGpuData(m); + for (auto& [mid, m] : models_gpu_) releaseWgpuModelGpuData(m, pool_); models_gpu_.clear(); releaseDepthTexture(); @@ -3859,6 +3998,12 @@ void WgpuViewportWindow::shutdown() { if (model_bgl_) { wgpuBindGroupLayoutRelease(model_bgl_); model_bgl_ = nullptr; } if (frame_bgl_) { wgpuBindGroupLayoutRelease(frame_bgl_); frame_bgl_ = nullptr; } + // Destroy the streaming pool while device_ is still alive (it owns + // the underlying WGPUBuffer). All chunks have already returned their + // ranges via releaseWgpuModelGpuData above; pool's free-list count + // should equal capacity at this point. + pool_.destroy(); + if (queue_) { wgpuQueueRelease(queue_); queue_ = nullptr; } if (device_) { wgpuDeviceRelease(device_); device_ = nullptr; } if (adapter_) { wgpuAdapterRelease(adapter_); adapter_ = nullptr; } diff --git a/src/ifcviewer-wgpu/WgpuViewportWindow.h b/src/ifcviewer-wgpu/WgpuViewportWindow.h index 3db05d9262..da3642e4a1 100644 --- a/src/ifcviewer-wgpu/WgpuViewportWindow.h +++ b/src/ifcviewer-wgpu/WgpuViewportWindow.h @@ -33,6 +33,7 @@ #include #include "SidecarCache.h" +#include "WgpuBufferPool.h" #include "WgpuModelGpuData.h" #include "WgpuSelectionState.h" #include "WgpuVisibilityState.h" @@ -72,10 +73,10 @@ public: // Streaming variant: takes a StreamingSidecar (metadata only — no // vertex / index bytes). Allocates per-chunk small buffers and the - // model-shared index / mesh / instance storage upfront, but leaves - // each chunk's vertex_storage null and is_resident=false. The per- - // frame loader (commit 4 of streaming) brings chunks resident on - // demand as cull flags them visible. + // model-shared mesh / instance storage upfront, but leaves each + // chunk's pool ranges unclaimed and is_resident=false. The per-frame + // loader (driveStreamingLoads) sub-allocates the chunk's vertex + + // index ranges from pool_ on demand as cull flags them visible. void applyCachedModelStreaming(uint32_t model_id, struct StreamingSidecar metadata); @@ -129,20 +130,30 @@ private: bool buildPipelines(); void buildModelBindGroup(WgpuModelGpuData& m); void buildChunkBindGroup(WgpuModelGpuData& m, size_t chunk_idx); - // Streaming: read the chunk's vertex bytes from disk, allocate - // vertex_storage, build the chunk's bind group, flip is_resident=true. - // Returns true on success. No-op (returns true) when already resident. + // Streaming: read the chunk's vertex + index bytes from disk, + // sub-allocate ranges in pool_, queueWriteBuffer them in, build the + // chunk's bind group, flip is_resident=true. Returns true on success; + // false if either the disk read or a pool alloc fails (caller is + // expected to have already evicted enough). No-op (returns true) + // when already resident. bool loadChunkBytesAndUploadGpu(WgpuModelGpuData& m, size_t chunk_idx); - // Release a resident chunk's vertex+index buffers and bind group; - // flip is_resident=false. The chunk's CPU metadata (offsets, AABB, + // Release a resident chunk's pool ranges + bind group; flip + // is_resident=false. The chunk's CPU metadata (offsets, AABB, // visible-draw scratch) is retained so a subsequent // loadChunkBytesAndUploadGpu can bring it back without re-planning. void unloadChunk(WgpuModelGpuData& m, size_t chunk_idx); // Called from render() after cull: find non-resident chunks with - // current visible draw counts > 0 and bring them resident, up to a - // per-frame budget. Triggers requestUpdate() if more remain. Evicts - // LRU non-visible chunks first when over streaming_vram_budget_bytes_. + // current visible draw counts > 0 and bring them resident. When the + // pool is full, evicts LRU non-visible chunks first, then falls back + // to evicting the farthest-from-camera visible chunks if a closer + // candidate needs the space. Triggers requestUpdate() if more remain. void driveStreamingLoads(); + + // Discover the largest single buffer wgpu will give us by descending + // from the device's limits.maxBufferSize through OOM error scopes. + // Allocates pool_ at the discovered size. Returns false only when + // even a tiny pool can't be created (i.e. the device is unusable). + bool probeAndCreatePool(); void ensureDepthTexture(int w, int h); void releaseDepthTexture(); void ensureMsaaColorTexture(int w, int h); @@ -399,31 +410,18 @@ public: // preserved; --streaming opts in. bool streaming_enabled_ = false; - // Streaming residency budget (bytes). The per-frame loader will not - // bring a chunk resident if doing so would exceed this; instead it - // evicts the LRU non-visible chunks until headroom exists, then - // falls back to evicting the farthest-from-camera visible chunks - // when even visible-set residency would overshoot. - // - // STOPGAP: this is a hand-picked number — the wrong shape of fix. - // wgpu-native's per-allocation overhead (gpu-alloc-rs fragmentation + - // one VkDeviceMemory block per createBuffer) makes the true usable - // ceiling far below physical VRAM, by a margin that varies per - // GPU/driver/runtime. The proper fix is a single-pool buffer with - // sub-allocation (see WgpuBufferPool task), whose size is *probed* - // at startup via wgpuDevicePushErrorScope rather than guessed at - // compile time. Once the pool lands, this field disappears. - // - // For now: 1 GB is a portable-ish floor that won't OOM on any - // desktop GPU we care about, and is at least close to the web's - // common ceiling. Tune via --streaming-vram-mb on machines with - // more headroom. - uint64_t streaming_vram_budget_bytes_ = 1024ull * 1024 * 1024; - uint64_t streaming_vram_resident_bytes_ = 0; // Monotonic frame counter, bumped at the top of driveStreamingLoads. // Used as the LRU key for chunk eviction. uint64_t streaming_frame_idx_ = 0; + // Sub-allocator for all chunk vertex + index bytes. Sized at startup + // by probeAndCreatePool() — the runtime tells us how big a single + // buffer it can actually deliver, eliminating the per-machine OOM + // ceiling that one-WGPUBuffer-per-chunk would otherwise hit. All + // chunk allocations land here; nothing else uses the pool. Replaces + // the old hand-picked streaming_vram_budget_bytes_ knob entirely. + WgpuBufferPool pool_; + private: // Switch to LOD1 when an instance's projected bounding-sphere radius