M4.9: H.264 draft mode (no deblocking of non-reference pictures, off by default)

Decoder::set_draft: non-reference pictures skip the deblocking filter and are
flagged Picture::draft; all other pictures stay bit-exact (conformance test,
single- and frame-threaded). Conformance fixtures now also decode with 3
threads. Decoder::threads / default_threads for perf.stats.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-02 23:06:54 -04:00
parent a95fd5c829
commit b3a09fab64
8 changed files with 121 additions and 14 deletions

View File

@ -1,5 +1,6 @@
//! Decode an Annex-B H.264 file to raw yuv420p: `h264dec in.h264 [out.yuv]`.
//! Prints timing information to stderr.
//! Prints timing information to stderr. `H264_BENCH_ITERS=n` (with `H264_THREADS=t`, and
//! `H264_DRAFT=1` for draft mode) turns it into a benchmark.
use filmcraft_h264::Decoder;
use std::io::Write;
@ -18,6 +19,7 @@ fn main() {
let threads = std::env::var("H264_THREADS").ok().and_then(|v| v.parse::<usize>().ok());
for _ in 0..n {
let mut dec = threads.map(Decoder::with_threads).unwrap_or_default();
dec.set_draft(std::env::var_os("H264_DRAFT").is_some());
let t0 = std::time::Instant::now();
frames = dec.decode(&data, 0).expect("decode").len() + dec.flush().len();
best = best.min(t0.elapsed().as_secs_f64());

View File

@ -159,7 +159,10 @@ impl<'a> Cabac<'a> {
/// at high bit rates, so it selects values instead of jumping.
#[inline(always)]
pub fn decode_decision(&mut self, ctx_idx: usize) -> u32 {
let s = self.ctx[ctx_idx] as usize;
// Every ctxIdx is < 1024 and every state < 128: the masks only let the compiler drop the
// bounds checks from the per-bin path.
let ctx_idx = ctx_idx & (NUM_CTX - 1);
let s = (self.ctx[ctx_idx] & 127) as usize;
let q = ((self.range >> 6) & 3) as usize;
let lps = RANGE_TAB_LPS[s >> 1][q] as u32;
let mps_range = self.range - lps;

View File

@ -63,6 +63,8 @@ pub struct DecodeStats {
pub frame_num_gaps: u64,
pub weighted_slices: u64,
pub temporal_direct_slices: u64,
/// Non-reference pictures decoded in draft mode (deblocking skipped).
pub draft_pictures: u64,
}
/// State shared with decoding jobs.
@ -93,6 +95,9 @@ pub struct Decoder {
pool: Option<rayon::ThreadPool>,
in_flight: VecDeque<FrameRef>,
max_in_flight: usize,
threads: usize,
/// Draft mode: skip deblocking of non-reference pictures ([`Decoder::set_draft`]).
draft: bool,
}
impl Default for Decoder {
@ -122,7 +127,9 @@ fn is_new_picture(prev: &SliceHeader, sh: &SliceHeader, sps: &Sps) -> bool {
false
}
fn default_threads() -> usize {
/// The worker threads [`Decoder::new`] uses: the available cores (at most 16) with the `threads`
/// feature on native targets, 1 otherwise.
pub fn default_threads() -> usize {
#[cfg(all(feature = "threads", not(target_arch = "wasm32")))]
{
std::thread::available_parallelism().map(|n| n.get()).unwrap_or(1).min(16)
@ -148,6 +155,10 @@ impl Decoder {
} else {
None
};
#[cfg(feature = "threads")]
let workers = if pool.is_some() { threads } else { 1 };
#[cfg(not(feature = "threads"))]
let workers = 1;
Decoder {
spss: vec![None; 32],
ppss: vec![None; 256],
@ -165,9 +176,30 @@ impl Decoder {
pool,
in_flight: VecDeque::new(),
max_in_flight: threads.max(1) + 2,
threads: workers,
draft: false,
}
}
/// Worker threads decoding pictures (1: on the calling thread).
pub fn threads(&self) -> usize {
self.threads
}
/// Draft mode (off by default) for reduced-resolution playback: pictures submitted from now
/// on that are non-reference pictures (`nal_ref_idc` 0) skip the deblocking filter (8.7).
/// Nothing predicts from a non-reference picture (and deblocking never changes the motion
/// data used for direct prediction), so every other picture stays bit-exact; the draft
/// pictures themselves are approximate and flagged [`Picture::draft`]. Never use it for
/// export or for a frame shown while paused.
pub fn set_draft(&mut self, on: bool) {
self.draft = on;
}
pub fn draft(&self) -> bool {
self.draft
}
/// Configure from an `avcC` (AVCDecoderConfigurationRecord) box payload.
pub fn from_avcc(avcc: &[u8]) -> Result<Self> {
let mut d = Decoder::new();
@ -450,19 +482,20 @@ impl Decoder {
fn submit_pending(&mut self) {
let Some(p) = self.pending.take() else { return };
let PendingPic { frame, sps, first, poc, pts, key, slices } = p;
self.dispatch(frame.clone(), slices);
let draft = self.draft && first.nal_ref_idc == 0;
self.dispatch(frame.clone(), slices, draft);
self.poc_state.update(&first, &poc);
if first.nal_ref_idc != 0 {
self.prev_ref_frame_num = if first.has_mmco5() { 0 } else { first.frame_num };
}
let meta = Arc::new(output_meta(&sps, pts, key));
let meta = Arc::new(OutputMeta { draft, ..output_meta(&sps, pts, key) });
let mut outs = Vec::new();
let fpoc = frame.poc;
self.dpb.store_picture(&first, frame, fpoc, sps.max_frame_num(), sps.max_num_ref_frames as usize, meta, &mut outs);
self.emit(outs);
}
fn dispatch(&mut self, frame: FrameRef, slices: Vec<SliceJob>) {
fn dispatch(&mut self, frame: FrameRef, slices: Vec<SliceJob>, draft: bool) {
let shared = self.shared.clone();
#[cfg(feature = "threads")]
if let Some(pool) = &self.pool {
@ -473,10 +506,10 @@ impl Decoder {
}
}
self.in_flight.push_back(frame.clone());
pool.spawn_fifo(move || run_job(frame, slices, &shared));
pool.spawn_fifo(move || run_job(frame, slices, &shared, draft));
return;
}
run_job(frame, slices, &shared);
run_job(frame, slices, &shared, draft);
}
fn emit(&mut self, outs: Vec<Output>) {
@ -485,7 +518,7 @@ impl Decoder {
}
/// Decode all slices of one picture and publish its rows.
fn run_job(frame: FrameRef, slices: Vec<SliceJob>, shared: &Shared) {
fn run_job(frame: FrameRef, slices: Vec<SliceJob>, shared: &Shared, draft: bool) {
let reuse = shared.pool.lock().ok().and_then(|mut p| p.pop());
let mut pic = match reuse {
Some(mut p) if p.mb_w == frame.mb_w && p.mb_h == frame.mb_h() => {
@ -494,6 +527,7 @@ fn run_job(frame: FrameRef, slices: Vec<SliceJob>, shared: &Shared) {
}
_ => PicState::new(frame.clone()),
};
pic.skip_deblock = draft;
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let mut first_err = None;
for s in &slices {
@ -521,6 +555,7 @@ fn run_job(frame: FrameRef, slices: Vec<SliceJob>, shared: &Shared) {
};
if let Ok(mut s) = shared.stats.lock() {
s.pictures += 1;
s.draft_pictures += draft as u64;
for st in &pic.mbs {
match st.kind {
MbKind::I4x4 => s.mb_i4x4 += 1,
@ -570,6 +605,7 @@ fn output_meta(sps: &Sps, pts: i64, key: bool) -> OutputMeta {
transfer_characteristics: vui.transfer_characteristics,
matrix_coefficients: vui.matrix_coefficients,
sar: vui.sar,
draft: false,
}
}
@ -598,5 +634,6 @@ fn make_picture(o: &Output) -> Picture {
matrix: o.meta.matrix_coefficients,
},
sar: o.meta.sar,
draft: o.meta.draft,
}
}

View File

@ -24,6 +24,8 @@ pub struct OutputMeta {
pub transfer_characteristics: u8,
pub matrix_coefficients: u8,
pub sar: (u16, u16),
/// Decoded without deblocking (draft mode, non-reference picture).
pub draft: bool,
}
pub struct DpbEntry {

View File

@ -41,7 +41,7 @@ mod synth_tests;
mod tables;
mod transform;
pub use decoder::{DecodeStats, Decoder};
pub use decoder::{DecodeStats, Decoder, default_threads};
pub use error::{Error, Result};
/// Colour description from the VUI (ITU-T H.273 code points).
@ -77,4 +77,8 @@ pub struct Picture {
pub color: ColorInfo,
/// Sample aspect ratio (0, 0 when unspecified).
pub sar: (u16, u16),
/// Decoded in draft mode ([`Decoder::set_draft`]): a non-reference picture whose deblocking
/// filter was skipped. Its samples are approximate (not the conforming output); no other
/// picture is affected.
pub draft: bool,
}

View File

@ -40,6 +40,8 @@ pub struct PicState {
rows_recon: usize,
rows_deblocked: usize,
rows_published: usize,
/// Draft mode for a non-reference picture: rows are published without deblocking.
pub skip_deblock: bool,
}
impl PicState {
@ -58,6 +60,7 @@ impl PicState {
rows_recon: 0,
rows_deblocked: 0,
rows_published: 0,
skip_deblock: false,
}
}
@ -76,6 +79,7 @@ impl PicState {
self.rows_recon = 0;
self.rows_deblocked = 0;
self.rows_published = 0;
self.skip_deblock = false;
}
/// Record a decoded macroblock and deblock / publish rows that became final.
@ -100,8 +104,10 @@ impl PicState {
let deblock_limit = if finished { self.mb_h } else { self.rows_recon.saturating_sub(1) };
while self.rows_deblocked < deblock_limit {
let r = self.rows_deblocked;
for addr in r * self.mb_w..(r + 1) * self.mb_w {
deblock::deblock_mb(self, addr, self.mb_w);
if !self.skip_deblock {
for addr in r * self.mb_w..(r + 1) * self.mb_w {
deblock::deblock_mb(self, addr, self.mb_w);
}
}
self.rows_deblocked += 1;
}

View File

@ -422,8 +422,15 @@ pub fn decode_file(path: &Path) -> Result<Vec<Picture>, DecodeFailure> {
/// Decode with `threads` worker threads (0 = decoder default).
pub fn decode_file_threads(path: &Path, threads: usize) -> Result<Vec<Picture>, DecodeFailure> {
decode_file_opts(path, threads, false)
}
/// [`decode_file_threads`] with draft mode ([`Decoder::set_draft`]) on or off.
pub fn decode_file_opts(path: &Path, threads: usize, draft: bool) -> Result<Vec<Picture>, DecodeFailure> {
let data = std::fs::read(path).unwrap();
let mut dec = if threads == 0 { Decoder::new() } else { Decoder::with_threads(threads) };
assert!(!dec.draft(), "draft mode is off by default");
dec.set_draft(draft);
let mut out = Vec::new();
for (i, au) in split_access_units(&data).into_iter().enumerate() {
match dec.decode(au, i as i64) {
@ -494,8 +501,9 @@ pub fn check_fixture(name: &str) -> Result<bool, String> {
let f = fixture(name);
let Some((h264, yuv)) = ensure(f) else { return Ok(false) };
let reference = std::fs::read(&yuv).unwrap();
// single-threaded and frame-threaded decoding must both be bit-exact
for threads in [1, 0] {
// single-threaded and frame-threaded decoding (an odd thread count and every core) must all
// be bit-exact
for threads in [1, 3, 0] {
let pics = match decode_file_threads(&h264, threads) {
Ok(p) => p,
Err((au, e, partial)) => {
@ -505,6 +513,9 @@ pub fn check_fixture(name: &str) -> Result<bool, String> {
}
};
compare(&pics, &reference, f.width as usize, f.height as usize).map_err(|e| format!("{name} (threads {threads}): {e}"))?;
if pics.iter().any(|p| p.draft) {
return Err(format!("{name} (threads {threads}): draft picture without draft mode"));
}
check_pts(&pics).map_err(|e| format!("{name} (threads {threads}): {e}"))?;
}
Ok(true)

View File

@ -56,6 +56,48 @@ fixture_tests!(
vt_high_1080p,
);
/// Draft mode (reduced-resolution playback) skips deblocking of non-reference pictures only:
/// every other picture stays bit-exact with ffmpeg, single- and frame-threaded, the skipped ones
/// are flagged, and they are the only ones that differ.
#[test]
fn draft_mode_changes_only_flagged_non_reference_pictures() {
let mut checked = 0;
for name in ["main_cabac_b", "bpyramid", "cavlc_b", "direct_temporal", "weightb"] {
let f = common::fixture(name);
let Some((h264, yuv)) = common::ensure(f) else {
eprintln!("skipped {name}");
continue;
};
let reference = std::fs::read(&yuv).unwrap();
let (w, h) = (f.width as usize, f.height as usize);
let fsize = w * h + 2 * w.div_ceil(2) * h.div_ceil(2);
for threads in [1, 0] {
let pics = common::decode_file_opts(&h264, threads, true).unwrap_or_else(|(au, e, _)| panic!("{name}: error at {au}: {e}"));
common::check_pts(&pics).unwrap_or_else(|e| panic!("{name}: {e}"));
assert_eq!(pics.len() * fsize, reference.len(), "{name}: picture count");
let (mut draft, mut changed) = (0, 0);
for (i, p) in pics.iter().enumerate() {
let r = &reference[i * fsize..(i + 1) * fsize];
let same = p.y == r[..w * h] && [&p.u[..], &p.v[..]].concat() == r[w * h..];
if p.draft {
draft += 1;
changed += !same as usize;
} else {
assert!(same, "{name} (threads {threads}): picture {i} (not draft) differs from the reference");
}
}
assert!(draft > 0, "{name}: has non-reference pictures");
if name != "no_deblock" {
assert!(changed > 0, "{name}: skipping deblocking changes some draft picture");
}
checked += 1;
}
}
if checked == 0 {
eprintln!("skipped: no fixtures");
}
}
/// Print which coding tools each fixture exercises (run with `-- --ignored --nocapture`).
#[test]
#[ignore]