From 78e50b5fc555d1f4c2f263df8952d9e60babeb13 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 10:28:13 -0400 Subject: [PATCH] M5.6a: memory report (library.memory, ui.inspect memory) and opt-in DHAT heap profile Per-cache bytes: decoded thumbnail/preview/full sources, rendered previews, GPU device buffers (allocated/pooled/retired), loupe stage caches (CPU and GPU) and textures. lightcraft-cli --features dhat-heap adds live/peak heap and writes dhat-heap.json. Baseline (14 raws, loupe, 12 steps): peak RSS 1.9-2.3 GB, footprint 2.5-2.6 GB; heap peak 634 MB after import (probes decode every raw), 930 MB during grid thumbnails (parallel decodes); GPU free pool grows to 2 GB (exact-size reuse). Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 80 +++++++++++++++++++++++++ Cargo.toml | 1 + apps/lightcraft-cli/Cargo.toml | 7 +++ apps/lightcraft-cli/src/main.rs | 15 +++++ crates/engine/src/cmd/query.rs | 3 + crates/engine/src/lib.rs | 1 + crates/engine/src/media.rs | 9 +++ crates/engine/src/memory.rs | 102 ++++++++++++++++++++++++++++++++ crates/engine/src/tests.rs | 20 +++++++ crates/gpu/src/ctx.rs | 96 +++++++++++++++++++++++++----- crates/gpu/src/lib.rs | 59 ++++++++++++++++++ crates/gpu/src/render.rs | 35 +++++++++++ crates/pipeline/src/lib.rs | 33 +++++++++++ crates/pipeline/src/tests.rs | 5 ++ crates/ui-egui/src/control.rs | 10 ++++ crates/ui-egui/src/render.rs | 12 ++++ docs/control-protocol.md | 2 +- docs/gpu-pipeline.md | 13 ++++ 18 files changed, 486 insertions(+), 17 deletions(-) create mode 100644 crates/engine/src/memory.rs diff --git a/Cargo.lock b/Cargo.lock index ead3762c..fa371fe2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -131,6 +131,15 @@ dependencies = [ "winit", ] +[[package]] +name = "addr2line" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" +dependencies = [ + "gimli", +] + [[package]] name = "adler2" version = "2.0.1" @@ -519,6 +528,21 @@ dependencies = [ "arrayvec", ] +[[package]] +name = "backtrace" +version = "0.3.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide 0.8.9", + "object", + "rustc-demangle", + "windows-link", +] + [[package]] name = "bit-set" version = "0.8.0" @@ -890,6 +914,22 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" +[[package]] +name = "dhat" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98cd11d84628e233de0ce467de10b8633f4ddaecafadefc86e13b84b8739b827" +dependencies = [ + "backtrace", + "lazy_static", + "mintex", + "parking_lot", + "rustc-hash 1.1.0", + "serde", + "serde_json", + "thousands", +] + [[package]] name = "dispatch" version = "0.2.0" @@ -1393,6 +1433,12 @@ dependencies = [ "weezl", ] +[[package]] +name = "gimli" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" + [[package]] name = "gl_generator" version = "0.14.0" @@ -1956,6 +2002,12 @@ dependencies = [ "smallvec", ] +[[package]] +name = "lazy_static" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" + [[package]] name = "libc" version = "0.2.189" @@ -2032,6 +2084,7 @@ dependencies = [ name = "lightcraft-cli" version = "0.1.0" dependencies = [ + "dhat", "lightcraft-codecs", "lightcraft-engine", "lightcraft-mcp", @@ -2373,6 +2426,12 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "mintex" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c505b3e17ed6b70a7ed2e67fbb2c560ee327353556120d6e72f5232b6880d536" + [[package]] name = "moxcms" version = "0.8.1" @@ -2881,6 +2940,15 @@ dependencies = [ "objc2-foundation 0.2.2", ] +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -3483,6 +3551,12 @@ version = "0.8.53" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" +[[package]] +name = "rustc-demangle" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b74b56ffa8bb2830709a538c2cbcae9aa062db0d2a42563bfb09bdaae44020eb" + [[package]] name = "rustc-hash" version = "1.1.0" @@ -3896,6 +3970,12 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "thousands" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bf63baf9f5039dadc247375c29eb13706706cfde997d0330d05aa63a77d8820" + [[package]] name = "tiff" version = "0.11.3" diff --git a/Cargo.toml b/Cargo.toml index 76abfa4c..e7287c0b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -39,6 +39,7 @@ bytemuck = { version = "1", features = ["derive"] } crc32fast = "1" siphasher = "1" libc = "0.2" # dev-only: process CPU time in benches +dhat = "0.3" # opt-in heap profiling (lightcraft-cli --features dhat-heap); never in default builds # formats quick-xml = "0.38" diff --git a/apps/lightcraft-cli/Cargo.toml b/apps/lightcraft-cli/Cargo.toml index 939bdefe..3770a91b 100644 --- a/apps/lightcraft-cli/Cargo.toml +++ b/apps/lightcraft-cli/Cargo.toml @@ -20,3 +20,10 @@ lightcraft-ui-egui = { workspace = true } lightcraft-codecs = { workspace = true } lightcraft-raster = { workspace = true } serde_json = { workspace = true } +dhat = { workspace = true, optional = true } + +[features] +# Heap profiling: `cargo run --release -p lightcraft-cli --features dhat-heap -- snapshot …` counts +# every allocation (live/peak bytes in `library.memory` / `ui.inspect`) and writes dhat-heap.json +# (open it in DHAT's viewer: allocation sites by bytes at the peak). Slows allocation-heavy work. +dhat-heap = ["dep:dhat"] diff --git a/apps/lightcraft-cli/src/main.rs b/apps/lightcraft-cli/src/main.rs index 46186fb4..2f36513e 100644 --- a/apps/lightcraft-cli/src/main.rs +++ b/apps/lightcraft-cli/src/main.rs @@ -51,7 +51,22 @@ USAGE: lightcraft-cli --version | --help "; +#[cfg(feature = "dhat-heap")] +#[global_allocator] +static ALLOC: dhat::Alloc = dhat::Alloc; + fn main() -> ExitCode { + // `--features dhat-heap`: count allocations; the profile is written when `_heap` drops + // (LIGHTCRAFT_DHAT_FILE, default dhat-heap.json). + #[cfg(feature = "dhat-heap")] + let _heap = { + let file = std::env::var("LIGHTCRAFT_DHAT_FILE").unwrap_or_else(|_| "dhat-heap.json".into()); + lightcraft_engine::memory::set_heap_stats(|| { + let s = dhat::HeapStats::get(); + lightcraft_engine::memory::HeapUsage { current: s.curr_bytes as u64, peak: s.max_bytes as u64 } + }); + dhat::Profiler::builder().file_name(file).build() + }; let args: Vec = std::env::args().skip(1).collect(); let r = match args.first().map(String::as_str) { Some("mcp") => mcp(&args[1..]), diff --git a/crates/engine/src/cmd/query.rs b/crates/engine/src/cmd/query.rs index 5348c351..1d0bb6c0 100644 --- a/crates/engine/src/cmd/query.rs +++ b/crates/engine/src/cmd/query.rs @@ -135,6 +135,9 @@ pub fn specs() -> Vec { } Ok(json!({"enabled": lightcraft_gpu::enabled(), "available": lightcraft_gpu::available(), "adapter": lightcraft_gpu::adapter_name()})) }), + cmd!(query "library.memory", "Memory Usage", [], None, "{} — bytes held by each cache (decoded sources, rendered previews, GPU buffers; heap when instrumented)", always, |s, _| { + Ok(serde_json::to_value(s.memory_report()).unwrap_or_default()) + }), cmd!(query "journal.list", "Command Journal", [], None, "{limit?}", always, |s, p| { let lim = p.get("limit").and_then(Value::as_u64).unwrap_or(100) as usize; let start = s.journal.len().saturating_sub(lim); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 2f971c97..6fcddef3 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -18,6 +18,7 @@ pub mod files; pub mod import; pub mod library; pub mod media; +pub mod memory; pub mod presets; pub mod sidecar; mod view; diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index c2b6b845..f851b032 100644 --- a/crates/engine/src/media.rs +++ b/crates/engine/src/media.rs @@ -169,6 +169,15 @@ impl MediaCache { (self.thumbs.len(), self.thumbs.cost()) } + /// Decoded sources held: (thumbnail level, preview level, full size). + pub fn usage(&self) -> (crate::memory::Usage, crate::memory::Usage, crate::memory::Usage) { + use crate::memory::Usage; + let bytes = |i: &Rgb32f| i.data.len() * 12; + let previews = Usage::new(self.previews.len(), self.previews.iter().map(|(_, a)| bytes(a)).sum()); + let full = self.full.as_ref().map(|(_, a)| Usage::new(1, bytes(a))).unwrap_or_default(); + (Usage::new(self.thumbs.len(), self.thumbs.cost()), previews, full) + } + pub fn source_ref(&mut self, p: &Photo, level: SourceLevel) -> SourceRef { if let Some(a) = self.get(p.id, level) { return SourceRef::Loaded(a); diff --git a/crates/engine/src/memory.rs b/crates/engine/src/memory.rs new file mode 100644 index 00000000..2a9ba9e1 --- /dev/null +++ b/crates/engine/src/memory.rs @@ -0,0 +1,102 @@ +//! Memory accounting: what each cache holds (`library.memory`, and `ui.inspect` → `memory` with the +//! frontend's own caches added). +//! +//! The numbers are the caches' own bookkeeping (pixels held), not the process's resident size: the +//! allocator keeps freed pages for reuse and the GPU driver maps device buffers, so `ps`/`time -l` +//! report more. A binary built with a heap profiler installs [`set_heap_stats`] (e.g. +//! `lightcraft-cli` with `--features dhat-heap`) to add live/peak heap bytes. + +use std::sync::OnceLock; + +use serde::Serialize; + +/// Entries and bytes held by one cache. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize)] +pub struct Usage { + pub count: usize, + pub bytes: usize, +} + +impl Usage { + pub fn new(count: usize, bytes: usize) -> Usage { + Usage { count, bytes } + } +} + +/// Heap bytes as counted by an instrumented allocator. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct HeapUsage { + /// Live heap bytes now. + pub current: u64, + /// Highest live heap bytes so far. + pub peak: u64, +} + +/// Device buffers of the GPU renderer. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GpuUsage { + /// Every device buffer that exists. + pub allocated: u64, + /// Of which recycled buffers waiting in the free pool. + pub pooled: u64, + /// Of which buffers released since their thread's last submit. + pub retired: u64, +} + +/// What the engine's caches hold. +#[derive(Clone, Debug, Default, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct MemoryReport { + /// Decoded thumbnail-level sources (≤ 512 px, linear float). + pub thumb_sources: Usage, + /// Decoded preview-level sources (≤ 2560 px, linear float) of the current and prefetched photos. + pub preview_sources: Usage, + /// The last full-resolution original (exports, 1:1). + pub full_source: Usage, + /// Rendered thumbnails and view renders (8-bit) in memory. + pub rendered: Usage, + /// Sum of the above. + pub engine_bytes: usize, + pub gpu: GpuUsage, + /// Live/peak heap when the binary counts allocations. + #[serde(skip_serializing_if = "Option::is_none")] + pub heap: Option, +} + +static HEAP: OnceLock HeapUsage> = OnceLock::new(); + +/// Install the heap counter of an instrumented binary (first call wins). +pub fn set_heap_stats(f: fn() -> HeapUsage) { + let _ = HEAP.set(f); +} + +/// Live/peak heap bytes, when the binary installed a counter. +pub fn heap_stats() -> Option { + HEAP.get().map(|f| f()) +} + +/// The GPU renderer's device buffers. +pub fn gpu_usage() -> GpuUsage { + let g = lightcraft_gpu::memory(); + GpuUsage { allocated: g.allocated, pooled: g.pooled, retired: g.retired } +} + +impl crate::Session { + /// What the engine's caches hold now. + pub fn memory_report(&self) -> MemoryReport { + let (thumb_sources, preview_sources, full_source) = self.media.usage(); + let (n, b) = self.media.rendered.mem_usage(); + let rendered = Usage::new(n, b); + MemoryReport { + thumb_sources, + preview_sources, + full_source, + rendered, + engine_bytes: thumb_sources.bytes + preview_sources.bytes + full_source.bytes + rendered.bytes, + gpu: gpu_usage(), + heap: heap_stats(), + } + } +} diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index a6623710..9b6e8bd6 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -189,3 +189,23 @@ fn upright_and_guides_commands() { assert!(s.render_now(id, 160, 160).is_ok()); assert!(s.execute("geometry.upright", &json!({"mode": "sideways"})).is_err()); } + +#[test] +fn memory_report_counts_decoded_sources_and_renders() { + let mut s = demo(); + let r = s.execute("library.memory", &json!({})).unwrap(); + assert_eq!(r["previewSources"]["count"], 0); + assert!(r["gpu"]["allocated"].is_u64() && r.get("engineBytes").is_some()); + let id = s.active().unwrap(); + s.render_now(id, 800, 600).unwrap(); + let m = s.memory_report(); + assert_eq!(m.preview_sources.count, 1); + // a 2560 px linear float source: 12 bytes per pixel + assert!(m.preview_sources.bytes >= 12 * 2560 * 1000, "{:?}", m.preview_sources); + assert_eq!(m.engine_bytes, m.thumb_sources.bytes + m.preview_sources.bytes + m.full_source.bytes + m.rendered.bytes); + let r = s.thumb_job(id, 200).unwrap().run(); + s.accept(&r); + let m = s.memory_report(); + assert_eq!(m.thumb_sources.count, 1); + assert!(m.rendered.count >= 1, "the thumbnail render is cached"); +} diff --git a/crates/gpu/src/ctx.rs b/crates/gpu/src/ctx.rs index b71471f6..25d6ef4d 100644 --- a/crates/gpu/src/ctx.rs +++ b/crates/gpu/src/ctx.rs @@ -1,6 +1,7 @@ //! The wgpu device, the compiled kernels, buffers, dispatch and readback. use std::collections::HashMap; +use std::sync::atomic::{AtomicU64, Ordering}; use wgpu::util::DeviceExt; @@ -64,31 +65,72 @@ struct Kernel { /// as the queue then runs every earlier use before any later one. Reuse matters: allocating (and /// zero-filling) a fresh 100–300 MB buffer per pass costs as much as the pass itself. pub struct Buf { - buf: Option, + buf: Option, pub len: usize, } impl Buf { pub fn raw(&self) -> &wgpu::Buffer { - self.buf.as_ref().expect("live buffer") + &self.buf.as_ref().expect("live buffer").0 } } impl Drop for Buf { fn drop(&mut self) { if let Some(b) = self.buf.take() { + RETIRED_BYTES.fetch_add(b.0.size(), Ordering::Relaxed); RETIRED.with(|r| r.borrow_mut().push(b)); } } } -thread_local! { - /// Buffers dropped on this thread since its last submit. - static RETIRED: std::cell::RefCell> = const { std::cell::RefCell::new(Vec::new()) }; +/// A device buffer, counted in [`ALLOCATED`] while it exists. +pub(crate) struct Tracked(wgpu::Buffer); + +impl Tracked { + fn new(b: wgpu::Buffer) -> Tracked { + ALLOCATED.fetch_add(b.size(), Ordering::Relaxed); + Tracked(b) + } } -/// Most bytes kept in the free pool. -const POOL_BYTES: u64 = 2 << 30; +impl Drop for Tracked { + fn drop(&mut self) { + ALLOCATED.fetch_sub(self.0.size(), Ordering::Relaxed); + } +} + +thread_local! { + /// Buffers dropped on this thread since its last submit. + static RETIRED: std::cell::RefCell> = const { std::cell::RefCell::new(Vec::new()) }; +} + +/// Bytes of device buffers that exist (in use, retired or pooled). +static ALLOCATED: AtomicU64 = AtomicU64::new(0); +/// Bytes waiting in per-thread retired lists. +static RETIRED_BYTES: AtomicU64 = AtomicU64::new(0); +/// Bytes in the free pool. +static POOLED: AtomicU64 = AtomicU64::new(0); +/// Most bytes kept in the free pool (see [`crate::set_pool_limit`]). +pub(crate) static POOL_LIMIT: AtomicU64 = AtomicU64::new(DEFAULT_POOL_BYTES); + +/// Default for the most bytes kept in the free pool. +pub(crate) const DEFAULT_POOL_BYTES: u64 = 2 << 30; + +/// Device buffers held by the renderer (bytes). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct GpuMemory { + /// Every buffer that exists (in use + retired + pooled). + pub allocated: u64, + /// Of which recycled buffers waiting in the free pool. + pub pooled: u64, + /// Of which buffers dropped since their thread's last submit (pooled at its next submit). + pub retired: u64, +} + +pub(crate) fn memory() -> GpuMemory { + GpuMemory { allocated: ALLOCATED.load(Ordering::Relaxed), pooled: POOLED.load(Ordering::Relaxed), retired: RETIRED_BYTES.load(Ordering::Relaxed) } +} pub struct Gpu { pub device: wgpu::Device, @@ -97,7 +139,7 @@ pub struct Gpu { kernels: HashMap<&'static str, Kernel>, dummy: wgpu::Buffer, /// Recycled buffers (see [`Buf`]). - free: std::sync::Mutex>, + free: std::sync::Mutex>, /// Largest storage buffer the device accepts (bytes). pub max_buffer: u64, } @@ -217,15 +259,19 @@ impl Gpu { let size = (len.max(1) * 4) as u64; let recycled = { let mut f = self.free.lock().unwrap_or_else(|e| e.into_inner()); - f.iter().position(|b| b.size() == size).map(|i| f.swap_remove(i)) + let b = f.iter().position(|b| b.0.size() == size).map(|i| f.swap_remove(i)); + if b.is_some() { + POOLED.fetch_sub(size, Ordering::Relaxed); + } + b }; let buf = recycled.unwrap_or_else(|| { - self.device.create_buffer(&wgpu::BufferDescriptor { + Tracked::new(self.device.create_buffer(&wgpu::BufferDescriptor { label: None, size, usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false, - }) + })) }); Buf { buf: Some(buf), len } } @@ -234,11 +280,29 @@ impl Gpu { pub fn submit(&self, cmds: impl IntoIterator) { self.queue.submit(cmds); let retired = RETIRED.with(|r| std::mem::take(&mut *r.borrow_mut())); + let bytes: u64 = retired.iter().map(|b| b.0.size()).sum(); + RETIRED_BYTES.fetch_sub(bytes, Ordering::Relaxed); + POOLED.fetch_add(bytes, Ordering::Relaxed); + self.free.lock().unwrap_or_else(|e| e.into_inner()).extend(retired); + self.trim(POOL_LIMIT.load(Ordering::Relaxed)); + } + + /// Free pooled buffers (oldest first) until at most `keep` bytes stay pooled. + pub fn trim(&self, keep: u64) { let mut f = self.free.lock().unwrap_or_else(|e| e.into_inner()); - f.extend(retired); - let mut total: u64 = f.iter().map(|b| b.size()).sum(); - while total > POOL_BYTES && !f.is_empty() { - total -= f.remove(0).size(); + let mut total: u64 = f.iter().map(|b| b.0.size()).sum(); + let mut dropped = Vec::new(); + while total > keep && !f.is_empty() { + let b = f.remove(0); + total -= b.0.size(); + POOLED.fetch_sub(b.0.size(), Ordering::Relaxed); + dropped.push(b); + } + drop(f); + if !dropped.is_empty() { + drop(dropped); + // let wgpu release the memory now rather than at its next maintenance + let _ = self.device.poll(wgpu::PollType::Poll); } } @@ -254,7 +318,7 @@ impl Gpu { contents: bytes, usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC | wgpu::BufferUsages::COPY_DST, }); - Buf { buf: Some(buf), len } + Buf { buf: Some(Tracked::new(buf)), len } } /// Whether a buffer of `len` 32-bit values fits the device limits. diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index ee88430b..c767a7d2 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -114,6 +114,65 @@ pub fn adapter_name() -> Option { } } +/// Device buffers held by the renderer. +#[cfg(not(target_arch = "wasm32"))] +pub use ctx::GpuMemory; + +/// Device buffers held by the renderer. +#[cfg(target_arch = "wasm32")] +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct GpuMemory { + pub allocated: u64, + pub pooled: u64, + pub retired: u64, +} + +/// Device memory held by the renderer's buffers (zero without a device). +pub fn memory() -> GpuMemory { + #[cfg(not(target_arch = "wasm32"))] + { + ctx::memory() + } + #[cfg(target_arch = "wasm32")] + { + GpuMemory::default() + } +} + +/// Keep at most `bytes` of recycled buffers in the free pool from now on (trimming it now). +pub fn set_pool_limit(bytes: u64) { + #[cfg(not(target_arch = "wasm32"))] + { + ctx::POOL_LIMIT.store(bytes, Ordering::Relaxed); + trim_pool(bytes); + } + #[cfg(target_arch = "wasm32")] + let _ = bytes; +} + +/// Free recycled buffers until at most `keep` bytes stay pooled (e.g. when the app goes idle). +pub fn trim_pool(keep: u64) { + #[cfg(not(target_arch = "wasm32"))] + if let Some(g) = GPU.get().and_then(|g| g.as_ref()) { + g.trim(keep); + } + #[cfg(target_arch = "wasm32")] + let _ = keep; +} + +/// Device bytes held by a view's GPU stages (kept with its [`StageCache`]); 0 when it has none. +pub fn stage_bytes(stages: &StageCache) -> usize { + #[cfg(not(target_arch = "wasm32"))] + { + stages.peek_extension::().map(|g| g.bytes()).unwrap_or(0) + } + #[cfg(target_arch = "wasm32")] + { + let _ = stages; + 0 + } +} + /// Render `src` with `s` on the GPU, reusing the device-resident stages kept with `stages` (the /// view's CPU stage cache). `None`: render on the CPU instead. pub fn render(src: &Arc, info: &SourceInfo, s: &DevelopSettings, req: &RenderRequest, stages: Option<&StageCache>) -> Option { diff --git a/crates/gpu/src/render.rs b/crates/gpu/src/render.rs index 72c51f5f..b0dc2120 100644 --- a/crates/gpu/src/render.rs +++ b/crates/gpu/src/render.rs @@ -82,6 +82,41 @@ impl GpuStages { self.lock().len() } + /// Device bytes held (each buffer counted once). + pub fn bytes(&self) -> usize { + let mut seen: Vec<*const Buf> = Vec::new(); + let mut total = 0; + let mut add = |b: &Arc| { + let p = Arc::as_ptr(b); + if !seen.contains(&p) { + seen.push(p); + total += b.len * 4; + } + }; + for e in self.lock().iter() { + add(&e.sampled); + if let Some((_, b)) = &e.lin { + add(b); + } + let p = &e.planes; + p.log_l.iter().for_each(&mut add); + p.base.iter().for_each(|(_, b)| add(b)); + p.clarity.iter().for_each(|(_, b)| add(b)); + p.texture.iter().for_each(|(_, b)| add(b)); + p.dark.iter().for_each(|(_, b, _)| add(b)); + } + if let Some((_, b)) = &*self.source.lock().unwrap_or_else(|e| e.into_inner()) { + add(b); + } + total + } + + /// Drop everything (e.g. when memory is needed). + pub fn clear(&self) { + self.lock().clear(); + *self.source.lock().unwrap_or_else(|e| e.into_inner()) = None; + } + pub fn is_empty(&self) -> bool { self.len() == 0 } diff --git a/crates/pipeline/src/lib.rs b/crates/pipeline/src/lib.rs index 4ba0d886..17cfc622 100644 --- a/crates/pipeline/src/lib.rs +++ b/crates/pipeline/src/lib.rs @@ -167,6 +167,39 @@ impl StageCache { t } + /// The per-view state of type `T` if there is one (unlike [`Self::extension`], never creates it). + pub fn peek_extension(&self) -> Option> { + self.ext.lock().unwrap_or_else(|e| e.into_inner()).as_ref().and_then(|e| e.clone().downcast::().ok()) + } + + /// Bytes of the intermediate images held (each buffer counted once; the sources they were + /// made from belong to their owner and are not counted). + pub fn bytes(&self) -> usize { + fn size(i: &lightcraft_raster::Image) -> usize { + i.data.len() * std::mem::size_of::() + } + let mut seen: Vec = Vec::new(); + let mut total = 0; + let mut add = |p: usize, n: usize| { + if !seen.contains(&p) { + seen.push(p); + total += n; + } + }; + for e in self.lock().iter() { + add(Arc::as_ptr(&e.sampled) as usize, size(&e.sampled)); + if let Some((_, l)) = &e.lin { + add(Arc::as_ptr(l) as usize, size(l)); + } + let pl = &e.planes; + let planes = pl.log_l.iter().chain(pl.base.iter().map(|x| &x.1)).chain(pl.clarity.iter().map(|x| &x.1)); + for p in planes.chain(pl.texture.iter().map(|x| &x.1)).chain(pl.dark.iter().map(|x| &x.1)) { + add(Arc::as_ptr(p) as usize, size(p)); + } + } + total + } + /// Number of cached output sizes. pub fn len(&self) -> usize { self.lock().len() diff --git a/crates/pipeline/src/tests.rs b/crates/pipeline/src/tests.rs index 0361cb95..6a8c4e4a 100644 --- a/crates/pipeline/src/tests.rs +++ b/crates/pipeline/src/tests.rs @@ -209,6 +209,11 @@ fn stage_cache_matches_uncached_render_through_a_slider_session() { } } assert_eq!(cache.len(), 2); + // both sizes' sampled + linear images at least (12 bytes per pixel each), nothing after clear() + let b = cache.bytes(); + assert!(b >= 2 * 12 * (200 * 133 + 120 * 80), "{b}"); + cache.clear(); + assert_eq!(cache.bytes(), 0); // a different source buffer never hits another source's entries let other = Arc::new(Rgb32f::filled(src.width, src.height, [0.3, 0.3, 0.3])); assert_eq!(render_cached(&other, &info, &s, &full, &cache).image, render(&other, &info, &s, &full).image); diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index c9220302..9fad1a4f 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -80,9 +80,19 @@ pub fn inspect(app: &LightcraftApp, ctx: &egui::Context) -> Value { "perf": {"frameMs": app.perf.frame_ms, "fps": app.perf.fps, "lastRenderMs": app.renderer.last_main_ms, "renderQueue": app.renderer.queued(), "rendersInFlight": app.renderer.in_flight(), "rendersDone": app.renderer.completed, "thumbTextures": app.renderer.thumb_textures(), "gpu": (lightcraft_engine::gpu::ready() && lightcraft_engine::gpu::available()).then(lightcraft_engine::gpu::adapter_name).flatten()}, "loupe": app.loupe_shown.map(|(p, src)| json!({"photo": p.0, "source": src, "pending": app.renderer.is_pending(crate::render::Slot::Main)})), "status": app.ui.status, + "memory": memory(app), }) } +/// Bytes held by the engine's caches and the renderer's (`ui.inspect` → `memory`). +pub fn memory(app: &LightcraftApp) -> Value { + let mut v = serde_json::to_value(app.session.memory_report()).unwrap_or_default(); + if let (Some(o), Value::Object(r)) = (v.as_object_mut(), app.renderer.memory()) { + o.extend(r); + } + v +} + fn modifiers(p: &Value) -> egui::Modifiers { let b = |n: &str| p.get(n).and_then(Value::as_bool).unwrap_or(false); egui::Modifiers { alt: b("alt"), ctrl: b("ctrl"), shift: b("shift"), mac_cmd: b("cmd") && cfg!(target_os = "macos"), command: b("cmd") } diff --git a/crates/ui-egui/src/render.rs b/crates/ui-egui/src/render.rs index 3703dcb8..8cafb8d3 100644 --- a/crates/ui-egui/src/render.rs +++ b/crates/ui-egui/src/render.rs @@ -298,6 +298,18 @@ impl Renderer { changed } + /// What the renderer holds: per-view stage caches (CPU images and GPU buffers) and textures. + pub fn memory(&self) -> serde_json::Value { + let cpu: usize = self.stages.values().map(|s| s.bytes()).sum(); + let gpu: usize = self.stages.values().map(|s| lightcraft_engine::gpu::stage_bytes(s)).sum(); + let tex: usize = self.textures.values().map(|t| t.size[0] * t.size[1] * 4).sum(); + let copies: usize = self.textures.values().filter_map(|t| t.pixels.as_ref()).map(|p| p.pixels.len() * 4).sum(); + serde_json::json!({ + "stageCaches": {"count": self.stages.len(), "cpuBytes": cpu, "gpuBytes": gpu}, + "textures": {"count": self.textures.len(), "bytes": tex, "cpuCopyBytes": copies}, + }) + } + /// CPU copies of the current textures by id (see [`Self::keep_pixels`]). pub fn cpu_textures(&self) -> HashMap { self.textures.values().filter_map(|t| Some((t.tex.id(), crate::softpaint::CpuTexture::linear(t.pixels.clone()?)))).collect() diff --git a/docs/control-protocol.md b/docs/control-protocol.md index c3cb8103..254040b5 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -20,7 +20,7 @@ mode ([mcp.md](mcp.md)) is a thin layer over this channel. Implementation: | `engine.execute` (alias `ui.menu.invoke`) | `{command, params?}` | Run any engine or UI command (see `engine.commands`) | | `engine.commands` | — | Engine + UI commands: id, label, menu, shortcut, params doc, enabled | | `ui.menu.list` | — | Menu entries | -| `ui.inspect` | — | UI state, window, canvas/image rects, active photo, selection, perf, status | +| `ui.inspect` | — | UI state, window, canvas/image rects, active photo, selection, perf, status, memory (bytes per cache, see `library.memory`; plus stage caches and textures) | | `ui.widgets` | `{filter?}` | On-screen widgets `{id, rect: [x, y, w, h]}` (screen points) | | `ui.clickWidget` / `ui.dragWidget` | `{id, count?, fx?, fy?}` / `{id, toX?, toY?, dx?, dy?, steps?}` | Real egui input on a widget | | `ui.move` / `ui.click` / `ui.drag` | `{x, y, count?, button?}` / `{x, y, toX, toY, steps?}` | Raw pointer input, screen points | diff --git a/docs/gpu-pipeline.md b/docs/gpu-pipeline.md index 88f19924..82a57e61 100644 --- a/docs/gpu-pipeline.md +++ b/docs/gpu-pipeline.md @@ -88,6 +88,19 @@ the device on their first GPU render. `lightcraft_gpu::ready()` asks without blo one is rendered (decoded source kept, view render cached): stepping through photos shows the developed image in ~50 ms. +## Memory (M5.6) +- `library.memory` reports what the engine's caches hold (decoded thumbnail / preview / full-size + sources, rendered previews) and the GPU renderer's device buffers (allocated, of which pooled + and retired); `ui.inspect` → `memory` adds the loupe's stage caches (CPU images, GPU buffers) + and the textures. +- Heap profile: build `lightcraft-cli` with `--features dhat-heap`; `library.memory` then also + reports live/peak heap bytes and the run writes `dhat-heap.json` (`LIGHTCRAFT_DHAT_FILE`), whose + allocation sites at the peak (`t-gmax`) show who holds the memory. +- Measuring the scenario (import 14 raws from `corpus/raw`, open the loupe, step 12 times): + `/usr/bin/time -l lightcraft-cli snapshot --script steps.jsonl -o out.png` → "maximum + resident set size" and "peak memory footprint". Run it several times: the high-water mark is + noisy (allocator caching, scheduling). On Apple silicon GPU buffers count in the footprint. + ## Open items - Keep the loupe texture on the GPU (render into an `egui-wgpu` texture on eframe's device instead of reading back); share the device with eframe.