Skip to content

fix(cambi): keep the c-values walks inside short frames - #1642

Merged
lusoris merged 2 commits into
masterfrom
fix/cambi-short-frame-oob
Oct 1, 2026
Merged

lusoris merged 2 commits into
masterfrom
fix/cambi-short-frame-oob

Conversation

@lusoris

@lusoris lusoris commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor

Summary

CPU cambi read and wrote outside its buffers on wide, short frames (Netflix/vmaf#1628). At the coarsest of the five scales, such a frame has no more rows than half the window (pad_size rows): 1920x128 leaves 8 rows against pad_size 11, and with the default window every height up to 176 at 1920 wide (352 at 3840) is affected. The c-values walk ran its first pass and its top edge for pad_size rows, and started its bottom edge at height - pad_size, whatever the height. On master, ASan reports a heap-buffer-overflow or SEGV at 1920x2, 1920x16, 1920x64, 1920x128, 1920x160 and 3840x128, at every dispatch level. A release build aborts or segfaults on 1920x64, 1920x160, 2560x224, 3840x256 and 1921x129 4:4:4.

This ports the upstream fix (Netflix/vmaf#1629) and applies it to all three copies of the walk in the fork:

  • calculate_c_values() in core/src/feature/cambi.c (the scalar path, and the host c-values pass of the CUDA, HIP and Metal twins through vmaf_cambi_calculate_c_values())
  • calculate_c_values_avx2() in x86/cambi_avx2.c (the upstream mirror; built and tested, not dispatched)
  • cambi_calculate_c_values_frame() in cambi_c_values_frame.h, which the dispatched AVX2 scan, AVX-512 and NEON drivers share (fork-local)
for (int i = 0; i < MIN(pad_size, height); i++)          /* first pass */
for (int i = 0; i < MIN(pad_size + 1, height); i++)      /* top edge */
for (int i = MAX(height - pad_size, 0); i < height; i++) /* bottom edge */

The PR also fixes the column version of the bug: the scalar and AVX2-mirror first column loops ran to pad_size whatever the width, so on a scale with fewer than pad_size columns (default window: up to 80 wide at 1080 high, 160 at 1920 high) they counted columns past the frame. decimate() works in place, so those columns hold the previous scale's pixels, inside the stride, where no sanitizer sees the read. The shared SIMD walk never visited them, so --cpumask 63 and the default dispatch disagreed, and so did the CUDA, HIP and Metal twins, which run the scalar walk on the host. All eight loops now run to MIN(pad_size, width). Upstream has the same column loops.

Scores change for frames narrower than pad_size at some scale (measured: 64x1920 vertical ramp master 14.975700714938673 vs branch 14.964394451743877 on the C path; the SIMD paths already agreed, giving 14.964394451743877 on both; another vertical ramp variant moves from 16.14104577967309 to 16.131541053198784). This column bound goes beyond upstream Netflix/vmaf#1629, which bounds only the rows.

Three more changes in the same test file:

Type

  • fix — bug fix
  • port — cherry-pick from upstream Netflix/vmaf
  • sycl / cuda / simd — backend-specific

Checklist

  • Commits follow Conventional Commits (the commit-msg hook enforces this).
  • make format && make lint is green for the touched files. The pre-commit and pre-push hook sets pass over the whole branch diff (pre-commit run --from-ref origin/master --to-ref HEAD, both stages). clang-tidy 22 through scripts/ci/tidy-ratchet.py --lane cpu --only on cambi.c, x86/cambi_avx2.c and test/test_cambi.c: 0 diagnostics in those files. cppcheck 2.22 with make lint's arguments (--enable=all --check-level=exhaustive, the generated POSIX model, the public-entrypoint library and .cppcheck-suppressions.txt) on the same three TUs: no findings. The one pre-existing finding in a touched file, constParameterPointer on calculate_c_values_scan_avx2(), carries a cited inline suppression: its non-const picture is fixed by upstream's VmafCalcCValues callback type (Research-2132). Whole-tree make lint still reports pre-existing findings in untouched files, such as cambi_avx512.c.
  • Unit tests pass: test_cambi (31), test_cambi_simd, test_cambi_stage_simd, test_cambi_spatial_mask_simd and test_cambi_dispatch_invariance pass in a gcc 16 release build and in a clang 22 ASan+UBSan build with no sanitizer report. test_cambi and test_cambi_stage_simd also pass on an aarch64 cross build under qemu-aarch64-static, which exercises the NEON driver.
  • If I touched any SIMD/GPU code path, I ran /cross-backend-diff and the worst ULP is ≤ 2. On the branch, the AVX-512, AVX2 and C dispatch levels give bit-identical cambi and cambi=full_ref=true scores on every input in the table below, including the short and narrow frames (on master they split on narrow frames or crash on short ones). cambi_cuda on the RTX 4090 and cambi_hip on the gfx1036 give the CPU's default-dispatch score bit for bit on four narrow sizes. The SYCL twin, which computes c-values on the device and already clamps each window to the frame, was not run: see Known follow-ups.
  • If I touched a feature extractor with SIMD/GPU twins, I either updated every twin or listed the gap under "Known follow-ups" below.

Bug-status hygiene (ADR-0165)

  • docs/state.md updated: T-CAMBI-SHORT-FRAME-OOB-2026-09-30, T-CAMBI-NARROW-FRAME-COLUMNS-2026-09-30 and T-CAMBI-AVX2-PARITY-GATE-LOST-IN-REBASE-2026-09-30 are closed; T-CLI-PRE-REGISTRATION-OPTS-DICT-LEAK-2026-09-30, found while testing CLI error exits, is opened as RC2 stabilisation. fix(cli): release option dictionaries on every early exit #1647 fixes it and moves the row to Recently closed; whichever of the two PRs merges second keeps only the closed row.

Netflix golden-data gate (ADR-0024)

  • I did not modify any assertAlmostEqual(...) score in the Netflix golden Python tests. The five golden files and python/test/cambi_test.py pass against this branch in the scripts/ci/setup-golden-build.sh profile build (core/build-golden).

Cross-backend numerical results

zeus, master 10f27efe2 against this branch, clang 22 release builds, --precision max, 3 frames of 8-bit 4:2:0 unless noted, at --cpumask 0 (AVX-512), 48 (AVX2) and 63 (C), for cambi and cambi=full_ref=true.

Input master branch
Netflix src01_hrc00/01_576x324; checkerboard 1-px; ramps at 1920x1080; all four inputs at 96x1080 identical at every level identical to master
64x1920, 128x1920 vertical, horizontal and diagonal ramps AVX-512 = AVX2; C differs all levels equal master's SIMD score
64x1920, 128x1920 noise levels equal identical to master
1920x64, 1920x160, 3840x256 ramps and noise SIMD levels abort or segfault all levels equal, exit 0
1920x176, 2560x240, 3840x352 (exactly pad_size rows; levels 0 and 63) scores identical to master
2560x224, 3840x336 (levels 0 and 63) aborts, or C completes after reading outside the frame both levels equal

Short frames with fewer than pad_size rows that master completed on the C path move, for example a 3840x128 horizontal ramp 16 + 60 * x / w (distorted frame one level brighter) from 19.544346510264372 to 19.512268984993305. Narrow frames move on the C path only, to the SIMD score, for example a 64x1920 vertical ramp 16 + 200 * y / h (distorted +1) from 16.14104577967309 to 16.131541053198784. cambi_cuda (RTX 4090) and cambi_hip (gfx1036) gave exactly the old C-path value before and exactly the new one after, at 64x1920, 128x1920, 32x1080 and 216x3840. Research-2132 lists every value, the input generators and the earlier measurements of the row fix (BBB 3840x2160, both checkerboard pairs, 4:4:4 inputs).

Scores change for frames narrower than pad_size at some scale (measured: 64x1920 vertical ramp master 14.975700714938673 vs branch 14.964394451743877 on the C path; the SIMD paths already agreed, giving 14.964394451743877 on both; another vertical ramp variant moves from 16.14104577967309 to 16.131541053198784). This column bound goes beyond upstream Netflix/vmaf#1629, which bounds only the rows.

7680x216 still fails cleanly with cambi: window_size 85 too large for reciprocal LUT.

Deep-dive deliverables (ADR-0108)

  • Research digest — Research-2132: affected sizes, the in-place decimation behind the column read, measurements at three dispatch levels, the GPU twins.
  • Decision matrix — ADR-1393 ## Alternatives considered: clip in every walk (chosen), reject in init() (the alternative in cambi: out-of-bounds read and write on wide, short frames (e.g. 1920x128), C and AVX2 paths Netflix/vmaf#1628), port only upstream's row bounds, make the SIMD walks read the extra columns too.
  • AGENTS.md invariant note — core/src/feature/AGENTS.md: the three walks keep both bounds, and an upstream sync keeps them.
  • Reproducer / smoke-test command — below.
  • CHANGELOG fragment — changelog.d/fixed/cambi-short-frame-oob.md; CHANGELOG.md is regenerated with scripts/release/concat-changelog-fragments.sh --write.
  • Rebase note — docs/rebase-notes.md, entry fix/cambi-short-frame-oob, including what to keep if upstream rejects such frames in init() instead.

Reproducer

meson setup build-asan core -Db_sanitize=address,undefined -Db_lundef=false \
    -Denable_cuda=false -Denable_sycl=false
ninja -C build-asan tools/vmaf test/test_cambi
./build-asan/test/test_cambi   # short_frame and narrow_frame: pass
head -c $((3 * 1920 * 128 * 3 / 2)) /dev/urandom > ref.yuv
head -c $((3 * 1920 * 128 * 3 / 2)) /dev/urandom > dis.yuv
for mask in 0 48 63; do
  ./build-asan/tools/vmaf -r ref.yuv -d dis.yuv -w 1920 -h 128 -p 420 -b 8 \
      --frame_cnt 3 --no_prediction --feature cambi --cpumask $mask \
      --json -o /dev/null
done                           # master: ASan SEGV; branch: exit 0

Known follow-ups

  • SYCL twin not measured. cvals_prime() and cvals_column() in integer_cambi_sycl.cpp clamp every window to rows [0, height - 1] and columns [0, width - 1], so by code reading the twin already computes the clipped window. It was not run: the Arc A380 on this host runs the xe kernel driver this boot, and under it SYCL kernels that use scratch memory (IGC private_size or spill_size above 0) return wrong values. Switching to i915 needs root and a reboot.
  • GPU device c-values kernels in flight. The CUDA device kernel in perf(cuda): run cambi and SpEED entirely on the device #1639 and the HIP one on wip/handoff-hip-rc3-device need the same short- and narrow-frame check before they replace the host walk.
  • CLI leak. T-CLI-PRE-REGISTRATION-OPTS-DICT-LEAK-2026-09-30 is fixed in fix(cli): release option dictionaries on every early exit #1647.

@github-actions github-actions Bot added the type:bug Something isn't working label Sep 30, 2026
@lusoris
lusoris force-pushed the fix/cambi-short-frame-oob branch from 0dc4716 to 06c9a43 Compare September 30, 2026 20:25
@lusoris
lusoris force-pushed the fix/cambi-short-frame-oob branch from 06c9a43 to a25d390 Compare September 30, 2026 23:28
@lusoris
lusoris force-pushed the fix/cambi-short-frame-oob branch from a25d390 to ee81dbc Compare October 1, 2026 00:12
lusoris added a commit that referenced this pull request Oct 1, 2026
Align SYCL feature twins with CPU reference arithmetic:
- float_ssim_sycl / integer_ssim_sycl: implement CPU reference arithmetic
  matching CUDA twin (#1637). float_ssim evaluates per-pixel l*c*s with
  exact fp32 pairs (Ff) and work-group fixed-point sums reduced in double on
  the host, preserving ADR-1370 fp32 frame-mean rounding. integer_ssim groups
  terms as ((w*a)*b)/den without identical-window shortcuts. Both achieve
  exact match on flat identical 64x64 frames (72.247199 dB).
- integer_psnr_sycl: mark with VMAF_FEATURE_EXTRACTOR_TEMPORAL flag so
  --subsample scaling operates consistently.
- integer_motion_v2_sycl: apply motion_fps_weight and motion_max_val cap in
  collect() and derive motion2_v2 / motion3_v2 in flush(), including 1-frame
  inputs.
- Drop duplicate CPU CAMBI edits belonging to #1642.
- Re-record .standards-baseline.json with pinned praetor engine.
@lusoris lusoris mentioned this pull request Oct 1, 2026
16 of 19 tasks
@lusoris
lusoris force-pushed the fix/cambi-short-frame-oob branch from ee81dbc to f44c313 Compare October 1, 2026 01:43
At the coarsest of CAMBI's five scales a wide, short input can have no more
rows than pad_size, half the window: 1920x128 leaves 8 rows against pad_size
11, and with the default window every height up to 176 at 1920 wide (352 at
3840) is affected. The c-values walk ran its first pass and its top edge for
pad_size rows and started its bottom edge at height - pad_size whatever the
height, so it read image and mask rows past both ends of the frame and wrote
them into c_values (Netflix/vmaf#1628). Under ASan, master fails at 1920x2,
1920x16, 1920x64, 1920x128, 1920x160 and 3840x128 at every dispatch level; a
release build aborts or segfaults on 1920x64, 1920x160, 2560x224, 3840x256
and 1921x129 4:4:4.

Port the upstream fix (Netflix/vmaf#1629) to calculate_c_values() in cambi.c
and to the upstream-mirror calculate_c_values_avx2(), and give
cambi_calculate_c_values_frame(), the walk the dispatched AVX2 scan, AVX-512
and NEON drivers share, the same three bounds:

    first pass   i < MIN(pad_size, height)
    top edge     i < MIN(pad_size + 1, height)
    bottom edge  i = MAX(height - pad_size, 0)

The same walks had the column version of the bug: the scalar and AVX2-mirror
first column loops ran to pad_size whatever the width, so on a scale with
fewer than pad_size columns (default window: up to 80 wide at 1080 high, 160
at 1920 high) they counted columns past the frame. decimate() works in place,
so those columns hold the previous scale's pixels, inside the stride, where
no sanitizer sees the read. The shared SIMD walk never visited them, so
--cpumask 63 and the default dispatch disagreed, and so did the CUDA, HIP and
Metal twins, which run the scalar walk on the host. All eight loops now run
to MIN(pad_size, width). This column bound goes beyond upstream
Netflix/vmaf#1629, which bounds only the rows. Upstream has the same column
loops.

Scores are identical to master at --precision max, at the AVX-512, AVX2 and
C dispatch levels, for every size the old loops handled: the Netflix pairs,
both checkerboard pairs, BBB 3840x2160, and ramp and noise inputs at
1920x176, 2560x240, 3840x352, 96x1080 and 1920x1080. Short frames that master
completed on the C path while reading outside the frame change (3840x128
horizontal ramp 19.544347 -> 19.512269). Scores change for frames narrower
than pad_size at some scale (measured: 64x1920 vertical ramp master
14.975700714938673 vs branch 14.964394451743877 on the C path; the SIMD
paths already agreed, giving 14.964394451743877 on both; another vertical
ramp variant moves from 16.141046 to 16.131541; cambi_cuda on the RTX 4090
and cambi_hip on gfx1036 give the new CPU score bit for bit). ADR-1393
records clipping over rejecting such frames in init(); Research-2132 has the
measurements.

test_calculate_c_values_short_frame and test_calculate_c_values_narrow_frame
run every driver the host has on 1 to 10 rows and 1 to pad + 2 columns
against a from-scratch clipped-window histogram and a sentinel margin. On
master sources the short test fails for 1 to 4 rows on all five drivers
without a sanitizer; with the column loops unbounded the narrow test fails on
the scalar walk, and with only the AVX2 mirror unbounded on that walk.
test_cambi_stage_simd also sweeps 1-row and pad-row frames.

test_cambi's AVX2 parity check never ran on master: #1479 changed its gate to
vmaf_get_cpu_flags_x86(), but #1483 merged first, and when #1479 was rebased
onto it the branch kept its comment and took #1483's helper with the old
vmaf_get_cpu_flags() gate. It now reads CPUID.

docs/state.md closes T-CAMBI-SHORT-FRAME-OOB-2026-09-30,
T-CAMBI-NARROW-FRAME-COLUMNS-2026-09-30 and
T-CAMBI-AVX2-PARITY-GATE-LOST-IN-REBASE-2026-09-30, and opens
T-CLI-PRE-REGISTRATION-OPTS-DICT-LEAK-2026-09-30 (fix in #1647).
calculate_c_values_scan_avx2() carries a cited cppcheck suppression: its
non-const picture comes from upstream's VmafCalcCValues callback type.
core/test/meson.build raises test_gpu_public_header_docs timeout to 120s
for Windows MinGW64.
@lusoris
lusoris force-pushed the fix/cambi-short-frame-oob branch from f44c313 to 8605979 Compare October 1, 2026 06:40
@lusoris
lusoris merged commit bcb45e6 into master Oct 1, 2026
72 of 76 checks passed
@lusoris
lusoris deleted the fix/cambi-short-frame-oob branch October 1, 2026 06:40
lusoris added a commit that referenced this pull request Oct 1, 2026
#1642 clipped the CPU c-values walk to short and narrow frames and
described the CUDA, HIP and Metal twins as running that walk on the host.
With cambi_hip device-resident that no longer holds for HIP (nor for CUDA
since ADR-1379): only the Metal twin calls
vmaf_cambi_calculate_c_values(). The feature notes, the metric pages and
the changelog entry say so, and the state row records that cambi_hip
equals the CPU on the gfx1036 on narrow frames as well.
lusoris added a commit that referenced this pull request Oct 1, 2026
Align SYCL feature twins with CPU reference arithmetic:
- float_ssim_sycl / integer_ssim_sycl: implement CPU reference arithmetic
  matching CUDA twin (#1637). float_ssim evaluates per-pixel l*c*s with
  exact fp32 pairs (Ff) and work-group fixed-point sums reduced in double on
  the host, preserving ADR-1370 fp32 frame-mean rounding. integer_ssim groups
  terms as ((w*a)*b)/den without identical-window shortcuts. Both achieve
  exact match on flat identical 64x64 frames (72.247199 dB).
- integer_psnr_sycl: mark with VMAF_FEATURE_EXTRACTOR_TEMPORAL flag so
  --subsample scaling operates consistently.
- integer_motion_v2_sycl: apply motion_fps_weight and motion_max_val cap in
  collect() and derive motion2_v2 / motion3_v2 in flush(), including 1-frame
  inputs.
- Drop duplicate CPU CAMBI edits belonging to #1642.
- Re-record .standards-baseline.json with pinned praetor engine.
lusoris added a commit that referenced this pull request Oct 1, 2026
* fix(sycl): RC3 parity follow-ups

Align SYCL feature twins with CPU reference arithmetic:
- float_ssim_sycl / integer_ssim_sycl: implement CPU reference arithmetic
  matching CUDA twin (#1637). float_ssim evaluates per-pixel l*c*s with
  exact fp32 pairs (Ff) and work-group fixed-point sums reduced in double on
  the host, preserving ADR-1370 fp32 frame-mean rounding. integer_ssim groups
  terms as ((w*a)*b)/den without identical-window shortcuts. Both achieve
  exact match on flat identical 64x64 frames (72.247199 dB).
- integer_psnr_sycl: mark with VMAF_FEATURE_EXTRACTOR_TEMPORAL flag so
  --subsample scaling operates consistently.
- integer_motion_v2_sycl: apply motion_fps_weight and motion_max_val cap in
  collect() and derive motion2_v2 / motion3_v2 in flush(), including 1-frame
  inputs.
- Drop duplicate CPU CAMBI edits belonging to #1642.
- Re-record .standards-baseline.json with pinned praetor engine.

* docs(sycl): document RC3 SYCL follow-ups

Document the three resolved RC3 SYCL parity gaps (SSIM identical/flat-frame
arithmetic, PSNR temporal subsampling, motion_v2 FPS weighting and one-frame
flush) in the SYCL backend overview and record rebase notes.

* docs: regenerate the indexes and the citation map after rebasing
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

type:bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant