Skip to content

fix(adm): keep the scale-0 masking centre tap in int32 (ADR-1402) - #1700

Merged
lusoris merged 3 commits into
masterfrom
fix/adm-cm-centre-tap-wrap
Oct 1, 2026
Merged

lusoris merged 3 commits into
masterfrom
fix/adm-cm-centre-tap-wrap

Conversation

@lusoris

@lusoris lusoris commented Oct 1, 2026

Copy link
Copy Markdown
Contributor

Summary

Integer ADM no longer scores isolated impairments above 1, and no Netflix golden assertion moves: the golden gate gives 271 passed, 12 skipped before and after.

The scale-0 masking threshold narrowed its centre tap to int16, as upstream master does. A coefficient of 15360 or more wrapped the tap negative, and a negative threshold adds contrast instead of masking it. The tap is now int32 and the excess over the threshold is clamp(|x| - thr * 2^shift, 0, INT32_MAX), formed in int64, in the scalar, AVX2, AVX-512, CUDA, HIP, SYCL and Metal code. This is the second revision of Netflix/vmaf PR 1602, which upstream has not merged; the maintainer chose to take it now on the condition that the goldens hold (ADR-1402, docs/adr/1402-adm-cm-centre-tap-int32.md).

Base: origin/master. PR #1662 merged while this was in progress, so the branch is not stacked on it.

Two commits, meant to be read separately:

  1. refactor(adm): no score change. adm_avx2.c, adm_avx512.c and the SYCL DWT launches are split to the 60-line limit, which the commit hook requires before those files can be edited (ADR-1298). The scalar kernels move from integer_adm.c into integer_adm_kernels.h, and the x86 files call them instead of expanding their own copies of upstream's macros.
  2. fix(adm): the change above, its tests and its documentation.

Type

  • fix — bug fix
  • refactor — no behavior change (first commit)
  • sycl / cuda / simd — backend-specific

What changes score, and what does not

CPU, default options, pooled mean. The values are the same at --cpumask 0, 48 and 4294967295, and the CUDA, HIP and SYCL twins move by the same amounts.

Input Metric Before After
flat grey 64x64 against 4x2 patches every 16 pixels integer_adm_scale0 1.0829225419556654 1
integer_adm2 1.035481944668303 1
flat grey 24x24 against one patch at (3, 3) integer_adm_scale0 1.0701309766616138 1
integer_adm2 1.0301034698295983 1
independent full-range noise, 576x324 integer_adm2 0.38954843646923215 0.38950305912838107
integer_adm_scale0 0.4549724278265123 0.45480076976959144
noise against itself plus [-16, 15] integer_aim 7.166752298663627e-05 0
integer_adm3 0.9771812019019719 0.9772170356634652

Unchanged at --precision max: the three Netflix pairs and the other ten fixture pairs under python/test/resource/yuv/ (adm, float_adm, and the default model where the frame is large enough for it), one-pixel stripes, impulses on a gradient and blurred blocks. The default model's own ADM features and its VMAF score did not move on the two noise pairs either. float_adm gives exactly 1 on both patch pictures.

Until upstream merges PR 1602, the fork's integer ADM differs from upstream master on content that reaches a centre coefficient of 15360.

Cross-backend numerical results

20 picture pairs (13 fixture pairs, 7 synthetic), --precision max. "Before" is a build of master 2c3acf1c9.

Implementation Against the scalar CPU after the change Before against after
AVX2 (--cpumask 48) identical on all 20 13 fixture pairs identical
AVX-512 identical on all 20 13 fixture pairs identical
adm_cuda, RTX 4090 within 2.56e-7 (host float finalisation, unchanged); the 64x64 patch pair identical 13 fixture pairs identical
adm_hip, gfx1036 identical on 19; impulses on a gradient 3.96e-7 on scales 2 and 3, before and after 13 fixture pairs identical
adm_sycl, Arc A380 (xe) identical on all 20, all seven metrics 13 fixture pairs identical
integer_adm_metal not run: no Apple device source change only

The SYCL ADM kernels still use no scratch memory on the A380 (test_sycl_kernel_scratch: 108 kernels audited, the 2 that use scratch are in the ratchet, neither is integer ADM), so the A380 results count.

Kernel level, old objects against new objects in one binary, 14976 generated cases per configuration: every stage output of the scalar, AVX2 and AVX-512 kernels is identical except the scale-0 contrast-masking stages, and the new scalar, AVX2 and AVX-512 kernels are identical to each other in every stage.

Tests

  • test_integer_adm_cm_threshold (rewritten): pins the clamp, and scores both patch pictures on the scalar path, the default dispatch and AVX2 alone. No score may exceed 1 and every level must return the scalar's bits. On master it fails with 64x64 cpumask 4294967295 frame 0 integer_adm_scale0: 1.0829225419556654.
  • test_integer_adm_simd (extended): adm_cm_avx2() and adm_cm_avx512() against the scalar kernels on hand-built bands, with thresholds of either sign in every column and threshold products that leave int32. Sixteen planted defects each fail it, among them upstream's own vector form, which differs from its scalar for a negative threshold.
  • test_gpu_adm_tiny_frames (extended): the patch content on the CUDA, HIP and SYCL twins.
  • Undefined shift in the x86 tails, gcc 16.2.1 ASan + UBSan, 24x24 picture with one patch at (3, 3): master reports adm_avx512.c:2291:17: runtime error: left shift of negative value -15176 (and adm_avx2.c:2687:17 with --cpumask 48); the branch reports nothing at --cpumask 0, 48 and 4294967295. The thirteen ADM unit tests pass under UBSAN_OPTIONS=halt_on_error=1.
  • Fast suite on the restacked head: 204 passed on the CPU build, 263 passed on the CUDA build (RTX 4090), which includes test_cuda_adm_cm_register_pressure (no stack or local spill, at most 208 registers, as on master).

Performance

Old and new objects in one benchmark, alternated on the same 1920x1080 frames (Ryzen 9 9950X3D, gcc 16.2.1, -O3, minimum of 4 to 6 runs). The same benchmark with the old source in both slots gives 1.000.

Level Scale-0 contrast masking Whole integer ADM pipeline
scalar +7% +1.8%
AVX2 -5% (-12% at 576x324) -2.1% (-4.2% at 576x324)
AVX-512 +5% (unchanged at 576x324) +2.6% (+2.0% at 576x324)

The AVX-512 pipeline as a whole runs 0% to 5% slower than before the split at an equal instruction count. I could not find the cause; it is filed as T-ADM-AVX512-SPLIT-STAGE-TIME-2026-10-01. An end-to-end vmaf --feature adm run on BBB 3840x2160 does not resolve differences of this size on this host while other jobs run.

Checklist

  • Commits follow Conventional Commits.
  • Unit tests pass (fast suite, CPU and CUDA builds).
  • I touched SIMD/GPU code paths; the cross-backend table is above.
  • Every twin of the extractor is updated; Metal is listed under "Known follow-ups" because it could not be run.
  • The new header integer_adm_kernels.h carries the Netflix + Lusoris BSD+Patent header of the file its code came from.
  • The ADR row lives in docs/adr/_index_fragments/1402-adm-cm-centre-tap-int32.md and the slug is appended to _order.txt.

Bug-status hygiene

  • docs/state.md updated: T-ADM-CM-CENTRE-TAP-WRAP-ABOVE-ONE-2026-10-01 and T-ADM-CM-X86-TAIL-NEGATIVE-THRESHOLD-SHIFT-2026-10-01 moved to "Recently closed"; T-ADM-DECOUPLE-X86-FRACTIONAL-GAIN-ROUNDING-2026-10-01 and T-ADM-AVX512-SPLIT-STAGE-TIME-2026-10-01 opened; the Netflix/vmaf PR 1602 row under "Confirmed not-affected" rewritten.

Netflix golden-data gate

  • I did not modify any assertAlmostEqual(...) score in the Netflix golden Python tests. The gate was run before any twin was changed (scalar prototype, assembly disabled: 271 passed, 12 skipped, same outcome per test as master) and again on the finished change (271 passed, 12 skipped).

Deep-dive deliverables

  • Research digest — docs/research/1402-adm-cm-centre-tap-int32.md.
  • Decision matrix — ## Alternatives considered in docs/adr/1402-adm-cm-centre-tap-int32.md.
  • AGENTS.md invariant note — core/src/feature/AGENTS.md, core/src/feature/x86/AGENTS.md, core/src/feature/sycl/AGENTS.md, and the index entry in docs/development/rebase-sensitive-invariants.md.
  • Reproducer / smoke-test command — below.
  • CHANGELOG fragment — changelog.d/fixed/adm-cm-centre-tap-int32.md.
  • Rebase note — docs/rebase-notes.md, entry "fix/adm-cm-centre-tap-wrap": which upstream hunks to refuse when PR 1602 merges, and the function map of the split x86 files.

Reproducer

make test-netflix-golden
meson test -C build test_integer_adm_cm_threshold test_integer_adm_simd test_integer_adm_simd_noise
# device twins, where a device is present:
meson test -C build-cuda test_cuda_adm_tiny_frames test_cuda_adm_parity
meson test -C build-hip test_hip_adm_tiny_frames
meson test -C build-sycl test_sycl_adm_tiny_frames test_sycl_kernel_scratch

Known follow-ups

  • Metal: integer_adm.metal is changed in source only. Its centre tap was already 32 bits wide; the excess now goes through an MSL twin of adm_cm_excess_s0(). test_metal_integer_adm_parity needs an Apple device.
  • T-ADM-DECOUPLE-X86-FRACTIONAL-GAIN-ROUNDING-2026-10-01 (older than this change): with a non-integer adm_enhn_gain_limit the AVX2 and AVX-512 decouple kernels round where the scalar truncates; up to 1.2e-6 on integer_adm_scale0 for a gain of 1.2 on the Netflix pair. Integer gains (the default 100, the 1.0 of the NEG models) are bit-identical.
  • T-ADM-AVX512-SPLIT-STAGE-TIME-2026-10-01: see Performance.
  • .standards-baseline.json is not re-recorded. The audit passes (138 active violations within 182 baselined limit); this change removes 24 baselined findings, which leaves stale entries. Re-recording here would conflict with every other open pull request that does the same, so it is left for one re-record on master.
  • Not investigated: integer_aim is 3.18 on the 64x64 patch picture where float_adm's aim is 1, before and after this change. vmaf --backend hip --feature adm_hip --threads 4 exits with problem flushing context on the gfx1036, on master and on this branch alike.

@lusoris
lusoris force-pushed the fix/adm-cm-centre-tap-wrap branch from f0b4190 to 0fda806 Compare October 1, 2026 13:16
No score changes. The scalar integer ADM kernels move from integer_adm.c
into integer_adm_kernels.h, and adm_avx2.c / adm_avx512.c call them for
edge columns, vector tails and the per-row fold instead of carrying their
own macro copies. Every function in the two x86 files and in the SYCL DWT
launches now fits the 60-line limit, so the files can be edited under the
touched-file rule (ADR-1298) without a waiver.

The x86 contrast-masking tails previously expanded the upstream
ADM_CM_ACCUM_ROUND macro, which left-shifts a negative threshold. They
now run adm_cm_accum_px(), which already uses adm_cm_excess_s0(), so the
undefined shift is gone on every dispatch level.

Checked bit for bit against the previous objects: scalar, AVX2 and
AVX-512 stage outputs on 14976 generated cases (plus filled buffers,
non-default viewing distance, gain limits and p-norm), and CLI JSON at
--precision max on 15 picture pairs for cpumask 0, 48 and the default.
The SYCL kernels match the scalar path on the OpenCL CPU device and the
refactored DWT kernels use no private or spill memory on the Arc A380.
Integer ADM no longer scores isolated impairments above 1, and no Netflix
golden assertion moves. The scale-0 masking threshold narrowed its centre
tap to int16, as upstream master does; a coefficient of 15360 or more
wrapped it negative, and a negative threshold adds contrast instead of
masking it. The tap is now int32 and the excess over the threshold is
clamp(|x| - thr * 2^shift, 0, INT32_MAX), formed in int64, in the scalar,
AVX2, AVX-512, CUDA, HIP, SYCL and Metal code. This is the second revision
of Netflix/vmaf PR #1602, which upstream has not merged, taken by
maintainer decision on the condition that the goldens hold (ADR-1402).

Golden gate (gcc golden profile): 271 passed, 12 skipped before and after.
The 13 fixture pairs give identical JSON at --precision max on the CPU and
on the CUDA (RTX 4090), HIP (gfx1036) and SYCL (Arc A380) twins.

What moves, on every dispatch level and twin alike: a flat 64x64 reference
against isolated patches, integer_adm_scale0 1.0829225419556654 to 1 and
integer_adm2 1.035481944668303 to 1; independent full-range noise at
576x324, integer_adm2 0.38954843646923215 to 0.38950305912838107. The
fork differs from upstream master on such content until #1602 merges.

The vector kernels equal the scalar on every operand: a row is summed with
the 32-bit form, which is exact while every threshold lies in [0, 2^19),
and summed again with an exact form otherwise. Upstream's own vector form
differs from its scalar for a negative threshold and is not used. The
leftover columns of a row are the top lanes of one more vector block, so
the x86 kernels have no scalar tail left to shift a negative threshold in.

Stage time of the scale-0 contrast masking against the unsplit files:
scalar +7%, AVX2 -5%, AVX-512 +5%; the AVX-512 pipeline as a whole is
about 3% slower, which is tracked as
T-ADM-AVX512-SPLIT-STAGE-TIME-2026-10-01.

No snapshot under testdata/ changes: they are computed from the Netflix
pairs. The Metal twin is changed in source only; no Apple device was
available. Opened: T-ADM-DECOUPLE-X86-FRACTIONAL-GAIN-ROUNDING-2026-10-01.
@lusoris
lusoris force-pushed the fix/adm-cm-centre-tap-wrap branch from 0fda806 to 0fb4b27 Compare October 1, 2026 14:00
@lusoris
lusoris merged commit 408dcaa into master Oct 1, 2026
62 of 69 checks passed
@lusoris
lusoris deleted the fix/adm-cm-centre-tap-wrap branch October 1, 2026 14:01
lusoris added a commit that referenced this pull request Oct 1, 2026
The x86 SIMD kernels are bit-exact twins of scalar references that live
in baseline libraries, where no fused multiply-add exists. Most kernels
finish the last n % lanes elements in plain C, and the two general
libraries (x86_avx2, x86_avx512) were built with vmaf_fp_model_args
only. Under icx that is -fp-model=precise, which implies
-ffp-contract=on, so an icx build (every SYCL build is one) rounded
those tails once where the reference rounds twice.

In ssim_avx512.c that moved a score. Measured on an AVX-512 host
(9950X3D, icx 2026.0) at --precision max: the CPU float_ms_ssim of an
icx build differed from a GCC build and from its own scalar path by
one fp32 unit in a per-scale mean, 7.7e-9 to 1.4e-8 in the score, on 4
of 104 frames (Netflix 576x324 pair, 1080p checkerboards, BBB
3840x2160). adm, vif and speed files were contracted too (42, 2 and 43
to 47 FMA instructions against 0, 0 and 3 with GCC), with no score
difference measured.

ADR-1415:

- core/src/meson.build: both general libraries take
  vmaf_strict_fp_args, like the nine carve-out libraries.
- test_strict_fp_compiler_args.py: both are strict targets.
- test_ssim_x86_simd (new): AVX2 and AVX-512 precompute, variance and
  accumulate against transcriptions of the scalar reference, bit for
  bit, at element counts with and without a tail. Fails on the unfixed
  icx build at ssim_variance_avx512 n=1.
- test_integer_adm_simd takes _simd_strict_fp_args: it compiles the
  scalar ADM kernels into its own translation unit and failed on icx
  builds since #1700 (the compiler's default fast model).

GCC: the disassembly of all 28 objects of the two libraries is
identical with and without the flag, so GCC builds compute what they
did. icx: only explicit fmadd intrinsics and fmaf() calls remain; fast
suite 207 of 207 (device suites left out), simd suite 21 of 21. GCC
build against icx build, --backend cpu, 19 features on four fixtures:
15 bit-identical; psnr, psnr_hvs, ciede and speed_chroma still differ
(at most 7.1e-15, 7.1e-15, 5.7e-12, 1.2e-6) because icx links Intel's
math library.

docs/state.md: T-ICX-SSIM-AVX512-FP-CONTRACT-2026-10-01,
T-ICX-X86-SIMD-GENERAL-LIBS-CONTRACT-2026-10-01 and
T-ICX-INTEGER-ADM-SIMD-TEST-FP-MODEL-2026-10-01 found and closed;
T-ICX-LIBIMF-HOST-MATH-2026-10-01 opened.
lusoris added a commit that referenced this pull request Oct 1, 2026
* fix(build): build every x86 SIMD library without FP contraction

The x86 SIMD kernels are bit-exact twins of scalar references that live
in baseline libraries, where no fused multiply-add exists. Most kernels
finish the last n % lanes elements in plain C, and the two general
libraries (x86_avx2, x86_avx512) were built with vmaf_fp_model_args
only. Under icx that is -fp-model=precise, which implies
-ffp-contract=on, so an icx build (every SYCL build is one) rounded
those tails once where the reference rounds twice.

In ssim_avx512.c that moved a score. Measured on an AVX-512 host
(9950X3D, icx 2026.0) at --precision max: the CPU float_ms_ssim of an
icx build differed from a GCC build and from its own scalar path by
one fp32 unit in a per-scale mean, 7.7e-9 to 1.4e-8 in the score, on 4
of 104 frames (Netflix 576x324 pair, 1080p checkerboards, BBB
3840x2160). adm, vif and speed files were contracted too (42, 2 and 43
to 47 FMA instructions against 0, 0 and 3 with GCC), with no score
difference measured.

ADR-1415:

- core/src/meson.build: both general libraries take
  vmaf_strict_fp_args, like the nine carve-out libraries.
- test_strict_fp_compiler_args.py: both are strict targets.
- test_ssim_x86_simd (new): AVX2 and AVX-512 precompute, variance and
  accumulate against transcriptions of the scalar reference, bit for
  bit, at element counts with and without a tail. Fails on the unfixed
  icx build at ssim_variance_avx512 n=1.
- test_integer_adm_simd takes _simd_strict_fp_args: it compiles the
  scalar ADM kernels into its own translation unit and failed on icx
  builds since #1700 (the compiler's default fast model).

GCC: the disassembly of all 28 objects of the two libraries is
identical with and without the flag, so GCC builds compute what they
did. icx: only explicit fmadd intrinsics and fmaf() calls remain; fast
suite 207 of 207 (device suites left out), simd suite 21 of 21. GCC
build against icx build, --backend cpu, 19 features on four fixtures:
15 bit-identical; psnr, psnr_hvs, ciede and speed_chroma still differ
(at most 7.1e-15, 7.1e-15, 5.7e-12, 1.2e-6) because icx links Intel's
math library.

docs/state.md: T-ICX-SSIM-AVX512-FP-CONTRACT-2026-10-01,
T-ICX-X86-SIMD-GENERAL-LIBS-CONTRACT-2026-10-01 and
T-ICX-INTEGER-ADM-SIMD-TEST-FP-MODEL-2026-10-01 found and closed;
T-ICX-LIBIMF-HOST-MATH-2026-10-01 opened.

* docs: regenerate the indexes and the citation map after rebasing
@github-actions github-actions Bot added the type:bug Something isn't working label Oct 1, 2026
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