fix: give back the memory of viewports nobody is drawing

`MultiPipeline::inners` only ever grew. A slot that no viewport is using
still owned its wire arena, its render targets, its text atlas and every
category buffer — tens of megabytes each — and nothing ever asked for them
back. Zoom a paper layout out until five viewports are on screen, and the
memory the hidden ones are holding is exactly what the visible one cannot
get.

`Pipeline::release_heavy_resources` hands it back: arenas, wire and block
batches, the handle index, selection and preview buffers, wipeouts,
images, mesh batches, silhouettes, the text atlas, and the render targets,
which go through `ensure_depth_texture` at its smallest size rather than
being reached for directly so the blit bind group stays consistent with
the views it samples — about 0.6 MiB against the ~70 MiB a full-canvas
slot holds.

Everything released is derived state whose rebuild is guarded by a cache
key, and every one of those keys is reset. That reset was already written
once, inline, for slot reuse; both now call `forget_cached_keys` so they
cannot disagree about what "this slot holds nothing you can trust" means.
It gained two entries in the move — `cached_highlight_key` and
`silhouette_source_key` — which slot reuse had not been clearing, so a
recycled slot could keep a stale highlight or silhouette.

The policy is gradual while the device is healthy: a slot is released once
it has been idle for 64 prepare calls, so an ordinary session pays nothing
and a layout the user keeps returning to stays warm. It stops being
patient the moment the device refuses an allocation — at that point
holding an invisible viewport is what turns a degraded frame into a
session that cannot recover, and the operator needs the drawing to stay
usable long enough to close a viewport or two.

The cost of releasing a slot that is wanted again is one slow frame.

`slots-released n= urgent= slots=` reports when it happens, since the
alternative is inferring it from a memory number.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
PanosandClaude Opus 5 committed 2026-09-05 18:53:09 +03:00
1 parent d81a858040
commit 0118b28d10
2 files changed
+147 -20

No files matched your search

+18 -20
View File
@@ -367,6 +367,9 @@ impl shader::Primitive for Primitive {
let scale = viewport.scale_factor() as f32;
let instance_ids: Vec<u64> = self.viewports.iter().map(|vp| vp.instance_id).collect();
let slots = pipeline.resolve_slots(device, queue, &instance_ids);
// Compared after the frame's uploads: a move means the device refused
// something, which changes how patient the slot release below can be.
let errors_at_entry = crate::scene::pipeline::gpu_errors_seen();
for (i, vp) in self.viewports.iter().enumerate() {
let inner = &mut pipeline.inners[slots[i]];
@@ -381,26 +384,7 @@ impl shader::Primitive for Primitive {
// the surviving viewport's text/geometry vanish.
if inner.slot_id != vp.instance_id {
inner.slot_id = vp.instance_id;
inner.cached_epoch = (u64::MAX, u64::MAX, u64::MAX);
inner.cached_wire_id = u64::MAX;
inner.cached_selection = (u64::MAX, u64::MAX);
inner.cached_mesh_content_id = u64::MAX;
inner.cached_face3d_key = (u64::MAX, false, false, u64::MAX);
inner.cached_solid_visibility =
(u64::MAX, [u32::MAX; 3], u64::MAX);
inner.cached_hatch_source = None;
inner.cached_preview_hatch_source = None;
inner.cached_wipeout_source = None;
inner.cached_image_source = None;
inner.cached_text_source = None;
inner.cached_mesh_source = None;
inner.cached_face3d_source = None;
inner.cached_face3d_depth_source = None;
inner.wire_cull_key = (u64::MAX, u64::MAX, 0, 0);
inner.hatch_lod_key = (usize::MAX, u64::MAX, 0, 0, false);
inner.wipeout_lod_key = (usize::MAX, u64::MAX, 0, 0, false);
inner.silhouette_key = (usize::MAX, u64::MAX, [u32::MAX; 3], false);
inner.render_sig = u64::MAX;
inner.forget_cached_keys();
}
// The MSAA / depth / resolve textures are always sized to the
// FULL viewport rectangle (not the on-canvas-visible portion)
@@ -1217,6 +1201,20 @@ impl shader::Primitive for Primitive {
self.viewports.len(),
);
}
// Give back what nobody is drawing. Gradual while the device is
// healthy; immediate once it has refused an allocation, because the
// memory a hidden viewport is holding is exactly what the visible one
// could not get — and freeing it is what lets the operator close a
// viewport or two and carry on instead of restarting.
let errors_now = crate::scene::pipeline::gpu_errors_seen();
let urgent = errors_now != errors_at_entry;
let released = pipeline.release_idle_slots(device, &slots, urgent);
if released > 0 && crate::perf::enabled() {
crate::perf_record!(
"[perf] slots-released n={released} urgent={urgent} slots={}",
pipeline.inners.len(),
);
}
report_gpu_live(pipeline);
}