ifcviewer: move the live budget only on sustained device readings

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 <noreply@anthropic.com>
This commit is contained in:
Dion Moult
2026-08-23 19:27:58 +10:00
parent 24616ed655
commit 777b728205
3 changed files with 90 additions and 10 deletions
+19 -1
View File
@@ -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;
}
+16
View File
@@ -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;
+55 -9
View File
@@ -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