mirror of
https://github.com/storytold/lightcraft.git
synced 2026-11-02 22:49:48 -06:00
Merge branch 'main' into worktree-agent-aecd35c3421bad4ae
# Conflicts: # crates/ui-egui/src/render.rs
This commit is contained in:
@@ -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
@@ -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)
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user