Skip to content

fix(gpu): make HIP and Metal CAMBI use the shared TVI bisection and the CPU border rules - #1378

Draft
lusoris wants to merge 1 commit into
masterfrom
fix/gpu-cambi-tvi-bisection
Draft

fix(gpu): make HIP and Metal CAMBI use the shared TVI bisection and the CPU border rules#1378
lusoris wants to merge 1 commit into
masterfrom
fix/gpu-cambi-tvi-bisection

Conversation

@lusoris

@lusoris lusoris commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Summary

CAMBI on the HIP backend returned exactly 0.0 on banding content that the
CPU scores at 5.85. Not a tolerance drift — a total collapse, on default
options, on any input CAMBI is meant to detect. Three independent divergences,
behind a parity fixture that asserted 0 == 0.

1. The TVI table (HIP + Metal)

cambi.c::get_tvi_for_diff() bisects tvi_hard_threshold_condition between
luma_range.foot (64) and luma_range.head - diff - 1. Both twins hand-rolled a
search over the negated predicate, seeded from luma 0 instead of foot,
and derived vlt_luma as the largest luma below the visibility threshold where
the CPU takes the smallest at or above it. Replicating both against the same
luminance model at the default max_log_contrast = 2:

diff CPU bisection hand-rolled
1 182 1026
2 309 1025
3 436 1024
4 563 4

v_band_size = tvi_for_diff[num_diffs-1] + 1 - v_band_base, so the scored luma
band collapses from 564 entries to a handful and calculate_c_values() discards
almost every pixel as out-of-band. Both twins now call
vmaf_cambi_init_tvi_and_vlt() — the CPU's own bisection, which the SYCL twin
has always used.

2. filter_mode border rows (HIP + Metal)

cambi.c writes its vertical pass back under if (i > 1), covering output rows
1 .. height-2. Rows 0 and height-1 keep the original unfiltered pixels;
their horizontal results live only in the 3-row ring buffer. CUDA and SYCL
already carried the matching guard.

3. The 7x7 mask box sum (HIP only)

get_spatial_mask_for_index() accumulates a zero-padded summed-area table,
so an out-of-frame tap adds nothing. The HIP kernel clamped each of the 49
taps to the border pixel, counting its zero-derivative flag up to three extra
times per axis. CUDA and Metal already zero-pad.

Reproducer

meson setup build-hip core -Denable_hip=true -Denable_hipcc=true -Denable_cuda=false -Denable_sycl=false -Db_lto=false
ninja -C build-hip
meson test -C build-hip test_hip_cambi_parity --print-errorlogs -v

Measured on gfx1030, 640x480 10-bit banding gradient:

before   cambi parity FAIL: cpu=5.84615400 hip=0.00000000 delta=5.85e+00 tol=1.00e-03
after    cpu=5.8461540042  hip=5.8461540042   (bit-exact)

Revert core/src/feature/hip/ and the test fails with that 5.85 delta.

How the three were separated

Fixing only the TVI table left a 6.15e-3 residual against the 1e-3 gate —
close enough to look like GPU float noise and be waved through with a wider
tolerance. Running the CUDA twin on the identical fixture gave
cpu = gpu = 5.8461540042, bit-exact, which proved the residual was
HIP-specific rather than a general GPU/CPU property; diffing the HIP kernels
against the CUDA ones then located defects 2 and 3 directly. No tolerance was
widened.

Why nothing caught it

Both the HIP and Metal CAMBI parity fixtures were 8-bit gradients stepping 32
code levels every 32 columns. CAMBI counts neighbour differences of
1 .. num_diffs (4 at the default), so a 32-level step is an edge, not
banding
: those fixtures scored exactly 0.0 on the CPU as well, and the
parity assertion compared zero to zero. Both now use a 10-bit gradient of one
code level every two columns held inside the TVI band (200..900), and assert
cpu > 1.0 before comparing so the gate cannot rot back.

Scope

Metal gets fixes 1 and 2 by construction — there is no Apple hardware here to
measure it on; that is stated in the ADR rather than implied. CUDA and SYCL were
unaffected and stay bit-exact on the same fixture.

Deep-dive deliverables (ADR-0108)

Docs (rule 10)

docs/metrics/cambi.md gains a prominent note under
## GPU support: what HIP returned, why, which fixes landed, and the
instruction to re-measure any CAMBI score taken on the HIP backend.

Bug status (rule 13 / ADR-0165)

  • docs/state.md — closes T-GPU-CAMBI-HIP-SCORE-COLLAPSE-2026-09-07.

🤖 Generated with Claude Code

@lusoris
lusoris force-pushed the fix/gpu-cambi-tvi-bisection branch 6 times, most recently from f47062d to 12c2c82 Compare September 7, 2026 13:15
…he CPU border rules

CAMBI on the HIP backend returned exactly 0.0 on banding content that the CPU
scores at 5.85. Three divergences, each independently sufficient to move the
score, and a parity fixture that could not see any of them.

1. TVI table. cambi.c::get_tvi_for_diff bisects tvi_hard_threshold_condition
   between luma_range.foot (64) and luma_range.head - diff - 1. The HIP and
   Metal twins each hand-rolled a search over the NEGATED predicate, seeded from
   luma 0 instead of foot, and derived vlt_luma as the largest luma below the
   visibility threshold where the CPU takes the smallest at or above it.
   Replicating both at the default max_log_contrast = 2:

       diff   CPU    hand-rolled
          1   182           1026
          2   309           1025
          3   436           1024
          4   563              4

   Since v_band_size = tvi_for_diff[num_diffs-1] + 1 - v_band_base, the scored
   luma band collapses from 564 entries to a handful and calculate_c_values()
   discards almost every pixel as out-of-band. Both twins now call
   vmaf_cambi_init_tvi_and_vlt(), the CPU's own bisection, which the SYCL twin
   has always used.

2. filter_mode border rows. cambi.c writes its vertical pass back under
   `if (i > 1)`, covering output rows 1..height-2, so rows 0 and height-1 keep
   the ORIGINAL unfiltered pixels -- the horizontal results for those rows live
   only in the 3-row ring buffer. CUDA and SYCL already carried the matching
   guard; HIP and Metal filtered those rows.

3. Mask box sum. get_spatial_mask_for_index() accumulates a ZERO-PADDED
   summed-area table, so an out-of-frame tap adds nothing. The HIP kernel
   clamped each of the 49 taps to the border pixel, counting its zero-derivative
   flag up to three extra times per axis and flipping box_sum > mask_index on a
   band of border pixels. CUDA and Metal already zero-pad.

Measured on gfx1030 with a 640x480 10-bit banding gradient:

    before   cpu = 5.84615400   hip = 0.00000000
    after    cpu = 5.8461540042 hip = 5.8461540042   (bit-exact)

Fixing only the TVI table left a 6.15e-3 residual against the 1e-3 gate --
close enough to be waved through as float noise. Running the CUDA twin on the
identical fixture gave cpu == gpu bit-exact, which proved the residual was
HIP-specific and led to defects 2 and 3.

Nothing caught any of this because both the HIP and Metal CAMBI parity fixtures
were 8-bit gradients stepping 32 code levels every 32 columns. CAMBI counts
neighbour differences of 1..num_diffs (4 at the default), so a 32-level step is
an edge, not banding: those fixtures scored exactly 0.0 on the CPU as well and
the parity assertion was 0 == 0. Both now use a 10-bit gradient of one code
level every two columns held inside the TVI band, and assert the CPU score is
non-degenerate before comparing.

The Metal twin gets fixes 1 and 2 by construction -- there is no Apple hardware
here to measure it on. CUDA and SYCL were unaffected and stay bit-exact.

ADR-1219.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@lusoris
lusoris force-pushed the fix/gpu-cambi-tvi-bisection branch from 12c2c82 to f3ac7ab Compare September 7, 2026 16:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant