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
+7
View File
@@ -111,6 +111,13 @@ public:
// could rescue them, or whether eviction is the only path.
bool can_grow() const { return !growth_disabled_ && per_sub_buffer_capacity_ > 0; }
// Proactively add a sub-buffer (no allocation). On web this kicks off the
// async provisional-validation cycle so validated free space appears a
// frame or two later — letting the streaming driver grow the pool BEFORE
// fetching a chunk's bytes, instead of fetching, failing the alloc on a
// not-yet-grown pool, and re-fetching. No-op if growth is pending/disabled.
bool requestGrowth() { return addSubBuffer(); }
// Test-only seam. Production code populates sub-pools lazily through
// alloc() → addSubBuffer() → wgpuDeviceCreateBuffer; that path needs a
// real WGPUDevice and is impractical to exercise from a unit test.
+56 -6
View File
@@ -2267,6 +2267,36 @@ void ViewportCore::driveStreamingLoads() {
const std::uint64_t need = c.vertex_byte_size
+ c.index_count * sizeof(std::uint32_t);
#if defined(__EMSCRIPTEN__)
// Web async loads only allocate pool space when they COMPLETE, and pool
// growth is itself async (provisional sub-buffers validated off the JS
// event loop). Gate issuance on what the pool can hold so we never fetch
// bytes we can't place (which would re-fetch → network thrash; a 531 MB
// model re-fetched 4×).
if (cand.m->streaming_from_web
&& pool_.total_free_bytes() < streaming_web_inflight_bytes_ + need) {
// Not enough VALIDATED pool space for this chunk plus what's already
// in flight. Don't fetch — the bytes would arrive, fail the alloc on
// a not-yet-grown pool, and re-fetch (network thrash). Instead grow
// the pool first (async on web: a provisional sub-buffer validates a
// frame or two later, then this chunk fits and is fetched exactly
// once). When the pool is saturated (model exceeds GPU memory) hold
// the chunk off for the full cooldown so we keep a stable resident
// subset instead of re-fetching what will never fit. Blocking before
// the evictor also avoids phase-2 visible↔visible swap thrash.
if (pool_.can_grow()) {
pool_.requestGrowth();
c.blocked_cooldown_until_frame_idx =
streaming_frame_idx_ + kGrowBackoffFrames;
} else {
c.blocked_cooldown_until_frame_idx =
streaming_frame_idx_ + kBlockedCooldownFrames;
}
more_pending = true;
continue;
}
#endif
while (!pool_can_fit(c.vertex_byte_size)
|| (c.index_count > 0
&& !pool_can_fit(c.index_count * sizeof(std::uint32_t)))
@@ -3326,6 +3356,13 @@ void ViewportCore::beginWebChunkLoad(std::uint32_t model_id, std::size_t chunk_i
for (const auto& [first_u32, count] : req.i_ranges)
i_byte_ranges.emplace_back(first_u32 * 4u, count * 4u);
// Reserve this load's pool footprint while it's in flight (released when it
// resolves) so driveStreamingLoads doesn't over-commit the pool — see
// streaming_web_inflight_bytes_.
const std::uint64_t need = m.chunks[chunk_idx].vertex_byte_size
+ std::uint64_t(m.chunks[chunk_idx].index_count) * sizeof(std::uint32_t);
streaming_web_inflight_bytes_ += need;
// Fire the vertex and index range reads CONCURRENTLY and join when both
// land — over a network this halves per-chunk latency vs reading vertices
// then indices serially (two round trips → one). The join holds both
@@ -3338,17 +3375,30 @@ void ViewportCore::beginWebChunkLoad(std::uint32_t model_id, std::size_t chunk_i
bool v_done = false, i_done = false, v_ok = false, i_ok = false;
};
auto join = std::make_shared<ChunkJoin>();
std::function<void()> finish = [this, model_id, chunk_idx, join]() {
std::function<void()> finish = [this, model_id, chunk_idx, need, join]() {
if (!join->v_done || !join->i_done) return; // wait for the other read
// Release the in-flight reservation (clamped — a mid-flight resetScene
// could have zeroed it) regardless of what happens below.
streaming_web_inflight_bytes_ -=
std::min(streaming_web_inflight_bytes_, need);
auto mit = models_gpu_.find(model_id);
if (mit == models_gpu_.end()) return;
ModelGpuData& mm = mit->second;
if (chunk_idx >= mm.chunks.size()) return;
if (!join->v_ok || !join->i_ok) { mm.chunks[chunk_idx].is_loading = false; return; }
if (!applyStreamedChunk(mm, chunk_idx, join->vbytes, join->idx))
mm.chunks[chunk_idx].is_loading = false; // pool full; retry later
else
host_->requestFrame();
auto& cc = mm.chunks[chunk_idx];
cc.is_loading = false;
// On read failure or a full pool, back off instead of re-candidating
// next frame → re-fetch thrash. If the pool can still grow (its async
// sub-buffer is mid-validation), retry soon; if it's saturated, hold
// off for the full cooldown.
if (!join->v_ok || !join->i_ok
|| !applyStreamedChunk(mm, chunk_idx, join->vbytes, join->idx)) {
cc.blocked_cooldown_until_frame_idx = streaming_frame_idx_
+ (pool_.can_grow() ? kGrowBackoffFrames : kBlockedCooldownFrames);
return;
}
host_->requestFrame();
};
webReadRangesAsync(vsec, v_ranges,
+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