From 19490a548bad05356d69bd47c6e3827a6d3b0a77 Mon Sep 17 00:00:00 2001 From: Panos <25664832+mechpanos@users.noreply.github.com> Date: Sat, 5 Sep 2026 09:50:17 +0300 Subject: [PATCH] fix: give the wire arena its own buffer budget `PersistentWireArena` keeps one instance buffer for the whole resident set and patches it in place on an edit. Sizing that against the shared per-chunk budget caps it at 32 MiB, which is 524 288 `WireInstance`s. A drawing with more than that fails `WireArena::build`, falls back to the batched path, and re-uploads everything on every edit. On the 198 k-wire reproducer -- 821 316 instances -- that shows up as: wire 200-370ms arena-hybrid instead of the ~12 ms `arena-patch` the same drawing reached before the limits moved onto the shared budget. The 32 MiB cap is right for transient chunks, where many are allocated per frame and any one of them failing loses the frame. The arena's buffer is a single long-lived allocation that exists precisely to avoid those re-uploads, and it can afford more: 821 k instances is 53 MB, ~79 MB with the arena's headroom. 128 MiB leaves it four times smaller than the 505 MiB allocation that crashed a 2 GB card, and it is allocated through `gpu_upload`, so exhausting VRAM still degrades the frame rather than aborting. `OCS_GPU_CHUNK_MIB` overrides both caps, so a test can still force small buffers everywhere. Co-Authored-By: Claude Opus 5 --- src/scene/pipeline/gpu_budget.rs | 39 ++++++++++++++++++++++++++++---- src/scene/pipeline/wire_arena.rs | 9 +++++--- 2 files changed, 41 insertions(+), 7 deletions(-) diff --git a/src/scene/pipeline/gpu_budget.rs b/src/scene/pipeline/gpu_budget.rs index 5338a2310..052f1fdb9 100644 --- a/src/scene/pipeline/gpu_budget.rs +++ b/src/scene/pipeline/gpu_budget.rs @@ -8,19 +8,50 @@ fn device_ceiling(device: &wgpu::Device) -> usize { (usize::try_from(device.limits().max_buffer_size).unwrap_or(usize::MAX) / 10) * 9 } -/// Optional per-buffer cap in MiB, useful for testing smaller chunks. -fn cap_bytes() -> usize { - static CAP: std::sync::OnceLock = std::sync::OnceLock::new(); +/// The wire arena keeps one persistent instance buffer for the whole resident +/// set and patches it in place. Capping that at the per-chunk budget pushes any +/// drawing past ~524 k instances onto the batched fallback, which re-uploads +/// everything on every edit — 200 ms a click on a drawing with 821 k. A single +/// long-lived buffer can afford more than a transient chunk, and 128 MiB is +/// still four times smaller than the 505 MiB allocation that crashed a 2 GB +/// card in the first place. +const ARENA_CAP_BYTES: usize = 128 * 1024 * 1024; + +/// `OCS_GPU_CHUNK_MIB`, when set. Overrides both caps so a test can force +/// small buffers. +fn env_cap_bytes() -> Option { + static CAP: std::sync::OnceLock> = std::sync::OnceLock::new(); *CAP.get_or_init(|| { std::env::var("OCS_GPU_CHUNK_MIB") .ok() .and_then(|value| value.trim().parse::().ok()) .filter(|mib| *mib > 0) .and_then(|mib| mib.checked_mul(1024 * 1024)) - .unwrap_or(HARD_CAP_BYTES) }) } +/// Optional per-buffer cap in MiB, useful for testing smaller chunks. +fn cap_bytes() -> usize { + env_cap_bytes().unwrap_or(HARD_CAP_BYTES) +} + +/// Largest byte size the wire arena's persistent buffers may take. +pub fn arena_budget(device: &wgpu::Device) -> usize { + device_ceiling(device).min(env_cap_bytes().unwrap_or(ARENA_CAP_BYTES)) +} + +/// How many `T` fit in one arena buffer. Never returns 0. +pub fn max_arena_elements(device: &wgpu::Device) -> usize { + (arena_budget(device) / std::mem::size_of::().max(1)).max(1) +} + +/// Arena storage buffers must also fit in one binding. +pub fn max_arena_storage_elements(device: &wgpu::Device) -> usize { + (arena_budget(device).min(device.limits().max_storage_buffer_binding_size as usize) + / std::mem::size_of::().max(1)) + .max(1) +} + /// Largest byte size a single buffer built by this renderer may take. pub fn buffer_budget(device: &wgpu::Device) -> usize { device_ceiling(device).min(cap_bytes()) diff --git a/src/scene/pipeline/wire_arena.rs b/src/scene/pipeline/wire_arena.rs index cf9707c58..9b10f76f7 100644 --- a/src/scene/pipeline/wire_arena.rs +++ b/src/scene/pipeline/wire_arena.rs @@ -313,8 +313,10 @@ impl WireArena { const_bgl: &wgpu::BindGroupLayout, mesh_edge: bool, ) -> Option { - let instance_limit = super::gpu_budget::max_elements::(device) as u64; - let constant_limit = super::gpu_budget::max_storage_elements::(device) as u64; + let instance_limit = + super::gpu_budget::max_arena_elements::(device) as u64; + let constant_limit = + super::gpu_budget::max_arena_storage_elements::(device) as u64; let ranges = handle_ranges(wires)?; let perf = crate::perf::enabled(); let total_started = iced::time::Instant::now(); @@ -971,7 +973,8 @@ impl PackedWireArena { depth_map: &FxHashMap, mesh_edge: bool, ) -> Option { - let packed_limit = super::gpu_budget::max_elements::(device) as u64; + let packed_limit = + super::gpu_budget::max_arena_elements::(device) as u64; let ranges = handle_ranges(wires)?; let max_instances: usize = wires .iter()