diff --git a/.github/workflows/libvmaf-build-matrix.yml b/.github/workflows/libvmaf-build-matrix.yml index 90b4b7158..e9bc96ff9 100644 --- a/.github/workflows/libvmaf-build-matrix.yml +++ b/.github/workflows/libvmaf-build-matrix.yml @@ -525,6 +525,35 @@ jobs: # Static libs must include -lpthread and -lm pkg-config --static --libs libvmaf | grep -q '\-lpthread\|\-pthread' pkg-config --static --libs libvmaf | grep -q '\-lm' + # ... and the C++ runtime, because libvmaf contains C++ translation + # units (feature_extractor.cpp, luminance_tools.cpp, vendored libsvm, + # ...). Netflix/vmaf#1178. + pkg-config --static --libs libvmaf | grep -q '\-lstdc++\|\-lc++' + # Grepping the flag list is not the test that actually reproduces the + # downstream FFmpeg failure — LINKING is. Build a C consumer with the + # C driver, using exactly what pkg-config reports. + echo "=== static link smoke ===" + cat > /tmp/pc_static_smoke.c <<'SMOKE' + #include + int main(void) + { + VmafContext *ctx = 0; + VmafConfiguration cfg = {0}; + return vmaf_init(&ctx, cfg) == 0 ? 0 : 1; + } + SMOKE + # Use the leg's OWN compiler, not bare `cc`. The matrix builds with + # `ccache gcc-14` / `ccache clang-22`, while `cc` is the image + # default (a different gcc), so a bare `cc` link tests a toolchain + # the archive was not produced with. $CC is intentionally unquoted: + # its value is two words ("ccache gcc-14") and must word-split. + # (b_lto is meson-default false here, and explicitly false on the + # SYCL/CUDA legs, so the archive holds plain objects rather than LTO + # IR — a plugin mismatch is not in play, but matching the compiler + # still costs nothing.) + ${CC:-cc} /tmp/pc_static_smoke.c $(pkg-config --cflags libvmaf) \ + $(pkg-config --static --libs libvmaf) -o /tmp/pc_static_smoke + echo "static link OK" # DNN legs: run the dedicated dnn suite first so its logs are easy # to find if the build regresses ORT compatibility. The full test diff --git a/CHANGELOG.md b/CHANGELOG.md index 563f410be..81f467da9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24885,6 +24885,286 @@ channel yet); bump CI CUDA pin to 13.2.0 on all Jimver/cuda-toolkit-action legs. (ADR-0603, triggered by Renovate PR #1402) +- **Upstream issue harvest (ADR-1166): nine stale Netflix/vmaf reports + verified against the fork and fixed, eight more recorded.** The fork + diverged far enough from upstream that an open report there is neither + automatically applicable nor automatically stale, so each candidate was + checked against this tree and reproduced before anything was changed. The + full triage table — including the ALREADY-FIXED and NOT-APPLICABLE + verdicts, which are the expensive ones to re-derive — is in + `docs/research/1166-upstream-issue-harvest-2026-09-03.md`. + + Fixed here: + + - **Netflix/vmaf#1582 — two out-of-bounds accesses in the float + convolution, both reachable from the public C API today.** The + reflect-101 mirror in `convolution_edge_s` / `_sq_s` / `_xy_s` bounced an + out-of-range tap exactly once, which only lands in range when the plane + is at least `radius + 1` across; below that it fell out the opposite + side (heap-buffer-overflow READ). Separately, + `convolution_x_c_s` / `convolution_y_c_s` derived the trailing border + bound as `dim - (filter_width - radius)`, which goes negative for a + plane narrower than the filter, so the trailing loop started at a + negative index and wrote `dst[i * dst_stride - 1]` — a heap underflow + WRITE. Two live paths reached the defective sizes: `--feature float_vif` + on any frame in 9..15 px (the guard admitted `>= 9`, but the four-scale + ladder needs `>= 16` — the binding constraint is scale 3), and + `--feature float_motion` with `motion_add_uv=true` on a 4x4 YUV420P + frame (the guard validated luma only, while the blur runs per plane at + the 2x2 chroma dimensions). The mirror now folds iteratively — bit- + identical to the single bounce for every in-contract size, pinned by a + bit-equality test at 24x24 — the borders are clamped, `float_vif` + derives its minimum from `vif_get_min_dim(kernelscale)`, and + `float_motion` validates every plane it will convolve. + - **Netflix/vmaf#1581 — same mirror, motion extractors.** The motion + extractors' own single-bounce `mirror()` bodies are deliberately left + alone: they sit downstream of an `init()` guard that rejects + `w < 3 || h < 3`, so the defective sizes never reach them. That is a + deliberate divergence from upstream, which instead fixes `mirror()` so + tiny frames can be scored; see `docs/rebase-notes.md`. + - **Netflix/vmaf#1580 — the three fork-added Metal motion extractors had + no dimension guard at all.** `motion_metal`, `motion_v2_metal` and + `float_motion_metal` are registered and shipped, so a 1- or 2-pixel-tall + frame read out of bounds on device. They now carry the same min-dim + guard as the CPU, CUDA, SYCL and HIP twins, placed before the Metal + context is created so no cleanup path is needed. + - **Netflix/vmaf#1242 — `VmafFeatureDictionary` ownership.** + `vmaf_model_feature_overload()` returned `-ENOMEM` straight out of its + loop and skipped the unconditional free at the tail, leaking the + caller's dictionary; `vmaf_model_collection_feature_overload()` + discarded the copy's return value, leaked the partial copy, silently + skipped the remaining sub-models, and dereferenced `*model_collection` + without checking it. `` and `` also + documented **opposite** ownership rules, which made one of the two + readings a latent double free for any third-party caller. All three + public headers now state the implemented contract identically: the + dictionary is consumed on every path except the argument-validation + guards, where the caller still owns it. ADR-0806 is superseded. + - **Netflix/vmaf#743 — the CLI progress spinner was mojibake on Windows.** + The UTF-8 braille table went to stderr through a byte-oriented + `fprintf` while nothing in the tree ever set the console output code + page or enabled VT processing, so under cp437 the two glyphs rendered as + six garbage characters (widening the line past the `\r` overwrite), + under cp936 as replacement boxes, and the `\033[K` erase printed + literally on legacy conhost. The CLI now switches the console to UTF-8 + + VT for the run and restores the previous state on exit, and falls back + to an ASCII spinner and space padding when the console refuses either. + POSIX output is byte-identical. + - **Netflix/vmaf#1551 (retracting Netflix/vmaf#1422) — the MSVC + `__builtin_clz` shim emitted LZCNT.** MSVC's `__lzcnt` emits + `F3 0F BD` with no runtime feature gate; on an x86-64 without ABM/LZCNT + the prefix is ignored and the instruction retires as BSR, returning the + MSB index instead of the leading-zero count. Two of the four call sites + are on the generic scalar path, so an MSVC-built `vmaf.exe` on + pre-Haswell hardware silently mis-normalised every VIF and ADM log2 (a + 2048-LSB error, i.e. a factor of two in the VIF fixed point) and shifted + by a negative count for large inputs — no fault, no diagnostic, and + invisible to CI because every hosted Windows runner has LZCNT. The shim + now uses `_BitScanReverse`, which is BSR by definition and present on + every x86-64 part, and carries an architecture guard so an MSVC ARM64 + leg compiles. `scripts/ci/check-msvc-clz-shim.sh` keeps the intrinsic + from coming back. + - **Netflix/vmaf#1178 — `libvmaf.pc` omitted the C++ runtime.** + `pkg-config --static --libs libvmaf` reported only `-pthread -lm`, so + linking the static archive failed with hundreds of undefined references + to `operator new` / `std::ios_base::ios_base()` — the fork is more + exposed than upstream because the C++ symbols come from its own + converted translation units, not just vendored libsvm. Downstream + fully-static FFmpeg builds had to add `-lstdc++` by hand (see + ADR-0198). `Libs.private` now carries the C++ runtime, detected from + the STL actually in use (`_LIBCPP_VERSION`) rather than from the + compiler id, and the CI check performs a real link instead of grepping + the flag list. + - **Netflix/vmaf#1573 — two build-system defects.** The nvcc fatbin + include list used relative paths, which only resolve when the build + directory is a direct child of `core/`; since ADR-0700 the layout the + docs themselves use put it elsewhere, and every `.cu` failed with + `fatal error: cuda/integer_adm_cuda.h: No such file or directory`. And + the three shell-driven tool tests declared no `depends`, so running one + as a subset (`meson test test_vmaf_cuda_gpumask`) built nothing and the + script died with exit 127. + + Regression tests: `core/test/test_convolution_edge_small.c`, + `core/test/test_compat_clz.c`, + `core/test/test_model_feature_overload_ownership.c`, + `core/test/test_spinner.cpp`, `scripts/ci/check-msvc-clz-shim.sh`, plus + extended cases in `core/test/test_motion_min_dim.c` and + `core/test/test_float_vif_min_dim.c`. Netflix golden scores unchanged + (76.66744 / 35.070245 / 7.985956; 271 passed, 12 skipped). + + Behaviour change to note: `float_vif` now rejects frames below 16 px in + either dimension (it previously accepted 9 px and read out of bounds at + scale 3), and `float_motion` with `motion_add_uv` now rejects frames whose + chroma planes fall below the filter minimum. Both convert previously + undefined behaviour into a documented `-EINVAL`. + + Post-review corrections (three defects an independent adversarial review + found in the harvest itself, all reproduced before fixing): + + - **The #1582 border clamp landed only on the scalar path.** + `convolution_f32_c_s` dispatches to `convolution_f32_avx_s` whenever + AVX2 is present — every CI runner and the dev workstation — so the + clamp was dead code on x86. The AVX2 and AVX-512 twins derive the same + `height - radius` split at three sites each and kept it unclamped: for a + plane shorter than the radius that is negative, so the trailing border + loop starts at a negative row and the leading one runs past the end. + Both are heap **writes**. All six sites now share the scalar clamp, + which moved into `convolution_internal.h`. + - **`motion_filter_size=1` bypassed the minimum-dimension guard.** + `motion_check_min_dim` gated the whole check on + `effective_filter_size > 1`, but `motion_blur_plane` keeps + `filter_size = 5` for that value and only swaps in the no-op + coefficients, so radius stays 2. A 1-row plane therefore reached the + convolution above through a documented public option (range 0..9). The + guard now mirrors `motion_blur_plane` exactly. + - **Odd-height 4:2:0 chroma planes were under-allocated by one row.** + `motion_chroma_heights` used `h / 2` while `picture.c` and the guard + both use the ceiling `(h + 1) >> 1`, so `motion_copy_and_blur` overran + `ref`, `tmp` and every blur-ring buffer for both U and V. Even heights + were unaffected, which is why the golden fixtures never caught it. + + Regression test `core/test/test_motion_convolution_oob.c` drives + `float_motion` through the public `vmaf_read_pictures` entry point, + because neither existing test could reach the dispatched SIMD path: + `test_motion_min_dim` only calls `init()`, and + `test_convolution_edge_small` calls the scalar kernels directly. Verified + both ways — the new test fails on the pre-fix tree, and under + `-Db_sanitize=address` the pre-fix tree reports + `heap-buffer-overflow ... WRITE of size 4 in convolution_f32_avx_s` + reached from `vmaf_read_pictures`. + + Second post-review round — the `VmafFeatureDictionary` ownership contract + (Netflix/vmaf#1242) was still stated three different ways, and one of them + was a double free: + + - `` and `docs/api/index.md` claimed that an unknown + `feature_name` never consumes the dictionary. That is true of + `vmaf_use_feature`, which resolves the name against the global extractor + registry and returns `-EINVAL` before touching it — but **not** of + `vmaf_model_feature_overload`, which matches against the features of one + particular model. A name matching nothing there is a successful no-op + returning `0`, and the dictionary is consumed anyway. A caller following + the old wording would double-free. All three headers and the API page now + state the asymmetry explicitly, and `` no longer claims + its rule "matches `vmaf_use_feature`". + - `vmaf_use_feature` leaked the caller's dictionary on two failure paths — + a failed `vmaf_dictionary_copy`, and a failed + `vmaf_feature_extractor_context_create`, which frees only what it + allocated. Both leaked exactly when the documented contract told the + caller not to free. Both now release it. + + Two cases added to `core/test/test_model_feature_overload_ownership.c` pin + the asymmetry from both sides, and pass under `-Db_sanitize=address`. + + Third post-review round — the MSVC clz shim's architecture allowlist + excluded the one platform it was introduced to protect: + + - **The `_M_X64 || _M_IX86` guard locked MSVC ARM64 out of the shim + entirely.** Both the header comment and + `scripts/ci/check-msvc-clz-shim.sh` justified the exclusion by + asserting that `_BitScanReverse` is x86-only. The MSVC intrinsics + reference states the opposite: `_BitScanReverse` is available on x86, + ARM, x64 **and** ARM64, and only `_BitScanReverse64` is restricted (to + x64 and ARM64). Since this header is the sole definition of + `__builtin_clz` for `integer_adm.c` and `integer_vif.h` — generic + scalar code compiled for every target — an MSVC ARM64 build fell + through the guard with no definition at all and could not compile. + Nothing observed it because the fork has no MSVC ARM64 CI leg. The + allowlist now enumerates every architecture MSVC targets, + `_BitScanReverse64` is used on x64 and ARM64 with the two-step 32-bit + reconstruction elsewhere, and the gate pins the ARM64 arm so it cannot + be narrowed again. Both the narrowing and the `__lzcnt` reintroduction + were negative-tested against the gate. + + Fourth post-review round — the adversarial review's remaining findings, + each checked against the code before acting. Two were confirmed as + defects in this PR's own earlier rounds, two were pre-existing + cross-backend gaps, one was a real evasion hole in a gate this PR added, + and one did not hold up: + + - **The Metal motion guard added in round 3 was insufficient, and the + real defect was in the kernel.** `integer_motion.metal`, + `float_motion.metal` and `integer_motion_v2.metal` load a + `TILE_W x TILE_H = 20x20` threadgroup tile at origin `bid * 16 - 2`, + so their mirror helper receives indices up to `16*bid + 17` -- far + outside the 5-tap neighbourhood it appears to serve. A single bounce + only lands in range when `idx <= 2 * (sup - 1)`. Enumerating the real + tile span, the single-bounce form read out of bounds for **every + dimension in 1..9 and for exactly 17** (at 17 the last workgroup + reaches idx 33 while `2 * (17 - 1) = 32`, folding to -1). The + `w < 3 || h < 3` guard closes neither the 4..9 range nor 17, so the + fix moved into the kernels: all three now fold iteratively, exactly as + the CPU scalar path does in `convolution_internal.h`. Verified by + exhaustive enumeration over dims 1..299 across the full tile span -- + always in range, always terminating, and bit-identical to the single + bounce wherever one bounce already sufficed, so no in-contract score + moves. The host-side guard comments were rewritten: they had claimed + the 3x3 floor was what kept the kernel in bounds, which was wrong. + - **`integer_motion_v2.metal` was the last backend still using the + wrong reflection convention.** Its `mv2_mirror` used + `2 * sup - idx - 1`, which reflects `idx == sup` back to `sup - 1` and + so REPEATS the boundary row; reflect-101 skips it + (`2 * (sup - 1) - idx`). CPU (`integer_motion_v2.c::mirror`), CUDA + (fixed in PR #120 / T7-15), SYCL and HIP all carry the corrected + form, and the SYCL fix records the measured impact of the `- 1` form + as a systematic ~2.6e-3 motion drift vs CPU on every frame after the + first. Metal now matches (ADR-0214 places=4). The ADM kernels' + `2 * sup - idx - 1` was checked and deliberately left alone: ADM + legitimately uses whole-sample reflection, matching + `adm_tools.c::dwt2_src_indices_filt_s`, the CUDA + `calculate_indices()` and the SYCL twin. + - **All four GPU `float_vif` backends were missing the CPU's minimum + dimension.** The CPU floor became `vif_get_min_dim(kernelscale)` = 16 + at the default kernelscale earlier in this PR (the binding constraint + is scale 3, `max(9, 10, 12, 16)`), but Metal's only check was + `scale_w[FVIF_SCALES - 1] == 0`, i.e. `w >> 3 == 0` -- an effective + floor of 8 -- and CUDA, HIP and SYCL had **no dimension floor at + all**, halving to scale 3 unchecked. All four now derive the floor + from `vif_get_min_dim()`, the same single source of truth the CPU + uses, so the 8..15px range that walks the reflect-101 mirror out of + the plane at scale 3 is rejected uniformly. `vif_tools.h` gained a + `extern "C"` guard, without which the C++ (SYCL) and Objective-C++ + (Metal) translation units would demand mangled symbols and fail to + link against the C `vif_tools.c`; it was previously included only by + C translation units. + - **`--help` and `--version` left the Windows console in UTF-8 + VT + mode.** The `WindowsConsoleGuard` added for Netflix/vmaf#743 was an + automatic local in `main`, and its comment claimed it restored "on + every exit path". It did not: `cli_parse` terminates the process + directly for `--help`, `--version` and every argument error + (`usage_exit` is `[[noreturn]]` and calls `exit()`), and `exit()` + does not destroy objects with automatic storage duration. Objects + with static storage duration ARE destroyed by `exit()` + ([basic.start.term]), so the guard is now `static` -- the restore + runs on the `exit()` paths, on the `goto cleanup` spine and on a + normal return alike. POSIX behaviour is unchanged (the whole block is + `#ifdef _WIN32`). + - **`scripts/ci/check-msvc-clz-shim.sh` was evadable by macro + indirection.** Rules (1) and (4) keyed on the call syntax + `__lzcnt(`, so `#define LZ __lzcnt` followed by `LZ(x)` -- or token + pasting -- reintroduced the instruction while still passing the gate + that exists to prevent exactly that. Both rules now match the bare + identifier. Rule (4) is scoped to source extensions because + `core/src/feature/AGENTS.md` legitimately discusses `__lzcnt` in + prose. Negative-tested: macro indirection, a narrowed architecture + allowlist, and a direct `__lzcnt` reintroduction all fail the gate. + - **The static-link smoke test now links with the leg's own + compiler.** It used bare `cc` while the matrix builds with + `ccache gcc-14` / `ccache clang-22`, so it linked with a toolchain + the archive was not produced with. Now `${CC:-cc}`. The review's + accompanying LTO concern does not apply: `b_lto` is meson-default + false here and explicitly `false` on the SYCL/CUDA legs, so the + archive holds plain objects rather than LTO IR and no plugin + mismatch is in play. + - **Not a defect: the `Libs.private` libc++ detection.** The review + held that keying on `_LIBCPP_VERSION` ignores an explicit + `-stdlib=libc++`. Tested directly against the installed meson: a + probe project reading `cxx.get_define('FOO')` under + `-Dcpp_args=-DFOO=42` reports `42`, so compiler checks do observe + the project's `cpp_args` and the `_LIBCPP_VERSION` probe therefore + sees `-stdlib=libc++` exactly as its comment claims. No change made. + + - **VCQ-223**: `VmafQualityRunnerWithLocalExplainer` no longer times out CI. The runner's fallback `LocalExplainer` now defaults to `neighbor_samples=100` (previously the upstream default of 5 000 produced ~480 000 libsvm diff --git a/changelog.d/fixed/upstream-harvest.md b/changelog.d/fixed/upstream-harvest.md new file mode 100644 index 000000000..60cf7ec6d --- /dev/null +++ b/changelog.d/fixed/upstream-harvest.md @@ -0,0 +1,278 @@ +- **Upstream issue harvest (ADR-1166): nine stale Netflix/vmaf reports + verified against the fork and fixed, eight more recorded.** The fork + diverged far enough from upstream that an open report there is neither + automatically applicable nor automatically stale, so each candidate was + checked against this tree and reproduced before anything was changed. The + full triage table — including the ALREADY-FIXED and NOT-APPLICABLE + verdicts, which are the expensive ones to re-derive — is in + `docs/research/1166-upstream-issue-harvest-2026-09-03.md`. + + Fixed here: + + - **Netflix/vmaf#1582 — two out-of-bounds accesses in the float + convolution, both reachable from the public C API today.** The + reflect-101 mirror in `convolution_edge_s` / `_sq_s` / `_xy_s` bounced an + out-of-range tap exactly once, which only lands in range when the plane + is at least `radius + 1` across; below that it fell out the opposite + side (heap-buffer-overflow READ). Separately, + `convolution_x_c_s` / `convolution_y_c_s` derived the trailing border + bound as `dim - (filter_width - radius)`, which goes negative for a + plane narrower than the filter, so the trailing loop started at a + negative index and wrote `dst[i * dst_stride - 1]` — a heap underflow + WRITE. Two live paths reached the defective sizes: `--feature float_vif` + on any frame in 9..15 px (the guard admitted `>= 9`, but the four-scale + ladder needs `>= 16` — the binding constraint is scale 3), and + `--feature float_motion` with `motion_add_uv=true` on a 4x4 YUV420P + frame (the guard validated luma only, while the blur runs per plane at + the 2x2 chroma dimensions). The mirror now folds iteratively — bit- + identical to the single bounce for every in-contract size, pinned by a + bit-equality test at 24x24 — the borders are clamped, `float_vif` + derives its minimum from `vif_get_min_dim(kernelscale)`, and + `float_motion` validates every plane it will convolve. + - **Netflix/vmaf#1581 — same mirror, motion extractors.** The motion + extractors' own single-bounce `mirror()` bodies are deliberately left + alone: they sit downstream of an `init()` guard that rejects + `w < 3 || h < 3`, so the defective sizes never reach them. That is a + deliberate divergence from upstream, which instead fixes `mirror()` so + tiny frames can be scored; see `docs/rebase-notes.md`. + - **Netflix/vmaf#1580 — the three fork-added Metal motion extractors had + no dimension guard at all.** `motion_metal`, `motion_v2_metal` and + `float_motion_metal` are registered and shipped, so a 1- or 2-pixel-tall + frame read out of bounds on device. They now carry the same min-dim + guard as the CPU, CUDA, SYCL and HIP twins, placed before the Metal + context is created so no cleanup path is needed. + - **Netflix/vmaf#1242 — `VmafFeatureDictionary` ownership.** + `vmaf_model_feature_overload()` returned `-ENOMEM` straight out of its + loop and skipped the unconditional free at the tail, leaking the + caller's dictionary; `vmaf_model_collection_feature_overload()` + discarded the copy's return value, leaked the partial copy, silently + skipped the remaining sub-models, and dereferenced `*model_collection` + without checking it. `` and `` also + documented **opposite** ownership rules, which made one of the two + readings a latent double free for any third-party caller. All three + public headers now state the implemented contract identically: the + dictionary is consumed on every path except the argument-validation + guards, where the caller still owns it. ADR-0806 is superseded. + - **Netflix/vmaf#743 — the CLI progress spinner was mojibake on Windows.** + The UTF-8 braille table went to stderr through a byte-oriented + `fprintf` while nothing in the tree ever set the console output code + page or enabled VT processing, so under cp437 the two glyphs rendered as + six garbage characters (widening the line past the `\r` overwrite), + under cp936 as replacement boxes, and the `\033[K` erase printed + literally on legacy conhost. The CLI now switches the console to UTF-8 + + VT for the run and restores the previous state on exit, and falls back + to an ASCII spinner and space padding when the console refuses either. + POSIX output is byte-identical. + - **Netflix/vmaf#1551 (retracting Netflix/vmaf#1422) — the MSVC + `__builtin_clz` shim emitted LZCNT.** MSVC's `__lzcnt` emits + `F3 0F BD` with no runtime feature gate; on an x86-64 without ABM/LZCNT + the prefix is ignored and the instruction retires as BSR, returning the + MSB index instead of the leading-zero count. Two of the four call sites + are on the generic scalar path, so an MSVC-built `vmaf.exe` on + pre-Haswell hardware silently mis-normalised every VIF and ADM log2 (a + 2048-LSB error, i.e. a factor of two in the VIF fixed point) and shifted + by a negative count for large inputs — no fault, no diagnostic, and + invisible to CI because every hosted Windows runner has LZCNT. The shim + now uses `_BitScanReverse`, which is BSR by definition and present on + every x86-64 part, and carries an architecture guard so an MSVC ARM64 + leg compiles. `scripts/ci/check-msvc-clz-shim.sh` keeps the intrinsic + from coming back. + - **Netflix/vmaf#1178 — `libvmaf.pc` omitted the C++ runtime.** + `pkg-config --static --libs libvmaf` reported only `-pthread -lm`, so + linking the static archive failed with hundreds of undefined references + to `operator new` / `std::ios_base::ios_base()` — the fork is more + exposed than upstream because the C++ symbols come from its own + converted translation units, not just vendored libsvm. Downstream + fully-static FFmpeg builds had to add `-lstdc++` by hand (see + ADR-0198). `Libs.private` now carries the C++ runtime, detected from + the STL actually in use (`_LIBCPP_VERSION`) rather than from the + compiler id, and the CI check performs a real link instead of grepping + the flag list. + - **Netflix/vmaf#1573 — two build-system defects.** The nvcc fatbin + include list used relative paths, which only resolve when the build + directory is a direct child of `core/`; since ADR-0700 the layout the + docs themselves use put it elsewhere, and every `.cu` failed with + `fatal error: cuda/integer_adm_cuda.h: No such file or directory`. And + the three shell-driven tool tests declared no `depends`, so running one + as a subset (`meson test test_vmaf_cuda_gpumask`) built nothing and the + script died with exit 127. + + Regression tests: `core/test/test_convolution_edge_small.c`, + `core/test/test_compat_clz.c`, + `core/test/test_model_feature_overload_ownership.c`, + `core/test/test_spinner.cpp`, `scripts/ci/check-msvc-clz-shim.sh`, plus + extended cases in `core/test/test_motion_min_dim.c` and + `core/test/test_float_vif_min_dim.c`. Netflix golden scores unchanged + (76.66744 / 35.070245 / 7.985956; 271 passed, 12 skipped). + + Behaviour change to note: `float_vif` now rejects frames below 16 px in + either dimension (it previously accepted 9 px and read out of bounds at + scale 3), and `float_motion` with `motion_add_uv` now rejects frames whose + chroma planes fall below the filter minimum. Both convert previously + undefined behaviour into a documented `-EINVAL`. + + Post-review corrections (three defects an independent adversarial review + found in the harvest itself, all reproduced before fixing): + + - **The #1582 border clamp landed only on the scalar path.** + `convolution_f32_c_s` dispatches to `convolution_f32_avx_s` whenever + AVX2 is present — every CI runner and the dev workstation — so the + clamp was dead code on x86. The AVX2 and AVX-512 twins derive the same + `height - radius` split at three sites each and kept it unclamped: for a + plane shorter than the radius that is negative, so the trailing border + loop starts at a negative row and the leading one runs past the end. + Both are heap **writes**. All six sites now share the scalar clamp, + which moved into `convolution_internal.h`. + - **`motion_filter_size=1` bypassed the minimum-dimension guard.** + `motion_check_min_dim` gated the whole check on + `effective_filter_size > 1`, but `motion_blur_plane` keeps + `filter_size = 5` for that value and only swaps in the no-op + coefficients, so radius stays 2. A 1-row plane therefore reached the + convolution above through a documented public option (range 0..9). The + guard now mirrors `motion_blur_plane` exactly. + - **Odd-height 4:2:0 chroma planes were under-allocated by one row.** + `motion_chroma_heights` used `h / 2` while `picture.c` and the guard + both use the ceiling `(h + 1) >> 1`, so `motion_copy_and_blur` overran + `ref`, `tmp` and every blur-ring buffer for both U and V. Even heights + were unaffected, which is why the golden fixtures never caught it. + + Regression test `core/test/test_motion_convolution_oob.c` drives + `float_motion` through the public `vmaf_read_pictures` entry point, + because neither existing test could reach the dispatched SIMD path: + `test_motion_min_dim` only calls `init()`, and + `test_convolution_edge_small` calls the scalar kernels directly. Verified + both ways — the new test fails on the pre-fix tree, and under + `-Db_sanitize=address` the pre-fix tree reports + `heap-buffer-overflow ... WRITE of size 4 in convolution_f32_avx_s` + reached from `vmaf_read_pictures`. + + Second post-review round — the `VmafFeatureDictionary` ownership contract + (Netflix/vmaf#1242) was still stated three different ways, and one of them + was a double free: + + - `` and `docs/api/index.md` claimed that an unknown + `feature_name` never consumes the dictionary. That is true of + `vmaf_use_feature`, which resolves the name against the global extractor + registry and returns `-EINVAL` before touching it — but **not** of + `vmaf_model_feature_overload`, which matches against the features of one + particular model. A name matching nothing there is a successful no-op + returning `0`, and the dictionary is consumed anyway. A caller following + the old wording would double-free. All three headers and the API page now + state the asymmetry explicitly, and `` no longer claims + its rule "matches `vmaf_use_feature`". + - `vmaf_use_feature` leaked the caller's dictionary on two failure paths — + a failed `vmaf_dictionary_copy`, and a failed + `vmaf_feature_extractor_context_create`, which frees only what it + allocated. Both leaked exactly when the documented contract told the + caller not to free. Both now release it. + + Two cases added to `core/test/test_model_feature_overload_ownership.c` pin + the asymmetry from both sides, and pass under `-Db_sanitize=address`. + + Third post-review round — the MSVC clz shim's architecture allowlist + excluded the one platform it was introduced to protect: + + - **The `_M_X64 || _M_IX86` guard locked MSVC ARM64 out of the shim + entirely.** Both the header comment and + `scripts/ci/check-msvc-clz-shim.sh` justified the exclusion by + asserting that `_BitScanReverse` is x86-only. The MSVC intrinsics + reference states the opposite: `_BitScanReverse` is available on x86, + ARM, x64 **and** ARM64, and only `_BitScanReverse64` is restricted (to + x64 and ARM64). Since this header is the sole definition of + `__builtin_clz` for `integer_adm.c` and `integer_vif.h` — generic + scalar code compiled for every target — an MSVC ARM64 build fell + through the guard with no definition at all and could not compile. + Nothing observed it because the fork has no MSVC ARM64 CI leg. The + allowlist now enumerates every architecture MSVC targets, + `_BitScanReverse64` is used on x64 and ARM64 with the two-step 32-bit + reconstruction elsewhere, and the gate pins the ARM64 arm so it cannot + be narrowed again. Both the narrowing and the `__lzcnt` reintroduction + were negative-tested against the gate. + + Fourth post-review round — the adversarial review's remaining findings, + each checked against the code before acting. Two were confirmed as + defects in this PR's own earlier rounds, two were pre-existing + cross-backend gaps, one was a real evasion hole in a gate this PR added, + and one did not hold up: + + - **The Metal motion guard added in round 3 was insufficient, and the + real defect was in the kernel.** `integer_motion.metal`, + `float_motion.metal` and `integer_motion_v2.metal` load a + `TILE_W x TILE_H = 20x20` threadgroup tile at origin `bid * 16 - 2`, + so their mirror helper receives indices up to `16*bid + 17` -- far + outside the 5-tap neighbourhood it appears to serve. A single bounce + only lands in range when `idx <= 2 * (sup - 1)`. Enumerating the real + tile span, the single-bounce form read out of bounds for **every + dimension in 1..9 and for exactly 17** (at 17 the last workgroup + reaches idx 33 while `2 * (17 - 1) = 32`, folding to -1). The + `w < 3 || h < 3` guard closes neither the 4..9 range nor 17, so the + fix moved into the kernels: all three now fold iteratively, exactly as + the CPU scalar path does in `convolution_internal.h`. Verified by + exhaustive enumeration over dims 1..299 across the full tile span -- + always in range, always terminating, and bit-identical to the single + bounce wherever one bounce already sufficed, so no in-contract score + moves. The host-side guard comments were rewritten: they had claimed + the 3x3 floor was what kept the kernel in bounds, which was wrong. + - **`integer_motion_v2.metal` was the last backend still using the + wrong reflection convention.** Its `mv2_mirror` used + `2 * sup - idx - 1`, which reflects `idx == sup` back to `sup - 1` and + so REPEATS the boundary row; reflect-101 skips it + (`2 * (sup - 1) - idx`). CPU (`integer_motion_v2.c::mirror`), CUDA + (fixed in PR #120 / T7-15), SYCL and HIP all carry the corrected + form, and the SYCL fix records the measured impact of the `- 1` form + as a systematic ~2.6e-3 motion drift vs CPU on every frame after the + first. Metal now matches (ADR-0214 places=4). The ADM kernels' + `2 * sup - idx - 1` was checked and deliberately left alone: ADM + legitimately uses whole-sample reflection, matching + `adm_tools.c::dwt2_src_indices_filt_s`, the CUDA + `calculate_indices()` and the SYCL twin. + - **All four GPU `float_vif` backends were missing the CPU's minimum + dimension.** The CPU floor became `vif_get_min_dim(kernelscale)` = 16 + at the default kernelscale earlier in this PR (the binding constraint + is scale 3, `max(9, 10, 12, 16)`), but Metal's only check was + `scale_w[FVIF_SCALES - 1] == 0`, i.e. `w >> 3 == 0` -- an effective + floor of 8 -- and CUDA, HIP and SYCL had **no dimension floor at + all**, halving to scale 3 unchecked. All four now derive the floor + from `vif_get_min_dim()`, the same single source of truth the CPU + uses, so the 8..15px range that walks the reflect-101 mirror out of + the plane at scale 3 is rejected uniformly. `vif_tools.h` gained a + `extern "C"` guard, without which the C++ (SYCL) and Objective-C++ + (Metal) translation units would demand mangled symbols and fail to + link against the C `vif_tools.c`; it was previously included only by + C translation units. + - **`--help` and `--version` left the Windows console in UTF-8 + VT + mode.** The `WindowsConsoleGuard` added for Netflix/vmaf#743 was an + automatic local in `main`, and its comment claimed it restored "on + every exit path". It did not: `cli_parse` terminates the process + directly for `--help`, `--version` and every argument error + (`usage_exit` is `[[noreturn]]` and calls `exit()`), and `exit()` + does not destroy objects with automatic storage duration. Objects + with static storage duration ARE destroyed by `exit()` + ([basic.start.term]), so the guard is now `static` -- the restore + runs on the `exit()` paths, on the `goto cleanup` spine and on a + normal return alike. POSIX behaviour is unchanged (the whole block is + `#ifdef _WIN32`). + - **`scripts/ci/check-msvc-clz-shim.sh` was evadable by macro + indirection.** Rules (1) and (4) keyed on the call syntax + `__lzcnt(`, so `#define LZ __lzcnt` followed by `LZ(x)` -- or token + pasting -- reintroduced the instruction while still passing the gate + that exists to prevent exactly that. Both rules now match the bare + identifier. Rule (4) is scoped to source extensions because + `core/src/feature/AGENTS.md` legitimately discusses `__lzcnt` in + prose. Negative-tested: macro indirection, a narrowed architecture + allowlist, and a direct `__lzcnt` reintroduction all fail the gate. + - **The static-link smoke test now links with the leg's own + compiler.** It used bare `cc` while the matrix builds with + `ccache gcc-14` / `ccache clang-22`, so it linked with a toolchain + the archive was not produced with. Now `${CC:-cc}`. The review's + accompanying LTO concern does not apply: `b_lto` is meson-default + false here and explicitly `false` on the SYCL/CUDA legs, so the + archive holds plain objects rather than LTO IR and no plugin + mismatch is in play. + - **Not a defect: the `Libs.private` libc++ detection.** The review + held that keying on `_LIBCPP_VERSION` ignores an explicit + `-stdlib=libc++`. Tested directly against the installed meson: a + probe project reading `cxx.get_define('FOO')` under + `-Dcpp_args=-DFOO=42` reports `42`, so compiler checks do observe + the project's `cpp_args` and the `_LIBCPP_VERSION` probe therefore + sees `-stdlib=libc++` exactly as its comment claims. No change made. diff --git a/core/include/libvmaf/feature.h b/core/include/libvmaf/feature.h index 6245cb493..679fe5746 100644 --- a/core/include/libvmaf/feature.h +++ b/core/include/libvmaf/feature.h @@ -34,13 +34,35 @@ extern "C" { * are detected at set-time and stored in a normalised form so that * `"1"` / `"1.0"` / `" 1 "` compare equal downstream. * - * Ownership transfer rules: - * - On success of @ref vmaf_use_feature / @ref vmaf_model_feature_overload, - * ownership of the dictionary passes to the VmafContext / VmafModel and - * the caller MUST NOT free it. - * - On failure of those calls (non-zero return), the caller still owns the - * dictionary and is responsible for releasing it with + * Ownership transfer rules (identical for @ref vmaf_use_feature, + * @ref vmaf_model_feature_overload and + * @ref vmaf_model_collection_feature_overload; see Netflix/vmaf#1242, which + * reported the divergence these three headers used to carry): + * + * - **NULL-argument failures do not consume the dictionary.** If the call + * returns `-EINVAL` because a required argument was NULL, nothing was + * taken: the caller still owns the dictionary and must release it with * @ref vmaf_feature_dictionary_free. + * - **@ref vmaf_use_feature additionally does not consume when + * @p feature_name names no registered feature.** It looks the extractor up + * first and returns `-EINVAL` before touching the dictionary. + * - **Every other path consumes it.** Once those guards have passed, the call + * releases the dictionary internally — on success and on failure alike, + * including `-ENOMEM` from the merge/copy step — and the caller MUST NOT + * free it. + * + * Note the asymmetry in the third bullet, which is deliberate and was the + * divergence Netflix/vmaf#1242 reported. @ref vmaf_model_feature_overload and + * @ref vmaf_model_collection_feature_overload match @p feature_name against the + * features of a *particular model*. A name that matches nothing there is not an + * error — it is a successful no-op that returns `0` — and the dictionary is + * still consumed. Only @ref vmaf_use_feature, which resolves against the global + * extractor registry, can report an unknown name as `-EINVAL` and hand the + * dictionary back. + * + * In practice: free the dictionary yourself only when the call returned + * `-EINVAL` *and* you either passed a NULL argument or called + * @ref vmaf_use_feature. Otherwise never. */ typedef struct VmafFeatureDictionary VmafFeatureDictionary; diff --git a/core/include/libvmaf/libvmaf.h b/core/include/libvmaf/libvmaf.h index d3dce577c..3a11562ea 100644 --- a/core/include/libvmaf/libvmaf.h +++ b/core/include/libvmaf/libvmaf.h @@ -270,9 +270,15 @@ VMAF_EXPORT int vmaf_use_features_from_model_collection(VmafContext *vmaf, * Register specific feature extractor. * Useful when a specific/additional feature is required, usually one which * is not already provided by a model via `vmaf_use_features_from_model()`. - * This may be called multiple times. `VmafContext` will take ownership of the - * `VmafFeatureDictionary` (`opts_dict`). Use `vmaf_feature_dictionary_free()` - * only in the case of failure. + * This may be called multiple times. `VmafContext` takes ownership of the + * `VmafFeatureDictionary` (`opts_dict`) on every path EXCEPT the + * argument-validation guards: if this returns `-EINVAL` because `vmaf` or + * `feature_name` was NULL, or because `feature_name` names no registered + * feature, nothing was consumed and the caller must release the dictionary + * with `vmaf_feature_dictionary_free()`. On any other return — success or + * failure — the dictionary has already been released internally and the + * caller MUST NOT free it. See for the shared contract + * (Netflix/vmaf#1242). * * @param vmaf The VMAF context allocated with `vmaf_init()`. * diff --git a/core/include/libvmaf/model.h b/core/include/libvmaf/model.h index 355b44b57..de0abb662 100644 --- a/core/include/libvmaf/model.h +++ b/core/include/libvmaf/model.h @@ -173,10 +173,23 @@ VMAF_EXPORT int vmaf_model_load_from_path(VmafModel **model, VmafModelConfig *cf * existing options. Useful when the caller wants to tweak (for example) the * VIF enhancement gain limit without editing the model JSON on disk. * - * On both success and failure, ownership of @p opts_dict transfers to this - * call — the function releases the dictionary internally before returning. - * The caller MUST NOT call @ref vmaf_feature_dictionary_free on @p opts_dict - * after invoking this function, even on a non-zero return. + * Ownership of @p opts_dict transfers to this call on every path EXCEPT the + * argument-validation guards: if the function returns `-EINVAL` because + * @p model, @p feature_name or @p opts_dict was NULL, nothing was consumed and + * the caller still owns the dictionary. On any other return — success, or + * `-ENOMEM` from the merge step — the function has released the dictionary + * internally and the caller MUST NOT call + * @ref vmaf_feature_dictionary_free on it. + * + * This is NOT quite the same rule as @ref vmaf_use_feature, and the difference + * is deliberate. That function resolves @p feature_name against the global + * extractor registry, so it can reject an unknown name with `-EINVAL` before + * touching the dictionary and hand it back. This one matches @p feature_name + * against the features of a *particular model*: a name that matches nothing is + * not an error but a successful no-op returning `0`, and the dictionary is + * still consumed. See for the contract covering all three + * entry points (Netflix/vmaf#1242 reported the headers contradicting each + * other; the `-ENOMEM` leak it described is fixed). * * @param model Loaded model from @ref vmaf_model_load or * @ref vmaf_model_load_from_path. Must not be NULL. @@ -342,10 +355,15 @@ VMAF_EXPORT int vmaf_model_collection_load_from_path(VmafModel **model, * the collection plus the lead model @p model, so a single override * propagates to the ensemble. * - * Ownership of @p opts_dict transfers to this call — the function deep-copies - * the dictionary onto each sub-model and releases the original (and every - * temporary copy on failure) before returning. The caller MUST NOT call - * @ref vmaf_feature_dictionary_free on @p opts_dict afterwards. + * Ownership of @p opts_dict transfers to this call on every path EXCEPT the + * argument-validation guards: if the function returns `-EINVAL` because + * @p model, @p model_collection, @p feature_name or @p opts_dict was NULL, + * nothing was consumed and the caller still owns the dictionary. Otherwise + * the function deep-copies the dictionary onto each sub-model and releases the + * original (and every temporary copy, including a partially-built one on an + * allocation failure) before returning, and the caller MUST NOT call + * @ref vmaf_feature_dictionary_free on @p opts_dict afterwards. Same rule as + * @ref vmaf_model_feature_overload; see . * * @param model Lead model returned by * @ref vmaf_model_collection_load / diff --git a/core/src/feature/AGENTS.md b/core/src/feature/AGENTS.md index 7c3e6d63e..2a2900aa6 100644 --- a/core/src/feature/AGENTS.md +++ b/core/src/feature/AGENTS.md @@ -1198,3 +1198,109 @@ after a port-upstream of any of these files. (`a=1/256`, `b_Y=-5.4715e-3`, `c_Y=1.91`) regenerate the official lookup table to 8 dp. Oracle values in `core/test/test_y_funque_plus.c` were re-derived against a `pywt` + OpenCV reference at places=4. + +## Reflect-101 mirror padding — invariants (ADR-1166) + +The separable float convolution in `common/convolution_internal.h` uses +**reflect-101** mirror padding, and the fold is deliberately **iterative**: + +```c +FORCE_INLINE int convolution_reflect101(int idx, int size) +{ + if (size <= 1) return 0; + while (idx < 0 || idx >= size) + idx = (idx < 0) ? -idx : (2 * size - idx - 2); + return idx; +} +``` + +Load-bearing details a rebase or a "simplification" must not break: + +1. **The loop is not decoration.** Upstream (and this fork, before ADR-1166) + bounced once. One bounce only lands in range when `size >= radius + 1`; + at `size == 2` a tap of `-2` folds to `+2` and a tap of `+3` folds to `-1`, + and the caller dereferences out of bounds. Two live CPU paths reached those + sizes — `float_vif` on 9..15 px frames and `float_motion` with + `motion_add_uv` on 4x4 4:2:0 chroma. Do not collapse it back to an + `if/else if`. +2. **The `size <= 1` short circuit is required for termination**, not just for + correctness: at `size == 1` the fold alternates between `-2` and `+2` + forever. +3. **The fold is bit-identical to the single bounce for every in-contract + size** (the loop exits on the first iteration), which is what lets this be a + pure safety fix with no score movement. + `core/test/test_convolution_edge_small.c::test_large_plane_bit_identical` + pins that against an explicit single-bounce reference; if you change the + fold, that test must still pass unmodified. +4. **`convolution.c`'s `convolution_clamp_borders()` is load-bearing too.** + `borders_right` / `borders_bottom` are derived as + `dim - (filter_width - radius)` and go **negative** for a plane narrower + than the filter, which makes the trailing border loop start at a negative + index and write before the destination. The clamp is a no-op for every + `dim >= filter_width`. + +The motion extractors' own `mirror()` bodies (`integer_motion.c`, +`integer_motion_v2.c`, `x86/motion_avx2.c`, `x86/motion_avx512.c`, +`arm64/motion_v2_neon.c`, and the CUDA / HIP / Metal twins) are **still +single-bounce on purpose**: they sit behind an `init()` guard that rejects +`w < 3 || h < 3`, so the defective sizes are unreachable. That is a deliberate +divergence from Netflix/vmaf#1581, which instead fixes `mirror()` so tiny +frames can be scored. Changing it is a behaviour decision, not a cleanup — +see `docs/rebase-notes.md`. + +## Minimum-dimension guards cover every plane, not just luma (ADR-1166) + +`float_motion.c::motion_check_min_dim_all_planes` validates the **chroma** +dimensions too when `motion_add_uv` is set, because `motion_blur_plane` is +called per plane with `ref_pic->w[c]` / `ref_pic->h[c]`. The chroma geometry +must stay in step with `core/src/picture.c` (`(dim + ss) >> ss`); a luma-only +guard is exactly the bug Netflix/vmaf#1582 describes. + +`float_vif.c`'s guard is derived from `vif_get_min_dim(kernelscale)` — the +largest `((filter_width_s / 2) + 1) << s` over the four-scale ladder, 16 at the +default kernelscale — not from the scale-0 filter alone. Do not replace it with +a constant. + +## `compat_builtin.h`: never `__lzcnt` (ADR-1166) + +The MSVC `__builtin_clz` / `__builtin_clzll` shim must use `_BitScanReverse` / +`_BitScanReverse64`. `__lzcnt` emits the LZCNT instruction unconditionally with +no runtime feature gate; on an x86-64 without ABM/LZCNT the `F3` prefix is +ignored and it retires as BSR, returning the MSB index instead of the +leading-zero count — silently wrong VIF and ADM shifts, with no fault and no CI +signal (every hosted Windows runner has LZCNT). Netflix/vmaf#1422 proposes the +`__lzcnt` form; Netflix/vmaf#1551 is upstream's own retraction of it. +`scripts/ci/check-msvc-clz-shim.sh` fails the `fast` suite if it comes back. + +## `convolution_f32_c_s` dispatches to SIMD — fix the twins, not just the scalar + +`core/src/feature/common/convolution.c::convolution_f32_c_s` returns straight +into `convolution_f32_avx_s` whenever `VMAF_X86_CPU_FLAG_AVX2` is set. That is +every CI runner and the dev workstation. **A fix applied only to the scalar +body in `convolution.c` is dead code on x86.** + +The AVX2 (`convolution_avx.c`) and AVX-512 (`convolution_avx512.c`) twins each +derive the same vertical border split — `radius` and `height - radius` — at +three sites apiece, once per kernel variant (`_s`, `_sq_s`, `_xy_s`). Six sites +total. All of them must stay clamped via `convolution_clamp_borders` in +`convolution_internal.h`: for a plane shorter than the radius, `height - radius` +is negative, so the trailing border loop starts at a negative row and the +leading one runs past the end. Both are heap **writes**, not reads. + +**Testing the scalar kernel does not test this.** +`core/test/test_convolution_edge_small.c` calls `convolution_y_c_s` / +`convolution_x_c_s` directly and so never reaches the dispatch; +`test_motion_min_dim.c` only calls `init()`. Anything asserting the convolution +is safe at small sizes must go through the public API — see +`core/test/test_motion_convolution_oob.c`. + +**A guard must mirror the kernel it protects, not the option that named it.** +`motion_blur_plane` keeps `filter_size = 5` for `motion_filter_size == 1` and +merely swaps in `FILTER_5_NO_OP_s`, so the radius is 2 regardless. A guard that +reads the option value instead of the filter width the kernel actually uses +will let the defective sizes through. + +**Chroma plane geometry is the ceiling, `(dim + ss) >> ss`, matching +`picture.c`.** Using `h / 2` under-allocates by one row for every odd luma +height, and even-height fixtures — including both Netflix golden resolutions — +never catch it. diff --git a/core/src/feature/common/convolution.c b/core/src/feature/common/convolution.c index c5fa3a896..3d10e69ff 100644 --- a/core/src/feature/common/convolution.c +++ b/core/src/feature/common/convolution.c @@ -22,15 +22,29 @@ #include "convolution_internal.h" #include "cpu.h" -extern int vmaf_floorn(int, int); -extern int vmaf_ceiln(int, int); - +/* + * Clamp the border/interior split into [0, dim]. + * + * `borders_hi` is derived as `dim - (filter_width - radius)`, which goes + * NEGATIVE as soon as the plane is narrower/shorter than the filter. The + * trailing border loop then starts at a negative index and writes + * `dst[i * dst_stride - 1]` / `dst[-dst_stride + j]` — a heap underflow + * WRITE, reported upstream as Netflix/vmaf#1582. `borders_lo` can likewise + * exceed the dimension (ceil(radius) > dim), which walks the leading border + * loop past the end of the row. + * + * Clamping leaves every in-contract size untouched (for dim >= filter_width + * neither bound is out of range) and additionally removes the duplicate + * recomputation that happens when the two border bands would otherwise + * overlap. + */ void convolution_x_c_s(const float *filter, int filter_width, const float *src, float *dst, int width, int height, int src_stride, int dst_stride, int step) { int radius = filter_width / 2; int borders_left = vmaf_ceiln(radius, step); int borders_right = vmaf_floorn(width - (filter_width - radius), step); + convolution_clamp_borders(width, &borders_left, &borders_right); for (int i = 0; i < height; ++i) { for (int j = 0; j < borders_left; j += step) { @@ -59,6 +73,7 @@ void convolution_y_c_s(const float *filter, int filter_width, const float *src, int radius = filter_width / 2; int borders_top = vmaf_ceiln(radius, step); int borders_bottom = vmaf_floorn(height - (filter_width - radius), step); + convolution_clamp_borders(height, &borders_top, &borders_bottom); for (int i = 0; i < borders_top; i += step) { for (int j = 0; j < width; ++j) { diff --git a/core/src/feature/common/convolution.h b/core/src/feature/common/convolution.h index cff4234f5..151a0d2d4 100644 --- a/core/src/feature/common/convolution.h +++ b/core/src/feature/common/convolution.h @@ -43,6 +43,16 @@ Filter widths above this one will not use the AVX path for convolutions. void convolution_f32_c_s(const float *filter, int filter_width, const float *src, float *dst, float *tmp, int width, int height, int src_stride, int dst_stride); +/* Scalar single-axis passes. Exposed so the border/mirror regression test can + * drive them directly without going through the runtime SIMD dispatch in + * convolution_f32_c_s; they carry external linkage either way, so declaring + * them here also silences -Wmissing-prototypes. */ +void convolution_x_c_s(const float *filter, int filter_width, const float *src, float *dst, + int width, int height, int src_stride, int dst_stride, int step); + +void convolution_y_c_s(const float *filter, int filter_width, const float *src, float *dst, + int width, int height, int src_stride, int dst_stride, int step); + /* AVX2 paths (256-bit, 8 floats per FMA). */ void convolution_f32_avx_s(const float *filter, int filter_width, const float *src, float *dst, float *tmp, int width, int height, int src_stride, int dst_stride); diff --git a/core/src/feature/common/convolution_avx.c b/core/src/feature/common/convolution_avx.c index ecfed1cb3..e7fd423be 100644 --- a/core/src/feature/common/convolution_avx.c +++ b/core/src/feature/common/convolution_avx.c @@ -150,7 +150,12 @@ void convolution_f32_avx_s(const float *RESTRICT filter, int filter_width, int width_floor_step = vmaf_floorn(width, AVX_STEP); int tmp_stride = vmaf_ceiln(width, AVX_STEP); + /* Clamp the vertical border split to the plane -- rationale and the + * negative-`height - radius` failure mode are documented on + * convolution_clamp_borders() in convolution_internal.h. */ + int i_border_top = radius; int i_vec_end = height - radius; + convolution_clamp_borders(height, &i_border_top, &i_vec_end); int j_vec_end = vmaf_floorn(width - radius, AVX_STEP); const ptrdiff_t src_pdt = (ptrdiff_t)src_stride; @@ -158,7 +163,7 @@ void convolution_f32_avx_s(const float *RESTRICT filter, int filter_width, const ptrdiff_t tmp_pdt = (ptrdiff_t)tmp_stride; // Vertical pass. - for (int i = 0; i < radius; ++i) { + for (int i = 0; i < i_border_top; ++i) { for (int j = 0; j < width; ++j) { tmp[(ptrdiff_t)i * tmp_pdt + j] = convolution_edge_s(false, filter, filter_width, src, width, height, src_stride, i, j); @@ -206,7 +211,12 @@ void convolution_f32_avx_sq_s(const float *RESTRICT filter, int filter_width, int width_floor_step = vmaf_floorn(width, AVX_STEP); int tmp_stride = vmaf_ceiln(width, AVX_STEP); + /* Clamp the vertical border split to the plane -- rationale and the + * negative-`height - radius` failure mode are documented on + * convolution_clamp_borders() in convolution_internal.h. */ + int i_border_top = radius; int i_vec_end = height - radius; + convolution_clamp_borders(height, &i_border_top, &i_vec_end); int j_vec_end = vmaf_floorn(width - radius, AVX_STEP); const ptrdiff_t src_pdt = (ptrdiff_t)src_stride; @@ -214,7 +224,7 @@ void convolution_f32_avx_sq_s(const float *RESTRICT filter, int filter_width, const ptrdiff_t tmp_pdt = (ptrdiff_t)tmp_stride; // Vertical pass. - for (int i = 0; i < radius; ++i) { + for (int i = 0; i < i_border_top; ++i) { for (int j = 0; j < width; ++j) { tmp[(ptrdiff_t)i * tmp_pdt + j] = convolution_edge_sq_s( false, filter, filter_width, src, width, height, src_stride, i, j); @@ -263,7 +273,12 @@ void convolution_f32_avx_xy_s(const float *RESTRICT filter, int filter_width, int width_floor_step = vmaf_floorn(width, AVX_STEP); int tmp_stride = vmaf_ceiln(width, AVX_STEP); + /* Clamp the vertical border split to the plane -- rationale and the + * negative-`height - radius` failure mode are documented on + * convolution_clamp_borders() in convolution_internal.h. */ + int i_border_top = radius; int i_vec_end = height - radius; + convolution_clamp_borders(height, &i_border_top, &i_vec_end); int j_vec_end = vmaf_floorn(width - radius, AVX_STEP); const ptrdiff_t src1_pdt = (ptrdiff_t)src1_stride; @@ -272,7 +287,7 @@ void convolution_f32_avx_xy_s(const float *RESTRICT filter, int filter_width, const ptrdiff_t tmp_pdt = (ptrdiff_t)tmp_stride; // Vertical pass. - for (int i = 0; i < radius; ++i) { + for (int i = 0; i < i_border_top; ++i) { for (int j = 0; j < width; ++j) { tmp[(ptrdiff_t)i * tmp_pdt + j] = convolution_edge_xy_s(false, filter, filter_width, src1, src2, width, height, diff --git a/core/src/feature/common/convolution_avx512.c b/core/src/feature/common/convolution_avx512.c index 6458644b9..d4c27cef6 100644 --- a/core/src/feature/common/convolution_avx512.c +++ b/core/src/feature/common/convolution_avx512.c @@ -191,7 +191,12 @@ void convolution_f32_avx512_s(const float *RESTRICT filter, int filter_width, int width_floor_step = vmaf_floorn(width, AVX512_STEP); int tmp_stride = vmaf_ceiln(width, AVX512_STEP); + /* Clamp the vertical border split to the plane -- rationale and the + * negative-`height - radius` failure mode are documented on + * convolution_clamp_borders() in convolution_internal.h. */ + int i_border_top = radius; int i_vec_end = height - radius; + convolution_clamp_borders(height, &i_border_top, &i_vec_end); int j_vec_end = vmaf_floorn(width - radius, AVX512_STEP); const ptrdiff_t src_pdt = (ptrdiff_t)src_stride; @@ -199,7 +204,7 @@ void convolution_f32_avx512_s(const float *RESTRICT filter, int filter_width, const ptrdiff_t tmp_pdt = (ptrdiff_t)tmp_stride; /* Vertical pass. */ - for (int i = 0; i < radius; ++i) { + for (int i = 0; i < i_border_top; ++i) { for (int j = 0; j < width; ++j) { tmp[(ptrdiff_t)i * tmp_pdt + j] = convolution_edge_s(false, filter, filter_width, src, width, height, src_stride, i, j); @@ -248,7 +253,12 @@ void convolution_f32_avx512_sq_s(const float *RESTRICT filter, int filter_width, int width_floor_step = vmaf_floorn(width, AVX512_STEP); int tmp_stride = vmaf_ceiln(width, AVX512_STEP); + /* Clamp the vertical border split to the plane -- rationale and the + * negative-`height - radius` failure mode are documented on + * convolution_clamp_borders() in convolution_internal.h. */ + int i_border_top = radius; int i_vec_end = height - radius; + convolution_clamp_borders(height, &i_border_top, &i_vec_end); int j_vec_end = vmaf_floorn(width - radius, AVX512_STEP); const ptrdiff_t src_pdt = (ptrdiff_t)src_stride; @@ -256,7 +266,7 @@ void convolution_f32_avx512_sq_s(const float *RESTRICT filter, int filter_width, const ptrdiff_t tmp_pdt = (ptrdiff_t)tmp_stride; /* Vertical pass. */ - for (int i = 0; i < radius; ++i) { + for (int i = 0; i < i_border_top; ++i) { for (int j = 0; j < width; ++j) { tmp[(ptrdiff_t)i * tmp_pdt + j] = convolution_edge_sq_s( false, filter, filter_width, src, width, height, src_stride, i, j); @@ -305,7 +315,12 @@ void convolution_f32_avx512_xy_s(const float *RESTRICT filter, int filter_width, int width_floor_step = vmaf_floorn(width, AVX512_STEP); int tmp_stride = vmaf_ceiln(width, AVX512_STEP); + /* Clamp the vertical border split to the plane -- rationale and the + * negative-`height - radius` failure mode are documented on + * convolution_clamp_borders() in convolution_internal.h. */ + int i_border_top = radius; int i_vec_end = height - radius; + convolution_clamp_borders(height, &i_border_top, &i_vec_end); int j_vec_end = vmaf_floorn(width - radius, AVX512_STEP); const ptrdiff_t src1_pdt = (ptrdiff_t)src1_stride; @@ -314,7 +329,7 @@ void convolution_f32_avx512_xy_s(const float *RESTRICT filter, int filter_width, const ptrdiff_t tmp_pdt = (ptrdiff_t)tmp_stride; /* Vertical pass. */ - for (int i = 0; i < radius; ++i) { + for (int i = 0; i < i_border_top; ++i) { for (int j = 0; j < width; ++j) { tmp[(ptrdiff_t)i * tmp_pdt + j] = convolution_edge_xy_s(false, filter, filter_width, src1, src2, width, height, diff --git a/core/src/feature/common/convolution_internal.h b/core/src/feature/common/convolution_internal.h index e299b0d22..cc996b42b 100644 --- a/core/src/feature/common/convolution_internal.h +++ b/core/src/feature/common/convolution_internal.h @@ -24,6 +24,37 @@ #include "macros.h" #include +/** + * Reflect-101 ("mirror without repeating the edge sample") index fold. + * + * Upstream carries a single-bounce form open-coded at every tap site: + * + * if (idx < 0) idx = -idx; + * else if (idx >= size) idx = size - (idx - size + 2); // == 2*size - idx - 2 + * + * One bounce is only sufficient when `size >= radius + 1`. For a smaller + * plane the bounced index lands outside the opposite edge and the caller + * dereferences out of bounds: at size == 2 a tap of -2 folds to +2 (>= size) + * and a tap of +3 folds to -1. Reported upstream as Netflix/vmaf#1582 and + * Netflix/vmaf#1581; reachable in this fork through `float_motion`'s + * `motion_add_uv` chroma planes and through `float_vif`'s multi-scale ladder. + * + * Folding repeatedly until the index is in range is bit-identical to the + * single bounce for every `size >= radius + 1` (the loop exits after the + * first iteration), so no in-contract score moves. `size <= 1` has no + * interior to reflect into and would not terminate, so it short-circuits + * to the only valid index. + */ +FORCE_INLINE int convolution_reflect101(int idx, int size) +{ + if (size <= 1) + return 0; + while (idx < 0 || idx >= size) { + idx = (idx < 0) ? -idx : (2 * size - idx - 2); + } + return idx; +} + FORCE_INLINE float convolution_edge_s(bool horizontal, const float *filter, int filter_width, const float *src, int width, int height, int stride, int i, int j) @@ -35,19 +66,13 @@ FORCE_INLINE float convolution_edge_s(bool horizontal, const float *filter, int int i_tap = horizontal ? i : i - radius + k; int j_tap = horizontal ? j - radius + k : j; - // Handle edges by mirroring. Upstream `41d42c9e` fixed the off-by-one: - // `+ 1` → `+ 2` makes the reflection match sample indexing correctly. - if (horizontal) { - if (j_tap < 0) - j_tap = -j_tap; - else if (j_tap >= width) - j_tap = width - (j_tap - width + 2); - } else { - if (i_tap < 0) - i_tap = -i_tap; - else if (i_tap >= height) - i_tap = height - (i_tap - height + 2); - } + // Handle edges by mirroring (reflect-101). The fold is iterative so + // that planes smaller than radius + 1 stay in bounds; see + // convolution_reflect101 above (Netflix/vmaf#1582). + if (horizontal) + j_tap = convolution_reflect101(j_tap, width); + else + i_tap = convolution_reflect101(i_tap, height); accum += filter[k] * src[i_tap * stride + j_tap]; } @@ -66,18 +91,13 @@ FORCE_INLINE float convolution_edge_sq_s(bool horizontal, const float *filter, i int i_tap = horizontal ? i : i - radius + k; int j_tap = horizontal ? j - radius + k : j; - // Handle edges by mirroring (upstream `41d42c9e` +1 → +2 bugfix). - if (horizontal) { - if (j_tap < 0) - j_tap = -j_tap; - else if (j_tap >= width) - j_tap = width - (j_tap - width + 2); - } else { - if (i_tap < 0) - i_tap = -i_tap; - else if (i_tap >= height) - i_tap = height - (i_tap - height + 2); - } + // Handle edges by mirroring (reflect-101). The fold is iterative so + // that planes smaller than radius + 1 stay in bounds; see + // convolution_reflect101 above (Netflix/vmaf#1582). + if (horizontal) + j_tap = convolution_reflect101(j_tap, width); + else + i_tap = convolution_reflect101(i_tap, height); src_val = src[i_tap * stride + j_tap]; accum += filter[k] * (src_val * src_val); } @@ -96,18 +116,13 @@ FORCE_INLINE float convolution_edge_xy_s(bool horizontal, const float *filter, i int i_tap = horizontal ? i : i - radius + k; int j_tap = horizontal ? j - radius + k : j; - // Handle edges by mirroring (upstream `41d42c9e` +1 → +2 bugfix). - if (horizontal) { - if (j_tap < 0) - j_tap = -j_tap; - else if (j_tap >= width) - j_tap = width - (j_tap - width + 2); - } else { - if (i_tap < 0) - i_tap = -i_tap; - else if (i_tap >= height) - i_tap = height - (i_tap - height + 2); - } + // Handle edges by mirroring (reflect-101). The fold is iterative so + // that planes smaller than radius + 1 stay in bounds; see + // convolution_reflect101 above (Netflix/vmaf#1582). + if (horizontal) + j_tap = convolution_reflect101(j_tap, width); + else + i_tap = convolution_reflect101(i_tap, height); src_val1 = src1[i_tap * stride1 + j_tap]; src_val2 = src2[i_tap * stride2 + j_tap]; accum += filter[k] * (src_val1 * src_val2); @@ -115,4 +130,22 @@ FORCE_INLINE float convolution_edge_xy_s(bool horizontal, const float *filter, i return accum; } +/* + * convolution_clamp_borders — bound a vertical/horizontal border split to the + * plane so the border loops cannot run past, or before, the buffer. + * + * Shared by the scalar path and by every AVX2 / AVX-512 twin. The SIMD kernels + * derive the same `radius` / `dim - radius` split and had the identical defect: + * for a plane shorter than the filter radius, `dim - radius` goes negative, so + * the trailing border loop starts at a negative row and the leading one runs + * past the end. Both are heap writes, not just reads. + */ +static inline void convolution_clamp_borders(int dim, int *borders_lo, int *borders_hi) +{ + if (*borders_lo > dim) + *borders_lo = dim; + if (*borders_hi < *borders_lo) + *borders_hi = *borders_lo; +} + #endif // CONVOLUTION_INTERNAL_H_ diff --git a/core/src/feature/compat_builtin.h b/core/src/feature/compat_builtin.h index ac4acd170..6085b9e0b 100644 --- a/core/src/feature/compat_builtin.h +++ b/core/src/feature/compat_builtin.h @@ -21,21 +21,95 @@ /* * MSVC (but not clang-cl) lacks GCC's __builtin_clz / __builtin_clzll. - * Provide drop-in replacements via the Win32 intrinsic __lzcnt / __lzcnt64, - * which the CPU exposes on every Haswell-era x86_64 target supported by the - * MSVC Windows GPU build-only CI legs. + * + * DO NOT implement these with `__lzcnt` / `__lzcnt64`, which is what this + * header used to do and what Netflix/vmaf#1422 proposes: MSVC emits the LZCNT + * instruction (`F3 0F BD`) unconditionally, with no runtime feature gate. On + * an x86-64 without ABM/LZCNT (Intel Core 2 through Ivy Bridge, AMD pre- + * Barcelona) the `F3` prefix is IGNORED and the encoding retires as BSR, + * returning the INDEX of the most-significant set bit instead of the + * leading-zero COUNT. No fault, no diagnostic — just silently wrong VIF and + * ADM fixed-point shifts: + * + * integer_vif.h::log2_32 k = 16 - clz(temp) -> off by 2048 LSBs, i.e. + * a factor of two in the + * VIF log2 fixed point + * integer_adm.c::get_best15_from32 k = 17 - clz(temp) -> shift by a + * negative count (UB) + * + * Both of those live on the GENERIC scalar path (no SIMD dispatch gate), so + * they run on every CPU an MSVC-built vmaf.exe lands on, while the CI runners + * are all LZCNT-capable and never see it. Netflix/vmaf#1551 is upstream's own + * retraction of the #1422 form. + * + * `_BitScanReverse` / `_BitScanReverse64` compile to BSR, which every x86-64 + * part has, so `31 - BSR` is correct everywhere and bit-identical to LZCNT on + * hardware that has it. The zero input returns 32 / 64 (GCC's + * `__builtin_clz(0)` is undefined; defining it here is strictly safer). + * + * The guard carries an explicit architecture allowlist. Per the MSVC intrinsics + * reference, `_BitScanReverse` is available on x86, ARM, x64 AND ARM64, while + * `_BitScanReverse64` is available on x64 and ARM64 only -- hence the two-step + * fallback that reconstructs a 64-bit count from two 32-bit scans on the + * remaining targets. `__lzcnt` by contrast really is x86-only, and is not used + * here at all (see above). + * + * The allowlist is deliberate rather than a bare `defined(_MSC_VER)`, and it + * must enumerate every architecture MSVC targets. This header supplies + * `__builtin_clz` for integer_adm.c and integer_vif.h, which sit on the GENERIC + * scalar path and are therefore compiled for every target: an MSVC + * architecture that falls through the guard gets no definition at all and + * fails to compile. That is exactly what happened to ARM64, which an earlier + * `_M_X64 || _M_IX86` test excluded on the incorrect premise that + * `_BitScanReverse` was x86-only. scripts/ci/check-msvc-clz-shim.sh pins the + * ARM64 arm of the allowlist so it cannot be dropped again. + * + * The names stay as-is: the four call sites (integer_vif.h, integer_adm.c, + * x86/adm_avx2.c, x86/adm_avx512.c) are upstream-verbatim and the rebase story + * depends on them keeping the `__builtin_clz` spelling (ADR-0141 §2). + */ +/* + * Leading-zero count derived from a most-significant-bit index — the shape + * `_BitScanReverse` / `_BitScanReverse64` return. Defined unconditionally + * (outside the MSVC guard) so the host CI can unit-test the arithmetic that + * distinguishes a correct shim from the BSR-vs-LZCNT confusion; see + * core/test/test_compat_clz.c. */ -#if defined(_MSC_VER) && !defined(__clang__) +static inline int vmaf_compat_clz32_from_msb(int found, unsigned msb_index) +{ + return found ? (int)(31u - msb_index) : 32; +} + +static inline int vmaf_compat_clz64_from_msb(int found, unsigned msb_index) +{ + return found ? (int)(63u - msb_index) : 64; +} + +#if defined(_MSC_VER) && !defined(__clang__) && \ + (defined(_M_X64) || defined(_M_IX86) || defined(_M_ARM64) || defined(_M_ARM64EC) || \ + defined(_M_ARM)) #include static inline int __builtin_clz(unsigned x) { - return (int)__lzcnt(x); + unsigned long idx = 0; + const int found = _BitScanReverse(&idx, (unsigned long)x) != 0; + return vmaf_compat_clz32_from_msb(found, (unsigned)idx); } static inline int __builtin_clzll(unsigned long long x) { - return (int)__lzcnt64(x); + unsigned long idx = 0; +#if defined(_M_X64) || defined(_M_ARM64) || defined(_M_ARM64EC) + const int found = _BitScanReverse64(&idx, x) != 0; + return vmaf_compat_clz64_from_msb(found, (unsigned)idx); +#else + if (_BitScanReverse(&idx, (unsigned long)(x >> 32)) != 0) + return vmaf_compat_clz32_from_msb(1, (unsigned)idx); + if (_BitScanReverse(&idx, (unsigned long)x) != 0) + return vmaf_compat_clz64_from_msb(1, (unsigned)idx); + return 64; +#endif } #endif diff --git a/core/src/feature/cuda/float_vif_cuda.c b/core/src/feature/cuda/float_vif_cuda.c index 552749dbf..0b1c42203 100644 --- a/core/src/feature/cuda/float_vif_cuda.c +++ b/core/src/feature/cuda/float_vif_cuda.c @@ -22,6 +22,8 @@ #include "feature_collector.h" #include "feature_extractor.h" #include "feature_name.h" +#include "vif_tools.h" +#include "log.h" #include "cuda/float_vif_cuda.h" #include "cuda/kernel_template.h" @@ -122,6 +124,23 @@ static int init_fex_cuda(VmafFeatureExtractor *fex, enum VmafPixelFormat pix_fmt if (s->vif_kernelscale != 1.0) return -EINVAL; + /* Cross-backend parity with the CPU floor (ADR-0214 places=4). The + * four-scale ladder halves the working dimension once per scale, so the + * binding constraint is scale 3 -- at the default kernelscale the minimum + * is 16, not 8. This backend previously had no dimension floor at all, admitting the + * 8..15px range that walks the reflect-101 mirror out of the plane at + * scale 3 (Netflix/vmaf#1582, the same defect fixed on the CPU path). + * vif_get_min_dim() is the single source of truth shared with + * float_vif.c; see its derivation in vif_tools.c. */ + const int vif_min_dim = vif_get_min_dim((float)s->vif_kernelscale); + if (w < (unsigned)vif_min_dim || h < (unsigned)vif_min_dim) { + vmaf_log(VMAF_LOG_LEVEL_ERROR, + "float_vif_cuda: width and height must be >= %d for the four-scale VIF " + "ladder (got %ux%u)\n", + vif_min_dim, w, h); + return -EINVAL; + } + s->width = w; s->height = h; s->bpc = bpc; diff --git a/core/src/feature/float_motion.c b/core/src/feature/float_motion.c index 5b64fe02e..f50cf8fdd 100644 --- a/core/src/feature/float_motion.c +++ b/core/src/feature/float_motion.c @@ -212,14 +212,23 @@ static double compute_motion_simd(const float *ref, const float *dis, int w, int } /* Chroma plane heights for the motion_add_uv buffers; -EINVAL when the pixel - * format carries no chroma planes. */ + * format carries no chroma planes. + * + * Must use picture.c's CEILING geometry, (h + ss_ver) >> ss_ver, not h / 2. + * The floor form under-allocated by one row for every odd luma height: a 4:2:0 + * frame of height 5 has 3 chroma rows in the VmafPicture but only 2 were + * allocated here, so motion_copy_and_blur overran ref, tmp and all + * MOTION_BLUR_RING blur buffers for both U and V. It agreed with neither + * picture.c nor motion_check_min_dim_all_planes, which is what let the guard + * pass a frame the allocation could not hold. Even heights were unaffected, + * which is why the golden fixtures never caught it. */ static int motion_chroma_heights(enum VmafPixelFormat pix_fmt, unsigned h, unsigned *h_u, unsigned *h_v) { switch (pix_fmt) { case VMAF_PIX_FMT_YUV420P: - *h_u = h / 2; - *h_v = h / 2; + *h_u = (h + 1u) >> 1u; + *h_v = (h + 1u) >> 1u; return 0; case VMAF_PIX_FMT_YUV422P: case VMAF_PIX_FMT_YUV444P: @@ -276,24 +285,83 @@ static void motion_free_planes(MotionState *s) * option has not been applied yet (e.g. in unit tests that manually * allocate priv) — treat it as the default. Refuse smaller frames * up front to prevent out-of-bounds reads in the convolution kernel. */ -static int motion_check_min_dim(const MotionState *s, unsigned w, unsigned h) +static int motion_check_min_dim(const MotionState *s, unsigned w, unsigned h, const char *plane) { - const int configured = - s->motion_filter_size > 0 ? s->motion_filter_size : DEFAULT_MOTION_FILTER_SIZE; - const unsigned effective_filter_size = (unsigned)configured; - if (effective_filter_size > 1u) { - const unsigned min_dim = effective_filter_size / 2u + 1u; - if (h < min_dim || w < min_dim) { - vmaf_log(VMAF_LOG_LEVEL_ERROR, - "float_motion: frame %ux%u is below the %u-tap filter minimum %ux%u; " - "refusing to avoid out-of-bounds mirror reads\n", - w, h, effective_filter_size, min_dim, min_dim); - return -EINVAL; - } + /* Mirror motion_blur_plane exactly. motion_filter_size == 1 selects the + * FILTER_5_NO_OP_s coefficients but keeps filter_size at 5, so the kernel + * still convolves with radius 2; only the 3-tap option narrows the filter, + * and every other value (including 0, "option not applied yet") uses 5. + * + * The previous `effective_filter_size > 1` gate skipped the check outright + * for motion_filter_size == 1, which let a plane as small as 1 row reach + * the convolution. That is a heap WRITE out of bounds in the AVX2/AVX-512 + * kernels, not merely a mirror read, and motion_filter_size is a + * documented public option (0..9), so the path was reachable. */ + const unsigned effective_filter_size = (s->motion_filter_size == 3) ? 3u : 5u; + const unsigned min_dim = effective_filter_size / 2u + 1u; + if (h < min_dim || w < min_dim) { + vmaf_log(VMAF_LOG_LEVEL_ERROR, + "float_motion: %s plane %ux%u is below the %u-tap filter minimum %ux%u; " + "refusing to avoid out-of-bounds convolution accesses\n", + plane, w, h, effective_filter_size, min_dim, min_dim); + return -EINVAL; } return 0; } +/* Chroma subsampling shifts, mirroring picture.c's plane geometry. */ +static int motion_chroma_shifts(enum VmafPixelFormat pix_fmt, unsigned *ss_hor, unsigned *ss_ver) +{ + switch (pix_fmt) { + case VMAF_PIX_FMT_YUV420P: + *ss_hor = 1u; + *ss_ver = 1u; + return 0; + case VMAF_PIX_FMT_YUV422P: + *ss_hor = 1u; + *ss_ver = 0u; + return 0; + case VMAF_PIX_FMT_YUV444P: + *ss_hor = 0u; + *ss_ver = 0u; + return 0; + case VMAF_PIX_FMT_UNKNOWN: + case VMAF_PIX_FMT_YUV400P: + default: + return -EINVAL; + } +} + +/* + * `motion_add_uv` blurs the chroma planes with the same separable Gaussian as + * luma, but at the SUBSAMPLED dimensions (`motion_blur_plane` is called with + * `ref_pic->w[c]` / `ref_pic->h[c]`). Validating only the luma dimensions let + * a 4x4 YUV420P frame through with a 2x2 chroma plane and read out of bounds + * in the mirror padding — the live CPU path behind Netflix/vmaf#1582 / + * Netflix/vmaf#1581, reproduced under ASan. Validate every plane that will + * actually be convolved. + */ +static int motion_check_min_dim_all_planes(const MotionState *s, enum VmafPixelFormat pix_fmt, + unsigned w, unsigned h) +{ + int err = motion_check_min_dim(s, w, h, "luma"); + if (err) + return err; + if (!s->motion_add_uv) + return 0; + + unsigned ss_hor = 0; + unsigned ss_ver = 0; + err = motion_chroma_shifts(pix_fmt, &ss_hor, &ss_ver); + if (err) + return err; + + /* picture.c:147-149 geometry: (dim + ss) >> ss. */ + const unsigned cw = (w + ss_hor) >> ss_hor; + const unsigned ch = (h + ss_ver) >> ss_ver; + return motion_check_min_dim(s, cw, ch, "chroma"); +} + static MotionSadLineFn motion_select_sad_line(void) { MotionSadLineFn sad_line = float_sad_line_c; @@ -324,7 +392,7 @@ static int init(VmafFeatureExtractor *fex, enum VmafPixelFormat pix_fmt, unsigne unsigned h_u = 0; unsigned h_v = 0; - int err = motion_check_min_dim(s, w, h); + int err = motion_check_min_dim_all_planes(s, pix_fmt, w, h); if (err) { return err; } diff --git a/core/src/feature/float_vif.c b/core/src/feature/float_vif.c index 8e91628b6..4159000ff 100644 --- a/core/src/feature/float_vif.c +++ b/core/src/feature/float_vif.c @@ -194,18 +194,27 @@ static int init(VmafFeatureExtractor *fex, enum VmafPixelFormat pix_fmt, unsigne VifState *s = fex->priv; /* - * The scale-0 Gaussian uses a 17-tap separable filter. The reflect-101 - * mirror-padding formula `ii = 2*h - ii - 2` requires the dimension to be - * >= 9 to stay in-bounds for every filter position (half-width = 8, - * worst-case index = dim + 7, mirrored = dim - 9 >= 0). - * Guard on the raw input dimensions first (before any string-option access) + * compute_vif() runs a four-scale ladder: each scale halves the working + * dimension and then convolves at that size with the scale's own Gaussian + * (widths {17, 9, 5, 3} at the default kernelscale). Every one of those + * convolutions needs `dim >= filter_width/2 + 1` for the reflect-101 + * mirror to stay inside the plane, so the frame minimum is the largest + * `(filter_width_s/2 + 1) << s` over the ladder -- 16, not the 9 this + * guard used to carry. The old scale-0-only floor let 9..15px input + * reach the scale-3 convolution with a sub-minimum plane and read out of + * bounds; reported upstream as Netflix/vmaf#1582. + * + * Guard the raw input dimensions first (before any string-option access) * to provide a fast, unconditional early exit. A second guard after - * computing scaled_w / scaled_h covers the case where a prescale < 1.0 - * shrinks a larger frame below the minimum. + * computing scaled_w / scaled_h covers the case where a prescale != 1.0 + * moves the dimensions actually handed to compute_vif(). */ - if (w < 9 || h < 9) { + const int vif_min_dim = vif_get_min_dim((float)s->vif_kernelscale); + if (w < (unsigned)vif_min_dim || h < (unsigned)vif_min_dim) { vmaf_log(VMAF_LOG_LEVEL_ERROR, - "float_vif requires width >= 9 and height >= 9 (got %ux%u)\n", w, h); + "float_vif requires width >= %d and height >= %d for the four-scale " + "ladder (got %ux%u)\n", + vif_min_dim, vif_min_dim, w, h); return -EINVAL; } @@ -217,10 +226,11 @@ static int init(VmafFeatureExtractor *fex, enum VmafPixelFormat pix_fmt, unsigne s->scaled_w = (int)(w * s->vif_prescale + 0.5); s->scaled_h = (int)(h * s->vif_prescale + 0.5); - if (s->scaled_w < 9 || s->scaled_h < 9) { + if (s->scaled_w < (size_t)vif_min_dim || s->scaled_h < (size_t)vif_min_dim) { vmaf_log(VMAF_LOG_LEVEL_ERROR, - "float_vif requires scaled width >= 9 and height >= 9 (got %zux%zu)\n", - s->scaled_w, s->scaled_h); + "float_vif requires scaled width >= %d and height >= %d for the " + "four-scale ladder (got %zux%zu)\n", + vif_min_dim, vif_min_dim, s->scaled_w, s->scaled_h); return -EINVAL; } s->float_stride = ALIGN_CEIL(w * sizeof(float)); diff --git a/core/src/feature/hip/float_vif_hip.c b/core/src/feature/hip/float_vif_hip.c index dace1745b..f20431ceb 100644 --- a/core/src/feature/hip/float_vif_hip.c +++ b/core/src/feature/hip/float_vif_hip.c @@ -42,6 +42,7 @@ #include "feature_collector.h" #include "feature_extractor.h" #include "feature_name.h" +#include "vif_tools.h" #include "libvmaf/picture.h" #include "log.h" @@ -430,6 +431,23 @@ static int init_fex_hip(VmafFeatureExtractor *fex, enum VmafPixelFormat pix_fmt, if (s->vif_kernelscale != 1.0) return -EINVAL; + /* Cross-backend parity with the CPU floor (ADR-0214 places=4). The + * four-scale ladder halves the working dimension once per scale, so the + * binding constraint is scale 3 -- at the default kernelscale the minimum + * is 16, not 8. This backend previously had no dimension floor at all, admitting the + * 8..15px range that walks the reflect-101 mirror out of the plane at + * scale 3 (Netflix/vmaf#1582, the same defect fixed on the CPU path). + * vif_get_min_dim() is the single source of truth shared with + * float_vif.c; see its derivation in vif_tools.c. */ + const int vif_min_dim = vif_get_min_dim((float)s->vif_kernelscale); + if (w < (unsigned)vif_min_dim || h < (unsigned)vif_min_dim) { + vmaf_log(VMAF_LOG_LEVEL_ERROR, + "float_vif_hip: width and height must be >= %d for the four-scale VIF " + "ladder (got %ux%u)\n", + vif_min_dim, w, h); + return -EINVAL; + } + s->width = w; s->height = h; s->bpc = bpc; diff --git a/core/src/feature/metal/float_motion.metal b/core/src/feature/metal/float_motion.metal index 5d2e6f7a9..57c40a9f4 100644 --- a/core/src/feature/metal/float_motion.metal +++ b/core/src/feature/metal/float_motion.metal @@ -46,9 +46,34 @@ constant float FILT[5] = { /* s_vert: TILE_H × TILE_W. */ #define TILE_PITCH_V 20 +/* Reflect-101 index fold, iterated. + * + * The kernels below load a TILE_W x TILE_H = 20x20 source tile at origin + * `bid * 16 - HALF_FW`, so this helper is handed indices spanning + * [-2, 16*bid + 17] -- far wider than the 5-tap neighbourhood it looks like + * it serves. A SINGLE bounce only lands back in range when the overshoot is + * at most `sup - 1`, i.e. `idx <= 2 * (sup - 1)`. Enumerated over the real + * tile span, the single-bounce form read OUT OF BOUNDS for every dimension + * in 1..9 and for exactly 17 -- at 17 the last workgroup's tile reaches + * idx = 33 while 2 * (17 - 1) = 32, folding to -1. The `w < 3 || h < 3` + * guard in the host wrapper covers neither case, which is why the fix + * belongs here and not in the guard. + * + * Folding until the index is in range is bit-identical to the single bounce + * for every index one bounce already handled (verified by exhaustive + * enumeration over dims 1..299 across the full tile span), so no + * in-contract score moves. `sup <= 1` has no interior to reflect into and + * would not terminate, so it short-circuits. + * + * Same defect and same fix as the CPU scalar path in + * core/src/feature/common/convolution_internal.h (Netflix/vmaf#1582 and + * Netflix/vmaf#1581). + */ static inline int skip_mirror(int idx, int sup) { - if (idx < 0) { return -idx; } - if (idx >= sup) { return 2 * (sup - 1) - idx; } + if (sup <= 1) { return 0; } + while (idx < 0 || idx >= sup) { + idx = (idx < 0) ? -idx : 2 * (sup - 1) - idx; + } return idx; } diff --git a/core/src/feature/metal/float_motion_metal.mm b/core/src/feature/metal/float_motion_metal.mm index 2f951f22c..3ae6f1239 100644 --- a/core/src/feature/metal/float_motion_metal.mm +++ b/core/src/feature/metal/float_motion_metal.mm @@ -31,6 +31,7 @@ #include "feature_collector.h" #include "feature_name.h" #include "libvmaf/picture.h" +#include "log.h" #include "../../metal/common.h" #include "../../metal/kernel_template.h" @@ -113,6 +114,27 @@ static int init_fex_metal(VmafFeatureExtractor *fex, enum VmafPixelFormat pix_fm (void)pix_fmt; FloatMotionStateMetal *s = (FloatMotionStateMetal *)fex->priv; + /* The 5-tap separable Gaussian needs at least radius + 1 = 3 samples per + * axis to produce meaningful output; this matches the floor carried by the + * CPU, CUDA, SYCL and HIP twins since Research-0094. The Metal extractors + * were written afterwards and had no guard at all (found while triaging + * Netflix/vmaf#1580). + * + * This guard is NOT what keeps the device kernel in bounds, and must not + * be read as such: float_motion.metal loads a 20x20 threadgroup tile, so its + * `skip_mirror` helper is handed indices far outside the 5-tap + * neighbourhood. A single-bounce fold read out of bounds for every + * dimension in 1..9 AND for exactly 17 -- a hole a 3x3 floor does not + * close. That is fixed in the kernel, where `skip_mirror` now folds + * iteratively; see the comment there. */ + if (w < 3u || h < 3u) { + vmaf_log(VMAF_LOG_LEVEL_ERROR, + "float_motion_metal: frame %ux%u is below the 5-tap filter minimum 3x3; " + "refusing to avoid out-of-bounds mirror reads on device\n", + w, h); + return -EINVAL; + } + s->frame_w = w; s->frame_h = h; s->bpc = bpc; diff --git a/core/src/feature/metal/float_vif_metal.mm b/core/src/feature/metal/float_vif_metal.mm index be2145b46..cd8489400 100644 --- a/core/src/feature/metal/float_vif_metal.mm +++ b/core/src/feature/metal/float_vif_metal.mm @@ -62,6 +62,8 @@ #include "dict.h" #include "feature_collector.h" #include "feature_name.h" +#include "log.h" +#include "vif_tools.h" #include "libvmaf/picture.h" #include "../../metal/common.h" @@ -259,9 +261,20 @@ static int init_fex_metal(VmafFeatureExtractor *fex, enum VmafPixelFormat pix_fm s->scale_w[i] = s->scale_w[i - 1] / 2u; s->scale_h[i] = s->scale_h[i - 1] / 2u; } - /* A 4-scale pyramid needs scale 3 >= one workgroup tile. Reject too-small - * frames up front rather than emitting a degenerate score. */ - if (s->scale_w[FVIF_SCALES - 1] == 0u || s->scale_h[FVIF_SCALES - 1] == 0u) { + /* Cross-backend parity with the CPU floor (ADR-0214 places=4). This check + * used to be `scale_w[FVIF_SCALES - 1] == 0`, i.e. `w >> 3 == 0`, an + * effective floor of 8 -- which admitted the 8..15px range that walks the + * reflect-101 mirror out of the plane at scale 3 (Netflix/vmaf#1582, the + * same defect fixed on the CPU path). The binding constraint is scale 3, + * so the real minimum at the default kernelscale is 16. + * vif_get_min_dim() is the single source of truth shared with + * float_vif.c; see its derivation in vif_tools.c. */ + const int vif_min_dim = vif_get_min_dim((float)s->vif_kernelscale); + if (w < (unsigned)vif_min_dim || h < (unsigned)vif_min_dim) { + vmaf_log(VMAF_LOG_LEVEL_ERROR, + "float_vif_metal: width and height must be >= %d for the " + "four-scale VIF ladder (got %ux%u)\n", + vif_min_dim, w, h); return -EINVAL; } diff --git a/core/src/feature/metal/integer_motion.metal b/core/src/feature/metal/integer_motion.metal index 057d03b11..6521ece50 100644 --- a/core/src/feature/metal/integer_motion.metal +++ b/core/src/feature/metal/integer_motion.metal @@ -45,9 +45,34 @@ constant uint FILTER[5] = {3571u, 16004u, 26386u, 16004u, 3571u}; #define TILE_H 20 /* same for vertical pass */ #define TILE_PITCH 20 +/* Reflect-101 index fold, iterated. + * + * The kernels below load a TILE_W x TILE_H = 20x20 source tile at origin + * `bid * 16 - HALF_FW`, so this helper is handed indices spanning + * [-2, 16*bid + 17] -- far wider than the 5-tap neighbourhood it looks like + * it serves. A SINGLE bounce only lands back in range when the overshoot is + * at most `sup - 1`, i.e. `idx <= 2 * (sup - 1)`. Enumerated over the real + * tile span, the single-bounce form read OUT OF BOUNDS for every dimension + * in 1..9 and for exactly 17 -- at 17 the last workgroup's tile reaches + * idx = 33 while 2 * (17 - 1) = 32, folding to -1. The `w < 3 || h < 3` + * guard in the host wrapper covers neither case, which is why the fix + * belongs here and not in the guard. + * + * Folding until the index is in range is bit-identical to the single bounce + * for every index one bounce already handled (verified by exhaustive + * enumeration over dims 1..299 across the full tile span), so no + * in-contract score moves. `sup <= 1` has no interior to reflect into and + * would not terminate, so it short-circuits. + * + * Same defect and same fix as the CPU scalar path in + * core/src/feature/common/convolution_internal.h (Netflix/vmaf#1582 and + * Netflix/vmaf#1581). + */ static inline int skip_mirror(int idx, int sup) { - if (idx < 0) { return -idx; } - if (idx >= sup) { return 2 * (sup - 1) - idx; } + if (sup <= 1) { return 0; } + while (idx < 0 || idx >= sup) { + idx = (idx < 0) ? -idx : 2 * (sup - 1) - idx; + } return idx; } diff --git a/core/src/feature/metal/integer_motion_metal.mm b/core/src/feature/metal/integer_motion_metal.mm index 48b2f872b..4ea146159 100644 --- a/core/src/feature/metal/integer_motion_metal.mm +++ b/core/src/feature/metal/integer_motion_metal.mm @@ -122,6 +122,27 @@ static int init_fex_metal(VmafFeatureExtractor *fex, enum VmafPixelFormat pix_fm return -ENOTSUP; } + /* The 5-tap separable Gaussian needs at least radius + 1 = 3 samples per + * axis to produce meaningful output; this matches the floor carried by the + * CPU, CUDA, SYCL and HIP twins since Research-0094. The Metal extractors + * were written afterwards and had no guard at all (found while triaging + * Netflix/vmaf#1580). + * + * This guard is NOT what keeps the device kernel in bounds, and must not + * be read as such: integer_motion.metal loads a 20x20 threadgroup tile, so its + * `skip_mirror` helper is handed indices far outside the 5-tap + * neighbourhood. A single-bounce fold read out of bounds for every + * dimension in 1..9 AND for exactly 17 -- a hole a 3x3 floor does not + * close. That is fixed in the kernel, where `skip_mirror` now folds + * iteratively; see the comment there. */ + if (w < 3u || h < 3u) { + vmaf_log(VMAF_LOG_LEVEL_ERROR, + "motion_metal: frame %ux%u is below the 5-tap filter minimum 3x3; " + "refusing to avoid out-of-bounds mirror reads on device\n", + w, h); + return -EINVAL; + } + s->frame_w = w; s->frame_h = h; s->bpc = bpc; diff --git a/core/src/feature/metal/integer_motion_v2.metal b/core/src/feature/metal/integer_motion_v2.metal index 4470cec91..2452b6a82 100644 --- a/core/src/feature/metal/integer_motion_v2.metal +++ b/core/src/feature/metal/integer_motion_v2.metal @@ -48,10 +48,34 @@ using namespace metal; constant int MV2_FILTER[5] = {3571, 16004, 26386, 16004, 3571}; +/* Reflect-101 index fold, iterated -- TWO defects fixed here. + * + * (1) CONVENTION. This helper used `2 * sup - idx - 1`, which reflects + * idx == sup back to sup - 1 and therefore REPEATS the boundary row. + * Reflect-101 skips it: `2 * (sup - 1) - idx`. Every other backend + * already carries the corrected form -- CPU + * (integer_motion_v2.c::mirror), CUDA (fixed in PR #120 / T7-15), + * SYCL (integer_motion_sycl.cpp::dev_mirror_motion) and HIP + * (integer_motion_v2/motion_v2_score.hip) -- and the SYCL fix records + * the measured impact of the `- 1` form as a systematic ~2.6e-3 motion + * drift vs CPU on every frame after the first. Metal was the last + * backend still diverging (cross-backend parity, ADR-0214 places=4). + * + * (2) OUT OF BOUNDS. The kernels load a 20x20 tile at origin + * `tile_origin - 2`, so this helper sees indices far outside the 5-tap + * neighbourhood, and a single bounce only lands in range when + * `idx <= 2 * (sup - 1)`. Enumerated over the real tile span the + * single-bounce form read out of bounds for every dimension in 1..9 and + * for exactly 17. Iterating is bit-identical wherever one bounce already + * sufficed, so this costs no in-contract score movement -- only the + * `- 1` -> `- 2` convention change in (1) moves borders, and it moves + * them onto the CPU reference. + */ inline int mv2_mirror(int idx, int sup) { - if (idx < 0) return -idx; - if (idx >= sup) return 2 * sup - idx - 1; + if (sup <= 1) return 0; + while (idx < 0 || idx >= sup) + idx = (idx < 0) ? -idx : 2 * (sup - 1) - idx; return idx; } diff --git a/core/src/feature/metal/integer_motion_v2_metal.mm b/core/src/feature/metal/integer_motion_v2_metal.mm index da978e9ec..85951128e 100644 --- a/core/src/feature/metal/integer_motion_v2_metal.mm +++ b/core/src/feature/metal/integer_motion_v2_metal.mm @@ -46,6 +46,7 @@ #include "feature_collector.h" #include "feature_name.h" #include "libvmaf/picture.h" +#include "log.h" #include "motion_blend_tools.h" #include "../../metal/common.h" @@ -217,6 +218,27 @@ static int init_fex_metal(VmafFeatureExtractor *fex, enum VmafPixelFormat pix_fm (void)pix_fmt; MotionV2StateMetal *s = (MotionV2StateMetal *)fex->priv; + /* The 5-tap separable Gaussian needs at least radius + 1 = 3 samples per + * axis to produce meaningful output; this matches the floor carried by the + * CPU, CUDA, SYCL and HIP twins since Research-0094. The Metal extractors + * were written afterwards and had no guard at all (found while triaging + * Netflix/vmaf#1580). + * + * This guard is NOT what keeps the device kernel in bounds, and must not + * be read as such: integer_motion_v2.metal loads a 20x20 threadgroup tile, so its + * `mv2_mirror` helper is handed indices far outside the 5-tap + * neighbourhood. A single-bounce fold read out of bounds for every + * dimension in 1..9 AND for exactly 17 -- a hole a 3x3 floor does not + * close. That is fixed in the kernel, where `mv2_mirror` now folds + * iteratively; see the comment there. */ + if (w < 3u || h < 3u) { + vmaf_log(VMAF_LOG_LEVEL_ERROR, + "motion_v2_metal: frame %ux%u is below the 5-tap filter minimum 3x3; " + "refusing to avoid out-of-bounds mirror reads on device\n", + w, h); + return -EINVAL; + } + s->frame_w = w; s->frame_h = h; s->bpc = bpc; diff --git a/core/src/feature/sycl/float_vif_sycl.cpp b/core/src/feature/sycl/float_vif_sycl.cpp index 096bfd395..b9a348d6b 100644 --- a/core/src/feature/sycl/float_vif_sycl.cpp +++ b/core/src/feature/sycl/float_vif_sycl.cpp @@ -28,6 +28,7 @@ #include "feature_collector.h" #include "feature_extractor.h" #include "feature_name.h" +#include "vif_tools.h" #include "log.h" #include "picture.h" #include "sycl/common.h" @@ -495,6 +496,23 @@ static int init_fex_sycl(VmafFeatureExtractor *fex, enum VmafPixelFormat pix_fmt if (s->vif_kernelscale != 1.0) return -EINVAL; + /* Cross-backend parity with the CPU floor (ADR-0214 places=4). The + * four-scale ladder halves the working dimension once per scale, so the + * binding constraint is scale 3 -- at the default kernelscale the minimum + * is 16, not 8. This backend previously had no dimension floor at all, admitting the + * 8..15px range that walks the reflect-101 mirror out of the plane at + * scale 3 (Netflix/vmaf#1582, the same defect fixed on the CPU path). + * vif_get_min_dim() is the single source of truth shared with + * float_vif.c; see its derivation in vif_tools.c. */ + const int vif_min_dim = vif_get_min_dim((float)s->vif_kernelscale); + if (w < (unsigned)vif_min_dim || h < (unsigned)vif_min_dim) { + vmaf_log(VMAF_LOG_LEVEL_ERROR, + "float_vif_sycl: width and height must be >= %d for the four-scale VIF " + "ladder (got %ux%u)\n", + vif_min_dim, w, h); + return -EINVAL; + } + s->scale_w[0] = w; s->scale_h[0] = h; for (int i = 1; i < 4; i++) { diff --git a/core/src/feature/vif_tools.c b/core/src/feature/vif_tools.c index 68f2d119c..3fa23996b 100644 --- a/core/src/feature/vif_tools.c +++ b/core/src/feature/vif_tools.c @@ -146,6 +146,28 @@ int vif_get_filter_size(int scale, float kernelscale) return MAX(round_up_to_odd(n * kernelscale), 3); } +int vif_get_min_dim(float kernelscale) +{ + /* The scale ladder in compute_vif() halves the working dimension once per + * scale (VIF_OPT_HANDLE_BORDERS keeps the full plane, so + * `dim_s = dim_0 >> s`), then convolves at that size with the scale's own + * Gaussian. The reflect-101 mirror needs `dim_s >= filter_width_s/2 + 1`, + * i.e. `dim_0 >= (filter_width_s/2 + 1) << s`; the binding scale is the + * last one. At the default kernelscale of 1.0 the widths are + * {17, 9, 5, 3} and the bound is max(9, 10, 12, 16) = 16 — well above the + * scale-0-only floor of 9 the guard used to carry, which is why 9..15px + * input walked the mirror out of bounds (Netflix/vmaf#1582). + */ + int min_dim = 1; + for (int scale = 0; scale < 4; scale++) { + const int filter_width = vif_get_filter_size(scale, kernelscale); + const int need = (filter_width / 2 + 1) << scale; + if (need > min_dim) + min_dim = need; + } + return min_dim; +} + void vif_get_filter(float *out, int scale, float kernelscale) { int window_size = vif_get_filter_size(scale, kernelscale); diff --git a/core/src/feature/vif_tools.h b/core/src/feature/vif_tools.h index 5ea614dcb..2e6fdf833 100644 --- a/core/src/feature/vif_tools.h +++ b/core/src/feature/vif_tools.h @@ -23,6 +23,13 @@ #include +/* The GPU backends that share vif_get_min_dim() are C++ (SYCL) and + * Objective-C++ (Metal) translation units, while vif_tools.c is C -- without + * this guard they would demand mangled symbols and fail to link. */ +#ifdef __cplusplus +extern "C" { +#endif + enum vif_scaling_method { vif_scale_nearest = 0, vif_scale_bicubic = 1, @@ -73,10 +80,20 @@ void vif_scale_frame_s(enum vif_scaling_method scale_method, const float *src, f int vif_get_filter_size(int scale, float kernelscale); +/* Smallest frame dimension the four-scale VIF ladder can process without + * walking the reflect-101 mirror out of the plane, for a given kernelscale. + * 16 at the default kernelscale of 1.0. See the definition for the + * derivation; Netflix/vmaf#1582. */ +int vif_get_min_dim(float kernelscale); + void vif_get_filter(float *out, int scale, float kernelscale); void speed_get_antialias_filter(float *out, int scale, float kernelscale); bool vif_validate_kernelscale(float kernelscale); +#ifdef __cplusplus +} /* extern "C" */ +#endif + #endif /* VIF_TOOLS_H_ */ diff --git a/core/src/libvmaf.c b/core/src/libvmaf.c index 3c822fb91..6e7dcf0d6 100644 --- a/core/src/libvmaf.c +++ b/core/src/libvmaf.c @@ -1619,20 +1619,33 @@ int vmaf_use_feature(VmafContext *vmaf, const char *feature_name, VmafFeatureDic if (!fex) return -EINVAL; + /* Netflix/vmaf#1242 ownership contract: past the argument guards above, this + * call consumes `opts_dict` on every path. Both failure paths below used to + * return without releasing it, which leaked the caller's dictionary exactly + * when the caller was told not to free it. */ VmafDictionary *d = NULL; if (s) { err = vmaf_dictionary_copy(&s, &d); - if (err) + if (err) { + (void)vmaf_dictionary_free(&s); return err; + } err = vmaf_dictionary_free(&s); - if (err) + if (err) { + (void)vmaf_dictionary_free(&d); return err; + } } VmafFeatureExtractorContext *fex_ctx = NULL; err = vmaf_feature_extractor_context_create(&fex_ctx, fex, d); - if (err) + if (err) { + /* context_create does not release the dictionary on its own failure + * paths (it frees only what it allocated), so the copy is ours to + * release here. */ + (void)vmaf_dictionary_free(&d); return err; + } fex_ctx_bind_backends(fex_ctx, vmaf); RegisteredFeatureExtractors *rfe = &(vmaf->registered_feature_extractors); diff --git a/core/src/meson.build b/core/src/meson.build index ffdbd1767..2320719b0 100644 --- a/core/src/meson.build +++ b/core/src/meson.build @@ -1264,10 +1264,25 @@ if is_cuda_enabled # libvmaf/src/compat/win32/pthread.h is wired into the C/C++ builds # via pthread_dependency, but custom_target nvcc invocations build # their own -I list — so add the shim path explicitly here too. + # Netflix/vmaf#1573 hunk (b): these used to be RELATIVE paths ('./src', + # '../src', ...), which only resolve when the build directory happens to be + # a direct child of core/. Since ADR-0700 moved the project root to core/, + # the layout the docs actually use (`meson setup build core` from the repo + # root) put the build dir elsewhere and every .cu fatbin failed with + # "fatal error: cuda/integer_adm_cuda.h: No such file or directory". + # Absolute paths derived from meson.current_{source,build}_dir(), matching + # what the SYCL block below already does. cuda_extra_includes = [] if host_machine.system() == 'windows' cuda_extra_includes += ['-I', meson.current_source_dir() / 'compat' / 'win32'] endif + cuda_inc_flags = [ + '-I', meson.current_build_dir(), + '-I', meson.current_source_dir(), + '-I', libvmaf_src_root / 'include', + '-I', meson.current_source_dir() / 'feature', + '-I', meson.current_source_dir() / cuda_dir, + ] ptx_files = {} foreach name, _cu : cuda_cu_sources per_kernel_flags = cuda_cu_extra_flags.get(name, []) @@ -1279,11 +1294,7 @@ if is_cuda_enabled gencode, '@INPUT@', '-o', '@OUTPUT@' , - '-I', meson.current_build_dir(), - '-I', meson.current_source_dir(), - '-I', libvmaf_src_root / 'include', - '-I', meson.current_source_dir() / 'feature', - '-I', meson.current_source_dir() / 'cuda', + cuda_inc_flags, cuda_extra_includes, '-DDEVICE_CODE', ] + cuda_flags + nvcc_ccbin_flags + nvcc_host_includes + per_kernel_flags @@ -2158,6 +2169,28 @@ if is_metal_enabled libvmaf_private_libs += ['-Wl,-framework,Foundation', '-Wl,-framework,Metal'] endif +# Netflix/vmaf#1178: libvmaf contains C++ translation units (the fork's own +# feature_extractor.cpp / feature_collector.cpp / luminance_tools.cpp / +# read_json_model.cpp / log.cpp / picture_pool ... plus vendored libsvm), so a +# consumer linking the static archive through +# `pkg-config --static --libs libvmaf` needs the C++ runtime. Without it the +# link fails with ~670 undefined references (`operator new(unsigned long)`, +# `std::ios_base::ios_base()`, ...) — which is why ADR-0198's fully-static +# FFmpeg reproducer had to add `-lstdc++` by hand. +# +# Detect the STL actually in use rather than keying off the compiler id +# (upstream's patch maps clang -> -lc++, which is wrong on Linux, where clang +# defaults to libstdc++; -Dcpp_args=-stdlib=libc++ can flip either compiler). +# _LIBCPP_VERSION is defined only by libc++. MSVC / clang-cl auto-link their +# runtime via `#pragma comment(lib)`, so there is nothing to add there. +if cxx.get_argument_syntax() != 'msvc' + if cxx.get_define('_LIBCPP_VERSION', prefix: '#include ') != '' + libvmaf_private_libs += '-lc++' + else + libvmaf_private_libs += '-lstdc++' + endif +endif + pkg_mod.generate(libraries: libvmaf, version: meson.project_version(), name: 'libvmaf', diff --git a/core/src/model.c b/core/src/model.c index bc5c28103..1500020ef 100644 --- a/core/src/model.c +++ b/core/src/model.c @@ -253,15 +253,23 @@ int vmaf_model_feature_overload(VmafModel *model, const char *feature_name, continue; VmafDictionary *d = vmaf_dictionary_merge((VmafDictionary **)&model->feature[i].opts_dict, (VmafDictionary **)&opts_dict, 0); - if (!d) - return -ENOMEM; + if (!d) { + /* Netflix/vmaf#1242: this used to `return -ENOMEM` and skip the + * unconditional free below, leaking the caller's dictionary on + * every allocation failure. The contract documented in + * is that the dictionary is consumed once the + * argument guards above have passed, so break out and let the + * common exit release it. core/src/model.cpp (the unbuilt C++ + * twin) already had this shape. */ + err = -ENOMEM; + break; + } err = vmaf_dictionary_free(&model->feature[i].opts_dict); if (err) - goto exit; + break; model->feature[i].opts_dict = d; } -exit: err |= vmaf_dictionary_free((VmafDictionary **)&opts_dict); return err; } @@ -432,19 +440,34 @@ int vmaf_model_collection_feature_overload(VmafModel *model, VmafModelCollection const char *feature_name, VmafFeatureDictionary *opts_dict) { - if (!model_collection) + /* Argument-validation guards consume nothing: the caller still owns + * `opts_dict` when any of them fires. See the ownership contract in + * and . */ + if (!model_collection || !*model_collection) + return -EINVAL; + if (!model || !feature_name || !opts_dict) return -EINVAL; VmafModelCollection *mc = *model_collection; int err = 0; for (unsigned i = 0; i < mc->cnt; i++) { VmafFeatureDictionary *d = NULL; - if (vmaf_dictionary_copy((VmafDictionary **)&opts_dict, (VmafDictionary **)&d)) - goto exit; + /* Netflix/vmaf#1242: the copy's return value used to be discarded and + * the partially-built copy leaked on failure, while the function could + * still report success from the lead-model call below. Free the + * partial copy, fold the error into `err`, and stop iterating. */ + const int copy_err = + vmaf_dictionary_copy((VmafDictionary **)&opts_dict, (VmafDictionary **)&d); + if (copy_err) { + err |= vmaf_dictionary_free((VmafDictionary **)&d); + err |= copy_err; + break; + } err |= vmaf_model_feature_overload(mc->model[i], feature_name, d); } -exit: + /* Always run the lead-model overload so `opts_dict` is consumed on every + * path, matching the ownership contract in . */ err |= vmaf_model_feature_overload(model, feature_name, opts_dict); return err; } diff --git a/core/test/AGENTS.md b/core/test/AGENTS.md index ccf4be800..8ea692f9a 100644 --- a/core/test/AGENTS.md +++ b/core/test/AGENTS.md @@ -354,3 +354,20 @@ json_model + dnn_sidecar additions). Conventions: library-linked reproducer will report the bug fixed while the fuzz lane stays red, because the two binaries do not share that translation unit. `scripts/ci/twin-drift-check.sh` labels the `.c` a "test-only twin side". + +## New C test files inherit the ADR-1138 `NULL` carve-out (ADR-1166) + +`core/test/*.c` compile on the Windows MSVC legs with `cl.exe`, whose +documented `/std:clatest` C23 feature set does not include the `nullptr` +keyword, so C test files spell the null pointer constant `NULL` and carry a +file-scoped `NOLINTBEGIN/END(modernize-use-nullptr)` bracket citing ADR-1138 — +the same shape `core/src/feature/float_motion.c` uses. Keep the closing +`NOLINTEND` at EOF when appending to such a file; the clang-tidy ratchet counts +an uncited `NOLINT` as debt (ADR-1142), so the citation comment is part of the +suppression, not a nicety. + +`run_tests()` is bounded by `readability-function-size` at 15 branches, and +every `mu_run_test` expansion contributes two. Past ~7 cases, group them into +named driver functions (see `test_motion_min_dim.c`'s +`run_integer_motion_tests` / `run_float_and_metal_motion_tests`) rather than +adding a NOLINT. diff --git a/core/test/meson.build b/core/test/meson.build index 28e3887c6..869f7532b 100644 --- a/core/test/meson.build +++ b/core/test/meson.build @@ -202,6 +202,18 @@ test_model_libsvm_dup_key = executable('test_model_libsvm_dup_key', read_json_model_cpp23_lib.extract_all_objects(recursive: true)], ) +# Netflix/vmaf#1242 regression: VmafFeatureDictionary ownership contract on +# vmaf_model_feature_overload / vmaf_model_collection_feature_overload. +test_model_feature_overload_ownership = executable('test_model_feature_overload_ownership', + ['test.c', 'test_model_feature_overload_ownership.c', '../src/dict.cpp', '../src/pdjson.c', json_model_c_sources], + include_directories : [libvmaf_inc, test_inc, include_directories('../src')], + link_with : get_option('default_library') == 'both' ? libvmaf.get_static_lib() : libvmaf, + c_args : [vmaf_cflags_common, '-DJSON_MODEL_PATH="'+join_paths(meson.project_source_root(), '../model/')+'"'], + dependencies : [pthread_dependency, thread_lib, gpu_all_deps], + objects : [libsvm_static_lib.extract_all_objects(recursive: true), + read_json_model_cpp23_lib.extract_all_objects(recursive: true)], +) + # Coverage for model-collection public API: vmaf_model_collection_load, # vmaf_use_features_from_model_collection, vmaf_score_at_index_model_collection, # vmaf_score_pooled_model_collection, vmaf_model_collection_load_from_path, @@ -610,6 +622,16 @@ test_cli_parse = executable('test_cli_parse', dependencies: [pthread_dependency, getopt_dependency, sycl_dependency], ) +# Netflix/vmaf#743 regression: the CLI progress spinner must fall back to an +# ASCII table (and drop the CSI erase-to-EOL) on a console that reports a +# non-UTF-8 code page or refuses VT processing, and must stay byte-identical +# on POSIX. +test_spinner = executable('test_spinner', + ['test.c', 'test_spinner.cpp'], + include_directories : [libvmaf_inc, test_inc, include_directories('../tools/')], + dependencies : [pthread_dependency], +) + # test_y4m_411_oob uses POSIX fmemopen which has no MSVC/MinGW # equivalent. The reproducer exercises the Y4M parser via an # in-memory FILE*; on Windows the test isn't built (the bug is @@ -2087,6 +2109,55 @@ test_motion_min_dim = executable('test_motion_min_dim', ) test('test_motion_min_dim', test_motion_min_dim, suite : ['fast']) +# Drives float_motion through the PUBLIC API so the AVX2/AVX-512 convolution is +# actually reached. test_motion_min_dim only calls init(), and +# test_convolution_edge_small calls the scalar kernels directly, so neither can +# observe the dispatched SIMD path where the out-of-bounds writes lived. +test_motion_convolution_oob = executable('test_motion_convolution_oob', + ['test.c', 'test_motion_convolution_oob.c'], + include_directories : [libvmaf_inc, test_inc, include_directories('../src/')], + link_with : get_option('default_library') == 'both' ? libvmaf.get_static_lib() : libvmaf, + dependencies: [pthread_dependency, math_lib], +) +test('test_motion_convolution_oob', test_motion_convolution_oob, suite : ['fast']) + +# Netflix/vmaf#1582 + #1581 regression: the float convolution border helpers +# must stay inside the plane for every size, including planes smaller than the +# filter (reachable through float_motion's motion_add_uv chroma planes and the +# float_vif multi-scale ladder), and must remain bit-identical above it. +test_convolution_edge_small = executable('test_convolution_edge_small', + ['test.c', 'test_convolution_edge_small.c'], + include_directories : [libvmaf_inc, test_inc, include_directories('../src/')], + link_with : get_option('default_library') == 'both' ? libvmaf.get_static_lib() : libvmaf, + dependencies : [math_lib, pthread_dependency, sycl_dependency], +) +test('test_convolution_edge_small', test_convolution_edge_small, suite : ['fast']) + +# Netflix/vmaf#1551 (retracting Netflix/vmaf#1422): the MSVC __builtin_clz shim +# must derive the leading-zero count from BSR, not from the ungated LZCNT +# instruction. The arithmetic is portable and unit-tested here; the "must not +# be reimplemented with __lzcnt" half is the shell guard registered below. +test_compat_clz = executable('test_compat_clz', + ['test.c', 'test_compat_clz.c'], + include_directories : [libvmaf_inc, test_inc, include_directories('../src/')], + dependencies : [pthread_dependency], +) +test('test_compat_clz', test_compat_clz, suite : ['fast']) + +# Static source-content check, so one platform is enough — and on Windows it +# cannot run at all. meson invokes the script through its shebang interpreter, +# and on the MinGW64 runner `bash` resolves to Windows' own WSL bash.exe, which +# has no installed distribution: the leg failed with "Windows Subsystem for +# Linux has no installed distributions" before the script ever executed. Linux +# and macOS both run it in the fast suite, and the lint lane runs it too, so +# skipping the Windows hosts loses no coverage. +if host_machine.system() != 'windows' + check_msvc_clz_shim = find_program(meson.project_source_root() / '..' / 'scripts' / 'ci' / 'check-msvc-clz-shim.sh') + test('check_msvc_clz_shim', check_msvc_clz_shim, + args : [meson.project_source_root() / '..'], + suite : ['fast']) +endif + # float_vif min-dimension guard — regression test for double-free / OOB-read # when min(w,h) <= 6 triggers the 17-tap Gaussian filter at scale 0. # Fixed by inserting a w < 7 || h < 7 guard in float_vif init() before any @@ -3211,6 +3282,7 @@ test('test_public_api_score', test_public_api_score, suite : ['fast']) test('test_thread_pool', test_thread_pool, suite : ['fast']) test('test_model', test_model, suite : ['fast']) test('test_model_libsvm_dup_key', test_model_libsvm_dup_key, suite : ['fast']) +test('test_model_feature_overload_ownership', test_model_feature_overload_ownership, suite : ['fast']) test('test_model_collection_api', test_model_collection_api, suite : ['fast']) test('test_predict', test_predict, suite : ['fast']) test('test_svm_api', test_svm_api, suite : ['fast']) @@ -3244,6 +3316,7 @@ endif test('test_speed_qa', test_speed_qa, suite : ['fast']) test('test_luminance_tools', test_luminance_tools, suite : ['fast']) test('test_cli_parse', test_cli_parse, suite : ['fast']) +test('test_spinner', test_spinner, suite : ['fast']) if host_machine.system() != 'windows' test('test_y4m_411_oob', test_y4m_411_oob, suite : ['fast']) test('test_cli_parse_long_only_args', test_cli_parse_long_only_args, suite : ['fast']) diff --git a/core/test/test_compat_clz.c b/core/test/test_compat_clz.c new file mode 100644 index 000000000..f6ef7771d --- /dev/null +++ b/core/test/test_compat_clz.c @@ -0,0 +1,178 @@ +/** + * Copyright 2026 Lusoris + * SPDX-License-Identifier: BSD-2-Clause-Patent + * + * Netflix/vmaf#1551 / Netflix/vmaf#1422 regression test — the MSVC + * __builtin_clz / __builtin_clzll shim in core/src/feature/compat_builtin.h. + * + * The shim used to be implemented with MSVC's `__lzcnt` / `__lzcnt64` + * (the form Netflix/vmaf#1422 proposes). MSVC emits the LZCNT instruction + * unconditionally, with no runtime feature gate; on an x86-64 without + * ABM/LZCNT the `F3` prefix is ignored and the encoding retires as BSR, + * which returns the INDEX of the most-significant set bit rather than the + * leading-zero COUNT. The two scalar call sites — integer_vif.h::log2_32 + * (`k = 16 - clz`) and integer_adm.c::get_best15_from32 (`k = 17 - clz`) — + * then silently mis-normalise every VIF and ADM log2, and for large inputs + * shift by a negative count. Netflix/vmaf#1551 is upstream's own retraction + * of that form; the fork now uses `_BitScanReverse`, which IS BSR by + * definition and exists on every x86-64 part. + * + * The MSVC bodies cannot be compiled here, so this file pins the two halves + * that are portable: + * + * 1. `vmaf_compat_clz{32,64}_from_msb` — the `31 - msb` / `63 - msb` + * arithmetic that converts BSR's answer into a leading-zero count. + * This is exactly what the `__lzcnt` form got wrong. The helpers are + * compiled on every platform and are what the MSVC shim calls. + * + * 2. Whatever `__builtin_clz` / `__builtin_clzll` resolve to in this + * translation unit (the compiler builtin here; the shim on the MSVC CI + * legs) must agree with a portable reference over a wide sweep. + * + * The "shim must not be reimplemented with __lzcnt" half is enforced + * separately and unconditionally by scripts/ci/check-msvc-clz-shim.sh. + */ + +#include + +#include "test.h" + +#include "feature/compat_builtin.h" + +/* NOLINTBEGIN(modernize-use-nullptr): C translation unit. The fork builds C as + * C23, where clang-tidy also proposes the `nullptr` keyword, but the Windows + * MSVC legs compile the test tree with cl.exe, whose documented /std:clatest + * C23 feature set does not include `nullptr`. Same carve-out and reasoning as + * core/src/feature/float_motion.c. ADR-1138. */ + +/* Portable reference: count leading zeros of a 32-bit value. */ +static int ref_clz32(uint32_t x) +{ + if (x == 0u) + return 32; + int n = 0; + while ((x & 0x80000000u) == 0u) { + x <<= 1; + n++; + } + return n; +} + +static int ref_clz64(uint64_t x) +{ + if (x == 0u) + return 64; + int n = 0; + while ((x & 0x8000000000000000ull) == 0ull) { + x <<= 1; + n++; + } + return n; +} + +/* Portable reference for BSR: index of the most-significant set bit. */ +static int ref_msb32(uint32_t x, unsigned *idx) +{ + if (x == 0u) + return 0; + *idx = (unsigned)(31 - ref_clz32(x)); + return 1; +} + +static int ref_msb64(uint64_t x, unsigned *idx) +{ + if (x == 0ull) + return 0; + *idx = (unsigned)(63 - ref_clz64(x)); + return 1; +} + +/* ------------------------------------------------------------------ */ + +static char *test_clz32_from_msb_matches_reference(void) +{ + mu_assert("clz32_from_msb(0) must be 32", vmaf_compat_clz32_from_msb(0, 0) == 32); + + for (unsigned bit = 0; bit < 32u; bit++) { + const uint32_t v = 1u << bit; + unsigned idx = 0; + const int found = ref_msb32(v, &idx); + mu_assert("clz32_from_msb disagrees with the reference on a power of two", + vmaf_compat_clz32_from_msb(found, idx) == ref_clz32(v)); + } + + /* Values that straddle the 2^k boundaries, where the BSR-vs-LZCNT + * confusion produced the 2048-LSB (factor-of-two) VIF log2 error. */ + static const uint32_t kCases[] = { + 1u, 2u, 3u, 0xFFFFu, 0x10000u, 0x1FFFFu, 0x100000u, 0x7FFFFFFFu, 0x80000000u, 0xFFFFFFFFu, + }; + for (size_t i = 0; i < sizeof(kCases) / sizeof(kCases[0]); i++) { + unsigned idx = 0; + const int found = ref_msb32(kCases[i], &idx); + mu_assert("clz32_from_msb disagrees with the reference", + vmaf_compat_clz32_from_msb(found, idx) == ref_clz32(kCases[i])); + } + return NULL; +} + +static char *test_clz64_from_msb_matches_reference(void) +{ + mu_assert("clz64_from_msb(0) must be 64", vmaf_compat_clz64_from_msb(0, 0) == 64); + + for (unsigned bit = 0; bit < 64u; bit++) { + const uint64_t v = 1ull << bit; + unsigned idx = 0; + const int found = ref_msb64(v, &idx); + mu_assert("clz64_from_msb disagrees with the reference on a power of two", + vmaf_compat_clz64_from_msb(found, idx) == ref_clz64(v)); + } + return NULL; +} + +/* On the MSVC CI legs this exercises the shim itself; elsewhere it anchors the + * reference against the compiler builtin the shim has to imitate. */ +static char *test_builtin_clz_matches_reference(void) +{ + static const uint32_t kCases32[] = { + 1u, 2u, 3u, 255u, 256u, 0xFFFFu, + 0x10000u, 0x1FFFFu, 0x100000u, 0x40000000u, 0x80000000u, 0xFFFFFFFFu, + }; + for (size_t i = 0; i < sizeof(kCases32) / sizeof(kCases32[0]); i++) { + mu_assert("__builtin_clz disagrees with the reference", + __builtin_clz(kCases32[i]) == ref_clz32(kCases32[i])); + } + + static const uint64_t kCases64[] = { + 1ull, + 0x20000ull, + 0xFFFFFFFFull, + 0x100000000ull, + 0x8000000000000000ull, + 0xFFFFFFFFFFFFFFFFull, + }; + for (size_t i = 0; i < sizeof(kCases64) / sizeof(kCases64[0]); i++) { + mu_assert("__builtin_clzll disagrees with the reference", + __builtin_clzll(kCases64[i]) == ref_clz64(kCases64[i])); + } + + /* The two shift expressions the defect actually corrupted. */ + mu_assert("integer_vif log2_32 shift must be 1 for 0x00010000", + 16 - __builtin_clz(0x00010000u) == 1); + mu_assert("integer_vif log2_32 shift must be 16 for 0x80000000", + 16 - __builtin_clz(0x80000000u) == 16); + mu_assert("integer_adm get_best15_from32 shift must be 2 for 0x00010000", + 17 - __builtin_clz(0x00010000u) == 2); + mu_assert("integer_adm get_best15_from32 shift must be 16 for 0x40000000", + 17 - __builtin_clz(0x40000000u) == 16); + return NULL; +} + +char *run_tests(void) +{ + mu_run_test(test_clz32_from_msb_matches_reference); + mu_run_test(test_clz64_from_msb_matches_reference); + mu_run_test(test_builtin_clz_matches_reference); + return NULL; +} + +/* NOLINTEND(modernize-use-nullptr) */ diff --git a/core/test/test_convolution_edge_small.c b/core/test/test_convolution_edge_small.c new file mode 100644 index 000000000..3375914e7 --- /dev/null +++ b/core/test/test_convolution_edge_small.c @@ -0,0 +1,366 @@ +/** + * Copyright 2026 Lusoris + * SPDX-License-Identifier: BSD-2-Clause-Patent + * + * Regression test for the two float-convolution border defects reported + * upstream as Netflix/vmaf#1582 (the mirror half is also the subject of + * Netflix/vmaf#1581): + * + * 1. `convolution_edge_s` / `_sq_s` / `_xy_s` bounced an out-of-range tap + * exactly once. One bounce only lands in range when the plane is at + * least `radius + 1` samples across; below that the bounced index falls + * out the opposite side and the helper dereferences out of bounds + * (heap-buffer-overflow READ under ASan). + * + * 2. `convolution_x_c_s` / `convolution_y_c_s` derived the trailing border + * bound as `dim - (filter_width - radius)`, which is NEGATIVE for any + * plane narrower/shorter than the filter. The trailing loop then began + * at a negative index and wrote `dst[i * dst_stride - 1]` / + * `dst[-dst_stride + j]` — a heap underflow WRITE. + * + * Detection without a sanitizer: the source plane is embedded in a buffer + * poisoned with NaN everywhere outside the plane, and the destination plane + * is embedded in a NaN-poisoned buffer as well. Any tap that escapes the + * plane reads a NaN and taints the output; any write that escapes the plane + * replaces a poison NaN with a finite value. So: + * + * - every in-plane output sample must be finite (catches defect 1) + * - every out-of-plane guard sample must stay NaN (catches defect 2) + * + * Both assertions fail on the pre-fix tree for planes smaller than the + * filter and pass after it. `test_large_plane_bit_identical` pins the other + * half of the contract: for any plane at least `radius + 1` across the + * iterative fold must reproduce the single-bounce result bit-for-bit, so no + * in-contract score moves. + */ + +#include +#include +#include +#include +#include + +#include "test.h" + +#include "cpu.h" +#include "feature/common/convolution.h" +#include "feature/common/convolution_internal.h" /* convolution_reflect101 */ + +/* NOLINTBEGIN(modernize-use-nullptr): C translation unit. The fork builds C as + * C23, where clang-tidy also proposes the `nullptr` keyword, but the Windows + * MSVC legs compile the test tree with cl.exe, whose documented /std:clatest + * C23 feature set does not include `nullptr`. Same carve-out and reasoning as + * core/src/feature/float_motion.c. ADR-1138. */ + +/* Rows of NaN poison above and below the plane inside each buffer. */ +#define GUARD_ROWS 4 + +/* Normalised 5-tap Gaussian, the motion workhorse. */ +static const float kFilter5[5] = {0.054488685f, 0.244201342f, 0.402619947f, 0.244201342f, + 0.054488685f}; + +/* Normalised 3-tap Gaussian, the `motion_filter_size=3` option. */ +static const float kFilter3[3] = {0.25f, 0.5f, 0.25f}; + +/* Scale-0 VIF filter width: half-width 8, so every plane below 9 exercised + * the single-bounce defect. A delta kernel keeps the expected output equal + * to the input, which makes a NaN leak unambiguous. */ +static const float kFilter17[17] = { + 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 1.0f, + 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, +}; + +typedef struct { + float *base; /* poisoned allocation */ + float *plane; /* base + GUARD_ROWS * stride */ + size_t n; /* total float count in base */ +} PoisonBuf; + +static int poison_buf_alloc(PoisonBuf *b, int stride, int height) +{ + b->n = (size_t)(height + 2 * GUARD_ROWS) * (size_t)stride; + b->base = malloc(b->n * sizeof(float)); + if (!b->base) + return -1; + for (size_t i = 0; i < b->n; i++) { + b->base[i] = NAN; + } + b->plane = b->base + (size_t)GUARD_ROWS * (size_t)stride; + return 0; +} + +static void poison_buf_free(PoisonBuf *b) +{ + free(b->base); + b->base = NULL; + b->plane = NULL; +} + +/* Fill the w x h sub-rectangle with a finite ramp; leave the row tails and + * the guard rows poisoned. */ +static void poison_buf_fill_plane(PoisonBuf *b, int stride, int width, int height) +{ + for (int i = 0; i < height; i++) { + for (int j = 0; j < width; j++) { + b->plane[(size_t)i * (size_t)stride + (size_t)j] = (float)(i * width + j) + 1.0f; + } + } +} + +/* Every sample outside the w x h destination rectangle must still be NaN. */ +static int poison_buf_guards_intact(const PoisonBuf *b, int stride, int width, int height) +{ + for (size_t i = 0; i < (size_t)GUARD_ROWS * (size_t)stride; i++) { + if (!isnan(b->base[i])) + return 0; + } + const size_t after = (size_t)(GUARD_ROWS + height) * (size_t)stride; + for (size_t i = after; i < b->n; i++) { + if (!isnan(b->base[i])) + return 0; + } + /* Row tails between `width` and `stride` are never a valid output. */ + for (int i = 0; i < height; i++) { + for (int j = width; j < stride; j++) { + if (!isnan(b->plane[(size_t)i * (size_t)stride + (size_t)j])) + return 0; + } + } + return 1; +} + +static int plane_all_finite(const float *plane, int stride, int width, int height) +{ + for (int i = 0; i < height; i++) { + for (int j = 0; j < width; j++) { + if (!isfinite(plane[(size_t)i * (size_t)stride + (size_t)j])) + return 0; + } + } + return 1; +} + +/* Run one (filter, w, h) case through the scalar y-then-x passes. */ +static int run_case(const float *filter, int filter_width, int width, int height) +{ + const int stride = width + 8; + PoisonBuf src = {0}; + PoisonBuf dst = {0}; + PoisonBuf tmp = {0}; + int ok = 0; + + if (poison_buf_alloc(&src, stride, height)) + goto out; + if (poison_buf_alloc(&dst, stride, height)) + goto out; + if (poison_buf_alloc(&tmp, stride, height)) + goto out; + + poison_buf_fill_plane(&src, stride, width, height); + + convolution_y_c_s(filter, filter_width, src.plane, tmp.plane, width, height, stride, stride, 1); + convolution_x_c_s(filter, filter_width, tmp.plane, dst.plane, width, height, stride, stride, 1); + + ok = plane_all_finite(dst.plane, stride, width, height) && + poison_buf_guards_intact(&dst, stride, width, height) && + poison_buf_guards_intact(&tmp, stride, width, height); + +out: + poison_buf_free(&src); + poison_buf_free(&dst); + poison_buf_free(&tmp); + return ok; +} + +static char *test_scalar_5tap_small_planes(void) +{ + for (int h = 1; h <= 12; h++) { + for (int w = 1; w <= 12; w++) { + mu_assert("5-tap scalar convolution escaped the plane", run_case(kFilter5, 5, w, h)); + } + } + return NULL; +} + +static char *test_scalar_3tap_small_planes(void) +{ + for (int h = 1; h <= 8; h++) { + for (int w = 1; w <= 8; w++) { + mu_assert("3-tap scalar convolution escaped the plane", run_case(kFilter3, 3, w, h)); + } + } + return NULL; +} + +static char *test_scalar_17tap_small_planes(void) +{ + for (int h = 1; h <= 20; h++) { + for (int w = 1; w <= 20; w++) { + mu_assert("17-tap scalar convolution escaped the plane", run_case(kFilter17, 17, w, h)); + } + } + return NULL; +} + +/* Explicit single-bounce reflect-101 index, valid only for size >= radius + 1. + * This is the pre-fix formula, kept verbatim as the reference the iterative + * fold has to reproduce bit-for-bit above the minimum. */ +static int single_bounce(int idx, int size) +{ + if (idx < 0) { + return -idx; + } + if (idx >= size) { + return 2 * size - idx - 2; + } + return idx; +} + +/* Reference y-then-x separable pass built from single_bounce(). */ +static void reference_convolve(const float *src, float *scratch, float *dst, int width, int height, + int stride) +{ + const int radius = 2; + + for (int i = 0; i < height; i++) { + for (int j = 0; j < width; j++) { + float accum = 0.0f; + for (int k = 0; k < 5; k++) { + const int it = single_bounce(i - radius + k, height); + accum += kFilter5[k] * src[it * stride + j]; + } + scratch[i * stride + j] = accum; + } + } + for (int i = 0; i < height; i++) { + for (int j = 0; j < width; j++) { + float accum = 0.0f; + for (int k = 0; k < 5; k++) { + const int jt = single_bounce(j - radius + k, width); + accum += kFilter5[k] * scratch[i * stride + jt]; + } + dst[i * stride + j] = accum; + } + } +} + +/* Bit-exact float comparison without memcmp on a float object representation + * (clang-tidy bugprone-suspicious-memory-comparison): copy both operands into + * an integer of the same width and compare those. */ +/* The real content of the "no in-contract score moves" claim is an INTEGER + * one: for any plane at least `radius + 1` across, the iterative fold must + * return exactly what a single bounce returned. Assert that directly and + * exhaustively — it is platform-independent, unlike any float comparison + * between two separately-compiled accumulations (see + * test_large_plane_matches_single_bounce below for why that distinction + * matters). */ +/* Same-value-within-a-few-ULP comparison. See + * test_large_plane_matches_single_bounce for why bit-identity is not a + * portable assertion here. 8 ULP is far below any score-visible delta and + * still orders of magnitude tighter than a genuine fold divergence, which + * changes which SAMPLE is read and so moves results by O(1e-2). */ +static int planes_match_within_ulps(const float *a, const float *b, int width, int height, + int stride) +{ + for (int i = 0; i < height; i++) { + for (int j = 0; j < width; j++) { + const float x = a[i * stride + j]; + const float y = b[i * stride + j]; + const float scale = fmaxf(1.0f, fmaxf(fabsf(x), fabsf(y))); + if (!(fabsf(x - y) <= 8.0f * FLT_EPSILON * scale)) { + return 0; + } + } + } + return 1; +} + +static char *test_fold_matches_single_bounce_exactly(void) +{ + for (int size = 2; size <= 64; size++) { + /* In-contract means the overshoot a single bounce has to absorb stays + * inside the plane, i.e. |idx| and idx - (size-1) are at most + * size - 1. Outside that the two deliberately disagree: the single + * bounce leaves the plane (that is the defect) and the fold does not. */ + for (int idx = -(size - 1); idx <= 2 * (size - 1); idx++) { + const int folded = convolution_reflect101(idx, size); + const int bounced = single_bounce(idx, size); + if (folded != bounced) { + return "iterative fold disagrees with the single bounce in contract"; + } + if (folded < 0 || folded >= size) { + return "iterative fold returned an out-of-range index in contract"; + } + } + /* And out of contract the fold must still always land in the plane — + * that is the whole point of iterating. */ + for (int idx = -4 * size; idx <= 4 * size; idx++) { + const int folded = convolution_reflect101(idx, size); + if (folded < 0 || folded >= size) { + return "iterative fold left the plane out of contract"; + } + } + } + return NULL; +} + +/* End-to-end cross-check at a size where the fold and a single bounce must + * agree. Compared within a few ULP rather than bit-for-bit ON PURPOSE. + * + * This assertion used to demand bit-identity and passed on x86-64 while + * failing on arm64 macOS. The fold was not the cause: it is integer-only + * (pinned exactly by test_fold_matches_single_bounce_exactly above). The + * difference is floating-point contraction. reference_convolve() below reads + * the file-scope `kFilter5`, which the compiler can constant-fold and + * vectorize, while the library kernel receives an opaque `const float + * *filter`. On a target with FMA in its baseline — every arm64 — clang + * contracts `accum += filter[k] * src[...]` to an fma in one and not + * necessarily the other, so the last bit legitimately differs. x86-64 only + * agreed because FMA is not in its baseline. Two separately-compiled float + * accumulations are simply not a portable bit-identity claim. */ +static char *test_large_plane_matches_single_bounce(void) +{ + enum { WIDTH = 24, HEIGHT = 24, STRIDE = 32 }; + const size_t n = (size_t)STRIDE * (size_t)HEIGHT; + float *src = calloc(n, sizeof(float)); + float *dst = calloc(n, sizeof(float)); + float *tmp = calloc(n, sizeof(float)); + float *ref = calloc(n, sizeof(float)); + float *rtmp = calloc(n, sizeof(float)); + int matches = 0; + + if (src && dst && tmp && ref && rtmp) { + for (int i = 0; i < HEIGHT; i++) { + for (int j = 0; j < WIDTH; j++) { + src[i * STRIDE + j] = (float)((i * 37 + j * 11) % 251) / 251.0f; + } + } + + convolution_y_c_s(kFilter5, 5, src, tmp, WIDTH, HEIGHT, STRIDE, STRIDE, 1); + convolution_x_c_s(kFilter5, 5, tmp, dst, WIDTH, HEIGHT, STRIDE, STRIDE, 1); + reference_convolve(src, rtmp, ref, WIDTH, HEIGHT, STRIDE); + + matches = planes_match_within_ulps(dst, ref, WIDTH, HEIGHT, STRIDE); + } + + free(src); + free(dst); + free(tmp); + free(ref); + free(rtmp); + mu_assert("iterative fold moved an in-contract result", matches); + return NULL; +} + +char *run_tests(void) +{ + vmaf_init_cpu(); + mu_run_test(test_scalar_5tap_small_planes); + mu_run_test(test_scalar_3tap_small_planes); + mu_run_test(test_scalar_17tap_small_planes); + mu_run_test(test_fold_matches_single_bounce_exactly); + mu_run_test(test_large_plane_matches_single_bounce); + return NULL; +} + +/* NOLINTEND(modernize-use-nullptr) */ diff --git a/core/test/test_float_vif_min_dim.c b/core/test/test_float_vif_min_dim.c index 27abc98d0..1ef9ca3ea 100644 --- a/core/test/test_float_vif_min_dim.c +++ b/core/test/test_float_vif_min_dim.c @@ -2,21 +2,25 @@ * Copyright 2026 Lusoris * SPDX-License-Identifier: BSD-2-Clause-Patent * - * Regression test — float_vif must reject frames smaller than 7x7 at - * init() time with -EINVAL instead of proceeding into the scale-0 - * Gaussian filter path, which uses a 17-tap kernel and requires at - * least 7 pixels in each dimension to avoid a double-free / out-of- - * bounds read in the filter scratch buffer. + * Regression test — float_vif must reject, at init() time and with -EINVAL, + * every frame the four-scale VIF ladder cannot process without walking its + * reflect-101 mirror out of the plane. * - * Bug: float_vif init() allocated VIF scratch buffers unconditionally. - * When min(scaled_w, scaled_h) < 9 the 17-tap Gaussian filter at scale 0 - * walks its reflect-101 mirror index out of the allocated region - * (half-width = 8, worst-case mirrored index = h - 9 which underflows for - * h < 9), triggering UB (ASan heap-buffer-overflow or double-free on - * close()). + * Original bug: float_vif init() allocated VIF scratch buffers + * unconditionally. When min(scaled_w, scaled_h) < 9 the 17-tap Gaussian at + * scale 0 walks its mirror index out of the allocated region (half-width = 8, + * worst-case mirrored index = h - 9, which underflows for h < 9), triggering + * UB (ASan heap-buffer-overflow or double-free on close()). Fixed by a + * `< 9` guard. * - * Fix: float_vif init() now checks scaled_w < 9 || scaled_h < 9 and - * returns -EINVAL with a human-readable log message before any allocation. + * Netflix/vmaf#1582 (2026-09-03): that guard covered scale 0 only. + * compute_vif() halves the working dimension once per scale and re-convolves + * with the scale's own Gaussian ({17, 9, 5, 3} taps), so the real floor is + * max over s of `((filter_width_s / 2) + 1) << s` = 16. Input in 9..15 + * passed the old guard and reached the scale-3 convolution with a 1px plane; + * reproduced under ASan as a heap-buffer-overflow READ in + * convolution_internal.h. The guard is now derived from + * vif_get_min_dim(kernelscale) instead of a hard-coded 9. * * This file exercises the CPU path only (GPU paths require a live GPU * device and are covered by per-backend smoke tests). @@ -32,6 +36,12 @@ #include "feature/feature_extractor.h" +/* NOLINTBEGIN(modernize-use-nullptr): C translation unit. The fork builds C as + * C23, where clang-tidy also proposes the `nullptr` keyword, but the Windows + * MSVC legs compile the test tree with cl.exe, whose documented /std:clatest + * C23 feature set does not include `nullptr`. Same carve-out and reasoning as + * core/src/feature/float_motion.c. ADR-1138. */ + /* Allocate priv, apply option defaults (so string fields like * vif_prescale_method are not NULL), call init(), then call close() and * free priv. Returns the init() return code. @@ -39,8 +49,8 @@ * float_vif has VMAF_OPT_TYPE_STRING options (vif_prescale_method) that are * dereferenced inside init() before the dimension guard fires. Applying * defaults first via vmaf_option_set(opt, priv, NULL) avoids a strcmp(NULL,…) - * crash for the acceptance tests. The rejection tests (w/h < 9) return - * -EINVAL before reaching the string option access, so they are unaffected. */ + * crash for the acceptance tests. The rejection tests return -EINVAL before + * reaching the string option access, so they are unaffected. */ static int invoke_init(VmafFeatureExtractor *fex, unsigned w, unsigned h) { void *priv = calloc(1, fex->priv_size); @@ -68,7 +78,7 @@ static int invoke_init(VmafFeatureExtractor *fex, unsigned w, unsigned h) } /* ------------------------------------------------------------------ */ -/* float_vif — reject frames below the 7x7 floor */ +/* float_vif — reject frames below the four-scale ladder minimum */ /* ------------------------------------------------------------------ */ static char *test_float_vif_rejects_1x1(void) @@ -80,13 +90,20 @@ static char *test_float_vif_rejects_1x1(void) return NULL; } -static char *test_float_vif_rejects_8x8(void) +static char *test_float_vif_rejects_below_ladder_minimum(void) { VmafFeatureExtractor *fex = vmaf_get_feature_extractor_by_name("float_vif"); mu_assert("float_vif extractor missing", fex != NULL); /* 17-tap filter half-width=8: mirror formula 2*h-ii-2 underflows for h<9 */ - int rc = invoke_init(fex, 8u, 8u); - mu_assert("float_vif: init(8x8) must return -EINVAL (below 9-pixel floor)", rc == -EINVAL); + mu_assert("float_vif: init(8x8) must return -EINVAL", invoke_init(fex, 8u, 8u) == -EINVAL); + /* Netflix/vmaf#1582: 9..15 cleared the old scale-0-only floor of 9 but + * still walked the scale-3 convolution (3-tap, needs >= 2) off the end of + * a 1px plane — reproduced as an ASan heap-buffer-overflow at + * convolution_internal.h. */ + mu_assert("float_vif: init(9x9) must return -EINVAL (scale-3 plane is 1px)", + invoke_init(fex, 9u, 9u) == -EINVAL); + mu_assert("float_vif: init(15x15) must return -EINVAL (scale-3 plane is 1px)", + invoke_init(fex, 15u, 15u) == -EINVAL); return NULL; } @@ -96,7 +113,7 @@ static char *test_float_vif_rejects_Nx8(void) mu_assert("float_vif extractor missing", fex != NULL); /* width above floor but height below */ int rc = invoke_init(fex, 64u, 8u); - mu_assert("float_vif: init(64x8) must return -EINVAL (height < 9)", rc == -EINVAL); + mu_assert("float_vif: init(64x8) must return -EINVAL (height below minimum)", rc == -EINVAL); return NULL; } @@ -106,16 +123,18 @@ static char *test_float_vif_rejects_8xN(void) mu_assert("float_vif extractor missing", fex != NULL); /* height above floor but width below */ int rc = invoke_init(fex, 8u, 64u); - mu_assert("float_vif: init(8x64) must return -EINVAL (width < 9)", rc == -EINVAL); + mu_assert("float_vif: init(8x64) must return -EINVAL (width below minimum)", rc == -EINVAL); return NULL; } -static char *test_float_vif_accepts_9x9(void) +static char *test_float_vif_accepts_16x16(void) { VmafFeatureExtractor *fex = vmaf_get_feature_extractor_by_name("float_vif"); mu_assert("float_vif extractor missing", fex != NULL); - int rc = invoke_init(fex, 9u, 9u); - mu_assert("float_vif: init(9x9) must succeed (exact minimum)", rc == 0); + /* 16 is the exact four-scale minimum at kernelscale 1.0: + * max over s of ((filter_width_s / 2) + 1) << s = max(9, 10, 12, 16). */ + int rc = invoke_init(fex, 16u, 16u); + mu_assert("float_vif: init(16x16) must succeed (exact ladder minimum)", rc == 0); return NULL; } @@ -131,10 +150,12 @@ static char *test_float_vif_accepts_576x324(void) char *run_tests(void) { mu_run_test(test_float_vif_rejects_1x1); - mu_run_test(test_float_vif_rejects_8x8); + mu_run_test(test_float_vif_rejects_below_ladder_minimum); mu_run_test(test_float_vif_rejects_Nx8); mu_run_test(test_float_vif_rejects_8xN); - mu_run_test(test_float_vif_accepts_9x9); + mu_run_test(test_float_vif_accepts_16x16); mu_run_test(test_float_vif_accepts_576x324); return NULL; } + +/* NOLINTEND(modernize-use-nullptr) */ diff --git a/core/test/test_model_feature_overload_ownership.c b/core/test/test_model_feature_overload_ownership.c new file mode 100644 index 000000000..0d519795a --- /dev/null +++ b/core/test/test_model_feature_overload_ownership.c @@ -0,0 +1,291 @@ +/** + * Copyright 2026 Lusoris + * SPDX-License-Identifier: BSD-2-Clause-Patent + * + * Netflix/vmaf#1242 regression test — the VmafFeatureDictionary ownership + * contract of vmaf_model_feature_overload() and + * vmaf_model_collection_feature_overload(). + * + * Three defects were reported / found: + * + * 1. vmaf_model_feature_overload() returned `-ENOMEM` straight out of the + * loop when vmaf_dictionary_merge() failed, skipping the unconditional + * `vmaf_dictionary_free(&opts_dict)` at the tail — an unconditional leak + * of the caller's dictionary under the documented "the call consumes it" + * contract. Now the loop breaks and falls through to the common exit. + * `test_overload_merge_failure_consumes_dict` drives that exact branch + * without malloc-fail injection; the leak itself is what LeakSanitizer + * reports in the ASan lane. + * + * 2. vmaf_model_collection_feature_overload() discarded + * vmaf_dictionary_copy()'s return value, leaked the partially built copy + * and silently skipped the remaining sub-models while still able to + * report success. It also dereferenced `*model_collection` without + * checking it, and never checked `model` / `feature_name` / `opts_dict`. + * `test_collection_overload_rejects_null_collection_handle` below is + * undefined behaviour on the pre-fix tree (a NULL dereference) and a + * defined -EINVAL after. + * + * 3. ("on failure the caller still owns the + * dictionary") and ("ownership transfers on both + * success and failure") documented opposite contracts. Both now state + * the implemented rule: the argument-validation guards consume nothing, + * every other path consumes. The guard cases below pin that half, and + * are what core/test/test_model_collection_api.c already relies on. + */ + +#include +#include +#include +#include + +#include "libvmaf/libvmaf.h" +#include "libvmaf/model.h" + +#include "dict.h" +#include "model.h" /* internal: VmafModel layout, for the override assertion */ +#include "test.h" + +/* NOLINTBEGIN(modernize-use-nullptr): C translation unit. The fork builds C as + * C23, where clang-tidy also proposes the `nullptr` keyword, but the Windows + * MSVC legs compile the test tree with cl.exe, whose documented /std:clatest + * C23 feature set does not include `nullptr`. Same carve-out and reasoning as + * core/src/feature/float_motion.c. ADR-1138. */ + +/* Build a single-entry options dictionary. */ +static VmafFeatureDictionary *make_dict(void) +{ + VmafFeatureDictionary *dict = NULL; + if (vmaf_feature_dictionary_set(&dict, "adm_enhancement_gain_limit", "1.1")) + return NULL; + return dict; +} + +/* ------------------------------------------------------------------ */ +/* Argument-validation guards must NOT consume the dictionary. */ +/* ------------------------------------------------------------------ */ + +static char *test_overload_guards_do_not_consume(void) +{ + VmafFeatureDictionary *dict = make_dict(); + mu_assert("vmaf_feature_dictionary_set failed", dict != NULL); + + mu_assert("overload(NULL model) must return -EINVAL", + vmaf_model_feature_overload(NULL, "adm", dict) == -EINVAL); + + /* The dictionary must still be intact and owned by us. */ + VmafDictionaryEntry *e = + vmaf_dictionary_get((VmafDictionary **)&dict, "adm_enhancement_gain_limit", 0); + mu_assert("guard path must leave the dictionary intact", e != NULL); + mu_assert("guard path must leave the value intact", strcmp(e->val, "1.1") == 0); + + /* ... and releasing it here must be well-defined (not a double free). */ + mu_assert("caller must be able to free after a guard rejection", + vmaf_feature_dictionary_free(&dict) == 0); + return NULL; +} + +static char *test_overload_null_feature_name_guard(void) +{ + VmafFeatureDictionary *dict = make_dict(); + mu_assert("vmaf_feature_dictionary_set failed", dict != NULL); + + VmafModel *model = NULL; + VmafModelConfig cfg = {0}; + mu_assert("vmaf_model_load failed", vmaf_model_load(&model, &cfg, "vmaf_v0.6.1") == 0); + + mu_assert("overload(NULL feature_name) must return -EINVAL", + vmaf_model_feature_overload(model, NULL, dict) == -EINVAL); + mu_assert("caller must be able to free after a guard rejection", + vmaf_feature_dictionary_free(&dict) == 0); + + vmaf_model_destroy(model); + return NULL; +} + +/* ------------------------------------------------------------------ */ +/* Collection wrapper: a NULL collection behind a non-NULL handle must */ +/* be rejected, not dereferenced. */ +/* ------------------------------------------------------------------ */ + +static char *test_collection_overload_rejects_null_collection_handle(void) +{ + VmafFeatureDictionary *dict = make_dict(); + mu_assert("vmaf_feature_dictionary_set failed", dict != NULL); + + VmafModel *model = NULL; + VmafModelConfig cfg = {0}; + mu_assert("vmaf_model_load failed", vmaf_model_load(&model, &cfg, "vmaf_v0.6.1") == 0); + + /* Pre-fix this reached `mc->cnt` with mc == NULL: undefined behaviour that + * the optimiser is free to (and, under LTO, does) delete, leaving the + * function returning garbage. UBSan/ASan trap on it; post-fix it is a + * defined -EINVAL on every build. */ + VmafModelCollection *mc = NULL; + mu_assert("collection overload(*model_collection == NULL) must return -EINVAL", + vmaf_model_collection_feature_overload(model, &mc, "adm", dict) == -EINVAL); + mu_assert("caller must be able to free after a guard rejection", + vmaf_feature_dictionary_free(&dict) == 0); + + vmaf_model_destroy(model); + return NULL; +} + +static char *test_collection_overload_null_lead_model_guard(void) +{ + VmafFeatureDictionary *dict = make_dict(); + mu_assert("vmaf_feature_dictionary_set failed", dict != NULL); + + VmafModelCollection *mc = NULL; + mu_assert("collection overload(NULL model) must return -EINVAL", + vmaf_model_collection_feature_overload(NULL, &mc, "adm", dict) == -EINVAL); + mu_assert("collection overload(NULL handle) must return -EINVAL", + vmaf_model_collection_feature_overload(NULL, NULL, "adm", dict) == -EINVAL); + mu_assert("caller must be able to free after a guard rejection", + vmaf_feature_dictionary_free(&dict) == 0); + return NULL; +} + +/* ------------------------------------------------------------------ */ +/* Success path: the dictionary is consumed and the override lands. */ +/* Never freed here — doing so would be a double free under the fixed */ +/* contract, and LSan proves the callee released it. */ +/* ------------------------------------------------------------------ */ + +static char *test_overload_success_consumes_dict(void) +{ + VmafFeatureDictionary *dict = make_dict(); + mu_assert("vmaf_feature_dictionary_set failed", dict != NULL); + + VmafModel *model = NULL; + VmafModelConfig cfg = {0}; + mu_assert("vmaf_model_load failed", vmaf_model_load(&model, &cfg, "vmaf_v0.6.1") == 0); + + mu_assert("overload must succeed", vmaf_model_feature_overload(model, "adm", dict) == 0); + + VmafDictionaryEntry *e = + vmaf_dictionary_get(&model->feature[0].opts_dict, "adm_enhancement_gain_limit", 0); + mu_assert("override must land on the model's feature", e != NULL); + mu_assert("override value must be the one supplied", strcmp(e->val, "1.1") == 0); + + vmaf_model_destroy(model); + return NULL; +} + +/* ------------------------------------------------------------------ */ +/* The merge-failure branch must still consume the dictionary. */ +/* */ +/* vmaf_dictionary_merge() returns NULL both on allocation failure and */ +/* when there is nothing to merge, and vmaf_model_feature_overload() */ +/* maps that to -ENOMEM. Handing it a heap-allocated EMPTY dictionary */ +/* reaches that branch deterministically, with no malloc-fail */ +/* injection: pre-fix it `return`ed straight out and leaked the */ +/* caller's dictionary (the defect Netflix/vmaf#1242 reports); */ +/* post-fix it breaks to the common exit and releases it. The return */ +/* code is -ENOMEM either way, so the leak half is what LeakSanitizer */ +/* sees in the ASan lane — hence this case must NOT free the dict. */ +/* ------------------------------------------------------------------ */ + +static char *test_overload_merge_failure_consumes_dict(void) +{ + VmafModel *model = NULL; + VmafModelConfig cfg = {0}; + mu_assert("vmaf_model_load failed", vmaf_model_load(&model, &cfg, "vmaf_v0.6.1") == 0); + + VmafDictionary *empty = calloc(1, sizeof(*empty)); + mu_assert("calloc failed", empty != NULL); + + mu_assert("overload must report -ENOMEM when the merge yields nothing", + vmaf_model_feature_overload(model, "adm", (VmafFeatureDictionary *)empty) == -ENOMEM); + + vmaf_model_destroy(model); + return NULL; +} + +/* The asymmetry adversarial review found in the headers: a feature_name that + * matches nothing in THIS model is a successful no-op returning 0, and the + * dictionary is still consumed. feature.h and docs/api used to tell callers + * they still owned it in that case, which is a double free. Only + * vmaf_use_feature -- which resolves against the global extractor registry -- + * rejects an unknown name and hands the dictionary back. + * + * Under ASan a regression here surfaces as a double free rather than a silent + * contract violation, because the test frees only what it is told it owns. */ +static char *test_overload_unknown_feature_name_returns_zero_and_consumes(void) +{ + VmafFeatureDictionary *dict = NULL; + mu_assert("dictionary_set failed", + vmaf_feature_dictionary_set(&dict, "some_key", "some_value") == 0); + + VmafModel *model = NULL; + VmafModelConfig cfg = {0}; + mu_assert("vmaf_model_load failed", vmaf_model_load(&model, &cfg, "vmaf_v0.6.1") == 0); + + /* No model carries a feature named like this, so the match loop never fires. */ + mu_assert("overload(unknown feature_name) must report success, not -EINVAL", + vmaf_model_feature_overload(model, "no_such_feature_xyz", dict) == 0); + /* Deliberately NOT freeing `dict` here: the call consumed it. Freeing would + * be the double free the old header wording invited. */ + + vmaf_model_destroy(model); + return NULL; +} + +/* vmaf_use_feature is the one that does hand the dictionary back, because it + * resolves the name against the global registry before touching it. */ +static char *test_use_feature_unknown_name_does_not_consume(void) +{ + VmafFeatureDictionary *dict = NULL; + mu_assert("dictionary_set failed", + vmaf_feature_dictionary_set(&dict, "some_key", "some_value") == 0); + + VmafConfiguration cfg = {.log_level = VMAF_LOG_LEVEL_NONE}; + VmafContext *ctx = NULL; + mu_assert("vmaf_init failed", vmaf_init(&ctx, cfg) == 0); + + mu_assert("use_feature(unknown name) must return -EINVAL", + vmaf_use_feature(ctx, "no_such_feature_xyz", dict) == -EINVAL); + /* The caller still owns it here -- releasing is required, not a double free. */ + mu_assert("caller must be able to free after an unknown-name rejection", + vmaf_feature_dictionary_free(&dict) == 0); + + (void)vmaf_close(ctx); + return NULL; +} + +/* Grouped the way core/test/test_iqa_helpers.c and test_cli_parse.c group + * theirs: each `mu_run_test` expands to several statements, so eight of them in + * one function crosses .clang-tidy's readability-function-size + * StatementThreshold of 120. Splitting keeps the ratchet at zero for this file + * without a suppression. */ + +/* Argument-validation guards: the caller still owns `opts_dict` when any of + * these fires. */ +static char *run_guard_tests(void) +{ + mu_run_test(test_overload_guards_do_not_consume); + mu_run_test(test_overload_null_feature_name_guard); + mu_run_test(test_collection_overload_rejects_null_collection_handle); + mu_run_test(test_collection_overload_null_lead_model_guard); + return NULL; +} + +/* Ownership transfer: which paths consume the dictionary and which do not. */ +static char *run_consumption_tests(void) +{ + mu_run_test(test_overload_success_consumes_dict); + mu_run_test(test_overload_unknown_feature_name_returns_zero_and_consumes); + mu_run_test(test_use_feature_unknown_name_does_not_consume); + mu_run_test(test_overload_merge_failure_consumes_dict); + return NULL; +} + +char *run_tests(void) +{ + char *msg = run_guard_tests(); + if (msg) + return msg; + return run_consumption_tests(); +} + +/* NOLINTEND(modernize-use-nullptr) */ diff --git a/core/test/test_motion_convolution_oob.c b/core/test/test_motion_convolution_oob.c new file mode 100644 index 000000000..decabc3b0 --- /dev/null +++ b/core/test/test_motion_convolution_oob.c @@ -0,0 +1,166 @@ +/** + * Copyright 2026 Lusoris + * SPDX-License-Identifier: BSD-2-Clause-Patent + * + * Regression test for two heap out-of-bounds WRITES reachable from the public + * C API through `float_motion`, found by adversarial review of the + * Netflix/vmaf#1582 harvest (ADR-1166). + * + * Why this file exists separately from test_motion_min_dim.c: that file only + * calls `fex->init()`, which is where the guard lives, and + * test_convolution_edge_small.c calls `convolution_y_c_s` / `convolution_x_c_s` + * DIRECTLY. Neither reaches `convolution_f32_c_s`, which on any AVX2 host — + * every CI runner and the dev workstation — dispatches to + * `convolution_f32_avx_s`. The two defects below live on that dispatched path, + * so no existing test could observe them. These cases drive the real + * `vmaf_read_pictures` entry point instead. + * + * Defect 1 — the AVX2/AVX-512 vertical border split was never clamped. + * `convolution_avx.c` and `convolution_avx512.c` each derive + * `i_vec_end = height - radius` at three sites. For a plane shorter than the + * radius that is negative, so the trailing border loop starts at a negative + * row and the leading one runs past the end — both WRITES. The scalar path was + * clamped by the #1582 fix; the SIMD twins it actually dispatches to were not. + * + * Reachability — defect 2 makes defect 1 live: `motion_check_min_dim` skipped + * its check entirely when `motion_filter_size == 1`, but `motion_blur_plane` + * keeps `filter_size = 5` for that value and only swaps in the no-op + * coefficients `FILTER_5_NO_OP_s`. Radius stays 2, so a 1-row plane reached + * the convolution. `motion_filter_size` is a documented option with range + * 0..9, so this was a live public path, not a synthetic one. + * + * Defect 3 — `motion_chroma_heights` allocated `h / 2` chroma rows while + * `picture.c` and the guard both use the ceiling `(h + 1) >> 1`. For an odd + * luma height the copy wrote one row past `ref`, `tmp` and every + * `MOTION_BLUR_RING` blur buffer, for both U and V. Even heights were + * unaffected, which is why the golden fixtures (576x324, 1920x1080) never + * caught it. + * + * Under `-Db_sanitize=address` the pre-fix tree reports + * "heap-buffer-overflow ... WRITE of size 4 in convolution_f32_avx_s" for the + * first case. Post-fix the guard returns -EINVAL and the odd-height case + * scores cleanly. + */ + +#include +#include +#include + +#include "libvmaf/libvmaf.h" +#include "libvmaf/picture.h" + +#include "test.h" + +/* NOLINTBEGIN(modernize-use-nullptr): C translation unit. The fork builds C as + * C23, where clang-tidy also proposes the `nullptr` keyword, but MSVC's + * documented /std:clatest C23 feature set does not include `nullptr` while the + * required Windows build compiles this TU with cl.exe, and this test mirrors + * the C spelling of the surface it exercises. ADR-1138. */ + +/* Score one ref/dis pair through the public API with `float_motion` configured + * from `opts` ("key=value" pairs, ':'-separated). Returns the + * vmaf_read_pictures() result, or the earlier failure that prevented it. */ +static int drive_float_motion(unsigned w, unsigned h, enum VmafPixelFormat pix_fmt, + const char *opts) +{ + VmafConfiguration cfg = {.log_level = VMAF_LOG_LEVEL_NONE}; + VmafContext *ctx = NULL; + int err = vmaf_init(&ctx, cfg); + if (err) + return err; + + VmafFeatureDictionary *dict = NULL; + char buf[128]; + (void)snprintf(buf, sizeof(buf), "%s", opts); + /* Split "k=v:k=v" by hand rather than with strtok_r: that function is + * POSIX-only and the MSVC legs (which ship strtok_s instead) failed to + * compile this file with "call to undeclared function 'strtok_r'". Plain + * strtok is on the fork's banned-function list (docs/principles.md + * S1.2 rule 30), so neither variant is an option here. */ + char *cursor = buf; + while (*cursor != '\0') { + char *sep = strchr(cursor, ':'); + if (sep != NULL) + *sep = '\0'; + char *eq = strchr(cursor, '='); + if (eq != NULL) { + *eq = '\0'; + (void)vmaf_feature_dictionary_set(&dict, cursor, eq + 1); + } + if (sep == NULL) + break; + cursor = sep + 1; + } + + err = vmaf_use_feature(ctx, "float_motion", dict); + if (err) { + (void)vmaf_close(ctx); + return err; + } + + VmafPicture ref; + VmafPicture dis; + if (vmaf_picture_alloc(&ref, pix_fmt, 8, w, h) || vmaf_picture_alloc(&dis, pix_fmt, 8, w, h)) { + (void)vmaf_close(ctx); + return -ENOMEM; + } + + err = vmaf_read_pictures(ctx, &ref, &dis, 0); + (void)vmaf_read_pictures(ctx, NULL, NULL, 0); + (void)vmaf_close(ctx); + return err; +} + +/* motion_filter_size == 1 must NOT bypass the minimum-dimension guard: the + * kernel still convolves 5-wide, so radius is 2 and the floor is 3x3. */ +static char *test_filter_size_1_still_enforces_the_5_tap_minimum(void) +{ + mu_assert("float_motion(8x1, motion_filter_size=1) must be rejected, not convolved", + drive_float_motion(8, 1, VMAF_PIX_FMT_YUV420P, "motion_filter_size=1") != 0); + mu_assert("float_motion(8x2, motion_filter_size=1) must be rejected (below the 3-row floor)", + drive_float_motion(8, 2, VMAF_PIX_FMT_YUV420P, "motion_filter_size=1") != 0); + return NULL; +} + +/* The same option on a frame at or above the floor must still work — the fix + * must not turn a reachable defect into a blanket rejection. */ +static char *test_filter_size_1_accepts_frames_at_the_minimum(void) +{ + mu_assert("float_motion(8x8, motion_filter_size=1) must score", + drive_float_motion(8, 8, VMAF_PIX_FMT_YUV420P, "motion_filter_size=1") == 0); + return NULL; +} + +/* Odd luma height with motion_add_uv: chroma is ceil(h/2) rows in the picture, + * and the motion planes must be allocated to match. Pre-fix this overran every + * chroma buffer by one row. */ +static char *test_odd_height_chroma_planes_are_not_overrun(void) +{ + mu_assert("float_motion(8x5, motion_add_uv) must score without overrunning chroma", + drive_float_motion(8, 5, VMAF_PIX_FMT_YUV420P, "motion_add_uv=true") == 0); + mu_assert("float_motion(8x7, motion_add_uv) must score without overrunning chroma", + drive_float_motion(8, 7, VMAF_PIX_FMT_YUV420P, "motion_add_uv=true") == 0); + /* Even heights were always fine; pin that they stay fine. */ + mu_assert("float_motion(8x8, motion_add_uv) must score", + drive_float_motion(8, 8, VMAF_PIX_FMT_YUV420P, "motion_add_uv=true") == 0); + return NULL; +} + +/* The Netflix golden resolution must be unaffected by all of the above. */ +static char *test_golden_resolution_still_scores(void) +{ + mu_assert("float_motion(576x324) must score", + drive_float_motion(576, 324, VMAF_PIX_FMT_YUV420P, "motion_add_uv=false") == 0); + return NULL; +} + +char *run_tests(void) +{ + mu_run_test(test_filter_size_1_still_enforces_the_5_tap_minimum); + mu_run_test(test_filter_size_1_accepts_frames_at_the_minimum); + mu_run_test(test_odd_height_chroma_planes_are_not_overrun); + mu_run_test(test_golden_resolution_still_scores); + return NULL; +} + +/* NOLINTEND(modernize-use-nullptr) */ diff --git a/core/test/test_motion_min_dim.c b/core/test/test_motion_min_dim.c index e2f70845a..ad7b7b9d8 100644 --- a/core/test/test_motion_min_dim.c +++ b/core/test/test_motion_min_dim.c @@ -14,8 +14,17 @@ * * Fix: each of the three CPU motion extractors (motion, motion_v2, * float_motion) now checks h < 3 || w < 3 in init() and returns -EINVAL - * with a human-readable message. The same check is present on every - * GPU backend (CUDA, SYCL, Vulkan, HIP) that shares the formula. + * with a human-readable message. The same check is present on the CUDA, + * SYCL, HIP and (since the Netflix/vmaf#1582 harvest) Metal backends that + * share the formula. + * + * 2026-09-03 extension (Netflix/vmaf#1582 / Netflix/vmaf#1581): the luma-only + * form of that guard was incomplete. `float_motion` with `motion_add_uv=1` + * blurs the CHROMA planes at their subsampled dimensions, so a 4x4 YUV420P + * frame passed the >= 3 luma check and handed the 5-tap convolution a 2x2 + * plane — a live heap out-of-bounds read on plain CPU. The guard now runs + * against every plane that will actually be convolved, and the cases below + * pin that. * * This file exercises the CPU paths only (the GPU paths require a live * GPU device; they are validated by the per-backend smoke tests). @@ -23,13 +32,21 @@ #include #include +#include #include "libvmaf/picture.h" +#include "opt.h" #include "test.h" #include "feature/feature_extractor.h" +/* NOLINTBEGIN(modernize-use-nullptr): C translation unit. The fork builds C as + * C23, where clang-tidy also proposes the `nullptr` keyword, but the Windows + * MSVC legs compile the test tree with cl.exe, whose documented /std:clatest + * C23 feature set does not include `nullptr`. Same carve-out and reasoning as + * core/src/feature/float_motion.c. ADR-1138. */ + /* Helper: allocate priv, call init(), then call close() and free priv. * Returns the init() return code. The extractor's close() contract * tolerates partially-initialised state (same pattern as @@ -52,49 +69,24 @@ static int invoke_init(VmafFeatureExtractor *fex, unsigned w, unsigned h) /* motion (integer_motion.c) */ /* ------------------------------------------------------------------ */ -static char *test_motion_rejects_1x1(void) -{ - VmafFeatureExtractor *fex = vmaf_get_feature_extractor_by_name("motion"); - mu_assert("motion extractor missing", fex != NULL); - int rc = invoke_init(fex, 1u, 1u); - mu_assert("motion: init(1x1) must return -EINVAL", rc == -EINVAL); - return NULL; -} - -static char *test_motion_rejects_2x2(void) -{ - VmafFeatureExtractor *fex = vmaf_get_feature_extractor_by_name("motion"); - mu_assert("motion extractor missing", fex != NULL); - int rc = invoke_init(fex, 2u, 2u); - mu_assert("motion: init(2x2) must return -EINVAL", rc == -EINVAL); - return NULL; -} - -static char *test_motion_rejects_1xN(void) +static char *test_motion_rejects_small_frames(void) { VmafFeatureExtractor *fex = vmaf_get_feature_extractor_by_name("motion"); mu_assert("motion extractor missing", fex != NULL); + mu_assert("motion: init(1x1) must return -EINVAL", invoke_init(fex, 1u, 1u) == -EINVAL); + mu_assert("motion: init(2x2) must return -EINVAL", invoke_init(fex, 2u, 2u) == -EINVAL); /* 1-row frame: width above floor but height below */ - int rc = invoke_init(fex, 64u, 1u); - mu_assert("motion: init(64x1) must return -EINVAL", rc == -EINVAL); + mu_assert("motion: init(64x1) must return -EINVAL", invoke_init(fex, 64u, 1u) == -EINVAL); return NULL; } -static char *test_motion_accepts_3x3(void) +static char *test_motion_accepts_valid_frames(void) { VmafFeatureExtractor *fex = vmaf_get_feature_extractor_by_name("motion"); mu_assert("motion extractor missing", fex != NULL); - int rc = invoke_init(fex, 3u, 3u); - mu_assert("motion: init(3x3) must succeed (exact minimum)", rc == 0); - return NULL; -} - -static char *test_motion_accepts_576x324(void) -{ - VmafFeatureExtractor *fex = vmaf_get_feature_extractor_by_name("motion"); - mu_assert("motion extractor missing", fex != NULL); - int rc = invoke_init(fex, 576u, 324u); - mu_assert("motion: init(576x324) must succeed (Netflix golden resolution)", rc == 0); + mu_assert("motion: init(3x3) must succeed (exact minimum)", invoke_init(fex, 3u, 3u) == 0); + mu_assert("motion: init(576x324) must succeed (Netflix golden resolution)", + invoke_init(fex, 576u, 324u) == 0); return NULL; } @@ -102,99 +94,159 @@ static char *test_motion_accepts_576x324(void) /* motion_v2 (integer_motion_v2.c) */ /* ------------------------------------------------------------------ */ -static char *test_motion_v2_rejects_1x1(void) +static char *test_motion_v2_rejects_small_frames(void) { VmafFeatureExtractor *fex = vmaf_get_feature_extractor_by_name("motion_v2"); mu_assert("motion_v2 extractor missing", fex != NULL); - int rc = invoke_init(fex, 1u, 1u); - mu_assert("motion_v2: init(1x1) must return -EINVAL", rc == -EINVAL); + mu_assert("motion_v2: init(1x1) must return -EINVAL", invoke_init(fex, 1u, 1u) == -EINVAL); + mu_assert("motion_v2: init(2x2) must return -EINVAL", invoke_init(fex, 2u, 2u) == -EINVAL); return NULL; } -static char *test_motion_v2_rejects_2x2(void) +static char *test_motion_v2_accepts_valid_frames(void) { VmafFeatureExtractor *fex = vmaf_get_feature_extractor_by_name("motion_v2"); mu_assert("motion_v2 extractor missing", fex != NULL); - int rc = invoke_init(fex, 2u, 2u); - mu_assert("motion_v2: init(2x2) must return -EINVAL", rc == -EINVAL); + mu_assert("motion_v2: init(3x3) must succeed (exact minimum)", invoke_init(fex, 3u, 3u) == 0); + mu_assert("motion_v2: init(576x324) must succeed (Netflix golden resolution)", + invoke_init(fex, 576u, 324u) == 0); return NULL; } -static char *test_motion_v2_accepts_3x3(void) +/* ------------------------------------------------------------------ */ +/* float_motion (float_motion.c) */ +/* ------------------------------------------------------------------ */ + +static char *test_float_motion_rejects_small_frames(void) { - VmafFeatureExtractor *fex = vmaf_get_feature_extractor_by_name("motion_v2"); - mu_assert("motion_v2 extractor missing", fex != NULL); - int rc = invoke_init(fex, 3u, 3u); - mu_assert("motion_v2: init(3x3) must succeed (exact minimum)", rc == 0); + VmafFeatureExtractor *fex = vmaf_get_feature_extractor_by_name("float_motion"); + mu_assert("float_motion extractor missing", fex != NULL); + mu_assert("float_motion: init(1x1) must return -EINVAL", invoke_init(fex, 1u, 1u) == -EINVAL); + mu_assert("float_motion: init(2x2) must return -EINVAL", invoke_init(fex, 2u, 2u) == -EINVAL); return NULL; } -static char *test_motion_v2_accepts_576x324(void) +static char *test_float_motion_accepts_valid_frames(void) { - VmafFeatureExtractor *fex = vmaf_get_feature_extractor_by_name("motion_v2"); - mu_assert("motion_v2 extractor missing", fex != NULL); - int rc = invoke_init(fex, 576u, 324u); - mu_assert("motion_v2: init(576x324) must succeed (Netflix golden resolution)", rc == 0); + VmafFeatureExtractor *fex = vmaf_get_feature_extractor_by_name("float_motion"); + mu_assert("float_motion extractor missing", fex != NULL); + mu_assert("float_motion: init(3x3) must succeed (exact minimum)", + invoke_init(fex, 3u, 3u) == 0); + mu_assert("float_motion: init(576x324) must succeed (Netflix golden resolution)", + invoke_init(fex, 576u, 324u) == 0); return NULL; } /* ------------------------------------------------------------------ */ -/* float_motion (float_motion.c) */ +/* float_motion + motion_add_uv — the chroma planes must be guarded */ +/* too (Netflix/vmaf#1582 / Netflix/vmaf#1581). */ /* ------------------------------------------------------------------ */ -static char *test_float_motion_rejects_1x1(void) +/* Apply option defaults, force motion_add_uv=true, then init(). */ +static int invoke_init_add_uv(VmafFeatureExtractor *fex, unsigned w, unsigned h) +{ + void *priv = calloc(1, fex->priv_size); + if (!priv) + return -1; + fex->priv = priv; + + int rc = -EINVAL; + if (fex->options) { + for (unsigned i = 0; fex->options[i].name; i++) { + const char *val = NULL; + if (strcmp(fex->options[i].name, "motion_add_uv") == 0) + val = "true"; + rc = vmaf_option_set(&fex->options[i], priv, val); + if (rc) + goto done; + } + } + rc = fex->init(fex, VMAF_PIX_FMT_YUV420P, 8u, w, h); + +done: + if (fex->close) + (void)fex->close(fex); + free(priv); + fex->priv = NULL; + return rc; +} + +static char *test_float_motion_add_uv_chroma_guard(void) { VmafFeatureExtractor *fex = vmaf_get_feature_extractor_by_name("float_motion"); mu_assert("float_motion extractor missing", fex != NULL); - int rc = invoke_init(fex, 1u, 1u); - mu_assert("float_motion: init(1x1) must return -EINVAL", rc == -EINVAL); + + /* 4x4 YUV420P: luma clears the >= 3 floor but chroma is 2x2, which used + * to reach convolution_edge_s and read one full row past the plane. */ + mu_assert("float_motion+uv: init(4x4) must return -EINVAL (2x2 chroma)", + invoke_init_add_uv(fex, 4u, 4u) == -EINVAL); + /* 3x3 YUV420P: chroma is 2x2 as well ((3 + 1) >> 1). */ + mu_assert("float_motion+uv: init(3x3) must return -EINVAL (2x2 chroma)", + invoke_init_add_uv(fex, 3u, 3u) == -EINVAL); + /* 5x5: chroma is 3x3, exactly the 5-tap minimum. */ + mu_assert("float_motion+uv: init(5x5) must succeed (3x3 chroma)", + invoke_init_add_uv(fex, 5u, 5u) == 0); + /* The Netflix golden resolution is unaffected. */ + mu_assert("float_motion+uv: init(576x324) must succeed", + invoke_init_add_uv(fex, 576u, 324u) == 0); return NULL; } -static char *test_float_motion_rejects_2x2(void) +/* ------------------------------------------------------------------ */ +/* Metal twins — the guard must fire before vmaf_metal_context_new(), */ +/* so the rejection half is host-side and needs no Apple GPU. On a */ +/* build without HAVE_METAL the extractors are not registered and the */ +/* case degrades to a no-op. */ +/* ------------------------------------------------------------------ */ + +static char *test_metal_motion_min_dim(void) { - VmafFeatureExtractor *fex = vmaf_get_feature_extractor_by_name("float_motion"); - mu_assert("float_motion extractor missing", fex != NULL); - int rc = invoke_init(fex, 2u, 2u); - mu_assert("float_motion: init(2x2) must return -EINVAL", rc == -EINVAL); + static const char *const kMetalMotionFex[] = { + "motion_metal", + "motion_v2_metal", + "float_motion_metal", + }; + + for (size_t i = 0; i < sizeof(kMetalMotionFex) / sizeof(kMetalMotionFex[0]); i++) { + VmafFeatureExtractor *fex = vmaf_get_feature_extractor_by_name(kMetalMotionFex[i]); + if (!fex) + continue; /* backend not built into this configuration */ + mu_assert("metal motion: init(1x1) must return -EINVAL", + invoke_init(fex, 1u, 1u) == -EINVAL); + mu_assert("metal motion: init(2x2) must return -EINVAL", + invoke_init(fex, 2u, 2u) == -EINVAL); + mu_assert("metal motion: init(64x2) must return -EINVAL", + invoke_init(fex, 64u, 2u) == -EINVAL); + } return NULL; } -static char *test_float_motion_accepts_3x3(void) +/* Split in two so neither driver exceeds the readability-function-size branch + * budget: every mu_run_test expansion contributes two branches. */ +static char *run_integer_motion_tests(void) { - VmafFeatureExtractor *fex = vmaf_get_feature_extractor_by_name("float_motion"); - mu_assert("float_motion extractor missing", fex != NULL); - int rc = invoke_init(fex, 3u, 3u); - mu_assert("float_motion: init(3x3) must succeed (exact minimum)", rc == 0); + mu_run_test(test_motion_rejects_small_frames); + mu_run_test(test_motion_accepts_valid_frames); + mu_run_test(test_motion_v2_rejects_small_frames); + mu_run_test(test_motion_v2_accepts_valid_frames); return NULL; } -static char *test_float_motion_accepts_576x324(void) +static char *run_float_and_metal_motion_tests(void) { - VmafFeatureExtractor *fex = vmaf_get_feature_extractor_by_name("float_motion"); - mu_assert("float_motion extractor missing", fex != NULL); - int rc = invoke_init(fex, 576u, 324u); - mu_assert("float_motion: init(576x324) must succeed (Netflix golden resolution)", rc == 0); + mu_run_test(test_float_motion_rejects_small_frames); + mu_run_test(test_float_motion_accepts_valid_frames); + mu_run_test(test_float_motion_add_uv_chroma_guard); + mu_run_test(test_metal_motion_min_dim); return NULL; } char *run_tests(void) { - /* motion */ - mu_run_test(test_motion_rejects_1x1); - mu_run_test(test_motion_rejects_2x2); - mu_run_test(test_motion_rejects_1xN); - mu_run_test(test_motion_accepts_3x3); - mu_run_test(test_motion_accepts_576x324); - /* motion_v2 */ - mu_run_test(test_motion_v2_rejects_1x1); - mu_run_test(test_motion_v2_rejects_2x2); - mu_run_test(test_motion_v2_accepts_3x3); - mu_run_test(test_motion_v2_accepts_576x324); - /* float_motion */ - mu_run_test(test_float_motion_rejects_1x1); - mu_run_test(test_float_motion_rejects_2x2); - mu_run_test(test_float_motion_accepts_3x3); - mu_run_test(test_float_motion_accepts_576x324); - return NULL; + char *msg = run_integer_motion_tests(); + if (msg) + return msg; + return run_float_and_metal_motion_tests(); } + +/* NOLINTEND(modernize-use-nullptr) */ diff --git a/core/test/test_spinner.cpp b/core/test/test_spinner.cpp new file mode 100644 index 000000000..681f50c17 --- /dev/null +++ b/core/test/test_spinner.cpp @@ -0,0 +1,115 @@ +/** + * Copyright 2026 Lusoris + * SPDX-License-Identifier: BSD-2-Clause-Patent + * + * Netflix/vmaf#743 regression test — the CLI progress spinner must not emit + * UTF-8 braille (or a bare CSI erase-to-EOL) to a console that cannot render + * it. + * + * The CLI writes the spinner to stderr with a plain byte-oriented fprintf and + * never touched the Windows console output code page, so under the default + * OEM/ANSI pages the two braille glyphs decoded as mojibake — six garbage + * glyphs under cp437, an illegal multibyte sequence under cp936 — on every + * frame of every run. The same fprintf also emitted `\033[K` + * unconditionally, which legacy conhost prints literally because + * ENABLE_VIRTUAL_TERMINAL_PROCESSING is off by default. + * + * The CLI now switches the console to UTF-8 + VT for the duration of the run + * and restores the previous state on exit; when the console refuses either, + * the selectors below pick the ASCII table and space padding. Driving the + * selectors with the code pages a real conhost reports is what makes this + * testable without a Windows box — the two Windows CI legs then prove the + * console-init code compiles and links. + * + * The POSIX half matters just as much: the CLI passes + * SPINNER_CODEPAGE_UTF8 / vt_enabled=1 unconditionally there, so the emitted + * bytes must stay identical to the pre-fix build. + */ + +#include + +#include "test.h" + +#include "spinner.h" + +namespace +{ + +/* Code pages a real Windows console reports by default. */ +constexpr unsigned kCp437 = 437u; /* US OEM — the conhost default */ +constexpr unsigned kCp1252 = 1252u; /* Western ANSI */ +constexpr unsigned kCp936 = 936u; /* Simplified Chinese GBK */ + +bool table_is_ascii_only(const char *const *table, unsigned length) +{ + for (unsigned i = 0; i < length; i++) { + for (const char *p = table[i]; *p; p++) { + if (static_cast(*p) > 0x7Fu) + return false; + } + } + return true; +} + +const char *test_utf8_console_gets_the_braille_table() +{ + unsigned length = 0; + const char *const *table = spinner_table_for_codepage(SPINNER_CODEPAGE_UTF8, &length); + mu_assert("UTF-8 console must get the braille table", table == spinner); + mu_assert("UTF-8 console must get the braille table length", length == spinner_length); + mu_assert("the braille table must actually be non-ASCII", !table_is_ascii_only(table, length)); + return nullptr; +} + +const char *test_legacy_code_pages_get_the_ascii_table() +{ + static const unsigned kCases[] = {kCp437, kCp1252, kCp936, 0u}; + for (const unsigned code_page : kCases) { + unsigned length = 0; + const char *const *table = spinner_table_for_codepage(code_page, &length); + mu_assert("a non-UTF-8 console must get the ASCII table", table == spinner_ascii); + mu_assert("a non-UTF-8 console must get the ASCII table length", + length == spinner_ascii_length); + mu_assert("the fallback table must be pure ASCII", table_is_ascii_only(table, length)); + } + return nullptr; +} + +const char *test_erase_eol_is_vt_gated() +{ + mu_assert("a VT-capable console gets the CSI erase-to-EOL", + std::strcmp(spinner_erase_eol(1), "\033[K") == 0); + const char *fallback = spinner_erase_eol(0); + mu_assert("a VT-less console must not get a CSI sequence", + std::strchr(fallback, '\033') == nullptr); + mu_assert("the VT-less fallback must still clear trailing characters", + std::strlen(fallback) > 0); + return nullptr; +} + +/* Byte-for-byte anchor for the POSIX path: the first and last braille entries + * must be exactly the bytes the pre-fix table carried, and every entry must be + * two 3-byte U+28xx code points. */ +const char *test_braille_table_bytes_unchanged() +{ + mu_assert("braille table must still have 56 entries", spinner_length == 56u); + mu_assert("first entry must be U+2880 U+2800", + std::strcmp(spinner[0], "\xe2\xa2\x80\xe2\xa0\x80") == 0); + mu_assert("last entry must be U+2800 U+2840", + std::strcmp(spinner[spinner_length - 1], "\xe2\xa0\x80\xe2\xa1\x80") == 0); + for (const char *const entry : spinner) { + mu_assert("every braille entry is two 3-byte code points", std::strlen(entry) == 6u); + } + return nullptr; +} + +} // namespace + +mu_message_t run_tests() +{ + mu_run_test(test_utf8_console_gets_the_braille_table); + mu_run_test(test_legacy_code_pages_get_the_ascii_table); + mu_run_test(test_erase_eol_is_vt_gated); + mu_run_test(test_braille_table_bytes_unchanged); + return nullptr; +} diff --git a/core/tools/AGENTS.md b/core/tools/AGENTS.md index ff4d3bb14..34462f928 100644 --- a/core/tools/AGENTS.md +++ b/core/tools/AGENTS.md @@ -216,3 +216,27 @@ tools/ multiplication overflow. - In `vmaf.cpp`, `ModelArrays` is encapsulated with private members and RAII accessors; all internal helpers reside in an anonymous namespace. + +## Progress-line rendering is console-capability-driven (ADR-1166) + +`spinner.h` now carries two glyph tables and two selectors, and `vmaf.cpp` +resolves them from the console's **actual** capabilities: + +- `spinner[]` — the upstream UTF-8 braille table. Byte-for-byte unchanged; + `core/test/test_spinner.cpp` pins the first and last entries and asserts + every entry is exactly 6 bytes, so a well-meaning re-encode (universal + character names, a different braille range) fails the fast suite. Do not + rewrite these literals as `\uXXXX` escapes: MSVC's narrow execution charset + is the ANSI code page, where they would not round-trip. +- `spinner_ascii[]` + `spinner_table_for_codepage()` + `spinner_erase_eol()` — + the fallback for a console that reports a non-UTF-8 code page or refuses VT + processing. + +On POSIX both selectors are called with `SPINNER_CODEPAGE_UTF8` and +`vt_enabled = 1`, so the emitted bytes are identical to the pre-ADR-1166 form. +Keep it that way — the golden-gate CLI invocations parse this stream. + +`WindowsConsoleGuard` in `vmaf.cpp` is declared in `main()` **before every +`goto cleanup` target**, which is what makes the restore run on the error +paths. Moving its declaration below a jump target is ill-formed C++ and would +silently leave the user's console in UTF-8 + VT mode after an error exit. diff --git a/core/tools/spinner.h b/core/tools/spinner.h index 673500818..68a58c8e6 100644 --- a/core/tools/spinner.h +++ b/core/tools/spinner.h @@ -19,10 +19,64 @@ /* ADR-0809: static gives internal linkage in both C and C++ so the header * can be included in a C++ TU without an ODR violation if ever included in * more than one translation unit. */ -static const char *spinner[] = { + +/* + * Progress-spinner glyph tables for the interactive (`isatty`) CLI display. + * + * The braille table is UTF-8. Netflix/vmaf#743 reports that it renders as + * mojibake on Windows, because the CLI writes raw bytes to stderr with + * fprintf and never touches the console output code page: under the default + * OEM/ANSI pages the two-glyph spinner decodes as + * + * cp437 -> six garbage glyphs, so the progress line is four characters + * wider than the `\r` overwrite assumes + * cp1252 -> six garbage glyphs (a different set) + * cp936 -> an illegal multibyte sequence; conhost draws replacement boxes + * + * and it repeats on every frame for the whole run. The CLI now switches the + * console to UTF-8 for the duration of the run (restoring the previous code + * page on exit) and falls back to the ASCII table below when the console + * refuses UTF-8 or VT sequences. On POSIX the selector always returns the + * braille table, so output is byte-identical to before. + */ +static const char *const spinner[] = { "⢀⠀", "⡀⠀", "⠄⠀", "⢂⠀", "⡂⠀", "⠅⠀", "⢃⠀", "⡃⠀", "⠍⠀", "⢋⠀", "⡋⠀", "⠍⠁", "⢋⠁", "⡋⠁", "⠍⠉", "⠋⠉", "⠋⠉", "⠉⠙", "⠉⠙", "⠉⠩", "⠈⢙", "⠈⡙", "⢈⠩", "⡀⢙", "⠄⡙", "⢂⠩", "⡂⢘", "⠅⡘", "⢃⠨", "⡃⢐", "⠍⡐", "⢋⠠", "⡋⢀", "⠍⡁", "⢋⠁", "⡋⠁", "⠍⠉", "⠋⠉", "⠋⠉", "⠉⠙", "⠉⠙", "⠉⠩", "⠈⢙", "⠈⡙", "⠈⠩", "⠀⢙", "⠀⡙", "⠀⠩", "⠀⢘", "⠀⡘", "⠀⠨", "⠀⢐", "⠀⡐", "⠀⠠", "⠀⢀", "⠀⡀"}; static const unsigned spinner_length = sizeof(spinner) / sizeof(spinner[0]); + +/* Pure-ASCII fallback: safe under every Windows console code page. */ +static const char *const spinner_ascii[] = {"|", "/", "-", "\\"}; + +static const unsigned spinner_ascii_length = sizeof(spinner_ascii) / sizeof(spinner_ascii[0]); + +/* Windows code page for UTF-8 (CP_UTF8). Spelled out so this header stays + * usable without , and so the POSIX build can pass it verbatim. */ +#define SPINNER_CODEPAGE_UTF8 65001u + +/* + * Pick the glyph table for a console output code page. Anything other than + * UTF-8 gets the ASCII table; POSIX callers pass SPINNER_CODEPAGE_UTF8 + * unconditionally, which keeps their output byte-identical to before. + */ +static inline const char *const *spinner_table_for_codepage(unsigned code_page, unsigned *length) +{ + if (code_page == SPINNER_CODEPAGE_UTF8) { + *length = spinner_length; + return spinner; + } + *length = spinner_ascii_length; + return spinner_ascii; +} + +/* + * Erase-to-end-of-line for the progress line. Legacy conhost has + * ENABLE_VIRTUAL_TERMINAL_PROCESSING off by default and prints the CSI + * sequence literally as "<-[K"; pad with spaces there instead. + */ +static inline const char *spinner_erase_eol(int vt_enabled) +{ + return vt_enabled ? "\033[K" : " "; +} diff --git a/core/tools/test/meson.build b/core/tools/test/meson.build index 879bf19e0..27d89fd33 100644 --- a/core/tools/test/meson.build +++ b/core/tools/test/meson.build @@ -1,7 +1,17 @@ +# Netflix/vmaf#1573 hunk (c): every shell-driven test here invokes a binary +# from the build tree, but none declared `depends`. Meson only rebuilds the +# targets a SELECTED test declares (mesonbuild/mtest.py rebuild_deps(): "if not +# targets: return True"), so running one of these as a subset — +# `meson test test_vmaf_cuda_gpumask`, `meson test --suite=gpu` — built nothing +# and the script died with exit 127 ("./tools/vmaf: No such file or directory"). +# `workdir` pins the cwd the scripts' relative `./tools/...` paths assume +# instead of relying on mtest's chdir to the build root. if get_option('enable_cuda') test_vmaf_cuda_gpumask = find_program('test_vmaf_cuda_gpumask.sh') test('test_vmaf_cuda_gpumask', test_vmaf_cuda_gpumask, suite : ['slow', 'gpu'], + depends : [vmaf], + workdir : meson.project_build_root(), timeout : 10) endif @@ -9,6 +19,8 @@ endif test_vmaf_per_shot = find_program('test_vmaf_per_shot.sh') test('test_vmaf_per_shot', test_vmaf_per_shot, suite : ['slow'], + depends : [vmaf_per_shot], + workdir : meson.project_build_root(), env: [ 'MESON_BUILD_ROOT=' + meson.project_build_root(), 'MESON_SOURCE_ROOT=' + meson.project_source_root(), @@ -19,6 +31,8 @@ test('test_vmaf_per_shot', test_vmaf_per_shot, test_vmaf_roi_high_bitdepth = find_program('test_vmaf_roi_high_bitdepth.sh') test('test_vmaf_roi_high_bitdepth', test_vmaf_roi_high_bitdepth, suite : ['slow'], + depends : [vmaf_roi], + workdir : meson.project_build_root(), env: [ 'MESON_BUILD_ROOT=' + meson.project_build_root(), 'MESON_SOURCE_ROOT=' + meson.project_source_root(), diff --git a/core/tools/vmaf.cpp b/core/tools/vmaf.cpp index a102c4516..b450f12b7 100644 --- a/core/tools/vmaf.cpp +++ b/core/tools/vmaf.cpp @@ -1100,6 +1100,140 @@ double wall_time_s() } #endif +#ifdef _WIN32 +/* Some older Windows SDK headers predate the VT console mode flag. */ +#ifndef ENABLE_VIRTUAL_TERMINAL_PROCESSING +#define ENABLE_VIRTUAL_TERMINAL_PROCESSING 0x0004 +#endif + +/* + * Netflix/vmaf#743: the CLI writes the UTF-8 braille spinner and a `\033[K` + * erase-to-EOL straight to stderr with fprintf, but nothing in the tree ever + * set the console output code page or enabled VT processing — so under the + * default OEM/ANSI code pages the spinner rendered as mojibake and legacy + * conhost printed the CSI sequence literally, on every frame of every run. + * + * Switch the console to UTF-8 and enable VT for the life of the process, and + * restore whatever was there on the way out (including the `goto cleanup` + * paths — the guard is declared before every jump target, so C++ runs its + * destructor on each of them). Whatever the console refuses is reflected back + * through console_progress_style(), which then falls back to the ASCII table + * and space padding. No effect on POSIX: the whole class is #ifdef'd out. + */ +class WindowsConsoleGuard +{ + public: + WindowsConsoleGuard() + { + prev_code_page_ = GetConsoleOutputCP(); + if (prev_code_page_ != 0 && prev_code_page_ != CP_UTF8) + code_page_changed_ = SetConsoleOutputCP(CP_UTF8) != 0; + + const HANDLE h = GetStdHandle(STD_ERROR_HANDLE); + if (h != INVALID_HANDLE_VALUE && GetConsoleMode(h, &prev_mode_) != 0) { + const DWORD wanted = prev_mode_ | ENABLE_VIRTUAL_TERMINAL_PROCESSING; + if (wanted != prev_mode_) + mode_changed_ = SetConsoleMode(h, wanted) != 0; + } + } + + WindowsConsoleGuard(const WindowsConsoleGuard &) = delete; + WindowsConsoleGuard &operator=(const WindowsConsoleGuard &) = delete; + + ~WindowsConsoleGuard() + { + if (code_page_changed_) + (void)SetConsoleOutputCP(prev_code_page_); + if (mode_changed_) { + const HANDLE h = GetStdHandle(STD_ERROR_HANDLE); + if (h != INVALID_HANDLE_VALUE) + (void)SetConsoleMode(h, prev_mode_); + } + } + + private: + UINT prev_code_page_ = 0; + DWORD prev_mode_ = 0; + bool code_page_changed_ = false; + bool mode_changed_ = false; +}; + +#endif /* _WIN32 */ + +namespace +{ + +/* Glyph table + erase-to-EOL sequence for the interactive progress line. */ +struct ProgressStyle { + const char *const *table; + unsigned length; + const char *erase_eol; +}; + +/* + * Resolve the progress-line style from the console's ACTUAL capabilities. + * On POSIX this is unconditionally the braille table plus "\033[K", so the + * emitted bytes are identical to the pre-Netflix/vmaf#743 behaviour. + */ +#ifdef _WIN32 +unsigned console_output_code_page() +{ + const UINT cp = GetConsoleOutputCP(); + return (cp != 0) ? static_cast(cp) : 0u; +} + +int console_vt_enabled() +{ + const HANDLE h = GetStdHandle(STD_ERROR_HANDLE); + DWORD mode = 0; + /* A redirected stderr has no console mode; raw bytes reach the file or + * pipe unmodified, so the CSI sequence is fine there. */ + if (h == INVALID_HANDLE_VALUE || GetConsoleMode(h, &mode) == 0) + return 1; + return (mode & ENABLE_VIRTUAL_TERMINAL_PROCESSING) != 0; +} +#else +unsigned console_output_code_page() +{ + return SPINNER_CODEPAGE_UTF8; +} + +int console_vt_enabled() +{ + return 1; +} +#endif + +/* + * Emit one interactive progress line. Netflix/vmaf#743: the glyph table and + * the erase-to-EOL sequence come from the console's actual capabilities, so a + * Windows console that refuses UTF-8 or VT gets ASCII plus space padding + * instead of mojibake and a literal erase sequence. On POSIX the style is + * always the braille table plus the CSI erase, i.e. the emitted bytes are + * identical to the previous unconditional form. + */ +void emit_progress_line(const ProgressStyle &style, unsigned picture_index, float fps) +{ + (void)fprintf(stderr, "\r%u frame%s %s %.2f FPS%s", picture_index + 1, + picture_index ? "s" : " ", style.table[picture_index % style.length], fps, + style.erase_eol); + (void)fflush(stderr); +} + +ProgressStyle console_progress_style() +{ + const unsigned code_page = console_output_code_page(); + const int vt_enabled = console_vt_enabled(); + + ProgressStyle style; + style.length = 0; + style.table = spinner_table_for_codepage(code_page, &style.length); + style.erase_eol = spinner_erase_eol(vt_enabled); + return style; +} + +} // namespace + /* Drive the main per-frame fetch + process loop. Returns the number of frames * successfully consumed (the post-increment `picture_index` value the original * inline loop used to compute `picture_index - 1` in pooling). Stops at EOF @@ -1110,6 +1244,7 @@ unsigned run_frame_loop(VmafContext *vmaf, video_input *vid_ref, video_input *vi { float fps = 0.; const double t0 = wall_time_s(); + const ProgressStyle progress_style = console_progress_style(); unsigned picture_index; for (picture_index = 0;; picture_index++) { @@ -1152,9 +1287,7 @@ unsigned run_frame_loop(VmafContext *vmaf, video_input *vid_ref, video_input *vi fps = static_cast((picture_index + 1) / (wall_time_s() - t0)); } - (void)fprintf(stderr, "\r%u frame%s %s %.2f FPS\033[K", picture_index + 1, - picture_index ? "s" : " ", spinner[picture_index % spinner_length], fps); - (void)fflush(stderr); + emit_progress_line(progress_style, picture_index, fps); } const int err = vmaf_read_pictures(vmaf, &pic_ref, &pic_dist, picture_index); @@ -1308,6 +1441,22 @@ int main(int argc, char *argv[]) int ret = 0; const int istty = isatty(fileno(stderr)); +#ifdef _WIN32 + /* Netflix/vmaf#743: put the console into UTF-8 + VT mode for the run and + * restore it on every exit path, including the `goto cleanup` spine. + * + * `static` here is load-bearing, not a style choice. cli_parse() below + * terminates the process directly for --help, --version and every + * argument error: usage_exit() is [[noreturn]] and calls exit(), which + * does NOT destroy objects with automatic storage duration. As a plain + * local, this guard therefore never ran its destructor on those paths and + * left the user's console in UTF-8 + VT mode after a bare `vmaf --help`. + * Objects with static storage duration ARE destroyed by exit() + * ([basic.start.term]), so the restore now runs on the exit() paths, on + * the `goto cleanup` spine and on a normal return alike. */ + static const WindowsConsoleGuard console_guard; +#endif + CLISettings c; cli_parse(argc, argv, &c); diff --git a/docs/adr/0806-feature-dictionary-ownership.md b/docs/adr/0806-feature-dictionary-ownership.md index f6dcf61f4..dcc1dfdd7 100644 --- a/docs/adr/0806-feature-dictionary-ownership.md +++ b/docs/adr/0806-feature-dictionary-ownership.md @@ -1,6 +1,6 @@ # ADR-0806: VmafFeatureDictionary caller-ownership contract -- **Status**: Accepted +- **Status**: Superseded by [ADR-1166](1166-upstream-issue-harvest.md) - **Date**: 2026-05-29 - **Deciders**: lusoris - **Tags**: `api`, `memory`, `testing` diff --git a/docs/adr/1166-upstream-issue-harvest.md b/docs/adr/1166-upstream-issue-harvest.md new file mode 100644 index 000000000..c13495bbf --- /dev/null +++ b/docs/adr/1166-upstream-issue-harvest.md @@ -0,0 +1,108 @@ + +# ADR-1166: Harvest stale upstream Netflix/vmaf reports, verify each against the fork, fix what still bites + +- **Status**: Accepted +- **Date**: 2026-09-03 +- **Deciders**: Lusoris +- **Tags**: process, upstream, bug, build, windows, api, docs + +## Context + +Netflix/vmaf carries a long tail of open, unworked bug reports — some years old, +several with a patch attached that nobody merged. The fork diverged from +upstream a long time ago (ADR-0700 moved `libvmaf/` to `core/`, several C files +became C++, and CUDA / SYCL / HIP / Metal backends were added), so an upstream +report is never automatically applicable and never automatically stale: some +defects were fixed here years ago, some were never present, some are present +*verbatim*, and a few are **wider here than upstream** because a fork-added +backend or SIMD path copied the defective shape. + +Nothing in the fork's process converted that queue into either fixes or +recorded knowledge. Every session that looked at an upstream issue re-did the +same "does this still affect us?" investigation from scratch, and the answer — +including the negative answers, which are the expensive ones to re-derive — +evaporated with the session. + +The forces: + +- Upstream reports are free signal about real defects in code we still ship. +- They are also noisy: a report's own diagnosis is frequently wrong about the + fork (and sometimes about upstream), so it cannot be trusted without local + verification. +- Porting an upstream patch verbatim is often wrong here: paths moved, the fork + guards inputs upstream does not, and at least one upstream proposal + (Netflix/vmaf#1422) is retracted by a later upstream PR (Netflix/vmaf#1551). +- The fork's own hard rules (CLAUDE.md §12) demand an ADR, docs, changelog, + `docs/state.md` row and a regression test per behavioural change, which makes + a per-issue drip of micro-PRs expensive; the user has explicitly asked for + bundles instead of 200-item queues. + +## Decision + +We harvest upstream reports in periodic batches. Each candidate is **verified +against the fork's own tree** — read the real file at the real path, run the +real reproducer — and assigned one of four verdicts: ALREADY-FIXED, +NOT-APPLICABLE, AFFECTS-FORK, or NEEDS-HARDWARE. Everything that still bites and +is safe to batch is fixed in one bundled PR with a regression test per fix; +everything else gets a `docs/state.md` row citing the upstream reference and the +evidence. The complete triage table — including the negative verdicts — is +written to a numbered research digest under `docs/research/`, because that table +is the durable output of the work: it is what stops the next session +re-investigating a closed question. + +Where the fork's fix deliberately differs from what upstream proposes, the +divergence and its reason go into `docs/rebase-notes.md`, so the next +`/sync-upstream` knows why the two trees disagree. + +## Alternatives considered + +| Option | Pros | Cons | Why not chosen | +|---|---|---|---| +| **Harvest, verify, fix the confirmed subset, record the rest** (chosen) | Real defects get fixed; negative verdicts are recorded once and reused; divergences are documented for the next sync | Verification is the expensive part — each candidate needs the file read and, where possible, a reproducer | — | +| Ignore upstream reports entirely | Zero cost | Leaves live memory-safety defects in shipped code. This batch alone found a reachable heap out-of-bounds read on the plain CPU path (Netflix/vmaf#1582), a silently-wrong-score path on older Windows hardware (Netflix/vmaf#1551), and a static-link break for every downstream FFmpeg build (Netflix/vmaf#1178) | Rejected: the defects are real and reachable | +| Port every upstream patch blindly | Cheap per item; keeps the diff close to upstream | Half of them do not apply (paths moved, already fixed here), and porting Netflix/vmaf#1422's `__lzcnt` form would have *introduced* the silent-wrong-score defect upstream's own #1551 retracts. Upstream's #1178 patch is not even valid meson (`else compiler.get_id() == 'clang':`) and its clang→`-lc++` mapping is wrong on Linux | Rejected: the fork must re-derive, not transcribe | +| File findings back upstream only | Cheapest; helps everyone | Does not fix our shipped binary, and the harvested queue is evidence that upstream reports can sit for years. This session is also a read-only visitor to a repository we do not own | Rejected as the *only* action; the digest records what would be worth reporting | +| One PR per upstream issue | Small, easy reviews | Nine issues × (ADR + docs + changelog + state row + rebase note) is a merge-train stall, and the fixes overlap (three issues touch the same mirror helper) | Rejected: bundle per round, per the user's standing direction | + +## Consequences + +- **Positive**: the confirmed subset is fixed with a regression test each; the + triage table means a future session answers "does upstream #N affect us?" by + reading one file. Three of the fixes close memory-safety defects that were + reachable from the public C API with supported inputs. +- **Negative**: two of the fixes are user-visible behaviour changes — + `float_vif` now rejects frames below 16 px (it used to accept 9 px and read + out of bounds at scale 3), and `float_motion` with `motion_add_uv` now + validates the chroma planes. Both convert previously-undefined behaviour into + a documented `-EINVAL`, but a caller feeding sub-16px frames will see a new + error. The public `VmafFeatureDictionary` ownership contract is also now + written the same way in all three headers, which means one of the two + previously-contradictory readings is now explicitly wrong. +- **Neutral / follow-ups**: the digest lists the confirmed-but-not-batched + items (Netflix/vmaf#1564, #930, #1568, #1109, #766, #818, #1305, #1494) with + their evidence; each has a `docs/state.md` row and needs its own PR, because + each either moves scores, changes CLI grammar, or needs hardware we do not + have here. The Metal `motion_v2` mirror off-by-one found while triaging + Netflix/vmaf#1580 is one of those: fixing it moves Metal scores and needs an + Apple GPU for the parity run. + +## References + +- Netflix/vmaf#1580, #1242, #743, #1582, #1581, #1573, #1551, #1422, #1178 — + the batch fixed here. +- Netflix/vmaf#1564, #930, #1568, #1109, #766, #818, #1305, #1494 — confirmed, + deferred, one `docs/state.md` row each. +- [docs/research/1166-upstream-issue-harvest-2026-09-03.md](../research/1166-upstream-issue-harvest-2026-09-03.md) + — the full triage table with per-candidate evidence. +- [ADR-0700](0700-vmafx-repo-layout.md) — the `libvmaf/` → `core/` rename + that makes upstream paths non-obvious. +- [ADR-0806](0806-feature-dictionary-ownership.md) — the earlier, internally + inconsistent codification of the dictionary ownership contract this ADR + supersedes in substance (see the header rewrite). +- [ADR-1138](1138-c-translation-units-keep-null.md) — the `NULL`-in-C + carve-out the new test files reuse. +- [ADR-1142](1142-whole-codebase-standards.md) — the clang-tidy ratchet the + touched files are measured against. +- Source: `req` (paraphrased) — fix the upstream-reported defects that were + confirmed to still affect the fork, in one bundled draft PR, and record the + rest. diff --git a/docs/adr/README.md b/docs/adr/README.md index ab09f0ec0..b7b2e3e24 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -710,7 +710,7 @@ ADRs may exist there for local session continuity, but the tracked | [ADR-0797](0797-openapi-rest-schema.md) | vmafx-server OpenAPI REST contract | Accepted | server, api, rest, openapi, swagger, go, vmafx-server | | [ADR-0802](0802-ci-runner-image-standardization.md) | CI Runner Image Standardization — Pin ubuntu-latest to ubuntu-24.04 | Accepted | `ci`, `build` | | [ADR-0804](0804-vmaf-context-get-backend.md) | Add `vmaf_context_get_backend` — additive ABI introspection | Accepted | api, abi, backend, gpu, fork-local | -| [ADR-0806](0806-feature-dictionary-ownership.md) | VmafFeatureDictionary caller-ownership contract | Accepted | `api`, `memory`, `testing` | +| [ADR-0806](0806-feature-dictionary-ownership.md) | VmafFeatureDictionary caller-ownership contract | Superseded by [ADR-1166](1166-upstream-issue-harvest.md) | `api`, `memory`, `testing` | | [ADR-0809](0809-cli-cpp23-conversion.md) | C++23 Wave 8 — CLI conversion (`cli_parse.c` → `.cpp`, `vmaf.c` → `.cpp`) | Accepted | `cpp23`, `build`, `cli`, `raii`, `fork-local` | | [ADR-0811](0811-security-codeql-go-pvr.md) | Security hardening — CodeQL Go coverage + codeql-config | Accepted | `ci`, `security`, `codeql`, `go`, `dependabot`, `ossf` | | [ADR-0812](0812-renovate-go-rust-scheduling.md) | Renovate — Go/Cargo grouping, schedule, and concurrent-PR cap | Accepted | `ci`, `build`, `deps` | @@ -994,3 +994,4 @@ ADRs may exist there for local session continuity, but the tracked | [ADR-1146](1146-speed-cambi-upstream-mirror-rework.md) | SPEED and CAMBI feature translation units reworked to fork lint profile (0 warnings, bit-exact max-precision numerical identity preserved). | Accepted | 2026-09-02 | lint, ci, refactor, feature, speed, cambi, bit-exact | | [ADR-1154](1154-hip-backend-gaps.md) | AMD ROCm HIP backend gap closure: promotes 11 feature extractors to active GPU execution bringing active HIP extractors to 17/19 on AMD hardware, fixes pointer-to-pointer kernel parameter packaging in float_psnr_hip and float_moment_hip, fixes parameter ordering in float_moment_hip, fixes parameter types and partial buffer sizes to double in integer_ms_ssim_hip, fixes option dictionary serialization in integer_cambi_hip, fixes chroma plane copying in integer_psnr_hip, implements vmaf_hip_dispatch_supports with VMAF_HIP_DISPATCH env support, drains gpu_pending in libvmaf serial flush, prunes dead uncompiled files (adm_decouple.hip, integer_moment_hip.h/moment_score.hip), and formally defers integer_ssim_hip and integer_adm_hip. | Accepted | 2026-09-03 | hip, rocm, gpu, dispatch, parity, docs | | [ADR-1168](1168-default-model-single-source.md) | The default VMAF model is defined in exactly one place | Accepted | model, ci-gate, c-api, go, python, single-source | +| [ADR-1166](1166-upstream-issue-harvest.md) | Harvest stale upstream Netflix/vmaf reports, verify each against the fork, fix what still bites | Accepted | process, upstream, bug, build, windows, api, docs | diff --git a/docs/adr/_index_fragments/0806-feature-dictionary-ownership.md b/docs/adr/_index_fragments/0806-feature-dictionary-ownership.md index a4a13c259..a3f145b9d 100644 --- a/docs/adr/_index_fragments/0806-feature-dictionary-ownership.md +++ b/docs/adr/_index_fragments/0806-feature-dictionary-ownership.md @@ -1 +1 @@ -| [ADR-0806](0806-feature-dictionary-ownership.md) | VmafFeatureDictionary caller-ownership contract | Accepted | `api`, `memory`, `testing` | +| [ADR-0806](0806-feature-dictionary-ownership.md) | VmafFeatureDictionary caller-ownership contract | Superseded by [ADR-1166](1166-upstream-issue-harvest.md) | `api`, `memory`, `testing` | diff --git a/docs/adr/_index_fragments/1166-upstream-issue-harvest.md b/docs/adr/_index_fragments/1166-upstream-issue-harvest.md new file mode 100644 index 000000000..a5eb1e6a1 --- /dev/null +++ b/docs/adr/_index_fragments/1166-upstream-issue-harvest.md @@ -0,0 +1 @@ +| [ADR-1166](1166-upstream-issue-harvest.md) | Harvest stale upstream Netflix/vmaf reports, verify each against the fork, fix what still bites | Accepted | process, upstream, bug, build, windows, api, docs | diff --git a/docs/adr/_index_fragments/_order.txt b/docs/adr/_index_fragments/_order.txt index 137392095..51b0c29d5 100644 --- a/docs/adr/_index_fragments/_order.txt +++ b/docs/adr/_index_fragments/_order.txt @@ -903,3 +903,4 @@ 1146-speed-cambi-upstream-mirror-rework 1154-hip-backend-gaps 1168-default-model-single-source +1166-upstream-issue-harvest diff --git a/docs/api/index.md b/docs/api/index.md index abaf478d9..3b78d405b 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -273,13 +273,63 @@ if (err < 0) { /* -errno */ } err = vmaf_feature_dictionary_set(&opts, "enable_apsnr", "true"); err = vmaf_use_feature(ctx, "psnr", opts); -/* On success, `ctx` owns `opts`. Do NOT free on success. */ -/* On failure: */ -if (err < 0) { - vmaf_feature_dictionary_free(&opts); -} +/* The call consumed `opts` unless it rejected an argument — see below. */ +``` + +### Ownership: who frees the dictionary + +Three calls accept a `VmafFeatureDictionary`: `vmaf_use_feature()`, +`vmaf_model_feature_overload()` and +`vmaf_model_collection_feature_overload()`. They share most of a rule, with +one deliberate difference. + +> **All three:** a `-EINVAL` caused by a `NULL` argument takes nothing — the +> caller still owns the dictionary. On every other return, success or failure +> (including `-ENOMEM` from the internal merge/copy), the call has already +> released it and the caller **must not** free it. +> +> **`vmaf_use_feature()` only:** it also takes nothing when `feature_name` +> names no registered feature. It resolves the name against the global +> extractor registry and returns `-EINVAL` before touching the dictionary. + +The difference matters, and getting it wrong is a double free. The two model +overloads match `feature_name` against the features of *one particular model*. +A name that matches nothing there is **not** an error — it is a successful +no-op that returns `0` — and the dictionary is consumed anyway. Only +`vmaf_use_feature()` can report an unknown name and hand the dictionary back. + +In practice: free the dictionary yourself only when the call returned +`-EINVAL` **and** you either passed a `NULL` argument or called +`vmaf_use_feature()`. Otherwise never. + +```c +/* Consumed — success. Freeing here would be a double free. */ +if (vmaf_use_feature(ctx, "psnr", opts) == 0) + opts = NULL; + +/* Consumed — the merge ran out of memory. Still do not free. */ + +/* NOT consumed — vmaf_use_feature rejected the name before taking anything. */ +if (vmaf_use_feature(ctx, "no_such_feature", opts2) == -EINVAL) + vmaf_feature_dictionary_free(&opts2); + +/* CONSUMED, and it returned 0. The model simply has no "psnr" feature to + * overload, which is a no-op, not an error. Freeing opts3 here is a double + * free — this is the case the old wording got wrong. */ +if (vmaf_model_feature_overload(model, "psnr", opts3) == 0) + opts3 = NULL; ``` +Until ADR-1166 this contract was documented two different ways — +`` said the caller kept ownership on any failure, +`` said ownership transferred unconditionally — so one of the +two readings was a latent double free +([Netflix/vmaf#1242](https://github.com/Netflix/vmaf/issues/1242)). All three +headers now state the rule above, and it matches what the implementation has +always done. The same report's `-ENOMEM` leak in +`vmaf_model_feature_overload()` and the swallowed copy error in +`vmaf_model_collection_feature_overload()` are fixed in the same change. + Each feature extractor publishes its own option keys — see [../metrics/features.md](../metrics/features.md) for the full table of recognised keys per feature. diff --git a/docs/development/build-flags.md b/docs/development/build-flags.md index a01c95bee..e1a255865 100644 --- a/docs/development/build-flags.md +++ b/docs/development/build-flags.md @@ -200,3 +200,48 @@ the original 207-symbol audit and fix rationale. - [getting-started/building-on-windows.md](../getting-started/building-on-windows.md) — platform-specific toolchain setup. - [development/release.md](release.md) — release build + signing flow. + +## Static linking and `pkg-config` + +`core/meson.build` sets `default_library=both`, so a build produces +`libvmaf.so` **and** `libvmaf.a`, and downstream projects (FFmpeg's +`--enable-libvmaf`, the fork's own `ffmpeg-patches/` stack) discover it through +`pkg-config`. + +libvmaf contains C++ translation units — the fork's own converted sources +(`feature_extractor.cpp`, `feature_collector.cpp`, `luminance_tools.cpp`, +`log.cpp`, `read_json_model.cpp`, the C++23 picture pools) plus vendored +libsvm — so a consumer linking the **static** archive needs the C++ runtime on +the link line. Since ADR-1166 `libvmaf.pc` carries it: + +```console +$ pkg-config --static --libs libvmaf +-L/usr/local/lib -lvmaf -pthread -lm -lstdc++ +``` + +The runtime is chosen from the STL actually in use rather than from the +compiler id: `_LIBCPP_VERSION` selects `-lc++`, otherwise `-lstdc++`. That +matters because clang defaults to libstdc++ on Linux and to libc++ on +macOS/FreeBSD, and `-Dcpp_args=-stdlib=libc++` can flip either. MSVC and +clang-cl auto-link their runtime through `#pragma comment(lib)`, so nothing is +added there. + +Before ADR-1166 `Libs.private` read only `-pthread -lm`, and a static consumer +failed with several hundred undefined references to `operator new(unsigned +long)` and `std::ios_base::ios_base()` +([Netflix/vmaf#1178](https://github.com/Netflix/vmaf/issues/1178)); downstream +fully-static FFmpeg builds had to add `-lstdc++` by hand (see +[ADR-0198](../adr/0198-volk-priv-remap-static-archive.md)). To check your own +build: + +```bash +printf '#include \nint main(void){VmafContext *c=0;VmafConfiguration f={0};return vmaf_init(&c,f);}\n' > /tmp/smoke.c +cc /tmp/smoke.c $(pkg-config --cflags libvmaf) $(pkg-config --static --libs libvmaf) -o /tmp/smoke +``` + +The `libvmaf-build-matrix` workflow runs exactly that link on its static leg; +grepping the flag list is not sufficient, because it is the *link* that +reproduces the downstream failure. + +`-Denable_dnn` static builds additionally carry the ONNX Runtime shared object +in `Libs.private`. diff --git a/docs/metrics/motion.md b/docs/metrics/motion.md index 90fd038c2..894dc6ff0 100644 --- a/docs/metrics/motion.md +++ b/docs/metrics/motion.md @@ -259,7 +259,27 @@ All three extractors: than YUV 4:0:0. - Require a minimum frame size of 3x3 pixels (5-tap Gaussian minimum dimension = filter_radius + 1 = 3). Smaller frames are rejected with `-EINVAL` - at `init()`. + at `init()`. With `motion_filter_size=3` the minimum is 2x2. + + **The minimum applies to every plane that is actually convolved, not only to + luma.** With `motion_add_uv=true` on `float_motion`, the U and V planes are + blurred at their *subsampled* dimensions, so a 4x4 YUV 4:2:0 frame — whose + luma clears the 3x3 floor — presents a 2x2 chroma plane and is rejected. In + practice this means `motion_add_uv=true` needs at least 5x5 for 4:2:0 + (chroma 3x3), 5x3 for 4:2:2, and 3x3 for 4:4:4. The error message names the + plane that failed: + + ```text + libvmaf ERROR float_motion: chroma plane 2x2 is below the 5-tap filter + minimum 3x3; refusing to avoid out-of-bounds mirror reads + ``` + + Before ADR-1166 the guard validated luma only, and a sub-minimum chroma plane + reached the convolution and read out of bounds + ([Netflix/vmaf#1582](https://github.com/Netflix/vmaf/issues/1582), + [Netflix/vmaf#1581](https://github.com/Netflix/vmaf/issues/1581)). The same + guard is now present on the three Metal twins, which had none at all + ([Netflix/vmaf#1580](https://github.com/Netflix/vmaf/issues/1580)). - Are temporal extractors: frame 0 always emits `0.0` for all motion scores. ## See also diff --git a/docs/metrics/vif.md b/docs/metrics/vif.md index 2517c6d96..cc1be019b 100644 --- a/docs/metrics/vif.md +++ b/docs/metrics/vif.md @@ -82,6 +82,43 @@ core/build/tools/vmaf \ --no_prediction --feature 'integer_vif:vif_skip_scale0=true' --output /dev/stdout ``` +## Minimum frame size + +`float_vif` runs a four-scale pyramid: each scale halves the working dimension +and then convolves at that size with the scale's own separable Gaussian — +17, 9, 5 and 3 taps at the default `vif_kernelscale` of 1.0. Every one of those +convolutions needs at least `filter_width / 2 + 1` samples in each axis for the +reflect-101 mirror padding to stay inside the plane, so the frame minimum is +the largest `(filter_width_s / 2 + 1) << s` over the ladder: + +| Scale | Filter width | Needs at that scale | Implies at full size | +| --- | --- | --- | --- | +| 0 | 17 | 9 | 9 | +| 1 | 9 | 5 | 10 | +| 2 | 5 | 3 | 12 | +| 3 | 3 | 2 | **16** | + +**`float_vif` therefore rejects any frame below 16x16** with `-EINVAL` at +`init()`: + +```text +libvmaf ERROR float_vif requires width >= 16 and height >= 16 for the +four-scale ladder (got 12x12) +``` + +The bound is derived from `vif_kernelscale`, not hard-coded, so a non-default +kernel scale moves it. When `vif_prescale != 1.0` the check is applied to the +*scaled* dimensions — the ones actually handed to the pyramid — as well as to +the raw input. + +Before ADR-1166 the guard only covered scale 0 and admitted anything at or +above 9x9, so frames in 9..15 px reached the scale-3 convolution with a +sub-minimum plane and read out of bounds +([Netflix/vmaf#1582](https://github.com/Netflix/vmaf/issues/1582)). If you were +scoring 9..15 px input, that run was reading uninitialised memory and its +scores were not meaningful; upscale the input or use a smaller +`vif_kernelscale`. + ## Cross-backend parity The `core/test/test_integer_vif_cpu_cuda_parity.c` smoke test (suite diff --git a/docs/rebase-notes.md b/docs/rebase-notes.md index 98bfd362d..9e68902ef 100644 --- a/docs/rebase-notes.md +++ b/docs/rebase-notes.md @@ -47849,3 +47849,145 @@ Rebase-sensitive points: `pkg/version/version.go`, `scripts/ci/release-pr-exempt.sh`, `scripts/ci/tests/test-release-pr-exempt.sh`, and the docs. No rebase impact. +## Upstream-issue harvest 2026-09-03 (ADR-1166, branch `fix/upstream-harvest-2026-09-03`) + +Nine stale Netflix/vmaf reports were verified against this tree and the +confirmed subset fixed. The entries below are the ones a future +`/sync-upstream` needs, because each touches an upstream-mirrored file where +the two trees now diverge. Full triage table, including the ALREADY-FIXED and +NOT-APPLICABLE verdicts, in +[`docs/research/1166-upstream-issue-harvest-2026-09-03.md`](research/1166-upstream-issue-harvest-2026-09-03.md). + +### `core/src/feature/common/convolution_internal.h` — Netflix/vmaf#1582 / #1581 + +The three edge helpers no longer open-code the single-bounce reflect-101 fold. +There is one `convolution_reflect101(idx, size)` `FORCE_INLINE` helper at the +top of the header, and `convolution_edge_s` / `_sq_s` / `_xy_s` each call it +once per tap. The fold is **iterative** (`while (idx < 0 || idx >= size)`) with +a `size <= 1` short circuit, because a single bounce only lands in range for +`size >= radius + 1`; below that it falls out the opposite side and the caller +dereferences out of bounds. + +For every `size >= radius + 1` the loop exits on the first iteration and yields +the identical index, so this is a pure safety change with no score movement — +pinned by `core/test/test_convolution_edge_small.c::test_large_plane_bit_identical`, +which compares a 24x24 run against an explicit single-bounce reference and +asserts **bit** equality. + +An upstream hunk that re-introduces the open-coded +`width - (j_tap - width + 2)` form at any of the three sites must be dropped, +not merged. Upstream's own #1582 patch introduces a `convolution_mirror()` +helper of the same shape; prefer keeping the fork's name and the header comment +that cites both issue numbers. + +### `core/src/feature/common/convolution.c` — Netflix/vmaf#1582 + +`convolution_x_c_s` and `convolution_y_c_s` now call +`convolution_clamp_borders(dim, &borders_lo, &borders_hi)` immediately after +deriving the two bounds. Upstream leaves `borders_right` / `borders_bottom` +negative for a plane narrower/shorter than the filter, which makes the trailing +loop start at a negative index and write `dst[i * dst_stride - 1]` / +`dst[-dst_stride + j]` — a heap underflow write. The clamp is a no-op for every +`dim >= filter_width`, so no in-contract behaviour changes; it also removes the +duplicate recompute when the two border bands would otherwise overlap. + +The file also now `#include "alignment.h"` instead of re-declaring +`vmaf_floorn` / `vmaf_ceiln` as local `extern`s. `core/src/feature/common/convolution.h` +gained prototypes for `convolution_x_c_s` / `convolution_y_c_s`, which already +had external linkage; this silences `-Wmissing-prototypes` and lets the +regression test drive the scalar passes without going through the SIMD +dispatch. + +### `core/src/feature/integer_motion.c`, `integer_motion_v2.c`, `x86/motion_avx2.c`, `x86/motion_avx512.c`, `arm64/motion_v2_neon.c` — deliberate divergence from Netflix/vmaf#1581 + +These files keep their **single-bounce** `mirror()` bodies on purpose. They sit +downstream of an `init()` guard that has rejected `w < 3 || h < 3` since +Research-0094, so the defective sizes never reach them. Upstream #1581 goes the +other way — it fixes `mirror()` so tiny frames can be *scored*; the fork errors +out instead. A sync that pulls upstream's `mirror()` change here is a +behaviour decision, not a mechanical merge: it would make the guards +unnecessary and start producing scores for 1x1 and 2x2 frames, which the fork +has deliberately refused since Research-0094. + +The same applies to the CUDA / HIP / Metal `mirror` twins. + +### `core/src/feature/float_vif.c` — Netflix/vmaf#1582 + +The min-dimension guard is no longer a hard-coded 9. It is +`vif_get_min_dim((float)s->vif_kernelscale)` — the largest +`((filter_width_s / 2) + 1) << s` over the four-scale ladder, which is 16 at the +default kernelscale. The old floor covered scale 0 only, so 9..15 px input +reached the scale-3 convolution with a sub-minimum plane. `vif_get_min_dim` is +new in `core/src/feature/vif_tools.{c,h}`; upstream has no counterpart, so an +upstream hunk that touches the guard will conflict. + +### `core/src/feature/float_motion.c` — Netflix/vmaf#1582 / #1581 + +`motion_check_min_dim` gained a `const char *plane` argument (for the log +message) and is now driven by `motion_check_min_dim_all_planes`, which also +validates the **chroma** dimensions when `motion_add_uv` is set, deriving them +with `picture.c`'s own `(dim + ss) >> ss` geometry via the new +`motion_chroma_shifts` helper. Upstream validates nothing here; the fork's own +prior guard validated luma only, which is what left the live out-of-bounds read +on the chroma blur. + +### `core/src/model.c` (and the unbuilt `core/src/model.cpp` twin) — Netflix/vmaf#1242 + +`vmaf_model_feature_overload` no longer has an `exit:` label: the `-ENOMEM` and +dictionary-free failure paths `break` out of the loop and fall through to the +single unconditional `vmaf_dictionary_free(&opts_dict)`. That is exactly the +shape the unbuilt C++ twin already had, so the two files are now convergent — +keep them that way (T-TWIN-DEAD-SIDES-2026-09-02 tracks the twin's +build wiring). `vmaf_model_collection_feature_overload` gained argument guards +(`!model || !feature_name || !opts_dict`, plus `!*model_collection`) and now +propagates and cleans up after a failed `vmaf_dictionary_copy`. + +Do **not** adopt upstream's proposed `VmafFeatureDictionary **` signature +change: it is an API/ABI break that would need its own ADR and soname handling. + +### `core/include/libvmaf/feature.h`, `model.h`, `libvmaf.h` — Netflix/vmaf#1242 + +The `VmafFeatureDictionary` ownership contract is now written **identically** in +all three headers: consumed on every path except the argument-validation +guards, where the caller still owns it. `feature.h` and `model.h` previously +documented opposite rules. These are fork-authored doc comments (upstream's +headers are much sparser), so an upstream sync will not conflict, but any edit +must keep the three copies in step. + +### `core/src/feature/compat_builtin.h` — Netflix/vmaf#1551, retracting Netflix/vmaf#1422 + +This file is fork-added (there is no upstream counterpart), but round-21 item +(n) recorded the `__lzcnt` choice as settled, and it is not: `__lzcnt` emits +LZCNT unconditionally, which silently decodes as BSR on any x86-64 without +ABM/LZCNT and returns the MSB index instead of the leading-zero count. The shim +now uses `_BitScanReverse` / `_BitScanReverse64` and carries a +`_M_X64 || _M_IX86` architecture guard. + +**Do not adopt Netflix/vmaf#1422's `__lzcnt` form** — upstream's own #1551 +retracts it. `scripts/ci/check-msvc-clz-shim.sh` enforces this and fails the +`fast` suite if the intrinsic returns anywhere under `core/src`. + +### `core/tools/spinner.h` and `core/tools/vmaf.cpp` — Netflix/vmaf#743 + +`spinner.h` is upstream-mirrored and upstream still has the bug open. The +braille table itself is byte-for-byte unchanged (56 entries, verified in +`core/test/test_spinner.cpp`); what is new is the `spinner_ascii` fallback +table, `spinner_table_for_codepage()`, `spinner_erase_eol()` and the +`SPINNER_CODEPAGE_UTF8` constant, and the array is now +`static const char *const`. `vmaf.cpp` gained an `#ifdef _WIN32` +`WindowsConsoleGuard` RAII class plus `console_output_code_page()` / +`console_vt_enabled()` / `console_progress_style()` / `emit_progress_line()` in +an anonymous namespace; the progress `fprintf` moved into +`emit_progress_line()`. On POSIX every selector returns the pre-existing value, +so the emitted bytes are unchanged. + +### `core/src/meson.build`, `core/tools/test/meson.build` — Netflix/vmaf#1573 + +The nvcc fatbin include list is now built from absolute +`meson.current_source_dir()` / `meson.current_build_dir()` paths +(`cuda_inc_flags`), matching what the SYCL block below it already did. The +relative form only resolved when the build directory was a direct child of +`core/`, which stopped being the documented layout at ADR-0700. +`libvmaf_private_libs` gained the C++ runtime for Netflix/vmaf#1178, detected +via `_LIBCPP_VERSION` rather than the compiler id. The three shell-driven tool +tests now declare `depends` and `workdir`. diff --git a/docs/research/1166-upstream-issue-harvest-2026-09-03.md b/docs/research/1166-upstream-issue-harvest-2026-09-03.md new file mode 100644 index 000000000..df30b8848 --- /dev/null +++ b/docs/research/1166-upstream-issue-harvest-2026-09-03.md @@ -0,0 +1,554 @@ + +# Research 1166 — upstream Netflix/vmaf issue harvest, 2026-09-03 + +Companion to [ADR-1166](../adr/1166-upstream-issue-harvest.md). + +This is the durable output of the harvest: every candidate that was looked at, +its verdict, and the evidence behind it — **including the negative verdicts**. +A future session that wonders "does Netflix/vmaf#N affect us?" should read this +table before re-investigating. + +Fork layout reminder: upstream's `libvmaf/src/...` maps to `core/src/...` +(ADR-0700), several C files became C++ twins, and CUDA / SYCL / HIP / Metal +backends are fork-added. An upstream line number is never directly usable. + +## Verdict vocabulary + +| Verdict | Meaning | +|---|---| +| `ALREADY-FIXED` | The defect existed here and was fixed before this harvest. Nothing to do. | +| `NOT-APPLICABLE` | The defect never existed here, or the fork solved the same problem a different way. | +| `AFFECTS-FORK` | Present in the fork's tree today. Either fixed in this batch or given a `docs/state.md` row. | +| `NEEDS-HARDWARE` | Present, but a live reproduction or the parity run needs silicon this workstation does not have. | + +## Summary table + +| Upstream | Severity | Verdict | Fixed here? | Evidence anchor | +|---|---|---|---|---| +| [#1582](https://github.com/Netflix/vmaf/issues/1582) | high | AFFECTS-FORK | **yes** | `core/src/feature/common/convolution_internal.h`, `convolution.c` | +| [#1581](https://github.com/Netflix/vmaf/issues/1581) | medium | AFFECTS-FORK | **yes** (chroma path) | `core/src/feature/float_motion.c` | +| [#1178](https://github.com/Netflix/vmaf/issues/1178) | medium | AFFECTS-FORK | **yes** | `core/src/meson.build` `libvmaf_private_libs` | +| [#1551](https://github.com/Netflix/vmaf/issues/1551) | medium | AFFECTS-FORK | **yes** | `core/src/feature/compat_builtin.h` | +| [#1422](https://github.com/Netflix/vmaf/issues/1422) | low | mixed | **yes** (1 of 3 hunks) | same header; other two hunks dead | +| [#1573](https://github.com/Netflix/vmaf/issues/1573) | low-medium | mixed | **yes** (2 of 3 hunks) | `core/src/meson.build`, `core/tools/test/meson.build` | +| [#1242](https://github.com/Netflix/vmaf/issues/1242) | low-medium | AFFECTS-FORK | **yes** | `core/src/model.c`, the three public headers | +| [#743](https://github.com/Netflix/vmaf/issues/743) | low | AFFECTS-FORK | **yes** | `core/tools/spinner.h`, `core/tools/vmaf.cpp` | +| [#1580](https://github.com/Netflix/vmaf/issues/1580) | low | mostly ALREADY-FIXED | **yes** (Metal residual) | `core/src/feature/metal/*_motion_metal.mm` | +| [#1564](https://github.com/Netflix/vmaf/issues/1564) | medium | AFFECTS-FORK | no — deferred | `core/src/feature/cuda/integer_adm/adm_cm.cu` | +| [#930](https://github.com/Netflix/vmaf/issues/930) | low-medium | AFFECTS-FORK | no — deferred | `core/src/feature/cuda/integer_adm/adm_decouple_inline.cuh` | +| [#1568](https://github.com/Netflix/vmaf/issues/1568) | medium | AFFECTS-FORK | no — deferred | `core/src/libvmaf.c` `output_file_open` | +| [#1109](https://github.com/Netflix/vmaf/issues/1109) | medium | AFFECTS-FORK | no — deferred | `core/src/feature/integer_psnr.c` | +| [#766](https://github.com/Netflix/vmaf/issues/766) | medium | AFFECTS-FORK | no — deferred | `core/tools/cli_parse.cpp` | +| [#818](https://github.com/Netflix/vmaf/issues/818) | low | AFFECTS-FORK | no — deferred | `core/include/libvmaf/libvmaf.h` pooling enum | +| [#1305](https://github.com/Netflix/vmaf/issues/1305) | medium | AFFECTS-FORK | no — deferred | `core/src/cuda/drain_batch.c` | +| [#1494](https://github.com/Netflix/vmaf/issues/1494) | medium | partly NOT-APPLICABLE | no — deferred | `core/src/feature/integer_adm.c` `i_rfactor` | + +## Fixed in this batch + +### Netflix/vmaf#1582 — single-bounce mirror + negative border bound + +Two distinct defects in the float convolution, both carried verbatim. + +**Defect 1 — single-bounce mirror.** `convolution_edge_s`, `_sq_s` and `_xy_s` +in `core/src/feature/common/convolution_internal.h` each open-coded + +```c +if (j_tap < 0) j_tap = -j_tap; +else if (j_tap >= width) j_tap = width - (j_tap - width + 2); /* == 2*w - j - 2 */ +``` + +One bounce only lands in range when the plane is at least `radius + 1` across. +At `size == 2`, a tap of `-2` folds to `+2` (still `>= size`) and a tap of `+3` +folds to `-1`. There is no `convolution_mirror()` helper in the fork +(`grep -rn convolution_mirror` → 0 hits before this PR). + +**Defect 2 — negative border bound.** `convolution.c` derived +`borders_right = vmaf_floorn(width - (filter_width - radius), step)`, which is +negative whenever the plane is narrower than the filter. The trailing loop then +started at a negative index and wrote `dst[i * dst_stride - 1]` — a heap +underflow **write**. `borders_left = vmaf_ceiln(radius, step)` can likewise +exceed the width, walking the leading loop past the row. + +**Reachability.** Not latent. Two live CPU paths reach the defective sizes with +supported input and no non-default option beyond the one named: + +- `--feature float_vif` on any frame in 9..15 px. `float_vif`'s own guard + admitted `>= 9`, but `compute_vif()` runs a four-scale ladder that halves the + working dimension per scale and re-convolves with that scale's Gaussian + ({17, 9, 5, 3} taps at the default kernelscale). The binding constraint is + scale 3: `w0 / 8 >= 2`, i.e. `w0 >= 16`. ASan reports a + heap-buffer-overflow READ at `convolution_internal.h` for 15x15 / 12x12 / + 10x10 / 9x9 and is clean at 16 and above — the break is exactly at 16, as the + arithmetic predicts. +- `--feature float_motion` with `motion_add_uv=true` on a 4x4 YUV420P frame. + `motion_check_min_dim()` validated the **luma** dimensions only, while + `motion_blur_plane()` is called per plane with `ref_pic->w[c]` / + `ref_pic->h[c]`. 4x4 YUV420P gives a 2x2 chroma plane, below the 3x3 minimum. + The same 4x4 run with default options (luma only) is clean, which isolates + the chroma plane as the sole cause. + +**Fix.** A single `convolution_reflect101(idx, size)` helper that folds +repeatedly until the index is in range (with a `size <= 1` short circuit, which +would otherwise not terminate), used by all three edge helpers; and a +`convolution_clamp_borders()` that clamps both bounds into `[0, dim]`. For every +`size >= radius + 1` the fold exits on the first iteration and yields the +identical index, so nothing in contract moves — pinned by +`test_large_plane_bit_identical` in `core/test/test_convolution_edge_small.c`, +which compares a 24x24 run against an explicit single-bounce reference and +asserts **bit** equality. + +The two guard gaps are closed at the guard, not only in the kernel: + +- `float_vif` now derives its minimum from `vif_get_min_dim(kernelscale)` — + `max` over the ladder of `((filter_width_s / 2) + 1) << s`, which is 16 at the + default kernelscale — instead of a hard-coded 9. +- `float_motion` validates every plane it will actually convolve, deriving the + chroma dimensions with `picture.c`'s own `(dim + ss) >> ss` geometry. + +**Regression test.** `core/test/test_convolution_edge_small.c` embeds each plane +in a NaN-poisoned buffer: an escaping tap reads NaN and taints the output, an +escaping write replaces a poison NaN with a finite value. On the pre-fix tree +`test_scalar_5tap_small_planes` fails on the first case; after the fix all four +cases pass. Plus the guard cases in `test_float_vif_min_dim.c` (9x9 and 15x15 +now rejected, 16x16 accepted) and `test_motion_min_dim.c` +(`motion_add_uv` at 4x4 / 3x3 rejected, 5x5 accepted). + +### Netflix/vmaf#1581 — same mirror, motion extractors + +The candidate's framing ("latent hardening") was too weak: the chroma path +above is a live heap out-of-bounds read on plain CPU, and it is fixed here. + +The motion extractors' own `mirror()` bodies (`integer_motion.c:148`, +`integer_motion_v2.c:152`, `x86/motion_avx2.c:27`, `x86/motion_avx512.c:30`, +`arm64/motion_v2_neon.c:46`, and the CUDA / HIP / Metal twins) are **left +single-bounce on purpose**. They sit downstream of an `init()` guard that has +rejected `w < 3 || h < 3` since Research-0094, so the defective sizes never +reach them. That is a deliberate divergence from upstream #1581, which fixes +`mirror()` so tiny frames can be *scored*; the fork errors out instead. Recorded +in `docs/rebase-notes.md` because `/sync-upstream` will collide here. + +The Metal extractors were the exception — see #1580 below. + +### Netflix/vmaf#1580 — motion mirror at tiny frames + +Mostly ALREADY-FIXED. The fork found this independently +(`docs/research/0094-motion-v2-flush-dict-leak-round7.md`, "Finding E-1") and +discharged it with min-dim `init()` guards. Audit of all 15 motion extractor +translation units: 12 guarded, 3 not — and the 3 were exactly the fork-added +Metal extractors, written after that sweep: + +- `core/src/feature/metal/integer_motion_metal.mm` (only rejected + `motion_add_uv`, then assigned `frame_w`/`frame_h` and allocated) +- `core/src/feature/metal/integer_motion_v2_metal.mm` +- `core/src/feature/metal/float_motion_metal.mm` + +All three are registered and shipped (`feature_extractor.cpp:229/232/234`), so +`--feature float_motion_metal` on a 1- or 2-pixel-tall frame read out of bounds +on device. The guard now runs as the first statement after the `fex->priv` cast, +before `vmaf_metal_context_new`, so no cleanup path is needed. Runtime +reproduction needs an Apple GPU; the gap is grep-provable and the fix needs no +hardware to write, which is why this is AFFECTS-FORK and not NEEDS-HARDWARE. + +`test_motion_min_dim.c::test_metal_motion_min_dim` exercises the rejection +host-side (it degrades to a no-op on a build without `HAVE_METAL`). + +**Adjacent finding, deliberately NOT fixed here.** +`core/src/feature/metal/integer_motion_v2.metal:54` uses +`2 * sup - idx - 1`, while the CUDA twin +(`cuda/integer_adm/../integer_motion_v2/motion_v2_score.cu:50`) and the CPU +source (`integer_motion_v2.c:157`) both use `2 * size - idx - 2`. The Metal +file's own header comment claims it "matches the CUDA twin" — it does not. This +is the boundary-row-replication off-by-one that PR #120 fixed for motion v1, so +Metal `motion_v2` is very likely off-parity at *every* frame size, not only tiny +ones. Fixing it moves Metal `motion_v2` scores and needs a cross-backend parity +run on Apple hardware: own PR, own ADR. `docs/state.md` row added. + +### Netflix/vmaf#1242 — VmafFeatureDictionary ownership + +Three findings, all present. + +1. **Leak on the `-ENOMEM` path.** `core/src/model.c` returned `-ENOMEM` + straight out of the loop when `vmaf_dictionary_merge()` failed, skipping the + unconditional `vmaf_dictionary_free(&opts_dict)` at the tail. Under the + contract the fork itself publishes, that is an unconditional leak of the + caller's dictionary. The correct shape already existed in-tree, in the + **unbuilt** C++ twin `core/src/model.cpp` (`err = -ENOMEM; break;`); + `core/src/meson.build` compiles `model.c`, not `model.cpp`. +2. **Collection wrapper compounded it and swallowed the error.** + `vmaf_model_collection_feature_overload()` discarded + `vmaf_dictionary_copy()`'s return value, never freed the partially built + copy, silently skipped the remaining sub-models, and could still return 0 + from the lead-model call. It also dereferenced `*model_collection` without + checking it and never checked `model` / `feature_name` / `opts_dict`. +3. **The headers contradicted each other.** `core/include/libvmaf/feature.h` + said "on failure the caller still owns the dictionary"; `model.h` and + ADR-0806 said ownership transfers "on both success and failure". The fork's + own test `core/test/test_model_collection_api.c:274-277` follows `feature.h` + — correct against the implementation, a textbook CWE-415 double free against + the `model.h` wording. A third-party caller reading either header alone gets + it wrong on some path. + +**Fix.** `model.c` breaks to the common exit instead of returning; the +collection wrapper captures the copy result, frees the partial copy, folds the +error into `err`, and gains the missing argument guards; `model.cpp` is kept +convergent. The contract is written **once**, identically, in `feature.h`, +`model.h` (both call sites) and `libvmaf.h`, in the shape the implementation +actually has: + +> the dictionary is consumed on every path EXCEPT the argument-validation +> guards (`-EINVAL` from a NULL or unknown argument), where nothing was +> consumed and the caller still owns it. + +That is also what `vmaf_use_feature()` does — `core/src/libvmaf.c:1607-1650` +returns `-EINVAL` for `!vmaf` / `!feature_name` / unknown feature name without +touching `opts_dict`, and consumes on every other path. ADR-0806's line +citations were stale (`model.c:196`, `libvmaf.c:1520-1525`); it is now marked +Superseded by ADR-1166 rather than edited in place. + +**No production double free today**: the only in-tree production callers are +`core/tools/vmaf.cpp:471-473` and `:520-522`, and neither frees the dict +afterwards. The exposure was leaks on the OOM / `-EINVAL` paths plus the +contract landmine for external API users. + +**Regression test.** `core/test/test_model_feature_overload_ownership.c` pins +the guard paths (caller may still free), the success path (consumed), and drives +the merge-failure branch deterministically without malloc-fail injection: a +heap-allocated **empty** dictionary makes `vmaf_dictionary_merge()` return NULL, +which is exactly the branch that leaked. The leak itself is what LeakSanitizer +reports in the ASan lane. The NULL-collection case is undefined behaviour +pre-fix (the optimiser deletes it under LTO, leaving a garbage return) and a +defined `-EINVAL` after. + +### Netflix/vmaf#743 — Windows console mojibake + +Present verbatim. `core/tools/spinner.h` carries the 56-entry UTF-8 braille +table (336 non-ASCII bytes = 56 × 2 glyphs × 3 bytes), and +`core/tools/vmaf.cpp` emitted it with a plain byte-oriented `fprintf` to stderr, +inside the `if (istty && !c->quiet)` block — the interactive-console case. + +Grep-provable API misuse: nothing in the tree set the console output code page +or enabled VT processing. +`grep -rn "SetConsoleOutputCP\|CP_UTF8\|GetConsoleOutputCP\|_setmode\|_O_U8TEXT" core/` +returned nothing; the one `#include ` in the CLI is commented as +being there for `QueryPerformanceCounter` (ADR-1081). + +Decoding the exact frame bytes under the default console code pages, Linux-side: + +| Code page | Result | +|---|---| +| cp437 (conhost default) | 6 garbage glyphs — the progress line is 4 characters wider than the `\r` overwrite assumes | +| cp1252 | 6 different garbage glyphs | +| cp936 | `illegal multibyte sequence` on byte 0x80; conhost renders replacement boxes | + +Same line, second defect: the trailing `\033[K` erase-to-EOL was also +unconditional, and legacy conhost has `ENABLE_VIRTUAL_TERMINAL_PROCESSING` off +by default, so it prints literally. + +The surface is live on Windows: `libvmaf-build-matrix.yml` builds the MinGW64 +CPU leg and the two MSVC + CUDA / SYCL legs, all of which compile `vmaf.cpp`. + +**Fix.** A `WindowsConsoleGuard` RAII object declared at the top of `main()` +switches the console to UTF-8 and enables VT for the run, restoring the previous +code page and mode in its destructor — which C++ runs on every `goto cleanup` +path too, since the guard is declared before every jump target. `spinner.h` +grows an ASCII fallback table plus `spinner_table_for_codepage()` and +`spinner_erase_eol()`; `vmaf.cpp` resolves both from the console's actual +capabilities. Everything Windows-specific is `#ifdef _WIN32`; on POSIX the +selectors return the braille table and `\033[K` unconditionally, so the emitted +bytes are unchanged. + +**Regression test.** `core/test/test_spinner.cpp` drives the selectors with the +code pages a real conhost reports (437, 1252, 936, 0) and asserts the ASCII +table; asserts the braille table for 65001; asserts the erase sequence is +VT-gated; and pins the braille table's first/last entries byte-for-byte plus +`strlen(entry) == 6` for all 56, so the POSIX output cannot drift. + +### Netflix/vmaf#1573 — build-system hunks + +Hunk (a) — `picture_cuda.c` uninitialised `priv->cuda.state` — is +**ALREADY-FIXED**: `core/src/cuda/picture_cuda.c:190` sets it in the pinned path +and `:251` in the device path. + +Hunk (b) — **AFFECTS-FORK, and worse here than upstream.** +`core/src/meson.build` fed nvcc relative includes (`-I ./src -I ../src +-I ../include ...`). Since ADR-0700 moved the project root to `core/`, those only +resolve when the build directory is a direct child of `core/`. With the layout +the fork's own docs use (`meson setup build core` from the repo root) the CUDA +build hard-fails: +`core/src/./feature/cuda/integer_adm/adm_dwt2.cu:23:10: fatal error: cuda/integer_adm_cuda.h: No such file or directory`. +The neighbouring SYCL block already did this correctly with absolute +`meson.current_source_dir()` paths, so the fix pattern was in-tree. The +Windows pthread-shim include was relative for the same reason and is now +absolute too. + +Hunk (c) — **AFFECTS-FORK.** `core/tools/test/meson.build` registered +`test_vmaf_cuda_gpumask` with only `suite`/`timeout` — no `depends`, no +`workdir` — while the script invokes `./tools/vmaf`. Meson only rebuilds the +targets a *selected* test declares (`mtest.py` `rebuild_deps()`: "if not +targets: return True"), so selecting it as a subset built nothing and the script +died with `exit status 127`. Reproduced on a freshly configured, uncompiled CUDA +build dir. The two neighbouring fork-added tests (`test_vmaf_per_shot`, +`test_vmaf_roi_high_bitdepth`) had the same omission. All three now declare +`depends` and `workdir`. + +### Netflix/vmaf#1551 (and the live third of #1422) — the MSVC clz shim + +`core/src/feature/compat_builtin.h` carried the exact defective shim upstream +Netflix/vmaf#1551 replaces — and it is **fork-added**, adopted from #1422's proposal, not +inherited: + +```c +static inline int __builtin_clz(unsigned x) { return (int)__lzcnt(x); } +static inline int __builtin_clzll(unsigned long long x) { return (int)__lzcnt64(x); } +``` + +LZCNT encodes as `F3 0F BD`. On an x86-64 without ABM/LZCNT (Intel Nehalem +through Ivy Bridge; AMD pre-Barcelona) the `F3` prefix is ignored and the +instruction retires as **BSR**, returning the index of the highest set bit +instead of the leading-zero count. No fault, no diagnostic. + +Two of the four call sites are on the generic scalar path, behind no SIMD gate: + +| Site | Expression | correct | as BSR | +|---|---|---|---| +| `integer_vif.h:148` `log2_32` | `k = 16 - clz(temp)`, `temp=0x00010000` | 1 | 0 → a 2048-LSB error, i.e. a factor of two in the VIF log2 fixed point | +| `integer_vif.h:148` `log2_32` | `temp=0x80000000` | 16 | −15 → shift by a negative count (UB) | +| `integer_adm.c:989` `get_best15_from32` | `k = 17 - clz(temp)`, `temp=0x40000000` | 16 | −13 → `1 << (k-1)` shifts by a negative count | + +CI cannot catch it: the MSVC leg is a real shipped path (it runs CPU unit tests +and uploads `install/bin/vmaf.exe` as the `windows-msvc-cuda-vmaf` artifact), but +every hosted Windows runner is LZCNT-capable, so the shim tests correct there +and the divergence only appears on a user's older machine. No runtime gate and +no `/arch` baseline exists in the tree. + +**Fix.** `_BitScanReverse` / `_BitScanReverse64`, which are BSR by definition +and present on every x86-64 part, plus a `_M_X64 || _M_IX86` architecture test +on the guard (neither intrinsic exists on MSVC ARM64, so that leg previously +failed to compile) and a 32-bit fallback path. The names stay `__builtin_clz*`: +the four call sites are upstream-verbatim and the rebase story depends on the +spelling (ADR-0141 §2). + +Do **not** adopt the `__lzcnt` form of Netflix/vmaf#1422 — that is the defect, +and #1551 is upstream's own retraction of it. This is recorded inline in the +header and enforced by `scripts/ci/check-msvc-clz-shim.sh`. + +**Regression tests.** `scripts/ci/check-msvc-clz-shim.sh` (registered as a +`fast`-suite meson test) fails on the pre-fix header and passes after; it also +scans the rest of `core/src` so the intrinsic cannot come back elsewhere. +`core/test/test_compat_clz.c` unit-tests the `31 - msb` / `63 - msb` +arithmetic — the part the `__lzcnt` form got wrong — on every platform, and on +the MSVC legs exercises the real shim bodies. + +The other two thirds of #1422 are dead here: the `integer_vif.c` pointer-typing +hunk was already rewritten fork-side (`vif_buffers_alloc` carves the aligned +allocation with an explicit `uint8_t *data` and per-field casts, and its block +comment names the MSVC C2036 problem), and the `HAVE_UNISTD_H` / +`HAVE_DIRECT_H` / `HAVE_STRUCT_TIMESPEC` meson feature-detection hunk is +NOT-APPLICABLE — the fork solved the same portability problem by point-gating +the includes (rebase-notes round-21 items (l)/(m)). + +#### Correction — the guard excluded ARM64 on a false premise + +The first form of this fix carried the architecture test +`_MSC_VER && !__clang__ && (_M_X64 || _M_IX86)`, documented in both the +header and the CI gate as necessary because "`__lzcnt` and `_BitScanReverse` +are x86-only". That premise is wrong. Per the MSVC intrinsics reference: + +| Intrinsic | Architectures | +| --- | --- | +| `_BitScanReverse` | x86, ARM, x64, ARM64 | +| `_BitScanReverse64` | ARM64, x64 | + +Only `__lzcnt` is genuinely x86-only. The consequence of the wrong premise is +that MSVC ARM64 matched no branch: this header is the sole definition of +`__builtin_clz` for `integer_adm.c` and `integer_vif.h`, which sit on the +generic scalar path and are compiled for every target, so the leg failed to +compile outright rather than falling back to anything. The fork runs no MSVC +ARM64 CI leg, so the break was latent. + +The allowlist now covers every architecture MSVC targets, selects +`_BitScanReverse64` on x64/ARM64 and the two-step 32-bit reconstruction +elsewhere, and `scripts/ci/check-msvc-clz-shim.sh` asserts the ARM64 arm +specifically — a narrowed allowlist now fails the gate, verified by negative +test alongside the existing `__lzcnt` negative test. + +### Netflix/vmaf#1178 — C++ runtime missing from `Libs.private` + +`core/src/meson.build` built `libvmaf_private_libs` as `[thread_lib, math_lib]` +and only extended it for SYCL and the Metal frameworks. Every generated +`libvmaf.pc` in this tree read: + +```text +Libs: -L${libdir} -lvmaf +Libs.private: -pthread -lm +``` + +The fork is far more exposed than upstream: the undefined symbols come not just +from vendored `svm.cpp` but from the fork's own C++ conversions +(`luminance_tools.cpp`, `feature_extractor.cpp`, `feature_collector.cpp`, +`log.cpp`, `read_json_model.cpp`, the C++23 picture pools, ...). + +Reproduced with the existing static archive: + +```console +$ gcc t.c -I core/include build/src/libvmaf.a -pthread -lm -o t # == Libs + Libs.private +/usr/bin/ld: undefined reference to `operator new(unsigned long)' +/usr/bin/ld: undefined reference to `operator delete(void*, unsigned long)' +... +$ gcc t.c -I core/include -L build/src $(pkg-config --static --libs libvmaf) -o t # after the fix +GOOD LINK OK +``` + +Corroboration that this already bit a real consumer: +`docs/adr/0198-volk-priv-remap-static-archive.md:130-133` records the BtbN-style +fully-static FFmpeg reproducer with `-lstdc++` added **by hand**, precisely +because `libvmaf.pc` did not carry it. + +Upstream's patch cannot be ported: `else compiler.get_id() == 'clang':` is not +meson syntax, and its clang→`-lc++` mapping is wrong on Linux, where clang +defaults to libstdc++. The fork detects the STL actually in use +(`_LIBCPP_VERSION` via `cxx.get_define`) instead of keying off the compiler id, +and skips MSVC/clang-cl, which auto-link the runtime with +`#pragma comment(lib)`. + +**Regression test.** The CI step "Verify static pkgconfig" was grep-only; it +never attempted a link. It now compiles a 3-line C consumer with the **C** +driver against exactly what `pkg-config --static --libs libvmaf` reports — the +test that actually reproduces the downstream FFmpeg failure — and keeps the +`-lm` / `-pthread` / C++-runtime greps as cheap extras. + +Note: the ORT dependency the candidate flagged as an "adjacent gap" is already +present in `Libs.private` on a DNN-enabled configure; the C++ runtime was the +only missing element. + +CLAUDE.md §12 r14 check: **no `ffmpeg-patches/` update is required**. The change +adds no public C-API entry point, no configure flag, no `LIBVMAFContext` field, +and renames no symbol the `check_pkg_config` probes look for — the existing +probes simply start succeeding under `--pkg-config-flags=--static`. + +## Round-4 review findings — measurements + +### The Metal motion mirror: which dimensions actually break + +The round-3 guard (`w < 3 || h < 3`) was derived from the 5-tap radius, but the +kernels do not index a 5-tap neighbourhood directly — they load a +`TILE_W x TILE_H = 20x20` threadgroup tile at origin `bid * 16 - HALF_FW`, so +the mirror helper is handed `idx` in `[-2, 16*bid + 17]`. A single bounce +(`2 * (sup - 1) - idx`) only lands in range when `idx <= 2 * (sup - 1)`. + +Enumerated over the real tile span for every dimension (all workgroups, all +400 tile elements): + +| Dimension | Single bounce | Why | +| --- | --- | --- | +| 1..9 | **out of bounds** | `idx` reaches 17, needs `sup >= 10` | +| 10..16 | safe | one workgroup, `2*(sup-1) >= 17` | +| **17** | **out of bounds** | last workgroup reaches `idx = 33`, `2*(17-1) = 32` -> folds to -1 | +| 18+ | safe | — | + +The 17 case is the one no radius-derived guard would predict, and it is why the +fix belongs in the kernel rather than in the host-side floor. Replacing the +single bounce with an iterative fold was verified over dims 1..299: + +- never out of range, always terminating (`sup <= 1` short-circuits); +- **bit-identical** to the single bounce for every index one bounce already + handled, so no in-contract score moves. + +### Reflection convention: Metal v2 was the last backend still diverging + +| Backend | Form | Status | +| --- | --- | --- | +| CPU `integer_motion_v2.c::mirror` | `2 * size - idx - 2` | reflect-101, reference | +| CUDA | `2 * (sup - 1) - idx` | fixed in PR #120 / T7-15 | +| SYCL `dev_mirror_motion` | `2 * sup - idx - 2` | fixed; records ~2.6e-3 drift from the old form | +| HIP `motion_v2_score.hip` | `2 * size - idx - 2` | fixed | +| **Metal `mv2_mirror`** | `2 * sup - idx - 1` | **still diverging — fixed here** | + +The ADM kernels' `2 * sup - idx - 1` is *correct* and deliberately untouched: +ADM uses whole-sample reflection, matching +`adm_tools.c::dwt2_src_indices_filt_s`, CUDA's `calculate_indices()` and the +SYCL twin. Convention differs per metric; it must be checked per metric. + +### `float_vif` minimum dimension across backends + +| Backend | Floor before | Mechanism | +| --- | --- | --- | +| CPU `float_vif.c` | 16 | `vif_get_min_dim(kernelscale)` | +| Metal | 8 | `scale_w[FVIF_SCALES - 1] == 0`, i.e. `w >> 3 == 0` | +| CUDA | none | halves to scale 3 unchecked | +| HIP | none | halves to scale 3 unchecked | +| SYCL | none | halves to scale 3 unchecked | + +All four now call `vif_get_min_dim()`. This required an `extern "C"` guard on +`vif_tools.h`, which had none — it was included only by C translation units, so +the C++ (SYCL) and Objective-C++ (Metal) callers would otherwise have demanded +mangled symbols. + +### Finding not upheld: the `Libs.private` libc++ detection + +The review held that `cxx.get_define('_LIBCPP_VERSION', ...)` ignores an +explicit `-stdlib=libc++`. Tested against the installed meson with a probe +project: + +```meson +project('probe', 'cpp') +cxx = meson.get_compiler('cpp') +message('FOO=[' + cxx.get_define('FOO') + ']') +``` + +`meson setup b -Dcpp_args=-DFOO=42` reports `FOO=[42]`, so compiler checks do +observe the project's `cpp_args`; the `_LIBCPP_VERSION` probe therefore sees +`-stdlib=libc++` exactly as its comment claims. No change made. + +## Confirmed but deferred + +Each of these is real and located; each has a `docs/state.md` row. None is in +this PR, and the reason is given. + +| Upstream | Why not batched | +|---|---| +| **#1564** | Three defects. (1) CUDA/HIP `i4 adm_cm` `i == 0` border walks flt rows {1,2,3} where the CPU reference reads {1,0,1} — reachable only for `h_at_scale <= 14`, which no current fixture hits. (2) The `>> shift_inner_accum` rounding is applied per warp (CUDA) / per thread (HIP) instead of once per image row, so `round(a)+round(b) != round(a+b)` accumulates ~n/2 output units per row. That one changes every CUDA/HIP ADM score and the launch shape, and `test_cuda_adm_parity.c`'s `PARITY_TOL` of 1e-4 is too loose to prove the fix. (3) The x86 `half_w_modN` empty scalar tail is present but empirically **score-neutral** at 388x288 across `--cpumask 0 / 8 / 56` — the corrupted column always falls inside the `ADM_BORDER_FACTOR` crop, the same conclusion the fork already reached for the NEON twin. Needs GPU parity runs; splits into two PRs of very different risk. | +| **#930** | The fork holds **four** different `angle_flag` predicates for one test: CPU/AVX2/AVX-512 (narrow to float, evaluate in double), CUDA/HIP scale 0 (exact int64), CUDA/HIP scales 1-3 (matches CPU), SYCL (all-float), Metal (exact int64 narrowed to float). A 40M-sample sweep of near-parallel vectors puts the disagreement at 0.0031-0.0046%, switching on exactly where the operands cross 2^24 — the float mantissa. Inherited verbatim from upstream, not fork-introduced. Fixing it moves GPU scores and needs `/regen-snapshots` plus an ADR; the CPU side is frozen by the golden gate. | +| **#1568** | `output_file_open()` uses the narrow `_open()`, which decodes with the process ANSI code page. The fork has the same class of defect at 12 further sites upstream does not have (`vmaf.cpp` fopen sites, `read_json_model.cpp`, `vmaf_per_shot.c`, `vmaf_roi.c`, `vmaf_bench.c`, `vmaf_vpl.c`, `dnn/model_loader.c`, `interop/pelorus_qp_report_csv.c`). Wants a new `core/src/compat/path_utf8.{h,c}` surface and a documented encoding contract on the public API — its own ADR. | +| **#1109** | The per-frame `MIN(..., psnr_max)` doubles as both the infinity sentinel and a hard truncation. Reproduced: a one-luma-byte flip on the 576x324 golden reference gives `psnr_y=60.0` where the ground truth (and FFmpeg's own psnr filter) is 100.840479 — a 40.84 dB under-report. The escape hatch (`--feature psnr=min_sse=0.000001`) exists but is undocumented, and there is no `docs/metrics/psnr.md` at all. The fix wants an opt-in `uncapped` option propagated to eight GPU twins plus a new docs page and an ADR; the golden assertions at 60/84/108 are all `sse == 0` byte-identical pairs and would survive, but proving that is part of the work. | +| **#766** | `cli_parse.cpp` splits `--model` / `--feature` option strings with raw `strsep` and no escape state. Reproduced against the existing build: `path=/dir=eq/m.json` **silently truncates** to `.../dir` and reports a phantom path; `path=C:\models\x.json` is unrepresentable. Blast radius is wider here because `pkg/libvmaf/libvmaf.go`, `pkg/scorecli`, `pkg/corpus` and `cmd/vmafx-mcp` all synthesise the same string from user paths. Changes user-visible CLI grammar and triggers §12 r14 on `ffmpeg-patches/0008`; needs its own ADR. | +| **#818** | The public pooling enum still has no `MEDIAN` / `PERC*` enumerator, five years on. The candidate's "silently falls back to mean" claim is **refuted** — `pool_reduce()` ends in `default: return -EINVAL;` — and so is "two surfaces disagree": the Python `perc10` path never reaches the C pooling code (it applies `ListStats.perc10` to the per-frame list in NumPy). Growing the enum triggers §12 r14 on three ffmpeg filter patches. | +| **#1305** | The upstream gap (`vmaf_score_at_index` has no fence against pending GPU work) is unchanged here. The fork *also* introduced a multi-instance defect of its own: `core/src/cuda/drain_batch.c:49` keys the ADR-0242 fence batch by `_Thread_local`, not by `VmafContext`, and `vmaf_close()` never closes it — so an abandoned instance leaves destroyed `CUevent`s and dangling `bool*` in a batch the next instance flushes. Establishes statically from the call graph; a GPU repro is the natural first step of the fix PR. | +| **#1494** | The candidate's nvd/rdh premise is **false**: `integer_adm.c:3509-3512` already rejects `nvd * rdh < 3240`, and over the whole allowed region `i_rfactor` stays below 65536. What *is* real is fork-specific: the fork-added `adm_csf_mode=1` (BARTEN) overflows `uint16_t i_rfactor` at the stock nvd=3.0/rdh=1080 (exact 2538596 / 10154382 wrap to 48227 / 61838), and the run produces `integer_adm2_csf_1` mean 0.000614 against the fork's own float reference of 0.9396 — a ~1500x discrepancy. Widening also needs the `adm_cm` products moved to int64 and the AVX2/AVX-512 twins restructured to 64-bit lanes; two stages, own PR. | + +## Reproducer commands + +```bash +# CPU-only build used throughout +meson setup build core -Denable_cuda=false -Denable_sycl=false -Denable_dnn=disabled +meson compile -C build -j 8 + +# The regression tests added by this harvest +meson test -C build --suite=fast -j 4 + +# The two convolution defects, isolated (fails before the fix, passes after) +./build/test/test_convolution_edge_small + +# The clz shim guard (fails on the pre-fix header) +bash scripts/ci/check-msvc-clz-shim.sh + +# The pkg-config static-link defect +PKG_CONFIG_PATH=$PWD/build/meson-private pkg-config --static --libs libvmaf +# pre-fix: -L/usr/local/lib -lvmaf -pthread -lm +# post-fix: -L/usr/local/lib -lvmaf -pthread -lm -lstdc++ + +# Netflix golden gate — must not move +CUDA_VISIBLE_DEVICES= make test-netflix-golden +``` + +## Golden values (unchanged) + +Float VMAF v0.6.1, `build/tools/vmaf`, on the three canonical CPU pairs: + +| Pair | VMAF mean | +|---|---| +| `src01_hrc00_576x324` vs `src01_hrc01_576x324` | 76.66744 | +| `checkerboard_1920_1080_10_3_0_0` vs `..._1_0` (1-px shift) | 35.070245 | +| `checkerboard_1920_1080_10_3_0_0` vs `..._10_0` (10-px shift) | 7.985956 | + +`make test-netflix-golden`: 271 passed, 12 skipped, 0 failed. diff --git a/docs/state.md b/docs/state.md index 46e58d4df..9c689735e 100644 --- a/docs/state.md +++ b/docs/state.md @@ -326,6 +326,15 @@ landed fix yet._ | **T-SPEED-GPU-REGISTRY-ORPHAN-2026-06-19** | The SpEED GPU twins (`speed_{chroma,temporal}_{cuda,sycl,hip}`) were unreachable by name on the shipping build. PR #875 split `feature_extractor.c` → the compiled `feature_extractor.cpp` but left the six GPU SpEED `extern`s + `feature_extractor_list[]` entries behind in the now-dead `.c` (meson compiles only the `.cpp`). The kernels compiled but `vmaf_get_feature_extractor_by_name("speed_chroma_cuda")` returned NULL, so SpEED silently fell back to the CPU path — regressing the ADR-0964/0965/0852 GPU SpEED wiring. Found by the RC independent registry audit (read-only worktree, master `97c147da0`). CPU Netflix golden gate unaffected (CPU `speed_chroma`/`speed_temporal` were always registered). Fix ports the six `extern`s + array entries into the `.cpp` `#if HAVE_{CUDA,SYCL,HIP}` blocks and deletes the dead `.c` twin. | `meson test -C build test_feature_extractor` (with any GPU backend enabled) → the new by-name resolution asserts pass; before the fix they fail (`speed_chroma_cuda must resolve by name`). CPU build + test green; `feature_extractor.cpp` compiles clean under `-Denable_cuda=true`. | Bug fix (no ADR; implements ADR-0545 dead-file policy, restores ADR-0964/0965/0852). PR #875 root cause. | Closed on merge; full device parity for GPU SpEED tracked under existing cross-backend gates (ADR-0214). | | **T-HIP-MOTION-V2-MIRROR-OFF-BY-ONE-2026-06-13** | HIP `integer_motion_v2` `mv2_mirror` used `2*sup-idx-1` at the high boundary while CPU (`integer_motion_v2.c:157`), CUDA (`motion_v2_score.cu:51`) and SYCL (`integer_motion_v2_sycl.cpp:95`) all use reflect-101 `2*sup-idx-2`. Identical call sites across backends → a genuine one-pixel divergence (same class as the HIP VIF fix, ADR-1103). ADR-0377 wrongly claimed the `-1` matched CPU/CUDA. Surfaced by an adversarial fresh-eyes verification sweep. HIP-only; CPU Netflix golden gate unaffected. | Verified across all four backends + call sites; fix compiles under `hipcc`. Full device cross-backend-diff to run in the dev container (host gfx1036 HIP runtime errored, §15 host-debt). | [ADR-1106](adr/1106-hip-motion-v2-mirror-reflect101-correction.md) supersedes [ADR-0377](adr/0377-hip-batch4-ciede-motion-v2.md) claim. | Closes on merge; device places=4 parity confirmation tracked as a container follow-up. | | **T-CUDA-INIT-SUBMIT-LEAKS-2026-06-19** | Four CUDA feature extractors leak already-acquired resources on init / submit error paths (found by the RC independent audit). `integer_ms_ssim_cuda.c`: `close_fex_cuda` never freed the pinned host buffers (`h_ref`/`h_cmp` + per-scale `h_{l,c,s}_partials`); the two `init` `-ENOMEM` returns leaked all device buffers + PTX module + lifecycle stream; and `submit` leaked the `tmp_uint` pinned staging buffer on its early `CHECK_CUDA_RETURN` exits. `integer_psnr_hvs_cuda.c`: the two `init` bulk-alloc `-ENOMEM` returns leaked partial device/host buffers + upload stream/event + PTX module. `ssimulacra2_cuda.c`: an `ss2c_alloc_buffers` failure returned without freeing the partial buffers + two PTX modules + stream. `speed_chroma_cuda.c`: the `fail_pop` label only popped the context, leaking the module + stream + completed buffers (its `fail_after_pop` sibling also leaked module + stream since `free_cuda_buffers` does not touch either). Fork-local CUDA error-path only; success path and CPU golden gate unaffected. | Object-compile-check the four edited `.c` files clean (`ninja -C build-cuda src/liblibvmaf_feature.a.p/feature_cuda_{integer_ms_ssim,integer_psnr_hvs,ssimulacra2,speed_chroma}_cuda.c.o`); full device leak confirmation via `compute-sanitizer --leak-check full` on an init/submit/close loop in the dev container (host CUDA fatbin toolchain broken, §15 host-debt). | fix/cuda-init-submit-leaks (bug fix, no ADR). | Closed on merge. | +| **T-METAL-MOTION-V2-MIRROR-OFF-BY-ONE-2026-09-03** | `core/src/feature/metal/integer_motion_v2.metal:54` uses `2 * sup - idx - 1` at the high boundary, while the CUDA twin (`core/src/feature/cuda/integer_motion_v2/motion_v2_score.cu:50`) and the CPU source (`core/src/feature/integer_motion_v2.c:157`) both use reflect-101 `2 * size - idx - 2`. The Metal file's own header comment (lines 20-23) claims the `-1` "matches the CUDA twin" — it does not. This is the same boundary-row-replication off-by-one that PR #120 fixed for motion v1 and PR #905 / [ADR-1106](adr/1106-hip-motion-v2-mirror-reflect101-correction.md) fixed for HIP, so Metal `motion_v2` is very likely off-parity at every frame size, not only tiny ones. Found while triaging Netflix/vmaf#1580; the missing min-dim guards in the same three Metal extractors were fixed in fix/upstream-harvest-2026-09-03, this was not. | Grep-provable: `grep -n 'mv2_mirror' -A 6 core/src/feature/metal/integer_motion_v2.metal` vs the CUDA / CPU twins. A numeric reproduction needs an Apple GPU. | Owner-driven; needs Apple Silicon for the cross-backend parity run. | Closes when the Metal fold matches reflect-101, the misleading header comment is corrected, and a `/cross-backend-diff` on Apple hardware confirms places=4 parity. Moves Metal `motion_v2` scores, so it needs its own ADR and a `/regen-snapshots` justification. | +| **T-UPSTREAM-1564-ADM-CM-GPU-BORDER-AND-ROUNDING-2026-09-03** | Netflix/vmaf#1564, verified against this tree. Three defects. (1) `core/src/feature/cuda/integer_adm/adm_cm.cu:210-244`: at `i == 0 && top <= 0` the running-pointer walk reads flt rows {1,2,3} where the CPU reference macros (`core/src/feature/integer_adm.c:484-550`) read {1,0,1}, and row 3 is outside the 3-row window the csf pass guarantees. `core/src/feature/hip/integer_adm/adm_cm.hip:216-247` carries the same form verbatim. The fork's own AIM twin at `adm_cm.cu:540-543` already uses the correct absolute form, which is what makes this the outlier rather than the convention. Only reachable for `h_at_scale <= 14`, i.e. scale-3 of frames under ~120 px tall — no current fixture hits it. (2) `adm_cm.cu:263-266` / `:379-382` apply `(lane_accum + add) >> shift_inner_accum` once per 32-lane warp, and `adm_cm.hip:376-379` once per thread, where the CPU applies it once per image ROW; `round(a) + round(b) != round(a + b)`, so the error accumulates up to ~n/2 output units per row (60 warps/row at 1920 wide). ADR-0539's "bit-exact since int64 add is associative" justification is unsound — the change moved a truncating round inside the sum. (3) The x86 `half_w_modN` tail bound leaves the last DWT column to the vector loop when `half_w % N == 1`; present at `core/src/feature/x86/adm_avx2.c:3357`/`:3622` and `adm_avx512.c:3095`/`:3578`/`:3835`. | (3) is empirically **score-neutral**: 388x288 clips (which trip mod-4 and mod-8 at scales 1-3) give bit-identical `integer_adm2` / `adm3` / `adm_scale0-3` across `--cpumask 0`, `8` and `56` at `--precision max`, because the corrupted column always falls inside the `ADM_BORDER_FACTOR` crop — the same conclusion the fork already recorded for the NEON twin (T-ADM-DWT2-NEON-PARITY-2026-08-30). (1) and (2) need CUDA/HIP device runs. | Owner-driven. Splits into two PRs of very different risk: the i==0 border + x86 tail bound (local, GPU-only or score-neutral) and the per-row rounding (changes kernel launch shape and every CUDA/HIP ADM score). | Closes when the CUDA/HIP `i == 0` branch indexes absolutely like the AIM twin, the x86 tail bound always leaves the last column to the scalar loop with a `test_adm_dwt2_x86.c` harness mirroring `test_adm_dwt2_neon.c`, and the rounding is moved out of the per-warp atomic with `test_cuda_adm_parity.c`'s `PARITY_TOL` tightened in the same PR. | +| **T-UPSTREAM-930-ADM-ANGLE-FLAG-PREDICATE-DIVERGENCE-2026-09-03** | Netflix/vmaf#930, verified. The fork holds **four** different `angle_flag` predicates for one 1-degree test: CPU/AVX2/AVX-512 narrow the int64 operands to float and evaluate in double (`core/src/feature/integer_adm.c:930-932`, `:1075-1077`, `core/src/feature/x86/adm_avx2.c:739-744`); CUDA/HIP scale 0 use an exact int64 product (`core/src/feature/cuda/integer_adm/adm_decouple_inline.cuh:83-86`, `.hip:89`); CUDA/HIP scales 1-3 mirror the CPU form (`.cuh:141-145`), so s0 and s123 disagree *inside one backend*; SYCL uses an all-float form (`core/src/feature/sycl/integer_adm_sycl.cpp:633-639`); Metal narrows the exact product to float (`core/src/feature/metal/integer_adm.metal:178-179`). Inherited verbatim from upstream, not fork-introduced — `git show upstream/master:libvmaf/src/feature/cuda/integer_adm/adm_decouple.cu` already has the s0/s123 split. | A 40M-sample sweep of near-parallel int16 vectors puts the disagreement at 1357 (CUDA/HIP s0), 1220 (SYCL) and 1850 (Metal) cases out of 40M — 0.0031-0.0046%. The two `cos(1deg)^2` constants are bit-identical, so the sole cause is the float narrowing past the 24-bit mantissa: a magnitude sweep shows 0 disagreements at `|band| <= 2048` and 1374 at `<= 16384`, switching on exactly where the operands cross 2^24. | Owner-driven. GPU-side only: the CPU expression is frozen by the Netflix golden gate. | Closes when one shared reference predicate (narrow to float, then evaluate in double with FP contraction off) replaces the s0 forms on CUDA, HIP, SYCL and Metal, `/cross-backend-diff` is green, and the fork-added GPU snapshots are regenerated with justification. Needs an ADR — it trades the GPU's more accurate predicate for CPU parity, which another engineer could reverse. | +| **T-UPSTREAM-1568-WINDOWS-NARROW-PATH-API-2026-09-03** | Netflix/vmaf#1568, verified. `core/src/libvmaf.c:3259-3287` `output_file_open()` uses the narrow `_open()` on `_WIN32`, which decodes the path with the process ANSI code page, so a non-ASCII `--output` / ffmpeg `log_path` fails or lands in the wrong place. It is the sole opener for the public API (`vmaf_write_output` / `vmaf_write_output_with_format`), and the in-tree ffmpeg patches pass the raw `AV_OPT_TYPE_STRING` straight through. No mitigation exists anywhere: `grep -rn "_wfopen\|MultiByteToWideChar\|_wopen\|CommandLineToArgvW\|wmain" core/ ffmpeg-patches/` returns nothing, no `.manifest` sets `activeCodePage`, and the only `setlocale` use forces `LC_ALL=C` for numeric formatting. The fork has the same class of defect at 12 further sites upstream does not have: `core/tools/vmaf.cpp:146`/`:1202`/`:1318`/`:1325`, `core/src/read_json_model.cpp:612`/`:742`, `core/tools/vmaf_per_shot.c:672`/`:761`, `core/tools/vmaf_roi.c:250`/`:403`, `core/tools/vmaf_bench.c:139-140`, `core/tools/vmaf_vpl.c:187`, `core/src/dnn/model_loader.c:383`/`:819`/`:984`, `core/src/interop/pelorus_qp_report_csv.c:298`. | Not reproducible on Linux — every call is byte-transparent there. The evidence is the grep-provable API misuse plus the absence of any ACP mitigation; end-to-end confirmation needs a Windows CI leg with a non-ASCII log path. | Owner-driven; needs a new `core/src/compat/path_utf8.{h,c}` surface and a documented encoding contract on the public API, so it needs its own ADR. | Closes when an internal `vmaf_fopen_utf8` / `vmaf_open_utf8` shim (wide on `_WIN32`, a literal no-op on POSIX) is wired through `output_file_open()` and the 12 fork-added sites, `docs/api/` states that path arguments are UTF-8 on Windows, and a Windows-gated unit test round-trips a non-ASCII filename. | +| **T-UPSTREAM-1109-PSNR-CAP-TRUNCATES-2026-09-03** | Netflix/vmaf#1109, verified and reproduced with no build. `core/src/feature/integer_psnr.c:203` and `:240` apply `MIN(10*log10(peak^2/mse), s->psnr_max[p])` **unconditionally per frame**, so `psnr_max` doubles as both the `mse == 0` infinity sentinel and a hard truncation of every genuinely computed value above it. Same shape in `float_psnr.c:144`, `psnr.c:54`, `psnr_tools.cpp:61-73`, and replicated across all eight GPU twins (`cuda/integer_psnr_cuda.c:302-303`, `cuda/float_psnr_cuda.c:231-232`, `sycl/integer_psnr_sycl.cpp:388-389`, `sycl/float_psnr_sycl.cpp:261-262`, `hip/integer_psnr_hip.c:415-416`, `hip/float_psnr_hip.c:422-423`, `metal/integer_psnr_metal.mm:284-286`, `metal/float_psnr_metal.mm:233`). The escape hatch `--feature psnr=min_sse=0.000001` exists but is undocumented, and there is no `docs/metrics/psnr.md` at all — an existing CLAUDE.md §12 r10 gap. Scope correction on the upstream report: the reporter's concrete symptom (28 dB vs 72 dB) is a frame-alignment problem in their FFmpeg graph and is NOT caused by this cap — a MIN can only lower a value. | One frame of `src01_hrc00_576x324.yuv` with a single luma byte flipped by +1: `build-cpu/tools/vmaf --feature psnr --feature float_psnr` reports `psnr_y=60.000000` and `float_psnr=60.000000`, where the ground truth (sse=1 over 186624 px) is 100.840479 dB — which is also exactly what FFmpeg n9.0.1's own `psnr` filter reports on the identical pair. `--feature psnr=min_sse=0.000001` on the same pair gives 100.840479. | Owner-driven; needs an ADR (opt-in flag on a stable feature-extractor surface), a new `docs/metrics/psnr.md`, and propagation to eight GPU twins. | Closes when an opt-in `uncapped` option splits the `mse <= 0` sentinel from the truncation on CPU and all GPU twins, the default stays bit-identical, and a fork-added test asserts 100.840479 with the flag and 60.0 without. The golden assertions at 60.0 / 84.0 / 108.0 are all byte-identical (`sse == 0`) pairs and must not move. | +| **T-UPSTREAM-766-CLI-OPTION-STRING-DELIMITERS-2026-09-03** | Netflix/vmaf#766, verified and reproduced against the existing build. `core/tools/cli_parse.cpp` splits `--model` and `--feature` option strings with raw `strsep` and no escape state (lines 441-443, 474, 476, 516, 532-534, plus the fork's own `#ifndef HAVE_STRSEP` fallback at :405). `grep -n "strsep\|escape\|backslash" core/tools/cli_parse.cpp` returns only the nine split sites and nothing resembling escape handling. Blast radius is wider here than upstream because the Go surfaces synthesise the same string from user paths: `pkg/libvmaf/libvmaf.go:153` builds `"path=" + modelPath`, and `pkg/scorecli`, `pkg/corpus` and `cmd/vmafx-mcp/impl_direct.go` all pass `path=`-prefixed strings through. `docs/usage/cli.md:55` never states the restriction. | Worst failure mode is silent truncation: `--model "path=/dir=eq/m.json"` reports `could not read model from path: ".../dir"` — a phantom path the user never typed. `--model "path=/dir:colon/m.json"` gives `bad option string "colon/m.json"`. `--model 'path=C:\models\vmaf_v0.6.1.json'` (the exact upstream case) gives `bad option string "\models\..."`, and the backslash escape the fork's own `docs/usage/ffmpeg.md:120` teaches for the filter layer does not help. `--feature 'psnr=some_path=C:\x'` fails identically. | Owner-driven; changes user-visible CLI grammar and triggers CLAUDE.md §12 r14 on `ffmpeg-patches/0008-add-libvmaf_tune-filter.patch:276-281`, so it needs its own ADR and PR. | Closes when an escape-aware splitter handles `\\:`, `\\=`, `\\.` and `\\\\` at all nine sites, a Windows drive-letter affordance accepts `path=C:\...` unescaped, `docs/usage/cli.md` documents the grammar, the ffmpeg patch is updated in the same PR, and a `core/tools/test/` unit test covers all five reproduced cases plus a no-change regression. | +| **T-UPSTREAM-818-POOLING-ENUM-NO-PERCENTILES-2026-09-03** | Netflix/vmaf#818, verified. `core/include/libvmaf/libvmaf.h:127-133` still exposes only `{UNKNOWN, MIN, MAX, MEAN, HARMONIC_MEAN, NB}`, so a C-API or Go-binding caller cannot ask for perc5/perc10/perc20/median five years after the report. Two of the report's implied claims are **refuted** here and should not be re-investigated: (a) there is no silent fallback to mean — `pool_reduce()` (`core/src/libvmaf.c:3061-3090`) ends in `default: return -EINVAL;` and the three public entry points reject `VMAF_POOL_METHOD_UNKNOWN` up front; upstream does the same. (b) The Python `perc5/perc10/perc20/median` options never reach the C pooling code — `Result._try_get_aggregate_score` applies `ListStats.perc10` to the per-frame list in NumPy, and `python/test/quality_runner_test.py:662-681` is a passing golden assertion proving it computes a true percentile. Every in-tree C consumer hardcodes MEAN and the CLI has no `--pool` flag, so the gap is currently unreachable from the shipped binary; the three ffmpeg filter patches do route a user-selectable option through `pool_method_map`. | `grep -n VMAF_POOL_METHOD core/include/libvmaf/*.h` shows no `PERC` / `MEDIAN` enumerator. `grep -rn '\"pool\"\|--pool' core/tools/ docs/usage/` is empty. | Owner-driven; append-only enum growth plus a percentile buffer, and CLAUDE.md §12 r14 on `ffmpeg-patches/0005`, `0006` and `0013`. | Closes when the four enumerators are appended after `HARMONIC_MEAN`, `pool_reduce` gains a sorted-vector path reusing `core/src/predict.c:580`'s `percentile()` helper (matching `np.percentile`'s linear interpolation so C and the Python harness agree), the weighted-pooling interaction is decided in an ADR, the three ffmpeg patches are updated, and a test asserts the C perc10 matches `quality_runner_test.py:679`'s 72.71845922683059. | +| **T-UPSTREAM-1305-CUDA-DRAIN-BATCH-THREAD-GLOBAL-2026-09-03** | Netflix/vmaf#1305, verified, plus a fork-original defect of the same shape. The upstream gap is unchanged: `core/src/libvmaf.c:2998-3025` `vmaf_score_at_index()` reads the feature collector with no CUDA sync, no drain-batch flush and no thread-pool wait, so a caller draining index N-2 while N is in flight reads unwritten slots; only `flush_context_cuda` fences, and only on the terminal `vmaf_read_pictures(NULL, NULL)`. The fork-original half: `core/src/cuda/drain_batch.c:49` declares the ADR-0242 fence batch `static _Thread_local`, i.e. keyed by OS thread and not by `VmafContext` or `CUcontext`, and the batch is deliberately left open with `n > 0` across the return from `vmaf_read_pictures` (`core/src/libvmaf.c:2665-2668`). `vmaf_close()` never closes or clears it — `vmaf_close_backends` only destroys the TLS drain *stream* — so an abandoned or errored instance leaves destroyed `CUevent`s and dangling `bool *` in a batch the next `VmafContext` on that thread flushes. The driver recycles freed event handles, so `cuStreamWaitEvent` frequently succeeds against an unrelated live event and then writes `*flags[i] = true` into freed memory; when it does not, `vmaf_read_pictures` fails outright with `CUDA_ERROR_INVALID_HANDLE`. Both shapes match the reported intermittent wrong-score / NaN symptom. `g_drain_batch` is the only cross-instance mutable state in the fork's CUDA path (`dispatch_strategy.c` caches only a getenv string; `__constant__` data is read-only; CUmodules and the GPU picture pool are per-extractor / per-context). | Established statically from the call graph and lifetime ordering (`drain_batch.c:49`, `:77-78`, `:135`, `:159-175`; `kernel_template.h:233-238`, `:273`, `:288`; `libvmaf.c:1483-1490`, `:1510`, `:1527`, `:2630`). An intermittent multi-instance CUDA race needs a bespoke two-context harness plus a CUDA build; that harness is the natural first step of the fix PR. | Owner-driven; two layers, and the public `vmaf_score_at_index` fence is a behaviour change on a public entry point needing its own ADR. | Closes when (a) `vmaf_close()` flushes and closes the drain batch before `feature_extractor_vector_destroy`, (b) `DrainBatchTls` moves out of `_Thread_local` into the internal `VmafCudaState` (no ABI break — the public type is opaque) with the by-value `vmaf_cuda_import_state` caveat handled, and (c) separately, `vmaf_score_at_index` / `vmaf_feature_score_at_index` gain an explicit fence or a documented `-EAGAIN` contract. Unrelated nit to fold in: `core/src/cuda/common.c:74-75` clamps stream priority as `MAX(low, MIN(high, prio))`, but CUDA's range is numerically inverted, so the primary-context path silently gets the *lowest* priority. | +| **T-UPSTREAM-1494-ADM-CSF-MODE-IRFACTOR-OVERFLOW-2026-09-03** | Netflix/vmaf#1494, verified — but the upstream premise is **false here** and the real defect is fork-specific. The nvd/rdh half does not hold: `core/src/feature/integer_adm.c:3509-3512` already rejects `adm_norm_view_dist * adm_ref_display_height < 3240` with `-EINVAL` (asserted by `core/test/test_adm_coverage.c:246-289`), and over the whole allowed region `i_rfactor` peaks at {36452, 36452, 49415}, inside `uint16_t`. What *is* real: the fork-added `adm_csf_mode` values 1-3 (`integer_adm.h:159-163`, a documented range-validated public option) take the else branch at `integer_adm.c:1233-1236` at the stock nvd=3.0/rdh=1080, and mode 1 (BARTEN) overflows `uint16_t i_rfactor[3]` (`:1224`, `:1690`) — exact hv=2538596 / d=10154382 wrap to 48227 / 61838, i.e. 52x and 164x too small. Modes 2 and 3 fit. No cross-backend hole: the GPU backends implement WATSON97 only (`grep -c adm_csf_mode` is 0 for the CUDA and SYCL ADM files); their `uint32_t` is defensive width, not a divergent value. Widening is not a one-liner: `adm_cm`'s `int32_t xh/xv/xd` products (`:1747-1749`, ~30 use sites) would reach ~8e10 and overflow signed int32 even after the factor widens, so the `ADM_CM_ACCUM_ROUND` shift contract documented at `:1768-1778` has to be re-derived; and the AVX2/AVX-512 twins (`adm_avx2.c:2317`/`:4314`, `adm_avx512.c:2037`/`:4177`) multiply with 32-bit lanes and need restructuring to 64-bit lanes to stay bit-exact with scalar. | On the Netflix golden fixture pair at 576x324, installed fork vmaf 3.2.0: `--feature adm` gives `integer_adm_scale0` 0.9463; `--feature adm=adm_csf_mode=1` gives `integer_adm2_csf_1` 0.000614 and `integer_adm_scale0_csf_1` 0.030096, against the fork's own float reference of 0.9395831666666666 (`python/test/vmafexec_feature_extractor_test.py:2078`) — a ~1500x discrepancy. Modes 2 and 3 give sane 0.9576 / 0.9590. That exactly and only the overflowing mode produces nonsense is the confirmation. | Owner-driven, two stages. Default config (nvd=3.0, rdh=1080, csf_mode=0) takes the hardcoded fast path and is untouched by either stage, so the golden gate is safe. | Closes when stage 1 extends the `extract()` guard to reject integer-ADM CSF configurations whose scale-0 factor exceeds the 16-bit budget (turning silent garbage into a loud `-EINVAL`, with a `docs/metrics/features.md` note and an ADR because it is a user-visible behaviour change), and stage 2 widens `i_rfactor` to `uint32_t` with int64 products in `adm_csf` / `adm_cm`, re-derived `ADM_CM_ACCUM_ROUND` constants, 64-bit-lane SIMD twins, and fork-added regression assertions pinning csf_mode 1-3 against the existing float references. | | **T-VMAFTUNE-TWOPASS-CRF-INVALID-2026-08-30** | `vmaf-tune corpus --two-pass` produces a failed row (`exit_status=187`, `encode_size_bytes=0`, `vmaf_score=NaN`) for libx264: the driver runs pass 2 with the same `-crf` the single-pass path uses, and x264 refuses `-pass 2` in CRF mode (`Could not open encoder before EOF` / `error code: -22 (Invalid argument)`), because FFmpeg's generic 2-pass rate control targets a bitrate, not a quality level. Pass 1 succeeds and writes the stats file; only pass 2 fails. Pre-existing in the Python implementation (`vmaftune.encode.run_two_pass_encode` + `X264Adapter.two_pass_args`); the Go port reproduces it byte-identically, which is how it surfaced. Affects every `supports_two_pass = True` software adapter driven from the CRF axis. | `vmaf-tune corpus --source testdata/ref_576x324_48f.yuv --width 576 --height 324 --duration 2 --two-pass --preset medium --crf 28 --output /tmp/c.jsonl` -> one row with `exit_status=187`; the same happens under `vmafx-tune-go corpus`. Reduced: `ffmpeg -f rawvideo -pix_fmt yuv420p -s 576x324 -r 24 -i ref.yuv -c:v libx264 -preset medium -crf 28 -pass 2 -passlogfile p out.mp4`. | Owner-driven; the fix is an adapter/driver decision (target a bitrate for pass 2, or refuse `--two-pass` on the CRF axis the way libsvtav1 already does) and needs an ADR. | Closes when `--two-pass` either produces a scorable encode or fails fast with an actionable message instead of a silent `exit_status != 0` row. | ## Deferred (waiting on external dataset access) @@ -367,6 +376,13 @@ landed fix yet._ | **T-GAP-BUILD-DEAD-CUDA-ADM-DECOUPLE-2026-09-02** | `core/src/feature/cuda/integer_adm/adm_decouple.cu` was dead code unlisted in `core/src/meson.build` and uncompiled in all build configurations since the adm_cm kernel consolidation. Deleted the orphan file, updated tidy baselines, and recorded the deletion rationale in `docs/rebase-notes.md`. | [ADR-1143](adr/1143-cuda-intel-backend-gaps.md) | gap/cuda-intel-bucket | Orphan file removed; CUDA build and fast test suite pass clean. | (2026-09-02) | | **T-GAP-BUILD-UNCOMPILED-CUDA-RESOLUTION-DISPATCH-2026-09-02** | `core/src/feature/cuda/resolution_dispatch.{c,h}` was unlisted in `core/src/meson.build`, uncompiled in all configurations, and unused by CUDA kernels. Deleted the orphaned dead code and documented the deletion in `docs/rebase-notes.md`. | [ADR-1143](adr/1143-cuda-intel-backend-gaps.md) | gap/cuda-intel-bucket | Dead code removed; tree builds and passes test suite. | (2026-09-02) | +| **T-UPSTREAM-1582-CONVOLUTION-MIRROR-AND-BORDER-OOB-2026-09-03** | Netflix/vmaf#1582 (mirror half also Netflix/vmaf#1581). Two out-of-bounds accesses in the float convolution, both reachable today from the public C API. (1) `convolution_edge_s` / `_sq_s` / `_xy_s` in `core/src/feature/common/convolution_internal.h` bounced an out-of-range reflect-101 tap exactly once, which only lands in range for `size >= radius + 1`; at size 2 a tap of -2 folds to +2 and a tap of +3 folds to -1 (heap-buffer-overflow READ). (2) `convolution_x_c_s` / `convolution_y_c_s` derived `borders_right = dim - (filter_width - radius)`, which is negative for a plane narrower than the filter, so the trailing loop started at a negative index and wrote `dst[i * dst_stride - 1]` (heap underflow WRITE). Two live paths reached the defective sizes: `--feature float_vif` on any frame in 9..15 px (the guard admitted >= 9, but the four-scale ladder needs >= 16 — the binding constraint is scale 3), and `--feature float_motion` with `motion_add_uv=true` on a 4x4 YUV420P frame (the guard validated luma only while the blur runs per plane at the 2x2 chroma dimensions). Fixed with one iterative `convolution_reflect101()` fold, a `convolution_clamp_borders()` helper, a `vif_get_min_dim(kernelscale)`-derived VIF floor, and a chroma-aware `float_motion` guard. | [ADR-1166](adr/1166-upstream-issue-harvest.md) / [Research-1166](research/1166-upstream-issue-harvest-2026-09-03.md) | fix/upstream-harvest-2026-09-03 | `core/test/test_convolution_edge_small.c` (NaN-poisoned guard buffers) fails on the pre-fix tree at the first 5-tap case and passes after; `test_large_plane_bit_identical` asserts bit-equality against an explicit single-bounce reference at 24x24 so nothing in contract moved. Netflix golden gate unchanged: 76.66744 / 35.070245 / 7.985956, 271 passed / 12 skipped. | (2026-09-03) | +| **T-UPSTREAM-1580-METAL-MOTION-MIN-DIM-GUARD-2026-09-03** | Netflix/vmaf#1580, residual gap. The min-dim guard from Research-0094 was present on 12 of 15 motion extractor translation units; the three missing were exactly the fork-added Metal ones (`core/src/feature/metal/integer_motion_metal.mm`, `integer_motion_v2_metal.mm`, `float_motion_metal.mm`), written after that sweep. All three are registered and shipped (`feature_extractor.cpp:229/232/234`), so a 1- or 2-pixel-tall frame read out of bounds on device through the `skip_mirror` / `mv2_mirror` helpers. The guard now runs before `vmaf_metal_context_new`, so no cleanup path is needed. The stale "the same check is present on every GPU backend" claim in `core/test/test_motion_min_dim.c`'s header was corrected in the same change. | [ADR-1166](adr/1166-upstream-issue-harvest.md) / [Research-1166](research/1166-upstream-issue-harvest-2026-09-03.md) | fix/upstream-harvest-2026-09-03 | `core/test/test_motion_min_dim.c::test_metal_motion_min_dim` asserts `-EINVAL` at 1x1 / 2x2 / 64x2 for all three Metal extractors; it degrades to a no-op on a build without `HAVE_METAL`, so the Apple CI leg is what exercises it. | (2026-09-03) | +| **T-UPSTREAM-1242-FEATURE-DICT-OWNERSHIP-2026-09-03** | Netflix/vmaf#1242. `vmaf_model_feature_overload()` returned `-ENOMEM` straight out of its loop when `vmaf_dictionary_merge()` failed, skipping the unconditional `vmaf_dictionary_free(&opts_dict)` at the tail — an unconditional leak of the caller's dictionary under the contract the fork itself publishes. `vmaf_model_collection_feature_overload()` additionally discarded `vmaf_dictionary_copy()`'s return value, leaked the partial copy, silently skipped the remaining sub-models while still able to return 0, and dereferenced `*model_collection` without checking it. And the public headers contradicted each other: `` said the caller still owns the dictionary on failure, `` and ADR-0806 said ownership transfers on both success and failure — one of the two readings is a latent CWE-415 for any third-party caller. All three headers now state the implemented contract identically: consumed on every path except the argument-validation guards. The unbuilt C++ twin `core/src/model.cpp` (which already had the correct `-ENOMEM` shape) was kept convergent. ADR-0806 is marked Superseded. | [ADR-1166](adr/1166-upstream-issue-harvest.md) / [Research-1166](research/1166-upstream-issue-harvest-2026-09-03.md) (supersedes [ADR-0806](adr/0806-feature-dictionary-ownership.md)) | fix/upstream-harvest-2026-09-03 | `core/test/test_model_feature_overload_ownership.c` pins the guard paths (caller may still free), the success path (consumed), and drives the merge-failure branch deterministically with a heap-allocated empty dictionary — the exact branch that leaked, which LeakSanitizer reports in the ASan lane. The NULL-collection case is UB pre-fix and a defined `-EINVAL` after. | (2026-09-03) | +| **T-UPSTREAM-743-WINDOWS-CONSOLE-SPINNER-2026-09-03** | Netflix/vmaf#743. `core/tools/spinner.h`'s 56-entry UTF-8 braille table went to stderr through a byte-oriented `fprintf` in `core/tools/vmaf.cpp`, inside the interactive `if (istty && !c->quiet)` block, while nothing in the tree ever set the console output code page or enabled VT processing — `grep -rn "SetConsoleOutputCP\|CP_UTF8\|_setmode" core/` returned nothing, and no `.manifest` sets `activeCodePage`. Under cp437 the two glyphs decoded as six garbage characters (widening the line past what the `\r` overwrite assumes), under cp1252 as a different six, and under cp936 as an illegal multibyte sequence rendered as replacement boxes — every frame of every run. The same `fprintf` also emitted `\033[K` unconditionally, which legacy conhost prints literally because `ENABLE_VIRTUAL_TERMINAL_PROCESSING` is off by default. The CLI now switches the console to UTF-8 + VT for the run through an RAII guard that restores the previous state on every exit path including the `goto cleanup` spine, and falls back to an ASCII spinner and space padding when the console refuses either. | [ADR-1166](adr/1166-upstream-issue-harvest.md) / [Research-1166](research/1166-upstream-issue-harvest-2026-09-03.md) | fix/upstream-harvest-2026-09-03 | `core/test/test_spinner.cpp` drives `spinner_table_for_codepage()` with the code pages a real conhost reports (437 / 1252 / 936 / 0) and asserts the ASCII table, asserts braille for 65001, asserts the erase sequence is VT-gated, and pins the braille table's first and last entries byte-for-byte plus `strlen == 6` for all 56 so POSIX output cannot drift. The two Windows CI legs prove the console-init code compiles and links. | (2026-09-03) | +| **T-UPSTREAM-1551-MSVC-CLZ-SHIM-LZCNT-2026-09-03** | Netflix/vmaf#1551, which retracts Netflix/vmaf#1422 — and the fork had adopted #1422's form. `core/src/feature/compat_builtin.h` implemented the MSVC `__builtin_clz` / `__builtin_clzll` shim with `__lzcnt` / `__lzcnt64`. MSVC emits LZCNT (`F3 0F BD`) unconditionally with no runtime feature gate; on an x86-64 without ABM/LZCNT (Intel Nehalem through Ivy Bridge, AMD pre-Barcelona) the `F3` prefix is ignored and the encoding retires as BSR, returning the MSB index instead of the leading-zero count. Two of the four call sites are on the generic scalar path behind no SIMD gate — `integer_vif.h:148` `log2_32` (`k = 16 - clz`) and `integer_adm.c:989` `get_best15_from32` (`k = 17 - clz`) — so an MSVC-built `vmaf.exe` on such a machine silently mis-normalised every VIF and ADM log2 (a 2048-LSB error, i.e. a factor of two in the VIF fixed point) and shifted by a negative count for large inputs. Invisible to CI because every hosted Windows runner has LZCNT, and the MSVC leg is a shipped path (it runs CPU unit tests and uploads `install/bin/vmaf.exe`). Replaced with `_BitScanReverse` / `_BitScanReverse64`, which are BSR by definition and present on every x86-64 part, plus a `_M_X64 || _M_IX86` architecture guard (neither intrinsic exists on MSVC ARM64, so that leg previously could not compile) and a 32-bit fallback. Closes the one live third of Netflix/vmaf#1422; its other two hunks are dead here (the `integer_vif.c` pointer-typing hunk was already rewritten fork-side, and the meson feature-detection hunk is not applicable — the fork point-gates the includes instead). | [ADR-1166](adr/1166-upstream-issue-harvest.md) / [Research-1166](research/1166-upstream-issue-harvest-2026-09-03.md) | fix/upstream-harvest-2026-09-03 | `scripts/ci/check-msvc-clz-shim.sh` (registered as a `fast`-suite meson test) fails on the pre-fix header with four findings and passes after; it also scans the rest of `core/src` so the intrinsic cannot return elsewhere. `core/test/test_compat_clz.c` unit-tests the `31 - msb` / `63 - msb` arithmetic the `__lzcnt` form got wrong, plus the two shift expressions the defect corrupted, on every platform. | (2026-09-03) | +| **T-UPSTREAM-1178-PKGCONFIG-MISSING-CXX-RUNTIME-2026-09-03** | Netflix/vmaf#1178. `core/src/meson.build` built `libvmaf_private_libs` as `[thread_lib, math_lib]` and extended it only for SYCL and the Metal frameworks, so every generated `libvmaf.pc` read `Libs.private: -pthread -lm` and `pkg-config --static --libs libvmaf` handed consumers a link line without the C++ runtime. The fork is more exposed than upstream: the undefined symbols come from its own converted translation units (`luminance_tools.cpp`, `feature_extractor.cpp`, `feature_collector.cpp`, `log.cpp`, `read_json_model.cpp`, the C++23 picture pools) as well as vendored `svm.cpp`. `docs/adr/0198-volk-priv-remap-static-archive.md:130-133` already records the BtbN-style fully-static FFmpeg reproducer with `-lstdc++` added by hand for exactly this reason. Upstream's patch is unportable (`else compiler.get_id() == 'clang':` is not meson syntax, and its clang -> `-lc++` mapping is wrong on Linux), so the fork detects the STL actually in use via `_LIBCPP_VERSION` and skips MSVC / clang-cl, which auto-link the runtime. No `ffmpeg-patches/` change is required (CLAUDE.md §12 r14): no new entry point, configure flag or `LIBVMAFContext` field — the existing `check_pkg_config` probes simply start succeeding under `--pkg-config-flags=--static`. | [ADR-1166](adr/1166-upstream-issue-harvest.md) / [Research-1166](research/1166-upstream-issue-harvest-2026-09-03.md) | fix/upstream-harvest-2026-09-03 | `gcc t.c -I core/include build/src/libvmaf.a -pthread -lm` (i.e. exactly `Libs` + the old `Libs.private`) fails with undefined references to `operator new(unsigned long)` and `operator delete(void*, unsigned long)`; the same consumer built from `pkg-config --static --libs libvmaf` after the fix links clean. The CI step "Verify static pkgconfig" no longer greps the flag list only — it compiles and links a C consumer with the C driver. | (2026-09-03) | +| **T-UPSTREAM-1573-CUDA-INCLUDES-AND-TEST-DEPENDS-2026-09-03** | Netflix/vmaf#1573, two of three hunks. Hunk (b): `core/src/meson.build` fed nvcc relative includes (`-I ./src -I ../src -I ../include ...`), which only resolve when the build directory is a direct child of `core/`; since ADR-0700 moved the project root the layout the fork's own docs use (`meson setup build core` from the repo root) put it elsewhere and every `.cu` fatbin failed with `fatal error: cuda/integer_adm_cuda.h: No such file or directory`. The neighbouring SYCL block already used absolute `meson.current_source_dir()` paths, so the fix pattern was in-tree; the Windows pthread-shim include was relative for the same reason and is now absolute too. Hunk (c): `core/tools/test/meson.build` registered `test_vmaf_cuda_gpumask` with no `depends` and no `workdir` while the script invokes `./tools/vmaf`; meson only rebuilds the targets a *selected* test declares (`mtest.py` `rebuild_deps()`: "if not targets: return True"), so selecting it as a subset built nothing and it died with exit 127. The two neighbouring fork-added tests had the same omission. Hunk (a) (`picture_cuda.c` uninitialised `priv->cuda.state`) is already fixed in-tree at `core/src/cuda/picture_cuda.c:190` and `:251` — nothing to port. | [ADR-1166](adr/1166-upstream-issue-harvest.md) / [Research-1166](research/1166-upstream-issue-harvest-2026-09-03.md) | fix/upstream-harvest-2026-09-03 | Reproduced on a freshly configured, uncompiled CUDA build dir: `meson setup core -Denable_cuda=true -Denable_sycl=false && meson test test_vmaf_cuda_gpumask` gave `FAIL 0.01s exit status 127` with `./tools/vmaf: No such file or directory`. The include fix is exercised by the CUDA fatbin build that `make test-netflix-golden` triggers. | (2026-09-03) | | **T-GAP-HIP-EXTRACTORS-PROMOTION-2026-09-03** | 13 of 19 registered HIP feature extractors previously left `.flags = 0`, falling back to CPU execution under `--backend hip`. Audited and promoted 11 extractors (`integer_cambi_hip`, `ciede_hip`, `integer_psnr_hip`, `float_psnr_hip`, `float_moment_hip`, `integer_motion_v2_hip`, `float_motion_hip`, `float_ssim_hip`, `integer_ms_ssim_hip`, `integer_psnr_hvs_hip`, `float_adm_hip`) to active GPU execution with `VMAF_FEATURE_EXTRACTOR_HIP` / `TEMPORAL` flags, bringing active HIP extractors to 17/19 on AMD hardware. | [ADR-1154](adr/1154-hip-backend-gaps.md) | gap/hip-bucket-v2 | 17/19 extractors actively dispatch on GPU; all parity tests green on AMD Granite Ridge (`gfx1036`). | (2026-09-03) | @@ -977,6 +993,12 @@ re-investigating dead ends._ | Netflix issue | Status on this fork | Evidence | | --- | --- | --- | +| **Netflix/vmaf#1422 — MSVC portability hunks (2 of 3)** | **Confirmed not-affected.** The `integer_vif.c` pointer-typing hunk was already rewritten fork-side: `vif_buffers_alloc` (`core/src/feature/integer_vif.c:541-601`) carves the single aligned allocation with an explicit `uint8_t *data` plus per-field casts, and its block comment names the exact upstream problem ("the original upstream form used `void *data` and relied on the GCC extension ... MSVC rejects that with C2036"). The `HAVE_UNISTD_H` / `HAVE_DIRECT_H` / `HAVE_STRUCT_TIMESPEC` / `HAVE_MODE_T` meson feature-detection hunk is **not applicable**: the fork solved the same portability problem by point-gating the includes instead (`` behind `!_WIN32`, `` + `_isatty`/`_fileno` redirects in `core/src/log.c`), and the MSVC legs build green without any feature-detection block. Only the `__lzcnt` hunk was live; see T-UPSTREAM-1551-MSVC-CLZ-SHIM-LZCNT-2026-09-03. | `grep -n 'unistd\|HAVE_DIRECT_H\|HAVE_STRUCT_TIMESPEC\|has_header' core/meson.build` returns nothing; `core/src/feature/integer_vif.c:541-601`; rebase-notes round-21 items (l)/(m). [Research-1166](research/1166-upstream-issue-harvest-2026-09-03.md) | +| **Netflix/vmaf#1573 hunk (a) — `vmaf_cuda_picture_synchronize` NULL `priv->cuda.state`** | **Already fixed.** `core/src/cuda/picture_cuda.c:190` sets `priv->cuda.state = cuda_state;` on the pinned path and `:251` sets `priv->cuda.state = cuda_cookie->state;` on the device path, so the `priv->cuda.state->f` dereference is safe. Do not re-port. | `sed -n '185,255p' core/src/cuda/picture_cuda.c`. [Research-1166](research/1166-upstream-issue-harvest-2026-09-03.md) | +| **Netflix/vmaf#1551 — the `M_PI` half** | **Already fixed / not applicable.** Every site that needs it carries an `M_PI` guard: `core/src/feature/adm_tools.h:24`, `adm_tools.c:31`, `adm_csf_tools.h:32`, `barten_csf_tools.h:31`, `integer_adm.h:117`, `ciede.c:54`, `integer_ssim.c:47`, `speed.c:30`, `speed_qa.c:65`, `speed_internal.c:41`, `vif_tools.c:39`, `y_funque_plus.c:89`, plus the CUDA / SYCL / HIP `float_adm` variants. Only the clz half of #1551 was live. | `grep -rn 'define M_PI' core/src/feature/ | wc -l`. [Research-1166](research/1166-upstream-issue-harvest-2026-09-03.md) | +| **Netflix/vmaf#818 — "pooling silently falls back to mean" / "two surfaces disagree"** | **Both claims refuted; do not re-investigate.** `pool_reduce()` (`core/src/libvmaf.c:3061-3090`) ends in `default: return -EINVAL;`, and `vmaf_feature_score_pooled` / `vmaf_score_pooled` / `vmaf_score_pooled_model_collection` each reject `VMAF_POOL_METHOD_UNKNOWN` up front; current upstream does the same, so the silent-mean behaviour exists in neither tree. The Python `perc5/perc10/perc20/median` options never reach the C pooling code — `Result._try_get_aggregate_score` applies `ListStats.perc10` to the per-frame list in NumPy (`compat/python-vmaf/core/result.py:47-85`, `tools/stats.py:82-91`), proven by the passing golden assertion `python/test/quality_runner_test.py:662-681` (72.71845922683059). The *feature gap* (no `MEDIAN` / `PERC*` enumerator in the public header) is real and tracked as T-UPSTREAM-818-POOLING-ENUM-NO-PERCENTILES-2026-09-03. | `sed -n '3055,3095p' core/src/libvmaf.c`. [Research-1166](research/1166-upstream-issue-harvest-2026-09-03.md) | +| **Netflix/vmaf#1494 — the nvd / adm_ref_display_height half** | **Confirmed not-affected.** `core/src/feature/integer_adm.c:3509-3512` rejects `adm_norm_view_dist * adm_ref_display_height < DEFAULT_ADM_NORM_VIEW_DIST * DEFAULT_ADM_REF_DISPLAY_HEIGHT` (3240) with `-EINVAL` before any kernel runs, which covers every case the report cites (rdh=720, rdh=480, nvd=0.75/rdh=240, nvd=1.5); `core/test/test_adm_coverage.c:246-289` asserts it. Over the whole allowed region `i_rfactor` peaks at {36452, 36452, 49415}, inside `uint16_t`, so upstream's stated motivation does not reproduce here. There is also no cross-backend hole: the GPU backends implement WATSON97 only. The fork-specific `adm_csf_mode` overflow *is* real and is tracked as T-UPSTREAM-1494-ADM-CSF-MODE-IRFACTOR-OVERFLOW-2026-09-03. | `vmaf ... --feature adm=adm_ref_display_height=720` -> `problem with feature extractor "adm"`; `--feature adm=adm_ref_display_height=2160` runs clean. [Research-1166](research/1166-upstream-issue-harvest-2026-09-03.md) | +| **Netflix/vmaf#1109 — the reporter's 28 dB vs 72 dB symptom** | **Confirmed not-affected (wrong diagnosis).** A `MIN` can only lower a value, never raise 28 to 72, so the reporter's concrete symptom is a frame-alignment / filter-ordering problem in their FFmpeg graph, not the PSNR cap. The *mechanism* the report describes — an unconditional per-frame `MIN` that truncates genuinely near-lossless frames — is real here and is tracked as T-UPSTREAM-1109-PSNR-CAP-TRUNCATES-2026-09-03. | [Research-1166](research/1166-upstream-issue-harvest-2026-09-03.md) | | **GAP-RUST-TAD-EXTRACTOR-STUB — `core/src/feature/tad_rust.c:95` returns `-ENOSYS` when `enable_rust_features=false`** | **Confirmed not-affected (by design).** TAD is an opt-in Rust pilot feature gate ([ADR-0707](adr/0707-rust-feature-extractor-pilot.md)); returning `-ENOSYS` on disabled builds matches the project-wide disabled-build contract ([ADR-0374](adr/0374-disabled-build-enosys-contract.md)). `tad_init` now logs a clear error message naming `-Denable_rust_features=true` when invoked on a build where TAD was disabled. | `core/src/feature/tad_rust.c` lines 87–99; documented in `docs/development/build-flags.md` and `docs/metrics/tad.md`. | | **GAP-STUBS-FALLBACK-ENOSYS-WHEN-DISABLED — `core/src/hip/stubs.c` returns `-ENOSYS` when `enable_hip=false`** | **Confirmed not-affected (by design).** The public C-API stubs in `core/src/hip/stubs.c` (`vmaf_hip_state_init`, `vmaf_hip_import_state`, `vmaf_hip_list_devices`) provide ABI linkage for applications compiling against `libvmaf_hip.h` when libvmaf is built without HIP support ([ADR-0212](adr/0212-hip-backend-scaffold.md), [ADR-0374](adr/0374-disabled-build-enosys-contract.md)). All error paths now emit `vmaf_log` diagnostics explicitly naming `-Denable_hip=true`. | `core/src/hip/stubs.c` lines 44–75; documented in `docs/backends/hip/overview.md` and `docs/development/build-flags.md`. | | **GAP-TINYAI-TRANSNET-V2-PLACEHOLDER-GRAPH — inventory flagged `transnet_v2.onnx` as synthetic placeholder graph** | **Confirmed not-affected (premise corrected).** `model/tiny/transnet_v2.onnx` is 30 MiB of real upstream weights from `github.com/soCzech/TransNetV2` (Soucek & Lokoc 2020) shipped via [ADR-0261](adr/0261-transnet-v2-real-weights.md) (`smoke: false`). Verified intact; `transnet_v2_init` logs a warning if an explicit placeholder model is passed. | `model/tiny/transnet_v2.onnx` (30 MiB), `core/src/feature/transnet_v2.c`, `docs/ai/models/transnet_v2.md`. | diff --git a/docs/usage/cli.md b/docs/usage/cli.md index e7450e8bf..4ce2f2dbd 100644 --- a/docs/usage/cli.md +++ b/docs/usage/cli.md @@ -366,6 +366,34 @@ workflow. | `--no_prediction` | `-n` | Skip final model prediction; extract features only. Useful for feeding raw features into a custom pool. | | `--version` | `-v` | Print `libvmaf` version + git SHA and exit. | +## Windows console output + +The interactive progress line (frame counter, spinner, FPS) is written to +stderr whenever stderr is a TTY and `--quiet` is not set. The spinner uses +Unicode braille glyphs and an ANSI erase-to-end-of-line sequence, neither of +which a Windows console renders correctly by default: under the conhost default +code page (cp437) each two-glyph frame decodes as six garbage characters, under +cp936 it decodes as replacement boxes, and legacy conhost prints the erase +sequence literally as `<-[K`. + +Since ADR-1166 the CLI handles this itself. On Windows it: + +1. records the current console output code page and stderr console mode, +2. switches the console to UTF-8 (`CP_UTF8`) and enables + `ENABLE_VIRTUAL_TERMINAL_PROCESSING`, +3. restores both on exit — including error exits — so your shell is left as it + was found, +4. and, if the console refuses either change, falls back to a pure-ASCII + spinner (`|` `/` `-` `\\`) and pads with spaces instead of emitting the + erase sequence. + +There is no flag for this and nothing to configure; `--quiet` still suppresses +the progress line entirely, and redirecting stderr to a file or pipe suppresses +it as well (the CLI only draws it for a TTY). On Linux and macOS the emitted +bytes are unchanged from previous releases. + +Reported upstream as [Netflix/vmaf#743](https://github.com/Netflix/vmaf/issues/743). + ## Exit codes | Code | Meaning | diff --git a/scripts/ci/check-msvc-clz-shim.sh b/scripts/ci/check-msvc-clz-shim.sh new file mode 100755 index 000000000..2880af209 --- /dev/null +++ b/scripts/ci/check-msvc-clz-shim.sh @@ -0,0 +1,106 @@ +#!/usr/bin/env bash +# check-msvc-clz-shim.sh — guard the MSVC __builtin_clz shim against the +# Netflix/vmaf#1422 form that Netflix/vmaf#1551 retracts. +# +# MSVC's `__lzcnt` / `__lzcnt64` emit the LZCNT instruction (F3 0F BD) +# unconditionally, with no runtime feature gate. On an x86-64 without +# ABM/LZCNT the F3 prefix is ignored and the encoding retires as BSR, which +# returns the INDEX of the most-significant set bit instead of the +# leading-zero COUNT. Both scalar call sites of the shim +# (integer_vif.h::log2_32, integer_adm.c::get_best15_from32) then compute +# silently wrong VIF / ADM log2 shifts — no fault, no diagnostic, wrong VMAF. +# CI cannot catch it because every hosted Windows runner has LZCNT. +# +# The shim must therefore be written with `_BitScanReverse` / +# `_BitScanReverse64`, which are BSR by definition and present on every +# x86-64 part, and must carry an explicit architecture allowlist that +# enumerates every architecture MSVC targets -- ARM64 included. Per the MSVC +# intrinsics reference, `_BitScanReverse` is available on x86, ARM, x64 and +# ARM64, and only `_BitScanReverse64` is restricted (to x64 and ARM64), so no +# MSVC architecture needs to be excluded. An excluded one does not fall back to +# anything: the header is the sole definition of `__builtin_clz` for the +# generic scalar path, so that leg simply fails to compile. +# +# Usage: scripts/ci/check-msvc-clz-shim.sh [repo-root] +# Exit 0 when the shim is in the required shape, 1 otherwise. +# +# Copyright 2026 Lusoris +# SPDX-License-Identifier: BSD-3-Clause-Plus-Patent + +set -euo pipefail + +ROOT="${1:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}" +HDR="$ROOT/core/src/feature/compat_builtin.h" + +if [[ ! -f "$HDR" ]]; then + echo "ERROR: $HDR not found" >&2 + exit 1 +fi + +rc=0 + +# (1) The intrinsic must not be *used*. The explanatory comment names it, so +# only flag occurrences outside comment lines. +# +# Matches the bare IDENTIFIER, not `__lzcnt(`: keying on the call syntax +# let `#define LZ __lzcnt` + `LZ(x)` (or token pasting) reintroduce the +# instruction while still passing this gate. There is no legitimate +# non-comment mention of the identifier in this header, so requiring its +# total absence outside comments is both stricter and simpler. +if grep -vE '^[[:space:]]*(\*|/\*|//)' "$HDR" | grep -qE '\b__lzcnt(64)?\b'; then + echo "FAIL: $HDR calls __lzcnt/__lzcnt64." >&2 + echo " LZCNT silently decodes as BSR on pre-Haswell x86-64 and yields" >&2 + echo " wrong VIF/ADM shifts. Use _BitScanReverse. Netflix/vmaf#1551." >&2 + rc=1 +fi + +# (2) The BSR intrinsic must be present. +if ! grep -q '_BitScanReverse' "$HDR"; then + echo "FAIL: $HDR does not use _BitScanReverse." >&2 + rc=1 +fi + +# (3) The MSVC guard must carry an explicit architecture allowlist covering +# every architecture MSVC targets. Continuation lines are joined first, +# because the guard legitimately spans several physical lines. +guard=$(sed -e :a -e '/\\$/N; s/\\\n//; ta' "$HDR" | grep -E '^#if defined\(_MSC_VER\)' || true) +if [[ -z "$guard" ]]; then + echo "FAIL: $HDR has no '#if defined(_MSC_VER)' guard at all." >&2 + rc=1 +elif ! grep -qE '_M_(X64|IX86)' <<<"$guard"; then + echo "FAIL: the _MSC_VER guard in $HDR has no _M_X64 / _M_IX86 architecture test." >&2 + rc=1 +elif ! grep -q '_M_ARM64' <<<"$guard"; then + echo "FAIL: the _MSC_VER guard in $HDR does not cover _M_ARM64." >&2 + echo " _BitScanReverse is available on x86, ARM, x64 and ARM64 (MSVC" >&2 + echo " intrinsics reference); excluding ARM64 leaves __builtin_clz with" >&2 + echo " no definition on the generic scalar path, so the leg cannot" >&2 + echo " compile. Do not narrow this allowlist." >&2 + rc=1 +fi + +# (4) Nothing else in the tree may reintroduce the intrinsic either. +# Restricted to source extensions: rule (1) now matches the bare identifier, +# and documentation legitimately discusses `__lzcnt` in prose +# (core/src/feature/AGENTS.md explains why the shim must not use it). The rule +# is about CODE reintroducing the instruction. +# The audited shim is excluded by BASENAME, not by matching against "$HDR": +# grep -rl prints whatever path form it walked, which on MSYS / Git-for-Windows +# is not the same string as the absolute "$HDR" this script computes, so the +# previous `grep -vF "$HDR"` filter silently failed to exclude the shim there +# and the gate reported its own header (the Windows MinGW64 leg failed on +# exactly that). Rule (1) above already audits the shim itself. +others=$(grep -rlE '\b__lzcnt(64)?\b' "$ROOT/core/src" \ + --include='*.c' --include='*.h' --include='*.cpp' --include='*.hpp' \ + --include='*.cu' --include='*.cuh' --include='*.hip' --include='*.mm' \ + --include='*.metal' --exclude='compat_builtin.h' 2>/dev/null || true) +if [[ -n "$others" ]]; then + echo "FAIL: __lzcnt used outside the audited shim:" >&2 + echo "$others" >&2 + rc=1 +fi + +if [[ $rc -eq 0 ]]; then + echo "OK: MSVC clz shim uses _BitScanReverse and is architecture-guarded." +fi +exit $rc diff --git a/scripts/ci/tidy-baseline-cpu.json b/scripts/ci/tidy-baseline-cpu.json index 5cefde96f..75a26b584 100644 --- a/scripts/ci/tidy-baseline-cpu.json +++ b/scripts/ci/tidy-baseline-cpu.json @@ -4,7 +4,7 @@ "generator": "scripts/ci/tidy-ratchet.py", "clang_tidy_version": "22.1.8", "tus": 282, - "total_warnings": 3095, + "total_warnings": 3070, "total_nolint_uncited": 58, "warnings": { "build/src/brisque_live.model.c": 2, @@ -36,7 +36,6 @@ "core/src/feature/brisque.c": 3, "core/src/feature/ciede.c": 18, "core/src/feature/common/blur_array.c": 6, - "core/src/feature/common/convolution.c": 2, "core/src/feature/delta_e_itp.c": 3, "core/src/feature/fastdvdnet_pre.c": 7, "core/src/feature/feature_collector.cpp": 13, @@ -142,7 +141,6 @@ "core/test/test_float_psnr_coverage.c": 13, "core/test/test_float_ssim_coverage.c": 18, "core/test/test_float_vif_coverage.c": 21, - "core/test/test_float_vif_min_dim.c": 8, "core/test/test_flush_context_ordering.c": 20, "core/test/test_framesync.c": 8, "core/test/test_gpu_dispatch_env_oom.cpp": 6, @@ -170,7 +168,6 @@ "core/test/test_model_libsvm_dup_key.c": 6, "core/test/test_moment_simd.c": 8, "core/test/test_motion_avx512_parity.c": 23, - "core/test/test_motion_min_dim.c": 15, "core/test/test_motion_v2_simd.c": 3, "core/test/test_ms_ssim_decimate.c": 10, "core/test/test_ms_ssim_decimate_coverage.c": 10,