ifcviewer-web: stop large-model network streaming thrash (grow before fetch)

Battle-testing real sidecars over HTTP Range exposed severe thrash: a
531 MB model re-fetched 2.25 GB (4×) and never converged — viewAll puts
the whole model in frustum, so every chunk wants to be resident, and the
web async path made it worse two ways:

  - A web load only consumes pool space when it COMPLETES (async), so the
    per-frame issuance over-committed the pool; completions then failed
    applyStreamedChunk on a full pool, the chunk re-candidated with no
    cooldown, and re-fetched every frame.
  - Pool growth is itself async on web (provisional sub-buffers validated
    off the JS event loop), so even fetched chunks failed to alloc until
    the pool caught up, and re-fetched.

Fix: gate web chunk issuance on VALIDATED free space + in-flight
reservation, and grow the pool BEFORE fetching:

  - streaming_web_inflight_bytes_ reserves each in-flight load's footprint
    so we never have more bytes in flight than the pool can place.
  - When a visible chunk doesn't fit validated free, don't fetch — call
    pool_.requestGrowth() (BufferPool: drives the async provisional grow
    without allocating) and short-back-off; the chunk is fetched once,
    after space exists. When the pool is saturated (model > GPU memory),
    long-cooldown so a never-fitting chunk isn't re-fetched. Gating before
    the evictor also kills phase-2 visible↔visible swap thrash.
  - On async load failure, cool down (short if the pool can still grow,
    long if saturated) instead of re-candidating next frame.

Result (manual battle tool, host.mjs + real files): 531 MB now loads
23/23 chunks, 322 MB loads 14/14 — resident climbs monotonically with
ZERO thrash warnings and a stable resident set, vs the old re-fetch loop.
The whole model resides on the GPU and stays. (Remaining ~3× ramp
over-fetch — per-chunk re-loads during the async-growth ramp + read
amplification from chunk byte-locality — is a separate efficiency
follow-up, not thrash.) 6/6 web smoke + 107/107 unit pass; desktop
unaffected (the gate is web-only; requestGrowth is a no-op wrapper there).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Dion Moult
2026-06-30 14:26:12 +10:00
parent 2c5e2d1685
commit 9db42df81c
3 changed files with 80 additions and 6 deletions
+17
View File
@@ -890,6 +890,23 @@ private:
int streaming_loads_this_frame_ = 0;
bool streaming_more_pending_ = false;
// Frames a chunk that couldn't fit (or whose async load failed) is held off
// the candidate list before retrying. Shared by the sync evictor and the
// web async-failure path so a saturated pool backs off instead of thrashing.
static constexpr std::uint64_t kBlockedCooldownFrames = 180;
// Short backoff when a web load couldn't fit but the pool can still grow
// (provisional sub-buffer validating) — retry soon, don't long-cooldown.
static constexpr std::uint64_t kGrowBackoffFrames = 8;
// Web only: bytes reserved by in-flight async chunk loads. A web load only
// consumes pool space when it COMPLETES (async), so without reserving here
// the per-frame issuance over-commits the pool — chunks get fetched, then
// applyStreamedChunk fails on a full pool and re-fetches (observed: a 531 MB
// model re-fetched 4× over the network). Incremented at issue, decremented
// when the load resolves (success or failure); driveStreamingLoads blocks
// candidates that won't fit total_free - this.
std::uint64_t streaming_web_inflight_bytes_ = 0;
// Settle burst: keep the render loop alive for a few frames after any
// streaming activity so the cull→load→display latency (the draw + cull
// precede driveStreamingLoads, so a freshly-resident chunk paints a frame