Merge pull request #299 from dcarnelutti/export/trim-pool-when-done

export: free the frame pool when an export ends
This commit is contained in:
Brandon Thomas
2026-10-09 09:56:07 -04:00
committed by GitHub
7 changed files with 208 additions and 5 deletions
+17
View File
@@ -68,6 +68,22 @@ pub(crate) struct Pipeline {
overlay: Option<Image>,
start_tc: i64,
drop_frame: bool,
/// Free the pool's float images when the export ends: a standalone export, not one part of a
/// batch (`ExportSettings::part_of_batch`).
trim_pool: 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 `filmcraft_frame::pool` float shelf 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.
/// Parts of a larger job (render-preview segments, proxies) keep them for the next part.
fn drop(&mut self) {
if self.trim_pool {
filmcraft_frame::pool::trim_f32();
}
}
}
impl Pipeline {
@@ -100,6 +116,7 @@ impl Pipeline {
seq_rate: q.settings.frame_rate,
start_tc: q.start_timecode,
drop_frame: q.settings.drop_frame,
trim_pool: !settings.part_of_batch,
project: project.clone(),
seq,
rate: r.rate,
+102
View File
@@ -0,0 +1,102 @@
//! A standalone export gives back the float images it left on the frame pool
//! (`filmcraft_frame::pool`) when it ends, however it ends; one part of a batch keeps them. 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<Project>, 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().f32_idle_bytes as f64 / 1e6
}
#[test]
fn the_float_shelf_is_empty_when_a_standalone_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().f32_idle_bytes > 0);
};
let check = |what: &str| assert_eq!(pool::stats().f32_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");
// one part of a batch (a render-preview segment, a proxy): the next part reuses the images
leave_something();
let part = ExportSettings { part_of_batch: true, ..settings(Format::ProRes, "e.mov") };
export(&p, seq, &part, &sources, &Progress::default()).unwrap();
assert!(pool::stats().f32_idle_bytes > 0, "a part of a batch keeps the float images");
pool::trim_f32();
let _ = std::fs::remove_dir_all(&dir);
}
+16 -1
View File
@@ -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 (a standalone export) calls [`trim_f32`].
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex, PoisonError};
@@ -138,10 +141,22 @@ pub fn recycle(frame: Arc<VideoFrame>) {
}
}
/// Free the idle float images. A standalone export leaves that shelf full (up to [`F32_MAX_BYTES`]
/// of images, each the size of that export's frames) and nothing else asks for those sizes again,
/// so they would sit idle until the next export. Images still in use are not touched. The 8- and
/// 16-bit plane shelves are left alone: playback recycles decoded frames through them all the time.
pub fn trim_f32() {
// taken under the lock, freed after it: giving back hundreds of megabytes takes a moment
let floats = std::mem::take(&mut *F32.lock().unwrap_or_else(PoisonError::into_inner));
drop(floats);
}
/// Idle buffers held and buffers handed out again so far (`perf.stats`).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct PoolStats {
pub idle_bytes: usize,
/// The part of `idle_bytes` held as float images ([`recycle_f32`]).
pub f32_idle_bytes: usize,
pub reused: u64,
}
@@ -150,7 +165,7 @@ pub fn stats() -> PoolStats {
let a = U8.lock().unwrap_or_else(PoisonError::into_inner).bytes;
let b = U16.lock().unwrap_or_else(PoisonError::into_inner).bytes;
let c: usize = F32.lock().unwrap_or_else(PoisonError::into_inner).iter().map(|b| b.len().saturating_mul(4)).sum();
PoolStats { idle_bytes: idle(idle(a, b), c), reused: REUSED.load(Ordering::Relaxed) }
PoolStats { idle_bytes: idle(idle(a, b), c), f32_idle_bytes: c, reused: REUSED.load(Ordering::Relaxed) }
}
#[cfg(test)]
+55
View File
@@ -0,0 +1,55 @@
//! `pool::trim_f32` frees the idle float images of the process-wide pool and leaves the plane
//! shelves alone. 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<VideoFrame> {
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::<u8>::new());
pool::recycle(frame(PixelData::Yuv8 { planes: [Arc::new(vec![7u8; BYTES]), empty(), empty()], chroma: Chroma::C420, alpha: None }));
let empty = || Arc::new(Vec::<u16>::new());
pool::recycle(frame(PixelData::Yuv16 { planes: [Arc::new(vec![7u16; WORDS]), empty(), empty()], chroma: Chroma::C420, bits: 10, alpha: None }));
}
#[test]
fn trim_f32_frees_the_float_images_keeps_the_planes_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 planes = BYTES + WORDS * 2;
assert_eq!(pool::stats().f32_idle_bytes, FLOATS * 4);
assert!(pool::stats().idle_bytes >= FLOATS * 4 + planes, "idle {}", pool::stats().idle_bytes);
pool::trim_f32();
assert_eq!(pool::stats().f32_idle_bytes, 0, "the float shelf is empty");
assert!(pool::stats().idle_bytes >= planes, "the plane shelves are untouched");
// the float image is a fresh (zeroed) one and doesn't count as a reuse; the planes come back
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");
assert_eq!(pool::stats().reused, reused);
drop((pool::take_u8(BYTES), pool::take_u16(WORDS)));
assert!(pool::stats().reused > reused, "the planes were kept");
// the pool works after a trim, and trimming twice (or an empty shelf) is fine
pool::recycle_f32(image);
assert_eq!(pool::stats().f32_idle_bytes, FLOATS * 4);
pool::trim_f32();
pool::trim_f32();
assert_eq!(pool::stats().f32_idle_bytes, 0);
}
+5 -3
View File
@@ -310,9 +310,11 @@ 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; a standalone export's
pipeline frees the idle float images (`pool::trim_f32`) when it goes away, so a finished export
leaves none behind (parts of a batch, such as render-preview segments, keep them for the next part). 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.
+1 -1
View File
@@ -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; `monitor`: the `frame` and `revision` of the picture the Program monitor shows against `playheadFrame` and `projectRevision` (how far a drag or a scrub is ahead of the picture); `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 a standalone 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; `monitor`: the `frame` and `revision` of the picture the Program monitor shows against `playheadFrame` and `projectRevision` (how far a drag or a scrub is ahead of the picture); `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` /
+12
View File
@@ -95,6 +95,18 @@ 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. A standalone export's pipeline now frees the idle float images when it goes
away, however the export ended (done, failed, cancelled, dropped half way). The plane shelves are
kept (playback recycles decoded frames through them), and so are the float images after one part
of a batch (a render-preview segment, a proxy), which the next part reuses. 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