diff --git a/src/bonsaiviewer/MainWindow.cpp b/src/bonsaiviewer/MainWindow.cpp index 7bcda0dc47..bcd4653fa6 100644 --- a/src/bonsaiviewer/MainWindow.cpp +++ b/src/bonsaiviewer/MainWindow.cpp @@ -566,7 +566,11 @@ void MainWindow::setupLoader() { .arg(stats.total_triangles) .arg(stats.gl_draw_calls) .arg(double(stats.vram_used_bytes) * mb, 0, 'f', 0) - .arg(double(stats.vram_capacity_bytes) * mb, 0, 'f', 0); + // Used against what the cache may grow to; the pool's + // momentary capacity only until the budget is known. + .arg(double(stats.vram_budget_bytes > 0 + ? stats.vram_budget_bytes + : stats.vram_capacity_bytes) * mb, 0, 'f', 0); // Device total is only known when a driver backend answered. if (stats.device_vram_total_bytes > 0) { text += QString(" | Device %1/%2 MB") diff --git a/src/ifcviewer/BufferPool.cpp b/src/ifcviewer/BufferPool.cpp index bdf533587b..4e41e1c95d 100644 --- a/src/ifcviewer/BufferPool.cpp +++ b/src/ifcviewer/BufferPool.cpp @@ -18,7 +18,9 @@ ********************************************************************************/ #include "BufferPool.h" +#include "GpuAllocScope.h" +#include #include #include #include @@ -59,17 +61,22 @@ bool BufferPool::addSubBuffer() { if (!device_ || per_sub_buffer_capacity_ == 0) return false; if (growth_disabled_) return false; - // 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; + // Never overshoot the budget: the cache's whole job is to stop short + // of what the required tier needs, and a sub-buffer that straddles + // the line would take exactly the bytes it was told to leave. A + // budget refusal is not a driver refusal, so growth_disabled_ is not + // latched — the budget is the (already lower) ceiling. + if (max_total_capacity_bytes_ > 0) { + const uint64_t total = total_capacity_bytes(); + if (total + MIN_SUB_BUFFER_BYTES > max_total_capacity_bytes_) return false; + try_size = std::min(try_size, max_total_capacity_bytes_ - total); + } + #if defined(__EMSCRIPTEN__) // Web can't synchronously learn whether createBuffer OOM'd: the // desktop spin-wait that drains PopErrorScope would block the JS @@ -94,7 +101,7 @@ bool BufferPool::addSubBuffer() { desc.label.data = label; desc.label.length = std::strlen(label); - wgpuDevicePushErrorScope(device_, WGPUErrorFilter_OutOfMemory); + GpuAllocScope scope(instance_, device_); WGPUBuffer buf = wgpuDeviceCreateBuffer(device_, &desc); SubPool sp; @@ -107,15 +114,7 @@ bool BufferPool::addSubBuffer() { last_growth_size_ = try_size; growth_pending_ = true; - WGPUPopErrorScopeCallbackInfo pcb = {}; - pcb.mode = WGPUCallbackMode_AllowSpontaneous; - pcb.callback = [](WGPUPopErrorScopeStatus, WGPUErrorType type, - WGPUStringView, void* ud1, void* /*ud2*/) { - static_cast(ud1)->resolveProvisionalGrowth( - type != WGPUErrorType_NoError); - }; - pcb.userdata1 = this; - wgpuDevicePopErrorScope(device_, pcb); + scope.end([this](bool ok) { resolveProvisionalGrowth(!ok); }); // No usable space yet: the provisional sub-buffer isn't handed out // until validated. alloc fails this frame and retries on a later one. @@ -132,31 +131,10 @@ bool BufferPool::addSubBuffer() { desc.label.data = label; desc.label.length = std::strlen(label); - // 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); - + GpuAllocScope scope(instance_, device_); WGPUBuffer buf = wgpuDeviceCreateBuffer(device_, &desc); - - struct PopResult { bool done = false; bool error = false; }; - auto pop = [&](PopResult& pop_result) { - 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 = &pop_result; - wgpuDevicePopErrorScope(device_, pcb); - while (!pop_result.done) wgpuInstanceProcessEvents(instance_); - }; - PopResult oom_pop, validation_pop; - pop(oom_pop); - pop(validation_pop); - const bool ok = buf && !oom_pop.error && !validation_pop.error; + bool ok = false; + scope.end([&](bool result) { ok = result && buf; }); if (ok) { SubPool sp; @@ -188,6 +166,39 @@ bool BufferPool::addSubBuffer() { #endif // __EMSCRIPTEN__ } +uint64_t BufferPool::shrinkToCapacity( + uint64_t target_bytes, + const std::function& evict_sub_buffer) { + uint64_t released = 0; + while (!sub_pools_.empty() && total_capacity_bytes() > target_bytes) { + const int idx = int(sub_pools_.size()) - 1; + if (sub_pools_[size_t(idx)].provisional) break; + evict_sub_buffer(idx); + SubPool& sub_pool = sub_pools_[size_t(idx)]; + assert(sub_pool.used == 0 && "owner must free every slice before a sub-buffer is released"); + if (sub_pool.buffer && sub_pool.owns_handle) { + // Destroy, not just release: the handle may still be + // referenced by in-flight work, and destroy tells the + // backend to reclaim the memory as soon as that completes + // instead of when the last reference goes away. + wgpuBufferDestroy(sub_pool.buffer); + wgpuBufferRelease(sub_pool.buffer); + } + released += sub_pool.capacity; + sub_pools_.pop_back(); + } + if (released > 0) { + std::fprintf(stderr, + "[wgpu pool] released %llu MB under memory pressure; pool now %llu MB " + "across %zu sub-buffer(s), budget %llu MB\n", + (unsigned long long)(released / (1024 * 1024)), + (unsigned long long)(total_capacity_bytes() / (1024 * 1024)), + sub_pools_.size(), + (unsigned long long)(max_total_capacity_bytes_ / (1024 * 1024))); + } + return released; +} + #if defined(__EMSCRIPTEN__) void BufferPool::resolveProvisionalGrowth(bool failed) { growth_pending_ = false; @@ -325,9 +336,10 @@ uint64_t BufferPool::largest_free_run_bytes() const { void BufferPool::addSubBufferForTesting(WGPUBuffer fake_buffer, uint64_t capacity) { SubPool sp; - sp.buffer = fake_buffer; - sp.capacity = capacity; - sp.used = 0; + sp.buffer = fake_buffer; + sp.capacity = capacity; + sp.used = 0; + sp.owns_handle = false; sp.free_ranges.push_back({0, capacity}); sub_pools_.push_back(std::move(sp)); } diff --git a/src/ifcviewer/BufferPool.h b/src/ifcviewer/BufferPool.h index 1dc3f80d37..5ce7f8162a 100644 --- a/src/ifcviewer/BufferPool.h +++ b/src/ifcviewer/BufferPool.h @@ -23,6 +23,7 @@ #include #include +#include #include #include @@ -105,6 +106,12 @@ public: uint64_t next_growth_size_bytes() const { return last_growth_size_ > 0 ? last_growth_size_ : per_sub_buffer_capacity_; } + // Smallest sub-buffer worth adding: below this the per-allocation + // bookkeeping (one bind group per chunk, free-list overhead) outweighs + // the space. Growth that cannot reach the floor — the driver refusing, + // or the budget leaving less than this — is not attempted. + static constexpr uint64_t MIN_SUB_BUFFER_BYTES = 64ull * 1024 * 1024; + // Whether the pool can still attempt to add a sub-buffer. Flips to // false the first time addSubBuffer is refused even at the floor // size — eviction callers need this to know whether a future alloc @@ -112,7 +119,8 @@ public: bool can_grow() const { return !growth_disabled_ && per_sub_buffer_capacity_ > 0 && (max_total_capacity_bytes_ == 0 - || total_capacity_bytes() < max_total_capacity_bytes_); + || total_capacity_bytes() + MIN_SUB_BUFFER_BYTES + <= max_total_capacity_bytes_); } // Whether a growth is in flight. On web that window is real time — a @@ -127,6 +135,20 @@ public: // is a bad_alloc that -fno-exceptions turns into an uncatchable abort, so // the async grow-OOM detection can't save us — we must stop first. void setMaxTotalCapacity(uint64_t max_bytes) { max_total_capacity_bytes_ = max_bytes; } + uint64_t max_total_capacity_bytes() const { return max_total_capacity_bytes_; } + + // Release whole sub-buffers, newest first, until total capacity is + // ≤ target_bytes (or nothing is left). Before each sub-buffer is + // dropped, `evict_sub_buffer(sub_idx)` is invoked so the owner can + // free every slice that lives in it — the pool does not know what a + // slice holds, and a sub-buffer is only released once it is empty. + // Releasing from the back keeps every surviving Slice::sub_idx valid. + // Returns the number of bytes released. This is how the cache yields + // memory to the required tier (see GpuBudget); on web a provisional + // sub-buffer that is still validating is left alone and the shrink is + // retried once that resolves. + uint64_t shrinkToCapacity(uint64_t target_bytes, + const std::function& evict_sub_buffer); // Proactively add a sub-buffer (no allocation). On web this kicks off the // async provisional-validation cycle so validated free space appears a @@ -161,6 +183,9 @@ private: // capacity/free tallies skip provisional sub-pools so an // unvalidated (possibly invalid) buffer is never handed out. bool provisional = false; + // False only for addSubBufferForTesting's fake handles: release + // paths (shrinkToCapacity, destroy) then skip the wgpu calls. + bool owns_handle = true; }; // Append a new sub-buffer to the pool. Starts at last_growth_size_ diff --git a/src/ifcviewer/CMakeLists.txt b/src/ifcviewer/CMakeLists.txt index c8e511e50f..9c7dbf1684 100644 --- a/src/ifcviewer/CMakeLists.txt +++ b/src/ifcviewer/CMakeLists.txt @@ -145,6 +145,8 @@ set(IFCVIEWER_CORE_SOURCES InstanceCompose.cpp LodBuilder.cpp FederationMath.cpp + GpuAllocScope.cpp + GpuBudget.cpp GpuMemory.cpp SidecarCache.cpp SidecarCompress.cpp diff --git a/src/ifcviewer/FrameStats.h b/src/ifcviewer/FrameStats.h index 938538b4a2..19aa458ba4 100644 --- a/src/ifcviewer/FrameStats.h +++ b/src/ifcviewer/FrameStats.h @@ -38,11 +38,12 @@ struct FrameStats { std::uint32_t unique_meshes; std::uint32_t gl_draw_calls; // wgpu draw-call count; name kept for bonsai parity std::uint32_t indirect_sub_draws; // sub-draws packed into the chunk-indirect lists - // Chunk geometry pool occupancy (see BufferPool). wgpu exposes no - // adapter-wide VRAM query, so this is the viewer's own allocation, - // not the device total. + // Chunk geometry pool occupancy (see BufferPool): bytes held by + // resident chunks, the pool's current capacity, and the budget the + // pool may grow to (GpuBudget; 0 when still unbounded). std::uint64_t vram_used_bytes; std::uint64_t vram_capacity_bytes; + std::uint64_t vram_budget_bytes; // Whole-device VRAM from the driver (NVML / sysfs, see GpuMemory.h). // Desktop only; zero on web or when no backend could answer, so // consumers must treat 0 as "unknown" rather than as empty. diff --git a/src/ifcviewer/GpuAllocScope.cpp b/src/ifcviewer/GpuAllocScope.cpp new file mode 100644 index 0000000000..d8ce0807c1 --- /dev/null +++ b/src/ifcviewer/GpuAllocScope.cpp @@ -0,0 +1,92 @@ +/******************************************************************************** + * * + * 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 "GpuAllocScope.h" + +#include + +namespace { + +// Shared between the two pop callbacks; freed by whichever fires last. +struct PendingPop { + GpuAllocScope::Callback on_result; + int remaining = 2; + bool error = false; + // Desktop: points at a flag on end()'s stack frame so the spin-wait + // can observe completion without touching this (freed) object. + bool* done = nullptr; +}; + +void onPopped(WGPUPopErrorScopeStatus, WGPUErrorType type, WGPUStringView, + void* userdata1, void* /*userdata2*/) { + auto* pending = static_cast(userdata1); + if (type != WGPUErrorType_NoError) pending->error = true; + if (--pending->remaining > 0) return; + const bool ok = !pending->error; + bool* done = pending->done; + GpuAllocScope::Callback on_result = std::move(pending->on_result); + delete pending; + on_result(ok); + if (done) *done = true; +} + +} // namespace + +GpuAllocScope::GpuAllocScope(WGPUInstance instance, WGPUDevice device) + : instance_(instance), device_(device) { + // Validation outer, OutOfMemory inner: each pop sees the errors of + // its own filter, and an OOM reported under either classification + // reaches one of the two. + wgpuDevicePushErrorScope(device_, WGPUErrorFilter_Validation); + wgpuDevicePushErrorScope(device_, WGPUErrorFilter_OutOfMemory); +} + +GpuAllocScope::~GpuAllocScope() { + assert(ended_ && "GpuAllocScope::end() must be called exactly once"); +} + +void GpuAllocScope::end(Callback on_result) { + assert(!ended_); + ended_ = true; + + bool done = false; + auto* pending = new PendingPop{std::move(on_result)}; + + WGPUPopErrorScopeCallbackInfo cb = {}; +#if defined(__EMSCRIPTEN__) + // Dawn-web resolves pops from the JS event loop; the caller proceeds + // provisionally and hears back in on_result. + cb.mode = WGPUCallbackMode_AllowSpontaneous; +#else + // wgpu-native fires these from wgpuInstanceProcessEvents, which we + // spin below so on_result has run by the time end() returns. + cb.mode = WGPUCallbackMode_AllowProcessEvents; + pending->done = &done; +#endif + cb.callback = onPopped; + cb.userdata1 = pending; + wgpuDevicePopErrorScope(device_, cb); // OutOfMemory (inner) + wgpuDevicePopErrorScope(device_, cb); // Validation (outer) + +#if !defined(__EMSCRIPTEN__) + while (!done) wgpuInstanceProcessEvents(instance_); +#else + (void)done; +#endif +} diff --git a/src/ifcviewer/GpuAllocScope.h b/src/ifcviewer/GpuAllocScope.h new file mode 100644 index 0000000000..0a2b64b8d3 --- /dev/null +++ b/src/ifcviewer/GpuAllocScope.h @@ -0,0 +1,67 @@ +/******************************************************************************** + * * + * 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 IFCVIEWER_GPUALLOCSCOPE_H +#define IFCVIEWER_GPUALLOCSCOPE_H + +#include + +#include + +// Brackets one or more wgpu resource creations so an out-of-memory is +// observed instead of silently producing an invalid resource. +// +// WebGPU never returns null from createBuffer / createTexture: a failed +// allocation yields an *error* resource, and the failure is only reported +// through an error scope. Left unobserved it surfaces later as a validation +// error on the first use -- and on wgpu-native an invalid attachment in +// wgpuQueueSubmit is a Rust panic across the FFI boundary, i.e. an abort +// with no recovery path. So every allocation the renderer cannot do +// without goes through one of these. +// +// Two filters are pushed, not one: wgpu-native classifies "Not enough +// memory left" as a Validation error, Dawn as OutOfMemory. +// +// Desktop and web differ only in *when* the answer arrives. On wgpu-native +// the scope pops synchronously (the instance is spun until the callback +// fires) and `end` invokes the callback before returning. On Dawn-web the +// pop is a promise and spinning would deadlock the JS event loop, so the +// callback fires later from the event loop; callers use the resource +// provisionally and correct course in the callback if it turns out bad. +class GpuAllocScope { +public: + using Callback = std::function; + + GpuAllocScope(WGPUInstance instance, WGPUDevice device); + ~GpuAllocScope(); + + GpuAllocScope(const GpuAllocScope&) = delete; + GpuAllocScope& operator=(const GpuAllocScope&) = delete; + + // Pop the scopes and deliver the verdict: `ok` is true when no error + // fired between construction and here. Must be called exactly once. + void end(Callback on_result); + +private: + WGPUInstance instance_ = nullptr; + WGPUDevice device_ = nullptr; + bool ended_ = false; +}; + +#endif // IFCVIEWER_GPUALLOCSCOPE_H diff --git a/src/ifcviewer/GpuBudget.cpp b/src/ifcviewer/GpuBudget.cpp new file mode 100644 index 0000000000..4b683743eb --- /dev/null +++ b/src/ifcviewer/GpuBudget.cpp @@ -0,0 +1,60 @@ +/******************************************************************************** + * * + * 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 "GpuBudget.h" + +#include + +void GpuBudget::configure(std::uint64_t device_free_bytes, + std::uint64_t reserve_bytes, + std::uint64_t hard_cap_bytes) { + bounded_ = false; + budget_ = 0; + if (device_free_bytes > 0) { + bounded_ = true; + budget_ = device_free_bytes > reserve_bytes + ? device_free_bytes - reserve_bytes + : 0; + } + if (hard_cap_bytes > 0) { + budget_ = bounded_ ? std::min(budget_, hard_cap_bytes) : hard_cap_bytes; + bounded_ = true; + } + if (bounded_) budget_ = std::max(budget_, kMinCacheBudgetBytes); +} + +bool GpuBudget::onPressure(std::uint64_t cache_capacity_bytes, + std::uint64_t bytes_needed) { + ++pressure_events_; + // What the cache may keep once the failed allocation and its slack + // have been carved out of what it holds right now. The pool's actual + // capacity, not the previous budget, is the honest baseline: the + // budget may never have been reached (unbounded, or growth refused + // earlier by the driver), and lowering a number the pool never hit + // would free nothing. + const std::uint64_t carve = bytes_needed + kPressureSlackBytes; + const std::uint64_t target = cache_capacity_bytes > carve + ? cache_capacity_bytes - carve + : 0; + const std::uint64_t lowered = std::max(target, kMinCacheBudgetBytes); + if (bounded_ && lowered >= budget_) return false; + bounded_ = true; + budget_ = lowered; + return true; +} diff --git a/src/ifcviewer/GpuBudget.h b/src/ifcviewer/GpuBudget.h new file mode 100644 index 0000000000..db1303df45 --- /dev/null +++ b/src/ifcviewer/GpuBudget.h @@ -0,0 +1,92 @@ +/******************************************************************************** + * * + * 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 IFCVIEWER_GPUBUDGET_H +#define IFCVIEWER_GPUBUDGET_H + +#include + +// How much device memory the elastic geometry cache (BufferPool) may hold. +// +// GPU memory in the viewer falls in two tiers. *Required* allocations -- +// render attachments, per-model metadata, readback staging -- are allocated +// eagerly at deterministic moments (surface configure, model load) and the +// frame cannot be drawn without them. The *cache* -- streamed chunk +// geometry -- is elastic: a chunk that does not fit is simply not resident +// this frame. The rule that keeps the two from colliding is that the cache +// never takes the last byte: it grows only up to this budget, and yields +// whenever a required allocation fails. +// +// The budget's *source* differs per platform, the mechanism does not: +// - desktop: derived from the driver's free-memory report minus a reserve +// for the attachments (GpuMemory.h); +// - web: no memory query exists, so a fixed ceiling (the wasm heap) and +// pressure feedback alone; +// - either: when a required allocation still fails, onPressure() lowers +// the budget so the pool releases enough for that allocation to succeed +// on retry. Over a session the budget converges on what the device +// actually allows, which is the only information WebGPU ever gives. +// +// Pure policy, no wgpu: the pool applies the number via +// BufferPool::setMaxTotalCapacity / shrinkToCapacity. +class GpuBudget { +public: + // Below this the viewer cannot keep even a handful of 4 MB chunks + // resident, so there is no point lowering further: a required + // allocation that still fails at the floor is a genuinely exhausted + // device, and the caller degrades instead. + static constexpr std::uint64_t kMinCacheBudgetBytes = 64ull * 1024 * 1024; + // Headroom added on top of the failed allocation when lowering the + // budget, so the very next small required allocation does not fail + // again and trigger another shrink cycle. + static constexpr std::uint64_t kPressureSlackBytes = 32ull * 1024 * 1024; + + // `device_free_bytes` 0 = unknown (web, or an unsupported driver). + // `reserve_bytes` is what the required tier is expected to need at its + // largest (attachments at the biggest plausible surface, plus margin). + // `hard_cap_bytes` 0 = none; otherwise an absolute ceiling regardless + // of device memory (the wasm heap on web). + void configure(std::uint64_t device_free_bytes, + std::uint64_t reserve_bytes, + std::uint64_t hard_cap_bytes); + + // False when nothing bounds the cache yet (no device information, no + // cap, no pressure so far). The pool then grows until the driver + // refuses, exactly as before; the first pressure event bounds it. + bool bounded() const { return bounded_; } + // Meaningful only when bounded(). + std::uint64_t cache_budget_bytes() const { return budget_; } + + // A required allocation of `bytes_needed` failed while the cache held + // `cache_capacity_bytes`. Lowers the budget so that shrinking the cache + // to it frees bytes_needed + slack. Returns false when the budget could + // not be lowered any further (already at the floor): the device is + // exhausted and the caller must degrade rather than retry. + bool onPressure(std::uint64_t cache_capacity_bytes, + std::uint64_t bytes_needed); + + std::uint32_t pressure_events() const { return pressure_events_; } + +private: + bool bounded_ = false; + std::uint64_t budget_ = 0; + std::uint32_t pressure_events_ = 0; +}; + +#endif // IFCVIEWER_GPUBUDGET_H diff --git a/src/ifcviewer/GpuMemory.cpp b/src/ifcviewer/GpuMemory.cpp index dc99ecc44d..e66f5d5039 100644 --- a/src/ifcviewer/GpuMemory.cpp +++ b/src/ifcviewer/GpuMemory.cpp @@ -24,7 +24,7 @@ #include #include -#if defined(__linux__) +#if defined(__linux__) && !defined(__EMSCRIPTEN__) #include #include #endif @@ -32,7 +32,7 @@ namespace ifcviewer { namespace { -#if defined(__linux__) +#if defined(__linux__) && !defined(__EMSCRIPTEN__) // NVML, loaded at run time rather than linked: the viewer must run on machines // with no NVIDIA driver at all, so a link-time dependency is not an option. @@ -179,13 +179,13 @@ bool querySysfs(std::uint32_t vendor_id, std::uint32_t device_id, GpuMemoryInfo& return found; } -#endif // __linux__ +#endif // __linux__ && !__EMSCRIPTEN__ } // namespace GpuMemoryInfo queryGpuMemory(std::uint32_t vendor_id, std::uint32_t device_id) { GpuMemoryInfo info; -#if defined(__linux__) +#if defined(__linux__) && !defined(__EMSCRIPTEN__) if (queryNvml(vendor_id, device_id, info)) return info; if (querySysfs(vendor_id, device_id, info)) return info; #else diff --git a/src/ifcviewer/ModelGpuData.h b/src/ifcviewer/ModelGpuData.h index 14d8fac13a..7558d7e2f8 100644 --- a/src/ifcviewer/ModelGpuData.h +++ b/src/ifcviewer/ModelGpuData.h @@ -475,5 +475,9 @@ struct ModelGpuData { // ranges via `pool.free()`) and clear its size mirrors. Safe to call // repeatedly; idempotent on already-released entries. void releaseWgpuModelGpuData(ModelGpuData& m, BufferPool& pool); +// Just the model's own (non-pool) wgpu buffers: mesh + instance storage +// and the per-chunk cull buffers. Chunk bookkeeping is left intact so the +// buffers can be re-created — the undo step of a failed model load. +void releaseModelBuffers(ModelGpuData& m); #endif // WGPUMODELGPUDATA_H diff --git a/src/ifcviewer/ViewportCore.cpp b/src/ifcviewer/ViewportCore.cpp index 838212a3db..cb93187da4 100644 --- a/src/ifcviewer/ViewportCore.cpp +++ b/src/ifcviewer/ViewportCore.cpp @@ -28,6 +28,7 @@ #endif #include "CameraMath.h" +#include "GpuAllocScope.h" #include "GpuMemory.h" #include "InstanceCompose.h" #include "Log.h" @@ -61,6 +62,17 @@ Eigen::Vector3f orbitEye(const float target[3], float dist, ViewportCore::ViewportCore(ViewportHost* host) : host_(host) {} ViewportCore::~ViewportCore() = default; +void releaseModelBuffers(ModelGpuData& m) { + for (auto& c : m.chunks) { + 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; } + } + if (m.mesh_storage) { wgpuBufferRelease(m.mesh_storage); m.mesh_storage = nullptr; } + if (m.instance_storage) { wgpuBufferRelease(m.instance_storage); m.instance_storage = nullptr; } + m.vram_bytes_ssbo = 0; +} + // Tear down a model's per-chunk GPU resources, free its pool slices, // and reset all the bookkeeping vectors so the slot can be reused. // Static because callers from outside this TU still live in @@ -77,10 +89,8 @@ void releaseWgpuModelGpuData(ModelGpuData& m, BufferPool& pool) { pool.free(c.index_slice); c.index_slice = {}; } - 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; } } + releaseModelBuffers(m); m.chunks.clear(); m.mesh_chunk_idx.clear(); m.mesh_chunk_local_base_vertex.clear(); @@ -90,8 +100,6 @@ void releaseWgpuModelGpuData(ModelGpuData& m, BufferPool& pool) { m.instance_base_vertex.clear(); m.instance_ebo_first_u32.clear(); m.instance_lod1_first_u32.clear(); - if (m.mesh_storage) { wgpuBufferRelease(m.mesh_storage); m.mesh_storage = nullptr; } - if (m.instance_storage) { wgpuBufferRelease(m.instance_storage); m.instance_storage = nullptr; } m.vertex_bytes = 0; m.index_count = 0; m.mesh_count = 0; @@ -1593,22 +1601,160 @@ bool ViewportCore::createPool() { std::max(MIN_POOL_CAPACITY, INITIAL_SUB_BUFFER)); pool_.configure(instance_, device_, pool_usage, per_sub, "ifcviewer-wgpu.pool"); + + // How far the pool may grow. The cache must stop short of what the + // required tier (attachments, model metadata, staging) will need, + // because those are allocated later and the frame cannot be drawn + // without them; see GpuBudget.h for the model. + std::uint64_t device_free = 0; + std::uint64_t hard_cap = 0; #if defined(__EMSCRIPTEN__) - // Cap total pool capacity below the wasm heap ceiling. On web a growth that - // would push the heap past MAXIMUM_MEMORY is a bad_alloc → uncatchable - // abort, so the pool must stop growing (and evict) before then. Leave - // headroom for metadata (instances/maps), transient decompression buffers, - // and wgpu overhead. Big federations then keep a bounded, highest-priority - // resident set instead of aborting. - pool_.setMaxTotalCapacity(3072ull * 1024 * 1024); // 3 GB (heap ceiling 4 GB) + // No memory query on web. Cap total pool capacity below the wasm heap + // ceiling: a growth that would push the heap past MAXIMUM_MEMORY is a + // bad_alloc → uncatchable abort, so the pool must stop growing (and + // evict) before then. Leave headroom for metadata (instances/maps), + // transient decompression buffers, and wgpu overhead. Big federations + // then keep a bounded, highest-priority resident set instead of + // aborting. Device exhaustion below that is learnt through pressure. + hard_cap = 3072ull * 1024 * 1024; // 3 GB (heap ceiling 4 GB) +#else + { + WGPUAdapterInfo adapter_info = WGPU_ADAPTER_INFO_INIT; + wgpuAdapterGetInfo(adapter_, &adapter_info); + const ifcviewer::GpuMemoryInfo mem = + ifcviewer::queryGpuMemory(adapter_info.vendorID, adapter_info.deviceID); + wgpuAdapterInfoFreeMembers(adapter_info); + if (mem.valid) device_free = mem.free_bytes(); + } #endif + budget_.configure(device_free, requiredTierReserveBytes(), hard_cap); + pool_.setMaxTotalCapacity(budget_.bounded() ? budget_.cache_budget_bytes() : 0); + Log::info() << "wgpu: pool per-sub-buffer capacity = " << (per_sub / (1024 * 1024)) << " MB (grows lazily on " << "demand; device maxBufferSize = " << (device_limits.maxBufferSize / (1024 * 1024)) << " MB)"; + if (budget_.bounded()) { + Log::info() << "wgpu: geometry cache budget = " + << (budget_.cache_budget_bytes() / (1024 * 1024)) << " MB" + << (device_free > 0 + ? " (device free " + std::to_string(device_free / (1024 * 1024)) + + " MB - reserve " + + std::to_string(requiredTierReserveBytes() / (1024 * 1024)) + + " MB)" + : " (fixed cap)"); + } else { + Log::info() << "wgpu: geometry cache budget unknown (no device memory " + "query); bounded on first memory-pressure event"; + } return true; } +std::uint64_t ViewportCore::attachmentBytesPerPixel() { + // Sizes follow the formats in ensureDepthTexture / ensureMsaaColorTexture / + // ensureSelectionOutlineTextures / createPickAttachments. + constexpr std::uint64_t msaa = kViewportSampleCount; + return 4 * msaa // msaa colour, 4 B/sample (any 8-bit RGBA surface) + + 4 * msaa // depth32 msaa + + 1 * msaa // selection mask msaa (R8) + + 1 // selection mask resolve (R8) + + 4 // selection scratch (RGBA8) + + 4 + 8 + 16 + 4; // pick: R32Uint id, RGBA16F normal, RGBA32F position, depth32 +} + +std::uint64_t ViewportCore::requiredTierReserveBytes() { + // Attachments at a 4K surface — a user can maximise onto a larger + // monitor after load, and the cache must already have left room — + // plus a fixed margin for per-model metadata buffers, readback + // staging, and the driver's own bookkeeping. + constexpr std::uint64_t kMaxPlausiblePixels = 3840ull * 2160; + constexpr std::uint64_t kFixedMargin = 256ull * 1024 * 1024; + return attachmentBytesPerPixel() * kMaxPlausiblePixels + kFixedMargin; +} + +bool ViewportCore::onRequiredAllocationFailed(const char* what, std::uint64_t bytes) { + const std::uint64_t capacity_before = pool_.total_capacity_bytes(); + const bool lowered = budget_.onPressure(capacity_before, bytes); + const double mb = 1.0 / (1024.0 * 1024.0); + if (!lowered) { + Log::warn() << "[wgpu] out of memory allocating " << what << " (" + << double(bytes) * mb << " MB) and the geometry cache (" + << double(capacity_before) * mb + << " MB) has nothing left to give -- device exhausted"; + return false; + } + pool_.setMaxTotalCapacity(budget_.cache_budget_bytes()); + const std::uint64_t released = pool_.shrinkToCapacity( + budget_.cache_budget_bytes(), + [this](int sub_idx) { evictChunksInSubBuffer(sub_idx); }); + Log::warn() << "[wgpu] out of memory allocating " << what << " (" + << double(bytes) * mb << " MB); geometry cache budget lowered to " + << double(budget_.cache_budget_bytes()) * mb << " MB, released " + << double(released) * mb << " MB (pressure event #" + << budget_.pressure_events() << ")"; +#if !defined(__EMSCRIPTEN__) + // Released buffers are reclaimed once the GPU has finished with them; + // wait for that so the retry that follows sees the memory. On web + // there is no blocking wait — the next frame's attempt will. + wgpuDevicePoll(device_, true, nullptr); +#endif + return released > 0; +} + +void ViewportCore::evictChunksInSubBuffer(int sub_idx) { + for (auto& [session_model_id, m] : models_gpu_) { + for (std::size_t ci = 0; ci < m.chunks.size(); ++ci) { + const auto& c = m.chunks[ci]; + if (!c.is_resident) continue; + if (c.vertex_slice.sub_idx == sub_idx || c.index_slice.sub_idx == sub_idx) { + unloadChunk(m, ci); + } + } + } +} + +bool ViewportCore::allocateRequired(const char* what, std::uint64_t bytes, + const std::function& create, + const std::function& release, + std::function on_web_failure) { +#if defined(__EMSCRIPTEN__) + (void)release; + GpuAllocScope scope(instance_, device_); + create(); + scope.end([this, what, bytes, on_web_failure = std::move(on_web_failure)](bool ok) { + if (ok) return; + onRequiredAllocationFailed(what, bytes); + if (on_web_failure) on_web_failure(); + }); + return true; +#else + (void)on_web_failure; + // Each failed attempt lowers the budget by the allocation plus slack + // and releases at least one sub-buffer, so the loop strictly shrinks + // the cache and ends at the floor — the driver may need more headroom + // than one carve-out (its refusal threshold is not "free == 0"). + for (;;) { + GpuAllocScope scope(instance_, device_); + create(); + bool ok = false; + scope.end([&](bool result) { ok = result; }); + if (ok) return true; + release(); + if (!onRequiredAllocationFailed(what, bytes)) return false; + } +#endif +} + +WGPUBuffer ViewportCore::createRequiredBuffer(const WGPUBufferDescriptor& desc, + const char* what) { + WGPUBuffer buf = nullptr; + const bool ok = allocateRequired( + what, desc.size, + [&]() { buf = wgpuDeviceCreateBuffer(device_, &desc); }, + [&]() { if (buf) { wgpuBufferRelease(buf); buf = nullptr; } }); + return ok ? buf : nullptr; +} + bool ViewportCore::initWgpu(bool web_limits) { #if !defined(__EMSCRIPTEN__) wgpuSetLogCallback(onWgpuLog, nullptr); @@ -3129,6 +3275,64 @@ SidecarData& getOrCreateDirectStaging( } // namespace +bool ViewportCore::createModelBuffers(std::uint32_t session_model_id, + ModelGpuData& m, + const std::vector& mesh_gpu, + const std::vector& inst_gpu) { + const std::size_t mesh_storage_bytes = mesh_gpu.size() * sizeof(MeshGpu); + const std::size_t inst_storage_bytes = inst_gpu.size() * sizeof(InstanceGpu); + std::uint64_t total_bytes = mesh_storage_bytes + inst_storage_bytes; + for (const auto& c : m.chunks) { + total_bytes += c.visible_draws_capacity * sizeof(ModelGpuData::VisibleDrawGpu) + + c.prefix_sums_capacity * sizeof(std::uint32_t) + 16; + } + + return allocateRequired( + "model buffers", total_bytes, + [&]() { + for (auto& chunk : m.chunks) { + WGPUBufferDescriptor vd_desc = {}; + vd_desc.size = std::max( + chunk.visible_draws_capacity * sizeof(ModelGpuData::VisibleDrawGpu), 16); + vd_desc.usage = WGPUBufferUsage_Storage | WGPUBufferUsage_CopyDst; + vd_desc.label = svFromCStr("model.chunk.visible_draws"); + chunk.visible_draws_buffer = wgpuDeviceCreateBuffer(device_, &vd_desc); + m.vram_bytes_ssbo += vd_desc.size; + + WGPUBufferDescriptor ps_desc = {}; + ps_desc.size = std::max( + chunk.prefix_sums_capacity * sizeof(std::uint32_t), 16); + ps_desc.usage = WGPUBufferUsage_Storage | WGPUBufferUsage_CopyDst; + ps_desc.label = svFromCStr("model.chunk.prefix_sums"); + chunk.prefix_sums_buffer = wgpuDeviceCreateBuffer(device_, &ps_desc); + m.vram_bytes_ssbo += ps_desc.size; + + WGPUBufferDescriptor mu_desc = {}; + mu_desc.size = 16; + mu_desc.usage = WGPUBufferUsage_Uniform | WGPUBufferUsage_CopyDst; + mu_desc.label = svFromCStr("model.chunk.uniform"); + chunk.per_chunk_uniform = wgpuDeviceCreateBuffer(device_, &mu_desc); + m.vram_bytes_ssbo += 16; + } + m.mesh_storage = createBufferWithData( + device_, queue_, mesh_gpu.data(), mesh_storage_bytes, + WGPUBufferUsage_Storage, "model.mesh_storage"); + m.vram_bytes_ssbo += mesh_storage_bytes; + m.instance_storage = createBufferWithData( + device_, queue_, inst_gpu.data(), inst_storage_bytes, + WGPUBufferUsage_Storage, "model.instance_storage"); + m.vram_bytes_ssbo += inst_storage_bytes; + }, + [&]() { releaseModelBuffers(m); }, + // Web hears of the failure after the model is in the scene; its + // buffers are error objects that would fail every bind, so drop it. + [this, session_model_id]() { + Log::warn() << "[wgpu] model " << session_model_id + << " removed: the device could not fit its metadata buffers"; + removeModel(session_model_id); + }); +} + void ViewportCore::applyCachedModel(std::uint32_t session_model_id, StreamingSidecar metadata) { if (!device_ || !queue_) { @@ -3306,35 +3510,12 @@ void ViewportCore::applyCachedModel(std::uint32_t session_model_id, model_gpu_data.vertex_bytes += chunk.vertex_byte_size; model_gpu_data.index_count += std::uint32_t(chunk.index_count); - // Small per-chunk buffers, allocated upfront so cull can write into - // them. visible_draws_buffer cap = chunk's instance count. + // Per-chunk cull buffer capacities: visible_draws cap = the chunk's + // instance count. The buffers themselves are created together with + // the model's other buffers in createModelBuffers below. const std::size_t chunk_inst = std::max(chunk_instance_count[chunk_index], 1); - const std::size_t draws_bytes = chunk_inst * sizeof(ModelGpuData::VisibleDrawGpu); - const std::size_t ps_bytes = (chunk_inst + 1) * sizeof(std::uint32_t); - - WGPUBufferDescriptor vd_desc = {}; - vd_desc.size = std::max(draws_bytes, 16); - vd_desc.usage = WGPUBufferUsage_Storage | WGPUBufferUsage_CopyDst; - vd_desc.label = svFromCStr("model.chunk.visible_draws"); - chunk.visible_draws_buffer = wgpuDeviceCreateBuffer(device_, &vd_desc); chunk.visible_draws_capacity = chunk_inst; - model_gpu_data.vram_bytes_ssbo += vd_desc.size; - - WGPUBufferDescriptor ps_desc = {}; - ps_desc.size = std::max(ps_bytes, 16); - ps_desc.usage = WGPUBufferUsage_Storage | WGPUBufferUsage_CopyDst; - ps_desc.label = svFromCStr("model.chunk.prefix_sums"); - chunk.prefix_sums_buffer = wgpuDeviceCreateBuffer(device_, &ps_desc); - chunk.prefix_sums_capacity = chunk_inst + 1; - model_gpu_data.vram_bytes_ssbo += ps_desc.size; - - WGPUBufferDescriptor mu_desc = {}; - mu_desc.size = 16; - mu_desc.usage = WGPUBufferUsage_Uniform | WGPUBufferUsage_CopyDst; - mu_desc.label = svFromCStr("model.chunk.uniform"); - chunk.per_chunk_uniform = wgpuDeviceCreateBuffer(device_, &mu_desc); - model_gpu_data.vram_bytes_ssbo += 16; - + chunk.prefix_sums_capacity = chunk_inst + 1; chunk.visible_draws_scratch.reserve(chunk_inst); chunk.prefix_sums_scratch.reserve(chunk_inst + 1); } @@ -3355,13 +3536,6 @@ void ViewportCore::applyCachedModel(std::uint32_t session_model_id, mesh_gpu_record.aabb_max[2] = mesh_info.local_aabb_max[2]; mesh_gpu.push_back(mesh_gpu_record); } - const std::size_t mesh_storage_bytes = mesh_gpu.size() * sizeof(MeshGpu); - model_gpu_data.mesh_storage = createBufferWithData( - device_, queue_, - mesh_gpu.data(), mesh_storage_bytes, - WGPUBufferUsage_Storage, - "model.mesh_storage"); - model_gpu_data.vram_bytes_ssbo += mesh_storage_bytes; // InstanceGpu storage. Rebase object_ids globally. const std::uint32_t object_id_base = next_object_id_; @@ -3380,13 +3554,12 @@ void ViewportCore::applyCachedModel(std::uint32_t session_model_id, } next_object_id_ = object_id_base + max_local_id + 1; model_gpu_data.object_id_base = object_id_base; // element metadata records rebase to match - const std::size_t inst_storage_bytes = inst_gpu.size() * sizeof(InstanceGpu); - model_gpu_data.instance_storage = createBufferWithData( - device_, queue_, - inst_gpu.data(), inst_storage_bytes, - WGPUBufferUsage_Storage, - "model.instance_storage"); - model_gpu_data.vram_bytes_ssbo += inst_storage_bytes; + + if (!createModelBuffers(session_model_id, model_gpu_data, mesh_gpu, inst_gpu)) { + Log::warn() << "[wgpu] model " << session_model_id + << " not loaded: the device cannot fit its metadata buffers"; + return; + } // Hand off CPU mirrors. model_gpu_data.meshes = std::move(metadata.meta.meshes); @@ -4473,21 +4646,7 @@ void ViewportCore::ensureHizTextures(int viewport_w, int viewport_h) { if (dst_w == hiz_resolve_w_ && dst_h == hiz_resolve_h_ && hiz_resolve_view_) return; - if (hiz_resolve_view_) { wgpuTextureViewRelease(hiz_resolve_view_); hiz_resolve_view_ = nullptr; } - if (hiz_resolve_texture_) { wgpuTextureRelease(hiz_resolve_texture_); hiz_resolve_texture_ = nullptr; } - for (int s = 0; s < HIZ_SLOTS; ++s) { - if (hiz_staging_buffers_[s]) { - if (hiz_slot_state_[s] == HizSlotState::Mapped) { - wgpuBufferUnmap(hiz_staging_buffers_[s]); - } - wgpuBufferRelease(hiz_staging_buffers_[s]); - hiz_staging_buffers_[s] = nullptr; - } - hiz_slot_state_[s] = HizSlotState::Idle; - } - hiz_write_idx_ = 0; - hiz_valid_ = false; - if (hiz_bind_group_) { wgpuBindGroupRelease(hiz_bind_group_); hiz_bind_group_ = nullptr; } + releaseHizTextures(); WGPUTextureDescriptor desc = {}; desc.usage = WGPUTextureUsage_RenderAttachment | WGPUTextureUsage_CopySrc; @@ -4530,9 +4689,8 @@ void ViewportCore::ensureHizTextures(int viewport_w, int viewport_h) { hiz_valid_ = false; } -void ViewportCore::releaseHizResources() { +void ViewportCore::releaseHizTextures() { if (hiz_bind_group_) { wgpuBindGroupRelease(hiz_bind_group_); hiz_bind_group_ = nullptr; } - if (hiz_uniform_buffer_) { wgpuBufferRelease(hiz_uniform_buffer_); hiz_uniform_buffer_ = nullptr; } if (hiz_resolve_view_) { wgpuTextureViewRelease(hiz_resolve_view_); hiz_resolve_view_ = nullptr; } if (hiz_resolve_texture_) { wgpuTextureRelease(hiz_resolve_texture_); hiz_resolve_texture_ = nullptr; } for (int s = 0; s < HIZ_SLOTS; ++s) { @@ -4546,12 +4704,17 @@ void ViewportCore::releaseHizResources() { hiz_slot_state_[s] = HizSlotState::Idle; } hiz_write_idx_ = 0; + hiz_resolve_w_ = hiz_resolve_h_ = hiz_padded_bpr_ = 0; + hiz_valid_ = false; +} + +void ViewportCore::releaseHizResources() { + releaseHizTextures(); + if (hiz_uniform_buffer_) { wgpuBufferRelease(hiz_uniform_buffer_); hiz_uniform_buffer_ = nullptr; } if (hiz_pipeline_) { wgpuRenderPipelineRelease(hiz_pipeline_); hiz_pipeline_ = nullptr; } if (hiz_shader_module_) { wgpuShaderModuleRelease(hiz_shader_module_); hiz_shader_module_ = nullptr; } if (hiz_pipeline_layout_) { wgpuPipelineLayoutRelease(hiz_pipeline_layout_); hiz_pipeline_layout_ = nullptr; } if (hiz_bgl_) { wgpuBindGroupLayoutRelease(hiz_bgl_); hiz_bgl_ = nullptr; } - hiz_resolve_w_ = hiz_resolve_h_ = hiz_padded_bpr_ = 0; - hiz_valid_ = false; hiz_pyramid_.clear(); hiz_mip_offset_.clear(); hiz_mip_w_.clear(); @@ -4839,6 +5002,44 @@ bool ViewportCore::aabbOccludedByHiz(const float mn[3], const float mx[3]) const return rejected; } +bool ViewportCore::ensureRenderAttachments(int width_px, int height_px) { + const std::uint64_t bytes = + attachmentBytesPerPixel() * std::uint64_t(width_px) * std::uint64_t(height_px); + const bool ok = allocateRequired( + "render attachments", bytes, + [&]() { + ensureDepthTexture(width_px, height_px); + ensureMsaaColorTexture(width_px, height_px); + ensureHizTextures(width_px, height_px); + ensureSelectionOutlineTextures(width_px, height_px); + createPickAttachments(width_px, height_px); + }, + [&]() { releaseRenderAttachments(); }, + // Web learns of the failure after the views are already in use: + // drop the set and reconfigure against the now-smaller cache. + [this]() { + releaseRenderAttachments(); + int w = 0, h = 0; + host_->framebufferSize(w, h); + if (w > 0 && h > 0) configureSurface(w, h); + host_->requestFrame(); + }); + if (!ok && render_attachments_ok_) { + Log::warn() << "[wgpu] render attachments unavailable at " << width_px + << "x" << height_px << "; frames are skipped until memory frees up"; + } + render_attachments_ok_ = ok; + return ok; +} + +void ViewportCore::releaseRenderAttachments() { + releaseDepthTexture(); + releaseMsaaColorTexture(); + releaseHizTextures(); + releaseSelectionOutlineTextures(); + releasePickResources(); +} + void ViewportCore::ensureDepthTexture(int w, int h) { if (w == depth_w_ && h == depth_h_ && depth_view_) return; releaseDepthTexture(); @@ -5736,8 +5937,18 @@ bool ViewportCore::buildBoxPickPipeline() { return true; } -void ViewportCore::ensurePickAttachments(int w, int h) { - if (w <= 0 || h <= 0) return; +bool ViewportCore::ensurePickAttachments(int w, int h) { + if (w <= 0 || h <= 0) return false; + if (w == pick_w_ && h == pick_h_ && pick_color_view_) return true; + constexpr std::uint64_t kPickBytesPerPixel = 4 + 8 + 16 + 4; + const std::uint64_t bytes = kPickBytesPerPixel * std::uint64_t(w) * std::uint64_t(h); + return allocateRequired( + "pick attachments", bytes, + [&]() { createPickAttachments(w, h); }, + [&]() { releasePickResources(); }); +} + +void ViewportCore::createPickAttachments(int w, int h) { if (w == pick_w_ && h == pick_h_ && pick_color_view_) return; if (pick_color_view_) { wgpuTextureViewRelease(pick_color_view_); pick_color_view_ = nullptr; } @@ -5964,8 +6175,8 @@ std::uint32_t ViewportCore::pickObjectAt(int x_pixels, int y_pixels, if (x_pixels < 0 || y_pixels < 0 || x_pixels >= configured_w_ || y_pixels >= configured_h_) return 0; - ensurePickAttachments(configured_w_, configured_h_); - if (!pick_color_view_ || !pick_depth_view_ || !pick_staging_buffer_) return 0; + if (!ensurePickAttachments(configured_w_, configured_h_) + || !pick_staging_buffer_) return 0; if (normal_out && !pick_normal_staging_buffer_) return 0; encodePickReadbackToStaging(x_pixels, y_pixels, normal_out != nullptr); @@ -6193,8 +6404,8 @@ void ViewportCore::pickObjectAtAsync(int x_pixels, int y_pixels, if (x_pixels < 0 || y_pixels < 0 || x_pixels >= configured_w_ || y_pixels >= configured_h_) { miss(0); return; } - ensurePickAttachments(configured_w_, configured_h_); - if (!pick_color_view_ || !pick_depth_view_ || !pick_staging_buffer_) { miss(0); return; } + if (!ensurePickAttachments(configured_w_, configured_h_) + || !pick_staging_buffer_) { miss(0); return; } // One pick in flight at a time. Clicks are far slower than a readback, so // dropping a pick issued while another is mapping is acceptable (and @@ -6239,8 +6450,7 @@ bool ViewportCore::encodeBoxPickToStaging(int& x, int& y, int& w, int& h, if (y + h > configured_h_) h = configured_h_ - y; if (w <= 0 || h <= 0) return false; - ensurePickAttachments(configured_w_, configured_h_); - if (!pick_color_view_ || !pick_depth_view_) return false; + if (!ensurePickAttachments(configured_w_, configured_h_)) return false; // Padded bytes-per-row. R32UInt = 4 B/texel; align to 256 B. constexpr std::uint64_t kWgpuBytesPerRowAlign = 256; @@ -6259,8 +6469,8 @@ bool ViewportCore::encodeBoxPickToStaging(int& x, int& y, int& w, int& h, sb.size = cap; sb.usage = WGPUBufferUsage_CopyDst | WGPUBufferUsage_MapRead; sb.label = svFromCStr("ifcviewer-wgpu.box_pick_staging"); - box_pick_staging_buffer_ = wgpuDeviceCreateBuffer(device_, &sb); - box_pick_staging_capacity_ = cap; + box_pick_staging_buffer_ = createRequiredBuffer(sb, "box pick staging"); + box_pick_staging_capacity_ = box_pick_staging_buffer_ ? cap : 0; } if (!box_pick_staging_buffer_) return false; @@ -6374,8 +6584,7 @@ bool ViewportCore::encodeXrayBoxPickToStaging(int& x, int& y, int& w, int& h, if (y + h > configured_h_) h = configured_h_ - y; if (w <= 0 || h <= 0) return false; - ensurePickAttachments(configured_w_, configured_h_); - if (!pick_depth_view_) return false; + if (!ensurePickAttachments(configured_w_, configured_h_)) return false; const std::uint64_t needed_bytes = std::uint64_t(hit_flags_words_) * sizeof(std::uint32_t); if (needed_bytes > hit_flags_staging_capacity_) { @@ -6388,8 +6597,8 @@ bool ViewportCore::encodeXrayBoxPickToStaging(int& x, int& y, int& w, int& h, sb.size = cap; sb.usage = WGPUBufferUsage_CopyDst | WGPUBufferUsage_MapRead; sb.label = svFromCStr("ifcviewer-wgpu.hit_flags_staging"); - hit_flags_staging_buffer_ = wgpuDeviceCreateBuffer(device_, &sb); - hit_flags_staging_capacity_ = cap; + hit_flags_staging_buffer_ = createRequiredBuffer(sb, "hit flags staging"); + hit_flags_staging_capacity_ = hit_flags_staging_buffer_ ? cap : 0; } if (!hit_flags_staging_buffer_) return false; @@ -6701,9 +6910,8 @@ void ViewportCore::pickSurfaceAtAsync(int x_pixels, int y_pixels, if (configured_w_ <= 0 || configured_h_ <= 0) { miss(); return; } if (x_pixels < 0 || y_pixels < 0 || x_pixels >= configured_w_ || y_pixels >= configured_h_) { miss(); return; } - ensurePickAttachments(configured_w_, configured_h_); - if (!pick_color_view_ || !pick_depth_view_ || - !pick_staging_buffer_ || !pick_normal_staging_buffer_) { miss(); return; } + if (!ensurePickAttachments(configured_w_, configured_h_) + || !pick_staging_buffer_ || !pick_normal_staging_buffer_) { miss(); return; } // Shares the single-pick staging buffers → shares the in-flight guard. if (pick_async_in_flight_) { miss(); return; } pick_async_in_flight_ = true; @@ -7154,10 +7362,7 @@ void ViewportCore::configureSurface(int width_px, int height_px) { configured_w_ = width_px; configured_h_ = height_px; surface_configured_ = true; - ensureDepthTexture(width_px, height_px); - ensureMsaaColorTexture(width_px, height_px); - ensureHizTextures(width_px, height_px); - ensureSelectionOutlineTextures(width_px, height_px); + ensureRenderAttachments(width_px, height_px); // depth_view_ was just replaced; force the HiZ + edge bind groups // to rebuild against the new view on next encode. if (hiz_bind_group_) { @@ -7210,7 +7415,8 @@ WGPUBuffer ViewportCore::encodeScreenshotCapture( bdesc.size = total_bytes; bdesc.usage = WGPUBufferUsage_CopyDst | WGPUBufferUsage_MapRead; bdesc.label = svFromCStr("ifcviewer-wgpu.capture"); - WGPUBuffer capture_buffer = wgpuDeviceCreateBuffer(device_, &bdesc); + WGPUBuffer capture_buffer = createRequiredBuffer(bdesc, "screenshot capture"); + if (!capture_buffer) return nullptr; WGPUTexelCopyTextureInfo src = {}; src.texture = surface_texture; @@ -7342,6 +7548,12 @@ void ViewportCore::render() { // we don't busy-loop reconfiguring a dead surface — that retry storm is // what otherwise freezes the tab. The page logs guidance to reload. if (device_lost_) return; + // configureSurface could not fit the per-pixel attachments even after + // the geometry cache yielded. Drawing would submit invalid views (an + // abort on wgpu-native), so try again — memory may have been freed by + // another process since — and skip the frame if it still does not fit. + if (!render_attachments_ok_ + && !ensureRenderAttachments(configured_w_, configured_h_)) return; Stopwatch frame_timer; frame_timer.start(); @@ -7738,6 +7950,7 @@ void ViewportCore::render() { stats.indirect_sub_draws = last_sub_draws_; stats.vram_used_bytes = pool_.total_used_bytes(); stats.vram_capacity_bytes = pool_.total_capacity_bytes(); + stats.vram_budget_bytes = budget_.bounded() ? budget_.cache_budget_bytes() : 0; #if !defined(__EMSCRIPTEN__) if (!device_vram_poll_timer_.isValid() || device_vram_poll_timer_.elapsed() >= 1000) { diff --git a/src/ifcviewer/ViewportCore.h b/src/ifcviewer/ViewportCore.h index 0e4c692f22..5f5c22c932 100644 --- a/src/ifcviewer/ViewportCore.h +++ b/src/ifcviewer/ViewportCore.h @@ -49,6 +49,7 @@ #include "AxisIndicatorRenderer.h" #include "BufferPool.h" +#include "GpuBudget.h" #include "InstanceCompose.h" #include "InstancedGeometry.h" #include "ModelGpuData.h" @@ -481,6 +482,12 @@ public: // brings them in. Triggers an auto-viewAll on the first model (so a // freshly-loaded scene frames itself). void applyCachedModel(std::uint32_t session_model_id, StreamingSidecar metadata); + // The model's required-tier buffers (mesh + instance storage, per-chunk + // cull buffers) as one allocation unit — see allocateRequired. False + // when the device cannot fit them even after the cache yielded. + bool createModelBuffers(std::uint32_t session_model_id, ModelGpuData& m, + const std::vector& mesh_gpu, + const std::vector& inst_gpu); // Qt-free sidecar load: readSidecarMetadata + applyCachedModel. // Used by the web build (and any other non-Qt embedder) so the @@ -705,6 +712,9 @@ public: // dimensions match. Resets ping-pong state so any in-flight map is // dropped (caller already ensured the surface resize blocked). void ensureHizTextures(int viewport_w, int viewport_h); + // Drop just the resolve texture + staging buffers (pipeline stays), + // resetting the ping-pong state. ensureHizTextures recreates them. + void releaseHizTextures(); // Tear down every HiZ-owned wgpu resource (pipeline + textures + // staging buffers + pyramid). Called from shutdown() before @@ -800,8 +810,13 @@ public: bool buildPickPipeline(); // (Re)allocate the pick MRT attachments + readback staging buffers - // to the supplied size. Idempotent when dimensions match. - void ensurePickAttachments(int w, int h); + // to the supplied size. Idempotent when dimensions match. Created + // eagerly with the other attachments in configureSurface; the pick + // entry points call it again only as the retry after a pressure + // shrink, and bail when it returns false. + bool ensurePickAttachments(int w, int h); + // The raw (unscoped) creation ensurePickAttachments wraps. + void createPickAttachments(int w, int h); // Encode the one-shot pick pass + copy the (x, y) texel into the pick // staging buffer(s) and submit. Shared by the sync (pickObjectAt) and @@ -1052,6 +1067,63 @@ public: private: bool createPool(); + // ---- Memory tiers (see GpuBudget.h) ------------------------------------ + // + // Every allocation the frame cannot do without — the per-pixel + // attachments, a model's metadata buffers, readback staging — is + // "required" and goes through one of these so an out-of-memory is + // observed and answered by shrinking the geometry cache, instead of + // surfacing as an invalid resource that aborts in wgpuQueueSubmit. + + // Reserve to hold back from the cache for the required tier: the + // per-pixel attachments at the largest plausible surface plus margin. + static std::uint64_t requiredTierReserveBytes(); + // Bytes every per-pixel attachment set costs (MSAA colour + depth, + // selection mask trio, pick MRT + depth) — used to size both the + // reserve and the pressure carve-out when an attachment set fails. + static std::uint64_t attachmentBytesPerPixel(); + + // A required allocation of `bytes` (`what` names it for the log) + // failed. Lowers the budget, evicts and releases cache sub-buffers + // down to it, and on desktop waits for the device to actually reclaim + // them so an immediate retry can succeed. Returns false when the + // cache had nothing left to give: the device is exhausted and the + // caller degrades (skips the operation) rather than retrying. + bool onRequiredAllocationFailed(const char* what, std::uint64_t bytes); + // Unload every resident chunk whose slices live in pool sub-buffer + // `sub_idx`; the evictor BufferPool::shrinkToCapacity calls before it + // releases that sub-buffer. + void evictChunksInSubBuffer(int sub_idx); + + // Run `create` (one or more wgpu allocations totalling ~`bytes`) under + // an allocation scope. Desktop: verified synchronously; on failure + // `release` undoes the attempt, the cache yields, and `create` runs + // again, until it succeeds or the cache has nothing left to give + // (false). Web: the resources are used + // provisionally and true is returned; if the scope later reports a + // failure the cache yields and `on_web_failure` (if any) corrects + // course, since the caller has long since moved on. + bool allocateRequired(const char* what, std::uint64_t bytes, + const std::function& create, + const std::function& release, + std::function on_web_failure = {}); + // allocateRequired for a single buffer: the buffer, or null when the + // device could not fit it even after the cache yielded. + WGPUBuffer createRequiredBuffer(const WGPUBufferDescriptor& desc, + const char* what); + + // (Re)create every per-pixel attachment for a width_px × height_px + // surface as one required allocation. False when they could not be + // allocated even after the cache yielded; render() then skips the + // frame rather than submitting with invalid views. + bool ensureRenderAttachments(int width_px, int height_px); + void releaseRenderAttachments(); + + GpuBudget budget_; + // Latched false by ensureRenderAttachments when the device could not + // fit the attachments; re-evaluated on the next configureSurface. + bool render_attachments_ok_ = true; + // The scene's models in load order (ascending session_model_id, minted at // request time — see loadSidecarMetadataWeb). Every per-model API indexes // against this, so a model keeps a stable UI slot instead of hopping with diff --git a/src/ifcviewer/tests/CMakeLists.txt b/src/ifcviewer/tests/CMakeLists.txt index 42a953a6f2..94c1c2fb27 100644 --- a/src/ifcviewer/tests/CMakeLists.txt +++ b/src/ifcviewer/tests/CMakeLists.txt @@ -109,6 +109,12 @@ endif() add_ifcviewer_unit_test(test_selection) add_ifcviewer_unit_test(test_visibility) +# GpuBudget: the pure policy deciding how much device memory the geometry +# cache may hold and how it yields under pressure. No wgpu at all. +add_ifcviewer_unit_test(test_gpu_budget + SOURCES ${IFCVIEWER_SRC}/GpuBudget.cpp +) + # BufferPool sub-allocator invariants. The pool's wgpu calls live inside # addSubBuffer() (the growth path); tests use the addSubBufferForTesting # seam to preseed sub-pools with fake handles, so the only wgpu touchpoint @@ -117,7 +123,7 @@ add_ifcviewer_unit_test(test_visibility) # the pool go out of scope holding any. Linking wgpu_native satisfies the # symbol regardless. add_ifcviewer_unit_test(test_buffer_pool - SOURCES ${IFCVIEWER_SRC}/BufferPool.cpp + SOURCES ${IFCVIEWER_SRC}/BufferPool.cpp ${IFCVIEWER_SRC}/GpuAllocScope.cpp LIBS wgpu_native ) if(UNIX AND NOT APPLE AND WGPU_NATIVE_LIB_DIR) diff --git a/src/ifcviewer/tests/test_buffer_pool.cpp b/src/ifcviewer/tests/test_buffer_pool.cpp index 8d1391d498..320f9fe314 100644 --- a/src/ifcviewer/tests/test_buffer_pool.cpp +++ b/src/ifcviewer/tests/test_buffer_pool.cpp @@ -34,6 +34,7 @@ #include #include +#include namespace { @@ -252,3 +253,62 @@ TEST_CASE("free with invalid slice is a no-op", "[buffer_pool]") { pool.free(a); REQUIRE(pool.total_used_bytes() == 0); } + +// ---- Budget ceiling + shrink (the cache yielding to the required tier) ---- + +TEST_CASE("can_grow respects the total-capacity budget with sub-buffer granularity", "[buffer_pool]") { + BufferPool pool; + FakePoolGuard guard{pool}; + // No device configured, so can_grow is false regardless; the budget + // arithmetic is what we check via max_total_capacity_bytes. + pool.setMaxTotalCapacity(256ull * 1024 * 1024); + REQUIRE(pool.max_total_capacity_bytes() == 256ull * 1024 * 1024); + pool.addSubBufferForTesting(fake_handle(1), 200ull * 1024 * 1024); + // 200 MB held + 64 MB floor > 256 MB budget: a grow could not fit. + REQUIRE_FALSE(pool.can_grow()); +} + +TEST_CASE("shrinkToCapacity releases sub-buffers newest-first after the owner empties them", "[buffer_pool]") { + BufferPool pool; + FakePoolGuard guard{pool}; + pool.addSubBufferForTesting(fake_handle(1), 1024); + pool.addSubBufferForTesting(fake_handle(2), 1024); + pool.addSubBufferForTesting(fake_handle(3), 1024); + + auto a = pool.alloc(256, 16); // sub 0 + auto b = pool.alloc(1024, 16); // sub 1 (sub 0 has only 768 left) + auto c = pool.alloc(512, 16); // sub 0 again (first fit) + auto d = pool.alloc(512, 16); // sub 2 + REQUIRE(a.sub_idx == 0); + REQUIRE(b.sub_idx == 1); + REQUIRE(c.sub_idx == 0); + REQUIRE(d.sub_idx == 2); + + std::vector evicted; + auto evict = [&](int sub_idx) { + evicted.push_back(sub_idx); + if (sub_idx == 2) pool.free(d); + if (sub_idx == 1) pool.free(b); + if (sub_idx == 0) { pool.free(a); pool.free(c); } + }; + + // Shrink to 1024: drops sub 2 then sub 1; sub 0 and its slices survive + // with their sub_idx still valid. + const uint64_t released = pool.shrinkToCapacity(1024, evict); + REQUIRE(released == 2048); + REQUIRE(evicted == std::vector{2, 1}); + REQUIRE(pool.sub_buffer_count() == 1); + REQUIRE(pool.total_capacity_bytes() == 1024); + REQUIRE(pool.total_used_bytes() == 256 + 512); + REQUIRE(pool.largest_free_run_bytes() == 256); + + // Already at or below target: nothing happens, evictor not consulted. + evicted.clear(); + REQUIRE(pool.shrinkToCapacity(1024, evict) == 0); + REQUIRE(evicted.empty()); + + // Shrinking to zero empties the pool entirely. + REQUIRE(pool.shrinkToCapacity(0, evict) == 1024); + REQUIRE(pool.sub_buffer_count() == 0); + REQUIRE(evicted == std::vector{0}); +} diff --git a/src/ifcviewer/tests/test_gpu_budget.cpp b/src/ifcviewer/tests/test_gpu_budget.cpp new file mode 100644 index 0000000000..d1964972cd --- /dev/null +++ b/src/ifcviewer/tests/test_gpu_budget.cpp @@ -0,0 +1,112 @@ +/******************************************************************************** + * * + * 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 . * + * * + ********************************************************************************/ + +// GpuBudget decides how much device memory the streamed-geometry cache may +// hold. It is pure policy: a number derived from what the platform can +// tell us (desktop: driver free-memory report; web: nothing but a heap +// ceiling) and lowered by pressure events when a required allocation fails +// anyway. These pin down the arithmetic and the floor behaviour. + +#include "GpuBudget.h" + +#include + +namespace { +constexpr std::uint64_t MB = 1024ull * 1024; +} + +TEST_CASE("unknown device memory and no cap leaves the cache unbounded", "[gpu_budget]") { + GpuBudget b; + b.configure(0, 512 * MB, 0); + REQUIRE_FALSE(b.bounded()); +} + +TEST_CASE("desktop budget is free memory minus the required-tier reserve", "[gpu_budget]") { + GpuBudget b; + b.configure(2800 * MB, 800 * MB, 0); + REQUIRE(b.bounded()); + REQUIRE(b.cache_budget_bytes() == 2000 * MB); +} + +TEST_CASE("a reserve larger than free memory floors the budget, not zero", "[gpu_budget]") { + GpuBudget b; + b.configure(300 * MB, 800 * MB, 0); + REQUIRE(b.bounded()); + REQUIRE(b.cache_budget_bytes() == GpuBudget::kMinCacheBudgetBytes); +} + +TEST_CASE("a hard cap bounds the cache on its own (web) and clamps a device-derived budget", "[gpu_budget]") { + GpuBudget web; + web.configure(0, 0, 3072 * MB); + REQUIRE(web.bounded()); + REQUIRE(web.cache_budget_bytes() == 3072 * MB); + + GpuBudget both; + both.configure(8000 * MB, 800 * MB, 3072 * MB); + REQUIRE(both.cache_budget_bytes() == 3072 * MB); + + GpuBudget small_device; + small_device.configure(2800 * MB, 800 * MB, 3072 * MB); + REQUIRE(small_device.cache_budget_bytes() == 2000 * MB); +} + +TEST_CASE("pressure lowers the budget below what the cache currently holds", "[gpu_budget]") { + GpuBudget b; + b.configure(0, 0, 0); + REQUIRE_FALSE(b.bounded()); + + // The pool grew to 2048 MB unbounded; a 120 MB attachment set then failed. + REQUIRE(b.onPressure(2048 * MB, 120 * MB)); + REQUIRE(b.bounded()); + REQUIRE(b.cache_budget_bytes() + == 2048 * MB - 120 * MB - GpuBudget::kPressureSlackBytes); + REQUIRE(b.pressure_events() == 1); +} + +TEST_CASE("pressure is measured against actual capacity, not the previous budget", "[gpu_budget]") { + // Budget said 2000 MB but the driver only ever granted 1024 MB; a + // failure must carve out of the 1024, else nothing would be released. + GpuBudget b; + b.configure(2800 * MB, 800 * MB, 0); + REQUIRE(b.onPressure(1024 * MB, 100 * MB)); + REQUIRE(b.cache_budget_bytes() + == 1024 * MB - 100 * MB - GpuBudget::kPressureSlackBytes); +} + +TEST_CASE("pressure never raises the budget", "[gpu_budget]") { + GpuBudget b; + b.configure(0, 0, 500 * MB); + // Pool is at 256 MB (below budget) and a tiny allocation fails: + // capacity - carve is 224 MB-ish, which IS lower, so it lowers. + REQUIRE(b.onPressure(256 * MB, 0)); + REQUIRE(b.cache_budget_bytes() == 256 * MB - GpuBudget::kPressureSlackBytes); + // A later event whose arithmetic lands above the current budget is a no-op. + REQUIRE_FALSE(b.onPressure(4096 * MB, 0)); + REQUIRE(b.cache_budget_bytes() == 256 * MB - GpuBudget::kPressureSlackBytes); +} + +TEST_CASE("pressure bottoms out at the floor and then reports exhaustion", "[gpu_budget]") { + GpuBudget b; + b.configure(0, 0, 0); + REQUIRE(b.onPressure(100 * MB, 90 * MB)); + REQUIRE(b.cache_budget_bytes() == GpuBudget::kMinCacheBudgetBytes); + // Already at the floor: nothing more to give. + REQUIRE_FALSE(b.onPressure(64 * MB, 90 * MB)); + REQUIRE(b.pressure_events() == 2); +}