mirror of
https://github.com/storytold/filmcraft.git
synced 2026-11-11 02:40:01 -06:00
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:
co-authored by
Claude Opus 5.5
parent
804eb14fa9
commit
5e9d7d904e
@@ -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`.
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
Reference in New Issue
Block a user