Skip to content

fix(ciede): write the squares of ciede.c as products so every compiler computes the same ciede2000 (ADR-1467) - #1862

Merged
lusoris merged 2 commits into
masterfrom
fix/ciede-powf-explicit
Oct 2, 2026
Merged

lusoris merged 2 commits into
masterfrom
fix/ciede-powf-explicit

Conversation

@lusoris

@lusoris lusoris commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor

Summary

A clang build and a GCC build of the CPU ciede extractor returned different ciede2000 scores on the same machine, on x86-64 and on aarch64. This PR writes the squares of ciede.c as products, so every compiler and every C library computes the same value. Closes T-CIEDE-CLANG-POWF-BUILTIN-2026-10-02 (ADR-1467, docs/adr/1467-ciede-squares-as-products.md).

  • Cause: one call. get_r_sub_t() wrote powf(degrees, 2). GCC calls the C library; clang replaces the call by degrees * degrees. glibc's powf is not correctly rounded, so the two differ on 0.12 % of the arguments.
  • Fix: degrees * degrees in the source, and square(x) for the 13 pow(x, 2) of the formula. No compiler flag. Decided by the maintainer: "Write the product, if golden holds".
  • Result: x86-64 GCC, x86-64 clang, aarch64 GCC and aarch64 clang agree on 180 of 180 measured frames (115 before).
  • The GCC build moves: 65 of 180 frames, by at most 1.96e-11. clang and icx builds do not move.
  • Golden gate: 271 passed, 12 skipped on all four builds. No assertion moves.
  • Twins: all three within 5.2e-12 of the GCC CPU (2.0e-11 before). LIBM_TWINS stays 1e-9.

The first version of this PR took the other option (-fno-builtin-powf for ciede.c under clang, GCC untouched). It is removed: the policy block, the separate library and its two tests are gone. Both options and their measurements are in the ADR.

Which call, measured

Each power form of ciede.c compiled alone with the build's flags (-O3 -std=c23 -ffp-contract=off; icx with -fp-model=precise), then the object inspected:

Form in upstream ciede.c GCC 16 (x86-64, aarch64) clang 22 (x86-64, aarch64) icx 2026.0
powf(x, 2) calls powf multiplies multiplies
powf(x, 7) calls powf calls powf calls powf
powf(25., 7) constant constant constant
pow(x, 2) (13 uses) calls pow multiplies multiplies
pow(x, 7), pow(x, 2.4), pow(x, 1.0 / 3.0) calls pow calls pow calls pow
pow(25, 7) constant constant constant

No exponent 0.5 occurs in the file. clang leaves pow(x, 0.5), powf(x, 0.5f), pow(x, 3) and pow(10.0, x) as calls and turns pow(2, n) into ldexp, which is exact either way.

Replacement glibc 2.44 call against the product Effect
powf(x, 2) → x * x differ on 4966 of 4 000 000 sampled arguments of get_r_sub_t() (0.12 %), the ties this is the difference between the builds
pow(x, 2) → (double)x * x, x a float equal on 100 000 000 sampled arguments (the square of a float is exact in double) none

Scores on the final head

ciede2000 at --precision max on ten fixtures: the Netflix 576x324 pair at 8, 10, 12 and 16 bits and as 10-bit 4:2:2, Sparks 480x270, both 1920x1080 checkerboard pairs, BBB 1920x1080 (48 frames) and 3840x2160 (16 frames). 180 frames. Before = master 5d67b4939, after = 513d2a6fc plus this change (no CPU source differs between the two); the x86-64 builds were measured again on the final head, 38e8ec0b0 plus this change, with the same 180 values. aarch64 through qemu-aarch64 11.1.1.

Pair of builds Identical before Identical after
x86-64 GCC vs x86-64 clang 115 180
aarch64 GCC vs aarch64 clang 115 180
x86-64 GCC vs aarch64 clang 115 180
x86-64 clang vs aarch64 GCC 115 180
x86-64 GCC vs aarch64 GCC 180 180
x86-64 clang vs aarch64 clang 180 180
Build Frames that move against master Largest move
x86-64 GCC 65 of 180 1.96e-11
aarch64 GCC 65 of 180 1.96e-11
x86-64 clang 0 of 180
aarch64 clang 0 of 180

The GCC build against master, exactly (the same frames and values on x86-64 and aarch64):

Fixture Frames that move Largest move
Netflix 576x324, 8 bits 1 of 48 (frame 35) 6.9e-13
Netflix 576x324, 10-bit 4:2:2 2 of 48 2.9e-12
BBB 1920x1080 46 of 48 1.96e-11
BBB 3840x2160 16 of 16 9.4e-12
Netflix 10, 12, 16 bits; Sparks; both checkerboards 0 of 20
Total 65 of 180 1.96e-11

The clang build against master: 0 of 180 move. icx: 0 of 180 move; it differs from GCC on 50 frames by at most 9.7e-12 afterwards (68 and 1.4e-11 before), through Intel's math library.

The double squares change nothing: a GCC build with only the float product returns the same 180 values as this PR.

Dispatch: host, AVX2-only and scalar agree on 180 of 180 for GCC and for clang.

Golden gate

The pytest command of make test-netflix-golden, 283 tests collected, on the final head (38e8ec0b0 plus this change), and before the rebase on 513d2a6fc plus this change with the same counts:

Build Result
x86-64 GCC 16.2.1 271 passed, 12 skipped
x86-64 clang 22.1.8 271 passed, 12 skipped
aarch64 GCC 16.1 (qemu, four shards) 271 passed, 12 skipped
aarch64 clang 22.1.8 (qemu, four shards) 271 passed, 12 skipped

No assertion is edited and none moves.

test_ciede_device_math

The host replay of the CUDA kernel's arithmetic against the CPU extractor, bit for bit. It passes under GCC and under clang, on x86-64 and on aarch64 (qemu), and in the CUDA build. Both sides now write the squares as products, so the result no longer depends on which side a compiler folds. I could not reproduce the hosted Ubuntu clang failure with clang 22.1.8 on master here; whatever folds differently there, this PR removes the call it could fold. The test is now registered on every architecture (it was x86-64 only while ciede.c was contraction-free only there; ADR-1461 changed that).

The three GPU twins

ciede_sycl and ciede_hip already wrote degrees * degrees (feature/ciede_ff_math.h). ciede_cuda rounded the fp64 pow() to float (CIEDE_POWF), which is not the product on every tie; ciede_device.h multiplies now. SYCL and HIP sources change in comments only: ciede_ff_math.h and ciede_score.hip have the same comment-stripped sha256 before and after (2b153850bf86ac58, 8cdb58288dd219b7).

Against the GCC build's --backend cpu, same 180 frames, each twin on its device under its lock:

Twin Device Identical before Max before Identical after Max after
ciede_cuda RTX 4090 113 2.0e-11 127 5.2e-12
ciede_sycl Arc A380 111 2.0e-11 124 5.2e-12
ciede_hip gfx1036 113 2.0e-11 127 5.2e-12
  • The Netflix 8-bit pair is identical on 48 of 48 frames for all three (47 before).
  • ciede_cuda itself moves on 3 of 180 frames (BBB), by at most 1.1e-13.
  • What remains is glibc's powf(x, 7). Against a GCC build with that one call correctly rounded (an experiment, not in this PR): CUDA identical on 180 of 180, SYCL on 173, HIP on 176; the rest is the precision of an fp32 pair, at most 8.4e-13.
  • LIBM_TWINS["ciede"] stays 1e-9: the bound is the size of one straddling pixel on the smallest gated frame (ADR-1426), and this change removes pixels, not their size.
  • Parity gate cell (cross_backend_parity_gate.py, CPU of the same build): 0 on the Netflix pair and both checkerboards for all three; BBB 1920x1080 5.2e-12 (CUDA, HIP) and 7.2e-12 (SYCL, against its icx CPU).
  • Device tests: test_cuda_ciede_parity 9 of 9, test_hip_ciede_parity 11 of 11, test_sycl_ciede_parity 11 of 11, test_sycl_ciede_math 4 of 4.

Fork snapshots

No file under testdata/ stores ciede (the score snapshots hold the features of vmaf_v0.6.1). Nothing to regenerate.

Other integer powers in core/src

Call site clang GCC GCC vs clang on master
powf(vif_sigma_nsq, 2.0f): vif_tools.c:309, and its copies in x86/vif_statistic_avx2.c, cuda/float_vif_cuda.c, hip/float_vif_hip.c, sycl/sycl_float_vif_math.h multiplies calls powf identical: default 2.0 is exact; with vif_sigma_nsq=1.000244140625 (a tie, where glibc and the product differ) 384 of 384 values
pow(spatial_frequency / 7, 2): barten_csf_tools.h:147 multiplies calls pow identical with adm_csf_mode=1: 1344 of 1344 values (adm, float_adm)
pow(scores[i] - mean, 2): predict.c:561 (bootstrap standard deviation) multiplies calls pow identical with vmaf_b_v0.6.3: 3744 of 3744 values
pow(x, 3) (integer ADM), pow(2, n), pow(10.0, x) call, ldexp, call call not compiler-dependent in value

Measured on the Netflix 8-bit pair and BBB 1920x1080, 5472 values, none differs. glibc's pow(x, 2) differs from the product on 41 676 of 50 million general double arguments, so the first three are compiler-dependent in principle; no score difference was found, so no row is opened and nothing is changed for them here.

Also in this PR: the icx model-score move of ADR-1461 is written down

Requested by the coordinator after the SYCL lane measured it. It is a record, not a code change.

  • With vmaf_strict_fp_args project-wide (fix(build): build every C and C++ file without FP contraction so aarch64 clang and GCC builds agree (ADR-1461) #1829), an icx build compiles svm.cpp, predict.c, model.c and libvmaf.c with -fp-model=precise -ffp-contract=off; before, with -O3 alone (icx's fast model).
  • No extractor value moved. The predicted vmaf of an icx build moved on every frame with a non-zero score (160 of 163), by at most 7.05e-12, towards GCC: frames with identical features but a different vmaf went from 153 of 163 to 15 of 163. Netflix 8-bit frame 42: 83.13509129537665 before, 83.1350912953696 after (the GCC value). GCC did not move.
  • What remains between icx and GCC is Intel's libimf (an icx-built library with glibc's libm preloaded returns the GCC values on 107 of 107 frames).
  • Written to: docs/state.md (closed row T-ICX-PREDICT-FP-MODEL-SCORE-MOVE-2026-10-02), docs/development/build-flags.md ("Floating-point contraction is off everywhere"), changelog.d/changed/icx-predicted-vmaf-moves-towards-gcc.md.
  • Snapshots under testdata/: none would show it. The sixteen scores_cpu_* / scores_sycl_* reports that hold a vmaf and the pooled scores of the three benchmark files are stored at six decimals. The only values stored at more are timings and the BRISQUE and NIQE extractor scores, which are not model scores and are compared at places=4.
  • Evidence, cited and not re-measured: the SYCL lane's sweep/cmp6.txt, cmp6_200.txt, model6.txt and the stored reports under sweep/model/ and sweep/preload/ (masters dedae7035 and 8cf182a85).

What changed

  • core/src/feature/ciede.c: degrees * degrees; square() for the 13 pow(x, 2).
  • core/src/feature/cuda/integer_ciede/ciede_device.h: ciede_r_sub_t() multiplies; comments.
  • core/src/feature/ciede_ff_math.h, core/src/feature/hip/integer_ciede/ciede_score.hip: comments only (same comment-stripped hash before and after).
  • core/test/meson.build: test_ciede_device_math on every architecture.
  • core/test/test_ciede_device_math.c: the pow(x, 2) library fact is no longer needed and is dropped; one message reworded.
  • Seven comments in ciede_ff_math.h and that message say "pi" where they said M_PI: the MSVC preflight stage (scripts/dev/preflight.sh --stage msvcism) reads comments and strings as uses of the macro.
  • core/test/test_sycl_ciede_exact_contract.py, core/test/test_cuda_ciede_exact_contract.py: the pinned statements.
  • scripts/ci/cross_backend_calibration.py: comment with the new measurements; LIBM_TWINS unchanged.
  • Docs: ADR-1467, docs/metrics/features.md (CIEDE2000), docs/development/build-flags.md, docs/development/cross-backend-gate.md, core/src/AGENTS.d/ciede-squares-are-products.md, two lines each in core/src/feature/cuda/AGENTS.md and core/src/feature/sycl/AGENTS.md, docs/rebase-notes.md, docs/state.md, two changelog fragments.

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: the commit hooks (clang-format, black, ruff, markdownlint, the HISS audit with its touched-file rule, generated-docs and ADR-citation checks) are green.
  • Unit tests pass: python3 scripts/ci/run_meson_test.py -- -C build --suite=fast (244 of 244 with GCC on x86-64, final head).
  • If I touched any SIMD/GPU code path, I ran /cross-backend-diff and the worst ULP is ≤ 2. Run as the three device comparisons above (CUDA, SYCL, HIP against the CPU): at most 5.2e-12.
  • 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.

T-CIEDE-CLANG-POWF-BUILTIN-2026-10-02 moves from Open to Recently closed. T-CUDA-CIEDE-LIBM-RESIDUAL-2026-10-01 stays open with the new measurements (it names the SYCL twin too; there is no separate SYCL or HIP residual row, so all three twins' figures are in that row). T-ICX-PREDICT-FP-MODEL-SCORE-MOVE-2026-10-02 is new, under Recently closed.

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. No golden value changes.

Cross-backend numerical results

ciede  cpu(GCC)-vs-cuda 5.2e-12   cpu(GCC)-vs-sycl 5.2e-12   cpu(GCC)-vs-hip 5.2e-12   (max abs diff, 180 frames; 2.0e-11 before)

Deep-dive deliverables (ADR-0108)

  • Research digest — no digest needed: the measurements are in ADR-1467 and in the docs/state.md rows.
  • Decision matrix — docs/adr/1467-ciede-squares-as-products.md, the two-option table in "Context" and "Alternatives considered".
  • AGENTS.md invariant note — core/src/AGENTS.d/ciede-squares-are-products.md (indexed in core/src/AGENTS.md); core/src/feature/cuda/AGENTS.md, core/src/feature/sycl/AGENTS.md.
  • Reproducer / smoke-test command — pasted below under "Reproducer".
  • CHANGELOG fragment — changelog.d/changed/ciede-squares-as-products.md and changelog.d/changed/icx-predicted-vmaf-moves-towards-gcc.md.
  • Rebase note — docs/rebase-notes.md, "ADR-1467 — ciede.c writes its squares as products (2026-10-02)".

Reproducer

# the host replay of the CUDA arithmetic against the CPU extractor
python3 scripts/ci/run_meson_test.py -- -C build test_ciede_device_math test_ciede

# two builds side by side
CC=gcc CXX=g++ meson setup build-gcc core -Denable_cuda=false -Denable_sycl=false
CC=clang CXX=clang++ meson setup build-clang core -Denable_cuda=false -Denable_sycl=false
for cc in gcc clang; do
  ninja -C build-$cc tools/vmaf
  build-$cc/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 \
      --no_prediction --feature ciede --precision max --json -o ciede-$cc.json
done
diff <(grep -v '"fps"' ciede-gcc.json) <(grep -v '"fps"' ciede-clang.json)   # empty with this PR; frame 35 before

# a twin against the CPU
python3 scripts/ci/cross_backend_parity_gate.py --vmaf-binary build-cuda/tools/vmaf \
    --reference ref.yuv --distorted dis.yuv --width 1920 --height 1080 \
    --features ciede --backends cpu cuda

Known follow-ups

  • powf(c_bar_prime, 7) is the last float power of the formula and the whole remaining CPU-versus-twin difference. No compiler folds it, so builds agree on it; making it correctly rounded would move every build again. Recorded in T-CUDA-CIEDE-LIBM-RESIDUAL-2026-10-01.
  • Apple's clang and MSVC are not measured: they compute the same expression now, whatever their C library's powf does with a tie.
  • Cost: none. A GCC build makes one powf and 13 pow calls fewer per pixel and takes about a third less time in ciede: 563 against 837 ms per 1920x1080 frame (user CPU time, minimum of five interleaved runs on one core of a busy host); clang 517 against 509, unchanged.

Breaking changes / migration

None. No public API, CLI or FFmpeg patch surface changes. ciede2000 from an existing GCC build differs from a new one by up to 2.0e-11 on some frames; ciede_cuda outputs by up to 1.1e-13.

@github-actions github-actions Bot added the type:bug Something isn't working label Oct 2, 2026
@lusoris
lusoris force-pushed the fix/ciede-powf-explicit branch from 228e28e to 00028f4 Compare October 2, 2026 16:35
@lusoris lusoris changed the title fix(build): keep ciede.c's powf() calls under clang so a clang build returns the GCC build's ciede2000 (ADR-1467) fix(ciede): write the squares of ciede.c as products so every compiler computes the same ciede2000 (ADR-1467) Oct 2, 2026
@lusoris
lusoris force-pushed the fix/ciede-powf-explicit branch from 00028f4 to 6510d61 Compare October 2, 2026 16:48
… HISS standard (ADR-1142) (#1868)

* refactor(simd): bring the integer motion SIMD kernels to the lint and HISS standard (ADR-1142)

core/src/feature/x86/motion_avx2.c, motion_avx512.c and
core/src/feature/arm64/motion_neon.c had 7, 18 and 2 clang-tidy findings
and ten functions over 60 lines, which is why the SPDX backfill (#1739)
could not touch them.

Each motion_score_pipeline_{8,16}_* keeps its name and signature and is a
row loop over inlined stages: the vertical pass of one row
(y_conv_row_{8,16}_*), which reports whether the row is non-zero, and the
horizontal pass (x_conv_row_sad_*), each with one vector block helper and
one scalar helper for the columns the vector loop cannot reach. The three
test-only convolution kernels of motion_avx512.c share two filter
helpers; x_convolution_16_neon is three passes over two helpers. Each
file includes its own header. All arithmetic is integer and every
statement moved whole.

Verified: master's and the new objects in one process, 286 952
comparisons on generated input with GCC, clang and icx, 0 mismatches
(NEON: 3 920 under qemu). motion, motion_v2, float_motion and the default
model at --precision max: 105 of 105 cases identical on x86-64 under
scalar, AVX2 and AVX-512 dispatch, 48 of 48 on aarch64 under scalar and
NEON. Netflix golden gate on x86-64 GCC: 271 passed, 12 skipped. Fast
suite: 244 of 244.

clang-tidy 0 in every lane that reads the files (lane totals: cpu 310 to
285, cuda 662 to 637, hip 664 to 639, sycl 766 to 741, arm64 558 to 556).
HISS baseline 242 to 232. The three files carry
SPDX-License-Identifier: BSD-2-Clause-Patent (ADR-1250: upstream paths).
…r computes the same ciede2000 (ADR-1467) (#1862)

* fix(ciede): write the squares of ciede.c as products so every compiler computes the same ciede2000 (ADR-1467)

get_r_sub_t() in ciede.c wrote powf(degrees, 2). GCC emits the call;
clang replaces it by degrees * degrees, the correctly rounded square.
glibc's powf is not correctly rounded: on 0.12 % of the arguments, where
the exact square is a tie, it returns the other neighbouring float. A
clang build and a GCC build of the CPU ciede therefore differed on 65 of
180 measured frames, by up to 2.0e-11, on x86-64 and on aarch64.

Both ways out were built and measured: keeping the call under clang
(-fno-builtin-powf for this file) leaves the GCC build untouched but
keeps the value a property of the C library, cannot cover icx (Intel's
math library) and costs a clang build three powf calls per pixel;
writing the product moves the GCC build and makes the value the same
everywhere. The maintainer chose the product, provided the golden gate
holds.

ciede.c now writes degrees * degrees, and square(x), the double product
of a float, for the 13 uses of pow(x, 2) (exact, so no value changes
through them). ciede_device.h, the CUDA twin's arithmetic, multiplies as
well instead of rounding the fp64 pow() to float.

After: x86-64 and aarch64 builds with GCC and with clang return the same
ciede2000 on 180 of 180 frames. A GCC build moves on 65 of them, by at
most 1.96e-11; clang and icx builds do not move. Netflix golden gate:
271 passed, 12 skipped on x86-64 and aarch64, each with GCC and with
clang. No fork snapshot stores ciede.

The twins are closer to the CPU: against the GCC build ciede_cuda,
ciede_sycl and ciede_hip are within 5.2e-12 (2.0e-11 before), and what
is left is glibc's powf(x, 7). ciede_cuda itself moves on 3 of 180
frames, by at most 1.1e-13. LIBM_TWINS stays 1e-9.

test_ciede_device_math holds under every compiler now and runs on every
architecture.

Also recorded, at the coordinator's request: with ADR-1461 (#1829) an
icx build compiles svm.cpp, predict.c, model.c and libvmaf.c under the
precise floating-point model instead of icx's fast default, and its
predicted vmaf moved by up to 7.05e-12 on every frame with a non-zero
score, towards the GCC build (frames with identical features but another
vmaf: 153 of 163 before, 15 after). No extractor value moved. Row
T-ICX-PREDICT-FP-MODEL-SCORE-MOVE-2026-10-02, a paragraph in
docs/development/build-flags.md and a changelog fragment; measured by the
SYCL lane, cited here.

Closes T-CIEDE-CLANG-POWF-BUILTIN-2026-10-02

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

This branch was successfully deployed

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