diff --git a/apps/lightcraft-cli/src/alloc_release.rs b/apps/lightcraft-cli/src/alloc_release.rs new file mode 100644 index 000000000..0d0775bad --- /dev/null +++ b/apps/lightcraft-cli/src/alloc_release.rs @@ -0,0 +1,32 @@ +//! Give memory the system allocator keeps after frees back to the system +//! ([`lightcraft_engine::memory::set_release_hook`]). +//! +//! macOS's allocator keeps freed large blocks mapped as "reusable": they no longer count in the +//! process's memory footprint but stay in its resident size until the system runs short. After a +//! raw decode (hundreds of MB of temporaries) that made the resident size climb towards the sum of +//! everything ever decoded. `malloc_zone_pressure_relief` (libSystem) returns those pages now. +//! Elsewhere (glibc, Windows) blocks this large are unmapped when freed: nothing to do. + +/// Install the hook for this platform. +pub fn install() { + #[cfg(target_os = "macos")] + lightcraft_engine::memory::set_release_hook(macos::release); +} + +#[cfg(target_os = "macos")] +#[allow(unsafe_code)] +mod macos { + unsafe extern "C" { + /// libSystem malloc: release free memory of `zone` (all zones when null) back to the + /// system, up to `goal` bytes (0 = as much as possible). Returns the bytes released. + fn malloc_zone_pressure_relief(zone: *mut std::ffi::c_void, goal: usize) -> usize; + } + + pub fn release() { + // SAFETY: a documented libSystem function taking a null zone (= every zone) and a size; + // it only returns free pages to the system and touches no memory we own. + unsafe { + malloc_zone_pressure_relief(std::ptr::null_mut(), 0); + } + } +} diff --git a/apps/lightcraft-cli/src/main.rs b/apps/lightcraft-cli/src/main.rs index 246fd96c7..2988c3278 100644 --- a/apps/lightcraft-cli/src/main.rs +++ b/apps/lightcraft-cli/src/main.rs @@ -8,6 +8,8 @@ //! lightcraft-cli controls [--json] //! ``` +mod alloc_release; + use std::io::{BufReader, Write}; use std::path::Path; use std::process::ExitCode; @@ -67,6 +69,7 @@ fn main() -> ExitCode { }); dhat::Profiler::builder().file_name(file).build() }; + alloc_release::install(); let args: Vec = std::env::args().skip(1).collect(); let r = match args.first().map(String::as_str) { Some("mcp") => mcp(&args[1..]), @@ -337,6 +340,9 @@ fn snapshot(args: &[String]) -> Result<(), String> { }; if let Some(o) = reply.as_object_mut() { o.insert("id".into(), id); + // wall time of the request (incl. the frames it ran), and since the start + o.insert("ms".into(), json!((ts.elapsed().as_secs_f64() * 1e4).round() / 10.0)); + o.insert("t".into(), json!((t0.elapsed().as_secs_f64() * 1e4).round() / 10.0)); } writeln!(out, "{reply}").map_err(|e| e.to_string())?; if method == "ui.screenshot" { diff --git a/apps/lightcraft/src/alloc_release.rs b/apps/lightcraft/src/alloc_release.rs new file mode 100644 index 000000000..0d0775bad --- /dev/null +++ b/apps/lightcraft/src/alloc_release.rs @@ -0,0 +1,32 @@ +//! Give memory the system allocator keeps after frees back to the system +//! ([`lightcraft_engine::memory::set_release_hook`]). +//! +//! macOS's allocator keeps freed large blocks mapped as "reusable": they no longer count in the +//! process's memory footprint but stay in its resident size until the system runs short. After a +//! raw decode (hundreds of MB of temporaries) that made the resident size climb towards the sum of +//! everything ever decoded. `malloc_zone_pressure_relief` (libSystem) returns those pages now. +//! Elsewhere (glibc, Windows) blocks this large are unmapped when freed: nothing to do. + +/// Install the hook for this platform. +pub fn install() { + #[cfg(target_os = "macos")] + lightcraft_engine::memory::set_release_hook(macos::release); +} + +#[cfg(target_os = "macos")] +#[allow(unsafe_code)] +mod macos { + unsafe extern "C" { + /// libSystem malloc: release free memory of `zone` (all zones when null) back to the + /// system, up to `goal` bytes (0 = as much as possible). Returns the bytes released. + fn malloc_zone_pressure_relief(zone: *mut std::ffi::c_void, goal: usize) -> usize; + } + + pub fn release() { + // SAFETY: a documented libSystem function taking a null zone (= every zone) and a size; + // it only returns free pages to the system and touches no memory we own. + unsafe { + malloc_zone_pressure_relief(std::ptr::null_mut(), 0); + } + } +} diff --git a/apps/lightcraft/src/main.rs b/apps/lightcraft/src/main.rs index baac940f1..f6ea375f6 100644 --- a/apps/lightcraft/src/main.rs +++ b/apps/lightcraft/src/main.rs @@ -12,6 +12,7 @@ //! `{"id":1,"method":"ui.inspect","params":{}}` → `{"id":1,"ok":true,"result":…}`. //! See `lightcraft_ui_egui::control` for the methods. +mod alloc_release; mod control_server; use lightcraft_engine::Session; @@ -167,6 +168,7 @@ OPTIONS: "; fn main() -> eframe::Result { + alloc_release::install(); let mut control_port: Option = std::env::var("LIGHTCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok()); let mut files = Vec::new(); let mut seed_demo = true; diff --git a/crates/engine/src/cmd/query.rs b/crates/engine/src/cmd/query.rs index 1d0bb6c0f..964a0d8d0 100644 --- a/crates/engine/src/cmd/query.rs +++ b/crates/engine/src/cmd/query.rs @@ -138,6 +138,15 @@ pub fn specs() -> Vec { 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 "app.memoryBudget", "Memory Budget", [], None, "{mb?: number} — set the memory budget shared by the caches (default: a quarter of RAM, at most 1536 MB); returns the report", always, |s, p| { + if let Some(mb) = p.get("mb").and_then(Value::as_f64) { + if !(64.0..=1_048_576.0).contains(&mb) { + return Err(bad("app.memoryBudget", "mb must be 64..1048576")); + } + s.set_memory_budget((mb * 1048576.0) as usize); + } + 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/files.rs b/crates/engine/src/files.rs index 153922c9a..a857f6cb1 100644 --- a/crates/engine/src/files.rs +++ b/crates/engine/src/files.rs @@ -191,19 +191,26 @@ pub fn bin_factor(raw: &lightcraft_raw::RawImage, max_edge: usize) -> Option Result<(Rgb32f, SourceInfo), String> { - rayon::scope(|_| load_bytes_now(bytes, max_edge)) + rayon::scope(|_| load_bytes_now(std::borrow::Cow::Borrowed(bytes), max_edge)) } -fn load_bytes_now(bytes: &[u8], max_edge: usize) -> Result<(Rgb32f, SourceInfo), String> { - if lightcraft_raw::probe(bytes).is_some() { - let mut raw = match lightcraft_raw::decode(bytes) { +/// [`load_bytes`] taking the file's bytes: a raw file's bytes are freed as soon as it is decoded +/// (less memory held while it is developed). +pub fn load_vec(bytes: Vec, max_edge: usize) -> Result<(Rgb32f, SourceInfo), String> { + rayon::scope(move |_| load_bytes_now(std::borrow::Cow::Owned(bytes), max_edge)) +} + +fn load_bytes_now(bytes: std::borrow::Cow<'_, [u8]>, max_edge: usize) -> Result<(Rgb32f, SourceInfo), String> { + if lightcraft_raw::probe(&bytes).is_some() { + let mut raw = match lightcraft_raw::decode(&bytes) { Ok(r) => r, Err(lightcraft_raw::RawError::Unsupported(why)) => { // show the camera's embedded JPEG (rendered, not raw) until the variant is supported - return load_embedded_preview(bytes, max_edge).ok_or(format!("unsupported raw ({why}) without an embedded preview")); + return load_embedded_preview(&bytes, max_edge).ok_or(format!("unsupported raw ({why}) without an embedded preview")); } Err(e) => return Err(e.to_string()), }; + drop(bytes); // Embedded lens corrections are applied by the pipeline ("Enable Profile Corrections"), not baked in. let lens = embedded_lens(&raw.info()); raw.opcodes.list3.retain(|op| !is_lens_opcode(op)); @@ -221,6 +228,8 @@ fn load_bytes_now(bytes: &[u8], max_edge: usize) -> Result<(Rgb32f, SourceInfo), raw.develop(method).map_err(|e| e.to_string())? } }; + // the samples aren't needed any more (the colour model below reads only the tags) + raw.data = lightcraft_raw::RawData::U16(Vec::new()); let mut stages = vec![("develop", t0.elapsed())]; let xy = lightcraft_raw::color::as_shot_white_xy(&raw); let t = lightcraft_raw::color::camera_transform(&raw, xy); @@ -230,7 +239,7 @@ fn load_bytes_now(bytes: &[u8], max_edge: usize) -> Result<(Rgb32f, SourceInfo), let m = t.matrix.to_f32(); let gain = 2f32.powf(t.baseline_exposure as f32); let wb = t.wb; - let img = img.map(|p| { + img.map_in_place(|p| { let c = [p[0] * wb[0], p[1] * wb[1], p[2] * wb[2]]; [ ((m[0][0] * c[0] + m[0][1] * c[1] + m[0][2] * c[2]) * gain).max(0.0), @@ -241,7 +250,7 @@ fn load_bytes_now(bytes: &[u8], max_edge: usize) -> Result<(Rgb32f, SourceInfo), stages.push(("colour", t0.elapsed())); let img = fit(&img, max_edge, max_edge, Filter::Box); stages.push(("fit", t0.elapsed())); - let img = img.oriented(raw.orientation); + let img = img.into_oriented(raw.orientation); stages.push(("orient", t0.elapsed())); if lightcraft_pipeline::profiling() { let mut prev = std::time::Duration::ZERO; @@ -258,10 +267,11 @@ fn load_bytes_now(bytes: &[u8], max_edge: usize) -> Result<(Rgb32f, SourceInfo), let (temp, tint) = xy_to_temp_tint(xy); return Ok((img, SourceInfo { raw: true, as_shot_temp: temp.round(), as_shot_tint: tint.round(), lens })); } - let d = lightcraft_codecs::decode(bytes, lightcraft_codecs::DecodeOptions::fit(max_edge as u32, max_edge as u32)).map_err(|e| e.to_string())?; + let d = lightcraft_codecs::decode(&bytes, lightcraft_codecs::DecodeOptions::fit(max_edge as u32, max_edge as u32)).map_err(|e| e.to_string())?; + drop(bytes); let img = d.to_working(); let img = if img.width.max(img.height) > max_edge { fit(&img, max_edge, max_edge, Filter::Mitchell) } else { img }; - Ok((img.oriented(Orientation::from_exif(d.orientation)), SourceInfo::default())) + Ok((img.into_oriented(Orientation::from_exif(d.orientation)), SourceInfo::default())) } /// Orientation for an embedded preview: its own EXIF orientation when it has one, else the raw file's. @@ -311,12 +321,27 @@ pub fn fs_preview_loader() -> PreviewLoader { } /// Filesystem-backed hooks (native). On the web the host installs bytes-based hooks instead. +/// +/// Their working memory is bounded by [`crate::memory::work_gate`]: background loads (grid +/// thumbnails, neighbour prefetch: [`crate::memory::in_background`]) and import probes wait while +/// too much is in flight, each counting its file's size times the expansion of decoding it; +/// interactive loads (the loupe, exports) never wait — they are counted, so background work +/// yields to them. pub fn fs_hooks() -> (FileLoader, FileProbe) { let loader: FileLoader = Arc::new(|path: &str, max_edge: usize| { + let len = std::fs::metadata(path).map(|m| m.len() as usize).unwrap_or(0); + let weight = len * if max_edge <= crate::media::SourceLevel::Thumb.max_edge() { 3 } else { 6 }; + let gate = crate::memory::work_gate(); + let _permit = if crate::memory::is_background() { gate.acquire(weight) } else { gate.acquire_urgent(weight) }; let bytes = std::fs::read(path).map_err(|e| format!("{path}: {e}"))?; - load_bytes(&bytes, max_edge) + let r = load_vec(bytes, max_edge); + // the file, the samples and the intermediate images are gone: give their pages back + crate::memory::release(); + r }); let probe: FileProbe = Arc::new(|path: &str| { + let len = std::fs::metadata(path).map(|m| m.len() as usize).unwrap_or(0); + let _permit = crate::memory::work_gate().acquire(len); let bytes = std::fs::read(path).map_err(|e| format!("{path}: {e}"))?; probe_bytes(path, &bytes) }); diff --git a/crates/engine/src/import.rs b/crates/engine/src/import.rs index e07199d68..2be62172b 100644 --- a/crates/engine/src/import.rs +++ b/crates/engine/src/import.rs @@ -194,6 +194,7 @@ pub fn import(s: &mut Session, paths: &[String], mode: ImportMode) -> crate::Res } let probed = probe_all(s, &todo); + crate::memory::release(); let now = (s.clock)(); let mut ops = Vec::new(); for (path, info) in todo.into_iter().zip(probed) { diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index f851b0321..49d7a6db2 100644 --- a/crates/engine/src/media.rs +++ b/crates/engine/src/media.rs @@ -94,11 +94,15 @@ impl SourceRef { pub struct MediaCache { /// Decoded thumbnail-level sources (LRU by bytes). thumbs: Lru>, - previews: Vec<(PhotoId, Arc)>, + /// Decoded preview-level sources with their last use ([`lightcraft_preview::next_tick`]). + previews: Vec<(PhotoId, Arc, u64)>, /// The last full-resolution original (exports; one at a time: ~300 MB at 24 MP). - full: Option<(PhotoId, Arc)>, + full: Option<(PhotoId, Arc, u64)>, /// How many previews to keep (LRU). pub preview_capacity: usize, + /// Bytes all decoded sources and rendered previews in memory may take together (the cache + /// share of [`crate::memory::budget`]); the least recently used entry of any of them goes first. + budget: usize, pub file_loader: Option, pub file_probe: Option, pub preview_loader: Option, @@ -109,57 +113,123 @@ pub struct MediaCache { impl Default for MediaCache { fn default() -> Self { + let budget = crate::memory::cache_share(crate::memory::budget()); MediaCache { - thumbs: Lru::new(THUMB_SOURCE_BYTES), + thumbs: Lru::new(THUMB_SOURCE_BYTES.min(budget)), previews: Vec::new(), full: None, preview_capacity: 0, + budget, file_loader: None, file_probe: None, preview_loader: None, scenes: Vec::new(), - rendered: Arc::new(PreviewCache::memory(RENDERED_MEM_BYTES)), + rendered: Arc::new(PreviewCache::memory(rendered_budget(budget))), } } } +/// Memory for rendered previews out of the cache share. +fn rendered_budget(share: usize) -> usize { + RENDERED_MEM_BYTES.min(share / 4) +} + +fn source_bytes(img: &Rgb32f) -> usize { + img.data.len() * 12 + 64 +} + impl MediaCache { /// Keep rendered thumbnails on disk in `dir` as well. pub fn attach_disk_cache(&mut self, dir: &std::path::Path) { - self.rendered = Arc::new(PreviewCache::with_disk(RENDERED_MEM_BYTES, dir, DISK_CACHE_BYTES)); + self.rendered = Arc::new(PreviewCache::with_disk(rendered_budget(self.budget), dir, DISK_CACHE_BYTES)); + } + + /// Bytes the caches may hold together. + pub fn budget(&self) -> usize { + self.budget + } + + /// Change the bytes the caches may hold together, evicting at once if needed. + pub fn set_budget(&mut self, bytes: usize) { + self.budget = bytes; + self.thumbs.set_budget(THUMB_SOURCE_BYTES.min(bytes)); + self.rendered.set_mem_budget(rendered_budget(bytes)); + self.enforce_budget(); + } + + /// Bytes held by decoded sources and rendered previews in memory. + pub fn held(&self) -> usize { + let (t, p, f) = self.usage(); + t.bytes + p.bytes + f.bytes + self.rendered.mem_usage().1 + } + + /// Evict least recently used entries across the source caches and the rendered previews + /// until they fit the budget. The newest preview-level source (the photo on screen, or the + /// one just decoded) is never evicted here. + pub fn enforce_budget(&mut self) { + let mut held = self.held(); + while held > self.budget { + let newest_preview = self.previews.iter().map(|e| e.2).max(); + let candidates = [ + self.thumbs.oldest_tick().map(|t| (t, 0)), + self.previews.iter().filter(|e| Some(e.2) != newest_preview).map(|e| e.2).min().map(|t| (t, 1)), + self.full.as_ref().map(|e| (e.2, 2)), + self.rendered.oldest_tick().map(|t| (t, 3)), + ]; + let Some((tick, which)) = candidates.into_iter().flatten().min() else { break }; + let freed = match which { + 0 => self.thumbs.pop_oldest(), + 1 => self.previews.iter().position(|e| e.2 == tick).map(|i| source_bytes(&self.previews.remove(i).1)), + 2 => self.full.take().map(|e| source_bytes(&e.1)), + _ => self.rendered.evict_oldest(), + }; + match freed { + Some(b) if b > 0 => held = held.saturating_sub(b), + _ => break, + } + } } pub fn get(&mut self, id: PhotoId, level: SourceLevel) -> Option> { match level { SourceLevel::Thumb => self.thumbs.get(&id).cloned(), - SourceLevel::Preview => self.previews.iter().find(|(p, _)| *p == id).map(|(_, a)| a.clone()), - SourceLevel::Full => self.full.as_ref().filter(|(p, _)| *p == id).map(|(_, a)| a.clone()), + SourceLevel::Preview => self.previews.iter_mut().find(|e| e.0 == id).map(|e| { + e.2 = lightcraft_preview::next_tick(); + e.1.clone() + }), + SourceLevel::Full => self.full.as_mut().filter(|e| e.0 == id).map(|e| { + e.2 = lightcraft_preview::next_tick(); + e.1.clone() + }), } } pub fn insert(&mut self, id: PhotoId, level: SourceLevel, img: Arc) { + let tick = lightcraft_preview::next_tick(); match level { SourceLevel::Thumb => { - let cost = img.width * img.height * 12 + 64; + let cost = source_bytes(&img); self.thumbs.insert(id, img, cost); } SourceLevel::Preview => { - self.previews.retain(|(p, _)| *p != id); - self.previews.push((id, img)); + self.previews.retain(|e| e.0 != id); + self.previews.push((id, img, tick)); let cap = if self.preview_capacity == 0 { 4 } else { self.preview_capacity }; while self.previews.len() > cap { - self.previews.remove(0); + let oldest = self.previews.iter().enumerate().min_by_key(|(_, e)| e.2).map(|(i, _)| i).unwrap_or(0); + self.previews.remove(oldest); } } - SourceLevel::Full => self.full = Some((id, img)), + SourceLevel::Full => self.full = Some((id, img, tick)), } + self.enforce_budget(); } /// Forget a photo's decoded sources (e.g. after its file changed). pub fn forget(&mut self, id: PhotoId) { self.thumbs.remove(&id); - self.previews.retain(|(p, _)| *p != id); - if self.full.as_ref().is_some_and(|(p, _)| *p == id) { + self.previews.retain(|e| e.0 != id); + if self.full.as_ref().is_some_and(|e| e.0 == id) { self.full = None; } } @@ -172,9 +242,8 @@ impl MediaCache { /// 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(); + let previews = Usage::new(self.previews.len(), self.previews.iter().map(|e| source_bytes(&e.1)).sum()); + let full = self.full.as_ref().map(|e| Usage::new(1, source_bytes(&e.1))).unwrap_or_default(); (Usage::new(self.thumbs.len(), self.thumbs.cost()), previews, full) } @@ -536,6 +605,54 @@ pub type FileProbe = Arc Result + Send + Sync mod tests { use super::*; + #[test] + fn budget_evicts_least_recently_used_across_caches() { + let img = |w: usize| Arc::new(Rgb32f::new(w, w)); + let mut m = MediaCache::default(); + let mb = 1 << 20; + m.set_budget(30 * mb); + // 3 previews of 12 MB (1000² × 12 B) don't fit 30 MB: the least recently used goes + m.insert(PhotoId(1), SourceLevel::Preview, img(1000)); + m.insert(PhotoId(2), SourceLevel::Preview, img(1000)); + assert!(m.get(PhotoId(1), SourceLevel::Preview).is_some()); // 1 is now more recent than 2 + m.insert(PhotoId(3), SourceLevel::Preview, img(1000)); + assert!(m.get(PhotoId(2), SourceLevel::Preview).is_none()); + assert!(m.get(PhotoId(1), SourceLevel::Preview).is_some() && m.get(PhotoId(3), SourceLevel::Preview).is_some()); + // a thumbnail source and a rendered preview compete in the same budget + m.insert(PhotoId(4), SourceLevel::Thumb, img(500)); + m.rendered.put(Hash128(7), Arc::new(lightcraft_raster::Rgba8::new(1000, 1000))); + assert!(m.held() <= 30 * mb, "{}", m.held()); + // the newest preview (the one on screen) is never evicted, even alone over budget + m.set_budget(mb); + assert!(m.get(PhotoId(3), SourceLevel::Preview).is_some()); + assert_eq!(m.usage().1.count, 1); + assert_eq!(m.usage().0.count, 0); + assert_eq!(m.rendered.mem_usage().0, 0); + } + + #[test] + fn work_gate_waits_for_room() { + use crate::memory::WorkGate; + let g = Arc::new(WorkGate::new(100)); + let a = g.acquire(80); + // more than fits waits until `a` is released; urgent work never waits + let urgent = g.acquire_urgent(500); + assert_eq!(g.usage().0, 580); + drop(urgent); + let g2 = g.clone(); + let t = std::thread::spawn(move || { + let _b = g2.acquire(50); + g2.usage().0 + }); + std::thread::sleep(std::time::Duration::from_millis(50)); + assert_eq!(g.usage().0, 80, "the second holder is still waiting"); + drop(a); + assert_eq!(t.join().unwrap(), 50); + assert_eq!(g.usage().0, 0); + // a single holder may exceed the limit + drop(g.acquire(1000)); + } + #[test] fn levels_cover_sizes_and_large_exports_use_the_original() { assert_eq!(SourceLevel::for_size(256), SourceLevel::Thumb); diff --git a/crates/engine/src/memory.rs b/crates/engine/src/memory.rs index 2a9ba9e12..fa7cb878b 100644 --- a/crates/engine/src/memory.rs +++ b/crates/engine/src/memory.rs @@ -6,10 +6,169 @@ //! 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 std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Condvar, Mutex, OnceLock}; use serde::Serialize; +/// The memory budget: one number from which every cache's share is derived. +/// +/// - half for the engine's caches (decoded sources and rendered previews), evicted least recently +/// used *across* them ([`crate::media::MediaCache`]); +/// - a quarter for the working memory of decodes in flight ([`work_gate`]); +/// - an eighth for the GPU renderer's pool of recycled buffers (trimmed when the app is idle). +/// +/// The rest is headroom for what isn't cached (renders in progress, textures, the UI). Default: +/// a quarter of the machine's RAM, at most 1.5 GiB (`LIGHTCRAFT_MEMORY_MB` overrides it). +pub fn default_budget() -> usize { + if let Some(mb) = std::env::var("LIGHTCRAFT_MEMORY_MB").ok().and_then(|v| v.trim().parse::().ok()).filter(|m| *m >= 64) { + return mb << 20; + } + let cap = 3usize << 29; // 1.5 GiB + match total_ram() { + Some(ram) => (ram / 4).clamp(256 << 20, cap), + None => cap, + } +} + +/// Physical memory (bytes), when the platform tells us without native calls. +fn total_ram() -> Option { + #[cfg(target_os = "linux")] + { + let s = std::fs::read_to_string("/proc/meminfo").ok()?; + let kb: usize = s.lines().find(|l| l.starts_with("MemTotal:"))?.split_whitespace().nth(1)?.parse().ok()?; + Some(kb * 1024) + } + #[cfg(target_os = "macos")] + { + let out = std::process::Command::new("/usr/sbin/sysctl").args(["-n", "hw.memsize"]).output().ok()?; + String::from_utf8_lossy(&out.stdout).trim().parse().ok() + } + #[cfg(not(any(target_os = "linux", target_os = "macos")))] + { + None + } +} + +static BUDGET: AtomicUsize = AtomicUsize::new(0); + +/// The current budget (bytes). +pub fn budget() -> usize { + match BUDGET.load(Ordering::Relaxed) { + 0 => { + let b = default_budget(); + BUDGET.store(b, Ordering::Relaxed); + apply(b); + b + } + b => b, + } +} + +/// Change the budget (bytes, at least 64 MiB) for the process: the decode gate and the GPU pool +/// follow at once; sessions apply their cache share with [`crate::Session::set_memory_budget`]. +pub fn set_budget(bytes: usize) -> usize { + let b = bytes.max(64 << 20); + BUDGET.store(b, Ordering::Relaxed); + apply(b); + b +} + +fn apply(b: usize) { + if let Some(g) = GATE.get() { + g.set_limit(b / 4); + } + lightcraft_gpu::set_pool_limit((b / 8) as u64); +} + +/// Share of the budget for the engine's caches. +pub fn cache_share(budget: usize) -> usize { + budget / 2 +} + +/// A weighted semaphore over bytes of working memory: [`WorkGate::acquire`] waits while other +/// holders already use the limit (one holder may always proceed, whatever its size). +pub struct WorkGate { + state: Mutex<(usize, usize)>, + cv: Condvar, +} + +/// Bytes held from a [`WorkGate`] until dropped. +pub struct Permit<'a> { + gate: &'a WorkGate, + bytes: usize, +} + +impl WorkGate { + pub fn new(limit: usize) -> WorkGate { + WorkGate { state: Mutex::new((0, limit)), cv: Condvar::new() } + } + + fn lock(&self) -> std::sync::MutexGuard<'_, (usize, usize)> { + self.state.lock().unwrap_or_else(|e| e.into_inner()) + } + + pub fn set_limit(&self, limit: usize) { + self.lock().1 = limit; + self.cv.notify_all(); + } + + /// Bytes held now and the limit. + pub fn usage(&self) -> (usize, usize) { + *self.lock() + } + + /// Hold `bytes`, waiting until they fit (or nothing else is held). + pub fn acquire(&self, bytes: usize) -> Permit<'_> { + let mut g = self.lock(); + while g.0 > 0 && g.0 + bytes > g.1 { + g = self.cv.wait(g).unwrap_or_else(|e| e.into_inner()); + } + g.0 += bytes; + Permit { gate: self, bytes } + } + + /// Hold `bytes` without waiting (interactive work): counted, so background work waits for it. + pub fn acquire_urgent(&self, bytes: usize) -> Permit<'_> { + self.lock().0 += bytes; + Permit { gate: self, bytes } + } +} + +impl Drop for Permit<'_> { + fn drop(&mut self) { + let mut g = self.gate.lock(); + g.0 = g.0.saturating_sub(self.bytes); + drop(g); + self.gate.cv.notify_all(); + } +} + +thread_local! { + static BACKGROUND: std::cell::Cell = const { std::cell::Cell::new(false) }; +} + +/// Run `f` as background work (grid thumbnails, neighbour prefetch): its decodes wait at the +/// [`work_gate`] while interactive work holds the memory. +pub fn in_background(f: impl FnOnce() -> R) -> R { + let was = BACKGROUND.with(|b| b.replace(true)); + let r = f(); + BACKGROUND.with(|b| b.set(was)); + r +} + +/// Is this thread running background work ([`in_background`])? +pub fn is_background() -> bool { + BACKGROUND.with(|b| b.get()) +} + +/// The process-wide gate for decodes and import probes (limit: a quarter of the budget). +pub fn work_gate() -> &'static WorkGate { + GATE.get_or_init(|| WorkGate::new(budget() / 4)) +} + +static GATE: OnceLock = OnceLock::new(); + /// Entries and bytes held by one cache. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize)] pub struct Usage { @@ -60,12 +219,35 @@ pub struct MemoryReport { /// Sum of the above. pub engine_bytes: usize, pub gpu: GpuUsage, + /// The memory budget (see [`budget`]) and the engine caches' share of it. + pub budget: usize, + pub cache_budget: usize, + /// Working memory of decodes / probes in flight and its limit ([`work_gate`]). + pub work_bytes: usize, + pub work_limit: usize, /// Live/peak heap when the binary counts allocations. #[serde(skip_serializing_if = "Option::is_none")] pub heap: Option, } static HEAP: OnceLock HeapUsage> = OnceLock::new(); +static RELEASE: OnceLock = OnceLock::new(); + +/// Install a hook that hands memory the allocator keeps after frees back to the system (first +/// call wins). The macOS allocator keeps freed large blocks resident (reusable, but counted in +/// the resident size) until the system runs short; the apps install +/// `malloc_zone_pressure_relief` so the resident size follows what is actually held. +pub fn set_release_hook(f: fn()) { + let _ = RELEASE.set(f); +} + +/// Return freed memory to the system now (after large temporaries were dropped: a decode, an +/// import). No-op without a hook. +pub fn release() { + if let Some(f) = RELEASE.get() { + f(); + } +} /// Install the heap counter of an instrumented binary (first call wins). pub fn set_heap_stats(f: fn() -> HeapUsage) { @@ -96,7 +278,18 @@ impl crate::Session { rendered, engine_bytes: thumb_sources.bytes + preview_sources.bytes + full_source.bytes + rendered.bytes, gpu: gpu_usage(), + budget: budget(), + cache_budget: self.media.budget(), + work_bytes: work_gate().usage().0, + work_limit: work_gate().usage().1, heap: heap_stats(), } } + + /// Set the memory budget (bytes; process-wide) and apply this session's cache share. + pub fn set_memory_budget(&mut self, bytes: usize) -> usize { + let b = set_budget(bytes); + self.media.set_budget(cache_share(b)); + b + } } diff --git a/crates/gpu/src/ctx.rs b/crates/gpu/src/ctx.rs index 25d6ef4d9..e02a2818b 100644 --- a/crates/gpu/src/ctx.rs +++ b/crates/gpu/src/ctx.rs @@ -77,9 +77,47 @@ impl Buf { 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)); + let Some(b) = self.buf.take() else { return }; + // Outside a render this thread has no commands recorded (only renders record), so the + // buffer is reusable at once; without this, buffers dropped on threads that rarely + // submit (the UI thread clearing a view's stages) would wait indefinitely. + if !IN_RENDER.with(|c| c.get() > 0) + && let Some(g) = crate::existing_device() + { + g.pool([b]); + return; + } + RETIRED_BYTES.fetch_add(b.0.size(), Ordering::Relaxed); + RETIRED.with(|r| r.borrow_mut().push(b)); + } +} + +thread_local! { + /// Renders running on this thread (they may hold recorded, unsubmitted commands). + static IN_RENDER: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// Marks a render on this thread; when it ends, the buffers it released join the pool (everything +/// it recorded has been submitted by then). +pub(crate) struct RenderScope<'a>(&'a Gpu); + +impl<'a> RenderScope<'a> { + pub fn new(g: &'a Gpu) -> RenderScope<'a> { + IN_RENDER.with(|c| c.set(c.get() + 1)); + RenderScope(g) + } +} + +impl Drop for RenderScope<'_> { + fn drop(&mut self) { + let outer = IN_RENDER.with(|c| { + c.set(c.get().saturating_sub(1)); + c.get() == 0 + }); + if outer { + let retired = RETIRED.with(|r| std::mem::take(&mut *r.borrow_mut())); + RETIRED_BYTES.fetch_sub(retired.iter().map(|b| b.0.size()).sum(), Ordering::Relaxed); + self.0.pool(retired); } } } @@ -114,8 +152,8 @@ 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; +/// Default for the most bytes kept in the free pool (apps derive it from their memory budget). +pub(crate) const DEFAULT_POOL_BYTES: u64 = 256 << 20; /// Device buffers held by the renderer (bytes). #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] @@ -259,9 +297,17 @@ 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()); - 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); + // the smallest pooled buffer that holds `size` with at most 25 % to spare: photos of + // slightly different sizes reuse each other's buffers instead of piling up new ones + let b = f + .iter() + .enumerate() + .filter(|(_, b)| b.0.size() >= size && b.0.size() <= size + size / 4) + .min_by_key(|(_, b)| b.0.size()) + .map(|(i, _)| i) + .map(|i| f.swap_remove(i)); + if let Some(b) = &b { + POOLED.fetch_sub(b.0.size(), Ordering::Relaxed); } b }; @@ -280,11 +326,24 @@ 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)); + RETIRED_BYTES.fetch_sub(retired.iter().map(|b| b.0.size()).sum(), Ordering::Relaxed); + self.pool(retired); + } + + /// Put reusable buffers into the free pool (trimmed to its limit). + fn pool(&self, bufs: impl IntoIterator) { + let mut added = false; + { + let mut f = self.free.lock().unwrap_or_else(|e| e.into_inner()); + for b in bufs { + POOLED.fetch_add(b.0.size(), Ordering::Relaxed); + f.push(b); + added = true; + } + } + if added { + self.trim(POOL_LIMIT.load(Ordering::Relaxed)); + } } /// Free pooled buffers (oldest first) until at most `keep` bytes stay pooled. diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index c767a7d21..850d23fd9 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -66,6 +66,12 @@ pub(crate) fn device() -> Option<&'static ctx::Gpu> { GPU.get_or_init(|| std::panic::catch_unwind(ctx::Gpu::new).ok().flatten()).as_ref() } +/// The device if it has been created (never creates it). +#[cfg(not(target_arch = "wasm32"))] +pub(crate) fn existing_device() -> Option<&'static ctx::Gpu> { + GPU.get().and_then(|g| g.as_ref()) +} + /// Create the device and compile the kernels on a background thread now (app start), so the /// first render doesn't wait ~0.3–0.4 s for it and the UI thread never does. pub fn warm_up() { @@ -183,7 +189,10 @@ pub fn render(src: &Arc, info: &SourceInfo, s: &DevelopSettings, req: &R } let gpu = device()?; let ext = stages.map(|c| c.extension::()); - let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| render::render(gpu, src, info, s, req, ext.as_deref()))); + let r = { + let _scope = ctx::RenderScope::new(gpu); + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| render::render(gpu, src, info, s, req, ext.as_deref()))) + }; match r { // a device error during the render: its result is not trustworthy Ok(_) if BROKEN.load(Ordering::Relaxed) => None, diff --git a/crates/gpu/src/render.rs b/crates/gpu/src/render.rs index b0dc21209..d229cdbae 100644 --- a/crates/gpu/src/render.rs +++ b/crates/gpu/src/render.rs @@ -765,6 +765,7 @@ mod tests { #[ignore] fn bench_kernels() { let Some(gpu) = crate::device() else { return }; + let _scope = crate::ctx::RenderScope::new(gpu); let (w, h) = (6000, 4000); let src = gpu.upload(&vec![0.5f32; w * h * 3]); let time = |name: &str, f: &mut dyn FnMut(&mut Cx<'_>)| { diff --git a/crates/gpu/tests/memory.rs b/crates/gpu/tests/memory.rs new file mode 100644 index 000000000..2ef958cb1 --- /dev/null +++ b/crates/gpu/tests/memory.rs @@ -0,0 +1,41 @@ +//! Device-memory bookkeeping: a view's stages are counted, released buffers are pooled within +//! the pool limit, and trimming frees them. Skips when no GPU adapter exists. + +use std::sync::Arc; + +use lightcraft_develop::DevelopSettings; +use lightcraft_pipeline::{RenderRequest, SourceInfo, StageCache}; + +#[test] +fn stages_are_counted_and_the_pool_is_bounded_and_trimmed() { + if !lightcraft_gpu::available() { + eprintln!("skipped: no GPU adapter"); + return; + } + let limit = 64u64 << 20; + lightcraft_gpu::set_pool_limit(limit); + let info = SourceInfo { raw: true, ..Default::default() }; + let mut s = DevelopSettings::default(); + s.effects.clarity = 30.0; + let stages = StageCache::default(); + // photos of slightly different sizes, like stepping through a library + for (i, (w, h)) in [(1200usize, 800usize), (1190, 810), (1210, 790), (800, 1200)].into_iter().enumerate() { + let src = Arc::new(lightcraft_scenes::demo_library()[i].render(w, h)); + let r = lightcraft_gpu::render(&src, &info, &s, &RenderRequest::fit(w, h), Some(&stages)).expect("gpu render"); + assert_eq!((r.image.width, r.image.height), (w, h)); + let m = lightcraft_gpu::memory(); + assert!(m.pooled <= limit, "pool {} over its limit", m.pooled); + assert_eq!(m.retired, 0, "a finished render leaves nothing retired"); + } + let held = lightcraft_gpu::stage_bytes(&stages); + assert!(held >= 1200 * 800 * 3 * 4, "the view's stages hold at least its resampled source: {held}"); + let before = lightcraft_gpu::memory().allocated; + stages.clear(); + let m = lightcraft_gpu::memory(); + assert_eq!(lightcraft_gpu::stage_bytes(&stages), 0); + assert!(m.pooled <= limit && m.allocated <= before); + lightcraft_gpu::trim_pool(0); + let m = lightcraft_gpu::memory(); + assert_eq!(m.pooled, 0); + assert!(m.allocated < before, "trimming frees the pooled buffers: {} → {}", before, m.allocated); +} diff --git a/crates/preview/src/lib.rs b/crates/preview/src/lib.rs index 5490194e4..e36f1b414 100644 --- a/crates/preview/src/lib.rs +++ b/crates/preview/src/lib.rs @@ -22,7 +22,7 @@ use std::sync::{Arc, Mutex}; pub use disk::{DiskCache, decode_jpeg, encode_jpeg}; pub use hash::{Hash128, Hasher128, hash_bytes}; use lightcraft_raster::Rgba8; -pub use lru::Lru; +pub use lru::{Lru, next_tick}; pub use pool::JobPool; /// Rendered-thumbnail cache: memory LRU over an optional disk cache. @@ -93,6 +93,25 @@ impl PreviewCache { } } + /// Use tick ([`next_tick`]) of the least recently used image in memory. + pub fn oldest_tick(&self) -> Option { + self.mem.lock().unwrap_or_else(|e| e.into_inner()).oldest_tick() + } + + /// Drop the least recently used image from memory (it stays on disk); returns its bytes. + pub fn evict_oldest(&self) -> Option { + self.mem.lock().unwrap_or_else(|e| e.into_inner()).pop_oldest() + } + + /// Change the memory budget (bytes), evicting at once if needed. + pub fn set_mem_budget(&self, bytes: usize) { + let mut m = self.mem.lock().unwrap_or_else(|e| e.into_inner()); + m.set_budget(bytes); + while m.cost() > bytes && m.len() > 1 { + m.pop_oldest(); + } + } + /// (entries, bytes) held in memory. pub fn mem_usage(&self) -> (usize, usize) { let m = self.mem.lock().unwrap_or_else(|e| e.into_inner()); diff --git a/crates/preview/src/lru.rs b/crates/preview/src/lru.rs index 1be8bca4d..a15d4eecb 100644 --- a/crates/preview/src/lru.rs +++ b/crates/preview/src/lru.rs @@ -2,12 +2,20 @@ use std::collections::{BTreeMap, HashMap}; use std::hash::Hash; +use std::sync::atomic::{AtomicU64, Ordering}; + +static TICK: AtomicU64 = AtomicU64::new(1); + +/// A process-wide use counter: entries of different caches (LRUs or not) compare by recency, so +/// a memory budget can evict the least recently used entry across all of them. +pub fn next_tick() -> u64 { + TICK.fetch_add(1, Ordering::Relaxed) +} pub struct Lru { map: HashMap, - /// Use tick → key, oldest first. + /// Use tick ([`next_tick`], shared by every LRU) → key, oldest first. order: BTreeMap, - tick: u64, cost: usize, budget: usize, } @@ -16,12 +24,11 @@ impl Lru { /// An LRU holding at most `budget` cost units (e.g. bytes). The most recent entry is always /// kept, even if it alone exceeds the budget. pub fn new(budget: usize) -> Self { - Lru { map: HashMap::new(), order: BTreeMap::new(), tick: 0, cost: 0, budget } + Lru { map: HashMap::new(), order: BTreeMap::new(), cost: 0, budget } } fn touch(&mut self, k: &K) { - self.tick += 1; - let t = self.tick; + let t = next_tick(); if let Some(e) = self.map.get_mut(k) { self.order.remove(&e.2); e.2 = t; @@ -41,15 +48,28 @@ impl Lru { self.map.get(k).map(|e| &e.0) } + /// Use tick of the least recently used entry. + pub fn oldest_tick(&self) -> Option { + self.order.first_key_value().map(|(t, _)| *t) + } + + /// Remove the least recently used entry; returns its cost. + pub fn pop_oldest(&mut self) -> Option { + let (_, k) = self.order.pop_first()?; + let (_, c, _) = self.map.remove(&k)?; + self.cost -= c; + Some(c) + } + pub fn contains(&self, k: &K) -> bool { self.map.contains_key(k) } pub fn insert(&mut self, k: K, v: V, cost: usize) { self.remove(&k); - self.tick += 1; - self.order.insert(self.tick, k.clone()); - self.map.insert(k, (v, cost, self.tick)); + let t = next_tick(); + self.order.insert(t, k.clone()); + self.map.insert(k, (v, cost, t)); self.cost += cost; while self.cost > self.budget && self.map.len() > 1 { let Some((_, old)) = self.order.pop_first() else { break }; diff --git a/crates/raster/src/lib.rs b/crates/raster/src/lib.rs index 15f3e940c..50593f29b 100644 --- a/crates/raster/src/lib.rs +++ b/crates/raster/src/lib.rs @@ -97,6 +97,19 @@ impl Image { pub fn is_empty(&self) -> bool { self.data.is_empty() } + /// [`Self::map`] into the same buffer (no second image in memory). + pub fn map_in_place(&mut self, f: impl Fn(T) -> T + Sync + Send) + where + T: Send, + { + let w = self.width; + par_rows(&mut self.data, w, |_, row| { + for p in row.iter_mut() { + *p = f(*p); + } + }); + } + pub fn map(&self, f: impl Fn(T) -> U + Sync + Send) -> Image where T: Sync, @@ -139,6 +152,19 @@ impl Image { } Self { width: w, height: h, data } } + /// [`Self::crop`] in place: rows move to the front of the same buffer (no second image). + pub fn into_crop(mut self, x0: usize, y0: usize, w: usize, h: usize) -> Self { + let x0 = x0.min(self.width); + let y0 = y0.min(self.height); + let w = w.min(self.width - x0); + let h = h.min(self.height - y0); + for (i, y) in (y0..y0 + h).enumerate() { + let src = y * self.width + x0; + self.data.copy_within(src..src + w, i * w); + } + self.data.truncate(w * h); + Self { width: w, height: h, data: self.data } + } /// Rotate 90° clockwise. pub fn rotate_cw(&self) -> Self { let (w, h) = (self.width, self.height); @@ -157,6 +183,11 @@ impl Image { out } /// Apply an EXIF orientation (stored → displayed). + /// [`Self::oriented`] without a copy when `o` is the identity. + pub fn into_oriented(self, o: lightcraft_geom::Orientation) -> Self { + if o == lightcraft_geom::Orientation::Normal { self } else { self.oriented(o) } + } + pub fn oriented(&self, o: lightcraft_geom::Orientation) -> Self { let (flip, turns) = o.to_parts(); let mut img = if flip { self.flip_h() } else { self.clone() }; @@ -231,6 +262,17 @@ mod tests { let img = Image::::from_fn(4, 3, |x, y| (y * 10 + x) as u32); let c = img.crop(1, 1, 2, 2); assert_eq!(c.data, vec![11, 12, 21, 22]); + // the in-place variants equal the copying ones + for (x, y, w, h) in [(1, 1, 2, 2), (0, 0, 4, 3), (2, 0, 9, 9), (0, 2, 4, 1), (3, 2, 1, 1)] { + assert_eq!(img.clone().into_crop(x, y, w, h), img.crop(x, y, w, h)); + } + for v in 1..=8 { + let o = Orientation::from_exif(v); + assert_eq!(img.clone().into_oriented(o), img.oriented(o)); + } + let mut m = img.clone(); + m.map_in_place(|p| p * 2 + 1); + assert_eq!(m, img.map(|p| p * 2 + 1)); let r = img.rotate_cw(); assert_eq!((r.width, r.height), (3, 4)); // top-left of rotated = bottom-left of original diff --git a/crates/raw/src/lib.rs b/crates/raw/src/lib.rs index d61e313b6..01c9fd2c5 100644 --- a/crates/raw/src/lib.rs +++ b/crates/raw/src/lib.rs @@ -552,12 +552,13 @@ impl RawImage { pub fn develop(&self, method: Method) -> Result { let n = self.normalized()?; let mut rgb = demosaic(&n, method); + drop(n); opcodes::apply_list3(&self.opcodes.list3, &mut rgb); let c = self.crop.clipped(rgb.width, rgb.height); if c.width == 0 || c.height == 0 || (c.x == 0 && c.y == 0 && c.width == rgb.width && c.height == rgb.height) { return Ok(rgb); } - Ok(rgb.crop(c.x, c.y, c.width, c.height)) + Ok(rgb.into_crop(c.x, c.y, c.width, c.height)) } } diff --git a/crates/ui-egui/src/render.rs b/crates/ui-egui/src/render.rs index 8cafb8d3e..86fa8d516 100644 --- a/crates/ui-egui/src/render.rs +++ b/crates/ui-egui/src/render.rs @@ -90,8 +90,15 @@ pub struct Renderer { quick_tried: HashMap, /// Prefetch slot → key of the last job submitted (each runs once). prefetched: HashMap, + /// Since when nothing has been pending, and whether the GPU pool was trimmed since. + #[cfg(not(target_arch = "wasm32"))] + idle: Option<(std::time::Instant, bool)>, } +/// After this long without renders the GPU renderer's pool of recycled buffers is freed. +#[cfg(not(target_arch = "wasm32"))] +const IDLE_TRIM: std::time::Duration = std::time::Duration::from_secs(3); + impl Default for Renderer { fn default() -> Self { let threads = if cfg!(target_arch = "wasm32") { 0 } else { JobPool::::default_threads().min(6) }; @@ -108,6 +115,8 @@ impl Default for Renderer { stages: HashMap::new(), quick_tried: HashMap::new(), prefetched: HashMap::new(), + #[cfg(not(target_arch = "wasm32"))] + idle: None, } } } @@ -149,7 +158,13 @@ impl Renderer { return; } let key = job.key; - self.pool.submit(slot, key, priority, Box::new(move || job.run())); + let background = matches!(slot, Slot::Thumb(_) | Slot::ThumbQuick(_) | Slot::Prefetch(_)); + self.pool.submit( + slot, + key, + priority, + Box::new(move || if background { lightcraft_engine::memory::in_background(|| job.run()) } else { job.run() }), + ); } /// Request a stand-in for `slot` (once per job key). @@ -172,7 +187,7 @@ impl Renderer { self.prefetched.insert(slot, job.key); self.pending.insert(slot, (job.key, priority)); let key = job.key; - self.pool.submit(slot, key, priority, Box::new(move || job.run())); + self.pool.submit(slot, key, priority, Box::new(move || lightcraft_engine::memory::in_background(|| job.run()))); } /// Is a request for `slot` queued or running? @@ -292,12 +307,37 @@ impl Renderer { } changed = true; } + #[cfg(not(target_arch = "wasm32"))] + self.trim_when_idle(ctx); if !self.pending.is_empty() { ctx.request_repaint_after(std::time::Duration::from_millis(16)); } changed } + /// Free the GPU renderer's recycled buffers once nothing has rendered for [`IDLE_TRIM`] (they + /// are reallocated by the next render; while working, renders reuse them). + #[cfg(not(target_arch = "wasm32"))] + fn trim_when_idle(&mut self, ctx: &egui::Context) { + if !self.pending.is_empty() { + self.idle = None; + return; + } + let now = std::time::Instant::now(); + let (since, trimmed) = *self.idle.get_or_insert((now, false)); + if trimmed { + return; + } + let waited = now.duration_since(since); + if waited >= IDLE_TRIM { + lightcraft_engine::gpu::trim_pool(0); + lightcraft_engine::memory::release(); + self.idle = Some((since, true)); + } else { + ctx.request_repaint_after(IDLE_TRIM - waited); + } + } + /// 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(); diff --git a/docs/gpu-pipeline.md b/docs/gpu-pipeline.md index 82a57e612..b8db4585c 100644 --- a/docs/gpu-pipeline.md +++ b/docs/gpu-pipeline.md @@ -100,6 +100,20 @@ the device on their first GPU render. `lightcraft_gpu::ready()` asks without blo `/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. +- Imports read raw headers only (`lightcraft_raw::probe_info`): no pixel data is decompressed. +- One budget (`memory::budget`, default min(25 % of RAM, 1.5 GiB); `LIGHTCRAFT_MEMORY_MB` or + `app.memoryBudget {mb}`): half for the engine caches (decoded thumbnail / preview / full + sources and rendered previews, evicted least recently used *across* them; the photo on screen + stays), a quarter for decodes in flight (`memory::work_gate`: grid thumbnails, neighbour + prefetch and import probes wait for room, loupe and export loads never wait), an eighth for the + GPU buffer pool (reuse by best fit within 25 %; freed after 3 s idle). +- Decodes free the file's bytes and the raw samples as soon as they are consumed and colour / + crop / orient in place. macOS keeps freed large blocks resident ("reusable") until the system + is short of memory, so the resident size climbed with every photo decoded; the apps install + `malloc_zone_pressure_relief` as `memory::set_release_hook`, called after each decode, import + and idle trim. +- Result (M4 Pro, 14 raws, loupe, 12 steps): peak RSS 1.9–2.3 GB → 0.83–0.99 GB, peak footprint + 2.5–2.6 GB → 0.91–0.96 GB, heap peak 930 → ~600 MB; stepping median ~13 ms (unchanged). ## Open items - Keep the loupe texture on the GPU (render into an `egui-wgpu` texture on eframe's device instead of