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