From 777b7282056ec44cdd65284b63512b60a626a1b2 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Sun, 23 Aug 2026 19:27:58 +1000 Subject: [PATCH] ifcviewer: move the live budget only on sustained device readings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A 66-model session oscillated with a ~4 s period — 298 releases in one log: the pool grew to its ceiling, the next report read ~83 MB free, the budget dropped and the pool shrank, the reading rebounded, the budget rose and the pool re-grew, reloading the same chunks each time. Objects flickered on and off continuously. The report includes transients the viewer itself creates: the upload staging behind a burst of chunk loads (~170 MB in that session) and a released sub-buffer the driver has not yet reclaimed. A budget that followed every reading fed those straight back into growth decisions. GpuBudget::update now bounds the cache outright on the first device report and afterwards moves only on sustained readings: lower when free memory is below half the margin on two consecutive scheduled reports, raise when it is above 1.5× the margin on two, and nothing in between. Transients drain well within a poll interval, so a momentary low never reaches the pool, while a process that really took memory still does a second later. A refused allocation (onPressure) is never deferred. Verified in the saturated regime (working set ~990 MB against a 683 MB budget, continuous streaming): zero releases over 75 s. Co-Authored-By: Claude Fable 5 --- src/ifcviewer/GpuBudget.cpp | 20 +++++++- src/ifcviewer/GpuBudget.h | 16 +++++++ src/ifcviewer/tests/test_gpu_budget.cpp | 64 +++++++++++++++++++++---- 3 files changed, 90 insertions(+), 10 deletions(-) diff --git a/src/ifcviewer/GpuBudget.cpp b/src/ifcviewer/GpuBudget.cpp index 38cffb6a76..a188fb31d1 100644 --- a/src/ifcviewer/GpuBudget.cpp +++ b/src/ifcviewer/GpuBudget.cpp @@ -37,7 +37,24 @@ void GpuBudget::update(std::uint64_t device_free_bytes, if (device_free_bytes == 0) return; const std::uint64_t available = cache_capacity_bytes + device_free_bytes; const std::uint64_t margin = margin_bytes(); - bound(available > margin ? available - margin : 0); + const std::uint64_t reading = available > margin ? available - margin : 0; + if (!had_device_report_) { + had_device_report_ = true; + bound(reading); + return; + } + + const bool tight = device_free_bytes < margin / 2; + const bool roomy = device_free_bytes > margin + margin / 2 && reading > budget_; + low_reports_ = tight ? low_reports_ + 1 : 0; + high_reports_ = roomy ? high_reports_ + 1 : 0; + if (low_reports_ >= kConfirmReports) { + bound(std::min(budget_, reading)); + low_reports_ = 0; + } else if (high_reports_ >= kConfirmReports) { + bound(reading); + high_reports_ = 0; + } } bool GpuBudget::onPressure(std::uint64_t cache_capacity_bytes, @@ -64,5 +81,6 @@ bool GpuBudget::onPressure(std::uint64_t cache_capacity_bytes, const std::uint64_t lowered = std::max(target, kMinCacheBudgetBytes); if (bounded_ && lowered >= budget_) return false; bound(lowered); + low_reports_ = high_reports_ = 0; return true; } diff --git a/src/ifcviewer/GpuBudget.h b/src/ifcviewer/GpuBudget.h index 0e8617874e..950dd5389f 100644 --- a/src/ifcviewer/GpuBudget.h +++ b/src/ifcviewer/GpuBudget.h @@ -88,8 +88,21 @@ public: // Desktop: a fresh driver report. `device_free_bytes` 0 = the query // could not answer -- ignored, the budget keeps its last value. + // + // The first report bounds the cache outright. After that the budget + // moves only on *sustained* readings, because the report includes + // transients the viewer itself creates -- the upload staging behind a + // burst of chunk loads, a released sub-buffer the driver has not yet + // reclaimed -- and a budget that followed every reading oscillated: + // grow, read a momentary low, shrink, read the rebound, grow again, + // reloading the same chunks every few seconds. So: lower when free + // memory is below half the margin on kConfirmReports consecutive + // reports; raise when it is above 1.5× the margin on as many; between + // those nothing changes. A refused allocation (onPressure) is never + // deferred. void update(std::uint64_t device_free_bytes, std::uint64_t cache_capacity_bytes); + static constexpr int kConfirmReports = 2; // A required allocation of `bytes_needed` failed while the cache held // `cache_capacity_bytes` and the driver reported `device_free_bytes` @@ -124,6 +137,9 @@ private: bool bounded_ = false; std::uint64_t budget_ = 0; + bool had_device_report_ = false; + int low_reports_ = 0; // consecutive reports below the lower band + int high_reports_ = 0; // consecutive reports above the upper band std::uint64_t hard_cap_ = 0; // Reported-free memory that a refusal proved unusable, plus slack. std::uint64_t learned_margin_ = 0; diff --git a/src/ifcviewer/tests/test_gpu_budget.cpp b/src/ifcviewer/tests/test_gpu_budget.cpp index 6a6f964d38..59a1a3581e 100644 --- a/src/ifcviewer/tests/test_gpu_budget.cpp +++ b/src/ifcviewer/tests/test_gpu_budget.cpp @@ -40,23 +40,68 @@ TEST_CASE("nothing known leaves the cache unbounded", "[gpu_budget]") { REQUIRE_FALSE(b.bounded()); } -TEST_CASE("a device report bounds the cache at held + free - margin", "[gpu_budget]") { +TEST_CASE("the first device report bounds the cache at held + free - margin", "[gpu_budget]") { GpuBudget b; b.update(2800 * MB, 0); REQUIRE(b.bounded()); REQUIRE(b.cache_budget_bytes() == 2800 * MB - GpuBudget::kFixedMarginBytes); // The pool now holds 1000 MB and the driver reports 1800 MB free: the - // cache's own bytes count as available to it. + // cache's own bytes count as available to it, so nothing moves. + b.update(1800 * MB, 1000 * MB); b.update(1800 * MB, 1000 * MB); REQUIRE(b.cache_budget_bytes() == 2800 * MB - GpuBudget::kFixedMarginBytes); +} - // Another process took 1000 MB: the budget follows the device down. - b.update(800 * MB, 1000 * MB); - REQUIRE(b.cache_budget_bytes() == 1800 * MB - GpuBudget::kFixedMarginBytes); - // ...and back up when it is released. - b.update(1800 * MB, 1000 * MB); - REQUIRE(b.cache_budget_bytes() == 2800 * MB - GpuBudget::kFixedMarginBytes); +TEST_CASE("a momentary reading never moves the budget; a sustained one does", "[gpu_budget]") { + const std::uint64_t margin = GpuBudget::kFixedMarginBytes; + GpuBudget b; + b.update(2800 * MB, 0); + const std::uint64_t initial = b.cache_budget_bytes(); + + // Pool at budget; one poll reads 80 MB free (upload staging in flight). + b.update(80 * MB, initial); + REQUIRE(b.cache_budget_bytes() == initial); + // The staging drained: back in the dead band, streak reset. + b.update(margin, initial); + b.update(80 * MB, initial); + REQUIRE(b.cache_budget_bytes() == initial); + + // Tight on two consecutive reports: another process really took it. + b.update(80 * MB, initial); + REQUIRE(b.cache_budget_bytes() == initial + 80 * MB - margin); + const std::uint64_t lowered = b.cache_budget_bytes(); + + // One roomy report is not enough to raise it... + b.update(1500 * MB, lowered); + REQUIRE(b.cache_budget_bytes() == lowered); + // ...two are. + b.update(1500 * MB, lowered); + REQUIRE(b.cache_budget_bytes() == lowered + 1500 * MB - margin); +} + +TEST_CASE("free memory inside the dead band changes nothing however long it lasts", "[gpu_budget]") { + const std::uint64_t margin = GpuBudget::kFixedMarginBytes; + GpuBudget b; + b.update(2800 * MB, 0); + const std::uint64_t initial = b.cache_budget_bytes(); + for (int i = 0; i < 10; ++i) b.update(margin, initial); // exactly the margin + for (int i = 0; i < 10; ++i) b.update(margin + margin / 2, initial); // top of the band + for (int i = 0; i < 10; ++i) b.update(margin / 2, initial); // bottom of the band + REQUIRE(b.cache_budget_bytes() == initial); +} + +TEST_CASE("a refusal lowers the budget immediately and resets the streaks", "[gpu_budget]") { + GpuBudget b; + b.update(2800 * MB, 0); + const std::uint64_t initial = b.cache_budget_bytes(); + b.update(80 * MB, initial); // one tight report + REQUIRE(b.onPressure(initial, 100 * MB, 80 * MB)); + REQUIRE(b.cache_budget_bytes() < initial); + const std::uint64_t after = b.cache_budget_bytes(); + // The streak did not carry over: one more tight report is not two. + b.update(80 * MB, after); + REQUIRE(b.cache_budget_bytes() == after); } TEST_CASE("less than the margin available floors the budget, not zero", "[gpu_budget]") { @@ -135,9 +180,10 @@ TEST_CASE("a refusal with memory still reported free teaches the margin", "[gpu_ REQUIRE(b.margin_bytes() == GpuBudget::kFixedMarginBytes + 162 * MB + GpuBudget::kPressureSlackBytes); - // The next live report stops short by the learned amount, so the pool + // The next live reports stop short by the learned amount, so the pool // does not grow straight back into the same refusal. b.update(221 * MB, 2048 * MB); + b.update(221 * MB, 2048 * MB); REQUIRE(b.cache_budget_bytes() == 2048 * MB + 221 * MB - b.margin_bytes()); // Learning only ever grows; a later refusal with less phantom free