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 data | Start with |
|---|---|
| General bytes | CompressGeneric, the default |
| Fastest decode, or already-compressed data | Store, Lz4 |
| Integers | SelectNumeric |
| Fixed-width records | FieldLz |
| Strings | StringCompress |
| A known binary layout | SimpleDataDescriptionLanguage (SDDL) |
| Not sure | BruteForce 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
finish.
Parallel compressionOne input across scoped threads, Rayon or your executor.
Framing containers opt-inIndexed resources, each with its own graph.
BenchmarksEvery graph against OpenZL C on Silesia, with raw data.
How it worksFrame format, graph engine and codecs, with diagrams.
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-Rustmzstdcrate; 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
- Sans I/O compression and decompression: caller buffers, progress counts, and completion.
- Read / Write I/O: readers and writers for raw, parallel, and framed streams.
- Parallel compression: scoped threads, Rayon, files, and streaming.
- Framing containers: multiple resources, metadata, and indexed reads.
- Graphs, codecs and structured data: choose what runs inside a frame.
- Choose your API: every entry point for both directions, with its guide.
- Configuration and features: level, format version, checksums, and Cargo features.
- Decompression and limits: budgets for untrusted input.
- Examples: pick a recipe and run the repository examples.
Choose your API
Compression and decompression expose the same I/O shapes. Pick the row that matches how your application already moves bytes, then follow its guide. New to the crate? Start with getting started; to choose what runs inside a frame, read graphs.
Both codecs
| I/O shape | Compression | Decompression |
|---|---|---|
Return a new Vec<u8> | Compressor::compress | Decompressor::decompress |
| Append to an existing Vec | compress_into | decompress_into |
| Write into a caller-owned slice | compress_to_slice | decompress_to_slice |
| Write into uninitialized memory | compress_to_uninit | decompress_to_uninit |
| Typed and multiple inputs / outputs | compress_inputs, compress_inputs_into | decompress_typed → Vec<TypedOutput> |
| Choose the graph / codec nodes | Compressor::builder(..).with_graph(..) / ParallelCompressor::builder(..).with_graph(..) | — (the frame carries its own) |
Pull output through std::io::Read | Compressor::reader | Decompressor::reader |
Push input through std::io::Write | Compressor::writer | Decompressor::writer |
| Drive input/output incrementally | start → EncoderSession | start → DecoderSession |
| Same, into uninitialized memory | EncoderSession::process_uninit | DecoderSession::process_uninit |
| Same, with the session owning the codec | into_session → EncoderSessionOwned | into_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 shape | Entry point |
|---|---|
| Split one known input across your threads | prepare_slice / prepare_source → ScopedParallelBatch |
| Stream chunked input across your threads | start → ParallelEncoderSession + ParallelTaskQueue |
| Same, into uninitialized memory | ParallelEncoderSession::process_uninit |
| Same, with the session owning the compressor | into_session → ParallelEncoderSessionOwned |
Pull parallel output through std::io::Read | reader → ParallelEncoderReader + ParallelTaskQueue |
Push input through std::io::Write across your threads | writer → ParallelEncoderWriter + ParallelTaskQueue |
See parallel compression for scoped threads, Rayon, sources, staging, and streaming.
Guides by task
| Task | Read |
|---|---|
| Choose graphs, selectors, segmenters or codec pipelines | Graphs |
| See what each codec does and which feature enables it | Codecs |
| Compress integers, records or strings | Structured data |
| Implement and register an application-defined codec | Custom codecs |
| Use a Zstandard dictionary | Dictionaries |
| Set the level, format version, checksums or Cargo features | Configuration and features |
| Control allocations, or write into uninitialized memory | Buffers and memory reuse |
| Decode untrusted input with resource limits | Decompression and limits |
| Read a frame’s comment, version or output types without decoding | Frame metadata |
Understand readers, writers, sessions, and finish | Streaming and completion |
| Connect synchronous I/O | Read / Write I/O |
| Drive caller-owned buffers directly | Sans I/O compression and decompression |
| Schedule compression on your own threads | Parallel compression |
| Use a different graph for each indexed resource | Framing containers and graph recipes (framed feature) |
| Copy a complete program | Examples |
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
- API reference on docs.rs
- Compatibility and validation: what is byte-identical to OpenZL v0.3.0, and how it is checked
- Benchmark results per graph on Silesia, with raw data
- Architecture: how the implementation works
- Development and benchmarking for contributors
Examples
The recipes below include complete programs and round-trip assertions. Examples
use the default std, compression, and decompression features unless stated
otherwise. The documentation workflow compiles and runs their Rust code against
the current checkout.
| Task | Recipe | Features / extra dependency |
|---|---|---|
| Choose graphs and compose codec nodes | Graphs · Codecs | Default; per-codec features |
| Preserve typed and multiple inputs | Structured data | Default |
| Set the level, format version and checksums | Encoder settings | Default |
| Write into a slice or uninitialized memory | Output buffers | Default |
| Decode untrusted input with limits | Set limits for untrusted input | Default |
| Read a frame header without decoding | Frame metadata | Default |
| Compress and decompress bytes | First round trip | Default |
| Reuse codec and destination memory | Reuse the workspace | Default |
| Write a raw stream incrementally | Stream through Write | Default |
| Drive a raw or framed encoder with tiny buffers | Sans I/O compression | Default; framed for frames |
| Decode arriving chunks and resource events | Sans I/O decompression | Default; framed for frames |
| Read or write raw, parallel, and framed streams | Read / Write I/O | Default; framed for frames |
| Use scoped CPU workers | Compressing a slice | Default |
| Reuse a Rayon thread pool | Running tasks with Rayon | Default + rayon |
| Stream through parallel workers | Reader and writer adapters | Default |
| Package compressed and stored resources | One container, two resources | framed |
| Add resource names and write incrementally | Write resources and metadata | framed |
| Read just the selected resource | Indexed resource reads | framed |
Run from the repository
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:
| Type | Holds | Input constructor | Decoded as |
|---|---|---|---|
Serial | raw bytes | Input::from(&[u8]) | TypedOutput::content() |
Struct | fixed-width records of any width | Input::from(Records::try_from((bytes, width))?) | content, width() |
Numeric | little-endian unsigned integers of width 1, 2, 4 or 8 | Input::from(Numbers::try_from((bytes, width))?) | content, width() |
String | variable-length strings: concatenated bytes plus one u32 length each | Input::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.
| Need | Use |
|---|---|
| General bytes, good ratio | CompressGeneric (Zstandard for serial) |
| Fastest decode, or already-compressed data | Store, Lz4 |
| Integers: pick automatically | SelectNumeric (the numeric transformer at level 7 and above) |
| Integers with structure, level 7+ | TransformerNumeric |
| Fixed-width records | FieldLz; column-wise with a function graph and the TransposeSplit node (structured data) |
| Strings | StringCompress; TryParseInt for numbers in text (needs successors) |
| Choose empirically | BruteForce through GraphBuilder::brute_force |
| Data with a known layout | SimpleDataDescriptionLanguage (SDDL), SimpleDataDescriptionLanguageV2 |
| Many inputs of related meaning | Clustering (group by tag) |
| Pick by a trained model | MlSelector |
| Bound memory on huge serial input | SegmentSerial via GraphBuilder::serial_segmenter or Chunking::Fixed |
Public graphs
| ID | StandardGraph | Inputs | What it does |
|---|---|---|---|
| 2 | Store | any, several | Stores every input without compression. Typed inputs are converted to serial first |
| 3 | Fse | S | Finite State Entropy coding |
| 4 | Huffman | S, St, N | Huffman coding of byte or 16-bit symbols; picks the Huffman variant |
| 5 | HuffmanHuf0 | S, St, N | Huffman with the zstd-style huffman_v2 coder |
| 6 | HuffmanPivco | S, St, N | Huffman with the pivoted-code coder (format 27) |
| 7 | Entropy | S, St, N | Chooses per 32 KiB chunk among constant, flatpack, bitpack, Huffman, FSE, tokenize and store from statistics |
| 8 | Constant | S, St, N | Encodes a stream of one repeated element as that element and a count. Fails at the node when the stream is not constant |
| 9 | Zstd | S | The Zstandard node, then store |
| 10 | Bitpack | S, N | Packs values into the fewest bits they need |
| 11 | Flatpack | S | Alphabet plus packed indices for small alphabets |
| 12 | FieldLz | St, N | LZ over fixed-width fields (also for numeric data) |
| 13 | CompressGeneric | any, several | Default router: serial to Zstandard; struct to FieldLz; numeric to SelectNumeric; string to separated components, each compressed by type |
| 14 | SelectGenericLzBackend | S | Selects the generic LZ backend (Zstandard) |
| 15 | SegmentNumeric | N | Segmenter: cuts numeric input every 16 MiB |
| 16 | SelectNumeric | N | Below level 7 the struct route (FieldLz); from level 7 the numeric transformer |
| 17 | MlSelector | N | Sends the input to the successor a gradient-boosted-tree model picks from statistics of the data |
| 18 | Clustering | any, several | Groups inputs by (type, width, tag) into clusters compressed together |
| 19 | TryParseInt | T | Parses decimal strings as integers. Needs three successors: integers, strings that did not parse, indices |
| 20 | SimpleDataDescriptionLanguage | S | Parses serial data with a compiled SDDL description and sends the fields it cuts out to a successor |
| 21 | SimpleDataDescriptionLanguageV2 | S | The SDDL2 segmenter: runs bytecode once, cuts chunks along its segments |
| 22 | Lz4 | S | LZ4 blocks |
| 23 | PartitionBitpack | N | Splits the value range into partitions and bitpacks bucket indices (16- and 32-bit numbers) |
| 24-27 | SegmentNum8FromSerial … SegmentNum64FromSerial | S | Segmenters that cut serial input aligned to the element width and compress each chunk as numeric |
| 28 | Lz | S | The lz codec with entropy-coded literals, offsets and lengths |
| 29 | SegmentSerial | S | Segmenter: cuts serial input every N bytes (default 16 MiB) |
| 30 | TransformerNumeric | N | The numeric transformer: scores candidate transform chains with a trained network |
| 31 | BruteForce | any | Tries 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:
| IDs | StandardGraph | What they do |
|---|---|---|
| 1, 32, 33 | SerialStore, Store1, StringStore | The store primitives |
| 34-39 | Compress1, SerialCompress, StructCompress, NumericCompress, StringCompress, StringSeparateCompress | The routers behind CompressGeneric |
| 40-44 | BitpackSerial, BitpackInt, ConstantSerial, ConstantFixed, FseNcount | Node-plus-store graphs |
| 45, 46 | FieldLzLiterals, FieldLzLiteralsChannel | Literal handling of FieldLz |
| 47-56 | DeltaHuffman, DeltaFlatpack, DeltaZstd, DeltaFieldLz, RangePack, RangePackZstd, TokenizeDeltaFieldLz and their *Internal forms | Static numeric pipelines |
| 57-60, 62, 64 | SplitSerial, SplitStruct, SplitNumeric, SplitString, NToN, TransposeSplit | Split and transpose graphs |
| 61 | Sddl2Chunk | The SDDL2 per-chunk replay graph |
| 63 | MergeSorted | Merges sorted runs |
| 65-68 | InterpretNum8Compress … InterpretNum64Compress | Serial read as numbers, then NumericCompress |
| 69 | CompressSmallLengths | Lengths that are mostly tiny with a few large values |
| 70-90 | TransformerDeltaInt … TransformerNumeric1 | The 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:
| Graph | Needs | Helper |
|---|---|---|
BruteForce | one or more candidates (none stores) | GraphBuilder::brute_force |
MlSelector | one successor per class of its model, and the model | ml_selector, untrained_ml_selector |
Clustering | the configuration, clustering nodes and the successors it indexes | clustering, clustering_with_codecs |
TryParseInt | exactly three: integers (numeric), exceptions (strings), indices | none |
SimpleDataDescriptionLanguage | exactly one, plus the compiled description | sddl |
SimpleDataDescriptionLanguageV2 | zero or one (default CompressGeneric), plus the bytecode | sddl2 |
SegmentSerial | one per chunk (default CompressGeneric) | serial_segmenter |
SegmentNum8FromSerial … SegmentNum64FromSerial | one per chunk, which receives serial data (default InterpretNumNCompress) | num_from_serial_segmenter |
SplitSerial, SplitStruct, SplitNumeric, SplitString | one custom node and one successor per node output | none |
NToN | one successor per input | none |
MergeSorted | exactly two: the merge graph, then the fallback | none |
TransposeSplit | exactly five: widths 1, 2, 4, 8, then the split graph | none |
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.
| Call | Creates |
|---|---|
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, segmenter | Your own Rust graphs |
serial_segmenter, num_from_serial_segmenter | The standard segmenters, typed |
brute_force, ml_selector, untrained_ml_selector, clustering, clustering_with_codecs | Configured standard graphs |
sddl, sddl2 | The SDDL graphs with a description or bytecode |
load_dictionaries | Attaches 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.
| Trait | Registered with | Sees | Decides |
|---|---|---|---|
Selector | GraphBuilder::selector(name, selector, TypeSet, &successors) | One input (InputView) and its successors | Which graph compresses it (returns a GraphId) |
FunctionGraph | GraphBuilder::function_graph(name, f, &input_types, last_variable, &successors, &nodes) | Edge handles of its inputs | Runs nodes (run_node) and gives every produced edge a destination (set_destination) |
Segmenter | GraphBuilder::segmenter(name, s, &input_types, &successors) | The remaining() elements of each input | Cuts 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 ofChunkSizeinput bytes (32 KiB toi32::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 withChunking::Fixed(anEncodeError::Graph).GraphBuilder::num_from_serial_segmenter(width, chunk, successor)cuts serial input aligned towidth-byte integers and compresses each chunk as numeric data (the default successor,InterpretNum8CompresstoInterpretNum64Compress, 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 withConvertSerialToNumLe32).SegmentNumericcuts 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(¬_constant).is_err());
let config = EncoderConfig::default().with_permissive(true);
let mut permissive = Compressor::builder(config).with_graph(graph).build()?;
let frame = permissive.compress(¬_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 declaredInputSize::Exactis 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.
| Feature | Nodes | Codec IDs |
|---|---|---|
custom-codec | application-defined nodes implementing CustomCodec | application-defined u32 IDs in a separate namespace |
zstd | zstd | 22 |
conversion | the type conversions and string separation | 5-14 |
delta | delta_int | 1 |
zigzag | zigzag | 3 |
divide-by | divide_by | 53 |
quantize | quantize_offsets, quantize_lengths | 25, 26 |
range-pack | range_pack | 35 |
bitsplit | bitsplit_*, bit_split | 63 |
float-deconstruct | float32_, bfloat16_, float16_deconstruct | 33 |
sparse-num | sparse_num, sparse_num_auto | 66 |
merge-sorted | merge_sorted | 43 |
constant | constant_serial, constant_fixed | 44, 45 |
prefix | prefix | 46 |
transpose | transpose_split | 2, 4, 30-32 |
split | splitN, splitN_struct, splitN_num, split_byrange | 40, 47, 48 |
split-by-struct | split_by_struct | 41 |
dispatch | dispatchN_byTag, dispatch_string | 42, 54 |
concat | concat_serial, _num, _struct, _string | 55, 57-59 |
dedup | dedup_num, dedup_num_trusted | 56 |
interleave | interleave_string | 61 |
mux-lengths | mux_lengths | 65 |
sentinel | sentinel_byte, sentinel_num | 18 |
bitpack | bitpack_serial, bitpack_int | 27, 28 |
bitunpack | bitunpack | 34 |
flatpack | flatpack | 29 |
tokenize | tokenize_* | 36-38 |
parse-int | parse_int | 60 |
partition | partition | 64 |
entropy | fse_v2, huffman_v2, huffman_struct_v2, fse_ncount | 49-52 |
pivco-huffman | pivco_huffman | 67 |
lz4 | lz4 | 62 |
lz | lz | 19 |
field-lz | field_lz | 24 |
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
| ID | StandardNode | Feature | Since | In | Out | What it does and key parameters |
|---|---|---|---|---|---|---|
| 2 | DeltaInt | delta | 3 | numeric | numeric | Replaces values with differences from their predecessor; the first value goes in the header |
| 4 | Zigzag | zigzag | 3 | numeric | numeric | Maps signed-looking values to small unsigned ones |
| 31 | DivideBy | divide-by | 16 | numeric | numeric | Divides 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 |
| 28 | RangePack | range-pack | 8 | numeric | numeric | Rebases values on their minimum into a narrower width |
| 43, 44 | QuantizeOffsets, QuantizeLengths | quantize | 3 | numeric | numeric, serial | Splits values into a small code and extra bits (LZ offsets and lengths) |
| 45-47, 75 | BitsplitTop8, BitsplitFp, BitsplitBf16, BitSplit | bitsplit | 24 | numeric | variable numeric | Splits each number into bit-field streams. Copy parameter 701 sets the field widths of BitSplit |
| 6-8 | Float32Deconstruct, Bfloat16Deconstruct, Float16Deconstruct | float-deconstruct | 4 (5 for the 16-bit ones) | numeric | struct, serial | Splits floats into sign+fraction and exponent streams |
| 54, 55 | SparseNum, SparseNumAuto | sparse-num | 26 | numeric | numeric, numeric | Mostly-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, 51 | SentinelByte, SentinelNum | sentinel | 24 | numeric | numeric, numeric | Exceptional values behind a sentinel. Copy parameter params::sentinel::INDICES (130) holds the exception indices, params::sentinel::VALUE (131) the sentinel |
| 29 | MergeSorted | merge-sorted | 9 | numeric | numeric, numeric | Merges up to 64 sorted runs into one sorted stream plus a bitset |
| 48 | Partition | partition | 24 | numeric | numeric, serial | Bucket 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 |
| 53 | MuxLengths | mux-lengths | 24 | numeric, numeric | serial, numeric | Multiplexes the two length streams of lz |
| 63, 64 | BitpackSerial, BitpackInt | bitpack | 3 | serial / numeric | serial | Packs values into the fewest bits the maximum needs |
| 27 | Bitunpack | bitunpack | 6 | serial | numeric | Reads bit-packed bytes as numbers of a chosen bit width (the inverse of BitpackInt’s packing) |
| 38 | ParseInt | parse-int | 19 | string | numeric | Parses canonical decimal strings as 64-bit integers |
Dictionary-style transforms
| ID | StandardNode | Feature | Since | In | Out | What it does and key parameters |
|---|---|---|---|---|---|---|
| 40-42 | TokenizeStruct, TokenizeNumeric, TokenizeString | tokenize | 8 (11 for strings) | struct / numeric / string | alphabet, numeric indices | Alphabet plus an index per element. Int parameter params::tokenize::SORT (0) non-zero sorts the alphabet |
| 72, 73 | TokenizeSorted, TokenizeStringSorted | tokenize | 8 / 11 | numeric / string | alphabet, numeric indices | The same with sorting preset |
| 65 | Flatpack | flatpack | 3 | serial | serial, serial | Alphabet of at most 256 symbols plus packed indices |
| 30 | Prefix | prefix | 11 | string | string, numeric | Shared prefixes with the previous string, as match sizes plus suffixes |
| 70, 71 | ConstantSerial, ConstantFixed | constant | 11 | serial / struct | one element | A stream of one repeated element, as the element and a count |
| 37, 74 | DedupNum, DedupNumTrusted | dedup | 16 | variable numeric | numeric | Identical inputs stored once. Int parameter 9438 = 1 skips the verification (DedupNumTrusted) |
Splitting, merging and routing
| ID | StandardNode | Feature | Since | In | Out | What it does and key parameters |
|---|---|---|---|---|---|---|
| 66-68 | SplitN, SplitNStruct, SplitNNum | split | 9 (15 for struct and numeric) | serial / struct / numeric | variable same type | Cuts into consecutive segments. Segment sizes are a reference parameter; run it with GraphContext::run_split_node |
| 49 | SplitByrange | split | 24 | numeric | variable numeric | Splits by value range. Int parameter params::split::BYRANGE_MIN_SEGMENT_SIZE (324) is the minimum segment size |
| 69 | SplitByStruct | split-by-struct | 9 | serial | variable struct | One stream per field of a repeated record. Copy parameter 387 holds the field sizes |
| 5 | DispatchNByTag | dispatch | 9 | serial | tags, sizes, variable serial | Routes segments to streams by tag; run it with GraphContext::run_dispatch_node and DispatchInstructions |
| 32 | DispatchString | dispatch | 16 | string | indices, variable string | Routes strings to streams by index; GraphContext::run_dispatch_string_node. Int parameter 47 is the number of outputs |
| 33-36 | ConcatSerial, ConcatNum, ConcatStruct, ConcatString | concat | 16-18 | variable same type | sizes, concatenation | Concatenates inputs, remembering their sizes (used by clustering) |
| 39 | InterleaveString | interleave | 20 | variable string | string | Round-robin interleaving of string streams |
| 3 | TransposeSplit | transpose | 11 | struct | variable serial | Column-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.
| ID | StandardNode | In | Out | Notes |
|---|---|---|---|---|
| 10 | ConvertStructToSerial | struct | serial | |
| 11, 12 | ConvertNumToStructLe, ConvertNumToSerialLe | numeric | struct / serial | |
| 13-16 | ConvertSerialToStruct, ...Struct2, ...Struct4, ...Struct8 | serial | struct | Int parameter 1 is the record width; the 2/4/8 variants preset it |
| 17, 18 | ConvertStructToNumLe, ConvertStructToNumBe | struct | numeric | Big-endian from format 21 |
| 19-22 | ConvertSerialToNum8, ...NumLe16, ...NumLe32, ...NumLe64 | serial | numeric | |
| 23-25 | ConvertSerialToNumBe16, ...Be32, ...Be64 | serial | numeric | Format 21 and newer |
| 26 | SeparateStringComponents | string | serial (content), numeric (lengths) | |
| 57 | SetStringLens | serial | string | Lengths come from LocalParams::with_string_lengths |
Entropy coders
| ID | StandardNode | Feature | Since | In | Out | What it does |
|---|---|---|---|---|---|---|
| 58 | FseV2 | entropy | 15 | serial | numeric counts, serial bits | Finite State Entropy |
| 59 | HuffmanV2 | entropy | 15 | serial | numeric weights, serial bits | Huffman (zstd’s huf) |
| 60 | HuffmanStructV2 | entropy | 15 | struct (width 2) | numeric weights, serial bits | Huffman over 16-bit symbols |
| 61 | FseNcount | entropy | 15 | numeric | serial | Serializes FSE count tables |
| 87 | PivcoHuffman | pivco-huffman | 27 | serial | numeric weights, serial bits | Pivoted-code Huffman |
These come from OpenZL’s own FSE and Huffman fork, not from mzstd.
LZ and general-purpose coders
| ID | StandardNode | Feature | Since | In | Out | What it does and key parameters |
|---|---|---|---|---|---|---|
| 62 | Zstd | zstd | 3 | serial | serial | Zstandard 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 |
| 86 | Lz4 | lz4 | 23 | serial | serial | LZ4 blocks (fast, high-compression and optimal parsers ported from lz4 1.10.0). Int parameter params::lz4::COMPRESSION_LEVEL_OVERRIDE (0) overrides the level |
| 52 | Lz | lz | 24 | serial | literals, offsets, literal lengths, match lengths | The generic LZ codec; the Lz graph entropy-codes the four streams |
| 9 | FieldLz | field-lz | 3 | struct | literals, tokens, offsets, extra literal and match lengths | LZ 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 borrowedCodecInputviews, commits outputs throughEncoderContext::emitoremit_strings, and can send a private header withsend_header. Every mandatory output must be committed exactly once.decode()receives the encoder’s output streams and private header, then restores the original inputs throughDecoderContext::emitoremit_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:
| Graph | Behavior |
|---|---|
SelectNumeric (also what CompressGeneric uses) | Below level 7, FieldLz; from level 7, the numeric transformer |
TransformerNumeric | The numeric transformer: scores candidate chains (delta, range pack, tokenize, GCD, sparse, …) with a trained network and runs the best |
FieldLz | LZ over fixed-width fields |
Entropy, Huffman, Fse | Entropy coding of the values (width 1 and 2 directly) |
Bitpack, PartitionBitpack | Narrow values; partitioned value ranges for 16- and 32-bit numbers |
Constant | One repeated value |
BruteForce | Whichever 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:
| Graph | Behavior |
|---|---|
StructCompress (what CompressGeneric uses) | FieldLz over the records |
FieldLz | LZ over whole records |
TransposeSplit | Column-wise bytes, but it needs five successors (graphs that need successors); a function graph with the TransposeSplit node is simpler |
Entropy, Huffman | Entropy coding of 16-bit records |
Constant | One 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(())
}
Clustering related inputs with tags
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 intags.successorandcodecindex the successor graphs and clustering codecs given to the builder (clusteringuses 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)andGbtNode::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 tocompress_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) tosuccessoras one multi-input destination. An invalid program is aGraphError::Invalidat 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 aboutchunk_sizebytes (0 = the 16 MiB default) along the segments the program defines, and compresses each chunk’s typed fields withsuccessor(defaultCompressGeneric). 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
Dictis opaque content for one codec with aDictId, by default the SHA-256 of its content. - A
DictBundleis an ordered list of dictionaries with aBundleId, 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
BundleIdin 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
zstdfeature. Other codecs, custom codecs and a build withoutzstdgetDictError::NoMaterializer.
Building dictionaries and bundles
| Item | Builds | Notes |
|---|---|---|
Dict::zstd(raw, level) | A Zstandard dictionary | raw 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 DictCodec | DictCodec::ZSTD is the only one that can be loaded |
Dict::with_id(id) | The same, with your own ID | An all-zero ID restores the SHA-256 default |
Vec::from(&dict) / Dict::try_from(&[u8]) | Packed form | The reference’s Dict_pack / ZL_Dict_parse |
DictBundle::try_from(Vec<Dict>) | A bundle | At most 65 535 dictionaries |
Vec::from(&bundle) / DictBundle::try_from(&[u8]) | The fat bundle | OpenZL’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:
- Parameterize the node that will use it:
parameterize_node_with(node, NodeParams::default().with_dictionary(dict_id)). Only the Zstandard node uses a dictionary. - Load the bundle:
load_dictionaries(&bundle). Every dictionary in the bundle must already be referenced by some node, and a compressor holds one bundle. - 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
BundleStoreis shared: put it in anArcand give the same store to many decoders (with_dictionaries(impl Into<Arc<BundleStore>>)); a builtCompressorGraphis 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
| Error | When |
|---|---|
DictError::Malformed | Bytes that are not a dictionary or a fat bundle, or an ID that differs from the declared one |
DictError::TooManyDicts | More than 65 535 dictionaries in a bundle |
DictError::ContentTooLarge | A content of 4 GiB or more |
DictError::ConflictingId | Two different contents share an ID |
DictError::NoMaterializer | The dictionary is not a standard Zstandard one, or zstd is off |
DictError::Materialization | Zstandard rejected the content |
DictError::StoreFull | A BundleStore is at its limits |
GraphError::Dictionary(DictError) | A bundle problem found by load_dictionaries or build |
GraphError::BundleAlreadyLoaded | load_dictionaries called twice on one builder |
GraphError::DictionaryNotFound(id) | A node references an ID the loaded bundle lacks, or no bundle is loaded |
DecodeError::MissingBundle | The 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.
| Setting | Type | Behavior |
|---|---|---|
| Level | Level | -131072..=22; 0 selects the default, 6. Standard graphs hand it to Zstandard and to level-sensitive codecs |
| Format version | FormatVersion | 21 to 27; default 27. Older versions disable newer codecs and features (comments need 22, dictionaries 25) |
| Content checksum | bool | An XXH3 checksum of each chunk’s content; default on |
| Compressed checksum | bool | An XXH3 checksum of each chunk’s compressed bytes plus a frame-header check byte; default on |
| Chunking | Chunking | Whole (default, as ZL_compress) or Fixed(ChunkSize), the serial segmenter, 32 KiB..=i32::MAX |
| Decompression level | u8 | Trades ratio for decoder speed in some codecs; 0 selects the default, 3. At level 1 Zstandard literals are stored uncompressed |
| Minimum stream size | u32 | A lone stream below it is stored; 0 selects the default, 10 |
| Store on expansion | bool | A chunk that would not shrink is stored instead; default on |
| Permissive | bool | A 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.
| Configuration | Dependency |
|---|---|
| Default std encoder + decoder | mopenzl = "0.2.3" |
| Encoder only | mopenzl = { version = "0.2.3", default-features = false, features = ["std", "compression"] } |
| Decoder only | mopenzl = { version = "0.2.3", default-features = false, features = ["std", "decompression"] } |
| Decoder for default-graph frames only | mopenzl = { version = "0.2.3", default-features = false, features = ["std", "decompression", "zstd"] } |
| Indexed resources with independent graphs | mopenzl = { version = "0.2.3", features = ["framed"] } |
| Alloc-backed codecs without std | mopenzl = { 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 to | Use |
|---|---|
Get a fresh Vec<u8> | compress / decompress |
| Append to a Vec you already own | compress_into / decompress_into |
| Write into a fixed slice | compress_to_slice / decompress_to_slice |
| Write into uninitialized memory | compress_to_uninit / decompress_to_uninit |
| Cap what the codec keeps between calls | A retention policy |
Output buffers
compress returns a new Vec<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 policy | Effect |
|---|---|
Aggressive (default) | Keeps every buffer between operations |
Bounded { max_bytes } | Releases retained storage when its accounting exceeds the ceiling |
ReleaseAll | Releases 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
| Task | Read |
|---|---|
| Decode chunks as they arrive | Sans I/O decompression |
Decode from a Read source or into a Write sink | Read / Write I/O |
| Decode typed and multiple outputs | Structured data |
| Decode a framed container | Framing containers |
| Supply a dictionary bundle | Dictionaries |
Streaming and completion
Use streaming when the payload does not fit in memory, arrives in pieces, or must leave before the input ends. There are three shapes, and they drive the same state machine:
| Shape | You call | The library does | Recipes |
|---|---|---|---|
| Reader | read on the adapter | Pulls source bytes and yields transformed bytes | Read / Write I/O |
| Writer | write_all, then finish | Pushes transformed bytes into your sink | Read / Write I/O |
| Session | process with your own buffers | No I/O; reports consumed and produced counts | Sans I/O compression and decompression |
Readers and writers require std. Sessions fit blocking code, async runtimes
and event loops.
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 / status | Meaning |
|---|---|
Operation::Continue | Accept input without ending the frame |
Operation::Flush | End the current chunk so everything accepted so far is decodable (costs ratio; no effect with InputSize::Unknown) |
Operation::End | End the frame after the supplied input |
EncoderStatus::NeedsInput | Supply more input |
EncoderStatus::NeedsOutput | Supply output space and continue with the unconsumed input |
EncoderStatus::Finished | All 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:
| Meaning | Encoder Operation | Decoder DecodeOperation |
|---|---|---|
| More input may follow | Continue | Process |
| End the current chunk so far is decodable | Flush | none (the decoder emits what it can on every call) |
| This is all the input | End | Finish |
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
| Operation | Pull bytes through a reader | Push bytes through a writer |
|---|---|---|
| Raw compression | Compressor::reader | Compressor::writer |
| Raw decompression | Decompressor::reader | Decompressor::writer |
| Parallel compression | ParallelCompressor::reader + task queue | ParallelCompressor::writer + task queue |
| Decompress parallel output | Decompressor::reader | Decompressor::writer |
Framed compression · framed | Drain a native FramedEncoderSession | FramedCompressor::framed_writer accepts complete resources |
Framed decompression · framed | framed_reader or framed_seek_reader returns owned typed resources | Route 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
| Adapter | Complete operation | Recover after a failure |
|---|---|---|
| Ordinary encoder / decoder reader | Read to EOF | Decoder into_parts preserves read-ahead; codec errors are terminal |
| Ordinary encoder / decoder writer | finish or retryable try_finish | FinishError::into_parts returns error and writer |
| Parallel reader | Read to EOF with active workers | into_parts preserves read-ahead; keep queue execution alive |
| Parallel writer | finish with active workers | FinishError::into_parts returns error and adapter |
| Framed writer | Submit complete resources, then container finish | FramedFinishError exposes writer and error |
| Framed resource reader | read_resource until None | Transport 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 format | Session entry point | Scheduling |
|---|---|---|
| OpenZL frame | Compressor::start(stream) | Call process yourself |
| One raw stream encoded across workers | ParallelCompressor::start(window) | Drive process and run its task queue |
Framed resources · framed | FramedCompressor::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.
| Input | Decoder |
|---|---|
| Ordinary OpenZL frame | Decompressor::start |
Output of ParallelCompressor | The same Decompressor::start |
Framing container · framed | FramedDecompressor::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(())
}
processnever blocks.Pendingmeans the window is full or a flush or finish waits for queued tasks; callwait, which blocks until one completes.windowbounds the segments queued, running or awaiting delivery, and 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_sliceover the same bytes, however the input was chunked.Operation::Flushends the current segment early, which moves later segment boundaries. - On a single thread, run
queue.try_recv()tasks beforewait; nothing else will. Dropping every queue handle fails the stream instead of hanging. into_session(window)returns an owned session without a lifetime;into_compressorreturns 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, andfinishwaits for every part. Run the queue on threads other than the one using the adapter. - Output is byte-identical to
prepare_sliceover the same bytes. The writer’sflushends 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,
WouldBlockand sink errors lose nothing, andfinishhands the adapter back insideFinishErrorfor a retry, asEncoderWriterdoes.reader.into_parts()returns source bytes read ahead of the session. - Session failures arrive as
std::io::Errorwith theParallelEncodeErroras their source. Dropping an adapter abandons the stream and closes the queue.
Segments and compressed size
Segment size defaults to 4 MiB and accepts 64 KiB–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
| Input | Entry point |
|---|---|
| Borrowed slice | prepare_slice |
| Regular file | prepare_source(FileSource::open(path)?, config) |
| Owned seekable reader | prepare_source(SeekSource::from(reader), config) |
| Custom random-access source | prepare_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 suite | What it compares |
|---|---|
tests/differential_c.rs | Default graph, levels, versions, checksums, chunking, bounds and representative/boundary inputs |
tests/graph_engine.rs, tests/standard_graphs.rs | Typed/multiple inputs, graph composition, selectors, segmenters, permissive/trial compression and every standard graph |
tests/encode_numeric.rs, tests/encode_structure.rs, tests/encode_backends.rs | Numeric/structural transforms and packing/LZ4 nodes, widths and local parameters |
tests/encode_entropy.rs, tests/encode_lz.rs, tests/encode_lzgraphs.rs | FSE/Huffman, LZ families and their graph decisions |
tests/encode_transformer.rs, tests/encode_ml.rs, tests/encode_sddl.rs | Numeric selection, model/configuration parsing and SDDL interpreters |
tests/encode_dict.rs, tests/custom_codec.rs | Bundles and application codecs, source chains and C interoperability |
tests/codecs_*.rs, tests/typed_outputs.rs | Reference-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
| Graph | Files | C compress | mopenzl compress | / C | C decompress | mopenzl decompress | / C |
|---|---|---|---|---|---|---|---|
| zstd | 12 | 142 MB/s | 142 MB/s | 0.99× | 1,304 MB/s | 1,231 MB/s | 0.94× |
| lz4 | 12 | 80.2 MB/s | 75.7 MB/s | 0.94× | 2,552 MB/s | 2,394 MB/s | 0.94× |
| lz | 12 | 114 MB/s | 96.1 MB/s | 0.85× | 677 MB/s | 1,036 MB/s | 1.53× |
| huffman | 12 | 1,125 MB/s | 1,052 MB/s | 0.94× | 1,591 MB/s | 1,235 MB/s | 0.78× |
| fse | 12 | 619 MB/s | 597 MB/s | 0.96× | 1,090 MB/s | 955 MB/s | 0.88× |
| entropy | 12 | 1,075 MB/s | 989 MB/s | 0.92× | 1,507 MB/s | 1,198 MB/s | 0.79× |
| huffman_pivco | 12 | 967 MB/s | 1,054 MB/s | 1.09× | 2,325 MB/s | 2,422 MB/s | 1.04× |
| flatpack | 12 | 985 MB/s | 1,425 MB/s | 1.45× | 2,733 MB/s | 4,073 MB/s | 1.49× |
| generic | 12 | 145 MB/s | 141 MB/s | 0.97× | 1,541 MB/s | 1,487 MB/s | 0.96× |
| field_lz_struct4 | 3 | 89.5 MB/s | 73.2 MB/s | 0.82× | 1,946 MB/s | 1,398 MB/s | 0.72× |
| bitpack_num16 | 2 | 4,721 MB/s | 8,438 MB/s | 1.79× | 7,666 MB/s | 17,192 MB/s | 2.24× |
| numeric16 | 2 | 28.3 MB/s | 26.7 MB/s | 0.95× | 1,020 MB/s | 810 MB/s | 0.79× |
| numeric32 | 3 | 88.8 MB/s | 75.9 MB/s | 0.85× | 1,840 MB/s | 1,241 MB/s | 0.67× |
| generic_num16 | 2 | 28.6 MB/s | 25.2 MB/s | 0.88× | 964 MB/s | 799 MB/s | 0.83× |
| generic_num32 | 3 | 80.9 MB/s | 71.5 MB/s | 0.88× | 1,810 MB/s | 1,188 MB/s | 0.66× |
zstd
| File | Bytes | Ratio | C compress | mopenzl compress | / C | C decompress | mopenzl decompress | / C |
|---|---|---|---|---|---|---|---|---|
| dickens | 10192446 | 2.982 | 102 MB/s | 102 MB/s | 1.00× | 1,326 MB/s | 1,324 MB/s | 1.00× |
| mozilla | 51220480 | 2.986 | 143 MB/s | 139 MB/s | 0.97× | 916 MB/s | 865 MB/s | 0.94× |
| mr | 9970564 | 2.944 | 132 MB/s | 131 MB/s | 0.99× | 1,599 MB/s | 1,479 MB/s | 0.92× |
| nci | 33553445 | 13.830 | 264 MB/s | 258 MB/s | 0.98× | 2,939 MB/s | 2,427 MB/s | 0.83× |
| ooffice | 6152192 | 2.099 | 117 MB/s | 113 MB/s | 0.96× | 1,302 MB/s | 1,256 MB/s | 0.96× |
| osdb | 10085684 | 2.970 | 149 MB/s | 152 MB/s | 1.02× | 2,079 MB/s | 2,164 MB/s | 1.04× |
| reymont | 6627202 | 3.735 | 118 MB/s | 117 MB/s | 0.98× | 1,747 MB/s | 1,754 MB/s | 1.00× |
| samba | 21606400 | 4.724 | 179 MB/s | 173 MB/s | 0.97× | 2,157 MB/s | 1,740 MB/s | 0.81× |
| sao | 7251944 | 1.375 | 94.3 MB/s | 101 MB/s | 1.07× | 1,373 MB/s | 1,275 MB/s | 0.93× |
| webster | 41458703 | 3.836 | 117 MB/s | 118 MB/s | 1.01× | 958 MB/s | 948 MB/s | 0.99× |
| x-ray | 8474240 | 1.526 | 110 MB/s | 114 MB/s | 1.04× | 1,131 MB/s | 1,073 MB/s | 0.95× |
| xml | 5345280 | 9.777 | 249 MB/s | 236 MB/s | 0.95× | 2,868 MB/s | 3,270 MB/s | 1.14× |
lz4
| File | Bytes | Ratio | C compress | mopenzl compress | / C | C decompress | mopenzl decompress | / C |
|---|---|---|---|---|---|---|---|---|
| dickens | 10192446 | 2.284 | 49.4 MB/s | 48.7 MB/s | 0.99× | 3,926 MB/s | 3,393 MB/s | 0.86× |
| mozilla | 51220480 | 2.309 | 93.4 MB/s | 88.3 MB/s | 0.95× | 1,718 MB/s | 1,684 MB/s | 0.98× |
| mr | 9970564 | 2.308 | 58.6 MB/s | 57.5 MB/s | 0.98× | 4,423 MB/s | 3,792 MB/s | 0.86× |
| nci | 33553445 | 8.889 | 136 MB/s | 112 MB/s | 0.83× | 5,813 MB/s | 5,269 MB/s | 0.91× |
| ooffice | 6152192 | 1.733 | 61.6 MB/s | 58.0 MB/s | 0.94× | 3,530 MB/s | 3,183 MB/s | 0.90× |
| osdb | 10085684 | 2.534 | 92.0 MB/s | 87.5 MB/s | 0.95× | 4,904 MB/s | 4,351 MB/s | 0.89× |
| reymont | 6627202 | 3.055 | 50.0 MB/s | 49.0 MB/s | 0.98× | 3,855 MB/s | 3,652 MB/s | 0.95× |
| samba | 21606400 | 3.508 | 108 MB/s | 105 MB/s | 0.97× | 4,398 MB/s | 4,132 MB/s | 0.94× |
| sao | 7251944 | 1.261 | 46.4 MB/s | 45.0 MB/s | 0.97× | 3,240 MB/s | 3,000 MB/s | 0.93× |
| webster | 41458703 | 2.945 | 68.4 MB/s | 65.5 MB/s | 0.96× | 1,561 MB/s | 1,442 MB/s | 0.92× |
| x-ray | 8474240 | 1.181 | 65.3 MB/s | 59.1 MB/s | 0.91× | 3,320 MB/s | 3,084 MB/s | 0.93× |
| xml | 5345280 | 6.844 | 144 MB/s | 137 MB/s | 0.95× | 6,412 MB/s | 5,843 MB/s | 0.91× |
lz
| File | Bytes | Ratio | C compress | mopenzl compress | / C | C decompress | mopenzl decompress | / C |
|---|---|---|---|---|---|---|---|---|
| dickens | 10192446 | 2.935 | 62.9 MB/s | 55.9 MB/s | 0.89× | 436 MB/s | 647 MB/s | 1.48× |
| mozilla | 51220480 | 2.713 | 120 MB/s | 97.9 MB/s | 0.82× | 617 MB/s | 1,032 MB/s | 1.67× |
| mr | 9970564 | 2.954 | 105 MB/s | 87.2 MB/s | 0.83× | 525 MB/s | 908 MB/s | 1.73× |
| nci | 33553445 | 12.380 | 197 MB/s | 191 MB/s | 0.97× | 1,384 MB/s | 1,549 MB/s | 1.12× |
| ooffice | 6152192 | 1.972 | 95.5 MB/s | 78.5 MB/s | 0.82× | 593 MB/s | 737 MB/s | 1.24× |
| osdb | 10085684 | 2.945 | 134 MB/s | 112 MB/s | 0.84× | 824 MB/s | 1,135 MB/s | 1.38× |
| reymont | 6627202 | 3.651 | 87.5 MB/s | 67.9 MB/s | 0.78× | 590 MB/s | 1,104 MB/s | 1.87× |
| samba | 21606400 | 4.437 | 144 MB/s | 112 MB/s | 0.78× | 942 MB/s | 1,327 MB/s | 1.41× |
| sao | 7251944 | 1.370 | 94.5 MB/s | 76.2 MB/s | 0.81× | 618 MB/s | 825 MB/s | 1.34× |
| webster | 41458703 | 3.739 | 91.5 MB/s | 78.8 MB/s | 0.86× | 582 MB/s | 930 MB/s | 1.60× |
| x-ray | 8474240 | 1.477 | 101 MB/s | 87.5 MB/s | 0.86× | 421 MB/s | 770 MB/s | 1.83× |
| xml | 5345280 | 8.950 | 177 MB/s | 162 MB/s | 0.92× | 1,396 MB/s | 2,021 MB/s | 1.45× |
huffman
| File | Bytes | Ratio | C compress | mopenzl compress | / C | C decompress | mopenzl decompress | / C |
|---|---|---|---|---|---|---|---|---|
| dickens | 10192446 | 1.748 | 1,228 MB/s | 1,166 MB/s | 0.95× | 2,119 MB/s | 1,482 MB/s | 0.70× |
| mozilla | 51220480 | 1.441 | 1,000 MB/s | 977 MB/s | 0.98× | 1,338 MB/s | 1,049 MB/s | 0.78× |
| mr | 9970564 | 2.250 | 1,103 MB/s | 1,047 MB/s | 0.95× | 1,984 MB/s | 1,398 MB/s | 0.70× |
| nci | 33553445 | 3.256 | 1,213 MB/s | 1,137 MB/s | 0.94× | 1,841 MB/s | 1,441 MB/s | 0.78× |
| ooffice | 6152192 | 1.268 | 1,156 MB/s | 1,095 MB/s | 0.95× | 1,584 MB/s | 1,041 MB/s | 0.66× |
| osdb | 10085684 | 1.202 | 1,100 MB/s | 1,028 MB/s | 0.93× | 1,447 MB/s | 1,177 MB/s | 0.81× |
| reymont | 6627202 | 1.658 | 1,378 MB/s | 998 MB/s | 0.72× | 2,021 MB/s | 1,610 MB/s | 0.80× |
| samba | 21606400 | 1.514 | 1,212 MB/s | 1,084 MB/s | 0.89× | 1,584 MB/s | 1,172 MB/s | 0.74× |
| sao | 7251944 | 1.074 | 1,195 MB/s | 1,102 MB/s | 0.92× | 1,529 MB/s | 1,101 MB/s | 0.72× |
| webster | 41458703 | 1.599 | 1,102 MB/s | 1,045 MB/s | 0.95× | 1,622 MB/s | 1,342 MB/s | 0.83× |
| x-ray | 8474240 | 1.247 | 1,130 MB/s | 976 MB/s | 0.86× | 1,373 MB/s | 1,098 MB/s | 0.80× |
| xml | 5345280 | 1.609 | 1,368 MB/s | 1,208 MB/s | 0.88× | 2,063 MB/s | 1,610 MB/s | 0.78× |
fse
| File | Bytes | Ratio | C compress | mopenzl compress | / C | C decompress | mopenzl decompress | / C |
|---|---|---|---|---|---|---|---|---|
| dickens | 10192446 | 1.761 | 564 MB/s | 562 MB/s | 1.00× | 972 MB/s | 864 MB/s | 0.89× |
| mozilla | 51220480 | 1.443 | 532 MB/s | 488 MB/s | 0.92× | 1,077 MB/s | 966 MB/s | 0.90× |
| mr | 9970564 | 2.272 | 656 MB/s | 580 MB/s | 0.89× | 1,090 MB/s | 897 MB/s | 0.82× |
| nci | 33553445 | 3.273 | 681 MB/s | 653 MB/s | 0.96× | 1,029 MB/s | 844 MB/s | 0.82× |
| ooffice | 6152192 | 1.266 | 686 MB/s | 652 MB/s | 0.95× | 1,172 MB/s | 1,071 MB/s | 0.91× |
| osdb | 10085684 | 1.202 | 673 MB/s | 642 MB/s | 0.95× | 1,141 MB/s | 1,007 MB/s | 0.88× |
| reymont | 6627202 | 1.665 | 730 MB/s | 698 MB/s | 0.96× | 1,254 MB/s | 1,104 MB/s | 0.88× |
| samba | 21606400 | 1.520 | 582 MB/s | 654 MB/s | 1.12× | 1,111 MB/s | 998 MB/s | 0.90× |
| sao | 7251944 | 1.070 | 687 MB/s | 643 MB/s | 0.94× | 1,254 MB/s | 1,101 MB/s | 0.88× |
| webster | 41458703 | 1.607 | 665 MB/s | 659 MB/s | 0.99× | 1,065 MB/s | 966 MB/s | 0.91× |
| x-ray | 8474240 | 1.245 | 671 MB/s | 614 MB/s | 0.92× | 1,224 MB/s | 1,020 MB/s | 0.83× |
| xml | 5345280 | 1.618 | 667 MB/s | 684 MB/s | 1.03× | 1,264 MB/s | 1,071 MB/s | 0.85× |
entropy
| File | Bytes | Ratio | C compress | mopenzl compress | / C | C decompress | mopenzl decompress | / C |
|---|---|---|---|---|---|---|---|---|
| dickens | 10192446 | 1.748 | 1,172 MB/s | 1,152 MB/s | 0.98× | 1,953 MB/s | 1,487 MB/s | 0.76× |
| mozilla | 51220480 | 1.442 | 1,038 MB/s | 978 MB/s | 0.94× | 1,362 MB/s | 1,056 MB/s | 0.78× |
| mr | 9970564 | 2.258 | 1,065 MB/s | 975 MB/s | 0.92× | 1,802 MB/s | 1,378 MB/s | 0.76× |
| nci | 33553445 | 3.256 | 1,137 MB/s | 975 MB/s | 0.86× | 1,500 MB/s | 1,481 MB/s | 0.99× |
| ooffice | 6152192 | 1.268 | 1,078 MB/s | 1,048 MB/s | 0.97× | 1,433 MB/s | 1,123 MB/s | 0.78× |
| osdb | 10085684 | 1.202 | 1,104 MB/s | 952 MB/s | 0.86× | 1,375 MB/s | 981 MB/s | 0.71× |
| reymont | 6627202 | 1.658 | 1,330 MB/s | 1,041 MB/s | 0.78× | 1,991 MB/s | 1,609 MB/s | 0.81× |
| samba | 21606400 | 1.514 | 1,094 MB/s | 959 MB/s | 0.88× | 1,441 MB/s | 1,020 MB/s | 0.71× |
| sao | 7251944 | 1.074 | 1,123 MB/s | 907 MB/s | 0.81× | 1,305 MB/s | 1,114 MB/s | 0.85× |
| webster | 41458703 | 1.599 | 983 MB/s | 1,024 MB/s | 1.04× | 1,622 MB/s | 1,253 MB/s | 0.77× |
| x-ray | 8474240 | 1.247 | 1,054 MB/s | 965 MB/s | 0.92× | 1,434 MB/s | 1,088 MB/s | 0.76× |
| xml | 5345280 | 1.609 | 1,374 MB/s | 929 MB/s | 0.68× | 1,837 MB/s | 1,649 MB/s | 0.90× |
huffman_pivco
| File | Bytes | Ratio | C compress | mopenzl compress | / C | C decompress | mopenzl decompress | / C |
|---|---|---|---|---|---|---|---|---|
| dickens | 10192446 | 1.745 | 1,008 MB/s | 1,140 MB/s | 1.13× | 3,210 MB/s | 3,281 MB/s | 1.02× |
| mozilla | 51220480 | 1.439 | 903 MB/s | 913 MB/s | 1.01× | 1,859 MB/s | 1,962 MB/s | 1.06× |
| mr | 9970564 | 2.247 | 987 MB/s | 1,281 MB/s | 1.30× | 3,462 MB/s | 3,490 MB/s | 1.01× |
| nci | 33553445 | 3.251 | 1,130 MB/s | 1,575 MB/s | 1.39× | 2,787 MB/s | 2,979 MB/s | 1.07× |
| ooffice | 6152192 | 1.266 | 974 MB/s | 992 MB/s | 1.02× | 2,871 MB/s | 3,066 MB/s | 1.07× |
| osdb | 10085684 | 1.200 | 913 MB/s | 832 MB/s | 0.91× | 2,457 MB/s | 2,447 MB/s | 1.00× |
| reymont | 6627202 | 1.656 | 1,136 MB/s | 1,179 MB/s | 1.04× | 3,466 MB/s | 4,167 MB/s | 1.20× |
| samba | 21606400 | 1.512 | 932 MB/s | 1,004 MB/s | 1.08× | 2,149 MB/s | 2,179 MB/s | 1.01× |
| sao | 7251944 | 1.073 | 831 MB/s | 888 MB/s | 1.07× | 2,729 MB/s | 2,802 MB/s | 1.03× |
| webster | 41458703 | 1.597 | 937 MB/s | 1,027 MB/s | 1.10× | 2,046 MB/s | 2,099 MB/s | 1.03× |
| x-ray | 8474240 | 1.246 | 973 MB/s | 991 MB/s | 1.02× | 3,004 MB/s | 2,918 MB/s | 0.97× |
| xml | 5345280 | 1.607 | 1,203 MB/s | 1,213 MB/s | 1.01× | 3,478 MB/s | 4,131 MB/s | 1.19× |
flatpack
| File | Bytes | Ratio | C compress | mopenzl compress | / C | C decompress | mopenzl decompress | / C |
|---|---|---|---|---|---|---|---|---|
| dickens | 10192446 | 1.143 | 1,154 MB/s | 1,958 MB/s | 1.70× | 1,515 MB/s | 2,562 MB/s | 1.69× |
| mozilla | 51220480 | 1.000 | 847 MB/s | 1,119 MB/s | 1.32× | 5,774 MB/s | 8,016 MB/s | 1.39× |
| mr | 9970564 | 1.000 | 1,397 MB/s | 1,954 MB/s | 1.40× | 12,087 MB/s | 17,584 MB/s | 1.45× |
| nci | 33553445 | 1.333 | 1,074 MB/s | 1,792 MB/s | 1.67× | 1,536 MB/s | 2,249 MB/s | 1.46× |
| ooffice | 6152192 | 1.000 | 1,416 MB/s | 2,108 MB/s | 1.49× | 13,131 MB/s | 19,621 MB/s | 1.49× |
| osdb | 10085684 | 1.000 | 1,357 MB/s | 1,871 MB/s | 1.38× | 11,147 MB/s | 17,714 MB/s | 1.59× |
| reymont | 6627202 | 1.000 | 1,391 MB/s | 2,076 MB/s | 1.49× | 13,042 MB/s | 19,593 MB/s | 1.50× |
| samba | 21606400 | 1.000 | 1,093 MB/s | 1,421 MB/s | 1.30× | 6,972 MB/s | 10,658 MB/s | 1.53× |
| sao | 7251944 | 1.000 | 1,352 MB/s | 1,968 MB/s | 1.46× | 12,698 MB/s | 18,868 MB/s | 1.49× |
| webster | 41458703 | 1.143 | 751 MB/s | 1,133 MB/s | 1.51× | 1,401 MB/s | 2,067 MB/s | 1.48× |
| x-ray | 8474240 | 1.000 | 1,367 MB/s | 1,921 MB/s | 1.41× | 12,384 MB/s | 18,283 MB/s | 1.48× |
| xml | 5345280 | 1.143 | 1,231 MB/s | 2,142 MB/s | 1.74× | 1,552 MB/s | 2,633 MB/s | 1.70× |
generic
| File | Bytes | Ratio | C compress | mopenzl compress | / C | C decompress | mopenzl decompress | / C |
|---|---|---|---|---|---|---|---|---|
| dickens | 10192446 | 2.982 | 102 MB/s | 100 MB/s | 0.99× | 1,431 MB/s | 1,191 MB/s | 0.83× |
| mozilla | 51220480 | 2.986 | 147 MB/s | 144 MB/s | 0.98× | 1,303 MB/s | 1,257 MB/s | 0.96× |
| mr | 9970564 | 2.944 | 134 MB/s | 130 MB/s | 0.97× | 1,546 MB/s | 1,482 MB/s | 0.96× |
| nci | 33553445 | 13.830 | 263 MB/s | 259 MB/s | 0.99× | 2,924 MB/s | 2,598 MB/s | 0.89× |
| ooffice | 6152192 | 2.099 | 115 MB/s | 109 MB/s | 0.95× | 1,301 MB/s | 1,066 MB/s | 0.82× |
| osdb | 10085684 | 2.970 | 149 MB/s | 145 MB/s | 0.98× | 1,857 MB/s | 1,973 MB/s | 1.06× |
| reymont | 6627202 | 3.735 | 103 MB/s | 104 MB/s | 1.01× | 1,695 MB/s | 1,613 MB/s | 0.95× |
| samba | 21606400 | 4.724 | 190 MB/s | 172 MB/s | 0.90× | 2,024 MB/s | 1,945 MB/s | 0.96× |
| sao | 7251944 | 1.375 | 108 MB/s | 105 MB/s | 0.97× | 1,249 MB/s | 1,261 MB/s | 1.01× |
| webster | 41458703 | 3.836 | 121 MB/s | 118 MB/s | 0.98× | 1,254 MB/s | 1,341 MB/s | 1.07× |
| x-ray | 8474240 | 1.526 | 107 MB/s | 105 MB/s | 0.98× | 1,071 MB/s | 925 MB/s | 0.86× |
| xml | 5345280 | 9.777 | 248 MB/s | 237 MB/s | 0.96× | 3,317 MB/s | 3,212 MB/s | 0.97× |
field_lz_struct4
| File | Bytes | Ratio | C compress | mopenzl compress | / C | C decompress | mopenzl decompress | / C |
|---|---|---|---|---|---|---|---|---|
| sao | 7251944 | 1.471 | 62.8 MB/s | 43.1 MB/s | 0.69× | 1,379 MB/s | 1,116 MB/s | 0.81× |
| osdb | 10085684 | 3.027 | 60.8 MB/s | 50.3 MB/s | 0.83× | 1,727 MB/s | 1,318 MB/s | 0.76× |
| nci | 33553444 | 12.506 | 117 MB/s | 103 MB/s | 0.88× | 2,230 MB/s | 1,508 MB/s | 0.68× |
bitpack_num16
| File | Bytes | Ratio | C compress | mopenzl compress | / C | C decompress | mopenzl decompress | / C |
|---|---|---|---|---|---|---|---|---|
| mr | 9970564 | 1.000 | 4,316 MB/s | 8,106 MB/s | 1.88× | 7,495 MB/s | 16,517 MB/s | 2.20× |
| x-ray | 8474240 | 1.000 | 5,308 MB/s | 8,865 MB/s | 1.67× | 7,876 MB/s | 18,060 MB/s | 2.29× |
numeric16
| File | Bytes | Ratio | C compress | mopenzl compress | / C | C decompress | mopenzl decompress | / C |
|---|---|---|---|---|---|---|---|---|
| mr | 9970564 | 3.178 | 34.9 MB/s | 32.2 MB/s | 0.92× | 1,338 MB/s | 1,097 MB/s | 0.82× |
| x-ray | 8474240 | 1.636 | 23.1 MB/s | 22.3 MB/s | 0.96× | 797 MB/s | 619 MB/s | 0.78× |
numeric32
| File | Bytes | Ratio | C compress | mopenzl compress | / C | C decompress | mopenzl decompress | / C |
|---|---|---|---|---|---|---|---|---|
| sao | 7251944 | 1.471 | 66.1 MB/s | 45.9 MB/s | 0.69× | 1,339 MB/s | 1,105 MB/s | 0.83× |
| osdb | 10085684 | 3.027 | 57.7 MB/s | 47.7 MB/s | 0.83× | 1,599 MB/s | 1,129 MB/s | 0.71× |
| nci | 33553444 | 12.506 | 116 MB/s | 112 MB/s | 0.96× | 2,107 MB/s | 1,315 MB/s | 0.62× |
generic_num16
| File | Bytes | Ratio | C compress | mopenzl compress | / C | C decompress | mopenzl decompress | / C |
|---|---|---|---|---|---|---|---|---|
| mr | 9970564 | 3.178 | 36.9 MB/s | 30.2 MB/s | 0.82× | 1,325 MB/s | 1,070 MB/s | 0.81× |
| x-ray | 8474240 | 1.636 | 22.6 MB/s | 21.1 MB/s | 0.94× | 730 MB/s | 616 MB/s | 0.84× |
generic_num32
| File | Bytes | Ratio | C compress | mopenzl compress | / C | C decompress | mopenzl decompress | / C |
|---|---|---|---|---|---|---|---|---|
| sao | 7251944 | 1.471 | 60.5 MB/s | 44.6 MB/s | 0.74× | 1,220 MB/s | 1,042 MB/s | 0.85× |
| osdb | 10085684 | 3.027 | 52.2 MB/s | 44.1 MB/s | 0.84× | 1,507 MB/s | 1,069 MB/s | 0.71× |
| nci | 33553444 | 12.506 | 106 MB/s | 105 MB/s | 0.99× | 2,167 MB/s | 1,270 MB/s | 0.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 view | Files | Input bytes | Compressed bytes | Ratio | C MB/s | mopenzl MB/s | / C |
|---|---|---|---|---|---|---|---|
| zstd | 12 | 211,938,580 | 61,240,193 | 3.461 | 142.4 | 141.6 | 0.99× |
| lz4 | 12 | 211,938,580 | 78,385,876 | 2.704 | 80.2 | 75.7 | 0.94× |
| lz | 12 | 211,938,580 | 64,385,308 | 3.292 | 113.7 | 96.1 | 0.85× |
| huffman | 12 | 211,938,580 | 130,419,862 | 1.625 | 1,124.6 | 1,051.6 | 0.94× |
| fse | 12 | 211,938,580 | 130,057,228 | 1.630 | 618.6 | 596.7 | 0.96× |
| entropy | 12 | 211,938,580 | 130,379,952 | 1.626 | 1,075.3 | 989.2 | 0.92× |
| huffman_pivco | 12 | 211,938,580 | 130,590,079 | 1.623 | 967.1 | 1,053.9 | 1.09× |
| flatpack | 12 | 211,938,580 | 196,426,372 | 1.079 | 985.3 | 1,425.5 | 1.45× |
| generic | 12 | 211,938,580 | 61,240,193 | 3.461 | 145.2 | 141.1 | 0.97× |
| field_lz_struct4 | 3 | 50,891,072 | 10,944,133 | 4.650 | 89.5 | 73.2 | 0.82× |
| bitpack_num16 | 2 | 18,444,804 | 18,444,872 | 1.000 | 4,721.3 | 8,437.7 | 1.79× |
| numeric16 | 2 | 18,444,804 | 8,318,978 | 2.217 | 28.3 | 26.7 | 0.95× |
| numeric32 | 3 | 50,891,072 | 10,944,138 | 4.650 | 88.8 | 75.9 | 0.85× |
| generic_num16 | 2 | 18,444,804 | 8,318,978 | 2.217 | 28.6 | 25.2 | 0.88× |
| generic_num32 | 3 | 50,891,072 | 10,944,138 | 4.650 | 80.9 | 71.5 | 0.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.
| File | Input bytes | Compressed bytes | Ratio | C MB/s | mopenzl MB/s | / C |
|---|---|---|---|---|---|---|
| dickens | 10,192,446 | 3,418,544 | 2.982 | 101.9 | 101.7 | 1.00× |
| mozilla | 51,220,480 | 17,156,051 | 2.986 | 143.2 | 139.3 | 0.97× |
| mr | 9,970,564 | 3,386,490 | 2.944 | 132.1 | 131.2 | 0.99× |
| nci | 33,553,445 | 2,426,210 | 13.830 | 264.0 | 257.8 | 0.98× |
| ooffice | 6,152,192 | 2,931,236 | 2.099 | 117.1 | 112.7 | 0.96× |
| osdb | 10,085,684 | 3,395,768 | 2.970 | 148.7 | 151.8 | 1.02× |
| reymont | 6,627,202 | 1,774,256 | 3.735 | 118.5 | 116.6 | 0.98× |
| samba | 21,606,400 | 4,573,414 | 4.724 | 179.4 | 173.2 | 0.97× |
| sao | 7,251,944 | 5,272,412 | 1.375 | 94.3 | 100.8 | 1.07× |
| webster | 41,458,703 | 10,807,553 | 3.836 | 116.7 | 118.1 | 1.01× |
| x-ray | 8,474,240 | 5,551,530 | 1.526 | 109.7 | 114.1 | 1.04× |
| xml | 5,345,280 | 546,729 | 9.777 | 249.1 | 235.6 | 0.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.
| File | Input bytes | Compressed bytes | Ratio | C MB/s | mopenzl MB/s | / C |
|---|---|---|---|---|---|---|
| dickens | 10,192,446 | 4,463,175 | 2.284 | 49.4 | 48.7 | 0.99× |
| mozilla | 51,220,480 | 22,186,571 | 2.309 | 93.4 | 88.3 | 0.95× |
| mr | 9,970,564 | 4,320,408 | 2.308 | 58.6 | 57.5 | 0.98× |
| nci | 33,553,445 | 3,774,716 | 8.889 | 135.6 | 112.4 | 0.83× |
| ooffice | 6,152,192 | 3,550,510 | 1.733 | 61.6 | 58.0 | 0.94× |
| osdb | 10,085,684 | 3,980,494 | 2.534 | 92.0 | 87.5 | 0.95× |
| reymont | 6,627,202 | 2,169,561 | 3.055 | 50.0 | 49.0 | 0.98× |
| samba | 21,606,400 | 6,158,302 | 3.508 | 108.0 | 104.7 | 0.97× |
| sao | 7,251,944 | 5,749,778 | 1.261 | 46.4 | 45.0 | 0.97× |
| webster | 41,458,703 | 14,076,340 | 2.945 | 68.4 | 65.5 | 0.96× |
| x-ray | 8,474,240 | 7,175,039 | 1.181 | 65.3 | 59.1 | 0.91× |
| xml | 5,345,280 | 780,982 | 6.844 | 144.0 | 136.9 | 0.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.
| File | Input bytes | Compressed bytes | Ratio | C MB/s | mopenzl MB/s | / C |
|---|---|---|---|---|---|---|
| dickens | 10,192,446 | 3,472,606 | 2.935 | 62.9 | 55.9 | 0.89× |
| mozilla | 51,220,480 | 18,882,124 | 2.713 | 119.7 | 97.9 | 0.82× |
| mr | 9,970,564 | 3,375,035 | 2.954 | 104.8 | 87.2 | 0.83× |
| nci | 33,553,445 | 2,710,290 | 12.380 | 197.4 | 190.9 | 0.97× |
| ooffice | 6,152,192 | 3,119,413 | 1.972 | 95.5 | 78.5 | 0.82× |
| osdb | 10,085,684 | 3,424,522 | 2.945 | 134.4 | 112.4 | 0.84× |
| reymont | 6,627,202 | 1,815,380 | 3.651 | 87.5 | 67.9 | 0.78× |
| samba | 21,606,400 | 4,869,406 | 4.437 | 143.7 | 112.1 | 0.78× |
| sao | 7,251,944 | 5,294,308 | 1.370 | 94.5 | 76.2 | 0.81× |
| webster | 41,458,703 | 11,087,724 | 3.739 | 91.5 | 78.8 | 0.86× |
| x-ray | 8,474,240 | 5,737,250 | 1.477 | 101.4 | 87.5 | 0.86× |
| xml | 5,345,280 | 597,250 | 8.950 | 176.9 | 162.2 | 0.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.
| File | Input bytes | Compressed bytes | Ratio | C MB/s | mopenzl MB/s | / C |
|---|---|---|---|---|---|---|
| dickens | 10,192,446 | 5,832,253 | 1.748 | 1,228.4 | 1,166.0 | 0.95× |
| mozilla | 51,220,480 | 35,552,039 | 1.441 | 999.5 | 977.3 | 0.98× |
| mr | 9,970,564 | 4,430,985 | 2.250 | 1,103.0 | 1,047.1 | 0.95× |
| nci | 33,553,445 | 10,303,816 | 3.256 | 1,213.4 | 1,137.2 | 0.94× |
| ooffice | 6,152,192 | 4,851,265 | 1.268 | 1,156.1 | 1,095.1 | 0.95× |
| osdb | 10,085,684 | 8,392,329 | 1.202 | 1,099.9 | 1,028.2 | 0.93× |
| reymont | 6,627,202 | 3,996,616 | 1.658 | 1,378.3 | 997.5 | 0.72× |
| samba | 21,606,400 | 14,269,176 | 1.514 | 1,211.9 | 1,084.3 | 0.89× |
| sao | 7,251,944 | 6,751,127 | 1.074 | 1,195.1 | 1,101.9 | 0.92× |
| webster | 41,458,703 | 25,921,202 | 1.599 | 1,102.1 | 1,044.9 | 0.95× |
| x-ray | 8,474,240 | 6,797,464 | 1.247 | 1,130.5 | 975.9 | 0.86× |
| xml | 5,345,280 | 3,321,590 | 1.609 | 1,368.3 | 1,207.8 | 0.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.
| File | Input bytes | Compressed bytes | Ratio | C MB/s | mopenzl MB/s | / C |
|---|---|---|---|---|---|---|
| dickens | 10,192,446 | 5,788,229 | 1.761 | 564.2 | 561.6 | 1.00× |
| mozilla | 51,220,480 | 35,496,717 | 1.443 | 531.8 | 488.2 | 0.92× |
| mr | 9,970,564 | 4,389,364 | 2.272 | 655.6 | 580.4 | 0.89× |
| nci | 33,553,445 | 10,251,578 | 3.273 | 680.7 | 653.1 | 0.96× |
| ooffice | 6,152,192 | 4,857,949 | 1.266 | 685.8 | 651.6 | 0.95× |
| osdb | 10,085,684 | 8,392,227 | 1.202 | 673.0 | 642.0 | 0.95× |
| reymont | 6,627,202 | 3,980,972 | 1.665 | 730.3 | 698.1 | 0.96× |
| samba | 21,606,400 | 14,214,892 | 1.520 | 581.8 | 653.7 | 1.12× |
| sao | 7,251,944 | 6,775,427 | 1.070 | 687.0 | 642.6 | 0.94× |
| webster | 41,458,703 | 25,801,631 | 1.607 | 665.0 | 658.8 | 0.99× |
| x-ray | 8,474,240 | 6,804,245 | 1.245 | 670.7 | 613.9 | 0.92× |
| xml | 5,345,280 | 3,303,997 | 1.618 | 667.0 | 683.8 | 1.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.
| File | Input bytes | Compressed bytes | Ratio | C MB/s | mopenzl MB/s | / C |
|---|---|---|---|---|---|---|
| dickens | 10,192,446 | 5,832,253 | 1.748 | 1,172.1 | 1,152.1 | 0.98× |
| mozilla | 51,220,480 | 35,528,338 | 1.442 | 1,037.8 | 978.5 | 0.94× |
| mr | 9,970,564 | 4,416,528 | 2.258 | 1,065.0 | 974.7 | 0.92× |
| nci | 33,553,445 | 10,303,816 | 3.256 | 1,136.5 | 974.7 | 0.86× |
| ooffice | 6,152,192 | 4,851,265 | 1.268 | 1,077.7 | 1,047.6 | 0.97× |
| osdb | 10,085,684 | 8,392,329 | 1.202 | 1,103.8 | 951.8 | 0.86× |
| reymont | 6,627,202 | 3,996,616 | 1.658 | 1,330.0 | 1,041.3 | 0.78× |
| samba | 21,606,400 | 14,267,424 | 1.514 | 1,093.6 | 958.6 | 0.88× |
| sao | 7,251,944 | 6,751,127 | 1.074 | 1,122.6 | 906.5 | 0.81× |
| webster | 41,458,703 | 25,921,202 | 1.599 | 982.9 | 1,023.6 | 1.04× |
| x-ray | 8,474,240 | 6,797,464 | 1.247 | 1,054.1 | 965.4 | 0.92× |
| xml | 5,345,280 | 3,321,590 | 1.609 | 1,373.9 | 929.0 | 0.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.
| File | Input bytes | Compressed bytes | Ratio | C MB/s | mopenzl MB/s | / C |
|---|---|---|---|---|---|---|
| dickens | 10,192,446 | 5,839,668 | 1.745 | 1,007.9 | 1,139.6 | 1.13× |
| mozilla | 51,220,480 | 35,598,933 | 1.439 | 902.8 | 912.8 | 1.01× |
| mr | 9,970,564 | 4,438,055 | 2.247 | 986.8 | 1,281.1 | 1.30× |
| nci | 33,553,445 | 10,321,586 | 3.251 | 1,130.5 | 1,575.5 | 1.39× |
| ooffice | 6,152,192 | 4,858,312 | 1.266 | 973.8 | 992.1 | 1.02× |
| osdb | 10,085,684 | 8,403,320 | 1.200 | 912.6 | 832.3 | 0.91× |
| reymont | 6,627,202 | 4,001,859 | 1.656 | 1,136.0 | 1,178.7 | 1.04× |
| samba | 21,606,400 | 14,285,989 | 1.512 | 931.9 | 1,004.4 | 1.08× |
| sao | 7,251,944 | 6,756,935 | 1.073 | 830.6 | 887.8 | 1.07× |
| webster | 41,458,703 | 25,955,953 | 1.597 | 937.4 | 1,026.5 | 1.10× |
| x-ray | 8,474,240 | 6,803,770 | 1.246 | 972.7 | 991.2 | 1.02× |
| xml | 5,345,280 | 3,325,699 | 1.607 | 1,202.9 | 1,213.2 | 1.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.
| File | Input bytes | Compressed bytes | Ratio | C MB/s | mopenzl MB/s | / C |
|---|---|---|---|---|---|---|
| dickens | 10,192,446 | 8,918,524 | 1.143 | 1,153.7 | 1,958.3 | 1.70× |
| mozilla | 51,220,480 | 51,220,506 | 1.000 | 846.7 | 1,119.4 | 1.32× |
| mr | 9,970,564 | 9,970,590 | 1.000 | 1,397.1 | 1,953.8 | 1.40× |
| nci | 33,553,445 | 25,165,179 | 1.333 | 1,073.8 | 1,792.4 | 1.67× |
| ooffice | 6,152,192 | 6,152,218 | 1.000 | 1,416.4 | 2,108.3 | 1.49× |
| osdb | 10,085,684 | 10,085,710 | 1.000 | 1,357.4 | 1,871.3 | 1.38× |
| reymont | 6,627,202 | 6,627,228 | 1.000 | 1,391.0 | 2,076.4 | 1.49× |
| samba | 21,606,400 | 21,606,426 | 1.000 | 1,093.1 | 1,420.8 | 1.30× |
| sao | 7,251,944 | 7,251,970 | 1.000 | 1,352.2 | 1,967.6 | 1.46× |
| webster | 41,458,703 | 36,276,497 | 1.143 | 751.3 | 1,133.0 | 1.51× |
| x-ray | 8,474,240 | 8,474,266 | 1.000 | 1,366.8 | 1,920.6 | 1.41× |
| xml | 5,345,280 | 4,677,258 | 1.143 | 1,231.4 | 2,141.8 | 1.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.
| File | Input bytes | Compressed bytes | Ratio | C MB/s | mopenzl MB/s | / C |
|---|---|---|---|---|---|---|
| dickens | 10,192,446 | 3,418,544 | 2.982 | 101.7 | 100.4 | 0.99× |
| mozilla | 51,220,480 | 17,156,051 | 2.986 | 147.1 | 143.8 | 0.98× |
| mr | 9,970,564 | 3,386,490 | 2.944 | 133.6 | 130.0 | 0.97× |
| nci | 33,553,445 | 2,426,210 | 13.830 | 262.6 | 258.9 | 0.99× |
| ooffice | 6,152,192 | 2,931,236 | 2.099 | 114.5 | 108.9 | 0.95× |
| osdb | 10,085,684 | 3,395,768 | 2.970 | 148.8 | 145.2 | 0.98× |
| reymont | 6,627,202 | 1,774,256 | 3.735 | 103.1 | 104.4 | 1.01× |
| samba | 21,606,400 | 4,573,414 | 4.724 | 190.2 | 171.6 | 0.90× |
| sao | 7,251,944 | 5,272,412 | 1.375 | 108.3 | 104.8 | 0.97× |
| webster | 41,458,703 | 10,807,553 | 3.836 | 121.3 | 118.3 | 0.98× |
| x-ray | 8,474,240 | 5,551,530 | 1.526 | 107.0 | 104.9 | 0.98× |
| xml | 5,345,280 | 546,729 | 9.777 | 247.8 | 237.1 | 0.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.
| File | Input bytes | Compressed bytes | Ratio | C MB/s | mopenzl MB/s | / C |
|---|---|---|---|---|---|---|
| sao | 7,251,944 | 4,929,504 | 1.471 | 62.8 | 43.1 | 0.69× |
| osdb | 10,085,684 | 3,331,600 | 3.027 | 60.8 | 50.3 | 0.83× |
| nci | 33,553,444 | 2,683,029 | 12.506 | 116.7 | 103.0 | 0.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.
| File | Input bytes | Compressed bytes | Ratio | C MB/s | mopenzl MB/s | / C |
|---|---|---|---|---|---|---|
| mr | 9,970,564 | 9,970,598 | 1.000 | 4,316.1 | 8,105.7 | 1.88× |
| x-ray | 8,474,240 | 8,474,274 | 1.000 | 5,307.7 | 8,865.0 | 1.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.
| File | Input bytes | Compressed bytes | Ratio | C MB/s | mopenzl MB/s | / C |
|---|---|---|---|---|---|---|
| mr | 9,970,564 | 3,137,677 | 3.178 | 34.9 | 32.2 | 0.92× |
| x-ray | 8,474,240 | 5,181,301 | 1.636 | 23.1 | 22.3 | 0.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.
| File | Input bytes | Compressed bytes | Ratio | C MB/s | mopenzl MB/s | / C |
|---|---|---|---|---|---|---|
| sao | 7,251,944 | 4,929,506 | 1.471 | 66.1 | 45.9 | 0.69× |
| osdb | 10,085,684 | 3,331,602 | 3.027 | 57.7 | 47.7 | 0.83× |
| nci | 33,553,444 | 2,683,030 | 12.506 | 116.4 | 111.5 | 0.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.
| File | Input bytes | Compressed bytes | Ratio | C MB/s | mopenzl MB/s | / C |
|---|---|---|---|---|---|---|
| mr | 9,970,564 | 3,137,677 | 3.178 | 36.9 | 30.2 | 0.82× |
| x-ray | 8,474,240 | 5,181,301 | 1.636 | 22.6 | 21.1 | 0.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.
| File | Input bytes | Compressed bytes | Ratio | C MB/s | mopenzl MB/s | / C |
|---|---|---|---|---|---|---|
| sao | 7,251,944 | 4,929,506 | 1.471 | 60.5 | 44.6 | 0.74× |
| osdb | 10,085,684 | 3,331,602 | 3.027 | 52.2 | 44.1 | 0.84× |
| nci | 33,553,444 | 2,683,030 | 12.506 | 106.1 | 104.8 | 0.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 view | Files | Input bytes | Compressed bytes | Ratio | C MB/s | mopenzl MB/s | / C |
|---|---|---|---|---|---|---|---|
| zstd | 12 | 211,938,580 | 61,240,193 | 3.461 | 1,303.9 | 1,230.7 | 0.94× |
| lz4 | 12 | 211,938,580 | 78,385,876 | 2.704 | 2,551.8 | 2,393.6 | 0.94× |
| lz | 12 | 211,938,580 | 64,385,308 | 3.292 | 676.5 | 1,036.2 | 1.53× |
| huffman | 12 | 211,938,580 | 130,419,862 | 1.625 | 1,591.3 | 1,234.5 | 0.78× |
| fse | 12 | 211,938,580 | 130,057,228 | 1.630 | 1,089.7 | 955.0 | 0.88× |
| entropy | 12 | 211,938,580 | 130,379,952 | 1.626 | 1,507.1 | 1,197.8 | 0.79× |
| huffman_pivco | 12 | 211,938,580 | 130,590,079 | 1.623 | 2,325.3 | 2,422.0 | 1.04× |
| flatpack | 12 | 211,938,580 | 196,426,372 | 1.079 | 2,733.3 | 4,072.7 | 1.49× |
| generic | 12 | 211,938,580 | 61,240,193 | 3.461 | 1,541.5 | 1,486.7 | 0.96× |
| field_lz_struct4 | 3 | 50,891,072 | 10,944,133 | 4.650 | 1,946.2 | 1,398.1 | 0.72× |
| bitpack_num16 | 2 | 18,444,804 | 18,444,872 | 1.000 | 7,665.6 | 17,191.8 | 2.24× |
| numeric16 | 2 | 18,444,804 | 8,318,978 | 2.217 | 1,019.6 | 809.7 | 0.79× |
| numeric32 | 3 | 50,891,072 | 10,944,138 | 4.650 | 1,840.4 | 1,241.1 | 0.67× |
| generic_num16 | 2 | 18,444,804 | 8,318,978 | 2.217 | 963.9 | 799.4 | 0.83× |
| generic_num32 | 3 | 50,891,072 | 10,944,138 | 4.650 | 1,809.9 | 1,188.4 | 0.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.
| File | Input bytes | Compressed bytes | Ratio | C MB/s | mopenzl MB/s | / C |
|---|---|---|---|---|---|---|
| dickens | 10,192,446 | 3,418,544 | 2.982 | 1,326.4 | 1,324.1 | 1.00× |
| mozilla | 51,220,480 | 17,156,051 | 2.986 | 916.4 | 864.6 | 0.94× |
| mr | 9,970,564 | 3,386,490 | 2.944 | 1,599.1 | 1,478.7 | 0.92× |
| nci | 33,553,445 | 2,426,210 | 13.830 | 2,939.1 | 2,426.9 | 0.83× |
| ooffice | 6,152,192 | 2,931,236 | 2.099 | 1,301.9 | 1,256.0 | 0.96× |
| osdb | 10,085,684 | 3,395,768 | 2.970 | 2,079.1 | 2,163.7 | 1.04× |
| reymont | 6,627,202 | 1,774,256 | 3.735 | 1,747.1 | 1,754.2 | 1.00× |
| samba | 21,606,400 | 4,573,414 | 4.724 | 2,157.5 | 1,739.8 | 0.81× |
| sao | 7,251,944 | 5,272,412 | 1.375 | 1,373.4 | 1,274.5 | 0.93× |
| webster | 41,458,703 | 10,807,553 | 3.836 | 957.6 | 948.3 | 0.99× |
| x-ray | 8,474,240 | 5,551,530 | 1.526 | 1,130.7 | 1,073.0 | 0.95× |
| xml | 5,345,280 | 546,729 | 9.777 | 2,867.6 | 3,270.4 | 1.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.
| File | Input bytes | Compressed bytes | Ratio | C MB/s | mopenzl MB/s | / C |
|---|---|---|---|---|---|---|
| dickens | 10,192,446 | 4,463,175 | 2.284 | 3,926.0 | 3,393.3 | 0.86× |
| mozilla | 51,220,480 | 22,186,571 | 2.309 | 1,717.8 | 1,683.9 | 0.98× |
| mr | 9,970,564 | 4,320,408 | 2.308 | 4,423.0 | 3,791.9 | 0.86× |
| nci | 33,553,445 | 3,774,716 | 8.889 | 5,812.9 | 5,269.4 | 0.91× |
| ooffice | 6,152,192 | 3,550,510 | 1.733 | 3,529.9 | 3,183.4 | 0.90× |
| osdb | 10,085,684 | 3,980,494 | 2.534 | 4,903.6 | 4,351.0 | 0.89× |
| reymont | 6,627,202 | 2,169,561 | 3.055 | 3,854.6 | 3,652.5 | 0.95× |
| samba | 21,606,400 | 6,158,302 | 3.508 | 4,398.0 | 4,132.3 | 0.94× |
| sao | 7,251,944 | 5,749,778 | 1.261 | 3,239.5 | 3,000.3 | 0.93× |
| webster | 41,458,703 | 14,076,340 | 2.945 | 1,561.1 | 1,442.0 | 0.92× |
| x-ray | 8,474,240 | 7,175,039 | 1.181 | 3,320.1 | 3,083.9 | 0.93× |
| xml | 5,345,280 | 780,982 | 6.844 | 6,411.9 | 5,843.0 | 0.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.
| File | Input bytes | Compressed bytes | Ratio | C MB/s | mopenzl MB/s | / C |
|---|---|---|---|---|---|---|
| dickens | 10,192,446 | 3,472,606 | 2.935 | 436.4 | 646.8 | 1.48× |
| mozilla | 51,220,480 | 18,882,124 | 2.713 | 617.5 | 1,032.1 | 1.67× |
| mr | 9,970,564 | 3,375,035 | 2.954 | 524.5 | 908.2 | 1.73× |
| nci | 33,553,445 | 2,710,290 | 12.380 | 1,384.3 | 1,549.5 | 1.12× |
| ooffice | 6,152,192 | 3,119,413 | 1.972 | 592.9 | 736.9 | 1.24× |
| osdb | 10,085,684 | 3,424,522 | 2.945 | 824.3 | 1,135.2 | 1.38× |
| reymont | 6,627,202 | 1,815,380 | 3.651 | 589.9 | 1,103.9 | 1.87× |
| samba | 21,606,400 | 4,869,406 | 4.437 | 942.0 | 1,327.1 | 1.41× |
| sao | 7,251,944 | 5,294,308 | 1.370 | 617.6 | 825.3 | 1.34× |
| webster | 41,458,703 | 11,087,724 | 3.739 | 581.9 | 930.2 | 1.60× |
| x-ray | 8,474,240 | 5,737,250 | 1.477 | 421.2 | 770.0 | 1.83× |
| xml | 5,345,280 | 597,250 | 8.950 | 1,396.2 | 2,021.1 | 1.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.
| File | Input bytes | Compressed bytes | Ratio | C MB/s | mopenzl MB/s | / C |
|---|---|---|---|---|---|---|
| dickens | 10,192,446 | 5,832,253 | 1.748 | 2,118.5 | 1,482.3 | 0.70× |
| mozilla | 51,220,480 | 35,552,039 | 1.441 | 1,338.3 | 1,048.9 | 0.78× |
| mr | 9,970,564 | 4,430,985 | 2.250 | 1,984.1 | 1,397.9 | 0.70× |
| nci | 33,553,445 | 10,303,816 | 3.256 | 1,840.8 | 1,441.2 | 0.78× |
| ooffice | 6,152,192 | 4,851,265 | 1.268 | 1,584.3 | 1,040.9 | 0.66× |
| osdb | 10,085,684 | 8,392,329 | 1.202 | 1,446.7 | 1,176.7 | 0.81× |
| reymont | 6,627,202 | 3,996,616 | 1.658 | 2,021.3 | 1,609.8 | 0.80× |
| samba | 21,606,400 | 14,269,176 | 1.514 | 1,583.7 | 1,172.2 | 0.74× |
| sao | 7,251,944 | 6,751,127 | 1.074 | 1,529.2 | 1,101.2 | 0.72× |
| webster | 41,458,703 | 25,921,202 | 1.599 | 1,621.7 | 1,341.7 | 0.83× |
| x-ray | 8,474,240 | 6,797,464 | 1.247 | 1,373.2 | 1,097.8 | 0.80× |
| xml | 5,345,280 | 3,321,590 | 1.609 | 2,062.7 | 1,610.0 | 0.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.
| File | Input bytes | Compressed bytes | Ratio | C MB/s | mopenzl MB/s | / C |
|---|---|---|---|---|---|---|
| dickens | 10,192,446 | 5,788,229 | 1.761 | 972.1 | 863.9 | 0.89× |
| mozilla | 51,220,480 | 35,496,717 | 1.443 | 1,076.6 | 965.6 | 0.90× |
| mr | 9,970,564 | 4,389,364 | 2.272 | 1,089.8 | 896.6 | 0.82× |
| nci | 33,553,445 | 10,251,578 | 3.273 | 1,029.0 | 844.2 | 0.82× |
| ooffice | 6,152,192 | 4,857,949 | 1.266 | 1,172.4 | 1,070.5 | 0.91× |
| osdb | 10,085,684 | 8,392,227 | 1.202 | 1,141.1 | 1,007.3 | 0.88× |
| reymont | 6,627,202 | 3,980,972 | 1.665 | 1,254.1 | 1,103.8 | 0.88× |
| samba | 21,606,400 | 14,214,892 | 1.520 | 1,111.0 | 998.0 | 0.90× |
| sao | 7,251,944 | 6,775,427 | 1.070 | 1,254.1 | 1,100.8 | 0.88× |
| webster | 41,458,703 | 25,801,631 | 1.607 | 1,065.3 | 965.9 | 0.91× |
| x-ray | 8,474,240 | 6,804,245 | 1.245 | 1,223.8 | 1,019.7 | 0.83× |
| xml | 5,345,280 | 3,303,997 | 1.618 | 1,264.2 | 1,071.4 | 0.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.
| File | Input bytes | Compressed bytes | Ratio | C MB/s | mopenzl MB/s | / C |
|---|---|---|---|---|---|---|
| dickens | 10,192,446 | 5,832,253 | 1.748 | 1,952.8 | 1,486.9 | 0.76× |
| mozilla | 51,220,480 | 35,528,338 | 1.442 | 1,361.5 | 1,056.4 | 0.78× |
| mr | 9,970,564 | 4,416,528 | 2.258 | 1,801.6 | 1,377.8 | 0.76× |
| nci | 33,553,445 | 10,303,816 | 3.256 | 1,499.7 | 1,480.9 | 0.99× |
| ooffice | 6,152,192 | 4,851,265 | 1.268 | 1,432.6 | 1,123.0 | 0.78× |
| osdb | 10,085,684 | 8,392,329 | 1.202 | 1,375.4 | 980.9 | 0.71× |
| reymont | 6,627,202 | 3,996,616 | 1.658 | 1,991.0 | 1,608.9 | 0.81× |
| samba | 21,606,400 | 14,267,424 | 1.514 | 1,440.9 | 1,019.9 | 0.71× |
| sao | 7,251,944 | 6,751,127 | 1.074 | 1,304.6 | 1,114.0 | 0.85× |
| webster | 41,458,703 | 25,921,202 | 1.599 | 1,622.2 | 1,252.5 | 0.77× |
| x-ray | 8,474,240 | 6,797,464 | 1.247 | 1,434.4 | 1,088.4 | 0.76× |
| xml | 5,345,280 | 3,321,590 | 1.609 | 1,837.3 | 1,649.0 | 0.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.
| File | Input bytes | Compressed bytes | Ratio | C MB/s | mopenzl MB/s | / C |
|---|---|---|---|---|---|---|
| dickens | 10,192,446 | 5,839,668 | 1.745 | 3,210.5 | 3,281.0 | 1.02× |
| mozilla | 51,220,480 | 35,598,933 | 1.439 | 1,858.9 | 1,961.9 | 1.06× |
| mr | 9,970,564 | 4,438,055 | 2.247 | 3,462.4 | 3,489.7 | 1.01× |
| nci | 33,553,445 | 10,321,586 | 3.251 | 2,787.4 | 2,979.2 | 1.07× |
| ooffice | 6,152,192 | 4,858,312 | 1.266 | 2,870.9 | 3,065.9 | 1.07× |
| osdb | 10,085,684 | 8,403,320 | 1.200 | 2,457.3 | 2,447.1 | 1.00× |
| reymont | 6,627,202 | 4,001,859 | 1.656 | 3,465.8 | 4,166.8 | 1.20× |
| samba | 21,606,400 | 14,285,989 | 1.512 | 2,148.9 | 2,179.4 | 1.01× |
| sao | 7,251,944 | 6,756,935 | 1.073 | 2,728.5 | 2,801.8 | 1.03× |
| webster | 41,458,703 | 25,955,953 | 1.597 | 2,045.5 | 2,098.9 | 1.03× |
| x-ray | 8,474,240 | 6,803,770 | 1.246 | 3,003.6 | 2,918.3 | 0.97× |
| xml | 5,345,280 | 3,325,699 | 1.607 | 3,478.3 | 4,130.9 | 1.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.
| File | Input bytes | Compressed bytes | Ratio | C MB/s | mopenzl MB/s | / C |
|---|---|---|---|---|---|---|
| dickens | 10,192,446 | 8,918,524 | 1.143 | 1,514.7 | 2,561.5 | 1.69× |
| mozilla | 51,220,480 | 51,220,506 | 1.000 | 5,774.4 | 8,016.4 | 1.39× |
| mr | 9,970,564 | 9,970,590 | 1.000 | 12,086.8 | 17,583.8 | 1.45× |
| nci | 33,553,445 | 25,165,179 | 1.333 | 1,536.3 | 2,248.7 | 1.46× |
| ooffice | 6,152,192 | 6,152,218 | 1.000 | 13,131.3 | 19,621.5 | 1.49× |
| osdb | 10,085,684 | 10,085,710 | 1.000 | 11,146.7 | 17,713.5 | 1.59× |
| reymont | 6,627,202 | 6,627,228 | 1.000 | 13,042.3 | 19,593.1 | 1.50× |
| samba | 21,606,400 | 21,606,426 | 1.000 | 6,971.5 | 10,658.4 | 1.53× |
| sao | 7,251,944 | 7,251,970 | 1.000 | 12,697.6 | 18,868.0 | 1.49× |
| webster | 41,458,703 | 36,276,497 | 1.143 | 1,400.9 | 2,067.2 | 1.48× |
| x-ray | 8,474,240 | 8,474,266 | 1.000 | 12,384.4 | 18,283.2 | 1.48× |
| xml | 5,345,280 | 4,677,258 | 1.143 | 1,552.1 | 2,633.3 | 1.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.
| File | Input bytes | Compressed bytes | Ratio | C MB/s | mopenzl MB/s | / C |
|---|---|---|---|---|---|---|
| dickens | 10,192,446 | 3,418,544 | 2.982 | 1,431.4 | 1,190.9 | 0.83× |
| mozilla | 51,220,480 | 17,156,051 | 2.986 | 1,303.0 | 1,257.0 | 0.96× |
| mr | 9,970,564 | 3,386,490 | 2.944 | 1,546.3 | 1,482.1 | 0.96× |
| nci | 33,553,445 | 2,426,210 | 13.830 | 2,923.6 | 2,597.6 | 0.89× |
| ooffice | 6,152,192 | 2,931,236 | 2.099 | 1,300.7 | 1,065.8 | 0.82× |
| osdb | 10,085,684 | 3,395,768 | 2.970 | 1,856.8 | 1,972.9 | 1.06× |
| reymont | 6,627,202 | 1,774,256 | 3.735 | 1,694.8 | 1,612.9 | 0.95× |
| samba | 21,606,400 | 4,573,414 | 4.724 | 2,023.7 | 1,945.3 | 0.96× |
| sao | 7,251,944 | 5,272,412 | 1.375 | 1,248.6 | 1,260.8 | 1.01× |
| webster | 41,458,703 | 10,807,553 | 3.836 | 1,254.4 | 1,340.6 | 1.07× |
| x-ray | 8,474,240 | 5,551,530 | 1.526 | 1,070.8 | 925.4 | 0.86× |
| xml | 5,345,280 | 546,729 | 9.777 | 3,316.6 | 3,212.0 | 0.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.
| File | Input bytes | Compressed bytes | Ratio | C MB/s | mopenzl MB/s | / C |
|---|---|---|---|---|---|---|
| sao | 7,251,944 | 4,929,504 | 1.471 | 1,378.7 | 1,116.3 | 0.81× |
| osdb | 10,085,684 | 3,331,600 | 3.027 | 1,726.8 | 1,317.6 | 0.76× |
| nci | 33,553,444 | 2,683,029 | 12.506 | 2,229.7 | 1,508.0 | 0.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.
| File | Input bytes | Compressed bytes | Ratio | C MB/s | mopenzl MB/s | / C |
|---|---|---|---|---|---|---|
| mr | 9,970,564 | 9,970,598 | 1.000 | 7,495.1 | 16,517.1 | 2.20× |
| x-ray | 8,474,240 | 8,474,274 | 1.000 | 7,876.4 | 18,059.7 | 2.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.
| File | Input bytes | Compressed bytes | Ratio | C MB/s | mopenzl MB/s | / C |
|---|---|---|---|---|---|---|
| mr | 9,970,564 | 3,137,677 | 3.178 | 1,338.0 | 1,096.8 | 0.82× |
| x-ray | 8,474,240 | 5,181,301 | 1.636 | 796.5 | 619.1 | 0.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.
| File | Input bytes | Compressed bytes | Ratio | C MB/s | mopenzl MB/s | / C |
|---|---|---|---|---|---|---|
| sao | 7,251,944 | 4,929,506 | 1.471 | 1,338.9 | 1,104.7 | 0.83× |
| osdb | 10,085,684 | 3,331,602 | 3.027 | 1,599.1 | 1,129.3 | 0.71× |
| nci | 33,553,444 | 2,683,030 | 12.506 | 2,106.6 | 1,315.4 | 0.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.
| File | Input bytes | Compressed bytes | Ratio | C MB/s | mopenzl MB/s | / C |
|---|---|---|---|---|---|---|
| mr | 9,970,564 | 3,137,677 | 3.178 | 1,325.1 | 1,069.7 | 0.81× |
| x-ray | 8,474,240 | 5,181,301 | 1.636 | 729.9 | 616.1 | 0.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.
| File | Input bytes | Compressed bytes | Ratio | C MB/s | mopenzl MB/s | / C |
|---|---|---|---|---|---|---|
| sao | 7,251,944 | 4,929,506 | 1.471 | 1,220.4 | 1,042.0 | 0.85× |
| osdb | 10,085,684 | 3,331,602 | 3.027 | 1,506.6 | 1,069.0 | 0.71× |
| nci | 33,553,444 | 2,683,030 | 12.506 | 2,167.4 | 1,269.7 | 0.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
- Per-graph and per-file report
- Recorded environment and CSV
- Reproduction and profiling
- Decoder implementation
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 understand | Read, in order |
|---|---|
| What a frame looks like on the wire | Frame format |
How a compress call becomes a frame | Compressor → Compression graphs → Standard graphs → Encoder codecs |
| How a frame is decoded | Decompressor → Decoder codecs |
| How a codec family works | Entropy coders, LZ codecs and graphs, Transformer, ML selector and clustering, SDDL and SDDL2 |
| How applications extend the codec set | Custom codecs → Dictionaries |
| How one input is split across threads | Parallel compression |
| How containers work | Framed containers |
| How byte identity is established | Compatibility → Fuzzing |
| Which features compile which code | Codec features |
Specifications share an outline: module and ownership boundaries, public surface, control and data flow with Mermaid diagrams, and error propagation. What is missing or slower than the reference is listed under known gaps.
Module map
graph TD
Root[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.
| Specification | Scope |
|---|---|
| Frame format | Magic, frame header, chunk header lanes, stream numbering, stored streams, checksums and limits as both codecs read and write them. |
| Codec features | Direction and per-codec feature gates, std/no_std, libm and profiling features, and how disabled codecs behave. |
| Shared primitives | Varints, bit packing, wire constants, XXH3 with its SIMD accumulator, and the encoder-side shared modules. |
| Uninitialized output | compress_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.
| Specification | Scope |
|---|---|
| Compressor | Public API, the specialized default-graph path, chunking, sinks, the session state machine, graph selection, I/O and errors. |
| Compression graphs | The graph engine: typed streams, runtime graph, contexts, successor order, stores, conversions, trial compression and the registry tables. |
| Standard graphs | The decision logic of every standard graph: routing, stores, segmenters and structure graphs. |
| Encoder codecs | Every node encoder: node ID to codec ID, feature, ports, wire summaries, deviations and gaps. |
| Parallel compression | Caller-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.
| Specification | Scope |
|---|---|
| Entropy coders | OpenZL’s FSE/Huffman fork, PivCo-Huffman, the five entropy nodes and the entropy graph’s plan. |
| LZ codecs and graphs | lz4 (fast, HC, optimal), lz, field_lz, mux_lengths, quantize, the shared lz_common infrastructure and the LZ graphs. |
| Transformer | The numeric selector: features, generated scorers, guards, depth limits, static fallbacks, float determinism and the no_std caveat. |
| ML selector and clustering | Gradient-boosted-tree selection and tag-based clustering with their CBOR configurations. |
| SDDL and SDDL2 | The description interpreter, the bytecode VM, chunking and replay. |
| Custom codecs | Application codec registration, typed and variable ports, callback contexts, custom wire IDs, validation, errors and lifetime. |
| Dictionaries | Dict, DictBundle and BundleStore, wire formats, the encoder and decoder flows. |
Decompression
The universal decoder and its codec catalogue.
| Specification | Scope |
|---|---|
| Decompressor | Frame and unit parsing, chunk graph layout, the typed node executor, typed multi-output decoding, dictionaries, sessions, limits, I/O and errors. |
| Decoder codecs | Every standard codec decoder: signatures, wire formats, invariants and deviations from the reference on malformed data. |
Containers
The opt-in framed container around ordinary frames.
| Specification | Scope |
|---|---|
| Framed containers | Opt-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.
| Specification | Scope |
|---|---|
| Compatibility | The C oracle and its shims per family, byte-identity and decode differential tests, and what each test file covers. |
| Fuzzing | AFL targets, oracles, regression replay and the libFuzzer adapters. |
| Implementation comparison | Criterion and Silesia harness methodology (standard graphs and custom callbacks against C) and recorded results. |
| Continuous integration | Workflows, feature probes, AFL smoke runs, Miri and AddressSanitizer selection, coverage gate, hotpath reports and releases. |
| Documentation publishing | Source 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_simdkernels 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_stdbuilds use thelibmcrate for floating point; the Transformer and estimator decisions are byte-identical with the reference only withstd(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.
| Specification | Scope |
|---|---|
| Frame format | Magic, frame header, chunk header lanes, stream numbering, stored streams, checksums and limits as both codecs read and write them. |
| Codec features | Direction and per-codec feature gates, std/no_std, libm and profiling features, and how disabled codecs behave. |
| Shared primitives | Varints, bit packing, wire constants, XXH3 with its SIMD accumulator, and the encoder-side shared modules. |
| Uninitialized output | compress_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
0x00means “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, whileDecompressor::decompress_typedreturns 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:
| Chunk | Header bytes | Streams |
|---|---|---|
| Stored | 01 01 varint(n) | the input |
| Zstandard | 02 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 (
0x00first 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.
compressionexposescompressor,graph,inputand the root re-exports;decompressionexposesdecompressorand its re-exports;dictexists with either direction.Backend,RetentionPolicy,FormatVersionandDataTypeexist with either direction; with neither the crate is empty.ioexists only with a direction and withoutno_std.no_stdselects the compile-time baseline SIMD level and wins overstdwhen both are set (as--all-featuresdoes);hotpath*anchors are compiled out with it.libmis an optional dependency enabled byno_std. It supplieslog,exp,powandexpftoshared::estimateand the Transformer (graphs::transformer::math), andsqrtto the ML selector’s features. Withstdthose functions are the platform’s, which is what the reference links; ano_stdbuild has no byte-identity guarantee for decisions that depend on them (see transformer).shared::estimateis compiled withcompressionin 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.
| Feature | Codec IDs | Decoder module (decompressor::core::codecs) | Encoder module (compressor::core::codecs) | Encoder graphs needing it |
|---|---|---|---|---|
custom-codec | application u32 IDs (separate namespace) | custom_codec::core callback adapter | custom_codec::core callback adapter | application graphs |
zstd | 22 | zstd, core::zstd, State::zstd, Out::zstd | zstd_node, core::zstd | zstd 9, default fast path |
conversion | 5-14 | conversion | conversion (always compiled; rows gated) | stores, routers |
delta | 1 | delta | numeric::delta | transformer |
zigzag | 3 | zigzag | numeric::zigzag | transformer |
divide-by | 53 | divide_by | numeric::divide_by | transformer |
quantize | 25, 26 | quantize | quantize | field_lz graph, compress_small_lengths |
range-pack | 35 | range_pack | numeric::range_pack | transformer |
bitsplit | 63 | bitsplit | numeric::bitsplit | |
float-deconstruct | 33 | float_deconstruct | numeric::float_deconstruct | |
sparse-num | 66 | sparse_num | numeric::sparse_num | transformer |
merge-sorted | 43 | merge_sorted | numeric::merge_sorted | merge_sorted 63 |
sentinel | 18 | sentinel | numeric::sentinel | compress_small_lengths |
partition | 64 | partition | numeric::partition | partition_bitpack 23 |
constant | 44, 45 | constant | constant | constant 8, transformer |
prefix | 46 | prefix | structure::prefix | |
transpose | 2, 4, 30-32 | transpose | structure::transpose | transpose_split selector 64, field_lz literals |
split | 40, 47, 48 | split | structure::split | entropy graph (32 KiB chunks), split_* |
split-by-struct | 41 | split_by_struct | structure::split_by_struct | sddl2 |
dispatch | 42, 54 | dispatch | structure::dispatch | sddl, try_parse_int |
concat | 55, 57-59 | concat | structure::concat | cluster |
dedup | 56 | dedup | structure::dedup | |
interleave | 61 | interleave | structure::interleave | |
tokenize | 36-38 | tokenize | structure::tokenize | entropy graph, transformer |
parse-int | 60 | parse_int | structure::parse_int | try_parse_int 19 |
bitpack | 27, 28 | bitpack | bitpack | bitpack 10, entropy graph |
bitunpack | 34 | bitunpack | bitunpack | |
flatpack | 29 | flatpack | flatpack | flatpack 11, entropy graph |
mux-lengths | 65 | mux_lengths | mux_lengths | lz 28 |
entropy | 49-52 | entropy | entropy (needs compression) | graphs 3-7, 44 |
pivco-huffman | 67 | pivco_huffman | pivco_huffman (needs compression) | huffman_pivco 6 |
lz4 | 62 | lz4 | lz4 | lz4 22 |
lz | 19 | lz | lz, lz_common | lz 28 (also needs entropy) |
field-lz | 24 | field_lz | field_lz, lz_common | field_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.
| Module | Compiled with |
|---|---|
wire, varint, bitpack, xxh3, destination, sha256, dictwire | either direction (wire::CODEC_ZSTD, MIN_STREAM_SIZE need zstd; parts of dictwire need decompression) |
cbor, estimate, numeric | compression |
bitpack_kernels | compression and (bitpack or bitunpack) |
entropy, stats | compression 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.pycovers the root re-exports; it does not probegraph,inputanddictitems. - CI’s feature matrix toggles
zstdand “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
entropywithoutbitpack) 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 asZL_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_forisZL_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 privateWireError; see dictionaries.fixed(withlz4encoding orentropydecoding):fixed_table::<T, N>builds aBox<[T; N]>throughvec!(zeroed tables reachalloc_zeroed) without an error path, for the LZ4 HC hash/chain tables and the FSE and Huffman decoding tables, whose index checks then compare againstN.
Encoder-side modules (compression)
| Module | Contents | Gate |
|---|---|---|
stats | Histogram (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 |
entropy | OpenZL’s FSE/HUF encoder fork (bits, fse, huf, huf16), see entropy | entropy or pivco-huffman |
estimate | cardinality 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 xxh3 | compression; consumers: Transformer, ML selector features |
numeric | read_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_kernels | nb_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_BitCStreamFF | bitpack or bitunpack |
byte_transpose | split (encoder) and merge (decoder) byte-column transposition: unzip/zip rounds on u8x16 for widths 2, 4, 8, scalar otherwise | transpose |
cbor | A1CBOR port: Item, strict decode (max_depth 128, arena limit_bytes), encode_to_vec; see ML selector and clustering | compression |
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_stdestimators uselibm, 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 thesimd_backendsanduninittests) are unchanged: the encoder codecs, graphs and shared algorithms added since are scalar,Vec-based safe code with no SIMD kernel and noMaybeUninitpath, so they are not on the lists. decompress_to_sliceanddecompress_to_uninitdecode Zstandard throughmzstd::decompress_to_slice, which is slower than itsdecompress_intopath 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.
| Specification | Scope |
|---|---|
| Compressor | Public API, the specialized default-graph path, chunking, sinks, the session state machine, graph selection, I/O and errors. |
| Compression graphs | The graph engine: typed streams, runtime graph, contexts, successor order, stores, conversions, trial compression and the registry tables. |
| Standard graphs | The decision logic of every standard graph: routing, stores, segmenters and structure graphs. |
| Encoder codecs | Every node encoder: node ID to codec ID, feature, ports, wire summaries, deviations and gaps. |
| Parallel compression | Caller-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_sliceandcompress_to_uninitstage 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).EncoderConfiggainsdecompression_level,min_stream_size,store_on_expansionandpermissive(0selects 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_planruns before each operation and gives the engine aGraphPlan: the registry, the start graph (wrapped insegment_serialforChunking::Fixed), the comment and, for a serial segmenter, the chunk size and successor, which keeps sessions incremental. Any other graph buffers its input untilEnd.- 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);CompressorBuilderis no longerCopy. - Dictionaries: a graph with a loaded bundle writes the bundle ID and the dictionary lane (dictionaries).
- The graphs available through
with_graphare every standard graph, userSelector/FunctionGraph/Segmenterimplementations,brute_force,ml_selector,clustering,sddlandsddl2(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_STOREsends each input tostore1, which picksserial_store(the stream is marked stored) orstring_store(separate_string_components, twoserial_stores). A numeric or struct stream reachesserial_storethrough 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 tostore1. - Store on expansion: when
header + stored >= inputs (+ 4 per string)or the chunk header does not fit a capped sink, the chunk is rebuilt asZL_GRAPH_STOREat 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 throughcompress_generic. - Trial compression (
Exec::try_graph): a derivedExecwith checksums off and no comment compresses a whole frame into aCappedSinkofZL_compressBoundbytes; the result is the frame length.brute_forceuses 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.
| Status | Nodes | Graphs |
|---|---|---|
| Ported | all 75 non-legacy nodes (IDs 2 to 75, 86, 87 except 56) | all 90 standard graphs (standard graphs) |
| Feature off | the row’s encoder is DISABLED and fails with NodeError::Disabled | the row aliases UNPORTED_FUNCTION / UNPORTED_SELECTOR and fails with ExecError::Unported (field_lz, lz, partition_bitpack, merge_sorted and the entropy graphs) |
| Legacy | deprecated 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
- Node encoder. Write
fn(&mut NodeCx<'_, 'a>, &[Stream<'a>]) -> Result<(), NodeError>incore/codecs/<family>.rs, wrap it inFnEncoder, and point the node’s row inregistry/nodes.rsat it:if cfg!(feature = "x") { &codecs::x::ENCODER } else { &codecs::DISABLED }. Create outputs withcx.emit,cx.emit_with_metadata,cx.emit_strings,cx.reference(zero-copy) orcx.reference_strings; send at most one header withcx.send_header; set output metadata withcx.set_output_metadata; read parameters withcx.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. - Graph function, selector or segmenter. Implement
GraphFn,SelectFnorSegmentFnand point the row inregistry/graphs.rsat it. Inside, useGraphCx::run_node,set_destination,set_metadata,int_metadata,mparam,is_node_supported,SelectorCx::try_graphandSegmenterCx::process_chunk. - Add the codec feature to
Cargo.toml(all-codecs), toscripts/check_codec_features.py(CODECSis read fromall-codecs), gate modules, tests and dependencies together (codec features), and add a differential test (tests/encode_*.rsfor byte identity,tests/standard_graphs.rsfor 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 (
Numberstakes bytes). compress_inputswithout a graph starts atcompress_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 != 0is refused bymzstdwhile 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_lenstakes lengths throughLocalParams::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"]
| ID | Graph | Decision |
|---|---|---|
| 1 | serial_store | Store kind: marks the stream stored |
| 2 | store | function: every input to store1 (32) |
| 32 | store1 | selector: strings to string_store (33), everything else to serial_store; typed data reaches it through an implicit conversion node |
| 33 | string_store | node 26 separate_string_components, both outputs to serial_store |
| 13 | compress_generic | function, variable inputs: each input to compress1 |
| 34 | compress1 (private “compress2”) | selector by input type, table above |
| 35-38 | serial/struct/numeric/string_compress | constant selectors: zstd (9), field_lz (12), select_numeric (16), string_separate_compress (39) |
| 39 | string_separate_compress | node 26, then lengths to numeric_compress (37), content to serial_compress (35) |
| 14 | select_generic_lz_backend | always zstd (9) |
| 16 | select_numeric | level < 7 -> struct_compress; otherwise transformer_numeric1 (90) |
| 31 | brute_force | trial-compresses each compatible successor with try_graph; the smallest frame strictly below the input weight wins, otherwise store |
| 62 | n_to_n | input i goes to custom graph i |
Segmenters
| ID | Graph | Behaviour |
|---|---|---|
| 29 | segment_serial | cuts 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 |
| 15 | segmenter_numeric | cuts every 16 MiB of elements, chunks to numeric_compress |
| 24-27 | segment_num{8,16,32,64}_from_serial | serial input cut every N bytes aligned down to the width (parameters 300 size, 301 width), chunks to the interpret graphs 65-68 |
| 21 | sddl2 | see SDDL |
Entropy and packing graphs
| ID | Graph | Decision |
|---|---|---|
| 3-7 | fse, huffman, huffman_huf0, huffman_pivco, entropy | the one entropy plan, see entropy |
| 8 | constant | selector: 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 |
| 10 | bitpack | selector: numeric -> bitpack_int (41), otherwise bitpack_serial (40) |
| 11 | flatpack | node 65, both outputs to serial_store |
| 40-44 | private bitpack_serial/int, constant_serial/fixed, fse_ncount | one node then serial_store |
| 23 | partition_bitpack | below |
| 63 | private merge_sorted | function 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 |
| 69 | compress_small_lengths | below |
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
| ID | Graph | Decision |
|---|---|---|
| 19 | try_parse_int | parses 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-60 | split_serial/struct/numeric/string | run the one custom node, output i to custom graph i |
| 64 | transpose_split selector | v11 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 |
| 18 | cluster | see ML selector and clustering |
| 17 | ml_selector | see ML selector and clustering |
| 20, 21, 61 | sddl, sddl2, sddl2_chunk | see SDDL |
Numeric static pipelines
Static node-plus-successor graphs the Transformer reaches; see transformer.
| IDs | Graphs |
|---|---|
| 47-49, 65-68 | delta_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-56 | delta_huffman_internal, flatpack_internal, zstd_internal, delta_field_lz, range_pack, range_pack_zstd, tokenize_delta_field_lz |
| 70-75 | transformer_delta_int, range_pack, divide_by_gcd, tokenize_numeric(_sorted), sparse_num: the node, outputs back to transformer_numeric1 (90) |
| 76-89 | transformer_static_*: the heuristic selectors and their pipelines |
| 30, 90 | transformer_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::Unportedis 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,FunctionGraphandSegmenter; a failing user graph is reported asGraphError.
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_*.rscompare 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 withemit,emit_with_metadata,emit_strings,referenceorreference_strings, and integer output metadata withset_output_metadata. - Local parameters follow
ZL_Encoder_getLocalParam:NodeCx::int,ints,copy_param,ref_param(reference parameters carryArc-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.
| Node | Name | Codec | Versions | Inputs | Outputs | Feature |
|---|---|---|---|---|---|---|
| 2 | delta_int | 1 | 3-27 | Numeric | Numeric | delta |
| 3 | transpose_split | 4 | 11-27 | Struct | variable Serial | transpose |
| 4 | zigzag | 3 | 3-27 | Numeric | Numeric | zigzag |
| 5 | dispatchN_byTag | 42 | 9-27 | Serial | Numeric, Numeric; variable Serial | dispatch |
| 6 | float32_deconstruct | 33 | 4-27 | Numeric | Struct, Serial | float-deconstruct |
| 7 | bfloat16_deconstruct | 33 | 5-27 | Numeric | Struct, Serial | float-deconstruct |
| 8 | float16_deconstruct | 33 | 5-27 | Numeric | Struct, Serial | float-deconstruct |
| 9 | field_lz | 24 | 3-27 | Struct | Struct, Struct, Numeric, Numeric, Numeric | field-lz |
| 10 | convert_struct_to_serial | 6 | 3-27 | Struct | Serial | conversion |
| 11 | convert_num_to_struct_le | 8 | 3-27 | Numeric | Struct | conversion |
| 12 | convert_num_to_serial_le | 10 | 3-27 | Numeric | Serial | conversion |
| 13 | convert_serial_to_struct | 5 | 3-27 | Serial | Struct | conversion |
| 14 | convert_serial_to_struct2 | 5 | 3-27 | Serial | Struct | conversion |
| 15 | convert_serial_to_struct4 | 5 | 3-27 | Serial | Struct | conversion |
| 16 | convert_serial_to_struct8 | 5 | 3-27 | Serial | Struct | conversion |
| 17 | convert_struct_to_num_le | 7 | 3-27 | Struct | Numeric | conversion |
| 18 | convert_struct_to_num_be | 13 | 21-27 | Struct | Numeric | conversion |
| 19 | convert_serial_to_num8 | 9 | 3-27 | Serial | Numeric | conversion |
| 20 | convert_serial_to_num_le16 | 9 | 3-27 | Serial | Numeric | conversion |
| 21 | convert_serial_to_num_le32 | 9 | 3-27 | Serial | Numeric | conversion |
| 22 | convert_serial_to_num_le64 | 9 | 3-27 | Serial | Numeric | conversion |
| 23 | convert_serial_to_num_be16 | 14 | 21-27 | Serial | Numeric | conversion |
| 24 | convert_serial_to_num_be32 | 14 | 21-27 | Serial | Numeric | conversion |
| 25 | convert_serial_to_num_be64 | 14 | 21-27 | Serial | Numeric | conversion |
| 26 | separate_string_components | 12 | 10-27 | String | Serial, Numeric | conversion |
| 27 | bitunpack | 34 | 6-27 | Serial | Numeric | bitunpack |
| 28 | range_pack | 35 | 8-27 | Numeric | Numeric | range-pack |
| 29 | merge_sorted | 43 | 9-27 | Numeric | Numeric, Numeric | merge-sorted |
| 30 | prefix | 46 | 11-27 | String | String, Numeric | prefix |
| 31 | divide_by | 53 | 16-27 | Numeric | Numeric | divide-by |
| 32 | dispatch_string | 54 | 16-27 | String | Numeric; variable String | dispatch |
| 33 | concat_serial | 55 | 16-27 | Serial (variable count) | Numeric, Serial | concat |
| 34 | concat_num | 57 | 17-27 | Numeric (variable count) | Numeric, Numeric | concat |
| 35 | concat_struct | 58 | 17-27 | Struct (variable count) | Numeric, Struct | concat |
| 36 | concat_string | 59 | 18-27 | String (variable count) | Numeric, String | concat |
| 37 | dedup_num | 56 | 16-27 | Numeric (variable count) | Numeric | dedup |
| 38 | parse_int | 60 | 19-27 | String | Numeric | parse-int |
| 39 | interleave_string | 61 | 20-27 | String (variable count) | String | interleave |
| 40 | tokenize_struct | 36 | 8-27 | Struct | Struct, Numeric | tokenize |
| 41 | tokenize_numeric | 37 | 8-27 | Numeric | Numeric, Numeric | tokenize |
| 42 | tokenize_string | 38 | 11-27 | String | String, Numeric | tokenize |
| 43 | quantize_offsets | 25 | 3-27 | Numeric | Numeric, Serial | quantize |
| 44 | quantize_lengths | 26 | 3-27 | Numeric | Numeric, Serial | quantize |
| 45 | bitsplit_top8 | 63 | 24-27 | Numeric | variable Numeric | bitsplit |
| 46 | bitsplit_fp | 63 | 24-27 | Numeric | variable Numeric | bitsplit |
| 47 | bitsplit_bf16 | 63 | 24-27 | Numeric | variable Numeric | bitsplit |
| 48 | partition | 64 | 24-27 | Numeric | Numeric, Serial | partition |
| 49 | split_byrange | 48 | 24-27 | Numeric | variable Numeric | split |
| 50 | sentinel_byte | 18 | 24-27 | Numeric | Numeric, Numeric | sentinel |
| 51 | sentinel_num | 18 | 24-27 | Numeric | Numeric, Numeric | sentinel |
| 52 | lz | 19 | 24-27 | Serial | Serial, Numeric, Numeric, Numeric | lz |
| 53 | mux_lengths | 65 | 24-27 | Numeric, Numeric | Serial, Numeric | mux-lengths |
| 54 | sparse_num | 66 | 26-27 | Numeric | Numeric, Numeric | sparse-num |
| 55 | sparse_num_auto | 66 | 26-27 | Numeric | Numeric, Numeric | sparse-num |
| 57 | set_string_lens | 11 | 10-27 | Serial | String | conversion |
| 58 | fse_v2 | 49 | 15-27 | Serial | Numeric, Serial | entropy |
| 59 | huffman_v2 | 50 | 15-27 | Serial | Numeric, Serial | entropy |
| 60 | huffman_struct_v2 | 51 | 15-27 | Struct | Numeric, Serial | entropy |
| 61 | fse_ncount | 52 | 15-27 | Numeric | Serial | entropy |
| 62 | zstd | 22 | 3-27 | Serial | Serial | zstd |
| 63 | bitpack_serial | 27 | 3-27 | Serial | Serial | bitpack |
| 64 | bitpack_int | 28 | 3-27 | Numeric | Serial | bitpack |
| 65 | flatpack | 29 | 3-27 | Serial | Serial, Serial | flatpack |
| 66 | splitN | 40 | 9-27 | Serial | variable Serial | split |
| 67 | splitN_struct | 47 | 15-27 | Struct | variable Struct | split |
| 68 | splitN_num | 48 | 15-27 | Numeric | variable Numeric | split |
| 69 | split_by_struct | 41 | 9-27 | Serial | variable Struct | split-by-struct |
| 70 | constant_serial | 44 | 11-27 | Serial | Serial | constant |
| 71 | constant_fixed | 45 | 11-27 | Struct | Struct | constant |
| 72 | tokenize_sorted | 37 | 8-27 | Numeric | Numeric, Numeric | tokenize |
| 73 | tokenize_string_sorted | 38 | 11-27 | String | String, Numeric | tokenize |
| 74 | dedup_num_trusted | 56 | 16-27 | Numeric (variable count) | Numeric | dedup |
| 75 | bit_split | 63 | 24-27 | Numeric | variable Numeric | bitsplit |
| 76 | rolz_deprecated | 20 | 3-12 | Serial | Serial | legacy |
| 77 | fast_lz_deprecated | 21 | 3-12 | Serial | Serial | legacy |
| 78 | fse_deprecated | 15 | 3-14 | Serial | Serial | legacy |
| 79 | huffman_deprecated | 16 | 3-14 | Serial | Serial | legacy |
| 80 | huffman_fixed_deprecated | 17 | 3-14 | Struct | Serial | legacy |
| 81 | zstd_fixed_deprecated | 23 | 3-10 | Struct | Serial | legacy |
| 82 | transpose_deprecated | 2 | 3-10 | Struct | Struct | legacy |
| 83 | transpose_split2_deprecated | 30 | 3-10 | Struct | Serial, Serial | legacy |
| 84 | transpose_split4_deprecated | 31 | 3-10 | Struct | Serial, Serial, Serial, Serial | legacy |
| 85 | transpose_split8_deprecated | 32 | 3-10 | Struct | Serial, Serial, Serial, Serial, Serial, Serial, Serial, Serial | legacy |
| 86 | lz4 | 62 | 23-27 | Serial | Serial | lz4 |
| 87 | pivco_huffman | 67 | 27-27 | Serial | Numeric, Serial | pivco-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:mzstdwritesFrameFormat::Magiclessnatively, so no staging or magic overwrite exists. The default-graph fast path (ZstdNode::encode) pushes[0x01]thencompress_into. - Parameters are applied in the reference’s order: the global level, then
literalCompressionMode = Disabledwhen the decompression level is 1, then the node’s local integer parameters (rawZSTD_c_*IDs) throughCParameter::try_from,check_memory_cap(3)andwith_parameter.format(10) andcontentSizeFlag(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 theenc_zstdC 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
refCDictequivalent (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/)
| Codec | Format summary |
|---|---|
delta_int | header carries the first element (an empty input has 0 elements); deltas wrap at the width |
zigzag | (v << 1) ^ (v >> (w-1)) |
divide_by | varint divisor (divide_by plain / and %, no SIMD) |
range_pack | header: destination width and, when needed, the little-endian minimum |
quantize_offsets/lengths | codes (numeric w1) plus extra bits (serial); offsets base 1<<c, lengths c < 16 literal else 1<<(c-12) |
bitSplit | header: output width plus field widths, 512-element blocks, variable numeric outputs |
float_deconstruct | header 0/1/2 for f32/bf16/f16; sign and fraction as struct, exponent as serial |
sparse_num, sparse_num_auto | distances w1/2/4 and the dominant symbol in the header; auto chooses the dominant value |
merge_sorted | varint 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 |
partition | header 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/)
| Node | Notes |
|---|---|
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_byrange | segment 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_struct | field sizes as a reference parameter |
dispatchN_byTag, dispatch_string | tags, sizes and one stream per tag, with metadata 83; dispatch_string writes the index stream first |
concat_*, dedup_num (+ trusted), interleave_string, prefix, parse_int | multi-input nodes take a variable input count; parse_int accepts pre-parsed integers as a reference parameter |
transpose_split | the 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 bytelog2(width) << 6 | (nbBits - 1)plus a padding byte; values LSB first.nbBitscomes frombitpack_kernels::nb_bits, an OR reduction over 64-bytefearless_simdlanes (the highest set bit of the OR is that of the maximum), dispatched once oncx.simdand folded onto the element’s byte positions. Fields of up to 16 bits are packed eight at a time: eight fields are exactlynbBitsbytes, composed in au128and stored whole into a pre-sized buffer (encode_groups); wider fields use the one-fieldencode_scalar.bitunpack(27): width fromnbBits; header[nbBits]plus trailing bits from v7.flatpack(65): sorted distinct alphabet and the indices plus a terminator; eight indices (exactlybitsbytes) are composed in au64per store, the< 8tail and the terminator bit are written separately.constant(70, 71): one element and a varint count.mux_lengths(53): multiplexes literal and match lengths; headersplit | bias << 4, the bias taken from the LZ metadata.- Kernels live in
shared::bitpack_kernels(encode, decode,encode_bound, andwriter::BitWriter, the port ofZS_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_byandpartitionuse the portable kernels;merge_sortedscans k-way; SIMD variants of the reference are not ported (outputs are identical).- Encoder-side
transpose2 andtranspose_split2/4/8are 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-addressingTokenMap(linear probing, load factor 1/2) and records each element’s id while scanning, so sorting only remaps ids to ranks; strings still use aBTreeMap.transpose_splitrunsshared::byte_transpose::split: for widths 2, 4, 8 the elements are deinterleaved sixteen at a time withlog2(W)rounds ofunzip_low/highonu8x16, dispatched oncx.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
hotpathanchors 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
limitedOutputmodes). - Some shared helpers (
shared::bitpack_kernels::writer,shared::stats::countanddata_stats, parts ofshared::estimate) carryexpect(dead_code)gates for feature sets that do not use them. - The C
prefixencoder 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.
| Specification | Scope |
|---|---|
| Entropy coders | OpenZL’s FSE/Huffman fork, PivCo-Huffman, the five entropy nodes and the entropy graph’s plan. |
| LZ codecs and graphs | lz4 (fast, HC, optimal), lz, field_lz, mux_lengths, quantize, the shared lz_common infrastructure and the LZ graphs. |
| Transformer | The numeric selector: features, generated scorers, guards, depth limits, static fallbacks, float determinism and the no_std caveat. |
| ML selector and clustering | Gradient-boosted-tree selection and tag-based clustering with their CBOR configurations. |
| SDDL and SDDL2 | The description interpreter, the bytecode VM, chunking and replay. |
| Custom codecs | Application codec registration, typed and variable ports, callback contexts, custom wire IDs, validation, errors and lifetime. |
| Dictionaries | Dict, 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
| Node | Codec | Inputs | Outputs | Header |
|---|---|---|---|---|
fse_v2 58 | 49 | Serial | Numeric counts (w2) with metadata 0 = table log, Serial bits | [states 2 or 4][size], 2 states below 1000 symbols |
fse_ncount 61 | 52 | Numeric (w2) | Serial FSE_writeNCount header | none |
huffman_v2 59 | 50 | Serial | Numeric weights (w1) with metadata 0 = table log, Serial bits | [x4][size], four streams from 256 bytes |
huffman_struct_v2 60 | 51 | Struct (w2) | Numeric weights (up to 65536 symbols, log up to 20), Serial blocks | blocks [count u32][size u32][payload], one or four streams |
pivco_huffman 87 | 67 | Serial | Numeric weights (w1), Serial bit stream | varint 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::<4 or 2>: 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::refillloads 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 whatReverse::peekwould; the last symbols useReverse, which keeps the zero-fill and the over-read/exact-end rules (remaining() < 0is corruption, as is leftover withexact). - Byte Huffman:
ByteTableholds a fixed 4096-cell[u16; 4096](symbol, bits) so the masked index needs no bounds check. For outputs of 8192 bytes or morepairs()derives a table indexed by the firstlogbits (the cells past2^logstay zero), each cell holding two symbols when both codes fit in the log-bit prefix (the content of the reference’sX2table 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) andrun_lanes()runs fourPairLanes over the shared body and the output split in its four quarters. The lookup index isbits >> shiftwithshiftclamped to 52..=63, so LLVM sees an index below 4096 (no check) and, on the BMI2 levels, uses the non-destructiveshrx. The lane keeps its unread bits left-aligned with a 1 bit under them, so the bits eaten since the last load aretrailing_zerosand 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 bytz >> 3(at most 8) after one length check, andlast_chunk::<8>then needs none. The output of a lane is the unwritten rest of its quarter: a windowfirst_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 (lookupjof every lane beforej + 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 anx4payload (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:
Tableis a fixed[Cell; 4096]plus afastflag, set when no symbol owns more than half of the table (the reference’sFSE_decompress_usingDTable_fastcondition), 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_quadskeeps 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:
HufCTablecarriesentries, two[u64; 256]tables ofTopWriterentries ([0]the code left-aligned with the code length in the low byte,[1]the code alone; two scale-8 lookups per symbol).TopWriteris the reference’sHUF_addBitsscheme: add is one shift by the entry, oneorand one add;add_groupadds 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 oneorper group; five symbols per flush when the table log is 11 or less, four otherwise. The four streams ofcompress4xshare one writer and buffer (close_into).fse::CTablehas fixed[u16; 4096]and[SymbolTransform; 256]tables, borrowed once per call (Tables) so the loop does not reload them through the struct, andcompress_states::<STATES>keeps the states in locals and writes four symbols between flushes into aWordWriterwhose 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).count8loads 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
X2symbols (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 (seebenchmark-comparison.mdfor 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, andflatat 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::<S, FIELD or DEPTH>"]
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
| Node | Codec | Output | Header | Mechanics |
|---|---|---|---|---|
lz4 86 | 62 | raw LZ4 block | varint(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 52 | 19 | literals (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 9 | 24 | literals (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 53 | 65 | muxed bytes, overflow lengths | split_point OR bias << 4 | one 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 44 | 25, 26 | codes (numeric w1), extra bits (serial, LSB first) | none | offsets: 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
byU16table with a 4-byte 13-bit hash; larger inputsbyU32with 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 au16so no bounds check remains);insertwalkswindows(4)over one pre-sliced range.Hc::insert_and_get_wider_matchis#[inline(always)](each call site gets its own copy with no call frame, as the C compiler does) and generic over the reference’spatternAnalysisandchainSwapflags (<PATTERN, SWAP>): levels 3 to 7 run<false, false>, levels 8 and 9<true, false>(chosen once incompress_hash_chainbymax_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 CLZ4_read16(iLowLimit + longest - 1)) and the matching index adjustment in locals that change only whenlongestdoes;peek16returns-2for a read outside the source (a negativeLZ4HC_back position wraps and failsget), which never equalshead16’s-1miss value or a byte pair. Only a candidate that passes the 16-bit and 32-bit tests reachesextend(inlined:count_back, thencount), which keeps the walk’s register set small.count_backcompares 8 bytes with a single read ending at the positions (most differences lie there) and falls tocount_back_longonly for longer runs or positions within 8 bytes of the start;countprobes the first 8 bytes inline and falls tocount_long(8-byteas_chunks) otherwise.peek16,countandcount_backbound their reads with one or three comparisons and fall to the long loops outside them. The reads areget-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:fast_sequences(inlined into the block loop) decodes a sequence while the unread input holdsIN_WINDOW(288) bytes and the output hasOUT_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-linecopy_long_match(32-byte chunks, sequential 16-byte chunks for overlap,copy_matchbelow offset 16). Keeping that code out of line keeps the loop’s positions in registers. It stops once the position reachesMAX_OFFSET(65535). 1b.far_sequences(out of line,#[inline(never)]) takes over fromMAX_OFFSETbytes of output on. Then everyu16offset is valid (theoffset > writtencheck 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 index65535 + literals - offset, so every copy is a constant-lengthcopy_withinover 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, asLZ4_decompress_genericdoes (right for any such offset, one predictable condition); longer matches usechunked::<32|16|8>by offset class (each chunk reads only final bytes, so in-order chunks reproduce an overlapping copy); offsets below 8 callcopy_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).- The general loop keeps the reference’s checks for everything else (longer
extensions, the end of the block, blocks shorter than the windows).
copy_literalsandcopy_matchuse 16-byte chunk copies when the output has the surplus room (the surplus is overwritten by later bytes),memmovefor 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.extendedfinds the end of a 255-run with onepositionscan. 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 safeVec;lz4_flexwas 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_lengthsandZS_Reps_update4produce the same bytes as the scalar ports here (mux_lengthsis 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.rsdecodes frames of the LZ4 graph and of thelzandfield_lznodes the C library writes; the standardlz(28) andfield_lz(12) graphs are covered bytests/encode_lzgraphs.rsandtests/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:
offset_codesgathers the four 2-bit offset codes with one multiply;resolve_groupresolves the four offsets and the new repeat offsets from theOFFSET_PLANStable (the scalar counterpart ofZS_kOffsetShuffleplus_pext_u64), with a shortcut when all four are explicit.short_grouphandles 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 returnsNoneand the group is redone by the general path (same bytes, same validity decision).- The general path copies literals and matches in 16-byte blocks (
copy_matchfor offsets under 16). A token with an extra length hands the rest of the group tofinish/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.
statsimplements the KMV sample (K = 256), the sorted-gap mode, thematch4hash table (4096 entries,xxh3::hash32) and the transition-gap coefficient of variation;cardinalitythe bitmap and HyperLogLog estimators.scorers/num{8,16,32,64}.rshold the weight tables and the per-width feature expressions. They are generated byscripts/gen_transformer_scorers.pyfrom the vendored C (score_numeric{8,16,32,64}.cand the feature headers): the script re-emits eachf32with 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-writtenscorers/mod.rs(two dense layers, ReLU, sigmoid clamped at +-15). Rerun it,cargo fmtand commit whenever the vendored scorers change.- Guards (
apply_score_guards) zero the score of:DivideByGcdwhenmin_lb0 == 0ormin_u == 1; the tokenize operations when range densitycardinality / (range + 1)exceeds the float-rounded 0.9;RangePackwhen 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
(
floatnetwork,doublefeature expressions);mul_addis 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.rsroutesln,log2,expfandsqrtto the platform libm withstd, and to thelibmcrate withno_std.- no_std caveat.
libm::expfdiffers by one ULP from the platformexpffor some inputs, andshared::estimate(log,exp,pow) can differ in the last bit too. Ano_stdbuild 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).sqrtis correctly rounded everywhere. Thelibmcrate is an optional dependency enabled byno_std;shared::estimateis compiled withcompressionin every build. - Wide arithmetic (
wide.rs) uses nativeu128with the reference’s release-build saturation (a quotient that does not fit isu64::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_stddeterminism, 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’sunsignedproducts, 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).
f32sums, tree by tree. A node splits onfeature < valuetoleft, otherwiseright, and takesmissingfor 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_validatewould 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.pydoes not list these modules (they use nounsafeorMaybeUninit). - 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). DictBundleconverts 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_dictionariesmust come after the nodes are parameterized (the reference quirk:CDictMgr_findMaterializerneeds a node that references the dictionary), loads one bundle per compressor and reportsGraphError::{Dictionary, BundleAlreadyLoaded, DictionaryNotFound}.- Only Zstandard nodes (codec 22) materialize; a dictionary no such node
references cannot be loaded. Without the
zstdfeature 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::insertmaterializes at insertion (a ZstandardDecodeDictionary), 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)takesimpl Into<Arc<BundleStore>>(the builder is no longerCopy). Errors:DecodeError::MissingBundle { bundle }andInvalidDataKind::DictionaryIndex;Unsupported::Dictionaryno 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.
| Specification | Scope |
|---|---|
| Decompressor | Frame and unit parsing, chunk graph layout, the typed node executor, typed multi-output decoding, dictionaries, sessions, limits, I/O and errors. |
| Decoder codecs | Every 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, whileVecdestinations (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
iis streamend - 1 - i.VO Tmeans a variable number ofTinputs.
| ID | Codec | Feature | Since | Regenerates | Inputs |
|---|---|---|---|---|---|
| 1 | delta_int | delta | 3 | Numeric | Numeric |
| 2 | transpose | transpose | 3 | Struct | Struct |
| 3 | zigzag | zigzag | 3 | Numeric | Numeric |
| 4 | transpose_split | transpose | 11 | Struct | VO Serial |
| 5–14 | conversions | conversion | 3/10/21 | see conversion.rs | |
| 18 | sentinel | sentinel | 24 | Numeric | Numeric values, Numeric exceptions |
| 19 | lz | lz | 24 | Serial | Serial literals, Numeric offsets, literal lengths, match lengths |
| 22 | zstd | zstd | 3 | Serial | Serial |
| 24 | field_lz | field-lz | 3 | Struct | Struct literals, Struct tokens (w2), Numeric offsets, extra literal and match lengths (w4) |
| 25, 26 | quantize_offsets / lengths | quantize | 3 | Numeric (u32) | Numeric codes (w1), Serial extra bits |
| 27 | bitpack_serial | bitpack | 3 | Serial | Serial |
| 28 | bitpack_int | bitpack | 3 | Numeric | Serial |
| 29 | flatpack | flatpack | 3 | Serial | Serial alphabet, Serial packed |
| 30–32 | transpose_split2/4/8 | transpose | 3 | Struct | 2/4/8 Serial |
| 33 | float_deconstruct | float-deconstruct | 4 | Numeric | Struct sign+fraction, Serial exponent |
| 34 | bitunpack | bitunpack | 6 | Serial | Numeric |
| 35 | range_pack | range-pack | 8 | Numeric | Numeric |
| 36 | tokenize_fixed | tokenize | 8 | Struct | Struct alphabet, Numeric indices |
| 37 | tokenize_numeric | tokenize | 8 | Numeric | Numeric alphabet, Numeric indices |
| 38 | tokenize_string | tokenize | 11 | String | String alphabet, Numeric indices |
| 40, 47, 48 | splitn / _struct / _num | split | 9/14/14 | Serial / Struct / Numeric | VO of the same type |
| 41 | splitByStruct | split-by-struct | 9 | Serial | VO Struct |
| 42 | dispatchN_byTag | dispatch | 9 | Serial | Numeric tags, Numeric sizes, VO Serial |
| 43 | merge_sorted | merge-sorted | 9 | Numeric | Numeric bitsets, Numeric merged |
| 44, 45 | constant_serial / fixed | constant | 11 | Serial / Struct | one element |
| 46 | prefix | prefix | 11 | String | String suffixes, Numeric match sizes (u32) |
| 49 | fse_v2 | entropy | 15 | Serial | Numeric counts (w2), Serial bits |
| 50 | huffman_v2 | entropy | 15 | Serial | Numeric weights (w1), Serial bits |
| 51 | huffman_struct_v2 | entropy | 15 | Struct (w2) | Numeric weights (w1), Serial bits |
| 52 | fse_ncount | entropy | 15 | Numeric (w2) | Serial |
| 53 | divide_by | divide-by | 16 | Numeric | Numeric |
| 54 | dispatch_string | dispatch | 16 | String | Numeric indices, VO String |
| 55, 57–59 | concat_serial / num / struct / string | concat | 16–18 | T … | Numeric sizes (w4), T |
| 56 | dedup_num | dedup | 16 | Numeric … | Numeric |
| 60 | parse_int | parse-int | 19 | String | Numeric (w8) |
| 61 | interleave_string | interleave | 20 | String … | String |
| 62 | lz4 | lz4 | 23 | Serial | Serial |
| 63 | bitSplit | bitsplit | 24 | Numeric | VO Numeric |
| 64 | partition | partition | 24 | Numeric | Numeric bucket IDs (w1), Serial offset bits |
| 65 | mux_lengths | mux-lengths | 24 | Numeric, Numeric | Serial muxed, Numeric long lengths |
| 66 | sparse_num | sparse-num | 26 | Numeric | Numeric, Numeric |
| 67 | pivco_huffman | pivco-huffman | 27 | Serial | Numeric 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, withcextra bits. - Lengths: below code 16 the value is the code itself; from 16 on it is base
1 << (c - 12)withc - 12extra 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.
nstored widths withninput streams means partial coverage; withn + 1streams 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
wcolumns ofnbytes. - The output is
nrecords of widthw. - Both transposes call
shared::byte_transpose::mergewithctx.state.simd: for widths 2, 4, 8 sixteen elements are rebuilt per step withlog2(w)rounds ofinterleaveonu8x16; 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 indeximust equal the number of strings in streami.
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. nis also the regenerated stream count.- Stream
jtakes stringsj,j + n, and so on.
mux_lengths
- The header byte holds
split = h & 15(at most 8) andbias = 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
1bit ends the stream. - The alphabet has at most 256 symbols.
- Eight indices span exactly
bitsbytes: 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:
- initialise every state;
- decode round-robin across the states;
- tail: keep emitting while a state needs no bits;
- flush the remaining
states - 1symbols.
- 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 holdsIN_WINDOW(288) bytes and the output hasOUT_WINDOW(640) bytes of room afterop. 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, onefirst_chunk_mutper sequence): everyu16offset is valid there, literal and match indices are constants plusliterals,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 usecopy_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 callscopy_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 callcopy_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
nbBitsis 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.
| Specification | Scope |
|---|---|
| Framed containers | Opt-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"]
| Structure | Fields |
|---|---|
| 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. |
| Metadata | Repeated 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. |
| Directory | ASCII 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
Readconcatenation 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.
| Corpus | Input bytes | Raw OpenZL bytes | Container bytes | Rust / C encode | Rust / C decode |
|---|---|---|---|---|---|
| tiny | 64 | 84 | 188 | 2.128 / 8.826 µs | 0.443 / 0.497 µs |
| C source text | 571,256 | 116,328 | 116,848 | 2.963 / 3.108 ms | 0.290 / 0.266 ms |
| structured records | 1,048,576 | 142,713 | 143,597 | 4.207 / 3.673 ms | 0.620 / 0.542 ms |
| incompressible | 262,144 | 262,240 | 262,500 | 71.463 / 114.340 µs | 36.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.
| Specification | Scope |
|---|---|
| Compatibility | The C oracle and its shims per family, byte-identity and decode differential tests, and what each test file covers. |
| Fuzzing | AFL targets, oracles, regression replay and the libFuzzer adapters. |
| Implementation comparison | Criterion and Silesia harness methodology (standard graphs and custom callbacks against C) and recorded results. |
| Continuous integration | Workflows, feature probes, AFL smoke runs, Miri and AddressSanitizer selection, coverage gate, hotpath reports and releases. |
| Documentation publishing | Source 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
| File | Covers |
|---|---|
differential_c.rs | Default 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.rs | Registered 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.rs | Graph 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.rs | Every 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.rs | Zstandard node: local parameter lists, levels, decompression level 1, block splitting vs enc_zstd, formats |
encode_numeric.rs | Numeric 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.rs | tokenize, split, split_by_struct, dispatch, concat, dedup, interleave, prefix, parse_int, transpose nodes and their graphs; estimators |
encode_backends.rs | lz4 (fast, HC, optimal), bitpack, bitunpack, flatpack nodes and graphs |
encode_entropy.rs | fse, huffman, 16-bit huffman, pivco, constant, fse_ncount nodes; graphs 3 to 8 |
encode_lz.rs | field_lz, lz, mux_lengths, quantize_* nodes |
encode_lzgraphs.rs | Graphs 12, 28, 45, 46, 69 and compress_generic on struct and numeric input |
encode_transformer.rs | Graphs 16, 30, 70 to 90 over widths 1, 2, 4, 8 and many data shapes |
encode_ml.rs | ML selector and clustering graphs, CBOR codec, configuration behaviour |
encode_sddl.rs | SDDL, SDDL2 and the chunk graph, hand-built programs from tests/support/sddl.rs |
encode_dict.rs | Dictionary 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.rs | Decoders against frames the C encoder writes, including damaged frames (accepted or rejected as C does, never a panic) |
typed_outputs.rs | Typed and multi-output frames against the C library |
docs_examples.rs | Every 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.rs | Public 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).
prefixon an all-empty string input cannot be compared (the C encoder crashes).- Segmented multi-chunk dictionary frames are round-trip tested only.
targetCBlockSizeis accepted by C zstd and refused bymzstd; 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]
| Target | Oracle |
|---|---|
framed_decode | Bounded 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_roundtrip | Up 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_codec | Four-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. |
decompress | Arbitrary 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_typed | Arbitrary 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_roundtrip | One 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_configs | Selector 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_roundtrip | Any 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. |
roundtrip | Byte identity with ZL_CCtx_compress for the header’s level, version, checksums and chunking; round trip through one-shot and pieces. |
parallel | Two-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_compress | Sessions 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):
| Family | Decoder | Encoder |
|---|---|---|
| zstd | 1/1 | 2/2 |
| entropy (fse, huffman, ncount), pivco huffman | 28/28, 11/13 + 11/11 | 18/18, 27/27 |
| lz, lz4, field_lz | 5/6, 5/5, 6/7 | lz 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, constant | 12/12, 2/5, 3/3, 2/2 | 2/2, 1/1, 3/3, 3/3 |
| delta, zigzag, divide_by, range_pack, bitsplit, float_deconstruct | 9/9, 9/9, 10/10, 15/16, 3/3, 1/1 | 2/2, 2/2, 4/5, 3/3, 11/11, 7/7 |
| sparse_num, sentinel, partition, merge_sorted, quantize, mux_lengths | 5/5, 2/3, 10/13, 3/4, 8/8, 9/9 | 8/10, 4/4, 18/18, 6/8, 6/6, 8/8 |
| conversion, split, split_by_struct, dispatch, concat, dedup, interleave | 6/7, 2/2, 2/2, 7/7, 5/5, 2/2, 1/1 | 19/20, 12/12, 2/3, 7/10, 2/2, 3/3, 3/3 |
| prefix, tokenize, parse_int, transpose | 4/4, 11/20, 4/4, 9/9 | 2/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 thatZL_decompressdecodes to 65536 bytes and mopenzl rejected withInvalidData { 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 intests/public_api.rs.node_roundtrip:partition(node 48) with a custom partition (copy parameter 121) holding a size above2^63:size.next_power_of_two()inPartition::bits(src/compressor/core/codecs/numeric/partition.rs:137) panics withattempt to add with overflowin a debug build (cargo afl test, libFuzzer) and wraps in release. Fixed: the widths followZL_nextPow2(64 above2^63); the input isregressions/node_roundtrip/finding-partition-bits-add-overflow.bin.node_roundtrip:dispatch_string(node 32) with a copy parameter of fewer than2 * stringsbytes. The C encoder reads oneu16per 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 asregressions/node_roundtrip/finding-node32-dispatch-string-209-outputs.bin. Trusted deduplication (dedup_num_trusted,dedup_numwithTRUST_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 ofgraph_configs. typed_roundtripdoes not compare frames with C; byte identity is covered bynode_roundtripper node and by the tests per graph.- Dictionary frames reach the decoders only through
graph_configsround trips, not throughdecompress. - 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:
| Case | Rust compress | C compress | Rust decompress | C decompress |
|---|---|---|---|---|
| tiny-64B L1 | 44.6 MiB/s | 9.9 MiB/s | 1.01 GiB/s | 136 MiB/s |
| c-source-1MiB L1 | 788 MiB/s | 814 MiB/s | 2.27 GiB/s | 2.26 GiB/s |
| c-source-1MiB L6 | 192 MiB/s | 203 MiB/s | 2.45 GiB/s | 2.48 GiB/s |
| records-1MiB L1 | 717 MiB/s | 711 MiB/s | 1.38 GiB/s | 1.64 GiB/s |
| records-1MiB L6 | 279 MiB/s | 283 MiB/s | 2.11 GiB/s | 2.36 GiB/s |
| random-256KiB L1 | 8.19 GiB/s | 4.63 GiB/s | 15.8 GiB/s | 9.93 GiB/s |
| random-256KiB L6 | 5.49 GiB/s | 3.13 GiB/s | 15.7 GiB/s | 9.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:
| Case | slice | uninit | Vec (decompress) | C |
|---|---|---|---|---|
| c-source-1MiB L1 | 2.10 GiB/s | 2.05 GiB/s | 2.09 GiB/s | 2.13 GiB/s |
| c-source-1MiB L6 | 2.39 GiB/s | 2.29 GiB/s | 2.29 GiB/s | 2.40 GiB/s |
| records-1MiB L1 | 1.39 GiB/s | 1.37 GiB/s | 1.76 GiB/s | 1.62 GiB/s |
| records-1MiB L6 | 2.16 GiB/s | 2.16 GiB/s | 2.48 GiB/s | 2.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.
| Case | Before | After | C (after) |
|---|---|---|---|
| decompress c-source-1MiB L1, slice | 1.995 GiB/s | 2.269 GiB/s | 2.312 GiB/s |
| decompress c-source-1MiB L1, uninit | 2.171 GiB/s | 2.270 GiB/s | |
| decompress c-source-1MiB L6, slice | 2.413 GiB/s | 2.517 GiB/s | 2.561 GiB/s |
| decompress records-1MiB L1, slice | 1.416 GiB/s | 1.462 GiB/s | 1.681 GiB/s |
| decompress records-1MiB L1, Vec | 1.854 GiB/s | 1.850 GiB/s | 1.681 GiB/s |
| decompress tiny-64B L1, slice | 613 MiB/s | 664 MiB/s | 136 MiB/s |
| compress c-source-1MiB L6 | 175.6 MiB/s | 197.2 MiB/s | 208.3 MiB/s |
| compress records-1MiB L6 | 264.5 MiB/s | 278.9 MiB/s | 284.6 MiB/s |
| compress tiny-64B L1 | 42.4 MiB/s | 43.6 MiB/s | 10.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:
| Graph | Compress | Decompress | Before: compress | Before: decompress |
|---|---|---|---|---|
| zstd | 0.989× | 0.957× | 0.951× | 0.963× |
| lz4 | 0.954× | 0.918× | 0.784× | 0.305× |
| lz | 0.834× | 1.465× | 0.489× | 0.436× |
| huffman | 0.936× | 0.782× | 0.397× | 0.197× |
| fse | 0.955× | 0.881× | 0.437× | 0.294× |
| entropy | 0.896× | 0.774× | 0.401× | 0.198× |
| huffman_pivco | 1.053× | 1.026× | 0.092× | 0.051× |
| flatpack | 1.472× | 1.490× | 0.734× | 1.432× |
| generic | 0.976× | 0.960× | 0.972× | 0.947× |
| field_lz_struct4 | 0.827× | 0.763× | 0.598× | 0.323× |
| bitpack_num16 | 1.774× | 2.248× | 0.078× | 0.683× |
| numeric16 | 0.943× | 0.798× | 0.483× | 0.269× |
| numeric32 | 0.827× | 0.706× | 0.557× | 0.368× |
| generic_num16 | 0.878× | 0.826× | 0.508× | 0.282× |
| generic_num32 | 0.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
Vecbefore writing it (about 3 to 10% of a stage): skipping that needsset_len, which the crate forbids. shared::stats::histogram::count8is 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
lzdecoding 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:
| Corpus | Input bytes | Frame bytes | Rust encode | C encode | Rust decode | C decode |
|---|---|---|---|---|---|---|
| tiny text | 64 | 93 | 1.11 | 5.14 | 0.61 | 0.73 |
| vendored C source | 571256 | 571289 | 45.46 | 68.34 | 44.64 | 54.55 |
| structured records | 1048576 | 1048609 | 82.94 | 129.64 | 87.88 | 103.90 |
| random bytes | 262144 | 262177 | 26.14 | 38.97 | 20.78 | 26.87 |
| zero bytes | 1048576 | 1048609 | 86.52 | 122.67 | 89.30 | 103.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 / C | Rust 1 task | Rust 2 requested | Rust 4 requested | Rust streaming 4 | C serial |
|---|---|---|---|---|---|---|
| C source prefix (64) | 84 / 84 | 2.24 µs | 15.9 µs | 16.0 µs | 68.5 µs | 6.66 µs |
| Vendored C sources (571,256) | 121,154 / 121,154 | 885 µs | 855 µs | 420 µs | 486 µs | 721 µs |
| Structured records (1,048,576) | 130,089 / 130,089 | 2,549 µs | 1,355 µs | 696 µs | 939 µs | 927 µs |
| Pseudorandom bytes (262,144) | 262,168 / 262,168 | 39.8 µs | 60.2 µs | 63.5 µs | 114 µs | 51.9 µs |
| Zeros (4,194,304) | 652 / 652 | 276 µs | 162 µs | 106 µs | 375 µs | 498 µ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.
| Graph | Corpus | Output bytes, Rust / C | Rust 4 requested | C serial |
|---|---|---|---|---|
| Store | tiny-64B | 84 / 84 | 18.754 µs | 6.4709 µs |
| Store | c-source-1MiB | 571,306 / 571,306 | 247.53 µs | 103.79 µs |
| Store | records-1MiB | 1,048,639 / 1,048,639 | 257.93 µs | 104.67 µs |
| Store | random-256KiB | 262,168 / 262,168 | 60.233 µs | 50.098 µs |
| Store | zeros-4MiB | 4,194,524 / 4,194,524 | 280.41 µs | 383.30 µs |
| Zstd | tiny-64B | 96 / 96 | 19.263 µs | 6.4678 µs |
| Zstd | c-source-1MiB | 121,154 / 121,154 | 492.30 µs | 760.09 µs |
| Zstd | records-1MiB | 130,089 / 130,089 | 1.0563 ms | 946.19 µs |
| Zstd | random-256KiB | 262,186 / 262,186 | 65.919 µs | 62.730 µs |
| Zstd | zeros-4MiB | 652 / 652 | 134.73 µs | 522.78 µs |
| Lz4 | tiny-64B | 94 / 94 | 24.128 µs | 6.0870 µs |
| Lz4 | c-source-1MiB | 138,274 / 138,274 | 1.0600 ms | 2.1148 ms |
| Lz4 | records-1MiB | 341,418 / 341,418 | 799.40 µs | 2.7486 ms |
| Lz4 | random-256KiB | 263,205 / 263,205 | 2.5873 ms | 2.8799 ms |
| Lz4 | zeros-4MiB | 16,972 / 16,972 | 148.71 µs | 639.33 µs |
| Huffman | tiny-64B | 84 / 84 | 54.807 µs | 7.2728 µs |
| Huffman | c-source-1MiB | 353,365 / 353,365 | 388.85 µs | 665.61 µs |
| Huffman | records-1MiB | 363,103 / 363,103 | 348.23 µs | 853.60 µs |
| Huffman | random-256KiB | 262,196 / 262,196 | 117.43 µs | 81.186 µs |
| Huffman | zeros-4MiB | 1,308 / 1,308 | 2.7543 ms | 1.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
MaybeUninitpath needs an explicit entry inMIRI_LIBand a matchingASAN_LIBentry inscripts/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.pyand diffs its output, so a drift between the vendored scorers and the generated Rust is not detected. - The clippy and docs matrices toggle
zstdand “no codecs” only, not every codec feature subset; thefeaturesjob 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:
| Command | Behavior |
|---|---|
prepare | Recreate the staged source tree, navigation, and bundled diagram assets |
build | Prepare, build HTML, then check local links and heading anchors |
serve | Prepare, then serve the generated tree on loopback port 3000 |
check-links | Validate a previously built HTML tree |
test-examples | Compile and run Rust recipes in a separate Cargo consumer |
Source and link flow
flowchart TD
Copy[Copy docs and architecture] --> Links[Inspect destinations outside fenced code]
Links --> Local{Target in published directories?}
Local -->|yes| Relative[Keep relative link]
Local -->|no| GitHub[Link to source on GitHub main]
Relative --> Summary[Generate SUMMARY with every Markdown page and the section pages]
GitHub --> Summary
Summary --> Complete{All source pages indexed?}
Complete -->|no| Fail[Fail preparation]
Complete -->|yes| Render[mdBook preprocessors and HTML renderer]
Render --> Validate[Validate href and src paths, HTML IDs and README site URLs]
Validate --> Artifact[Publishable static directory]
The generated SUMMARY.md has 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.
Navigation
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.
servewatches generated files. Restart the command after editing canonical sources so preparation runs again.- The checker validates local destinations and anchors, not external network URLs or Mermaid syntax. Browser QA is needed for rendered diagrams.
- The diagram dialog and theme have no automated test; they were checked in a desktop browser.
- 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
| Path | Contents |
|---|---|
src/compressor, src/decompressor | Public codecs; private core implementations |
src/graph.rs, src/input.rs | Graph construction and typed borrowed inputs |
src/framing | Opt-in independent-resource container and seek index |
src/custom_codec, src/dict | Application codecs and external dictionary bundles |
src/shared | Wire parsing, checksums, shared scalar/SIMD kernels |
tests, examples, benches | Public behavior, runnable APIs and Criterion comparisons |
benchmarks/comparison | Isolated graph/typed-view Silesia C comparison |
openzl-ffi | C oracle, shims and the three pinned upstream submodules |
fuzz/afl, fuzz/libfuzzer | Isolated fuzz packages and regression replay |
.clusterfuzzlite | ClusterFuzzLite image and build script |
docs, architecture | Canonical guides, data and implementation specifications |
book.toml, book-theme | mdBook renderer, local diagram assets and styling |
scripts/docs.py, scripts/test_docs.py | Staging, 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.
| Workload | Harness | Results |
|---|---|---|
| Serial bytes and sessions | Criterion compress/decompress | Per-level IDs and printed frame sizes |
| Standard graphs and typed inputs | Isolated Silesia comparison | Encoder matrix, decoder matrix |
| Parallel batches and streaming | Criterion parallel | Recorded parallel results |
| Independent framed resources | Criterion 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:
- Builds and validates the book on pull requests and pushes to
main. - Compiles and runs the documented recipes.
- Uploads the static book as a Pages artifact.
- Deploys only on
mainpushes or a manual run onmain.
Pull requests validate without publishing or receiving Pages write permissions.
Deployment uses the github-pages environment and GitHub’s Pages actions;
there is no generated-content branch. The site lives under /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.