Research-1412: Why float_vif_cuda was not the CPU's float_vif — four causes, their sizes, and the cost of removing them¶
Question¶
T-CUDA-FLOAT-VIF-RESIDUAL-2026-10-01: with FMA contraction off (ADR-1403), float_vif_cuda still matched --backend cpu on no frame and was 3.8e-5 away on the Netflix pair. The row suspected the device log2f and the order of the sums. Which operations actually differ, how much does each contribute, can the twin be made identical, and what does that cost?
Sources¶
- CPU:
core/src/feature/float_vif.c(init(),extract()),core/src/feature/vif.c(compute_vif()),core/src/feature/vif_tools.c(vif_get_filter(),vif_filter1d_*_s(),vif_statistic_s(),log2f_approx()),core/src/feature/vif_options.h,core/src/feature/common/convolution_avx.candconvolution_internal.h(the path this host runs). - CUDA:
core/src/feature/cuda/float_vif_cuda.candfloat_vif/float_vif_score.cuat master24ac5bbb3(before; the same kernel at95df9adbe, the base the timings use) and onfix/cuda-float-vif-cpu-arithmetic(after). - Host
zeus: RTX 4090 (sm_89, driver 615.71.09), CUDA 13.4 (nvccV13.4.92), gcc 16.2.1, clang 22.1.8, glibc 2.44, Ryzen 9 9950X3D.meson setup build-cuda core -Denable_cuda=true -Denable_sycl=false --buildtype=release -Db_lto=false. - Fixtures, 4:2:0,
--precision max: the Netflix pairsrc01_hrc00/01_576x324at 8 bits (48 frames) and its 10-, 12- and 16-bit versions (3 frames each), the checkerboard pairscheckerboard_1920_1080_10_3_0_0against_1_0and_10_0(3 frames each), and the first 50 frames of BBB 3840x2160.
Findings¶
1. The CPU's arithmetic, from source¶
A host program that reads the fixture and recomputes the four scales was written until it gave --backend cpu's vif_scale0..3 bit for bit on every frame of every fixture. What it has to do:
- Input.
picture_copy()with offset -128; a high-bit-depth sample is divided by 4, 16 or 256 first. - Taps.
float_vif.c::init()callsvif_get_filter()per scale:exp()in fp64 rounded to fp32, an fp32 running sum, one fp32 division per tap. The widths are 17, 9, 5, 3. - Filters. Vertical pass, then horizontal, each tap one fp32 product and one fp32 add, taps in order, reflect-101 at both edges. The AVX2 path (
convolution_f32_avx_*) and its scalar edge helper give the same value: the build does not contract (-std=c23under gcc; the edge helper names its product). - Decimation. The next scale is this scale's filter over the previous plane, sampled at even positions.
- Statistic.
vif_pixel_statistic_s().vif_sigma_nsqis adoubleparameter, sosv_sq + vif_sigma_nsq, the quotient and1.0f + ...are fp64; the result is rounded to fp32 when it is passed tolog2f. log2f.vif_options.hdefinesVIF_OPT_FAST_LOG2, and under itvif_tools.chas#define log2f log2f_approx: exponent minus 127 plus an fp32 Horner polynomial of nine coefficients inmantissa - 1. libm is not called, so the result does not depend on the host's C library.- Sums.
vif_statistic_s(): one fp32 accumulator per row, left to right, added into a second fp32 accumulator top to bottom.compute_vif()widens the two floats todoubleand the collector divides them.
2. What the old kernel did differently, and how much each is worth¶
The replay was then changed one property at a time to what the kernel did, and once to all four. Largest absolute difference from the CPU over the frames of a fixture (BBB: 8 frames):
| Fixture | Output | Tap table | libm log2f | fp32 vif_sigma_nsq | Block order | All four | Old twin, measured |
|---|---|---|---|---|---|---|---|
| Netflix 576x324 | vif_scale0 | 4.37e-6 | 4.85e-8 | 2.43e-8 | 3.62e-7 | 4.18e-6 | 4.18e-6 |
vif_scale1 | 8.51e-6 | 1.30e-7 | 1.30e-7 | 4.29e-7 | 8.35e-6 | 8.35e-6 | |
vif_scale2 | 1.88e-5 | 7.10e-8 | 7.09e-8 | 5.15e-7 | 1.89e-5 | 1.89e-5 | |
vif_scale3 | 3.83e-5 | 2.15e-7 | 0 | 3.40e-7 | 3.81e-5 | 3.81e-5 | |
| Checkerboard 1 px | vif_scale0 | 9.71e-9 | 0 | 0 | 1.00e-6 | 1.01e-6 | 1.01e-6 |
vif_scale1 | 3.72e-7 | 0 | 0 | 6.38e-7 | 5.98e-7 | 5.98e-7 | |
vif_scale2 | 1.21e-7 | 0 | 0 | 8.01e-7 | 8.26e-7 | 8.26e-7 | |
vif_scale3 | 5.12e-7 | 0 | 0 | 5.69e-7 | 1.04e-6 | 1.04e-6 | |
| Checkerboard 10 px | vif_scale0 | 1.85e-13 | 0 | 0 | 1.25e-12 | 1.14e-12 | 1.14e-12 |
vif_scale1..3 | 0 | 0 | 0 | 0 | 0 | 0 | |
| BBB 3840x2160 | vif_scale0 | 2.54e-6 | 1.79e-9 | 3.19e-8 | 2.24e-6 | 2.25e-6 | 2.25e-6 |
vif_scale1 | 7.01e-7 | 7.14e-8 | 0 | 2.73e-6 | 2.29e-6 | 2.29e-6 | |
vif_scale2 | 3.87e-6 | 1.20e-7 | 0 | 1.19e-6 | 3.79e-6 | 3.79e-6 | |
vif_scale3 | 2.07e-6 | 6.26e-8 | 7.49e-8 | 6.04e-7 | 1.71e-6 | 1.71e-6 |
"All four" replays the old kernel on the host with glibc's log2f; it is within 1.4e-8 of the twin's own output on every row, which is the device log2f against glibc's. So the four causes account for the whole distance.
- Tap table. The kernel carried
FVIF_COEFF_S0..S3, the decimal values of thevif_filter1d_table_sthe CPU used until #758 (ADR-0416) replaced it with the run-timevif_get_filter(). The difference to the computed taps, in units in the last place, tap by tap: scale 00 0 0 0 -2 -1 -1 -2 -14 -2 -1 -1 -2 0 0 0 0, scale 1-1 -1 1 1 -3 1 1 -1 -1, scale 2-1 -1 -1 -1 -1, scale 31 -1 1: 26 of 34 taps. It is the dominant term wherever the picture has texture, and it grows with the scale because the decimated planes are filtered again. - Order. The dominant term at 3840x2160 and on the 1 px checkerboard (long rows of near-equal terms).
log2f, types. Each below 2.2e-7, and each alone still moves the score on the Netflix pair and at 3840x2160.
The row's two suspects were the second and third largest. The first was found only because the replay had to reproduce the CPU before it was allowed to explain the twin.
3. After: identical on every output¶
--backend cpu --feature float_vif against --backend cuda --feature float_vif_cuda, identical outputs / all outputs, largest difference:
| Fixture | Before | After |
|---|---|---|
| Netflix 576x324, 8-bit, 48 frames | 0/192, 3.81e-5 | 192/192, 0 |
| Checkerboard 1 px, 3 frames | 0/12, 1.04e-6 | 12/12, 0 |
| Checkerboard 10 px, 3 frames | 10/12, 1.14e-12 | 12/12, 0 |
| BBB 3840x2160, 50 frames | 0/200, 7.03e-6 | 200/200, 0 |
| Netflix 576x324, 10-bit, 3 frames | 0/12, 1.07e-5 | 12/12, 0 |
| Netflix 576x324, 12-bit, 3 frames | 0/12, 1.07e-5 | 12/12, 0 |
| Netflix 576x324, 16-bit, 3 frames | not measured | 12/12, 0 |
With debug=true (15 outputs: the four ratios, the frame ratio, its numerator and denominator and the eight per-scale sums) on the first five fixtures: 1 605 of 1 605. The parity gate over all 200 BBB frames and over the Netflix pair reports tol=0.0e+00 (exact:ADR-1397) max_abs_diff=0.000e+00. Built with clang's CUDA driver (-Denable_nvcc=false) the kernels give the same 1 605 of 1 605.
Each cause was checked against the shipped header by putting it back alone (float_vif_device.h edited, test_float_vif_device_math rebuilt): an fp32 vif_sigma_nsq, a libm log2f and a pairwise row sum each fail the test. The old twin fails test_cuda_float_vif_parity on its first case (up to 8.8e-6 on the 256x144 fixture).
4. Cost¶
3840x2160, BBB, RTX 4090, shared host. "Before" is master 95df9adbe, the base of this change (it includes the pinned CLI upload of ADR-1406).
- One twin per run,
(t(200) - t(2)) / 198ms per frame, nine alternating pairs, load average 20 to 24: 1.97 before, 1.96 after, paired difference +0.02 (quartiles -0.37 to +0.11, after slower in 5 of 9). At this size the run waits for the frame to be read and uploaded, not for the kernels. On the earlier base24ac5bbb3(pageable upload, load 8 to 9): 2.42 and 2.31, paired difference -0.09.scripts/dev/speed_gpu_parity.pyon that base (median of 3 of(t(22) - t(2)) / 20, load 10) read 2.88 ms for the twin and 35.6 ms for 16 CPU threads. - The kernels alone. One process with nine
float_vif_cudainstances (nine values ofvif_enhn_gain_limit) against one with a single instance, 60 frames:(t9 - t1) / (8 * 60)is what one more instance costs per frame with the frame already on the device. Median of seven alternating runs:
| Base | Load average | Before | After | fp32 statistic (not shipped) |
|---|---|---|---|---|
95df9adbe | 20 to 24 | 0.72 | 1.00 | not measured |
24ac5bbb3 | 12 to 14 | 0.57 | 0.89 | 0.70 |
So the twin's kernels cost about 0.3 ms more per frame. Of that, about 0.19 ms is the fp64 arithmetic (two quotients and three sums per pixel, 11 million pixels over the four scales) and the rest the term store plus the row-sum kernel. - Memory. Two floats per pixel of scale 0: 66 MB at 3840x2160, next to 50 MB of raw planes and scale buffers.
Candidates for the tuning pass (T-CUDA-FLOAT-VIF-EXACT-THROUGHPUT-2026-10-01), none tried: the two fp64 quotients as exact fp32 pairs (also what an fp64-less SYCL twin needs); the row-sum thread loading several terms per step, since its adds are serial but its loads are not; skipping the scale-0 launches under vif_skip_scale0, which the CPU skips and the twin still computes.
5. The other twins¶
float_vif_sycl.cpp, hip/float_vif/float_vif_score.hip and metal/float_vif.metal hold the same tap literals and call sycl::log2, log2f and log2 (read from source). ADR-1403 measured the SYCL twin at the same 3.81e-5 on the Netflix pair as the CUDA twin, which is the tap table. T-GPU-FLOAT-VIF-CPU-ARITHMETIC-2026-10-01.
Reproduce¶
meson setup build-cuda core -Denable_cuda=true -Denable_sycl=false --buildtype=release -Db_lto=false
ninja -C build-cuda
# Netflix 576x324 and BBB 3840x2160, every frame, with timing
python3 scripts/dev/speed_gpu_parity.py --backend cuda --vmaf "$PWD/build-cuda/tools/vmaf" \
--netflix-dir python/test/resource/yuv --bbb-dir testdata/bbb --feature float_vif
# the header against vif_tools.c / vif.c on the host, the twin against the CPU on the device
build-cuda/test/test_float_vif_device_math
build-cuda/test/test_cuda_float_vif_parity
python3 core/test/test_cuda_float_vif_exact_contract.py
# the gate cell
python3 scripts/ci/cross_backend_parity_gate.py --vmaf-binary "$PWD/build-cuda/tools/vmaf" \
--reference python/test/resource/yuv/src01_hrc00_576x324.yuv \
--distorted python/test/resource/yuv/src01_hrc01_576x324.yuv \
--width 576 --height 324 --features float_vif --backends cpu cuda