Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Zstandard compression and decompression in safe Rust

mzstd is a port of the zstd 1.5.7 encoder that writes the same bytes, plus an independent Rust decoder. Reuse working memory between payloads, plug into Read and Write, drive either codec with your own buffers, and schedule parallel compression on your own threads.

A round trip

[dependencies]
mzstd = "0.2.13"
use mzstd::{Compressor, Decompressor, EncoderConfig, Level};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let input = "hello, smaller world! ".repeat(1000);
    let mut encoder = Compressor::new(EncoderConfig::default().with_level(Level::try_from(3)?))?;
    let compressed = encoder.compress(input.as_bytes())?;

    let mut decoder = Decompressor::default();
    assert_eq!(decoder.decompress(&compressed)?, input.as_bytes());
    Ok(())
}

Requires Rust 1.89 or later. Getting started continues from here with memory reuse, streaming and feature selection.

Pick a level

The level trades speed for size; the default is 3. Each bar is the compressed size of the 211 MB Silesia corpus against its original size.

LevelCompressed sizeCompressDecompress
-548.6%838 MB/s2828 MB/s
134.5%626 MB/s1992 MB/s
331.2%374 MB/s1784 MB/s
828.2%103 MB/s1945 MB/s
1526.8%9.04 MB/s2014 MB/s
2224.7%2.27 MB/s1498 MB/s

mzstd 0.2.12 in lzbench 2.4.1 on one thread of an AMD Ryzen 9 7950X VDS, fastest of three rounds. Your data and machine will differ.

See the lzbench run for the command and raw output, and benchmark results for every measured level against zstd 1.5.7, including the cases where mzstd is slower.

Find your path

What you can rely on

  • Byte-identical encoder. For the same input and parameters, serial compression writes exactly the bytes of zstd 1.5.7. See what is claimed and how it is checked.
  • Safe implementation, not a C wrapper. Production code forbids unsafe; SIMD goes through fearless_simd. The reference C library is only a test and benchmark oracle.
  • No hidden I/O or threads. Sessions touch only the buffers you pass in, and parallel compression hands tasks to your scheduler. The library does not create a thread pool.
  • Reusable state. A codec keeps its workspace between payloads, under a retention policy you choose.

Framing requires the opt-in framed feature; its API may change in a patch release.

Getting started

Build a complete round trip, then choose the API that fits your application. Requires Rust 1.89+.

Install

cargo new zstd-demo
cd zstd-demo
cargo add mzstd@0.2.13

Or add this to Cargo.toml:

[dependencies]
mzstd = "0.2.13"

Compress and decompress

Replace src/main.rs with this complete program:

use mzstd::{Compressor, Decompressor, EncoderConfig, Level};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let input = "hello, smaller world! ".repeat(1000);
    let config = EncoderConfig::default().with_level(Level::try_from(3)?);
    let mut encoder = Compressor::new(config)?;
    let compressed = encoder.compress(input.as_bytes())?;

    let mut decoder = Decompressor::default();
    let restored = decoder.decompress(&compressed)?;
    assert_eq!(restored, input.as_bytes());

    println!(
        "{} input bytes → {} compressed bytes",
        input.len(),
        compressed.len()
    );
    Ok(())
}
cargo run --release

Choose the level explicitly: the default is level 3. Higher positive levels spend more CPU searching for compression opportunities. Compare speed and output size on your data; the benchmark pages show how results vary with input and level.

Reuse the workspace and output

A codec retains working memory between calls. compress_into appends a stream and returns its range; clear the destination when you want to reuse it for a new payload.

use mzstd::{Compressor, Decompressor, EncoderConfig, Level};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let mut encoder = Compressor::new(EncoderConfig::default().with_level(Level::try_from(3)?))?;
    let mut decoder = Decompressor::default();
    let mut output = Vec::new();

    for payload in [b"first message".as_slice(), b"second message".as_slice()] {
        output.clear();
        let stream = encoder.compress_into(payload, &mut output)?;
        assert_eq!(decoder.decompress(&output[stream])?, payload);
    }
    Ok(())
}

See retention policies to control which workspace allocations remain after an operation. Caller output capacity is separate.

Stream through Write

Write plain input into the adapter, then call finish to obtain a complete Zstandard stream. Dropping the writer does not finalize it.

use mzstd::{Compressor, Decompressor, EncoderConfig, InputSize, Level};
use std::io::Write;

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let input = b"a payload arriving in two writes";
    let mut encoder = Compressor::new(EncoderConfig::default().with_level(Level::try_from(3)?))?;
    let stream = InputSize::Exact(input.len() as u64).into();
    let mut writer = encoder.writer(Vec::new(), stream);
    writer.write_all(&input[..10])?;
    writer.write_all(&input[10..])?;
    let compressed = writer
        .finish()
        .map_err(mzstd::io::FinishError::into_error)?;

    let mut decoder = Decompressor::default();
    assert_eq!(decoder.decompress(&compressed)?, input);
    Ok(())
}

For a retryable sink failure, FinishError::into_parts returns the error and writer. into_error discards the writer, as this in-memory example does. The streaming guide explains completion, backpressure, reader read-ahead, and direct sessions.

Choose features

The default features enable std and both codecs. To build only one codec, drop std, or enable framing containers, see Cargo features.

Next steps

Choose your API

Compression and decompression expose the same I/O shapes. Pick the row that matches how your application already moves bytes, then follow its guide. New to the crate? Start with getting started.

Both codecs

I/O shapeCompressionDecompressionGuide
Return a new Vec<u8>Compressor::compressDecompressor::decompressGetting started
Append to an existing Veccompress_intodecompress_intoBuffers and memory reuse
Write into a caller-owned slicecompress_to_slice, compress_to_uninitdecompress_to_slice, decompress_to_uninitBuffers and memory reuse
Pull output through std::io::ReadCompressor::readerDecompressor::readerRead / Write I/O
Push input through std::io::WriteCompressor::writerDecompressor::writerRead / Write I/O
Drive input and output incrementallystart → EncoderSessionstart → DecoderSessionSans I/O compression, decompression
Same, with the session owning the codecinto_session → EncoderSessionOwnedinto_session → DecoderSessionOwnedSans I/O compression, decompression
Any of the above with a dictionarycompress_with_dictionary*, start_with_dictionary, into_session_with_dictionary, writer_with_dictionary, reader_with_dictionarydecompress_with_dictionary*, start_with_dictionary, into_session_with_dictionary, writer_with_dictionary, reader_with_dictionaryDictionaries

Compressor and Decompressor own reusable working state. Keep one alive across payloads when allocation reuse matters.

Parallel compression

ParallelCompressor (in mzstd::compressor::parallel) is a separate execution strategy for compression: it splits one frame into independent segments and hands them to your executor as tasks. The library does not create a thread pool. The output is a valid Zstandard frame, decoded by the ordinary Decompressor. Requires std.

I/O shapeEntry point
Split one known input across your threadsprepare_slice / prepare_source → ScopedParallelBatch
Stream chunked input across your threadsstart → ParallelEncoderSession + ParallelTaskQueue
Same, with the session owning the compressorinto_session → ParallelEncoderSessionOwned
Push or pull through std::io across your threadswriter, reader

See parallel compression for scoped threads, Rayon, sources, staging, and streaming.

Guides by task

TaskRead
Pick a level, parameters, or Cargo featuresConfiguration and features
Control allocations and retained memoryBuffers and memory reuse
Decode untrusted input with resource limitsDecompression and limits
Understand readers, writers, sessions, and finishStreaming and completion
Connect readers and writers for every formatRead / Write I/O
Drive compression with caller buffersSans I/O compression
Decode arriving chunks and resource eventsSans I/O decompression
Split a job across workersParallel compression
Package several resourcesFraming containers
Share a dictionary or use magicless framesDictionaries and extended formats
Copy a complete programExamples

Errors

ConfigError covers encoder configuration, DecodeConfigError decoder limits, DictionaryError dictionary preparation, and EncodeError / DecodeError codec failures. Failed decoder calls report exact progress in DecodeFailure; output delivered before final validation can be partial. I/O adapters return std::io::Error, with the codec error as its source.

Reference

Examples

The recipes below include complete programs and round-trip assertions. Examples use the default std, compression, and decompression features unless stated otherwise. The documentation workflow compiles and runs their Rust code against the current checkout.

TaskRecipeFeatures / extra dependency
Compress and decompress bytesFirst round tripDefault
Reuse codec and destination memoryReuse the workspaceDefault
Pick a level and explicit parametersChoose a level firstDefault
Write into a caller-owned sliceOutput buffersDefault
Decode untrusted input with limitsSet limits for untrusted inputDefault
Compress small records with a dictionaryPrepared dictionariesDefault
Write a raw stream incrementallyStream through WriteDefault
Drive a raw or framed encoder with tiny buffersSans I/O compressionDefault; framed for frames
Decode arriving chunks and resource eventsSans I/O decompressionDefault; framed for frames
Read or write raw, parallel, and framed streamsRead / Write I/ODefault; framed for frames
Use scoped CPU workersCompressing a sliceDefault
Reuse a Rayon thread poolRunning tasks with RayonDefault + rayon
Stream through parallel workersReader and writer adaptersDefault
Package compressed and stored resourcesOne container, two resourcesframed
Add resource names and write incrementallyWrite resources and metadataframed
Read just the selected resourceIndexed resource readsframed

Run from the repository

The runnable user recipes are complete programs embedded in the guides. To compile and run all of them against this checkout:

python3 scripts/docs.py test-examples

The repository also provides a profiling driver:

git submodule update --init --recursive
cargo run --release --example profile_compressor -- testdata/alice29.txt

See benchmarking for profiling options and the C comparison.

For a parallel batch, all taken tasks must run or be dropped before finalization. For a streaming adapter, keep workers running while the adapter blocks. For writers, finish each resource and the enclosing stream or container explicitly.

Verify the documented recipes

python3 scripts/docs.py test-examples

This creates a temporary Cargo consumer under target/, depends on the local crate with framed enabled and on Rayon, and runs each fenced Rust program from the recipe guides: getting started, configuration, buffers, decompression, streaming, parallel, frames, Sans I/O compression/decompression, the I/O guide, and dictionaries. It does not exercise the codec with --all-features, because that would also enable no_std and remove the std APIs these recipes demonstrate.

Configuration and features

Three things are configured separately: the encoder (level and parameters), the stream (what you know about one input), and the Cargo features (which codecs and adapters are compiled in). Decoder settings are covered in decompression and limits.

Choose a level first

EncoderConfig::default() uses level 3. Levels run from -131072 to 22; negative levels trade size for speed, and level 0 is an alias for 3.

use mzstd::{Compressor, EncoderConfig, Level};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let config = EncoderConfig::default().with_level(Level::try_from(19)?);
    let mut encoder = Compressor::new(config)?;
    let compressed = encoder.compress(b"configured payload")?;
    assert!(!compressed.is_empty());
    Ok(())
}

Speed and size depend on your data. The benchmark pages show both for every measured level. Serial output is byte-identical to zstd 1.5.7 at every level, so compressed sizes match the reference.

Encoder settings

EncoderConfig holds settings shared by successive frames:

SettingType / methodBehavior
Compression levelLevel / with_level-131072–22; default 3; 0 aliases 3
Advanced compression parametersCompressionParams / with_paramsWindow, hash/chain/search logs, match length, target length, strategy
Long distance matchingLongDistanceMatching / with_long_distance_matchingAutomatic, disabled, or explicit LdmParams
Content checksumwith_checksumAdd a checksum to the frame
Frame formatwith_formatStandard or magicless
Target compressed block sizewith_target_compressed_block_sizeOptional NonZeroU32 target, like ZSTD_c_targetCBlockSize

Explicit parameters override the level as ZSTD_CCtx_setParameter does. Compressor::new validates parameter combinations. CParameter provides a validated parameter-by-ID interface for integrations; see OpenZL integration.

StreamConfig carries an InputSize: unknown by default, or InputSize::Exact(input.len() as u64) when the full length is known. Declared sizes are checked. Session scheduling is part of the byte-identity contract.

Explicit parameters, a checksum and long distance matching on top of a level:

use mzstd::{CompressionParams, Compressor, EncoderConfig, LdmParams, Level, LongDistanceMatching, Strategy};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let config = EncoderConfig::default()
        .with_level(Level::try_from(9)?)
        .with_checksum(true)
        .with_params(CompressionParams::default().with_window_log(24).with_strategy(Strategy::Lazy2))
        .with_long_distance_matching(LongDistanceMatching::Enabled(LdmParams::default()));
    let mut encoder = Compressor::new(config)?;
    assert!(!encoder.compress(b"tuned payload")?.is_empty());
    Ok(())
}

See dictionaries and extended formats for dictionaries, large windows, long distance matching and magicless frames.

Cargo features

The default feature set is std, compression, and decompression.

ConfigurationDependency
Default std encoder + decodermzstd = "0.2.13"
Encoder onlymzstd = { version = "0.2.13", default-features = false, features = ["std", "compression"] }
Decoder onlymzstd = { version = "0.2.13", default-features = false, features = ["std", "decompression"] }
Framing containersmzstd = { version = "0.2.13", features = ["framed"] }
Alloc-backed codecs without stdmzstd = { version = "0.2.13", default-features = false, features = ["no_std", "compression", "decompression"] }
FeatureDefaultEnables
compressionyesmzstd::compressor
decompressionyesmzstd::decompressor
stdyesstd::error::Error, mzstd::io, parallel compression
no_stdnoAlloc-only build; overrides std and removes std-only I/O and parallel APIs
framednomzstd::framing: seekable containers of resources and metadata. Its API may change in a patch release
hotpath, hotpath-cpu, hotpath-alloc, hotpath-callgrind, hotpath-cloudnoProfiling anchors; Callgrind instruction counts and hotpath Cloud upload

See codec features and no_std support for the gates and the checked combinations.

Not supported

  • External sequence producers
  • Legacy (v0.x) frames
  • Byte identity with zstd’s multithreaded mode
  • Dictionaries in parallel compression and the C ABI

Buffers and memory reuse

Every call involves two kinds of memory: the destination you hand to the codec, and the workspace the codec keeps for itself. You control both.

You want toUse
Get a fresh Vec<u8>compress / decompress
Append to a Vec you already owncompress_into / decompress_into
Write into a fixed slicecompress_to_slice / decompress_to_slice
Write into uninitialized memorycompress_to_uninit / decompress_to_uninit
Cap what the codec keeps between callsA retention policy

Output buffers

compress returns a new Vec. compress_into appends to a supplied vector and returns the range containing the new frame. Existing bytes remain intact and its original length is restored on failure.

compress_to_slice returns the number of bytes written. Allocate a conservative buffer using Compressor::max_compressed_size:

use mzstd::Compressor;

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let input = b"data to compress";
    let mut encoder = Compressor::default();
    let mut output = vec![0; Compressor::max_compressed_size(input.len())?];
    let written = encoder.compress_to_slice(input, &mut output)?;
    output.truncate(written);
    assert_eq!(output, encoder.compress(input)?);
    Ok(())
}

An undersized slice returns an error and can contain a partial prefix. Retry the complete operation with sufficient storage. compress_to_uninit and decompress_to_uninit accept caller-owned MaybeUninit<u8> destinations; read only the prefix reported as initialized. See decoder output contracts.

Reusing memory

Keep a compressor or decompressor across requests to reuse workspace. Caller output capacity is managed separately. A retention policy controls storage kept between operations:

Retention policyEffect
Aggressive (default)Keeps allocated buffers
Bounded { max_bytes }Releases storage when retained accounting exceeds the ceiling
ReleaseAllReleases storage after an operation

Use Compressor::builder(config).with_retention(policy).build() or the decoder builder, and trim(policy) for immediate cleanup. retained_bytes() excludes caller output and allocator overhead. A retention ceiling is not a peak memory limit. Use a separate codec per simultaneous independent operation, or parallel compression to split one input across workers.

Decompression and limits

Decompressor owns reusable state. decompress returns a Vec, decompress_into appends a result, and decompress_to_slice writes into caller storage. Sessions report consumed/produced counts; reader and writer adapt synchronous I/O. Construction is infallible: Decompressor::new(config).

Set limits for untrusted input

The default accepts concatenated Zstandard frames and skips skippable frames. Content checksums are always verified. The default window limit is 128 MiB; input and output byte budgets are unlimited. Set limits for your application:

use mzstd::{DecodeLimits, DecoderConfig, Decompressor};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let limits = DecodeLimits::default()
        .with_max_window_log(23)?
        .with_max_input_bytes(Some(1 << 20))
        .with_max_output_bytes(Some(8 << 20));
    let mut decoder = Decompressor::new(DecoderConfig::default().with_limits(limits));
    let frame = mzstd::Compressor::default().compress(b"bounded payload")?;
    assert_eq!(decoder.decompress(&frame)?, b"bounded payload");
    Ok(())
}

The window log accepts 10–31. Input/output budgets count the whole operation, including concatenated frames; they are not process-memory ceilings. FrameMode::Single stops a session after one Zstandard or skippable frame. One-shot decoding rejects trailing bytes in that mode. FrameHeaderInfo inspects headers without decoding the payload. See decoder mechanics.

Where to go next

TaskRead
Decode chunks as they arriveSans I/O decompression
Decode from a Read source or into a Write sinkRead / Write I/O
Decode a framing containerFraming containers
Attach a dictionaryDictionaries and extended formats

Streaming and completion

Use streaming when the payload does not fit in memory, arrives in pieces, or must leave before the input ends. There are three shapes, and they drive the same state machine:

ShapeYou callThe library doesRecipes
Readerread on the adapterPulls source bytes and yields transformed bytesRead / Write I/O
Writerwrite_all, then finishPushes transformed bytes into your sinkRead / Write I/O
Sessionprocess with your own buffersNo I/O; reports consumed and produced countsSans I/O compression and decompression

Readers and writers require std. Sessions fit blocking code, async runtimes and event loops.

Readers and writers

encoder.reader(source, stream) pulls plain input and yields compressed bytes. encoder.writer(sink, stream) accepts plain input and delivers compressed bytes. Decoder adapters take just the source or sink: decoder.reader(source) and decoder.writer(sink). Their frame mode and limits come from DecoderConfig.

Read a reader to EOF. Call finish on a writer: dropping it performs no finalization. Encoder flush creates an intermediate decoding boundary and can change output size; decoder flush drains output without declaring final input. try_finish supports retrying a sink failure. Consuming finish returns the sink or a FinishError; into_parts recovers the error and writer, while into_error discards the writer. Reader into_parts preserves source read-ahead.

Sessions

For direct control, encoder.start(stream) creates a borrowed EncoderSession. decoder.start() creates a DecoderSession. Neither performs I/O.

Operation / statusMeaning
Operation::ContinueAccept input without ending the frame
Operation::FlushMake accepted input decodable without ending the frame
Operation::EndEnd the frame after this input
EncoderStatus::NeedsInputSupply more input or explicit flush/finish
EncoderStatus::NeedsOutputSupply output space and retain unconsumed input
EncoderStatus::FinishedAll final output has been delivered

Advance input by Progress::consumed and deliver only output[..produced]. Finish can take several calls. Decoder sessions use DecodeOperation and DecoderStatus; use Finish when no more compressed input can arrive. Owned session variants move the codec into a session without a borrowed-owner lifetime. See Sans I/O compression and decompression for complete loops.

Read / Write I/O

Use std adapters to connect the codecs to files, network streams, in-memory buffers, and other implementations of Read or Write. Adapters drive the same sessions as the Sans I/O APIs, adding buffering and synchronous I/O conventions.

API map

OperationPull bytes through a readerPush bytes through a writer
Raw compressionCompressor::readerCompressor::writer
Raw decompressionDecompressor::readerDecompressor::writer
Parallel compressionParallelCompressor::reader + task queueParallelCompressor::writer + task queue
Decompress parallel outputDecompressor::readerDecompressor::writer
Framed compression · opt-inFramedCompressor::framed_reader over complete borrowed resource inputsFramedCompressor::framed_writer with resource writers
Framed decompression · opt-inFramedDecompressor::framed_reader → events; framed_seek_reader → selected resource ReadDrive framed decoder events and write payload to your sink

The library has no dedicated framed decoder Write adapter. Its event API keeps resource boundaries and metadata visible; forward each payload event to an appropriate Write destination. It also has no parallel decoder: parallel compression produces one ordinary raw Zstandard stream.

All adapters on this page require std. Enable framed for framing. no_std removes the std surface even when std is also enabled.

Raw compression and decompression through Read

An encoder reader consumes plain bytes and yields compressed bytes. A decoder reader consumes compressed bytes and yields restored bytes. std::io::copy drives each one to EOF. Use a File or socket in place of a slice.

use mzstd::{Compressor, Decompressor, EncoderConfig, InputSize, Level};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let input = b"pull plain bytes through the encoder\n".repeat(100);
    let mut encoder = Compressor::new(EncoderConfig::default().with_level(Level::try_from(3)?))?;
    let stream = InputSize::Exact(input.len() as u64).into();
    let mut reader = encoder.reader(input.as_slice(), stream);
    let mut compressed = Vec::new();
    std::io::copy(&mut reader, &mut compressed)?;
    drop(reader);

    let mut decoder = Decompressor::default();
    let mut reader = decoder.reader(compressed.as_slice());
    let mut restored = Vec::new();
    std::io::copy(&mut reader, &mut restored)?;
    assert_eq!(restored, input);
    Ok(())
}

When the total input size is unknown, use the default stream configuration for the encoder. Reading a decoder to EOF is necessary to observe final validation errors. A raw decoder reader can read past a frame boundary in FrameMode::Single; recover its source and buffered suffix with into_parts when another protocol message follows.

Raw compression and decompression through Write

An encoder writer accepts plain bytes; a decoder writer accepts compressed bytes. Each owns its sink and borrows its codec owner. Finalize both writers.

use mzstd::{Compressor, Decompressor, EncoderConfig, Level};
use std::io::Write;

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let input = b"push input into a writer\n".repeat(100);
    let mut encoder = Compressor::new(EncoderConfig::default().with_level(Level::try_from(3)?))?;
    let mut writer = encoder.writer(Vec::new(), Default::default());
    for chunk in input.chunks(13) {
        writer.write_all(chunk)?;
    }
    let compressed = writer
        .finish()
        .map_err(mzstd::io::FinishError::into_error)?;

    let mut decoder = Decompressor::default();
    let mut writer = decoder.writer(Vec::new());
    for chunk in compressed.chunks(3) {
        writer.write_all(chunk)?;
    }
    let restored = writer
        .finish()
        .map_err(mzstd::io::FinishError::into_error)?;
    assert_eq!(restored, input);
    Ok(())
}

A decoder writer’s finish checks that input ends at a valid stream boundary; flush only drains output and flushes the sink. Encoder flush also creates an intermediate decoding boundary and can change compressed size. Dropping a writer does not finalize or flush it.

Parallel compression through Read and Write

Both constructors return an adapter and a task queue. Run that queue on independent workers while using the adapter; reads, writes, and finish can block waiting for tasks. A queue with no running executor cannot make progress.

This reader example compresses across scoped threads, then uses an ordinary decoder reader to restore the assembled raw stream:

use mzstd::compressor::parallel::{ParallelCompressor, ParallelConfig, TaskCount};
use mzstd::{Decompressor, EncoderConfig, Level};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let input = b"parallel reader with caller-owned workers\n".repeat(220_000);
    let mut encoder = ParallelCompressor::new(
        EncoderConfig::default().with_level(Level::try_from(3)?),
        ParallelConfig::default(),
    )?;
    let mut compressed = Vec::new();
    std::thread::scope(|scope| -> Result<(), Box<dyn std::error::Error>> {
        let (mut reader, queue) = encoder.reader(
            input.as_slice(),
            Default::default(),
            TaskCount::try_from(4)?,
        );
        for _ in 0..4 {
            let worker_queue = queue.clone();
            scope.spawn(move || worker_queue.into_iter().for_each(|task| task.run()));
        }
        drop(queue);
        std::io::copy(&mut reader, &mut compressed)?;
        Ok(())
    })?;

    let mut decoder = Decompressor::default();
    let mut reader = decoder.reader(compressed.as_slice());
    let mut restored = Vec::new();
    std::io::copy(&mut reader, &mut restored)?;
    assert_eq!(restored, input);
    Ok(())
}

For the writer direction, the complete parallel writer recipe copies a source into the returned writer, then finishes it while workers keep running. The task window bounds outstanding segments; the parallel writer’s flush ends the current segment early. Decode its output with either raw adapter shown above. See parallel output and backpressure.

Framed compression through readers and resource writers

framed_reader encodes a complete borrowed FramedInput into a Read stream. It does not accept resources that arrive later. Use framed_writer when input arrives gradually: start a resource, write its payload, finish it, release its borrow, then start another resource. Finish the enclosing container last.

The framed writer recipe demonstrates compressed and uncompressed resource writers and metadata. Here is the reader direction, paired with sequential event decoding:

use mzstd::framing::{
    FramedCompressor, FramedDecompressor, FramedEvent, FramedInput, FramedItem, FramedResource,
};
use std::io::BufReader;

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let input = b"a resource exposed as an encoded reader";
    let items = [FramedItem::Resource(FramedResource::from(input.as_slice()))];
    let mut encoder = FramedCompressor::new(Default::default())?;
    let mut encoded =
        encoder.framed_reader(FramedInput::from(items.as_slice()), Default::default())?;
    let mut container = Vec::new();
    std::io::copy(&mut encoded, &mut container)?;
    drop(encoded);

    let mut decoder = FramedDecompressor::new(Default::default())?;
    let source = BufReader::new(container.as_slice());
    let mut reader = decoder.framed_reader(source, Default::default())?;
    let mut restored = Vec::new();
    let mut resources = 0;
    while let Some(event) = reader.next_event()? {
        match event {
            FramedEvent::ResourceStart(_) => resources += 1,
            FramedEvent::ResourceData(data) => restored.extend_from_slice(data.bytes),
            _ => {}
        }
    }
    assert!(reader.is_finished());
    assert_eq!(resources, 1);
    assert_eq!(restored, input);
    Ok(())
}

Framed decompression into a Write sink

FramedReader lends events over a BufRead source. Wrap an ordinary Read in BufReader. For each ResourceData event, send data.bytes to your sink with write_all; use ResourceStart to select the destination for that resource. Continue until next_event returns None, after complete validation.

use mzstd::framing::{
    FramedCompressor, FramedDecompressor, FramedEvent, FramedInput, FramedItem, FramedResource,
};
use std::io::{BufReader, Write};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let items = [
        FramedItem::Resource(FramedResource::from(b"first".as_slice())),
        FramedItem::Resource(FramedResource::from(b"second".as_slice())),
    ];
    let mut encoder = FramedCompressor::new(Default::default())?;
    let container = encoder.compress(FramedInput::from(items.as_slice()))?;
    let mut decoder = FramedDecompressor::new(Default::default())?;
    let mut reader =
        decoder.framed_reader(BufReader::new(container.as_slice()), Default::default())?;
    let mut sinks: Vec<Vec<u8>> = Vec::new();
    while let Some(event) = reader.next_event()? {
        match event {
            FramedEvent::ResourceStart(_) => sinks.push(Vec::new()),
            FramedEvent::ResourceData(data) => {
                let sink = sinks.last_mut().ok_or("payload without a resource")?;
                sink.write_all(data.bytes)?;
            }
            _ => {}
        }
    }
    assert!(reader.is_finished());
    assert_eq!(sinks, vec![b"first".to_vec(), b"second".to_vec()]);
    Ok(())
}

This recipe uses in-memory sinks; a resource router can instead choose files or other writers. If a sink fails after an event was read, retain its unwritten suffix before advancing to the next event. FramedReader::partial_output exposes payload produced on a failing reader call. Keep protocol bytes after the container by recovering the buffered source with into_inner.

For Read + Seek, use indexed resource reads: framed_seek_reader opens the directory and resource(index) returns a ResourceReader implementing Read, suitable for std::io::copy. For push-driven compressed input, use the native framed decoder and route the same resource events to sinks.

Completion, retries, and ownership

AdapterComplete operationRecover after a failure
Raw encoder / decoder readerRead to EOFDecoder into_parts preserves read-ahead; codec errors are terminal
Raw encoder / decoder writerfinish or retryable try_finishFinishError::into_parts returns error and writer
Parallel readerRead to EOF with active workersinto_parts preserves read-ahead; keep queue execution alive
Parallel writerfinish with active workersFinishError::into_parts returns error and adapter
Framed encoded readerRead to EOFDropping early abandons that container operation
Framed writerEach resource try_finish, then container finishFramingFinishError exposes writer and error
Framed event readernext_event until NoneInspect partial_output; into_inner preserves buffered source suffix

Short sink writes and errors must not cause accepted input to be replayed. The writer adapters retain pending output, and a later call may report an error after an earlier call already accepted input. WouldBlock is not EOF. If you need explicit readiness handling, use native sessions and drive them when your runtime says buffers are ready. See streaming completion and framed retry mechanics.

Sans I/O compression

Use a session when your application owns the input/output buffers and decides when more bytes are available. Sessions perform no I/O. This works in a blocking loop, an async task, or a custom event loop without an I/O adapter.

Output formatSession entry pointScheduling
Raw ZstandardCompressor::start(stream)Call process yourself
One raw stream encoded across workersParallelCompressor::start(stream, window)Drive process and run its task queue
Framed resources · opt-inFramedCompressor::start(stream)Drain container output, drive resource guards, finish container

For transport integration, see Read / Write I/O. For the inverse operation, see Sans I/O decompression.

Drive a raw encoder

Supply chunks with Operation::Continue. After the last chunk, call End until EncoderStatus::Finished. An empty input passed with Continue is not EOF. The deliberately small output buffer below forces repeated output drains.

use mzstd::{Compressor, Decompressor, EncoderConfig, EncoderStatus, InputSize, Level, Operation};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let input = b"incremental encoding with tiny buffers\n".repeat(100);
    let config = EncoderConfig::default().with_level(Level::try_from(3)?);
    let mut encoder = Compressor::new(config)?;
    let mut compressed = Vec::new();
    let mut output = [0; 11];
    {
        let mut session = encoder.start(InputSize::Exact(input.len() as u64).into());
        for chunk in input.chunks(7) {
            let mut remaining = chunk;
            loop {
                let progress = session.process(remaining, &mut output, Operation::Continue)?;
                remaining = &remaining[progress.consumed..];
                compressed.extend_from_slice(&output[..progress.produced]);
                if progress.status == EncoderStatus::NeedsInput {
                    assert!(remaining.is_empty());
                    break;
                }
            }
        }
        loop {
            let progress = session.process(&[], &mut output, Operation::End)?;
            compressed.extend_from_slice(&output[..progress.produced]);
            if progress.status == EncoderStatus::Finished {
                break;
            }
        }
    }
    let mut decoder = Decompressor::default();
    assert_eq!(decoder.decompress(&compressed)?, input);
    Ok(())
}

Always advance input by consumed and publish only output[..produced] before handling status. NeedsOutput requires output space and the unconsumed input suffix; reaching the end of the current input slice alone does not finish the stream.

Flush and finish are different

Operation::Flush makes accepted bytes decodable without ending the stream. Drain it to NeedsInput before changing operation or supplying new input. Flush boundaries can change output size and bytes. End ends the stream; while retrying it, keep the same operation and exact unconsumed input suffix.

Use InputSize::Exact when the total input size is known. Otherwise use the default unknown size; the encoder uses streaming parameter resolution. See stream scheduling and identity.

Drive parallel input

ParallelCompressor::start(stream, window) returns (session, queue). Keep workers running the queue while you feed input and drain output. The session’s process does not block: ParallelEncoderStatus::Pending means progress depends on tasks. Call session.wait() with independently runnable workers, or execute queued tasks yourself before waiting on a single thread.

The complete Sans I/O parallel example handles all statuses and source chunks. Its window bounds queued, running, and not-yet-delivered segments. With the exact input size declared and no flushes, output matches a batch over the same input and segment configuration. See parallel session mechanics.

Drive a framed encoder

Enable framed. Container process takes an output buffer and a FramedEncodeOperation; resource process takes payload, output, and an ordinary Operation. Finish the resource before finishing the container.

use mzstd::framing::{
    FramedCompressor, FramedDecompressor, FramedEncodeConfig, FramedEncodeOperation,
    FramedEncoderStatus, FramingConfig,
};
use mzstd::{EncoderConfig, Level, Operation};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let input = b"framed incremental payload\n".repeat(20);
    let config = FramedEncodeConfig::default()
        .with_encoder_config(EncoderConfig::default().with_level(Level::try_from(3)?))
        .with_framing_config(FramingConfig {
            chunk_bytes: 32,
            ..Default::default()
        });
    let mut encoder = FramedCompressor::new(config)?;
    let mut session = encoder.start(Default::default())?;
    let mut buffer = [0; 13];
    let mut container = Vec::new();
    // Drain the main header before accepting a resource command.
    loop {
        let progress = session.process(&mut buffer, FramedEncodeOperation::Process)?;
        container.extend_from_slice(&buffer[..progress.produced]);
        if progress.status == FramedEncoderStatus::NeedsInput {
            break;
        }
    }
    {
        let mut resource = session.resource(Default::default(), Default::default())?;
        let mut chunks = input.chunks(9).peekable();
        while let Some(chunk) = chunks.next() {
            let operation = if chunks.peek().is_none() {
                Operation::End
            } else {
                Operation::Continue
            };
            let mut remaining = chunk;
            loop {
                let progress = resource.process(remaining, &mut buffer, operation)?;
                remaining = &remaining[progress.consumed..];
                container.extend_from_slice(&buffer[..progress.produced]);
                if progress.status == FramedEncoderStatus::Finished
                    || progress.status == FramedEncoderStatus::NeedsInput
                {
                    assert!(remaining.is_empty());
                    break;
                }
            }
        }
        assert!(resource.is_finished());
    }
    loop {
        let progress = session.process(&mut buffer, FramedEncodeOperation::Finish)?;
        container.extend_from_slice(&buffer[..progress.produced]);
        if progress.status == FramedEncoderStatus::Finished {
            break;
        }
    }
    let mut decoder = FramedDecompressor::new(Default::default())?;
    assert_eq!(decoder.decompress(&container)?.resources[0].data, input);
    Ok(())
}

The example marks the last nonempty chunk with End; for an empty resource, drive resource.process(&[], output, Operation::End) to Finished too. Metadata and new resource commands require previous container output to be drained; otherwise they return OutputPending without committing the command. See resource lifecycles and metadata.

Ownership and cancellation

Borrowed sessions exclusively borrow their codec owner. Dropping an unfinished session cancels it; dropping an unfinished framed resource abandons its container. A forgotten framed session requires owner recover(). No drop performs transport I/O. Owned session variants move the codec into the session when a lifetime-free holder fits your application better. See owned sessions.

Sans I/O decompression

Feed compressed input and supply writable output space as it becomes available. Sessions own decoding state and retain no caller input/output slices between calls. DecodeOperation::Process allows more input; Finish declares the final input boundary and enables truncation checks.

InputDecoder
Ordinary raw ZstandardDecompressor::start
Output of ParallelCompressorThe same Decompressor::start
Framing container · opt-inFramedDecompressor::start

There is no separate parallel decoder. Parallel compression assembles a standard raw stream, decoded through the ordinary decoder APIs. For transport adapters, see Read / Write I/O.

Decode a raw stream in chunks

This example supplies three compressed bytes at a time, uses a tiny output buffer, and declares Finish only for the last input chunk.

use mzstd::{Compressor, DecodeOperation, DecoderStatus, Decompressor, EncoderConfig, Level};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let input = b"incremental decoding preserves every byte\n".repeat(100);
    let mut encoder = Compressor::new(EncoderConfig::default().with_level(Level::try_from(3)?))?;
    let compressed = encoder.compress(&input)?;
    let mut decoder = Decompressor::default();
    let mut session = decoder.start();
    let mut chunks = compressed.chunks(3).peekable();
    let mut output = [0; 11];
    let mut restored = Vec::new();
    let mut finished = false;
    while let Some(chunk) = chunks.next() {
        let operation = if chunks.peek().is_none() {
            DecodeOperation::Finish
        } else {
            DecodeOperation::Process
        };
        let mut remaining = chunk;
        loop {
            let progress = session.process(remaining, &mut output, operation)?;
            remaining = &remaining[progress.consumed..];
            restored.extend_from_slice(&output[..progress.produced]);
            match progress.status {
                DecoderStatus::NeedsInput => {
                    assert!(remaining.is_empty());
                    break;
                }
                DecoderStatus::NeedsOutput => {}
                DecoderStatus::Finished => {
                    assert!(remaining.is_empty());
                    finished = true;
                    break;
                }
            }
        }
        if finished {
            break;
        }
    }
    assert!(finished);
    assert_eq!(restored, input);
    Ok(())
}

In an application where EOF is learned from a later read, offer chunks with Process and then call process(&[], output, Finish) until Finished. After the first Finish, continue with Finish and exactly the remaining input suffix. Do not add new bytes or switch back to Process.

NeedsOutput can occur after every input byte has been consumed: deliver the output and call again with an empty input slice and fresh output space. The default FrameMode::Concatenated waits for final input. For protocols with bytes after one frame, select FrameMode::Single and preserve unconsumed input when Finished arrives. The example asserts an empty suffix because its input contains exactly one complete member.

Decode a framing container as events

With framed, a framed session reports resource boundaries, metadata, payload fragments, directory entries, and a validated footer. Handle events before mutating the session again: events can borrow the session and output.

use mzstd::DecodeOperation;
use mzstd::framing::{
    FramedCompressor, FramedDecoderStatus, FramedDecompressor, FramedEvent, FramedInput,
    FramedItem, FramedResource,
};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let input = b"resource decoded one fragment at a time";
    let items = [FramedItem::Resource(FramedResource::from(input.as_slice()))];
    let mut encoder = FramedCompressor::new(Default::default())?;
    let container = encoder.compress(FramedInput::from(items.as_slice()))?;
    let mut decoder = FramedDecompressor::new(Default::default())?;
    let mut session = decoder.start(Default::default())?;
    let mut chunks = container.chunks(3).peekable();
    let mut buffer = [0; 5];
    let mut restored = Vec::new();
    let mut resources = 0;
    let mut finished = false;
    while let Some(chunk) = chunks.next() {
        let operation = if chunks.peek().is_none() {
            DecodeOperation::Finish
        } else {
            DecodeOperation::Process
        };
        let mut remaining = chunk;
        loop {
            let progress = session.process(remaining, &mut buffer, operation)?;
            remaining = &remaining[progress.consumed..];
            match progress.status {
                FramedDecoderStatus::Event(FramedEvent::ResourceStart(_)) => resources += 1,
                FramedDecoderStatus::Event(FramedEvent::ResourceData(data)) => {
                    restored.extend_from_slice(data.bytes);
                }
                FramedDecoderStatus::NeedsInput => {
                    assert!(remaining.is_empty());
                    break;
                }
                FramedDecoderStatus::Finished => {
                    assert!(remaining.is_empty());
                    finished = true;
                    break;
                }
                _ => {}
            }
        }
        if finished {
            break;
        }
    }
    assert!(finished);
    assert_eq!(resources, 1);
    assert_eq!(restored, input);
    Ok(())
}

ResourceDataEnd ends one resource’s payload; it does not validate the rest of the container. Continue through Finished to validate remaining structure. The recipe has one resource; for multiple resources, route ResourceData to the destination selected by ResourceStart rather than concatenating payloads. See framed event semantics.

Limits and errors

Set input, output, and window limits in DecoderConfig before starting a raw decoder. For framing, use FramedDecodeConfig and its framing limits in addition to raw decoder limits. See resource-limit configuration.

Output delivered before a later error is partial and has not passed complete validation. Failure values carry consumed/produced counts for that failing call; account for them if your transport needs exact progress. Drop a failed session before starting a new operation. Finish reports truncated streams; an empty Process call does not declare EOF.

Parallel compression

mzstd::compressor::parallel divides a known input into fixed independent segments and assembles them into one standard Zstandard frame. It supports levels -131072–22. Dictionaries, magicless frames, and framing containers are unsupported by this API. Output is deterministic for fixed input and segment settings, but differs from serial compression and zstd’s multithreaded mode.

The caller runs the tasks using scoped threads, Rayon, or another executor. The library does not create a thread pool.

Compressing a slice

use mzstd::compressor::parallel::{ParallelCompressor, ParallelConfig, TaskCount};
use mzstd::{Decompressor, EncoderConfig, Level};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let input = vec![b'a'; 8 << 20];
    let mut encoder = ParallelCompressor::new(
        EncoderConfig::default().with_level(Level::try_from(3)?),
        ParallelConfig::default(),
    )?;
    let mut batch = encoder.prepare_slice(&input, TaskCount::try_from(4)?);
    let tasks = batch.take_tasks();
    std::thread::scope(|scope| {
        for task in tasks {
            scope.spawn(move || task.run());
        }
    });
    let mut output = Vec::new();
    batch.finish_into(&mut output)?;
    let mut decoder = Decompressor::default();
    assert_eq!(decoder.decompress(&output)?, input);
    Ok(())
}

take_tasks transfers each task once. Every taken task must run or be dropped so its completion can be reported. Join workers before finalization: it collects already reported results, checks the source, and assembles the frame in input order. A missing completion returns InvalidState.

Running tasks with Rayon

Add rayon = "1.12" alongside mzstd in your dependencies. Create a pool once and reuse it across requests. Task completion order does not affect assembly order or the resulting bytes for fixed segment settings.

use mzstd::compressor::parallel::{ParallelCompressor, ParallelConfig, TaskCount};
use mzstd::{Decompressor, EncoderConfig, Level};
use rayon::prelude::*;

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let pool = rayon::ThreadPoolBuilder::new().num_threads(4).build()?;
    let mut encoder = ParallelCompressor::new(
        EncoderConfig::default().with_level(Level::try_from(3)?),
        ParallelConfig::default(),
    )?;
    let input = b"parallel compression with a reusable pool\n".repeat(220_000);
    let mut batch = encoder.prepare_slice(&input, TaskCount::try_from(4)?);
    let tasks = batch.take_tasks();
    pool.install(|| tasks.into_par_iter().for_each(|task| task.run()));

    let mut compressed = Vec::new();
    batch.finish_into(&mut compressed)?;
    let mut decoder = Decompressor::default();
    assert_eq!(decoder.decompress(&compressed)?, input);
    Ok(())
}

For many unrelated small inputs, another option is one ordinary Compressor per worker. That preserves serial compression decisions for each input. The batch API above splits one input into a single assembled stream.

Streaming input

When input arrives in chunks, start(stream, window) returns a session and a task queue. Each full segment the session accepts becomes a task on that queue; clone the queue for each executor and run for task in queue { task.run() }. The session delivers finished parts in input order while later segments are still encoding, and closes the queue when the stream finishes, fails, or is dropped.

use mzstd::compressor::parallel::{
    ParallelCompressor, ParallelConfig, ParallelEncoderStatus, TaskCount,
};
use mzstd::{EncoderConfig, Level, Operation};
use std::io::{Read, Write};

fn compress(
    mut reader: impl Read,
    mut writer: impl Write,
) -> Result<(), Box<dyn std::error::Error>> {
    let mut encoder = ParallelCompressor::new(
        EncoderConfig::default().with_level(Level::try_from(3)?),
        ParallelConfig::default(),
    )?;
    std::thread::scope(|scope| {
        let (mut session, queue) = encoder.start(Default::default(), TaskCount::try_from(4)?);
        for _ in 0..4 {
            let queue = queue.clone();
            scope.spawn(move || queue.into_iter().for_each(|task| task.run()));
        }
        drop(queue);
        let mut chunk = vec![0; 1 << 16];
        let mut output = vec![0; 1 << 16];
        loop {
            let read = reader.read(&mut chunk)?;
            let operation = if read == 0 {
                Operation::End
            } else {
                Operation::Continue
            };
            let mut input = &chunk[..read];
            loop {
                let progress = session.process(input, &mut output, operation)?;
                input = &input[progress.consumed..];
                writer.write_all(&output[..progress.produced])?;
                match progress.status {
                    ParallelEncoderStatus::Finished => return Ok(()),
                    ParallelEncoderStatus::Pending => session.wait()?,
                    ParallelEncoderStatus::NeedsInput if input.is_empty() => break,
                    _ => {}
                }
            }
        }
    })
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let input = b"incremental parallel input\n".repeat(350_000);
    let mut compressed = Vec::new();
    compress(input.as_slice(), &mut compressed)?;
    let mut decoder = mzstd::Decompressor::default();
    assert_eq!(decoder.decompress(&compressed)?, input);
    Ok(())
}
  • process never blocks. Pending means the window is full or a flush or finish waits for queued tasks; call wait, which blocks until one completes.
  • window bounds the segments queued, running or awaiting delivery, and so the session’s memory. A full window stops input from being consumed.
  • With the exact input size declared and no flushes, the output is byte-identical to prepare_slice over the same bytes, however the input was chunked. Operation::Flush ends the current segment early, which moves later segment boundaries.
  • On a single thread, run queue.try_recv() tasks before wait; nothing else will. Dropping every queue handle fails the stream instead of hanging.
  • into_session(stream, window) returns an owned session without a lifetime; into_compressor returns the compressor.

Reader and writer adapters

writer(sink, stream, window) and reader(source, stream, window) wrap the same session in std::io::Write and std::io::Read and return the task queue beside the adapter, so the loop above becomes std::io::copy:

use mzstd::compressor::parallel::{ParallelCompressor, ParallelConfig, TaskCount};
use mzstd::io::FinishError;
use mzstd::{EncoderConfig, Level};
use std::io::{Read, Write};

fn compress<W: Write>(mut reader: impl Read, writer: W) -> Result<W, Box<dyn std::error::Error>> {
    let mut encoder = ParallelCompressor::new(
        EncoderConfig::default().with_level(Level::try_from(3)?),
        ParallelConfig::default(),
    )?;
    std::thread::scope(|scope| {
        let (mut sink, queue) = encoder.writer(writer, Default::default(), TaskCount::try_from(4)?);
        for _ in 0..4 {
            let queue = queue.clone();
            scope.spawn(move || queue.into_iter().for_each(|task| task.run()));
        }
        drop(queue);
        std::io::copy(&mut reader, &mut sink)?;
        Ok(sink.finish().map_err(FinishError::into_error)?)
    })
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let input = b"stream through caller-owned workers\n".repeat(250_000);
    let compressed = compress(input.as_slice(), Vec::new())?;
    let mut decoder = mzstd::Decompressor::default();
    assert_eq!(decoder.decompress(&compressed)?, input);
    Ok(())
}
  • Unlike process, the adapters block: a write waits while the window is full, a read waits for the next part, and finish waits for every part. Run the queue on threads other than the one using the adapter.
  • With the exact input size declared and no flushes, output matches prepare_slice. The writer’s flush is Operation::Flush: it makes everything written decodable and moves later segment boundaries.
  • The writer keeps every compressed byte until the sink takes it, so short writes, WouldBlock and sink errors lose nothing, and finish hands the adapter back inside FinishError for a retry, as EncoderWriter does. reader.into_parts() returns source bytes read ahead of the session.
  • Session failures arrive as std::io::Error with the ParallelEncodeError as their source. Dropping an adapter abandons the stream and closes the queue.

Segments and compressed size

Segment size defaults to 4 MiB and accepts 64 KiB–256 MiB. Effective task count is at most the number of segments. Task grouping and completion order do not change output bytes.

Segments are primed with preceding input: an eighth of the window for most strategies, or the full window for BtUltra and BtUltra2. The overlap is not capped by segment size. Long distance matching runs within each segment. Compression can be worse than serial compression on data whose useful history exceeds that overlap; compare compressed size as well as elapsed time. An input that fits in one segment uses serial encoding and matches Compressor.

Sources and staging

InputEntry point
Borrowed sliceprepare_slice(input, tasks)
Regular fileprepare_source(FileSource::open(path)?, tasks)
Owned seekable readerprepare_source(SeekSource::from(reader), tasks)
Custom random-access sourceprepare_source(source, tasks) with RandomAccessSource

tasks is a validated TaskCount. FileSource uses positional reads on Unix and Windows. SeekSource accepts Read + Seek + Send, serializes seek/read operations, and lets compression run concurrently. Sources must remain immutable for the batch lifetime; identity checks cannot detect every in-place mutation.

Owned sources and shared handles use the same generic entry point. For an existing Arc<FileSource>, type inference can require prepare_source::<FileSource, _>(shared_file, tasks).

Batch output is staged in memory. Use streaming sessions and their bounded window when the complete input or compressed output should not be retained.

Output and cancellation

finish returns a new vector. finish_into appends a frame and restores the vector’s original length on failure. Write the completed frame to a sink or use the streaming writer; the caller controls flushing, durability, and publication.

Every taken task must run or be dropped, and workers must finish before batch finalization. The batch does not wait for detached tasks. Dropping a batch abandons assembly without joining detached workers. Worker storage can be reused by the parallel owner. See parallel mechanics for lifecycle, source checks, memory, cancellation, and error propagation.

Framing containers

Package multiple resources, metadata, and optional dictionary references in an opt-in Zstandard container. This is useful when an application needs resource boundaries or indexed access in addition to compression.

Enable framing

[dependencies]
mzstd = { version = "0.2.13", features = ["framed"] }

The examples use std, compression, and decompression. Framing APIs may change in a patch release. Use FramedDecompressor to preserve resources and metadata. An ordinary Zstandard decoder extracts the concatenated visible resources and skips the container metadata, hidden resources, directory, and footer.

ConceptMeaning
ContainerThe enclosing structure, with resources and optional metadata/directory/footer
ResourceOne logical payload, compressed or stored verbatim
ChunkA bounded portion of a resource on the wire; each visible compressed chunk is a standard Zstandard frame
DirectoryOptional index used for seeking to selected resources

The library’s parallel compressor emits one raw Zstandard stream. It does not emit framing containers. See parallel compression when the goal is compressing one input across cores.

One container, two resources

Use borrowed inputs when all resource bytes are available. The first resource below is Zstandard-compressed; the second is explicitly stored without compression.

use mzstd::framing::{
    FramedCompressor, FramedDecompressor, FramedEncodeConfig, FramedInput, FramedItem,
    FramedResource, ResourceEncoding,
};
use mzstd::{EncoderConfig, Level};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let text = b"a document with repeated content\n".repeat(1000);
    let binary = [0x91, 0x02, 0x7f, 0x00];
    let mut stored = FramedResource::from(binary.as_slice());
    stored.encoding = ResourceEncoding::Uncompressed;
    let items = [
        FramedItem::Resource(FramedResource::from(text.as_slice())),
        FramedItem::Resource(stored),
    ];

    let config = FramedEncodeConfig::default()
        .with_encoder_config(EncoderConfig::default().with_level(Level::try_from(3)?));
    let mut encoder = FramedCompressor::new(config)?;
    let container = encoder.compress(FramedInput::from(items.as_slice()))?;

    let mut decoder = FramedDecompressor::new(Default::default())?;
    let decoded = decoder.decompress(&container)?;
    assert_eq!(decoded.resources.len(), 2);
    assert_eq!(decoded.resources[0].data, text);
    assert_eq!(decoded.resources[1].data, binary);
    Ok(())
}

compress_into appends a container to a reusable Vec; compress_to_slice writes into caller storage. The owner can be reused for later containers. The framed compressor specification describes size contracts, retention, and native incremental output APIs.

Write resources and metadata

Use a writer when resource input arrives gradually. Resource metadata precedes the resource it describes. Finish each resource before starting the next one, then finish the container.

use mzstd::framing::{
    FramedCompressor, FramedDecompressor, FramedEncodeConfig, FramingConfig, MetadataField,
    MetadataKind,
};
use mzstd::{EncoderConfig, Level};
use std::io::Write;

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let config = FramedEncodeConfig::default()
        .with_encoder_config(EncoderConfig::default().with_level(Level::try_from(3)?))
        .with_framing_config(FramingConfig {
            chunk_bytes: 4096,
            ..Default::default()
        });
    let mut encoder = FramedCompressor::new(config)?;
    let mut writer = encoder.framed_writer(Vec::new(), Default::default())?;
    writer.metadata(
        MetadataKind::Resource,
        &[MetadataField {
            code: *b"id",
            value: b"notes.txt",
        }],
    )?;
    {
        let mut resource = writer.resource(Default::default(), Default::default())?;
        resource.write_all(b"first part\n")?;
        resource.write_all(b"second part\n")?;
        resource.try_finish()?;
    }
    {
        let mut resource = writer.uncompressed_resource(Default::default())?;
        resource.write_all(&[1, 2, 3, 4])?;
        resource.try_finish()?;
    }
    let container = writer.finish().map_err(|failure| failure.error)?;

    let mut decoder = FramedDecompressor::new(Default::default())?;
    let decoded = decoder.decompress(&container)?;
    assert_eq!(decoded.resources[0].data, b"first part\nsecond part\n");
    assert_eq!(
        decoded.resources[0]
            .metadata
            .as_ref()
            .and_then(|m| m.name()),
        Some("notes.txt")
    );
    assert_eq!(decoded.resources[1].data, [1, 2, 3, 4]);
    Ok(())
}

Neither resource drop nor container drop performs I/O. Dropping an unfinished resource abandons its container. try_finish supports retrying resource finalization; a failed consuming container finish retains the writer in FramingFinishError for recovery through its writer and error fields. The in-memory example above keeps the error and discards the writer. See the retry state machine.

Metadata id is a caller-provided UTF-8 name, not an extraction path or a unique lookup key. Checksums are supplied by the caller; the writer does not compute them and the decoder does not verify them.

Indexed resource reads

For a container with a central directory on a Read + Seek source, open the footer and directory, then read a selected resource. You can use a File in place of the in-memory Cursor below.

use mzstd::framing::{
    FramedCompressor, FramedDecompressor, FramedInput, FramedItem, FramedResource, ResourceIndex,
};
use std::io::{Cursor, Read};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let items = [
        FramedItem::Resource(FramedResource::from(b"first".as_slice())),
        FramedItem::Resource(FramedResource::from(b"second".as_slice())),
    ];
    let mut encoder = FramedCompressor::new(Default::default())?;
    let container = encoder.compress(FramedInput::from(items.as_slice()))?;

    let mut decoder = FramedDecompressor::new(Default::default())?;
    let mut reader = decoder.framed_seek_reader(Cursor::new(container))?;
    assert_eq!(reader.resources().len(), 2);
    let mut selected = Vec::new();
    reader
        .resource(ResourceIndex(1))?
        .read_to_end(&mut selected)?;
    assert_eq!(selected, b"second");
    Ok(())
}

Resources are identified by zero-based ResourceIndex, independent of metadata names. A resource that references earlier dictionary content can require decoding its dependencies. A selected resource reader implements Read; the enclosing reader uses Seek to locate container content. The final frame is a seek table in zstd’s contrib/seekable_format layout. See indexed decoder mechanics.

Limits and streaming decoding

FramingConfig bounds encoder chunks, metadata, resource/chunk counts, and framing storage. FramedDecodeConfig / FramedDecodeLimits control decoder budgets; raw DecoderConfig limits apply within compressed resources. Choose limits for the workload before decoding untrusted containers.

For arriving input, FramedDecompressor::start exposes incremental events; framed_reader adapts a BufRead source. InputMode::Auto also accepts ordinary Zstandard frames; the default is FramedOnly. External dictionaries need a caller-supplied resolver. See decoder APIs, limits, and events and dictionaries.

Dictionaries and extended formats

Prepared dictionaries

A PreparedDictionary owns immutable dictionary bytes and compression indexes. Prepare it once for the encoder configuration and borrow it across operations or threads. Both raw content and trained Zstandard-format dictionaries work with the ordinary codec features; framing is not required.

use mzstd::dictionary::DictionaryBuilder;
use mzstd::{Compressor, Decompressor, EncoderConfig, Level};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let config = EncoderConfig::default().with_level(Level::try_from(3)?);
    let dictionary = DictionaryBuilder::new(b"HTTP/1.1 200 OK\r\nContent-Type: ")
        .with_config(config)
        .build()?;
    let input = b"HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\n";
    let frame = Compressor::new(config)?.compress_with_dictionary(&dictionary, input)?;
    assert_eq!(
        Decompressor::default().decompress_with_dictionary(&dictionary, &frame)?,
        input
    );
    Ok(())
}

DictionaryContent::Auto recognizes Zstandard dictionary magic, Raw forces raw content, and Zstd requires a trained dictionary including valid entropy tables. The library consumes trained dictionaries; it does not train them. A trained dictionary carries a nonzero ID checked against the frame. Raw content has no ID, so applications must supply the correct bytes themselves.

DictionaryBuilder::with_config prepares for matching compressor parameters; frame checksum/content-size options can differ. Incompatible configurations return EncodeError::DictionaryMismatch. with_level_parameters(level) instead prepares a standalone CDict with ZSTD_createCDict_advanced / ZSTD_CCtx_refCDict semantics; its level replaces the context level during parameter resolution. See dictionary mechanics.

Decoder dictionaries

For a decoder-only application, prepare DecodeDictionary::new(bytes, content). This avoids encoder search indexes and works with only decompression enabled. Decoder entry points borrow a prepared encoder or decoder dictionary through DictionaryRef. Reuse it for one-shot, slice, session, reader, and writer APIs.

Dictionary IDs identify content; the decoder does not fetch missing dictionaries. See decoder dictionaries.

Large windows and long distance matching

Set a window through CompressionParams::with_window_log and enable long distance matching through LongDistanceMatching::Enabled(LdmParams::default()). These remain standard Zstandard frames. Explicit parameter validation follows the reference; decoder window limits must permit the resulting declaration. Larger windows can require more retained history and memory. See encoder core and configuration.

Magicless frames

EncoderConfig::with_format(FrameFormat::Magicless) omits the four-byte Zstandard magic. Select the same format through DecoderConfig::with_format: it cannot be autodetected. This supports integrations such as OpenZL; it does not apply to parallel compression, framed containers, or the C ABI. See OpenZL integration.

Feature flags

FeatureSurface
compression, decompressionIndependently selectable codecs and dictionary preparation
stdStandard error traits and runtime SIMD support
no_stdAlloc-backed codecs; removes std-only I/O and parallel APIs
framedStructured, seekable Zstandard containers; APIs may change in a patch release
hotpath, hotpath-cpu, hotpath-alloc, hotpath-callgrind, hotpath-cloudProfiling instrumentation

See codec features for checked combinations.

Framing containers

With framed, resources and compressed metadata can refer to external or earlier internal dictionaries. The caller supplies dictionary identifiers, references, and a resolver for external content. Ordinary Zstandard decoding concatenates visible resources and skips the container’s other frames; resource-aware decoding uses FramedDecompressor.

Each compressed chunk is a Zstandard frame. Metadata, hidden resources, directory, and footer use skippable frames; the last frame is a contrib-compatible seek table. Optional resource IDs/checksums are caller-supplied and not computed or verified by the container layer; ordinary Zstandard frame checksums are verified by the codec. See framing recipes, wire format, and decoder limits and events.

Compatibility and validation

This page describes the contracts checked by the repository. Tests, fuzzing, coverage, and memory tools provide bounded evidence. Lean proofs cover selected format primitives, rather than the entire codec. For commands, see development.

What is claimed

SurfaceClaimOracle
Serial one-shot encoding, levels -131072–22Byte identity for equivalent input, parameters, and dictionaryzstd 1.5.7 ZSTD_compress2
Serial sessionsByte identity with the same chunks, capacities, flushes, parameters, and pledged sizeZSTD_compressStream2
DecodingStandard Zstandard frames, concatenated/skippable frames, raw/trained dictionaries, checksums, and configured limitsIndependent Rust decoder, C differential tests, upstream golden vectors
Parallel encodingValid deterministic frames for fixed input/configuration/segment sizeRust API comparisons and reference decoding; no ZSTDMT byte identity
FramingResources, metadata, directory, footer, seek table, and indexed readsRound trips, independent wire fixtures, zstd regular and contrib seekable decoders
SIMD selection and output destinationsEquivalent observable resultsBaseline/backend differential and output-capacity tests

The development oracle is the vendored submodule at zstd-ffi/vendor/zstd. It is not a production dependency of the main crate. The library forbids unsafe in production code; SIMD and uninitialized-output dependencies still need memory-tool checks. The separate mzstd-ffi crate provides a C ABI.

Output compatibility

Single-threaded one-shot compression is byte-identical to zstd 1.5.7 ZSTD_compress2 with equivalent input, parameters, and dictionary. Sessions match ZSTD_compressStream2 driven with the same chunks, output capacities, flushes, and pledged size. Different schedules are not interchangeable byte oracles. SIMD selection and workspace reuse preserve observable behavior.

Parallel compression writes valid, deterministic Zstandard frames, but its bytes differ from serial compression and zstd’s multithreaded mode. Framing is this crate’s container format built from Zstandard and skippable frames.

Validation layers

  • Encoder differential tests cover levels, random advanced parameters, long distance matching, dictionaries, short destinations, pledged sizes, target compressed block sizes, and streaming schedules with flushes and window wrap.
  • The overflow-correction build forces index rescaling often on both sides.
  • Decoder tests cover reference frames, golden vectors, truncation, corruption, limits, checksums, and all public input/output shapes.
  • AFL++ and ClusterFuzzLite exercise byte-consuming and stateful boundaries; minimized findings become committed regressions.
  • Miri and AddressSanitizer exercise memory-sensitive paths, including SIMD and uninitialized destinations. Function coverage is checked separately.
  • Lean 4 compares selected translated Rust functions with a transcription of RFC 8878. See formal verification for the trusted base and coverage checklist.

Scheduling and partial output

Serial session byte identity requires equivalent C and Rust call schedules. A different chunk or flush schedule can produce different valid bytes. Parallel output is not a serial or multithreaded C byte oracle. Performance comparisons must record size as well as speed and validate decoded bytes first.

Streaming output delivered before an error is partial and may not have passed checksum or structural validation. Account for a failure’s consumed/produced counts and do not replay input already accepted by a writer. Complete readers to EOF and explicitly finish writers and sessions.

Scope and known gaps

The codecs do not support legacy zstd v0.x frames or external sequence producers. Parallel compression and the C ABI do not accept dictionaries; magicless framing is limited to ordinary codec APIs. Container resource checksum fields are recorded without verification. See the current architecture gaps, encoder verification, decoder verification, and CI checks.

Benchmark results

Two kinds of runs, both on an AMD Ryzen 9 7950X (Linux KVM guest with two vCPUs, its host shared with other tenants): the cold library comparison of mzstd 0.2.12 against zstd 1.5.7, recorded on 2026-11-09, and lzbench on silesia.tar. Every report identifies its measured sources, sampling settings and limitations. Other tenants loaded the host during both: the medians are stable, single cells are good to about ±10%.

CompareResultsData
Compression speed and size, 384 cold casesLevels -5 to 22, reportCSV, environment, size manifest, Criterion samples
Decompression speed on identical frames, 384 cold casesSource levels -5 to 22, reportCSV, environment, size manifest, Criterion samples
lzbench on silesia.tar, one frameReportRaw output of every round in the report

Compression

Each speed bar is the median of eight per-dataset ratios to C, timed in the same process. Higher speed and lower size are better. Every mzstd frame is byte-identical to zstd’s, so the size ratio is 1× at every level. The median over all 192 dataset/level pairs is 1.248×, with lower mean latency in 174 pairs (2026-10-30: 1.256×, 179 pairs). The per-dataset medians agree with that run to within a few percent: Alice 1.008×, cyclic Alice 1.088×, structured binary 1.241×, repeated bytes 2.437×. The 18 slower pairs are Alice at ten levels, cyclic Alice at four and random data at four, between 0.84× and 0.998×. Report · exact results and confidence bounds.

Decompression

Both decoders restore the same C-written frames; level is the source encoder’s setting. The median over all 192 pairs is 1.517× (2026-10-30: 1.502×), faster in 151 pairs: tens of times faster on empty and tiny frames, 3.2× on random 64 KiB, 1.53× on repeated bytes and 1.45× on structured binary, but 0.948× on Alice and 0.937× on cyclic Alice, the datasets of all 41 slower pairs. Report · exact results.

lzbench

lzbench master a6fa8aa (2.4.1), gcc 15.2 with -march=native, silesia.tar as one frame on one vCPU, the fastest of three rounds: mzstd 0.2.12 writes byte-identical output to zstd 1.5.7 at every level, compresses 6.6-17.5% faster at levels 15, 18 and 22, at zstd’s speed at level 3 and 2.4-6.6% slower at the other levels, and decompresses 4.7-7.2% slower at every level. Against 0.2.11 in the same rounds, relative to zstd, decompression gained 0.8-5 points at levels 1 to 22 and level 3 compression went from 0.973 to 1.011. Compression at level 2 and above moves by 3-21% between rounds on this host. Command, environment, raw output of every round.

How to interpret the results

The cold suites time construction, allocation, the call and disposal. Empty and tiny inputs have equal weight with large inputs; medians describe this corpus, not a production workload. C decoding knows the output capacity from the frame header. The lowest mean is not necessarily a statistically significant lead, and on this run’s loaded host a single cell’s ratio can move by 10% between sweeps.

DatasetInput bytesContent
empty0API overhead without input
tiny-text44Quick-brown-fox sentence
alice29152,089Alice in Wonderland text (testdata/alice29.txt)
text-1m1,048,576Alice repeated cyclically
binary-64k65,536Structured binary bytes
random-64k65,536Prefix of the pseudorandom corpus
random-1m1,048,576Deterministic xorshift64 bytes
repeated-1m1,048,576Repeated a bytes

The datasets, generators and seeds are mbrotli’s, so the two projects’ comparisons describe one corpus.

Reproduce

Use benchmarking and profiling for validation, fresh timings, the main branch’s series, the Silesia sweep and the report-generation commands.

Encoder comparison

Benchmark index

Recorded run: long-20261109 (2026-11-09). Raw results · Environment · Run analysis.

All 8 datasets contribute equally to each median, including empty and tiny input. For each dataset, speed / C = C mean latency / mzstd mean latency; output / C = mzstd bytes / C bytes. Medians are taken over those ratios. Levels are ordered by mzstd median speed / C, highest first. Frames are byte-identical, so output / C is 1× at every level.

Levelmzstd speed / C ↑mzstd output / C ↓Datasets
Level -51.720×1.000×8
Level -11.633×1.000×8
Level 21.455×1.000×8
Level 11.380×1.000×8
Level 161.337×1.000×8
Level 191.318×1.000×8
Level 171.298×1.000×8
Level 181.287×1.000×8
Level 201.268×1.000×8
Level 81.268×1.000×8
Level 31.268×1.000×8
Level 91.266×1.000×8
Level 101.261×1.000×8
Level 51.256×1.000×8
Level 71.244×1.000×8
Level 61.222×1.000×8
Level 211.219×1.000×8
Level 151.195×1.000×8
Level 221.193×1.000×8
Level 121.184×1.000×8
Level 131.172×1.000×8
Level 111.164×1.000×8
Level 41.163×1.000×8
Level 141.111×1.000×8

Level -5

Level -1

Level 2

Level 1

Level 16

Level 19

Level 17

Level 18

Level 20

Level 8

Level 3

Level 9

Level 10

Level 5

Level 7

Level 6

Level 21

Level 15

Level 22

Level 12

Level 13

Level 11

Level 4

Level 14

Compression level -5

Benchmark index · Median overview · Level -1 →

Recorded run: long-20261109 (2026-11-09). Raw results · Environment · Run analysis.

Median across all datasets

Each of the 8 datasets has equal weight, including empty and tiny input. Speed / C = C mean latency / implementation mean latency; output / C = implementation bytes / C bytes. We take the median of these 8 per-dataset ratios separately for speed and size. 1× matches C; higher speed and lower output are better. These are medians across datasets, not median sample latencies, and do not imply statistical significance.

ImplementationMedian speed / C ↑Median output / C ↓Datasets
zstd C1.000×1.000×8
mzstd1.720×1.000×8
Measurement setup and interpretation

zstd 1.5.7 (the C library built by zstd-ffi) and mzstd at the recorded working tree compress identical bytes at this level. Every frame was checked byte-identical to zstd’s and decoded back to its input before any timing.

These are cold native API measurements: construction, allocation, compression, and disposal. Complete known input; 50 samples, 3 s warmup, at least 6 s measurement. Host: AMD Ryzen 9 7950X, Linux KVM guest with two vCPUs: the encoder suite, then the decoder suite, each in one Criterion process pinned to vCPU 1; nothing else ran in the guest. Other tenants loaded the host: a fixed C decode varied by 8-43% (p10-p90 wall time) through the day, so sampling was lengthened to average over the noise. unset; Cargo release defaults (bench profile), runtime SIMD dispatch (AVX-512 level on this host; the decoder runs its block loops at the AVX2 level).

Chart whiskers and table intervals describe 95% confidence bounds on mean timing. They do not capture all host or allocator variation. Throughput bounds are transformed latency bounds. Output / input is compressed bytes divided by input bytes; smaller is better, and values above 100% mean expansion. For empty input, throughput and output / input are undefined (—). See the run analysis for measurement limitations.

Dataset details

Ordered by mzstd speed / C, highest first; all 8 datasets are shown. This order uses recorded mean latency, not statistical significance. Bars start at zero.

empty · tiny-text · repeated-1m · binary-64k · random-64k · random-1m · alice29 · text-1m

empty

Empty input; measures API overhead. Input: 0 bytes.

mzstd speed / C: 10.763×; output: 9 / 9 bytes.

Exact measurements and confidence bounds
ImplementationMean, ns95% mean interval, nsMiB/sOutput bytesOutput / inputSpeed / C
zstd C2,2002,168–2,236—9—1.000×
mzstd204.4197.2–212.7—9—10.763×

tiny-text

44-byte quick-brown-fox sentence. Input: 44 bytes.

mzstd speed / C: 8.134×; output: 53 / 53 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C2.4832.416–2.56116.9053120.455%1.000×
mzstd0.30530.2914–0.3207137.4653120.455%8.134×

repeated-1m

1 MiB of repeated a bytes. Input: 1,048,576 bytes.

mzstd speed / C: 2.799×; output: 55 / 55 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C79.9877.36–82.9912,503.89550.005%1.000×
mzstd28.5728.31–28.8734,999.29550.005%2.799×

binary-64k

64 KiB of structured binary bytes: ((i / 16) XOR i) modulo 256. Input: 65,536 bytes.

mzstd speed / C: 1.801×; output: 3,189 / 3,189 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C9.5669.255–9.9126,533.283,1894.866%1.000×
mzstd5.3135.232–5.40411,764.113,1894.866%1.801×

random-64k

64 KiB prefix of the deterministic pseudorandom corpus. Input: 65,536 bytes.

mzstd speed / C: 1.638×; output: 65,546 / 65,546 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C6.7046.486–6.9439,323.2465,546100.015%1.000×
mzstd4.0923.995–4.20715,275.1865,546100.015%1.638×

random-1m

1 MiB of deterministic xorshift64 pseudorandom bytes. Input: 1,048,576 bytes.

mzstd speed / C: 1.138×; output: 1,048,610 / 1,048,610 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C69.0167.74–70.3214,491.551,048,610100.003%1.000×
mzstd60.6559.48–61.8516,488.591,048,610100.003%1.138×

alice29

Alice in Wonderland text from the Canterbury corpus (testdata/alice29.txt). Input: 152,089 bytes.

mzstd speed / C: 1.109×; output: 85,658 / 85,658 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C330.3315.9–346.2439.1585,65856.321%1.000×
mzstd297.8292.9–304487.0785,65856.321%1.109×

text-1m

Alice text repeated cyclically to 1 MiB. Input: 1,048,576 bytes.

mzstd speed / C: 1.091×; output: 96,657 / 96,657 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C319.5305.3–335.23,130.2596,6579.218%1.000×
mzstd292.8279.3–306.83,415.0096,6579.218%1.091×

Compression level -1

Benchmark index · Median overview · ← Level -5 · Level 1 →

Recorded run: long-20261109 (2026-11-09). Raw results · Environment · Run analysis.

Median across all datasets

Each of the 8 datasets has equal weight, including empty and tiny input. Speed / C = C mean latency / implementation mean latency; output / C = implementation bytes / C bytes. We take the median of these 8 per-dataset ratios separately for speed and size. 1× matches C; higher speed and lower output are better. These are medians across datasets, not median sample latencies, and do not imply statistical significance.

ImplementationMedian speed / C ↑Median output / C ↓Datasets
zstd C1.000×1.000×8
mzstd1.633×1.000×8
Measurement setup and interpretation

zstd 1.5.7 (the C library built by zstd-ffi) and mzstd at the recorded working tree compress identical bytes at this level. Every frame was checked byte-identical to zstd’s and decoded back to its input before any timing.

These are cold native API measurements: construction, allocation, compression, and disposal. Complete known input; 50 samples, 3 s warmup, at least 6 s measurement. Host: AMD Ryzen 9 7950X, Linux KVM guest with two vCPUs: the encoder suite, then the decoder suite, each in one Criterion process pinned to vCPU 1; nothing else ran in the guest. Other tenants loaded the host: a fixed C decode varied by 8-43% (p10-p90 wall time) through the day, so sampling was lengthened to average over the noise. unset; Cargo release defaults (bench profile), runtime SIMD dispatch (AVX-512 level on this host; the decoder runs its block loops at the AVX2 level).

Chart whiskers and table intervals describe 95% confidence bounds on mean timing. They do not capture all host or allocator variation. Throughput bounds are transformed latency bounds. Output / input is compressed bytes divided by input bytes; smaller is better, and values above 100% mean expansion. For empty input, throughput and output / input are undefined (—). See the run analysis for measurement limitations.

Dataset details

Ordered by mzstd speed / C, highest first; all 8 datasets are shown. This order uses recorded mean latency, not statistical significance. Bars start at zero.

empty · tiny-text · repeated-1m · binary-64k · random-64k · random-1m · text-1m · alice29

empty

Empty input; measures API overhead. Input: 0 bytes.

mzstd speed / C: 11.441×; output: 9 / 9 bytes.

Exact measurements and confidence bounds
ImplementationMean, ns95% mean interval, nsMiB/sOutput bytesOutput / inputSpeed / C
zstd C2,2992,231–2,374—9—1.000×
mzstd200.9193.6–209.1—9—11.441×

tiny-text

44-byte quick-brown-fox sentence. Input: 44 bytes.

mzstd speed / C: 9.111×; output: 53 / 53 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C2.6452.535–2.77115.8653120.455%1.000×
mzstd0.29030.2849–0.2965144.5353120.455%9.111×

repeated-1m

1 MiB of repeated a bytes. Input: 1,048,576 bytes.

mzstd speed / C: 2.735×; output: 51 / 51 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C78.1276.16–80.5912,800.99510.005%1.000×
mzstd28.5628.21–29.0435,014.71510.005%2.735×

binary-64k

64 KiB of structured binary bytes: ((i / 16) XOR i) modulo 256. Input: 65,536 bytes.

mzstd speed / C: 1.738×; output: 2,442 / 2,442 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C11.110.64–11.65,630.722,4423.726%1.000×
mzstd6.3866.19–6.6019,787.282,4423.726%1.738×

random-64k

64 KiB prefix of the deterministic pseudorandom corpus. Input: 65,536 bytes.

mzstd speed / C: 1.528×; output: 65,546 / 65,546 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C6.8036.705–6.939,187.2265,546100.015%1.000×
mzstd4.4534.352–4.5714,036.8865,546100.015%1.528×

random-1m

1 MiB of deterministic xorshift64 pseudorandom bytes. Input: 1,048,576 bytes.

mzstd speed / C: 1.122×; output: 1,048,610 / 1,048,610 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C70.5169.28–71.8614,181.861,048,610100.003%1.000×
mzstd62.8461.8–63.9315,913.721,048,610100.003%1.122×

text-1m

Alice text repeated cyclically to 1 MiB. Input: 1,048,576 bytes.

mzstd speed / C: 1.085×; output: 80,894 / 80,894 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C438.2425.7–451.12,282.3180,8947.715%1.000×
mzstd403.7391–417.22,477.1580,8947.715%1.085×

alice29

Alice in Wonderland text from the Canterbury corpus (testdata/alice29.txt). Input: 152,089 bytes.

mzstd speed / C: 0.953×; output: 71,645 / 71,645 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C390.8378–405.8371.1471,64547.107%1.000×
mzstd410395.3–426.5353.7371,64547.107%0.953×

Compression level 1

Benchmark index · Median overview · ← Level -1 · Level 2 →

Recorded run: long-20261109 (2026-11-09). Raw results · Environment · Run analysis.

Median across all datasets

Each of the 8 datasets has equal weight, including empty and tiny input. Speed / C = C mean latency / implementation mean latency; output / C = implementation bytes / C bytes. We take the median of these 8 per-dataset ratios separately for speed and size. 1× matches C; higher speed and lower output are better. These are medians across datasets, not median sample latencies, and do not imply statistical significance.

ImplementationMedian speed / C ↑Median output / C ↓Datasets
zstd C1.000×1.000×8
mzstd1.380×1.000×8
Measurement setup and interpretation

zstd 1.5.7 (the C library built by zstd-ffi) and mzstd at the recorded working tree compress identical bytes at this level. Every frame was checked byte-identical to zstd’s and decoded back to its input before any timing.

These are cold native API measurements: construction, allocation, compression, and disposal. Complete known input; 50 samples, 3 s warmup, at least 6 s measurement. Host: AMD Ryzen 9 7950X, Linux KVM guest with two vCPUs: the encoder suite, then the decoder suite, each in one Criterion process pinned to vCPU 1; nothing else ran in the guest. Other tenants loaded the host: a fixed C decode varied by 8-43% (p10-p90 wall time) through the day, so sampling was lengthened to average over the noise. unset; Cargo release defaults (bench profile), runtime SIMD dispatch (AVX-512 level on this host; the decoder runs its block loops at the AVX2 level).

Chart whiskers and table intervals describe 95% confidence bounds on mean timing. They do not capture all host or allocator variation. Throughput bounds are transformed latency bounds. Output / input is compressed bytes divided by input bytes; smaller is better, and values above 100% mean expansion. For empty input, throughput and output / input are undefined (—). See the run analysis for measurement limitations.

Dataset details

Ordered by mzstd speed / C, highest first; all 8 datasets are shown. This order uses recorded mean latency, not statistical significance. Bars start at zero.

empty · tiny-text · repeated-1m · binary-64k · random-64k · text-1m · random-1m · alice29

empty

Empty input; measures API overhead. Input: 0 bytes.

mzstd speed / C: 11.256×; output: 9 / 9 bytes.

Exact measurements and confidence bounds
ImplementationMean, ns95% mean interval, nsMiB/sOutput bytesOutput / inputSpeed / C
zstd C2,2162,169–2,271—9—1.000×
mzstd196.9191.8–202.7—9—11.256×

tiny-text

44-byte quick-brown-fox sentence. Input: 44 bytes.

mzstd speed / C: 7.708×; output: 53 / 53 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C2.412.366–2.46517.4153120.455%1.000×
mzstd0.31270.2992–0.3274134.2053120.455%7.708×

repeated-1m

1 MiB of repeated a bytes. Input: 1,048,576 bytes.

mzstd speed / C: 2.871×; output: 51 / 51 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C83.379.45–87.7512,004.13510.005%1.000×
mzstd29.0228.67–29.4334,461.55510.005%2.871×

binary-64k

64 KiB of structured binary bytes: ((i / 16) XOR i) modulo 256. Input: 65,536 bytes.

mzstd speed / C: 1.501×; output: 2,537 / 2,537 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C10.7210.61–10.865,829.122,5373.871%1.000×
mzstd7.1436.886–7.4388,750.082,5373.871%1.501×

random-64k

64 KiB prefix of the deterministic pseudorandom corpus. Input: 65,536 bytes.

mzstd speed / C: 1.259×; output: 65,546 / 65,546 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C8.7848.64–8.9647,115.3765,546100.015%1.000×
mzstd6.9796.796–7.188,955.3765,546100.015%1.259×

text-1m

Alice text repeated cyclically to 1 MiB. Input: 1,048,576 bytes.

mzstd speed / C: 1.153×; output: 61,206 / 61,206 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C465.5456.7–474.62,148.3461,2065.837%1.000×
mzstd403.6390.3–417.62,477.6361,2065.837%1.153×

random-1m

1 MiB of deterministic xorshift64 pseudorandom bytes. Input: 1,048,576 bytes.

mzstd speed / C: 1.094×; output: 1,048,610 / 1,048,610 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C85.1784.34–86.0511,741.621,048,610100.003%1.000×
mzstd77.8677.01–78.8112,843.851,048,610100.003%1.094×

alice29

Alice in Wonderland text from the Canterbury corpus (testdata/alice29.txt). Input: 152,089 bytes.

mzstd speed / C: 0.993×; output: 59,657 / 59,657 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C366.9360.8–375.2395.3159,65739.225%1.000×
mzstd369.4360.6–380.4392.6259,65739.225%0.993×

Compression level 2

Benchmark index · Median overview · ← Level 1 · Level 3 →

Recorded run: long-20261109 (2026-11-09). Raw results · Environment · Run analysis.

Median across all datasets

Each of the 8 datasets has equal weight, including empty and tiny input. Speed / C = C mean latency / implementation mean latency; output / C = implementation bytes / C bytes. We take the median of these 8 per-dataset ratios separately for speed and size. 1× matches C; higher speed and lower output are better. These are medians across datasets, not median sample latencies, and do not imply statistical significance.

ImplementationMedian speed / C ↑Median output / C ↓Datasets
zstd C1.000×1.000×8
mzstd1.455×1.000×8
Measurement setup and interpretation

zstd 1.5.7 (the C library built by zstd-ffi) and mzstd at the recorded working tree compress identical bytes at this level. Every frame was checked byte-identical to zstd’s and decoded back to its input before any timing.

These are cold native API measurements: construction, allocation, compression, and disposal. Complete known input; 50 samples, 3 s warmup, at least 6 s measurement. Host: AMD Ryzen 9 7950X, Linux KVM guest with two vCPUs: the encoder suite, then the decoder suite, each in one Criterion process pinned to vCPU 1; nothing else ran in the guest. Other tenants loaded the host: a fixed C decode varied by 8-43% (p10-p90 wall time) through the day, so sampling was lengthened to average over the noise. unset; Cargo release defaults (bench profile), runtime SIMD dispatch (AVX-512 level on this host; the decoder runs its block loops at the AVX2 level).

Chart whiskers and table intervals describe 95% confidence bounds on mean timing. They do not capture all host or allocator variation. Throughput bounds are transformed latency bounds. Output / input is compressed bytes divided by input bytes; smaller is better, and values above 100% mean expansion. For empty input, throughput and output / input are undefined (—). See the run analysis for measurement limitations.

Dataset details

Ordered by mzstd speed / C, highest first; all 8 datasets are shown. This order uses recorded mean latency, not statistical significance. Bars start at zero.

empty · tiny-text · repeated-1m · binary-64k · random-64k · text-1m · random-1m · alice29

empty

Empty input; measures API overhead. Input: 0 bytes.

mzstd speed / C: 11.177×; output: 9 / 9 bytes.

Exact measurements and confidence bounds
ImplementationMean, ns95% mean interval, nsMiB/sOutput bytesOutput / inputSpeed / C
zstd C2,3452,278–2,413—9—1.000×
mzstd209.8201.3–219.1—9—11.177×

tiny-text

44-byte quick-brown-fox sentence. Input: 44 bytes.

mzstd speed / C: 7.214×; output: 53 / 53 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C2.4272.369–2.49117.2953120.455%1.000×
mzstd0.33650.3196–0.3537124.7153120.455%7.214×

repeated-1m

1 MiB of repeated a bytes. Input: 1,048,576 bytes.

mzstd speed / C: 2.772×; output: 50 / 50 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C85.9680.9–91.6511,633.42500.005%1.000×
mzstd31.0130.35–31.7432,249.55500.005%2.772×

binary-64k

64 KiB of structured binary bytes: ((i / 16) XOR i) modulo 256. Input: 65,536 bytes.

mzstd speed / C: 1.484×; output: 2,425 / 2,425 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C11.7511.52–12.035,318.442,4253.700%1.000×
mzstd7.9187.777–8.0977,893.332,4253.700%1.484×

random-64k

64 KiB prefix of the deterministic pseudorandom corpus. Input: 65,536 bytes.

mzstd speed / C: 1.426×; output: 65,546 / 65,546 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C10.910.53–11.35,732.8065,546100.015%1.000×
mzstd7.6457.514–7.8078,175.5665,546100.015%1.426×

text-1m

Alice text repeated cyclically to 1 MiB. Input: 1,048,576 bytes.

mzstd speed / C: 1.198×; output: 58,340 / 58,340 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C555.7535.2–576.81,799.4858,3405.564%1.000×
mzstd463.9450.1–478.72,155.8658,3405.564%1.198×

random-1m

1 MiB of deterministic xorshift64 pseudorandom bytes. Input: 1,048,576 bytes.

mzstd speed / C: 1.099×; output: 1,048,609 / 1,048,609 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C96.3894.05–98.8210,375.171,048,609100.003%1.000×
mzstd87.7185.9–89.6111,401.161,048,609100.003%1.099×

alice29

Alice in Wonderland text from the Canterbury corpus (testdata/alice29.txt). Input: 152,089 bytes.

mzstd speed / C: 0.841×; output: 57,348 / 57,348 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C477.6468.5–488.3303.6657,34837.707%1.000×
mzstd568.1549.2–589.6255.3057,34837.707%0.841×

Compression level 3

Benchmark index · Median overview · ← Level 2 · Level 4 →

Recorded run: long-20261109 (2026-11-09). Raw results · Environment · Run analysis.

Median across all datasets

Each of the 8 datasets has equal weight, including empty and tiny input. Speed / C = C mean latency / implementation mean latency; output / C = implementation bytes / C bytes. We take the median of these 8 per-dataset ratios separately for speed and size. 1× matches C; higher speed and lower output are better. These are medians across datasets, not median sample latencies, and do not imply statistical significance.

ImplementationMedian speed / C ↑Median output / C ↓Datasets
zstd C1.000×1.000×8
mzstd1.268×1.000×8
Measurement setup and interpretation

zstd 1.5.7 (the C library built by zstd-ffi) and mzstd at the recorded working tree compress identical bytes at this level. Every frame was checked byte-identical to zstd’s and decoded back to its input before any timing.

These are cold native API measurements: construction, allocation, compression, and disposal. Complete known input; 50 samples, 3 s warmup, at least 6 s measurement. Host: AMD Ryzen 9 7950X, Linux KVM guest with two vCPUs: the encoder suite, then the decoder suite, each in one Criterion process pinned to vCPU 1; nothing else ran in the guest. Other tenants loaded the host: a fixed C decode varied by 8-43% (p10-p90 wall time) through the day, so sampling was lengthened to average over the noise. unset; Cargo release defaults (bench profile), runtime SIMD dispatch (AVX-512 level on this host; the decoder runs its block loops at the AVX2 level).

Chart whiskers and table intervals describe 95% confidence bounds on mean timing. They do not capture all host or allocator variation. Throughput bounds are transformed latency bounds. Output / input is compressed bytes divided by input bytes; smaller is better, and values above 100% mean expansion. For empty input, throughput and output / input are undefined (—). See the run analysis for measurement limitations.

Dataset details

Ordered by mzstd speed / C, highest first; all 8 datasets are shown. This order uses recorded mean latency, not statistical significance. Bars start at zero.

empty · tiny-text · repeated-1m · binary-64k · text-1m · random-64k · random-1m · alice29

empty

Empty input; measures API overhead. Input: 0 bytes.

mzstd speed / C: 10.353×; output: 9 / 9 bytes.

Exact measurements and confidence bounds
ImplementationMean, ns95% mean interval, nsMiB/sOutput bytesOutput / inputSpeed / C
zstd C2,2072,163–2,260—9—1.000×
mzstd213.2206–221.5—9—10.353×

tiny-text

44-byte quick-brown-fox sentence. Input: 44 bytes.

mzstd speed / C: 6.912×; output: 53 / 53 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C2.5452.467–2.62816.4953120.455%1.000×
mzstd0.36820.3582–0.3806113.9853120.455%6.912×

repeated-1m

1 MiB of repeated a bytes. Input: 1,048,576 bytes.

mzstd speed / C: 3.340×; output: 50 / 50 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C144.8141.5–149.46,904.64500.005%1.000×
mzstd43.3642.57–44.2523,063.42500.005%3.340×

binary-64k

64 KiB of structured binary bytes: ((i / 16) XOR i) modulo 256. Input: 65,536 bytes.

mzstd speed / C: 1.391×; output: 2,324 / 2,324 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C17.4916.89–18.153,573.622,3243.546%1.000×
mzstd12.5811.91–13.264,969.852,3243.546%1.391×

text-1m

Alice text repeated cyclically to 1 MiB. Input: 1,048,576 bytes.

mzstd speed / C: 1.145×; output: 55,878 / 55,878 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C720.3696.4–744.91,388.3455,8785.329%1.000×
mzstd629607.6–651.11,589.7955,8785.329%1.145×

random-64k

64 KiB prefix of the deterministic pseudorandom corpus. Input: 65,536 bytes.

mzstd speed / C: 1.143×; output: 65,546 / 65,546 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C14.1613.75–14.624,412.5165,546100.015%1.000×
mzstd12.3912.03–12.785,045.0265,546100.015%1.143×

random-1m

1 MiB of deterministic xorshift64 pseudorandom bytes. Input: 1,048,576 bytes.

mzstd speed / C: 1.020×; output: 1,048,609 / 1,048,609 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C126.1122.3–130.17,928.031,048,609100.003%1.000×
mzstd123.6120.8–126.88,088.231,048,609100.003%1.020×

alice29

Alice in Wonderland text from the Canterbury corpus (testdata/alice29.txt). Input: 152,089 bytes.

mzstd speed / C: 1.011×; output: 56,970 / 56,970 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C565.5551.9–580.3256.5056,97037.458%1.000×
mzstd559.2535.9–584.5259.3856,97037.458%1.011×

Compression level 4

Benchmark index · Median overview · ← Level 3 · Level 5 →

Recorded run: long-20261109 (2026-11-09). Raw results · Environment · Run analysis.

Median across all datasets

Each of the 8 datasets has equal weight, including empty and tiny input. Speed / C = C mean latency / implementation mean latency; output / C = implementation bytes / C bytes. We take the median of these 8 per-dataset ratios separately for speed and size. 1× matches C; higher speed and lower output are better. These are medians across datasets, not median sample latencies, and do not imply statistical significance.

ImplementationMedian speed / C ↑Median output / C ↓Datasets
zstd C1.000×1.000×8
mzstd1.163×1.000×8
Measurement setup and interpretation

zstd 1.5.7 (the C library built by zstd-ffi) and mzstd at the recorded working tree compress identical bytes at this level. Every frame was checked byte-identical to zstd’s and decoded back to its input before any timing.

These are cold native API measurements: construction, allocation, compression, and disposal. Complete known input; 50 samples, 3 s warmup, at least 6 s measurement. Host: AMD Ryzen 9 7950X, Linux KVM guest with two vCPUs: the encoder suite, then the decoder suite, each in one Criterion process pinned to vCPU 1; nothing else ran in the guest. Other tenants loaded the host: a fixed C decode varied by 8-43% (p10-p90 wall time) through the day, so sampling was lengthened to average over the noise. unset; Cargo release defaults (bench profile), runtime SIMD dispatch (AVX-512 level on this host; the decoder runs its block loops at the AVX2 level).

Chart whiskers and table intervals describe 95% confidence bounds on mean timing. They do not capture all host or allocator variation. Throughput bounds are transformed latency bounds. Output / input is compressed bytes divided by input bytes; smaller is better, and values above 100% mean expansion. For empty input, throughput and output / input are undefined (—). See the run analysis for measurement limitations.

Dataset details

Ordered by mzstd speed / C, highest first; all 8 datasets are shown. This order uses recorded mean latency, not statistical significance. Bars start at zero.

empty · tiny-text · repeated-1m · text-1m · random-64k · binary-64k · alice29 · random-1m

empty

Empty input; measures API overhead. Input: 0 bytes.

mzstd speed / C: 10.888×; output: 9 / 9 bytes.

Exact measurements and confidence bounds
ImplementationMean, ns95% mean interval, nsMiB/sOutput bytesOutput / inputSpeed / C
zstd C2,2542,179–2,342—9—1.000×
mzstd207203.6–210.9—9—10.888×

tiny-text

44-byte quick-brown-fox sentence. Input: 44 bytes.

mzstd speed / C: 5.462×; output: 53 / 53 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C2.4552.403–2.51717.0953120.455%1.000×
mzstd0.44950.4378–0.464993.3553120.455%5.462×

repeated-1m

1 MiB of repeated a bytes. Input: 1,048,576 bytes.

mzstd speed / C: 3.084×; output: 50 / 50 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C164.7158.5–171.96,071.32500.005%1.000×
mzstd53.452.48–54.4618,726.54500.005%3.084×

text-1m

Alice text repeated cyclically to 1 MiB. Input: 1,048,576 bytes.

mzstd speed / C: 1.180×; output: 55,760 / 55,760 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C755.3734–777.71,324.0255,7605.318%1.000×
mzstd639.8620.2–661.11,563.0055,7605.318%1.180×

random-64k

64 KiB prefix of the deterministic pseudorandom corpus. Input: 65,536 bytes.

mzstd speed / C: 1.145×; output: 65,546 / 65,546 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C18.7418.23–19.293,335.6665,546100.015%1.000×
mzstd16.3716–16.793,818.2965,546100.015%1.145×

binary-64k

64 KiB of structured binary bytes: ((i / 16) XOR i) modulo 256. Input: 65,536 bytes.

mzstd speed / C: 1.078×; output: 3,909 / 3,909 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C102.999.89–106.2607.673,9095.965%1.000×
mzstd95.3792.92–98.36655.363,9095.965%1.078×

alice29

Alice in Wonderland text from the Canterbury corpus (testdata/alice29.txt). Input: 152,089 bytes.

mzstd speed / C: 0.979×; output: 54,282 / 54,282 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C1,007968.5–1,051144.0454,28235.691%1.000×
mzstd1,0291,001–1,059141.0154,28235.691%0.979×

random-1m

1 MiB of deterministic xorshift64 pseudorandom bytes. Input: 1,048,576 bytes.

mzstd speed / C: 0.940×; output: 1,048,609 / 1,048,609 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C126.1124.3–128.37,929.461,048,609100.003%1.000×
mzstd134.2132.1–136.87,452.211,048,609100.003%0.940×

Compression level 5

Benchmark index · Median overview · ← Level 4 · Level 6 →

Recorded run: long-20261109 (2026-11-09). Raw results · Environment · Run analysis.

Median across all datasets

Each of the 8 datasets has equal weight, including empty and tiny input. Speed / C = C mean latency / implementation mean latency; output / C = implementation bytes / C bytes. We take the median of these 8 per-dataset ratios separately for speed and size. 1× matches C; higher speed and lower output are better. These are medians across datasets, not median sample latencies, and do not imply statistical significance.

ImplementationMedian speed / C ↑Median output / C ↓Datasets
zstd C1.000×1.000×8
mzstd1.256×1.000×8
Measurement setup and interpretation

zstd 1.5.7 (the C library built by zstd-ffi) and mzstd at the recorded working tree compress identical bytes at this level. Every frame was checked byte-identical to zstd’s and decoded back to its input before any timing.

These are cold native API measurements: construction, allocation, compression, and disposal. Complete known input; 50 samples, 3 s warmup, at least 6 s measurement. Host: AMD Ryzen 9 7950X, Linux KVM guest with two vCPUs: the encoder suite, then the decoder suite, each in one Criterion process pinned to vCPU 1; nothing else ran in the guest. Other tenants loaded the host: a fixed C decode varied by 8-43% (p10-p90 wall time) through the day, so sampling was lengthened to average over the noise. unset; Cargo release defaults (bench profile), runtime SIMD dispatch (AVX-512 level on this host; the decoder runs its block loops at the AVX2 level).

Chart whiskers and table intervals describe 95% confidence bounds on mean timing. They do not capture all host or allocator variation. Throughput bounds are transformed latency bounds. Output / input is compressed bytes divided by input bytes; smaller is better, and values above 100% mean expansion. For empty input, throughput and output / input are undefined (—). See the run analysis for measurement limitations.

Dataset details

Ordered by mzstd speed / C, highest first; all 8 datasets are shown. This order uses recorded mean latency, not statistical significance. Bars start at zero.

empty · tiny-text · repeated-1m · binary-64k · random-1m · random-64k · text-1m · alice29

empty

Empty input; measures API overhead. Input: 0 bytes.

mzstd speed / C: 11.276×; output: 9 / 9 bytes.

Exact measurements and confidence bounds
ImplementationMean, ns95% mean interval, nsMiB/sOutput bytesOutput / inputSpeed / C
zstd C2,3782,259–2,519—9—1.000×
mzstd210.9204.4–218.4—9—11.276×

tiny-text

44-byte quick-brown-fox sentence. Input: 44 bytes.

mzstd speed / C: 5.223×; output: 53 / 53 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C2.4832.421–2.55516.9053120.455%1.000×
mzstd0.47550.4587–0.49588.2653120.455%5.223×

repeated-1m

1 MiB of repeated a bytes. Input: 1,048,576 bytes.

mzstd speed / C: 1.975×; output: 50 / 50 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C171.9169–175.75,816.25500.005%1.000×
mzstd87.0685.51–88.7611,485.84500.005%1.975×

binary-64k

64 KiB of structured binary bytes: ((i / 16) XOR i) modulo 256. Input: 65,536 bytes.

mzstd speed / C: 1.275×; output: 1,270 / 1,270 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C27.7326.67–28.942,254.061,2701.938%1.000×
mzstd21.7521.4–22.212,873.521,2701.938%1.275×

random-1m

1 MiB of deterministic xorshift64 pseudorandom bytes. Input: 1,048,576 bytes.

mzstd speed / C: 1.236×; output: 1,048,609 / 1,048,609 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C274.4266.3–282.83,644.181,048,609100.003%1.000×
mzstd221.9216.9–227.44,505.581,048,609100.003%1.236×

random-64k

64 KiB prefix of the deterministic pseudorandom corpus. Input: 65,536 bytes.

mzstd speed / C: 1.178×; output: 65,546 / 65,546 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C28.4127.76–29.112,200.0565,546100.015%1.000×
mzstd24.1223.29–24.992,591.4265,546100.015%1.178×

text-1m

Alice text repeated cyclically to 1 MiB. Input: 1,048,576 bytes.

mzstd speed / C: 1.052×; output: 54,185 / 54,185 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C1,3071,264–1,352765.2054,1855.167%1.000×
mzstd1,2421,203–1,281805.2754,1855.167%1.052×

alice29

Alice in Wonderland text from the Canterbury corpus (testdata/alice29.txt). Input: 152,089 bytes.

mzstd speed / C: 0.998×; output: 53,372 / 53,372 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C1,1461,120–1,177126.5253,37235.093%1.000×
mzstd1,1481,123–1,180126.3253,37235.093%0.998×

Compression level 6

Benchmark index · Median overview · ← Level 5 · Level 7 →

Recorded run: long-20261109 (2026-11-09). Raw results · Environment · Run analysis.

Median across all datasets

Each of the 8 datasets has equal weight, including empty and tiny input. Speed / C = C mean latency / implementation mean latency; output / C = implementation bytes / C bytes. We take the median of these 8 per-dataset ratios separately for speed and size. 1× matches C; higher speed and lower output are better. These are medians across datasets, not median sample latencies, and do not imply statistical significance.

ImplementationMedian speed / C ↑Median output / C ↓Datasets
zstd C1.000×1.000×8
mzstd1.222×1.000×8
Measurement setup and interpretation

zstd 1.5.7 (the C library built by zstd-ffi) and mzstd at the recorded working tree compress identical bytes at this level. Every frame was checked byte-identical to zstd’s and decoded back to its input before any timing.

These are cold native API measurements: construction, allocation, compression, and disposal. Complete known input; 50 samples, 3 s warmup, at least 6 s measurement. Host: AMD Ryzen 9 7950X, Linux KVM guest with two vCPUs: the encoder suite, then the decoder suite, each in one Criterion process pinned to vCPU 1; nothing else ran in the guest. Other tenants loaded the host: a fixed C decode varied by 8-43% (p10-p90 wall time) through the day, so sampling was lengthened to average over the noise. unset; Cargo release defaults (bench profile), runtime SIMD dispatch (AVX-512 level on this host; the decoder runs its block loops at the AVX2 level).

Chart whiskers and table intervals describe 95% confidence bounds on mean timing. They do not capture all host or allocator variation. Throughput bounds are transformed latency bounds. Output / input is compressed bytes divided by input bytes; smaller is better, and values above 100% mean expansion. For empty input, throughput and output / input are undefined (—). See the run analysis for measurement limitations.

Dataset details

Ordered by mzstd speed / C, highest first; all 8 datasets are shown. This order uses recorded mean latency, not statistical significance. Bars start at zero.

empty · tiny-text · repeated-1m · binary-64k · random-64k · random-1m · text-1m · alice29

empty

Empty input; measures API overhead. Input: 0 bytes.

mzstd speed / C: 10.385×; output: 9 / 9 bytes.

Exact measurements and confidence bounds
ImplementationMean, ns95% mean interval, nsMiB/sOutput bytesOutput / inputSpeed / C
zstd C2,1932,147–2,250—9—1.000×
mzstd211.1203.6–219.8—9—10.385×

tiny-text

44-byte quick-brown-fox sentence. Input: 44 bytes.

mzstd speed / C: 5.446×; output: 53 / 53 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C2.5252.469–2.58916.6253120.455%1.000×
mzstd0.46360.4429–0.487990.5153120.455%5.446×

repeated-1m

1 MiB of repeated a bytes. Input: 1,048,576 bytes.

mzstd speed / C: 2.910×; output: 49 / 49 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C363346.5–381.32,755.03490.005%1.000×
mzstd124.7123.4–126.28,017.82490.005%2.910×

binary-64k

64 KiB of structured binary bytes: ((i / 16) XOR i) modulo 256. Input: 65,536 bytes.

mzstd speed / C: 1.240×; output: 1,270 / 1,270 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C30.1229.53–30.882,075.041,2701.938%1.000×
mzstd24.2923.35–25.372,573.141,2701.938%1.240×

random-64k

64 KiB prefix of the deterministic pseudorandom corpus. Input: 65,536 bytes.

mzstd speed / C: 1.204×; output: 65,546 / 65,546 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C28.7227.95–29.552,175.9465,546100.015%1.000×
mzstd23.8623.19–24.582,619.9965,546100.015%1.204×

random-1m

1 MiB of deterministic xorshift64 pseudorandom bytes. Input: 1,048,576 bytes.

mzstd speed / C: 1.119×; output: 1,048,609 / 1,048,609 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C263.5255.6–271.83,794.701,048,609100.003%1.000×
mzstd235.5228.5–242.84,246.511,048,609100.003%1.119×

text-1m

Alice text repeated cyclically to 1 MiB. Input: 1,048,576 bytes.

mzstd speed / C: 1.106×; output: 53,196 / 53,196 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C1,8881,826–1,950529.7953,1965.073%1.000×
mzstd1,7061,652–1,764586.2153,1965.073%1.106×

alice29

Alice in Wonderland text from the Canterbury corpus (testdata/alice29.txt). Input: 152,089 bytes.

mzstd speed / C: 0.926×; output: 53,111 / 53,111 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C1,3871,351–1,428104.6053,11134.921%1.000×
mzstd1,4971,450–1,54896.9053,11134.921%0.926×

Compression level 7

Benchmark index · Median overview · ← Level 6 · Level 8 →

Recorded run: long-20261109 (2026-11-09). Raw results · Environment · Run analysis.

Median across all datasets

Each of the 8 datasets has equal weight, including empty and tiny input. Speed / C = C mean latency / implementation mean latency; output / C = implementation bytes / C bytes. We take the median of these 8 per-dataset ratios separately for speed and size. 1× matches C; higher speed and lower output are better. These are medians across datasets, not median sample latencies, and do not imply statistical significance.

ImplementationMedian speed / C ↑Median output / C ↓Datasets
zstd C1.000×1.000×8
mzstd1.244×1.000×8
Measurement setup and interpretation

zstd 1.5.7 (the C library built by zstd-ffi) and mzstd at the recorded working tree compress identical bytes at this level. Every frame was checked byte-identical to zstd’s and decoded back to its input before any timing.

These are cold native API measurements: construction, allocation, compression, and disposal. Complete known input; 50 samples, 3 s warmup, at least 6 s measurement. Host: AMD Ryzen 9 7950X, Linux KVM guest with two vCPUs: the encoder suite, then the decoder suite, each in one Criterion process pinned to vCPU 1; nothing else ran in the guest. Other tenants loaded the host: a fixed C decode varied by 8-43% (p10-p90 wall time) through the day, so sampling was lengthened to average over the noise. unset; Cargo release defaults (bench profile), runtime SIMD dispatch (AVX-512 level on this host; the decoder runs its block loops at the AVX2 level).

Chart whiskers and table intervals describe 95% confidence bounds on mean timing. They do not capture all host or allocator variation. Throughput bounds are transformed latency bounds. Output / input is compressed bytes divided by input bytes; smaller is better, and values above 100% mean expansion. For empty input, throughput and output / input are undefined (—). See the run analysis for measurement limitations.

Dataset details

Ordered by mzstd speed / C, highest first; all 8 datasets are shown. This order uses recorded mean latency, not statistical significance. Bars start at zero.

empty · tiny-text · repeated-1m · binary-64k · random-64k · random-1m · text-1m · alice29

empty

Empty input; measures API overhead. Input: 0 bytes.

mzstd speed / C: 10.730×; output: 9 / 9 bytes.

Exact measurements and confidence bounds
ImplementationMean, ns95% mean interval, nsMiB/sOutput bytesOutput / inputSpeed / C
zstd C2,1992,141–2,271—9—1.000×
mzstd204.9200.3–210.4—9—10.730×

tiny-text

44-byte quick-brown-fox sentence. Input: 44 bytes.

mzstd speed / C: 5.314×; output: 53 / 53 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C2.4232.408–2.44117.3253120.455%1.000×
mzstd0.4560.4403–0.475392.0153120.455%5.314×

repeated-1m

1 MiB of repeated a bytes. Input: 1,048,576 bytes.

mzstd speed / C: 2.427×; output: 49 / 49 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C364.1352.6–378.62,746.15490.005%1.000×
mzstd150.1146.9–153.46,664.32490.005%2.427×

binary-64k

64 KiB of structured binary bytes: ((i / 16) XOR i) modulo 256. Input: 65,536 bytes.

mzstd speed / C: 1.334×; output: 1,270 / 1,270 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C33.3532.71–34.341,874.191,2701.938%1.000×
mzstd24.9924.46–25.592,500.721,2701.938%1.334×

random-64k

64 KiB prefix of the deterministic pseudorandom corpus. Input: 65,536 bytes.

mzstd speed / C: 1.154×; output: 65,546 / 65,546 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C27.4226.71–28.212,279.4165,546100.015%1.000×
mzstd23.7623.2–24.362,629.9965,546100.015%1.154×

random-1m

1 MiB of deterministic xorshift64 pseudorandom bytes. Input: 1,048,576 bytes.

mzstd speed / C: 1.042×; output: 1,048,609 / 1,048,609 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C328.9320.5–3373,040.691,048,609100.003%1.000×
mzstd315.6308.2–322.83,168.421,048,609100.003%1.042×

text-1m

Alice text repeated cyclically to 1 MiB. Input: 1,048,576 bytes.

mzstd speed / C: 0.986×; output: 52,804 / 52,804 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C1,8971,842–1,955527.2252,8045.036%1.000×
mzstd1,9231,858–1,990519.9252,8045.036%0.986×

alice29

Alice in Wonderland text from the Canterbury corpus (testdata/alice29.txt). Input: 152,089 bytes.

mzstd speed / C: 0.929×; output: 53,195 / 53,195 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C1,6451,621–1,67488.1853,19534.976%1.000×
mzstd1,7701,738–1,80681.9453,19534.976%0.929×

Compression level 8

Benchmark index · Median overview · ← Level 7 · Level 9 →

Recorded run: long-20261109 (2026-11-09). Raw results · Environment · Run analysis.

Median across all datasets

Each of the 8 datasets has equal weight, including empty and tiny input. Speed / C = C mean latency / implementation mean latency; output / C = implementation bytes / C bytes. We take the median of these 8 per-dataset ratios separately for speed and size. 1× matches C; higher speed and lower output are better. These are medians across datasets, not median sample latencies, and do not imply statistical significance.

ImplementationMedian speed / C ↑Median output / C ↓Datasets
zstd C1.000×1.000×8
mzstd1.268×1.000×8
Measurement setup and interpretation

zstd 1.5.7 (the C library built by zstd-ffi) and mzstd at the recorded working tree compress identical bytes at this level. Every frame was checked byte-identical to zstd’s and decoded back to its input before any timing.

These are cold native API measurements: construction, allocation, compression, and disposal. Complete known input; 50 samples, 3 s warmup, at least 6 s measurement. Host: AMD Ryzen 9 7950X, Linux KVM guest with two vCPUs: the encoder suite, then the decoder suite, each in one Criterion process pinned to vCPU 1; nothing else ran in the guest. Other tenants loaded the host: a fixed C decode varied by 8-43% (p10-p90 wall time) through the day, so sampling was lengthened to average over the noise. unset; Cargo release defaults (bench profile), runtime SIMD dispatch (AVX-512 level on this host; the decoder runs its block loops at the AVX2 level).

Chart whiskers and table intervals describe 95% confidence bounds on mean timing. They do not capture all host or allocator variation. Throughput bounds are transformed latency bounds. Output / input is compressed bytes divided by input bytes; smaller is better, and values above 100% mean expansion. For empty input, throughput and output / input are undefined (—). See the run analysis for measurement limitations.

Dataset details

Ordered by mzstd speed / C, highest first; all 8 datasets are shown. This order uses recorded mean latency, not statistical significance. Bars start at zero.

empty · tiny-text · repeated-1m · binary-64k · random-64k · random-1m · text-1m · alice29

empty

Empty input; measures API overhead. Input: 0 bytes.

mzstd speed / C: 10.060×; output: 9 / 9 bytes.

Exact measurements and confidence bounds
ImplementationMean, ns95% mean interval, nsMiB/sOutput bytesOutput / inputSpeed / C
zstd C2,2982,237–2,367—9—1.000×
mzstd228.5222.6–235.7—9—10.060×

tiny-text

44-byte quick-brown-fox sentence. Input: 44 bytes.

mzstd speed / C: 5.643×; output: 53 / 53 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C2.4842.439–2.5416.8953120.455%1.000×
mzstd0.44020.4297–0.452795.3353120.455%5.643×

repeated-1m

1 MiB of repeated a bytes. Input: 1,048,576 bytes.

mzstd speed / C: 2.535×; output: 49 / 49 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C543.3535–553.21,840.51490.005%1.000×
mzstd214.3210.7–218.64,665.85490.005%2.535×

binary-64k

64 KiB of structured binary bytes: ((i / 16) XOR i) modulo 256. Input: 65,536 bytes.

mzstd speed / C: 1.375×; output: 1,270 / 1,270 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C33.8233.12–34.711,847.971,2701.938%1.000×
mzstd24.624.06–25.282,540.941,2701.938%1.375×

random-64k

64 KiB prefix of the deterministic pseudorandom corpus. Input: 65,536 bytes.

mzstd speed / C: 1.161×; output: 65,546 / 65,546 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C27.8727.08–28.712,242.7365,546100.015%1.000×
mzstd24.0123.28–24.82,603.5865,546100.015%1.161×

random-1m

1 MiB of deterministic xorshift64 pseudorandom bytes. Input: 1,048,576 bytes.

mzstd speed / C: 1.070×; output: 1,048,609 / 1,048,609 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C344338.5–349.52,907.091,048,609100.003%1.000×
mzstd321.4315.6–326.93,111.091,048,609100.003%1.070×

text-1m

Alice text repeated cyclically to 1 MiB. Input: 1,048,576 bytes.

mzstd speed / C: 0.985×; output: 52,393 / 52,393 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C2,3952,341–2,454417.4752,3934.997%1.000×
mzstd2,4312,351–2,513411.3452,3934.997%0.985×

alice29

Alice in Wonderland text from the Canterbury corpus (testdata/alice29.txt). Input: 152,089 bytes.

mzstd speed / C: 0.885×; output: 52,710 / 52,710 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C2,2252,170–2,28965.1852,71034.657%1.000×
mzstd2,5162,434–2,60157.6552,71034.657%0.885×

Compression level 9

Benchmark index · Median overview · ← Level 8 · Level 10 →

Recorded run: long-20261109 (2026-11-09). Raw results · Environment · Run analysis.

Median across all datasets

Each of the 8 datasets has equal weight, including empty and tiny input. Speed / C = C mean latency / implementation mean latency; output / C = implementation bytes / C bytes. We take the median of these 8 per-dataset ratios separately for speed and size. 1× matches C; higher speed and lower output are better. These are medians across datasets, not median sample latencies, and do not imply statistical significance.

ImplementationMedian speed / C ↑Median output / C ↓Datasets
zstd C1.000×1.000×8
mzstd1.266×1.000×8
Measurement setup and interpretation

zstd 1.5.7 (the C library built by zstd-ffi) and mzstd at the recorded working tree compress identical bytes at this level. Every frame was checked byte-identical to zstd’s and decoded back to its input before any timing.

These are cold native API measurements: construction, allocation, compression, and disposal. Complete known input; 50 samples, 3 s warmup, at least 6 s measurement. Host: AMD Ryzen 9 7950X, Linux KVM guest with two vCPUs: the encoder suite, then the decoder suite, each in one Criterion process pinned to vCPU 1; nothing else ran in the guest. Other tenants loaded the host: a fixed C decode varied by 8-43% (p10-p90 wall time) through the day, so sampling was lengthened to average over the noise. unset; Cargo release defaults (bench profile), runtime SIMD dispatch (AVX-512 level on this host; the decoder runs its block loops at the AVX2 level).

Chart whiskers and table intervals describe 95% confidence bounds on mean timing. They do not capture all host or allocator variation. Throughput bounds are transformed latency bounds. Output / input is compressed bytes divided by input bytes; smaller is better, and values above 100% mean expansion. For empty input, throughput and output / input are undefined (—). See the run analysis for measurement limitations.

Dataset details

Ordered by mzstd speed / C, highest first; all 8 datasets are shown. This order uses recorded mean latency, not statistical significance. Bars start at zero.

empty · tiny-text · repeated-1m · binary-64k · random-64k · random-1m · text-1m · alice29

empty

Empty input; measures API overhead. Input: 0 bytes.

mzstd speed / C: 10.021×; output: 9 / 9 bytes.

Exact measurements and confidence bounds
ImplementationMean, ns95% mean interval, nsMiB/sOutput bytesOutput / inputSpeed / C
zstd C2,2742,221–2,335—9—1.000×
mzstd226.9221.9–232.8—9—10.021×

tiny-text

44-byte quick-brown-fox sentence. Input: 44 bytes.

mzstd speed / C: 5.109×; output: 53 / 53 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C2.6232.574–2.6816.0053120.455%1.000×
mzstd0.51340.4936–0.53681.7353120.455%5.109×

repeated-1m

1 MiB of repeated a bytes. Input: 1,048,576 bytes.

mzstd speed / C: 2.345×; output: 49 / 49 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C622.8603.3–646.91,605.73490.005%1.000×
mzstd265.6262.6–269.23,765.15490.005%2.345×

binary-64k

64 KiB of structured binary bytes: ((i / 16) XOR i) modulo 256. Input: 65,536 bytes.

mzstd speed / C: 1.279×; output: 1,270 / 1,270 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C33.9433.52–34.431,841.471,2701.938%1.000×
mzstd26.5325.41–27.82,355.461,2701.938%1.279×

random-64k

64 KiB prefix of the deterministic pseudorandom corpus. Input: 65,536 bytes.

mzstd speed / C: 1.253×; output: 65,546 / 65,546 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C29.4528.34–30.62,122.3165,546100.015%1.000×
mzstd23.522.92–24.162,659.4865,546100.015%1.253×

random-1m

1 MiB of deterministic xorshift64 pseudorandom bytes. Input: 1,048,576 bytes.

mzstd speed / C: 1.102×; output: 1,048,609 / 1,048,609 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C529.5515–543.51,888.721,048,609100.003%1.000×
mzstd480.3464.3–4962,081.861,048,609100.003%1.102×

text-1m

Alice text repeated cyclically to 1 MiB. Input: 1,048,576 bytes.

mzstd speed / C: 1.090×; output: 52,381 / 52,381 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C3,0472,932–3,166328.2352,3814.995%1.000×
mzstd2,7952,694–2,898357.7852,3814.995%1.090×

alice29

Alice in Wonderland text from the Canterbury corpus (testdata/alice29.txt). Input: 152,089 bytes.

mzstd speed / C: 1.006×; output: 52,188 / 52,188 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C2,6722,623–2,72954.2952,18834.314%1.000×
mzstd2,6572,617–2,70254.6052,18834.314%1.006×

Compression level 10

Benchmark index · Median overview · ← Level 9 · Level 11 →

Recorded run: long-20261109 (2026-11-09). Raw results · Environment · Run analysis.

Median across all datasets

Each of the 8 datasets has equal weight, including empty and tiny input. Speed / C = C mean latency / implementation mean latency; output / C = implementation bytes / C bytes. We take the median of these 8 per-dataset ratios separately for speed and size. 1× matches C; higher speed and lower output are better. These are medians across datasets, not median sample latencies, and do not imply statistical significance.

ImplementationMedian speed / C ↑Median output / C ↓Datasets
zstd C1.000×1.000×8
mzstd1.261×1.000×8
Measurement setup and interpretation

zstd 1.5.7 (the C library built by zstd-ffi) and mzstd at the recorded working tree compress identical bytes at this level. Every frame was checked byte-identical to zstd’s and decoded back to its input before any timing.

These are cold native API measurements: construction, allocation, compression, and disposal. Complete known input; 50 samples, 3 s warmup, at least 6 s measurement. Host: AMD Ryzen 9 7950X, Linux KVM guest with two vCPUs: the encoder suite, then the decoder suite, each in one Criterion process pinned to vCPU 1; nothing else ran in the guest. Other tenants loaded the host: a fixed C decode varied by 8-43% (p10-p90 wall time) through the day, so sampling was lengthened to average over the noise. unset; Cargo release defaults (bench profile), runtime SIMD dispatch (AVX-512 level on this host; the decoder runs its block loops at the AVX2 level).

Chart whiskers and table intervals describe 95% confidence bounds on mean timing. They do not capture all host or allocator variation. Throughput bounds are transformed latency bounds. Output / input is compressed bytes divided by input bytes; smaller is better, and values above 100% mean expansion. For empty input, throughput and output / input are undefined (—). See the run analysis for measurement limitations.

Dataset details

Ordered by mzstd speed / C, highest first; all 8 datasets are shown. This order uses recorded mean latency, not statistical significance. Bars start at zero.

empty · tiny-text · repeated-1m · binary-64k · random-64k · random-1m · alice29 · text-1m

empty

Empty input; measures API overhead. Input: 0 bytes.

mzstd speed / C: 10.012×; output: 9 / 9 bytes.

Exact measurements and confidence bounds
ImplementationMean, ns95% mean interval, nsMiB/sOutput bytesOutput / inputSpeed / C
zstd C2,2922,237–2,353—9—1.000×
mzstd228.9221.5–237.8—9—10.012×

tiny-text

44-byte quick-brown-fox sentence. Input: 44 bytes.

mzstd speed / C: 5.047×; output: 53 / 53 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C2.7022.622–2.78915.5353120.455%1.000×
mzstd0.53530.5044–0.569278.3953120.455%5.047×

repeated-1m

1 MiB of repeated a bytes. Input: 1,048,576 bytes.

mzstd speed / C: 2.447×; output: 49 / 49 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C636.4612.4–664.41,571.46490.005%1.000×
mzstd260258.4–261.93,845.90490.005%2.447×

binary-64k

64 KiB of structured binary bytes: ((i / 16) XOR i) modulo 256. Input: 65,536 bytes.

mzstd speed / C: 1.270×; output: 1,270 / 1,270 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C35.0134.11–36.131,785.021,2701.938%1.000×
mzstd27.5726.42–28.862,266.781,2701.938%1.270×

random-64k

64 KiB prefix of the deterministic pseudorandom corpus. Input: 65,536 bytes.

mzstd speed / C: 1.251×; output: 65,546 / 65,546 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C30.4129.36–31.552,055.0465,546100.015%1.000×
mzstd24.323.61–25.062,571.6065,546100.015%1.251×

random-1m

1 MiB of deterministic xorshift64 pseudorandom bytes. Input: 1,048,576 bytes.

mzstd speed / C: 1.169×; output: 1,048,609 / 1,048,609 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C504.1496–511.81,983.921,048,609100.003%1.000×
mzstd431.4419.9–443.22,318.241,048,609100.003%1.169×

alice29

Alice in Wonderland text from the Canterbury corpus (testdata/alice29.txt). Input: 152,089 bytes.

mzstd speed / C: 0.973×; output: 51,894 / 51,894 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C3,2263,171–3,29044.9651,89434.121%1.000×
mzstd3,3153,209–3,43543.7551,89434.121%0.973×

text-1m

Alice text repeated cyclically to 1 MiB. Input: 1,048,576 bytes.

mzstd speed / C: 0.936×; output: 52,148 / 52,148 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C3,1703,077–3,269315.4152,1484.973%1.000×
mzstd3,3863,282–3,493295.3252,1484.973%0.936×

Compression level 11

Benchmark index · Median overview · ← Level 10 · Level 12 →

Recorded run: long-20261109 (2026-11-09). Raw results · Environment · Run analysis.

Median across all datasets

Each of the 8 datasets has equal weight, including empty and tiny input. Speed / C = C mean latency / implementation mean latency; output / C = implementation bytes / C bytes. We take the median of these 8 per-dataset ratios separately for speed and size. 1× matches C; higher speed and lower output are better. These are medians across datasets, not median sample latencies, and do not imply statistical significance.

ImplementationMedian speed / C ↑Median output / C ↓Datasets
zstd C1.000×1.000×8
mzstd1.164×1.000×8
Measurement setup and interpretation

zstd 1.5.7 (the C library built by zstd-ffi) and mzstd at the recorded working tree compress identical bytes at this level. Every frame was checked byte-identical to zstd’s and decoded back to its input before any timing.

These are cold native API measurements: construction, allocation, compression, and disposal. Complete known input; 50 samples, 3 s warmup, at least 6 s measurement. Host: AMD Ryzen 9 7950X, Linux KVM guest with two vCPUs: the encoder suite, then the decoder suite, each in one Criterion process pinned to vCPU 1; nothing else ran in the guest. Other tenants loaded the host: a fixed C decode varied by 8-43% (p10-p90 wall time) through the day, so sampling was lengthened to average over the noise. unset; Cargo release defaults (bench profile), runtime SIMD dispatch (AVX-512 level on this host; the decoder runs its block loops at the AVX2 level).

Chart whiskers and table intervals describe 95% confidence bounds on mean timing. They do not capture all host or allocator variation. Throughput bounds are transformed latency bounds. Output / input is compressed bytes divided by input bytes; smaller is better, and values above 100% mean expansion. For empty input, throughput and output / input are undefined (—). See the run analysis for measurement limitations.

Dataset details

Ordered by mzstd speed / C, highest first; all 8 datasets are shown. This order uses recorded mean latency, not statistical significance. Bars start at zero.

empty · repeated-1m · tiny-text · binary-64k · random-1m · alice29 · text-1m · random-64k

empty

Empty input; measures API overhead. Input: 0 bytes.

mzstd speed / C: 6.068×; output: 9 / 9 bytes.

Exact measurements and confidence bounds
ImplementationMean, ns95% mean interval, nsMiB/sOutput bytesOutput / inputSpeed / C
zstd C2,4732,397–2,549—9—1.000×
mzstd407.5381.7–434—9—6.068×

repeated-1m

1 MiB of repeated a bytes. Input: 1,048,576 bytes.

mzstd speed / C: 2.231×; output: 49 / 49 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C613.3599.5–629.91,630.62490.005%1.000×
mzstd274.9269.2–281.13,637.48490.005%2.231×

tiny-text

44-byte quick-brown-fox sentence. Input: 44 bytes.

mzstd speed / C: 2.015×; output: 53 / 53 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C4.3084.142–4.4869.7453120.455%1.000×
mzstd2.1392.046–2.24119.6253120.455%2.015×

binary-64k

64 KiB of structured binary bytes: ((i / 16) XOR i) modulo 256. Input: 65,536 bytes.

mzstd speed / C: 1.189×; output: 1,276 / 1,276 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C31.631.19–32.21,977.851,2761.947%1.000×
mzstd26.5825.91–27.362,351.271,2761.947%1.189×

random-1m

1 MiB of deterministic xorshift64 pseudorandom bytes. Input: 1,048,576 bytes.

mzstd speed / C: 1.140×; output: 1,048,609 / 1,048,609 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C480.7472.3–488.32,080.191,048,609100.003%1.000×
mzstd421.6412.4–430.32,371.701,048,609100.003%1.140×

alice29

Alice in Wonderland text from the Canterbury corpus (testdata/alice29.txt). Input: 152,089 bytes.

mzstd speed / C: 1.072×; output: 51,491 / 51,491 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C7,3327,201–7,46619.7851,49133.856%1.000×
mzstd6,8376,722–6,97221.2151,49133.856%1.072×

text-1m

Alice text repeated cyclically to 1 MiB. Input: 1,048,576 bytes.

mzstd speed / C: 1.041×; output: 52,034 / 52,034 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C3,8033,670–3,940262.9452,0344.962%1.000×
mzstd3,6543,536–3,773273.6452,0344.962%1.041×

random-64k

64 KiB prefix of the deterministic pseudorandom corpus. Input: 65,536 bytes.

mzstd speed / C: 1.011×; output: 65,546 / 65,546 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C95.9192.19–99.96651.6865,546100.015%1.000×
mzstd94.8389.56–100.3659.1165,546100.015%1.011×

Compression level 12

Benchmark index · Median overview · ← Level 11 · Level 13 →

Recorded run: long-20261109 (2026-11-09). Raw results · Environment · Run analysis.

Median across all datasets

Each of the 8 datasets has equal weight, including empty and tiny input. Speed / C = C mean latency / implementation mean latency; output / C = implementation bytes / C bytes. We take the median of these 8 per-dataset ratios separately for speed and size. 1× matches C; higher speed and lower output are better. These are medians across datasets, not median sample latencies, and do not imply statistical significance.

ImplementationMedian speed / C ↑Median output / C ↓Datasets
zstd C1.000×1.000×8
mzstd1.184×1.000×8
Measurement setup and interpretation

zstd 1.5.7 (the C library built by zstd-ffi) and mzstd at the recorded working tree compress identical bytes at this level. Every frame was checked byte-identical to zstd’s and decoded back to its input before any timing.

These are cold native API measurements: construction, allocation, compression, and disposal. Complete known input; 50 samples, 3 s warmup, at least 6 s measurement. Host: AMD Ryzen 9 7950X, Linux KVM guest with two vCPUs: the encoder suite, then the decoder suite, each in one Criterion process pinned to vCPU 1; nothing else ran in the guest. Other tenants loaded the host: a fixed C decode varied by 8-43% (p10-p90 wall time) through the day, so sampling was lengthened to average over the noise. unset; Cargo release defaults (bench profile), runtime SIMD dispatch (AVX-512 level on this host; the decoder runs its block loops at the AVX2 level).

Chart whiskers and table intervals describe 95% confidence bounds on mean timing. They do not capture all host or allocator variation. Throughput bounds are transformed latency bounds. Output / input is compressed bytes divided by input bytes; smaller is better, and values above 100% mean expansion. For empty input, throughput and output / input are undefined (—). See the run analysis for measurement limitations.

Dataset details

Ordered by mzstd speed / C, highest first; all 8 datasets are shown. This order uses recorded mean latency, not statistical significance. Bars start at zero.

empty · repeated-1m · tiny-text · binary-64k · random-64k · random-1m · text-1m · alice29

empty

Empty input; measures API overhead. Input: 0 bytes.

mzstd speed / C: 6.807×; output: 9 / 9 bytes.

Exact measurements and confidence bounds
ImplementationMean, ns95% mean interval, nsMiB/sOutput bytesOutput / inputSpeed / C
zstd C2,1992,186–2,215—9—1.000×
mzstd323.1319.3–328.1—9—6.807×

repeated-1m

1 MiB of repeated a bytes. Input: 1,048,576 bytes.

mzstd speed / C: 2.238×; output: 49 / 49 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C603.4591.2–619.11,657.14490.005%1.000×
mzstd269.6264.7–275.23,709.16490.005%2.238×

tiny-text

44-byte quick-brown-fox sentence. Input: 44 bytes.

mzstd speed / C: 1.893×; output: 53 / 53 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C4.1924.093–4.30710.0153120.455%1.000×
mzstd2.2142.161–2.27618.9553120.455%1.893×

binary-64k

64 KiB of structured binary bytes: ((i / 16) XOR i) modulo 256. Input: 65,536 bytes.

mzstd speed / C: 1.271×; output: 1,276 / 1,276 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C34.0232.76–35.461,837.291,2761.947%1.000×
mzstd26.7725.85–27.812,334.991,2761.947%1.271×

random-64k

64 KiB prefix of the deterministic pseudorandom corpus. Input: 65,536 bytes.

mzstd speed / C: 1.097×; output: 65,546 / 65,546 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C96.4191.85–101.3648.2565,546100.015%1.000×
mzstd87.8683.48–92.68711.3965,546100.015%1.097×

random-1m

1 MiB of deterministic xorshift64 pseudorandom bytes. Input: 1,048,576 bytes.

mzstd speed / C: 1.091×; output: 1,048,609 / 1,048,609 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C458.6443.3–474.32,180.511,048,609100.003%1.000×
mzstd420.4410.5–4302,378.651,048,609100.003%1.091×

text-1m

Alice text repeated cyclically to 1 MiB. Input: 1,048,576 bytes.

mzstd speed / C: 1.080×; output: 52,034 / 52,034 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C4,0153,897–4,130249.0752,0344.962%1.000×
mzstd3,7173,586–3,845269.0752,0344.962%1.080×

alice29

Alice in Wonderland text from the Canterbury corpus (testdata/alice29.txt). Input: 152,089 bytes.

mzstd speed / C: 0.880×; output: 51,492 / 51,492 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C6,9516,847–7,06520.8751,49233.856%1.000×
mzstd7,8967,758–8,03718.3751,49233.856%0.880×

Compression level 13

Benchmark index · Median overview · ← Level 12 · Level 14 →

Recorded run: long-20261109 (2026-11-09). Raw results · Environment · Run analysis.

Median across all datasets

Each of the 8 datasets has equal weight, including empty and tiny input. Speed / C = C mean latency / implementation mean latency; output / C = implementation bytes / C bytes. We take the median of these 8 per-dataset ratios separately for speed and size. 1× matches C; higher speed and lower output are better. These are medians across datasets, not median sample latencies, and do not imply statistical significance.

ImplementationMedian speed / C ↑Median output / C ↓Datasets
zstd C1.000×1.000×8
mzstd1.172×1.000×8
Measurement setup and interpretation

zstd 1.5.7 (the C library built by zstd-ffi) and mzstd at the recorded working tree compress identical bytes at this level. Every frame was checked byte-identical to zstd’s and decoded back to its input before any timing.

These are cold native API measurements: construction, allocation, compression, and disposal. Complete known input; 50 samples, 3 s warmup, at least 6 s measurement. Host: AMD Ryzen 9 7950X, Linux KVM guest with two vCPUs: the encoder suite, then the decoder suite, each in one Criterion process pinned to vCPU 1; nothing else ran in the guest. Other tenants loaded the host: a fixed C decode varied by 8-43% (p10-p90 wall time) through the day, so sampling was lengthened to average over the noise. unset; Cargo release defaults (bench profile), runtime SIMD dispatch (AVX-512 level on this host; the decoder runs its block loops at the AVX2 level).

Chart whiskers and table intervals describe 95% confidence bounds on mean timing. They do not capture all host or allocator variation. Throughput bounds are transformed latency bounds. Output / input is compressed bytes divided by input bytes; smaller is better, and values above 100% mean expansion. For empty input, throughput and output / input are undefined (—). See the run analysis for measurement limitations.

Dataset details

Ordered by mzstd speed / C, highest first; all 8 datasets are shown. This order uses recorded mean latency, not statistical significance. Bars start at zero.

empty · repeated-1m · tiny-text · binary-64k · alice29 · random-64k · text-1m · random-1m

empty

Empty input; measures API overhead. Input: 0 bytes.

mzstd speed / C: 6.491×; output: 9 / 9 bytes.

Exact measurements and confidence bounds
ImplementationMean, ns95% mean interval, nsMiB/sOutput bytesOutput / inputSpeed / C
zstd C2,3472,275–2,426—9—1.000×
mzstd361.6346.1–378.9—9—6.491×

repeated-1m

1 MiB of repeated a bytes. Input: 1,048,576 bytes.

mzstd speed / C: 3.155×; output: 50 / 50 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C1,3401,307–1,380746.11500.005%1.000×
mzstd424.8419.9–430.12,354.24500.005%3.155×

tiny-text

44-byte quick-brown-fox sentence. Input: 44 bytes.

mzstd speed / C: 1.570×; output: 53 / 53 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C6.4876.349–6.6526.4753120.455%1.000×
mzstd4.1314.031–4.24710.1653120.455%1.570×

binary-64k

64 KiB of structured binary bytes: ((i / 16) XOR i) modulo 256. Input: 65,536 bytes.

mzstd speed / C: 1.201×; output: 1,259 / 1,259 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C117.9112.6–123.8529.971,2591.921%1.000×
mzstd98.2395.17–101.8636.241,2591.921%1.201×

alice29

Alice in Wonderland text from the Canterbury corpus (testdata/alice29.txt). Input: 152,089 bytes.

mzstd speed / C: 1.144×; output: 49,705 / 49,705 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C1.137e+041.105e+04–1.17e+0412.7649,70532.682%1.000×
mzstd9,9379,794–1.01e+0414.6049,70532.682%1.144×

random-64k

64 KiB prefix of the deterministic pseudorandom corpus. Input: 65,536 bytes.

mzstd speed / C: 1.050×; output: 65,546 / 65,546 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C860.1834.3–889.872.6765,546100.015%1.000×
mzstd819.2787.5–852.976.3065,546100.015%1.050×

text-1m

Alice text repeated cyclically to 1 MiB. Input: 1,048,576 bytes.

mzstd speed / C: 0.959×; output: 51,847 / 51,847 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C7,9567,729–8,185125.7051,8474.945%1.000×
mzstd8,2968,068–8,529120.5451,8474.945%0.959×

random-1m

1 MiB of deterministic xorshift64 pseudorandom bytes. Input: 1,048,576 bytes.

mzstd speed / C: 0.849×; output: 1,048,609 / 1,048,609 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C3,1963,082–3,318312.921,048,609100.003%1.000×
mzstd3,7623,549–3,992265.791,048,609100.003%0.849×

Compression level 14

Benchmark index · Median overview · ← Level 13 · Level 15 →

Recorded run: long-20261109 (2026-11-09). Raw results · Environment · Run analysis.

Median across all datasets

Each of the 8 datasets has equal weight, including empty and tiny input. Speed / C = C mean latency / implementation mean latency; output / C = implementation bytes / C bytes. We take the median of these 8 per-dataset ratios separately for speed and size. 1× matches C; higher speed and lower output are better. These are medians across datasets, not median sample latencies, and do not imply statistical significance.

ImplementationMedian speed / C ↑Median output / C ↓Datasets
zstd C1.000×1.000×8
mzstd1.111×1.000×8
Measurement setup and interpretation

zstd 1.5.7 (the C library built by zstd-ffi) and mzstd at the recorded working tree compress identical bytes at this level. Every frame was checked byte-identical to zstd’s and decoded back to its input before any timing.

These are cold native API measurements: construction, allocation, compression, and disposal. Complete known input; 50 samples, 3 s warmup, at least 6 s measurement. Host: AMD Ryzen 9 7950X, Linux KVM guest with two vCPUs: the encoder suite, then the decoder suite, each in one Criterion process pinned to vCPU 1; nothing else ran in the guest. Other tenants loaded the host: a fixed C decode varied by 8-43% (p10-p90 wall time) through the day, so sampling was lengthened to average over the noise. unset; Cargo release defaults (bench profile), runtime SIMD dispatch (AVX-512 level on this host; the decoder runs its block loops at the AVX2 level).

Chart whiskers and table intervals describe 95% confidence bounds on mean timing. They do not capture all host or allocator variation. Throughput bounds are transformed latency bounds. Output / input is compressed bytes divided by input bytes; smaller is better, and values above 100% mean expansion. For empty input, throughput and output / input are undefined (—). See the run analysis for measurement limitations.

Dataset details

Ordered by mzstd speed / C, highest first; all 8 datasets are shown. This order uses recorded mean latency, not statistical significance. Bars start at zero.

empty · repeated-1m · tiny-text · binary-64k · text-1m · alice29 · random-1m · random-64k

empty

Empty input; measures API overhead. Input: 0 bytes.

mzstd speed / C: 6.560×; output: 9 / 9 bytes.

Exact measurements and confidence bounds
ImplementationMean, ns95% mean interval, nsMiB/sOutput bytesOutput / inputSpeed / C
zstd C2,2862,230–2,349—9—1.000×
mzstd348.5335.7–363.2—9—6.560×

repeated-1m

1 MiB of repeated a bytes. Input: 1,048,576 bytes.

mzstd speed / C: 3.326×; output: 50 / 50 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C1,5331,454–1,618652.35500.005%1.000×
mzstd460.9452.5–469.42,169.62500.005%3.326×

tiny-text

44-byte quick-brown-fox sentence. Input: 44 bytes.

mzstd speed / C: 1.513×; output: 53 / 53 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C6.626.519–6.746.3453120.455%1.000×
mzstd4.3754.215–4.5519.5953120.455%1.513×

binary-64k

64 KiB of structured binary bytes: ((i / 16) XOR i) modulo 256. Input: 65,536 bytes.

mzstd speed / C: 1.200×; output: 1,259 / 1,259 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C121.9118.1–126.3512.851,2591.921%1.000×
mzstd101.598.42–105.5615.551,2591.921%1.200×

text-1m

Alice text repeated cyclically to 1 MiB. Input: 1,048,576 bytes.

mzstd speed / C: 1.023×; output: 51,818 / 51,818 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C7,5147,284–7,749133.0951,8184.942%1.000×
mzstd7,3477,163–7,532136.1251,8184.942%1.023×

alice29

Alice in Wonderland text from the Canterbury corpus (testdata/alice29.txt). Input: 152,089 bytes.

mzstd speed / C: 1.007×; output: 49,635 / 49,635 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C1.275e+041.243e+04–1.309e+0411.3849,63532.635%1.000×
mzstd1.267e+041.241e+04–1.293e+0411.4549,63532.635%1.007×

random-1m

1 MiB of deterministic xorshift64 pseudorandom bytes. Input: 1,048,576 bytes.

mzstd speed / C: 0.953×; output: 1,048,609 / 1,048,609 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C2,9572,823–3,117338.231,048,609100.003%1.000×
mzstd3,1032,961–3,246322.231,048,609100.003%0.953×

random-64k

64 KiB prefix of the deterministic pseudorandom corpus. Input: 65,536 bytes.

mzstd speed / C: 0.915×; output: 65,546 / 65,546 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C1,2341,193–1,27850.6665,546100.015%1.000×
mzstd1,3491,298–1,40046.3365,546100.015%0.915×

Compression level 15

Benchmark index · Median overview · ← Level 14 · Level 16 →

Recorded run: long-20261109 (2026-11-09). Raw results · Environment · Run analysis.

Median across all datasets

Each of the 8 datasets has equal weight, including empty and tiny input. Speed / C = C mean latency / implementation mean latency; output / C = implementation bytes / C bytes. We take the median of these 8 per-dataset ratios separately for speed and size. 1× matches C; higher speed and lower output are better. These are medians across datasets, not median sample latencies, and do not imply statistical significance.

ImplementationMedian speed / C ↑Median output / C ↓Datasets
zstd C1.000×1.000×8
mzstd1.195×1.000×8
Measurement setup and interpretation

zstd 1.5.7 (the C library built by zstd-ffi) and mzstd at the recorded working tree compress identical bytes at this level. Every frame was checked byte-identical to zstd’s and decoded back to its input before any timing.

These are cold native API measurements: construction, allocation, compression, and disposal. Complete known input; 50 samples, 3 s warmup, at least 6 s measurement. Host: AMD Ryzen 9 7950X, Linux KVM guest with two vCPUs: the encoder suite, then the decoder suite, each in one Criterion process pinned to vCPU 1; nothing else ran in the guest. Other tenants loaded the host: a fixed C decode varied by 8-43% (p10-p90 wall time) through the day, so sampling was lengthened to average over the noise. unset; Cargo release defaults (bench profile), runtime SIMD dispatch (AVX-512 level on this host; the decoder runs its block loops at the AVX2 level).

Chart whiskers and table intervals describe 95% confidence bounds on mean timing. They do not capture all host or allocator variation. Throughput bounds are transformed latency bounds. Output / input is compressed bytes divided by input bytes; smaller is better, and values above 100% mean expansion. For empty input, throughput and output / input are undefined (—). See the run analysis for measurement limitations.

Dataset details

Ordered by mzstd speed / C, highest first; all 8 datasets are shown. This order uses recorded mean latency, not statistical significance. Bars start at zero.

empty · repeated-1m · tiny-text · random-64k · binary-64k · alice29 · text-1m · random-1m

empty

Empty input; measures API overhead. Input: 0 bytes.

mzstd speed / C: 6.138×; output: 9 / 9 bytes.

Exact measurements and confidence bounds
ImplementationMean, ns95% mean interval, nsMiB/sOutput bytesOutput / inputSpeed / C
zstd C2,2422,195–2,296—9—1.000×
mzstd365.3349.6–381.9—9—6.138×

repeated-1m

1 MiB of repeated a bytes. Input: 1,048,576 bytes.

mzstd speed / C: 3.316×; output: 50 / 50 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C1,5731,485–1,666635.73500.005%1.000×
mzstd474.4458.5–492.92,108.14500.005%3.316×

tiny-text

44-byte quick-brown-fox sentence. Input: 44 bytes.

mzstd speed / C: 1.568×; output: 53 / 53 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C6.9226.719–7.1426.0653120.455%1.000×
mzstd4.4144.224–4.6229.5153120.455%1.568×

random-64k

64 KiB prefix of the deterministic pseudorandom corpus. Input: 65,536 bytes.

mzstd speed / C: 1.244×; output: 65,546 / 65,546 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C1,4351,377–1,49443.5665,546100.015%1.000×
mzstd1,1531,097–1,21454.2065,546100.015%1.244×

binary-64k

64 KiB of structured binary bytes: ((i / 16) XOR i) modulo 256. Input: 65,536 bytes.

mzstd speed / C: 1.145×; output: 1,259 / 1,259 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C121.9117.3–127.3512.761,2591.921%1.000×
mzstd106.5102.6–110.8587.001,2591.921%1.145×

alice29

Alice in Wonderland text from the Canterbury corpus (testdata/alice29.txt). Input: 152,089 bytes.

mzstd speed / C: 1.088×; output: 49,610 / 49,610 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C1.321e+041.275e+04–1.368e+0410.9849,61032.619%1.000×
mzstd1.215e+041.183e+04–1.25e+0411.9449,61032.619%1.088×

text-1m

Alice text repeated cyclically to 1 MiB. Input: 1,048,576 bytes.

mzstd speed / C: 1.033×; output: 51,817 / 51,817 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C8,0157,676–8,358124.7751,8174.942%1.000×
mzstd7,7627,539–7,986128.8451,8174.942%1.033×

random-1m

1 MiB of deterministic xorshift64 pseudorandom bytes. Input: 1,048,576 bytes.

mzstd speed / C: 1.009×; output: 1,048,609 / 1,048,609 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C2,9522,853–3,052338.791,048,609100.003%1.000×
mzstd2,9272,761–3,091341.681,048,609100.003%1.009×

Compression level 16

Benchmark index · Median overview · ← Level 15 · Level 17 →

Recorded run: long-20261109 (2026-11-09). Raw results · Environment · Run analysis.

Median across all datasets

Each of the 8 datasets has equal weight, including empty and tiny input. Speed / C = C mean latency / implementation mean latency; output / C = implementation bytes / C bytes. We take the median of these 8 per-dataset ratios separately for speed and size. 1× matches C; higher speed and lower output are better. These are medians across datasets, not median sample latencies, and do not imply statistical significance.

ImplementationMedian speed / C ↑Median output / C ↓Datasets
zstd C1.000×1.000×8
mzstd1.337×1.000×8
Measurement setup and interpretation

zstd 1.5.7 (the C library built by zstd-ffi) and mzstd at the recorded working tree compress identical bytes at this level. Every frame was checked byte-identical to zstd’s and decoded back to its input before any timing.

These are cold native API measurements: construction, allocation, compression, and disposal. Complete known input; 50 samples, 3 s warmup, at least 6 s measurement. Host: AMD Ryzen 9 7950X, Linux KVM guest with two vCPUs: the encoder suite, then the decoder suite, each in one Criterion process pinned to vCPU 1; nothing else ran in the guest. Other tenants loaded the host: a fixed C decode varied by 8-43% (p10-p90 wall time) through the day, so sampling was lengthened to average over the noise. unset; Cargo release defaults (bench profile), runtime SIMD dispatch (AVX-512 level on this host; the decoder runs its block loops at the AVX2 level).

Chart whiskers and table intervals describe 95% confidence bounds on mean timing. They do not capture all host or allocator variation. Throughput bounds are transformed latency bounds. Output / input is compressed bytes divided by input bytes; smaller is better, and values above 100% mean expansion. For empty input, throughput and output / input are undefined (—). See the run analysis for measurement limitations.

Dataset details

Ordered by mzstd speed / C, highest first; all 8 datasets are shown. This order uses recorded mean latency, not statistical significance. Bars start at zero.

empty · random-1m · repeated-1m · tiny-text · text-1m · binary-64k · random-64k · alice29

empty

Empty input; measures API overhead. Input: 0 bytes.

mzstd speed / C: 6.648×; output: 9 / 9 bytes.

Exact measurements and confidence bounds
ImplementationMean, ns95% mean interval, nsMiB/sOutput bytesOutput / inputSpeed / C
zstd C2,3992,313–2,493—9—1.000×
mzstd360.9347.3–376.2—9—6.648×

random-1m

1 MiB of deterministic xorshift64 pseudorandom bytes. Input: 1,048,576 bytes.

mzstd speed / C: 1.672×; output: 1,048,609 / 1,048,609 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C5.585e+045.078e+04–6.109e+0417.901,048,609100.003%1.000×
mzstd3.34e+043.171e+04–3.518e+0429.941,048,609100.003%1.672×

repeated-1m

1 MiB of repeated a bytes. Input: 1,048,576 bytes.

mzstd speed / C: 1.369×; output: 49 / 49 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C891.5855.5–929.51,121.74490.005%1.000×
mzstd651629.4–673.71,536.19490.005%1.369×

tiny-text

44-byte quick-brown-fox sentence. Input: 44 bytes.

mzstd speed / C: 1.347×; output: 53 / 53 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C6.8736.804–6.9636.1153120.455%1.000×
mzstd5.1024.897–5.3188.2253120.455%1.347×

text-1m

Alice text repeated cyclically to 1 MiB. Input: 1,048,576 bytes.

mzstd speed / C: 1.328×; output: 49,924 / 49,924 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C1.399e+041.361e+04–1.436e+0471.4949,9244.761%1.000×
mzstd1.053e+041.021e+04–1.089e+0494.9449,9244.761%1.328×

binary-64k

64 KiB of structured binary bytes: ((i / 16) XOR i) modulo 256. Input: 65,536 bytes.

mzstd speed / C: 1.242×; output: 1,259 / 1,259 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C147.9144.9–152.1422.491,2591.921%1.000×
mzstd119.1117.6–121.1524.551,2591.921%1.242×

random-64k

64 KiB prefix of the deterministic pseudorandom corpus. Input: 65,536 bytes.

mzstd speed / C: 1.149×; output: 65,546 / 65,546 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C1,2061,165–1,25151.8465,546100.015%1.000×
mzstd1,0491,026–1,07959.5765,546100.015%1.149×

alice29

Alice in Wonderland text from the Canterbury corpus (testdata/alice29.txt). Input: 152,089 bytes.

mzstd speed / C: 1.009×; output: 49,355 / 49,355 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C1.663e+041.621e+04–1.707e+048.7249,35532.451%1.000×
mzstd1.649e+041.583e+04–1.712e+048.8049,35532.451%1.009×

Compression level 17

Benchmark index · Median overview · ← Level 16 · Level 18 →

Recorded run: long-20261109 (2026-11-09). Raw results · Environment · Run analysis.

Median across all datasets

Each of the 8 datasets has equal weight, including empty and tiny input. Speed / C = C mean latency / implementation mean latency; output / C = implementation bytes / C bytes. We take the median of these 8 per-dataset ratios separately for speed and size. 1× matches C; higher speed and lower output are better. These are medians across datasets, not median sample latencies, and do not imply statistical significance.

ImplementationMedian speed / C ↑Median output / C ↓Datasets
zstd C1.000×1.000×8
mzstd1.298×1.000×8
Measurement setup and interpretation

zstd 1.5.7 (the C library built by zstd-ffi) and mzstd at the recorded working tree compress identical bytes at this level. Every frame was checked byte-identical to zstd’s and decoded back to its input before any timing.

These are cold native API measurements: construction, allocation, compression, and disposal. Complete known input; 50 samples, 3 s warmup, at least 6 s measurement. Host: AMD Ryzen 9 7950X, Linux KVM guest with two vCPUs: the encoder suite, then the decoder suite, each in one Criterion process pinned to vCPU 1; nothing else ran in the guest. Other tenants loaded the host: a fixed C decode varied by 8-43% (p10-p90 wall time) through the day, so sampling was lengthened to average over the noise. unset; Cargo release defaults (bench profile), runtime SIMD dispatch (AVX-512 level on this host; the decoder runs its block loops at the AVX2 level).

Chart whiskers and table intervals describe 95% confidence bounds on mean timing. They do not capture all host or allocator variation. Throughput bounds are transformed latency bounds. Output / input is compressed bytes divided by input bytes; smaller is better, and values above 100% mean expansion. For empty input, throughput and output / input are undefined (—). See the run analysis for measurement limitations.

Dataset details

Ordered by mzstd speed / C, highest first; all 8 datasets are shown. This order uses recorded mean latency, not statistical significance. Bars start at zero.

empty · random-1m · repeated-1m · tiny-text · binary-64k · alice29 · text-1m · random-64k

empty

Empty input; measures API overhead. Input: 0 bytes.

mzstd speed / C: 5.443×; output: 9 / 9 bytes.

Exact measurements and confidence bounds
ImplementationMean, ns95% mean interval, nsMiB/sOutput bytesOutput / inputSpeed / C
zstd C2,3182,280–2,364—9—1.000×
mzstd425.9409.1–444—9—5.443×

random-1m

1 MiB of deterministic xorshift64 pseudorandom bytes. Input: 1,048,576 bytes.

mzstd speed / C: 1.725×; output: 1,048,609 / 1,048,609 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C5.724e+045.218e+04–6.247e+0417.471,048,609100.003%1.000×
mzstd3.319e+043.152e+04–3.485e+0430.131,048,609100.003%1.725×

repeated-1m

1 MiB of repeated a bytes. Input: 1,048,576 bytes.

mzstd speed / C: 1.554×; output: 49 / 49 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C914.1884.2–944.41,093.96490.005%1.000×
mzstd588.1574–603.31,700.44490.005%1.554×

tiny-text

44-byte quick-brown-fox sentence. Input: 44 bytes.

mzstd speed / C: 1.394×; output: 53 / 53 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C7.2186.988–7.4655.8153120.455%1.000×
mzstd5.1784.954–5.4128.1053120.455%1.394×

binary-64k

64 KiB of structured binary bytes: ((i / 16) XOR i) modulo 256. Input: 65,536 bytes.

mzstd speed / C: 1.203×; output: 1,259 / 1,259 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C148.4144.9–152.6421.261,2591.921%1.000×
mzstd123.4120–127.6506.641,2591.921%1.203×

alice29

Alice in Wonderland text from the Canterbury corpus (testdata/alice29.txt). Input: 152,089 bytes.

mzstd speed / C: 1.151×; output: 49,341 / 49,341 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C1.708e+041.647e+04–1.769e+048.4949,34132.442%1.000×
mzstd1.483e+041.432e+04–1.538e+049.7849,34132.442%1.151×

text-1m

Alice text repeated cyclically to 1 MiB. Input: 1,048,576 bytes.

mzstd speed / C: 1.142×; output: 49,642 / 49,642 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C1.342e+041.305e+04–1.381e+0474.4949,6424.734%1.000×
mzstd1.176e+041.143e+04–1.212e+0485.0649,6424.734%1.142×

random-64k

64 KiB prefix of the deterministic pseudorandom corpus. Input: 65,536 bytes.

mzstd speed / C: 1.080×; output: 65,546 / 65,546 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C1,1601,133–1,19153.9065,546100.015%1.000×
mzstd1,0741,039–1,11458.2065,546100.015%1.080×

Compression level 18

Benchmark index · Median overview · ← Level 17 · Level 19 →

Recorded run: long-20261109 (2026-11-09). Raw results · Environment · Run analysis.

Median across all datasets

Each of the 8 datasets has equal weight, including empty and tiny input. Speed / C = C mean latency / implementation mean latency; output / C = implementation bytes / C bytes. We take the median of these 8 per-dataset ratios separately for speed and size. 1× matches C; higher speed and lower output are better. These are medians across datasets, not median sample latencies, and do not imply statistical significance.

ImplementationMedian speed / C ↑Median output / C ↓Datasets
zstd C1.000×1.000×8
mzstd1.287×1.000×8
Measurement setup and interpretation

zstd 1.5.7 (the C library built by zstd-ffi) and mzstd at the recorded working tree compress identical bytes at this level. Every frame was checked byte-identical to zstd’s and decoded back to its input before any timing.

These are cold native API measurements: construction, allocation, compression, and disposal. Complete known input; 50 samples, 3 s warmup, at least 6 s measurement. Host: AMD Ryzen 9 7950X, Linux KVM guest with two vCPUs: the encoder suite, then the decoder suite, each in one Criterion process pinned to vCPU 1; nothing else ran in the guest. Other tenants loaded the host: a fixed C decode varied by 8-43% (p10-p90 wall time) through the day, so sampling was lengthened to average over the noise. unset; Cargo release defaults (bench profile), runtime SIMD dispatch (AVX-512 level on this host; the decoder runs its block loops at the AVX2 level).

Chart whiskers and table intervals describe 95% confidence bounds on mean timing. They do not capture all host or allocator variation. Throughput bounds are transformed latency bounds. Output / input is compressed bytes divided by input bytes; smaller is better, and values above 100% mean expansion. For empty input, throughput and output / input are undefined (—). See the run analysis for measurement limitations.

Dataset details

Ordered by mzstd speed / C, highest first; all 8 datasets are shown. This order uses recorded mean latency, not statistical significance. Bars start at zero.

empty · tiny-text · random-1m · repeated-1m · binary-64k · text-1m · alice29 · random-64k

empty

Empty input; measures API overhead. Input: 0 bytes.

mzstd speed / C: 6.005×; output: 9 / 9 bytes.

Exact measurements and confidence bounds
ImplementationMean, ns95% mean interval, nsMiB/sOutput bytesOutput / inputSpeed / C
zstd C2,4812,380–2,592—9—1.000×
mzstd413.1397.5–430.4—9—6.005×

tiny-text

44-byte quick-brown-fox sentence. Input: 44 bytes.

mzstd speed / C: 1.502×; output: 53 / 53 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C6.9556.838–7.0886.0353120.455%1.000×
mzstd4.6324.475–4.8129.0653120.455%1.502×

random-1m

1 MiB of deterministic xorshift64 pseudorandom bytes. Input: 1,048,576 bytes.

mzstd speed / C: 1.401×; output: 1,048,609 / 1,048,609 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C6.256e+045.65e+04–6.863e+0415.981,048,609100.003%1.000×
mzstd4.466e+044.295e+04–4.635e+0422.391,048,609100.003%1.401×

repeated-1m

1 MiB of repeated a bytes. Input: 1,048,576 bytes.

mzstd speed / C: 1.367×; output: 49 / 49 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C851.6826.8–877.81,174.21490.005%1.000×
mzstd623.1607.9–6401,604.80490.005%1.367×

binary-64k

64 KiB of structured binary bytes: ((i / 16) XOR i) modulo 256. Input: 65,536 bytes.

mzstd speed / C: 1.207×; output: 1,259 / 1,259 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C151.3146.7–156.8413.011,2591.921%1.000×
mzstd125.4121.5–129.8498.461,2591.921%1.207×

text-1m

Alice text repeated cyclically to 1 MiB. Input: 1,048,576 bytes.

mzstd speed / C: 1.127×; output: 49,433 / 49,433 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C1.691e+041.648e+04–1.739e+0459.1249,4334.714%1.000×
mzstd1.501e+041.464e+04–1.544e+0466.6349,4334.714%1.127×

alice29

Alice in Wonderland text from the Canterbury corpus (testdata/alice29.txt). Input: 152,089 bytes.

mzstd speed / C: 1.054×; output: 49,215 / 49,215 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C3.018e+042.912e+04–3.127e+044.8149,21532.359%1.000×
mzstd2.864e+042.757e+04–2.972e+045.0649,21532.359%1.054×

random-64k

64 KiB prefix of the deterministic pseudorandom corpus. Input: 65,536 bytes.

mzstd speed / C: 1.034×; output: 65,546 / 65,546 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C1,1361,117–1,15855.0065,546100.015%1.000×
mzstd1,0991,062–1,14356.8565,546100.015%1.034×

Compression level 19

Benchmark index · Median overview · ← Level 18 · Level 20 →

Recorded run: long-20261109 (2026-11-09). Raw results · Environment · Run analysis.

Median across all datasets

Each of the 8 datasets has equal weight, including empty and tiny input. Speed / C = C mean latency / implementation mean latency; output / C = implementation bytes / C bytes. We take the median of these 8 per-dataset ratios separately for speed and size. 1× matches C; higher speed and lower output are better. These are medians across datasets, not median sample latencies, and do not imply statistical significance.

ImplementationMedian speed / C ↑Median output / C ↓Datasets
zstd C1.000×1.000×8
mzstd1.318×1.000×8
Measurement setup and interpretation

zstd 1.5.7 (the C library built by zstd-ffi) and mzstd at the recorded working tree compress identical bytes at this level. Every frame was checked byte-identical to zstd’s and decoded back to its input before any timing.

These are cold native API measurements: construction, allocation, compression, and disposal. Complete known input; 50 samples, 3 s warmup, at least 6 s measurement. Host: AMD Ryzen 9 7950X, Linux KVM guest with two vCPUs: the encoder suite, then the decoder suite, each in one Criterion process pinned to vCPU 1; nothing else ran in the guest. Other tenants loaded the host: a fixed C decode varied by 8-43% (p10-p90 wall time) through the day, so sampling was lengthened to average over the noise. unset; Cargo release defaults (bench profile), runtime SIMD dispatch (AVX-512 level on this host; the decoder runs its block loops at the AVX2 level).

Chart whiskers and table intervals describe 95% confidence bounds on mean timing. They do not capture all host or allocator variation. Throughput bounds are transformed latency bounds. Output / input is compressed bytes divided by input bytes; smaller is better, and values above 100% mean expansion. For empty input, throughput and output / input are undefined (—). See the run analysis for measurement limitations.

Dataset details

Ordered by mzstd speed / C, highest first; all 8 datasets are shown. This order uses recorded mean latency, not statistical significance. Bars start at zero.

empty · tiny-text · random-1m · repeated-1m · alice29 · random-64k · text-1m · binary-64k

empty

Empty input; measures API overhead. Input: 0 bytes.

mzstd speed / C: 5.661×; output: 9 / 9 bytes.

Exact measurements and confidence bounds
ImplementationMean, ns95% mean interval, nsMiB/sOutput bytesOutput / inputSpeed / C
zstd C2,1772,163–2,195—9—1.000×
mzstd384.6376.4–395.3—9—5.661×

tiny-text

44-byte quick-brown-fox sentence. Input: 44 bytes.

mzstd speed / C: 1.485×; output: 53 / 53 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C7.0136.86–7.1975.9853120.455%1.000×
mzstd4.7244.542–4.9278.8853120.455%1.485×

random-1m

1 MiB of deterministic xorshift64 pseudorandom bytes. Input: 1,048,576 bytes.

mzstd speed / C: 1.448×; output: 1,048,609 / 1,048,609 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C7.104e+046.711e+04–7.507e+0414.081,048,609100.003%1.000×
mzstd4.906e+044.711e+04–5.106e+0420.381,048,609100.003%1.448×

repeated-1m

1 MiB of repeated a bytes. Input: 1,048,576 bytes.

mzstd speed / C: 1.342×; output: 49 / 49 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C867.3835.8–899.81,153.00490.005%1.000×
mzstd646.3624.3–670.31,547.25490.005%1.342×

alice29

Alice in Wonderland text from the Canterbury corpus (testdata/alice29.txt). Input: 152,089 bytes.

mzstd speed / C: 1.295×; output: 49,211 / 49,211 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C3.5e+043.395e+04–3.599e+044.1449,21132.357%1.000×
mzstd2.703e+042.605e+04–2.809e+045.3749,21132.357%1.295×

random-64k

64 KiB prefix of the deterministic pseudorandom corpus. Input: 65,536 bytes.

mzstd speed / C: 1.128×; output: 65,546 / 65,546 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C2,4612,357–2,57725.4065,546100.015%1.000×
mzstd2,1822,116–2,25928.6565,546100.015%1.128×

text-1m

Alice text repeated cyclically to 1 MiB. Input: 1,048,576 bytes.

mzstd speed / C: 1.090×; output: 49,285 / 49,285 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C2.899e+042.817e+04–2.99e+0434.5049,2854.700%1.000×
mzstd2.658e+042.601e+04–2.722e+0437.6249,2854.700%1.090×

binary-64k

64 KiB of structured binary bytes: ((i / 16) XOR i) modulo 256. Input: 65,536 bytes.

mzstd speed / C: 1.087×; output: 1,259 / 1,259 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C277.9263.9–293.5224.921,2591.921%1.000×
mzstd255.6240.2–271.7244.571,2591.921%1.087×

Compression level 20

Benchmark index · Median overview · ← Level 19 · Level 21 →

Recorded run: long-20261109 (2026-11-09). Raw results · Environment · Run analysis.

Median across all datasets

Each of the 8 datasets has equal weight, including empty and tiny input. Speed / C = C mean latency / implementation mean latency; output / C = implementation bytes / C bytes. We take the median of these 8 per-dataset ratios separately for speed and size. 1× matches C; higher speed and lower output are better. These are medians across datasets, not median sample latencies, and do not imply statistical significance.

ImplementationMedian speed / C ↑Median output / C ↓Datasets
zstd C1.000×1.000×8
mzstd1.268×1.000×8
Measurement setup and interpretation

zstd 1.5.7 (the C library built by zstd-ffi) and mzstd at the recorded working tree compress identical bytes at this level. Every frame was checked byte-identical to zstd’s and decoded back to its input before any timing.

These are cold native API measurements: construction, allocation, compression, and disposal. Complete known input; 50 samples, 3 s warmup, at least 6 s measurement. Host: AMD Ryzen 9 7950X, Linux KVM guest with two vCPUs: the encoder suite, then the decoder suite, each in one Criterion process pinned to vCPU 1; nothing else ran in the guest. Other tenants loaded the host: a fixed C decode varied by 8-43% (p10-p90 wall time) through the day, so sampling was lengthened to average over the noise. unset; Cargo release defaults (bench profile), runtime SIMD dispatch (AVX-512 level on this host; the decoder runs its block loops at the AVX2 level).

Chart whiskers and table intervals describe 95% confidence bounds on mean timing. They do not capture all host or allocator variation. Throughput bounds are transformed latency bounds. Output / input is compressed bytes divided by input bytes; smaller is better, and values above 100% mean expansion. For empty input, throughput and output / input are undefined (—). See the run analysis for measurement limitations.

Dataset details

Ordered by mzstd speed / C, highest first; all 8 datasets are shown. This order uses recorded mean latency, not statistical significance. Bars start at zero.

empty · tiny-text · random-1m · repeated-1m · binary-64k · text-1m · alice29 · random-64k

empty

Empty input; measures API overhead. Input: 0 bytes.

mzstd speed / C: 5.092×; output: 9 / 9 bytes.

Exact measurements and confidence bounds
ImplementationMean, ns95% mean interval, nsMiB/sOutput bytesOutput / inputSpeed / C
zstd C2,2212,191–2,257—9—1.000×
mzstd436.2414.8–459.1—9—5.092×

tiny-text

44-byte quick-brown-fox sentence. Input: 44 bytes.

mzstd speed / C: 1.451×; output: 53 / 53 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C7.9327.676–8.1885.2953120.455%1.000×
mzstd5.4665.186–5.7527.6853120.455%1.451×

random-1m

1 MiB of deterministic xorshift64 pseudorandom bytes. Input: 1,048,576 bytes.

mzstd speed / C: 1.412×; output: 1,048,609 / 1,048,609 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C6.607e+046.066e+04–7.207e+0415.131,048,609100.003%1.000×
mzstd4.681e+044.455e+04–4.905e+0421.361,048,609100.003%1.412×

repeated-1m

1 MiB of repeated a bytes. Input: 1,048,576 bytes.

mzstd speed / C: 1.310×; output: 49 / 49 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C782.4766–801.51,278.14490.005%1.000×
mzstd597.3585.9–609.71,674.08490.005%1.310×

binary-64k

64 KiB of structured binary bytes: ((i / 16) XOR i) modulo 256. Input: 65,536 bytes.

mzstd speed / C: 1.226×; output: 1,259 / 1,259 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C275.9263.6–289.1226.571,2591.921%1.000×
mzstd225215.3–236.1277.811,2591.921%1.226×

text-1m

Alice text repeated cyclically to 1 MiB. Input: 1,048,576 bytes.

mzstd speed / C: 1.171×; output: 49,285 / 49,285 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C2.986e+042.896e+04–3.088e+0433.4949,2854.700%1.000×
mzstd2.551e+042.52e+04–2.586e+0439.2049,2854.700%1.171×

alice29

Alice in Wonderland text from the Canterbury corpus (testdata/alice29.txt). Input: 152,089 bytes.

mzstd speed / C: 1.135×; output: 49,211 / 49,211 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C3.129e+043.004e+04–3.258e+044.6449,21132.357%1.000×
mzstd2.756e+042.667e+04–2.851e+045.2649,21132.357%1.135×

random-64k

64 KiB prefix of the deterministic pseudorandom corpus. Input: 65,536 bytes.

mzstd speed / C: 1.090×; output: 65,546 / 65,546 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C2,3272,260–2,40726.8665,546100.015%1.000×
mzstd2,1352,069–2,20729.2865,546100.015%1.090×

Compression level 21

Benchmark index · Median overview · ← Level 20 · Level 22 →

Recorded run: long-20261109 (2026-11-09). Raw results · Environment · Run analysis.

Median across all datasets

Each of the 8 datasets has equal weight, including empty and tiny input. Speed / C = C mean latency / implementation mean latency; output / C = implementation bytes / C bytes. We take the median of these 8 per-dataset ratios separately for speed and size. 1× matches C; higher speed and lower output are better. These are medians across datasets, not median sample latencies, and do not imply statistical significance.

ImplementationMedian speed / C ↑Median output / C ↓Datasets
zstd C1.000×1.000×8
mzstd1.219×1.000×8
Measurement setup and interpretation

zstd 1.5.7 (the C library built by zstd-ffi) and mzstd at the recorded working tree compress identical bytes at this level. Every frame was checked byte-identical to zstd’s and decoded back to its input before any timing.

These are cold native API measurements: construction, allocation, compression, and disposal. Complete known input; 50 samples, 3 s warmup, at least 6 s measurement. Host: AMD Ryzen 9 7950X, Linux KVM guest with two vCPUs: the encoder suite, then the decoder suite, each in one Criterion process pinned to vCPU 1; nothing else ran in the guest. Other tenants loaded the host: a fixed C decode varied by 8-43% (p10-p90 wall time) through the day, so sampling was lengthened to average over the noise. unset; Cargo release defaults (bench profile), runtime SIMD dispatch (AVX-512 level on this host; the decoder runs its block loops at the AVX2 level).

Chart whiskers and table intervals describe 95% confidence bounds on mean timing. They do not capture all host or allocator variation. Throughput bounds are transformed latency bounds. Output / input is compressed bytes divided by input bytes; smaller is better, and values above 100% mean expansion. For empty input, throughput and output / input are undefined (—). See the run analysis for measurement limitations.

Dataset details

Ordered by mzstd speed / C, highest first; all 8 datasets are shown. This order uses recorded mean latency, not statistical significance. Bars start at zero.

empty · random-1m · tiny-text · binary-64k · repeated-1m · alice29 · random-64k · text-1m

empty

Empty input; measures API overhead. Input: 0 bytes.

mzstd speed / C: 5.362×; output: 9 / 9 bytes.

Exact measurements and confidence bounds
ImplementationMean, ns95% mean interval, nsMiB/sOutput bytesOutput / inputSpeed / C
zstd C2,2562,207–2,312—9—1.000×
mzstd420.7405.3–437.7—9—5.362×

random-1m

1 MiB of deterministic xorshift64 pseudorandom bytes. Input: 1,048,576 bytes.

mzstd speed / C: 1.883×; output: 1,048,609 / 1,048,609 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C6.008e+045.417e+04–6.646e+0416.641,048,609100.003%1.000×
mzstd3.19e+043.032e+04–3.359e+0431.341,048,609100.003%1.883×

tiny-text

44-byte quick-brown-fox sentence. Input: 44 bytes.

mzstd speed / C: 1.561×; output: 53 / 53 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C7.9647.69–8.2375.2753120.455%1.000×
mzstd5.1014.871–5.3498.2353120.455%1.561×

binary-64k

64 KiB of structured binary bytes: ((i / 16) XOR i) modulo 256. Input: 65,536 bytes.

mzstd speed / C: 1.220×; output: 1,259 / 1,259 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C268.2258.7–278.7233.061,2591.921%1.000×
mzstd219.8209.7–231.8284.381,2591.921%1.220×

repeated-1m

1 MiB of repeated a bytes. Input: 1,048,576 bytes.

mzstd speed / C: 1.218×; output: 49 / 49 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C861.2832.1–8921,161.15490.005%1.000×
mzstd707.2669–750.71,413.98490.005%1.218×

alice29

Alice in Wonderland text from the Canterbury corpus (testdata/alice29.txt). Input: 152,089 bytes.

mzstd speed / C: 1.161×; output: 49,211 / 49,211 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C3.024e+042.924e+04–3.128e+044.8049,21132.357%1.000×
mzstd2.605e+042.524e+04–2.694e+045.5749,21132.357%1.161×

random-64k

64 KiB prefix of the deterministic pseudorandom corpus. Input: 65,536 bytes.

mzstd speed / C: 1.119×; output: 65,546 / 65,546 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C2,6352,523–2,75223.7265,546100.015%1.000×
mzstd2,3552,258–2,45926.5465,546100.015%1.119×

text-1m

Alice text repeated cyclically to 1 MiB. Input: 1,048,576 bytes.

mzstd speed / C: 1.080×; output: 49,285 / 49,285 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C2.894e+042.835e+04–2.96e+0434.5549,2854.700%1.000×
mzstd2.68e+042.598e+04–2.77e+0437.3249,2854.700%1.080×

Compression level 22

Benchmark index · Median overview · ← Level 21

Recorded run: long-20261109 (2026-11-09). Raw results · Environment · Run analysis.

Median across all datasets

Each of the 8 datasets has equal weight, including empty and tiny input. Speed / C = C mean latency / implementation mean latency; output / C = implementation bytes / C bytes. We take the median of these 8 per-dataset ratios separately for speed and size. 1× matches C; higher speed and lower output are better. These are medians across datasets, not median sample latencies, and do not imply statistical significance.

ImplementationMedian speed / C ↑Median output / C ↓Datasets
zstd C1.000×1.000×8
mzstd1.193×1.000×8
Measurement setup and interpretation

zstd 1.5.7 (the C library built by zstd-ffi) and mzstd at the recorded working tree compress identical bytes at this level. Every frame was checked byte-identical to zstd’s and decoded back to its input before any timing.

These are cold native API measurements: construction, allocation, compression, and disposal. Complete known input; 50 samples, 3 s warmup, at least 6 s measurement. Host: AMD Ryzen 9 7950X, Linux KVM guest with two vCPUs: the encoder suite, then the decoder suite, each in one Criterion process pinned to vCPU 1; nothing else ran in the guest. Other tenants loaded the host: a fixed C decode varied by 8-43% (p10-p90 wall time) through the day, so sampling was lengthened to average over the noise. unset; Cargo release defaults (bench profile), runtime SIMD dispatch (AVX-512 level on this host; the decoder runs its block loops at the AVX2 level).

Chart whiskers and table intervals describe 95% confidence bounds on mean timing. They do not capture all host or allocator variation. Throughput bounds are transformed latency bounds. Output / input is compressed bytes divided by input bytes; smaller is better, and values above 100% mean expansion. For empty input, throughput and output / input are undefined (—). See the run analysis for measurement limitations.

Dataset details

Ordered by mzstd speed / C, highest first; all 8 datasets are shown. This order uses recorded mean latency, not statistical significance. Bars start at zero.

empty · tiny-text · repeated-1m · random-64k · binary-64k · random-1m · alice29 · text-1m

empty

Empty input; measures API overhead. Input: 0 bytes.

mzstd speed / C: 5.475×; output: 9 / 9 bytes.

Exact measurements and confidence bounds
ImplementationMean, ns95% mean interval, nsMiB/sOutput bytesOutput / inputSpeed / C
zstd C2,2922,232–2,361—9—1.000×
mzstd418.5403.1–435.6—9—5.475×

tiny-text

44-byte quick-brown-fox sentence. Input: 44 bytes.

mzstd speed / C: 1.572×; output: 53 / 53 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C7.2367.033–7.4615.8053120.455%1.000×
mzstd4.6024.475–4.7559.1253120.455%1.572×

repeated-1m

1 MiB of repeated a bytes. Input: 1,048,576 bytes.

mzstd speed / C: 1.310×; output: 49 / 49 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C800.4781.9–820.91,249.44490.005%1.000×
mzstd611597.9–625.61,636.61490.005%1.310×

random-64k

64 KiB prefix of the deterministic pseudorandom corpus. Input: 65,536 bytes.

mzstd speed / C: 1.225×; output: 65,546 / 65,546 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C2,6332,517–2,75423.7465,546100.015%1.000×
mzstd2,1492,087–2,22029.0865,546100.015%1.225×

binary-64k

64 KiB of structured binary bytes: ((i / 16) XOR i) modulo 256. Input: 65,536 bytes.

mzstd speed / C: 1.161×; output: 1,259 / 1,259 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C253.3248.6–258.7246.731,2591.921%1.000×
mzstd218.3208.6–229.7286.331,2591.921%1.161×

random-1m

1 MiB of deterministic xorshift64 pseudorandom bytes. Input: 1,048,576 bytes.

mzstd speed / C: 1.093×; output: 1,048,609 / 1,048,609 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C3.446e+043.257e+04–3.676e+0429.021,048,609100.003%1.000×
mzstd3.153e+042.991e+04–3.333e+0431.721,048,609100.003%1.093×

alice29

Alice in Wonderland text from the Canterbury corpus (testdata/alice29.txt). Input: 152,089 bytes.

mzstd speed / C: 1.052×; output: 49,211 / 49,211 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C2.896e+042.802e+04–2.998e+045.0149,21132.357%1.000×
mzstd2.752e+042.666e+04–2.843e+045.2749,21132.357%1.052×

text-1m

Alice text repeated cyclically to 1 MiB. Input: 1,048,576 bytes.

mzstd speed / C: 1.049×; output: 49,285 / 49,285 bytes.

Exact measurements and confidence bounds
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
zstd C2.832e+042.782e+04–2.891e+0435.3149,2854.700%1.000×
mzstd2.7e+042.62e+04–2.786e+0437.0349,2854.700%1.049×

Decoder comparison

Benchmark index

Recorded run: long-20261109 (2026-11-09). CSV · Environment · Methodology and limits.

Each value is the median of C mean time / decoder mean time across all 8 datasets, equally weighted. Higher is faster; 1× matches C. Levels are ordered by mzstd median speed / C. Medians do not imply statistical significance.

Both decoders restore identical C-written frames. The level is an encoder setting, not a decoder setting. Compressed sizes and ratios are properties of those shared inputs, so there is no decoder size ranking.

Source levelzstd Cmzstd
11.000×1.676×
21.000×1.611×
121.000×1.591×
51.000×1.589×
151.000×1.573×
-51.000×1.572×
61.000×1.565×
111.000×1.542×
201.000×1.539×
-11.000×1.513×
71.000×1.506×
141.000×1.497×
211.000×1.496×
131.000×1.492×
191.000×1.457×
101.000×1.455×
161.000×1.453×
31.000×1.438×
91.000×1.435×
221.000×1.428×
181.000×1.407×
81.000×1.389×
171.000×1.348×
41.000×1.321×

Open a source level for all datasets, compressed/restored byte counts, mean latency, confidence bounds and throughput. Measurements include cold construction, allocation, decoding and disposal. C receives the frame’s declared output capacity. See the linked methodology for reproducible commands and the limits of this single-host comparison.

Decompression of level -5 frames

Decoder overview · Benchmark index

Recorded run: long-20261109 (2026-11-09). CSV · Environment · Methodology and limits.

Level belongs to the C encoder that prepared the frame; decoders have no level setting. Both decoders restore the same bytes.

Median speed across 8 datasets

Median of per-dataset C mean time / decoder mean time ratios, with equal weight. Higher is faster; 1× matches C. No confidence interval is inferred for this across-dataset median.

DecoderMedian speed / C
zstd C1.000×
mzstd1.572×

Datasets are ranked by mzstd speed relative to C, highest first. Throughput counts restored bytes; empty input has latency only. Whiskers and intervals describe sampling uncertainty, not all host variation. Cold APIs include allocation and disposal. C reads the frame’s content size and decodes into a buffer of exactly that size; mzstd returns its own vector.

empty

Empty input; measures API overhead.

9 compressed → 0 restored bytes; compressed / original: undefined.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C2.1432.086–2.207—1.000×
mzstd0.039510.03816–0.04109—54.242×

tiny-text

44-byte quick-brown-fox sentence.

53 compressed → 44 restored bytes; compressed / original: 120.455%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C2.0882.026–2.15520.101.000×
mzstd0.051060.0493–0.05306821.8740.897×

random-64k

64 KiB prefix of the deterministic pseudorandom corpus.

65,546 compressed → 65,536 restored bytes; compressed / original: 100.015%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C3.3043.223–3.38718,914.431.000×
mzstd0.95910.9433–0.975765,168.063.445×

binary-64k

64 KiB of structured binary bytes: ((i / 16) XOR i) modulo 256.

3,189 compressed → 65,536 restored bytes; compressed / original: 4.866%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C4.5954.44–4.76513,601.761.000×
mzstd2.8282.681–2.98322,098.001.625×

repeated-1m

1 MiB of repeated a bytes.

55 compressed → 1,048,576 restored bytes; compressed / original: 0.005%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C14.4514.1–14.8669,195.881.000×
mzstd9.519.305–9.736105,153.731.520×

random-1m

1 MiB of deterministic xorshift64 pseudorandom bytes.

1,048,610 compressed → 1,048,576 restored bytes; compressed / original: 100.003%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C19.0918.96–19.2352,383.771.000×
mzstd16.9116.78–17.0559,129.681.129×

alice29

Alice in Wonderland text from the Canterbury corpus (testdata/alice29.txt).

85,658 compressed → 152,089 restored bytes; compressed / original: 56.321%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C78.6675.31–82.581,844.041.000×
mzstd80.7777.98–84.31,795.840.974×

text-1m

Alice text repeated cyclically to 1 MiB.

96,657 compressed → 1,048,576 restored bytes; compressed / original: 9.218%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C73.0771–75.4113,685.071.000×
mzstd78.4575.91–81.212,746.300.931×

Decompression of level -1 frames

Decoder overview · Benchmark index

Recorded run: long-20261109 (2026-11-09). CSV · Environment · Methodology and limits.

Level belongs to the C encoder that prepared the frame; decoders have no level setting. Both decoders restore the same bytes.

Median speed across 8 datasets

Median of per-dataset C mean time / decoder mean time ratios, with equal weight. Higher is faster; 1× matches C. No confidence interval is inferred for this across-dataset median.

DecoderMedian speed / C
zstd C1.000×
mzstd1.513×

Datasets are ranked by mzstd speed relative to C, highest first. Throughput counts restored bytes; empty input has latency only. Whiskers and intervals describe sampling uncertainty, not all host variation. Cold APIs include allocation and disposal. C reads the frame’s content size and decodes into a buffer of exactly that size; mzstd returns its own vector.

empty

Empty input; measures API overhead.

9 compressed → 0 restored bytes; compressed / original: undefined.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C1.9651.947–1.985—1.000×
mzstd0.037960.03724–0.03895—51.750×

tiny-text

44-byte quick-brown-fox sentence.

53 compressed → 44 restored bytes; compressed / original: 120.455%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C1.9671.953–1.98221.341.000×
mzstd0.052930.05079–0.05523792.8137.155×

random-64k

64 KiB prefix of the deterministic pseudorandom corpus.

65,546 compressed → 65,536 restored bytes; compressed / original: 100.015%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C3.2143.133–3.29619,448.341.000×
mzstd1.021.009–1.03161,266.253.150×

binary-64k

64 KiB of structured binary bytes: ((i / 16) XOR i) modulo 256.

2,442 compressed → 65,536 restored bytes; compressed / original: 3.726%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C4.7784.625–4.94713,081.991.000×
mzstd3.1253.009–3.25320,000.691.529×

repeated-1m

1 MiB of repeated a bytes.

51 compressed → 1,048,576 restored bytes; compressed / original: 0.005%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C14.1413.97–14.3470,738.351.000×
mzstd9.4479.226–9.699105,850.501.496×

random-1m

1 MiB of deterministic xorshift64 pseudorandom bytes.

1,048,610 compressed → 1,048,576 restored bytes; compressed / original: 100.003%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C19.1318.98–19.2952,284.361.000×
mzstd16.8416.67–17.0259,387.921.136×

text-1m

Alice text repeated cyclically to 1 MiB.

80,894 compressed → 1,048,576 restored bytes; compressed / original: 7.715%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C97.4393.2–101.910,263.741.000×
mzstd100.495.58–105.49,961.540.971×

alice29

Alice in Wonderland text from the Canterbury corpus (testdata/alice29.txt).

71,645 compressed → 152,089 restored bytes; compressed / original: 47.107%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C92.2489.16–95.641,572.451.000×
mzstd96.4992.71–1011,503.170.956×

Decompression of level 1 frames

Decoder overview · Benchmark index

Recorded run: long-20261109 (2026-11-09). CSV · Environment · Methodology and limits.

Level belongs to the C encoder that prepared the frame; decoders have no level setting. Both decoders restore the same bytes.

Median speed across 8 datasets

Median of per-dataset C mean time / decoder mean time ratios, with equal weight. Higher is faster; 1× matches C. No confidence interval is inferred for this across-dataset median.

DecoderMedian speed / C
zstd C1.000×
mzstd1.676×

Datasets are ranked by mzstd speed relative to C, highest first. Throughput counts restored bytes; empty input has latency only. Whiskers and intervals describe sampling uncertainty, not all host variation. Cold APIs include allocation and disposal. C reads the frame’s content size and decodes into a buffer of exactly that size; mzstd returns its own vector.

empty

Empty input; measures API overhead.

9 compressed → 0 restored bytes; compressed / original: undefined.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C2.0691.984–2.182—1.000×
mzstd0.03760.03706–0.03826—55.044×

tiny-text

44-byte quick-brown-fox sentence.

53 compressed → 44 restored bytes; compressed / original: 120.455%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C2.0041.969–2.04420.941.000×
mzstd0.047990.04755–0.04853874.4541.760×

random-64k

64 KiB prefix of the deterministic pseudorandom corpus.

65,546 compressed → 65,536 restored bytes; compressed / original: 100.015%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C3.4153.325–3.518,303.891.000×
mzstd1.0020.9869–1.01662,394.573.409×

binary-64k

64 KiB of structured binary bytes: ((i / 16) XOR i) modulo 256.

2,537 compressed → 65,536 restored bytes; compressed / original: 3.871%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C5.2225.046–5.40111,967.811.000×
mzstd2.8462.781–2.9221,960.661.835×

repeated-1m

1 MiB of repeated a bytes.

51 compressed → 1,048,576 restored bytes; compressed / original: 0.005%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C14.1413.93–14.3870,740.381.000×
mzstd9.3239.173–9.496107,264.221.516×

random-1m

1 MiB of deterministic xorshift64 pseudorandom bytes.

1,048,610 compressed → 1,048,576 restored bytes; compressed / original: 100.003%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C18.8518.69–19.0353,051.961.000×
mzstd16.4516.29–16.6360,772.311.146×

alice29

Alice in Wonderland text from the Canterbury corpus (testdata/alice29.txt).

59,657 compressed → 152,089 restored bytes; compressed / original: 39.225%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C96.491.21–102.11,504.561.000×
mzstd94.2692.55–96.551,538.741.023×

text-1m

Alice text repeated cyclically to 1 MiB.

61,206 compressed → 1,048,576 restored bytes; compressed / original: 5.837%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C99.8496.69–103.310,015.851.000×
mzstd111.9107.5–116.68,939.280.893×

Decompression of level 2 frames

Decoder overview · Benchmark index

Recorded run: long-20261109 (2026-11-09). CSV · Environment · Methodology and limits.

Level belongs to the C encoder that prepared the frame; decoders have no level setting. Both decoders restore the same bytes.

Median speed across 8 datasets

Median of per-dataset C mean time / decoder mean time ratios, with equal weight. Higher is faster; 1× matches C. No confidence interval is inferred for this across-dataset median.

DecoderMedian speed / C
zstd C1.000×
mzstd1.611×

Datasets are ranked by mzstd speed relative to C, highest first. Throughput counts restored bytes; empty input has latency only. Whiskers and intervals describe sampling uncertainty, not all host variation. Cold APIs include allocation and disposal. C reads the frame’s content size and decodes into a buffer of exactly that size; mzstd returns its own vector.

empty

Empty input; measures API overhead.

9 compressed → 0 restored bytes; compressed / original: undefined.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C2.0822.022–2.148—1.000×
mzstd0.044310.04299–0.04565—46.989×

tiny-text

44-byte quick-brown-fox sentence.

53 compressed → 44 restored bytes; compressed / original: 120.455%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C1.9451.932–1.9621.571.000×
mzstd0.053490.05118–0.056784.4436.360×

random-64k

64 KiB prefix of the deterministic pseudorandom corpus.

65,546 compressed → 65,536 restored bytes; compressed / original: 100.015%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C3.3633.283–3.44318,584.971.000×
mzstd0.97580.9592–0.992764,049.713.446×

binary-64k

64 KiB of structured binary bytes: ((i / 16) XOR i) modulo 256.

2,425 compressed → 65,536 restored bytes; compressed / original: 3.700%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C5.1044.896–5.32512,245.561.000×
mzstd3.0982.989–3.21420,173.511.647×

repeated-1m

1 MiB of repeated a bytes.

50 compressed → 1,048,576 restored bytes; compressed / original: 0.005%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C14.4814.15–14.8569,074.211.000×
mzstd9.1889.079–9.313108,831.921.576×

random-1m

1 MiB of deterministic xorshift64 pseudorandom bytes.

1,048,609 compressed → 1,048,576 restored bytes; compressed / original: 100.003%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C19.4619.24–19.751,379.361.000×
mzstd16.8816.74–17.0559,231.561.153×

alice29

Alice in Wonderland text from the Canterbury corpus (testdata/alice29.txt).

57,348 compressed → 152,089 restored bytes; compressed / original: 37.707%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C93.9492.21–96.141,543.921.000×
mzstd105.3101–110.61,377.900.892×

text-1m

Alice text repeated cyclically to 1 MiB.

58,340 compressed → 1,048,576 restored bytes; compressed / original: 5.564%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C113.5109.8–117.68,812.281.000×
mzstd134.7128.5–141.37,423.310.842×

Decompression of level 3 frames

Decoder overview · Benchmark index

Recorded run: long-20261109 (2026-11-09). CSV · Environment · Methodology and limits.

Level belongs to the C encoder that prepared the frame; decoders have no level setting. Both decoders restore the same bytes.

Median speed across 8 datasets

Median of per-dataset C mean time / decoder mean time ratios, with equal weight. Higher is faster; 1× matches C. No confidence interval is inferred for this across-dataset median.

DecoderMedian speed / C
zstd C1.000×
mzstd1.438×

Datasets are ranked by mzstd speed relative to C, highest first. Throughput counts restored bytes; empty input has latency only. Whiskers and intervals describe sampling uncertainty, not all host variation. Cold APIs include allocation and disposal. C reads the frame’s content size and decodes into a buffer of exactly that size; mzstd returns its own vector.

empty

Empty input; measures API overhead.

9 compressed → 0 restored bytes; compressed / original: undefined.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C1.9991.978–2.025—1.000×
mzstd0.03970.03841–0.04121—50.355×

tiny-text

44-byte quick-brown-fox sentence.

53 compressed → 44 restored bytes; compressed / original: 120.455%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C2.0041.954–2.06120.931.000×
mzstd0.05080.0492–0.05269826.1039.461×

random-64k

64 KiB prefix of the deterministic pseudorandom corpus.

65,546 compressed → 65,536 restored bytes; compressed / original: 100.015%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C3.3183.233–3.40218,836.831.000×
mzstd0.91380.9052–0.923668,394.933.631×

repeated-1m

1 MiB of repeated a bytes.

50 compressed → 1,048,576 restored bytes; compressed / original: 0.005%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C14.414.03–14.8269,447.921.000×
mzstd9.8579.588–10.14101,451.541.461×

binary-64k

64 KiB of structured binary bytes: ((i / 16) XOR i) modulo 256.

2,324 compressed → 65,536 restored bytes; compressed / original: 3.546%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C5.0784.936–5.23412,309.021.000×
mzstd3.5883.439–3.73917,418.341.415×

random-1m

1 MiB of deterministic xorshift64 pseudorandom bytes.

1,048,609 compressed → 1,048,576 restored bytes; compressed / original: 100.003%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C19.319.08–19.5551,808.661.000×
mzstd16.916.74–17.0759,174.841.142×

alice29

Alice in Wonderland text from the Canterbury corpus (testdata/alice29.txt).

56,970 compressed → 152,089 restored bytes; compressed / original: 37.458%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C111.5107.9–115.61,300.261.000×
mzstd116.7113.6–120.81,242.720.956×

text-1m

Alice text repeated cyclically to 1 MiB.

55,878 compressed → 1,048,576 restored bytes; compressed / original: 5.329%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C116.8112.8–121.28,562.621.000×
mzstd136.1129.5–142.97,350.100.858×

Decompression of level 4 frames

Decoder overview · Benchmark index

Recorded run: long-20261109 (2026-11-09). CSV · Environment · Methodology and limits.

Level belongs to the C encoder that prepared the frame; decoders have no level setting. Both decoders restore the same bytes.

Median speed across 8 datasets

Median of per-dataset C mean time / decoder mean time ratios, with equal weight. Higher is faster; 1× matches C. No confidence interval is inferred for this across-dataset median.

DecoderMedian speed / C
zstd C1.000×
mzstd1.321×

Datasets are ranked by mzstd speed relative to C, highest first. Throughput counts restored bytes; empty input has latency only. Whiskers and intervals describe sampling uncertainty, not all host variation. Cold APIs include allocation and disposal. C reads the frame’s content size and decodes into a buffer of exactly that size; mzstd returns its own vector.

empty

Empty input; measures API overhead.

9 compressed → 0 restored bytes; compressed / original: undefined.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C2.2862.228–2.346—1.000×
mzstd0.04040.03942–0.04148—56.593×

tiny-text

44-byte quick-brown-fox sentence.

53 compressed → 44 restored bytes; compressed / original: 120.455%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C1.9851.94–2.04121.141.000×
mzstd0.050110.04863–0.05187837.4039.611×

random-64k

64 KiB prefix of the deterministic pseudorandom corpus.

65,546 compressed → 65,536 restored bytes; compressed / original: 100.015%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C2.9542.887–3.03721,156.811.000×
mzstd0.90870.9014–0.916868,780.473.251×

repeated-1m

1 MiB of repeated a bytes.

50 compressed → 1,048,576 restored bytes; compressed / original: 0.005%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C13.9813.84–14.1571,518.901.000×
mzstd9.369.166–9.583106,840.901.494×

binary-64k

64 KiB of structured binary bytes: ((i / 16) XOR i) modulo 256.

3,909 compressed → 65,536 restored bytes; compressed / original: 5.965%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C50.3648.12–52.831,241.101.000×
mzstd43.8641.46–46.61,425.031.148×

random-1m

1 MiB of deterministic xorshift64 pseudorandom bytes.

1,048,609 compressed → 1,048,576 restored bytes; compressed / original: 100.003%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C19.619.31–19.9251,025.861.000×
mzstd17.2117.01–17.4158,121.111.139×

alice29

Alice in Wonderland text from the Canterbury corpus (testdata/alice29.txt).

54,282 compressed → 152,089 restored bytes; compressed / original: 35.691%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C98.595.48–102.11,472.511.000×
mzstd101.499.9–103.31,429.830.971×

text-1m

Alice text repeated cyclically to 1 MiB.

55,760 compressed → 1,048,576 restored bytes; compressed / original: 5.318%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C114.1111.1–117.88,762.631.000×
mzstd130.9125.1–137.37,638.940.872×

Decompression of level 5 frames

Decoder overview · Benchmark index

Recorded run: long-20261109 (2026-11-09). CSV · Environment · Methodology and limits.

Level belongs to the C encoder that prepared the frame; decoders have no level setting. Both decoders restore the same bytes.

Median speed across 8 datasets

Median of per-dataset C mean time / decoder mean time ratios, with equal weight. Higher is faster; 1× matches C. No confidence interval is inferred for this across-dataset median.

DecoderMedian speed / C
zstd C1.000×
mzstd1.589×

Datasets are ranked by mzstd speed relative to C, highest first. Throughput counts restored bytes; empty input has latency only. Whiskers and intervals describe sampling uncertainty, not all host variation. Cold APIs include allocation and disposal. C reads the frame’s content size and decodes into a buffer of exactly that size; mzstd returns its own vector.

empty

Empty input; measures API overhead.

9 compressed → 0 restored bytes; compressed / original: undefined.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C2.1942.127–2.263—1.000×
mzstd0.043390.04148–0.04542—50.557×

tiny-text

44-byte quick-brown-fox sentence.

53 compressed → 44 restored bytes; compressed / original: 120.455%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C2.0021.957–2.06420.961.000×
mzstd0.05130.04955–0.05327818.0339.030×

random-64k

64 KiB prefix of the deterministic pseudorandom corpus.

65,546 compressed → 65,536 restored bytes; compressed / original: 100.015%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C2.9692.91–3.03521,052.201.000×
mzstd0.94790.9289–0.969665,935.793.132×

binary-64k

64 KiB of structured binary bytes: ((i / 16) XOR i) modulo 256.

1,270 compressed → 65,536 restored bytes; compressed / original: 1.938%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C6.5496.279–6.8319,542.761.000×
mzstd4.0593.981–4.16315,396.371.613×

repeated-1m

1 MiB of repeated a bytes.

50 compressed → 1,048,576 restored bytes; compressed / original: 0.005%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C14.4814.16–14.8569,069.471.000×
mzstd9.2539.119–9.411108,073.491.565×

random-1m

1 MiB of deterministic xorshift64 pseudorandom bytes.

1,048,609 compressed → 1,048,576 restored bytes; compressed / original: 100.003%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C19.8619.64–20.150,345.471.000×
mzstd17.1917.03–17.3758,159.581.155×

text-1m

Alice text repeated cyclically to 1 MiB.

54,185 compressed → 1,048,576 restored bytes; compressed / original: 5.167%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C121117.3–1258,264.801.000×
mzstd120117.6–1238,331.921.008×

alice29

Alice in Wonderland text from the Canterbury corpus (testdata/alice29.txt).

53,372 compressed → 152,089 restored bytes; compressed / original: 35.093%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C92.7591.47–94.261,563.771.000×
mzstd99.1497.16–101.51,462.980.936×

Decompression of level 6 frames

Decoder overview · Benchmark index

Recorded run: long-20261109 (2026-11-09). CSV · Environment · Methodology and limits.

Level belongs to the C encoder that prepared the frame; decoders have no level setting. Both decoders restore the same bytes.

Median speed across 8 datasets

Median of per-dataset C mean time / decoder mean time ratios, with equal weight. Higher is faster; 1× matches C. No confidence interval is inferred for this across-dataset median.

DecoderMedian speed / C
zstd C1.000×
mzstd1.565×

Datasets are ranked by mzstd speed relative to C, highest first. Throughput counts restored bytes; empty input has latency only. Whiskers and intervals describe sampling uncertainty, not all host variation. Cold APIs include allocation and disposal. C reads the frame’s content size and decodes into a buffer of exactly that size; mzstd returns its own vector.

empty

Empty input; measures API overhead.

9 compressed → 0 restored bytes; compressed / original: undefined.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C2.1262.067–2.191—1.000×
mzstd0.039940.03888–0.04116—53.218×

tiny-text

44-byte quick-brown-fox sentence.

53 compressed → 44 restored bytes; compressed / original: 120.455%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C1.9991.966–2.03820.991.000×
mzstd0.050210.04866–0.05204835.7439.821×

random-64k

64 KiB prefix of the deterministic pseudorandom corpus.

65,546 compressed → 65,536 restored bytes; compressed / original: 100.015%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C3.1213.042–3.20120,026.431.000×
mzstd0.89810.8927–0.904569,589.563.475×

binary-64k

64 KiB of structured binary bytes: ((i / 16) XOR i) modulo 256.

1,270 compressed → 65,536 restored bytes; compressed / original: 1.938%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C6.5236.234–6.8359,582.021.000×
mzstd4.1124.02–4.23215,198.301.586×

repeated-1m

1 MiB of repeated a bytes.

49 compressed → 1,048,576 restored bytes; compressed / original: 0.005%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C14.3714.05–14.7469,600.811.000×
mzstd9.3059.136–9.507107,468.051.544×

random-1m

1 MiB of deterministic xorshift64 pseudorandom bytes.

1,048,609 compressed → 1,048,576 restored bytes; compressed / original: 100.003%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C19.0118.81–19.2452,611.421.000×
mzstd16.416.29–16.5260,984.311.159×

text-1m

Alice text repeated cyclically to 1 MiB.

53,196 compressed → 1,048,576 restored bytes; compressed / original: 5.073%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C109.1106.6–112.49,164.101.000×
mzstd119.8115.6–124.38,348.540.911×

alice29

Alice in Wonderland text from the Canterbury corpus (testdata/alice29.txt).

53,111 compressed → 152,089 restored bytes; compressed / original: 34.921%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C89.4888.58–90.671,621.051.000×
mzstd107.8102.8–1131,345.830.830×

Decompression of level 7 frames

Decoder overview · Benchmark index

Recorded run: long-20261109 (2026-11-09). CSV · Environment · Methodology and limits.

Level belongs to the C encoder that prepared the frame; decoders have no level setting. Both decoders restore the same bytes.

Median speed across 8 datasets

Median of per-dataset C mean time / decoder mean time ratios, with equal weight. Higher is faster; 1× matches C. No confidence interval is inferred for this across-dataset median.

DecoderMedian speed / C
zstd C1.000×
mzstd1.506×

Datasets are ranked by mzstd speed relative to C, highest first. Throughput counts restored bytes; empty input has latency only. Whiskers and intervals describe sampling uncertainty, not all host variation. Cold APIs include allocation and disposal. C reads the frame’s content size and decodes into a buffer of exactly that size; mzstd returns its own vector.

empty

Empty input; measures API overhead.

9 compressed → 0 restored bytes; compressed / original: undefined.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C2.2272.081–2.408—1.000×
mzstd0.040640.03921–0.04226—54.790×

tiny-text

44-byte quick-brown-fox sentence.

53 compressed → 44 restored bytes; compressed / original: 120.455%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C2.031.988–2.07920.671.000×
mzstd0.048420.04777–0.04923866.6641.929×

random-64k

64 KiB prefix of the deterministic pseudorandom corpus.

65,546 compressed → 65,536 restored bytes; compressed / original: 100.015%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C2.8272.805–2.85622,104.811.000×
mzstd0.91580.9033–0.929668,247.693.087×

repeated-1m

1 MiB of repeated a bytes.

49 compressed → 1,048,576 restored bytes; compressed / original: 0.005%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C14.7514.29–15.2567,815.921.000×
mzstd9.5389.33–9.767104,840.921.546×

binary-64k

64 KiB of structured binary bytes: ((i / 16) XOR i) modulo 256.

1,270 compressed → 65,536 restored bytes; compressed / original: 1.938%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C6.7656.434–7.1079,238.261.000×
mzstd4.6154.361–4.89113,543.221.466×

random-1m

1 MiB of deterministic xorshift64 pseudorandom bytes.

1,048,609 compressed → 1,048,576 restored bytes; compressed / original: 100.003%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C19.6619.34–20.0250,855.591.000×
mzstd17.2216.99–17.558,087.901.142×

text-1m

Alice text repeated cyclically to 1 MiB.

52,804 compressed → 1,048,576 restored bytes; compressed / original: 5.036%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C107.5106.4–1099,303.371.000×
mzstd110.2109.6–1119,070.880.975×

alice29

Alice in Wonderland text from the Canterbury corpus (testdata/alice29.txt).

53,195 compressed → 152,089 restored bytes; compressed / original: 34.976%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C101.498.18–105.31,430.871.000×
mzstd108.1104.6–112.21,342.040.938×

Decompression of level 8 frames

Decoder overview · Benchmark index

Recorded run: long-20261109 (2026-11-09). CSV · Environment · Methodology and limits.

Level belongs to the C encoder that prepared the frame; decoders have no level setting. Both decoders restore the same bytes.

Median speed across 8 datasets

Median of per-dataset C mean time / decoder mean time ratios, with equal weight. Higher is faster; 1× matches C. No confidence interval is inferred for this across-dataset median.

DecoderMedian speed / C
zstd C1.000×
mzstd1.389×

Datasets are ranked by mzstd speed relative to C, highest first. Throughput counts restored bytes; empty input has latency only. Whiskers and intervals describe sampling uncertainty, not all host variation. Cold APIs include allocation and disposal. C reads the frame’s content size and decodes into a buffer of exactly that size; mzstd returns its own vector.

empty

Empty input; measures API overhead.

9 compressed → 0 restored bytes; compressed / original: undefined.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C2.0782.031–2.133—1.000×
mzstd0.043250.04147–0.04517—48.051×

tiny-text

44-byte quick-brown-fox sentence.

53 compressed → 44 restored bytes; compressed / original: 120.455%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C2.0752.011–2.14720.221.000×
mzstd0.053490.05136–0.05585784.4738.794×

random-64k

64 KiB prefix of the deterministic pseudorandom corpus.

65,546 compressed → 65,536 restored bytes; compressed / original: 100.015%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C2.9012.843–2.96821,543.971.000×
mzstd0.93460.9174–0.953466,876.463.104×

repeated-1m

1 MiB of repeated a bytes.

49 compressed → 1,048,576 restored bytes; compressed / original: 0.005%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C14.0413.87–14.2471,237.711.000×
mzstd9.3769.233–9.539106,654.631.497×

binary-64k

64 KiB of structured binary bytes: ((i / 16) XOR i) modulo 256.

1,270 compressed → 65,536 restored bytes; compressed / original: 1.938%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C6.115.908–6.34110,228.921.000×
mzstd4.7724.518–5.03913,097.711.280×

random-1m

1 MiB of deterministic xorshift64 pseudorandom bytes.

1,048,609 compressed → 1,048,576 restored bytes; compressed / original: 100.003%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C19.2919.17–19.4351,841.811.000×
mzstd17.4417.22–17.6757,335.361.106×

text-1m

Alice text repeated cyclically to 1 MiB.

52,393 compressed → 1,048,576 restored bytes; compressed / original: 4.997%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C104.1102.5–106.39,603.981.000×
mzstd107.7106.5–109.49,286.350.967×

alice29

Alice in Wonderland text from the Canterbury corpus (testdata/alice29.txt).

52,710 compressed → 152,089 restored bytes; compressed / original: 34.657%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C96.9494.11–100.31,496.241.000×
mzstd102.599.67–1061,415.190.946×

Decompression of level 9 frames

Decoder overview · Benchmark index

Recorded run: long-20261109 (2026-11-09). CSV · Environment · Methodology and limits.

Level belongs to the C encoder that prepared the frame; decoders have no level setting. Both decoders restore the same bytes.

Median speed across 8 datasets

Median of per-dataset C mean time / decoder mean time ratios, with equal weight. Higher is faster; 1× matches C. No confidence interval is inferred for this across-dataset median.

DecoderMedian speed / C
zstd C1.000×
mzstd1.435×

Datasets are ranked by mzstd speed relative to C, highest first. Throughput counts restored bytes; empty input has latency only. Whiskers and intervals describe sampling uncertainty, not all host variation. Cold APIs include allocation and disposal. C reads the frame’s content size and decodes into a buffer of exactly that size; mzstd returns its own vector.

empty

Empty input; measures API overhead.

9 compressed → 0 restored bytes; compressed / original: undefined.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C2.1732.11–2.241—1.000×
mzstd0.038020.03754–0.03864—57.159×

tiny-text

44-byte quick-brown-fox sentence.

53 compressed → 44 restored bytes; compressed / original: 120.455%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C1.941.93–1.95221.631.000×
mzstd0.04930.04835–0.05042851.2339.354×

random-64k

64 KiB prefix of the deterministic pseudorandom corpus.

65,546 compressed → 65,536 restored bytes; compressed / original: 100.015%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C2.9172.863–2.97821,422.491.000×
mzstd0.89220.8851–0.900370,052.533.270×

repeated-1m

1 MiB of repeated a bytes.

49 compressed → 1,048,576 restored bytes; compressed / original: 0.005%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C14.8214.4–15.2967,488.681.000×
mzstd9.4369.276–9.616105,978.971.570×

binary-64k

64 KiB of structured binary bytes: ((i / 16) XOR i) modulo 256.

1,270 compressed → 65,536 restored bytes; compressed / original: 1.938%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C5.8525.726–6.01310,679.261.000×
mzstd4.5034.297–4.72713,879.751.300×

random-1m

1 MiB of deterministic xorshift64 pseudorandom bytes.

1,048,609 compressed → 1,048,576 restored bytes; compressed / original: 100.003%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C19.6319.43–19.8650,930.211.000×
mzstd17.0316.9–17.1758,706.281.153×

alice29

Alice in Wonderland text from the Canterbury corpus (testdata/alice29.txt).

52,188 compressed → 152,089 restored bytes; compressed / original: 34.314%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C91.990.15–94.091,578.311.000×
mzstd99.8797.45–102.81,452.290.920×

text-1m

Alice text repeated cyclically to 1 MiB.

52,381 compressed → 1,048,576 restored bytes; compressed / original: 4.995%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C100.8100.4–101.29,923.641.000×
mzstd112.1108.2–116.98,919.300.899×

Decompression of level 10 frames

Decoder overview · Benchmark index

Recorded run: long-20261109 (2026-11-09). CSV · Environment · Methodology and limits.

Level belongs to the C encoder that prepared the frame; decoders have no level setting. Both decoders restore the same bytes.

Median speed across 8 datasets

Median of per-dataset C mean time / decoder mean time ratios, with equal weight. Higher is faster; 1× matches C. No confidence interval is inferred for this across-dataset median.

DecoderMedian speed / C
zstd C1.000×
mzstd1.455×

Datasets are ranked by mzstd speed relative to C, highest first. Throughput counts restored bytes; empty input has latency only. Whiskers and intervals describe sampling uncertainty, not all host variation. Cold APIs include allocation and disposal. C reads the frame’s content size and decodes into a buffer of exactly that size; mzstd returns its own vector.

empty

Empty input; measures API overhead.

9 compressed → 0 restored bytes; compressed / original: undefined.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C2.2632.194–2.331—1.000×
mzstd0.041460.03992–0.04314—54.572×

tiny-text

44-byte quick-brown-fox sentence.

53 compressed → 44 restored bytes; compressed / original: 120.455%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C1.9871.952–2.02821.121.000×
mzstd0.049740.04863–0.05104843.6539.944×

random-64k

64 KiB prefix of the deterministic pseudorandom corpus.

65,546 compressed → 65,536 restored bytes; compressed / original: 100.015%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C2.9632.886–3.05121,094.511.000×
mzstd0.92260.9078–0.939167,740.343.211×

repeated-1m

1 MiB of repeated a bytes.

49 compressed → 1,048,576 restored bytes; compressed / original: 0.005%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C14.7914.45–15.1867,596.181.000×
mzstd9.8969.668–10.14101,052.391.495×

binary-64k

64 KiB of structured binary bytes: ((i / 16) XOR i) modulo 256.

1,270 compressed → 65,536 restored bytes; compressed / original: 1.938%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C5.8225.748–5.91110,735.271.000×
mzstd4.1134.01–4.23415,195.031.415×

random-1m

1 MiB of deterministic xorshift64 pseudorandom bytes.

1,048,609 compressed → 1,048,576 restored bytes; compressed / original: 100.003%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C19.7819.53–20.0550,554.661.000×
mzstd17.0416.86–17.2358,699.281.161×

text-1m

Alice text repeated cyclically to 1 MiB.

52,148 compressed → 1,048,576 restored bytes; compressed / original: 4.973%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C115.7111.8–119.78,643.851.000×
mzstd125119.4–130.87,999.660.925×

alice29

Alice in Wonderland text from the Canterbury corpus (testdata/alice29.txt).

51,894 compressed → 152,089 restored bytes; compressed / original: 34.121%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C90.5188.86–92.741,602.431.000×
mzstd100.896.89–105.51,439.140.898×

Decompression of level 11 frames

Decoder overview · Benchmark index

Recorded run: long-20261109 (2026-11-09). CSV · Environment · Methodology and limits.

Level belongs to the C encoder that prepared the frame; decoders have no level setting. Both decoders restore the same bytes.

Median speed across 8 datasets

Median of per-dataset C mean time / decoder mean time ratios, with equal weight. Higher is faster; 1× matches C. No confidence interval is inferred for this across-dataset median.

DecoderMedian speed / C
zstd C1.000×
mzstd1.542×

Datasets are ranked by mzstd speed relative to C, highest first. Throughput counts restored bytes; empty input has latency only. Whiskers and intervals describe sampling uncertainty, not all host variation. Cold APIs include allocation and disposal. C reads the frame’s content size and decodes into a buffer of exactly that size; mzstd returns its own vector.

empty

Empty input; measures API overhead.

9 compressed → 0 restored bytes; compressed / original: undefined.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C2.072.023–2.123—1.000×
mzstd0.040460.03937–0.04168—51.172×

tiny-text

44-byte quick-brown-fox sentence.

53 compressed → 44 restored bytes; compressed / original: 120.455%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C2.1052.049–2.16519.931.000×
mzstd0.052860.05085–0.05507793.8639.825×

random-64k

64 KiB prefix of the deterministic pseudorandom corpus.

65,546 compressed → 65,536 restored bytes; compressed / original: 100.015%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C2.9372.867–3.01821,283.041.000×
mzstd0.96270.9522–0.974164,923.893.050×

binary-64k

64 KiB of structured binary bytes: ((i / 16) XOR i) modulo 256.

1,276 compressed → 65,536 restored bytes; compressed / original: 1.947%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C7.2246.885–7.5628,651.431.000×
mzstd4.5384.334–4.76113,772.721.592×

repeated-1m

1 MiB of repeated a bytes.

49 compressed → 1,048,576 restored bytes; compressed / original: 0.005%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C15.2614.84–15.7165,533.341.000×
mzstd10.239.961–10.5297,718.671.491×

random-1m

1 MiB of deterministic xorshift64 pseudorandom bytes.

1,048,609 compressed → 1,048,576 restored bytes; compressed / original: 100.003%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C19.9819.78–20.250,044.731.000×
mzstd17.9617.76–18.1555,690.811.113×

text-1m

Alice text repeated cyclically to 1 MiB.

52,034 compressed → 1,048,576 restored bytes; compressed / original: 4.962%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C111.5107.3–1168,965.691.000×
mzstd108104.9–111.99,256.131.032×

alice29

Alice in Wonderland text from the Canterbury corpus (testdata/alice29.txt).

51,491 compressed → 152,089 restored bytes; compressed / original: 33.856%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C92.4489.81–95.561,569.031.000×
mzstd99.896.2–1041,453.360.926×

Decompression of level 12 frames

Decoder overview · Benchmark index

Recorded run: long-20261109 (2026-11-09). CSV · Environment · Methodology and limits.

Level belongs to the C encoder that prepared the frame; decoders have no level setting. Both decoders restore the same bytes.

Median speed across 8 datasets

Median of per-dataset C mean time / decoder mean time ratios, with equal weight. Higher is faster; 1× matches C. No confidence interval is inferred for this across-dataset median.

DecoderMedian speed / C
zstd C1.000×
mzstd1.591×

Datasets are ranked by mzstd speed relative to C, highest first. Throughput counts restored bytes; empty input has latency only. Whiskers and intervals describe sampling uncertainty, not all host variation. Cold APIs include allocation and disposal. C reads the frame’s content size and decodes into a buffer of exactly that size; mzstd returns its own vector.

empty

Empty input; measures API overhead.

9 compressed → 0 restored bytes; compressed / original: undefined.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C2.2252.168–2.285—1.000×
mzstd0.044630.04284–0.04646—49.850×

tiny-text

44-byte quick-brown-fox sentence.

53 compressed → 44 restored bytes; compressed / original: 120.455%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C2.0311.978–2.09120.661.000×
mzstd0.051690.04981–0.05385811.7339.293×

random-64k

64 KiB prefix of the deterministic pseudorandom corpus.

65,546 compressed → 65,536 restored bytes; compressed / original: 100.015%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C2.9842.909–3.06720,948.481.000×
mzstd0.90150.8957–0.908469,327.133.309×

repeated-1m

1 MiB of repeated a bytes.

49 compressed → 1,048,576 restored bytes; compressed / original: 0.005%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C17.5417.09–17.9757,016.251.000×
mzstd10.5610.33–10.8194,653.901.660×

binary-64k

64 KiB of structured binary bytes: ((i / 16) XOR i) modulo 256.

1,276 compressed → 65,536 restored bytes; compressed / original: 1.947%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C6.5876.267–6.9289,488.051.000×
mzstd4.334.168–4.50814,435.581.521×

random-1m

1 MiB of deterministic xorshift64 pseudorandom bytes.

1,048,609 compressed → 1,048,576 restored bytes; compressed / original: 100.003%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C19.9219.66–20.250,200.531.000×
mzstd17.1617.03–17.358,286.721.161×

text-1m

Alice text repeated cyclically to 1 MiB.

52,034 compressed → 1,048,576 restored bytes; compressed / original: 4.962%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C100.699.23–102.49,937.211.000×
mzstd104.1102.9–105.69,609.880.967×

alice29

Alice in Wonderland text from the Canterbury corpus (testdata/alice29.txt).

51,492 compressed → 152,089 restored bytes; compressed / original: 33.856%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C95.5691.75–99.981,517.851.000×
mzstd100.597.48–1041,442.870.951×

Decompression of level 13 frames

Decoder overview · Benchmark index

Recorded run: long-20261109 (2026-11-09). CSV · Environment · Methodology and limits.

Level belongs to the C encoder that prepared the frame; decoders have no level setting. Both decoders restore the same bytes.

Median speed across 8 datasets

Median of per-dataset C mean time / decoder mean time ratios, with equal weight. Higher is faster; 1× matches C. No confidence interval is inferred for this across-dataset median.

DecoderMedian speed / C
zstd C1.000×
mzstd1.492×

Datasets are ranked by mzstd speed relative to C, highest first. Throughput counts restored bytes; empty input has latency only. Whiskers and intervals describe sampling uncertainty, not all host variation. Cold APIs include allocation and disposal. C reads the frame’s content size and decodes into a buffer of exactly that size; mzstd returns its own vector.

empty

Empty input; measures API overhead.

9 compressed → 0 restored bytes; compressed / original: undefined.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C2.2592.196–2.323—1.000×
mzstd0.040260.03867–0.04205—56.115×

tiny-text

44-byte quick-brown-fox sentence.

53 compressed → 44 restored bytes; compressed / original: 120.455%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C1.9651.938–1.99621.361.000×
mzstd0.05360.05139–0.056782.8436.654×

random-64k

64 KiB prefix of the deterministic pseudorandom corpus.

65,546 compressed → 65,536 restored bytes; compressed / original: 100.015%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C2.922.864–2.98321,405.601.000×
mzstd0.910.8992–0.921768,684.173.209×

repeated-1m

1 MiB of repeated a bytes.

50 compressed → 1,048,576 restored bytes; compressed / original: 0.005%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C15.515.08–15.9564,516.251.000×
mzstd9.9359.664–10.23100,654.741.560×

binary-64k

64 KiB of structured binary bytes: ((i / 16) XOR i) modulo 256.

1,259 compressed → 65,536 restored bytes; compressed / original: 1.921%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C6.7236.445–7.0239,296.291.000×
mzstd4.7234.488–4.98213,232.541.423×

random-1m

1 MiB of deterministic xorshift64 pseudorandom bytes.

1,048,609 compressed → 1,048,576 restored bytes; compressed / original: 100.003%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C19.6319.34–19.9350,950.511.000×
mzstd16.9316.69–17.259,062.321.159×

text-1m

Alice text repeated cyclically to 1 MiB.

51,847 compressed → 1,048,576 restored bytes; compressed / original: 4.945%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C105101.6–109.19,522.541.000×
mzstd104.8103–107.49,541.631.002×

alice29

Alice in Wonderland text from the Canterbury corpus (testdata/alice29.txt).

49,705 compressed → 152,089 restored bytes; compressed / original: 32.682%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C104.1100.4–108.51,392.901.000×
mzstd105.7104.1–107.71,372.450.985×

Decompression of level 14 frames

Decoder overview · Benchmark index

Recorded run: long-20261109 (2026-11-09). CSV · Environment · Methodology and limits.

Level belongs to the C encoder that prepared the frame; decoders have no level setting. Both decoders restore the same bytes.

Median speed across 8 datasets

Median of per-dataset C mean time / decoder mean time ratios, with equal weight. Higher is faster; 1× matches C. No confidence interval is inferred for this across-dataset median.

DecoderMedian speed / C
zstd C1.000×
mzstd1.497×

Datasets are ranked by mzstd speed relative to C, highest first. Throughput counts restored bytes; empty input has latency only. Whiskers and intervals describe sampling uncertainty, not all host variation. Cold APIs include allocation and disposal. C reads the frame’s content size and decodes into a buffer of exactly that size; mzstd returns its own vector.

empty

Empty input; measures API overhead.

9 compressed → 0 restored bytes; compressed / original: undefined.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C2.0051.957–2.06—1.000×
mzstd0.037890.03716–0.03877—52.916×

tiny-text

44-byte quick-brown-fox sentence.

53 compressed → 44 restored bytes; compressed / original: 120.455%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C2.0672.015–2.12820.301.000×
mzstd0.050990.04988–0.05224822.8840.541×

random-64k

64 KiB prefix of the deterministic pseudorandom corpus.

65,546 compressed → 65,536 restored bytes; compressed / original: 100.015%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C2.8062.779–2.83922,274.561.000×
mzstd0.90140.8916–0.91269,338.543.113×

repeated-1m

1 MiB of repeated a bytes.

50 compressed → 1,048,576 restored bytes; compressed / original: 0.005%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C15.2214.81–15.6565,718.731.000×
mzstd9.7149.462–9.981102,943.341.566×

binary-64k

64 KiB of structured binary bytes: ((i / 16) XOR i) modulo 256.

1,259 compressed → 65,536 restored bytes; compressed / original: 1.921%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C6.5396.32–6.7759,558.221.000×
mzstd4.5774.386–4.79713,653.871.428×

random-1m

1 MiB of deterministic xorshift64 pseudorandom bytes.

1,048,609 compressed → 1,048,576 restored bytes; compressed / original: 100.003%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C18.8918.82–18.9752,931.641.000×
mzstd17.3417.12–17.5757,681.631.090×

alice29

Alice in Wonderland text from the Canterbury corpus (testdata/alice29.txt).

49,635 compressed → 152,089 restored bytes; compressed / original: 32.635%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C99.3698.5–100.41,459.831.000×
mzstd106.2104.9–107.61,366.310.936×

text-1m

Alice text repeated cyclically to 1 MiB.

51,818 compressed → 1,048,576 restored bytes; compressed / original: 4.942%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C100.598.49–103.59,946.041.000×
mzstd108104.8–111.99,259.960.931×

Decompression of level 15 frames

Decoder overview · Benchmark index

Recorded run: long-20261109 (2026-11-09). CSV · Environment · Methodology and limits.

Level belongs to the C encoder that prepared the frame; decoders have no level setting. Both decoders restore the same bytes.

Median speed across 8 datasets

Median of per-dataset C mean time / decoder mean time ratios, with equal weight. Higher is faster; 1× matches C. No confidence interval is inferred for this across-dataset median.

DecoderMedian speed / C
zstd C1.000×
mzstd1.573×

Datasets are ranked by mzstd speed relative to C, highest first. Throughput counts restored bytes; empty input has latency only. Whiskers and intervals describe sampling uncertainty, not all host variation. Cold APIs include allocation and disposal. C reads the frame’s content size and decodes into a buffer of exactly that size; mzstd returns its own vector.

empty

Empty input; measures API overhead.

9 compressed → 0 restored bytes; compressed / original: undefined.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C2.0692.006–2.138—1.000×
mzstd0.038560.03728–0.04011—53.656×

tiny-text

44-byte quick-brown-fox sentence.

53 compressed → 44 restored bytes; compressed / original: 120.455%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C2.1272.065–2.19819.731.000×
mzstd0.05110.04961–0.05283821.1641.629×

random-64k

64 KiB prefix of the deterministic pseudorandom corpus.

65,546 compressed → 65,536 restored bytes; compressed / original: 100.015%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C2.922.86–2.98821,403.451.000×
mzstd0.89270.8851–0.901770,012.213.271×

repeated-1m

1 MiB of repeated a bytes.

50 compressed → 1,048,576 restored bytes; compressed / original: 0.005%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C15.6515.12–16.263,903.511.000×
mzstd9.7339.499–9.983102,744.881.608×

binary-64k

64 KiB of structured binary bytes: ((i / 16) XOR i) modulo 256.

1,259 compressed → 65,536 restored bytes; compressed / original: 1.921%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C7.1716.843–7.5078,715.721.000×
mzstd4.6634.462–4.88713,402.881.538×

random-1m

1 MiB of deterministic xorshift64 pseudorandom bytes.

1,048,609 compressed → 1,048,576 restored bytes; compressed / original: 100.003%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C19.6719.36–20.0150,836.071.000×
mzstd16.5916.52–16.6860,261.621.185×

text-1m

Alice text repeated cyclically to 1 MiB.

51,817 compressed → 1,048,576 restored bytes; compressed / original: 4.942%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C104.1101.3–107.29,606.471.000×
mzstd104.1102.9–105.69,609.841.000×

alice29

Alice in Wonderland text from the Canterbury corpus (testdata/alice29.txt).

49,610 compressed → 152,089 restored bytes; compressed / original: 32.619%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C103.7100.3–1081,398.451.000×
mzstd113.4108.8–118.81,279.280.915×

Decompression of level 16 frames

Decoder overview · Benchmark index

Recorded run: long-20261109 (2026-11-09). CSV · Environment · Methodology and limits.

Level belongs to the C encoder that prepared the frame; decoders have no level setting. Both decoders restore the same bytes.

Median speed across 8 datasets

Median of per-dataset C mean time / decoder mean time ratios, with equal weight. Higher is faster; 1× matches C. No confidence interval is inferred for this across-dataset median.

DecoderMedian speed / C
zstd C1.000×
mzstd1.453×

Datasets are ranked by mzstd speed relative to C, highest first. Throughput counts restored bytes; empty input has latency only. Whiskers and intervals describe sampling uncertainty, not all host variation. Cold APIs include allocation and disposal. C reads the frame’s content size and decodes into a buffer of exactly that size; mzstd returns its own vector.

empty

Empty input; measures API overhead.

9 compressed → 0 restored bytes; compressed / original: undefined.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C2.0652.001–2.138—1.000×
mzstd0.038090.03724–0.03915—54.227×

tiny-text

44-byte quick-brown-fox sentence.

53 compressed → 44 restored bytes; compressed / original: 120.455%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C2.2172.151–2.28318.931.000×
mzstd0.050890.04957–0.05241824.6243.570×

random-64k

64 KiB prefix of the deterministic pseudorandom corpus.

65,546 compressed → 65,536 restored bytes; compressed / original: 100.015%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C2.922.865–2.9821,405.351.000×
mzstd0.90040.894–0.907769,412.173.243×

repeated-1m

1 MiB of repeated a bytes.

49 compressed → 1,048,576 restored bytes; compressed / original: 0.005%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C14.2113.97–14.5270,356.231.000×
mzstd9.7489.483–10.03102,583.681.458×

binary-64k

64 KiB of structured binary bytes: ((i / 16) XOR i) modulo 256.

1,259 compressed → 65,536 restored bytes; compressed / original: 1.921%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C6.2816.143–6.4389,950.301.000×
mzstd4.3364.231–4.47414,413.161.449×

random-1m

1 MiB of deterministic xorshift64 pseudorandom bytes.

1,048,609 compressed → 1,048,576 restored bytes; compressed / original: 100.003%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C19.2819.18–19.3851,870.231.000×
mzstd17.2717.07–17.4957,900.781.116×

text-1m

Alice text repeated cyclically to 1 MiB.

49,924 compressed → 1,048,576 restored bytes; compressed / original: 4.761%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C117.6113.1–122.88,504.211.000×
mzstd116.9115–119.18,553.421.006×

alice29

Alice in Wonderland text from the Canterbury corpus (testdata/alice29.txt).

49,355 compressed → 152,089 restored bytes; compressed / original: 32.451%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C113.4108.2–1191,279.181.000×
mzstd117.3112.2–1231,236.960.967×

Decompression of level 17 frames

Decoder overview · Benchmark index

Recorded run: long-20261109 (2026-11-09). CSV · Environment · Methodology and limits.

Level belongs to the C encoder that prepared the frame; decoders have no level setting. Both decoders restore the same bytes.

Median speed across 8 datasets

Median of per-dataset C mean time / decoder mean time ratios, with equal weight. Higher is faster; 1× matches C. No confidence interval is inferred for this across-dataset median.

DecoderMedian speed / C
zstd C1.000×
mzstd1.348×

Datasets are ranked by mzstd speed relative to C, highest first. Throughput counts restored bytes; empty input has latency only. Whiskers and intervals describe sampling uncertainty, not all host variation. Cold APIs include allocation and disposal. C reads the frame’s content size and decodes into a buffer of exactly that size; mzstd returns its own vector.

empty

Empty input; measures API overhead.

9 compressed → 0 restored bytes; compressed / original: undefined.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C2.0271.975–2.085—1.000×
mzstd0.037710.03687–0.03869—53.745×

tiny-text

44-byte quick-brown-fox sentence.

53 compressed → 44 restored bytes; compressed / original: 120.455%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C2.0752.013–2.15220.221.000×
mzstd0.05170.05–0.05362811.6040.139×

random-64k

64 KiB prefix of the deterministic pseudorandom corpus.

65,546 compressed → 65,536 restored bytes; compressed / original: 100.015%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C2.8342.798–2.87822,050.011.000×
mzstd0.89970.8917–0.909169,463.763.150×

repeated-1m

1 MiB of repeated a bytes.

49 compressed → 1,048,576 restored bytes; compressed / original: 0.005%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C14.8614.43–15.3367,299.091.000×
mzstd9.9489.674–10.23100,520.541.494×

binary-64k

64 KiB of structured binary bytes: ((i / 16) XOR i) modulo 256.

1,259 compressed → 65,536 restored bytes; compressed / original: 1.921%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C6.4986.301–6.7139,617.731.000×
mzstd5.4095.1–5.72111,554.681.201×

random-1m

1 MiB of deterministic xorshift64 pseudorandom bytes.

1,048,609 compressed → 1,048,576 restored bytes; compressed / original: 100.003%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C19.4519.31–19.6251,406.141.000×
mzstd17.0516.91–17.258,665.511.141×

text-1m

Alice text repeated cyclically to 1 MiB.

49,642 compressed → 1,048,576 restored bytes; compressed / original: 4.734%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C119114.7–1248,403.131.000×
mzstd126.9122.8–131.67,882.110.938×

alice29

Alice in Wonderland text from the Canterbury corpus (testdata/alice29.txt).

49,341 compressed → 152,089 restored bytes; compressed / original: 32.442%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C103.3101.1–105.91,404.161.000×
mzstd127.2118.6–136.41,140.260.812×

Decompression of level 18 frames

Decoder overview · Benchmark index

Recorded run: long-20261109 (2026-11-09). CSV · Environment · Methodology and limits.

Level belongs to the C encoder that prepared the frame; decoders have no level setting. Both decoders restore the same bytes.

Median speed across 8 datasets

Median of per-dataset C mean time / decoder mean time ratios, with equal weight. Higher is faster; 1× matches C. No confidence interval is inferred for this across-dataset median.

DecoderMedian speed / C
zstd C1.000×
mzstd1.407×

Datasets are ranked by mzstd speed relative to C, highest first. Throughput counts restored bytes; empty input has latency only. Whiskers and intervals describe sampling uncertainty, not all host variation. Cold APIs include allocation and disposal. C reads the frame’s content size and decodes into a buffer of exactly that size; mzstd returns its own vector.

empty

Empty input; measures API overhead.

9 compressed → 0 restored bytes; compressed / original: undefined.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C1.9991.938–2.076—1.000×
mzstd0.03770.03713–0.03837—53.021×

tiny-text

44-byte quick-brown-fox sentence.

53 compressed → 44 restored bytes; compressed / original: 120.455%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C1.971.954–1.98921.301.000×
mzstd0.053830.05149–0.05635779.5536.605×

random-64k

64 KiB prefix of the deterministic pseudorandom corpus.

65,546 compressed → 65,536 restored bytes; compressed / original: 100.015%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C2.892.847–2.9421,627.461.000×
mzstd0.89590.8902–0.903769,762.493.226×

repeated-1m

1 MiB of repeated a bytes.

49 compressed → 1,048,576 restored bytes; compressed / original: 0.005%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C14.6114.23–15.0568,434.721.000×
mzstd9.3839.221–9.561106,577.921.557×

binary-64k

64 KiB of structured binary bytes: ((i / 16) XOR i) modulo 256.

1,259 compressed → 65,536 restored bytes; compressed / original: 1.921%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C6.7896.502–7.0949,206.001.000×
mzstd5.4065.156–5.65711,561.581.256×

random-1m

1 MiB of deterministic xorshift64 pseudorandom bytes.

1,048,609 compressed → 1,048,576 restored bytes; compressed / original: 100.003%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C19.4919.35–19.6651,300.311.000×
mzstd17.5217.33–17.7257,082.291.113×

alice29

Alice in Wonderland text from the Canterbury corpus (testdata/alice29.txt).

49,215 compressed → 152,089 restored bytes; compressed / original: 32.359%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C110.7105.3–1171,309.841.000×
mzstd115.3110.9–120.41,257.680.960×

text-1m

Alice text repeated cyclically to 1 MiB.

49,433 compressed → 1,048,576 restored bytes; compressed / original: 4.714%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C121.5116.8–127.18,229.541.000×
mzstd129.8124.7–135.87,702.410.936×

Decompression of level 19 frames

Decoder overview · Benchmark index

Recorded run: long-20261109 (2026-11-09). CSV · Environment · Methodology and limits.

Level belongs to the C encoder that prepared the frame; decoders have no level setting. Both decoders restore the same bytes.

Median speed across 8 datasets

Median of per-dataset C mean time / decoder mean time ratios, with equal weight. Higher is faster; 1× matches C. No confidence interval is inferred for this across-dataset median.

DecoderMedian speed / C
zstd C1.000×
mzstd1.457×

Datasets are ranked by mzstd speed relative to C, highest first. Throughput counts restored bytes; empty input has latency only. Whiskers and intervals describe sampling uncertainty, not all host variation. Cold APIs include allocation and disposal. C reads the frame’s content size and decodes into a buffer of exactly that size; mzstd returns its own vector.

empty

Empty input; measures API overhead.

9 compressed → 0 restored bytes; compressed / original: undefined.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C1.9551.94–1.971—1.000×
mzstd0.037560.03678–0.0386—52.045×

tiny-text

44-byte quick-brown-fox sentence.

53 compressed → 44 restored bytes; compressed / original: 120.455%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C2.051.995–2.11420.471.000×
mzstd0.049090.04843–0.04984854.7741.754×

random-64k

64 KiB prefix of the deterministic pseudorandom corpus.

65,546 compressed → 65,536 restored bytes; compressed / original: 100.015%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C2.822.792–2.85322,163.931.000×
mzstd0.94290.9265–0.9666,283.212.991×

binary-64k

64 KiB of structured binary bytes: ((i / 16) XOR i) modulo 256.

1,259 compressed → 65,536 restored bytes; compressed / original: 1.921%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C7.4627.176–7.7488,375.541.000×
mzstd5.1214.899–5.34712,205.561.457×

repeated-1m

1 MiB of repeated a bytes.

49 compressed → 1,048,576 restored bytes; compressed / original: 0.005%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C14.1413.92–14.3970,738.811.000×
mzstd9.719.432–10.01102,982.421.456×

random-1m

1 MiB of deterministic xorshift64 pseudorandom bytes.

1,048,609 compressed → 1,048,576 restored bytes; compressed / original: 100.003%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C19.819.58–20.0650,492.791.000×
mzstd17.3217.18–17.4757,741.151.144×

alice29

Alice in Wonderland text from the Canterbury corpus (testdata/alice29.txt).

49,211 compressed → 152,089 restored bytes; compressed / original: 32.357%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C116.4110.5–122.91,245.671.000×
mzstd121.1114.6–128.51,197.330.961×

text-1m

Alice text repeated cyclically to 1 MiB.

49,285 compressed → 1,048,576 restored bytes; compressed / original: 4.700%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C117.2114.9–119.98,533.021.000×
mzstd134.8127.3–143.17,419.530.870×

Decompression of level 20 frames

Decoder overview · Benchmark index

Recorded run: long-20261109 (2026-11-09). CSV · Environment · Methodology and limits.

Level belongs to the C encoder that prepared the frame; decoders have no level setting. Both decoders restore the same bytes.

Median speed across 8 datasets

Median of per-dataset C mean time / decoder mean time ratios, with equal weight. Higher is faster; 1× matches C. No confidence interval is inferred for this across-dataset median.

DecoderMedian speed / C
zstd C1.000×
mzstd1.539×

Datasets are ranked by mzstd speed relative to C, highest first. Throughput counts restored bytes; empty input has latency only. Whiskers and intervals describe sampling uncertainty, not all host variation. Cold APIs include allocation and disposal. C reads the frame’s content size and decodes into a buffer of exactly that size; mzstd returns its own vector.

empty

Empty input; measures API overhead.

9 compressed → 0 restored bytes; compressed / original: undefined.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C2.051.998–2.109—1.000×
mzstd0.038180.03755–0.03893—53.690×

tiny-text

44-byte quick-brown-fox sentence.

53 compressed → 44 restored bytes; compressed / original: 120.455%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C2.0141.959–2.08820.831.000×
mzstd0.048560.04812–0.04904864.1741.478×

random-64k

64 KiB prefix of the deterministic pseudorandom corpus.

65,546 compressed → 65,536 restored bytes; compressed / original: 100.015%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C3.0022.923–3.08720,816.621.000×
mzstd0.90470.896–0.914469,086.743.319×

binary-64k

64 KiB of structured binary bytes: ((i / 16) XOR i) modulo 256.

1,259 compressed → 65,536 restored bytes; compressed / original: 1.921%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C7.0576.756–7.378,855.971.000×
mzstd4.5234.359–4.71713,819.391.560×

repeated-1m

1 MiB of repeated a bytes.

49 compressed → 1,048,576 restored bytes; compressed / original: 0.005%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C14.3313.99–14.7269,785.381.000×
mzstd9.4429.244–9.663105,910.671.518×

random-1m

1 MiB of deterministic xorshift64 pseudorandom bytes.

1,048,609 compressed → 1,048,576 restored bytes; compressed / original: 100.003%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C20.0219.77–20.2749,954.891.000×
mzstd17.6917.44–17.9556,535.561.132×

alice29

Alice in Wonderland text from the Canterbury corpus (testdata/alice29.txt).

49,211 compressed → 152,089 restored bytes; compressed / original: 32.357%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C111.4105.8–117.61,302.351.000×
mzstd110.7107.6–114.41,310.791.006×

text-1m

Alice text repeated cyclically to 1 MiB.

49,285 compressed → 1,048,576 restored bytes; compressed / original: 4.700%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C122.5118.7–1278,165.861.000×
mzstd131126.4–136.27,635.640.935×

Decompression of level 21 frames

Decoder overview · Benchmark index

Recorded run: long-20261109 (2026-11-09). CSV · Environment · Methodology and limits.

Level belongs to the C encoder that prepared the frame; decoders have no level setting. Both decoders restore the same bytes.

Median speed across 8 datasets

Median of per-dataset C mean time / decoder mean time ratios, with equal weight. Higher is faster; 1× matches C. No confidence interval is inferred for this across-dataset median.

DecoderMedian speed / C
zstd C1.000×
mzstd1.496×

Datasets are ranked by mzstd speed relative to C, highest first. Throughput counts restored bytes; empty input has latency only. Whiskers and intervals describe sampling uncertainty, not all host variation. Cold APIs include allocation and disposal. C reads the frame’s content size and decodes into a buffer of exactly that size; mzstd returns its own vector.

empty

Empty input; measures API overhead.

9 compressed → 0 restored bytes; compressed / original: undefined.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C1.9771.94–2.021—1.000×
mzstd0.038120.03739–0.03898—51.872×

tiny-text

44-byte quick-brown-fox sentence.

53 compressed → 44 restored bytes; compressed / original: 120.455%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C1.9831.95–2.02421.161.000×
mzstd0.049090.04847–0.04979854.8040.394×

random-64k

64 KiB prefix of the deterministic pseudorandom corpus.

65,546 compressed → 65,536 restored bytes; compressed / original: 100.015%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C2.8842.836–2.93821,673.941.000×
mzstd0.94230.9219–0.964566,325.133.060×

repeated-1m

1 MiB of repeated a bytes.

49 compressed → 1,048,576 restored bytes; compressed / original: 0.005%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C15.3814.85–15.9465,033.631.000×
mzstd9.3849.229–9.557106,562.301.639×

binary-64k

64 KiB of structured binary bytes: ((i / 16) XOR i) modulo 256.

1,259 compressed → 65,536 restored bytes; compressed / original: 1.921%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C7.1236.798–7.4638,773.991.000×
mzstd5.2665.015–5.52811,869.301.353×

random-1m

1 MiB of deterministic xorshift64 pseudorandom bytes.

1,048,609 compressed → 1,048,576 restored bytes; compressed / original: 100.003%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C19.4619.34–19.5951,397.581.000×
mzstd17.2317.12–17.3658,023.291.129×

text-1m

Alice text repeated cyclically to 1 MiB.

49,285 compressed → 1,048,576 restored bytes; compressed / original: 4.700%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C124.1120.3–128.38,061.021.000×
mzstd128.3124.6–132.87,791.430.967×

alice29

Alice in Wonderland text from the Canterbury corpus (testdata/alice29.txt).

49,211 compressed → 152,089 restored bytes; compressed / original: 32.357%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C116111.4–120.81,250.531.000×
mzstd122.7116.6–129.21,182.390.946×

Decompression of level 22 frames

Decoder overview · Benchmark index

Recorded run: long-20261109 (2026-11-09). CSV · Environment · Methodology and limits.

Level belongs to the C encoder that prepared the frame; decoders have no level setting. Both decoders restore the same bytes.

Median speed across 8 datasets

Median of per-dataset C mean time / decoder mean time ratios, with equal weight. Higher is faster; 1× matches C. No confidence interval is inferred for this across-dataset median.

DecoderMedian speed / C
zstd C1.000×
mzstd1.428×

Datasets are ranked by mzstd speed relative to C, highest first. Throughput counts restored bytes; empty input has latency only. Whiskers and intervals describe sampling uncertainty, not all host variation. Cold APIs include allocation and disposal. C reads the frame’s content size and decodes into a buffer of exactly that size; mzstd returns its own vector.

empty

Empty input; measures API overhead.

9 compressed → 0 restored bytes; compressed / original: undefined.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C2.0291.978–2.091—1.000×
mzstd0.037550.03692–0.03833—54.034×

tiny-text

44-byte quick-brown-fox sentence.

53 compressed → 44 restored bytes; compressed / original: 120.455%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C2.0151.961–2.08220.831.000×
mzstd0.053610.05132–0.05609782.7437.584×

random-64k

64 KiB prefix of the deterministic pseudorandom corpus.

65,546 compressed → 65,536 restored bytes; compressed / original: 100.015%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C2.8242.799–2.85222,133.471.000×
mzstd0.92830.9107–0.947467,327.793.042×

repeated-1m

1 MiB of repeated a bytes.

49 compressed → 1,048,576 restored bytes; compressed / original: 0.005%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C15.8515.34–16.3763,104.311.000×
mzstd9.3459.181–9.54107,009.861.696×

binary-64k

64 KiB of structured binary bytes: ((i / 16) XOR i) modulo 256.

1,259 compressed → 65,536 restored bytes; compressed / original: 1.921%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C6.9296.669–7.1969,020.531.000×
mzstd5.9745.758–6.18310,461.551.160×

random-1m

1 MiB of deterministic xorshift64 pseudorandom bytes.

1,048,609 compressed → 1,048,576 restored bytes; compressed / original: 100.003%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C2019.79–20.2250,006.481.000×
mzstd17.417.28–17.5557,460.351.149×

alice29

Alice in Wonderland text from the Canterbury corpus (testdata/alice29.txt).

49,211 compressed → 152,089 restored bytes; compressed / original: 32.357%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C112.7108.4–117.31,287.221.000×
mzstd115.1110.2–120.91,259.630.979×

text-1m

Alice text repeated cyclically to 1 MiB.

49,285 compressed → 1,048,576 restored bytes; compressed / original: 4.700%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
zstd C120.1117.3–123.38,323.891.000×
mzstd126123.3–129.37,933.850.953×

Decompression comparison — 2026-11-09

Benchmark index · All level pages · Published CSV

The published 384 rows cover two decoders on eight identical C-written frames at each source level, recorded as long-20261109 (mzstd 0.2.12, the working tree on 5ecb52f, identified by the source hashes, zstd 1.5.7). mzstd’s median speed relative to C is 1.517× across all 192 dataset/level pairs; mzstd is faster in 151 of them, and in 144 of those its 95% mean timing interval lies wholly below C’s. Empty and tiny inputs have the same weight as large inputs. Per-workload results include cases where mzstd is slower.

The host was loaded by other tenants throughout the run. Sampling was lengthened to 50 samples of 6 s after a 3 s warmup, which keeps the medians stable but leaves a median 95% interval of about 5% per case, against 0.5% in the 2026-10-30 run. Read single cells as ±10%.

Each bar is the median of eight C mean time / decoder mean time ratios, in the same process. The decoder overview lists every source level. Higher is faster; medians do not establish statistical significance.

Datasetmzstd speed / C ↑2026-10-30Range across levels
empty53.4×48.9×47.0–57.2×
tiny-text39.8×38.1×36.4–43.6×
alice290.948×0.944×0.812–1.023×
text-1m0.937×0.965×0.842–1.032×
binary-64k1.453×1.440×1.148–1.835×
random-64k3.218×3.126×2.991–3.631×
random-1m1.142×1.137×1.090–1.185×
repeated-1m1.532×1.506×1.456–1.696×

Empty and tiny frames decode tens of times faster because mzstd needs no preallocated decoding context. Compressible text still trails: all 41 slower pairs are Alice (22 of 24 levels, all but 1 and 20) and cyclic Alice (19 of 24, all but 5, 11, 13, 15 and 16). At level 1 Alice decodes at 1,539 MiB/s against C’s 1,505 MiB/s; cyclic Alice at 8,939 MiB/s against 10,016. Most of that gap is the Huffman literal loop (decoder fast paths). Cyclic Alice’s median fell from 0.965× to 0.937×, well inside the 0.84–1.03× spread its cases show across levels in this run; this run cannot tell that change from the host’s noise.

This suite times only decompress; decompress_to_slice and the streaming sessions are measured by the root decompress benchmark, and lzbench measures whole-archive throughput on silesia.tar.

Measurement contract

Cold one-shot APIs include construction, allocation, decoding and disposal: C reads the frame’s content size (ZSTD_getFrameContentSize) and decodes with ZSTD_decompress into an uninitialized buffer of exactly that size; mzstd runs Decompressor::default().decompress, which returns its own vector. Throughput counts restored bytes. The shared frame sizes belong to the C encoder, not to a decoder. Every case must restore the original bytes through both decoders before timing.

The corpus is empty input, tiny text, Alice, cyclic Alice at 1 MiB, structured binary at 64 KiB, random bytes at 64 KiB and 1 MiB, and repeated a at 1 MiB. The host is an AMD Ryzen 9 7950X KVM guest with two vCPUs, its host shared with other tenants; the decoder suite ran on vCPU 1 after the encoder suite, with nothing else in the guest. Sampling: 50 samples, 3 s warmup and 6 s requested measurement per case (Criterion extends slow cases to collect every sample). Rust 1.99.1, runtime AVX-512 dispatch with the block loops at the AVX2 level; zstd built by zstd-ffi with GCC 15.2 -O3, without its Huffman assembly.

Provenance and limits

The environment record hashes the measured sources and binaries, the size manifest lists every frame, and the raw Criterion samples and estimates are archived. This is one sweep on one KVM guest; frequency, thermal state and scheduling are not controlled. Other tenants loaded the host throughout: a fixed C decode varied by 8-43% between its 10th and 90th percentile wall times during the day, the median case’s 95% interval is 5.2% (90th percentile 9.1%), and C’s own timings moved by a median 8% against the 2026-10-30 run.

Reproduce from the repository root with a fresh baseline name:

cargo bench --manifest-path benchmarks/comparison/Cargo.toml --bench decoders --locked --no-run
cd benchmarks/comparison
taskset -c 1 target/release/deps/decoders-<hash> --bench --save-baseline my-decoders --sample-size 50 --warm-up-time 3 --measurement-time 6 --noplot
cd ../..
python3 benchmarks/comparison/report.py --decoding --baseline my-decoders --csv /tmp/my-decoders.csv
cp benchmarks/comparison/target/criterion-decoders/sizes.csv /tmp/my-decoders-sizes.csv

Use the benchmark guide to regenerate the published pages from the recorded CSV and environment metadata.

Compression comparison — 2026-11-09

Benchmark index · Results by level

The 384-row CSV records mzstd 0.2.12 (the working tree on 5ecb52f with the v0.2.12 changes in the changelog) against zstd 1.5.7, under the Criterion baseline long-20261109. The median speed relative to C is 1.248× over all 192 dataset/level pairs, and mzstd has the lower mean latency in 174 of them; in 153 of those its 95% interval for mean latency lies wholly below C’s. Every frame is byte-identical to zstd’s.

The host was loaded by other tenants throughout the run (see interpretation limits). Sampling was lengthened to 50 samples of 6 s after a 3 s warmup, which keeps the medians stable but leaves a median 95% interval of about 6% per case, against 0.5% in the 2026-10-30 run. Read single cells as ±10%.

The environment record identifies the measured sources and binaries by SHA-256. The size manifest and the raw Criterion samples and estimates are archived with this run.

Results

Speed / C is C mean latency divided by mzstd mean latency for the same input, in the same process. Each level takes the median of these ratios over eight equally weighted datasets, including empty and tiny input; higher is better. The level overview lists every level’s medians.

The medians over all 24 levels for each dataset, with the 2026-10-30 run (mzstd 0.2.7 plus the then-unreleased changes) for reference:

Datasetmzstd speed / C ↑2026-10-30Range across levels
empty8.410×8.267×5.09–11.44×
tiny-text3.531×3.618×1.35–9.11×
alice291.008×1.019×0.84–1.29×
text-1m1.088×1.085×0.94–1.33×
binary-64k1.241×1.230×1.08–1.80×
random-64k1.151×1.100×0.92–1.64×
random-1m1.111×1.218×0.85–1.88×
repeated-1m2.437×2.390×1.22–3.34×

Eighteen pairs are slower than C, ten of them Alice (levels -1, 1, 2, 4-8, 10 and 12; 0.84–0.998×). The rest are cyclic Alice at levels 7, 8, 10 and 13 (0.94–0.99×), random 64 KiB at level 14 and random 1 MiB at levels 4, 13 and 14 (0.85–0.95×). With this run’s noise, the cells below 0.9× (Alice level 2, random 1 MiB level 13, Alice levels 8 and 12) are outside the range of the previous run but not yet confirmed. Text results at selected levels:

DatasetLevelmzstd, MiB/s ↑C, MiB/s ↑Speed / C ↑Frame bytes, both
alice29-5487.1439.11.109×85,658
alice291392.6395.30.993×59,657
alice293259.4256.51.011×56,970
alice29954.654.31.006×52,188
alice29195.44.11.295×49,211
text-1m-53,4153,1301.091×96,657
text-1m12,4782,1481.153×61,206
text-1m31,5901,3881.145×55,878
text-1m9357.8328.21.090×52,381
text-1m1937.634.51.090×49,285

Absolute throughput is lower than on 2026-10-30 for both implementations, since the tenants’ load slows C and mzstd alike; compare ratios, not MiB/s. Every mzstd frame passed byte-for-byte comparison with zstd and decoded back to the original input through zstd before timing. The level pages expose all datasets, timings, throughput and 95% mean confidence bounds.

Measurement contract

SettingRecorded value
HostAMD Ryzen 9 7950X, Linux KVM guest with two vCPUs, host shared with other tenants
AffinityThe encoder suite’s Criterion process on vCPU 1, then the decoder suite’s; nothing else ran in the guest
Rustrustc 1.99.1 (LLVM 23.1.1); Cargo release defaults; runtime SIMD dispatch (AVX-512 here)
C compilerGCC 15.2.0; cc crate defaults (-O3)
zstd1.5.7; submodule f8745da6ff1ad1e7bab384bd1f9d742439278e99; single-threaded, no legacy formats, no Huffman assembly
CompressionLevel only, complete known input, no dictionary or explicit flush
APICold one-shot: ZSTD_createCCtx + ZSTD_compress2 into a ZSTD_compressBound buffer against Compressor::new(level).compress; construction, allocation, compression and disposal timed
Sampling50 samples; 3 s warmup; 6 s requested measurement per case; Criterion’s default 100,000 bootstrap resamples; 95% confidence intervals
ValidationAll 192 mzstd frames byte-identical to zstd and decoded to the original input through zstd before timing

Inputs are empty, 44-byte text, testdata/alice29.txt (152,089 bytes), cyclic Alice text at 1 MiB, structured binary at 64 KiB, deterministic pseudorandom bytes at 64 KiB and 1 MiB, and 1 MiB of repeated a bytes: mbrotli’s corpus, with the same generators and seeds. Construction and validation occur outside timing. Empty input has latency and output size but no meaningful throughput or compressed/input fraction.

Reproduction

Run from the repository root and choose a fresh baseline name for new timings. These options reproduce the recorded sampling settings:

cargo bench --manifest-path benchmarks/comparison/Cargo.toml --bench encoders --locked --no-run
cd benchmarks/comparison
taskset -c 1 target/release/deps/encoders-<hash> --bench --save-baseline my-encoders --sample-size 50 --warm-up-time 3 --measurement-time 6 --noplot
cd ../..
python3 benchmarks/comparison/report.py --baseline my-encoders --csv /tmp/my-encoders.csv
cp benchmarks/comparison/target/criterion/sizes.csv /tmp/my-encoders-sizes.csv

On a quiet host the shorter settings of the benchmark guide (20 samples, 0.5 s warmup, 1 s measurement) reach intervals below 1%. Criterion extends slow cases to collect the requested samples. The exporter rejects incomplete matrices and mismatched input lengths. Export immediately after timing and archive sizes.csv with the named baseline: a later preflight replaces that shared manifest. The archived gzip file holds each case’s benchmark, estimates and sample JSON records from Criterion.

Use the benchmark guide to regenerate the checked-in pages and charts from the published CSV and environment record. Plotting uses Matplotlib 3.10.8 and does not rerun timings.

Interpretation limits

This is one complete sweep on one KVM guest whose host other tenants loaded throughout: a fixed C decode, probed every 10 minutes from 08:28 to 15:34 CET, varied by 8-43% between its 10th and 90th percentile wall times, and identical instruction counts took up to 24% more cycles. Single cases are therefore noisy (median 95% interval 6.2%, 90th percentile 9.6%; C’s own timings moved by a median 11% against the 2026-10-30 run), while the per-dataset medians agree with that run to within a few percent. C and mzstd run consecutively for each case in a fixed order. Affinity restricts CPU migration but does not control frequency, thermal state or host scheduling.

Every dataset/level pair has equal weight, regardless of input size or execution time. The median describes this corpus, not a production workload distribution. Cold APIs include allocation and allocator state; timings include the effects of glibc’s changing mmap and trim thresholds within a process.

Streaming, retained contexts and parallel compression have separate root benchmarks (cargo bench --bench compress, --bench parallel) and are outside this comparison; lzbench measures whole-archive throughput on silesia.tar. See the harness specification.

lzbench run, 2026-11-09

mzstd 0.2.12 and the previous release, 0.2.11, against zstd 1.5.7, measured with the lzbench in-memory benchmark on the Silesia corpus. The README’s lzbench table is taken from the 0.2.12 rounds.

Environment

  • lzbench master at a6fa8aa (2026-11-07), reporting itself as lzbench 2.4.1. It vendors zstd 1.5.7 and mzstd 0.2.11. For the 0.2.12 build, its lz+entropy/mzstd directory was replaced by this release’s sources; lzbench still labels the codec “mzstd 0.2.11”, the version its codec table names, so the 0.2.12 output below is relabelled.
  • gcc 15.2.0, rustc 1.99.1. Built with make BUILD_ONLY='ZSTD MZSTD' MOREFLAGS="-march=native"; the Rust codecs use lzbench’s default RUST_TARGET_CPU=native, so both sides target the same CPU.
  • AMD Ryzen 9 7950X VDS (2 vCPU, KVM), Linux 7.0, default CPU governor and boost. The host is shared with other tenants, who loaded it throughout the day: a fixed C decode varied by 8-43% between its 10th and 90th percentile wall times. The benchmark is pinned to one vCPU with taskset -c 1.
  • Input: silesia.tar, 211,957,760 bytes, the twelve Silesia files in a ustar archive, compressed as one stream.

Command

taskset -c 1 ./lzbench -t8,8 -o1 \
  -ezstd_fast,-5/mzstd_fast,-5/zstd_fast,-3/mzstd_fast,-3/zstd_fast,-1/mzstd_fast,-1/zstd,1/mzstd,1/zstd,2/mzstd,2/zstd,3/mzstd,3/zstd,5/mzstd,5/zstd,8/mzstd,8/zstd,11/mzstd,11/zstd,15/mzstd,15/zstd,18/mzstd,18/zstd,22/mzstd,22 \
  silesia.tar

-t8,8 repeats each compression and decompression for at least eight seconds and reports the fastest iteration. Codecs are interleaved level by level so that drift on the shared host affects both sides of a pair alike. Because the host was loaded, the command ran in three rounds, each the 0.2.12 build followed by the 0.2.11 build (14:45 to 15:08 UTC, then 17:27 to 18:13 UTC). The tables below take, for every codec and level, the fastest result of the three rounds, extending lzbench’s own fastest-iteration rule; the raw output of every round is at the end.

mzstd 0.2.12, fastest of three rounds

Compressor nameCompressionDecompress.Compr. sizeRatio
zstd 1.5.7 –fast –5893 MB/s3048 MB/s10302389248.61
mzstd 0.2.12 –fast –5838 MB/s2828 MB/s10302389248.61
zstd 1.5.7 –fast –3812 MB/s2867 MB/s9460059244.63
mzstd 0.2.12 –fast –3763 MB/s2673 MB/s9460059244.63
zstd 1.5.7 –fast –1720 MB/s2695 MB/s8691591841.01
mzstd 0.2.12 –fast –1682 MB/s2504 MB/s8691591841.01
zstd 1.5.7 -1658 MB/s2134 MB/s7319135734.53
mzstd 0.2.12 -1626 MB/s1992 MB/s7319135734.53
zstd 1.5.7 -2534 MB/s1966 MB/s6930399432.70
mzstd 0.2.12 -2505 MB/s1834 MB/s6930399432.70
zstd 1.5.7 -3370 MB/s1892 MB/s6613264631.20
mzstd 0.2.12 -3374 MB/s1784 MB/s6613264631.20
zstd 1.5.7 -5208 MB/s1858 MB/s6274079329.60
mzstd 0.2.12 -5203 MB/s1769 MB/s6274079329.60
zstd 1.5.7 -8107 MB/s2040 MB/s5970169728.17
mzstd 0.2.12 -8103 MB/s1945 MB/s5970169728.17
zstd 1.5.7 -1145.7 MB/s2082 MB/s5796611227.35
mzstd 0.2.12 -1142.7 MB/s1982 MB/s5796611227.35
zstd 1.5.7 -158.41 MB/s2116 MB/s5687242526.83
mzstd 0.2.12 -159.04 MB/s2014 MB/s5687242526.83
zstd 1.5.7 -184.28 MB/s1858 MB/s5329792225.15
mzstd 0.2.12 -185.03 MB/s1745 MB/s5329792225.15
zstd 1.5.7 -222.13 MB/s1608 MB/s5228370424.67
mzstd 0.2.12 -222.27 MB/s1498 MB/s5228370424.67

mzstd 0.2.11, fastest of three rounds

Compressor nameCompressionDecompress.Compr. sizeRatio
zstd 1.5.7 –fast –5892 MB/s3036 MB/s10302389248.61
mzstd 0.2.11 –fast –5827 MB/s2849 MB/s10302389248.61
zstd 1.5.7 –fast –3810 MB/s2852 MB/s9460059244.63
mzstd 0.2.11 –fast –3755 MB/s2652 MB/s9460059244.63
zstd 1.5.7 –fast –1714 MB/s2692 MB/s8691591841.01
mzstd 0.2.11 –fast –1665 MB/s2501 MB/s8691591841.01
zstd 1.5.7 -1655 MB/s2122 MB/s7319135734.53
mzstd 0.2.11 -1607 MB/s1874 MB/s7319135734.53
zstd 1.5.7 -2540 MB/s1964 MB/s6930399432.70
mzstd 0.2.11 -2500 MB/s1780 MB/s6930399432.70
zstd 1.5.7 -3368 MB/s1879 MB/s6613264631.20
mzstd 0.2.11 -3358 MB/s1737 MB/s6613264631.20
zstd 1.5.7 -5209 MB/s1854 MB/s6274079329.60
mzstd 0.2.11 -5201 MB/s1730 MB/s6274079329.60
zstd 1.5.7 -8107 MB/s2023 MB/s5970169728.17
mzstd 0.2.11 -8100 MB/s1894 MB/s5970169728.17
zstd 1.5.7 -1143.1 MB/s2061 MB/s5796611227.35
mzstd 0.2.11 -1143.6 MB/s1946 MB/s5796611227.35
zstd 1.5.7 -158.25 MB/s2125 MB/s5687242526.83
mzstd 0.2.11 -158.75 MB/s1969 MB/s5687242526.83
zstd 1.5.7 -183.96 MB/s1861 MB/s5329792225.15
mzstd 0.2.11 -184.98 MB/s1728 MB/s5329792225.15
zstd 1.5.7 -222.11 MB/s1619 MB/s5228370424.67
mzstd 0.2.11 -222.44 MB/s1473 MB/s5228370424.67

Speed relative to zstd

Each value is mzstd’s speed divided by zstd’s, both the fastest of the three rounds; the rounds columns give the ratio within each round. The two releases are compared through the zstd beside them rather than through absolute speeds. Compressed sizes are byte-for-byte equal at every level in every round.

LevelCompress 0.2.11Compress 0.2.12rounds 0.2.12Decompress 0.2.11Decompress 0.2.12rounds 0.2.12
–fast=50.9270.9380.950 0.937 0.9330.9380.9280.931 0.921 0.922
–fast=30.9320.9400.943 0.938 0.9490.9300.9320.943 0.922 0.932
–fast=10.9310.9470.947 0.938 0.9450.9290.9290.929 0.930 0.931
-10.9270.9510.963 0.948 0.9470.8830.9330.933 0.939 0.936
-20.9260.9460.958 0.949 0.9270.9060.9330.947 0.937 0.933
-30.9731.0111.000 1.082 1.0420.9240.9430.943 0.935 0.944
-50.9620.9760.947 0.903 0.9760.9330.9520.948 0.934 0.952
-80.9350.9630.848 1.030 0.9630.9360.9530.950 0.954 0.953
-111.0120.9340.925 0.966 0.9340.9440.9520.955 0.965 0.952
-151.0611.0751.136 1.047 0.9980.9270.9520.959 0.953 0.946
-181.2581.1751.084 1.130 1.2300.9290.9390.931 0.913 0.939
-221.1561.0661.089 1.030 1.0660.9100.9320.921 0.998 0.932

Decompression ratios reproduce within about 2% between rounds, except at level 22 (0.921 to 0.998), and so does compression at the --fast levels and level 1. Compression at level 2 and above moves by 3% to 21% between rounds (level 8: 0.848 to 1.030) on this host, so differences of a few percent there are within noise. Against 0.2.11, decompression is faster relative to zstd at levels 1 to 22 (level 1: 0.883 to 0.933, level 2: 0.906 to 0.933) and level 3 compresses at about zstd’s speed (1.011) instead of 0.973 of it, from the branchy candidate tests of dfast.

Raw output

Round 1, mzstd 0.2.12
lzbench 2.4.1 | GCC 15.2.0 | 64-bit Linux | AMD Ryzen 9 7950X 16-Core Processor

| Compressor name         | Compression| Decompress.| Compr. size | Ratio | Filename |
| ---------------         | -----------| -----------| ----------- | ----- | -------- |
| zstd 1.5.7 --fast --5   |   882 MB/s |  3038 MB/s |   103023892 | 48.61 | ../silesia.tar|
| mzstd 0.2.12 --fast --5 |   838 MB/s |  2828 MB/s |   103023892 | 48.61 | ../silesia.tar|
| zstd 1.5.7 --fast --3   |   809 MB/s |  2836 MB/s |    94600592 | 44.63 | ../silesia.tar|
| mzstd 0.2.12 --fast --3 |   763 MB/s |  2673 MB/s |    94600592 | 44.63 | ../silesia.tar|
| zstd 1.5.7 --fast --1   |   720 MB/s |  2695 MB/s |    86915918 | 41.01 | ../silesia.tar|
| mzstd 0.2.12 --fast --1 |   682 MB/s |  2504 MB/s |    86915918 | 41.01 | ../silesia.tar|
| zstd 1.5.7 -1           |   650 MB/s |  2134 MB/s |    73191357 | 34.53 | ../silesia.tar|
| mzstd 0.2.12 -1         |   626 MB/s |  1992 MB/s |    73191357 | 34.53 | ../silesia.tar|
| zstd 1.5.7 -2           |   524 MB/s |  1936 MB/s |    69303994 | 32.70 | ../silesia.tar|
| mzstd 0.2.12 -2         |   502 MB/s |  1834 MB/s |    69303994 | 32.70 | ../silesia.tar|
| zstd 1.5.7 -3           |   370 MB/s |  1892 MB/s |    66132646 | 31.20 | ../silesia.tar|
| mzstd 0.2.12 -3         |   370 MB/s |  1784 MB/s |    66132646 | 31.20 | ../silesia.tar|
| zstd 1.5.7 -5           |   208 MB/s |  1840 MB/s |    62740793 | 29.60 | ../silesia.tar|
| mzstd 0.2.12 -5         |   197 MB/s |  1744 MB/s |    62740793 | 29.60 | ../silesia.tar|
| zstd 1.5.7 -8           |   103 MB/s |  2012 MB/s |    59701697 | 28.17 | ../silesia.tar|
| mzstd 0.2.12 -8         |  87.3 MB/s |  1911 MB/s |    59701697 | 28.17 | ../silesia.tar|
| zstd 1.5.7 -11          |  39.9 MB/s |  2060 MB/s |    57966112 | 27.35 | ../silesia.tar|
| mzstd 0.2.12 -11        |  36.9 MB/s |  1967 MB/s |    57966112 | 27.35 | ../silesia.tar|
| zstd 1.5.7 -15          |  7.96 MB/s |  2100 MB/s |    56872425 | 26.83 | ../silesia.tar|
| mzstd 0.2.12 -15        |  9.04 MB/s |  2014 MB/s |    56872425 | 26.83 | ../silesia.tar|
| zstd 1.5.7 -18          |  4.28 MB/s |  1849 MB/s |    53297922 | 25.15 | ../silesia.tar|
| mzstd 0.2.12 -18        |  4.64 MB/s |  1721 MB/s |    53297922 | 25.15 | ../silesia.tar|
| zstd 1.5.7 -22          |  1.92 MB/s |  1547 MB/s |    52283704 | 24.67 | ../silesia.tar|
| mzstd 0.2.12 -22        |  2.09 MB/s |  1425 MB/s |    52283704 | 24.67 | ../silesia.tar|
Round 1, mzstd 0.2.11
lzbench 2.4.1 | GCC 15.2.0 | 64-bit Linux | AMD Ryzen 9 7950X 16-Core Processor

| Compressor name         | Compression| Decompress.| Compr. size | Ratio | Filename |
| ---------------         | -----------| -----------| ----------- | ----- | -------- |
| zstd 1.5.7 --fast --5   |   892 MB/s |  3034 MB/s |   103023892 | 48.61 | ../silesia.tar|
| mzstd 0.2.11 --fast --5 |   824 MB/s |  2849 MB/s |   103023892 | 48.61 | ../silesia.tar|
| zstd 1.5.7 --fast --3   |   810 MB/s |  2852 MB/s |    94600592 | 44.63 | ../silesia.tar|
| mzstd 0.2.11 --fast --3 |   750 MB/s |  2651 MB/s |    94600592 | 44.63 | ../silesia.tar|
| zstd 1.5.7 --fast --1   |   712 MB/s |  2670 MB/s |    86915918 | 41.01 | ../silesia.tar|
| mzstd 0.2.11 --fast --1 |   659 MB/s |  2492 MB/s |    86915918 | 41.01 | ../silesia.tar|
| zstd 1.5.7 -1           |   638 MB/s |  2107 MB/s |    73191357 | 34.53 | ../silesia.tar|
| mzstd 0.2.11 -1         |   597 MB/s |  1857 MB/s |    73191357 | 34.53 | ../silesia.tar|
| zstd 1.5.7 -2           |   523 MB/s |  1926 MB/s |    69303994 | 32.70 | ../silesia.tar|
| mzstd 0.2.11 -2         |   500 MB/s |  1702 MB/s |    69303994 | 32.70 | ../silesia.tar|
| zstd 1.5.7 -3           |   352 MB/s |  1843 MB/s |    66132646 | 31.20 | ../silesia.tar|
| mzstd 0.2.11 -3         |   333 MB/s |  1680 MB/s |    66132646 | 31.20 | ../silesia.tar|
| zstd 1.5.7 -5           |   177 MB/s |  1791 MB/s |    62740793 | 29.60 | ../silesia.tar|
| mzstd 0.2.11 -5         |   174 MB/s |  1689 MB/s |    62740793 | 29.60 | ../silesia.tar|
| zstd 1.5.7 -8           |  96.3 MB/s |  1962 MB/s |    59701697 | 28.17 | ../silesia.tar|
| mzstd 0.2.11 -8         |  96.2 MB/s |  1803 MB/s |    59701697 | 28.17 | ../silesia.tar|
| zstd 1.5.7 -11          |  39.0 MB/s |  2006 MB/s |    57966112 | 27.35 | ../silesia.tar|
| mzstd 0.2.11 -11        |  39.8 MB/s |  1864 MB/s |    57966112 | 27.35 | ../silesia.tar|
| zstd 1.5.7 -15          |  7.66 MB/s |  2066 MB/s |    56872425 | 26.83 | ../silesia.tar|
| mzstd 0.2.11 -15        |  7.74 MB/s |  1947 MB/s |    56872425 | 26.83 | ../silesia.tar|
| zstd 1.5.7 -18          |  3.93 MB/s |  1846 MB/s |    53297922 | 25.15 | ../silesia.tar|
| mzstd 0.2.11 -18        |  4.57 MB/s |  1654 MB/s |    53297922 | 25.15 | ../silesia.tar|
| zstd 1.5.7 -22          |  2.06 MB/s |  1584 MB/s |    52283704 | 24.67 | ../silesia.tar|
| mzstd 0.2.11 -22        |  2.44 MB/s |  1473 MB/s |    52283704 | 24.67 | ../silesia.tar|
Round 2, mzstd 0.2.12
lzbench 2.4.1 | GCC 15.2.0 | 64-bit Linux | AMD Ryzen 9 7950X 16-Core Processor

| Compressor name         | Compression| Decompress.| Compr. size | Ratio | Filename |
| ---------------         | -----------| -----------| ----------- | ----- | -------- |
| zstd 1.5.7 --fast --5   |   884 MB/s |  3048 MB/s |   103023892 | 48.61 | ../silesia.tar|
| mzstd 0.2.12 --fast --5 |   828 MB/s |  2806 MB/s |   103023892 | 48.61 | ../silesia.tar|
| zstd 1.5.7 --fast --3   |   812 MB/s |  2867 MB/s |    94600592 | 44.63 | ../silesia.tar|
| mzstd 0.2.12 --fast --3 |   762 MB/s |  2643 MB/s |    94600592 | 44.63 | ../silesia.tar|
| zstd 1.5.7 --fast --1   |   712 MB/s |  2658 MB/s |    86915918 | 41.01 | ../silesia.tar|
| mzstd 0.2.12 --fast --1 |   668 MB/s |  2472 MB/s |    86915918 | 41.01 | ../silesia.tar|
| zstd 1.5.7 -1           |   653 MB/s |  2100 MB/s |    73191357 | 34.53 | ../silesia.tar|
| mzstd 0.2.12 -1         |   619 MB/s |  1971 MB/s |    73191357 | 34.53 | ../silesia.tar|
| zstd 1.5.7 -2           |   532 MB/s |  1932 MB/s |    69303994 | 32.70 | ../silesia.tar|
| mzstd 0.2.12 -2         |   505 MB/s |  1811 MB/s |    69303994 | 32.70 | ../silesia.tar|
| zstd 1.5.7 -3           |   342 MB/s |  1880 MB/s |    66132646 | 31.20 | ../silesia.tar|
| mzstd 0.2.12 -3         |   370 MB/s |  1758 MB/s |    66132646 | 31.20 | ../silesia.tar|
| zstd 1.5.7 -5           |   207 MB/s |  1852 MB/s |    62740793 | 29.60 | ../silesia.tar|
| mzstd 0.2.12 -5         |   187 MB/s |  1730 MB/s |    62740793 | 29.60 | ../silesia.tar|
| zstd 1.5.7 -8           |  98.1 MB/s |  2025 MB/s |    59701697 | 28.17 | ../silesia.tar|
| mzstd 0.2.12 -8         |   101 MB/s |  1931 MB/s |    59701697 | 28.17 | ../silesia.tar|
| zstd 1.5.7 -11          |  43.6 MB/s |  2043 MB/s |    57966112 | 27.35 | ../silesia.tar|
| mzstd 0.2.12 -11        |  42.1 MB/s |  1971 MB/s |    57966112 | 27.35 | ../silesia.tar|
| zstd 1.5.7 -15          |  8.04 MB/s |  2106 MB/s |    56872425 | 26.83 | ../silesia.tar|
| mzstd 0.2.12 -15        |  8.42 MB/s |  2006 MB/s |    56872425 | 26.83 | ../silesia.tar|
| zstd 1.5.7 -18          |  4.07 MB/s |  1847 MB/s |    53297922 | 25.15 | ../silesia.tar|
| mzstd 0.2.12 -18        |  4.60 MB/s |  1687 MB/s |    53297922 | 25.15 | ../silesia.tar|
| zstd 1.5.7 -22          |  2.01 MB/s |  1459 MB/s |    52283704 | 24.67 | ../silesia.tar|
| mzstd 0.2.12 -22        |  2.07 MB/s |  1456 MB/s |    52283704 | 24.67 | ../silesia.tar|
Round 2, mzstd 0.2.11
lzbench 2.4.1 | GCC 15.2.0 | 64-bit Linux | AMD Ryzen 9 7950X 16-Core Processor

| Compressor name         | Compression| Decompress.| Compr. size | Ratio | Filename |
| ---------------         | -----------| -----------| ----------- | ----- | -------- |
| zstd 1.5.7 --fast --5   |   884 MB/s |  3036 MB/s |   103023892 | 48.61 | ../silesia.tar|
| mzstd 0.2.11 --fast --5 |   811 MB/s |  2830 MB/s |   103023892 | 48.61 | ../silesia.tar|
| zstd 1.5.7 --fast --3   |   808 MB/s |  2846 MB/s |    94600592 | 44.63 | ../silesia.tar|
| mzstd 0.2.11 --fast --3 |   750 MB/s |  2652 MB/s |    94600592 | 44.63 | ../silesia.tar|
| zstd 1.5.7 --fast --1   |   708 MB/s |  2687 MB/s |    86915918 | 41.01 | ../silesia.tar|
| mzstd 0.2.11 --fast --1 |   652 MB/s |  2501 MB/s |    86915918 | 41.01 | ../silesia.tar|
| zstd 1.5.7 -1           |   644 MB/s |  2112 MB/s |    73191357 | 34.53 | ../silesia.tar|
| mzstd 0.2.11 -1         |   580 MB/s |  1874 MB/s |    73191357 | 34.53 | ../silesia.tar|
| zstd 1.5.7 -2           |   505 MB/s |  1941 MB/s |    69303994 | 32.70 | ../silesia.tar|
| mzstd 0.2.11 -2         |   500 MB/s |  1764 MB/s |    69303994 | 32.70 | ../silesia.tar|
| zstd 1.5.7 -3           |   361 MB/s |  1842 MB/s |    66132646 | 31.20 | ../silesia.tar|
| mzstd 0.2.11 -3         |   346 MB/s |  1723 MB/s |    66132646 | 31.20 | ../silesia.tar|
| zstd 1.5.7 -5           |   195 MB/s |  1822 MB/s |    62740793 | 29.60 | ../silesia.tar|
| mzstd 0.2.11 -5         |   197 MB/s |  1723 MB/s |    62740793 | 29.60 | ../silesia.tar|
| zstd 1.5.7 -8           |   104 MB/s |  2011 MB/s |    59701697 | 28.17 | ../silesia.tar|
| mzstd 0.2.11 -8         |  95.4 MB/s |  1894 MB/s |    59701697 | 28.17 | ../silesia.tar|
| zstd 1.5.7 -11          |  37.7 MB/s |  2042 MB/s |    57966112 | 27.35 | ../silesia.tar|
| mzstd 0.2.11 -11        |  40.3 MB/s |  1946 MB/s |    57966112 | 27.35 | ../silesia.tar|
| zstd 1.5.7 -15          |  7.98 MB/s |  2092 MB/s |    56872425 | 26.83 | ../silesia.tar|
| mzstd 0.2.11 -15        |  8.09 MB/s |  1946 MB/s |    56872425 | 26.83 | ../silesia.tar|
| zstd 1.5.7 -18          |  3.96 MB/s |  1823 MB/s |    53297922 | 25.15 | ../silesia.tar|
| mzstd 0.2.11 -18        |  4.74 MB/s |  1689 MB/s |    53297922 | 25.15 | ../silesia.tar|
| zstd 1.5.7 -22          |  2.03 MB/s |  1619 MB/s |    52283704 | 24.67 | ../silesia.tar|
| mzstd 0.2.11 -22        |  2.39 MB/s |  1471 MB/s |    52283704 | 24.67 | ../silesia.tar|
Round 3, mzstd 0.2.12
lzbench 2.4.1 | GCC 15.2.0 | 64-bit Linux | AMD Ryzen 9 7950X 16-Core Processor

| Compressor name         | Compression| Decompress.| Compr. size | Ratio | Filename |
| ---------------         | -----------| -----------| ----------- | ----- | -------- |
| zstd 1.5.7 --fast --5   |   893 MB/s |  3034 MB/s |   103023892 | 48.61 | ../silesia.tar|
| mzstd 0.2.12 --fast --5 |   833 MB/s |  2797 MB/s |   103023892 | 48.61 | ../silesia.tar|
| zstd 1.5.7 --fast --3   |   803 MB/s |  2842 MB/s |    94600592 | 44.63 | ../silesia.tar|
| mzstd 0.2.12 --fast --3 |   762 MB/s |  2650 MB/s |    94600592 | 44.63 | ../silesia.tar|
| zstd 1.5.7 --fast --1   |   712 MB/s |  2671 MB/s |    86915918 | 41.01 | ../silesia.tar|
| mzstd 0.2.12 --fast --1 |   673 MB/s |  2487 MB/s |    86915918 | 41.01 | ../silesia.tar|
| zstd 1.5.7 -1           |   658 MB/s |  2122 MB/s |    73191357 | 34.53 | ../silesia.tar|
| mzstd 0.2.12 -1         |   623 MB/s |  1987 MB/s |    73191357 | 34.53 | ../silesia.tar|
| zstd 1.5.7 -2           |   534 MB/s |  1966 MB/s |    69303994 | 32.70 | ../silesia.tar|
| mzstd 0.2.12 -2         |   495 MB/s |  1834 MB/s |    69303994 | 32.70 | ../silesia.tar|
| zstd 1.5.7 -3           |   359 MB/s |  1873 MB/s |    66132646 | 31.20 | ../silesia.tar|
| mzstd 0.2.12 -3         |   374 MB/s |  1768 MB/s |    66132646 | 31.20 | ../silesia.tar|
| zstd 1.5.7 -5           |   208 MB/s |  1858 MB/s |    62740793 | 29.60 | ../silesia.tar|
| mzstd 0.2.12 -5         |   203 MB/s |  1769 MB/s |    62740793 | 29.60 | ../silesia.tar|
| zstd 1.5.7 -8           |   107 MB/s |  2040 MB/s |    59701697 | 28.17 | ../silesia.tar|
| mzstd 0.2.12 -8         |   103 MB/s |  1945 MB/s |    59701697 | 28.17 | ../silesia.tar|
| zstd 1.5.7 -11          |  45.7 MB/s |  2082 MB/s |    57966112 | 27.35 | ../silesia.tar|
| mzstd 0.2.12 -11        |  42.7 MB/s |  1982 MB/s |    57966112 | 27.35 | ../silesia.tar|
| zstd 1.5.7 -15          |  8.41 MB/s |  2116 MB/s |    56872425 | 26.83 | ../silesia.tar|
| mzstd 0.2.12 -15        |  8.39 MB/s |  2001 MB/s |    56872425 | 26.83 | ../silesia.tar|
| zstd 1.5.7 -18          |  4.09 MB/s |  1858 MB/s |    53297922 | 25.15 | ../silesia.tar|
| mzstd 0.2.12 -18        |  5.03 MB/s |  1745 MB/s |    53297922 | 25.15 | ../silesia.tar|
| zstd 1.5.7 -22          |  2.13 MB/s |  1608 MB/s |    52283704 | 24.67 | ../silesia.tar|
| mzstd 0.2.12 -22        |  2.27 MB/s |  1498 MB/s |    52283704 | 24.67 | ../silesia.tar|
Round 3, mzstd 0.2.11
lzbench 2.4.1 | GCC 15.2.0 | 64-bit Linux | AMD Ryzen 9 7950X 16-Core Processor

| Compressor name         | Compression| Decompress.| Compr. size | Ratio | Filename |
| ---------------         | -----------| -----------| ----------- | ----- | -------- |
| zstd 1.5.7 --fast --5   |   891 MB/s |  3024 MB/s |   103023892 | 48.61 | ../silesia.tar|
| mzstd 0.2.11 --fast --5 |   827 MB/s |  2810 MB/s |   103023892 | 48.61 | ../silesia.tar|
| zstd 1.5.7 --fast --3   |   796 MB/s |  2833 MB/s |    94600592 | 44.63 | ../silesia.tar|
| mzstd 0.2.11 --fast --3 |   755 MB/s |  2641 MB/s |    94600592 | 44.63 | ../silesia.tar|
| zstd 1.5.7 --fast --1   |   714 MB/s |  2692 MB/s |    86915918 | 41.01 | ../silesia.tar|
| mzstd 0.2.11 --fast --1 |   665 MB/s |  2501 MB/s |    86915918 | 41.01 | ../silesia.tar|
| zstd 1.5.7 -1           |   655 MB/s |  2122 MB/s |    73191357 | 34.53 | ../silesia.tar|
| mzstd 0.2.11 -1         |   607 MB/s |  1873 MB/s |    73191357 | 34.53 | ../silesia.tar|
| zstd 1.5.7 -2           |   540 MB/s |  1964 MB/s |    69303994 | 32.70 | ../silesia.tar|
| mzstd 0.2.11 -2         |   493 MB/s |  1780 MB/s |    69303994 | 32.70 | ../silesia.tar|
| zstd 1.5.7 -3           |   368 MB/s |  1879 MB/s |    66132646 | 31.20 | ../silesia.tar|
| mzstd 0.2.11 -3         |   358 MB/s |  1737 MB/s |    66132646 | 31.20 | ../silesia.tar|
| zstd 1.5.7 -5           |   209 MB/s |  1854 MB/s |    62740793 | 29.60 | ../silesia.tar|
| mzstd 0.2.11 -5         |   201 MB/s |  1730 MB/s |    62740793 | 29.60 | ../silesia.tar|
| zstd 1.5.7 -8           |   107 MB/s |  2023 MB/s |    59701697 | 28.17 | ../silesia.tar|
| mzstd 0.2.11 -8         |   100 MB/s |  1881 MB/s |    59701697 | 28.17 | ../silesia.tar|
| zstd 1.5.7 -11          |  43.1 MB/s |  2061 MB/s |    57966112 | 27.35 | ../silesia.tar|
| mzstd 0.2.11 -11        |  43.6 MB/s |  1901 MB/s |    57966112 | 27.35 | ../silesia.tar|
| zstd 1.5.7 -15          |  8.25 MB/s |  2125 MB/s |    56872425 | 26.83 | ../silesia.tar|
| mzstd 0.2.11 -15        |  8.75 MB/s |  1969 MB/s |    56872425 | 26.83 | ../silesia.tar|
| zstd 1.5.7 -18          |  3.94 MB/s |  1861 MB/s |    53297922 | 25.15 | ../silesia.tar|
| mzstd 0.2.11 -18        |  4.98 MB/s |  1728 MB/s |    53297922 | 25.15 | ../silesia.tar|
| zstd 1.5.7 -22          |  2.11 MB/s |  1577 MB/s |    52283704 | 24.67 | ../silesia.tar|
| mzstd 0.2.11 -22        |  2.30 MB/s |  1471 MB/s |    52283704 | 24.67 | ../silesia.tar|

Architecture

These specifications describe mzstd as it is implemented today: module and ownership boundaries, public APIs, data flow, state machines, errors and known gaps. zstd-ffi/vendor/zstd (zstd v1.5.7) is the reference implementation, and doc/zstd_compression_format.md in that tree is the format specification. To use the crate, read the guides instead; for checks and repository layout, see development.

Where to start

You want to understandRead, in order
How a compress call becomes a frameCompressor → Encoder core → Optimal parser for the bt strategies
How a frame is decodedDecompressor → Decoder fast paths → Decoder uninitialized output
How dictionaries are loaded and usedDictionaries → Decompressor
How one input is split across threadsParallel compression
How containers workFramed containers → Framed decoder
How byte identity and correctness are establishedEncoder verification → Decoder verification → Fuzzing → Formal verification
Which features compile which codeCodec features

Specifications share an outline: module and ownership boundaries, public surface, control and data flow with Mermaid diagrams, and error propagation. What is missing or slower than the reference is listed under known gaps.

Module map

graph TD
    Root[mzstd public facade] --> Encode[compressor: compression feature]
    Root --> Decode[decompressor: decompression feature]
    Root --> Ret[RetentionPolicy]
    Root --> Back[Backend: SIMD level]
    Root --> IO["io: reader and writer adapters (std)"]
    Root --> Dict["dictionary: DictionaryContent, DictionaryId, DictionaryBuilder, PreparedDictionary, DecodeDictionary, DictionaryRef"]
    Decode --> Dict
    Encode --> Dict
    Dict --> DCore
    Dict --> ECore
    Encode --> Cfg[config + internal lowering]
    Encode --> ECore["compressor::core (private): engine, match finders, entropy, streaming"]
    Encode --> Par["parallel (std): batches, random-access sources, sessions, io adapters + parallel::core (private): segments, inputs, stream"]
    Par --> ECore
    Decode --> DCore["decompressor::core (private): frames, blocks, entropy, history"]
    Root --> Framing["framing (framed feature): neutral types, wire constants"]
    Framing --> FEnc["compressor::framing: FramedCompressor, sessions, std adapters + core: container, resource, driver"]
    Framing --> FDec["decompressor::framing: FramedDecompressor, sessions, FramedReader, FramedSeekReader + core: engine, wire, seek"]
    FEnc --> Encode
    FDec --> DCore
    ECore --> Shared["shared (private): format constants, XXH64"]
    DCore --> Shared
    IO --> Encode
    IO --> Decode
    Ref["zstd-ffi: zstd 1.5.7 + oracle + corpus"] -. tests, benches, fuzzing .-> Root
    CABI["mzstd-ffi: C ABI crate, one-shot and sessions"] --> Root
    Fuzz["fuzz/afl (excluded package)"] -.-> Root
    Fuzz -.-> Ref
    LibFuzz["fuzz/libfuzzer (excluded package): ClusterFuzzLite adapters"] -.-> Fuzz
    Cmp["benchmarks/comparison (excluded package): cold reports and warm profiling against C"] -.-> Root
    Cmp -.-> Ref
    Lean["verification/lean (excluded): Lean 4 proofs against RFC 8878"] -. Charon/Aeneas translation .-> ECore
    Lean -.-> DCore
    Lean -.-> Shared

Public modules own configuration, ownership, sessions and high-level errors. Private core modules own algorithms and state machines. Their errors (CoreError, the decoder’s internal errors) never appear in public signatures. Each codec compiles on its own, with or without std.

Common boundaries

SpecificationScope
Codec featuresFeature gates, no_std, std-only modules, hotpath anchors and the checked configurations.

Compression

From the public Compressor down to the engine, match finders and entropy coding.

SpecificationScope
CompressorPublic encoder API, configuration lowering, one-shot calls, sessions, I/O adapters, errors and retention.
Encoder coreParameter resolution, engine lifecycle, the window model, the block pipeline, backend dispatch, match finders, LDM, entropy coding and output destinations.
Optimal parserbtopt/btultra/btultra2 and LDM: per-block SIMD dispatch, the shared binary-tree walk, match-length kernels, price tables.
Target compressed block sizeZSTD_c_targetCBlockSize: the superblock path that cuts a block into sub-blocks near a compressed size.
DictionariesRaw and Zstandard-format dictionaries on the encoder side: PreparedDictionary, parameter modes, attach / copy / load and entropy loading.
Parallel compressionFrame plans, overlap-primed segments, scoped batches, windowed streaming sessions with task queues, the parallel std::io adapters, errors and panic safety.
Encoder verificationByte-identity oracles, test suites and the overflow-correction build mode.

Decompression

The independent decoder, its fast paths and its output contracts.

SpecificationScope
DecompressorPublic decoder API, dictionaries, streaming state machine, history, block regeneration and compatibility with the reference decoder.
Decoder uninitialized outputprocess_uninit and decompress_to_uninit: the write-only sink, the one-shot BorrowedBuf, the C ABI and their contract.
Decoder fast pathsSIMD dispatch, bit containers, Huffman stream scheduling, FSE tables, sequence loops, wildcopies and measurements.
Decoder verificationDifferential, golden, corruption and API suites for the decoder.

Containers and integrations

The framed container format, the OpenZL-facing surface and the C ABI.

SpecificationScope
Framed containersThe framed wire format and the framed compressor: owner, sessions, structured input, std adapters, state and memory.
Framed decoderFramedDecompressor: detection, validation, events, dictionaries and budgets; the FramedSeekReader index and dependency plan.
OpenZL integrationThe zstd calls OpenZL’s codec makes and their mzstd counterparts: magicless frames, CParameter, standalone dictionaries and FrameHeaderInfo.
C ABImzstd-ffi: one-shot and session functions, handle lifecycle, validation, panic guard and status mapping.

Development tools

How the repository measures, tests, proves and publishes itself.

SpecificationScope
FuzzingAFL targets, input layout, corpora, regressions and campaigns, and the libFuzzer adapters ClusterFuzzLite builds.
Formal verificationCharon/Aeneas translation to Lean 4, the RFC 8878 transcription, function specifications, round trips, trusted base and the RFC coverage checklist.
CIWorkflows and the checks they run: fuzzing, Miri, AddressSanitizer and hotpath Cloud benchmarks.
BenchmarkingThe cold C-versus-mzstd comparison harness: corpus, preflight validation, Criterion sampling and the report and chart generators.
Documentation publishingSource staging, navigation, section pages, recipe execution, link validation and Pages deployment.

Known gaps

  • Scope. Both codecs take raw and Zstandard-format dictionaries (dictionaries, decompressor); the parallel compressor and the C ABI do not. Magicless frames are written by Compressor and read by the decoder (not by the parallel or framed compressors, nor the C ABI; OpenZL integration). There are no external sequence producers, and no legacy (v0.x) frames.
  • Speed. A Backend (fearless_simd level) is chosen once per codec and dispatched once per block. In the cold comparison against zstd 1.5.7 (benchmarks, Ryzen 9 7950X under KVM, AVX-512, 2026-11-09, host loaded by other tenants) compression runs at a median 1.248x the reference’s speed, faster in 174 of 192 dataset/level pairs, and decompression at 1.517x, faster in 151. These medians include empty and tiny inputs. The encoder still trails on Alice at ten levels (0.84-0.998x in that noisy run; 0.94-0.99x on 2026-10-30), and on the whole silesia.tar in lzbench at most levels; fast executes about 127 instructions per match against the reference’s 91 (encoder core). The decoder trails on compressible text (Alice 0.948x and cyclic Alice 0.937x in the median) and in lzbench at every level (0.92-0.96x of zstd): its four-stream Huffman loop executes about 242 instructions per round against the reference’s 221 (decoder fast paths).
  • Parallel frames. They decode anywhere, but they are not identical to the serial encoder’s frames or to ZSTDMT’s: segments are primed with their overlap the way ZSTDMT primes a job, but the overlap is capped at a quarter of a segment and LDM runs per segment.
  • Decoder memory. Streaming history may reach twice the window plus a block. Allocation follows actual output.

Common boundaries

SpecificationScope
Codec featuresFeature gates, no_std, std-only modules, hotpath anchors and the checked configurations.

Architecture overview and module map

Codec features

Cargo features select each codec and the standard library independently.

flowchart TD
    D[default] --> Std[std]
    D --> C[compression]
    D --> Dc[decompression]
    C --> T[thiserror]
    Dc --> T
    C --> FS[fearless_simd + fearless_simd_macros]
    Dc --> FS
    C --> BR["branches (prefetch feature): safe cache prefetch hints"]
    C --> BB["borrowed-buf: zeroed-ahead uninitialized output"]
    Dc --> BB
    Std --> TS[thiserror/std]
    Std --> BRS[branches/std]
    Std --> FSS[fearless_simd/std: runtime level detection]
    N[no_std] -.overrides.-> Std
    N --> FSL[fearless_simd/libm]
    H[hotpath] --> HP[hotpath crate: measurement anchors]
    HC[hotpath-cpu] --> H
    HA[hotpath-alloc] --> H
    HG[hotpath-callgrind] --> H
    HCl[hotpath-cloud] --> H
FeatureEffect
compressionmzstd::compressor and its root re-exports. Pulls in branches (with only its prefetch feature, plus std under std) for the safe prefetch_read_data hints of the row match finder and of the fast and dfast loops: inline assembly inside that crate on x86 with SSE and on aarch64, a no-op elsewhere, so mzstd itself stays forbid(unsafe_code). The row match finder’s NEON tag mask is a fearless_simd::kernel!: its intrinsics are safe calls in NEON’s feature context, and the one unsafe call that establishes that context is inside the macro’s audited expansion in fearless_simd, not in mzstd’s source.
decompressionmzstd::decompressor and its root re-exports, and mzstd::dictionary::{DecodeDictionary, DictionaryRef}. Pulls in borrowed-buf, through which decompress_to_uninit zeroes its destination just ahead of the decoder without unsafe, and branches, whose safe prefetch_write_data hint the literal loops issue for the next block’s output.
stdstd::error::Error for every error type, plus, unless no_std is also set, mzstd::io (reader and writer adapters, FinishError) and compressor::parallel.
no_std#![no_std] with alloc; it wins over std, so --all-features builds the alloc-only crate. One-shot calls, sessions, owned sessions and retention remain. Backend::default() is then the compile-time baseline level instead of the detected one.
framedmzstd::framing with either codec: its neutral types always; FramedCompressor and its sessions with compression; FramedDecompressor and its sessions with decompression. FramedWriter, ResourceWriter, FramedEncoderReader, FramedReader and FramedSeekReader also need std (not no_std). Enables no codec by itself and adds no dependency.
hotpath, hotpath-cpu, hotpath-alloc, hotpath-callgrind, hotpath-cloudTurn on the hotpath::measure anchors at stage boundaries (one-shot compression, frame chunks, sequence building, entropy coding, LDM, parallel segments, and the decoder’s frame, block, literal, Huffman and sequence stages). hotpath-cpu and hotpath-alloc add CPU and allocation metrics, hotpath-callgrind adds Valgrind Callgrind instruction counts per anchor (merged into a native report named by HOTPATH_MERGE_REPORT), and hotpath-cloud lets the report be uploaded to hotpath Cloud. examples/profile_compressor.rs (needs compression) carries #[hotpath::main]. Ignored with no_std, but the dependency stays in the graph, so bare-metal builds must omit every hotpath* feature.

With neither codec enabled, the crate exposes nothing; backend, retention, shared, dictionary (its content type, ID and error) and io exist only when at least one codec does. Either codec pulls in fearless_simd; development builds enable its force_support_fallback so unit tests can run the scalar level (Backend::SCALAR, test-only) on every host.

graph LR
    subgraph always[either codec]
        R[RetentionPolicy] --- S[shared::format, shared::xxhash]
        B[Backend] --- R
    end
    subgraph stdonly["std, not no_std"]
        IO[io: EncoderReader/Writer, DecoderReader/Writer, FinishError]
        P[compressor::parallel]
    end
    C[compressor] --> always
    Dc[decompressor] --> always
    C --> stdonly
    Dc --> IO
    Dc --> DD["dictionary::DecodeDictionary, DictionaryRef"]

shared::format items that only the decoder reads (skippable-frame magic numbers and sizes, the checksum size) are gated on decompression, so a compression-only build has no dead code.

Framed gates

flowchart TD
    F[framed + either codec] --> Neutral["framing: DictionaryId, DictionaryReference, MetadataKind, MetadataField, core wire constants"]
    F --> |compression| Enc["FramedCompressor, input, sessions"]
    F --> |decompression| Dec["FramedDecompressor, sessions, types"]
    Enc --> |std, not no_std| EncIo["FramedWriter, ResourceWriter, FramedEncoderReader"]
    Dec --> |std, not no_std| DecIo["FramedReader, FramedSeekReader"]

Checks

Clippy is clean with -D warnings for each of these configurations:

  • --all-features (alloc only), and the default features;
  • std,compression and std,decompression;
  • no_std,compression and no_std,decompression;
  • no codec at all;
  • std,compression,framed, std,decompression,framed, no_std,compression,framed and no_std,decompression,framed.

cargo doc is clean in each configuration under deny(rustdoc::broken_intra_doc_links). That works because the crate documentation’s codec sections, and their links, are attached with cfg_attr(feature = …, doc = …). Integration tests are gated by the codec they need and, for io and parallel, by not(no_std).

Compression

From the public Compressor down to the engine, match finders and entropy coding.

SpecificationScope
CompressorPublic encoder API, configuration lowering, one-shot calls, sessions, I/O adapters, errors and retention.
Encoder coreParameter resolution, engine lifecycle, the window model, the block pipeline, backend dispatch, match finders, LDM, entropy coding and output destinations.
Optimal parserbtopt/btultra/btultra2 and LDM: per-block SIMD dispatch, the shared binary-tree walk, match-length kernels, price tables.
Target compressed block sizeZSTD_c_targetCBlockSize: the superblock path that cuts a block into sub-blocks near a compressed size.
DictionariesRaw and Zstandard-format dictionaries on the encoder side: PreparedDictionary, parameter modes, attach / copy / load and entropy loading.
Parallel compressionFrame plans, overlap-primed segments, scoped batches, windowed streaming sessions with task queues, the parallel std::io adapters, errors and panic safety.
Encoder verificationByte-identity oracles, test suites and the overflow-correction build mode.

Architecture overview and module map

Compressor

The public compression layer: configuration, the Compressor owner, one-shot calls, incremental sessions, std::io adapters, errors and memory retention. Everything here is a thin shell over the private engine described in encoder core; the bytes it produces are decided there.

Modules and ownership

graph TD
    Root["mzstd (re-exports)"] --> Config[compressor::config]
    Root --> Encoder[compressor::encoder]
    Root --> Session[compressor::session]
    Root --> Error[compressor::error]
    Root --> Parameter[compressor::parameter: CParameter]
    Root --> IO["compressor::io (std)"]
    Root --> Parallel["compressor::parallel (std)"]
    Parameter --> Config
    Config --> Internal[compressor::internal: lowering]
    Internal --> Core["compressor::core (private)"]
    Encoder --> Core
    Session --> Core
    IO --> Session
    Parallel --> Core
    Encoder --> Retention[retention::RetentionPolicy]
ModuleOwns
configLevel, Strategy, Switch, DictionaryAttach, CompressionParams (including for_level, ZSTD_getCParams), LdmParams, LongDistanceMatching, EncoderConfig, Parameter, ConfigError, SizeOverflow, and validation against the reference’s bounds.
parameterCParameter: a (ZSTD_cParameter, value) pair validated as ZSTD_CCtx_setParameter does, applied with EncoderConfig::with_parameter; see OpenZL integration.
internalrequested_params: the single translation from EncoderConfig to the core’s RequestedParams (the reference’s ZSTD_CCtx_params).
dictionaryDictionaryBuilder, PreparedDictionary (re-exported from mzstd::dictionary); see dictionaries.
encoderCompressor, CompressorBuilder (with_retention, with_backend), one-shot calls (with and without a dictionary), start/into_session and their _with_dictionary forms, retention. The backend (default: the detected SIMD level) is stored in the Engine for the match finders.
sessionEncoderSession (optionally borrowing a dictionary), EncoderSessionOwned<D = PreparedDictionary> (optionally owning one; into_parts), StreamConfig, InputSize, Operation, EncoderStatus, Progress.
errorEncodeError and the mapping from the core’s private CoreError.
ioEncoderReader, EncoderWriter, EncoderReaderParts (re-exported from mzstd::io). EncoderReader::into_parts returns the source with the bytes read from it that the session had not accepted; ParallelEncoderReader::into_parts returns the same type.
parallelSee parallel compression.

Compressor owns a boxed core::cctx::Engine (match state, entropy tables, LDM state, sequence store) and a core::stream::Stream (the streaming input and output buffers). Sessions borrow the compressor mutably, or own it in the Owned variant, so one engine serves one frame at a time.

Types

classDiagram
    class EncoderConfig {
        level: Level
        params: CompressionParams
        ldm_switch: Switch
        ldm: LdmParams
        checksum: bool
        content_size: bool
        size_hint: Option~u32~
        format: FrameFormat
        dictionary_id: bool
        literal_compression: Switch
        dictionary_attach: DictionaryAttach
        force_max_window: bool
        row_match_finder: Switch
        post_block_splitter: Switch
        block_splitter_level: u8
        max_block_size: Option~u32~
        target_compressed_block_size: Option~NonZeroU32~
    }
    class CParameter {
        <<enum>>
        +try_from((i32, i32)) CParameter
        +check_memory_cap(u32) CParameter
    }
    CParameter --> EncoderConfig : with_parameter
    class Compressor {
        config: EncoderConfig
        requested: RequestedParams
        retention: RetentionPolicy
        backend: Backend
        engine: Box~Engine~
        stream: Stream
        +backend() Backend
        +compress(&[u8]) Vec~u8~
        +compress_into(&[u8], &mut Vec~u8~) Range
        +compress_to_slice(&[u8], &mut [u8]) usize
        +compress_to_uninit(&[u8], &mut [MaybeUninit~u8~]) usize
        +start(StreamConfig) EncoderSession
        +into_session(StreamConfig) EncoderSessionOwned
        +reconfigure(EncoderConfig)
        +trim(RetentionPolicy)
        +max_compressed_size(usize)$ usize
    }
    class EncoderSession {
        +process(&[u8], &mut [u8], Operation) Progress
        +process_uninit(...)
        +flush(...)
        +finish(...)
        +is_finished() bool
    }
    EncoderConfig --> Compressor : validated by build
    Compressor --> EncoderSession : start
    Compressor --> EncoderSessionOwned : into_session

EncoderConfig::default() is level 3 with content size on and checksum off, like the reference’s defaults; Compressor::default() uses it without validation because it has no explicit parameter. Explicit parameters are Options: None keeps the level’s value, exactly as an unset ZSTD_c_* parameter does. Every numeric with_* setter of CompressionParams and LdmParams stores 0 as None, as ZSTD_CCtx_setParameter(.., 0) resets a parameter to its default, so the last value given wins and a caller can apply a list of zstd-style parameters directly, one setter call each; CParameter does the same for numeric ZSTD_cParameter IDs, with the reference’s validation. Lowering maps None to 0, which the core already treated as “derive from the level”, so frames are unchanged.

One-shot compression

sequenceDiagram
    participant Caller
    participant C as Compressor
    participant E as core::compress_once_into
    participant Eng as Engine
    Caller->>C: compress_to_slice(input, dst)
    C->>E: requested params, input, dst
    E->>Eng: begin(resolve(input.len()))
    E->>Eng: compress_end(whole input as one segment)
    Eng-->>E: frame size or CoreError
    E-->>C: Result
    C->>C: apply_retention()
    C-->>Caller: size or EncodeError

The slice entry points use core::compress_once_into, which is ZSTD_compress2 with a stable input: the input is a segment of its own (Segment::Input(id), with a fresh id per call), so the engine matches directly in caller memory and never copies it. compress_to_uninit runs the same pipeline through core::compress_once_uninit, which writes into the caller’s MaybeUninit memory via core::output::UninitOutput: the destination is zeroed just ahead of each block instead of being copied from a temporary buffer (see encoder core). It succeeds, fails and writes exactly as compress_to_slice.

compress and compress_into use core::compress_once_append: they reserve compressBound capacity, but for inputs larger than 128 KiB initialize only the frame header and the room required by each next block. The shared engine pipeline writes through core::output::VecOutput, reusing the initialized tail between blocks. Highly compressible frames therefore avoid zeroing a destination as large as their input. Inputs up to 128 KiB keep the slice path. compress_into preserves the existing prefix and rolls the vector back on failure. Returned capacity and retention policy are unchanged.

A destination smaller than the frame fails with OutputTooSmall exactly when ZSTD_compress2 fails with dstSize_tooSmall; the reference needs room for the largest frame header and some slack, so an output exactly as long as the frame may be refused. The output_capacity fuzz target checks this.

Sessions

A session is one frame driven by process(input, output, operation), which is ZSTD_compressStream2 in buffered mode: the same sequence of inputs, output capacities and operations produces the same bytes.

stateDiagram-v2
    [*] --> Open: start / into_session
    Open --> Open: process(Continue) / Flush with output drained
    Open --> Open: Flush or End needing output (NeedsOutput)
    Open --> Finished: End with nothing left to write
    Open --> Failed: core error, or first End contradicting a declared size
    Finished --> Finished: further calls report Finished, consume nothing
    Failed --> Failed: further calls return InvalidState
    Finished --> Open: reinit (owned session)
    Failed --> Open: reinit (owned session)
  • InputSize::Exact(n) pledges the size (ZSTD_CCtx_setPledgedSrcSize): it is written in the header and a mismatch fails with InputSizeMismatch. InputSize::Unknown leaves the header without a content size unless the whole input arrives with the first End, as in the reference.
  • A first call that is already End deviates from the reference on purpose. ZSTD_compressStream2 replaces the pledged size with that call’s input length and writes a frame for it; the session instead fails with InputSizeMismatch before touching the stream, as it does for every later mismatch, so a truncated or overlong input never produces a frame. The check lives in session::Core; the core stream remains a faithful port. When the length matches, the bytes are the reference’s.
  • process_uninit is process over a [MaybeUninit<u8>] destination: the same generic Core::process and Stream::compress, with blocks written straight to the caller zeroed just ahead and buffered output copied in. Bytes, counts, statuses and errors equal process, and the two may be mixed on one frame; nothing is allocated per call. Past produced the output may hold zeros or the engine’s scratch bytes (a block it replaced with a raw or RLE block, for instance), exactly as a slice would; only the reported prefix is meaningful.
flowchart TD
    Process["Core::process(input, output: &mut D, op)"] --> St{state}
    St -- Failed --> Invalid[InvalidState]
    St -- Finished --> Done["Finished, nothing moved"]
    St -- Open --> First{"op == End, stream idle, declared != input.len()?"}
    First -- yes --> Mismatch["state = Failed, reset stream, InputSizeMismatch"]
    First -- no --> Stream["Stream::compress (D = [u8] or [MaybeUninit u8])"]
    Stream -- CoreError --> Failed["state = Failed, mapped EncodeError"]
    Stream -- step --> Status[NeedsInput / NeedsOutput / Finished]
  • flush and finish are process with Flush and End.
  • Dropping a session mid-frame leaves the compressor reusable: the next start resets the stream state.

EncoderStatus reports NeedsInput, NeedsOutput (internal output is still pending) or Finished. Progress carries the bytes consumed and produced by the call.

I/O adapters (std)

EncoderWriter wraps a Write sink: write feeds Continue, flush feeds Flush and drains, and finish/try_finish feed End, returning the sink. finish reports a failure as FinishError, which keeps the writer so the caller can retry or recover the sink. Compressed bytes stay in the writer’s buffer until the sink takes them. A write whose input the session has taken returns that count even when delivering the output then fails (drain_accepted), so the caller never offers those bytes again; the next write, flush or finish delivers the pending bytes first and reports the failure if it persists. A failure before any input is taken is returned as the error. EncoderReader wraps a Read source and yields the compressed frame; it drives the session with End once the source is exhausted. Both use internal buffers of one block. EncoderWriter pulls compressed bytes into an owned buffer of ZSTD_CStreamOutSize bytes (a full block’s compress bound plus a block header and a checksum). A session writes a block straight into the caller’s output whenever the room left there holds the block’s bound, so every full block lands in the writer’s buffer without passing through the session’s internal output buffer; only a frame’s end or a partly drained buffer falls back to it and the Flush stage. With 128 KiB, a few hundred bytes short of the bound, every block took that detour and its copy.

Errors

flowchart LR
    Core[CoreError, private] -->|from_core| Encode[EncodeError]
    Config[ConfigError] --> Build[Compressor::new / reconfigure]
    Encode --> Io["std::io::Error (adapters)"]
    Encode -->|OutputTooSmall| Caller
    Encode -->|InputSizeMismatch| Caller
    Encode -->|InvalidState| Caller
    Encode -->|DictionaryMismatch| Caller
    Encode -->|Internal| Caller

ConfigError::OutOfRange names the Parameter, the value and its bounds. EncodeError::Internal stands for a core failure that valid input cannot produce; it exists so such a bug is an error rather than a panic. EncodeError::Bound reports a compressBound overflow for inputs near usize::MAX. EncodeError::DictionaryMismatch reports a dictionary prepared for another configuration (frame options aside); it is checked before the engine is touched.

Retention

RetentionPolicy::Aggressive keeps every table and buffer between frames, Bounded(n) releases them when they exceed n bytes, and ReleaseAll releases them after each operation. trim applies a policy once. retained_bytes sums the engine’s tables and the stream buffers. Reuse never changes output: a reused engine’s frames equal a fresh one’s (checked by compressor_lifecycle and tests/reuse_probe.rs against the reference).

Known gaps

  • No external sequence producers and no multithreaded ZSTD_c_nbWorkers. targetCBlockSize is implemented, see target compressed block size.
  • Magicless frames (EncoderConfig::with_format) are written by Compressor only; the parallel and framed compressors refuse them.
  • The stable-buffer streaming modes (ZSTD_c_stableInBuffer, ZSTD_c_stableOutBuffer) are accepted by CParameter and ignored; sessions always behave as the reference’s buffered mode.
  • Streaming at the fast levels trails zstd on the stream/mixed-4MiB bench (level 1: 7.4 ms against 6.1 ms on the i7-13700KF before the branch-free segment select, about 2% less since) because of the extDict fast finder, which most blocks use once the stream buffer wraps: it executes about 1.6x the reference’s instructions (73.5M against about 45M per 4 MiB stream; stack spills and bounds checks in its search loop) and mispredicts 136K branches against 95K. The session, window and output layers cost under 0.02M instructions per 4 MiB call, at parity with the reference.

Encoder core

compressor::core is a port of zstd 1.5.7’s lib/compress restricted to what mzstd exposes: no multithreading, no external sequence producers. Dictionaries (cdict, the dictMatchState finders and the window’s dictionary segment) are described in dictionaries. Its one obligation is byte identity: for the same parameters, input and call sequence it writes the bytes the reference writes. Every function names its C counterpart in its doc comment, and control flow follows the C code closely, including heuristics that look arbitrary, because any deviation changes some frame.

Module map

graph TD
    Mod["core::mod: compress_once_into, compress_once_uninit, compress_once_append"] --> Cctx[cctx: Engine, params, frame chunks]
    Stream[stream: ZSTD_compressStream2] --> Cctx
    Stream --> Output
    Cctx --> Params[params: levels, bounds, adjust, resolve, CParamMode]
    Cctx --> CDict["cdict: CDict, insert_dictionary, load_dictionary_content"]
    CDict --> Read["entropy::read: FSE_readNCount, HUF_readCTable"]
    Cctx --> Window[window: Segment, Addr, WindowState]
    Cctx --> Block[block: buildSeqStore, entropy, block emit]
    Cctx --> Output["output: fixed slice, incrementally initialized Vec or zeroed-ahead uninit memory"]
    Cctx --> Presplit[presplit: pre-splitter]
    Block --> Split[split_block: post splitter]
    Block --> Stats["block_stats: ZSTD_buildBlockEntropyStats"]
    Split --> Stats
    Cctx --> Super["superblock: targetCBlockSize sub-blocks"]
    Super --> Block
    Super --> Stats
    Block --> MS[match_state: tables, opt state]
    MS --> LB["lazy_box: LazyBox, state allocated on first write"]
    Entropy --> LB
    Block --> Finders
    subgraph Finders[match finders]
        Fast[fast]
        DFast[double_fast]
        Lazy["lazy: hash chain, row, binary tree (+ lazy::row, lazy::count kernels)"]
        Opt["opt: btopt, btultra, btultra2"]
        OptCount["opt_count: match-length kernels"]
        Opt --> OptCount
    end
    Block --> Ldm[ldm + ldm_geartab]
    Ldm --> OptCount
    Block --> Seq[seq_store]
    Block --> Entropy
    subgraph Entropy[entropy]
        Bits[bits] --- Hist[hist]
        Fse[fse] --- Huf[huf]
        Lit[literals] --- Seqs[sequences]
    end
    Finders --> Mem["mem: Mem, Window views, hashes"]
    Window --> Mem
    Error[error: CoreError] -.-> Cctx

Parameters

flowchart TD
    Public[EncoderConfig] -->|internal::requested_params| Req[RequestedParams]
    Req -->|"resolve(pledged size)"| Applied[AppliedParams]
    subgraph resolve["RequestedParams::resolve (ZSTD_getCParamsFromCCtxParams and friends)"]
        Level["level_cparams: DEFAULT_CPARAMS[table][row]"] --> Over[apply explicit overrides]
        Over --> Adjust["adjust_cparams: shrink to source size / hint"]
        Adjust --> Auto["resolve automatic switches: row finder, block splitter, LDM, literal compression, max block size"]
    end
    Applied --> Engine[Engine::begin]

params holds the level tables from clevels.h (four tables by source size), the parameter bounds, ZSTD_adjustCParams_internal, the cycle log and the automatic switches. A size hint (ZSTD_c_srcSizeHint) and a pledged size feed table selection and window shrinking exactly as in the reference; a level of 0 means 3, negative levels use the fast row with target_length as the acceleration.

Engine lifecycle

cctx::Engine is ZSTD_CCtx without the streaming buffers.

stateDiagram-v2
    [*] --> Created
    Created --> Init: begin(applied, pledged)
    Init --> Ongoing: compress_continue writes the frame header
    Ongoing --> Ongoing: compress_continue (non-last chunks)
    Ongoing --> Ending: last block written
    Ongoing --> Created: compress_end writes epilogue
    Ending --> Created: compress_end writes checksum
    Init --> Created: compress_end on empty input

The frame header is written by the free function cctx::write_frame_header from a FrameHeaderFields value (window log, dictionary ID, checksum and content-size flags, pledged size) that the engine’s method fills in, and block headers by write_block_header; block::write_sequence_count writes a block’s Number_of_Sequences. The three are small and free of SIMD so that the formal verification can translate them and check them against RFC 8878.

begin is ZSTD_resetCCtx_internal: it derives the block size from the window and the pledged size, resets indices on first use or when they come within 16 MiB of ZSTD_CURRENT_MAX (ZSTD_indexTooCloseToMax), sizes the match state’s tables, and resets the entropy tables, the repeat offsets, the checksum and the LDM state.

Table sizing follows the reference’s workspace policy for every finder:

flowchart TD
    Reset["MatchState::reset(cparams, row, reset_index)"] --> Q{"reset_index?"}
    Q -->|yes| Clean["clear, then size: every entry zero"]
    Q -->|no| Keep["keep entries, size: only new entries zero (ZSTD_cwksp_clean_tables)"]
    Clean --> Grow["grow_zeroed: an empty table is allocated zeroed, a used one resized in place"]
    Keep --> Grow

When indices continue, every entry left from the previous input lies below the new window. The fast and dfast finders test each entry against the window before reading it. The hash chain and row searches stop at the first candidate below lowLimit, and the binary trees (lazy and optimal) and the 3-byte table stop at their low bounds; inserting a tree node rewrites both of its cells. A stale entry therefore acts as the zero it replaces. Keeping the tables changes no output and saves zeroing them on every call. The row finder’s tag table is likewise never re-zeroed while indices continue (the reference’s init_once tags). Kept row tables are searched through a view one 64-entry step further on at every reset (MatchState::row_view, eight views in turn, back to the first when tables are cleared), so the entries an input of similar content left behind lie in rows of unrelated hashes (tables of at least 2^14 entries) and match the new input’s tags by chance only. Without it, on repeated Alice frames every search found and dismissed the previous frame’s twins: five times the stale candidates, ten times the page walks of speculatively prefetched ones, and 1-6% of the row levels’ time. The reference keeps them apart by salting its hash at every reset (ZSTD_advanceHashSalt); a salt costs an XOR per hash, which measured 1.5-5% slower on cold row searches, and a view costs nothing per position. Like the salt, the view changes no frame. A copied dictionary’s rows are written through the context’s current view.

stateDiagram-v2
    [*] --> View0: tables allocated (zero)
    View0 --> ViewNext: reset, indices continue (tables kept)
    ViewNext --> ViewNext: reset, indices continue: view + 64 entries mod 8 steps
    ViewNext --> View0: reset with fresh indices or for a dictionary (tables cleared)
    View0 --> View0: reset with fresh indices
    note right of ViewNext
        RowState::new and copy_dictionary_tables
        read and write rows from the 64-byte
        boundary plus row_view
    end note
``` The engine's two block states (`prev`, `next`) are boxed, so
confirming a block swaps two pointers as the reference does.

Their tables are not allocated with them. `Tables` keeps the Huffman table
and the three FSE tables in `LazyBox`es (`core::lazy_box`): the Huffman
table alone, the sequence tables together (`FseCTables`, which `FseTables`
dereferences to). A box is allocated, zeroed, on its first mutable access;
before that it reads as one shared static all-zero table, the value a
zeroed table holds. A table is read only after its repeat mode says a block
wrote it, so empty or tiny inputs, raw literals and blocks without
sequences allocate nothing, as the reference leaves these tables
uninitialized in its workspace. `clone_from` copies in place when both sides
are allocated, so `next.clone_from(prev)` per block never reallocates, and
`huf::compress` stores a fresh table with `LazyBox::set`, without zeroing
it first. The optimal parser's `OptState` (its price tables) is a `LazyBox`
in `MatchState` too, allocated only by btopt and above; `reset` and
`build_seq_store` write it only when it exists. `Engine::with_backend` is
the engine's one constructor.

```mermaid
stateDiagram-v2
    [*] --> Unallocated: Engine::with_backend
    Unallocated --> Unallocated: read (static empty table)
    Unallocated --> Allocated: first write (DerefMut, set) or clone_from(allocated)
    Allocated --> Allocated: build / set / clone_from(allocated), in place
    Allocated --> Unallocated: clone_from(unallocated)

compress_continue is ZSTD_compressContinue_internal: it writes the header once, updates the window, then compress_frame_chunk cuts the input into blocks (optionally with the pre-splitter’s block sizes), runs overflow correction and enforce_max_dist before each block, and emits each block through block::compress_block.

Addresses and the window

The reference compares raw pointers into up to three regions: its own input buffer, the caller’s input and the static placeholder a fresh window starts with. Safe Rust cannot, so window models them:

classDiagram
    class Segment {
        <<enum>>
        Initial
        Buffer
        Input(id)
    }
    class Addr {
        segment: Segment
        offset: i64
    }
    class WindowState {
        base: Addr
        dict_base: Addr
        dict_limit: u32
        low_limit: u32
        next_src: Addr
        update(src, len) bool
        enforce_max_dist(...)
        need_overflow_correction(...)
        correct_overflow(...) u32
        view(segments, block_end) Window
    }
    class Segments {
        buffer: &[u8]
        input: (&[u8], id)
    }
    WindowState --> Addr
    Addr --> Segment
    WindowState ..> Segments : view

An Addr is a segment plus an offset that may lie outside the segment, as a base pointer does. Indices (u32, as in C) are offsets from base; the prefix is [dict_limit, end) relative to base, the extDict [low_limit, dict_limit) relative to dict_base. view resolves both to borrowed slices (mem::Window), whose Mem accessors take the reference’s indices directly, so match finders read prefix.read32(index) where C reads MEM_read32(base + index).

Window updates reproduce ZSTD_window_update: input contiguous with the previous chunk (same segment, next offset) extends the prefix; anything else turns the old prefix into the extDict and starts a new prefix. Overflow correction (ZSTD_window_correctOverflow) rebases indices and MatchState::reduce_index subtracts the correction from every table entry. OVERFLOW_CORRECT_FREQUENTLY, set at build time by MZSTD_OVERFLOW_CORRECT_FREQUENTLY, makes it run as early as the reference built with ZSTD_WINDOW_OVERFLOW_CORRECT_FREQUENTLY (zstd-ffi’s build script reads the same variable), so the rarely reached path is testable.

Block pipeline

sequenceDiagram
    participant FC as compress_frame_chunk
    participant B as block::compress_block
    participant BS as build_seq_store
    participant MF as match finder
    participant L as ldm
    participant S as split_block
    participant EC as entropy (literals, sequences)
    FC->>B: block [start, end), last?
    B->>BS: run match finding
    alt LDM enabled
        BS->>L: generate_sequences over the block
        L->>MF: block_compress: LDM matches with the finder in between
    else
        BS->>MF: compressor(ms, seq_store, rep, block)
    end
    MF-->>BS: sequences + last literals
    alt target compressed block size set
        FC->>FC: superblock::compress_block instead of B (see target-block-size)
    else post splitter enabled
        B->>S: split sequences into partitions, entropy-cost each
        S->>EC: compress each partition, repeat offsets fixed up
    else
        B->>EC: entropy_compress(seq_store)
    end
    EC-->>B: compressed size, or 0 for "store raw"
    B->>B: RLE block if all bytes equal, raw block if not smaller
    B->>B: confirm entropy and repeat state (prev <- next)
  • block::select_block_compressor is ZSTD_selectBlockCompressor: the strategy, whether the row finder is in use, and the dictionary mode (DictMode: noDict, extDict, or dictMatchState while a dictionary is attached; ZSTD_matchState_dictMode) pick one function pointer, chosen once per block outside the finder’s inner loop. Before each block check_dict_validity and enforce_max_dist may detach the dictionary.
  • seq_store holds sequences in the reference’s SeqDef form (offBase, a 16-bit literal length and match length with one “long length” escape). fill_codes(backend) is one pass, dispatched once per block, that turns them into codes: one four-byte SeqCodes per sequence (the three FSE codes and the sequence’s extra-bit count) and, in the same loop, the three code histograms (CodeCounts) that ZSTD_buildSequencesStatistics counts with three HIST_countFast_wksp passes. The long-length escape is patched afterwards for its one sequence, counts included. Small lengths use packed 16-bit code/width lookup rows; larger stored lengths derive both values from the same high bit. Adding the packed LL and ML rows cannot carry from the code byte into the width byte (35 + 52 < 256), so one shift obtains their combined extra-bit width. Exhaustive tests cover every stored 16-bit length and every code combination. The original ll_code and ml_code RFC functions remain unchanged. The codes buffer only grows and every entry below nb_seq is written before it is read. It is kept at four bytes a sequence on purpose: a 16-byte record holding precomputed extra bits pushed a cold one-shot call’s peak heap past glibc’s dynamic trim threshold, so every call returned and re-faulted about 2.7 MB (text-1m level 3 went from 0.69 ms to 1.03 ms).
  • Dense full blocks in the fast prefix parser can switch from SeqStore vector appends to a SeqCursor over the store’s kept full-block destinations (FullBlock: a boxed [SeqDef; 32768] and [u8; 128 KiB + 32]), allocated and zeroed on the store’s first promotion and reused by every later one. Until a store holds a FullBlock, a full block starts with the original vector writer. After 1024 sequences, at the end of a complete match/repeat-code chain, at least 64 KiB of remaining input selects the cursor; otherwise the vector loop resumes. This keeps the initialization cost away from sparse random and long-run inputs. The continuation preserves the hash table, current position and raw repeat offsets; it changes neither search order nor match decisions. Promotion copies the entries the vectors already hold into the destinations. Once the store holds a FullBlock, every later full block starts on the cursor (SeqStore::has_full_block): starting costs nothing then, and the block writes no vector entries and copies none at a promotion (silesia.tar through mzstd_compress, level 1: 601 to 618 MB/s against zstd’s 640-643; dickens, 2 MiB, levels -1 and 1: 2.5-4% fewer cycles per call, 2026-11-08). The cursor owns local counts, and on return the block’s entries stay there: sequences(), literals(), sequences_mut() and nb_seq() read them until reset, and store_last_literals appends after them. Before, the vectors were resized to a full block and truncated again around every promotion, zeroing 384 KiB per dense block: on Silesia nci level -1 that was 2.3% of the time, and the call ran 4.5% behind the main branch (2026-10-30); with the kept destinations it runs 1-2.5% ahead of it, and cyclic Alice with a reused context gains 2.4-2.9% at levels -5 to 2. The vector writer is unchanged: making the vectors themselves keep their length cost the dfast and lazy levels 1.5-3.9%. At most 32768 matches of at least four bytes fit in 128 KiB; masks on the sequence and literal indices are therefore identity while proving the fixed-size writes in bounds. A run of at most 32 literals is copied as one 32-byte chunk (CURSOR_WILD, the destination’s slack), where the reference copies 16 bytes and wildcopies the rest; a longer run, or one within 32 bytes of the input’s end, is copied exactly (a memcpy call, which runs of 17-32 bytes used to take). Dictionary entry points and partial blocks keep the vector path. The promoted loop has its own #[simd] entry so the callback retains the selected target features, and scans with a local reborrow of the cursor (SeqCursor::reborrow), whose address never escapes: the compiler then keeps its counts in registers, where through the caller’s cursor it stored both on every sequence (2026-10-29: 3 instructions per sequence on Silesia dickens). Exceptional lengths share one cold helper; the common path tests both lengths with one bitwise OR.
flowchart TD
    Start[fast prefix block] --> Full{128 KiB and min match at least 4?}
    Full -->|no| Vec[vector writer]
    Full -->|yes| Has{store has a FullBlock?}
    Has -->|yes| Direct[whole block on SeqCursor over it]
    Direct --> Counts
    Has -->|no| Probe[vector writer through 1024 sequences]
    Probe --> Boundary[finish match, hash insertions and repeat chain]
    Boundary --> Left{at least 64 KiB left?}
    Left -->|no| Vec
    Left -->|yes| Alloc[allocate and zero it, once per store]
    Alloc --> Copy[copy the vectors' entries into it]
    Copy --> Cursor[resume with SeqCursor over it]
    Cursor --> Counts[block's counts kept beside it until reset]
    Vec --> End[unchanged entropy coding]
    Counts --> End

The lazy-family no-dictionary loop uses block-relative usize positions for its cursor, anchor, repeat checks and match counting. One source slice ends at the logical block end; conversion to global window indices happens at search-table boundaries. Dictionary variants keep their separate window adapters. Lookahead depths, cache updates and parsing heuristics are unchanged.

  • entropy is the reference’s FSE and Huffman encoders: histogram counting, normalization, table building, the cost estimates used to choose between predefined, repeat, RLE and new tables, and the backward bit streams.
    • hist::count counts inputs below 1500 bytes straight into the caller’s table (HIST_count_simple) and larger ones with four interleaved tables (HIST_count_parallel_wksp). It counts literals (and the optimal parser’s and FSE weights’ inputs); sequence code histograms come from fill_codes, read through code_histogram, which reports the largest code and count as HIST_countFast_wksp does.
    • fse::CTable::build takes the reference’s branch-light spread when no symbol has the low-probability count, and sizes its scratch to the largest table it holds. Its state-table and spread loops mask indices (symbols below 64, positions below 512), which removes their bounds checks without changing any index.
    • BitWriter and the Huffman writer store each flushed word at min(pos, end) and leave pos unclamped; close reports “does not fit” when pos passed end, the reference’s clamp result, while the store needs no bounds check.
    • huf packs each code with its length (HUF_CElt, built per call, branch-free over the whole alphabet in the backend’s feature context; symbols the table does not describe pack to values never read) and encodes with the reference’s two-container, unrolled loop, whose shape depends on the table log and on whether the destination holds the tight bound; every shape writes the bits the one-symbol-at-a-time writer writes (tested against it). Optimal-depth selection sorts and builds the unlimited tree once. Each subsequent depth trial restores leaf lengths from the unchanged internal-node heights before applying the reference’s height limit; table pricing, tie order and the selected depth are unchanged. sort (HUF_sort) places only symbols that occur: the reference puts absent ones in the last bucket, so the others’ positions do not change, and their nodes keep the zero count and length of the cleared tree. fill_from_tree therefore clears the alphabet’s lengths and writes those of the non-null ranks rather than reading every leaf. One array replaces the reference’s start and cursor per bucket: after placement it holds each bucket’s end, which is the next lower bucket’s start, so bucket n - 1 spans rank[n + 1]..rank[n]. The buckets from count 164 up are sorted by HUF_simpleQuickSort without recursion: the smaller side of each partition waits on a fixed stack of at most 28 byte-sized ranges, and short runs go through the reference’s stable insertion sort, written out rather than slice::sort_by_key, which would link a whole stable sort for runs of at most nine nodes. Order among equal counts is the reference’s; tests compare sort with a verbatim port of HUF_sort and the quicksort with the recursive one.
    • sequences::encode walks codes and sequences backwards together. Each sequence’s extra bits are assembled into one word (literal length, match length, offset, from the least significant bit; SeqCodes::extra) and added with one write when they number fewer than 31, the reference’s own bound; otherwise the word is flushed in two parts. Flushes only move whole bytes out of the container, so where they fall does not change the stream; fused_encoding_matches_the_reference_loop checks every backend against a step-by-step port of ZSTD_encodeSequences_body.
  • literals::try_compress reports a raw-literals fallback without copying it. If those raw bytes alone reach block_size - min_gain, entropy coding returns the raw-block decision immediately. This shortcut requires a destination large enough for the raw block; smaller caller-owned buffers follow the reference’s original capacity/error path. A block with no sequences can still use Huffman literals, so the Huffman attempt is kept.
  • presplit (ZSTD_splitBlock, by fingerprint distance) chooses block sizes before match finding for the strategies that enable it; split_block (ZSTD_compressBlock_splitBlock) splits the sequences of a finished block for the ultra levels. superblock (ZSTD_compressBlock_targetCBlockSize) replaces both block paths when a target compressed block size is set (see target compressed block size); the entropy statistics it shares with split_block live in block_stats. presplit::split_block dispatches once per eligible full block to the encoder’s selected backend. The splitter body stays out of line so the dispatcher does not retain a second fingerprint stack frame; the frame is released before match finding. split_by_chunks selects the instance compiled for the level’s sampling rate and hash log (43/8, 11/9, 5/10, 1/10), as ZSTD_GEN_RECORD_FINGERPRINT does: a constant stride and hash indices that provably fit the table. Fingerprints hold their fixed 1024-entry event arrays on the stack; chunk sampling needs two, and border sampling at most three. Recording, comparison and accumulation touch only the active 256, 512 or 1024 entries, with no fingerprint heap allocation. fpDistance uses signed 32-bit products and an unsigned 32-bit sum: before each comparison at most fifteen 8191-event chunks have accumulated, and the incoming chunk has at most 8191 events. Each product is below 2^30 and the sum of absolute differences is below 2^31. Border samples have only 512 events. These bounds preserve the reference’s exact distance while allowing native integer SIMD arithmetic. The scalar and feature-enabled paths keep the reference’s sample-count rounding, thresholds and split positions; tests compare every backend with a plain reference loop and cover the largest chunk products.
  • After a block, prev and next swap (confirm_block_state) unless the block was stored raw or RLE, which keeps the previous entropy tables and demotes repeat-mode state as the reference does.
flowchart LR
    PRE[eligible full 128 KiB block] --> PB["presplit: backend dispatch"]
    PB --> FP["stack fingerprints: sample, 32-bit distance, merge"]
    FP --> BC["choose block boundary"]
    BC --> MF
    MF[match finder] --> SS["SeqStore: vectors, or a promoted block's FullBlock"]
    SS --> FC["fill_codes(backend): codes + extra-bit counts + CodeCounts, one pass"]
    FC --> ST["build_statistics: code_histogram -> select_encoding_type / build_ctable"]
    FC --> EN["sequences::encode: codes + sequences -> extra-bit word, FSE states"]
    ST --> EN
    SS --> LIT["literals: hist::count, Huffman"]
    FC --> SB["split_block: extra bits from histograms x extra bits per code"]

Huffman table construction (huf::CTable::build; optimal_table_log runs the same steps once and then only the height limit per depth):

flowchart TD
    H["literal histogram"] --> C["count present symbols per bucket (exact below 165, log2 above)"]
    C --> P["suffix sums: rank[r] = start of bucket r - 1"]
    P --> L["place present symbols; rank[r] advances to the bucket's end"]
    L --> Q{"bucket from count 164 up with 2+ nodes?"}
    Q -->|yes| QS["quick_sort: Lomuto partitions, smaller side on a 28-entry stack, runs of 9 or fewer by insertion"]
    Q -->|no| T
    QS --> T["build_tree: unlimited lengths"]
    T --> M["set_max_height: limit to the table log"]
    M --> F["fill_from_tree: clear lengths, write present ranks, canonical codes"]

Backend dispatch

The engine carries the compressor’s Backend (a fearless_simd::Level chosen once when the compressor is built); Engine::with_backend stores it in MatchState::backend for the match finders. Dispatch happens once per block at the top of each kernel, never inside a loop:

sequenceDiagram
    participant B as block::compress_block_internal
    participant F as fast / double_fast::compress_block[_ext_dict]
    participant K as compress_block[_ext_dict]_mls::<S, MLS>
    participant E as entropy_compress_internal
    participant H as huf::compress_1x / compress_4x
    participant Q as sequences::encode
    B->>F: ms (backend), seq store, rep, block
    F->>K: dispatch!(backend) then match min_match (4..=7)
    K-->>B: sequences, last literals
    B->>E: engine.backend
    E->>H: LiteralsSettings.backend -> Options.backend, dispatch!
    E->>Q: dispatch!(backend)

The kernels are #[simd] functions generic over the token, so their whole body is compiled with the level’s target features (BMI2, LZCNT and so on on x86-64; the reference’s BMI2 builds of the Huffman and sequence encoders are the same idea). They use no vector types today. The scalar fallback is Backend::SCALAR in the crate’s tests, and every backend writes the same bytes; core::tests and core::stream_tests compare every backend of Backend::available() with the reference for levels -5..4, every minimum match length, and the extDict variants.

Match finders

StrategyFinderTablesextDict variantdictMatchState variant
fastfasthashyescompress_block_dict_match_state (tagged dictionary table)
dfastdouble_fastlong hash, short hashyescompress_block_dict_match_state (tagged dictionary tables)
greedy, lazy, lazy2lazy hash chain, or row finder for windows above 16 KiBhash, chain or tag rowsyesgreedy_dms .. lazy2_row_dms
btlazy2lazy with the DUBT binary treehash, treeyesbtlazy2_dms (dictionary tree sorted by ZSTD_updateTree)
btopt, btultra, btultra2opt with the binary tree and price modelhash, tree, hash3, opt arraysyesbtopt_dms, btultra_dms (also btultra2’s)

The dictMatchState variants run while a dictionary is attached (dictionaries): the block’s own tables are searched as in the noDict variant, then the dictionary’s tables, read in place through BlockInput::dms and translated to window indices by dictIndexDelta. Each is a separate monomorphization (fast and dfast as separate kernels, lazy_dms_generic and the optimal parser’s DMS const parameter), so the noDict and extDict code is unchanged; a dms entry reached without a dictionary falls back to the noDict compressor. Ported details that differ from the noDict kernels: fast’s step starts at targetLength + !targetLength and grows every 256 bytes, a dictionary candidate is tried when the context’s index is at or below the prefix start, dfast tries the dictionary’s short table when its short candidate is not strictly inside the prefix, the dictionary’s tagged tables are hashed with the context’s minimum match and the dictionary’s hash log plus 8, and the lazy parser’s repeat-offset checks may read the dictionary (ZSTD_index_overlap_check) without the saved-offset rotation.

fast and dfast are ported loop for loop but specialized the way the reference’s templates are:

  • one instance per minimum match length (ZSTD_GEN_FAST_FN, ZSTD_GEN_DFAST_FN), so the hash width is a constant (mem::hash_word, mem::hash_at);
  • compress_block_mls handles the repeat-offset setup and the saved-offset restore, then calls compress_scan with a small Scan (dfast also a Tables { long, small }), which returns the trailing literals and the two repeat offsets;
  • fast compiles in the hash widths of the default parameters for inputs above 256 KiB with their match lengths: compress_scan::<S, MLS, IN_PREFIX, HLOG, BRANCHY> has instances for (MLS, hashLog) = (6, 13) (negative levels, and level 1 up to 128 KiB), (7, 14) (level 1) and (6, 16) (level 2). The hash shift becomes an immediate and the table mask a constant, which frees two of the loop’s registers; one test of the table’s length before the loop proves the masked indices in bounds. Other parameters take the instance with the width read at run time (0). The defaults for inputs up to 128 and 256 KiB, (5, 12), (5, 13) and (6, 14), also compile in the window index of the scan’s first byte (ORIGIN, the window’s first index) and are taken only when the prefix starts the window (one-shot frames and a fresh context’s first frame), with the select alone; any other prefix runs the generic instance. Only the prefix loop is specialized;
  • dfast compiles in the widths of its defaults too, as (MLS, hashLog, chainLog) = (5, 14, 14) (level 2 up to 256 KiB), (4, 16, 16) (level 3 up to 256 KiB), (5, 17, 16) and (5, 18, 18) (levels 3 and 4 above 256 KiB), (5, 16, 15) (level 3 up to 128 KiB) and (4, 17, 16) (level 4 on 64 KiB). Each set is its own out-of-line #[simd] function (compress_scan_widths), called once per block: inlined side by side into compress_block_mls, LLVM merged the instances back into one loop that loads the widths at run time. The tables are cut to 1 << hashLog entries first, so the masks are constants. Other parameters run the inlined compress_scan::<.., 0, 0>;
  • IN_PREFIX holds when the block starts in the prefix and both repeat offsets reach no further back than the prefix start from the block’s first position. Every later repeat offset is then ip - match with the match in the prefix, so no read can go below the prefix: positions become offsets from the prefix start and the search loop drops the window base, the history pointer and its length. Otherwise the base-relative loop runs (IN_PREFIX = false);
  • dfast, and fast while its table is still filling, test candidates without a branch on their validity (the reference’s ZSTD_match4Found_cmov / ZSTD_selectAddr). While the table fills, stale and valid entries are about equally likely, which made the validity branch the finder’s main source of mispredictions. fast clamps the candidate’s offset into the history (min(offset, len - 4)), so its bytes load without a branch, and the candidate matches only when its bytes equal the probed word and the clamp left the offset unchanged. Both conditions are folded into one value, (bytes ^ word) | (offset ^ clamped), and tested once: written as two comparisons joined with &, LLVM turned the range test back into a branch on x86, which mispredicts as often as the validity branch the clamp replaced. dfast, and the dictionary variants, select between the candidate and a stand-in word picked by the probed word’s low bit (mem::matches32 / matches64, DUMMIES), which can never equal it. In fast the clamp replaced that select, whose stand-in address and low-bit choice were rebuilt at every position; in dfast it measured faster on single small blocks but slower on multi-block inputs (see the known gaps);
  • dfast’s prefix and base-relative loops take a BRANCHY parameter, chosen per block by takes_branches(windowLog, hashLog): when the window spans at least sixteen times the long table (windowLog >= hashLog + 4, level 3 above 1 MiB), a slot goes stale only if nothing overwrites it for a whole window, so candidates are tested with branches (mem::matches64_branchy, matches32_branchy), which then predict well; otherwise (level 4, small windows) the selects above remain. This is the choice ClickHouse’s zstd fork makes (useCmov = windowLog < hashLog + 4). Both forms accept the same candidates, and the strict lower limit of the second long candidate at ip + 1 is kept, so output is unchanged in one-shot and streaming compression. silesia.tar at level 3 (one pinned vCPU of a Ryzen 9 7950X VDS, native build, 2026-11-09): 8582M to 8085M instructions per compression (zstd 1.5.7: 6489M); level 4 unchanged at 8413-8415M;
  • like the reference, fast and dfast prefetch the input at ip1 + 64 and ip1 + 128 whenever the step grows (mem::prefetch_l1, through the branches crate). The reference’s per-position ip + 256 on aarch64 is not ported: it measured 1-4% slower on the M5;
  • dfast’s loops call mem::count_at_simd, whose first-word test is always inlined; the 128-byte scan behind it stays out of line. Its repeat offsets are usize inside the scan, which saves LLVM a second copy of the first one. Its hash slots come from Slots::{long, small} (always inlined, widths as const generics): closures that LLVM left out of line reloaded the compiled-in widths from memory;
  • after a match, dfast’s prefix loop computes the forward end before the backward extension and loads the word there with its long-table slot (Slots::ahead), prefetching both candidates when the widths are compiled in; the catch-up loop and the sequence store follow, and the next search starts from the carried word and slot (the slot is read again after the complementary insertions, so their writes are seen). The load succeeding is the reference’s ip <= ilimit test, also after each immediate repcode. The kernel is bound by the latency after each mispredicted match: this took its work off the next search’s load chain and made text 2-3% faster with 2% more instructions;
  • fast’s prefix loop switches to branches (BRANCHY, mem::matches32_branchy: is the word inside the history, then does it equal the probed word) for a block whose history before it is at least four times the table’s size (4 << hashLog bytes). Its entries are then almost all inside the window and rarely match, so both branches predict well, and the select’s stand-in costs more than they do. On the first 20 MB of silesia.tar this made levels -5, -1 and 1 8-10% faster; on Alice (152 KB) the branches alone were 4-7% slower, the per-block choice 0-3% faster;
  • the loops hold &Cell<u32> references to the hash slots they will write, which removes the bounds check on a table index carried across iterations;
  • the exit tests share their bounds checks with the loads they guard: fast fetches the window at ip2 with one 10-byte range check that is also its ip2 + 1 < ilimit test (mem::probe_window, probe_pair), dfast’s 8-byte load at ip1 (mem::try_load64) is its ip1 <= ilimit test;
  • after a match, fast extends backwards with the reference’s loop over single bytes (it rarely takes a step; cutting two slices first cost four bounds tests per match), compares the first word of the forward length inline with one bounds test (mem::count_behind_simd: the match lies behind the searched position, so its word is inside the input whenever the position’s is), and reads ip0 - 2 once for both the hash insert and the first repcode compare;
  • the extDict variants use the same prefix offsets, reading only candidates below the prefix from the external segment by window index. fast’s (compress_block_ext_dict_mls, ZSTD_compressBlock_fast_extDict_generic) addresses a candidate through locate, which picks without a branch (select_unpredictable, the reference’s idxBase select) the slice holding it: the external segment from dict_start_index up to the prefix, or src from base, and adds that slice’s origin as a masked difference (selected together with the slice, LLVM turned the origin back into a branch). segment_matches clamps the offset to the slice’s last whole word and folds the range test into the word comparison, so a stale entry below dict_start_index wraps to an out-of-range offset and is rejected, as the reference’s idx >= dictStartIndex test rejects it. Offset matches extend backwards inside the selected slice, and matches starting in the external segment are counted out of line (count_from_ext, ZSTD_count_2segments). An external segment shorter than one word never matches;
  • literals go to the sequence store through SeqStore::store_seq_from, which copies a run of at most 16 bytes as one fixed chunk (ZSTD_copy16) and cuts the excess. fast’s and dfast’s prefix loops use a local store_seq_wild instead, which appends the 16-byte chunk straight from the input without the stack copy and overflow test; the same body in store_seq_from made fast 2.3% faster but dfast 1.3-1.6% slower, so the shared store keeps its form.

dfast’s match tail:

sequenceDiagram
    participant S as search loop
    participant M as match tail
    participant T as tables / seq store
    S->>M: match found (start, forward length)
    M->>T: ahead(end): word, long slot, prefetch both candidates
    M->>M: catch-up (backward extension)
    M->>T: offsets, store_seq_wild
    M->>T: complementary insertions (curr + 2, end - 2, end - 1)
    loop immediate repcode while the word matches ip - offset_2
        M->>T: store_seq_only, slots at ip, try_load64(new ip)
    end
    M->>S: word and long slot of the next position (none past ilimit)
flowchart TD
    A[compress_block_mls: rep offsets, saved offsets] --> B{block in prefix and reps within it?}
    B -- yes --> W{"default (MLS, hashLog[, chainLog])?"}
    W -- yes --> CW["fast: compress_scan with constant widths (and ORIGIN when the prefix starts the window); dfast: compress_scan_widths, out of line"]
    W -- no --> CR["compress_scan with run-time widths"]
    CW --> Y{"fast: history >= 4 << hashLog?"}
    CR --> Y
    Y -- yes --> C1["BRANCHY: validity branch, then compare"]
    Y -- "no, or dfast" --> C2["fast: offset clamped into the history, match only if unclamped; dfast: stand-in word if invalid"]
    B -- no --> D["compress_scan::<.., false, ..>: offsets from the window base, clamp or stand-in"]
    C1 --> E[probe: hash slots as Cell refs, window load = exit test]
    C2 --> E
    D --> E
    E --> F{"candidate word == probed word?"}
    F -- no --> E
    F -- yes --> G["extend back, count forward, store_seq_from (fast: store_seq_wild), insert hashes, repcode loop"]
    G --> E
    E -- window load fails --> H[trailing literals, rep offsets]

Match lengths (mem::count_slices, behind Mem::count/count_with) compare the first word inline with an XOR and a trailing-zero count. Out of line, count_long compares words up to 32 bytes, where most matches end, then skips equal 32-byte chunks (their XORs OR-ed in vector registers), then finds the difference a word at a time, finishing with one word that ends at the limit. Run-length inputs, whose matches span whole blocks, spend their time there. Fast/dfast and lazy search use mem::count_slices_simd in the already-selected backend: the same short-word path, then 128-byte chunks and a 32-byte cleanup loop. The XOR/OR reduction auto-vectorizes to four AVX2 registers and one vptest per 128 bytes. The first differing chunk is resolved in byte order with words; every load stays inside both windows. The feature-entry wrapper is inlined, while the scan stays out of the candidate loop.

block::is_rle (ZSTD_isRLE) decides whether a compressed block is re-emitted as an RLE block. It compares src[1..] with src[..src.len() - 1]: equal adjacent bytes imply a constant run. Slice equality delegates the bulk scan to the platform comparison implementation.

Lazy finders and SIMD dispatch

lazy holds one parser (lazy_generic, and lazy_ext_generic for an extDict window) driving three searches. It is the only finder that uses the engine’s Backend so far. The engine copies its backend into MatchState::backend before each block (block::build_seq_store), and each block compressor (lazy::greedy_row, lazy::btlazy2_ext, …) runs fearless_simd::dispatch! once on it. Everything below that dispatch is generic over the S: Simd token and runs with that level’s target features: the dispatch happens once per block, never per position.

sequenceDiagram
    participant B as block::build_seq_store
    participant E as lazy entry (e.g. lazy2_row)
    participant P as lazy_generic::<S, RowHash<MLS, SLOTS>, 2>
    participant F as RowHash::find
    participant R as row_find_best_match::<S, MLS, ROW>
    participant K as kernels (row::match_mask, count)
    B->>E: ms (backend set), seq_store, rep, block
    E->>E: dispatch!(ms.backend) once
    E->>E: row_block!: (mls, row_log) once
    E->>P: concrete token S, concrete row search
    loop every searched position
        P->>F: ip
        F->>R: row_find_best_match::<S, MLS, SLOTS>, chosen once per block
        R->>K: tag compare, match lengths
        R-->>P: length, offset
    end
    P-->>B: trailing literals

The parser is specialized on its search (the Search trait: HashChain, BinaryTree, RowHash) and on its depth (greedy 0, lazy 1, lazy2 2). The searches are specialized on the minimum match (4, 5 or 6) and the row finder on its row size (16, 32 or 64 slots). The hash chain and the binary tree pick their specialization with one match per call, as ZSTD_searchMax does. The row finder (RowHash<MLS, SLOTS>) is chosen once per block instead (row_block!, inside the backend’s dispatch), so each prefix-only and extDict parser inlines a single row search: when every call site inlined all nine behind a match, the parser kept their state live and branched on the parameters at every position (lazy2 levels 1-2.5% slower, 6-8% more mispredicted branches in Callgrind’s model on Alice). The dictMatchState parsers keep the per-search choice (RowHashAny): specializing them too would add about 600 KB of code for parsers that run only with attached dictionaries. The row and hash-chain searches are inlined into the parser. The binary tree stays out of line behind #[fearless_simd_macros::simd], which measured fewer instructions. Candidate loops are specialized on whether the window has an external dictionary (EXT), so the prefix-only case has no segment test.

Kernels, all safe code:

KernelReferenceWhat it does
lazy::row::match_maskZSTD_row_getMatchMaskOne u8x16/u8x32/u8x64 compare of a tag row with the tag, to_bitmask, then a rotation so bit 0 is the row’s head. 16-slot rows on NEON instead narrow the compare to four bits per slot (vshrn by four in a fearless_simd::kernel!, the reference’s ZSTD_row_getNEONMask), since NEON has no movemask; Matches::pop turns a group back into a slot.
lazy::count::count, common_runZSTD_countWindow adapters for the shared mem::count_slices_simd word and vector scan described above.
Ctx::tree_stepZSTD_count + byte compare in the DUBTWithout an extDict, one 8-byte load per side gives both the common length and the two bytes that order the runs.
ProbeMEM_read32(match + ml - 3) == MEM_read32(ip + ml - 3)Cuts the searched window once per search (to the block end, which can precede the prefix end under LDM), and keeps its probe word until ml changes.

The row search keeps the reference’s two phases: it collects the candidates, inserts the searched position, and then measures them. A single fused pass gives identical output but measured 15-20% slower, because collecting first lets the candidates’ loads overlap. Row tables are viewed as &mut [[u8; ROW]] and &mut [[u32; ROW]], cut to a common row count, so one check of the row number covers both and slot indexes are masked.

Per block the parser builds the search’s state (Search::State<'t>, from Search::state(ms, ctx)) and keeps lazy_skipping locally, storing it back into the match state at the block’s end. The row finder’s RowState holds:

  • the tag and slot tables viewed from their first 64-byte boundary plus the current view’s offset (aligned_rows). MatchState gives both tables 64 extra entries in row mode, and ROW_TABLE_SLACK (64 plus seven view steps) from ROW_VIEW_MIN (2^14) entries on; smaller tables keep one view, since tiny inputs allocate and zero them on every call and the views’ slack made a 44-byte level 5 call 5-8% slower. glibc returns large blocks 16 bytes past a page, so unaligned, half of the 32-byte tag rows crossed a cache line and every 128-byte slot row spanned three; the reference’s workspace aligns them to 64 bytes. Moving the view cannot change a frame: a search only follows entries written since indices were last invalidated, and older ones fall below lowLimit and end the walk as zeros would;
  • the hash cache, a local next_to_update written back on Drop, the precomputed hash bits, low-limit inputs and number of attempts;
  • a persistent 64-entry candidate buffer, so no row-sized array is zeroed per search.

The update loop cuts the words once for the whole range and walks them with windows(8) (a cold per-index fallback covers the end), and steps the row head through a 256-entry constant table per row size instead of the wrap the compiler turned into a branch. The tag mask clears slot 0 before rotating, so collection has no per-candidate head test, and when the attempts reach the row size the loop keeps no attempts counter. Each collected candidate is prefetched (PREFETCH_L1(base + matchIndex)), as the reference does. best_prefix_candidate measures prefix candidates: the probe word is compared against a prefix slice shifted by the best length, the first eight bytes inline, stopping when the next probe word would leave the block.

btlazy2’s DUBT inserts nodes from one cut word slice and writes each node pair in one store. Prefix tree walks use TreeRun, which cuts the run once and counts up to four words inline, ordering the runs with a byte-swapped compare; longer runs continue in long_tree_step, out of line, with the vector scan (word-at-a-time counting of run-length input, whose matches span whole blocks, made levels 13-15 on 1 MiB of repeated bytes 18% slower); TreeLinks::descend picks the side with a branch, as the reference does, and writes the dummy link to a NO_LINK sentinel, so its write check doubles as the bounds check. The side is close to a coin flip, but the walk is a chain of dependent cache misses: a predicted branch starts the next node’s load before the byte comparison that picks it resolves, where a select_unpredictable made every step wait for it. On the first 5 MB of Silesia (silesia.tar) the branch made levels 13 and 15 17% and 21% faster than the select, as fast as the reference (the optimal parser’s walk measured the same, see optimal parser). ExtDict walks use Ctx::ext_tree_step.

flowchart TD
    A[row_find_best_match ip] --> B{lazy skipping?}
    B -- no --> C[row_update: insert next_to_update..ip via the hash cache, prefetching the rows hashed 8 positions ahead]
    C --> D[hash of ip from the cache]
    B -- yes --> E[next_to_update = ip; hash ip directly]
    D --> F[match_mask: SIMD tag compare, rotate by head]
    E --> F
    F --> G[collect slots newest first, skip slot 0, stop below low_limit or after nb_attempts]
    G --> P[prefetch each candidate]
    P --> H[insert ip into its row]
    H --> I{ext window?}
    I -- no --> J["best_prefix_candidate: shifted probe word, first 8 bytes inline, count"]
    I -- yes --> K["best_candidate::<S, true>: extDict candidates via count_2segments"]
    J --> L[ml, offset]
    K --> L

With an attached dictionary, lazy_dms_generic drives the same searches through Search::find_dms, which spends the attempts the context’s search left on the dictionary’s tables:

sequenceDiagram
    participant P as lazy_dms_generic::<S, M, DEPTH>
    participant F as M::find_dms
    participant C as context search (hc / row / DUBT)
    participant D as DictCtx (dictionary tables, index_delta)
    P->>P: repeat offsets, reading the dictionary below the prefix
    P->>F: ip
    F->>C: search the block's own tables
    C-->>F: best match, attempts left
    F->>D: hash chain / row (unsalted, dictionary row bits) / ZSTD_DUBT_findBetterDictMatch
    D-->>F: better match with the remaining budget
    F-->>P: length, offset

DictCtx is built once per block from BlockInput::dms: the dictionary’s tables (row tables through aligned_range), its bytes, bounds and index_delta. btlazy2 stops searching the dictionary when the context’s walk reached the block end (the reference zeroes nbCompares); the other searches always continue into it.

Every backend in Backend::available(), including the scalar fallback that unit tests add, must produce the reference’s bytes. lazy::tests checks levels 5–15, every search specialization, the extDict variants, and LDM sub-blocks on each backend against zstd_ffi::oracle. The kernel tests compare match_mask and count with scalar references at every row head and at vector and word boundaries.

opt is the optimal parser: it collects every match at each position (get_all_matches with a MatchQuery), prices literals and matches from the current statistics (OptState, initialized from the previous block or, for btultra2’s first block, by a throwaway first pass), and backtracks the cheapest path. LDM candidates enter as extra matches. It dispatches to the block’s SIMD backend (BlockInput::backend) once per block, specializes the tree search by minimum match length, and keeps its prices in tables patched as the statistics change; see Optimal parser.

ldm is long distance matching: a gear rolling hash (ldm_geartab) samples positions, a bucketed table stores them, and matches found far back become raw sequences that block_compress interleaves with the regular finder’s output. LDM keeps its own window (LdmState::window) over the same segments. It takes the backend as an argument of generate_sequences and counts match lengths with the optimal parser’s kernels (opt_count).

Streaming

stream::Stream is ZSTD_compressStream2 in buffered mode on top of the engine.

stateDiagram-v2
    [*] --> Init
    Init --> Load: first call (init with pledged size and operation)
    Load --> Load: copy input into in_buff until a block is full
    Load --> Flush: block compressed into out_buff (or straight into output)
    Flush --> Load: out_buff drained, frame continues
    Flush --> Init: out_buff drained after the epilogue
    Load --> Init: End shortcut (whole remaining input compressed directly)
    Init --> Init: reset_session on error

The input buffer is a ring of window size plus one block; blocks are taken from it, so its wrap-around is what makes the engine see an extDict. When the reference’s shortcut condition holds — an End call, an empty input buffer, and an output with room for compressBound of the remaining input — the input is compressed straight from the caller’s memory into the caller’s output, which changes the bytes and is reproduced. A first call that is already End also pledges the input size, so the header records it; the public session rejects that call first when its length contradicts a declared size (see compressor). StreamStep reports bytes consumed, produced and still buffered.

Destinations

The engine writes frames through output::Output::prepare(position, needed), which returns the writable tail at position; needed is the largest amount the next write can take (a frame header, compressBound of the next block, the epilogue). SliceOutput returns the caller’s exact tail. VecOutput and UninitOutput initialize only up to min(position + needed, limit), so the encoder sees either at least a block’s bound or the exact remaining capacity, the two cases in which its capacity checks behave as with the whole slice. UninitOutput keeps a borrowed_buf::BorrowedBuf over the caller’s MaybeUninit bytes, zeroes its filled prefix forward and hands out filled_mut()[position..], so no unsafe code is involved and each byte is zeroed at most once per call.

Stream::compress is generic over output::Destination, implemented for [u8] and [MaybeUninit<u8>]. Its tail(at) is the Output for blocks written straight to the caller (the shortcut and direct blocks), and put(at, bytes) copies what was compressed into out_buff. The [u8] instance compiles to the code the stream had before the abstraction.

flowchart TD
    Call["Stream::compress(output: &mut D)"] --> Path{block path}
    Path -- "End shortcut or direct block" --> Tail["output.tail(op): Output"]
    Path -- "otherwise" --> OutBuff["SliceOutput(out_buff)"]
    OutBuff --> Put["Flush stage: output.put(op, out_buff[..])"]
    Tail --> Kind{D}
    Kind -- "[u8]" --> Slice["SliceOutput: exact caller tail"]
    Kind -- "[MaybeUninit u8]" --> Uninit["UninitOutput: zero up to position + needed, then filled tail"]
    Slice --> Engine["Engine::compress_end_to / compress_continue_to"]
    Uninit --> Engine

Errors

CoreError mirrors the reference’s error codes the encoder can raise (dstSize_tooSmall, srcSize_wrong, stage_wrong, parameter errors, table construction failures). It never leaves the crate: EncodeError::from_core maps it, and sessions turn an unexpected DstSizeTooSmall (their internal buffers are always large enough) into EncodeError::Internal.

Invariants

  • Indices are u32 and wrap exactly as in C; Addr arithmetic is i64 so that “pointer below the segment” states exist without overflow.
  • Every table read is bounds-checked (safe Rust only); a bounds failure would be a panic, so the fuzz targets and differential tests double as checks that the reference’s index invariants hold here too. Where an index is masked to its table size instead (finder hash tables, FSE state and symbol tables, Huffman tree nodes), the mask is the identity for every index the reference’s invariants allow.
  • Kernels never re-detect the SIMD level: the backend is fixed when the compressor is built and dispatched once per block.
  • Parameters are validated before begin; the core assumes they are in range.

Known gaps

  • Measured 2026-11-08 (one pinned vCPU of a Ryzen 9 7950X VDS, native builds, lzbench master on silesia.tar, five interleaved repeats): levels 3, 5 and 8 compress at a median 0.950, 0.963 and 0.962 of zstd 1.5.7’s speed, single runs from 0.915 to 1.05. At level 8 the instructions, branch and cache misses per call are within 1-4% of the reference’s (32.56G against 32.38G instructions per silesia.tar call); the row finder’s cycles vary by up to 25% between runs on that host. The dfast loop executes about 60 instructions per searched position against the reference’s 45 (mozilla, 1 MiB, level 3: 23.95M against 16.16M for the whole loop, 9.56M against 7.53M cycles): about fifteen are stack traffic, loads of the input’s length for each word’s bounds test, and the repcode read’s own bounds test. Running full dfast blocks on the fast levels’ SeqCursor removed only 0.7-3.9% of the instructions and no cycles, and was not kept. Rechecked the same day: one fixed stand-in with the range test joined to the equality (loaded == word && inside, as ZSTD_selectAddr compares) cut 1.3% of the instructions but cost 32% more cycles, since LLVM again tested the range with its own, mispredicted branch.

  • fast on inputs above 256 KiB (Silesia, levels -1 and 1) runs at a median 0.939-0.945x the reference, 0.93-0.96x on text (Silesia run of 2026-10-30, Ryzen 9 7950X, since replaced by lzbench). Its search loop costs about the reference’s instructions per position; a match costs about 127 instructions against the reference’s 91 (Silesia dickens, level 1), of which about 25 load and store values the loop cannot keep in registers and about 12 are bounds tests. Rejected: one span for the two hash inserts after a match (no fewer instructions); constant hash widths for reused contexts (level -5 3-6% slower).

  • The lazy no-dictionary loop’s block-relative positions (lazy_generic) trade incompressible inputs for text: against the main branch’s absolute positions it is 1-2% faster on Alice with reused contexts at the row levels 4-9 (0.7% cold) and 0.4-1% on cyclic Alice, but random data at levels 7-10 runs 2-11% slower (random 64 KiB level 10 11%, 2026-10-30, paired against the main branch). The instruction counts match within 1.2%; the extra cycles are register pressure from the five inlined count_at_simd calls, which spill the loop’s state (store-to-load interlocks +9%). Calling the context’s count instead, relative repeat tests or ctx.base for the local base recovered at most half.

  • Cold one-shot calls on empty input execute about 70 more instructions than the main branch’s (2,635 against 2,563, 2026-10-30), 3.7% slower, and the 44-byte text at levels -5 to 10 runs 2-6% slower: the frame setup (begin_sized, compress_once_into) is no longer inlined into Compressor::compress_to_slice_with, and the store carries the full-block destinations’ state. Forcing the inlining added instructions.

  • The fast kernel’s speed depends on where it lands in the binary: on 2026-10-29 a change elsewhere left its instructions identical (5,035M for 800 Alice level 1 calls) but raised branch mispredictions from 9.28M to 9.99M and its time by 1.8% (Ryzen 9 7950X). Measure kernel changes by instruction and misprediction counts as well as time.

  • SIMD: the lazy finders compare row tags with one vector compare; fast/dfast and lazy search share the 128-byte match-length scan. The optimal parser and LDM use native-vector kernels (opt_count). The entropy coders run in the backend’s feature context without explicit vector code. The pre-splitter’s 32-bit distance and histogram merges also run in that context. Histogram counting is scalar. Output is identical on every backend.

  • See the benchmarks for the current end-to-end ratios. Text at the fast levels and the lazy levels 6–12 still trails the reference. After the small-table widths (2026-10-18) the fast kernel executes 1.34x the reference’s instructions on Alice at level 1 (11.99M against 8.96M over three calls; 1.48x before). Per match it executes about 146 instructions plus 27 to restart the search against the reference’s 87 plus 28, with 21 conditional branches against 10: bounds tests in the backward extension, the first-word count and the post-match fill, and the Vec capacity tests of the literal and sequence pushes. Per two probed positions it is about 61 against 54. Mispredictions are at the reference’s level. Removing instructions did not make it faster (i7-13700KF, paired cold calls): a literal buffer whose length runs ahead of the stored literals (up to 3.5% fewer kernel instructions, 1-4% slower on text at levels -5 and -1), the repcode read clamped instead of bounds checked (7% fewer kernel branches, 1-2% slower), the long count out of line (1.5% faster at level 1, 2% slower at -5), a branch-free eight-byte backward extension (a quarter fewer mispredictions in callgrind’s model, 7% faster on 1 MiB of text at -5, but 5-6% slower on Alice at -1 and 1-5% on binary data), the clamp limit unified with the probe window’s (1.6% more instructions), the post-match fill bounded by one 10-byte window and repcodes held as usize (no gain). random-1m timings move by 5-15% between builds from code placement alone.

  • Cold, the row finder plus its table setup runs at the reference’s speed on 1 MiB of text (levels 9-12, i7-13700KF, measured against ZSTD_generateSequences with the entropy stage short-circuited). The pre-splitter was the larger part of the rest (1.3M extra instructions on 1 MiB at level 9) before it was compiled per rate; the entropy stage after build_seq_store executes at or below the reference’s instruction count (2.43M against 2.50M there). On Alice at levels 7-8 (minimum match 4, 16-slot rows) the finder is still 2-3% slower and executes about 4% more instructions, two per collected candidate for the masked buffer index. The finder is bound by memory latency (without its prefetches it is 15-26% slower), so instruction counts are a poor proxy there. A reused context measured the finder 2-6% slower on an input identical to the previous one before the row view moved per reset (see Engine lifecycle); the view recovers 0.2-6.5% at levels 4-10, the reference’s salt measured 1-3% more at levels 6-8 with ROW 16 (2026-10-29). btlazy2 executes about 21% more instructions than the reference on Alice at level 12 (index arithmetic and bounds checks in the tree walk) at equal or better speed. Measured and rejected (2026-10-18): the row search out of line (0.5-4.5% slower), the reference’s branch-free next-slot arithmetic instead of the lookup table (0-1% slower), the row update out of line (1.5-2.5% slower), and row state pre-cut into rows and generic over the row size (no gain).

  • A cold one-shot call into a Vec zeroes its destination before writing it (compressBound of one block, and one block’s bound ahead for longer inputs), about 0.8 µs per 64 KiB on the i7-13700KF, which the reference, writing into uninitialized memory, does not pay. Sizing it down would change the capacity the entropy coder’s fit decisions see; writing without zeroing needs unsafe or bit writers that append.

  • The select’s stand-in picked by the word’s low bit was measured (2026-10-15, first 20 MB of silesia.tar) against one fixed stand-in followed by the validity test, as ZSTD_match4Found_cmov does: the latter was 1% slower at level 1 and 3% slower at level 3. The clamp in fast’s loop, the prefetches and dfast’s inlined count were measured on an Apple M5 Pro (2026-10-17, three or more interleaved runs of both builds, one reused context per input, on 1 KiB to 128 KiB slices of Alice, the structured binary corpus and zstd’s large-literal-and-match-lengths file and on 1 MiB of the synthetic text and binary corpora). Against the previous loops, level -5 took 2-18% less time, level 1 0.3-15%, level 2 0.3-14% and level 3 1-11% (1 MiB of text at level 3 unchanged); level 4 moved by -3% to +1%. The largest gains are on small inputs. One case measured slower, 1 MiB of binary data at level 2 (+5%), and it is equally slower with dfast’s loop left untouched, so it comes from code placement elsewhere, not from this loop.

  • The same changes were rechecked on the i7-13700KF (2026-10-17, the comparison harness’s profile driver, seven to nine interleaved process pairs per case on one pinned core). The clamp as first written ((bytes == word) & (offset == clamped)) compiled to a second, mispredicted branch: text at levels -5 to 2 took 5-17% longer than with the select (callgrind: 40% more mispredicted conditional branches with 4% fewer instructions). With the folded test it is back to the select’s time on text and 1-5% faster on 64 KiB of binary and random data at levels -5 to 1. A select_unpredictable between the loaded word and the probe’s complement was as slow on text as the split test. dfast’s inlined count took 3-6% longer on text at levels 2 to 4 (Alice at level 2, 1 MiB at levels 3 and 4) and saved 2-3% only on 44 bytes, so dfast’s prefix loop went back to the out-of-line count. Removing the prefetches did not remove the slowdown on text, and they were kept.

  • Measured and rejected on the M5 (2026-10-17, interleaved runs of both builds; these loops’ timings move by up to 10% between builds that differ only in code elsewhere, through inlining, register allocation and placement):

    • dfast’s candidates clamped as fast’s are. Clamping both was up to 18% faster on single blocks up to 16 KiB at level 3, but 6% slower on 1 MiB of binary data at level 3, 16% at level 4, and 4-6% slower on 128 KiB at level 4: the compiler turned the long candidate’s clamp test into a branch that waited on the table load. Choosing per block (clamped only on a frame’s first block, or while the history is under 4 << hashLog) fixed 1 MiB, but as a second instance of the loop it moved compress_block_mls out of line and as a run-time flag it cost a register, and either made a single 128 KiB block 3-12% slower. Clamping only the short candidate gave 1-4% on small inputs and lost 3-5% on 128 KiB blocks;
    • dfast’s validity tests as branches (5-18% faster up to 16 KiB, 11-32% slower on 128 KiB blocks), its repcode read clamped instead of bounds checked (no change), its searched index taken where the search stops or read back from the short slot just written (no gain, 8-11% slower on 128 KiB), and the per-position ip + 256 prefetch on aarch64 (1-4% slower);
    • NEON kernels for the 32- and 64-slot tag masks (vuzp in place of vld2q/vld4q; up to 6% slower at level 10, no change at 12) and for match counting in 64-byte blocks (vshrn nibble masks; no change at levels 1 and 3, 0.5-1% slower at 16 and 19, up to 7% slower on highly repetitive input).
  • dfast’s kernel is latency bound: on Alice at level 2 it takes about 78% of a cold call’s time but 67% of its instructions, and executes 18.3M instructions over three calls against the reference’s 12.3M (about 61 against 51 per searched position: the repcode test with its bounds check, the word-dependent stand-in select and about 16 spills and reloads, since the loop’s values do not fit x86-64’s 15 registers; about 185 against 121 per match: store bookkeeping, SeqDef packing and bounds-checked insertions) while matching or beating its time. Removing instructions (the wild literal copy alone, a branch-free catch-up, one match tail shared by all exits) did not change the time; only taking work off the post-match load chain did. Prefetching the four likeliest match ends instead of the match end’s own candidates was 1-3% slower. The 44-byte input at level 3 (run-time widths) is 1.5-3% slower than before the look-ahead (a 0.5 µs call). Measured and rejected (2026-10-18, i7-13700KF, paired runs): branches::unlikely on the candidate exits (18% more instructions; LLVM lost the constant widths), an un-pipelined loop (same instructions, 1-3% slower), a branch-free backward extension (28% fewer mispredictions in callgrind’s model, 1-2% slower: the hardware predicts the catch-up branch far better than the model), software pipelining one position ahead (21% more instructions, 4-8% slower), a lean loop with match handling after it (2% faster on text, up to 3.8% slower on random data), the post-match insertions and repcode check through one 10-byte window (2.3-3% fewer instructions, 1-2% slower), and & instead of && in the repcode test (3% more instructions).

  • On the Silesia corpus (silesia.tar, 2026-10-15, warm contexts and the fastest of several runs, as lzbench measures) fast compresses about 5-10% faster than before the constant widths and the validity branches, at 0.91-0.93x the reference’s speed; dfast and greedy/lazy/lazy2 still run 6-14% slower than the reference; btlazy2 and the optimal parser are at or above its speed. Fast’s prefix loop executes about 58 instructions per two probed positions against the reference’s 45: bounds checks on the repcode read and the probe windows, and reloads of spilled values.

  • Measured and rejected (2026-10-15, first 20 MB of silesia.tar): carrying fast’s current0 out of the loop instead of through it (0.5-1% slower), reading the repcode word at ip2.saturating_sub(rep) to drop its bounds check (2-3% slower with the select, noise with the branches), the search loop out of line in its own function (5-10% slower: the call and the result per match cost more than the registers it freed) and dfast’s candidates with branches (level 3 +1%, level 4 6% slower). dfast’s compiled-in table widths measured no change then because LLVM merged the inlined instances into one loop with run-time widths; as separate functions they are now kept. A sequence store written through a block-long cursor kept its positions in registers and gave dfast 5%, but its buffers had to be initialized at full capacity to be written safely: a cold one-shot call on 64 KiB then paid about 2.5 us more (32-50% at levels -5 to 1), so that trial kept the store on Vec. The current dense full-block promotion above avoids paying that setup for short inputs. The constant widths for every match length at once instead of per default pair cost 100 KB of code for the same instruction count; timings of one build against another varied by about 3% with the register allocation alone, so these comparisons interleave several runs of both builds.

  • The row finder issues the reference’s row prefetches (ZSTD_row_prefetch from ZSTD_row_fillHashCache and ZSTD_row_nextCachedHash, 8 positions ahead) and its per-candidate prefetch through the branches crate’s safe prefetch_read_data. The hash chain and binary tree have no prefetch points outside dictionary modes, so none are ported.

  • A cold one-shot call’s heap footprint sits close to glibc’s dynamic trim threshold on 1 MiB inputs; growing per-call allocations by a few hundred KiB makes every call return and re-fault the heap top (see fill_codes).

  • The reference also resets indices when it reallocates its workspace for larger parameters; mzstd resets them only on first use or near ZSTD_CURRENT_MAX. Frames are unaffected — a reused reference context writes the same bytes as a fresh one (zstd-ffi/tests/reuse_probe.rs), and so does a reused mzstd engine — but internal index values differ from a reused reference context’s.

  • Allocation sizes do not follow the reference’s workspace accounting.

  • Platform constants are the reference’s 64-bit ones (ZSTD_CURRENT_MAX, ZSTD_WINDOWLOG_MAX). On 32-bit targets mzstd still writes what a 64-bit build of the reference writes; a 32-bit build of the reference caps the window at 2^30 and corrects overflow earlier, so their frames can differ for very large windows or inputs.

Alice follow-up (2026-10-29)

The dense full-block writer, relative lazy prefix positions and packed code/width maps above were chosen with paired Alice runs on AMD; eager cursor initialization, promotion in dfast/lazy and a separate padded-input specialization were rejected. Current timings against C and the main branch are in the benchmark results.

Optimal parser and long distance matching

compressor::core::opt is zstd 1.5.7’s zstd_opt.c: the btopt, btultra and btultra2 strategies (levels 16 to 22 by default), their binary-tree match finder and their price model. compressor::core::ldm is zstd_ldm.c. compressor::core::opt_count holds the match-length kernels both use. All three are private to compressor::core. Their output is byte-identical to the reference’s, and the SIMD backend only changes how many bytes a comparison covers per step.

Module and dispatch map

graph TD
    Block["block::build_seq_store"] -->|"BlockInput { window, start, end, backend, dms, symbol_costs }"| Entry["btopt / btopt_ext / btopt_dms / btultra / btultra_ext / btultra_dms / btultra2"]
    Block -->|"generate_sequences(.., backend)"| Ldm["ldm::LdmState"]
    Entry -->|"dispatch!(backend) once per block"| Parser["opt_generic::&lt;S, OPT_LEVEL, EXT, DMS&gt; (#[simd])"]
    Parser --> Split["Finder::split(ms): Finder + OptState"]
    Split --> Find["find_matches: match on mls"]
    Find --> Gam["get_all_matches::&lt;S, EXT, DMS, MLS&gt; (#[simd] entry)"]
    Gam --> Update["update_tree (out of line) -> insert_bt1"]
    Gam --> Search["insert_bt_and_get_all_matches"]
    Update --> Walk["TreeWalk::run"]
    Search --> Walk
    Walk --> Ctx["Ctx::extend"]
    Search -->|"DMS, comparisons left"| Dict["search_dictionary: attached dictionary's tree (read only)"]
    Dict --> Count2["Ctx::count_dictionary -> opt_count::count_2segments"]
    Ctx --> Count["opt_count::count_ordered: first word inline / count_2segments"]
    Count --> Run["count_ordered_run (out of line, unlikely)"]
    Run --> Tail["opt_count::count_tail (#[simd] entry): words, native vectors, words"]
    Parser --> Prices["OptState + PriceTables"]
    Ldm -->|"dispatch!(backend) once per 1 MiB chunk"| Gen["generate_internal (#[simd])"]
    Gen --> Count

Backend (the fearless_simd level chosen when the compressor is built) reaches the parser through BlockInput::backend and the LDM through an argument of generate_sequences. Each strategy entry dispatches once per block; opt_generic is monomorphized over the SIMD token, the optimization level (0 for btopt, 2 for the ultra strategies), whether the window has an external dictionary and whether a dictionary is attached, which are the reference’s template parameters. find_matches picks the get_all_matches instance for the block’s minimum match length (ZSTD_selectBtGetAllMatches); the branch resolves the same way for every position of a block. get_all_matches and count_tail are #[fearless_simd_macros::simd] entries: their bodies run in the backend’s feature context, so the vector loads and compares inline into them.

Per block, Finder::split(ms) divides the match state into a Finder and the OptState the parser keeps pricing with. The Finder borrows the hash table, the tree (cut to 2 << btLog cells) and the 3-byte hash table, and precomputes the hash logs, bt_mask, nb_compares, sufficient_len, the low limit and max_distance (u32::MAX with a loaded dictionary, which reproduces ZSTD_getLowestMatchIndex). It owns next_to_update and next_to_update3 for the block and stores them back afterwards, so the search reloads and recomputes nothing per call. update_tree stays out of line: inlined, its copy of the walk crowded the search’s registers. Each search hints the hash cell and the 3-byte hash cell of ip + 4, and each tree insertion the hash cell of ip + 4, through the branches crate’s safe prefetch_read_data; four positions ahead measured best (the largest wall-clock gain on 1 MiB inputs).

The parser

stateDiagram-v2
    [*] --> Rescale: rescale_freqs (rebuild PriceTables)
    Rescale --> FirstMatch
    FirstMatch --> FirstMatch: no match, ip += 1
    FirstMatch --> Stretch: longest > sufficient_len
    FirstMatch --> Forward: prices of every match length
    Forward --> Forward: literal or match improves opt[cur + n]
    Forward --> Stretch: long match, end of the window or of opt
    Forward --> Backtrack: cur reaches last_pos
    Stretch --> Backtrack
    Backtrack --> Store: sequences, update_stats (patch PriceTables), set_base_prices
    Store --> FirstMatch
    FirstMatch --> [*]: ip reaches the block's end - 8

The control flow is ZSTD_compressBlock_opt_generic line for line. The position table (opt, ZSTD_optimal_t) and the match buffer live in OptState and are moved out for the block, as is the LDM candidate store (lent by ldm::block_compress and handed back with its cursor unchanged, which is what the reference’s header copy amounts to). The reference carves both tables uninitialized from its workspace. Here the position table grows only when a block’s first match is found (grow_positions, cold), to at most min(block length + 3, ZSTD_OPT_SIZE) entries, and the parser indexes a &mut [Optimal] slice that stays in registers. The match table (OptState::reserve_matches) holds min(ZSTD_OPT_SIZE, block length + 3, nb_compares + 5) entries: a search reports at most three repeat matches or one 3-byte hash match, one match per tree comparison and one LDM candidate. OptState::allocate at frame start sizes only the frequency tables, so an empty frame never fills them and a tiny one fills a few dozen entries instead of 4099.

Prices

OptState keeps the reference’s frequencies and sums. PriceTables folds each frequency’s WEIGHT with its symbol’s extra bits:

TableEntryPrice
literal[256]WEIGHT(litFreq[b])litSumBasePrice - min(entry, litSumBasePrice - 256)
lit_length[64]LL_bits[c] * 256 - WEIGHT(litLengthFreq[c])entry + litLengthSumBasePrice
match_length[128]ML_bits[c] * 256 - WEIGHT(matchLengthFreq[c]) + 51entry + matchLengthSumBasePrice
off_code[32]c * 256 - WEIGHT(offCodeFreq[c]) (+ handicap below level 2)entry + offCodeSumBasePrice
sequenceDiagram
    participant P as opt_generic
    participant S as OptState
    participant T as PriceTables
    P->>S: rescale_freqs(block, opt_level)
    S->>T: rebuild_tables(opt_level): every entry
    loop each position and candidate length
        P->>S: literal_price / lit_length_price / offset_price + match_length_price
        S->>T: one masked read + the sum's base price
    end
    P->>S: update_stats(literals, offBase, matchLength)
    S->>T: patch the entries whose frequency changed
    P->>S: set_base_prices

All sums wrap as the reference’s U32 sums do, so each price equals ZSTD_rawLiteralsCost, ZSTD_litLengthPrice and ZSTD_getMatchPrice computed from the frequencies. The offset and match-length parts of a match price are computed apart; the offset part once per candidate match rather than once per length. Every table is a power of two longer than any code its index function can return, so the masked index needs no bounds check. Predefined prices (zop_predef, blocks of at most eight bytes with fresh statistics) bypass the tables.

A rescale runs at the start of every opt_generic call (twice for btultra2’s first block), so a tiny input pays for the rebuild on every call. weights therefore computes a whole table’s weights with a vectorizable float form. For stat + 1 < 2^24 the f32 holding it is exact: bits >> 23 is highbit32(stat + 1) + 127 and bits >> 15 additionally carries the eight bits of (stat + 1) << 8 >> highbit. If any stat + 1 reaches 2^24, which the scaled frequencies never do (they stay below 2^19), the table is redone with the integer form, which wraps exactly as the reference’s stat << 8 does. update_stats patches literal weights through a fixed 256-entry view of the frequencies, with the level’s weight form chosen once per sequence; a run of 64 or more literals increments the frequencies as the reference does and then rebuilds all 256 literal weights at once with weights, which is cheaper than patching each.

The binary tree

Ctx holds a block’s constants: the prefix and external-dictionary bytes as plain slices (an index at or above dict_limit is an offset into the prefix, one below it an offset from the end of the external segment), the block end and the minimum match length. TreeWalk::run is the loop that ZSTD_insertBt1 and ZSTD_insertBtAndGetAllMatches share: it re-links the tree around the inserted position and reports each candidate longer than every earlier one through a closure (which records the match for the search and does nothing for an insertion).

An insertion or search whose first candidate is already below the window (almost every position of incompressible input) only clears its two cells. A child cell is (index << 1) & cells, and the loop tests its condition at the bottom. The repeat-offset checks read the candidate word before testing the window, as the reference’s & allows, and guard prefix candidates with rep_offset != 0 && rep_offset <= ip_offset (the reference’s rep_offset - 1 < curr - dictLimit), so one slice prefix[..ip + 4] covers every candidate read. Ctx cuts the prefix at the block end, so lengths are bounded by the slice instead of a separate limit.

flowchart TD
    Start([candidate from the hash table]) --> Budget{"compares left and index >= window low?"}
    Budget -- no --> Clear["clear both open cells"]
    Budget -- yes --> Extend["Ctx::extend: length past min(commonSmaller, commonLarger) + order of the first differing bytes"]
    Extend --> Longer{"longer than the best?"}
    Longer -- yes --> Report["update best and matchEndIdx, on_longer"]
    Report -- stop --> Clear
    Report --> End
    Longer -- no --> End{"run reaches the block end?"}
    End -- yes --> Clear
    End -- no --> Side{"candidate byte smaller?"}
    Side -- yes --> Smaller["link into the smaller cell; descend to its larger child"]
    Side -- no --> Larger["link into the larger cell; descend to its smaller child"]
    Smaller -- "index <= btLow" --> ClearOther["clear the larger cell only"]
    Larger -- "index <= btLow" --> ClearOther2["clear the smaller cell only"]
    Smaller --> Budget
    Larger --> Budget

The tree is cut to its exact size, 2 * (btMask + 1) cells, and every cell index is masked with 2 * btMask + 1. The cell indices are below that size already, so the mask changes nothing, but it lets the compiler drop the bounds checks from the walk. The reference parks a finished side on a dummy cell; here that side is simply not cleared.

Match-length kernels (opt_count)

count_ordered is the walk’s comparison. It compares the first word of each side inline, with one length test apiece. If they differ, it returns the length from a trailing-zero count and the order of the first differing bytes (match[len] < ip[len], which the walk branches on next) from a compare of the byte-swapped words. Longer runs continue in count_ordered_run, out of line and hinted unlikely, so the walk’s state is not spilled at every candidate; it starts after the matched first word and compares up to INLINE_WORDS (2) more words before count_tail, out of line: two more words, then native vectors (the backend’s 32 or 64 lanes, else 16; the scalar fallback emulates them), then words, then one word ending at the limit that overlaps bytes already known equal. count is ZSTD_count within one segment and count_2segments is ZSTD_count_2segments; the repeat-offset checks, the 3-byte hash candidate and the LDM’s forward match length use them. Every variant returns exactly the byte-by-byte length.

Long distance matching

generate_sequences cuts its range into 1 MiB chunks and dispatches each chunk’s generate_internal to the backend. The gear hash feeds bytes into a fixed batch of 64 split points (ZSTD_ldm_gear_feed). Each split is hashed with a one-shot XXH64, the same digest as the frame checksum’s streaming hasher without its buffering. A candidate’s forward length comes from opt_count (both segments). Its backward length is a byte loop, as in the reference. The rolling hash is a serial dependency chain (each step shifts the previous state), so it stays scalar.

Dictionaries

Attached dictionaries (dictMatchState)

btopt_dms and btultra_dms (ZSTD_compressBlock_{btopt,btultra}_dictMatchState; btultra2 has no variant of its own and uses btultra_dms, as the reference’s selection table does) run opt_generic::<S, OPT_LEVEL, false, true>. The DMS const parameter is the reference’s dictMode == ZSTD_dictMatchState template argument: the noDict (EXT = false, DMS = false) and extDict (EXT = true, DMS = false) instances compile exactly the branches they had before. A *_dms entry called without BlockInput::dms runs the plain noDict parser, which is what ZSTD_matchState_dictMode selects then.

Ctx::with_dictionary derives the dictionary’s constants once per block into DictSearch (dmsHashLog, dmsBtMask, dmsLowLimit, dmsHighLimit, dmsBtLow, its hash table, its tree cut to 2 << dmsBtLog cells and its bytes). Dictionary index i is window index i + dmsIndexDelta, with dmsIndexDelta = windowLow - dmsHighLimit: the dictionary ends just below the window’s low limit, where the attach left the working window.

sequenceDiagram
    participant G as get_all_matches::<S, false, true, MLS>
    participant U as update_tree::<S, false, MLS>
    participant I as insert_bt_and_get_all_matches
    participant W as TreeWalk::run
    participant D as search_dictionary
    G->>U: insert skipped positions (noDict insertion)
    G->>I: search ip
    I->>I: repeat offsets: prefix, else dictionary_repeat_length (dms bytes, then prefix start)
    I->>I: 3-byte hash candidate (prefix only; dictionaries have no 3-byte table)
    I->>W: block's own tree
    W-->>I: best length, matchEndIdx, comparisons left (0 after a stop)
    alt comparisons left
        I->>D: dictionary's tree with the remaining budget
        D-->>I: more matches, matchEndIdx
    end
    I-->>G: matches, nextToUpdate = matchEndIdx - 8

The dictionary’s tree is only read: candidates are compared through Ctx::count_dictionary (ZSTD_count_2segments(ip, dmsBase + i, iend, dmsEnd, prefixStart); a candidate whose known common bytes already run past the dictionary’s end counts nothing more, as the reference’s does), and the walk follows the smaller/larger child by the candidate’s next byte, read in the dictionary or, past its end, at window index i + length + dmsIndexDelta in the prefix.

Statistics from dictionary entropy tables

OptState::rescale_freqs(src, opt_level, symbol_costs) takes the block’s inherited entropy tables (BlockInput::symbol_costs, the reference’s ms->opt.symbolCosts = &prevCBlock->entropy). On a frame’s first block (litLengthSum == 0) it seeds the frequencies from them when their Huffman table is Repeat::Valid, which only a Zstandard-format dictionary covering all 256 bytes produces; this applies to attached, copied and loaded dictionaries alike.

flowchart TD
    R["rescale_freqs"] --> F{"litLengthSum == 0?"}
    F -- no --> S["scale_stats: downscale running statistics"]
    F -- yes --> V{"symbol_costs Huffman table Valid?"}
    V -- yes --> D["seed_from_dictionary: literal 2^(11 - HUF bits), sequence codes 2^(10 - FSE max bits), 1 for a zero cost; prices dynamic even for blocks of 8 bytes or less"]
    V -- no --> N["seed_without_dictionary: literal histogram of src, fixed sequence frequencies; predefined prices for blocks of 8 bytes or less"]
    S --> B["set_base_prices, rebuild_tables"]
    D --> B
    N --> B

The bit costs come from huf::CTable::symbol_nb_bits (HUF_getNbBitsFromCTable) and fse::CTable::max_nb_bits (FSE_getMaxNbBits). Shifts wrap their count modulo 32, as the unchecked C shift does on common targets, so a malformed table cannot panic.

Invariants

  • Every byte the parser emits is the reference’s. The SIMD level, the price tables and the specialized hashes change no decision. Each is differential-tested against the byte-by-byte count, the reference formulas or mem::hash_ptr, on every backend in Backend::available().
  • Tree cell indices are below the tree size. Window indices handed to Ctx are at least the window’s low limit, and in the non-extDict variants at least dict_limit. A violation panics on a slice bound and never reads out of range.
  • PriceTables is valid from the rescale_freqs at the start of each opt_generic call until the end of that call. Nothing outside opt reads it.

Known gaps

  • The tree walk still pays one length test for the candidate’s word and one for the current position’s per visited node, which the reference’s pointer arithmetic does not. On Alice at level 15 get_all_matches and update_tree execute about 18% more instructions than the reference’s search; at level 19 and on 1 MiB inputs the parser executes fewer in total, and with the prefetches every measured btopt..btultra2 case runs at least as fast as the reference.
  • Branchless child selection in the walk measured 19% slower: the next node’s load becomes data-dependent and loses speculation.
  • MatchState::reset zeroes the hash, tree and 3-byte hash tables on every frame for these strategies, while the reference keeps them when indices continue. Every read here is checked against the window first: the hash and 3-byte candidates against the window low, and each inserted node rewrites both of its own cells. So the btopt..btultra2 tables could be kept as the fast and dfast ones are. On a 64-byte input that zeroing is about 4K of the roughly 80K instructions per call.
  • The gear hash is scalar. A vector version would have to rebuild the rolling state from the last 64 bytes in every lane.
  • Dictionaries: FSE_getMaxNbBits reads every symbol up to MaxLL/MaxML, also past a dictionary table’s largest symbol. The reference reads uninitialized workspace there; this port reads whatever an earlier build left in the table (zero for a fresh one). Trained dictionaries cover every symbol, so only hand-made dictionaries with short litlength or matchlength tables can differ.

Target compressed block size

ZSTD_c_targetCBlockSize (parameter 130, EncoderConfig::with_target_compressed_block_size) asks the encoder for compressed blocks of about a given size, so a receiver can start decoding long before a 128 KiB block has arrived. It is a port of the superblock path of zstd 1.5.7: ZSTD_compressBlock_targetCBlockSize in zstd_compress.c and ZSTD_compressSuperBlock in zstd_compress_superblock.c. The frames are byte-identical to ZSTD_compress2 / ZSTD_compressStream2 with the same parameter.

Where it lives

graph TD
    Cfg["EncoderConfig::with_target_compressed_block_size(Option&lt;NonZeroU32&gt;)"] --> Int["internal::requested_params: max(size, 1340), 0 = none"]
    Par["CParameter::TargetCBlockSize (ID 130)"] --> Cfg
    Int --> Req["RequestedParams / AppliedParams::target_c_block_size"]
    Req --> FC["cctx::compress_frame_chunk"]
    FC -->|"target != 0"| SB["core::superblock::compress_block"]
    FC -->|"post splitter"| Split["core::split_block"]
    FC -->|"otherwise"| Blk["core::block::compress_block_internal"]
    SB --> Build["block::build_seq_store (shared)"]
    SB --> Stats["core::block_stats::build_block_entropy_stats"]
    Split --> Stats
    SB --> Enc["entropy::{literals, huf, sequences}: sub-block sections"]
    SB --> Raw["cctx::{no_compress_block, rle_compress_block}"]

The public module owns the option, its validation (ConfigError::OutOfRange for a target above 131072) and the CParameter pair. Everything else is private to compressor::core:

  • superblock is the port of zstd_compress_superblock.c and of the targetCBlockSize functions of zstd_compress.c.
  • block_stats is ZSTD_buildBlockEntropyStats and its two halves. They used to be private to split_block; the superblock also needs the descriptions they produce (hufDesBuffer, fseTablesBuffer, lastCountSize), so the metadata types keep them.
  • sequences::encode_slice writes the bitstream for a run of a block’s sequences and codes; sequences::encode is the whole block through it.
  • SeqStore::sequence_length is ZSTD_getSequenceLength.

Parameter semantics

flowchart TD
    V["(130, value)"] --> Z{"value == 0?"}
    Z -->|yes| None["TargetCBlockSize(None): no target"]
    Z -->|no| Raise["value = max(value, 1340)"]
    Raise --> B{"value <= 131072?"}
    B -->|no| Err["ConfigError::OutOfRange (min 1340, max 131072)"]
    B -->|yes| Some["TargetCBlockSize(Some(value))"]
    Some --> Apply["with_parameter: replaces any earlier target (last value wins)"]
    None --> Apply

ZSTD_CCtx_setParameter raises any non-zero value, negative ones included, to ZSTD_TARGETCBLOCKSIZE_MIN (1340) and only then checks the upper bound (ZSTD_TARGETCBLOCKSIZE_MAX, 131072). The typed parameter does the same and (i32, i32)::from returns the raised value, which is what ZSTD_CCtx_getParameter reports. EncoderConfig keeps the size as given and raises it when lowering to the core, and rejects one above 131072 when the compressor is built. ZSTD_generateSequences’ refusal of a target has no counterpart: mzstd has no such API.

The target takes the branch of ZSTD_compress_frameChunk before the post-sequence block splitter, so with a target with_post_block_splitter has no effect. The pre-matching splitter (blockSplitterLevel) still chooses the block boundaries, and with_max_block_size still caps the block that is cut into sub-blocks; the target’s own bound is the format’s 128 KiB either way. Streaming, dictionaries, long distance matching and the checksum only reach the superblock path through the blocks they hand it.

One block

sequenceDiagram
    participant FC as compress_frame_chunk
    participant SB as superblock::compress_block
    participant BS as build_seq_store
    participant ST as block_stats
    participant SUB as compress_sub_blocks
    FC->>SB: block, last?
    SB->>BS: match finding into the sequence store
    alt too short to compress
        SB->>SB: stored block
    else compressible candidate
        opt not the first block, under 4 sequences and 10 literals, all bytes equal
            SB-->>FC: RLE block
        end
        SB->>ST: tables of the whole block into next, with descriptions
        ST-->>SB: EntropyMetadata
        SB->>SUB: split into sub-blocks
        SUB-->>SB: bytes written, or 0, or DstSizeTooSmall
        alt written, smaller than block - min_gain + 3
            SB->>SB: confirm_block_state (prev and next swap)
        else 0, too large or out of room
            SB->>SB: one stored block, tables not confirmed
        end
    end
    SB->>SB: demote a Valid offset table to Check
    SB-->>FC: bytes written

Errors other than DstSizeTooSmall (a table that cannot be built) propagate; DstSizeTooSmall from the superblock, like a result of zero or one that does not save min_gain, falls back to a stored block, and that block may itself fail with DstSizeTooSmall. Nothing is confirmed on a fallback, so the next block inherits the previous tables and repeat offsets.

Splitting into sub-blocks

The tables of the whole superblock are built first (next); estimates come from them and from the block’s code histograms, in integer arithmetic scaled by BYTESCALE (256).

flowchart TD
    A["estimate the whole block: literals, sequences + tables, 3-byte header"] --> B{"estimate > input size?"}
    B -->|yes| Z["return 0: caller stores one block"]
    B -->|no| C["avgLitCost, avgSeqCost, nbSubBlocks = max((est + target/2) / target, 1)"]
    C --> D["for each of the first nbSubBlocks - 1 sub-blocks"]
    D --> E["size_block_sequences: take sequences until the budget is spent AND the sub-block pays for itself (first one: 120-byte header allowance)"]
    E --> F{"reaches the last sequence?"}
    F -->|yes| L
    F -->|no| G["compress_sub_block"]
    G --> H{"size > 0 and size < the bytes it regenerates?"}
    H -->|yes| I["commit: advance sequences, literals, input, output; entropy written once"]
    H -->|no| J["coalesce: the next attempt starts at the same sequence"]
    I --> D
    J --> D
    D -->|loop ends| L["last sub-block takes everything left"]
    L --> M{"compressed and smaller?"}
    M -->|yes| N["commit"]
    M -->|no| O["leave the rest"]
    N --> P{"data left over?"}
    O --> P
    P -->|yes| Q["stored block for the tail; repeat offsets rebuilt from the coded sequences"]
    P -->|no| R["done"]

The reference declares a budget supplement that is reset to zero on a commit and never raised; the port omits the variable.

Each sub-block is a complete compressed block. Which tables its header carries is state shared across the sub-blocks of a superblock:

stateDiagram-v2
    [*] --> Pending: write_literals_entropy = (hType == Compressed), write_sequences_entropy = true
    Pending --> Written: a sub-block that is kept carries the tables
    Pending --> Pending: sub-block rejected, or its literals were stored raw (literals) / old-decoder guard (sequences)
    Written --> Written: later sub-blocks use Repeat modes
    Pending --> Inherited: end, literals tables never written: next.huf = prev.huf
    Pending --> Rejected: end, sequence tables never written but needed: return 0
    Written --> [*]
    Inherited --> [*]
    Rejected --> [*]

A sub-block’s literals section (compress_sub_block_literals):

flowchart TD
    S["literals, write_entropy?"] --> K{"no literals, or hType Basic?"}
    K -->|yes| Raw["raw section (entropy not written)"]
    K -->|no| R{"hType Rle?"}
    R -->|yes| Rle["one-byte RLE section"]
    R -->|no| H["header size from the count, allowing 200 bytes when writing the table; table description copied first when hType Compressed"]
    H --> C["Huffman stream: one stream for a 3-byte header, four otherwise"]
    C --> Z{"stream did not fit?"}
    Z -->|yes| Zero["0: the sub-block does not compress"]
    Z -->|no| X{"repeating and not smaller, or the section outgrew the allotted header?"}
    X -->|yes| Raw
    X -->|no| Out["compressed (hType) or treeless (Repeat) section; entropy written"]

A sub-block’s sequences section writes the count, a head byte and, for the first one, the table descriptions; later ones are Repeat in all three streams. Two guards return 0 so the sub-block is merged or stored: the reference’s workaround for decoders up to 1.3.4 (a two-byte last table with a one-byte bitstream) and for decoders up to 1.4.0 (a body under four bytes).

Invariants

  • The superblock’s next.entropy is built once; a sub-block only reads it. next.rep is the repeat offsets after the whole block, unless a tail was stored, when it is rebuilt from prev.rep and the coded sequences.
  • A sub-block is kept only when it is smaller than what it regenerates, so the output never grows by more than block headers and the stored tail.
  • Sub-block boundaries fall between sequences; the literals that follow a sequence’s match belong to the next sequence, the block’s trailing literals to the last sub-block.
  • The costs sizing a sub-block read the raw 16-bit lit_length and ml_base fields, the lengths summed for a decision read SeqStore::sequence_length (with the 16-bit overflow escape), exactly as the reference does.
  • The destination handed to the superblock is the block’s output slice: its capacity bounds every sub-block, as dstCapacity does in the reference.

Verification

tests/compressor_target_block.rs compares frames with ZSTD_compress2 and ZSTD_compressStream2: levels -5 to 22 and 0; targets around 1, 1340, 2 KiB, 16 KiB, 64 KiB and 131072; sizes from 0 to 5 MiB around the block boundaries; text, noise, constant, structured, mixed, binary and patchwork inputs (blocks of different kinds in one frame); a randomized structured corpus; one-shot, slice, uninitialized and Vec outputs and every output capacity boundary; streaming with flushes, exact and unknown sizes and small output buffers; dictionaries (every attach mode) in one shot and streaming; long distance matching; the checksum and content size flags; the post and pre block splitters and maxBlockSize; every available backend; and reuse. tests/openzl_binding.rs checks every parameter ID and value, and the node’s parameter lists, against the reference. The parameter_parsing and differential AFL targets cover the parameter through the header shared by every target. Unit tests beside superblock and block_stats reach the paths a corpus rarely produces: RLE and raw literals, expansion past the allotted header, the two old-decoder guards, tables that were never written, and the estimators’ error arms.

Known gaps

  • The parallel compressor accepts a target and applies it inside every segment; its frames are valid, but like all parallel frames they are not those of the reference’s multithreaded mode.
  • Outputs that grow a Vec or zero uninitialized memory ahead of each block offer the superblock compress_bound(block) bytes, where ZSTD_compress2 into a ZSTD_compressBound buffer offers the rest of the frame. A sub-block that is attempted, would be rejected for not shrinking, and does not fit in the smaller room would make this crate store the block where the reference writes sub-blocks. It needs a sub-block larger than the room a compress_bound margin leaves; no input among the tests, including the capacity-boundary sweeps and the randomized corpus, produces one.

Dictionaries

This specification covers the shared types and the encoder; the decoder’s side (parsing into DecodeDictionary, frame-start setup, dictionary history) is in decompressor.

A dictionary is history both sides agree on before a frame starts. mzstd reads dictionaries in both forms zstd 1.5.7 does: raw content, and the Zstandard dictionary format (magic number 0xEC30A437, a 32-bit ID, entropy tables, three repeat offsets, content). Compression with a dictionary is byte-identical to the reference’s ZSTD_CCtx_loadDictionary_advanced (by copy) followed by ZSTD_compress2 or ZSTD_compressStream2, for every level, strategy and dictionary size.

Modules and public types

graph TD
    Dict["mzstd::dictionary (public)"] --> Common["DictionaryContent, DictionaryId, DictionaryError, layout()"]
    Dict --> Enc["compressor::dictionary: DictionaryBuilder, PreparedDictionary (compression)"]
    Dict --> Dec["DecodeDictionary, DictionaryRef (decompression)"]
    Enc --> CDict["compressor::core::cdict (private): CDict, insert_dictionary, load_c_entropy, load_dictionary_content"]
    CDict --> Read["compressor::core::entropy::read: FSE_readNCount, HUF_readStats, HUF_readCTable"]
    CDict --> Fills["fast / double_fast tagged fills, lazy::load_dictionary, opt::load_dictionary"]
    Engine["cctx::Engine::begin_with_dict"] --> CDict
    Finders["dictMatchState block compressors"] --> CDict
TypeRole
DictionaryContentAuto (format by magic number), Raw, Zstd (ZSTD_dct_*).
DictionaryIdA non-zero ID, as a Zstandard dictionary declares it and a frame header names it.
DictionaryErrorNotZstdFormat, Corrupted, and Config for an invalid encoder configuration.
DictionaryBuilderBorrowed bytes, a content type and an EncoderConfig; build copies and prepares.
PreparedDictionaryAn immutable CDict for one configuration: id, config, as_bytes, content, retained_bytes, AsRef<Self>. With decompression it also holds the decoder’s DecodeParts, so DictionaryRef::Prepared decodes its frames without a second copy of the bytes.
DecodeDictionary, DictionaryRefThe decoder’s parsed dictionary and the borrowed view decoder entry points take (Decode or Prepared).

Compressor gains compress_with_dictionary, _into, _to_slice, _to_uninit, start_with_dictionary, into_session_with_dictionary and, with std, writer_with_dictionary and reader_with_dictionary. EncoderSessionOwned<D = PreparedDictionary> owns its dictionary as any D: AsRef<PreparedDictionary> (an Arc shares one) and returns it from into_parts.

Ownership and size

PreparedDictionary is a single Box around its private Prepared contents: the CDict (its byte copy, MatchState and BlockState entropy tables, about 7.9 KiB inline before its heap tables), the configuration, the dictionary key and, with decompression, the decoder’s DecodeParts. The handle is therefore one pointer wide, and Option<PreparedDictionary> uses the box’s niche. An EncoderSessionOwned started without a dictionary carries None in that one word instead of reserving room for the tables (about 8 KiB per session in 0.2.4). Encoder entry points reach the tables through the crate-private PreparedDictionary::cdict; building a dictionary costs one extra allocation and every use one extra indirection at frame start.

classDiagram
    class PreparedDictionary {
        -inner: Box~Prepared~
        +id() Option~DictionaryId~
        +config() &EncoderConfig
        +as_bytes() &[u8]
        +content() DictionaryContent
        +retained_bytes() usize
        ~cdict() &CDict
        ~decode_view() DictView
        ~serves(&RequestedParams) bool
    }
    class Prepared {
        cdict: CDict
        config: EncoderConfig
        key: RequestedParams
        decode: DecodeParts
    }
    class EncoderSessionOwned~D~ {
        compressor: Box~Compressor~
        dictionary: Option~D~
        core: Core
    }
    PreparedDictionary *-- Prepared : Box
    Prepared *-- CDict
    Prepared *-- DecodeParts : decompression
    EncoderSessionOwned *-- PreparedDictionary : Option, default D

A prepared dictionary’s tables depend on the configuration’s level, explicit parameters, LDM switch and size hint, as the reference’s local CDict depends on requestedParams. A compressor accepts it when its own configuration matches apart from the frame options (checksum, content size); otherwise every dictionary entry point fails with EncodeError::DictionaryMismatch before touching the engine.

Preparing (ZSTD_createCDict_advanced2)

sequenceDiagram
    participant B as DictionaryBuilder::build
    participant C as CDict::new
    participant P as RequestedParams::cparams_for
    participant I as insert_dictionary
    participant E as load_c_entropy
    participant L as load_dictionary_content
    B->>B: validate config, lower to RequestedParams
    B->>C: copy of the bytes, content type
    C->>P: (size unknown, dict size, ZSTD_cpm_createCDict)
    P-->>C: tables' parameters (tagged fast/dfast hash logs capped at 24)
    C->>C: MatchState::reset_for_dictionary (no hash3, no optimal state)
    C->>I: tfp = CDict, dtlm = full
    alt fewer than 8 bytes
        I-->>C: nothing loaded, ID 0 (Zstd content: NotZstdFormat)
    else raw content
        I->>L: whole dictionary
    else Zstandard format
        I->>E: Huffman table, OF/ML/LL FSE tables, repeat offsets
        E-->>I: header size, repeat modes (Valid only for complete tables)
        I->>L: content after the header
    end

load_dictionary_content is ZSTD_loadDictionaryContent: it truncates a huge dictionary to a suffix (index space; 2^24 for tagged tables; then what the tables can index), updates the window, records loaded_dict_end, and fills the strategy’s tables:

StrategyDictionary tables (tfp = CDict)Context load (tfp = CCtx)
fastevery position, tagged (fill_cdict_hash_table)every third (dtlm_fast) or all (dtlm_full)
dfastlong and short tables, taggedas fast
greedy, lazy, lazy2row tags cleared then ZSTD_row_update, or ZSTD_insertAndFindFirstIndexsame
btlazy2, btopt, btultra(2)ZSTD_updateTree (sorted tree)same

Tagged entries hold the index above an 8-bit hash tag (ZSTD_writeTaggedIndex), which the dictMatchState finders compare before reading the dictionary.

The entropy section is read by straight ports of FSE_readNCount, HUF_readStats (with its FSE weight decoder) and HUF_readCTable, checked against the reference’s functions on mutated descriptions, so a malformed dictionary is rejected exactly when the reference rejects it.

Beginning a frame

An empty dictionary is no dictionary, whatever its content type (ZSTD_CCtx_loadDictionary_advanced with size zero clears it): the frame is begun as without one. Dictionaries of 1 to 7 bytes still select the dictionary’s parameters, as the reference’s do, but load nothing.

flowchart TD
    A[core::begin_frame] --> B{non-empty dictionary?}
    B -- no --> N["Engine::begin(resolve(pledged))"]
    B -- yes --> M["mode = attach if pledged ≤ cutoff(strategy) or unknown (ZSTD_shouldAttachDict)"]
    M --> R["applied = resolve_for(pledged, dict size, mode)"]
    R --> F{AttachPref}
    F -- ForceLoad --> L["begin_sized, insert_dictionary into the context (dtlm_fast, tfp CCtx)"]
    F -- attach --> AT["begin with the dictionary's parameters re-adjusted for the input; dictMatchState on; window starts at or after the dictionary's end; loaded_dict_end = dictLimit"]
    F -- copy --> CP["begin with the dictionary's parameters; copy tables (untagged), take its window"]
    AT --> S["dict_id, prev block state = dictionary's entropy and repeat offsets"]
    CP --> S

The cutoffs are attachDictSizeCutoffs: 8 KiB for fast, btultra and btultra2, 16 KiB for dfast, 32 KiB otherwise; a stream of unknown size always attaches. AttachPref (ZSTD_c_forceAttachDict) is internal: the public API always uses the default, and tests force each path to compare it with the reference.

Copy mode copies the row tables between their 64-byte aligned views (match_state::aligned_range), since each table’s view depends on its own allocation’s address.

Addressing the dictionary

The dictionary’s bytes are a window segment of their own (Segment::Dict). Segments carries them and the CDict for every call of a frame, so a session that borrows or owns a dictionary passes it on each step:

  • attach: the context’s window holds only input; BlockInput::dms carries the dictionary’s MatchState and a view of its content in the dictionary’s own indices. The finders translate between the two index spaces as the reference does.
  • copy and load: the dictionary is the window’s first segment, so the first input turns it into the extDict and the extDict finders read it.

Before each block, check_dict_validity (ZSTD_checkDictValidity) and enforce_max_dist drop an attached or loaded dictionary once the window has moved past it or the window stopped being contiguous with it (a stream’s ring buffer wrapping); overflow correction drops it too. select_block_compressor then picks the dictMatchState variant only while the dictionary is attached and the window has no extDict (ZSTD_matchState_dictMode).

The frame header names the dictionary’s ID in 1, 2 or 4 bytes.

Known gaps

  • Dedicated dictionary search (ZSTD_c_enableDedicatedDictSearch) and referencing a prefix (ZSTD_CCtx_refPrefix) are not public. The attach preferences (ZSTD_dictForceAttach, ForceCopy, ForceLoad) are EncoderConfig::with_dictionary_attach. (Engine::load_prefix is the prefix mechanism, used by the parallel compressor for most strategies’ overlaps.)
  • Dictionaries are not available to the parallel compressor or the C ABI.
  • Speed against zstd 1.5.7 (benches/dictionary.rs, 64 KiB trained dictionary, i7-13700KF, one core, 2026-10-15): 0.85-0.91x its time at levels 16 and 19 on 100 and 1000 records, 1.09-1.19x at levels 1-9, and 1.20-1.29x for one 107-byte record, where per-frame work dominates (the fast dictMatchState kernel executes about 1.6x the reference’s instructions on it).
  • A prepared dictionary serves one configuration; another configuration needs its own (EncodeError::DictionaryMismatch otherwise), unless it was built with DictionaryBuilder::with_level_parameters: that is the ZSTD_createCDict_advanced + ZSTD_CCtx_refCDict route, which any compressor accepts and which replaces the compressor’s level with the default one (OpenZL integration).
  • The optimal parser’s seeding reads FSE_getMaxNbBits for every literal and match length symbol. Past a dictionary table’s largest symbol the reference reads uninitialized workspace, so its own frames then depend on what the heap held before (an AFL campaign found such a dictionary: the reference’s frame changed in 20 of 20 runs after other compressions), while mzstd reads the zeros of a fresh table and is deterministic. Byte identity therefore holds only for dictionaries whose literal and match length tables describe every symbol, which ZDICT training produces; the compress_dictionary target checks identity only for those (optimal parser).

Verification

compressor::core::dict_tests and the finder modules’ tests compare frames with ZSTD_CCtx_loadDictionary_advanced + ZSTD_compress2 / ZSTD_compressStream2 for every level and strategy, every attach preference (forced through ZSTD_c_forceAttachDict), trained and raw dictionaries, tiny, multi-block and streamed inputs, LDM, every backend and the overflow-correction build; entropy::read is checked against FSE_readNCount and HUF_readCTable on mutated descriptions; tests/compressor_dictionary.rs covers the public API, mismatch errors and dictionaries the reference rejects; the compress_dictionary AFL target compares one-shot and streamed frames and dictionary acceptance with the reference and decodes with both decoders.

  • Training dictionaries (ZDICT_*) is out of scope; tests train with the reference through zstd-ffi.

Parallel compression

compressor::parallel (with std) compresses one frame with tasks the caller runs on its own threads or executor. It does not spawn threads. The frame is a standard Zstandard frame that any decoder accepts. Its bytes depend only on the configuration, the segment size and, for streams, the declared size and the flushes; not on the task count, the window or the order tasks run in. They are not the reference’s: neither the serial encoder’s frame nor the frame of zstd’s multithreaded mode (ZSTDMT).

Modules

graph TD
    Public["parallel (public): SegmentSize, TaskCount, ParallelConfig, ParallelCompressor, ScopedParallelBatch, OwnedParallelBatch"] --> PCore["parallel::core (private): FramePlan, compress_segment, compress_serial, EnginePool"]
    Source["parallel::source (public): RandomAccessSource, ArcBytesSource, SeekSource, FileSource, SourceIdentity"] --> PSource["parallel::core::source (private): Input, SharedSource, SeekReader"]
    Public --> PSource
    Session["parallel::session (public): ParallelEncoderSession(Owned), ParallelTaskQueue, OwnedParallelTask, statuses"] --> Stream["parallel::core::stream (private): Stream, Shared queue, StreamTask, Sink"]
    IO["parallel::io (public): ParallelEncoderWriter, ParallelEncoderReader"] --> Session
    Error["parallel::error (public): ParallelEncodeError, SourceIoError, io::Error conversion"]
    Stream --> PCore
    PCore --> Engine[compressor::core::cctx::Engine]
    PCore --> Serial[compressor::core::compress_once_into]
    IO --> Parts["compressor::io::EncoderReaderParts"]
TypeRole
SegmentSize64 KiB to 256 MiB, default 4 MiB: the input each segment covers.
TaskCount1 to 65536: how many tasks a slice batch is cut into, or a stream’s window.
ParallelConfigThe segment size.
ParallelCompressorAn EncoderConfig, a ParallelConfig and a pool of idle engines shared with its tasks.
ScopedParallelBatch / ScopedParallelTaskA whole input, borrowed for a scope or read from a source; each task compresses a run of consecutive segments.
OwnedParallelBatchScopedParallelBatch<'static>, from prepare_source: its tasks own a shared source handle and run on detached threads.
RandomAccessSourcelen, read_exact_at(offset, dst) and an optional identity; Send + Sync + 'static.
ArcBytesSource / SeekSource / FileSourceShared immutable bytes; a Read + Seek reader behind a mutex; a regular file read with positional reads.
SourceIdentity / SourceIoErrorA source’s version token; a source’s io::Error, shared so ParallelEncodeError stays Clone + Eq.
ParallelEncoderSession / ParallelEncoderSessionOwnedInput that arrives in chunks; borrows or owns the compressor and wraps one core::Stream.
ParallelTaskQueue / OwnedParallelTask / ParallelTasksThe stream’s queue handle, its 'static tasks and the blocking iterator over them.
ParallelEncoderStatus / ParallelProgressWhat one process call did and what the session needs next.
ParallelEncoderWriter / ParallelEncoderReaderstd::io adapters over a borrowed session.

Frame layout

flowchart LR
    H[frame header from FramePlan] --> S1["segment 1 blocks (none last)"]
    S1 --> S2["segment 2 blocks"]
    S2 --> Sn["… segment n blocks"]
    Sn --> E["empty last block"]
    E --> C["XXH64 checksum (optional)"]

FramePlan::new resolves the parameters once for the whole frame, with the total size when it is known, so every segment shares the window, strategy and block size. header is ZSTD_writeFrameHeader without a dictionary; epilogue writes an empty last block and, if enabled, the checksum of the whole input. An input that fits in one segment is instead compressed by compress_serial (compress_once_into), which is Compressor::compress.

Segments

sequenceDiagram
    participant T as task
    participant E as Engine (from the pool)
    T->>E: begin(plan.applied, checksum off, size unknown)
    alt lazy, lazy2, btlazy2
        T->>E: compress_continue(overlap) — header and blocks, discarded
        T->>E: entropy tables reset
    else every other strategy
        T->>E: load_prefix(overlap): window + tables, nothing written
        T->>E: compress_continue(empty) — frame header, discarded
    end
    T->>E: repeat offsets invalidated (ZSTD_invalidateRepCodes)
    T->>E: compress_continue(segment, not last) into the task's output
    E-->>T: the segment's blocks
    T->>T: return engine to the pool

A segment may reach back into the overlap bytes before it: an eighth of the window, or the whole window for btultra and btultra2, whose frames gain the most from distant history. The overlap is not capped by the segment size, so at level 22 (a 128 MiB window) a segment primes itself with up to 128 MiB of earlier input.

How a segment primes itself depends on the strategy (FramePlan::compresses_overlap), chosen by measurement (benches/parallel.rs parallel-corpora):

flowchart TD
    S{strategy} -->|fast, dfast, greedy, btopt, btultra, btultra2| L["load_prefix: insert the overlap into the tables"]
    S -->|lazy, lazy2| C1["compress the overlap, discard it: the lazy finders skip ahead through input that does not match, a load inserts every position"]
    S -->|btlazy2| C2["compress the overlap, discard it: compression sorts the tree lazily, a load sorts all of it"]

With four threads, 4 MiB segments and 16 MiB inputs (synthetic text, binary, random and mixed data, and the 5 MiB of vendored zstd sources; i7-13700KF), loading instead of compressing the overlap made levels 18-22 15-27% faster, with frames 0.01-0.09% larger (0.25-0.27% on the binary corpus): a compressed overlap left the optimal parser’s statistics primed for the segment’s first block, a loaded one does not. Levels 1-5 ran up to 27% faster (random input at level 1) and levels 16-17 1-7% faster, with frames within 0.12% of compressing the overlap. Loading made lazy and lazy2 up to three times slower on random input and btlazy2 9% slower on text, so those strategies keep compressing it. Fast and dfast load every third position (ZSTD_dtlm_fast), as ZSTDMT does: the full load only fills empty slots, so with the stale entries a pooled engine keeps it would make a segment depend on the engine’s history.

Engine::load_prefix loads the overlap the way ZSTDMT primes a job (ZSTD_compressBegin_advanced_internal with a raw-content prefix, ZSTD_dtlm_fast and forceWindow): load_dictionary_content makes the bytes history in the window and inserts them into the match finder’s tables (and LDM’s, when enabled) without compressing them, and the prefix remains subject to the window like any input. Overlap and segment are one slice, so the segment continues the prefix contiguously and the noDict finders run. The segment starts with empty entropy tables and repeat offsets that match nothing, so its first block refers to nothing the decoder has not seen; matches into the overlap stay valid, because the decoder holds those bytes as the previous segment’s output.

Scoped batches

prepare_slice(input, tasks) plans a whole borrowed input, and prepare_source(source, tasks) one read from a RandomAccessSource (see sources). Both go through one private prepare over a core::Input (Slice(&[u8]) or Source(Arc<dyn RandomAccessSource>)) and its length:

flowchart TD
    A{input fits in one segment?} -->|yes| S[one serial task: compress_serial, same bytes as Compressor::compress]
    A -->|no| B[split segments into at most `tasks` contiguous runs]
    B --> T[ScopedParallelTask per run]
    T --> F["finish / finish_into: run untaken tasks, order slots by index, assemble"]

Each task pushes (index, result) into a shared slot list exactly once: when it runs, or from its Drop when it is dropped unrun (TaskAbandoned) or unwinds while running (TaskPanicked; the panic still propagates, and the engine it held is discarded instead of returned). finish runs any task the caller did not take, orders the slots by index and assembles the frame; the first failing slot becomes BatchFailed(cause) and leaves out untouched. A slot count short of the task count (a task leaked with mem::forget) is InvalidState. compress is prepare_slice with one task, run on the calling thread by finish.

For a source, finish then checks that the source’s length and identity still equal those recorded at planning (SourceChanged, or SourceMetadata when the length cannot be read), and, for a frame with a checksum, reads the source once more in segment-sized chunks to hash it. Both happen before out is touched, so a failure leaves it as it was.

Sources

sequenceDiagram
    participant Caller
    participant Compressor as prepare_source
    participant Input as core::Input::Source
    participant Task
    participant Source as RandomAccessSource
    Caller->>Compressor: source.into() as Arc<S>
    Compressor->>Input: Arc<SharedSource<S>> as Arc<dyn RandomAccessSource>
    Input->>Source: len(), identity() — planning snapshot
    Compressor-->>Caller: OwnedParallelBatch ('static tasks)
    Caller->>Task: run on any thread
    loop each segment of the task
        Task->>Source: read_exact_at(segment start − overlap, buffer)
        Task->>Task: compress_segment(overlap + segment)
    end
    Caller->>Compressor: finish
    Compressor->>Source: len(), identity() — equal to the snapshot?
    opt checksum
        Compressor->>Source: read_exact_at per segment, into XXH64
    end

prepare_source<S: RandomAccessSource + ?Sized, T: Into<Arc<S>>> converts the argument once. The private SharedSource<S> bridge lets an unsized S (dyn RandomAccessSource) become the batch’s Arc<dyn RandomAccessSource>; tasks clone that handle, never the bytes. An Arc argument or a custom conversion may need S named, because Into<Arc<S>> admits more than one target.

A task reads each segment together with its overlap into one buffer, grown with Vec::resize and reused across the task’s segments, so a source batch holds at most one overlap plus one segment per running task. The serial fallback reads the whole (at most one-segment) source. A read failure is SourceRead { segment }, reported as the task’s result; a panic inside the source unwinds the task like any other (TaskPanicked). The frame is byte-identical to prepare_slice over the same bytes.

  • ArcBytesSource wraps Arc<[u8]>; a range past the end is UnexpectedEof.
  • SeekSource<R> (R: Read + Seek + Send, not Sync) holds the reader in core::SeekReader’s mutex: len seeks to the end, and each read seeks to its absolute offset and read_exacts under one lock, so tasks never see a cursor another task moved. A panic inside the reader poisons the mutex and later calls fail with an I/O error. It has no identity.
  • FileSource reads with FileExt::read_at (Unix) or seek_read (Windows), retrying Interrupted and short reads; other platforms return Unsupported. It accepts regular files only. Its identity is the length, modification time and, on Unix, device, inode and change time — it catches most rewrites but not every in-place write.

Streaming sessions

start(stream, window) and into_session(stream, window) build a core::Stream and its first QueueHandle. The borrowed session holds the compressor’s borrow only as a PhantomData; the stream itself holds what it needs (RequestedParams, the FramePlan, the segment size and a clone of the engine pool), so both sessions share one state machine.

classDiagram
    class Stream {
        flight: VecDeque~Entry~
        filling: Vec~u8~ (history + segment)
        history: usize
        in_flight: usize
        window: usize
        fragments: bool
        phase: Phase
        +process(input, Sink, Operation) ParallelProgress
        +wait()
    }
    class Shared {
        state: Mutex~QueueState~
        ready: Condvar
        completed: Condvar
    }
    class QueueState {
        tasks: VecDeque~StreamTask~
        done: Vec~(segment, result)~
        closed: bool
        cancelled: bool
        handles: usize
    }
    class StreamTask {
        work: Fragment | Serial
        data: Vec~u8~
        segment: u64
        +run()
    }
    Stream --> Shared
    Shared --> QueueState
    QueueState --> StreamTask
    StreamTask --> Shared : publishes once
    QueueHandle --> Shared

Input and dispatch

filling starts with history, the last overlap bytes of earlier input, followed by the segment being gathered. accept copies input until a segment is full; a full segment becomes a Fragment task whose data is the whole filling (moved, not copied), and the new filling is seeded with the tail the next segment may reach into. The first fragment is preceded by the frame header as an inline output entry. Dispatch stops when in_flight (parts with a task that are queued, running or not fully delivered) reaches the window; the rest of the input is left unconsumed.

A full first segment is not dispatched until a following byte or an End arrives, because a frame that turns out to fit in one segment takes the batch’s serial fallback: End with no fragment queued compresses the whole input as one Serial task, or inline when it is empty. This holds with and without a declared size. Without a declared size, a multi-segment stream resolves its plan for an unknown size, so its frame differs from the batch’s.

Ordered delivery and status

flowchart TD
    P[process] --> C{failed?}
    C -->|yes| F[Err StreamFailed cause]
    C -->|no| K["collect: move published results into entries; fail if the first entry not yet compressed is a failure"]
    K --> D1["drain Done entries in order into the Sink"]
    D1 --> O{phase Open?}
    O -->|yes| A["accept input; on all consumed: Flush dispatches the partial segment, End runs begin_finish"]
    A --> D2[drain again]
    O -->|no| S
    D2 --> S{status}
    S -->|front entry Done| NO[NeedsOutput]
    S -->|Finishing and nothing left| FIN["Finished: close the queue"]
    S -->|Open, all consumed, and Continue / Flush fully delivered| NI[NeedsInput]
    S -->|otherwise| PE[Pending]

process never blocks. Sink is implemented for [u8] and [MaybeUninit<u8>], so process and process_uninit are two instantiations of one function: same bytes, counts, statuses and errors, and they may be mixed. Delivery only writes output[..produced].

begin_finish checks a declared size, then chooses the serial fallback or queues the last partial segment (retried by a later End if the window is full) followed by the epilogue. Flush queues the partial segment early, which moves every later segment boundary, and reports NeedsInput only when nothing is gathered or in flight.

wait returns at once when a result was published since the last collection or when nothing runs; otherwise it sleeps on completed until a task publishes. Every task publishes exactly once, so a running part always ends a wait.

Lifecycle

stateDiagram-v2
    [*] --> Open: start / into_session (stream, window)
    Open --> Open: process Continue / Flush — accept, dispatch, deliver
    Open --> Finishing: End — last part and epilogue queued
    Open --> Failed: size mismatch, task failure, closed queue
    Finishing --> Finishing: deliver; Pending while parts run
    Finishing --> Finished: last byte delivered — queue closed
    Finishing --> Failed: task failure
    Failed --> Failed: every call returns StreamFailed(cause)
    Finished --> Finished: process returns Finished, consumes nothing

Queue, cancellation and failures

QueueHandle::recv blocks on ready until a task is queued or the queue closes; try_recv (next_task) does not block. The stream closes the queue with cancelled set when it finishes, fails or is dropped: queued tasks are dropped (each publishes TaskAbandoned, outside the lock), executors’ loops end, and a task taken before the close skips its work. Dropping the last handle closes the queue without cancelling: queued tasks report TaskAbandoned, and the next dispatch fails with TaskQueueClosed, so the session fails instead of waiting forever. Tasks executors took before the close remain theirs and must still be run or dropped; dropping every handle cannot resolve a task held forever.

Stopping executors is not itself an error, and there is no timeout. If a needed task stays queued, or is held by an executor without being run or dropped, wait can block indefinitely. The blocking adapters wait the same way, so the reader’s read and the writer’s write, flush, try_finish and finish can block too. Executors must stay able to run until finishing returns, and a bounded pool must leave capacity for the tasks instead of filling every worker with callers waiting for them.

A StreamTask publishes from run, or from Drop with TaskAbandoned (never ran) or TaskPanicked (unwound; its engine is dropped). collect fails the stream only when the first entry that is not yet compressed is a failure, so the reported cause is the first failing segment in input order, whatever order tasks finished in. Failures are wrapped as StreamFailed(Arc<cause>), and every later call repeats it.

I/O adapters

ParallelCompressor::writer(sink, stream, window) and reader(source, stream, window) return the adapter beside the queue, as start does. Both own only buffering; every decision belongs to the session, so they produce the session’s bytes.

sequenceDiagram
    participant U as caller
    participant W as ParallelEncoderWriter
    participant S as session
    participant X as executor thread
    U->>W: write(buf)
    W->>W: drain outbox to sink (errors returned, nothing consumed)
    loop until some input is accepted
        W->>S: process(buf, outbox, Continue)
        alt consumed > 0
            W->>W: drain (a sink error waits for the next call)
            W-->>U: Ok(consumed)
        else window full
            W->>W: drain
            W->>S: wait() if nothing was delivered
            X-->>S: task completes
        end
    end
  • The writer keeps compressed bytes in a 128 KiB outbox until the sink takes them, so short writes, Interrupted, WouldBlock and sink errors lose nothing. Write::flush is Operation::Flush followed by the sink’s flush; try_finish repeats End, waiting whenever a step delivered nothing, and finish returns the adapter inside FinishError for a retry. The session is boxed so FinishError<Self> stays small.
  • The reader fills a 128 KiB buffer from the source, calls process with Continue (or End after the source’s end) straight into the caller’s slice, and waits when the session is Pending with nothing produced. into_parts returns the source and the bytes read but not accepted as EncoderReaderParts.
  • Both block in wait, so the queue must run on other threads.

Errors

ParallelEncodeError is #[non_exhaustive]:

VariantWhen
Encode(EncodeError)A segment or serial frame failed in the engine (a core bug for valid input).
InputSizeMismatch { declared, actual }A stream’s input contradicts its declared size.
InvalidStateA batch task never reported (leaked).
BatchFailed(Arc<cause>)A batch task failed; the first failing task’s cause.
StreamFailed(Arc<cause>)A stream failed, on this call or earlier.
TaskQueueClosedEvery queue handle was dropped and the stream had a task to queue.
TaskAbandoned { segment }A task was dropped without running.
TaskPanicked { segment }A task unwound while running.
SourceMetadata(SourceIoError)A source’s length could not be read, at prepare_source or at finish.
SourceRead { segment, source }A source read failed in a task (inside BatchFailed) or while hashing for the checksum.
SourceChangedA source’s length or identity differs at finish from planning.

From<ParallelEncodeError> for io::Error takes the kind of the root cause through StreamFailed and BatchFailed (InvalidInput for size mismatches and Bound, WriteZero for OutputTooSmall, a source failure’s own kind, InvalidData for SourceChanged, Other otherwise) and keeps the error as the source. It never produces Interrupted or WouldBlock, which std::io would retry. The wrapped cause’s source in the chain is the Arc<ParallelEncodeError> itself. SourceIoError is transparent: it displays as its io::Error, which AsRef reaches, and two compare equal when their kinds and messages do.

Invariants

  • Segment boundaries depend only on the segment size and the flushes, so the frame is the same for every task count, window, chunking and execution order. A stream with a declared size and no flush produces the batch frame; so does any stream whose input fits in one segment.
  • Every task publishes exactly once (run, drop or unwind), so wait and batch assembly never wait for a task that cannot report.
  • in_flight <= window; filling holds at most an overlap plus one segment, allocated once per segment at that size.
  • process writes exactly output[..produced].
  • Engines are reused through the pool; reuse does not change output (see compressor). An engine that unwound is never returned.
  • tests/parallel.rs, tests/parallel_stream.rs, tests/parallel_io.rs and the parallel fuzz target check these, and that frames decode with the reference decoder.

Known gaps

  • Frames are not ZSTDMT-identical (segments are not ZSTDMT jobs, LDM runs per segment rather than serially, overlaps follow their own sizes, and lazy, lazy2 and btlazy2 compress their overlap instead of loading it), and there is no job-size or overlap-log parameter.
  • Segment size does not follow the window as ZSTDMT’s job size does: at levels 18-22 small segments each prime themselves with a whole window of earlier input, which can cost more than compressing the segment.
  • The parallel compressor takes no dictionary.
  • A stream copies its input into segments, and each part’s blocks through the caller’s buffer; the batch reads its slice in place, and a source batch copies each segment and its overlap into a per-task buffer.
  • A source batch with a checksum reads the source twice. Length and identity checks cannot prove a source unchanged, and there is no option to skip them (mbrotli’s SourceConsistency).
  • A task held by the caller and never run or dropped blocks wait forever.
  • There is no wait with a timeout.

Encoder verification

The encoder’s contract is byte identity with zstd 1.5.7. The reference is built from the vendored submodule by zstd-ffi and is used only in tests, benchmarks and fuzzing. zstd_ffi::oracle wraps it in safe functions, and zstd_ffi::corpus generates reproducible inputs.

flowchart LR
    Corpus[corpus: text, binary, mixed, random, vendored golden files] --> Ours[mzstd]
    Corpus --> Ref["zstd 1.5.7 (zstd-ffi)"]
    Params[levels / advanced parameters / call schedule] --> Ours
    Params --> Ref
    Ours --> Cmp{bytes equal?}
    Ref --> Cmp
    Ours --> Dec[reference decoder and mzstd decoder]
    Dec --> RT{round trip?}

Oracle mapping

mzstdReference
Compressor::compress and the other one-shot callsZSTD_compress2 with the same ZSTD_c_* parameters (oracle::compress2, compress2_capacity)
EncoderSession::process sequencesZSTD_compressStream2 with the same chunks, output capacities and directives (oracle::compress_stream, compress_stream_with_output)
mzstd_compressZSTD_compress (oracle::compress, compress_capacity)
mzstd_compress_bound, Compressor::max_compressed_sizeZSTD_compressBound
entropy stages (unit tests)FSE_normalizeCount, FSE_optimalTableLog, HUF_buildCTable, HUF_optimalTableLog through zstd-ffi/shim/zstd_shim.c
resolved parameters (unit tests)ZSTD_CCtxParams after ZSTD_CCtx_init_compressStream2, through the shim

Suites

SuiteWhat it establishes
core::testsLevels 1 to 22 and -1, -2, -5, -100 and -131072 on the standard corpus match ZSTD_compress2. This exercises the pre-splitter, the post splitter, btultra2’s first-block pass, and RLE and raw blocks.
core::advanced_testsRandom advanced configurations match (300 per release run; MZSTD_CASES and MZSTD_SEED widen and vary the run): window, hash, chain and search logs, minimum match, target length, strategy, LDM parameters, frame flags. Also LDM sequences cut at block boundaries.
core::stream_testsStreams match ZSTD_compressStream2 for random chunk and flush schedules, pledged sizes, windows that wrap (extDict), and small windows that select every hash-chain and binary-tree extDict finder.
Stage unit testsHistogram, FSE and Huffman tables, the bit writer, sequence codes, parameter tables and bounds, window updates and overflow correction, index reduction, and frame header layout.
tests/compressor_identity.rsThe public API produces the reference’s bytes for one-shot calls and sessions, including Compressor::default.
tests/parallel.rsParallel frames decode and do not depend on the task count or order; batch task failures, abandonment and leaks.
tests/parallel_stream.rsParallel sessions: byte identity with the batch for any chunking and window, the serial fallback, windows, ordered delivery, flushes, declared sizes, closed queues, abandoned tasks, process_uninit and the owned session.
tests/parallel_io.rsParallelEncoderWriter and ParallelEncoderReader: batch identity, flushes, short and refusing sinks, FinishError retries, source failures, closed queues and into_parts.
zstd-ffi/tests/reuse_probe.rsA reused reference context writes the same frames as a fresh one, which justifies comparing a reused mzstd engine against fresh reference calls.
mzstd-ffi/tests/c_abi.rsThe C ABI matches ZSTD_compress, including short destinations.
fuzz targetsSee fuzzing.

Overflow correction mode

Index overflow correction normally needs gigabytes of input. Building with MZSTD_OVERFLOW_CORRECT_FREQUENTLY=1 makes both sides run it as early as possible: mzstd through window::OVERFLOW_CORRECT_FREQUENTLY, and the C library through ZSTD_WINDOW_OVERFLOW_CORRECT_FREQUENTLY, which zstd-ffi’s build script sets from the same variable. The same differential suites then cover the correction path:

MZSTD_OVERFLOW_CORRECT_FREQUENTLY=1 CARGO_TARGET_DIR=target-ocf \
    cargo test --release --lib compressor::core

Running

The suites are slow without optimizations. Run them with --release:

cargo test --workspace --release
CARGO_INCREMENTAL=1 cargo llvm-cov --workspace --release --summary-only --ignore-filename-regex '/zstd-ffi/|/vendor/'

CARGO_INCREMENTAL=1 matters. Without incremental compilation, a release build copies small functions into each test binary that calls them, and calls through those copies are not credited to src/. The report then shows public functions as missed even though tests call them.

Destinations the reference overruns

zstd 1.5.7 does not always stay inside the destination of a one-shot call. With ZSTD_c_targetCBlockSize, ZSTD_compress2 compresses straight into the caller’s buffer, and the sub-block writer copies the Huffman and FSE table descriptions without checking the room left; the writers after it then see a wrapped-around capacity. oracle::compress2_capacity therefore allocates the capacity plus a guard band of ZSTD_compressBound(len) and one block, fills it with a marker byte, passes only the capacity to the reference, and reports CAPACITY_OVERRUN when the band changed or the reported size exceeds the capacity. Streaming calls are not affected: the reference compresses into its own buffer unless the room left covers ZSTD_compressBound of the input.

flowchart TD
    A["compress2_capacity(input, params, capacity)"] --> B["buffer = capacity + compressBound(len) + 128 KiB, filled with 0xA5"]
    B --> C["ZSTD_compress2(dst, capacity, ...)"]
    C --> D{"guard band changed, or size > capacity?"}
    D -->|yes| E["Err(CAPACITY_OVERRUN)"]
    D -->|no| F{"reference error?"}
    F -->|yes| G["Err(error name)"]
    F -->|no| H["Ok(frame)"]

mzstd never writes past its destination; at every capacity the reference overruns it returns EncodeError::OutputTooSmall (tests/compressor_target_block.rs, capacities_the_reference_overruns_fail_with_output_too_small), so the comparisons treat an overrun as the reference failing.

Known gaps

  • Comparing against the reference catches any change in output. It cannot show that the reference’s own heuristics are good.
  • Inputs above a few megabytes are covered only by the parallel tests and the overflow-correction mode, not by the level matrix.
  • Where zstd 1.5.7 overruns a target-block destination, there is no reference result to compare with; only mzstd’s OutputTooSmall is checked there.

OpenZL integration

tests/openzl_binding.rs compares the calls OpenZL’s zstd node makes (magicless frames, parameters by ID, the per-element flush stream, referenced ZSTD_CDicts, ZSTD_getCParams, ZSTD_compressBound) with the oracle shim mzstd_shim_openzl_encode byte for byte; see OpenZL integration.

Decompression

The independent decoder, its fast paths and its output contracts.

SpecificationScope
DecompressorPublic decoder API, dictionaries, streaming state machine, history, block regeneration and compatibility with the reference decoder.
Decoder uninitialized outputprocess_uninit and decompress_to_uninit: the write-only sink, the one-shot BorrowedBuf, the C ABI and their contract.
Decoder fast pathsSIMD dispatch, bit containers, Huffman stream scheduling, FSE tables, sequence loops, wildcopies and measurements.
Decoder verificationDifferential, golden, corruption and API suites for the decoder.

Architecture overview and module map

Decompressor

The decompression feature enables the public decompressor module: a safe-Rust Zstandard (RFC 8878) decoder with one-shot, incremental and std::io APIs, with or without a dictionary. The public module owns configuration, decoder ownership, sessions, typed errors and the I/O adapters; crate::dictionary owns the public dictionary types; the private decompressor::core owns parsing and regeneration. The decoder needs only alloc; the io adapters and the std::io::Error conversion exist unless no_std is enabled. There is no unsafe code. Compressed blocks run fearless_simd kernels on the Backend chosen when the decoder is built; the fast paths are described in decoder fast paths.

The reference this decoder is checked against is zstd 1.5.7, vendored in zstd-ffi/vendor/zstd; the format text is zstd_compression_format.md. How it is verified is described in decoder verification.

Module map

graph TD
    Lib[mzstd root re-exports] --> API
    subgraph public[decompressor]
        API[config / decoder / session / error]
        IO[io: DecoderReader / DecoderWriter]
    end
    IO --> API
    IO --> Finish[crate::finish_error::FinishError]
    API --> Retention[crate::RetentionPolicy]
    API --> Stream
    API --> Dict["crate::dictionary: DecodeDictionary, DictionaryRef, DictionaryId"]
    Dict --> DCore
    subgraph core[decompressor::core, private]
        Stream[stream: Stream state machine + decode_all] --> Frame[frame: FrameHeader, FrameDecoder]
        Stream --> History[history: output buffer = window]
        Frame --> DCore[dictionary: DecoderEntropy, DictView]
        Frame --> Block[block: BlockHeader, BlockDecoder]
        Block --> Literals[literals: LiteralsDecoder, Literals]
        Block --> Sequences[sequences: SequenceTables, execute]
        Literals --> Huffman[huffman: HuffmanTable, X1 and pair rounds]
        Huffman --> HufRef["huffman::reference: the reference's stream-end verdicts, TableKind"]
        Huffman --> Fse[fse: distributions, FseTable, SeqTable, weights]
        Sequences --> Fse
        Sequences --> Copy[copy: wildcopies, short-offset shuffles]
        Huffman --> Bits[bits: BitContainer, Sentinel, Reverse / Forward readers]
        Stream --> Ahead[ahead: WriteAhead, next block's output prefetch]
        Huffman --> Ahead
        Fse --> Bits
        Sequences --> Bits
    end
    API --> Backend[crate::Backend: fearless_simd level]
    Frame --> Xxh[shared::xxhash::Xxh64]
    Core2[core modules] -.-> Format[shared::format wire constants]

shared::format (wire constants, length-code extra bits, predefined distributions, highbit32) and shared::xxhash are shared with the encoder; everything else is decoder-private. Length-code baselines are derived at compile time from the shared extra-bit tables (sequences::baselines).

Public API

classDiagram
    class Decompressor {
        +new(DecoderConfig) Decompressor
        +builder(DecoderConfig) DecompressorBuilder
        +config() DecoderConfig
        +reconfigure(DecoderConfig)
        +retention() RetentionPolicy
        +backend() Backend
        +retained_bytes() usize
        +trim(RetentionPolicy)
        +decompress(src) Vec~u8~
        +decompress_into(src, Vec) Range
        +decompress_to_slice(src, slice) usize
        +decompress_to_uninit(src, uninit slice) usize
        +start() DecoderSession
        +into_session() DecoderSessionOwned
        +reader(R) DecoderReader
        +writer(W) DecoderWriter
        +decompress_with_dictionary(dict, src) Vec~u8~
        +decompress_with_dictionary_into(dict, src, Vec) Range
        +decompress_with_dictionary_to_slice(dict, src, slice) usize
        +decompress_with_dictionary_to_uninit(dict, src, uninit slice) usize
        +start_with_dictionary(dict) DecoderSession
        +into_session_with_dictionary(D) DecoderSessionOwned~D~
        +reader_with_dictionary(R, dict) DecoderReader
        +writer_with_dictionary(W, dict) DecoderWriter
    }
    class DecodeDictionary {
        +new(bytes, DictionaryContent) Result
        +try_from(bytes) Result
        +id() Option~DictionaryId~
        +content_len() usize
        +retained_bytes() usize
    }
    class DictionaryRef {
        <<enum, Copy>>
        Decode(&DecodeDictionary)
    }
    class DecompressorBuilder {
        +with_retention(RetentionPolicy)
        +with_backend(Backend)
        +build() Decompressor
    }
    class DecoderConfig {
        +DEFAULT
        +with_frame_mode(FrameMode)
        +with_limits(DecodeLimits)
    }
    class DecodeLimits {
        +DEFAULT
        +with_max_window_log(u8) Result
        +with_max_input_bytes(Option)
        +with_max_output_bytes(Option)
        +max_window_size() u64
    }
    class FrameMode {
        <<enum>>
        Concatenated
        Single
    }
    class DecoderSession {
        +process(input, output, DecodeOperation) DecodeProgress
        +process_uninit(input, uninit output, DecodeOperation) DecodeProgress
        +flush(output)
        +finish(output)
        +is_finished() bool
        +total_in() u64
        +total_out() u64
        +frames_decoded() u64
        +window_size() Option~u64~
    }
    class DecoderSessionOwned~D = DecodeDictionary~ {
        +process / process_uninit / flush / finish
        +reinit()
        +into_decompressor() Decompressor
    }
    Decompressor --> DecoderConfig
    DecoderConfig --> FrameMode
    DecoderConfig --> DecodeLimits
    DecompressorBuilder --> Decompressor
    DecoderSession --> Decompressor : borrows
    DecoderSession --> DictionaryRef : borrows, optional
    DecoderSessionOwned --> Decompressor : owns
    DecoderSessionOwned --> DecodeDictionary : owns D, optional
    DictionaryRef --> DecodeDictionary
  • Construction. Decompressor::new, Default, From<DecoderConfig> and Decompressor::builder(config).with_retention(..).with_backend(..).build() are infallible (the backend defaults to the best detected level): every configuration value is validated where it is built (DecodeLimits::with_max_window_log returns DecodeConfigError). Nothing is allocated until the first decode.
  • DecoderConfig is a Copy value with DEFAULT, Default and From<DecodeLimits> / From<FrameMode>.
    • FrameMode::Concatenated (default) decodes every frame until the input ends, as ZSTD_decompress does; at least one frame (Zstandard or skippable) is required.
    • FrameMode::Single decodes exactly one frame, Zstandard or skippable, and stops: sessions leave the rest unconsumed, one-shot calls report TrailingData. A skippable frame ends the operation with no output, as ZSTD_decompressStream reports a frame complete (returns 0) after either kind; a reader of single-frame members therefore sees a trailing skippable frame, or a stream of only skippable frames, as complete empty members, and a skippable frame before a Zstandard frame as its own member.
  • DecodeLimits: the window limit (default 2^27, the reference’s ZSTD_WINDOWLOG_LIMIT_DEFAULT; 10..=31) and optional input and output byte budgets over a whole operation.
  • One-shot. decompress returns a new vector; decompress_into appends and returns the appended range, truncating back on error; decompress_to_slice fills a caller slice and reports OutputTooSmall with the slice completely filled. decompress_to_uninit does the same into &mut [MaybeUninit<u8>] and writes nothing past the result; see uninitialized output.
  • Incremental. start() borrows the decoder, into_session() owns it. Both run the same core::Stream state machine; see below. Each has process_uninit, the same call into &mut [MaybeUninit<u8>], which may be mixed with process and writes exactly output[..produced].
  • Retention. RetentionPolicy (shared with the encoder) is applied after each one-shot operation, when a session’s operation finishes or fails, when the next operation starts, and by DecoderSessionOwned::{reinit, into_decompressor}. trim applies a policy once; releasing drops every buffer but keeps the session state, so a finished session still reports Finished.
  • I/O. reader(R) and writer(W) (module decompressor::io) own one 8 KiB buffer each; a failed DecoderWriter::finish returns the writer in the crate-wide io::FinishError.
  • Dictionaries. Every entry point has a *_with_dictionary twin taking impl Into<DictionaryRef>; see Dictionaries. DecoderSession<'d> keeps its single lifetime: start_with_dictionary borrows the dictionary for 'd too. DecoderSessionOwned<D> owns any D: AsRef<DecodeDictionary> (an Arc shares one); D defaults to DecodeDictionary, so DecoderSessionOwned without parameters still names the plain owned session.

Errors

graph LR
    Kind[InvalidDataKind] -->|From| Err[DecodeError]
    Entropy[bits / fse / huffman / literals / sequences / block] -->|Result&lt;_, InvalidDataKind&gt;| Frame
    Frame[frame / stream] -->|DecodeError| Session
    Session[session::process] -->|DecodeFailure: error + consumed + produced| Caller
    OneShot[decompress*] -->|DecodeError| Caller
    Err -->|From| Io[std::io::Error]
    Session -->|io adapters| Io
  • Entropy-level code returns InvalidDataKind, a #[non_exhaustive] enum naming the malformed structure: Magic, FrameHeader, SkippableFrame, BlockType, BlockSize, LiteralsSection, HuffmanTable, HuffmanStream, FseTable, SequencesSection, SequenceStream, LiteralsOverrun, Offset.
  • The frame and stream layers return DecodeError (#[non_exhaustive], thiserror): InvalidData { kind } (with kind as its source), UnexpectedEndOfInput, TrailingData, OutputTooSmall, ChecksumMismatch, ContentSizeMismatch, DictionaryUnsupported (the frame names a dictionary and the operation has none), DictionaryMismatch { expected, frame } (the operation’s dictionary declares another ID, or none), WindowLimitExceeded, InputLimitExceeded, OutputLimitExceeded, AllocationFailed, InvalidState.
  • Building a DecodeDictionary returns crate::dictionary::DictionaryError: NotZstdFormat or Corrupted. The core’s entropy parser reports an InvalidDataKind, which the dictionary layer maps to Corrupted.
  • Sessions wrap errors in DecodeFailure { error, consumed, produced }, the exact progress of the failing call; From<DecodeFailure> recovers the error. Failures are terminal for the operation; the decoder is reusable.
  • From<DecodeError> for std::io::Error maps format errors to InvalidData, truncation to UnexpectedEof, contract violations to InvalidInput, allocation failure to OutOfMemory and limits to Other.

Streaming state machine

core::stream::Stream is a resumable parser over arbitrary input pieces. Every header, and each block’s whole content (at most 128 KiB), is collected in a scratch buffer before it is acted on, so the parsers below it never see partial data and never need to suspend.

stateDiagram-v2
    [*] --> Magic
    Magic --> FrameHeader: 0xFD2FB528
    Magic --> SkippableSize: 0x184D2A5?
    Magic --> Finished: Finish, no input left, >= 1 frame (Concatenated)
    Magic --> Failed: other magic
    SkippableSize --> Skipping: size <= u32::MAX - 8
    Skipping --> Magic: payload skipped (Concatenated)
    Skipping --> Done: payload skipped (Single)
    FrameHeader --> BlockHeader: header parsed and within limits
    BlockHeader --> BlockContent: type and size valid
    BlockContent --> BlockHeader: block regenerated, not last
    BlockContent --> Checksum: last block, checksum flag
    BlockContent --> FrameEnd: last block, no checksum
    Checksum --> FrameEnd: XXH64 low 32 bits match
    FrameEnd --> Magic: Concatenated
    FrameEnd --> Done: Single
    Done --> Finished: output delivered
    FrameHeader --> Failed
    BlockHeader --> Failed
    BlockContent --> Failed
    Checksum --> Failed
    Finished --> [*]

FrameEnd is not a stored state: end_frame counts the frame and picks Magic or Done directly.

Every process call first delivers pending output from the history; it returns NeedsOutput while any remains, so a new block is only decoded once the previous one is fully delivered. Each state then either has enough bytes to advance or returns NeedsInput.

The incremental contract

stateDiagram-v2
    [*] --> Active: start / into_session / reinit
    Active --> Active: Process -> NeedsInput / NeedsOutput
    Active --> Finishing: first Finish latches total_in + input.len
    Finishing --> Finishing: Finish with exactly the unconsumed suffix
    Active --> Complete: Single frame done and delivered
    Finishing --> Complete: frames complete at end of input
    Active --> Failed: format, limit or check error
    Finishing --> Failed: truncation, error, or changed suffix / Process after Finish
    Complete --> Complete: zero progress, Finished
    Failed --> Failed: InvalidState
  • Process with empty input only drains output; it never ends the input.
  • In Concatenated mode a frame boundary is NeedsInput until Finish confirms the input ends there. Finish with the input exhausted inside a frame, or before any frame, is UnexpectedEndOfInput.
  • The input budget slices each call’s input to what remains of the budget; if the decoder then still needs input, the call fails with InputLimitExceeded. The output budget is checked after each block is regenerated, before any of it is delivered.

One call

sequenceDiagram
    participant C as Caller
    participant S as Stream::process
    participant H as History
    participant F as FrameDecoder
    participant B as BlockDecoder
    C->>S: input, output, operation
    S->>S: check failed / finished / Finish contract, apply input budget
    loop until NeedsInput, NeedsOutput or Finished
        S->>H: deliver pending into output
        alt output still pending
            S-->>C: NeedsOutput
        end
        S->>S: fill scratch for the current state
        alt block content complete
            S->>H: prepare(window, block_max): compact if past window + slack, ensure room
            S->>F: decode_block(backend, header, scratch, buffer, start)
            F->>B: raw / RLE / literals + sequences (SIMD dispatch)
            B-->>F: end of the block's output
            S->>H: commit(end)
            F->>F: XXH64 update, content-size check
            S->>S: output budget check
        end
    end
    S-->>C: DecodeProgress or DecodeFailure (consumed, produced)

History and window

core::history::History is one buffer that holds the frame’s retained history followed by undelivered output, kept initialized past its fill mark with room for a block and the copies’ 32-byte overlength. Blocks are regenerated after the fill mark, and matches copy from the same buffer, so history never wraps. Before each block (all output delivered), if the buffer is longer than window + max(window, 128 KiB) it drops everything but the last window bytes. Compaction therefore moves each byte at most once on average, and the buffer never exceeds 2 * window + 128 KiB (window >= 128 KiB): the retained window, a window of slack and one block. A single-segment frame’s window is its content size, so its history is never compacted.

The one-shot driver (Stream::walk, behind decode_all and decode_to_slice) parses straight from the input slice, with no scratch copies, and regenerates into the caller’s vector or slice, which serves as history; each frame’s history starts at its first byte in the output, so a decompress_into prefix is never referenced. decompress_to_slice decodes straight into dst when every frame declares a content size that fits, and otherwise (or on any error) takes the incremental path, which defines its results.

Dictionaries

A dictionary is parsed once, when a DecodeDictionary is built (ZSTD_createDDict_advanced and ZSTD_loadEntropy_intoDDict), and borrowed by each operation; the decoder keeps no reference to it between calls, so Stream::process, decode_all and decode_to_slice take it as an Option<DictView> argument on every call.

flowchart TD
    Bytes[bytes, DictionaryContent] --> Empty{empty?}
    Empty -->|yes| RawParts[id 0, no tables]
    Empty -->|no| Layout{dictionary::layout}
    Layout -->|Raw, or Auto without magic or under 8 bytes| RawParts
    Layout -->|Zstd requested, no magic| E1[NotZstdFormat]
    Layout -->|magic + ID| Load["DecoderEntropy::load (ZSTD_loadDEntropy): Huffman tree, OF / ML / LL FSE tables, 3 repeat offsets"]
    Load -->|malformed table, log too large, rep 0 or beyond the content after it| E2[Corrupted]
    Load --> ZParts[id, DecoderEntropy with pair rows prebuilt]
    RawParts --> Parts[DecodeParts]
    ZParts --> Parts
    Parts --> Owned["DecodeDictionary: Box of bytes + DecodeParts"]
    Owned -->|DictionaryRef::view| View["DictView: content = all bytes, id, Option of entropy"]
  • parse_decode_parts(bytes, content) (crate-private, in dictionary::decode) returns DecodeParts { id, entropy } without holding the bytes; DecodeParts::view(bytes) pairs them with whatever owns them. DecodeDictionary is one such owner; PreparedDictionary (with compression) is the other, and DictionaryRef::Prepared views its bytes the same way.
  • As in the reference’s ZSTD_DDict, all of a dictionary’s bytes are history, the Zstandard format’s magic number, ID and tables included; encoders only reference the content after the tables.
  • The Huffman table’s pair rows are built when the dictionary is parsed, so decoding reads the shared table without modifying it (HuffmanTable::decode_streams_shared).

Frame start

sequenceDiagram
    participant S as Stream / walk
    participant F as FrameDecoder::begin
    participant B as BlockDecoder::reset
    participant D as FrameDecoder::decode_block
    participant Q as SequenceTables::execute
    S->>F: header, limits, Option of DictView
    alt frame names ID f, no dictionary
        F-->>S: DictionaryUnsupported
    else frame names ID f, dictionary declares another or none
        F-->>S: DictionaryMismatch
    end
    F->>B: Option of DecoderEntropy
    B->>B: literals tree = Dictionary or Missing; LL/OF/ML slots = Dictionary or Empty
    B->>B: repeat offsets = dictionary's or 1, 4, 8
    loop each compressed block
        S->>D: content, out, history range, same DictView
        D->>D: dictionary content reachable iff frame output so far < window
        D->>Q: BlockContext: history start, window, dictionary content, entropy
        Q->>Q: Repeat / treeless modes read the dictionary's tables until the frame replaces them
        Q->>Q: offsets past the frame's output take execute_exactly, which copies from the dictionary's tail
    end
  • IDs follow ZSTD_decodeFrameHeader: a frame without a dictionary ID decodes with any dictionary or without one; a frame with ID f needs a dictionary declaring f. Raw content declares no ID.
  • Entropy follows ZSTD_copyDDictParameters: a Zstandard-format dictionary sets the reference’s litEntropy and fseEntropy, so the frame’s first treeless literals block and its first repeat-mode sequence tables use the dictionary’s tables (TreeSource::Dictionary, SlotContent::Dictionary), and its repeat offsets start at the dictionary’s. A block that describes its own tables replaces them for the rest of the frame. Raw dictionaries leave the frame-start state of a frame without a dictionary.
  • History. The dictionary’s content is a separate segment before the frame’s first byte (the reference’s extDict): it is never copied into the output or the streaming history. A block may reach into it while the frame’s output before the block is below the frame’s window, and only when the executor’s history starts at the frame’s first byte; an offset may then reach output + dictionary length bytes back, whatever the window, as oLitEnd - virtualStart bounds it in the reference. The reference encoder references a dictionary only from blocks that end within one window of it (ZSTD_checkDictValidity), and the first window is never compacted out of the streaming history, so every frame it writes decodes. Past the first window, offsets are bounded by the window as without a dictionary.
  • Hot loop. The fast path’s offset test is unchanged; a sequence that fails it calls the cold execute_exactly, which is where the dictionary’s reach and the two-segment copy live. Frames without a dictionary execute the same instructions as before (see decoder fast paths).

Block regeneration

flowchart TD
    Header[Block header: last, type, size] -->|reserved type| E1[BlockType]
    Header -->|size > min window, 128 KiB| E2[BlockSize]
    Header --> Raw[Raw: copy content]
    Header --> Rle[RLE: repeat one byte size times]
    Header --> Compressed
    Compressed --> Lit{Literals type}
    Lit -->|Raw| Borrow[borrow from block]
    Lit -->|RLE| Fill[fill literal buffer]
    Lit -->|Compressed| Tree[read Huffman tree: direct or FSE weights] --> Streams
    Lit -->|Treeless| Prev{previous tree?} -->|no| E3[LiteralsSection]
    Prev -->|yes| Streams[decode 1 stream or 4 with jump table; see Huffman stream ends]
    Borrow --> Seq
    Fill --> Seq
    Streams --> Seq[Sequences header: count, modes]
    Seq -->|count 0| Tail[append literals; no trailing bytes allowed]
    Seq --> Tables[LL, OF, ML tables: predefined / RLE / FSE / repeat]
    Tables --> Loop[decode offset, match and literal lengths; update states]
    Loop --> Exec[append literals; check offset; copy match]
    Exec --> Loop
    Exec --> End{bitstream exactly consumed?}
    End -->|no| E4[SequenceStream]
    End -->|yes| Rest[append remaining literals; save repeat offsets]

Invariants each layer relies on:

  • Bit readers. Readers start after the end marker of the last byte (a zero last byte is rejected); sequence streams are valid only when consumed exactly, and Huffman streams whenever the reference accepts them (see Huffman stream ends). BitContainer (Huffman and sequence streams) is refilled where the reference refills, and once reads pass the start its unread-bit count wraps above 64, so such streams are rejected; the Huffman weight decoder reads zeros past the start through the same container and checks overflowed (BIT_DStream_overflow) as the reference’s FSE decoder does. Reads are at most 56 bits (the widest is a 31-bit offset).
  • FSE. read_distribution accepts accuracy logs 5..=15 and requires the probabilities to sum exactly to the table size with no symbol above the alphabet; callers then cap the log (LL/ML 9, OF 8, Huffman weights 6). FseTable::from_distribution follows RFC 8878 section 4.1.1 (and matches Appendix A row for row); SeqTable::fill_rows spreads the same way and adds each symbol’s baseline and extra-bit count from static alphabet rows. Basic sequence modes borrow complete predefined tables built at compile time. Custom tables retain their private allocations; repeat mode reuses the selected static, private or dictionary table. Every state a table produces indexes that table.
  • Huffman. Weights above 12 or trees deeper than 12 bits are rejected; the implied last weight must complete a power of two and there must be an even number, at least two, of weight-1 symbols. The lookup table is indexed by the next log bits; codes are assigned by ascending weight, then symbol. Rows are replicated to a 12-bit lookup.
  • Literals. Regenerated size at most the block maximum; four streams need at least six literals and a 6-byte jump table plus four non-empty streams; the Huffman table persists across blocks of one frame, with the decoder type (TableKind) the reference read it for.
  • Sequences. Repeat mode needs an earlier table in the frame; an RLE symbol must be in its alphabet. Each sequence is checked before it is executed: literal length within the remaining literals, block output within the block maximum, and 0 < offset <= min(window, bytes of this frame produced so far). The repeat-offset rules (including Repeated_Offset1 - 1 for a zero literal length) follow the reference exactly; a zero offset is Offset.
  • Headers. FrameHeader::parse reads the fields at the offsets header_size fixed, through dictionary_id_size, described_window_size and read_le; sequence_count reads Number_of_Sequences before read_header_on updates the tables. These and BlockHeader::parse are proved against RFC 8878 in formal verification, including that none of their indexing can panic.
  • Frames. The reserved descriptor bit must be clear; a non-zero dictionary ID must match the operation’s dictionary; the window must be within the limit (single-segment frames: the content size). The regenerated size may never exceed a declared content size and must equal it after the last block; the checksum is the low 32 bits of XXH64 with seed 0.

Huffman stream ends

RFC 8878 calls a Huffman stream faulty unless decoding it consumes every bit exactly. zstd 1.5.7 (huf_decompress.c) does not always check that, and this decoder accepts exactly what it accepts:

  • Fast four-stream path. HUF_decompress4X{1,2}_usingDTable_internal_fast runs for four streams when the tree is at most 11 bits deep (its table log is then HUF_DECODER_FAST_TABLELOG), every stream holds at least eight bytes, and the fourth segment is not empty. Its C loops read each stream as a continuation of the bytes before it, down to the jump table; each stream is then finished by HUF_decodeStreamX{1,2} over a reader whose start is the jump table, and only the regenerated length is checked. A stream may therefore stop short of its first bit, or read on into the previous stream, the jump table and, past them, the wrapped container of BIT_lookBitsFast. The one rejection after the loop (HUF_initRemainingDStream) is a stream whose loop position moved more than a word before its own start, which depends on how many rounds the loop ran: those round counts (ip[0] - ilowest over 7, and the outputs’ room over 5 or 10) and the loop’s exit when one position crosses another are ported as they are. The fast path also opens streams whose last byte is zero.
  • Pair decoding. HUF_decodeLastSymbolX2 caps the bits a stream’s last pair lookup consumes at the container’s end, so a stream whose last lookup reads past its bits still ends exactly. This applies on every path that decodes pairs, including the general four-stream body and a single stream.
  • Decoder type. A Compressed_Literals_Block reads its tree for single symbols when it has one stream (HUF_decompress1X1_DCtx_wksp) and as HUF_selectDecoder chooses for four, from the literal count and the section size including the tree; a dictionary’s tree is read for pairs (ZSTD_loadDEntropy). A Treeless_Literals_Block keeps the type of the table it reuses. HuffmanTable records it as a TableKind, which literals and dictionary set after each read.
flowchart TD
    Entry[HuffmanTable::decode_streams / decode_streams_shared] --> Exact[exact decoder: SIMD fast loops, exact end checks]
    Exact -->|Ok| Done[literals]
    Exact -->|HuffmanStream| Cold["decode_as_reference (cold): huffman::reference::decode"]
    Cold --> Streams{four streams?}
    Streams -->|no| One["HUF_decompress1X{1,2}: decode, BIT_endOfDStream"]
    Streams -->|yes| Init{"HUF_DecompressFastArgs_init"}
    Init -->|section under 10 bytes, or length4 past it| Reject[HuffmanStream]
    Init -->|"table log 12, a stream under 8 bytes, or op[3] at the end"| General["HUF_decompress4X{1,2} body: per-stream readers, BIT_endOfDStream"]
    Init -->|supported| Loop["fast C loop, X1 or X2 by TableKind"]
    Loop --> Remaining{"each stream: op within its segment, ip at most a word before its start?"}
    Remaining -->|no| Reject
    Remaining -->|yes| Tail["HUF_decodeStream{X1,X2} from the jump table, no end check"]
    Tail --> Done
    One -->|ended exactly| Done
    One -->|no| Reject
    General -->|every stream ended exactly| Done
    General -->|no| Reject

The exact decoder stays the only hot path. Whatever it accepts the reference accepts with the same literals: an exactly consumed stream reads only its own bits, which every reference path decodes to the same symbols, and no reference check fails on it. Only a section it rejects reaches huffman::reference, a scalar port that follows the reference’s control flow statement for statement over a model of BIT_DStream_t (BitStream) and the fast loops’ sentinel containers; its verdict and literals are final. The port builds HUF_DEltX2 rows on demand from the single-symbol rows (a pair when both codes fit in the table log), which is how HUF_fillDTableX2 fills them, also for 12-bit trees the pair rows of the hot path do not serve.

The reference takes these paths on 64-bit little-endian hosts and, on x86-64 builds with dynamic BMI2 dispatch, only where the CPU has BMI2; elsewhere it uses the general body and rejects what the fast path accepts. This decoder follows the fast path on every host, so its verdicts do not depend on the machine.

Compatibility with the reference decoder

Valid frames decode identically. On malformed input the verdicts match the reference’s, Huffman literal streams that are not consumed exactly included (see Huffman stream ends), with these deliberate exceptions:

  • Offsets beyond the window. Offsets are limited to the window as well as to the frame’s output, deterministically, in every API. The reference’s one-shot decoder only checks the latter, and its streaming decoder depends on its buffer layout. Reference encoders never emit such offsets.
  • Block sizes. Raw and RLE blocks larger than the block maximum and compressed blocks regenerating more than it are rejected in every API, as the reference’s streaming decoder does; its one-shot decoder only bounds compressed block input.
  • Empty input is UnexpectedEndOfInput; ZSTD_decompress returns an empty result.
  • Window limit. The limit applies to one-shot calls too (the reference’s one-shot API has none beyond 2^31), and the default limit is exactly 2^27, where the reference’s streaming default is 2^27 + 1 bytes.
  • Huffman depth. Trees of 12 bits are accepted, as by the reference, although RFC 8878 caps literal codes at 11 bits.
  • Dictionary reach. A dictionary’s content is reachable only from blocks that start within the frame’s first window. The reference’s one-shot decoder lets any offset reach it, and its streaming decoder until its ring buffer first wraps, which is never before the end of the first window. Reference encoders never reference a dictionary later.
  • Dictionary acceptance matches ZSTD_DCtx_loadDictionary_advanced exactly: empty bytes are no dictionary, raw content is never rejected, and Zstandard-format bytes are rejected where ZSTD_loadDEntropy rejects them. DictionaryContent::Zstd on bytes without the magic number is NotZstdFormat, where the reference reports dictionary_corrupted.

Magicless frames and header queries

DecoderConfig::with_format(FrameFormat::Magicless) (ZSTD_d_format) makes State::Magic of the streaming machine, and the one-shot walk, skip the magic number and the skippable-frame check: every frame starts at its header descriptor, and concatenated frames follow header to header. decompressor::FrameHeaderInfo::parse(src, format) ports ZSTD_getFrameHeader_advanced over FrameHeader::parse; see OpenZL integration.

stateDiagram-v2
    [*] --> Magic
    Magic --> FrameHeader: Magicless, or magic number read
    Magic --> SkippableSize: Standard, skippable magic
    SkippableSize --> Skipping
    Skipping --> Magic
    FrameHeader --> BlockHeader

Known gaps

  • Dictionaries. Only one dictionary per operation: there is no selection among several by frame ID (ZSTD_d_refMultipleDDicts), and DecodeDictionary always copies its bytes (no ZSTD_dlm_byRef). A PreparedDictionary decodes through DictionaryRef::Prepared, which views its own bytes with the DecodeParts it parsed when it was built.
  • Speed. Text at levels 1-3 decodes about 1.1x slower than the reference; see decoder fast paths.
  • Memory. The streaming history can reach twice the window plus 128 KiB, where the reference keeps about one window plus two blocks; a ring buffer would close the gap. Allocation follows the output actually regenerated: decompress/decompress_into reserve a declared size up front only up to 32 bytes per unread input byte, and grow with the output beyond that (output paths).
  • Uninitialized output has no decompress_into variant, and the one-shot path zeroes compressed blocks’ output, 4 KiB at a time just ahead of the sequence loop, before decoding into it (uninitialized output). decompress zeroes only the room of sequences shorter than 1 KiB. There is no exact-size output contract, unlike mbrotli.
  • Legacy formats (zstd v0.x) are not decoded.

Decoder output into uninitialized memory

Decoder sessions and the one-shot slice methods have *_uninit siblings that write into &mut [MaybeUninit<u8>]: spare Vec capacity, a foreign buffer, or C memory that was never initialized. They produce the same bytes, counts, statuses and errors as the initialized methods. The crate stays free of unsafe (src/lib.rs forbids it outside tests). The one path that reads back what it wrote, the one-shot direct path, does so through borrowed_buf::BorrowedBuf, whose filled prefix is safe to view as &mut [u8]. The encoder side is described in encoder core.

Public API surface

InitializedUninitializedOwner
DecoderSession::processDecoderSession::process_uninitdecompressor::session
DecoderSessionOwned::processDecoderSessionOwned::process_uninitdecompressor::session
Decompressor::decompress_to_sliceDecompressor::decompress_to_uninitdecompressor::decoder
Decompressor::decompress_with_dictionary_to_sliceDecompressor::decompress_with_dictionary_to_uninitdecompressor::decoder

flush and finish have no uninitialized siblings: they are shorthands for process. Sessions with a dictionary use process_uninit unchanged.

The contract every method keeps:

  • On success exactly output[..produced] (or dst[..written]) is initialized and no byte after it has been written.
  • A session’s DecodeFailure reports produced; exactly that prefix was written, as on success.
  • After a one-shot OutputTooSmall, all of dst holds the output’s prefix, as with decompress_to_slice. After any other one-shot error the caller treats dst as uninitialized.
  • Calls of process and process_uninit may be mixed on one operation.
classDiagram
    class DecoderSession {
        +process(input, &mut [u8], op) DecodeProgress
        +process_uninit(input, &mut [MaybeUninit u8], op) DecodeProgress
    }
    class DecoderSessionOwned~D~ {
        +process(..)
        +process_uninit(..)
    }
    class Decompressor {
        +decompress_to_slice(src, &mut [u8]) usize
        +decompress_to_uninit(src, &mut [MaybeUninit u8]) usize
        +decompress_with_dictionary_to_uninit(dict, src, dst) usize
    }
    class Stream {
        <<private, core::stream>>
        +process(.., &mut [u8], ..)
        +process_uninit(.., &mut [MaybeUninit u8], ..)
        -process_into~O: Sink~(..)
        -run~O: Sink~(..)
        +decode_to_slice(src, &mut [u8]) Option usize
        +decode_to_uninit(src, &mut [MaybeUninit u8]) Option usize
        -walk~O: Output~(..)
    }
    DecoderSession ..> Stream : process_on
    DecoderSessionOwned ..> Stream : process_on
    Decompressor ..> Stream : one-shot

Streaming: a write-only sink

The streaming decoder regenerates every block into its own History and only copies finished bytes out (History::deliver). It never reads the caller’s slice, so an uninitialized one serves as well as an initialized one and needs no zeroing.

classDiagram
    class Sink {
        <<private trait, core::history>>
        +capacity() usize
        +put(at, bytes)
    }
    class InitSlice["[u8]"]
    class UninitSlice["[MaybeUninit u8]"]
    Sink <|.. InitSlice : copy_from_slice
    Sink <|.. UninitSlice : zip + MaybeUninit::write
    class History {
        +deliver~O: Sink~(output, at) usize
    }
    History ..> Sink

Stream::process and Stream::process_uninit are thin non-generic entry points over process_into::<O: Sink>, which runs the state machine (run::<O>). Each destination type gets its own instantiation, chosen at compile time; no call matches on a destination at run time, so the initialized loop compiles to the slice copy it had before. The [MaybeUninit<u8>] put is a zip + write loop (write_copy_of_slice needs Rust 1.93; the MSRV is 1.89) and compiles to a memcpy. It writes exactly the bytes deliver counts into produced, which is what keeps “nothing past produced”, on success and on failure alike.

sequenceDiagram
    participant API as DecoderSession{,Owned}::process_uninit
    participant P as session::process_on
    participant S as Stream::process_uninit
    participant R as Stream::run::<[MaybeUninit u8]>
    participant H as History
    API->>P: closure calling the stream with the session's dictionary
    P->>S: input, output, operation, dictionary
    S->>R: process_into: Finish contract, input budget
    loop until NeedsInput, NeedsOutput or Finished
        R->>H: deliver(output, produced)
        H->>H: Sink::put(produced, pending bytes)
        R->>H: prepare / decode_block / commit (unchanged)
    end
    R-->>S: status, produced
    S-->>P: DecodeProgress or DecodeFailure
    P->>P: retention once finished or failed
    P-->>API: result

One-shot: the zeroed-ahead prefix

decompress_to_slice decodes straight into dst when every frame declares a content size that fits (Stream::decode_to_slice over SliceOutput); the decoder then reads earlier output as history and its 16- and 32-byte copies may read bytes ahead of what they have written. decompress_to_uninit keeps that direct path with UninitOutput, which wraps dst in a BorrowedBuf. The buffer’s filled prefix is what the decoder may see. It is grown with zeros (BorrowedCursor::append_fill) one compressed block’s room at a time, just before that block, and never past the current frame’s declared end.

flowchart TD
    Call[decompress_to_uninit] --> Reset[stream.reset]
    Reset --> Direct{decode_to_uninit: every frame declares a size that fits, no error?}
    Direct -->|yes| Done[Ok written: dst..written decoded, nothing after it touched]
    Direct -->|no| Session[DecoderSession::process_uninit with Finish]
    Session --> Outcome[one_shot_outcome: OutputTooSmall / UnexpectedEndOfInput / TrailingData / written]
stateDiagram-v2
    [*] --> Frame: begin_frame(content size)
    Frame --> TooSmall: no size, or size above capacity - filled
    Frame --> Block
    Block --> Placed: raw / RLE block, place()
    Block --> Reserved: compressed block, reserve(block_max)
    Placed --> Block: commit(end)
    Reserved --> Block: decode_block, commit(end)
    Block --> Frame: last block, next frame
    Block --> [*]: input ends
    TooSmall --> [*]: None, incremental path
  • begin_frame accepts a frame only with a declared size that fits in the rest of dst, as SliceOutput does, and records frame_end.
  • reserve(block_max) extends the zeroed prefix to the block’s room, min(filled + block_max + WILDCOPY_OVERLENGTH, frame_end), and hands the output over as the block’s BlockOutput. Its grows() is false and grow never extends it, so the sequence loop runs the fixed Executor::<false> over the room, as for decompress_to_slice, with no stops for more room, and the block’s trailing literals are written through put. Bytes zeroed for an earlier block’s room are not zeroed again, so each byte is zeroed at most once, and only inside a frame whose size is declared: on success nothing past written has been written. The cut is at most a block plus the copies’ overlength, where SliceOutput hands over the whole frame; decoded bytes are the same, and a block that would pass frame_end fails OutputFull either way. Unlike VecOutput, it cannot append long sequences without zeroing them first: BorrowedBuf cannot read its filled part while appending to it.
  • v0.2.7 to v0.2.9 zeroed the room 4 KiB at a time as the growing sequence loop reached it. A whole block’s room fits in the second-level cache, and the fixed loop needs no room tests: mzstd_decompress over silesia.tar decodes 5-9% faster with the room zeroed at once (see Measurements).
  • place writes raw and RLE blocks in one pass: bytes already zeroed for an earlier reserve are overwritten through filled_mut, the rest is appended (BorrowedCursor::append or append_fill with the run byte). A stored or RLE block’s output is therefore never zeroed first.
  • Any failure returns None. The incremental process_uninit then decodes the whole input again and defines the result. It rewrites at least every frame the direct attempt reached whenever it succeeds, because the direct path only touches frames whose declared size fits.

one_shot_outcome maps the single Finish call’s progress to the one-shot result and is shared by decompress_to_slice and decompress_to_uninit, so both report OutputTooSmall, UnexpectedEndOfInput and TrailingData identically.

C ABI

mzstd_decompress views the caller’s output as &mut [MaybeUninit<u8>] and calls decompress_to_uninit; it no longer zeroes the whole destination first. See C ABI.

Invariants

  • No uninitialized byte is ever read. Streaming delivery only writes to the caller’s slice. The one-shot direct path reads only BorrowedBuf’s filled prefix, which holds decoded bytes or zeros it wrote itself.
  • The uninitialized and initialized methods produce identical bytes, counts, statuses and errors: they share run, walk, decode_block and one_shot_outcome, and differ only in how bytes reach the destination.
  • Destination selection is static: Sink and Output are monomorphized, never matched inside the decode loops.

Measurements

Criterion, benches/decompress.rs, Apple M5 Pro, one core, 10 samples (2026-10-16): decompress_to_uninit against decompress_to_slice into the same exact-size buffer took 0.97-1.05 times as long on 4 MiB text and binary at levels 1, 3 and 19 and on the 12 MiB LDM frame, and the same on 1 MiB of random data (raw blocks, never zeroed). Streaming process_uninit over 4 MiB of mixed data took 2.83 ms, against 2.81 ms for process; process itself was unchanged from before this path existed (2.84 ms → 2.81 ms).

Through mzstd_decompress, silesia.tar (211,957,760 bytes; streams from the zstd 1.5.7 CLI with -T1), best of six one-shot calls into a reused, already-touched destination, one pinned core of a Ryzen 9 7950X, cdylib built with -C target-cpu=native, fat LTO and one codegen unit (2026-11-03): zeroing each block’s room at once instead of 4 KiB at a time decoded 2,326 MB/s instead of 2,131 at --fast=5 (+9.1%), 2,058 instead of 1,915 at --fast=1, 1,372 instead of 1,307 at -1, 1,421 instead of 1,340 at -3, 1,600 instead of 1,495 at -9 and 1,475 instead of 1,391 at -19 (the Huffman change in decoder performance held fixed).

Verification

  • tests/decoder_uninit.rs: process_uninit alone and alternating with process against process for five corpora, three levels, with and without a content size, input chunks of 1 byte to 1 MiB and output windows of 1 byte to 1 MiB, with sentinels checked past every produced. Also failure progress on corrupt data and a bad checksum, failed and finished sessions, owned and dictionary sessions, and decompress_to_uninit against decompress_to_slice for exact, larger, smaller, tiny and empty destinations, frame sequences (skippable, declared and undeclared frames, where the direct path gives up midway), truncated, empty and trailing input, and an output limit. The dictionary one-shot is covered as well.
  • Unit tests: history (Sink delivery into an offset of an uninitialized slice) and stream (UninitOutput zeroing each block’s room up to the frame’s end without growing, place across and past the zeroed prefix, decode_to_uninit sentinels, process_uninit delivery).
  • AFL: decode_streaming, decode_roundtrip and decode_dictionary drive sessions alternating process and process_uninit and check that nothing is written past produced; decompress compares decompress_to_uninit with decompress_to_slice, including sentinels past the result.

Known gaps

  • decompress_to_uninit zeroes compressed blocks’ output, a block’s room at a time, before decoding into it, so those bytes are written twice. Safe code cannot hand the decoder memory it has not initialized, and BorrowedBuf cannot read its filled part while appending, so the appends decompress uses for long sequences are not available here. Raw and RLE blocks are written once.
  • Through the C ABI (mzstd-ffi/benches/one_shot.rs, same host), a 64-byte frame at level -5 decodes in 30 ns instead of 25 ns, from the BorrowedBuf bookkeeping; mbrotli zeroes destinations of at most 1 KiB instead, which mzstd does not, to keep “nothing past the result is written”.
  • No uninitialized variant of decompress_into, which already writes into the vector’s spare capacity in one pass for raw and RLE blocks.

Decoder fast paths

How decompressor::core regenerates blocks at speed: SIMD dispatch, the bit readers, the Huffman and sequence loops, the copies, and the output paths. Every fast path decodes exactly the bits the reference decoder (zstd 1.5.7) reads, in the same order, so it accepts and rejects the same streams as the straightforward port it replaced; only refill timing and copy granularity differ. The crate stays forbid(unsafe_code): bounds are checked, and the code is shaped so the compiler can drop most of the checks (see index masks).

Dispatch

sequenceDiagram
    participant D as Decompressor (Backend)
    participant S as Stream
    participant F as FrameDecoder
    participant B as BlockDecoder::decode
    participant H as HuffmanTable::decode_streams
    participant Q as SequenceTables::execute
    D->>S: Stream::new(config, backend)
    S->>F: decode_block(backend, ...)
    F->>B: decode(backend, ...)
    alt raw / RLE block
        B->>B: copy_from_slice / fill
    else compressed block
        B->>B: MatchCopy from backend.has_fast_shuffle()
        B->>B: fearless_simd::dispatch!(backend.block_decode_level(), simd => ...)
        B->>H: #[simd] kernel: literals
        B->>Q: #[simd] kernel: sequences
    end

Backend (public, src/backend.rs) wraps a fearless_simd::Level detected once when the decoder is built (DecompressorBuilder::with_backend overrides it). BlockDecoder::decode dispatches once per compressed block. The two hot loops are separate #[fearless_simd_macros::simd] functions: each is compiled per level with that level’s target features (on AVX2 that includes BMI2, so bit reads use shrx/bzhi), and keeping them apart keeps each loop’s register allocation independent. MatchCopy::Shuffle is chosen on levels with a native byte shuffle (SSSE3 and up, NEON, WASM SIMD); sse2 and fallback use MatchCopy::Scalar.

Backend::block_decode_level runs the block loops of an AVX-512 backend with AVX2’s features. The loops are scalar apart from 16- and 32-byte copies, which AVX2 already has; with AVX-512’s cheap gathers the compiler vectorized the four Huffman streams’ independent lookups across lanes (vpgatherdd, vpsllvq, lane extracts), lengthening every stream’s chain: a pair loop written as the reference’s took 101.8 µs on Alice level 1 compiled for AVX-512 against 87.2 µs for AVX2 (Ryzen 9 7950X).

That choice cannot hold where AVX-512 is in the compile-time baseline (-C target-cpu=native on Zen 4 or Ice Lake and later, as lzbench builds): fearless_simd::dispatch! promotes every level to Avx512 there (Level::__dispatch_target), and target features can only be added, so the block loops always see AVX-512. HuffmanTable::decode_streams and decode_streams_shared therefore also ask their own feature context (serial, folded per backend) and, given AVX-512, decode the fixed-buffer pair loop one stream at a time (pair_rounds_serial, see Huffman literals), which the compiler leaves scalar.

Bit readers

classDiagram
    class BitContainer {
        container: u64
        avail: u32
        pos: usize
        +new(src) Option
        +look(n) / look_nonzero(n) / read(n) / skip(n)
        +reload_fast(src)
        +reload(src) bool
        +overflowed() bool
        +is_exhausted() bool
    }
    class Sentinel {
        bits: u64
        pos: usize
        +from_container(BitContainer, src, start)
        +into_container(src, start) BitContainer
        +reload_within(src, limit)
        +peek(shift) / skip(n)
    }
    BitContainer <--> Sentinel : Huffman fast loops
  • BitContainer keeps the eight bytes at pos and the count avail of unread low bits. look subtracts the requested width, shifts and masks; a zero-width read returns zero. A full refill while pos >= 8 moves back by (64 - avail) / 8 bytes. Near the start, the cautious refill is cold and takes the container by value so the hot loop can keep it in registers. At pos == 0 it returns directly without that cold call. Valid exhaustion is pos == 0 && avail == 0; overreading wraps avail above 64 and refills preserve that overflow until validation rejects it. The Huffman weight decoder checks the same overflow state.
  • Sentinel is the reference’s fast-loop stream (HUF_DecompressFastArgs): unread bits at the top, a 1 below them shifted up with every read, so the consumed count is the sentinel’s position (trailing_zeros) and a lookup is one shift. Positions index the buffer holding all four streams. reload_within(src, limit) refills at the position clamped to limit, the last in-bounds one: the fast loops’ round counts keep every position below it, so the clamp never changes a value, but it replaces a bounds-checked load and its fallback branch with a compare and a cmov. Each stream keeps its own position and output count, so its tail takes over where its fast rounds ended. Pair rounds may refill across a later stream’s start within the common input allocation. into_container moves back to that stream’s first byte and subtracts prefix bits from avail, preserving both exact exhaustion and overread errors before the stream-local tail runs.

Huffman literals

flowchart TD
    Read["HuffmanTable::read: weights 4 per refill, rows filled by weight in runs"] --> Kind{four streams?}
    Kind -->|no| Tail1[decode_tail: HUF_decodeStreamX1]
    Kind -->|yes| Open[open four BitContainers]
    Open --> Fast{every pos >= 8?}
    Fast -->|no| Tails
    Fast -->|yes| Sel{prefers_pairs: HUF_selectDecoder}
    Sel -->|X1| Single[single_rounds: 5 symbols per stream per round, 4 if 12-bit tree]
    Sel -->|X2, tree up to 11 bits| Fixed{buffer of FIXED_OUT bytes?}
    Fixed -->|yes| Avx{AVX-512 features?}
    Avx -->|no| PairsFixed[build_pairs if stale; pair_rounds_fixed: one masked position per stream, streams 1-3 interleaved, stream 4 with the refills]
    Avx -->|yes| PairsSerial[build_pairs if stale; pair_rounds_serial: streams 1-3 interleaved, stream 2's refill behind a branch, then stream 4 in its own block]
    Fixed -->|no| Pairs[build_pairs if stale; pair_rounds: per-round windows, streams 1-3 interleaved, then stream 4]
    Single -. one WriteAhead step per round .-> Ahead[prefetchw: next block's output]
    PairsFixed -. one WriteAhead step per round .-> Ahead
    PairsSerial -. one WriteAhead step per round .-> Ahead
    PairsFixed --> Back
    PairsSerial --> Back
    Single --> Back[Sentinels back to containers]
    Pairs --> Back
    Back --> Tails[decode_tail per stream: exact end check]
    Tail1 --> Verdict{exact?}
    Tails --> Verdict
    Verdict -->|no| Reference["decode_as_reference (cold, out of line): huffman::reference"]
  • Single rows are built for 12-bit lookups whatever the tree depth (each code’s rows repeated 1 << (12 - log) times), so lookups need no mask. A row packs the code length in its low byte and the symbol in its high byte (HUF_DEltX1), so a Sentinel shift by the whole row (shlx masks to six bits) consumes the code. HuffmanTable::fill counting-sorts the symbols by weight and writes each weight’s runs, all of one length and aligned to it, with fixed-size stores (fill_runs::<1|2|4|8>, 16-row chunks beyond), as HUF_readDTableX1_wksp does; weight streams decode four symbols per refill while a whole word can be refilled, then follow the reference’s per-symbol overflow tail. Reading a tree costs about 35% fewer instructions than the per-symbol fill, with far fewer mispredictions.
  • single_rounds splits the four output segments once and walks them as fixed-size chunks (windows, single_round), storing each stream’s five (or four) symbols straight into its chunk, with no per-stream window check and no array gathered on the stack: 176 instructions per 20 symbols against the reference’s 163.
  • The fast loops count the rounds every stream can afford up front (each refill moves back at most seven bytes; outputs need room for the round’s stores), run them without per-stream tests, then recount. Single rounds remain within each input stream. Pair rounds use the common input bounds; conversion back to stream-local containers restores strict end checks.
  • Pair rows (HUF_DEltX2) use 11-bit lookups (HUF_DECODER_FAST_TABLELOG): 2048 rows, half the size and build cost of 12-bit ones, a row pairing the code at the top of its index with the next only when both fit in 11 bits. A PairRow packs the bit count (byte 0, so a whole-row shift consumes them), the symbols (bits 8-23) and the symbol count (the top two bits, read with one shift and no mask). pair_rounds does five lookups per stream per refill (55 bits), as the reference’s fast loop does; each stores two bytes into a 14-byte window of the stream’s segment, and the count’s range (at most 3) lets the compiler prove the five stores stay inside it without a mask or per-store checks. The output is split into four non-overlapping segments once; every round takes a 14-byte window inside each segment. The first three streams interleave their five lookups, exposing three independent lookup chains to the processor. The fourth stream then decodes its five lookups separately, limiting the live state during the interleaved work. Refill positions and each segment’s window offsets are clamped to their last in-bounds values, which the round counts never pass. Rounds are limited to (end - op - 4) / 10 per stream. Trees of 12 bits, which the reference accepts but never writes, build no pair rows and decode with the single loop. Pair rows are rebuilt only after a new tree, and a test checks every row of trees of every depth against the definition.
  • pair_rounds_fixed is the reference’s register layout: each stream keeps only its container and one output position live, where a window costs a base and an offset. Large four-stream sections (at least FIXED_MIN, 4 KiB) decode into a literal buffer of at least FIXED_OUT (BLOCK_SIZE_MAX + PAIR_WINDOW) bytes, which decode_streams receives with the section size; smaller sections keep a buffer of their own size, so small frames zero nothing more. Each round masks the four positions to BLOCK_SIZE_MAX - 1 (no section position exceeds it) and advances them by at most three per lookup by the count’s type, so every two-byte store provably lands inside the array with no check; the round counts still keep each stream inside its own segment. As in the reference, the fourth stream’s lookups interleave with the four refills and its position ends the inner loop (op4 < olimit, with olimit five symbols per affordable round), so no round counter is live. On Alice level 1 the round executes 242 instructions instead of 295 (the reference: 221), and decoding takes 2.6% less time.
  • pair_rounds_serial replaces pair_rounds_fixed in AVX-512 feature contexts. Given AVX-512, LLVM’s SLP vectorizer packs the lockstep loop’s four branch-free containers into one ymm register: one vpsrlq makes four lookup indices, vpgatherqd loads four rows, vpsllvq consumes them, every row is extracted again for its scalar store, and the four refills become a vpgatherqq. That runs the four streams as one dependency chain through the gathers. The AVX-512 loop runs the same rounds with the same bounds over the same masked positions, but no basic block holds all four streams: streams 1-3 interleave their five lookups and refill, stream 2 with Sentinel::reload, whose bounds test is a branch the compiler cannot remove, streams 1 and 3 clamped with reload_within; stream 4 then decodes its round in its own block, which the rotated loop ends with its exit test. SLP finds no four isomorphic chains in one block, and with the branch no three refills either; the processor overlaps three lookup chains at a time. Earlier the loop ran one stream at a time, each refill behind a branch: that left the scheduler full of one stream’s waiting lookups, and on x-ray (1 MiB, level 1, almost all four-stream literals) a decode took 3.66M cycles against zstd 1.5.7’s asm loop at 2.55M; the current order takes 3.25M (2026-11-08, Ryzen 9 7950X VDS, native build). Measured and rejected there: four streams interleaved with every refill behind a branch, or with branch-free refills in a rotated loop (both vectorized again, 1.6-2x slower), two pairs of streams (3.55M), and a pair row whose symbols sit in the low 16 bits with the bit count rotated into place (rorx; LLVM emits mov and shr for it, no fewer instructions).
  • A code of b bits fills a run of 1 << (11 - b) pair rows whose jth row continues with the code at row j << b, the same series for every code of that length (the reference’s HUF_fillDTableX2Level2 per rank). The first run of each length is built from the single rows; later runs of that length copy it with one add per row that swaps in their own first symbol.
  • Selected pair streams continue with four pair lookups per refill while the remaining output has an 11-byte window. Every stream then finishes with the single-symbol table in the cautious loop and must be consumed exactly; pair decoding never decides how a stream ends. A section that fails that check is decoded again by the scalar port of the reference (huffman::reference, see Huffman stream ends), whose verdict stands. It is #[cold] and #[inline(never)], and only error paths reach it, so the loops above and the sections they accept are unchanged; the hot path’s choice of pair loops (prefers_pairs over the streams’ size) is independent of the reference’s decoder type, which only the port uses.
  • The loops reach a table through TableAccess: the decoder’s own table (&mut HuffmanTable, decode_streams) builds its pair rows on first use, a dictionary’s shared table (&HuffmanTable, decode_streams_shared) only uses rows built when the dictionary was parsed. Both are monomorphized, so the own-table path compiles as before.

Sequences

flowchart TD
    Header[read_header_on: count and per-symbol modes] --> Mode{table mode}
    Mode -->|Basic| Static[borrow static predefined rows]
    Mode -->|RLE or Compressed| Custom[allocate or rebuild private rows]
    Mode -->|Repeat| Reuse[reuse static, private or dictionary rows]
    Static --> Init[initial states, reload]
    Custom --> Init
    Reuse --> Init
    Init --> Next{sequences left?}
    Next -->|yes| Dec[SequenceDecoder::decode: ZSTD_decodeSequence]
    Dec -->|zero repeat offset| EOff[Offset]
    Dec --> Exe[Executor::execute: ZSTD_execSequence]
    Exe -->|literals overrun| ELit[LiteralsOverrun]
    Exe -->|match_end + 32 <= out.len| Wild[offset check; wildcopy literals; wildcopy match]
    Exe -->|"else, GROW = false"| Exact[execute_exactly, cold: BlockSize / Offset / OutputFull; exact copies]
    Exe -->|"else, GROW = true"| Cold[leave the loop with the sequence: append, grow or execute_exactly, then resume]
    Wild --> Next
    Exact --> Next
    Cold --> Next
    Next -->|no| End{bitstream exhausted?}
    End -->|no| ESeq[SequenceStream]
    End -->|yes| Last[copy remaining literals; save repeat offsets]
  • SeqTable rows are ZSTD_seqSymbol: baseline, next-state base, extra-bit and state-bit counts. Both counts are a BitCount, a 32-variant #[repr(u8)] enum: its range reaches the compiler with every load, so reads by these counts need no & 63 before the bzhi or shift. Constructing a count uses an exhaustive match, which compiles to range masking without the memory lookup that indexing an array of enum variants required. The sequence loop still holds references to rows and reads fields as needed.
  • Basic sequence modes borrow immutable LL, OF and ML decoding rows built at compile time from the predefined distributions. A TableView carries the rows and initial-state width, whether the rows are static, owned by the decoder or borrowed from a dictionary. Selecting a basic mode needs no allocation or table construction, including the first block of a cold decoder. RLE and described modes allocate private storage on first use and reuse it thereafter. A frame start forgets which table repeat mode may use; selecting a basic mode again simply restores its static view. Private allocations remain reusable when a basic mode replaces them.
  • SeqTable::fill_rows consumes static per-alphabet symbol rows containing the baselines and extra-bit counts. It no longer zeroes and rebuilds a 64-row symbol workspace for every described table. spread still ports ZSTD_buildFSETable_body: without “less than one” symbols it lays the symbols down eight at a time and scatters them with a fixed step, unrolled by two with no per-cell tests; otherwise it keeps the skipping loop. Tests compare every predefined row with the runtime builder and compare custom tables across the alphabets’ symbols and accuracy logs. Header parsing passes the block’s existing SIMD token through read_header_on to fill_rows_on; table construction therefore uses the selected target features without detecting the CPU per table or row. Dictionary parsing outside a block selects its backend once per table. read_header_on is #[inline(never)]. Being generic over the token, it was otherwise inlined into the block decoder that also holds the sequence loop, and with AVX-512 in the baseline the loop’s code then took 9% more instructions per decode (1645.0M against 1498.0M on silesia.tar at --fast=1) and 8-12% more time at every level. Portable builds were unaffected.
  • Executor<const GROW: bool>: fixed outputs (decompress_to_slice, streaming History) compile to the loop above with execute_exactly inline and cold, and so does UninitOutput, which zeroes a block’s room before the block. Growing outputs (VecOutput) leave the loop with any sequence the wildcopies cannot take (no room for the overlength, a match before the output or past the window); the cold part of SequenceTables::execute appends it when it is at least APPEND_MIN (1024) bytes and valid, else grows the zeroed room and retries it, else runs execute_exactly (dictionary, end of output, errors in the old order: block size, offset, output full). Then the loop resumes.
  • An offset with more than one extra bit is read with BitContainer::read (sub, shrx, bzhi), not look_nonzero, whose two shifts each need a negated count: four instructions fewer per such sequence.
  • Decoding refills once per sequence and once more when the sequence’s extra bits exceed 64 - 7 - (9 + 9 + 8), as the reference does. Literal and match lengths with zero extra bits use their baselines directly; they do not perform a zero-width shift and mask.
  • Errors keep the old order: literal overrun, block size, offset (and output room for caller-sized buffers).

Copies

copy.rs holds the wildcopies (ZSTD_wildcopy, ZSTD_overlapCopy8):

CaseCopy
literalsone 16-byte chunk, then 32-byte chunks
match, offset >= 3232-byte chunks; the first is copied from the output split at the literals’ end, whose history’s last offset bytes provably hold it, so it needs no bounds check
match, 16 <= offset < 3216-byte chunks
match, offset < 16, ShufflePERIOD[offset] spreads the period over a u8x16; ROTATE[offset] turns each chunk’s vector into the next’s
match, offset < 16, Scalarthe reference’s offset spreading (dec32table/dec64table), then 8-byte chunks
end of bufferexecute_exactly: exact literal copy, exact_match doubling copies

Fast copies write up to WILDCOPY_OVERLENGTH (32) bytes past a sequence and read as far past its source; the executor uses them only when the output has that room, and literals always carry 32 readable bytes after their end (Literals): raw literals are borrowed in place when the block holds 32 more bytes after them, and copied otherwise.

Index masks

Release-mode usize additions may wrap, so out[i..i + 16] normally costs an overflow test and a bounds test. The sequence executor cuts the output to start at max(history_start, op - window) — no valid match reaches further back — so every index is below 2^31 + 2^19 (compile-time asserted against DecodeLimits::MAX_WINDOW_LOG), and copy::idx masks operands to 32 bits. The masked sums provably do not wrap, which lets the compiler relate the executor’s room tests to the copies’ bounds checks and drop many of them. The masks never change a valid index.

Dictionaries

A dictionary adds nothing to the per-sequence fast path. Its content is not copied into the output: Executor carries it as a slice that only the cold execute_exactly reads. The fast path’s test is the one it always had, offset > lit_end || offset > window; a sequence failing it, which without a dictionary is always an error, now calls execute_exactly, which reports the error or, with a dictionary, copies the match’s head from the dictionary’s tail and the rest from the output. The dictionary’s tables are picked once per block (TableSlot::get), and its repeat offsets once per frame. The index bounds above are unchanged: the cut output still starts at the history’s first byte or a window before the block.

Measured on a loaded i7-13700KF (AVX2, pinned), cargo bench --bench decompress -- 'decompress-(dictionary|small)': a thousand JSON-like records of about 120 bytes, each its own level-3 frame with a 16 KiB dictionary trained on similar records, decode in 198 µs against zstd 1.5.7’s 135 µs (1.47x), and the same records compressed without a dictionary in 1.60 ms against 1.11 ms (1.44x). The dictionary adds no gap of its own beyond the decoder’s per-frame overhead on tiny frames. Frames without a dictionary decode as before: on a quiet host, text level 1 2.12 → 2.06 ms, text level 3 1.85 → 1.80 ms, binary level 3 3.49 → 3.43 ms, random 19.7 → 19.1 µs, streaming mixed 2.46 → 2.44 ms (Criterion medians, before and after the change).

Output paths

flowchart LR
    ToSlice[decompress_to_slice] --> Direct{decode_to_slice: every frame declares a size that fits?}
    Direct -->|yes| Slice[SliceOutput: frame cut at its declared end]
    Direct -->|no, or any error| Session[incremental session over History]
    ToUninit[decompress_to_uninit] --> DirectU{decode_to_uninit: same test}
    DirectU -->|yes| Uninit[UninitOutput: BorrowedBuf zeroed a block's room before each compressed block, cut at the declared end]
    DirectU -->|no, or any error| SessionU[process_uninit over History]
    Into[decompress / decompress_into] --> Cut[decode_all: VecOutput, declared frames cut at their end]
    Cut -->|any error| Uncut[reset, rerun with VecOutput uncut: defines the error]
    Process[DecoderSession::process] --> History[History: prepare / commit]

decompress and decompress_into write through VecOutput, which owns three decisions per frame and block:

flowchart TD
    Begin[begin_frame: content size, unread input] --> Declared{declared size?}
    Declared -->|yes| Reserve["reserve min(declared end, filled + 32 x unread input)"]
    Declared -->|no| Grow
    Reserve --> Block
    Grow[no reservation] --> Block
    Block[next block] --> Kind{raw or RLE?}
    Kind -->|yes| Place["place: extend_from_slice / resize with the byte, one pass"]
    Kind -->|compressed| Slack["reserve capacity only; BlockOutput: grow 4 KiB of zeros on demand, append long sequences and trailing literals"]
    Place --> Account[FrameDecoder::account_block: checksum, size checks]
    Slack --> Decode[FrameDecoder::decode_block] --> Account
  • Stream::walk is the one-shot frame grammar over an Output. SliceOutput cuts the caller’s slice at each frame’s declared end, so chunked copies never touch bytes past the output and the rest of dst stays unchanged.
  • VecOutput cuts a declared frame’s blocks at its end, like SliceOutput, so no slack past the output is zeroed or kept. A frame that overruns its declared size then fails with OutputTooSmall instead of the reference’s ContentSizeMismatch, so decode_all resets the stream and reruns the input uncut on any error; the uncut pass defines every error and leaves the vector as it was.
  • Capacity grows by doubling (grow_output/reserve_output), never past twice the requested length. When the frame’s last request is within that factor, capacity jumps straight to it, so a declared frame ends with no reallocation and no spare capacity. At a frame start, a declared size is reserved only up to 32 output bytes per unread input byte (PLAUSIBLE_RATIO): an allocation always needs input in proportion, and a frame that claims more grows with its actual output.
  • Raw and RLE blocks are appended in one pass (Output::place): extend_from_slice or resize with the run byte write straight into spare capacity. Compressed blocks write through BlockOutput (block.rs): the buffer’s initialized prefix, which VecOutput extends on demand (grow, 4 KiB of zeros at a time, up to the block’s room cut at the frame’s end) and by append (cut at the output’s end, then extend_from_slice for the literals and doubling extend_from_within for the match). Sequences of 1 KiB or more, a block’s trailing literals and literal-only blocks are therefore written once; shorter sequences still need zeroed room for the wildcopies’ 32 bytes of overlength, since safe Rust cannot hand the decoder uninitialized memory. Caller slices, UninitOutput and History are fixed (grows() == false).
flowchart TD
    Loop["Executor::<true>::run: decode + wildcopy"] -->|all done| Rest[room.put remaining literals]
    Loop -->|sequence does not fit, or leaves the window or output| Cold{valid, inside window and block?}
    Cold -->|"yes, >= APPEND_MIN"| Append["room.append: extend_from_slice + extend_from_within"]
    Cold -->|yes, short| Grow["room.grow: zero 4 KiB more"] -->|grew| Retry[retry the sequence] --> Loop
    Cold -->|no, or cannot grow| Exact["grow to the sequence end; execute_exactly"]
    Append --> Loop
    Exact --> Loop
  • UninitOutput is SliceOutput over a BorrowedBuf: reserve zeroes a compressed block’s whole room (cut at the frame’s end) before the block, which then runs the fixed sequence loop, and place writes raw and RLE blocks once, over the zeroed bytes and then appended. See uninitialized output.
  • decompress_to_slice tries the direct path and, when a frame has no content size, does not fit, or anything fails, reruns the whole input through the incremental session, which defines the method’s results and errors.
  • History keeps an initialized buffer with a fill mark; prepare compacts as before and ensures room for a block plus the overlength, commit records the block’s end.
  • The literal buffer only grows, so blocks of varying sizes zero nothing but its new tail. A four-stream section of at least 4 KiB grows it to FIXED_OUT bytes once, for the fixed-size pair loop.

Write prefetch of the next block

sequenceDiagram
    participant S as Stream::walk
    participant O as Output (Uninit / Slice / Vec)
    participant B as BlockDecoder::decode_compressed
    participant H as Huffman fast loop
    S->>O: reserve(block_max)
    O->>O: zero this block's room (UninitOutput only)
    O->>O: ahead = next_room(room end, block_max, limit)
    S->>B: decode(out)
    B->>O: out.ahead()
    B->>H: decode_streams(..., ahead)
    loop every round
        H->>H: lookups and refills
        H->>H: ahead.step(): prefetchw two lines, advance 128 bytes
    end
    B->>B: sequences (unchanged)
    S->>O: commit; next reserve zeroes lines already cached

A caller’s buffer is usually not cached when the decoder reaches it. The reference decodes a large block’s literals into the end of the block’s own output (ZSTD_in_dst), so its literal loop takes those misses while it computes. Here literals go to the decoder’s own buffer, and an uninitialized output is zeroed a block’s room at a time before the block (UninitOutput::reserve), a memset whose misses nothing overlaps.

Output::reserve therefore also records the block_max bytes after the current block’s room (next_room, cut at the frame’s end for caller slices, at the capacity for a vector) as a WriteAhead (decompressor::core::ahead), which BlockOutput::ahead hands to the block’s literal decoding. single_rounds, pair_rounds_fixed and pair_rounds_serial call WriteAhead::step once per round: two prefetchw hints (branches::prefetch_write_data, safe inline assembly; nothing on targets without one, and nothing under Miri) and an advance of 128 bytes, until the range is covered. The loops are bound by their lookup chains and leave memory bandwidth idle, so the misses overlap decoding; the next block’s zeroing (or its first writes into a caller’s slice or a vector’s spare capacity) then finds the lines cached. A prefetch never faults and changes no result, so the range needs no bounds proof; history outputs, dictionaries and the cold paths pass WriteAhead::NONE.

Measured on silesia.tar through mzstd_decompress in lzbench master a6fa8aa (one pinned vCPU of a Ryzen 9 7950X VDS, native build, three interleaved rounds, ratio to zstd 1.5.7 in the same run; the host’s noise is about ±3%): without prefetch 0.876-0.900 at level 1 and 0.887-0.926 at level 8; one line per round 0.913-0.938 and 0.923-0.927; two lines 0.903-0.941, 0.945-0.975 and 0.913-1.016 at level 19. Four lines measured no better than two. The hint costs the literal loops about 1% on x-ray, whose literals leave little else to overlap.

Measurements

cargo bench --bench decompress -- --warm-up-time 1 --measurement-time 3, pinned to one core of an i7-13700KF (AVX2 backend). Frames are written by zstd 1.5.7 from zstd_ffi::corpus inputs and decoded with reused contexts, decompress_to_slice against ZSTD_decompressDCtx. Times are Criterion medians; “before” is the scalar port this replaced.

Casebeforenowzstd 1.5.7ratio
text 4 MiB, level 15.96 ms2.14 ms1.81 ms1.18
text 4 MiB, level 34.47 ms1.86 ms1.64 ms1.13
text 4 MiB, level 192.70 ms1.10 ms1.05 ms1.05
binary 4 MiB, level 118.89 ms2.13 ms2.28 ms0.93
binary 4 MiB, level 314.37 ms3.52 ms3.28 ms1.07
binary 4 MiB, level 1915.98 ms4.73 ms4.34 ms1.09
random 1 MiB, levels 1-1964.5 µs19.8-21.3 µs19.6-21.4 µs0.99-1.04
text+binary+text 12 MiB, LDM18.02 ms5.06 ms4.79 ms1.06
mixed 4 MiB, streaming7.26 ms2.48 ms——

Both decoders run near five instructions per cycle on these inputs, so instruction counts (callgrind) track time closely. When these were measured, the remaining gap was in the sequence loop (about 140 instructions per sequence against the reference’s 125, mostly bounds checks and bit-count masks) and the pair Huffman loop; the loop has since reached the reference’s count (see the known gaps).

mzstd_decompress over silesia.tar (211,957,760 bytes, streams from the zstd 1.5.7 CLI with -T1), best of six one-shot calls into a reused destination, one pinned core of a Ryzen 9 7950X, cdylibs with fat LTO and one codegen unit, MB/s averaged over two interleaved rounds (2026-11-03). “native” is -C target-cpu=native, as lzbench builds; “portable” is the default target. “before” is v0.2.9; “after” adds pair_rounds_serial and zeroing each block’s room at once (uninitialized output):

levelnative beforenative afterportable beforeportable after
--fast=52105230222922448
--fast=11881205020802196
-11296162216311707
-31317149015151571
-91468161216161694
-191363145614701535

Keeping read_header_on out of line (the “fix” columns; same host, three interleaved rounds, best of 18 decodes per value) then made the native build faster than the portable one at every level:

levelv0.2.5 nativev0.2.10 nativefix nativev0.2.10 portablefix portable
--fast=523142313259424432455
--fast=120422061230121922197
-113431625176517241708
-312531486165415971591
-913541630176617131705
-1913151463161415521556

Portable builds never run the serial loop (their block loops use AVX2), so their gain is the zeroing alone. With the zeroing held fixed, the serial loop takes level -1 from 1372 to 1622 MB/s in the native build and leaves the other levels within 1.5%.

XXH64 checksums are not vectorized: each of its four lanes is a serial multiply-rotate-multiply chain, and AVX2 has no 64-bit vector multiply. A u64x4 kernel measured 11.8 GB/s against 20.9 GB/s for the scalar code, which already matches the reference’s 21 GB/s.

Known gaps

  • The block decoder inlines the whole sequence loop, and its code moves with whatever else is inlined into it: v0.2.9’s generic read_header_on cost 8-12% in AVX-512 builds alone. Check changes to block, literals and sequences with -C target-cpu=native as well as a portable build, by instruction counts per decode as well as time.

  • On a Zen 4 EPYC 9554, lzbench measured v0.2.10 8% slower than 0.2.5 at the --fast levels and within 3% at levels 1-19 (inikep/lzbench#363); the same EPYC already measured 0.2.6’s per-block zeroing 1-3% slower than 0.2.5’s whole-destination zeroing, the reverse of the Ryzen 9 7950X. The out-of-line read_header_on is not yet measured there.

  • Literal-heavy inputs decode slowest against the reference: Silesia x-ray at level 1 0.85x (Silesia run of 2026-10-30, Ryzen 9 7950X, reused contexts; since replaced by lzbench), where both decoders spend 77-79% of their time in the four-stream pair loop. pair_rounds_fixed takes 72.6 cycles per round against the reference’s 61.5 (242 instructions against 221): the compiler keeps two streams’ containers on the stack between rounds, on their dependency chains (22.6M store-to-load interlocks against 6.9M for 30 decodes). A branch-checked refill and other stream orders measured slower. In AVX-512 builds the three-plus-one pair_rounds_serial still executes about 13 instructions per pair lookup against the asm loop’s 9.75 that lzbench’s zstd links (huf_decompress_amd64.S): 12.4M instructions and 3.25M cycles per decode of 1 MiB of x-ray against 9.2M and 2.55M (2026-11-08). The remaining instructions are the stack traffic of twenty live values in fifteen registers and a mov per lookup that the asm avoids with three narrow loads from each row.

  • Measured and rejected (2026-11-08, same input): the four streams’ containers in one fearless_simd u64x4, with the lookup index, the consumed bits (shlv_u64x4) and the rounds in vector form, and the row loads, stores and refills per lane. It executed the fewest instructions (8.0M against 12.6M) but took 7.36M cycles against 3.32M at 1.1 instructions per cycle: LLVM turned the per-lane row loads into vpgatherqd, whose latency sits on every stream’s lookup chain. A gather cannot be written explicitly either: _mm256_i64gather_epi32 takes a raw pointer and stays unsafe inside fearless_simd::kernel!, whose other intrinsics (_mm256_sllv_epi64 and the like) are the operations the portable types already give.

  • Measured and rejected (2026-11-09, C-written frames at level 1): the four-stream sections of four consecutive blocks decoded together, every lookup step taking one pair from each of the sixteen streams, which a decoder could do without changing the format. Even in an SSE4.2 build, where nothing can become a gather, sixteen chains decoded at 0.69x the speed of one section at a time on x-ray and 0.73-0.76x on mozilla and dickens (0.60-0.82x with AVX2 or AVX-512 features, whose builds also vectorize the step into gathers). Sixteen containers, positions and output positions do not fit in fifteen registers: every chain step then loads and stores its state, and the store-to-load latency joins the chain. More chains do not help a loop that already overlaps three or four.

  • Measured and rejected (2026-11-08, same input): the AVX-512 pair loop over one shared buffer, the output at its start and the streams copied after it (FIXED_OUT + BLOCK_SIZE_MAX + 8 bytes), with refills masked into the copy instead of clamped to a limit. One base and no limit freed registers and cut the loop to 11.85M instructions from 12.56M, but not its cycles (3.23M against 3.23M, eight rounds): the three-plus-one order is bound by the fourth stream’s lookup chain, which runs alone. All four streams interleaved, as the reference’s asm does, vectorized again into gathers (6.4M cycles) even with one or two streams indexing by rotate_left(11) & 0x7ff, which LLVM folds back into the same shift.

  • Large outputs: with the write prefetch, silesia.tar decodes at 0.90-0.97 of zstd 1.5.7’s speed at levels 1 and 8 in lzbench, about even at level 19. The rest of the gap is the uninitialized output’s zeroing, which safe code cannot skip, and the part of each block’s room the literal loops do not reach in time: blocks with few or raw literals prefetch little.

  • Measured and rejected (2026-11-08, lzbench on silesia.tar, ratio to zstd 1.5.7 in the same run, medians of three): writing the uninitialized output in place behind a checked initialized front, as a safe WriteFront type would (each chunk write tests that it starts at or before the front and moves the front; reads test that they end below it), instead of zeroing each block’s room. Simulated in the copy kernels, the checks added 22-27% instructions per decode (dickens 2 MiB level 1: 25.1M to 30.7M) and decoded at 0.866-0.876 of zstd’s speed at levels 1, 8 and 15, against 0.932-0.949 with zeroing and the write prefetch. Even with no zeroing and no checks at all (an unsafe cast, for the measurement only) the decoder reached 0.960-0.984: the zeroing left costs about 3%.

  • Measured and rejected (2026-10-29, Ryzen 9 7950X, Alice level 1): the repeat offsets as scalars, a packed u64 pair, permuted slots, a ring of four slots, the states kept local to the loop, a clamped refill position, and the window test left out where the window covers the output. Each changed the sequence loop’s register allocation for the worse: 2-12 more instructions per sequence and 2-14% slower, although the ring cut store-to-load interlocks from 196M to 109M (the reference: 136M) for 3000 decodes. Shifting [u32; 3] in memory reloads two offsets across two stores of different widths, about two interlocks per sequence, but at about five instructions per cycle the loop is bound by its instruction count, which the interlocks barely change.

  • Measured and rejected for decompress (2026-10-14): appending every sequence to the vector with extend_from_slice/extend_from_within is slower (Alice level 1: 110 µs against 96 µs) because every chunk reloads and stores the vector’s length through memory, which serializes the copies; appending only sequences of 1 KiB or more is what VecOutput does now. Decoding a batch of sequences before executing it, in two out-of-line loops, executes about 15% more instructions; one allocation for the three sequence tables changes nothing.

  • Measured and rejected (2026-10-15, silesia.tar levels 1, 3 and 19): copying matches with offsets of 32 or more in 16-byte chunks, as ZSTD_wildcopy does, instead of 32-byte ones (no faster; the hot store after the match load is a cache miss on the match source, which the reference pays too).

  • With the default 16 codegen units, a size change in huffman.rs or fse.rs moves the unchanged SequenceTables::execute by about ±1.6% of its instructions (26.45M against 26.87M on Alice level 3, identical source), and its time by about ±2%. The table builds rejected on 2026-10-15 for a 2.5-4% slowdown were measured through that noise; with execute controlled for they are faster and are now in place. Check loop changes by execute’s instructions per sequence or a codegen-units = 1 build, not by time alone.

  • Measured and rejected (2026-10-18, i7-13700KF): per-stream staging buffers in the pair loop (fewer instructions per round, more mispredictions and more Huffman instructions overall); a run-based pair table build (no fewer instructions, more mispredictions); zeroing growing output in 2-16 KiB chunks without appends (±0.5%), or with prefetchw 16 KiB ahead of the zeroed front (Alice 2% slower); 64 KiB zeroing chunks (14-23% slower on the 64 KiB binary input, which they zero whole); the cold path for growing outputs as a separate #[cold] function (1% fewer instructions, 6-8% slower on Alice from layout). Sequence-loop rewrites each added instructions per sequence on Alice level 1: one bounds test per refill instead of two (+1.0), literals as an advancing slice (+5.0, +7.0 with a merged offset/window test), current rows held by value (+24.9) or by reference (+11.0), the decoder copied into run (+12.1), the fixed-output loop out of the function (+3.0); repeat offsets as [u64; 3] (+2.9% in total, no faster) or packed into a u64 and a u32 (+12.4%, slower). Almost any change to this function moves its register allocation by 1 to 25 instructions per sequence. Executing matches that start in a dictionary inline (the extDict branch of ZSTD_execSequence) made dictionary decoding 9% faster but large decodes without a dictionary 1.5-3% slower through the same code-generation shift; it is not kept.

  • The one-shot sequence loop executes 126.7 instructions per sequence on Alice level 1 (AVX2 code, Ryzen 9 7950X, 2026-10-29) against the reference’s 118.8; both spill the decoder state to the stack on every sequence. It is the largest remaining gap: 74-89% of decoding time at every level, against the reference’s 77-90%. Literal-heavy text (Alice) still decodes a few percent slower than the reference through decompress: the Huffman pair loop originally executed about 13.2 instructions per lookup against the reference’s 11.25 (spills: four streams’ bits, positions and outputs plus their bases do not fit in 15 registers), before the interleaved three-stream schedule above and the fixed-size loop, which keeps two of its four streams’ containers in registers across a round and stores the other two between rounds. The single-symbol loop used 8.8 per symbol against 8.2, and about 2% of the time zeroes room for short sequences (VecOutput::grow). Both loops are limited by each stream’s chain of dependent lookups, as the reference’s are: removing 9-12% of their instructions made no measurable difference in a codegen-units = 1 build. Raw-literal frames (Alice level -5) decode at parity.

  • SeqTable::fill_rows still runs outside the SIMD dispatch, without BMI or LZCNT in a portable build. Its alphabet rows are now static and constructing a BitCount no longer needs a table lookup. Building the pair table costs about 6K instructions per tree beyond its 8 KiB zeroed allocation.

  • decompress_to_slice on 4 MiB of text is about 11% slower than ZSTD_decompressDCtx (level 1: 2.18 ms against 1.85 ms; level 3: 1.89 ms against 1.65 ms) while executing only about 4% more instructions (111.9M against 107.3M), so it retires fewer instructions per cycle; finding the stall needs a cycle-level profiler, which this WSL2 host lacks. One suspect is the repeat-offset shift, which compiles to an 8-byte reload spanning two earlier stores that cannot be forwarded; u64 repeat offsets removed that pattern without measurable gain.

  • decompress_to_slice takes the incremental path, with its extra copies, for frames without a content size.

  • No prefetching variant for long offsets (ZSTD_decompressSequencesLong) and no split literal buffer.

Decoder verification

How the decompressor is checked against RFC 8878 and the zstd 1.5.7 reference. Unit tests sit beside each private core module; integration tests in tests/decoder_*.rs drive the public API against the reference through the zstd-ffi oracle (zstd_ffi::oracle).

graph TD
    subgraph unit[Unit tests in decompressor::core]
        Bits[bits: marker, padding, overflow, wide reads]
        Fse[fse: distributions, repeat flags, Appendix A tables, weight decoder]
        Huf[huffman: RFC example tree, 1 and 4 streams, invalid weights, 12-bit trees]
        HufRef["huffman::reference: exact sections, fast-path stream ends, last pairs, BIT_DStream_t model, pair rows"]
        Lit[literals: all size formats, RLE, treeless reuse]
        Seq[sequences: baselines, overlapping copies, repeat-offset rules, invalid sequences, dictionary reach]
        DictU[dictionary: entropy section, repeat-offset limits, truncation]
        Frame[frame / block / history / stream]
    end
    subgraph integration[tests/decoder_*.rs]
        Oracle[decoder_oracle: reference frames, every entry point, chunkings, windows, limits]
        Golden[decoder_golden: golden-decompression and golden-decompression-errors]
        Corrupt[decoder_corruption: every truncation, bit flips, random damage]
        Ends[decoder_huffman_ends: fuzz finds, damaged variants, synthetic sections]
        Api[decoder_api: modes, Finish contract, budgets, typed errors, retention]
        Io[decoder_io: Read / Write adapters, deferred and sink errors]
        Uninit[decoder_uninit: process_uninit and decompress_to_uninit against the initialized methods, sentinels]
        Dict[decoder_dictionary: raw and trained dictionaries, IDs, entropy reuse, dictionary acceptance]
    end
    Ref --> Dict
    Ref[zstd 1.5.7 via zstd-ffi] --> Oracle
    Ref --> Golden
    Ref --> Corrupt
    Ref --> Ends
    Spec[format spec Appendix A, vendored copy] --> Fse

What each suite establishes

  • decoder_oracle compresses generated corpora (text, random, records, runs, mixed) with ZSTD_compress2 at levels -5 to 19, sizes 0 to 140 KB, with the checksum and content-size flags varied, and requires byte equality through decompress, decompress_into (after a prefix) and decompress_to_slice (suffix untouched), plus agreement with ZSTD_decompress. Streaming is checked with 1-, 7-, 13- and 4096-byte input pieces against 1-, 3- and 64-byte output buffers and random pieces, and through an owned session. Frames from ZSTD_compressStream2 with random flushes cover unknown content sizes and small blocks; window logs 10, 12 and 17 on 1.2 MB force history compaction and 1 KiB blocks. Concatenated frames with skippable frames, Single mode, and window limits (including a header patched to a 2^28 window) are compared with the reference’s streaming limit. Single mode is also split into members, one session each, and compared with ZSTD_decompressStream run until it reports a frame complete (oracle::decompress_first_frame): leading, trailing and lone skippable frames each form a member. A release-only matrix covers every level from -7 to 22 on 3 MB corpora and 20 MB inputs with long-distance matching and 2^27 windows.
  • decoder_golden decodes the reference’s golden vectors one-shot and in 1/7/whole-input pieces, comparing with the reference, and requires every golden error vector to be rejected as invalid data.
  • decoder_corruption requires every proper prefix of sample frames to fail with UnexpectedEndOfInput, one-shot and streaming. For every single-bit flip of the first 64 bytes and random byte damage, it requires no panic, one-shot and streaming verdicts and outputs to agree, identical output whenever both decoders accept, and the reference’s verdict for every damaged frame, Huffman streams that are not consumed exactly included.
  • decoder_huffman_ends decodes the frames in testdata/huffman-stream-ends (fuzz finds that ZSTD_decompress accepts although their streams are not consumed exactly: two as found, four cut down to their literals and zeroed byte by byte) on every backend, one-shot, into a slice and streaming, and requires the reference’s bytes. 2400 damaged variants of them, and 3000 synthetic frames with random complete trees of up to 12 bits and mostly valid one- or four-stream sections (a few symbols more or fewer per stream, cut, damaged or random streams), optionally followed by a treeless block that inherits the decoder type, must get the reference’s verdict and output. Unit tests in huffman::reference pin each lenient case by hand: streams with bits left, streams reading into the jump table, a stream reading more than a word past its start after the fast loop, and a last pair read past the stream; literals checks that treeless blocks keep the decoder type, and dictionary that a dictionary’s tree is read for pairs.
  • decoder_dictionary compresses with ZSTD_compress2 and a dictionary trained by ZDICT_trainFromBuffer on JSON-like records, or 48 KB of raw text, at every level from -5 to 22 (five levels in debug builds), from one record to inputs past 128 KiB (so the reference both attaches and copies the dictionary; forced attach, copy and load modes are checked too), with checksums, unknown sizes, small windows and long-distance matching. Every frame must decode through decompress_with_dictionary, its _into and _to_slice twins, a borrowed session and an owned session (Arc) fed random pieces, and the reader and writer adapters, on every backend, and concatenations with skippable and dictionary-less frames must decode. Dictionary IDs are checked against the reference’s verdicts (missing, other trained, raw and empty dictionaries); frames whose first block uses treeless literals or repeated tables are found by parsing them and must fail, as in the reference, when the same bytes are loaded as raw content. Every truncation of the entropy section, zero and oversized repeat offsets and 2000 random corruptions must be accepted or rejected exactly as ZSTD_DCtx_loadDictionary_advanced does, and a frame decoded with an accepted corrupt dictionary must equal the reference’s output.
  • decoder_api and decoder_io pin the public contract: frame modes, the Finish suffix rule and InvalidState, failure progress, byte budgets, typed errors (with reference agreement where it applies), retention, owned sessions, and the adapters’ deferred errors, retries, into_parts and FinishError round trips.
  • decoder_uninit checks that the uninitialized methods match their initialized counterparts byte for byte, count for count and error for error, and that no byte past the reported output is written; see uninitialized output.

Running

CARGO_TARGET_DIR=target-dec cargo test --no-default-features --features std,decompression \
    --lib --test 'decoder_*'
CARGO_TARGET_DIR=target-dec cargo test --release --no-default-features \
    --features std,decompression --test decoder_oracle

Function coverage of src/decompressor with cargo llvm-cov --no-default-features --features std,decompression --lib --test 'decoder_*' is 100%.

Magicless frames and header queries

tests/openzl_binding.rs decodes magicless frames in every frame shape, chunking and frame mode against ZSTD_decompress_usingDDict (mzstd_shim_openzl_decode), and compares FrameHeaderInfo::parse with ZSTD_getFrameHeader_advanced on every prefix of generated frames and on random bytes, for both formats.

Containers and integrations

The framed container format, the OpenZL-facing surface and the C ABI.

SpecificationScope
Framed containersThe framed wire format and the framed compressor: owner, sessions, structured input, std adapters, state and memory.
Framed decoderFramedDecompressor: detection, validation, events, dictionaries and budgets; the FramedSeekReader index and dependency plan.
OpenZL integrationThe zstd calls OpenZL’s codec makes and their mzstd counterparts: magicless frames, CParameter, standalone dictionaries and FrameHeaderInfo.
C ABImzstd-ffi: one-shot and session functions, handle lifecycle, validation, panic guard and status mapping.

Architecture overview and module map

Framed containers: wire format and compressor

framed with compression exposes a reusable FramedCompressor, its configuration, borrowed structured input, one-shot operations and native sessions with std or alloc. no_std excludes only FramedWriter, ResourceWriter, FramedEncoderReader and transport errors. The decoder side is described in framed decoder. The public API mirrors mbrotli’s RFC 9841 framing API one to one; the wire format is this crate’s own, built only from Zstandard frames.

Wire format

A container is a sequence of Zstandard frames (RFC 8878). Skippable frames carry every structure; regular Zstandard frames carry visible payload; the last frame is a seek table in the layout of zstd’s contrib/seekable_format. Two consequences follow, and both are tested against the C library:

  • any Zstandard decoder (zstd -d, ZSTD_decompress) skips the skippable frames and outputs exactly the concatenation of the visible resources;
  • the contrib seekable decoder (ZSTD_seekable_decompress) maps every decompressed offset of that concatenation to its frame through the seek table, without reading the container.
graph LR
    H["header frame<br/>skippable 0x184D2A5B: 'MZFR' flags"] --> C1
    subgraph chunks["chunks, in wire order"]
        C1["chunk header frame<br/>skippable 0x184D2A5B"] --> B1["body frame<br/>Zstandard, visible payload"]
        B1 --> C2["chunk frame with inline content<br/>metadata, hidden payload, padding"]
    end
    C2 --> R["repeated metadata (optional)"]
    R --> D["central directory (optional)"]
    D --> F["footer"]
    F --> S["seek table frame<br/>skippable 0x184D2A5E, ends with 0x8F92EAB1"]

All integers are little-endian. A varint is LEB128 of at most nine bytes and 63 bits; decoders accept nonminimal encodings, which the directory then compares byte for byte. Offsets are relative to the first byte of the container header.

Container header

BytesField
40x184D2A5B, the chunk magic (a skippable frame)
4frame size, always 5
4signature MZFR
1flags: bits 0-1 version (0), bit 2 container profile; other bits are preserved

The full (container) profile allows any number of resources, metadata, padding, an optional central directory, and ends with a footer. The single-resource profile (bit 2 clear) holds exactly one resource and optional padding, and has no footer. Both end with the seek table.

Chunks

Each chunk starts with one skippable frame of magic 0x184D2A5B whose content is the chunk header followed by inline content. A frame of size 0 is padding without content. The chunk header is RFC 9841’s header without its leading length varint (the frame size takes its place):

FieldPresent forEncoding
typeevery chunk0 padding, 1 resource metadata, 2 complete resource, 3/4/5 first/middle/last partial resource, 6 footer metadata, 7 global metadata, 8 repeated metadata, 9 central directory, 10 footer
codectypes 1-80 stored, 2 one Zstandard frame, 3 one Zstandard frame with dictionary references; 1 is reserved and invalid
declared sizecodec 2, 3varint: decoded content bytes
referencescodec 3count (1-16), then per reference a flag (0 prefix resource, 1 prefix chunk, 4 serialized resource: varint offset; 2 prefix id, 6 serialized id: byte 3 and 32 identity bytes)
flagstypes 2-5bit 0 hidden (types 2, 3), bit 1 checksum (types 2, 5), bit 2 detached; other bits invalid
checksumflag bit 1byte 3 and 32 bytes, recorded, never computed
body lengthflag bit 2varint: size of the detached Zstandard frame
repeated kindtype 81 or 6

A detached chunk has no inline content: its content is the regular Zstandard frame that immediately follows the header frame, exactly body length bytes. The placement is canonical and the decoder enforces it:

  • every chunk of a visible resource is detached (codec 2 or 3), so its payload is a top-level frame; a visible ResourceEncoding::Uncompressed resource is written as a single-segment frame of raw blocks;
  • every chunk of a hidden resource is inline: codec 0 stores the bytes, codec 2 or 3 nests a Zstandard frame inside the skippable frame;
  • metadata is always inline, stored or compressed.

Every compressed content is exactly one complete Zstandard frame; a skippable frame, trailing bytes or a short frame inside it is invalid. Each chunk’s frame is independent of every other chunk’s (there is no decoder continuation across chunks), which is what makes each seek table entry decodable on its own.

A Zstandard frame takes one dictionary, so references name either a single serialized (Zstandard-format) dictionary, or one to sixteen prefixes whose contents, concatenated in order, form one raw-content dictionary. Internal references address an earlier chunk (prefix chunk: its decoded content) or the first chunk of an earlier complete resource (prefix or serialized resource: its decoded payload). External references name a caller-managed 256-bit DictionaryId resolved by the decoder’s DictionaryResolver.

Metadata content is RFC 9841’s serialization: per field a two-byte code, a varint length and the value. Uppercase codes may repeat; id (UTF-8) and mt (eight bytes) appear at most once and only in resource metadata.

Suffix

ChunkContent
repeated metadata (8)a copy of every resource and footer metadata in order, optionally restricted to selected codes, each with its own encoding
central directory (9)varint offset of the first repeat (0 for none), then per content chunk (types 1-8): varint offset, varint header length, the exact header bytes (skippable frame header and chunk header)
footer (10)u64 directory offset (0 for none), u64 container size through the end of the seek table (0 unspecified)

The seek table frame (magic 0x184D2A5E, size 8n + 9) lists every frame from the container header through the footer: header frames and inline chunks with decompressed size 0, body frames with their content size. Its footer is the frame count, a zero descriptor (no checksums) and 0x8F92EAB1. Frames are at most 4 GiB and a table lists at most 0x8000000 frames, the contrib limits.

Ownership and dispatch

graph TD
    Facade[framing: neutral types and public re-exports] --> Owner[FramedCompressor]
    Owner --> Raw[Compressor: Aggressive inner retention]
    Owner --> Engine[private framing core::engine::Engine]
    Engine --> Container["Container: frame queue, records, seek table entries"]
    Engine --> Resource["Resource: chunk staging and emission"]
    Resource --> OneShot["Compressor::compress_into per chunk"]
    OneShot --> SIMD[fearless_simd backend selected once at owner construction]
    Guard[FramedResourceSession] -. borrowed per call .-> Dictionary[external PreparedDictionary]
    Guard --> Owner
    Borrowed[FramedEncoderSession: &mut owner] --> Owner
    Owned[FramedEncoderSessionOwned: owner by value] --> Owner

FramedEncoderSession borrows the owner and FramedEncoderSessionOwned owns it; neither holds state of its own. Both go through the private begin_session start path, the same Engine calls, and cancel on release. The owner stores no sink, input slices or dictionary references: resource guards hold the dictionary borrow, and the lazy input driver looks up its borrowed description on each call.

Each chunk is compressed with one Compressor::compress_into (or compress_with_dictionary_into) call on the staged chunk, so every body frame is byte-identical to ZSTD_compress2 of the chunk’s bytes under the same parameters, and the owner’s backend is selected once and dispatched once per block inside the encoder core. A dictionary must have been prepared for the owner’s encoder configuration; begin checks this before any output (EncodeError::DictionaryMismatch).

Public input and API

FramedEncodeConfig combines EncoderConfig and the public-field FramingConfig; the builder selects backend and outer retention. Defaults are a full container with a directory, no repeats, 65,536 bytes per chunk, 1 MiB metadata, 8 MiB framing storage, 10,000 resources and 1,000,000 chunks. Chunk size (1..=16 MiB, with four chunks plus 8 KiB of budget) and profile combinations are validated before use.

FramedInput borrows an ordered slice of FramedItem: complete resources, metadata or padding. FramedResource carries payload, visibility and checksum, a StreamConfig size contract, and ResourceEncoding::{Uncompressed, Zstd, Shared}. compress, append and rollback compress_into, compress_to_slice and framed_reader share one lazy core::driver::Driver that advances the same engine as native sessions; no destination stages the whole container.

sequenceDiagram
    participant Caller
    participant API as Session / borrowed-input driver
    participant Engine
    participant Raw as Compressor
    participant Out as Caller slice / std transport
    Caller->>API: start(stream aggregate)
    API->>Engine: queue header frame, seek entry (13, 0)
    Caller->>API: process(output, Process)
    Engine->>Out: header frame prefix
    Caller->>API: resource(options, stream, dictionary)
    API->>Engine: validate references and dictionary, commit resource
    Caller->>API: process(input, output, End)
    Engine->>Raw: compress_into(staged chunk) per full chunk and at End
    Raw-->>Engine: one independent Zstandard frame
    Engine->>Out: chunk header frame, body frame (two seek entries)
    Caller->>API: container Finish
    Engine->>Out: repeats, directory, footer, seek table in 8 KiB batches

Container process consumes no payload. Its NeedsInput admits the next command; NeedsOutput requires draining. Structural methods reject OutputPending before commit. The container’s exact size counts accepted payload across all resources, hidden ones included, and excludes metadata and padding.

State, cancellation and retry

stateDiagram-v2
    [*] --> HeaderPending: start
    HeaderPending --> Idle: drain header
    Idle --> Pending: metadata or padding committed
    Pending --> Idle: drain output
    Idle --> Resource: commit resource guard
    Resource --> Resource: Continue or Flush
    Resource --> Idle: final chunk delivered and guard released
    Resource --> Abandoned: unfinished guard dropped or forgotten
    Idle --> Finishing: Finish
    Finishing --> Finishing: repeats, directory, footer, seek table pieces
    Finishing --> Complete: seek table footer delivered
    Resource --> Failed: process error
    Finishing --> Failed: process error
    Abandoned --> Cancelled: container Drop
    Failed --> Cancelled: container Drop
    Complete --> Cancelled: container Drop applies retention
    Cancelled --> [*]

A full chunk stays staged until another byte, Flush or End decides whether it is the last one, so an exact first-chunk fill followed by End is a complete (type 2) chunk. Flush and End record their input boundary; retries must keep the operation and remaining suffix. Repeated empty Flush emits nothing. A resource is Finished once its final chunk was delivered; the container is Finished once the seek table’s last byte was.

Process errors are terminal, with exact per-call accepted/produced counts and a location (resource index and input offset, structured item index, delivered wire offset). Structural errors before commit do not poison the engine. FramingFinishError retains a whole std writer for retry.

flowchart TD
    Commit[Engine queues one chunk: header frame plus detached body] --> Q[Advance queued offset and seek entries once]
    Q --> Copy[Copy bounded prefix into caller output]
    Copy --> Native[Advance delivered wire count]
    Native --> Transport[Writer holds fixed 8192-byte inline buffer]
    Transport --> Write[Sink write]
    Write -->|short write| Cursor[Advance transport cursor]
    Cursor --> Write
    Write -->|Interrupted| Write
    Write -->|WouldBlock, zero, oversized count, error| Retain[Keep unwritten suffix for retry]
    Retain --> Write
    Write -->|all accepted| Next[Request next engine prefix]
    Next --> Copy
    Next -->|container complete| Flush[Flush sink]

Normal container drop releases the owner for reuse. A forgotten guard leaves abandoned protection until recover. Raw retention stays Aggressive within a container; the owner’s ReleaseAll/Bounded policy applies at the container boundary.

Memory

Framing allocations use fallible reservations and checked budgets: chunk input and content staging, pending output, directory records, repeated metadata copies, the repeat field selection and eight bytes of seek table entry per frame. Records and entries scale with the number of chunks, so there is no O(1) bound for a whole container. The seek table is emitted in batches of 1,024 entries without a second copy. Raw encoder workspace, external dictionaries and caller destinations are outside the framing budget.

Verification

tests/framed_oracle.rs checks containers against zstd’s own decoders: ZSTD_decompress must output the concatenated visible resources, and the contrib seekable decoder (compiled into zstd-ffi) must read every range of them. tests/framed_encoder.rs compares every way of writing a container (native sessions at every output width and input split, one-shot, append, slice, reader and writer, every backend), checks each body frame byte for byte against ZSTD_compress2 of its chunk, and walks every container with an independent wire parser (tests/framed_support); tests/framed_encoder_owned_session.rs and tests/framed_encoder_memory.rs cover owned sessions, injected allocation failures and retained bytes. The decoder suites are listed in framed decoder. AFL targets framed_encode, framed_decode and framed_roundtrip (fuzzing) drive random command schedules and inputs. benches/framed_compress.rs compares owner reuse with the contrib seekable compressor.

Known gaps

  • Speed: with 64 KiB chunks on 1 MiB inputs (Apple M5 Pro, loaded host, cargo bench --features framed --bench framed_compress), levels 1 and 3 run at 0.75-0.9x of the contrib seekable compressor and level 19 at 1.0-1.05x; sizes are within 0.4% of contrib’s for large resources. Each chunk is staged, compressed into separate content storage and copied into the pending queue; those copies have not been profiled away. Many tiny resources cost about 47 bytes each of structure (header frame, directory record, seek entries).

  • No checksum computation or verification of the caller-supplied whole-resource identity, and no seek table checksums.

  • No automatic dictionary resolver or symbolic references; references are explicit offsets and identities.

  • Compressing a chunk starts a fresh frame: there is no cross-chunk history (by design, so each frame decodes alone).

  • The reader consumes borrowed complete slices; gradually arriving payload uses native guards or ResourceWriter.

  • Retained-byte accounting is requested heap storage, not allocator overhead or an RSS ceiling.

Framed decoder and seek reader

framed with decompression exposes FramedDecompressor: one-shot decoding into owned, appended or caller-sliced storage, incremental sessions (borrowed and owned) that report one semantic event per call, a std FramedReader over BufRead, and a std FramedSeekReader that reads individual resources of a container through Read + Seek. The wire format is specified in framed containers; this page covers how it is validated and decoded.

Ownership and modules

graph TD
    Owner[FramedDecompressor] --> Engine["core::Engine: phases, records, budgets"]
    Engine --> Wire["core::wire: chunk header and metadata parsers"]
    Engine --> Codec["decompressor::core::Stream (single-frame mode per chunk)"]
    Engine --> Dict["owned dictionary bytes + DecodeParts"]
    Owner --> Session["FramedDecoderSession / FramedDecoderSessionOwned"]
    Session --> Reader["FramedReader (std): BufRead events"]
    Owner --> Seek["FramedSeekReader (std)"]
    Seek --> Index["core::seek::Index: seek table, footer, directory"]
    Seek --> Operation["core::seek::Operation: dependency plan, positional reads"]
    Operation --> Engine
    Resolver[DictionaryResolver] -. borrowed per call .-> Engine

The engine stores no caller borrows: input, output and the resolver are passed to every call. External dictionaries are copied into owned storage when a chunk that references them begins, within max_dictionary_bytes, and reused while consecutive chunks name the same references. The codec is the decoder’s own streaming Stream, so the framed decoder shares its block decoding, SIMD dispatch (backend chosen once per owner, dispatched once per block) and checksum verification.

Sequential decoding

stateDiagram-v2
    [*] --> Detect
    Detect --> Header: 13-byte container header matched
    Detect --> RawContent: Auto mode, other magic (bytes replayed into the codec)
    Detect --> Failed: dictionary magic, FramedOnly mismatch, version bits
    Header --> ChunkHeader: chunk magic
    Header --> SeekTable: seek table magic, after footer or the single resource
    ChunkHeader --> Begin: header parsed, order checked (ChunkHeader event)
    Begin --> Content: dictionary attached, codec reset (ResourceStart event)
    Begin --> Directory: type 9
    Content --> Content: inline or detached body bytes (ResourceData events)
    Content --> Semantic: content complete and validated
    Directory --> Directory: entries (DirectoryStart, DirectoryEntry events)
    Directory --> ChunkEnd: DirectoryEnd event
    Semantic --> ChunkEnd: Padding, Metadata or Footer event
    ChunkEnd --> ResourceEnd: types 2 and 5
    ChunkEnd --> Header
    ResourceEnd --> Header: ResourceDataEnd event
    RawContent --> Finish: codec finished (raw input)
    SeekTable --> Finish: every entry matches its frame (SeekTable event)
    Finish --> [*]: Finished, with zero progress
  • Detection. The first four bytes decide: the Zstandard dictionary magic is UnexpectedInputKind::SerializedDictionary; the chunk magic starts a container, whose size field and MZFR signature must follow; any other input is InvalidSignature, or raw Zstandard frames in InputMode::Auto. Raw input is one resource decoded in concatenated-frame mode; the bytes read while detecting are replayed into the codec first.
  • Chunk headers are accumulated byte by byte and parsed by wire::header, which never reads past the skippable frame’s size and reports None until the header is complete.
  • Order. As in RFC 9841: partial resources are contiguous, resource metadata precedes its resource, footer metadata follows one, repeats follow everything but precede the directory, and only the footer follows the directory. After the footer only the seek table may follow. A data chunk is detached exactly when its resource is visible.
  • Content. Stored inline content is copied. Compressed content (inline or detached) goes through the codec in single-frame mode with an output ceiling fixed when the chunk begins: the tightest of the declared size, max_decoded_bytes, max_output_bytes or max_metadata_bytes, max_resource_bytes, and the stream’s exact output size. Exceeding it maps back to LimitExceeded or DeclaredSizeMismatch. The frame must end exactly at the content’s end, be a Zstandard (not skippable) frame, and decode to exactly its declared size.
  • Seek table. Its size must list exactly one entry per frame read, each entry must equal the frame’s compressed size and decompressed size, the frame count and magic must match, the descriptor must be zero (checksums are not part of this format), and a nonzero footer file size must equal the container’s length. The call that reads the table’s last byte reports FramedEvent::SeekTable (also recorded in FramedLayout::seek_table); the next call reports Finished without consuming or producing anything, so a container ends itself without a caller Finish.
  • Internal dictionary cache. With InternalDictionaryPolicy::Retain, decoded resource payload is kept so later chunks can reference it. The cache grows within max_dictionary_bytes (and the workspace budget); once a growth would exceed them it stops for the rest of the object, and only a later reference to content it did not keep fails, with LimitExceeded { kind: DictionaryBytes }. Containers larger than the budget therefore decode with default limits.

Metadata, repeated metadata, the central directory and resource accounting follow mbrotli’s engine: repeated copies must match their originals on the selected codes, the directory must list every content chunk’s exact header bytes in order, and the footer must name the directory.

Errors are terminal (InvalidState afterwards) and carry exact per-call accepted and produced counts and the last written fragment. Budgets cover input, output, decoded, resource, metadata, field, chunk and resource counts, and owned framing, dictionary and workspace bytes; the codec’s retained history is checked against the workspace ceiling after each call.

Seek reader

sequenceDiagram
    participant Caller
    participant Seek as FramedSeekReader
    participant Index as core::seek::Index
    participant Source as Read + Seek
    Caller->>Seek: framed_seek_reader(source)
    Seek->>Source: container header (13 bytes)
    Seek->>Source: seek table footer (last 9 bytes), table frame, entries
    Seek->>Source: footer frame (the last listed frame)
    Seek->>Source: central directory records
    Index->>Index: match every frame: record, body, directory, footer or padding
    Index->>Source: read each padding header (9 bytes)
    Caller->>Seek: resource(index)
    Seek->>Index: plan target chunks and dictionary dependencies
    loop each planned chunk
        Seek->>Source: re-read header bytes, compare with the directory copy
        Seek->>Source: read content (inline or detached body)
        Seek-->>Caller: payload of target chunks only
    end

Opening requires the container profile and a directory (CentralDirectoryRequired otherwise). The seek table locates every frame: the sum of its compressed sizes must equal the table’s offset, its first entry is the container header, its last the 25-byte footer. Each directory record must start at a listed frame, have that frame’s size, and a detached record’s next entry must be its body with the declared size as its decompressed size. Every other frame must be the directory or padding; padding is recognized from its 9-byte header without reading its content.

Reading a resource plans its chunks plus the closure of dictionary dependencies (earlier resources or chunks) in one backward pass; only dependencies enter the retained cache. Each planned chunk’s header is re-read and compared with its directory copy (DirectoryMismatch) before its content reaches the codec. Metadata is decoded lazily and cached.

Verification

tests/framed_decoder.rs hand-assembles containers to reach every validation error, decodes every input split and output size identically, and compares random containers with ZSTD_decompress on every backend; tests/framed_decoder_owned_session.rs covers owned sessions and resolvers; tests/framed_seek.rs covers indexing, dependency plans, header mismatches and failing sources against the contrib seekable decoder. benches/framed_decompress.rs measures one-shot and seek reads against ZSTD_decompressDCtx and the contrib seekable decoder.

Known gaps

  • Speed: on 1 MiB inputs at level 3 (Apple M5 Pro, loaded host, cargo bench --features framed --bench framed_decompress), one-shot framed decoding runs at 0.65-0.75x of ZSTD_decompressDCtx and of the contrib seekable decoder on compressible data. The engine decodes through the streaming codec into 8 KiB staging and resets it per chunk; it has not been profiled yet.
  • Caller-supplied whole-resource checksums are recorded, not verified.
  • Seek tables with checksums (descriptor bit 7) are rejected rather than verified.
  • The seek reader decodes a resource from its first chunk; it does not seek to a decompressed offset inside a resource (the contrib seekable decoder can, through the seek table).
  • Raw InputMode::Auto input is decoded as concatenated frames until the end of input; a protocol suffix after raw frames is an error.

OpenZL integration surface

OpenZL v0.3.0 drives zstd through a fixed set of calls (encode_zstd_binding.c, decode_zstd_binding.c, common_zstd.c). This specification maps each call to the mzstd API that reproduces it byte for byte, and records the zstd 1.5.7 behaviours that shape the mapping. mzstd itself knows nothing about OpenZL: every item is a general zstd feature, and the “node” in the tests is a Rust transcription of the binding.

Call map

graph LR
    subgraph OpenZL encoder node
        R1["ZSTD_CCtx_reset(session_and_parameters)"]
        R2["setParameter(format, magicless)"]
        R3["setParameter(compressionLevel)"]
        R4["setParameter(literalCompressionMode, uncompressed)"]
        R5["setParameter(id, value) for each local int param"]
        R6["ZSTD_CCtx_refCDict + ZSTD_compress2"]
        R7["setPledgedSrcSize + ZSTD_compressStream2 flush ... end"]
        R8["ZSTD_createCDict_advanced(getCParams(level, 0, size))"]
        R9["ZSTD_compressBound"]
    end
    subgraph mzstd
        M1["EncoderConfig::default()"]
        M2["EncoderConfig::with_format(FrameFormat::Magicless)"]
        M3["CParameter::CompressionLevel"]
        M4["CParameter::LiteralCompressionMode(Switch::Disabled)"]
        M5["CParameter::try_from((id, value)) + check_memory_cap + with_parameter"]
        M6["Compressor::compress_with_dictionary"]
        M7["Compressor::start(InputSize::Exact) + process(.., Flush) ... process(.., End)"]
        M8["DictionaryBuilder::with_level_parameters"]
        M9["Compressor::max_compressed_size"]
    end
    R1 --> M1
    R2 --> M2
    R3 --> M3
    R4 --> M4
    R5 --> M5
    R6 --> M6
    R7 --> M7
    R8 --> M8
    R9 --> M9
zstd callmzstd
ZSTD_CCtx_reset(.., session_and_parameters)Build a fresh EncoderConfig::default(). ZSTD_reset_session_only is what starting a new frame already does (Compressor::start, one-shot calls and reconfigure abandon an unfinished session).
ZSTD_c_formatEncoderConfig::with_format / CParameter::Format; decoder: DecoderConfig::with_format.
ZSTD_CCtx_setParameter(id, value)CParameter::try_from((id, value)), then EncoderConfig::with_parameter.
EI_zstd_parameter_validCParameter::check_memory_cap(3); the host refuses Format and ContentSizeFlag itself.
ZSTD_compress2, ZSTD_CCtx_refCDictCompressor::compress* and compress_with_dictionary* with a standalone PreparedDictionary.
ZSTD_CCtx_setPledgedSrcSize, ZSTD_compressStream2Compressor::start(InputSize::Exact(n).into()) and EncoderSession::process with Operation::Flush / Operation::End.
ZSTD_createCDict_advanced(.., ZSTD_getCParams(level, 0, size))DictionaryBuilder::with_level_parameters(level); CompressionParams::for_level is ZSTD_getCParams.
ZSTD_compressBoundCompressor::max_compressed_size.
ZSTD_createDDict_advanced(byCopy, auto), ZSTD_decompress_usingDDictDecodeDictionary::new(bytes, DictionaryContent::Auto) (or try_from), Decompressor::decompress_with_dictionary*.
ZSTD_getFrameHeader_advanceddecompressor::FrameHeaderInfo::parse(src, format).
ZSTD_DCtx_reset, ZSTD_d_formatA Decompressor built from DecoderConfig::with_format; a decoder holds no parameters between calls.

Frame format

FrameFormat is a crate-level enum (Standard, Magicless) shared by the encoder and decoder configurations. It travels in core::cctx::FrameParams::format, the same struct that carries the content size, checksum and dictionary-ID switches, so AppliedParams already holds it when the header is written.

flowchart TD
    Cfg["EncoderConfig::format"] --> Lower["internal::requested_params -> FrameParams.format"]
    Lower --> Applied["AppliedParams.fparams"]
    Applied --> Header["cctx::write_frame_header"]
    Header -->|Standard| Magic["write 0xFD2FB528, then descriptor, window, id, size"]
    Header -->|Magicless| NoMagic["descriptor, window, id, size at offset 0"]
    DCfg["DecoderConfig::format"] --> Stream["decompressor::core::stream"]
    Stream -->|Standard| Sniff["read magic: frame, skippable or InvalidData(Magic)"]
    Stream -->|Magicless| Direct["State::Magic goes straight to State::FrameHeader"]
  • Nothing but the four magic bytes differs: blocks, the checksum and the window are the standard frame’s, so a magicless frame equals the standard frame minus its first four bytes (asserted at every level in tests/openzl_binding.rs).
  • A magicless decoder reads no skippable frames and has no magic to resync on; concatenated frames follow one another header to header. The format of a stream cannot be detected, so both ends must agree.
  • ParallelCompressor::new and the framed compressor write their own headers, always with a magic number, and refuse the magicless format with ConfigError::Unsupported.

Parameters by ID

compressor::parameter::CParameter is the validated form of one (ZSTD_cParameter, value) pair. TryFrom<(i32, i32)> ports ZSTD_CCtx_setParameter for a single-threaded build (the oracle is compiled without ZSTD_MULTITHREAD):

flowchart TD
    Pair["(id, value)"] --> Known{"id defined by zstd 1.5.7?"}
    Known -->|no| Unknown["ConfigError::UnknownParameter"]
    Known -->|yes| Kind{"kind"}
    Kind -->|"level (100)"| Clamp["clamp to -131072..=22, 0 -> default"]
    Kind -->|"logs, sizes (0 = default)"| Zero{"value == 0?"}
    Zero -->|yes| Default["store 0: the level's value"]
    Zero -->|no| Bounds{"within bounds?"}
    Bounds -->|no| Range["ConfigError::OutOfRange"]
    Bounds -->|yes| Store["typed variant"]
    Kind -->|"flags (200-202, 1000)"| Flag["value != 0"]
    Kind -->|"nbWorkers, jobSize, overlapLog, rsyncable"| MT{"value == 0?"}
    MT -->|no| Unsup["ConfigError::Unsupported"]
    MT -->|yes| Ign["CParameter::Ignored"]
    Kind -->|"targetCBlockSize (130)"| TCB{"value == 0?"}
    TCB -->|yes| NoTarget["TargetCBlockSize(None)"]
    TCB -->|no| TB{"within 1340..=131072 after raising to 1340?"}
    TB -->|no| Range
    TB -->|yes| Target["TargetCBlockSize(Some)"]
    Kind -->|"no-op for single-threaded bytes"| Ign

EncoderConfig::with_parameter applies the variant to the matching option, so “last value wins” and “0 restores the default” fall out of the option types (Option<u32> for the numeric ones). Facts worth keeping in mind:

  • Long distance matching keeps its sizes separately from its switch (EncoderConfig::ldm, ldm_switch), because the reference stores ldmHashLog and friends whatever enableLongDistanceMatching is; a later switch to Enabled or the automatic enabling at btopt and a 2^27 window uses them. The older with_long_distance_matching still resets the values.
  • ForceMaxWindow is modelled completely: it turns attaching a dictionary off (ZSTD_shouldAttachDict) and makes a force-loaded dictionary obey the window (loadedDictEnd = 0).
  • TargetCBlockSize is the superblock path of target compressed block size. The reference raises any non-zero value, negative ones included, to 1340 before it checks the upper bound, and ZSTD_CCtx_getParameter returns the raised value; the typed parameter and its (i32, i32) pair do the same, so a container that stores the pair it read back replays the same frame.
  • Ignored parameters are validated against their bounds and dropped: enableDedicatedDictSearch (any value; mzstd never builds dedicated tables), stable buffers, blockDelimiters, validateSequences, deterministicRefPrefix, prefetchCDictTables, enableSeqProducerFallback, repcodeResolution (only the sequence API reads it), and the multithreading parameters at 0.
  • literalCompressionMode is Switch: Disabled stores literals raw in every block (ZSTD_lcm_uncompressed); the optimal parser also prices literals as raw (opt.rs).

Dictionaries: refCDict versus loadDictionary

zstd 1.5.7 treats a dictionary differently depending on how it reaches the context, and OpenZL uses the ZSTD_CDict route:

flowchart TD
    Start["frame start: ZSTD_CCtx_init_compressStream2"] --> Origin{"dictionary origin"}
    Origin -->|"loadDictionary: ZSTD_initLocalDict"| Local["CDict built with the CCtx's own parameters; level kept"]
    Origin -->|"refCDict"| Ref["level := cdict->compressionLevel (ZSTD_NO_CLEVEL = 0 -> default)"]
    Local --> Mode
    Ref --> Mode["mode = attachDict if ZSTD_shouldAttachDict else noAttachDict"]
    Mode --> Params["params.cParams from the (possibly replaced) level plus the CCtx overrides"]
    Params --> Use{"forceLoad?"}
    Use -->|yes| Load["reset the CCtx, index dictionary bytes with its own params"]
    Use -->|no| Attach{"attach?"}
    Attach -->|yes| ByAttach["dictMatchState: cdict cParams, window log from params"]
    Attach -->|no| ByCopy["copy cdict tables, cdict cParams, window log from params"]
  • A CDict made by ZSTD_createCDict_advanced records ZSTD_NO_CLEVEL (0). On refCDict the context level is therefore replaced by the default level before the frame’s parameters are resolved, whatever level the compressor requested; the dictionary’s own tables use the cParams it was created with. A dictionary loaded with loadDictionary keeps the context’s level and parameters.
  • mzstd models the two as PreparedDictionary’s standalone flag. DictionaryBuilder::with_level_parameters(level) builds EncoderConfig::default().with_params(CompressionParams::for_level(level, 0, len)) and marks the dictionary standalone: any compressor accepts it (PreparedDictionary::serves), and requested_for swaps the compressor’s level for the default one when resolving a frame. Everything else a compressor requests (window log, checksum, dictionary ID flag, attach preference, forced window) still applies, as it does to a referenced ZSTD_CDict.
  • ZSTD_getCParams(level, 0, size) reads a source size of 0 as unknown (ZSTD_CONTENTSIZE_UNKNOWN), so the table row is selected as for an unknown source with a dictionary (ZSTD_getCParamRowSize); CompressionParams::for_level follows it.
  • The dictionary is only ever built from DictionaryContent::Auto/Raw/Zstd bytes by copy; the type is detected from the leading magic number as ZSTD_dct_auto does, and an empty dictionary is accepted (and still replaces the level).
  • The binding uses the dictionary only on its ZSTD_compress2 route. When it cuts one block per element (element width of at least 1024 and at most 8 elements) it streams without a dictionary.

Streaming with a pledged size and flushes

The binding’s loop calls ZSTD_compressStream2 with a growing ZSTD_inBuffer and ZSTD_e_flush, switching to ZSTD_e_end for the last element, after ZSTD_CCtx_setPledgedSrcSize(srcSize). The output buffer holds the whole bound, so each call consumes its element fully. In mzstd:

sequenceDiagram
    participant Node
    participant Compressor
    participant Session as EncoderSession
    Node->>Compressor: start(InputSize::Exact(total))
    Compressor->>Session: pledged size set, session reset
    loop each element but the last
        Node->>Session: process(element, out, Operation::Flush)
        Session-->>Node: consumed == element.len(), bytes of the finished blocks
    end
    Node->>Session: process(last, out, Operation::End)
    Session-->>Node: Finished, remaining blocks, epilogue

Operation::Flush ends the current block early exactly where ZSTD_e_flush does, which is what the per-element concatenation depends on (tests/openzl_binding.rs::the_encoder_node_writes_the_reference_bytes_at_every_level covers element counts 2 to 8 at widths from 1 KiB to 300 KB).

Decoder side

  • DecoderConfig::with_format(FrameFormat::Magicless) changes only the frame grammar (see above).
  • FrameHeaderInfo::parse(src, format) is ZSTD_getFrameHeader_advanced: content size (None for unknown), window size, dictionary ID, checksum flag and header size, with the reference’s early magic check on short input, skippable-frame headers, the reserved bit and the 2^31 window limit. UnexpectedEndOfInput stands for a positive “need more” result.
  • DecodeDictionary with DictionaryContent::Auto is ZSTD_createDDict_advanced(byCopy, auto); ZSTD_decompress_usingDDict is decompress_with_dictionary (every frame starts from the dictionary; a frame naming another ID is DictionaryMismatch).

Known gaps

  • Multithreaded modes and dedicated dictionary search are not implemented. The first is refused with ConfigError::Unsupported; dedicated dictionary search is accepted and ignored, so a dictionary that C would build with it differs. ZSTD_c_targetCBlockSize is implemented (target compressed block size).
  • Magicless frames are written by Compressor only (not by the parallel or framed compressors) and are not in the C ABI.
  • The oracle is single-threaded: the accepted set of the parameters nbWorkers, jobSize, overlapLog and rsyncable is the one of a build without ZSTD_MULTITHREAD.

Verification

tests/openzl_binding.rs runs OpenZL’s calls through the oracle shim (mzstd_shim_openzl_encode / _decode, line-for-line ports of the binding’s zstd calls, with a reused ZSTD_CCtx) and through mzstd, and compares bytes: levels -5 to 22 and 0, sizes 0 to several MiB, raw and Zstandard-format dictionaries at several dictionary levels, attach preferences, forced window, local parameter lists including the node’s policy refusals, header queries on every prefix and on random bytes, and the numeric acceptance of every parameter ID against ZSTD_CCtx_setParameter. tests/openzl_config.rs and the unit tests of compressor::core::dict_tests cover the option surface and the forced-window dictionary behaviour.

C ABI

mzstd-ffi is a separate workspace crate that exports C functions over mzstd: one-shot compression at a level, one-shot decompression of a sequence of frames, the output bound, and incremental encoder and decoder sessions behind opaque handles. It builds a cdylib, a staticlib and an rlib (for the Rust tests), and ships include/mzstd.h.

Layout

graph TD
    Header[include/mzstd.h] -.declares.-> Lib
    Header -.declares.-> Session
    Lib["lib.rs: MzstdResult, mzstd_compress, mzstd_decompress, mzstd_compress_bound"] --> Core["core.rs (private): Region, Slot, Arguments, panic guard, codecs"]
    Session["session.rs: MzstdEncoder, MzstdDecoder, MzstdStatus, MzstdOperation, MzstdDecodeOperation, mzstd_encoder_*, mzstd_decoder_*"] --> CoreSession["core/session.rs (private): Encoder, Decoder, Handle, create, apply, process, free"]
    CoreSession --> Core
    Core --> Mzstd[mzstd: Compressor, Decompressor]
    CoreSession --> Owned[mzstd: EncoderSessionOwned, DecoderSessionOwned]

lib.rs and session.rs hold only the exported items and their contracts. Each one-shot function forwards to core::call with a closure, and each session function to core::session::{create, apply, process, free}, so all the unsafe code lives in core. MzstdEncoder and MzstdDecoder are public newtypes over the private core::session::Encoder and Decoder; C sees them only as incomplete structs.

Call flow

sequenceDiagram
    participant C as C caller
    participant X as mzstd_compress / mzstd_decompress
    participant K as core::call
    participant G as guarded (catch_unwind)
    participant Z as core::compress / core::decompress
    C->>X: input, input_len, output, &output_len, (level)
    X->>K: forward
    K->>K: LengthSlot::new: non-null, aligned
    K->>K: read *output_len
    K->>K: Arguments::new: null/length/overlap checks
    K->>K: slices(): &[u8], &mut [MaybeUninit<u8>]
    K->>G: run codec
    G->>Z: safe Rust only
    Z-->>G: Ok(written) or FfiError
    G-->>K: panic becomes FfiError::Panic
    K->>K: write written (0 on failure) to *output_len
    K-->>C: MzstdResult

Sessions

classDiagram
    class MzstdEncoder {
        <<opaque handle>>
    }
    class MzstdDecoder {
        <<opaque handle>>
    }
    class Encoder {
        session: EncoderSessionOwned
        poisoned: bool
    }
    class Decoder {
        session: DecoderSessionOwned
        poisoned: bool
    }
    class Handle {
        <<trait>>
        operation(c_int) Operation
        step(input, output, Operation) Step
        poisoned() bool
    }
    MzstdEncoder *-- Encoder
    MzstdDecoder *-- Decoder
    Handle <|.. MzstdEncoder
    Handle <|.. MzstdDecoder

A handle is a Box the C caller owns between *_create and *_free. The encoder wraps Compressor::into_session at the requested level (content size on, checksum off, as a fresh ZSTD_CCtx); the decoder wraps Decompressor::into_session with FrameMode::Concatenated and a window limit of 2^window_log_max (0 keeps DecodeLimits::DEFAULT, 2^27, the reference’s ZSTD_d_windowLogMax default). Handle is the one trait the generic process and apply are written against: it parses the C operation, runs one process_uninit step and exposes the poison flag.

stateDiagram-v2
    [*] --> Open: mzstd_*_create
    Open --> Open: process OK (NEEDS_INPUT / NEEDS_OUTPUT)
    Open --> Finished: process OK (FINISHED)
    Finished --> Finished: process OK, nothing moved
    Open --> Failed: process MZSTD_ERROR (codec error)
    Failed --> Failed: process MZSTD_ERROR
    Open --> Open: process MZSTD_INVALID_PARAMETER
    Open --> Open: reset
    Finished --> Open: reset
    Failed --> Open: reset
    Open --> Poisoned: codec panic
    Poisoned --> Poisoned: process / reset MZSTD_ERROR
    Open --> [*]: mzstd_*_free
    Finished --> [*]: mzstd_*_free
    Failed --> [*]: mzstd_*_free
    Poisoned --> [*]: mzstd_*_free

Failed is the mzstd session’s own terminal state (EncodeError / DecodeError::InvalidState on later calls); reset clears it through EncoderSessionOwned::reinit(stream) or DecoderSessionOwned::reinit(). Poisoned is the FFI layer’s flag: a panic may leave the codec half-updated, so core::session::run refuses every later call on the handle with FfiError::Poisoned, and only free remains. Invalid arguments are rejected before the session is touched, so they never change its state.

sequenceDiagram
    participant C as C caller
    participant X as mzstd_encoder_process / mzstd_decoder_process
    participant P as core::session::process
    participant R as core::session::run
    participant S as Handle::step
    C->>X: handle, input, &input_len, output, &output_len, operation, &status
    X->>P: forward
    P->>P: Outputs::new: three non-null, aligned slots (else return, nothing written)
    P->>P: read *input_len, *output_len
    P->>P: Slot::new(handle), H::operation(operation)
    P->>P: Arguments::new: buffers vs each other, the three slots and the handle struct
    P->>P: slices(), &mut *handle
    P->>R: run(handle, step)
    R->>R: poisoned? -> FfiError::Poisoned
    R->>S: guarded (catch_unwind)
    S->>S: session.process_uninit(src, dst, op)
    S-->>R: Step or FfiError
    R->>R: Panic -> set poisoned
    R-->>P: outcome
    P->>P: OK: write *status; write consumed / produced (0, 0 on failure)
    P-->>C: MzstdResult

create validates its out-pointer, builds the handle inside the panic guard, and stores the pointer, or null on failure. free ignores null and misaligned pointers, rebuilds the Box, and drops it inside the guard. apply (behind both *_resets) validates the handle and runs its body through run.

Status mapping

flowchart LR
    NL[NullPointer] --> IP[MZSTD_INVALID_PARAMETER]
    ML[MisalignedPointer] --> IP
    NB[NullBuffer] --> IP
    OB[OversizedBuffer] --> IP
    OV[Overlap] --> IP
    LV[Level] --> IP
    WL[WindowLog] --> IP
    OP[Operation] --> IP
    ETS["Encode(OutputTooSmall)"] --> OTS[MZSTD_OUTPUT_TOO_SMALL]
    DTS["Decode(OutputTooSmall)"] --> OTS
    EO["Encode(other)"] --> ER[MZSTD_ERROR]
    DO["Decode(other)"] --> ER
    P[Panic] --> ER
    PO[Poisoned] --> ER

FfiError is private. Its Level variant keeps the ConfigError as its source, WindowLog its DecodeConfigError, and the codec variants keep their EncodeError or DecodeError (a session’s DecodeFailure is reduced to its error; the lengths report zero on failure). Only the status code crosses the boundary. Sessions report progress through a separate mzstd_status (NEEDS_INPUT, NEEDS_OUTPUT, FINISHED), mapped one to one from EncoderStatus and DecoderStatus and written only on MZSTD_OK.

Contracts

FunctionBehavior
mzstd_compressZSTD_compress(level) byte for byte: content size recorded, no checksum. Levels -131072..=22; 0 is 3. Out-of-range levels are rejected rather than clamped. A destination of mzstd_compress_bound bytes always suffices. A smaller one fails exactly when ZSTD_compress fails: the reference needs room for an 18-byte worst-case header and some slack, so a destination as long as the frame may be refused.
mzstd_decompressZSTD_decompress: every Zstandard frame in order, with skippable frames skipped. Empty input decodes to nothing. Window logs up to 31 are accepted, because the destination already bounds memory. The destination is viewed as MaybeUninit<u8> and decoded with decompress_to_uninit: it is never zeroed in full, and on success no byte past the result is written.
mzstd_compress_boundZSTD_compressBound; 0 when it overflows size_t.
mzstd_encoder_createA handle at a level -131072..=22 (0 is 3), its first frame started with an undeclared size; *encoder is null on failure.
mzstd_encoder_resetAbandons the current frame and starts one of pledged_size bytes (ZSTD_CCtx_setPledgedSrcSize), or MZSTD_CONTENT_SIZE_UNKNOWN (UINT64_MAX). Clears a failure, not a poison.
mzstd_encoder_processOne ZSTD_compressStream2 step with MZSTD_CONTINUE, MZSTD_FLUSH or MZSTD_END: the same inputs, capacities and operations give the same bytes. Input contradicting the pledged size, including a first END of another length, is MZSTD_ERROR (the reference instead re-pledges on a first END).
mzstd_decoder_createA handle for concatenated frames with windows up to 2^window_log_max (10..=31, 0 for 27). The session allocates its window, so the limit bounds memory.
mzstd_decoder_resetAbandons the current operation and starts a new one.
mzstd_decoder_processOne ZSTD_decompressStream step with MZSTD_DECODE_PROCESS or MZSTD_DECODE_FINISH. A frame boundary reports NEEDS_INPUT until FINISH confirms the end; input ending inside a frame is MZSTD_ERROR. No byte past the produced ones is written.
mzstd_encoder_free, mzstd_decoder_freeRelease a handle; null is ignored.

*output_len is the capacity on entry and the bytes written on return. It is 0 after any failure, except when output_len itself is null or misaligned, in which case it is never touched. Every one-shot call builds and drops its own codec, so those functions are thread-safe. A session’s *input_len and *output_len follow the same rule (offered and capacity on entry, consumed and produced on return, both 0 after a failure, none of the three out-pointers written when any is null or misaligned). A handle may move between threads but serves one call at a time.

Invariants

  • Validation happens before any dereference: null pointers with a non-zero length, lengths above isize::MAX, ranges that wrap the address space, and any overlap among input, output and the length slot are rejected.
  • Empty buffers become empty slices whatever their pointer is.
  • A session call’s buffers are also checked disjoint from its three out-pointers and from the handle’s own struct; the handle’s heap state cannot be checked and is the caller’s obligation.
  • A handle is dereferenced only after every check passed, and the borrow ends before the out-pointers are written.
  • No panic unwinds into C, and a handle a panic interrupted is never run again.

Verification

mzstd-ffi/tests/c_abi.rs calls the exported symbols through their ABI and:

  • compares frames with ZSTD_compress at 19 levels over empty, tiny, text, binary, incompressible and multi-block inputs;
  • compares the bound with ZSTD_compressBound;
  • compares each short destination’s outcome with the reference;
  • decodes reference frames with checksums and 2^30 windows;
  • checks concatenated and skippable frames, every rejection path, and concurrent calls;
  • checks that the status codes match the header.

mzstd-ffi/tests/sessions.rs drives the session functions the same way and:

  • compares frames with ZSTD_compressStream2 (oracle::compress_stream_with_output) at six levels over six inputs, four chunk/flush schedules and output capacities of 128 KiB, 4 KiB and 7 bytes, reusing one handle per level through mzstd_encoder_reset;
  • compares pledged-size frames, and checks that a contradicted pledge fails until the next reset;
  • decodes at input chunks of 1, 7 and 4096 bytes and output buffers down to one byte, reference frames with checksums, corrupt checksums, concatenated and skippable frames, frame boundaries before FINISH, truncated input, and the window limit at 27 and 28;
  • checks every rejection path (null and misaligned handles and out-pointers, out-of-range levels, window logs and operations, overlaps) and that rejections leave the session usable;
  • moves handles to other threads and runs them side by side.

core/session.rs unit-tests the status and operation mapping, the window limits, the pledged-size sentinel, and poisoning after a panic.

benches/one_shot.rs times both libraries through identical calls after checking that their frames are identical. benches/session.rs does the same for sessions, with one reused context per side, 64 KiB input chunks and 64 KiB (encoder) / 128 KiB (decoder) output buffers.

Known gaps

  • No advanced-parameter (checksum, window log, LDM, strategy) or dictionary entry points, one-shot or session.
  • No session entry point reports the decoder’s totals, frame count or window size, or a failed call’s partial progress.
  • mzstd-ffi has no AFL or libFuzzer target of its own; the mzstd sessions it wraps are fuzzed directly.
  • mzstd_decompress decodes a 64-byte frame about 5 ns slower than when it zeroed the destination and called decompress_to_slice; see uninitialized output.

Development tools

How the repository measures, tests, proves and publishes itself.

SpecificationScope
FuzzingAFL targets, input layout, corpora, regressions and campaigns, and the libFuzzer adapters ClusterFuzzLite builds.
Formal verificationCharon/Aeneas translation to Lean 4, the RFC 8878 transcription, function specifications, round trips, trusted base and the RFC coverage checklist.
CIWorkflows and the checks they run: fuzzing, Miri, AddressSanitizer and hotpath Cloud benchmarks.
BenchmarkingThe cold C-versus-mzstd comparison harness: corpus, preflight validation, Criterion sampling and the report and chart generators.
Documentation publishingSource staging, navigation, section pages, recipe execution, link validation and Pages deployment.

Architecture overview and module map

Fuzzing

AFL++ targets live in fuzz/afl, a package excluded from the workspace because its binaries link AFL’s runtime. Target bodies are plain functions in src/targets.rs; each binary in src/bin/ is a three-line afl::fuzz! adapter, so committed inputs replay through the same bodies under cargo afl test without instrumentation. A second excluded package, fuzz/libfuzzer, runs the same bodies under libFuzzer for ClusterFuzzLite; see libFuzzer adapters and ClusterFuzzLite.

graph TD
    Seeds["prepare-seeds.sh: vendored golden-compression files and zstd.h behind parameter headers, benchmark text at one level per strategy"] --> AFL[cargo afl fuzz]
    AFL --> Bin["src/bin/<target>.rs: afl::fuzz!"]
    Bin --> Body["src/targets.rs: target body"]
    Body --> Decode["src/lib.rs: decode_config"]
    Body --> Mzstd[mzstd]
    Body --> Ref["zstd-ffi oracle"]
    Findings[findings/: crashes] -->|cargo afl tmin| Reg["regressions/<target>/*.bin"]
    Reg --> Replay["tests/regressions.rs (cargo afl test)"]
    Replay --> Body
    Bench["tests/benchmark_corpora.rs: the comparison benchmark's 8 inputs x 24 levels"] --> Body
    Framed["framed feature: src/framed_targets.rs, bins framed_*"] --> Mzstd
    Framed --> Ref

Input layout

decode_config turns the first bytes into an EncoderConfig and the equivalent reference Params, so every target compares like with like.

flowchart LR
    B0["byte 0: level (value % 40), target compressed block size (value / 40)"] --> B1["byte 1: flags"]
    B1 -->|0x10| W[window log byte]
    B1 -->|0x20| S[strategy + min match byte]
    B1 -->|0x40| H[search / hash / chain byte]
    B1 -->|"0x04/0x08"| L[LDM disable / enable + min match byte]
    B1 -->|0x80| Z[two size-hint bytes]
    Z --> P["payload, capped at 256 KiB"]

Flags 1 and 2 set the checksum and clear the content size. Targets that need more bytes (capacity, schedule, task count) take them from the start of the payload, so every target shares one seed corpus.

Targets

TargetOracle
differential_cCompressor::compress equals ZSTD_compress2 with the same parameters, and the frame decodes.
output_capacitycompress_to_slice into an arbitrary capacity returns the reference’s frame, or OutputTooSmall exactly when ZSTD_compress2 fails (a reference write past the capacity counts as a failure; see encoder verification); compress_to_uninit into the same capacity returns the same result and frame.
encoder_sessionA session driven by random chunks, flushes and output capacity, with calls alternating process and process_uninit, equals ZSTD_compressStream2 driven the same way (compress_stream_with_output).
compressor_lifecycleReuse, reconfigure and trim(ReleaseAll) never change a frame.
parallelParallel frames decode with the reference and are identical for 1–8 tasks run in reverse order, whether the batch borrows the input or reads it from a SeekSource through prepare_source with each task on a detached thread. A session (windows 1–4, an optional flush, calls alternating process and process_uninit, which must leave every byte past produced untouched) and the ParallelEncoderWriter/ParallelEncoderReader adapters (one executor thread each) decode; with a declared size, or an input within one segment, and no flush they equal the batch frame; the writer always equals the session driven with the same writes and flush.
parameter_parsingHeader decoding never panics; window logs are accepted exactly in 10..=31, and 0 restores the default; CParameter::try_from((id, value)) agrees with ZSTD_CCtx_setParameter on acceptance, and accepted lists compress to the reference’s bytes.
decompressRaw bytes: no panic; anything mzstd accepts, ZSTD_decompressStream accepts with the same output; decompress_to_slice agrees with decompress; decompress_to_uninit returns the same result and bytes as decompress_to_slice and leaves every byte past it untouched.
decode_streamingTwo piece sizes, then a frame: a session, with calls alternating process and process_uninit (which must leave every byte past produced untouched), agrees with one-shot decoding and never over-reports progress. decode_roundtrip and decode_dictionary drive their sessions the same way.
decode_roundtripParameter header: frames the reference writes with that configuration decode to the payload, one-shot and in pieces.
decode_dictionaryTwo-byte dictionary length, a content-type byte (% 3: auto, raw, Zstandard), the dictionary, then frames: mzstd accepts the dictionary exactly when ZSTD_DCtx_loadDictionary_advanced does; whatever frames it then decodes, ZSTD_decompressDCtx with that dictionary decodes to the same output; decompress_with_dictionary_to_slice and a session fed pieces agree with one-shot decoding.
compress_dictionaryA parameter header, then a two-byte dictionary length, a byte for the content type (% 3) and the session’s chunk size (/ 3), the dictionary and the payload: the dictionary is accepted exactly when the reference accepts it after ZSTD_CCtx_loadDictionary_advanced; compress_with_dictionary equals ZSTD_compress2 and a session fed chunks equals ZSTD_compressStream2; frames decode with both decoders.

Decoder targets cap output at 16 MiB, so a few run-length blocks cannot exhaust memory. The decoder rejects some inputs the reference accepts (see decompressor), so a rejection by mzstd is not compared with the reference.

Framed targets

The opt-in framed feature (framed = ["mzstd/framed"]) builds three more targets from src/framed_targets.rs. Their binaries require the feature, their module and their entries in tests/regressions.rs are gated on it together, and framed_targets_follow_feature_selection checks, in both configurations, that each binary requires framed and has committed regressions. The fuzz package’s dependencies never enable the feature, so the stable configuration builds and replays without these targets.

flowchart LR
    E["framed_encode: selector byte + payload (≤ 1 KiB)"] --> S1["native session schedule, chunk size 1-31, output width 1/2/7/31"]
    S1 --> Eq["= FramedWriter = compress = framed_reader"]
    Eq --> D1["FramedDecompressor, ZSTD_decompress (with the raw prefix for shared resources), contrib seekable reader"]
    R["framed_roundtrip: two settings bytes + payload (≤ 4 KiB)"] --> W[FramedWriter, repeated metadata, chunk size 1-63, five levels]
    W --> D2["decode, seek read, ZSTD_decompress, contrib seekable range"]
    D2 --> Dec
    Dec["framed_decode: arbitrary bytes (≤ 64 KiB)"] --> O["Auto mode one-shot, FramedOnly, Vec rollback"]
    Dec --> St["streaming on every backend: split input, outputs 0-32 bytes"]
    Dec --> Sk["seek reader over a Cursor"]
TargetOracle
framed_encodeByte 0 selects the chunk size (% 31 + 1), flush at the middle (bit 0), stored (bit 2) or prefix-dictionary (bit 3) encoding, hidden (bit 4), a checksum identity (bit 5), the output width (bits 6-7) and the backend. A native session (also checking OutputPending, a rejected lowercase global field and a dangling internal reference) equals FramedWriter, and without a flush compress and framed_reader. The container decodes back with a resolver; ZSTD_decompress outputs exactly the payload when visible and nothing when hidden; without a dictionary the contrib seekable reader agrees.
framed_decodeArbitrary bytes, Auto mode with tight limits: no panic or zero-progress loop. Raw input decodes like Decompressor; a framed container without dictionaries decodes with ZSTD_decompress and the contrib seekable reader to its visible resources; FramedOnly accepts exactly the framed results; decompress_into rolls back; a session fed stride input bytes and 0-32 output bytes per call on every backend agrees with one-shot decoding, never over-reports progress, and finishes with zero progress; a seek reader over the same bytes, when it opens, returns every resource and its metadata exactly as the sequential decode.
framed_roundtripTwo settings bytes (chunk size % 63 + 1, level among 1, 3, 9, 19, -5, checksum) and a payload through FramedWriter with resource metadata and repeated metadata: the decode, a seek read, ZSTD_decompress and a contrib seekable read from a third of the payload all return it; then the container goes through framed_decode.

Their regressions hold boundary inputs (empty, a lone selector, a bare container header, a dictionary magic, raw Zstandard frames, a truncated container) and containers covering every chunk kind, hidden and stored resources, external and internal dictionary references and the single-resource profile, generated once with the encoder. campaign.sh builds with --features framed and seeds these targets from their regressions.

Corpora

prepare-seeds.sh builds seeds/params from upstream’s tests/golden-compression payloads and lib/zstd.h, each behind twelve parameter headers, the first 64 KiB of testdata/alice29.txt (the comparison benchmark’s text) at levels -5, 1, 3, 5, 6, 9, 13, 16, 19 and 22, and the committed bench-* regressions; and seeds/frames from the golden decompression fixtures. Campaigns use seeds/frames for decompress, the committed regressions for decode_streaming, decode_dictionary and compress_dictionary, and seeds/params for every other target. Seeds and findings are local artifacts and are gitignored. The committed regressions/<target>/ hold boundary inputs (empty, truncated header, all-ones header, every override) and minimized findings. regressions/decode_dictionary/ holds frames the reference wrote with a small trained dictionary and with raw text, the trained bytes read as raw content, a frame without a dictionary ID, and the rejections: a truncated dictionary, a zero repeat offset and the wrong dictionary ID. regressions/compress_dictionary/ holds payloads with a trained dictionary at several levels (also read as raw content, and with a 12-bit window at level 22), with raw text, an empty dictionary (which must behave as none; this seed caught the first encoder mismatch) and the rejections: raw bytes as the Zstandard format and a truncated dictionary. regressions/decompress/huffman-stream-end-*.bin are frames whose Huffman streams are not consumed exactly and which zstd 1.5.7 accepts through its fast four-stream path (found by a downstream crate’s fuzzer, four of them cut down to their literals and zeroed byte by byte); the target now decodes them and compares the bytes with the reference’s. The decompress target checks only that what this decoder accepts the reference accepts identically; the other direction is checked by tests/decoder_huffman_ends.rs and tests/decoder_corruption.rs, since the documented exceptions make it false for arbitrary input.

The comparison benchmark’s cases are regressions too. tests/benchmark_corpora.rs generates its eight inputs with the benchmark’s own generators (testdata/alice29.txt, Alice cycled to 1 MiB, the structured 64 KiB binary, xorshift data at 64 KiB and 1 MiB, 1 MiB of a, the 44-byte text, empty input), puts each behind the header for each of the benchmark’s 24 levels (-5, -1 and 1 to 22, no flags), and replays all 192 through differential_c (byte identity with ZSTD_compress2) and decode_roundtrip (the reference’s frames decode one-shot and in pieces). The 1 MiB inputs are cut to the targets’ 256 KiB cap, as any payload is. The 44-byte text at every level is also committed as regressions/{differential_c,decode_roundtrip}/bench-tiny-text-l<level>.bin, and empty input at every level as regressions/differential_c/boundary-bench-empty-l<level>.bin.

Campaigns

campaign.sh [seconds] [targets…] builds in release mode and runs one AFL instance per target in parallel. It then reports crashes, hangs and AFL’s own statistics. Two 4-minute runs (the six encoder targets, then the three decoder targets) found no crashes. They saved three timeouts, but each replays in under 2 ms, including under afl-showmap with the same 20 s limit. One was saved as AFL stopped the run at its time limit. The other two were saved mid-run on a loaded macOS host, and are kept as regressions named afl-timeout-not-reproduced-*.

The first decoder run also exposed a bug in the test oracle rather than in mzstd: oracle::decompress_stream took the header hint returned by an extra call after a frame ending exactly at a full output buffer as “truncated”. It now records only calls that made progress, and zstd-ffi/tests/oracle_stream_boundary.rs covers both outcomes.

The ClusterFuzzLite cron of 2026-10-27 found another harness bug in framed_roundtrip: with one-byte chunks a 4 KiB payload makes a chunk per byte plus the metadata and seek table, more than the 4096 chunks the shared decode limits allowed, so the decoder rightly returned LimitExceeded { kind: Chunks }. The limit is now twice the largest round-trip payload, and regressions/framed_roundtrip/ keeps the CI input and a 4096-byte payload with one-byte chunks.

A 10-minute run of decode_dictionary from its committed regressions (2026-10-15) found no crashes or hangs: 952 new corpus items, 40% map coverage.

A 10-minute run of all eleven targets (2026-10-18, after the 0.2.7 encoder and decoder optimizations, seeds including the benchmark inputs) found no crashes or hangs: differential_c 1,470 new corpus items, output_capacity 605, encoder_session 1,731, compressor_lifecycle 3,365, parallel 185, parameter_parsing 3,683, decompress 387, decode_streaming 1,119, decode_roundtrip 226, decode_dictionary 1,174, compress_dictionary 2,746.

The CI campaign for the 0.2.12 tag (2026-11-09) failed in output_capacity, again on the oracle rather than mzstd. With ZSTD_c_targetCBlockSize, zstd 1.5.7’s ZSTD_compressSubBlock_literal and ZSTD_compressSubBlock_sequences copy the Huffman and FSE table descriptions without checking the room left, so ZSTD_compress2 into a destination somewhat smaller than the frame wrote past the compress2_capacity buffer and corrupted the heap. Persistent mode runs many inputs in one process, so the abort landed on a later, innocent input: the CI crash does not reproduce alone, while a short local run from it found inputs that crash deterministically in ZSTD_freeCCtx. compress2_capacity now passes the capacity with a guard band behind it and reports a write into the band as CAPACITY_OVERRUN; mzstd returns OutputTooSmall at each such capacity. Both inputs are kept as regressions/output_capacity/{target-block-reference-overrun,ci-2026-11-09-after-reference-overrun}.bin, zstd-ffi/tests/oracle_capacity_overrun.rs covers the oracle, and tests/compressor_target_block.rs compares mzstd at every capacity of such frames. A 5-minute run from the findings afterwards found no crashes (2,420 new corpus items), where 45 seconds had found 13 before.

libFuzzer adapters and ClusterFuzzLite

fuzz/libfuzzer is a cargo-fuzz package excluded from the root workspace, like fuzz/afl. It owns no target logic: each of its 14 fuzz_targets/*.rs files is a libfuzzer_sys::fuzz_target! adapter around the body the AFL binary of the same name runs, taken from the mzstd-afl library by path. That library names no AFL symbol; only fuzz/afl/src/bin/ calls afl::fuzz!, and nothing in the libFuzzer package links those binaries. A libFuzzer finding therefore replays through cargo afl test once its input is committed under fuzz/afl/regressions/<target>/.

graph TD
    subgraph afl["fuzz/afl"]
        aflbins["src/bin/ — afl::fuzz! adapters"]
        bodies["library: targets, framed_targets, decode_config"]
        regs["regressions/ and tests/regressions.rs"]
    end
    subgraph lf["fuzz/libfuzzer"]
        lfbins["fuzz_targets/ — fuzz_target! adapters<br/>11 stable, 3 framed"]
    end
    subgraph cflite[".clusterfuzzlite"]
        docker["Dockerfile: base-builder-rust"]
        build["build.sh"]
    end
    aflbins --> bodies
    regs --> bodies
    lfbins --> bodies
    docker --> build
    build -->|cargo fuzz build| lfbins
    build -->|prepare-seeds.sh, regressions| corpora["OUT/target_seed_corpus.zip"]

The bodies take only the input bytes, so the adapters hold no state. The framed adapters carry required-features = ["framed"], the gate their bodies already share; the package’s framed feature only forwards to mzstd-afl/framed. The targets are exactly the AFL package’s: all eleven stable ones and framed_decode, framed_encode and framed_roundtrip.

Build and corpora

build.sh runs inside ClusterFuzzLite’s Rust image. It fetches the pinned zstd submodule when the checkout left it empty, because every C oracle and the seed script need it. It then builds twice, each time with -O --debug-assertions, which also turns on overflow checks.

Under the coverage sanitizer, build.sh runs cargo build --release --target <host> --bins in fuzz/libfuzzer instead of cargo fuzz build, adding -Cdebug-assertions to the image’s RUSTFLAGS. The image’s cargo wrapper rewrites cargo fuzz build into cargo build --bins run from ./fuzz and drops --fuzz-dir, --target-dir and --features. Here that would build the root crate, which has no binaries, so OUT would stay empty. The direct build writes to the same <target-dir>/<host>/release as cargo fuzz, so both builds ship their binaries the same way. It also remaps fuzz_targets to an absolute path for the report, as the wrapper does.

flowchart TD
    Start[build.sh] --> Sub{vendored zstd present?}
    Sub -->|no| Fetch[git submodule update at the pinned commit]
    Sub -->|yes| Mode
    Fetch --> Mode{SANITIZER}
    Mode -->|address| Stable[cargo fuzz build into WORK/stable]
    Mode -->|coverage| Stable2[cargo build --release --target host into WORK/stable]
    Stable --> ShipS[copy every built target to OUT]
    Stable2 --> ShipS
    ShipS --> Framed[same build with --features framed into WORK/framed]
    Framed --> ShipF[copy only targets not yet in OUT]
    ShipF --> Seeds[prepare-seeds.sh]
    Seeds --> Zip[per target: mapped seeds plus regressions, zipped]

The stable binaries come from a build without framed, so they fuzz the default surface a dependant gets; the framed build’s copies of the same targets are not shipped. The two builds use separate target directories.

Seed selection follows campaign.sh: decompress takes seeds/frames; decode_streaming, decode_dictionary, compress_dictionary and the framed targets read their own layouts and take only their regressions; every other target takes seeds/params. Every target also gets its committed regressions/<target>/ inputs. Files get a seeds- or regressions- prefix so equal names cannot collide. No size filter is applied: prepare-seeds.sh cuts payloads to 64 KiB, the largest regression is under the 256 KiB payload cap, and libFuzzer guesses its default maximum input length from the corpus, so dropping the larger regressions would also stop it from generating inputs that span several blocks. No target has a dictionary.

The workflows that run these binaries are described in CI.

Known gaps

  • No target drives the std::io adapters or owned sessions with fuzzed input; the integration tests cover them.

The first 10-minute run of compress_dictionary (2026-10-15) found two classes of crash, both kept as regressions: an empty dictionary with the Zstandard content type, which the reference treats as no dictionary (fixed), and frames at levels 16-22 with a dictionary whose literal and match length tables omit their last symbols, where the reference reads uninitialized memory and is not reproducible itself (dictionaries); the target now checks byte identity only for dictionaries with complete length tables. A second 10-minute run of compress_dictionary and decode_dictionary found no crashes or hangs (2093 and 922 new corpus items).

Formal verification

Machine-checked Lean 4 proofs that parts of the Rust implementation follow RFC 8878. The Rust source of selected items is translated into Lean by Charon and Aeneas; hand-written theorems then prove properties of that translation against a transcription of the RFC.

The specification is the RFC, never the C library. The encoder’s contract of byte identity with zstd 1.5.7 stays with the differential tests; the proofs show something different and independent of it: that what both sides of the codec compute is what RFC 8878 prescribes, that the encoder writes fields the RFC’s decoding reads back to the values it meant, and that the translated code cannot panic or overflow on the inputs the specifications cover.

Today the proofs cover the frame and block headers (sections 3.1.1.1 and 3.1.1.2), Number_of_Sequences and the two-bit modes (3.1.1.3.2.1), the literals length, match length and offset codes (3.1.1.3.2.1.1), the default distributions (3.1.1.3.2.2), the repeat offsets (3.1.1.5), the magic numbers (3.1.1, 3.1.2) and the FSE state transitions (4.1.1). This document describes what is verified, how the translation stays tied to the Rust code, what the proofs trust, and what is not covered.

Ownership boundaries

PathOwnerContents
scripts/extract_lean.pyhand-writtenPins the Aeneas release, lists the translated items, regenerates or checks the translation
verification/lean/Mzstd/Code/Types.lean, Code/Funs.leangenerated, never editedAeneas translation of the listed Rust items
verification/lean/Mzstd/Code/FunsExternal.leanhand-written modelThe one standard-library function the translation calls and the Aeneas library does not model, <[T]>::first
verification/lean/Mzstd/Rfc.leanhand-written transcriptionRFC 8878 as printed: header fields and formulas, code tables, default distributions, repeat offset rules, Table 21, with consistency checks
verification/lean/Mzstd/*.lean (others)hand-written proofsSpecifications of the generated functions, round-trip and uniqueness theorems, the axiom audit
verification/lean/lakefile.lean, lean-toolchain, lake-manifest.jsonhand-written pinsLean 4.35.0 and the Aeneas Lean library at the same commit as the binaries

The Lean project is not part of the Cargo workspace and nothing in src/ refers to it: no attribute, feature or dependency was added to the crate. Charon compiles the ordinary crate with its own nightly toolchain into target/charon/, so the stable build is untouched.

graph TD
    Rust["src: shared/format.rs, compressor/core/{cctx, block, seq_store}.rs, decompressor/core/{frame, block, sequences, fse, literals}.rs"] -->|Charon: rustc MIR| LLBC[LLBC]
    LLBC -->|Aeneas -split-files| Code[Mzstd/Code/Types.lean, Funs.lean: generated]
    Ext[Mzstd/Code/FunsExternal.lean: std model] --> Code
    Script[scripts/extract_lean.py] -.drives and pins.-> LLBC
    Script -.--check compares.-> Code
    Script -.EXTERNALS must match.-> Ext
    AeneasLib[Aeneas Lean library: Result monad, scalar and slice models, step tactic] --> Code
    Mathlib[Mathlib] --> Rfc
    Rfc[Mzstd/Rfc.lean: RFC 8878 transcribed] --> Basic
    Code --> Basic[Mzstd/Basic.lean: platform facts, highbit32, little-endian bytes]
    Basic --> Tables[Mzstd/Tables.lean: constants and decoder tables]
    Tables --> Codes[Mzstd/SequenceCodes.lean: ll_code, ml_code, round trips]
    Tables --> Reps[Mzstd/RepeatOffsets.lean: offBase, update_rep]
    Basic --> Fse[Mzstd/Fse.lean: transition]
    Basic --> Block[Mzstd/BlockHeader.lean]
    Basic --> Count[Mzstd/SequenceCount.lean]
    Basic --> Frame[Mzstd/FrameHeader.lean: decoder]
    Basic --> Writer[Mzstd/FrameHeaderWriter.lean: encoder]
    Frame --> RT[Mzstd/FrameRoundTrip.lean]
    Writer --> RT
    Codes --> Audit[Mzstd/Audit.lean: axiom check]
    Reps --> Audit
    Fse --> Audit
    Block --> Audit
    Count --> Audit
    RT --> Audit

Translation

START in the script names the items to translate, grouped by the RFC section they implement; Charon follows everything they call. EXCLUDE is empty: every item the list reaches translates.

Aeneas runs with -split-files, writing the types to Code/Types.lean and the functions to Code/Funs.lean. Standard-library functions the Aeneas library has no model for land in a template of axioms; the script compares their names with EXTERNALS and fails if the set changes. Today there is one, <[T]>::first, modelled in Code/FunsExternal.lean as the head of the list. No axiom reaches the proofs.

Aeneas runs with -loops-to-rec. The translated code lives in Aeneas’ Result monad: every +, -, *, shift, cast and index becomes an operation that fails on overflow, out-of-range shift or out-of-bounds index, exactly as a debug build panics. wrapping_* calls are modelled as wrapping. A proof that a function returns ok is therefore a proof that it cannot panic on those inputs.

Aeneas records each item’s source line range; the script strips the ranges and keeps the file name, so moving code without changing it leaves the translation unchanged. --check regenerates into a temporary directory and fails on any difference.

A few functions were reshaped so that they translate, without changing what they compute: FrameHeader::parse reads its fields by index instead of re-slicing in a branch and folding a reversed iterator (read_le is a while loop, the descriptor’s field sizes and window formula are dictionary_id_size and described_window_size); Number_of_Sequences is read by sequences::sequence_count and written by block::write_sequence_count, out of read_header and the block compressor; the encoder’s frame header is written by the free function cctx::write_frame_header from a FrameHeaderFields value. The unit and differential tests cover the refactored code as before.

flowchart TD
    Start[extract_lean.py] --> Bundle{target/aeneas/RELEASE present?}
    Bundle -->|no| Download[download aeneas-OS-ARCH.tar.gz from the pinned release]
    Bundle -->|yes| Toolchain
    Download --> Toolchain{nightly from bundle rust-toolchain installed?}
    Toolchain -->|no| Rustup[rustup toolchain install with rustc-dev, rust-src, llvm-tools, miri]
    Toolchain -->|yes| Charon
    Rustup --> Charon[charon cargo --preset aeneas --start-from START --exclude EXCLUDE]
    Charon --> Aeneas[aeneas -backend lean -loops-to-rec -split-files]
    Aeneas --> Ext{template axioms equal EXTERNALS?}
    Ext -->|no| Fail[exit: model the new item in FunsExternal.lean]
    Ext -->|yes| Strip[strip source line ranges]
    Strip --> Mode{--check?}
    Mode -->|no| Write[write Code/Types.lean and Code/Funs.lean]
    Mode -->|yes| Compare{identical to the committed files?}
    Compare -->|no| Stale[exit: translation is stale]
    Compare -->|yes| Ok[exit 0]

Specification

Mzstd/Rfc.lean transcribes the RFC with natural numbers and lists, reading multi-byte fields with le, the RFC’s little-endian convention. Nothing in it is derived from the Rust code or from zstd’s C sources.

DefinitionRFC 8878
magicNumber, skippableMagic3.1.1 0xFD2FB528; 3.1.2 0x184D2A50 to 0x184D2A5F
fcsFlag, singleSegment, reservedBit, checksumFlag, didFlag3.1.1.1.1, Table 3
fcsFieldSize, didFieldSize, windowDescriptorSize, headerSizeTables 4 and 5, 3.1.1.1.2
windowSize3.1.1.1.2: windowLog = 10 + Exponent, windowBase + (windowBase / 8) * Mantissa
frameContentSize3.1.1.1.4: the 2-byte form adds 256
parseFrameHeader3.1.1.1: the fields at their offsets, the reserved bit rejected, Window_Size = Frame_Content_Size with Single_Segment_Flag
parseBlockHeader, blockType, blockMaximumSize, blockContentSize3.1.1.2, Tables 9 and 10
compressionModeTable 15
numberOfSequences3.1.1.3.2.1
literalsLengthCodes, matchLengthCodes, holdsTables 16 and 17
offsetValue, offset3.1.1.3.2.1.1: (1 << offsetCode) + readNBits(offsetCode), minus 3 above 3
literalsLengthDefault, matchLengthDefault, offsetDefault, points3.1.1.3.2.2; 4.1.1 counts -1 as 1
initialRepeats, usedOffset, updateRepeats3.1.1.5
nextPowerOfTwo, fseState4.1.1, Table 21

The transcription checks itself, by decide:

  • each length code table is contiguous (every code starts where the previous range ends) and spans the lengths the RFC states, 0 to 131071 and 3 to 131074;
  • each default distribution sums to its table size, counting -1 as 1, and the offset default reaches 536,870,908;
  • updateRepeats reproduces Table 18 row by row and fseState reproduces Table 21.

RFC 8878 prints the last two rows of Table 18 inconsistently with its own text (offset value 1 for the row that inserts Repeated_Offset1 - 1, and 3333 instead of 1111 in the last row). The current Zstandard format document corrects both; updateRepeats follows the prose, and the check uses the corrected rows.

Function specifications

Each theorem is tagged @[step] and states, in the f args ⦃ r => P r ⦄ form, that the generated function returns ok r (no panic, no overflow) and that r satisfies P.

Rust itemPreconditionsResult
shared::format::highbit32value ≠ 0Nat.log 2 value
shared::format tables and constantsnoneLL_BITS, ML_BITS, the three default distributions and their logs, REP_START, the magic numbers equal the RFC’s
decompressor::core::sequences::LL_BASE, ML_BASEnoneentry c is the RFC Baseline of code c (the loop computes the running sum of 2 ^ Number_of_Bits)
decompressor::core::sequences::OF_BITS, OF_BASEnoneOF_BITS[c] = c; for c ≥ 2, OF_BASE[c] + extra = Offset_Value - 3; entries 0 and 1 are 0 and 1
decompressor::core::literals::encoding_typenonethe Table 15 mode of the two low bits
compressor::core::seq_store::ll_codelength ≤ 131071a code below 36 whose Table 16 range holds the length
compressor::core::seq_store::ml_codeml_base ≤ 131071a code below 53 whose Table 17 range holds ml_base + 3
offset_to_offbase, repcode_to_offbase, offbase_is_repcode, offbase_to_offsetno overflowthe offBase sum type is Offset_Value: an offset is stored plus 3, repeat codes 1 to 3 as themselves
compressor::core::seq_store::update_repoff_base ≥ 1; Repeated_Offset1 ≥ 1 when it is decrementedupdateRepeats of section 3.1.1.5
decompressor::core::fse::transitiontable_log ≤ 15, 1 ≤ p ≤ 2 ^ table_log, k < p, counter p + kfseState table_log p k, the pair of Table 21
decompressor::core::block::BlockHeader::parsenoneBlockType for the reserved type, BlockSize above block_max, otherwise the Table 9 fields
BlockHeader::content_sizenoneblockContentSize: one byte for RLE
compressor::core::cctx::write_block_header3 bytes of room, kind ≤ 2, size < 2^21the three bytes read little-endian are last + 2 kind + 8 size
decompressor::core::sequences::sequence_countnoneErr(SequencesSection) exactly when numberOfSequences fails, otherwise its count and size
compressor::core::block::write_sequence_count3 bytes of room, fewer than 0x7F00 + 0x10000 sequencesthe shortest RFC form, which numberOfSequences reads back
decompressor::core::frame::dictionary_id_size, described_window_size, header_sizenoneTable 5, the window formula, the header size
decompressor::core::frame::read_leat most 8 bytesle of the bytes
decompressor::core::frame::FrameHeader::parsenoneErr(FrameHeader) exactly when parseFrameHeader fails, otherwise its fields
compressor::core::cctx::write_frame_header18 bytes of room, 10 ≤ window_log ≤ 31the magic number, then a header parseFrameHeader reads as the window, content size, dictionary ID and checksum flag the encoder asked for

All statements hold for both 32- and 64-bit usize: the proofs only use 32 ≤ System.Platform.numBits ≤ 64.

Headers

The frame and block headers are proved on both sides, and the round trip composes them.

sequenceDiagram
    participant F as FrameHeaderFields
    participant W as write_frame_header
    participant R as Rfc.parseFrameHeader
    participant P as FrameHeader::parse
    F->>W: window_log, dict_id, no_dict_id, checksum, content_size, pledged
    W-->>R: magic (4 bytes LE), descriptor, [window], [dictionary ID], [content size]
    Note over W,R: write_frame_header.spec: the RFC reads the intended fields
    R-->>P: same bytes
    Note over R,P: FrameHeader.parse.spec: the decoder returns the RFC's fields
    P-->>F: window = pledged if single segment else 2^window_log, content size, dictionary ID, checksum

FrameHeader.parse.spec holds for every byte string: the result is Err(FrameHeader) exactly when parseFrameHeader rejects the bytes, and the RFC’s fields otherwise, so the parse cannot panic on any input. write_frame_header_rfc states the encoder side: with 18 bytes of room and 10 ≤ window_log ≤ 31 (no condition on the pledged size), it returns the end of the header, 4 < pos ≤ 18; the first four bytes read little-endian are the magic number, parseFrameHeader of bytes 4..pos is declared header, and the bytes past pos are untouched. frame_header_round_trip composes the two: the decoder’s FrameHeader::parse of those bytes returns the declared fields.

For the frame header the encoder writes the smallest Dictionary_ID field that holds the ID, the single-segment form when the declared size fits the window, and for the content size the one-byte form (single segment, below 256), the two-byte form for 256 to 65791, four bytes up to 0xFFFFFFFE and eight bytes above; each is a form RFC 8878 Table 7 allows. Its Window_Descriptor has mantissa 0 and the unused and reserved bits are 0.

block_header_round_trip states that for every last, every type other than the reserved one and every size up to block_max (and below 2^21), BlockHeader::parse of the three bytes write_block_header wrote returns them. Block sizes are limited by the caller’s block_max; FrameDecoder::begin sets it to min(Window_Size, 128 KiB), which is section 3.1.1.2.4’s Block_Maximum_Size, but begin itself is not translated.

Number_of_Sequences follows the same pattern: write_sequence_count writes the shortest of the three forms (numberOfSequences_shortest: no form the RFC reads gives the same count in fewer bytes) and sequence_count reads every form the RFC defines, so the decoder reads back the encoder’s count and field size (sequence_count_round_trip). The rule that nothing may follow a section with no sequences is checked by read_header, which is not translated.

Length and offset codes

flowchart TD
    Rfc[Rfc.lean: Tables 16 and 17 as printed] --> Contig[contiguous ranges, spans 0..131071 and 3..131074]
    Rfc --> Dec[LL_BASE, ML_BASE, LL_BITS, ML_BITS equal the tables]
    Rfc --> Enc[ll_code, ml_code: the code whose range holds the length]
    Contig --> Unique[holds_unique: at most one code holds a length]
    Enc --> RT[literals_length_round_trip, match_length_round_trip]
    Dec --> RT
    Unique --> RT

literals_length_round_trip: for every literals length up to 131071, the decoder’s LL_BASE entry for the encoder’s code is at most the length, the extra field length - Baseline fits in LL_BITS of that code, and no other code holds the length, so any encoder writes the same code. match_length_round_trip says the same for match lengths 3 to 131074 through ML_BASE and ML_BITS.

For offsets the decoder’s tables give, for every code c ≥ 2, OF_BASE[c] + extra = Offset_Value - 3, the offset of section 3.1.1.3.2.1.1, with OF_BITS[c] = c extra bits. The encoder’s offset code is highbit32 of Offset_Value inside the sequence-code loop of seq_store.rs, which is not translated.

Repeat offsets

The encoder’s “offBase” is Offset_Value: offset_to_offbase adds 3 and offbase_to_offset subtracts it, repeat codes 1 to 3 are themselves. update_rep.spec shows that the encoder’s update after a sequence is updateRepeats, the RFC’s rule, for every offset value and both cases of the literals length:

stateDiagram-v2
    [*] --> Start: initialRepeats = 1, 4, 8 (REP_START)
    Start --> Shift: Offset_Value > 3, or 3 with literals_length = 0
    Shift: r1' = used offset, r2' = r1, r3' = r2
    Start --> Same: Offset_Value 1 with literals_length > 0
    Same: unchanged
    Start --> Swap: 2 with literals_length > 0, or 1 with 0
    Swap: r1' = r2, r2' = r1
    Start --> Rotate: 3 with literals_length > 0, or 2 with 0
    Rotate: r1' = r3, r2' = r1, r3' = r2

The only excluded input is decrementing a Repeated_Offset1 of 0, which the Rust code does with a wrapping subtraction and which no stream reaches: the starting offsets are positive, every offset a sequence uses is, and the decoder rejects a zero offset. The decoder’s own update runs inside the sequence loop of decompressor/core/sequences.rs and is not translated.

FSE state transitions

Section 4.1.1 assigns each state of a symbol with probability p (in natural order, k = 0, 1, ...) a Number_of_Bits and a Baseline (Table 21). SeqTable::fill_rows visits the cells in natural order with a per-symbol counter that starts at p and calls transition(p + k, table_log). transition.spec proves that the result is fseState table_log p k. The proof goes through fseState_eq, which shows that the RFC’s description by shares and doubled states is the closed form Accuracy_Log - log2 (p + k) bits and baseline (p + k) * 2 ^ bits - 2 ^ Accuracy_Log. A “less than 1” symbol is the case p = 1: Accuracy_Log bits from baseline 0.

Mzstd/Audit.lean checks with #guard_msgs that the headline theorems depend only on propext, Classical.choice and Quot.sound; a sorry, native_decide or new axiom fails lake build.

Trusted base

A theorem here is a statement about the Rust code only if these hold:

  • Charon and Aeneas translate the MIR of the listed items faithfully, including overflow, shift and cast semantics;
  • the Aeneas Lean models of the scalar and slice operations used (leading_zeros, shifts, casts, wrapping_sub, to_le_bytes, from_le_bytes, range indexing, copy_from_slice, Option::ok_or, Option::unwrap_or) match Rust;
  • rustc’s MIR for those items matches the code the release build runs (the translation is of the nightly MIR Charon sees, without optimizations);
  • the model in Code/FunsExternal.lean of <[T]>::first matches the standard library;
  • Lean’s kernel and the three standard axioms;
  • Mzstd/Rfc.lean transcribes RFC 8878 correctly. It is short enough to read against the RFC, quotes the sentence each definition encodes, and checks the tables’ contiguity and sums and the RFC’s two worked examples.

Finite facts (table contents, the 64- and 128-entry encoder code tables) are checked with decide +kernel, which the kernel evaluates; no native_decide is used.

Nothing relies on a hand-written restatement of the Rust code.

A mutation check confirms the coupling: changing the + 256 of the two-byte Frame_Content_Size in FrameHeader::parse to + 255 makes extract_lean.py --check fail, and after regeneration FrameHeader.parse.spec no longer proves; changing rep_code >= 2 to rep_code > 2 in update_rep breaks update_rep.spec.

Checks

python3 scripts/extract_lean.py --check   # translation matches the Rust source
cd verification/lean && lake exe cache get && lake build   # proofs and axiom audit

ci-lean.yml runs both on every push to main and every pull request. The proofs check in under a minute once Mathlib’s prebuilt cache is present.

Known gaps

Inside the verified items

Open RFC items are tracked in the coverage checklist; these are the limits of what is already proved.

  • Callers are not translated. FrameDecoder::begin, which sets block_max = min(Window_Size, 128 KiB) and checks the dictionary ID, the stream state machine that reads the magic number and skippable frames, the engine method that fills FrameHeaderFields, and the parallel compressor’s own frame header writer (parallel::core) are outside the proofs.
  • SeqTable::fill_rows and spread, which call transition with the counter p + k and place the symbols with the section 4.1.1 step, use iterator adapters and const generics and are not translated; their spread is checked against Appendix A by unit tests.
  • The encoder’s offset code (highbit32(Offset_Value)) and the decoder’s repeat offset update both run inside sequence loops that are not translated.
  • Byte-for-byte agreement with zstd’s C library is outside the proofs; it stays with the differential tests and fuzzing.

Coverage checklist

Progress against RFC 8878, ticked when an item’s theorems build and pass the axiom audit. The RFC constrains the format and the decoder; the encoder only has to emit what that decoder accepts and reads back, so match finding, block splitting and cost models stay out of scope. Levels are ordered by cost; each later level builds on the earlier ones.

graph TD
    L1[Level 1: headers, codes and tables] --> Bits[Level 2: bit order and bit readers]
    Bits --> FSE[Level 2: §4.1.1 distribution reading and table spread]
    Bits --> Huf[Level 2: §4.2 Huffman trees and streams]
    FSE --> Seq[Level 2: §3.1.1.3.2.1.2 sequence decoding]
    Huf --> Lit[Level 2: §3.1.1.3.1 literals section]
    Seq --> Ref[Level 3: Lean reference decoder]
    Lit --> Ref
    Ref --> Refine[Level 3: Rust decoder refines the reference]

Level 1: headers, codes and tables

  • §3.1.1 and §3.1.2 magic numbers (Tables.lean).
  • §3.1.1.1 frame header: descriptor, window, dictionary ID and content size, decoder and encoder, round trip (FrameHeader.lean, FrameHeaderWriter.lean, FrameRoundTrip.lean).
  • §3.1.1.2 block header, decoder and encoder, round trip (BlockHeader.lean).
  • §3.1.1.2.4 Block_Maximum_Size set by FrameDecoder::begin.
  • §3.1.1.3.1.1 and Table 15: two-bit block and mode types (Tables.lean).
  • §3.1.1.3.1.1 Literals_Section_Header size formats, both sides (literals.rs on both sides; the decoder’s is inline in a SIMD-generic function).
  • §3.1.1.3.2.1 Number_of_Sequences, both sides, round trip (SequenceCount.lean).
  • §3.1.1.3.2.1 Symbol_Compression_Modes reserved bits and the Repeat_Mode rules in read_header.
  • §3.1.1.3.2.1.1 literals length and match length codes: tables, encoder codes, round trip and uniqueness (Tables.lean, SequenceCodes.lean).
  • §3.1.1.3.2.1.1 offset codes: decoder tables (Tables.lean).
  • §3.1.1.3.2.1.1 the encoder’s offset code.
  • §3.1.1.3.2.2 default distributions (Tables.lean).
  • §3.1.1.5 initial repeat offsets and the encoder’s update (Tables.lean, RepeatOffsets.lean).
  • §3.1.1.5 the decoder’s repeat offset update and the zero-offset rejection.
  • §4.1.1 Number_of_Bits and Baseline of every state (Fse.lean).
  • §4.2.1 Huffman weights: Number_of_Bits = Max_Number_of_Bits + 1 - Weight, the implied last weight, canonical code assignment.

Level 2: stateful decoding

  • Bit order: the forward and backward bit readers of decompressor/core/bits.rs read what the encoder’s bit writers write, including the end marker of backward streams.
  • §4.1.1 FSE distribution reading (read_distribution) and the cell spread (spread), against Appendix A.
  • §4.2 Huffman tree descriptions, direct and FSE-compressed weights, and one- and four-stream literal decoding with the jump table.
  • §3.1.1.3.2.1.2 sequence decoding order: offset, match length, literals length extra bits, then the state updates.
  • §3.1.1.4 sequence execution: offsets within the window and the produced output.

Level 3: the decoder as a whole

  • A Lean reference decoder transcribing RFC 8878, compiled and used as a second oracle beside C in the fuzz targets and tests: evidence rather than proof, but independent of the C implementation.
  • A refinement proof that the baseline path of the Rust decoder computes the reference decoder’s output for any chunking of the input. SIMD paths, which Aeneas cannot translate, stay with the differential tests, Miri and AddressSanitizer.

Outside RFC coverage, the content checksum (XXH64), SIMD kernels, the parallel and framed layers, match finding and the rest of the crate are not verified.

CI

GitHub Actions workflows live in .github/workflows. Every job checks out submodules recursively, because the C reference in zstd-ffi/vendor/zstd is needed to build the test oracle. The ClusterFuzzLite jobs are the exception: their actions check out the repository themselves, and .clusterfuzzlite/build.sh fetches the submodule.

graph TD
    Push[push to main / pull request] --> CI[ci.yml]
    CI --> Check["check: fmt, clippy per feature set, rustdoc per feature set, package"]
    CI --> NoStd["no-std: thumbv7em build, alloc-only host tests"]
    CI --> Test["test: Linux x86-64, Linux arm64, macOS, and MSRV 1.89 — release mode"]
    CI --> OCF["overflow-correction: core differential suites with MZSTD_OVERFLOW_CORRECT_FREQUENTLY"]
    CI --> Fuzz["fuzz: fmt/clippy of fuzz/afl, regression replay with and without framed"]
    CI --> LibFuzzer["libfuzzer: fmt/clippy of fuzz/libfuzzer with and without framed"]
    Push --> Lean["ci-lean.yml: Lean translation drift check, Lean proofs and axiom audit"]
    PR[pull request touching code] --> CFPR["ci-cflite-pr.yml: ClusterFuzzLite on the changed code"]
    Daily[daily schedule or manual] --> CFCron["ci-cflite-cron.yml: batch, prune, coverage"]
    CFCron -. coverage report selects targets .-> CFPR
    Manual[workflow_dispatch] --> Cov["ci-coverage.yml: llvm-cov, 100% function coverage"]
    Manual --> Bench["ci-benchmarks.yml: Criterion smoke runs with differential checks"]
    Tag[v* tag or manual] --> Camp["ci-fuzz.yml: bounded AFL campaign per target"]
    Tag --> Miri["ci-miri.yml: Miri over SIMD kernels and BorrowedBuf output"]
    Tag --> ASan["ci-sanitizer.yml: AddressSanitizer over integration and kernel tests"]
    Manual --> Rel["release.yml: trusted publishing from a v* tag"]
    Push --> Docs["pages.yml: build book, check links, run recipes"]
    Docs -->|"main only"| Pages["GitHub Pages"]
    Push --> HB["hotpath-benchmark.yml: profile_compressor natively, then under Callgrind (merged)"]
    HB -->|"push: OIDC upload"| Cloud[(hotpath Cloud)]
    HB -->|"pull request: report artifact"| Relay["hotpath-relay.yml (workflow_run)"]
    Push --> HC["hotpath-criterion.yml: one Criterion id per matrix entry, natively then under Callgrind (merged)"]
    HC -->|"push: OIDC upload"| Cloud
    HC -->|"pull request: criterion-<series> artifact"| Relay
    Relay -->|"OIDC upload"| Cloud
WorkflowTriggerChecks
ci.ymlpush to main, pull requestscargo fmt --check. Clippy with -D warnings for all features, for the default features plus hotpath, and for each codec with std and with no_std. Rustdoc with -D warnings for the same configurations. cargo package. A bare-metal build. Release-mode tests on three platforms and on the MSRV, each with the default features, with all features (alloc only) and with framed (which adds the std framed adapters and seek reader). Clippy and rustdoc also cover framed with each codec. The overflow-correction mode. Fuzz package lint and regression replay. libFuzzer adapter lint.
pages.ymldocumentation/code pushes to main, matching pull requests, manualPython tooling tests, release compilation and execution of documented Rust recipes with framed, mdBook build and local-link/anchor validation, including the root README’s links into the published site; upload Pages artifact. Only the deployment job on main has pages: write and id-token: write. See documentation publishing.
ci-lean.ymlpush to main, pull requestsscripts/extract_lean.py --check: the Charon/Aeneas translation of the verified items matches the Rust source. lake exe cache get and lake build in verification/lean: the RFC 8878 proofs and the axiom audit. See formal verification.
hotpath-benchmark.ymlpush to main, pull requestsprofile_compressor built with hotpath-alloc,hotpath-callgrind,hotpath-cloud on pinned Rust 1.99.1 and Valgrind 3.26.0, run on testdata/alice29.txt in merged mode: natively for timing and allocations, then under valgrind --tool=callgrind --instr-atstart=no with HOTPATH_MERGE_REPORT pointing at the native report, which adds instruction counts. jq checks that the merged report has both icount and timing data. A push uploads the merged report to hotpath Cloud as the compressor benchmark’s baseline (id-token: write). A pull request runs read-only and leaves the JSON report as the compressor-report artifact. See hotpath Cloud.
hotpath-criterion.ymlpush to main, pull requestsOne job per matrix entry (compress/text-1MiB/mzstd/3 and decompress/text-4MiB/level-3/mzstd, 200 iterations each): the Criterion bench built with hotpath-alloc,hotpath-callgrind,hotpath-cloud on the same pinned toolchain and Valgrind, run with --bench --test --exact <id> and HOTPATH_CRITERION_ITERATIONS, natively and then under Callgrind in merged mode. jq also checks that the report names the Criterion id. A push uploads each report as its own hotpath Cloud series (compress-text-1MiB-mzstd-3, decompress-text-4MiB-level-3-mzstd); a pull request leaves it as the criterion-<series> artifact. See hotpath Cloud.
hotpath-relay.ymlworkflow_run of hotpath benchmark or hotpath criterionhotpath-rs/hotpath-relay (v1.0.0, pinned by commit) uploads a successful pull request run’s report with the base repository’s OIDC token, so the pull request jobs never need write permissions: the relay job the compressor-report, the criterion job one criterion-<series> artifact per series of the hotpath criterion matrix. The workflow has no default permissions; each job gets actions: read, id-token: write and pull-requests: read, and a newer run for the same branch cancels an older one.
ci-cflite-pr.ymlpull requests touching src/, zstd-ffi/, fuzz/, Cargo.toml, .clusterfuzzlite/, .dockerignore or the workflowClusterFuzzLite libFuzzer/ASan run on the changed code, 600 s. See ClusterFuzzLite.
ci-cflite-cron.ymldaily, manualClusterFuzzLite batch fuzzing (5.5 h), corpus pruning and a coverage report.
ci-coverage.ymlmanualcargo llvm-cov --workspace --release with --fail-under-functions 100 over repository-owned sources (zstd-ffi and the vendored C excluded); HTML report as an artifact.
ci-benchmarks.ymlmanualShort compress, decompress and mzstd-ffi one_shot runs. Each benchmark asserts byte identity with zstd before timing. The framed benches (framed_compress, framed_decompress) are run locally with --features framed.
ci-fuzz.ymlv* tags, manualprepare-seeds.sh, then campaign.sh for each target, the three framed targets included (600 s by default); findings uploaded as findings-<target>.tar.gz, since AFL file names contain :, which upload-artifact rejects.
ci-miri.ymlv* tags, manualscripts/miri.sh on nightly: exact-named tests for the vector kernels and BorrowedBuf output under Miri, interpreted for x86-64 with MIRI_TARGET_CPU=native, so the runner’s CPU picks the backends. See memory checks.
ci-sanitizer.ymlv* tags, manualNightly -Zsanitizer=address release builds, LeakSanitizer included: every integration test with framed (the two that forget an owning guard on purpose without leak detection), then the kernel and uninitialized-output unit modules on the runner’s own backends.
release.ymlmanual from a v* tagTag/version match, then cargo publish through crates.io trusted publishing.

ci.yml, ci-lean.yml, ci-coverage.yml, ci-benchmarks.yml, ci-fuzz.yml, ci-miri.yml and ci-sanitizer.yml declare permissions: contents: read at the workflow level: they only check out, build, test and upload artifacts, so their GITHUB_TOKEN needs nothing more. The ClusterFuzzLite workflows use read-all, and the hotpath and release workflows grant permissions per job.

All Cargo invocations that build the workspace pass --locked, because Cargo.lock is committed so that benchmark baselines and campaigns are reproducible.

hotpath Cloud

hotpath-benchmark.yml judges the encoder by instruction count rather than time, so a shared GitHub runner gives reproducible numbers. It runs in merged mode: the same build first runs natively and writes a JSON report with timing and allocations, then runs under Callgrind with HOTPATH_MERGE_REPORT naming that report, and the Callgrind run adds its instruction counts and writes (and, on a push, uploads) the merged report. Valgrind distorts allocation data as well as timing, so both come from the native run. Both runs share HOTPATH_BENCHMARK, HOTPATH_USER_METADATA and HOTPATH_OUTPUT_FORMAT from the job environment, so their reports describe one benchmark. Counts depend on the Valgrind build, the Rust toolchain and the runner image, which are all pinned; bumping any of them moves the baseline and belongs in a separate pull request. hotpath Cloud compares each pull request’s report with the latest main upload and judges it by hotpath/policy.toml: instruction counts and allocations are judged (0.5% and 1% thresholds, functions above 0.05% of the total), wall time is reported but not judged. The thresholds come from mbrotli’s measured run-to-run spread and have not been re-measured for this crate.

hotpath-criterion.yml applies the same merged mode to selected Criterion benchmarks. benches/compress.rs and benches/decompress.rs import Criterion through hotpath::criterion (the criterion-0-8 feature of the hotpath dev-dependency). Without HOTPATH_CRITERION_ITERATIONS the adapter forwards to Criterion unchanged; with it set, the one benchmark that --test --exact selects runs its routine exactly that many times inside one Criterion iteration, and the hotpath guard, which also starts Callgrind’s instrumentation, wraps only those iterations. The bench’s setup, including its byte-identity checks against zstd, runs earlier and is not counted. The report records the Criterion id, iteration count and Criterion version in meta.criterion. Each matrix entry is its own hotpath Cloud series, judged by the same hotpath/policy.toml. The matrix and the relay’s criterion matrix list the same series names and are kept in sync by hand.

flowchart TD
    Env{HOTPATH_CRITERION_ITERATIONS set?}
    Env -->|no| Plain[Criterion samples and reports as usual]
    Env -->|yes| Filter{exactly one id selected by --test --exact?}
    Filter -->|none ran| Fail1[exit 1: no report written]
    Filter -->|second routine starts| Fail2[panic: one benchmark per process]
    Filter -->|one| Setup[bench setup and byte-identity checks, unprofiled]
    Setup --> Guard[hotpath guard starts, Callgrind instrumentation on]
    Guard --> Loop[routine runs N times inside one Criterion iteration]
    Loop --> Report[report with meta.criterion written on guard drop]
sequenceDiagram
    participant PR as pull_request job (read-only)
    participant Relay as hotpath-relay.yml
    participant Push as push job (main)
    participant Cloud as hotpath Cloud
    Push->>Push: build profile_compressor, run natively (native.json)
    Push->>Push: run under Callgrind, merge icount into native.json
    Push->>Cloud: upload merged report (OIDC), new baseline
    PR->>PR: build profile_compressor, run natively (native.json)
    PR->>PR: run under Callgrind, merge into compressor-report.json
    PR->>PR: upload-artifact compressor-report.json
    PR-->>Relay: workflow_run completed
    Relay->>Cloud: upload report for the pull request (OIDC)
    Cloud->>Cloud: diff against baseline, apply hotpath/policy.toml

ClusterFuzzLite

Both ClusterFuzzLite workflows build .clusterfuzzlite/Dockerfile with google/clusterfuzzlite/actions/build_fuzzers@v1 and run the binaries that build.sh produces from fuzz/libfuzzer (see fuzzing). The actions do their own checkout, so the workflows have no checkout step and build.sh fetches the vendored zstd submodule itself. Runs use libFuzzer with AddressSanitizer, the only engine and sanitizer ClusterFuzzLite supports for Rust, and the coverage job builds with the coverage sanitizer. All jobs use read-only permissions; corpora, crashes and coverage reports are kept as workflow artifacts, ClusterFuzzLite’s default storage on GitHub.

sequenceDiagram
    participant PR as pull request
    participant Cron as daily schedule
    participant CF as ClusterFuzzLite actions
    participant Store as workflow artifacts
    PR->>CF: build (address), run code-change, 600 s
    Store-->>CF: latest corpus and coverage report
    CF-->>PR: job fails on a crash, with the reproducer
    Cron->>CF: batch: build (address), fuzz 18000 s, targets in parallel
    CF->>Store: grown corpus, crashes
    Cron->>CF: prune: minimize corpus, 18000 s
    CF->>Store: pruned corpus
    Cron->>CF: coverage: build (coverage), report
    CF->>Store: coverage report

ci-cflite-pr.yml runs only when a pull request touches src/, zstd-ffi/, fuzz/, Cargo.toml, .clusterfuzzlite/, .dockerignore or the workflow itself, and a newer push cancels the run in progress. Code-change mode runs only the targets whose coverage reaches the changed lines once a coverage report exists, and every target before that. The 600 seconds are split among the targets it runs.

In ci-cflite-cron.yml the jobs are chained: pruning follows batch fuzzing (ClusterFuzzLite requires pruning whenever batch fuzzing runs), and coverage follows pruning, so the report reflects the pruned corpus. A failed batch job still lets pruning and coverage run. Batch fuzzing gets 5 hours, shared by all targets, which run side by side on the runner’s cores (parallel-fuzzing: true); the job’s six-hour limit leaves the rest for the image and fuzzer builds (up to 30 minutes) and the corpus uploads. With 5.5 hours the job hit the limit on days the build was slow. Pruning gets 18000 seconds, which with a build of up to 35 minutes fits the job’s limit even if every target used its whole share. Each target’s merge must finish within its share of that budget: an even split at the start, growing by whatever earlier targets leave unused. A day’s corpus takes about an hour to merge at batch speed. parallel is the slowest, at about four inputs a second, since each input compresses nine times, and a quarter of the inputs are at levels 16 to 22, which take over half the time. Its corpus grows by about 150 inputs a day: at 3387 inputs it outgrew the 812-second share that 10800 seconds gave it, and 18000 seconds give it about 1370. With 600 seconds the first target timed out and none were pruned. Every run_fuzzers step sets language: rust, because the action defaults to C++.

.dockerignore keeps target directories and local AFL and libFuzzer artifacts out of the image, but keeps .git, which the submodule fetch needs.

Memory checks

mzstd is forbid(unsafe_code) outside tests. The unsafe with memory effects that its code reaches lives in two dependencies: fearless_simd, whose vector types, kernel! and #[simd] entries call architecture intrinsics inside a target-feature context, and borrowed-buf, which tracks the zeroed prefix of uninitialized output. The third, branches, only emits prefetch hints, which never fault; on nightly, and so under Miri, they lower to the compiler intrinsic instead of inline assembly. The FFI glue in zstd-ffi and mzstd-ffi is outside the published crate.

flowchart TD
    Unsafe[unsafe reached by mzstd] --> Simd[fearless_simd intrinsics]
    Unsafe --> Buf[borrowed-buf BorrowedBuf]
    Simd --> K1["compressor::core::opt_count: u8x16/32/64 match-length scans"]
    Simd --> K2["compressor::core::lazy::row: tag masks, NEON kernel!"]
    Simd --> K3["decompressor::core::copy: shuffle wildcopy"]
    K3 --> K4["decompressor::core::sequences: execution at fixed and growing output edges"]
    Buf --> B1["compressor::core::output: UninitOutput"]
    Buf --> B2["decompressor::core::stream: decode_to_uninit output"]
    B1 --> B3["tests/compressor_api.rs, tests/compressor_target_block.rs: compress_to_uninit end to end"]
    Simd --> K5["compressor::core::superblock: sub-block sequence encoding on every backend"]
    K1 & K2 & K3 & K4 & K5 & B1 & B2 & B3 --> Miri["scripts/miri.sh: Miri, x86-64: scalar, SSE2, SSE4.2, AVX2, AVX-512 on capable runners"]
    K1 & K2 & K3 & K4 & K5 & B1 & B2 --> ASan["ci-sanitizer.yml: native backends under ASan"]
    All[every integration test with framed] --> ASan

Miri covers only those paths. Code that is safe Rust inside a #[simd] context, such as the scalar mem counts the compiler auto-vectorizes, the Huffman and sequence bit packers and the match finders around the kernels, has no unsafe of its own and is left to the ordinary suites.

scripts/miri.sh interprets the tests for x86_64-unknown-linux-gnu on any host, with -Ctarget-cpu=$MIRI_TARGET_CPU (default x86-64-v3). Miri implements the x86 intrinsics the kernels use, AVX-512 included, but not all of NEON (vsli is missing), and it reports compile-time target features instead of executing CPUID, so the target CPU decides which backends Backend::available() returns. The default gives scalar, SSE2, SSE4.2 and AVX2 on any machine; CI passes native, which adds AVX-512 on runners with every extension fearless_simd requires for it (Ice Lake and later Xeons, not Cascade Lake or Zen 3), and prints the runner’s CPU model first. A local MIRI_TARGET_CPU=icelake-server reproduces that case on any host. Each run names its tests with --exact and fails unless that many pass, so a renamed test cannot leave a filter that silently matches nothing. Two sweeps thin out under cfg!(miri) while keeping every vector width, tail and full-length case: opt_count’s window test keeps lengths next to 16-byte boundaries, and compress_to_uninit_writes_the_one_shot_frame compresses 2 KB instead of 30 KB. Native runs are unchanged.

The AddressSanitizer job needs an explicit --target so that build scripts and proc macros stay uninstrumented. It runs every integration test, the C-reference differential suites included, and then the Miri modules natively on the runner’s own backends, AVX-512 included when the runner has it. Locally, the same commands with an aarch64-apple-darwin target cover NEON.

On Linux, ASan also runs LeakSanitizer when the process exits, so a test must not leak either: a borrowed &'static input or resolver lives in a static OnceLock rather than behind Vec::leak or Box::leak. The exceptions are forgotten_readers_keep_the_owner_protected_until_recovery (framed_seek) and batches_with_leaked_tasks_report_invalid_state (parallel), which mem::forget a seek reader or a task list that owns memory and borrows the test’s input: the leak is the behavior they check. The integration step skips them and a second step runs them with ASAN_OPTIONS=detect_leaks=0, so ASan still checks their accesses, and a renamed one would make the first step report its leak. macOS does not run LeakSanitizer unless ASAN_OPTIONS=detect_leaks=1 is set.

Known gaps

  • Miri does not interpret NEON and cannot call the C reference, so NEON and the differential suites are checked only natively, under AddressSanitizer. Whether a tag’s Miri run reaches AVX-512 depends on the runner GitHub assigns. The C oracle itself is not built with ASan.
  • Memory checks run on tags and on demand, not on pull requests. ClusterFuzzLite’s ten-minute pull request run is the only sanitizer run there, and it covers only the fuzz targets.
  • ClusterFuzzLite builds for x86-64 only, so its runs reach the x86 backends the runner supports, not NEON.
  • Benchmark numbers from shared runners are smoke tests, not evidence.

Benchmarking

mzstd has two benchmark layers. The library’s own Criterion benches (benches/) time warm contexts and streaming against zstd 1.5.7 for development work. The implementation comparison (benchmarks/comparison/) times both libraries’ cold one-shot APIs on a fixed corpus and produces the published reports in docs/benchmarks/. This page describes the comparison harness; running it is described with the commands.

Framed container benches

benches/framed_compress.rs and benches/framed_decompress.rs need --features framed. They compare framed containers with the closest thing zstd ships, the contrib seekable format compiled into zstd-ffi: identical inputs (zstd_ffi::corpus text, binary and mixed data, a 16 KiB text, 512 records), identical levels and 64 KiB frames. Before timing, every container must decode with FramedDecompressor, ZSTD_decompress and ZSTD_seekable_decompress, and every reference output with the seekable reader; both sizes are printed.

flowchart LR
    In["corpus input"] --> M["FramedCompressor::compress_into, reused owner"]
    In --> C["ZSTD_seekable_compressStream, maxFrameSize 64 KiB"]
    M --> V{"decodes with mzstd, ZSTD_decompress, contrib seekable"}
    C --> V
    V --> T[Criterion timing, throughput in input bytes]
    Ct["container (level 3, 64 KiB chunks)"] --> D1["FramedDecompressor::decompress_into"]
    Ct --> D2["FramedSeekReader resource read"]
    Ct --> D3["ZSTD_decompressDCtx"]
    Ct --> D4["ZSTD_seekable_initBuff + decompress"]

The records case stores each record as its own resource and compares with one ZSTD_compress2 frame per record; the difference in size is the container’s per-resource structure (header frame, directory record and seek table entries).

Module map

graph TD
    subgraph comparison["benchmarks/comparison (own workspace, excluded from the root)"]
        Lib["lib.rs: run_encoders, run_decoders, run_profile"] --> Core["core (private): corpora, cold encoders, validation"]
        Core --> Dec["core::decoder (private): cold decoders"]
        Core --> Profile["core::profile (private): one validated case"]
        BEnc["benches/encoders.rs"] --> Lib
        BDec["benches/decoders.rs"] --> Lib
        BProfile["bin/profile.rs"] --> Lib
        BWarm["bin/warm.rs: reused contexts, corpus or file:PATH"] --> Mzstd
        Paired["paired.py: randomized AB/BA process pairs"] --> BProfile
        Paired --> BWarm
        Silesia["silesia.py: C / main / working tree rounds on whole files, median chart"] --> BWarm
        Report["report.py: Criterion run to CSV"] --> Docs["level_docs.py: compression level pages and dataset charts"]
        Report --> Plot["plot.py: overview, throughput and size charts"]
        Report --> DecDocs["decoder_docs.py: decompression level pages and charts"]
        Docs --> Plot
        DecDocs --> Docs
    end
    Core --> Mzstd["mzstd (path dependency)"]
    Core --> Ffi["zstd-ffi: zstd 1.5.7 C library"]
    Core --> Alice["testdata/alice29.txt"]
    Docs --> Out["docs/benchmarks/{encoders,decoders}/"]
    Plot --> Out
    DecDocs --> Out
    Silesia --> SOut["docs/benchmarks/silesia*"]

The package has its own [workspace] and lockfile, and the root workspace excludes it, so Criterion output and the comparison’s dependency resolution never touch the library’s own checks. Its core module is private and exposes the encoder and decoder benchmark entry points plus the focused profiling driver. The driver validates one case before entering an uninlined collection boundary, so Callgrind can exclude corpus setup and preflight. paired.py runs saved driver binaries with randomized AB/BA process ordering and records every pair for analysis; it does not generate the published Criterion matrix. See the profiling commands. The warm driver times one case with a context and destination reused across iterations, after checking its frame or output against C; it also reads any file as file:PATH. silesia.py drives two saved warm binaries over whole Silesia files, the working tree’s (C and mzstd) and the main branch’s (mzstd), in five rounds of seeded random order per cell.

Corpus and matrix

The eight datasets are mbrotli’s comparison corpus, with the same generators and seeds, so the two projects’ results describe one corpus: empty, tiny-text (44 bytes), alice29 (152,089 bytes), text-1m (Alice repeated to 1 MiB), binary-64k (((i / 16) ^ i) as u8), random-64k and random-1m (xorshift64), and repeated-1m (a bytes). Each dataset is timed at 24 levels: -5, -1 and 1 through 22. That is 384 encoder and 384 decoder cases per run (two implementations each).

Data flow

sequenceDiagram
    participant B as bench binary
    participant C as core::run / decoder::run
    participant Z as zstd 1.5.7
    participant M as mzstd
    participant Cr as Criterion
    participant R as report.py
    participant L as level_docs.py / plot.py / decoder_docs.py
    B->>C: run_encoders / run_decoders
    C->>Z: preflight: every dataset x level
    C->>M: same input, same level
    C->>C: frames identical (encoders) / outputs equal (decoders), else Err
    C->>C: write target/criterion*/sizes.csv
    C->>Cr: time c-zstd and mzstd per group
    Cr-->>R: <group>/<impl>/<baseline>/estimates.json
    R->>R: join with sizes.csv, reject incomplete or duplicate matrices
    R-->>L: CSV with means, 95% bounds, MiB/s, speed / C
    L-->>L: README, l<level>.md pages, summary and per-dataset SVGs (encoders)
    L-->>L: README, l<level>.md pages, one SVG per level (decoders)
    Note over R,L: optional --main-csv: the main branch's matrix from its own run
  • Preflight before timing. The encoder run compresses every case with both libraries first and fails unless the frames are byte-identical and decode to the input through the C decoder. The decoder run compresses with C and requires both decoders to restore the input. Filtered runs preflight the whole matrix too, so a partial sweep never succeeds over a broken case.
  • Cold APIs. Each iteration includes construction, allocation, the call and disposal: ZSTD_createCCtx + ZSTD_CCtx_setParameter(level) + ZSTD_compress2 into a ZSTD_compressBound buffer against Compressor::new(level).compress; ZSTD_getFrameContentSize + ZSTD_decompress into an exactly sized buffer against Decompressor::default().decompress. Buffers on the C side are uninitialized Vec capacity, which C fills; mzstd’s API returns its own vector.
  • Sampling defaults. 20 samples, 1 s warm-up and at least 3 s of measurement per case; Criterion command-line options override them. Each published environment record stores the settings actually used for that run.

Report generation

flowchart TD
    Run[Criterion baseline] --> Collect{report.py: every case measured once, sizes match, bounds ordered?}
    Collect -->|no| Fail[exit with the reason]
    Collect -->|yes| Csv["encoder-comparison.csv / decoder-comparison.csv"]
    Csv --> Validate{level_docs.validate_rows: complete matrix, consistent lengths and frame sizes?}
    Validate -->|no| Fail
    Validate -->|encoders| Enc["level_docs.py: README + l-5.md ... l22.md, l<level>-summary.svg, l<level>-<dataset>.svg"]
    Enc --> Plot["plot.py: overview.svg, throughput.svg, size.svg"]
    Validate -->|decoders| Dec["decoder_docs.py: README + l-5.md ... l22.md, l<level>.svg, overview.svg"]
    Main["--main-csv: main branch's CSV, validated the same way"] -.-> Enc
    Main -.-> Plot
    Main -.-> Dec

The layout mirrors mbrotli’s benchmark documentation. Per level and dataset, speed / C = C mean latency / mzstd mean latency and output / C = mzstd bytes / C bytes; level summaries take the median of the eight per-dataset ratios, and the index pages order levels by mzstd’s median speed / C. With --main-csv, a third series, mzstd main, joins every median, overview and summary chart and table: the main branch’s harness timed in its own process beside the working tree’s, each normalized to the C timings of its own run, so drift between the two processes cancels as far as C sees it. Per-dataset charts keep the working tree’s run only. Each compression level page has a summary chart, one chart per dataset (throughput or latency with 95% bounds, beside the frame size) and the exact numbers; each decompression level page has one panel per dataset. The hand-written run reports (encoder-comparison.md, decoder-comparison.md) record the measurement contract, reproduction commands and interpretation limits; the environment records hash the measured sources and name the archived size manifests. SVGs are generated with Matplotlib 3.10.8 with fixed hash salts and no timestamps, so regenerating from the same CSV yields the same files.

Invariants

  • Both sides of a case see identical bytes and the same level; the encoder comparison only runs when the frames are identical, so every speed ratio is read at an identical compression ratio.
  • A published CSV always holds the complete 384-case matrix of one named Criterion baseline; report.py and level_docs.py refuse anything else.
  • Python unit tests (python3 -m unittest in benchmarks/comparison) cover validation, ordering, page text and the exporter without Matplotlib (test_level_docs.py, test_decoder_docs.py, test_report.py, test_silesia.py); test_plot.py renders every chart kind when Matplotlib is installed.

Known gaps

  • One host (AMD Ryzen 9 7950X, two dedicated KVM vCPUs); frequency and scheduling are not controlled beyond pinning each process to one vCPU.
  • The main branch’s series comes from a separate process run beside the working tree’s, not from interleaved pairs; only the Silesia sweep interleaves the three sides.
  • The C library is built by zstd-ffi with cc defaults (-O3), without its x86-64 Huffman assembly (ZSTD_DISABLE_ASM) and single-threaded.
  • Reused contexts are published only for Silesia; streaming performance is measured only by the root benches/.

Documentation publishing

The repository publishes a static mdBook through GitHub Pages. The book contains every Markdown page and associated asset in docs/ and architecture/. These directories remain the canonical sources; generated book sources, Mermaid assets, consumer examples, and HTML are local artifacts under ignored target/.

Modules and ownership

graph TD
    Guides[docs: user guides, examples, benchmark data] --> Prepare[scripts/docs.py: prepare]
    Specs[architecture: specifications and index] --> Prepare
    Index[architecture/README.md: architecture navigation and section pages] --> Prepare
    Prepare --> Source[target/book-src: generated Markdown and assets]
    Mermaid[mdbook-mermaid 0.17.1: bundled JS] --> Assets[target/book-assets]
    Config[book.toml, book-theme/custom.css and diagram-zoom.js] --> Build[mdbook 0.5.4]
    Source --> Build
    Assets --> Build
    Build --> HTML[target/book: static HTML and search]
    HTML --> Check[Python local-link and anchor validation]
    Check --> Pages[GitHub Pages artifact]

The root book.toml owns renderer, theme, search, folding, output path, and Mermaid preprocessor settings. book-theme/custom.css overrides the palette of mdBook’s light and navy themes and styles tables, the sidebar and the landing page. book-theme/diagram-zoom.js moves a rendered Mermaid SVG into a modal dialog at its natural size and returns it to its page position on close; it borrows the node because Mermaid scopes diagram styles by the SVG’s id. Search, keyboard navigation, theme selection, Rust syntax highlighting, and code copying come from mdBook. Rust playground execution is disabled because its environment lacks mzstd.

scripts/docs.py has five repository-local entry points:

CommandBehavior
prepareRecreate the staged source tree, navigation, and bundled diagram assets
buildPrepare, build HTML, then check local links and heading anchors
servePrepare, then serve the generated tree on loopback port 3000
check-linksValidate a previously built HTML tree
test-examplesCompile and run Rust recipes in a separate Cargo consumer
flowchart TD
    Copy[Copy docs and architecture] --> Links[Inspect destinations outside fenced code]
    Links --> Local{Target in published directories?}
    Local -->|yes| Relative[Keep relative link]
    Local -->|no| GitHub[Link to source on GitHub main]
    Relative --> Summary[Generate SUMMARY with every Markdown page and the section pages]
    GitHub --> Summary
    Summary --> Complete{All source pages indexed?}
    Complete -->|no| Fail[Fail preparation]
    Complete -->|yes| Render[mdBook preprocessors and HTML renderer]
    Render --> Validate[Validate href and src paths, HTML IDs and README site URLs]
    Validate --> Artifact[Publishable static directory]

The generated SUMMARY.md has five parts: Start here (getting started, the API chooser in docs/README.md, examples), Guides, Performance and correctness, How it works, and Contributing. GUIDE_PARTS and CONTRIBUTING in scripts/docs.py hold the curated order; a tuple nests its tail under its first page, as the I/O and Sans I/O guides nest under streaming. Benchmark level pages nest two levels below the results index. book.toml folds every nested level, so the sidebar opens with top-level chapters only and mdBook expands the branch of the current page.

Architecture chapters follow the section headings and specification tables in architecture/README.md. For each ## section that contains a specification table, preparation writes a generated landing page architecture/sections/<slug>.md holding that section’s text, with links rewritten for its location, and nests the section’s specifications under it. These are the only published pages without a canonical source file. Unindexed architecture Markdown fails preparation. New guide pages are appended to Guides automatically. No source guide or specification is excluded merely because it has no manually configured navigation entry.

Preparation preserves fenced code. Relative links to files outside the published directories are checked for existence and rewritten to GitHub blob or tree URLs. Staging maps README pages to index.md and the introduction to the root index.md, adjusting relative links and summary entries to their final locations. CSV, JSON, and SVG assets retain their source directory layout. All generated HTML local destinations and fragments are checked after rendering. The root README.md is not published, but it links into the site: the same check resolves each of its https://m-compressors.github.io/mzstd/ URLs, with its fragment, against the built HTML.

The preprocessor’s install command writes bundled Mermaid assets into a small generated book under target/book-assets/; the real book references those files. The site needs no runtime CDN, Node package manager, or application server.

graph LR
    Book[SUMMARY.md] --> Overview[Overview: docs/introduction.md]
    Book --> Start[Start here]
    Book --> Guides[Guides]
    Book --> Evidence[Performance and correctness]
    Book --> Internals[How it works]
    Book --> Contributing[Contributing]
    Start --> StartPages[getting started, API chooser, examples]
    Guides --> GuidePages[configuration, memory, decompression, streaming, parallel, frames, dictionaries]
    GuidePages --> Nested[folded: I/O and Sans I/O under streaming]
    Evidence --> Bench[correctness, benchmark index]
    Bench --> Level[folded: codec indexes, level pages, run reports]
    Internals --> Sections[overview, generated section pages]
    Sections --> Specs[folded: specifications from architecture/README.md tables]
    Contributing --> Dev[development, benchmarking, website]

Examples and deployment

sequenceDiagram
    participant Source as Markdown recipes
    participant Consumer as Temporary Cargo consumer
    participant CI as Pages build job
    participant Deploy as Pages deploy job
    Source->>Consumer: Extract every rust fence from the recipe guides
    Consumer->>Consumer: Release build with local mzstd + framed + Rayon
    Consumer->>CI: Run programs and round-trip assertions
    CI->>CI: Build book and check local links and anchors
    CI->>Deploy: Upload static Pages artifact
    alt main push or manual run on main
        Deploy->>Deploy: Configure Pages and deploy with OIDC
    else pull request
        Deploy->>Deploy: Skip publication
    end

Recipes in the guides listed in RECIPES (getting started, configuration, buffers, decompression, streaming, parallel, frames, Sans I/O compression/decompression, the I/O guide, and dictionaries) are complete Rust programs. test-examples generates a separate package beneath target/book-examples/, enables default std codecs plus framed, and adds Rayon. It runs each binary and propagates build or assertion failures. It does not change the workspace, public codec APIs, state machines, SIMD dispatch, or error propagation; normal codec tests remain separate.

.github/workflows/pages.yml runs source and recipe checks on matching pull requests, main pushes, and manual dispatch. Only its deployment job has pages: write and id-token: write; pull requests cannot deploy. The github-pages environment records the deployed URL. Deployment is serialized and the output is hosted at https://m-compressors.github.io/mzstd/.

Known gaps

  • The Pages source setting must be configured to GitHub Actions in repository settings before the first deployment. Adding a workflow alone does not enable it.
  • serve watches generated files. Restart the command after editing canonical sources so preparation runs again.
  • The checker validates local destinations and anchors, not external network URLs or Mermaid syntax. Browser QA is needed for rendered diagrams.
  • The diagram dialog and theme have no automated test; they were checked in a desktop browser in the light and navy themes.
  • Recipe validation covers the guides in RECIPES, not every historical code fragment in the implementation specifications.

Development and checks

Read the contributing guide and architecture index before changing the implementation. The main crate has no dependency on another Zstandard implementation. The C reference is used only by development, tests, and benchmarks.

Set up

Requires Rust 1.89+, a C compiler for the reference oracle, and Git:

git clone --recurse-submodules https://github.com/M-Compressors/mzstd.git
cd mzstd

For an existing clone, initialize git submodule update --init --recursive.

Format, lint, and test

Differential tests run in release mode because debug execution is too slow:

cargo fmt --all -- --check
cargo clippy --workspace --all-targets --all-features --locked -- -D warnings
cargo clippy --workspace --all-targets --locked -- -D warnings
cargo clippy --workspace --all-targets --features framed --locked -- -D warnings
cargo test --workspace --release --locked
cargo test --workspace --all-features --release --locked
cargo test --workspace --features framed --release --locked

All features enable no_std, removing std-only APIs, so the default and framed std configurations need separate runs. CI also checks each codec separately, the MSRV, bare-metal compilation, rustdoc, packaging, and index overflow correction.

Fuzzing, memory, and proofs

AFL and libFuzzer are excluded packages with their own feature checks and regression commands. Follow the contributing guide and fuzzing specification. Never commit generated findings or corpus output directories; minimize failures into regression inputs.

Use scripts/miri.sh and the documented AddressSanitizer commands after changing SIMD or uninitialized-output paths. Inspect function coverage with cargo llvm-cov; generated reports stay local. Changes to verified format primitives require refreshing Charon/Aeneas output and rebuilding Lean proofs. See CI memory checks and formal verification.

Documentation and benchmarks

Keep architecture specifications and Mermaid diagrams current, add new specifications to the index, and record user-visible changes in the changelog. All guide and architecture pages are published together. Validate the book and its runnable recipes:

python3 -m unittest discover -s scripts -p test_docs.py
python3 scripts/docs.py test-examples
python3 scripts/docs.py build

Install the pinned tools and preview as described in documentation website. The benchmarking guide describes cold comparisons, profiling, raw measurements, and chart generation. Performance changes need representative measurements of throughput and output size, with the command, machine, input, and before/after results recorded.

Benchmarks and profiling

The independent implementation comparison measures mzstd against zstd 1.5.7, the C library built from the vendored submodule by zstd-ffi. Browse the benchmark results index for one page per compression level, with medians across all eight datasets, vertical speed/size bars, and complete timings. The strongest mzstd comparisons appear first; the pages explain the ranking and retain every dataset. The index also links the recorded run reports, raw data, and limitations.

Both codecs use the same documentation layout under docs/benchmarks/:

ArtifactEncoderDecoder
Reportencoder-comparison.mddecoder-comparison.md
Measurementsencoder-comparison.csvdecoder-comparison.csv
Environmentencoder-comparison-environment.jsondecoder-comparison-environment.json
Size manifestencoder-comparison-<date>/sizes.csvdecoder-comparison-<date>/sizes.csv
Main branch’s run, when recordedencoder-comparison-<date>/main.csvdecoder-comparison-<date>/main.csv
Index and level pagesencoders/README.md, encoders/l-5.md–l22.mddecoders/README.md, decoders/l-5.md–l22.md
Chartsencoders/charts/decoders/charts/

Each chart directory contains overview.svg. Install plotting support in an isolated environment if needed:

python3 -m venv /tmp/zstd-plots
/tmp/zstd-plots/bin/python -m pip install matplotlib==3.10.8

Use that environment’s Python for the generation commands below.

Encoder comparison

Run from the repository root:

git submodule update --init --recursive
cargo bench --manifest-path benchmarks/comparison/Cargo.toml --bench encoders --locked -- --test
cargo bench --manifest-path benchmarks/comparison/Cargo.toml --bench encoders --locked -- --save-baseline my-comparison
python3 benchmarks/comparison/report.py --baseline my-comparison --csv /tmp/my-comparison.csv

Use a fresh baseline name for each complete run. The standalone package owns its lockfile and writes to benchmarks/comparison/target/criterion/. Its own workspace keeps the comparison’s dependency resolution and Criterion results apart from the root benchmarks and target/criterion/.

The suite contains 384 cases: eight corpora at levels -5, -1 and 1–22 for both implementations. Every case compresses a complete known input with only the level set, no dictionary and no explicit flush.

ImplementationVersionTimed API
zstd C1.5.7, pinned submoduleZSTD_createCCtx, ZSTD_CCtx_setParameter(level), ZSTD_compress2 into a ZSTD_compressBound buffer, ZSTD_freeCCtx
mzstdLocal checkoutConstruct Compressor for the level, then compress

This is cold end-to-end compression: configuration, construction, scratch and destination allocation, encoding, and disposal occur inside each iteration. No implementation retains workspace. Before timing starts, every mzstd frame must be byte-identical to zstd’s and decode to its input through the C decoder; sizes.csv records the frame lengths.

The corpora are empty input, a 44-byte text, testdata/alice29.txt, Alice repeated to 1 MiB, structured 64 KiB binary, deterministic pseudorandom bytes at 64 KiB and 1 MiB, and 1 MiB of repeated a bytes — mbrotli’s comparison corpus. Construction and validation of all 384 frames finish before timing starts.

Defaults are 20 samples, one second of warmup, and three seconds of measurement per case. Criterion options override them. A focused timing run is:

cargo bench --manifest-path benchmarks/comparison/Cargo.toml --bench encoders --locked -- 'alice29/l3/'

Filtered runs still validate the full matrix. The exporter rejects incomplete timing sweeps and checks input lengths. Export immediately after the matching run: sizes.csv describes the most recent preflight. Archive that file with the named baseline for later analysis. CSV columns include mean nanoseconds and 95% confidence bounds, MiB/s, C time / mzstd time, compressed bytes, and compressed / input. For empty input, throughput is zero and the compressed fraction is undefined; compare latency.

Rebuild the checked-in encoder pages and charts with Matplotlib 3.10.8:

python3 benchmarks/comparison/level_docs.py \
  --csv docs/benchmarks/encoder-comparison.csv \
  --environment docs/benchmarks/encoder-comparison-environment.json \
  --report docs/benchmarks/encoder-comparison.md \
  --output docs/benchmarks/encoders
python3 benchmarks/comparison/plot.py \
  --csv docs/benchmarks/encoder-comparison.csv \
  --output docs/benchmarks/encoders/charts \
  --subtitle 'Ryzen 9 7950X (KVM); 50 samples; one vCPU; 2026-11-09'

level_docs.py writes the index, 24 level pages and their detailed charts. plot.py writes overview.svg, throughput.svg and size.svg into the same charts directory. All eight corpora contribute to the medians. --main-csv adds the main branch’s series (see main branch series). The published encoder and decoder CSVs are the Criterion baseline long-20261109 (2026-11-09), with their size manifests and raw Criterion samples in benchmarks/encoder-comparison-2026-11-09/ and benchmarks/decoder-comparison-2026-11-09/. Other tenants loaded the host that day, so they use 50 samples, 3 s warmup and 6 s requested measurement instead of the 20 samples, 0.5 s and 1 s that suffice on a quiet host; no main branch series was recorded. See the encoder and decoder reports. Each environment record identifies the measured sources; regenerating charts reuses those measurements and does not rerun the benchmark. When publishing a fresh run, update the CSV, archived manifest and environment record together before regenerating pages and charts.

Decoder comparison

The decoder results compare zstd C and mzstd on identical frames, written by zstd C at levels -5, -1 and 1–22 from the same eight inputs as the encoder comparison. All 384 cases must restore the original bytes through both decoders before timing starts.

cargo bench --manifest-path benchmarks/comparison/Cargo.toml --bench decoders --locked -- --test
cargo bench --manifest-path benchmarks/comparison/Cargo.toml --bench decoders --locked -- --save-baseline my-decoders
python3 benchmarks/comparison/report.py --decoding --baseline my-decoders --csv /tmp/my-decoders.csv

Results live in benchmarks/comparison/target/criterion-decoders/, independent of encoder and root API measurements. Archive its sizes.csv with each fresh named baseline. Filtering, for example 'alice29/l3/', still validates the complete matrix; a full sweep is required for export. Sampling defaults match the encoder comparison and accept the same Criterion overrides.

DecoderVersionTimed API
zstd C1.5.7, pinned submoduleZSTD_getFrameContentSize, then ZSTD_decompress into an uninitialized buffer of exactly that size
mzstdLocal checkoutConstruct Decompressor, then decompress

This measures cold native APIs, including construction, allocation, decode and disposal. C reads the output capacity from the frame header; mzstd’s decompress returns its own vector. No decoder retains state between iterations. Throughput counts restored bytes, not compressed input bytes. In the CSV, input_bytes is the original corpus length and compressed_bytes is the shared frame size. Level is the source encoder’s setting. Empty input has latency but no meaningful throughput or compression fraction.

Rebuild the checked-in overview, 24 level pages and 25 SVG charts with Matplotlib 3.10.8:

python3 benchmarks/comparison/decoder_docs.py \
  --csv docs/benchmarks/decoder-comparison.csv \
  --environment docs/benchmarks/decoder-comparison-environment.json \
  --report docs/benchmarks/decoder-comparison.md \
  --output docs/benchmarks/decoders

The overview takes the median of eight per-dataset C-time/decoder-time ratios, with equal weight. Empty and tiny inputs have equal weight in the median and use latency panels. Per-level panels retain all individual mean confidence bounds. Shared frame sizes are listed alongside restored lengths instead of ranking decoders by size. See the run report for environment and limitations.

The generators are covered by unit tests that need no Matplotlib:

cd benchmarks/comparison && python3 -m unittest

Main branch series

To compare the working tree with the main branch, export main into a separate directory (git archive main, plus the vendored zstd submodule), build its harness with its own CARGO_TARGET_DIR, and run each suite of both trees side by side, one process per vCPU, under the same baseline name:

taskset -c 0 "$current_encoders" --bench --save-baseline full-20261030 --sample-size 20 --warm-up-time 0.5 --measurement-time 1 --noplot &
taskset -c 1 "$main_encoders" --bench --save-baseline full-20261030 --sample-size 20 --warm-up-time 0.5 --measurement-time 1 --noplot &
wait
python3 benchmarks/comparison/report.py --root "$main_tree/benchmarks/comparison/target/criterion" --baseline full-20261030 --csv /tmp/main-encoders.csv

Each tree’s harness writes Criterion output under its own manifest directory. The generators normalize each tree’s mzstd to the C timings of its own process, so the two series are comparable as ratios to C, not as absolute times. Repeat with the decoder binaries and --decoding.

Allocation-heavy cold cases (levels 13 and above, the 1 MiB inputs, empty input) move by up to 40% between processes, so the published change from main comes from paired process runs of the two trees’ profile drivers over the whole matrix:

python3 benchmarks/comparison/paired.py /tmp/profile-main /tmp/profile-current \
  --csv docs/benchmarks/encoder-comparison.csv --output /tmp/paired-encoders.json \
  --codec encode --cpu 0 --pairs 5 --seconds .1

Silesia

silesia.py times whole Silesia files with reused contexts. It takes two saved warm binaries: the working tree’s, which times C and mzstd, and the main branch’s. Every cell runs five rounds of the three sides in a seeded random order, each process at about 0.25 s of iterations calibrated on C, and checks its bytes against C first:

python3 benchmarks/comparison/silesia.py run --current /tmp/warm-current --main /tmp/warm-main \
  --corpus /path/to/silesia --output /tmp/silesia-encode.json --cpu 0 \
  --plan encode-1,encode1,encode3,encode5,encode7,encode9,encode12
python3 benchmarks/comparison/silesia.py report --results /tmp/silesia-encode.json \
  --output /tmp/silesia.svg

Interrupted runs resume from the output file. report reads plain or gzipped JSON.

Sharded runs

A complete sweep on one core takes about 45 minutes for each suite. On a host with idle cores, run the two suites, or corpus shards of them, at once, one process per physical core. Every implementation of a case still runs in the same process, so ratios between implementations stay comparable; absolute timings drop by a few percent because the shards share the last-level cache, memory bandwidth and turbo budget. Record the lanes in the environment file, and keep builds and other work off those cores.

Build the harness once, then run its executables directly so that concurrent cargo bench invocations do not wait on the build directory lock. The harness writes to fixed directories under benchmarks/comparison/target/, separate benchmark IDs land in separate directories, and every shard writes the same deterministic sizes.csv during its preflight:

cargo bench --manifest-path benchmarks/comparison/Cargo.toml --bench encoders --bench decoders --locked --no-run
cd benchmarks/comparison
enc=$(ls -t target/release/deps/encoders-* | grep -v '\.d$' | head -1)
dec=$(ls -t target/release/deps/decoders-* | grep -v '\.d$' | head -1)
taskset -c 2 "$enc" --bench --save-baseline my-enc &
taskset -c 6 "$dec" --bench --save-baseline my-dec &
wait
cd ../..
python3 benchmarks/comparison/report.py --baseline my-enc --csv /tmp/my-enc.csv
python3 benchmarks/comparison/report.py --decoding --baseline my-dec --csv /tmp/my-dec.csv

The published runs used exactly these two lanes.

Development benchmarks

cargo bench --bench compress, --bench decompress and --bench parallel at the repository root time warm contexts, streaming and parallel compression against the same C library, after checking identical output.

Profiling

The comparison package also supplies a focused driver with the same datasets and cold APIs. Arguments are encode|decode, c-zstd|mzstd, corpus, level, and a positive iteration count. It validates the selected case before the loop; use Criterion for reported timings and this driver for diagnosis or paired process runs.

CARGO_PROFILE_RELEASE_DEBUG=line-tables-only cargo build --release --manifest-path benchmarks/comparison/Cargo.toml --bin profile --locked
taskset -c 2 benchmarks/comparison/target/release/profile encode mzstd repeated-1m 1 10000
valgrind --tool=callgrind --collect-atstart=no --toggle-collect='*profile_encode*' \
  benchmarks/comparison/target/release/profile encode mzstd repeated-1m 1 30

For decoding, use decode and --toggle-collect='*profile_decode*'. The uninlined collection boundaries exclude corpus construction and preflight.

Save each build’s profile executable before rebuilding, then collect paired runs with a fixed core and randomized AB/BA ordering:

python3 benchmarks/comparison/paired.py /tmp/profile-before /tmp/profile-after \
  --csv /tmp/my-comparison.csv --output /tmp/paired.json \
  --cpu 2 --pairs 10 --seconds 0.15 --filter '^(text-1m|repeated-1m)/l(1|3|9)$'

Both processes use the same iteration count, calibrated from the CSV. The JSON retains every pair; it is not a significance claim. Use a new output path for each run. To compare C with Rust, pass the same executable twice and add --baseline-implementation c-zstd. Keep builds and other CPU-heavy work out of timed runs. Include tiny, incompressible, warm and streaming cases when checking for regressions, and report intervals and noisy cases alongside improvements.

  • Stages. cargo build --release --features hotpath turns on the hotpath::measure anchors at the codec’s stage boundaries; a binary whose main carries #[hotpath::main] prints the timing table on exit.

  • Sampling. samply record --save-only --rate 20000 -o profile.json.gz -- <binary> samples a release build (needs perf_event_paranoid <= 1). The leaf addresses map onto objdump -d or addr2line of the binary for instruction and source attribution. Samples locate hotspots; they are not exact per-instruction cycle counts. Record the event and sampling settings when comparing C and Rust profiles of the same workload.

  • Instructions and cache. valgrind --tool=callgrind (optionally with --dump-instr=yes or --cache-sim=yes) on a build with CARGO_PROFILE_RELEASE_DEBUG=line-tables-only gives exact instruction counts per function and source line; callgrind_annotate reads them.

  • Instruction counts per stage. On x86_64 Linux, hotpath-callgrind adds per-anchor instruction counts from Callgrind. The hotpath benchmark workflow runs in merged mode on every pull request: one build runs natively for timing and allocations, then under Callgrind, which merges its instruction counts into the native report and uploads it to hotpath Cloud, judged by hotpath/policy.toml. Valgrind distorts allocation data as well as timing, so both come from the native run. Counts and allocations are judged, timing is shown but not judged; compare counts only within one Valgrind version, Rust toolchain and runner image:

    cargo build --release --locked --example profile_compressor --features hotpath-alloc,hotpath-callgrind
    HOTPATH_OUTPUT_FORMAT=json HOTPATH_OUTPUT_PATH=native.json \
      target/release/examples/profile_compressor testdata/alice29.txt
    HOTPATH_OUTPUT_FORMAT=json HOTPATH_MERGE_REPORT=native.json HOTPATH_OUTPUT_PATH=merged.json \
      valgrind -q --tool=callgrind --instr-atstart=no \
      target/release/examples/profile_compressor testdata/alice29.txt
    
  • Criterion benchmarks per stage. benches/compress.rs and benches/decompress.rs import Criterion through hotpath::criterion, so they can produce the same reports. With HOTPATH_CRITERION_ITERATIONS set, one benchmark id runs exactly that many times in Criterion’s test mode and writes one hotpath report; without it, cargo bench is unchanged. The bench’s setup and byte-identity checks run before the profiler starts:

    HOTPATH_CRITERION_ITERATIONS=200 cargo bench --locked --bench decompress --features hotpath-alloc \
      -- --test --exact decompress/text-4MiB/level-3/mzstd
    

    The hotpath criterion workflow runs the ids listed in its matrix this way, in merged mode, as one hotpath Cloud series each. Adding an id takes one matrix entry (series, bench target, Criterion id and iteration count) and one entry in the criterion matrix of hotpath-relay.yml. A group’s config names hotpath::criterion::Config, the plain Criterion type, since the adapter’s Criterion wrapper has no default().

  • Code. cargo asm --lib --release '<symbol>' shows what a kernel compiled to; the SIMD-dispatched kernels appear as vectorize_avx2::<…::{closure}> symbols.

Measure allocation strategies in a fresh process: glibc raises its mmap threshold after the first large free, so the first cold call and later ones pay different page-fault and zeroing costs.

Documentation website

The mzstd book is built with mdBook and published through GitHub Pages. All pages in docs/ and architecture/ are included, along with their charts, CSV measurements, and environment records. Edit those files directly: there is no second maintained copy of the documentation.

Build locally

Requires Python 3.10+ and the pinned book tools:

cargo install mdbook --version 0.5.4 --locked
cargo install mdbook-mermaid --version 0.17.1 --locked
python3 scripts/docs.py build

Open target/book/index.html, or serve the book with:

python3 scripts/docs.py serve

The server listens on http://127.0.0.1:3000. Stop and rerun it after editing source Markdown: the server watches the generated source tree rather than the canonical directories. No Node.js installation or runtime CDN is needed. Mermaid assets come from the pinned preprocessor and are served with the book.

How source files become pages

scripts/docs.py copies docs/ and architecture/ into ignored target/book-src/, creates a table of contents, extracts Mermaid’s bundled JavaScript into target/book-assets/, then invokes mdbook build using the root book.toml. The output goes to target/book/. In the staged copy, README pages are renamed to index.md and the introductory page becomes the root index.md; their links are rewritten to match.

The sidebar has five parts in reading order: Start here, Guides, Performance and correctness, How it works, and Contributing. GUIDE_PARTS and CONTRIBUTING in scripts/docs.py hold the curated order. Nested chapters, such as the per-level benchmark pages, stay folded until their parent is opened.

Architecture entries follow the tables in architecture/README.md. Each ## section there that holds a specification table becomes a generated landing page under architecture/sections/, with its specifications nested beneath it. Every source Markdown file must appear in the table of contents: a missing architecture entry fails preparation. A new guide page is included automatically at the end of Guides until it receives a curated position.

Relative links between published pages stay local. Links to source files outside the published directories point to GitHub’s main branch. Code fences are preserved. The staged index pages become index.html pages. The build checks local files and heading anchors after rendering, including HTML links, images, and downloaded benchmark data. It also resolves every link from the root README.md into the published site, so renaming a guide without updating the README fails the build.

Theme and diagrams

book-theme/custom.css sets the palette for the default light theme and the navy dark theme, and styles tables, the sidebar, and the landing page (docs/introduction.md). book-theme/diagram-zoom.js opens a Mermaid diagram at its natural size when it is clicked or activated from the keyboard; Close or Esc returns it to the page. Both files are plain CSS and JavaScript with no build step.

Check the examples

python3 scripts/docs.py test-examples

This compiles and runs each Rust program in the guides listed in RECIPES in scripts/docs.py against the local crate with std and framed features, plus Rayon. A fenced rust block in those guides must be a complete program. Each program checks its decoded result. Temporary consumer source and build products remain under target/book-examples/.

Publish with GitHub Pages

In the repository settings, select Pages → Build and deployment → Source → GitHub Actions once. .github/workflows/pages.yml then:

  1. Builds and validates the book on pull requests and pushes to main.
  2. Compiles and runs the documented recipes.
  3. Uploads the static book as a Pages artifact.
  4. Deploys only on main pushes or a manual run on main.

Pull requests validate without publishing or receiving Pages write permissions. Deployment uses the github-pages environment and GitHub’s Pages actions; there is no generated-content branch. The site lives under /mzstd/, and relative asset links support that prefix and local previews.

See documentation architecture for the source/build boundaries and the CI specification.