Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

OpenZL compression and decompression in safe Rust

mopenzl is a port of Meta's OpenZL v0.3.0, the format-aware compression framework, with a Rust-native API. Describe compression as a graph of codecs, feed it typed data, and decode any frame with one universal decoder. Serial frames are byte-identical to the C library's.

A round trip

[dependencies]
mopenzl = "0.2.3"
use mopenzl::{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(6)?))?;
    let frame = encoder.compress(input.as_bytes())?;

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

Requires Rust 1.89 or later. Without a graph the compressor runs the default, compress_generic, which uses Zstandard for serial bytes. Getting started continues from here.

Tell it the shape of your data

OpenZL’s advantage over a general-purpose compressor is typed input. Say that a buffer holds 32-bit integers, and a numeric graph transforms them before an entropy coder sees them:

use mopenzl::graph::{CompressorGraph, StandardGraph};
use mopenzl::input::{Input, Numbers};
use mopenzl::{Compressor, DataType, Decompressor, EncoderConfig};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    // 10 000 slowly growing 32-bit timestamps, little-endian.
    let values: Vec<u8> = (0..10_000u32)
        .flat_map(|i| (1_700_000_000 + i * 3).to_le_bytes())
        .collect();
    let numbers = Numbers::try_from((&values[..], 4))?;

    let mut compressor = Compressor::builder(EncoderConfig::default())
        .with_graph(CompressorGraph::standard(StandardGraph::SelectNumeric))
        .build()?;
    let frame = compressor.compress_inputs(&[Input::from(numbers)])?;
    println!("{} -> {} bytes", values.len(), frame.len());

    let outputs = Decompressor::default().decompress_typed(&frame)?;
    assert_eq!(outputs[0].data_type(), DataType::Numeric);
    assert_eq!(outputs[0].width(), 4);
    assert_eq!(outputs[0].content(), values);
    Ok(())
}

The decoder needs no graph: the frame records the codecs that produced it. Structured data covers numbers, records and strings.

Pick a graph

Your dataStart with
General bytesCompressGeneric, the default
Fastest decode, or already-compressed dataStore, Lz4
IntegersSelectNumeric
Fixed-width recordsFieldLz
StringsStringCompress
A known binary layoutSimpleDataDescriptionLanguage (SDDL)
Not sureBruteForce with your candidates; it keeps the smallest

Graphs has the full table, and codecs lists every node and the Cargo feature that enables it. You can also compose nodes yourself with GraphBuilder or register a custom codec.

Find your path

What you can rely on

  • Byte-identical serial encoder. For the same input, graph and global parameters, frames are exactly those of OpenZL v0.3.0 at format versions 21 to 27. See what is claimed and how it is checked.
  • Safe implementation, not a C wrapper. Production code forbids unsafe. Zstandard comes from the safe-Rust mzstd crate; every other codec is ported here. The 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.
  • Measured against C. The benchmarks show where mopenzl is faster than C and where it is still behind.

Framing requires the opt-in framed feature. The book tracks main, including unreleased changes; documented recipes are checked against the local checkout.

Getting started

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

Install

cargo new openzl-demo
cd openzl-demo
cargo add mopenzl@0.2.3

Or add this to Cargo.toml:

[dependencies]
mopenzl = "0.2.3"

Compress and decompress

Replace src/main.rs with this complete program:

use mopenzl::{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(5)?);
    let mut encoder = Compressor::new(config)?;
    let compressed = encoder.compress(input.as_bytes())?;

    let mut decoder = Decompressor::new(Default::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 6 and accepted values are -131072 through 22. Graphs interpret the level according to their codecs; numeric selection changes at level 7. Compare speed and output size on your data; the benchmark pages show results by graph.

Choose a graph or codec

OpenZL describes compression as a graph of codec nodes. The default serial path uses Zstandard; typed data can use numeric, record and string graphs. Install a standard graph with Compressor::builder(config).with_graph(...), or compose nodes through GraphBuilder. The decoder reads the codec layout from the frame. Start with graphs, codecs, and typed inputs before tuning the compression 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 mopenzl::{Compressor, Decompressor, EncoderConfig, Level};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let mut encoder = Compressor::new(
        EncoderConfig::default().with_level(Level::try_from(5)?),
    )?;
    let mut decoder = Decompressor::new(Default::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 OpenZL stream. Dropping the writer does not finalize it.

use mopenzl::{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(5)?),
    )?;
    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(mopenzl::io::FinishError::into_error)?;

    let mut decoder = Decompressor::new(Default::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, both directions and all-codecs. To build one direction, select individual codecs, drop std, or enable framed containers, see Cargo features. The book tracks main; use a local or Git dependency for APIs not yet released.

Next steps

Choose your API

Compression and decompression expose the same I/O shapes. Pick the row that matches how your application already moves bytes, then follow its guide. New to the crate? Start with getting started; to choose what runs inside a frame, read graphs.

Both codecs

I/O shapeCompressionDecompression
Return a new Vec<u8>Compressor::compressDecompressor::decompress
Append to an existing Veccompress_intodecompress_into
Write into a caller-owned slicecompress_to_slicedecompress_to_slice
Write into uninitialized memorycompress_to_uninitdecompress_to_uninit
Typed and multiple inputs / outputscompress_inputs, compress_inputs_intodecompress_typed → Vec<TypedOutput>
Choose the graph / codec nodesCompressor::builder(..).with_graph(..) / ParallelCompressor::builder(..).with_graph(..)— (the frame carries its own)
Pull output through std::io::ReadCompressor::readerDecompressor::reader
Push input through std::io::WriteCompressor::writerDecompressor::writer
Drive input/output incrementallystart → EncoderSessionstart → DecoderSession
Same, into uninitialized memoryEncoderSession::process_uninitDecoderSession::process_uninit
Same, with the session owning the codecinto_session → EncoderSessionOwnedinto_session → DecoderSessionOwned

The decoder needs no graph: a frame records the codecs that produced it. Compressor and Decompressor own reusable working state. Keep one alive across payloads when allocation reuse matters.

Parallel compression

ParallelCompressor lives in mopenzl::compressor::parallel and is unavailable with no_std. It splits one input into segments and hands them to your executor as tasks; the library does not create a thread pool. Streaming buffers compressed chunks until finish, when the total input size is known.

I/O shapeEntry point
Split one known input across your threadsprepare_slice / prepare_source → ScopedParallelBatch
Stream chunked input across your threadsstart → ParallelEncoderSession + ParallelTaskQueue
Same, into uninitialized memoryParallelEncoderSession::process_uninit
Same, with the session owning the compressorinto_session → ParallelEncoderSessionOwned
Pull parallel output through std::io::Readreader → ParallelEncoderReader + ParallelTaskQueue
Push input through std::io::Write across your threadswriter → ParallelEncoderWriter + ParallelTaskQueue

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

Guides by task

TaskRead
Choose graphs, selectors, segmenters or codec pipelinesGraphs
See what each codec does and which feature enables itCodecs
Compress integers, records or stringsStructured data
Implement and register an application-defined codecCustom codecs
Use a Zstandard dictionaryDictionaries
Set the level, format version, checksums or Cargo featuresConfiguration and features
Control allocations, or write into uninitialized memoryBuffers and memory reuse
Decode untrusted input with resource limitsDecompression and limits
Read a frame’s comment, version or output types without decodingFrame metadata
Understand readers, writers, sessions, and finishStreaming and completion
Connect synchronous I/ORead / Write I/O
Drive caller-owned buffers directlySans I/O compression and decompression
Schedule compression on your own threadsParallel compression
Use a different graph for each indexed resourceFraming containers and graph recipes (framed feature)
Copy a complete programExamples

Errors

ConfigError covers configuration, EncodeError covers compression (including EncodeError::Graph(GraphError) for graph failures), DecodeError covers decompression, graph::GraphError covers graph construction, and dict::DictError covers dictionaries. I/O adapters return std::io::Error; writer finalization preserves the writer in FinishError.

Reference

Examples

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

TaskRecipeFeatures / extra dependency
Choose graphs and compose codec nodesGraphs · CodecsDefault; per-codec features
Preserve typed and multiple inputsStructured dataDefault
Set the level, format version and checksumsEncoder settingsDefault
Write into a slice or uninitialized memoryOutput buffersDefault
Decode untrusted input with limitsSet limits for untrusted inputDefault
Read a frame header without decodingFrame metadataDefault
Compress and decompress bytesFirst round tripDefault
Reuse codec and destination memoryReuse the workspaceDefault
Write a raw stream incrementallyStream through WriteDefault
Drive a raw or framed encoder with tiny buffersSans I/O compressionDefault; framed for frames
Decode arriving chunks and resource eventsSans I/O decompressionDefault; framed for frames
Read or write raw, parallel, and framed streamsRead / Write I/ODefault; framed for frames
Use scoped CPU workersCompressing a sliceDefault
Reuse a Rayon thread poolRunning tasks with RayonDefault + rayon
Stream through parallel workersReader and writer adaptersDefault
Package compressed and stored resourcesOne container, two resourcesframed
Add resource names and write incrementallyWrite resources and metadataframed
Read just the selected resourceIndexed resource readsframed

Run from the repository

Initialize the C reference submodule used by development builds:

git submodule update --init --recursive
cargo run --release --example compress

The parallel example uses caller-owned scoped threads, reuses its compressor and verifies every assembled frame:

cargo run --release --example parallel

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. Finish ordinary/parallel writers and the enclosing framed container explicitly. Framed resource commands accept complete input rather than resource writer guards.

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 all user-guide chapters, including graphs, codecs, typed inputs, dictionaries, parallel, framing and Sans I/O. It does not exercise the codec with --all-features, because that would also enable no_std and remove the std APIs these recipes demonstrate.

Graphs

OpenZL compresses by routing typed streams through a graph of codecs. This guide explains how the graphs of this crate work, which standard graphs exist, and how to build your own with mopenzl::graph. Frame-level mechanics live in compression graphs.

Types · Standard graphs · GraphBuilder · Parameters · Selectors, function graphs, segmenters · Chunking · try_graph · Permissive mode · Sessions

Typed inputs and outputs

Every stream has a DataType:

TypeHoldsInput constructorDecoded as
Serialraw bytesInput::from(&[u8])TypedOutput::content()
Structfixed-width records of any widthInput::from(Records::try_from((bytes, width))?)content, width()
Numericlittle-endian unsigned integers of width 1, 2, 4 or 8Input::from(Numbers::try_from((bytes, width))?)content, width()
Stringvariable-length strings: concatenated bytes plus one u32 length eachInput::from(Strings::try_from((bytes, &lens))?)content, string_lens()

The constructors validate the layout (widths, whole elements, lengths summing to the content size) and return InputError otherwise. Numbers are always little-endian bytes. Input::with_metadata(&[(id, value)]) attaches integer metadata that graphs can read, such as the tag the clustering graph groups by; metadata is never written to the frame.

Compressor::compress_inputs(&[Input]) writes one frame from one or more inputs and Decompressor::decompress_typed returns one TypedOutput per input, in order. Compressor::compress is compress_inputs with a single serial input.

A node is one codec invocation: it takes typed streams and produces typed streams (delta_int: numeric to numeric; float32_deconstruct: numeric to a struct plus a serial; tokenize: any to an alphabet plus numeric indices). A graph is anything that accepts streams and decides what happens to them: a node plus successor graphs, a selector, a function graph, a segmenter, or store. Nodes do no implicit type conversion when you run them yourself; the engine converts implicitly only when a stream reaches store (numeric and struct data are converted to serial bytes first, which emits a conversion codec).

Standard graphs

StandardGraph names the graphs of the reference’s registry with their numeric IDs. CompressorGraph::standard(graph) (or CompressorGraph::from) makes a ready compressor graph, and Compressor::builder(config).with_graph(..) installs it. Without a graph the compressor runs the default: compress_generic.

use mopenzl::graph::{CompressorGraph, StandardGraph};
use mopenzl::input::{Input, Records, Strings};
use mopenzl::{Compressor, DataType, Decompressor, EncoderConfig};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let text = b"alpha beta gamma delta alpha beta gamma delta ".repeat(20);
    let lens: Vec<u32> = vec![46; 20];
    let records: Vec<u8> = (0..4000u32)
        .flat_map(|i| [(i % 7) as u8, 1, 2, 3])
        .collect();
    let inputs = [
        Input::from(&text[..]),
        Input::from(Records::try_from((&records[..], 4))?),
        Input::from(Strings::try_from((&text[..], &lens[..]))?),
    ];

    // `compress_generic` takes any number of inputs and routes each by type.
    let mut compressor = Compressor::builder(EncoderConfig::default())
        .with_graph(CompressorGraph::standard(StandardGraph::CompressGeneric))
        .build()?;
    let frame = compressor.compress_inputs(&inputs)?;

    let outputs = Decompressor::default().decompress_typed(&frame)?;
    let types: Vec<DataType> = outputs.iter().map(|o| o.data_type()).collect();
    assert_eq!(
        types,
        [DataType::Serial, DataType::Struct, DataType::String]
    );
    assert_eq!(outputs[0].content(), text);
    assert_eq!(outputs[1].width(), 4);
    assert_eq!(outputs[2].string_lens(), lens);
    Ok(())
}

Which graph when

Start with CompressGeneric (the default) and move to a specific graph when you know your data. S, St, N and T below mean serial, struct, numeric and string inputs. Graphs that take “any” accept several inputs.

NeedUse
General bytes, good ratioCompressGeneric (Zstandard for serial)
Fastest decode, or already-compressed dataStore, Lz4
Integers: pick automaticallySelectNumeric (the numeric transformer at level 7 and above)
Integers with structure, level 7+TransformerNumeric
Fixed-width recordsFieldLz; column-wise with a function graph and the TransposeSplit node (structured data)
StringsStringCompress; TryParseInt for numbers in text (needs successors)
Choose empiricallyBruteForce through GraphBuilder::brute_force
Data with a known layoutSimpleDataDescriptionLanguage (SDDL), SimpleDataDescriptionLanguageV2
Many inputs of related meaningClustering (group by tag)
Pick by a trained modelMlSelector
Bound memory on huge serial inputSegmentSerial via GraphBuilder::serial_segmenter or Chunking::Fixed

Public graphs

IDStandardGraphInputsWhat it does
2Storeany, severalStores every input without compression. Typed inputs are converted to serial first
3FseSFinite State Entropy coding
4HuffmanS, St, NHuffman coding of byte or 16-bit symbols; picks the Huffman variant
5HuffmanHuf0S, St, NHuffman with the zstd-style huffman_v2 coder
6HuffmanPivcoS, St, NHuffman with the pivoted-code coder (format 27)
7EntropyS, St, NChooses per 32 KiB chunk among constant, flatpack, bitpack, Huffman, FSE, tokenize and store from statistics
8ConstantS, St, NEncodes a stream of one repeated element as that element and a count. Fails at the node when the stream is not constant
9ZstdSThe Zstandard node, then store
10BitpackS, NPacks values into the fewest bits they need
11FlatpackSAlphabet plus packed indices for small alphabets
12FieldLzSt, NLZ over fixed-width fields (also for numeric data)
13CompressGenericany, severalDefault router: serial to Zstandard; struct to FieldLz; numeric to SelectNumeric; string to separated components, each compressed by type
14SelectGenericLzBackendSSelects the generic LZ backend (Zstandard)
15SegmentNumericNSegmenter: cuts numeric input every 16 MiB
16SelectNumericNBelow level 7 the struct route (FieldLz); from level 7 the numeric transformer
17MlSelectorNSends the input to the successor a gradient-boosted-tree model picks from statistics of the data
18Clusteringany, severalGroups inputs by (type, width, tag) into clusters compressed together
19TryParseIntTParses decimal strings as integers. Needs three successors: integers, strings that did not parse, indices
20SimpleDataDescriptionLanguageSParses serial data with a compiled SDDL description and sends the fields it cuts out to a successor
21SimpleDataDescriptionLanguageV2SThe SDDL2 segmenter: runs bytecode once, cuts chunks along its segments
22Lz4SLZ4 blocks
23PartitionBitpackNSplits the value range into partitions and bitpacks bucket indices (16- and 32-bit numbers)
24-27SegmentNum8FromSerial … SegmentNum64FromSerialSSegmenters that cut serial input aligned to the element width and compress each chunk as numeric
28LzSThe lz codec with entropy-coded literals, offsets and lengths
29SegmentSerialSSegmenter: cuts serial input every N bytes (default 16 MiB)
30TransformerNumericNThe numeric transformer: scores candidate transform chains with a trained network
31BruteForceanyTries every successor and keeps the smallest frame, or store

IDs from 32 on are private in the reference but exist here with stable numbers; they are useful as successors and for reading the registry:

IDsStandardGraphWhat they do
1, 32, 33SerialStore, Store1, StringStoreThe store primitives
34-39Compress1, SerialCompress, StructCompress, NumericCompress, StringCompress, StringSeparateCompressThe routers behind CompressGeneric
40-44BitpackSerial, BitpackInt, ConstantSerial, ConstantFixed, FseNcountNode-plus-store graphs
45, 46FieldLzLiterals, FieldLzLiteralsChannelLiteral handling of FieldLz
47-56DeltaHuffman, DeltaFlatpack, DeltaZstd, DeltaFieldLz, RangePack, RangePackZstd, TokenizeDeltaFieldLz and their *Internal formsStatic numeric pipelines
57-60, 62, 64SplitSerial, SplitStruct, SplitNumeric, SplitString, NToN, TransposeSplitSplit and transpose graphs
61Sddl2ChunkThe SDDL2 per-chunk replay graph
63MergeSortedMerges sorted runs
65-68InterpretNum8Compress … InterpretNum64CompressSerial read as numbers, then NumericCompress
69CompressSmallLengthsLengths that are mostly tiny with a few large values
70-90TransformerDeltaInt … TransformerNumeric1The numeric transformer’s static candidates and its model selector

Graphs that need successors

Some standard graphs route to graphs you must supply with GraphBuilder::parameterize_graph(graph, GraphParams::default().with_graphs([..])); run bare they fail (“needs exactly three custom graphs”). Each variant’s rustdoc says what it needs; in short:

GraphNeedsHelper
BruteForceone or more candidates (none stores)GraphBuilder::brute_force
MlSelectorone successor per class of its model, and the modelml_selector, untrained_ml_selector
Clusteringthe configuration, clustering nodes and the successors it indexesclustering, clustering_with_codecs
TryParseIntexactly three: integers (numeric), exceptions (strings), indicesnone
SimpleDataDescriptionLanguageexactly one, plus the compiled descriptionsddl
SimpleDataDescriptionLanguageV2zero or one (default CompressGeneric), plus the bytecodesddl2
SegmentSerialone per chunk (default CompressGeneric)serial_segmenter
SegmentNum8FromSerial … SegmentNum64FromSerialone per chunk, which receives serial data (default InterpretNumNCompress)num_from_serial_segmenter
SplitSerial, SplitStruct, SplitNumeric, SplitStringone custom node and one successor per node outputnone
NToNone successor per inputnone
MergeSortedexactly two: the merge graph, then the fallbacknone
TransposeSplitexactly five: widths 1, 2, 4, 8, then the split graphnone

FieldLz and Lz need none; their custom graphs are optional overrides picked by index through params::field_lz and params::lz. SegmentNumeric needs none. Every other standard graph is self-contained.

StandardGraph and StandardNode are #[non_exhaustive]; TryFrom<u32> and From<..> for u32 convert to and from the reference’s IDs. Every standard graph needs the codecs it uses: a graph whose codec feature is off fails like a failing codec (see permissive mode and codecs).

Building graphs

GraphBuilder starts with only the standard graphs and nodes and derives new ones. Each call returns a GraphId or NodeId that is meaningful to that builder only; build(start) freezes everything into a cheap-to-clone, thread-shareable CompressorGraph rooted at start.

CallCreates
custom_codec(id, codec)An application-defined node implementing CustomCodec (custom-codec feature, enabled by default)
static_graph(node, &[successors])A node whose outputs go to one successor each
pipeline(&[nodes], last)Nodes in a chain, each feeding the next, the last feeding last
parameterize_node(node, LocalParams) / parameterize_node_with(node, NodeParams)A node copy with new local parameters (and a dictionary)
parameterize_graph(graph, GraphParams)A graph copy with other parameters, successors, nodes or name
selector, function_graph, segmenterYour own Rust graphs
serial_segmenter, num_from_serial_segmenterThe standard segmenters, typed
brute_force, ml_selector, untrained_ml_selector, clustering, clustering_with_codecsConfigured standard graphs
sddl, sddl2The SDDL graphs with a description or bytecode
load_dictionariesAttaches a dictionary bundle
use mopenzl::graph::{GraphBuilder, StandardGraph, StandardNode};
use mopenzl::input::{Input, Numbers};
use mopenzl::{Compressor, Decompressor, EncoderConfig};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let mut builder = GraphBuilder::new();
    // delta -> zigzag -> bit-pack: three nodes in a chain, then store.
    let packed = builder.pipeline(
        &[
            StandardNode::DeltaInt.into(),
            StandardNode::Zigzag.into(),
            StandardNode::BitpackInt.into(),
        ],
        StandardGraph::Store,
    )?;
    let graph = builder.build(packed)?;

    let values: Vec<u8> = (0..2000u32)
        .flat_map(|i| (500 + i % 17).to_le_bytes())
        .collect();
    let numbers = Numbers::try_from((&values[..], 4))?;
    let mut compressor = Compressor::builder(EncoderConfig::default())
        .with_graph(graph)
        .build()?;
    let frame = compressor.compress_inputs(&[Input::from(numbers)])?;
    assert!(frame.len() < values.len() / 2);

    let outputs = Decompressor::default().decompress_typed(&frame)?;
    assert_eq!(outputs[0].content(), values);
    Ok(())
}

Builders report mistakes early: a successor count that does not match a node’s outputs, an unknown or foreign ID, an empty pipeline or an unknown start graph is a GraphError (Invalid, UnknownId). Failures while a graph runs surface from the compressor as EncodeError::Graph(GraphError::Execution(..)), or GraphError::User(..) when your own graph returned the error.

Parameterized nodes and graphs

Nodes and some graphs take local parameters: integer, byte-blob or object parameters keyed by the reference’s IDs. LocalParams::default().with_int(id, v) and .with_bytes(id, bytes) build them; giving a node or graph parameters replaces the ones it had. The global level reaches Zstandard unchanged, and the Zstandard node applies its own integer parameters after it, as raw ZSTD_c_* IDs (params::zstd::COMPRESSION_LEVEL is 100), in list order.

The IDs are the reference’s, and mopenzl::graph::params names them: one module per codec or graph (zstd, lz, lz4, field_lz, entropy, divide_by, bitunpack, partition, sparse_num, sentinel, tokenize, split, dispatch, mux_lengths, segmenters, sddl, sddl2, clustering, …). Each constant cites the C macro or enum it mirrors (ZL_ENTROPY_MIN_GAIN_BYTES_PID, ZL_LzParam_windowLog, …) and its doc says whether the parameter is an integer or a copy (bytes) parameter.

use mopenzl::graph::params::zstd;
use mopenzl::graph::{GraphBuilder, GraphParams, LocalParams, StandardGraph, StandardNode};
use mopenzl::{Compressor, Decompressor, EncoderConfig};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let mut builder = GraphBuilder::new();
    // The Zstandard node with its compression level pinned to 19.
    let zstd19 = builder.parameterize_node(
        StandardNode::Zstd,
        LocalParams::default().with_int(zstd::COMPRESSION_LEVEL, 19),
    )?;
    let strong = builder.static_graph(zstd19, &[StandardGraph::Store.into()])?;
    // A copy of `brute_force`, with its candidates replaced.
    let race = builder.parameterize_graph(
        StandardGraph::BruteForce,
        GraphParams::default()
            .with_graphs([
                strong,
                StandardGraph::Lz4.into(),
                StandardGraph::Store.into(),
            ])
            .with_name("zstd19-or-lz4"),
    )?;
    let graph = builder.build(race)?;

    let input = b"parameters replace, they do not merge ".repeat(500);
    let mut compressor = Compressor::builder(EncoderConfig::default())
        .with_graph(graph)
        .build()?;
    let frame = compressor.compress(&input)?;
    assert!(frame.len() < input.len() / 10);
    assert_eq!(Decompressor::default().decompress(&frame)?, input);
    Ok(())
}

GraphBuilder::brute_force(&[..]) is the shorthand for the parameterize_graph call above. LocalParams::with_string_lengths supplies the lengths of the set_string_lens node. The key local parameters of every codec are in codecs.

Custom graphs in Rust

Three traits mirror the reference’s callbacks. All are Send + Sync + 'static so a built graph is shareable between threads.

TraitRegistered withSeesDecides
SelectorGraphBuilder::selector(name, selector, TypeSet, &successors)One input (InputView) and its successorsWhich graph compresses it (returns a GraphId)
FunctionGraphGraphBuilder::function_graph(name, f, &input_types, last_variable, &successors, &nodes)Edge handles of its inputsRuns nodes (run_node) and gives every produced edge a destination (set_destination)
SegmenterGraphBuilder::segmenter(name, s, &input_types, &successors)The remaining() elements of each inputCuts chunks with process_chunk(&[n_elts], graph), which must consume everything

Selector

A selector looks at a stream and returns one of its successors (or any other graph of the compressor). SelectorContext::try_graph compresses the stream with a candidate and returns the resulting frame size, so a selector can measure instead of guess.

use mopenzl::graph::{
    GraphBuilder, GraphId, InputView, Selector, SelectorContext, StandardGraph, TypeSet,
};
use mopenzl::{Compressor, DataType, Decompressor, EncoderConfig};

/// Picks the successor that produces the smallest frame.
struct Smallest;

impl Selector for Smallest {
    fn select(
        &self,
        cx: &mut SelectorContext<'_>,
        input: InputView<'_>,
        successors: &[GraphId],
    ) -> GraphId {
        let mut best = successors[0];
        let mut best_size = usize::MAX;
        for &graph in successors {
            let size = cx.try_graph(&input, graph).unwrap_or(usize::MAX);
            if size < best_size {
                (best, best_size) = (graph, size);
            }
        }
        best
    }
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let mut builder = GraphBuilder::new();
    let selector = builder.selector(
        "smallest",
        Smallest,
        TypeSet::from(DataType::Serial),
        &[
            StandardGraph::Zstd.into(),
            StandardGraph::Lz4.into(),
            StandardGraph::Store.into(),
        ],
    )?;
    let graph = builder.build(selector)?;

    let input = b"selectors measure instead of guessing ".repeat(300);
    let mut compressor = Compressor::builder(EncoderConfig::default())
        .with_graph(graph)
        .build()?;
    let frame = compressor.compress(&input)?;
    assert_eq!(Decompressor::default().decompress(&frame)?, input);
    Ok(())
}

A selector cannot fail: return a graph that always works (such as Store) as the fallback. SelectorContext also exposes level(), format_version(), depth(), is_node_supported(node), local_int(id) and set_successor_params(..).

Function graph

A function graph gets its inputs as edges, runs nodes on them and assigns every resulting edge a destination graph. Nodes run at the moment you ask; successors run after the function returns, in the order of the edge handles (the inputs first, then node outputs in creation order), not in the order you call set_destination. An edge left without a destination is an error (or triggers the permissive fallback). GraphContext::data(edge) reads a stream, custom_graphs() and custom_nodes() return the lists you registered, and run_node_with and set_destination_with override parameters at run time. run_split_node, run_dispatch_node and run_dispatch_string_node run the split and dispatch nodes with their runtime instructions.

use mopenzl::graph::{
    Edge, FunctionGraph, GraphBuilder, GraphContext, GraphError, StandardGraph, StandardNode,
};
use mopenzl::input::{Input, Strings};
use mopenzl::{Compressor, DataType, Decompressor, EncoderConfig};

/// Separates strings into lengths and content, and compresses each its own way.
struct SplitStrings;

impl FunctionGraph for SplitStrings {
    fn compress(&self, cx: &mut GraphContext<'_>, inputs: &[Edge]) -> Result<(), GraphError> {
        let parts = cx.run_node(StandardNode::SeparateStringComponents, inputs)?;
        let graphs = cx.custom_graphs();
        cx.set_destination(&parts[0..1], graphs[0])?; // content
        cx.set_destination(&parts[1..2], graphs[1])?; // lengths
        Ok(())
    }
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let mut builder = GraphBuilder::new();
    let function = builder.function_graph(
        "split_strings",
        SplitStrings,
        &[DataType::String.into()],
        false,
        &[
            StandardGraph::Zstd.into(),
            StandardGraph::NumericCompress.into(),
        ],
        &[],
    )?;
    let graph = builder.build(function)?;

    let text = b"red green blue red green blue ".repeat(100);
    let lens: Vec<u32> = [3, 5, 4, 3, 5, 4]
        .iter()
        .cycle()
        .take(600)
        .copied()
        .collect();
    let content_len: u32 = lens.iter().sum();
    let strings = Strings::try_from((&text[..content_len as usize], &lens[..]))?;

    let mut compressor = Compressor::builder(EncoderConfig::default())
        .with_graph(graph)
        .build()?;
    let frame = compressor.compress_inputs(&[Input::from(strings)])?;
    let outputs = Decompressor::default().decompress_typed(&frame)?;
    assert_eq!(outputs[0].string_lens(), lens);
    assert_eq!(outputs[0].content(), &text[..content_len as usize]);
    Ok(())
}

Segmenter

A segmenter cuts the whole input into chunks, each compressed independently through a successor, and writes one frame. See chunking.

use mopenzl::graph::{GraphBuilder, GraphError, Segmenter, SegmenterContext, StandardGraph};
use mopenzl::{Compressor, DataType, Decompressor, EncoderConfig};

/// Cuts the input in thirds.
struct Thirds;

impl Segmenter for Thirds {
    fn segment(&self, cx: &mut SegmenterContext<'_>) -> Result<(), GraphError> {
        let total = cx.remaining()[0];
        let successor = cx.custom_graphs()[0];
        let third = total.div_ceil(3).max(1);
        let mut left = total;
        while left > 0 {
            let take = third.min(left);
            cx.process_chunk(&[take], successor)?;
            left -= take;
        }
        if total == 0 {
            cx.process_chunk(&[0], successor)?;
        }
        Ok(())
    }
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let mut builder = GraphBuilder::new();
    let thirds = builder.segmenter(
        "thirds",
        Thirds,
        &[DataType::Serial.into()],
        &[StandardGraph::CompressGeneric.into()],
    )?;
    let graph = builder.build(thirds)?;

    let input = b"three chunks, three independent graphs ".repeat(400);
    let mut compressor = Compressor::builder(EncoderConfig::default())
        .with_graph(graph)
        .build()?;
    let frame = compressor.compress(&input)?;
    assert_eq!(Decompressor::default().decompress(&frame)?, input);
    Ok(())
}

Chunking and segmenters

A frame is a sequence of chunks, each with its own codec layout. The default graph writes the whole input as one chunk (Chunking::Whole, which still splits every 2 GiB). A segmenter cuts several. The standard ones:

  • EncoderConfig::with_chunking(Chunking::Fixed(ChunkSize)) is the serial segmenter of the reference CLI: chunks of ChunkSize input bytes (32 KiB to i32::MAX, default 16 MiB), the last one shorter. It wraps whatever graph you installed.
  • GraphBuilder::serial_segmenter(chunk, successor) is the same as a start graph of your own, with a chosen successor per chunk. A start graph that is already a segmenter cannot be combined with Chunking::Fixed (an EncodeError::Graph).
  • GraphBuilder::num_from_serial_segmenter(width, chunk, successor) cuts serial input aligned to width-byte integers and compresses each chunk as numeric data (the default successor, InterpretNum8Compress to InterpretNum64Compress, reads each serial chunk as numbers and compresses them; a successor you pass receives the chunk as serial data, so it must accept serial input and convert it itself, for example a static graph that starts with ConvertSerialToNumLe32).
  • SegmentNumeric cuts numeric input every 16 MiB; the SDDL2 graph is a segmenter that cuts along the segments of its program.

Chunks cap memory: with a declared InputSize::Exact both the encoder session and the decoder work one chunk at a time. Chunking needs format version 21 or newer, which every supported version has.

use mopenzl::graph::GraphBuilder;
use mopenzl::{ChunkSize, Compressor, Decompressor, EncoderConfig};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let mut builder = GraphBuilder::new();
    // 8-byte integers, 64 KiB chunks; each chunk is read as numbers and compressed.
    let segmenter = builder.num_from_serial_segmenter(8, ChunkSize::try_from(64 << 10)?, None)?;
    let graph = builder.build(segmenter)?;

    let input: Vec<u8> = (0..40_000u64)
        .flat_map(|i| (i * 1000).to_le_bytes())
        .collect();
    let mut compressor = Compressor::builder(EncoderConfig::default())
        .with_graph(graph)
        .build()?;
    let frame = compressor.compress(&input)?;
    assert_eq!(Decompressor::default().decompress(&frame)?, input);
    Ok(())
}

Trial compression: try_graph

SelectorContext::try_graph(&input, graph) and GraphContext::try_graph(edge, graph) compress the stream with graph in a throwaway context (checksums off, no comment) and return the size of the complete frame it would produce, or an error when the graph fails. brute_force is built on it. Trials cost a full compression each; use them on the successors that matter. The selector example shows the idiom.

Permissive mode

Normally a failing node, graph or successor fails the compression. With EncoderConfig::with_permissive(true), a failing successor outside a segmenter is rolled back and its input recompressed with compress_generic, as in the reference. This is the net under graphs that cannot always apply: Constant on data that is not constant, an MlSelector whose model does not match its successors, a codec whose Cargo feature is off.

use mopenzl::graph::{CompressorGraph, StandardGraph};
use mopenzl::{Compressor, Decompressor, EncoderConfig};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let not_constant = b"this is certainly not one repeated byte".repeat(10);
    let graph = CompressorGraph::standard(StandardGraph::Constant);

    let mut strict = Compressor::builder(EncoderConfig::default())
        .with_graph(graph.clone())
        .build()?;
    assert!(strict.compress(&not_constant).is_err());

    let config = EncoderConfig::default().with_permissive(true);
    let mut permissive = Compressor::builder(config).with_graph(graph).build()?;
    let frame = permissive.compress(&not_constant)?;
    assert_eq!(Decompressor::default().decompress(&frame)?, not_constant);
    Ok(())
}

Two more nets always apply: a lone stream below min_stream_size (10) is stored, and, with store_on_expansion (the default), a chunk that would not shrink is rebuilt as store.

Sessions and graphs

Compressor::start and the std::io writer work with any graph, with these rules:

  • The default graph and a serial segmenter (including Chunking::Fixed) compress incrementally, chunk by chunk, and match the one-shot bytes.
  • Every other graph is buffered: the session holds its input until Operation::End, runs the graph once and then drains the output. A declared InputSize::Exact is checked at the end.
  • Typed and multiple inputs go through compress_inputs; sessions, readers and writers take serial bytes.

See streaming.

Codecs

A codec is the decoder half of a node: every standard OpenZL transform or coder has a node (the encoder side, a StandardNode) and a decoder in this crate, and both live behind one Cargo feature. This page lists them. Wire formats, invariants and the deviations on malformed data are in decoder codecs; how nodes are wired into graphs is in graphs.

Types are serial (bytes), struct (fixed-width records), numeric (little-endian integers) and string. “Since” is the first format version that has the node. The crate writes versions 21 to 27, so a node with a “since” of 21 or less exists at every version you can select; nodes introduced at 23, 24, 26 or 27 (lz4, lz, the bit-split family, partition, sentinel, sparse_num, pivco_huffman) are not available when you pick an older format version.

Features

all-codecs (in default) enables every feature below. Without a codec’s feature the encoder never writes it (a chunk that would use it is stored; a graph that runs its node fails, falling back only in permissive mode) and the decoder reports frames that use it as DecodeError::Unsupported. The decoder parses the chunk layout in full either way, so the error names the missing codec before any stream is decoded.

FeatureNodesCodec IDs
custom-codecapplication-defined nodes implementing CustomCodecapplication-defined u32 IDs in a separate namespace
zstdzstd22
conversionthe type conversions and string separation5-14
deltadelta_int1
zigzagzigzag3
divide-bydivide_by53
quantizequantize_offsets, quantize_lengths25, 26
range-packrange_pack35
bitsplitbitsplit_*, bit_split63
float-deconstructfloat32_, bfloat16_, float16_deconstruct33
sparse-numsparse_num, sparse_num_auto66
merge-sortedmerge_sorted43
constantconstant_serial, constant_fixed44, 45
prefixprefix46
transposetranspose_split2, 4, 30-32
splitsplitN, splitN_struct, splitN_num, split_byrange40, 47, 48
split-by-structsplit_by_struct41
dispatchdispatchN_byTag, dispatch_string42, 54
concatconcat_serial, _num, _struct, _string55, 57-59
dedupdedup_num, dedup_num_trusted56
interleaveinterleave_string61
mux-lengthsmux_lengths65
sentinelsentinel_byte, sentinel_num18
bitpackbitpack_serial, bitpack_int27, 28
bitunpackbitunpack34
flatpackflatpack29
tokenizetokenize_*36-38
parse-intparse_int60
partitionpartition64
entropyfse_v2, huffman_v2, huffman_struct_v2, fse_ncount49-52
pivco-huffmanpivco_huffman67
lz4lz462
lzlz19
field-lzfield_lz24

Feature names with a hyphen are Cargo features (field-lz); the library selects them independently, so features = ["std", "decompression", "zstd", "delta"] decodes only those codecs. Several standard graphs need more than one feature (the entropy graph uses most coders); a missing one makes the graph fail at run time. The scripts/check_codec_features.py script builds every combination.

Nodes

StandardNode::X below is the variant name for the registry ID; run a node from a function graph with GraphContext::run_node, or in a chain with GraphBuilder::pipeline. “Key parameters” are local parameters (see parameterized nodes) whose IDs are the reference’s; nodes not listed take none that matter.

Numeric transforms

IDStandardNodeFeatureSinceInOutWhat it does and key parameters
2DeltaIntdelta3numericnumericReplaces values with differences from their predecessor; the first value goes in the header
4Zigzagzigzag3numericnumericMaps signed-looking values to small unsigned ones
31DivideBydivide-by16numericnumericDivides every value by a common divisor. Copy parameter params::divide_by::DIVIDE_BY (112): the divisor as 8 little-endian bytes; absent or zero means the greatest common divisor
28RangePackrange-pack8numericnumericRebases values on their minimum into a narrower width
43, 44QuantizeOffsets, QuantizeLengthsquantize3numericnumeric, serialSplits values into a small code and extra bits (LZ offsets and lengths)
45-47, 75BitsplitTop8, BitsplitFp, BitsplitBf16, BitSplitbitsplit24numericvariable numericSplits each number into bit-field streams. Copy parameter 701 sets the field widths of BitSplit
6-8Float32Deconstruct, Bfloat16Deconstruct, Float16Deconstructfloat-deconstruct4 (5 for the 16-bit ones)numericstruct, serialSplits floats into sign+fraction and exponent streams
54, 55SparseNum, SparseNumAutosparse-num26numericnumeric, numericMostly-one-value data: the exceptions and their positions. Copy parameter params::sparse_num::DOMINANT_VALUE (132, 8 bytes) gives the dominant value of SparseNumAuto
50, 51SentinelByte, SentinelNumsentinel24numericnumeric, numericExceptional values behind a sentinel. Copy parameter params::sentinel::INDICES (130) holds the exception indices, params::sentinel::VALUE (131) the sentinel
29MergeSortedmerge-sorted9numericnumeric, numericMerges up to 64 sorted runs into one sorted stream plus a bitset
48Partitionpartition24numericnumeric, serialBucket IDs plus offsets within value-range partitions. Int parameter params::partition::PRESET (120) picks a preset; copy parameter params::partition::CUSTOM (121) holds a custom start value and partition sizes
53MuxLengthsmux-lengths24numeric, numericserial, numericMultiplexes the two length streams of lz
63, 64BitpackSerial, BitpackIntbitpack3serial / numericserialPacks values into the fewest bits the maximum needs
27Bitunpackbitunpack6serialnumericReads bit-packed bytes as numbers of a chosen bit width (the inverse of BitpackInt’s packing)
38ParseIntparse-int19stringnumericParses canonical decimal strings as 64-bit integers

Dictionary-style transforms

IDStandardNodeFeatureSinceInOutWhat it does and key parameters
40-42TokenizeStruct, TokenizeNumeric, TokenizeStringtokenize8 (11 for strings)struct / numeric / stringalphabet, numeric indicesAlphabet plus an index per element. Int parameter params::tokenize::SORT (0) non-zero sorts the alphabet
72, 73TokenizeSorted, TokenizeStringSortedtokenize8 / 11numeric / stringalphabet, numeric indicesThe same with sorting preset
65Flatpackflatpack3serialserial, serialAlphabet of at most 256 symbols plus packed indices
30Prefixprefix11stringstring, numericShared prefixes with the previous string, as match sizes plus suffixes
70, 71ConstantSerial, ConstantFixedconstant11serial / structone elementA stream of one repeated element, as the element and a count
37, 74DedupNum, DedupNumTrusteddedup16variable numericnumericIdentical inputs stored once. Int parameter 9438 = 1 skips the verification (DedupNumTrusted)

Splitting, merging and routing

IDStandardNodeFeatureSinceInOutWhat it does and key parameters
66-68SplitN, SplitNStruct, SplitNNumsplit9 (15 for struct and numeric)serial / struct / numericvariable same typeCuts into consecutive segments. Segment sizes are a reference parameter; run it with GraphContext::run_split_node
49SplitByrangesplit24numericvariable numericSplits by value range. Int parameter params::split::BYRANGE_MIN_SEGMENT_SIZE (324) is the minimum segment size
69SplitByStructsplit-by-struct9serialvariable structOne stream per field of a repeated record. Copy parameter 387 holds the field sizes
5DispatchNByTagdispatch9serialtags, sizes, variable serialRoutes segments to streams by tag; run it with GraphContext::run_dispatch_node and DispatchInstructions
32DispatchStringdispatch16stringindices, variable stringRoutes strings to streams by index; GraphContext::run_dispatch_string_node. Int parameter 47 is the number of outputs
33-36ConcatSerial, ConcatNum, ConcatStruct, ConcatStringconcat16-18variable same typesizes, concatenationConcatenates inputs, remembering their sizes (used by clustering)
39InterleaveStringinterleave20variable stringstringRound-robin interleaving of string streams
3TransposeSplittranspose11structvariable serialColumn-wise bytes of the records

Type conversions

The conversion feature covers IDs 10-26 and 57. They change the type of a stream, not its bytes, and appear in frames whenever typed data meets a serial-only codec.

IDStandardNodeInOutNotes
10ConvertStructToSerialstructserial
11, 12ConvertNumToStructLe, ConvertNumToSerialLenumericstruct / serial
13-16ConvertSerialToStruct, ...Struct2, ...Struct4, ...Struct8serialstructInt parameter 1 is the record width; the 2/4/8 variants preset it
17, 18ConvertStructToNumLe, ConvertStructToNumBestructnumericBig-endian from format 21
19-22ConvertSerialToNum8, ...NumLe16, ...NumLe32, ...NumLe64serialnumeric
23-25ConvertSerialToNumBe16, ...Be32, ...Be64serialnumericFormat 21 and newer
26SeparateStringComponentsstringserial (content), numeric (lengths)
57SetStringLensserialstringLengths come from LocalParams::with_string_lengths

Entropy coders

IDStandardNodeFeatureSinceInOutWhat it does
58FseV2entropy15serialnumeric counts, serial bitsFinite State Entropy
59HuffmanV2entropy15serialnumeric weights, serial bitsHuffman (zstd’s huf)
60HuffmanStructV2entropy15struct (width 2)numeric weights, serial bitsHuffman over 16-bit symbols
61FseNcountentropy15numericserialSerializes FSE count tables
87PivcoHuffmanpivco-huffman27serialnumeric weights, serial bitsPivoted-code Huffman

These come from OpenZL’s own FSE and Huffman fork, not from mzstd.

LZ and general-purpose coders

IDStandardNodeFeatureSinceInOutWhat it does and key parameters
62Zstdzstd3serialserialZstandard magicless frame via mzstd. Integer parameters are raw ZSTD_c_* IDs, named by params::zstd (COMPRESSION_LEVEL, WINDOW_LOG, …), applied after the global level; params::zstd::FORMAT and params::zstd::CONTENT_SIZE_FLAG are refused, as in the reference
86Lz4lz423serialserialLZ4 blocks (fast, high-compression and optimal parsers ported from lz4 1.10.0). Int parameter params::lz4::COMPRESSION_LEVEL_OVERRIDE (0) overrides the level
52Lzlz24serialliterals, offsets, literal lengths, match lengthsThe generic LZ codec; the Lz graph entropy-codes the four streams
9FieldLzfield-lz3structliterals, tokens, offsets, extra literal and match lengthsLZ over fixed-width fields. Int parameter params::field_lz::COMPRESSION_LEVEL_OVERRIDE (181) overrides the compression level (1-3 fast finder, 4 and up greedy)
use mopenzl::graph::params::{divide_by, zstd};
use mopenzl::graph::{GraphBuilder, LocalParams, StandardGraph, StandardNode};
use mopenzl::input::{Input, Numbers};
use mopenzl::{Compressor, Decompressor, EncoderConfig};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let mut builder = GraphBuilder::new();
    // divide_by with an explicit divisor of 1000, then zstd at level 19.
    let divide = builder.parameterize_node(
        StandardNode::DivideBy,
        LocalParams::default().with_bytes(divide_by::DIVIDE_BY, 1000u64.to_le_bytes().to_vec()),
    )?;
    let zstd19 = builder.parameterize_node(
        StandardNode::Zstd,
        LocalParams::default().with_int(zstd::COMPRESSION_LEVEL, 19),
    )?;
    let to_bytes = builder.static_graph(zstd19, &[StandardGraph::Store.into()])?;
    let start = builder.pipeline(
        &[divide, StandardNode::ConvertNumToSerialLe.into()],
        to_bytes,
    )?;
    let graph = builder.build(start)?;

    let values: Vec<u8> = (0..4096u32)
        .flat_map(|i| (i * 1000).to_le_bytes())
        .collect();
    let numbers = Numbers::try_from((&values[..], 4))?;
    let mut compressor = Compressor::builder(EncoderConfig::default())
        .with_graph(graph)
        .build()?;
    let frame = compressor.compress_inputs(&[Input::from(numbers)])?;
    let outputs = Decompressor::default().decompress_typed(&frame)?;
    assert_eq!(outputs[0].content(), values);
    Ok(())
}

Custom codecs

Application-defined codecs are supported for compression and decompression at format versions 21 to 27. The custom-codec feature is included in all-codecs and enabled by default; it also works with no_std and alloc.

Implement custom_codec::CustomCodec:

  • signature() declares the encoder’s input types, mandatory outputs and variable ports. Streams can be serial bytes, fixed-width records, numbers or strings. Numeric content is little-endian.
  • encode() reads borrowed CodecInput views, commits outputs through EncoderContext::emit or emit_strings, and can send a private header with send_header. Every mandatory output must be committed exactly once.
  • decode() receives the encoder’s output streams and private header, then restores the original inputs through DecoderContext::emit or emit_strings. The context validates widths, string lengths and the per-stream output budget.

Register the encoder with GraphBuilder::custom_codec(id, codec) and use the returned NodeId in a static graph, pipeline or function graph. Register the matching decoder with Decompressor::builder(config).with_custom_codec(id, codec)?. Both sides must agree on the wire ID, signature and encoded representation. Custom u32 IDs occupy a separate namespace from standard codec IDs; duplicate custom decoder IDs are rejected.

Custom nodes support parameterization and the regular one-shot and streaming APIs. Minimum-stream-size and store-on-expansion rules still apply: a small or expanding chunk may be stored instead of using the custom codec. Callbacks and signatures remain registered across operations and buffer trimming.

A frame carries the custom ID and its data, so the receiving application must register the decoder implementation. An unregistered ID, or a build without custom-codec, reports DecodeError::Unsupported { feature: Unsupported::CustomCodec { id } }. Custom dictionary materializers are not implemented.

See the API example for a complete implementation and registration example, and the custom codec specification for ownership, wire format, validation and error propagation.

Legacy codecs

Legacy codecs whose newest format version is below 21 (rolz, fastlz, zstd_fixed and the deprecated FSE and Huffman nodes) are not registered and decode as Unsupported::Codec.

Structured data

OpenZL’s advantage over a general-purpose compressor is knowing the shape of the data. Tell it that a buffer is an array of 32-bit timestamps, 16-byte records or a column of strings, and a graph can split, transform and entropy-code each part with the codec that fits. This guide covers the typed inputs, the standard graphs that fit each shape, clustering of related inputs, ML-selected successors and the SDDL description languages. Graphs explains the graph machinery.

Always decode typed frames with Decompressor::decompress_typed: the byte-oriented methods read one serial output.

Numeric data

Numbers::try_from((bytes, width)) wraps little-endian unsigned integers of width 1, 2, 4 or 8. For signed or floating-point data pass the bit patterns; the numeric codecs treat them as unsigned (zigzag, float32_deconstruct and friends are the bridges). Pick a graph by effort:

GraphBehavior
SelectNumeric (also what CompressGeneric uses)Below level 7, FieldLz; from level 7, the numeric transformer
TransformerNumericThe numeric transformer: scores candidate chains (delta, range pack, tokenize, GCD, sparse, …) with a trained network and runs the best
FieldLzLZ over fixed-width fields
Entropy, Huffman, FseEntropy coding of the values (width 1 and 2 directly)
Bitpack, PartitionBitpackNarrow values; partitioned value ranges for 16- and 32-bit numbers
ConstantOne repeated value
BruteForceWhichever of your candidates is smallest

Higher levels cost more time: the transformer is only used from level 7, and it is a model’s best guess, not a guarantee: measure on your data (the example below prints the sizes).

use mopenzl::graph::{CompressorGraph, StandardGraph};
use mopenzl::input::{Input, Numbers};
use mopenzl::{Compressor, EncoderConfig, Level};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    // A slowly growing counter with a little noise, 64-bit.
    let values: Vec<u8> = (0..20_000u64)
        .flat_map(|i| (1_000_000 + i * 16 + (i * 2654435761) % 5).to_le_bytes())
        .collect();
    let input = Input::from(Numbers::try_from((&values[..], 8))?);

    for (name, graph, level) in [
        ("zstd", StandardGraph::Zstd, 6),
        ("field_lz", StandardGraph::FieldLz, 6),
        ("select_numeric, level 6", StandardGraph::SelectNumeric, 6),
        ("select_numeric, level 10", StandardGraph::SelectNumeric, 10),
    ] {
        let config = EncoderConfig::default().with_level(Level::try_from(level)?);
        let mut compressor = Compressor::builder(config)
            .with_graph(CompressorGraph::standard(graph))
            .build()?;
        // `Zstd` takes serial input and nodes do no implicit conversion, so it is
        // shown on the raw bytes.
        let frame = if graph == StandardGraph::Zstd {
            compressor.compress(&values)?
        } else {
            compressor.compress_inputs(&[input])?
        };
        println!("{name}: {} -> {} bytes", values.len(), frame.len());
    }
    Ok(())
}

Fixed-width records

Records::try_from((bytes, width)) wraps records of any width. Record-aware graphs treat each byte position of a record as its own column:

GraphBehavior
StructCompress (what CompressGeneric uses)FieldLz over the records
FieldLzLZ over whole records
TransposeSplitColumn-wise bytes, but it needs five successors (graphs that need successors); a function graph with the TransposeSplit node is simpler
Entropy, HuffmanEntropy coding of 16-bit records
ConstantOne repeated record

To compress each byte column of the records on its own, run the TransposeSplit node (one serial stream per byte of the record) from a function graph and send every column to a successor. GraphContext::run_split_node and the split_by_struct node cut records into fields of different widths, and SDDL describes whole layouts.

use mopenzl::graph::{
    Edge, FunctionGraph, GraphBuilder, GraphContext, GraphError, StandardGraph, StandardNode,
};
use mopenzl::input::{Input, Records};
use mopenzl::{Compressor, DataType, Decompressor, EncoderConfig};

/// One serial stream per byte of the record, each compressed on its own.
struct Columns;

impl FunctionGraph for Columns {
    fn compress(&self, cx: &mut GraphContext<'_>, inputs: &[Edge]) -> Result<(), GraphError> {
        let columns = cx.run_node(StandardNode::TransposeSplit, inputs)?;
        let successor = cx.custom_graphs()[0];
        for column in columns {
            cx.set_destination(&[column], successor)?;
        }
        Ok(())
    }
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    // 16-byte records: a counter, a small enum, a constant tail.
    let mut bytes = Vec::new();
    for i in 0..5_000u32 {
        bytes.extend_from_slice(&i.to_le_bytes());
        bytes.extend_from_slice(&(i % 3).to_le_bytes());
        bytes.extend_from_slice(&[0xAB; 8]);
    }
    let records = Records::try_from((&bytes[..], 16))?;

    let mut builder = GraphBuilder::new();
    let columns = builder.function_graph(
        "columns",
        Columns,
        &[DataType::Struct.into()],
        false,
        &[StandardGraph::Zstd.into()],
        &[],
    )?;
    let graph = builder.build(columns)?;
    let mut compressor = Compressor::builder(EncoderConfig::default())
        .with_graph(graph)
        .build()?;
    let frame = compressor.compress_inputs(&[Input::from(records)])?;
    println!("{} -> {} bytes", bytes.len(), frame.len());

    let outputs = Decompressor::default().decompress_typed(&frame)?;
    assert_eq!(outputs[0].data_type(), DataType::Struct);
    assert_eq!(outputs[0].width(), 16);
    assert_eq!(outputs[0].content(), bytes);
    Ok(())
}

Strings

Strings::try_from((content, &lengths)) pairs concatenated bytes with one u32 length per string; the lengths must sum to the content size. StringCompress (what CompressGeneric uses) separates the two components and compresses the content as serial and the lengths as numeric. TryParseInt parses columns of decimal numbers as integers when they are canonical decimals; it takes three successors you provide (GraphParams::with_graphs): the integers, the strings that did not parse, and the indices that tell the two apart. The tokenize_string nodes build an alphabet for columns of repeated values.

use mopenzl::graph::{GraphBuilder, GraphParams, StandardGraph};
use mopenzl::input::{Input, Strings};
use mopenzl::{Compressor, DataType, Decompressor, EncoderConfig};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    // A column of numbers written as text.
    let column: Vec<String> = (0..3_000u32)
        .map(|i| (10_000 + i * 7).to_string())
        .collect();
    let content: Vec<u8> = column.concat().into_bytes();
    let lens: Vec<u32> = column.iter().map(|s| s.len() as u32).collect();
    let strings = Strings::try_from((&content[..], &lens[..]))?;

    // successors: [integers, strings that did not parse, indices]
    let mut builder = GraphBuilder::new();
    let parse = builder.parameterize_graph(
        StandardGraph::TryParseInt,
        GraphParams::default().with_graphs([
            StandardGraph::NumericCompress.into(),
            StandardGraph::StringCompress.into(),
            StandardGraph::NumericCompress.into(),
        ]),
    )?;
    let mut compressor = Compressor::builder(EncoderConfig::default())
        .with_graph(builder.build(parse)?)
        .build()?;
    let frame = compressor.compress_inputs(&[Input::from(strings)])?;
    println!("{} -> {} bytes", content.len(), frame.len());

    let outputs = Decompressor::default().decompress_typed(&frame)?;
    assert_eq!(outputs[0].data_type(), DataType::String);
    assert_eq!(outputs[0].string_lens(), lens);
    assert_eq!(outputs[0].content(), content);
    Ok(())
}

A frame may carry several inputs of different types. The Clustering graph groups them by (type, element width, tag), concatenates each group with a concat node and compresses it with the successor its cluster names, so inputs that look alike compress together. Tags are integer metadata with id CLUSTERING_TAG_METADATA_ID attached with Input::with_metadata; metadata never reaches the frame.

ClusteringConfig describes the clusters:

  • with_cluster(type, width, successor, codec, tags) makes a cluster of the inputs of that type and width whose tag is in tags. successor and codec index the successor graphs and clustering codecs given to the builder (clustering uses the four standard concat nodes: serial 0, struct 1, numeric 2, string 3).
  • with_type_default(type, width, successor, codec) handles inputs of that type whose tag no cluster lists.
  • Inputs matching neither form their own cluster, compressed by CompressGeneric.
  • The width of a serial input is 1 and that of a string input 0.

The configuration serializes to the reference’s CBOR blob (Vec::from(&config), ClusteringConfig::try_from(&bytes[..])), so a configuration trained elsewhere loads here.

use mopenzl::graph::{CLUSTERING_TAG_METADATA_ID, ClusteringConfig, GraphBuilder, StandardGraph};
use mopenzl::input::{Input, Numbers};
use mopenzl::{Compressor, DataType, Decompressor, EncoderConfig};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let latency: Vec<u8> = (0..4000u32)
        .flat_map(|i| (20 + i % 9).to_le_bytes())
        .collect();
    let latency_p99: Vec<u8> = (0..4000u32)
        .flat_map(|i| (90 + i % 13).to_le_bytes())
        .collect();
    let ids: Vec<u8> = (0..4000u32)
        .flat_map(|i| (1_000_000 + i).to_le_bytes())
        .collect();
    let tags = [
        [(CLUSTERING_TAG_METADATA_ID, 1)],
        [(CLUSTERING_TAG_METADATA_ID, 2)],
        [(CLUSTERING_TAG_METADATA_ID, 3)],
    ];
    let inputs = [
        Input::from(Numbers::try_from((&latency[..], 4))?).with_metadata(&tags[0]),
        Input::from(Numbers::try_from((&latency_p99[..], 4))?).with_metadata(&tags[1]),
        Input::from(Numbers::try_from((&ids[..], 4))?).with_metadata(&tags[2]),
    ];

    // Tags 1 and 2 share a cluster (successor 0); tag 3 falls to the type default.
    let config = ClusteringConfig::default()
        .with_cluster(DataType::Numeric, 4, 0, 2, [1, 2])
        .with_type_default(DataType::Numeric, 4, 1, 2);
    let mut builder = GraphBuilder::new();
    let clustering = builder.clustering(
        &config,
        &[
            StandardGraph::SelectNumeric.into(),
            StandardGraph::FieldLz.into(),
        ],
    )?;
    let graph = builder.build(clustering)?;

    let mut compressor = Compressor::builder(EncoderConfig::default())
        .with_graph(graph)
        .build()?;
    let frame = compressor.compress_inputs(&inputs)?;

    let outputs = Decompressor::default().decompress_typed(&frame)?;
    assert_eq!(outputs.len(), 3);
    assert_eq!(outputs[0].content(), latency);
    assert_eq!(outputs[1].content(), latency_p99);
    assert_eq!(outputs[2].content(), ids);
    Ok(())
}

ML selectors

MlSelector sends a numeric input to the successor a gradient-boosted-tree model picks from statistics of the data. The statistics (features) are nbElts, eltWidth, cardinality, cardinality_upper, cardinality_lower, range_size, mean, variance, stddev, skewness and kurtosis; a name outside this list reads as missing.

MlSelectorConfig wraps the model:

  • MlSelectorConfig::try_from(&GbtModel) validates a model as the reference does and serializes it; MlSelectorConfig::try_from(&blob[..]) loads a trained CBOR blob (the model is not validated, so a bad one may fail when the graph runs).
  • A model with one forest is binary (two successors; class 1 when the summed leaves are not negative); otherwise there is one forest per successor and the highest score wins. GbtNode::split(feature, threshold, left, right, missing) and GbtNode::leaf(score) build trees; a feature strictly below its threshold goes left, and children must come after their parent.
  • GraphBuilder::untrained_ml_selector(&successors) always picks the first successor. The number of successors must match the model when the graph runs; otherwise it fails (and a permissive compression falls back to compress_generic).
use mopenzl::graph::{GbtModel, GbtNode, GraphBuilder, MlSelectorConfig, StandardGraph};
use mopenzl::input::{Input, Numbers};
use mopenzl::{Compressor, Decompressor, EncoderConfig};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    // One tree on `nbElts`: fewer than 64 elements -> class 0 (store), else class 1.
    let tree = vec![
        GbtNode::split(0, 64.0, 1, 2, 1),
        GbtNode::leaf(-1.0),
        GbtNode::leaf(1.0),
    ];
    let model = GbtModel {
        forests: vec![vec![tree]],
        successors: 2,
        features: vec!["nbElts".to_string()],
    };
    let config = MlSelectorConfig::try_from(&model)?;

    let mut builder = GraphBuilder::new();
    let selector = builder.ml_selector(
        &config,
        &[StandardGraph::Store.into(), StandardGraph::FieldLz.into()],
    )?;
    let graph = builder.build(selector)?;
    let mut compressor = Compressor::builder(EncoderConfig::default())
        .with_graph(graph)
        .build()?;

    let mut decoder = Decompressor::default();
    for count in [16u32, 4096] {
        let values: Vec<u8> = (0..count).flat_map(|i| (i % 5).to_le_bytes()).collect();
        let numbers = Numbers::try_from((&values[..], 4))?;
        let frame = compressor.compress_inputs(&[Input::from(numbers)])?;
        println!("{count} values -> {} bytes", frame.len());
        assert_eq!(decoder.decompress_typed(&frame)?[0].content(), values);
    }
    Ok(())
}

SDDL and SDDL2

The Simple Data Description Language describes the layout of a binary format so a graph can cut a buffer into typed fields: the integers of a header, the records of an array, the floats of a column. The reference compiles descriptions with C++ tools that are not part of this crate; mopenzl takes the compiled program.

  • SDDL (GraphBuilder::sddl(&description, successor)): the description is the CBOR program the SDDL compiler writes ({"exprs": [...]}). The graph runs it, dispatches the fields it cuts out to destinations, converts them to numeric where the description says so, and hands every stream (tags, segment sizes, then one per destination) to successor as one multi-input destination. An invalid program is a GraphError::Invalid at build time; a description that does not match the data fails when compressing (and falls back only in permissive mode).
  • SDDL2 (GraphBuilder::sddl2(&bytecode, chunk_size, successor)): the segmenter runs bytecode (32-bit little-endian words) once over the input, cuts chunks of about chunk_size bytes (0 = the 16 MiB default) along the segments the program defines, and compresses each chunk’s typed fields with successor (default CompressGeneric). The bytecode is validated when the data is compressed.

The example below builds a tiny SDDL description by hand — “an array of 1000 little-endian u32 fields, sent to destination d0” — and a minimal SDDL2 program.

use mopenzl::graph::{GraphBuilder, StandardGraph};
use mopenzl::{Compressor, Decompressor, EncoderConfig};

// A minimal CBOR writer: just the shapes the program needs.
fn head(major: u8, n: usize) -> Vec<u8> {
    assert!(n < 24);
    vec![major << 5 | n as u8]
}
fn text(s: &str) -> Vec<u8> {
    [head(3, s.len()), s.as_bytes().to_vec()].concat()
}
fn map1(key: &str, value: Vec<u8>) -> Vec<u8> {
    [head(5, 1), text(key), value].concat()
}
fn array(items: &[Vec<u8>]) -> Vec<u8> {
    [head(4, items.len()), items.concat()].concat()
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let records = 1000u32;
    // d0 = dest; consume(array(send(atom("u4l"), d0), int(records)))
    let var_d0 = map1("var", text("d0"));
    let assign = map1("assign", array(&[var_d0.clone(), map1("dest", vec![0xF6])]));
    let count = map1(
        "int",
        [vec![0x19], (records as u16).to_be_bytes().to_vec()].concat(),
    );
    let field = map1("send", array(&[map1("atom", text("u4l")), var_d0]));
    let consume = map1("consume", map1("array", array(&[field, count])));
    let description = map1("exprs", array(&[assign, consume]));

    let mut builder = GraphBuilder::new();
    let sddl = builder.sddl(&description, StandardGraph::CompressGeneric)?;
    let graph = builder.build(sddl)?;
    let mut compressor = Compressor::builder(EncoderConfig::default())
        .with_graph(graph)
        .build()?;
    let data: Vec<u8> = (0..records).flat_map(|i| (i * 3).to_le_bytes()).collect();
    let frame = compressor.compress(&data)?;
    assert_eq!(Decompressor::default().decompress(&frame)?, data);

    // SDDL2: push.i32 4000, segment.create_unspecified, halt (a 4000-byte segment).
    let words: [u32; 4] = [0x0001_0003, 4000, 0x000C_0001, 0x0005_0001];
    let bytecode: Vec<u8> = words.iter().flat_map(|word| word.to_le_bytes()).collect();
    let mut builder = GraphBuilder::new();
    let sddl2 = builder.sddl2(&bytecode, 0, None)?;
    let graph = builder.build(sddl2)?;
    let mut compressor = Compressor::builder(EncoderConfig::default())
        .with_graph(graph)
        .build()?;
    let frame = compressor.compress(&data)?;
    assert_eq!(Decompressor::default().decompress(&frame)?, data);
    Ok(())
}

Dictionaries

OpenZL names external dictionaries through bundles and references them from codec nodes in compression graphs. Start with graphs and codecs to choose which nodes reference a dictionary.

A codec may need side information a frame does not carry. In OpenZL v0.3.0 that is a trained Zstandard dictionary: small records that share structure compress far better against a dictionary built from similar records. This guide covers the mopenzl::dict types, attaching a dictionary to a graph, and resolving it on the decoder.

Concepts · Building dictionaries · Compressing · Decoding · Errors

Concepts

flowchart LR
    D1["Dict (zstd content, ID = SHA-256 of content)"] --> B["DictBundle (ordered Dicts, ID = SHA-256 of the Dict IDs)"]
    B --> L["GraphBuilder::load_dictionaries"]
    N["zstd node + NodeParams::with_dictionary(id)"] --> L
    L --> F["frame: bundle ID in the header, dictionary lane in every chunk"]
    F --> S["BundleStore::insert(bundle)"]
    S --> R["Decompressor resolves the bundle by its ID"]
  • A Dict is opaque content for one codec with a DictId, by default the SHA-256 of its content.
  • A DictBundle is an ordered list of dictionaries with a BundleId, the SHA-256 of the dictionary IDs in order. Codecs address a dictionary by its position in the bundle, so the order is part of the bundle’s identity.
  • A frame compressed with a bundle records the BundleId in its header (format version 25 and newer) and each chunk carries a lane naming the dictionary of every codec. The dictionary content itself is not in the frame: the decoder must hold the same bundle.
  • Only standard Zstandard dictionaries can be materialized, and only with the zstd feature. Other codecs, custom codecs and a build without zstd get DictError::NoMaterializer.

Building dictionaries and bundles

ItemBuildsNotes
Dict::zstd(raw, level)A Zstandard dictionaryraw is a dictionary in Zstandard’s format (what zstd --train writes) or raw content; it is prepared for compression level
Dict::new(codec, content)A dictionary for any DictCodecDictCodec::ZSTD is the only one that can be loaded
Dict::with_id(id)The same, with your own IDAn all-zero ID restores the SHA-256 default
Vec::from(&dict) / Dict::try_from(&[u8])Packed formThe reference’s Dict_pack / ZL_Dict_parse
DictBundle::try_from(Vec<Dict>)A bundleAt most 65 535 dictionaries
Vec::from(&bundle) / DictBundle::try_from(&[u8])The fat bundleOpenZL’s interchange format: bundle info plus the packed dictionaries; the reference’s training tools write it

This crate does not train dictionaries. Train with the reference’s tools or with zstd --train, and load the result here.

use mopenzl::dict::{Dict, DictBundle, DictCodec};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let users = Dict::zstd(&br#"{"user":"alice","plan":"pro"}"#.repeat(8), 3)?;
    let events = Dict::new(DictCodec::ZSTD, b"raw dictionary content".to_vec())?;

    // Order matters: the bundle's ID, and the index frames record, follow it.
    let bundle = DictBundle::try_from(vec![users.clone(), events.clone()])?;
    let reversed = DictBundle::try_from(vec![events, users.clone()])?;
    assert_ne!(bundle.id(), reversed.id());

    // Fat bundles round-trip through bytes.
    let fat = Vec::from(&bundle);
    assert_eq!(DictBundle::try_from(&fat[..])?, bundle);
    assert_eq!(bundle.dicts()[0].id(), users.id());
    println!("bundle {}", bundle.id()); // 64 hexadecimal digits
    Ok(())
}

Compressing with a dictionary

The order of the calls matters. A dictionary can only be materialized for a node that references it, so:

  1. Parameterize the node that will use it: parameterize_node_with(node, NodeParams::default().with_dictionary(dict_id)). Only the Zstandard node uses a dictionary.
  2. Load the bundle: load_dictionaries(&bundle). Every dictionary in the bundle must already be referenced by some node, and a compressor holds one bundle.
  3. Build the graph: build(start) resolves each node’s dictionary to its index in the bundle.

Format version 25 or newer is required: below it, a node that references a dictionary fails when it runs (as in the reference), and a bundle that no node uses leaves the frame unchanged. The default format version is 27.

use mopenzl::dict::{BundleStore, Dict, DictBundle};
use mopenzl::graph::{GraphBuilder, NodeParams, StandardGraph, StandardNode};
use mopenzl::{Compressor, Decompressor, EncoderConfig};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    // Dictionary content: 40 representative records of the data to compress.
    let record = |i: usize| {
        let users = ["alice", "bob", "carol", "dave", "erin"];
        let cities = ["Oslo", "Lima", "Kyiv", "Pune"];
        format!(
            r#"{{"user":"{}","city":"{}","plan":"{}","seen":{},"flags":["beta","email"],"locale":"en-GB"}}"#,
            users[i % 5],
            cities[i % 4],
            if i.is_multiple_of(2) { "pro" } else { "free" },
            i * 7
        )
    };
    let history = (0..40).map(record).collect::<Vec<_>>().join("\n");
    let dict = Dict::zstd(history.as_bytes(), 3)?;
    let bundle = DictBundle::try_from(vec![dict.clone()])?;

    let mut builder = GraphBuilder::new();
    let node = builder.parameterize_node_with(
        StandardNode::Zstd,
        NodeParams::default().with_dictionary(dict.id()),
    )?;
    builder.load_dictionaries(&bundle)?;
    let start = builder.static_graph(node, &[StandardGraph::Store.into()])?;
    let graph = builder.build(start)?;

    let record = record(1234).into_bytes();
    let mut with_dict = Compressor::builder(EncoderConfig::default())
        .with_graph(graph)
        .build()?;
    let small = with_dict.compress(&record)?;
    let plain = Compressor::default().compress(&record)?;
    println!(
        "with dictionary {} bytes, without {}",
        small.len(),
        plain.len()
    );

    let mut store = BundleStore::default();
    store.insert(&bundle)?;
    let mut decoder = Decompressor::builder(Default::default())
        .with_dictionaries(store)
        .build();
    assert_eq!(decoder.decompress(&small)?, record);
    Ok(())
}

Notes:

  • A graph with a loaded bundle writes the bundle ID and a dictionary lane in every chunk, including stored and empty ones, as the reference does.
  • Block splitting inside the Zstandard node ignores the dictionary, as in the reference.
  • The dictionary is prepared at the level stored in its content: pass the level you compress with to Dict::zstd.
  • A BundleStore is shared: put it in an Arc and give the same store to many decoders (with_dictionaries(impl Into<Arc<BundleStore>>)); a built CompressorGraph is cheap to clone for many compressors.

Decoding with a BundleStore

BundleStore holds the bundles frames may refer to. insert(&bundle) materializes the dictionaries once (a Zstandard DDict), returns the BundleId, and shares them with every decoder given the store. A dictionary listed by several bundles is materialized once; inserting the same bundle twice is a no-op. contains(&id) and len() inspect it. A store holds at most 128 bundles and 65 535 dictionaries.

DecompressorBuilder::with_dictionaries(store) attaches the store (the builder is no longer Copy for that reason). A frame that names a bundle the decoder does not hold is DecodeError::MissingBundle { bundle }, and a codec that names a dictionary index past the end of the bundle is DecodeError::InvalidData of kind DictionaryIndex.

Errors

ErrorWhen
DictError::MalformedBytes that are not a dictionary or a fat bundle, or an ID that differs from the declared one
DictError::TooManyDictsMore than 65 535 dictionaries in a bundle
DictError::ContentTooLargeA content of 4 GiB or more
DictError::ConflictingIdTwo different contents share an ID
DictError::NoMaterializerThe dictionary is not a standard Zstandard one, or zstd is off
DictError::MaterializationZstandard rejected the content
DictError::StoreFullA BundleStore is at its limits
GraphError::Dictionary(DictError)A bundle problem found by load_dictionaries or build
GraphError::BundleAlreadyLoadedload_dictionaries called twice on one builder
GraphError::DictionaryNotFound(id)A node references an ID the loaded bundle lacks, or no bundle is loaded
DecodeError::MissingBundleThe decoder holds no bundle with the frame’s ID
use mopenzl::dict::{BundleStore, Dict, DictBundle};
use mopenzl::graph::{GraphBuilder, GraphError, NodeParams, StandardGraph, StandardNode};
use mopenzl::{Compressor, DecodeError, Decompressor, EncoderConfig};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let dict = Dict::zstd(&b"some shared history ".repeat(10), 3)?;
    let bundle = DictBundle::try_from(vec![dict.clone()])?;

    // The order rule: loading before any node references the dictionary fails.
    let mut early = GraphBuilder::new();
    assert!(matches!(
        early.load_dictionaries(&bundle),
        Err(GraphError::Dictionary(_))
    ));

    // A node referencing a dictionary with no bundle loaded fails at build time.
    let mut unloaded = GraphBuilder::new();
    let node = unloaded.parameterize_node_with(
        StandardNode::Zstd,
        NodeParams::default().with_dictionary(dict.id()),
    )?;
    let start = unloaded.static_graph(node, &[StandardGraph::Store.into()])?;
    assert!(matches!(
        unloaded.build(start),
        Err(GraphError::DictionaryNotFound(_))
    ));

    // The right order, and a second load is refused.
    let mut builder = GraphBuilder::new();
    let node = builder.parameterize_node_with(
        StandardNode::Zstd,
        NodeParams::default().with_dictionary(dict.id()),
    )?;
    builder.load_dictionaries(&bundle)?;
    assert_eq!(
        builder.load_dictionaries(&bundle),
        Err(GraphError::BundleAlreadyLoaded)
    );
    let start = builder.static_graph(node, &[StandardGraph::Store.into()])?;
    let graph = builder.build(start)?;

    let frame = Compressor::builder(EncoderConfig::default())
        .with_graph(graph)
        .build()?
        .compress(b"some shared history, again")?;

    // A decoder without the bundle says which one it is missing.
    assert!(matches!(
        Decompressor::default().decompress(&frame),
        Err(DecodeError::MissingBundle { bundle: id }) if id == bundle.id()
    ));
    let mut store = BundleStore::default();
    store.insert(&bundle)?;
    let mut decoder = Decompressor::builder(Default::default())
        .with_dictionaries(store)
        .build();
    assert_eq!(decoder.decompress(&frame)?, b"some shared history, again");
    Ok(())
}

Feature flags

OpenZL frame versions are selected through EncoderConfig::with_format_version: this implementation reads/writes 21–27, comments start at 22, and dictionary bundle IDs/lanes require 25 or newer. Codec availability is separate from the frame version: all-codecs enables the default node set, while direction and individual codec features can be selected independently. See codec features.

Brotli-style Large Window headers and arbitrary headerless continuations are not part of this API. Use an independent OpenZL frame, a segmenter inside one frame, or parallel assembly according to the required boundary. Decoder input/output caps live in DecodeLimits; graph execution retains the existing codec workspace budgets described in the decoder specification.

Serialized and custom static dictionaries

DictBundle packs/unpacks the OpenZL dictionary bundle wire format and BundleStore materializes matching decoder dictionaries. Only Zstandard has a materializer today. Application codec registration through custom_codec is a separate facility, not a universal custom dictionary loader. Parsing, duplicate identities and unavailable materializers produce typed DictError values. Dictionary training is not provided by the safe Rust API.

Framing containers

The opt-in framed container can select a different dictionary-bearing graph per resource. Convert a configured ordinary decoder with FramedDecompressor::from to retain its BundleStore and custom codec registrations. Each inner frame records its bundle ID. No automatic resolver fetches external bytes and no resource implicitly borrows dictionary content from earlier resources. See framed resources and bundle ownership.

Prefix dictionaries

A raw-content Zstandard dictionary is prepared through Dict::zstd, placed in a DictBundle and referenced by a Zstandard graph node. Decode through a matching BundleStore. There is no Brotli-style DictionaryBuilder::add_prefix or headerless raw-prefix attachment overload; see dictionary construction.

Large Window

The public OpenZL EncoderConfig has no Large Window toggle. Frame versions, graph/node parameters and chunking determine the format and codec behavior. Zstandard-specific controls belong to its parameterized node, while decoder limits live in DecoderConfig. No Brotli extended window declaration is emitted.

Headerless continuations

Each ordinary API call emits a complete OpenZL frame. There is no nonzero stream offset for appending a headerless continuation. Use frame concatenation for independent byte payloads, a segmenter for chunks within one frame, or a framed container for typed resources and whole-resource seek.

Configuration and features

Four things are configured separately: the graph (which codecs run), the encoder (global parameters shared by successive frames), the stream (what you know about one input), and the Cargo features (which codecs and adapters are compiled in). Graphs have their own guide: start with graphs. Decoder settings are covered in decompression and limits.

Encoder settings

EncoderConfig holds the global parameters shared by successive frames. They are the reference’s global parameters and shape the bytes of a frame.

SettingTypeBehavior
LevelLevel-131072..=22; 0 selects the default, 6. Standard graphs hand it to Zstandard and to level-sensitive codecs
Format versionFormatVersion21 to 27; default 27. Older versions disable newer codecs and features (comments need 22, dictionaries 25)
Content checksumboolAn XXH3 checksum of each chunk’s content; default on
Compressed checksumboolAn XXH3 checksum of each chunk’s compressed bytes plus a frame-header check byte; default on
ChunkingChunkingWhole (default, as ZL_compress) or Fixed(ChunkSize), the serial segmenter, 32 KiB..=i32::MAX
Decompression levelu8Trades ratio for decoder speed in some codecs; 0 selects the default, 3. At level 1 Zstandard literals are stored uncompressed
Minimum stream sizeu32A lone stream below it is stored; 0 selects the default, 10
Store on expansionboolA chunk that would not shrink is stored instead; default on
PermissiveboolA failing successor is replaced by compress_generic; default off

Compressor::set_comment(Some(Comment)) stores 1 to 10 000 bytes in the frame header (format version 22 or newer). Unlike the reference, which forgets the comment after each compression, it stays until you change it. Read the comment with Decompressor::frame_info; the decompressed payload contains only the original input.

StreamConfig carries the declared input size of a session; its default is InputSize::Unknown. For a complete known input use StreamConfig::from(InputSize::Exact(len)).

use mopenzl::graph::Comment;
use mopenzl::{Compressor, Decompressor, EncoderConfig, FormatVersion, Level};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let config = EncoderConfig::default()
        .with_level(Level::try_from(19)?)
        .with_format_version(FormatVersion::try_from(24)?)
        .with_content_checksum(false)
        .with_min_stream_size(32)
        .with_store_on_expansion(true);
    let mut encoder = Compressor::new(config)?;
    encoder.set_comment(Some(Comment::try_from(&b"produced by the docs"[..])?))?;

    let frame = encoder.compress(&b"configured payload ".repeat(100))?;
    assert_eq!(
        Decompressor::default().decompress(&frame)?,
        b"configured payload ".repeat(100)
    );

    // A comment needs format version 22; version 21 refuses it.
    let mut old = Compressor::new(config.with_format_version(FormatVersion::try_from(21)?))?;
    assert!(
        old.set_comment(Some(Comment::try_from(&b"too old"[..])?))
            .is_err()
    );
    Ok(())
}

Validated values (Level, FormatVersion, ChunkSize, Comment) reject out-of-range inputs when built. Compressor::new and reconfigure check combinations.

Cargo features

The default feature set is std, compression, decompression and all-codecs. all-codecs enables one Cargo feature per OpenZL codec (zstd, entropy, lz4, field-lz, delta, tokenize, custom-codec, …); the codec guide lists them all.

ConfigurationDependency
Default std encoder + decodermopenzl = "0.2.3"
Encoder onlymopenzl = { version = "0.2.3", default-features = false, features = ["std", "compression"] }
Decoder onlymopenzl = { version = "0.2.3", default-features = false, features = ["std", "decompression"] }
Decoder for default-graph frames onlymopenzl = { version = "0.2.3", default-features = false, features = ["std", "decompression", "zstd"] }
Indexed resources with independent graphsmopenzl = { version = "0.2.3", features = ["framed"] }
Alloc-backed codecs without stdmopenzl = { version = "0.2.3", default-features = false, features = ["no_std", "compression", "decompression"] }

Add the codec features you need, such as zstd or all-codecs, when disabling defaults. Without a codec’s feature the encoder never writes it (a chunk that would use it is stored, so frames stop matching the reference’s default graph) and the decoder reports frames that use it as DecodeError::Unsupported. Frames of the default graph need only zstd.

no_std

no_std takes precedence when enabled. It requires a global allocator, removes the std::io adapters, parallel APIs and runtime SIMD detection (the compile-time baseline is used), and pulls in libm for the estimators the numeric selectors rank candidates with.

Caveat: libm’s expf differs from the platform’s by one ULP for some inputs, so the numeric transformer selector’s scores can differ slightly from the reference’s; decisions change only on exact ties (none were observed in 4000+ frames), but byte identity of encoder decisions that depend on floating point is not guaranteed under no_std. Cargo features are additive: another dependency can re-enable std.

See codec features for the gates and what each build exposes.

Buffers and memory reuse

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

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

Output buffers

compress returns a new Vec<u8>. compress_into appends to the vector you pass and returns the range that holds the new frame; existing bytes stay intact. compress_to_slice returns the number of bytes written. Size the slice with Compressor::max_compressed_size:

use mopenzl::{Compressor, Decompressor, EncodeError};

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

    // A slice that is too small is an error, not a truncated frame.
    let mut cramped = [0u8; 4];
    assert!(matches!(
        encoder.compress_to_slice(input, &mut cramped),
        Err(EncodeError::OutputTooSmall { provided: 4 })
    ));

    // Decompression into a slice reports how much it wrote.
    let mut restored = [0u8; 64];
    let n = Decompressor::default().decompress_to_slice(&output, &mut restored)?;
    assert_eq!(&restored[..n], input);
    Ok(())
}

Uninitialized memory

compress_to_uninit, decompress_to_uninit and the sessions’ process_uninit write into &mut [MaybeUninit<u8>], such as Vec::spare_capacity_mut, without zeroing it first. They produce the same bytes, counts and errors as the initialized APIs; on success exactly the reported prefix is initialized and nothing after it is written. Reading the prefix back is the caller’s unsafe step. See uninitialized output.

use mopenzl::{Compressor, Decompressor};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let input = b"uninitialized destination ".repeat(50);
    let mut encoder = Compressor::default();
    let mut frame: Vec<u8> = Vec::with_capacity(Compressor::max_compressed_size(input.len())?);
    let written = encoder.compress_to_uninit(&input, frame.spare_capacity_mut())?;
    // SAFETY: `compress_to_uninit` initialized the first `written` bytes.
    unsafe { frame.set_len(written) };
    assert_eq!(frame, encoder.compress(&input)?);

    let mut restored: Vec<u8> = Vec::with_capacity(input.len());
    let n = Decompressor::default().decompress_to_uninit(&frame, restored.spare_capacity_mut())?;
    // SAFETY: `decompress_to_uninit` initialized the first `n` bytes.
    unsafe { restored.set_len(n) };
    assert_eq!(restored, input);
    Ok(())
}

Reusing memory

Keep the codec and your output buffer alive across operations when allocation reuse matters:

use mopenzl::{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 frame = Vec::new();

    for input in [b"first payload".as_slice(), b"second payload".as_slice()] {
        frame.clear(); // Keep the allocation; compress_into appends.
        let written = encoder.compress_into(input, &mut frame)?;
        assert_eq!(written, 0..frame.len());
        assert_eq!(decoder.decompress(&frame)?, input);
    }
    Ok(())
}

A compressor or decompressor retains working state between operations: hash tables, stream buffers and the Zstandard context. Compatible calls reuse the allocations. Destination capacity is yours to manage.

Retention policyEffect
Aggressive (default)Keeps every buffer between operations
Bounded { max_bytes }Releases retained storage when its accounting exceeds the ceiling
ReleaseAllReleases storage after each operation

Set the policy with Compressor::builder(config).with_retention(policy).build() (Decompressor::builder likewise). Use trim(policy) for an immediate cleanup and retained_bytes() for the owned heap size; it excludes caller buffers and allocator overhead, and a ceiling is not a peak-memory limit. The policy never changes the bytes produced.

use mopenzl::{Compressor, EncoderConfig, RetentionPolicy};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let mut encoder = Compressor::builder(EncoderConfig::default())
        .with_retention(RetentionPolicy::Bounded { max_bytes: 1 << 20 })
        .build()?;
    encoder.compress(&b"warm the tables up ".repeat(1000))?;
    println!("retained {} bytes", encoder.retained_bytes());
    encoder.trim(RetentionPolicy::ReleaseAll);
    assert_eq!(encoder.retained_bytes(), 0);
    Ok(())
}

For simultaneous independent streams use one codec per worker; a CompressorGraph is cheap to clone and share between them. A compressor configured with a graph keeps it; Compressor::builder(config).with_graph(graph).

Decompression and limits

Decompressor owns reusable state. decompress returns a Vec, decompress_into appends, and decompress_to_slice writes into caller storage. Sessions report consumed and produced counts; reader and writer adapt synchronous I/O. The decoder needs no graph: a frame records the codecs that produced it, so one decoder reads frames from the default graph, from any standard graph and from your own.

The byte-oriented methods read frames with one serial output, as the reference’s ZL_decompress does. A frame built from typed or several inputs reports DecodeError::Unsupported there (TypedOutput or MultipleOutputs); read it with decompress_typed, which returns one TypedOutput per output with its data_type(), width(), content() and, for strings, string_lens().

By default one call decodes exactly one frame and rejects trailing bytes. FrameMode::Concatenated decodes every frame until the input ends. Both checksums are verified unless you switch them off with with_content_checksum_check(false) and with_compressed_checksum_check(false).

Set limits for untrusted input

Configure limits for untrusted input. Limits are unlimited by default. A frame declaring more output than allowed fails before any chunk is decoded.

use mopenzl::{DecodeError, DecodeLimits, DecoderConfig, Decompressor, FrameMode};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let limits = DecodeLimits::default()
        .with_max_input_bytes(Some(1 << 20))
        .with_max_output_bytes(Some(8 << 20));
    let config = DecoderConfig::default()
        .with_frame_mode(FrameMode::Concatenated)
        .with_limits(limits);
    let mut decoder = Decompressor::new(config);

    // Two frames back to back decode to their concatenation.
    let mut encoder = mopenzl::Compressor::default();
    let mut frames = encoder.compress(b"first frame, ")?;
    frames.extend(encoder.compress(b"second frame")?);
    assert_eq!(decoder.decompress(&frames)?, b"first frame, second frame");

    // Garbage and over-limit frames are errors, not panics.
    assert!(decoder.decompress(b"not a frame").is_err());
    let big = encoder.compress(&vec![7u8; 16 << 20])?;
    assert!(matches!(
        decoder.decompress(&big),
        Err(DecodeError::OutputLimitExceeded { .. })
    ));
    let _ = DecoderConfig::from(limits);
    Ok(())
}

A frame that needs a codec whose Cargo feature is off, an unregistered custom codec, or a format version outside 21 to 27 reports DecodeError::Unsupported. Custom codecs are supported through CustomCodec, GraphBuilder::custom_codec and DecompressorBuilder::with_custom_codec; see custom codecs. A frame compressed with a dictionary bundle the decoder does not hold reports DecodeError::MissingBundle; see dictionaries. See decoder mechanics and the codec catalogue.

Frame metadata

Decompressor::frame_info(&frame) (or FrameInfo::try_from(&frame[..])) reads the frame header without decoding any chunk: the format version, every output’s type, size and element count (ZL_FrameInfo_getOutputType, getDecompressedSize, getNumElts; struct and numeric outputs have no element count, as in the reference), the checksum flags, the header comment and the dictionary bundle ID.

use mopenzl::graph::Comment;
use mopenzl::{Compressor, DataType, Decompressor};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let mut encoder = Compressor::default();
    encoder.set_comment(Some(Comment::try_from(&b"nightly export"[..])?))?;
    let frame = encoder.compress(b"payload")?;

    let info = Decompressor::default().frame_info(&frame)?;
    assert_eq!(info.comment(), Some(&b"nightly export"[..]));
    assert_eq!(info.outputs()[0].data_type(), DataType::Serial);
    assert_eq!(info.decompressed_size(), 7);
    assert!(info.bundle_id().is_none());
    Ok(())
}

Where to go next

TaskRead
Decode chunks as they arriveSans I/O decompression
Decode from a Read source or into a Write sinkRead / Write I/O
Decode typed and multiple outputsStructured data
Decode a framed containerFraming containers
Supply a dictionary bundleDictionaries

Streaming and completion

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

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

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

Declare the input size

A frame header records the frame’s total size, so declare it with InputSize::Exact when you know it: a session then writes the header at once and, with Chunking::Fixed, holds at most one chunk of input. With InputSize::Unknown it holds the whole input until the frame ends.

Readers and writers

reader(source, stream) consumes source bytes and yields transformed bytes through Read; writer(sink, stream) accepts source bytes through Write and delivers transformed bytes to the sink. Compression takes plain input, decompression takes compressed input (and no stream argument). Both adapter families require std.

A writer borrows the codec and owns its sink. Use write_all for input and finish to end the frame: consuming finish returns the sink, and FinishError retains the writer on failure (try_finish retries, into_error discards the writer). Dropping a writer performs no finalization. A reader owns its source; read to EOF for the complete output. flush() makes accepted input decodable without ending the frame.

use mopenzl::{ChunkSize, Chunking, Compressor, Decompressor, EncoderConfig, InputSize};
use std::io::{Read, Write};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let payload = b"streamed payload ".repeat(10_000);
    let config = EncoderConfig::default().with_chunking(Chunking::Fixed(ChunkSize::MIN));

    // Push model: write plain bytes into a compressed sink.
    let mut encoder = Compressor::new(config)?;
    let mut writer = encoder.writer(Vec::new(), InputSize::Exact(payload.len() as u64).into());
    writer.write_all(&payload)?;
    let frame = writer
        .finish()
        .map_err(mopenzl::io::FinishError::into_error)?;

    // Pull model: read compressed bytes from a plain source.
    let mut reader = encoder.reader(&payload[..], InputSize::Exact(payload.len() as u64).into());
    let mut from_reader = Vec::new();
    reader.read_to_end(&mut from_reader)?;
    assert_eq!(from_reader, frame);

    // Decompression, pulling restored bytes from a compressed source.
    let mut decoder = Decompressor::default();
    let mut restored = Vec::new();
    decoder.reader(&frame[..]).read_to_end(&mut restored)?;
    assert_eq!(restored, payload);

    // ... or pushing compressed bytes into a restored sink.
    let mut writer = decoder.writer(Vec::new());
    writer.write_all(&frame)?;
    let restored = writer
        .finish()
        .map_err(mopenzl::io::FinishError::into_error)?;
    assert_eq!(restored, payload);
    Ok(())
}

The decoder adapters use bounded internal buffering, and decoder-writer finalization reports truncated or invalid input instead of silently accepting an incomplete frame.

Sessions

For direct buffer control start creates an EncoderSession; call process with input, output and an Operation:

Operation / statusMeaning
Operation::ContinueAccept input without ending the frame
Operation::FlushEnd the current chunk so everything accepted so far is decodable (costs ratio; no effect with InputSize::Unknown)
Operation::EndEnd the frame after the supplied input
EncoderStatus::NeedsInputSupply more input
EncoderStatus::NeedsOutputSupply output space and continue with the unconsumed input
EncoderStatus::FinishedAll final output has been delivered

Progress reports consumed, produced and status; advance by the counts. Finishing can take several calls when output buffers are small. The decoder’s DecoderSession::process mirrors it with DecodeOperation::{Process, Finish}, DecoderStatus and DecodeProgress, and total_in, total_out and frames_decoded counters.

Operation names. The encoder and decoder name the same idea differently, and both are kept for compatibility:

MeaningEncoder OperationDecoder DecodeOperation
More input may followContinueProcess
End the current chunk so far is decodableFlushnone (the decoder emits what it can on every call)
This is all the inputEndFinish

Adapters return no Result. Compressor::reader, Compressor::writer and the decoder’s reader and writer return the adapter directly. Unlike mbrotli, whose adapter constructors return a Result, nothing can fail when they are created; every error surfaces from the first read, write or finish call as an io::Error. FinishError lives in mopenzl::io, not at the crate root: use writer.finish().map_err(mopenzl::io::FinishError::into_error)? to turn a failed finish into the plain error and drop the writer.

Sessions and graphs. A session holds the input a frame needs. With the default graph and Chunking::Fixed, or a start graph that is a serial segmenter, it compresses chunk by chunk and holds at most one chunk when you declare InputSize::Exact. Any other graph buffers its input until the frame ends and then runs the graph once; the declared size is checked at the end. Session output is byte-identical to the one-shot call. See graphs.

use mopenzl::{
    ChunkSize, Chunking, Compressor, DecodeOperation, DecoderStatus, Decompressor, EncoderConfig,
    EncoderStatus, InputSize, Operation,
};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let payload = b"incremental payload ".repeat(5_000);
    let config = EncoderConfig::default().with_chunking(Chunking::Fixed(ChunkSize::MIN));
    let mut encoder = Compressor::new(config)?;
    let expected = encoder.compress(&payload)?;

    // Compress in 4 KiB pieces through a small 1 KiB output buffer.
    let mut session = encoder.start(InputSize::Exact(payload.len() as u64).into());
    let mut frame = Vec::new();
    let mut buffer = [0u8; 1024];
    let mut rest = &payload[..];
    loop {
        let take = rest.len().min(4096);
        let operation = if take == rest.len() {
            Operation::End
        } else {
            Operation::Continue
        };
        let progress = session.process(&rest[..take], &mut buffer, operation)?;
        frame.extend_from_slice(&buffer[..progress.produced]);
        rest = &rest[progress.consumed..];
        if progress.status == EncoderStatus::Finished {
            break;
        }
    }
    assert_eq!(frame, expected);

    // Decompress the same way.
    let mut decoder = Decompressor::default();
    let mut session = decoder.start();
    let mut restored = Vec::new();
    let mut position = 0;
    loop {
        let end = (position + 1000).min(frame.len());
        let operation = if end == frame.len() {
            DecodeOperation::Finish
        } else {
            DecodeOperation::Process
        };
        let progress = session.process(&frame[position..end], &mut buffer, operation)?;
        restored.extend_from_slice(&buffer[..progress.produced]);
        position += progress.consumed;
        if progress.status == DecoderStatus::Finished {
            break;
        }
    }
    assert_eq!(restored, payload);
    Ok(())
}

A dropped unfinished session abandons its frame; the codec is ready for the next one.

Read / Write I/O

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

API map

OperationPull bytes through a readerPush bytes through a writer
Raw compressionCompressor::readerCompressor::writer
Raw decompressionDecompressor::readerDecompressor::writer
Parallel compressionParallelCompressor::reader + task queueParallelCompressor::writer + task queue
Decompress parallel outputDecompressor::readerDecompressor::writer
Framed compression · framedDrain a native FramedEncoderSessionFramedCompressor::framed_writer accepts complete resources
Framed decompression · framedframed_reader or framed_seek_reader returns owned typed resourcesRoute native decoder resources 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 OpenZL frame.

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 mopenzl::{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(5)?),
    )?;
    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::new(Default::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 member boundary; 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 mopenzl::{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(5)?),
    )?;
    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(mopenzl::io::FinishError::into_error)?;

    let mut decoder = Decompressor::new(Default::default());
    let mut writer = decoder.writer(Vec::new());
    for chunk in compressed.chunks(3) {
        writer.write_all(chunk)?;
    }
    let restored = writer.finish().map_err(mopenzl::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. With a known input size, encoder flush creates an intermediate chunk boundary and can change compressed size; unknown-size frames wait until End. 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 mopenzl::compressor::parallel::{ParallelCompressor, ParallelConfig, TaskCount};
use mopenzl::{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(5)?),
        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(), 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::new(Default::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

Enable framed. A framed writer accepts complete resources as they arrive; each resource command queues one OpenZL frame and optional metadata. It is resource-oriented: there is no encoded framed_reader or byte-oriented resource writer on FramedCompressor. Use its native session to drain a container into caller-owned output buffers, or use framed_writer for a Write transport.

The framed writer recipe demonstrates graph selection and metadata. Here is a complete writer paired with sequential resource decoding:

use mopenzl::framing::{FramedCompressor, FramedDecompressor, FramedResource};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let input = b"a resource sent through an encoded writer";
    let mut encoder = FramedCompressor::new(Default::default())?;
    let mut writer = encoder.framed_writer(Vec::new())?;
    writer.resource(FramedResource::from(input.as_slice()))?;
    let container = writer.finish().map_err(|failure| failure.error)?;
    let mut decoder = FramedDecompressor::new(Default::default())?;
    let mut reader = decoder.framed_reader(container.as_slice());
    let mut restored = Vec::new();
    let mut resources = 0;
    while let Some(resource) = reader.read_resource()? {
        resources += 1;
        restored.extend_from_slice(resource.outputs[0].content());
    }
    assert_eq!(resources, 1);
    assert_eq!(restored, input);
    Ok(())
}

Framed decompression into a Write sink

FramedReader accepts any Read source and returns owned DecodedResource values. Route its outputs to a sink with write_all, using metadata to select a destination when needed. Continue through None to validate the directory, footer and EOF. Typed resources may have several outputs and string lengths; the example below contains one serial output per resource.

use mopenzl::framing::{FramedCompressor, FramedDecompressor, FramedResource};
use std::io::Write;

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let resources = [
        FramedResource::from(&b"first"[..]),
        FramedResource::from(&b"second"[..]),
    ];
    let container = FramedCompressor::new(Default::default())?
        .compress(resources.as_slice().into())?;
    let mut decoder = FramedDecompressor::new(Default::default())?;
    let mut reader = decoder.framed_reader(container.as_slice());
    let mut sinks: Vec<Vec<u8>> = Vec::new();
    while let Some(resource) = reader.read_resource()? {
        let mut sink = Vec::new();
        sink.write_all(resource.outputs[0].content())?;
        sinks.push(sink);
    }
    assert_eq!(sinks, vec![b"first".to_vec(), b"second".to_vec()]);
    Ok(())
}

An application can route resources to files or other writers. Retain any unwritten suffix after a sink error; the owned resource remains available while the transport retries. A resource already delivered remains tentative until the container validates. This reader consumes through EOF; it does not expose a buffered suffix for another container or protocol message.

For Read + Seek, indexed reads use framed_seek_reader and read_resource(index) to return a whole selected resource. For push-driven compressed input use the native framed decoder and route its owned resource events to sinks.

Completion, retries, and ownership

AdapterComplete operationRecover after a failure
Ordinary encoder / decoder readerRead to EOFDecoder into_parts preserves read-ahead; codec errors are terminal
Ordinary encoder / decoder writerfinish or retryable try_finishFinishError::into_parts returns error and writer
Parallel readerRead to EOF with active workersinto_parts preserves read-ahead; keep queue execution alive
Parallel writerfinish with active workersFinishError::into_parts returns error and adapter
Framed writerSubmit complete resources, then container finishFramedFinishError exposes writer and error
Framed resource readerread_resource until NoneTransport failures preserve input; container/codec errors are sticky

Short sink writes and errors must not replay accepted input. Ordinary and parallel writers can accept input before delivery fails; respect returned byte counts. A framed writer’s resource command drains previous bytes before commit, so a command error means that new resource was not accepted. WouldBlock is not EOF. Use native sessions for explicit runtime readiness handling. See streaming completion and framed retry mechanics.

Sans I/O compression

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

Output formatSession entry pointScheduling
OpenZL frameCompressor::start(stream)Call process yourself
One raw stream encoded across workersParallelCompressor::start(window)Drive process and run its task queue
Framed resources · framedFramedCompressor::start()Drain container output, submit complete resources, 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 Finish until EncoderStatus::Finished. An empty input passed with Process is not EOF. The deliberately small output buffer below forces repeated output drains.

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

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(5)?);
    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;
            }
        }
    }
    // Exact input size, offset zero, and no flushes preserve one-shot bytes.
    assert_eq!(compressed, encoder.compress(&input)?);
    let mut decoder = Decompressor::new(Default::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 ends the current chunk without ending the frame, provided its total input size is known. With unknown size it has no effect. Drain it to NeedsInput before changing operation or supplying new input. Flush boundaries can change output size and bytes. Operation::End ends the frame; 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 buffers input until End because the frame header needs the total size. See stream scheduling and identity.

Drive parallel input

ParallelCompressor::start(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 active tasks; OpenZL streaming holds compressed results until the total size is known at End. Without 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. Submit each complete FramedResource with its optional graph, drain the queued frame, then finish the container. Resource inputs can be serial, typed or multiple; this API does not offer byte-oriented resource guards.

use mopenzl::framing::{
    FramedCompressor, FramedDecompressor, FramedEncodeOperation,
    FramedEncoderStatus, FramedResource,
};
use mopenzl::graph::{CompressorGraph, StandardGraph};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let input = b"framed incremental payload\n".repeat(20);
    let store = CompressorGraph::standard(StandardGraph::Store);
    let mut encoder = FramedCompressor::new(Default::default())?;
    let mut session = encoder.start()?;
    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; }
    }
    session.resource(FramedResource {
        graph: Some(&store),
        ..FramedResource::from(input.as_slice())
    })?;
    loop {
        let progress = session.process(&mut buffer, FramedEncodeOperation::Finish)?;
        container.extend_from_slice(&buffer[..progress.produced]);
        if progress.status == FramedEncoderStatus::Finished { break; }
    }
    drop(session);
    let mut decoder = FramedDecompressor::new(Default::default())?;
    assert_eq!(decoder.decompress(&container)?.resources[0].outputs[0].content(), input);
    Ok(())
}

An empty resource is an ordinary FramedResource::from(&b""[..]) command and still produces one complete frame. A new resource requires previous container output to be drained; otherwise OutputPending rejects the command before commit. After a failed resource command the same session can accept a corrected resource. See resource lifecycles and metadata.

Ownership and cancellation

Borrowed sessions exclusively borrow their codec owner. Dropping an unfinished session cancels its frame/container and releases the owner; Drop performs no transport I/O. Raw codec into_session and parallel into_session provide owned sessions when a lifetime-free holder is useful. Framed native sessions are borrowed only. See encoder sessions, decoder sessions and framed lifecycles.

Sans I/O decompression

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

InputDecoder
Ordinary OpenZL frameDecompressor::start
Output of ParallelCompressorThe same Decompressor::start
Framing container · framedFramedDecompressor::start

There is no separate parallel decoder. Parallel compression assembles a standard OpenZL frame, 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 mopenzl::{
    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(5)?),
    )?;
    let compressed = encoder.compress(&input)?;
    let mut decoder = Decompressor::new(Default::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. For protocols with bytes after the OpenZL member, 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 session yields one owned DecodedResource at each resource boundary. Metadata and every typed output are attached to that value. There are no borrowed payload-fragment events or caller output buffer in this API.

use mopenzl::framing::{
    FramedCompressor, FramedDecompressor, FramedDecoderStatus, FramedResource,
};
use mopenzl::DecodeOperation;

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let input = b"resource decoded from tiny arriving chunks";
    let resources = [FramedResource::from(input.as_slice())];
    let container = FramedCompressor::new(Default::default())?
        .compress(resources.as_slice().into())?;
    let mut decoder = FramedDecompressor::new(Default::default())?;
    let mut session = decoder.start();
    let mut chunks = container.chunks(3).peekable();
    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, operation)?;
            remaining = &remaining[progress.consumed..];
            if let Some(resource) = progress.resource {
                resources += 1;
                restored.extend_from_slice(resource.outputs[0].content());
            }
            match progress.status {
                FramedDecoderStatus::NeedsInput => { assert!(remaining.is_empty()); break; }
                FramedDecoderStatus::Finished => { assert!(remaining.is_empty()); finished = true; break; }
                FramedDecoderStatus::Resource => {}
            }
        }
        if finished { break; }
    }
    assert!(finished);
    assert_eq!(resources, 1);
    assert_eq!(restored, input);
    Ok(())
}

A resource event does not validate the rest of the container. Continue through Finished to verify its directory, footer and EOF. For multiple resources, route each DecodedResource to a separate destination. Metadata does not select an extraction path. See framed event semantics.

Limits and errors

Set input and output budgets in DecoderConfig before starting an ordinary OpenZL decoder. Framing adds FramingConfig caps for encoded frame bytes, metadata, resource count, aggregate decoded content and total wire bytes. See resource-limit configuration.

Raw DecodeFailure carries consumed/produced counts for a failing call; FramedDecodeFailure carries consumed bytes and no partial owned resource. Previously returned resource events remain tentative until the container finishes validation. Failures are sticky: drop and start a new session. Finish reports truncation; an empty Process call does not declare EOF.

Parallel compression

mopenzl::compressor::parallel divides a known input into fixed independent segments and assembles them into one ordinary OpenZL frame. It uses the same encoder configuration, graphs and codec registry as serial compression. Typed/multiple top-level inputs and frame comments are not exposed here.

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

Compressing a slice

use mopenzl::compressor::parallel::{
    BatchConfig, ParallelCompressor, ParallelConfig, TaskCount,
};
use mopenzl::{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(5)?),
        ParallelConfig::default(),
    )?;
    let mut batch = encoder.prepare_slice(
        &input,
        BatchConfig::auto(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::new(Default::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. Finalization collects task results, validates artifacts, and assembles them in input order. A blocking wait requires the caller’s executor to remain runnable.

Running tasks with Rayon

Add rayon = "1.12" alongside mopenzl 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 mopenzl::compressor::parallel::{
    BatchConfig, ParallelCompressor, ParallelConfig, TaskCount,
};
use mopenzl::{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(5)?),
        ParallelConfig::default(),
    )?;
    let input = b"parallel compression with a reusable pool\n".repeat(220_000);
    let mut batch = encoder.prepare_slice(
        &input,
        BatchConfig::auto(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::new(Default::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(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() }. OpenZL requires the total size in its frame header, so the session stages compressed segments until End establishes that size. It then assembles results in input order. The queue closes when the session finishes, fails, or is dropped.

use mopenzl::compressor::parallel::{
    ParallelCompressor, ParallelConfig, ParallelEncoderStatus, TaskCount,
};
use mopenzl::{EncoderConfig, Operation, Level};
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(5)?),
        ParallelConfig::default(),
    )?;
    std::thread::scope(|scope| {
        let (mut session, queue) = encoder.start(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 = mopenzl::Decompressor::new(Default::default());
    assert_eq!(decoder.decompress(&compressed)?, input);
    Ok(())
}
  • process never blocks. Pending means the window is full or a flush or finish waits for queued tasks; call wait, which blocks until one completes.
  • window bounds the segments queued, running or awaiting delivery, and therefore active work. Compressed results accumulate until End; the optional aggregate memory estimate also accounts for buffered results.
  • Without a flush, the output is byte-identical to prepare_slice over the same bytes, however the input was chunked. Operation::Flush ends the current segment early, which moves later segment boundaries.
  • On a single thread, run queue.try_recv() tasks before wait; nothing else will. Dropping every queue handle fails the stream instead of hanging.
  • into_session(window) returns an owned session without a lifetime; into_compressor returns the compressor.

Reader and writer adapters

writer(sink, window) and reader(source, 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 mopenzl::compressor::parallel::{ParallelCompressor, ParallelConfig, TaskCount};
use mopenzl::io::FinishError;
use mopenzl::{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(5)?),
        ParallelConfig::default(),
    )?;
    std::thread::scope(|scope| {
        let (mut sink, queue) = encoder.writer(writer, 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 = mopenzl::Decompressor::new(Default::default());
    assert_eq!(decoder.decompress(&compressed)?, input);
    Ok(())
}
  • Unlike process, the adapters block: a write waits while the window is full, a read waits for the next part, and finish waits for every part. Run the queue on threads other than the one using the adapter.
  • Output is byte-identical to prepare_slice over the same bytes. The writer’s flush ends the partial segment early and moves later boundaries; it emits no decodable prefix until End establishes the total size.
  • The writer keeps every compressed byte until the sink takes it, so short writes, WouldBlock and sink errors lose nothing, and finish hands the adapter back inside FinishError for a retry, as EncoderWriter does. reader.into_parts() returns source bytes read ahead of the session.
  • Session failures arrive as std::io::Error with the ParallelEncodeError as their source. Dropping an adapter abandons the stream and closes the queue.

Segments and compressed size

Segment size defaults to 4 MiB and accepts 64 KiB–16 MiB. Effective task count is at most the number of segments. For fixed input and segment settings, task grouping and completion order do not change output bytes.

Segments are encoded independently, without cross-segment history. The frame is OpenZL-compatible, but byte identity with the serial reference is not promised. For fixed input, graph and segment settings, task grouping and completion order do not change bytes. Compare compressed size and throughput with serial output.

Empty input uses serial encoding without tasks. A nonempty input below the default 8 MiB parallel threshold uses a single serial task only when it also fits in one segment.

Sources and staging

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

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; metadata 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, config).

BatchConfig::auto(tasks) stages in memory using bounds derived from source length and configured segment size. It caps effective tasks at the segment count and still enforces any configured aggregate memory limit. It does not query available RAM or switch to files.

BatchConfig::memory(tasks, max_bytes) sets an explicit payload-and-descriptor budget. To obtain its exact required preflight allowance, call estimate_source with BatchConfig::auto(tasks), then convert estimate.maximum_staging_memory_bytes()? using usize::try_from. maximum_staged_bytes remains the payload-only bound. Planning also accounts for worker and assembly storage. These estimates are not a process RSS measurement. BatchConfig::directory(tasks, directory) stages output in exclusive temporary files in an existing directory, keeping payload RAM proportional to active workers and segment size. File cleanup is best effort on drop.

Output and cancellation

finish_into appends to a vector and restores its length on failure. finish_to_writer consumes the batch and returns the destination with statistics on success. On failure it preserves destination ownership and the number of accepted bytes; the consumed batch cannot retry assembly. The caller controls flushing, file durability, and publication.

Cancellation is cooperative around source reads and segment encoding. A running codec call or blocking source read can delay it. Dropping a batch cancels work without waiting for detached tasks. Worker retention is configurable and defaults to retaining no workers.

The repository example runs scoped batches, reuses the compressor and checks each resulting frame:

cargo run --release --example parallel

See parallel mechanics for lifecycle, resource accounting, source checks, and error propagation.

Choose a graph or individual codecs

ParallelCompressor::builder(config, parallel).with_graph(graph).build() accepts the same CompressorGraph used by the serial API. Standard graphs, parameterized nodes, selectors, segmenters and loaded Zstandard dictionaries run for every segment, including tails and flush-cut segments. Choose aligned boundaries for a graph interpreting bytes as records or numbers. Callbacks may run concurrently and must be deterministic for schedule-independent output.

use mopenzl::compressor::parallel::{BatchConfig, ParallelCompressor, ParallelConfig, TaskCount};
use mopenzl::graph::{CompressorGraph, StandardGraph};
use mopenzl::Decompressor;

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let mut encoder = ParallelCompressor::builder(Default::default(), ParallelConfig::default())
        .with_graph(CompressorGraph::standard(StandardGraph::Store))
        .build()?;
    let input = b"graph selected for every segment";
    let mut batch = encoder.prepare_slice(input, BatchConfig::auto(TaskCount::ONE))?;
    for task in batch.take_tasks()? { task.run(); }
    let mut frame = Vec::new();
    batch.finish_into(&mut frame)?;
    assert_eq!(Decompressor::default().decompress(&frame)?, input);
    Ok(())
}

For individual codecs, use GraphBuilder::pipeline, static_graph or parameterize_node, then build the selected root graph and pass it to with_graph. Graph selection also applies to serial fallback and survives reconfiguration. See graphs and codec nodes.

Framing containers

Package independent resources with metadata and a checked seek index. Each resource contains one ordinary OpenZL frame, with its own graph and serial, typed or multiple inputs. The outer format belongs to mopenzl; an ordinary OpenZL decoder reads an extracted frame rather than the complete container.

Enable framing

mopenzl = { version = "0.2", features = ["framed"] }

The book follows the repository’s main branch; use this checkout or a Git dependency for features not yet in a crates.io release. framed is opt-in, outside default and all-codecs. With compression it exposes the framed encoder; with decompression, the framed decoder. Owned results and borrowed native sessions work with alloc; std adds writer, sequential reader and seek reader adapters. no_std removes those transport adapters.

The container has one resource profile and a mandatory directory/footer. Resource commands take complete borrowed inputs; there are no byte-oriented resource writers, hidden resources, padding or repeated metadata. Split large input into resources explicitly. See format and lifecycle.

One container, two resources

Use the default graph for text and Store for already encoded binary data. Graph overrides apply only to their resource and are restored after failure.

use mopenzl::framing::{FramedCompressor, FramedDecompressor, FramedResource};
use mopenzl::graph::{CompressorGraph, StandardGraph};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let text = b"notes for a compressed resource\n".repeat(200);
    let binary = [1, 2, 3, 4, 5];
    let store = CompressorGraph::standard(StandardGraph::Store);
    let resources = [
        FramedResource::from(text.as_slice()),
        FramedResource { graph: Some(&store), ..FramedResource::from(&binary[..]) },
    ];
    let mut encoder = FramedCompressor::new(Default::default())?;
    let container = encoder.compress(resources.as_slice().into())?;
    let mut decoder = FramedDecompressor::new(Default::default())?;
    let decoded = decoder.decompress(&container)?;
    assert_eq!(decoded.resources.len(), 2);
    assert_eq!(decoded.resources[0].outputs[0].content(), text);
    assert_eq!(decoded.resources[1].outputs[0].content(), binary);
    Ok(())
}

compress_into appends transactionally to a reusable Vec and returns the container’s range. Raw slice/uninitialized output methods belong to the ordinary codec; framed output uses Vec, a native output session or a writer. FramedResource::from(&inputs[..]) accepts typed/multiple Input values. See graphs and typed resources.

Write resources and metadata

Use a writer when complete resources arrive one after another. Attach borrowed metadata directly to each resource command, then finish the enclosing container. Metadata codes are uppercase ASCII pairs, id (unique UTF-8 name within one resource’s fields), or mt (unique eight-byte signed little-endian timestamp).

use mopenzl::framing::{
    FramedCompressor, FramedDecompressor, FramedEncodeConfig, FramedResource,
    FramingConfig, MetadataField,
};
use mopenzl::graph::{CompressorGraph, StandardGraph};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let config = FramedEncodeConfig {
        framing: FramingConfig { max_resources: 16, ..Default::default() },
        ..Default::default()
    };
    let fields = [MetadataField { code: *b"id", value: b"notes.txt" }];
    let store = CompressorGraph::standard(StandardGraph::Store);
    let mut encoder = FramedCompressor::new(config)?;
    let mut writer = encoder.framed_writer(Vec::new())?;
    writer.resource(FramedResource {
        metadata: &fields,
        ..FramedResource::from(&b"first part\nsecond part\n"[..])
    })?;
    writer.resource(FramedResource {
        graph: Some(&store),
        ..FramedResource::from(&[1, 2, 3, 4][..])
    })?;
    let container = writer.finish().map_err(|failure| failure.error)?;
    let decoded = FramedDecompressor::new(Default::default())?.decompress(&container)?;
    assert_eq!(decoded.resources[0].outputs[0].content(), b"first part\nsecond part\n");
    assert_eq!(decoded.resources[0].metadata[0].value, b"notes.txt");
    assert_eq!(decoded.resources[1].outputs[0].content(), [1, 2, 3, 4]);
    Ok(())
}

No Drop performs I/O. resource drains older output before accepting its command; an error means this resource was not accepted, so retrying that command does not duplicate it. try_finish retains the writer after an I/O error; consuming finish returns FramedFinishError with writer and error fields. See the retry state machine.

id is a name, not an extraction path or a globally unique lookup key. Framing computes/verifies metadata and directory XXH3 checksums, while inner OpenZL checksums follow encoder/decoder configuration. These detect damage and do not authenticate content.

Indexed resource reads

Open the footer and directory on a Read + Seek source, then decode only a selected whole resource. A File can replace this example’s Cursor.

use mopenzl::framing::{FramedCompressor, FramedDecompressor, FramedResource};
use std::io::Cursor;

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let resources = [
        FramedResource::from(&b"first"[..]),
        FramedResource::from(&b"second"[..]),
    ];
    let container = FramedCompressor::new(Default::default())?
        .compress(resources.as_slice().into())?;
    let mut decoder = FramedDecompressor::new(Default::default())?;
    let mut reader = decoder.framed_seek_reader(Cursor::new(container))?;
    assert_eq!(reader.resources().len(), 2);
    let selected = reader.read_resource(1)?;
    assert_eq!(selected.outputs[0].content(), b"second");
    Ok(())
}

Indices are zero-based usize values, independent of metadata names. Opening validates the directory; reading validates that resource’s header, metadata, size and inner frame. It returns owned typed outputs rather than a Read adapter. The container extends from the source’s current position to EOF; offsets remain relative for a container embedded after a prefix. External dictionary bundles come from the decoder’s configured BundleStore. See indexed decoder mechanics.

Limits and streaming decoding

FramingConfig bounds resources, metadata per resource, encoded frame bytes, aggregate decoded content and total wire bytes. FramedDecodeConfig::decoder adds raw decoder input/output limits and checksum policy for each frame.

For arriving input, FramedDecompressor::start yields owned whole-resource events; framed_reader adapts a Read source through read_resource. Continue until Finished or None to validate the directory, footer and EOF. Earlier resources remain tentative until then. There is no automatic raw-frame detection or dictionary reference to an earlier resource. See Sans I/O decoding and dictionary bundles.

Framed resources with independent graphs

Enable the opt-in feature alongside your codec directions:

mopenzl = { version = "0.2", features = ["framed"] }

framing::FramedCompressor writes an indexed container. Each resource is one ordinary OpenZL frame with its own graph and optional metadata. The outer container is mopenzl-specific: an ordinary OpenZL decoder accepts an extracted resource frame, not the complete container. The decoder reconstructs codecs from each frame; it needs no graph selection.

use mopenzl::framing::{FramedCompressor, FramedDecompressor, FramedResource, MetadataField};
use mopenzl::graph::{CompressorGraph, StandardGraph};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let store = CompressorGraph::standard(StandardGraph::Store);
    let name = [MetadataField { code: *b"id", value: b"image.bin" }];
    let text = b"text text text text text text";
    let image = [0x42; 1024];
    let resources = [
        FramedResource::from(&text[..]), // default graph
        FramedResource {
            graph: Some(&store),
            metadata: &name,
            ..FramedResource::from(&image[..])
        },
    ];
    let mut encoder = FramedCompressor::new(Default::default())?;
    let bytes = encoder.compress(resources.as_slice().into())?;
    let mut decoder = FramedDecompressor::new(Default::default())?;
    let output = decoder.decompress(&bytes)?;
    assert_eq!(output.resources[0].outputs[0].content(), text);
    assert_eq!(output.resources[1].outputs[0].content(), image);
    Ok(())
}

For typed or multiple inputs, construct FramedResource::from(&inputs[..]) where inputs is a slice of mopenzl::input::Input. Decoded resources retain the original output types, widths and string lengths. A graph override lasts only for its resource. Convert a configured Compressor or Decompressor with FramedCompressor::from / FramedDecompressor::from to retain its backend, graph, retention, dictionaries or custom-codec registrations.

Without no_std, use encoder.framed_writer(sink) to add complete resources as they arrive, then finish to write the seek index. resource accepts complete borrowed inputs and queues one encoded resource. flush delivers queued bytes; finish returns the sink or a failure containing the writer for retry. Nothing is written on Drop. Reader methods return whole typed resources:

use mopenzl::framing::{FramedCompressor, FramedDecompressor, FramedResource};
use std::io::Cursor;

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let mut encoder = FramedCompressor::new(Default::default())?;
    let mut writer = encoder.framed_writer(Vec::new())?;
    writer.resource(FramedResource::from(&b"first"[..]))?;
    writer.resource(FramedResource::from(&b"second"[..]))?;
    let bytes = writer.finish().map_err(|failure| failure.error)?;

    let mut decoder = FramedDecompressor::new(Default::default())?;
    let mut reader = decoder.framed_seek_reader(Cursor::new(&bytes))?;
    assert_eq!(reader.resources().len(), 2);
    assert_eq!(reader.read_resource(1)?.outputs[0].content(), b"second");
    drop(reader);
    let mut sequential = decoder.framed_reader(&bytes[..]);
    while let Some(resource) = sequential.read_resource()? {
        println!("{} content bytes", resource.info.decoded_size);
    } // None validates the directory, footer and EOF.
    Ok(())
}

With alloc (including no_std), both codecs also offer borrowed native sessions. Drain an encoder session’s header with process(output, Process) until NeedsInput, submit resource, drain again, and finally use Finish until Finished. Decoder sessions consume wire bytes until a resource event; process the unconsumed suffix, and use DecodeOperation::Finish at EOF. Resource events are tentative until Finished validates the directory and footer.

FramingConfig supplies bounded resource count, metadata, frame, aggregate output and total wire sizes. Configure it through FramedEncodeConfig, FramedDecodeConfig, or reconfigure. Inputs are complete per resource, and each resource/frame is staged in memory. Choose resource boundaries explicitly for large streams. This first version supports resource metadata and whole resource seek; it does not mirror every mbrotli/mzstd container command.

See the wire format, lifecycle and limits.

Compatibility and validation

This page states the output contract and the evidence behind it. Rust/C frame identity, decoding interoperability, malformed-input behavior and memory safety are separate checks. See oracle mechanics.

What is claimed

  • Serial compression is byte-identical to Meta’s pinned OpenZL v0.3.0 for the same graph, typed inputs, global/local parameters and chunk boundaries.
  • Supported frame versions are 21–27. Every non-legacy standard node/graph is ported; deprecated nodes 76–85 and older formats remain unsupported.
  • A frame describes its codec layout. Decoding needs matching enabled codecs, custom-codec registrations and external dictionary bundles, not its selector or compressor graph definition.
  • Backend choice, workspace retention and destination shape preserve output. Sessions without flushes match one-shot bytes for arbitrary buffer splits.
  • Parallel assembly produces compatible OpenZL frames, with a separate byte contract from serial compression. Framing adds a mopenzl-specific container around independent ordinary frames.

no_std estimators use libm; Transformer/estimator decisions have a C byte identity guarantee only with std. Missing codec features can produce stored fallbacks or explicit graph errors, and disabled decoder codecs report Unsupported. See codec features.

Output compatibility

Serial frames match OpenZL v0.3.0 for the same graph, inputs, global parameters, format version, checksum choices and chunk boundaries. Destination shape, workspace reuse and backend selection do not change bytes. Sessions without explicit flushes match one-shot output for any input/output split. Unknown-size frames wait until End; they do not infer an early header size.

Parallel segments form one compatible frame but are not promised byte-identical to serial encoding. Framed containers have a mopenzl-specific outer format; every inner resource is an ordinary frame from its selected graph. Custom codecs must be registered on both sides and external dictionary bundles must match.

Validation layers

graph TD
    API[Public APIs and examples] --> Tests[Unit and integration tests]
    Tests --> C[Pinned C OpenZL oracle]
    Tests --> Identity[Backend, chunking and output-destination identity]
    Tests --> Fuzz[AFL and libFuzzer shared target bodies]
    Tests --> Coverage[Function coverage report]
    Tests --> Memory[Miri and AddressSanitizer]

Differential tests

Test suiteWhat it compares
tests/differential_c.rsDefault graph, levels, versions, checksums, chunking, bounds and representative/boundary inputs
tests/graph_engine.rs, tests/standard_graphs.rsTyped/multiple inputs, graph composition, selectors, segmenters, permissive/trial compression and every standard graph
tests/encode_numeric.rs, tests/encode_structure.rs, tests/encode_backends.rsNumeric/structural transforms and packing/LZ4 nodes, widths and local parameters
tests/encode_entropy.rs, tests/encode_lz.rs, tests/encode_lzgraphs.rsFSE/Huffman, LZ families and their graph decisions
tests/encode_transformer.rs, tests/encode_ml.rs, tests/encode_sddl.rsNumeric selection, model/configuration parsing and SDDL interpreters
tests/encode_dict.rs, tests/custom_codec.rsBundles and application codecs, source chains and C interoperability
tests/codecs_*.rs, tests/typed_outputs.rsReference-encoded frames, malformed streams and typed output validation

Internal identity tests

tests/streaming.rs, tests/io.rs, tests/uninit.rs and tests/simd_backends.rs cover arbitrary input/output splits, completion, buffer destinations and every available backend. Private core tests explicitly exercise scalar baselines. tests/parallel.rs covers task ordering, sources, staging and streaming assembly. tests/framed.rs covers independent graphs, typed resources, metadata, seek, transport retries and corruption/truncation.

Feature and fuzz coverage

scripts/check_codec_features.py checks public direction/codec isolation. scripts/check_simd_uninit.py selects focused Miri tests and full ASan codec modules; x86-64 Miri uses x86-64-v3, while ARM64 tests Neon paths supported by Miri and leaves unsupported Neon operations to ASan.

The isolated AFL package covers ordinary/typed decoding, one-shot and streaming compression, graph configuration, individual nodes, custom codecs, parallel assembly and framed containers. Every target has an engine-neutral body, small regression corpus and matching libFuzzer adapter. CI replays regressions and ClusterFuzzLite runs on pull requests and daily schedules. See fuzzing coverage and commands.

Framed-container smoke campaign — 2026-11-11

Apple M5 Pro, aarch64-apple-darwin, libFuzzer with AddressSanitizer under Rust 1.101.0-nightly (2026-10-23). The bounded arbitrary-container target completed 411,538 executions in 31 seconds; resource round-trip completed 67,489 in 31 seconds, with no findings. Inputs cap frame/output sizes and resource count; the round-trip oracle checks native/one-shot equality and C decoding of every embedded frame. These are short smoke campaigns, not an exhaustive audit.

The same AFL++ 4.40c/cargo-afl 0.18.2 targets built and replayed the committed corpus. Local campaigns failed before execution because macOS SysV shared-memory allocation was unavailable, also outside the sandbox. No host tuning was done. Linux CI contains bounded AFL smoke runs. See the recorded framing verification.

Campaign records and reproducibility

Record input corpus, target, revision, toolchain, feature set, engine version, limits, duration and findings together. Keep live queues/findings out of Git. Minimize every confirmed crash, commit a deterministic regression, then fix it. The AFL guide and libFuzzer guide describe replay and minimization. Every published campaign record should identify the mopenzl revision and its actual target bodies, corpus and oracle.

Limits of the evidence

Passing tests and short fuzz runs do not prove every input safe or every platform equivalent. Function coverage shows which functions ran, not exhaustive branch or state coverage. There are no Lean/Aeneas proofs in this repository. C compatibility intentionally differs where the reference reads uninitialized data on malformed input; see decoder codec deviations. Directory/resource checksums detect corruption and do not authenticate input.

Silesia comparison

AMD Ryzen 9 7950X (KVM, 2 vCPUs) · rustc 1.99.1 · C at -O3 · full Silesia files · 2026-11-10. Each standard graph compresses every listed Silesia file as one input with the library defaults (level 6, format version 27, both checksums). Every frame was verified byte-identical to OpenZL v0.3.0 C, and both decoders restored the input, before timing; compressed sizes are therefore equal. Throughput is MB/s (10⁶ bytes per second) of uncompressed bytes, from the median of alternating rounds; a graph’s figure is its total bytes over its total median time. “/ C” is mopenzl’s speed relative to C (above 1× is faster).

Raw measurements: silesia-2026-11-10/ · how to reproduce.

Per graph

GraphFilesC compressmopenzl compress/ CC decompressmopenzl decompress/ C
zstd12142 MB/s142 MB/s0.99×1,304 MB/s1,231 MB/s0.94×
lz41280.2 MB/s75.7 MB/s0.94×2,552 MB/s2,394 MB/s0.94×
lz12114 MB/s96.1 MB/s0.85×677 MB/s1,036 MB/s1.53×
huffman121,125 MB/s1,052 MB/s0.94×1,591 MB/s1,235 MB/s0.78×
fse12619 MB/s597 MB/s0.96×1,090 MB/s955 MB/s0.88×
entropy121,075 MB/s989 MB/s0.92×1,507 MB/s1,198 MB/s0.79×
huffman_pivco12967 MB/s1,054 MB/s1.09×2,325 MB/s2,422 MB/s1.04×
flatpack12985 MB/s1,425 MB/s1.45×2,733 MB/s4,073 MB/s1.49×
generic12145 MB/s141 MB/s0.97×1,541 MB/s1,487 MB/s0.96×
field_lz_struct4389.5 MB/s73.2 MB/s0.82×1,946 MB/s1,398 MB/s0.72×
bitpack_num1624,721 MB/s8,438 MB/s1.79×7,666 MB/s17,192 MB/s2.24×
numeric16228.3 MB/s26.7 MB/s0.95×1,020 MB/s810 MB/s0.79×
numeric32388.8 MB/s75.9 MB/s0.85×1,840 MB/s1,241 MB/s0.67×
generic_num16228.6 MB/s25.2 MB/s0.88×964 MB/s799 MB/s0.83×
generic_num32380.9 MB/s71.5 MB/s0.88×1,810 MB/s1,188 MB/s0.66×

zstd

FileBytesRatioC compressmopenzl compress/ CC decompressmopenzl decompress/ C
dickens101924462.982102 MB/s102 MB/s1.00×1,326 MB/s1,324 MB/s1.00×
mozilla512204802.986143 MB/s139 MB/s0.97×916 MB/s865 MB/s0.94×
mr99705642.944132 MB/s131 MB/s0.99×1,599 MB/s1,479 MB/s0.92×
nci3355344513.830264 MB/s258 MB/s0.98×2,939 MB/s2,427 MB/s0.83×
ooffice61521922.099117 MB/s113 MB/s0.96×1,302 MB/s1,256 MB/s0.96×
osdb100856842.970149 MB/s152 MB/s1.02×2,079 MB/s2,164 MB/s1.04×
reymont66272023.735118 MB/s117 MB/s0.98×1,747 MB/s1,754 MB/s1.00×
samba216064004.724179 MB/s173 MB/s0.97×2,157 MB/s1,740 MB/s0.81×
sao72519441.37594.3 MB/s101 MB/s1.07×1,373 MB/s1,275 MB/s0.93×
webster414587033.836117 MB/s118 MB/s1.01×958 MB/s948 MB/s0.99×
x-ray84742401.526110 MB/s114 MB/s1.04×1,131 MB/s1,073 MB/s0.95×
xml53452809.777249 MB/s236 MB/s0.95×2,868 MB/s3,270 MB/s1.14×

lz4

FileBytesRatioC compressmopenzl compress/ CC decompressmopenzl decompress/ C
dickens101924462.28449.4 MB/s48.7 MB/s0.99×3,926 MB/s3,393 MB/s0.86×
mozilla512204802.30993.4 MB/s88.3 MB/s0.95×1,718 MB/s1,684 MB/s0.98×
mr99705642.30858.6 MB/s57.5 MB/s0.98×4,423 MB/s3,792 MB/s0.86×
nci335534458.889136 MB/s112 MB/s0.83×5,813 MB/s5,269 MB/s0.91×
ooffice61521921.73361.6 MB/s58.0 MB/s0.94×3,530 MB/s3,183 MB/s0.90×
osdb100856842.53492.0 MB/s87.5 MB/s0.95×4,904 MB/s4,351 MB/s0.89×
reymont66272023.05550.0 MB/s49.0 MB/s0.98×3,855 MB/s3,652 MB/s0.95×
samba216064003.508108 MB/s105 MB/s0.97×4,398 MB/s4,132 MB/s0.94×
sao72519441.26146.4 MB/s45.0 MB/s0.97×3,240 MB/s3,000 MB/s0.93×
webster414587032.94568.4 MB/s65.5 MB/s0.96×1,561 MB/s1,442 MB/s0.92×
x-ray84742401.18165.3 MB/s59.1 MB/s0.91×3,320 MB/s3,084 MB/s0.93×
xml53452806.844144 MB/s137 MB/s0.95×6,412 MB/s5,843 MB/s0.91×

lz

FileBytesRatioC compressmopenzl compress/ CC decompressmopenzl decompress/ C
dickens101924462.93562.9 MB/s55.9 MB/s0.89×436 MB/s647 MB/s1.48×
mozilla512204802.713120 MB/s97.9 MB/s0.82×617 MB/s1,032 MB/s1.67×
mr99705642.954105 MB/s87.2 MB/s0.83×525 MB/s908 MB/s1.73×
nci3355344512.380197 MB/s191 MB/s0.97×1,384 MB/s1,549 MB/s1.12×
ooffice61521921.97295.5 MB/s78.5 MB/s0.82×593 MB/s737 MB/s1.24×
osdb100856842.945134 MB/s112 MB/s0.84×824 MB/s1,135 MB/s1.38×
reymont66272023.65187.5 MB/s67.9 MB/s0.78×590 MB/s1,104 MB/s1.87×
samba216064004.437144 MB/s112 MB/s0.78×942 MB/s1,327 MB/s1.41×
sao72519441.37094.5 MB/s76.2 MB/s0.81×618 MB/s825 MB/s1.34×
webster414587033.73991.5 MB/s78.8 MB/s0.86×582 MB/s930 MB/s1.60×
x-ray84742401.477101 MB/s87.5 MB/s0.86×421 MB/s770 MB/s1.83×
xml53452808.950177 MB/s162 MB/s0.92×1,396 MB/s2,021 MB/s1.45×

huffman

FileBytesRatioC compressmopenzl compress/ CC decompressmopenzl decompress/ C
dickens101924461.7481,228 MB/s1,166 MB/s0.95×2,119 MB/s1,482 MB/s0.70×
mozilla512204801.4411,000 MB/s977 MB/s0.98×1,338 MB/s1,049 MB/s0.78×
mr99705642.2501,103 MB/s1,047 MB/s0.95×1,984 MB/s1,398 MB/s0.70×
nci335534453.2561,213 MB/s1,137 MB/s0.94×1,841 MB/s1,441 MB/s0.78×
ooffice61521921.2681,156 MB/s1,095 MB/s0.95×1,584 MB/s1,041 MB/s0.66×
osdb100856841.2021,100 MB/s1,028 MB/s0.93×1,447 MB/s1,177 MB/s0.81×
reymont66272021.6581,378 MB/s998 MB/s0.72×2,021 MB/s1,610 MB/s0.80×
samba216064001.5141,212 MB/s1,084 MB/s0.89×1,584 MB/s1,172 MB/s0.74×
sao72519441.0741,195 MB/s1,102 MB/s0.92×1,529 MB/s1,101 MB/s0.72×
webster414587031.5991,102 MB/s1,045 MB/s0.95×1,622 MB/s1,342 MB/s0.83×
x-ray84742401.2471,130 MB/s976 MB/s0.86×1,373 MB/s1,098 MB/s0.80×
xml53452801.6091,368 MB/s1,208 MB/s0.88×2,063 MB/s1,610 MB/s0.78×

fse

FileBytesRatioC compressmopenzl compress/ CC decompressmopenzl decompress/ C
dickens101924461.761564 MB/s562 MB/s1.00×972 MB/s864 MB/s0.89×
mozilla512204801.443532 MB/s488 MB/s0.92×1,077 MB/s966 MB/s0.90×
mr99705642.272656 MB/s580 MB/s0.89×1,090 MB/s897 MB/s0.82×
nci335534453.273681 MB/s653 MB/s0.96×1,029 MB/s844 MB/s0.82×
ooffice61521921.266686 MB/s652 MB/s0.95×1,172 MB/s1,071 MB/s0.91×
osdb100856841.202673 MB/s642 MB/s0.95×1,141 MB/s1,007 MB/s0.88×
reymont66272021.665730 MB/s698 MB/s0.96×1,254 MB/s1,104 MB/s0.88×
samba216064001.520582 MB/s654 MB/s1.12×1,111 MB/s998 MB/s0.90×
sao72519441.070687 MB/s643 MB/s0.94×1,254 MB/s1,101 MB/s0.88×
webster414587031.607665 MB/s659 MB/s0.99×1,065 MB/s966 MB/s0.91×
x-ray84742401.245671 MB/s614 MB/s0.92×1,224 MB/s1,020 MB/s0.83×
xml53452801.618667 MB/s684 MB/s1.03×1,264 MB/s1,071 MB/s0.85×

entropy

FileBytesRatioC compressmopenzl compress/ CC decompressmopenzl decompress/ C
dickens101924461.7481,172 MB/s1,152 MB/s0.98×1,953 MB/s1,487 MB/s0.76×
mozilla512204801.4421,038 MB/s978 MB/s0.94×1,362 MB/s1,056 MB/s0.78×
mr99705642.2581,065 MB/s975 MB/s0.92×1,802 MB/s1,378 MB/s0.76×
nci335534453.2561,137 MB/s975 MB/s0.86×1,500 MB/s1,481 MB/s0.99×
ooffice61521921.2681,078 MB/s1,048 MB/s0.97×1,433 MB/s1,123 MB/s0.78×
osdb100856841.2021,104 MB/s952 MB/s0.86×1,375 MB/s981 MB/s0.71×
reymont66272021.6581,330 MB/s1,041 MB/s0.78×1,991 MB/s1,609 MB/s0.81×
samba216064001.5141,094 MB/s959 MB/s0.88×1,441 MB/s1,020 MB/s0.71×
sao72519441.0741,123 MB/s907 MB/s0.81×1,305 MB/s1,114 MB/s0.85×
webster414587031.599983 MB/s1,024 MB/s1.04×1,622 MB/s1,253 MB/s0.77×
x-ray84742401.2471,054 MB/s965 MB/s0.92×1,434 MB/s1,088 MB/s0.76×
xml53452801.6091,374 MB/s929 MB/s0.68×1,837 MB/s1,649 MB/s0.90×

huffman_pivco

FileBytesRatioC compressmopenzl compress/ CC decompressmopenzl decompress/ C
dickens101924461.7451,008 MB/s1,140 MB/s1.13×3,210 MB/s3,281 MB/s1.02×
mozilla512204801.439903 MB/s913 MB/s1.01×1,859 MB/s1,962 MB/s1.06×
mr99705642.247987 MB/s1,281 MB/s1.30×3,462 MB/s3,490 MB/s1.01×
nci335534453.2511,130 MB/s1,575 MB/s1.39×2,787 MB/s2,979 MB/s1.07×
ooffice61521921.266974 MB/s992 MB/s1.02×2,871 MB/s3,066 MB/s1.07×
osdb100856841.200913 MB/s832 MB/s0.91×2,457 MB/s2,447 MB/s1.00×
reymont66272021.6561,136 MB/s1,179 MB/s1.04×3,466 MB/s4,167 MB/s1.20×
samba216064001.512932 MB/s1,004 MB/s1.08×2,149 MB/s2,179 MB/s1.01×
sao72519441.073831 MB/s888 MB/s1.07×2,729 MB/s2,802 MB/s1.03×
webster414587031.597937 MB/s1,027 MB/s1.10×2,046 MB/s2,099 MB/s1.03×
x-ray84742401.246973 MB/s991 MB/s1.02×3,004 MB/s2,918 MB/s0.97×
xml53452801.6071,203 MB/s1,213 MB/s1.01×3,478 MB/s4,131 MB/s1.19×

flatpack

FileBytesRatioC compressmopenzl compress/ CC decompressmopenzl decompress/ C
dickens101924461.1431,154 MB/s1,958 MB/s1.70×1,515 MB/s2,562 MB/s1.69×
mozilla512204801.000847 MB/s1,119 MB/s1.32×5,774 MB/s8,016 MB/s1.39×
mr99705641.0001,397 MB/s1,954 MB/s1.40×12,087 MB/s17,584 MB/s1.45×
nci335534451.3331,074 MB/s1,792 MB/s1.67×1,536 MB/s2,249 MB/s1.46×
ooffice61521921.0001,416 MB/s2,108 MB/s1.49×13,131 MB/s19,621 MB/s1.49×
osdb100856841.0001,357 MB/s1,871 MB/s1.38×11,147 MB/s17,714 MB/s1.59×
reymont66272021.0001,391 MB/s2,076 MB/s1.49×13,042 MB/s19,593 MB/s1.50×
samba216064001.0001,093 MB/s1,421 MB/s1.30×6,972 MB/s10,658 MB/s1.53×
sao72519441.0001,352 MB/s1,968 MB/s1.46×12,698 MB/s18,868 MB/s1.49×
webster414587031.143751 MB/s1,133 MB/s1.51×1,401 MB/s2,067 MB/s1.48×
x-ray84742401.0001,367 MB/s1,921 MB/s1.41×12,384 MB/s18,283 MB/s1.48×
xml53452801.1431,231 MB/s2,142 MB/s1.74×1,552 MB/s2,633 MB/s1.70×

generic

FileBytesRatioC compressmopenzl compress/ CC decompressmopenzl decompress/ C
dickens101924462.982102 MB/s100 MB/s0.99×1,431 MB/s1,191 MB/s0.83×
mozilla512204802.986147 MB/s144 MB/s0.98×1,303 MB/s1,257 MB/s0.96×
mr99705642.944134 MB/s130 MB/s0.97×1,546 MB/s1,482 MB/s0.96×
nci3355344513.830263 MB/s259 MB/s0.99×2,924 MB/s2,598 MB/s0.89×
ooffice61521922.099115 MB/s109 MB/s0.95×1,301 MB/s1,066 MB/s0.82×
osdb100856842.970149 MB/s145 MB/s0.98×1,857 MB/s1,973 MB/s1.06×
reymont66272023.735103 MB/s104 MB/s1.01×1,695 MB/s1,613 MB/s0.95×
samba216064004.724190 MB/s172 MB/s0.90×2,024 MB/s1,945 MB/s0.96×
sao72519441.375108 MB/s105 MB/s0.97×1,249 MB/s1,261 MB/s1.01×
webster414587033.836121 MB/s118 MB/s0.98×1,254 MB/s1,341 MB/s1.07×
x-ray84742401.526107 MB/s105 MB/s0.98×1,071 MB/s925 MB/s0.86×
xml53452809.777248 MB/s237 MB/s0.96×3,317 MB/s3,212 MB/s0.97×

field_lz_struct4

FileBytesRatioC compressmopenzl compress/ CC decompressmopenzl decompress/ C
sao72519441.47162.8 MB/s43.1 MB/s0.69×1,379 MB/s1,116 MB/s0.81×
osdb100856843.02760.8 MB/s50.3 MB/s0.83×1,727 MB/s1,318 MB/s0.76×
nci3355344412.506117 MB/s103 MB/s0.88×2,230 MB/s1,508 MB/s0.68×

bitpack_num16

FileBytesRatioC compressmopenzl compress/ CC decompressmopenzl decompress/ C
mr99705641.0004,316 MB/s8,106 MB/s1.88×7,495 MB/s16,517 MB/s2.20×
x-ray84742401.0005,308 MB/s8,865 MB/s1.67×7,876 MB/s18,060 MB/s2.29×

numeric16

FileBytesRatioC compressmopenzl compress/ CC decompressmopenzl decompress/ C
mr99705643.17834.9 MB/s32.2 MB/s0.92×1,338 MB/s1,097 MB/s0.82×
x-ray84742401.63623.1 MB/s22.3 MB/s0.96×797 MB/s619 MB/s0.78×

numeric32

FileBytesRatioC compressmopenzl compress/ CC decompressmopenzl decompress/ C
sao72519441.47166.1 MB/s45.9 MB/s0.69×1,339 MB/s1,105 MB/s0.83×
osdb100856843.02757.7 MB/s47.7 MB/s0.83×1,599 MB/s1,129 MB/s0.71×
nci3355344412.506116 MB/s112 MB/s0.96×2,107 MB/s1,315 MB/s0.62×

generic_num16

FileBytesRatioC compressmopenzl compress/ CC decompressmopenzl decompress/ C
mr99705643.17836.9 MB/s30.2 MB/s0.82×1,325 MB/s1,070 MB/s0.81×
x-ray84742401.63622.6 MB/s21.1 MB/s0.94×730 MB/s616 MB/s0.84×

generic_num32

FileBytesRatioC compressmopenzl compress/ CC decompressmopenzl decompress/ C
sao72519441.47160.5 MB/s44.6 MB/s0.74×1,220 MB/s1,042 MB/s0.85×
osdb100856843.02752.2 MB/s44.1 MB/s0.84×1,507 MB/s1,069 MB/s0.71×
nci3355344412.506106 MB/s105 MB/s0.99×2,167 MB/s1,270 MB/s0.59×

Encoder results

Published measurements compare mopenzl with the pinned OpenZL v0.3.0 C implementation on identical complete Silesia files. The matrix varies graphs and typed views at default level 6, format 27 and both checksums. Every inner frame was checked byte-identical before either implementation was timed.

AMD Ryzen 9 7950X (KVM, 2 vCPUs) · rustc 1.99.1 · C -O3 · 2026-11-10.

Graph / input viewFilesInput bytesCompressed bytesRatioC MB/smopenzl MB/s/ C
zstd12211,938,58061,240,1933.461142.4141.60.99×
lz412211,938,58078,385,8762.70480.275.70.94×
lz12211,938,58064,385,3083.292113.796.10.85×
huffman12211,938,580130,419,8621.6251,124.61,051.60.94×
fse12211,938,580130,057,2281.630618.6596.70.96×
entropy12211,938,580130,379,9521.6261,075.3989.20.92×
huffman_pivco12211,938,580130,590,0791.623967.11,053.91.09×
flatpack12211,938,580196,426,3721.079985.31,425.51.45×
generic12211,938,58061,240,1933.461145.2141.10.97×
field_lz_struct4350,891,07210,944,1334.65089.573.20.82×
bitpack_num16218,444,80418,444,8721.0004,721.38,437.71.79×
numeric16218,444,8048,318,9782.21728.326.70.95×
numeric32350,891,07210,944,1384.65088.875.90.85×
generic_num16218,444,8048,318,9782.21728.625.20.88×
generic_num32350,891,07210,944,1384.65080.971.50.88×

Throughput is uncompressed MB/s (10⁶ bytes/s), aggregated as total bytes over the sum of per-file median times. Compressed bytes are equal for both sides. The linked graph pages contain file-by-file numbers and charts. This dataset is a graph sweep, not a per-level benchmark; no unmeasured levels are implied.

Read the measurements

Use the full comparison for per-file charts and ratios, raw CSV for analysis, and commands to reproduce the API mode and corpus. Numeric/record views use only aligned compatible files, so compare each graph within its own corpus rather than ranking unrelated rows as one workload.

Other API modes

The parallel API records segment/task scheduling separately. The framed measurements record container overhead and independent-frame C timings. End-to-end results include different setup/allocation costs from reused per-graph benchmarks.

zstd · compress

Derived from the committed Silesia CSV; context and parameters are in the measurement record. C and Rust use identical frame bytes. Throughput is uncompressed MB/s (10⁶ bytes/s); ratios are uncompressed bytes / compressed bytes.

FileInput bytesCompressed bytesRatioC MB/smopenzl MB/s/ C
dickens10,192,4463,418,5442.982101.9101.71.00×
mozilla51,220,48017,156,0512.986143.2139.30.97×
mr9,970,5643,386,4902.944132.1131.20.99×
nci33,553,4452,426,21013.830264.0257.80.98×
ooffice6,152,1922,931,2362.099117.1112.70.96×
osdb10,085,6843,395,7682.970148.7151.81.02×
reymont6,627,2021,774,2563.735118.5116.60.98×
samba21,606,4004,573,4144.724179.4173.20.97×
sao7,251,9445,272,4121.37594.3100.81.07×
webster41,458,70310,807,5533.836116.7118.11.01×
x-ray8,474,2405,551,5301.526109.7114.11.04×
xml5,345,280546,7299.777249.1235.60.95×

All graphs · Encoder matrix · Decoder matrix · Download CSV · Reproduction

lz4 · compress

Derived from the committed Silesia CSV; context and parameters are in the measurement record. C and Rust use identical frame bytes. Throughput is uncompressed MB/s (10⁶ bytes/s); ratios are uncompressed bytes / compressed bytes.

FileInput bytesCompressed bytesRatioC MB/smopenzl MB/s/ C
dickens10,192,4464,463,1752.28449.448.70.99×
mozilla51,220,48022,186,5712.30993.488.30.95×
mr9,970,5644,320,4082.30858.657.50.98×
nci33,553,4453,774,7168.889135.6112.40.83×
ooffice6,152,1923,550,5101.73361.658.00.94×
osdb10,085,6843,980,4942.53492.087.50.95×
reymont6,627,2022,169,5613.05550.049.00.98×
samba21,606,4006,158,3023.508108.0104.70.97×
sao7,251,9445,749,7781.26146.445.00.97×
webster41,458,70314,076,3402.94568.465.50.96×
x-ray8,474,2407,175,0391.18165.359.10.91×
xml5,345,280780,9826.844144.0136.90.95×

All graphs · Encoder matrix · Decoder matrix · Download CSV · Reproduction

lz · compress

Derived from the committed Silesia CSV; context and parameters are in the measurement record. C and Rust use identical frame bytes. Throughput is uncompressed MB/s (10⁶ bytes/s); ratios are uncompressed bytes / compressed bytes.

FileInput bytesCompressed bytesRatioC MB/smopenzl MB/s/ C
dickens10,192,4463,472,6062.93562.955.90.89×
mozilla51,220,48018,882,1242.713119.797.90.82×
mr9,970,5643,375,0352.954104.887.20.83×
nci33,553,4452,710,29012.380197.4190.90.97×
ooffice6,152,1923,119,4131.97295.578.50.82×
osdb10,085,6843,424,5222.945134.4112.40.84×
reymont6,627,2021,815,3803.65187.567.90.78×
samba21,606,4004,869,4064.437143.7112.10.78×
sao7,251,9445,294,3081.37094.576.20.81×
webster41,458,70311,087,7243.73991.578.80.86×
x-ray8,474,2405,737,2501.477101.487.50.86×
xml5,345,280597,2508.950176.9162.20.92×

All graphs · Encoder matrix · Decoder matrix · Download CSV · Reproduction

huffman · compress

Derived from the committed Silesia CSV; context and parameters are in the measurement record. C and Rust use identical frame bytes. Throughput is uncompressed MB/s (10⁶ bytes/s); ratios are uncompressed bytes / compressed bytes.

FileInput bytesCompressed bytesRatioC MB/smopenzl MB/s/ C
dickens10,192,4465,832,2531.7481,228.41,166.00.95×
mozilla51,220,48035,552,0391.441999.5977.30.98×
mr9,970,5644,430,9852.2501,103.01,047.10.95×
nci33,553,44510,303,8163.2561,213.41,137.20.94×
ooffice6,152,1924,851,2651.2681,156.11,095.10.95×
osdb10,085,6848,392,3291.2021,099.91,028.20.93×
reymont6,627,2023,996,6161.6581,378.3997.50.72×
samba21,606,40014,269,1761.5141,211.91,084.30.89×
sao7,251,9446,751,1271.0741,195.11,101.90.92×
webster41,458,70325,921,2021.5991,102.11,044.90.95×
x-ray8,474,2406,797,4641.2471,130.5975.90.86×
xml5,345,2803,321,5901.6091,368.31,207.80.88×

All graphs · Encoder matrix · Decoder matrix · Download CSV · Reproduction

fse · compress

Derived from the committed Silesia CSV; context and parameters are in the measurement record. C and Rust use identical frame bytes. Throughput is uncompressed MB/s (10⁶ bytes/s); ratios are uncompressed bytes / compressed bytes.

FileInput bytesCompressed bytesRatioC MB/smopenzl MB/s/ C
dickens10,192,4465,788,2291.761564.2561.61.00×
mozilla51,220,48035,496,7171.443531.8488.20.92×
mr9,970,5644,389,3642.272655.6580.40.89×
nci33,553,44510,251,5783.273680.7653.10.96×
ooffice6,152,1924,857,9491.266685.8651.60.95×
osdb10,085,6848,392,2271.202673.0642.00.95×
reymont6,627,2023,980,9721.665730.3698.10.96×
samba21,606,40014,214,8921.520581.8653.71.12×
sao7,251,9446,775,4271.070687.0642.60.94×
webster41,458,70325,801,6311.607665.0658.80.99×
x-ray8,474,2406,804,2451.245670.7613.90.92×
xml5,345,2803,303,9971.618667.0683.81.03×

All graphs · Encoder matrix · Decoder matrix · Download CSV · Reproduction

entropy · compress

Derived from the committed Silesia CSV; context and parameters are in the measurement record. C and Rust use identical frame bytes. Throughput is uncompressed MB/s (10⁶ bytes/s); ratios are uncompressed bytes / compressed bytes.

FileInput bytesCompressed bytesRatioC MB/smopenzl MB/s/ C
dickens10,192,4465,832,2531.7481,172.11,152.10.98×
mozilla51,220,48035,528,3381.4421,037.8978.50.94×
mr9,970,5644,416,5282.2581,065.0974.70.92×
nci33,553,44510,303,8163.2561,136.5974.70.86×
ooffice6,152,1924,851,2651.2681,077.71,047.60.97×
osdb10,085,6848,392,3291.2021,103.8951.80.86×
reymont6,627,2023,996,6161.6581,330.01,041.30.78×
samba21,606,40014,267,4241.5141,093.6958.60.88×
sao7,251,9446,751,1271.0741,122.6906.50.81×
webster41,458,70325,921,2021.599982.91,023.61.04×
x-ray8,474,2406,797,4641.2471,054.1965.40.92×
xml5,345,2803,321,5901.6091,373.9929.00.68×

All graphs · Encoder matrix · Decoder matrix · Download CSV · Reproduction

huffman_pivco · compress

Derived from the committed Silesia CSV; context and parameters are in the measurement record. C and Rust use identical frame bytes. Throughput is uncompressed MB/s (10⁶ bytes/s); ratios are uncompressed bytes / compressed bytes.

FileInput bytesCompressed bytesRatioC MB/smopenzl MB/s/ C
dickens10,192,4465,839,6681.7451,007.91,139.61.13×
mozilla51,220,48035,598,9331.439902.8912.81.01×
mr9,970,5644,438,0552.247986.81,281.11.30×
nci33,553,44510,321,5863.2511,130.51,575.51.39×
ooffice6,152,1924,858,3121.266973.8992.11.02×
osdb10,085,6848,403,3201.200912.6832.30.91×
reymont6,627,2024,001,8591.6561,136.01,178.71.04×
samba21,606,40014,285,9891.512931.91,004.41.08×
sao7,251,9446,756,9351.073830.6887.81.07×
webster41,458,70325,955,9531.597937.41,026.51.10×
x-ray8,474,2406,803,7701.246972.7991.21.02×
xml5,345,2803,325,6991.6071,202.91,213.21.01×

All graphs · Encoder matrix · Decoder matrix · Download CSV · Reproduction

flatpack · compress

Derived from the committed Silesia CSV; context and parameters are in the measurement record. C and Rust use identical frame bytes. Throughput is uncompressed MB/s (10⁶ bytes/s); ratios are uncompressed bytes / compressed bytes.

FileInput bytesCompressed bytesRatioC MB/smopenzl MB/s/ C
dickens10,192,4468,918,5241.1431,153.71,958.31.70×
mozilla51,220,48051,220,5061.000846.71,119.41.32×
mr9,970,5649,970,5901.0001,397.11,953.81.40×
nci33,553,44525,165,1791.3331,073.81,792.41.67×
ooffice6,152,1926,152,2181.0001,416.42,108.31.49×
osdb10,085,68410,085,7101.0001,357.41,871.31.38×
reymont6,627,2026,627,2281.0001,391.02,076.41.49×
samba21,606,40021,606,4261.0001,093.11,420.81.30×
sao7,251,9447,251,9701.0001,352.21,967.61.46×
webster41,458,70336,276,4971.143751.31,133.01.51×
x-ray8,474,2408,474,2661.0001,366.81,920.61.41×
xml5,345,2804,677,2581.1431,231.42,141.81.74×

All graphs · Encoder matrix · Decoder matrix · Download CSV · Reproduction

generic · compress

Derived from the committed Silesia CSV; context and parameters are in the measurement record. C and Rust use identical frame bytes. Throughput is uncompressed MB/s (10⁶ bytes/s); ratios are uncompressed bytes / compressed bytes.

FileInput bytesCompressed bytesRatioC MB/smopenzl MB/s/ C
dickens10,192,4463,418,5442.982101.7100.40.99×
mozilla51,220,48017,156,0512.986147.1143.80.98×
mr9,970,5643,386,4902.944133.6130.00.97×
nci33,553,4452,426,21013.830262.6258.90.99×
ooffice6,152,1922,931,2362.099114.5108.90.95×
osdb10,085,6843,395,7682.970148.8145.20.98×
reymont6,627,2021,774,2563.735103.1104.41.01×
samba21,606,4004,573,4144.724190.2171.60.90×
sao7,251,9445,272,4121.375108.3104.80.97×
webster41,458,70310,807,5533.836121.3118.30.98×
x-ray8,474,2405,551,5301.526107.0104.90.98×
xml5,345,280546,7299.777247.8237.10.96×

All graphs · Encoder matrix · Decoder matrix · Download CSV · Reproduction

field_lz_struct4 · compress

Derived from the committed Silesia CSV; context and parameters are in the measurement record. C and Rust use identical frame bytes. Throughput is uncompressed MB/s (10⁶ bytes/s); ratios are uncompressed bytes / compressed bytes.

FileInput bytesCompressed bytesRatioC MB/smopenzl MB/s/ C
sao7,251,9444,929,5041.47162.843.10.69×
osdb10,085,6843,331,6003.02760.850.30.83×
nci33,553,4442,683,02912.506116.7103.00.88×

All graphs · Encoder matrix · Decoder matrix · Download CSV · Reproduction

bitpack_num16 · compress

Derived from the committed Silesia CSV; context and parameters are in the measurement record. C and Rust use identical frame bytes. Throughput is uncompressed MB/s (10⁶ bytes/s); ratios are uncompressed bytes / compressed bytes.

FileInput bytesCompressed bytesRatioC MB/smopenzl MB/s/ C
mr9,970,5649,970,5981.0004,316.18,105.71.88×
x-ray8,474,2408,474,2741.0005,307.78,865.01.67×

All graphs · Encoder matrix · Decoder matrix · Download CSV · Reproduction

numeric16 · compress

Derived from the committed Silesia CSV; context and parameters are in the measurement record. C and Rust use identical frame bytes. Throughput is uncompressed MB/s (10⁶ bytes/s); ratios are uncompressed bytes / compressed bytes.

FileInput bytesCompressed bytesRatioC MB/smopenzl MB/s/ C
mr9,970,5643,137,6773.17834.932.20.92×
x-ray8,474,2405,181,3011.63623.122.30.96×

All graphs · Encoder matrix · Decoder matrix · Download CSV · Reproduction

numeric32 · compress

Derived from the committed Silesia CSV; context and parameters are in the measurement record. C and Rust use identical frame bytes. Throughput is uncompressed MB/s (10⁶ bytes/s); ratios are uncompressed bytes / compressed bytes.

FileInput bytesCompressed bytesRatioC MB/smopenzl MB/s/ C
sao7,251,9444,929,5061.47166.145.90.69×
osdb10,085,6843,331,6023.02757.747.70.83×
nci33,553,4442,683,03012.506116.4111.50.96×

All graphs · Encoder matrix · Decoder matrix · Download CSV · Reproduction

generic_num16 · compress

Derived from the committed Silesia CSV; context and parameters are in the measurement record. C and Rust use identical frame bytes. Throughput is uncompressed MB/s (10⁶ bytes/s); ratios are uncompressed bytes / compressed bytes.

FileInput bytesCompressed bytesRatioC MB/smopenzl MB/s/ C
mr9,970,5643,137,6773.17836.930.20.82×
x-ray8,474,2405,181,3011.63622.621.10.94×

All graphs · Encoder matrix · Decoder matrix · Download CSV · Reproduction

generic_num32 · compress

Derived from the committed Silesia CSV; context and parameters are in the measurement record. C and Rust use identical frame bytes. Throughput is uncompressed MB/s (10⁶ bytes/s); ratios are uncompressed bytes / compressed bytes.

FileInput bytesCompressed bytesRatioC MB/smopenzl MB/s/ C
sao7,251,9444,929,5061.47160.544.60.74×
osdb10,085,6843,331,6023.02752.244.10.84×
nci33,553,4442,683,03012.506106.1104.80.99×

All graphs · Encoder matrix · Decoder matrix · Download CSV · Reproduction

Decoder results

Published measurements compare mopenzl with the pinned OpenZL v0.3.0 C implementation on identical complete Silesia files. The matrix varies graphs and typed views at default level 6, format 27 and both checksums. Every inner frame was checked byte-identical before either implementation was timed.

AMD Ryzen 9 7950X (KVM, 2 vCPUs) · rustc 1.99.1 · C -O3 · 2026-11-10.

Graph / input viewFilesInput bytesCompressed bytesRatioC MB/smopenzl MB/s/ C
zstd12211,938,58061,240,1933.4611,303.91,230.70.94×
lz412211,938,58078,385,8762.7042,551.82,393.60.94×
lz12211,938,58064,385,3083.292676.51,036.21.53×
huffman12211,938,580130,419,8621.6251,591.31,234.50.78×
fse12211,938,580130,057,2281.6301,089.7955.00.88×
entropy12211,938,580130,379,9521.6261,507.11,197.80.79×
huffman_pivco12211,938,580130,590,0791.6232,325.32,422.01.04×
flatpack12211,938,580196,426,3721.0792,733.34,072.71.49×
generic12211,938,58061,240,1933.4611,541.51,486.70.96×
field_lz_struct4350,891,07210,944,1334.6501,946.21,398.10.72×
bitpack_num16218,444,80418,444,8721.0007,665.617,191.82.24×
numeric16218,444,8048,318,9782.2171,019.6809.70.79×
numeric32350,891,07210,944,1384.6501,840.41,241.10.67×
generic_num16218,444,8048,318,9782.217963.9799.40.83×
generic_num32350,891,07210,944,1384.6501,809.91,188.40.66×

Throughput is uncompressed MB/s (10⁶ bytes/s), aggregated as total bytes over the sum of per-file median times. Compressed bytes are equal for both sides. The linked graph pages contain file-by-file numbers and charts. This dataset is a graph sweep, not a per-level benchmark; no unmeasured levels are implied.

Read the measurements

Use the full comparison for per-file charts and ratios, raw CSV for analysis, and commands to reproduce the API mode and corpus. Numeric/record views use only aligned compatible files, so compare each graph within its own corpus rather than ranking unrelated rows as one workload.

Other API modes

The parallel API records segment/task scheduling separately. The framed measurements record container overhead and independent-frame C timings. End-to-end results include different setup/allocation costs from reused per-graph benchmarks.

zstd · decompress

Derived from the committed Silesia CSV; context and parameters are in the measurement record. C and Rust use identical frame bytes. Throughput is uncompressed MB/s (10⁶ bytes/s); ratios are uncompressed bytes / compressed bytes.

FileInput bytesCompressed bytesRatioC MB/smopenzl MB/s/ C
dickens10,192,4463,418,5442.9821,326.41,324.11.00×
mozilla51,220,48017,156,0512.986916.4864.60.94×
mr9,970,5643,386,4902.9441,599.11,478.70.92×
nci33,553,4452,426,21013.8302,939.12,426.90.83×
ooffice6,152,1922,931,2362.0991,301.91,256.00.96×
osdb10,085,6843,395,7682.9702,079.12,163.71.04×
reymont6,627,2021,774,2563.7351,747.11,754.21.00×
samba21,606,4004,573,4144.7242,157.51,739.80.81×
sao7,251,9445,272,4121.3751,373.41,274.50.93×
webster41,458,70310,807,5533.836957.6948.30.99×
x-ray8,474,2405,551,5301.5261,130.71,073.00.95×
xml5,345,280546,7299.7772,867.63,270.41.14×

All graphs · Encoder matrix · Decoder matrix · Download CSV · Reproduction

lz4 · decompress

Derived from the committed Silesia CSV; context and parameters are in the measurement record. C and Rust use identical frame bytes. Throughput is uncompressed MB/s (10⁶ bytes/s); ratios are uncompressed bytes / compressed bytes.

FileInput bytesCompressed bytesRatioC MB/smopenzl MB/s/ C
dickens10,192,4464,463,1752.2843,926.03,393.30.86×
mozilla51,220,48022,186,5712.3091,717.81,683.90.98×
mr9,970,5644,320,4082.3084,423.03,791.90.86×
nci33,553,4453,774,7168.8895,812.95,269.40.91×
ooffice6,152,1923,550,5101.7333,529.93,183.40.90×
osdb10,085,6843,980,4942.5344,903.64,351.00.89×
reymont6,627,2022,169,5613.0553,854.63,652.50.95×
samba21,606,4006,158,3023.5084,398.04,132.30.94×
sao7,251,9445,749,7781.2613,239.53,000.30.93×
webster41,458,70314,076,3402.9451,561.11,442.00.92×
x-ray8,474,2407,175,0391.1813,320.13,083.90.93×
xml5,345,280780,9826.8446,411.95,843.00.91×

All graphs · Encoder matrix · Decoder matrix · Download CSV · Reproduction

lz · decompress

Derived from the committed Silesia CSV; context and parameters are in the measurement record. C and Rust use identical frame bytes. Throughput is uncompressed MB/s (10⁶ bytes/s); ratios are uncompressed bytes / compressed bytes.

FileInput bytesCompressed bytesRatioC MB/smopenzl MB/s/ C
dickens10,192,4463,472,6062.935436.4646.81.48×
mozilla51,220,48018,882,1242.713617.51,032.11.67×
mr9,970,5643,375,0352.954524.5908.21.73×
nci33,553,4452,710,29012.3801,384.31,549.51.12×
ooffice6,152,1923,119,4131.972592.9736.91.24×
osdb10,085,6843,424,5222.945824.31,135.21.38×
reymont6,627,2021,815,3803.651589.91,103.91.87×
samba21,606,4004,869,4064.437942.01,327.11.41×
sao7,251,9445,294,3081.370617.6825.31.34×
webster41,458,70311,087,7243.739581.9930.21.60×
x-ray8,474,2405,737,2501.477421.2770.01.83×
xml5,345,280597,2508.9501,396.22,021.11.45×

All graphs · Encoder matrix · Decoder matrix · Download CSV · Reproduction

huffman · decompress

Derived from the committed Silesia CSV; context and parameters are in the measurement record. C and Rust use identical frame bytes. Throughput is uncompressed MB/s (10⁶ bytes/s); ratios are uncompressed bytes / compressed bytes.

FileInput bytesCompressed bytesRatioC MB/smopenzl MB/s/ C
dickens10,192,4465,832,2531.7482,118.51,482.30.70×
mozilla51,220,48035,552,0391.4411,338.31,048.90.78×
mr9,970,5644,430,9852.2501,984.11,397.90.70×
nci33,553,44510,303,8163.2561,840.81,441.20.78×
ooffice6,152,1924,851,2651.2681,584.31,040.90.66×
osdb10,085,6848,392,3291.2021,446.71,176.70.81×
reymont6,627,2023,996,6161.6582,021.31,609.80.80×
samba21,606,40014,269,1761.5141,583.71,172.20.74×
sao7,251,9446,751,1271.0741,529.21,101.20.72×
webster41,458,70325,921,2021.5991,621.71,341.70.83×
x-ray8,474,2406,797,4641.2471,373.21,097.80.80×
xml5,345,2803,321,5901.6092,062.71,610.00.78×

All graphs · Encoder matrix · Decoder matrix · Download CSV · Reproduction

fse · decompress

Derived from the committed Silesia CSV; context and parameters are in the measurement record. C and Rust use identical frame bytes. Throughput is uncompressed MB/s (10⁶ bytes/s); ratios are uncompressed bytes / compressed bytes.

FileInput bytesCompressed bytesRatioC MB/smopenzl MB/s/ C
dickens10,192,4465,788,2291.761972.1863.90.89×
mozilla51,220,48035,496,7171.4431,076.6965.60.90×
mr9,970,5644,389,3642.2721,089.8896.60.82×
nci33,553,44510,251,5783.2731,029.0844.20.82×
ooffice6,152,1924,857,9491.2661,172.41,070.50.91×
osdb10,085,6848,392,2271.2021,141.11,007.30.88×
reymont6,627,2023,980,9721.6651,254.11,103.80.88×
samba21,606,40014,214,8921.5201,111.0998.00.90×
sao7,251,9446,775,4271.0701,254.11,100.80.88×
webster41,458,70325,801,6311.6071,065.3965.90.91×
x-ray8,474,2406,804,2451.2451,223.81,019.70.83×
xml5,345,2803,303,9971.6181,264.21,071.40.85×

All graphs · Encoder matrix · Decoder matrix · Download CSV · Reproduction

entropy · decompress

Derived from the committed Silesia CSV; context and parameters are in the measurement record. C and Rust use identical frame bytes. Throughput is uncompressed MB/s (10⁶ bytes/s); ratios are uncompressed bytes / compressed bytes.

FileInput bytesCompressed bytesRatioC MB/smopenzl MB/s/ C
dickens10,192,4465,832,2531.7481,952.81,486.90.76×
mozilla51,220,48035,528,3381.4421,361.51,056.40.78×
mr9,970,5644,416,5282.2581,801.61,377.80.76×
nci33,553,44510,303,8163.2561,499.71,480.90.99×
ooffice6,152,1924,851,2651.2681,432.61,123.00.78×
osdb10,085,6848,392,3291.2021,375.4980.90.71×
reymont6,627,2023,996,6161.6581,991.01,608.90.81×
samba21,606,40014,267,4241.5141,440.91,019.90.71×
sao7,251,9446,751,1271.0741,304.61,114.00.85×
webster41,458,70325,921,2021.5991,622.21,252.50.77×
x-ray8,474,2406,797,4641.2471,434.41,088.40.76×
xml5,345,2803,321,5901.6091,837.31,649.00.90×

All graphs · Encoder matrix · Decoder matrix · Download CSV · Reproduction

huffman_pivco · decompress

Derived from the committed Silesia CSV; context and parameters are in the measurement record. C and Rust use identical frame bytes. Throughput is uncompressed MB/s (10⁶ bytes/s); ratios are uncompressed bytes / compressed bytes.

FileInput bytesCompressed bytesRatioC MB/smopenzl MB/s/ C
dickens10,192,4465,839,6681.7453,210.53,281.01.02×
mozilla51,220,48035,598,9331.4391,858.91,961.91.06×
mr9,970,5644,438,0552.2473,462.43,489.71.01×
nci33,553,44510,321,5863.2512,787.42,979.21.07×
ooffice6,152,1924,858,3121.2662,870.93,065.91.07×
osdb10,085,6848,403,3201.2002,457.32,447.11.00×
reymont6,627,2024,001,8591.6563,465.84,166.81.20×
samba21,606,40014,285,9891.5122,148.92,179.41.01×
sao7,251,9446,756,9351.0732,728.52,801.81.03×
webster41,458,70325,955,9531.5972,045.52,098.91.03×
x-ray8,474,2406,803,7701.2463,003.62,918.30.97×
xml5,345,2803,325,6991.6073,478.34,130.91.19×

All graphs · Encoder matrix · Decoder matrix · Download CSV · Reproduction

flatpack · decompress

Derived from the committed Silesia CSV; context and parameters are in the measurement record. C and Rust use identical frame bytes. Throughput is uncompressed MB/s (10⁶ bytes/s); ratios are uncompressed bytes / compressed bytes.

FileInput bytesCompressed bytesRatioC MB/smopenzl MB/s/ C
dickens10,192,4468,918,5241.1431,514.72,561.51.69×
mozilla51,220,48051,220,5061.0005,774.48,016.41.39×
mr9,970,5649,970,5901.00012,086.817,583.81.45×
nci33,553,44525,165,1791.3331,536.32,248.71.46×
ooffice6,152,1926,152,2181.00013,131.319,621.51.49×
osdb10,085,68410,085,7101.00011,146.717,713.51.59×
reymont6,627,2026,627,2281.00013,042.319,593.11.50×
samba21,606,40021,606,4261.0006,971.510,658.41.53×
sao7,251,9447,251,9701.00012,697.618,868.01.49×
webster41,458,70336,276,4971.1431,400.92,067.21.48×
x-ray8,474,2408,474,2661.00012,384.418,283.21.48×
xml5,345,2804,677,2581.1431,552.12,633.31.70×

All graphs · Encoder matrix · Decoder matrix · Download CSV · Reproduction

generic · decompress

Derived from the committed Silesia CSV; context and parameters are in the measurement record. C and Rust use identical frame bytes. Throughput is uncompressed MB/s (10⁶ bytes/s); ratios are uncompressed bytes / compressed bytes.

FileInput bytesCompressed bytesRatioC MB/smopenzl MB/s/ C
dickens10,192,4463,418,5442.9821,431.41,190.90.83×
mozilla51,220,48017,156,0512.9861,303.01,257.00.96×
mr9,970,5643,386,4902.9441,546.31,482.10.96×
nci33,553,4452,426,21013.8302,923.62,597.60.89×
ooffice6,152,1922,931,2362.0991,300.71,065.80.82×
osdb10,085,6843,395,7682.9701,856.81,972.91.06×
reymont6,627,2021,774,2563.7351,694.81,612.90.95×
samba21,606,4004,573,4144.7242,023.71,945.30.96×
sao7,251,9445,272,4121.3751,248.61,260.81.01×
webster41,458,70310,807,5533.8361,254.41,340.61.07×
x-ray8,474,2405,551,5301.5261,070.8925.40.86×
xml5,345,280546,7299.7773,316.63,212.00.97×

All graphs · Encoder matrix · Decoder matrix · Download CSV · Reproduction

field_lz_struct4 · decompress

Derived from the committed Silesia CSV; context and parameters are in the measurement record. C and Rust use identical frame bytes. Throughput is uncompressed MB/s (10⁶ bytes/s); ratios are uncompressed bytes / compressed bytes.

FileInput bytesCompressed bytesRatioC MB/smopenzl MB/s/ C
sao7,251,9444,929,5041.4711,378.71,116.30.81×
osdb10,085,6843,331,6003.0271,726.81,317.60.76×
nci33,553,4442,683,02912.5062,229.71,508.00.68×

All graphs · Encoder matrix · Decoder matrix · Download CSV · Reproduction

bitpack_num16 · decompress

Derived from the committed Silesia CSV; context and parameters are in the measurement record. C and Rust use identical frame bytes. Throughput is uncompressed MB/s (10⁶ bytes/s); ratios are uncompressed bytes / compressed bytes.

FileInput bytesCompressed bytesRatioC MB/smopenzl MB/s/ C
mr9,970,5649,970,5981.0007,495.116,517.12.20×
x-ray8,474,2408,474,2741.0007,876.418,059.72.29×

All graphs · Encoder matrix · Decoder matrix · Download CSV · Reproduction

numeric16 · decompress

Derived from the committed Silesia CSV; context and parameters are in the measurement record. C and Rust use identical frame bytes. Throughput is uncompressed MB/s (10⁶ bytes/s); ratios are uncompressed bytes / compressed bytes.

FileInput bytesCompressed bytesRatioC MB/smopenzl MB/s/ C
mr9,970,5643,137,6773.1781,338.01,096.80.82×
x-ray8,474,2405,181,3011.636796.5619.10.78×

All graphs · Encoder matrix · Decoder matrix · Download CSV · Reproduction

numeric32 · decompress

Derived from the committed Silesia CSV; context and parameters are in the measurement record. C and Rust use identical frame bytes. Throughput is uncompressed MB/s (10⁶ bytes/s); ratios are uncompressed bytes / compressed bytes.

FileInput bytesCompressed bytesRatioC MB/smopenzl MB/s/ C
sao7,251,9444,929,5061.4711,338.91,104.70.83×
osdb10,085,6843,331,6023.0271,599.11,129.30.71×
nci33,553,4442,683,03012.5062,106.61,315.40.62×

All graphs · Encoder matrix · Decoder matrix · Download CSV · Reproduction

generic_num16 · decompress

Derived from the committed Silesia CSV; context and parameters are in the measurement record. C and Rust use identical frame bytes. Throughput is uncompressed MB/s (10⁶ bytes/s); ratios are uncompressed bytes / compressed bytes.

FileInput bytesCompressed bytesRatioC MB/smopenzl MB/s/ C
mr9,970,5643,137,6773.1781,325.11,069.70.81×
x-ray8,474,2405,181,3011.636729.9616.10.84×

All graphs · Encoder matrix · Decoder matrix · Download CSV · Reproduction

generic_num32 · decompress

Derived from the committed Silesia CSV; context and parameters are in the measurement record. C and Rust use identical frame bytes. Throughput is uncompressed MB/s (10⁶ bytes/s); ratios are uncompressed bytes / compressed bytes.

FileInput bytesCompressed bytesRatioC MB/smopenzl MB/s/ C
sao7,251,9444,929,5061.4711,220.41,042.00.85×
osdb10,085,6843,331,6023.0271,506.61,069.00.71×
nci33,553,4442,683,03012.5062,167.41,269.70.59×

All graphs · Encoder matrix · Decoder matrix · Download CSV · Reproduction

Decoder comparison

The decoder matrix decodes byte-identical OpenZL frames on both implementations. Each result preserves the same content type, width and string boundaries as its input. The full report includes per-file throughput, ratio and charts.

Implementations and API mode

Reuse prepared decoder contexts and caller output storage. Frame generation, corpus loading, validation and output checks remain outside timing. The published cases use the same level-6, format-27 frames as the encoder matrix; a decoder comparison must record how the compressed corpus was generated.

Interpret the result

Use total uncompressed bytes divided by summed median time for aggregate throughput. Distinguish serial, numeric and record views and their selected files. Backend availability and compiler flags belong to the recorded host, not a universal guarantee. Stored frames, typed node execution and compressed formats have different costs; a short smoke run is not a regression threshold.

Sources

Encoder comparison

The encoder matrix compares safe Rust OpenZL against the pinned C implementation, with matching graphs, parameters, typed views, frame bytes and corpus. C and Rust compressed sizes are identical; throughput is read against that fixed ratio. The full report includes every file.

Implementations and API mode

The C side uses OpenZL v0.3.0, zstd 1.5.7 and lz4 1.10.0 from the pinned submodules at -O3. The Rust side uses mopenzl and mzstd with the lockfile recorded by the comparison workspace. Prepared contexts and caller output storage are reused. Untimed validation precedes the alternating measurement rounds.

Levels, graphs and corpora

The published graph sweep uses level 6 and format 27. Serial bytes, numeric 16/32-bit inputs and four-byte records are separate views, with only compatible files in each view. Store-on-expansion and checksums follow defaults. There is no published multi-level sweep; changing a node/graph or level requires a fresh matched run. See reproduction.

Sources

Silesia record — 2026-11-10

AMD Ryzen 9 7950X (KVM, 2 vCPUs) · rustc 1.99.1 · C at -O3. Full Silesia files; level 6, format 27, both checksums. Every compared frame was verified byte-identical and both decoders restored the input before timing.

CSV columns are graph case, file, input bytes, compressed bytes, implementation side, operation, median/minimum time in nanoseconds, rounds and iterations. The dataset contains per-side/per-operation records. Numeric and record views select aligned files, as the harness’s list command reports.

The committed historical CSV contains the stated environment and measurement values; it does not embed full compiler flags, corpus digests or revision IDs. Record those alongside any new published run rather than inferring them from this snapshot. See the benchmark specification.

Architecture

Specifications describe how the implementation works today: ownership, APIs, data flow, state transitions, SIMD dispatch, errors and known gaps. To use the crate, read the guides instead; for checks and repository layout, see development.

Where to start

You want to understandRead, in order
What a frame looks like on the wireFrame format
How a compress call becomes a frameCompressor → Compression graphs → Standard graphs → Encoder codecs
How a frame is decodedDecompressor → Decoder codecs
How a codec family worksEntropy coders, LZ codecs and graphs, Transformer, ML selector and clustering, SDDL and SDDL2
How applications extend the codec setCustom codecs → Dictionaries
How one input is split across threadsParallel compression
How containers workFramed containers
How byte identity is establishedCompatibility → Fuzzing
Which features compile which codeCodec features

Specifications share an outline: module and ownership boundaries, public surface, control and data flow with Mermaid diagrams, and error propagation. What is missing or slower than the reference is listed under known gaps.

Module map

graph TD
    Root[mopenzl public facade] --> Encode[compressor: compression feature]
    Root --> Decode[decompressor: decompression feature]
    Root --> Framed["framing: opt-in framed resource containers"]
    Framed --> FCore["private framing::core: resource assembly, units, directory"]
    FCore --> Encode
    FCore --> Decode
    Root --> GraphApi["graph, input: graph builder, typed inputs (compression)"]
    Root --> Custom["custom_codec: application callbacks, signatures and contexts"]
    Custom --> ECore
    Custom --> DCore
    Root --> DictApi["dict: Dict, DictBundle, BundleStore"]
    Root --> Common[Backend, RetentionPolicy, FormatVersion, DataType, io]
    Encode --> Parallel["parallel: caller-scheduled tasks, batches, streams (std)"]
    GraphApi --> Parallel
    Parallel --> PCore[private parallel::core: workers, staging, ordered assembly]
    PCore --> ECore
    Encode --> Dest[shared::destination: initialized or MaybeUninit output]
    Decode --> Dest
    GraphApi --> Reg
    Encode --> ECore[private compressor::core]
    Decode --> DCore[private decompressor::core]
    ECore --> EStream[core::stream: session state machine]
    ECore --> EChunk[core::chunk + core::frame: specialized default-graph writer]
    ECore --> EZstd[core::zstd: Zstandard node]
    ECore --> EGraph[core::exec + layout + rtgraph: graph engine]
    EGraph --> Reg["core::registry: 85 nodes, 90 graphs, dict state"]
    Reg --> ECodecs["core::codecs: every node encoder, one module per feature"]
    Reg --> EGraphs["core::graphs: standard graphs, selectors, segmenters"]
    EGraphs --> Special["transformer, ml_selector, clustering, sddl, sddl2, entropy, lz"]
    DCore --> DStream[core::stream: unit assembly]
    DCore --> DChunk[core::chunk: chunk graph layout]
    DCore --> DZstd[core::zstd: magicless Zstandard decoder]
    DCore --> DCodecs[core::codecs: every standard codec, one module per feature]
    DCore --> DData[core::data: typed streams]
    EChunk --> Shared[private shared]
    ECodecs --> Shared
    EGraphs --> Shared
    DChunk --> Shared
    DCodecs --> Shared
    Shared --> Prims["wire, varint, bitpack, xxh3, sha256, dictwire"]
    Shared --> Algo["entropy, stats, estimate, numeric, bitpack_kernels, byte_transpose, cbor"]
    Prims --> SIMD[fearless_simd: selected backend]
    ECodecs --> SIMD
    DCodecs --> SIMD
    EZstd --> Mzstd[mzstd: safe-Rust zstd 1.5.7]
    DZstd --> Mzstd
    C[openzl-ffi: OpenZL v0.3.0 C] -. tests and benchmarks .-> Root

Public modules own ergonomic configuration and high-level errors. Private core modules own algorithms and state machines; implementation errors, SIMD types and mzstd types do not appear in public signatures. Each codec compiles independently.

SIMD dispatch boundary: the fearless_simd::Level is chosen once per Compressor / Decompressor (its Backend) and reaches every node encoder as NodeCx::simd and every codec decoder as State::simd. Each codec dispatches on it once per stream (PivCo-Huffman once per tree node) and runs monomorphized kernels inside the dispatched function; every kernel keeps a scalar baseline that tests compare at each level the host offers.

Common boundaries

The wire format, feature gates and primitives both directions share.

SpecificationScope
Frame formatMagic, frame header, chunk header lanes, stream numbering, stored streams, checksums and limits as both codecs read and write them.
Codec featuresDirection and per-codec feature gates, std/no_std, libm and profiling features, and how disabled codecs behave.
Shared primitivesVarints, bit packing, wire constants, XXH3 with its SIMD accumulator, and the encoder-side shared modules.
Uninitialized outputcompress_to_uninit, decompress_to_uninit and process_uninit: BorrowedBuf sinks, zero-free Vec decoding and their Miri/ASan coverage.

Compression

From the public Compressor to the graph engine and node encoders.

SpecificationScope
CompressorPublic API, the specialized default-graph path, chunking, sinks, the session state machine, graph selection, I/O and errors.
Compression graphsThe graph engine: typed streams, runtime graph, contexts, successor order, stores, conversions, trial compression and the registry tables.
Standard graphsThe decision logic of every standard graph: routing, stores, segmenters and structure graphs.
Encoder codecsEvery node encoder: node ID to codec ID, feature, ports, wire summaries, deviations and gaps.
Parallel compressionCaller-scheduled segments, graph/codec builder, source/staging ownership, dictionary-aware frame assembly and deferred streaming output.

Codec families

Selectors, interpreters and coders with enough machinery for a specification of their own.

SpecificationScope
Entropy codersOpenZL’s FSE/Huffman fork, PivCo-Huffman, the five entropy nodes and the entropy graph’s plan.
LZ codecs and graphslz4 (fast, HC, optimal), lz, field_lz, mux_lengths, quantize, the shared lz_common infrastructure and the LZ graphs.
TransformerThe numeric selector: features, generated scorers, guards, depth limits, static fallbacks, float determinism and the no_std caveat.
ML selector and clusteringGradient-boosted-tree selection and tag-based clustering with their CBOR configurations.
SDDL and SDDL2The description interpreter, the bytecode VM, chunking and replay.
Custom codecsApplication codec registration, typed and variable ports, callback contexts, custom wire IDs, validation, errors and lifetime.
DictionariesDict, DictBundle and BundleStore, wire formats, the encoder and decoder flows.

Decompression

The universal decoder and its codec catalogue.

SpecificationScope
DecompressorFrame and unit parsing, chunk graph layout, the typed node executor, typed multi-output decoding, dictionaries, sessions, limits, I/O and errors.
Decoder codecsEvery standard codec decoder: signatures, wire formats, invariants and deviations from the reference on malformed data.

Containers

The opt-in framed container around ordinary frames.

SpecificationScope
Framed containersOpt-in graph-per-resource containers, typed frames, metadata, bounded native sessions, transport retries and checked seek directories.

Development tools

How the repository measures, tests and publishes itself.

SpecificationScope
CompatibilityThe C oracle and its shims per family, byte-identity and decode differential tests, and what each test file covers.
FuzzingAFL targets, oracles, regression replay and the libFuzzer adapters.
Implementation comparisonCriterion and Silesia harness methodology (standard graphs and custom callbacks against C) and recorded results.
Continuous integrationWorkflows, feature probes, AFL smoke runs, Miri and AddressSanitizer selection, coverage gate, hotpath reports and releases.
Documentation publishingSource staging, navigation, section pages, recipe execution, link validation and Pages deployment.

Known gaps (whole crate)

  • Scope: every non-legacy OpenZL v0.3.0 node, graph, selector and segmenter is ported, on both sides. Deprecated nodes 76 to 85 and format versions below 21 are not supported; unregistered custom codecs decode as Unsupported.
  • Dictionaries: only Zstandard dictionaries materialize; no training (dictionaries).
  • Performance: fearless_simd kernels cover XXH3, the LZ row-table tag match, PivCo-Huffman (128-bit, plus 512-bit expand/compress on AVX-512), bitpack bit counting, byte transposes and flatpack. FSE, Huffman, LZ4 and the LZ decoders are tuned scalar loops compiled inside the dispatched level. Huffman decoding and the numeric and field_lz graphs remain 0.7 to 0.9× of C on Silesia; LZ4, FSE and Zstandard are within about 10% (comparison). The partition+bitpack decoder fusion is not ported.
  • no_std builds use the libm crate for floating point; the Transformer and estimator decisions are byte-identical with the reference only with std (transformer).
  • No C ABI crate. Parallel compression supports serial inputs; streaming holds compressed chunks until the total input size is known.
  • Verification gaps: no AFL targets for the graph engine, SDDL or CBOR configurations; new SIMD kernels and uninit paths require explicit Miri and ASan selections (fuzzing, CI).

Common boundaries

The wire format, feature gates and primitives both directions share.

SpecificationScope
Frame formatMagic, frame header, chunk header lanes, stream numbering, stored streams, checksums and limits as both codecs read and write them.
Codec featuresDirection and per-codec feature gates, std/no_std, libm and profiling features, and how disabled codecs behave.
Shared primitivesVarints, bit packing, wire constants, XXH3 with its SIMD accumulator, and the encoder-side shared modules.
Uninitialized outputcompress_to_uninit, decompress_to_uninit and process_uninit: BorrowedBuf sinks, zero-free Vec decoding and their Miri/ASan coverage.

Architecture overview and module map

Frame format

Both codecs follow the reference’s src/openzl/common/wire_format.h. This page describes the subset the crate reads and writes (versions 21 to 27), in shared::wire, compressor::core::{frame, chunk} and decompressor::core::{frame, chunk}.

Frame

flowchart LR
    M[magic LE: 0xD7B1A5C0 + version] --> F[flags]
    F --> B{v25+ and bundle flag?}
    B -- yes --> BID[bundle ID: len + bytes]
    B -- no --> T
    BID --> T[inputs token: count bits 0-3, types from bit 4]
    T --> S[varint size + 1 per input]
    S --> C{v22+ and comment flag?}
    C -- yes --> CM[varint len + comment]
    C -- no --> K
    CM --> K{compressed checksum flag?}
    K -- yes --> KB[check byte: XXH3 of header & 0xFF]
    K -- no --> CH
    KB --> CH[chunk ...]
    CH --> CH
    CH --> E[end marker 0x00]
  • Flags: bit 0 content checksums, bit 1 compressed checksums (and the header check byte), bit 2 comment (v22+), bit 3 bundle ID (v25+).
  • Bundle ID (flag bit 3, v25+): one length byte, then that many leading bytes of the 32-byte ID; trailing zero bytes are trimmed on write and restored on read (an all-zero ID is refused by the encoder). See dictionaries.
  • A first size byte of 0x00 means “unknown sizes”, which the reference decoder rejects; neither codec writes or accepts it.
  • The header lists any number of inputs of any type (the general form is below). The default graph writes one serial input, token 0x01. The decoder rejects zero inputs as invalid; the byte-oriented APIs report more than one input, or a non-serial one, as unsupported, while Decompressor::decompress_typed returns every output.
  • Magics from version 8 to 20, and up to 16 versions past 27, are OpenZL but unsupported; anything else is not OpenZL.

Chunk

flowchart TD
    A["varint(codecs + 1)"] --> B["varint(stored streams)"]
    B --> C[1-bit lane: custom codec flags]
    C --> D["standard IDs, 6 bits (v21-23) or 7 bits (v24+)"]
    D --> E[varint custom IDs]
    E --> F["1-bit lane + varints: private header sizes - 1"]
    F --> G["1-bit lane + varints: variable inputs - 1"]
    G --> H["1-bit lane + varints: regenerated streams - 2"]
    H --> DL["v25+ with a bundle: dictionary lane = 1 bit per codec, then width byte and bit-packed bundle indices of the flagged codecs"]
    DL --> I["distances - 1, bits_for(regenerated + stored) each"]
    I --> J[varint stored stream sizes]
    J --> K[codec private headers, concatenated]
    K --> L[stored streams in stream order]
    L --> M["[content checksum: XXH3(chunk output) low 32]"]
    M --> N["[compressed checksum: XXH3(chunk bytes so far) low 32]"]

Codecs are listed in decoding order. Codec t takes fixed + variable input streams starting right after codec t - 1’s inputs; each stream it regenerates lies distance streams past the end of its inputs. Streams no codec regenerates are stored, in index order. The last stream is the chunk’s output. The compressed checksum covers the chunk header, streams and the content checksum; it is verified before any codec runs.

graph LR
    S0["stream 0: stored (zstd stream)"] -->|zstd, distance 0| S1["stream 1: output"]

The two chunks the encoder writes:

ChunkHeader bytesStreams
Stored01 01 varint(n)the input
Zstandard02 01 00 16 00 00 00 00 varint(n)01 (element width) + magicless zstd frame

Limits

Codec count below 20 000 (ZL_runtimeNodeLimit), streams below 110 000 (ZL_runtimeStreamLimit), at most 2 048 regenerated streams per codec, stored streams and private headers below 4 GiB, comments of 1 to 10 000 bytes.

Typed and multiple inputs, comments

The encoder writes the general v21+ header (layout::write_frame_header):

flowchart LR
    M[magic LE32] --> F["flags: content, compressed, comment (v22+), bundle (v25+)"]
    F --> T["inputs token: n below 15: n, type0 shifted 4, type1 shifted 6, the rest 2 bits each; n of 15 or more: ((n-15) shifted 4) OR 15, then (n-15) shifted right 4, then all types"]
    T --> S["varint(size+1) per input, then varint(numElts) per string input"]
    S --> C["varint(len) + comment (v22+)"]
    C --> K["check byte: XXH3(header) & 0xFF"]

Types are 2-bit codes (serial 0, struct 1, numeric 2, string 3). A string’s size is the sum of its lengths. Content checksums hash every chunk input in order and, for strings, their little-endian length array after the content. The chunk header and its stored-stream order are described in compression graphs. The dictionary lane (push_header_with on the encoder, dictionary_lane on the decoder) is present in every chunk of a frame that has a bundle (flag bit 3), including stored and empty chunks; the decoder reads it exactly when the frame header named a bundle. The decoder reads a multi-output chunk’s outputs as its last n streams (output k is stream total - 1 - k); the content checksum covers each output’s content followed, for strings, by its length array.

Known gaps

  • Formats below 21 are not read or written (the supported range is 21 to 27).
  • A comment is accepted and written from v22; only a single bundle per frame exists in the format.
  • Unknown-size frames (0x00 first size byte) are rejected as in the reference.

Codec features

flowchart TD
    D["default = std + compression + decompression + all-codecs"] --> C[compression]
    D --> A["all-codecs: 34 codec features"]
    D --> R[decompression]
    D --> S[std]
    A --> Z["zstd: dep:mzstd"]
    A --> K["33 other codec features: no dependency"]
    N["no_std: alloc only"] --> X["fearless_simd/libm, mzstd/no_std, dep:libm"]
    C --> M1["mzstd?/compression"]
    R --> M2["mzstd?/decompression"]
    S --> IO[io adapters, std::error::Error, runtime SIMD detection]
    X --> LM["libm: ln, exp, pow, expf for the estimators and Transformer"]
    P["hotpath-cpu / hotpath-alloc / hotpath-callgrind / hotpath-cloud"] --> H["hotpath: optional profiler dependency + measurement anchors"]

Directions

framed is opt-in and independent of codec features and directions. With a direction it exposes framing (resource metadata, directory and limits), the corresponding framed owner and borrowed sessions. Std adds resource writers, sequential readers and seek readers; alloc-only builds keep one-shot and native sessions. framed alone enables no dependencies or public modules and is not in default or all-codecs. See framed containers.

  • compression exposes compressor, graph, input and the root re-exports; decompression exposes decompressor and its re-exports; dict exists with either direction. Backend, RetentionPolicy, FormatVersion and DataType exist with either direction; with neither the crate is empty.
  • io exists only with a direction and without no_std. no_std selects the compile-time baseline SIMD level and wins over std when both are set (as --all-features does); hotpath* anchors are compiled out with it.
  • libm is an optional dependency enabled by no_std. It supplies log, exp, pow and expf to shared::estimate and the Transformer (graphs::transformer::math), and sqrt to the ML selector’s features. With std those functions are the platform’s, which is what the reference links; a no_std build has no byte-identity guarantee for decisions that depend on them (see transformer). shared::estimate is compiled with compression in every build.

The custom-codec feature exposes custom_codec with either direction, encoder contexts and graph registration with compression, and decoder contexts and registration with decompression. It is enabled by all-codecs. Without it, custom wire IDs decode as Unsupported::CustomCodec. See custom codecs.

Profiling features

Profiling is opt-in and independent of codec selection. hotpath enables the optional profiler and library measurement anchors. hotpath-cpu, hotpath-alloc, hotpath-callgrind and hotpath-cloud each imply hotpath and forward the corresponding upstream feature: CPU sampling, allocation tracking, instruction counts and authenticated report uploads respectively. The library anchors remain disabled with no_std.

The dev-dependency additionally enables hotpath/criterion-0-8, used by the compression and decompression benchmarks. This adapter does not reach normal library consumers. See CI for report ownership, the fixed-count native/Callgrind data flow and the pull request relay.

Codecs

Each OpenZL codec has its own feature; all-codecs enables every one and is in default. Codec features change behaviour, not the public API:

flowchart LR
    I[chunk input] --> F{codec feature on?}
    F -- yes --> E[codec chunk when it shrinks the input]
    F -- no --> S[stored chunk]
    H[chunk header naming the codec] --> G{codec feature on?}
    G -- yes --> R[run the codec]
    G -- no --> U["DecodeError::Unsupported { Codec { id } }"]

Without a codec’s feature the encoder never writes it (a node row selects codecs::DISABLED, which fails the node, so the engine stores the chunk, falls back under permissive mode, or skips a trial) and the decoder reports it as Unsupported. The chunk layout is parsed in full either way, so an unsupported codec is reported before any stream is wired. Frames of a build without zstd are valid but no longer match the reference’s default graph.

FeatureCodec IDsDecoder module (decompressor::core::codecs)Encoder module (compressor::core::codecs)Encoder graphs needing it
custom-codecapplication u32 IDs (separate namespace)custom_codec::core callback adaptercustom_codec::core callback adapterapplication graphs
zstd22zstd, core::zstd, State::zstd, Out::zstdzstd_node, core::zstdzstd 9, default fast path
conversion5-14conversionconversion (always compiled; rows gated)stores, routers
delta1deltanumeric::deltatransformer
zigzag3zigzagnumeric::zigzagtransformer
divide-by53divide_bynumeric::divide_bytransformer
quantize25, 26quantizequantizefield_lz graph, compress_small_lengths
range-pack35range_packnumeric::range_packtransformer
bitsplit63bitsplitnumeric::bitsplit
float-deconstruct33float_deconstructnumeric::float_deconstruct
sparse-num66sparse_numnumeric::sparse_numtransformer
merge-sorted43merge_sortednumeric::merge_sortedmerge_sorted 63
sentinel18sentinelnumeric::sentinelcompress_small_lengths
partition64partitionnumeric::partitionpartition_bitpack 23
constant44, 45constantconstantconstant 8, transformer
prefix46prefixstructure::prefix
transpose2, 4, 30-32transposestructure::transposetranspose_split selector 64, field_lz literals
split40, 47, 48splitstructure::splitentropy graph (32 KiB chunks), split_*
split-by-struct41split_by_structstructure::split_by_structsddl2
dispatch42, 54dispatchstructure::dispatchsddl, try_parse_int
concat55, 57-59concatstructure::concatcluster
dedup56dedupstructure::dedup
interleave61interleavestructure::interleave
tokenize36-38tokenizestructure::tokenizeentropy graph, transformer
parse-int60parse_intstructure::parse_inttry_parse_int 19
bitpack27, 28bitpackbitpackbitpack 10, entropy graph
bitunpack34bitunpackbitunpack
flatpack29flatpackflatpackflatpack 11, entropy graph
mux-lengths65mux_lengthsmux_lengthslz 28
entropy49-52entropyentropy (needs compression)graphs 3-7, 44
pivco-huffman67pivco_huffmanpivco_huffman (needs compression)huffman_pivco 6
lz462lz4lz4lz4 22
lz19lzlz, lz_commonlz 28 (also needs entropy)
field-lz24field_lzfield_lz, lz_commonfield_lz 12, 45, 46 (also needs entropy)

Each decoder module is one registry arm in codecs::standard; the decoder catalogue gives every signature and the encoder catalogue every node. Frames from the reference’s typed graphs carry conversion codecs (storing a numeric stream converts it to serial), so differential tests of most codecs also need conversion.

Graphs that need several features alias UNPORTED_FUNCTION / UNPORTED_SELECTOR when any is off (field_lz family: field-lz and entropy; lz: lz and entropy; partition_bitpack: partition; merge_sorted: merge-sorted; the five entropy graphs: entropy). The store graphs, the generic routers, the segmenters, small_lengths, bitpack, constant, the ML selector, clustering, SDDL and the Transformer graphs are ungated; they fail at run time on the first node whose feature is off.

Shared modules by feature

shared is private and compiled when either direction is on; each item is gated to its users.

ModuleCompiled with
wire, varint, bitpack, xxh3, destination, sha256, dictwireeither direction (wire::CODEC_ZSTD, MIN_STREAM_SIZE need zstd; parts of dictwire need decompression)
cbor, estimate, numericcompression
bitpack_kernelscompression and (bitpack or bitunpack)
entropy, statscompression and (entropy or pivco-huffman)

Checks

Shared helpers carry per-direction cfgs so single-direction builds have no dead code. scripts/check_codec_features.py builds an isolated consumer for every mode (none, std, no_std) x direction x codec-set x framed combination and probes which public items exist (DataType, TypedOutput included). Framing probes cover shared metadata and errors, each direction’s owner and native session API, and std-only reader/writer/seek adapters; the same imports must fail when their feature gates are off. CODECS is read from the all-codecs feature, so a new codec feature is picked up automatically, and the script fails on a feature missing from default or one it does not classify. The consumer inherits the root [patch.crates-io] table when there is one (a dependency checked out locally during development). CI runs clippy over std, no_std and direction combinations (the codec set there is zstd or none, not every codec subset) and the test suite without codec features.

Known gaps

  • The probe list of check_codec_features.py covers the root re-exports; it does not probe graph, input and dict items.
  • CI’s feature matrix toggles zstd and “no codecs” only; individual codec features are exercised by the probe script and by the default build.
  • Codec subsets that disable a codec another one’s graph depends on (for example entropy without bitpack) compile and fail at run time, like a failing codec of the reference.

The compressor::parallel API is included with compression unless no_std is enabled. It uses exactly the serial encoder codec gates (including stored chunks when zstd is absent), adds no executor dependency or codec feature, and supports batches, random-access sources and deferred-output streaming. See parallel compression.

The parallel builder accepts the same CompressorGraph as the serial builder. Explicit codec nodes follow serial feature/version errors and permissive fallbacks, while the default parallel graph continues to store chunks without zstd. ParallelCompressorBuilder follows the same std/compression gate.

Shared primitives

shared is private and compiled when either codec is enabled; each item is gated to the codec (and, for the encoder-side algorithms, the Cargo codec features) that uses it. Codec features lists the gates.

graph LR
    Wire[wire: magic, flags, limits, codec IDs, StreamType] --> Enc[compressor::core]
    Wire --> Dec[decompressor::core]
    Varint[varint: encode/push - decode] --> Enc
    Varint --> Dec
    Bitpack[bitpack: pack - unpack, bits_for] --> Enc
    Bitpack --> Dec
    Xxh3[xxh3: hash64 / hash32] --> Enc
    Xxh3 --> Dec
    Sha[sha256 + dictwire] --> Dict[dict: Dict, DictBundle, BundleStore]
    Dict --> Enc
    Dict --> Dec
    Stats["stats: histogram, data_stats, count"] --> Enc
    Entropy["entropy: FSE and HUF encoders"] --> Enc
    Estimate["estimate: cardinality, dimensionality, ranges"] --> Enc
    Numeric["numeric: element accessors and NUMOP helpers"] --> Enc
    BpK["bitpack_kernels: encode, decode, BitWriter"] --> Enc
    Cbor["cbor: A1CBOR port"] --> Enc
    Xxh3 --> Estimate
    Xxh3 --> Stats2[transformer stats]
    Stats --> Entropy

Both directions

  • varint: LEB128 as ZL_varintEncode; decoding is the reference’s lenient reader (redundant groups accepted, ten bytes at most, bit 0 of a tenth byte).
  • bitpack: values packed from the least significant bit of the first byte, zero-padded to a byte (ZS_bitpackEncode32); bits_for is ZL_nextPow2.
  • wire: magic, flag bits, BUNDLE_VERSION_MIN (25), COMMENT_VERSION_MIN (22), limits, standard codec IDs, StreamType.
  • sha256 (FIPS 180-4, scalar): derives dictionary and bundle IDs (ZL_UniqueID_computeSHA256); dictionaries are hashed once, so it is far from a hot path. Tests check the NIST vectors and the C library on every padding shape.
  • dictwire: the formats of dictionaries, bundle info, fat bundles and trained Zstandard content (parse_dict, pack_dict, parse_bundle_info, parse_fat_bundle, pack_fat_bundle, bundle_id, parse_zstd_content, pack_zstd_content) with a private WireError; see dictionaries.
  • fixed (with lz4 encoding or entropy decoding): fixed_table::<T, N> builds a Box<[T; N]> through vec! (zeroed tables reach alloc_zeroed) without an error path, for the LZ4 HC hash/chain tables and the FSE and Huffman decoding tables, whose index checks then compare against N.

Encoder-side modules (compression)

ModuleContentsGate
statsHistogram (8 and 16 bit), DataStatsU8 (lazily computed entropy with the reference’s integer log table, cardinality, bit-packed, flat-packed and constant sizes, delta variants, Huffman size estimates), count (match-length counting)entropy or pivco-huffman; data_stats also serves the field_lz literals selector
entropyOpenZL’s FSE/HUF encoder fork (bits, fse, huf, huf16), see entropyentropy or pivco-huffman
estimatecardinality by linear counting and HyperLogLog (estimate_cardinality_fixed, _variable, _hashed), estimate_dimensionality, compute_unsigned_range, guess_float_width; floating point ported expression by expression with no fused multiply-add, libm entry points from std or the libm crate; uses xxh3compression; consumers: Transformer, ML selector features
numericread_le/push_le/elements, the NUMOP_* helpers (find_max, sums, under_limit, width_for_array, write_numerics_from_*, byteswap); items without a caller are #[cfg(test)]compression (inner items per codec feature)
bitpack_kernelsnb_bits (SIMD OR reduction), encode (grouped for fields up to 16 bits, encode_scalar baseline), decode/encode_bound of fixed-width fields (LSB first) and writer::BitWriter, the port of ZS_BitCStreamFFbitpack or bitunpack
byte_transposesplit (encoder) and merge (decoder) byte-column transposition: unzip/zip rounds on u8x16 for widths 2, 4, 8, scalar otherwisetranspose
cborA1CBOR port: Item, strict decode (max_depth 128, arena limit_bytes), encode_to_vec; see ML selector and clusteringcompression

All of it is a scalar port of the reference function of the same name and returns the same numbers, quirks included (the integer entropy approximation, the estimators’ bias, wrapping arithmetic); several helpers carry expect(dead_code) for feature sets that do not use them.

XXH3

xxh3::hash64(level, input) is XXH3_64bits with the default secret and seed 0; hash32 keeps the low 32 bits. Inputs up to 240 bytes take the reference’s scalar short paths. Longer inputs run the stripe accumulator:

flowchart LR
    I["input over 240 bytes"] --> D{little endian?}
    D -- yes --> X["dispatch!(level): simd_long"]
    D -- no --> S[scalar_long]
    X --> M[merge + avalanche]
    S --> M

simd_long keeps the eight accumulator lanes in one u64x8. Per 64-byte stripe it loads the data as u8x64, bit-casts to u64x8, XORs the key, splits the keyed u32x16 into two u32x8, de-interleaves low and high words (unzip_low/unzip_high) and multiplies them with widening_mul_u32x8; the pairwise swap of data lanes is a swizzle_dyn_within_blocks that exchanges the 8-byte halves of each 16-byte block. The once-per-kilobyte scramble runs on the stored lanes with the scalar code. The level is selected when the codec is built and passed down; dispatch! runs once per hash, outside the stripe loop. scalar_long is the independent baseline: unit tests compare both at every host level and against the C library for every length class.

Known gaps

  • Big-endian targets take the scalar accumulator.
  • Only XXH3 has a SIMD path. The histogram, estimators, entropy encoders and bit packing kernels are scalar; they were not profiled (see encoder codecs).
  • no_std estimators use libm, whose last bit can differ from the platform libm (see transformer).

Uninitialized output

compress_to_uninit, decompress_to_uninit and process_uninit on both sessions write into &mut [MaybeUninit<u8>] (for example Vec::spare_capacity_mut) without unsafe in the crate and without a temporary buffer. They return the same bytes, counts and errors as their initialized counterparts; exactly dst[..written] / output[..produced] is initialized afterwards.

graph TD
    CU[Compressor::compress_to_uninit] --> US[core::sink::UninitSink: BorrowedBuf]
    CS[Compressor::compress_to_slice] --> SS[SliceSink]
    CV[compress / compress_into] --> VS[VecSink]
    DU[Decompressor::decompress_to_uninit] --> UO[core::out::UninitOut: BorrowedBuf]
    DS[decompress_to_slice] --> SO[SliceOut]
    DV[decompress / decompress_into] --> VO[VecOut]
    EP[EncoderSession::process / process_uninit] --> DST[shared::destination::Destination]
    DP[DecoderSession::process / process_uninit] --> DST
    DST --> B["[u8]: copy_from_slice"]
    DST --> M["[MaybeUninit<u8>]: write per byte"]

Encoder

UninitSink wraps the destination in a borrowed_buf::BorrowedBuf; every put appends to its filled prefix, so only written bytes become initialized and nothing is zeroed. The compressed checksum reads the chunk back from the filled prefix.

Decoder

UninitOut appends stored streams to the filled prefix without zeroing. A Zstandard stream (magicless, decoded in place with no staging buffer; with a dictionary when the node carries one, Out::zstd takes the optional DecodeDictionary) is decoded into grow(size), which zeroes exactly the bytes about to be decoded into (each byte once). VecOut instead appends through mzstd::Decompressor::decompress_into, so Vec destinations are never zero-filled; the content checksum reads the regenerated chunk back from the destination either way.

Sessions

Sessions decode or compress into their own pending buffer and copy into the caller’s output through shared::destination::fill, which is generic over Destination; [MaybeUninit<u8>] is written byte by byte (a memcpy after optimization) and never read. Calls of both kinds may be mixed on one operation.

Verification

tests/uninit.rs starts every buffer as MaybeUninit::uninit(); its rust_only_* tests and the unit tests of shared::destination, compressor::core::sink and decompressor::core::out run under Miri and AddressSanitizer through scripts/check_simd_uninit.py.

Known gaps

  • The Miri/ASan lists in scripts/check_simd_uninit.py (compressor::core::sink, decompressor::core::out, shared::destination, shared::xxh3, and the simd_backends and uninit tests) are unchanged: the encoder codecs, graphs and shared algorithms added since are scalar, Vec-based safe code with no SIMD kernel and no MaybeUninit path, so they are not on the lists.
  • decompress_to_slice and decompress_to_uninit decode Zstandard through mzstd::decompress_to_slice, which is slower than its decompress_into path on some data (records: 1.37 vs 1.76 GiB/s); see the comparison.

tests/uninit.rs::rust_only_custom_codec_uses_uninitialized_destinations registers custom ID 22 and verifies custom encoding and decoding into initially uninitialized buffers. Its existing rust_only_ prefix includes it in the Miri and AddressSanitizer selections in scripts/check_simd_uninit.py.

Compression

From the public Compressor to the graph engine and node encoders.

SpecificationScope
CompressorPublic API, the specialized default-graph path, chunking, sinks, the session state machine, graph selection, I/O and errors.
Compression graphsThe graph engine: typed streams, runtime graph, contexts, successor order, stores, conversions, trial compression and the registry tables.
Standard graphsThe decision logic of every standard graph: routing, stores, segmenters and structure graphs.
Encoder codecsEvery node encoder: node ID to codec ID, feature, ports, wire summaries, deviations and gaps.
Parallel compressionCaller-scheduled segments, graph/codec builder, source/staging ownership, dictionary-aware frame assembly and deferred streaming output.

Architecture overview and module map

Compressor

Ownership

classDiagram
    class Compressor {
        EncoderConfig config
        Params params
        Option~CompressorGraph~ graph
        Option~Comment~ comment
        Backend backend
        RetentionPolicy retention
        Engine engine
        Stream stream
    }
    class Engine {
        ZstdNode zstd
        Vec header
        Level simd
        Option~GraphPlan~ plan
    }
    class GraphPlan {
        Arc~Registry~ registry
        GraphId start
        Option~Comment~ comment
        Option segmented
    }
    class Stream {
        Vec input
        Vec output
        Option total
        u64 accepted, chunked, chunks
    }
    class ZstdNode {
        mzstd::Compressor encoder
        Vec output
    }
    Compressor *-- Engine
    Compressor *-- Stream
    Engine *-- ZstdNode
    Engine o-- GraphPlan
    EncoderSession --> Compressor : borrows
    EncoderSessionOwned --> Compressor : boxes
    EncoderWriter --> EncoderSession
    EncoderReader --> EncoderSession

compressor owns the public API: EncoderConfig (level, format version, chunking, both checksum switches), Level, ChunkSize, Chunking, Compressor/CompressorBuilder, the sessions and EncodeError. compressor::core owns everything that touches bytes. Params is the resolved copy of the configuration the core reads.

The default graph (specialized path)

Compressor has two execution paths. The specialized writer described here serves plain serial input with default parameters, no graph and no comment (the common case, and the one the benchmarks measure). Everything else goes through the graph engine (compression graphs); both write the same bytes for the default graph, which a differential test checks.

ZL_CCtx_compress without a compressor runs ZL_GRAPH_SERIAL_COMPRESS, a selector that always picks ZL_GRAPH_ZSTD (the Zstandard node followed by store). The engine reproduces the decisions around it per chunk:

flowchart TD
    A[chunk input] --> B{len < 10?<br/>ZL_MINSTREAMSIZE_DEFAULT}
    B -- yes --> S[stored chunk]
    B -- no --> Z["ZstdNode::encode: push width byte 0x01,<br/>mzstd magicless frame at the level"]
    Z --> H[zstd chunk header]
    H --> C{"header + stream < input?<br/>(store on expansion)"}
    C -- yes --> W[write zstd chunk]
    C -- no --> S
    S --> K
    W --> K[content checksum, then compressed checksum]

mzstd writes ZSTD_compress2’s bytes in the magicless frame format (FrameFormat::Magicless, the reference’s ZSTD_f_zstd1_magicless) natively, so the node pushes the element-width varint 0x01 and appends the frame with compress_into; no magic is written and overwritten any more.

Frames are a header with the total size, chunks, and the end marker. Chunking::Whole (default, as ZL_compress) makes the input one chunk; Chunking::Fixed(size) cuts every size bytes as SEGM_serial does. Whole inputs are still cut every 2 GiB because stored streams must stay below 4 GiB. An empty input is one empty stored chunk.

Sinks

core::sink::Sink abstracts the destination: VecSink appends to a caller’s Vec (compress_into, sessions’ pending buffer), SliceSink fills a caller’s slice (compress_to_slice, failing with Full) and UninitSink fills uninitialized memory (compress_to_uninit; see uninitialized output). The compressed checksum hashes back the chunk just written through Sink::since. compress and compress_inputs, which return a new Vec, reserve Compressor::max_compressed_size of the input bytes (content plus four bytes per string length) before writing, as C sizes its destination with ZL_compressBound: the frame is then written without reallocation, and pages of the reservation the frame never reaches are never touched. The _into variants append to the caller’s Vec as it is.

Sessions

stateDiagram-v2
    [*] --> Unknown: InputSize Unknown
    [*] --> Header: InputSize Exact n
    Unknown --> Unknown: Continue / Flush (buffer all input)
    Unknown --> Header: End (total = accepted)
    Header --> Chunking: header queued
    Chunking --> Chunking: chunk_limit bytes buffered (or whole chunk in caller input) -> chunk
    Chunking --> Chunking: Flush with buffered input -> short chunk
    Chunking --> Ended: End, accepted == total -> last chunk (or empty chunk), end marker
    Chunking --> Failed: input beyond or short of the declared size
    Ended --> [*]: pending output delivered -> Finished

core::stream::Stream::process drains pending output first, then advances at most one chunk per loop iteration, so pending output holds at most a header and one chunk. A chunk that lies whole in the caller’s input is compressed in place. Chunk boundaries depend only on the total size and flushes, so a session writes the one-shot frame for any split of input and output; Flush with an unknown size has no effect because no byte can precede the header.

EncoderSession borrows the compressor, EncoderSessionOwned boxes it (reinit, into_compressor). Finishing or failing resets the stream and applies the retention policy. io::EncoderWriter/EncoderReader adapt sessions to Write/Read; a writer whose sink fails after the session accepted input reports the input as written and keeps the compressed bytes pending.

Errors

CoreError (OutputFull, Internal) and StreamError (SizeMismatch, Core) stay private and map to EncodeError (OutputTooSmall, InputSizeMismatch, InvalidState, Bound, Internal).

SIMD

The backend is chosen once in CompressorBuilder::build; its level is stored in Engine and passed to every xxh3::hash32 call, and the matching mzstd::Backend (same name) is handed to the Zstandard node.

Known gaps

  • compress_to_slice and compress_to_uninit stage the zstd stream in the node’s buffer and copy it, as the reference does.
  • Only the serial default-graph path is incremental in the specialized writer; other graphs buffer their input until End, except serial segmenters (see below).
  • No parallel compression; no C ABI.
  • Parameters the public configuration does not expose (zstd local parameters, dictionaries) are reachable only through graph::GraphBuilder.

Graphs, typed inputs and comments

Beyond the default graph the compressor runs the graph engine described in compression graphs:

  • CompressorBuilder::with_graph(CompressorGraph) selects the graphs; Compressor::compress_inputs(&[Input]) (and _into) compresses typed and multiple inputs; Compressor::set_comment(Option<Comment>) writes a header comment (format 22 or newer; the comment stays until changed).
  • EncoderConfig gains decompression_level, min_stream_size, store_on_expansion and permissive (0 selects the default of the two numeric ones, as in the reference). The specialized path is used only when they equal the defaults, no graph or comment is set and the decompression level is not 1.
  • Compressor::sync_plan runs before each operation and gives the engine a GraphPlan: the registry, the start graph (wrapped in segment_serial for Chunking::Fixed), the comment and, for a serial segmenter, the chunk size and successor, which keeps sessions incremental. Any other graph buffers its input until End.
  • Errors: EncodeError::Graph(GraphError) carries failures of the graph engine (a codec whose feature is off, a graph that rejects its input, a malformed configuration, a segmenter placement error, dictionary errors); CompressorBuilder is no longer Copy.
  • Dictionaries: a graph with a loaded bundle writes the bundle ID and the dictionary lane (dictionaries).
  • The graphs available through with_graph are every standard graph, user Selector/FunctionGraph/Segmenter implementations, brute_force, ml_selector, clustering, sddl and sddl2 (standard graphs).

Parallel serial inputs

The opt-in framing::FramedCompressor wraps independent serial or typed frames in indexed resource records, selecting an optional graph per resource. It reuses this encoder and temporarily replaces/restores its graph through a private helper; see framed containers.

The std-only compressor::parallel module schedules independent segments on caller-selected executors and assembles one compatible OpenZL frame. Its byte contract differs from this serial API; see parallel compression for ownership, dispatch, staging, source validation and the streaming lifecycle.

The private Compressor::frame_header_into resolves the current graph plan and delegates header writing to Engine::header without executing the graph. The parallel encoder uses it for exact chunk extraction and dictionary-aware assembly; no low-level types or new methods are exposed by the serial public API.

Compression graphs

The graph engine runs OpenZL’s compression graphs over typed, possibly multiple inputs and produces the frame the reference’s ZL_CCtx_compress family writes. The specialized default-graph path (compressor::core::chunk, frame) still serves plain serial input with the default parameters; the engine writes the same bytes for it (a differential test checks this) and is used for everything else.

Ownership

graph TD
    Pub["graph.rs (public): StandardGraph, StandardNode, GraphBuilder, CompressorGraph, traits, contexts"] --> Reg
    Input["input.rs (public): Input, Numbers, Records, Strings"] --> Data
    Enc["encoder.rs: Compressor.sync_plan, compress_inputs"] --> Plan[core::GraphPlan]
    Plan --> Exec
    subgraph core ["compressor::core (private)"]
        Reg["registry: Registry, GraphDef, NodeSpec, nodes.rs, graphs.rs"]
        Exec["exec: Exec, GraphCx, NodeCx, SelectorCx, SegmenterCx"]
        Rt["rtgraph: RtNode, RtStream"]
        Data["data: Stream, Bytes, Lens"]
        Layout["layout: Plan, frame and chunk headers"]
        Codecs["codecs: every node encoder (one module per feature), DISABLED / LEGACY"]
        Graphs["graphs: stores, routers, segmenters, entropy, lz, transformer, ml_selector, clustering, sddl, sddl2"]
        Dict["registry::dict: DictState, bundle loading"]
        Sink["sink: Sink, CappedSink"]
    end
    Exec --> Reg
    Exec --> Rt
    Exec --> Layout
    Exec --> Sink
    Reg --> Codecs
    Reg --> Graphs
    Reg --> Dict
    Layout --> Chunk["chunk::push_header_with (decoding map lanes)"]
    Codecs --> Mzstd[mzstd]

Public items never expose core types: the contexts handed to user graphs are thin wrappers over private object-safe traits (GraphApi, SelectorApi, SegmenterApi), and GraphError carries the message of the private ExecError, not the type.

Custom application nodes use a shared owned NodeSpec and callback; parameterized standard nodes keep a borrowed static spec. The runtime graph preserves whether a wire ID is standard or custom. See custom codecs for the callback contexts, signatures, registration and error mapping.

Engine flow

Exec is the port of ZL_CCtx (cctx.c). Each function cites its C original.

sequenceDiagram
    participant C as Compressor
    participant E as Exec
    participant G as GraphCx (function graph)
    participant N as run_node
    participant L as layout / sink
    C->>E: compress_frame(start)
    E->>L: write_frame_header (types, sizes, comment, check byte)
    E->>E: run_successor(start, depth 1)
    Note over E: segmenter? only on the full input before any node
    E->>E: run_successor_internal: lone stream < min_stream_size -> store1
    E->>E: run_supervised: implicit conversions, serial_store -> mark stored
    E->>G: run_graph_desc: build edges, call graph function
    G->>N: run_node (eager): create RtNode, singleton slots, NodeCx.encode
    N-->>G: edges of the outputs (appended handles)
    G-->>E: destinations recorded (set_destination)
    E->>E: finish: unassigned edge = error, or compress_generic if permissive
    loop successors in edge-handle order, depth first
        E->>E: run_successor(depth + 1)
    end
    alt error and permissive and not in backup
        E->>E: RtGraph.clear_nodes_from(before), then run compress_generic
    end
    E->>L: flush_chunk (store-on-expansion fallback), end marker

Successor order

Nodes run at the moment the graph function asks; successors run after it returns, in the order of the edge handles: the graph’s inputs first, then node outputs in creation order. A destination for several edges triggers at its first edge’s handle. The order of set_destination calls is irrelevant.

flowchart LR
    I0[handle 0: input] -->|run_node A| O1[handle 1: A out 0]
    I0 --> O2[handle 2: A out 1]
    O2 -->|set_destination first| S2[successor X]
    O1 -->|set_destination second| S1[successor Y]
    S1 -. runs 1st .-> R1[Y subtree completes]
    S2 -. runs 2nd .-> R2[X subtree completes]

Chunk layout

Stream index e is creation order: chunk inputs 0..n, then each node’s outputs as one block (declared singletons first, variable outputs appended in creation order). The chunk lists codecs in decoding order (reverse creation) and addresses a codec’s inputs by distance from its first output.

flowchart TD
    A["e0 input (serial)"] -->|r0 conversion, codec 9| B["e1 numeric"]
    B -->|r1 delta, codec 1| C["e2 numeric"]
    C -->|r2 conversion, codec 10, header 02| D["e3 serial"]
    D -->|r3 zstd, codec 22| E["e4 serial, stored"]
    H["header: codecs 22,10,1,9; distances 0,0,0,0; stored e4"]

Header lanes: varint(T+1), varint(nbStored), custom flags, standard IDs (6 or 7 bits), private header sizes, variable-output counts, input counts, the dictionary lane (written in every chunk of a frame whose compressor loaded a bundle, format 25 and newer; see dictionaries), distances, stored sizes. The stored list is the private-header stream followed by the to_store streams from the last created to the first.

Stores, conversions, fallbacks

  • ZL_GRAPH_STORE sends each input to store1, which picks serial_store (the stream is marked stored) or string_store (separate_string_components, two serial_stores). A numeric or struct stream reaches serial_store through an implicit conversion node (numeric to struct, struct to serial, numeric to serial, first match), so storing typed data emits a codec.
  • min stream size: a lone non-string stream below EncoderConfig::min_stream_size (10) goes to store1.
  • Store on expansion: when header + stored >= inputs (+ 4 per string) or the chunk header does not fit a capped sink, the chunk is rebuilt as ZL_GRAPH_STORE at depth 1.
  • Permissive: a failing successor outside a segmenter is rolled back with RtGraph::clear_nodes_from (store marks of earlier streams stay, as in the reference) and rerun through compress_generic.
  • Trial compression (Exec::try_graph): a derived Exec with checksums off and no comment compresses a whole frame into a CappedSink of ZL_compressBound bytes; the result is the frame length. brute_force uses it.

Registry

registry::nodes::STANDARD_NODES has every standard node of ER_standardNodes (IDs 2 to 87, 85 rows: codec ID, version range, port types, default local parameters, encoder). registry::graphs::STANDARD_GRAPHS has every graph of GR_standardGraphs (IDs 1 to 90). Custom IDs start at 88 (nodes) and 91 (graphs) and carry the owning registry.

StatusNodesGraphs
Portedall 75 non-legacy nodes (IDs 2 to 75, 86, 87 except 56)all 90 standard graphs (standard graphs)
Feature offthe row’s encoder is DISABLED and fails with NodeError::Disabledthe row aliases UNPORTED_FUNCTION / UNPORTED_SELECTOR and fails with ExecError::Unported (field_lz, lz, partition_bitpack, merge_sorted and the entropy graphs)
Legacydeprecated nodes 76 to 85 (LEGACY, NodeError::Legacy, never reachable at v21+)none

The node catalogue is in encoder codecs. A node of a disabled family fails the node run; the engine then reacts like the reference to a failing codec (permissive fallback, skipped trial, stored chunk). UnportedSegmenter exists only for tests of the engine.

Plugging a codec in

  1. Node encoder. Write fn(&mut NodeCx<'_, 'a>, &[Stream<'a>]) -> Result<(), NodeError> in core/codecs/<family>.rs, wrap it in FnEncoder, and point the node’s row in registry/nodes.rs at it: if cfg!(feature = "x") { &codecs::x::ENCODER } else { &codecs::DISABLED }. Create outputs with cx.emit, cx.emit_with_metadata, cx.emit_strings, cx.reference (zero-copy) or cx.reference_strings; send at most one header with cx.send_header; set output metadata with cx.set_output_metadata; read parameters with cx.int, cx.copy_param, cx.ref_param, cx.level(), cx.decompression_level(), cx.format_version() and, for dictionaries, cx.dictionary. Fail exactly where the C codec fails: permissive mode and trial compressions depend on it.
  2. Graph function, selector or segmenter. Implement GraphFn, SelectFn or SegmentFn and point the row in registry/graphs.rs at it. Inside, use GraphCx::run_node, set_destination, set_metadata, int_metadata, mparam, is_node_supported, SelectorCx::try_graph and SegmenterCx::process_chunk.
  3. Add the codec feature to Cargo.toml (all-codecs), to scripts/check_codec_features.py (CODECS is read from all-codecs), gate modules, tests and dependencies together (codec features), and add a differential test (tests/encode_*.rs for byte identity, tests/standard_graphs.rs for graphs).

Parameter IDs

graph::params names every local-parameter ID a standard node or graph reads (ZSTD_c_*, ZL_LzParam_*, the field LZ override and successor indices, the entropy minimum gains, the segmenter chunk sizes, the SDDL and clustering parameters, and so on), one module per codec or graph, each constant citing the reference macro. The encoders and graph functions read their parameters through the same constants, so the public names and the values the engine looks up cannot drift; tests/graph_params.rs pins the values to the C headers.

graph LR
    U[user LocalParams] -->|"graph::params::zstd::COMPRESSION_LEVEL"| N[zstd node]
    G[graph functions] -->|same constants| N
    T[tests/graph_params.rs] -. pins values .-> H[C headers]

Sessions and ChunkPlan

Compressor::sync_plan chooses the engine path before every operation.

stateDiagram-v2
    [*] --> Fast: no graph, no comment, default parameters
    [*] --> Segmented: start, or the Chunking Fixed wrapper, is a serial segmenter
    [*] --> Buffered: any other graph
    Fast --> Fast: chunk by chunk, specialized writer
    Segmented --> Segmented: header at once, one chunk per N bytes via Exec (depth 2)
    Buffered --> Buffered: hold input until End
    Buffered --> [*]: run the graph once, drain
    Fast --> [*]
    Segmented --> [*]

SegmentFn::serial_chunk lets the registry detect a serial segmenter structurally, so incremental chunks go through the same process_chunk logic (depth segmenter_depth + 1, runtime graph reset per chunk) and match the one-shot bytes. Buffered plans ignore a declared input size until the end, then check it.

Differences from the reference

  • The header comment stays set until changed (the reference clears it after each compression); setting one below format 22 is a ConfigError.
  • Numeric inputs are always little-endian bytes (Numbers takes bytes).
  • compress_inputs without a graph starts at compress_generic.
  • Streams are kept until the chunk ends instead of being freed when consumed.
  • A node run that fails is removed from the runtime graph immediately (the reference leaves a half-built node if a user graph ignores the error).

Known gaps

  • Zstandard. targetCBlockSize != 0 is refused by mzstd while the reference accepts it. Block splitting of struct input is ported but unreachable from node 62 at v21 and newer. The zstd node takes serial input only (the reference registers it for serial too, accepting struct only internally).
  • Parameters. Parser-function and callback parameters of nodes (custom tokenizers, parsed-int callbacks) are unsupported and fail the node; set_string_lens takes lengths through LocalParams::with_string_lengths.
  • Recursion depth. A graph chain of 1000 successors recurses deeply; run it on a large stack.
  • Memory. Streams are kept until the chunk ends instead of being freed when consumed.
  • Dictionaries. Only Zstandard dictionaries materialize (dictionaries).

Parallel graph selection

ParallelCompressor::builder(...).with_graph(...) shares the same immutable registry with independent worker compressors. It applies the graph to each serial segment and uses the private Compressor::frame_header_into boundary to obtain an exact graph-aware envelope, including dictionary IDs. See parallel compression for ownership and assembly.

Standard graphs

Every graph of GR_standardGraphs (IDs 1 to 90, registry/graphs.rs) is implemented. This page records what each one decides; the engine that runs them is in compression graphs. Graph IDs below are the registry’s numbering. Implementations live in compressor::core::graphs (one file per family) and stay byte-identical with the reference: tests/standard_graphs.rs runs every graph the C library accepts over serial, struct, numeric and string inputs at several levels and format versions and compares the frames.

Kinds

classDiagram
    class GraphDef {
        name
        kind GraphKind
        inputs type masks
        last_variable
        custom_graphs
        custom_nodes
        params
    }
    class GraphKind {
        <<enum>>
        Store
        Static
        Selector
        Function
        Segmenter
    }
    class GraphFn {
        <<trait>>
        run(GraphCx, edges)
    }
    class SelectFn {
        <<trait>>
        select(SelectorCx, stream, successors) GraphId
    }
    class SegmentFn {
        <<trait>>
        segment(SegmenterCx)
        serial_chunk(params, graphs)
    }
    GraphDef --> GraphKind
    GraphKind ..> GraphFn : Function
    GraphKind ..> SelectFn : Selector
    GraphKind ..> SegmentFn : Segmenter
  • Static: one node followed by fixed successors (node_graph!).
  • Selector: looks at one stream and returns the successor graph.
  • Function: runs nodes and assigns every output edge a destination.
  • Segmenter: cuts the whole input into chunks, each run through a successor.

Without a Cargo feature a graph that needs its codec aliases UNPORTED_FUNCTION / UNPORTED_SELECTOR (ExecError::Unported), which only exists for feature-off builds and tests: field_lz and its private graphs need field-lz + entropy, lz needs lz + entropy, partition_bitpack needs partition, merge_sorted needs merge-sorted, and the five entropy graphs need entropy.

Routing and stores

flowchart TD
    CG["compress_generic 13"] -->|each input| C1["compress1 34"]
    C1 -->|serial| SC["serial_compress 35"] --> Z["zstd 9"]
    C1 -->|struct| STC["struct_compress 36"] --> FLZ["field_lz 12"]
    C1 -->|numeric| NC["numeric_compress 37"] --> SN["select_numeric 16"]
    C1 -->|string| SSC["string_compress 38"] --> SSP["string_separate_compress 39"]
    SN -->|"level below 7"| STC
    SN -->|"level 7 or more"| TN1["transformer_numeric1 90"]
    SSP -->|"node 26 splits"| SSP2["content to 35, lengths to 37"]
    Z --> ST["serial_store 1"]
IDGraphDecision
1serial_storeStore kind: marks the stream stored
2storefunction: every input to store1 (32)
32store1selector: strings to string_store (33), everything else to serial_store; typed data reaches it through an implicit conversion node
33string_storenode 26 separate_string_components, both outputs to serial_store
13compress_genericfunction, variable inputs: each input to compress1
34compress1 (private “compress2”)selector by input type, table above
35-38serial/struct/numeric/string_compressconstant selectors: zstd (9), field_lz (12), select_numeric (16), string_separate_compress (39)
39string_separate_compressnode 26, then lengths to numeric_compress (37), content to serial_compress (35)
14select_generic_lz_backendalways zstd (9)
16select_numericlevel < 7 -> struct_compress; otherwise transformer_numeric1 (90)
31brute_forcetrial-compresses each compatible successor with try_graph; the smallest frame strictly below the input weight wins, otherwise store
62n_to_ninput i goes to custom graph i

Segmenters

IDGraphBehaviour
29segment_serialcuts every N bytes (int parameter 7700, default 16 MiB), each chunk to custom graph 0 (default compress_generic); below format 21 one chunk. serial_chunk exposes (size, successor) structurally so sessions stay incremental
15segmenter_numericcuts every 16 MiB of elements, chunks to numeric_compress
24-27segment_num{8,16,32,64}_from_serialserial input cut every N bytes aligned down to the width (parameters 300 size, 301 width), chunks to the interpret graphs 65-68
21sddl2see SDDL

Entropy and packing graphs

IDGraphDecision
3-7fse, huffman, huffman_huf0, huffman_pivco, entropythe one entropy plan, see entropy
8constantselector: a single-byte-pattern stream (or serial input) to constant_serial (42), struct or numeric to constant_fixed (43), strings illegal; verification happens at the node
10bitpackselector: numeric -> bitpack_int (41), otherwise bitpack_serial (40)
11flatpacknode 65, both outputs to serial_store
40-44private bitpack_serial/int, constant_serial/fixed, fse_ncountone node then serial_store
23partition_bitpackbelow
63private merge_sortedfunction with two custom graphs (the node pipeline and a backup): width other than 4, or more than 64 strictly increasing runs, goes to the backup
69compress_small_lengthsbelow

partition_bitpack (23)

flowchart TD
    A[numeric input] --> W{width 2 or 4?}
    W -- no --> E[error]
    W -- yes --> N{"fewer than 10 elements?"}
    N -- yes --> S[store]
    N -- no --> H["bucket histogram: 16-value buckets below 2^16, then one per power of two; sampled by 3, widened when not optimal"]
    H --> P["greedy split to 16 then 32 partitions by gain; iterative improvement from 10000 elements; optimal mode (param 0 = 1) is a DP"]
    P --> G{"saves under 1 percent?"}
    G -- yes --> T{"trivial partitioning narrows the numbers?"}
    T -- yes --> BP[bitpack 10]
    T -- no --> S
    G -- no --> PN["partition node 48: start 0, sizes in copy parameter 121"]
    PN --> O0["bucket ids to bitpack 10"]
    PN --> O1["offsets to store"]

Cost model: 24 + count * ceil(log2(bucket size)) bits per partition plus ceil(log2(partitions)) bits per element; at most 256 partitions.

compress_small_lengths (69)

The overflow-lengths stream of the lz graph: mostly tiny values, a few large ones.

flowchart TD
    I[numeric lengths] --> A{"fewer than 50 elements?"}
    A -- yes --> ST[store]
    A -- no --> B{"width 1?"}
    B -- yes --> HU[huffman 4]
    B -- no --> C{"sentinel_byte node supported?"}
    C -- no --> CG[compress_generic]
    C -- yes --> SB["sentinel_byte 50: small values below 255, large values"]
    SB --> HU2["small stream to huffman 4"]
    SB --> L{"large stream"}
    L -- "under 100 elements" --> ST2[store]
    L -- "width 2 and partition supported" --> PB[partition_bitpack 23]
    L -- "width 4" --> Q["quantize_lengths 44: codes to fse 3, extra bits to store"]
    L -- otherwise --> CG2[compress_generic]

Structure graphs

IDGraphDecision
19try_parse_intparses every string as i64. All parse: parse_int (38) with pre-parsed values, output to custom graph 0. None parse: whole input to custom graph 1. Mixed: dispatch_string into two outputs, indices to custom graph 2, the parsable ones through parse_int to graph 0, exceptions to graph 1
57-60split_serial/struct/numeric/stringrun the one custom node, output i to custom graph i
64transpose_split selectorv11 and newer always the transpose_split pipeline (last of five custom graphs); older versions pick by width, but the deprecated nodes are not reachable at the supported range
18clustersee ML selector and clustering
17ml_selectorsee ML selector and clustering
20, 21, 61sddl, sddl2, sddl2_chunksee SDDL

Numeric static pipelines

Static node-plus-successor graphs the Transformer reaches; see transformer.

IDsGraphs
47-49, 65-68delta_huffman, delta_flatpack, delta_zstd (serial-to-numeric conversion node 19, then the internal delta graph 50-52), interpret_num{8,16,32,64}_compress (conversion nodes 19-22, then numeric_compress)
50-56delta_huffman_internal, flatpack_internal, zstd_internal, delta_field_lz, range_pack, range_pack_zstd, tokenize_delta_field_lz
70-75transformer_delta_int, range_pack, divide_by_gcd, tokenize_numeric(_sorted), sparse_num: the node, outputs back to transformer_numeric1 (90)
76-89transformer_static_*: the heuristic selectors and their pipelines
30, 90transformer_numeric, transformer_numeric1

LZ graphs

lz4 (22) is a static node graph; field_lz (12), field_lz_literals (45), the channel selector (46) and lz (28) are covered in LZ.

Known gaps

  • ExecError::Unported is no longer reachable with every codec feature on; with a feature off a graph that needs it fails like any disabled codec.
  • Custom (user) graphs are expressed through graph::Selector, FunctionGraph and Segmenter; a failing user graph is reported as GraphError.

Encoder codecs

Every standard node of OpenZL v0.3.0 has an encoder in compressor::core::codecs, behind the Cargo feature of its codec. This page is the encoder-side counterpart of the decoder catalogue: it maps registry node IDs to wire codec IDs, lists their ports, and records the mechanics and deviations of each family. The engine that runs them is described in compression graphs; the graphs that wire them together in standard graphs.

Module map

graph TD
    REG["registry::nodes: STANDARD_NODES, 85 rows"] -->|"encoder: &dyn Encode"| ENC
    subgraph ENC ["compressor::core::codecs"]
        ZN["zstd_node + core::zstd"]
        CV[conversion]
        NUM["numeric/: delta, zigzag, divide_by, range_pack, bitsplit, float_deconstruct, sparse_num, merge_sorted, sentinel, partition"]
        STR["structure/: transpose, split, split_by_struct, dispatch, concat, dedup, interleave, prefix, tokenize, parse_int"]
        PK["bitpack, bitunpack, flatpack, constant, quantize, mux_lengths"]
        EN["entropy, pivco_huffman"]
        LZ["lz4/, lz/, field_lz/, lz_common/"]
        DIS["DISABLED / LEGACY"]
    end
    ZN --> MZ[mzstd]
    EN --> SE["shared::entropy + shared::stats"]
    PK --> SB[shared::bitpack_kernels]
    NUM --> SN[shared::numeric]
    LZ --> SS[shared::stats::count]

Each encoder is a FnEncoder static wrapping fn(&mut NodeCx, &[Stream]) -> Result<(), NodeError>. A registry row selects if cfg!(feature = "x") { &codecs::x::ENCODER } else { &codecs::DISABLED }; families with several features (numeric, structure) alias the single nodes to DISABLED inside their module. DISABLED fails the node with NodeError::Disabled, LEGACY (deprecated nodes 76 to 85, never reachable at v21 and newer) with NodeError::Legacy. There is no “not ported” encoder any more: every standard node runs.

The key invariants every encoder keeps:

  • The output bytes equal the reference’s for the same input, parameters and format version (tests/encode_*.rs compare node by node).
  • A node fails exactly where the C encoder fails, because permissive mode, trial compression and the store-on-expansion fallback depend on it.
  • At most one codec header per run (NodeCx::send_header); outputs are created with emit, emit_with_metadata, emit_strings, reference or reference_strings, and integer output metadata with set_output_metadata.
  • Local parameters follow ZL_Encoder_getLocalParam: NodeCx::int, ints, copy_param, ref_param (reference parameters carry Arc-shared Rust values instead of C pointers). Parser-function and callback parameters are not supported and fail the node.

Node table

Generated from registry/nodes.rs (node IDs are the registry’s, not the wire codec IDs). “Versions” is the format-version range the node exists in.

NodeNameCodecVersionsInputsOutputsFeature
2delta_int13-27NumericNumericdelta
3transpose_split411-27Structvariable Serialtranspose
4zigzag33-27NumericNumericzigzag
5dispatchN_byTag429-27SerialNumeric, Numeric; variable Serialdispatch
6float32_deconstruct334-27NumericStruct, Serialfloat-deconstruct
7bfloat16_deconstruct335-27NumericStruct, Serialfloat-deconstruct
8float16_deconstruct335-27NumericStruct, Serialfloat-deconstruct
9field_lz243-27StructStruct, Struct, Numeric, Numeric, Numericfield-lz
10convert_struct_to_serial63-27StructSerialconversion
11convert_num_to_struct_le83-27NumericStructconversion
12convert_num_to_serial_le103-27NumericSerialconversion
13convert_serial_to_struct53-27SerialStructconversion
14convert_serial_to_struct253-27SerialStructconversion
15convert_serial_to_struct453-27SerialStructconversion
16convert_serial_to_struct853-27SerialStructconversion
17convert_struct_to_num_le73-27StructNumericconversion
18convert_struct_to_num_be1321-27StructNumericconversion
19convert_serial_to_num893-27SerialNumericconversion
20convert_serial_to_num_le1693-27SerialNumericconversion
21convert_serial_to_num_le3293-27SerialNumericconversion
22convert_serial_to_num_le6493-27SerialNumericconversion
23convert_serial_to_num_be161421-27SerialNumericconversion
24convert_serial_to_num_be321421-27SerialNumericconversion
25convert_serial_to_num_be641421-27SerialNumericconversion
26separate_string_components1210-27StringSerial, Numericconversion
27bitunpack346-27SerialNumericbitunpack
28range_pack358-27NumericNumericrange-pack
29merge_sorted439-27NumericNumeric, Numericmerge-sorted
30prefix4611-27StringString, Numericprefix
31divide_by5316-27NumericNumericdivide-by
32dispatch_string5416-27StringNumeric; variable Stringdispatch
33concat_serial5516-27Serial (variable count)Numeric, Serialconcat
34concat_num5717-27Numeric (variable count)Numeric, Numericconcat
35concat_struct5817-27Struct (variable count)Numeric, Structconcat
36concat_string5918-27String (variable count)Numeric, Stringconcat
37dedup_num5616-27Numeric (variable count)Numericdedup
38parse_int6019-27StringNumericparse-int
39interleave_string6120-27String (variable count)Stringinterleave
40tokenize_struct368-27StructStruct, Numerictokenize
41tokenize_numeric378-27NumericNumeric, Numerictokenize
42tokenize_string3811-27StringString, Numerictokenize
43quantize_offsets253-27NumericNumeric, Serialquantize
44quantize_lengths263-27NumericNumeric, Serialquantize
45bitsplit_top86324-27Numericvariable Numericbitsplit
46bitsplit_fp6324-27Numericvariable Numericbitsplit
47bitsplit_bf166324-27Numericvariable Numericbitsplit
48partition6424-27NumericNumeric, Serialpartition
49split_byrange4824-27Numericvariable Numericsplit
50sentinel_byte1824-27NumericNumeric, Numericsentinel
51sentinel_num1824-27NumericNumeric, Numericsentinel
52lz1924-27SerialSerial, Numeric, Numeric, Numericlz
53mux_lengths6524-27Numeric, NumericSerial, Numericmux-lengths
54sparse_num6626-27NumericNumeric, Numericsparse-num
55sparse_num_auto6626-27NumericNumeric, Numericsparse-num
57set_string_lens1110-27SerialStringconversion
58fse_v24915-27SerialNumeric, Serialentropy
59huffman_v25015-27SerialNumeric, Serialentropy
60huffman_struct_v25115-27StructNumeric, Serialentropy
61fse_ncount5215-27NumericSerialentropy
62zstd223-27SerialSerialzstd
63bitpack_serial273-27SerialSerialbitpack
64bitpack_int283-27NumericSerialbitpack
65flatpack293-27SerialSerial, Serialflatpack
66splitN409-27Serialvariable Serialsplit
67splitN_struct4715-27Structvariable Structsplit
68splitN_num4815-27Numericvariable Numericsplit
69split_by_struct419-27Serialvariable Structsplit-by-struct
70constant_serial4411-27SerialSerialconstant
71constant_fixed4511-27StructStructconstant
72tokenize_sorted378-27NumericNumeric, Numerictokenize
73tokenize_string_sorted3811-27StringString, Numerictokenize
74dedup_num_trusted5616-27Numeric (variable count)Numericdedup
75bit_split6324-27Numericvariable Numericbitsplit
76rolz_deprecated203-12SerialSeriallegacy
77fast_lz_deprecated213-12SerialSeriallegacy
78fse_deprecated153-14SerialSeriallegacy
79huffman_deprecated163-14SerialSeriallegacy
80huffman_fixed_deprecated173-14StructSeriallegacy
81zstd_fixed_deprecated233-10StructSeriallegacy
82transpose_deprecated23-10StructStructlegacy
83transpose_split2_deprecated303-10StructSerial, Seriallegacy
84transpose_split4_deprecated313-10StructSerial, Serial, Serial, Seriallegacy
85transpose_split8_deprecated323-10StructSerial, Serial, Serial, Serial, Serial, Serial, Serial, Seriallegacy
86lz46223-27SerialSeriallz4
87pivco_huffman6727-27SerialNumeric, Serialpivco-huffman

Node 56 does not exist in the reference table. Nodes 72 and 73 (tokenize_*_sorted) share the tokenize encoders with a default local parameter that sorts the alphabet. bit_split (75) and bitsplit_top8/fp/bf16 (45 to 47) share one encoder with different splits. Numeric, struct and string conversion nodes 10 to 26 are the implicit conversions of the engine (conversion feature).

Mechanics by family

Zstandard (node 62, zstd)

  • Output stream is varint(element width) followed by the magicless Zstandard frame: mzstd writes FrameFormat::Magicless natively, so no staging or magic overwrite exists. The default-graph fast path (ZstdNode::encode) pushes [0x01] then compress_into.
  • Parameters are applied in the reference’s order: the global level, then literalCompressionMode = Disabled when the decompression level is 1, then the node’s local integer parameters (raw ZSTD_c_* IDs) through CParameter::try_from, check_memory_cap(3) and with_parameter. format (10) and contentSizeFlag (200) are refused; window, hash, chain and long-distance hash logs stop three below their maximum.
  • Block splitting (encode_elements: 2 to 8 elements of at least 1024 bytes, one flushed block per element with a pledged size) is ported and tested against the enc_zstd C oracle, but is unreachable from node 62 at v21 and newer (the registered node takes serial input only; the struct variant is v3 to 10).
  • A node with a dictionary compresses through refCDict equivalent (compress_with_dictionary_into); the block-splitting route ignores it, as C does. See dictionaries.

Conversions (conversion)

Nodes 10 to 26 and set_string_lens (57): reinterpret between serial, struct, numeric (little and big endian) and string without copying (reference), separate_string_components splits a string into serial content and numeric lengths. These are also the engine’s implicit conversions.

Numeric (numeric/)

CodecFormat summary
delta_intheader carries the first element (an empty input has 0 elements); deltas wrap at the width
zigzag(v << 1) ^ (v >> (w-1))
divide_byvarint divisor (divide_by plain / and %, no SIMD)
range_packheader: destination width and, when needed, the little-endian minimum
quantize_offsets/lengthscodes (numeric w1) plus extra bits (serial); offsets base 1<<c, lengths c < 16 literal else 1<<(c-12)
bitSplitheader: output width plus field widths, 512-element blocks, variable numeric outputs
float_deconstructheader 0/1/2 for f32/bf16/f16; sign and fraction as struct, exponent as serial
sparse_num, sparse_num_autodistances w1/2/4 and the dominant symbol in the header; auto chooses the dominant value
merge_sortedvarint per run, at most 64 runs; the engine’s graph rejects wider or longer inputs to the backup
sentinel (byte and num)exceptional values behind a sentinel; the sentinel and divisor indices travel in copy parameters
partitionheader flags (log2 width, preset, pow2 sizes) plus varint or bit-packed sizes, 1 to 256 partitions; value is base + offset; generic kernel only

Shared helpers live in shared::numeric (feature-gated by the codecs that call them; unused NUMOP_* functions are #[cfg(test)]).

Structure (structure/)

NodeNotes
tokenize (struct, numeric, string, and the *_sorted rows)alphabet plus index stream; the registry default local parameter (0, 1) selects the sort; custom tokenizer and parser functions fail the node
splitN, splitN_struct, splitN_num, split_byrangesegment sizes arrive as the reference parameter 323 (Arc<Vec<usize>>); a last size of 0 takes the rest; split_byrange reads parameter 324
split_by_structfield sizes as a reference parameter
dispatchN_byTag, dispatch_stringtags, sizes and one stream per tag, with metadata 83; dispatch_string writes the index stream first
concat_*, dedup_num (+ trusted), interleave_string, prefix, parse_intmulti-input nodes take a variable input count; parse_int accepts pre-parsed integers as a reference parameter
transpose_splitthe only transpose still written (v11 and newer)

Public helpers GraphContext::run_split_node, run_dispatch_node and run_dispatch_string_node wrap the reference-parameter forms for user graphs.

Packing

  • bitpack_serial / bitpack_int (63, 64): nbBits = 1 + highbit(max); header byte log2(width) << 6 | (nbBits - 1) plus a padding byte; values LSB first. nbBits comes from bitpack_kernels::nb_bits, an OR reduction over 64-byte fearless_simd lanes (the highest set bit of the OR is that of the maximum), dispatched once on cx.simd and folded onto the element’s byte positions. Fields of up to 16 bits are packed eight at a time: eight fields are exactly nbBits bytes, composed in a u128 and stored whole into a pre-sized buffer (encode_groups); wider fields use the one-field encode_scalar.
  • bitunpack (27): width from nbBits; header [nbBits] plus trailing bits from v7.
  • flatpack (65): sorted distinct alphabet and the indices plus a terminator; eight indices (exactly bits bytes) are composed in a u64 per store, the < 8 tail and the terminator bit are written separately.
  • constant (70, 71): one element and a varint count.
  • mux_lengths (53): multiplexes literal and match lengths; header split | bias << 4, the bias taken from the LZ metadata.
  • Kernels live in shared::bitpack_kernels (encode, decode, encode_bound, and writer::BitWriter, the port of ZS_BitCStreamFF).

Entropy and LZ

See entropy (fse_v2, fse_ncount, huffman_v2, huffman_struct_v2, pivco_huffman) and LZ (lz4, lz, field_lz, quantize, mux_lengths).

Deviations from the reference

  • divide_by and partition use the portable kernels; merge_sorted scans k-way; SIMD variants of the reference are not ported (outputs are identical).
  • Encoder-side transpose 2 and transpose_split2/4/8 are not ported: the encoder’s maximum useful version for them is 10, below the supported range.
  • Custom tokenizers and parser-function parameters are unsupported.
  • tokenize (struct and numeric) finds first occurrences with an open-addressing TokenMap (linear probing, load factor 1/2) and records each element’s id while scanning, so sorting only remaps ids to ranks; strings still use a BTreeMap.
  • transpose_split runs shared::byte_transpose::split: for widths 2, 4, 8 the elements are deinterleaved sixteen at a time with log2(W) rounds of unzip_low/high on u8x16, dispatched on cx.simd; the tail and other widths are scalar.

lz4 (HC chain walk)

The graph level 6 maps to lz4hc hash chains (32 attempts, <PATTERN = false, SWAP = false>). The hot path is Hc::insert_and_get_wider_match, which is #[inline(always)] so each call site (find_best_match, the two insert_and_get_wider_match calls of the lazy parser, the optimal parser) gets its own copy: the lookback is a constant at the first-search site, and the call needs no frame or Match return through memory (the C compiler inlines it the same way). Reads in the chain loop are single-comparison checked: peek16 tests at <= len - 2 once (a wrapped, negative position fails it and reads as -2), count bounds both 8-byte reads with limit <= len, p_match < p_in and p_in + 8 <= limit, and count_back slices [ip - 8, ip) and [matched - 8, matched) after matched <= ip <= len. Anything outside those cases falls to the unchanged count_long / count_back_long loops.

flowchart LR
    A[find_best_match / lazy parser] --> B[insert_and_get_wider_match inlined]
    B --> C[LZ4HC_Insert: hash and chain every position before ip]
    C --> D{chain walk, up to 32 attempts}
    D -->|peek16 and read32 match| E[extend: count_back + count]
    D -->|no| F[next = index - chain delta]
    E --> F
    F --> D
    D -->|done| G[Match len, off, back]

Known gaps

  • No SIMD in any encoder kernel except the XXH3 checksum; the hotpath anchors cover only the chunk writer, the zstd node and XXH3, so the new codecs have not been profiled.
  • LZ4 has only the fresh-stream, not-limited entry points (no streaming or limitedOutput modes).
  • Some shared helpers (shared::bitpack_kernels::writer, shared::stats::count and data_stats, parts of shared::estimate) carry expect(dead_code) gates for feature sets that do not use them.
  • The C prefix encoder crashes on an all-empty string input, so the oracle tests cannot cover that shape.

Parallel compression

compressor::parallel is available with compression unless no_std is selected. Its caller-scheduled API follows ../mbrotli: ParallelCompressor, ParallelConfig, BatchConfig, validated SegmentSize and TaskCount, scoped/owned tasks and batches, random-access sources, staging, polling, retention, borrowed/owned sessions, task queues, and synchronous I/O adapters. There is no executor dependency, thread creation, global pool or unsafe code. ParallelCompressor::builder(encoder, parallel) returns a ParallelCompressorBuilder with serial-style with_graph and with_backend methods. new and with_backend remain shorthand for the default graph. Encoder settings are this crate’s EncoderConfig; operations use this crate’s Continue, Flush, and End names.

Ownership and dispatch

graph TD
    Builder[public ParallelCompressorBuilder] --> Registry[shared immutable CompressorGraph registry]
    Builder --> API
    Registry --> Worker
    API[compressor::parallel public API] --> Planner[private core::Compressor and Plan]
    API --> Batch[core::batch::Batch]
    API --> Stream[core::stream::Stream]
    Planner --> Workers[exclusive idle Worker reservoir]
    Batch --> Task[core::task::Task]
    Stream --> Queue[shared task queue]
    Queue --> Task
    Task --> Worker[owned Worker]
    Worker --> Fragment[core::fragment::FragmentEncoder]
    Fragment --> Serial[serial Compressor: private codec engine]
    Serial --> Header[private Compressor::frame_header_into: resolve plan and write header]
    Header --> Assembly[batch cached header / stream final header]
    Serial --> SIMD[Backend selected once by ParallelCompressor]
    Task --> Artifact[core::artifact: memory or core::spool file]
    Task --> Completion[single-assignment completion slot and ID channel]
    Completion --> Batch
    Completion --> Stream
    Batch --> Output[one ordered OpenZL frame]
    Stream --> Output

A task owns its worker, artifact, input handle, cancellation observer and completion capability; it never borrows the coordinator or compressor. Borrowed slice tasks work with scoped executors; owned sources produce Send + 'static tasks. A batch exclusively borrows the compressor until finish/drop. A stream borrows or owns it. Reconfiguration releases idle workers only when encoder settings change; the selected graph is preserved by reconfigure. Parallel reconfiguration reapplies retention limits. The graph registry and prepared dictionaries are shared by Arc; every worker has its own serial encoder and fragment buffers. Graph callbacks must accept concurrent calls and must be deterministic for scheduling/backend byte invariance to apply.

Backend is detected once at construction or supplied with with_backend, then passed to each worker’s serial compressors. The existing codec engine performs SIMD dispatch outside kernels. The parallel layer adds no SIMD kernels.

Planning and source consistency

Segments have a validated size of 64 KiB through 16 MiB (default 4 MiB). Segment boundaries depend on input coordinates, never task count. Tasks receive balanced contiguous groups; effective count is min(requested, segments). A nonempty input smaller than minimum_parallel_size (default 8 MiB) uses a canonical serial frame only if it fits one segment. Empty input has zero tasks and uses the serial empty frame. OpenZL chunks have no cross-segment history; context_prefix_bytes is always zero.

Slices are borrowed directly. ArcBytesSource, positional FileSource, mutex-protected SeekSource<R> and custom RandomAccessSource handles support owned tasks. Reads use u64 absolute coordinates and at most one segment buffer per worker. SeekSource keeps seek and short-read retry under one lock; file sources use positional reads on Unix/Windows. Other platforms report unsupported positional reads. Length and optional caller-supplied identity are captured at planning and rechecked before batch destination writes, according to SourceConsistency. Metadata snapshots cannot prove that contents are immutable; the caller must keep the source unchanged.

Preflight checks arithmetic, task/descriptor storage, a conservative per-worker workspace estimate (64 * segment_size + 128 MiB), retained workers and staging. Payload allowance is 2 * input + 1024 * max(segments, 1). Complete-output allowance adds 64 envelope bytes. Memory staging also charges descriptors; its explicit limit and the optional aggregate memory limit are independent. maximum_staging_memory_bytes computes the payload-plus-descriptor bound. BatchConfig::auto uses memory staging with a usize::MAX caller ceiling; it does not inspect available RAM or switch to disk. Estimates account for library buffers and workspaces, and are not process RSS or allocator guarantees.

Encoding and one-frame assembly

sequenceDiagram
    participant Caller
    participant Batch
    participant Task
    participant Serial as serial Compressor
    participant C as OpenZL decoder
    Caller->>Batch: prepare_slice / prepare_source
    Batch-->>Caller: take_tasks
    Caller->>Task: run in arbitrary executor/order
    loop fixed segments of this task
        Task->>Serial: compress one segment
        Serial-->>Task: complete serial frame
        Task->>Task: retain chunks; omit frame header and end marker
    end
    Task-->>Batch: artifact, descriptors and returned worker
    Caller->>Batch: finish_into / finish_to_writer
    Batch->>Batch: wait; verify source and every descriptor
    Batch->>C: one total-size header, ordered chunks, end marker 0
    C-->>Caller: original concatenated input

FragmentEncoder reuses a serial compressor, frame buffer and header buffer. Every worker is built with the selected CompressorGraph; standard graphs, static pipelines, parameterized codec nodes, selectors, segmenters and application codecs use the existing serial graph engine. Each independent input segment is serial; the graph may convert it internally. Segment boundaries, including a short final segment or a flush, must satisfy the graph’s alignment/record rules. Typed or multiple top-level inputs are not exposed by this API.

The private Compressor::frame_header_into resolves the serial plan and writes only its header through Engine::header. Fragment extraction uses that exact header length, rather than assuming a default header or parsing compressed data. This preserves the bundle-ID lane and checksum for dictionary graphs. The batch prepares and caches the full-input header before handing out workers; streaming writes it when the input size is known. Header failures preserve EncodeError under ParallelEncodeError::FrameHeader, before any destination mutation.

All bytes between the serial header and end marker are independently checksummed OpenZL chunks. Smaller fixed chunking and a selected graph’s own segmenter stay in force inside each parallel segment; trying to wrap an existing segmenter with Chunking::Fixed returns the same graph error as serial compression. Serial fallback and empty-input handling use the selected graph too. Changing global encoder parameters releases idle workers and preserves graph selection.

Without the zstd feature, the default-graph factory selects stored chunks even with nondefault stream/expansion/decompression-level settings. An explicitly selected graph receives its exact configuration and follows serial behavior for disabled codecs or unsupported versions: it may fail, or fall back when the graph and permissive settings permit it. Explicit selection never silently replaces the graph. Graph execution errors retain task/segment context and typed sources. The existing staged-payload allowance still applies; graphs that expand beyond it fail staging rather than exceeding the approved artifact bound.

The coordinator writes one new header announcing the full input length, copies artifacts in source order, and writes the OpenZL end marker. The serial fallback already contains its envelope and is copied unchanged. These are ordinary OpenZL frames, not concatenated frames or a private container. They need not match the reference default graph’s bytes. Fixed configuration/input gives identical bytes across scheduling order, task count, source, staging and supported SIMD backend.

Memory artifacts grow geometrically within their approved payload bounds. Directory staging creates exclusive private spool files, retains open handles, and unlinks them on drop. Assembly streams file contents through 128 KiB scratch. Descriptors must exactly cover planned segment ranges and artifact lengths. finish_into preserves the existing vector prefix and rolls its length back on assembly failure. Consuming finish_to_writer returns the writer, typed cause and exact accepted byte count on error; it does not flush or promise durability.

Streaming lifecycle

stateDiagram-v2
    [*] --> Open
    Open --> Open: Continue / gather, queue, collect compressed chunks
    Open --> Open: Flush / queue partial segment, await encoding
    Open --> Finishing: End / queue final input
    Finishing --> Finishing: await tasks, then emit header/chunks/end
    Finishing --> Finished: all bytes delivered
    Open --> Failed: queue, codec, allocation or staging-limit failure
    Finishing --> Failed: task or allocation failure
    Open --> Cancelled: drop
    Finishing --> Cancelled: drop
    Finished --> [*]
    Failed --> [*]
    Cancelled --> [*]

The task queue’s cloned handles support blocking iteration and nonblocking try_recv. At most window segments are queued/running/awaiting ordered collection; one additional input segment is gathered. Completed chunks move into a deque without copying payload and free their completion slots for reuse. OpenZL’s reference decoder requires a known total input size. The API follows mbrotli’s size-free streaming signatures, so all compressed chunks remain buffered until End. Only then is the total-size header prepended and the end marker appended. Flush encodes a partial segment but emits no prefix; it changes later segment boundaries. A stream without flushes matches its batch bytes. The optional aggregate memory limit reserves the initial window estimate and limits subsequent staged payload capacity plus per-part metadata. Unlimited configuration can retain a whole compressed input; window size alone does not bound that storage.

process is nonblocking and reports exact consumed/produced counts with NeedsInput, Pending, NeedsOutput, or Finished. wait/wait_timeout await completions without executing tasks. process_uninit shares the same state machine and writes only the produced prefix through the existing safe Destination abstraction. Read consumes the source through EOF before first output; Write retains compressed chunks until finish. Writer sink failures preserve undelivered bytes and allow finalization retry via FinishError. Executors must stay runnable while any adapter or wait blocks.

Failures and cleanup

Each task publishes exactly once. Dropping an unrun task publishes abandonment. Under unwind builds, a task catches worker/source panics, discards the worker, and reports TaskPanicked; abort builds cannot recover. Cancellation is cooperative between segments and cannot interrupt arbitrary blocking source I/O. A failed task cancels siblings. Batch failure selection prefers originating errors over cancellation fallout, then lowest task ID. Stream failures preserve the cause in StreamFailed, permanently fail subsequent calls and close the queue. Public errors preserve typed serial/configuration/I/O source chains.

Dropping a coordinator cancels and drains already published completions without waiting for detached tasks. Late tasks own their resources until completion. Dropping all queue handles closes the queue and abandons queued tasks; tasks already taken remain the executor’s responsibility. Completion never requires a coordinator to drain payload, so one-thread executors can run complete batches.

Validation and known gaps

tests/parallel*.rs cover C interoperability, versions/checksums, sources, staging, scheduler/backend determinism, fallback/segment boundaries, cancellation, panics, retryable I/O, incremental chunking and uninitialized destinations. tests/parallel_graphs.rs additionally covers selected graphs, codec pipelines, local parameters, custom nodes, dictionary envelopes and error propagation. Focused core tests cover baseline dispatch, artifact invariants and budget failure. The uninitialized streaming test is registered in scripts/check_simd_uninit.py for both Miri and ASan. AFL parallel and its libFuzzer adapter compare batch, source and streaming results and decode through C and Rust. benches/parallel.rs compares complete caller-scheduled compression with the C serial segmenter.

Known gaps: no parallel typed/multiple top-level input API or comment setter; no streaming disk staging or early frame output; no built-in executor; byte identity with reference default compression is not a contract. Active workspace estimates are conservative models, not hard allocator limits.

Codec families

Selectors, interpreters and coders with enough machinery for a specification of their own.

SpecificationScope
Entropy codersOpenZL’s FSE/Huffman fork, PivCo-Huffman, the five entropy nodes and the entropy graph’s plan.
LZ codecs and graphslz4 (fast, HC, optimal), lz, field_lz, mux_lengths, quantize, the shared lz_common infrastructure and the LZ graphs.
TransformerThe numeric selector: features, generated scorers, guards, depth limits, static fallbacks, float determinism and the no_std caveat.
ML selector and clusteringGradient-boosted-tree selection and tag-based clustering with their CBOR configurations.
SDDL and SDDL2The description interpreter, the bytecode VM, chunking and replay.
Custom codecsApplication codec registration, typed and variable ports, callback contexts, custom wire IDs, validation, errors and lifetime.
DictionariesDict, DictBundle and BundleStore, wire formats, the encoder and decoder flows.

Architecture overview and module map

Entropy coders

OpenZL carries its own FSE and Huffman libraries, forked from Zstandard 1.5.7 (configurable number of FSE states, table-log aware histogram, different workspace layout), so mzstd cannot stand in for them. The decoder side lives in decompressor::core::codecs::entropy and pivco_huffman (see the decoder catalogue); this page covers the encoder side: the shared FSE/HUF library, the five nodes and the entropy graph’s plan.

Module map

graph TD
    G["graphs/entropy.rs: graphs 3 to 7, set_entropy_destination"] --> N
    subgraph N ["codecs (features entropy, pivco-huffman)"]
        F["entropy.rs: fse_v2 58, fse_ncount 61, huffman_v2 59, huffman_struct_v2 60"]
        P["pivco_huffman.rs: node 87, v27 only"]
    end
    F --> LIB
    P --> LIB
    subgraph LIB ["shared::entropy (OpenZL fork)"]
        BITS["bits: forward BitWriter"]
        FSE["fse: normalize_count, CTable, write NCount, compress_using_ctable"]
        HUF["huf: build ctable, table log, write ctable, estimate, 1x and 4x"]
        H16["huf16: 16-bit Huffman for huffman_struct_v2"]
    end
    G --> STATS
    F --> STATS
    subgraph STATS ["shared::stats"]
        HIST[histogram]
        DS["data_stats: DataStatsU8, integer entropy with log table"]
        CNT[count]
    end

shared::entropy is compiled with compression and either entropy or pivco-huffman (pivco only builds Huffman tables); shared::stats likewise. All encoders write the reference’s exact bytes. The reference’s unrolled and “fast” loop variants differ only in speed; the byte Huffman and FSE encoders use bits::WordWriter (a 64-bit container flushed four symbols at a time, like BIT_flushBits), the 16-bit Huffman the simpler bits::BitWriter. Both apply the reference’s buffer-capacity failure rules (close). Deviation: stable sort for the 16-bit Huffman.

Nodes

NodeCodecInputsOutputsHeader
fse_v2 5849SerialNumeric counts (w2) with metadata 0 = table log, Serial bits[states 2 or 4][size], 2 states below 1000 symbols
fse_ncount 6152Numeric (w2)Serial FSE_writeNCount headernone
huffman_v2 5950SerialNumeric weights (w1) with metadata 0 = table log, Serial bits[x4][size], four streams from 256 bytes
huffman_struct_v2 6051Struct (w2)Numeric weights (up to 65536 symbols, log up to 20), Serial blocksblocks [count u32][size u32][payload], one or four streams
pivco_huffman 8767SerialNumeric weights (w1), Serial bit streamvarint decoded size, plus the block size when the input exceeds 32 KiB

The size in the [flag][size] header is little endian in nextPow2(size + 1) bits rounded up to bytes. Every coding node requires at least two elements and refuses a single repeated value (constant handles it). The graph passes precomputed state as reference parameters, HISTOGRAM_PID 246 (Arc<Histogram>) and HUF_CTABLE_PID 247 (Arc<HufCTable>), so the node skips recomputing it; a node run on its own builds both itself (histograms exist for widths 1 and 2).

PivCo-Huffman codes the input through the Huffman tree level by level: at each tree node the ranks of the symbols below it are partitioned by the next code bit into a bitmap (plus a one-count for internal nodes), then the halves recurse; runs of equal-length codes become flat leaves coded as fixed-width indices. Max 256 symbols, table log 12, 32 KiB blocks. The kernels (partition and flat-leaf packing on the encoder; merge and flat-leaf expansion on the decoder) have a scalar baseline and a fearless_simd kernel, byte-equivalent by definition; see PivCo-Huffman kernels.

PivCo-Huffman kernels

Both directions walk the Huffman tree recursively (one Walk per stream: the tree, the writer or reader, and the fearless_simd::Level) and call a kernel per tree node. A node is recursion, so a kernel cannot be inlined into one dispatched loop; instead each call matches the level once (Level::is_fallback, else dispatch!) and everything inside the kernel is #[inline(always)] generic code. Closures passed to the kernels are marked #[inline(always)] too, or they lose the kernel’s target_feature and every vector operation becomes a call.

graph TD
    E["compressor::codecs::pivco_huffman::encode (cx.simd)"] --> EW["Walk::node: tree recursion"]
    EW --> P["partition(level): bitmap, left ranks, right ranks"]
    EW --> K["pack(depth): flat leaf, DEPTH const generic"]
    P --> PS["partition_scalar: Level::Fallback"]
    P --> PV["partition_simd: dispatch!, 16 ranks per step"]
    D["decompressor::codecs::pivco_huffman::decode (ctx.state.simd)"] --> DW["Walk::node: tree recursion"]
    DW --> M["kernels::merge(level)"]
    DW --> F["kernels::flat(level)"]
    M --> MS["merge_scalar"]
    M --> MV["merge_simd: 16 symbols per step"]
    F --> FS["flat_scalar: u64 window, DEPTH const generic"]
    F --> FV["flat_simd: depth 1 to 7 by swizzle_dyn, depth 8 byte lookup"]

Encoder. partition splits the first num ranks of a node at the split rank: it writes the bitmap, compacts the left ranks to the scratch buffer and the right ranks over the source (the write position never passes the read position), or to the scratch buffer when the left child is constant, or nowhere when the right one is. The SIMD step compares 16 ranks, takes the bitmap word with to_bitmask, and compacts each side with one swizzle_dyn whose control comes from the 256-entry COMPRESS table (lane numbers of the set bits of a byte), the upper half’s control offset by 8; the two halves are stored as two words, the second after the first’s count. Stores may write junk past the ranks they keep, so both rank buffers hold block + 64 bytes. Flat leaves pack eight ranks into a u64 window (pack_depth::<DEPTH>). The specialization on “left kept” and “right destination” is a const-generic parameter of partition_kernel.

Decoder. merge interleaves the children by the node’s bitmap. A step loads 16 symbols from each source at its running offset, computes the exclusive prefix popcount of the 16 bitmap bits from the 256-entry PREFIX table (the right source’s gather index at set lanes; lane - prefix is the left source’s at clear lanes), gathers both with swizzle_dyn and selects by the bitmap. A constant child replaces its gather by a splat. flat unpacks 16 DEPTH-bit indices per step (gather the two bytes holding each field, multiply by 2^(8 - shift) and shift to align it, mask, narrow to bytes) and looks them up in the symbol table held as pages of 16 entries (one swizzle_dyn per page, depth 1 to 7); depth 8 is a plain byte-indexed table lookup, as in the reference.

flowchart TD
    N["Walk::node"] --> L{"flat leaf?"}
    L -- yes --> FL["bitmap = reader.bitmap(count * depth); kernels::flat(out)"]
    L -- no --> B["bitmap = reader.bitmap(count); ones = reader.int, unless both children constant"]
    B --> V{"ones <= count?"}
    V -- no --> X["corrupt"]
    V -- yes --> C1["left child: out = scratch, scratch = out"]
    C1 --> C2["right child: out = scratch[zeros..], scratch = out[zeros..]"]
    C2 --> MG["kernels::merge(out, bitmap, left, right) returns the ones it drew"]
    MG --> W{"equals the count read?"}
    W -- no --> X
    W -- yes --> OK["part"]

Slack invariant. Every out and scratch buffer holds count + SLACK (64) bytes, from the work buffers (block + SLACK each) down to every child’s share, and a vector source is the whole buffer from its first symbol, so a kernel reads a source a whole step (16 or 64 bytes) past its last symbol; the load checks the bytes are there and falls back to a zero-padded copy otherwise. The bitmap is checked against the count of ones by the merge itself (it returns the ones it drew), after the children decoded: a disagreeing bitmap reads other bytes of the buffers, which is safe, and ends in the same error. A block is decoded straight into the output when the rest of the output has SLACK bytes behind the block (the blocks to come overwrite what the children scribble there); only the last block uses a work buffer and a copy. flat is given the stream from its bitmap to the end, so its vector loads run into the following bytes (lanes past the count are discarded) instead of a padded copy.

Wide kernels. The AVX-512 level (is_wide) merges 64 bytes per step with expand (one vpexpandb per source, masks straight from the bitmap word) and partitions 64 ranks per step with compress (one vpcompressb per side, a full-width store, the cursor advanced by the popcount); the rank translation there is two concat_swizzle_dyn lookups of 128 entries and a select per 64 symbols. Every other level takes the 16-byte path. Its merge control comes from two 256-entry tables (CONTROLS) whose sum gives, per lane, the source index at the lane’s own side and a byte with the high bit set at the other side, so two zeroing shuffles and an OR replace the select (shuffle uses pshufb directly on SSE4.2/AVX2, where out-of-range indices zero the lane, and the precise variant elsewhere). The 16-byte partition produces both sides with one shuffle, as the reference: right ranks ascending then left ranks descending (PARTITION, the upper half’s row read through a window offset by the lower half’s left count and merged by min), stored as is for the right side and byte-reversed for the left. pack merges neighbouring fields three times (bytes into 16-, 32- and 64-bit lanes) and stores the two 64-bit lanes depth bytes apart. Tree::new (both directions) builds leaves as runs of the symbols grouped by weight in fixed arrays, with no allocation.

Tests: the kernels are compared with the scalar baseline at every available backend (merge in all four constant/vector shapes with short sources, flat at depths 1 to 8 against the definition, partition for every destination mode, whole-stream encode across backends), and C frames are decoded at every backend (tests/codecs_entropy.rs). The modules are in scripts/check_simd_uninit.py for Miri and AddressSanitizer.

The entropy graph

Graphs fse (3), huffman (4), huffman_huf0 (5), huffman_pivco (6) and entropy (7) are one function graph, entropyDynamicGraph, with a backend mode (FSE, Huffman, or whichever is estimated smaller) and a Huffman mode (huffman_v2 or pivco_huffman). Graph 6 selects PivCo at format 27 and newer; graphs 4 and 7 always use huffman_v2; below v27 PivCo falls back to huffman_v2. The deprecated pre-15 codecs are not supported (the minimum format is 21).

flowchart TD
    A[input stream] --> C["convert: width 1 to serial, width 2 to struct; other widths error"]
    C --> S{"1 element or fewer?"}
    S -- yes --> ST[store]
    S -- no --> M["minGain = max(param 66 or 32, size/100 * param 80 or 1)"]
    M --> K{"100000 elements or more?"}
    K -- yes --> SP["splitN into 32 KiB chunks (ref param 323); minGain scaled by chunk share"]
    K -- no --> CH
    SP --> CH[per chunk]
    CH --> W{"serial?"}
    W -- yes --> B[byte plan]
    W -- no --> D[wide plan]

Byte plan (plan_byte_chunk), for serial chunks:

flowchart TD
    H[histogram and DataStatsU8] --> I{"Huffman mode and largest count at most total/128 + 4?"}
    I -- yes --> MS[mode = Store]
    I -- no --> CD{"cardinality 1?"}
    MS --> CD
    CD -- yes --> CO[constant 8]
    CD -- no --> CT["build Huffman ctable when mode is Huf or Any"]
    CT --> RM["resolve_mode: n at most 64 means Store; Any compares FSE and Huffman estimates (FSE wins when fse + n/100 <= huf); the winner needs entropy + minGain <= n, else Store"]
    RM --> FP{"flatpacked < bitpacked?"}
    FP -- yes --> F2{"flatpacked < entropy and + minGain <= n"}
    F2 -- yes --> FLAT[flatpack 11]
    FP -- no --> BP{"bitpacked <= entropy and + minGain <= n"}
    BP -- yes --> BIT[bitpack 10]
    F2 -- no --> OUT
    BP -- no --> OUT
    OUT{"mode"} --> HU["Huffman node: weights to bitpack_int 41, bits to store"]
    OUT --> FS["fse_v2 node: counts to fse_ncount 44, bits to store"]
    OUT --> STO[store 2]

Wide plan (plan_wide_chunk), for 16-bit struct chunks: one value -> constant; bit-packing when it beats the Huffman estimate and store by minGain -> bitpack; store when the entropy exceeds 15 bits or the estimate does not pay -> store; fewer than 256 distinct values -> tokenize, alphabet bit-packed (when the maximum value needs fewer than 16 bits, else stored) and indices to huffman; else huffman_struct_v2 with its weights to fse and bits to store.

set_entropy_destination(cx, edge, graph, min_gain_bytes, min_gain_pct) (ZL_Edge_setEntropyDestination) sends an edge to an entropy graph with optional minimum-gain parameters; the LZ graphs use it and fall back to the default graph when an override index is invalid (see LZ).

Hot loops (FSE and byte Huffman)

Encoder nodes pass cx.simd, decoders ctx.state.simd, to the kernels, which dispatch once per call (dispatch!) into an #[inline(always)] kernel. The kernels are scalar code; compiling them inside the dispatch target is what lets LLVM use BMI2 shlx/shrx on the AVX2 level. Every level produces the same bytes (tests loop over Backend::available()).

flowchart TD
    D["decode_huffman / decode_fse (ctx.state.simd)"] --> L{"dispatch! level"}
    L --> Q["ByteTable::quad_kernel"]
    Q --> R{"output >= 8192?"}
    R -- yes --> P["pairs(): X1 cells folded into 2-symbol cells (built in place on the stack)"]
    P --> B["run_lanes: per iteration phase 1 = LOOKUPS lookups of the 4 lanes (chains only, cells to a stack array), phase 2 = the writes, then one refill per lane"]
    R -- no --> X["batches(): 4 lanes x 4 single lookups"]
    B --> F
    X --> F["finish per lane: 1-lane batches, then Reverse (zero-fill, exact-end check)"]
    F --> E["error if over-read or bits left"]
    L --> FD["fse::decode_kernel"]
    FD --> FT{"Table::fast?"}
    FT -- yes --> FQ["fast_quads::&lt;4 or 2&gt;: states in locals, one shift per read"]
    FT -- no --> GQ["generic_quads: rotate and mask, zero bits allowed"]
    FQ --> FF["tail: Reverse, bit-exact drain"]
    GQ --> FF
  • Backward bit reader: bits::refill loads the 8 bytes holding the cursor and left-aligns them, giving at least 56 valid bits; four lookups of at most 12 bits (BYTE_BATCH) fit. A batch runs only while the cursor has 56 bits, so the fast loop reads exactly what Reverse::peek would; the last symbols use Reverse, which keeps the zero-fill and the over-read/exact-end rules (remaining() < 0 is corruption, as is leftover with exact).
  • Byte Huffman: ByteTable holds a fixed 4096-cell [u16; 4096] (symbol, bits) so the masked index needs no bounds check. For outputs of 8192 bytes or more pairs() derives a table indexed by the first log bits (the cells past 2^log stay zero), each cell holding two symbols when both codes fit in the log-bit prefix (the content of the reference’s X2 table for the codes it covers), written in runs like the reference’s: each first symbol’s run is filled with the symbol alone, then the part of the run whose next bits start with a short enough second code gets the pair (the base table lists the longest codes first, so those codes are a suffix); the bits count sits in the low byte so a shift by the cell is a shift by the bits. pair_lanes() fills a [u32; 4096] on the stack (so its address needs no register) and run_lanes() runs four PairLanes over the shared body and the output split in its four quarters. The lookup index is bits >> shift with shift clamped to 52..=63, so LLVM sees an index below 4096 (no check) and, on the BMI2 levels, uses the non-destructive shrx. The lane keeps its unread bits left-aligned with a 1 bit under them, so the bits eaten since the last load are trailing_zeros and the refill is one 8-byte load at the new end of the lane’s input. The input of a lane is the prefix of the body that ends with the 8 bytes its bits came from; a refill shortens it by tz >> 3 (at most 8) after one length check, and last_chunk::<8> then needs none. The output of a lane is the unwritten rest of its quarter: a window first_chunk_mut::<WINDOW>() is taken once per iteration (one length check) and the rest shrinks by the symbols written (split_at_mut_checked), so no offset is kept. Lookups per refill are 6, 5 or 4 by log (as many as fit in 56 bits; 4 instead of 5 at log 11 was measured 20% slower, the refill is the dearest part of an iteration); the iteration count is fixed up front from input bytes and output room. Each iteration has two phases: phase 1 does the lookups of all four lanes (lookup j of every lane before j + 1, so the four dependent-load chains sit next to each other) and only stores the cell, so the four bit words stay in registers; phase 2 then writes each lane’s symbols (two bytes per cell, advance by the symbol count). Doing both in one pass spilled the bit words between dependent lookups. An order lane after lane was about 9% slower; two lanes at a time, 20%). The tail (finish) converts back to a cursor. 16-bit Huffman (WideTable) decodes two symbols per refill; the four blocks of an x4 payload (whose headers give every block’s size and symbol count up front) decode in lockstep into four parts of the output, as the byte lanes do.
  • FSE decoder: Table is a fixed [Cell; 4096] plus a fast flag, set when no symbol owns more than half of the table (the reference’s FSE_decompress_usingDTable_fast condition), which is exactly when no cell reads zero bits. Four symbols per refill (a state reads at most 12 bits per step), states cycling 0..4 or 0,1,0,1. fast_quads::<STATES> keeps the states in locals (a dynamic lane index would put them in memory), extracts the low bits with one shift of the left-aligned word and writes the four symbols as one chunk; generic_quads keeps the rotate-and-mask read for tables with zero-bit cells. A table without low-probability symbols spreads its symbols with the reference’s fast spread (symbols laid down in order, then scattered by the step).
  • Encoders: HufCTable carries entries, two [u64; 256] tables of TopWriter entries ([0] the code left-aligned with the code length in the low byte, [1] the code alone; two scale-8 lookups per symbol). TopWriter is the reference’s HUF_addBits scheme: add is one shift by the entry, one or and one add; add_group adds a whole group with independent shifts (each code shifted by the lengths that follow it, the old container by all of them), so the chain through the container is one shift and one or per group; five symbols per flush when the table log is 11 or less, four otherwise. The four streams of compress4x share one writer and buffer (close_into). fse::CTable has fixed [u16; 4096] and [SymbolTransform; 256] tables, borrowed once per call (Tables) so the loop does not reload them through the struct, and compress_states::<STATES> keeps the states in locals and writes four symbols between flushes into a WordWriter whose buffer is sized up front (its flush never allocates, so the writer’s fields stay in registers; a flush with no room fails the stream exactly where the reference’s destination would overflow). count8 loads eight bytes per step into four tables.

Known gaps

  • No vector instructions: the kernels are scalar, compiled under the dispatched level for BMI2 only. The reference’s assembly Huffman loop and its sub-table X2 symbols (longer than the prefix) are not ported; the pair table covers the symbols whose codes fit one 12-bit prefix.
  • Huffman decode is below the reference (about 0.7 to 0.8x in the comparison harness): the four-lane loop is bound by the chain of dependent lookups and registers (a lane needs its bits, a write position and a window; sixteen general registers do not hold four of them, so some spill), and the reference’s assembly loop addresses memory by absolute pointers. A three-symbol cell table (fewer lookups per byte) and a per-store bounds check instead of windows were both measured slower. The byte Huffman encoder is at about 0.86 to 1.0x (the histogram, which is in shared::stats, is a fifth of it); FSE is at about 0.9 to 1.05x (see benchmark-comparison.md for the current numbers).
  • Per-call tables: a struct Huffman table at log 20 costs 4 MiB per call.
  • The 16-bit Huffman uses a stable sort and a single bit writer replaces the reference’s unrolled variants.
  • PivCo: the dispatch is per tree node (the recursion cannot carry a target_feature), the 16-byte path is the only one below AVX-512, and flat at depth 8 and the rank translation below AVX-512 are scalar lookups, as in the reference. The work buffers are zeroed per node call.

LZ codecs and graphs

Four LZ-family encoders (lz4, lz, field_lz, plus the helper nodes quantize_* and mux_lengths), the infrastructure they share and the graphs that wire them to the entropy coders. Decoders are in the decoder catalogue. Tests: tests/encode_lz.rs (nodes), tests/encode_lzgraphs.rs (graphs 12, 28, 45, 46, 69 and compress_generic on struct and numeric inputs), tests/encode_backends.rs (lz4), all byte-identical with the reference and round-tripped through both decoders.

Module map

graph TD
    subgraph codecs ["compressor::core::codecs"]
        L4["lz4/: block, fast, hc, optimal"]
        LZ["lz/: kernel (fast, dfast, greedy, lazy, lazy2)"]
        FL["field_lz/: fast, greedy"]
        MX[mux_lengths]
        QZ[quantize]
        CM["lz_common/: hash, count, fast_table, row_table, window"]
    end
    LZ --> CM
    FL --> CM
    subgraph graphs ["compressor::core::graphs"]
        GL["lz.rs: graph 28"]
        GF["field_lz.rs: 12, 45, 46"]
        GS["small_lengths.rs: 69"]
        GE["entropy.rs: set_entropy_destination"]
    end
    GL --> LZ
    GL --> MX
    GL --> GS
    GL --> GE
    GF --> FL
    GF --> QZ
    GF --> GE
    LZ --> ST["shared::stats::count"]

lz_common is compiled with field-lz or lz: the multiplicative hashes of shared/hash.h, match-length counting (ZS_count, ZS_countBack), the single-candidate fast_table, the 16-candidate row_table and a single-segment window over indices (index 0 marks an empty slot). Dead code of the reference (fast_table16, fast_tag_table, copy.h) is not ported.

Row table and SIMD dispatch

row_table keeps each row as one 128-byte block (tags at 0, head at 16, positions at 20, as ZS_RowTable_Row), a 64-byte aligned Aligned struct in a boxed slice, so a row covers two cache lines (a Vec<[u8; 128]> from calloc is only 16-byte aligned, which spreads a row over three lines). A lookup compares the sixteen tags against the hash tag in one fearless_simd u8x16 compare, takes the bitmask, rotates it by the head and visits the set bits newest first, so the candidates (and the frames) are those of the reference.

RowTable<BIAS, MIN> carries the index bias (0 for lz, 1 for the field_lz window) and the hashed length as const generics (MIN 0 keeps the length in the table, as lz does; field_lz passes 4 or 8 and 8 or 16 so that the hash is selected at compile time: with a runtime length the hash was a jump table dispatched per lookup). fill / best_match carry the field size (FIELD: 1 for lz, 2, 4 or 8 for field_lz). The reference gathers the candidates into an array (prefetching each) and scores them afterwards. best_match with OUTLINE (field_lz) does the same: it reads each candidate’s first word into small arrays, so the cache misses of the candidates overlap, then scores them. Without OUTLINE (lz) each candidate is scored as it is read, which is the same match because the candidate set is fixed before the new position is inserted (the insert replaces the oldest slot, so it happens after the loop, also when the loop breaks early).

Cache warming. Safe Rust has no prefetch instruction, and a load that misses blocks retirement until it completes, so a “touch” by reading stalls the pipeline. lz keeps one early read of the head byte of the row four fields ahead (warmed() keeps the sum alive). field_lz greedy instead writes a zero to an unused byte of the first cache line of the rows of the next eight positions of both tables, four fields ahead, in one batch every eight positions (Finder::warm, RowTable::touch): a store that misses is fetched by the core without waiting for it at retirement, like a prefetch. Measured on sao (4-byte fields, 7 MB): 4.6-5.2 G cycles with per-call early reads, 3.8 G with the store batch; the C reference runs in 3.3 G.

fill hashes the windows of 16 bytes at each field step with a plain loop, so the hash reads of every position whose bytes lie inside the source are bounds-check free; the last few positions fall back to the zero-padded hash::load.

count / count_back (lz_common::count) slice both runs once and compare a word at a time over as_chunks / the preceding word, clamping the result to the anchor and window bounds; a run that does not fit the source takes the padded reference loop. count_after and count_back take a const OUTLINE flag that runs them behind a call: the field_lz greedy parser sets it (count_back keeps the usual case, a difference within the word before both positions, inline and calls out only for longer runs and the window edges), because inlined, the compiler hoists their slice arithmetic in front of the usual case of a position with no candidate; the lz lazy parser keeps them inline (measured with callgrind, 168 M against 177 M instructions on osdb for lz; the opposite for field_lz). field_lz::count_fields is the common prefix of the two runs rounded down to whole fields and is never inlined, for the same reason.

The SIMD level is selected once per node run, outside the parse loop: lz (kernel::lazy) and field_lz (compress) call dispatch!(cx.simd, simd => parse(simd, ..)), and every function below the parse is #[inline(always)] generic over S: Simd, so the whole parse is compiled with the level’s target features. Parsers are further specialised at compile time: field_lz greedy on the field width (2, 4, 8) and its two hash lengths (parse::<S, FIELD, MIN, LONG>) and lz lazy on the strategy depth and on the preset hash length 5. Fast, double-fast and the field_lz fast finder have no vector kernel (single-candidate tables).

flowchart LR
    N[node encode] -->|cx.simd| D{dispatch!}
    D -->|Fallback / Sse2 / Avx2 / Avx512 / Neon| P["parse::&lt;S, FIELD or DEPTH&gt;"]
    P --> B[RowTable::best_match]
    B --> G[u8x16 tag compare, bitmask, rotate]
    G --> C[each candidate: first word, count_after]
    C --> K[count_back when long enough]
    K --> C
    C --> I[insert pos, touch row ahead]

Nodes

NodeCodecOutputHeaderMechanics
lz4 8662raw LZ4 blockvarint(input size)level = global or local int 0; levels up to 1 use LZ4_compress_fast (acceleration -level + 1, clamped to 1..65537), above uses HC (level clamped to 12, below 1 means 9)
lz 5219literals (serial), offsets (numeric, 16 or 32 bit), literal lengths and match lengths (numeric, 16 bit)varint(bytes)strategy fast, dfast, greedy, lazy or lazy2 from the level; parameters 1 and 100 to 106 override fields; offsets are 32-bit when the adjusted window log exceeds 16; lengths above 65535 are split across sequences; match lengths carry metadata 77 = 4 (minimum match)
field_lz 924literals (struct), tokens (struct w2), offsets, extra literal lengths, extra match lengths (numeric w4)varint(fields)fields of width 2, 4 or 8; token: bits 0-1 offset kind (0 to 2 repcode, 3 explicit), bits 2-5 literal length, bits 6-9 match length, saturating at 15 into the extra streams; fast finder for levels up to 3 (long hash from 2), greedy from 4; level override parameter 181, levels at or below 0 act as 3 and above 5 as 5
mux_lengths 5365muxed bytes, overflow lengthssplit_point OR bias << 4one byte per sequence: split_point bits of literal length and the rest of match length minus the bias; saturated fields write length - mask to the overflow stream, literal length first; the bias comes from the lz metadata
quantize_offsets 43, quantize_lengths 4425, 26codes (numeric w1), extra bits (serial, LSB first)noneoffsets: one code per power of two (0 is an error); lengths: below 16 literal codes, else power-of-two buckets

lz4 details

  • Fast: inputs under 65547 bytes use the byU16 table with a 4-byte 13-bit hash; larger inputs byU32 with a 5-byte 12-bit hash and 65535 maximum distance; under 13 bytes the result is one literal run.
  • HC: positions are indexed with a 64 KiB offset; level 2 is lz4mid (two 14-bit tables), levels 3 to 9 hash chains, 10 to 12 the optimal parser (96, 512 and 16384 candidates, target lengths 64, 128 and 4095).
  • Only the fresh-stream, not-limited output mode is ported (all OpenZL uses); the limited-output, fill-output, dictionary and streaming entry points are not.
  • Hot-path shape (all safe Rust): the HC tables are fixed boxes (Box<[u32; 1 << 15]> hash, Box<[u16; 1 << 16]> chain, indexed by a u16 so no bounds check remains); insert walks windows(4) over one pre-sliced range. Hc::insert_and_get_wider_match is #[inline(always)] (each call site gets its own copy with no call frame, as the C compiler does) and generic over the reference’s patternAnalysis and chainSwap flags (<PATTERN, SWAP>): levels 3 to 7 run <false, false>, levels 8 and 9 <true, false> (chosen once in compress_hash_chain by max_attempts > 128) and the optimal parser <true, true>, so each variant has only its own branches. The chain walk keeps the two bytes a candidate must share with the best match so far (head16, the C LZ4_read16(iLowLimit + longest - 1)) and the matching index adjustment in locals that change only when longest does; peek16 returns -2 for a read outside the source (a negative LZ4HC_ back position wraps and fails get), which never equals head16’s -1 miss value or a byte pair. Only a candidate that passes the 16-bit and 32-bit tests reaches extend (inlined: count_back, then count), which keeps the walk’s register set small. count_back compares 8 bytes with a single read ending at the positions (most differences lie there) and falls to count_back_long only for longer runs or positions within 8 bytes of the start; count probes the first 8 bytes inline and falls to count_long (8-byte as_chunks) otherwise. peek16, count and count_back bound their reads with one or three comparisons and fall to the long loops outside them. The reads are get-based and panic only if a read leaves the source, which the reference never does.
  • Decoder (decompressor/core/codecs/lz4.rs), three tiers of the same sequence loop:
    1. fast_sequences (inlined into the block loop) decodes a sequence while the unread input holds IN_WINDOW (288) bytes and the output has OUT_WINDOW (640) bytes of room. The windows cover a token, one literal extension byte (at most 269 literals), the offset and one match extension byte (at most 273), read as whole 16-byte chunks, so each window is one comparison and the end-of-block checks are implied. An extension byte of 255 leaves the sequence to the general loop. The unread input is a shrinking slice advanced as soon as the lengths are known, so the loop-carried chain does not wait on the copies. A match of at most 16 bytes whose source ends before the literals is one inline 16-byte copy; every other match calls the out-of-line copy_long_match (32-byte chunks, sequential 16-byte chunks for overlap, copy_match below offset 16). Keeping that code out of line keeps the loop’s positions in registers. It stops once the position reaches MAX_OFFSET (65535). 1b. far_sequences (out of line, #[inline(never)]) takes over from MAX_OFFSET bytes of output on. Then every u16 offset is valid (the offset > written check disappears; only offset 0 is an error) and each sequence touches one fixed-size window of the output, FAR_WINDOW = 65535 + 640 bytes starting 65535 bytes before the position (first_chunk_mut::<FAR_WINDOW>, one check per sequence). Literals are copied to the constant index 65535 and a source is the index 65535 + literals - offset, so every copy is a constant-length copy_within over computed indices that LLVM proves in bounds: the loop has no panic edge. A match of at most 18 bytes at an offset of 8 or more is three copies of 8, 8 and 2 bytes in order, as LZ4_decompress_generic does (right for any such offset, one predictable condition); longer matches use chunked::<32|16|8> by offset class (each chunk reads only final bytes, so in-order chunks reproduce an overlapping copy); offsets below 8 call copy_long_match. The old per-sequence choice “does the 16-byte source chunk end before the literals” depended on the literal length and mispredicted (43 M branch misses against C’s 26 M on ooffice); the offset-class conditions brought it level with C (28 M).
    2. The general loop keeps the reference’s checks for everything else (longer extensions, the end of the block, blocks shorter than the windows). copy_literals and copy_match use 16-byte chunk copies when the output has the surplus room (the surplus is overwritten by later bytes), memmove for non-overlapping matches over 64 bytes, a fill for offset 1 and a doubling copy for short offsets with long matches. A chunk of an overlapping match only reads bytes that are final, so the in-order chunks reproduce LZ4’s overlap semantics. extended finds the end of a 255-run with one position scan. The windows of tier 1 imply the checks of the general loop, so accepted and rejected blocks are unchanged (offset 0 stays an error).
flowchart TD
    T["fast_sequences: sequence at ip, op"] --> S{"288 input and 640 output bytes left?"}
    S -- no --> G["general loop: extended lengths, end checks, copy_literals, copy_match"]
    S -- yes --> P{"op >= 65535?"}
    P -- yes --> F["far_sequences: fixed 66175-byte window, 8+8+2 short match, chunked by offset class"]
    F --> T
    P -- no --> X{"an extension byte of 255?"}
    X -- yes --> G
    X -- no --> L["copy literals in 16-byte chunks"]
    L --> O{"offset 0 or beyond output?"}
    O -- yes --> E[corrupt]
    O -- no --> M{"match of 16 bytes or less, source before the literals?"}
    M -- yes --> C16["inline 16-byte copy"]
    M -- no --> CL["copy_long_match (out of line): 32-byte chunks, 16-byte chunks, copy_match"]
    C16 --> T
    CL --> T
    G --> T
flowchart TD
    A["insert_and_get_wider_match (PATTERN, SWAP const flags)"] --> I["insert: chain every position before ip"]
    I --> W{"match_index >= lowest and attempts left?"}
    W -- no --> R[return best match]
    W -- yes --> H{"2 bytes at the best-length position equal (head16) and 4 bytes equal?"}
    H -- no --> N["next = match_index - chain delta"]
    H -- yes --> X["extend: count_back + count"]
    X --> U{"longer than best?"}
    U -- yes --> V["update longest, offset, head16, adjust"]
    U -- no --> N
    V --> N
    N --> W

Graphs

flowchart TD
    FLZ["field_lz 12: width 2, 4 or 8?"] -- no --> Z[zstd 9]
    FLZ -- yes --> CV{"numeric?"}
    CV -- yes --> NS["convert_num_to_struct"]
    CV -- no --> RN
    NS --> RN["field_lz node 9 (override param 181)"]
    RN --> S0["literals: field_lz_literals 45"]
    RN --> S1["tokens: bitpack when decompression level 1 or at most 128 tokens, else huffman"]
    RN --> S2["offsets: quantize_offsets 43"]
    RN --> S3["extra literal lengths: quantize_lengths 44"]
    RN --> S4["extra match lengths: quantize_lengths 44"]
    S2 --> Q["codes: bitpack at decompression level 1, else fse; extra bits: store"]
    S3 --> Q
    S4 --> Q

field_lz (12): widths other than 2, 4 and 8 go to zstd. A stream shorter than the min_stream_size is stored, and local int parameter i (0 to 4) replaces stream i’s graph with custom graph number value.

field_lz_literals (45): width 1 goes to the channel selector (46); wider literals are transposed with transpose_split (or the deprecated width nodes below v11) and each byte channel goes to the selector.

Channel selector (46), ZS2_transposedLiteralStreamSelector: at decompression level 1 a fast choice among store, constant, bitpack, flatpack and delta-flatpack by statistics. Otherwise: a constant channel -> constant (v11+) or huffman; under 200 bytes -> store; delta-or-not decided by comparing the Huffman estimate with the delta-Huffman estimate (fast estimates at level 3 or below, exact estimates below 7, trial compressions from 7); if the best Huffman saves less than 2 percent, store; else the smallest trial of store, Huffman and zstd (delta variants when delta wins), each with a size penalty (gainSize, wrapping like size_t).

lz (28): runs node 52 forwarding the graph’s parameters, then

flowchart TD
    N["lz node 52"] --> LI[literals]
    N --> OF[offsets]
    N --> LL[literal lengths]
    N --> ML[match lengths]
    LI --> E1["huffman (pivco at v27+ and 16 KiB or more; store at level below 0), param 1000"]
    OF --> E2{"input at most 8 KiB?"}
    E2 -- yes --> BP[bitpack 10]
    E2 -- no --> PB[partition_bitpack 23]
    LL --> MX
    ML --> MX["mux_lengths 53 (unless param 1004 names a graph for both)"]
    MX --> MB["muxed bytes: huffman when a guessed entropy size is below the raw size, else store; param 1002"]
    MX --> OV["overflow lengths: compress_small_lengths 69; param 1003"]

Parameters: level 1, graph indices 1000 (literals), 1001 (offsets), 1002 (muxed bytes), 1003 (overflow lengths), 1004 (mux for both lengths), minimum gain 10000 bytes (default input/200 + 50) and 10001 percent (default off). An invalid override index keeps the default graph (setEntropyDestinationOrOverride). compress_small_lengths (69) is described in standard graphs.

Deviations and known gaps

  • LZ4 decoder: the output is zero-filled (resize) before decoding because the stream buffer is a safe Vec; lz4_flex was not evaluated as a decoder (its end-of-block rules differ from the reference, which this port mirrors exactly).
  • The reference’s AVX2/SVE2 mux_lengths and ZS_Reps_update4 produce the same bytes as the scalar ports here (mux_lengths is a scalar loop specialised on the length width). The row-table tag compare is vectorised; the row prefetch is an early read because safe Rust has no prefetch intrinsic.
  • LZ4 streaming and limited-output entry points are not ported.
  • Decoders reject offset 0 everywhere (the reference reads unwritten memory); see decoder codecs.
  • tests/codecs_lz.rs decodes frames of the LZ4 graph and of the lz and field_lz nodes the C library writes; the standard lz (28) and field_lz (12) graphs are covered by tests/encode_lzgraphs.rs and tests/standard_graphs.rs.

Decoders of lz, field_lz, quantize

The lz and field_lz decoders write into an output buffer sized to the declared size up front (decompressor::core::codecs::lz_copy): literal runs of up to 16 bytes and matches with offset >= length are single 16-byte block copies whose overshoot is scratch (never read: sources lie before the write cursor) that later writes cover or the final truncation drops; longer or overlapping matches use copy_within, fill (offset 1) or doubling of the repeated pattern. Validation is unchanged (offset 0, offsets past the output, lengths past the declared size and unused streams are Corrupted). quantize reads the extra bits with one 64-bit load per value (a cold tail path zero pads past the end) from tables covering all 256 codes after a range check.

field_lz decode loop

Fields::expand (monomorphised on the field shift for 1, 2, 4 and 8-byte fields, groups::<BITS, LM>) runs groups of four tokens, like the reference’s four-token unrolling, with one margin check per group (output room, literal room, four explicit offsets left). Per group:

  1. offset_codes gathers the four 2-bit offset codes with one multiply; resolve_group resolves the four offsets and the new repeat offsets from the OFFSET_PLANS table (the scalar counterpart of ZS_kOffsetShuffle plus _pext_u64), with a shortcut when all four are explicit.
  2. short_group handles groups whose runs are at most 16 bytes (code 15 never qualifies): per token one 16-byte block for the literals and one for the match, both into a fixed 128-byte output window, the source read from the part of the output before the window. If any token is longer, or a source is not entirely before the window, it returns None and the group is redone by the general path (same bytes, same validity decision).
  3. The general path copies literals and matches in 16-byte blocks (copy_match for offsets under 16). A token with an extra length hands the rest of the group to finish/copy, the fully checked step also used for the tail tokens, so errors and limits are exactly those of the previous loop.
flowchart TD
    G[group of 4 tokens] --> M{margins: output, literals, 4 offsets}
    M -- no --> T[checked tail: resolve + copy per token]
    M -- yes --> R[resolve_group: OFFSET_PLANS]
    R --> S{short_group: runs <= 16 bytes, sources before window?}
    S -- yes --> N[next group]
    S -- no --> P{extra length in token?}
    P -- no --> B[block copies, copy_match for offsets < 16] --> N
    P -- yes --> F[finish: checked copy for rest of group] --> N

The output is still zero-filled up front (Vec::resize): about a tenth of the stage on sao. Known gap: no safe way found to skip it.

Transformer numeric selector

For numeric streams at compression level 7 and above, select_numeric (16) hands the stream to transformer_numeric1 (90), a selector that extracts statistical features, scores the candidate operations with a small neural network and routes the stream to the best supported one. The code is in compressor::core::graphs::transformer; it is the only consumer of shared::estimate. Output is byte-identical with the reference (tests/encode_transformer.rs: widths 1, 2, 4 and 8 over many data shapes, graphs 16, 30, 70 to 90, with no skipped cases).

Module map

graph TD
    NUM["numeric1: transformer_numeric 30, transformer_numeric1 90"] --> DEC[decide: decide8/16/32/64 + guards]
    NUM --> OPS[ids: operation to graph ID]
    DEC --> FEAT["features: per-width scans"]
    FEAT --> STATS["stats: KMV sample, sorted-gap mode, match4, gap CV"]
    FEAT --> CARD[cardinality: bitmap and HLL estimators]
    FEAT --> WIDE["wide: u128 helpers with release saturation"]
    DEC --> SC["scorers: num8, num16, num32, num64 GENERATED"]
    SC --> MATH["math: ln, log2, expf, sqrt (std or libm)"]
    NUM --> STATIC[static_select: core 76, index 82, fallback 89]
    STATIC --> EST[shared::estimate]
    STATS --> XXH[shared::xxh3 hash32]
    GEN["scripts/gen_transformer_scorers.py"] -.-> SC

Decision flow

flowchart TD
    A["SI_transformer_numeric_select(stream, depth)"] --> B{"empty?"}
    B -- yes --> ST[store 2]
    B -- no --> C{"width below 8 and more than u32::MAX elements?"}
    C -- yes --> FB[static_fallback 89]
    C -- no --> D{"depth above 24?"}
    D -- yes --> FB
    D -- no --> E["decide{8,16,32,64}: extract features"]
    E --> F{"range_s == 0?"}
    F -- yes --> K[Constant]
    F -- no --> G["network: hidden = relu(b1 + x W1), logits = b2 + hidden W2, scores = sigmoid(logits)"]
    G --> H["guards zero useless scores"]
    H --> I["argmax of logits over scores above 0, first on ties"]
    I --> J{"best op supported by this format version?"}
    J -- yes --> OP[graph for the operation]
    J -- no --> J2["best supported survivor by logit, else Invalid"]
    J2 -- Invalid --> FB
    J2 --> OP
    K --> OP

The operation to graph mapping (graph_for_operation): Constant 8, Bitpack 10, DeltaInt 70, DivideByGcd 72, Entropy 7, FieldLz 12, Fse 3, RangePack 71, SparseNum 75, Store 2, TokenizeNumeric 73, TokenizeNumericSorted 74, Zstd 9, Invalid 89. Support is a format-version check through is_node_supported: Constant needs both constant nodes, DivideByGcd needs divide_by, SparseNum needs sparse_num; the other operations are always supported.

The transforming graphs 70 to 75 run their node and send every output back to transformer_numeric1, so a stream can be transformed repeatedly; the recursion depth (SelectorCx::depth) is bounded at 24 for the model, after which the fallback decides.

Features and scorers

  • One scan per width, each ported in the reference’s operation order: Q32 fixed point for widths 1 to 4 and doubles for width 8; a full feature set for width 1 and “decision” subsets for widths 2, 4 and 8. Only the fields the scorers and guards read are computed. Elements are read little endian.
  • stats implements the KMV sample (K = 256), the sorted-gap mode, the match4 hash table (4096 entries, xxh3::hash32) and the transition-gap coefficient of variation; cardinality the bitmap and HyperLogLog estimators.
  • scorers/num{8,16,32,64}.rs hold the weight tables and the per-width feature expressions. They are generated by scripts/gen_transformer_scorers.py from the vendored C (score_numeric{8,16,32,64}.c and the feature headers): the script re-emits each f32 with the shortest decimal that round-trips, converts the feature expressions with a small C expression parser and refuses to run when the C network differs from the hand-written scorers/mod.rs (two dense layers, ReLU, sigmoid clamped at +-15). Rerun it, cargo fmt and commit whenever the vendored scorers change.
  • Guards (apply_score_guards) zero the score of: DivideByGcd when min_lb0 == 0 or min_u == 1; the tokenize operations when range density cardinality / (range + 1) exceeds the float-rounded 0.9; RangePack when the minimum is 0 and the maximum reaches half the width.

Static selectors (no model)

transformer_static_fallback (89) is the answer when the model has none. It tries, in order: the dense small-range num16 tokenize rule, the delta override, the float64 override, then a low-cardinality tokenize policy and the num16 delta rescue. Those choices end in the transformer_static_* pipelines (77 to 88), whose outputs lead to static_core (76), static_index (82) or, for the monotonic tokenize rows, straight to zstd and entropy.

static_core (76), in order: empty -> store; constant -> constant (v11 and newer, else store); depth above 30 (24 plus a margin of 6) -> terminal graph (zstd for width 1, field_lz otherwise); delta_int rules on up/down counts and delta cardinality; zigzag when the signed data crosses zero with at least 25 percent negative extent; divide_by_gcd for gcd of at least 256 (powers of two need decisive narrowing); range_pack (with a second delta stage for smooth data); the num8 FSE gate; the terminal graph.

static_index (82) decides whether a tokenize index stream is smooth enough for delta_int first (num8 locality and small-delta share, num16 temporal locality).

graph TD
    N1[transformer_numeric1 90] -->|no model answer or depth above 24| FB[static_fallback 89]
    FB --> CORE[static_core 76]
    FB --> TS["static_tok_sorted 83 (and delta_tok 84)"]
    FB --> TM["static_tok_mono 85, 86 and tok_mono_lz 87, 88: alphabet to zstd or entropy"]
    TS -->|indices| IDX[static_index 82]
    TS -->|alphabet| D
    IDX --> CORE
    IDX --> D
    CORE --> D[static_delta 77]
    CORE --> RP[static_range_pack 78]
    CORE --> RPD[static_range_pack_delta 79]
    CORE --> ZZ[static_zigzag 80]
    CORE --> GCD[static_gcd 81]
    D --> CORE
    RP --> CORE
    RPD --> D
    ZZ --> CORE
    GCD --> RP

Floating point and determinism

  • Every operation is a single IEEE operation in the reference’s order and type (float network, double feature expressions); mul_add is never used and the reference is built with -ffp-contract=off. The matrix product loops are interchanged (inputs outermost) for contiguous weight rows, but each output’s summation order (bias first, then inputs by index) is preserved.
  • math.rs routes ln, log2, expf and sqrt to the platform libm with std, and to the libm crate with no_std.
  • no_std caveat. libm::expf differs by one ULP from the platform expf for some inputs, and shared::estimate (log, exp, pow) can differ in the last bit too. A no_std build therefore has no byte-identity guarantee for decisions that depend on these functions; scores are within about 2 ULP and a decision changes only on an exact tie (none seen in more than 4000 compared frames). sqrt is correctly rounded everywhere. The libm crate is an optional dependency enabled by no_std; shared::estimate is compiled with compression in every build.
  • Wide arithmetic (wide.rs) uses native u128 with the reference’s release-build saturation (a quotient that does not fit is u64::MAX).

Known gaps

  • No automated drift check runs the generator in CI; the suggested check is to rerun the script and git diff.
  • The feature scans are scalar; the reference’s are scalar too, so no SIMD path exists to compare.
  • no_std determinism, see above.

ML selector and clustering

Two configurable graphs route streams by data the user trains or tags: ml_selector (17) chooses a successor with a gradient-boosted-tree model over statistics of a numeric stream, and cluster (18) groups tagged inputs and concatenates each group before its successor. Both read the CBOR blobs the reference’s training tools write, through the shared CBOR codec. Code: compressor::core::graphs::{ml_selector, clustering}, public wrappers in graph::{MlSelectorConfig, GbtModel, GbtNode, ClusteringConfig, ConfigError}. Tests: tests/encode_ml.rs (byte identity with the C graphs built through openzl_ffi::enc_ml, round trips, configuration behaviour).

Ownership

classDiagram
    class MlSelector {
        GraphFn
    }
    class Config {
        <<enum>>
        Gbt(Model)
        Unsupported
    }
    class Model {
        forests
        generator FeatureGen
        nb_successors
        feature_labels
    }
    class FeatureGen {
        <<enum>>
        Integer
    }
    class Clustering {
        GraphFn
    }
    class ClusterConfig {
        clusters
        type_defaults
    }
    class Cbor {
        shared::cbor: decode, encode, Item
    }
    MlSelector --> Config : mparam reference
    Config --> Model
    Model --> FeatureGen
    Clustering --> ClusterConfig : param 315
    Config ..> Cbor : deserialize
    ClusterConfig ..> Cbor : deserialize

shared::cbor is a port of the reference’s A1CBOR library (decode, encode, item): strict decoder configuration (DecoderConfig::STRICT), max_depth 128, arena accounting through limit_bytes (sizeof(A1C_Item) = 32 and sizeof(A1C_Pair) = 64 per item and pair), integers outside i64 rejected, maps written in item order. JSON output is not ported.

ML selector (17)

The model travels as a CBOR blob, the graph’s MParam. It is decoded once when the graph is registered (ZL_MLSel_materialize) and kept as the reserved reference parameter MPARAM_REF_ID (i32::MIN + 1) holding an Arc<Config>; a graph without it fails like the reference’s “config was not materialized”. GraphBuilder::ml_selector(config, successors) builds the graph; untrained_ml_selector builds one whose model always chooses class 0.

flowchart TD
    A["ML selector function graph"] --> B{"MParam materialized?"}
    B -- no --> F1[fail]
    B -- yes --> C{"model is Gbt?"}
    C -- no --> F2["fail (unknown model type loads but cannot run)"]
    C -- yes --> D{"successors == model.nb_successors?"}
    D -- no --> F3[fail]
    D -- yes --> E["per input: must be numeric"]
    E --> G["11 integer features"]
    G --> H["GBT predict: class"]
    H --> I{"class < successors?"}
    I -- no --> F4[fail]
    I -- yes --> J["set_destination(input, customGraphs[class])"]
  • Features (FeatureGen::Integer, the only generator; an unknown generator ID fails to load): nbElts, eltWidth, cardinality, cardinality_upper, cardinality_lower, range_size, mean, variance, stddev, skewness, kurtosis. The mean is accumulated in two interleaved sums, width 1 is computed from a histogram with the reference’s unsigned products, no fused multiply-adds.
  • GBT prediction: one forest is a binary classifier (class 1 when the summed leaves are not negative), several forests score one class each and the highest wins (the first on ties). f32 sums, tree by tree. A node splits on feature < value to left, otherwise right, and takes missing for a missing or NaN feature. Every child must advance (next > node).
  • Deviation: the reference trusts a deserialized tree and, when malformed, reads out of bounds or loops; here a malformed tree (no children, a child that does not advance or is out of range, a feature index below -1) fails the prediction. The config reader accepts what the reference’s reader accepts (including models GBTModel_validate would reject) and ignores unknown keys.

Clustering (18)

Every input carries the integer metadata CLUSTERING_TAG_METADATA_ID (0), its tag (Input::with_metadata). The configuration (CBOR, reference or copy parameter 315) lists clusters (type, element width, member tags, successor index, clustering-codec index) and per-type defaults. The graph’s custom graphs are the successors and its custom nodes the clustering codecs.

flowchart TD
    I["inputs with tags"] --> V["validate: successor indices in range, no tag in two clusters, no duplicate type default"]
    V --> C1["configured clusters, in configuration order"]
    V --> C2["unconfigured: one cluster per distinct (type, width, tag), in order of first appearance"]
    C1 --> S{"edges in the cluster"}
    C2 --> S
    S -- 0 --> N[skip]
    S -- 1 --> D1["set_destination(edge, successor)"]
    S -- "2 or more" --> CN["clustering node: default concat 33 serial, 34 numeric, 35 struct, 36 string"]
    CN -->|"2 outputs: sizes"| FL[field_lz 12]
    CN -->|data| D2["set_destination(data, successor)"]

An unconfigured tag uses its type’s default (successor and codec), or compress_generic and the type’s concat node without one. The map keys are (type, width, tag) in a BTreeMap, so cluster order and hence node and stream order, which determine the frame bytes, follow the rules above exactly. The typed ClusteringConfig converts a blob only when its type fields are known stream types (unknown stream type otherwise); a tag set twice on one input is last-wins.

Known gaps

  • Only the GBT model type and the integer feature generator exist (the same as the reference at v0.3.0).
  • No training: configurations come from the reference’s tools or are written by hand in code.
  • No AFL target feeds CBOR configurations to the decoder yet (see fuzzing).

SDDL and SDDL2

The Simple Data Description Language graphs parse a serial input with a compiled description and route the parsed fields to separate streams. sddl (20) is an interpreter over a CBOR description; sddl2 (21) is a segmenter that runs a stack-machine bytecode and replays the segments it found through the private sddl2_chunk graph (61). The compilers of the reference are C++ tools outside this repository, so descriptions and bytecode arrive as bytes (public: GraphBuilder::sddl(&[u8], impl Into<GraphId>), GraphBuilder::sddl2(&[u8], chunk_size, Option<GraphId>)). Code: compressor::core::graphs::{sddl, sddl2}; tests: tests/encode_sddl.rs with hand-built programs in tests/support/sddl.rs (byte identity with the reference and round trips through both decoders).

Module map

graph TD
    SD["sddl/mod.rs: graph 20"] --> PR["program: load CBOR, Expr tree"]
    SD --> ST["state: interpreter, scopes, instructions"]
    SD --> ER["error: SddlError, folded into ExecError"]
    PR --> CB[shared::cbor strict decoder]
    SD --> DN[run_dispatch_node + convert_serial_to_num nodes]
    S2["sddl2/mod.rs: segmenter 21 + replay graph 61"] --> VM["vm: bytecode machine"]
    S2 --> SP[run_split_node, split_by_struct, conversions]
    S2 --> SG[segmenters: default chunk size]
    DN --> DISP[dispatchN_byTag 5]

SDDL (20)

Parameters: description in copy parameter 522 (DESCRIPTION_PID), exactly one custom graph, no custom node, exactly one serial input.

sequenceDiagram
    participant G as Sddl graph
    participant P as program::load
    participant S as state::run
    participant D as dispatchN_byTag
    participant C as convert_serial_to_num
    participant U as successor
    G->>P: load description (CBOR, strict)
    P-->>G: Program exprs
    G->>S: run program over the input
    S-->>G: Instructions segment_sizes, tags, per-destination type, width, endianness
    G->>D: run_dispatch_node(nb_tags = destinations)
    D-->>G: tags, sizes, one serial stream per destination
    loop numeric destinations
        G->>C: node by width and endianness (19 to 25)
        C-->>G: numeric stream
    end
    G->>G: metadata 0 = position on every edge
    G->>U: set_destination(all edges), one multi-input destination

Interpreter (state.rs): it evaluates the program’s root expressions against the input. Consuming an atom appends to the dispatch instructions; consecutive atoms sent to the same destination merge into one segment. A record consumed repeatedly by an array replays a cached copy of its instructions (consume_record_no_scope), including the way the reference reconstructs a cached first instruction, because the segment list is part of the frame. Expression kinds: Null, Op (die, expect, log, consume, sizeof, send, assign, member, bind, comparison, arithmetic, bit and logic operators), Num, Field (atom, record, array, poison), Dest, Var, Scope, Tuple, Func. Reference counting is Rc; scopes shared by several holders are copied before modification, as in the reference.

Limits (port of the reference constants): at most 100 000 000 segments, 1 024 variables per scope, 1 024 destinations (a destination index of 1 024 or more is an error). Failures: a violated expect, a read past the input, an inconsistent destination type, unconsumed input.

Reproduced quirks: the cached first-instruction merge length; the 1 024 variable scope limit; wrapping i64 arithmetic where C is undefined. Deviations: no stderr logging, the disassembler is not ported.

SDDL2 (21 and 61)

flowchart TD
    A["sddl2 segmenter: bytecode (copy 7685), chunk size (int 7686)"] --> B["vm::execute over the whole input: list of typed, tagged segments"]
    B --> C{"no segments?"}
    C -- yes --> ST[whole input to store]
    C -- no --> D["pack segments into chunks of at most the chunk size (default 16 MiB; unlimited below format 21)"]
    D --> E{"segment larger than the chunk size?"}
    E -- yes --> F["split on element boundaries (one structure for a structure segment)"]
    E -- no --> G
    F --> G["each chunk: process_chunk to replay graph 61 with segment list (ref 7687) and first stream id (int 7688)"]
    G --> H["replay: splitN at segment boundaries"]
    H --> I{"segment kind"}
    I -- bytes --> J[destination as is]
    I -- primitive --> K["convert struct to num LE or BE by width, then destination"]
    I -- structure --> L["split_by_struct (field sizes 387), each field converted to numbers; a bytes field stays a struct stream"]
    J --> M["integer metadata 0 = running stream id (clustering tag)"]
    K --> M
    L --> M

The destination is the segmenter’s custom graph, compress_generic when none. Zero-size segments consume stream IDs without producing a stream, so the tags a downstream clustering graph sees stay aligned with the program.

VM (sddl2/vm.rs): 32-bit little-endian instruction words, the family in the high 16 bits and the opcode in the low 16; push.u32, push.i32 and push.tag carry a 4-byte immediate, push.i64 an 8-byte one. Families: push, math, cmp, logic, control, load, stack, type, var, call, segment. Capacities: 256 stack values, 256 value registers, 524 288 segments, 32 768 tags.

Quirks kept on purpose: an unknown family is a silent no-op (the reference’s dispatch switch has no default); stack.over and stack.rot are in the opcode table but invalid bytecode, as in the interpreter; type.fixed_array truncates its count to 32 bits before multiplying. VM failures map to the reference’s messages (InvalidBytecode, StackOverflow, StackUnderflow, MathOverflow, TypeMismatch, LoadBounds, SegmentBounds, LimitExceeded, DivZero, ValidationFailed) and surface as GraphError.

Known gaps

  • No AFL target feeds descriptions or bytecode to the interpreter and VM, and check_simd_uninit.py does not list these modules (they use no unsafe or MaybeUninit).
  • The SDDL2 bytecode disassembler and trace buffer are not ported (debug only).
  • No compiler for either language.

Custom codecs

The custom-codec feature, included in all-codecs and the defaults, exposes custom_codec. Applications implement CustomCodec::signature, plus encode and/or decode according to enabled directions. Signature borrows static type slices; registration validates and freezes it. CodecInput borrows typed content, widths and string lengths without exposing engine types. Callbacks are Send + Sync + 'static, owned through Arc, and may be shared by cloned graphs or decoder builders. Per-call contexts borrow engine state.

graph TD
    App[CustomCodec + Signature] --> GB[GraphBuilder.custom_codec]
    App --> DB[DecompressorBuilder.with_custom_codec]
    GB --> Reg[NodeDef: shared NodeSpec + Registered callback]
    Reg --> Exec[compressor::core::exec]
    Exec --> EC[EncoderContext: private EncoderApi adapter]
    EC --> NC[NodeCx: outputs and header]
    NC --> RT[RtGraph: standard flag + wire ID]
    RT --> Frame[layout: custom flag lane + u32 varint ID]
    DB --> DL[Layout: persistent custom registrations]
    Frame --> DL
    DL --> DC[DecoderContext: regenerated streams and budget]

Registration and identity

GraphBuilder::custom_codec(id, codec) returns an ordinary builder-owned NodeId. It works with static graphs, pipelines, function graphs and node parameterization. A custom node owns its spec; standard specs remain borrowed static entries. Parameterized nodes clone the shared spec and callback while replacing parameters. Multiple encoder nodes may use the same wire ID and signature with different configuration; conflicting signatures are rejected. Decoder registration rejects duplicate custom IDs. All u32 IDs, including zero, are supported independently of the standard codec namespace.

The decoder resolves custom IDs before laying out any stream; an unregistered ID or a build without the feature returns Unsupported::CustomCodec. Custom descriptors are copied into the per-chunk layout. Registration remains in the layout when reusable buffers are released, so ReleaseAll drops workspaces without dropping callbacks. retained_bytes counts workspaces rather than persistent configuration, as it does for dictionary stores.

Ports, headers and execution

The encoder requires at least one declared input port. The final port may repeat zero or more times. Singleton output outcomes must each be created once; variable outcomes may be emitted repeatedly, in creation order. Variable outcomes have distinct types because the decoder records only their type mask. The existing engine bounds input/output counts and validates each input type.

sequenceDiagram
    participant Engine
    participant Adapter as custom_codec::core
    participant User as CustomCodec
    participant Streams
    Engine->>Adapter: registered node + borrowed inputs + NodeCx
    Adapter->>User: encode(EncoderContext, CodecInput views)
    User->>Streams: emit / emit_strings, send_header
    Streams-->>Engine: mandatory outputs + variable outputs + private header
    Engine->>Engine: write reversed node order and custom ID lane
    Engine->>Adapter: registered decoder + header + typed inputs + budget
    Adapter->>User: decode(DecoderContext, CodecInput views)
    User->>Streams: emit every regenerated input in original port order
    Streams-->>Engine: validate widths, lengths, budget and commitment

EncoderContext exposes compression level, format version, local integer and byte parameters, fixed/string output creation and a single private header of at most 1 MB. Fixed outputs reject zero/invalid widths and incomplete elements; strings require lengths summing to their content. It delegates output placement to NodeCx, preserving singleton order and variable creation order.

DecoderContext exposes the private header, regenerated stream count and the engine’s per-stream byte budget (including four bytes per string length). Its emit methods enforce type, width, budget and exactly one commit per stream. Callbacks should check the budget before allocating their own workspace; the library cannot bound allocations made inside application code. The engine validates regenerated streams again after the callback, then applies the usual frame size and checksum checks. Custom ID 22 bypasses the standard Zstandard direct-output optimization even when zstd is enabled.

State, dispatch and errors

stateDiagram-v2
    [*] --> Registered: validate signature
    Registered --> Running: node invocation
    Running --> Committed: every mandatory stream emitted once
    Running --> Failed: callback or validation error
    Committed --> Registered: next chunk / frame
    Failed --> Registered: next operation resets engine workspace
    Registered --> Registered: trim reusable buffers

Application dispatch occurs once per node, outside application inner loops. No SIMD type crosses the public callback boundary; the application chooses its own kernels. Existing standard codecs retain the compressor/decompressor’s selected backend. No new SIMD or uninitialized-memory kernel is introduced.

CodecError is the public, non-exhaustive registration/callback error. Encoder callback and context failures retain it through private NodeError::Custom and public GraphError::CustomCodec; decoder failures retain it as the source of DecodeError::CustomCodec { id, source }. I/O adapters classify custom decoder errors as InvalidData. Other engine failures retain their existing high-level error mapping. Permissive fallback, minimum-stream-size handling and store-on-expansion apply to custom nodes just as to standard ones.

Verification and known gaps

tests/custom_codec.rs compares a serial XOR codec and its one-byte private header with the independently implemented C codec in openzl-ffi/shim/custom_codec.c: versions 21–27, IDs 0/22/127/128/1000/u32::MAX, empty and small inputs, byte identity and decoding both ways. Rust tests cover typed regeneration, variable ports, parameterization, fragmented sessions, registration lifetime, duplicate IDs and invalid callback output. The AFL and libFuzzer custom_codec adapters share a bounded byte-identity/round-trip target.

Custom dictionary materializers, callback access to reference parameters and stream metadata, and borrowed/zero-copy custom outputs are not implemented. Only built-in Zstandard dictionaries materialize. Custom codecs are not serialized as executable code: the receiving application must register them.

Dictionaries

A dictionary is opaque side information for one codec, today a Zstandard dictionary. Dictionaries travel in a bundle; a frame names the bundle it was compressed with and each codec instance of a chunk names its dictionary by index in the bundle. Supported from format version 25. The public surface is mopenzl::dict (compression or decompression feature) plus the graph builder methods; the wire formats live in shared::dictwire and shared::sha256. Tests: tests/encode_dict.rs (byte identity with the reference for single and chained nodes, several dictionaries per bundle, raw and trained dictionaries, levels and versions) with the openzl_ffi::enc_dict oracle.

Public types

classDiagram
    class DictId {
        32 bytes
    }
    class BundleId {
        32 bytes
    }
    class DictCodec {
        id u32
        is_custom bool
        ZSTD
    }
    class Dict {
        codec DictCodec
        id DictId
        content
        new() zstd() with_id()
    }
    class DictBundle {
        dicts
        id BundleId
    }
    class BundleStore {
        insert() contains() len()
    }
    class DictError {
        <<non_exhaustive>>
    }
    Dict --> DictId
    Dict --> DictCodec
    DictBundle o-- Dict : ordered
    DictBundle --> BundleId
    BundleStore o-- DictBundle : materialized
  • A dictionary ID defaults to the SHA-256 of its content; a bundle ID is the SHA-256 of the concatenated dictionary IDs, so the order of a bundle is part of its identity. Dict::zstd(raw, level) wraps raw content in the trained-content header (LE32 1 | LE32 level | raw).
  • DictBundle converts to and from the fat bundle (Vec::from(&bundle), TryFrom<&[u8]>), OpenZL’s interchange format; bytes after the last dictionary are ignored, as in the reference.
  • DictError (#[non_exhaustive]): malformed data, too many dictionaries (more than 65 535 per bundle), content over 4 GiB - 1, ConflictingId (two contents sharing an ID), NoMaterializer (only the standard Zstandard codec has one), Materialization, StoreFull.

Wire formats

dictionary   = LE32 0x4944CCDA | id (32) | LE32 codec | custom flag (1) | LE32 size | content
bundle info  = LE32 0x4942CCDA | bundle id (32) | fat flag (1) | LE32 count | ids (32 each)
fat bundle   = bundle info | dictionary * count
zstd content = LE32 1 | LE32 level | raw dictionary

In the frame (frame format): flag bit 3 and, from v25, the bundle ID as a length byte plus the ID with trailing zero bytes trimmed (an all-zero ID is refused). In every chunk, the dictionary lane: a has-dictionary bit per codec, then behind a width byte (nextPow2(max + 1)) the bit-packed indices of the flagged codecs. The lane is written in every chunk of a frame that has a bundle, including stored and empty chunks.

Encoder flow

sequenceDiagram
    participant U as user
    participant B as GraphBuilder
    participant R as Registry
    participant X as Exec
    participant N as zstd node
    U->>B: parameterize_node_with(Zstd, NodeParams.with_dictionary(id))
    B->>R: NodeDef with dict_id
    U->>B: load_dictionaries(bundle)
    B->>R: load_bundle: every dictionary needs a zstd node that names it
    R->>R: materialize each once (ZSTD_createCDict_advanced at the level in its content)
    U->>B: build(start)
    B->>R: resolve_dict_indices (dict_id to bundle index, NotFound fails the build)
    U->>X: compress
    X->>X: frame header carries the bundle ID (v25+)
    X->>X: run_node: dict_index below v25 is NodeVersion error
    X->>N: NodeCx.dictionary = prepared dictionary
    N->>N: compress_with_dictionary_into (block splitting ignores it, as C)
    X->>X: flush_chunk writes the dictionary lane
  • GraphBuilder::load_dictionaries must come after the nodes are parameterized (the reference quirk: CDictMgr_findMaterializer needs a node that references the dictionary), loads one bundle per compressor and reports GraphError::{Dictionary, BundleAlreadyLoaded, DictionaryNotFound}.
  • Only Zstandard nodes (codec 22) materialize; a dictionary no such node references cannot be loaded. Without the zstd feature nothing materializes.
  • DictState (registry::dict) holds the bundle ID, the dictionary IDs in order and the prepared dictionaries (Prepared::Zstd).

Decoder flow

sequenceDiagram
    participant U as user
    participant S as BundleStore
    participant E as Engine
    participant L as Layout
    participant Z as zstd decoder
    U->>S: insert(bundle): materialize DDicts once, shared by bundles that list them
    U->>E: DecompressorBuilder.with_dictionaries(store)
    E->>E: frame header names a bundle
    E->>S: open_frame resolves it (MissingBundle when absent)
    E->>L: parse chunk: dictionary lane gives Node.dict
    E->>E: Ctx.dict = bundle.dicts[index] (past the end: InvalidData DictionaryIndex)
    E->>Z: decompress_with_dictionary (direct path: Out::zstd takes the dictionary)
  • BundleStore::insert materializes at insertion (a Zstandard DecodeDictionary), shares a dictionary listed by several bundles, is a no-op for a bundle already held, caps at 128 bundles and 65 535 dictionaries, and leaves the store unchanged on error. BundleStore: TryFrom<&DictBundle>.
  • DecompressorBuilder::with_dictionaries(store) takes impl Into<Arc<BundleStore>> (the builder is no longer Copy). Errors: DecodeError::MissingBundle { bundle } and InvalidDataKind::DictionaryIndex; Unsupported::Dictionary no longer exists.

Known gaps

  • Only Zstandard dictionaries are materialized (encoder and decoder); other codecs, custom codecs and custom materializers are NoMaterializer.
  • No dictionary training; trained dictionaries come from the reference’s tools.
  • The multi-chunk (segmented) dictionary path is covered by round-trip tests only, not by byte-identity tests against the reference.
  • The block-splitting zstd route ignores the dictionary (as the reference).

Decompression

The universal decoder and its codec catalogue.

SpecificationScope
DecompressorFrame and unit parsing, chunk graph layout, the typed node executor, typed multi-output decoding, dictionaries, sessions, limits, I/O and errors.
Decoder codecsEvery standard codec decoder: signatures, wire formats, invariants and deviations from the reference on malformed data.

Architecture overview and module map

Decompressor

Ownership

classDiagram
    class Decompressor {
        DecoderConfig config
        RetentionPolicy retention
        Backend backend
        Engine engine
        Stream stream
    }
    class Engine {
        Layout layout
        State state
        Vec~Stream~ buffers
        Vec~Stream~ running
        Vec hashed
        Level simd
        Option~Arc~BundleStore~~ store
        Option~Arc~StoredBundle~~ bundle
    }
    class State {
        ZstdDecoder zstd
        Level simd
    }
    class Layout {
        Vec~Node~ nodes
        Vec~Source~ streams
        lanes...
        usize len
    }
    class Stream {
        Phase phase
        FrameInfo frame, pending_frame
        Vec unit, peek, output
    }
    Decompressor *-- Engine
    Decompressor *-- Stream
    Engine *-- Layout
    Engine *-- State
    DecoderSession --> Decompressor : borrows
    DecoderSessionOwned --> Decompressor : boxes

DecompressorBuilder::with_dictionaries(store) gives the engine the BundleStore frames may name. DecoderConfig selects FrameMode (Single, the default, rejects trailing bytes like ZL_decompress; Concatenated), DecodeLimits (input and output caps) and both checksum checks (on by default, as the reference).

Parsing

core::reader::Reader returns Parse::Truncated when bytes run out and Parse::Failed for invalid or unsupported data, so the same parsers serve one-shot calls (truncation is UnexpectedEndOfInput) and sessions (truncation means “wait”). frame::parse_with validates the magic, flags, output count and types (DFH_decoderOutputTypes), known sizes, string counts, comment and check byte, reporting each output’s OutputSpec; frame::parse wraps it for the byte-oriented paths and rejects frames that are not one serial output (Unsupported::MultipleOutputs / TypedOutput). Layout::parse reads a chunk header’s lanes, maps each standard ID or registered custom ID to its codec descriptor (codecs::standard; a disabled feature or unknown ID is Unsupported, a codec newer than the frame’s version is a header error), then wires streams as fillStoredStreams and DCTX_validateNodeStatic do and computes the chunk’s length. Each node owns inputs (a stream range) and regens (a range of Layout::regenerated, one stream per regenerated input); the last outputs streams of the chunk are its outputs and may not feed a codec:

flowchart TD
    A[chunk header lanes] --> B{every codec known?}
    B -- no --> U[Unsupported Codec / CustomCodec]
    B -- yes --> C[per codec: inputs after previous inputs]
    C --> D{inputs past the outputs or over 100 000, regen out of range<br/>or taken, variable inputs or regen count off the signature?}
    D -- yes --> G[InvalidData Graph]
    D -- no --> E{codec input produced by a later codec?}
    E -- yes --> G
    E -- no --> F[stored streams fill the rest in order]
    F --> H{stored count matches?}
    H -- no --> G
    H -- yes --> L[len = header + private headers + stored + checksums]

Chunk execution

Codecs are described by codecs::Desc: the types of the streams they regenerate (regens, the last repeating when variable_regens), of their fixed inputs and the type mask of variable inputs, the first format version that has them, and a decode function. Each codec family lives in codecs/<feature>.rs (or a directory) behind its feature; the codec catalogue lists them all.

sequenceDiagram
    participant E as Engine
    participant L as Layout
    participant C as codec decode fn
    participant O as Out / TypedOutput
    E->>L: parse(chunk)
    E->>E: verify compressed checksum (before any codec)
    E->>E: budget = 16 x remaining output + 1 MiB per stream
    loop nodes in order
        E->>E: input i = stream inputs.end - 1 - i (stored: serial width 1)
        E->>E: check each input type against the signature
        alt zstd regenerating the only output (byte paths)
            E->>O: decode straight into the destination
        else
            E->>E: take regenerated streams out of buffers, prepare(type, budget)
            E->>C: decode(ctx {header, state}, inputs, regens)
            C->>C: Stream::fixed / strings size each output within the budget
            E->>E: validate every regen (created, width, string sum, budget)
            E->>E: put regens back into buffers
        end
    end
    E->>O: hand over or copy the outputs (byte paths: one serial stream)
    E->>E: verify content checksum over the outputs (+ string lengths)

How the last stream reaches the destination:

flowchart TD
    S{source of the output stream} -- stored in the chunk --> P[Out::put copies it]
    S -- regenerated by a codec --> E{destination}
    E -- "empty Vec (decompress, empty decompress_into)" --> W["Out::put_vec swaps buffers: the stream becomes the output, the stream keeps the old empty allocation"]
    E -- "non-empty Vec, slice, uninit" --> P
    E -- "TypedOutput without content" --> A["TypedOutput::adopt takes the stream's buffers (Stream::take_content)"]
    E -- "TypedOutput with content (later chunks)" --> C[TypedOutput::append copies]

data::Stream mirrors ZL_Data for regenerated streams: a type, an element width (STREAM_validateTypeWidth: serial and string 1, numeric 1/2/4/8, struct any non-zero), the content, string lengths for strings, and the budget. Numeric content is little-endian on every host. Codecs read data::Input views and may only create outputs through Stream::fixed and Stream::strings, which reserve within the budget, so no frame can make the decoder allocate beyond 16 times its declared output plus slack per stream. Stream::fixed, Stream::strings, Stream::copy_of and Input::len are compiled only with the codec features that call them (a #[cfg(any(..))] list each, plus test), not kept under expect(dead_code): Rust 1.89 treats an expected-dead function as used and its callees as live. Codecs report malformed data as InvalidData { CodecStream } and never panic; differential tests check that they accept what the reference accepts on damaged frames, apart from the documented cases where the reference reads uninitialized memory.

Decompressor::decompress_typed returns one TypedOutput per frame output (ZL_DCtx_decompressMultiTBuffer): Engine::decode_typed decodes a single frame, appends each chunk’s output streams (output n is stream total - 1 - n) after checking their type, width and declared size and string count, and checks the content checksum over every output’s content followed by its string lengths (STREAM_hashLastCommit_xxh3low32). A regenerated output stream is moved into an output that has no content yet (TypedOutput::adopt, with append’s width rules) rather than copied; the engine’s buffer for that stream is reallocated by the next chunk. With a single output and no string lengths the checksum is computed over the output in place; otherwise the hashed bytes are staged in Engine::hashed.

On the byte paths the last regenerated stream is not read again after the chunk’s codecs run, so Out::put_vec lets a Vec destination that is still empty take the buffer by swapping it with its own (empty) allocation instead of copying. Destinations that already hold bytes, slices and MaybeUninit buffers copy through Out::put.

The zstd fast path decodes the magicless frame where it lies: mzstd reads FrameFormat::Magicless natively, the content size is read first with FrameHeaderInfo::parse (its absence is invalid, as in DI_zstd), and the decoder writes straight into the sized destination; no staging buffer holds a magic + frame copy any more. A node carrying a dictionary index decodes with decompress_with_dictionary (slice, uninit and Vec destinations all take the dictionary).

Dictionaries

sequenceDiagram
    participant S as Session or one-shot
    participant E as Engine
    participant L as Layout
    participant C as codec decode fn
    S->>E: open_frame(frame info)
    E->>E: frame.bundle? resolve in BundleStore, else DecodeError::MissingBundle
    S->>E: decode chunk
    E->>L: parse: dictionary lane gives Node.dict (index in the bundle)
    E->>E: index past the bundle: InvalidData DictionaryIndex
    E->>C: Ctx.with_dictionary(materialized dictionary)

Only Zstandard dictionaries are materialized. The bundle ID is read from the frame header (FrameInfo::bundle, length byte plus ID with trailing zeros trimmed); a frame without one skips the lane.

Frame metadata

The opt-in framing::FramedDecompressor validates outer resource records and their seek directory, then calls frame_info and decompress_typed on each independent frame. It preserves configured dictionaries, custom decoders, backend, retention and inner limits; see framed containers.

Decompressor::frame_info and TryFrom<&[u8]> for FrameInfo (decompressor::info) read a frame header without decoding any chunk, as the reference’s ZL_FrameInfo_* getters do: the format version, every output’s OutputInfo (type, content size, string count for strings and None for struct and numeric outputs), the checksum flags, the header comment and the dictionary bundle ID. The public type wraps the parser’s core::frame::FrameInfo, which keeps the comment’s bytes for it. tests/frame_info.rs compares every accessor with the C getters through openzl-ffi’s frame_info shim.

flowchart LR
    B[frame bytes] --> P["core::frame::parse_with"]
    P --> I["FrameInfo: version, outputs, comment, bundle, checksums"]
    I --> O["OutputInfo per output"]
    P -. no chunk is read .-> X[chunks]

Sessions

stateDiagram-v2
    [*] --> Frame
    Frame --> Chunk: frame header complete (output limit checked)
    Chunk --> Chunk: chunk complete -> decoded into pending output
    Chunk --> Frame: end marker, Concatenated
    Chunk --> Done: end marker, Single
    Frame --> Done: Finish between frames (Concatenated, >= 1 frame)
    Done --> [*]

A unit (frame header, chunk or end marker) is decoded once all its bytes are present: in place when it lies whole in the caller’s input, otherwise from the unit buffer. Its length comes from its header alone. A header split across calls is measured over the buffered prefix plus a window of new input that doubles from 64 bytes, so the session never accepts bytes past the unit it builds (trailing data after a Single frame stays with the caller) and measuring stays linear in the header’s length. The frame header found while measuring is kept, not parsed again.

DecoderSession/DecoderSessionOwned add a sticky failure flag (InvalidState afterwards), counters (total_in, total_out, frames_decoded) and retention on completion. io::DecoderReader and io::DecoderWriter wrap sessions with an 8 KiB buffer each.

Errors

DecodeError: InvalidData { kind } (Magic, FrameHeader, HeaderChecksum, ChunkHeader, Graph, CodecStream, DictionaryIndex), Unsupported { feature } (FormatVersion, Codec, CustomCodec, UnknownSize, MultipleOutputs, TypedOutput: the last two only from the byte-oriented APIs), MissingBundle { bundle } (the frame names a dictionary bundle the store lacks), UnexpectedEndOfInput, TrailingData, OutputTooSmall, both checksum mismatches, ContentSizeMismatch, both limits and InvalidState. It converts to std::io::Error with matching kinds.

Known gaps

  • Only Zstandard dictionaries are materialized; custom dictionary materializers are not implemented. See custom codecs for callback registration.
  • Sessions and the byte APIs read frames with one serial output only; typed and multi-output frames go through one-shot decompress_typed.
  • The partition+bitpack decoder fusion is not ported; see each codec’s specification for which kernels are vectorized.
  • Regenerated streams are copied where the reference references their inputs (conversions, dedup, concat).
  • Session output is decoded into the pending buffer and copied out; one-shot calls decode in place (see uninitialized output).
  • Slice and uninit destinations decode Zstandard through mzstd::decompress_to_slice, which trails the C library by about 15% on records-like data, while Vec destinations (mzstd::decompress_into) are ahead of it (see the comparison).

Registered application codecs use the same chunk layout, regenerated-stream budgets and checksum validation. DataType also serves as the private stream type, avoiding conversion at the callback boundary; its wire helpers remain private. See custom codecs.

Decoder codecs

Every standard codec valid at format versions 21–27 has a decoder in decompressor::core::codecs, one module per Cargo feature. This page lists their signatures, formats and invariants. The decompressor describes the engine that runs them, and codec features the gating.

Module map

graph TD
    R["codecs::standard id to Desc"] --> Z[zstd]
    R --> CV[conversion]
    R --> NUM["numeric: delta, zigzag, divide_by, quantize, range_pack,<br/>bitsplit, float_deconstruct, sparse_num, merge_sorted, constant, prefix"]
    R --> ST["structure: transpose, split, split_by_struct, dispatch,<br/>concat, dedup, interleave, mux_lengths, sentinel"]
    R --> PK[packing: bitpack, bitunpack, flatpack, tokenize, parse_int, partition]
    R --> EN[entropy: fse, huf, bits / pivco_huffman: tree]
    R --> LZ[lz4, lz, field_lz]
    Z --> MZ[mzstd]
    EN -. OpenZL's own FSE/HUF fork, not mzstd .-> EN

Each codec is a function fn(&mut Ctx, &[Input], &mut [Stream]). Ctx carries the codec’s private header, the reusable State (only zstd keeps any) and the codec’s materialized dictionary (Ctx::dict, set from the chunk’s dictionary lane; only the zstd codec reads it, see dictionaries). No codec keeps state across chunks; none needs the format version, since every version-dependent branch predates v21.

Signatures

Reading the table:

  • Regenerates: the streams the decoder rebuilds, i.e. the encoder’s inputs. A trailing … means the codec rebuilds a variable number of them (lastInputIsVariable).
  • Inputs: listed in reference order. Input i is stream end - 1 - i. VO T means a variable number of T inputs.
IDCodecFeatureSinceRegeneratesInputs
1delta_intdelta3NumericNumeric
2transposetranspose3StructStruct
3zigzagzigzag3NumericNumeric
4transpose_splittranspose11StructVO Serial
5–14conversionsconversion3/10/21see conversion.rs
18sentinelsentinel24NumericNumeric values, Numeric exceptions
19lzlz24SerialSerial literals, Numeric offsets, literal lengths, match lengths
22zstdzstd3SerialSerial
24field_lzfield-lz3StructStruct literals, Struct tokens (w2), Numeric offsets, extra literal and match lengths (w4)
25, 26quantize_offsets / lengthsquantize3Numeric (u32)Numeric codes (w1), Serial extra bits
27bitpack_serialbitpack3SerialSerial
28bitpack_intbitpack3NumericSerial
29flatpackflatpack3SerialSerial alphabet, Serial packed
30–32transpose_split2/4/8transpose3Struct2/4/8 Serial
33float_deconstructfloat-deconstruct4NumericStruct sign+fraction, Serial exponent
34bitunpackbitunpack6SerialNumeric
35range_packrange-pack8NumericNumeric
36tokenize_fixedtokenize8StructStruct alphabet, Numeric indices
37tokenize_numerictokenize8NumericNumeric alphabet, Numeric indices
38tokenize_stringtokenize11StringString alphabet, Numeric indices
40, 47, 48splitn / _struct / _numsplit9/14/14Serial / Struct / NumericVO of the same type
41splitByStructsplit-by-struct9SerialVO Struct
42dispatchN_byTagdispatch9SerialNumeric tags, Numeric sizes, VO Serial
43merge_sortedmerge-sorted9NumericNumeric bitsets, Numeric merged
44, 45constant_serial / fixedconstant11Serial / Structone element
46prefixprefix11StringString suffixes, Numeric match sizes (u32)
49fse_v2entropy15SerialNumeric counts (w2), Serial bits
50huffman_v2entropy15SerialNumeric weights (w1), Serial bits
51huffman_struct_v2entropy15Struct (w2)Numeric weights (w1), Serial bits
52fse_ncountentropy15Numeric (w2)Serial
53divide_bydivide-by16NumericNumeric
54dispatch_stringdispatch16StringNumeric indices, VO String
55, 57–59concat_serial / num / struct / stringconcat16–18T …Numeric sizes (w4), T
56dedup_numdedup16Numeric …Numeric
60parse_intparse-int19StringNumeric (w8)
61interleave_stringinterleave20String …String
62lz4lz423SerialSerial
63bitSplitbitsplit24NumericVO Numeric
64partitionpartition24NumericNumeric bucket IDs (w1), Serial offset bits
65mux_lengthsmux-lengths24Numeric, NumericSerial muxed, Numeric long lengths
66sparse_numsparse-num26NumericNumeric, Numeric
67pivco_huffmanpivco-huffman27SerialNumeric weights (w1), Serial bits

Legacy codecs whose newest format is below 21 are not registered and decode as Unsupported:

  • rolz (20) and fastlz (21);
  • zstd_fixed (23);
  • the deprecated FSE and Huffman codecs (15–17).

Transpose 2 and 30–32 still decode, as in the reference, even though no v21+ encoder writes them.

Formats and invariants

flowchart LR
    H[private header] --> K[codec kernel]
    I[typed inputs] --> K
    K --> F["Stream::fixed / strings (budgeted)"]
    F --> V[engine validate]
    K -->|malformed| X["InvalidData { CodecStream }"]

Numeric

delta_int

  • The header is the first element.
  • An empty header means zero elements, and then the delta stream must also be empty.
  • Sums wrap at the element width.

zigzag

  • Each value is decoded as (z >> 1) ^ -(z & 1), truncated to the element width.

divide_by

  • The header is a varint divisor, which must be non-zero and must fit the element width.
  • Each quotient must satisfy quotient ≤ MAX / divisor.

quantize

  • Codes are 1 byte wide and must be below 32 (offsets) or 44 (lengths).
  • Extra bits are read least significant bit first.
  • Offsets: base 1 << c, with c extra bits.
  • Lengths: below code 16 the value is the code itself; from 16 on it is base 1 << (c - 12) with c - 12 extra bits.
  • The output is u32.

range_pack

  • The header is the destination width, optionally followed by a little-endian minimum.
  • Each value is zero-extended, then the minimum is added with wrapping.

bitSplit

  • The header is the output width followed by the field widths.
  • n stored widths with n input streams means partial coverage; with n + 1 streams it means full coverage.
  • Each stream uses the smallest of 1, 2, 4 or 8 bytes that fits its field.
  • Values are joined in 512-element blocks.

float_deconstruct

  • The header is one byte: 0 for float32 (3-byte sign + fraction), 1 for bfloat16, 2 for float16.
  • Both inputs must have the same length.

sparse_num

  • Distances are 1, 2 or 4 bytes wide.
  • There are as many distances as values, or one more for a trailing run.
  • The header holds the dominant symbol.

merge_sorted

  • The header is one varint per list, up to 64 lists.
  • Each list must end exactly full.

constant

  • The input is a single element.
  • The header is a varint repeat count, at least 1, that fills the header exactly.

prefix

  • A match length cannot exceed the previous string’s length, so the first one is 0.

Structure

transpose (2)

  • The input is w columns of n bytes.
  • The output is n records of width w.
  • Both transposes call shared::byte_transpose::merge with ctx.state.simd: for widths 2, 4, 8 sixteen elements are rebuilt per step with log2(w) rounds of interleave on u8x16; the tail and other widths are scalar.

transpose_split

  • All columns must have equal length.
  • The output width is the number of columns; zero columns is corrupt.

splitn family

  • All inputs share one width.
  • With zero inputs the width comes from the header: an empty header means 1, otherwise a varint.

splitByStruct

  • There is at least one field, and all fields have the same element count.

dispatchN_byTag

  • Tags and sizes have the same length, and tags are at most 2 bytes wide.
  • Every tag must be below the stream count.
  • The segment sizes for each stream must sum exactly to that stream’s length.

dispatch_string

  • Indices are 16-bit, with at most 2048 streams.
  • For each i, the count of index i must equal the number of strings in stream i.

concat

  • The sizes have width 4, there is at least one, and there is one per regenerated stream.
  • The sizes must sum to the element count.

dedup

  • Every regenerated stream is a copy of the input.

interleave

  • The header is 4 bytes, little-endian: n, with 1 ≤ n ≤ 512.
  • n is also the regenerated stream count.
  • Stream j takes strings j, j + n, and so on.

mux_lengths

  • The header byte holds split = h & 15 (at most 8) and bias = h >> 4.
  • Literal overflow is consumed before match overflow.
  • Every long length must be consumed.

sentinel

  • The sentinel is either all ones or a varint header.
  • Each hit consumes one exception, and every exception must be used.

Input count

  • A codec reads at most 100 000 streams (ZL_transformOutStreamsLimit).

Packing

bitpack

  • The header is [log2(w) << 6 | (nbBits - 1)], optionally followed by an extra-elements byte.
  • count = floor(8 * len / nbBits) - extra.
  • Fields are packed least significant bit first.

bitunpack

  • The header is [nbBits] or [nbBits, remBits].

flatpack

  • Indices take ceil(log2(alphabet)) bits, or 1 bit for a one-symbol alphabet.
  • A sentinel 1 bit ends the stream.
  • The alphabet has at most 256 symbols.
  • Eight indices span exactly bits bytes: one unaligned 64-bit load and eight table lookups decode a group (unpack_symbols), the last groups read bit by bit. The maximum index is tracked and checked once; the sentinel is checked from the element count.

tokenize

  • Fixed-width tokens: an out-of-range index decodes as entry 0, as in the reference. gather::<IW, EW> is specialised for index and entry widths 1, 2, 4, 8; other entry widths copy slice by slice.
  • Strings: indices are validated, and there must be at least as many indices as alphabet entries.

parse_int

  • Values are printed as canonical decimal.

partition header flags:

  • bits 0–1: log2 of the output width;
  • bit 2: preset, with id = flags >> 3 (0 quantize offsets, 1 quantize lengths, 2 varbyte16);
  • bit 3: the range starts at zero;
  • bit 5: power-of-two sizes, with numBits = (flags >> 6) + 3; without it, sizes are varints.

partition decoding:

  • There are 1 to 256 partitions.
  • Each value is base + offset, truncated to the output width.
  • The offset reader loads eight bytes at the field’s first byte for fields up to 56 bits (the 16-byte window remains for wider ones); the output is pre-sized.

Entropy

All entropy codecs use OpenZL’s own FSE and Huffman fork; nothing is shared with mzstd. bits::Reverse replaces BIT_DStream_t.

FSE

  • The header is [states 2|4] followed by the size, little-endian, in 1 to 8 bytes.
  • There are 2 to 256 counts, which sum to a power of two with log between 5 and 12.
  • Decoding:
    1. initialise every state;
    2. decode round-robin across the states;
    3. tail: keep emitting while a state needs no bits;
    4. flush the remaining states - 1 symbols.
  • The decoded size must match exactly.

fse_ncount

  • A 4-bit log - 5, then variable-width counts, with 2-bit zero-repeat codes.
  • It must consume the whole stream.

Huffman v2

  • The header is [x4 flag][size].
  • There are 2 to 256 weights, each at most 12, with table log at most 12.
  • With x4: a jump table of three u16 values, then four streams.

Huffman struct

  • Up to 65 536 weights, with table log at most 20.
  • The stream is one or four blocks of [count u32][size u32][payload].

pivco

  • The header is the decoded size as a varint, plus an optional block size: 0 is invalid, and so is anything above 2^28.
  • The tree is built from the weights.
  • The bitstream is read forward, least significant bit first, and must be consumed exactly.

LZ

lz4

  • The header is the decoded size as a varint, at most INT_MAX.
  • The block is decoded by a safe port of LZ4_decompress_safe (lz4 1.10.0). It enforces the same end-of-block rules and is differentially tested on mutated blocks, including blocks large enough to reach the long-match and chunked-copy paths. Sequences far from both ends take a fast loop whose margins imply the general checks, so the accepted set is unchanged (offset 0 is the one rejection the reference lacks).
  • Fast loop (fast_sequences): a sequence is fast while the unread input holds IN_WINDOW (288) bytes and the output has OUT_WINDOW (640) bytes of room after op. The window covers a token, one extension byte for the literal run (at most 269 literals), the offset and one extension byte for the match (at most 273), all read as whole 16-byte chunks, with the end-of-block rules (MFLIMIT, LASTLITERALS) and the extension limits to spare, so none of those checks is repeated. A literal or match extension byte of 255 leaves the sequence untouched for the general path. The unread input is carried as a shrinking slice that is advanced as soon as the sequence length is known, so the loop-carried chain (token, lengths, next position) does not wait on the copies.
  • From MAX_OFFSET (65535) bytes of output on, far_sequences (out of line) runs the same sequences over one fixed-size output window (FAR_WINDOW, 65535 + 640 bytes, one first_chunk_mut per sequence): every u16 offset is valid there, literal and match indices are constants plus literals, offset, so the loop has no panic edge. A match of at most 18 bytes at an offset of 8 or more is three in-order copies of 8, 8 and 2 bytes (as the reference); longer matches are chunked by offset class (32, 16, 8 bytes), offsets below 8 use copy_long_match. Branch misses fell to the reference’s level.
  • Match copy classes in fast_sequences (output positions below 65535), by what the source looks like: a match of at most 16 bytes whose 16 source bytes end before the literals is one inline 16-byte copy. Every other match calls copy_long_match, which is deliberately out of line (the inline loop keeps its state in registers; with the long-match code inlined the loop spilled its position to the stack and the loop-carried chain grew): a first 32-byte copy when the first 32 source bytes end before the literals, followed by 32-byte chunks while the whole source does, or by sequential 16-byte chunks (which reproduce an overlapping copy) otherwise; offsets of 16 or more that fail those conditions use sequential 16-byte chunks; offsets below 16 call copy_match. Every copy writes whole chunks and the surplus is overwritten by the next sequence or lies in the room window.
  • Everything near the end of the block, with a longer extension, or in a block shorter than the windows, runs the general path (extended, copy_literals, copy_match), which is the checked port of the reference.
flowchart TD
    A[sequence at ip, op] --> B{input window 288 and output room 640?}
    B -- no --> G[general checked path]
    B -- yes --> C{extension byte 255?}
    C -- yes --> G
    C -- no --> D[copy literals in 16-byte chunks]
    D --> E{offset 0 or beyond output?}
    E -- yes --> X[corrupt]
    E -- no --> F{source before literals?}
    F -- "match of 16 bytes or less" --> H[inline 16-byte copy]
    F -- otherwise --> K[copy_long_match: 32-byte chunks, sequential 16-byte chunks, or copy_match]
    H --> A
    K --> A
    G --> A

lz

  • The header is the varint size.
  • The sequence count is the length of the offset stream, and all three numeric streams must be that length.
  • Matches may overlap their own output.

field_lz

  • The header is the capacity, in fields.
  • Each token is 16 bits: bits 0–1 are the offset code, 2–5 the literal-length code, 6–9 the match-length code.
  • Offset code 3 is an explicit offset, which is pushed onto the repeat stack. Codes 0–2 pick a repeat offset and move it to the front.
  • The repeat offsets start as 1, 2 and 4.
  • A length code of 15 adds an extra length.
  • Every side stream must be consumed.
  • Decoded in groups of four tokens with table-resolved offsets and fixed-size block copies; see lz.md.

Deviations from the reference

All of these concern malformed frames only:

  • Offset 0 (lz4, lz, field_lz): rejected here. The reference copies memory it has not written yet.
  • partition: a bucket ID at or above the partition count is rejected. The reference reads out of bounds.
  • flatpack: an out-of-range index is rejected. The reference’s SIMD path does not check it.
  • huffman_struct_v2: over-reads are rejected.
  • quantize: reading past the end of the extra-bits stream is rejected. The reference reads up to 56 zero-padded bits there.
  • bitunpack: a value wider than nbBits is masked to the field width; the reference’s pack kernel requires that it fits.
  • Entropy: only the X1 Huffman decoders and the generic FSE symbol spread are ported (the reference’s X2, assembly and AVX2 paths decode the same output); struct Huffman over-reads are rejected where the reference reads garbage; the PivCo merge is generic and bounded.
  • Overflow: arithmetic the reference lets wrap silently on corrupt sizes is reported as an error.

Known gaps

  • The kernels are scalar. The reference’s SSE, AVX2, NEON and BMI2 decoder variants are not ported. Decoding follows the reference’s portable algorithms.
  • The partition+bitpack decoder fusion is not ported. It is an optimization only.
  • Huffman tables are built for every call. A struct table at log 20 takes 4 MiB.
  • Copies are made where the reference passes streams by reference.
  • Only Zstandard consumes a dictionary; the other codecs ignore Ctx::dict.
  • The encoder-side counterpart is encoder codecs.

Containers

The opt-in framed container around ordinary frames.

SpecificationScope
Framed containersOpt-in graph-per-resource containers, typed frames, metadata, bounded native sessions, transport retries and checked seek directories.

Architecture overview and module map

Framed resource containers

The opt-in framed feature exposes framing with either codec direction. It adds a mopenzl-specific resource container around ordinary OpenZL frames; it does not change the OpenZL wire format or either serial codec’s defaults. One resource is exactly one frame, including typed and multiple inputs. Its graph is selected independently through FramedResource::graph. The outer container itself is not accepted by ZL_decompress or FrameMode::Concatenated.

Ownership and API

graph TD
    API[framing public facade: configs, metadata, errors, resource locations]
    API --> E[FramedCompressor owns Compressor]
    API --> D[FramedDecompressor owns Decompressor]
    E --> ES[borrowed FramedEncoderSession]
    ES --> EC[private core::encode::Encoder: queue and directory]
    D --> DS[borrowed FramedDecoderSession]
    DS --> DC[private core::decode::Decoder: unit assembly and directory]
    W[FramedWriter owns sink and 8 KiB transport buffer] --> ES
    R[FramedReader owns source and 8 KiB transport buffer] --> DS
    S[FramedSeekReader owns source, base offset and directory] --> D
    EC --> RawE[existing compressor graph engine]
    DC --> RawD[existing typed decoder]
    S --> RawD

FramedEncodeConfig combines EncoderConfig and FramingConfig; FramedDecodeConfig combines DecoderConfig and FramingConfig. Construct with new, or convert an existing raw codec with From to keep its backend, retention, graph (encoder), dictionary store and registered codecs (decoder). reconfigure changes container limits transactionally for later containers.

FramedInput borrows ordered FramedResource values. Each resource borrows serial bytes or a slice of Input, metadata fields, and an optional CompressorGraph. None uses the underlying compressor’s configured graph. An override is installed only around the raw compression call and restored on both success and failure. The registry is shared by the graph’s normal clone; frame bytes equal an ordinary compression call with that graph and config. The encoder’s private replace_graph is not a new public raw codec API.

compress and append-with-rollback compress_into drive the same borrowed session as framed_writer. A resource command takes complete borrowed inputs and queues a complete encoded frame; it keeps no caller input borrow. FramedDecoderSession::process consumes wire bytes until one resource is ready, then yields an owned DecodedResource with metadata and typed outputs. decompress collects those events and returns only after validating EOF. The decoder does not accept a graph: frames describe their codec layouts.

Wire format (version 1)

All integers are fixed-width little-endian; all offsets are relative to the first container byte. There are no alignment bytes, flags, padding records, implicit dictionaries or optional fields.

flowchart LR
    H["8 bytes: MOZFRM, version 1, reserved 0"] --> R1
    R1["RSRC record: header, metadata, checksum, OpenZL frame"] --> RN
    RN[zero or more further RSRC records] --> I
    I["INDX: count, directory entries, checksum"] --> F
    F["32-byte footer: MOZEND, version 1, reserved 0; index offset, index length, total length"]
StructureFields
Container header (8)ASCII MOZFRM, byte 1, byte 0. Future version bytes are UnsupportedVersion; a bad signature or reserved byte is invalid.
Resource fixed header (24)ASCII RSRC, u32 metadata byte length, u64 OpenZL frame byte length, u64 sum of decoded content bytes.
MetadataRepeated two-byte code, u32 value length, value bytes, with no field count. Uppercase ASCII codes may repeat. id is a unique UTF-8 name; mt is a unique signed i64 microsecond Unix timestamp.
Resource header checksum (4)Low 32 bits of XXH3-64, seed 0, over the fixed header followed by serialized metadata. The OpenZL frame follows immediately; its own configured checksums protect content.
DirectoryASCII INDX, u32 resource count, then 24 bytes per resource: u64 record offset, u64 record length including metadata/checksum/frame, u64 decoded content size; finally low 32 bits of XXH3-64 over the directory before its checksum.
Footer (32)ASCII MOZEND, byte 1, byte 0, u64 directory offset, u64 directory byte length including checksum, u64 complete container length.

An empty container is 52 bytes. A resource without metadata adds 28 bytes of record structure plus its unchanged OpenZL frame and 24 directory bytes. String length arrays are part of the inner frame but not counted as decoded content bytes. The directory must describe contiguous records starting at offset 8 and ending exactly at the directory; the footer and EOF are mandatory.

Encoder lifecycle and retries

stateDiagram-v2
    [*] --> HeaderQueued: start
    HeaderQueued --> Idle: Process drains header
    Idle --> ResourceQueued: resource command succeeds
    Idle --> Idle: rejected resource command, no commit
    ResourceQueued --> Idle: Process drains record
    HeaderQueued --> Draining: Finish
    ResourceQueued --> Draining: Finish
    Idle --> DirectoryQueued: Finish
    Draining --> DirectoryQueued: previous output fully drained
    DirectoryQueued --> Finished: directory and footer delivered
    Finished --> Finished: drain again, produces zero

NeedsInput admits another resource; NeedsOutput requires draining into caller storage. A zero-sized output consumes no queued bytes. Finish closes the resource list when prior output has drained and the suffix is queued; retries should keep using Finish. A rejected resource (metadata, graph, limit or allocation) leaves no directory entry or queued partial resource. Dropping the borrowed session abandons the container without poisoning the owner.

The std writer first drains older bytes, then accepts a resource without attempting to write its new bytes. Thus an error from resource means that resource was not accepted and the same command can be retried. Transport cursors advance only after successful writes; Interrupted is retried and WouldBlock or WriteZero preserves pending bytes. try_finish retains the writer; consuming finish returns FramedFinishError with the whole writer on error. No output is written during Drop. Directory allocation failures from native process do not carry a produced count; abandon that container.

Decoder lifecycle and limits

stateDiagram-v2
    [*] --> Header
    Header --> Tag: validate magic
    Tag --> RecordHeader: RSRC
    RecordHeader --> RecordBody: validate bounded lengths and aggregate output
    RecordBody --> Tag: validate metadata checksum, inner size and frame; resource event
    Tag --> Index: INDX
    Index --> Footer: verify checksum, contiguous entries, equality with records
    Footer --> AwaitEOF: validate offsets and complete length
    AwaitEOF --> Finished: Finish with no trailing bytes
    Header --> Failed: malformed or incomplete at Finish
    RecordBody --> Failed: decode or checksum failure
    Index --> Failed: invalid directory
    AwaitEOF --> Failed: trailing bytes
    Failed --> Failed: InvalidState

Unit assembly copies only as far as the currently required header/body boundary, so a resource event never accepts bytes of the next record. Host conversions, offset arithmetic and reservations are checked. Defaults cap resources at 10,000, metadata at 1 MiB per resource, encoded frames at 64 MiB, aggregate decoded content at 256 MiB and total wire input at 1 GiB. Untrusted lengths are capped before body buffering; inner frame size must equal the resource size before any codec executes. Raw DecoderConfig limits apply additionally to each frame; the typed decoder always rejects trailing bytes regardless of FrameMode. Codec workspace and string length storage follow existing typed decoder budgets, not the outer content-byte cap.

Resource events are tentative until the session reaches Finished: a later directory/footer failure invalidates the container. FramedDecodeFailure preserves the exact accepted prefix of the failing call; further calls fail with InvalidState. FramedReader::read_resource yields None only after the index, footer and EOF validate. Transport errors preserve its state for retry. After Finished, empty decoder calls stay Finished; further input fails with InvalidState. This terminal state is distinct from the footer’s AwaitEOF state.

Seek and dispatch

sequenceDiagram
    participant C as Caller
    participant S as FramedSeekReader
    participant R as Read + Seek source
    participant D as Decompressor
    C->>S: framed_seek_reader at base offset
    S->>R: read header, footer and bounded directory
    S->>S: validate lengths, checksum, contiguous ranges and aggregate size
    C->>S: read_resource(index)
    S->>R: seek base + record offset, read header then bounded body
    S->>S: validate record/directory agreement and metadata checksum
    S->>D: frame_info, then decompress_typed on exactly one frame
    D-->>C: typed outputs and metadata

The container extends from the source’s initial position to its end. Loading the index does not read/decode resource payloads; selecting a resource validates that record and its frame. The source must remain unchanged while the reader is alive. External dictionary bundles are resolved through the raw decoder’s BundleStore; there is no implicit dependency on earlier resources.

The raw codec chooses the SIMD backend once at construction. Framing checksums use that same level; codec dispatch remains in the existing graph/decoder engines. No new SIMD kernel, uninitialized buffer, FFI or unsafe code is added. FramedError adds container/resource context and preserves typed ConfigError, EncodeError, DecodeError and std I/O error sources.

Verification and known gaps

tests/framed.rs covers frame identity and C interoperability, graph overrides, typed/string/multiple inputs, metadata, empty resources/containers, every short session split, malformed/truncated wire, limits, native/transport equivalence, transport retries, embedded containers, seek and backend equality. Private wire tests exercise checked arithmetic and checksum-valid malformed structures. framed_decode and framed_roundtrip AFL/libFuzzer targets compare incremental and one-shot parsing, random resource schedules, graph choices and C decoding. benches/framed.rs records container and raw-frame sizes and measures full containers against independent C frames; the C API has no outer container.

  • Unlike the larger mbrotli/mzstd framing APIs, this version has no hidden resources, global/footer metadata, repeats, padding, internal dictionary references, owned native sessions or byte-oriented resource writers.
  • Resource commands take complete inputs. One encoded resource is staged; the decoder stages one encoded record and its typed output. Split very large input into resources explicitly; the container does not split it for you.
  • No partial-resource seeks or Read concatenation adapter: random access and sequential events return whole typed resources.
  • Directory storage grows with the resource count. Checksums detect damage; they do not authenticate the container or provide a whole-container hash.

Initial measurements

Apple M5 Pro, aarch64-apple-darwin, rustc 1.99.1, release, default level 6, 64 KiB resources, default checksums and graphs. Commands:

cargo bench --features framed --bench framed --locked -- --sample-size 10 --measurement-time 1 --warm-up-time 1
cargo bench --features framed --bench framed --locked -- rust-session --sample-size 10 --measurement-time 1 --warm-up-time 1

The first implementation has no pre-change framed baseline. C times are end-to-end independent frames (ZL_CCtx_compress / ZL_decompress), including context/output allocation, without the outer container that C does not support. Rust reuses its raw codec and measures container/output allocation and framing. Before timing, every embedded frame equals C’s frame byte for byte and C decodes it to the same content. The text corpus label says 1 MiB but the available C source corpus contains 571,256 bytes. These short measurements ran on a loaded development host and are initial evidence, not a performance guarantee.

CorpusInput bytesRaw OpenZL bytesContainer bytesRust / C encodeRust / C decode
tiny64841882.128 / 8.826 µs0.443 / 0.497 µs
C source text571,256116,328116,8482.963 / 3.108 ms0.290 / 0.266 ms
structured records1,048,576142,713143,5974.207 / 3.673 ms0.620 / 0.542 ms
incompressible262,144262,240262,50071.463 / 114.340 µs36.947 / 46.576 µs

Native sessions (encoder drains through an 8 KiB output window; decoder receives 1 KiB input windows) write/read the same bytes. Encode/decode point estimates: tiny 1.725/0.363 µs; text 3.047/0.295 ms; records 3.926/0.633 ms; incompressible 71.372/24.193 µs. Separate runs have visible scheduling noise, especially tiny and stored-frame decoding; no session speedup is claimed.

Verification on this host: Cargo fmt; std and all-feature workspace Clippy; all-feature workspace tests; std framed integration tests; doctests and rustdoc with warnings denied; direction-only framed feature builds; Rust 1.89 check; 100% function coverage in the new production modules (62 functions), plus the private compressor graph swap. cargo afl test --no-default-features replays the regression corpus, and both AFL binaries build in release mode.

Local AFL++ 4.40c / cargo-afl 0.18.2 campaigns could not start: macOS SysV shared-memory allocation failed, also outside the sandbox. No system tuning was performed. The same engine-neutral targets ran for 30 seconds each under libFuzzer with AddressSanitizer (nightly 1.101.0, 2026-10-23): 411,538 decoder executions and 67,489 round-trip executions, no findings. Their input corpora and generated findings stay outside the repository. CI adds bounded 15-second AFL smoke jobs on Linux, preserving findings and checking crash and hang counts. A short smoke run is not an exhaustive fuzzing campaign.

Development tools

How the repository measures, tests and publishes itself.

SpecificationScope
CompatibilityThe C oracle and its shims per family, byte-identity and decode differential tests, and what each test file covers.
FuzzingAFL targets, oracles, regression replay and the libFuzzer adapters.
Implementation comparisonCriterion and Silesia harness methodology (standard graphs and custom callbacks against C) and recorded results.
Continuous integrationWorkflows, feature probes, AFL smoke runs, Miri and AddressSanitizer selection, coverage gate, hotpath reports and releases.
Documentation publishingSource staging, navigation, section pages, recipe execution, link validation and Pages deployment.

Architecture overview and module map

Compatibility

openzl-ffi builds the pinned OpenZL v0.3.0 (with zstd 1.5.7 and lz4 1.10.0) from openzl-ffi/vendor/ and exposes it as the test and benchmark oracle. The build compiles every C file of openzl-ffi/shim/ into one static library; a new shim file reruns the build script. The vendored tree is never edited.

Oracle map

graph TD
    subgraph ffi ["openzl-ffi (Rust)"]
        LIB["lib.rs + sys: compress, decompress, compress_bound, xxh3_64, frame_info"]
        GR["graph: arbitrary graphs by ID, typed and multiple inputs and outputs"]
        NUM["numeric, structure, lz: node and parameter IDs, frames the generic builder cannot express"]
        EB["enc_backends: LZ4 block compressors"]
        ED["enc_dict: SHA-256, dictionary and bundle packers, trained dictionaries, fat-bundle loader"]
        EM["enc_ml: A1CBOR, features, GBT, clustering and MParam graph builder"]
        EN["enc_numeric: partition_bitpack search"]
        ES["enc_structure: custom-node split graphs, estimators"]
        ET["enc_transformer: scorers and decision"]
        EZ["enc_zstd: zstd node oracle"]
    end
    subgraph shim ["openzl-ffi/shim (C)"]
        S1["shim.c"]
        S2["graph.c, numeric.c, structure.c, lz.c"]
        S3["enc_backends.c, enc_dict.c, enc_ml.c, enc_numeric.c, enc_structure.c, enc_transformer.c, enc_zstd.c"]
    end
    LIB --> S1
    GR --> S2
    NUM --> S2
    EB --> S3
    ED --> S3
    EM --> S3
    EN --> S3
    ES --> S3
    ET --> S3
    EZ --> S3
    S1 --> V[vendored OpenZL, zstd, lz4]
    S2 --> V
    S3 --> V

The shims flatten OpenZL’s by-value ZL_Report into a size_t result and an error code; the safe wrappers own every buffer they hand to C. Graphs are named by the numeric IDs of ZL_StandardGraphID_*, ZL_StandardNodeID_* and the private IDs, so a test reaches every codec the C library has.

sequenceDiagram
    participant T as tests/*.rs
    participant R as mopenzl
    participant C as openzl-ffi
    T->>C: compress(input, graph, params)
    T->>R: Compressor with the same graph and params
    T->>T: assert frames equal
    T->>R: Decompressor::decompress(C frame)
    T->>C: decompress(Rust frame)

Test files

FileCovers
differential_c.rsDefault graph: every format version 21..=27, levels -50 to 22, every checksum combination, whole and segmented chunking (32 KiB, 100 000 bytes), empty, small, incompressible, text, zeros and mixed inputs; Compressor::max_compressed_size against ZL_compressBound
custom_codec.rsRegistered XOR codec: custom ID boundaries and standard ID collisions, private headers, C byte identity and decoding both ways; typed/variable ports, callback failures, sessions and registration lifetime
graph_engine.rsGraph engine byte identity: stores, conversions, min stream size, store on expansion, permissive and trial compression, segmenters, typed and multiple inputs, comments, user graphs
standard_graphs.rsEvery standard graph the C library accepts over serial, struct, numeric and string inputs at several levels and versions: C frames decode here, Rust frames equal C’s
encode_zstd.rsZstandard node: local parameter lists, levels, decompression level 1, block splitting vs enc_zstd, formats
encode_numeric.rsNumeric nodes (delta, zigzag, range_pack, divide_by, merge_sorted, partition, sparse_num, sentinel, bitSplit, float deconstruct) at every width, v21/24/27; partition_bitpack and merge_sorted graphs; static numeric pipelines
encode_structure.rstokenize, split, split_by_struct, dispatch, concat, dedup, interleave, prefix, parse_int, transpose nodes and their graphs; estimators
encode_backends.rslz4 (fast, HC, optimal), bitpack, bitunpack, flatpack nodes and graphs
encode_entropy.rsfse, huffman, 16-bit huffman, pivco, constant, fse_ncount nodes; graphs 3 to 8
encode_lz.rsfield_lz, lz, mux_lengths, quantize_* nodes
encode_lzgraphs.rsGraphs 12, 28, 45, 46, 69 and compress_generic on struct and numeric input
encode_transformer.rsGraphs 16, 30, 70 to 90 over widths 1, 2, 4, 8 and many data shapes
encode_ml.rsML selector and clustering graphs, CBOR codec, configuration behaviour
encode_sddl.rsSDDL, SDDL2 and the chunk graph, hand-built programs from tests/support/sddl.rs
encode_dict.rsDictionary bundles: byte identity for zstd nodes with dictionaries, round trips through both decoders
codecs_numeric.rs, codecs_packing.rs, codecs_structure.rs, codecs_entropy.rs, codecs_lz.rsDecoders against frames the C encoder writes, including damaged frames (accepted or rejected as C does, never a panic)
typed_outputs.rsTyped and multi-output frames against the C library
docs_examples.rsEvery Rust example of README.md and docs/*.md, generated by scripts/gen_docs_examples.py
public_api.rs, io.rs, streaming.rs, uninit.rs, simd_backends.rsPublic error contracts, adapters, sessions, uninitialized destinations, every host SIMD backend

Rules the tests follow: comparisons are strict (the “skip when a node is not ported” probes of the early packages are gone); frames are compared before timing in benchmarks (comparison); decoder-only unit tests do not depend on the Rust encoder. Unit tests in shared::xxh3 compare every length from 0 to 2 100 and several block boundaries with the C hash.

Known gaps

  • Miri cannot call C, so oracle tests do not run under it (see CI).
  • prefix on an all-empty string input cannot be compared (the C encoder crashes).
  • Segmented multi-chunk dictionary frames are round-trip tested only.
  • targetCBlockSize is accepted by C zstd and refused by mzstd; not compared.

Parallel frames

tests/parallel.rs, parallel_stream.rs and parallel_io.rs validate the mbrotli-style scheduling API, independent OpenZL chunks and one-frame assembly. The C oracle decodes versions 21–27 with all checksum combinations, negative and positive levels, smaller serial chunking and nondefault encoder parameters. Output is deterministic across scheduling, task count, source/staging and available backends, but matching reference serial bytes is not required. See parallel compression.

parallel_graphs tests selected standard graphs and individual codec pipelines, parameterized Zstandard nodes, registered XOR application nodes and dictionary headers against C decoding. It also compares serial fallback and local-parameter frames with the serial API and checks graph errors before destination writes.

Fuzzing

fuzz/afl holds the target bodies (src/targets.rs), thin AFL adapters, the committed regression corpus replayed by cargo afl test, the seed generators (examples/prepare_seeds.rs, examples/codec_seeds.rs) and campaign.sh; fuzz/libfuzzer wraps the same bodies for ClusterFuzzLite (.clusterfuzzlite/). Both packages are outside the workspace and link the C oracle (openzl-ffi).

flowchart LR
    I[fuzz input] --> DC[decompress: serial bytes, one-shot == session == slice, agrees with ZL_decompress]
    I --> DT[decompress_typed: frame_info == outputs, typed outputs agree with C]
    I --> H[decode_config: 2-byte header]
    H --> RT[roundtrip: Rust frame == C frame, decodes back]
    H --> PC[parallel: caller-scheduled batch, source and streaming equality; C decode]
    H --> SC[stream_compress: random splits, flushes and windows]
    I --> TR[typed_roundtrip: 1-4 typed inputs, standard graph, decodes to the inputs]
    I --> GC[graph_configs: bundle, ML and clustering CBOR, SDDL, SDDL2, then a compressor from it]
    I --> CC[custom_codec: application XOR codec and mutated frames]
    I --> NR[node_roundtrip: one standard node with parameters, Rust frame == C frame]
    I --> FD[framed_decode: bounded arbitrary containers, one-shot == split input]
    I --> FR[framed_roundtrip: resource boundaries, graph overrides, backpressure, C decode]
TargetOracle
framed_decodeBounded arbitrary outer wire input: one-shot and incremental acceptance and typed resources agree. Resource metadata, lengths, checksums, mandatory directory/footer and aggregate limits are exercised.
framed_roundtripUp to 64 serial resources from 64 KiB input, default or Store graph per resource, randomized output windows; native bytes equal one-shot, split-input decoding restores the resources and C decodes every embedded frame.
custom_codecFour-byte little-endian custom ID, version byte modulo 7, payload up to 256 KiB. XOR frames match C byte-for-byte and round-trip through both decoders; arbitrary payload and mutated frames reach a registered Rust decoder under an output limit.
decompressArbitrary bytes never panic; one-shot, session and slice decoding agree; content decoded by either mopenzl or ZL_decompress is decoded identically by the other unless mopenzl reports Unsupported, a limit or a size mismatch.
decompress_typedArbitrary bytes never panic decompress_typed or frame_info, under input (1 MiB) and output (16 MiB) limits. Where both succeed the header’s output count, types and element counts match the decoded outputs; the typed outputs equal openzl_ffi::graph::decompress under the same out-of-scope exemptions as decompress.
typed_roundtripOne to four typed inputs (serial, struct, numeric 1/2/4/8 bytes, strings with empty ones) go through one of 24 standard graphs at a chosen level and format version. Compression may fail; a frame it writes decodes with decompress_typed, and with C, to exactly the inputs (type, content, width, string lengths).
graph_configsSelector byte % 5: DictBundle::try_from (then BundleStore::insert and a Zstandard node with the first dictionary), MlSelectorConfig::try_from, ClusteringConfig::try_from, GraphBuilder::sddl, GraphBuilder::sddl2. No parser panics; whatever parses is built into a compressor that compresses three fixed input sets, and each frame it writes decodes to them.
node_roundtripAny of the 75 standard node IDs, 0 to 3 integer parameters (the node’s own IDs or raw ones) and an optional copy parameter, over a typed input (usually the node’s type, sometimes a mismatched one), at a chosen level and format version 21 to 27. Both libraries run the node in a function graph that stores all outputs: both succeed or both fail, frames are equal bytes, and the frame decodes (mopenzl and C) to the inputs.
roundtripByte identity with ZL_CCtx_compress for the header’s level, version, checksums and chunking; round trip through one-shot and pieces.
parallelTwo-byte encoder header, control byte and up to 32 KiB payload repeated 1–8 times. Varies fallback, owned/slice input, 1–4 tasks, task order and explicit standard/parameterized graphs (byte 1 bits 2–4); batch bytes equal streaming bytes and both C/Rust decoders restore input.
stream_compressSessions finish, never over-report progress and write frames that decode to the payload; without a chunk-cutting flush the frame equals the one-shot frame.

What the targets reach

flowchart LR
    T1[decompress] --> D["frame, chunk layout, every standard codec decoder (serial output), budget"]
    T2[decompress_typed] --> D2["typed and multi-output frames, frame_info, every decoder"]
    T3[node_roundtrip] --> E1["every standard node encoder, local parameters, function-graph runner"]
    T4[typed_roundtrip] --> E2["standard graphs over typed inputs, compress_inputs"]
    T5[graph_configs] --> E3["fat bundle and dictionary parsers, CBOR decoders, SDDL and SDDL2 loaders, ML selector, clustering"]
    T6[roundtrip] --> E4["default graph: specialized writer and chunking"]
    T8[parallel] --> E6[parallel graph builder, planner, tasks, sources, staging, stream assembly]
    T7[stream_compress] --> E5[sessions and flush schedules]
    S1["seeds/codec_frames: a C frame per node and graph"] -.-> T1
    S1 -.-> T2
    S2["seeds/node_params: a default and a parameterized input per node"] -.-> T3

The decoders are reached through frames that name their codec, which mutation rarely invents, so examples/codec_seeds.rs asks the C reference (openzl_ffi::structure::compress with Run::RawNode, and openzl_ffi::graph::compress with Graph::Id) for one small frame per standard node (format versions 27 and 24 plus other widths) and per standard graph over serial, numeric 1/2/4/8, struct, string and digit inputs. Frames the reference refuses for the generated input (a node that needs a specific configuration) are skipped. prepare-seeds.sh runs the generator; with REGRESSIONS_DIR=fuzz/afl/regressions it also commits the frames under 4 KiB to regressions/decompress_typed/codec-*.bin and the node inputs to regressions/node_roundtrip/seed-n*.bin.

Reach is measured by replaying a corpus through the target bodies under cargo llvm-cov: FUZZ_REPLAY=<target>=<dir>,... cargo llvm-cov test --no-default-features --test regressions replays_corpora_named_by_the_environment in fuzz/afl.

Running without AFL

AFL++ needs SysV shared memory; on macOS shmget() fails until cargo afl system-config raises the limits (it needs sudo). The libFuzzer adapters run the same bodies with AddressSanitizer (cargo +nightly fuzz run --fuzz-dir fuzz/libfuzzer ..., see fuzz/libfuzzer/README.md) and need no system change. fuzz/afl and fuzz/libfuzzer form their own workspaces with their own lock files, so a [patch.crates-io] added to the root manifest during development does not reach them; pass it as --config 'patch.crates-io.<crate>.path=...'.

Measured reach

Corpora replayed under rust-cov (-Cinstrument-coverage, release): the grown libFuzzer corpora of decompress (2174 inputs), decompress_typed (1188), node_roundtrip (11 468), typed_roundtrip (5171) and graph_configs (1307), plus seeds/codec_frames, seeds/frames, seeds/params, seeds/stream and the committed regressions. Of the 594 functions under compressor/core/codecs and decompressor/core/codecs 551 run (92.8 %); the whole src/ tree has 1629 of 2237 functions covered (these corpora alone, no unit tests).

flowchart LR
    C[corpora] --> DEC["decompressor/core/codecs: every codec decode entry runs"]
    C --> ENC["compressor/core/codecs: every node encode entry runs"]
    DEC --> U1["not run: monomorphizations (bitunpack pack 2/4/8, partition fill 1/8, range_pack widen, tokenize each_index widths), State::release, closures"]
    ENC --> U2["not run: Legacy and Disabled encoders, merge_sorted count_runs and within_run_limit, sparse_num dominant_fits, structure util width_for, lz4 fast hash5, lz_common hash load 3, closures"]

Per codec family, functions run / functions (decoder files, then encoder files):

FamilyDecoderEncoder
zstd1/12/2
entropy (fse, huffman, ncount), pivco huffman28/28, 11/13 + 11/1118/18, 27/27
lz, lz4, field_lz5/6, 5/5, 6/7lz 5/6 + 16/16, lz4 3/3 + 9/9 + 18/18 + 3/4 + 6/6, field_lz 13/13 + 5/5 + 14/14
bitpack, bitunpack, flatpack, constant12/12, 2/5, 3/3, 2/22/2, 1/1, 3/3, 3/3
delta, zigzag, divide_by, range_pack, bitsplit, float_deconstruct9/9, 9/9, 10/10, 15/16, 3/3, 1/12/2, 2/2, 4/5, 3/3, 11/11, 7/7
sparse_num, sentinel, partition, merge_sorted, quantize, mux_lengths5/5, 2/3, 10/13, 3/4, 8/8, 9/98/10, 4/4, 18/18, 6/8, 6/6, 8/8
conversion, split, split_by_struct, dispatch, concat, dedup, interleave6/7, 2/2, 2/2, 7/7, 5/5, 2/2, 1/119/20, 12/12, 2/3, 7/10, 2/2, 3/3, 3/3
prefix, tokenize, parse_int, transpose4/4, 11/20, 4/4, 9/92/2, 7/7, 1/2, 2/2

Every uncovered function is a closure, a monomorphization of a covered generic at another width, a helper behind a limit the corpora do not hit (the merge_sorted run limit, sparse_num dominant value that does not fit, dispatchN_byTag closures, dispatch_string tag errors), the Legacy and Disabled encoders (unreachable by design at format versions 21 to 27 and with all features) or const fn conversion, which runs at compile time. No codec is unreached. Nodes the C reference refused to build a seed for (48 partition, 57 set_string_lens, 60 huffman_struct_v2 at some widths, 61 fse_ncount, 75 bit_split and graph 19 try_parse_int, which need a specific configuration) were reached by node_roundtrip mutation instead.

Findings

The first libFuzzer runs (300 s each, ASan) found no crash in roundtrip and stream_compress. decompress found decoder divergences from the reference: frames without chunks and unused output-type bits were accepted (fixed, with regression tests in tests/public_api.rs), and 19 frames whose mutated Zstandard payload zstd 1.5.7 decodes but mzstd rejects with HuffmanStream. The last class is fixed in mzstd 0.2.12 (its decoder now takes the reference’s verdict on Huffman stream ends).

The campaigns of the typed and configuration targets (libFuzzer, ASan, 300 s each, 600 s for decompress and node_roundtrip) found:

  • decompress_typed, typed_roundtrip, graph_configs: no crash (3.3 M, 0.7 M and 3.7 M executions).
  • decompress: a 43-byte frame that ZL_decompress decodes to 65536 bytes and mopenzl rejected with InvalidData { kind: Graph } (regressions/decompress/finding-reference-decodes-graph-rejected.bin). The reference keeps a codec’s variable-input and regenerated-stream counts in 32 bits ((uint32_t)varint + 1 / + 2), so a huge count wraps to a valid one; the chunk parser now does the same. Fixed, with a regression test in tests/public_api.rs.
  • node_roundtrip: partition (node 48) with a custom partition (copy parameter 121) holding a size above 2^63: size.next_power_of_two() in Partition::bits (src/compressor/core/codecs/numeric/partition.rs:137) panics with attempt to add with overflow in a debug build (cargo afl test, libFuzzer) and wraps in release. Fixed: the widths follow ZL_nextPow2 (64 above 2^63); the input is regressions/node_roundtrip/finding-partition-bits-add-overflow.bin.
  • node_roundtrip: dispatch_string (node 32) with a copy parameter of fewer than 2 * strings bytes. The C encoder reads one u16 per string without checking the parameter’s size, so it succeeded on a read past the buffer where mopenzl returns an error. A harness artifact: the target pads the indices. The input stays as regressions/node_roundtrip/finding-node32-dispatch-string-209-outputs.bin. Trusted deduplication (dedup_num_trusted, dedup_num with TRUST_IDENTICAL) is lossy for unequal inputs by design, so the target feeds it equal inputs.

Known gaps

  • The graph engine is reached through standard graphs and a single function graph (node_roundtrip); user-written graph shapes, selectors and segmenters are not fuzzed beyond the fixed shapes of graph_configs.
  • typed_roundtrip does not compare frames with C; byte identity is covered by node_roundtrip per node and by the tests per graph.
  • Dictionary frames reach the decoders only through graph_configs round trips, not through decompress.
  • AFL itself was not run on the development host (shared memory limits); libFuzzer ran the same bodies.

Custom codec smoke run

The custom_codec target’s committed seeds cover an empty payload, custom ID 22 (the standard Zstandard ID in the other namespace), and u32::MAX. The target builds with cargo afl build --release --bin custom_codec; replay runs through cargo afl test --no-default-features. CI runs it as an additional ci-fuzz.yml matrix entry and ClusterFuzzLite discovers its adapter automatically. It uses its committed regression seeds, so it needs no generated seed directory.

On macOS with a 4 MiB System V shared-memory limit, the ordinary AFL launch cannot allocate its default map or CmpLog segment. A smoke run can use the supported reduced-map and CmpLog-off options without changing system settings:

AFL_MAP_SIZE=1048576 AFL_HANG_TMOUT=20000 AFL_SKIP_CPUFREQ=1 AFL_NO_UI=1 \
AFL_I_DONT_CARE_ABOUT_MISSING_CRASHES=1 cargo afl fuzz -c - -V 30 -t 20000 \
    -i regressions/custom_codec -o findings/custom-codec-smoke \
    target/release/custom_codec

An initial 30-second run on aarch64-apple-darwin (cargo-afl 0.18.2, AFL++ 4.40c, Rust 1.99.1 release) completed 169313 executions with no crashes and one saved timeout while other checks were running. That input replayed five times successfully in 2.7–10.6 ms. Findings remain local and are not committed; this did not reproduce a library hang.

A second 30-second run with AFL_HANG_TMOUT=20000 completed 729863 executions with zero crashes and zero saved timeouts (99.93% reported stability).

Parallel seeds are committed in regressions/parallel; the campaign includes parallel and ClusterFuzzLite uses its adapter and parameter seed inputs. On macOS with a small SysV shared-memory limit, AFL_MAP_SIZE=262144 lets the 60-second smoke campaign run without changing system configuration.

Implementation comparison

benches/compress.rs and benches/decompress.rs (Criterion) time mopenzl against OpenZL v0.3.0 through openzl-ffi on the corpora of benches/support: a 64-byte prefix (per-call cost), 1 MiB of the vendored OpenZL C sources (text), 1 MiB of structured 16-byte records and 256 KiB of random bytes.

The compression and decompression benches import the hotpath::criterion adapter. Ordinary Criterion runs retain the same timing and validation; HOTPATH_CRITERION_ITERATIONS selects fixed-count profiling for one exact benchmark ID. CI merges native timing and allocation data with Callgrind instruction counts and uploads each series to hotpath-cloud. See CI for the workloads, pinned environment and PR artifact relay. Run throughput comparisons without instrumentation features.

flowchart LR
    I[input] --> V{Rust frame == C frame?}
    V -- no --> X[abort]
    V -- yes --> T[time both]

Every serial compression case asserts byte-identical frames before timing, so ratios are equal by construction; decompression cases assert both decoders regenerate the input. The C compression side creates its context per call (as ZL_compress-style callers do) while the Rust side reuses its compressor, so the 64-byte case mostly measures context setup.

Recorded results

Initial port (the graph engine and node packages came after), recorded 2026-11-09 on an Apple M5 Pro, rustc 1.99.1, release profile, Criterion with 1 s warm-up and 2 s measurement (cargo bench --bench compress / decompress with those flags), throughput of input bytes:

CaseRust compressC compressRust decompressC decompress
tiny-64B L144.6 MiB/s9.9 MiB/s1.01 GiB/s136 MiB/s
c-source-1MiB L1788 MiB/s814 MiB/s2.27 GiB/s2.26 GiB/s
c-source-1MiB L6192 MiB/s203 MiB/s2.45 GiB/s2.48 GiB/s
records-1MiB L1717 MiB/s711 MiB/s1.38 GiB/s1.64 GiB/s
records-1MiB L6279 MiB/s283 MiB/s2.11 GiB/s2.36 GiB/s
random-256KiB L18.19 GiB/s4.63 GiB/s15.8 GiB/s9.93 GiB/s
random-256KiB L65.49 GiB/s3.13 GiB/s15.7 GiB/s9.93 GiB/s

Zstandard dominates compressible inputs, so those rows mirror mzstd against zstd. Incompressible inputs are stored, and there the SIMD XXH3 checksum and the absence of per-call setup decide.

The “Rust decompress” column decodes into a reused slice. A second run of the same harness with the uninit and Vec cases:

CasesliceuninitVec (decompress)C
c-source-1MiB L12.10 GiB/s2.05 GiB/s2.09 GiB/s2.13 GiB/s
c-source-1MiB L62.39 GiB/s2.29 GiB/s2.29 GiB/s2.40 GiB/s
records-1MiB L11.39 GiB/s1.37 GiB/s1.76 GiB/s1.62 GiB/s
records-1MiB L62.16 GiB/s2.16 GiB/s2.48 GiB/s2.34 GiB/s

The Vec path appends through mzstd::decompress_into and beats C on records, so the slice/uninit gap lies in mzstd::decompress_to_slice on this data, not in the OpenZL layer.

Zstandard node package: before and after

Recorded 2026-11-09 on the same Apple M5 Pro with the same harness (cargo bench --bench compress / decompress, Criterion, 1 s warm-up, 2 s measurement, throughput of input bytes), before and after the Zstandard node moved to native magicless frames: the encoder no longer writes a magic and overwrites it, and the decoder no longer stages magic + frame into a buffer before decoding (it reads the content size with FrameHeaderInfo::parse and decodes in place). Frames are byte-identical before and after, so compressed sizes did not move.

CaseBeforeAfterC (after)
decompress c-source-1MiB L1, slice1.995 GiB/s2.269 GiB/s2.312 GiB/s
decompress c-source-1MiB L1, uninit2.171 GiB/s2.270 GiB/s
decompress c-source-1MiB L6, slice2.413 GiB/s2.517 GiB/s2.561 GiB/s
decompress records-1MiB L1, slice1.416 GiB/s1.462 GiB/s1.681 GiB/s
decompress records-1MiB L1, Vec1.854 GiB/s1.850 GiB/s1.681 GiB/s
decompress tiny-64B L1, slice613 MiB/s664 MiB/s136 MiB/s
compress c-source-1MiB L6175.6 MiB/s197.2 MiB/s208.3 MiB/s
compress records-1MiB L6264.5 MiB/s278.9 MiB/s284.6 MiB/s
compress tiny-64B L142.4 MiB/s43.6 MiB/s10.1 MiB/s

The slice and uninit decode paths of the compressible text corpus gained up to about 9 to 14 percent (run to run noise on this machine is roughly 5 percent: the untouched C rows moved by up to 15 percent between the two runs). Compression is within that noise, because the removed work was a one-byte overwrite. The records corpus through the slice destination remains about 15 percent behind C (mzstd::decompress_to_slice), while the Vec destination is ahead of it.

No benchmarks of the other node encoders, the graphs or the selectors have been recorded yet; their correctness is established by byte identity (see compatibility), their speed is unmeasured.

Silesia harness: every codec family

benchmarks/comparison (its own workspace) runs one standard graph per case over whole Silesia files, Rust and C in one process, with openzl-ffi’s C built at -O3 (OPENZL_FFI_OPT_LEVEL=3). The harness run loop is:

sequenceDiagram
    participant H as harness
    participant R as mopenzl (reused Compressor/Decompressor)
    participant C as OpenZL C (context per call)
    loop every (case, file)
        H->>C: compress (graph, typed view, defaults)
        H->>R: compress_inputs
        H->>H: frames identical? else skip and report
        H->>R: decompress(frame) == input?
        H->>C: decompress(frame) == input?
        H->>H: calibrate iterations per op to --min-time-ms
        loop --rounds
            H->>R: compress x n
            H->>C: compress x n
            H->>R: decompress x n
            H->>C: decompress x n
        end
        H->>H: CSV row: median and min ns per call
    end

Cases: zstd, lz4, lz, huffman, fse, entropy, huffman_pivco, flatpack and generic over every file; field_lz_struct4, numeric32 and generic_num32 over sao/osdb/nci as 4-byte records or numbers; bitpack_num16, numeric16 and generic_num16 over mr/x-ray as 16-bit numbers. profile runs one side and operation untimed for callgrind, perf or the hotpath features. report.py renders docs/benchmarks/.

Recorded 2026-11-10 on an AMD Ryzen 9 7950X (KVM guest, 2 vCPUs), rustc 1.99.1, --min-time-ms 300 --rounds 5. “Before” is commit 0839dd3 (v0.2.0), measured with the same harness on the same host the same day. Every one of the 123 pairs was byte-identical on both revisions. Medians of per-file C time / Rust time:

GraphCompressDecompressBefore: compressBefore: decompress
zstd0.989×0.957×0.951×0.963×
lz40.954×0.918×0.784×0.305×
lz0.834×1.465×0.489×0.436×
huffman0.936×0.782×0.397×0.197×
fse0.955×0.881×0.437×0.294×
entropy0.896×0.774×0.401×0.198×
huffman_pivco1.053×1.026×0.092×0.051×
flatpack1.472×1.490×0.734×1.432×
generic0.976×0.960×0.972×0.947×
field_lz_struct40.827×0.763×0.598×0.323×
bitpack_num161.774×2.248×0.078×0.683×
numeric160.943×0.798×0.483×0.269×
numeric320.827×0.706×0.557×0.368×
generic_num160.878×0.826×0.508×0.282×
generic_num320.844×0.710×0.615×0.334×

Per-file tables and charts: docs/benchmarks. Where the time went before and what changed is in each codec’s specification (entropy, LZ, shared primitives, decompressor, compressor). Remaining gaps, measured with callgrind and perf stat:

  • Huffman decode now executes fewer instructions than C’s HUF_decompress4X2 (23.4M against 24.6M on 3 MB of dickens) but at IPC about 3.2 against 5: the dependent lookup chain, the refills and the two-phase cell store/reload. It also pays the output zero-fill and an extra output copy that C does not.
  • FSE is within 2 to 8% of C’s instructions and cycles; two-state decode is bound by the format’s dependent state chain.
  • LZ4 decode matches C’s instructions and branch misses; about 10% more cycles remain at the match-source loads and stores (likely store forwarding). The HC chain walk runs 8 to 16% over C’s instructions.
  • The field_lz decoder runs at C’s instruction count (about 47 per token); the field_lz graphs’ remaining decode gap is mostly the Huffman/FSE stages and the zero-fill. The field_lz parse runs 1.3 to 1.5× C’s instructions (finder state spilled around the two inlined searches).
  • Decoders zero-fill their output Vec before writing it (about 3 to 10% of a stage): skipping that needs set_len, which the crate forbids.
  • shared::stats::histogram::count8 is about a fifth of Huffman compression.
  • Cells move by up to ±8% between runs of unchanged code on this shared host (for example LZ4 compression and lz decoding between the round-3 and round-4 recordings).

Known gaps

  • The Criterion benchmarks cover only the default graph; the Silesia harness covers the standard graphs on serial, struct and numeric views, but not the Transformer at levels 7 and above, dictionaries, SDDL or string inputs.
  • Both harnesses run on one shared x86-64 host per recording; no ARM64 or non-AVX-512 recording exists, so the 128-bit fallback paths of the SIMD kernels are tested but not benchmarked.
  • The C compression side creates a context per call; see the methodology note.

Custom codec callback benchmark

cargo bench --bench custom_codec --locked -- --sample-size 10 --measurement-time 0.1 --warm-up-time 0.1 measures the custom serial XOR codec against its C implementation (openzl-ffi/shim/custom_codec.c). Both sides include fresh context creation and output allocation, use format 27, minimum stream size 1, both checksums, and disable store-on-expansion. Rust graph and decoder registration are prepared outside timing; the C helper registers its transform inside the timed call. Both paths construct their per-operation contexts. Frames are checked for byte identity and decoded by both implementations before timing. This is a new API, so no before-change timing exists. These short smoke measurements are not a performance regression baseline.

Measured on aarch64-apple-darwin, Rust 1.99.1 release, vendored C at its build script default -O2, with other validation jobs running; medians in microseconds:

CorpusInput bytesFrame bytesRust encodeC encodeRust decodeC decode
tiny text64931.115.140.610.73
vendored C source57125657128945.4668.3444.6454.55
structured records1048576104860982.94129.6487.88103.90
random bytes26214426217726.1438.9720.7826.87
zero bytes1048576104860986.52122.6789.30103.39

XOR preserves payload length; the frame overhead is 29 bytes for the tiny case and 33 for these larger cases. The benchmark’s corpus labels call the source case c-source-1MiB, but this checkout supplies only 571256 bytes; the table records the actual size. The C timed encoder constructs and registers its compressor graph while Rust clones a prebuilt graph, so these numbers describe end-to-end API cost rather than an isolated transform kernel.

Parallel compression

The parallel harness validates both Rust and C frames with openzl-ffi::decompress before timing, prints compressed sizes, and checks that task counts give equal Rust bytes. It measures complete batch operations with 1/2/4 requested tasks, a four-worker streaming writer, and C’s serial segmenter. Unlike the serial comparison above, C byte identity is not required.

flowchart LR
    I[identical bytes, level 3, 256 KiB segments] --> R[caller-scheduled Rust]
    I --> C[C serial segmenter]
    R --> V{both C-decode to input?}
    C --> V
    V -- yes --> T[time complete operations; record sizes]
    V -- no --> X[abort]

Measured 2026-11-10 on Apple M5 Pro, macOS Darwin 25.6.0 / aarch64, rustc 1.99.1 LLVM 23.1.1, release defaults, no profiling flags. Commands:

cargo bench --bench parallel --locked
cargo bench --bench parallel --locked -- rust-stream
cargo run --release --example parallel --features hotpath-cpu,hotpath-alloc --locked

Criterion used 10 samples, 200 ms warm-up and one-second measurement. These are local exploratory central estimates, not an isolated-machine regression gate; streaming was measured separately. Rust retains up to four workers, C creates a context per call. Multiple requested tasks include scoped thread creation and joining; one task executes inline. Caller input/corpus loading and validation are outside timing, while planning, staging, output allocation and assembly are inside. A source smaller than 256 KiB still creates only one effective task.

Input (actual bytes)Encoded bytes, Rust / CRust 1 taskRust 2 requestedRust 4 requestedRust streaming 4C serial
C source prefix (64)84 / 842.24 µs15.9 µs16.0 µs68.5 µs6.66 µs
Vendored C sources (571,256)121,154 / 121,154885 µs855 µs420 µs486 µs721 µs
Structured records (1,048,576)130,089 / 130,0892,549 µs1,355 µs696 µs939 µs927 µs
Pseudorandom bytes (262,144)262,168 / 262,16839.8 µs60.2 µs63.5 µs114 µs51.9 µs
Zeros (4,194,304)652 / 652276 µs162 µs106 µs375 µs498 µs

The source corpus is named c-source-1MiB by the shared harness but contains 571,256 bytes at this vendor pin. Compression sizes happened to match C here; this does not strengthen the parallel API’s byte contract. Multiple workers help the larger multi-segment workloads; tiny inputs and a single incompressible segment pay thread startup without added codec parallelism. Default serial fallback is deliberately disabled in this harness to expose that overhead.

The hotpath example compressed a 10,200,000-byte repeated-text input five times, validating 1,178-byte output each time. Instrumented timing attributed 10.47 ms aggregate to fragment encoding, including 9.10 ms in Zstandard; parallel task bookkeeping/staging outside fragment encoding accounted for about 0.04 ms in that run. Cumulative allocation was 230.2 MB including verification decoding; Zstandard encoding accounted for 92.1 MB and decoding for 128.1 MB. Reported peak RSS was 41.5 MB. These are instrumented diagnostic figures; they are not the Criterion timings. Sampled CPU reporting was unavailable because samply was not installed; timing/allocation instrumentation still completed. Retention uses the existing serial workspaces rather than adding a new codec kernel.

Resource tradeoffs: per-worker codec/input scratch and staged compressed bytes increase memory use; directory staging bounds batch payload memory, while size-free streaming retains compressed chunks until finish. Peak/post-burst memory across worker counts, throughput under offered load, NUMA placement and confidence-aware paired regression thresholds remain unmeasured. The evidence supports offering caller-controlled parallelism, without claiming a universal speedup or automatically selecting an executor or task count.

Explicit graph selection

cargo bench --bench parallel --locked -- parallel-graphs adds serial-style with_graph cases for Store, Zstandard, LZ4 and Huffman. Rust requests four tasks; C runs a segment_serial graph with the same selected successor, level 3, 256 KiB segments, min_stream_size = 1, store-on-expansion disabled, format 27 and both checksums. Setup/validation are outside timing; complete task scheduling, thread creation, compression, allocation and assembly are timed, as in the preceding batch measurements. Both frames are C-decoded before timing.

The 2026-11-10 Apple M5 Pro / rustc 1.99.1 run used the same 10-sample, 200 ms warm-up and one-second measurement settings. Other verification work ran on the host concurrently, so these exploratory central estimates are not a paired regression verdict or a universal parallel speedup. Sizes matched C in every listed case; byte identity remains outside the parallel API contract.

GraphCorpusOutput bytes, Rust / CRust 4 requestedC serial
Storetiny-64B84 / 8418.754 µs6.4709 µs
Storec-source-1MiB571,306 / 571,306247.53 µs103.79 µs
Storerecords-1MiB1,048,639 / 1,048,639257.93 µs104.67 µs
Storerandom-256KiB262,168 / 262,16860.233 µs50.098 µs
Storezeros-4MiB4,194,524 / 4,194,524280.41 µs383.30 µs
Zstdtiny-64B96 / 9619.263 µs6.4678 µs
Zstdc-source-1MiB121,154 / 121,154492.30 µs760.09 µs
Zstdrecords-1MiB130,089 / 130,0891.0563 ms946.19 µs
Zstdrandom-256KiB262,186 / 262,18665.919 µs62.730 µs
Zstdzeros-4MiB652 / 652134.73 µs522.78 µs
Lz4tiny-64B94 / 9424.128 µs6.0870 µs
Lz4c-source-1MiB138,274 / 138,2741.0600 ms2.1148 ms
Lz4records-1MiB341,418 / 341,418799.40 µs2.7486 ms
Lz4random-256KiB263,205 / 263,2052.5873 ms2.8799 ms
Lz4zeros-4MiB16,972 / 16,972148.71 µs639.33 µs
Huffmantiny-64B84 / 8454.807 µs7.2728 µs
Huffmanc-source-1MiB353,365 / 353,365388.85 µs665.61 µs
Huffmanrecords-1MiB363,103 / 363,103348.23 µs853.60 µs
Huffmanrandom-256KiB262,196 / 262,196117.43 µs81.186 µs
Huffmanzeros-4MiB1,308 / 1,3082.7543 ms1.2826 ms

The corpus byte lengths are those in the earlier table (the source corpus is 571,256 bytes). Results include scheduling costs even for one effective segment. They show codec-dependent scaling: the single-segment random workload and the Huffman all-zero workload do not benefit from four requested Rust tasks here.

The updated release hotpath example still compressed 10,200,000 bytes to 1,178 bytes and validated five frames. The new frame_header_into boundary accounted for 183 µs across 20 calls (0.56% of reported elapsed time); Zstandard encoding accounted for 21.64 ms aggregate. Cumulative allocation including decoding was 230.2 MB, with 215 bytes attributed directly to the header boundary; the profiler reported 47.6 MB peak RSS. These diagnostic figures come from a separate run and are not a before/after memory comparison. CPU sampling remained unavailable without samply; timing and allocation reports completed. No codec kernel was changed for this API addition. All graph, header and dictionary behavior is validated by deterministic compatibility tests and the graph-extended AFL target.

Continuous integration

The opt-in framed API is included in std Clippy, release tests, docs and the coverage run. Direction-only std and no_std framed combinations are in the feature matrix; all-features also exercises the alloc-only container. Framed AFL/libFuzzer bodies and regression replay run through the excluded fuzz packages, whose root dependency enables framed explicitly. The Linux fuzz job also runs each framed AFL target for 15 seconds with a secondary worker, preserves artifacts and rejects saved crashes or hangs.

graph TD
    PR[push / pull request] --> CI[ci.yml]
    PR --> CF[ci-cflite-pr.yml: ClusterFuzzLite on changed code]
    PR --> HB[hotpath-benchmark.yml: compressor profile]
    PR --> HC[hotpath-criterion.yml: compress and decompress profiles]
    HB --> HR[hotpath-relay.yml: upload PR report artifacts]
    HC --> HR
    Tag[tag v*] --> M[ci-miri.yml]
    Tag --> A[ci-sanitizer.yml]
    Tag --> F[ci-fuzz.yml: AFL smoke runs]
    Cron[daily] --> CC[ci-cflite-cron.yml]
    Manual[workflow_dispatch] --> COV[ci-coverage.yml]
    Release[release tag] --> R[release.yml: crates.io trusted publishing of mopenzl]

Code-running workflows check out with submodules: true (non-recursive: OpenZL, zstd and lz4 for openzl-ffi). They use read-only repository permissions; the hotpath push jobs additionally request an OIDC token for uploads. The hotpath relay only handles report artifacts and checks out no code.

ci.yml: formatting; clippy with --all-features, default features, the hotpath features and a feature matrix including builds without codec features; documentation builds with -D warnings per feature set; cargo package; scripts/check_codec_features.py; a thumbv7em-none-eabi no_std check; release-mode tests on Linux x86-64, Linux arm64 and macOS with default features, --all-features (alloc-only) and without codec features; MSRV 1.89; formatting, clippy and cargo afl test of the fuzz packages. Parallel streaming doc tests validate decoded bytes when decompression is enabled and assert size reduction only with zstd; codec-free builds store chunks. The parallel standard-graph integration test selects codec graphs with compile-time feature gates, retaining the Store graph in every build.

ci-miri.yml and ci-sanitizer.yml run scripts/check_simd_uninit.py: the SIMD kernels, uninitialized destinations and the simd_backends and uninit tests, under Miri (x86-64 with -Ctarget-cpu=x86-64-v3, arm64 at its Neon baseline; tests that call the C library are skipped or filtered to rust_only_*) and AddressSanitizer. ci-sanitizer.yml also runs every unit and integration test under AddressSanitizer and LeakSanitizer on x86-64. openzl-ffi exports __asan_default_options with allocator_may_return_null=1: the C decoder reserves whatever size a damaged frame declares, and must see a null allocation (and report an error) as it does with plain malloc instead of ASan aborting the test. ASAN_OPTIONS overrides it. ci-coverage.yml runs cargo llvm-cov and gates function coverage.

Miri selects 17 library tests by exact name (MIRI_LIB), plus the backend integration test and four rust_only_* uninitialized-output tests (22 tests on x86-64). They cover the LZ row-table tag compare, PivCo-Huffman partition/translation/packing and merge/flat kernels, bit counting, byte transpose, XXH3, and uninitialized sinks and destinations. Each differential kernel test still checks the scalar baseline and every available SIMD level. ARM64 excludes three PivCo tests (MIRI_NEON_SKIPS) because Miri cannot interpret llvm.aarch64.neon.ushl.v8i16; the 19 remaining tests run there, while x86-64 Miri and ARM64 ASan retain those three tests. Exact library names fail validation if renamed, and new tests in a codec module do not increase Miri’s workload automatically. Exhaustive scalar parser, tree, entropy and copy tests remain in the native and ASan suites; ASan retains the broader ASAN_LIB module prefixes. The runner prints the selected test/process count before execution and reports the slowest tests after completion.

flowchart TD
    List["libtest --list, excluding ignored tests"] --> Tool{Checker}
    Tool -->|Miri| Exact["MIRI_LIB: exact kernel and uninit tests"]
    Tool -->|ASan| Prefix["ASAN_LIB: full codec module prefixes"]
    Exact --> Neon["ARM64: exclude three unsupported Neon-shift tests"]
    Neon --> Validate[Fail on missing selection or stale skip]
    Prefix --> Validate
    Integration["simd_backends + uninit integration tests"] --> Validate
    Validate --> Shard["optional K/N shard, then batches"]
    Shard --> Run["parallel cargo processes with --exact"]
    Run --> Result["require every selected test to report ok; timing summary"]

Coverage of the encoder and graph code

The integration tests that compare against the C library (tests/encode_*.rs, standard_graphs.rs, graph_engine.rs, codecs_*.rs; see compatibility) run in the release-mode test jobs on Linux x86-64, Linux arm64 and macOS, with default features, --all-features and without codec features, so the byte-identity of every node and graph is checked on both architectures. tests/docs_examples.rs runs every example of README.md and the guides listed in that script; it is generated by scripts/gen_docs_examples.py and its docs_examples_are_current test fails when the markdown and the file disagree. scripts/check_codec_features.py runs once on Ubuntu and derives the codec list from the all-codecs feature. Its consumer matrix toggles framed independently of directions, codecs and std mode, checking both available and unavailable framing imports, including native sessions and std transport adapters.

The framed AFL smoke step first runs cargo afl system-config on the disposable Ubuntu host, as the tag-triggered fuzz workflow does. This replaces a piped core dump handler with direct core dumps so AFL can observe crashes through waitpid. The smoke step creates the parent findings directory before starting AFL; AFL creates each target’s output directory, but not missing parent directories. Each framed target runs for 15 seconds and must report zero saved crashes and hangs. Findings are archived as framed-afl-smoke.tar.gz before artifact upload because AFL queue filenames contain colons.

flowchart LR
    CI[ci.yml] --> L[fmt, clippy matrix, docs, package]
    CI --> F[check_codec_features.py]
    F --> FR["framed on/off: shared, per-direction and std-only imports"]
    CI --> NS[no_std: thumbv7em check + host tests]
    CI --> T["release tests: x86-64, arm64, macOS; default, all-features, no codec features"]
    T --> P["parallel examples: round trip; size reduction with zstd"]
    L --> G["parallel graph tests: compile-time codec gates"]
    CI --> MSRV[MSRV 1.89]
    CI --> FZ[fuzz packages: fmt, clippy, cargo afl test]
    FZ --> AFC["AFL host configuration"]
    AFC --> AFD["create findings parent directory"]
    AFD --> AFS["framed smoke: zero crashes/hangs; tar findings artifact"]
    MI[ci-miri.yml] --> SU[check_simd_uninit.py miri]
    SA[ci-sanitizer.yml] --> SU2[check_simd_uninit.py asan]
    COV[ci-coverage.yml] --> LC[llvm-cov function coverage]

Hotpath reports

hotpath-benchmark.yml and hotpath-criterion.yml run on pushes to main and pull requests, matching the setup in the sibling mbrotli repository. Both pin Ubuntu 26.04, Rust 1.99.1 and Valgrind 3.26.0 and build with hotpath-alloc,hotpath-callgrind,hotpath-cloud in release mode with the lockfile. Runtime SIMD detection still chooses the backend on the runner; none of these jobs changes dispatch or codec output.

The standalone examples/profile_compressor.rs validates a round trip and then performs ten reusable-output compressions at each of levels 1, 3, 6 and 12 over a fixed repeated text. Its entry point owns the hotpath guard and tracking allocator. Its unit test runs the same workload without profiling.

The Criterion jobs use hotpath::criterion in the existing compress and decompress benches. The adapter delegates to ordinary Criterion unless HOTPATH_CRITERION_ITERATIONS is set. CI selects exactly one benchmark per process: compress/rust/c-source-1MiB/L6 (20 iterations) or decompress/rust/c-source-1MiB/L6 (100 iterations). Existing Rust/C frame and round-trip validation still runs outside the measured closures. The adapter starts its guard around the selected closure, excluding corpus loading, validation and unrelated cases from its report.

sequenceDiagram
    participant W as push / PR benchmark job
    participant B as release binary
    participant A as Actions artifacts
    participant R as workflow_run relay
    participant C as hotpath-cloud
    W->>B: native run, JSON timing + allocations
    W->>B: Callgrind run, same iterations + native JSON
    B->>B: merge instruction counts with native measurements
    B->>B: embed hotpath/policy.toml in report metadata
    W->>W: validate merged mode, timing, counts, policy and Criterion ID
    alt push to main
        B->>C: upload with job OIDC token
        W->>W: require upload response
    else pull request
        W->>A: store merged JSON for one day
        A->>R: completed successful benchmark workflow
        R->>C: upload artifact with relay OIDC token
    end

Push jobs use HOTPATH_UPLOAD=fail-on-error and fail on upload errors. Pull request jobs have no OIDC permission and only publish report artifacts. hotpath-relay.yml handles successful PR runs using the pinned hotpath-rs/hotpath-relay v1.0.0 action, with actions: read, pull-requests: read and id-token: write. It never executes PR code. Workflow names, Criterion series, artifact names and report filenames must stay in sync with the relay; concurrency cancels an older relay for the same repository, branch and series. The repository must be connected to hotpath-cloud for authenticated uploads to succeed.

hotpath/policy.toml is shared by all three benchmark series and uses the same thresholds as mbrotli: judge instruction-count changes from 0.5% and allocation changes from 1%, for both avg and total, with at least one call. Functions below 0.05% of the run are skipped. Native timing is shown but not judged. These thresholds report regressions without enabling fail_check, matching mbrotli. The profiler embeds the checkout’s policy in meta.policy; the merged-report checks require its path and nonempty source so a missing policy cannot silently pass the report checks.

Known gaps

  • Miri uses focused kernel and uninitialized-output tests, rather than all codec tests. A new SIMD kernel or MaybeUninit path needs an explicit entry in MIRI_LIB and a matching ASAN_LIB entry in scripts/check_simd_uninit.py; broad ASan prefixes alone do not add Miri coverage. Scalar codec and graph behavior is checked natively and by ASan.
  • No CI step reruns scripts/gen_transformer_scorers.py and diffs its output, so a drift between the vendored scorers and the generated Rust is not detected.
  • The clippy and docs matrices toggle zstd and “no codecs” only, not every codec feature subset; the features job probes the public API of each.
  • Hotpath CI profiles the default graph only; the full Silesia comparison remains a manual run (comparison).

The SIMD/uninitialized-output runner also selects parallel_stream::rust_only_uninitialized_output_and_flush_preserve_one_openzl_frame under Miri and AddressSanitizer. This test starts destinations uninitialized, checks deferred flush output, and decodes entirely in Rust. Parallel native integration tests run in the default/std jobs; all-features enables no_std and omits the parallel module.

parallel_graphs::rust_only_graph_sessions_write_uninitialized_output_and_close_their_queue adds graph-selected owned-session output to both memory-safety runners; ASan also runs the entire parallel_graphs integration binary, including C interop.

Documentation book

.github/workflows/pages.yml builds the complete mdBook on documentation, architecture, manifest or API changes and manual dispatch. Python tests cover navigation/link rules, the temporary Cargo consumer runs the user-guide recipes, and the HTML build validates local links/anchors. Only the main-branch deployment job has Pages/OIDC write permissions; pull requests build without publishing. The staged sources, bundled Mermaid assets and static output stay in target. See documentation publishing for the full flow and diagrams.

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]
    CSV[Canonical Silesia CSV] --> Derive[benchmark_pages: per-graph encoder/decoder chapters]
    Prepare --> Derive
    Derive --> Source
    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 mopenzl.

scripts/docs.py has five repository-local entry points:

CommandBehavior
prepareRecreate the staged source tree, navigation, and bundled diagram assets
buildPrepare, build HTML, then check local links and heading anchors
servePrepare, then serve the generated tree on loopback port 3000
check-linksValidate a previously built HTML tree
test-examplesCompile and run Rust recipes in a separate Cargo consumer
flowchart TD
    Copy[Copy docs and architecture] --> Links[Inspect destinations outside fenced code]
    Links --> Local{Target in published directories?}
    Local -->|yes| Relative[Keep relative link]
    Local -->|no| GitHub[Link to source on GitHub main]
    Relative --> Summary[Generate SUMMARY with every Markdown page and the section pages]
    GitHub --> Summary
    Summary --> Complete{All source pages indexed?}
    Complete -->|no| Fail[Fail preparation]
    Complete -->|yes| Render[mdBook preprocessors and HTML renderer]
    Render --> Validate[Validate href and src paths, HTML IDs and README site URLs]
    Validate --> Artifact[Publishable static directory]

The generated SUMMARY.md has six parts: Start here (getting started, the API chooser in docs/README.md, examples), Graphs and codecs (graphs, codecs, structured data, dictionaries), 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 and the graph recipes under framing containers. book.toml folds every nested level, so the sidebar opens with top-level chapters only and mdBook expands the branch of the current page. Encoder/decoder benchmark indexes refer to the actual OpenZL graph sweep; raw CSV and per-graph charts are published unchanged. The most recent committed Silesia after.csv also generates individual encoder/decoder graph chapters at build time. Their paired Rust/C sizes are validated, and each table links to its source measurement record. Canonical Markdown overrides a derived page with the same path; derived pages do not duplicate maintained source text. 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. 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/mopenzl/ URLs, with its fragment, against the built HTML.

The preprocessor’s install command writes bundled Mermaid assets into a small generated book under target/book-assets/; the real book references those files. The site needs no runtime CDN, Node package manager, or application server.

graph LR
    Book[SUMMARY.md] --> Overview[Overview: docs/introduction.md]
    Book --> Start[Start here]
    Book --> Concepts[Graphs and codecs]
    Book --> Guides[Guides]
    Book --> Evidence[Performance and correctness]
    Book --> Internals[How it works]
    Book --> Contributing[Contributing]
    Start --> StartPages[getting started, API chooser, examples]
    Concepts --> ConceptPages[graphs, codecs, structured data, dictionaries]
    Guides --> GuidePages[configuration, memory, decompression, streaming, parallel, frames]
    GuidePages --> Nested[folded: I/O and Sans I/O under streaming, graph recipes under frames]
    Evidence --> Bench[correctness, benchmark index]
    Bench --> Graph[folded: encoder and decoder matrices, derived per-graph pages, run records]
    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 all user-guide recipe chapters
    Consumer->>Consumer: Release build with local mopenzl + 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

Every fenced rust block under docs/, including the landing page and the configuration, memory, decompression and streaming guides, is a complete Rust program. 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/mopenzl/.

Known gaps

  • The repository is configured for the GitHub Actions Pages build type. A fork must enable it independently; first publication still requires this workflow on main and a successful build/deploy run.
  • serve watches generated files. Restart the command after editing canonical sources so preparation runs again.
  • The checker validates local destinations and anchors, not external network URLs or Mermaid syntax. Browser QA is needed for rendered diagrams.
  • The diagram dialog and theme have no automated test; they were checked in a desktop browser.
  • Recipe validation covers all Rust programs in docs/, not historical code fragments in implementation specifications. In-workspace generated tests cover the README and the guides listed in scripts/gen_docs_examples.py; the book consumer additionally tests Rayon/framed/std recipes without adding Rayon to production dependencies.

Development

Requires Rust 1.89 or later. openzl-ffi builds the pinned OpenZL v0.3.0, zstd 1.5.7 and lz4 1.10.0 sources as the C test/benchmark oracle. A C compiler is required for development builds. Production uses safe Rust codecs.

git clone --recurse-submodules https://github.com/M-Compressors/mopenzl.git
# In an existing checkout, only the three pinned submodules are needed:
git submodule update --init
cargo run --example compress
cargo doc --workspace --all-features --no-deps --locked

Workspace checks

The documentation website uses mdBook; see local builds and publication. Run these at the workspace root:

cargo fmt --all
cargo fmt --all -- --check
cargo clippy --workspace --all-targets --all-features --locked -- -D warnings
cargo test --workspace --all-features --locked

The differential suite is faster in release mode. All-features enables no_std, which removes std transports and parallel APIs, so check std/framed separately:

cargo test --workspace --release --locked
cargo test --workspace --features framed --release --locked
cargo clippy --workspace --all-targets --features framed --locked -- -D warnings
python3 scripts/check_codec_features.py

Coverage

Every changed Rust function must be exercised. The repository targets 100% function coverage for owned library code; inspect a report rather than assuming passing tests reached each function.

rustup component add llvm-tools-preview
cargo install cargo-llvm-cov --version 0.9.0 --locked
cargo llvm-cov clean --workspace
CARGO_INCREMENTAL=1 cargo llvm-cov --workspace --release --features framed --locked --no-report
cargo llvm-cov report --workspace --release --ignore-filename-regex '/openzl-ffi/|/vendor/' --fail-under-functions 100
cargo llvm-cov report --workspace --release --html

Incremental compilation preserves counters for small public functions copied across crates in optimized builds. Reports remain local artifacts under target. The CI coverage workflow records its exact invocation and library exclusions.

Fuzzing

AFL and libFuzzer are isolated workspaces. After changing target bodies or APIs they call, run from fuzz/afl:

cargo fmt --all -- --check
cargo clippy --all-targets --no-default-features -- -D warnings
cargo afl test --no-default-features

cargo afl test links AFL’s runtime and replays the committed corpus. The AFL guide covers installation, seeds, dictionaries, campaigns and minimization. Avoid committing generated queues or findings.

Run the libFuzzer package’s checks from fuzz/libfuzzer:

cargo fmt --all -- --check
cargo clippy --all-targets --locked -- -D warnings

The libFuzzer guide describes local sanitizer builds and ClusterFuzzLite. Both engines call the same target bodies.

Formal verification

There is no Lean/Aeneas proof tree in mopenzl. Correctness evidence comes from unit/integration tests, C differential checks, coverage, fuzzing and memory sanitizers. See compatibility and validation for the scope and limits of that evidence.

Repository map

PathContents
src/compressor, src/decompressorPublic codecs; private core implementations
src/graph.rs, src/input.rsGraph construction and typed borrowed inputs
src/framingOpt-in independent-resource container and seek index
src/custom_codec, src/dictApplication codecs and external dictionary bundles
src/sharedWire parsing, checksums, shared scalar/SIMD kernels
tests, examples, benchesPublic behavior, runnable APIs and Criterion comparisons
benchmarks/comparisonIsolated graph/typed-view Silesia C comparison
openzl-ffiC oracle, shims and the three pinned upstream submodules
fuzz/afl, fuzz/libfuzzerIsolated fuzz packages and regression replay
.clusterfuzzliteClusterFuzzLite image and build script
docs, architectureCanonical guides, data and implementation specifications
book.toml, book-thememdBook renderer, local diagram assets and styling
scripts/docs.py, scripts/test_docs.pyStaging, navigation, link checks and runnable book recipes

Repository rules require public/private core boundaries, focused error types, per-codec feature gating and updated architecture diagrams. Vendored sources are maintained separately. The CI specification describes feature matrices, packaging, fuzzing and profiling workflows.

SIMD/uninitialized-output checks use python3 scripts/check_simd_uninit.py miri and python3 scripts/check_simd_uninit.py asan. x86-64 Miri uses RUSTFLAGS=-Ctarget-cpu=x86-64-v3; unsupported Neon paths are covered by ASan. See Miri and AddressSanitizer for commands and limits.

The book workflow builds every canonical page, validates local files and anchors, and runs all complete user-guide Rust programs in a temporary consumer with framed and Rayon enabled. Core guide examples also remain in the workspace’s existing generated documentation tests; the external consumer supplies dependencies used only by book recipes. See documentation architecture.

Benchmarks and profiling

Compare throughput together with compressed size under a matched graph, typed view, level, format version, checksum policy and API mode. Setup and validation are separate from timed work unless the case explicitly measures an end-to-end API. Every production optimization needs a measured hot path and a correct baseline; see performance methodology.

WorkloadHarnessResults
Serial bytes and sessionsCriterion compress/decompressPer-level IDs and printed frame sizes
Standard graphs and typed inputsIsolated Silesia comparisonEncoder matrix, decoder matrix
Parallel batches and streamingCriterion parallelRecorded parallel results
Independent framed resourcesCriterion framed (framed)Recorded container results

Encoder comparison

Build the isolated harness with the C oracle at -O3:

cd benchmarks/comparison
OPENZL_FFI_OPT_LEVEL=3 cargo build --release --locked
B=target/release/mopenzl-comparison
$B list
$B verify --corpus /path/to/silesia
$B run --corpus /path/to/silesia --min-time-ms 300 --rounds 5 --out after.csv

The harness lists each graph, input view and compatible files. It verifies byte-identical Rust/C frames and round trips before timing alternating sides and operations. Contexts and output buffers are prepared/reused; corpus loading and validation are untimed. --cases, --files and --limit BYTES narrow a run.

Published comparisons use level 6, format 27, both checksums and full compatible Silesia files. This varies the graph rather than sweeping quality numbers. The source configuration owns the level and global parameters; change both matched sides together for another study. See encoder interpretation.

Decoder comparison

The same run times decoding on both sides of the exact same frames. The frame corpus therefore records graph choice, level and typed layout even though the decoder needs no compressor graph. Verify output types, widths and string lengths as well as content before timing. See the decoder report.

Profile one untimed decoder workload rather than mixing instrumentation with throughput measurements:

cd benchmarks/comparison
B=target/release/mopenzl-comparison
$B profile --corpus /path/to/silesia --case huffman --file dickens \
  --side rust --op decompress --iters 100 --limit 1000000

Use --side c for the matched C profile and --op compress for encoding. perf, Callgrind, or the workspace’s hotpath features can identify CPU and allocation costs. Keep profiler runs separate from the timed baseline.

Silesia comparison

The full graph report includes raw CSV, charts, file-by-file throughput and compression ratios. The recorded environment identifies the published host and what metadata the historical snapshot contains.

Build an older revision in a separate worktree and run the same corpus and harness on the same host for a before/after comparison. Render new results with report.py, which needs matplotlib==3.10.8:

cd benchmarks/comparison
python3 report.py --csv after.csv \
  --output ../../docs/benchmarks --subtitle "machine · toolchain · date"

Reports distinguish graph/input view rather than ranking different typed corpora as one workload. Record bytes and ratio alongside speed. A changed output size is a different encoding result, not an equivalent speed comparison.

Sharded runs

For exploration, split cases or files into independent invocations of the same built executable. Use separate output paths; keep validation enabled:

cd benchmarks/comparison
B=target/release/mopenzl-comparison
$B run --corpus /path/to/silesia --cases zstd,lz4 --out general.csv
$B run --corpus /path/to/silesia --cases huffman,fse --out entropy.csv

Concurrent lanes compete for caches, memory bandwidth and turbo budget. Record all lanes, affinity and host load, and use a serial sweep when publishing matched implementation comparisons. Do not merge measurements from different operating conditions into one named baseline. --files and --limit also change the workload, so report them with the corpus identity.

API benchmarks

Criterion covers meaningful end-to-end and reused API modes:

cargo bench --bench compress --locked
cargo bench --bench decompress --locked
cargo bench --bench custom_codec --locked
cargo bench --bench parallel --locked
cargo bench --bench framed --features framed --locked

compress/decompress use identical serial bytes and parameters against C and include sessions/uninitialized destinations. custom_codec compares application callbacks. parallel covers task counts, graph selection, staging and a streaming writer against the C serial segmenter; compressed sizes can differ. framed compares whole containers and native sessions with independent C frames; C has no implementation of the outer mopenzl container.

For a quick exploratory run:

cargo bench --bench compress -- --sample-size 10 --warm-up-time 1 --measurement-time 2
cargo bench --bench parallel --locked -- parallel-graphs
cargo bench --bench framed --features framed --locked -- rust-session

Short runs provide smoke evidence. Keep Criterion’s configuration and confidence intervals with any performance claim; there is no universal threshold across graphs, APIs or platforms.

Checks and profiling

The standalone comparison package has its own build/check boundary:

cargo fmt --manifest-path benchmarks/comparison/Cargo.toml --all -- --check
cargo clippy --manifest-path benchmarks/comparison/Cargo.toml --all-targets --locked -- -D warnings
cargo test --manifest-path benchmarks/comparison/Cargo.toml --release --locked

Use representative optimized workloads with hotpath’s optional modes:

cargo run --release --features hotpath-cpu --example profile_compressor
cargo run --release --features hotpath-alloc --example profile_compressor
cargo run --release --features hotpath-cpu,hotpath-alloc --example parallel

profile_compressor uses its built-in text payload and validates levels 1/3/6/12. On Linux, keep native timing/allocation reports separate from Callgrind instruction counts, then merge matching runs:

cargo build --release --example profile_compressor --features hotpath-alloc,hotpath-callgrind
HOTPATH_OUTPUT_FORMAT=json HOTPATH_OUTPUT_PATH=native.json \
  target/release/examples/profile_compressor
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

Criterion adapters can run a fixed count for an exact benchmark ID:

HOTPATH_CRITERION_ITERATIONS=100 cargo bench --bench decompress --features hotpath-alloc \
  -- --test --exact decompress/rust/c-source-1MiB/L6

The hotpath Criterion workflow records exact IDs, iteration counts, runner/toolchain and upload policy. Its relay separates pull-request execution from authenticated report uploads. See CI profiling mechanics.

Record revision, lockfile, Rust/C compilers, CPU/target flags, affinity, corpus identity, graph/parameters, input and output sizes, API mode, setup/allocation policy and timing intervals. Generated Criterion, profiling and coverage artifacts remain local. The development guide lists correctness checks.

Documentation website

The mopenzl 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 six parts in reading order: Start here, Graphs and codecs, 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-graph 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. Individual encoder and decoder graph chapters are generated directly from the latest committed Silesia after.csv; no second table source is maintained. 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 every page under docs/ against the local crate with default codecs, std and the opt-in framed feature, plus Rayon. A fenced rust block there must be a complete program with fn main. The examples of the pages listed in scripts/gen_docs_examples.py also run as tests/docs_examples.rs; rerun that script after editing them. Programs run their documented round-trip and API assertions. Temporary consumer source and build products remain under target/book-examples/.

Publish with GitHub Pages

This repository uses GitHub Actions as its Pages source. For a new fork, select Pages → Build and deployment → Source → GitHub Actions once in repository settings. .github/workflows/pages.yml then:

  1. Builds and validates the book on pull requests and pushes to main.
  2. Compiles and runs the documented recipes.
  3. Uploads the static book as a Pages artifact.
  4. Deploys only on main pushes or a manual run on main.

Pull requests validate without publishing or receiving Pages write permissions. Deployment uses the github-pages environment and GitHub’s Pages actions; there is no generated-content branch. The site lives under /mopenzl/, and relative asset links support that prefix and local previews.

See documentation architecture for the source/build boundaries and the CI specification.

GitHub documents the Pages workflow build type and its repository permissions.