Files
lightcraft/crates/engine/src/memory.rs
T
hxwandClaude Opus 5.5 5630734832 LR-BEHAV-BG-TASKS: render export batches side by side (issue #496)
A batch ran its photos strictly one after another: each render is
row-parallel inside, but a photo's decode, its PNG/TIFF/WebP encode and
the wait on a GPU render are mostly serial, so a 32-core machine averaged
~9.5 busy cores on the CPU path and 1-2 with the GPU.

run_batch now keeps export_parallelism() photos in flight (cores / 4,
1..=4; one on wasm32) on scoped lane threads plus the calling thread.
Each holds an estimate of its working memory from a new export_gate
(twice the memory budget, separate from the decode gate it nests over),
so full-size 24 MP exports (~1.2 GB each) run two at a time by default.
Lanes run at most `lanes` photos past the last one placed, which also
bounds the finished files waiting for their turn. Paths, conflict
handling, original protection, writes, results and progress stay in
batch order (Placer), so the files are the same as one at a time; a
panic in a lane becomes that photo's error.

render_bench's batch scenario now goes through export_batch. 8 x 24 MP
JPEGs, 32 cores + RTX 4080 Laptop: CPU 3.87 s -> 2.35 s, GPU 4.21 s ->
1.82 s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 07:00:59 -05:00

319 lines
11 KiB
Rust

//! 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). An export
/// batch may, in addition, hold up to twice the budget in photos rendering side by side
/// ([`export_gate`]): transient memory, given back when the batch ends. 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(target_os = "freebsd")]
{
let out = std::process::Command::new("/sbin/sysctl").args(["-n", "hw.physmem"]).output().ok()?;
String::from_utf8_lossy(&out.stdout).trim().parse().ok()
}
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "freebsd")))]
{
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);
}
if let Some(g) = EXPORT_GATE.get() {
g.set_limit(export_share(b));
}
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();
/// The process-wide gate for the photos of an export batch rendering side by side
/// ([`crate::export::run_batch`]; limit: twice the budget). Separate from [`work_gate`], which the
/// decodes inside each export take: one gate held across the other's acquire could deadlock.
pub fn export_gate() -> &'static WorkGate {
EXPORT_GATE.get_or_init(|| WorkGate::new(export_share(budget())))
}
fn export_share(budget: usize) -> usize {
budget.saturating_mul(2)
}
static EXPORT_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
}
}