ifcviewer: make the geometry cache budget live, not fixed at startup

The budget was derived once at init as device free minus a reserve sized
for attachments at 4K plus margin. On a 1440p surface that idled ~550 MB
of VRAM the user's hardware could have spent on geometry, and it never
followed the device as other processes came and went.

Now, on the same once-a-second device poll that feeds the status bar,

    budget = cache capacity + device free - margin

is recomputed and applied: the pool's growth ceiling moves with it, and
the pool yields whole sub-buffers when the device has less to give than
the pool holds. The attachments are eager, so at any poll they are
already inside "used" at the actual surface size; a resize that no
longer fits is answered by the existing pressure path rather than by a
permanent reserve.

The margin is 256 MB for later required allocations plus a learned part:
drivers refuse while still reporting memory free (the original crash
refused 59 MB with 221 MB "free"), so a pressure event records how much
reported-free memory proved unusable and update() stops short of it from
then on, instead of growing straight back into the same refusal.

Web is unchanged: fixed heap cap plus pressure. On the test machine the
idle-device budget goes from 1609 MB to 2212 MB; with another process
holding 1 GB mid-session the budget follows it down and back up without
evicting geometry the device could still hold.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Dion Moult
2026-08-23 17:51:04 +10:00
parent ab99024307
commit 6201c4052b
5 changed files with 230 additions and 126 deletions
+53 -39
View File
@@ -29,7 +29,6 @@
#include "CameraMath.h"
#include "GpuAllocScope.h"
#include "GpuMemory.h"
#include "InstanceCompose.h"
#include "Log.h"
@@ -1606,8 +1605,6 @@ bool ViewportCore::createPool() {
// 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__)
// 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
@@ -1616,18 +1613,17 @@ bool ViewportCore::createPool() {
// 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)
budget_.setHardCap(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);
adapter_vendor_id_ = adapter_info.vendorID;
adapter_device_id_ = adapter_info.deviceID;
wgpuAdapterInfoFreeMembers(adapter_info);
if (mem.valid) device_free = mem.free_bytes();
}
pollDeviceMemory();
#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 = "
@@ -1637,11 +1633,13 @@ bool ViewportCore::createPool() {
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)"
<< (device_vram_total_bytes_ > 0
? " (device free "
+ std::to_string((device_vram_total_bytes_ - device_vram_used_bytes_)
/ (1024 * 1024))
+ " MB - margin "
+ std::to_string(budget_.margin_bytes() / (1024 * 1024))
+ " MB; tracks the driver's report)"
: " (fixed cap)");
} else {
Log::info() << "wgpu: geometry cache budget unknown (no device memory "
@@ -1650,6 +1648,44 @@ bool ViewportCore::createPool() {
return true;
}
ifcviewer::GpuMemoryInfo ViewportCore::queryDeviceMemory() const {
#if defined(__EMSCRIPTEN__)
return {};
#else
return ifcviewer::queryGpuMemory(adapter_vendor_id_, adapter_device_id_);
#endif
}
void ViewportCore::pollDeviceMemory() {
if (device_vram_poll_timer_.isValid()
&& device_vram_poll_timer_.elapsed() < 1000) return;
device_vram_poll_timer_.start();
const ifcviewer::GpuMemoryInfo mem = queryDeviceMemory();
if (!mem.valid) return;
device_vram_used_bytes_ = mem.used_bytes;
device_vram_total_bytes_ = mem.total_bytes;
budget_.update(mem.free_bytes(), pool_.total_capacity_bytes());
applyBudgetToPool();
}
void ViewportCore::applyBudgetToPool() {
if (!budget_.bounded()) return;
const std::uint64_t budget = budget_.cache_budget_bytes();
pool_.setMaxTotalCapacity(budget);
// Whole sub-buffers are the release granularity, so only act once the
// excess is worth one; below that the fixed margin covers it.
const std::uint64_t capacity = pool_.total_capacity_bytes();
if (capacity < budget + BufferPool::MIN_SUB_BUFFER_BYTES) return;
const std::uint64_t released = pool_.shrinkToCapacity(
budget, [this](int sub_idx) { evictChunksInSubBuffer(sub_idx); });
if (released > 0) {
const double mb = 1.0 / (1024.0 * 1024.0);
Log::info() << "[wgpu] device has less to give: geometry cache budget now "
<< double(budget) * mb << " MB, released "
<< double(released) * mb << " MB";
}
}
std::uint64_t ViewportCore::attachmentBytesPerPixel() {
// Sizes follow the formats in ensureDepthTexture / ensureMsaaColorTexture /
// ensureSelectionOutlineTextures / createPickAttachments.
@@ -1662,19 +1698,11 @@ std::uint64_t ViewportCore::attachmentBytesPerPixel() {
+ 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 ifcviewer::GpuMemoryInfo mem = queryDeviceMemory();
const bool lowered = budget_.onPressure(capacity_before, bytes,
mem.valid ? mem.free_bytes() : 0);
const double mb = 1.0 / (1024.0 * 1024.0);
if (!lowered) {
Log::warn() << "[wgpu] out of memory allocating " << what << " ("
@@ -7951,21 +7979,7 @@ void ViewportCore::render() {
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) {
device_vram_poll_timer_.start();
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_vram_used_bytes_ = mem.used_bytes;
device_vram_total_bytes_ = mem.total_bytes;
}
}
#endif
pollDeviceMemory();
stats.device_vram_used_bytes = device_vram_used_bytes_;
stats.device_vram_total_bytes = device_vram_total_bytes_;
host_->onFrameStats(stats);