Skip to content

Rust extractor framework

RC4 (epic #1723) moves the features of the default model vmaf_v1.0.16_3d0h to Rust. Each Rust extractor is a twin of a C extractor: it returns the C extractor's scores bit for bit, it is registered next to it, and the C extractor stays the default and the fallback. This page explains how to build and select the Rust path, how a twin is written, and how its equality with C is proven. The decision and its alternatives are in ADR-1713; the measurements that shaped it are in Research-2151.

Build and run the Rust path

You need a stable Rust toolchain (1.85 or newer, edition 2024; see Rust guide). The build needs no network: no crate libvmaf links depends on anything outside the workspace.

meson setup build-rs core -Denable_rust_features=true -Denable_cuda=false \
  -Denable_sycl=false -Denable_hip=false
ninja -C build-rs

ninja calls core/src/rust/build_staticlib.py, which runs cargo build --release --locked --offline -p vmafx-core-rs on core/src/rust/Cargo.toml and links the one archive into libvmaf. Without cargo, configure warns and builds the C extractors only.

Select the Rust twins at run time with the environment variable VMAF_FEATURE_IMPL (read once per process, environment variables):

VMAF_FEATURE_IMPL=rust build-rs/tools/vmaf -r ref.yuv -d dis.yuv \
  -w 576 -h 324 -p 420 -b 8 --feature psnr --json -o out.json
Value Effect
unset, empty or c The C extractors run (default).
rust Each CPU extractor with a Rust twin is replaced by it (INFO log line); one without a twin keeps running in C and a WARNING names it. Device twins (CUDA, SYCL, HIP, Metal) are not affected.
anything else Registration fails with an error naming the value.

--feature <name>_rust (for example --feature psnr_rust) selects a twin directly, whatever the variable says. The JSON report's feature_backends list names the extractor that ran (psnr_rust, backend cpu), so a run shows which implementation produced its scores.

What exists

Twin C extractor State
psnr_rust psnr (core/src/feature/integer_psnr.c) reference twin of the framework; equal to C on every fixture below
speed_chroma_rust speed_chroma (core/src/feature/speed.c) RC4 lane; empty crate until it lands
motion_rust motion (core/src/feature/integer_motion.c) RC4 lane; empty crate until it lands
adm_rust adm (core/src/feature/integer_adm.c) RC4 lane; empty crate until it lands
cambi_rust cambi (core/src/feature/cambi.c) RC4 lane; empty crate until it lands
vmafx-predict (no twin name) the prediction of vmaf_predict_score_at_index() (core/src/predict.c) RC4 lane P; equal to C on every vmaf_v1.0.16* model (--models); predict.c reaches it through the VmafRustPredictOps table core/src/rust/shim/rust_predict.c installs (Models)

The TAD pilot (ADR-0707, TAD) is a Rust extractor without a C counterpart and is linked through the same archive.

Layout

The Rust code libvmaf links is its own Cargo workspace, core/src/rust/Cargo.toml, separate from the repository-root workspace of the bindings (vmafx-sys, vmafx). Cargo resolves a whole workspace even when it builds one package, and the bindings pull in bindgen; keeping them apart leaves core/src/rust/Cargo.lock without any external crate, which is what lets the build run offline. Run cargo there with --manifest-path core/src/rust/Cargo.toml (or from that directory).

Path Crate Role
core/src/rust/fex vmafx-fex ABI types (abi.rs), the Extractor trait and twin::<E>() (twin.rs), option, picture and host views, libm wrappers
core/src/rust/feature/<name> vmafx-fex-<name> one twin per crate; lists itself in pub const TWINS
core/src/rust/predict vmafx-predict model prediction (RC4 lane P)
core/src/rust/staticlib vmafx-core-rs the only staticlib: registry vmafx_rs_twin_at(), re-exports the other crates
core/src/rust/shim/rust_twins.cpp - C side: turns each twin into a VmafFeatureExtractor
core/src/rust/include/vmafx_rs.h - C header of the ABI, generated by cbindgen, committed

Exactly one Rust static library may be linked into a binary (two would each carry the Rust standard library), so every crate is an rlib reached from vmafx-core-rs.

How a twin is registered

vmaf_init() calls vmaf_rust_twins_install(). For each entry of the Rust registry, the shim finds the C extractor by name, copies its descriptor and replaces the name (<name>_rust), the callbacks and the flags (adding VMAF_FEATURE_EXTRACTOR_RUST). advance is the twin's own (the trampoline to Extractor::advance) whenever the C extractor has one, never the inherited C callback, which would derive from the twin's scores a second time. The twin therefore shares the C extractor's option table, priv_size, provided features, flags and reads_prev_prev_ref. The C option parser fills the C-layout priv blob as usual; the shim reads every option back by name and type and hands the list to Rust; it builds the feature-name dictionary with vmaf_feature_name_dict_from_provided_features() exactly as the C extractors do, so option-dependent feature names match. Rust never sees a libvmaf struct.

feature_extractor.cpp walks its static list and then the extractors the shim installed. A lookup by feature name without flags skips Rust twins, so the C extractor is found first whatever the order; vmaf_feature_extractor_impl_select() swaps in the twin when VMAF_FEATURE_IMPL=rust. Nothing in feature_extractor.cpp refers to a Rust symbol: test binaries that link it never need the archive.

Writing a twin

Copy core/src/rust/feature/psnr and follow its shape:

  1. An options struct implementing FromOptions, reading every option of the C table by its name (opts.f64(c"cambi_max_val")?). Defaults, aliases and ranges stay in C; replicate only what the C init does with the values. An option value the twin does not implement returns Error::Unsupported(c"<name>: <option>").
  2. A state struct implementing Extractor: init allocates every buffer (vmafx_fex::try_filled_vec), extract runs one frame, flush and close mirror the C ones when it has them. advance mirrors the C advance() (ADR-2090) when the C extractor has one: libvmaf calls it on the thread that feeds frames after each accepted frame, and it appends the scores the collector's contents now make final; flush appends the rest. Without an advance of its own a twin appends nothing there and its flush appends every score, as before.
  3. Emit exactly what the C extractor emits, through the same collector call: Host::emit for vmaf_feature_collector_append_with_dict, Host::emit_raw for vmaf_feature_collector_append, Host::get for a read-back, Host::set_aggregate for an aggregate. Names are the base names of the C extractor's provided_features.
  4. pub const TWINS: &[VmafxRsTwin] = &[twin::<MyTwin>(c"<c name>", c"<c name>_rust")];

Rules every twin crate follows ([workspace.lints] in core/src/rust/Cargo.toml, thresholds in the root clippy.toml): #![forbid(unsafe_code)], no unwrap / expect / panic!, functions of at most 60 lines and complexity 10, no allocation in extract or flush, no global mutable state. panic = "abort" in every profile Meson builds.

Arithmetic rules for bit identity

The reference is the C extractor of the same build, scalar path; every C translation unit is built without FP contraction (ADR-1461).

  • Integers: write the C integer promotions explicitly (u8 / u16 operands become i32), widen with i64::from / u64::from, narrow with as exactly where the C converts. Use wrapping_* where the C relies on unsigned wrap-around; signed overflow is undefined in C and must not occur. Replicate C quirks, never fix them.
  • Floating point: keep each value in the C's type (float is f32). A double literal in a C float expression promotes it: x * 0.5 with float x is f64::from(x) * 0.5. Keep the C's evaluation order, one operation per C operation. Never use mul_add unless the C calls fma(); rustc never fuses on its own. Clippy's float-rewrite lints are allowed and their suggestions never applied.
  • Libm: call exp, log, log2, log10, pow, powf, cbrt, sin, ... only through vmafx_fex::libm, which calls the platform libm the C calls (glibc on Linux) out of line with black_box arguments, so the compiler cannot evaluate the call itself. sqrt, floor, ceil, round (C lround) and abs are the std methods.
  • Tables are copied from the C source, never recomputed (the CAMBI reciprocal table has entries one ulp away from 1.0f / i).

Proving equality: the differential harness

scripts/ci/rust_twin_diff.py runs the same binary twice per cell, VMAF_FEATURE_IMPL=c and VMAF_FEATURE_IMPL=rust, at --precision max. A cell passes only when both runs have the same frames and metric names, every value is the same IEEE double (NaN equals NaN), and the JSON receipt names the C extractor in the first run and the twin in the second. It prints the first ten differences with both values and their distance in ulp. Every cell runs once per --threads value (default 0,1,4): with a thread pool, the flush of a non-temporal extractor, and every advance of a twin the pool runs, run on the shared context, which libvmaf initialises for a Rust twin first (init_shared_rust_twin() in core/src/libvmaf.c). A cell where both sides refuse the input with the same exit status (for example speed_chroma at prescale 0.5 on 480x270) is reported as REFUSED and passes; one side refusing alone is an error. Exit 0 equal, 1 mismatch, 2 run or fixture failure.

The cells are derived: per extractor, the default options and every distinct option set of the model/vmaf_v1.0.16*/*.json models; with --models, every vmaf_v1.0.16 model end to end.

python3 scripts/ci/rust_twin_diff.py --vmaf build-rs/tools/vmaf --feature psnr \
  --fixtures netflix,checker1,checker10,sparks10,bbb4k
python3 scripts/ci/rust_twin_diff.py --vmaf build-rs/tools/vmaf --models
Fixture Files Geometry
netflix src01_hrc00_576x324.yuv / src01_hrc01_576x324.yuv 576x324, 8-bit, 48 frames
checker1 checkerboard_1920_1080_10_3_0_0.yuv / ..._1_0.yuv 1920x1080, 8-bit, 3 frames
checker10 checkerboard_1920_1080_10_3_0_0.yuv / ..._10_0.yuv 1920x1080, 8-bit, 3 frames
sparks10 sparks_ref_480x270.yuv42010le.yuv / sparks_dis_... 480x270, 10-bit, 5 frames
bbb4k testdata/bbb/ref_3840x2160_200f.yuv / dis_... 3840x2160, 8-bit, 200 frames (--bbb-frames N while iterating)

The first three come from scripts/test/fetch-test-yuvs.sh. sparks10 and bbb4k are not fetched by any script and are not in the repository (both directories are ignored by git); a missing fixture fails its cell unless --skip-missing is given, which lists what it skipped.

Tests and CI

Check What it holds Command
test_rust_abi_layout every size and field offset of the ABI is the same in C and Rust python3 scripts/ci/run_meson_test.py -- -C build-rs --suite rust
test_rust_twin_registry every twin is registered, inherits the C descriptor, is reached only through the twin lookup and is chosen by VMAF_FEATURE_IMPL=rust; TAD is registered same
test_rust_twin_harness_netflix the harness on netflix for every registered twin same
header drift core/src/rust/include/vmafx_rs.h is what cbindgen generates scripts/dev/rust-abi-header.sh --check
clippy, fmt, unit tests every crate of both workspaces cargo clippy --manifest-path core/src/rust/Cargo.toml --workspace --all-targets -- -D warnings, cargo fmt --manifest-path core/src/rust/Cargo.toml --all --check, cargo test --manifest-path core/src/rust/Cargo.toml --workspace (and the same without --manifest-path for the bindings)
offline build no crate of core/src/rust depends on anything outside it CARGO_HOME=$(mktemp -d) cargo build --release --locked --offline -p vmafx-core-rs --manifest-path core/src/rust/Cargo.toml

.github/workflows/rust-ci.yml runs all of them, plus the harness on netflix, checker1 and checker10, on every pull request that touches the Rust paths (.github/ci-impact.json, selector rust).

Limits

  • The dev container has no Rust toolchain yet, so artifacts published from it (ADR-1102) carry no Rust path.
  • Linking the archive is verified on Linux; on macOS the Rust symbols are not yet hidden from libvmaf.dylib, and Windows needs the archive's native libraries on the link line.