Export: render the next batch while the current one is encoded

A step now renders batch k+1 on the rayon pool while the calling thread
encodes, muxes and mixes the audio of batch k. The file is byte-identical
with or without overlap. A prefetch that hits pending media is dropped and
rendered again, cancel frees it, a render panic becomes an error, and wasm
never overlaps. perf.stats export.stages gains waitMs.

Memory: up to 2 x batch x frame bytes of finished frames are in flight, so
the batch is capped to a 512 MiB budget (1080p keeps 16, 4K SDR 8, 4K HDR 2
frames). Measured peak RSS vs before: 1080p +67..+158 MB; 4K SDR -535..-927
MB; 4K HDR about -2.6 GB (5.2 -> 2.6 GB). Wall time 1.06-1.16x faster.

Known pre-existing web issue, untouched: AudioOut::pull advances before the
pending check, so a retried audio group is lost.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Davi Andrei 2026-10-09 00:54:35 -03:00
parent 1996f319c3
commit 9510e26584
5 changed files with 606 additions and 59 deletions

View File

@ -50,7 +50,9 @@ pub fn decode_json() -> Value {
/// Export counters: pictures encoded by hardware encoders, sessions, declined requests; and the wall
/// time of each export stage in milliseconds (`stages`: setup, loudness, render, encode with its
/// `convert` part, audio, mux, finish), summed over the exports of this process (diff two readings).
/// `convert` part, audio, mux, finish, wait), summed over the exports of this process (diff two readings).
/// Render and encode overlap (the next batch renders while this one is encoded), so their sum can exceed
/// the export's wall time; `waitMs` is the encoding side's idle wait for the render.
pub fn export_json() -> Value {
let hw = filmcraft_export::hw_encode_stats();
let stages: serde_json::Map<String, Value> =
@ -109,7 +111,7 @@ mod tests {
for k in ["frames", "sessions", "declined"] {
assert!(v["export"]["hardware"][k].is_number(), "export.hardware.{k} in {v}");
}
for k in ["setupMs", "loudnessMs", "renderMs", "encodeMs", "convertMs", "audioMs", "muxMs", "finishMs"] {
for k in ["setupMs", "loudnessMs", "renderMs", "encodeMs", "convertMs", "audioMs", "muxMs", "finishMs", "waitMs"] {
assert!(v["export"]["stages"][k].is_number(), "export.stages.{k} in {v}");
}
assert!(v["media"]["openSources"].is_number());

View File

@ -96,3 +96,215 @@ fn h264_slices_follow_the_frame_size_not_the_cores() {
assert_eq!(slices_in(&sample), 64, "picture {i}");
}
}
// ---------------------------------------------------------------------------------------------
// rendering the next batch while the current one is encoded (job.rs, "Overlapping render and encode")
// ---------------------------------------------------------------------------------------------
use filmcraft_media::SharedSource;
use filmcraft_render::SourceMap;
use std::sync::atomic::AtomicUsize;
fn pool(threads: usize) -> rayon::ThreadPool {
rayon::ThreadPoolBuilder::new().num_threads(threads).build().unwrap()
}
/// Step an export on a pool of `threads` cores with `batch` frames per step, rendering ahead or
/// not; returns the file and how many steps rendered ahead.
fn export_overlapped(threads: usize, batch: i64, overlap: bool, settings: &ExportSettings) -> (Vec<u8>, u64) {
let (p, seq, m) = project();
pool(threads).install(|| {
let prog = Progress::default();
let mut ex = Exporter::new(p, seq, settings, &prog).unwrap();
ex.set_batch(batch);
ex.set_overlap(overlap);
while !matches!(ex.step(&m, &prog).unwrap(), Step::Done(_)) {}
assert_eq!(prog.done.load(Ordering::Relaxed), prog.total.load(Ordering::Relaxed), "every frame is counted once");
assert_eq!(ex.frames_ahead(), 0, "nothing is left rendered at the end");
(std::fs::read(&settings.path).unwrap(), ex.overlapped_steps())
})
}
#[test]
fn rendering_ahead_does_not_change_the_file() {
for (format, ext) in [(Format::ProRes, "mov"), (Format::H264, "mp4"), (Format::Mjpeg, "mov")] {
let settings = |name: &str| ExportSettings { format, path: tmp(&format!("{name}.{ext}")), ..Default::default() };
let (reference, ahead) = export_overlapped(3, 7, false, &settings(&format!("ov-{}-off", format.id())));
assert_eq!(ahead, 0, "{}: overlap is off", format.label());
for (threads, batch) in [(2, 1), (3, 3), (5, 7), (16, 24)] {
let (other, ahead) = export_overlapped(threads, batch, true, &settings(&format!("ov-{}-{threads}-{batch}", format.id())));
// 24 frames in one batch (the last case) has nothing to render ahead; the others do
assert_eq!(ahead > 0, batch < 24, "{}: {ahead} overlapped steps at batch {batch}", format.label());
assert!(reference == other, "{}: rendering ahead ({threads} cores, {batch} per step) gives a different file", format.label());
}
}
}
#[test]
fn rendering_ahead_in_a_two_pass_export() {
let settings = |name: &str| ExportSettings {
format: Format::H264,
path: tmp(name),
bitrate_mode: BitrateMode::Vbr2Pass,
bitrate_kbps: 2000,
include_audio: false,
..Default::default()
};
let (reference, _) = export_overlapped(3, 5, false, &settings("2pass-off.mp4"));
let (other, ahead) = export_overlapped(3, 5, true, &settings("2pass-on.mp4"));
assert!(ahead > 0);
assert!(reference == other, "two-pass: rendering ahead gives a different file");
// the whole export, with audio, through `export`: both passes counted once
let prog = Progress::default();
let (p, seq, m) = project();
pool(4).install(|| export(&p, seq, &ExportSettings { include_audio: true, ..settings("2pass-all.mp4") }, &m, &prog)).unwrap();
assert_eq!(prog.total.load(Ordering::Relaxed), 48);
assert_eq!(prog.done.load(Ordering::Relaxed), 48);
}
#[test]
fn hdr_with_audio_is_the_same_file_when_the_memory_budget_caps_the_batch() {
use filmcraft_color::{ColorPipeline, WorkingSpace};
for (format, ext) in [(Format::ProRes, "mov"), (Format::H264, "mp4")] {
let run = |name: &str, batch: i64, overlap: bool, budget: Option<u64>| {
let (p, seq, m) = project();
let mut p = (*p).clone();
p.sequence_mut(seq).unwrap().settings.color = ColorPipeline { working: WorkingSpace::Rec2100Pq, ..ColorPipeline::REC709 };
let p = Arc::new(p);
let s = ExportSettings { format, path: tmp(&format!("{name}.{ext}")), ..Default::default() };
pool(3).install(|| {
let prog = Progress::default();
let mut ex = Exporter::new(p, seq, &s, &prog).unwrap();
ex.set_batch(batch);
ex.set_overlap(overlap);
if let Some(b) = budget {
ex.set_budget(b);
}
let mut first_ahead = None;
loop {
let step = ex.step(&m, &prog).unwrap();
first_ahead.get_or_insert(ex.frames_ahead());
if matches!(step, Step::Done(_)) {
break;
}
}
(std::fs::read(&s.path).unwrap(), first_ahead.unwrap_or(0))
})
};
let (reference, _) = run(&format!("hdrcap-{}-ref", format.id()), 16, false, None);
// 320 x 180 HDR frames are 691 200 bytes: a budget of six of them lets two batches of three be in
// flight, so a batch of 16 is cut to 3 and the interleave cut at frame 16 falls in a prefetched batch
let budget = 6 * frame_bytes_for_test();
let (capped, ahead) = run(&format!("hdrcap-{}-3", format.id()), 16, true, Some(budget));
assert_eq!(ahead, 3, "{}: the batch was not capped to 3", format.label());
assert!(reference == capped, "{}: a capped HDR batch gives a different file", format.label());
let (one, ahead) = run(&format!("hdrcap-{}-1", format.id()), 16, true, Some(1));
assert_eq!(ahead, 1);
assert!(reference == one, "{}: one-frame HDR batches give a different file", format.label());
let (full, ahead) = run(&format!("hdrcap-{}-16", format.id()), 16, true, None);
assert_eq!(ahead, 8, "{}: 24 frames in batches of 16 leave 8 ahead", format.label());
assert!(reference == full, "{}: an uncapped HDR batch gives a different file", format.label());
}
}
/// Bytes of one HDR frame of the test project (320 x 180, three f32 per pixel).
fn frame_bytes_for_test() -> u64 {
320 * 180 * 12
}
/// A provider that answers like an asynchronous (web) reader whose bytes have not arrived yet for
/// the calls numbered in `loading` (marks pending, offers no source), and can be told to panic.
struct Flaky<'a> {
inner: &'a SourceMap,
calls: AtomicUsize,
loading: std::ops::Range<usize>,
panic_from: AtomicUsize,
}
impl<'a> Flaky<'a> {
fn new(inner: &'a SourceMap, loading: std::ops::Range<usize>) -> Self {
Self { inner, calls: AtomicUsize::new(0), loading, panic_from: AtomicUsize::new(usize::MAX) }
}
}
impl SourceProvider for Flaky<'_> {
fn source(&self, item: ItemId) -> Option<SharedSource> {
let n = self.calls.fetch_add(1, Ordering::SeqCst);
if n >= self.panic_from.load(Ordering::SeqCst) {
panic!("a source blew up (test)");
}
if self.loading.contains(&n) {
filmcraft_media::pending::mark();
return None;
}
self.inner.source(item)
}
}
#[test]
fn a_batch_rendered_ahead_with_missing_media_is_never_encoded() {
let settings = |name: &str| ExportSettings { format: Format::Mjpeg, path: tmp(name), include_audio: false, ..Default::default() };
let (reference, _) = export_overlapped(3, 3, false, &settings("pending-ref.mov"));
let (p, seq, m) = project();
let flaky = Flaky::new(&m, 5..70);
let s = settings("pending.mov");
let pendings = pool(3).install(|| {
let prog = Progress::default();
let mut ex = Exporter::new(p, seq, &s, &prog).unwrap();
ex.set_batch(3);
let mut pendings = 0;
for _ in 0..1000 {
match ex.step(&flaky, &prog).unwrap() {
Step::Done(_) => {
assert_eq!(prog.done.load(Ordering::Relaxed), 24);
return pendings;
}
Step::Pending => {
pendings += 1;
// the batch that was not complete is not kept for the next step
assert_eq!(ex.frames_ahead(), 0);
}
Step::Progress => {}
}
}
panic!("the export did not finish");
});
assert!(pendings > 0, "the missing media was never noticed");
// a frame drawn without its media would be black, not the red of the matte
assert!(std::fs::read(&s.path).unwrap() == reference, "frames rendered without their media were encoded");
}
#[test]
fn cancelling_drops_the_batch_rendered_ahead() {
let s = ExportSettings { format: Format::Mjpeg, path: tmp("cancel.mov"), ..Default::default() };
let (p, seq, m) = project();
pool(3).install(|| {
let prog = Progress::default();
let mut ex = Exporter::new(p, seq, &s, &prog).unwrap();
ex.set_batch(4);
assert!(matches!(ex.step(&m, &prog).unwrap(), Step::Progress));
assert_eq!(ex.frames_ahead(), 4, "the second batch was rendered while the first was encoded");
assert_eq!(prog.done.load(Ordering::Relaxed), 4);
prog.cancel.store(true, Ordering::Relaxed);
assert!(matches!(ex.step(&m, &prog), Err(ExportError::Cancelled)));
assert_eq!(ex.frames_ahead(), 0);
assert_eq!(prog.done.load(Ordering::Relaxed), 4, "nothing more was encoded");
});
}
#[test]
fn a_panic_while_rendering_ahead_is_an_error() {
let s = ExportSettings { format: Format::Mjpeg, path: tmp("panic.mov"), include_audio: false, ..Default::default() };
let (p, seq, m) = project();
let flaky = Flaky::new(&m, 0..0);
pool(3).install(|| {
let prog = Progress::default();
let mut ex = Exporter::new(p, seq, &s, &prog).unwrap();
ex.set_batch(4);
assert!(matches!(ex.step(&flaky, &prog).unwrap(), Step::Progress));
// the batch rendered ahead during the next step is the one that panics
flaky.panic_from.store(flaky.calls.load(Ordering::SeqCst), Ordering::SeqCst);
let r = ex.step(&flaky, &prog);
assert!(matches!(r, Err(ExportError::Encode(_))), "{:?}", r.map(|_| ()));
});
}

View File

@ -9,15 +9,59 @@
//! then encodes for real with the first pass's statistics.
//!
//! When a source's bytes are not available yet (asynchronous web reads mark
//! [`filmcraft_media::pending`]), the batch is rendered again on the next call: nothing is encoded
//! from frames or audio with missing media.
//! [`filmcraft_media::pending`]), the video batch is rendered again on the next call: nothing is encoded
//! from frames with missing media. Known, pre-existing web issue (TODO, separate fix): the audio pull
//! has already advanced its position when it hits a pending source, so a retried group loses its audio.
//!
//! The file does not depend on how many frames a step renders (one per core natively, one on the
//! web): packets go to the muxer in fixed groups of [`INTERLEAVE`] output frames, each followed by
//! the audio up to its end, so the same project and settings give the same bytes on any machine.
//!
//! # Overlapping render and encode
//!
//! Rendering a batch is parallel (rayon); encoding, muxing and the audio are serial and own the
//! encoder, the container writer and the audio state. Run one after the other the wall time was
//! `render + encode + audio + mux`. A step now renders batch *k+1* on the rayon pool
//! ([`rayon::in_place_scope`] + `spawn`) while the *calling* thread encodes, muxes and mixes the
//! audio of batch *k*, and keeps the finished frames in `ahead` for the next step: the wall time
//! is about `max(render, encode + audio + mux)`. Design points:
//!
//! * **Bytes do not change.** Batches are cut exactly as before (`next .. next + batch`), frames
//! are encoded in order, and the audio / mux interleave still follows [`INTERLEAVE`] groups, so
//! the file is identical with or without overlap, on any core count and for any step size
//! (`determinism_tests`).
//! * **The encode side stays on the thread that called [`Exporter::step`].** Encoders (hardware
//! ones in particular) may be tied to a thread, and the exporter need not be `Send`; only the
//! render of the next batch moves to the pool. It needs no `&mut` access to the exporter: it
//! holds an `Arc<Pipeline>` and the source provider.
//! * **Pending media.** [`filmcraft_media::pending`] is a thread-local flag, set by web readers
//! (whose export never overlaps: wasm has no threads, so [`Exporter::overlapping`] is false and
//! every step is the sequential render-then-encode of before). The render of a batch takes the
//! flag after every frame, on whatever thread drew it, and a batch that saw it is discarded:
//! a prefetched batch that hit pending is dropped and rendered again by the next step, and
//! nothing is ever encoded from it. The audio pull checks the flag on the encoding thread right
//! after pulling, before a frame is encoded (the batch is then not encoded; its audio is not
//! recovered, see the known issue above) and the prefetched batch is dropped too, because the step
//! did not advance `next`.
//! * **Cancellation** is checked at the start of every step, which drops the prefetched frames;
//! the prefetch is not started once it is set.
//! * **Panics.** The prefetch runs under `catch_unwind` and becomes an [`ExportError`]; a panic of
//! the encode side unwinds the calling thread as before (the job guard turns it into an error).
//! * **Memory.** Two batches are in flight instead of one, so the batch is capped by
//! [`IN_FLIGHT_BUDGET`]: `2 * batch * frame_bytes <= budget`, at least one frame
//! ([`overlap_batch`]). A batch never grows over what it was without overlap, so the peak is the
//! old peak plus at most the finished frames of the batch being encoded (half the budget,
//! 256 MiB); a 4K HDR export (100 MB a frame) runs 2-frame batches and 4K SDR 8-frame ones instead of 16. Frames are
//! dropped as soon as they are encoded.
//! * **Two-pass** exports overlap in the analysis pass (encode only) and in the real pass; the
//! prefetch never runs past the end of a pass.
//! * **Timers.** `Render` and `Encode` are wall times of work that now runs at the same time, so
//! their sum may exceed the export's wall time; `Wait` is the time the encoding side then spent
//! waiting for the render (large: render-bound; near zero: encode-bound).
use std::panic::{AssertUnwindSafe, catch_unwind};
use std::sync::Arc;
use std::sync::atomic::Ordering;
use std::sync::atomic::{AtomicBool, Ordering};
use filmcraft_isobmff::{Brand, Mp4Writer, PcmConfig, SampleEntry, TrackConfig, WriteSample, WriterOptions};
use filmcraft_project::{ItemId, Project};
@ -39,6 +83,58 @@ use crate::{
/// count) so the file layout is the same on every machine.
pub const INTERLEAVE: i64 = 16;
/// Most bytes of rendered frames an overlapped export keeps in flight (the batch being encoded and
/// the one being rendered), see [`overlap_batch`].
pub const IN_FLIGHT_BUDGET: u64 = 1 << 29;
/// One rendered picture: straight RGBA8, or for HDR exports the encoded R'G'B' floats.
type Frame = (Vec<u8>, Vec<f32>);
/// Bytes of one [`Frame`] of a `w` x `h` export (RGBA8, or three f32 per pixel for HDR).
pub(crate) fn frame_bytes(w: u32, h: u32, hdr: bool) -> u64 {
u64::from(w).saturating_mul(u64::from(h)).saturating_mul(if hdr { 12 } else { 4 })
}
/// Frames per batch when two batches are in flight: `batch` (what a sequential export would use),
/// lowered so that `2 * frames * frame_bytes` stays within `budget`, but never below one frame.
pub(crate) fn overlap_batch(batch: i64, frame_bytes: u64, budget: u64) -> i64 {
let want = u64::try_from(batch.max(1)).unwrap_or(1);
let fit = (budget / 2) / frame_bytes.max(1);
i64::try_from(fit.min(want)).unwrap_or(1).max(1)
}
/// A batch rendered while the previous one was being encoded.
struct Ahead {
/// First frame.
first: i64,
frames: Vec<Frame>,
}
/// Render frames `f..end` in parallel. `None` when a source was not ready for one of them (the
/// pending flag, taken after every frame because it is per thread): the batch is incomplete and
/// must be rendered again.
fn render_batch(pipe: &Pipeline, f: i64, end: i64, sources: &dyn SourceProvider) -> Option<Vec<Frame>> {
let pending = AtomicBool::new(false);
let frames: Vec<Frame> = (f..end)
.into_par_iter()
.map(|fi| {
let frame = pipe.frame(fi, sources);
if filmcraft_media::pending::take() {
pending.store(true, Ordering::Relaxed);
}
frame
})
.collect();
(!pending.load(Ordering::Relaxed)).then_some(frames)
}
/// What encoding a batch came to.
enum Encoded {
Done,
/// Audio media was not ready: nothing was encoded, run the batch again.
Pending,
}
/// What one [`Exporter::step`] did.
#[derive(Debug)]
pub enum Step {
@ -52,13 +148,21 @@ pub enum Step {
/// An export of an encoded-video format, advanced one batch at a time.
pub struct Exporter {
pipe: Pipeline,
pipe: Arc<Pipeline>,
settings: ExportSettings,
f0: i64,
f1: i64,
/// Next frame to render.
/// Next frame to encode.
next: i64,
batch: i64,
/// Render the next batch while this one is encoded (see the module docs).
overlap: bool,
/// Bytes of frames the overlap may keep in flight ([`IN_FLIGHT_BUDGET`]; tests lower it).
budget: u64,
/// The batch starting at `next`, already rendered.
ahead: Option<Ahead>,
/// Steps that rendered a batch alongside the encoding of the previous one.
overlapped_steps: u64,
brand: Brand,
venc: Box<dyn VideoEncoder>,
aenc: Option<Box<dyn AudioEncoder>>,
@ -124,7 +228,7 @@ impl Exporter {
let two_pass = settings.video_format() == Format::H264 && settings.bitrate_mode == BitrateMode::Vbr2Pass;
settings.h264_pass = if two_pass { H264Pass::First } else { H264Pass::Single };
let range = export_range(&project, seq, &settings)?;
let pipe = Pipeline::new(project.clone(), seq, &settings, hdr_out)?;
let pipe = Arc::new(Pipeline::new(project.clone(), seq, &settings, hdr_out)?);
let (f0, f1) = frame_span(pipe.rate, range);
let nframes = (f1 - f0) as u64;
if !settings.part_of_batch {
@ -149,6 +253,10 @@ impl Exporter {
f1,
next: f0,
batch: rayon::current_num_threads().clamp(2, 16) as i64,
overlap: true,
budget: IN_FLIGHT_BUDGET,
ahead: None,
overlapped_steps: 0,
brand,
venc,
aenc,
@ -177,6 +285,42 @@ impl Exporter {
self.batch = n.max(1);
}
/// Render the next batch while encoding the current one (the default natively; the web, with
/// no threads, never does). Off gives the sequential render-then-encode; the file is the same.
pub fn set_overlap(&mut self, on: bool) {
self.overlap = on;
if !on {
self.ahead = None;
}
}
/// Lower the in-flight byte budget (tests make small frames hit the cap).
#[cfg(test)]
pub(crate) fn set_budget(&mut self, bytes: u64) {
self.budget = bytes;
}
/// Whether steps overlap rendering with encoding now: switched on, a pool with more than one
/// thread, and not on wasm.
pub fn overlapping(&self) -> bool {
self.overlap && !cfg!(target_arch = "wasm32") && rayon::current_num_threads() > 1
}
/// Steps so far that rendered the next batch alongside the encoding of the current one.
pub fn overlapped_steps(&self) -> u64 {
self.overlapped_steps
}
/// Frames rendered ahead of the encoder now (memory held by the overlap).
pub fn frames_ahead(&self) -> usize {
self.ahead.as_ref().map_or(0, |a| a.frames.len())
}
/// Frames in the next batch: [`Self::set_batch`], lowered to the memory budget when overlapping.
fn batch_len(&self) -> i64 {
if self.overlapping() { overlap_batch(self.batch, frame_bytes(self.pipe.w, self.pipe.h, self.pipe.hdr_out), self.budget) } else { self.batch }
}
fn sample_at_frame(&self, f: i64, sr: u32) -> i64 {
self.pipe.rate.tick_of(f).to_units_floor(sr as i64)
}
@ -279,6 +423,7 @@ impl Exporter {
/// Render, encode and mux the next batch (or finish the file).
pub fn step(&mut self, sources: &dyn SourceProvider, progress: &Progress) -> Result<Step> {
if progress.cancel.load(Ordering::Relaxed) {
self.ahead = None;
return Err(ExportError::Cancelled);
}
let _ = filmcraft_media::pending::take();
@ -300,54 +445,9 @@ impl Exporter {
return Ok(Step::Progress);
}
if self.next < self.f1 {
let (f, end) = (self.next, (self.next + self.batch).min(self.f1));
let pipe = &self.pipe;
let frames: Vec<(Vec<u8>, Vec<f32>)> = timed(Stage::Render, || (f..end).into_par_iter().map(|fi| pipe.frame(fi, sources)).collect());
if self.first_pass {
if filmcraft_media::pending::take() {
return Ok(Step::Pending);
}
timed(Stage::Encode, || self.encode(&frames, f))?;
progress.done.fetch_add((end - f) as u64, Ordering::Relaxed);
self.next = end;
return Ok(Step::Progress);
}
// the audio of every interleave group that ends in this batch (before encoding, so a
// pending source can still make the batch run again)
let cuts: Vec<i64> = (f + 1..=end).filter(|&g| (g - self.f0) % INTERLEAVE == 0).collect();
let mut mixed = Vec::with_capacity(cuts.len());
if let Some(sr) = self.audio.as_ref().map(|a| a.sr) {
let t = web_time::Instant::now();
for &g in &cuts {
let until = self.sample_at_frame(g, sr);
mixed.push(self.audio.as_mut().and_then(|a| a.pull(until, sources)));
}
note_stage(Stage::Audio, t.elapsed());
}
if filmcraft_media::pending::take() {
return Ok(Step::Pending);
}
let mut mixed = mixed.into_iter();
for (k, frame) in frames.iter().enumerate() {
let fi = f + k as i64;
let packets = timed(Stage::Encode, || self.encode(std::slice::from_ref(frame), fi))?;
self.queued.extend(packets);
if cuts.contains(&(fi + 1)) {
timed(Stage::Mux, || -> Result<()> {
if self.mux.is_none() && self.mxf.is_none() {
self.open_mux()?;
}
let packets = std::mem::take(&mut self.queued);
self.write_video(packets)
})?;
let pcm = mixed.next().flatten();
timed(Stage::Audio, || self.write_audio(pcm))?;
}
}
progress.done.fetch_add((end - f) as u64, Ordering::Relaxed);
self.next = end;
return Ok(Step::Progress);
return self.step_batch(sources, progress);
}
self.ahead = None;
if self.first_pass {
// end of the analysis pass: encode for real with its statistics
let _ = self.venc.flush()?;
@ -407,6 +507,115 @@ impl Exporter {
}))
}
/// Encode (and mux) the batch starting at `self.next`, rendering the one after it meanwhile.
fn step_batch(&mut self, sources: &dyn SourceProvider, progress: &Progress) -> Result<Step> {
let (f, frames) = match self.ahead.take() {
Some(a) if a.first == self.next => (a.first, a.frames),
_ => {
let (f, end) = (self.next, self.next.saturating_add(self.batch_len()).min(self.f1));
let pipe = &self.pipe;
match timed(Stage::Render, || render_batch(pipe, f, end, sources)) {
Some(frames) => (f, frames),
None => return Ok(Step::Pending),
}
}
};
let end = f.saturating_add(i64::try_from(frames.len()).unwrap_or(0));
if end <= f {
return Err(ExportError::Encode("internal: an empty batch was rendered".into()));
}
// the batch after this one, when the pool can render it while this one is encoded
let plan =
(end < self.f1 && self.overlapping() && !progress.cancel.load(Ordering::Relaxed)).then(|| (end, end.saturating_add(self.batch_len()).min(self.f1)));
let Some((a, b)) = plan else {
return match self.encode_batch(f, frames, sources)? {
Encoded::Pending => Ok(Step::Pending),
Encoded::Done => {
progress.done.fetch_add((end - f) as u64, Ordering::Relaxed);
self.next = end;
Ok(Step::Progress)
}
};
};
let pipe = self.pipe.clone();
let mut slot: Option<std::thread::Result<Option<Vec<Frame>>>> = None;
let mut encode_done: Option<web_time::Instant> = None;
let encoded = rayon::in_place_scope(|s| {
s.spawn(|_| {
slot = Some(catch_unwind(AssertUnwindSafe(|| timed(Stage::Render, || render_batch(&pipe, a, b, sources)))));
});
let r = self.encode_batch(f, frames, sources);
encode_done = Some(web_time::Instant::now());
r
});
// the scope returned only now: what is left since the encoding ended is waiting for the render
if let Some(t) = encode_done {
note_stage(Stage::Wait, t.elapsed());
}
match encoded? {
Encoded::Pending => Ok(Step::Pending),
Encoded::Done => {
self.overlapped_steps += 1;
progress.done.fetch_add((end - f) as u64, Ordering::Relaxed);
self.next = end;
match slot {
Some(Ok(Some(frames))) => self.ahead = Some(Ahead { first: a, frames }),
// a source was not ready: the next step renders this batch again
Some(Ok(None)) | None => {}
Some(Err(_)) => return Err(ExportError::Encode("internal error while rendering a frame (the panic was logged by the panic hook)".into())),
}
Ok(Step::Progress)
}
}
}
/// Encode the rendered frames `f..` (the analysis pass of a two-pass export only encodes), mux
/// them with their audio, and drop each frame as soon as it is encoded.
fn encode_batch(&mut self, f: i64, frames: Vec<Frame>, sources: &dyn SourceProvider) -> Result<Encoded> {
if self.first_pass {
for (k, frame) in frames.into_iter().enumerate() {
timed(Stage::Encode, || self.encode(std::slice::from_ref(&frame), f + k as i64))?;
}
return Ok(Encoded::Done);
}
let end = f.saturating_add(i64::try_from(frames.len()).unwrap_or(0));
// the audio of every interleave group that ends in this batch, mixed before encoding so a
// pending source stops the step before any frame of the batch is encoded (the audio position has
// already moved though: known web issue, see the module docs)
let cuts: Vec<i64> = (f + 1..=end).filter(|&g| (g - self.f0) % INTERLEAVE == 0).collect();
let mut mixed = Vec::with_capacity(cuts.len());
if let Some(sr) = self.audio.as_ref().map(|a| a.sr) {
let t = web_time::Instant::now();
for &g in &cuts {
let until = self.sample_at_frame(g, sr);
mixed.push(self.audio.as_mut().and_then(|a| a.pull(until, sources)));
}
note_stage(Stage::Audio, t.elapsed());
}
// taken right here, on the thread that pulled, before other work can touch the flag
if filmcraft_media::pending::take() {
return Ok(Encoded::Pending);
}
let mut mixed = mixed.into_iter();
for (k, frame) in frames.into_iter().enumerate() {
let fi = f + k as i64;
let packets = timed(Stage::Encode, || self.encode(std::slice::from_ref(&frame), fi))?;
self.queued.extend(packets);
if cuts.contains(&(fi + 1)) {
timed(Stage::Mux, || -> Result<()> {
if self.mux.is_none() && self.mxf.is_none() {
self.open_mux()?;
}
let packets = std::mem::take(&mut self.queued);
self.write_video(packets)
})?;
let pcm = mixed.next().flatten();
timed(Stage::Audio, || self.write_audio(pcm))?;
}
}
Ok(Encoded::Done)
}
fn encode(&mut self, frames: &[(Vec<u8>, Vec<f32>)], f: i64) -> Result<Vec<EncodedPacket>> {
let mut packets = Vec::new();
for (k, (rgba, hdr)) in frames.iter().enumerate() {
@ -422,3 +631,48 @@ impl Exporter {
Ok(packets)
}
}
#[cfg(test)]
mod tests {
use super::*;
const MIB: u64 = 1 << 20;
#[test]
fn frame_bytes_by_format() {
assert_eq!(frame_bytes(1920, 1080, false), 1920 * 1080 * 4);
assert_eq!(frame_bytes(3840, 2160, true), 3840 * 2160 * 12);
assert_eq!(frame_bytes(u32::MAX, u32::MAX, true), u64::MAX, "saturates");
assert_eq!(frame_bytes(0, 1080, false), 0);
}
#[test]
fn the_overlap_batch_fits_the_memory_budget() {
let budget = IN_FLIGHT_BUDGET;
// 1080p keeps the full 16 frames, 4K SDR (33 MB a frame) 8 + 8 in flight (2 x 8 x 33 MB = 512 MiB)
assert_eq!(overlap_batch(16, frame_bytes(1920, 1080, false), budget), 16);
assert_eq!(overlap_batch(16, frame_bytes(3840, 2160, false), budget), 8);
// 4K HDR (100 MB a frame): 256 MiB per batch is 2 frames, not 16
let hdr4k = frame_bytes(3840, 2160, true);
let n = overlap_batch(16, hdr4k, budget);
assert_eq!(n, 2);
assert!(2 * n as u64 * hdr4k <= budget);
// 8K HDR: one frame at a time, even though two do not fit (never fewer than one)
assert_eq!(overlap_batch(16, frame_bytes(7680, 4320, true), budget), 1);
// a smaller batch (web: 1) is never raised, a degenerate one is one frame
assert_eq!(overlap_batch(1, 4, budget), 1);
assert_eq!(overlap_batch(0, 4, budget), 1);
assert_eq!(overlap_batch(-3, 4, budget), 1);
assert_eq!(overlap_batch(8, 0, budget), 8, "no division by zero");
assert_eq!(overlap_batch(8, u64::MAX, budget), 1);
assert_eq!(overlap_batch(8, 100 * MIB, 0), 1);
// two batches in flight never exceed the budget unless a single frame alone does
for batch in 1..=16 {
for fb in [MIB, 8 * MIB, 33 * MIB, 100 * MIB, 400 * MIB, 700 * MIB] {
let n = overlap_batch(batch, fb, budget) as u64;
assert!(n >= 1 && n <= batch as u64);
assert!(n == 1 || 2 * n * fb <= budget, "batch {batch}, frame {fb}: {n}");
}
}
}
}

View File

@ -658,7 +658,10 @@ pub fn note_hw_encode_declined() {
HW_DECLINED.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
}
/// The stages of an export whose wall time [`stage_times`] adds up (`perf.stats` `export.stages`).
/// The stages of an export whose wall time [`stage_times`] adds up (`perf.stats` `export.stages`). The
/// render of one batch overlaps the encoding of the previous one (`Exporter::overlapping`), so
/// [`Stage::Render`] and [`Stage::Encode`] with its neighbours can sum to more than the export took;
/// [`Stage::Wait`] is the time left waiting for the render.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Stage {
/// [`Exporter::new`]: settings, pipeline, encoder and audio set-up.
@ -677,12 +680,16 @@ pub enum Stage {
Mux,
/// Flushing the encoders and finishing the file.
Finish,
/// Time the encoding side waited, after encoding a batch, for the next batch to finish rendering
/// (only with overlap: large means the export is render-bound, near zero encode-bound).
Wait,
}
const STAGES: usize = 8;
const STAGES: usize = 9;
impl Stage {
pub const ALL: [Stage; STAGES] = [Stage::Setup, Stage::Loudness, Stage::Render, Stage::Encode, Stage::Convert, Stage::Audio, Stage::Mux, Stage::Finish];
pub const ALL: [Stage; STAGES] =
[Stage::Setup, Stage::Loudness, Stage::Render, Stage::Encode, Stage::Convert, Stage::Audio, Stage::Mux, Stage::Finish, Stage::Wait];
/// The name in `perf.stats`.
pub fn name(self) -> &'static str {
@ -695,6 +702,7 @@ impl Stage {
Stage::Audio => "audio",
Stage::Mux => "mux",
Stage::Finish => "finish",
Stage::Wait => "wait",
}
}
}

View File

@ -214,9 +214,80 @@ Where the built-in export's time goes (`sample` on the process during a 20 s exp
time in the call tree; the figures overlap because work-stealing mixes the tasks): about two thirds
of the busy CPU is in the encoder, about a third in compositing and layer decoding. Moving the encoder to
the media engine removes most of the first and lets the next batch render while the previous one
encodes (the exporter renders a batch of frames in parallel, then encodes it). What is left is the
encodes (the exporter now does that: see "Export: rendering the next batch while the current one is
encoded" below). What is left is the
CPU compositor, the next target of #30 ("GPU export").
## Export: rendering the next batch while the current one is encoded
Until now a step of the stepped exporter (`filmcraft_export::Exporter`) rendered a batch of frames in
parallel and only then encoded, muxed and mixed the audio of that batch on one thread, so the wall
time was the sum of the stages. Where the sum was large (1080p30, 300 frames, `perf.stats`
`export.stages`) the stages were: software H.264 render 4257 ms, encode 17845 (RGB to YUV 766 of it),
audio 462, mux 17, finish 142 = 22.96 s; NVENC H.264 setup 465, render 3316, encode 1113 (convert 794),
audio 471, mux 20, finish 24 = 5.69 s.
Now a step renders batch k+1 on the rayon pool while the calling thread encodes, muxes and mixes the
audio of batch k (`rayon::in_place_scope`), and the next step starts from the frames already rendered.
Wall time is about `max(render, encode + audio + mux)` instead of the sum. What did not change:
- **The file.** Batches are cut and encoded in the same order and the audio / mux interleave still
follows the 16-frame groups, so the output is byte-identical with or without overlap, on any core count
and with any step size, including one frame per step (tests in `determinism_tests.rs`: H.264, ProRes,
Motion-JPEG, two-pass).
- **Pending media.** A prefetched batch whose sources were not ready is dropped and rendered again by a
later step; nothing is encoded from an incomplete batch. The web app (no threads) never overlaps: it runs
the sequential render-then-encode as before.
- **Cancel and errors.** Cancelling drops the prefetched frames; a panic while rendering ahead becomes an
export error. The encoder stays on the thread that called `step`.
- **Memory.** Two batches are in flight (the one being encoded and the one being rendered), so up to
`2 * batch * frame_bytes` of finished frames are held, and the batch is lowered until that fits in
`IN_FLIGHT_BUDGET` (512 MiB; at least one frame): 16 frames of 1080p (8 MB), 8 + 8 frames of 4K SDR
(33 MB), 2 + 2 frames of 4K HDR (100 MB, 3840 x 2160 x 3 f32), 1 of 8K HDR. A batch never grows, and
frames are freed as soon as they are encoded. A first try with a 1 GiB budget (16 + 16 frames of 4K SDR,
5 + 5 of 4K HDR) raised the peak RSS of the 4K SDR exports by 80-700 MB over the old code; 512 MiB
brings it under the old peak (table below). `Exporter::set_overlap(false)` gives the old behaviour.
- **Known web issue, not changed here.** `AudioOut::pull` moves its position before the pending check, so
when a web export retries a batch after a pending audio source, that group of audio is lost. It is
independent of the overlap (the web never overlaps); TODO: fix separately.
`perf.stats` `export.stages`: `renderMs` and `encodeMs` (and audio / mux) now run at the same time, so
their sum can exceed the export's wall time. `waitMs` is the time the encoding side spent waiting for the
next batch to finish rendering after it was done encoding: large means render-bound, near zero means
encode-bound.
Measured (Xeon E5-2680 v4 14C/28T, RTX 5060, driver 617.42, Windows 11, release CLI, 300 frames of
30 fps, mean of 2 alternated rounds, export wall time, peak RSS of the CLI process; "before" is the same
harness on the commit without the overlap, "now" the 512 MiB budget; the 1080p rows were measured with a
1 GiB budget, which gives the same batches at that size):
| Case | wall before | wall now | speedup | `waitMs` now | peak RSS before / now |
|---|---|---|---|---|---|
| 1080p SW H.264 + AAC | 22.60 s | 20.66 s | 1.09x | 0 | 1279 / 1427 MB (+148) |
| 1080p NVENC H.264 | 5.56 s | 4.99 s | 1.11x | 49 ms | 1318 / 1441 MB (+123) |
| 1080p ProRes 422 HQ | 16.97 s | 15.32 s | 1.11x | 0 | 1234 / 1392 MB (+158) |
| 2160p SW H.264 | 60.79 s | 54.12 s | 1.12x | 0 | 4030 / 3113 MB (-917) |
| 2160p NVENC H.264 | 18.08 s | 15.64 s | 1.16x | 678 ms | 4177 / 3250 MB (-927) |
| 2160p ProRes 422 HQ | 60.92 s | 55.71 s | 1.09x | 0 | 3675 / 3140 MB (-535) |
| 2160p HDR PQ SW H.264 | 82.79 s | 77.62 s | 1.07x | 6758 ms | 5284 / 2560 MB (-2724) |
| 2160p HDR PQ ProRes 422 HQ | 89.14 s | 83.95 s | 1.06x | 215 ms | 5183 / 2565 MB (-2618) |
| 1080p effects NVENC H.264 | 28.70 s | 26.82 s | 1.07x | 13116 ms | 2044 / 2130 MB (+86) |
| 1080p effects SW H.264 | 39.60 s | 34.35 s | 1.15x | 0 | 2017 / 2084 MB (+67) |
With the first 1 GiB budget the 2160p rows were 55.42 / 16.00 / 56.75 s at 4404 / 4254 / 4376 MB peak and
the HDR rows 76.69 / 84.39 s at 3504 / 3532 MB: the same speed within 2 % (the HDR SW row 1.2 % slower
at 512 MiB), but 4K SDR used up to 700 MB more than the old code. Halving the budget costs no time and
makes every 4K export smaller than before.
The gain is 6-16 %, well under the `max(render, encode + audio + mux)` bound (1.06-1.86x). The reason is
that the encode side is not one core: the software H.264 and ProRes encoders run their slices on the same
rayon pool, and the RGB to YUV conversion is parallel too, so render and encode compete for the same
cores and overlapping them only fills idle gaps (the serial parts: entropy coding tails, muxing, audio,
the encoder's hand-off). Only NVENC, whose encode is light on the CPU, comes close to the bound in the
1080p case (4.99 s against 4.00 s). The effects case on NVENC is render-bound (`waitMs` 13 s of 27 s: the
encoder waits for the renderer), as expected. 1080p exports use 70-160 MB more (the second batch of 16
frames); 4K exports use less than before because the batch is smaller (8 for SDR, 2 for HDR instead of 16).
## Results (HW3: VideoToolbox hardware H.265 (HEVC) encoding, against hardware H.264)
Apple M1 (8 cores, 16 GB), 2026-10-07, single runs on an otherwise idle machine, release build. Both