Skip to content

speed: CUDA chroma SpEED-QA feature extractor - #1574

Open
samfrazerdutton wants to merge 5 commits into
Netflix:masterfrom
samfrazerdutton:speed-cuda
Open

samfrazerdutton wants to merge 5 commits into
Netflix:masterfrom
samfrazerdutton:speed-cuda

Conversation

@samfrazerdutton

Copy link
Copy Markdown

Adds a CUDA speed_chroma extractor. Together with #1571 (CUDA CAMBI) this is
what a VMAF v1 model needs in order to load on libvmaf_cuda at all.

speed_chroma_u, _v and _uv are identical to the CPU extractor across
a 75-frame 1080p clip.

Chroma on the CUDA path

libvmaf.c called vmaf_cuda_picture_upload_async(pic_device, pic, 0x1), so
only the luma plane has ever reached the device. Every CUDA extractor to
date — VIF, ADM, Motion — is luma-only, so this was never visible. A chroma
feature reads zeroed planes; est_params then returns -EINVAL on the
degenerate covariance and every score comes out 0.

Rather than upload all three planes unconditionally, extractors now declare
what they read: VMAF_FEATURE_EXTRACTOR_CHROMA, with translate_picture_host
building the upload mask from the registered extractors' flags. Luma-only
pipelines are unaffected — vmaf_v0.6.1 over 75 frames runs 0.68 s median
with this change against a 0.71 s baseline, versus 0.75 s if chroma were
uploaded unconditionally. (Wall clock including I/O, three runs — indicative
rather than tight.)

Where the work runs

Profiling put est_params at 28% of speed_chroma and the filtering around
it at ~72%. Within est_params the covariance is estimated once per scale
across all blocks and there is one eigen-decomposition — O(1) per scale,
not per block.

So the device runs the per-pixel front end (picture_copy, two separable
filters, decimation, subtraction) and est_params / get_speed_score stay on
the host, where the awkward numerics already work: a float reduction in
compute_mean whose summation order a parallel reduction wouldn't preserve,
and an iterative QR eigensolver. Cost is one download of the filtered plane
per picture per channel — 1/16 the source in each axis after dec16.

The interleaving of speed_extract_score is preserved — filter(ref),
est(ref), filter(dis), est(dis) — since est_params writes into
SpeedState::buffers.

speed_prescale != 1.0 is rejected at init rather than silently running a
different pipeline; that path uses vif_scale_frame_*, which has no kernel
here.

Testing

test/test_speed_cuda.c gates each kernel bit-exact against the CPU:
filter1d (both passes, six filter widths), dec16 including non-multiples
of 16, subtract, and picture_copy for 8/10/12/16-bit. Strides are padded
and differ between source and destination so a stride bug can't hide.

The CPU accumulates filter taps with accum += fcoeff * imgcoeff, which nvcc
would contract into an FMA and round once instead of twice. The kernels spell
the arithmetic out with __fmul_rn/__fadd_rn rather than depending on
-fmad=false, and one thread per output walks the taps in the same order.

Verified that the AVX2 and scalar CPU paths agree bit-for-bit
(--cpumask 4294967295), so "identical to the CPU" is unambiguous here.

These are the float vif_* primitives; the existing CUDA VIF is
integer_vif, so they're new and reusable beyond SpEED.

samfrazerdutton added 5 commits August 10, 2026 01:25
filter_and_downscale() in speed.c is built from the shared float vif_*
primitives; these are their device counterparts, each gated bit-exact against
the CPU implementation in test/test_speed_cuda.c.

  speed_filter1d_v / _h   vif_filter1d_s, separable, mirrored edges
  speed_dec16             vif_dec16_s (decimation by sixteen)
  speed_subtract          subtract_image
  speed_picture_copy_u8   picture_copy, 8-bit
  speed_picture_copy_u16  picture_copy, 10/12/16-bit

The CPU accumulates the filter taps with `accum += fcoeff * imgcoeff`, which
nvcc would contract into an FMA and round once rather than twice. Rather than
depend on -fmad=false reaching this file the arithmetic is spelled out with
__fmul_rn/__fadd_rn, and one thread per output walks the taps in the same
order, so the summation matches by construction.

These are the float vif_* primitives; the existing CUDA VIF is integer_vif,
so they are new and reusable beyond SpEED.
Moves SpeedDimensions, SpeedResultBuffers, SpeedBuffers, SpeedOptions,
SpeedState, SpeedChromaState and the compute_cov_kernel_fn typedef into a new
speed.h, and gives est_params and get_speed_score external linkage so a CUDA
extractor can run filter_and_downscale on the device and score on the host.

speed_init, speed_extract_score and speed_close were already external and only
needed declaring; speed_get_antialias_filter is already public in vif_tools.h.
options_chroma becomes speed_chroma_options so both extractors share one table.

No functional change. meson test: 21/21. speed_chroma_uv scores 22.705135 /
21.056826 / 25.779747 before and after.
Moves the Speed* structs and the compute_cov_kernel_fn typedef into a new
speed.h, gives est_params and get_speed_score external linkage, and renames
options_chroma to speed_chroma_options so both extractors share one table.
NUM_SCALES is exposed as NUM_SPEED_SCALES.

No functional change. meson test: 21/21.
Registers vmaf_fex_speed_chroma_cuda. speed_chroma_u/_v/_uv are identical to
the CPU extractor across a 75-frame 1080p clip.

Device: picture_copy, the two separable filters, decimation and subtraction.
Host: est_params and get_speed_score. Profiling put est_params at 28% of the
extractor and the filtering at ~72%, and est_params estimates one covariance
matrix and runs one eigen-decomposition per scale rather than per block, so
the sequential numerics are O(1) and stay where they already work.

Preserves speed_extract_score's filter(ref)/est(ref)/filter(dis)/est(dis)
ordering, since est_params writes into SpeedState::buffers.
speed_prescale != 1.0 is rejected at init; that path has no device kernel.
vmaf_cuda_picture_upload_async was called with a 0x1 channel mask, so only the
luma plane ever reached the device. Every CUDA extractor to date is luma-only,
so this went unnoticed; a chroma feature reads zeroed planes and est_params
returns -EINVAL on the degenerate covariance.

Adds VMAF_FEATURE_EXTRACTOR_CHROMA so extractors declare what they read, and
builds the upload mask in translate_picture_host from the registered
extractors' flags. Luma-only pipelines are unaffected: vmaf_v0.6.1 over 75
frames runs 0.68 s median with this change against a 0.71 s baseline, versus
0.75 s if chroma were uploaded unconditionally.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant