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:
parent
1996f319c3
commit
9510e26584
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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(|_| ()));
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue