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
+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_