diff --git a/crates/export/src/pipeline.rs b/crates/export/src/pipeline.rs index fde70f83f..0ef9ac95a 100644 --- a/crates/export/src/pipeline.rs +++ b/crates/export/src/pipeline.rs @@ -68,6 +68,16 @@ pub(crate) struct Pipeline { drop_frame: bool, } +impl Drop for Pipeline { + /// The export is over, however it ended (done, failed, cancelled, dropped half way). The float + /// images its frames left on the shelves of `filmcraft_frame::pool` are the size of its frames, + /// which nothing else asks for, so free them instead of holding up to 320 MiB idle. A job still + /// rendering at that moment allocates a few images again; the pool is only a cache. + fn drop(&mut self) { + filmcraft_frame::pool::trim(); + } +} + impl Pipeline { pub fn new(project: Arc, seq: ItemId, settings: &ExportSettings, hdr_out: bool) -> Result { let q = project.sequence(seq).ok_or(ExportError::NoSequence)?; diff --git a/crates/export/tests/pool_trim.rs b/crates/export/tests/pool_trim.rs new file mode 100644 index 000000000..678a995c6 --- /dev/null +++ b/crates/export/tests/pool_trim.rs @@ -0,0 +1,95 @@ +//! An export gives back what it left on the frame pool (`filmcraft_frame::pool`) when it ends, +//! however it ends. One test in this file on purpose: the pool is shared by every test of a binary, +//! and its numbers mean something only while nothing else renders. + +use std::sync::Arc; +use std::sync::atomic::Ordering; + +use filmcraft_export::{ExportSettings, Exporter, Format, Progress, Step, export}; +use filmcraft_frame::pool; +use filmcraft_media::generators::GeneratorSource; +use filmcraft_media::{Generator, MediaSource}; +use filmcraft_project::{ItemId, ItemKind, Label, MediaClip, MediaRef, Project, SequenceSettings, TrackKind}; +use filmcraft_render::SourceMap; +use filmcraft_time::{FrameRate, TICKS_PER_SECOND, Tick, TimeRange}; + +/// A red matte filling a 320×180 sequence for a second. +fn project() -> (Arc, ItemId, SourceMap) { + let mut p = Project::new("x"); + let rate = FrameRate::FPS_24; + let g = GeneratorSource::new(Generator::ColorMatte { color: [1.0, 0.0, 0.0, 1.0] }, 320, 180, rate, Tick(2 * TICKS_PER_SECOND)); + let info = g.info().clone(); + let clip = MediaClip { + media: MediaRef::Generator(g.generator.clone()), + info: info.clone(), + interpret: Default::default(), + mark_in: None, + mark_out: None, + markers: vec![], + offline: false, + proxy: None, + identity: None, + }; + let red = p.add_item(&info.name, Label::Iris, ItemKind::Media(clip), None); + let seq = p.new_sequence("s", SequenceSettings { width: 320, height: 180, frame_rate: rate, ..Default::default() }, 1, 1, None); + let item = p.make_track_item(red, TrackKind::Video, Tick::ZERO, TimeRange::new(Tick::ZERO, rate.tick_of(24)), rate).unwrap(); + p.sequence_mut(seq).unwrap().video_tracks[0].items.push(item); + let mut sources = SourceMap::default(); + sources.0.insert(red, Arc::new(g)); + (Arc::new(p), seq, sources) +} + +fn idle_mb() -> f64 { + pool::stats().idle_bytes as f64 / 1e6 +} + +#[test] +fn the_pool_is_empty_when_an_export_ends_however_it_ended() { + let (p, seq, sources) = project(); + let dir = std::env::temp_dir().join(format!("fc-pool-trim-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let settings = + |format: Format, name: &str| ExportSettings { format, path: dir.join(name).to_string_lossy().to_string(), include_audio: false, ..Default::default() }; + // an image the pool could keep, standing in for what the frames of an export leave (the frames + // themselves do too: the checks below fail without the trim) + let leave_something = || { + pool::recycle_f32(vec![0.0; 100_000]); + assert!(pool::stats().idle_bytes > 0); + }; + let check = |what: &str| assert_eq!(pool::stats().idle_bytes, 0, "after {what}: {:.1} MB idle", idle_mb()); + + // images, which run in `export` itself + leave_something(); + export(&p, seq, &settings(Format::PngSequence, "seq.png"), &sources, &Progress::default()).unwrap(); + check("an image sequence"); + + // an encoded video, run to the end + leave_something(); + export(&p, seq, &settings(Format::ProRes, "a.mov"), &sources, &Progress::default()).unwrap(); + check("a finished video"); + + // cancelled before the first frame + leave_something(); + let cancelled = Progress::default(); + cancelled.cancel.store(true, Ordering::Relaxed); + assert!(export(&p, seq, &settings(Format::ProRes, "b.mov"), &sources, &cancelled).is_err()); + check("a cancelled video"); + + // an encoder that is not there: the pipeline was built, the export never started + leave_something(); + assert!(export(&p, seq, &settings(Format::Hevc, "c.mp4"), &sources, &Progress::default()).is_err()); + check("an export that could not start"); + + // stepped by the host (the web build), dropped half way: 6 frames of 24 + let progress = Progress::default(); + let mut ex = Exporter::new(p.clone(), seq, &settings(Format::ProRes, "d.mov"), &progress).unwrap(); + ex.set_batch(2); + for _ in 0..3 { + assert!(matches!(ex.step(&sources, &progress), Ok(Step::Progress))); + } + leave_something(); + drop(ex); + check("an export dropped half way"); + + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/frame/src/pool.rs b/crates/frame/src/pool.rs index 22f04b9e3..8b778418d 100644 --- a/crates/frame/src/pool.rs +++ b/crates/frame/src/pool.rs @@ -9,6 +9,9 @@ //! The compositor's working images (premultiplied linear `f32`, 33 MB at 1080p) get the same //! treatment ([`take_f32_overwritten`], [`recycle_f32`]): allocated and freed once per layer per //! frame they cost a zero-fill and fresh page faults every time. +//! +//! The shelves are sized for the busiest work, so they are a lot to keep once it is over: whoever +//! finishes such work (an export) calls [`trim`]. use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex, PoisonError}; @@ -138,6 +141,19 @@ pub fn recycle(frame: Arc) { } } +/// Free every idle buffer. An export leaves the shelves full (up to [`F32_MAX_BYTES`] of float +/// images, each the size of that export's frames, and [`MAX_BYTES`] of each plane type) and +/// nothing else asks for those sizes again, so they would sit idle until the next export. What +/// is still in use is not touched, and the next job starts cold, for the price of its first +/// allocations. +pub fn trim() { + // taken under the locks, freed after them: giving back hundreds of megabytes takes a moment + let floats = std::mem::take(&mut *F32.lock().unwrap_or_else(PoisonError::into_inner)); + let planes8 = std::mem::replace(&mut *U8.lock().unwrap_or_else(PoisonError::into_inner), Shelf::new()); + let planes16 = std::mem::replace(&mut *U16.lock().unwrap_or_else(PoisonError::into_inner), Shelf::new()); + drop((floats, planes8, planes16)); +} + /// Idle buffers held and buffers handed out again so far (`perf.stats`). #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub struct PoolStats { diff --git a/crates/frame/tests/pool_trim.rs b/crates/frame/tests/pool_trim.rs new file mode 100644 index 000000000..64c4b67ef --- /dev/null +++ b/crates/frame/tests/pool_trim.rs @@ -0,0 +1,53 @@ +//! `pool::trim` frees every idle buffer of the process-wide pool. This file holds one test on +//! purpose: the pool is shared by every test of a binary, and another test recycling or taking +//! buffers while this one counts them would make the counts meaningless. + +use std::sync::Arc; + +use filmcraft_frame::{Chroma, PixelData, VideoFrame, pool}; + +const FLOATS: usize = 300_000; +const BYTES: usize = 200_000; +const WORDS: usize = 100_000; + +fn frame(data: PixelData) -> Arc { + Arc::new(VideoFrame { width: 1, height: 1, data, color: filmcraft_color::ColorInfo::SRGB_FULL, par: (1, 1), pts: filmcraft_time::Tick::ZERO }) +} + +/// Hand the pool one 8-bit and one 16-bit plane (the other planes are too small to be kept). +fn recycle_planes() { + let empty = || Arc::new(Vec::::new()); + pool::recycle(frame(PixelData::Yuv8 { planes: [Arc::new(vec![7u8; BYTES]), empty(), empty()], chroma: Chroma::C420, alpha: None })); + let empty = || Arc::new(Vec::::new()); + pool::recycle(frame(PixelData::Yuv16 { planes: [Arc::new(vec![7u16; WORDS]), empty(), empty()], chroma: Chroma::C420, bits: 10, alpha: None })); +} + +#[test] +fn trim_frees_every_idle_buffer_and_the_pool_keeps_working() { + assert_eq!(pool::stats().idle_bytes, 0, "nothing has been recycled yet"); + + // one idle buffer on each of the three shelves + pool::recycle_f32(vec![f32::NAN; FLOATS]); + recycle_planes(); + let held = FLOATS * 4 + BYTES + WORDS * 2; + assert!(pool::stats().idle_bytes >= held, "idle {} of {held}", pool::stats().idle_bytes); + + pool::trim(); + assert_eq!(pool::stats().idle_bytes, 0, "every shelf is empty"); + + // nothing comes back from the shelves: the float image is a fresh (zeroed) one and no take + // counts as a reuse + let reused = pool::stats().reused; + let image = pool::take_f32_overwritten(FLOATS); + assert!(image.iter().all(|v| *v == 0.0), "the recycled image was freed"); + drop((pool::take_u8(BYTES), pool::take_u16(WORDS))); + assert_eq!(pool::stats().reused, reused); + + // the pool works after a trim, and trimming twice (or an empty pool) is fine + pool::recycle_f32(image); + recycle_planes(); + assert!(pool::stats().idle_bytes >= held); + pool::trim(); + pool::trim(); + assert_eq!(pool::stats().idle_bytes, 0); +} diff --git a/docs/architecture.md b/docs/architecture.md index e0a7885c8..9e460c960 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -306,9 +306,10 @@ file ──► codecs (MP4/MOV, MKV, audio) demux + decode, GOP-aware see not zero (`VideoFrame::alpha_region`, `to_linear_f32_region`, `blend::composite_at`), the rest being transparent anyway; and working images are recycled through `filmcraft_frame::pool` (`take_f32_overwritten` / `recycle_f32`: the buffer comes back with its old contents, so there is - no zero-fill) by the export pipeline once a frame is converted to 8 bits. A clip with effects, - opacity masks, frame blending, colour management, or a picture that is not placed one to one on - the output takes the general path. + no zero-fill) by the export pipeline once a frame is converted to 8 bits; the pipeline trims the + pool (`pool::trim`) when it goes away, so a finished export leaves no idle images behind. A clip + with effects, opacity masks, frame blending, colour management, or a picture that is not placed + one to one on the output takes the general path. - **Frame scheduling.** `crates/ui-egui/src/frames.rs` runs a small pool of worker threads with prioritised jobs: the frame on screen first, then playback prefetch, then thumbnails. The UI never decodes. It shows the exact frame when it is ready and holds the nearest cached frame meanwhile. diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 6419e154a..d586e9b80 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -39,7 +39,7 @@ Methods (handlers in `crates/ui-egui/src/control.rs`): | `ui.playback` | `{action: play|stop|toggle, speed?}` | | | `ui.screenshot` | `{path?, panel?}` | PNG of the window or one panel; fails after 10 s when no frame is presented (window hidden, display asleep) | | `ui.resize`, `ui.focus`, `app.quit` | | `ui.focus` is the only request that activates the app and takes keyboard focus | -| `perf.stats` | – | performance counters (also the command `perf.stats`, so `engine.execute` and MCP `command_run` reach it; headless sessions return the engine part). `decode`: GOP-cache requests / hits / `cacheHitRate`, decoder seeks, samples decoded and skipped while catching up, `draftFrames` (decoded in draft mode), `h264Threads` (frame-threading workers per H.264 decoder), `liveDecoders` (sources holding a decoder now), `cacheMB` / `cacheBudgetMB` (decoded frames all sources hold, and the shared budget), `planePoolMB` / `planesReused` (idle plane buffers of evicted frames, and planes decoded into a recycled one), decoder ms (total and per sample), `framesDecoded`, `hardware` (Settings ▸ Playback ▸ Hardware decoding: `enabled`, pictures from hardware decoders `frames` vs `softwareFrames`, `sessions`, `declined` streams, mid-stream `fallbacks`); `playback`: playing, shown / dropped frames and `dropRate` of the current or last play, resolution, `draftDecode` (Settings ▸ Playback ▸ Draft decoding), `audio` (desktop: sound is mixed 200 ms ahead of the device; device `callbacks`, `underruns` and `missingFrames` played as silence because the mixer fell behind, `minLeadMs` the least sound left buffered); `frames`: frame-worker jobs, cancelled, `requestHitRate`, `decodeMs` / `renderMs` (mean, p50, p95 of the last 256 jobs), queue, render-cost estimate, cache use; `ui`: fps and frame ms; `process`: CPU seconds; `media`, `jobs`. Counters are cumulative: diff two readings to measure an interval ([performance.md](performance.md)) | +| `perf.stats` | – | performance counters (also the command `perf.stats`, so `engine.execute` and MCP `command_run` reach it; headless sessions return the engine part). `decode`: GOP-cache requests / hits / `cacheHitRate`, decoder seeks, samples decoded and skipped while catching up, `draftFrames` (decoded in draft mode), `h264Threads` (frame-threading workers per H.264 decoder), `liveDecoders` (sources holding a decoder now), `cacheMB` / `cacheBudgetMB` (decoded frames all sources hold, and the shared budget), `planePoolMB` / `planesReused` (idle buffers of the frame pool, the planes of evicted frames and the compositor's float images, which an export frees when it ends; and buffers taken from it instead of allocated), decoder ms (total and per sample), `framesDecoded`, `hardware` (Settings ▸ Playback ▸ Hardware decoding: `enabled`, pictures from hardware decoders `frames` vs `softwareFrames`, `sessions`, `declined` streams, mid-stream `fallbacks`); `playback`: playing, shown / dropped frames and `dropRate` of the current or last play, resolution, `draftDecode` (Settings ▸ Playback ▸ Draft decoding), `audio` (desktop: sound is mixed 200 ms ahead of the device; device `callbacks`, `underruns` and `missingFrames` played as silence because the mixer fell behind, `minLeadMs` the least sound left buffered); `frames`: frame-worker jobs, cancelled, `requestHitRate`, `decodeMs` / `renderMs` (mean, p50, p95 of the last 256 jobs), queue, render-cost estimate, cache use; `ui`: fps and frame ms; `process`: CPU seconds; `media`, `jobs`. Counters are cumulative: diff two readings to measure an interval ([performance.md](performance.md)) | **Selection or explicit targets.** A command that acts on the selection is "not available right now" when nothing is selected. When `params` name the targets under a key the command documents (`clips` / diff --git a/docs/performance.md b/docs/performance.md index 5fd62d739..70e330119 100644 --- a/docs/performance.md +++ b/docs/performance.md @@ -95,6 +95,17 @@ compositor's savings are a larger part of the export. What is left in a frame without overlays is the Y'CbCr → linear float conversion of the camera picture (4.5 ms in the synthetic case) and the conversion to 8 bits (2 ms). +### Memory after an export + +The recycled images stay on the shelves of `filmcraft_frame::pool` (up to 320 MiB of them, and +192 MiB of each plane type), and nothing else asks for images of an export's size, so a finished +export left them idle. The export pipeline now trims the pool when it goes away, however the +export ended (done, failed, cancelled, dropped half way): `perf.stats` reads `decode.planePoolMB` +0 afterwards. A process that exported a synthetic 3 s 1080p24 matte as PNG frames three times +(Apple M1, `footprint`, 10 s after the last export) rested at **575 MB** with the pool holding +332 MB idle and at **258 MB** with the trim. The memory does not come back at once: macOS returns +freed pages over a few seconds (892 MB right after the export in both cases, 726 MB after 2 s). + ## Results (GPU2: standard effects on the GPU compositor, #30, before → after) Before = this change with clips that carry standard effects sent back to the CPU layer path in