Summary
calculate_motion_score_kernel_8bpc / _16bpc (libvmaf/src/feature/cuda/integer_motion/motion_score.cu) diverge from the CPU integer_motion reference on every resolution and bit depth, because the CUDA kernel was never updated when the CPU motion pipeline changed. GPU vs CPU (--gpumask 0 vs --gpumask 4294967295) on 8-bit crowd_run-derived clips:
resolution │ pooled VMAF (CPU / GPU) │ max per-frame integer_motion2
854×480 │ 48.153502 / 48.153631 │ 2.229e-03
1920×1080 │ 51.548181 / 51.548218 │ 6.620e-04
3840×2160 │ 56.689925 / 56.690007 │ 2.040e-04
The delta shrinks as ~1/width — the signature of a border artifact, not a per-pixel one.
This is distinct from #1552 (16bpc row-stride / OOB). #1552 fixes only the 16bpc addressing; the two issues below remain on both kernels after it lands.
Root cause 1 — edge mirror off-by-one (the dominant term)
The device mirror() uses the pre-fix edge form:
// motion_score.cu
device forceinline int mirror(const int idx, const int sup) {
int out = abs(idx);
return (out < sup) ? out : (sup - (out - sup + 1)); // == 2*sup - out - 1
}
The CPU reference is reflect_101:
// integer_motion.c
static inline int mirror(int idx, int size) {
if (idx < 0) return -idx;
if (idx >= size) return 2 * size - idx - 2; // note: -2, not -1
return idx;
}
Commit a44e5e6 ("libvmaf/feature: port motion updates, bugfix for edge mirroring") changed the CPU edge handling from - 1 to - 2 and never touched the .cu.
This accounts for essentially the entire delta above (a host-side reimplementation isolating the two mirror variants reproduces the full 2.2e-3 at 854×480,scaling as 1/width). It affects the 8bpc kernel too, so it is not covered by #1552.
Root cause 2 — stale blur-first pipeline (~1e-6 residual)
Commit a4a1492 ("replace integer_motion with pipelined v2 variant") changed the CPU to convolve the source difference (prev − cur) with a single rounding
step. The CUDA kernel still blurs each frame separately into a uint16 buffer and subtracts the two blurred results, which rounds differently. After the
mirror is corrected, this leaves a ~1e-6 residual per frame.
Reproduce
BIN=libvmaf/build_cuda/tools/vmaf
$BIN --gpumask 4294967295 -n --feature 'motion=debug=true' -r ref.y4m -d dist.y4m --json -o cpu.json
$BIN --gpumask 0 -n --feature motion -r ref.y4m -d dist.y4m --json -o gpu.json
diff integer_motion / integer_motion2 per frame
Suggested fix
Rewrite both kernels to compute, per pixel, the y-convolution of (prev − cur) then the x-convolution, mirroring with reflect_101 and using an int64
x-accumulator — i.e. the arithmetic of motion_score_pipeline_{8,16} in integer_motion.c. This needs the previous source frame on the device (a packed
2-entry device ring filled with a cuMemcpy2DAsync per frame works; the picture pool is a fixed round-robin so the picture itself can't be retained, and
PREV_REF only supplies a host picture). Doing so makes GPU motion/motion2 bit-exact with the CPU on 8/10/12/16-bit (verified against the AVX-512 reference).
Summary
calculate_motion_score_kernel_8bpc / _16bpc (libvmaf/src/feature/cuda/integer_motion/motion_score.cu) diverge from the CPU integer_motion reference on every resolution and bit depth, because the CUDA kernel was never updated when the CPU motion pipeline changed. GPU vs CPU (--gpumask 0 vs --gpumask 4294967295) on 8-bit crowd_run-derived clips:
resolution │ pooled VMAF (CPU / GPU) │ max per-frame integer_motion2
854×480 │ 48.153502 / 48.153631 │ 2.229e-03
1920×1080 │ 51.548181 / 51.548218 │ 6.620e-04
3840×2160 │ 56.689925 / 56.690007 │ 2.040e-04
The delta shrinks as ~1/width — the signature of a border artifact, not a per-pixel one.
This is distinct from #1552 (16bpc row-stride / OOB). #1552 fixes only the 16bpc addressing; the two issues below remain on both kernels after it lands.
Root cause 1 — edge mirror off-by-one (the dominant term)
The device mirror() uses the pre-fix edge form:
// motion_score.cu
device forceinline int mirror(const int idx, const int sup) {
int out = abs(idx);
return (out < sup) ? out : (sup - (out - sup + 1)); // == 2*sup - out - 1
}
The CPU reference is reflect_101:
// integer_motion.c
static inline int mirror(int idx, int size) {
if (idx < 0) return -idx;
if (idx >= size) return 2 * size - idx - 2; // note: -2, not -1
return idx;
}
Commit a44e5e6 ("libvmaf/feature: port motion updates, bugfix for edge mirroring") changed the CPU edge handling from - 1 to - 2 and never touched the .cu.
This accounts for essentially the entire delta above (a host-side reimplementation isolating the two mirror variants reproduces the full 2.2e-3 at 854×480,scaling as 1/width). It affects the 8bpc kernel too, so it is not covered by #1552.
Root cause 2 — stale blur-first pipeline (~1e-6 residual)
Commit a4a1492 ("replace integer_motion with pipelined v2 variant") changed the CPU to convolve the source difference (prev − cur) with a single rounding
step. The CUDA kernel still blurs each frame separately into a uint16 buffer and subtracts the two blurred results, which rounds differently. After the
mirror is corrected, this leaves a ~1e-6 residual per frame.
Reproduce
BIN=libvmaf/build_cuda/tools/vmaf
$BIN --gpumask 4294967295 -n --feature 'motion=debug=true' -r ref.y4m -d dist.y4m --json -o cpu.json
$BIN --gpumask 0 -n --feature motion -r ref.y4m -d dist.y4m --json -o gpu.json
diff integer_motion / integer_motion2 per frame
Suggested fix
Rewrite both kernels to compute, per pixel, the y-convolution of (prev − cur) then the x-convolution, mirroring with reflect_101 and using an int64
x-accumulator — i.e. the arithmetic of motion_score_pipeline_{8,16} in integer_motion.c. This needs the previous source frame on the device (a packed
2-entry device ring filled with a cuMemcpy2DAsync per frame works; the picture pool is a fixed round-robin so the picture itself can't be retained, and
PREV_REF only supplies a host picture). Doing so makes GPU motion/motion2 bit-exact with the CPU on 8/10/12/16-bit (verified against the AVX-512 reference).