Skip to content

perf(cambi): vectorize the spatial-mask dp and mask rows on AVX2, AVX-512 and NEON - #1472

Merged
lusoris merged 6 commits into
masterfrom
perf/cambi-spatial-mask-simd
Sep 19, 2026
Merged

lusoris merged 6 commits into
masterfrom
perf/cambi-spatial-mask-simd

Conversation

@lusoris

@lusoris lusoris commented Sep 18, 2026

Copy link
Copy Markdown
Contributor

Summary

CAMBI's spatial-mask compute_dp_row and compute_mask_row ran scalar on every ISA. They now have AVX2, AVX-512 and NEON kernels, each dispatched only where it measurably beats the scalar it replaces (ADR-1256, Proposed). CAMBI scores are byte-identical at --precision max.

Upstream Netflix/vmaf added AVX2 versions in 86da14d03. Taken as-is, upstream's AVX2 dp row is slower than scalar under Clang (0.74x) and icx (0.64x), and icx builds the published container. So the dp row is rewritten to carry one add per block instead of a serial chain. The AVX2 mask row biases its signed compare so it matches scalar for every input, where upstream's plain signed compare breaks near 2^31.

Speed-up vs the production scalar at 1080p on Zen 5:

Kernel GCC Clang icx
dp row, AVX2 2.25x 1.81x 1.81x
dp row, AVX-512 3.18x 2.59x 2.55x
mask row, AVX2 1.68x 1.51x 1.48x
mask row, AVX-512 2.26x 2.08x 2.05x

The NEON dp row is wired: 8 columns go from roughly 56–80 scalar instructions to 26, with one loop-carried add instead of eight. The NEON mask row is not wired, because GCC and Clang already compile the scalar loop to the same instruction count. Its kernel stays in the tree and in the parity test. The two row kernels are about 2% of a 1080p CAMBI frame, so the end-to-end gain is small. The point of the change is closing the backend gap.

Reviewer decision: ADR-1256, the measure-before-dispatch rule.

Type

  • feat — new feature
  • fix — bug fix
  • perf — performance improvement
  • refactor — no behavior change
  • docs — documentation only
  • test — test-only
  • build / ci — tooling / infra
  • port — cherry-pick from upstream Netflix/vmaf
  • sycl / cuda / simd — backend-specific

Checklist

  • Commits follow Conventional Commits (the commit-msg hook enforces this).
  • make format && make lint is green locally.
  • Unit tests pass: meson test -C build.
  • If I touched any SIMD/GPU code path, I ran /cross-backend-diff and the worst ULP is ≤ 2: integer kernels, bit-exact to scalar. 21 cambi JSONs are byte-identical to origin/master under default dispatch, AVX2-only and scalar, on x86 and on the aarch64 cross build under qemu.
  • If I touched a feature extractor with SIMD/GPU twins, I either updated every twin or listed the gap under "Known follow-ups" below: AVX2, AVX-512 and NEON all have both kernels. The GPU backends compute the spatial mask on the device already.
  • If I added a new .c / .cpp / .cu / .h / .hpp, it has the appropriate license header (see CONTRIBUTING.md).
  • If this is a breaking change, the commit message uses ! or BREAKING CHANGE: and the migration path is documented below: not breaking.
  • If this PR adds an ADR, the ADR row lives in docs/adr/_index_fragments/<NNNN-slug>.md and the slug is appended to docs/adr/_index_fragments/_order.txt.

Bug-status hygiene (ADR-0165)

  • no state delta: a performance change; no tracked bug is opened, closed or re-scoped. The dead AVX-512/NEON CAMBI kernels found along the way are listed under Known follow-ups and get their own row in the follow-up that re-wires them.

Netflix golden-data gate (ADR-0024)

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

Deep-dive deliverables (ADR-0108)

  • Research digest — docs/research/2062-cambi-spatial-mask-simd.md: per-compiler measurements, the upstream comparison and the NEON instruction counts.
  • Decision matrix — docs/adr/1256-cambi-spatial-mask-simd-dispatch.md, ## Alternatives considered.
  • AGENTS.md invariant note — core/src/feature/x86/AGENTS.md and core/src/feature/arm64/AGENTS.md.
  • Reproducer / smoke-test command — below.
  • CHANGELOG fragment — changelog.d/changed/perf-cambi-spatial-mask-simd.md.
  • Rebase note — docs/rebase-notes.md: upstream 86da14d03 adapted, and what differs.

Reproducer

meson setup build core -Denable_cuda=false -Denable_sycl=false -Db_lto=false && ninja -C build
meson test -C build test_cambi_spatial_mask_simd test_cambi test_cambi_simd

# scores identical under default dispatch, AVX2 only, and scalar
for m in "" "--cpumask 16" "--cpumask 65535"; do
  build/tools/vmaf -r python/test/resource/yuv/src01_hrc00_576x324.yuv \
    -d python/test/resource/yuv/src01_hrc01_576x324.yuv -w 576 -h 324 -p 420 -b 8 \
    --feature cambi --no_prediction --precision max --json -o /tmp/cambi$m.json $m
done

Known follow-ups

  • The older AVX-512 and NEON CAMBI kernels (derivative row, c-values row, range updates) have not been dispatched since e3fd1c88a, so they are dead code and CAMBI runs AVX2 or scalar there. docs/backends/arm/overview.md claimed NEON CAMBI; that claim is corrected here. Re-wiring or retiring them is the next CAMBI change.
  • Intel AVX-512 and real aarch64 hardware are unmeasured.
  • The research digest number (2062) was chosen by hand: research digests have no allocator.

…rnel

calculate_c_values_row_neon carried the scalar per-pixel c-value body twice,
once for eight-column blocks with an active mask and once for the tail, which
put the function over the readability-function-size budget and five levels
deep. The body now lives in one static helper that takes the row-invariant
inputs as a struct, and both loops call it. The helper keeps the scalar
operation order, so the output is unchanged; test_cambi_simd passes under
qemu-aarch64.

The row pointer arithmetic now widens to ptrdiff_t before multiplying, the file
includes its own header so its definitions are checked against their
prototypes, and test_cambi.c declares the AVX2-only histogram buffer inside
the ARCH_X86 guard, which removes an unused-variable warning on aarch64.
…-512 and NEON

CAMBI builds its spatial mask once per frame from two row kernels,
compute_dp_row (one summed-area-table row) and compute_mask_row (the box sum
compared against mask_index), and both ran scalar on every ISA. This adapts
upstream Netflix/vmaf 86da14d03 ("feature/cambi: AVX2 vectorize spatial-mask
dp row and mask row"), whose AVX2 kernels land in cambi_avx2.c under the
file's existing Netflix notice, and adds AVX-512 and NEON twins.

The port differs from upstream in two places. Upstream's AVX2 dp row carries
its running prefix through a lane broadcast of the already-carried scan, which
measured 0.64-0.74x of scalar when built with Clang or icx. The fork's version
adds the block total to the carry, so a single add is loop-carried, and runs
1.8-2.3x faster than scalar at 1080p on Zen 5 across GCC, Clang and icx. The
AVX2 mask row biases both compare operands by 2^31, which makes the signed
vpcmpgtd equal to the scalar unsigned compare for every input rather than only
for box sums below 2^31. AVX-512 uses the same structure with an unsigned
compare and runs 2.6-3.2x (dp) and 2.1-2.3x (mask) faster than scalar.

Dispatch follows the existing setup_callbacks pattern. The scalar kernels are
the default binding, AVX2 upgrades both, AVX-512 (under HAVE_AVX512) upgrades
both, and aarch64 upgrades the dp row to NEON. The NEON mask row is built and
tested but not dispatched, because GCC and Clang already compile the scalar
loop to the same NEON instructions (ADR-1256). The scalar kernels become the
non-static reference functions declared in cambi.h, as upstream made them, and
the GPU twins' spatial-mask trampoline keeps passing the scalar kernels.

test_cambi_spatial_mask_simd checks every twin byte for byte against the
production scalar. It covers the dp row over every tail residue of both vector
widths, random widths, pad sizes 1 to 8 and wrapping uint32 inputs; the mask
row with box sums planted around thresholds that include 2^31; and the whole
row recurrence for several heights and filter sizes. CAMBI scores at
--precision max are identical to master for 8-bit and 10-bit inputs, odd
widths and full-reference mode, under default, AVX2-only and scalar dispatch,
and on aarch64 under qemu.
…ispatch decision

The CAMBI metric page gains a CPU SIMD section. It lists, per stage, which
AVX2, AVX-512 and NEON kernels the extractor dispatches, gives the measured
speed-ups of the spatial-mask row kernels, and shows how to compare paths with
--cpumask. It also states plainly that several older AVX-512 and NEON CAMBI
kernels are built but not dispatched, and the arm backend overview no longer
claims a full NEON CAMBI path.

ADR-1256 records the rule applied here: a spatial-mask row kernel is dispatched
on an ISA only when it beats scalar on a measured run, or, where it cannot be
timed, when its instruction count clearly drops. Research-2062 holds the
benchmark method and numbers for GCC, Clang and icx, the reason upstream's AVX2
dp row loses to scalar under Clang and icx, and the NEON instruction counts.

The changelog fragment, the rebase note for upstream 86da14d03, and the x86 and
arm64 AGENTS.md rows mark what a future upstream sync must keep.
…onto master

Recorded from a clean clone with the pinned engine. The branch predates the governance adoption, so its changes to cambi.c shifted the lines the baseline keys on. 1,623 to 1,622; no finding is new.
The maintainer accepted the measure-before-dispatch rule for CAMBI's spatial-mask row kernels on 2026-09-19 (popup Q3.5). Status, index row and tag pages updated.
@lusoris
lusoris force-pushed the perf/cambi-spatial-mask-simd branch from ccda800 to 5940e14 Compare September 19, 2026 09:57
@lusoris
lusoris merged commit 4918330 into master Sep 19, 2026
80 checks passed
@lusoris
lusoris deleted the perf/cambi-spatial-mask-simd branch September 19, 2026 11:15
lusoris added a commit that referenced this pull request Sep 19, 2026
…limit

clang-format spreads a braced initializer over one line per field, so the
eight-case table was a 96-line brace block, which the HISS-04 size check counts
as a function. One case per line inside a format-off region, as the HIP option
tables already do. The data is unchanged.

Also drops the pre-correction copy of the CAMBI state row that the keep-both
rebase resolution kept twice, and re-records the standards baseline from a
clean clone with the pinned engine: #1472's squash moved the lines the
line-keyed file tracks in cambi.c. Total is unchanged at 1,622.
lusoris added a commit that referenced this pull request Sep 19, 2026
…to master

Recorded from a clean clone of this branch with the pinned engine, after #1472 and #1489 merged and moved the lines the line-keyed file tracks. 1,501 to 1,500.
lusoris added a commit that referenced this pull request Sep 19, 2026
…onto master

Recorded from a clean clone of this branch with the pinned engine, after #1472 and #1489 merged and moved the lines the line-keyed file tracks.
lusoris added a commit that referenced this pull request Sep 19, 2026
…to master

Recorded from a clean clone of this branch with the pinned engine, after #1472 and #1489 merged and moved the lines the line-keyed file tracks. 1,501 to 1,500.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

type:perf Performance improvement

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant