ADR-1216: The GPU motion3 twins apply motion_fps_weight exactly once¶
- Status: Proposed
- Date: 2026-09-07
- Deciders: Lusoris
- Tags:
cuda,sycl,hip,correctness,feature-extractor,testing
Context¶
motion_fps_weight is a VMAF_OPT_FLAG_FEATURE_PARAM option on the integer motion extractor: an fps-aware multiplicative correction applied to the SAD-derived motion score. The CPU reference applies it in exactly one place — extract() in core/src/feature/integer_motion.c, where the per-frame SAD is scaled and clipped before being written to the feature collector as motion_sad_score. Everything downstream reads that already-weighted value back: flush() derives motion2 as a min over neighbouring motion_sad_score entries and then blends motion2 into motion3 through motion_blend() with no further weighting.
The CUDA, SYCL and HIP twins each carry a host-side motion3_postprocess_*() helper that reproduced this blend. All three opened with score2 * s->motion_fps_weight — but every caller in all three twins already passes a value that has been fps-weighted and motion_max_val-clipped. The weight was therefore applied twice, so motion3 carried motion_fps_weight squared.
The defect was invisible to the existing gates because motion_fps_weight defaults to 1.0, and 1.0² = 1.0. Every motion3 parity test in the tree ran the extractors with NULL options, so all three twins agreed with the CPU on a value neither of them was computing correctly. This is the same blind-spot shape as ADR-1204 / ADR-1206: a parity test that pins the one parameter that would expose the drift.
Decision¶
We will remove the second motion_fps_weight multiplication from motion3_postprocess_cuda(), motion3_postprocess_sycl() and motion3_postprocess_hip(), blending the caller-supplied score2 directly, so each twin applies the weight exactly once — at the same point the CPU reference does. Each of the three motion3 parity tests gains a variant that pins motion_fps_weight = 0.6 and asserts CPU/GPU parity on the derived integer_motion3_mfw_0.6 key.
Alternatives considered¶
| Option | Pros | Cons | Why not chosen |
|---|---|---|---|
Remove the weighting from motion3_postprocess_* (chosen) | One-line change per twin; the surviving application sits exactly where the CPU applies it; callers stay untouched | None material | — |
Strip the weight from the callers and keep it inside motion3_postprocess_* | Also yields a single application for motion3 | The callers' weighted value is also the motion2 emission, which must stay weighted; stripping it would break motion2 to fix motion3 | Trades one bug for another |
Divide the weight back out inside motion3_postprocess_* | Smallest textual diff | Introduces a division by an option whose declared min is 0.0; a motion_fps_weight = 0 run would produce NaN instead of 0 | Numerically unsafe |
| Document the divergence and leave the twins as they are | Zero code risk | A non-default motion_fps_weight silently returns a different metric on GPU than on CPU — the fork's cross-backend parity contract (ADR-0214) forbids exactly this | Contradicts the parity contract |
Consequences¶
- Positive:
motion3agrees across CPU / CUDA / SYCL / HIP for every legalmotion_fps_weight, not just the default. The three new parity variants keep it that way. - Negative: any downstream consumer that had calibrated around the squared weight on a GPU backend will see
motion3change. No in-tree model or snapshot does — every shipped model leavesmotion_fps_weightat its1.0default, where the arithmetic is unchanged. - Neutral / follow-ups: the Metal twin has no integer-motion3 post-process to fix (it implements
motion_v2, whose weighting is a single application at the emission site).float_motionon every backend already applies the weight once, at the emission site — verified while auditing this defect, no change needed.
References¶
- Finding id 122/123/124 from the twin-drift sweep:
motion3_postprocess_cudamultiplies its input bymotion_fps_weightagain, but every caller already passes a value that has been fps-weighted and clipped, somotion3_scorecarries the weight squared. The CPU reference appliesmotion_fps_weightexactly once, inextract(). - Measured drift on the RTX 4090 before the fix, 256x144 8-bpc fixture,
motion_fps_weight = 0.6:cpu = 14.48987751,cuda = 8.69392654(= 14.48987751 × 0.6), delta5.80e+00against a1e-4tolerance. - ADR-0214 — the GPU parity CI gate and its places=4 cross-backend tolerance.
- ADR-1183 — the derived feature-name key (
integer_motion3_mfw_0.6) the parity variants read. - ADR-1204, ADR-1206 — the same pinned-fixture blind-spot pattern.