diff --git a/.github/workflows/hevc-conformance.yml b/.github/workflows/hevc-conformance.yml index 38f4ea510..809c11bf0 100644 --- a/.github/workflows/hevc-conformance.yml +++ b/.github/workflows/hevc-conformance.yml @@ -281,7 +281,7 @@ jobs: kernel-differential: if: github.event_name == 'workflow_dispatch' && !inputs.scalar runs-on: ${{ matrix.os }} - timeout-minutes: 60 + timeout-minutes: 90 strategy: fail-fast: false matrix: @@ -299,7 +299,7 @@ jobs: - name: Prepare bounded kernel differential targets run: | scripts/fuzz_run.sh setup - printf '%s\n' 'crates/coreav-kernels-hevc/fuzz horizontal' 'crates/coreav-kernels-hevc/fuzz vertical' 'crates/coreav-kernels-hevc/fuzz sao' 'crates/coreav-kernels-hevc/fuzz intra' 'crates/coreav-kernels-hevc/fuzz deblock' > "$RUNNER_TEMP/interpolation-plan.txt" + printf '%s\n' 'crates/coreav-kernels-hevc/fuzz horizontal' 'crates/coreav-kernels-hevc/fuzz vertical' 'crates/coreav-kernels-hevc/fuzz sao' 'crates/coreav-kernels-hevc/fuzz intra' 'crates/coreav-kernels-hevc/fuzz deblock' 'crates/coreav-kernels-hevc/fuzz combine' > "$RUNNER_TEMP/interpolation-plan.txt" scripts/fuzz_run.sh build "$RUNNER_TEMP/interpolation-plan.txt" - id: differential name: Compare automatic and scalar kernels for ten minutes per target diff --git a/crates/coreav-kernels-hevc/fuzz/Cargo.toml b/crates/coreav-kernels-hevc/fuzz/Cargo.toml index be462d27d..aea1eb60b 100644 --- a/crates/coreav-kernels-hevc/fuzz/Cargo.toml +++ b/crates/coreav-kernels-hevc/fuzz/Cargo.toml @@ -51,3 +51,10 @@ path = "fuzz_targets/deblock.rs" test = false doc = false bench = false + +[[bin]] +name = "combine" +path = "fuzz_targets/combine.rs" +test = false +doc = false +bench = false diff --git a/crates/coreav-kernels-hevc/fuzz/fuzz_targets/combine.rs b/crates/coreav-kernels-hevc/fuzz/fuzz_targets/combine.rs new file mode 100644 index 000000000..91ad4ded5 --- /dev/null +++ b/crates/coreav-kernels-hevc/fuzz/fuzz_targets/combine.rs @@ -0,0 +1,113 @@ +#![no_main] +use coreav_kernels_hevc::{BitDepth, PredictionWeight, Rect, RectMut}; + +libfuzzer_sys::fuzz_target!(|data: &[u8]| { + if data.len() < 24 { + return; + } + let bits = if data[0] & 1 == 0 { 8 } else { 10 }; + let depth = BitDepth::new(bits).unwrap(); + let (minimum, maximum) = if bits == 8 { + (-16830, 33150) + } else { + (-16880, 33247) + }; + let width = usize::from(data[2] % 64) + 1; + let height = usize::from(data[3] % 64) + 1; + let first_stride = width + usize::from(data[4] & 7); + let second_stride = width + usize::from(data[5] & 7); + let output_stride = width + usize::from(data[6] & 7); + let first_offset = usize::from(data[7] & 7); + let second_offset = usize::from(data[8] & 7); + let output_offset = usize::from(data[13] & 7); + let mut first = vec![i32::MIN; first_offset + first_stride * (height - 1) + width]; + let mut second = vec![i32::MAX; second_offset + second_stride * (height - 1) + width]; + for (side, buffer, stride, offset) in [ + (0, &mut first, first_stride, first_offset), + (1, &mut second, second_stride, second_offset), + ] { + for y in 0..height { + for x in 0..width { + let index = side * width * height + y * width + x; + let position = 20 + (4 * index) % (data.len() - 20); + let raw = + u32::from_le_bytes(core::array::from_fn(|i| data[(position + i) % data.len()])); + buffer[offset + y * stride + x] = + minimum + (raw % (maximum - minimum + 1) as u32) as i32; + } + } + if data[8] & 0xc0 == 0xc0 && side == usize::from(data[0] & 2 != 0) { + *buffer.last_mut().unwrap() = i32::MAX; + } + } + let denom = data[1] % 8; + let second_denom = if data[13] & 0xc0 == 0x40 { + (denom + 1) % 8 + } else { + denom + }; + let weight = PredictionWeight::new( + denom, + (1 << denom) + i16::from(data[9] as i8), + i16::from(data[10] as i8), + ) + .unwrap(); + let second_weight = PredictionWeight::new( + second_denom, + (1 << second_denom) + i16::from(data[11] as i8), + i16::from(data[12] as i8), + ) + .unwrap(); + let first = Rect::new(&first[first_offset..], first_stride, width, height).unwrap(); + let second = Rect::new(&second[second_offset..], second_stride, width, height).unwrap(); + let extent = output_stride * (height - 1) + width; + let mut actual = vec![u16::MAX; output_offset + extent + 5]; + let mut expected = actual.clone(); + let result = if data[0] & 2 != 0 { + coreav_kernels_hevc::weight_bi( + depth, + weight, + second_weight, + first, + second, + RectMut::new( + &mut actual[output_offset..output_offset + extent], + output_stride, + width, + height, + ) + .unwrap(), + ) + } else { + coreav_kernels_hevc::weight_uni( + depth, + weight, + first, + RectMut::new( + &mut actual[output_offset..output_offset + extent], + output_stride, + width, + height, + ) + .unwrap(), + ) + }; + let reference = coreav_kernels_hevc::combine_reference( + depth, + weight, + first, + (data[0] & 2 != 0).then_some((second_weight, second)), + RectMut::new( + &mut expected[output_offset..output_offset + extent], + output_stride, + width, + height, + ) + .unwrap(), + ); + assert_eq!(result, reference); + assert_eq!(actual, expected); + if result.is_err() { + assert!(actual.iter().all(|&value| value == u16::MAX)); + } +}); diff --git a/crates/coreav-kernels-hevc/src/domain.rs b/crates/coreav-kernels-hevc/src/domain.rs index b64128c26..10c78029c 100644 --- a/crates/coreav-kernels-hevc/src/domain.rs +++ b/crates/coreav-kernels-hevc/src/domain.rs @@ -213,6 +213,7 @@ impl<'a, T> Rect<'a, T> { } } +#[cfg(any(test, feature = "test-support"))] impl Rect<'_, T> { pub(crate) fn at(&self, x: usize, y: usize) -> Result { self.data @@ -261,6 +262,7 @@ impl<'a, T> RectMut<'a, T> { self.height } + #[cfg(any(test, feature = "test-support"))] pub(crate) fn put(&mut self, x: usize, y: usize, value: T) -> Result<(), Error> { *self .data diff --git a/crates/coreav-kernels-hevc/src/inter.rs b/crates/coreav-kernels-hevc/src/inter.rs index 8e69ab298..5a00b74e8 100644 --- a/crates/coreav-kernels-hevc/src/inter.rs +++ b/crates/coreav-kernels-hevc/src/inter.rs @@ -372,6 +372,102 @@ pub fn weight_bi( } fn combine( + depth: BitDepth, + weight: PredictionWeight, + first: Rect<'_, i32>, + second: Option<(PredictionWeight, Rect<'_, i32>)>, + output: RectMut<'_, u16>, +) -> Result<(), Error> { + if first.width != output.width || first.height != output.height { + return Err(Error::Rectangle); + } + if let Some((other_weight, other)) = second { + if other.width != output.width || other.height != output.height { + return Err(Error::Rectangle); + } + if other_weight.denom != weight.denom { + return Err(Error::Weight); + } + } + let max_sample = i32::from(depth.max_sample()); + let lower = (-24 * max_sample) >> (depth.bits() - 8); + let upper = (88 * max_sample) >> (depth.bits() - 8); + let minimum = (88 * lower - 24 * upper) >> 6; + let maximum = (88 * upper - 24 * lower) >> 6; + for row in first.data.chunks(first.stride).take(first.height) { + if row + .get(..first.width) + .ok_or(Error::Buffer)? + .iter() + .any(|value| !(minimum..=maximum).contains(value)) + { + return Err(Error::Sample); + } + } + if let Some((_, other)) = second { + for row in other.data.chunks(other.stride).take(other.height) { + if row + .get(..other.width) + .ok_or(Error::Buffer)? + .iter() + .any(|value| !(minimum..=maximum).contains(value)) + { + return Err(Error::Sample); + } + } + } + let width = output.width; + let height = output.height; + let shift = weight.denom + 14 - depth.bits(); + let offset = i32::from(weight.offset) << (depth.bits() - 8); + let first_weight = i32::from(weight.weight); + if let Some((other_weight, other)) = second { + let second_weight = i32::from(other_weight.weight); + let other_offset = i32::from(other_weight.offset) << (depth.bits() - 8); + let bias = (offset + other_offset + 1) << shift; + for ((first, other), output) in first + .data + .chunks(first.stride) + .zip(other.data.chunks(other.stride)) + .zip(output.data.chunks_mut(output.stride)) + .take(height) + { + for ((&first, &other), output) in first + .get(..width) + .ok_or(Error::Buffer)? + .iter() + .zip(other.get(..width).ok_or(Error::Buffer)?) + .zip(output.get_mut(..width).ok_or(Error::Buffer)?) + { + let sample = (first * first_weight + other * second_weight + bias) >> (shift + 1); + *output = sample.clamp(0, max_sample) as u16; + } + } + } else { + let rounding = 1 << (shift - 1); + for (first, output) in first + .data + .chunks(first.stride) + .zip(output.data.chunks_mut(output.stride)) + .take(height) + { + for (&first, output) in first + .get(..width) + .ok_or(Error::Buffer)? + .iter() + .zip(output.get_mut(..width).ok_or(Error::Buffer)?) + { + let sample = ((first * first_weight + rounding) >> shift) + offset; + *output = sample.clamp(0, max_sample) as u16; + } + } + } + Ok(()) +} + +#[cfg(any(test, feature = "test-support"))] +#[doc(hidden)] +pub fn combine_reference( depth: BitDepth, weight: PredictionWeight, first: Rect<'_, i32>, diff --git a/crates/coreav-kernels-hevc/src/inter_tests.rs b/crates/coreav-kernels-hevc/src/inter_tests.rs index d6b918e47..df0aa970e 100644 --- a/crates/coreav-kernels-hevc/src/inter_tests.rs +++ b/crates/coreav-kernels-hevc/src/inter_tests.rs @@ -1,6 +1,91 @@ use crate::*; use std::{vec, vec::Vec}; +#[test] +fn combination_rows_match_literal_full_domain_and_exact_extents() { + let mut seed = 0x943a_1807_u32; + let deltas = [-128, -127, -1, 0, 1, 126, 127]; + let offsets = [-128, -1, 0, 1, 127]; + for (bits, minimum, maximum) in [(8, -16830, 33150), (10, -16880, 33247)] { + let depth = BitDepth::new(bits).unwrap(); + for width in 1..=64 { + let height = 3; + let first_stride = width + 3; + let second_stride = width + 5; + let output_stride = width + 1; + let mut first = vec![i32::MIN; 1 + first_stride * (height - 1) + width]; + let mut second = vec![i32::MAX; 1 + second_stride * (height - 1) + width]; + for y in 0..height { + for x in 0..width { + for (data, stride) in [(&mut first, first_stride), (&mut second, second_stride)] + { + seed ^= seed << 13; + seed ^= seed >> 17; + seed ^= seed << 5; + data[1 + y * stride + x] = match x % 4 { + 0 => minimum, + 1 => maximum, + 2 => 0, + _ => minimum + (seed % (maximum - minimum + 1) as u32) as i32, + }; + } + } + } + let first = Rect::new(&first[1..], first_stride, width, height).unwrap(); + let second = Rect::new(&second[1..], second_stride, width, height).unwrap(); + for denom in 0..=7 { + for case in 0..49 { + let weight = PredictionWeight::new( + denom, + (1 << denom) + deltas[case % 7], + offsets[case % 5], + ) + .unwrap(); + let other_weight = PredictionWeight::new( + denom, + (1 << denom) + deltas[case / 7], + offsets[(case / 5) % 5], + ) + .unwrap(); + for bi in [false, true] { + let mut actual = vec![999; 1 + output_stride * (height - 1) + width]; + let mut expected = actual.clone(); + let other = bi.then_some((other_weight, second)); + let result = if bi { + weight_bi( + depth, + weight, + other_weight, + first, + second, + RectMut::new(&mut actual[1..], output_stride, width, height) + .unwrap(), + ) + } else { + weight_uni( + depth, + weight, + first, + RectMut::new(&mut actual[1..], output_stride, width, height) + .unwrap(), + ) + }; + let reference = inter::combine_reference( + depth, + weight, + first, + other, + RectMut::new(&mut expected[1..], output_stride, width, height).unwrap(), + ); + assert_eq!(result, reference, "{bits}/{width}/{denom}/{case}/{bi}"); + assert_eq!(actual, expected, "{bits}/{width}/{denom}/{case}/{bi}"); + } + } + } + } + } +} + #[test] fn automatic_and_scalar_match_all_widths_phases_and_tails() { let mut seed = 0x18a5_92c3_u32; diff --git a/crates/coreav-kernels-hevc/src/lib.rs b/crates/coreav-kernels-hevc/src/lib.rs index a74c63cf5..caa4d5c40 100644 --- a/crates/coreav-kernels-hevc/src/lib.rs +++ b/crates/coreav-kernels-hevc/src/lib.rs @@ -55,9 +55,6 @@ pub use filter::{ #[cfg(all(feature = "native-hevc", feature = "test-support"))] #[doc(hidden)] pub use filter::{deblock_chroma_scalar, deblock_luma_scalar}; -#[cfg(all(feature = "native-hevc", feature = "test-support"))] -#[doc(hidden)] -pub use inter::interpolate_inter_scalar; #[cfg(feature = "native-hevc")] pub use inter::{ InterPrediction, MotionVector, PredictionWeight, RefPlane, interpolate_inter, predict_bi, @@ -65,6 +62,9 @@ pub use inter::{ }; #[cfg(all(feature = "native-hevc", feature = "test-support"))] #[doc(hidden)] +pub use inter::{combine_reference, interpolate_inter_scalar}; +#[cfg(all(feature = "native-hevc", feature = "test-support"))] +#[doc(hidden)] pub use intra::predict_intra_reference; #[cfg(feature = "native-hevc")] pub use intra::{IntraPrediction, predict_intra}; diff --git a/docs/design/hevc-inter-combine.md b/docs/design/hevc-inter-combine.md new file mode 100644 index 000000000..d55ec5643 --- /dev/null +++ b/docs/design/hevc-inter-combine.md @@ -0,0 +1,19 @@ +# HEVC inter prediction combination + +This family changes only the opt-in HEVC kernel crate's uni, bi and weighted prediction combination under H.265 V11 (01/2026), clause 8.5.3.3.4. Interpolation, reference-picture selection, parsing, default features and decoder isolation stay unchanged. + +## Safe arithmetic first + +Validate shapes, matching denominators and every active predictor before writing any output. Borrow active rows with their independent strides, so padding is neither validated nor modified. Select uni or bi once per block rather than inside every sample. Keep the original checked, signed `i64` equations as a test-support reference. + +Every admitted internal predictor has magnitude at most 33,247. Effective weights have magnitude at most 255, scaled offsets at most 512, and the initial shift is in 4..=13. Even the conservative bi bound `2*33247*255 + (2*512+1)*8192 = 25352770` is below `2^25`, so products, offset sums and rounding fit signed `i32`. The bound deliberately combines maxima from different depths. Signed right shifts and final clipping remain in their normative order; in particular, bi offsets are not rounded separately. + +## Dispatch boundary + +Both depths use the safe row implementation. This family adds no runtime capability check, target-feature function or unsafe code. The `scalar` feature continues to exclude existing SIMD in other kernel families without changing these combination equations. + +## Verification + +Compare automatic and scalar-forced results with the literal reference over both depths, all denominators, effective-weight and offset extremes, uni and bi, widths 1..=64, independent strides, unaligned starts and exact final extents. Preserve rejection-before-write checks. A bounded differential fuzz target exercises the same safe interfaces. Both full conformance modes and the census remain required before shipping. + +Reference comparisons bind every arm to an exact commit, verify pixels with the qualified pinned oracle before timing, and check decoded work on every call. Baseline and safe rows run on the same pinned Actions CPU with alternating repeated calls. Instruction attribution is not a CPU-time share or a promised speedup; private reports retain the host, raw calls and CoreAV/FFmpeg ratios. diff --git a/scripts/tests/test_hevc_simd_workflow.py b/scripts/tests/test_hevc_simd_workflow.py index a601db4d9..497afaccb 100644 --- a/scripts/tests/test_hevc_simd_workflow.py +++ b/scripts/tests/test_hevc_simd_workflow.py @@ -21,7 +21,8 @@ def test_differential_campaign_is_bounded_and_covers_both_architectures(self): self.assertIn("if: github.event_name == 'workflow_dispatch' && !inputs.scalar", job) self.assertIn('os: [ubuntu-24.04, macos-15]', job) self.assertIn('grep -qw avx2 /proc/cpuinfo', job) - for target in ('horizontal', 'vertical', 'sao', 'intra', 'deblock'): + self.assertIn('timeout-minutes: 90', job) + for target in ('horizontal', 'vertical', 'sao', 'intra', 'deblock', 'combine'): self.assertIn('crates/coreav-kernels-hevc/fuzz ' + target, job) self.assertIn('600 "$RUNNER_TEMP/fuzz"', job) self.assertIn('scripts/fuzz_upload.py "$RUNNER_TEMP/fuzz/findings"', job)