ifcviewer: budget the geometry cache and make required allocations fallible

Loading enough models drove the chunk pool to the driver's refusal point,
after which the first click aborted: the pick attachments are allocated
lazily, wgpu-native reported their OOM as a validation error nobody
observed, and the invalid views reached wgpuQueueSubmit, which panics
across the FFI boundary. Two policy defects compounding: the cache was
allowed to take the last byte, and nothing but the pool's own growth was
treated as fallible.

GPU memory is now two tiers. Required allocations (per-pixel attachments,
a model's metadata buffers, readback staging) are eager, deterministic
and fallible; the chunk pool is an elastic cache that grows only to a
budget and yields whenever a required allocation fails.

- GpuBudget (pure, unit-tested): desktop derives the budget from the
  driver's free-memory report minus a reserve for the attachments at 4K;
  web keeps the wasm-heap cap; either lowers it on pressure. The budget's
  source differs per platform, the mechanism does not.
- GpuAllocScope: the OOM/Validation error-scope dance in one place,
  synchronous on wgpu-native, provisional on Dawn-web. BufferPool's
  inline copy now uses it.
- BufferPool::shrinkToCapacity releases whole sub-buffers newest-first
  after the owner empties them; growth clamps to the budget instead of
  overshooting.
- ViewportCore::allocateRequired runs any required creation under a
  scope and, on failure, lowers the budget, evicts and releases cache
  sub-buffers, waits for the device to reclaim them, and retries until
  it fits or the cache is at its floor. Pick attachments are created with
  the other attachments in configureSurface; render() skips a frame
  rather than submit invalid views; a model whose buffers cannot fit is
  not loaded instead of aborting.

Verified on a 4 GB GeForce: the pool clamps itself at the derived budget
(256+256+67 MB for a 579 MB budget) and, in a standalone check against
the real device, a pool grown to the driver's refusal point observes a
failed required allocation, releases 320 MB and succeeds on retry.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Dion Moult
2026-08-23 14:07:08 +10:00
parent b7d2b2fa3a
commit ab99024307
16 changed files with 971 additions and 149 deletions
+5 -1
View File
@@ -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")
+55 -43
View File
@@ -18,7 +18,9 @@
********************************************************************************/
#include "BufferPool.h"
#include "GpuAllocScope.h"
#include <algorithm>
#include <cassert>
#include <cstdio>
#include <cstring>
@@ -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<BufferPool*>(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<PopResult*>(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<void(int sub_idx)>& 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));
}
+26 -1
View File
@@ -23,6 +23,7 @@
#include <webgpu/webgpu.h>
#include <cstdint>
#include <functional>
#include <string>
#include <vector>
@@ -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<void(int sub_idx)>& 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_
+2
View File
@@ -145,6 +145,8 @@ set(IFCVIEWER_CORE_SOURCES
InstanceCompose.cpp
LodBuilder.cpp
FederationMath.cpp
GpuAllocScope.cpp
GpuBudget.cpp
GpuMemory.cpp
SidecarCache.cpp
SidecarCompress.cpp
+4 -3
View File
@@ -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.
+92
View File
@@ -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 <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#include "GpuAllocScope.h"
#include <cassert>
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<PendingPop*>(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
}
+67
View File
@@ -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 <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#ifndef IFCVIEWER_GPUALLOCSCOPE_H
#define IFCVIEWER_GPUALLOCSCOPE_H
#include <webgpu/webgpu.h>
#include <functional>
// 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<void(bool ok)>;
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
+60
View File
@@ -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 <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#include "GpuBudget.h"
#include <algorithm>
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;
}
+92
View File
@@ -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 <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#ifndef IFCVIEWER_GPUBUDGET_H
#define IFCVIEWER_GPUBUDGET_H
#include <cstdint>
// 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
+4 -4
View File
@@ -24,7 +24,7 @@
#include <cstring>
#include <string>
#if defined(__linux__)
#if defined(__linux__) && !defined(__EMSCRIPTEN__)
#include <dirent.h>
#include <dlfcn.h>
#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
+4
View File
@@ -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
+307 -94
View File
@@ -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<uint64_t>(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<void()>& create,
const std::function<void()>& release,
std::function<void()> 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<MeshGpu>& mesh_gpu,
const std::vector<InstanceGpu>& 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<std::uint64_t>(
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<std::uint64_t>(
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<std::size_t>(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<std::uint64_t>(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<std::uint64_t>(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) {
+74 -2
View File
@@ -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<MeshGpu>& mesh_gpu,
const std::vector<InstanceGpu>& 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<void()>& create,
const std::function<void()>& release,
std::function<void()> 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
+7 -1
View File
@@ -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)
+60
View File
@@ -34,6 +34,7 @@
#include <catch2/catch_all.hpp>
#include <cstdint>
#include <vector>
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<int> 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<int>{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<int>{0});
}
+112
View File
@@ -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 <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
// 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 <catch2/catch_all.hpp>
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);
}