Skip to content

About

Slower, safer SIMD fork of rav1d - Rust AV1 decoder with safe intrinsics replacing 160k lines of assembly

Resources

Contributing

Stars

10 stars

Watchers

1 watching

Forks

Latest commit

 

History

1,636 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

rav1d-safe CI crates.io lib.rs docs.rs license

An AV1 decoder with a native Rust API: Decoder, Settings, Frame, and borrowed pixel-plane views are available directly from rav1d_safe. Forked from rav1d, with checked safe Rust SIMD enabled by default. Rust callers need neither a C FFI wrapper nor the c-ffi feature.

Use zenrav1e to encode raw AV1 and zenavif for complete AVIF files, including container handling and color conversion. See the compiled Rust round-trip examples.

Quick Start

Add to your Cargo.toml:

[dependencies]
rav1d-safe = "0.7.0"

Decode an AV1 bitstream:

use rav1d_safe::{Decoder, Planes};

fn decode(obu_data: &[u8]) -> Result<(), Box<dyn std::error::Error>> {
    let mut decoder = Decoder::new()?;

    // Feed raw OBU data (not IVF/WebM containers)
    if let Some(frame) = decoder.decode(obu_data)? {
        println!("{}x{} @ {}bpc", frame.width(), frame.height(), frame.bit_depth());

        match frame.planes() {
            Planes::Depth8(planes) => {
                for row in planes.y().rows() {
                    // row is &[u8] — zero-copy, no allocation
                }
            }
            Planes::Depth16(planes) => {
                let px = planes.y().pixel(0, 0); // 10 or 12-bit value
            }
        }
    }

    // Drain any buffered frames
    for frame in decoder.flush()? {
        // ...
    }
    Ok(())
}

API Overview

The reference below describes the 0.6 API, including Strictness and cooperative cancellation. The historical runnable codec examples remain pinned to the published versions used for their recorded validation.

The public API lives in src/managed.rs and is re-exported at the crate root, so every public type is reachable directly from rav1d_safe — no src::managed:: path needed. One canonical import covering the whole surface:

use rav1d_safe::{
    Decoder, Settings, Strictness, CpuLevel, Error, Frame, Result,
    Planes,                       // enum; you match Planes::Depth8(_) / Planes::Depth16(_)
    Planes8, Planes16,            // the inner per-bit-depth plane sets the variants wrap
    PlaneView8, PlaneView16,      // zero-copy 2D plane views
    PixelLayout,                  // I400 / I420 / I422 / I444 (chroma subsampling)
    DecodeFrameType, InloopFilters,
    // HDR / color metadata:
    ColorInfo, ColorPrimaries, TransferCharacteristics, MatrixCoefficients,
    ColorRange, ContentLightLevel, MasteringDisplay,
    enabled_features,
};

Configure the conformance policy by modifying the default settings:

use rav1d_safe::{Decoder, Settings, Strictness};

# fn main() -> rav1d_safe::Result<()> {
let mut settings = Settings::default();
settings.strictness = Strictness::Lenient;
let decoder = Decoder::with_settings(settings)?;
# Ok(())
# }

The default is Strictness::Strict. Settings remains non-exhaustive, so downstream callers use field assignment rather than a struct literal.

Core types:

Type Purpose
Decoder Decodes AV1 OBU data into frames
Frame Decoded frame with metadata (cloneable, Arc-backed)
Planes Enum with variants Planes::Depth8(Planes8) / Planes::Depth16(Planes16), dispatched by bit depth
Planes8 / Planes16 Per-bit-depth plane set: .y() → luma view, .u() / .v() → Option<view> (None for I400 monochrome)
PlaneView8 / PlaneView16 Zero-copy 2D view: row(y), pixel(x, y), rows(), width(), height(), stride()
Settings Thread count, film grain, frame size limit, inloop filters, CPU level, etc.
CpuLevel SIMD dispatch level (Scalar, X86V2, X86V3, X86V4, Neon, Native)
Error Enum: InvalidData, OutOfMemory, NeedMoreData, InvalidSettings(&str), InitFailed, Cancelled, Other(String)

Naming note (reconciles the example above): Depth8 / Depth16 are the variants of the Planes enum — you write Planes::Depth8(..) in a match. Planes8 / Planes16 are the struct types those variants wrap, and are also re-exported at the crate root so you can name them in signatures. Both are correct; they refer to different things.

Metadata types: ColorInfo, ColorPrimaries, TransferCharacteristics, MatrixCoefficients, ColorRange, ContentLightLevel, MasteringDisplay, PixelLayout

Input Format

The decoder expects raw AV1 Open Bitstream Unit (OBU) data. If you have IVF or WebM containers, strip the container framing first and pass the OBU payload. See tests/ivf_parser.rs for an IVF parser example. For complete AVIF images, use zenavif, which uses rav1d-safe’s Rust API and handles the container, alpha, and color conversion. Use zenavif-parse directly only when implementing that container layer yourself; AVIF files can contain multiple image items or tiles, not just one OBU payload.

Output Format

decode() / flush() yield a Frame of planar YUV pixels — there is no built-in RGB conversion (do that downstream, e.g. with zenpixels-convert, or via zenavif for AVIF). Read the planes through the bit-depth-dispatched Planes enum:

let layout = frame.pixel_layout();        // PixelLayout: I400 | I420 | I422 | I444
let bpc    = frame.bit_depth();           // 8, 10, or 12

match frame.planes() {
    Planes::Depth8(p) => {                 // 8-bit content: planes are u8
        let y = p.y();                     // PlaneView8 (luma, always present)
        let u = p.u();                     // Option<PlaneView8> — None for I400 monochrome
        let v = p.v();                     // Option<PlaneView8>
        let (w, h, stride) = (y.width(), y.height(), y.stride());
        for row in y.rows() { /* row: &[u8], one luma scanline, zero-copy */ }
        let _ = (u, v, w, h, stride);
    }
    Planes::Depth16(p) => {                // 10/12-bit content: planes are u16
        let _y = p.y();                    // PlaneView16; pixel(x, y) -> u16
    }
}
  • Subsampling is reported by frame.pixel_layout(): I420 (4:2:0, chroma half-width & half-height), I422 (4:2:2, half-width), I444 (4:4:4, full-res chroma), I400 (monochrome — u()/v() return None).
  • Bit depth is frame.bit_depth() (8 / 10 / 12). Planes::Depth8 carries u8 samples; Planes::Depth16 carries u16 (the 10/12-bit value is in the low bits).
  • Each PlaneView{8,16} is a strided 2D view: row(y) borrows one scanline, rows() iterates them, pixel(x, y) reads one sample, and width()/height()/stride() give its geometry. All accessors are zero-copy borrows into the decoder's frame buffer (held alive by the Frame).

Threading

use rav1d_safe::{Decoder, Settings, CpuLevel};

// Single-threaded (default) — synchronous, deterministic
let decoder = Decoder::new()?;

// Tile threading within a frame; safe checked mode supports this.
let mut settings = Settings::default();
settings.threads = 4;
settings.max_frame_delay = 1;
let decoder = Decoder::with_settings(settings)?;

// Constrained decoding — limit frame size and CPU features
let mut settings = Settings::default();
settings.frame_size_limit = 3840 * 2160; // total pixels, not bytes
settings.cpu_level = CpuLevel::Native;
let decoder = Decoder::with_settings(settings)?;

frame_size_limit — the pre-decode DoS guard (read this before decoding untrusted AV1)

This is the only resource bound applied before a frame is decoded, so it is the knob that matters for untrusted input. Verified against src/managed.rs and src/obu.rs:

  • Unit: the maximum width * height in pixels (total luma sample count). Not bytes, not the longest dimension. The check is literally width * height > frame_size_limit during OBU header parsing — a frame is rejected before any pixel buffer is allocated or decoded.
  • Default: 120_000_000 (120 megapixels) — chosen to admit ~108 MP phone photos. It is not unlimited by default, but 120 MP is large; set it lower for untrusted servers (e.g. 7680 * 4320 for an 8K cap, or 3840 * 2160 for 4K).
  • Disable: set frame_size_limit: 0 to turn the limit off entirely (no cap).
  • On rejection: the offending OBU fails parsing and decode() returns an Err (surfaced as Error::Other carrying the internal ERANGE range error — not Error::InvalidData). The decoder logs Frame size WxH exceeds limit N.
  • 32-bit hosts: on targets where usize is < 64 bits, the effective cap is additionally clamped to 8192 * 8192 regardless of the value you set (a non-zero larger value is silently reduced; 0/unlimited still means unlimited).

Caveat for servers: frame_size_limit bounds the declared frame dimensions only. It does not bound total decode time, memory of a large-but-legal frame, or the number of OBUs/frames in a stream. There is no per-decode time budget — see Cancellation below.

With threads >= 2 or threads == 0, the decoder uses tile threading to parallelize decode within each frame. decode() may return None for complete frames because processing is asynchronous — call it repeatedly or use flush() to drain.

Tile threading works in the default forbid(unsafe_code) build. No special feature flags needed:

rav1d-safe = { version = "0.7.0", features = ["bitdepth_8", "bitdepth_16"] }

Still-image scaling depends on the encoded tile layout. A single-tile image may gain little from additional workers; tiled images can benefit substantially. Measure the intended workload and set max_frame_delay = 1 when comparing single-frame latency. Frame threading (max_frame_delay > 1) also works in the tracked default build. The managed API selects a delay of two when multiple workers are requested and max_frame_delay is left at zero; poll get_frame() between input chunks and call flush() at end of input. See the 2K/4K/8K still investigation and the performance parity goal.

strictness — what to do with a stream that breaks the spec

AV1 leaves the handling of non-conforming streams to the decoder. dav1d's library default conceals and keeps going (a player would rather show a damaged frame than stop); the AV1 reference decoder aomdec rejects the frame. Settings::strictness picks between the two:

  • Strictness::Strict (default) rejects what libaom rejects: dav1d's strict_std_compliance checks (OBU trailing bits, the padding after each tile's symbol decoder, the OBU forbidden bit, zero timing_info, …) plus the spec's requirement that every decoded segment_id stays within LastActiveSegId. A desynchronised symbol stream is an Error::InvalidData instead of garbage pixels. These are compares at points the decoder already evaluates; none is in a per-pixel loop.
  • Strictness::Lenient is dav1d's behaviour, bit-exact with dav1d even on corrupt input. Use it for differential testing against dav1d, or when a best-effort frame beats an error.

Why Strict is the default: this decoder's main job is decoding still images and verifying encoder output. An encoder bug that desynchronised the symbol stream (zenrav1e#35) passed every encode→decode round trip for weeks because the decoder concealed it (#422). The 766-vector dav1d md5 gate decodes identically under both settings, and across the AVIF corpora in benchmarks/strictness_2026-08-28.meta the only file Strict rejects that Lenient accepts is a deliberately corrupted conformance-suite sample. cargo run --release --example strictness_sweep -- <dir> reports the two verdicts side by side for your own corpus.

HDR Metadata

if let Some(cll) = frame.content_light() {
    println!("MaxCLL: {} nits", cll.max_content_light_level);
}
if let Some(mdcv) = frame.mastering_display() {
    println!("Peak: {} nits", mdcv.max_luminance_nits());
}
let color = frame.color_info();
// color.primaries, color.transfer_characteristics, color.matrix_coefficients

Error Handling

Fallible operations return the crate's Result<T> alias, which is Result<T, whereat::At<Error>> — the Error is wrapped in [whereat]'s At<…>, recording the source location where the failure surfaced (handy for server logs). Unwrap the inner Error with err.error() (borrow) or err.decompose().0 (owned). Error variants: InvalidData, OutOfMemory, NeedMoreData, InitFailed, InvalidSettings(&str), Cancelled, Other(String). (From<Rav1dError> maps the internal EAGAIN → NeedMoreData, ENOMEM → OutOfMemory, EINVAL → InvalidData, and everything else — including the frame_size_limit ERANGE — to Other.)

Cancellation

The 0.6 Rust API provides Decoder::set_stop, accepting an Option<Arc<dyn enough::Stop>>. Single-threaded decoding checks at superblock-row boundaries; tile workers also check for cancellation. A triggered token returns Error::Cancelled. None disables cancellation checks. This is cooperative cancellation, not a hard wall-clock deadline; frame_size_limit remains useful as a separate bound on declared dimensions. This API is not in published 0.5.7.

Safety Model

The default library compiles under crate-wide forbid(unsafe_code) and uses runtime borrow-overlap checks. Unsafe implementations behind dependency APIs remain part of the trust boundary, including rav1d-disjoint-mut, alignment helpers, archmage, and SIMD memory-access helpers. This is not a proof of the whole decoder or its dependencies.

Enabling c-ffi, asm or partial_asm removes the crate-wide prohibition (those features contain unsafe FFI wrappers). c-ffi implies untracked; asm implies c-ffi; partial_asm also implies untracked. The SIMD kernels themselves stay safe Rust in every mode.

untracked is an opt-in race-tolerant fast mode: it turns off the DisjointMut overlap tracker and, under tile threading, uses zero-copy pixel access. Slice bounds checks stay on. Two threads that really do touch the same bytes become undefined behaviour under the Rust memory model, which the tracked default build refuses at runtime; the decoder is designed so this does not happen, every DisjointMut element is a plain-data type (all bit patterns valid, no pointers), and the intended consequence of a decoder bug is a wrong pixel value rather than memory corruption. That is a design argument, not a proof, and a weaker guarantee than the default build gives. See UNTRACKED_MODE.md. Normal Rust callers need none of these features.

The default SIMD path uses archmage for feature-token dispatch and safe_unaligned_simd for reference-based loads/stores, with slice-based kernel interfaces.

Verify at runtime with rav1d_safe::enabled_features() — returns a comma-delimited list including the active safety level (e.g. "bitdepth_8, bitdepth_16, safety:forbid-unsafe").

What's Been Ported

The default build compiles under forbid(unsafe_code) in the main crate. SIMD kernels live in src/safe_simd/, with additional entropy specialization in src/msac.rs.

Ported: All DSP Kernels (AVX2 + NEON)

Every DSP kernel family has a safe Rust SIMD implementation that compiles under forbid(unsafe_code):

Module x86 ASM replaced ARM ASM replaced Safe Rust
mc (motion compensation) 3 files (SSE/AVX2/AVX-512) x2 bitdepths 4 files (32+64-bit) x2 bitdepths + SVE/dotprod mc.rs + mc_arm.rs
itx (inverse transforms) 3 files x2 bitdepths 2 files x2 bitdepths itx.rs + itx_arm.rs
ipred (intra prediction) 3 files x2 bitdepths 2 files x2 bitdepths ipred.rs + ipred_arm.rs
cdef (directional enhancement) 3 files x2 bitdepths 2+tmpl files x2 bitdepths cdef.rs + cdef_arm.rs
loopfilter 3 files x2 bitdepths 2 files x2 bitdepths loopfilter.rs + loopfilter_arm.rs
looprestoration (Wiener + SGR) 3 files x2 bitdepths 2+common+tmpl files x2 bitdepths looprestoration.rs + looprestoration_arm.rs
filmgrain 3+common files x2 bitdepths 2 files x2 bitdepths filmgrain.rs + filmgrain_arm.rs
pal (palette) 1 file (none — ARM uses scalar) pal.rs
refmvs (reference MVs) 1 file 2 files (32+64-bit) refmvs.rs + refmvs_arm.rs
msac (entropy decoder) 1 file (shared) 1 file (shared) inline in msac.rs
cpuid 1 file (55 lines) — replaced by std::arch detection in cpu.rs

The entropy decoder combines scalar routines with selected safe SIMD specialization; see the measured entropy experiments.

Not Ported (With Rationale)

Scaled MC (put_8tap_scaled, prep_8tap_scaled, put_bilin_scaled, prep_bilin_scaled) — These functions use per-pixel variable step sizes with per-pixel filter selection, making them fundamentally different from fixed-block MC. The ASM versions are heavily register-scheduled for this pattern. Falls back to scalar Rust. ~2% of profile on inter-frame content.

SSE-only paths — 14 files, ~52k lines. The safe SIMD dispatch jumps straight to AVX2 when available. On pre-AVX2 hardware (pre-Haswell, 2013), the decoder falls back to scalar Rust rather than SSE intrinsics. SSE-only x86 hardware is rare enough that maintaining a second intrinsics tier isn't worth the code.

ARM SVE2, dotprod, i8mm extensions — mc_dotprod.S (1,880 lines) and mc16_sve.S (1,649 lines) are optional fast paths for newer ARM cores. The safe SIMD covers baseline NEON; these extension paths fall back to the NEON implementation.

Remaining AVX-512 paths — Some AVX-512 paths have been ported (itx, mc, ipred, looprestoration Wiener), but others remain unported. Falls back to AVX2 where not implemented.

ASM infrastructure files — x86inc.asm (1,983 lines), asm.S, util.S, *_tmpl.S, *_common.S are macro libraries and constants that only exist to support the raw assembly. No independent functionality to port.

Performance

For the current assembly-disabled upstream comparison, see the matched 2K/4K/8K benchmark. Upstream rav1d has no memory-safe feature mode; turning off its assembly selects its Rust implementation, which still contains unsafe code. Checked rav1d-safe retains safe SIMD and overlap checks.

Current measurements against dav1d and libgav1 (2026-10, Zen 4)

Real footage: an 8 s 4K H.264 clip, downscaled to 1080p and 720p, encoded with aomenc and SVT-AV1 (8-bit) plus a 10-bit 1080p encode. All streams decode bit-identically in dav1d 1.5.3, libgav1 and rav1d-safe. Steady-state decode time per frame, one thread, lightly loaded machine (load 2 to 5), ratio to dav1d (dav1d runs hand-written AVX-512 assembly; lower is better):

stream libgav1 rav1d-safe untracked rav1d-safe default (checked)
720p aomenc / SVT-AV1 1.59x / 1.45x 2.00x / 1.70x 2.51x / 1.97x
1080p aomenc / SVT-AV1 1.64x / 1.53x 2.14x / 1.90x 2.64x / 2.27x
4K aomenc / SVT-AV1 1.64x / 1.58x 2.21x / 2.07x 2.76x / 2.51x
4K still photo (AVIF) 1.6x 1.55x 1.58x

With four threads the 4K photo is 1.45x (untracked) and 2.1x (default) of dav1d. Frame threading is on by default for the managed Decoder (two frames in flight when threads > 1); on 4K video it takes untracked decode time to about half and checked decode time down by 15 to 35% at 4 to 8 threads. Method, raw logs and every caveat (one machine, one clip, Zen 4 only, Zen 5 not measured): docs/DECODER_COMPARISON.md.

Where the remaining gap is (docs/SAFE_VS_DAV1D.md): about 75% is SIMD kernel quality, mostly motion compensation (our 8-tap two-pass pipeline against dav1d's fused AVX-512 6-tap kernels) and CDEF; about 25% is control-flow and bounds-check glue. Entropy decoding is at parity. Checked mode costs 2% (4K still) to 25% (video) more than untracked at one thread (the overlap tracker), and more at 4K with several threads.

Historical measurements (v0.5.6, kept for reference)

These compare rav1d-safe against its own assembly build at v0.5.6 and are not the current state. The Safe (unchecked) column is the removed bounds-unchecked feature, which measured as noise.

Resolution ASM Safe (checked) Safe (unchecked) Safe vs ASM
4K (3840x2561) 120.7 ms 187.8 ms 179.5 ms 1.56x
8K (8192x5464) 714.1 ms 1103.2 ms 1066.9 ms 1.54x

Reproduce locally

just generate-bench-avif  # create 4K/8K AVIF test images (requires avifdec + avifenc)
just profile              # side-by-side runs of the ASM, partial ASM, checked and untracked builds
just profile-quick        # same with fewer iterations

Conformance

Tested against the dav1d-test-data suite: all 803 vectors match their reference MD5 at every CPU dispatch level (scalar, SSE4, AVX2, AVX-512, native) with 1, 2, 4 and 8 worker threads, in both the default and untracked builds (40 configurations). The runner skips the oss-fuzz fuzz inputs by default and fails on any missing file or incomplete selection. SVC operating points, S-frames and the vq_suite modes are covered by passing the matching decode_md5 flags.

Run it with just conformance-all-modes <tracked-decode_md5> <untracked-decode_md5> (see the justfile), or one configuration with bash scripts/conformance_test.sh --binary <decode_md5> --threads 4 --level all.

Building

Use a current stable Rust toolchain. The default checked library is tested on its declared Rust 1.89 minimum; the workflow examples and benchmarks record their exact compiler versions. Install via rustup.rs.

# Default safe-SIMD build (recommended)
cargo build --release

# With original hand-written assembly (for benchmarking)
cargo build --features asm --release

# Run tests
cargo test --release

Feature Flags

Feature Default Description
bitdepth_8 on 8-bit pixel support
bitdepth_16 on 10/12-bit pixel support
unchecked off Deprecated alias of untracked (kept for manifests written against 0.6.0)
untracked off Race-tolerant fast mode: no DisjointMut overlap tracking, zero-copy pixel access under tile threading; bounds checks stay on. See UNTRACKED_MODE.md
partial_asm off ASM for entropy decoding (msac) and loopfilter only; safe SIMD everything else. Implies untracked
c-ffi off C API entry points (dav1d_* symbols). Implies untracked
asm off Full hand-written assembly. Implies c-ffi

Safety chain: default (forbid(unsafe_code), overlap-checked, tile and frame threading) -> untracked (no overlap tracking) -> c-ffi -> asm. Each level relaxes the safety constraint. The bounds-unchecked mode the former unchecked feature added was removed: skipping bounds checks measured 0% (within +-1.7%, both signs). The unchecked feature name remains as a deprecated alias of untracked so existing manifests keep resolving.

Cross-Compilation

# aarch64
RUSTFLAGS="-C linker=aarch64-linux-gnu-gcc" \
  cargo build --target aarch64-unknown-linux-gnu --release

# Verify aarch64 NEON compiles
cargo check --target aarch64-unknown-linux-gnu

Supported targets: x86_64-unknown-linux-gnu, aarch64-unknown-linux-gnu, i686-unknown-linux-gnu, armv7-unknown-linux-gnueabihf, riscv64gc-unknown-linux-gnu.

Image tech I maintain

Codecs ¹ zenjpeg · zenpng · zenwebp · zengif · zenavif · zenjxl · zenjxl-decoder · jxl-encoder · zenbitmaps · heic · zentiff · zenpdf · zensvg · zenjp2 · zenraw · ultrahdr
Codec internals zenrav1e · rav1d-safe · zenravif · zenavif-parse · zenavif-serialize
Compression zenflate · zenzop · zenzstd
Processing zenresize · zenquant · zenblend · zenfilters · zensally · zentone
Pixels & color zenpixels · zenpixels-convert · linear-srgb · garb · zenyuv
Pipeline & framework zenpipe · zencodec · zencodecs · zenlayout · zennode · zenwasm · zentract
Metrics zensim · fast-ssim2 · butteraugli · zenmetrics · resamplescope-rs
Pickers & ML zenanalyze · zenpredict · zenpicker · zenanalyze-api
Test corpora codec-corpus · imazen-26
Products Imageflow image engine (.NET · Node · Go) · Imageflow Server · ImageResizer (C#)

¹ pure-Rust, #![forbid(unsafe_code)] codecs, as of 2026

General Rust awesomeness

zenbench · archmage · magetypes · enough · whereat · cargo-copter · zenutils

Open source · @imazen · @lilith · lib.rs/~lilith

About

Slower, safer SIMD fork of rav1d - Rust AV1 decoder with safe intrinsics replacing 160k lines of assembly

Resources

Contributing

Stars

10 stars

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages