Skip to main content

zvidlib_hevc_decoder/
decode_bench.rs

1//! Prepared per-stage workloads for the HEVC decoder benchmark.
2//!
3//! `crates/zvidlib-av1/benches/codec.rs` is an external crate and the HEVC engine
4//! (`crate::engine`) is crate-private, so a benchmark cannot reach the
5//! individual decode stages the way it reaches the public AV1 kernels. This
6//! module is the narrow surface that closes that gap: one prepared workload per
7//! hot stage, each with its input construction in [`HevcStageInputs::new`] and
8//! only the kernel under test inside the `run_*` method.
9//!
10//! Every `run_*` method returns the eight bytes of an FNV-1a fold over all of
11//! the samples the stage produced. The bytes exist for the benchmark harness's
12//! bit-exactness guard, which compares one instruction set's arm against the
13//! scalar arm; folding rather than returning the samples themselves keeps a
14//! multi-megabyte allocation out of the timed loop, and the fold still depends
15//! on every output sample, so a backend that diverged anywhere changes it.
16//!
17//! None of these methods pin an instruction set. They call the same entry
18//! points the decoder calls, which resolve through
19//! [`crate::simd::set_override`], so the caller selects the arm exactly as it
20//! does for a whole-frame decode.
21
22use super::engine::deblock::{EdgePos, EdgeQp, EdgeType, SamplePlane, filter_luma_block_edge};
23use super::engine::inter_pred::{
24    RefPlane, default_weighted_pred, interp_chroma_block, interp_luma_block,
25};
26use super::engine::intra_pred::{
27    Component as IntraComponent, IntraPredParams, ReferenceSamples, intra_predict,
28};
29use super::engine::picture::{Picture, Plane};
30use super::engine::sao::{
31    ResolvedSao, ResolvedSaoComponent, SaoBoundaries, apply_sao_picture_with_boundaries,
32};
33use super::engine::transform::{BlockParams, Component as TxComponent, PredMode, residual_block};
34use super::engine::{BitReader, CabacEngine, ContextModel};
35use super::{HevcDecoder, ParsedConfiguration, picture_to_rgba};
36use crate::{
37    CancellationToken, Codec, CodecProfile, ColorRange, EncodedVideoSample, HardwarePreference,
38    Limits, PixelFormat, VideoDecoder, VideoDecoderConfig, VideoDimensions,
39};
40
41/// The largest inter prediction unit, and the cell the §8.5.3.3 workload's
42/// prediction-unit schedule is laid out on: every cell is filled by prediction
43/// units of one class, so a cell is either one 64x64 unit or the 4, 16 or 64
44/// smaller ones that tile it.
45const INTER_CELL: usize = 64;
46
47/// The §8.5.3.3 prediction-unit mix a real decode runs, as issue #280 measured
48/// it: 48 frames of `examples/media/BigBuckBunny.mp4` at 1920x1080, every
49/// prediction unit the decoder reconstructed, weighted by luma sample.
50///
51/// Each entry is `(luma side, bi-predicted, share of luma samples)`. The
52/// workload this drives replaced a uniform grid of 16x16 bi-predicted
53/// *luma-only* blocks, which that measurement showed was not what the stage
54/// runs on real content in any of the three ways that matter to a vector
55/// kernel:
56///
57/// * **Size.** 62% of predicted luma samples are in 64x64 units and 31% in
58///   32x32; 16x16 is 5% and 8x8 is 1.4%. The old grid's block was the third
59///   most common size by sample and carried 5% of the real work.
60/// * **List utilization.** 38% of predicted luma samples are uni-predicted,
61///   which runs one interpolation rather than two and takes the
62///   §8.5.3.3.4.2 single-list combine rather than the two-list one. The old
63///   grid was bi-predicted throughout.
64/// * **Component.** At 4:2:0 every luma sample brings half a chroma sample,
65///   through the §8.5.3.3.3.3 4-tap filter rather than the 8-tap one — a
66///   third of the stage's samples, on a kernel that measures materially lower
67///   than the luma one. The old grid predicted no chroma at all.
68///
69/// The clip codes 2Nx2N prediction units throughout, so every size here is
70/// square; a stream using the §7.3.8.5 asymmetric partitions would add
71/// rectangular units, and this table would be re-measured rather than guessed.
72const INTER_PU_MIX: [(usize, bool, f64); 8] = [
73    (64, true, 0.432),
74    (64, false, 0.189),
75    (32, true, 0.162),
76    (32, false, 0.149),
77    (16, false, 0.036),
78    (16, true, 0.017),
79    (8, false, 0.009),
80    (8, true, 0.005),
81];
82/// The §7.3.8.3 SAO parameter mix a real decode runs, as issue #310 measured
83/// it: 48 frames of `examples/media/BigBuckBunny.mp4` at 1920x1080, all 26,520
84/// coding tree blocks the decoder resolved parameters for, counted per color
85/// component.
86///
87/// Row 0 is luma, row 1 chroma — Cb and Cr came out identical CTB for CTB, as
88/// §7.3.8.3 signals one `sao_type_idx_chroma` and one `SaoEoClass` for the
89/// pair. Each row is the share of CTBs taking, in order: no SAO at all
90/// (`SaoTypeIdx == 0`), band offset, and the four edge-offset classes
91/// (0-degree, 90-degree, 135-degree, 45-degree).
92///
93/// The workload this drives replaced a grid that put band or edge offset on
94/// *every* CTB of *every* component, which is not what the stage runs on real
95/// content in the way that matters most to it: **SAO is off on 86.7% of luma
96/// CTBs and 94.4% of chroma CTBs**, and an off CTB costs nothing at all. The
97/// old grid therefore filtered about eight times the samples a 1080p frame
98/// actually puts through the classifiers, so its ratio was the kernels'
99/// throughput on a saturated picture rather than their worth to a decode.
100const SAO_CTB_MIX: [[f64; 6]; 2] = [
101    // off, band, EO 0-deg, EO 90-deg, EO 135-deg, EO 45-deg
102    [0.866780, 0.020362, 0.020777, 0.030656, 0.026697, 0.034729],
103    [0.944155, 0.009540, 0.001357, 0.004450, 0.015875, 0.024623],
104];
105/// The CTB side the SAO stage is driven at, as `log2`.
106const SAO_CTB_LOG2: u32 = 6;
107/// Bit depth every stage runs at; the bundled sample is 8-bit Main.
108const BIT_DEPTH: u8 = 8;
109/// Transform-block sides the inverse-transform stage covers.
110const TX_SIZES: [usize; 4] = [4, 8, 16, 32];
111/// CABAC bins one iteration of the entropy stage decodes.
112const CABAC_BINS: usize = 1 << 18;
113
114/// One scheduled §8.5.3.3 prediction unit of the inter-prediction workload.
115#[derive(Debug, Clone, Copy)]
116struct InterPu {
117    /// Top-left luma position, in samples.
118    x: i32,
119    y: i32,
120    /// Luma side; the unit is square, as every unit the measured clip codes is.
121    n: usize,
122    /// `predFlagL1` — whether this unit runs two interpolations and the
123    /// two-list §8.5.3.3.4.2 combine, or one and the single-list one.
124    bi: bool,
125    /// Which fractional phase pair this unit filters at, so the schedule
126    /// sweeps the one- and two-dimensional filter paths and the copy path.
127    phase: i32,
128}
129
130/// Lay out one frame's worth of prediction units matching [`INTER_PU_MIX`].
131///
132/// The frame is walked in [`INTER_CELL`]-sized cells, and each cell is filled
133/// entirely by units of one class. The class is chosen greedily — whichever is
134/// furthest below its target share of luma samples so far — so the emitted mix
135/// converges on the measured one exactly rather than approximately, and does so
136/// deterministically, which is what the benchmark's bit-exactness guard needs.
137///
138/// A margin of one cell is left on the right and bottom edges because the
139/// workload offsets its second reference list by one sample; every unit and
140/// both of its lists therefore lie inside the plane.
141fn schedule_inter_pus(width: usize, height: usize) -> Vec<InterPu> {
142    let cells_x = (width / INTER_CELL).saturating_sub(1);
143    let cells_y = (height / INTER_CELL).saturating_sub(1);
144    let mut emitted = [0u64; INTER_PU_MIX.len()];
145    let mut total = 0u64;
146    let mut pus = Vec::new();
147    let mut phase = 0i32;
148    for cy in 0..cells_y {
149        for cx in 0..cells_x {
150            // Furthest below target, measured as a sample deficit so the very
151            // first cell (where every share is zero) still picks the largest.
152            let class = (0..INTER_PU_MIX.len())
153                .max_by(|&a, &b| {
154                    let deficit = |i: usize| {
155                        INTER_PU_MIX[i]
156                            .2
157                            .mul_add(total as f64, -(emitted[i] as f64))
158                    };
159                    deficit(a)
160                        .partial_cmp(&deficit(b))
161                        .expect("the shares are finite")
162                })
163                .expect("the mix is not empty");
164            let (n, bi, _) = INTER_PU_MIX[class];
165            for by in (0..INTER_CELL).step_by(n) {
166                for bx in (0..INTER_CELL).step_by(n) {
167                    pus.push(InterPu {
168                        x: (cx * INTER_CELL + bx) as i32,
169                        y: (cy * INTER_CELL + by) as i32,
170                        n,
171                        bi,
172                        phase,
173                    });
174                    phase = (phase + 1) % 16;
175                }
176            }
177            let cell_samples = (INTER_CELL * INTER_CELL) as u64;
178            emitted[class] += cell_samples;
179            total += cell_samples;
180        }
181    }
182    pus
183}
184
185/// An FNV-1a fold, so a stage's whole output identifies it in eight bytes.
186struct Digest(u64);
187
188impl Digest {
189    fn new() -> Self {
190        Digest(0xcbf2_9ce4_8422_2325)
191    }
192
193    fn push_i32(&mut self, value: i32) {
194        for byte in value.to_le_bytes() {
195            self.0 ^= u64::from(byte);
196            self.0 = self.0.wrapping_mul(0x1000_0000_01b3);
197        }
198    }
199
200    fn push_all(&mut self, values: &[i32]) {
201        for &value in values {
202            self.push_i32(value);
203        }
204    }
205
206    /// Folds `value` in one step.
207    fn push_u64(&mut self, value: u64) {
208        self.0 = (self.0 ^ value).wrapping_mul(0x1000_0000_01b3);
209    }
210
211    /// Folds a whole plane of decoded samples, two at a time.
212    ///
213    /// [`push_all`] costs a multiply per *byte*, which is nothing next to a
214    /// per-stage workload of a few thousand samples but is real work next to a
215    /// whole-frame group that folds several million per iteration, inside the
216    /// timed loop. This is the wide step for those; every sample still reaches
217    /// the accumulator, which is what the harness's bit-exactness guard needs.
218    ///
219    /// [`push_all`]: Self::push_all
220    fn push_samples(&mut self, values: &[i32]) {
221        let mut chunks = values.chunks_exact(2);
222        for pair in &mut chunks {
223            self.push_u64(u64::from(pair[0] as u32) | (u64::from(pair[1] as u32) << 32));
224        }
225        for &value in chunks.remainder() {
226            self.push_u64(u64::from(value as u32));
227        }
228    }
229
230    /// Folds a byte buffer eight bytes at a time, for the same reason as
231    /// [`push_samples`].
232    ///
233    /// [`push_samples`]: Self::push_samples
234    fn push_bytes(&mut self, bytes: &[u8]) {
235        let mut chunks = bytes.chunks_exact(8);
236        for chunk in &mut chunks {
237            self.push_u64(u64::from_le_bytes(
238                chunk.try_into().expect("the chunk is eight bytes"),
239            ));
240        }
241        for &byte in chunks.remainder() {
242            self.push_u64(u64::from(byte));
243        }
244    }
245
246    fn finish(self) -> Vec<u8> {
247        self.0.to_le_bytes().to_vec()
248    }
249}
250
251/// A deterministic, dependency-free generator for benchmark content.
252///
253/// Reproducibility is the point: the bit-exactness guard runs the same workload
254/// once per instruction set and compares the results, so the inputs have to be
255/// identical across those runs and across processes.
256struct Lcg(u64);
257
258impl Lcg {
259    fn new(seed: u64) -> Self {
260        Lcg(seed | 1)
261    }
262
263    fn next_u32(&mut self) -> u32 {
264        self.0 = self
265            .0
266            .wrapping_mul(6_364_136_223_846_793_005)
267            .wrapping_add(1_442_695_040_888_963_407);
268        (self.0 >> 33) as u32
269    }
270
271    fn below(&mut self, bound: u32) -> u32 {
272        self.next_u32() % bound
273    }
274}
275
276/// Synthetic 8-bit luma content mixing textured and flat regions.
277///
278/// The flat regions are what make this usable for deblocking: the §8.7.2.5.3
279/// wide-filter decision is gated on a flatness check across the edge, so a
280/// purely textured plane never reaches the strong filter and would measure only
281/// half the kernel. Every other stage sees a mixture, which is what real content
282/// looks like.
283fn synthetic_luma(width: usize, height: usize, seed: u64) -> Vec<i32> {
284    let mut rng = Lcg::new(seed);
285    let mut plane = vec![0i32; width * height];
286    for y in 0..height {
287        // Alternating 32-row bands: textured, then flat with a small step every
288        // 8 columns so the deblocking edges have something to smooth.
289        let flat_band = (y / 32) % 2 == 1;
290        for x in 0..width {
291            let value = if flat_band {
292                let level = 40 + 12 * ((x / 8) % 5) as i32;
293                level + i32::from((x % 8) == 0)
294            } else {
295                let base = ((x * 3 + y * 5) % 200) as i32;
296                base + (rng.below(24) as i32) - 12
297            };
298            plane[y * width + x] = value.clamp(0, 255);
299        }
300    }
301    plane
302}
303
304/// Prepared inputs for every HEVC stage the benchmark times.
305///
306/// Construction is the setup half — plane allocation, content generation,
307/// reference-sample and coefficient-block construction, CABAC bitstream
308/// synthesis — and never appears inside a timed loop.
309pub struct HevcStageInputs {
310    width: usize,
311    height: usize,
312    luma: Vec<i32>,
313    intra_blocks: Vec<(ReferenceSamples, IntraPredParams)>,
314    intra_samples: u64,
315    tx_blocks: Vec<(Vec<i32>, BlockParams)>,
316    tx_samples: u64,
317    /// A full 8-bit 4:2:0 picture of synthetic content, driven by both the
318    /// §8.7.3 SAO stage and the output-conversion stage — the second needs
319    /// exactly what the first already builds, a whole decoded picture.
320    picture: Picture,
321    sao_ctbs: Vec<ResolvedSao>,
322    /// The single-slice, single-tile §8.7.3.2 boundary grids the stage is
323    /// driven with. A decode always has these — whether a stream is
324    /// single-slice and single-tile is not known before it is parsed — so
325    /// running the stage without them (as this group did before issue #310)
326    /// measures a dispatch the decoder never takes.
327    sao_boundaries: SaoBoundaries,
328    /// Samples the SAO classifiers actually run over per run, luma plus
329    /// chroma: [`SAO_CTB_MIX`] leaves most CTBs with `SaoTypeIdx == 0`, and
330    /// those cost nothing.
331    sao_filtered_samples: u64,
332    /// The §8.5.3.3 prediction units one run of the stage reconstructs, in
333    /// raster order, built to [`INTER_PU_MIX`]. Scheduling them is setup, so
334    /// it happens here rather than inside the timed loop.
335    inter_pus: Vec<InterPu>,
336    inter_luma_samples: u64,
337    convert_config: VideoDecoderConfig,
338    cabac_bytes: Vec<u8>,
339}
340
341impl HevcStageInputs {
342    /// Builds every stage's inputs for a `width` x `height` luma frame.
343    ///
344    /// # Panics
345    /// Panics if the dimensions are not a multiple of the 64-sample CTB the SAO
346    /// stage is driven at, or if either is zero.
347    #[must_use]
348    pub fn new(width: usize, height: usize) -> Self {
349        assert!(width > 0 && height > 0, "the frame has to have samples");
350        let luma = synthetic_luma(width, height, 0x5EED_1234);
351        let intra_blocks = build_intra_blocks();
352        let intra_samples = intra_blocks
353            .iter()
354            .map(|(p, _)| (p.n_tbs() * p.n_tbs()) as u64)
355            .sum();
356        let tx_blocks = build_tx_blocks();
357        let tx_samples = tx_blocks.iter().map(|(l, _)| l.len() as u64).sum();
358        let (picture, sao_ctbs) = build_sao_inputs(width, height, &luma);
359        let (sao_boundaries, sao_filtered_samples) = build_sao_boundaries(width, height, &sao_ctbs);
360        let inter_pus = schedule_inter_pus(width, height);
361        let inter_luma_samples = inter_pus.iter().map(|pu| (pu.n * pu.n) as u64).sum();
362        Self {
363            width,
364            height,
365            luma,
366            intra_blocks,
367            intra_samples,
368            tx_blocks,
369            tx_samples,
370            picture,
371            sao_ctbs,
372            sao_boundaries,
373            sao_filtered_samples,
374            inter_pus,
375            inter_luma_samples,
376            convert_config: convert_config(width, height),
377            cabac_bytes: build_cabac_bitstream(),
378        }
379    }
380
381    /// Luma samples the inter-prediction stage writes per run.
382    ///
383    /// Luma only, so the figure stays comparable with the one this group
384    /// reported before it predicted chroma; each of these samples brings half
385    /// a chroma sample of §8.5.3.3.3.3 work with it at 4:2:0.
386    #[must_use]
387    pub fn inter_pred_samples(&self) -> u64 {
388        self.inter_luma_samples
389    }
390
391    /// Samples the intra-prediction stage writes per run.
392    #[must_use]
393    pub fn intra_pred_samples(&self) -> u64 {
394        self.intra_samples
395    }
396
397    /// Luma samples the deblocking stage rewrites per run.
398    #[must_use]
399    pub fn deblock_samples(&self) -> u64 {
400        (self.luma_edges().len() * 4 * 8) as u64
401    }
402
403    /// Samples the SAO stage classifies per run, luma plus chroma.
404    ///
405    /// Counts only the CTBs `SAO_CTB_MIX` leaves switched on, since a CTB
406    /// with `SaoTypeIdx == 0` is returned from before it reads a sample. On the
407    /// measured mix that is a small minority of the picture, which is the
408    /// point: the throughput this divides is the classifiers', not the frame's.
409    #[must_use]
410    pub fn sao_samples(&self) -> u64 {
411        self.sao_filtered_samples
412    }
413
414    /// Residual samples the inverse-transform stage produces per run.
415    #[must_use]
416    pub fn inverse_transform_samples(&self) -> u64 {
417        self.tx_samples
418    }
419
420    /// Samples the output-conversion stage writes per run, one RGBA pixel each.
421    #[must_use]
422    pub fn color_convert_samples(&self) -> u64 {
423        (self.width * self.height) as u64
424    }
425
426    /// Bins the entropy stage decodes per run.
427    #[must_use]
428    pub fn cabac_bins(&self) -> u64 {
429        CABAC_BINS as u64
430    }
431
432    /// §8.5.3.3 — fractional-sample interpolation and the weighted combine,
433    /// over the prediction-unit mix a real decode runs.
434    ///
435    /// Each scheduled unit interpolates its luma through the §8.5.3.3.3.2
436    /// 8-tap filter and, at 4:2:0, its Cb and Cr through the §8.5.3.3.3.3
437    /// 4-tap filter, once per used reference list, and combines the result
438    /// through the §8.5.3.3.4.2 default weighted prediction. Every one of
439    /// those is a vectorized `filter_taps` / `combine_weighted` primitive.
440    ///
441    /// The unit sizes, the uni/bi split and the presence of chroma all come
442    /// from `INTER_PU_MIX`, which is measured rather than chosen; see that
443    /// constant for what the uniform 16x16 bi-predicted luma-only grid this
444    /// replaced was getting wrong.
445    ///
446    /// # Panics
447    /// Panics if the prepared planes no longer match their dimensions.
448    #[must_use]
449    pub fn run_inter_pred(&self) -> Vec<u8> {
450        let luma = RefPlane::new(&self.luma, self.width, self.height)
451            .expect("the prepared plane matches its dimensions");
452        let (cw, ch) = (self.width / 2, self.height / 2);
453        let cb = RefPlane::new(self.picture.plane(Plane::Cb), cw, ch)
454            .expect("the prepared picture's Cb plane matches its dimensions");
455        let cr = RefPlane::new(self.picture.plane(Plane::Cr), cw, ch)
456            .expect("the prepared picture's Cr plane matches its dimensions");
457        let mut digest = Digest::new();
458        for pu in &self.inter_pus {
459            let n = pu.n;
460            // Sweep the fractional phases so both the one-dimensional and the
461            // two-dimensional filter paths run, and so a unit that lands on a
462            // full-sample position (the copy path) is reached too.
463            let (fx0, fy0) = (pu.phase % 4, (pu.phase / 4) % 4);
464            let (fx1, fy1) = ((pu.phase + 1) % 4, (pu.phase + 3) % 4);
465            let l0 = interp_luma_block(&luma, pu.x, pu.y, fx0, fy0, n, n, BIT_DEPTH)
466                .expect("the unit lies inside the plane");
467            let l1 = if pu.bi {
468                interp_luma_block(&luma, pu.x + 1, pu.y + 1, fx1, fy1, n, n, BIT_DEPTH)
469                    .expect("the unit lies inside the plane")
470            } else {
471                Vec::new()
472            };
473            let combined = default_weighted_pred(&l0, &l1, true, pu.bi, n, n, BIT_DEPTH)
474                .expect("both lists are the unit's size");
475            digest.push_all(&combined);
476
477            // §8.5.3.3.3.3: the chroma units are half the luma unit's side at
478            // 4:2:0, at eighth-sample phases rather than quarter-sample ones.
479            let (cn, cx, cy) = (n / 2, pu.x / 2, pu.y / 2);
480            let (cfx0, cfy0) = (pu.phase % 8, (pu.phase / 8) % 8);
481            let (cfx1, cfy1) = ((pu.phase + 3) % 8, (pu.phase + 5) % 8);
482            for plane in [&cb, &cr] {
483                let c0 = interp_chroma_block(plane, cx, cy, cfx0, cfy0, cn, cn, BIT_DEPTH)
484                    .expect("the unit lies inside the plane");
485                let c1 = if pu.bi {
486                    interp_chroma_block(plane, cx + 1, cy + 1, cfx1, cfy1, cn, cn, BIT_DEPTH)
487                        .expect("the unit lies inside the plane")
488                } else {
489                    Vec::new()
490                };
491                let combined = default_weighted_pred(&c0, &c1, true, pu.bi, cn, cn, BIT_DEPTH)
492                    .expect("both lists are the unit's size");
493                digest.push_all(&combined);
494            }
495        }
496        digest.finish()
497    }
498
499    /// §8.4.4.2 — reference-sample smoothing and the planar / DC / angular
500    /// predictors, over every mode at every transform-block size.
501    #[must_use]
502    pub fn run_intra_pred(&self) -> Vec<u8> {
503        let mut digest = Digest::new();
504        for (samples, params) in &self.intra_blocks {
505            let pred = intra_predict(samples, params).expect("the prepared block is well-formed");
506            digest.push_all(&pred);
507        }
508        digest.finish()
509    }
510
511    /// §8.7.2.5.4 — the luma block-edge deblocking filter over a whole frame's
512    /// worth of vertical edge segments.
513    ///
514    /// `bS == 2` and a high QP put the §8.7.2.5.3 thresholds where the strong
515    /// filter is reachable; the plane's flat bands are what actually reach it.
516    #[must_use]
517    pub fn run_deblock(&self) -> Vec<u8> {
518        let mut samples = self.luma.clone();
519        let mut plane = SamplePlane {
520            samples: &mut samples,
521            width: self.width,
522            stride: self.width,
523        };
524        let qp = EdgeQp {
525            qp_q: 37,
526            qp_p: 37,
527            beta_offset_div2: 0,
528            tc_offset_div2: 0,
529            bit_depth: BIT_DEPTH,
530        };
531        for pos in self.luma_edges() {
532            filter_luma_block_edge(&mut plane, pos, 2, qp);
533        }
534        let mut digest = Digest::new();
535        digest.push_all(&samples);
536        digest.finish()
537    }
538
539    /// §8.7.3 — sample adaptive offset over every CTB of a full picture, with
540    /// both the band-offset and the edge-offset classifiers in the grid.
541    #[must_use]
542    pub fn run_sao(&self) -> Vec<u8> {
543        let filtered = apply_sao_picture_with_boundaries(
544            self.picture.clone(),
545            &self.sao_ctbs,
546            SAO_CTB_LOG2,
547            1,
548            true,
549            true,
550            Some(&self.sao_boundaries),
551        );
552        // Fold only the CTBs the mix switches on, which is what the stage
553        // produced: a `SaoTypeIdx == 0` CTB is returned from before it reads a
554        // sample, so its samples are the input's and folding them would be
555        // folding the picture rather than the stage. It would also cost several
556        // times what the stage does — [`SAO_CTB_MIX`] leaves most of a picture
557        // switched off — and criterion would be timing this fold.
558        let ctb = 1usize << SAO_CTB_LOG2;
559        let ctbs_x = self.width.div_ceil(ctb);
560        let mut digest = Digest::new();
561        for (i, resolved) in self.sao_ctbs.iter().enumerate() {
562            let (rx, ry) = (i % ctbs_x, i / ctbs_x);
563            for (cidx, plane) in [Plane::Luma, Plane::Cb, Plane::Cr].into_iter().enumerate() {
564                if resolved.components[cidx].sao_type_idx == 0 {
565                    continue;
566                }
567                let step = if cidx == 0 { ctb } else { ctb / 2 };
568                let (pw, ph) = filtered.plane_dims(plane);
569                let (x0, y0) = (rx * step, ry * step);
570                let (w, h) = (step.min(pw - x0), step.min(ph - y0));
571                let samples = filtered.plane(plane);
572                for j in 0..h {
573                    let o = (y0 + j) * pw + x0;
574                    digest.push_samples(&samples[o..o + w]);
575                }
576            }
577        }
578        digest.finish()
579    }
580
581    /// §8.6.2 / §8.6.3 / §8.6.4 — dequantization and the inverse transform, at
582    /// every transform-block size and for both the DCT and the 4x4 intra DST.
583    #[must_use]
584    pub fn run_inverse_transform(&self) -> Vec<u8> {
585        let mut digest = Digest::new();
586        for (levels, params) in &self.tx_blocks {
587            let residual =
588                residual_block(levels, None, *params).expect("the prepared block is well-formed");
589            digest.push_all(&residual);
590        }
591        digest.finish()
592    }
593
594    /// The YUV420-to-RGBA output conversion every whole-frame decode ends in.
595    ///
596    /// Issue #220: this stage is not decoding, but it was a third of what the
597    /// whole-frame groups measure (see `benches/README.md`), so it is timed
598    /// directly rather than inferred by subtracting one whole-frame group from
599    /// another. Issue #219 gave it the [`color_convert`] kernel family, so its
600    /// arms now separate by instruction set like the decoding stages do, and
601    /// this group is what reports that.
602    ///
603    /// # Panics
604    /// Panics if the prepared picture no longer converts.
605    ///
606    /// [`color_convert`]: super::color_convert
607    #[must_use]
608    pub fn run_color_convert(&self) -> Vec<u8> {
609        let frame = picture_to_rgba(&self.picture, &self.convert_config, &Limits::default())
610            .expect("the prepared picture is Main-profile 8-bit 4:2:0");
611        let mut digest = Digest::new();
612        for plane in &frame.planes {
613            digest.push_bytes(&plane.data);
614        }
615        digest.finish()
616    }
617
618    /// §9.3.4 — the CABAC arithmetic decoder's bin loop.
619    ///
620    /// This stage has no vector path and is not expected to grow one: the
621    /// arithmetic decoder is inherently serial, each bin's range update
622    /// depending on the previous one's. It is measured because it is the serial
623    /// fraction that bounds what vectorizing everything else can buy — the
624    /// residual parser in `engine::residual` spends essentially all of its time
625    /// in these two calls, so their throughput is the Amdahl ceiling for the
626    /// whole-frame arms.
627    ///
628    /// The mixture is roughly the context-coded / bypass split of residual
629    /// parsing: three context-coded decisions per bypass bin.
630    #[must_use]
631    pub fn run_cabac(&self) -> Vec<u8> {
632        let mut contexts: Vec<ContextModel> = (0..64)
633            .map(|i| ContextModel::init(90 + (i as u8 % 60), 30))
634            .collect();
635        let mut engine =
636            CabacEngine::new(BitReader::new(&self.cabac_bytes)).expect("the buffer has bits");
637        let mut digest = Digest::new();
638        let mut acc = 0i32;
639        let context_count = contexts.len();
640        for i in 0..CABAC_BINS {
641            let bin = if i % 4 == 3 {
642                engine.decode_bypass()
643            } else {
644                engine.decode_decision(&mut contexts[i % context_count])
645            };
646            match bin {
647                Ok(bin) => acc = acc.wrapping_mul(3).wrapping_add(i32::from(bin)),
648                // A fixed buffer decoded to exhaustion stops here; the bin count
649                // reached is the same on every arm because the input is fixed.
650                Err(_) => break,
651            }
652            digest.push_i32(acc);
653        }
654        digest.finish()
655    }
656
657    /// Inter-prediction block grid, leaving a block of margin on each axis so
658    /// the `+1` L1 offset stays addressable.
659    /// Every vertical luma edge segment of the frame, on the §8.7.2.4 grid:
660    /// 8 samples apart across the edge, 4 rows apart along it.
661    fn luma_edges(&self) -> Vec<EdgePos> {
662        let mut edges = Vec::new();
663        let mut ey = 0;
664        while ey + 4 <= self.height {
665            let mut ex = 8;
666            while ex + 8 <= self.width {
667                edges.push(EdgePos {
668                    ex,
669                    ey,
670                    edge: EdgeType::Vertical,
671                });
672                ex += 8;
673            }
674            ey += 4;
675        }
676        edges
677    }
678}
679
680/// One reference array and parameter set per (mode, size) pair, repeated so a
681/// run covers a frame-scale number of samples rather than a handful of blocks.
682fn build_intra_blocks() -> Vec<(ReferenceSamples, IntraPredParams)> {
683    const REPEATS: usize = 24;
684    let mut rng = Lcg::new(0x17AB_5EED);
685    let mut blocks = Vec::new();
686    for _ in 0..REPEATS {
687        for n_tbs in TX_SIZES {
688            let want = 2 * n_tbs;
689            let left: Vec<i32> = (0..want).map(|_| rng.below(256) as i32).collect();
690            let top: Vec<i32> = (0..want).map(|_| rng.below(256) as i32).collect();
691            let corner = rng.below(256) as i32;
692            let samples = ReferenceSamples::new(n_tbs, corner, left, top)
693                .expect("the arrays are 2*nTbS long and nTbS is legal");
694            for mode in 0..=34u8 {
695                blocks.push((
696                    samples.clone(),
697                    IntraPredParams {
698                        pred_mode_intra: mode,
699                        cidx: IntraComponent::Luma,
700                        bit_depth: BIT_DEPTH,
701                        bit_depth_luma: BIT_DEPTH,
702                        intra_smoothing_disabled: false,
703                        strong_intra_smoothing_enabled: true,
704                        chroma_array_type_3: false,
705                        disable_boundary_filter: false,
706                    },
707                ));
708            }
709        }
710    }
711    blocks
712}
713
714/// Coefficient blocks at every transform size, sparse the way real residual is:
715/// energy concentrated in the low-frequency corner, most positions zero.
716fn build_tx_blocks() -> Vec<(Vec<i32>, BlockParams)> {
717    const BLOCKS_PER_SIZE: usize = 512;
718    let mut rng = Lcg::new(0x7A_5F0);
719    let mut blocks = Vec::new();
720    for n_tbs in TX_SIZES {
721        for i in 0..BLOCKS_PER_SIZE {
722            let mut levels = vec![0i32; n_tbs * n_tbs];
723            // A low-frequency sub-block of non-zero levels, as a coded
724            // sub-block group would leave.
725            let coded = (n_tbs / 2).max(4);
726            for y in 0..coded {
727                for x in 0..coded {
728                    let magnitude = (rng.below(64) as i32) - 32;
729                    levels[y * n_tbs + x] = magnitude;
730                }
731            }
732            let pred_mode = if i % 2 == 0 {
733                PredMode::Intra
734            } else {
735                PredMode::Inter
736            };
737            blocks.push((
738                levels,
739                BlockParams {
740                    n_tbs,
741                    q_p: 26 + (i as u32 % 12),
742                    component: TxComponent::Luma,
743                    pred_mode,
744                    bit_depth: BIT_DEPTH,
745                    extended_precision: false,
746                    transquant_bypass: false,
747                    transform_skip: false,
748                    transform_skip_rotation_enabled: false,
749                },
750            ));
751        }
752    }
753    blocks
754}
755
756/// Pick the class furthest below its target share of the CTBs emitted so far.
757///
758/// Greedy on the sample deficit, as [`schedule_inter_pus`] is, so the emitted
759/// mix converges on the measured one exactly rather than approximately and does
760/// so deterministically — which is what the benchmark's bit-exactness guard
761/// needs.
762fn sao_class_for(shares: &[f64; 6], emitted: &[u64; 6], total: u64) -> usize {
763    (0..6)
764        .max_by(|&a, &b| {
765            let deficit = |i: usize| shares[i].mul_add(total as f64, -(emitted[i] as f64));
766            deficit(a)
767                .partial_cmp(&deficit(b))
768                .expect("the shares are finite")
769        })
770        .expect("the mix is not empty")
771}
772
773/// The resolved SAO parameters of one [`SAO_CTB_MIX`] class.
774fn sao_component_for(class: usize, i: usize, cidx: usize) -> ResolvedSaoComponent {
775    match class {
776        0 => ResolvedSaoComponent::off(),
777        1 => ResolvedSaoComponent {
778            sao_type_idx: 1,
779            offset_val: [0, 3, -2, 1, -3],
780            band_position: ((i * 7 + cidx) % 28) as u8,
781            eo_class: 0,
782        },
783        _ => ResolvedSaoComponent {
784            sao_type_idx: 2,
785            offset_val: [0, 2, 1, -1, -2],
786            band_position: 0,
787            eo_class: (class - 2) as u8,
788        },
789    }
790}
791
792/// A full 4:2:0 picture of synthetic content plus a per-CTB SAO parameter grid
793/// built to the measured [`SAO_CTB_MIX`].
794fn build_sao_inputs(width: usize, height: usize, luma: &[i32]) -> (Picture, Vec<ResolvedSao>) {
795    let mut picture = Picture::new(width, height, 1, BIT_DEPTH, BIT_DEPTH);
796    let (y_plane, _) = picture.plane_mut(Plane::Luma);
797    y_plane.copy_from_slice(luma);
798    let chroma = synthetic_luma(width / 2, height / 2, 0xC4B0);
799    for plane in [Plane::Cb, Plane::Cr] {
800        let (samples, _) = picture.plane_mut(plane);
801        samples.copy_from_slice(&chroma);
802    }
803
804    let ctb = 1usize << SAO_CTB_LOG2;
805    let ctbs_x = width.div_ceil(ctb);
806    let ctbs_y = height.div_ceil(ctb);
807    let mut grid = Vec::with_capacity(ctbs_x * ctbs_y);
808    // One deficit accumulator per row of the mix: luma is scheduled on its own
809    // shares, and Cb / Cr share the chroma row's — and take the same class as
810    // each other, as §7.3.8.3 signals them as one.
811    let mut emitted = [[0u64; 6]; 2];
812    for i in 0..ctbs_x * ctbs_y {
813        let total = i as u64;
814        let luma_class = sao_class_for(&SAO_CTB_MIX[0], &emitted[0], total);
815        let chroma_class = sao_class_for(&SAO_CTB_MIX[1], &emitted[1], total);
816        emitted[0][luma_class] += 1;
817        emitted[1][chroma_class] += 1;
818        grid.push(ResolvedSao {
819            components: [
820                sao_component_for(luma_class, i, 0),
821                sao_component_for(chroma_class, i, 1),
822                sao_component_for(chroma_class, i, 2),
823            ],
824        });
825    }
826    (picture, grid)
827}
828
829/// The §8.7.3.2 boundary grids for a single-slice, single-tile picture, plus
830/// the number of samples the grid's switched-on CTBs classify.
831///
832/// Every CTB is in slice 0 and tile 0, so nothing here denies a neighbor read
833/// — but the decoder still carries the grids, and the per-CTB
834/// [`SaoBoundaries::ctb_neighborhood_unconstrained`] test that clears the
835/// vector path has to run over them. Passing them is what makes this group time
836/// the dispatch a decode takes.
837fn build_sao_boundaries(width: usize, height: usize, grid: &[ResolvedSao]) -> (SaoBoundaries, u64) {
838    let ctb = 1usize << SAO_CTB_LOG2;
839    let ctbs_x = width.div_ceil(ctb);
840    let ctbs_y = height.div_ceil(ctb);
841    let mut samples = 0u64;
842    for (i, resolved) in grid.iter().enumerate() {
843        let (rx, ry) = (i % ctbs_x, i / ctbs_x);
844        let w = ctb.min(width.saturating_sub(rx * ctb));
845        let h = ctb.min(height.saturating_sub(ry * ctb));
846        if resolved.components[0].sao_type_idx != 0 {
847            samples += (w * h) as u64;
848        }
849        for cidx in 1..3 {
850            if resolved.components[cidx].sao_type_idx != 0 {
851                samples += ((w / 2) * (h / 2)) as u64;
852            }
853        }
854    }
855    let n = ctbs_x * ctbs_y;
856    let boundaries = SaoBoundaries {
857        slice_addr_of_ctb: vec![0; n],
858        tile_id_of_ctb: vec![0; n],
859        pic_w_ctbs: ctbs_x,
860        ctb_log2_size_y: SAO_CTB_LOG2,
861        across_slices: true,
862        across_tiles: true,
863        filter_across_of_ctb: Some(vec![true; n]),
864        ctb_ts_of_rs: Some((0..n as u32).collect()),
865    };
866    (boundaries, samples)
867}
868
869/// A fixed pseudo-random byte buffer for the CABAC stage.
870///
871/// The arithmetic decoder's cost is in its renormalization and range updates,
872/// which depend on the bin values it produces, not on those bins being a valid
873/// slice segment. A fixed buffer makes the bin sequence reproducible, which is
874/// what the cross-instruction-set comparison needs.
875fn build_cabac_bitstream() -> Vec<u8> {
876    let mut rng = Lcg::new(0xCABA_C0DE);
877    (0..CABAC_BINS).map(|_| rng.below(256) as u8).collect()
878}
879
880/// The decoder configuration [`HevcStageInputs::run_color_convert`] converts
881/// under: the same RGBA8 limited-range output every whole-frame group asks for,
882/// at the prepared picture's dimensions.
883fn convert_config(width: usize, height: usize) -> VideoDecoderConfig {
884    let dimensions = VideoDimensions::new(width as u32, height as u32, &Limits::default())
885        .expect("the benchmark frame is within the default limits");
886    VideoDecoderConfig {
887        codec: Codec::Hevc,
888        profile: CodecProfile::HevcMain,
889        coded_dimensions: dimensions,
890        output_format: PixelFormat::Rgba8,
891        color_range: ColorRange::Limited,
892        hardware: HardwarePreference::Avoid,
893        configuration: Vec::new(),
894    }
895}
896
897/// Decodes `frames` frames of `samples` and stops at the decoded `Picture`.
898///
899/// Issue #220: the public decoder's `submit` returns RGBA, so a whole-frame
900/// benchmark through it measures decoding *plus* the color conversion of
901/// [`HevcStageInputs::run_color_convert`] — a third of the interval, which
902/// until issue #219 gave it a kernel had no vector path at all and diluted
903/// every scalar-versus-SIMD ratio taken off those groups. This is the same
904/// decode without that tail: it drives the same
905/// `HevcDecoder` over the same access units and collects its pictures instead
906/// of converting them, so the difference between this and the end-to-end group
907/// is the conversion and nothing else about how the bitstream is handled.
908///
909/// Returns a fold over every decoded sample, for the harness's bit-exactness
910/// guard — the same fold [`decode_frames`] applies to the converted bytes, so
911/// neither group pays for identifying its output more than the other does. Pins
912/// no instruction set; the caller selects the arm through
913/// [`crate::simd::set_override`] exactly as for an end-to-end decode.
914///
915/// # Panics
916/// Panics if the configuration is not decodable or if `samples` does not yield
917/// `frames` frames — a benchmark measuring less work than it reports would be
918/// worse than a failure.
919/// Decodes `frames` frames of `samples` all the way out to RGBA.
920///
921/// The end-to-end half of the issue #220 split, and the counterpart to
922/// [`decode_pictures`]: same decoder, same access units, same frame count, same
923/// output fold — the only difference is the `picture_to_rgba` pass on each
924/// decoded picture, which is what makes the gap between the two groups the
925/// conversion rather than an artifact of how each arm identifies its output.
926///
927/// # Panics
928/// Panics under the same conditions as [`decode_pictures`].
929#[must_use]
930pub fn decode_frames(
931    configuration: &VideoDecoderConfig,
932    samples: &[EncodedVideoSample],
933    limits: &Limits,
934    frames: u64,
935) -> Vec<u8> {
936    let mut decoder = bench_decoder(configuration, limits);
937    let cancellation = CancellationToken::new();
938    let mut digest = Digest::new();
939    let mut decoded = 0_u64;
940    for sample in samples {
941        for frame in decoder
942            .submit(sample, &cancellation)
943            .expect("the sample decodes")
944        {
945            for plane in &frame.frame.planes {
946                digest.push_bytes(&plane.data);
947            }
948            decoded += 1;
949        }
950        if decoded >= frames {
951            break;
952        }
953    }
954    assert_frame_count(decoded, frames);
955    digest.finish()
956}
957
958/// The software decoder the whole-frame benchmark surface drives.
959///
960/// # Panics
961/// Panics if the configuration is not decodable.
962fn bench_decoder(configuration: &VideoDecoderConfig, limits: &Limits) -> HevcDecoder {
963    let parsed = ParsedConfiguration::parse(configuration, limits)
964        .expect("the sample's configuration record parses");
965    HevcDecoder::new(configuration.clone(), *limits, parsed)
966        .expect("the software HEVC decoder is constructible")
967}
968
969/// A benchmark measuring less work than it reports would be worse than a
970/// failure.
971fn assert_frame_count(decoded: u64, frames: u64) {
972    assert!(
973        decoded >= frames,
974        "the sample yielded {decoded} decoded pictures, not the {frames} the group reports"
975    );
976}
977
978#[must_use]
979pub fn decode_pictures(
980    configuration: &VideoDecoderConfig,
981    samples: &[EncodedVideoSample],
982    limits: &Limits,
983    frames: u64,
984) -> Vec<u8> {
985    let mut decoder = bench_decoder(configuration, limits);
986    let cancellation = CancellationToken::new();
987    let mut digest = Digest::new();
988    let mut decoded = 0_u64;
989    for sample in samples {
990        for picture in decoder
991            .submit_pictures(sample, &cancellation)
992            .expect("the sample decodes")
993        {
994            for plane in [Plane::Luma, Plane::Cb, Plane::Cr] {
995                digest.push_samples(picture.plane(plane));
996            }
997            decoded += 1;
998        }
999        if decoded >= frames {
1000            break;
1001        }
1002    }
1003    assert_frame_count(decoded, frames);
1004    digest.finish()
1005}
1006
1007#[cfg(test)]
1008mod tests {
1009    use super::*;
1010    use crate::simd::{self, SimdIsa};
1011
1012    /// A frame small enough to build and run quickly, but still a whole number
1013    /// of the 64-sample CTBs the SAO stage is driven at.
1014    fn small_inputs() -> HevcStageInputs {
1015        HevcStageInputs::new(256, 128)
1016    }
1017
1018    /// The §8.5.3.3 workload has to run the prediction-unit mix issue #280
1019    /// measured, not a mix that happens to be close to it.
1020    ///
1021    /// The uniform 16x16 bi-predicted luma-only grid this replaced would fail
1022    /// every assertion here: one size instead of four, no uni-predicted units,
1023    /// and no chroma. A schedule that drifted back towards one of those — say
1024    /// by rounding every cell to the largest class — would look like a faster
1025    /// kernel rather than a narrower workload, which is the failure this
1026    /// benchmark exists to avoid.
1027    #[test]
1028    fn the_inter_pred_schedule_runs_the_measured_prediction_unit_mix() {
1029        // A frame large enough that each class gets whole cells to land in;
1030        // the greedy schedule converges on the shares as cells accumulate.
1031        let pus = schedule_inter_pus(1920, 1088);
1032        let total: u64 = pus.iter().map(|pu| (pu.n * pu.n) as u64).sum();
1033        assert!(total > 0, "the schedule emitted no prediction units");
1034
1035        for &(n, bi, share) in &INTER_PU_MIX {
1036            let got: u64 = pus
1037                .iter()
1038                .filter(|pu| pu.n == n && pu.bi == bi)
1039                .map(|pu| (pu.n * pu.n) as u64)
1040                .sum();
1041            let got = got as f64 / total as f64;
1042            assert!(
1043                (got - share).abs() < 0.01,
1044                "{n}x{n} {} is {:.1}% of luma samples, measured {:.1}%",
1045                if bi { "bi" } else { "uni" },
1046                got * 100.0,
1047                share * 100.0,
1048            );
1049        }
1050
1051        // Bi-prediction is the majority but not the whole of it, and every
1052        // measured size is present — the three things the old grid collapsed.
1053        let bi: u64 = pus
1054            .iter()
1055            .filter(|pu| pu.bi)
1056            .map(|pu| (pu.n * pu.n) as u64)
1057            .sum();
1058        let bi = bi as f64 / total as f64;
1059        assert!((0.55..0.70).contains(&bi), "bi-predicted share is {bi}");
1060        for side in [8usize, 16, 32, 64] {
1061            assert!(
1062                pus.iter().any(|pu| pu.n == side),
1063                "no {side}x{side} prediction unit was scheduled"
1064            );
1065        }
1066
1067        // And the workload predicts chroma: every unit is at least 8x8 luma,
1068        // so every unit has a 4:2:0 chroma unit of at least 4x4 to filter.
1069        assert!(
1070            pus.iter()
1071                .all(|pu| pu.n >= 8 && pu.x % 2 == 0 && pu.y % 2 == 0),
1072            "a unit has no whole 4:2:0 chroma unit"
1073        );
1074    }
1075
1076    /// Every stage must produce identical output on every instruction set the
1077    /// host can run.
1078    ///
1079    /// This is the same guarantee the benchmark's own guard checks, asserted
1080    /// here so it fails in `cargo test` rather than only when someone runs the
1081    /// benchmarks: a stage whose vector path diverged would otherwise report a
1082    /// speedup that is really just a different answer.
1083    #[test]
1084    fn every_stage_is_bit_exact_across_instruction_sets() {
1085        let _guard = simd::test_lock();
1086        let inputs = small_inputs();
1087        /// One stage's name and the entry point that runs it.
1088        type Stage = (&'static str, fn(&HevcStageInputs) -> Vec<u8>);
1089        let stages: [Stage; 7] = [
1090            ("inter_pred", HevcStageInputs::run_inter_pred),
1091            ("intra_pred", HevcStageInputs::run_intra_pred),
1092            ("deblock", HevcStageInputs::run_deblock),
1093            ("sao", HevcStageInputs::run_sao),
1094            ("inverse_transform", HevcStageInputs::run_inverse_transform),
1095            ("color_convert", HevcStageInputs::run_color_convert),
1096            ("cabac", HevcStageInputs::run_cabac),
1097        ];
1098
1099        for (name, run) in stages {
1100            simd::set_override(Some(SimdIsa::Scalar));
1101            let reference = run(&inputs);
1102            for isa in simd::available() {
1103                simd::set_override(Some(isa));
1104                assert_eq!(
1105                    run(&inputs),
1106                    reference,
1107                    "{name} on {} diverged from the scalar reference",
1108                    isa.name()
1109                );
1110            }
1111        }
1112        simd::set_override(None);
1113    }
1114
1115    /// The SAO grid has to run the §7.3.8.3 parameter mix issue #310 measured,
1116    /// not a saturated picture.
1117    ///
1118    /// The share that matters most is the one that used to be zero: on real
1119    /// content SAO is switched *off* on most CTBs, and an off CTB is returned
1120    /// from before it reads a sample. A grid drifting back towards filtering
1121    /// everything would read as a faster kernel when it is really a wider
1122    /// workload, so the emitted mix is pinned rather than trusted.
1123    #[test]
1124    fn the_sao_grid_runs_the_measured_parameter_mix() {
1125        let inputs = HevcStageInputs::new(1920, 1088);
1126        let total = inputs.sao_ctbs.len() as f64;
1127        assert!(total > 0.0, "the grid has CTBs");
1128        for (row, cidx) in [(0usize, 0usize), (1, 1), (1, 2)] {
1129            for (class, &want) in SAO_CTB_MIX[row].iter().enumerate() {
1130                let got = inputs
1131                    .sao_ctbs
1132                    .iter()
1133                    .filter(|r| {
1134                        let c = &r.components[cidx];
1135                        let seen = match c.sao_type_idx {
1136                            0 => 0,
1137                            1 => 1,
1138                            _ => 2 + usize::from(c.eo_class),
1139                        };
1140                        seen == class
1141                    })
1142                    .count() as f64
1143                    / total;
1144                assert!(
1145                    (got - want).abs() < 0.01,
1146                    "component {cidx} class {class}: {got:.4} against the measured {want:.4}"
1147                );
1148            }
1149        }
1150        // Cb and Cr are signaled as one in §7.3.8.3, so they resolve alike.
1151        assert!(
1152            inputs.sao_ctbs.iter().all(|r| r.components[1].sao_type_idx
1153                == r.components[2].sao_type_idx
1154                && r.components[1].eo_class == r.components[2].eo_class),
1155            "Cb and Cr take the same SAO type and class"
1156        );
1157        // Most of the picture is switched off, so the classifiers run over a
1158        // fraction of it rather than all of it.
1159        let all = (1920u64 * 1088) * 3 / 2;
1160        assert!(
1161            inputs.sao_samples() < all / 4,
1162            "{} filtered samples against {all} in the picture",
1163            inputs.sao_samples()
1164        );
1165    }
1166
1167    /// The digests have to depend on the stages' samples.
1168    ///
1169    /// A workload that returned a constant would pass the bit-exactness guard
1170    /// above vacuously, so check that different stages of the same inputs
1171    /// produce different digests and that none of them is the empty fold.
1172    #[test]
1173    fn stage_digests_depend_on_the_stage_output() {
1174        let _guard = simd::test_lock();
1175        simd::set_override(Some(SimdIsa::Scalar));
1176        let inputs = small_inputs();
1177        let empty = Digest::new().finish();
1178        let digests = [
1179            inputs.run_inter_pred(),
1180            inputs.run_intra_pred(),
1181            inputs.run_deblock(),
1182            inputs.run_sao(),
1183            inputs.run_inverse_transform(),
1184            inputs.run_color_convert(),
1185            inputs.run_cabac(),
1186        ];
1187        simd::set_override(None);
1188
1189        for digest in &digests {
1190            assert_eq!(digest.len(), 8, "a digest is eight bytes");
1191            assert_ne!(*digest, empty, "a stage folded nothing");
1192        }
1193        for (i, a) in digests.iter().enumerate() {
1194            for b in &digests[i + 1..] {
1195                assert_ne!(a, b, "two stages produced the same digest");
1196            }
1197        }
1198    }
1199
1200    /// The deblocking input has to actually reach the wide filter.
1201    ///
1202    /// The §8.7.2.5.3 decision takes the strong filter only where the samples
1203    /// on both sides of an edge are flat enough. Content that never satisfies
1204    /// it would leave the wide path untimed while still producing a plausible
1205    /// number, so assert the plane contains flat runs across its edges rather
1206    /// than trusting the generator.
1207    #[test]
1208    fn the_deblocking_content_contains_flat_edges() {
1209        let inputs = small_inputs();
1210        let flat_edges = inputs
1211            .luma_edges()
1212            .iter()
1213            .filter(|pos| {
1214                // The §8.7.2.5.3 dEp/dEq flatness test reads p3..p0 / q0..q3 of
1215                // the segment's first row; approximate it by requiring both
1216                // sides to span no more than a couple of levels.
1217                let row = pos.ey * inputs.width;
1218                let span = |from: usize| {
1219                    let window = &inputs.luma[row + from..row + from + 4];
1220                    window.iter().max().unwrap() - window.iter().min().unwrap()
1221                };
1222                span(pos.ex - 4) <= 2 && span(pos.ex) <= 2
1223            })
1224            .count();
1225        assert!(
1226            flat_edges > inputs.luma_edges().len() / 10,
1227            "only {flat_edges} of {} edges are flat; the wide filter would go untimed",
1228            inputs.luma_edges().len()
1229        );
1230    }
1231
1232    /// The stages' reported work has to match what they actually touch, since
1233    /// it is the denominator of every throughput number the benchmark prints.
1234    #[test]
1235    fn reported_sample_counts_match_the_workloads() {
1236        let inputs = small_inputs();
1237        // Three 64x64 cells fit a 256x128 frame once the one-cell margin the
1238        // second reference list needs is taken off each edge, and every cell
1239        // is filled by prediction units of one class whatever that class is.
1240        assert_eq!(inputs.inter_pred_samples(), 3 * 64 * 64);
1241        // Only the CTBs the measured mix leaves switched on are counted, so
1242        // this is a fraction of the 256x128 4:2:0 picture rather than all of it.
1243        assert!(
1244            inputs.sao_samples() > 0 && inputs.sao_samples() < 256 * 128 * 3 / 2,
1245            "sao_samples was {}",
1246            inputs.sao_samples()
1247        );
1248        assert_eq!(
1249            inputs.deblock_samples(),
1250            inputs.luma_edges().len() as u64 * 32
1251        );
1252        assert_eq!(inputs.cabac_bins(), CABAC_BINS as u64);
1253        assert!(inputs.intra_pred_samples() > 0);
1254        assert!(inputs.inverse_transform_samples() > 0);
1255    }
1256
1257    /// The picture-only path exists so a whole-frame benchmark can leave the
1258    /// output conversion out of the measured interval — which is only sound if
1259    /// it is otherwise the same decode. Converting what it collects has to
1260    /// reproduce, frame for frame and byte for byte, what the public
1261    /// `submit` returns.
1262    #[test]
1263    fn the_picture_only_path_decodes_what_the_rgba_path_decodes() {
1264        let (configuration, samples) = pcm_stream();
1265        let limits = Limits::default();
1266        let cancellation = CancellationToken::new();
1267
1268        let mut rgba_decoder = new_decoder(&configuration, &limits);
1269        let mut expected = Vec::new();
1270        for sample in &samples {
1271            for frame in rgba_decoder.submit(sample, &cancellation).unwrap() {
1272                expected.push(frame.frame);
1273            }
1274        }
1275        assert!(
1276            !expected.is_empty(),
1277            "the generated stream has to produce output frames for this to test anything"
1278        );
1279
1280        let mut picture_decoder = new_decoder(&configuration, &limits);
1281        let mut actual = Vec::new();
1282        for sample in &samples {
1283            for picture in picture_decoder
1284                .submit_pictures(sample, &cancellation)
1285                .unwrap()
1286            {
1287                actual.push(picture_to_rgba(&picture, &configuration, &limits).unwrap());
1288            }
1289        }
1290        assert_eq!(actual, expected);
1291    }
1292
1293    /// The digest the picture-only whole-frame group returns is what the
1294    /// harness compares across instruction sets, so it has to depend on the
1295    /// decoded samples and be reproducible.
1296    #[test]
1297    fn decode_pictures_folds_the_decoded_samples_reproducibly() {
1298        let (configuration, samples) = pcm_stream();
1299        let limits = Limits::default();
1300        let one = decode_pictures(&configuration, &samples, &limits, 1);
1301        assert_eq!(one, decode_pictures(&configuration, &samples, &limits, 1));
1302        assert_ne!(one, decode_pictures(&configuration, &samples, &limits, 2));
1303        assert_ne!(one, Digest::new().finish());
1304    }
1305
1306    /// The two whole-frame surfaces have to decode the same frames, so the
1307    /// interval between them is the conversion and not a different workload.
1308    #[test]
1309    fn the_two_whole_frame_paths_fold_the_same_number_of_frames() {
1310        let (configuration, samples) = pcm_stream();
1311        let limits = Limits::default();
1312        let frames = decode_frames(&configuration, &samples, &limits, 2);
1313        assert_eq!(frames, decode_frames(&configuration, &samples, &limits, 2));
1314        assert_ne!(
1315            frames,
1316            decode_pictures(&configuration, &samples, &limits, 2)
1317        );
1318        assert_ne!(frames, Digest::new().finish());
1319    }
1320
1321    /// The conversion stage folds a real converted frame, and folds a
1322    /// different one for different content.
1323    #[test]
1324    fn color_convert_folds_the_converted_frame() {
1325        let inputs = small_inputs();
1326        let converted = inputs.run_color_convert();
1327        assert_eq!(converted, inputs.run_color_convert());
1328        assert_ne!(converted, HevcStageInputs::new(128, 64).run_color_convert());
1329        assert_eq!(inputs.color_convert_samples(), 256 * 128);
1330    }
1331
1332    /// A short PCM-coded stream, built with the crate's own HEVC encoder so
1333    /// the decode-side tests run on a real bitstream without a fixture.
1334    fn pcm_stream() -> (VideoDecoderConfig, Vec<EncodedVideoSample>) {
1335        use crate::FrameIndex;
1336        use zvidlib_hevc_encoder::encoder::{hvcc_box, length_prefixed_vcl};
1337        use zvidlib_hevc_encoder::engine::encoder::pcm::encode_idr_pcm_au;
1338
1339        const SIDE: usize = 16;
1340        const FRAMES: u64 = 4;
1341        let mut rng = Lcg::new(0x8EEF_0220);
1342        let y: Vec<u8> = (0..SIDE * SIDE).map(|_| rng.below(256) as u8).collect();
1343        let chroma: Vec<u8> = (0..SIDE * SIDE / 4).map(|_| rng.below(256) as u8).collect();
1344        let au = encode_idr_pcm_au(&y, &chroma, &chroma, SIDE, SIDE).unwrap();
1345        let data = length_prefixed_vcl(&au).unwrap();
1346        let mut configuration = convert_config(SIDE, SIDE);
1347        configuration.configuration = hvcc_box(&au).unwrap();
1348        let samples = (0..FRAMES)
1349            .map(|index| EncodedVideoSample {
1350                presentation_index: FrameIndex(index),
1351                random_access: true,
1352                data: data.clone(),
1353            })
1354            .collect();
1355        (configuration, samples)
1356    }
1357
1358    fn new_decoder(configuration: &VideoDecoderConfig, limits: &Limits) -> HevcDecoder {
1359        let parsed = ParsedConfiguration::parse(configuration, limits).unwrap();
1360        HevcDecoder::new(configuration.clone(), *limits, parsed).unwrap()
1361    }
1362}