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:
Brandon Thomas
2026-10-01 10:28:13 -04:00
co-authored by Claude Opus 5.5
parent c2a09ed8e9
commit 78e50b5fc5
18 changed files with 486 additions and 17 deletions
+80 -16
View File
@@ -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.
+59
View File
@@ -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> {
+35
View File
@@ -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
}