Merge branch 'main' into worktree-agent-aecd35c3421bad4ae

# Conflicts:
#	crates/ui-egui/src/render.rs
This commit is contained in:
Brandon Thomas
2026-10-01 13:01:41 -04:00
39 changed files with 1465 additions and 130 deletions
+12
View File
@@ -166,6 +166,18 @@ pub fn specs() -> Vec<CommandSpec> {
}
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 "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);
+41 -15
View File
@@ -36,7 +36,7 @@ fn meta_of(m: &lightcraft_meta::Metadata) -> (Meta, Option<String>) {
/// Lens corrections embedded in a DNG's `OpcodeList3` (`WarpRectilinear`, `FixVignetteRadial`), re-expressed for
/// the default-cropped, EXIF-oriented image. These are the only "profile" corrections LightCraft applies.
pub fn embedded_lens(raw: &lightcraft_raw::RawImage) -> Option<lightcraft_develop::EmbeddedLens> {
pub fn embedded_lens(raw: &lightcraft_raw::RawInfo) -> Option<lightcraft_develop::EmbeddedLens> {
use lightcraft_develop::{EmbeddedLens, EmbeddedVignette, EmbeddedWarp};
use lightcraft_geom::Point;
let (aw, ah) = (raw.active_area.width as f64, raw.active_area.height as f64);
@@ -86,13 +86,14 @@ fn ext_upper(name: &str) -> String {
std::path::Path::new(name).extension().map(|e| e.to_string_lossy().to_uppercase()).unwrap_or_default()
}
/// Probe a file's bytes: kind, dimensions (oriented), metadata.
/// Probe a file's bytes: kind, dimensions (oriented), metadata. Raws are described from their
/// headers ([`lightcraft_raw::probe_info`]): no pixel data is decompressed.
pub fn probe_bytes(name: &str, bytes: &[u8]) -> Result<ProbeInfo, String> {
let content_hash = Some(lightcraft_preview::hash_bytes(bytes).to_string());
let m = lightcraft_meta::extract(bytes);
let (meta, captured) = meta_of(&m);
if lightcraft_raw::probe(bytes).is_some() {
let raw = match lightcraft_raw::decode(bytes) {
let raw = match lightcraft_raw::probe_info(bytes) {
Ok(r) => r,
Err(lightcraft_raw::RawError::Unsupported(why)) => {
// a raw variant we can't decode yet: describe it from its embedded preview
@@ -120,7 +121,7 @@ pub fn probe_bytes(name: &str, bytes: &[u8]) -> Result<ProbeInfo, String> {
if raw.orientation.swaps_axes() {
std::mem::swap(&mut w, &mut h);
}
let (t, tint) = xy_to_temp_tint(lightcraft_raw::color::as_shot_white_xy(&raw));
let (t, tint) = xy_to_temp_tint(lightcraft_raw::color::as_shot_white_xy_of(&raw));
let as_shot_wb = Some((t.round(), tint.round()));
let embedded_lens = embedded_lens(&raw);
return Ok(ProbeInfo {
@@ -190,21 +191,28 @@ pub fn bin_factor(raw: &lightcraft_raw::RawImage, max_edge: usize) -> Option<usi
/// instead of each one waking the pool from outside and waiting for it (which costs more than the
/// loops themselves when the machine is busy).
pub fn load_bytes(bytes: &[u8], max_edge: usize) -> 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<u8>, 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);
let lens = embedded_lens(&raw.info());
raw.opcodes.list3.retain(|op| !is_lens_opcode(op));
// Previews and thumbnails bin the mosaic straight to (about) the size they need; only
// larger levels (exports, 1:1) demosaic the whole sensor.
@@ -220,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);
@@ -229,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),
@@ -240,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;
@@ -257,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.
@@ -310,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)
});
+1
View File
@@ -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) {
+1
View File
@@ -18,6 +18,7 @@ pub mod files;
pub mod import;
pub mod library;
pub mod media;
pub mod memory;
pub mod merge;
pub mod presets;
pub mod sidecar;
+140 -14
View File
@@ -94,11 +94,15 @@ impl SourceRef {
pub struct MediaCache {
/// Decoded thumbnail-level sources (LRU by bytes).
thumbs: Lru<PhotoId, Arc<Rgb32f>>,
previews: Vec<(PhotoId, Arc<Rgb32f>)>,
/// Decoded preview-level sources with their last use ([`lightcraft_preview::next_tick`]).
previews: Vec<(PhotoId, Arc<Rgb32f>, u64)>,
/// The last full-resolution original (exports; one at a time: ~300 MB at 24 MP).
full: Option<(PhotoId, Arc<Rgb32f>)>,
full: Option<(PhotoId, Arc<Rgb32f>, 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<FileLoader>,
pub file_probe: Option<FileProbe>,
pub preview_loader: Option<PreviewLoader>,
@@ -111,58 +115,124 @@ 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,
file_bytes: 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<Arc<Rgb32f>> {
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<Rgb32f>) {
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,6 +242,14 @@ 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 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)
}
pub fn source_ref(&mut self, p: &Photo, level: SourceLevel) -> SourceRef {
if let Some(a) = self.get(p.id, level) {
return SourceRef::Loaded(a);
@@ -592,6 +670,54 @@ pub type FileProbe = Arc<dyn Fn(&str) -> Result<ProbeInfo, String> + 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);
+295
View File
@@ -0,0 +1,295 @@
//! 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::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::<usize>().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<usize> {
#[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<bool> = 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<R>(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<WorkGate> = OnceLock::new();
/// 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,
/// 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<HeapUsage>,
}
static HEAP: OnceLock<fn() -> HeapUsage> = OnceLock::new();
static RELEASE: OnceLock<fn()> = 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) {
let _ = HEAP.set(f);
}
/// Live/peak heap bytes, when the binary installed a counter.
pub fn heap_stats() -> Option<HeapUsage> {
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(),
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
}
}
+20
View File
@@ -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");
}