M4.10: before / after results in docs/performance.md, crate READMEs and ROADMAP

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-03 10:51:57 -04:00
parent a0735c98f6
commit 32f4471e7b
6 changed files with 241 additions and 49 deletions

View File

@ -81,6 +81,7 @@ Status: ✅ done · 🟡 in progress · ⬜ not started. Estimates are remaining
- **2026-10-03 (later):** graphics & captions (M10.7): `.fcgt` graphics templates (export/install/apply/edit, 8 original built-ins; Adobe .mogrt refused), Essential Graphics Browse/Edit, rolls/crawls, responsive design pins and time, per-character styles, Upgrade Caption to Graphic / to Source Graphic, MCC/EBU STL/TTML/DFXP, Replace Fonts; schema v12. 1479 tests.
- **2026-10-03:** 4K H.264 playback (M4.9): vectorised deblocking, branch-free CABAC bins and column-parallel inverse transforms cut H.264 decoding by 23 % (cycles per frame, bit-exact; 4K cold seeks −27 % CPU); opt-in draft decoding (Settings ▸ Playback) skips deblocking of non-reference pictures and uploads decimated planes while playing at 1/2–1/4, never for paused frames or exports; conformance runs 1, 3 and all threads. 4K plays 192/0 at load 100–140 (before: only at load ~90).
- **2026-10-03:** 4K VP9 / HEVC / AV1 playback (M4.10): VP9 frame threading (references published per superblock row, loop filter overlapping the next frames) plus a vectorised loop filter, column-parallel transforms and 16-bit MC cut VP9 work by 25 % single-threaded and 44 % with all cores (4K decode 8 → 27–34 fps at load ~200); HEVC −28 % (vectorised SAO, MC without per-block zeroing, batched bypass bins), AV1 −20 % (fixed-width MC, recycled planes); opt-in draft decoding for all three; bit-exact at 1, 3 and all threads. 4K VP9 and HEVC now play 192/0 at load ~200 (before: 0–58 of 192 shown).
- **2026-10-03:** keyboard parity (M3.12: 143 new commands on Premiere's default keys, table validated against the registry), audio (M7.8: 5.1 tracks/submixes/Mix, 5.1 panner, BS.775 downmix, 5.1 WAV/MOV/AAC export, voice-over record with punch-in, Remix, Clip Mixer Latch/Touch/Write). 1432 tests.

View File

@ -58,7 +58,8 @@ and `b10-23-film_grain-50` are **bit-exact** in the default run. The ignored
with scaled inter-layer prediction), `24-monochrome` (8 and 10-bit) and
`16-intra_only-intrabc-extreme-dv` (1080p intra block copy), `23-film_grain-50` (8 and 10-bit).
`tests/oracle_inter.rs` encodes SVT-AV1 GOPs (hierarchical references, compound prediction,
`tests/oracle_inter.rs` also checks draft mode: every unflagged frame stays bit-exact with
libdav1d and only flagged frames differ, with 1 and 4 threads. `tests/oracle_inter.rs` encodes SVT-AV1 GOPs (hierarchical references, compound prediction,
OBMC / warped motion, motion-field projection, all loop filters; presets 3–8, 8- and 10-bit, odd
sizes), plus super-resolution (denominator 12) and film-grain streams, and compares every frame
with libdav1d: **bit-exact** on all 6 fixtures.
@ -91,6 +92,36 @@ noisy; single-threaded figures use thread CPU time and are comparable.
| `perf_1080p_gop_hq` (crf 18) | 19.5 fps | 19.4 fps | 2.4 fps | 3.6 fps |
| `perf_1080p_gop_tiles` (4x2 tiles) | 44.4 fps | 44.7 fps | 2.7 fps | 16.3 fps |
### M4.10 (2026-10-03)
CPU cycles per frame counted by the kernel (`/usr/bin/time -l`, all threads summed; mean of two
interleaved base / after rounds at load average 190-320) with
`AV1_THREADS=n cargo run --release -p filmcraft-av1 --example av1dec -- in.ivf` on the
`cargo xtask bench` decode clips (SVT-AV1 preset 10, CRF 35, keyint 48, testsrc2 + grain),
bit-exact with libdav1d:
| stream | threads | Mcycles / frame before | after | after, draft mode |
|---|---|---|---|---|
| 1080p (120 frames) | 1 | 60.5 | **48.3** (−20 %) | 46.2 |
| 1080p | 14 | 63.6 | **51.9** (−18 %) | 50.3 |
| 2160p (72 frames) | 1 | 212.5 | **172.4** (−19 %) | 167.9 |
| 2160p | 14 | 220.0 | **177.1** (−20 %) | 171.1 |
What changed: the sub-pixel filter passes run over fixed block widths (taps outer, columns
inner); frame and tile-region planes come from a bounded pool of recycled buffers (zeroed on
reuse) instead of a fresh `calloc` and `munmap` per frame (allocation, page faults and zeroing
were ~10 % of the time); the palette mode-info arrays (40 bytes per 4x4 unit) are only
allocated when screen content tools are allowed; CDF adaptation is branch-free (a branch-free
symbol search was tried and was slower: most symbols have 2-4 values). Single-threaded 4K now
splits into coefficient parsing ~20 %, inter prediction ~18 %, other block-level parsing
~12 %, deblocking ~11 % (still one edge at a time), inverse transforms ~12 %, CDEF ~5 %.
Draft mode (`Decoder::set_draft(true)`, `AV1_DRAFT=1`; reduced-resolution playback only): shown
frames whose `refresh_frame_flags` is 0 — no reference slot keeps them, so neither their samples,
motion vectors nor CDFs reach another frame — skip deblocking, CDEF and loop restoration
(super-resolution still runs, it sets the output size) and are flagged `Picture::draft`. In
SVT-AV1's hierarchical GOPs that is the top layer, half of the frames.
## API
```rust

View File

@ -29,6 +29,7 @@ and fixture generators.
| Scaling (flat and scaling lists), DCT 4..32, DST 4x4, transform skip, sign data hiding | done |
| Deblocking (bS, slice / tile boundary rules, PCM / bypass exemption), SAO (band / edge, boundary rules) | done |
| Frame-level multithreading (CTB-row granularity dependencies) | done |
| Draft mode (non-reference pictures skip deblocking / SAO; reduced-resolution playback only) | done |
| Tiles / WPP | parsed and decoded sequentially within a picture (parallelism is across pictures) |
| 4:2:2 / 4:4:4 / monochrome, range-extension tools (RExt), 12-bit+ with extended precision | not yet (`Error::Unsupported`) |
| SCC, multilayer (SHVC / MV-HEVC), 3D extensions | not supported (layers > 0 are ignored) |
@ -48,6 +49,7 @@ for (pts, access_unit) in access_units {
}
for pic in dec.flush() { /* ... */ }
if let Some(err) = dec.take_error() { /* error reported by a decoding thread */ }
// dec.set_draft(true): draft mode (playback at reduced resolution only)
```
`decode` expects whole access units (a buffer with several complete access units — e.g. an entire
@ -65,6 +67,13 @@ into the shared `Frame` (`OnceLock` per CTB row). Motion compensation of later p
row on exactly the reference rows it reads. Samples are stored as `u16` internally for all bit
depths.
Draft mode (`Decoder::set_draft(true)`; FilmCraft enables it with Settings ▸ Playback ▸ Draft
decoding while the Program monitor plays at 1/2 or 1/4): sub-layer non-reference pictures
(TRAIL_N, RASL_N, ...) of the highest temporal sub-layer — no later picture predicts from them,
and TMVP reads only reference pictures — skip deblocking and SAO and are flagged
`Picture::draft`; every other picture is unchanged. libx265's non-reference B pictures are such
pictures.
Modules: `params` (VPS/SPS/PPS/tile layout), `slice` (NAL + slice header), `dpb` (POC, RPS, lists,
bumping), `cabac`, `slicedec` (CTU syntax + reconstruction), `mvpred` (merge / AMVP / TMVP),
`intra`, `inter`, `transform`, `filter` (deblocking, SAO, row publication).
@ -85,10 +94,13 @@ with ffmpeg; tests print a message and skip when ffmpeg is absent).
ffmpeg.
- Robustness: hvcC input with pts round trip; randomly corrupted, truncated and garbage input never
panics or deadlocks (single- and multi-threaded).
- Conformance (`tests/conformance.rs`): every fixture is decoded single-threaded and frame-threaded
and compared sample-exactly with `ffmpeg -f rawvideo -pix_fmt yuv420p / yuv420p10le`; on mismatch
- Conformance (`tests/conformance.rs`): every fixture is decoded with 1 thread, 3 threads and every
core and compared sample-exactly with `ffmpeg -f rawvideo -pix_fmt yuv420p / yuv420p10le`; on mismatch
the first differing frame, plane, sample, CTB and 8x8 block are reported. Output pts/POC order is
checked too. `compare_detects_mismatch` guards the harness itself.
checked too, and that no picture is flagged draft without draft mode. `compare_detects_mismatch`
guards the harness itself. Draft mode (`draft_mode_changes_only_flagged_non_reference_pictures`):
on B-pyramid / weighted fixtures, single- and frame-threaded, every unflagged picture is
bit-exact and only flagged pictures differ.
### Fixture matrix (all bit-exact, single- and multi-threaded)
@ -135,7 +147,7 @@ average 50-100), so these are lower bounds:
| 1080p Main, x265 medium CRF 24, 60 frames | ~75 fps | ~225 fps |
| 2160p Main 10, x265 fast CRF 26, 20 frames | ~20 fps | ~95 fps |
No SIMD-specific code yet; interpolation, transforms and loop filters are plain loops. M4.8
No intrinsics: the hot loops are written to auto-vectorise. M4.8
profiling (`cargo xtask bench`, macOS `sample`) found the inverse transform at over half of the
decoder's CPU time: it evaluated the full matrix product with strided table reads. It now sums
contiguous basis rows scaled by the non-zero coefficients only, with the block size a compile-time
@ -144,7 +156,30 @@ against the direct product on random sparse and dense blocks). SAO edge offset h
loop for samples whose neighbours are inside the CTB. Together: ~60 % less CPU per frame
(1080p 78 → 33 ms, 2160p 320 → 129 ms of CPU per frame through the media stack, alternating A/B), see
[docs/performance.md](../../docs/performance.md).
`cargo run --release -p filmcraft-hevc --example hevcdec -- in.hevc out.yuv` decodes a file.
`cargo run --release -p filmcraft-hevc --example hevcdec -- in.hevc out.yuv` decodes a file
(`HEVC_THREADS=n`, `HEVC_LOOPS=k`, `HEVC_DRAFT=1`).
M4.10 (2026-10-03), CPU cycles per frame counted by the kernel (`/usr/bin/time -l`, all threads
summed; mean of two interleaved base / after rounds at load average 190-320) on the
`cargo xtask bench` decode clips (x265 preset fast, CRF 22, testsrc2 + grain), bit-exact:
| stream | threads | Mcycles / frame before | after | after, draft mode |
|---|---|---|---|---|
| 1080p (120 frames) | 1 | 90.9 | **67.3** (−26 %) | 61.2 |
| 1080p | 14 | 100.2 | **75.1** (−25 %) | 67.3 |
| 2160p (72 frames) | 1 | 362.6 | **262.3** (−28 %) | 232.5 |
| 2160p | 14 | 398.3 | **284.8** (−28 %) | 260.1 |
What changed: motion compensation zeroed two 10 KB windows per prediction block per component
and list (11 % of the time in `memset`) and filtered with dynamic-width scalar sums; it now keeps
reusable scratch windows and runs taps outer / outputs inner over fixed block widths. SAO
looked its offset up per sample in a table (20 % of the time); the edge / band offset is now
selected per lane from the neighbour-sign sum / band index, so the runs vectorise. Uni / bi
output clips with min / max instead of `clamp` (whose bound assert kept it scalar). Runs of
bypass bins (signs, Rice suffixes, SAO / intra fields) decode with one division, and
context-coded bins are branch-free. Single-threaded 4K now splits into CABAC residual parsing
~28 %, reference window copies ~11 %, interpolation ~11 %, deblocking + SAO + row publication
~15 %, inverse transform ~8 %.
## Known gaps

View File

@ -27,8 +27,8 @@ is used only as an external fixture generator and test oracle.
| 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 |
| Frame threading: references published per superblock row, the loop filter / output of a frame overlaps the next frames | done |
| Draft mode (non-reference frames skip the loop filter; reduced-resolution playback only) | done |
## API
@ -45,7 +45,8 @@ for (pts, chunk) in chunks { // one IVF / WebM block / MP4 sample each (frame o
// pic.color { color_space (0..7 as coded), full_range }
}
}
let _ = dec.flush(); // VP9 has no reordering: always empty, kept for API symmetry
let rest = dec.flush(); // pictures still in flight (frame threads)
// dec.set_draft(true): draft mode (playback at reduced resolution only), dec.threads()
dec.reset(); // forget references / contexts (seek), keeps the thread pool
let stats = dec.stats(); // what the stream exercised (frame kinds, tiles, compound, scaling...)
@ -60,33 +61,60 @@ let info: Option<filmcraft_vp9::KeyframeInfo> = filmcraft_vp9::keyframe_info(chu
(e.g. an MP4 without `stss`) still seek correctly.
A superframe produces every shown frame it contains (normally one); hidden frames produce none.
With frame threads (`Decoder::new()`, or `with_threads(n)` with n > 1) a picture can come out of
a later `decode` call or `flush` (each carries the `pts` of the chunk that showed it);
`with_threads(1)` returns every picture from the call that completes it.
### 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.
(tile rows of a column are decoded in order: they share above contexts). Counts are then merged
for backward adaptation, the mode info is assembled (the next frame reads it for motion vector
prediction and segment ids) and the frame goes into the reference slots at once, while its
**post stage** runs on a thread of its own: it assembles each superblock row ("band") from the
strips, loop filters it superblock by superblock in raster order (so the result is the
specification's), publishes the band above it once that can no longer change (`OnceLock` per
band; the 16-wide filter of row r reads 8 and changes 7 rows of row r - 1, which travel in 8
padding rows on top of each band) and copies it into the output picture. Motion compensation of
later frames reads reference rows through `Frame::row` / `Frame::span`, which wait for exactly
the bands a block reaches, so the next frames' tile columns decode while earlier frames are still
being filtered. Probability adaptation needs only the counts, so even backward-adaptive streams
start the next frame before the loop filter is done; frame-parallel / error-resilient streams
(no backward adaptation) never wait at all. Results never depend on scheduling: the conformance
suite decodes every fixture with 1, 3 and all threads. Samples are `u16` internally for every bit
depth.
Draft mode (`Decoder::set_draft(true)`; FilmCraft enables it with Settings ▸ Playback ▸ Draft
decoding while the Program monitor plays at 1/2 or 1/4): frames with `refresh_frame_flags` 0 —
no reference slot receives them, so nothing predicts from their samples — skip the loop filter
and are flagged `Picture::draft`. The loop filter changes neither mode info, segmentation map nor
probabilities, so every other frame is unchanged. Such frames occur in temporally layered streams
(e.g. libvpx `-ts-parameters`); single-layer realtime streams refresh LAST on every frame.
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`.
inter prediction calls, reconstruction), `intra`, `inter`, `transform`, `loopfilter`, `frame`
(banded frames, pools), `decoder` (scheduling, post stage).
## 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.
output with 1 thread, 3 threads (frame threading with a small pool) and every core; the first
mismatch reports frame, plane, position and superblock. No picture may be flagged draft without
draft mode.
- 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.
against floating-point bases, DC-only shortcut vs. full transform, the column-parallel 2D
transform against the per-column formulation on random blocks of every size and type, scaled
MC path vs. unscaled (through a banded reference frame), superframe index, `inv_remap_prob`,
probability merging.
- Draft mode (`draft_mode_changes_only_flagged_non_reference_frames`): on a two-layer temporal
scalability stream, single- and frame-threaded, every unflagged frame is bit-exact and only
flagged frames differ.
- 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.
@ -126,47 +154,55 @@ Backward adaptation is on (`-frame-parallel 0`) unless noted.
| 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 |
| temporal_layers | 352x288 | realtime, 2 temporal layers (`-ts-parameters`): non-reference frames |
## Performance
`cargo test --release -p filmcraft-vp9 --test perf -- --ignored --nocapture` (bit-exactness is
verified first), or `VP9_THREADS=n VP9_LOOPS=k cargo run --release -p filmcraft-vp9 --example
vp9dec -- in.ivf` (decodes the file k times and prints the statistics).
vp9dec -- in.ivf` (decodes the file k times and prints the statistics; `VP9_DRAFT=1` for draft
mode).
Apple M4 Pro (14 cores), 1080p 8-bit `bench_1080p` fixture (libvpx speed 4, 4 MB/s, 4 tile
columns), 120 frames per run, before / after binaries alternated. The machine was shared with
other builds (load average 80-110 during the multi-thread runs, up to 200 earlier), so
wall-clock numbers are the best of 3 runs and still well below what an idle machine gives;
single-thread CPU time is the stable figure.
M4.10 (2026-10-03), Apple M4 Pro (14 cores) shared with other builds (load average 190-320), so
the figures are CPU cycles per frame counted by the kernel (`/usr/bin/time -l`, all threads
summed; mean of two interleaved base / after rounds) — wall clock only within a round. Fixtures:
the `cargo xtask bench` decode clips (testsrc2 + grain, libvpx realtime speed 8, CRF 32; 1080p
4 tile columns, 2160p 8 tile columns, frame-parallel mode), bit-exact with ffmpeg.
| | before (5e9d7d9) | now |
|---|---|---|
| 1 thread, CPU time | 52-55 fps | 98-102 fps |
| 14 threads, wall clock (best of 3, load ~90) | 82-89 fps | 143-145 fps |
| stream | threads | Mcycles / frame before | after | wall ms / frame before → after (same load) |
|---|---|---|---|---|
| 1080p (120 frames) | 1 | 68.0 | **52.0** (−24 %) | |
| 1080p | 14 | 100.7 | **57.9** (−43 %) | 95, 82 → 18, 17 |
| 2160p (72 frames) | 1 | 273.6 | **205.8** (−25 %) | |
| 2160p | 14 | 391.7 | **219.2** (−44 %) | 146, 118 → 38, 36 |
What made the difference:
Before M4.10 the frame-level work was serial (tile columns of one frame, then a loop-filter
wavefront, then assembly and output conversion), so 14 threads spent 40-50 % more cycles than one
thread (spinning between short parallel phases) and a 4K frame still took ~120 ms of wall time
at this load; now the post stage overlaps the next frames and 4K decodes at ~27 fps on the
loaded machine. What changed:
- Loop filter: each group of 8 samples along an edge is filtered as 8 lanes at once (16-bit lanes
for 8 / 10-bit, 32-bit for 12-bit); the mask / flat / flat2 decisions are lane-wise and every
filter result is selected per lane, so the arithmetic vectorizes. Per-group decisions use masks
instead of divisions and skip groups with no edge before looking up levels. The parallel loop
filter (superblock wavefront) locks per superblock-row band only while copying, instead of
holding one frame-wide lock.
- Motion compensation: the 8-tap filters run tap-outer over fixed-width rows (4..64) and no longer
zero a 10 KB edge window per block; the 2D inverse transform buffers are sized per transform.
- Memory: strip / frame planes and mode-info grids are recycled through a pool (no per-frame
multi-megabyte allocations and page faults); assembling tile strips into the frame and the
cropping / 8-bit narrowing of output pictures run in parallel chunks of rows.
- **Frame threading** (see Architecture).
- **Loop filter** (was ~50 % of single-threaded time): the lane masks were `bool`s with
`clamp` and branchy selects, which kept the "vectorised" group filter scalar; masks are now
0 / -1 integers with bitwise selects and min / max clipping, so it compiles to NEON.
- **Inverse transforms**: the column pass runs the generated butterflies
(`transform_gen::cols4` / `cols8`) on 4 / 8 columns at once.
- **Motion compensation**: 8-bit sub-pixel filters accumulate in 16-bit lanes (8 per vector
instead of 4): every sum of the VP9 filters plus rounding lies in [-13706, 46474], so the sum
modulo 2^16 identifies it (negative sums clip to 0).
- Bool decoder: branch-free update.
The loop filter is still the largest single cost (~30% single-threaded), followed by inter
prediction and coefficient decoding.
Single-threaded 4K time now splits into coefficient tokens (bool decoding) ~27 %, loop filter
~25 % (two thirds of it the vertical-edge transposes and per-group decisions), motion
compensation ~15 %, inverse transforms ~8 %, intra prediction ~6 %.
## Known gaps
- No frame-level threading: parallelism is tile columns + loop filter wavefront + row-parallel
copies, so streams with a single tile column (narrower than 512 pixels or encoded without tile
columns) decode mostly on one thread, and 1080p (at most 4 tile columns) uses 4 threads for
coefficient decoding and prediction.
- A frame's tile decoding is still a barrier: the next frame starts once every tile column of
the current one is done (its post stage then runs alongside), so streams with a single tile
column decode their tiles on one thread at a time; the post stage (loop filter) of one frame
runs 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).

View File

@ -13,6 +13,94 @@ the before/after comparison relies on. Each milestone below alternated base / af
each); the most load-independent figure is the decoder's own cycle count (`proc_pid_rusage`
instructions and cycles of a process, all threads summed).
## Results (M4.10: 4K VP9, AV1 and HEVC, before → after)
Baseline = commit `a814ff6` (decoders unchanged since M4.8); after = M4.10. Interleaved runs on
2026-10-03 at load average 170-320 (two base and two after rounds; the second bench round of
each overlapped another bench run, so its wall-clock columns are extra pessimistic).
### Decoder work per frame (load-independent)
Codec examples (`vp9dec`, `av1dec`, `hevcdec`) on the `cargo xtask bench` decode clips, CPU
cycles counted by the kernel (`/usr/bin/time -l`, all threads summed), mean of the two rounds;
output bit-exact with ffmpeg / libdav1d at every thread count:
| stream | 1 thread before → after | 14 threads before → after | wall ms / frame, 14 threads, before → after |
|---|---|---|---|
| VP9 1080p | 68.0 → **52.0** (−24 %) | 100.7 → **57.9** (−43 %) | 95, 82 → 18, 17 |
| VP9 2160p | 273.6 → **205.8** (−25 %) | 391.7 → **219.2** (−44 %) | 146, 118 → 38, 36 |
| HEVC 1080p | 90.9 → **67.3** (−26 %) | 100.2 → **75.1** (−25 %) | 17, 10 → 5, 13 |
| HEVC 2160p | 362.6 → **262.3** (−28 %) | 398.3 → **284.8** (−28 %) | 24, 55 → 26, 25 |
| AV1 1080p | 60.5 → **48.3** (−20 %) | 63.6 → **51.9** (−18 %) | 26, 8 → 8, 7 |
| AV1 2160p | 212.5 → **172.4** (−19 %) | 220.0 → **177.1** (−20 %) | 158, 39 → 20, 32 |
Mcycles per frame. Draft mode (1 thread): HEVC 2160p 232.5 (−11 % on top), AV1 2160p 167.9
(−3 %: SVT-AV1's top-layer frames filter little); the VP9 clips have no non-reference frames.
### Decode (every frame through the media stack)
| codec | size | CPU ms/frame before (2 rounds) | after (2 rounds) | fps before → after |
|---|---|---|---|---|
| VP9 | 1080p | 33.6, 30.8 | **23.5, 21.7** | 14, 17 → 67, 56 |
| VP9 | 2160p | 120.2, 117.7 | **68.4, 67.9** | 9.1, 7.8 → **34, 27** |
| HEVC | 1080p | 31.2, 29.3 | **23.9, 22.4** | 50, 58 → 138, 145 |
| HEVC | 2160p | 117.9, 111.8 | **86.9, 82.1** | 13, 14 → **30, 41** |
| AV1 | 1080p | 18.5, 18.9 | **15.1, 15.7** | 53, 44 → 122, 69 |
| AV1 | 2160p | 64.8, 66.7 | **48.4, 51.3** | 16, 8.9 → **25, 25** |
### Program-monitor playback, 2160p (8 s, GPU path; new `vp9-2160` / `av1-2160` scenarios)
| case | before: shown/dropped (4 runs) | after (4 runs) | CPU ms/frame before → after |
|---|---|---|---|
| VP9 Full | 6/186, 53/139, 0/192, 0/192 | **192/0, 192/0, 189/3, 192/0** | 23-49 → 75-83 |
| VP9 1/2 | 6/186, 58/134, 2/190, 52/140 | **192/0, 191/1, 192/0, 192/0** | 34-70 → 78-80 |
| HEVC Full | 6/186, 1/191, 0/192, 9/183 | **192/0, 192/0, 192/0, 192/0** | 74-124 → 91-97 |
| HEVC 1/2 | 0/192, 9/183, 0/192, 19/173 | **192/0, 192/0, 192/0, 185/7** | 64-118 → 94-97 |
| HEVC 1/2 draft | 5/187, 88/104, 97/95, 138/54 | **188/4, 192/0, 187/5, 189/3** | 135-148 → 96-104 |
| AV1 Full | 4/188, 19/173, 0/192, 36/156 | 52/140, 0/192, **178/14**, 0/192 | 31-47 → 38-56 |
| AV1 1/2 | 32/160, 44/148, 1/191, 38/154 | 2/190, **150/42, 184/8**, 50/142 | 30-50 → 41-72 |
| AV1 1/2 draft | 1/191, 24/168, 1/191, 27/165 | **192/0, 187/5, 184/8, 192/0** | 25-51 → 68-70 |
CPU per *displayed* frame rises where the decoder now keeps up: before, most frames were never
decoded (dropped jobs cost nothing). At load ~180-220, 4K VP9 and HEVC now play in real time at
Full and 1/2; AV1 does at 1/2 with draft decoding and sometimes at 1/2 / Full: its frames
decode concurrently only once their references are complete (whole-frame dependencies) and the
clip has a single tile, so a 4K frame waits for its references' deblocking, CDEF and output
copy.
## What M4.10 changed
1. **VP9 frame threading** (the decoder had none: tile columns, then a loop-filter wavefront,
then assembly and output, each a barrier — 14 threads used 40-50 % more cycles than one and
barely ran faster). Frames are now published one superblock row at a time once loop
filtered; each frame's post stage (band assembly from the tile strips, loop filter in raster
order, publication, output picture) runs on its own thread while the next frames' tile
columns decode, their motion compensation waiting per band for exactly the reference rows it
reads. Backward-adaptive streams only wait for the previous frame's symbol counts.
2. **VP9 kernels**: the loop filter's lane masks were `bool`s with `clamp` and branchy selects,
which kept it scalar; 0 / -1 integer masks, bitwise selects and min / max make it NEON. The
inverse transform's column pass runs the generated butterflies on 4 / 8 columns at once;
8-bit sub-pixel filters accumulate in 16-bit lanes (every VP9 filter sum fits modulo 2^16).
3. **HEVC**: motion compensation zeroed two 10 KB windows per block (11 % of the time in
`memset`) and filtered with dynamic-width scalar sums; it now reuses scratch windows and
filters taps-outer over fixed widths. SAO (20 % of the time) selects edge / band offsets per
lane instead of a table lookup per sample. Runs of bypass bins decode with one division;
output clipping uses min / max. Frame threading with per-CTB-row waits was already in place.
4. **AV1**: fixed-width sub-pixel filter passes; frame and tile-region planes recycled through a
bounded pool instead of a fresh `calloc` / `munmap` per frame (~10 % of the time); palette
mode-info arrays (40 bytes per 4x4) only with screen content tools; branch-free CDF update.
5. **Draft mode** for all three through `VideoDecoder::set_draft` (Settings ▸ Playback ▸ Draft
decoding, reduced-resolution playback only): VP9 frames that refresh no reference slot skip
the loop filter; HEVC sub-layer non-reference pictures of the top sub-layer skip deblocking
and SAO; AV1 shown frames that refresh no slot skip deblocking, CDEF and loop restoration.
Tested per codec (unflagged pictures bit-exact, single- and multi-threaded) and through the
media stack (`crates/codecs/tests/draft_decoders.rs`: draft frames reach draft requests only;
an export after draft playback is bit-exact with ffmpeg).
Not done: AV1 row-level frame threading (references are waited for as whole frames) and its
per-edge deblocking; VP9 and HEVC coefficient / CABAC parsing are now the largest single costs
(~27 %); HEVC reference windows are still copied per block (~11 %).
## Results (M4.9: 4K H.264, before → after)
Baseline = commit `3d14099` (M3.12, unchanged decoder); after = M4.9. Interleaved runs on

View File

@ -239,8 +239,9 @@ Options: `--sections decode,scrub`, `--only <substring>` (fixture or scenario),
`--section NAME --json FILE` (one section in-process, e.g. under a profiler). Fixtures are made
with ffmpeg in `target/fixtures/playback/` (or `$FILMCRAFT_FIXTURES/playback`). The suite's
`playback` section plays `h264-1080` and `stack3` at Full, `h264-2160` at Full, 1/2 and 1/4 and with
draft decoding (Settings ▸ Playback ▸ Draft decoding) at 1/2 and 1/4, and `hevc-2160` at Full and
1/2; its `draft` column counts the frames decoded in draft mode.
draft decoding (Settings ▸ Playback ▸ Draft decoding) at 1/2 and 1/4, and `hevc-2160`, `vp9-2160`
and `av1-2160` at Full, 1/2 and 1/2 draft; its `draft` column counts the frames decoded in draft
mode.
The perf tests check bit-exactness before they time anything. Results go in the crate README's
performance table, with machine and thread count. Headline numbers go in