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:
- 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 Cinitdoes with the values. An option value the twin does not implement returnsError::Unsupported(c"<name>: <option>"). - A state struct implementing
Extractor:initallocates every buffer (vmafx_fex::try_filled_vec),extractruns one frame,flushandclosemirror the C ones when it has them.advancemirrors the Cadvance()(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;flushappends the rest. Without anadvanceof its own a twin appends nothing there and itsflushappends every score, as before. - Emit exactly what the C extractor emits, through the same collector call:
Host::emitforvmaf_feature_collector_append_with_dict,Host::emit_rawforvmaf_feature_collector_append,Host::getfor a read-back,Host::set_aggregatefor an aggregate. Names are the base names of the C extractor'sprovided_features. 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/u16operands becomei32), widen withi64::from/u64::from, narrow withasexactly where the C converts. Usewrapping_*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 (
floatisf32). A double literal in a C float expression promotes it:x * 0.5withfloat xisf64::from(x) * 0.5. Keep the C's evaluation order, one operation per C operation. Never usemul_addunless the C callsfma(); 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 throughvmafx_fex::libm, which calls the platform libm the C calls (glibc on Linux) out of line withblack_boxarguments, so the compiler cannot evaluate the call itself.sqrt,floor,ceil,round(Clround) andabsare 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.