Files
filmcraft/crates/render/examples/frame_bench.rs
Dario CarneluttiandClaude Sonnet 5.5 d2ab33ba76 render: composite only what overlays cover, and reuse the float images
The CPU compositor, which is what export runs, works on premultiplied linear f32 images,
33 MB each at 1080p, and every layer was one of them. A profile of a 9:29 export with a
hardware H.264 encoder (so the encoder is no longer the bottleneck) put 61-64 % of the busy
CPU in compositing, 12 % in zeroing and copying memory, 8-11 % each in the float -> 8-bit
and RGBA -> Y'CbCr conversions and 4-7 % in decoding the ProRes overlays: a banner that is
transparent except for its bottom 100 rows still cost a whole conversion and a whole mix,
and the bottom layer paid for a zero-filled canvas and a pass that mixes it over that canvas.

Three changes, each of which gives the bits the general path gives:

- The canvas is allocated when a layer needs it, and an opaque first layer (a Y'CbCr frame
  without an alpha plane, opacity 100 %, Normal blend, the size of the output) becomes the
  canvas instead of being mixed over zeros.
- A clip of plain media whose frame has an alpha plane (a ProRes 4444 banner) is converted
  and mixed only inside the rectangle where its alpha is not zero: `VideoFrame::alpha_region`,
  `to_linear_f32_region` and `blend::composite_at` (`composite` is `composite_at` at the
  origin; Dissolve's pattern follows the pixel's place in the output). 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, as before.
- `filmcraft_frame::pool` keeps idle f32 images (320 MiB at most). `take_f32_overwritten`
  hands one back with its old contents, which the conversions overwrite whole (so no
  zero-fill), and the export pipeline gives each frame's image back after the 8-bit
  conversion.

Apple M1 (8 cores), a machine that was not idle, so the two builds alternate and the
median is reported:

- Synthetic 1080p frame (`cargo run --release -p filmcraft-render --example frame_bench`,
  5 rounds): camera only 8.9 -> 6.5 ms, camera + banner 16.2 -> 7.8 ms, same picture checksum.
- A real project (1080p25 camera clip, ProRes 4444 banner and subscribe bar), 30 s with the
  banner exported with the built-in encoder, which is deterministic: 46.4 -> 36.7 s, and
  both exports are the same file (same MD5), so every frame of the real pipeline matches.
- The same project with a hardware encoder, 3 rounds: 30 s with the banner 17.4 -> 11.2 s
  (CPU 99 -> 56 s), 30 s of the camera alone 11.4 -> 8.8 s, 20 s with the subscribe bar
  11.1 -> 7.0 s.

Tests: `render/src/region_tests.rs` compares each shortcut with the general path bit for bit
(an opaque bottom layer, banners over and under a camera, an 8-bit sticker, every blend mode,
several opacities, moved, scaled and effect clips, a bigger source, reduced-resolution
rendering), each render twice so the second starts from recycled buffers, and checks which
layers take a shortcut; the frame tests check that a region is the crop of the whole
conversion, that `alpha_region` is the box around what is not transparent, and that a buffer
full of NaN never leaks into a conversion, for every pixel format. Breaking a shortcut on
purpose (a region one pixel off, an opaque layer taken as the canvas at half opacity, a
conversion that skips the alpha channel) makes them fail.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 13:35:20 -06:00

176 lines
7.4 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Per-frame cost of the CPU compositor at 1920×1080, stage by stage, with synthetic sources:
//! a camera-like 8-bit 4:2:0 picture and a ProRes-4444-like 10-bit 4:4:4 banner whose alpha is
//! transparent except for its bottom 100 rows.
//!
//! cargo run --release -p filmcraft-render --example frame_bench
//!
//! Prints the median of 40 frames (after 6 warm-up frames) in milliseconds, wall time on the
//! global rayon pool, and a checksum of the final 8-bit picture of each scenario so two builds can
//! be compared for identical output.
use std::sync::Arc;
use std::time::Instant;
use filmcraft_frame::{AudioBuffer, Chroma, PixelData, VideoFrame};
use filmcraft_media::generators::GeneratorSource;
use filmcraft_media::{FrameRequest, Generator, MediaInfo, MediaSource};
use filmcraft_project::{ItemKind, Label, MediaClip, MediaRef, Project, SequenceSettings, TrackKind};
use filmcraft_render::blend::{self, Blend};
use filmcraft_render::{Image, RenderOptions, SourceMap, render_sequence};
use filmcraft_time::{FrameRate, TICKS_PER_SECOND, Tick, TimeRange};
const W: usize = 1920;
const H: usize = 1080;
const FPS: FrameRate = FrameRate::FPS_25;
struct Still {
info: MediaInfo,
frame: Arc<VideoFrame>,
}
impl MediaSource for Still {
fn info(&self) -> &MediaInfo {
&self.info
}
fn video_frame(&self, _req: FrameRequest) -> filmcraft_media::Result<Arc<VideoFrame>> {
Ok(self.frame.clone())
}
fn audio(&self, _start: i64, frames: usize, sample_rate: u32) -> filmcraft_media::Result<AudioBuffer> {
Ok(AudioBuffer::silence(sample_rate, 2, frames))
}
}
/// A camera picture: 8-bit 4:2:0, limited range BT.709, no alpha.
fn camera() -> Arc<VideoFrame> {
let y: Vec<u8> = (0..W * H).map(|i| (16 + ((i % W) * 200 / W + (i / W) * 30 / H + (i * 7919) % 5) % 219) as u8).collect();
let (cw, ch) = (W / 2, H / 2);
let u: Vec<u8> = (0..cw * ch).map(|i| (100 + (i % cw) * 40 / cw) as u8).collect();
let v: Vec<u8> = (0..cw * ch).map(|i| (150 - (i / cw) * 40 / ch) as u8).collect();
Arc::new(VideoFrame {
width: W as u32,
height: H as u32,
data: PixelData::Yuv8 { planes: [Arc::new(y), Arc::new(u), Arc::new(v)], chroma: Chroma::C420, alpha: None },
color: filmcraft_color::ColorInfo::REC709,
par: (1, 1),
pts: Tick::ZERO,
})
}
/// A banner like the channel's: 10-bit 4:4:4 with an alpha plane, opaque only in the bottom `bar` rows
/// (with a soft one-row edge), transparent elsewhere.
fn banner(bar: usize) -> Arc<VideoFrame> {
let n = W * H;
let top = H - bar;
let alpha: Vec<u16> = (0..n)
.map(|i| match i / W {
r if r + 1 < top => 0,
r if r < top => 512,
_ => 1023,
})
.collect();
Arc::new(VideoFrame {
width: W as u32,
height: H as u32,
data: PixelData::Yuv16 {
planes: [Arc::new(vec![300u16; n]), Arc::new((0..n).map(|i| 512 + (i % 64) as u16).collect()), Arc::new(vec![600u16; n])],
chroma: Chroma::C444,
bits: 10,
alpha: Some(Arc::new(alpha)),
},
color: filmcraft_color::ColorInfo::REC709,
par: (1, 1),
pts: Tick::ZERO,
})
}
fn project(layers: &[Arc<VideoFrame>]) -> (Project, filmcraft_project::ItemId, SourceMap) {
let mut p = Project::new("bench");
let mut map = SourceMap::default();
let seq = p.new_sequence("s", SequenceSettings { width: W as u32, height: H as u32, frame_rate: FPS, ..Default::default() }, layers.len().max(1), 1, None);
for (track, frame) in layers.iter().enumerate() {
let g = GeneratorSource::new(Generator::ColorMatte { color: [0.0, 0.0, 0.0, 1.0] }, W as u32, H as u32, FPS, Tick(60 * TICKS_PER_SECOND));
let info = g.info().clone();
let item = p.add_item(
&format!("layer{track}"),
Label::Iris,
ItemKind::Media(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,
}),
None,
);
let mut clip = p.make_track_item(item, TrackKind::Video, Tick::ZERO, TimeRange::new(Tick::ZERO, FPS.tick_of(2000)), FPS).expect("clip");
clip.scale_to_frame = true;
if let Some(t) = p.sequence_mut(seq).and_then(|s| s.video_tracks.get_mut(track)) {
t.items.push(clip);
}
map.0.insert(item, Arc::new(Still { info, frame: frame.clone() }));
}
(p, seq, map)
}
fn median_ms(mut f: impl FnMut()) -> f64 {
for _ in 0..6 {
f();
}
let mut v: Vec<f64> = (0..40)
.map(|_| {
let t = Instant::now();
f();
t.elapsed().as_secs_f64() * 1000.0
})
.collect();
v.sort_by(|a, b| a.total_cmp(b));
v[v.len() / 2]
}
fn checksum(bytes: &[u8]) -> u64 {
bytes.iter().fold(0xcbf29ce484222325u64, |h, b| (h ^ u64::from(*b)).wrapping_mul(0x100000001b3))
}
fn main() {
println!("rayon threads: {}", rayon::current_num_threads());
let (cam, ban) = (camera(), banner(100));
println!("\n-- stages (ms per frame, 1920x1080) --");
println!("camera frame -> linear f32 image : {:6.2}", median_ms(|| drop(std::hint::black_box(cam.to_linear_f32_decimated(1)))));
println!("banner frame -> linear f32 image : {:6.2}", median_ms(|| drop(std::hint::black_box(ban.to_linear_f32_decimated(1)))));
println!("Image::new (zeroed 33 MB canvas) : {:6.2}", median_ms(|| drop(std::hint::black_box(Image::new(W, H)))));
let (_, _, cpx) = cam.to_linear_f32_decimated(1);
let (_, _, bpx) = ban.to_linear_f32_decimated(1);
let (cam_img, ban_img) = (Image { w: W, h: H, px: cpx }, Image { w: W, h: H, px: bpx });
let mut canvas = Image::new(W, H);
println!("composite camera over empty : {:6.2}", median_ms(|| blend::composite(&mut canvas, &cam_img, 1.0, Blend::Normal)));
println!("composite banner over camera : {:6.2}", median_ms(|| blend::composite(&mut canvas, &ban_img, 1.0, Blend::Normal)));
println!("linear f32 -> sRGB 8-bit RGBA : {:6.2}", median_ms(|| drop(std::hint::black_box(canvas.over_black_rgba8()))));
// FC_RECYCLE=1 hands each frame's float image back to the pool, as the exporter does
let recycle = std::env::var_os("FC_RECYCLE").is_some();
println!("\n-- whole frames: render_sequence + 8-bit conversion (ms per frame){} --", if recycle { ", buffers recycled" } else { "" });
for (name, layers) in [("camera only", vec![cam.clone()]), ("camera + banner", vec![cam.clone(), ban.clone()])] {
let (p, seq, map) = project(&layers);
let opts = RenderOptions::default();
let mut last = Vec::new();
let ms = median_ms(|| {
let img = render_sequence(&p, seq, FPS.tick_of(10), opts, &map);
last = img.over_black_rgba8();
if recycle {
filmcraft_frame::pool::recycle_f32(img.px);
}
});
let render_only = median_ms(|| {
let img = std::hint::black_box(render_sequence(&p, seq, FPS.tick_of(10), opts, &map));
if recycle {
filmcraft_frame::pool::recycle_f32(img.px);
}
});
println!("{name:16}: render {render_only:6.2} render + 8-bit {ms:6.2} checksum {:016x}", checksum(&last));
}
}