ADR-1402: Integer ADM keeps the scale-0 masking centre tap in int32 and clamps the excess in int64¶
- Status: Accepted
- Date: 2026-10-01
- Deciders: lusoris
- Tags: adm, correctness, numerics, simd, cuda, hip, sycl, metal, upstream-divergence, fork-local
Context¶
Integer ADM masks each scale-0 coefficient with a threshold: the 3x3 sum of the filtered neighbours in the three bands, with the centre tap replaced by 1/15 of the coefficient itself, ((8738 * |a|) + 2048) >> 12. That tap reaches 69904 for |a| = 32768. Upstream's C reference narrows it to int16_t, so every tap above 32767 (|a| >= 15360) wraps negative. The contrast kept after masking is max(|x| - (thr << shift), 0).
The fork carried the first revision of its own upstream pull request Netflix/vmaf#1602, which made the AVX2 and AVX-512 kernels and every GPU twin reproduce the wrap so that all implementations agreed (T-ADM-CM-SIMD-NOISE-NOT-BIT-EXACT-2026-09-18, T-SYCL-ADM-INT16-SEMANTICS-2026-09-18). After review upstream, the second revision of that pull request removes the wrap instead. Two measurements on master c7f28317f (2026-10-01) show why the wrap is a defect and not a convention:
- A wrapped tap that outweighs its eight neighbours makes the threshold negative, and a negative threshold adds contrast instead of masking it. A flat grey 64x64 reference against the same picture with isolated 4x2 patches scored
integer_adm_scale01.0829225419556654 andinteger_adm21.035481944668303, wherefloat_admgives exactly 1 for both. A ratio above 1 says the distorted picture has more of the reference's detail than the reference. thr << shiftwith a negativethris undefined in C. PR #1662 removed it from the scalar reference; the scalar tails ofadm_avx2.candadm_avx512.ckept it, and a UBSan build stopped on a 24x24 picture with one patch at (3, 3) (adm_avx512.c:2291,adm_avx2.c:2687).
The maintainer decided to remove the wrap in every implementation now, on the condition that no Netflix golden assertion moves (see References).
Decision¶
The centre tap stays in int32 in every implementation, and the excess over the threshold is clamp(|x| - thr * 2^shift, 0, INT32_MAX), formed in int64. The scalar definition is adm_cm_thresh() in core/src/feature/integer_adm_kernels.h and adm_cm_excess_s0() in core/src/feature/adm_cm_accumulator.h; they are the second revision of upstream's #1602 for the scalar code. Each twin returns the scalar's value bit for bit:
| Implementation | Where | How the excess is formed |
|---|---|---|
| Scalar, x86 edge rows and tail columns | adm_cm_accum_round() | calls adm_cm_excess_s0() |
| AVX2 | cm_excess_avx2(), cm_row_avx2() in x86/adm_avx2.c | a row is summed in int32 (exact while every threshold lies in [0, 2^19)); a row with a threshold outside that range is summed again with the threshold clamped to +/-2^(31 - shift), int32 for a non-negative threshold and saturating uint32 for a negative one |
| AVX-512 | cm_excess_avx512(), cm_row_avx512() in x86/adm_avx512.c | the same, with a lane mask |
| CUDA (DLM and AIM) | cuda/integer_adm/adm_cm.cu | calls adm_cm_excess_s0() |
| HIP (DLM) | hip/integer_adm/adm_cm.hip | calls adm_cm_excess_s0() |
| SYCL (DLM and AIM) | adm_dev_cm_excess_s0() in sycl/integer_adm_sycl.cpp | int64, as the scalar |
| Metal (DLM and AIM) | adm_cm_excess_s0() in metal/integer_adm.metal | MSL twin in long; source only, no device was available |
The vector kernels also narrow the squared excess to int32 and shift the cube arithmetically, as the scalar does: AVX-512 with _mm512_sra_epi64, AVX2 by folding the sign bias into the rounding term (cm_accum_avx2()), which costs no instruction per sample. A decoded picture never takes the second pass of a row: its filtered neighbours are non-negative and its thresholds far below 2^19. NEON has no contrast-masking kernel; aarch64 runs the scalar code.
The x86 files could only be edited after their oversized functions were split (ADR-1298); that refactor is the first commit of the same pull request and changes no score.
Alternatives considered¶
| Option | Pros | Cons | Why not chosen |
|---|---|---|---|
| Keep the int16 wrap everywhere (first revision of #1602) | Equal to upstream master on every input; nothing to re-verify | Scores isolated impairments above 1; depends on a narrowing conversion the C standard leaves to the implementation; a negative threshold needs modular arithmetic in every twin | The wrap is a defect, and upstream's own review rejected it |
| Remove the wrap only when upstream merges #1602 | No period in which the fork differs from upstream master | The defect and the open state rows stay until a date the fork does not control | The maintainer chose to fix now, provided the goldens hold; they do |
Upstream's vector form: thr > (INT32_MAX >> shift) selects 0, otherwise the 32-bit expression | Fewer vector instructions | Equals upstream's scalar only for a non-negative threshold; a negative threshold whose product leaves int32 wraps where the scalar saturates | The fork's rule is that SIMD equals the scalar on every input the kernel accepts (ADR-0138, ADR-0139); test_integer_adm_simd fails on this form |
| Saturate the centre tap at 32767 instead of widening it | The threshold stays in int16 range | A third behaviour, equal to neither upstream revision; coefficients above 15359 would be under-masked | No reference implementation to compare against |
| Clamp the excess in int32 after a saturating threshold product | No 64-bit arithmetic in the scalar | More branches than the int64 form and no faster once the int64 clamp is two selects | The int64 form is upstream's and is simpler to mirror in device code |
Consequences¶
- Positive:
- Integer ADM no longer scores above 1 on isolated impairments: the 64x64 patch picture gives
integer_adm_scale01 andinteger_adm21 (before: 1.0829225419556654 and 1.035481944668303), the 24x24 one 1 and 1 (before: 1.0701309766616138 and 1.0301034698295983), on scalar, AVX2, AVX-512, CUDA, HIP and SYCL. - No implementation left-shifts a negative threshold or depends on an int16 narrowing conversion in this path.
- The Netflix golden gate is unchanged: 271 passed, 12 skipped before and after (
make test-netflix-goldenprofile, gcc 16.2.1). The 13 fixture pairs underpython/test/resource/yuv/give identical JSON at--precision maxwith the default model,admandfloat_admon the CPU. The CUDA, HIP and SYCL twins (adm_cuda,adm_hip,adm_sycl) give identical JSON on the same 13 pairs before and after. - Negative:
- The fork's integer ADM differs from upstream master on content that reaches a centre coefficient of 15360 or more, until Netflix/vmaf merges #1602. Measured (CPU, default options, pooled mean): independent full-range 8-bit noise at 576x324 moves
integer_adm2from 0.38954843646923215 to 0.38950305912838107 andinteger_adm_scale0from 0.4549724278265123 to 0.45480076976959144; noise against the same noise plus a perturbation in [-16, 15] movesinteger_aimfrom 7.166752298663627e-05 to 0 andinteger_adm3from 0.9771812019019719 to 0.9772170356634652. The default model's own ADM features (adm_csf_mode=2) and its VMAF score did not move on either. One-pixel stripes, salt-and-pepper impulses on a gradient and blurred blocks are identical. - The scalar excess is formed in int64 with two selects; the scalar scale-0 contrast-masking stage takes about 5% longer and the scalar extractor as a whole is unchanged within measurement noise. The research digest has the stage times of every dispatch level.
- Neutral / follow-ups:
docs/rebase-notes.mdrecords the divergence and what to do when upstream merges or changes #1602.- The Metal twin was changed in source only; its parity tests need an Apple device.
- The golden gate must be re-run when upstream changes the centre tap or the excess again.
References¶
- Q (popup answer, 2026-10-01, to "remove the int16 centre-tap wrap in every implementation now, or wait for upstream?"): "Fix now if goldens hold (Recommended)".
- Netflix/vmaf#1602, both revisions, and Netflix/vmaf issue 1610 (the AVX2 cube shift).
- Research-1402: golden gate, score deltas, the vector derivation, planted defects, device parity and stage timings.
- ADR-1298 (touched-file rule), ADR-0141 (refactor first), ADR-0138 and ADR-0139 (SIMD equals scalar), ADR-1167 (the row fold these kernels share), ADR-0024 (golden gate).
docs/state.md:T-ADM-CM-CENTRE-TAP-WRAP-ABOVE-ONE-2026-10-01,T-ADM-CM-X86-TAIL-NEGATIVE-THRESHOLD-SHIFT-2026-10-01,T-ADM-CM-NEGATIVE-THRESHOLD-SHIFT-2026-10-01(PR #1662).