Skip to content

fix(float_adm): refuse frames below 17x17 instead of reading outside the bands - #1770

Merged
lusoris merged 3 commits into
masterfrom
fix/float-adm-min-frame
Oct 1, 2026
Merged

lusoris merged 3 commits into
masterfrom
fix/float-adm-min-frame

Conversation

@lusoris

@lusoris lusoris commented Oct 1, 2026 •

Copy link
Copy Markdown
Contributor

Summary

float_adm and float_adm_cuda now refuse frames smaller than 17x17, as the fixed-point adm extractor does. Before, the CPU extractor accepted them and read outside its coarsest bands; at 8 pixels or fewer that is a heap read before a buffer.

#1734 recorded the defect while making the CUDA twin exact; this is the fix.

The defect

float_adm decomposes a frame into four DWT levels. Below 17 pixels the scale-3 bands have one sample, and core/src/feature/adm_tools.c reads outside them in two places:

  • adm_cm_thresh3x3_s() mirrors the sample before the first one to index 1, which a one-sample band does not have (frames of 9 to 16 pixels);
  • dwt2_src_indices_1d_s() gives a one-sample input a fourth tap at index -1, and adm_dwt2_s() reads src[-stride + j], one row before the band buffer (frames of 8 pixels or fewer).

An AddressSanitizer build of master e955b2fe6 on a random 8x8 pair:

ERROR: AddressSanitizer: heap-buffer-overflow
READ of size 4
    #0 adm_dwt2_vert_pass_s ../core/src/feature/adm_tools.c:898
    #1 adm_dwt2_s ../core/src/feature/adm_tools.c:996
    #2 adm_dwt2_dispatch ../core/src/feature/adm.c:72

At 12x9 and 16x16 the read stays inside the buffer and returns a sample of another scale, so the scores are not meaningful (a random 8x8 pair scored adm_scale3 = 1.05). Upstream Netflix has the same code and no check.

What changed

  • core/src/feature/float_adm.c::init() calls adm_frame_size_check("float_adm", w, h) before it allocates. That is the fixed-point extractor's check (adm_csf_fixed_point.h), not a second implementation.
  • core/src/feature/cuda/float_adm_cuda.c::init_fex_cuda() calls it before it claims a device resource. The twin clamped the two indices and so scored frames the CPU cannot.
  • Both log <extractor> requires width >= 17 and height >= 17 (got WxH) and return -EINVAL. 17x17 is accepted and bit-identical on the two.

What did not change, and why

The row T-FLOAT-ADM-DEBUG-KEY-UNSUFFIXED-2026-10-01 proposed a second fix for this PR: list adm instead of adm_scale0 in provided_features, so that the debug ratio gets its option suffix and two float_adm debug instances can run together. I tried it and withdrew it. It renames the key of every non-default instance from adm to adm_<suffix>, and the Python harness then reports VMAF_feature_adm_<suffix>_score, where two Netflix golden tests read results[0]["VMAF_feature_adm_score"] under non-default options (test_run_vmaf_fextractor_with_feature_overloads, test_run_vmaf_fextractor_with_adm_skip_scale0 in python/test/feature_extractor_test.py). Those tests are not to be changed, so the unsuffixed key is a contract. The row moves to deferred with that finding and the two options left.

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. (clang-format and the commit hooks; clang-tidy reports 0 in the touched files.)
  • Unit tests pass: python3 scripts/ci/run_meson_test.py -- -C build. (--suite=fast: 222 of 222 on a CPU build, 286 of 286 on a CUDA build with an RTX 4090.)
  • If I touched any SIMD/GPU code path, I ran /cross-backend-diff and the worst ULP is ≤ 2. (No kernel changes; 17x17 is identical on CPU and CUDA, test_cuda_float_adm_parity 17 of 17.)
  • If I touched a feature extractor with SIMD/GPU twins, I either updated every twin or listed the gap under "Known follow-ups" below. (CUDA updated; SYCL, HIP and Metal listed.)
  • If I added a new .c / .cpp / .cu / .h / .hpp, it has the appropriate license header (see CONTRIBUTING.md). (None added.)
  • If this is a breaking change, the commit message uses ! or BREAKING CHANGE: and the migration path is documented below. (Frames below 17x17 now fail at start instead of returning scores that were not meaningful; no API changes.)
  • If this PR adds an ADR, the ADR row lives in docs/adr/_index_fragments/<NNNN-slug>.md. (No ADR: see the decision matrix item.)

Bug-status hygiene (ADR-0165)

  • docs/state.md updated in this PR: T-FLOAT-ADM-TINY-FRAME-BAND-READS-2026-10-01 moved to Recently closed with the sanitizer evidence; T-GPU-FLOAT-ADM-TINY-FRAME-FLOOR-2026-10-01 opened for the SYCL, HIP and Metal twins; T-FLOAT-ADM-DEBUG-KEY-UNSUFFIXED-2026-10-01 rewritten and moved to deferred.

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. (None changes. The golden fixtures are 576x324 and 1920x1080; make test-netflix-golden was not run for this PR.)

Cross-backend numerical results

frame   float_adm (cpu)                          float_adm_cuda
8x8     -EINVAL, "float_adm requires ..."        -EINVAL, "float_adm_cuda requires ..."
16x16   -EINVAL                                  -EINVAL
17x17   scores                                   identical at --precision max

Performance (if perf or feat)

Not a performance change: one comparison in init().

Deep-dive deliverables (ADR-0108)

  • Research digest — no digest needed: the evidence is the sanitizer trace above and in the state row.
  • Decision matrix — no alternatives: only-one-way fix. No new ADR: the fix applies the frame-size floor the fixed-point extractor already has (ADR-1374 for its twins) to the float one, with the same helper. The alternative, clamping the two indices so that tiny frames keep scoring, would define scores for one-sample bands that no reference has; the CUDA twin did that and matched nothing.
  • AGENTS.md invariant note — core/src/feature/AGENTS.md (the floor is fork-local, keep it on an upstream sync; the debug key stays unsuffixed).
  • Reproducer / smoke-test command — pasted below under "Reproducer".
  • CHANGELOG fragment — changelog.d/fixed/float-adm-min-frame.md.
  • Rebase note — docs/rebase-notes.md, "float_adm refuses frames below 17x17".

Reproducer

meson setup build core -Denable_cuda=false -Denable_sycl=false -Db_lto=false
ninja -C build
# Rejects 8x8, 16x16, 17x16, 16x17; accepts 17x17 and 576x324. Fails on master.
build/test/test_float_adm_coverage
# The heap read, on master: an 8x8 pair under AddressSanitizer.
meson setup build-asan core -Denable_cuda=false -Db_sanitize=address,undefined -Db_lto=false
ninja -C build-asan tools/vmaf
head -c 192 /dev/urandom > r.yuv; head -c 192 /dev/urandom > d.yuv
build-asan/tools/vmaf -r r.yuv -d d.yuv -w 8 -h 8 -p 420 -b 8 --no_prediction --feature float_adm -o /dev/null --json
# The CUDA twin's rejection needs no device.
build-cuda/test/test_cuda_float_adm_parity

Known follow-ups

  • float_adm_sycl, float_adm_hip, float_adm_metal still accept frames below 17x17 (read from source, not run): T-GPU-FLOAT-ADM-TINY-FRAME-FLOOR-2026-10-01.
  • The debug key: T-FLOAT-ADM-DEBUG-KEY-UNSUFFIXED-2026-10-01, deferred, with the reason the proposed fix cannot be applied.

@lusoris
lusoris force-pushed the fix/float-adm-min-frame branch from 95b6fcf to 6b3ed9f Compare October 1, 2026 21:08
@lusoris
lusoris marked this pull request as ready for review October 1, 2026 21:08
…1754)

* fix(cuda): download every plane of device input for CPU extractors

translate_picture_device() copied the luma plane of a device picture into the
host picture only. With device-resident input and an extractor on the CPU the
chroma planes stayed uninitialised, and psnr_cb came out as the 60 dB cap where
the CPU gives 12.54 dB, with no error (Netflix/vmaf#1613). The download now
takes every plane the picture has, as the upload already does.

test_cuda_device_input_host_extractors compares psnr y/cb/cr of device input
with a CPU-only run at n_threads 0 and 4; it fails without the fix.

* docs: regenerate the indexes and the citation map after rebasing
… changes (#1764)

* fix(build): rebuild SYCL translation units when a header they include changes

meson compiles every SYCL source through a custom target that declared its
source file and nothing else, so Ninja knew none of its headers. After an
edit to a header such as feature/sycl/sycl_exact_fp.h, ninja reported
nothing to do and the library kept the kernels compiled from the old text.
A clean build was never affected.

The sycl_common and sycl_feature targets now pass -MD -MF to icpx and
declare the depfile. Not on Windows, where the depfile of the icpx driver
is unverified; that lane builds from clean.

Found while measuring float_adm_sycl's differences one at a time: six
header variants scored as the unchanged twin until the source was touched.
ADR-1320 fixed the same defect for the CUDA fatbins and HIP HSACOs.

Closes T-SYCL-TU-HEADER-DEPS-UNTRACKED-2026-10-01.

* docs: regenerate the indexes and the citation map after rebasing
…the bands (#1770)

* fix(float_adm): refuse frames below 17x17 instead of reading outside the bands

float_adm decomposes a frame into four DWT levels. Below 17 pixels the
scale-3 bands have one sample and adm_tools.c reads outside them:
adm_cm_thresh3x3_s() mirrors the sample before the first one to index
1, and dwt2_src_indices_1d_s() gives a one-sample input a fourth tap at
index -1. The second is a heap read before the band buffer: an
AddressSanitizer build reports heap-buffer-overflow, READ of size 4 in
adm_dwt2_vert_pass_s, on an 8x8 pair. From 9 to 16 pixels the read
stays inside the buffer and returns a sample of another scale (a random
8x8 pair scored adm_scale3 = 1.05).

float_adm.c::init() now calls adm_frame_size_check(), the fixed-point
extractor's 17x17 floor, and float_adm_cuda does the same before it
claims a device resource. Both log
"<extractor> requires width >= 17 and height >= 17 (got WxH)" and
return -EINVAL. 17x17 is accepted and identical on the two.

Not changed, and recorded why: the debug ratio stays under the
unsuffixed key `adm`. Listing `adm` in provided_features (it lists
`adm_scale0`, as upstream does) would suffix the key and let two debug
instances coexist, but the Netflix golden tests read
VMAF_feature_adm_score under non-default options
(T-FLOAT-ADM-DEBUG-KEY-UNSUFFIXED-2026-10-01, now deferred).

The SYCL, HIP and Metal float twins still accept such frames
(T-GPU-FLOAT-ADM-TINY-FRAME-FLOOR-2026-10-01).

* docs: regenerate the indexes and the citation map after rebasing
@lusoris
lusoris force-pushed the fix/float-adm-min-frame branch from 6b3ed9f to 953cf6e Compare October 1, 2026 21:41
@lusoris
lusoris merged commit 953cf6e into master Oct 1, 2026
8 of 71 checks passed
@lusoris
lusoris deleted the fix/float-adm-min-frame branch October 1, 2026 21:41
@github-actions github-actions Bot added the type:bug Something isn't working label Oct 2, 2026
lusoris added a commit that referenced this pull request Oct 2, 2026
…age and fail on a difference no ADR covers (ADR-1487, ADR-1494)

Code inherited from Netflix/vmaf evaluates as Netflix's source does; a
difference needs an ADR. The upstream parity audit of 2026-10-02 found
170,344 of 348,132 values different, six causes of them unintended, and
no gate that would have seen any of them. This makes the rule checkable.

scripts/dev/upstream_parity.py (make upstream-parity, make
upstream-parity-full) builds Netflix/vmaf at the recorded parity head
(read through scripts/ci/upstream_parity_pin.py) and this tree with the
golden build profile, with the same compilers, runs one C API harness
against each and compares every per-frame value, aggregate and pool at
%.17g: 16 shared extractors, their option variants and the shipped
models, on the Netflix pairs and clips derived from them, at scalar and
default dispatch (AVX2 too in the full matrix).

Both trees are built and run in the dev container image (--container):
Netflix's own ciede values differ between glibc 2.43 and 2.44, so a
comparison is evidence only in one recorded environment. The documents
record the image id, compilers and C library; outside the image the
guard refuses to measure unless --unpinned marks the verdict advisory.
--heap-check (in the full target) reruns every request with
MALLOC_PERTURB_=170: an output of this tree that changes fails, and an
upstream output that changes is undefined and may only be covered with
bound inf (ciede on odd sizes and float_motion's scale-1 chroma, which
went flaky on the host).

Every difference is attributed to one fragment under
scripts/ci/upstream_parity.d/ (the exact_twins.d pattern; the line
parser is now shared): 37 deliberate deviations with their ADRs and
bounds measured in the image, 5 pending (the SpEED revert and the
five-frame motion port). The guard fails on a difference no fragment
covers, on one above its bound, on a stale fragment and on a crash of
this tree's harness; exit 2 means it could not compare. The seven
fragments of the reverts that landed meanwhile (fork PRs #1891, #1892,
#1894, #1895) are removed.

ADR-1494 records the ADM extractors' refusal of frames below 17x17
(fork PRs #1473, #1770): upstream's integer ADM ends in signal 11 there,
its float ADM returns values that at 12x9 and 8x8 depend on the heap.

On master 96af5b3 in the image (GCC 15.2.0, glibc 2.43) against
Netflix 9e48141b: full matrix 886,002 values, 780,741 identical, 83,516
differences all covered, none stale; 1,675 upstream outputs depend on
the heap, none of this tree's. Probe set 252,162 values, pass. With the
SpEED revert applied in a scratch copy, exactly its 3 fragments go
stale. A one-ulp change planted in float_psnr.c fails the guard.

testdata/bench_upstream_ab.py builds upstream through the guard and
takes its score verdict from it (advisory on the host); --max-score-delta
is gone and the default upstream is the recorded head. Not a required
check yet (T-UPSTREAM-PARITY-GUARD-HOSTED-JOB-2026-10-02).
lusoris added a commit that referenced this pull request Oct 3, 2026
…age and fail on a difference no ADR covers (ADR-1487, ADR-1494) (#1890)

* feat(parity): compare every CPU value with Netflix/vmaf in the dev image and fail on a difference no ADR covers (ADR-1487, ADR-1494)

Code inherited from Netflix/vmaf evaluates as Netflix's source does; a
difference needs an ADR. The upstream parity audit of 2026-10-02 found
170,344 of 348,132 values different, six causes of them unintended, and
no gate that would have seen any of them. This makes the rule checkable.

scripts/dev/upstream_parity.py (make upstream-parity, make
upstream-parity-full) builds Netflix/vmaf at the recorded parity head
(read through scripts/ci/upstream_parity_pin.py) and this tree with the
golden build profile, with the same compilers, runs one C API harness
against each and compares every per-frame value, aggregate and pool at
%.17g: 16 shared extractors, their option variants and the shipped
models, on the Netflix pairs and clips derived from them, at scalar and
default dispatch (AVX2 too in the full matrix).

Both trees are built and run in the dev container image (--container):
Netflix's own ciede values differ between glibc 2.43 and 2.44, so a
comparison is evidence only in one recorded environment. The documents
record the image id, compilers and C library; outside the image the
guard refuses to measure unless --unpinned marks the verdict advisory.
--heap-check (in the full target) reruns every request with
MALLOC_PERTURB_=170: an output of this tree that changes fails, and an
upstream output that changes is undefined and may only be covered with
bound inf (ciede on odd sizes and float_motion's scale-1 chroma, which
went flaky on the host).

Every difference is attributed to one fragment under
scripts/ci/upstream_parity.d/ (the exact_twins.d pattern; the line
parser is now shared): 37 deliberate deviations with their ADRs and
bounds measured in the image, 5 pending (the SpEED revert and the
five-frame motion port). The guard fails on a difference no fragment
covers, on one above its bound, on a stale fragment and on a crash of
this tree's harness; exit 2 means it could not compare. The seven
fragments of the reverts that landed meanwhile (fork PRs #1891, #1892,

ADR-1494 records the ADM extractors' refusal of frames below 17x17
(fork PRs #1473, #1770): upstream's integer ADM ends in signal 11 there,
its float ADM returns values that at 12x9 and 8x8 depend on the heap.

On master 96af5b3 in the image (GCC 15.2.0, glibc 2.43) against
Netflix 9e48141b: full matrix 886,002 values, 780,741 identical, 83,516
differences all covered, none stale; 1,675 upstream outputs depend on
the heap, none of this tree's. Probe set 252,162 values, pass. With the
SpEED revert applied in a scratch copy, exactly its 3 fragments go
stale. A one-ulp change planted in float_psnr.c fails the guard.

testdata/bench_upstream_ab.py builds upstream through the guard and
takes its score verdict from it (advisory on the host); --max-score-delta
is gone and the default upstream is the recorded head. Not a required
check yet (T-UPSTREAM-PARITY-GUARD-HOSTED-JOB-2026-10-02).

* 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