M9.8: VP9 loop filter speedup (gathered samples, sliding wide filter), perf test, vp9dec example, README

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-09-30 22:46:13 -04:00
co-authored by Claude Opus 5.5
parent 804eb14fa9
commit 5e9d7d904e
4 changed files with 241 additions and 57 deletions
+148
View File
@@ -0,0 +1,148 @@
# filmcraft-vp9
Clean-room, pure-Rust (no `unsafe`) VP9 decoder, implemented from the public *VP9 Bitstream &
Decoding Process Specification* v0.6 (WebM project). It is an L0 crate: it depends only on
`filmcraft-bitstream`, `thiserror`, and optionally `rayon` (default feature `threads`), and builds
for `wasm32-unknown-unknown` with and without `threads`.
The constant tables (probability defaults, scans, coefficient bands, quantizer lookups, Pareto
table, interpolation filters, `mv_ref_blocks`...) were extracted from the specification text by
[`tools/extract_tables.py`](tools/extract_tables.py) into `src/spec_tables.rs`. The 1D inverse
DCT / ADST butterflies are generated by [`tools/gen_transforms.py`](tools/gen_transforms.py),
which symbolically executes the step lists of spec section 8.7.1 into `src/transform_gen.rs`.
No code was taken or ported from libvpx, FFmpeg or any other decoder; ffmpeg (with libvpx-vp9)
is used only as an external fixture generator and test oracle.
## Status
| Area | Status |
|---|---|
| Profiles 0 / 2 (8, 10, 12-bit 4:2:0) | done, bit-exact |
| Profiles 1 / 3 (4:2:2, 4:4:0, 4:4:4; 8 / 10 / 12-bit) | done, bit-exact |
| Uncompressed + compressed headers, superframe index (Annex B) | done |
| Boolean decoder, forward updates (`diff_update_prob`, MV probs), backward adaptation (8.4) | done |
| Segmentation (tree / temporal prediction, all four features), loop filter (levels, sharpness, ref / mode deltas, segment levels) | done |
| All 10 intra modes incl. edge rules, sub-8x8 blocks | done |
| Inter: 8-tap regular / smooth / sharp + bilinear, compound prediction, MV prediction (incl. previous-frame MVs, sub-8x8 append), reference scaling (8.5.2.3) | done |
| DCT / ADST 4..32, WHT (lossless) | done |
| Tiles (columns and rows); tile columns decode in parallel | done |
| Hidden frames, show_existing_frame, intra-only frames, resolution changes, error resilient / frame parallel modes | done |
| Loop filter in parallel (superblock wavefront) | done |
| Frame-level threading (overlapping consecutive frames) | not yet |
## API
```rust
use filmcraft_vp9::{Decoder, Plane};
let mut dec = Decoder::new(); // or Decoder::with_threads(n)
for (pts, chunk) in chunks { // one IVF / WebM block / MP4 sample each (frame or superframe)
for pic in dec.decode(chunk, pts)? {
// pic.width / pic.height (frame size), pic.render_width / render_height (render_size),
// pic.bit_depth, pic.subsampling_x / subsampling_y, pic.chroma_width / chroma_height,
// pic.y / pic.u / pic.v: Plane::U8(Vec<u8>) for 8-bit, Plane::U16(Vec<u16>) above,
// pic.y_stride / pic.uv_stride, pic.pts, pic.key, pic.intra_only,
// pic.color { color_space (0..7 as coded), full_range }
}
}
let _ = dec.flush(); // VP9 has no reordering: always empty, kept for API symmetry
let stats = dec.stats(); // what the stream exercised (frame kinds, tiles, compound, scaling...)
```
A superframe produces every shown frame it contains (normally one); hidden frames produce none.
### Architecture
The calling thread parses the uncompressed and compressed headers and manages the eight reference
slots, the four probability contexts, segmentation map and previous-frame motion vectors. Each tile
column is decoded by one `TileDecoder` (rayon pool) into a private strip of samples and mode info
(tile rows of a column are decoded in order — they share above contexts); strips are then
assembled, the loop filter runs as a superblock wavefront (superblock (r, c) waits for (r-1, c+1);
each superblock filters a private copy of its area plus 8 samples above / left and writes it
back), counts are merged for backward adaptation and the frame is stored. Samples are `u16`
internally for every bit depth.
Modules: `header` (6.2 / 6.3, superframes), `boolcoder` (9.2), `probs` (frame contexts, counts,
8.4), `tile` (partition / mode info / MV prediction), `tile::recon` (residual, tokens, intra /
inter prediction calls, reconstruction), `intra`, `inter`, `transform`, `loopfilter`, `decoder`.
## Tests
`cargo test --release -p filmcraft-vp9`. Fixtures are generated on first use into
`target/fixtures/vp9/` with ffmpeg + libvpx-vp9 (tests print a message and skip when ffmpeg or
libvpx is unavailable). Every frame is compared sample-exactly with `ffmpeg -c:v vp9 -f rawvideo`
output, single-threaded and with the default thread pool; the first mismatch reports frame, plane,
position and superblock.
- Unit tests: boolean decoder against a literal model of 9.2, tables / trees, inverse DCT / ADST
against floating-point bases, DC-only shortcut vs. full transform, scaled MC path vs. unscaled,
superframe index, `inv_remap_prob`, probability merging.
- Conformance (`tests/conformance.rs`): the fixture matrix below plus a stream concatenating two
sizes. `compare_detects_mismatch` guards the harness. `coverage_report` (ignored) prints the
decoder statistics for each fixture.
- Synthetic (`tests/synthetic.rs`): headers of libvpx output are rewritten to produce what ffmpeg's
libvpx wrapper cannot: inter frames of a stream encoded at another size spliced after a 352x288
key frame with explicit frame sizes, i.e. **scaled reference prediction** (downscale 256x208,
upscale 480x400, odd 301x199; a same-size splice is the control); a key frame rewritten as a
hidden **intra-only frame** (reset_frame_context 3 / 2 / 0, various refresh masks) shown with
**show_existing_frame**; show_existing_frame of every slot, also inside a superframe.
- Robustness (`tests/robustness.rs`): bit flips, byte replacement and truncation of real streams,
truncated frames, random data and oversized frames never panic (debug and release) or hang,
single- and multi-threaded.
### Fixture matrix (all bit-exact, 1 thread and thread pool)
Backward adaptation is on (`-frame-parallel 0`) unless noted.
| fixture | content | configuration |
|---|---|---|
| intra_only_keyframes | 352x288 | `-g 1` (key frames only, frame parallel) |
| default_good | 352x288, 20 frames | 800 kb/s |
| profile2_10bit / profile2_12bit | 352x288 / 176x144 | profile 2, 10 / 12-bit |
| profile1_444 / _422 / _440 | 176x144 | profile 1, 4:4:4 / 4:2:2 / 4:4:0 |
| profile3_444_10bit / profile3_422_12bit | 176x144 | profile 3 |
| lossless / lossless_10bit | 176x144 | `-lossless 1` (WHT) |
| tiles_cols4 | 1280x720 | 4 tile columns |
| tiles_rows_cols / tiles_rows4 | 1280x720 / 640x480 | tile columns + rows / 4 tile rows |
| altref_hidden / altref_10bit / altref_tiles | 352x288 / 720p | 2-pass, `-auto-alt-ref 1`: hidden frames, superframes, compound prediction |
| frame_parallel | 352x288 | `-frame-parallel 1` (no backward adaptation) |
| error_resilient | 352x288 | `-error-resilient 1` |
| odd_size / tiny_odd | 347x251 / 33x17 | odd sizes, partial superblocks |
| row_mt | 640x360 | `-row-mt 1`, 2 tile columns |
| speed0 .. speed8 | 176x144 / 352x288 | every speed (0 two-pass, 5..8 realtime) |
| cq_low / cq_q4 / cq_high / cq_10bit_low / cq_12bit_high | | `-crf 0 / 4 / 63 / 2 / 60` |
| aq_segmentation / aq_variance / aq_complexity | 352x288 | `-aq-mode 3 / 1 / 2` (segmentation, temporal segment-id prediction) |
| sharpness | 352x288 | `-sharpness 5` |
| fade_intra_heavy | 352x288 | fade-in, key frame every 8 |
| hd_1080p | 1920x1080 | realtime speed 5 |
| resize_concatenated | 352x288 then 347x251 | size change at a key frame |
## Performance
`cargo test --release -p filmcraft-vp9 --test perf -- --ignored --nocapture` (bit-exactness is
verified first). Apple M4 Pro (14 cores). The machine was heavily shared during measurement (load
average 130-170), so wall-clock figures are far below what an idle machine gives; single-thread
CPU time is the more meaningful number.
| stream | 1 thread (CPU time) | 1 thread (wall, loaded) | 14 threads (wall, loaded) |
|---|---|---|---|
| 1080p 8-bit, libvpx speed 4, 4 MB/s, 4 tile columns, 60 frames | ~47 fps | ~11-17 fps | ~26-35 fps |
| 2160p 8-bit, libvpx speed 6, 12 MB/s, 8 tile columns, 20 frames | ~13 fps | ~7-13 fps | ~15-21 fps |
No SIMD-specific code yet; interpolation, transforms and loop filter are plain loops (the loop
filter is still the largest cost). `cargo run --release -p filmcraft-vp9 --example vp9dec --
in.ivf [out.yuv]` decodes an IVF file (`VP9_THREADS=n` selects the thread count) and prints the
statistics.
## Known gaps
- No frame-level threading: parallelism is tile columns + loop filter wavefront, so streams with
a single tile column (narrower than 512 pixels or encoded without tile columns) decode mostly on
one thread.
- Scaled references combined with compound prediction are not covered by a fixture (both are
verified separately; libvpx through ffmpeg cannot change size mid-stream and the synthetic
splice cannot keep the compound streams' probability contexts intact).
- Frames larger than 16384x16384 are rejected (`Error::Invalid`) to bound memory.
- Damaged streams are decoded as far as the data allows (the boolean decoder reads zeros past the
end, as specified); there is no concealment beyond that, and inter frames whose reference slots
are empty return `Error::MissingReference`.
+2 -1
View File
@@ -7,7 +7,8 @@ fn main() {
let args: Vec<String> = std::env::args().collect();
let data = std::fs::read(&args[1]).expect("read input");
let mut out = args.get(2).map(|p| std::io::BufWriter::new(std::fs::File::create(p).expect("create output")));
let mut dec = filmcraft_vp9::Decoder::new();
let threads = std::env::var("VP9_THREADS").ok().and_then(|s| s.parse().ok()).unwrap_or(0);
let mut dec = if threads > 0 { filmcraft_vp9::Decoder::with_threads(threads) } else { filmcraft_vp9::Decoder::new() };
let hl = u16::from_le_bytes([data[6], data[7]]) as usize;
let mut p = hl;
let mut i = 0i64;
+44 -56
View File
@@ -148,20 +148,22 @@ fn filter_sb_plane(v: &mut PlaneView, mi: &MiGrid, f: &LfFrame, plane: usize, pa
}
/// Sample filtering process (8.8.5) at `pos` (the q0 sample), samples across the edge `step`
/// apart.
/// apart. The samples are gathered into `v` (`v[8 + k]` = sample at offset k) and only modified
/// samples are written back.
#[allow(clippy::too_many_arguments)]
#[inline]
fn filter_sample(d: &mut [u16], pos: isize, step: isize, limit: i32, blimit: i32, thresh: i32, filter_size: u8, bit_depth: u8) {
let at = |k: isize| (pos + k * step) as usize;
let s = |d: &[u16], k: isize| d[at(k)] as i32;
let q0 = s(d, 0);
let q1 = s(d, 1);
let q2 = s(d, 2);
let q3 = s(d, 3);
let p0 = s(d, -1);
let p1 = s(d, -2);
let p2 = s(d, -3);
let p3 = s(d, -4);
let reach: usize = if filter_size == TX_16X16 { 8 } else { 4 };
let step = step as usize;
let start = pos as usize - reach * step;
let mut v = [0i32; 16];
{
let span = &d[start..start + (2 * reach - 1) * step + 1];
for k in 0..2 * reach {
v[8 - reach + k] = span[k * step] as i32;
}
}
let (p3, p2, p1, p0, q0, q1, q2, q3) = (v[4], v[5], v[6], v[7], v[8], v[9], v[10], v[11]);
let shift = bit_depth as u32 - 8;
// Filter mask process (8.8.5.1).
let limit_bd = limit << shift;
@@ -186,71 +188,57 @@ fn filter_sample(d: &mut [u16], pos: isize, step: isize, limit: i32, blimit: i32
&& (q2 - q0).abs() <= one
&& (p3 - p0).abs() <= one
&& (q3 - q0).abs() <= one;
if filter_size == TX_4X4 || !flat {
narrow_filter(d, pos, step, hev, bit_depth, p1, p0, q0, q1);
return;
}
let flat2 = filter_size >= TX_16X16 && {
let mut ok = true;
for k in 4..8 {
if (s(d, -(k + 1)) - p0).abs() > one || (s(d, k) - q0).abs() > one {
ok = false;
break;
}
}
ok
};
if filter_size == TX_8X8 || !flat2 {
wide_filter(d, pos, step, 3);
let (lo, hi) = if filter_size == TX_4X4 || !flat {
narrow_filter(&mut v, hev, bit_depth);
(6, 10)
} else {
wide_filter(d, pos, step, 4);
let flat2 = filter_size >= TX_16X16 && (0..4).all(|k| (v[3 - k] - p0).abs() <= one && (v[12 + k] - q0).abs() <= one);
if flat2 {
wide_filter(&mut v, 4);
(1, 15)
} else {
wide_filter(&mut v, 3);
(5, 11)
}
};
for k in lo..hi {
d[(pos + (k as isize - 8) * step as isize) as usize] = v[k] as u16;
}
}
/// Narrow filter process (8.8.5.2).
#[allow(clippy::too_many_arguments)]
/// Narrow filter process (8.8.5.2) on gathered samples.
#[inline]
fn narrow_filter(d: &mut [u16], pos: isize, step: isize, hev: bool, bit_depth: u8, p1: i32, p0: i32, q0: i32, q1: i32) {
fn narrow_filter(v: &mut [i32; 16], hev: bool, bit_depth: u8) {
let shift = bit_depth as u32 - 8;
let lo = -(1 << (bit_depth - 1));
let hi = (1 << (bit_depth - 1)) - 1;
let c = |v: i32| v.clamp(lo, hi);
let c = |x: i32| x.clamp(lo, hi);
let off = 0x80 << shift;
let (ps1, ps0, qs0, qs1) = (p1 - off, p0 - off, q0 - off, q1 - off);
let (ps1, ps0, qs0, qs1) = (v[6] - off, v[7] - off, v[8] - off, v[9] - off);
let mut filter = if hev { c(ps1 - qs1) } else { 0 };
filter = c(filter + 3 * (qs0 - ps0));
let filter1 = c(filter + 4) >> 3;
let filter2 = c(filter + 3) >> 3;
let at = |k: isize| (pos + k * step) as usize;
d[at(0)] = (c(qs0 - filter1) + off) as u16;
d[at(-1)] = (c(ps0 + filter2) + off) as u16;
v[8] = c(qs0 - filter1) + off;
v[7] = c(ps0 + filter2) + off;
if !hev {
let f = (filter1 + 1) >> 1;
d[at(1)] = (c(qs1 - f) + off) as u16;
d[at(-2)] = (c(ps1 + f) + off) as u16;
v[9] = c(qs1 - f) + off;
v[6] = c(ps1 + f) + off;
}
}
/// Wide filter process (8.8.5.3) with 2^log2 taps.
/// Wide filter process (8.8.5.3) with 2^log2 taps on gathered samples; the sum over j of the
/// specification is computed as a sliding window over the clamped indices (identical results).
#[inline]
fn wide_filter(d: &mut [u16], pos: isize, step: isize, log2: u32) {
fn wide_filter(v: &mut [i32; 16], log2: u32) {
let n = (1isize << (log2 - 1)) - 1;
let at = |k: isize| (pos + k * step) as usize;
let mut src = [0i32; 16];
// src[k + 8] = sample at offset k, k in -8..8.
for k in -(n + 1)..=n {
src[(k + 8) as usize] = d[at(k)] as i32;
}
let mut f = [0i32; 16];
let src = *v;
let s = |k: isize| src[(k.clamp(-(n + 1), n) + 8) as usize];
let mut sum: i32 = (-n..=n).map(|j| s(-n + j)).sum();
let round = 1i32 << (log2 - 1);
for i in -n..n {
let mut t = src[(i + 8) as usize];
for j in -n..=n {
let p = (i + j).clamp(-(n + 1), n);
t += src[(p + 8) as usize];
}
f[(i + 8) as usize] = (t + (1 << (log2 - 1))) >> log2;
}
for i in -n..n {
d[at(i)] = f[(i + 8) as usize] as u16;
v[(i + 8) as usize] = (sum + s(i) + round) >> log2;
sum += s(i + 1 + n) - s(i - n);
}
}
+47
View File
@@ -0,0 +1,47 @@
//! Decode speed (ignored by default; run with
//! `cargo test --release -p filmcraft-vp9 --test perf -- --ignored --nocapture`).
mod common;
use filmcraft_vp9::Decoder;
use std::time::Instant;
fn bench(frames: &[&[u8]], threads: usize, iters: usize) -> (usize, f64) {
let mut best = f64::MAX;
let mut n = 0;
for _ in 0..iters {
let mut dec = Decoder::with_threads(threads);
let t0 = Instant::now();
n = 0;
for f in frames {
n += dec.decode(f, 0).expect("decode").len();
}
best = best.min(t0.elapsed().as_secs_f64());
}
(n, best)
}
fn run(name: &str, label: &str) {
let f = common::fixture(name);
let Some((ivf, _)) = common::ensure(f) else { return };
common::check_fixture(name).unwrap();
let data = std::fs::read(ivf).unwrap();
let frames = common::ivf_frames(&data);
let cores = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(1);
for threads in [1, cores.min(16)] {
let (n, secs) = bench(&frames, threads, 3);
println!("{label} ({} KB): {threads} thread(s): {n} frames in {secs:.3}s = {:.1} fps", data.len() / 1024, n as f64 / secs);
}
}
#[test]
#[ignore]
fn perf_1080p() {
run("bench_1080p", "1080p 8-bit, 4 tile columns");
}
#[test]
#[ignore]
fn perf_4k() {
run("bench_4k", "2160p 8-bit, 8 tile columns");
}