ADR-1024: R6 per-metric scoring guards — PSNR/ADM correctness fixes¶
- Status: Accepted
- Date: 2026-06-04
- Deciders: Lusoris
- Tags:
correctness,psnr,adm,simd
Context¶
Round-6 static analysis identified several correctness bugs in the CPU metric engine that produce NaN, infinite, or garbage scores under edge-case inputs:
-
APSNR flush —
log10(0)on zero SSE: when all frames in a sequence are identical,apsnr.sse[i]accumulates to zero;log10(0)returns-infand the aggregate score becomes NaN. Additionally, whenenable_chroma=falsethe flush loop still iterated over all three planes, callinglog10(0)for the two unaccumulated chroma channels. A secondary bug inflated the theoretical APSNR cap by ~1 unit via an unexplained* 2factor in themax_apsnrexpression. -
PSNR 16-bit scalar tail — signed overflow UB in AVX2/AVX512/NEON:
const int32_t e = ref - dis; result += (uint64_t)((uint32_t)(e * e))— the multiplicatione * eis evaluated inint32_tbefore the cast. Maximum 16-bit difference is 65535; 65535² = 4,294,836,225 > INT32_MAX, which is signed integer overflow (undefined behaviour in C11). On UBSan builds this traps; on production builds compilers that assume no-overflow can miscompile the expression. -
ADM
score_aimuninitialised onden==0path: bothadm.candinteger_adm.cset*score= 1.0 in the flat-frame branch but never write*score_aim; callers infloat_adm.candinteger_adm.creadscore_aimunconditionally, yielding garbage AIM scores on black frames. -
ADM harmonic-mean NaN:
float_adm.ccomputes2 * score * score_aim / (score + score_aim)without guarding the denominator; when both are zero (fully suppressed distortion) the result is0/0 = NaN, whichMAX(NaN, adm_min_val)propagates silently.
Decision¶
Apply targeted one-liner guards for each case:
- APSNR flush: bound the loop to
enable_chroma ? 3 : 1; skip channels wheresse[i] == 0and emitpsnr_max[i]instead; remove the* 2inflation inmax_apsnr. - PSNR scalar tail: change
int32_t etouint32_t e(capturing the two's-complement bit-pattern of the signed difference) before squaring; the product is thenuint32_t * uint32_twhich cannot overflow unsigned. - ADM
den==0branch: add*score_aim = 1.0fin bothadm.candinteger_adm.c. - ADM harmonic mean: guard with
(score + score_aim == 0.0) ? 0.0 : ....
Alternatives considered¶
| Option | Pros | Cons | Why not chosen |
|---|---|---|---|
| Clamp SSE to 1 for APSNR | Single expression | Changes semantics of the zero-SSE clamped output value | Emitting psnr_max[i] is more intuitive |
(int64_t)e * e for SIMD tail | Correct, no mask | Widens accumulator type unnecessarily | uint32_t is sufficient and matches the SIMD path semantics |
Consequences¶
- Positive: APSNR no longer emits NaN for identical-frame sequences; ADM AIM scores are always valid; PSNR 16-bit scalar tail is UBSan-clean.
- Negative: APSNR cap value changes by ~1 unit for sequences where the old
* 2cap was the binding constraint (negligible practical impact; cap is only hit on nearly-perfect sequences). - Neutral: No change to the Netflix golden-data assertions (these bugs only manifest on edge inputs not present in the golden test pairs).
References¶
- Round-6 scanner labels:
r6-psnr-correctness,r6-vif-adm-correctness core/src/feature/integer_psnr.c,x86/psnr_avx2.c,x86/psnr_avx512.c,arm64/psnr_neon.c,adm.c,integer_adm.c,float_adm.c