From d81a858040df5fca44990b67059a9f43e93b8dfb Mon Sep 17 00:00:00 2001 From: Panos <25664832+mechpanos@users.noreply.github.com> Date: Sat, 5 Sep 2026 16:38:25 +0300 Subject: [PATCH] perf: account for the device memory the renderer holds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stage 0 of the roadmap asks for a memory baseline before any more optimisation, and explicitly not from RSS: the allocator and the driver both retain high-water memory that RSS cannot tell apart from live resources. `gpu-live` counts from the sizes this renderer allocated, in the categories large enough to decide where the next fix goes: slots total shadow targets text_atlas wire_arena block_geometry It is partial by construction and says so, rather than presenting itself as a total. Hatches, images, meshes and Face3D are not counted yet. Three of the numbers needed a size that was not reachable before: the block-geometry chunk records its bytes at build because in storage mode the buffer belongs to the bind group, the arena exposes its allocation rather than its occupied prefix — headroom is what a budget has to answer for — and the text atlas reports its `R8` texel count. Emitted only when a number moves. `prepare` runs thousands of times in a session and these change a handful of times, so a per-frame line would bury the log it is meant to explain. This answers the question the previous fix left open: whether the arena's 52 MiB per slot, the 16 MiB shadow map every slot allocates whether or not shadows are on, or the render targets dominate once a paper layout has four or five viewports on screen. Co-Authored-By: Claude Opus 5 --- src/scene/pipeline/mod.rs | 74 ++++++++++++++++++++++++++++++++ src/scene/pipeline/text_gpu.rs | 5 +++ src/scene/pipeline/wire_arena.rs | 12 ++++++ src/scene/pipeline/wire_gpu.rs | 14 ++++++ src/scene/view/render.rs | 37 ++++++++++++++++ 5 files changed, 142 insertions(+) diff --git a/src/scene/pipeline/mod.rs b/src/scene/pipeline/mod.rs index b2005ba22..72d24b0c4 100644 --- a/src/scene/pipeline/mod.rs +++ b/src/scene/pipeline/mod.rs @@ -4727,6 +4727,80 @@ fn aabb_below_pixel( (max_px - min_px).max(max_py - min_py) < threshold_px } +/// Device bytes a renderer is holding, counted from the sizes it allocated +/// rather than from process RSS — the allocator and the driver both keep +/// high-water memory that RSS cannot distinguish from live resources. +/// +/// Partial by construction: it covers the categories large enough to decide +/// where the next fix goes, and says so rather than pretending to be a total. +#[derive(Default, Clone, Copy, PartialEq)] +pub(crate) struct GpuLiveBytes { + pub slots: usize, + pub shadow: u64, + pub render_targets: u64, + pub text_atlas: u64, + pub wire_arena: u64, + pub block_geometry: u64, +} + +impl GpuLiveBytes { + pub(crate) fn total(&self) -> u64 { + self.shadow + self.render_targets + self.text_atlas + self.wire_arena + self.block_geometry + } +} + +impl Pipeline { + /// This slot's share. `alloc_size` is the real allocation, which is rounded + /// up from the requested size and grows only. + fn gpu_live_bytes(&self) -> GpuLiveBytes { + // 4x MSAA colour (4 B/sample) + 4x MSAA depth-stencil (4 B/sample) + + // one single-sample resolve target. + const BYTES_PER_PIXEL: u64 = (MSAA_SAMPLES as u64) * 4 + (MSAA_SAMPLES as u64) * 4 + 4; + let pixels = u64::from(self.alloc_size.width) * u64::from(self.alloc_size.height); + GpuLiveBytes { + slots: 1, + // Allocated by every slot whether or not shadows are enabled. + shadow: u64::from(SHADOW_MAP_SIZE) * u64::from(SHADOW_MAP_SIZE) * 4, + render_targets: pixels * BYTES_PER_PIXEL, + text_atlas: self + .text_atlas_gpu + .as_ref() + .map(text_gpu::TextAtlasGpu::gpu_bytes) + .unwrap_or(0), + wire_arena: self + .wire_arena + .as_ref() + .map(wire_arena::PersistentWireArena::gpu_bytes) + .unwrap_or(0) + + self + .wire_arena_mesh + .as_ref() + .map(wire_arena::PersistentWireArena::gpu_bytes) + .unwrap_or(0), + block_geometry: 0, + } + } +} + +impl MultiPipeline { + /// Sum across slots, plus the caches held once for all of them. + pub(crate) fn gpu_live_bytes(&self) -> GpuLiveBytes { + let mut total = GpuLiveBytes { + block_geometry: wire_gpu::block_geometry_bytes(&self.block_geometry), + ..Default::default() + }; + for inner in &self.inners { + let slot = inner.gpu_live_bytes(); + total.slots += slot.slots; + total.shadow += slot.shadow; + total.render_targets += slot.render_targets; + total.text_atlas += slot.text_atlas; + total.wire_arena += slot.wire_arena; + } + total + } +} + /// Bind one block-wire batch and draw it. /// /// The two pipeline modes lay the vertex slots out differently — packed puts diff --git a/src/scene/pipeline/text_gpu.rs b/src/scene/pipeline/text_gpu.rs index cb3e1e482..136e0945b 100644 --- a/src/scene/pipeline/text_gpu.rs +++ b/src/scene/pipeline/text_gpu.rs @@ -221,6 +221,11 @@ pub struct TextAtlasGpu { } impl TextAtlasGpu { + /// Device bytes this atlas holds. `R8`, so one byte per texel. + pub fn gpu_bytes(&self) -> u64 { + u64::from(self._texture.width()) * u64::from(self._texture.height()) + } + /// Bind-group layout for group 1: `{ R8 atlas texture, linear sampler }`. pub fn bind_group_layout(device: &wgpu::Device) -> wgpu::BindGroupLayout { device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { diff --git a/src/scene/pipeline/wire_arena.rs b/src/scene/pipeline/wire_arena.rs index 8a98b41a5..4fe85fab0 100644 --- a/src/scene/pipeline/wire_arena.rs +++ b/src/scene/pipeline/wire_arena.rs @@ -1369,6 +1369,18 @@ impl PersistentWireArena { } } + /// Device bytes this arena holds. The instance buffer is reserved with + /// headroom, so this is the allocation, not the occupied prefix — which is + /// what a memory budget has to answer for. + pub fn gpu_bytes(&self) -> u64 { + match &self.inner { + PersistentWireArenaKind::Indexed(arena) => { + arena.inst_buf.size() + arena.const_buf.size() + } + PersistentWireArenaKind::Packed(arena) => arena.inst_buf.size(), + } + } + pub fn wire_gpus_visible( &self, view_rot: glam::Mat4, diff --git a/src/scene/pipeline/wire_gpu.rs b/src/scene/pipeline/wire_gpu.rs index 8774ed251..ab1fd455b 100644 --- a/src/scene/pipeline/wire_gpu.rs +++ b/src/scene/pipeline/wire_gpu.rs @@ -703,6 +703,15 @@ pub struct BlockWireTarget<'a> { pub mode: WirePipelineMode, } +/// Device bytes held by a whole block-geometry cache. +pub fn block_geometry_bytes(cache: &BlockGeometryCache) -> u64 { + cache + .values() + .flat_map(|chunks| chunks.iter()) + .map(|chunk| chunk.gpu_bytes) + .sum() +} + /// `(source_id, mesh_edge, colour bits, base translation bits)`. pub type BlockGeometryKey = (u64, bool, [u32; 4], [u64; 3]); @@ -722,6 +731,9 @@ pub struct BlockGeometryChunk { pub vertex_buffer: Option, pub vertex_count: u32, pub bind_group: std::sync::Arc, + /// Device bytes this chunk holds. Recorded at build because in storage mode + /// the buffer is owned by `bind_group` and cannot be measured from here. + pub gpu_bytes: u64, } impl BlockWireGpu { @@ -856,6 +868,7 @@ impl BlockWireGpu { BlockGeometryChunk { vertex_buffer: None, vertex_count: chunk.len() as u32 * 6, + gpu_bytes: std::mem::size_of_val(chunk) as u64, bind_group: std::sync::Arc::new(device.create_bind_group( &wgpu::BindGroupDescriptor { label: Some("block_wire.const.bg"), @@ -897,6 +910,7 @@ impl BlockWireGpu { vertices .chunks(max_verts) .map(|chunk| BlockGeometryChunk { + gpu_bytes: std::mem::size_of_val(chunk) as u64, vertex_buffer: Some(super::gpu_upload::upload_buffer( device, queue, diff --git a/src/scene/view/render.rs b/src/scene/view/render.rs index 60de2fc0a..3b3e18a06 100644 --- a/src/scene/view/render.rs +++ b/src/scene/view/render.rs @@ -1217,6 +1217,7 @@ impl shader::Primitive for Primitive { self.viewports.len(), ); } + report_gpu_live(pipeline); } fn render( @@ -1568,6 +1569,42 @@ fn solar_direction( // ── Render-style helpers (impl Scene) ──────────────────────────────────── +/// Report what the renderer is holding on the device, when it changes. +/// +/// Only on change: `prepare` runs thousands of times a session and these +/// numbers move a handful of times. RSS is deliberately not used — the +/// allocator and the driver both retain high-water memory that RSS cannot tell +/// apart from live resources. +fn report_gpu_live(pipeline: &crate::scene::pipeline::MultiPipeline) { + use std::cell::Cell; + if !crate::perf::enabled() { + return; + } + thread_local! { + static LAST: Cell> = const { Cell::new(None) }; + } + let now = pipeline.gpu_live_bytes(); + LAST.with(|last| { + if last.get() == Some(now) { + return; + } + last.set(Some(now)); + const MIB: f64 = 1024.0 * 1024.0; + let mib = |bytes: u64| bytes as f64 / MIB; + crate::perf_record!( + "[perf] gpu-live slots={} total={:.1}MiB shadow={:.1} targets={:.1} \ +text_atlas={:.1} wire_arena={:.1} block_geometry={:.1}", + now.slots, + mib(now.total()), + mib(now.shadow), + mib(now.render_targets), + mib(now.text_atlas), + mib(now.wire_arena), + mib(now.block_geometry), + ); + }); +} + impl Scene { fn model_tile_vport(&self, index: usize) -> Option<&acadrust::tables::VPort> { let rect = self.model_tiles.borrow().get(index)?.rect;