Skip to content

cambi: CUDA feature extractor for CAMBI (fixes #1567) - #1571

Open
samfrazerdutton wants to merge 8 commits into
Netflix:masterfrom
samfrazerdutton:cambi-cuda
Open

samfrazerdutton wants to merge 8 commits into
Netflix:masterfrom
samfrazerdutton:cambi-cuda

Conversation

@samfrazerdutton

Copy link
Copy Markdown

Fixes #1567. libvmaf_cuda had no CAMBI feature extractor, so loading a VMAF
v1 model failed at init with could not initialize feature extractor "Cambi_feature_cambi_score".

Per-frame cambi scores from the CUDA path are bit-identical to the CPU
extractor across a 75-frame 1080p CRF-40 clip, with cambi_high_res_speedup
both off and on.

Split

Device: preprocessing (decimate + convert to 10-bit, anti-dithering),
derivative and spatial mask, then per scale decimate, mode filter and
c_values.
Host: setup and spatial pooling, through the shared helpers exposed in
cambi.h by the preceding commit, per the direction in #1567 — no duplicated
setup. CambiState is the first member of CambiStateCuda, so
cambi_options[] is shared unchanged.

Pooling stays on the host deliberately. cambi_spatial_pooling runs
quick_select and then sums the top-k in whatever order the partition left
them
, accumulating into a double. Float addition isn't associative, so
matching the CPU bit-for-bit would mean reproducing quick_select's partition
ordering.

c_values is one kernel, not a ported histogram. Tracing the CPU's four
phases, the invariant at output row i is a clipped 2-D box count over value
bins; the ring buffer and column scatter are an incremental encoding of it.
And c_value_pixel reads only the bins at compact_v + all_diffs[...] —
typically 9 out of a v_band_size in the hundreds. So nothing is
materialised: each thread accumulates just those bins as deltas from its own
value, over a window staged in shared memory. No scatter, no atomics, no
row-to-row dependency.

Upstream defect found while debugging

vmaf_cuda_buffer_upload_async discards the caller's stream:

cuMemcpyHtoDAsync(buf->data, src, buf->size,
                  c_stream == 0 ? c_stream : cu_state->str);

Any extractor running kernels on its own stream therefore has an
unsynchronised upload. vmaf_cuda_buffer_download_async has the same shape.

Here it produced non-deterministic output — the derivative kernel reading
d_img[0] mid-upload. It survived full kernel serialisation and reported
clean under compute-sanitizer --tool initcheck (the memory is initialised,
with the previous frame's data). This PR uses cuMemcpyHtoDAsync /
cuMemcpyDtoHAsync directly. Happy to file separately — it may affect the
existing CUDA extractors.

Testing

test/test_cambi_cuda.c gates each kernel bit-exact against the CPU
implementation, keeping the CPU code verbatim — the four-phase incremental
histogram, the cyclic DP matrix — rather than a simplified reimplementation.
Six gates: derivative, decimate, filter_mode, spatial_mask, c_values
(96 synthetic configurations), and c_values again on a generated frame in
the production parameter regime.

That last one exists because the synthetic sweep supplies its own
tvi_for_diff/band values and so cannot catch a caller passing the wrong
ones — which is exactly what one bug here was (TVI thresholds derived at
8-bit when cambi_preprocessing emits 10-bit). All tests are self-contained.

Verified on RTX 2060 (sm_75), CUDA 13.2, Ubuntu 24.04/WSL2, from a clean
meson setup. meson test: 21/21, no new warnings. The test reaches the
driver through the ffnvcodec dynlink loader like the rest of the library, so
it needs no CUDA toolkit install.

Out of scope

VMAF v1 models also require a CUDA chroma SpEED-QA extractor (speed.c)
before they can run end-to-end on libvmaf_cuda. That's a separate
extractor — happy to open an issue for it.

samfrazerdutton added 8 commits August 6, 2026 22:05
Moves CambiState, CambiBuffers, the callback typedefs and the NUM_SCALES /
PICS_BUFFER_SIZE / MASK_FILTER_SIZE / CAMBI_MIN_WIDTH_HEIGHT macros into
cambi.h, and gives eleven helpers external linkage so a CUDA extractor can
share one copy rather than duplicating the setup (per discussion in Netflix#1567).

Generic names take a cambi_ prefix since they are external symbols now:
adjust_window_size, set_contrast_arrays, get_tvi_for_diff, get_vlt_luma,
get_mask_index, get_pixels_in_window, spatial_pooling,
weight_scores_per_scale, combine_dist_src_scores, dump_c_values.
cambi_preprocessing only loses static.

options[] becomes cambi_options[] so both extractors share one table; a
CUDA state struct with CambiState as its first member keeps every
offsetof() valid.

test/test_cambi.c #includes cambi.c directly to reach these statics, so its
call sites are renamed to match. No test logic changes.

No functional change. meson test: 21/21. VMAF v1.0.16_3d0h on a 1080p
CRF-40 gradient clip scores 75.281815 before and after.
Adds src/feature/cuda/cambi_cuda.c and registers vmaf_fex_cambi_cuda, so
Cambi_feature_cambi_score resolves on the libvmaf_cuda path. Per-frame scores
are bit-identical to the CPU extractor across a 75-frame 1080p clip, with
cambi_high_res_speedup both off and on.

Device: preprocessing (decimate/convert to 10 bit, anti-dithering), derivative
and spatial mask, then per scale decimate, mode filter and c_values. Host:
setup and spatial pooling, via the shared helpers from cambi.h.

Pooling stays on the host deliberately: cambi_spatial_pooling runs
quick_select then sums the top-k in whatever order the partition left them,
accumulating into a double, so matching it bit-for-bit on GPU would mean
reproducing the partition ordering.

c_values stages its window footprint in shared memory, keyed by a sentinel
that folds masked-out, out-of-band and out-of-image into one compare.

CambiState is the first member of CambiStateCuda so cambi_options[] is shared
unchanged.

Note: transfers use cuMemcpyHtoDAsync/cuMemcpyDtoHAsync directly rather than
vmaf_cuda_buffer_{upload,download}_async, which discard the caller's stream
(c_stream == 0 ? c_stream : cu_state->str) and would leave them
unsynchronised against kernels running on our own stream.
@lusoris

lusoris commented Oct 1, 2026

Copy link
Copy Markdown

Tested at the PR head 95f2a522 (merge base 4991d2b5a), because it no longer merges into master 8e7a1ac4e: conflicts in cambi.c, cambi.h and test/meson.build. Master has had its own cambi.h since 41bacc83 and the AVX2 dp/mask-row code since 86da14d0, while this PR renames the shared helpers (get_tvi_for_diff -> cambi_get_tvi_for_diff and so on) and adds its own cambi.h. RTX 4090, CUDA 13.4.92, driver 615.71.09, gcc 16.2.1; built with -Wno-error=incompatible-pointer-types, the flag GCC 16 needed on master before 8e7a1ac4e.

Build and tests: builds. meson test: 26 ok, 1 fail (test_cuda_pic_preallocation, SIGSEGV); master has 25 ok and the same failure, the extra pass is test_cambi_cuda. The CPU cambi is unchanged by the refactor: 0 difference against master on the three Netflix pairs and the 10-bit clip.

cambi vs cambi_cuda through the CLI (--no_prediction, every frame compared, the CLI prints 6 digits):

input frames max abs diff
src01 576x324 8-bit 48 0
checkerboard 1920x1080, 1 px and 10 px 3 each 0
src01 10-bit, 12-bit 3 each 0
577x323 4:4:4 3 0
256x144 3 0
bbb 3840x2160 8-bit, cambi_high_res_speedup=0 and =1 (cambi_hrs_1) 6 each 0 (values about 0.44 to 0.47)

Short frames: at 1920x128 the CPU cambi aborts on this head (malloc.c:3948 (_int_malloc): assertion failed; calculate_c_values() writes rows outside a frame shorter than the window, which is #1629), while cambi_cuda runs and returns 1e-05, 0, 0, the same as the CPU extractor with #1629 applied. At 16x16 both extractors refuse the input (exit 234).

Four instances in one process, one thread each, own VmafCudaState each, 8 runs, every per-frame value compared with a single-instance run: 0 mismatches. compute-sanitizer --tool memcheck on 2 frames of 576x324: 0 errors.

Overlap: #1629 (calculate_c_values() row bounds) conflicts with this PR in the same three files as master does, nothing extra. git merge-tree against #1563 and #1574 conflicts in feature_extractor.c, src/meson.build and test/meson.build (the registration lines).

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.

Feature request: CUDA feature extractor for CAMBI

2 participants