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.
| Level | Compressed size | Compress | Decompress |
|---|---|---|---|
| -5 | 48.6% | 838 MB/s | 2828 MB/s |
| 1 | 34.5% | 626 MB/s | 1992 MB/s |
| 3 | 31.2% | 374 MB/s | 1784 MB/s |
| 8 | 28.2% | 103 MB/s | 1945 MB/s |
| 15 | 26.8% | 9.04 MB/s | 2014 MB/s |
| 22 | 24.7% | 2.27 MB/s | 1498 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
no_std.
Decode untrusted inputWindow, input and output limits.
StreamingReaders, writers, sans-I/O sessions and finish.
Parallel compressionOne input across scoped threads, Rayon or your executor.
DictionariesRaw and trained dictionaries for small inputs.
Framing containers opt-inSeveral resources and metadata in one seekable container.
How it worksOwnership, dispatch and state machines, with diagrams.
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 throughfearless_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
- Sans I/O compression and decompression: caller buffers, progress counts, and completion.
- Read / Write I/O: readers and writers for raw, parallel, and framed streams.
- Parallel compression: scoped threads, Rayon, files, and streaming.
- Framing containers: multiple resources, metadata, and indexed reads.
- Choose your API: every entry point for both codecs, with its guide.
- Configuration and features: level, parameters, and Cargo features.
- Decompression and limits: budgets for untrusted input.
- Examples: pick a recipe and run the repository examples.
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 shape | Compression | Decompression | Guide |
|---|---|---|---|
Return a new Vec<u8> | Compressor::compress | Decompressor::decompress | Getting started |
| Append to an existing Vec | compress_into | decompress_into | Buffers and memory reuse |
| Write into a caller-owned slice | compress_to_slice, compress_to_uninit | decompress_to_slice, decompress_to_uninit | Buffers and memory reuse |
Pull output through std::io::Read | Compressor::reader | Decompressor::reader | Read / Write I/O |
Push input through std::io::Write | Compressor::writer | Decompressor::writer | Read / Write I/O |
| Drive input and output incrementally | start → EncoderSession | start → DecoderSession | Sans I/O compression, decompression |
| Same, with the session owning the codec | into_session → EncoderSessionOwned | into_session → DecoderSessionOwned | Sans I/O compression, decompression |
| Any of the above with a dictionary | compress_with_dictionary*, start_with_dictionary, into_session_with_dictionary, writer_with_dictionary, reader_with_dictionary | decompress_with_dictionary*, start_with_dictionary, into_session_with_dictionary, writer_with_dictionary, reader_with_dictionary | Dictionaries |
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 shape | Entry point |
|---|---|
| Split one known input across your threads | prepare_slice / prepare_source → ScopedParallelBatch |
| Stream chunked input across your threads | start → ParallelEncoderSession + ParallelTaskQueue |
| Same, with the session owning the compressor | into_session → ParallelEncoderSessionOwned |
Push or pull through std::io across your threads | writer, reader |
See parallel compression for scoped threads, Rayon, sources, staging, and streaming.
Guides by task
| Task | Read |
|---|---|
| Pick a level, parameters, or Cargo features | Configuration and features |
| Control allocations and retained memory | Buffers and memory reuse |
| Decode untrusted input with resource limits | Decompression and limits |
Understand readers, writers, sessions, and finish | Streaming and completion |
| Connect readers and writers for every format | Read / Write I/O |
| Drive compression with caller buffers | Sans I/O compression |
| Decode arriving chunks and resource events | Sans I/O decompression |
| Split a job across workers | Parallel compression |
| Package several resources | Framing containers |
| Share a dictionary or use magicless frames | Dictionaries and extended formats |
| Copy a complete program | Examples |
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
- API reference on docs.rs
- Compatibility and validation: what is byte-identical to zstd 1.5.7, and how it is checked
- Benchmark results for every measured level, with raw data
- Architecture: how the implementation works
- Development and benchmarking for contributors
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.
| Task | Recipe | Features / extra dependency |
|---|---|---|
| Compress and decompress bytes | First round trip | Default |
| Reuse codec and destination memory | Reuse the workspace | Default |
| Pick a level and explicit parameters | Choose a level first | Default |
| Write into a caller-owned slice | Output buffers | Default |
| Decode untrusted input with limits | Set limits for untrusted input | Default |
| Compress small records with a dictionary | Prepared dictionaries | Default |
| Write a raw stream incrementally | Stream through Write | Default |
| Drive a raw or framed encoder with tiny buffers | Sans I/O compression | Default; framed for frames |
| Decode arriving chunks and resource events | Sans I/O decompression | Default; framed for frames |
| Read or write raw, parallel, and framed streams | Read / Write I/O | Default; framed for frames |
| Use scoped CPU workers | Compressing a slice | Default |
| Reuse a Rayon thread pool | Running tasks with Rayon | Default + rayon |
| Stream through parallel workers | Reader and writer adapters | Default |
| Package compressed and stored resources | One container, two resources | framed |
| Add resource names and write incrementally | Write resources and metadata | framed |
| Read just the selected resource | Indexed resource reads | framed |
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:
| Setting | Type / method | Behavior |
|---|---|---|
| Compression level | Level / with_level | -131072–22; default 3; 0 aliases 3 |
| Advanced compression parameters | CompressionParams / with_params | Window, hash/chain/search logs, match length, target length, strategy |
| Long distance matching | LongDistanceMatching / with_long_distance_matching | Automatic, disabled, or explicit LdmParams |
| Content checksum | with_checksum | Add a checksum to the frame |
| Frame format | with_format | Standard or magicless |
| Target compressed block size | with_target_compressed_block_size | Optional 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.
| Configuration | Dependency |
|---|---|
| Default std encoder + decoder | mzstd = "0.2.13" |
| Encoder only | mzstd = { version = "0.2.13", default-features = false, features = ["std", "compression"] } |
| Decoder only | mzstd = { version = "0.2.13", default-features = false, features = ["std", "decompression"] } |
| Framing containers | mzstd = { version = "0.2.13", features = ["framed"] } |
| Alloc-backed codecs without std | mzstd = { version = "0.2.13", default-features = false, features = ["no_std", "compression", "decompression"] } |
| Feature | Default | Enables |
|---|---|---|
compression | yes | mzstd::compressor |
decompression | yes | mzstd::decompressor |
std | yes | std::error::Error, mzstd::io, parallel compression |
no_std | no | Alloc-only build; overrides std and removes std-only I/O and parallel APIs |
framed | no | mzstd::framing: seekable containers of resources and metadata. Its API may change in a patch release |
hotpath, hotpath-cpu, hotpath-alloc, hotpath-callgrind, hotpath-cloud | no | Profiling 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 to | Use |
|---|---|
Get a fresh Vec<u8> | compress / decompress |
| Append to a Vec you already own | compress_into / decompress_into |
| Write into a fixed slice | compress_to_slice / decompress_to_slice |
| Write into uninitialized memory | compress_to_uninit / decompress_to_uninit |
| Cap what the codec keeps between calls | A 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 policy | Effect |
|---|---|
Aggressive (default) | Keeps allocated buffers |
Bounded { max_bytes } | Releases storage when retained accounting exceeds the ceiling |
ReleaseAll | Releases 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
| Task | Read |
|---|---|
| Decode chunks as they arrive | Sans I/O decompression |
Decode from a Read source or into a Write sink | Read / Write I/O |
| Decode a framing container | Framing containers |
| Attach a dictionary | Dictionaries 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:
| Shape | You call | The library does | Recipes |
|---|---|---|---|
| Reader | read on the adapter | Pulls source bytes and yields transformed bytes | Read / Write I/O |
| Writer | write_all, then finish | Pushes transformed bytes into your sink | Read / Write I/O |
| Session | process with your own buffers | No I/O; reports consumed and produced counts | Sans 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 / status | Meaning |
|---|---|
Operation::Continue | Accept input without ending the frame |
Operation::Flush | Make accepted input decodable without ending the frame |
Operation::End | End the frame after this input |
EncoderStatus::NeedsInput | Supply more input or explicit flush/finish |
EncoderStatus::NeedsOutput | Supply output space and retain unconsumed input |
EncoderStatus::Finished | All 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
| Operation | Pull bytes through a reader | Push bytes through a writer |
|---|---|---|
| Raw compression | Compressor::reader | Compressor::writer |
| Raw decompression | Decompressor::reader | Decompressor::writer |
| Parallel compression | ParallelCompressor::reader + task queue | ParallelCompressor::writer + task queue |
| Decompress parallel output | Decompressor::reader | Decompressor::writer |
| Framed compression · opt-in | FramedCompressor::framed_reader over complete borrowed resource inputs | FramedCompressor::framed_writer with resource writers |
| Framed decompression · opt-in | FramedDecompressor::framed_reader → events; framed_seek_reader → selected resource Read | Drive 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
| Adapter | Complete operation | Recover after a failure |
|---|---|---|
| Raw encoder / decoder reader | Read to EOF | Decoder into_parts preserves read-ahead; codec errors are terminal |
| Raw encoder / decoder writer | finish or retryable try_finish | FinishError::into_parts returns error and writer |
| Parallel reader | Read to EOF with active workers | into_parts preserves read-ahead; keep queue execution alive |
| Parallel writer | finish with active workers | FinishError::into_parts returns error and adapter |
| Framed encoded reader | Read to EOF | Dropping early abandons that container operation |
| Framed writer | Each resource try_finish, then container finish | FramingFinishError exposes writer and error |
| Framed event reader | next_event until None | Inspect 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 format | Session entry point | Scheduling |
|---|---|---|
| Raw Zstandard | Compressor::start(stream) | Call process yourself |
| One raw stream encoded across workers | ParallelCompressor::start(stream, window) | Drive process and run its task queue |
| Framed resources · opt-in | FramedCompressor::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.
| Input | Decoder |
|---|---|
| Ordinary raw Zstandard | Decompressor::start |
Output of ParallelCompressor | The same Decompressor::start |
| Framing container · opt-in | FramedDecompressor::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(())
}
processnever blocks.Pendingmeans the window is full or a flush or finish waits for queued tasks; callwait, which blocks until one completes.windowbounds 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_sliceover the same bytes, however the input was chunked.Operation::Flushends the current segment early, which moves later segment boundaries. - On a single thread, run
queue.try_recv()tasks beforewait; 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_compressorreturns 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, andfinishwaits 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’sflushisOperation::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,
WouldBlockand sink errors lose nothing, andfinishhands the adapter back insideFinishErrorfor a retry, asEncoderWriterdoes.reader.into_parts()returns source bytes read ahead of the session. - Session failures arrive as
std::io::Errorwith theParallelEncodeErroras 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
| Input | Entry point |
|---|---|
| Borrowed slice | prepare_slice(input, tasks) |
| Regular file | prepare_source(FileSource::open(path)?, tasks) |
| Owned seekable reader | prepare_source(SeekSource::from(reader), tasks) |
| Custom random-access source | prepare_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.
| Concept | Meaning |
|---|---|
| Container | The enclosing structure, with resources and optional metadata/directory/footer |
| Resource | One logical payload, compressed or stored verbatim |
| Chunk | A bounded portion of a resource on the wire; each visible compressed chunk is a standard Zstandard frame |
| Directory | Optional 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
| Feature | Surface |
|---|---|
compression, decompression | Independently selectable codecs and dictionary preparation |
std | Standard error traits and runtime SIMD support |
no_std | Alloc-backed codecs; removes std-only I/O and parallel APIs |
framed | Structured, seekable Zstandard containers; APIs may change in a patch release |
hotpath, hotpath-cpu, hotpath-alloc, hotpath-callgrind, hotpath-cloud | Profiling 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
| Surface | Claim | Oracle |
|---|---|---|
| Serial one-shot encoding, levels -131072–22 | Byte identity for equivalent input, parameters, and dictionary | zstd 1.5.7 ZSTD_compress2 |
| Serial sessions | Byte identity with the same chunks, capacities, flushes, parameters, and pledged size | ZSTD_compressStream2 |
| Decoding | Standard Zstandard frames, concatenated/skippable frames, raw/trained dictionaries, checksums, and configured limits | Independent Rust decoder, C differential tests, upstream golden vectors |
| Parallel encoding | Valid deterministic frames for fixed input/configuration/segment size | Rust API comparisons and reference decoding; no ZSTDMT byte identity |
| Framing | Resources, metadata, directory, footer, seek table, and indexed reads | Round trips, independent wire fixtures, zstd regular and contrib seekable decoders |
| SIMD selection and output destinations | Equivalent observable results | Baseline/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%.
| Compare | Results | Data |
|---|---|---|
| Compression speed and size, 384 cold cases | Levels -5 to 22, report | CSV, environment, size manifest, Criterion samples |
| Decompression speed on identical frames, 384 cold cases | Source levels -5 to 22, report | CSV, environment, size manifest, Criterion samples |
lzbench on silesia.tar, one frame | Report | Raw 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.
| Dataset | Input bytes | Content |
|---|---|---|
| empty | 0 | API overhead without input |
| tiny-text | 44 | Quick-brown-fox sentence |
| alice29 | 152,089 | Alice in Wonderland text (testdata/alice29.txt) |
| text-1m | 1,048,576 | Alice repeated cyclically |
| binary-64k | 65,536 | Structured binary bytes |
| random-64k | 65,536 | Prefix of the pseudorandom corpus |
| random-1m | 1,048,576 | Deterministic xorshift64 bytes |
| repeated-1m | 1,048,576 | Repeated 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
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.
| Level | mzstd speed / C ↑ | mzstd output / C ↓ | Datasets |
|---|---|---|---|
| Level -5 | 1.720× | 1.000× | 8 |
| Level -1 | 1.633× | 1.000× | 8 |
| Level 2 | 1.455× | 1.000× | 8 |
| Level 1 | 1.380× | 1.000× | 8 |
| Level 16 | 1.337× | 1.000× | 8 |
| Level 19 | 1.318× | 1.000× | 8 |
| Level 17 | 1.298× | 1.000× | 8 |
| Level 18 | 1.287× | 1.000× | 8 |
| Level 20 | 1.268× | 1.000× | 8 |
| Level 8 | 1.268× | 1.000× | 8 |
| Level 3 | 1.268× | 1.000× | 8 |
| Level 9 | 1.266× | 1.000× | 8 |
| Level 10 | 1.261× | 1.000× | 8 |
| Level 5 | 1.256× | 1.000× | 8 |
| Level 7 | 1.244× | 1.000× | 8 |
| Level 6 | 1.222× | 1.000× | 8 |
| Level 21 | 1.219× | 1.000× | 8 |
| Level 15 | 1.195× | 1.000× | 8 |
| Level 22 | 1.193× | 1.000× | 8 |
| Level 12 | 1.184× | 1.000× | 8 |
| Level 13 | 1.172× | 1.000× | 8 |
| Level 11 | 1.164× | 1.000× | 8 |
| Level 4 | 1.163× | 1.000× | 8 |
| Level 14 | 1.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.
| Implementation | Median speed / C ↑ | Median output / C ↓ | Datasets |
|---|---|---|---|
| zstd C | 1.000× | 1.000× | 8 |
| mzstd | 1.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
| Implementation | Mean, ns | 95% mean interval, ns | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 2,200 | 2,168–2,236 | — | 9 | — | 1.000× |
| mzstd | 204.4 | 197.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 2.483 | 2.416–2.561 | 16.90 | 53 | 120.455% | 1.000× |
| mzstd | 0.3053 | 0.2914–0.3207 | 137.46 | 53 | 120.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 79.98 | 77.36–82.99 | 12,503.89 | 55 | 0.005% | 1.000× |
| mzstd | 28.57 | 28.31–28.87 | 34,999.29 | 55 | 0.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 9.566 | 9.255–9.912 | 6,533.28 | 3,189 | 4.866% | 1.000× |
| mzstd | 5.313 | 5.232–5.404 | 11,764.11 | 3,189 | 4.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 6.704 | 6.486–6.943 | 9,323.24 | 65,546 | 100.015% | 1.000× |
| mzstd | 4.092 | 3.995–4.207 | 15,275.18 | 65,546 | 100.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 69.01 | 67.74–70.32 | 14,491.55 | 1,048,610 | 100.003% | 1.000× |
| mzstd | 60.65 | 59.48–61.85 | 16,488.59 | 1,048,610 | 100.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 330.3 | 315.9–346.2 | 439.15 | 85,658 | 56.321% | 1.000× |
| mzstd | 297.8 | 292.9–304 | 487.07 | 85,658 | 56.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 319.5 | 305.3–335.2 | 3,130.25 | 96,657 | 9.218% | 1.000× |
| mzstd | 292.8 | 279.3–306.8 | 3,415.00 | 96,657 | 9.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.
| Implementation | Median speed / C ↑ | Median output / C ↓ | Datasets |
|---|---|---|---|
| zstd C | 1.000× | 1.000× | 8 |
| mzstd | 1.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
| Implementation | Mean, ns | 95% mean interval, ns | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 2,299 | 2,231–2,374 | — | 9 | — | 1.000× |
| mzstd | 200.9 | 193.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 2.645 | 2.535–2.771 | 15.86 | 53 | 120.455% | 1.000× |
| mzstd | 0.2903 | 0.2849–0.2965 | 144.53 | 53 | 120.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 78.12 | 76.16–80.59 | 12,800.99 | 51 | 0.005% | 1.000× |
| mzstd | 28.56 | 28.21–29.04 | 35,014.71 | 51 | 0.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 11.1 | 10.64–11.6 | 5,630.72 | 2,442 | 3.726% | 1.000× |
| mzstd | 6.386 | 6.19–6.601 | 9,787.28 | 2,442 | 3.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 6.803 | 6.705–6.93 | 9,187.22 | 65,546 | 100.015% | 1.000× |
| mzstd | 4.453 | 4.352–4.57 | 14,036.88 | 65,546 | 100.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 70.51 | 69.28–71.86 | 14,181.86 | 1,048,610 | 100.003% | 1.000× |
| mzstd | 62.84 | 61.8–63.93 | 15,913.72 | 1,048,610 | 100.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 438.2 | 425.7–451.1 | 2,282.31 | 80,894 | 7.715% | 1.000× |
| mzstd | 403.7 | 391–417.2 | 2,477.15 | 80,894 | 7.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 390.8 | 378–405.8 | 371.14 | 71,645 | 47.107% | 1.000× |
| mzstd | 410 | 395.3–426.5 | 353.73 | 71,645 | 47.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.
| Implementation | Median speed / C ↑ | Median output / C ↓ | Datasets |
|---|---|---|---|
| zstd C | 1.000× | 1.000× | 8 |
| mzstd | 1.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
| Implementation | Mean, ns | 95% mean interval, ns | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 2,216 | 2,169–2,271 | — | 9 | — | 1.000× |
| mzstd | 196.9 | 191.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 2.41 | 2.366–2.465 | 17.41 | 53 | 120.455% | 1.000× |
| mzstd | 0.3127 | 0.2992–0.3274 | 134.20 | 53 | 120.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 83.3 | 79.45–87.75 | 12,004.13 | 51 | 0.005% | 1.000× |
| mzstd | 29.02 | 28.67–29.43 | 34,461.55 | 51 | 0.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 10.72 | 10.61–10.86 | 5,829.12 | 2,537 | 3.871% | 1.000× |
| mzstd | 7.143 | 6.886–7.438 | 8,750.08 | 2,537 | 3.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 8.784 | 8.64–8.964 | 7,115.37 | 65,546 | 100.015% | 1.000× |
| mzstd | 6.979 | 6.796–7.18 | 8,955.37 | 65,546 | 100.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 465.5 | 456.7–474.6 | 2,148.34 | 61,206 | 5.837% | 1.000× |
| mzstd | 403.6 | 390.3–417.6 | 2,477.63 | 61,206 | 5.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 85.17 | 84.34–86.05 | 11,741.62 | 1,048,610 | 100.003% | 1.000× |
| mzstd | 77.86 | 77.01–78.81 | 12,843.85 | 1,048,610 | 100.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 366.9 | 360.8–375.2 | 395.31 | 59,657 | 39.225% | 1.000× |
| mzstd | 369.4 | 360.6–380.4 | 392.62 | 59,657 | 39.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.
| Implementation | Median speed / C ↑ | Median output / C ↓ | Datasets |
|---|---|---|---|
| zstd C | 1.000× | 1.000× | 8 |
| mzstd | 1.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
| Implementation | Mean, ns | 95% mean interval, ns | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 2,345 | 2,278–2,413 | — | 9 | — | 1.000× |
| mzstd | 209.8 | 201.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 2.427 | 2.369–2.491 | 17.29 | 53 | 120.455% | 1.000× |
| mzstd | 0.3365 | 0.3196–0.3537 | 124.71 | 53 | 120.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 85.96 | 80.9–91.65 | 11,633.42 | 50 | 0.005% | 1.000× |
| mzstd | 31.01 | 30.35–31.74 | 32,249.55 | 50 | 0.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 11.75 | 11.52–12.03 | 5,318.44 | 2,425 | 3.700% | 1.000× |
| mzstd | 7.918 | 7.777–8.097 | 7,893.33 | 2,425 | 3.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 10.9 | 10.53–11.3 | 5,732.80 | 65,546 | 100.015% | 1.000× |
| mzstd | 7.645 | 7.514–7.807 | 8,175.56 | 65,546 | 100.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 555.7 | 535.2–576.8 | 1,799.48 | 58,340 | 5.564% | 1.000× |
| mzstd | 463.9 | 450.1–478.7 | 2,155.86 | 58,340 | 5.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 96.38 | 94.05–98.82 | 10,375.17 | 1,048,609 | 100.003% | 1.000× |
| mzstd | 87.71 | 85.9–89.61 | 11,401.16 | 1,048,609 | 100.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 477.6 | 468.5–488.3 | 303.66 | 57,348 | 37.707% | 1.000× |
| mzstd | 568.1 | 549.2–589.6 | 255.30 | 57,348 | 37.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.
| Implementation | Median speed / C ↑ | Median output / C ↓ | Datasets |
|---|---|---|---|
| zstd C | 1.000× | 1.000× | 8 |
| mzstd | 1.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
| Implementation | Mean, ns | 95% mean interval, ns | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 2,207 | 2,163–2,260 | — | 9 | — | 1.000× |
| mzstd | 213.2 | 206–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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 2.545 | 2.467–2.628 | 16.49 | 53 | 120.455% | 1.000× |
| mzstd | 0.3682 | 0.3582–0.3806 | 113.98 | 53 | 120.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 144.8 | 141.5–149.4 | 6,904.64 | 50 | 0.005% | 1.000× |
| mzstd | 43.36 | 42.57–44.25 | 23,063.42 | 50 | 0.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 17.49 | 16.89–18.15 | 3,573.62 | 2,324 | 3.546% | 1.000× |
| mzstd | 12.58 | 11.91–13.26 | 4,969.85 | 2,324 | 3.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 720.3 | 696.4–744.9 | 1,388.34 | 55,878 | 5.329% | 1.000× |
| mzstd | 629 | 607.6–651.1 | 1,589.79 | 55,878 | 5.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 14.16 | 13.75–14.62 | 4,412.51 | 65,546 | 100.015% | 1.000× |
| mzstd | 12.39 | 12.03–12.78 | 5,045.02 | 65,546 | 100.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 126.1 | 122.3–130.1 | 7,928.03 | 1,048,609 | 100.003% | 1.000× |
| mzstd | 123.6 | 120.8–126.8 | 8,088.23 | 1,048,609 | 100.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 565.5 | 551.9–580.3 | 256.50 | 56,970 | 37.458% | 1.000× |
| mzstd | 559.2 | 535.9–584.5 | 259.38 | 56,970 | 37.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.
| Implementation | Median speed / C ↑ | Median output / C ↓ | Datasets |
|---|---|---|---|
| zstd C | 1.000× | 1.000× | 8 |
| mzstd | 1.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
| Implementation | Mean, ns | 95% mean interval, ns | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 2,254 | 2,179–2,342 | — | 9 | — | 1.000× |
| mzstd | 207 | 203.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 2.455 | 2.403–2.517 | 17.09 | 53 | 120.455% | 1.000× |
| mzstd | 0.4495 | 0.4378–0.4649 | 93.35 | 53 | 120.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 164.7 | 158.5–171.9 | 6,071.32 | 50 | 0.005% | 1.000× |
| mzstd | 53.4 | 52.48–54.46 | 18,726.54 | 50 | 0.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 755.3 | 734–777.7 | 1,324.02 | 55,760 | 5.318% | 1.000× |
| mzstd | 639.8 | 620.2–661.1 | 1,563.00 | 55,760 | 5.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 18.74 | 18.23–19.29 | 3,335.66 | 65,546 | 100.015% | 1.000× |
| mzstd | 16.37 | 16–16.79 | 3,818.29 | 65,546 | 100.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 102.9 | 99.89–106.2 | 607.67 | 3,909 | 5.965% | 1.000× |
| mzstd | 95.37 | 92.92–98.36 | 655.36 | 3,909 | 5.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 1,007 | 968.5–1,051 | 144.04 | 54,282 | 35.691% | 1.000× |
| mzstd | 1,029 | 1,001–1,059 | 141.01 | 54,282 | 35.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 126.1 | 124.3–128.3 | 7,929.46 | 1,048,609 | 100.003% | 1.000× |
| mzstd | 134.2 | 132.1–136.8 | 7,452.21 | 1,048,609 | 100.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.
| Implementation | Median speed / C ↑ | Median output / C ↓ | Datasets |
|---|---|---|---|
| zstd C | 1.000× | 1.000× | 8 |
| mzstd | 1.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
| Implementation | Mean, ns | 95% mean interval, ns | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 2,378 | 2,259–2,519 | — | 9 | — | 1.000× |
| mzstd | 210.9 | 204.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 2.483 | 2.421–2.555 | 16.90 | 53 | 120.455% | 1.000× |
| mzstd | 0.4755 | 0.4587–0.495 | 88.26 | 53 | 120.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 171.9 | 169–175.7 | 5,816.25 | 50 | 0.005% | 1.000× |
| mzstd | 87.06 | 85.51–88.76 | 11,485.84 | 50 | 0.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 27.73 | 26.67–28.94 | 2,254.06 | 1,270 | 1.938% | 1.000× |
| mzstd | 21.75 | 21.4–22.21 | 2,873.52 | 1,270 | 1.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 274.4 | 266.3–282.8 | 3,644.18 | 1,048,609 | 100.003% | 1.000× |
| mzstd | 221.9 | 216.9–227.4 | 4,505.58 | 1,048,609 | 100.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 28.41 | 27.76–29.11 | 2,200.05 | 65,546 | 100.015% | 1.000× |
| mzstd | 24.12 | 23.29–24.99 | 2,591.42 | 65,546 | 100.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 1,307 | 1,264–1,352 | 765.20 | 54,185 | 5.167% | 1.000× |
| mzstd | 1,242 | 1,203–1,281 | 805.27 | 54,185 | 5.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 1,146 | 1,120–1,177 | 126.52 | 53,372 | 35.093% | 1.000× |
| mzstd | 1,148 | 1,123–1,180 | 126.32 | 53,372 | 35.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.
| Implementation | Median speed / C ↑ | Median output / C ↓ | Datasets |
|---|---|---|---|
| zstd C | 1.000× | 1.000× | 8 |
| mzstd | 1.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
| Implementation | Mean, ns | 95% mean interval, ns | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 2,193 | 2,147–2,250 | — | 9 | — | 1.000× |
| mzstd | 211.1 | 203.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 2.525 | 2.469–2.589 | 16.62 | 53 | 120.455% | 1.000× |
| mzstd | 0.4636 | 0.4429–0.4879 | 90.51 | 53 | 120.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 363 | 346.5–381.3 | 2,755.03 | 49 | 0.005% | 1.000× |
| mzstd | 124.7 | 123.4–126.2 | 8,017.82 | 49 | 0.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 30.12 | 29.53–30.88 | 2,075.04 | 1,270 | 1.938% | 1.000× |
| mzstd | 24.29 | 23.35–25.37 | 2,573.14 | 1,270 | 1.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 28.72 | 27.95–29.55 | 2,175.94 | 65,546 | 100.015% | 1.000× |
| mzstd | 23.86 | 23.19–24.58 | 2,619.99 | 65,546 | 100.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 263.5 | 255.6–271.8 | 3,794.70 | 1,048,609 | 100.003% | 1.000× |
| mzstd | 235.5 | 228.5–242.8 | 4,246.51 | 1,048,609 | 100.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 1,888 | 1,826–1,950 | 529.79 | 53,196 | 5.073% | 1.000× |
| mzstd | 1,706 | 1,652–1,764 | 586.21 | 53,196 | 5.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 1,387 | 1,351–1,428 | 104.60 | 53,111 | 34.921% | 1.000× |
| mzstd | 1,497 | 1,450–1,548 | 96.90 | 53,111 | 34.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.
| Implementation | Median speed / C ↑ | Median output / C ↓ | Datasets |
|---|---|---|---|
| zstd C | 1.000× | 1.000× | 8 |
| mzstd | 1.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
| Implementation | Mean, ns | 95% mean interval, ns | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 2,199 | 2,141–2,271 | — | 9 | — | 1.000× |
| mzstd | 204.9 | 200.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 2.423 | 2.408–2.441 | 17.32 | 53 | 120.455% | 1.000× |
| mzstd | 0.456 | 0.4403–0.4753 | 92.01 | 53 | 120.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 364.1 | 352.6–378.6 | 2,746.15 | 49 | 0.005% | 1.000× |
| mzstd | 150.1 | 146.9–153.4 | 6,664.32 | 49 | 0.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 33.35 | 32.71–34.34 | 1,874.19 | 1,270 | 1.938% | 1.000× |
| mzstd | 24.99 | 24.46–25.59 | 2,500.72 | 1,270 | 1.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 27.42 | 26.71–28.21 | 2,279.41 | 65,546 | 100.015% | 1.000× |
| mzstd | 23.76 | 23.2–24.36 | 2,629.99 | 65,546 | 100.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 328.9 | 320.5–337 | 3,040.69 | 1,048,609 | 100.003% | 1.000× |
| mzstd | 315.6 | 308.2–322.8 | 3,168.42 | 1,048,609 | 100.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 1,897 | 1,842–1,955 | 527.22 | 52,804 | 5.036% | 1.000× |
| mzstd | 1,923 | 1,858–1,990 | 519.92 | 52,804 | 5.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 1,645 | 1,621–1,674 | 88.18 | 53,195 | 34.976% | 1.000× |
| mzstd | 1,770 | 1,738–1,806 | 81.94 | 53,195 | 34.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.
| Implementation | Median speed / C ↑ | Median output / C ↓ | Datasets |
|---|---|---|---|
| zstd C | 1.000× | 1.000× | 8 |
| mzstd | 1.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
| Implementation | Mean, ns | 95% mean interval, ns | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 2,298 | 2,237–2,367 | — | 9 | — | 1.000× |
| mzstd | 228.5 | 222.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 2.484 | 2.439–2.54 | 16.89 | 53 | 120.455% | 1.000× |
| mzstd | 0.4402 | 0.4297–0.4527 | 95.33 | 53 | 120.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 543.3 | 535–553.2 | 1,840.51 | 49 | 0.005% | 1.000× |
| mzstd | 214.3 | 210.7–218.6 | 4,665.85 | 49 | 0.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 33.82 | 33.12–34.71 | 1,847.97 | 1,270 | 1.938% | 1.000× |
| mzstd | 24.6 | 24.06–25.28 | 2,540.94 | 1,270 | 1.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 27.87 | 27.08–28.71 | 2,242.73 | 65,546 | 100.015% | 1.000× |
| mzstd | 24.01 | 23.28–24.8 | 2,603.58 | 65,546 | 100.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 344 | 338.5–349.5 | 2,907.09 | 1,048,609 | 100.003% | 1.000× |
| mzstd | 321.4 | 315.6–326.9 | 3,111.09 | 1,048,609 | 100.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 2,395 | 2,341–2,454 | 417.47 | 52,393 | 4.997% | 1.000× |
| mzstd | 2,431 | 2,351–2,513 | 411.34 | 52,393 | 4.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 2,225 | 2,170–2,289 | 65.18 | 52,710 | 34.657% | 1.000× |
| mzstd | 2,516 | 2,434–2,601 | 57.65 | 52,710 | 34.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.
| Implementation | Median speed / C ↑ | Median output / C ↓ | Datasets |
|---|---|---|---|
| zstd C | 1.000× | 1.000× | 8 |
| mzstd | 1.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
| Implementation | Mean, ns | 95% mean interval, ns | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 2,274 | 2,221–2,335 | — | 9 | — | 1.000× |
| mzstd | 226.9 | 221.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 2.623 | 2.574–2.68 | 16.00 | 53 | 120.455% | 1.000× |
| mzstd | 0.5134 | 0.4936–0.536 | 81.73 | 53 | 120.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 622.8 | 603.3–646.9 | 1,605.73 | 49 | 0.005% | 1.000× |
| mzstd | 265.6 | 262.6–269.2 | 3,765.15 | 49 | 0.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 33.94 | 33.52–34.43 | 1,841.47 | 1,270 | 1.938% | 1.000× |
| mzstd | 26.53 | 25.41–27.8 | 2,355.46 | 1,270 | 1.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 29.45 | 28.34–30.6 | 2,122.31 | 65,546 | 100.015% | 1.000× |
| mzstd | 23.5 | 22.92–24.16 | 2,659.48 | 65,546 | 100.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 529.5 | 515–543.5 | 1,888.72 | 1,048,609 | 100.003% | 1.000× |
| mzstd | 480.3 | 464.3–496 | 2,081.86 | 1,048,609 | 100.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 3,047 | 2,932–3,166 | 328.23 | 52,381 | 4.995% | 1.000× |
| mzstd | 2,795 | 2,694–2,898 | 357.78 | 52,381 | 4.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 2,672 | 2,623–2,729 | 54.29 | 52,188 | 34.314% | 1.000× |
| mzstd | 2,657 | 2,617–2,702 | 54.60 | 52,188 | 34.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.
| Implementation | Median speed / C ↑ | Median output / C ↓ | Datasets |
|---|---|---|---|
| zstd C | 1.000× | 1.000× | 8 |
| mzstd | 1.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
| Implementation | Mean, ns | 95% mean interval, ns | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 2,292 | 2,237–2,353 | — | 9 | — | 1.000× |
| mzstd | 228.9 | 221.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 2.702 | 2.622–2.789 | 15.53 | 53 | 120.455% | 1.000× |
| mzstd | 0.5353 | 0.5044–0.5692 | 78.39 | 53 | 120.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 636.4 | 612.4–664.4 | 1,571.46 | 49 | 0.005% | 1.000× |
| mzstd | 260 | 258.4–261.9 | 3,845.90 | 49 | 0.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 35.01 | 34.11–36.13 | 1,785.02 | 1,270 | 1.938% | 1.000× |
| mzstd | 27.57 | 26.42–28.86 | 2,266.78 | 1,270 | 1.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 30.41 | 29.36–31.55 | 2,055.04 | 65,546 | 100.015% | 1.000× |
| mzstd | 24.3 | 23.61–25.06 | 2,571.60 | 65,546 | 100.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 504.1 | 496–511.8 | 1,983.92 | 1,048,609 | 100.003% | 1.000× |
| mzstd | 431.4 | 419.9–443.2 | 2,318.24 | 1,048,609 | 100.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 3,226 | 3,171–3,290 | 44.96 | 51,894 | 34.121% | 1.000× |
| mzstd | 3,315 | 3,209–3,435 | 43.75 | 51,894 | 34.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 3,170 | 3,077–3,269 | 315.41 | 52,148 | 4.973% | 1.000× |
| mzstd | 3,386 | 3,282–3,493 | 295.32 | 52,148 | 4.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.
| Implementation | Median speed / C ↑ | Median output / C ↓ | Datasets |
|---|---|---|---|
| zstd C | 1.000× | 1.000× | 8 |
| mzstd | 1.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
| Implementation | Mean, ns | 95% mean interval, ns | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 2,473 | 2,397–2,549 | — | 9 | — | 1.000× |
| mzstd | 407.5 | 381.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 613.3 | 599.5–629.9 | 1,630.62 | 49 | 0.005% | 1.000× |
| mzstd | 274.9 | 269.2–281.1 | 3,637.48 | 49 | 0.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 4.308 | 4.142–4.486 | 9.74 | 53 | 120.455% | 1.000× |
| mzstd | 2.139 | 2.046–2.241 | 19.62 | 53 | 120.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 31.6 | 31.19–32.2 | 1,977.85 | 1,276 | 1.947% | 1.000× |
| mzstd | 26.58 | 25.91–27.36 | 2,351.27 | 1,276 | 1.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 480.7 | 472.3–488.3 | 2,080.19 | 1,048,609 | 100.003% | 1.000× |
| mzstd | 421.6 | 412.4–430.3 | 2,371.70 | 1,048,609 | 100.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 7,332 | 7,201–7,466 | 19.78 | 51,491 | 33.856% | 1.000× |
| mzstd | 6,837 | 6,722–6,972 | 21.21 | 51,491 | 33.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 3,803 | 3,670–3,940 | 262.94 | 52,034 | 4.962% | 1.000× |
| mzstd | 3,654 | 3,536–3,773 | 273.64 | 52,034 | 4.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 95.91 | 92.19–99.96 | 651.68 | 65,546 | 100.015% | 1.000× |
| mzstd | 94.83 | 89.56–100.3 | 659.11 | 65,546 | 100.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.
| Implementation | Median speed / C ↑ | Median output / C ↓ | Datasets |
|---|---|---|---|
| zstd C | 1.000× | 1.000× | 8 |
| mzstd | 1.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
| Implementation | Mean, ns | 95% mean interval, ns | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 2,199 | 2,186–2,215 | — | 9 | — | 1.000× |
| mzstd | 323.1 | 319.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 603.4 | 591.2–619.1 | 1,657.14 | 49 | 0.005% | 1.000× |
| mzstd | 269.6 | 264.7–275.2 | 3,709.16 | 49 | 0.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 4.192 | 4.093–4.307 | 10.01 | 53 | 120.455% | 1.000× |
| mzstd | 2.214 | 2.161–2.276 | 18.95 | 53 | 120.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 34.02 | 32.76–35.46 | 1,837.29 | 1,276 | 1.947% | 1.000× |
| mzstd | 26.77 | 25.85–27.81 | 2,334.99 | 1,276 | 1.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 96.41 | 91.85–101.3 | 648.25 | 65,546 | 100.015% | 1.000× |
| mzstd | 87.86 | 83.48–92.68 | 711.39 | 65,546 | 100.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 458.6 | 443.3–474.3 | 2,180.51 | 1,048,609 | 100.003% | 1.000× |
| mzstd | 420.4 | 410.5–430 | 2,378.65 | 1,048,609 | 100.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 4,015 | 3,897–4,130 | 249.07 | 52,034 | 4.962% | 1.000× |
| mzstd | 3,717 | 3,586–3,845 | 269.07 | 52,034 | 4.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 6,951 | 6,847–7,065 | 20.87 | 51,492 | 33.856% | 1.000× |
| mzstd | 7,896 | 7,758–8,037 | 18.37 | 51,492 | 33.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.
| Implementation | Median speed / C ↑ | Median output / C ↓ | Datasets |
|---|---|---|---|
| zstd C | 1.000× | 1.000× | 8 |
| mzstd | 1.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
| Implementation | Mean, ns | 95% mean interval, ns | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 2,347 | 2,275–2,426 | — | 9 | — | 1.000× |
| mzstd | 361.6 | 346.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 1,340 | 1,307–1,380 | 746.11 | 50 | 0.005% | 1.000× |
| mzstd | 424.8 | 419.9–430.1 | 2,354.24 | 50 | 0.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 6.487 | 6.349–6.652 | 6.47 | 53 | 120.455% | 1.000× |
| mzstd | 4.131 | 4.031–4.247 | 10.16 | 53 | 120.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 117.9 | 112.6–123.8 | 529.97 | 1,259 | 1.921% | 1.000× |
| mzstd | 98.23 | 95.17–101.8 | 636.24 | 1,259 | 1.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 1.137e+04 | 1.105e+04–1.17e+04 | 12.76 | 49,705 | 32.682% | 1.000× |
| mzstd | 9,937 | 9,794–1.01e+04 | 14.60 | 49,705 | 32.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 860.1 | 834.3–889.8 | 72.67 | 65,546 | 100.015% | 1.000× |
| mzstd | 819.2 | 787.5–852.9 | 76.30 | 65,546 | 100.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 7,956 | 7,729–8,185 | 125.70 | 51,847 | 4.945% | 1.000× |
| mzstd | 8,296 | 8,068–8,529 | 120.54 | 51,847 | 4.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 3,196 | 3,082–3,318 | 312.92 | 1,048,609 | 100.003% | 1.000× |
| mzstd | 3,762 | 3,549–3,992 | 265.79 | 1,048,609 | 100.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.
| Implementation | Median speed / C ↑ | Median output / C ↓ | Datasets |
|---|---|---|---|
| zstd C | 1.000× | 1.000× | 8 |
| mzstd | 1.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
| Implementation | Mean, ns | 95% mean interval, ns | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 2,286 | 2,230–2,349 | — | 9 | — | 1.000× |
| mzstd | 348.5 | 335.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 1,533 | 1,454–1,618 | 652.35 | 50 | 0.005% | 1.000× |
| mzstd | 460.9 | 452.5–469.4 | 2,169.62 | 50 | 0.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 6.62 | 6.519–6.74 | 6.34 | 53 | 120.455% | 1.000× |
| mzstd | 4.375 | 4.215–4.551 | 9.59 | 53 | 120.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 121.9 | 118.1–126.3 | 512.85 | 1,259 | 1.921% | 1.000× |
| mzstd | 101.5 | 98.42–105.5 | 615.55 | 1,259 | 1.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 7,514 | 7,284–7,749 | 133.09 | 51,818 | 4.942% | 1.000× |
| mzstd | 7,347 | 7,163–7,532 | 136.12 | 51,818 | 4.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 1.275e+04 | 1.243e+04–1.309e+04 | 11.38 | 49,635 | 32.635% | 1.000× |
| mzstd | 1.267e+04 | 1.241e+04–1.293e+04 | 11.45 | 49,635 | 32.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 2,957 | 2,823–3,117 | 338.23 | 1,048,609 | 100.003% | 1.000× |
| mzstd | 3,103 | 2,961–3,246 | 322.23 | 1,048,609 | 100.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 1,234 | 1,193–1,278 | 50.66 | 65,546 | 100.015% | 1.000× |
| mzstd | 1,349 | 1,298–1,400 | 46.33 | 65,546 | 100.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.
| Implementation | Median speed / C ↑ | Median output / C ↓ | Datasets |
|---|---|---|---|
| zstd C | 1.000× | 1.000× | 8 |
| mzstd | 1.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
| Implementation | Mean, ns | 95% mean interval, ns | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 2,242 | 2,195–2,296 | — | 9 | — | 1.000× |
| mzstd | 365.3 | 349.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 1,573 | 1,485–1,666 | 635.73 | 50 | 0.005% | 1.000× |
| mzstd | 474.4 | 458.5–492.9 | 2,108.14 | 50 | 0.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 6.922 | 6.719–7.142 | 6.06 | 53 | 120.455% | 1.000× |
| mzstd | 4.414 | 4.224–4.622 | 9.51 | 53 | 120.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 1,435 | 1,377–1,494 | 43.56 | 65,546 | 100.015% | 1.000× |
| mzstd | 1,153 | 1,097–1,214 | 54.20 | 65,546 | 100.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 121.9 | 117.3–127.3 | 512.76 | 1,259 | 1.921% | 1.000× |
| mzstd | 106.5 | 102.6–110.8 | 587.00 | 1,259 | 1.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 1.321e+04 | 1.275e+04–1.368e+04 | 10.98 | 49,610 | 32.619% | 1.000× |
| mzstd | 1.215e+04 | 1.183e+04–1.25e+04 | 11.94 | 49,610 | 32.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 8,015 | 7,676–8,358 | 124.77 | 51,817 | 4.942% | 1.000× |
| mzstd | 7,762 | 7,539–7,986 | 128.84 | 51,817 | 4.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 2,952 | 2,853–3,052 | 338.79 | 1,048,609 | 100.003% | 1.000× |
| mzstd | 2,927 | 2,761–3,091 | 341.68 | 1,048,609 | 100.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.
| Implementation | Median speed / C ↑ | Median output / C ↓ | Datasets |
|---|---|---|---|
| zstd C | 1.000× | 1.000× | 8 |
| mzstd | 1.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
| Implementation | Mean, ns | 95% mean interval, ns | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 2,399 | 2,313–2,493 | — | 9 | — | 1.000× |
| mzstd | 360.9 | 347.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 5.585e+04 | 5.078e+04–6.109e+04 | 17.90 | 1,048,609 | 100.003% | 1.000× |
| mzstd | 3.34e+04 | 3.171e+04–3.518e+04 | 29.94 | 1,048,609 | 100.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 891.5 | 855.5–929.5 | 1,121.74 | 49 | 0.005% | 1.000× |
| mzstd | 651 | 629.4–673.7 | 1,536.19 | 49 | 0.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 6.873 | 6.804–6.963 | 6.11 | 53 | 120.455% | 1.000× |
| mzstd | 5.102 | 4.897–5.318 | 8.22 | 53 | 120.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 1.399e+04 | 1.361e+04–1.436e+04 | 71.49 | 49,924 | 4.761% | 1.000× |
| mzstd | 1.053e+04 | 1.021e+04–1.089e+04 | 94.94 | 49,924 | 4.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 147.9 | 144.9–152.1 | 422.49 | 1,259 | 1.921% | 1.000× |
| mzstd | 119.1 | 117.6–121.1 | 524.55 | 1,259 | 1.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 1,206 | 1,165–1,251 | 51.84 | 65,546 | 100.015% | 1.000× |
| mzstd | 1,049 | 1,026–1,079 | 59.57 | 65,546 | 100.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 1.663e+04 | 1.621e+04–1.707e+04 | 8.72 | 49,355 | 32.451% | 1.000× |
| mzstd | 1.649e+04 | 1.583e+04–1.712e+04 | 8.80 | 49,355 | 32.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.
| Implementation | Median speed / C ↑ | Median output / C ↓ | Datasets |
|---|---|---|---|
| zstd C | 1.000× | 1.000× | 8 |
| mzstd | 1.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
| Implementation | Mean, ns | 95% mean interval, ns | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 2,318 | 2,280–2,364 | — | 9 | — | 1.000× |
| mzstd | 425.9 | 409.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 5.724e+04 | 5.218e+04–6.247e+04 | 17.47 | 1,048,609 | 100.003% | 1.000× |
| mzstd | 3.319e+04 | 3.152e+04–3.485e+04 | 30.13 | 1,048,609 | 100.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 914.1 | 884.2–944.4 | 1,093.96 | 49 | 0.005% | 1.000× |
| mzstd | 588.1 | 574–603.3 | 1,700.44 | 49 | 0.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 7.218 | 6.988–7.465 | 5.81 | 53 | 120.455% | 1.000× |
| mzstd | 5.178 | 4.954–5.412 | 8.10 | 53 | 120.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 148.4 | 144.9–152.6 | 421.26 | 1,259 | 1.921% | 1.000× |
| mzstd | 123.4 | 120–127.6 | 506.64 | 1,259 | 1.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 1.708e+04 | 1.647e+04–1.769e+04 | 8.49 | 49,341 | 32.442% | 1.000× |
| mzstd | 1.483e+04 | 1.432e+04–1.538e+04 | 9.78 | 49,341 | 32.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 1.342e+04 | 1.305e+04–1.381e+04 | 74.49 | 49,642 | 4.734% | 1.000× |
| mzstd | 1.176e+04 | 1.143e+04–1.212e+04 | 85.06 | 49,642 | 4.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 1,160 | 1,133–1,191 | 53.90 | 65,546 | 100.015% | 1.000× |
| mzstd | 1,074 | 1,039–1,114 | 58.20 | 65,546 | 100.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.
| Implementation | Median speed / C ↑ | Median output / C ↓ | Datasets |
|---|---|---|---|
| zstd C | 1.000× | 1.000× | 8 |
| mzstd | 1.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
| Implementation | Mean, ns | 95% mean interval, ns | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 2,481 | 2,380–2,592 | — | 9 | — | 1.000× |
| mzstd | 413.1 | 397.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 6.955 | 6.838–7.088 | 6.03 | 53 | 120.455% | 1.000× |
| mzstd | 4.632 | 4.475–4.812 | 9.06 | 53 | 120.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 6.256e+04 | 5.65e+04–6.863e+04 | 15.98 | 1,048,609 | 100.003% | 1.000× |
| mzstd | 4.466e+04 | 4.295e+04–4.635e+04 | 22.39 | 1,048,609 | 100.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 851.6 | 826.8–877.8 | 1,174.21 | 49 | 0.005% | 1.000× |
| mzstd | 623.1 | 607.9–640 | 1,604.80 | 49 | 0.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 151.3 | 146.7–156.8 | 413.01 | 1,259 | 1.921% | 1.000× |
| mzstd | 125.4 | 121.5–129.8 | 498.46 | 1,259 | 1.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 1.691e+04 | 1.648e+04–1.739e+04 | 59.12 | 49,433 | 4.714% | 1.000× |
| mzstd | 1.501e+04 | 1.464e+04–1.544e+04 | 66.63 | 49,433 | 4.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 3.018e+04 | 2.912e+04–3.127e+04 | 4.81 | 49,215 | 32.359% | 1.000× |
| mzstd | 2.864e+04 | 2.757e+04–2.972e+04 | 5.06 | 49,215 | 32.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 1,136 | 1,117–1,158 | 55.00 | 65,546 | 100.015% | 1.000× |
| mzstd | 1,099 | 1,062–1,143 | 56.85 | 65,546 | 100.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.
| Implementation | Median speed / C ↑ | Median output / C ↓ | Datasets |
|---|---|---|---|
| zstd C | 1.000× | 1.000× | 8 |
| mzstd | 1.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
| Implementation | Mean, ns | 95% mean interval, ns | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 2,177 | 2,163–2,195 | — | 9 | — | 1.000× |
| mzstd | 384.6 | 376.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 7.013 | 6.86–7.197 | 5.98 | 53 | 120.455% | 1.000× |
| mzstd | 4.724 | 4.542–4.927 | 8.88 | 53 | 120.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 7.104e+04 | 6.711e+04–7.507e+04 | 14.08 | 1,048,609 | 100.003% | 1.000× |
| mzstd | 4.906e+04 | 4.711e+04–5.106e+04 | 20.38 | 1,048,609 | 100.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 867.3 | 835.8–899.8 | 1,153.00 | 49 | 0.005% | 1.000× |
| mzstd | 646.3 | 624.3–670.3 | 1,547.25 | 49 | 0.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 3.5e+04 | 3.395e+04–3.599e+04 | 4.14 | 49,211 | 32.357% | 1.000× |
| mzstd | 2.703e+04 | 2.605e+04–2.809e+04 | 5.37 | 49,211 | 32.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 2,461 | 2,357–2,577 | 25.40 | 65,546 | 100.015% | 1.000× |
| mzstd | 2,182 | 2,116–2,259 | 28.65 | 65,546 | 100.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 2.899e+04 | 2.817e+04–2.99e+04 | 34.50 | 49,285 | 4.700% | 1.000× |
| mzstd | 2.658e+04 | 2.601e+04–2.722e+04 | 37.62 | 49,285 | 4.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 277.9 | 263.9–293.5 | 224.92 | 1,259 | 1.921% | 1.000× |
| mzstd | 255.6 | 240.2–271.7 | 244.57 | 1,259 | 1.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.
| Implementation | Median speed / C ↑ | Median output / C ↓ | Datasets |
|---|---|---|---|
| zstd C | 1.000× | 1.000× | 8 |
| mzstd | 1.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
| Implementation | Mean, ns | 95% mean interval, ns | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 2,221 | 2,191–2,257 | — | 9 | — | 1.000× |
| mzstd | 436.2 | 414.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 7.932 | 7.676–8.188 | 5.29 | 53 | 120.455% | 1.000× |
| mzstd | 5.466 | 5.186–5.752 | 7.68 | 53 | 120.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 6.607e+04 | 6.066e+04–7.207e+04 | 15.13 | 1,048,609 | 100.003% | 1.000× |
| mzstd | 4.681e+04 | 4.455e+04–4.905e+04 | 21.36 | 1,048,609 | 100.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 782.4 | 766–801.5 | 1,278.14 | 49 | 0.005% | 1.000× |
| mzstd | 597.3 | 585.9–609.7 | 1,674.08 | 49 | 0.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 275.9 | 263.6–289.1 | 226.57 | 1,259 | 1.921% | 1.000× |
| mzstd | 225 | 215.3–236.1 | 277.81 | 1,259 | 1.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 2.986e+04 | 2.896e+04–3.088e+04 | 33.49 | 49,285 | 4.700% | 1.000× |
| mzstd | 2.551e+04 | 2.52e+04–2.586e+04 | 39.20 | 49,285 | 4.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 3.129e+04 | 3.004e+04–3.258e+04 | 4.64 | 49,211 | 32.357% | 1.000× |
| mzstd | 2.756e+04 | 2.667e+04–2.851e+04 | 5.26 | 49,211 | 32.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 2,327 | 2,260–2,407 | 26.86 | 65,546 | 100.015% | 1.000× |
| mzstd | 2,135 | 2,069–2,207 | 29.28 | 65,546 | 100.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.
| Implementation | Median speed / C ↑ | Median output / C ↓ | Datasets |
|---|---|---|---|
| zstd C | 1.000× | 1.000× | 8 |
| mzstd | 1.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
| Implementation | Mean, ns | 95% mean interval, ns | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 2,256 | 2,207–2,312 | — | 9 | — | 1.000× |
| mzstd | 420.7 | 405.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 6.008e+04 | 5.417e+04–6.646e+04 | 16.64 | 1,048,609 | 100.003% | 1.000× |
| mzstd | 3.19e+04 | 3.032e+04–3.359e+04 | 31.34 | 1,048,609 | 100.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 7.964 | 7.69–8.237 | 5.27 | 53 | 120.455% | 1.000× |
| mzstd | 5.101 | 4.871–5.349 | 8.23 | 53 | 120.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 268.2 | 258.7–278.7 | 233.06 | 1,259 | 1.921% | 1.000× |
| mzstd | 219.8 | 209.7–231.8 | 284.38 | 1,259 | 1.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 861.2 | 832.1–892 | 1,161.15 | 49 | 0.005% | 1.000× |
| mzstd | 707.2 | 669–750.7 | 1,413.98 | 49 | 0.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 3.024e+04 | 2.924e+04–3.128e+04 | 4.80 | 49,211 | 32.357% | 1.000× |
| mzstd | 2.605e+04 | 2.524e+04–2.694e+04 | 5.57 | 49,211 | 32.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 2,635 | 2,523–2,752 | 23.72 | 65,546 | 100.015% | 1.000× |
| mzstd | 2,355 | 2,258–2,459 | 26.54 | 65,546 | 100.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 2.894e+04 | 2.835e+04–2.96e+04 | 34.55 | 49,285 | 4.700% | 1.000× |
| mzstd | 2.68e+04 | 2.598e+04–2.77e+04 | 37.32 | 49,285 | 4.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.
| Implementation | Median speed / C ↑ | Median output / C ↓ | Datasets |
|---|---|---|---|
| zstd C | 1.000× | 1.000× | 8 |
| mzstd | 1.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
| Implementation | Mean, ns | 95% mean interval, ns | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 2,292 | 2,232–2,361 | — | 9 | — | 1.000× |
| mzstd | 418.5 | 403.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 7.236 | 7.033–7.461 | 5.80 | 53 | 120.455% | 1.000× |
| mzstd | 4.602 | 4.475–4.755 | 9.12 | 53 | 120.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 800.4 | 781.9–820.9 | 1,249.44 | 49 | 0.005% | 1.000× |
| mzstd | 611 | 597.9–625.6 | 1,636.61 | 49 | 0.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 2,633 | 2,517–2,754 | 23.74 | 65,546 | 100.015% | 1.000× |
| mzstd | 2,149 | 2,087–2,220 | 29.08 | 65,546 | 100.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 253.3 | 248.6–258.7 | 246.73 | 1,259 | 1.921% | 1.000× |
| mzstd | 218.3 | 208.6–229.7 | 286.33 | 1,259 | 1.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 3.446e+04 | 3.257e+04–3.676e+04 | 29.02 | 1,048,609 | 100.003% | 1.000× |
| mzstd | 3.153e+04 | 2.991e+04–3.333e+04 | 31.72 | 1,048,609 | 100.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 2.896e+04 | 2.802e+04–2.998e+04 | 5.01 | 49,211 | 32.357% | 1.000× |
| mzstd | 2.752e+04 | 2.666e+04–2.843e+04 | 5.27 | 49,211 | 32.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
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| zstd C | 2.832e+04 | 2.782e+04–2.891e+04 | 35.31 | 49,285 | 4.700% | 1.000× |
| mzstd | 2.7e+04 | 2.62e+04–2.786e+04 | 37.03 | 49,285 | 4.700% | 1.049× |
Decoder comparison
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 level | zstd C | mzstd |
|---|---|---|
| 1 | 1.000× | 1.676× |
| 2 | 1.000× | 1.611× |
| 12 | 1.000× | 1.591× |
| 5 | 1.000× | 1.589× |
| 15 | 1.000× | 1.573× |
| -5 | 1.000× | 1.572× |
| 6 | 1.000× | 1.565× |
| 11 | 1.000× | 1.542× |
| 20 | 1.000× | 1.539× |
| -1 | 1.000× | 1.513× |
| 7 | 1.000× | 1.506× |
| 14 | 1.000× | 1.497× |
| 21 | 1.000× | 1.496× |
| 13 | 1.000× | 1.492× |
| 19 | 1.000× | 1.457× |
| 10 | 1.000× | 1.455× |
| 16 | 1.000× | 1.453× |
| 3 | 1.000× | 1.438× |
| 9 | 1.000× | 1.435× |
| 22 | 1.000× | 1.428× |
| 18 | 1.000× | 1.407× |
| 8 | 1.000× | 1.389× |
| 17 | 1.000× | 1.348× |
| 4 | 1.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.
| Decoder | Median speed / C |
|---|---|
| zstd C | 1.000× |
| mzstd | 1.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.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 2.143 | 2.086–2.207 | — | 1.000× |
| mzstd | 0.03951 | 0.03816–0.04109 | — | 54.242× |
tiny-text
44-byte quick-brown-fox sentence.
53 compressed → 44 restored bytes; compressed / original: 120.455%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 2.088 | 2.026–2.155 | 20.10 | 1.000× |
| mzstd | 0.05106 | 0.0493–0.05306 | 821.87 | 40.897× |
random-64k
64 KiB prefix of the deterministic pseudorandom corpus.
65,546 compressed → 65,536 restored bytes; compressed / original: 100.015%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 3.304 | 3.223–3.387 | 18,914.43 | 1.000× |
| mzstd | 0.9591 | 0.9433–0.9757 | 65,168.06 | 3.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%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 4.595 | 4.44–4.765 | 13,601.76 | 1.000× |
| mzstd | 2.828 | 2.681–2.983 | 22,098.00 | 1.625× |
repeated-1m
1 MiB of repeated a bytes.
55 compressed → 1,048,576 restored bytes; compressed / original: 0.005%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 14.45 | 14.1–14.86 | 69,195.88 | 1.000× |
| mzstd | 9.51 | 9.305–9.736 | 105,153.73 | 1.520× |
random-1m
1 MiB of deterministic xorshift64 pseudorandom bytes.
1,048,610 compressed → 1,048,576 restored bytes; compressed / original: 100.003%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 19.09 | 18.96–19.23 | 52,383.77 | 1.000× |
| mzstd | 16.91 | 16.78–17.05 | 59,129.68 | 1.129× |
alice29
Alice in Wonderland text from the Canterbury corpus (testdata/alice29.txt).
85,658 compressed → 152,089 restored bytes; compressed / original: 56.321%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 78.66 | 75.31–82.58 | 1,844.04 | 1.000× |
| mzstd | 80.77 | 77.98–84.3 | 1,795.84 | 0.974× |
text-1m
Alice text repeated cyclically to 1 MiB.
96,657 compressed → 1,048,576 restored bytes; compressed / original: 9.218%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 73.07 | 71–75.41 | 13,685.07 | 1.000× |
| mzstd | 78.45 | 75.91–81.2 | 12,746.30 | 0.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.
| Decoder | Median speed / C |
|---|---|
| zstd C | 1.000× |
| mzstd | 1.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.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 1.965 | 1.947–1.985 | — | 1.000× |
| mzstd | 0.03796 | 0.03724–0.03895 | — | 51.750× |
tiny-text
44-byte quick-brown-fox sentence.
53 compressed → 44 restored bytes; compressed / original: 120.455%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 1.967 | 1.953–1.982 | 21.34 | 1.000× |
| mzstd | 0.05293 | 0.05079–0.05523 | 792.81 | 37.155× |
random-64k
64 KiB prefix of the deterministic pseudorandom corpus.
65,546 compressed → 65,536 restored bytes; compressed / original: 100.015%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 3.214 | 3.133–3.296 | 19,448.34 | 1.000× |
| mzstd | 1.02 | 1.009–1.031 | 61,266.25 | 3.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%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 4.778 | 4.625–4.947 | 13,081.99 | 1.000× |
| mzstd | 3.125 | 3.009–3.253 | 20,000.69 | 1.529× |
repeated-1m
1 MiB of repeated a bytes.
51 compressed → 1,048,576 restored bytes; compressed / original: 0.005%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 14.14 | 13.97–14.34 | 70,738.35 | 1.000× |
| mzstd | 9.447 | 9.226–9.699 | 105,850.50 | 1.496× |
random-1m
1 MiB of deterministic xorshift64 pseudorandom bytes.
1,048,610 compressed → 1,048,576 restored bytes; compressed / original: 100.003%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 19.13 | 18.98–19.29 | 52,284.36 | 1.000× |
| mzstd | 16.84 | 16.67–17.02 | 59,387.92 | 1.136× |
text-1m
Alice text repeated cyclically to 1 MiB.
80,894 compressed → 1,048,576 restored bytes; compressed / original: 7.715%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 97.43 | 93.2–101.9 | 10,263.74 | 1.000× |
| mzstd | 100.4 | 95.58–105.4 | 9,961.54 | 0.971× |
alice29
Alice in Wonderland text from the Canterbury corpus (testdata/alice29.txt).
71,645 compressed → 152,089 restored bytes; compressed / original: 47.107%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 92.24 | 89.16–95.64 | 1,572.45 | 1.000× |
| mzstd | 96.49 | 92.71–101 | 1,503.17 | 0.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.
| Decoder | Median speed / C |
|---|---|
| zstd C | 1.000× |
| mzstd | 1.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.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 2.069 | 1.984–2.182 | — | 1.000× |
| mzstd | 0.0376 | 0.03706–0.03826 | — | 55.044× |
tiny-text
44-byte quick-brown-fox sentence.
53 compressed → 44 restored bytes; compressed / original: 120.455%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 2.004 | 1.969–2.044 | 20.94 | 1.000× |
| mzstd | 0.04799 | 0.04755–0.04853 | 874.45 | 41.760× |
random-64k
64 KiB prefix of the deterministic pseudorandom corpus.
65,546 compressed → 65,536 restored bytes; compressed / original: 100.015%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 3.415 | 3.325–3.5 | 18,303.89 | 1.000× |
| mzstd | 1.002 | 0.9869–1.016 | 62,394.57 | 3.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%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 5.222 | 5.046–5.401 | 11,967.81 | 1.000× |
| mzstd | 2.846 | 2.781–2.92 | 21,960.66 | 1.835× |
repeated-1m
1 MiB of repeated a bytes.
51 compressed → 1,048,576 restored bytes; compressed / original: 0.005%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 14.14 | 13.93–14.38 | 70,740.38 | 1.000× |
| mzstd | 9.323 | 9.173–9.496 | 107,264.22 | 1.516× |
random-1m
1 MiB of deterministic xorshift64 pseudorandom bytes.
1,048,610 compressed → 1,048,576 restored bytes; compressed / original: 100.003%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 18.85 | 18.69–19.03 | 53,051.96 | 1.000× |
| mzstd | 16.45 | 16.29–16.63 | 60,772.31 | 1.146× |
alice29
Alice in Wonderland text from the Canterbury corpus (testdata/alice29.txt).
59,657 compressed → 152,089 restored bytes; compressed / original: 39.225%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 96.4 | 91.21–102.1 | 1,504.56 | 1.000× |
| mzstd | 94.26 | 92.55–96.55 | 1,538.74 | 1.023× |
text-1m
Alice text repeated cyclically to 1 MiB.
61,206 compressed → 1,048,576 restored bytes; compressed / original: 5.837%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 99.84 | 96.69–103.3 | 10,015.85 | 1.000× |
| mzstd | 111.9 | 107.5–116.6 | 8,939.28 | 0.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.
| Decoder | Median speed / C |
|---|---|
| zstd C | 1.000× |
| mzstd | 1.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.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 2.082 | 2.022–2.148 | — | 1.000× |
| mzstd | 0.04431 | 0.04299–0.04565 | — | 46.989× |
tiny-text
44-byte quick-brown-fox sentence.
53 compressed → 44 restored bytes; compressed / original: 120.455%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 1.945 | 1.932–1.96 | 21.57 | 1.000× |
| mzstd | 0.05349 | 0.05118–0.056 | 784.44 | 36.360× |
random-64k
64 KiB prefix of the deterministic pseudorandom corpus.
65,546 compressed → 65,536 restored bytes; compressed / original: 100.015%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 3.363 | 3.283–3.443 | 18,584.97 | 1.000× |
| mzstd | 0.9758 | 0.9592–0.9927 | 64,049.71 | 3.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%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 5.104 | 4.896–5.325 | 12,245.56 | 1.000× |
| mzstd | 3.098 | 2.989–3.214 | 20,173.51 | 1.647× |
repeated-1m
1 MiB of repeated a bytes.
50 compressed → 1,048,576 restored bytes; compressed / original: 0.005%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 14.48 | 14.15–14.85 | 69,074.21 | 1.000× |
| mzstd | 9.188 | 9.079–9.313 | 108,831.92 | 1.576× |
random-1m
1 MiB of deterministic xorshift64 pseudorandom bytes.
1,048,609 compressed → 1,048,576 restored bytes; compressed / original: 100.003%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 19.46 | 19.24–19.7 | 51,379.36 | 1.000× |
| mzstd | 16.88 | 16.74–17.05 | 59,231.56 | 1.153× |
alice29
Alice in Wonderland text from the Canterbury corpus (testdata/alice29.txt).
57,348 compressed → 152,089 restored bytes; compressed / original: 37.707%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 93.94 | 92.21–96.14 | 1,543.92 | 1.000× |
| mzstd | 105.3 | 101–110.6 | 1,377.90 | 0.892× |
text-1m
Alice text repeated cyclically to 1 MiB.
58,340 compressed → 1,048,576 restored bytes; compressed / original: 5.564%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 113.5 | 109.8–117.6 | 8,812.28 | 1.000× |
| mzstd | 134.7 | 128.5–141.3 | 7,423.31 | 0.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.
| Decoder | Median speed / C |
|---|---|
| zstd C | 1.000× |
| mzstd | 1.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.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 1.999 | 1.978–2.025 | — | 1.000× |
| mzstd | 0.0397 | 0.03841–0.04121 | — | 50.355× |
tiny-text
44-byte quick-brown-fox sentence.
53 compressed → 44 restored bytes; compressed / original: 120.455%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 2.004 | 1.954–2.061 | 20.93 | 1.000× |
| mzstd | 0.0508 | 0.0492–0.05269 | 826.10 | 39.461× |
random-64k
64 KiB prefix of the deterministic pseudorandom corpus.
65,546 compressed → 65,536 restored bytes; compressed / original: 100.015%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 3.318 | 3.233–3.402 | 18,836.83 | 1.000× |
| mzstd | 0.9138 | 0.9052–0.9236 | 68,394.93 | 3.631× |
repeated-1m
1 MiB of repeated a bytes.
50 compressed → 1,048,576 restored bytes; compressed / original: 0.005%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 14.4 | 14.03–14.82 | 69,447.92 | 1.000× |
| mzstd | 9.857 | 9.588–10.14 | 101,451.54 | 1.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%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 5.078 | 4.936–5.234 | 12,309.02 | 1.000× |
| mzstd | 3.588 | 3.439–3.739 | 17,418.34 | 1.415× |
random-1m
1 MiB of deterministic xorshift64 pseudorandom bytes.
1,048,609 compressed → 1,048,576 restored bytes; compressed / original: 100.003%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 19.3 | 19.08–19.55 | 51,808.66 | 1.000× |
| mzstd | 16.9 | 16.74–17.07 | 59,174.84 | 1.142× |
alice29
Alice in Wonderland text from the Canterbury corpus (testdata/alice29.txt).
56,970 compressed → 152,089 restored bytes; compressed / original: 37.458%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 111.5 | 107.9–115.6 | 1,300.26 | 1.000× |
| mzstd | 116.7 | 113.6–120.8 | 1,242.72 | 0.956× |
text-1m
Alice text repeated cyclically to 1 MiB.
55,878 compressed → 1,048,576 restored bytes; compressed / original: 5.329%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 116.8 | 112.8–121.2 | 8,562.62 | 1.000× |
| mzstd | 136.1 | 129.5–142.9 | 7,350.10 | 0.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.
| Decoder | Median speed / C |
|---|---|
| zstd C | 1.000× |
| mzstd | 1.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.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 2.286 | 2.228–2.346 | — | 1.000× |
| mzstd | 0.0404 | 0.03942–0.04148 | — | 56.593× |
tiny-text
44-byte quick-brown-fox sentence.
53 compressed → 44 restored bytes; compressed / original: 120.455%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 1.985 | 1.94–2.041 | 21.14 | 1.000× |
| mzstd | 0.05011 | 0.04863–0.05187 | 837.40 | 39.611× |
random-64k
64 KiB prefix of the deterministic pseudorandom corpus.
65,546 compressed → 65,536 restored bytes; compressed / original: 100.015%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 2.954 | 2.887–3.037 | 21,156.81 | 1.000× |
| mzstd | 0.9087 | 0.9014–0.9168 | 68,780.47 | 3.251× |
repeated-1m
1 MiB of repeated a bytes.
50 compressed → 1,048,576 restored bytes; compressed / original: 0.005%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 13.98 | 13.84–14.15 | 71,518.90 | 1.000× |
| mzstd | 9.36 | 9.166–9.583 | 106,840.90 | 1.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%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 50.36 | 48.12–52.83 | 1,241.10 | 1.000× |
| mzstd | 43.86 | 41.46–46.6 | 1,425.03 | 1.148× |
random-1m
1 MiB of deterministic xorshift64 pseudorandom bytes.
1,048,609 compressed → 1,048,576 restored bytes; compressed / original: 100.003%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 19.6 | 19.31–19.92 | 51,025.86 | 1.000× |
| mzstd | 17.21 | 17.01–17.41 | 58,121.11 | 1.139× |
alice29
Alice in Wonderland text from the Canterbury corpus (testdata/alice29.txt).
54,282 compressed → 152,089 restored bytes; compressed / original: 35.691%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 98.5 | 95.48–102.1 | 1,472.51 | 1.000× |
| mzstd | 101.4 | 99.9–103.3 | 1,429.83 | 0.971× |
text-1m
Alice text repeated cyclically to 1 MiB.
55,760 compressed → 1,048,576 restored bytes; compressed / original: 5.318%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 114.1 | 111.1–117.8 | 8,762.63 | 1.000× |
| mzstd | 130.9 | 125.1–137.3 | 7,638.94 | 0.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.
| Decoder | Median speed / C |
|---|---|
| zstd C | 1.000× |
| mzstd | 1.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.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 2.194 | 2.127–2.263 | — | 1.000× |
| mzstd | 0.04339 | 0.04148–0.04542 | — | 50.557× |
tiny-text
44-byte quick-brown-fox sentence.
53 compressed → 44 restored bytes; compressed / original: 120.455%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 2.002 | 1.957–2.064 | 20.96 | 1.000× |
| mzstd | 0.0513 | 0.04955–0.05327 | 818.03 | 39.030× |
random-64k
64 KiB prefix of the deterministic pseudorandom corpus.
65,546 compressed → 65,536 restored bytes; compressed / original: 100.015%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 2.969 | 2.91–3.035 | 21,052.20 | 1.000× |
| mzstd | 0.9479 | 0.9289–0.9696 | 65,935.79 | 3.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%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 6.549 | 6.279–6.831 | 9,542.76 | 1.000× |
| mzstd | 4.059 | 3.981–4.163 | 15,396.37 | 1.613× |
repeated-1m
1 MiB of repeated a bytes.
50 compressed → 1,048,576 restored bytes; compressed / original: 0.005%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 14.48 | 14.16–14.85 | 69,069.47 | 1.000× |
| mzstd | 9.253 | 9.119–9.411 | 108,073.49 | 1.565× |
random-1m
1 MiB of deterministic xorshift64 pseudorandom bytes.
1,048,609 compressed → 1,048,576 restored bytes; compressed / original: 100.003%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 19.86 | 19.64–20.1 | 50,345.47 | 1.000× |
| mzstd | 17.19 | 17.03–17.37 | 58,159.58 | 1.155× |
text-1m
Alice text repeated cyclically to 1 MiB.
54,185 compressed → 1,048,576 restored bytes; compressed / original: 5.167%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 121 | 117.3–125 | 8,264.80 | 1.000× |
| mzstd | 120 | 117.6–123 | 8,331.92 | 1.008× |
alice29
Alice in Wonderland text from the Canterbury corpus (testdata/alice29.txt).
53,372 compressed → 152,089 restored bytes; compressed / original: 35.093%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 92.75 | 91.47–94.26 | 1,563.77 | 1.000× |
| mzstd | 99.14 | 97.16–101.5 | 1,462.98 | 0.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.
| Decoder | Median speed / C |
|---|---|
| zstd C | 1.000× |
| mzstd | 1.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.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 2.126 | 2.067–2.191 | — | 1.000× |
| mzstd | 0.03994 | 0.03888–0.04116 | — | 53.218× |
tiny-text
44-byte quick-brown-fox sentence.
53 compressed → 44 restored bytes; compressed / original: 120.455%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 1.999 | 1.966–2.038 | 20.99 | 1.000× |
| mzstd | 0.05021 | 0.04866–0.05204 | 835.74 | 39.821× |
random-64k
64 KiB prefix of the deterministic pseudorandom corpus.
65,546 compressed → 65,536 restored bytes; compressed / original: 100.015%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 3.121 | 3.042–3.201 | 20,026.43 | 1.000× |
| mzstd | 0.8981 | 0.8927–0.9045 | 69,589.56 | 3.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%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 6.523 | 6.234–6.835 | 9,582.02 | 1.000× |
| mzstd | 4.112 | 4.02–4.232 | 15,198.30 | 1.586× |
repeated-1m
1 MiB of repeated a bytes.
49 compressed → 1,048,576 restored bytes; compressed / original: 0.005%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 14.37 | 14.05–14.74 | 69,600.81 | 1.000× |
| mzstd | 9.305 | 9.136–9.507 | 107,468.05 | 1.544× |
random-1m
1 MiB of deterministic xorshift64 pseudorandom bytes.
1,048,609 compressed → 1,048,576 restored bytes; compressed / original: 100.003%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 19.01 | 18.81–19.24 | 52,611.42 | 1.000× |
| mzstd | 16.4 | 16.29–16.52 | 60,984.31 | 1.159× |
text-1m
Alice text repeated cyclically to 1 MiB.
53,196 compressed → 1,048,576 restored bytes; compressed / original: 5.073%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 109.1 | 106.6–112.4 | 9,164.10 | 1.000× |
| mzstd | 119.8 | 115.6–124.3 | 8,348.54 | 0.911× |
alice29
Alice in Wonderland text from the Canterbury corpus (testdata/alice29.txt).
53,111 compressed → 152,089 restored bytes; compressed / original: 34.921%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 89.48 | 88.58–90.67 | 1,621.05 | 1.000× |
| mzstd | 107.8 | 102.8–113 | 1,345.83 | 0.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.
| Decoder | Median speed / C |
|---|---|
| zstd C | 1.000× |
| mzstd | 1.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.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 2.227 | 2.081–2.408 | — | 1.000× |
| mzstd | 0.04064 | 0.03921–0.04226 | — | 54.790× |
tiny-text
44-byte quick-brown-fox sentence.
53 compressed → 44 restored bytes; compressed / original: 120.455%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 2.03 | 1.988–2.079 | 20.67 | 1.000× |
| mzstd | 0.04842 | 0.04777–0.04923 | 866.66 | 41.929× |
random-64k
64 KiB prefix of the deterministic pseudorandom corpus.
65,546 compressed → 65,536 restored bytes; compressed / original: 100.015%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 2.827 | 2.805–2.856 | 22,104.81 | 1.000× |
| mzstd | 0.9158 | 0.9033–0.9296 | 68,247.69 | 3.087× |
repeated-1m
1 MiB of repeated a bytes.
49 compressed → 1,048,576 restored bytes; compressed / original: 0.005%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 14.75 | 14.29–15.25 | 67,815.92 | 1.000× |
| mzstd | 9.538 | 9.33–9.767 | 104,840.92 | 1.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%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 6.765 | 6.434–7.107 | 9,238.26 | 1.000× |
| mzstd | 4.615 | 4.361–4.891 | 13,543.22 | 1.466× |
random-1m
1 MiB of deterministic xorshift64 pseudorandom bytes.
1,048,609 compressed → 1,048,576 restored bytes; compressed / original: 100.003%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 19.66 | 19.34–20.02 | 50,855.59 | 1.000× |
| mzstd | 17.22 | 16.99–17.5 | 58,087.90 | 1.142× |
text-1m
Alice text repeated cyclically to 1 MiB.
52,804 compressed → 1,048,576 restored bytes; compressed / original: 5.036%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 107.5 | 106.4–109 | 9,303.37 | 1.000× |
| mzstd | 110.2 | 109.6–111 | 9,070.88 | 0.975× |
alice29
Alice in Wonderland text from the Canterbury corpus (testdata/alice29.txt).
53,195 compressed → 152,089 restored bytes; compressed / original: 34.976%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 101.4 | 98.18–105.3 | 1,430.87 | 1.000× |
| mzstd | 108.1 | 104.6–112.2 | 1,342.04 | 0.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.
| Decoder | Median speed / C |
|---|---|
| zstd C | 1.000× |
| mzstd | 1.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.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 2.078 | 2.031–2.133 | — | 1.000× |
| mzstd | 0.04325 | 0.04147–0.04517 | — | 48.051× |
tiny-text
44-byte quick-brown-fox sentence.
53 compressed → 44 restored bytes; compressed / original: 120.455%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 2.075 | 2.011–2.147 | 20.22 | 1.000× |
| mzstd | 0.05349 | 0.05136–0.05585 | 784.47 | 38.794× |
random-64k
64 KiB prefix of the deterministic pseudorandom corpus.
65,546 compressed → 65,536 restored bytes; compressed / original: 100.015%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 2.901 | 2.843–2.968 | 21,543.97 | 1.000× |
| mzstd | 0.9346 | 0.9174–0.9534 | 66,876.46 | 3.104× |
repeated-1m
1 MiB of repeated a bytes.
49 compressed → 1,048,576 restored bytes; compressed / original: 0.005%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 14.04 | 13.87–14.24 | 71,237.71 | 1.000× |
| mzstd | 9.376 | 9.233–9.539 | 106,654.63 | 1.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%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 6.11 | 5.908–6.341 | 10,228.92 | 1.000× |
| mzstd | 4.772 | 4.518–5.039 | 13,097.71 | 1.280× |
random-1m
1 MiB of deterministic xorshift64 pseudorandom bytes.
1,048,609 compressed → 1,048,576 restored bytes; compressed / original: 100.003%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 19.29 | 19.17–19.43 | 51,841.81 | 1.000× |
| mzstd | 17.44 | 17.22–17.67 | 57,335.36 | 1.106× |
text-1m
Alice text repeated cyclically to 1 MiB.
52,393 compressed → 1,048,576 restored bytes; compressed / original: 4.997%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 104.1 | 102.5–106.3 | 9,603.98 | 1.000× |
| mzstd | 107.7 | 106.5–109.4 | 9,286.35 | 0.967× |
alice29
Alice in Wonderland text from the Canterbury corpus (testdata/alice29.txt).
52,710 compressed → 152,089 restored bytes; compressed / original: 34.657%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 96.94 | 94.11–100.3 | 1,496.24 | 1.000× |
| mzstd | 102.5 | 99.67–106 | 1,415.19 | 0.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.
| Decoder | Median speed / C |
|---|---|
| zstd C | 1.000× |
| mzstd | 1.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.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 2.173 | 2.11–2.241 | — | 1.000× |
| mzstd | 0.03802 | 0.03754–0.03864 | — | 57.159× |
tiny-text
44-byte quick-brown-fox sentence.
53 compressed → 44 restored bytes; compressed / original: 120.455%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 1.94 | 1.93–1.952 | 21.63 | 1.000× |
| mzstd | 0.0493 | 0.04835–0.05042 | 851.23 | 39.354× |
random-64k
64 KiB prefix of the deterministic pseudorandom corpus.
65,546 compressed → 65,536 restored bytes; compressed / original: 100.015%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 2.917 | 2.863–2.978 | 21,422.49 | 1.000× |
| mzstd | 0.8922 | 0.8851–0.9003 | 70,052.53 | 3.270× |
repeated-1m
1 MiB of repeated a bytes.
49 compressed → 1,048,576 restored bytes; compressed / original: 0.005%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 14.82 | 14.4–15.29 | 67,488.68 | 1.000× |
| mzstd | 9.436 | 9.276–9.616 | 105,978.97 | 1.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%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 5.852 | 5.726–6.013 | 10,679.26 | 1.000× |
| mzstd | 4.503 | 4.297–4.727 | 13,879.75 | 1.300× |
random-1m
1 MiB of deterministic xorshift64 pseudorandom bytes.
1,048,609 compressed → 1,048,576 restored bytes; compressed / original: 100.003%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 19.63 | 19.43–19.86 | 50,930.21 | 1.000× |
| mzstd | 17.03 | 16.9–17.17 | 58,706.28 | 1.153× |
alice29
Alice in Wonderland text from the Canterbury corpus (testdata/alice29.txt).
52,188 compressed → 152,089 restored bytes; compressed / original: 34.314%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 91.9 | 90.15–94.09 | 1,578.31 | 1.000× |
| mzstd | 99.87 | 97.45–102.8 | 1,452.29 | 0.920× |
text-1m
Alice text repeated cyclically to 1 MiB.
52,381 compressed → 1,048,576 restored bytes; compressed / original: 4.995%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 100.8 | 100.4–101.2 | 9,923.64 | 1.000× |
| mzstd | 112.1 | 108.2–116.9 | 8,919.30 | 0.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.
| Decoder | Median speed / C |
|---|---|
| zstd C | 1.000× |
| mzstd | 1.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.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 2.263 | 2.194–2.331 | — | 1.000× |
| mzstd | 0.04146 | 0.03992–0.04314 | — | 54.572× |
tiny-text
44-byte quick-brown-fox sentence.
53 compressed → 44 restored bytes; compressed / original: 120.455%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 1.987 | 1.952–2.028 | 21.12 | 1.000× |
| mzstd | 0.04974 | 0.04863–0.05104 | 843.65 | 39.944× |
random-64k
64 KiB prefix of the deterministic pseudorandom corpus.
65,546 compressed → 65,536 restored bytes; compressed / original: 100.015%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 2.963 | 2.886–3.051 | 21,094.51 | 1.000× |
| mzstd | 0.9226 | 0.9078–0.9391 | 67,740.34 | 3.211× |
repeated-1m
1 MiB of repeated a bytes.
49 compressed → 1,048,576 restored bytes; compressed / original: 0.005%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 14.79 | 14.45–15.18 | 67,596.18 | 1.000× |
| mzstd | 9.896 | 9.668–10.14 | 101,052.39 | 1.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%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 5.822 | 5.748–5.911 | 10,735.27 | 1.000× |
| mzstd | 4.113 | 4.01–4.234 | 15,195.03 | 1.415× |
random-1m
1 MiB of deterministic xorshift64 pseudorandom bytes.
1,048,609 compressed → 1,048,576 restored bytes; compressed / original: 100.003%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 19.78 | 19.53–20.05 | 50,554.66 | 1.000× |
| mzstd | 17.04 | 16.86–17.23 | 58,699.28 | 1.161× |
text-1m
Alice text repeated cyclically to 1 MiB.
52,148 compressed → 1,048,576 restored bytes; compressed / original: 4.973%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 115.7 | 111.8–119.7 | 8,643.85 | 1.000× |
| mzstd | 125 | 119.4–130.8 | 7,999.66 | 0.925× |
alice29
Alice in Wonderland text from the Canterbury corpus (testdata/alice29.txt).
51,894 compressed → 152,089 restored bytes; compressed / original: 34.121%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 90.51 | 88.86–92.74 | 1,602.43 | 1.000× |
| mzstd | 100.8 | 96.89–105.5 | 1,439.14 | 0.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.
| Decoder | Median speed / C |
|---|---|
| zstd C | 1.000× |
| mzstd | 1.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.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 2.07 | 2.023–2.123 | — | 1.000× |
| mzstd | 0.04046 | 0.03937–0.04168 | — | 51.172× |
tiny-text
44-byte quick-brown-fox sentence.
53 compressed → 44 restored bytes; compressed / original: 120.455%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 2.105 | 2.049–2.165 | 19.93 | 1.000× |
| mzstd | 0.05286 | 0.05085–0.05507 | 793.86 | 39.825× |
random-64k
64 KiB prefix of the deterministic pseudorandom corpus.
65,546 compressed → 65,536 restored bytes; compressed / original: 100.015%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 2.937 | 2.867–3.018 | 21,283.04 | 1.000× |
| mzstd | 0.9627 | 0.9522–0.9741 | 64,923.89 | 3.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%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 7.224 | 6.885–7.562 | 8,651.43 | 1.000× |
| mzstd | 4.538 | 4.334–4.761 | 13,772.72 | 1.592× |
repeated-1m
1 MiB of repeated a bytes.
49 compressed → 1,048,576 restored bytes; compressed / original: 0.005%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 15.26 | 14.84–15.71 | 65,533.34 | 1.000× |
| mzstd | 10.23 | 9.961–10.52 | 97,718.67 | 1.491× |
random-1m
1 MiB of deterministic xorshift64 pseudorandom bytes.
1,048,609 compressed → 1,048,576 restored bytes; compressed / original: 100.003%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 19.98 | 19.78–20.2 | 50,044.73 | 1.000× |
| mzstd | 17.96 | 17.76–18.15 | 55,690.81 | 1.113× |
text-1m
Alice text repeated cyclically to 1 MiB.
52,034 compressed → 1,048,576 restored bytes; compressed / original: 4.962%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 111.5 | 107.3–116 | 8,965.69 | 1.000× |
| mzstd | 108 | 104.9–111.9 | 9,256.13 | 1.032× |
alice29
Alice in Wonderland text from the Canterbury corpus (testdata/alice29.txt).
51,491 compressed → 152,089 restored bytes; compressed / original: 33.856%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 92.44 | 89.81–95.56 | 1,569.03 | 1.000× |
| mzstd | 99.8 | 96.2–104 | 1,453.36 | 0.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.
| Decoder | Median speed / C |
|---|---|
| zstd C | 1.000× |
| mzstd | 1.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.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 2.225 | 2.168–2.285 | — | 1.000× |
| mzstd | 0.04463 | 0.04284–0.04646 | — | 49.850× |
tiny-text
44-byte quick-brown-fox sentence.
53 compressed → 44 restored bytes; compressed / original: 120.455%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 2.031 | 1.978–2.091 | 20.66 | 1.000× |
| mzstd | 0.05169 | 0.04981–0.05385 | 811.73 | 39.293× |
random-64k
64 KiB prefix of the deterministic pseudorandom corpus.
65,546 compressed → 65,536 restored bytes; compressed / original: 100.015%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 2.984 | 2.909–3.067 | 20,948.48 | 1.000× |
| mzstd | 0.9015 | 0.8957–0.9084 | 69,327.13 | 3.309× |
repeated-1m
1 MiB of repeated a bytes.
49 compressed → 1,048,576 restored bytes; compressed / original: 0.005%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 17.54 | 17.09–17.97 | 57,016.25 | 1.000× |
| mzstd | 10.56 | 10.33–10.81 | 94,653.90 | 1.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%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 6.587 | 6.267–6.928 | 9,488.05 | 1.000× |
| mzstd | 4.33 | 4.168–4.508 | 14,435.58 | 1.521× |
random-1m
1 MiB of deterministic xorshift64 pseudorandom bytes.
1,048,609 compressed → 1,048,576 restored bytes; compressed / original: 100.003%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 19.92 | 19.66–20.2 | 50,200.53 | 1.000× |
| mzstd | 17.16 | 17.03–17.3 | 58,286.72 | 1.161× |
text-1m
Alice text repeated cyclically to 1 MiB.
52,034 compressed → 1,048,576 restored bytes; compressed / original: 4.962%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 100.6 | 99.23–102.4 | 9,937.21 | 1.000× |
| mzstd | 104.1 | 102.9–105.6 | 9,609.88 | 0.967× |
alice29
Alice in Wonderland text from the Canterbury corpus (testdata/alice29.txt).
51,492 compressed → 152,089 restored bytes; compressed / original: 33.856%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 95.56 | 91.75–99.98 | 1,517.85 | 1.000× |
| mzstd | 100.5 | 97.48–104 | 1,442.87 | 0.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.
| Decoder | Median speed / C |
|---|---|
| zstd C | 1.000× |
| mzstd | 1.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.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 2.259 | 2.196–2.323 | — | 1.000× |
| mzstd | 0.04026 | 0.03867–0.04205 | — | 56.115× |
tiny-text
44-byte quick-brown-fox sentence.
53 compressed → 44 restored bytes; compressed / original: 120.455%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 1.965 | 1.938–1.996 | 21.36 | 1.000× |
| mzstd | 0.0536 | 0.05139–0.056 | 782.84 | 36.654× |
random-64k
64 KiB prefix of the deterministic pseudorandom corpus.
65,546 compressed → 65,536 restored bytes; compressed / original: 100.015%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 2.92 | 2.864–2.983 | 21,405.60 | 1.000× |
| mzstd | 0.91 | 0.8992–0.9217 | 68,684.17 | 3.209× |
repeated-1m
1 MiB of repeated a bytes.
50 compressed → 1,048,576 restored bytes; compressed / original: 0.005%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 15.5 | 15.08–15.95 | 64,516.25 | 1.000× |
| mzstd | 9.935 | 9.664–10.23 | 100,654.74 | 1.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%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 6.723 | 6.445–7.023 | 9,296.29 | 1.000× |
| mzstd | 4.723 | 4.488–4.982 | 13,232.54 | 1.423× |
random-1m
1 MiB of deterministic xorshift64 pseudorandom bytes.
1,048,609 compressed → 1,048,576 restored bytes; compressed / original: 100.003%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 19.63 | 19.34–19.93 | 50,950.51 | 1.000× |
| mzstd | 16.93 | 16.69–17.2 | 59,062.32 | 1.159× |
text-1m
Alice text repeated cyclically to 1 MiB.
51,847 compressed → 1,048,576 restored bytes; compressed / original: 4.945%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 105 | 101.6–109.1 | 9,522.54 | 1.000× |
| mzstd | 104.8 | 103–107.4 | 9,541.63 | 1.002× |
alice29
Alice in Wonderland text from the Canterbury corpus (testdata/alice29.txt).
49,705 compressed → 152,089 restored bytes; compressed / original: 32.682%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 104.1 | 100.4–108.5 | 1,392.90 | 1.000× |
| mzstd | 105.7 | 104.1–107.7 | 1,372.45 | 0.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.
| Decoder | Median speed / C |
|---|---|
| zstd C | 1.000× |
| mzstd | 1.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.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 2.005 | 1.957–2.06 | — | 1.000× |
| mzstd | 0.03789 | 0.03716–0.03877 | — | 52.916× |
tiny-text
44-byte quick-brown-fox sentence.
53 compressed → 44 restored bytes; compressed / original: 120.455%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 2.067 | 2.015–2.128 | 20.30 | 1.000× |
| mzstd | 0.05099 | 0.04988–0.05224 | 822.88 | 40.541× |
random-64k
64 KiB prefix of the deterministic pseudorandom corpus.
65,546 compressed → 65,536 restored bytes; compressed / original: 100.015%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 2.806 | 2.779–2.839 | 22,274.56 | 1.000× |
| mzstd | 0.9014 | 0.8916–0.912 | 69,338.54 | 3.113× |
repeated-1m
1 MiB of repeated a bytes.
50 compressed → 1,048,576 restored bytes; compressed / original: 0.005%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 15.22 | 14.81–15.65 | 65,718.73 | 1.000× |
| mzstd | 9.714 | 9.462–9.981 | 102,943.34 | 1.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%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 6.539 | 6.32–6.775 | 9,558.22 | 1.000× |
| mzstd | 4.577 | 4.386–4.797 | 13,653.87 | 1.428× |
random-1m
1 MiB of deterministic xorshift64 pseudorandom bytes.
1,048,609 compressed → 1,048,576 restored bytes; compressed / original: 100.003%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 18.89 | 18.82–18.97 | 52,931.64 | 1.000× |
| mzstd | 17.34 | 17.12–17.57 | 57,681.63 | 1.090× |
alice29
Alice in Wonderland text from the Canterbury corpus (testdata/alice29.txt).
49,635 compressed → 152,089 restored bytes; compressed / original: 32.635%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 99.36 | 98.5–100.4 | 1,459.83 | 1.000× |
| mzstd | 106.2 | 104.9–107.6 | 1,366.31 | 0.936× |
text-1m
Alice text repeated cyclically to 1 MiB.
51,818 compressed → 1,048,576 restored bytes; compressed / original: 4.942%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 100.5 | 98.49–103.5 | 9,946.04 | 1.000× |
| mzstd | 108 | 104.8–111.9 | 9,259.96 | 0.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.
| Decoder | Median speed / C |
|---|---|
| zstd C | 1.000× |
| mzstd | 1.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.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 2.069 | 2.006–2.138 | — | 1.000× |
| mzstd | 0.03856 | 0.03728–0.04011 | — | 53.656× |
tiny-text
44-byte quick-brown-fox sentence.
53 compressed → 44 restored bytes; compressed / original: 120.455%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 2.127 | 2.065–2.198 | 19.73 | 1.000× |
| mzstd | 0.0511 | 0.04961–0.05283 | 821.16 | 41.629× |
random-64k
64 KiB prefix of the deterministic pseudorandom corpus.
65,546 compressed → 65,536 restored bytes; compressed / original: 100.015%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 2.92 | 2.86–2.988 | 21,403.45 | 1.000× |
| mzstd | 0.8927 | 0.8851–0.9017 | 70,012.21 | 3.271× |
repeated-1m
1 MiB of repeated a bytes.
50 compressed → 1,048,576 restored bytes; compressed / original: 0.005%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 15.65 | 15.12–16.2 | 63,903.51 | 1.000× |
| mzstd | 9.733 | 9.499–9.983 | 102,744.88 | 1.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%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 7.171 | 6.843–7.507 | 8,715.72 | 1.000× |
| mzstd | 4.663 | 4.462–4.887 | 13,402.88 | 1.538× |
random-1m
1 MiB of deterministic xorshift64 pseudorandom bytes.
1,048,609 compressed → 1,048,576 restored bytes; compressed / original: 100.003%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 19.67 | 19.36–20.01 | 50,836.07 | 1.000× |
| mzstd | 16.59 | 16.52–16.68 | 60,261.62 | 1.185× |
text-1m
Alice text repeated cyclically to 1 MiB.
51,817 compressed → 1,048,576 restored bytes; compressed / original: 4.942%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 104.1 | 101.3–107.2 | 9,606.47 | 1.000× |
| mzstd | 104.1 | 102.9–105.6 | 9,609.84 | 1.000× |
alice29
Alice in Wonderland text from the Canterbury corpus (testdata/alice29.txt).
49,610 compressed → 152,089 restored bytes; compressed / original: 32.619%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 103.7 | 100.3–108 | 1,398.45 | 1.000× |
| mzstd | 113.4 | 108.8–118.8 | 1,279.28 | 0.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.
| Decoder | Median speed / C |
|---|---|
| zstd C | 1.000× |
| mzstd | 1.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.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 2.065 | 2.001–2.138 | — | 1.000× |
| mzstd | 0.03809 | 0.03724–0.03915 | — | 54.227× |
tiny-text
44-byte quick-brown-fox sentence.
53 compressed → 44 restored bytes; compressed / original: 120.455%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 2.217 | 2.151–2.283 | 18.93 | 1.000× |
| mzstd | 0.05089 | 0.04957–0.05241 | 824.62 | 43.570× |
random-64k
64 KiB prefix of the deterministic pseudorandom corpus.
65,546 compressed → 65,536 restored bytes; compressed / original: 100.015%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 2.92 | 2.865–2.98 | 21,405.35 | 1.000× |
| mzstd | 0.9004 | 0.894–0.9077 | 69,412.17 | 3.243× |
repeated-1m
1 MiB of repeated a bytes.
49 compressed → 1,048,576 restored bytes; compressed / original: 0.005%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 14.21 | 13.97–14.52 | 70,356.23 | 1.000× |
| mzstd | 9.748 | 9.483–10.03 | 102,583.68 | 1.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%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 6.281 | 6.143–6.438 | 9,950.30 | 1.000× |
| mzstd | 4.336 | 4.231–4.474 | 14,413.16 | 1.449× |
random-1m
1 MiB of deterministic xorshift64 pseudorandom bytes.
1,048,609 compressed → 1,048,576 restored bytes; compressed / original: 100.003%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 19.28 | 19.18–19.38 | 51,870.23 | 1.000× |
| mzstd | 17.27 | 17.07–17.49 | 57,900.78 | 1.116× |
text-1m
Alice text repeated cyclically to 1 MiB.
49,924 compressed → 1,048,576 restored bytes; compressed / original: 4.761%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 117.6 | 113.1–122.8 | 8,504.21 | 1.000× |
| mzstd | 116.9 | 115–119.1 | 8,553.42 | 1.006× |
alice29
Alice in Wonderland text from the Canterbury corpus (testdata/alice29.txt).
49,355 compressed → 152,089 restored bytes; compressed / original: 32.451%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 113.4 | 108.2–119 | 1,279.18 | 1.000× |
| mzstd | 117.3 | 112.2–123 | 1,236.96 | 0.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.
| Decoder | Median speed / C |
|---|---|
| zstd C | 1.000× |
| mzstd | 1.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.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 2.027 | 1.975–2.085 | — | 1.000× |
| mzstd | 0.03771 | 0.03687–0.03869 | — | 53.745× |
tiny-text
44-byte quick-brown-fox sentence.
53 compressed → 44 restored bytes; compressed / original: 120.455%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 2.075 | 2.013–2.152 | 20.22 | 1.000× |
| mzstd | 0.0517 | 0.05–0.05362 | 811.60 | 40.139× |
random-64k
64 KiB prefix of the deterministic pseudorandom corpus.
65,546 compressed → 65,536 restored bytes; compressed / original: 100.015%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 2.834 | 2.798–2.878 | 22,050.01 | 1.000× |
| mzstd | 0.8997 | 0.8917–0.9091 | 69,463.76 | 3.150× |
repeated-1m
1 MiB of repeated a bytes.
49 compressed → 1,048,576 restored bytes; compressed / original: 0.005%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 14.86 | 14.43–15.33 | 67,299.09 | 1.000× |
| mzstd | 9.948 | 9.674–10.23 | 100,520.54 | 1.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%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 6.498 | 6.301–6.713 | 9,617.73 | 1.000× |
| mzstd | 5.409 | 5.1–5.721 | 11,554.68 | 1.201× |
random-1m
1 MiB of deterministic xorshift64 pseudorandom bytes.
1,048,609 compressed → 1,048,576 restored bytes; compressed / original: 100.003%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 19.45 | 19.31–19.62 | 51,406.14 | 1.000× |
| mzstd | 17.05 | 16.91–17.2 | 58,665.51 | 1.141× |
text-1m
Alice text repeated cyclically to 1 MiB.
49,642 compressed → 1,048,576 restored bytes; compressed / original: 4.734%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 119 | 114.7–124 | 8,403.13 | 1.000× |
| mzstd | 126.9 | 122.8–131.6 | 7,882.11 | 0.938× |
alice29
Alice in Wonderland text from the Canterbury corpus (testdata/alice29.txt).
49,341 compressed → 152,089 restored bytes; compressed / original: 32.442%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 103.3 | 101.1–105.9 | 1,404.16 | 1.000× |
| mzstd | 127.2 | 118.6–136.4 | 1,140.26 | 0.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.
| Decoder | Median speed / C |
|---|---|
| zstd C | 1.000× |
| mzstd | 1.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.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 1.999 | 1.938–2.076 | — | 1.000× |
| mzstd | 0.0377 | 0.03713–0.03837 | — | 53.021× |
tiny-text
44-byte quick-brown-fox sentence.
53 compressed → 44 restored bytes; compressed / original: 120.455%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 1.97 | 1.954–1.989 | 21.30 | 1.000× |
| mzstd | 0.05383 | 0.05149–0.05635 | 779.55 | 36.605× |
random-64k
64 KiB prefix of the deterministic pseudorandom corpus.
65,546 compressed → 65,536 restored bytes; compressed / original: 100.015%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 2.89 | 2.847–2.94 | 21,627.46 | 1.000× |
| mzstd | 0.8959 | 0.8902–0.9037 | 69,762.49 | 3.226× |
repeated-1m
1 MiB of repeated a bytes.
49 compressed → 1,048,576 restored bytes; compressed / original: 0.005%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 14.61 | 14.23–15.05 | 68,434.72 | 1.000× |
| mzstd | 9.383 | 9.221–9.561 | 106,577.92 | 1.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%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 6.789 | 6.502–7.094 | 9,206.00 | 1.000× |
| mzstd | 5.406 | 5.156–5.657 | 11,561.58 | 1.256× |
random-1m
1 MiB of deterministic xorshift64 pseudorandom bytes.
1,048,609 compressed → 1,048,576 restored bytes; compressed / original: 100.003%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 19.49 | 19.35–19.66 | 51,300.31 | 1.000× |
| mzstd | 17.52 | 17.33–17.72 | 57,082.29 | 1.113× |
alice29
Alice in Wonderland text from the Canterbury corpus (testdata/alice29.txt).
49,215 compressed → 152,089 restored bytes; compressed / original: 32.359%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 110.7 | 105.3–117 | 1,309.84 | 1.000× |
| mzstd | 115.3 | 110.9–120.4 | 1,257.68 | 0.960× |
text-1m
Alice text repeated cyclically to 1 MiB.
49,433 compressed → 1,048,576 restored bytes; compressed / original: 4.714%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 121.5 | 116.8–127.1 | 8,229.54 | 1.000× |
| mzstd | 129.8 | 124.7–135.8 | 7,702.41 | 0.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.
| Decoder | Median speed / C |
|---|---|
| zstd C | 1.000× |
| mzstd | 1.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.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 1.955 | 1.94–1.971 | — | 1.000× |
| mzstd | 0.03756 | 0.03678–0.0386 | — | 52.045× |
tiny-text
44-byte quick-brown-fox sentence.
53 compressed → 44 restored bytes; compressed / original: 120.455%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 2.05 | 1.995–2.114 | 20.47 | 1.000× |
| mzstd | 0.04909 | 0.04843–0.04984 | 854.77 | 41.754× |
random-64k
64 KiB prefix of the deterministic pseudorandom corpus.
65,546 compressed → 65,536 restored bytes; compressed / original: 100.015%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 2.82 | 2.792–2.853 | 22,163.93 | 1.000× |
| mzstd | 0.9429 | 0.9265–0.96 | 66,283.21 | 2.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%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 7.462 | 7.176–7.748 | 8,375.54 | 1.000× |
| mzstd | 5.121 | 4.899–5.347 | 12,205.56 | 1.457× |
repeated-1m
1 MiB of repeated a bytes.
49 compressed → 1,048,576 restored bytes; compressed / original: 0.005%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 14.14 | 13.92–14.39 | 70,738.81 | 1.000× |
| mzstd | 9.71 | 9.432–10.01 | 102,982.42 | 1.456× |
random-1m
1 MiB of deterministic xorshift64 pseudorandom bytes.
1,048,609 compressed → 1,048,576 restored bytes; compressed / original: 100.003%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 19.8 | 19.58–20.06 | 50,492.79 | 1.000× |
| mzstd | 17.32 | 17.18–17.47 | 57,741.15 | 1.144× |
alice29
Alice in Wonderland text from the Canterbury corpus (testdata/alice29.txt).
49,211 compressed → 152,089 restored bytes; compressed / original: 32.357%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 116.4 | 110.5–122.9 | 1,245.67 | 1.000× |
| mzstd | 121.1 | 114.6–128.5 | 1,197.33 | 0.961× |
text-1m
Alice text repeated cyclically to 1 MiB.
49,285 compressed → 1,048,576 restored bytes; compressed / original: 4.700%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 117.2 | 114.9–119.9 | 8,533.02 | 1.000× |
| mzstd | 134.8 | 127.3–143.1 | 7,419.53 | 0.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.
| Decoder | Median speed / C |
|---|---|
| zstd C | 1.000× |
| mzstd | 1.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.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 2.05 | 1.998–2.109 | — | 1.000× |
| mzstd | 0.03818 | 0.03755–0.03893 | — | 53.690× |
tiny-text
44-byte quick-brown-fox sentence.
53 compressed → 44 restored bytes; compressed / original: 120.455%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 2.014 | 1.959–2.088 | 20.83 | 1.000× |
| mzstd | 0.04856 | 0.04812–0.04904 | 864.17 | 41.478× |
random-64k
64 KiB prefix of the deterministic pseudorandom corpus.
65,546 compressed → 65,536 restored bytes; compressed / original: 100.015%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 3.002 | 2.923–3.087 | 20,816.62 | 1.000× |
| mzstd | 0.9047 | 0.896–0.9144 | 69,086.74 | 3.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%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 7.057 | 6.756–7.37 | 8,855.97 | 1.000× |
| mzstd | 4.523 | 4.359–4.717 | 13,819.39 | 1.560× |
repeated-1m
1 MiB of repeated a bytes.
49 compressed → 1,048,576 restored bytes; compressed / original: 0.005%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 14.33 | 13.99–14.72 | 69,785.38 | 1.000× |
| mzstd | 9.442 | 9.244–9.663 | 105,910.67 | 1.518× |
random-1m
1 MiB of deterministic xorshift64 pseudorandom bytes.
1,048,609 compressed → 1,048,576 restored bytes; compressed / original: 100.003%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 20.02 | 19.77–20.27 | 49,954.89 | 1.000× |
| mzstd | 17.69 | 17.44–17.95 | 56,535.56 | 1.132× |
alice29
Alice in Wonderland text from the Canterbury corpus (testdata/alice29.txt).
49,211 compressed → 152,089 restored bytes; compressed / original: 32.357%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 111.4 | 105.8–117.6 | 1,302.35 | 1.000× |
| mzstd | 110.7 | 107.6–114.4 | 1,310.79 | 1.006× |
text-1m
Alice text repeated cyclically to 1 MiB.
49,285 compressed → 1,048,576 restored bytes; compressed / original: 4.700%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 122.5 | 118.7–127 | 8,165.86 | 1.000× |
| mzstd | 131 | 126.4–136.2 | 7,635.64 | 0.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.
| Decoder | Median speed / C |
|---|---|
| zstd C | 1.000× |
| mzstd | 1.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.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 1.977 | 1.94–2.021 | — | 1.000× |
| mzstd | 0.03812 | 0.03739–0.03898 | — | 51.872× |
tiny-text
44-byte quick-brown-fox sentence.
53 compressed → 44 restored bytes; compressed / original: 120.455%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 1.983 | 1.95–2.024 | 21.16 | 1.000× |
| mzstd | 0.04909 | 0.04847–0.04979 | 854.80 | 40.394× |
random-64k
64 KiB prefix of the deterministic pseudorandom corpus.
65,546 compressed → 65,536 restored bytes; compressed / original: 100.015%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 2.884 | 2.836–2.938 | 21,673.94 | 1.000× |
| mzstd | 0.9423 | 0.9219–0.9645 | 66,325.13 | 3.060× |
repeated-1m
1 MiB of repeated a bytes.
49 compressed → 1,048,576 restored bytes; compressed / original: 0.005%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 15.38 | 14.85–15.94 | 65,033.63 | 1.000× |
| mzstd | 9.384 | 9.229–9.557 | 106,562.30 | 1.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%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 7.123 | 6.798–7.463 | 8,773.99 | 1.000× |
| mzstd | 5.266 | 5.015–5.528 | 11,869.30 | 1.353× |
random-1m
1 MiB of deterministic xorshift64 pseudorandom bytes.
1,048,609 compressed → 1,048,576 restored bytes; compressed / original: 100.003%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 19.46 | 19.34–19.59 | 51,397.58 | 1.000× |
| mzstd | 17.23 | 17.12–17.36 | 58,023.29 | 1.129× |
text-1m
Alice text repeated cyclically to 1 MiB.
49,285 compressed → 1,048,576 restored bytes; compressed / original: 4.700%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 124.1 | 120.3–128.3 | 8,061.02 | 1.000× |
| mzstd | 128.3 | 124.6–132.8 | 7,791.43 | 0.967× |
alice29
Alice in Wonderland text from the Canterbury corpus (testdata/alice29.txt).
49,211 compressed → 152,089 restored bytes; compressed / original: 32.357%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 116 | 111.4–120.8 | 1,250.53 | 1.000× |
| mzstd | 122.7 | 116.6–129.2 | 1,182.39 | 0.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.
| Decoder | Median speed / C |
|---|---|
| zstd C | 1.000× |
| mzstd | 1.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.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 2.029 | 1.978–2.091 | — | 1.000× |
| mzstd | 0.03755 | 0.03692–0.03833 | — | 54.034× |
tiny-text
44-byte quick-brown-fox sentence.
53 compressed → 44 restored bytes; compressed / original: 120.455%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 2.015 | 1.961–2.082 | 20.83 | 1.000× |
| mzstd | 0.05361 | 0.05132–0.05609 | 782.74 | 37.584× |
random-64k
64 KiB prefix of the deterministic pseudorandom corpus.
65,546 compressed → 65,536 restored bytes; compressed / original: 100.015%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 2.824 | 2.799–2.852 | 22,133.47 | 1.000× |
| mzstd | 0.9283 | 0.9107–0.9474 | 67,327.79 | 3.042× |
repeated-1m
1 MiB of repeated a bytes.
49 compressed → 1,048,576 restored bytes; compressed / original: 0.005%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 15.85 | 15.34–16.37 | 63,104.31 | 1.000× |
| mzstd | 9.345 | 9.181–9.54 | 107,009.86 | 1.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%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 6.929 | 6.669–7.196 | 9,020.53 | 1.000× |
| mzstd | 5.974 | 5.758–6.183 | 10,461.55 | 1.160× |
random-1m
1 MiB of deterministic xorshift64 pseudorandom bytes.
1,048,609 compressed → 1,048,576 restored bytes; compressed / original: 100.003%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 20 | 19.79–20.22 | 50,006.48 | 1.000× |
| mzstd | 17.4 | 17.28–17.55 | 57,460.35 | 1.149× |
alice29
Alice in Wonderland text from the Canterbury corpus (testdata/alice29.txt).
49,211 compressed → 152,089 restored bytes; compressed / original: 32.357%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 112.7 | 108.4–117.3 | 1,287.22 | 1.000× |
| mzstd | 115.1 | 110.2–120.9 | 1,259.63 | 0.979× |
text-1m
Alice text repeated cyclically to 1 MiB.
49,285 compressed → 1,048,576 restored bytes; compressed / original: 4.700%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| zstd C | 120.1 | 117.3–123.3 | 8,323.89 | 1.000× |
| mzstd | 126 | 123.3–129.3 | 7,933.85 | 0.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.
| Dataset | mzstd speed / C ↑ | 2026-10-30 | Range across levels |
|---|---|---|---|
| empty | 53.4× | 48.9× | 47.0–57.2× |
| tiny-text | 39.8× | 38.1× | 36.4–43.6× |
| alice29 | 0.948× | 0.944× | 0.812–1.023× |
| text-1m | 0.937× | 0.965× | 0.842–1.032× |
| binary-64k | 1.453× | 1.440× | 1.148–1.835× |
| random-64k | 3.218× | 3.126× | 2.991–3.631× |
| random-1m | 1.142× | 1.137× | 1.090–1.185× |
| repeated-1m | 1.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:
| Dataset | mzstd speed / C ↑ | 2026-10-30 | Range across levels |
|---|---|---|---|
| empty | 8.410× | 8.267× | 5.09–11.44× |
| tiny-text | 3.531× | 3.618× | 1.35–9.11× |
| alice29 | 1.008× | 1.019× | 0.84–1.29× |
| text-1m | 1.088× | 1.085× | 0.94–1.33× |
| binary-64k | 1.241× | 1.230× | 1.08–1.80× |
| random-64k | 1.151× | 1.100× | 0.92–1.64× |
| random-1m | 1.111× | 1.218× | 0.85–1.88× |
| repeated-1m | 2.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:
| Dataset | Level | mzstd, MiB/s ↑ | C, MiB/s ↑ | Speed / C ↑ | Frame bytes, both |
|---|---|---|---|---|---|
| alice29 | -5 | 487.1 | 439.1 | 1.109× | 85,658 |
| alice29 | 1 | 392.6 | 395.3 | 0.993× | 59,657 |
| alice29 | 3 | 259.4 | 256.5 | 1.011× | 56,970 |
| alice29 | 9 | 54.6 | 54.3 | 1.006× | 52,188 |
| alice29 | 19 | 5.4 | 4.1 | 1.295× | 49,211 |
| text-1m | -5 | 3,415 | 3,130 | 1.091× | 96,657 |
| text-1m | 1 | 2,478 | 2,148 | 1.153× | 61,206 |
| text-1m | 3 | 1,590 | 1,388 | 1.145× | 55,878 |
| text-1m | 9 | 357.8 | 328.2 | 1.090× | 52,381 |
| text-1m | 19 | 37.6 | 34.5 | 1.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
| Setting | Recorded value |
|---|---|
| Host | AMD Ryzen 9 7950X, Linux KVM guest with two vCPUs, host shared with other tenants |
| Affinity | The encoder suite’s Criterion process on vCPU 1, then the decoder suite’s; nothing else ran in the guest |
| Rust | rustc 1.99.1 (LLVM 23.1.1); Cargo release defaults; runtime SIMD dispatch (AVX-512 here) |
| C compiler | GCC 15.2.0; cc crate defaults (-O3) |
| zstd | 1.5.7; submodule f8745da6ff1ad1e7bab384bd1f9d742439278e99; single-threaded, no legacy formats, no Huffman assembly |
| Compression | Level only, complete known input, no dictionary or explicit flush |
| API | Cold one-shot: ZSTD_createCCtx + ZSTD_compress2 into a ZSTD_compressBound buffer against Compressor::new(level).compress; construction, allocation, compression and disposal timed |
| Sampling | 50 samples; 3 s warmup; 6 s requested measurement per case; Criterion’s default 100,000 bootstrap resamples; 95% confidence intervals |
| Validation | All 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, itslz+entropy/mzstddirectory 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 defaultRUST_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 name | Compression | Decompress. | Compr. size | Ratio |
|---|---|---|---|---|
| zstd 1.5.7 –fast –5 | 893 MB/s | 3048 MB/s | 103023892 | 48.61 |
| mzstd 0.2.12 –fast –5 | 838 MB/s | 2828 MB/s | 103023892 | 48.61 |
| zstd 1.5.7 –fast –3 | 812 MB/s | 2867 MB/s | 94600592 | 44.63 |
| mzstd 0.2.12 –fast –3 | 763 MB/s | 2673 MB/s | 94600592 | 44.63 |
| zstd 1.5.7 –fast –1 | 720 MB/s | 2695 MB/s | 86915918 | 41.01 |
| mzstd 0.2.12 –fast –1 | 682 MB/s | 2504 MB/s | 86915918 | 41.01 |
| zstd 1.5.7 -1 | 658 MB/s | 2134 MB/s | 73191357 | 34.53 |
| mzstd 0.2.12 -1 | 626 MB/s | 1992 MB/s | 73191357 | 34.53 |
| zstd 1.5.7 -2 | 534 MB/s | 1966 MB/s | 69303994 | 32.70 |
| mzstd 0.2.12 -2 | 505 MB/s | 1834 MB/s | 69303994 | 32.70 |
| zstd 1.5.7 -3 | 370 MB/s | 1892 MB/s | 66132646 | 31.20 |
| mzstd 0.2.12 -3 | 374 MB/s | 1784 MB/s | 66132646 | 31.20 |
| zstd 1.5.7 -5 | 208 MB/s | 1858 MB/s | 62740793 | 29.60 |
| mzstd 0.2.12 -5 | 203 MB/s | 1769 MB/s | 62740793 | 29.60 |
| zstd 1.5.7 -8 | 107 MB/s | 2040 MB/s | 59701697 | 28.17 |
| mzstd 0.2.12 -8 | 103 MB/s | 1945 MB/s | 59701697 | 28.17 |
| zstd 1.5.7 -11 | 45.7 MB/s | 2082 MB/s | 57966112 | 27.35 |
| mzstd 0.2.12 -11 | 42.7 MB/s | 1982 MB/s | 57966112 | 27.35 |
| zstd 1.5.7 -15 | 8.41 MB/s | 2116 MB/s | 56872425 | 26.83 |
| mzstd 0.2.12 -15 | 9.04 MB/s | 2014 MB/s | 56872425 | 26.83 |
| zstd 1.5.7 -18 | 4.28 MB/s | 1858 MB/s | 53297922 | 25.15 |
| mzstd 0.2.12 -18 | 5.03 MB/s | 1745 MB/s | 53297922 | 25.15 |
| zstd 1.5.7 -22 | 2.13 MB/s | 1608 MB/s | 52283704 | 24.67 |
| mzstd 0.2.12 -22 | 2.27 MB/s | 1498 MB/s | 52283704 | 24.67 |
mzstd 0.2.11, fastest of three rounds
| Compressor name | Compression | Decompress. | Compr. size | Ratio |
|---|---|---|---|---|
| zstd 1.5.7 –fast –5 | 892 MB/s | 3036 MB/s | 103023892 | 48.61 |
| mzstd 0.2.11 –fast –5 | 827 MB/s | 2849 MB/s | 103023892 | 48.61 |
| zstd 1.5.7 –fast –3 | 810 MB/s | 2852 MB/s | 94600592 | 44.63 |
| mzstd 0.2.11 –fast –3 | 755 MB/s | 2652 MB/s | 94600592 | 44.63 |
| zstd 1.5.7 –fast –1 | 714 MB/s | 2692 MB/s | 86915918 | 41.01 |
| mzstd 0.2.11 –fast –1 | 665 MB/s | 2501 MB/s | 86915918 | 41.01 |
| zstd 1.5.7 -1 | 655 MB/s | 2122 MB/s | 73191357 | 34.53 |
| mzstd 0.2.11 -1 | 607 MB/s | 1874 MB/s | 73191357 | 34.53 |
| zstd 1.5.7 -2 | 540 MB/s | 1964 MB/s | 69303994 | 32.70 |
| mzstd 0.2.11 -2 | 500 MB/s | 1780 MB/s | 69303994 | 32.70 |
| zstd 1.5.7 -3 | 368 MB/s | 1879 MB/s | 66132646 | 31.20 |
| mzstd 0.2.11 -3 | 358 MB/s | 1737 MB/s | 66132646 | 31.20 |
| zstd 1.5.7 -5 | 209 MB/s | 1854 MB/s | 62740793 | 29.60 |
| mzstd 0.2.11 -5 | 201 MB/s | 1730 MB/s | 62740793 | 29.60 |
| zstd 1.5.7 -8 | 107 MB/s | 2023 MB/s | 59701697 | 28.17 |
| mzstd 0.2.11 -8 | 100 MB/s | 1894 MB/s | 59701697 | 28.17 |
| zstd 1.5.7 -11 | 43.1 MB/s | 2061 MB/s | 57966112 | 27.35 |
| mzstd 0.2.11 -11 | 43.6 MB/s | 1946 MB/s | 57966112 | 27.35 |
| zstd 1.5.7 -15 | 8.25 MB/s | 2125 MB/s | 56872425 | 26.83 |
| mzstd 0.2.11 -15 | 8.75 MB/s | 1969 MB/s | 56872425 | 26.83 |
| zstd 1.5.7 -18 | 3.96 MB/s | 1861 MB/s | 53297922 | 25.15 |
| mzstd 0.2.11 -18 | 4.98 MB/s | 1728 MB/s | 53297922 | 25.15 |
| zstd 1.5.7 -22 | 2.11 MB/s | 1619 MB/s | 52283704 | 24.67 |
| mzstd 0.2.11 -22 | 2.44 MB/s | 1473 MB/s | 52283704 | 24.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.
| Level | Compress 0.2.11 | Compress 0.2.12 | rounds 0.2.12 | Decompress 0.2.11 | Decompress 0.2.12 | rounds 0.2.12 |
|---|---|---|---|---|---|---|
| –fast=5 | 0.927 | 0.938 | 0.950 0.937 0.933 | 0.938 | 0.928 | 0.931 0.921 0.922 |
| –fast=3 | 0.932 | 0.940 | 0.943 0.938 0.949 | 0.930 | 0.932 | 0.943 0.922 0.932 |
| –fast=1 | 0.931 | 0.947 | 0.947 0.938 0.945 | 0.929 | 0.929 | 0.929 0.930 0.931 |
| -1 | 0.927 | 0.951 | 0.963 0.948 0.947 | 0.883 | 0.933 | 0.933 0.939 0.936 |
| -2 | 0.926 | 0.946 | 0.958 0.949 0.927 | 0.906 | 0.933 | 0.947 0.937 0.933 |
| -3 | 0.973 | 1.011 | 1.000 1.082 1.042 | 0.924 | 0.943 | 0.943 0.935 0.944 |
| -5 | 0.962 | 0.976 | 0.947 0.903 0.976 | 0.933 | 0.952 | 0.948 0.934 0.952 |
| -8 | 0.935 | 0.963 | 0.848 1.030 0.963 | 0.936 | 0.953 | 0.950 0.954 0.953 |
| -11 | 1.012 | 0.934 | 0.925 0.966 0.934 | 0.944 | 0.952 | 0.955 0.965 0.952 |
| -15 | 1.061 | 1.075 | 1.136 1.047 0.998 | 0.927 | 0.952 | 0.959 0.953 0.946 |
| -18 | 1.258 | 1.175 | 1.084 1.130 1.230 | 0.929 | 0.939 | 0.931 0.913 0.939 |
| -22 | 1.156 | 1.066 | 1.089 1.030 1.066 | 0.910 | 0.932 | 0.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 understand | Read, in order |
|---|---|
How a compress call becomes a frame | Compressor → Encoder core → Optimal parser for the bt strategies |
| How a frame is decoded | Decompressor → Decoder fast paths → Decoder uninitialized output |
| How dictionaries are loaded and used | Dictionaries → Decompressor |
| How one input is split across threads | Parallel compression |
| How containers work | Framed containers → Framed decoder |
| How byte identity and correctness are established | Encoder verification → Decoder verification → Fuzzing → Formal verification |
| Which features compile which code | Codec 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
| Specification | Scope |
|---|---|
| Codec features | Feature 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.
| Specification | Scope |
|---|---|
| Compressor | Public encoder API, configuration lowering, one-shot calls, sessions, I/O adapters, errors and retention. |
| Encoder core | Parameter resolution, engine lifecycle, the window model, the block pipeline, backend dispatch, match finders, LDM, entropy coding and output destinations. |
| Optimal parser | btopt/btultra/btultra2 and LDM: per-block SIMD dispatch, the shared binary-tree walk, match-length kernels, price tables. |
| Target compressed block size | ZSTD_c_targetCBlockSize: the superblock path that cuts a block into sub-blocks near a compressed size. |
| Dictionaries | Raw and Zstandard-format dictionaries on the encoder side: PreparedDictionary, parameter modes, attach / copy / load and entropy loading. |
| Parallel compression | Frame plans, overlap-primed segments, scoped batches, windowed streaming sessions with task queues, the parallel std::io adapters, errors and panic safety. |
| Encoder verification | Byte-identity oracles, test suites and the overflow-correction build mode. |
Decompression
The independent decoder, its fast paths and its output contracts.
| Specification | Scope |
|---|---|
| Decompressor | Public decoder API, dictionaries, streaming state machine, history, block regeneration and compatibility with the reference decoder. |
| Decoder uninitialized output | process_uninit and decompress_to_uninit: the write-only sink, the one-shot BorrowedBuf, the C ABI and their contract. |
| Decoder fast paths | SIMD dispatch, bit containers, Huffman stream scheduling, FSE tables, sequence loops, wildcopies and measurements. |
| Decoder verification | Differential, golden, corruption and API suites for the decoder. |
Containers and integrations
The framed container format, the OpenZL-facing surface and the C ABI.
| Specification | Scope |
|---|---|
| Framed containers | The framed wire format and the framed compressor: owner, sessions, structured input, std adapters, state and memory. |
| Framed decoder | FramedDecompressor: detection, validation, events, dictionaries and budgets; the FramedSeekReader index and dependency plan. |
| OpenZL integration | The zstd calls OpenZL’s codec makes and their mzstd counterparts: magicless frames, CParameter, standalone dictionaries and FrameHeaderInfo. |
| C ABI | mzstd-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.
| Specification | Scope |
|---|---|
| Fuzzing | AFL targets, input layout, corpora, regressions and campaigns, and the libFuzzer adapters ClusterFuzzLite builds. |
| Formal verification | Charon/Aeneas translation to Lean 4, the RFC 8878 transcription, function specifications, round trips, trusted base and the RFC coverage checklist. |
| CI | Workflows and the checks they run: fuzzing, Miri, AddressSanitizer and hotpath Cloud benchmarks. |
| Benchmarking | The cold C-versus-mzstd comparison harness: corpus, preflight validation, Criterion sampling and the report and chart generators. |
| Documentation publishing | Source 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
Compressorand 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_simdlevel) 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 wholesilesia.tarin lzbench at most levels;fastexecutes 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
| Specification | Scope |
|---|---|
| Codec features | Feature 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
| Feature | Effect |
|---|---|
compression | mzstd::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. |
decompression | mzstd::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. |
std | std::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. |
framed | mzstd::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-cloud | Turn 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,compressionandstd,decompression;no_std,compressionandno_std,decompression;- no codec at all;
std,compression,framed,std,decompression,framed,no_std,compression,framedandno_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.
| Specification | Scope |
|---|---|
| Compressor | Public encoder API, configuration lowering, one-shot calls, sessions, I/O adapters, errors and retention. |
| Encoder core | Parameter resolution, engine lifecycle, the window model, the block pipeline, backend dispatch, match finders, LDM, entropy coding and output destinations. |
| Optimal parser | btopt/btultra/btultra2 and LDM: per-block SIMD dispatch, the shared binary-tree walk, match-length kernels, price tables. |
| Target compressed block size | ZSTD_c_targetCBlockSize: the superblock path that cuts a block into sub-blocks near a compressed size. |
| Dictionaries | Raw and Zstandard-format dictionaries on the encoder side: PreparedDictionary, parameter modes, attach / copy / load and entropy loading. |
| Parallel compression | Frame plans, overlap-primed segments, scoped batches, windowed streaming sessions with task queues, the parallel std::io adapters, errors and panic safety. |
| Encoder verification | Byte-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]
| Module | Owns |
|---|---|
config | Level, Strategy, Switch, DictionaryAttach, CompressionParams (including for_level, ZSTD_getCParams), LdmParams, LongDistanceMatching, EncoderConfig, Parameter, ConfigError, SizeOverflow, and validation against the reference’s bounds. |
parameter | CParameter: a (ZSTD_cParameter, value) pair validated as ZSTD_CCtx_setParameter does, applied with EncoderConfig::with_parameter; see OpenZL integration. |
internal | requested_params: the single translation from EncoderConfig to the core’s RequestedParams (the reference’s ZSTD_CCtx_params). |
dictionary | DictionaryBuilder, PreparedDictionary (re-exported from mzstd::dictionary); see dictionaries. |
encoder | Compressor, 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. |
session | EncoderSession (optionally borrowing a dictionary), EncoderSessionOwned<D = PreparedDictionary> (optionally owning one; into_parts), StreamConfig, InputSize, Operation, EncoderStatus, Progress. |
error | EncodeError and the mapping from the core’s private CoreError. |
io | EncoderReader, 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. |
parallel | See 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 withInputSizeMismatch.InputSize::Unknownleaves the header without a content size unless the whole input arrives with the firstEnd, as in the reference.- A first call that is already
Enddeviates from the reference on purpose.ZSTD_compressStream2replaces the pledged size with that call’s input length and writes a frame for it; the session instead fails withInputSizeMismatchbefore touching the stream, as it does for every later mismatch, so a truncated or overlong input never produces a frame. The check lives insession::Core; the core stream remains a faithful port. When the length matches, the bytes are the reference’s. process_uninitisprocessover a[MaybeUninit<u8>]destination: the same genericCore::processandStream::compress, with blocks written straight to the caller zeroed just ahead and buffered output copied in. Bytes, counts, statuses and errors equalprocess, and the two may be mixed on one frame; nothing is allocated per call. Pastproducedthe 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]
flushandfinishareprocesswithFlushandEnd.- Dropping a session mid-frame leaves the compressor reusable: the next
startresets 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.targetCBlockSizeis implemented, see target compressed block size. - Magicless frames (
EncoderConfig::with_format) are written byCompressoronly; the parallel and framed compressors refuse them. - The stable-buffer streaming modes (
ZSTD_c_stableInBuffer,ZSTD_c_stableOutBuffer) are accepted byCParameterand ignored; sessions always behave as the reference’s buffered mode. - Streaming at the fast levels trails zstd on the
stream/mixed-4MiBbench (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_compressorisZSTD_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 blockcheck_dict_validityandenforce_max_distmay detach the dictionary.seq_storeholds sequences in the reference’sSeqDefform (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-byteSeqCodesper sequence (the three FSE codes and the sequence’s extra-bit count) and, in the same loop, the three code histograms (CodeCounts) thatZSTD_buildSequencesStatisticscounts with threeHIST_countFast_wksppasses. 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 originalll_codeandml_codeRFC functions remain unchanged. The codes buffer only grows and every entry belownb_seqis 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
fastprefix parser can switch fromSeqStorevector appends to aSeqCursorover 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 aFullBlock, 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 aFullBlock, 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 throughmzstd_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()andnb_seq()read them untilreset, andstore_last_literalsappends 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 (amemcpycall, 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.
entropyis 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::countcounts 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 fromfill_codes, read throughcode_histogram, which reports the largest code and count asHIST_countFast_wkspdoes.fse::CTable::buildtakes 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.BitWriterand the Huffman writer store each flushed word atmin(pos, end)and leaveposunclamped;closereports “does not fit” whenpospassedend, the reference’s clamp result, while the store needs no bounds check.hufpacks 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_treetherefore 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 bucketn - 1spansrank[n + 1]..rank[n]. The buckets from count 164 up are sorted byHUF_simpleQuickSortwithout 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 thanslice::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 comparesortwith a verbatim port ofHUF_sortand the quicksort with the recursive one.sequences::encodewalks 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_loopchecks every backend against a step-by-step port ofZSTD_encodeSequences_body.
literals::try_compressreports a raw-literals fallback without copying it. If those raw bytes alone reachblock_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 withsplit_blocklive inblock_stats.presplit::split_blockdispatches 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_chunksselects the instance compiled for the level’s sampling rate and hash log (43/8, 11/9, 5/10, 1/10), asZSTD_GEN_RECORD_FINGERPRINTdoes: 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.fpDistanceuses 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,
prevandnextswap (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
| Strategy | Finder | Tables | extDict variant | dictMatchState variant |
|---|---|---|---|---|
| fast | fast | hash | yes | compress_block_dict_match_state (tagged dictionary table) |
| dfast | double_fast | long hash, short hash | yes | compress_block_dict_match_state (tagged dictionary tables) |
| greedy, lazy, lazy2 | lazy hash chain, or row finder for windows above 16 KiB | hash, chain or tag rows | yes | greedy_dms .. lazy2_row_dms |
| btlazy2 | lazy with the DUBT binary tree | hash, tree | yes | btlazy2_dms (dictionary tree sorted by ZSTD_updateTree) |
| btopt, btultra, btultra2 | opt with the binary tree and price model | hash, tree, hash3, opt arrays | yes | btopt_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_mlshandles the repeat-offset setup and the saved-offset restore, then callscompress_scanwith a smallScan(dfast also aTables { 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 intocompress_block_mls, LLVM merged the instances back into one loop that loads the widths at run time. The tables are cut to1 << hashLogentries first, so the masks are constants. Other parameters run the inlinedcompress_scan::<.., 0, 0>; IN_PREFIXholds 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 thenip - matchwith 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
BRANCHYparameter, chosen per block bytakes_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 atip + 1is 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 + 64andip1 + 128whenever the step grows (mem::prefetch_l1, through thebranchescrate). The reference’s per-positionip + 256on 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 areusizeinside the scan, which saves LLVM a second copy of the first one. Its hash slots come fromSlots::{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’sip <= ilimittest, 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 << hashLogbytes). 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 ofsilesia.tarthis 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
ip2with one 10-byte range check that is also itsip2 + 1 < ilimittest (mem::probe_window,probe_pair), dfast’s 8-byte load atip1(mem::try_load64) is itsip1 <= ilimittest; - 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 readsip0 - 2once 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 throughlocate, which picks without a branch (select_unpredictable, the reference’sidxBaseselect) the slice holding it: the external segment fromdict_start_indexup to the prefix, orsrcfrombase, and adds that slice’s origin as a masked difference (selected together with the slice, LLVM turned the origin back into a branch).segment_matchesclamps the offset to the slice’s last whole word and folds the range test into the word comparison, so a stale entry belowdict_start_indexwraps to an out-of-range offset and is rejected, as the reference’sidx >= dictStartIndextest 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 localstore_seq_wildinstead, which appends the 16-byte chunk straight from the input without the stack copy and overflow test; the same body instore_seq_frommade 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:
| Kernel | Reference | What it does |
|---|---|---|
lazy::row::match_mask | ZSTD_row_getMatchMask | One 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_run | ZSTD_count | Window adapters for the shared mem::count_slices_simd word and vector scan described above. |
Ctx::tree_step | ZSTD_count + byte compare in the DUBT | Without an extDict, one 8-byte load per side gives both the common length and the two bytes that order the runs. |
Probe | MEM_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).MatchStategives both tables 64 extra entries in row mode, andROW_TABLE_SLACK(64 plus seven view steps) fromROW_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 belowlowLimitand end the walk as zeros would; - the hash cache, a local
next_to_updatewritten back onDrop, 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
u32and wrap exactly as in C;Addrarithmetic isi64so 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
dfastloop 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 fulldfastblocks on thefastlevels’SeqCursorremoved 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, asZSTD_selectAddrcompares) cut 1.3% of the instructions but cost 32% more cycles, since LLVM again tested the range with its own, mispredicted branch. -
faston 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 inlinedcount_at_simdcalls, which spill the loop’s state (store-to-load interlocks +9%). Calling the context’scountinstead, relative repeat tests orctx.basefor 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 intoCompressor::compress_to_slice_with, and the store carries the full-block destinations’ state. Forcing the inlining added instructions. -
The
fastkernel’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
Veccapacity 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 asusize(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_generateSequenceswith 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 afterbuild_seq_storeexecutes 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
Veczeroes its destination before writing it (compressBoundof 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 needsunsafeor 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, asZSTD_match4Found_cmovdoes: 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’slarge-literal-and-match-lengthsfile 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. Aselect_unpredictablebetween 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 movedcompress_block_mlsout 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 + 256prefetch on aarch64 (1-4% slower); - NEON kernels for the 32- and 64-slot tag masks (
vuzpin place ofvld2q/vld4q; up to 6% slower at level 10, no change at 12) and for match counting in 64-byte blocks (vshrnnibble 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 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
-
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,
SeqDefpacking 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::unlikelyon 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’scurrent0out of the loop instead of through it (0.5-1% slower), reading the repcode word atip2.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 onVec. 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_prefetchfromZSTD_row_fillHashCacheandZSTD_row_nextCachedHash, 8 positions ahead) and its per-candidate prefetch through thebranchescrate’s safeprefetch_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::<S, OPT_LEVEL, EXT, DMS> (#[simd])"]
Parser --> Split["Finder::split(ms): Finder + OptState"]
Split --> Find["find_matches: match on mls"]
Find --> Gam["get_all_matches::<S, EXT, DMS, MLS> (#[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:
| Table | Entry | Price |
|---|---|---|
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]) + 51 | entry + 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 inBackend::available(). - Tree cell indices are below the tree size. Window indices handed to
Ctxare at least the window’s low limit, and in the non-extDict variants at leastdict_limit. A violation panics on a slice bound and never reads out of range. PriceTablesis valid from therescale_freqsat the start of eachopt_genericcall until the end of that call. Nothing outsideoptreads 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_matchesandupdate_treeexecute 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::resetzeroes 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_getMaxNbBitsreads every symbol up toMaxLL/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<NonZeroU32>)"] --> 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:
superblockis the port ofzstd_compress_superblock.cand of thetargetCBlockSizefunctions ofzstd_compress.c.block_statsisZSTD_buildBlockEntropyStatsand its two halves. They used to be private tosplit_block; the superblock also needs the descriptions they produce (hufDesBuffer,fseTablesBuffer,lastCountSize), so the metadata types keep them.sequences::encode_slicewrites the bitstream for a run of a block’s sequences and codes;sequences::encodeis the whole block through it.SeqStore::sequence_lengthisZSTD_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.entropyis built once; a sub-block only reads it.next.repis the repeat offsets after the whole block, unless a tail was stored, when it is rebuilt fromprev.repand 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_lengthandml_basefields, the lengths summed for a decision readSeqStore::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
dstCapacitydoes 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
Vecor zero uninitialized memory ahead of each block offer the superblockcompress_bound(block)bytes, whereZSTD_compress2into aZSTD_compressBoundbuffer 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 acompress_boundmargin 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
| Type | Role |
|---|---|
DictionaryContent | Auto (format by magic number), Raw, Zstd (ZSTD_dct_*). |
DictionaryId | A non-zero ID, as a Zstandard dictionary declares it and a frame header names it. |
DictionaryError | NotZstdFormat, Corrupted, and Config for an invalid encoder configuration. |
DictionaryBuilder | Borrowed bytes, a content type and an EncoderConfig; build copies and prepares. |
PreparedDictionary | An 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, DictionaryRef | The 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:
| Strategy | Dictionary tables (tfp = CDict) | Context load (tfp = CCtx) |
|---|---|---|
| fast | every position, tagged (fill_cdict_hash_table) | every third (dtlm_fast) or all (dtlm_full) |
| dfast | long and short tables, tagged | as fast |
| greedy, lazy, lazy2 | row tags cleared then ZSTD_row_update, or ZSTD_insertAndFindFirstIndex | same |
| 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::dmscarries the dictionary’sMatchStateand 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) areEncoderConfig::with_dictionary_attach. (Engine::load_prefixis 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::DictionaryMismatchotherwise), unless it was built withDictionaryBuilder::with_level_parameters: that is theZSTD_createCDict_advanced+ZSTD_CCtx_refCDictroute, which any compressor accepts and which replaces the compressor’s level with the default one (OpenZL integration). - The optimal parser’s seeding reads
FSE_getMaxNbBitsfor 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, whichZDICTtraining produces; thecompress_dictionarytarget 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 throughzstd-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"]
| Type | Role |
|---|---|
SegmentSize | 64 KiB to 256 MiB, default 4 MiB: the input each segment covers. |
TaskCount | 1 to 65536: how many tasks a slice batch is cut into, or a stream’s window. |
ParallelConfig | The segment size. |
ParallelCompressor | An EncoderConfig, a ParallelConfig and a pool of idle engines shared with its tasks. |
ScopedParallelBatch / ScopedParallelTask | A whole input, borrowed for a scope or read from a source; each task compresses a run of consecutive segments. |
OwnedParallelBatch | ScopedParallelBatch<'static>, from prepare_source: its tasks own a shared source handle and run on detached threads. |
RandomAccessSource | len, read_exact_at(offset, dst) and an optional identity; Send + Sync + 'static. |
ArcBytesSource / SeekSource / FileSource | Shared immutable bytes; a Read + Seek reader behind a mutex; a regular file read with positional reads. |
SourceIdentity / SourceIoError | A source’s version token; a source’s io::Error, shared so ParallelEncodeError stays Clone + Eq. |
ParallelEncoderSession / ParallelEncoderSessionOwned | Input that arrives in chunks; borrows or owns the compressor and wraps one core::Stream. |
ParallelTaskQueue / OwnedParallelTask / ParallelTasks | The stream’s queue handle, its 'static tasks and the blocking iterator over them. |
ParallelEncoderStatus / ParallelProgress | What one process call did and what the session needs next. |
ParallelEncoderWriter / ParallelEncoderReader | std::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.
ArcBytesSourcewrapsArc<[u8]>; a range past the end isUnexpectedEof.SeekSource<R>(R: Read + Seek + Send, notSync) holds the reader incore::SeekReader’s mutex:lenseeks to the end, and each read seeks to its absolute offset andread_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.FileSourcereads withFileExt::read_at(Unix) orseek_read(Windows), retryingInterruptedand short reads; other platforms returnUnsupported. 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,WouldBlockand sink errors lose nothing.Write::flushisOperation::Flushfollowed by the sink’s flush;try_finishrepeatsEnd, waiting whenever a step delivered nothing, andfinishreturns the adapter insideFinishErrorfor a retry. The session is boxed soFinishError<Self>stays small. - The reader fills a 128 KiB buffer from the source, calls
processwithContinue(orEndafter the source’s end) straight into the caller’s slice, and waits when the session isPendingwith nothing produced.into_partsreturns the source and the bytes read but not accepted asEncoderReaderParts. - Both block in
wait, so the queue must run on other threads.
Errors
ParallelEncodeError is #[non_exhaustive]:
| Variant | When |
|---|---|
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. |
InvalidState | A 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. |
TaskQueueClosed | Every 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. |
SourceChanged | A 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
waitand batch assembly never wait for a task that cannot report. in_flight <= window;fillingholds at most an overlap plus one segment, allocated once per segment at that size.processwrites exactlyoutput[..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.rsand theparallelfuzz 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
waitforever. - There is no
waitwith 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
| mzstd | Reference |
|---|---|
Compressor::compress and the other one-shot calls | ZSTD_compress2 with the same ZSTD_c_* parameters (oracle::compress2, compress2_capacity) |
EncoderSession::process sequences | ZSTD_compressStream2 with the same chunks, output capacities and directives (oracle::compress_stream, compress_stream_with_output) |
mzstd_compress | ZSTD_compress (oracle::compress, compress_capacity) |
mzstd_compress_bound, Compressor::max_compressed_size | ZSTD_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
| Suite | What it establishes |
|---|---|
core::tests | Levels 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_tests | Random 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_tests | Streams 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 tests | Histogram, 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.rs | The public API produces the reference’s bytes for one-shot calls and sessions, including Compressor::default. |
tests/parallel.rs | Parallel frames decode and do not depend on the task count or order; batch task failures, abandonment and leaks. |
tests/parallel_stream.rs | Parallel 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.rs | ParallelEncoderWriter and ParallelEncoderReader: batch identity, flushes, short and refusing sinks, FinishError retries, source failures, closed queues and into_parts. |
zstd-ffi/tests/reuse_probe.rs | A 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.rs | The C ABI matches ZSTD_compress, including short destinations. |
| fuzz targets | See 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
OutputTooSmallis 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.
| Specification | Scope |
|---|---|
| Decompressor | Public decoder API, dictionaries, streaming state machine, history, block regeneration and compatibility with the reference decoder. |
| Decoder uninitialized output | process_uninit and decompress_to_uninit: the write-only sink, the one-shot BorrowedBuf, the C ABI and their contract. |
| Decoder fast paths | SIMD dispatch, bit containers, Huffman stream scheduling, FSE tables, sequence loops, wildcopies and measurements. |
| Decoder verification | Differential, 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>andDecompressor::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_logreturnsDecodeConfigError). Nothing is allocated until the first decode. DecoderConfigis aCopyvalue withDEFAULT,DefaultandFrom<DecodeLimits>/From<FrameMode>.FrameMode::Concatenated(default) decodes every frame until the input ends, asZSTD_decompressdoes; at least one frame (Zstandard or skippable) is required.FrameMode::Singledecodes exactly one frame, Zstandard or skippable, and stops: sessions leave the rest unconsumed, one-shot calls reportTrailingData. A skippable frame ends the operation with no output, asZSTD_decompressStreamreports 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 (default2^27, the reference’sZSTD_WINDOWLOG_LIMIT_DEFAULT;10..=31) and optional input and output byte budgets over a whole operation.- One-shot.
decompressreturns a new vector;decompress_intoappends and returns the appended range, truncating back on error;decompress_to_slicefills a caller slice and reportsOutputTooSmallwith the slice completely filled.decompress_to_uninitdoes 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 samecore::Streamstate machine; see below. Each hasprocess_uninit, the same call into&mut [MaybeUninit<u8>], which may be mixed withprocessand writes exactlyoutput[..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 byDecoderSessionOwned::{reinit, into_decompressor}.trimapplies a policy once; releasing drops every buffer but keeps the session state, so a finished session still reportsFinished. - I/O.
reader(R)andwriter(W)(moduledecompressor::io) own one 8 KiB buffer each; a failedDecoderWriter::finishreturns the writer in the crate-wideio::FinishError. - Dictionaries. Every entry point has a
*_with_dictionarytwin takingimpl Into<DictionaryRef>; see Dictionaries.DecoderSession<'d>keeps its single lifetime:start_with_dictionaryborrows the dictionary for'dtoo.DecoderSessionOwned<D>owns anyD: AsRef<DecodeDictionary>(anArcshares one);Ddefaults toDecodeDictionary, soDecoderSessionOwnedwithout parameters still names the plain owned session.
Errors
graph LR
Kind[InvalidDataKind] -->|From| Err[DecodeError]
Entropy[bits / fse / huffman / literals / sequences / block] -->|Result<_, InvalidDataKind>| 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 }(withkindas itssource),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
DecodeDictionaryreturnscrate::dictionary::DictionaryError:NotZstdFormatorCorrupted. The core’s entropy parser reports anInvalidDataKind, which the dictionary layer maps toCorrupted. - 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::Errormaps format errors toInvalidData, truncation toUnexpectedEof, contract violations toInvalidInput, allocation failure toOutOfMemoryand limits toOther.
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
Processwith empty input only drains output; it never ends the input.- In
Concatenatedmode a frame boundary isNeedsInputuntilFinishconfirms the input ends there.Finishwith the input exhausted inside a frame, or before any frame, isUnexpectedEndOfInput. - 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, indictionary::decode) returnsDecodeParts { id, entropy }without holding the bytes;DecodeParts::view(bytes)pairs them with whatever owns them.DecodeDictionaryis one such owner;PreparedDictionary(withcompression) is the other, andDictionaryRef::Preparedviews 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 IDfneeds a dictionary declaringf. Raw content declares no ID. - Entropy follows
ZSTD_copyDDictParameters: a Zstandard-format dictionary sets the reference’slitEntropyandfseEntropy, 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 reachoutput + dictionary lengthbytes back, whatever the window, asoLitEnd - virtualStartbounds 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 checksoverflowed(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_distributionaccepts accuracy logs5..=15and 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_distributionfollows RFC 8878 section 4.1.1 (and matches Appendix A row for row);SeqTable::fill_rowsspreads 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
logbits; 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 (includingRepeated_Offset1 - 1for a zero literal length) follow the reference exactly; a zero offset isOffset. - Headers.
FrameHeader::parsereads the fields at the offsetsheader_sizefixed, throughdictionary_id_size,described_window_sizeandread_le;sequence_countreadsNumber_of_Sequencesbeforeread_header_onupdates the tables. These andBlockHeader::parseare 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_fastruns for four streams when the tree is at most 11 bits deep (its table log is thenHUF_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 byHUF_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 ofBIT_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] - ilowestover 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_decodeLastSymbolX2caps 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_Blockreads its tree for single symbols when it has one stream (HUF_decompress1X1_DCtx_wksp) and asHUF_selectDecoderchooses for four, from the literal count and the section size including the tree; a dictionary’s tree is read for pairs (ZSTD_loadDEntropy). ATreeless_Literals_Blockkeeps the type of the table it reuses.HuffmanTablerecords it as aTableKind, whichliteralsanddictionaryset 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_decompressreturns 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 exactly2^27, where the reference’s streaming default is2^27 + 1bytes. - 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_advancedexactly: empty bytes are no dictionary, raw content is never rejected, and Zstandard-format bytes are rejected whereZSTD_loadDEntropyrejects them.DictionaryContent::Zstdon bytes without the magic number isNotZstdFormat, where the reference reportsdictionary_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), andDecodeDictionaryalways copies its bytes (noZSTD_dlm_byRef). APreparedDictionarydecodes throughDictionaryRef::Prepared, which views its own bytes with theDecodePartsit 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_intoreserve 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_intovariant, 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).decompresszeroes 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
| Initialized | Uninitialized | Owner |
|---|---|---|
DecoderSession::process | DecoderSession::process_uninit | decompressor::session |
DecoderSessionOwned::process | DecoderSessionOwned::process_uninit | decompressor::session |
Decompressor::decompress_to_slice | Decompressor::decompress_to_uninit | decompressor::decoder |
Decompressor::decompress_with_dictionary_to_slice | Decompressor::decompress_with_dictionary_to_uninit | decompressor::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](ordst[..written]) is initialized and no byte after it has been written. - A session’s
DecodeFailurereportsproduced; exactly that prefix was written, as on success. - After a one-shot
OutputTooSmall, all ofdstholds the output’s prefix, as withdecompress_to_slice. After any other one-shot error the caller treatsdstas uninitialized. - Calls of
processandprocess_uninitmay 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_frameaccepts a frame only with a declared size that fits in the rest ofdst, asSliceOutputdoes, and recordsframe_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’sBlockOutput. Itsgrows()isfalseandgrownever extends it, so the sequence loop runs the fixedExecutor::<false>over the room, as fordecompress_to_slice, with no stops for more room, and the block’s trailing literals are written throughput. 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 pastwrittenhas been written. The cut is at most a block plus the copies’ overlength, whereSliceOutputhands over the whole frame; decoded bytes are the same, and a block that would passframe_endfailsOutputFulleither way. UnlikeVecOutput, it cannot append long sequences without zeroing them first:BorrowedBufcannot 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_decompressover silesia.tar decodes 5-9% faster with the room zeroed at once (see Measurements). placewrites raw and RLE blocks in one pass: bytes already zeroed for an earlierreserveare overwritten throughfilled_mut, the rest is appended (BorrowedCursor::appendorappend_fillwith the run byte). A stored or RLE block’s output is therefore never zeroed first.- Any failure returns
None. The incrementalprocess_uninitthen 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_blockandone_shot_outcome, and differ only in how bytes reach the destination. - Destination selection is static:
SinkandOutputare 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_uninitalone and alternating withprocessagainstprocessfor 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 everyproduced. Also failure progress on corrupt data and a bad checksum, failed and finished sessions, owned and dictionary sessions, anddecompress_to_uninitagainstdecompress_to_slicefor 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(Sinkdelivery into an offset of an uninitialized slice) andstream(UninitOutputzeroing each block’s room up to the frame’s end without growing,placeacross and past the zeroed prefix,decode_to_uninitsentinels,process_uninitdelivery). - AFL:
decode_streaming,decode_roundtripanddecode_dictionarydrive sessions alternatingprocessandprocess_uninitand check that nothing is written pastproduced;decompresscomparesdecompress_to_uninitwithdecompress_to_slice, including sentinels past the result.
Known gaps
decompress_to_uninitzeroes 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, andBorrowedBufcannot read its filled part while appending, so the appendsdecompressuses 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 theBorrowedBufbookkeeping; 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
BitContainerkeeps the eight bytes atposand the countavailof unread low bits.looksubtracts the requested width, shifts and masks; a zero-width read returns zero. A full refill whilepos >= 8moves back by(64 - avail) / 8bytes. Near the start, the cautious refill is cold and takes the container by value so the hot loop can keep it in registers. Atpos == 0it returns directly without that cold call. Valid exhaustion ispos == 0 && avail == 0; overreading wrapsavailabove 64 and refills preserve that overflow until validation rejects it. The Huffman weight decoder checks the same overflow state.Sentinelis the reference’s fast-loop stream (HUF_DecompressFastArgs): unread bits at the top, a1below 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 tolimit, 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 acmov. 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_containermoves back to that stream’s first byte and subtracts prefix bits fromavail, 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 aSentinelshift by the whole row (shlxmasks to six bits) consumes the code.HuffmanTable::fillcounting-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), asHUF_readDTableX1_wkspdoes; 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_roundssplits 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. APairRowpacks 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_roundsdoes 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) / 10per 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_fixedis 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 leastFIXED_MIN, 4 KiB) decode into a literal buffer of at leastFIXED_OUT(BLOCK_SIZE_MAX + PAIR_WINDOW) bytes, whichdecode_streamsreceives 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 toBLOCK_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, witholimitfive 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_serialreplacespair_rounds_fixedin AVX-512 feature contexts. Given AVX-512, LLVM’s SLP vectorizer packs the lockstep loop’s four branch-free containers into oneymmregister: onevpsrlqmakes four lookup indices,vpgatherqdloads four rows,vpsllvqconsumes them, every row is extracted again for its scalar store, and the four refills become avpgatherqq. 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 withSentinel::reload, whose bounds test is a branch the compiler cannot remove, streams 1 and 3 clamped withreload_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 emitsmovandshrfor it, no fewer instructions).- A code of
bbits fills a run of1 << (11 - b)pair rows whosejth row continues with the code at rowj << b, the same series for every code of that length (the reference’sHUF_fillDTableX2Level2per 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_pairsover 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]
SeqTablerows areZSTD_seqSymbol: baseline, next-state base, extra-bit and state-bit counts. Both counts are aBitCount, a 32-variant#[repr(u8)]enum: its range reaches the compiler with every load, so reads by these counts need no& 63before thebzhior 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
TableViewcarries 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_rowsconsumes 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.spreadstill portsZSTD_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 throughread_header_ontofill_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_onis#[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, streamingHistory) compile to the loop above withexecute_exactlyinline and cold, and so doesUninitOutput, 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 ofSequenceTables::executeappends it when it is at leastAPPEND_MIN(1024) bytes and valid, else grows the zeroed room and retries it, else runsexecute_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), notlook_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):
| Case | Copy |
|---|---|
| literals | one 16-byte chunk, then 32-byte chunks |
| match, offset >= 32 | 32-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 < 32 | 16-byte chunks |
match, offset < 16, Shuffle | PERIOD[offset] spreads the period over a u8x16; ROTATE[offset] turns each chunk’s vector into the next’s |
match, offset < 16, Scalar | the reference’s offset spreading (dec32table/dec64table), then 8-byte chunks |
| end of buffer | execute_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::walkis the one-shot frame grammar over anOutput.SliceOutputcuts the caller’s slice at each frame’s declared end, so chunked copies never touch bytes past the output and the rest ofdststays unchanged.VecOutputcuts a declared frame’s blocks at its end, likeSliceOutput, so no slack past the output is zeroed or kept. A frame that overruns its declared size then fails withOutputTooSmallinstead of the reference’sContentSizeMismatch, sodecode_allresets 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_sliceorresizewith the run byte write straight into spare capacity. Compressed blocks write throughBlockOutput(block.rs): the buffer’s initialized prefix, whichVecOutputextends on demand (grow, 4 KiB of zeros at a time, up to the block’s room cut at the frame’s end) and byappend(cut at the output’s end, thenextend_from_slicefor the literals and doublingextend_from_withinfor 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,UninitOutputandHistoryare 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
UninitOutputisSliceOutputover aBorrowedBuf:reservezeroes a compressed block’s whole room (cut at the frame’s end) before the block, which then runs the fixed sequence loop, andplacewrites raw and RLE blocks once, over the zeroed bytes and then appended. See uninitialized output.decompress_to_slicetries 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.Historykeeps an initialized buffer with a fill mark;preparecompacts as before and ensures room for a block plus the overlength,commitrecords 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_OUTbytes 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.
| Case | before | now | zstd 1.5.7 | ratio |
|---|---|---|---|---|
| text 4 MiB, level 1 | 5.96 ms | 2.14 ms | 1.81 ms | 1.18 |
| text 4 MiB, level 3 | 4.47 ms | 1.86 ms | 1.64 ms | 1.13 |
| text 4 MiB, level 19 | 2.70 ms | 1.10 ms | 1.05 ms | 1.05 |
| binary 4 MiB, level 1 | 18.89 ms | 2.13 ms | 2.28 ms | 0.93 |
| binary 4 MiB, level 3 | 14.37 ms | 3.52 ms | 3.28 ms | 1.07 |
| binary 4 MiB, level 19 | 15.98 ms | 4.73 ms | 4.34 ms | 1.09 |
| random 1 MiB, levels 1-19 | 64.5 µs | 19.8-21.3 µs | 19.6-21.4 µs | 0.99-1.04 |
| text+binary+text 12 MiB, LDM | 18.02 ms | 5.06 ms | 4.79 ms | 1.06 |
| mixed 4 MiB, streaming | 7.26 ms | 2.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):
| level | native before | native after | portable before | portable after |
|---|---|---|---|---|
--fast=5 | 2105 | 2302 | 2292 | 2448 |
--fast=1 | 1881 | 2050 | 2080 | 2196 |
-1 | 1296 | 1622 | 1631 | 1707 |
-3 | 1317 | 1490 | 1515 | 1571 |
-9 | 1468 | 1612 | 1616 | 1694 |
-19 | 1363 | 1456 | 1470 | 1535 |
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:
| level | v0.2.5 native | v0.2.10 native | fix native | v0.2.10 portable | fix portable |
|---|---|---|---|---|---|
--fast=5 | 2314 | 2313 | 2594 | 2443 | 2455 |
--fast=1 | 2042 | 2061 | 2301 | 2192 | 2197 |
-1 | 1343 | 1625 | 1765 | 1724 | 1708 |
-3 | 1253 | 1486 | 1654 | 1597 | 1591 |
-9 | 1354 | 1630 | 1766 | 1713 | 1705 |
-19 | 1315 | 1463 | 1614 | 1552 | 1556 |
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_oncost 8-12% in AVX-512 builds alone. Check changes toblock,literalsandsequenceswith-C target-cpu=nativeas 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
--fastlevels 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-lineread_header_onis 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_fixedtakes 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-onepair_rounds_serialstill 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 amovper 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_simdu64x4, 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 intovpgatherqd, whose latency sits on every stream’s lookup chain. A gather cannot be written explicitly either:_mm256_i64gather_epi32takes a raw pointer and staysunsafeinsidefearless_simd::kernel!, whose other intrinsics (_mm256_sllv_epi64and 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 + 8bytes), 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 byrotate_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
WriteFronttype 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 (anunsafecast, 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
u64pair, 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 withextend_from_slice/extend_from_withinis 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 whatVecOutputdoes 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.tarlevels 1, 3 and 19): copying matches with offsets of 32 or more in 16-byte chunks, asZSTD_wildcopydoes, 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.rsorfse.rsmoves the unchangedSequenceTables::executeby 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; withexecutecontrolled for they are faster and are now in place. Check loop changes byexecute’s instructions per sequence or acodegen-units = 1build, 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
prefetchw16 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 intorun(+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 au64and au32(+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 ofZSTD_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 acodegen-units = 1build. Raw-literal frames (Alice level -5) decode at parity. -
SeqTable::fill_rowsstill runs outside the SIMD dispatch, without BMI or LZCNT in a portable build. Its alphabet rows are now static and constructing aBitCountno longer needs a table lookup. Building the pair table costs about 6K instructions per tree beyond its 8 KiB zeroed allocation. -
decompress_to_sliceon 4 MiB of text is about 11% slower thanZSTD_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;u64repeat offsets removed that pattern without measurable gain. -
decompress_to_slicetakes 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_oraclecompresses generated corpora (text, random, records, runs, mixed) withZSTD_compress2at levels -5 to 19, sizes 0 to 140 KB, with the checksum and content-size flags varied, and requires byte equality throughdecompress,decompress_into(after a prefix) anddecompress_to_slice(suffix untouched), plus agreement withZSTD_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 fromZSTD_compressStream2with 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,Singlemode, and window limits (including a header patched to a2^28window) are compared with the reference’s streaming limit.Singlemode is also split into members, one session each, and compared withZSTD_decompressStreamrun 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 and2^27windows.decoder_goldendecodes 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_corruptionrequires every proper prefix of sample frames to fail withUnexpectedEndOfInput, 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_endsdecodes the frames intestdata/huffman-stream-ends(fuzz finds thatZSTD_decompressaccepts 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 inhuffman::referencepin 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;literalschecks that treeless blocks keep the decoder type, anddictionarythat a dictionary’s tree is read for pairs.decoder_dictionarycompresses withZSTD_compress2and a dictionary trained byZDICT_trainFromBufferon 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 throughdecompress_with_dictionary, its_intoand_to_slicetwins, 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 asZSTD_DCtx_loadDictionary_advanceddoes, and a frame decoded with an accepted corrupt dictionary must equal the reference’s output.decoder_apianddecoder_iopin the public contract: frame modes, theFinishsuffix rule andInvalidState, failure progress, byte budgets, typed errors (with reference agreement where it applies), retention, owned sessions, and the adapters’ deferred errors, retries,into_partsandFinishErrorround trips.decoder_uninitchecks 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.
| Specification | Scope |
|---|---|
| Framed containers | The framed wire format and the framed compressor: owner, sessions, structured input, std adapters, state and memory. |
| Framed decoder | FramedDecompressor: detection, validation, events, dictionaries and budgets; the FramedSeekReader index and dependency plan. |
| OpenZL integration | The zstd calls OpenZL’s codec makes and their mzstd counterparts: magicless frames, CParameter, standalone dictionaries and FrameHeaderInfo. |
| C ABI | mzstd-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
| Bytes | Field |
|---|---|
| 4 | 0x184D2A5B, the chunk magic (a skippable frame) |
| 4 | frame size, always 5 |
| 4 | signature MZFR |
| 1 | flags: 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):
| Field | Present for | Encoding |
|---|---|---|
| type | every chunk | 0 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 |
| codec | types 1-8 | 0 stored, 2 one Zstandard frame, 3 one Zstandard frame with dictionary references; 1 is reserved and invalid |
| declared size | codec 2, 3 | varint: decoded content bytes |
| references | codec 3 | count (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) |
| flags | types 2-5 | bit 0 hidden (types 2, 3), bit 1 checksum (types 2, 5), bit 2 detached; other bits invalid |
| checksum | flag bit 1 | byte 3 and 32 bytes, recorded, never computed |
| body length | flag bit 2 | varint: size of the detached Zstandard frame |
| repeated kind | type 8 | 1 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::Uncompressedresource 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
| Chunk | Content |
|---|---|
| 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 andMZFRsignature must follow; any other input isInvalidSignature, or raw Zstandard frames inInputMode::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 reportsNoneuntil 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_bytesormax_metadata_bytes,max_resource_bytes, and the stream’s exact output size. Exceeding it maps back toLimitExceededorDeclaredSizeMismatch. 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 inFramedLayout::seek_table); the next call reportsFinishedwithout consuming or producing anything, so a container ends itself without a callerFinish. - Internal dictionary cache. With
InternalDictionaryPolicy::Retain, decoded resource payload is kept so later chunks can reference it. The cache grows withinmax_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, withLimitExceeded { 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 ofZSTD_decompressDCtxand 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::Autoinput 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 call | mzstd |
|---|---|
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_format | EncoderConfig::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_valid | CParameter::check_memory_cap(3); the host refuses Format and ContentSizeFlag itself. |
ZSTD_compress2, ZSTD_CCtx_refCDict | Compressor::compress* and compress_with_dictionary* with a standalone PreparedDictionary. |
ZSTD_CCtx_setPledgedSrcSize, ZSTD_compressStream2 | Compressor::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_compressBound | Compressor::max_compressed_size. |
ZSTD_createDDict_advanced(byCopy, auto), ZSTD_decompress_usingDDict | DecodeDictionary::new(bytes, DictionaryContent::Auto) (or try_from), Decompressor::decompress_with_dictionary*. |
ZSTD_getFrameHeader_advanced | decompressor::FrameHeaderInfo::parse(src, format). |
ZSTD_DCtx_reset, ZSTD_d_format | A 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::newand the framed compressor write their own headers, always with a magic number, and refuse the magicless format withConfigError::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 storesldmHashLogand friends whateverenableLongDistanceMatchingis; a later switch toEnabledor the automatic enabling atbtoptand a 2^27 window uses them. The olderwith_long_distance_matchingstill resets the values. ForceMaxWindowis modelled completely: it turns attaching a dictionary off (ZSTD_shouldAttachDict) and makes a force-loaded dictionary obey the window (loadedDictEnd = 0).TargetCBlockSizeis 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, andZSTD_CCtx_getParameterreturns 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.Ignoredparameters 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 at0.literalCompressionModeisSwitch:Disabledstores 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
CDictmade byZSTD_createCDict_advancedrecordsZSTD_NO_CLEVEL(0). OnrefCDictthe 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 thecParamsit was created with. A dictionary loaded withloadDictionarykeeps the context’s level and parameters. - mzstd models the two as
PreparedDictionary’sstandaloneflag.DictionaryBuilder::with_level_parameters(level)buildsEncoderConfig::default().with_params(CompressionParams::for_level(level, 0, len))and marks the dictionary standalone: any compressor accepts it (PreparedDictionary::serves), andrequested_forswaps 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 referencedZSTD_CDict. ZSTD_getCParams(level, 0, size)reads a source size of0as unknown (ZSTD_CONTENTSIZE_UNKNOWN), so the table row is selected as for an unknown source with a dictionary (ZSTD_getCParamRowSize);CompressionParams::for_levelfollows it.- The dictionary is only ever built from
DictionaryContent::Auto/Raw/Zstdbytes by copy; the type is detected from the leading magic number asZSTD_dct_autodoes, and an empty dictionary is accepted (and still replaces the level). - The binding uses the dictionary only on its
ZSTD_compress2route. 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)isZSTD_getFrameHeader_advanced: content size (Nonefor 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.UnexpectedEndOfInputstands for a positive “need more” result.DecodeDictionarywithDictionaryContent::AutoisZSTD_createDDict_advanced(byCopy, auto);ZSTD_decompress_usingDDictisdecompress_with_dictionary(every frame starts from the dictionary; a frame naming another ID isDictionaryMismatch).
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_targetCBlockSizeis implemented (target compressed block size). - Magicless frames are written by
Compressoronly (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,overlapLogandrsyncableis the one of a build withoutZSTD_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
| Function | Behavior |
|---|---|
mzstd_compress | ZSTD_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_decompress | ZSTD_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_bound | ZSTD_compressBound; 0 when it overflows size_t. |
mzstd_encoder_create | A handle at a level -131072..=22 (0 is 3), its first frame started with an undeclared size; *encoder is null on failure. |
mzstd_encoder_reset | Abandons 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_process | One 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_create | A 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_reset | Abandons the current operation and starts a new one. |
mzstd_decoder_process | One 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_free | Release 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_compressat 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 throughmzstd_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-ffihas no AFL or libFuzzer target of its own; the mzstd sessions it wraps are fuzzed directly.mzstd_decompressdecodes a 64-byte frame about 5 ns slower than when it zeroed the destination and calleddecompress_to_slice; see uninitialized output.
Development tools
How the repository measures, tests, proves and publishes itself.
| Specification | Scope |
|---|---|
| Fuzzing | AFL targets, input layout, corpora, regressions and campaigns, and the libFuzzer adapters ClusterFuzzLite builds. |
| Formal verification | Charon/Aeneas translation to Lean 4, the RFC 8878 transcription, function specifications, round trips, trusted base and the RFC coverage checklist. |
| CI | Workflows and the checks they run: fuzzing, Miri, AddressSanitizer and hotpath Cloud benchmarks. |
| Benchmarking | The cold C-versus-mzstd comparison harness: corpus, preflight validation, Criterion sampling and the report and chart generators. |
| Documentation publishing | Source 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
| Target | Oracle |
|---|---|
differential_c | Compressor::compress equals ZSTD_compress2 with the same parameters, and the frame decodes. |
output_capacity | compress_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_session | A 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_lifecycle | Reuse, reconfigure and trim(ReleaseAll) never change a frame. |
parallel | Parallel 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_parsing | Header 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. |
decompress | Raw 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_streaming | Two 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_roundtrip | Parameter header: frames the reference writes with that configuration decode to the payload, one-shot and in pieces. |
decode_dictionary | Two-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_dictionary | A 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"]
| Target | Oracle |
|---|---|
framed_encode | Byte 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_decode | Arbitrary 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_roundtrip | Two 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::ioadapters 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
| Path | Owner | Contents |
|---|---|---|
scripts/extract_lean.py | hand-written | Pins the Aeneas release, lists the translated items, regenerates or checks the translation |
verification/lean/Mzstd/Code/Types.lean, Code/Funs.lean | generated, never edited | Aeneas translation of the listed Rust items |
verification/lean/Mzstd/Code/FunsExternal.lean | hand-written model | The one standard-library function the translation calls and the Aeneas library does not model, <[T]>::first |
verification/lean/Mzstd/Rfc.lean | hand-written transcription | RFC 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 proofs | Specifications of the generated functions, round-trip and uniqueness theorems, the axiom audit |
verification/lean/lakefile.lean, lean-toolchain, lake-manifest.json | hand-written pins | Lean 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.
| Definition | RFC 8878 |
|---|---|
magicNumber, skippableMagic | 3.1.1 0xFD2FB528; 3.1.2 0x184D2A50 to 0x184D2A5F |
fcsFlag, singleSegment, reservedBit, checksumFlag, didFlag | 3.1.1.1.1, Table 3 |
fcsFieldSize, didFieldSize, windowDescriptorSize, headerSize | Tables 4 and 5, 3.1.1.1.2 |
windowSize | 3.1.1.1.2: windowLog = 10 + Exponent, windowBase + (windowBase / 8) * Mantissa |
frameContentSize | 3.1.1.1.4: the 2-byte form adds 256 |
parseFrameHeader | 3.1.1.1: the fields at their offsets, the reserved bit rejected, Window_Size = Frame_Content_Size with Single_Segment_Flag |
parseBlockHeader, blockType, blockMaximumSize, blockContentSize | 3.1.1.2, Tables 9 and 10 |
compressionMode | Table 15 |
numberOfSequences | 3.1.1.3.2.1 |
literalsLengthCodes, matchLengthCodes, holds | Tables 16 and 17 |
offsetValue, offset | 3.1.1.3.2.1.1: (1 << offsetCode) + readNBits(offsetCode), minus 3 above 3 |
literalsLengthDefault, matchLengthDefault, offsetDefault, points | 3.1.1.3.2.2; 4.1.1 counts -1 as 1 |
initialRepeats, usedOffset, updateRepeats | 3.1.1.5 |
nextPowerOfTwo, fseState | 4.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;
updateRepeatsreproduces Table 18 row by row andfseStatereproduces 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 item | Preconditions | Result |
|---|---|---|
shared::format::highbit32 | value ≠ 0 | Nat.log 2 value |
shared::format tables and constants | none | LL_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_BASE | none | entry 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_BASE | none | OF_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_type | none | the Table 15 mode of the two low bits |
compressor::core::seq_store::ll_code | length ≤ 131071 | a code below 36 whose Table 16 range holds the length |
compressor::core::seq_store::ml_code | ml_base ≤ 131071 | a code below 53 whose Table 17 range holds ml_base + 3 |
offset_to_offbase, repcode_to_offbase, offbase_is_repcode, offbase_to_offset | no overflow | the offBase sum type is Offset_Value: an offset is stored plus 3, repeat codes 1 to 3 as themselves |
compressor::core::seq_store::update_rep | off_base ≥ 1; Repeated_Offset1 ≥ 1 when it is decremented | updateRepeats of section 3.1.1.5 |
decompressor::core::fse::transition | table_log ≤ 15, 1 ≤ p ≤ 2 ^ table_log, k < p, counter p + k | fseState table_log p k, the pair of Table 21 |
decompressor::core::block::BlockHeader::parse | none | BlockType for the reserved type, BlockSize above block_max, otherwise the Table 9 fields |
BlockHeader::content_size | none | blockContentSize: one byte for RLE |
compressor::core::cctx::write_block_header | 3 bytes of room, kind ≤ 2, size < 2^21 | the three bytes read little-endian are last + 2 kind + 8 size |
decompressor::core::sequences::sequence_count | none | Err(SequencesSection) exactly when numberOfSequences fails, otherwise its count and size |
compressor::core::block::write_sequence_count | 3 bytes of room, fewer than 0x7F00 + 0x10000 sequences | the shortest RFC form, which numberOfSequences reads back |
decompressor::core::frame::dictionary_id_size, described_window_size, header_size | none | Table 5, the window formula, the header size |
decompressor::core::frame::read_le | at most 8 bytes | le of the bytes |
decompressor::core::frame::FrameHeader::parse | none | Err(FrameHeader) exactly when parseFrameHeader fails, otherwise its fields |
compressor::core::cctx::write_frame_header | 18 bytes of room, 10 ≤ window_log ≤ 31 | the 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.leanof<[T]>::firstmatches the standard library; - Lean’s kernel and the three standard axioms;
Mzstd/Rfc.leantranscribes 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 setsblock_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 fillsFrameHeaderFields, and the parallel compressor’s own frame header writer (parallel::core) are outside the proofs. SeqTable::fill_rowsandspread, which calltransitionwith the counterp + kand 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_Sizeset byFrameDecoder::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_Headersize formats, both sides (literals.rson 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_Modesreserved bits and theRepeat_Moderules inread_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_BitsandBaselineof 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.rsread 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
| Workflow | Trigger | Checks |
|---|---|---|
ci.yml | push to main, pull requests | cargo 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.yml | documentation/code pushes to main, matching pull requests, manual | Python 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.yml | push to main, pull requests | scripts/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.yml | push to main, pull requests | profile_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.yml | push to main, pull requests | One 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.yml | workflow_run of hotpath benchmark or hotpath criterion | hotpath-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.yml | pull requests touching src/, zstd-ffi/, fuzz/, Cargo.toml, .clusterfuzzlite/, .dockerignore or the workflow | ClusterFuzzLite libFuzzer/ASan run on the changed code, 600 s. See ClusterFuzzLite. |
ci-cflite-cron.yml | daily, manual | ClusterFuzzLite batch fuzzing (5.5 h), corpus pruning and a coverage report. |
ci-coverage.yml | manual | cargo 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.yml | manual | Short 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.yml | v* tags, manual | prepare-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.yml | v* tags, manual | scripts/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.yml | v* tags, manual | Nightly -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.yml | manual from a v* tag | Tag/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_compress2into aZSTD_compressBoundbuffer againstCompressor::new(level).compress;ZSTD_getFrameContentSize+ZSTD_decompressinto an exactly sized buffer againstDecompressor::default().decompress. Buffers on the C side are uninitializedVeccapacity, 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.pyandlevel_docs.pyrefuse anything else. - Python unit tests (
python3 -m unittestinbenchmarks/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.pyrenders 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-ffiwithccdefaults (-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:
| Command | Behavior |
|---|---|
prepare | Recreate the staged source tree, navigation, and bundled diagram assets |
build | Prepare, build HTML, then check local links and heading anchors |
serve | Prepare, then serve the generated tree on loopback port 3000 |
check-links | Validate a previously built HTML tree |
test-examples | Compile and run Rust recipes in a separate Cargo consumer |
Source and link flow
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.
Navigation
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.
servewatches 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/:
| Artifact | Encoder | Decoder |
|---|---|---|
| Report | encoder-comparison.md | decoder-comparison.md |
| Measurements | encoder-comparison.csv | decoder-comparison.csv |
| Environment | encoder-comparison-environment.json | decoder-comparison-environment.json |
| Size manifest | encoder-comparison-<date>/sizes.csv | decoder-comparison-<date>/sizes.csv |
| Main branch’s run, when recorded | encoder-comparison-<date>/main.csv | decoder-comparison-<date>/main.csv |
| Index and level pages | encoders/README.md, encoders/l-5.md–l22.md | decoders/README.md, decoders/l-5.md–l22.md |
| Charts | encoders/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.
| Implementation | Version | Timed API |
|---|---|---|
| zstd C | 1.5.7, pinned submodule | ZSTD_createCCtx, ZSTD_CCtx_setParameter(level), ZSTD_compress2 into a ZSTD_compressBound buffer, ZSTD_freeCCtx |
| mzstd | Local checkout | Construct 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.
| Decoder | Version | Timed API |
|---|---|---|
| zstd C | 1.5.7, pinned submodule | ZSTD_getFrameContentSize, then ZSTD_decompress into an uninitialized buffer of exactly that size |
| mzstd | Local checkout | Construct 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 hotpathturns on thehotpath::measureanchors at the codec’s stage boundaries; a binary whosemaincarries#[hotpath::main]prints the timing table on exit. -
Sampling.
samply record --save-only --rate 20000 -o profile.json.gz -- <binary>samples a release build (needsperf_event_paranoid <= 1). The leaf addresses map ontoobjdump -doraddr2lineof 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=yesor--cache-sim=yes) on a build withCARGO_PROFILE_RELEASE_DEBUG=line-tables-onlygives exact instruction counts per function and source line;callgrind_annotatereads them. -
Instruction counts per stage. On x86_64 Linux,
hotpath-callgrindadds per-anchor instruction counts from Callgrind. Thehotpath benchmarkworkflow 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 byhotpath/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.rsandbenches/decompress.rsimport Criterion throughhotpath::criterion, so they can produce the same reports. WithHOTPATH_CRITERION_ITERATIONSset, one benchmark id runs exactly that many times in Criterion’s test mode and writes one hotpath report; without it,cargo benchis 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/mzstdThe
hotpath criterionworkflow 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 thecriterionmatrix ofhotpath-relay.yml. A group’sconfignameshotpath::criterion::Config, the plain Criterion type, since the adapter’sCriterionwrapper has nodefault(). -
Code.
cargo asm --lib --release '<symbol>'shows what a kernel compiled to; the SIMD-dispatched kernels appear asvectorize_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:
- Builds and validates the book on pull requests and pushes to
main. - Compiles and runs the documented recipes.
- Uploads the static book as a Pages artifact.
- Deploys only on
mainpushes or a manual run onmain.
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.