Skip to content

fix(simd): add the NEON and SVE2 float_moment lanes in the scalar's order so aarch64 returns the scalar bits past 2^53 units - #1923

Merged
lusoris merged 1 commit into
masterfrom
fix/arm-moment-scalar-order
Oct 3, 2026
Merged

lusoris merged 1 commit into
masterfrom
fix/arm-moment-scalar-order

Conversation

@lusoris

@lusoris lusoris commented Oct 3, 2026

Copy link
Copy Markdown
Contributor

Summary

The aarch64 CPU float_moment now returns the scalar function's bits on every input and on every SVE vector length. compute_*_moment_neon() added into lane accumulators across the frame. compute_*_moment_sve2() reduced each row's vector sum, so its grouping depended on the vector length. On a 16-bit frame whose sum of float squares passes 2^53 units, the scalar double rounds on every add, and both groupings returned different numbers. The maintainer moved T-ARM-MOMENT-NEON-SVE2-SUM-ORDER-2026-10-03 from RC7 to RC3 (popup, 2026-10-03: "RC3, fix before rc.3 (Recommended)").

Both kernels now store each vector of samples (squared in float for the second moment) and add the lanes into one double one after the other in raster order. That is the scalar loop's order, and moment_avx2.c / moment_avx512.c already use it. The SVE2 kernel adds the first svcntp_b32 active lanes of a svwhilelt_b32 predicate, so it makes the same adds at every vector length. ADR-1500 records the decision. It supersedes the 1e-7 tolerance of ADR-0179, ADR-0584 and ADR-0987 for every moment SIMD kernel.

The PR also fixes one test bug found on the way, the same line in two files. test_moment_simd and test_iqa_convolve gated their aarch64 cases on vmaf_get_cpu_flags(), which reads 0 until vmaf_init_cpu() has run, and neither test calls it. As a result, the SVE2 moment cases and the NEON convolution cases were skipped on every processor. Both tests now ask vmaf_get_cpu_flags_arm().

Closes T-ARM-MOMENT-NEON-SVE2-SUM-ORDER-2026-10-03 and T-ARM-MOMENT-SVE2-TEST-NEVER-RAN-2026-10-03. Opens T-ARM-MOMENT-SCALAR-ORDER-COST-2026-10-03 (RC8): the kernels' speed on aarch64 hardware has not been measured.

Type

  • fix — bug fix
  • sycl / cuda / simd — backend-specific

Checklist

  • Commits follow Conventional Commits (the commit-msg hook enforces this).
  • make format && make lint is green locally:
    • clang-format runs through the pre-commit hook.
    • The clang-tidy lanes were measured in the dev container with scripts/dev/tidy-lane.sh: cpu 70 = baseline 70. On arm64, test_moment_simd.c went from 1 warning to 0, and the baseline was tightened by a scoped --write (114). No file is above its baseline.
    • praetorctl audit passes: HISS 227 within 227, 29 touched files clean.
    • scripts/dev/preflight.sh --stage msvcism passes.
  • Unit tests pass:
    • x86 build: fast suite, 256 OK.
    • aarch64 cross build under qemu-aarch64: simd suite, 23 OK.
    • test_moment_simd under the five qemu settings below.
  • If I touched any SIMD/GPU code path, I ran /cross-backend-diff and the worst ULP is ≤ 2: 0 ULP. Every SIMD moment kernel is == to the scalar function on every test frame (table below).
  • If I touched a feature extractor with SIMD/GPU twins, I either updated every twin or listed the gap under "Known follow-ups" below. The x86 kernels already had the scalar's order. The GPU twins form the same sum since ADR-1497.
  • If I added a new .c / .cpp / .cu / .h / .hpp, it has the appropriate license header (see CONTRIBUTING.md). No new source file.
  • If this is a breaking change, the commit message uses ! or BREAKING CHANGE: and the migration path is documented below. Not a breaking change.
  • If this PR adds an ADR, the ADR row lives in docs/adr/_index_fragments/<NNNN-slug>.md and the slug is appended to docs/adr/_index_fragments/_order.txt — do not edit docs/adr/README.md directly (regenerated by scripts/docs/concat-adr-index.sh; see ADR-0221).

Bug-status hygiene (ADR-0165)

  • docs/state.md updated in this PR with a row in the appropriate section:
    • T-ARM-MOMENT-NEON-SVE2-SUM-ORDER-2026-10-03 is removed from the RC7 disposition and closed in Recently closed.
    • T-ARM-MOMENT-SVE2-TEST-NEVER-RAN-2026-10-03 is added to Recently closed.
    • T-ARM-MOMENT-SCALAR-ORDER-COST-2026-10-03 is opened under RC8.
    • T-ARM-SIMD-GATES-SVE2-VECTOR-LENGTH-UNVERIFIED-2026-10-02 gets a progress note.

Netflix golden-data gate (ADR-0024)

  • I did not modify any assertAlmostEqual(...) score in the Netflix golden Python tests. make test-netflix-golden-arm64 (GCC 16.1 cross build under qemu-aarch64): 280 passed, 3 skipped. It ran on 8e4f2f9c5, the same code before the rebase onto a8723a182, which changed no CPU source. x86 object code is unchanged (below), so the x86 gate cannot move.
  • If I believe a golden value must change, I have explained why below AND pinged @lusoris for a CODEOWNERS exception. Not applicable.

Cross-backend numerical results

test_moment_simd under qemu-aarch64 11.1.1, kernels of 6a3c26270 (master) against this PR. Every case compares both moments against the scalar function with ==:

frame                                                    NEON (sve=off)      SVE2 128 / 256 / 512 / 2048 bits
random floats, tails 1..15, 4096x2048 below 2^53         equal / equal       equal / equal
16x1: one 1, fifteen 1.5*2^-53 (first moment)            DIFF / equal        DIFF (VL-dependent value) / equal
4096x2049: 2^53 units, then 4096 single units            DIFF / equal        DIFF / equal
3841x2160 16-bit bright+dark noise (~2^54.2 units)       DIFF / equal        DIFF / equal

The NEON second moment on the noise frame was 37917.601966364906, against the scalar's 37917.601964216701. x86: AVX2 and AVX-512 are == on every case.

x86 object code: every object and library of a GCC x86 build (183 libvmaf objects, libvmaf.so, libvmaf.a) has the same sha256 with and without this change. Only test_moment_simd.c.o differs.

Performance (if perf or feat)

Not measured on aarch64 hardware: the kernels have only run under emulation. They now make one dependent add per sample, as the scalar loop and the x86 kernels do. The measurement and any tuning that keeps the bits are tracked in T-ARM-MOMENT-SCALAR-ORDER-COST-2026-10-03 (RC8).

Deep-dive deliverables (ADR-0108)

  • Research digest — no digest needed: ADR-1500 holds the measurements, the audit of the other aarch64 lane reductions and the proof.
  • Decision matrix — ADR-1500, "Alternatives considered": scalar order, exact integer sums as in ADR-1497, a size threshold, the second moment only, keeping the tolerance.
  • AGENTS.md invariant note:
    • core/src/feature/arm64/AGENTS.md: twin-update row, SVE2 section, CPU-flag note for tests.
    • core/src/feature/AGENTS.d/float-moment.md and core/src/feature/x86/AGENTS.d/moment.md updated.
    • New entry in docs/development/rebase-sensitive-invariants.md.
  • Reproducer / smoke-test command — pasted below under "Reproducer".
  • CHANGELOG fragment — changelog.d/fixed/arm-float-moment-scalar-order.md.
  • Rebase note — docs/rebase-notes.md, "The NEON and SVE2 float_moment kernels add in the scalar's order (ADR-1500, 2026-10-03)".

Reproducer

meson setup build-a64 core --cross-file build-aux/aarch64-linux-gnu.ini \
    -Denable_cuda=false -Denable_sycl=false -Denable_hip=false
ninja -C build-a64 test/test_moment_simd test/test_iqa_convolve
for cpu in max,sve=off max,sve128=on max,sve256=on max,sve512=on \
           max,sve2048=on,sve-default-vector-length=256; do
  qemu-aarch64 -cpu "$cpu" -L /usr/aarch64-linux-gnu build-a64/test/test_moment_simd
done
qemu-aarch64 -cpu max -L /usr/aarch64-linux-gnu build-a64/test/test_iqa_convolve   # 19 cases, was 6

Known follow-ups

  • T-ARM-MOMENT-SCALAR-ORDER-COST-2026-10-03 (RC8): measure the kernels on aarch64 hardware.
  • Other aarch64 kernels that add floating-point values in lanes were checked (ADR-1500, Consequences):
    • float_psnr_neon: row sums are exact at 16 bits.
    • float_adm_neon: the reductions are built but not dispatched (T-FLOAT-ADM-X86-SCALAR-STAGES-2026-10-02).
    • convolve_neon and speed_neon: one sum per lane.
    • The integer kernels sum in 64 bits.
    • No other kernel has the gap.

@lusoris

lusoris commented Oct 3, 2026

Copy link
Copy Markdown
Contributor Author

This PR also fixes the test_metal_selftest_float_moment failure that the aarch64 lanes show on master since #1918 (Ubuntu ARM clang and macOS clang, for example job 111218315363 on #1922).

The self-test runs the CPU float_moment extractor on the past-2^53 cases of core/test/float_moment_twin_parity.h. Its range check expects the CPU's second moment to differ from the exact sum's moment where the scalar double rounds. On aarch64 the NEON and SVE2 kernels kept the ties in their lane sums, so the check failed:

16-bit sum 2^53 and three ties: exact sum 9007199254740995 (2^53.0000),
CPU 65027.96899224809, exact moment 65027.96899224809
@case test_float_moment_16bit_past_2_53_exact fail

Under qemu-aarch64, build-a64/test/test_metal_selftest_float_moment gave these results:

  • With the kernels of a8723a182 it fails with -cpu max,sve=off (NEON) and with sve512=on.
  • With this PR's kernels it passes 7 of 7 with sve=off, sve128=on and sve512=on.

@github-actions github-actions Bot added the type:bug Something isn't working label Oct 3, 2026
@lusoris
lusoris force-pushed the fix/arm-moment-scalar-order branch from bb69fb9 to e1b1503 Compare October 3, 2026 14:43
…rder so aarch64 returns the scalar bits past 2^53 units (#1923)

* fix(simd): add the NEON and SVE2 float_moment lanes in the scalar's order so aarch64 returns the scalar bits past 2^53 units

The NEON and SVE2 float_moment kernels now return the scalar function's
bits on every input and on every SVE vector length. The NEON kernel
added into lane accumulators across the frame. The SVE2 kernel reduced
each row's vector sum, grouped by the vector length. On a 16-bit frame
the second moment's double passes 2^53 units of 2^-16, and from there
every add rounds, so those groupings returned other numbers. On
3841x2160 16-bit noise under qemu-aarch64 the result was
37917.601966364906 against the scalar's 37917.601964216701.

Both kernels now store each vector of samples and add the lanes into
one double one after the other, in raster order, as moment_avx2.c does.
The second moment squares the samples in float first. test_moment_simd
asserts == for AVX2, AVX-512, NEON and SVE2 on frames past 2^53 and at
the boundary. Under qemu-aarch64 it fails on master for NEON and for
SVE2 at 128, 256, 512 and 2048 bits, and it passes with the change. x86
object code does not change.

test_moment_simd and test_iqa_convolve read vmaf_get_cpu_flags() without
calling vmaf_init_cpu() first, so the call returned 0. Their SVE2 and
NEON cases never ran on any aarch64 processor. Both tests now ask
vmaf_get_cpu_flags_arm(). ADR-1500 supersedes the 1e-7 tolerance of
ADR-0179, ADR-0584 and ADR-0987.

* docs: regenerate the indexes and the citation map after rebasing
@lusoris
lusoris force-pushed the fix/arm-moment-scalar-order branch from e1b1503 to 219ee7d Compare October 3, 2026 14:56
@lusoris
lusoris merged commit 219ee7d into master Oct 3, 2026
69 of 79 checks passed
@lusoris
lusoris deleted the fix/arm-moment-scalar-order branch October 3, 2026 14:56
lusoris added a commit that referenced this pull request Oct 3, 2026
…t results by bits (ADR-1502) (#1927)

* fix(codeql): fix the open CodeQL alerts in code and compare exact test results by bits (ADR-1502)

Fixes 67 of the 71 open CodeQL alerts without changing a library or tool
object file (GCC and clang, x86-64 and aarch64, -Db_lto=false, cmp of every
object). No score moves.

The exact-twin, replay and recorded-value tests asserted bit identity with
`==`, which calls +0 equal to -0. They now use core/test/float_bits.h:
vmaf_test_identical_f64/_f32 hold when the bit patterns match and the value
is not a NaN, so the helper is never weaker than the `==` it replaces.
test_float_bits pins it and fails for `a == b` and for a bit compare without
the NaN rule.

Other fixes: the three SpEED base-entropy products convert the float
product's result explicitly (ADR-1477's arithmetic unchanged); adm.h and
motion.h get include guards and adm_tools.h / adm_csf_tools.h open theirs
before the includes; adm_options.h names the dead ADM_OPT_DEBUG_DUMP switch
in prose; run_netflix_value_tests() keeps one body in both meson variants of
test_speed_upstream_form; the CLI test fake keeps a comparison result
instead of a caller's pointer.

Left open: 1338 and 1339 (white-box tests that include ciede.c and
integer_psnr.c, dismissed before as 88 and 96) and 1392 and 1393
(test_float_moment_sum.c, which open PR #1921 moves).

* test(float_bits): split the signed-zero and neighbour cases to stay under the branch budget

clang-tidy readability-function-size counted 16 branches (threshold 15) in
test_signed_zeros_and_neighbours_differ() in all five lanes. The cases are
unchanged; they now run as two tests.

* test(moment): compare the SIMD moments with the scalar by bits

The check_frame() comparison #1923 added asserts "a SIMD moment is not the
scalar's bits" with `!=`, which CodeQL reports (cpp/equality-on-floats) and
which accepts +0 for -0. It now uses vmaf_test_identical_f64() (ADR-1502).
AVX2, AVX-512, NEON and SVE2 (qemu) runs pass.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

type:bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant