Skip to content

fix(ciede): form ciede2000()'s two products in float as upstream does and mirror them in the CUDA, SYCL and HIP twins (ADR-1476) - #1892

Merged
lusoris merged 2 commits into
masterfrom
fix/ciede-upstream-expression
Oct 2, 2026
Merged

lusoris merged 2 commits into
masterfrom
fix/ciede-upstream-expression

Conversation

@lusoris

@lusoris lusoris commented Oct 2, 2026

Copy link
Copy Markdown
Contributor

Summary

ciede2000() forms two products in float again, as Netflix's source does. A CodeQL sweep (PR #552) had widened their first operand to double, which changed the value. The CUDA, SYCL and HIP twins form the same float products. Decision: ADR-1476.

Opens and closes T-CIEDE-PRODUCTS-NOT-UPSTREAM-2026-10-02 (finding U4 of the upstream parity audit of 2026-10-02).

Land after #1886: under clang with link-time optimisation on an AVX-512 host, test_ciede_device_math needs that fix (see "The hosted Ubuntu clang failure" below).

What changes

Netflix libvmaf/src/feature/ciede.c at 9e48141b, lines 224-225 and 235-236:

2.0 * sqrt(c_prime_1 * c_prime_2) * sin(delta_h_prime / 2.0)
sqrt(pow(lightness, 2) + pow(chroma, 2) + pow(hue, 2) + r_sub_t * chroma * hue)

Both products have float operands, so C rounds them to float before the double expression widens them. The fork had (double)c_prime_1 * c_prime_2 and (double)r_sub_t * chroma * hue.

File Change
core/src/feature/ciede.c The two casts are removed. The squares stay products (ADR-1467).
core/src/feature/cuda/integer_ciede/ciede_device.h chroma_product and rotation are float products.
core/src/feature/ciede_ff_math.h (SYCL and HIP) from_float(c_prime_1 * c_prime_2) and rotation * chroma * hue in place of two_prod() forms. No constant or table changes, so gen_sycl_ff_math.py --check is unchanged.
core/src/feature/metal/integer_ciede.metal Not edited: it is float throughout and already forms both products in float. Not run: no device.

Upstream comparison

Audit harness (C API, %.17g, 31 fixtures from 8x8 to 3840x2160 at 8 to 16 bits, 327 frames with a ciede2000 on both sides) against Netflix master 9e48141b built with GCC 16.2.1 and glibc 2.44. Scalar (cpumask 63) and default dispatch give the same figures.

Tree Identical frames Largest difference outside 4:2:2 and odd sizes
master 39929960f 7 of 327 1.33e-9
this branch 153 of 327 2.16e-11
this branch with powf(degrees, 2) put back (experiment, not shipped) 272 of 327 0

AVX2 only (279 frames; upstream has no 3840x2160 run at that mask): 7 before, 153 after.

The 174 frames that still differ, each with its recorded cause:

Frames Largest difference Cause Record
119 2.16e-11 degrees * degrees where upstream calls powf(degrees, 2) ADR-1467
48 0.153 4:2:2 input: the fork reads chroma with the right subsampling flags PR #1050, T-BUGHUNT-FEATURE-CPU-2026-06-27
7 0.198 19x19, 17x17, 12x9: chroma planes rounded up ADR-1398

The brief expected 272 of 327 with the last two rows as the only rest. That is the third line of the first table: the audit ran on b34732ae1, before ADR-1467 (#1862) landed. With ADR-1467 on master the count is 153, and the 119 extra frames are exactly the ones that become identical when the call is put back.

The 119-frame class is a property of glibc 2.44's powf. In the dev container (glibc 2.43) a GCC 15.2 build and a clang 22.1.8 build of Netflix master both return this branch's values on all 96 frames of the Netflix 576x324 pair and Big Buck Bunny at 1920x1080; on the host 49 of those 96 differ.

How far scores move

ciede2000 of a CPU build moves on 319 of 327 measured frames: by at most 1.3e-9 on frames of 160x90 and larger, by up to 1.0e-8 on frames of 24x24 and smaller. No file under testdata/ stores ciede.

Compilers

Upstream's expression does not depend on the compiler. Built in the dev container and run on the Netflix 576x324 pair (48 frames, --precision max): x86-64 GCC 15.2, x86-64 clang 22.1.8, aarch64 GCC 15.2 and aarch64 clang 22.1.8 (both under qemu-aarch64) return the same 48 values. test_ciede, test_ciede_upstream_products and test_ciede_device_math pass in all four builds; test_ciede_neon passes in both aarch64 builds and test_ciede_simd_parity in both x86-64 builds.

The hosted Ubuntu clang failure of test_ciede_device_math

The hosted line is 96x64 8-bit fmt 1: cpu=35.418753523280415 replay=45 delta=9.581e+00. Reproduced with the hosted compiler (clang 22.1.8, -O3 -flto) in the dev container on master and on this branch.

Neither ciede nor the test is compiler-dependent. vmaf_init_cpu() runs one 512-bit instruction as inline assembly on AVX-512 hosts and declared only zmm0 clobbered; clang drops that clobber in a function not compiled for AVX-512. The link-time-optimised build inlined it into the test between the product and the sum of 45 - 20 * log10(x), and the value in xmm0 came back as zero. The job passes on runners without AVX-512, which is why it looked intermittent.

That is fixed in #1886 (T-CPU-AVX512-WARMUP-CLOBBER-2026-10-02), which also updates item 4 of T-CI-MASTER-FIRST-FULL-RUN-2026-10-02. Results for this branch under clang 22.1.8 -O3 -flto on an AVX-512 host:

Tree test_ciede_device_math
this branch alone fails: cpu=35.418753526663913 replay=45
this branch plus #1886 4 of 4 cases pass
this branch, clang 22.1.8 without LTO 4 of 4 cases pass

Golden gate

  • make test-netflix-golden (x86-64): 271 passed, 12 skipped.
  • make test-netflix-golden-arm64 (aarch64 GCC cross build under qemu-aarch64): 271 passed, 12 skipped.

No assertion is edited.

SIMD

The SIMD paths of ciede only convert samples to float. Scalar, AVX2-only and default dispatch return the same 327 values on this branch. test_ciede_simd_parity (x86-64) and test_ciede_neon (aarch64, qemu) pass.

Twins

Not exact twins: LIBM_TWINS["ciede"] = 1e-9. Against the GCC CPU extractor at --precision max on 153 frames (the Netflix 576x324 pair at 8 bits, at 10 bits and as 10-bit 4:2:2, both 1920x1080 checkerboard pairs, 48 frames of Big Buck Bunny at 3840x2160):

Twin Device Before: identical, largest difference After
ciede_cuda RTX 4090 109 of 153, 8.4e-13 109 of 153, 2.1e-12
ciede_sycl Arc A380 (xe) 107 of 153, 7.3e-13 107 of 153, 2.1e-12
ciede_hip gfx1036 109 of 153, 7.3e-13 108 of 153, 2.1e-12

After the change every differing frame is a 3840x2160 one. The parity gate cell (libm:ADR-1426, tolerance 1e-9, Netflix pair) reports a largest difference of 0 on all three devices.

Device tests, each under its device lock: test_cuda_ciede_parity, test_cuda_ciede_parity_large; test_sycl_ciede_math, test_sycl_ciede_parity, test_sycl_ciede_parity_large, test_sycl_kernel_scratch; test_hip_ciede_math, test_hip_ciede_parity, test_hip_ciede_parity_large, test_hip_first_frame_clear_ciede_hip. All pass. VMAF_SYCL_AOT_JOBS=8 meson test --suite sycl-aot: pass (every SYCL translation unit for the 19 default targets).

Tests

  • New test_ciede_upstream_products: replays ciede_delta_e() of the CUDA header with the header's own helpers, once with float products and once with widened ones, over 20 000 colour pairs. The header must return the float form on every pair, and the pairs must tell the two forms apart (193 do). Against the header as it was on master the test fails on those 193 pairs.
  • test_sycl_ciede_exact_contract.py, test_cuda_ciede_exact_contract.py: upstream's statements are pinned in ciede.c (comment cites the upstream file and lines) and their mirrors in the two headers, with the widened forms as planted regressions.
  • test_ciede_device_math is unchanged: it replays the CUDA header against the CPU extractor bit for bit. With the two together, a cast put back in ciede.c alone fails test_ciede_device_math, and a cast in both files fails the new test, whatever the math library. (A test that includes ciede.c itself is not allowed in core/test: check-no-non-header-includes.)

Lint

  • clang-tidy lanes in the dev container (scripts/dev/tidy-lane.sh all, clang-tidy 22.1.8): cpu, cuda, hip, sycl and arm64 each exit 0, the baseline matches (70, 620, 504, 172 and 115 warnings). No baseline changes. ciede_device.h keeps its 12 baselined findings in the cuda lane and ciede_ff_math.h its one in the hip lane: the kernel lint debt belongs to the standards batches, and a header's count only moves with a full rewrite of the lane.
  • scripts/dev/preflight.sh --stage msvcism: pass.
  • The two lines in ciede.c carry a cited NOLINT for performance-type-promotion-in-math-fn (the file's convention for upstream's promotions) and a codeql[...] comment: CodeQL will report cpp/integer-multiplication-cast-to-long on them again, and the alert is to be dismissed as upstream arithmetic.

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. Not run as one target: clang-format is clean on the touched files and the clang-tidy lanes were measured in the dev container (see Lint).
  • Unit tests pass: python3 scripts/ci/run_meson_test.py -- -C build.
  • If I touched any SIMD/GPU code path, I ran /cross-backend-diff and the worst ULP is ≤ 2. Measured per backend above (largest difference 2.1e-12, bound 1e-9).
  • If I touched a feature extractor with SIMD/GPU twins, I either updated every twin or listed the gap under "Known follow-ups" below.
  • 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 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.

Bug-status hygiene (ADR-0165)

  • docs/state.md updated in this PR with a row in the appropriate section (Open / Recently closed / Confirmed not-affected / Deferred), OR no state delta: REASON.

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.

Cross-backend numerical results

ciede  cpu-vs-cuda 2.1e-12 (109/153 identical)  cpu-vs-sycl 2.1e-12 (107/153)  cpu-vs-hip 2.1e-12 (108/153)  bound 1e-9

Deep-dive deliverables (ADR-0108)

  • Research digest — no digest needed: the measurements are in ADR-1476 and in the docs/state.md row.
  • Decision matrix — docs/adr/1476-ciede-upstream-expression.md, ## Alternatives considered.
  • AGENTS.md invariant note — core/src/AGENTS.d/ciede-squares-are-products.md, core/src/feature/cuda/AGENTS.d/ciede.md, core/src/feature/sycl/AGENTS.d/ciede.md, and the ciede entry of docs/development/rebase-sensitive-invariants.md.
  • Reproducer / smoke-test command — pasted below under "Reproducer".
  • CHANGELOG fragment — changelog.d/changed/ciede-upstream-products.md.
  • Rebase note — entry added to docs/rebase-notes.md.

Reproducer

python3 scripts/ci/run_meson_test.py -- -C build test_ciede_upstream_products test_ciede_device_math \
    test_ciede test_sycl_ciede_exact_contract test_cuda_ciede_exact_contract
# a twin on its device (replace cuda by sycl or hip):
python3 scripts/dev/speed_gpu_parity.py --backend cuda --feature ciede --no-timing \
    --max-abs-diff 1e-9 --vmaf "$PWD/build-cuda/tools/vmaf"

Known follow-ups

no ffmpeg-patches update needed: no public surface changes.

  • Metal ciede twin: not run (no device); it was not edited.
  • The 119 frames of ADR-1467 stay off a GCC 16 / glibc 2.44 build of upstream by at most 2.2e-11. That is ADR-1467's recorded deviation, not part of this PR.

Breaking changes / migration

None. ciede2000 values at --precision max change in their last digits (at most 1.3e-9 on frames of 160x90 and larger).

@github-actions github-actions Bot added the type:bug Something isn't working label Oct 2, 2026
…each with its size and the upstream change that ends it (ADR-1479 to ADR-1486) (#1884)

* docs(adr): record eight deliberate deviations from Netflix's source, each with its size and the upstream change that ends it (ADR-1479 to ADR-1486)

The reference for code inherited from Netflix/vmaf is Netflix's source;
a difference needs an ADR. The upstream parity audit of 2026-10-02 found
deliberate differences that had none of their own, or whose ADR
(ADR-1033) names neither upstream's behaviour nor the size:

- ADR-1479 ciede on 4:2:2: chroma flags (fork PR #1050); 0.153 on 48 of
  48 frames; Netflix/vmaf#1611.
- ADR-1480 speed_temporal buffers at speed_prescale above 1 (#1643);
  up to 195, upstream segfaults on two fixtures; Netflix/vmaf#1627.
- ADR-1481 a failing extractor fails the run (#871); status only, 78
  probe runs where upstream is silent and 88 where it crashes.
- ADR-1482 integer adm on frames of 17 to 32 pixels (#1473, #1507);
  scale 3 up to 0.23; Netflix/vmaf#1599, #1600.
- ADR-1483 odd-sized chroma planes round up (4f08d32); psnr_cb / cr
  up to 0.684 / 0.826 dB, ciede 0.198.
- ADR-1484 float_ms_ssim magnitude before pow() (#641, ADR-1033 item 2);
  NaN upstream on the 10 px checkerboard; Netflix/vmaf#1665.
- ADR-1485 apsnr of a plane without error (#641, item 1); 114 against
  60 dB; Netflix/vmaf#1666.
- ADR-1486 float_motion scale-1 stride (#641, item 9); up to 25.1;
  Netflix/vmaf#1667.

Each ADR gives upstream's file and line at Netflix 9e48141b, the fork's
lines, the reason found in the fork's pull request, commit or code, and
the measured size from the audit. Documentation only.

* fix(test): give test_cuda_motion_tiny_frames 120s timeout

On an unloaded RTX 4090 test_cuda_motion_tiny_frames runs 106 cases across
9 geometries and 3 bit depths in ~24s. Under parallel host load during
merge-train validation the default 30s Meson test timeout was exceeded at
30.04s (SIGTERM). Set explicit timeout to 120s matching sibling CUDA parity
suites.

* docs: regenerate the indexes and the citation map after rebasing
… and mirror them in the CUDA, SYCL and HIP twins (ADR-1476) (#1892)

* fix(ciede): form ciede2000()'s two products in float as upstream does and mirror them in the CUDA, SYCL and HIP twins (ADR-1476)

Netflix's ciede2000() writes sqrt(c_prime_1 * c_prime_2) and
+ r_sub_t * chroma * hue with float operands, so both products are
rounded to float before the double expression widens them
(libvmaf/src/feature/ciede.c:224-225, :235-236 at 9e48141b). PR #552, a
CodeQL sweep, cast the first operand of each to double, which keeps the
products exact and changes the score. The upstream parity audit of
2026-10-02 found it (U4); the golden gate does not see it.

ciede.c carries upstream's expressions again; the squares stay products
(ADR-1467). cuda/integer_ciede/ciede_device.h and ciede_ff_math.h (SYCL
and HIP) form the same float products in place of the exact ones.

Against Netflix master (C API, %.17g, 327 frames from 8x8 to 3840x2160,
GCC 16.2.1, glibc 2.44), scalar and default dispatch: 153 frames
identical, 7 before. The rest: 119 frames up to 2.2e-11 are ADR-1467's
product where upstream calls powf(x, 2) (272 of 327 with that call put
back), 48 are 4:2:2 input (chroma flag fix, PR #1050), 7 are odd sizes
(chroma planes rounded up). The CPU score moves by up to 1.3e-9 on
frames of 160x90 and larger.

Twins, 153 frames at --precision max, largest difference from the CPU
before and after: CUDA (RTX 4090) 8.4e-13, 2.1e-12; SYCL (Arc A380)
7.3e-13, 2.1e-12; HIP (gfx1036) 7.3e-13, 2.1e-12; bound 1e-9.

test_ciede_upstream_products replays the CUDA header's ciede_delta_e()
with float and with widened products over 20 000 colour pairs (193 tell
them apart); test_ciede_device_math holds the header to the CPU
extractor bit for bit. The SYCL and CUDA source contracts pin upstream's
statements.
Netflix golden gate: 271 passed, 12 skipped on x86-64 and aarch64.

* docs: regenerate the indexes and the citation map after rebasing
@lusoris
lusoris force-pushed the fix/ciede-upstream-expression branch from b2f8063 to cfc1cd2 Compare October 2, 2026 22:29
@lusoris
lusoris merged commit cfc1cd2 into master Oct 2, 2026
6 of 79 checks passed
@lusoris
lusoris deleted the fix/ciede-upstream-expression branch October 2, 2026 22:30
lusoris added a commit that referenced this pull request Oct 2, 2026
…age and fail on a difference no ADR covers (ADR-1487, ADR-1494)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* docs: regenerate the indexes and the citation map after rebasing

This branch was successfully deployed

1 active deployment
github-pages — cfc1cd20 Deployed Oct 2, 2026 by lusoris via deploy #4102
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