mirror of
https://github.com/storytold/filmcraft.git
synced 2026-10-24 10:10:05 -06:00
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:
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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)]
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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` /
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user