Skip to content

Latest commit

 

History

1,759 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

libjpeg-turbo-rs

crates.io docs.rs CI MSRV license

Pure-Rust reimplementation of libjpeg-turbo with NEON/AVX2/SSE2/WASM-SIMD128 acceleration. No C dependencies, no FFI to a C codec, no_std-capable — and byte-for-byte cross-validated against C libjpeg-turbo in CI.

Safety status. "No C dependencies" is not "no unsafe code" — the SIMD kernels are unsafe, and the boundary between them and the safe API is under audit (P4-135..P4-139 in docs/LAST_MILE.md). Until those close this project makes no memory-safety guarantee and no unqualified drop-in-replacement claim. Every unsafe under a workspace crate's src/ is listed, with its invariant and test coverage, in docs/UNSAFE_INVENTORY.md (root crate) and docs/UNSAFE_INVENTORY_CAPI.md (C ABI); CI fails when the code and those inventories disagree.

C compatibility tiers. TurboJPEG 3 is the primary target. The classic libjpeg leg targets the v8 identity only (libjpeg.so.8) and is experimental; v6b (libjpeg.so.62) and v7 are explicit non-goals — their struct layouts differ, so substituting this library for them corrupts memory rather than merely failing.

cargo add libjpeg-turbo-rs
use libjpeg_turbo_rs::{compress, decompress_to, PixelFormat, Subsampling};

let image = decompress_to(&jpeg_bytes, PixelFormat::Rgb)?; // decode any JPEG to RGB
let jpeg = compress(&image.data, image.width, image.height,
                    image.pixel_format, 85, Subsampling::S420)?; // re-encode

The crate-level doctests mirror these snippets and are compile-checked (cargo test --doc); the doctest decode calls decompress, the format-defaulting sibling of decompress_to. Runnable examples live in examples/.

How it compares

Measured with the in-repo harnesses (methodology: #361, #392; examples/bench_zune_matrix.rs, experiments/image_bridge.md):

vs Result (decode)
zune-jpeg (the image crate's default) 31 wins / 3 losses of 34 scored cases (±2% threshold) across subsampling × progressive × 16×16→8K, quiet aarch64, 2026-07-28; e.g. 4K progressive 0.65×, 4K 4:2:0 0.74× of zune's time. Through the image-crate bridge: 1.31× faster at 1080p. Two losses are 16×16 fixed-cost cases (1.20×, 1.08×); the third is a 64×64 non-interleaved 4:4:0 image (1.78×) on the multi-scan path. Full output: experiments/zune_matrix_aarch64_2026-07-28.md.
C libjpeg-turbo Decode (stock cargo build --release): matches or beats C on most benchmarks on x86_64/AVX2 (i5-10400), within a few % on aarch64/NEON (M1 Pro). Encode: the x86_64 portable table below is a stock cargo build --release (1.05–1.10× C 3.2.0 at 1080p on Zen 3 and Zen 4 runners); the i5-10400 table is a target-cpu=native build and supplementary — see Portable vs native builds before quoting either. Dated per-platform tables below.

Performance

x86_64 (AVX2)

Decoding

Intel Core i5-10400 @ 2.90GHz (turbo off, performance governor), C libjpeg-turbo 3.1.2, quality 75:

Image Subsampling Rust (us) C (us) Ratio
64x64 4:2:0 60 49 1.23x
320x240 4:2:0 769 996 0.77x
640x480 4:2:0 929 880 1.05x
640x480 4:2:2 3,267 3,480 0.94x
640x480 4:4:4 4,794 5,525 0.87x
1280x720 4:2:0 8,707 9,997 0.87x
1920x1080 4:2:0 19,736 22,031 0.90x
1920x1080 4:2:2 25,382 26,227 0.97x
1920x1080 4:4:4 37,585 40,026 0.94x
2560x1440 4:2:0 35,137 37,918 0.93x
3840x2160 4:2:0 78,868 89,325 0.88x

Encoding — portable build (stock cargo build --release)

The number a packager should judge: no RUSTFLAGS, AVX2/SSE2 kernels, the BMI1/LZCNT/BMI2 Huffman loop and the FMA float FDCT all reached by runtime detection. Measured 2026-09-08 by perf-portable-vs-native.yml on a GitHub-hosted AMD EPYC 7763 (Zen 3) runner against upstream's official libjpeg-turbo 3.2.0 package, quality 75, on the build as it stood before the BMI2 tier and the FMA twin were dispatched; shared runner, governor not pinnable, run-to-run noise 0–2.5 % (full tables and the per-feature A/B in experiments/portable_vs_native_x86_64_2026-09-08.md).

Image Subsampling Rust (µs) C (µs) Ratio
320x240 4:2:0 303 287 1.05x
320x240 4:2:2 382 361 1.06x
320x240 4:4:4 566 566 1.00x
640x480 4:2:0 1,065 1,037 1.03x
640x480 4:2:2 1,343 1,275 1.05x
640x480 4:4:4 1,920 1,890 1.02x
1280x720 4:2:0 3,619 3,501 1.03x
1920x1080 4:2:0 8,171 7,643 1.07x
1920x1080 4:2:2 10,471 9,685 1.08x
1920x1080 4:4:4 15,496 14,806 1.05x

On the same runner -C target-cpu=native moved these by 0.7–2.0 % at 1080p (1.03–1.07× C). A second sample on an AMD EPYC 9V74 (Zen 4) runner put the portable build at 1.09–1.10× C at 1080p and native 2.2–3.5 % faster than portable. That was the whole native-only gap for the default integer DCT — most of it BMI2, which the portable build now reaches by detection.

Encoding — native build (RUSTFLAGS="-C target-cpu=native", supplementary)

Intel Core i5-10400, C libjpeg-turbo 3.1.2, 2026-05; valid only for a binary built on the machine it runs on.

Image Subsampling Rust (µs) C (µs) Ratio
320x240 4:2:0 381 403 0.94x
320x240 4:2:2 474 508 0.93x
320x240 4:4:4 709 764 0.93x
640x480 4:2:2 1,653 1,731 0.96x
640x480 4:4:4 2,397 2,558 0.94x
1920x1080 4:2:0 10,273 10,474 0.98x
1920x1080 4:2:2 12,783 13,082 0.98x
1920x1080 4:4:4 19,057 19,873 0.96x

aarch64 (NEON)

Apple M1 Pro, C libjpeg-turbo 3.1.0, quality 75:

Decoding (1920x1080)

Subsampling Rust (µs) C (µs) Ratio
4:2:0 12,159 11,333 1.07x
4:2:2 15,246 15,329 0.99x
4:4:4 22,972 23,130 0.99x

Encoding (1920x1080)

Subsampling Rust (µs) C (µs) Ratio
4:2:0 5,724 5,332 1.07x
4:2:2 7,148 6,766 1.06x
4:4:4 10,596 10,272 1.03x

aarch64: Decoding matches or beats C for 4:2:2 and 4:4:4; 4:2:0 has a 7% gap. Encoding matches or beats C in 7 of 8 configurations (see docs/ENCODING_PERFORMANCE.md); the remaining 1080p 4:2:0 gap (~4%) is structural function-call overhead.

x86_64: Decoding beats C across most resolutions. The portable encoder trails C 3.2.0 by 0–14 % (5–10 % at 1080p) on Zen 3 and Zen 4 runners; the 2026-05 native i5-10400 build beat C 3.1.2 by 2–7 %. The encoder runs SSE2 Huffman + AVX2 FDCT/quantize/color/downsample, and the Huffman bitmap-iteration hot path dispatches at runtime on bmi1 && lzcnt (P4-8, src/encode/huffman_encode.rs), so a stock build already lights up TZCNT/BLSR/LZCNT. What target-cpu=native still adds was measured feature by feature in 2026-09 (P4-133 / #464): on the default integer DCT, BMI2 (SHLX-family shifts in the bit packer) is worth 1.3–2.9 % at 1080p and FMA is noise (≤ 1.7 %); on the non-default float DCT, FMA is worth 18–23 % because f32::mul_add is a libm call on a baseline build. Nothing in this port uses PEXT/PDEP. Both are now behind cpu_has! — a third BMI2 compilation of the Huffman AC loop, chosen once per encode on the operation's bit writer, and an FMA twin of the float FDCT installed once per encode in the kernel set — so a portable build reaches them. Measured on the branch (Zen 4 runner, C 3.2.0): the float-DCT portable build now sits at 1.39–1.41× C at 1080p where it was 1.72–1.94×, with compile-time +fma adding only 1–2 % more; on the integer DCT the remaining +bmi2 margin over portable is 0.4–2.2 % against a 0.4–0.6 % noise bracket, which is inside cross-run variance; the same-run main column added to the A/B afterwards settled the float side (main 12–27 % slower than the branch on every case) and landed on a host too noisy to settle a 1–3 % BMI2 effect. A later run on a quiet Zen 3 host (2026-09-08, run 7 in the same file, after the tier moved onto the per-encode bit writer) put compile-time +bmi2 over the dispatched portable build at 0.99–1.01 — noise — on the CPU model where it had been worth 1.3–2.9 % before the tier existed, and put the writer-carried tier 3–4 % ahead of the process-cached one on every 4:2:0 case.

Quick Start

[dependencies]
libjpeg-turbo-rs = "0.8"

# Optional: enable PNG support for tj3LoadImage8 / tj3SaveImage8
# libjpeg-turbo-rs = { version = "0.8", features = ["png"] }

Build flags

Portable vs native builds

Two numbers exist and they are not interchangeable (P4-133 / #464):

  • Portable — plain cargo build --release. This is what a distribution ships and what a packager should judge the project on. AVX2/NEON/SSE2 kernels are still reached, by runtime detection.
  • Native — RUSTFLAGS="-C target-cpu=native". Faster, but the binary is only valid on CPUs matching the build host.

Measured, not asserted (2026-09-08, Zen 3 and Zen 4 runners, C 3.2.0): on the default integer DCT the native build is 0.7–3.5 % faster than portable at 1080p, most of it BMI2 in the Huffman bit packer (1.3–2.9 %); FMA changes nothing there. On the float DCT, FMA alone is worth 18–23 %. -C target-feature=+bmi1,+lzcnt,+bmi2,+fma is not a portable build — it faults on any CPU without BMI2/FMA — so it is an application-only choice, worth 0.8–4.3 % at 1080p before the BMI2 and FMA paths were dispatched at runtime; since 2026-09-08 the portable build reaches both by detection, so what compile-time flags still add is the per-block dispatch overhead and the BMI1/LZCNT compile-time margin P4-8 priced at ≤ 1.5 %. The per-feature tables are in experiments/portable_vs_native_x86_64_2026-09-08.md.

x86_64

A package should ship the plain build; an application built for the machine it runs on may add the flags:

cargo build --release                                  # portable: what a distribution ships
RUSTFLAGS="-C target-cpu=native" cargo build --release  # application only: +0.7–3.5 % at 1080p (integer DCT), measured before the BMI2/FMA dispatch

The portable build already reaches the AVX2/SSE2 kernels, the BMI1/LZCNT/BMI2 Huffman loop and the FMA float FDCT by runtime detection (#464). -C target-feature=+bmi1,+lzcnt,+bmi2,+fma is not a portable baseline — it faults on CPUs without BMI2/FMA — and what it still buys over the dispatched portable build was 0.4–2.2 % at 1080p on the integer DCT and 1–2 % on the float DCT in the branch A/B (noise 0.4–0.6 %), and 0–1 % on the integer DCT on a quiet Zen 3 host once the tier rode on the bit writer. aarch64 / NEON builds are unaffected.

32-bit ARM (armv7) — measure before you ship it

The armv7-unknown-linux-gnueabihf target carries -neon in its baseline, so LLVM's auto-vectoriser never runs on our kernels there — on x86_64, where SSE2 is baseline, the same code vectorises silently. Enabling the feature turns it back on with no unsafe and no intrinsics:

RUSTFLAGS="-C target-feature=+neon -C target-cpu=cortex-a7" cargo build --release \
  --target armv7-unknown-linux-gnueabihf

Measured effect on the generated code (experiments/armv7_autovec_2026-07-30.md): idct_8x8 goes from 0 to 270 vector instructions, ycbcr_to_rgb_row 0 to 140, fancy_h2v2_row 0 to 232; 204/204 tests still pass under qemu-arm.

This is not a recommended default, for two reasons. target-feature is compile-time, so a +neon binary crashes with SIGILL on an ARMv7 core that has no NEON (C ships one binary for both by probing /proc/cpuinfo; a compile-time flag cannot). And it is unmeasured on hardware: the only A/B available was under emulation, which models no pipeline, cache, or NEON↔ARM register transfer cost — the very things that make auto-vectorised code regress on Cortex-A8 (transfer stalls) and A7/A9 (64-bit NEON datapath). Set -C target-cpu= to your actual core, A/B it per kernel on the real device, and keep it off if you cannot.

Feature flags, MSRV, platforms

MSRV: 1.87 for the root and capi crates, CI-enforced (cargo +1.87 check job). The image-bridge crate is 1.88 (inherited from image@0.25). MSRV bumps are considered minor, never patch, changes and are called out in CHANGELOG.md.

Target SIMD Notes
aarch64 (Linux/macOS) NEON compile-time selection, CI-tested
x86_64 (Linux/macOS/Windows) AVX2/SSE2 runtime CPUID dispatch (std), CI-tested incl. no-AVX2 emulation
wasm32 (browser/WASI) SIMD128 compile-time target_feature — see the wasm crate README
RISC-V / POWER / s390x scalar works, unoptimized. The ~1.1–1.7× C advantage measured in #359 was scalar vs scalar; libjpeg-turbo 3.2.0 added RISC-V Vector (RVV), so on RVV hardware the real gap is larger and unmeasured on hardware (#465): a 2026-09-08 qemu-riscv64 run showed the emulator cannot stand in (RVV code runs 3–4× slower than scalar under TCG), so the gap stays inferred at roughly 1.5–4× decode / 4–7× encode — see experiments/riscv64_rvv_2026-09-08.md. POWER/s390x remain scalar on both sides.
armv7 / 32-bit ARM (Cortex-A) scalar CI-tested: 204 tests run under qemu-arm. Our widest gap by installed base (the riscv64 row's inferred encode gap is larger, and equally unmeasured): C does vectorize 32-bit ARM (AArch32 NEON), we do not — estimated 2–5× slower, not yet measured on hardware (#424, P4-78)
thumbv7em (bare metal) scalar no_std + alloc, CI-built; no NEON backend is registered for thumb targets
flag default effect
std ✅ std::io streaming API (decompress_from_reader, compress_to_writer, bounded-memory decompress_from_reader_incremental), file-path helpers, PNG image I/O, runtime CPU-feature detection, and std::io::Error interop.
simd ✅ Architecture intrinsics: NEON (aarch64), SSE2/AVX2 (x86_64), SIMD128 (wasm32).
png ❌ PNG support for tj3LoadImage8 / tj3SaveImage8 (implies std).

no_std + alloc: build with --no-default-features for the core codec — headers, entropy decode, IDCT, upsample, colour convert, and encode all work. alloc is required (the decoder allocates pixel and coefficient buffers). Without std there is no CPUID probe, so SIMD dispatches on compile-time target_feature only; pass -C target-feature=+neon (or equivalent) to vectorise a bare-metal build. CI builds the crate for thumbv7em-none-eabihf on every PR.

Decompress

use libjpeg_turbo_rs::{decompress, decompress_to, PixelFormat};

// Decode to RGB
let img = decompress(&jpeg_bytes)?;
println!("{}x{}", img.width, img.height);

// Decode to specific format
let img = decompress_to(&jpeg_bytes, PixelFormat::Rgba)?;

// Decode into a caller-owned, reusable buffer (no per-frame output allocation)
use libjpeg_turbo_rs::{decompress_into, output_buffer_size};
let size = output_buffer_size(&jpeg_bytes, PixelFormat::Rgb)?;
let mut out = vec![0u8; size];
let info = decompress_into(&jpeg_bytes, PixelFormat::Rgb, &mut out)?;
println!("{}x{} ({} bytes)", info.width, info.height, info.bytes_written);

Compress

use libjpeg_turbo_rs::{compress, PixelFormat, Subsampling};

let jpeg = compress(&rgb_pixels, width, height, PixelFormat::Rgb, 85, Subsampling::S420)?;

Builder API

use libjpeg_turbo_rs::Encoder;

let jpeg = Encoder::new(&pixels, width, height, PixelFormat::Rgb)
    .quality(85)
    .subsampling(Subsampling::S420)
    .progressive(true)
    .optimize_huffman(true)
    .icc_profile(&icc_data)
    .xmp_data(&xmp_packet)   // APP1 XMP
    .iptc_data(&iptc_iim)    // APP13 Photoshop IRB
    .encode()?;

Every builder option composes with every other, on every colorspace and in every mode — including CMYK, and colorspace(Rgb) with progressive, arithmetic or lossless (#313, #322, #343, #345).

Composing baseline options

Encoder covers the common cases; CompressParams is the baseline core underneath it, and takes every option at once — on every pixel format, CMYK included.

use libjpeg_turbo_rs::encode::pipeline::{compress_with_params, CompressParams};

let jpeg = compress_with_params(
    &CompressParams::new(&rgb_pixels, width, height, PixelFormat::Rgb, 85, Subsampling::S420)
        .dct_method(DctMethod::IsFast)
        .restart_interval(8)
        .custom_quant(&quant_tables)
        .custom_huffman(&dc_tables, &ac_tables),
)?;

Lossless Transform

use libjpeg_turbo_rs::{transform, TransformOp};

let rotated = transform(&jpeg_bytes, TransformOp::Rot90)?;

transform preserves metadata (EXIF/ICC/COM markers) by default, matching C TurboJPEG's tjTransform; use transform_jpeg_with_options with MarkerCopyMode::None to strip markers.

Lossless transforms are coefficient-domain: they entropy-decode to DCT coefficients, permute blocks, and entropy-encode — no pixels are ever produced, so the pixel-path SIMD (IDCT / color convert / upsample) is not involved and transform throughput rides on scalar codegen. Build with the default release profile (opt-level = 3); a size-optimized profile (opt-level = "z") roughly halves transform throughput. -C target-cpu=native buys only a further ~3% (measured on a 24 MP rot90, Zen 4; issue #308 has the full numbers).

EXIF Orientation (load a phone photo the right way up)

Nearly every camera JPEG carries an EXIF orientation tag. Read it from the header alone — no pixel decode — and apply it in whichever domain fits (issue #391):

use libjpeg_turbo_rs::{decompress, Decoder, TransformOp};

// Probe without decoding pixels (None when the JPEG carries no EXIF):
let orientation: Option<u8> = Decoder::new(&jpeg_bytes)?.exif_orientation();

// Pixel domain — decode, then reorient in one call:
let upright = decompress(&jpeg_bytes)?.apply_orientation();

// DCT domain — rewrite the JPEG losslessly instead (skip the no-op
// re-encode for upright/untagged images). Strip the markers: transforms
// copy them by default, and a stale orientation tag on already-rotated
// pixels would make EXIF-aware viewers rotate twice. Note lossless
// transforms cannot fully reorient partial edge blocks when dimensions
// are not iMCU-aligned (see TransformOp::from_exif_orientation docs) —
// the pixel-domain path above is exact at any size.
use libjpeg_turbo_rs::{MarkerCopyMode, TransformOptions};
if let Some(op) = orientation.and_then(TransformOp::from_exif_orientation) {
    if op != TransformOp::None {
        let upright_jpeg = libjpeg_turbo_rs::transform_jpeg_with_options(
            &jpeg_bytes,
            &TransformOptions { op, copy_markers: MarkerCopyMode::None, ..Default::default() },
        )?;
    }
}

Scanline-Level I/O

use libjpeg_turbo_rs::ScanlineDecoder;

let mut decoder = ScanlineDecoder::new(&jpeg_bytes)?;
let height = decoder.header().height as usize;
let width = decoder.header().width as usize;
let mut buf = vec![0u8; width * 3]; // RGB row buffer
while decoder.output_scanline() < height {
    decoder.read_scanline(&mut buf)?;
    // process buf...
}
let img = decoder.finish()?;

Features

Codec Support

Feature Encode Decode
Baseline DCT (Huffman) yes yes
Progressive DCT yes yes
Arithmetic coding yes yes
Lossless JPEG yes yes
8/12/16-bit precision yes yes
Optimized Huffman tables yes -

Pixel Formats

Grayscale, RGB, BGR, RGBA, BGRA, ARGB, ABGR, RGBX, BGRX, XRGB, XBGR, CMYK, RGB565

Chroma Subsampling

4:4:4, 4:2:2, 4:2:0, 4:4:0, 4:1:1, 4:4:1, 4:1:0, 2:4

SIMD

Platform Backend Decode Encode
aarch64 NEON IDCT, color convert, upsample, dequantize FDCT, color convert, quantize+zigzag, downsample, Huffman
x86_64 SSE2 IDCT, color convert, upsample Huffman bitmap+sign-correction
x86_64 AVX2 IDCT, color convert, upsample, merged upsample+color FDCT, color convert, quantize+zigzag, downsample (fused H2V1/H2V2)

aarch64 has comprehensive SIMD across the full pipeline. x86_64 decode and encode are both fully accelerated; encode pairs SSE2 Huffman bitmap construction with AVX2 fused FDCT/quantize/color/downsample.

All SIMD routines have scalar fallbacks. SIMD is enabled by default via the simd feature flag.

Additional Features

  • Scaled IDCT (all 16 libjpeg factors: 2/1, 15/8, 7/4, ..., 1/2, 1/4, 1/8)
  • Lossless spatial transforms (rotate, flip, transpose)
  • DCT coefficient access (read_coefficients / write_coefficients)
  • Metadata: JFIF, EXIF, ICC profile, XMP (read incl. Extended XMP reassembly; write is single-segment), IPTC (APP13 Photoshop IRB), Adobe APP14, comments
  • YUV plane encode/decode (raw component data)
  • Scanline-level streaming API
  • Crop decoding (MCU-aligned)
  • Color quantization with dithering
  • Error recovery mode
  • Custom Huffman/quantization tables
  • Restart markers (DRI)
  • Progress callbacks

C ABI replacement tiers

Beyond the Rust crate, the workspace ships C ABI shims: a TurboJPEG 3 cdylib (libturbojpeg.so.0, ready for TJ3 consumers) and a classic libjpeg v8 cdylib (libjpeg.so.8, experimental/partial). A pinned OpenCV 4.6 workload and stock-tool gates prove important default paths, but open classic ABI ownership, lifecycle, option, error, and test-integrity gaps mean it is not yet a general system-library replacement; GNU ELF symbol versions are also tracked as P4-81. The legacy-alias matrix, SONAME opt-ins, threading contract, and the v6b/v7 drop-in non-goal live in docs/ABI_COMPATIBILITY.md; the T1–T4 replacement-tier framing and its readiness status live in docs/LAST_MILE.md.

Tagged releases attach prebuilt bundles of those shims — libraries, headers, .pc files and CMake config for x86_64/aarch64 Linux and macOS and for x86_64 Windows (MSVC: jpeg8.dll + jpeg.lib), with a SHA256SUMS manifest, and each bundle carries Sigstore build provenance and a CycloneDX SBOM that gh attestation verify checks. docs/RELEASE_ARTIFACTS.md covers what ships, how to verify and install it, and what is still missing. Downloading a bundle does not change the tiers above.

Contributing

Development workflow, the pre-commit gate, and the local sanitizer recipes live in CONTRIBUTING.md.

License

Licensed under either of

at your option.

Acknowledgments

This software is based in part on the work of the Independent JPEG Group.

Algorithms and implementation techniques referenced from libjpeg-turbo (IJG License / Modified BSD License) and zune-jpeg.

About

Rust port of libjpeg-turbo with equivalent or better performance

Topics

Resources

Contributing

Stars

9 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages