Research-1420: Why float_adm_cuda was not the CPU's float_adm — nine causes, their sizes, and a division that belongs to the host processor¶
Question¶
With FMA contraction off (ADR-1403) float_adm_cuda was still up to 1.3e-5 from --backend cpu. Which operations differ, how much does each contribute, can the twin be made identical, and what does that cost?
Sources¶
- CPU:
core/src/feature/float_adm.c(extract()),core/src/feature/adm.c(compute_adm()),core/src/feature/adm_tools.c(rcp_s(),adm_decouple_s(),adm_csf_s(),adm_csf_den_scale_s(),adm_cm_s(),adm_cm_thresh3x3_s(),adm_dwt2_s()),core/src/feature/adm_tools.h(dwt_quant_step()),core/src/feature/adm_options.h. No SIMD path is dispatched for float ADM on x86:--cpumask 0and--cpumask 4294967295give the same scores. - CUDA:
core/src/feature/cuda/float_adm_cuda.candfloat_adm/float_adm_score.cuat master5c8b9e9c7(before) and onfix/cuda-float-adm-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: 113 frames, seven scores each (adm2,adm_scale0..3,aim,adm3), 791 scores; withdebug=true18 outputs each, 2 034.
Findings¶
1. Before¶
| Fixture | Scores identical | Largest difference | With debug=true |
|---|---|---|---|
| Netflix 576x324, 8 bit | 66 / 336 | 2.5e-6 (adm_scale1) | 302 / 864 |
| Checkerboard 1 px | 2 / 21 | 3.5e-7 (adm_scale1) | 9 / 54 |
| Checkerboard 10 px | 4 / 21 | 1.5e-7 (adm_scale0) | 23 / 54 |
| BBB 3840x2160 | 66 / 350 | 1.3e-5 (adm_scale0) | 282 / 900 |
| Netflix 10, 12, 16 bit (each) | 2 / 21 | 1.9e-7 (adm_scale1) | 14 / 54 |
| Total | 144 / 791 | 1.3e-5 | 658 / 2 034 |
The debug sums are fp32 values in the hundreds and thousands; their largest difference was 1.5e-3 (adm_num on BBB).
2. The CPU's arithmetic, from source¶
What a twin has to do to return --backend cpu's bits:
- Input.
picture_copy()with offset -128; a high-bit-depth sample is divided by 4, 16 or 256 first. - DWT. Four taps, vertical pass then horizontal, each tap one fp32 product and one fp32 add into an accumulator that starts at 0, without contraction (the functions carry
optimize("-ffp-contract=off")). Out-of-range indices mirror as-iand2n - i - 1. The old kernels already did this. - Decouple (
adm_decouple_s()). The angle test comparesot_dp * ot_dp >= cos_1deg_sq * o_mag_sq * t_mag_sq: left to right, so(cos^2 * |o|^2) * |t|^2.k = DIVS(t, o + eps), clamped with two ternaries,rst = k * o, and under the angle flagrst = MIN(rst * adm_enhn_gain_limit, t)for a positiverstandMAXfor a negative one.adm_enhn_gain_limitis adouble, so the product and the comparison are fp64 and the result is rounded to fp32 once. DIVS. With__SSE2__andADM_OPT_RECIP_DIVISION,DIVS(n, d) = n * rcp_s(d)andrcp_s(x) = xi + xi * (1.0f - x * xi)withxi = _mm_rcp_ss(x). Without the macro (MSVC does not define__SSE2__; ARM) it isn / d.- CSF (
adm_csf_s()).dst = rfactor * srcin fp32 andflt = FLOAT_ONE_BY_30 * fabsf(dst).FLOAT_ONE_BY_30is0.0333333351, a double literal: the product is fp64 and rounded once. - Weights.
rfactor = 1.0f / dwt_quant_step(...), anddwt_quant_step()inadm_tools.hkeepsr, the logarithm andQindouble. - Masking threshold (
adm_cm_thresh3x3_s()). Per band afloat sum: the three filtered samples of the row above, the left one, thensum += FLOAT_ONE_BY_15 * fabsf(src)(an fp64 addend, the sum rounded once), the right one, the three of the row below. The three band sums are added into a secondfloat. The sample before the first mirrors to index 1, the sample past the last clamps. - Reductions.
adm_csf_den_scale_s()adds|rfactor * ref|^3andadm_cm_s()addsmax(|x| - thr, 0)^3, the cube as(x * x) * xin fp32. Each adds a row intofloat inner[3]and folds it intofloat accum[3]. The result ispowf(accum, 1/p) + powf(area * noise_weight, 1/p)per band, the three added in order. The AIM numerator is the same reduction over the additive signal with the threshold of the restored one and no noise floor. - Frame.
num,den,aim_numandaim_denaredoublesums of the per-scale floats;numanddenare zeroed below1e-10 * (w * h) / (1920 * 1080).
3. The reciprocal estimate of this processor¶
RCPSS is specified by its error (at most 1.5 * 2^-12 relative), and the SDM leaves part of its range to the implementation. Measured on the Ryzen 9 9950X3D with _mm_rcp_ss:
- The estimate of a normal
xdepends on the sign, the exponent and the top 12 mantissa bits only: over all 2^23 mantissas of[1, 2)the result equals the result of the first mantissa of its 4096-entry bucket. Twelve bits of the result's mantissa are used (0x7ff800). - Scaling by the exponent is exact:
rcp(m * 2^e)isrcp(m) * 2^-efor every exponent from 1 to 252 (16.6 million inputs checked, both signs). rcp(+-0)andrcp(denormal)are infinities,rcp(+-inf)zeros, and the two largest exponents (253, 254), whose result would be below the normal range, give a zero.rcp_s(x)is not the fp32 reciprocal1 / xfor 2 562 503 of the 8 388 608 mantissas.
So a 4096-entry table of this processor's estimates for [1, 2) plus integer exponent arithmetic reproduces the instruction on the whole fp32 range. adm_reciprocal_model_probe() builds that table at extractor start and checks the model against the instruction: every mantissa at exponent 127, then every sign and exponent (zeros, denormals, infinities and NaNs included) at 2 048 mantissas each, 9.4 million calls, 9.4 ms. A host on which the check fails (another table width, an emulator) is given the IEEE reciprocal with the same Newton step, which is exact when the host's estimate is the IEEE reciprocal and is logged as not exact otherwise. No second processor was available to measure.
4. The causes, one at a time¶
The exact twin was built first. Each row below puts one old construct back into it and measures the 791 scores against --backend cpu.
| Old construct | Scores that differ | Largest |
|---|---|---|
cos_1deg_sq * (o_mag_sq * t_mag_sq) in the angle test | 19 | 1.3e-5 (BBB adm_scale0) |
| Row reduced in 256 strided partial sums and a warp tree, rows added in fp64 | 533 | 4.4e-7 (1 px checkerboard adm_scale1) |
Host copy of dwt_quant_step() with fp32 r and logarithm | 527 | 2.1e-7 (Netflix adm_scale1) |
t / (o + eps) | 147 | 1.3e-7 (Netflix adm_scale2) |
| Threshold: 24 neighbours, then the three centres, one accumulator | 131 | 9.4e-8 (BBB adm_scale3) |
fp32 FLOAT_ONE_BY_15 | 39 | 7.2e-8 (Netflix adm_scale2) |
fp32 FLOAT_ONE_BY_30 | 2 | 1.5e-10 (Netflix aim) |
| fp32 gain limit | 0 | 0 at the default 100; 1.0e-7 at adm_enhn_gain_limit=1.2 |
fminf / fmaxf instead of the ternaries | 0 | only the sign of a zero in the CSF buffers |
cos^2 as the literal 0.99969541789740297f | 0 | the same fp32 value |
1e-2 floor of the frame sums | 0 on the fixtures | adm2 1 instead of 0 on the isolated-sample case below |
The angle test is the largest by two orders of magnitude and the rarest: the two associations differ in the last bit of the threshold, which matters only for a sample whose reference and distorted vectors are about one degree apart, but there the flag decides between k * o and the gain-limited value.
Four of the eight default weights were off: scale 0 by +1 and -2 units in the last place (h/v and d), scale 1 h/v by +1, scale 2 d by -3. Over five viewing distances and five display heights 135 of 200 weights differ.
All of the above put back together give the old twin's output bit for bit: 1 980 of 1 980 outputs on the Netflix pair at 8, 10 and 16 bits, both checkerboards and 50 BBB frames. No other difference is involved.
The floor: a flat 576x324 16-bit frame whose reference has one sample one level up, scored with adm_noise_weight=0, has adm_den = 2.8e-4. The CPU's floor is 9e-12 there and the old twin's 9e-4, so the twin zeroed the denominator and reported adm2 = 1 where the CPU reports 0.
5. After¶
Every output of every frame equals --backend cpu: 791 of 791 scores and 2 034 of 2 034 outputs with debug=true, also when clang's CUDA driver builds the kernels (-Denable_nvcc=false). The parity gate reports 0 on all 200 BBB frames and on the Netflix pair. Identical as well, each on the Netflix pair, both checkerboards, BBB and the 10-bit Netflix pair (1 819 outputs): adm_enhn_gain_limit 1.2 and 1, adm_bypass_cm=1, adm_noise_weight=0, adm_skip_aim_scale=2, adm_norm_view_dist=1.5 with adm_ref_display_height=2160, adm_csf_scale=2 with adm_csf_diag_scale=0.5, and adm_adm3_apply_hm with adm_dlm_weight=0.3 and adm_min_val=0.2.
Frames from 17x17 up are identical (17x17, 18x34, 33x17, 32x32, 34x34, 63x65, 322x182 checked). Below that the CPU extractor is not a reference: at 16x16 its coarsest bands have one sample and adm_cm_thresh3x3_s() reads index 1, a sample the previous scale left in the buffer; at 8x8 the scale-3 DWT input has one sample and dwt2_src_indices_filt_s() yields index -1 for the fourth tap (read from the index arithmetic; no sanitizer run). The CPU reports adm_scale3 = 1.047 for a random 8x8 pair.
6. What is not exact¶
adm_p_norm other than 3 replaces the cube by powf(x, p), glibc's on the CPU and CUDA's on the device.
adm_p_norm | Scores that differ | Largest |
|---|---|---|
| 1 | 0 | 0 |
| 2 | 72 | 9.9e-8 (BBB adm_scale3) |
| 3 (default) | 0 | 0 |
| 4.5 | 58 | 9.6e-8 (BBB adm_scale2) |
| 20 | 20 | 1.1e-7 (Netflix adm_scale0) |
7. Cost¶
Against master 5c8b9e9c7, host load average 6:
- A run of the twin alone, 3840x2160,
(t(52) - t(2)) / 50, seven alternating pairs: 1.87 ms per frame before, 1.98 ms after; paired difference +0.12 ms, quartiles -0.06 to +0.26, after slower in five of seven. At 576x324: 0.20 and 0.23 ms, paired +0.04 (-0.13 to +0.30). - The kernels of one more instance in a process that already has the frame on the device (nine
float_adm_cudainstances with nineadm_noise_weightvalues against one, 50 BBB frames,(t9 - t1) / (8 * 50), five alternating runs): 0.76 ms per frame before (0.745 to 0.792), 1.11 ms after (1.021 to 1.137). - Memory: nine fp32 terms per sample of the reduced scale-0 region, 9 x 1538 x 866 x 4 bytes = 48 MB at 3840x2160.
- Extractor start: 9.4 ms for the probe.
Where the time goes was not measured. The new stages store and re-read 48 MB of terms at scale 0 and add each row in one thread; the old ones reduced in place.
8. Found on the way¶
- The CPU's float ADM depends on the host processor through
RCPSS(section 3). - The CPU extractor below 17x17 (section 5).
float_admfiles its debug ratio under the keyadmwhatever its options, because itsprovided_featureslistsadm_scale0where the emitted name isadm. Two instances withdebug=trueand different options fail withfeature "adm" cannot be overwritten.float_adm_cudalistsadmand suffixes it.- The device DWT mirrored a one-sample input to index 1 and -1, like the CPU. It now stays inside the buffer.
Reproduce¶
meson setup build-cuda core -Denable_cuda=true -Denable_sycl=false \
--buildtype=release -Db_lto=false && ninja -C build-cuda
# bit identity on the Netflix pair and 50 BBB 4K frames
python3 scripts/dev/speed_gpu_parity.py --backend cuda \
--vmaf $PWD/build-cuda/tools/vmaf --feature float_adm
# the gate cell (tolerance 0)
python3 scripts/ci/cross_backend_parity_gate.py \
--vmaf-binary build-cuda/tools/vmaf \
--reference testdata/bbb/ref_3840x2160_200f.yuv \
--distorted testdata/bbb/dis_3840x2160_200f.yuv \
--width 3840 --height 2160 --features float_adm --backends cpu cuda
# host arithmetic, device cases, design
build-cuda/test/test_float_adm_device_math
build-cuda/test/test_cuda_float_adm_parity
python3 core/test/test_cuda_float_adm_exact_contract.py