Skip to content

fix(adm): do not left-shift a negative masking threshold - #1662

Merged
lusoris merged 1 commit into
masterfrom
port/upstream-1602-adm-cm-threshold-shift
Oct 1, 2026
Merged

lusoris merged 1 commit into
masterfrom
port/upstream-1602-adm-cm-threshold-shift

Conversation

@lusoris

@lusoris lusoris commented Oct 1, 2026

Copy link
Copy Markdown
Contributor

Summary

The scalar integer ADM contrast masking left-shifted a negative threshold, which is undefined behaviour in C. A sanitizer build of master stops on it; this PR computes the expression modulo 2^32 instead, as the AVX2 / AVX-512 vector loops and the SYCL twin already do. Scores do not change.

adm_cm_accum_round() in core/src/feature/integer_adm.c computed the scale-0 masking excess as abs(x) - ((int32_t)(thr) << shift_sub). The threshold's centre tap is narrowed to int16, so one large coefficient among small neighbours makes thr negative. The scalar path is the one every aarch64 run takes.

The new adm_cm_excess_s0() in core/src/feature/adm_cm_accumulator.h does the subtraction in uint32_t, and adm_cm_accum_round() calls it.

Found while checking whether the fork already carries the fix of our upstream Netflix/vmaf PR #1602. It carries the first revision of that PR (SIMD follows the scalar int16 wrap). This defect is what remains of it under the fork's semantics.

Type

  • feat — new feature
  • fix — bug fix
  • perf — performance improvement
  • refactor — no behavior change
  • docs — documentation only
  • test — test-only
  • build / ci — tooling / infra
  • 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 locally. Not run in full. Run instead: clang-format --dry-run -Werror on the touched C files (clean), scripts/ci/tidy-ratchet.py --lane cpu --only on integer_adm.c and the new test (0 warnings), and the pre-commit hook set (passed).
  • Unit tests pass: fast suite on a CPU build, 191 passed, 0 failed, 2 skipped; fast suite on a CUDA build (gcc, nvcc, RTX 4090, under the GPU lock), 249 passed, 0 failed, 1 skipped.
  • If I touched any SIMD/GPU code path, I ran /cross-backend-diff and the worst ULP is ≤ 2. No SIMD or GPU code path is touched.
  • If I touched a feature extractor with SIMD/GPU twins, I either updated every twin or listed the gap under "Known follow-ups" below.
  • If I added a new .c / .cpp / .cu / .h / .hpp, it has the appropriate license header (see CONTRIBUTING.md).
  • If this is a breaking change, the commit message uses ! or BREAKING CHANGE: and the migration path is documented below.
  • If this PR adds an ADR, the ADR row lives in docs/adr/_index_fragments/<NNNN-slug>.md and the slug is appended to docs/adr/_index_fragments/_order.txt.

Bug-status hygiene (ADR-0165)

  • docs/state.md updated in this PR: T-ADM-CM-NEGATIVE-THRESHOLD-SHIFT-2026-10-01 under Recently closed, and two rows under Open bugs, both explicitly deferred and added to the disposition table: T-ADM-CM-X86-TAIL-NEGATIVE-THRESHOLD-SHIFT-2026-10-01 and T-ADM-CM-CENTRE-TAP-WRAP-ABOVE-ONE-2026-10-01.

Netflix golden-data gate (ADR-0024)

  • I did not modify any assertAlmostEqual(...) score in the Netflix golden Python tests.
  • If I believe a golden value must change, I have explained why below AND pinged @lusoris for a CODEOWNERS exception.

Cross-backend numerical results

The change is bit-exact. 24 comparisons of the vmaf JSON at --precision max, master c7f28317f against this branch, all identical:

Input default dispatch --cpumask 48 (AVX2) --cpumask 4294967295 (scalar)
src01_hrc00 / src01_hrc01 576x324, default model, 48 frames identical identical identical
checkerboard 1920x1080, 1 px shift, default model identical identical identical
checkerboard 1920x1080, 10 px shift, default model identical identical identical
src01 16-bit pair, --feature adm identical identical identical
576x324 full-range noise, --feature adm identical identical identical
576x324 hard stripes, checkerboard, crossed stripes, --feature adm identical identical identical

The 64x64 picture of the new test also scores identically on master and on this branch at all three levels.

A CUDA build (-Denable_cuda=true, gcc, nvcc) and its fast suite (249 passed, 0 failed, 1 skipped) were run because the CUDA and HIP kernels include the header that gains the helper; the kernels themselves are unchanged.

Deep-dive deliverables (ADR-0108)

  • Research digest — no digest needed: one expression changes; the measurements are in the docs/state.md rows.
  • Decision matrix — no alternatives: only-one-way fix. Modulo 2^32 is what the vector code and the SYCL twin already compute, so it is the only form that keeps every dispatch level bit-identical.
  • AGENTS.md invariant note — added to core/src/feature/AGENTS.md.
  • Reproducer / smoke-test command — pasted below under "Reproducer".
  • CHANGELOG fragment — changelog.d/fixed/adm-cm-negative-threshold-shift.md.
  • Rebase note — entry added to docs/rebase-notes.md.

Reproducer

CC=gcc meson setup build-san core -Db_lto=false -Denable_cuda=false -Denable_sycl=false \
  -Denable_float=true -Db_sanitize=address,undefined -Dbuildtype=debugoptimized
ninja -C build-san test/test_integer_adm_cm_threshold
UBSAN_OPTIONS=halt_on_error=1:print_stacktrace=1 build-san/test/test_integer_adm_cm_threshold
# master:      core/src/feature/integer_adm.c:1118:42: runtime error: left shift of negative value -15176
# this branch: 4 tests run, 4 passed

On master the same report comes from vmaf --feature adm --no_prediction --cpumask 4294967295 on 576x324 full-range noise (left shift of negative value -826). Random noise reaches it in 1 of 40 seeds at 96x64 and 3 of 40 at 176x144; the seeds of test_integer_adm_simd_noise do not, which is why the sanitizer lane never reported it.

Known follow-ups

  • x86 scalar tails, not fixed here (T-ADM-CM-X86-TAIL-NEGATIVE-THRESHOLD-SHIFT-2026-10-01). The macro ADM_CM_ACCUM_ROUND in adm_avx2.c and adm_avx512.c carries the same shift for the edge columns and the columns left over after the vector loop. A 24x24 grey frame with one 4x2 patch 255 0 0 0 at x=3, y=3 stops a sanitizer build at adm_avx512.c:2291 and, with --cpumask 48, at adm_avx2.c:2687. The fix is one line per file, but the commit hook's touched-file rule (praetorctl audit, HISS-04) refuses any change to those files while they carry 22 baselined oversized functions, and -touched-debt-delta-reason is out of bounds (ADR-1298). The row names the trigger: apply the helper when the HISS-04 split of those files lands.
  • The int16 centre tap itself (T-ADM-CM-CENTRE-TAP-WRAP-ABOVE-ONE-2026-10-01). The wrap that makes the threshold negative also adds masked contrast that is not in the picture: the test's picture scores integer_adm_scale0 1.0829 and integer_adm2 1.0355 where float_adm gives exactly 1. The second revision of upstream PR fix(release): build the native Linux bundle on the Debian 13 release track #1602 removes the narrowing. Doing that here changes the scalar reference away from upstream master on such content, across the CPU paths and four GPU twins, so it needs a maintainer decision and an ADR.
  • The CUDA, HIP and Metal kernels write thr << shift in device code and are unchanged; no device run was made for them.

@github-actions github-actions Bot added the type:bug Something isn't working label Oct 1, 2026
@lusoris
lusoris force-pushed the port/upstream-1602-adm-cm-threshold-shift branch from f243bbf to 5db4361 Compare October 1, 2026 08:27
The scalar integer ADM contrast masking computed the scale-0 excess as
abs(x) - ((int32_t)(thr) << shift_sub). The threshold's centre tap is
narrowed to int16, so one large coefficient among small neighbours makes
thr negative, and shifting a negative int is undefined in C. An ASan +
UBSan build of master c7f2831 stops on 576x324 full-range noise with
"integer_adm.c:1118:42: runtime error: left shift of negative value -826"
under --cpumask 4294967295. Every aarch64 run takes this path.

adm_cm_excess_s0() in adm_cm_accumulator.h computes the same expression
modulo 2^32 in uint32_t, which is what the AVX2 and AVX-512 vector loops
and the SYCL twin already do, and adm_cm_accum_round() calls it.

Scores are unchanged: the three Netflix reference pairs, the 16-bit src01
pair, 576x324 noise, stripes and checkerboards give JSON identical to
master at --precision max under the default dispatch, AVX2 alone and
scalar.

test_integer_adm_cm_threshold pins the helper for positive, negative and
wrapping operands, and scores a 64x64 picture that reaches a threshold of
-15176 on every dispatch level. The sanitizer build stops it on the old
expression.

Found while re-checking Netflix/vmaf PR #1602 against master. Two rows
are opened in docs/state.md, both deferred. The scalar tail loops of
adm_avx2.c and adm_avx512.c carry the same shift, and the commit hook
refuses a change to those files until their oversized functions are
split (T-ADM-CM-X86-TAIL-NEGATIVE-THRESHOLD-SHIFT-2026-10-01). Upstream
PR #1602's second revision removes the int16 narrowing instead, which
changes scores and needs a decision
(T-ADM-CM-CENTRE-TAP-WRAP-ABOVE-ONE-2026-10-01).
@lusoris
lusoris force-pushed the port/upstream-1602-adm-cm-threshold-shift branch from 5db4361 to 98643d1 Compare October 1, 2026 08:53
@lusoris
lusoris merged commit 6661a32 into master Oct 1, 2026
72 of 76 checks passed
@lusoris
lusoris deleted the port/upstream-1602-adm-cm-threshold-shift branch October 1, 2026 08:53
lusoris added a commit that referenced this pull request Oct 1, 2026
Each of the fork's fifteen open Netflix/vmaf pull requests (#1588 to
against the fork's code, with ASan + UBSan builds where the report is
about memory or undefined behaviour.

The fork needs none of the six commits. It carries the fix of every pull
request except the second revision of #1602, which is a score change left
to the maintainer. Three gaps found on the way are in their own pull
requests: #1662 (a negative threshold shift in the scalar adm_cm), #1663
and #1664 (regression tests for #1590 and #1604).

docs/state.md gains seventeen rows under "Confirmed not-affected", each
with the fork file and function, the test, and what was run; the #1604
row is corrected (the tool refuses odd 4:2:0 dimensions for raw input
only). docs/rebase-notes.md says what a sync can skip and what it must
keep. docs/development/known-upstream-bugs.md lists all fifteen pull
requests with the fork's status and records that they are no longer
updated while upstream does not act on them.
lusoris added a commit that referenced this pull request Oct 1, 2026
* docs(state): record the 2026-10-01 upstream reconciliation

Each of the fork's fifteen open Netflix/vmaf pull requests (#1588 to
against the fork's code, with ASan + UBSan builds where the report is
about memory or undefined behaviour.

The fork needs none of the six commits. It carries the fix of every pull
request except the second revision of #1602, which is a score change left
to the maintainer. Three gaps found on the way are in their own pull
requests: #1662 (a negative threshold shift in the scalar adm_cm), #1663
and #1664 (regression tests for #1590 and #1604).

docs/state.md gains seventeen rows under "Confirmed not-affected", each
with the fork file and function, the test, and what was run; the #1604
row is corrected (the tool refuses odd 4:2:0 dimensions for raw input
only). docs/rebase-notes.md says what a sync can skip and what it must
keep. docs/development/known-upstream-bugs.md lists all fifteen pull
requests with the fork's status and records that they are no longer
updated while upstream does not act on them.

* docs(upstream): keep the reconciliation page to the technical status
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