Brotli compression and decompression in safe Rust
mbrotli is a port of Google's Brotli encoder with a Rust-native API, plus a native Rust decoder. Reuse working memory between payloads, plug into Read and Write, drive either codec with your own buffers, and schedule parallel compression on your own threads.
A round trip
[dependencies]
mbrotli = "0.5.21"
use mbrotli::{Compressor, Decompressor, EncoderConfig, Quality};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let input = "hello, smaller world! ".repeat(1000);
let mut encoder = Compressor::new(EncoderConfig::default().with_quality(Quality::Q5))?;
let compressed = encoder.compress(input.as_bytes())?;
let mut decoder = Decompressor::new(Default::default())?;
assert_eq!(decoder.decompress(&compressed)?, input.as_bytes());
Ok(())
}
Requires Rust 1.89 or later. Getting started continues from here with memory reuse, streaming and feature selection.
Pick a quality
Quality is the first decision: the default is 11, the slowest and smallest. Each bar is the compressed size of the 211 MB Silesia corpus against its original size.
| Quality | Compressed size | Compress | Decompress |
|---|---|---|---|
| 0 | 37.0% | 549 MB/s | 585 MB/s |
| 2 | 32.1% | 186 MB/s | 703 MB/s |
| 5 | 28.1% | 78.2 MB/s | 788 MB/s |
| 8 | 27.0% | 21.8 MB/s | 862 MB/s |
| 11 | 23.8% | 1.23 MB/s | 713 MB/s |
mbrotli 0.5.20 in lzbench 2.4.1 on one thread of an AMD Ryzen 9 7950X VDS. Your data and machine will differ.
See the lzbench run for the command and raw output, and benchmark results for every quality against Google’s C implementation and other Rust crates.
Find your path
no_std.
Decode untrusted inputInput, output and workspace budgets.
StreamingReaders, writers, sans-I/O sessions and finish.
Parallel compressionOne input across scoped threads, Rayon or your executor.
Framing containers experimentalSeveral resources and metadata in one container.
How it worksOwnership, dispatch and state machines, with diagrams.
What you can rely on
- Safe implementation, not a C wrapper. Production code forbids
unsafe; SIMD goes throughfearless_simd. Google’s C library is only a test and benchmark oracle. - Reference-compatible output. Qualities 0–11 are compared byte for byte with Google Brotli under equivalent streaming settings. See what is claimed and how it is checked.
- No hidden I/O or threads. Sessions touch only the buffers you pass in, and parallel compression hands tasks to your scheduler. The library does not create a thread pool.
- Reusable state. A codec keeps its workspace between payloads, under a retention policy you choose.
Framing and other formats behind the experimental feature may change in a
patch release.
Getting started
Build a complete round trip, then choose the API that fits your application. Requires Rust 1.89+.
Install
cargo new brotli-demo
cd brotli-demo
cargo add mbrotli@0.5.21
Or add this to Cargo.toml:
[dependencies]
mbrotli = "0.5.21"
Compress and decompress
Replace src/main.rs with this complete program:
use mbrotli::{Compressor, Decompressor, EncoderConfig, Quality};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let input = "hello, smaller world! ".repeat(1000);
let config = EncoderConfig::default().with_quality(Quality::Q5);
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 quality explicitly: the default is Q11. Higher quality spends more CPU searching for compression opportunities. Compare speed and output size on your data; the benchmark pages show how results vary with input and quality.
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 mbrotli::{Compressor, Decompressor, EncoderConfig, Quality};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut encoder = Compressor::new(
EncoderConfig::default().with_quality(Quality::Q5),
)?;
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
Brotli stream. Dropping the writer does not finalize it.
use mbrotli::{Compressor, Decompressor, EncoderConfig, InputSize, Quality};
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_quality(Quality::Q5),
)?;
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(mbrotli::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 and both codecs. To build only one codec,
drop std, or enable framing and other experimental formats, see
Cargo features.
Next steps
- Sans I/O compression and decompression: caller buffers, progress counts, and completion.
- Read / Write I/O: readers and writers for raw, parallel, and framed streams.
- Parallel compression: scoped threads, Rayon, files, and streaming.
- Framing containers: multiple resources, metadata, and indexed reads.
- Choose your API: every entry point for both codecs, with its guide.
- Configuration and features: quality, window, and Cargo features.
- Decompression and limits: budgets for untrusted input.
- Examples: pick a recipe and run the repository examples.
Choose your API
Compression and decompression expose the same I/O shapes. Pick the row that matches how your application already moves bytes, then follow its guide. New to the crate? Start with getting started.
Both codecs
| I/O shape | Compression | Decompression | Guide |
|---|---|---|---|
Return a new Vec<u8> | Compressor::compress | Decompressor::decompress | Getting started |
| Append to an existing Vec | compress_into | decompress_into | Buffers and memory reuse |
| Write into a caller-owned slice | compress_to_slice | decompress_to_slice | Buffers and memory reuse |
Pull output through std::io::Read | Compressor::reader | Decompressor::reader | Read / Write I/O |
Push input through std::io::Write | Compressor::writer | Decompressor::writer | Read / Write I/O |
| Drive input and output incrementally | start → EncoderSession | start → DecoderSession | Sans I/O compression, decompression |
| Same, with the session owning the codec | into_session → EncoderSessionOwned | into_session → DecoderSessionOwned | Sans I/O compression, decompression |
Compressor and Decompressor own reusable working state. Keep one alive
across payloads when allocation reuse matters.
Parallel compression
ParallelCompressor is a separate execution strategy for compression: it
splits the input into independent segments and hands them to your executor as
tasks. The library does not create a thread pool. The output is one ordinary
Brotli stream, decoded by the ordinary Decompressor. Requires std.
| I/O shape | Entry point |
|---|---|
| Split one known input across your threads | prepare_slice / prepare_source → ScopedParallelBatch |
| Stream chunked input across your threads | start → ParallelEncoderSession + ParallelTaskQueue |
| Same, with the session owning the compressor | into_session → ParallelEncoderSessionOwned |
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, budgets, disk staging, and streaming.
Guides by task
| Task | Read |
|---|---|
| Pick a quality, window, or Cargo features | Configuration and features |
| Control allocations and retained memory | Buffers and memory reuse |
| Decode untrusted input with resource limits | Decompression and limits |
Understand readers, writers, sessions, and finish | Streaming and completion |
| Connect readers and writers for every format | Read / Write I/O |
| Drive compression with caller buffers | Sans I/O compression |
| Decode arriving chunks and framed events | Sans I/O decompression |
| Split a job across workers | Parallel compression |
| Write or decode framing containers | Framing containers |
| Share a dictionary or use extended formats | Dictionaries and extended formats |
| Copy a complete program | Examples |
Errors
ConfigError covers configuration, DictionaryError covers dictionary
preparation, EncodeError covers compression, and SizeOverflow covers
size-bound arithmetic. 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 Google Brotli, and how it is checked
- Benchmark results for every quality, with raw data
- Architecture: how the implementation works
- Development and benchmarking for contributors
Examples
The recipes below include complete programs and round-trip assertions. Examples
use the default std, compression, and decompression features unless stated
otherwise. The documentation workflow compiles and runs their Rust code against
the current checkout.
| Task | Recipe | Features / extra dependency |
|---|---|---|
| Compress and decompress bytes | First round trip | Default |
| Reuse codec and destination memory | Reuse the workspace | Default |
| Pick a quality | Choose a quality first | Default |
| Write into a caller-owned slice | Output buffers | Default |
| Decode untrusted input with budgets | Set limits for untrusted input | Default |
| Write a raw stream incrementally | Stream through Write | Default |
| Drive a raw or framed encoder with tiny buffers | Sans I/O compression | Default; experimental for frames |
| Decode arriving chunks and resource events | Sans I/O decompression | Default; experimental for frames |
| Read or write raw, parallel, and framed streams | Read / Write I/O | Default; experimental 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 | experimental |
| Add resource names and write incrementally | Write resources and metadata | experimental |
| Read just the selected resource | Indexed resource reads | experimental |
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 file example uses caller-owned scoped threads and disk staging. The destination must not already exist:
cargo run --release --example parallel -- input.bin output.br
For a parallel batch, all taken tasks must run or be dropped before finalization. For a streaming adapter, keep workers running while the adapter blocks. For writers, finish each resource and the enclosing stream or container explicitly.
Verify the documented recipes
python3 scripts/docs.py test-examples
This creates a temporary Cargo consumer under target/, depends on the local
crate with experimental enabled and on Rayon, and runs each fenced Rust program
from the recipe guides: getting started, configuration, buffers, decompression,
streaming, parallel, frames, Sans I/O compression/decompression, and the I/O
guide. It does not
exercise the codec with --all-features, because that would also enable no_std
and remove the std APIs these recipes demonstrate.
Configuration and features
Three things are configured separately: the encoder (how hard to search), the stream (what you know about one input), and the Cargo features (which codecs and adapters are compiled in). Decoder settings are covered in decompression and limits.
Choose a quality first
EncoderConfig::default() uses quality 11, the most expensive search. Set
the quality explicitly for your workload:
use mbrotli::{Compressor, EncoderConfig, Quality};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let config = EncoderConfig::default().with_quality(Quality::Q5);
let mut encoder = Compressor::new(config)?;
let compressed = encoder.compress(b"configured payload")?;
assert!(!compressed.is_empty());
Ok(())
}
| Quality | Encoder | Search |
|---|---|---|
| 0–1 | Fast encoder | Fragment-based fast paths with direct entropy coding |
| 2–9 | Greedy encoder | Hash-table match search with match scoring |
| 10–11 | High-quality encoder | Tree search with command pricing and clustering |
Speed and size depend on your data. The benchmark pages show both for every quality.
Encoder settings
EncoderConfig holds settings shared by successive streams:
| Setting | Type | Behavior |
|---|---|---|
| Quality | Quality | 0–11; default 11 |
| Window | Window | Window::standard(bits) for 10–24 bits; Window::large(bits) for 10–62 bits |
| Input block size | BlockSize / BlockBits | Automatic or explicit 16–24 bits |
| Data mode | CompressionMode | Generic, text, or font |
| Distance coding | DistanceParams | Validated postfix bits and direct distance groups |
| Literal contexts | LiteralContextMode | Controls literal context modelling |
Validated values reject out-of-range inputs. Compressor::new and
reconfigure check combinations, including Large Window’s quality minimum.
The capability table lists the minimum quality of each format.
StreamConfig carries the declared input size and logical stream offset.
Its default is unknown size and offset zero. For a complete known input,
use StreamConfig::from(InputSize::Exact(input.len() as u64)).
What each quality supports
| Codec capability | Availability |
|---|---|
| Standard Brotli (RFC 7932) | Qualities 0–11 |
| Large Window Brotli | Qualities 3–11 |
| Prepared LZ77 prefix dictionaries | Qualities 5–11 |
| Serialized dictionaries and custom static dictionary encoding | experimental; qualities 5–11 |
| Headerless stream continuations | experimental; qualities 2–11 |
| Shared Brotli framed compressor, structured input and native sessions | compression,experimental; std or alloc |
| Framed encoder writer/reader adapters | compression,experimental; not available with no_std |
| Framed decoder, structured output and native events | decompression,experimental; std or alloc |
| Framed decoder event reader | decompression,experimental; not available with no_std |
Unsupported combinations return errors. External dictionary references require the same dictionaries at the decoder. Large Window and shared-dictionary streams require a decoder that supports the corresponding extension. See dictionaries and extended formats for each format and compatibility and validation for what is checked against C.
Cargo features
The default feature set is std, compression, and decompression.
| Configuration | Dependency |
|---|---|
| Default std encoder + decoder | mbrotli = "0.5.21" |
| Encoder only | mbrotli = { version = "0.5.21", default-features = false, features = ["std", "compression"] } |
| Decoder only | mbrotli = { version = "0.5.21", default-features = false, features = ["std", "decompression"] } |
| Framing and experimental formats | mbrotli = { version = "0.5.21", features = ["experimental"] } |
| Alloc-backed codecs without std | mbrotli = { version = "0.5.21", default-features = false, features = ["no_std", "compression", "decompression"] } |
| Feature | Surface |
|---|---|
std | Read / Write adapters and parallel compression |
compression, decompression | One codec each; they compile independently |
no_std | Alloc-backed build; takes precedence over std when both are enabled |
experimental | Serialized shared dictionaries, custom static encoding, nonzero stream offsets, and framing. This API may change in a patch release |
diagnostics | PreparedDictionary::longest_match prefix coverage diagnostic |
hotpath, hotpath-cpu, hotpath-alloc | Optional profiling instrumentation |
no_std requires a global allocator. It excludes I/O adapters, parallel
compression, framed I/O adapters, and profiling, and uses compile-time SIMD
selection. Cargo features are additive: another dependency can re-enable a
codec or std. Leave std and hotpath* disabled throughout the dependency
graph for a std-free build.
See codec features and no_std support for the gates and their checks.
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 |
| Cap what the codec keeps between calls | A retention policy |
Output buffers
compress returns a new Vec<u8>. compress_into appends to the supplied
vector and returns the range containing the new stream; existing bytes remain
intact. On failure, the vector’s length rolls back to its original value.
compress_to_slice returns the number of bytes written. Allocate a conservative
buffer using Compressor::max_compressed_size:
use mbrotli::{Compressor, EncoderConfig, Quality};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let input = b"data to compress";
let mut encoder = Compressor::new(
EncoderConfig::default().with_quality(Quality::Q5),
)?;
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)?);
Ok(())
}
A slice exactly as long as the stream also suffices. An undersized slice returns an error and may contain a partial prefix; retry the complete operation with a larger slice. Capacity does not select a different encoding.
Reusing memory
A compressor retains one encoder workspace. Compatible calls reset its logical state and reuse its allocations. Incompatible resolved settings can rebuild that workspace. Destination capacity is managed separately by the caller.
| Retention policy | Effect |
|---|---|
Aggressive (default) | Keeps allocated buffers between operations |
CurrentConfig | Releases incompatible storage when configuration changes |
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().
Use trim(policy) for immediate cleanup. retained_bytes() reports owned
heap allocation sizes; it excludes caller buffers, shared dictionaries, and
allocator overhead. A retention ceiling is not a peak process-memory limit.
For simultaneous independent streams, use one compressor per worker.
fork_empty() copies configuration, backend, and retention settings without
copying the workspace. For one stream split across workers, see
parallel compression.
Decompression and limits
Decompressor owns reusable state. decompress returns a Vec,
decompress_into appends atomically, and decompress_to_slice writes into
caller storage. Sessions report consumed/produced counts; reader and writer
adapt synchronous I/O.
Set limits for untrusted input
The default accepts one complete raw member and extended window declarations up to 62 bits. Numeric budgets are unlimited by default. Configure them for your application before decoding input you do not control:
use mbrotli::{DecodeLimits, DecoderConfig, Decompressor, WindowLimit};
fn decode_payload(input: &[u8]) -> Result<Vec<u8>, Box<dyn std::error::Error>> {
let limits = DecodeLimits::default()
.with_max_input_bytes(Some(1 << 20))
.with_max_output_bytes(Some(8 << 20))
.with_max_workspace_bytes(Some(32 << 20));
let config = DecoderConfig::default()
.with_window_limit(WindowLimit::standard(24)?)
.with_limits(limits);
let mut decoder = Decompressor::new(config)?;
Ok(decoder.decompress(input)?)
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let payload = b"a payload from the network";
let compressed = mbrotli::Compressor::new(Default::default())?.compress(payload)?;
assert_eq!(decode_payload(&compressed)?, payload);
Ok(())
}
The workspace budget excludes caller output and borrowed dictionaries; it is not a process-memory limit. Keep the decoder across calls when reuse matters.
Members, dictionaries and backends
MemberMode::Concatenated accepts successive raw
members. DecodeDictionary prepares decoding data without encoder indexes;
PreparedDictionary can also be borrowed. RAW dictionaries work in all profiles;
serialized/custom dictionaries require experimental.
Vec appends are rolled back on failure. The decoder specializes its command
loops and history copies for the selected CPU backend using safe
fearless_simd abstractions.
See decoder mechanics and compatibility limits.
Where to go next
| Task | Read |
|---|---|
| Decode chunks as they arrive | Sans I/O decompression |
Decode from a Read source or into a Write sink | Read / Write I/O |
| Decode a framing container | Framing containers |
| Attach a dictionary | Dictionaries and extended formats |
Streaming and completion
Use streaming when the payload does not fit in memory, arrives in pieces, or must leave before the input ends. There are three shapes, and they drive the same state machine:
| Shape | You call | The library does | Recipes |
|---|---|---|---|
| Reader | read on the adapter | Pulls source bytes and yields transformed bytes | Read / Write I/O |
| Writer | write_all, then finish | Pushes transformed bytes into your sink | Read / Write I/O |
| Session | process with your own buffers | No I/O; reports consumed and produced counts | Sans I/O compression and decompression |
Readers and writers require std. Sessions also work with no_std and alloc,
and fit async runtimes and event loops.
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. Both adapter families require std APIs.
use mbrotli::{Compressor, EncoderConfig, InputSize, Quality};
use std::io::Write;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let payload = b"streamed payload";
let mut encoder = Compressor::new(
EncoderConfig::default().with_quality(Quality::Q5),
)?;
let stream = InputSize::Exact(payload.len() as u64).into();
let mut writer = encoder.writer(Vec::new(), stream)?;
writer.write_all(payload)?;
let compressed = writer.finish().map_err(mbrotli::io::FinishError::into_error)?;
assert!(!compressed.is_empty());
Ok(())
}
For a decoder reader, pass compressed input and DecodeStreamConfig::default(),
then read the restored bytes to EOF. into_parts() recovers any reader read-ahead.
A decoder writer must also be finished: finalization reports incomplete or invalid
input. In a direct decoder session, declare final input with DecodeOperation::Finish.
A writer borrows the compressor and owns its sink. Use write_all for input,
flush for an intermediate decoding boundary, and finish to terminate the
stream. Frequent flushes add block boundaries and can increase output size.
try_finish(&mut self) supports retrying finalization after an I/O error.
Consuming finish returns the sink on success; FinishError retains the writer
on failure. Use into_parts() to recover both the error and writer for retry.
into_error() discards the writer.
The writer retains pending output across short writes and sink errors. An input write can succeed after accepting bytes even if delivery then fails; that delivery error is reported on a subsequent drain. Continue according to the returned byte counts rather than replaying accepted input. Dropping a writer performs no finalization.
A reader owns its input source and borrows the compressor. Read until EOF to receive the complete compressed stream. Dropping it early abandons that stream.
Sessions
For direct buffer control, start creates an EncoderSession.
Call process with input, output, and an Operation:
| Operation/status | Meaning |
|---|---|
Operation::Process | Accept input without ending the stream |
Operation::Flush | Emit accepted input and a decoding boundary |
Operation::Finish | End the stream after the supplied input |
EncoderStatus::NeedsInput | Supply more input or an explicit flush/finish |
EncoderStatus::NeedsOutput | Supply output space and continue with unconsumed input |
EncoderStatus::Finished | All final output has been delivered |
Use Progress to advance by the reported consumed and written counts.
Finish can require several calls when output buffers are small. Sessions retain
their own pending data and do not retain the caller’s slices.
A dropped unfinished session invalidates partial encoder state, allowing a fresh
stream on the next call. A session discarded with mem::forget requires
Compressor::recover() before reuse.
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 · experimental | FramedCompressor::framed_reader over complete borrowed resource inputs | FramedCompressor::framed_writer with resource writers |
| Framed decompression · experimental | FramedDecompressor::framed_reader → events; framed_seek_reader → selected resource Read | Drive framed decoder events and write payload to your sink |
The library has no dedicated framed decoder Write adapter. Its event API keeps
resource boundaries and metadata visible; forward each payload event to an
appropriate Write destination. It also has no parallel decoder: parallel
compression produces one ordinary raw Brotli stream.
All adapters on this page require std. Enable experimental 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 mbrotli::{Compressor, Decompressor, EncoderConfig, InputSize, Quality};
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_quality(Quality::Q5),
)?;
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(), Default::default())?;
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 mbrotli::{Compressor, Decompressor, EncoderConfig, Quality};
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_quality(Quality::Q5),
)?;
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(mbrotli::io::FinishError::into_error)?;
let mut decoder = Decompressor::new(Default::default())?;
let mut writer = decoder.writer(Vec::new(), Default::default())?;
for chunk in compressed.chunks(3) {
writer.write_all(chunk)?;
}
let restored = writer.finish().map_err(mbrotli::io::FinishError::into_error)?;
assert_eq!(restored, input);
Ok(())
}
A decoder writer’s finish checks that input ends at a valid stream boundary;
flush only drains output and flushes the sink. Encoder flush also creates an
intermediate decoding boundary and can change compressed size. Dropping a writer
does not finalize or flush it.
Parallel compression through Read and Write
Both constructors return an adapter and a task queue. Run that queue on independent workers while using the adapter; reads, writes, and finish can block waiting for tasks. A queue with no running executor cannot make progress.
This reader example compresses across scoped threads, then uses an ordinary decoder reader to restore the assembled raw stream:
use mbrotli::compressor::parallel::{ParallelCompressor, ParallelConfig, TaskCount};
use mbrotli::{Decompressor, EncoderConfig, Quality};
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_quality(Quality::Q5),
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(), Default::default())?;
let mut restored = Vec::new();
std::io::copy(&mut reader, &mut restored)?;
assert_eq!(restored, input);
Ok(())
}
For the writer direction, the complete parallel writer recipe
copies a source into the returned writer, then finishes it while workers keep
running. The task window bounds outstanding segments; the parallel writer’s
flush ends the current segment early. Decode its output with either raw adapter
shown above. See parallel output and backpressure.
Framed compression through readers and resource writers
framed_reader encodes a complete borrowed FramedInput into a Read stream.
It does not accept resources that arrive later. Use framed_writer when input
arrives gradually: start a resource, write its payload, finish it, release its
borrow, then start another resource. Finish the enclosing container last.
The framed writer recipe demonstrates compressed and uncompressed resource writers and metadata. Here is the reader direction, paired with sequential event decoding:
use mbrotli::framing::{
FramedCompressor, FramedDecompressor, FramedEvent,
FramedInput, FramedItem, FramedResource,
};
use std::io::BufReader;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let input = b"a resource exposed as an encoded reader";
let items = [FramedItem::Resource(FramedResource::from(input.as_slice()))];
let mut encoder = FramedCompressor::new(Default::default())?;
let mut encoded = encoder.framed_reader(
FramedInput::from(items.as_slice()), Default::default(),
)?;
let mut container = Vec::new();
std::io::copy(&mut encoded, &mut container)?;
drop(encoded);
let mut decoder = FramedDecompressor::new(Default::default())?;
let source = BufReader::new(container.as_slice());
let mut reader = decoder.framed_reader(source, Default::default())?;
let mut restored = Vec::new();
let mut resources = 0;
while let Some(event) = reader.next_event()? {
match event {
FramedEvent::ResourceStart(_) => resources += 1,
FramedEvent::ResourceData(data) => restored.extend_from_slice(data.bytes),
_ => {}
}
}
assert!(reader.is_finished());
assert_eq!(resources, 1);
assert_eq!(restored, input);
Ok(())
}
Framed decompression into a Write sink
FramedReader lends events over a BufRead source. Wrap an ordinary Read
in BufReader. For each ResourceData event, send data.bytes to your sink with
write_all; use ResourceStart to select the destination for that resource.
Continue until next_event returns None, after complete validation.
use mbrotli::framing::{
FramedCompressor, FramedDecompressor, FramedEvent,
FramedInput, FramedItem, FramedResource,
};
use std::io::{BufReader, Write};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let items = [
FramedItem::Resource(FramedResource::from(b"first".as_slice())),
FramedItem::Resource(FramedResource::from(b"second".as_slice())),
];
let mut encoder = FramedCompressor::new(Default::default())?;
let container = encoder.compress(FramedInput::from(items.as_slice()))?;
let mut decoder = FramedDecompressor::new(Default::default())?;
let mut reader = decoder.framed_reader(
BufReader::new(container.as_slice()), Default::default(),
)?;
let mut sinks: Vec<Vec<u8>> = Vec::new();
while let Some(event) = reader.next_event()? {
match event {
FramedEvent::ResourceStart(_) => sinks.push(Vec::new()),
FramedEvent::ResourceData(data) => {
let sink = sinks.last_mut().ok_or("payload without a resource")?;
sink.write_all(data.bytes)?;
}
_ => {}
}
}
assert!(reader.is_finished());
assert_eq!(sinks, vec![b"first".to_vec(), b"second".to_vec()]);
Ok(())
}
This recipe uses in-memory sinks; a resource router can instead choose files or
other writers. If a sink fails after an event was read, retain its unwritten
suffix before advancing to the next event. FramedReader::partial_output exposes
payload produced on a failing reader call. Keep protocol bytes after the
container by recovering the buffered source with into_inner.
For Read + Seek, use indexed resource reads:
framed_seek_reader opens the directory and resource(index) returns a
ResourceReader implementing Read, suitable for std::io::copy.
For push-driven compressed input, use the native framed decoder
and route the same resource events to sinks.
Completion, retries, and ownership
| Adapter | Complete operation | Recover after a failure |
|---|---|---|
| Raw encoder / decoder reader | Read to EOF | Decoder into_parts preserves read-ahead; codec errors are terminal |
| Raw encoder / decoder writer | finish or retryable try_finish | FinishError::into_parts returns error and writer |
| Parallel reader | Read to EOF with active workers | into_parts preserves read-ahead; keep queue execution alive |
| Parallel writer | finish with active workers | FinishError::into_parts returns error and adapter |
| Framed encoded reader | Read to EOF | Dropping early abandons that container operation |
| Framed writer | Each resource try_finish, then container finish | FramingFinishError exposes writer and error |
| Framed event reader | next_event until None | Inspect partial_output; into_inner preserves buffered source suffix |
Short sink writes and errors must not cause accepted input to be replayed.
The writer adapters retain pending output, and a later call may report an error
after an earlier call already accepted input. WouldBlock is not EOF. If you
need explicit readiness handling, use native sessions and drive them when your
runtime says buffers are ready. See streaming completion
and framed retry mechanics.
Sans I/O compression
Use a session when your application owns the input/output buffers and decides when more bytes are available. Sessions perform no I/O. This works in a blocking loop, an async task, or a custom event loop without an I/O adapter.
| Output format | Session entry point | Scheduling |
|---|---|---|
| Raw Brotli | Compressor::start(stream) | Call process yourself |
| One raw stream encoded across workers | ParallelCompressor::start(window) | Drive process and run its task queue |
| Framed resources · experimental | FramedCompressor::start(stream) | Drain container output, drive resource guards, finish container |
For transport integration, see Read / Write I/O. For the inverse operation, see Sans I/O decompression.
Drive a raw encoder
Supply chunks with Operation::Process. 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 mbrotli::{
Compressor, Decompressor, EncoderConfig, EncoderStatus, InputSize, Operation, Quality,
};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let input = b"incremental encoding with tiny buffers\n".repeat(100);
let config = EncoderConfig::default().with_quality(Quality::Q5);
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::Process)?;
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::Finish)?;
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 makes accepted bytes decodable without ending the stream.
Drain it to NeedsInput before changing operation or supplying new input.
Flush boundaries can change output size and bytes. Finish ends the stream;
while retrying it, keep the same operation and exact unconsumed input suffix.
Use InputSize::Exact when the total input size is known. Otherwise use the
default unknown size; the encoder infers its size hint when it first encodes.
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 queued, running, and not-yet-delivered segments. Without flushes, output matches a batch over the same input and segment configuration. See parallel session mechanics.
Drive a framed encoder
Enable experimental. Container process takes an output buffer and a
FramedEncodeOperation; resource process takes payload, output, and an ordinary
Operation. Finish the resource before finishing the container.
use mbrotli::framing::{
FramedCompressor, FramedDecompressor, FramedEncodeConfig,
FramedEncodeOperation, FramedEncoderStatus, FramingConfig,
};
use mbrotli::{EncoderConfig, Operation, Quality};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let input = b"framed incremental payload\n".repeat(20);
let config = FramedEncodeConfig::default()
.with_encoder_config(EncoderConfig::default().with_quality(Quality::Q5))
.with_framing_config(FramingConfig { chunk_bytes: 32, ..Default::default() });
let mut encoder = FramedCompressor::new(config)?;
let mut session = encoder.start(Default::default())?;
let mut buffer = [0; 13];
let mut container = Vec::new();
// Drain the main header before accepting a resource command.
loop {
let progress = session.process(&mut buffer, FramedEncodeOperation::Process)?;
container.extend_from_slice(&buffer[..progress.produced]);
if progress.status == FramedEncoderStatus::NeedsInput {
break;
}
}
{
let mut resource = session.resource(Default::default(), Default::default())?;
let mut chunks = input.chunks(9).peekable();
while let Some(chunk) = chunks.next() {
let operation = if chunks.peek().is_none() {
Operation::Finish
} else {
Operation::Process
};
let mut remaining = chunk;
loop {
let progress = resource.process(remaining, &mut buffer, operation)?;
remaining = &remaining[progress.consumed..];
container.extend_from_slice(&buffer[..progress.produced]);
if progress.status == FramedEncoderStatus::Finished
|| progress.status == FramedEncoderStatus::NeedsInput
{
assert!(remaining.is_empty());
break;
}
}
}
assert!(resource.is_finished());
}
loop {
let progress = session.process(&mut buffer, FramedEncodeOperation::Finish)?;
container.extend_from_slice(&buffer[..progress.produced]);
if progress.status == FramedEncoderStatus::Finished {
break;
}
}
let mut decoder = FramedDecompressor::new(Default::default())?;
assert_eq!(decoder.decompress(&container)?.resources[0].data, input);
Ok(())
}
The example marks the last nonempty chunk with Finish; for an empty resource,
drive resource.process(&[], output, Operation::Finish) to Finished too.
Metadata and new resource commands require previous container output to be
drained; otherwise they return OutputPending without committing the command.
See resource lifecycles and metadata.
Ownership and cancellation
Borrowed sessions exclusively borrow their codec owner. Dropping an unfinished session cancels it; dropping an unfinished framed resource abandons its container. Forgetting a session requires owner recovery. No drop performs transport I/O. Owned session variants move the codec into the session when a lifetime-free holder fits your application better. See owned sessions.
Sans I/O decompression
Feed compressed input and supply writable output space as it becomes available.
Sessions own decoding state and retain no caller input/output slices between
calls. DecodeOperation::Process allows more input; Finish declares the final
input boundary and enables truncation checks.
| Input | Decoder |
|---|---|
| Ordinary raw Brotli | Decompressor::start |
Output of ParallelCompressor | The same Decompressor::start |
| Framing container · experimental | FramedDecompressor::start |
There is no separate parallel decoder. Parallel compression assembles a standard raw stream, decoded through the ordinary decoder APIs. For transport adapters, see Read / Write I/O.
Decode a raw stream in chunks
This example supplies three compressed bytes at a time, uses a tiny output
buffer, and declares Finish only for the last input chunk.
use mbrotli::{
Compressor, DecodeOperation, DecoderStatus, Decompressor, EncoderConfig, Quality,
};
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_quality(Quality::Q5),
)?;
let compressed = encoder.compress(&input)?;
let mut decoder = Decompressor::new(Default::default())?;
let mut session = decoder.start(Default::default())?;
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 Brotli 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 experimental, a framed session reports resource boundaries, metadata,
payload fragments, directory entries, and a validated footer. Handle events
before mutating the session again: events can borrow the session and output.
use mbrotli::framing::{
FramedCompressor, FramedDecompressor, FramedDecoderStatus, FramedEvent,
FramedInput, FramedItem, FramedResource,
};
use mbrotli::DecodeOperation;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let input = b"resource decoded one fragment at a time";
let items = [FramedItem::Resource(FramedResource::from(input.as_slice()))];
let mut encoder = FramedCompressor::new(Default::default())?;
let container = encoder.compress(FramedInput::from(items.as_slice()))?;
let mut decoder = FramedDecompressor::new(Default::default())?;
let mut session = decoder.start(Default::default())?;
let mut chunks = container.chunks(3).peekable();
let mut buffer = [0; 5];
let mut restored = Vec::new();
let mut resources = 0;
let mut finished = false;
while let Some(chunk) = chunks.next() {
let operation = if chunks.peek().is_none() {
DecodeOperation::Finish
} else {
DecodeOperation::Process
};
let mut remaining = chunk;
loop {
let progress = session.process(remaining, &mut buffer, operation)?;
remaining = &remaining[progress.consumed..];
match progress.status {
FramedDecoderStatus::Event(FramedEvent::ResourceStart(_)) => resources += 1,
FramedDecoderStatus::Event(FramedEvent::ResourceData(data)) => {
restored.extend_from_slice(data.bytes);
}
FramedDecoderStatus::NeedsInput => {
assert!(remaining.is_empty());
break;
}
FramedDecoderStatus::Finished => {
assert!(remaining.is_empty());
finished = true;
break;
}
_ => {}
}
}
if finished {
break;
}
}
assert!(finished);
assert_eq!(resources, 1);
assert_eq!(restored, input);
Ok(())
}
ResourceDataEnd ends one resource’s payload; it does not validate the rest of
the container. Continue through Finished to validate remaining structure.
The recipe has one resource; for multiple resources, route ResourceData to
the destination selected by ResourceStart rather than concatenating payloads.
See framed event semantics.
Limits and errors
Set input, output, window, and workspace budgets in DecoderConfig before
starting a raw decoder. For framing, use FramedDecodeConfig and its framing
limits in addition to raw decoder limits. See resource-limit configuration.
Output delivered before a later error is partial and has not passed complete
validation. Failure values carry consumed/produced counts for that failing
call; account for them if your transport needs exact progress. Drop a failed
session before starting a new operation. Finish reports truncated streams;
an empty Process call does not declare EOF.
Parallel compression
mbrotli::compressor::parallel divides a known input into fixed independent
segments and assembles them into one Brotli stream. It supports qualities 0–11
with standard windows. Dictionaries, Large Window, and framing are unsupported
by this API.
The caller runs the tasks using scoped threads, Rayon, or another executor. The library does not create a thread pool.
Compressing a slice
use mbrotli::compressor::parallel::{
BatchConfig, ParallelCompressor, ParallelConfig, TaskCount,
};
use mbrotli::{Decompressor, EncoderConfig, Quality};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let input = vec![b'a'; 8 << 20];
let mut encoder = ParallelCompressor::new(
EncoderConfig::default().with_quality(Quality::Q5),
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 mbrotli 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 mbrotli::compressor::parallel::{
BatchConfig, ParallelCompressor, ParallelConfig, TaskCount,
};
use mbrotli::{Decompressor, EncoderConfig, Quality};
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_quality(Quality::Q5),
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() }.
The session delivers finished parts in input order while later segments are
still encoding, and closes the queue when the stream finishes, fails, or is
dropped.
use mbrotli::compressor::parallel::{
ParallelCompressor, ParallelConfig, ParallelEncoderStatus, TaskCount,
};
use mbrotli::{EncoderConfig, Operation, Quality};
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_quality(Quality::Q5),
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::Finish } else { Operation::Process };
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 = mbrotli::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 so the session’s memory. A full window stops input from being consumed.- 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 mbrotli::compressor::parallel::{ParallelCompressor, ParallelConfig, TaskCount};
use mbrotli::io::FinishError;
use mbrotli::{EncoderConfig, Quality};
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_quality(Quality::Q5),
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 = mbrotli::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’sflushisOperation::Flush: it makes everything written decodable and moves later segment boundaries. - The writer keeps every compressed byte until the sink takes it, so short
writes,
WouldBlockand sink errors lose nothing, andfinishhands the adapter back insideFinishErrorfor a retry, asEncoderWriterdoes.reader.into_parts()returns source bytes read ahead of the session. - Session failures arrive as
std::io::Errorwith theParallelEncodeErroras their source. Dropping an adapter abandons the stream and closes the queue.
Segments and compressed size
Segment size defaults to 4 MiB and accepts 64 KiB–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. At qualities 2-9 a segment may copy from the input before it, up to a quarter of a segment within the window, and codes distances as a serial stream does; at qualities 0-1 and 10-11 it starts from nothing and writes every distance explicitly, which costs most on input that repeats one distance, such as tables and logs. The compression ratio can be worse than serial compression, especially for repetition further back than that history. Compare both output size and elapsed time.
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 file example uses directory staging and creates a new output file:
cargo run --release --example parallel -- INPUT OUTPUT
See parallel mechanics for lifecycle, resource accounting, source checks, and error propagation.
Framing containers
Package multiple resources, metadata, and optional dictionary references in an experimental Shared Brotli container. This is useful when an application needs resource boundaries or indexed access in addition to compression.
Enable framing
[dependencies]
mbrotli = { version = "0.5.21", features = ["experimental"] }
The examples use std, compression, and decompression. Experimental APIs may
change in a patch release. A framed container needs FramedDecompressor;
the raw Decompressor API accepts raw Brotli members.
| Concept | Meaning |
|---|---|
| Container | The enclosing structure, with resources and optional metadata/directory/footer |
| Resource | One logical payload, compressed or stored verbatim |
| Chunk | A bounded portion of a resource on the wire; several chunks can share decoder state |
| Directory | Index for the full profile, used for seeking to selected resources |
The library’s parallel compressor emits one raw Brotli stream. It does not emit framing containers. See parallel compression when the goal is compressing one input across cores.
One container, two resources
Use borrowed inputs when all resource bytes are available. The first resource below is Brotli-compressed; the second is explicitly stored without compression.
use mbrotli::framing::{
FramedCompressor, FramedDecompressor, FramedEncodeConfig, FramedInput,
FramedItem, FramedResource, ResourceEncoding,
};
use mbrotli::{EncoderConfig, Quality};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let text = b"a document with repeated content\n".repeat(1000);
let binary = [0x91, 0x02, 0x7f, 0x00];
let mut stored = FramedResource::from(binary.as_slice());
stored.encoding = ResourceEncoding::Uncompressed;
let items = [
FramedItem::Resource(FramedResource::from(text.as_slice())),
FramedItem::Resource(stored),
];
let config = FramedEncodeConfig::default().with_encoder_config(
EncoderConfig::default().with_quality(Quality::Q5),
);
let mut encoder = FramedCompressor::new(config)?;
let container = encoder.compress(FramedInput::from(items.as_slice()))?;
let mut decoder = FramedDecompressor::new(Default::default())?;
let decoded = decoder.decompress(&container)?;
assert_eq!(decoded.resources.len(), 2);
assert_eq!(decoded.resources[0].data, text);
assert_eq!(decoded.resources[1].data, binary);
Ok(())
}
compress_into appends a container to a reusable Vec; compress_to_slice
writes into caller storage. The owner can be reused for later containers.
The framed compressor specification describes
size contracts, retention, and native incremental output APIs.
Write resources and metadata
Use a writer when resource input arrives gradually. Resource metadata precedes the resource it describes. Finish each resource before starting the next one, then finish the container.
use mbrotli::framing::{
FramedCompressor, FramedDecompressor, FramedEncodeConfig,
FramingConfig, MetadataField, MetadataKind,
};
use mbrotli::{EncoderConfig, Quality};
use std::io::Write;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let config = FramedEncodeConfig::default()
.with_encoder_config(EncoderConfig::default().with_quality(Quality::Q5))
.with_framing_config(FramingConfig {
chunk_bytes: 4096,
..Default::default()
});
let mut encoder = FramedCompressor::new(config)?;
let mut writer = encoder.framed_writer(Vec::new(), Default::default())?;
writer.metadata(MetadataKind::Resource, &[
MetadataField { code: *b"id", value: b"notes.txt" },
])?;
{
let mut resource = writer.resource(Default::default(), Default::default())?;
resource.write_all(b"first part\n")?;
resource.write_all(b"second part\n")?;
resource.try_finish()?;
}
{
let mut resource = writer.uncompressed_resource(Default::default())?;
resource.write_all(&[1, 2, 3, 4])?;
resource.try_finish()?;
}
let container = writer.finish().map_err(|failure| failure.error)?;
let mut decoder = FramedDecompressor::new(Default::default())?;
let decoded = decoder.decompress(&container)?;
assert_eq!(decoded.resources[0].data, b"first part\nsecond part\n");
assert_eq!(decoded.resources[0].metadata.as_ref().and_then(|m| m.name()),
Some("notes.txt"));
assert_eq!(decoded.resources[1].data, [1, 2, 3, 4]);
Ok(())
}
Neither resource drop nor container drop performs I/O. Dropping an unfinished
resource abandons its container. try_finish supports retrying resource
finalization; a failed consuming container finish retains the writer in
FramingFinishError for recovery through its writer and error fields. The
in-memory example above keeps the error and discards the writer. See the
retry state machine.
Metadata id is a caller-provided UTF-8 name, not an extraction path or a
unique lookup key. Checksums are supplied by the caller; the writer does not
compute them and the decoder does not verify them.
Indexed resource reads
For a full-profile container on a Read + Seek source, open the footer and
directory, then read a selected resource. You can use a File in place of the
in-memory Cursor below.
use mbrotli::framing::{
FramedCompressor, FramedDecompressor, FramedInput,
FramedItem, FramedResource, ResourceIndex,
};
use std::io::{Cursor, Read};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let items = [
FramedItem::Resource(FramedResource::from(b"first".as_slice())),
FramedItem::Resource(FramedResource::from(b"second".as_slice())),
];
let mut encoder = FramedCompressor::new(Default::default())?;
let container = encoder.compress(FramedInput::from(items.as_slice()))?;
let mut decoder = FramedDecompressor::new(Default::default())?;
let mut reader = decoder.framed_seek_reader(Cursor::new(container))?;
assert_eq!(reader.resources().len(), 2);
let mut selected = Vec::new();
reader.resource(ResourceIndex(1))?.read_to_end(&mut selected)?;
assert_eq!(selected, b"second");
Ok(())
}
Resources are identified by zero-based ResourceIndex, independent of metadata
names. A resource that references earlier dictionary content can require
decoding its dependencies. A selected resource reader implements Read;
the enclosing reader uses Seek to locate container content.
See indexed decoder mechanics.
Limits and streaming decoding
FramingConfig bounds encoder chunks, metadata, resource/chunk counts, and
framing storage. FramedDecodeConfig / FramedDecodeLimits control decoder
budgets; raw DecoderConfig limits apply within compressed resources.
Choose limits for the workload before decoding untrusted containers.
For arriving input, FramedDecompressor::start exposes incremental events;
framed_reader adapts a BufRead source. InputMode::Auto also accepts raw
Brotli; the default is FramedOnly. External dictionaries need a caller-supplied
resolver. See decoder APIs, limits, and events
and dictionaries.
Dictionaries and extended formats
Prefix dictionaries
A PreparedDictionary owns immutable dictionary bytes and search indexes.
Prepare it once and borrow it from multiple compressors, including across
threads. Prefix compression supports qualities 5–11.
use mbrotli::dictionary::DictionaryBuilder;
use mbrotli::{Compressor, EncoderConfig, Quality};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let dictionary = DictionaryBuilder::new()
.add_prefix(&b"HTTP/1.1 200 OK\r\nContent-Type: "[..])
.build()?;
let mut encoder = Compressor::new(
EncoderConfig::default().with_quality(Quality::Q5),
)?;
let input = b"HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\n";
let compressed = encoder.compress_with_dictionary(&dictionary, input)?;
assert!(!compressed.is_empty());
Ok(())
}
Attachment order is significant. The decoder must receive matching dictionary bytes in the same order; a raw Brotli stream does not embed those external bytes or resolve dictionary identifiers.
Dictionary overloads are available for vector, slice, session, reader, and writer
output. Passing a dictionary below quality 5 returns
EncodeError::DictionaryUnsupportedForQuality.
DictionaryBuilder::with_limits accepts DictionaryLimits to bound source
bytes, prefix bytes, attachment count, and prepared storage. Preparation returns
either a complete dictionary or an error. Inspect source_bytes(),
attachment_count(), and retained_bytes() for its stored sizes.
Large Window
Use Window::large(bits) in EncoderConfig to select the extended header.
Values range from 10 to 62, and compressor construction requires quality 3–11.
Window::standard(bits) selects ordinary Brotli with 10–24 bits.
The declared window controls the header. The encoder retains at most 30 bits of history, even with a wider declaration. Consumers must support the extended format. The repository’s C decoder checks original streams through 30 bits; wider declarations have header and payload checks but no independent end-to-end decoder validation here.
Feature flags
| Feature | Surface |
|---|---|
experimental | Serialized shared dictionaries, custom static encoding, nonzero stream offsets, and framing |
diagnostics | PreparedDictionary::longest_match prefix coverage diagnostic |
hotpath, hotpath-cpu, hotpath-alloc | Optional profiling instrumentation |
The experimental API may change in a patch release.
Serialized and custom static dictionaries
With experimental, SerializedDictionary parses and writes shared dictionary
descriptions containing an LZ77 prefix, word lists, transform lists, combinations,
and a 64-entry context map. DictionaryBuilder::add_serialized prepares that
description for compression at qualities 5–11.
Parsing validates structure and rejects trailing bytes. Serialization emits a
canonical representation. Preparation applies limits to the owned description,
prefix indexes, transformed words, static entries, and temporary storage.
Use the additional serialized, word, transform, combination, and expansion
ceilings in DictionaryLimits when processing external descriptions.
Custom search supports transformed words and context combinations. Tests require Rust backend/API identity and independent C decoding. Extended search does not have a blanket byte-identity guarantee with C’s experimental encoder.
See serialized format mechanics and custom dictionary encoding.
Headerless continuations
With experimental, StreamConfig::with_stream_offset accepts nonzero
offsets at qualities 2–11. Logical positions, including new input, must fit in
63 bits. A continuation has no stream header and is not independently decodable.
The caller joins it after a compatible stream’s byte-aligned flush boundary. The encoder uses a restart prefix and does not invent prior history from the offset. Use parallel compression for the API that plans and assembles independent parts of one input.
Framing containers
FramedCompressor::framed_writer creates an experimental Shared Brotli container
writer over a non-seekable Write sink. It supports compressed and uncompressed
resources, dictionary references, metadata, padding, repeated metadata, a central
directory, and a footer.
Start a resource with resource, resource_with_dictionary, or
uncompressed_resource. Write its input, call the resource’s try_finish,
then drop that borrow before starting another resource. Finish the container
explicitly. Resource and container finalization retain pending bytes for I/O
retries; dropping either performs no I/O. Dropping an unfinished resource
abandons the container.
The caller supplies dictionary IDs, matching dictionary references, and optional
checksums. The writer does not calculate hashes or fetch dictionaries.
FramingConfig bounds chunk input, metadata, framing storage, resources, and
chunk count. The single-resource profile has no metadata or directory.
mbrotli::framing::FramedDecompressor reads these containers with
decompression,experimental enabled. decompress returns resources and container
structure; start exposes incremental events, and framed_reader adapts a
BufRead source. The default input mode is FramedOnly; InputMode::Auto also
accepts a raw Brotli member. Raw Decompressor entry points do not parse containers.
The framed owner, one-shot encoder and native sessions support alloc builds.
The framing writer and encoded reader require std APIs. The decoder’s owned and Sans I/O APIs
also work with no_std and alloc; FramedReader requires std APIs. External
dictionaries use a caller-supplied resolver, and resource checksums are recorded
without verification. Tests check independent wire fixtures, writer/decoder
round trips and compressed payload decoding with C; the pinned C library has no
container codec.
See framed compressor mechanics and framed decoder APIs, validation and limits.
The experimental writer constructor moved from the raw owner:
use mbrotli::{EncoderConfig, framing::{FramedCompressor, FramedEncodeConfig, FramingConfig}};
let config = FramedEncodeConfig::default()
.with_encoder_config(EncoderConfig::default())
.with_framing_config(FramingConfig::default());
let mut encoder = FramedCompressor::new(config)?;
let writer = encoder.framed_writer(Vec::new(), Default::default())?;
Ok::<(), Box<dyn std::error::Error>>(())
The second writer argument is now a per-container FramedEncodeStreamConfig.
See runnable API examples
for borrowed inputs, native output buffers, metadata and owner reuse.
Compatibility and validation
This page describes the contracts checked by the repository. Tests, coverage, fuzzing and memory tools provide bounded evidence, not formal verification. For commands, use the development guide.
What is claimed
| Surface | Claim | Oracle |
|---|---|---|
| Default features, qualities 0–11, windows 10–24 | Byte identity with Google Brotli v1.2.0 as of upstream master 4508218e (brotli-ffi/vendor/brotli, 2026-09-01) configured with equivalent streaming settings, and decodability by its decoder | C encoder and decoder through google-brotli-ffi |
| Large Window Brotli, qualities 3–11 | Decodable by the C decoder up to its 30-bit limit; above it, identical to the 30-bit stream apart from the header bits | C decoder; header comparison |
| Prepared prefix dictionaries, qualities 5–11 | Byte identity with C where C supports the same attachment, and C decoding with the dictionary attached | C encoder and decoder |
| Native decoder, any input bytes | A stream the C decoder accepts decodes to the same bytes and consumes the same prefix, or is refused by a declared resource budget; no input panics | C decoder through google-brotli-ffi |
| Native decoder, streaming | Chunked sessions agree with one-shot decoding on bytes, on cumulative progress, and on termination, at any chunk and output size | Cross-entry-point comparison |
| All serial entry points | Same bytes from compress, compress_into, compress_to_slice, writer, reader, and start at any chunk size, with any backend, from a fresh or reused compressor | Cross-API and cross-backend comparison |
| Parallel compression | One valid stream, deterministic across task counts | C decoder; cross-schedule comparison |
experimental: serialized dictionaries, custom static encoding, continuations, framing | RFC 9841 conformance validated by the C parser and decoder built with BROTLI_EXPERIMENTAL, byte identity with C for continuations, and this crate’s own fixtures | C decoder; fixtures |
| Memory safety | The library forbids unsafe code outside test modules; retained storage and streaming state are checked under Miri and AddressSanitizer | #![forbid(unsafe_code)], Miri, ASan |
Output compatibility
Serial API identity requires the same encoder configuration, dictionary, declared size, flush boundaries, and offset. For one-shot equivalence, declare the exact input size, keep offset zero, and avoid explicit flushes. Chunk size, destination shape, workspace reuse, and SIMD selection do not change bytes. Unknown size can change match-finder selection: like C’s streaming API, a session of unknown length infers its size hint from the bytes it holds plus the current call’s input when it first encodes, so a body passed in one call compresses as if its length had been declared.
The ordinary encoder differential uses the pinned C encoder’s streaming API with matching settings and block scheduling. C’s native one-shot API can rewrite empty or expanded output, and its fast streaming APIs can emit fragments at caller chunk boundaries. Those schedules are not interchangeable byte oracles.
Validation layers
flowchart LR
Tests[unit, integration and doc tests] --> Oracles[C differential and round-trip checks]
Tests --> Identity[API, chunking, reuse and backend identity]
Oracles --> Fuzz[AFL targets and committed regressions]
Identity --> Fuzz
Tests --> Coverage[inspect function coverage]
Tests --> Memory[Miri and AddressSanitizer]
Differential tests
| Test file | What it compares |
|---|---|
tests/differential_c.rs | Structural corpora, boundary lengths, every window size, reused and reconfigured compressors against the C encoder |
tests/greedy_qualities.rs | Qualities 2–9 byte for byte |
tests/vendor_corpus.rs | Google’s own test corpus, including a 12 MiB multi-fragment input, against the C encoder, through the C decoder, and across backends |
tests/randomized.rs | Deterministically seeded structured inputs against the C encoder and decoder |
tests/large_window.rs, tests/window_bits.rs | Large Window headers and bounds |
tests/dictionary.rs, tests/serialized_dictionary.rs, tests/stream_offset.rs | Prefix dictionaries, serialized dictionaries and continuations against the C encoder and decoder |
tests/roundtrip.rs, tests/framing.rs, tests/parallel.rs | Decoding of every quality, framing fixtures, and parallel stream validity |
Internal identity tests
tests/streaming.rs, tests/flush.rs, tests/reuse.rs,
tests/simd_backends.rs, tests/track_a_lifecycle.rs, and
tests/writer_faults.rs check that chunk boundaries, destination shapes,
workspace reuse, backend selection, and misbehaving sinks never change the
bytes. tests/compressor_memory.rs and tests/dictionary_memory.rs check the
retention and preparation budgets with an accounting allocator.
Feature and fuzz coverage
Run std and alloc-backed configurations separately, with and without
experimental. --all-features enables no_std and therefore excludes std-only
I/O and parallel APIs; it does not replace a std test run. Feature consumer
scripts check both accepted imports and APIs that must be absent.
The isolated AFL package reuses engine-neutral target bodies for fuzzing and
committed regression replay. campaign.sh covers encoder boundaries and the
C-to-native round trip; decoder-campaign.sh covers decoder boundaries. Both
stable and experimental builds are needed. See the AFL guide
and target mechanics.
Dated records of completed campaigns, with hosts, durations and execution counts, are kept in fuzz campaign records.
Limits of the evidence
- C byte identity requires equivalent streaming settings. Native C one-shot shortcuts and arbitrary chunk schedules can differ.
- C validates extended windows through 30 bits. Wider declarations use independent header and distance-model tests; they have no equivalent C end-to-end oracle.
- Experimental custom static encoding and framing use parser/decoder checks and fixtures; they have no blanket C-encoder byte-identity guarantee.
- A bug shared with the reference implementation can escape differential tests.
- Fuzz budgets cap payload, decoded output and workspace. Large histories and cumulative counters rely on separate integration and ignored heavy tests.
- Coverage measures executed functions, not all paths or possible inputs. Backend execution tests cover only instruction sets available on their host.
- A saved AFL timeout needs standalone reproduction and resource profiling before classification. A bounded campaign cannot establish absence of bugs.
See decoder verification for wire-format, dictionary, resource-limit and large-history checks.
Benchmark results
Current library comparison — 2026-10-30
Full serial sweeps on the AMD Ryzen 9 7950X Linux VDS compare five encoders (432 cases) and four decoders (384 cases). Both use ordinary release builds, 20 samples, 1 s warmup and 3 s requested measurement per case, one process at a time on logical CPU 1. The reports identify the measured checkout and binaries.
| Compare | Results | Data |
|---|---|---|
| Compression speed and size | Qualities 0–11 | CSV, conditions, sizes |
| Decompression of identical C streams | Source qualities 0–11 | CSV, conditions, sizes |
Compression
Across all 96 corpus/quality pairs, mbrotli’s median speed / C is 1.099×; its mean latency is lower in 67/96 cases. Burli supports q0–q5 and the other encoders q0–q11. Each plotted value is a median of eight per-dataset ratios to C. Higher speed and lower output size are better. Equal numeric quality does not imply equal compressed size.
Decompression
Across all 96 corpus/quality pairs, mbrotli’s median speed is 2.588× C and 1.006× Burli. Its mean latency is lower in 80/96 C comparisons and 53/96 Burli comparisons. All decoders restore the same C-produced bytes; quality belongs to their source encoder. SIMD Brotli shares the Rust Brotli decoder project and is omitted. Throughput counts restored bytes.
How to interpret the results
Generic mode, window 22, cold native APIs: construction, allocation, transformation and disposal are timed. Inputs and complete matrices are validated before timing. Empty and tiny inputs carry the same weight as large inputs. Medians describe this corpus, not a production workload distribution; point counts do not imply statistical significance. Confidence bounds and every workload remain in the quality pages. Fixed implementation order and an uncontrolled virtual host can affect timings beyond sampling uncertainty. No build, test, fuzzing or other benchmark ran concurrently with either sweep.
| Dataset | Input bytes | Content |
|---|---|---|
| empty | 0 | API overhead without input |
| tiny-text | 44 | Quick-brown-fox sentence |
| alice29 | 152,089 | Vendored Alice in Wonderland text |
| text-1m | 1,048,576 | Alice repeated cyclically |
| binary-64k | 65,536 | Structured binary bytes |
| random-64k | 65,536 | Prefix of the pseudorandom corpus |
| random-1m | 1,048,576 | Deterministic xorshift64 bytes |
| repeated-1m | 1,048,576 | Repeated a bytes |
Use benchmarking and profiling for reproduction and page/chart generation. Root API suites separately measure reuse, streaming, slices, parallel tasks and dictionaries; their results are not mixed into these native-API medians. Superseded local reports and intermediate snapshots have been removed from the published result set.
lzbench — 2026-11-08
lzbench master a6fa8aa (2.4.1), gcc 15.2 with -march=native, silesia.tar
on one vCPU of the AMD Ryzen 9 7950X VDS: mbrotli 0.5.20 compresses 41% and
54% faster than Google Brotli 1.2.0 at qualities 8 and 11 and 10% faster at 5,
within run-to-run noise at 0 and 2, and decompresses 22–31% faster at every
quality. Sizes match at 0–8; at 11 mbrotli’s output is 0.031% larger.
Command, environment and raw output.
lzbench (independent)
lzbench is a long-running open-source in-memory benchmark: every codec is built from source into one binary with the same compiler and flags and runs on the same data and machine. Its maintainer ran these results, not us: lzbench 2.4 (gcc 15.2), the 211 MB Silesia corpus, one thread of an AMD EPYC 9555P with turbo disabled. Full table.
mbrotli 0.5.2 against Google Brotli 1.2.0; bold marks the faster result. Ratio is the compressed size as a percentage of the input and matches for both.
| Level | Compress mbrotli | Compress brotli | Decompress mbrotli | Decompress brotli | Ratio, % |
|---|---|---|---|---|---|
| -11 | 0.67 MB/s | 0.56 MB/s | 371 MB/s | 383 MB/s | 23.78 |
| -8 | 14.4 MB/s | 12.6 MB/s | 419 MB/s | 443 MB/s | 26.96 |
| -5 | 50.0 MB/s | 53.3 MB/s | 388 MB/s | 419 MB/s | 28.10 |
| -2 | 127 MB/s | 139 MB/s | 349 MB/s | 378 MB/s | 32.12 |
| -0 | 341 MB/s | 341 MB/s | 291 MB/s | 322 MB/s | 37.01 |
Summary: the same compressed size as Google Brotli (+0.005% at -11), 14–20% faster compression at -8 and -11, within 9% at lower levels, and decompression 3–10% behind C.
This external historical result covers mbrotli 0.5.2 on its maintainer’s host; it is separate from the current local library comparison above.
Encoder comparison
Recorded run: enc-2026-10-30-amd (2026-10-30). Raw results · Environment · Run analysis.
All eight datasets contribute equally to each median, including empty and tiny input. For each dataset, speed / C = C mean latency / implementation mean latency; output / C = implementation bytes / C bytes. Medians are taken over those ratios. Qualities are ordered by mbrotli median speed / C, highest first. Burli supports q0–q5 only.
| Quality | mbrotli speed / C ↑ | mbrotli output / C ↓ | Datasets |
|---|---|---|---|
| Quality 9 | 4.323× | 1.000× | 8 |
| Quality 11 | 1.493× | 1.000× | 8 |
| Quality 0 | 1.349× | 1.000× | 8 |
| Quality 10 | 1.243× | 1.000× | 8 |
| Quality 4 | 1.183× | 1.000× | 8 |
| Quality 1 | 1.123× | 1.000× | 8 |
| Quality 3 | 1.089× | 1.000× | 8 |
| Quality 8 | 1.022× | 1.000× | 8 |
| Quality 2 | 1.020× | 1.000× | 8 |
| Quality 5 | 1.017× | 1.000× | 8 |
| Quality 6 | 1.008× | 1.000× | 8 |
| Quality 7 | 1.004× | 1.000× | 8 |
Quality 9
Quality 11
Quality 0
Quality 10
Quality 4
Quality 1
Quality 3
Quality 8
Quality 2
Quality 5
Quality 6
Quality 7
Compression quality 0
Benchmark index · Median overview · Quality 1 →
Recorded run: enc-2026-10-30-amd (2026-10-30). Raw results · Environment · Run analysis.
Median across all datasets
Each of the eight datasets has equal weight, including empty and tiny input. Speed / C = C mean latency / implementation mean latency; output / C = implementation bytes / C bytes. We take the median of these eight per-dataset ratios separately for speed and size. 1× matches C; higher speed and lower output are better. These are medians across datasets, not median sample latencies, and do not imply equal compressed size or statistical significance.
| Implementation | Median speed / C ↑ | Median output / C ↓ | Datasets |
|---|---|---|---|
| Google C | 1.000× | 1.000× | 8 |
| mbrotli | 1.349× | 1.000× | 8 |
| Rust brotli | 0.090× | 1.095× | 8 |
| SIMD Brotli | 0.100× | 1.095× | 8 |
| Burli | 2.011× | 1.000× | 8 |
Measurement setup and interpretation
Google C Brotli 1.2.0, mbrotli at the recorded optimized checkout, Rust brotli 9.0.0, and SIMD Brotli 10.0.1 are compared at this quality. Burli 0.3.3 is also included.
These are cold native API measurements: construction, allocation, compression, and disposal. Generic mode, window 22, known input size; 20 samples, 1 s warmup, at least 3 s measurement. Host: AMD Ryzen 9 7950X (two KVM vCPUs), one benchmark process at a time pinned to logical CPU 1; codec calls and encoder/decoder sweeps ran serially; no concurrent builds, tests, fuzzers or profilers; unset; ordinary Cargo release defaults; runtime SIMD dispatch. Rust brotli and SIMD Brotli include their native 4 KiB I/O adapters.
Chart whiskers and table intervals describe 95% confidence bounds on mean timing. They do not capture all host or allocator variation. Throughput bounds are transformed latency bounds. Output / input is compressed bytes divided by input bytes; smaller is better, and values above 100% mean expansion. For empty input, throughput and output / input are undefined (—). See the run analysis for measurement limitations.
Dataset details
Ordered by mbrotli speed / fastest competing implementation, highest first; all eight datasets are shown. This order uses recorded mean latency, not statistical significance. Bars start at zero.
binary-64k · alice29 · random-1m · random-64k · empty · repeated-1m · tiny-text · text-1m
binary-64k
64 KiB of structured binary bytes: ((i / 16) XOR i) modulo 256. Input: 65,536 bytes.
mbrotli speed / fastest peer (Burli): 1.301×; output: 3,369 / 3,433 bytes.
Exact measurements and confidence bounds
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| Google C | 14.8 | 14.79–14.81 | 4,222.40 | 3,369 | 5.141% | 1.000× |
| mbrotli | 10.25 | 10.24–10.26 | 6,095.00 | 3,369 | 5.141% | 1.443× |
| Rust brotli | 301.1 | 300.9–301.3 | 207.58 | 51,603 | 78.740% | 0.049× |
| SIMD Brotli | 308 | 307.5–308.7 | 202.95 | 51,603 | 78.740% | 0.048× |
| Burli | 13.34 | 13.33–13.35 | 4,683.79 | 3,433 | 5.238% | 1.109× |
alice29
Vendored Alice in Wonderland text. Input: 152,089 bytes.
mbrotli speed / fastest peer (Burli): 0.745×; output: 65,795 / 74,802 bytes.
Exact measurements and confidence bounds
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| Google C | 297.2 | 296.4–297.9 | 488.02 | 65,795 | 43.261% | 1.000× |
| mbrotli | 327.9 | 327.6–328.2 | 442.40 | 65,795 | 43.261% | 0.907× |
| Rust brotli | 1,021 | 1,020–1,022 | 142.04 | 78,217 | 51.428% | 0.291× |
| SIMD Brotli | 932.8 | 932.2–933.4 | 155.49 | 78,217 | 51.428% | 0.319× |
| Burli | 244.4 | 243.2–245.7 | 593.45 | 74,802 | 49.183% | 1.216× |
random-1m
1 MiB of deterministic xorshift64 pseudorandom bytes. Input: 1,048,576 bytes.
mbrotli speed / fastest peer (Burli): 0.659×; output: 1,048,581 / 1,048,581 bytes.
Exact measurements and confidence bounds
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| Google C | 95.44 | 95.34–95.57 | 10,478.25 | 1,048,581 | 100.000% | 1.000× |
| mbrotli | 76.02 | 75.98–76.06 | 13,154.61 | 1,048,581 | 100.000% | 1.255× |
| Rust brotli | 4,604 | 4,596–4,612 | 217.22 | 1,049,345 | 100.073% | 0.021× |
| SIMD Brotli | 5,112 | 5,108–5,117 | 195.61 | 1,049,345 | 100.073% | 0.019× |
| Burli | 50.06 | 50.04–50.09 | 19,975.66 | 1,048,581 | 100.000% | 1.906× |
random-64k
64 KiB prefix of the deterministic pseudorandom corpus. Input: 65,536 bytes.
mbrotli speed / fastest peer (Burli): 0.511×; output: 65,540 / 65,540 bytes.
Exact measurements and confidence bounds
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| Google C | 8.908 | 8.905–8.911 | 7,016.18 | 65,540 | 100.006% | 1.000× |
| mbrotli | 8.23 | 8.217–8.247 | 7,593.73 | 65,540 | 100.006% | 1.082× |
| Rust brotli | 241.4 | 241.1–241.7 | 258.95 | 65,585 | 100.075% | 0.037× |
| SIMD Brotli | 269.4 | 269.2–269.6 | 232.03 | 65,585 | 100.075% | 0.033× |
| Burli | 4.209 | 4.205–4.214 | 14,849.38 | 65,540 | 100.006% | 2.116× |
empty
Empty input; measures API overhead. Input: 0 bytes.
mbrotli speed / fastest peer (Google C): 0.509×; output: 1 / 1 bytes.
Exact measurements and confidence bounds
| Implementation | Mean, ns | 95% mean interval, ns | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| Google C | 17.68 | 17.62–17.76 | — | 1 | — | 1.000× |
| mbrotli | 34.71 | 34.5–35.07 | — | 1 | — | 0.509× |
| Rust brotli | 195.9 | 193.7–198.2 | — | 1 | — | 0.090× |
| SIMD Brotli | 193.3 | 192.6–194.4 | — | 1 | — | 0.091× |
| Burli | 123.5 | 123.3–123.8 | — | 1 | — | 0.143× |
repeated-1m
1 MiB of repeated a bytes. Input: 1,048,576 bytes.
mbrotli speed / fastest peer (Burli): 0.395×; output: 712 / 21 bytes.
Exact measurements and confidence bounds
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| Google C | 131.7 | 131.6–131.9 | 7,590.51 | 712 | 0.068% | 1.000× |
| mbrotli | 84.42 | 84.32–84.54 | 11,844.89 | 712 | 0.068% | 1.560× |
| Rust brotli | 1,454 | 1,453–1,455 | 687.69 | 12,373 | 1.180% | 0.091× |
| SIMD Brotli | 1,224 | 1,223–1,225 | 817.13 | 12,373 | 1.180% | 0.108× |
| Burli | 33.32 | 33.29–33.36 | 30,008.99 | 21 | 0.002% | 3.953× |
tiny-text
44-byte quick-brown-fox sentence. Input: 44 bytes.
mbrotli speed / fastest peer (Burli): 0.390×; output: 48 / 48 bytes.
Exact measurements and confidence bounds
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| Google C | 0.9 | 0.8989–0.9012 | 46.62 | 48 | 109.091% | 1.000× |
| mbrotli | 0.1067 | 0.1063–0.1073 | 393.40 | 48 | 109.091% | 8.438× |
| Rust brotli | 4.069 | 4.066–4.071 | 10.31 | 48 | 109.091% | 0.221× |
| SIMD Brotli | 3.926 | 3.918–3.933 | 10.69 | 48 | 109.091% | 0.229× |
| Burli | 0.04155 | 0.04134–0.04184 | 1,009.93 | 48 | 109.091% | 21.662× |
text-1m
Alice text repeated cyclically to 1 MiB. Input: 1,048,576 bytes.
mbrotli speed / fastest peer (Burli): 0.126×; output: 190,491 / 74,828 bytes.
Exact measurements and confidence bounds
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| Google C | 4,507 | 4,504–4,513 | 221.86 | 190,491 | 18.167% | 1.000× |
| mbrotli | 2,433 | 2,431–2,435 | 410.96 | 190,491 | 18.167% | 1.852× |
| Rust brotli | 7,139 | 7,136–7,142 | 140.08 | 537,432 | 51.254% | 0.631× |
| SIMD Brotli | 6,579 | 6,572–6,584 | 152.01 | 537,432 | 51.254% | 0.685× |
| Burli | 306.7 | 306–307.4 | 3,260.55 | 74,828 | 7.136% | 14.697× |
Compression quality 1
Benchmark index · Median overview · ← Quality 0 · Quality 2 →
Recorded run: enc-2026-10-30-amd (2026-10-30). Raw results · Environment · Run analysis.
Median across all datasets
Each of the eight datasets has equal weight, including empty and tiny input. Speed / C = C mean latency / implementation mean latency; output / C = implementation bytes / C bytes. We take the median of these eight per-dataset ratios separately for speed and size. 1× matches C; higher speed and lower output are better. These are medians across datasets, not median sample latencies, and do not imply equal compressed size or statistical significance.
| Implementation | Median speed / C ↑ | Median output / C ↓ | Datasets |
|---|---|---|---|
| Google C | 1.000× | 1.000× | 8 |
| mbrotli | 1.123× | 1.000× | 8 |
| Rust brotli | 0.107× | 1.146× | 8 |
| SIMD Brotli | 0.141× | 1.146× | 8 |
| Burli | 0.825× | 1.000× | 8 |
Measurement setup and interpretation
Google C Brotli 1.2.0, mbrotli at the recorded optimized checkout, Rust brotli 9.0.0, and SIMD Brotli 10.0.1 are compared at this quality. Burli 0.3.3 is also included.
These are cold native API measurements: construction, allocation, compression, and disposal. Generic mode, window 22, known input size; 20 samples, 1 s warmup, at least 3 s measurement. Host: AMD Ryzen 9 7950X (two KVM vCPUs), one benchmark process at a time pinned to logical CPU 1; codec calls and encoder/decoder sweeps ran serially; no concurrent builds, tests, fuzzers or profilers; unset; ordinary Cargo release defaults; runtime SIMD dispatch. Rust brotli and SIMD Brotli include their native 4 KiB I/O adapters.
Chart whiskers and table intervals describe 95% confidence bounds on mean timing. They do not capture all host or allocator variation. Throughput bounds are transformed latency bounds. Output / input is compressed bytes divided by input bytes; smaller is better, and values above 100% mean expansion. For empty input, throughput and output / input are undefined (—). See the run analysis for measurement limitations.
Dataset details
Ordered by mbrotli speed / fastest competing implementation, highest first; all eight datasets are shown. This order uses recorded mean latency, not statistical significance. Bars start at zero.
repeated-1m · binary-64k · random-1m · random-64k · tiny-text · alice29 · empty · text-1m
repeated-1m
1 MiB of repeated a bytes. Input: 1,048,576 bytes.
mbrotli speed / fastest peer (Burli): 2.087×; output: 190 / 21 bytes.
Exact measurements and confidence bounds
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| Google C | 69.47 | 69.43–69.52 | 14,394.78 | 190 | 0.018% | 1.000× |
| mbrotli | 26.81 | 26.78–26.87 | 37,295.57 | 190 | 0.018% | 2.591× |
| Rust brotli | 594 | 593.6–594.4 | 1,683.46 | 5,377 | 0.513% | 0.117× |
| SIMD Brotli | 372.2 | 371.6–372.9 | 2,686.74 | 5,377 | 0.513% | 0.187× |
| Burli | 55.96 | 55.93–55.99 | 17,870.66 | 21 | 0.002% | 1.241× |
binary-64k
64 KiB of structured binary bytes: ((i / 16) XOR i) modulo 256. Input: 65,536 bytes.
mbrotli speed / fastest peer (Google C): 1.455×; output: 3,202 / 3,202 bytes.
Exact measurements and confidence bounds
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| Google C | 18.59 | 18.58–18.6 | 3,361.87 | 3,202 | 4.886% | 1.000× |
| mbrotli | 12.78 | 12.77–12.78 | 4,892.13 | 3,202 | 4.886% | 1.455× |
| Rust brotli | 366.2 | 366–366.5 | 170.66 | 49,877 | 76.106% | 0.051× |
| SIMD Brotli | 311.9 | 311.7–312 | 200.42 | 49,877 | 76.106% | 0.060× |
| Burli | 19.13 | 19.12–19.14 | 3,267.73 | 3,204 | 4.889% | 0.972× |
random-1m
1 MiB of deterministic xorshift64 pseudorandom bytes. Input: 1,048,576 bytes.
mbrotli speed / fastest peer (Google C): 1.236×; output: 1,048,581 / 1,048,581 bytes.
Exact measurements and confidence bounds
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| Google C | 120 | 119.8–120.1 | 8,334.97 | 1,048,581 | 100.000% | 1.000× |
| mbrotli | 97.05 | 96.92–97.19 | 10,304.49 | 1,048,581 | 100.000% | 1.236× |
| Rust brotli | 5,479 | 5,471–5,487 | 182.52 | 1,049,345 | 100.073% | 0.022× |
| SIMD Brotli | 4,842 | 4,772–4,915 | 206.53 | 1,049,345 | 100.073% | 0.025× |
| Burli | 684.9 | 684.2–686 | 1,460.02 | 1,048,625 | 100.005% | 0.175× |
random-64k
64 KiB prefix of the deterministic pseudorandom corpus. Input: 65,536 bytes.
mbrotli speed / fastest peer (Google C): 1.190×; output: 65,540 / 65,540 bytes.
Exact measurements and confidence bounds
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| Google C | 7.645 | 7.636–7.658 | 8,175.05 | 65,540 | 100.006% | 1.000× |
| mbrotli | 6.426 | 6.423–6.43 | 9,726.20 | 65,540 | 100.006% | 1.190× |
| Rust brotli | 290.8 | 290.3–291.3 | 214.95 | 65,585 | 100.075% | 0.026× |
| SIMD Brotli | 234.2 | 233.1–235.7 | 266.88 | 65,585 | 100.075% | 0.033× |
| Burli | 46.87 | 46.85–46.89 | 1,333.58 | 65,540 | 100.006% | 0.163× |
tiny-text
44-byte quick-brown-fox sentence. Input: 44 bytes.
mbrotli speed / fastest peer (Google C): 1.057×; output: 48 / 48 bytes.
Exact measurements and confidence bounds
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| Google C | 1.573 | 1.572–1.575 | 26.68 | 48 | 109.091% | 1.000× |
| mbrotli | 1.489 | 1.487–1.491 | 28.18 | 48 | 109.091% | 1.057× |
| Rust brotli | 2.262 | 2.258–2.267 | 18.55 | 48 | 109.091% | 0.696× |
| SIMD Brotli | 2.112 | 2.105–2.119 | 19.87 | 48 | 109.091% | 0.745× |
| Burli | 2.322 | 2.32–2.325 | 18.07 | 48 | 109.091% | 0.677× |
alice29
Vendored Alice in Wonderland text. Input: 152,089 bytes.
mbrotli speed / fastest peer (Burli): 0.733×; output: 60,292 / 63,139 bytes.
Exact measurements and confidence bounds
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| Google C | 485.4 | 484.8–486.1 | 298.79 | 60,292 | 39.643% | 1.000× |
| mbrotli | 471.7 | 471.1–472.2 | 307.52 | 60,292 | 39.643% | 1.029× |
| Rust brotli | 1,213 | 1,213–1,214 | 119.57 | 77,847 | 51.185% | 0.400× |
| SIMD Brotli | 958.1 | 957.1–959.3 | 151.39 | 77,847 | 51.185% | 0.507× |
| Burli | 345.5 | 342.8–348.4 | 419.80 | 63,139 | 41.515% | 1.405× |
empty
Empty input; measures API overhead. Input: 0 bytes.
mbrotli speed / fastest peer (Google C): 0.511×; output: 1 / 1 bytes.
Exact measurements and confidence bounds
| Implementation | Mean, ns | 95% mean interval, ns | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| Google C | 17.69 | 17.65–17.73 | — | 1 | — | 1.000× |
| mbrotli | 34.62 | 34.41–34.98 | — | 1 | — | 0.511× |
| Rust brotli | 184.1 | 182.5–185.7 | — | 1 | — | 0.096× |
| SIMD Brotli | 185.3 | 184.8–185.7 | — | 1 | — | 0.095× |
| Burli | 124.9 | 124.7–125.2 | — | 1 | — | 0.142× |
text-1m
Alice text repeated cyclically to 1 MiB. Input: 1,048,576 bytes.
mbrotli speed / fastest peer (Burli): 0.333×; output: 138,777 / 63,156 bytes.
Exact measurements and confidence bounds
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| Google C | 1,313 | 1,312–1,314 | 761.75 | 138,777 | 13.235% | 1.000× |
| mbrotli | 1,304 | 1,304–1,305 | 766.68 | 138,777 | 13.235% | 1.006× |
| Rust brotli | 8,440 | 8,435–8,446 | 118.48 | 536,268 | 51.143% | 0.156× |
| SIMD Brotli | 6,707 | 6,703–6,710 | 149.11 | 536,268 | 51.143% | 0.196× |
| Burli | 434.3 | 433.2–435.4 | 2,302.69 | 63,156 | 6.023% | 3.023× |
Compression quality 2
Benchmark index · Median overview · ← Quality 1 · Quality 3 →
Recorded run: enc-2026-10-30-amd (2026-10-30). Raw results · Environment · Run analysis.
Median across all datasets
Each of the eight datasets has equal weight, including empty and tiny input. Speed / C = C mean latency / implementation mean latency; output / C = implementation bytes / C bytes. We take the median of these eight per-dataset ratios separately for speed and size. 1× matches C; higher speed and lower output are better. These are medians across datasets, not median sample latencies, and do not imply equal compressed size or statistical significance.
| Implementation | Median speed / C ↑ | Median output / C ↓ | Datasets |
|---|---|---|---|
| Google C | 1.000× | 1.000× | 8 |
| mbrotli | 1.020× | 1.000× | 8 |
| Rust brotli | 0.355× | 1.000× | 8 |
| SIMD Brotli | 0.336× | 1.000× | 8 |
| Burli | 0.793× | 1.000× | 8 |
Measurement setup and interpretation
Google C Brotli 1.2.0, mbrotli at the recorded optimized checkout, Rust brotli 9.0.0, and SIMD Brotli 10.0.1 are compared at this quality. Burli 0.3.3 is also included.
These are cold native API measurements: construction, allocation, compression, and disposal. Generic mode, window 22, known input size; 20 samples, 1 s warmup, at least 3 s measurement. Host: AMD Ryzen 9 7950X (two KVM vCPUs), one benchmark process at a time pinned to logical CPU 1; codec calls and encoder/decoder sweeps ran serially; no concurrent builds, tests, fuzzers or profilers; unset; ordinary Cargo release defaults; runtime SIMD dispatch. Rust brotli and SIMD Brotli include their native 4 KiB I/O adapters.
Chart whiskers and table intervals describe 95% confidence bounds on mean timing. They do not capture all host or allocator variation. Throughput bounds are transformed latency bounds. Output / input is compressed bytes divided by input bytes; smaller is better, and values above 100% mean expansion. For empty input, throughput and output / input are undefined (—). See the run analysis for measurement limitations.
Dataset details
Ordered by mbrotli speed / fastest competing implementation, highest first; all eight datasets are shown. This order uses recorded mean latency, not statistical significance. Bars start at zero.
repeated-1m · binary-64k · text-1m · random-64k · random-1m · alice29 · tiny-text · empty
repeated-1m
1 MiB of repeated a bytes. Input: 1,048,576 bytes.
mbrotli speed / fastest peer (Burli): 2.676×; output: 23 / 23 bytes.
Exact measurements and confidence bounds
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| Google C | 468.3 | 468.1–468.4 | 2,135.46 | 23 | 0.002% | 1.000× |
| mbrotli | 51.14 | 51.08–51.21 | 19,553.21 | 23 | 0.002% | 9.156× |
| Rust brotli | 1,211 | 1,209–1,213 | 826.07 | 23 | 0.002% | 0.387× |
| SIMD Brotli | 1,389 | 1,382–1,396 | 719.93 | 23 | 0.002% | 0.337× |
| Burli | 136.9 | 136.6–137.1 | 7,307.17 | 23 | 0.002% | 3.422× |
binary-64k
64 KiB of structured binary bytes: ((i / 16) XOR i) modulo 256. Input: 65,536 bytes.
mbrotli speed / fastest peer (Burli): 1.208×; output: 2,673 / 2,688 bytes.
Exact measurements and confidence bounds
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| Google C | 47.3 | 47.27–47.33 | 1,321.36 | 2,673 | 4.079% | 1.000× |
| mbrotli | 23.06 | 23.04–23.07 | 2,710.71 | 2,673 | 4.079% | 2.051× |
| Rust brotli | 124.1 | 123.9–124.4 | 503.57 | 2,673 | 4.079% | 0.381× |
| SIMD Brotli | 131.6 | 131.5–131.9 | 474.76 | 2,673 | 4.079% | 0.359× |
| Burli | 27.85 | 27.8–27.91 | 2,244.26 | 2,688 | 4.102% | 1.698× |
text-1m
Alice text repeated cyclically to 1 MiB. Input: 1,048,576 bytes.
mbrotli speed / fastest peer (Burli): 1.103×; output: 57,403 / 58,006 bytes.
Exact measurements and confidence bounds
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| Google C | 1,308 | 1,308–1,309 | 764.36 | 57,403 | 5.474% | 1.000× |
| mbrotli | 959.6 | 958.7–960.5 | 1,042.11 | 57,403 | 5.474% | 1.363× |
| Rust brotli | 2,262 | 2,255–2,271 | 441.99 | 57,389 | 5.473% | 0.578× |
| SIMD Brotli | 2,371 | 2,365–2,377 | 421.79 | 57,389 | 5.473% | 0.552× |
| Burli | 1,059 | 1,058–1,059 | 944.42 | 58,006 | 5.532% | 1.236× |
random-64k
64 KiB prefix of the deterministic pseudorandom corpus. Input: 65,536 bytes.
mbrotli speed / fastest peer (Google C): 1.032×; output: 65,540 / 65,540 bytes.
Exact measurements and confidence bounds
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| Google C | 25.78 | 25.74–25.82 | 2,424.57 | 65,540 | 100.006% | 1.000× |
| mbrotli | 24.99 | 24.96–25.02 | 2,501.20 | 65,540 | 100.006% | 1.032× |
| Rust brotli | 105.2 | 105–105.5 | 593.92 | 65,540 | 100.006% | 0.245× |
| SIMD Brotli | 101.2 | 101.1–101.3 | 617.60 | 65,540 | 100.006% | 0.255× |
| Burli | 46.88 | 46.85–46.9 | 1,333.31 | 65,540 | 100.006% | 0.550× |
random-1m
1 MiB of deterministic xorshift64 pseudorandom bytes. Input: 1,048,576 bytes.
mbrotli speed / fastest peer (Google C): 1.008×; output: 1,048,581 / 1,048,581 bytes.
Exact measurements and confidence bounds
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| Google C | 434.3 | 434.1–434.7 | 2,302.30 | 1,048,581 | 100.000% | 1.000× |
| mbrotli | 430.8 | 430.6–431 | 2,321.38 | 1,048,581 | 100.000% | 1.008× |
| Rust brotli | 1,358 | 1,351–1,366 | 736.14 | 1,048,581 | 100.000% | 0.320× |
| SIMD Brotli | 1,294 | 1,292–1,296 | 772.83 | 1,048,581 | 100.000% | 0.336× |
| Burli | 684.8 | 684.4–685.3 | 1,460.29 | 1,048,625 | 100.005% | 0.634× |
alice29
Vendored Alice in Wonderland text. Input: 152,089 bytes.
mbrotli speed / fastest peer (Google C): 0.995×; output: 57,383 / 57,383 bytes.
Exact measurements and confidence bounds
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| Google C | 893.4 | 892.7–894.2 | 162.35 | 57,383 | 37.730% | 1.000× |
| mbrotli | 897.6 | 896.5–899.2 | 161.60 | 57,383 | 37.730% | 0.995× |
| Rust brotli | 1,314 | 1,306–1,324 | 110.38 | 57,372 | 37.723% | 0.680× |
| SIMD Brotli | 1,271 | 1,270–1,273 | 114.10 | 57,372 | 37.723% | 0.703× |
| Burli | 939.2 | 938.4–939.9 | 154.44 | 57,989 | 38.128% | 0.951× |
tiny-text
44-byte quick-brown-fox sentence. Input: 44 bytes.
mbrotli speed / fastest peer (Google C): 0.989×; output: 48 / 48 bytes.
Exact measurements and confidence bounds
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| Google C | 0.9911 | 0.9886–0.9947 | 42.34 | 48 | 109.091% | 1.000× |
| mbrotli | 1.002 | 1–1.004 | 41.88 | 48 | 109.091% | 0.989× |
| Rust brotli | 3.021 | 3.018–3.026 | 13.89 | 48 | 109.091% | 0.328× |
| SIMD Brotli | 3.027 | 3.024–3.03 | 13.86 | 48 | 109.091% | 0.327× |
| Burli | 1.948 | 1.946–1.951 | 21.54 | 48 | 109.091% | 0.509× |
empty
Empty input; measures API overhead. Input: 0 bytes.
mbrotli speed / fastest peer (Google C): 0.509×; output: 1 / 1 bytes.
Exact measurements and confidence bounds
| Implementation | Mean, ns | 95% mean interval, ns | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| Google C | 17.81 | 17.72–17.93 | — | 1 | — | 1.000× |
| mbrotli | 34.98 | 34.53–35.53 | — | 1 | — | 0.509× |
| Rust brotli | 1,902 | 1,900–1,905 | — | 1 | — | 0.009× |
| SIMD Brotli | 1,880 | 1,877–1,882 | — | 1 | — | 0.009× |
| Burli | 125.1 | 124.7–125.6 | — | 1 | — | 0.142× |
Compression quality 3
Benchmark index · Median overview · ← Quality 2 · Quality 4 →
Recorded run: enc-2026-10-30-amd (2026-10-30). Raw results · Environment · Run analysis.
Median across all datasets
Each of the eight datasets has equal weight, including empty and tiny input. Speed / C = C mean latency / implementation mean latency; output / C = implementation bytes / C bytes. We take the median of these eight per-dataset ratios separately for speed and size. 1× matches C; higher speed and lower output are better. These are medians across datasets, not median sample latencies, and do not imply equal compressed size or statistical significance.
| Implementation | Median speed / C ↑ | Median output / C ↓ | Datasets |
|---|---|---|---|
| Google C | 1.000× | 1.000× | 8 |
| mbrotli | 1.089× | 1.000× | 8 |
| Rust brotli | 0.409× | 1.000× | 8 |
| SIMD Brotli | 0.401× | 1.000× | 8 |
| Burli | 0.952× | 1.006× | 8 |
Measurement setup and interpretation
Google C Brotli 1.2.0, mbrotli at the recorded optimized checkout, Rust brotli 9.0.0, and SIMD Brotli 10.0.1 are compared at this quality. Burli 0.3.3 is also included.
These are cold native API measurements: construction, allocation, compression, and disposal. Generic mode, window 22, known input size; 20 samples, 1 s warmup, at least 3 s measurement. Host: AMD Ryzen 9 7950X (two KVM vCPUs), one benchmark process at a time pinned to logical CPU 1; codec calls and encoder/decoder sweeps ran serially; no concurrent builds, tests, fuzzers or profilers; unset; ordinary Cargo release defaults; runtime SIMD dispatch. Rust brotli and SIMD Brotli include their native 4 KiB I/O adapters.
Chart whiskers and table intervals describe 95% confidence bounds on mean timing. They do not capture all host or allocator variation. Throughput bounds are transformed latency bounds. Output / input is compressed bytes divided by input bytes; smaller is better, and values above 100% mean expansion. For empty input, throughput and output / input are undefined (—). See the run analysis for measurement limitations.
Dataset details
Ordered by mbrotli speed / fastest competing implementation, highest first; all eight datasets are shown. This order uses recorded mean latency, not statistical significance. Bars start at zero.
repeated-1m · text-1m · binary-64k · random-64k · random-1m · tiny-text · alice29 · empty
repeated-1m
1 MiB of repeated a bytes. Input: 1,048,576 bytes.
mbrotli speed / fastest peer (Burli): 4.353×; output: 13 / 13 bytes.
Exact measurements and confidence bounds
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| Google C | 469.1 | 468.8–469.5 | 2,131.53 | 13 | 0.001% | 1.000× |
| mbrotli | 52.06 | 51.94–52.21 | 19,209.32 | 13 | 0.001% | 9.012× |
| Rust brotli | 1,212 | 1,211–1,214 | 825.03 | 13 | 0.001% | 0.387× |
| SIMD Brotli | 1,381 | 1,378–1,384 | 724.14 | 13 | 0.001% | 0.340× |
| Burli | 226.6 | 226.5–226.7 | 4,413.12 | 13 | 0.001% | 2.070× |
text-1m
Alice text repeated cyclically to 1 MiB. Input: 1,048,576 bytes.
mbrotli speed / fastest peer (Burli): 1.347×; output: 56,879 / 57,517 bytes.
Exact measurements and confidence bounds
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| Google C | 1,489 | 1,487–1,490 | 671.81 | 56,879 | 5.424% | 1.000× |
| mbrotli | 1,085 | 1,084–1,086 | 921.46 | 56,879 | 5.424% | 1.372× |
| Rust brotli | 2,395 | 2,382–2,408 | 417.54 | 56,893 | 5.426% | 0.622× |
| SIMD Brotli | 2,511 | 2,501–2,526 | 398.20 | 56,893 | 5.426% | 0.593× |
| Burli | 1,461 | 1,461–1,462 | 684.29 | 57,517 | 5.485% | 1.019× |
binary-64k
64 KiB of structured binary bytes: ((i / 16) XOR i) modulo 256. Input: 65,536 bytes.
mbrotli speed / fastest peer (Burli): 1.301×; output: 2,687 / 2,760 bytes.
Exact measurements and confidence bounds
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| Google C | 54.06 | 54.02–54.11 | 1,156.10 | 2,687 | 4.100% | 1.000× |
| mbrotli | 23.73 | 23.72–23.75 | 2,633.30 | 2,687 | 4.100% | 2.278× |
| Rust brotli | 128.3 | 128.2–128.5 | 486.95 | 2,653 | 4.048% | 0.421× |
| SIMD Brotli | 136.1 | 135.9–136.3 | 459.17 | 2,653 | 4.048% | 0.397× |
| Burli | 30.88 | 30.85–30.92 | 2,023.71 | 2,760 | 4.211% | 1.750× |
random-64k
64 KiB prefix of the deterministic pseudorandom corpus. Input: 65,536 bytes.
mbrotli speed / fastest peer (Google C): 1.099×; output: 65,540 / 65,540 bytes.
Exact measurements and confidence bounds
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| Google C | 34.04 | 34.02–34.07 | 1,835.97 | 65,540 | 100.006% | 1.000× |
| mbrotli | 30.99 | 30.96–31.01 | 2,017.06 | 65,540 | 100.006% | 1.099× |
| Rust brotli | 108.7 | 108.5–109.1 | 574.83 | 65,540 | 100.006% | 0.313× |
| SIMD Brotli | 108.7 | 108.6–108.8 | 574.95 | 65,540 | 100.006% | 0.313× |
| Burli | 46.9 | 46.87–46.93 | 1,332.71 | 65,540 | 100.006% | 0.726× |
random-1m
1 MiB of deterministic xorshift64 pseudorandom bytes. Input: 1,048,576 bytes.
mbrotli speed / fastest peer (Google C): 1.079×; output: 1,048,581 / 1,048,581 bytes.
Exact measurements and confidence bounds
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| Google C | 607.3 | 606.5–608.3 | 1,646.55 | 1,048,581 | 100.000% | 1.000× |
| mbrotli | 562.8 | 562.4–563.1 | 1,776.94 | 1,048,581 | 100.000% | 1.079× |
| Rust brotli | 1,533 | 1,522–1,543 | 652.52 | 1,048,581 | 100.000% | 0.396× |
| SIMD Brotli | 1,499 | 1,496–1,503 | 666.96 | 1,048,581 | 100.000% | 0.405× |
| Burli | 685.4 | 684.9–685.9 | 1,458.98 | 1,048,625 | 100.005% | 0.886× |
tiny-text
44-byte quick-brown-fox sentence. Input: 44 bytes.
mbrotli speed / fastest peer (Google C): 1.009×; output: 46 / 46 bytes.
Exact measurements and confidence bounds
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| Google C | 1.911 | 1.909–1.912 | 21.96 | 46 | 104.545% | 1.000× |
| mbrotli | 1.894 | 1.893–1.896 | 22.15 | 46 | 104.545% | 1.009× |
| Rust brotli | 4.086 | 4.081–4.091 | 10.27 | 46 | 104.545% | 0.468× |
| SIMD Brotli | 4.051 | 4.048–4.054 | 10.36 | 46 | 104.545% | 0.472× |
| Burli | 2.369 | 2.366–2.373 | 17.71 | 48 | 109.091% | 0.806× |
alice29
Vendored Alice in Wonderland text. Input: 152,089 bytes.
mbrotli speed / fastest peer (Burli): 0.976×; output: 56,863 / 58,702 bytes.
Exact measurements and confidence bounds
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| Google C | 1,082 | 1,079–1,084 | 134.08 | 56,863 | 37.388% | 1.000× |
| mbrotli | 1,024 | 1,023–1,025 | 141.65 | 56,863 | 37.388% | 1.056× |
| Rust brotli | 1,426 | 1,416–1,438 | 101.71 | 56,876 | 37.397% | 0.759× |
| SIMD Brotli | 1,408 | 1,405–1,411 | 103.05 | 56,876 | 37.397% | 0.769× |
| Burli | 999.6 | 999–1,000 | 145.10 | 58,702 | 38.597% | 1.082× |
empty
Empty input; measures API overhead. Input: 0 bytes.
mbrotli speed / fastest peer (Google C): 0.516×; output: 1 / 1 bytes.
Exact measurements and confidence bounds
| Implementation | Mean, ns | 95% mean interval, ns | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| Google C | 17.89 | 17.85–17.93 | — | 1 | — | 1.000× |
| mbrotli | 34.64 | 34.39–35.09 | — | 1 | — | 0.516× |
| Rust brotli | 1,915 | 1,912–1,918 | — | 1 | — | 0.009× |
| SIMD Brotli | 1,900 | 1,897–1,904 | — | 1 | — | 0.009× |
| Burli | 125.2 | 125–125.4 | — | 1 | — | 0.143× |
Compression quality 4
Benchmark index · Median overview · ← Quality 3 · Quality 5 →
Recorded run: enc-2026-10-30-amd (2026-10-30). Raw results · Environment · Run analysis.
Median across all datasets
Each of the eight datasets has equal weight, including empty and tiny input. Speed / C = C mean latency / implementation mean latency; output / C = implementation bytes / C bytes. We take the median of these eight per-dataset ratios separately for speed and size. 1× matches C; higher speed and lower output are better. These are medians across datasets, not median sample latencies, and do not imply equal compressed size or statistical significance.
| Implementation | Median speed / C ↑ | Median output / C ↓ | Datasets |
|---|---|---|---|
| Google C | 1.000× | 1.000× | 8 |
| mbrotli | 1.183× | 1.000× | 8 |
| Rust brotli | 0.529× | 1.000× | 8 |
| SIMD Brotli | 0.488× | 1.000× | 8 |
| Burli | 1.201× | 1.000× | 8 |
Measurement setup and interpretation
Google C Brotli 1.2.0, mbrotli at the recorded optimized checkout, Rust brotli 9.0.0, and SIMD Brotli 10.0.1 are compared at this quality. Burli 0.3.3 is also included.
These are cold native API measurements: construction, allocation, compression, and disposal. Generic mode, window 22, known input size; 20 samples, 1 s warmup, at least 3 s measurement. Host: AMD Ryzen 9 7950X (two KVM vCPUs), one benchmark process at a time pinned to logical CPU 1; codec calls and encoder/decoder sweeps ran serially; no concurrent builds, tests, fuzzers or profilers; unset; ordinary Cargo release defaults; runtime SIMD dispatch. Rust brotli and SIMD Brotli include their native 4 KiB I/O adapters.
Chart whiskers and table intervals describe 95% confidence bounds on mean timing. They do not capture all host or allocator variation. Throughput bounds are transformed latency bounds. Output / input is compressed bytes divided by input bytes; smaller is better, and values above 100% mean expansion. For empty input, throughput and output / input are undefined (—). See the run analysis for measurement limitations.
Dataset details
Ordered by mbrotli speed / fastest competing implementation, highest first; all eight datasets are shown. This order uses recorded mean latency, not statistical significance. Bars start at zero.
repeated-1m · text-1m · random-64k · tiny-text · alice29 · binary-64k · random-1m · empty
repeated-1m
1 MiB of repeated a bytes. Input: 1,048,576 bytes.
mbrotli speed / fastest peer (Burli): 3.002×; output: 13 / 13 bytes.
Exact measurements and confidence bounds
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| Google C | 477 | 476.6–477.6 | 2,096.23 | 13 | 0.001% | 1.000× |
| mbrotli | 77.33 | 77.25–77.4 | 12,932.26 | 13 | 0.001% | 6.169× |
| Rust brotli | 906.8 | 901–913.1 | 1,102.84 | 13 | 0.001% | 0.526× |
| SIMD Brotli | 1,050 | 1,049–1,052 | 952.25 | 13 | 0.001% | 0.454× |
| Burli | 232.2 | 231.9–232.4 | 4,307.28 | 13 | 0.001% | 2.055× |
text-1m
Alice text repeated cyclically to 1 MiB. Input: 1,048,576 bytes.
mbrotli speed / fastest peer (Burli): 1.245×; output: 55,915 / 57,773 bytes.
Exact measurements and confidence bounds
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| Google C | 1,723 | 1,722–1,725 | 580.29 | 55,915 | 5.332% | 1.000× |
| mbrotli | 1,362 | 1,360–1,365 | 734.22 | 55,915 | 5.332% | 1.265× |
| Rust brotli | 2,432 | 2,413–2,455 | 411.11 | 55,891 | 5.330% | 0.708× |
| SIMD Brotli | 2,521 | 2,509–2,536 | 396.73 | 55,891 | 5.330% | 0.684× |
| Burli | 1,696 | 1,694–1,698 | 589.79 | 57,773 | 5.510% | 1.016× |
random-64k
64 KiB prefix of the deterministic pseudorandom corpus. Input: 65,536 bytes.
mbrotli speed / fastest peer (Google C): 1.100×; output: 65,540 / 65,540 bytes.
Exact measurements and confidence bounds
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| Google C | 45.47 | 45.43–45.52 | 1,374.44 | 65,540 | 100.006% | 1.000× |
| mbrotli | 41.33 | 41.3–41.36 | 1,512.21 | 65,540 | 100.006% | 1.100× |
| Rust brotli | 126.6 | 126.2–127 | 493.68 | 65,540 | 100.006% | 0.359× |
| SIMD Brotli | 124.4 | 124.2–124.5 | 502.60 | 65,540 | 100.006% | 0.366× |
| Burli | 46.92 | 46.87–46.97 | 1,332.13 | 65,540 | 100.006% | 0.969× |
tiny-text
44-byte quick-brown-fox sentence. Input: 44 bytes.
mbrotli speed / fastest peer (Burli): 1.026×; output: 48 / 48 bytes.
Exact measurements and confidence bounds
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| Google C | 4.597 | 4.593–4.602 | 9.13 | 48 | 109.091% | 1.000× |
| mbrotli | 3.162 | 3.157–3.168 | 13.27 | 48 | 109.091% | 1.454× |
| Rust brotli | 8.656 | 8.647–8.666 | 4.85 | 48 | 109.091% | 0.531× |
| SIMD Brotli | 8.822 | 8.818–8.826 | 4.76 | 48 | 109.091% | 0.521× |
| Burli | 3.245 | 3.242–3.247 | 12.93 | 48 | 109.091% | 1.417× |
alice29
Vendored Alice in Wonderland text. Input: 152,089 bytes.
mbrotli speed / fastest peer (Burli): 0.971×; output: 55,504 / 57,813 bytes.
Exact measurements and confidence bounds
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| Google C | 1,561 | 1,559–1,563 | 92.94 | 55,504 | 36.494% | 1.000× |
| mbrotli | 1,510 | 1,509–1,511 | 96.04 | 55,504 | 36.494% | 1.033× |
| Rust brotli | 1,921 | 1,913–1,930 | 75.51 | 55,489 | 36.485% | 0.812× |
| SIMD Brotli | 1,909 | 1,906–1,911 | 75.99 | 55,489 | 36.485% | 0.818× |
| Burli | 1,466 | 1,465–1,467 | 98.94 | 57,813 | 38.013% | 1.065× |
binary-64k
64 KiB of structured binary bytes: ((i / 16) XOR i) modulo 256. Input: 65,536 bytes.
mbrotli speed / fastest peer (Burli): 0.863×; output: 2,554 / 2,593 bytes.
Exact measurements and confidence bounds
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| Google C | 56.18 | 56.14–56.22 | 1,112.53 | 2,554 | 3.897% | 1.000× |
| mbrotli | 40.23 | 40.2–40.26 | 1,553.51 | 2,554 | 3.897% | 1.396× |
| Rust brotli | 157.4 | 157.1–157.7 | 397.08 | 2,567 | 3.917% | 0.357× |
| SIMD Brotli | 157.5 | 157–158 | 396.84 | 2,567 | 3.917% | 0.357× |
| Burli | 34.71 | 34.68–34.74 | 1,800.78 | 2,593 | 3.957% | 1.619× |
random-1m
1 MiB of deterministic xorshift64 pseudorandom bytes. Input: 1,048,576 bytes.
mbrotli speed / fastest peer (Burli): 0.766×; output: 1,048,581 / 1,048,625 bytes.
Exact measurements and confidence bounds
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| Google C | 917.7 | 917.1–918.3 | 1,089.70 | 1,048,581 | 100.000% | 1.000× |
| mbrotli | 895.9 | 894.9–896.8 | 1,116.24 | 1,048,581 | 100.000% | 1.024× |
| Rust brotli | 1,312 | 1,309–1,315 | 762.41 | 1,048,581 | 100.000% | 0.700× |
| SIMD Brotli | 1,310 | 1,305–1,317 | 763.37 | 1,048,581 | 100.000% | 0.701× |
| Burli | 685.9 | 685.3–686.5 | 1,458.04 | 1,048,625 | 100.005% | 1.338× |
empty
Empty input; measures API overhead. Input: 0 bytes.
mbrotli speed / fastest peer (Google C): 0.522×; output: 1 / 1 bytes.
Exact measurements and confidence bounds
| Implementation | Mean, ns | 95% mean interval, ns | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| Google C | 18.13 | 17.97–18.38 | — | 1 | — | 1.000× |
| mbrotli | 34.72 | 34.46–35.16 | — | 1 | — | 0.522× |
| Rust brotli | 3,498 | 3,496–3,500 | — | 1 | — | 0.005× |
| SIMD Brotli | 3,524 | 3,520–3,527 | — | 1 | — | 0.005× |
| Burli | 125.4 | 125.2–125.5 | — | 1 | — | 0.145× |
Compression quality 5
Benchmark index · Median overview · ← Quality 4 · Quality 6 →
Recorded run: enc-2026-10-30-amd (2026-10-30). Raw results · Environment · Run analysis.
Median across all datasets
Each of the eight datasets has equal weight, including empty and tiny input. Speed / C = C mean latency / implementation mean latency; output / C = implementation bytes / C bytes. We take the median of these eight per-dataset ratios separately for speed and size. 1× matches C; higher speed and lower output are better. These are medians across datasets, not median sample latencies, and do not imply equal compressed size or statistical significance.
| Implementation | Median speed / C ↑ | Median output / C ↓ | Datasets |
|---|---|---|---|
| Google C | 1.000× | 1.000× | 8 |
| mbrotli | 1.017× | 1.000× | 8 |
| Rust brotli | 0.442× | 1.000× | 8 |
| SIMD Brotli | 0.355× | 1.000× | 8 |
| Burli | 0.942× | 1.000× | 8 |
Measurement setup and interpretation
Google C Brotli 1.2.0, mbrotli at the recorded optimized checkout, Rust brotli 9.0.0, and SIMD Brotli 10.0.1 are compared at this quality. Burli 0.3.3 is also included.
These are cold native API measurements: construction, allocation, compression, and disposal. Generic mode, window 22, known input size; 20 samples, 1 s warmup, at least 3 s measurement. Host: AMD Ryzen 9 7950X (two KVM vCPUs), one benchmark process at a time pinned to logical CPU 1; codec calls and encoder/decoder sweeps ran serially; no concurrent builds, tests, fuzzers or profilers; unset; ordinary Cargo release defaults; runtime SIMD dispatch. Rust brotli and SIMD Brotli include their native 4 KiB I/O adapters.
Chart whiskers and table intervals describe 95% confidence bounds on mean timing. They do not capture all host or allocator variation. Throughput bounds are transformed latency bounds. Output / input is compressed bytes divided by input bytes; smaller is better, and values above 100% mean expansion. For empty input, throughput and output / input are undefined (—). See the run analysis for measurement limitations.
Dataset details
Ordered by mbrotli speed / fastest competing implementation, highest first; all eight datasets are shown. This order uses recorded mean latency, not statistical significance. Bars start at zero.
repeated-1m · text-1m · binary-64k · alice29 · tiny-text · random-1m · random-64k · empty
repeated-1m
1 MiB of repeated a bytes. Input: 1,048,576 bytes.
mbrotli speed / fastest peer (Google C): 5.073×; output: 13 / 13 bytes.
Exact measurements and confidence bounds
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| Google C | 475.2 | 474.9–475.5 | 2,104.23 | 13 | 0.001% | 1.000× |
| mbrotli | 93.67 | 93.58–93.77 | 10,675.73 | 13 | 0.001% | 5.073× |
| Rust brotli | 955.8 | 950.8–961 | 1,046.23 | 13 | 0.001% | 0.497× |
| SIMD Brotli | 1,166 | 1,161–1,173 | 857.68 | 13 | 0.001% | 0.408× |
| Burli | 953.3 | 952.9–953.8 | 1,048.96 | 13 | 0.001% | 0.499× |
text-1m
Alice text repeated cyclically to 1 MiB. Input: 1,048,576 bytes.
mbrotli speed / fastest peer (Google C): 1.167×; output: 51,228 / 51,228 bytes.
Exact measurements and confidence bounds
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| Google C | 2,787 | 2,783–2,791 | 358.86 | 51,228 | 4.885% | 1.000× |
| mbrotli | 2,389 | 2,385–2,394 | 418.62 | 51,228 | 4.885% | 1.167× |
| Rust brotli | 3,913 | 3,896–3,932 | 255.57 | 52,238 | 4.982% | 0.712× |
| SIMD Brotli | 4,187 | 4,182–4,194 | 238.82 | 51,238 | 4.886% | 0.665× |
| Burli | 3,482 | 3,479–3,485 | 287.19 | 52,664 | 5.022% | 0.800× |
binary-64k
64 KiB of structured binary bytes: ((i / 16) XOR i) modulo 256. Input: 65,536 bytes.
mbrotli speed / fastest peer (Google C): 0.975×; output: 1,588 / 1,588 bytes.
Exact measurements and confidence bounds
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| Google C | 74.02 | 73.9–74.12 | 844.39 | 1,588 | 2.423% | 1.000× |
| mbrotli | 75.89 | 75.8–75.97 | 823.55 | 1,588 | 2.423% | 0.975× |
| Rust brotli | 219.8 | 219.6–220 | 284.38 | 1,588 | 2.423% | 0.337× |
| SIMD Brotli | 337.9 | 337.3–338.6 | 184.95 | 1,588 | 2.423% | 0.219× |
| Burli | 102.5 | 102.4–102.6 | 609.86 | 1,593 | 2.431% | 0.722× |
alice29
Vendored Alice in Wonderland text. Input: 152,089 bytes.
mbrotli speed / fastest peer (Burli): 0.967×; output: 52,809 / 54,228 bytes.
Exact measurements and confidence bounds
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| Google C | 2,362 | 2,361–2,364 | 61.40 | 52,809 | 34.722% | 1.000× |
| mbrotli | 2,256 | 2,255–2,258 | 64.29 | 52,809 | 34.722% | 1.047× |
| Rust brotli | 3,018 | 3,013–3,024 | 48.05 | 52,808 | 34.722% | 0.783× |
| SIMD Brotli | 3,403 | 3,394–3,414 | 42.62 | 52,808 | 34.722% | 0.694× |
| Burli | 2,181 | 2,179–2,184 | 66.49 | 54,228 | 35.655% | 1.083× |
tiny-text
44-byte quick-brown-fox sentence. Input: 44 bytes.
mbrotli speed / fastest peer (Burli): 0.924×; output: 48 / 48 bytes.
Exact measurements and confidence bounds
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| Google C | 4.766 | 4.763–4.769 | 8.80 | 48 | 109.091% | 1.000× |
| mbrotli | 3.367 | 3.364–3.369 | 12.46 | 48 | 109.091% | 1.416× |
| Rust brotli | 14.23 | 14.2–14.26 | 2.95 | 48 | 109.091% | 0.335× |
| SIMD Brotli | 17.05 | 17.04–17.07 | 2.46 | 48 | 109.091% | 0.279× |
| Burli | 3.11 | 3.107–3.113 | 13.49 | 48 | 109.091% | 1.533× |
random-1m
1 MiB of deterministic xorshift64 pseudorandom bytes. Input: 1,048,576 bytes.
mbrotli speed / fastest peer (Burli): 0.581×; output: 1,048,581 / 1,048,625 bytes.
Exact measurements and confidence bounds
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| Google C | 1,169 | 1,168–1,170 | 855.60 | 1,048,581 | 100.000% | 1.000× |
| mbrotli | 1,183 | 1,182–1,184 | 845.21 | 1,048,581 | 100.000% | 0.988× |
| Rust brotli | 2,548 | 2,545–2,550 | 392.50 | 1,048,581 | 100.000% | 0.459× |
| SIMD Brotli | 2,606 | 2,602–2,609 | 383.78 | 1,048,581 | 100.000% | 0.449× |
| Burli | 686.8 | 686–687.7 | 1,455.92 | 1,048,625 | 100.005% | 1.702× |
random-64k
64 KiB prefix of the deterministic pseudorandom corpus. Input: 65,536 bytes.
mbrotli speed / fastest peer (Burli): 0.567×; output: 65,540 / 65,540 bytes.
Exact measurements and confidence bounds
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| Google C | 72.8 | 72.74–72.86 | 858.54 | 65,540 | 100.006% | 1.000× |
| mbrotli | 82.64 | 82.38–82.9 | 756.27 | 65,540 | 100.006% | 0.881× |
| Rust brotli | 171.5 | 171.1–171.8 | 364.52 | 65,540 | 100.006% | 0.425× |
| SIMD Brotli | 240.1 | 239.9–240.4 | 260.30 | 65,540 | 100.006% | 0.303× |
| Burli | 46.87 | 46.84–46.89 | 1,333.59 | 65,540 | 100.006% | 1.553× |
empty
Empty input; measures API overhead. Input: 0 bytes.
mbrotli speed / fastest peer (Google C): 0.518×; output: 1 / 1 bytes.
Exact measurements and confidence bounds
| Implementation | Mean, ns | 95% mean interval, ns | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| Google C | 17.87 | 17.84–17.9 | — | 1 | — | 1.000× |
| mbrotli | 34.53 | 34.28–34.96 | — | 1 | — | 0.518× |
| Rust brotli | 8,399 | 8,392–8,408 | — | 1 | — | 0.002× |
| SIMD Brotli | 1.107e+04 | 1.106e+04–1.109e+04 | — | 1 | — | 0.002× |
| Burli | 124.7 | 124.5–125 | — | 1 | — | 0.143× |
Compression quality 6
Benchmark index · Median overview · ← Quality 5 · Quality 7 →
Recorded run: enc-2026-10-30-amd (2026-10-30). Raw results · Environment · Run analysis.
Median across all datasets
Each of the eight datasets has equal weight, including empty and tiny input. Speed / C = C mean latency / implementation mean latency; output / C = implementation bytes / C bytes. We take the median of these eight per-dataset ratios separately for speed and size. 1× matches C; higher speed and lower output are better. These are medians across datasets, not median sample latencies, and do not imply equal compressed size or statistical significance.
| Implementation | Median speed / C ↑ | Median output / C ↓ | Datasets |
|---|---|---|---|
| Google C | 1.000× | 1.000× | 8 |
| mbrotli | 1.008× | 1.000× | 8 |
| Rust brotli | 0.404× | 1.000× | 8 |
| SIMD Brotli | 0.344× | 1.000× | 8 |
Measurement setup and interpretation
Google C Brotli 1.2.0, mbrotli at the recorded optimized checkout, Rust brotli 9.0.0, and SIMD Brotli 10.0.1 are compared at this quality. Burli supports only qualities 0–5; it has no results at this quality.
These are cold native API measurements: construction, allocation, compression, and disposal. Generic mode, window 22, known input size; 20 samples, 1 s warmup, at least 3 s measurement. Host: AMD Ryzen 9 7950X (two KVM vCPUs), one benchmark process at a time pinned to logical CPU 1; codec calls and encoder/decoder sweeps ran serially; no concurrent builds, tests, fuzzers or profilers; unset; ordinary Cargo release defaults; runtime SIMD dispatch. Rust brotli and SIMD Brotli include their native 4 KiB I/O adapters.
Chart whiskers and table intervals describe 95% confidence bounds on mean timing. They do not capture all host or allocator variation. Throughput bounds are transformed latency bounds. Output / input is compressed bytes divided by input bytes; smaller is better, and values above 100% mean expansion. For empty input, throughput and output / input are undefined (—). See the run analysis for measurement limitations.
Dataset details
Ordered by mbrotli speed / fastest competing implementation, highest first; all eight datasets are shown. This order uses recorded mean latency, not statistical significance. Bars start at zero.
repeated-1m · tiny-text · text-1m · alice29 · random-1m · binary-64k · empty · random-64k
repeated-1m
1 MiB of repeated a bytes. Input: 1,048,576 bytes.
mbrotli speed / fastest peer (Google C): 4.036×; output: 13 / 13 bytes.
Exact measurements and confidence bounds
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| Google C | 473.4 | 473–474 | 2,112.56 | 13 | 0.001% | 1.000× |
| mbrotli | 117.3 | 117.1–117.4 | 8,526.35 | 13 | 0.001% | 4.036× |
| Rust brotli | 961 | 957.3–965.1 | 1,040.61 | 13 | 0.001% | 0.493× |
| SIMD Brotli | 1,198 | 1,194–1,202 | 834.99 | 13 | 0.001% | 0.395× |
tiny-text
44-byte quick-brown-fox sentence. Input: 44 bytes.
mbrotli speed / fastest peer (Google C): 1.410×; output: 48 / 48 bytes.
Exact measurements and confidence bounds
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| Google C | 4.641 | 4.637–4.647 | 9.04 | 48 | 109.091% | 1.000× |
| mbrotli | 3.291 | 3.288–3.294 | 12.75 | 48 | 109.091% | 1.410× |
| Rust brotli | 21.48 | 21.46–21.5 | 1.95 | 48 | 109.091% | 0.216× |
| SIMD Brotli | 26.1 | 26.07–26.12 | 1.61 | 48 | 109.091% | 0.178× |
text-1m
Alice text repeated cyclically to 1 MiB. Input: 1,048,576 bytes.
mbrotli speed / fastest peer (Google C): 1.166×; output: 50,823 / 50,823 bytes.
Exact measurements and confidence bounds
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| Google C | 3,013 | 3,009–3,017 | 331.90 | 50,823 | 4.847% | 1.000× |
| mbrotli | 2,584 | 2,577–2,594 | 386.93 | 50,823 | 4.847% | 1.166× |
| Rust brotli | 4,679 | 4,652–4,705 | 213.74 | 51,421 | 4.904% | 0.644× |
| SIMD Brotli | 4,506 | 4,495–4,518 | 221.95 | 50,830 | 4.848% | 0.669× |
alice29
Vendored Alice in Wonderland text. Input: 152,089 bytes.
mbrotli speed / fastest peer (Google C): 1.043×; output: 51,967 / 51,967 bytes.
Exact measurements and confidence bounds
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| Google C | 2,633 | 2,631–2,635 | 55.08 | 51,967 | 34.169% | 1.000× |
| mbrotli | 2,523 | 2,522–2,525 | 57.48 | 51,967 | 34.169% | 1.043× |
| Rust brotli | 3,728 | 3,718–3,736 | 38.91 | 51,969 | 34.170% | 0.706× |
| SIMD Brotli | 3,702 | 3,694–3,712 | 39.18 | 51,969 | 34.170% | 0.711× |
random-1m
1 MiB of deterministic xorshift64 pseudorandom bytes. Input: 1,048,576 bytes.
mbrotli speed / fastest peer (Google C): 0.972×; output: 1,048,581 / 1,048,581 bytes.
Exact measurements and confidence bounds
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| Google C | 1,251 | 1,249–1,254 | 799.08 | 1,048,581 | 100.000% | 1.000× |
| mbrotli | 1,288 | 1,287–1,289 | 776.50 | 1,048,581 | 100.000% | 0.972× |
| Rust brotli | 2,924 | 2,915–2,934 | 341.98 | 1,048,581 | 100.000% | 0.428× |
| SIMD Brotli | 2,773 | 2,750–2,800 | 360.59 | 1,048,581 | 100.000% | 0.451× |
binary-64k
64 KiB of structured binary bytes: ((i / 16) XOR i) modulo 256. Input: 65,536 bytes.
mbrotli speed / fastest peer (Google C): 0.715×; output: 1,588 / 1,588 bytes.
Exact measurements and confidence bounds
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| Google C | 74.72 | 74.59–74.83 | 836.48 | 1,588 | 2.423% | 1.000× |
| mbrotli | 104.5 | 104.2–104.7 | 598.33 | 1,588 | 2.423% | 0.715× |
| Rust brotli | 251.5 | 250.4–252.9 | 248.50 | 1,588 | 2.423% | 0.297× |
| SIMD Brotli | 358 | 357.3–358.7 | 174.60 | 1,588 | 2.423% | 0.209× |
empty
Empty input; measures API overhead. Input: 0 bytes.
mbrotli speed / fastest peer (Google C): 0.518×; output: 1 / 1 bytes.
Exact measurements and confidence bounds
| Implementation | Mean, ns | 95% mean interval, ns | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| Google C | 17.91 | 17.86–17.97 | — | 1 | — | 1.000× |
| mbrotli | 34.57 | 34.33–34.98 | — | 1 | — | 0.518× |
| Rust brotli | 1.616e+04 | 1.615e+04–1.618e+04 | — | 1 | — | 0.001× |
| SIMD Brotli | 1.981e+04 | 1.98e+04–1.983e+04 | — | 1 | — | 0.001× |
random-64k
64 KiB prefix of the deterministic pseudorandom corpus. Input: 65,536 bytes.
mbrotli speed / fastest peer (Google C): 0.485×; output: 65,540 / 65,540 bytes.
Exact measurements and confidence bounds
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| Google C | 76.59 | 76.5–76.69 | 816.06 | 65,540 | 100.006% | 1.000× |
| mbrotli | 158 | 157.7–158.2 | 395.65 | 65,540 | 100.006% | 0.485× |
| Rust brotli | 201.8 | 200.3–203.1 | 309.75 | 65,540 | 100.006% | 0.380× |
| SIMD Brotli | 262.1 | 261.7–262.6 | 238.45 | 65,540 | 100.006% | 0.292× |
Compression quality 7
Benchmark index · Median overview · ← Quality 6 · Quality 8 →
Recorded run: enc-2026-10-30-amd (2026-10-30). Raw results · Environment · Run analysis.
Median across all datasets
Each of the eight datasets has equal weight, including empty and tiny input. Speed / C = C mean latency / implementation mean latency; output / C = implementation bytes / C bytes. We take the median of these eight per-dataset ratios separately for speed and size. 1× matches C; higher speed and lower output are better. These are medians across datasets, not median sample latencies, and do not imply equal compressed size or statistical significance.
| Implementation | Median speed / C ↑ | Median output / C ↓ | Datasets |
|---|---|---|---|
| Google C | 1.000× | 1.000× | 8 |
| mbrotli | 1.004× | 1.000× | 8 |
| Rust brotli | 0.360× | 1.000× | 8 |
| SIMD Brotli | 0.331× | 1.000× | 8 |
Measurement setup and interpretation
Google C Brotli 1.2.0, mbrotli at the recorded optimized checkout, Rust brotli 9.0.0, and SIMD Brotli 10.0.1 are compared at this quality. Burli supports only qualities 0–5; it has no results at this quality.
These are cold native API measurements: construction, allocation, compression, and disposal. Generic mode, window 22, known input size; 20 samples, 1 s warmup, at least 3 s measurement. Host: AMD Ryzen 9 7950X (two KVM vCPUs), one benchmark process at a time pinned to logical CPU 1; codec calls and encoder/decoder sweeps ran serially; no concurrent builds, tests, fuzzers or profilers; unset; ordinary Cargo release defaults; runtime SIMD dispatch. Rust brotli and SIMD Brotli include their native 4 KiB I/O adapters.
Chart whiskers and table intervals describe 95% confidence bounds on mean timing. They do not capture all host or allocator variation. Throughput bounds are transformed latency bounds. Output / input is compressed bytes divided by input bytes; smaller is better, and values above 100% mean expansion. For empty input, throughput and output / input are undefined (—). See the run analysis for measurement limitations.
Dataset details
Ordered by mbrotli speed / fastest competing implementation, highest first; all eight datasets are shown. This order uses recorded mean latency, not statistical significance. Bars start at zero.
repeated-1m · tiny-text · text-1m · random-1m · alice29 · random-64k · binary-64k · empty
repeated-1m
1 MiB of repeated a bytes. Input: 1,048,576 bytes.
mbrotli speed / fastest peer (Google C): 2.958×; output: 13 / 13 bytes.
Exact measurements and confidence bounds
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| Google C | 474.5 | 474–475.2 | 2,107.29 | 13 | 0.001% | 1.000× |
| mbrotli | 160.4 | 159.9–161.1 | 6,232.49 | 13 | 0.001% | 2.958× |
| Rust brotli | 1,087 | 1,081–1,095 | 919.66 | 13 | 0.001% | 0.436× |
| SIMD Brotli | 1,213 | 1,209–1,218 | 824.50 | 13 | 0.001% | 0.391× |
tiny-text
44-byte quick-brown-fox sentence. Input: 44 bytes.
mbrotli speed / fastest peer (Google C): 1.361×; output: 48 / 48 bytes.
Exact measurements and confidence bounds
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| Google C | 4.698 | 4.694–4.702 | 8.93 | 48 | 109.091% | 1.000× |
| mbrotli | 3.452 | 3.449–3.455 | 12.16 | 48 | 109.091% | 1.361× |
| Rust brotli | 67.05 | 66.95–67.14 | 0.63 | 48 | 109.091% | 0.070× |
| SIMD Brotli | 19.93 | 19.92–19.95 | 2.11 | 48 | 109.091% | 0.236× |
text-1m
Alice text repeated cyclically to 1 MiB. Input: 1,048,576 bytes.
mbrotli speed / fastest peer (Google C): 1.160×; output: 50,580 / 50,580 bytes.
Exact measurements and confidence bounds
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| Google C | 3,368 | 3,363–3,375 | 296.89 | 50,580 | 4.824% | 1.000× |
| mbrotli | 2,903 | 2,891–2,915 | 344.49 | 50,580 | 4.824% | 1.160× |
| Rust brotli | 5,188 | 5,167–5,215 | 192.75 | 50,909 | 4.855% | 0.649× |
| SIMD Brotli | 5,476 | 5,467–5,486 | 182.62 | 50,909 | 4.855% | 0.615× |
random-1m
1 MiB of deterministic xorshift64 pseudorandom bytes. Input: 1,048,576 bytes.
mbrotli speed / fastest peer (Google C): 1.029×; output: 1,048,581 / 1,048,581 bytes.
Exact measurements and confidence bounds
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| Google C | 1,879 | 1,877–1,881 | 532.18 | 1,048,581 | 100.000% | 1.000× |
| mbrotli | 1,825 | 1,821–1,830 | 547.86 | 1,048,581 | 100.000% | 1.029× |
| Rust brotli | 3,209 | 3,202–3,215 | 311.65 | 1,048,581 | 100.000% | 0.586× |
| SIMD Brotli | 3,484 | 3,453–3,523 | 287.01 | 1,048,581 | 100.000% | 0.539× |
alice29
Vendored Alice in Wonderland text. Input: 152,089 bytes.
mbrotli speed / fastest peer (Google C): 0.979×; output: 51,451 / 51,451 bytes.
Exact measurements and confidence bounds
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| Google C | 2,959 | 2,953–2,965 | 49.02 | 51,451 | 33.830% | 1.000× |
| mbrotli | 3,021 | 3,016–3,027 | 48.01 | 51,451 | 33.830% | 0.979× |
| Rust brotli | 4,291 | 4,276–4,308 | 33.80 | 51,451 | 33.830% | 0.690× |
| SIMD Brotli | 4,512 | 4,479–4,548 | 32.14 | 51,451 | 33.830% | 0.656× |
random-64k
64 KiB prefix of the deterministic pseudorandom corpus. Input: 65,536 bytes.
mbrotli speed / fastest peer (Google C): 0.750×; output: 65,540 / 65,540 bytes.
Exact measurements and confidence bounds
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| Google C | 84.99 | 84.85–85.13 | 735.40 | 65,540 | 100.006% | 1.000× |
| mbrotli | 113.3 | 112.6–114 | 551.59 | 65,540 | 100.006% | 0.750× |
| Rust brotli | 300.7 | 300.1–301.3 | 207.86 | 65,540 | 100.006% | 0.283× |
| SIMD Brotli | 313.5 | 312.5–314.6 | 199.36 | 65,540 | 100.006% | 0.271× |
binary-64k
64 KiB of structured binary bytes: ((i / 16) XOR i) modulo 256. Input: 65,536 bytes.
mbrotli speed / fastest peer (Google C): 0.699×; output: 1,588 / 1,588 bytes.
Exact measurements and confidence bounds
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| Google C | 71.97 | 71.79–72.16 | 868.36 | 1,588 | 2.423% | 1.000× |
| mbrotli | 103 | 102.7–103.3 | 606.64 | 1,588 | 2.423% | 0.699× |
| Rust brotli | 334.4 | 333.3–335.6 | 186.92 | 1,588 | 2.423% | 0.215× |
| SIMD Brotli | 365.6 | 360–371.9 | 170.97 | 1,588 | 2.423% | 0.197× |
empty
Empty input; measures API overhead. Input: 0 bytes.
mbrotli speed / fastest peer (Google C): 0.508×; output: 1 / 1 bytes.
Exact measurements and confidence bounds
| Implementation | Mean, ns | 95% mean interval, ns | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| Google C | 17.56 | 17.53–17.6 | — | 1 | — | 1.000× |
| mbrotli | 34.55 | 34.33–34.94 | — | 1 | — | 0.508× |
| Rust brotli | 6.088e+04 | 6.079e+04–6.098e+04 | — | 1 | — | 0.000× |
| SIMD Brotli | 6.035e+04 | 6.028e+04–6.043e+04 | — | 1 | — | 0.000× |
Compression quality 8
Benchmark index · Median overview · ← Quality 7 · Quality 9 →
Recorded run: enc-2026-10-30-amd (2026-10-30). Raw results · Environment · Run analysis.
Median across all datasets
Each of the eight datasets has equal weight, including empty and tiny input. Speed / C = C mean latency / implementation mean latency; output / C = implementation bytes / C bytes. We take the median of these eight per-dataset ratios separately for speed and size. 1× matches C; higher speed and lower output are better. These are medians across datasets, not median sample latencies, and do not imply equal compressed size or statistical significance.
| Implementation | Median speed / C ↑ | Median output / C ↓ | Datasets |
|---|---|---|---|
| Google C | 1.000× | 1.000× | 8 |
| mbrotli | 1.022× | 1.000× | 8 |
| Rust brotli | 0.291× | 1.000× | 8 |
| SIMD Brotli | 0.285× | 1.000× | 8 |
Measurement setup and interpretation
Google C Brotli 1.2.0, mbrotli at the recorded optimized checkout, Rust brotli 9.0.0, and SIMD Brotli 10.0.1 are compared at this quality. Burli supports only qualities 0–5; it has no results at this quality.
These are cold native API measurements: construction, allocation, compression, and disposal. Generic mode, window 22, known input size; 20 samples, 1 s warmup, at least 3 s measurement. Host: AMD Ryzen 9 7950X (two KVM vCPUs), one benchmark process at a time pinned to logical CPU 1; codec calls and encoder/decoder sweeps ran serially; no concurrent builds, tests, fuzzers or profilers; unset; ordinary Cargo release defaults; runtime SIMD dispatch. Rust brotli and SIMD Brotli include their native 4 KiB I/O adapters.
Chart whiskers and table intervals describe 95% confidence bounds on mean timing. They do not capture all host or allocator variation. Throughput bounds are transformed latency bounds. Output / input is compressed bytes divided by input bytes; smaller is better, and values above 100% mean expansion. For empty input, throughput and output / input are undefined (—). See the run analysis for measurement limitations.
Dataset details
Ordered by mbrotli speed / fastest competing implementation, highest first; all eight datasets are shown. This order uses recorded mean latency, not statistical significance. Bars start at zero.
repeated-1m · tiny-text · random-64k · text-1m · random-1m · alice29 · binary-64k · empty
repeated-1m
1 MiB of repeated a bytes. Input: 1,048,576 bytes.
mbrotli speed / fastest peer (Google C): 6.266×; output: 13 / 13 bytes.
Exact measurements and confidence bounds
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| Google C | 475.2 | 474.9–475.5 | 2,104.39 | 13 | 0.001% | 1.000× |
| mbrotli | 75.84 | 75.74–75.93 | 13,186.47 | 13 | 0.001% | 6.266× |
| Rust brotli | 1,358 | 1,328–1,392 | 736.56 | 13 | 0.001% | 0.350× |
| SIMD Brotli | 1,453 | 1,437–1,473 | 688.17 | 13 | 0.001% | 0.327× |
tiny-text
44-byte quick-brown-fox sentence. Input: 44 bytes.
mbrotli speed / fastest peer (Google C): 1.361×; output: 48 / 48 bytes.
Exact measurements and confidence bounds
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| Google C | 4.827 | 4.823–4.832 | 8.69 | 48 | 109.091% | 1.000× |
| mbrotli | 3.546 | 3.54–3.556 | 11.83 | 48 | 109.091% | 1.361× |
| Rust brotli | 168.7 | 168.1–169.5 | 0.25 | 48 | 109.091% | 0.029× |
| SIMD Brotli | 19.93 | 19.92–19.94 | 2.11 | 48 | 109.091% | 0.242× |
random-64k
64 KiB prefix of the deterministic pseudorandom corpus. Input: 65,536 bytes.
mbrotli speed / fastest peer (Google C): 1.206×; output: 65,540 / 65,540 bytes.
Exact measurements and confidence bounds
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| Google C | 136.9 | 136.8–137.1 | 456.51 | 65,540 | 100.006% | 1.000× |
| mbrotli | 113.5 | 112.7–114.4 | 550.44 | 65,540 | 100.006% | 1.206× |
| Rust brotli | 590.7 | 572.3–609.3 | 105.81 | 65,540 | 100.006% | 0.232× |
| SIMD Brotli | 583 | 565.1–602.8 | 107.21 | 65,540 | 100.006% | 0.235× |
text-1m
Alice text repeated cyclically to 1 MiB. Input: 1,048,576 bytes.
mbrotli speed / fastest peer (Google C): 1.058×; output: 50,473 / 50,473 bytes.
Exact measurements and confidence bounds
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| Google C | 3,790 | 3,777–3,805 | 263.83 | 50,473 | 4.813% | 1.000× |
| mbrotli | 3,581 | 3,557–3,612 | 279.22 | 50,473 | 4.813% | 1.058× |
| Rust brotli | 6,736 | 6,705–6,770 | 148.45 | 50,648 | 4.830% | 0.563× |
| SIMD Brotli | 6,663 | 6,647–6,682 | 150.08 | 50,648 | 4.830% | 0.569× |
random-1m
1 MiB of deterministic xorshift64 pseudorandom bytes. Input: 1,048,576 bytes.
mbrotli speed / fastest peer (Google C): 0.985×; output: 1,048,581 / 1,048,581 bytes.
Exact measurements and confidence bounds
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| Google C | 2,805 | 2,798–2,814 | 356.46 | 1,048,581 | 100.000% | 1.000× |
| mbrotli | 2,847 | 2,807–2,892 | 351.19 | 1,048,581 | 100.000% | 0.985× |
| Rust brotli | 4,542 | 4,530–4,554 | 220.17 | 1,048,581 | 100.000% | 0.618× |
| SIMD Brotli | 4,824 | 4,797–4,855 | 207.31 | 1,048,581 | 100.000% | 0.582× |
alice29
Vendored Alice in Wonderland text. Input: 152,089 bytes.
mbrotli speed / fastest peer (Google C): 0.958×; output: 51,187 / 51,187 bytes.
Exact measurements and confidence bounds
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| Google C | 3,437 | 3,433–3,440 | 42.21 | 51,187 | 33.656% | 1.000× |
| mbrotli | 3,587 | 3,582–3,592 | 40.44 | 51,187 | 33.656% | 0.958× |
| Rust brotli | 5,696 | 5,667–5,726 | 25.46 | 51,187 | 33.656% | 0.603× |
| SIMD Brotli | 5,604 | 5,540–5,672 | 25.88 | 51,187 | 33.656% | 0.613× |
binary-64k
64 KiB of structured binary bytes: ((i / 16) XOR i) modulo 256. Input: 65,536 bytes.
mbrotli speed / fastest peer (Google C): 0.734×; output: 1,588 / 1,588 bytes.
Exact measurements and confidence bounds
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| Google C | 77.72 | 77.55–77.87 | 804.17 | 1,588 | 2.423% | 1.000× |
| mbrotli | 105.9 | 105.8–106.1 | 589.91 | 1,588 | 2.423% | 0.734× |
| Rust brotli | 565.1 | 559.5–573.6 | 110.59 | 1,588 | 2.423% | 0.138× |
| SIMD Brotli | 557.7 | 554.9–560.9 | 112.06 | 1,588 | 2.423% | 0.139× |
empty
Empty input; measures API overhead. Input: 0 bytes.
mbrotli speed / fastest peer (Google C): 0.518×; output: 1 / 1 bytes.
Exact measurements and confidence bounds
| Implementation | Mean, ns | 95% mean interval, ns | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| Google C | 17.9 | 17.87–17.93 | — | 1 | — | 1.000× |
| mbrotli | 34.54 | 34.32–34.93 | — | 1 | — | 0.518× |
| Rust brotli | 1.648e+05 | 1.636e+05–1.661e+05 | — | 1 | — | 0.000× |
| SIMD Brotli | 1.64e+05 | 1.633e+05–1.649e+05 | — | 1 | — | 0.000× |
Compression quality 9
Benchmark index · Median overview · ← Quality 8 · Quality 10 →
Recorded run: enc-2026-10-30-amd (2026-10-30). Raw results · Environment · Run analysis.
Median across all datasets
Each of the eight datasets has equal weight, including empty and tiny input. Speed / C = C mean latency / implementation mean latency; output / C = implementation bytes / C bytes. We take the median of these eight per-dataset ratios separately for speed and size. 1× matches C; higher speed and lower output are better. These are medians across datasets, not median sample latencies, and do not imply equal compressed size or statistical significance.
| Implementation | Median speed / C ↑ | Median output / C ↓ | Datasets |
|---|---|---|---|
| Google C | 1.000× | 1.000× | 8 |
| mbrotli | 4.323× | 1.000× | 8 |
| Rust brotli | 0.828× | 1.000× | 8 |
| SIMD Brotli | 1.681× | 1.000× | 8 |
Measurement setup and interpretation
Google C Brotli 1.2.0, mbrotli at the recorded optimized checkout, Rust brotli 9.0.0, and SIMD Brotli 10.0.1 are compared at this quality. Burli supports only qualities 0–5; it has no results at this quality.
These are cold native API measurements: construction, allocation, compression, and disposal. Generic mode, window 22, known input size; 20 samples, 1 s warmup, at least 3 s measurement. Host: AMD Ryzen 9 7950X (two KVM vCPUs), one benchmark process at a time pinned to logical CPU 1; codec calls and encoder/decoder sweeps ran serially; no concurrent builds, tests, fuzzers or profilers; unset; ordinary Cargo release defaults; runtime SIMD dispatch. Rust brotli and SIMD Brotli include their native 4 KiB I/O adapters.
Chart whiskers and table intervals describe 95% confidence bounds on mean timing. They do not capture all host or allocator variation. Throughput bounds are transformed latency bounds. Output / input is compressed bytes divided by input bytes; smaller is better, and values above 100% mean expansion. For empty input, throughput and output / input are undefined (—). See the run analysis for measurement limitations.
Dataset details
Ordered by mbrotli speed / fastest competing implementation, highest first; all eight datasets are shown. This order uses recorded mean latency, not statistical significance. Bars start at zero.
tiny-text · repeated-1m · binary-64k · random-64k · alice29 · text-1m · random-1m · empty
tiny-text
44-byte quick-brown-fox sentence. Input: 44 bytes.
mbrotli speed / fastest peer (SIMD Brotli): 6.522×; output: 48 / 48 bytes.
Exact measurements and confidence bounds
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| Google C | 63.46 | 63.4–63.53 | 0.66 | 48 | 109.091% | 1.000× |
| mbrotli | 3.576 | 3.573–3.579 | 11.73 | 48 | 109.091% | 17.746× |
| Rust brotli | 66.49 | 66.38–66.61 | 0.63 | 48 | 109.091% | 0.954× |
| SIMD Brotli | 23.32 | 23.31–23.34 | 1.80 | 48 | 109.091% | 2.721× |
repeated-1m
1 MiB of repeated a bytes. Input: 1,048,576 bytes.
mbrotli speed / fastest peer (Google C): 5.634×; output: 13 / 13 bytes.
Exact measurements and confidence bounds
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| Google C | 425.7 | 425.6–426 | 2,348.81 | 13 | 0.001% | 1.000× |
| mbrotli | 75.57 | 75.52–75.61 | 13,233.41 | 13 | 0.001% | 5.634× |
| Rust brotli | 1,140 | 1,134–1,146 | 877.51 | 13 | 0.001% | 0.374× |
| SIMD Brotli | 1,202 | 1,195–1,210 | 831.89 | 13 | 0.001% | 0.354× |
binary-64k
64 KiB of structured binary bytes: ((i / 16) XOR i) modulo 256. Input: 65,536 bytes.
mbrotli speed / fastest peer (SIMD Brotli): 2.028×; output: 1,349 / 1,349 bytes.
Exact measurements and confidence bounds
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| Google C | 2,454 | 2,450–2,458 | 25.47 | 1,349 | 2.058% | 1.000× |
| mbrotli | 116 | 115.7–116.3 | 538.75 | 1,349 | 2.058% | 21.151× |
| Rust brotli | 2,814 | 2,803–2,826 | 22.21 | 1,349 | 2.058% | 0.872× |
| SIMD Brotli | 235.2 | 235.1–235.4 | 265.69 | 1,349 | 2.058% | 10.431× |
random-64k
64 KiB prefix of the deterministic pseudorandom corpus. Input: 65,536 bytes.
mbrotli speed / fastest peer (SIMD Brotli): 1.853×; output: 65,540 / 65,540 bytes.
Exact measurements and confidence bounds
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| Google C | 9,448 | 9,395–9,524 | 6.62 | 65,540 | 100.006% | 1.000× |
| mbrotli | 138.7 | 138.1–139.3 | 450.60 | 65,540 | 100.006% | 68.117× |
| Rust brotli | 1.041e+04 | 1.033e+04–1.051e+04 | 6.00 | 65,540 | 100.006% | 0.907× |
| SIMD Brotli | 257.1 | 256.8–257.3 | 243.14 | 65,540 | 100.006% | 36.755× |
alice29
Vendored Alice in Wonderland text. Input: 152,089 bytes.
mbrotli speed / fastest peer (SIMD Brotli): 1.201×; output: 51,054 / 51,056 bytes.
Exact measurements and confidence bounds
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| Google C | 1.379e+04 | 1.37e+04–1.39e+04 | 10.52 | 51,054 | 33.569% | 1.000× |
| mbrotli | 4,579 | 4,575–4,584 | 31.68 | 51,054 | 33.569% | 3.011× |
| Rust brotli | 1.695e+04 | 1.689e+04–1.703e+04 | 8.56 | 51,056 | 33.570% | 0.813× |
| SIMD Brotli | 5,501 | 5,497–5,506 | 26.37 | 51,056 | 33.570% | 2.506× |
text-1m
Alice text repeated cyclically to 1 MiB. Input: 1,048,576 bytes.
mbrotli speed / fastest peer (Google C): 1.001×; output: 50,608 / 50,608 bytes.
Exact measurements and confidence bounds
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| Google C | 1.516e+04 | 1.513e+04–1.519e+04 | 65.95 | 50,608 | 4.826% | 1.000× |
| mbrotli | 1.515e+04 | 1.501e+04–1.53e+04 | 66.02 | 50,608 | 4.826% | 1.001× |
| Rust brotli | 1.84e+04 | 1.824e+04–1.858e+04 | 54.36 | 50,510 | 4.817% | 0.824× |
| SIMD Brotli | 1.773e+04 | 1.762e+04–1.788e+04 | 56.40 | 50,510 | 4.817% | 0.855× |
random-1m
1 MiB of deterministic xorshift64 pseudorandom bytes. Input: 1,048,576 bytes.
mbrotli speed / fastest peer (Google C): 0.992×; output: 1,048,581 / 1,048,581 bytes.
Exact measurements and confidence bounds
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| Google C | 1.408e+04 | 1.402e+04–1.414e+04 | 71.04 | 1,048,581 | 100.000% | 1.000× |
| mbrotli | 1.418e+04 | 1.392e+04–1.448e+04 | 70.51 | 1,048,581 | 100.000% | 0.992× |
| Rust brotli | 1.692e+04 | 1.683e+04–1.704e+04 | 59.08 | 1,048,581 | 100.000% | 0.832× |
| SIMD Brotli | 1.727e+04 | 1.7e+04–1.757e+04 | 57.91 | 1,048,581 | 100.000% | 0.815× |
empty
Empty input; measures API overhead. Input: 0 bytes.
mbrotli speed / fastest peer (Google C): 0.516×; output: 1 / 1 bytes.
Exact measurements and confidence bounds
| Implementation | Mean, ns | 95% mean interval, ns | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| Google C | 17.84 | 17.79–17.89 | — | 1 | — | 1.000× |
| mbrotli | 34.59 | 34.38–34.97 | — | 1 | — | 0.516× |
| Rust brotli | 6,335 | 6,321–6,348 | — | 1 | — | 0.003× |
| SIMD Brotli | 6,387 | 6,375–6,399 | — | 1 | — | 0.003× |
Compression quality 10
Benchmark index · Median overview · ← Quality 9 · Quality 11 →
Recorded run: enc-2026-10-30-amd (2026-10-30). Raw results · Environment · Run analysis.
Median across all datasets
Each of the eight datasets has equal weight, including empty and tiny input. Speed / C = C mean latency / implementation mean latency; output / C = implementation bytes / C bytes. We take the median of these eight per-dataset ratios separately for speed and size. 1× matches C; higher speed and lower output are better. These are medians across datasets, not median sample latencies, and do not imply equal compressed size or statistical significance.
| Implementation | Median speed / C ↑ | Median output / C ↓ | Datasets |
|---|---|---|---|
| Google C | 1.000× | 1.000× | 8 |
| mbrotli | 1.243× | 1.000× | 8 |
| Rust brotli | 0.839× | 1.000× | 8 |
| SIMD Brotli | 0.899× | 1.000× | 8 |
Measurement setup and interpretation
Google C Brotli 1.2.0, mbrotli at the recorded optimized checkout, Rust brotli 9.0.0, and SIMD Brotli 10.0.1 are compared at this quality. Burli supports only qualities 0–5; it has no results at this quality.
These are cold native API measurements: construction, allocation, compression, and disposal. Generic mode, window 22, known input size; 20 samples, 1 s warmup, at least 3 s measurement. Host: AMD Ryzen 9 7950X (two KVM vCPUs), one benchmark process at a time pinned to logical CPU 1; codec calls and encoder/decoder sweeps ran serially; no concurrent builds, tests, fuzzers or profilers; unset; ordinary Cargo release defaults; runtime SIMD dispatch. Rust brotli and SIMD Brotli include their native 4 KiB I/O adapters.
Chart whiskers and table intervals describe 95% confidence bounds on mean timing. They do not capture all host or allocator variation. Throughput bounds are transformed latency bounds. Output / input is compressed bytes divided by input bytes; smaller is better, and values above 100% mean expansion. For empty input, throughput and output / input are undefined (—). See the run analysis for measurement limitations.
Dataset details
Ordered by mbrotli speed / fastest competing implementation, highest first; all eight datasets are shown. This order uses recorded mean latency, not statistical significance. Bars start at zero.
repeated-1m · random-64k · text-1m · alice29 · random-1m · binary-64k · tiny-text · empty
repeated-1m
1 MiB of repeated a bytes. Input: 1,048,576 bytes.
mbrotli speed / fastest peer (Google C): 1.805×; output: 14 / 14 bytes.
Exact measurements and confidence bounds
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| Google C | 8,825 | 8,820–8,830 | 113.32 | 14 | 0.001% | 1.000× |
| mbrotli | 4,889 | 4,868–4,915 | 204.54 | 14 | 0.001% | 1.805× |
| Rust brotli | 1.124e+04 | 1.118e+04–1.132e+04 | 88.95 | 14 | 0.001% | 0.785× |
| SIMD Brotli | 1.104e+04 | 1.1e+04–1.109e+04 | 90.56 | 14 | 0.001% | 0.799× |
random-64k
64 KiB prefix of the deterministic pseudorandom corpus. Input: 65,536 bytes.
mbrotli speed / fastest peer (Google C): 1.387×; output: 65,540 / 65,540 bytes.
Exact measurements and confidence bounds
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| Google C | 4,757 | 4,754–4,761 | 13.14 | 65,540 | 100.006% | 1.000× |
| mbrotli | 3,429 | 3,422–3,439 | 18.23 | 65,540 | 100.006% | 1.387× |
| Rust brotli | 5,454 | 5,446–5,463 | 11.46 | 65,540 | 100.006% | 0.872× |
| SIMD Brotli | 5,150 | 5,145–5,156 | 12.14 | 65,540 | 100.006% | 0.924× |
text-1m
Alice text repeated cyclically to 1 MiB. Input: 1,048,576 bytes.
mbrotli speed / fastest peer (Google C): 1.352×; output: 47,492 / 47,492 bytes.
Exact measurements and confidence bounds
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| Google C | 5.012e+04 | 4.985e+04–5.045e+04 | 19.95 | 47,492 | 4.529% | 1.000× |
| mbrotli | 3.709e+04 | 3.697e+04–3.721e+04 | 26.96 | 47,492 | 4.529% | 1.352× |
| Rust brotli | 5.442e+04 | 5.425e+04–5.462e+04 | 18.38 | 47,516 | 4.531% | 0.921× |
| SIMD Brotli | 5.054e+04 | 5.047e+04–5.062e+04 | 19.79 | 47,516 | 4.531% | 0.992× |
alice29
Vendored Alice in Wonderland text. Input: 152,089 bytes.
mbrotli speed / fastest peer (SIMD Brotli): 1.292×; output: 47,477 / 47,488 bytes.
Exact measurements and confidence bounds
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| Google C | 4.371e+04 | 4.362e+04–4.383e+04 | 3.32 | 47,477 | 31.217% | 1.000× |
| mbrotli | 3.369e+04 | 3.366e+04–3.372e+04 | 4.31 | 47,477 | 31.217% | 1.297× |
| Rust brotli | 4.741e+04 | 4.729e+04–4.757e+04 | 3.06 | 47,488 | 31.224% | 0.922× |
| SIMD Brotli | 4.354e+04 | 4.341e+04–4.367e+04 | 3.33 | 47,488 | 31.224% | 1.004× |
random-1m
1 MiB of deterministic xorshift64 pseudorandom bytes. Input: 1,048,576 bytes.
mbrotli speed / fastest peer (Google C): 1.189×; output: 1,048,581 / 1,048,581 bytes.
Exact measurements and confidence bounds
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| Google C | 1.259e+05 | 1.25e+05–1.273e+05 | 7.94 | 1,048,581 | 100.000% | 1.000× |
| mbrotli | 1.06e+05 | 1.053e+05–1.067e+05 | 9.44 | 1,048,581 | 100.000% | 1.189× |
| Rust brotli | 1.375e+05 | 1.366e+05–1.389e+05 | 7.27 | 1,048,581 | 100.000% | 0.916× |
| SIMD Brotli | 1.428e+05 | 1.421e+05–1.438e+05 | 7.00 | 1,048,581 | 100.000% | 0.882× |
binary-64k
64 KiB of structured binary bytes: ((i / 16) XOR i) modulo 256. Input: 65,536 bytes.
mbrotli speed / fastest peer (Google C): 1.086×; output: 1,063 / 1,063 bytes.
Exact measurements and confidence bounds
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| Google C | 1,610 | 1,608–1,612 | 38.83 | 1,063 | 1.622% | 1.000× |
| mbrotli | 1,482 | 1,481–1,484 | 42.16 | 1,063 | 1.622% | 1.086× |
| Rust brotli | 2,314 | 2,307–2,320 | 27.01 | 1,063 | 1.622% | 0.696× |
| SIMD Brotli | 1,758 | 1,755–1,760 | 35.56 | 1,063 | 1.622% | 0.916× |
tiny-text
44-byte quick-brown-fox sentence. Input: 44 bytes.
mbrotli speed / fastest peer (Google C): 0.965×; output: 48 / 48 bytes.
Exact measurements and confidence bounds
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| Google C | 125.4 | 125.2–125.6 | 0.33 | 48 | 109.091% | 1.000× |
| mbrotli | 130 | 128.3–133.2 | 0.32 | 48 | 109.091% | 0.965× |
| Rust brotli | 155.7 | 155.5–156 | 0.27 | 48 | 109.091% | 0.805× |
| SIMD Brotli | 155.6 | 154.9–156.2 | 0.27 | 48 | 109.091% | 0.806× |
empty
Empty input; measures API overhead. Input: 0 bytes.
mbrotli speed / fastest peer (Google C): 0.518×; output: 1 / 1 bytes.
Exact measurements and confidence bounds
| Implementation | Mean, ns | 95% mean interval, ns | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| Google C | 17.89 | 17.87–17.92 | — | 1 | — | 1.000× |
| mbrotli | 34.51 | 34.29–34.88 | — | 1 | — | 0.518× |
| Rust brotli | 1.755e+04 | 1.75e+04–1.761e+04 | — | 1 | — | 0.001× |
| SIMD Brotli | 1.084e+04 | 1.083e+04–1.084e+04 | — | 1 | — | 0.002× |
Compression quality 11
Benchmark index · Median overview · ← Quality 10
Recorded run: enc-2026-10-30-amd (2026-10-30). Raw results · Environment · Run analysis.
Median across all datasets
Each of the eight datasets has equal weight, including empty and tiny input. Speed / C = C mean latency / implementation mean latency; output / C = implementation bytes / C bytes. We take the median of these eight per-dataset ratios separately for speed and size. 1× matches C; higher speed and lower output are better. These are medians across datasets, not median sample latencies, and do not imply equal compressed size or statistical significance.
| Implementation | Median speed / C ↑ | Median output / C ↓ | Datasets |
|---|---|---|---|
| Google C | 1.000× | 1.000× | 8 |
| mbrotli | 1.493× | 1.000× | 8 |
| Rust brotli | 0.834× | 1.000× | 8 |
| SIMD Brotli | 0.906× | 1.000× | 8 |
Measurement setup and interpretation
Google C Brotli 1.2.0, mbrotli at the recorded optimized checkout, Rust brotli 9.0.0, and SIMD Brotli 10.0.1 are compared at this quality. Burli supports only qualities 0–5; it has no results at this quality.
These are cold native API measurements: construction, allocation, compression, and disposal. Generic mode, window 22, known input size; 20 samples, 1 s warmup, at least 3 s measurement. Host: AMD Ryzen 9 7950X (two KVM vCPUs), one benchmark process at a time pinned to logical CPU 1; codec calls and encoder/decoder sweeps ran serially; no concurrent builds, tests, fuzzers or profilers; unset; ordinary Cargo release defaults; runtime SIMD dispatch. Rust brotli and SIMD Brotli include their native 4 KiB I/O adapters.
Chart whiskers and table intervals describe 95% confidence bounds on mean timing. They do not capture all host or allocator variation. Throughput bounds are transformed latency bounds. Output / input is compressed bytes divided by input bytes; smaller is better, and values above 100% mean expansion. For empty input, throughput and output / input are undefined (—). See the run analysis for measurement limitations.
Dataset details
Ordered by mbrotli speed / fastest competing implementation, highest first; all eight datasets are shown. This order uses recorded mean latency, not statistical significance. Bars start at zero.
random-64k · repeated-1m · random-1m · text-1m · alice29 · binary-64k · tiny-text · empty
random-64k
64 KiB prefix of the deterministic pseudorandom corpus. Input: 65,536 bytes.
mbrotli speed / fastest peer (Google C): 1.702×; output: 65,540 / 65,540 bytes.
Exact measurements and confidence bounds
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| Google C | 8,208 | 8,200–8,217 | 7.61 | 65,540 | 100.006% | 1.000× |
| mbrotli | 4,822 | 4,818–4,825 | 12.96 | 65,540 | 100.006% | 1.702× |
| Rust brotli | 9,515 | 9,503–9,532 | 6.57 | 65,540 | 100.006% | 0.863× |
| SIMD Brotli | 8,829 | 8,814–8,845 | 7.08 | 65,540 | 100.006% | 0.930× |
repeated-1m
1 MiB of repeated a bytes. Input: 1,048,576 bytes.
mbrotli speed / fastest peer (Google C): 1.643×; output: 14 / 14 bytes.
Exact measurements and confidence bounds
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| Google C | 1.278e+04 | 1.276e+04–1.28e+04 | 78.26 | 14 | 0.001% | 1.000× |
| mbrotli | 7,777 | 7,755–7,802 | 128.58 | 14 | 0.001% | 1.643× |
| Rust brotli | 1.595e+04 | 1.583e+04–1.607e+04 | 62.68 | 14 | 0.001% | 0.801× |
| SIMD Brotli | 1.549e+04 | 1.541e+04–1.559e+04 | 64.55 | 14 | 0.001% | 0.825× |
random-1m
1 MiB of deterministic xorshift64 pseudorandom bytes. Input: 1,048,576 bytes.
mbrotli speed / fastest peer (Google C): 1.429×; output: 1,048,581 / 1,048,581 bytes.
Exact measurements and confidence bounds
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| Google C | 1.843e+05 | 1.832e+05–1.855e+05 | 5.43 | 1,048,581 | 100.000% | 1.000× |
| mbrotli | 1.29e+05 | 1.284e+05–1.296e+05 | 7.75 | 1,048,581 | 100.000% | 1.429× |
| Rust brotli | 2.083e+05 | 2.059e+05–2.112e+05 | 4.80 | 1,048,581 | 100.000% | 0.885× |
| SIMD Brotli | 2.086e+05 | 2.056e+05–2.123e+05 | 4.79 | 1,048,581 | 100.000% | 0.883× |
text-1m
Alice text repeated cyclically to 1 MiB. Input: 1,048,576 bytes.
mbrotli speed / fastest peer (SIMD Brotli): 1.409×; output: 46,491 / 46,491 bytes.
Exact measurements and confidence bounds
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| Google C | 1.268e+05 | 1.264e+05–1.272e+05 | 7.89 | 46,491 | 4.434% | 1.000× |
| mbrotli | 8.139e+04 | 8.094e+04–8.19e+04 | 12.29 | 46,491 | 4.434% | 1.558× |
| Rust brotli | 1.267e+05 | 1.265e+05–1.27e+05 | 7.89 | 46,491 | 4.434% | 1.001× |
| SIMD Brotli | 1.147e+05 | 1.14e+05–1.154e+05 | 8.72 | 46,491 | 4.434% | 1.106× |
alice29
Vendored Alice in Wonderland text. Input: 152,089 bytes.
mbrotli speed / fastest peer (SIMD Brotli): 1.367×; output: 46,487 / 46,493 bytes.
Exact measurements and confidence bounds
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| Google C | 1.219e+05 | 1.217e+05–1.22e+05 | 1.19 | 46,487 | 30.566% | 1.000× |
| mbrotli | 7.724e+04 | 7.713e+04–7.741e+04 | 1.88 | 46,487 | 30.566% | 1.578× |
| Rust brotli | 1.189e+05 | 1.188e+05–1.19e+05 | 1.22 | 46,493 | 30.570% | 1.025× |
| SIMD Brotli | 1.056e+05 | 1.053e+05–1.06e+05 | 1.37 | 46,493 | 30.570% | 1.154× |
binary-64k
64 KiB of structured binary bytes: ((i / 16) XOR i) modulo 256. Input: 65,536 bytes.
mbrotli speed / fastest peer (Google C): 1.352×; output: 1,065 / 1,065 bytes.
Exact measurements and confidence bounds
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| Google C | 2,910 | 2,907–2,913 | 21.48 | 1,065 | 1.625% | 1.000× |
| mbrotli | 2,152 | 2,150–2,154 | 29.04 | 1,065 | 1.625% | 1.352× |
| Rust brotli | 3,753 | 3,734–3,773 | 16.65 | 1,065 | 1.625% | 0.775× |
| SIMD Brotli | 3,087 | 3,083–3,091 | 20.25 | 1,065 | 1.625% | 0.943× |
tiny-text
44-byte quick-brown-fox sentence. Input: 44 bytes.
mbrotli speed / fastest peer (Google C): 1.004×; output: 48 / 48 bytes.
Exact measurements and confidence bounds
| Implementation | Mean, µs | 95% mean interval, µs | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| Google C | 132.9 | 132.7–133.1 | 0.32 | 48 | 109.091% | 1.000× |
| mbrotli | 132.3 | 132.2–132.4 | 0.32 | 48 | 109.091% | 1.004× |
| Rust brotli | 165 | 164.7–165.6 | 0.25 | 48 | 109.091% | 0.805× |
| SIMD Brotli | 163.7 | 162.9–164.4 | 0.26 | 48 | 109.091% | 0.812× |
empty
Empty input; measures API overhead. Input: 0 bytes.
mbrotli speed / fastest peer (Google C): 0.513×; output: 1 / 1 bytes.
Exact measurements and confidence bounds
| Implementation | Mean, ns | 95% mean interval, ns | MiB/s | Output bytes | Output / input | Speed / C |
|---|---|---|---|---|---|---|
| Google C | 17.71 | 17.68–17.74 | — | 1 | — | 1.000× |
| mbrotli | 34.54 | 34.32–34.94 | — | 1 | — | 0.513× |
| Rust brotli | 1.831e+04 | 1.829e+04–1.834e+04 | — | 1 | — | 0.001× |
| SIMD Brotli | 1.156e+04 | 1.154e+04–1.157e+04 | — | 1 | — | 0.002× |
Decoder comparison
Recorded run: dec-2026-10-30-amd (2026-10-30). CSV · Environment · Methodology and limits.
Each value is the median of C mean time / decoder mean time across all 8 datasets, equally weighted. Higher is faster; 1× matches C. Qualities are ordered by mbrotli median speed / C. Medians do not imply statistical significance.
All four decoders restore identical C-generated streams. Source quality is an encoder setting, not a decoder setting. Burli decodes q0–q11; SIMD Brotli shares Rust brotli’s decoder and is omitted. Compressed sizes and ratios are properties of those shared inputs, so there is no decoder size ranking.
| Source quality | Google C | mbrotli | Rust brotli | Burli | mbrotli / Burli |
|---|---|---|---|---|---|
| q4 | 1.000× | 3.461× | 0.574× | 3.407× | 1.003× |
| q2 | 1.000× | 3.325× | 0.570× | 3.362× | 1.056× |
| q1 | 1.000× | 3.196× | 0.445× | 3.129× | 1.020× |
| q0 | 1.000× | 3.059× | 0.446× | 2.873× | 1.036× |
| q11 | 1.000× | 2.847× | 0.584× | 2.662× | 1.067× |
| q10 | 1.000× | 2.770× | 0.581× | 2.632× | 1.051× |
| q7 | 1.000× | 2.574× | 0.562× | 2.511× | 0.982× |
| q5 | 1.000× | 2.572× | 0.574× | 2.555× | 0.982× |
| q6 | 1.000× | 2.568× | 0.569× | 2.545× | 0.982× |
| q9 | 1.000× | 2.553× | 0.569× | 2.536× | 0.986× |
| q3 | 1.000× | 2.548× | 0.563× | 2.536× | 0.986× |
| q8 | 1.000× | 2.547× | 0.572× | 2.533× | 0.988× |
The last column takes the median of direct Burli time / mbrotli time ratios; it does not divide the two medians relative to C.
Open a source quality for all datasets, compressed/restored byte counts, mean latency, confidence bounds and throughput. Measurements include cold construction, allocation, decoding and disposal. C receives the known output capacity; Rust brotli includes its native 4 KiB I/O adapter. See the linked methodology for reproducible commands and the limits of this single-host comparison.
Decompression of quality 0 streams
Decoder overview · Benchmark index
Recorded run: dec-2026-10-30-amd (2026-10-30). CSV · Environment · Methodology and limits.
Quality belongs to the C encoder that prepared the input; decoders have no quality setting. All four decode the same bytes. Burli is included at every source quality; SIMD Brotli re-exports Rust brotli’s decoder and is omitted.
Median speed across 8 datasets
Median of per-dataset C mean time / decoder mean time ratios, with equal weight. Higher is faster; 1× matches C. No confidence interval is inferred for this across-dataset median.
| Decoder | Median speed / C |
|---|---|
| Google C | 1.000× |
| mbrotli | 3.059× |
| Rust brotli | 0.446× |
| Burli | 2.873× |
mbrotli median speed / Burli: 1.036× (direct per-dataset ratios).
Datasets are ranked by mbrotli speed relative to the fastest peer, highest first. Throughput counts restored bytes; empty input has latency only. Whiskers and intervals describe sampling uncertainty, not all host variation. Cold APIs include allocation and disposal. C knows the output capacity; Rust brotli includes its 4 KiB I/O adapter.
tiny-text
44-byte quick-brown-fox sentence.
48 compressed → 44 restored bytes; compressed / original: 109.091%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| Google C | 0.1545 | 0.1541–0.1551 | 271.55 | 1.000× |
| mbrotli | 0.02264 | 0.02181–0.02347 | 1,853.82 | 6.827× |
| Rust brotli | 1.811 | 1.81–1.812 | 23.17 | 0.085× |
| Burli | 0.03467 | 0.03448–0.03489 | 1,210.16 | 4.456× |
binary-64k
64 KiB of structured binary bytes: ((i / 16) XOR i) modulo 256.
3,369 compressed → 65,536 restored bytes; compressed / original: 5.141%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| Google C | 35.85 | 35.83–35.88 | 1,743.21 | 1.000× |
| mbrotli | 10.11 | 10.1–10.12 | 6,180.74 | 3.546× |
| Rust brotli | 78.78 | 78.73–78.82 | 793.37 | 0.455× |
| Burli | 11.17 | 11.16–11.18 | 5,595.61 | 3.210× |
repeated-1m
1 MiB of repeated a bytes.
712 compressed → 1,048,576 restored bytes; compressed / original: 0.068%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| Google C | 206.2 | 206–206.5 | 4,848.70 | 1.000× |
| mbrotli | 45.56 | 45.53–45.59 | 21,949.93 | 4.527× |
| Rust brotli | 230.7 | 230.2–231.3 | 4,334.88 | 0.894× |
| Burli | 46.66 | 46.6–46.72 | 21,432.98 | 4.420× |
random-64k
64 KiB prefix of the deterministic pseudorandom corpus.
65,540 compressed → 65,536 restored bytes; compressed / original: 100.006%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| Google C | 2.168 | 2.167–2.17 | 28,826.64 | 1.000× |
| mbrotli | 0.8428 | 0.842–0.8436 | 74,159.30 | 2.573× |
| Rust brotli | 35.89 | 35.86–35.93 | 1,741.37 | 0.060× |
| Burli | 0.8548 | 0.8541–0.8556 | 73,117.62 | 2.536× |
text-1m
Alice text repeated cyclically to 1 MiB.
190,491 compressed → 1,048,576 restored bytes; compressed / original: 18.167%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| Google C | 902.8 | 902.1–903.7 | 1,107.64 | 1.000× |
| mbrotli | 895 | 894–896 | 1,117.37 | 1.009× |
| Rust brotli | 1,215 | 1,214–1,216 | 823.17 | 0.743× |
| Burli | 938.5 | 938–939.1 | 1,065.51 | 0.962× |
random-1m
1 MiB of deterministic xorshift64 pseudorandom bytes.
1,048,581 compressed → 1,048,576 restored bytes; compressed / original: 100.000%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| Google C | 39.92 | 39.9–39.95 | 25,048.14 | 1.000× |
| mbrotli | 15.97 | 15.95–15.99 | 62,628.72 | 2.500× |
| Rust brotli | 91.51 | 91.22–91.85 | 10,927.83 | 0.436× |
| Burli | 15.99 | 15.97–16 | 62,555.46 | 2.497× |
alice29
Vendored Alice in Wonderland text.
65,795 compressed → 152,089 restored bytes; compressed / original: 43.261%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| Google C | 289.9 | 289.6–290.2 | 500.37 | 1.000× |
| mbrotli | 304.4 | 304.2–304.5 | 476.56 | 0.952× |
| Rust brotli | 426.8 | 426.3–427.5 | 339.82 | 0.679× |
| Burli | 330.1 | 329.8–330.3 | 439.44 | 0.878× |
empty
Empty input; measures API overhead.
1 compressed → 0 restored bytes; compressed / original: undefined.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| Google C | 0.101 | 0.1008–0.1011 | — | 1.000× |
| mbrotli | 0.02024 | 0.01956–0.02094 | — | 4.989× |
| Rust brotli | 1.742 | 1.74–1.745 | — | 0.058× |
| Burli | 0.01543 | 0.01538–0.01552 | — | 6.545× |
Decompression of quality 1 streams
Decoder overview · Benchmark index
Recorded run: dec-2026-10-30-amd (2026-10-30). CSV · Environment · Methodology and limits.
Quality belongs to the C encoder that prepared the input; decoders have no quality setting. All four decode the same bytes. Burli is included at every source quality; SIMD Brotli re-exports Rust brotli’s decoder and is omitted.
Median speed across 8 datasets
Median of per-dataset C mean time / decoder mean time ratios, with equal weight. Higher is faster; 1× matches C. No confidence interval is inferred for this across-dataset median.
| Decoder | Median speed / C |
|---|---|
| Google C | 1.000× |
| mbrotli | 3.196× |
| Rust brotli | 0.445× |
| Burli | 3.129× |
mbrotli median speed / Burli: 1.020× (direct per-dataset ratios).
Datasets are ranked by mbrotli speed relative to the fastest peer, highest first. Throughput counts restored bytes; empty input has latency only. Whiskers and intervals describe sampling uncertainty, not all host variation. Cold APIs include allocation and disposal. C knows the output capacity; Rust brotli includes its 4 KiB I/O adapter.
tiny-text
44-byte quick-brown-fox sentence.
48 compressed → 44 restored bytes; compressed / original: 109.091%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| Google C | 0.1489 | 0.1484–0.1495 | 281.85 | 1.000× |
| mbrotli | 0.02249 | 0.02166–0.02334 | 1,865.55 | 6.619× |
| Rust brotli | 1.844 | 1.842–1.846 | 22.75 | 0.081× |
| Burli | 0.0347 | 0.03452–0.0349 | 1,209.37 | 4.291× |
text-1m
Alice text repeated cyclically to 1 MiB.
138,777 compressed → 1,048,576 restored bytes; compressed / original: 13.235%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| Google C | 795.2 | 793.7–796.3 | 1,257.58 | 1.000× |
| mbrotli | 681.3 | 680.9–681.8 | 1,467.70 | 1.167× |
| Rust brotli | 939.6 | 938.6–940.9 | 1,064.31 | 0.846× |
| Burli | 747.8 | 747.4–748.2 | 1,337.29 | 1.063× |
binary-64k
64 KiB of structured binary bytes: ((i / 16) XOR i) modulo 256.
3,202 compressed → 65,536 restored bytes; compressed / original: 4.886%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| Google C | 35.35 | 35.34–35.37 | 1,767.80 | 1.000× |
| mbrotli | 9.27 | 9.261–9.281 | 6,741.99 | 3.814× |
| Rust brotli | 77.69 | 77.61–77.78 | 804.46 | 0.455× |
| Burli | 9.545 | 9.54–9.55 | 6,547.74 | 3.704× |
random-64k
64 KiB prefix of the deterministic pseudorandom corpus.
65,540 compressed → 65,536 restored bytes; compressed / original: 100.006%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| Google C | 2.177 | 2.176–2.179 | 28,703.96 | 1.000× |
| mbrotli | 0.8443 | 0.8438–0.8449 | 74,023.09 | 2.579× |
| Rust brotli | 35.78 | 35.72–35.84 | 1,746.90 | 0.061× |
| Burli | 0.8527 | 0.852–0.8535 | 73,298.50 | 2.554× |
random-1m
1 MiB of deterministic xorshift64 pseudorandom bytes.
1,048,581 compressed → 1,048,576 restored bytes; compressed / original: 100.000%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| Google C | 39.99 | 39.94–40.05 | 25,006.64 | 1.000× |
| mbrotli | 15.9 | 15.89–15.92 | 62,881.56 | 2.515× |
| Rust brotli | 91.89 | 91.59–92.22 | 10,882.59 | 0.435× |
| Burli | 15.97 | 15.94–16.02 | 62,598.41 | 2.503× |
alice29
Vendored Alice in Wonderland text.
60,292 compressed → 152,089 restored bytes; compressed / original: 39.643%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| Google C | 268.5 | 268.2–268.9 | 540.14 | 1.000× |
| mbrotli | 281.7 | 281.4–282 | 514.89 | 0.953× |
| Rust brotli | 399.7 | 399.5–399.9 | 362.86 | 0.672× |
| Burli | 332.3 | 331.8–332.9 | 436.45 | 0.808× |
empty
Empty input; measures API overhead.
1 compressed → 0 restored bytes; compressed / original: undefined.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| Google C | 0.1136 | 0.1135–0.1138 | — | 1.000× |
| mbrotli | 0.02024 | 0.01954–0.02095 | — | 5.614× |
| Rust brotli | 1.735 | 1.734–1.737 | — | 0.065× |
| Burli | 0.01544 | 0.01539–0.01552 | — | 7.360× |
repeated-1m
1 MiB of repeated a bytes.
190 compressed → 1,048,576 restored bytes; compressed / original: 0.018%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| Google C | 373.3 | 372.8–373.8 | 2,678.88 | 1.000× |
| mbrotli | 32.43 | 32.38–32.48 | 30,839.38 | 11.512× |
| Rust brotli | 234 | 233.6–234.4 | 4,274.34 | 1.596× |
| Burli | 18.81 | 18.79–18.83 | 53,166.03 | 19.846× |
Decompression of quality 2 streams
Decoder overview · Benchmark index
Recorded run: dec-2026-10-30-amd (2026-10-30). CSV · Environment · Methodology and limits.
Quality belongs to the C encoder that prepared the input; decoders have no quality setting. All four decode the same bytes. Burli is included at every source quality; SIMD Brotli re-exports Rust brotli’s decoder and is omitted.
Median speed across 8 datasets
Median of per-dataset C mean time / decoder mean time ratios, with equal weight. Higher is faster; 1× matches C. No confidence interval is inferred for this across-dataset median.
| Decoder | Median speed / C |
|---|---|
| Google C | 1.000× |
| mbrotli | 3.325× |
| Rust brotli | 0.570× |
| Burli | 3.362× |
mbrotli median speed / Burli: 1.056× (direct per-dataset ratios).
Datasets are ranked by mbrotli speed relative to the fastest peer, highest first. Throughput counts restored bytes; empty input has latency only. Whiskers and intervals describe sampling uncertainty, not all host variation. Cold APIs include allocation and disposal. C knows the output capacity; Rust brotli includes its 4 KiB I/O adapter.
tiny-text
44-byte quick-brown-fox sentence.
48 compressed → 44 restored bytes; compressed / original: 109.091%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| Google C | 0.1846 | 0.1842–0.1851 | 227.34 | 1.000× |
| mbrotli | 0.02252 | 0.02169–0.02337 | 1,863.59 | 8.197× |
| Rust brotli | 1.845 | 1.842–1.849 | 22.74 | 0.100× |
| Burli | 0.03513 | 0.03499–0.03529 | 1,194.48 | 5.254× |
text-1m
Alice text repeated cyclically to 1 MiB.
57,403 compressed → 1,048,576 restored bytes; compressed / original: 5.474%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| Google C | 659.7 | 659–660.4 | 1,515.89 | 1.000× |
| mbrotli | 276.1 | 275.9–276.4 | 3,621.52 | 2.389× |
| Rust brotli | 877.3 | 876.6–877.9 | 1,139.92 | 0.752× |
| Burli | 308.2 | 307.8–308.7 | 3,244.40 | 2.140× |
repeated-1m
1 MiB of repeated a bytes.
23 compressed → 1,048,576 restored bytes; compressed / original: 0.002%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| Google C | 1,227 | 1,226–1,227 | 815.31 | 1.000× |
| mbrotli | 10.52 | 10.52–10.53 | 95,016.53 | 116.541× |
| Rust brotli | 1,258 | 1,257–1,259 | 794.77 | 0.975× |
| Burli | 11.55 | 11.54–11.56 | 86,549.46 | 106.156× |
random-64k
64 KiB prefix of the deterministic pseudorandom corpus.
65,540 compressed → 65,536 restored bytes; compressed / original: 100.006%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| Google C | 2.201 | 2.2–2.202 | 28,391.28 | 1.000× |
| mbrotli | 0.84 | 0.8393–0.8407 | 74,404.66 | 2.621× |
| Rust brotli | 35.84 | 35.81–35.86 | 1,744.07 | 0.061× |
| Burli | 0.852 | 0.8492–0.8574 | 73,355.28 | 2.584× |
random-1m
1 MiB of deterministic xorshift64 pseudorandom bytes.
1,048,581 compressed → 1,048,576 restored bytes; compressed / original: 100.000%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| Google C | 39.88 | 39.83–39.92 | 25,077.78 | 1.000× |
| mbrotli | 15.86 | 15.83–15.88 | 63,067.51 | 2.515× |
| Rust brotli | 83.82 | 83.69–83.97 | 11,930.95 | 0.476× |
| Burli | 15.86 | 15.84–15.88 | 63,057.55 | 2.514× |
binary-64k
64 KiB of structured binary bytes: ((i / 16) XOR i) modulo 256.
2,673 compressed → 65,536 restored bytes; compressed / original: 4.079%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| Google C | 34.73 | 34.7–34.77 | 1,799.57 | 1.000× |
| mbrotli | 8.621 | 8.617–8.625 | 7,250.00 | 4.029× |
| Rust brotli | 46.52 | 46.47–46.57 | 1,343.56 | 0.747× |
| Burli | 8.388 | 8.382–8.395 | 7,450.75 | 4.140× |
alice29
Vendored Alice in Wonderland text.
57,383 compressed → 152,089 restored bytes; compressed / original: 37.730%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| Google C | 245.9 | 245.4–246.4 | 589.90 | 1.000× |
| mbrotli | 254.7 | 254.4–255 | 569.48 | 0.965× |
| Rust brotli | 370.1 | 369.8–370.4 | 391.94 | 0.664× |
| Burli | 286.6 | 285.9–287.8 | 506.07 | 0.858× |
empty
Empty input; measures API overhead.
1 compressed → 0 restored bytes; compressed / original: undefined.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| Google C | 0.1133 | 0.113–0.1135 | — | 1.000× |
| mbrotli | 0.02025 | 0.01957–0.02095 | — | 5.593× |
| Rust brotli | 1.728 | 1.726–1.729 | — | 0.066× |
| Burli | 0.01551 | 0.01545–0.0156 | — | 7.302× |
Decompression of quality 3 streams
Decoder overview · Benchmark index
Recorded run: dec-2026-10-30-amd (2026-10-30). CSV · Environment · Methodology and limits.
Quality belongs to the C encoder that prepared the input; decoders have no quality setting. All four decode the same bytes. Burli is included at every source quality; SIMD Brotli re-exports Rust brotli’s decoder and is omitted.
Median speed across 8 datasets
Median of per-dataset C mean time / decoder mean time ratios, with equal weight. Higher is faster; 1× matches C. No confidence interval is inferred for this across-dataset median.
| Decoder | Median speed / C |
|---|---|
| Google C | 1.000× |
| mbrotli | 2.548× |
| Rust brotli | 0.563× |
| Burli | 2.536× |
mbrotli median speed / Burli: 0.986× (direct per-dataset ratios).
Datasets are ranked by mbrotli speed relative to the fastest peer, highest first. Throughput counts restored bytes; empty input has latency only. Whiskers and intervals describe sampling uncertainty, not all host variation. Cold APIs include allocation and disposal. C knows the output capacity; Rust brotli includes its 4 KiB I/O adapter.
text-1m
Alice text repeated cyclically to 1 MiB.
56,879 compressed → 1,048,576 restored bytes; compressed / original: 5.424%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| Google C | 647.6 | 646.9–648.3 | 1,544.18 | 1.000× |
| mbrotli | 262.3 | 262.1–262.5 | 3,812.97 | 2.469× |
| Rust brotli | 863.3 | 862.5–864.1 | 1,158.39 | 0.750× |
| Burli | 299.7 | 299.5–299.9 | 3,337.01 | 2.161× |
random-64k
64 KiB prefix of the deterministic pseudorandom corpus.
65,540 compressed → 65,536 restored bytes; compressed / original: 100.006%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| Google C | 2.21 | 2.209–2.211 | 28,277.40 | 1.000× |
| mbrotli | 0.8434 | 0.8426–0.8443 | 74,108.59 | 2.621× |
| Rust brotli | 35.9 | 35.87–35.93 | 1,740.85 | 0.062× |
| Burli | 0.8529 | 0.8524–0.8535 | 73,276.48 | 2.591× |
random-1m
1 MiB of deterministic xorshift64 pseudorandom bytes.
1,048,581 compressed → 1,048,576 restored bytes; compressed / original: 100.000%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| Google C | 39.6 | 39.56–39.64 | 25,253.03 | 1.000× |
| mbrotli | 15.99 | 15.98–16.01 | 62,531.82 | 2.476× |
| Rust brotli | 84.31 | 84.1–84.55 | 11,861.19 | 0.470× |
| Burli | 15.97 | 15.96–15.98 | 62,627.60 | 2.480× |
binary-64k
64 KiB of structured binary bytes: ((i / 16) XOR i) modulo 256.
2,687 compressed → 65,536 restored bytes; compressed / original: 4.100%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| Google C | 34.52 | 34.5–34.55 | 1,810.61 | 1.000× |
| mbrotli | 8.315 | 8.308–8.324 | 7,516.37 | 4.151× |
| Rust brotli | 46.2 | 46.14–46.28 | 1,352.70 | 0.747× |
| Burli | 8.102 | 8.098–8.107 | 7,713.85 | 4.260× |
repeated-1m
1 MiB of repeated a bytes.
13 compressed → 1,048,576 restored bytes; compressed / original: 0.001%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| Google C | 1,225 | 1,225–1,226 | 816.09 | 1.000× |
| mbrotli | 8.547 | 8.538–8.556 | 117,004.43 | 143.372× |
| Rust brotli | 1,258 | 1,256–1,259 | 795.08 | 0.974× |
| Burli | 8.199 | 8.189–8.209 | 121,971.40 | 149.459× |
alice29
Vendored Alice in Wonderland text.
56,863 compressed → 152,089 restored bytes; compressed / original: 37.388%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| Google C | 233.2 | 233–233.5 | 621.85 | 1.000× |
| mbrotli | 243.9 | 243.8–244.1 | 594.63 | 0.956× |
| Rust brotli | 355.8 | 355.6–356.1 | 407.66 | 0.656× |
| Burli | 278.5 | 278.3–278.6 | 520.85 | 0.838× |
empty
Empty input; measures API overhead.
1 compressed → 0 restored bytes; compressed / original: undefined.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| Google C | 0.1098 | 0.1096–0.11 | — | 1.000× |
| mbrotli | 0.02032 | 0.01965–0.02106 | — | 5.402× |
| Rust brotli | 1.73 | 1.726–1.735 | — | 0.063× |
| Burli | 0.01543 | 0.01538–0.0155 | — | 7.116× |
tiny-text
44-byte quick-brown-fox sentence.
46 compressed → 44 restored bytes; compressed / original: 104.545%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| Google C | 0.7607 | 0.7597–0.7621 | 55.16 | 1.000× |
| mbrotli | 0.7369 | 0.7351–0.7383 | 56.95 | 1.032× |
| Rust brotli | 3.463 | 3.46–3.466 | 12.12 | 0.220× |
| Burli | 0.5204 | 0.5172–0.523 | 80.64 | 1.462× |
Decompression of quality 4 streams
Decoder overview · Benchmark index
Recorded run: dec-2026-10-30-amd (2026-10-30). CSV · Environment · Methodology and limits.
Quality belongs to the C encoder that prepared the input; decoders have no quality setting. All four decode the same bytes. Burli is included at every source quality; SIMD Brotli re-exports Rust brotli’s decoder and is omitted.
Median speed across 8 datasets
Median of per-dataset C mean time / decoder mean time ratios, with equal weight. Higher is faster; 1× matches C. No confidence interval is inferred for this across-dataset median.
| Decoder | Median speed / C |
|---|---|
| Google C | 1.000× |
| mbrotli | 3.461× |
| Rust brotli | 0.574× |
| Burli | 3.407× |
mbrotli median speed / Burli: 1.003× (direct per-dataset ratios).
Datasets are ranked by mbrotli speed relative to the fastest peer, highest first. Throughput counts restored bytes; empty input has latency only. Whiskers and intervals describe sampling uncertainty, not all host variation. Cold APIs include allocation and disposal. C knows the output capacity; Rust brotli includes its 4 KiB I/O adapter.
tiny-text
44-byte quick-brown-fox sentence.
48 compressed → 44 restored bytes; compressed / original: 109.091%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| Google C | 0.1689 | 0.1684–0.1695 | 248.43 | 1.000× |
| mbrotli | 0.02235 | 0.02146–0.0233 | 1,877.77 | 7.558× |
| Rust brotli | 1.813 | 1.812–1.815 | 23.14 | 0.093× |
| Burli | 0.03519 | 0.03507–0.03533 | 1,192.56 | 4.800× |
text-1m
Alice text repeated cyclically to 1 MiB.
55,915 compressed → 1,048,576 restored bytes; compressed / original: 5.332%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| Google C | 655.2 | 640.5–670.3 | 1,526.33 | 1.000× |
| mbrotli | 239.6 | 239.4–239.8 | 4,173.38 | 2.734× |
| Rust brotli | 827.7 | 827.3–828.1 | 1,208.19 | 0.792× |
| Burli | 291.7 | 291.4–292 | 3,428.35 | 2.246× |
random-64k
64 KiB prefix of the deterministic pseudorandom corpus.
65,540 compressed → 65,536 restored bytes; compressed / original: 100.006%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| Google C | 2.186 | 2.185–2.187 | 28,590.91 | 1.000× |
| mbrotli | 0.8425 | 0.8419–0.8432 | 74,185.89 | 2.595× |
| Rust brotli | 35.78 | 35.76–35.8 | 1,746.73 | 0.061× |
| Burli | 0.8537 | 0.8533–0.8541 | 73,211.67 | 2.561× |
random-1m
1 MiB of deterministic xorshift64 pseudorandom bytes.
1,048,581 compressed → 1,048,576 restored bytes; compressed / original: 100.000%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| Google C | 40.18 | 40.12–40.23 | 24,890.59 | 1.000× |
| mbrotli | 15.88 | 15.85–15.9 | 62,986.65 | 2.531× |
| Rust brotli | 90.98 | 90.92–91.05 | 10,991.00 | 0.442× |
| Burli | 15.75 | 15.73–15.77 | 63,501.84 | 2.551× |
binary-64k
64 KiB of structured binary bytes: ((i / 16) XOR i) modulo 256.
2,554 compressed → 65,536 restored bytes; compressed / original: 3.897%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| Google C | 34.66 | 34.64–34.68 | 1,803.36 | 1.000× |
| mbrotli | 8.274 | 8.265–8.284 | 7,553.65 | 4.189× |
| Rust brotli | 46.7 | 46.66–46.74 | 1,338.46 | 0.742× |
| Burli | 8.15 | 8.145–8.154 | 7,669.11 | 4.253× |
repeated-1m
1 MiB of repeated a bytes.
13 compressed → 1,048,576 restored bytes; compressed / original: 0.001%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| Google C | 1,226 | 1,225–1,228 | 815.47 | 1.000× |
| mbrotli | 8.507 | 8.504–8.51 | 117,554.83 | 144.156× |
| Rust brotli | 1,253 | 1,253–1,254 | 797.77 | 0.978× |
| Burli | 8.189 | 8.178–8.201 | 122,112.05 | 149.744× |
alice29
Vendored Alice in Wonderland text.
55,504 compressed → 152,089 restored bytes; compressed / original: 36.494%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| Google C | 220.1 | 219.6–220.7 | 658.93 | 1.000× |
| mbrotli | 234.5 | 234.3–234.7 | 618.51 | 0.939× |
| Rust brotli | 312 | 311.8–312.2 | 464.88 | 0.706× |
| Burli | 291.8 | 291.4–292.2 | 497.14 | 0.754× |
empty
Empty input; measures API overhead.
1 compressed → 0 restored bytes; compressed / original: undefined.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| Google C | 0.1098 | 0.1097–0.1099 | — | 1.000× |
| mbrotli | 0.02026 | 0.01958–0.02096 | — | 5.419× |
| Rust brotli | 1.728 | 1.727–1.73 | — | 0.064× |
| Burli | 0.01545 | 0.0154–0.01555 | — | 7.105× |
Decompression of quality 5 streams
Decoder overview · Benchmark index
Recorded run: dec-2026-10-30-amd (2026-10-30). CSV · Environment · Methodology and limits.
Quality belongs to the C encoder that prepared the input; decoders have no quality setting. All four decode the same bytes. Burli is included at every source quality; SIMD Brotli re-exports Rust brotli’s decoder and is omitted.
Median speed across 8 datasets
Median of per-dataset C mean time / decoder mean time ratios, with equal weight. Higher is faster; 1× matches C. No confidence interval is inferred for this across-dataset median.
| Decoder | Median speed / C |
|---|---|
| Google C | 1.000× |
| mbrotli | 2.572× |
| Rust brotli | 0.574× |
| Burli | 2.555× |
mbrotli median speed / Burli: 0.982× (direct per-dataset ratios).
Datasets are ranked by mbrotli speed relative to the fastest peer, highest first. Throughput counts restored bytes; empty input has latency only. Whiskers and intervals describe sampling uncertainty, not all host variation. Cold APIs include allocation and disposal. C knows the output capacity; Rust brotli includes its 4 KiB I/O adapter.
text-1m
Alice text repeated cyclically to 1 MiB.
51,228 compressed → 1,048,576 restored bytes; compressed / original: 4.885%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| Google C | 668.6 | 662.3–675.6 | 1,495.77 | 1.000× |
| mbrotli | 272.5 | 272.2–272.8 | 3,669.63 | 2.453× |
| Rust brotli | 841.7 | 840.9–842.8 | 1,188.01 | 0.794× |
| Burli | 358.6 | 358.4–359 | 2,788.30 | 1.864× |
random-64k
64 KiB prefix of the deterministic pseudorandom corpus.
65,540 compressed → 65,536 restored bytes; compressed / original: 100.006%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| Google C | 2.176 | 2.175–2.177 | 28,723.63 | 1.000× |
| mbrotli | 0.836 | 0.8352–0.8368 | 74,764.92 | 2.603× |
| Rust brotli | 35.81 | 35.79–35.83 | 1,745.40 | 0.061× |
| Burli | 0.8477 | 0.8471–0.8484 | 73,728.71 | 2.567× |
random-1m
1 MiB of deterministic xorshift64 pseudorandom bytes.
1,048,581 compressed → 1,048,576 restored bytes; compressed / original: 100.000%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| Google C | 40.04 | 40.01–40.06 | 24,977.09 | 1.000× |
| mbrotli | 15.76 | 15.74–15.78 | 63,450.30 | 2.540× |
| Rust brotli | 91.21 | 91.15–91.29 | 10,963.55 | 0.439× |
| Burli | 15.74 | 15.72–15.75 | 63,542.42 | 2.544× |
binary-64k
64 KiB of structured binary bytes: ((i / 16) XOR i) modulo 256.
1,588 compressed → 65,536 restored bytes; compressed / original: 2.423%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| Google C | 34.79 | 34.75–34.84 | 1,796.64 | 1.000× |
| mbrotli | 8.717 | 8.708–8.728 | 7,170.03 | 3.991× |
| Rust brotli | 47.94 | 47.87–48.03 | 1,303.80 | 0.726× |
| Burli | 8.408 | 8.401–8.417 | 7,433.37 | 4.137× |
repeated-1m
1 MiB of repeated a bytes.
13 compressed → 1,048,576 restored bytes; compressed / original: 0.001%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| Google C | 1,225 | 1,224–1,225 | 816.53 | 1.000× |
| mbrotli | 8.556 | 8.542–8.573 | 116,872.96 | 143.133× |
| Rust brotli | 1,257 | 1,256–1,258 | 795.38 | 0.974× |
| Burli | 8.209 | 8.205–8.212 | 121,824.59 | 149.197× |
alice29
Vendored Alice in Wonderland text.
52,809 compressed → 152,089 restored bytes; compressed / original: 34.722%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| Google C | 207.7 | 207.4–208 | 698.21 | 1.000× |
| mbrotli | 225.6 | 225.3–225.8 | 643.06 | 0.921× |
| Rust brotli | 293 | 292.8–293.2 | 495.00 | 0.709× |
| Burli | 284.2 | 283.9–284.6 | 510.30 | 0.731× |
empty
Empty input; measures API overhead.
1 compressed → 0 restored bytes; compressed / original: undefined.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| Google C | 0.104 | 0.1038–0.1042 | — | 1.000× |
| mbrotli | 0.02027 | 0.01958–0.02102 | — | 5.129× |
| Rust brotli | 1.727 | 1.725–1.729 | — | 0.060× |
| Burli | 0.01542 | 0.01538–0.0155 | — | 6.744× |
tiny-text
44-byte quick-brown-fox sentence.
48 compressed → 44 restored bytes; compressed / original: 109.091%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| Google C | 0.7005 | 0.7–0.7011 | 59.90 | 1.000× |
| mbrotli | 0.8751 | 0.8744–0.8758 | 47.95 | 0.801× |
| Rust brotli | 3.432 | 3.429–3.435 | 12.23 | 0.204× |
| Burli | 0.5891 | 0.5875–0.5907 | 71.23 | 1.189× |
Decompression of quality 6 streams
Decoder overview · Benchmark index
Recorded run: dec-2026-10-30-amd (2026-10-30). CSV · Environment · Methodology and limits.
Quality belongs to the C encoder that prepared the input; decoders have no quality setting. All four decode the same bytes. Burli is included at every source quality; SIMD Brotli re-exports Rust brotli’s decoder and is omitted.
Median speed across 8 datasets
Median of per-dataset C mean time / decoder mean time ratios, with equal weight. Higher is faster; 1× matches C. No confidence interval is inferred for this across-dataset median.
| Decoder | Median speed / C |
|---|---|
| Google C | 1.000× |
| mbrotli | 2.568× |
| Rust brotli | 0.569× |
| Burli | 2.545× |
mbrotli median speed / Burli: 0.982× (direct per-dataset ratios).
Datasets are ranked by mbrotli speed relative to the fastest peer, highest first. Throughput counts restored bytes; empty input has latency only. Whiskers and intervals describe sampling uncertainty, not all host variation. Cold APIs include allocation and disposal. C knows the output capacity; Rust brotli includes its 4 KiB I/O adapter.
text-1m
Alice text repeated cyclically to 1 MiB.
50,823 compressed → 1,048,576 restored bytes; compressed / original: 4.847%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| Google C | 687.5 | 678.8–695.7 | 1,454.59 | 1.000× |
| mbrotli | 267.9 | 267.6–268.2 | 3,732.56 | 2.566× |
| Rust brotli | 837.5 | 836.7–838.5 | 1,194.01 | 0.821× |
| Burli | 355.3 | 354.9–355.6 | 2,814.86 | 1.935× |
random-64k
64 KiB prefix of the deterministic pseudorandom corpus.
65,540 compressed → 65,536 restored bytes; compressed / original: 100.006%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| Google C | 2.165 | 2.164–2.166 | 28,866.35 | 1.000× |
| mbrotli | 0.8424 | 0.8417–0.8431 | 74,194.16 | 2.570× |
| Rust brotli | 35.88 | 35.83–35.93 | 1,741.82 | 0.060× |
| Burli | 0.8536 | 0.8532–0.854 | 73,220.64 | 2.537× |
random-1m
1 MiB of deterministic xorshift64 pseudorandom bytes.
1,048,581 compressed → 1,048,576 restored bytes; compressed / original: 100.000%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| Google C | 40.04 | 39.99–40.09 | 24,976.27 | 1.000× |
| mbrotli | 15.69 | 15.67–15.7 | 63,744.07 | 2.552× |
| Rust brotli | 92.17 | 92.08–92.26 | 10,849.92 | 0.434× |
| Burli | 15.68 | 15.67–15.69 | 63,767.51 | 2.553× |
binary-64k
64 KiB of structured binary bytes: ((i / 16) XOR i) modulo 256.
1,588 compressed → 65,536 restored bytes; compressed / original: 2.423%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| Google C | 34.67 | 34.65–34.69 | 1,802.66 | 1.000× |
| mbrotli | 8.751 | 8.748–8.756 | 7,141.74 | 3.962× |
| Rust brotli | 46.42 | 46.36–46.49 | 1,346.29 | 0.747× |
| Burli | 8.44 | 8.417–8.462 | 7,405.27 | 4.108× |
repeated-1m
1 MiB of repeated a bytes.
13 compressed → 1,048,576 restored bytes; compressed / original: 0.001%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| Google C | 1,228 | 1,226–1,229 | 814.58 | 1.000× |
| mbrotli | 8.458 | 8.45–8.469 | 118,232.82 | 145.146× |
| Rust brotli | 1,268 | 1,266–1,269 | 788.88 | 0.968× |
| Burli | 8.054 | 8.047–8.061 | 124,165.77 | 152.430× |
alice29
Vendored Alice in Wonderland text.
51,967 compressed → 152,089 restored bytes; compressed / original: 34.169%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| Google C | 202.4 | 201.9–203 | 716.48 | 1.000× |
| mbrotli | 220.8 | 220.6–221 | 656.86 | 0.917× |
| Rust brotli | 287.6 | 287.3–287.9 | 504.35 | 0.704× |
| Burli | 281.1 | 280.9–281.3 | 515.95 | 0.720× |
empty
Empty input; measures API overhead.
1 compressed → 0 restored bytes; compressed / original: undefined.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| Google C | 0.1037 | 0.1036–0.1038 | — | 1.000× |
| mbrotli | 0.02023 | 0.01954–0.02095 | — | 5.126× |
| Rust brotli | 1.735 | 1.734–1.737 | — | 0.060× |
| Burli | 0.01543 | 0.01539–0.01551 | — | 6.720× |
tiny-text
44-byte quick-brown-fox sentence.
48 compressed → 44 restored bytes; compressed / original: 109.091%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| Google C | 0.6916 | 0.6911–0.6923 | 60.67 | 1.000× |
| mbrotli | 0.8731 | 0.8722–0.8741 | 48.06 | 0.792× |
| Rust brotli | 3.436 | 3.433–3.441 | 12.21 | 0.201× |
| Burli | 0.5861 | 0.5846–0.5873 | 71.60 | 1.180× |
Decompression of quality 7 streams
Decoder overview · Benchmark index
Recorded run: dec-2026-10-30-amd (2026-10-30). CSV · Environment · Methodology and limits.
Quality belongs to the C encoder that prepared the input; decoders have no quality setting. All four decode the same bytes. Burli is included at every source quality; SIMD Brotli re-exports Rust brotli’s decoder and is omitted.
Median speed across 8 datasets
Median of per-dataset C mean time / decoder mean time ratios, with equal weight. Higher is faster; 1× matches C. No confidence interval is inferred for this across-dataset median.
| Decoder | Median speed / C |
|---|---|
| Google C | 1.000× |
| mbrotli | 2.574× |
| Rust brotli | 0.562× |
| Burli | 2.511× |
mbrotli median speed / Burli: 0.982× (direct per-dataset ratios).
Datasets are ranked by mbrotli speed relative to the fastest peer, highest first. Throughput counts restored bytes; empty input has latency only. Whiskers and intervals describe sampling uncertainty, not all host variation. Cold APIs include allocation and disposal. C knows the output capacity; Rust brotli includes its 4 KiB I/O adapter.
text-1m
Alice text repeated cyclically to 1 MiB.
50,580 compressed → 1,048,576 restored bytes; compressed / original: 4.824%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| Google C | 691.9 | 674.7–709.8 | 1,445.28 | 1.000× |
| mbrotli | 268.7 | 268.3–269 | 3,722.26 | 2.575× |
| Rust brotli | 838.5 | 837.9–839.2 | 1,192.54 | 0.825× |
| Burli | 367 | 366.5–367.4 | 2,725.06 | 1.885× |
random-64k
64 KiB prefix of the deterministic pseudorandom corpus.
65,540 compressed → 65,536 restored bytes; compressed / original: 100.006%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| Google C | 2.17 | 2.168–2.171 | 28,804.19 | 1.000× |
| mbrotli | 0.8435 | 0.8426–0.8444 | 74,096.58 | 2.572× |
| Rust brotli | 35.44 | 35.42–35.47 | 1,763.36 | 0.061× |
| Burli | 0.8651 | 0.8648–0.8654 | 72,248.23 | 2.508× |
random-1m
1 MiB of deterministic xorshift64 pseudorandom bytes.
1,048,581 compressed → 1,048,576 restored bytes; compressed / original: 100.000%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| Google C | 40.24 | 40.17–40.32 | 24,853.21 | 1.000× |
| mbrotli | 16.04 | 16.02–16.06 | 62,348.51 | 2.509× |
| Rust brotli | 94.53 | 94.41–94.63 | 10,578.93 | 0.426× |
| Burli | 16.01 | 16–16.02 | 62,459.59 | 2.513× |
binary-64k
64 KiB of structured binary bytes: ((i / 16) XOR i) modulo 256.
1,588 compressed → 65,536 restored bytes; compressed / original: 2.423%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| Google C | 34.73 | 34.71–34.75 | 1,799.59 | 1.000× |
| mbrotli | 8.756 | 8.751–8.761 | 7,137.99 | 3.966× |
| Rust brotli | 46.44 | 46.38–46.51 | 1,345.73 | 0.748× |
| Burli | 8.453 | 8.437–8.47 | 7,394.00 | 4.109× |
repeated-1m
1 MiB of repeated a bytes.
13 compressed → 1,048,576 restored bytes; compressed / original: 0.001%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| Google C | 1,227 | 1,226–1,229 | 814.87 | 1.000× |
| mbrotli | 8.486 | 8.476–8.497 | 117,840.80 | 144.613× |
| Rust brotli | 1,263 | 1,262–1,264 | 791.84 | 0.972× |
| Burli | 8.047 | 8.044–8.051 | 124,265.58 | 152.498× |
alice29
Vendored Alice in Wonderland text.
51,451 compressed → 152,089 restored bytes; compressed / original: 33.830%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| Google C | 198.4 | 197.9–199 | 731.03 | 1.000× |
| mbrotli | 214.6 | 214.4–214.9 | 675.81 | 0.924× |
| Rust brotli | 283.9 | 283.7–284.1 | 510.84 | 0.699× |
| Burli | 278.2 | 277.9–278.5 | 521.41 | 0.713× |
empty
Empty input; measures API overhead.
1 compressed → 0 restored bytes; compressed / original: undefined.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| Google C | 0.1127 | 0.1126–0.1128 | — | 1.000× |
| mbrotli | 0.02022 | 0.01954–0.02093 | — | 5.573× |
| Rust brotli | 1.736 | 1.734–1.738 | — | 0.065× |
| Burli | 0.01544 | 0.01539–0.01552 | — | 7.300× |
tiny-text
44-byte quick-brown-fox sentence.
48 compressed → 44 restored bytes; compressed / original: 109.091%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| Google C | 0.695 | 0.6942–0.6958 | 60.38 | 1.000× |
| mbrotli | 0.8747 | 0.8736–0.8759 | 47.97 | 0.795× |
| Rust brotli | 3.435 | 3.43–3.44 | 12.22 | 0.202× |
| Burli | 0.5848 | 0.5838–0.5857 | 71.75 | 1.188× |
Decompression of quality 8 streams
Decoder overview · Benchmark index
Recorded run: dec-2026-10-30-amd (2026-10-30). CSV · Environment · Methodology and limits.
Quality belongs to the C encoder that prepared the input; decoders have no quality setting. All four decode the same bytes. Burli is included at every source quality; SIMD Brotli re-exports Rust brotli’s decoder and is omitted.
Median speed across 8 datasets
Median of per-dataset C mean time / decoder mean time ratios, with equal weight. Higher is faster; 1× matches C. No confidence interval is inferred for this across-dataset median.
| Decoder | Median speed / C |
|---|---|
| Google C | 1.000× |
| mbrotli | 2.547× |
| Rust brotli | 0.572× |
| Burli | 2.533× |
mbrotli median speed / Burli: 0.988× (direct per-dataset ratios).
Datasets are ranked by mbrotli speed relative to the fastest peer, highest first. Throughput counts restored bytes; empty input has latency only. Whiskers and intervals describe sampling uncertainty, not all host variation. Cold APIs include allocation and disposal. C knows the output capacity; Rust brotli includes its 4 KiB I/O adapter.
text-1m
Alice text repeated cyclically to 1 MiB.
50,473 compressed → 1,048,576 restored bytes; compressed / original: 4.813%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| Google C | 647.9 | 646.8–649.5 | 1,543.52 | 1.000× |
| mbrotli | 268.7 | 268.4–269 | 3,721.35 | 2.411× |
| Rust brotli | 838.8 | 838–840 | 1,192.11 | 0.772× |
| Burli | 364.5 | 364.2–364.9 | 2,743.19 | 1.777× |
random-64k
64 KiB prefix of the deterministic pseudorandom corpus.
65,540 compressed → 65,536 restored bytes; compressed / original: 100.006%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| Google C | 2.21 | 2.208–2.212 | 28,276.44 | 1.000× |
| mbrotli | 0.8597 | 0.8591–0.8603 | 72,700.10 | 2.571× |
| Rust brotli | 35.45 | 35.43–35.48 | 1,762.84 | 0.062× |
| Burli | 0.867 | 0.8666–0.8676 | 72,085.13 | 2.549× |
random-1m
1 MiB of deterministic xorshift64 pseudorandom bytes.
1,048,581 compressed → 1,048,576 restored bytes; compressed / original: 100.000%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| Google C | 40.01 | 39.98–40.06 | 24,991.58 | 1.000× |
| mbrotli | 15.87 | 15.84–15.89 | 63,031.53 | 2.522× |
| Rust brotli | 92.48 | 92.24–92.72 | 10,813.62 | 0.433× |
| Burli | 15.9 | 15.89–15.92 | 62,876.70 | 2.516× |
binary-64k
64 KiB of structured binary bytes: ((i / 16) XOR i) modulo 256.
1,588 compressed → 65,536 restored bytes; compressed / original: 2.423%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| Google C | 34.68 | 34.62–34.79 | 1,802.12 | 1.000× |
| mbrotli | 8.659 | 8.655–8.662 | 7,218.30 | 4.005× |
| Rust brotli | 46.86 | 46.83–46.89 | 1,333.68 | 0.740× |
| Burli | 8.427 | 8.4–8.455 | 7,416.64 | 4.116× |
repeated-1m
1 MiB of repeated a bytes.
13 compressed → 1,048,576 restored bytes; compressed / original: 0.001%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| Google C | 1,227 | 1,225–1,229 | 815.20 | 1.000× |
| mbrotli | 8.461 | 8.453–8.47 | 118,187.86 | 144.980× |
| Rust brotli | 1,270 | 1,268–1,273 | 787.34 | 0.966× |
| Burli | 8.071 | 8.067–8.075 | 123,898.16 | 151.984× |
alice29
Vendored Alice in Wonderland text.
51,187 compressed → 152,089 restored bytes; compressed / original: 33.656%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| Google C | 200.7 | 200.5–200.9 | 722.57 | 1.000× |
| mbrotli | 233.4 | 233.2–233.6 | 621.53 | 0.860× |
| Rust brotli | 282.4 | 281.9–282.8 | 513.70 | 0.711× |
| Burli | 286.7 | 286.4–287.1 | 505.88 | 0.700× |
empty
Empty input; measures API overhead.
1 compressed → 0 restored bytes; compressed / original: undefined.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| Google C | 0.11 | 0.1098–0.1102 | — | 1.000× |
| mbrotli | 0.02027 | 0.0196–0.02098 | — | 5.426× |
| Rust brotli | 1.711 | 1.709–1.713 | — | 0.064× |
| Burli | 0.01543 | 0.01538–0.01551 | — | 7.130× |
tiny-text
44-byte quick-brown-fox sentence.
48 compressed → 44 restored bytes; compressed / original: 109.091%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| Google C | 0.6926 | 0.6921–0.6931 | 60.59 | 1.000× |
| mbrotli | 0.9057 | 0.9049–0.9066 | 46.33 | 0.765× |
| Rust brotli | 3.54 | 3.536–3.543 | 11.85 | 0.196× |
| Burli | 0.5869 | 0.5854–0.5883 | 71.49 | 1.180× |
Decompression of quality 9 streams
Decoder overview · Benchmark index
Recorded run: dec-2026-10-30-amd (2026-10-30). CSV · Environment · Methodology and limits.
Quality belongs to the C encoder that prepared the input; decoders have no quality setting. All four decode the same bytes. Burli is included at every source quality; SIMD Brotli re-exports Rust brotli’s decoder and is omitted.
Median speed across 8 datasets
Median of per-dataset C mean time / decoder mean time ratios, with equal weight. Higher is faster; 1× matches C. No confidence interval is inferred for this across-dataset median.
| Decoder | Median speed / C |
|---|---|
| Google C | 1.000× |
| mbrotli | 2.553× |
| Rust brotli | 0.569× |
| Burli | 2.536× |
mbrotli median speed / Burli: 0.986× (direct per-dataset ratios).
Datasets are ranked by mbrotli speed relative to the fastest peer, highest first. Throughput counts restored bytes; empty input has latency only. Whiskers and intervals describe sampling uncertainty, not all host variation. Cold APIs include allocation and disposal. C knows the output capacity; Rust brotli includes its 4 KiB I/O adapter.
text-1m
Alice text repeated cyclically to 1 MiB.
50,608 compressed → 1,048,576 restored bytes; compressed / original: 4.826%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| Google C | 652.6 | 652.2–653.1 | 1,532.26 | 1.000× |
| mbrotli | 271.6 | 269.4–275.3 | 3,681.69 | 2.403× |
| Rust brotli | 860.4 | 859.4–861.8 | 1,162.20 | 0.758× |
| Burli | 373.8 | 373.3–374.2 | 2,675.39 | 1.746× |
random-64k
64 KiB prefix of the deterministic pseudorandom corpus.
65,540 compressed → 65,536 restored bytes; compressed / original: 100.006%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| Google C | 2.219 | 2.21–2.231 | 28,163.35 | 1.000× |
| mbrotli | 0.858 | 0.8577–0.8583 | 72,842.62 | 2.586× |
| Rust brotli | 35.43 | 35.4–35.46 | 1,764.11 | 0.063× |
| Burli | 0.8649 | 0.8642–0.8656 | 72,266.70 | 2.566× |
random-1m
1 MiB of deterministic xorshift64 pseudorandom bytes.
1,048,581 compressed → 1,048,576 restored bytes; compressed / original: 100.000%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| Google C | 40.07 | 40.04–40.1 | 24,959.00 | 1.000× |
| mbrotli | 15.9 | 15.9–15.91 | 62,877.12 | 2.519× |
| Rust brotli | 92.24 | 92.16–92.34 | 10,841.68 | 0.434× |
| Burli | 15.98 | 15.95–16.03 | 62,569.63 | 2.507× |
binary-64k
64 KiB of structured binary bytes: ((i / 16) XOR i) modulo 256.
1,349 compressed → 65,536 restored bytes; compressed / original: 2.058%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| Google C | 34.45 | 34.43–34.48 | 1,814.24 | 1.000× |
| mbrotli | 8.385 | 8.376–8.394 | 7,454.19 | 4.109× |
| Rust brotli | 46.64 | 46.61–46.67 | 1,340.02 | 0.739× |
| Burli | 8.109 | 8.096–8.121 | 7,707.05 | 4.248× |
repeated-1m
1 MiB of repeated a bytes.
13 compressed → 1,048,576 restored bytes; compressed / original: 0.001%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| Google C | 1,226 | 1,225–1,227 | 815.75 | 1.000× |
| mbrotli | 8.465 | 8.459–8.473 | 118,128.32 | 144.809× |
| Rust brotli | 1,269 | 1,265–1,274 | 788.31 | 0.966× |
| Burli | 8.05 | 8.043–8.057 | 124,226.86 | 152.285× |
alice29
Vendored Alice in Wonderland text.
51,054 compressed → 152,089 restored bytes; compressed / original: 33.569%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| Google C | 200.5 | 200.4–200.7 | 723.34 | 1.000× |
| mbrotli | 214.9 | 214.7–215.1 | 674.87 | 0.933× |
| Rust brotli | 284.6 | 284.4–284.9 | 509.59 | 0.704× |
| Burli | 287.9 | 287.6–288.1 | 503.87 | 0.697× |
empty
Empty input; measures API overhead.
1 compressed → 0 restored bytes; compressed / original: undefined.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| Google C | 0.1129 | 0.1128–0.1131 | — | 1.000× |
| mbrotli | 0.02025 | 0.01955–0.02097 | — | 5.575× |
| Rust brotli | 1.709 | 1.708–1.71 | — | 0.066× |
| Burli | 0.01545 | 0.0154–0.01553 | — | 7.309× |
tiny-text
44-byte quick-brown-fox sentence.
48 compressed → 44 restored bytes; compressed / original: 109.091%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| Google C | 0.7998 | 0.7984–0.8013 | 52.47 | 1.000× |
| mbrotli | 0.9037 | 0.9031–0.9043 | 46.43 | 0.885× |
| Rust brotli | 3.511 | 3.507–3.515 | 11.95 | 0.228× |
| Burli | 0.5856 | 0.5846–0.5865 | 71.65 | 1.366× |
Decompression of quality 10 streams
Decoder overview · Benchmark index
Recorded run: dec-2026-10-30-amd (2026-10-30). CSV · Environment · Methodology and limits.
Quality belongs to the C encoder that prepared the input; decoders have no quality setting. All four decode the same bytes. Burli is included at every source quality; SIMD Brotli re-exports Rust brotli’s decoder and is omitted.
Median speed across 8 datasets
Median of per-dataset C mean time / decoder mean time ratios, with equal weight. Higher is faster; 1× matches C. No confidence interval is inferred for this across-dataset median.
| Decoder | Median speed / C |
|---|---|
| Google C | 1.000× |
| mbrotli | 2.770× |
| Rust brotli | 0.581× |
| Burli | 2.632× |
mbrotli median speed / Burli: 1.051× (direct per-dataset ratios).
Datasets are ranked by mbrotli speed relative to the fastest peer, highest first. Throughput counts restored bytes; empty input has latency only. Whiskers and intervals describe sampling uncertainty, not all host variation. Cold APIs include allocation and disposal. C knows the output capacity; Rust brotli includes its 4 KiB I/O adapter.
tiny-text
44-byte quick-brown-fox sentence.
48 compressed → 44 restored bytes; compressed / original: 109.091%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| Google C | 0.1908 | 0.1901–0.1916 | 219.93 | 1.000× |
| mbrotli | 0.02248 | 0.02164–0.02334 | 1,866.71 | 8.488× |
| Rust brotli | 1.842 | 1.84–1.844 | 22.78 | 0.104× |
| Burli | 0.03516 | 0.03497–0.03538 | 1,193.44 | 5.426× |
text-1m
Alice text repeated cyclically to 1 MiB.
47,492 compressed → 1,048,576 restored bytes; compressed / original: 4.529%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| Google C | 707.4 | 706.4–708.6 | 1,413.56 | 1.000× |
| mbrotli | 315.1 | 314.8–315.3 | 3,173.84 | 2.245× |
| Rust brotli | 909.5 | 908.9–910.1 | 1,099.52 | 0.778× |
| Burli | 442 | 441.5–442.6 | 2,262.29 | 1.600× |
binary-64k
64 KiB of structured binary bytes: ((i / 16) XOR i) modulo 256.
1,063 compressed → 65,536 restored bytes; compressed / original: 1.622%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| Google C | 37.85 | 37.81–37.88 | 1,651.39 | 1.000× |
| mbrotli | 12.66 | 12.65–12.66 | 4,937.50 | 2.990× |
| Rust brotli | 51.7 | 51.61–51.84 | 1,208.95 | 0.732× |
| Burli | 13.84 | 13.83–13.86 | 4,514.47 | 2.734× |
random-64k
64 KiB prefix of the deterministic pseudorandom corpus.
65,540 compressed → 65,536 restored bytes; compressed / original: 100.006%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| Google C | 2.21 | 2.208–2.212 | 28,278.34 | 1.000× |
| mbrotli | 0.8666 | 0.866–0.8673 | 72,121.07 | 2.550× |
| Rust brotli | 35.45 | 35.42–35.49 | 1,763.08 | 0.062× |
| Burli | 0.8738 | 0.8733–0.8743 | 71,527.36 | 2.529× |
alice29
Vendored Alice in Wonderland text.
47,477 compressed → 152,089 restored bytes; compressed / original: 31.217%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| Google C | 283.2 | 282.6–283.7 | 512.23 | 1.000× |
| mbrotli | 281 | 280.7–281.2 | 516.17 | 1.008× |
| Rust brotli | 378.1 | 377.8–378.5 | 383.57 | 0.749× |
| Burli | 423 | 422.5–423.6 | 342.90 | 0.669× |
random-1m
1 MiB of deterministic xorshift64 pseudorandom bytes.
1,048,581 compressed → 1,048,576 restored bytes; compressed / original: 100.000%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| Google C | 40.11 | 40.08–40.15 | 24,929.99 | 1.000× |
| mbrotli | 15.92 | 15.9–15.96 | 62,805.31 | 2.519× |
| Rust brotli | 93.45 | 93.33–93.59 | 10,701.03 | 0.429× |
| Burli | 15.89 | 15.88–15.91 | 62,921.46 | 2.524× |
repeated-1m
1 MiB of repeated a bytes.
14 compressed → 1,048,576 restored bytes; compressed / original: 0.001%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| Google C | 1,227 | 1,226–1,228 | 815.00 | 1.000× |
| mbrotli | 8.955 | 8.948–8.963 | 111,664.27 | 137.011× |
| Rust brotli | 1,264 | 1,262–1,267 | 791.11 | 0.971× |
| Burli | 8.062 | 8.052–8.074 | 124,040.42 | 152.197× |
empty
Empty input; measures API overhead.
1 compressed → 0 restored bytes; compressed / original: undefined.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| Google C | 0.1131 | 0.113–0.1133 | — | 1.000× |
| mbrotli | 0.02025 | 0.01956–0.02096 | — | 5.588× |
| Rust brotli | 1.711 | 1.709–1.712 | — | 0.066× |
| Burli | 0.01543 | 0.01538–0.01551 | — | 7.333× |
Decompression of quality 11 streams
Decoder overview · Benchmark index
Recorded run: dec-2026-10-30-amd (2026-10-30). CSV · Environment · Methodology and limits.
Quality belongs to the C encoder that prepared the input; decoders have no quality setting. All four decode the same bytes. Burli is included at every source quality; SIMD Brotli re-exports Rust brotli’s decoder and is omitted.
Median speed across 8 datasets
Median of per-dataset C mean time / decoder mean time ratios, with equal weight. Higher is faster; 1× matches C. No confidence interval is inferred for this across-dataset median.
| Decoder | Median speed / C |
|---|---|
| Google C | 1.000× |
| mbrotli | 2.847× |
| Rust brotli | 0.584× |
| Burli | 2.662× |
mbrotli median speed / Burli: 1.067× (direct per-dataset ratios).
Datasets are ranked by mbrotli speed relative to the fastest peer, highest first. Throughput counts restored bytes; empty input has latency only. Whiskers and intervals describe sampling uncertainty, not all host variation. Cold APIs include allocation and disposal. C knows the output capacity; Rust brotli includes its 4 KiB I/O adapter.
tiny-text
44-byte quick-brown-fox sentence.
48 compressed → 44 restored bytes; compressed / original: 109.091%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| Google C | 0.1959 | 0.1952–0.1968 | 214.18 | 1.000× |
| mbrotli | 0.02331 | 0.02255–0.0241 | 1,800.10 | 8.405× |
| Rust brotli | 1.853 | 1.852–1.855 | 22.64 | 0.106× |
| Burli | 0.03506 | 0.03493–0.03521 | 1,196.79 | 5.588× |
text-1m
Alice text repeated cyclically to 1 MiB.
46,491 compressed → 1,048,576 restored bytes; compressed / original: 4.434%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| Google C | 665.7 | 662.9–669 | 1,502.22 | 1.000× |
| mbrotli | 273 | 272.6–273.6 | 3,663.14 | 2.438× |
| Rust brotli | 850.5 | 849.8–851.1 | 1,175.84 | 0.783× |
| Burli | 385.3 | 384.9–385.6 | 2,595.33 | 1.728× |
binary-64k
64 KiB of structured binary bytes: ((i / 16) XOR i) modulo 256.
1,065 compressed → 65,536 restored bytes; compressed / original: 1.625%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| Google C | 37.84 | 37.81–37.87 | 1,651.78 | 1.000× |
| mbrotli | 12.18 | 12.18–12.19 | 5,129.38 | 3.105× |
| Rust brotli | 51.38 | 51.34–51.42 | 1,216.37 | 0.736× |
| Burli | 13.7 | 13.69–13.71 | 4,562.06 | 2.762× |
random-64k
64 KiB prefix of the deterministic pseudorandom corpus.
65,540 compressed → 65,536 restored bytes; compressed / original: 100.006%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| Google C | 2.207 | 2.206–2.208 | 28,319.23 | 1.000× |
| mbrotli | 0.8525 | 0.8521–0.8529 | 73,314.54 | 2.589× |
| Rust brotli | 35.64 | 35.48–35.89 | 1,753.46 | 0.062× |
| Burli | 0.8611 | 0.8607–0.8614 | 72,584.43 | 2.563× |
random-1m
1 MiB of deterministic xorshift64 pseudorandom bytes.
1,048,581 compressed → 1,048,576 restored bytes; compressed / original: 100.000%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| Google C | 40.17 | 40.14–40.22 | 24,895.16 | 1.000× |
| mbrotli | 16.03 | 16–16.06 | 62,396.03 | 2.506× |
| Rust brotli | 92.35 | 92.2–92.52 | 10,828.28 | 0.435× |
| Burli | 16 | 15.99–16.01 | 62,489.75 | 2.510× |
alice29
Vendored Alice in Wonderland text.
46,487 compressed → 152,089 restored bytes; compressed / original: 30.566%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| Google C | 239.2 | 238.5–239.9 | 606.35 | 1.000× |
| mbrotli | 248.2 | 247.8–248.5 | 584.49 | 0.964× |
| Rust brotli | 326.5 | 326.3–326.9 | 444.17 | 0.733× |
| Burli | 367.9 | 367.5–368.3 | 394.25 | 0.650× |
repeated-1m
1 MiB of repeated a bytes.
14 compressed → 1,048,576 restored bytes; compressed / original: 0.001%.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| Google C | 1,226 | 1,225–1,227 | 815.69 | 1.000× |
| mbrotli | 8.947 | 8.936–8.958 | 111,771.06 | 137.027× |
| Rust brotli | 1,263 | 1,262–1,265 | 791.61 | 0.970× |
| Burli | 8.043 | 8.035–8.053 | 124,330.40 | 152.424× |
empty
Empty input; measures API overhead.
1 compressed → 0 restored bytes; compressed / original: undefined.
| Decoder | Mean µs | 95% mean interval µs | Decoded MiB/s | Speed / C |
|---|---|---|---|---|
| Google C | 0.1133 | 0.1131–0.1135 | — | 1.000× |
| mbrotli | 0.02026 | 0.01958–0.02098 | — | 5.589× |
| Rust brotli | 1.712 | 1.71–1.715 | — | 0.066× |
| Burli | 0.01544 | 0.01539–0.01551 | — | 7.337× |
Decompression comparison — 2026-10-30
Benchmark index · Quality pages · CSV
The complete 384-case run dec-2026-10-30-amd compares Google C Brotli 1.2.0,
the local mbrotli 0.5.19 checkout, Rust brotli 9.0.0 and Burli 0.3.3. All four
restore identical C-produced streams at source qualities q0–q11, generic mode
and window 22. SIMD Brotli re-exports the Rust Brotli decoder project and is omitted.
Across 96 corpus/quality pairs, mbrotli’s median speed is 2.588× C and 1.006× Burli. Its mean latency is lower than C in 80/96 cases and lower than Burli in 53/96. Its upper 95% mean-time bound is below the peer’s lower bound in 80/96 C comparisons and 50/96 Burli comparisons. These are sweep counts, not universal leads.
Final measurements
| Source quality | Google C | mbrotli | Rust brotli | Burli | mbrotli / Burli |
|---|---|---|---|---|---|
| q0 | 1.000× | 3.059× | 0.446× | 2.873× | 1.036× |
| q1 | 1.000× | 3.196× | 0.445× | 3.129× | 1.020× |
| q2 | 1.000× | 3.325× | 0.570× | 3.362× | 1.056× |
| q3 | 1.000× | 2.548× | 0.563× | 2.536× | 0.986× |
| q4 | 1.000× | 3.461× | 0.574× | 3.407× | 1.003× |
| q5 | 1.000× | 2.572× | 0.574× | 2.555× | 0.982× |
| q6 | 1.000× | 2.568× | 0.569× | 2.545× | 0.982× |
| q7 | 1.000× | 2.574× | 0.562× | 2.511× | 0.982× |
| q8 | 1.000× | 2.547× | 0.572× | 2.533× | 0.988× |
| q9 | 1.000× | 2.553× | 0.569× | 2.536× | 0.986× |
| q10 | 1.000× | 2.770× | 0.581× | 2.632× | 1.051× |
| q11 | 1.000× | 2.847× | 0.584× | 2.662× | 1.067× |
Library columns are medians of eight per-dataset C-time/library-time ratios. The last column is the median of direct Burli-time/mbrotli-time ratios; it does not divide C-relative medians. Overall medians use all 96 direct ratios. Empty and tiny inputs have the same weight as larger inputs. Higher ratios are faster.
Measurement contract
One process ran at a time on logical CPU 1 of an AMD Ryzen 9 7950X Linux VDS with two KVM vCPUs. Encoding and decoding sweeps ran serially; no builds, tests, fuzzers or other benchmarks ran concurrently. Rust 1.99.1 and GCC 15.2 used ordinary release defaults, with runtime backend selection. Criterion used 20 samples, 1 s warmup and 3 s requested measurement per case.
The eight corpora are empty, tiny text, Alice, cyclic Alice at 1 MiB, structured
64 KiB binary, random bytes at 64 KiB/1 MiB and repeated a at 1 MiB. C prepares
96 streams once; all 384 decoder cases must restore the original bytes before
timing. Quality belongs to that source encoder, not to a decoder. Throughput
counts restored bytes, and compressed sizes are properties of the shared inputs.
Cold iterations include construction, allocation, decoding and disposal. C gets the known output capacity and a freshly initialized buffer; Rust Vec APIs grow their output. Rust Brotli includes native 4 KiB I/O adaptation. Burli decodes every source quality. No decoder retains state between iterations.
Provenance and limits
The environment record identifies the source hashes, lockfile, measured executable, commands and CSV. The matching size manifest records the shared compressed/restored lengths. This is one sweep on a virtual host with fixed implementation order. Affinity does not control clocks, thermals, allocator effects or host scheduling; sampling intervals do not cover every environmental bias. The measured cold APIs do not cover retained state, streaming, dictionaries or streams from other producers. mbrotli remains 3–8% slower than C on Alice at source qualities other than q10 (14% at q8) and 11–24% slower on tiny text at q5–q9, where fixed setup cost dominates a 44-byte output.
Reproduce
cargo bench --manifest-path benchmarks/comparison/Cargo.toml --bench decoders --locked --no-run
taskset -c 1 cargo bench --manifest-path benchmarks/comparison/Cargo.toml --bench decoders --locked -- --sample-size 20 --warm-up-time 1 --measurement-time 3 --noplot --save-baseline dec-2026-10-30-amd
python3 benchmarks/comparison/report.py --decoding --baseline dec-2026-10-30-amd --csv docs/benchmarks/decoder-comparison.csv
Use a fresh baseline for a new run and archive its size manifest immediately. The measured run used the frozen executable recorded in the environment file. Benchmarking covers chart generation and separate API measurements.
Compression comparison — 2026-10-30
Benchmark index · Quality pages · CSV
The complete 432-case run enc-2026-10-30-amd compares Google C Brotli 1.2.0,
the optimized local mbrotli 0.5.19 checkout, Rust brotli 9.0.0, SIMD Brotli 10.0.1
and Burli 0.3.3. Source and executable identities are in the
environment record; the matching
size manifest is retained with this run.
Final measurements
Across all 96 matching corpus/quality pairs, mbrotli’s median C-time/mbrotli-time ratio is 1.099×. Its mean is lower than C in 67/96 cases and lower than every supported peer in 54/96. In 66/96 cases, mbrotli’s upper 95% mean-time bound is below C’s lower bound; the corresponding count against every peer is 53/96. These counts describe this sweep, not a universal or workload-weighted performance guarantee.
mbrotli is above C at 12/12 per-quality corpus medians. Each median gives the eight datasets equal weight, including empty and tiny input. It is computed from per-dataset C mean time / library mean time, not from median sample times.
| Quality | Google C | mbrotli | Rust brotli | SIMD Brotli | Burli | mbrotli output / C |
|---|---|---|---|---|---|---|
| q0 | 1.000× | 1.349× | 0.090× | 0.100× | 2.011× | 1.000× |
| q1 | 1.000× | 1.123× | 0.107× | 0.141× | 0.825× | 1.000× |
| q2 | 1.000× | 1.020× | 0.355× | 0.336× | 0.793× | 1.000× |
| q3 | 1.000× | 1.089× | 0.409× | 0.401× | 0.952× | 1.000× |
| q4 | 1.000× | 1.183× | 0.529× | 0.488× | 1.201× | 1.000× |
| q5 | 1.000× | 1.017× | 0.442× | 0.355× | 0.942× | 1.000× |
| q6 | 1.000× | 1.008× | 0.404× | 0.344× | — | 1.000× |
| q7 | 1.000× | 1.004× | 0.360× | 0.331× | — | 1.000× |
| q8 | 1.000× | 1.022× | 0.291× | 0.285× | — | 1.000× |
| q9 | 1.000× | 4.323× | 0.828× | 1.681× | — | 1.000× |
| q10 | 1.000× | 1.243× | 0.839× | 0.899× | — | 1.000× |
| q11 | 1.000× | 1.493× | 0.834× | 0.906× | — | 1.000× |
mbrotli and C have equal compressed lengths in 96/96 matching cases. This suite validates round trips through C, not compressed byte identity. Burli encodes only q0–q5; equal quality numbers do not imply equal output size.
Measurement contract
One benchmark process ran at a time, pinned to logical CPU 1 on an AMD Ryzen 9 7950X Linux VDS exposing two KVM vCPUs. Neither builds, tests, fuzzers nor another benchmark ran concurrently. Rust 1.99.1 and GCC 15.2 used ordinary release defaults, with runtime backend selection and no profiling or native-CPU flags. Criterion collected 20 samples after 1 s warmup, requesting 3 s of measurement per case; slow cases may exceed that duration.
Generic mode, window 22, complete known input, no dictionaries or explicit flushes.
Cold iterations include construction, allocation, encoding and disposal. C’s
one-shot wrapper includes initialized output allocation and native output rewrites;
Rust Brotli and SIMD Brotli include their native 4 KiB I/O adapters. No library
retains workspace. Corpus construction and validation of all 432 outputs finish
before timing. Inputs are empty, 44-byte text, Alice (152,089 bytes), cyclic Alice
at 1 MiB, structured 64 KiB binary, deterministic random 64 KiB/1 MiB and repeated
a at 1 MiB. All means, confidence bounds and byte counts remain in the quality pages.
Reproduce
cargo bench --manifest-path benchmarks/comparison/Cargo.toml --bench implementations --locked --no-run
taskset -c 1 cargo bench --manifest-path benchmarks/comparison/Cargo.toml --bench implementations --locked -- --sample-size 20 --warm-up-time 1 --measurement-time 3 --noplot --save-baseline enc-2026-10-30-amd
python3 benchmarks/comparison/report.py --baseline enc-2026-10-30-amd --csv docs/benchmarks/encoder-comparison.csv
Use a fresh baseline name for a new run. The measured run used the frozen
executable recorded in the environment file, directly after its completed build.
Archive sizes.csv immediately with its corresponding baseline. The
benchmark guide gives chart and page-generation commands.
Interpretation limits
This is one serial sweep on a virtual host, with fixed implementation order. CPU affinity does not control host scheduling, clocks, thermal state or allocator page reuse. Sampling intervals do not include every source of environmental bias. Equal corpus weight is not a production workload distribution. These results cover cold serial APIs; streaming, retained workspace, parallel execution and dictionaries require their separate API benchmarks. Earlier Intel and intermediate AMD sweeps are not paired before/after controls for this measured source. Against the 2026-10-29 sweep, C itself ran 5–22% faster on 64 KiB binary and random input at q2–q4 while mbrotli moved by at most 2%, which accounts for most of the lower overall median.
lzbench run, 2026-11-08
mbrotli 0.5.20 against Google Brotli 1.2.0, measured with the lzbench in-memory benchmark on the Silesia corpus. The README’s lzbench table is taken from this run.
Environment
- lzbench master at
a6fa8aa(2026-11-07), reporting itself as lzbench 2.4.1. It vendors Google Brotli 1.2.0 and mbrotli 0.5.20. - gcc 15.2.0, rustc 1.99.1. Built with
make BUILD_ONLY='ZSTD BROTLI MZSTD MBROTLI' MOREFLAGS="-march=native"; the Rust codecs use lzbench’s defaultRUST_TARGET_CPU=native, so both sides target the same CPU. - AMD Ryzen 9 7950X VDS (2 vCPU, KVM), Linux 7.0, default CPU governor and
boost. The benchmark is pinned to one vCPU with
taskset -c 1. - Input:
silesia.tar, 211,957,760 bytes, the twelve Silesia files in a ustar archive, compressed as one stream.
Command
taskset -c 1 ./lzbench -t8,8 -o1 \
-ebrotli,0/mbrotli,0/brotli,2/mbrotli,2/brotli,5/mbrotli,5/brotli,8/mbrotli,8/brotli,11/mbrotli,11 \
silesia.tar
-t8,8 repeats each compression and decompression for at least eight seconds
and reports the fastest iteration. Codecs are interleaved level by level so
that drift on the shared host affects both sides of a pair alike.
Results
lzbench 2.4.1 | GCC 15.2.0 | 64-bit Linux | AMD Ryzen 9 7950X 16-Core Processor
| Compressor name | Compression | Decompress. | Compr. size | Ratio | Filename |
|---|---|---|---|---|---|
| brotli 1.2.0 -0 | 531 MB/s | 452 MB/s | 78466829 | 37.02 | ../silesia.tar |
| mbrotli 0.5.20 -0 | 549 MB/s | 585 MB/s | 78466829 | 37.02 | ../silesia.tar |
| brotli 1.2.0 -2 | 197 MB/s | 542 MB/s | 68070634 | 32.12 | ../silesia.tar |
| mbrotli 0.5.20 -2 | 186 MB/s | 703 MB/s | 68070634 | 32.12 | ../silesia.tar |
| brotli 1.2.0 -5 | 71.1 MB/s | 601 MB/s | 59564581 | 28.10 | ../silesia.tar |
| mbrotli 0.5.20 -5 | 78.2 MB/s | 788 MB/s | 59564581 | 28.10 | ../silesia.tar |
| brotli 1.2.0 -8 | 15.5 MB/s | 662 MB/s | 57149840 | 26.96 | ../silesia.tar |
| mbrotli 0.5.20 -8 | 21.8 MB/s | 862 MB/s | 57149840 | 26.96 | ../silesia.tar |
| brotli 1.2.0 -11 | 0.80 MB/s | 587 MB/s | 50488360 | 23.82 | ../silesia.tar |
| mbrotli 0.5.20 -11 | 1.23 MB/s | 713 MB/s | 50503929 | 23.83 | ../silesia.tar |
Repeatability
A second run of qualities 2 and 5 right after the sweep measured:
| Compressor name | Compression | Decompress. |
|---|---|---|
| brotli 1.2.0 -2 | 204 MB/s | 539 MB/s |
| mbrotli 0.5.20 -2 | 203 MB/s | 706 MB/s |
| brotli 1.2.0 -5 | 77.2 MB/s | 605 MB/s |
| mbrotli 0.5.20 -5 | 83.3 MB/s | 793 MB/s |
Compression speed moved by up to 9% between runs (mbrotli -2: 186 to 203 MB/s) while decompression stayed within 1%. The -0 and -2 compression differences in the sweep (+3.4% and -5.6%) are therefore within noise; -5 (+10% and +8%), -8 and -11 are not. Compressed sizes are equal at qualities 0–8; at quality 11 mbrotli’s output is 15,569 bytes (0.031%) larger.
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 |
|---|---|
How a compress call becomes bytes | Compressor → the encoder for your quality (fast, greedy, high-quality) → Bit output |
| How a stream is decoded | Native decompressor → Decoder SIMD → Owned decoder output |
| Why memory is reused between calls | Encoder workspace → Uninitialized output |
| How one input is split across threads | Parallel compression → Parallel streaming sessions |
| How containers and dictionaries work | Shared Brotli → Framed compressor → Framed decoder |
| How correctness is established | Decoder verification → Fuzzing → Formal verification |
| Which features compile which code | Codec features → No standard library |
Specifications share an outline: module and ownership boundaries, public surface, control and data flow with Mermaid diagrams, and error propagation. Most close with a list of known gaps.
Module map
graph TD
Root[mbrotli public facade] --> Encode[compressor: compression feature]
Root --> Decode[decompressor: decompression feature]
Root --> Common[shared configuration, dictionary and I/O facades]
Encode --> ECore[private compressor::core]
Decode --> DCore[private decompressor::core]
Encode --> Parallel[parallel API and private core: std]
Parallel --> PStream[parallel sessions and task queue: core::stream]
Parallel --> PIO[parallel reader and writer: io]
PIO --> PStream
Encode --> Framing[framed compressor: experimental, alloc]
Framing --> FIO[framed writer and reader: std]
Decode --> FDecode[framed decoder and private core: experimental, alloc]
Framing --> Wire[codec-neutral framing types]
FDecode --> Seek[framed seek reader and resource Read: std]
FDecode --> Wire
ECore --> Probe[private cache_probe: SIMD distance-cache filter for qualities 7-11]
ECore --> Shared[private shared primitives]
DCore --> Shared
ECore --> SIMD[fearless_simd: selected backend]
ECore --> Macro["fearless_simd_macros: #[simd] feature entries"]
Macro --> SIMD
DCore --> SIMD
C[google-brotli-ffi] -. tests and benchmarks .-> Root
CABI[mbrotli-ffi: C ABI crate] --> Root
CABI --> CCore[private mbrotli_ffi::core]
CCore --> CSessions[core::sessions: opaque owning handles and panic guard]
Public modules own ergonomic configuration and high-level errors. Private core
modules own algorithms and state machines; implementation errors, SIMD types and
FFI do not appear in public signatures. Each codec compiles independently.
Common boundaries
Feature gates and primitives that both codecs share.
| Specification | Scope |
|---|---|
| Codec features | Codec gates, shared types and isolated consumer checks. |
| No standard library | Alloc-backed APIs, feature precedence and compile-time SIMD. |
| Shared primitives | Common data, transforms, fixed boxed tables, the baseline SIMD level, prefetch and mask scans. |
| Uninitialized output | Write-only destinations for sessions and one-shot calls, and the C ABI’s use of them. |
Compression
From the public Compressor down to the three encoder cores and the bit writer.
| Specification | Scope |
|---|---|
| Compressor | Public API, sessions, size-hint inference, I/O, dispatch and errors. |
| Encoder workspace | Retained output backing, reset, parked match-finder tables, accounting and backpressure. |
| Bit output | Destinations, command-prefix lookup, literal tables, direct Vec appends, bit operations and overflow. |
| Serial output identity | Shared scheduling, empty finalization and C compatibility. |
| Fast encoder | Qualities 0–1: fragments, direct encoding, entropy coding, prefix-code builders and SIMD scans. |
| Greedy encoder | Qualities 2–9: hash tables and their growth, SIMD range hashing, match scoring, distance-cache prefilter and meta-blocks. |
| High-quality encoder | Qualities 10–11: tree search, SIMD distance-cache and dictionary filters, command pricing and clustering. |
| Owned sessions | Lifetime-free raw and framed sessions, reinit and input-free flush/finish. |
| Parallel compression | Planning, encoder reconfiguration, caller-run tasks, staging and assembly. |
| Parallel streaming sessions | Chunked input into queued segment tasks, ordered delivery, flush, queue closure and the blocking adapters. |
Decompression
The native decoder, its performance paths and the framed container readers.
| Specification | Scope |
|---|---|
| Native decompressor | Parsing, sessions, history, the single-exit command loop, dictionaries, budgets and I/O. |
| Owned decoder output | Stored members, allocation transfer, linear slice history and read-ahead accounting. |
| Literal and stored-header decoding | Literal batches, per-context roots, byte-exact literal runs and stored-member recognition. |
| Decoder SIMD | Command-loop dispatch and bounded fixed-size history copies. |
| Decoder verification | C/RFC oracles, feature profiles and test limitations. |
| Framed decoder | Structured containers, detection, events, dictionaries, validation, budgets and lending reader. |
| Framed seek reader | Footer/directory indexing, lazy metadata, sparse dependency decoding and resource Read. |
Extended formats
Large Window, shared dictionaries and the experimental framing container.
| Specification | Scope |
|---|---|
| Shared Brotli | Large Window declarations and prepared prefix dictionaries. |
| Serialized dictionaries | Experimental wire format, transforms and resource limits. |
| Custom encoding and continuations | Static indexes, contexts and headerless stream offsets. |
| Framed compressor | Reusable owner, structured input, sessions, std adapters, metadata and wire compatibility. |
Language bindings
| Specification | Scope |
|---|---|
| C ABI | mbrotli-ffi: one-shot and session C functions, pointer validation, panic poisoning and status mapping. |
Development tools
How the repository measures, tests, proves and publishes itself.
| Specification | Scope |
|---|---|
| Implementation comparison | Isolated library comparisons, validation, provenance and charts. |
| Fuzzing | Target inputs, oracles, campaigns, regression replay and the libFuzzer adapters. |
| Continuous integration | Triggers, checks, Miri and AddressSanitizer, fuzz campaigns, coverage gating and release publishing. |
| Documentation publishing | Source staging, navigation, section pages, recipe execution, link validation and Pages deployment. |
| Formal verification | Charon/Aeneas translation to Lean 4, function specifications, round-trip theorems and the trusted base. |
Common boundaries
Feature gates and primitives that both codecs share.
| Specification | Scope |
|---|---|
| Codec features | Codec gates, shared types and isolated consumer checks. |
| No standard library | Alloc-backed APIs, feature precedence and compile-time SIMD. |
| Shared primitives | Common data, transforms, fixed boxed tables, the baseline SIMD level, prefetch and mask scans. |
| Uninitialized output | Write-only destinations for sessions and one-shot calls, and the C ABI’s use of them. |
Architecture overview and module map
Codec feature boundaries
The default features are std, compression, and decompression. Codec
selection is independent of the execution environment: a consumer disabling
defaults selects std or no_std and the codecs it needs. Cargo feature
unification is additive; another dependency can enable a disabled codec.
| Feature | Public surface |
|---|---|
compression | compressor, encoder configuration/errors/sessions, prepared and experimental serialized dictionaries, encoder I/O, parallel compression and experimental framing |
decompression | decompressor, decoder configuration/errors/sessions, decode-only dictionaries and borrowed decoding views, decoder I/O |
| Either codec | Backend, RetentionPolicy, Window, WindowEncoding, ConfigError, and the dictionary facade |
Either codec without no_std | io facade and its shared FinishError |
| Neither codec | No codec API or private codec primitives |
no_std still overrides std-only adapters, parallel encoding, framed I/O adapters, and
profiling. experimental and diagnostics do not enable a codec. With
compression disabled, ConfigError retains only window-validation variants.
DictionaryRef::Prepared and its conversion exist only with both codecs;
DictionaryRef::DecodeOnly works without compression. Serialized dictionary
parsing for decoding needs decompression,experimental, while the public
serialized dictionary builder and encoder indexes need compression,experimental.
graph TD
Features[Cargo feature selection] --> C{compression}
Features --> D{decompression}
C --> Encoder[public compressor -> private compressor::core]
D --> Decoder[public decompressor -> private decompressor::core]
C --> Common[private backend / retention / window modules]
D --> Common
Encoder --> Shared[private shared primitives]
Decoder --> Shared
C --> Prepared[compressor::dictionary: prepared encoder indexes]
D --> DecodeDict[dictionary::decode: public decode dictionary API]
Prepared --> Facade[public dictionary facade]
DecodeDict --> Facade
DecodeDict --> Storage[shared::decode_dictionary / decode_serialized]
Encoder --> IO[public io facade, without no_std]
Decoder --> IO
IO --> Finish[private finish_error: shared FinishError]
Common types are defined once and re-exported at their existing crate-root and
compressor paths. compressor::dictionary retains its decoder re-exports when
both codecs are enabled. Window’s conversion into private encoder parameters
remains inside compressor configuration. The common modules contain no encoder
state or decoder state. Public configuration errors use thiserror; codec
errors and their source chains retain their existing behavior.
The shared primitive tree also follows codec gates. Bit output, entropy construction, match scanning, hash tables, command generation and ring storage compile only for compression. Decode dictionary storage and parsing compile only for decompression. Wire tables and dictionary words compile for either codec; borrowed transforms compile for decoding or experimental encoding. Built-in decoder transform tables are absent without decompression.
sequenceDiagram
participant Cargo
participant Root as crate root
participant API as enabled codec API
participant Core as private codec core
Cargo->>Root: resolve additive features
Root->>Root: omit disabled modules and re-exports
Root->>API: expose shared configuration and enabled codec
API->>Core: construct retained state and select backend once
Core-->>API: existing progress or typed codec error
There is no new runtime branch, dispatch point, state transition, allocation or
byte-processing behavior. Existing SIMD selection remains at codec construction,
and streaming state machines and dictionary lifetime rules are unchanged.
fearless_simd and thiserror are optional production dependencies activated
by either codec; environment features forward to them only when enabled.
fearless_simd_macros, the proc-macro crate behind the encoder’s #[simd]
entries, is activated by compression alone: the decoder enters its feature
context through fearless_simd::dispatch!. Tests
explicitly enable dependency std support for host-only oracle builds.
scripts/check_codec_features.py checks independent consumers for all codec,
environment and experimental combinations, including imports that must fail.
Examples and benchmarks declare their required codecs; integration tests gate
their own codec and individual cross-codec cases. Decoder tests retain C and
wire fixtures when compression is disabled. The existing dictionary consumer
check explicitly enables both codecs, and CI no_std profiles do likewise.
Known gaps: active codecs still require a selected math provider (std or
no_std). There is no allocator-free codec or runtime codec switch.
Crate documentation
src/lib.rs owns the shared user guide: API choices, workspace reuse, both I/O
adapter pairs, caller-scheduled parallel encoding, decoder limits, and feature
selection. The overview remains visible with either codec disabled, and states
which features its APIs require. Hidden doctest gates run examples only when the
required codec and environment are enabled; decoder examples use a valid empty
Brotli member so they also execute without compression.
Method and type links resolve through rustdoc to the current build’s API pages.
When an API is disabled, its link points to the guide’s feature-selection section.
src/compressor.md supplements the guide with quality selection, Large Window,
and prepared dictionaries only when compression is enabled.
flowchart TD
Root[src/lib.rs: shared API guide] --> Docs[crate rustdoc]
Compression{compression enabled} -->|yes| Extra[src/compressor.md: encoder details]
Extra --> Docs
Examples[guide examples] --> Gate{required codec and environment enabled}
Gate -->|yes| Run[execute example as doctest]
Gate -->|no| Skip[compile empty doctest entry point]
Experimental framing gates
framing exists with experimental and either codec. Its neutral types have no
encoder dependency. decompression,experimental enables FramedDecompressor,
one-shot results and incremental sessions in both std and alloc builds.
FramedReader additionally requires the absence of no_std. compression,experimental enables FramedCompressor, configuration, borrowed input,
one-shot drivers and native sessions in alloc builds. Only its writer/resource
writer/encoded reader require the absence of no_std. Isolated consumer
probes cover the facade and both codec-specific types.
graph TD
Experimental[experimental + either codec] --> Neutral[framing shared types]
Decode[decompression + experimental] --> Framed[FramedDecompressor and session]
Framed --> Alloc[std or alloc]
Framed --> Reader[FramedReader: not no_std]
Encode[compression + experimental] --> Owner[FramedCompressor, input and native sessions]
Owner --> Alloc
Owner --> Writer[FramedWriter and FramedEncoderReader: not no_std]
no_std feature boundary
no_std is opt-in and disabled by default. The default std feature enables
standard-library support in fearless_simd and thiserror. To build for a target
without std, depend on mbrotli with default-features = false and
features = ["no_std", "compression", "decompression"] (or select just one codec). The library uses #![no_std] and extern crate alloc;
applications supply a global allocator. This is not allocation-free compression.
The no_std feature enables the optional libm dependency and
fearless_simd/libm; ordinary std builds enable neither. Select std or
no_std when disabling default features so fearless_simd has a math provider.
Cargo features are additive. no_std overrides the library’s std-only API and
instrumentation gates even if std or profiling features are also enabled, but
cannot remove dependencies enabled by those features. Bare-metal applications
must omit std and every hotpath* feature (hotpath, hotpath-cpu,
hotpath-alloc, hotpath-callgrind, hotpath-cloud) throughout their
resolved dependency graph. Disabling defaults alone does not select no_std.
flowchart TD
Feature{no_std enabled?}
Feature -->|no| Std[std API: I/O, parallel tasks, framed adapters]
Feature -->|yes| Alloc[core + alloc: compressor, sessions, dictionaries]
Feature -->|yes| Portable[enable libm and fearless_simd/libm]
Std --> Engine[private compressor::core encoder state machines]
Alloc --> Engine
Feature -->|no| Detect[runtime SIMD detection when dependency std is enabled]
Feature -->|yes| Baseline[compile-time SIMD baseline]
Detect --> Dispatch[one retained kernel selection]
Baseline --> Dispatch
Dispatch --> Engine
Engine --> Log{logarithm beyond table}
Log -->|std mode| Native[f64::log2]
Log -->|no_std mode| Libm[libm::log2]
API and ownership
When compression is enabled, the compressor, configuration, high-level errors, retained workspace, slice and
vector output, incremental sessions, and prepared dictionaries remain available.
Experimental serialized dictionaries and stream offsets also remain available
with experimental. Their ownership, state transitions, progress accounting,
reset behavior, and error propagation are unchanged. Owned buffers use alloc;
formatting, range operations, and error traits use core.
The io module, its compressor reader/writer constructors, the conversion from
EncodeError to std::io::Error, and the private I/O error variant are omitted.
The entire parallel module and its private independent-fragment adapter are
omitted because they depend on I/O, files, synchronization, clocks, and unwinding.
Framed writer/reader adapters are omitted because they depend on std::io;
FramedCompressor, structured input and native sessions remain available with
compression,experimental. Codec-neutral
framing types and the experimental framed decoder remain available.
Hotpath measurement attributes are inactive under no_std.
No new public type or state machine is introduced. Low-level implementation
errors remain private; public errors implement core::error::Error through
thiserror without requiring std.
SIMD and numeric invariants
Backend::default uses Level::baseline in no_std mode even when feature
unification enables std in fearless_simd. Backend::available lists backends
supported by that baseline. Compiler target features select the strongest usable
level; unsupported targets retain the scalar fallback. Kernel selection remains
outside inner loops. Unit tests retain their explicit scalar oracle.
CompressorBuilder::build obtains its default token through Backend::default,
so direct construction and explicit backend selection share this policy.
The existing logarithm table is unchanged. Beyond the table, libm::log2 replaces
the unavailable standard-library method. The portable result is tested against
the host logarithm within floating-point rounding tolerance. Differential tests
exercise all qualities against C, including the alloc-only slice/session API.
Different math implementations can round differently; exact byte identity across
all platforms and math libraries is not guaranteed by the logarithm tolerance.
Verification and known gaps
CI checks the library and experimental dictionaries for thumbv7em-none-eabi,
a target with no standard library, and runs host tests with defaults disabled.
Default and explicit std-feature tests continue to cover the omitted APIs;
--all-features exercises the no_std API because that feature takes
precedence. benches/alloc.rs validates and times the alloc-backed one-shot API against C
in either feature mode, recording throughput and output size for text, binary,
repeated, noisy, small, and large inputs.
tests/no_std.rs covers empty and non-empty output, all qualities,
small input/output chunks, flush/finish equivalence with C, dictionary
compatibility, errors, and recovery.
Coverage accumulates the full std workspace suite and no_std unit/API tests before enforcing the repository function-coverage threshold.
No allocator-free mode, embedded I/O adapter, or no-std parallel scheduler is implemented. Bare-metal builds are compile-checked; execution tests use a host allocator and the independent C decoder.
Native decoding
With decompression, the alloc-only surface also includes Decompressor, DecoderSession,
DecodeDictionary and RAW dictionary views. Serialized/custom mappings follow
the same experimental gate in std and alloc profiles. See decoder mechanics
and the four-profile compatibility report.
Codec selection is independent of no_std; see codec features.
Structured decoding with alloc
decompression,experimental,no_std exposes framing detection, owner/session,
owned/range outputs, metadata and dictionary resolution without compression.
Only FramedReader is excluded by no_std precedence.
graph LR
Alloc[alloc + decompression + experimental] --> Frame[framed owner/session and core]
Frame --> Raw[existing raw Stream]
Frame --> Dict[decode-only dictionary preparation]
Std[not no_std] --> Reader[lending BufRead adapter]
Reader --> Frame
Private common codec primitives
src/shared is a private crate-root module. It replaces
src/compressor/core/shared; both codecs import it directly. It is not a public
re-export or a second implementation. Existing bit writer, entropy construction,
match scans, command/metablock representations and ring buffer keep their encoder
behavior and tests. Binary dictionary/transform data is stored once here.
graph LR
Encoder[compressor::core: fast / greedy / hq] --> Shared[crate::shared]
Decoder[decompressor::core] --> Shared
Shared --> Format[format constants / context tables]
Shared --> Words[dictionary word bytes / borrowed Transform]
Shared --> Entropy[encoder entropy, command, block and bit writers]
Shared --> Baseline[baseline: compile-time SIMD level, prefetch]
Entropy --> Scan[scan: 16-lane mask walks]
Scan --> Baseline
Shared --> Decode[decode_dictionary / experimental decode_serialized]
RFC[compressor::core::rfc9841] --> Words
Entropy -. encoder command integration .-> RFC
Dictionary[public dictionary facade] -->|decompression| Decode
Dictionary -->|compression| RFC
The decoder uses wire constants, context tables, built-in words, shared borrowed
transform application and decode-only dictionary storage. It owns its own bit
reader, Huffman reader, window/history and parser state in decompressor::core;
encoder bit output and match-search types are not reused as decoder state.
The encoder’s experimental transform-list ownership and index construction stay
under compressor::core::rfc9841, but application delegates to the same borrowed
Transform that serialized-dictionary decoding uses. Built-in words in the
decoder take apply_builtin instead: every built-in prefix and suffix is at
most eight bytes and a word at most 24, so it copies them as fixed 8- and
32-byte moves from padded tables into the 541-byte scratch, where the bytes
past the logical end are overwritten by the suffix or never read. That replaced
three variable-length memcpy calls per dictionary reference (decoding
dictionary-heavy text 3-8% fewer instructions, up to 12% faster at quality
eleven); an out-of-line exact copy covers the last words of the table, whose
32-byte read would run past it. A test compares it with Transform::apply for
all 121 transforms. The shared module still contains encoder-only
primitives: command integration depends on private RFC9841 encoder types. This
is a crate-private dependency, not a fully independent reusable package. Backend
selection remains at each public codec’s construction boundary; encoder SIMD
dispatch stays at the encoders’ block boundaries in compressor::core.
sequenceDiagram
participant Codec
participant View as borrowed word/transform view
participant Shared as shared::dictionary::Transform
participant Scratch as caller-owned fixed scratch
Codec->>View: resolve word and transform index
View-->>Codec: borrowed bytes and validated operation
Codec->>Shared: apply(word, scratch)
Shared->>Scratch: prefix + transformed word + suffix
Shared-->>Codec: actual transformed length
Transform operations are byte-oriented format rules, not general Unicode case
conversion. Incomplete sequences use initialized guard space in the fixed scratch;
suffix output overwrites any casing writes beyond the logical word. Custom shift
operations and custom serialized representations compile only with experimental.
No transformed dictionary is materialized. Borrowed inputs outlive one application,
and no core keeps caller pointers between operations.
shared::baseline and shared::scan (compression only) give code outside the
encoders’ dispatched SIMD entries a vector level without detection. Handing
such code the encoder’s runtime level would make every vector operation an
out-of-line call, so baseline::run hands a Kernel (a value with a generic
run<S: Simd>) the level every build of the target has: Sse2 from
Level::baseline().as_sse2() on x86, Neon on AArch64, otherwise whatever
Level::baseline() is. The selection is a constant and folds away.
baseline::prefetch is the reference’s PREFETCH_L1, a read hint into L1:
branches::prefetch_read_data::<T, 0>, which keeps the inline assembly out of
this crate. It emits prefetcht0 on x86-64 and x86 with SSE, prfm pldl1keep on AArch64, the targets’ own prefetch on riscv64 with zicbop,
s390x and PowerPC, and the LLVM intrinsic on nightly; elsewhere it does
nothing. The crate’s std feature enables branches/std; without it,
branches builds its unused abort on core::intrinsics::abort_immediate
under any nightly, which older nightlies lack. A no_std build on such a
nightly still fails inside branches; stable is unaffected. There is no
write variant: the hinted bucket is read by the next
search before its store, and a line read by the encoder’s single thread
arrives exclusive, so the store needs no second request. scan holds the sixteen-lane mask walks the prefix-code builders use
(fast-encoder.md §6.1); each is tested on the scalar,
baseline and detected levels against the plain loop.
flowchart LR
Caller["huffman / histogram / hashers (no target_feature)"] --> Run["baseline::run(kernel)"]
Run -->|x86| SSE2["kernel.run(Sse2)"]
Run -->|AArch64| NEON["kernel.run(Neon)"]
Run -->|other| Any["dispatch!(Level::baseline())"]
Caller --> Pre["baseline::prefetch(&T)"]
Pre --> B["branches::prefetch_read_data::<T, 0>"]
B -->|x86 / x86-64| T0["prefetcht0"]
B -->|AArch64| PLD["prfm pldl1keep"]
B -->|unsupported target| Nop["no-op"]
shared::fixed (compression only) builds Box<[T; N]> tables:
fixed_table(initial) allocates through a Vec of exactly N entries, so a
zero fill reaches the allocator as a zeroed allocation, and
fixed_table_from_vec(values, initial) resizes an existing vector into one
without copying. The greedy matchers’ dense and chain tables and the
high-quality cost model’s histograms use it. A zero-valued table is built
with vec! rather than by collecting an iterator: vec![0; n] reaches
alloc_zeroed for every element type, while a collected u32 table compiles
to an allocation plus an explicit memset (up to 1800× slower on a fresh
16 MiB table touched in its first page).
grow_zeroed(buffer, len) grows a reusable Vec workspace. An empty buffer is
allocated zeroed, so a large buffer most of which is never written costs no
writes; a buffer that already holds data grows with Vec::resize, which an
allocator may perform in place, and never shrinks. Greedy, high-quality and fast
encoder output storage and the fast encoder’s hash table use it. Buffers that
are sized per block and written in full before they are read are resized
without a preceding clear(), so only newly exposed elements are zero-filled.
graph LR
Fixed[shared::fixed] --> Greedy[greedy dense / chain tables]
Fixed --> Cost[hq ZopfliCostModel histograms and literal prices]
Vec["vec![initial; N]"] --> Boxed["into_boxed_slice → Box<[T; N]>"]
Grow["grow_zeroed(buffer, len)"] --> Empty{"buffer empty?"}
Empty -->|yes| Calloc["vec![0; len]: zeroed allocation"]
Empty -->|no| Resize["Vec::resize: zero only the new tail"]
Known gap: encoder-specific primitives remain colocated with format-neutral data; there is no standalone common-data crate. This keeps existing private dependencies and public API identities intact while making common data accessible to both codecs.
Codec gates remove unused private primitives as well as public APIs. Encoder
search/hash/entropy modules require compression; decoder dictionary modules
require decompression. Built-in words and format tables are common. Public
decode dictionary types live in src/dictionary/decode.rs, with the root
facade retaining prepared dictionary exports when compression is enabled.
Shared configuration and finish errors live in private root modules; see
codec feature boundaries for their ownership diagram.
Uninitialized output
Sessions and one-shot slice APIs have *_uninit siblings that write into
&mut [MaybeUninit<u8>]: spare Vec capacity, a foreign buffer, or C memory
that was never initialized. They produce the same bytes, counts, statuses and
errors as the initialized-slice methods, and the crate stays free of unsafe
(src/lib.rs forbids it outside tests). The one place that has to read back
what it wrote into such memory, the decoder’s one-shot history, does it
through the borrowed-buf crate’s BorrowedBuf, whose filled prefix is safe
to view as &mut [u8].
Public API surface
| Initialized | Uninitialized | Owner |
|---|---|---|
EncoderSession::process | EncoderSession::process_uninit | compressor::session |
EncoderSessionOwned::process | EncoderSessionOwned::process_uninit | compressor::session |
ParallelEncoderSession::process | ParallelEncoderSession::process_uninit | compressor::parallel::session |
ParallelEncoderSessionOwned::process | ParallelEncoderSessionOwned::process_uninit | compressor::parallel::session |
Compressor::compress_to_slice | Compressor::compress_to_uninit | compressor::encoder |
DecoderSession::process | DecoderSession::process_uninit | decompressor::session |
DecoderSessionOwned::process | DecoderSessionOwned::process_uninit | decompressor::session |
Decompressor::decompress_to_slice | Decompressor::decompress_to_uninit | decompressor::decoder |
flush and finish have no uninitialized siblings: they are shorthands for
process. Dictionary one-shots and framed sessions have none either.
The contract every method keeps: on success exactly output[..produced] (or
dst[..written]) is initialized and no byte after it is written. Calls of the
initialized and uninitialized method may be mixed on one session. After a
decoder DecodeFailure, its produced prefix is initialized as well; after an
encoder error, or a decoder error other than OutputTooSmall, callers treat
the destination as uninitialized.
Why a separate path
Safe Rust can write a MaybeUninit<u8> but can neither read one back nor view
a range of them as &mut [u8]; write_copy_of_slice (the only safe view, and
itself a copy) needs Rust 1.93 and the MSRV is 1.89. BorrowedBuf (a stable
port of core::io::BorrowedBuf) tracks how much of such a slice was written
and views exactly that prefix as &mut [u8]; its unsafe stays inside that
crate. Two fast paths read their destination:
- the q0/q1 in-place fragment writer (
FastEncoder::encode_block_into), whose bit writer keeps a partial byte, writes 8-byte words read-modify-write, rewinds and patches meta-block headers in the caller’s slice; - the decoder’s linear one-shot delivery (
Delivery::Linear), which uses the caller’s slice as the first member’s history.
The encoder’s uninitialized methods encode into initialized owned storage and
copy completed bytes to the caller. Fast non-flush sessions use the compressor’s
pending backing directly, retaining its initialized length and tracking the
completed prefix separately. One-shot calls and other encoder paths use encoder
scratch. Decoder streaming process_uninit delivers through the ring, since a
later call’s destination cannot hold the paused member’s history. The one-shot
decompress_to_uninit keeps linear delivery: it zeroes the destination one
meta-block ahead of the decoder (see Linear one-shot
history).
Compressor
classDiagram
class Destination {
<<private enum, core::stream>>
Slice(&mut [u8])
Uninit(&mut [MaybeUninit u8])
Append(&mut Vec u8)
}
class Output {
-Destination destination
-usize produced
+append(bytes) usize
}
class OperationState {
+process(.., &mut [u8], ..)
-run(.., Destination, ..)
}
Output --> Destination
OperationState ..> Output : builds per call
SessionCore and OwnedSessionCore forward process_uninit to
OperationState::run with Destination::Uninit; process keeps its
&mut [u8] entry, which the framed resource driver also calls, and wraps it in
Destination::Slice. The one-shot compress_to_slice_attached and
compress_to_uninit_attached share driver::compress_to_destination, which
also writes the empty-input stream through Output::append.
flowchart TD
Block[Delivery::encode block] --> Fast{Fast encoder, not Flush?}
Fast -->|no| Greedy{Greedy, Append, not Flush?}
Greedy -->|no| Scratch[encode_block_with into retained scratch]
Greedy -->|yes| GreedyAppend[encode_block_append]
GreedyAppend --> Raw{Known verbatim block?}
Raw -->|yes| DirectRaw[Write ring payload directly to initialized Vec]
Raw -->|no| GreedyScratch[Compress into scratch, then append]
Fast -->|yes| Kind{destination}
Kind -->|Slice with room for fragment_reserve| InPlace[encode_block_into caller slice]
Kind -->|Append| Vec[encode_block_append into Vec]
Kind -->|Uninit, or Slice too small| Resumable{session permits pending?}
Resumable -->|no| Scratch
Resumable -->|yes| Backing[Fixed writer in initialized pending backing]
Backing --> Publish[Publish completed end after success]
Publish --> Drain[Copy pending from served to completed end]
Drain --> Retain[Retain initialized length; reset cursors after complete delivery]
Scratch --> Append[Output::append copies what fits]
Append --> Rest{bytes left over?}
Rest -->|session| Pending[keep in pending for the next call]
Rest -->|one-shot| Small[OutputTooSmall]
The greedy direct-append branch accepts only Destination::Append, whose Vec
contains an initialized prefix that the bit writer may read. It writes known
verbatim payloads directly and leaves compressed trials scratch-backed.
Destination::Uninit never enters this branch: its initialized scratch is still
copied through Output::append, preserving exactly the same produced-prefix
contract and leaving bytes beyond it untouched.
Output::append copies into Uninit through shared::uninit::copy_to_uninit,
a zip + MaybeUninit::write loop that compiles to a memcpy and keeps the
MSRV. It copies exactly the bytes it counts, which is what makes the
“nothing past produced” contract hold; the in-place Slice path may leave
scratch bytes past produced, which initialized callers never relied on.
Parallel streaming sessions
A parallel session never encodes into the caller’s slice: workers stage each
segment in a Vec<u8>, and the session only copies finished parts out in
input order. Its delivery therefore never reads the destination, and both
methods share one path. core::stream::Stream::process and its drain are
generic over a private Sink trait (len, put(at, bytes)), implemented for
[u8] (copy_from_slice) and [MaybeUninit<u8>] (copy_to_uninit). Each
public method instantiates it once, so the initialized path compiles to the
same slice copies as before and no call chooses a variant at run time.
sequenceDiagram
participant API as ParallelEncoderSession{,Owned}
participant Stream as core::Stream
participant Flight as finished parts
participant Sink as Sink impl
API->>Stream: process::<[MaybeUninit u8]>(parent, input, output, operation)
Stream->>Flight: collect published completions
Stream->>Sink: drain(output, 0): put(produced, part bytes)
Stream->>Stream: accept input, queue full segments
Stream->>Sink: drain(output, produced)
Stream-->>API: ParallelProgress
drain writes exactly the take bytes it adds to produced, which is what
keeps “nothing past produced”. See Parallel streaming
sessions.
Decompressor
classDiagram
class Sink~'a~ {
<<private trait, core::stream>>
+LINEAR bool
+len() usize
+put(index, byte)
+copy(start, bytes)
+history() initialized slice or empty
+take_history() initialized slice or empty
+restore_history(slice)
}
class InitSlice["&'a mut [u8]"]
class UninitSlice["&'a mut [MaybeUninit u8]"]
class Output~O: Sink~ {
+O bytes
+bool linear
+Option collect
+usize produced
}
Sink <|.. InitSlice
Sink <|.. UninitSlice
Output --> Sink
Every write of decoded bytes into the destination goes through Sink::put
(per-byte literal tails, emit) or Sink::copy (ring flushes, stored
meta-blocks, prefix runs). The bulk command loop works on the ring or on the
linear slice it took out with take_history and hands back with
restore_history. Only the initialized slice can be linear history:
history and take_history return an empty slice for the uninitialized one,
restore_history accepts only that empty slice back, and an uninitialized
sink is only ever run with Delivery::Slice (by process_uninit), where
linear is false. Its
Sink::LINEAR constant is false as well, so Stream::run never selects the
linear state machine for it and that instantiation is never generated (see
Owned decoder output).
OperationState::run, Stream::run, run_stages, fast, emit,
flush_ring, copy_ring and write_ring are generic over the sink, so each
destination type gets its own instantiation and no call chooses a variant at
run time. Below Stream::run they are also generic over const LINEAR: bool,
giving three instantiations: initialized linear, initialized ring and
uninitialized ring. The first version matched on a Sink enum inside those helpers;
the match itself was cheap, but run_stages inlines every stage and the
SIMD command loop into one function, and the extra arms changed that
function’s register allocation. Decoding alice29 then executed 9% more
instructions (callgrind, q0 and q5) and the cold decoder comparison was 4-7%
slower on text and binary corpora. The initialized instantiation now compiles
to the same slice writes as before uninitialized output existed.
sequenceDiagram
participant API as DecoderSession / Decompressor
participant Op as OperationState
participant Stream as core::Stream
participant Ring as history ring
participant Sink as uninitialized Sink
API->>Op: process_uninit(input, output, operation)
Op->>Op: run::<&mut [MaybeUninit u8]>(output, Delivery::Slice)
Op->>Stream: run(input, Output)
Stream->>Ring: decode commands into the ring
Stream->>Sink: flush_ring → copy(produced, ring bytes)
Stream->>Sink: literal tails → put(produced, byte)
Stream-->>Op: Stop and produced
Op-->>API: DecodeProgress or DecodeFailure
decompress_to_uninit first asks stored_one_shot, the check
Decompressor::decompress also uses, whether src is one complete stored
member under a configuration without limits or other members; if its payload
fits in dst, it is copied with copy_to_uninit and no session or ring is
created. Otherwise, including a stored payload that does not fit, it runs
DecoderSession::finish_linear_uninit on a fresh session and shares
one_shot_outcome with decompress_to_slice, so OutputTooSmall and
TrailingData are reported identically.
Linear one-shot history
finish_linear_uninit wraps dst in a BorrowedBuf<u8> whose filled prefix
is the decoder’s linear history: the bytes decoded so far plus zeros the
decoder is about to overwrite. Each round passes filled_mut() to
OperationState::process with Delivery::Linear, which resumes at
total_out with the earlier output in front of it as history (the first call
starts at an empty prefix). The initialized-slice instantiation of the decode
loop runs; no uninitialized one is involved.
sequenceDiagram
participant API as decompress_to_uninit
participant S as finish_linear_uninit
participant B as BorrowedBuf over dst
participant Op as OperationState::process (Linear)
API->>S: src, dst
S->>B: from(dst): empty filled prefix
loop until Finished, an error, or dst is full
S->>Op: input rest, filled_mut(), Finish
Op-->>S: progress (NeedsOutput when the prefix is used up)
S->>S: grow = clamp(produced + declared_remaining - filled, 1, 32 KiB)
S->>B: zero_extend(grow): cursor.append_fill(grow, 0)
end
S-->>API: summed consumed, produced, last status
stateDiagram-v2
[*] --> Decode: filled = 0
Decode --> Grow: NeedsOutput and room left
Grow --> Decode: filled += min(meta-block rest, 32 KiB, room)
Decode --> Done: Finished or NeedsInput
Decode --> TooSmall: NeedsOutput and dst full
Decode --> Failed: decode error
Done --> [*]
TooSmall --> [*]: OutputTooSmall, dst fully decoded
Failed --> [*]
The first call parses the window and meta-block headers and stops at the
first byte to write; from then on declared_remaining is the current
meta-block’s undecoded length. Growing by at most that keeps the zeroed prefix
inside bytes the stream will produce, so on success nothing past written is
written, as for every uninitialized method. The decode loop already bounds its
speculative 16- and 32-byte copies by the meta-block end and the slice end.
A pause outside a meta-block’s payload grows by one byte, which guarantees
progress. After the first member ends, later members (with
MemberMode::Concatenated) are delivered from the ring into the same growing
prefix at their offset.
zero_extend is one BorrowedCursor::append_fill(len, 0) (borrowed-buf
0.1.1), a memset; copying the zeros from a 4 KiB static block, the only way
before that method existed, took 3-7% longer on highly compressible data.
The 32 KiB cap (ZERO_AHEAD_STEP, shared with the Vec path of
decompress_into) keeps the freshly zeroed lines in L1 when the decoder
overwrites them: on exact-sized destinations (i7-13700KF, one core) caps of
16, 32 and 64 KiB took 1.023, 1.019 and 1.074 times as long as
decompress_to_slice. The ring path took 1.17 times, so the one-shot takes
12% less time than before in geometric mean: 15-20% on 1 MiB of text, 3-9%
on literary texts, 6-13% on binary and 28-41% on data compressed beyond
100:1; stored members and 1 KiB outputs are unchanged.
C ABI
mbrotli-ffi views the caller’s output as &mut [MaybeUninit<u8>] and calls
compress_to_uninit and decompress_to_uninit. Only a decoding destination
of at most 1 KiB is zeroed in full (BorrowedBuf::ensure_init) and decoded
with decompress_to_slice, which saves the extra decoding call per
meta-block. An uninitialized C buffer is never viewed as &mut [u8] before it
has been zeroed. See C ABI.
Invariants
- An uninitialized destination is never read before it is written, and the
uninitialized methods write no byte past the reported count. The decoder’s
linear one-shot reads back only
BorrowedBuf’s filled prefix, which it wrote itself (decoded bytes or zeros). The C ABI may zero a whole destination first, as its header documents. - Uninitialized and initialized methods produce identical streams: the fast encoder’s in-place and scratch paths are already byte-identical, and ring and linear decoder delivery write the same bytes.
- The encoder chooses between
Destinationvariants once per call or per helper invocation, never inside the SIMD copy kernels. The parallel session is monomorphized perSinktype, as the decoder is. The decoder does not choose at run time at all: it is monomorphized perSinktype, so the initialized decode loop carries no uninitialized-output code.
Verification
tests/encode_uninit.rs:process_uninitand mixed calls againstprocessfor every quality, two windows, output windows from 1 byte to 1 MiB (the latter drives the q0/q1 in-place path on the initialized side), sentinel checks pastproduced, owned sessions, andcompress_to_uninitagainstcompress_to_sliceincluding short and empty destinations.tests/decode_uninit.rs:process_uninitagainstprocessacross qualities, input chunks and output windows, a corrupt stream’s failure progress, owned sessions, anddecompress_to_uninitagainstdecompress_to_slicefor success,OutputTooSmall, trailing and truncated input; outputs many zeroing steps long,OutputTooSmallat every step boundary, concatenated members, output limits and corrupt input, each againstdecompress_to_slicewith sentinels past the result.tests/parallel_stream.rs:process_uninitand mixed calls against the batch for four qualities, windows 1-4, inline and threaded executors and output buffers down to one byte, the serial fallback and segment edges, an empty destination and a finished stream (sentinels untouched), a failed stream, and owned sessions.- AFL:
encoder_sessionanddecoder_sessiondrive borrowed and owned sessions withprocessandprocess_uninitmixed call by call, check that nothing is written pastproduced, and restart owned sessions withreinitfrom every state;streaming_equivalenceandoutput_capacitycomparecompress_to_uninitwith the initialized one-shots, anddecompresscomparesdecompress_to_uninitwith ring delivery;parallelalternatesprocessandprocess_uniniton its streamed session under the same sentinel check and batch-identity oracle. - Unit tests in
shared::uninit(includingzero_extend), resumableDelivery::Linearindecompressor::core::session, and thembrotli-fficore tests, which run on uninitialized destinations.
Known gaps
- q0/q1
process_uninitandcompress_to_uninitcopy each block once from scratch; the initialized methods write it in place when the destination has room for a whole fragment. decompress_to_uninitwrites every output byte twice, zero first: 1.2% slower thandecompress_to_sliceinto an initialized slice in geometric mean, most on data compressed beyond 100:1 and on 1 MiB of repetitive text. Safe code cannot view memory as&mut [u8]before writing it; avoiding the zeroing would need a decode loop that writes only through the cursor.- Each meta-block (each 32 KiB within one) costs
decompress_to_uninitone moreOperationState::processcall. On 1 KiB outputs this leaves it about 7% behind zeroing the whole destination; the C ABI does that below 1 KiB. process_uninitstill delivers through the ring.- No uninitialized variants for dictionary one-shots or framed sessions.
- A program that calls
process_uninitlinks a second instantiation of the decode loop: about 106 KiB more.texton x86_64.decompress_to_uninitruns the initialized instantiation and does not pay it. - In a program that links both
decompress_to_sliceanddecompress_to_uninit,OperationState::processhas two one-shot callers and is no longer inlined intodecompress_to_slice: about 90-116 more instructions per call (6 ns, measurable only on outputs of a few KiB).
C session destinations
mbrotli-ffi encoder/decoder process, flush and finish all delegate to
owned-session process_uninit. They never create an initialized slice over C
output. See C ABI sessions for count, error and
pointer contracts.
graph LR
C[C process / flush / finish] --> V[core::sessions: validate regions]
V --> U[MaybeUninit output slice]
U --> O[owned session process_uninit]
O --> P[initialized prefix and exact progress]
Compression
From the public Compressor down to the three encoder cores and the bit writer.
| Specification | Scope |
|---|---|
| Compressor | Public API, sessions, size-hint inference, I/O, dispatch and errors. |
| Encoder workspace | Retained output backing, reset, parked match-finder tables, accounting and backpressure. |
| Bit output | Destinations, command-prefix lookup, literal tables, direct Vec appends, bit operations and overflow. |
| Serial output identity | Shared scheduling, empty finalization and C compatibility. |
| Fast encoder | Qualities 0–1: fragments, direct encoding, entropy coding, prefix-code builders and SIMD scans. |
| Greedy encoder | Qualities 2–9: hash tables and their growth, SIMD range hashing, match scoring, distance-cache prefilter and meta-blocks. |
| High-quality encoder | Qualities 10–11: tree search, SIMD distance-cache and dictionary filters, command pricing and clustering. |
| Owned sessions | Lifetime-free raw and framed sessions, reinit and input-free flush/finish. |
| Parallel compression | Planning, encoder reconfiguration, caller-run tasks, staging and assembly. |
| Parallel streaming sessions | Chunked input into queued segment tasks, ordered delivery, flush, queue closure and the blocking adapters. |
Architecture overview and module map
Compressor
The public compressor module owns configuration, reusable codec state,
sessions, dictionaries and synchronous I/O. Private core modules own encoding
and state machines. See the guides for usage.
Boundaries and configuration
graph TD
API[Compressor and EncoderConfig] --> Driver[private core::driver and EncoderCache]
Session[EncoderSession] --> Core[private core::session]
Owned[EncoderSessionOwned] --> Core
IO[EncoderReader and EncoderWriter] --> Session
Core --> Stream[private core::stream: scheduling and delivery]
Driver --> Stream
Stream --> Encoders[private fast, greedy and hq encoders]
Encoders --> Dispatch[retained Backend: dispatch! at block boundaries]
Dict[PreparedDictionary] --> Encoders
EncoderConfig::new() is a const fn returning the reference defaults;
Default delegates to it, so both always agree.
Validated configuration types enforce quality 0–11, standard windows 10–24,
large declarations 10–62, block bits 16–24 and legal distance parameters.
Compressor::new and reconfigure validate combinations. Large Window requires
quality 3 or higher; dictionary operations require quality 5 or higher.
EncoderConfig::lower(size_hint) builds private CompressParams. One-shot
operations declare the complete input length. Sessions and adapters use
StreamConfig: InputSize::Exact supplies the declared length, while an unknown
size starts at hint zero and is inferred before the first encode (see
Size hint inference). The hint can change the quality 4–9 matcher selection.
Nonzero offsets require experimental and quality 2 or higher.
Ownership and reuse
Compressor owns one EncoderCache, staging and pending-output storage. The
cache resets a compatible encoder and rebuilds it when its resolved shape changes:
| Family | Compatibility key |
|---|---|
| Fast | Quality; the fragment limit and stream header follow the new window |
| Greedy | Quality, mode, distance layout and context modeling; a new window or block size reshapes the ring buffer, and a new match-finder plan or variant replaces only the finder, reusing parked tables |
| HQ | Resolved HqParams |
A pooled compressor serving inputs of different sizes typically declares a different window for each. Reshaping in place keeps the match finder and the window’s allocation, so alternating windows costs nothing over a steady stream as long as the match-finder plan stays the same. Greedy reuse first applies the previous stream’s match-finder cleanup, which reads that stream’s bytes, and only then reshapes the ring buffer.
Reset clears logical stream state; retained bytes cannot authorize references before the new stream’s start. An encoding failure discards partial encoder state. Encoder workspace specifies storage, accounting and reset.
RetentionPolicy runs after operations and session drop. Aggressive keeps
storage, CurrentConfig releases incompatible storage on reconfiguration,
Bounded releases it above its accounting ceiling, and ReleaseAll releases it
on completion. trim applies a policy immediately. Accounting includes owned
allocations and excludes caller output, shared dictionaries and allocator overhead.
stateDiagram-v2
[*] --> Idle
Idle --> Active: start
Active --> Idle: drop session; retain completed or discard partial state
Active --> Abandoned: forget session
Abandoned --> Abandoned: operations return AbandonedSession
Abandoned --> Idle: recover
Sessions borrow the compressor exclusively. into_session instead moves it into
an EncoderSessionOwned over the same OperationState, and into_compressor
runs the drop-time release path; see owned sessions.
fork_empty copies settings without workspace; concurrent independent streams
need separate compressors.
One-shot and incremental flow
compress creates a Vec; compress_into appends and returns its new range.
Failure restores the destination’s original length and prefix.
compress_to_slice reports OutputTooSmall instead of changing the encoding;
exact final length suffices, and failure may leave a partial prefix.
max_compressed_size supplies a conservative configuration-independent bound.
compress_to_uninit and the sessions’ process_uninit write the same bytes
into &mut [MaybeUninit<u8>]; see uninitialized output.
All serial paths use core::stream::StreamState. Complete input blocks are
borrowed directly when their end is known; only an undecided tail is staged.
A block is non-final only when something is known to follow it. Empty one-shot
input uses a validated allocation-free finalization path; see
serial output identity.
flowchart TD
Call[process input, output, operation] --> Drain[deliver pending bytes]
Drain --> Pending{pending remains?}
Pending -->|yes| Output[NeedsOutput]
Pending -->|no| Done{final block already emitted?}
Done -->|yes| Finished[Finished]
Done -->|no| Boundary{block end or explicit operation known?}
Boundary -->|no| Stage[stage undecided tail; NeedsInput]
Boundary -->|yes| Encode[encode borrowed block or staged tail]
Encode --> Drain
Progress reports exactly consumed input and produced output. Final output can
require multiple calls; is_finished() stays false until it has been delivered.
Completed sessions consume no further input. Failed sessions return InvalidState.
No-progress calls report NeedsInput, NeedsOutput or Finished.
Process accepts input without closing the stream. Flush emits accepted input
and byte padding, preserving the dictionary; an already aligned empty flush emits
nothing. Finish emits the last block. Frequent flushes can increase output size.
Equivalent configuration, dictionary, declared size, flush boundaries and offset
preserve bytes across APIs, chunk sizes, reuse and backends.
Size hint inference
C’s BrotliEncoderCompressStream runs UpdateSizeHint right before every
EncodeData while BROTLI_PARAM_SIZE_HINT is still zero, setting it to the
unprocessed ring-buffer bytes plus the caller’s remaining available_in
(capped at 2^30). Its first EncodeData comes when the current input block
fills or a flush or finish is requested, and its hasher is set up there. The
hint then picks H4 or H54 at quality 4, H5 or H6 at 5–9, and gates the complex
static context map at 5–9 (all at one mebibyte). Async adapters build an encoder
per body without a length and hand it the whole body in one call, so this
inference decides their output.
core::session::OperationState mirrors it. A stream started with
InputSize::Unknown holds SizeHint::Open; Exact starts Settled. Before
the scheduler runs, infer_size_hint checks whether this call is the one C
would first encode in: staged bytes plus this call’s input reach
StreamState::block_limit (the block size, or two bytes for a continuation’s
flint), or the operation is not Process. If so and anything is known, it
settles the hint at staging + input and calls Compressor::resolve_size_hint,
which re-lowers the stream’s parameters with that hint and asks the retained
encoder to retarget_size_hint. Encoders that do not read the hint (fast,
high-quality) ignore it.
The greedy encoder takes two values. The inferred hint goes into GreedyParams
and decides what reaches the bytes: the match finder plan and the context-map
gate. The expected input sizes storage only — the matcher’s quick/full variant
and layout, and the window’s capacity — and is the known total only when the
first encode is a finish of at most SIZED_STORAGE_LIMIT (4 KiB); otherwise it
stays zero (unknown), as before inference existed. After a flush or a full block
the stream goes on, and above 4 KiB a fresh encoder sized for the known total
measured slower on JSON and HTML than one sized for an unknown total (up to
+24% at qualities 2, 3 and 5 from 6 KiB, quality 6 from 10 KiB); at or below it
the compact and quick slot maps and the on-demand bucket layouts win (1 KiB
JSON at quality 6: 62 -> 22 us). A greedy encoder that has
encoded no input retargets in place when the hint keeps its shape and the
expected input its match-finder variant, and is otherwise rebuilt
(GreedyEncoder::expecting) from the same level; the rebuilt encoder adopts the
header bits an empty first flush may already have written, so the stream is not
restarted.
stateDiagram-v2
[*] --> Settled: InputSize::Exact
[*] --> Open: InputSize::Unknown
Open --> Open: Process below one block, or nothing known yet
Open --> Settled: block fills, Flush or Finish with input known
Settled --> [*]: release or reinit
sequenceDiagram
participant Op as OperationState::run
participant C as Compressor
participant E as driver::Encoder
participant S as StreamState
Op->>Op: infer_size_hint(staging + input, operation)
alt Open and C would encode now
Op->>C: resolve_size_hint(stream, min(known, 2^30), Finish ? known : 0)
C->>C: stream_params(stream, hint)
C->>E: retarget_size_hint(level, params, expected_input)
alt greedy, same shape and finder variant
E->>E: GreedyEncoder::retarget(fresh, expected_input)
else greedy, new match finder
E->>E: GreedyEncoder::expecting + adopt_stream_head
end
end
Op->>S: process(encoder, buffers, input, output, operation)
Streams whose inferred hint resolves the same as zero emit the same bytes as before. A body of at most 4 KiB that is finished in the call that first encodes it now takes the small-input storage (quick H2–H4 slots, compact or sparse bucket tables) instead of tables sized for an unbounded stream.
A full quick (H2/H3/H4/H54) table is not allocated by QuickMatcher::new. An
encoder built for a known size allocates it in its constructor
(MatchFinder::materialize); one built at begin for an unknown size allocates
it in retarget_size_hint, after the retarget or rebuild, so a rebuild for the
quick small-input variant discards no table. Either way the table is allocated
before the window: allocated at the matcher’s first preparation instead, after
the window, it reused freed heap that calloc had to clear, 5-18% slower on
8-50 KiB streams at qualities 2 and 3. Preparation still allocates a table that
is missing, for streams that reach it without either.
A failure here fails the session like any encode error. The framed compressor’s
resources share OperationState, so a resource of unknown length infers its hint
from its first chunk the same way.
I/O and finalization
EncoderWriter owns its sink and a cursor-addressed outbox bounded to 128 KiB.
Only head..end holds pending bytes. It drains before accepting new input, then
pumps the session into bounded spare space. Large writes may accept a prefix.
sequenceDiagram
participant Caller
participant Writer
participant Session
participant Sink
Caller->>Writer: write(input)
Writer->>Sink: drain pending output
alt drain fails
Writer-->>Caller: error, no input accepted
else drain succeeds
Writer->>Session: process input into bounded outbox
Session-->>Writer: consumed and produced counts
Writer->>Sink: attempt delivery
Writer-->>Caller: accepted count, delivery error deferred if necessary
end
Caller->>Writer: try_finish
loop until final output delivered
Writer->>Session: Finish
Writer->>Sink: drain
end
Writer->>Sink: flush
Short writes advance the cursor; Interrupted retries, Ok(0) becomes
WriteZero, and other errors preserve the exact unwritten suffix. A write never
both accepts caller input and returns an error. try_finish resumes after sink
errors; consuming finish returns the writer inside FinishError on failure.
Dropping an adapter performs no I/O or finalization.
EncoderReader owns its source and a cursor-addressed input buffer, writing
compressed bytes directly into the caller’s slice. The buffer is sized to its
64 KiB chunk once, and input[head..end] marks the bytes the encoder has not yet
taken; a refill reads over the previous chunk rather than clearing and
zero-filling it again, so streaming 64 KiB costs no memset after the first
read. Empty reads have no side
effects; interrupted source reads retry. EOF switches to Finish. into_parts
returns the source and unaccepted read-ahead bytes.
Dispatch and errors
Backend selection occurs when the compressor is built. Each encoder retains
that Backend; fearless_simd::dispatch! at block boundaries passes the
concrete SIMD token into inner loops. Feature detection and virtual
calls stay outside hot loops. no_std uses the compile-time backend.
ConfigError, DictionaryError, EncodeError and SizeOverflow separate
configuration, preparation, execution and size arithmetic failures. Public error
enums are non-exhaustive. Private core errors map into contextual high-level
variants; I/O conversion retains the original error as a source. SIMD types,
FFI and private implementation errors do not appear in public signatures.
Verification and known gaps
Cross-API, C differential, lifecycle, retention, backend and I/O fault tests cover these contracts; see validation and development checks.
- Only one encoder workspace is retained; alternating incompatible configurations can rebuild it on every operation.
- Encoder history is capped at 30 bits even for wider declarations.
- Serialized dictionaries, continuations and framing require
experimental. - C byte identity uses equivalent streaming settings; native C one-shot shortcuts are a different byte oracle.
Parallel compression owns separate planning, staging and assembly over private independent fragments. Native decompression is an independently selectable subsystem.
Encoder workspace
Retained encoder storage, shared stream scheduling, SIMD dispatch, and writer backpressure live behind the public compressor API.
Ownership and public boundary
Compressor owns one retained encoder, input staging and pending output. Public
configuration remains small and copyable. Backend is an opaque, host-validated
value: Default detects the host and available() enumerates distinct runnable
backends from lower to higher SIMD levels. SCALAR is crate-private under
cfg(test) and is included first in the internal unit-test matrix.
with_backend selects one through the builder; no SIMD or implementation type
is exposed.
fearless_simd/force_support_fallback is enabled only by a dev-dependency.
Production dispatch compiles the backends supported by the target; targets with
no supported SIMD retain their required portable fallback. Every encoder
kernel — fast fragments, copy extension, greedy and Zopfli searches, block
assignment and stitching — runs at the one selected backend. Scalar decisions
in tag filtering and histogram assignment use Level::is_fallback().
flowchart TD
Build{Build kind} -->|Production| Native[Target-supported SIMD levels]
Build -->|Unit tests| Scalar[Force fallback; private Backend::SCALAR]
Native -->|No supported SIMD| Portable[Required portable fallback]
Native --> Public[Public Backend::available matrix]
Scalar --> Matrix[Scalar plus host SIMD differential tests]
Public --> Select[Select once; retain Backend]
Portable --> Select
Matrix --> Select
graph TD
C[Compressor] --> Cache[EncoderCache: one resolved encoder]
C --> Staging[undecided input tail]
C --> Pending[initialized output backing, completed end and served cursor]
Cache --> Family[Fast / Greedy / HQ]
Family --> Kernel[retained Backend: dispatch! per block]
Family --> Search[retained matcher and ring buffer]
Family --> Entropy[retained splits, histograms, trees and codes]
Session[public EncoderSession] --> Owner[private SessionCore: borrows Compressor]
Owner --> Operation[private non-borrowing OperationState]
Operation --> State[private StreamState]
Framed[framed resource core] --> Operation
OneShot[private one-shot driver] --> State
Dictionary[immutable PreparedDictionary] -. borrowed .-> Family
retained_bytes sums every owned heap allocation, including boxed state and
capacity rather than length for vectors. It excludes stack fields, caller output,
shared dictionaries and allocator bookkeeping. The allocator-instrumented
compressor_memory tests compare this sum with live requested heap bytes at all
qualities. With hotpath enabled, each quality first runs through a temporary
compressor to initialize profiling storage outside the measured interval. That
compressor is dropped before measuring a fresh compressor, so cold workspace
allocations and their release remain covered. This is allocation accounting,
not a process-RSS estimate.
flowchart LR
Features{hotpath enabled?} -->|yes| Warm[Compress at this quality with temporary encoder]
Warm --> Drop[Drop temporary encoder and clear caller output]
Drop --> Baseline[Record live heap bytes]
Features -->|no| Baseline
Baseline --> Fresh[Create fresh compressor and compress]
Fresh --> Count[Compare live byte delta with retained_bytes]
Count --> Trim[Trim and verify live bytes return to baseline]
The configured retention policy applies after one-shot completion and when a
session drops, including through readers/writers. A finished session can retain
its resettable encoder; abandonment invalidates it first. Bounded first drops
the storage a greedy encoder parks for match finders of other plans (see
Parked match-finder storage); if the full
accounting still exceeds the ceiling, it releases the workspace. Session staging and
pending buffers obey the same policy. A forgotten session still requires
explicit recover; the exclusive borrow alone cannot detect mem::forget.
One scheduler, borrowed complete blocks
core::stream::StreamState owns the phase, resolved block limit and continuation
restart flag. core::session::SessionCore owns the compressor/dictionary borrows and delegates
to the non-borrowing OperationState shared with framed resource drivers. That state
checks logical positions, and translates private encoder errors into EncodeError.
One-shot vector and slice entry points call the same scheduler with Finish.
flowchart TD
Call[Process / Flush / Finish] --> Drain[drain durable pending output]
Drain --> Pending{suffix remains?}
Pending -->|yes| NeedOut[NeedsOutput, accept no new input]
Pending -->|no| Finished{final block already emitted?}
Finished -->|yes| Done[Finished]
Finished -->|no| Decide{block end or explicit operation known?}
Decide -->|no| Stage[stage undecided tail, NeedsInput]
Decide -->|yes| Source{staging empty?}
Source -->|yes| Borrow[borrow input block directly]
Source -->|no| Fill[complete staged block]
Borrow --> Encode[encode / flush / finish selected family]
Fill --> Encode
Encode --> Deliver[direct destination or completed prefix of retained backing]
Deliver --> Drain
One-shot calls need no staging or pending allocation: all input and its finality
are known, and the output is an append destination or a non-resumable slice. Fast
encoders write directly to slices with enough fragment reservation and into
append destinations through a statically specialized growing bit writer. Fast
session blocks that do not fit the caller’s slice, including uninitialized
destinations, use the fixed bit writer directly in the compressor’s pending
backing storage. Flushes and other encoder families use retained encoder scratch.
Before a one-shot append,
Compressor::compress_into reserves bound::append_reserve: the full
bound for q0/q1, whose bit writer fills the vector’s spare capacity, and
for every other quality the reference’s BrotliEncoderMaxCompressedSize
(input, four bytes per 16 KiB, six framing bytes), since those append each
finished meta-block from scratch and a longer stream just grows the vector.
Reserving twice the input there made a cold 1 MiB call’s peak footprint exceed
glibc’s adaptive trim threshold, so every call faulted its pages in again.
Bit output describes the storage and partial-byte invariants. Slice overflow returns a
private output-capacity error; sessions retain the suffix and report NeedsOutput.
stateDiagram-v2
[*] --> Open
Open --> Open: emit non-final block or stage tail
Open --> Flushed: explicit Flush
Flushed --> Flushed: redundant Flush
Flushed --> Open: accept new input
Open --> FinalPending: Finish emits final block
Flushed --> FinalPending: Finish
FinalPending --> FinalPending: drain part of output, NeedsOutput
FinalPending --> Finished: no output remains
Finished --> Finished: ignore later input
Open --> Failed: encoder failure
Flushed --> Failed: encoder failure
Failed --> Failed: session rejects further processing
FinalPending and Finished share the internal final phase; equality of the
served and completed-end cursors distinguishes them. Public is_finished() is
true only in the latter.
For experimental continuations, the logical position is checked before input
acceptance and advanced by consumed bytes. The two-byte restart uses the same
scheduler’s flush action. Finished sessions ignore even input that would overflow
the logical-position limit.
The one-shot driver shares the streaming finish path described in universal-encoding.md. All output destinations receive the same stream, or an output-capacity error. Vector appends roll back on failure. Slice contents may be partially written on failure, as documented.
Initialized backing and completed output
Compressor::pending retains its initialized length across drains and sessions.
Its pending_end marks the completed encoded prefix; served marks the bytes
already delivered. The invariant is served <= pending_end <= pending.len().
Bytes after pending_end are initialized scratch, never caller-visible output.
For q0/q1 non-flush session blocks, Delivery grows this backing to
FastEncoder::fragment_reserve only when necessary, then calls the existing
fixed-slice encoder. It publishes the completed end only after success. This
avoids both the scratch-to-pending copy and repeated initialization of retained
storage. The fast encoder still owns its partial byte and bit count separately.
Flush and higher-quality output copy their unserved suffix into the initialized
prefix, extending the vector only when that suffix exceeds its current length.
Draining resets both cursors to zero after the completed prefix is delivered;
it does not clear or truncate the backing. Starting, releasing or recovering a
session also resets only these cursors. has_pending() compares them, so stale
initialized bytes cannot delay Finished or enter the next stream. Releasing
the workspace under the retention policy drops the vector as before; accounting
continues to use capacity. Encoder failure leaves the new end unpublished and
the session failed, so partially written storage is not delivered.
flowchart TD
Empty[served = end = 0; backing retained] --> Grow[Grow initialized length only if reservation increases]
Grow --> Encode[Fixed writer encodes into backing]
Encode -->|success| Publish[Set end to completed bytes]
Encode -->|error| Failed[Fail session; publish no bytes]
Publish --> Copy[Copy backing from served to end into caller output]
Copy --> More{served below end?}
More -->|yes| Wait[NeedsOutput]
Wait --> Copy
More -->|no| Empty
Extending copies across input blocks
Greedy and HQ encoders dispatch their retained Backend and pass a borrowed
CommandExtension to the #[simd] extend_last_command; inside the token’s
feature context it runs the shared exact match-length scan. Each scan is bounded by both physical wrap points and the
remaining input; a fully matching chunk advances the command and repeats after
wrapping either index. The dictionary-prefix branch retains its existing seam
handling. No public API or error variant changes.
flowchart TD
Block[Next input block] --> Eligible{Last copy can continue?}
Eligible -->|yes| Kernel[dispatch Backend: extend_last_command]
Kernel --> Source{Distance addresses ring?}
Source -->|yes| Bound[Bound both slices at wrap and input end]
Bound --> Scan[Exact scalar or SIMD match length]
Scan --> Advance[Advance command length and block span]
Advance --> More{Whole chunk matched and input remains?}
More -->|yes| Bound
More -->|no| Symbol[Recompute command symbol]
Source -->|no| Prefix[Existing prefix seam extension]
Prefix --> Symbol
Reusable entropy and search storage
Fast arena resets clear fixed tables in place while retaining command, literal
and tree vectors. The quality 0 arena itself is created by the first fragment
that scans for matches, not by the constructor, so a stream stored verbatim
never allocates it. Before its first wrap, the ring buffer allocates only the
written prefix plus seven lookahead bytes. Its logical size and mask remain fixed.
A prefix write appends the new bytes and the zero margin (append_prefix)
instead of zero-extending the buffer and copying over the zeros, so each byte
is written once. The greedy encoder passes its size hint to the window
(expect_input, also on retarget): the first growth then reserves the whole
expected prefix, or the complete layout when the hint reaches the window, so a
one-shot call does not reallocate and copy the prefix at every doubling. The
hint only changes capacity, never the layout.
Before reaching the last two window bytes or reading into the tail, it allocates
the complete layout and replays deferred tail copies. A short first write retains
the reference’s omitted tail range and sentinel. Reset retains capacity and
restores the prefix-growth state; subsequent writes restore head and margin bytes.
The shared meta-block writer starts with empty vectors and no context-map arena.
It materializes scratch only when an entropy code needs it, then retains it. A q2
block using static command/distance codes allocates only literal depths/bits.
Full and trivial codes prepare their larger depth and bit tables; the
context-map arena is initialized on first use. The Huffman node pool is shared
by every code and is never pre-sized: each build counts the symbols it uses
and grows the pool to 2 * used + 1 nodes (huffman::nodes_for), so a cold
encoder writing a block with a few dozen literals never zero-fills the
1,409-node pool the full command alphabet would need. Nothing in the pool is
cleared between builds, because every build writes each node it reads. Block
encoders borrow the retained tables. Move-to-front uses bounded stack
scratch. Greedy splitters accept their previous split and histogram storage.
HQ retains split/cluster/literal-cost storage and the full meta-block shape.
Its cost model also retains command prices arranged by insert code and distance
policy, with 32 copy-code slots per row. These rows extend the existing symbol
price vector outside the dynamic program and are included by capacity in
retained_bytes. Blocks below 128 bytes keep only the symbol prefix active and
fill one stack row per useful match; a cold short stream adds no retained price
storage. A short block after a large one retains the vector’s capacity.
Greedy sparse matchers reserve
bounded extra pool capacity only after enough buckets have been populated; their
position and tag capacities remain part of the same accounting. See the
greedy and
HQ specifications for the growth rules.
flowchart TD
Write[RingBuffer::write bytes] --> Lap{before the first wrap and end below size - 8?}
Lap -->|yes| Reserve[reserve expected prefix, or full layout when the hint reaches the window]
Reserve --> Append[truncate to head + pos, append bytes, append 7 zero bytes, zero head]
Lap -->|no| Full[init_buffer total_size, sentinel, replay deferred tail copies]
Full --> Copy[copy into window and tail mirror, refresh head bytes]
HQ prefix candidates occupy retained workspace. They merge backwards into the
existing match arena, without split_off or a temporary merge vector. Earlier
arena entries remain unchanged; the ordering remains ascending match length,
then smaller distance, with tree matches first on exact ties. Boundary tests pin
the tie rule using distinguishable dictionary length codes.
flowchart LR
Reset[reset logical lengths and validity] --> Search[fill retained matcher / candidate arena]
Search --> Split[fill retained splits and histograms]
Split --> Codes[borrow retained entropy tables]
Codes --> Output[emit completed bytes]
Output --> Policy{retention policy}
Policy -->|keep| Reset
Policy -->|exceed ceiling| Parked[drop parked finder storage]
Parked -->|within ceiling| Reset
Parked -->|still over| Drop[drop all owned storage]
Policy -->|release| Drop
Parked match-finder storage
A greedy encoder reused for a stream whose parameters differ only in the
window, the block size, the size hint or the match-finder plan keeps
everything but the finder. When the plan changes — the chain finders below a
sixteen-bit window and the bucket finders above it at qualities five to nine,
the bucket finders’ H5 and H6 at the one-mebibyte hint, or quick storage for
short inputs — GreedyEncoder::retarget_for_new_stream builds the new finder
and parks the old one’s large tables in ParkedTables:
| Parked | From | Taken by | Why no zeroing is needed |
|---|---|---|---|
| Dense positions and tags | a bucket finder’s dense layout, or the storage it had not used yet | the next bucket finder’s first dense layout (BucketMatcher::recycle) | a dense table is read only below its bucket counters, which every dense preparation clears |
| Chain slots | a chain finder | the next chain finder (ChainMatcher::with_slots) | a chain is only entered through addr, which a new finder fills with the empty address |
A plan change therefore costs what a steady reuse costs — clearing counters
and chain heads — instead of zeroing a fresh table of up to several
mebibytes. Parked storage counts in retained_bytes; the Bounded policy
drops it before it would release the workspace, so parking never turns a
retained encoder into a released one. The quick finders park nothing: a reused
quick table needs a full wipe anyway.
sequenceDiagram
participant Driver as EncoderCache::acquire
participant Encoder as GreedyEncoder
participant Parked as ParkedTables
Driver->>Encoder: reset_for(params, hint)
Encoder->>Encoder: same plan? clean finder, reshape window
Encoder->>Parked: another plan: MatchFinder::rebuilt(plan, parked)
Parked-->>Encoder: dense storage / chain slots of that kind
Encoder->>Parked: old finder.park()
Encoder->>Encoder: reshape window, reset
Cold matcher allocation and SIMD
Quick H2/H3/H4 matchers use SmallSlots when the expected input (the declared
size, or a session’s inferred total of at most 4 KiB when its first encode is a
finish) is between
1 and 2048 bytes. It stores packed logical hash keys and positions in one open
addressed vector; a missing key reads as position zero, exactly like a fresh full
table. Keys are bounded by the quick matcher’s logical bucket range, so the
all-ones packed value is an unambiguous empty marker even for a u32::MAX
position. Linear probing and growth never change the logical slot or candidate order.
The vector starts at 32 entries on first insertion, doubles before exceeding half
occupancy, and retains capacity across clearing. A small hint may underestimate
the stream: growth preserves correctness. Other hints use the full quick table.
The matcher variant is selected before scanning, so large-input scans have no
compact-map branch inside their loops.
flowchart TD
Hint[Resolved quick-matcher size hint] --> Small{1 to 2048 bytes?}
Small -->|no| Full[Full logical slot array]
Small -->|yes| Map[SmallSlots: packed key and position]
Map --> Read{Logical key present?}
Read -->|no| Zero[Read position zero]
Read -->|yes| Position[Read stored position]
Map --> Insert[Insert or overwrite same logical key]
Insert --> Capacity{Next insertion fits half occupancy?}
Capacity -->|no| Grow[Double capacity and rehash entries]
Capacity -->|yes| Store[Store key and position]
Grow --> Store
Bucket matchers own exactly one storage layout; see the
greedy encoder for the
selection rule and migration state diagram. Sparse generation-stamped entries
activate sixteen-slot (one cache line) starter blocks on demand and promote
them into a full-block pool on the seventeenth store, leaving the starter
allocated until reset or migration.
Compact one-shot streams instead link their stores into one chain. Promotion
from compact to sparse reuses the larger word buffer; promotion from sparse to
dense flattens the larger position and tag pools and releases the unused storage.
A prepared sparse or dense table remains in use even for subsequent tiny streams. The dense layout, taken when the matcher’s size hint is at
least the shape’s dense limit (on the first stream a thirty-second of the
table for the tagged q5 shape, a sixteenth for q6 and an eighth for deep
q7–q9 shapes; a sixty-fourth for every shape once the matcher is reused), or switched to
mid-stream by a sparse stream whose store rate repays the clear (a fresh
table the live buckets are copied into), preallocates every
block at key << block_bits, zeroed once per matcher, and clears only its
u16 counters per stream. Counters, or the generation stamp, govern
validity; a block’s stale bytes are never read. Preparation reports
Sweep::SelfCleaning, so a reset neither replays a sweep nor marks the
table dirty. A reused compressor may therefore allocate once more on its
second stream — the dense table — and nothing after that; the
allocator-instrumented tests warm a compressor twice before they count.
The quick matchers’ SmallSlots map is sized for the input at preparation,
even on a fresh encoder, so it never rehashes during the stream. reset
zeroes the entries it already has and extends the same vector when the input
needs more, so a larger stream keeps growing one allocation instead of
replacing it.
flowchart LR
Reset["SmallSlots::reset(input_size)"] --> Size["size = next power of two of 2 × input, at least 32"]
Size --> Zero["fill existing entries with 0"]
Zero --> Fits{"len >= size?"}
Fits -->|yes| Keep["keep length and allocation"]
Fits -->|no| Extend["resize the same Vec to size with zeros"]
Forgetful-chain matchers materialize banks on first touch rather than allocating every bank’s slots up front. Their heads/counters likewise govern validity.
q5/q6 use parallel byte tags. One fearless_simd comparison covers the complete
16- or 32-position bucket; slots fill downwards, so rotating the mask by the
newest slot and splitting it at that slot yields the newest-to-oldest order as
two ascending scans. Unfilled slots are masked off. The scalar backend, and any
starter block, deliberately scan without filtering as an independent
oracle. q7–q9 use untagged bucket scans. Per input block the search loop works
through a MatchRun view that holds the tables as slices bound once, the way
the reference keeps restrict pointers, so a store never forces the loop to
reload the table it is about to read.
Each encoder retains the Backend it was built with; detection happened once,
when that Backend was created. At every block boundary the encoder matches
it with fearless_simd::dispatch! — the only dispatch point — enters the
selected feature-enabled body and passes the concrete token to generic inner
loops. There is no trait object, per-candidate feature detection or virtual
call. Whether an encoder serves an independent parallel worker is the
encoder’s const parameter INDEPENDENT (Encoder<true> in the fragment
adapter, the default false elsewhere), so serial builds never link the
fragment-only kernels. Cache reuse checks the backend and resolved parameter
shape before reset.
The outer kernels are #[simd] functions (fearless_simd_macros): each
enters the token’s feature context once, with its arguments passed separately
to the feature-enabled entry. After matcher specialization, greedy reference
generation calls the #[simd] search_positions for its search body, keeping
tag and match-length operations in the selected feature context. This static
entry stays outside the loop and uses the existing token. Helpers kept out of
line on purpose — the greedy accept_cached and accept_candidate
measurements, the match_len_tail scan and the four-slot matchers’
match_len_at_current — are #[simd] functions with an
#[inline(always)] wrapper: the loop calls the level’s feature-enabled entry
directly and the vector compares inline into it. An #[inline(never)] body
compiled without the level’s features would instead call a feature-enabled
helper per SIMD operation above SSE2 (measured: binary q5–q9 +14–19% time
with greedy at AVX2).
sequenceDiagram
participant Builder
participant Cache
participant Encoder as Encoder<INDEPENDENT> (retained Backend)
participant Inner as generic inner loops
Builder->>Builder: validate opaque Backend
Cache->>Encoder: construct with the Backend
loop blocks and reused streams
Cache->>Encoder: encode block
Encoder->>Encoder: dispatch!(backend) once per block
Encoder->>Inner: call #[simd] kernel, pass S
opt greedy reference generation
Inner->>Inner: specialize matcher, #[simd] search_positions with S
end
Inner-->>Cache: reference-ordered results
end
Bounded writer backpressure
The writer retains an initialized 128 KiB outbox; only head..end is live.
It does not clear/reinitialize the whole allocation for every sink write.
write drains previously owned bytes before accepting new input, pumps a bounded
session output, and returns the amount accepted. A sink error after acceptance
is deferred until the next drain, so callers do not replay input already owned.
Flush and Finish loop over bounded pulls. Finishing forbids new writes even
while final bytes await delivery. Fault-injection tests cover every output byte
position, short writes, Interrupted, WouldBlock, zero writes and retryable
flush/finalization failures.
Verification
Allocator-instrumented tests check retained requested-byte accounting and that a compressor warmed for two streams allocates nothing on the third. Lifecycle and streaming tests cover workspace reuse, retention, abandonment, recovery and writer backpressure. Private tests cover sparse promotion, prefix merge ordering and baseline/host-SIMD equivalence.
See development checks and benchmarking for commands.
Known gaps
- The cache has one slot; alternating incompatible resolved settings (another quality, mode or distance layout) rebuilds it. Windows, size hints and match-finder plans are compatible: see compressor and parked storage.
- Only the dense bucket tables and the chain slots are parked. A bucket finder on its sparse layout (deep qualities seven to nine below their promotion point) rebuilds its index after a plan switch, which still costs about 1% at quality seven and 6% at quality nine on 60–85 KiB inputs.
- Retained-byte accounting excludes allocator overhead and is not process RSS.
- Host-backend tests only exercise instruction sets available on the test host.
- Every encoder kernel, the greedy loops included, is compiled once per x86 level (SSE2, SSE4.2, AVX2, AVX-512): about 1.85x the machine code of the former SSE2-only greedy loops. Running the greedy loops at AVX2 leaves the quality 4 bucket loop with more spills on incompressible input (random 64 KiB q4 +3–4% time, +5% instructions).
Bit output and destination storage
shared::bits owns the private least-significant-bit-first bit writer.
Public vector, slice and streaming APIs retain the same output and error contracts;
no storage trait, bit position or SIMD token appears in those APIs.
Ownership and dispatch
BitWriter<'a, B> borrows initialized storage through the private ByteBuffer
trait. Its default type is [u8]. Fast one-shot vector output and early
uncompressed greedy scratch or direct appends specialize it for Vec<u8>; their helpers are
generic over that storage type. These
are static specializations, with no trait-object calls in the bit-writing loop.
classDiagram
class BitWriter {
-borrowed B storage
-usize position
-bool overflowed
+write(bits)
+write_bytes(bytes)
+rewind(position)
+update(position, bits)
}
class ByteBuffer {
<<trait>>
+bytes() initialized slice
+window(range) mutable initialized slice
+copy_bytes(start, bytes) bool
}
class FixedSlice
class GrowableVector
BitWriter --> ByteBuffer
ByteBuffer <|.. FixedSlice
ByteBuffer <|.. GrowableVector
Compressor reserves the one-shot vector bound before entering the driver. For
non-flush fast blocks, Delivery::encode selects FastEncoder::encode_block_append
for append destinations. The encoder seeds its carried bytes after the existing
prefix and checks conversion of that offset into a bit position. The encoder
dispatches its retained Backend and passes the concrete token to the
#[simd] encode_fragment, which enters the token’s feature context and runs
the same q0/q1 algorithms used by the slice path.
sequenceDiagram
participant API as Compressor
participant Driver as driver / Delivery
participant Fast as FastEncoder
participant Kernel as encode_fragment S
participant Bits as BitWriter Vec
API->>API: reserve output bound
API->>Driver: append destination and input
Driver->>Fast: encode_block_append
Fast->>Fast: seed carried bytes, prepare hash table
Fast->>Kernel: dispatch!(backend) and call
Kernel->>Bits: selected fragment and entropy algorithms
Bits->>Bits: initialize small write windows or append raw bytes
Bits-->>Fast: completed bit position and partial byte
Fast->>Fast: retain partial byte, truncate vector to completed bytes
Fast-->>Driver: number of appended bytes
Fast slice output uses the fixed writer when the destination meets its fragment
reservation. An uninitialized destination (Destination::Uninit) cannot be read
back. For non-flush fast session blocks, both that destination and a short slice
receive bytes from the compressor’s initialized pending backing, encoded with
the fixed writer. The backing retains its length; pending_end limits which
bytes are complete, and served limits which remain to be delivered. Growth
initializes only the new tail, and a drain resets cursors without clearing the
backing. See encoder workspace.
One-shot uninitialized output, flushes, compressed greedy trials and HQ blocks still use encoder scratch (see uninitialized output). Sessions copy any unserved scratch suffix into the pending backing without overwriting its initialized length. Failed one-shot appends roll back to the caller’s original length; failed session encodes publish no new completed end.
Byte and lifetime invariants
A word write accepts at most 56 bits and touches eight initialized bytes. It preserves the partial first byte and clears the higher bits and following bytes. No storage implementation reads uninitialized capacity. Both implementations use safe Rust borrows and slice operations.
The fixed buffer refuses a range outside its length. The writer sets its overflow
flag and preserves its existing position-advance behavior, allowing the encoder to
report the failure at its normal boundary. set_byte(usize::MAX, ...) also sets
that flag without overflowing index arithmetic.
The vector buffer initializes write windows in batches ending at a 256-byte
boundary, capped by existing capacity when that capacity covers the requested
range. Allocation and initialization live in an outlined cold helper, leaving
only the bounds check in each symbol write. This avoids a resize for every symbol.
Raw-byte writes copy any already
initialized portion and use Vec::extend_from_slice for the remaining bytes,
avoiding a zero-fill before copying an uncompressed block. Rewinds and header
updates operate on initialized bytes already emitted. After a fragment, padding
is truncated and the encoder retains the unfinished byte for the next fragment.
Early uncompressed greedy blocks
The greedy encoder decides whether a non-empty meta-block is uncompressed before
reserving initialized scratch. That path reserves capacity, seeds the carried
bits in two initialized bytes and restores the remembered distance cache, because
its match commands will be discarded. Generic uncompressed header/payload helpers
write through BitWriter<Vec<u8>>: small header windows are initialized on demand
and the raw payload is copied directly into spare capacity. Reused initialized
scratch is overwritten in place. This avoids clearing a full compression bound
before copying incompressible input.
For a non-Flush append destination, Delivery::encode selects the private
GreedyEncoder::encode_block_append. Known verbatim blocks seed carried bits
after the existing Vec prefix and copy ring bytes directly into that destination,
removing the intermediate scratch copy. Checked prefix/bit positions and sixteen
extra bytes cover headers (including the initial fourteen-bit large-window
header), final empty block and writer slack. Visible output is truncated to its
completed byte boundary after retaining the unfinished byte.
Compressed trials, their later incompressible fallback, and empty final blocks
retain the fixed-slice writer. BlockEmission carries a relative bit position,
partial byte and direct-append flag to the same encoder state update, which
advances stream positions and clears completed command counts. Scratch
output_len is zero when bytes were directly appended; other completed scratch
bytes are copied by the append entry. Overflow remains a typed encoder error.
Fixed/uninitialized destinations and Flush retain their prior scratch paths.
flowchart TD
Block[Completed greedy meta-block] --> Decide{Non-empty and should_compress false?}
Decide -->|yes| Destination{Non-Flush append destination?}
Destination -->|yes| Direct[Seed after Vec prefix; append payload directly]
Destination -->|no| Capacity[Reserve scratch capacity and seed carried bits]
Capacity --> Restore[Restore remembered distance cache]
Restore --> Grow[Growing writer: header windows and raw payload append]
Direct --> Metadata[Save relative bit position and partial byte; truncate slack]
Decide -->|no| Fixed[Reserve initialized bound and use fixed writer]
Grow --> State[Common carried-byte and stream-state update]
Fixed --> State
Metadata --> State
State --> Deliver[Return appended count or deliver completed scratch prefix]
Command fields
shared::command::Command already stores its combined insert/copy prefix.
extra_bits reverses that prefix through a constant table instead of classifying
both lengths again. Each 64-bit table entry packs the insert and copy bases,
insert width and total width. The 704 valid symbols are padded to 1024 entries
for masked indexing. Command::new, insert_only and command extension keep
the prefix consistent with the stored lengths, including the copy-length delta;
this invariant makes the lookup equivalent to the length-based calculation.
The table is shared read-only data, with no per-compressor allocation.
After a command-extension attempt passes its distance guard, the wrapper
rebuilds the symbol from copy_len_code(), the same encoded copy length read
by extra_bits. This decodes signed dictionary deltas and experimental
absolute-length tags before selecting a class. The attempt can absorb no bytes
when no attached prefix is available; even then, rebuilding must preserve the
symbol of the original valid command. Reading the upper modifier as an
unsigned additive delta would give a different class for these representations.
The fixed-code writer joins a command symbol with its length extras, and a
distance symbol with its distance extras, using BitWriter::write_pair.
The helper emits one word when the combined width is at most 56; otherwise it
performs the two original writes. It retains field order and the writer’s
initialized-storage/slack requirements. No command or dictionary decisions,
public APIs or SIMD dispatch points change.
flowchart TD
Extend[Extension attempt passes distance guard] --> Encoded[Decode encoded length with copy_len_code]
Encoded --> Rebuild[Rebuild symbol, including when no bytes were absorbed]
Rebuild --> Prefix[Existing command prefix]
Prefix --> Fields[Constant bases and widths table]
Lengths[Stored insert and copy-code lengths] --> Extra[Subtract bases and pack extras]
Fields --> Extra
Symbol[Huffman symbol and depth] --> Pair{Combined width at most 56?}
Extra --> Pair
Pair -->|yes| One[One ordered word write]
Pair -->|no| Two[Two original writes]
Short greedy prefix-code scratch
MetaBlockWriter::store_meta_block_fast uses fixed command and distance codes
when a meta-block contains at most 128 commands. Only the literal code is built
for these blocks. Its depths ([u8; 256]) and bits ([u16; 256]) are initialized
local arrays, borrowed by the Huffman builder and literal writer for this call.
They add no retained heap allocation. The variable Huffman-node pool remains in
MetaBlockWriter::tree and grows only for a larger used-symbol population.
Above 128 commands the fast writer builds all three prefix codes into its retained vectors. Full block-splitting and trivial-code writers retain their existing vector workspaces. Moving between these paths never assumes that a static-code block updated the retained literal vectors: each builder writes the active symbols it later emits. A reused writer therefore produces the same bytes as fresh scratch across static/dynamic/static transitions.
flowchart TD
Fast[store_meta_block_fast] --> Count{At most 128 commands?}
Count -->|yes| Local[Local literal depths and bits arrays]
Local --> Literal[Build literal code using retained Huffman nodes]
Literal --> Static[Fixed command and distance codes]
Count -->|no| Vectors[Prepare retained literal, command and distance vectors]
Vectors --> Dynamic[Build all three codes using retained Huffman nodes]
Static --> Emit[Emit commands and literals through existing BitWriter]
Dynamic --> Emit
This storage choice introduces no backend detection or dispatch boundary. The existing backend selection and output overflow/error handling are unchanged; all code tables stay private. Focused tests cover single-symbol and wider literal alphabets, the 128-command boundary, and transitions in a reused writer.
Greedy literal packing
shared::bitstream::store_data_with_huffman_codes emits the command, its extra
bits, literal run and optional distance in reference order. A contiguous literal
run is split into pairs and an optional final symbol. Two literal codes are
packed least-significant first into one writer call: the second code starts at
the first code’s width. General literal trees have depth at most 15, so a pair
needs at most 30 of the writer’s 56 supported bits. Zero-width singleton codes
also preserve the same bit position and output. This scalar bit packing has no
SIMD dispatch boundary; it reduces the sequential writer calls.
Ring-wrapped or incomplete input retains individual masked lookups, including its existing zero fallback for an absent byte. Huffman trees, command decisions and the resulting stream are unchanged. The caller provides the encoder’s reserved initialized output with writer slack; overflow remains reported at the encoder boundary.
flowchart TD
Command[Write command and extra bits] --> Run{Literal run contiguous?}
Run -->|yes| Pair[Pack two codes in original order]
Pair --> Write[Write at most 30 bits]
Write --> Pair
Write --> Tail[Write optional final symbol]
Run -->|no| Mask[Masked individual literal lookups and writes]
Tail --> Distance[Write optional distance]
Mask --> Distance
Verification and known gaps
Differential tests cover every insert/copy command class, extra-value endpoints, implicit and explicit distances, copy-length deltas and insert-only commands. Paired-field tests exercise both sides of the 56-bit limit at every alignment. A differential test compares packed and individual literal writes across every bit alignment, zero and maximum code widths, odd/even run lengths, ring wrapping and incomplete input.
Unit tests compare growing and fixed storage across bit/byte boundaries, rewinds, updates, prefixes, large raw writes and invalid byte indices. Fast-fragment tests compare both destinations across consecutive blocks and every host backend, including independent-fragment kernels. Integration tests and AFL compare full streams with C, and exercise append rollback and exact-sized slice behavior.
Direct growing output serves fast one-shot appends and known verbatim greedy append blocks. Fast I/O adapters use fixed output backing through the session scheduler. Compressed greedy trials, other greedy destinations, HQ output and all flushes continue to use encoder scratch before delivery. Focused tests cover direct greedy prefixes, carry offsets including the large-window header, remembered/derived distance history, output counts, capacity overflow before mutation, block transitions, explicit flush, empty finish and retained reuse.
Universal compression API identity
Empty one-shot inputs bypass encoder allocation after the usual large-window
validation. The private driver emits the resolved window header followed by
ISLAST and ISEMPTY, using the fast qualities’ minimum advertised window and
omitting the window for supported experimental continuations. Tests compare this
path directly with scheduler finalization. Existing cached encoder state remains
available for the next non-empty call, and the public retention policy still
applies. Non-empty streams continue through the shared scheduler.
flowchart LR
Input[One-shot input] --> Validate[Validate window support]
Validate --> Empty{Empty?}
Empty -->|yes| Header[Resolve window and terminal bits]
Header --> Deliver[Append or capacity-checked slice copy]
Empty -->|no| Encoder[Acquire encoder and run shared scheduler]
Scope
For equivalent stream settings, compress, compress_into, compress_to_slice,
EncoderSession, EncoderReader and EncoderWriter produce identical bytes.
The rule includes empty, tiny, incompressible, dictionary-backed and Large Window
inputs. Backend selection, reuse, caller chunk sizes and output capacity do not
select another encoding. Output capacity can determine success versus error.
Equivalent settings mean the same encoder configuration, dictionary, declared
size and flush/continuation behavior. The streaming counterpart of one-shot is a
zero-offset session declaring InputSize::Exact(input.len() as u64) and no extra
flushes. Unknown input size infers C’s UpdateSizeHint hint at the first
encode (see Compressor), which can choose
different matchers than a declared size; flush boundaries alter
meta-blocks; nonzero offsets produce continuations rather than standalone streams.
These are different compression jobs, not exceptions based on API shape.
Module boundaries and flow
core::driver owns routing, cache acquisition and one-shot transactionality.
core::stream::StreamState owns the shared block schedule and output delivery.
core::session::SessionCore owns session borrows, logical-position validation
and cleanup. Encoder families retain their selected SIMD kernels.
Empty one-shot input emits the configured encoder’s finish bits directly; sessions use the encoder finish path. Expanded output keeps its per-meta-block compressed/uncompressed choices; there is no whole-stream replacement based on final size. The same rules apply to all serial APIs.
graph TD
Vec[compress / compress_into] --> Driver[private driver: acquire and finish]
Slice[compress_to_slice] --> Driver
Reader[EncoderReader] --> Session[EncoderSession / private SessionCore]
Writer[EncoderWriter] --> Session
Direct[direct session calls] --> Session
Driver --> Empty[empty input: resolved header and terminal bits]
Empty --> Bytes
Driver --> State[private StreamState: one block schedule]
Session --> State
State --> Encoder[retained encoder and pinned Backend]
Encoder --> Bytes[identical completed bytes]
Bytes --> Append[append destination]
Bytes --> Fixed[fixed slice]
Bytes --> Pending[bounded incremental delivery]
One-shot input remains borrowed and needs no staging/pending allocation. Empty one-shot input also avoids encoder allocation. Empty standard streams retain the resolved window header; empty Large Window streams retain the explicit marker and declared bits. Unsupported configurations/dictionaries are still rejected before compression, including for empty input.
sequenceDiagram
participant Caller
participant Driver
participant Cache
participant State as StreamState
Caller->>Driver: input and append/slice destination
Note over Driver, State: Non-empty one-shot flow
Driver->>Cache: acquire backend and resolved parameters
Cache-->>Driver: reset or fresh encoder
Driver->>State: process(input, destination, Finish)
alt complete output fits
State-->>Driver: completed canonical bytes
Driver-->>Caller: range or written length
else slice is too small
State-->>Driver: OutputTooSmall
Driver->>Cache: invalidate partial encoder
Driver-->>Caller: public EncodeError with provided capacity
end
The vector path retains append rollback on error. A failed slice can contain a partial output prefix; its encoder is conservatively invalidated. A retry must restart the operation with sufficient capacity, and reproduces the same canonical stream. Exactly the vector output’s length suffices, even when it is smaller than the fast encoder’s preferred scratch reservation. Public conservative size bounds continue to apply without relying on a whole-stream rewrite.
Reference oracles
C remains an independent encoder and decoder oracle, but its API is part of the comparison contract:
| Oracle | Purpose |
|---|---|
| C streaming FINISH, same settings | Canonical byte differential for Rust one-shot and exact-size sessions |
| C streaming with matching flush boundaries | Flush semantics and dictionary continuation checks |
| Native C one-shot | Explicit regression demonstrating intentional API-specific differences |
| C decoder | Independent validity and content round trips |
C quality 0/1 PROCESS calls emit fragments at caller chunk boundaries; Rust holds undecided tails to make chunking irrelevant. The streaming Criterion adapter normalizes C’s chunk schedule and charges its staging copy/allocation to C. The one-shot Criterion adapter borrows the whole input and uses streaming FINISH. Test and benchmark C buffers use conservative streaming capacity, not the native one-shot bound whose validity depends on rewriting expanded output.
The public guarantee does not assert identical bytes to every possible native C call schedule. Nor does it claim a new independent decoder oracle for original window declarations above the pinned C decoder’s limit.
Verification
tests/streaming.rs compares all six serial API shapes across qualities,
empty/tiny/incompressible inputs, small windows, and input/output chunk sizes.
It checks exact-size and one-byte-short slices and append-prefix preservation.
Dictionary and Large Window tests check empty-input identity and header retention.
Native C one-shot differences have explicit regression tests.
AFL’s streaming target compares vector, append, exact slice, session, reader and
writer output. The C differential and Large Window targets include empty input.
universal/q*/ in the Criterion harness measures cold empty and incompressible
streams with matching C streaming settings. See benchmarking.
Known gaps
- Original window declarations above 30 bits lack an independent end-to-end decoder oracle in this repository.
- Experimental custom search has a narrower C byte oracle than ordinary encoding.
- Independent parallel segments have a separate output policy; serial API byte identity does not extend to parallel compression.
Fast Encoder Core (quality 0 and quality 1)
Scope: src/compressor/core/fast/, and the parts of
src/shared/ it uses. This is the implementation of the two fast
Brotli qualities: a one-pass encoder (quality 0) and a two-pass encoder
(quality 1), both ported from Google Brotli v1.2.0, commit 028fb5a, and both
byte-identical to it.
Everything here is private. No type, error or SIMD detail from this tree appears in the public API; compressor.md describes the surface that wraps it.
1. Module map
graph TD
modmod["fast/mod.rs<br/>FastEncoder, dispatch"]
q0["fast/q0.rs<br/>one-pass scan and meta-blocks"]
q1["fast/q1.rs<br/>two-pass scan, replay, TwoPassState"]
cmd["fast/commands.rs<br/>insert/copy/distance mapping"]
hist["fast/histogram.rs<br/>chunked byte counting"]
tabs["fast/tables.rs<br/>quality 0 and 1 command tables"]
consts["fast/constants.rs<br/>quality 0 and 1 constants"]
ws["fast/workspace.rs<br/>OnePassArena, TwoPassArena"]
subgraph shared["shared"]
huff["huffman.rs<br/>tree build, canonical codes, serialisation"]
ml["match_len.rs<br/>hybrid scalar/SIMD match length"]
bits["bits.rs<br/>LSB-first bit writer"]
log["fast_log.rs<br/>table + native or libm logarithms"]
stabs["tables.rs<br/>entropy-coding tables"]
sconsts["constants.rs<br/>format constants"]
end
modmod --> q0
modmod --> q1
modmod --> ws
q0 --> cmd
q0 --> huff
q0 --> hist
q0 --> ml
q0 --> bits
q1 --> cmd
q1 --> huff
q1 --> hist
q1 --> ml
q1 --> bits
cmd --> bits
cmd --> tabs
cmd --> log
huff --> bits
huff --> stabs
q0 --> consts
q1 --> consts
consts --> sconsts
log --> stabs
stabs --> sconsts
The bit writer, the Huffman builders, the match-length scan, the reference
logarithms and the entropy-coding tables live in shared
rather than in this tree, because the greedy encoder needs exactly the same
implementations; see greedy-encoder.md.
2. Ownership and reuse
FastEncoder retains its hash table, entropy workspace and scratch across
fragments. One-shot append output uses the caller’s reserved vector through a
statically specialized bit writer. Large initialized slices use the fixed writer
directly. Sessions with smaller or uninitialized destinations encode non-flush
blocks into the compressor’s initialized pending backing through that same fixed
writer; a separate completed-end cursor bounds delivery. Drains and session
resets retain its initialized length, so later fragments neither copy through
encoder scratch nor reinitialize the whole buffer. Flushes still use the encoder’s
scratch. Bit output specifies initialization, reservation,
overflow and partial-byte handling.
flowchart LR
Session[Fast non-flush session block] --> Backing[Retained initialized pending backing]
Backing --> Fixed[encode_block_into with selected backend]
Fixed --> Completed[Publish completed byte count]
Completed --> Caller[Copy only completed bytes to caller]
Caller --> Retain[Reset delivery cursors; retain initialized backing]
Retain --> Backing
The quality 0 arena is not built by the constructor. FastCore::OnePass
holds an Option<Box<OnePassArena>> that the first fragment needing a match
scan fills in; a stream whose only fragment is stored verbatim (§5) never
allocates it, never clears a hash table, and never touches the Huffman node
pool. Whether the encoder serves an independent worker is its const parameter
INDEPENDENT, because that decides whether the verbatim shortcut applies; a
serial build never instantiates the fragment-only kernels.
classDiagram
class FastEncoder {
-Backend backend
-FastCore core
-usize block_size_limit
-u16 last_bytes
-u32 last_bytes_bits
-Vec~i32~ table
-Vec~u8~ storage
-bool finished
+encode_block(input, is_last) BrotliResult~&[u8]~
}
class FastCore {
<<enum>>
OnePass: Option~Box~OnePassArena~~
TwoPass: Box~TwoPassState~
}
class OnePassArena {
+lit_depth [u8;256]
+lit_bits [u16;256]
+cmd_depth [u8;128]
+cmd_bits [u16;128]
+cmd_histo [u32;128]
+cmd_code [u8;512]
+cmd_code_numbits usize
+tree Vec~HuffmanNode~
+histogram [u32;256]
+tmp_depth [u8;704]
+tmp_bits [u16;64]
}
class TwoPassState {
+arena Box~TwoPassArena~
+commands Vec~u32~
+literals Vec~u8~
}
class TwoPassArena {
+lit_histo [u32;256]
+lit_depth [u8;256]
+lit_bits [u16;256]
+cmd_histo [u32;128]
+cmd_depth [u8;128]
+cmd_bits [u16;128]
+tmp_tree Vec~HuffmanNode~
+tmp_depth [u8;704]
+tmp_bits [u16;64]
}
FastEncoder *-- FastCore
FastCore o-- OnePassArena : Q0, built on first scan
FastCore *-- TwoPassState : Q1
TwoPassState *-- TwoPassArena
The hash table and the scratch output buffer grow but never shrink. Only the
active table range is cleared between fragments; unused capacity is left
untouched. Both grow through shared::fixed::grow_zeroed: the first
allocation comes from the allocator’s zeroing path, and a later, larger
fragment extends the same vector with resize, zero-filling only the new tail. The Huffman node pools (tree, tmp_tree) start empty and are
sized by each build to the symbols it actually uses, see
encoder-workspace.md.
3. Fragment lifecycle
Every call to encode_block handles one fragment of at most 1 << lgwin
bytes, mirroring BrotliEncoderCompressStreamFast.
stateDiagram-v2
[*] --> Sized: reserve 2 * len + 503 + 8 bytes
Sized --> Seeded: storage[0..2] = last_bytes
Seeded --> Verbatim: FastCore::stores_verbatim (final q0 fragment within the bound)
Verbatim --> Encoded: raw meta-block and closing bits, no table, no arena
Seeded --> Tabled: clear the active hash table range
Tabled --> Dispatched: retained kernel, #[simd] encode_fragment
Dispatched --> Encoded: q0 or q1 writes meta-blocks
Encoded --> Checked: writer overflow?
Checked --> Failed: yes
Checked --> Carried: no
Carried --> [*]: emit position >> 3 bytes,\ncarry the partial byte
Failed --> [*]: BufferOverflow
The trailing partial byte is never emitted: it is kept in last_bytes and
re-seeded into the next fragment’s scratch or append destination, exactly as the reference
carries s->last_bytes_.
4. Quality 0: one pass
stateDiagram-v2
[*] --> OpenBlock
OpenBlock --> Scan: header, 13 zero bits,\nliteral code, command code
state Scan {
[*] --> Probe
Probe --> Probe: no match, skip += 1 >> 5
Probe --> Emit: five byte match
Emit --> Chain: literals, distance, copy
Chain --> Chain: immediate match, no literals
Chain --> Probe: no immediate match
Emit --> Uncompressed: insert >= 6210 and\nShouldUseUncompressedMode
}
Scan --> Remainder: ip reaches ip_limit
Remainder --> Merge: ShouldMergeBlock and\ntotal + next <= 1 MiB
Merge --> Scan: patch MLEN, keep the meta-block open
Remainder --> OpenBlock: input left, new meta-block
Remainder --> NextCode: input exhausted
Uncompressed --> OpenBlock
NextCode --> [*]: build the next fragment's command code\nwhen is_last is false
The scan follows this order:
- the repeat candidate (
ip - last_distance) is tested before the hash table candidate; the reference subtracts without a sign test and rejects a candidate at or pastipafter comparing, and as both tests are pure the port testsrepeated < ipfirst, which reads nothing for the initial distance of minus one; - the word at the next position is loaded once: it is hashed for the table
lookup and kept as the word the candidates are compared against when the
scan reaches that position (
words_matchon two loaded words), so the table store between the two candidate tests never forces a reload; - the hash table is written at the same points and in the same order;
- post-copy hash updates cover
ip - 3,ip - 2,ip - 1andip, taken from a single word load; ShouldMergeBlockuses stride 43 and the current literal depths;- the final size guard rewrites the whole fragment verbatim when the compressed
form exceeds
31 + 8 * lenbits.
For a serial final fragment, q0::stores_verbatim checks
len <= cmd_code_numbits / 8 (an empty final fragment always qualifies: it is
just the closing block). The compressed form needs at least 19 header bits,
13 block/context bits, and the retained command code, already exceeding the
rewrite threshold at that length. q0::store_final_verbatim therefore emits
the same uncompressed block and the closing bits immediately, without building
literal codes or scanning matches. The initial command code has 448 bits,
giving a 56-byte bound; a trained command code supplies its own bound.
Non-final fragments still train the successor’s code, and independent parallel
fragments retain their separate command tree.
The decision is taken twice, from the same predicate: FastCore::stores_verbatim
answers it before prepare_table, so no hash table is cleared, and again in
encode_fragment before the arena is created, so a fresh encoder whose only
fragment qualifies allocates nothing beyond the output. Without an arena the
predicate uses the initial 448-bit bound, which is exactly what a fresh arena
would carry. compress_fragment itself still applies the predicate, so the
per-fragment kernels behave identically when they are called directly.
flowchart TD
Input[Quality 0 fragment] --> Empty{Empty?}
Empty -->|yes| Final[Write final empty marker and align]
Empty -->|no| Bound{Serial, final, and length within command-code bound?}
Bound -->|yes| Raw[Emit uncompressed block]
Bound -->|no| Encode[Build codes and scan matches]
Encode --> Guard{Compressed bits exceed size threshold?}
Guard -->|yes| Raw
Guard -->|no| Finish[Finalize if last]
Raw --> Finish
5. Quality 1: two passes
sequenceDiagram
participant Blk as block loop
participant P1 as CreateCommands
participant SC as ShouldCompress
participant P2 as StoreCommands
participant W as BitWriter
loop each 128 KiB block
Blk->>P1: scan, fill command and literal buffers
P1-->>Blk: num_commands, num_literals
Blk->>SC: literals < 98% of block, else sampled entropy
alt compressible
Blk->>W: meta-block header, 13 zero bits
Blk->>P2: exact histograms, prefix codes, replay
P2->>W: command bits, extra bits, literals
else incompressible
Blk->>W: uncompressed meta-block
end
end
The first pass stores each command as a packed u32: the low eight bits are
the fast command code (0..128) and the high bits carry the extra value. The
second pass builds exact literal and command histograms from those buffers,
seeds command codes 1, 2, 64 and 84, and replays the buffer.
5.1. Post-copy hash updates
The pinned reference contains an asymmetry that changes the command stream:
with min_match == 4, the first post-match update path hashes offsets
0, 1, 0 where the chained path hashes 0, 1, 2.
flowchart TD
A["copy emitted"] --> B{min_match}
B -->|4| C{first update after literals?}
C -->|yes| D["offsets 0, 1, 0 from the word at ip - 3"]
C -->|no| E["offsets 0, 1, 2 from the word at ip - 3"]
B -->|6| F["offsets 0, 1, 2 from ip - 5,<br/>then 0, 1 from ip - 2"]
D --> G["candidate = table[hash at offset 3]"]
E --> G
F --> H["candidate = table[hash at offset 2 of ip - 2]"]
update_hashes_after_copy takes a FIRST_UPDATE const parameter that
reproduces it. A targeted unit test pins the difference so the quirk cannot be
“fixed” by accident.
6. Bitstream layer
graph LR
pos["position: bit index"] --> w["BitWriter"]
w -->|write| store["storage[pos >> 3 .. +8] as one word"]
w -->|update| patch["patch an already emitted MLEN field"]
w -->|rewind| back["drop everything after a saved position"]
w -->|align| byte["advance to the next byte boundary"]
w -->|write_bytes| raw["copy an uncompressed meta-block verbatim"]
write materialises a whole 64-bit word, which is what clears the bits above
the new position — the same trick the reference uses — so the buffer keeps
eight bytes of headroom past the largest position the encoder reaches. Writes
past the end of the buffer set an overflow flag instead of panicking, and the
encoder turns that into BufferOverflow.
Meta-block layout for a compressed fast-path block:
| Field | Width | Source |
|---|---|---|
ISLAST | 1 | always 0 for a data block |
MNIBBLES - 4 | 2 | 4, 5 or 6 nibbles by length |
MLEN - 1 | 16, 20 or 24 | block length |
ISUNCOMPRESSED | 1 | 0 for a compressed block |
| block splits / contexts | 13 | always zero on the fast path |
| literal prefix code | variable | build_and_store_huffman_tree_fast |
| command prefix code | variable | full 704 symbol alphabet |
| distance prefix code | variable | 64 symbols |
| commands, extras, literals | variable | the scan |
6.1. Prefix-code construction
shared::huffman builds the prefix codes of every encoder, not only these
two qualities. Both builders list the used symbols by descending index, order
them least popular first, merge them (merge_nodes) and walk the tree for
depths (set_depth); a code deeper than its limit raises the minimum count
(count_limit, doubled per retry) and starts again. They differ in how
equal counts are ordered, and so in how they sort:
flowchart TD
H["histogram, count_limit"] --> W{"builder"}
W -->|"create_huffman_tree (q0/q1 commands, q2-q11, code-length codes)"| C["count used symbols per bucket: exact below 64, one per power of two above"]
C --> S{"fewer than 13 leaves?"}
S -->|yes| I["list by descending symbol, insertion sort"]
S -->|no| P["bucket starts from the counts; place leaves by descending symbol"]
P --> B["sort_items::<true> inside each power-of-two bucket with 2+ leaves"]
W -->|"build_and_store_huffman_tree_fast (q0/q1 literals, q2 fast codes)"| F["list by descending symbol, sort_items::<false>"]
I --> M["merge_nodes, set_depth"]
B --> M
F --> M
M -->|"too deep: count_limit *= 2"| H
create_huffman_treebreaks ties by descending symbol, so its order is total and any sort that reaches it gives the reference’s bytes.sort_leavesreaches it with a bucket sort: one pass counts the leaves of each bucket, prefix sums turn the counts into starts, and a second pass places leaves by descending symbol, which leaves a single-count bucket in order. Only the power-of-two buckets, from count 64 up, are sorted afterwards, by the reference’s shell sort. One array holds both start and cursor: after placement, bucketbspansrank[b - 1]..rank[b]. Fewer than 13 leaves go straight to the reference’s insertion sort, where the bucket pass would cost more than it saves.build_and_store_huffman_tree_fastcompares counts only, as the reference’s literal builder does. Shell sort is not stable, so its order among equal counts is whateverSortHuffmanTreeItemsleaves, and that order decides which of two equal symbols gets the shorter code. That path keeps the reference’s shell sort unchanged.
The scans around the tree go through shared::scan: they compare sixteen
entries at once at the target’s baseline SIMD level (shared::baseline:
SSE2, NEON or scalar lanes, chosen at compile time, since this code runs
outside the encoders’ dispatched entries) and walk the bit mask. The
bucket sort’s two passes visit only used symbols (for_each_nonzero_u32,
ascending and descending); convert_bit_depths_to_symbols collects the
used symbols once and assigns codes from that list; write_huffman_tree
finds run ends and the trailing zeros with run_end_u8 and
trim_zeros_u8; optimize_huffman_counts_for_rle takes its used-symbol
count, last used symbol and smallest count from one walk and its run
lengths from run_end_u32. Each reports exactly what the plain loop would,
in the same order, so the code lengths and bytes do not change; the tests
run every scan on the scalar, baseline and detected levels against the
plain loop.
flowchart LR
T["u32 counts / u8 depths"] --> C["16 lanes: simd_eq(0 or value)"]
C --> M["to_bitmask"]
M -->|"all lanes set (dense)"| D["straight loop over the chunk"]
M -->|sparse| W["tzcnt / lzcnt walk over set bits"]
M -->|"run end"| E["first differing lane"]
6.2. Two-queue Huffman merge
shared::huffman::merge_nodes uses the caller’s node pool for both sorted
leaves and newly built parents. The leaf queue starts at index 0, ends at
n, and has a sentinel at tree[n]. The parent queue starts at n + 1 with
another sentinel. Each iteration selects two children, writes their parent to
the next parent slot, then writes the following sentinel. It returns root
index 2 * n - 1; the pool needs 2 * n + 1 entries. The builders handle a
single used symbol before calling this merge.
For each child selection, the merge loads the two queue-front counts and
compares them with <=: equal counts select the leaf queue. It carries the
selected index and its already loaded count together, then advances that
queue. The second child is selected from the updated fronts. Writing the
parent uses those carried counts, without loading the selected children from
the pool again. The weight is left_count.wrapping_add(right_count), matching
the reference’s unsigned 32-bit addition even when a histogram overflows.
Child indices, sentinel positions, tie order and all later depth/code choices
remain unchanged.
flowchart TD
Start["Sorted leaves + leaf/parent sentinels"] --> Left["Load both front counts; select left index + count"]
Left --> TieL{"leaf count <= parent count?"}
TieL -->|yes| LeafL["Advance leaf queue"]
TieL -->|no| ParentL["Advance parent queue"]
LeafL --> Right["Load updated fronts; select right index + count"]
ParentL --> Right
Right --> TieR{"leaf count <= parent count?"}
TieR -->|yes| LeafR["Advance leaf queue"]
TieR -->|no| ParentR["Advance parent queue"]
LeafR --> Store["Store parent: wrapping sum + carried child indices"]
ParentR --> Store
Store --> Sentinel["Write next parent sentinel"]
Sentinel --> More{"More parents needed?"}
More -->|yes| Left
More -->|no| Root["Return root index 2*n - 1"]
The helper stays private, scalar and allocation-free; it adds no API, error or SIMD dispatch boundary. Its callers retain and grow the node workspace, and their depth-limit retry loop is unchanged. Differential tests compare the complete node pool with the original merge for tied, mixed and overflowing counts, including an unused suffix that must remain untouched.
7. SIMD dispatch points
graph TD
select["Backend detected once, retained on construction"] --> A["FastEncoder<INDEPENDENT>::encode_block / encode_block_append"]
A --> kernel["dispatch!(backend) once per block"]
kernel -->|"#[simd] entry, no re-selection"| B["encode_fragment<S: Simd, INDEPENDENT>"]
B --> C["q0::compress_fragment<S>"]
B --> D["q1::compress_fragment<S>"]
C --> E0["match_len_at: first word settled scalar"]
D --> E0
E0 -->|"first word equal"| E["match_len_windows<S> over the rest"]
E --> F{"S::u8s::LEN"}
F -->|16| G["u8x16 loop, stride 16"]
F -->|32| H["u8x32 loop, stride 32"]
F -->|64| I["u8x64 loop, stride 64"]
F -->|other| J["byte scan, stride 1"]
classDef scalar fill:#fce5cd,stroke:#b45f06;
class C,D,J scalar;
encode_fragment, the specialized q0 compress_fragment_impl and the q1
create_commands are #[simd] functions: each body becomes an always-inlined
closure called through a feature-enabled entry, with the arguments passed
separately. This keeps the native comparison loop inside a feature-enabled
function even when LLVM keeps the large scan separate from the outer
dispatcher. It introduces no feature detection
inside the scan and retains independent table-width specializations.
Only the exact match-length scan is vectorised. Hash lookups, candidate
selection, skip logic, command encoding, the bit writer and the Huffman builder
all stay scalar, in reference order. The lane count is resolved into a const
parameter at monomorphisation time so the vector loop can split its windows
with as_chunks and load them with load_array_ref, which leaves neither a
bounds check nor a length assertion in the loop.
find_match_length is a four-stage pipeline: a 16-byte scalar word prefix,
then native vectors, then whole words, then single bytes. The staging only
changes how a length is discovered, never which length it is, so every backend
emits identical bytes.
match_len_at, the entry point the q0, q1 and greedy scans call, settles the
first word before any window is cut: it bounds the candidate window once,
loads eight bytes from each side, and answers a difference inside them with a
trailing-zero count — the reference’s whole scan for the short matches that
dominate. Only a match running through the whole word pays for the slicing of
the staged pipeline, which then continues from byte eight. q0 calls
match_len_at_outlined, which keeps that first-word compare inline and calls
the vector tail out of line, so q0’s fragment loop does not carry the long-scan
code and its registers; q1 and the greedy scans keep the fully inlined form.
7.1. The stride invariant
Each stage decides “did every step match?” by comparing what the scan reported
against the window rounded down to a whole number of steps, and returns early
when the scan stopped short. That test is only exact when the size it rounds by
is the step the scan really takes, so native_vector_stride reports the stride
rather than the lane count: every width without a vector loop degrades to the
byte scan, whose stride is one. Rounding by a lane count the scan does not use
would round the window up past what the scan can report and truncate a match
that ran to the limit. No backend fearless_simd ships reaches that arm —
NEON, SSE2 and the fallback all have u8s::LEN == 16 — so it is an invariant kept
honest rather than a live path.
7.2. Vector entry
The scalar prefix checks the first 16 bytes. Longer matching runs reach the vector stage, followed by a scalar tail at the first mismatch or input limit.
8. Table-bit specialisation
flowchart LR
A["fragment length"] --> B["HashTableSize:<br/>256, doubling"]
B --> C{quality}
C -->|0| D["force an odd shift:<br/>9, 11, 13, 15"]
C -->|1| E["8 .. 17"]
D --> F["q0::TableBits"]
E --> G["q1::TableBits"]
F --> H["compress_fragment_impl<S, TABLE_BITS, ENTRIES>(table: &mut [i32; ENTRIES])"]
G --> I["compress_fragment_impl<S, TABLE_BITS, ENTRIES, MIN_MATCH>(table: &mut [i32; ENTRIES])"]
The table width and, for quality 1, the minimum match length are const
parameters, so the shift and the match predicate are compile-time constants
inside the hot loop. The table itself is handed to the specialised body as an
array reference &mut [i32; 1 << TABLE_BITS] (first_chunk_mut on the
prepared slice), so every hash index — a 64 - TABLE_BITS shift — is provably
inside it and no lookup carries a bounds check. The array type preserves this
bound inside the backend-specific entry #[simd] generates.
Inside q1’s create_commands the pass-one command and literal vectors are
moved into locals for the duration of the scan and moved back at its end:
behind the caller’s references their headers live in memory the compiler
cannot tell apart from the table, so every push would reload the length and
capacity after each table store.
9. Data-processing loops
Bulk loops are written as chunked iterators rather than indexed loops, which removes their bounds checks without any unsafe code:
| Loop | Shape |
|---|---|
match_len_words | as_chunks::<8> over both windows, zipped |
match_len_vectors_* | as_chunks::<LANES> plus load_array_ref |
match_len_bytes | zip + take_while over the tails |
histogram::accumulate | as_chunks::<4> into four independent counters, scalar tail |
emit_literals (q0) | as_chunks::<4> above an eight literal run, then as_chunks::<2>, then singles |
pack_literals (q1) | greedy accumulation up to the writer’s 56 bit limit |
9.1. Literal packing width
The tree builder caps a literal depth at fourteen bits, so four codes are at
most 56 bits and always fit one BitWriter::write; two fit with room to spare.
Quality 0 emits literals between matches. Runs of at least eight literals use
quadruples, followed by pairs and singles; shorter runs start with pairs.
Quality 1 replays a meta-block’s literals in quadruples too (pack_literals),
then writes the up to three left over in one more call. It used to accumulate
codes until the next would overflow 56 bits; that per-literal test cost more
than the calls it saved, about 10% of quality 1 on binary input, where most
bytes are literals.
flowchart LR
R["literal run"] --> Q{"4 or more left?"}
Q -->|yes| F["prefix-sum 4 widths, OR 4 codes, one write (<= 56 bits)"]
F --> Q
Q -->|no| T["OR the 0-3 left, one write (<= 42 bits)"]
Flushing
FastEncoder::flush_block is the fast path’s BROTLI_OPERATION_FLUSH. These
qualities already close a meta-block on every call, so the flush is only the
empty metadata block that pushes the stream back onto a byte boundary — and,
when there is buffered input, a short non-final fragment ahead of it. An empty
input skips the fragment entirely, exactly as the reference does when a flush
arrives with nothing buffered, and nothing at all is emitted when the stream
was already aligned. See compressor flushing.
Quality 1 emits a code description per meta-block. Frequent flushes create smaller blocks and can substantially increase compressed size.
Known gaps
- No attached prefix. These qualities carry no compound-dictionary search
in the reference either, so a
PreparedDictionaryis refused rather than ignored. - No static dictionary matching, no block splitting and no context modelling: out of scope for both fast qualities, as in the reference.
- Every word load in the scan is bounds-checked. The reference reads
eight bytes at the hash position, the repeat candidate and the table
candidate unchecked; the port proves each load with a compare, and the
compiler cannot fold them against the loop’s
ip_limit, which holdsip + 16inside the input by construction. Carrying the loaded word between iterations removes one of the four; the scan on binary input still executes about a third more instructions per position than the C build and measures 85% of it, against 96–99% on incompressible input. ShouldMergeBlockandShouldCompressuse floating point. They reproduce the reference’sdoublearithmetic, including its single-precisionlog2table, because the decisions are observable in the output.- The command prefix code carries stale depths for symbols with a zero
count.
create_huffman_treewrites depths only for symbols that appear, exactly as the reference does; the seed histogram guarantees every symbol that can be emitted has a non-zero count.
Independent parallel fragments
begin_fragment removes the standalone header and fragment_aligned checks
the flush boundary. The encoder’s const parameter INDEPENDENT (FastEncoder<true>) specializes
q0/q1 command generation: independent fragments emit full distances, including
the remainder after an insert command’s two-byte copy. q0 seeds the otherwise
unused short-copy symbols; q1 preserves the explicit copy-two symbol during
Huffman alphabet expansion. Serial specializations retain their original trees
and cache coding. See parallel compression for prefix
ownership, aligned assembly, and the minimized regressions.
Greedy Encoder Core (qualities 2 to 9)
Scope: src/compressor/core/greedy/. This document describes the code as it
stands; the Known gaps section lists what is not implemented.
The reference this port follows is google/brotli v1.2.0, commit 028fb5a.
On the platforms this crate targets that build defines BROTLI_MAX_SIMD_QUALITY
and therefore selects the tagged H58 and H68 match finders at qualities
five and six. This port keeps H5/H6 logical match order, with tag filtering
on SIMD backends and an unfiltered scalar oracle. These paths are byte-for-byte
equivalent — see §2.2 — and differential tests check
that equivalence against the real library.
1. Core mechanics
Where the fast encoder compresses one independent fragment at a time, the greedy encoder is a proper streaming state machine:
- Input is copied into a ring buffer that keeps the whole sliding window, so a match may reach back past the current block.
- A match finder, chosen once from the caller’s parameters, turns the new bytes into commands: an insert length, a copy length and a distance.
- Commands accumulate until a meta-block is worth emitting.
- The meta-block is split into blocks, each block type gets its own prefix codes, and the whole thing is written to the bit stream — or stored uncompressed when that turns out smaller.
graph TD
subgraph public["compressor::core::driver"]
drv["Encoder::Greedy"]
end
subgraph greedy["compressor::core::greedy"]
enc["encoder<br/>(GreedyEncoder, dispatch)"]
params["params<br/>(GreedyParams, HasherPlan)"]
hash["hashers<br/>(H2, H3, H4, H54, H40/41/42, H5, H6)"]
refs["backward_references<br/>(greedy search)"]
score["score<br/>(reference scoring)"]
ctx["context_model<br/>(literal contexts)"]
mb["metablock<br/>(greedy builder)"]
split["split<br/>(greedy block splitters)"]
end
subgraph shared["shared"]
rb["ringbuffer<br/>(RingBuffer)"]
dict["dictionary<br/>(static words + hash)"]
cmd["command<br/>(Command, prefix codes)"]
histo["histogram<br/>(counts, entropy, RLE)"]
mbs["metablock<br/>(MetaBlockSplit)"]
bsplit["block_split<br/>(BlockSplit)"]
bs["bitstream<br/>(MetaBlockWriter)"]
tables["format<br/>(context LUTs, prefix ranges)"]
bits["bits (BitWriter)"]
huff["huffman"]
ml["match_len"]
log["fast_log"]
end
drv --> enc
enc --> params
enc --> rb
enc --> refs
enc --> ctx
enc --> mb
enc --> bs
refs --> hash
refs --> cmd
refs --> score
hash --> dict
hash --> ml
hash --> score
mb --> split
mb --> histo
mb --> mbs
split --> histo
split --> bsplit
mbs --> bsplit
bs --> mbs
bs --> huff
bs --> bits
bs --> tables
ctx --> tables
ctx --> log
histo --> log
cmd --> tables
classDef greedyNode fill:#f6e8c3,stroke:#8a6d3b;
class enc,params,hash,refs,score,ctx,mb,split greedyNode;
classDef sharedNode fill:#e8f0f6,stroke:#3a6d8a;
class rb,dict,cmd,histo,mbs,bsplit,bs,tables,bits,huff,ml,log sharedNode;
The shaded modules on the right are shared with the high-quality encoder; see hq-encoder.md.
1.1. What each quality adds
| Feature | q2 | q3 | q4 | q5 | q6 | q7 | q8 | q9 |
|---|---|---|---|---|---|---|---|---|
Default lgblock | 14 | 14 | 16 | 16 | 16 | 16 | 16 | min(18, lgwin) |
| Block splitting | no | no | yes | yes | yes | yes | yes | yes |
| Non-zero distance parameters | no | no | yes | yes | yes | yes | yes | yes |
| Histogram optimisation | no | no | yes | yes | yes | yes | yes | yes |
| Extensive delayed search | no | no | no | yes | yes | yes | yes | yes |
| Literal context modelling | no | no | no | yes | yes | yes | yes | yes |
| Three-context model eligible | no | no | no | no | no | yes | yes | yes |
| Large window allowed | no | yes | yes | yes | yes | yes | yes | yes |
| Attached prefix consulted | no | no | no | yes | yes | yes | yes | yes |
| Sparse-search threshold | 64 | 64 | 64 | 64 | 64 | 64 | 64 | 512 |
| Bucket candidates | — | — | — | 16 | 32 | 64 | 128 | 256 |
| Cached distances probed | 4 | 4 | 4 | 4 | 4 | 10 | 10 | 16 |
| Small-window matcher | — | — | — | H40 | H40 | H41 | H41 | H42 |
| Chain hops | — | — | — | 16 | 32 | 56 | 112 | 224 |
| Meta-block storage | fast | trivial | greedy split | greedy split | greedy split | greedy split | greedy split | greedy split |
Qualities two and three use simpler meta-block storage and flush on a symbol
count. Quality two selects H2 and store_meta_block_fast; quality three selects
H3 and store_meta_block_trivial. Their intermediate command generation is
shared. From quality five through nine the search shape is shared, with depth
controlled by quality.
Because the fixed distance code store_meta_block_fast may fall back to is
built for the RFC 7932 alphabet, quality two cannot carry a large window;
GreedyParams::new refuses one, the same way FastEncoder::new does for
qualities zero and one.
2. Parameter resolution and the hasher plan
Everything that decides what the encoder does is resolved once, before any
loop runs, by params::GreedyParams::new. Nothing about the running machine
takes part, which is what makes the output identical across SIMD backends.
flowchart TD
q["quality"] --> plan{"which quality?"}
plan -->|2| h2["H2"]
plan -->|3| h3["H3"]
plan -->|4| q4{"size_hint >= 1 MiB?"}
q4 -->|no| h4["H4"]
q4 -->|yes| h54["H54"]
plan -->|5 to 9| q5{"lgwin <= 16?"}
q5 -->|yes| chain["forgetful chain<br/>H40 / H41 / H42"]
q5 -->|no| big{"size_hint >= 1 MiB<br/>and lgwin >= 19?"}
big -->|yes| h6["H6"]
big -->|no| h5["H5"]
classDef fixed fill:#d9ead3,stroke:#38761d;
class h2,h3,h4,h54,chain,h5,h6 fixed;
The depth of whichever matcher is chosen then follows the quality: the bucket
matchers take block_bits = quality - 1, and the chain matchers take
max_hops = (quality > 6 ? 7 : 8) << (quality - 4).
ChooseHasher sets the type to the quality itself below five, which is where
H2, H3 and H4 come from.
| Plan | Hash bytes | Bucket bits | Slots per bucket | Static dictionary |
|---|---|---|---|---|
H2 | 5 | 16 | 1 sweep slot | yes, shallow |
H3 | 5 | 16 | 1 sweep slot | no |
H4 | 5 | 17 | 4 sweep slots | yes, shallow |
H54 | 7 | 20 | 4 sweep slots | no |
H40 / H41 | 4 | 15 | forgetful chain, one 65,536-slot bank | yes |
H42 | 4 | 15 | forgetful chain, 512 banks of 512 | yes |
H5 | 4 | 14 (q5, q6) or 15 | 1 << (quality - 1) | yes |
H6 | 8 | 15 | 1 << (quality - 1) | yes |
Each plan is a distinct Rust type — QuickMatcher<BUCKETS, SWEEP_BITS, HASH_LEN, USE_DICTIONARY, COMPACT>, BucketMatcher<HASH64, BUCKETS, BLOCK, SLOTS>
or ChainMatcher<NUM_BANKS, BANK_BITS, LAST> — so the hash width, the table
size and, for the bucket matchers, the block depth are compile-time constants
inside the probe loop; each bucket quality is its own MatchFinder variant
(§2.3). The MatchFinder enum that selects between them is matched once per
input block, never per candidate.
The chain depth is an ordinary field used as a loop bound. The cached-distance
count is not: as the reference’s NUM_LAST_DISTANCES_TO_CHECK, it is the
chain matcher’s LAST parameter, so H40 (four), H41 (ten) and H42
(sixteen) are separate MatchFinder variants and each probe loop has a
constant trip count with no bounds check on the cache. The bucket matchers
derive their cached-distance count from the block depth.
H2 and H3 share a shape but not a path: with one slot per bucket the probe
has no loop to leave, so the reference returns as soon as it has a match and
reaches the static dictionary only by falling out of the bottom. H3 never
consults the dictionary, so that distinction is invisible there; H2 does, so
the single-slot branch must fall through rather than return — the two are the
only matchers where the difference is observable.
The chain matcher’s addresses, heads, tiny hashes, bank offsets and free-slot
indices are boxed arrays with compile-time lengths, allocated when the matcher
is created.
Its slots remain a Vec<ChainSlot>: activating a previously unused bank grows
that pool. Logical resets retain both fixed tables and activated banks.
H40 and H41 have a single bank, which every store reaches, so the matcher
materializes it when it is created and activate_bank folds to offset zero.
H42 keeps lazy activation, so a cold call on a short input still allocates
only the banks it touches; the growth path is a separate #[cold] function.
Keeping that call out of the search and store loops matters: a call that may
reallocate slots, reachable from inside the loop, makes the compiler reload
every table pointer from the matcher after each store.
The hop loop mirrors the reference’s walk with three changes that leave every result identical:
- the bank is borrowed once per search as a slice of exactly
BANK_SIZEentries, so the masked slot index needs no bounds check; - the candidate filter compares the four bytes that end at offset
best_len, and the current position’s side of that compare is read only whenbest_lenchanges; - both sides are read from
MatchQuery::window, the ring buffer cut to the mask, soat < window.len()is both the reference’sat <= ring_buffer_masktest and the bounds proof for the read. When the current side falls outside the window, no later candidate can be longer, and the walk stops.
2.2. The tagged matchers
The reference builds H58 and H68 in place of H5 and H6 whenever
BROTLI_MAX_SIMD_QUALITY is defined, which on GCC and Clang covers qualities
five and six. Those variants store a one-byte tag beside every position and
visit only the slots whose tag matches. The bucket matcher keeps tags for the
same two qualities (block depth 16 or 32, a compile-time property of the
shape). tag_equality is generic over the block width and compares a whole
bucket’s tags with one safe fearless_simd vector — sixteen or thirty-two
lanes, the only two widths that survive monomorphisation; rotate_candidates
drops unfilled slots and rotates the result right by the newest slot, so bit
age of the mask names the slot age stores older than the newest one, and
one loop over ascending bits walks the bucket newest to oldest — the
reference rotates its mask the same way. Slots fill downwards from the top of
a block, as the reference’s tagged matchers do, which is what makes that walk
a plain rotation. The scalar backend deliberately keeps the unfiltered scan
as an independent oracle, and so do a sixteen-slot starter block and the
compact layout’s chain walk. Filtering
preserves the accepted-match sequence:
- They select the same bucket. The tagged
HashByteskeeps eight more low bits, which the key shifts straight back off. - They walk the bucket newest to oldest, as the untagged loop does.
- H5 tags depend on the first four bytes. H6 hashes five bytes; within an equal bucket, equal first-four-byte prefixes force the fifth byte to agree (the odd multiplier maps its contribution injectively into the high eight bits). Tag rejection therefore cannot discard a candidate accepted at length four.
- Both stop at the first candidate beyond
max_backward, and positions grow monotonically along the ring, so both stop having seen the same prefix.
The accepted-match sets coincide. tests/differential_c.rs checks the
consequence directly: qualities six and seven are compared against a C library
that really is using the tagged matchers.
2.3. Storage layouts, runs and sweeps
Every bucket shape is its own type: BucketMatcher<HASH64, BUCKETS, BLOCK, SLOTS>
takes the bucket count, block depth and total slot count as constants, so MatchFinder
holds ten bucket variants (H5Q5–H5Q9, H6Q5–H6Q9) rather than three.
The depth is the quality less one, and it fixes the rest of the shape: how
many cached distances a search probes (last_distances_for: four up to
thirty-two slots, ten up to 128, sixteen beyond) and whether slots carry tags
(depth at most thirty-two). With both constants every slot mask, block index
and hash shift is an immediate, and the dense tables are arrays whose
indexing needs no runtime shape check.
SLOTS is supplied separately because stable Rust cannot express a product of
generic constants in an array type. BucketMatcher::new checks
SLOTS == BUCKETS * BLOCK in an inline const assertion, so an inconsistent shape
fails at compilation. The hash helper uses only HASH64 and BUCKETS; runs keep
their existing nested array views and do not carry the storage-size parameter.
The inlined fixed_table(initial), shared from crate::shared::fixed with the
high-quality cost model, creates an initialized vector of the final
length and converts it to a boxed array. The separate inlined
fixed_table_from_vec(values, initial) always resizes the supplied vector,
including an empty one, before transferring ownership into a boxed array.
Neither conversion copies elements. Dense construction establishes all tables;
subsequent preparation only clears counters.
dense_run returns DenseRun directly: the total array lengths fix the nested
array views, so their checked TryFrom conversions fold away in optimized code.
The run borrows existing counters, positions and optional tags without allocation
or copying. Tagged shapes forward the optional tag reference; untagged shapes
supply None without reading the tag pointer. visit_run passes the result
directly to its visitor without an outer Option check.
flowchart LR
Layout["DenseLayout: fixed boxed arrays"] --> Run["dense_run: borrow counters and reshape positions"]
Layout --> Tags{"BLOCK <= 32?"}
Tags -->|yes| Borrow["Map optional tag array to nested view"]
Tags -->|no| None["No tag pointer read"]
Borrow --> Run
None --> Run
Run --> Visitor["visitor.visit: direct DenseRun"]
The reference allocates every bucket’s block up front and never initialises
it, reading a slot only below the counter that guards it. Safe Rust has to
initialise what it reads, so the bucket matcher picks a layout per stream from
what prepare is told about it:
| Layout | Chosen when | Index | Blocks | Cost of a new stream |
|---|---|---|---|---|
| Compact | one-shot input of at most 1024 bytes while the matcher is still compact | KeyMap, sized two entries per input byte, probed once per position: counter and chain head per bucket | one chain: Vec<u64> of `position | next << 32, one node pushed per store in front of its bucket's previous node; a search walks the newest BLOCK` nodes, the order a block scan takes |
| Sparse | a known input below the dense limit; an input of unknown length on a deep shape; unless the dense table already exists | a boxed [u64; BUCKETS]: generation stamp, block index with a starter flag, counter | typed pools: Vec<[u32; 16]> starters (one cache line, at most the shallowest block) that grow into Vec<[u32; BLOCK]> full blocks on their seventeenth store, tags alongside for tagged shapes | bump the generation |
| Dense | on a tagged shape that knows its input, the first block samples as ordinary data and the input is at least a hundred-and-twenty-eighth of the table (8 KiB at q5, 16 KiB at q6), or it does not sample as repetitive data covering half the input and the size hint reaches the dense limit — on the matcher’s first stream a thirty-second of the table for q5 and a sixteenth for q6 (32 KiB and 128 KiB of input with the 14-bit H5 shapes), a sixty-fourth from its second stream on (16 KiB and 32 KiB) (see Sampling the first block); for deep q7–q9 shapes an eighth of it on the matcher’s first stream (1, 2 and 4 MiB) and a sixty-fourth from its second stream on (256 KiB, 256 KiB and 512 KiB) — or the length is unknown on a tagged shape, or the matcher already holds the dense table; a sparse stream also switches mid-stream at a checkpoint once its store rate repays the clear (see Mid-stream promotion) | [u16; BUCKETS] counters | one flat Box<[u32; SLOTS]> with SLOTS = BUCKETS * BLOCK, viewed as [[u32; BLOCK]; BUCKETS] for a run, zeroed once per matcher | zero the counters |
BucketMatcher owns only Layout<BUCKETS, BLOCK, SLOTS>, the retargetable size hint,
and the saturating stream count. Each enum variant owns its own storage:
CompactLayout has the key map and linked positions; SparseLayout has the
boxed index, generation and full/starter pools with optional tags; DenseLayout
has Box<[u16; BUCKETS]> counters, Box<[u32; SLOTS]> positions and optional
Box<[u8; SLOTS]> tags, absent for untagged shapes. retained_bytes counts array
lengths and growable vector capacities, only for the active variant’s allocations. The initial compact variant has no allocations.
graph TD
Fresh["New table"] --> Allocate["fixed_table: vec of final length"]
Existing["Existing Vec"] --> Resize["fixed_table_from_vec: resize"]
Allocate --> Fixed["Box of fixed-size array"]
Resize --> Fixed
DenseLayout --> Counters["Box of u16 array: BUCKETS counters"]
DenseLayout --> Positions["Box of u32 array: SLOTS positions"]
DenseLayout --> Tags["Optional box of u8 array: SLOTS tags"]
ChainMatcher --> Tables["Fixed boxed tables: addresses, heads, hashes, bank metadata"]
ChainMatcher --> Slots["Vec of ChainSlot: H40/H41 bank at creation, H42 banks on first use"]
Dense stores
A dense store bumps the bucket’s u16 counter and writes the position into
the slot below the previous one (!count & (BLOCK - 1)), plus the tag on
tagged shapes. The shallow tagged shapes (q5 and q6, sixteen and thirty-two
slots) address both tables with one flat index key * BLOCK + slot, masked to
BUCKETS * BLOCK, a power of two checked at compile time; the deep untagged
shapes keep the [[u32; BLOCK]; BUCKETS] row view. Measured with callgrind
under lzbench’s fat-LTO profile, the flat index is 1.3% and 1.6% fewer
instructions at q5 and q6 and 0.9% more at q8, which is why it is limited to
the tagged shapes.
DenseRun::store_range, which records every position a match covered, reads
its eight-byte hash inputs from one slice when the range does not wrap the
ring and the slice reaches seven bytes past the range’s last position. The
positions then hash consecutive bytes, so windows(8) hands the same words
store would read, without a bounds check per position; store itself may
also read past the mask into the tail copy, so the two agree there too. A
range that crosses the mask would read the tail where store reads the ring’s
start, and takes the per-position loop. Only the deep untagged shapes take
the slice path: for the tagged q5 and q6 loop it measured 3% and 2% more
instructions.
flowchart TD
range["store_range(start, end)"] --> deep{"BLOCK > 32<br/>(untagged q7–q9)?"}
deep -->|no| each["store each position:<br/>masked eight-byte read, dense_store"]
deep -->|yes| wraps{"start & mask + count <= mask + 1<br/>and data holds count + 7 bytes?"}
wraps -->|no| each
wraps -->|yes| slice["windows(8) over one slice:<br/>hash each word, dense_store"]
Explicit SIMD quick range stores
commit_match passes the search loop’s existing SIMD token to
MatchRun::store_range_simd. Its default implementation delegates to the
baseline range operation; QuickRun overrides it for both full tables and
compact maps. Backend detection stays at the encoder entry. The range wrapper
chooses its path before the loop: fewer than sixteen positions, a scalar
backend, SSE2, or big-endian targets use the bounded baseline. SSE2’s emulated
wide products were slower in the measured workload. Other available backends
use the explicit fearless_simd kernel for contiguous ranges.
store_range_vectorized is an out-of-line #[simd] entry so vector temporaries
do not extend the search loop’s register requirements. It borrows one bounded
slice and advances sixteen-byte windows by eight bytes. quick_hashes8 loads
u8x16, duplicates and swizzles its bytes into eight overlapping little-endian
words, then multiplies and shifts u64x8. This computes the same hashes as the
scalar oracle without gather loads. Eight explicit scalar writes commit the
keys in ascending position order: colliding slots must retain the last write,
including probing and overwrites in compact maps. Scatter stores are not used.
On AArch64, multiplication remains vectorized while the high-bit fields are
extracted from the eight product lanes before the ordered writes. This avoids
Neon’s variable-shift intrinsics, which Miri cannot interpret, without a
Miri-specific production path or skipped SIMD tests. Other architectures use
the vector shift directly.
Eight hashes need fifteen input bytes. A final full batch uses a sixteen-byte
load only when the extra physical byte exists; that byte never contributes to
a hash. Otherwise at most eight bounded, explicitly unrolled scalar stores
finish the range. Wrapped ranges or missing lookahead delegate to
store_range, whose scalar implementation also serves administrative/history
stores and the independent differential oracle. The sparse layout’s
store_range places each position through SparseLayout::place, which is
push without the store counter, and adds the range’s length to the counter
once: the counter sits behind the same reference as the pools a store
writes, so counted per store its read-modify-write would chain every store of
the range to the one before. No unsafe loads or public API changes are
introduced.
flowchart LR
Search[search_positions with selected SIMD token] --> Commit[commit_match]
Commit --> Range[MatchRun store_range_simd]
Range --> Eligible{QuickRun, supported backend, little endian, at least 16 positions?}
Eligible -->|no| Baseline[bounded scalar store_range]
Eligible -->|yes| Vector[outlined SIMD feature entry]
Vector --> Contiguous{range and lookahead fit?}
Contiguous -->|no| Baseline
Contiguous -->|yes| Hash[u8x16 load and swizzle; u64x8 multiply and shift]
Hash --> Store[eight ordered slot overwrites]
Store --> Hash
Store --> Tail[bounded scalar tail]
Compact key-map growth
KeyMap::reset clears the existing words, then resizes only up to the larger of
its current length and the requested power-of-two length. It retains capacity
and initializes newly added words to the empty sentinel. Normal compact inputs
are pre-sized to at least two slots per input byte, so grow is a fallback;
reused streams of increasing short lengths exercise the reset growth path.
KeyMap::grow doubles the logical length with Vec::resize and rebuilds the
open-addressed index in the same vector. Capacity already sufficient for the new
length means no allocation; otherwise the allocator may extend or move the buffer.
No separate destination vector is created.
Rehashing starts immediately after an empty slot in the old table and visits exactly the old length of slots, wrapping with the old mask. The table is at most half full, so such a slot exists whenever the table is nonempty. This traversal visits each old probe cluster in order. Each entry is removed before reinsertion using the new mask; its destination is in the new half or an already visited old slot. Unread entries cannot be overwritten or mistaken for reinserted entries. The count is rebuilt, and packed keys, counters and chain offsets are preserved. An initially empty table grows to 64 empty slots without a rehash loop.
flowchart TD
Old["Find an empty slot in the old table"] --> Resize["resize to max of twice old length and 64"]
Resize --> Count["Reset count; begin after old empty slot"]
Count --> Next{"Old slots left?"}
Next -->|yes| Take["Take current entry; leave empty sentinel"]
Take --> Present{"Entry occupied?"}
Present -->|yes| Insert["Reinsert using new mask; rebuild count"]
Present -->|no| Advance["Advance with old mask"]
Insert --> Advance
Advance --> Next
Next -->|no| Ready["All keys reachable in resized table"]
Layout changes occur during prepare, between independent streams, and at a
sparse stream’s mid-stream checkpoint, which only ever turns sparse into dense
(see Mid-stream promotion). Compact
resets reuse its map and chain. Once sparse or dense storage exists, even a tiny
one-shot stream keeps it; dense never demotes. Compact-to-sparse moves the larger
of the map and chain’s Vec<u64> allocations into the index, clears its previous
encoding, resizes it to BUCKETS, and converts it to a boxed array. Growing or
shrinking that allocation may relocate it. Sparse-to-dense uses safe
Vec::into_flattened to transfer the larger position pool (full or starter)
and the larger tag pool without copying, then resizes them to BUCKETS * BLOCK
before converting to boxed arrays using the safe TryFrom conversion. Exact
final capacity preserves their addresses;
growing or shrinking the allocation may relocate it. Dense preparation thereafter
clears only counters and retains the fixed-size position and tag allocations. The
smaller pools and the sparse index are released. Compact-to-dense allocates new typed buffers. No unsafe type punning
is used. Every transition resets counters or stamps before old slots can be read;
it transfers storage, not a previous stream’s match history.
stateDiagram-v2
[*] --> Compact: empty buffers
Compact --> Compact: one-shot input at most 1024 bytes / reset map and chain
Compact --> Sparse: below dense threshold / reuse larger u64 buffer
Compact --> Dense: dense threshold reached / allocate typed tables
Sparse --> Sparse: below dense threshold / advance generation
Sparse --> Dense: dense threshold reached / flatten larger pools, resize and convert to fixed arrays
Sparse --> Dense: checkpoint mid-stream, store rate repays the clear / copy live buckets and tags into a fresh table
Dense --> Dense: every subsequent stream / clear counters
Mid-stream promotion
The static dense limits are set by the table’s size, not by the input: a q6 stream below 128 KiB and a q7 or q8 first stream below 1 or 2 MiB start on the sparse layout, which costs no clear but pays a dependent index load and block bookkeeping at every store and search. Whether that overhead outgrows the clear depends on how much the stream stores, which only the stream itself reveals: text stores about one position per input byte, incompressible input about a third (the random-data heuristic strides over the rest), and long repetitive copies almost none.
Matcher::checkpoint(position, remaining) lets a matcher act on that. The
default returns usize::MAX (never call back). BucketMatcher answers
usize::MAX for the compact and dense layouts; on its sparse layout it
counts the stores made since its previous
checkpoint (SparseLayout::stores, one increment per store, reset with the
generation at prepare) and predicts the rest of the stream at that rate.
The rest is the size hint less the position, or the current block’s
remainder when that is larger or no hint was given. When
stores × remaining ≥ span × SLOTS / PROMOTION_SLOTS_PER_STORE
(32 for the deep shapes, 16 for the tagged ones, whose shallow blocks save
less per store while the copy weighs more against a 1–2 MiB table) it builds
a fresh dense table (DenseLayout::from(&SparseLayout): each live bucket’s
counter, its full block, or its starter’s sixteen slots at the top of the block
— where the seventeenth store would have moved them — with the same for its tags,
and nothing from a stale generation), drops the sparse storage, and returns
usize::MAX.
Otherwise it asks to be called again after CHECKPOINT_INTERVAL (16 KiB)
more positions. The first checkpoint of a stream has no interval to judge
and only records its starting point. The interval is long enough that a
repetitive input, which stores at almost every position of its first few
kibibytes and then copies to its end, usually reaches that copy before it
is judged. Lower bars switched incompressible and repetitive inputs whose
store rate later falls, and doubled their time; at the chosen bars no
measured input got slower.
sequenceDiagram
participant CBR as create_backward_references
participant M as BucketMatcher
participant Driver as SearchLoop (per run type)
CBR->>M: checkpoint(block start, block bytes)
M-->>CBR: CHECKPOINT_INTERVAL or usize::MAX
loop until position + hash length reaches the block end
CBR->>M: visit_run(SearchLoop { yield_at: position + interval })
M->>Driver: visit(SparseRun or DenseRun)
Driver-->>CBR: cursor (stopped at yield_at or the block end)
opt not at the block end
CBR->>M: checkpoint(cursor.position, block remainder)
alt store rate repays the clear
M->>M: Sparse → Dense (copy live buckets)
M-->>CBR: usize::MAX
else
M-->>CBR: CHECKPOINT_INTERVAL
end
end
end
Candidate sets and their order are the same in both layouts, so a switch — or none, or one at another position — never changes the output; the differential tests and a unit test that resumes the loop at every position check exactly that.
The dense decision uses the current retargetable size hint, not prepare’s
one_shot flag. Unknown-length streams use dense storage on tagged shapes and
on-demand layouts on deep shapes. Every shape’s length threshold drops to a
sixty-fourth of the table once the matcher has been reused: the table is then
zeroed once and later streams only clear its counters. Reused q6 on 64 KiB of
incompressible input measured 115 us on demand and 67 us with the table (C
85-92 us); reused text, JSON and repetitive input were unchanged, since the
sample already gives them the table or keeps them on demand. The cost is
retained memory: a q6 compressor reused on such input now keeps about 3.5 MiB
instead of 1.4 MiB, what it already kept for text of the same size. These
layout choices preserve candidate order and
output; allocation timing is not part of the public reuse contract.
Sampling the first block
Which layout is cheaper depends on how many positions the stream stores, and
that depends on the data more than on its length: text, markup, JSON and
binary formats store at almost every position, incompressible input about a
third as often once the random-data heuristic skips, and repetitive input
almost never, because long copies cover it. A tagged shape (q5, q6) whose size
is known therefore samples the block prepare receives
(greedy::probe::sample_store_outlook) once the input reaches
ORDINARY_DENSE_LIMIT, a hundred-and-twenty-eighth of the table:
flowchart TD
start[BucketMatcher::wants_dense] --> deep{deep shape or unknown length?}
deep -->|yes| length[size hint at least dense_limit: table/64 once reused]
deep -->|no| small{input below table/128?}
small -->|yes| sparse[on demand]
small -->|no| sample[sample 256 eight-byte windows of the first block]
sample --> rep{half the windows repeat?}
rep -->|yes, block at least half the input| sparse
rep -->|yes, input outruns the block| length
rep -->|no| entropy{at least 7.6 bits per sampled byte?}
entropy -->|incompressible| length
entropy -->|ordinary| dense[dense table]
sample -->|block shorter than a window| length
Repeats are counted against a 4096-bit filter, so a stray collision adds about
one window in thirty; ordinary data repeats at most an eighth of its windows at
this sampling and repetitive data four fifths or more. The byte histogram for
the entropy is built only after the repeat check, because a run of one byte
would chain every increment through one counter. The sample costs 0.3-1 us,
once per stream. Cold one-shot measurements behind the limits: ordinary data
took the dense table faster from 8 KiB at q5 (0.84-0.94 of the on-demand time)
and 16 KiB at q6 (0.86-0.98), 0.72-0.89 from 32 KiB; repetitive data lost two
to twelve times with it at every size to 128 KiB, but backward65536 cycled
to 256 KiB, whose first 64 KiB are runs, stores along its 64 KiB-distant copies
and lost half again its time without the table, hence the coverage condition.
Deep shapes measured within a few per cent either way on ordinary data and
split on repetitive data, so they keep the length rule. Like every layout
choice this changes no output.
Sparse pools normally grow geometrically. When a pool has promoted 1024
starter blocks, reserve_populated_blocks reserves from the current size hint to avoid
repeatedly copying a populated pool. The estimate is one full block per sixteen
input bytes, rounded down to a power of two and capped at 16384 blocks and the
shape’s bucket count. Unknown hints reserve nothing. This changes capacity only:
the fifth store still promotes each starter, tags stay alongside positions, and
generation stamps and candidate order are unchanged. The cap limits speculative
reservation on large compressible inputs; pools can still grow beyond it on
demand. Existing capacity is retained across resets.
The size hint itself is retargetable: a reused encoder whose
new hint resolves to the same shape and the same match-finder variant takes
the hint over (GreedyEncoder::retarget, MatchFinder::retarget) instead
of being rebuilt, so a compressor fed inputs of varying lengths keeps its
tables; only a hint that crosses the quick matchers’ 2048-byte compact
boundary, or changes the plan, still rebuilds. A session of unknown length
retargets the same way when it infers its hint before its first encode
(Encoder::retarget_size_hint), with the hint and the expected input passed
separately: the hint resolves GreedyParams (plan and context-map gate), the
expected input the finder variant, layout and window capacity
(GreedyEncoder::expecting, GreedyEncoder::retarget(fresh, expected_input)).
A rebuild there takes over the header bits an empty first flush already wrote
(GreedyEncoder::adopt_stream_head). QuickMatcher::new allocates no full
table: the constructor of an encoder that knows its size, or
retarget_size_hint for one that inferred it, calls materialize before the
window is allocated, so a matcher rebuilt for the quick small-input variant
costs no zeroed allocation and the table still comes from fresh pages;
prepare allocates one only if neither ran.
The table is kept flat on purpose: a zero-filled vector of integers, converted
to a boxed array at its final length,
comes straight from the allocator’s zeroed pages, whereas a vector of arrays
longer than sixteen elements is written out element by element, which for the
two-mebibyte quality-six table cost more than compressing a hundred kibibytes.
A sparse entry from an earlier generation still names its block but counts as
empty, so blocks persist across streams; a compressor that holds the dense
table uses it for every later stream, because clearing its counters is
cheaper than the on-demand layouts’ extra dependent load per bucket. Every layout empties itself in time
that does not depend on what the previous stream stored, which is what the
[Sweep::SelfCleaning] result of prepare tells the encoder.
Matcher::prepare reports one of three sweeps, and the encoder records what a
reset then has to do: Partial (quick and chain matchers on a short one-shot
input) is replayed at reset, because it clears exactly the slots the stream
could have dirtied; Full leaves the table dirty, so the next stream is not
one-shot and pays for a wipe; SelfCleaning needs nothing. The quick matchers
are QuickMatcher<BUCKETS, ...> with their table as a boxed array; a compact
quick matcher (size hints up to 2048 bytes) indexes a SmallSlots map for its
first stream only, sized for the input so it never rehashes mid-stream, and
allocates the table when the replay sweep after that stream asks it to clear,
from which point it is cleared by the partial sweep like any other. A map
entry is one word — the slot above the position plus one, zero when empty —
so clearing it costs half of what a two-word entry did; the packing needs
positions below 32767, so prepare allocates the table instead of the map
for a stream that is not one shot or is longer than that. The single-slot
shapes read and overwrite the same slot at every position and do both with
one probe (QuickSlots::replace).
SmallSlots::reset(input_size) reserves at least twice as many entries as
input positions before a run starts. Each position selects one deterministic
logical slot, including swept shapes; revisiting that position overwrites the
same key. The map therefore stays at most half full without a population counter
or growth check on each store. A compact run is restricted to a fresh final
block at position zero. Nonfinal blocks, prepended history, oversized input and
reused streams select a full table before any stores or boundary stitching.
Only preparation may resize the map; its capacity and mask remain fixed during
the search. The compact/full differential test must prepare the compact side
with clear=false: a dirty preparation intentionally materializes a full table.
flowchart TD
Prepare[QuickMatcher prepare] --> Eligible{fresh one-shot final block and bounded positions?}
Eligible -->|no| Full[materialize full table before stores]
Eligible -->|yes| Reserve[reset map with at least twice input_size entries]
Reserve --> Run[fixed capacity during searches and range stores]
Run --> Bound[one key per position; at most half full]
Bound --> Reuse[next stream requests clearing]
Reuse --> Full
The reference hoists its table pointers into restrict locals for a whole
block, so a store through one never makes the compiler reload the others. The
RunVisitor trait is the same idea: Matcher::visit_run binds the tables once
per input block and hands the visitor — the search loop — the concrete view
its layout uses, so the loop is compiled once per view rather than once over a
union of them:
| Matcher | Run types |
|---|---|
QuickMatcher | QuickRun<&mut [u32; BUCKETS]> over the table, or QuickRun<&mut SmallSlots> over the map |
BucketMatcher | DenseRun over three array references; CompactRun over compact storage; SparseRun over sparse storage plus the current size hint |
ChainMatcher | the matcher itself |
Dense runs repeat the BLOCK <= 32 tag invariant at search, store and
prefetch call sites. Deep layouts already construct no tag storage; making
that constant visible inside the SIMD feature entries removes optional-tag
branches and pointer lifetimes from qualities 7–9. The generic dense_store
helper still honors a supplied tag array, including in its standalone tests.
flowchart LR
prepare[prepare: one-shot? input length; size hint; tables held] -->|still compact and at most 1024 bytes| compact[Compact: key map + one chain of nodes]
prepare -->|known short input, repetitive or incompressible tagged input below the limit, or unknown length on a deep shape| sparse[Sparse: stamped entries + typed pools]
prepare -->|ordinary tagged input from table/128, size hint at least the dense limit, unknown length on a tagged shape, or table held| dense[Dense: counters and flat key-addressed blocks]
compact --> run[Matcher::visit_run binds one concrete run for the block]
sparse --> run
sparse --> checkpoint{checkpoint: store rate repays the clear?}
checkpoint -->|yes, mid-stream| dense
sparse --> promoted{1024 full blocks reached?}
promoted -->|yes| reserve[Reserve bounded pool capacity from size hint]
reserve --> run
dense --> run
run --> search[find_longest_match: cached distances, tag mask, candidates, one index lookup per position]
run --> store[store / store_range through the run]
prepare -. Sweep::SelfCleaning .-> reset[reset: nothing to replay]
2.4. The distance cache
The outlined accept_cached and accept_candidate measurement helpers return
an optional pair with a NonZeroUsize score. A winning score is strictly greater
than the current unsigned score, so zero cannot represent an accepted match.
The niche lets the x86-64 build return the pair in registers without a separate
option tag or a caller-provided return buffer. Ring positions and remaining
block lengths cross this private helper boundary as u32: the largest ring has
31 address bits and a block has at most 24 length bits. Widening them inside the
helper makes these bounds visible to the optimizer while preserving safe slice
checks. This is unrelated to the logical stream position, which can span many
ring wraps. SIMD selection still happens above the search loop; each helper
receives the already selected S token.
Qualities seven and above probe more than the four remembered distances. The
extra entries are near misses derived from the two freshest ones — one, two and
three either side — which prepare_distance_cache fills whenever the remembered
four change. Only those four survive a meta-block; the rest are rebuilt before
any search reads them, which is why saved_dist_cache is four wide.
Most cached probes fail on one byte, the byte at cur_ix + best_len that a
longer copy would have to repeat. For the shapes that probe the derived
distances (blocks above 32 slots: ten probes at qualities seven and eight,
sixteen at nine), probe_derived_distances tests them all at once through the
private compressor::core::cache_probe module the high-quality search shares
(see hq-encoder.md §4). The derived near misses are seven
adjacent ring bytes around each of the two freshest distances, so two
eight-byte reads and the two single bytes of the third and fourth distances fill
one u8x16; one simd_eq against the continuation byte and one u32x16 range
test over the cache give a candidate mask in index order. Each candidate still
runs the exact scalar filter and accept_cached, lowest index first; when a
copy is accepted, the indices after it are filtered again at the new length,
which is the order and the information the reference’s loop uses. A run that
would wrap around the ring mask, or read past the bytes the ring holds, is let
through whole. Without the second run (ten probes), code 1’s byte is read on its
own. The mask is a superset of the scalar filter’s admissions only when the
cache holds prepare_distance_cache’s entries; a debug assertion checks that,
and the search tests prepare their caches as the encoder does. Four-probe shapes
and the scalar backend keep the plain loop. The vector range test loads the
signed cache directly with i32x16::load_array_ref, then uses Bytes::bitcast
to interpret its unchanged bits as u32x16. Zero and negative distances retain
their rejection semantics. No owned array mapping or intermediate element
conversion is needed; the selected backend owns both the load and range test.
flowchart TD
Q[find_longest_match] --> Count{"probes > 4 and SIMD backend?"}
Count -->|no| Plain[scalar loop over the probes]
Count -->|yes| Cont{continuation byte in the window?}
Cont -->|no| Bucket[bucket scan]
Cont -->|yes| Mask["cache_probe: u8x16 byte compare + u32x16 range test -> index mask"]
Mask --> Next{candidate at or after next_index?}
Next -->|no| Bucket
Next -->|lowest| Exact[exact scalar filter]
Exact -->|rejected| Next
Exact -->|passes| Accept{accept_cached scores it higher?}
Accept -->|no| Next
Accept -->|yes| Raise["best_len = len; next_index = index + 1"]
Raise --> Cont
Plain --> Bucket
Instructions for one compression (callgrind, default release profile) against
the plain loop: q7 -5.8 to -7.7%, q9 -8.1 to -15.5% on alice29.txt,
lcet10.txt and mapsdatazrh; q8 -0.9% and -4.4% on two of them and +0.9% on
lcet10.txt, where inlining the filter costs the bucket loop more register
pressure than the probes save. Outlining it was worse on every case.
The distance alphabet is resolved in the same pass: font mode asks for one postfix bit and twelve direct codes, every other mode uses what the caller configured, and qualities below four always use neither.
2.5. Bucket prefetch
From quality six up the bucket tables outgrow the L2 cache (2.5 MiB at six,
8 to 32 MiB at seven to nine), and the load of a bucket’s newest slot was the
single most sampled instruction of the search. The reference prefetches the
bucket, the next sixteen slots and the tag row at the start of each
FindLongestMatch. Here the hint is issued one search earlier: every
iteration of search_positions first calls MatchRun::prefetch for the
following position, and commit_match does the same for the position after
the match before store_range records the match’s bytes. The dense and
sparse runs hash that position, read its counter or sparse entry, and hint
the lines holding the newest slot, the slot sixteen older (blocks deeper than
sixteen) and the tag row; the other runs keep the trait’s empty default, since
their tables fit in L2. A prefetch at search start, as in C, measured slower
on binary input (q7 +2%) than this.
The hint is shared::baseline::prefetch, a read hint into L1 through
branches (prefetcht0 on x86-64, prfm pldl1keep on AArch64; see
shared-primitives.md). It is a read hint although the
bucket is later written: the search reads it first, and the store finds the
line already exclusive to the encoder’s thread. It never changes a result, so
output is unchanged. Buckets deeper than 32 slots get two of their lines, as
in the reference.
sequenceDiagram
participant L as search_positions
participant R as DenseRun / SparseRun
participant C as cache
L->>R: prefetch(position + 1)
R->>C: prefetch L1 (newest slot, slot + 16, tag row)
L->>R: find_longest_match(position)
Note over R,C: bucket for position was hinted one iteration earlier
alt match found
L->>R: prefetch(position + len)
L->>R: store_range_simd(selected token, match bytes)
end
3. Streaming lifecycle
stateDiagram-v2
[*] --> Empty: GreedyEncoder::new
Empty --> Buffering: encode_block(input, false)
Buffering --> Buffering: meta-block not due yet
Buffering --> Emitting: is_last, or a flush condition fires
Emitting --> Buffering: meta-block written, is_last = false
Emitting --> Finished: meta-block written, is_last = true
Empty --> Finished: encode_block(&[], true)
Finished --> [*]
note right of Buffering
commands accumulate,
encode_block returns no bytes
end note
note right of Emitting
one meta-block, then
num_literals and commands reset
end note
A meta-block is emitted when any of these holds, mirroring EncodeData:
- this is the last block;
- a quality that does not split blocks — two or three — has buffered
0x2FFFliterals and commands together; - the caller asked for a flush, which forces the meta-block out and then realigns the stream to a byte boundary; see compressor.md §3.1;
- another whole input block would not fit inside the largest meta-block;
- buffered literals or commands reached an eighth of the largest meta-block.
Because encode_block may return nothing, the driver and both streaming
adapters treat an empty result as normal rather than as end of stream.
4. Command generation
backward_references::create_backward_references is the port of
CreateBackwardReferences, and its decision order is the compression format’s
semantics: which candidate wins, when a match is delayed by a byte, which
positions are stored and which are skipped are all visible in the output.
create_backward_references resolves the loop-invariant Block, refreshes
the distance cache, and runs the loop through Matcher::visit_run as the
SearchLoop visitor, so the loop is monomorphised per concrete run (§2.3).
The loop stops at the first of the block’s searchable end and the position
the matcher’s last Matcher::checkpoint allowed — one precomputed bound, so
a matcher that never asks to be called back costs the loop nothing — and
create_backward_references then checkpoints and re-enters visit_run with
the cursor, which is how a sparse bucket matcher switches layout mid-block (§2.3).
Block::store_end is derived from the block’s first position on every entry,
so a resumed loop bounds its stores exactly as an uninterrupted one would.
The loop body is split in two functions. The search at every position stays
in the loop; everything a found match entails — the delayed search, the
distance cache update, the command and the stores — lives in commit_match,
which re-enters the SIMD feature context and is left for the compiler to
inline. Both
feature-context closures move their captures: the context is a separate
function, and a capture by reference is a pointer it dereferences at every
use, where a moved value is a local it keeps in a register. The loop’s state
(position, insert length, the random-heuristics horizon, the matcher run) is
passed to the commit path by value and back, so nothing in the hot loop has
an address the compiler must keep current in memory. The static-dictionary
probe likewise works on a copy of the search result.
The query a search receives derives its rare-path values on demand rather
than carrying them: MatchQuery::dictionary_start computes the capped
distance to the start of the stream only for the dictionary probe and the
attached-prefix search, and the window a candidate is measured against
(current_window) is cut only once a candidate has passed the byte compare.
Inside the bucket scan the running result lives in locals (Found) and is
written back once at the end, and the two measuring steps —
accept_cached for a cached distance, accept_candidate for a bucket slot —
are out of line and return by value: the candidate loop is a filter that
rejects most of what it sees, and it stays in registers only while it holds
about a dozen values. The greedy matchers measure a match with
match_len_at, which settles the first eight bytes with one load per side
and a trailing-zero count before cutting any window, and only then continues
with match_len_windows, a whole-word scan with one overlapping word at the
tail (§8.1) — together the reference’s FindMatchLengthWithLimit without its
byte loop. The quick matchers measure behind an out-of-line call that
re-enters the SIMD feature context. H2 and H3 use match_len_at_outlined,
which settles the first word inline and calls match_len_tail for the rest:
inlined, that scan’s setup does not depend on the slot, so it was hoisted in
front of the slot loop and paid, with its spills, at every position, although
only about a quarter of the candidates get past the first word. The
four-slot H4 and H54 use match_len_at_current, which also cuts the
current window behind the call: with four slots the compiler cut that window
in front of the slot loop and kept it across all four, at every position,
although only the few candidates that pass the first-byte compare are
measured (measured on H4 text: about 2.5% fewer instructions per stream).
The one- and two-slot matchers measured 1-4% slower that way, so the choice
is made per matcher at compile time. current_window and the candidate cut
mask their index and length to 32 bits, which proves the slice end neither
overflows nor precedes the start and leaves one compare.
The bucket continuation filter uses the concrete run’s physical bucket width.
Compact chains, shallow buckets (N <= 32), the four-position starter blocks
and full buckets wider than 128 slots keep Best { len, word } and consider:
each candidate checks the four bytes at its masked position plus len - 3.
Only full buckets with 32 < N <= 128 use best_word and a borrowed
continuation slice. These are the 64/128-slot full buckets used at qualities
seven and eight; the 256-slot quality-nine bucket retains the original filter.
scan_bucket handles the selected widths in an early-return branch, leaving
the original shallow and wider-bucket implementation below it.
raw_continuation_window prepares the suffix after the cached-distance probes
have raised the initial comparison length to at least three. The width choice
is constant for the concrete run and outside the candidate loop; it does not
add backend detection or SIMD dispatch.
Let W be the original physical window,
data[..min(mask.saturating_add(1), data.len())]. For comparison length L,
the continuation is W[L - 3..], or an empty slice when the suffix is outside
that window. A candidate’s masked position p then reads
continuation[p..p + 4], which is exactly the former
W[p + L - 3..p + L + 1] check. The filter need not add the comparison offset
to each rejected candidate or retain it separately from the suffix. The
current four-byte word is cached as before. This slice is the physical
continuation filter, distinct from current_window, which bounds the actual
match measurement against remaining input.
consider_raw_rebased first applies the same logical backward-distance guard:
an old or future position outside max_backward stops the scan before a
physical read. An unavailable four-byte window or a different word skips that
candidate. A matching word calls the existing accept_candidate with the same
SIMD token, original data, current position, match-length limit, distance and
score. After acceptance, it updates Found and best_word, then rebuilds the
continuation from the original data and mask at the accepted length.
It never derives the next continuation from the previous suffix. A closer
candidate can win on score with a shorter length, so rebuilding must also
move the start backwards and expose positions the old suffix could not read.
Within the 64/128-slot branch, scan_bucket first selects the permitted
algorithm from its existing concrete SIMD token and hash shape:
| Selected token | Hash/depth | Young scan permitted |
|---|---|---|
| AVX2 | H5 or H6, N64/N128 | yes |
| AVX-512 | H6 wide hash, N128 | yes |
| AVX-512 | H5, or H6 with N64 | no: general rebased scan |
| SSE2, SSE4.2, Neon, Wasm SIMD or scalar fallback | any | no: general rebased scan |
The exact Level match distinguishes AVX2 from AVX-512; it uses
scan.simd.level() from the selected S, with x86-only enum arms behind target
configuration. Non-x86 targets set the permission to false. The gate depends
only on the concrete token and the HASH64/N constants, before any candidate
loop. It introduces no CPU feature detection and does not select a different
backend. For example, an AVX-512 H5 query retains its AVX-512 measurement token
while using the general rebased filter. The policy records measured native
algorithm combinations; the public backend selection stays unchanged.
An allowed entry can then select consider_raw_young once for this bucket
search. Runtime eligibility requires a nonempty bucket,
max_backward == cur_ix, cur_ix <= mask, and a low-ones mask
(mask & mask.wrapping_add(1) == 0). Only then does it read the newest logical
position and require that head to be at most cur_ix. The loaded head is reused
as the first candidate. A failed policy or state condition falls through to the
same general rebased loop; aged history, wrapped logical positions, unusual
masks and future heads retain its per-candidate distance guard and masking.
Compact, shallow/Starter4 and N>128/q9 use the original Best/consider
implementation.
Reachable bucket slots are already ordered newest to oldest by non-increasing
logical position. The head check therefore proves
0 <= previous <= head <= current <= mask for every visited slot. Combined
with max_backward == current, this makes every distance admissible:
current - previous <= current == max_backward. The low-ones mask also gives
previous & mask == previous and current & mask == current. A young
candidate’s stored logical position is already its physical index; the helper
reads the bounded continuation directly, with no per-candidate distance test or
position masking. Only a matching four-byte word computes the actual
current - previous distance for the unchanged accept_candidate and Found.
It does not recover distance from a masked difference. A debug assertion checks
the nonfuture-position invariant before the read. The one head check relies on
the existing chronology; it does not validate an arbitrary unordered array.
Young and general rebased loops share the same cached word, bounded suffix, measurement/scoring rules and original-window rebuild after acceptance, including a shorter accepted length. The choice is local to this bucket search; no mode flag is stored in the matcher or encoder, and no backend detection is added. The same previously selected SIMD token reaches the measurement entry.
flowchart TD
Probes["Cached-distance probes; comparison length L at least 3"] --> Shape{"Full bucket with 32 < N <= 128?"}
Shape -->|no: compact, shallow, starter or N > 128| Original["Best len/word and original consider filter"]
Original --> Stop["Finish scan; write back Found once"]
Shape -->|yes| Policy{"AVX2 N64/128, or AVX-512 H6 N128?"}
Policy -->|no| GeneralInit["Prepare bounded original-window suffix and best_word"]
Policy -->|yes| Eligible{"Slots present, max_backward == current, current <= mask, low-ones mask?"}
Eligible -->|no| GeneralInit
Eligible -->|yes| Head{"Load newest once: head <= current?"}
Head -->|future| GeneralInit
Head -->|nonfuture| YoungInit["Prepare suffix and best_word; reuse loaded head"]
GeneralInit --> GeneralMore{"Slots remain?"}
GeneralMore -->|no| Stop
GeneralMore -->|yes| Distance["Read slot; apply backward-distance guard"]
Distance -->|outside history| Stop
Distance -->|valid| GeneralRead["Mask previous position; bounded four-byte continuation check"]
GeneralRead -->|missing or different| GeneralNext["Advance newest to oldest"]
GeneralRead -->|equal| GeneralMeasure["accept_candidate with original data and selected SIMD token"]
GeneralMeasure -->|score does not improve| GeneralNext
GeneralMeasure -->|accepted| GeneralRebase["Update Found/word; rebuild from ORIGINAL W, even for shorter length"]
GeneralRebase --> GeneralNext
GeneralNext --> GeneralMore
YoungInit --> YoungRead["Bounded four-byte continuation check at direct logical position"]
YoungRead -->|missing or different| YoungNext["Decrease remaining count"]
YoungRead -->|equal| YoungDistance["Compute actual current - previous"]
YoungDistance --> YoungMeasure["Same accept_candidate, data, token and scoring"]
YoungMeasure -->|score does not improve| YoungNext
YoungMeasure -->|accepted| YoungRebase["Same Found/word update; rebuild from ORIGINAL W"]
YoungRebase --> YoungNext
YoungNext --> YoungMore{"Slots remain?"}
YoungMore -->|no| Stop
YoungMore -->|yes| YoungAdvance["Advance slot and load next logical position"]
YoungAdvance --> YoungRead
The ring data is borrowed read-only throughout the search. No storage allocation,
public API, low-level error, candidate ordering, distance scoring or match
measurement ABI changes; Found is still committed to the search result once
the scan ends. All supported SIMD levels and the scalar baseline preserve the
same candidate-order, scoring, output and error invariants. Depth, exact token/hash
policy and query state choose the filter implementation; the existing
feature-enabled measurement entry owns the SIMD context. The specialization
adds no public API, persistent matcher state, buffer ownership, allocation or
error path; only the local reject work changes.
The static-dictionary probe is chosen at compile time
(search_dictionary::<SHALLOW>). A shallow probe (the quick matchers) hashes
the four bytes inline and answers an empty bucket — about three quarters of
the probes on text — without a call, counting the lookup exactly as the
out-of-line probe would; only a filled bucket copies the search result and
calls probe_shallow, so an empty bucket stores nothing. The deep
probe of the bucket matchers keeps its hash inside probe_deep, so their
search loop is unchanged. None of this changes a decision: the sequence below
is the same as the reference’s.
sequenceDiagram
participant Search as create_backward_references
participant Finder as Matcher
participant Dict as dictionary
participant Prefix as attached prefix
participant Cmds as command buffer
loop while position + hash length < end
Search->>Finder: find_longest_match(position)
Finder->>Finder: probe the four cached distances
Finder->>Finder: probe the hash bucket
opt nothing beat the minimum score
alt shallow and the bucket is empty
Finder->>Finder: count the lookup inline
else
Finder->>Dict: probe_shallow(key) / probe_deep
end
end
Finder-->>Search: SearchResult
opt a context is attached (q5 and above)
Search->>Prefix: find_match(position)
Prefix-->>Search: improved SearchResult
end
alt no match
Search->>Search: one more literal, maybe stride ahead
else match found
loop at most four times
Search->>Finder: find_longest_match(position + 1)
Finder-->>Search: SearchResult
break delayed match not 175 points better
Search->>Search: keep the earlier match
end
Search->>Search: emit one literal, take the later match
end
Search->>Search: update the distance cache
Search->>Cmds: append one command
Search->>Finder: store_range_simd with the selected token over matched bytes
end
end
4.1. The attached prefix
An attached RFC 9841 prefix widens the distance space rather than the search.
Every distance that would address the dictionary sits past the window by
gap — the total attached bytes — so three things shift together, exactly as
they do in the reference:
- the match finder is told the static dictionary starts at
dictionary_start + gaprather thandictionary_start, which pushes the built-in dictionary past the attached one; SharedContextInner::find_matchruns after the ordinary search at each position and may replace its result, usingdictionary_startitself as the boundary between window and prefix;compute_distance_codeand the distance-cache update both compare againstdictionary_start + gap, so a prefix reference is coded as an ordinary distance but never enters the cache.
extend_last_command handles prefix copies: a copy whose distance is past
the window continues into the concatenated prefix, and unlike the search it
runs on across attachment seams. See shared-brotli.md.
create_backward_references is generic over a const ENABLE_PREFIX: bool, and
GreedyEncoder::create_references instantiates both — the reference’s
ENABLE_COMPOUND_DICTIONARY, which it uses to compile the same function twice
per match finder. The prefix-enabled and ordinary loops are separately
monomorphized. The high-quality path does the same with its own
ENABLE_PREFIX (see hq-encoder.md).
Two details separate the qualities:
- Delayed search. Below quality five the delayed candidate starts from the length already found, which lets the finder reject most candidates without measuring them. Quality five gives that shortcut up and searches everything again — this is a compression-semantics difference, not a tuning flag. The flag is a run-time field of the block, read once per match. Taking it from the finder type instead was tried and reverted: it cut instructions at qualities five to nine by 0.5-1%, but single-core time did not change measurably, and as a constant in the quick finders it made quality four execute up to 5% more instructions.
- Sparse search. After sixty-four literals without a match the scan strides forward two bytes at a time and stores every second position; after four times that, four bytes at a time. The exact thresholds and stores come from the reference, because they change which positions are findable later.
extend_last_command runs before the search when the previous block ended
exactly on a command boundary: bytes that continue that command’s copy are
absorbed into it instead of starting a new one.
After an admitted extension attempt, its symbol is rebuilt from the stored
insert length and copy_len_code(). That accessor reconstructs the encoded
copy length using its signed delta or experimental absolute-length tag. It
therefore keeps the symbol consistent with the extra-bit lookup even when the
prefix path returns without absorbing input. This shared helper also serves
the high-quality encoder; its SIMD token and distance checks do not change.
5. Meta-block construction
flowchart TD
cmds["commands + ring buffer"] --> should{"ShouldCompress?"}
should -->|no| destination{"non-Flush append destination?"}
destination -->|yes| direct["seed after existing output prefix<br/>append verbatim payload directly"]
destination -->|no| lazy["reserve scratch capacity; seed carried bits"]
lazy --> raw["store_uncompressed_meta_block<br/>BitWriter over Vec; append initialized payload"]
direct --> metadata["relative bit position, carried byte, appended flag"]
should -->|yes| scratch["initialize bounded scratch; seed carried bits"]
scratch --> q{"quality splits blocks?"}
q -->|"no, q2"| fastst["store_meta_block_fast<br/>static command and distance codes<br/>below 129 commands"]
q -->|"no, q3"| trivial["store_meta_block_trivial<br/>one code per stream"]
q -->|"yes, q4 and above"| ctx["decide_over_literal_context_modeling<br/>(q5 and above)"]
ctx --> build["build_meta_block_greedy<br/>literal / command / distance splitters"]
build --> opt["optimize_histograms<br/>(RLE-friendly counts)"]
opt --> store["store_meta_block<br/>block switches, context maps, codes"]
fastst --> grew{"grew by more than four bytes?"}
trivial --> grew
store --> grew
grew -->|yes| fallback["rewind slice writer; restore remembered cache<br/>store uncompressed into initialized scratch"]
grew -->|no| done["save partial byte and positions; clear commands"]
fallback --> done
raw --> done
metadata --> done
encode_data evaluates should_compress before growing initialized output
scratch. A nonempty block selected for verbatim storage reserves payload plus
header/slack capacity and initializes only the carried prefix before using
BitWriter<Vec<u8>>. The private ByteBuffer implementation overwrites an
existing initialized overlap and appends the remaining payload safely, avoiding
a zero-fill before copying it. Retained initialized storage is reused without
clearing or truncating it. The generic uncompressed header/store helpers also
retain the slice-backed instantiation for compressed-output rollback.
Both uncompressed paths restore the four remembered distance-cache entries;
derived entries are rebuilt before the next search. The common completion path
preserves any partial byte, advances the flush/processed positions and clears
the command/literal counters. Empty finalization and flush padding retain their
existing handling. Final uncompressed payloads still append a final empty block.
For a non-Flush Destination::Append, Delivery::encode calls the private
GreedyEncoder::encode_block_append with a borrowed Vec<u8>. encode_data
receives this optional destination once per block. Known verbatim blocks write
after its existing prefix and return BlockEmission: a relative bit position,
the carried byte and an appended flag. The writer’s absolute position is checked,
and its slack is truncated after the partial byte has been saved. Capacity
includes sixteen extra bytes for the initial large-window/meta-block headers,
final empty block and word-writer slack.
The common state update sets scratch output_len to zero for directly appended
bytes, preventing a duplicate copy. Compressed attempts and their size-expansion
fallback keep the scratch writer and are appended by the entry point afterward.
Fixed/uninitialized slices, session pending output and Flush also retain scratch.
The encoder accepts a Vec without depending on the stream output types; no SIMD
dispatch or public API changes occur at this storage choice.
store_meta_block_fast is quality two’s storage — BrotliStoreMetaBlockFast.
Below a hundred and twenty-nine commands only the literal code is built from
the data; the command and distance codes are the fixed ones the format defines,
written as the fifty-nine and twenty-eight literal bits their descriptions
encode to. Above that all three codes are built, but by the leaves-ordered-by-
count builder build_and_store_huffman_tree_fast rather than the full
package-merge store_meta_block_trivial uses.
Both paths write their commands through store_data_with_huffman_codes. It narrows the literal
depth and bit tables to exactly 256 entries once per meta-block, so a literal
byte indexes them with no bounds check in the per-literal loop; the command and
distance tables keep their checked lookups because static and built codes have
different lengths. On a 285 KB binary map (mapsdatazrh) this is 3.3% fewer
instructions at q2 and 0.1% on text at q2 and q3.
ShouldCompress refuses blocks of at most two bytes outright, and samples
every thirteenth literal of a block that is almost all literals: if the sample’s
entropy exceeds 7.92 bits per byte the block is stored verbatim, which is both
smaller and much faster than coding noise.
5.1. Block splitting
The greedy splitters consume symbols in order. Every time one has collected its target number of symbols it compares the entropy of the block it just gathered against the entropy of merging it into the last, or the second-last, block:
flowchart TD
add["add_symbol"] --> full{"block_size == target?"}
full -->|no| add
full -->|yes| diff["entropy(current + last) - entropy(current) - entropy(last),<br/>and the same against the second-last"]
diff --> new{"both above the threshold<br/>and types left?"}
new -->|yes| open["open a new block type"]
new -->|no| second{"second-last better by 20?"}
second -->|yes| reuse["reuse the second-last type"]
second -->|no| merge["merge into the last block,<br/>and gather more next time"]
| Stream | Minimum block | Split threshold | Alphabet measured |
|---|---|---|---|
| literals | 512 | 400 | 256 |
| commands | 1024 | 500 | 704 |
| distances | 512 | 100 | 64 |
Quality five may run the literal splitter per context instead, keeping one histogram per context of every block type and deciding on the total entropy change across all of them.
Without contexts, build_meta_block_greedy hands each command’s literal run
to BlockSplitter::add_bytes as one slice whenever the run does not wrap the
ring (ringbuffer::contiguous, bounded by the mask rather than the buffer
length, since the tail copy past the mask is not the wrapped bytes). The
splitter cuts the run where the current block fills, so its counts and block
boundaries are those of one add_symbol per byte, and looks up the current
histogram once per piece. The previous two bytes are tracked, and re-read
after each copy, only when a context splitter is running. On the first 60 MB
of silesia.tar this is 2.9% fewer instructions at q4 and 1–2% at q5 and q6.
flowchart TD
cmd["command: insert_len literals"] --> plain{"single literal context?"}
plain -->|yes| wrap{"run inside the mask?"}
wrap -->|yes| bytes["add_bytes(slice):<br/>pieces up to the block's end"]
wrap -->|no| sym["add_symbol per masked byte"]
plain -->|no| ctx["per literal: context(prev1, prev2),<br/>ContextBlockSplitter::add_symbol"]
ctx --> prev["track prev1, prev2;<br/>re-read them after a copy"]
The combined histograms are not materialised for the decision:
bits_entropy_of_sum accumulates the summed counts in the same order
bits_entropy would, so the result is bit-identical, and a block that merges
is added into its neighbour in place. A splitter creates a block type’s
histograms when it first advances into them, so a short input never zeroes
histograms it will not fill, and a retained buffer keeps whatever the last
meta-block left in the rest until they are cleared on use.
5.2. Literal context modelling
Only quality five and above reach this, and only when the meta-block is at least sixty-four bytes long. The decision samples sixty-four byte strides every four kibibytes:
flowchart TD
start["meta-block"] --> gate{"quality >= 5, enabled,<br/>length >= 64?"}
gate -->|no| one["one context"]
gate -->|yes| complex{"size hint >= 1 MiB?"}
complex -->|yes| try13["sample 13-context map<br/>over the top five bits"]
try13 --> ok{"entropy <= 3.0 and<br/>saving >= 0.2 bits?"}
ok -->|yes| thirteen["13 contexts"]
ok -->|no| bigram
complex -->|no| bigram["sample UTF-8 prefix bigrams"]
bigram --> choose{"saving >= 0.2 bits?"}
choose -->|no| one
choose -->|yes| two["2 contexts"]
The reference’s three-context map is deliberately priced out of reach below
quality seven, and ChooseContextMode only returns CONTEXT_SIGNED at quality
ten, so CONTEXT_UTF8 is the only literal context mode these qualities can
emit. Only that one lookup table is carried.
6. The ring buffer
The layout matters for the emitted bytes, not just for correctness: match finding reads whole words past the current position, and the reference defines exactly what those bytes are.
graph LR
subgraph buffer["RingBuffer::data"]
head["2 bytes<br/>copy of the window end"]
window["1 << rb_bits bytes<br/>the sliding window"]
tail["1 << lgblock bytes<br/>copy of the window start"]
slack["7 bytes<br/>zeros"]
end
head --- window --- tail --- slack
- Until the first wrap approaches, storage grows with the written prefix and seven lookahead bytes. The configured logical window mask never changes.
- A write ending within eight bytes of the window end materializes the full layout before copying input. The last two window bytes are initialized to zero, and deferred tail copies are replayed from the existing prefix.
- The reference skips tail copying for a short first write.
tail_startrecords that skipped range, preserving its first-byte sentinel241until a later write overwrites it. - After every write the seven bytes past the data are cleared, so hashing never depends on memory the encoder did not write. Reset retains allocation capacity and clears the prefix position, allocation state and skipped-tail boundary.
stateDiagram-v2
[*] --> Empty
Empty --> Prefix: write ending before window end minus eight
Prefix --> Prefix: grow initialized prefix and lookahead
Empty --> Full: write reaches window end region
Prefix --> Full: initialize tail and replay deferred copies
Full --> Full: circular writes with immediate tail mirroring
Prefix --> Empty: reset, retain capacity
Full --> Empty: reset, retain capacity
Absolute positions are wrapped into 32 bits by wrap_position, which keeps the
first three gibibytes contiguous and then alternates between two gibibyte-wide
halves so the “already lapped” property survives the truncation.
7. SIMD dispatch
graph TD
A["Compressor::new()"] -->|"Backend::default: detect or no_std baseline"| B["Backend stored in the Compressor"]
B -->|"new encoder"| D["GreedyEncoder<INDEPENDENT> retains the Backend"]
D -->|"dispatch!(backend) once per block"| E["select concrete matcher and run once per block"]
E --> V["SearchLoop::visit<R>"]
V -->|"#[simd] search_positions: S feature context"| F["find_longest_match(simd, ...)"]
F --> G1["tag_equality(simd, ...)"]
F --> G2["match_len_at: first word, then match_len_windows (scalar words)"]
classDef once fill:#d9ead3,stroke:#38761d;
class D once;
The backend is detected once, when the Compressor is built, and the
encoder retains it. The greedy loops run at that same backend as every other
encoder kernel; the scalar backend keeps them scalar, as the unfiltered
oracle. Per block, create_references matches the backend with
fearless_simd::dispatch!, then the MatchFinder enum, and calls
create_backward_references, a #[simd] function that enters S’s feature
context; the concrete token reaches the tag filter without inner dispatch.
Whether a prefix is attached and whether the encoder serves an independent
worker (INDEPENDENT) are type-level choices made at the same point.
SearchLoop::visit specializes the matcher and run, then calls
search_positions, the #[simd] function that holds the per-position loop;
commit_match is #[simd] as well. The #[simd] attribute from
fearless_simd_macros turns the function body into an always-inlined closure
and calls it through a feature-enabled entry generated for the token’s level,
passing every argument separately rather than as one captured struct, so the
generic fearless_simd operations become native instructions. Passing a token
to an out-of-line function alone does not enable its target features: a
baseline compilation instead calls feature-enabled helpers for each vector
comparison and mask operation. Entering the context after matcher and run
specialization also keeps each search body separate, rather than forcing every
matcher into one large outer dispatch function.
The nested feature entries use the existing token; they perform no detection
or virtual dispatch. All entries are outside the search loop. The candidate
measurements accept_cached and accept_candidate stay out of line on
purpose (§2) and are #[simd] with an #[inline(always)] wrapper, so the
search loop calls the level’s feature-enabled entry directly and the 32-byte
compares inline into it; the same holds for match_len_tail and
match_len_at_current. The macro’s fearless_simd helper owns the feature proof; no
handwritten target_feature or unsafe block is needed.
Scalar and SIMD paths keep the same matcher state, candidate order, score
arithmetic, and error propagation.
Everything else — match-length scans in the greedy matchers, bucket stores, the exact check behind each cached-distance candidate, distance-cache transitions, the greedy and lazy decisions, Huffman construction, bit writing — is scalar, because the reference’s decision order is not reorderable and a vector unit cannot help without changing it.
8. The static dictionary
Brotli’s built-in dictionary is 122,784 bytes of words plus a 32,768-bucket
hash over their four-byte prefixes, carried as binary blobs beside the module
and embedded with include_bytes!.
Only the encoder side is needed. A dictionary match is emitted as an ordinary distance beyond the end of the window:
distance = max_backward + 1 + word_index + (transform_id << size_bits[len])
and the decoder is the side that applies the transform, so the transform table itself never has to be carried — the encoder only computes which transform id a given prefix cut corresponds to.
Probing is self-limiting: once a stream has gone a hundred and twenty-eight lookups per match, the encoder stops paying for it.
8.1. Prefix-length scans
Every “how many leading bytes agree” question — static dictionary words, the
HQ dictionary matcher and the attached prefix search — goes through
shared::match_len: common_prefix_len scans whole eight-byte words and
then bytes with the bounds established once, which is all a dictionary word
of at most twenty-four bytes needs, and common_prefix_len_simd adds the
native-vector loop of find_match_length for the attached prefix, whose
matches run as long as the window allows. SharedContextInner::find_match
takes the SIMD token and enters its feature context itself, so the vector
compare stays inline however it is reached.
9. Error propagation
The greedy tree defines no error type of its own. GreedyParams::new reports
an unimplemented quality as the private UnsupportedQuality, which the public
EncodeError reports as an internal invariant because no validated
configuration can reach it, and the
only other failure is BufferOverflow, raised when the bit writer runs past
the scratch buffer or its checked capacity calculation overflows. Compressed
blocks reserve 2 * bytes + 503 plus writer slack, as before; known uncompressed
scratch blocks reserve bytes + 503 plus slack and initialize the output only as
written. Direct verbatim appends reserve payload plus sixteen bytes and check
the caller prefix offset before converting it to a bit position.
Neither overflow is reachable for a valid bounded meta-block.
10. Verification
| Test target | What it pins |
|---|---|
Module tests in core::greedy::* | Each ported function against the behaviour its reference documents. |
hashers::tests::rebased_raw_scan_rebuilds_the_original_window_after_shorter_matches | Rebased/original differential checks for both hash variants and available backends: accepted lengths decrease then increase, cyclic N=16/64/128/256 scans across both representations, empty and bounded windows, and stopping before a future-position read. |
hashers::tests::young_raw_scan_rebuilds_original_window_after_shorter_matches | Young/original differential checks of the 15→6→8→20 accepted-length sequence, suffix origin/length after every candidate, unavailable word windows and high incoming scores, on both hashes and available backends. |
hashers::tests::young_bucket_scan_keeps_results_at_eligibility_boundaries | Whole cyclic young/fallback scans at aged, wrapped, unusual-mask, future-head, empty, zero-length, linear-mask and physical-window boundaries, including N16/N256 legacy controls. |
tests/greedy_qualities.rs | Byte identity with the C encoder across window size, mode, size hint, block size, distance layout, context modelling, block and delayed-symbol boundaries, dictionary matches, ring-buffer wrapping and every short length. |
tests/differential_c.rs, tests/vendor_corpus.rs, tests/randomized.rs | Byte identity over the shared corpora, including Google’s own multi-megabyte test data. |
tests/simd_backends.rs | Byte identity between the scalar fallback and every SIMD backend the host supports. |
tests/streaming.rs | Chunk-size independence and one-shot equivalence. |
tests/shared_dictionary.rs | Byte identity against the C encoder with the same prefixes prepared and attached, and a round trip through the C decoder with them attached too. |
fuzz/afl/ | q3_roundtrip, q4_roundtrip, q5_roundtrip and the shared parameter-driven targets; see fuzzing.md. |
The direct young-helper differential test exercises its arithmetic on every available backend independently of the production policy. Whole-scan tests verify the same result when the policy selects the general fallback instead.
Known gaps
- The large-window match finders are unreachable.
Window::largereaches these qualities, so a large window is declared and the widened distance alphabet is used, butResolvedWindow::encoder_bitscaps retained history at 30 bits and every matcher is sized from that. The reference’sH35,H55andH65composite match finders are selected only above that cap, so they are never built. See shared-brotli.md. - An attached prefix reaches only qualities five and above. The reference
compiles its compound-dictionary search for
H5,H6,H40,H41,H42,H55andH65only, soH2,H3,H4andH54have nowhere to put a prefix match; where the reference then ignores the dictionary, this crate refuses. Experimental custom static dictionaries use the same quality floor. - Custom static and offset mechanics are experimental. The selected UTF-8 context combination replaces the implicit built-in probe; headerless continuations poison the distance cache and shift dictionary placement without inventing history. See rfc9841-encoding.md.
- Histogram accumulation and context sampling remain scalar. Bucket tag filtering and the high-quality match-length scans have SIMD implementations; the greedy matchers scan whole words.
- Cold bucket matchers initialize their tables. The reference leaves its bucket table uninitialised; safe Rust cannot. A cold q5–q8 call either zeroes a 1–16 MiB table (from the dense limit, or after a mid-stream promotion) or stays on the sparse layout, whose dependent index load costs 10–30% on text below the promotion point. Cold q5–q8 calls on 2–32 KiB inputs still run at roughly 0.7–0.85 of the reference; reused compressors keep their tables and pay neither cost.
- Short one-shot inputs pay for initialised memory. A cold call on at most a kibibyte indexes the compact map and allocates and zeroes its scratch per call. See the benchmark guide for separate cold and reused measurements.
- Small-window chain matchers trail the reference even when reused.
Windows of at most sixteen bits send qualities five to nine through
H40/H41/H42. With a reused compressor and identical output, q5–q8 on 2–64 KiB inputs run at roughly 0.84–0.91 of the reference and q9 at 0.94–0.99 (Intel i7-13700KF, x86-64 AVX2, against C 1.1.0). Under callgrind the hop loop costs about as many instructions per hop as the reference; what is left is per-position work around it — the distance-cache probes, the query, the stores — compiled into one large function whose loops spill table pointers and limits to the stack.
Independent parallel fragments
The parallel fragment adapter builds GreedyEncoder<true> once per worker.
DictionaryStats::DISABLED keeps static-dictionary lookup disabled.
prepend_history first takes the input before the segment as already-emitted
history, the way brotli 1.0’s BrotliEncoderSetCustomDictionary did: window,
positions, prior bytes and MatchFinder::prepend, which stores each position
whose lookahead lies inside it, except the earlier periods of short-period runs
(see
parallel compression).
begin_fragment then starts headerless, fills the four remembered distances (and
saved_dist_cache) with -16 so that no short code refers to the decoder’s
inherited cache, and seeds the ring and literal context with the raw prefix.
Distances are then coded by the serial compute_distance_code, which only
returns short codes for slots the fragment’s own copies filled; the common fragment
writer owns those prefix bytes in the output. Serial kernels use false and
retain their existing behavior. See parallel compression
for reset invariants and assembly.
High-Quality Encoder Core (qualities 10 and 11)
Scope: src/compressor/core/hq/. This document describes the code as it
stands; the Known gaps section lists what is not implemented.
The reference this port follows is google/brotli v1.2.0, commit 028fb5a.
BROTLI_MAX_SIMD_QUALITY does not reach these qualities — both use the
binary-tree matcher H10 unconditionally — so there is only one semantic
profile to match, and the differential tests compare against it byte for byte.
1. Core mechanics
The greedy encoder decides one command at a time and never reconsiders. This one prices every command that could start at every position and then solves for the cheapest path through the whole block:
- Input is copied into the same ring buffer the greedy path uses.
- A binary-tree matcher reports every match at a position, ordered by increasing length, rather than a single best one.
- A cost model puts an
f32price on every literal, command symbol and distance symbol. - A dynamic program walks the block, recording at each byte the cheapest way of arriving there, and finally traces the shortest path back into commands.
- The high-quality meta-block builder re-tunes the distance alphabet, splits all three symbol streams, gathers per-context histograms and clusters them into the context maps the decoder reads.
- The result is written through the same bit-stream layer every other quality above one uses.
graph TD
subgraph driver["compressor::core::driver"]
drv["Encoder::Hq"]
end
subgraph hq["compressor::core::hq"]
enc["encoder<br/>(HqEncoder, dispatch)"]
params["params<br/>(HqParams, HqQuality)"]
h10["h10<br/>(BinaryTreeMatcher, BackwardMatch)"]
utf8["utf8<br/>(IsMostlyUTF8)"]
lit["literal_cost<br/>(per-literal estimate)"]
cost["cost<br/>(ZopfliCostModel)"]
nodes["nodes<br/>(ZopfliNode, StartPosQueue)"]
zop["zopfli<br/>(the dynamic program)"]
mb["metablock<br/>(MetaBlockBuilder)"]
bsp["block_splitter<br/>(BlockSplitter)"]
clu["cluster<br/>(histogram clustering)"]
end
subgraph shared["shared"]
rb["ringbuffer"]
cmdmod["command"]
dist["distance"]
histo["histogram"]
bitcost["bit_cost<br/>(BrotliPopulationCost)"]
bsplit["block_split<br/>(BlockSplit)"]
mbs["metablock<br/>(MetaBlockSplit)"]
bs["bitstream<br/>(MetaBlockWriter)"]
dictionary["dictionary::all_matches"]
ml["match_len"]
fmt["format<br/>(ContextMode)"]
end
drv --> enc
enc --> params
enc --> rb
enc --> zop
enc --> mb
enc --> bs
zop --> h10
zop --> cost
zop --> nodes
zop --> cmdmod
cost --> lit
lit --> utf8
params --> utf8
h10 --> dictionary
h10 --> ml
mb --> bsp
mb --> clu
mb --> histo
mb --> dist
mb --> mbs
bsp --> clu
bsp --> bsplit
clu --> bitcost
bsp --> bitcost
classDef sharedNode fill:#e8f0f6,stroke:#3a6d8a;
class rb,cmdmod,dist,histo,bitcost,bsplit,mbs,bs,dictionary,ml,fmt sharedNode;
2. What separates the two qualities
Both qualities run the same code; every difference is a number resolved once in
params.rs, from the caller’s parameters alone.
| Behavior | q10 | q11 | Reference |
|---|---|---|---|
| Matcher | H10 | H10 | ChooseHasher |
| Matches held | one position at a time | every position, up front | BrotliCreate*ZopfliBackwardReferences |
| Cost-model passes | 1 | 2 | same |
| Start positions expanded | 1 | 5 | MaxZopfliCandidates |
| Distinct copy-length cap | 150 | 325 | MaxZopfliLen |
| Short backward scan | 16 positions | 64 positions | FindAllMatches |
| Splitter refinement passes | 3 | 10 | SplitByteVector |
Default lgblock | min(18, lgwin) above 16 | same | ComputeLgBlock |
Quality eleven’s second pass is what earns most of its ratio: the first pass prices commands from a literal-cost estimate, and the second re-prices them from the histograms the first pass actually produced.
sequenceDiagram
participant E as HqEncoder
participant Z as zopfli
participant H as H10
participant M as ZopfliCostModel
Note over E,M: quality 10 — one pass, matches discovered as it goes
E->>Z: create_zopfli_backward_references
Z->>M: set_from_literal_costs
loop every position
Z->>H: find_all_matches
Z->>Z: update_nodes
end
Z->>Z: shortest path → commands
Note over E,M: quality 11 — matches precomputed, two passes
E->>Z: create_hq_zopfli_backward_references
loop every position
Z->>H: find_all_matches → match arena
end
Z->>Z: save command count, literals, distance cache
Z->>M: set_from_literal_costs
Z->>Z: iterate → first commands
Z->>Z: restore saved state
Z->>M: set_from_commands(first commands)
Z->>Z: iterate → final commands
3. The binary-tree matcher
h10.rs keeps one binary tree per hash bucket, over 1 << 17 buckets. Each
tree is ordered lexicographically by the bytes at each position and is a
max-heap by position, so one traversal both collects every match worth
considering — in strictly increasing length order — and re-roots the tree at the
current position.
The forest — two child links per window position — is sized when the first
block prepares the matcher, as the reference’s HashMemAllocInBytes sizes
it: a stream whose first block is also its last (position == 0 && is_last)
stores only positions below its length, so it gets 2 * input_size links
rather than 2 << lgwin; any other stream gets the window’s worth. The
forest only grows, and a reused matcher keeps the largest one it has needed;
the links of positions a stream never stored are never followed, so nothing
is cleared between streams. A forest that has to grow is taken from the
allocator’s zeroing path after releasing the old allocation. A non-final first
block can reserve a full window of links even for short input; untouched links
must not be eagerly materialized. Buckets are initialized with their empty marker
on first preparation. ZopfliCostModel::reserve sizes literal prices per block.
The model’s literal, command and distance histograms and its per-byte literal
price table never change length, so they are Box<[T; N]> tables built by
shared::fixed::fixed_table; a literal byte indexes them with no bounds check.
The per-byte price spread and the carried cumulative sum walk the price slice
1..=num_bytes by iterator, in the same order and with the same f32
operations as the reference.
Three bounds shape what it finds, all from the reference:
- Search depth 64. A degenerate bucket cannot cost more than that.
- Comparison length 128. Two sequences that agree over 128 bytes are indistinguishable to the tree, so the older node’s children become the new root’s.
- Re-rooting needs 128 bytes of lookahead. Near the tail the traversal still reports matches but leaves the tree alone, because the final sort order of a shorter sequence is not yet known.
flowchart TD
A["find_all_matches at cur_ix"] --> B["scan_recent_positions<br/>(16 or 64 positions, nearest first)"]
B --> B1{"candidates contiguous<br/>in the ring?"}
B1 -->|yes| B2["u8x32 compare of the two<br/>leading bytes; measure the<br/>agreeing lanes, top lane first"]
B1 -->|"no (wrap, buffer end)"| B3["byte-by-byte walk"]
B2 --> B3
B3 --> C{"best_len < max_length?"}
C -->|no| F["static dictionary"]
C -->|yes| D["store_and_find_matches"]
D --> E{"lookahead >= 128?"}
E -->|yes| E1["re-root the tree at cur_ix"]
E -->|no| E2["leave the tree unchanged"]
E1 --> F
E2 --> F
F --> G["matches, increasing length"]
The short backward scan exists because the tree indexes four-byte prefixes and therefore cannot see a two- or three-byte repeat at all. It stops above its lower bound, so the oldest position in range is never examined — a reference quirk the port reproduces.
scan_recent_positions runs that scan. A candidate whose first two bytes
disagree with the current position cannot match, so while the candidates lie
contiguously below cur_ix in the ring, their leading bytes are compared
thirty-two at a time (u8x32::simd_eq against two splats, one bitmask) and
only the agreeing lanes are measured with find_match_length. Lanes are
visited from the top, which is the nearest candidate, and the walk still ends
at the first match of three bytes or more, so the matches and their order are
exactly the reference’s. A window that wraps around the ring, or lies within
two bytes of the buffer’s end, or a span the vector loop could not cover, falls
through to the byte-by-byte walk. At quality eleven this filter removed about
150 million of the 278 million instructions the search spent on Alice, most of
it the two-byte compare of sixty-three candidates per position; byte_walk in
the tests is the transcribed reference loop, and the vector scan is compared
against it on every backend, with and without wrapping, over five corpora.
store_range sparsifies: positions older than the last sixty-three are stored
every eighth position, and only once the range spans more than 512. A range
between 63 and 575 long has its middle skipped entirely.
4. The dynamic program
nodes.rs holds one ZopfliNode per input byte. The reference packs a union
into it — cost during the forward pass, a distance shortcut once the node has
been evaluated, the next hop while the path is traced back. Rust models that as
a plain u32 read and written through f32::to_bits, which keeps the exact
aliasing without any unsafe.
stateDiagram-v2
[*] --> Stub: BrotliInitZopfliNodes
Stub: length 1, cost = infinity
Stub --> Reached: update_nodes finds a cheaper way here
Reached: cost < previous cost
Reached --> Evaluated: evaluate_node
Evaluated: cost replaced by the distance shortcut
Evaluated --> OnPath: compute_shortest_path_from_nodes
OnPath: shortcut replaced by the next hop
OnPath --> [*]: create_commands
At each position update_nodes does two things, in this order:
- Cached distances. For each of the sixteen short distance codes — the four remembered distances and the twelve near misses derived from the two freshest — it measures the match and prices every copy length it allows.
- Tree matches. From the two cheapest start positions only, because a further start with the same distances rarely pays.
The cached-distance loop is the hottest code at quality eleven: sixteen probes for each of up to five start positions at every byte, about 24 million probe iterations for Alice. It is written to keep that iteration short while making the reference’s decisions:
CACHE_PROBESpackskDistanceCacheIndexandkDistanceCacheOffsetinto one byte pair per code, so a probe is one load.- Most probes fail on one byte: the copy would have to agree with the
continuation byte at
cur_ix + best_len.ProbeSite::candidates, in the privatecompressor::core::cache_probemodule shared with the greedy matchers (greedy-encoder.md §2.4), tests all sixteen codes of one start at once. The codes are not sixteen scattered distances: codes 0 and 4–9 are the freshest distance nudged by+3..=-3, seven adjacent ring bytes, and codes 1 and 10–15 are seven more around the second freshest. Two eight-byte reads, with their eighth byte replaced by the third and fourth distances’ bytes, fill oneu8x16in lane order (LANE_CODES); onesimd_eqagainst the continuation, turned into code order by two 256-entry tables (LANE_CODE_BITS), and oneu32x16range test over the sixteen distancesderived_distancesbuilds from the cache (distance - 1 < max_distance, the scalar admission below) give the candidate mask. - The mask is a superset of what the scalar filter admits, never a
replacement for it: each admitted code still runs the exact scalar body,
including its own byte compare. A run whose seven positions wrap around the
ring mask, or whose eight-byte word would read past the bytes the ring holds,
is admitted whole (
RUN_LANES), and with an attached prefix every distance outside the window is admitted forprefix_copy_lengthto decide. When a copy extendsbest_len, the codes after it are filtered again against the new continuation byte — exactly the codes, and the order, the reference’s sequential loop tries next. Onalice29.txtthis cut quality eleven from 1.479 to 1.156 billion instructions andmapsdatazrhfrom 3.396 to 2.743 billion (callgrind, one compression), with identical output. backward.wrapping_sub(1) < max_distanceadmits exactly the distances the reference’s three tests admit —backward > dictionary_start + gap,backward <= max_distance, andprev_ix >= cur_ix— because a zero distance wraps below the bound, a negative one wraps above it, andmax_distanceis never more thandictionary_start. Everything else goes to the coldprefix_copy_length, which holds the attached-prefix arm and the gray-area skip of §11.- The ring is probed through
window = ringbuffer[..=mask], so “would the copy run past the mask” and the slice bound are oneget. The continuation byte is read once per probe rather than the reference’s compare-then-index. - The lengths a match newly reaches are priced by iterating the node slice
pos + best_len + 1 ..= pos + lenand callingZopfliNode::recordon each node, instead of indexingnodes[pos + l]per length; the tree-match loop does the same overpos + len ..= pos + max_match_len. - Admitted codes are visited lowest first by clearing the mask’s lowest set bit, with the loop state held by value.
flowchart TD
Start[Start position k: cache, base cost] --> Stop{best_len >= max_len, or no continuation byte?}
Stop -->|yes| Done[Next start or tree matches]
Stop -->|no| Mask["ProbeSite::candidates: two 8-byte runs + 2 bytes -> u8x16 eq; u32x16 range test; tables -> code order"]
Mask --> Floor[Drop codes below next_code]
Floor --> Any{Admitted code left?}
Any -->|no| Done
Any -->|lowest code| Exact[Exact scalar filter: range, byte compare or prefix arm]
Exact -->|rejected or no longer copy| Any
Exact -->|copy longer than best_len| Price[Price newly reached lengths]
Price --> Extend[best_len = len; next_code = code + 1]
Extend --> Stop
At quality eleven, 44–57% of the start positions probed at a byte carry a
distance cache equal to an earlier start’s at the same byte: starts reached
through literals inherit the same four distances. The probes read only the
cache and the input, so such a start finds exactly the copies the earlier one
found; only the prices differ, because they start from a different cost.
update_nodes therefore keeps a ProbeMemo in the start-position queue: per
start, its cache and its hits (code, the best_len before the hit, the length
found), in probe order. A start whose cache matches an earlier slot replays
those hits through the same pricing, in the same order and with the same
f32 arithmetic, without calling ProbeSite::candidates or measuring a
match. That cut quality eleven by 6–11% of instructions and 3–6% of time.
Quality ten probes one start per byte, so the replay is compiled out there
(update_nodes’s MEMO parameter is false on the
zopfli_compute_shortest_path path).
flowchart TD
K["start k at this byte"] --> Same{"cache equal to slot j < k?"}
Same -->|yes| Replay["price slot j's hits (code, best_len, len) in order"]
Same -->|no| Probe["probe as above; append each hit to slot k"]
Replay --> Next["k >= 2: next start; else tree matches"]
Probe --> Next
ZopfliCostModelrearranges command-symbol prices into rows indexed by insert code, last-distance policy, and copy code whenever either initializer rebuilds a model for at least 128 bytes. Only insert codes reachable within the block are rebuilt; all 24 copy codes are covered, including dictionary length codes. Rows follow the 704 symbol prices in the same vector, whose capacity is retained between blocks. Symbol pricing and minimum-cost reduction use only the original prefix. A shorter block truncates the vector to that prefix and uses one 32-slot stack row, filled when a useful match needs prices. This avoids growing the price allocation for cold small inputs. Failed cached probes and tree matches with no new copy lengths do not request a row. The copy-length loop borrows the selected row, eliminating repeated command-prefix construction. Rows have 32 slots to expose the five-bit copy-code bound to the optimizer; the dynamic program reads codes below 24.
flowchart LR
Literal[Literal priors] --> Symbols[Command symbol prices]
Histogram[Previous-pass command histogram] --> Symbols
Symbols --> Size{Block has at least 128 bytes?}
Size -->|yes| Rows[Prepare reachable insert-code rows after symbol prices]
Size -->|no| Small[Keep symbol prefix; retain capacity]
Small --> Match[Useful match fills one stack row]
Rows --> Start[Borrow row outside copy-length loop]
Match --> Start
Start --> Length[Copy loop reads exact price by length code]
Length --> Compare[Original f32 addition order and strict comparison]
Compare --> Record[Record cheaper node]
Two shortcuts keep it tractable. compute_minimum_copy_length refuses to price
a copy shorter than one already known to reach its destination more cheaply.
And a copy longer than BROTLI_LONG_COPY_QUICK_STEP lets the search stride past
the positions it covers, evaluating them but not searching them.
StartPosQueue keeps the eight cheapest command starts, sorted. It is a ring
whose insertion shifts the cheaper prefix and stops at the first candidate with
an equal or higher cost difference. The new entry precedes equal-cost entries,
as in the reference’s strict adjacent-swap comparisons. Starts have finite
cost differences. The untouched suffix is already sorted, so this produces the
same ordering and eviction as visiting every adjacent pair, including after the
ring wraps. A differential test compares every rank and distance cache after
4096 insertions with ties and extreme finite costs.
5. Numerical determinism
Every comparison in the dynamic program is a strict < on an f32, so the
arithmetic is part of the output, not an implementation detail:
-
Rearranged command prices copy the original
f32bits. No distance or extra-bit price is folded into them: additions keep their original order. The implicit last-distance command condition remains exactlycode == 0, insert code below eight, and copy code below sixteen. Tests compare every legal length-code pair across both model initializers. -
FastLog2returns adoublein the reference and is narrowed tof32at each use, so the intermediate is computed at full width and rounded once. -
The cumulative literal costs are built with an explicit carry that recovers the precision a running
f32sum would throw away. Over a long block a naive sum drifts low by orders of magnitude more than the carried one, and the search starts preferring literals it should not. -
The literal-cost estimator’s constants — window widths 495 and 2000, the additive nudges, the halving below one bit, the prologue surcharge — were tuned by the reference against its corpora and are reproduced exactly.
-
The estimator reads every byte of a block through one contiguous slice (
contiguous_block): the block itself when it does not wrap the ring buffer, which is every block of a one-shot stream, and a copy in theLiteralCostArenaotherwise. Every read lies insidepos..pos + len, so the mask is applied in one place and the sliding-window loops index a plain slice. -
Both estimator models take two logarithms per byte, of the in-window count and of one symbol’s count. A window holds at most
2 * 2000bytes, so every such count is belowLOG2_WINDOW_END(4001). Counts below 256 read the sharedLOG2_TABLE; counts from 256 read aBox<[f64; 3745]>tail thatLiteralCostArenabuilds the first time it prices a block of at least 256 bytes, since a shorter block cannot hold a larger count. The tail is collected fromshared::fast_log::log2, the library logarithmfast_log2itself falls back to past its table, so no entry goes through the table check. Both lengths are in the types, so the bound checks compare against constants. A missing tail, or a count past it, computeslog2instead. Every path returns the samef64the library call would, so nothing rounds differently; tests compare the tail bit for bit withfast_log2and the priced blocks with and without it. The arena keeps the tail across blocks and streams and counts it in its retained bytes.
flowchart TD
Block["estimate_bit_costs_for_literals(len)"] --> Long{"len >= 256?"}
Long -- yes --> Build["build the tail once<br/>(3745 log2 calls, collected)"]
Long -- no --> Keep["keep whatever tail exists"]
Build --> Loop["per-byte loop: log2(in_window) - log2(histo)"]
Keep --> Loop
Loop --> Small{"count < 256?"}
Small -- yes --> Table["LOG2_TABLE[count]"]
Small -- no --> Tail{"tail built and<br/>count < 4001?"}
Tail -- yes --> TailRead["tail[count - 256]"]
Tail -- no --> Compute["log2(count)"]
is_mostly_utf8 scans a contiguous run in place, counting eight ASCII
bytes at a time when a word has no high bit and no NUL — each parses as a
one-byte sequence — and falls back to the per-byte masked decoder only for
a run that wraps. The reference’s size_utf8 > 0.75 * length is exact in
an f64, so it becomes an integer target, the next integer above the
product, and either scan stops as soon as the count reaches it or an invalid
byte leaves too few bytes for it to be reached. Binary input settles after
about a quarter of the run: mapsdatazrh q10 -1.3% instructions.
6. The high-quality meta-block
Three things happen here that the greedy builder does not do.
The distance alphabet is re-chosen per block. Every legal combination of postfix bits and direct codes is priced by building the distance histogram it would produce; the search walks postfix bits outward and direct-code counts upward, carrying an awkward index between rows exactly as the reference does. The commands’ distance prefixes are then rewritten in place.
Literal histograms are gathered per block type and per context — sixty-four of them per type — rather than through a fixed static map.
Everything is clustered. cluster.rs repeatedly merges the histogram pair
whose combination saves the most bits, until no merge pays or the format’s limit
of 256 is reached. The candidate pairs live in a bounded array with the best one
first, which the reference calls a heap and treats as one only at that first
position; reproducing that — including which pair is displaced when the array is
full — is what keeps the merge order, and therefore the context map, identical.
No histogram is copied to be read. A candidate pair’s combined cost is
priced from Histogram::set_sum, which writes left + right into the scratch
histogram in one pass, where the reference clones one side and adds the other.
A merge adds the absorbed histogram into the survivor through split_at_mut,
since pairs are always ordered idx1 < idx2, and remapping adds every input
histogram into its cluster straight from the borrowed input.
HistogramPair is #[repr(C)], laid out in declaration order as the
reference’s struct is, because the queue compaction after a merge copies whole
pairs. population_cost finds the first five used symbols, which decide its
closed forms, through first_used_symbols: it skips each block of sixteen
unused counts with one combined test instead of a branch per symbol, which
matters for the sparse histograms clustering prices.
flowchart LR
Pair["candidate pair idx1 < idx2"] --> Sum["tmp.set_sum(out[idx1], out[idx2])"]
Sum --> Cost["population_cost(tmp)"]
Cost --> Best{"best saving?"}
Best -->|merge| Split["out.split_at_mut(idx2)"]
Split --> Add["out[idx1].add_histogram(&out[idx2])"]
flowchart TD
A["commands"] --> B["choose_distance_params<br/>price every (npostfix, ndirect)"]
B --> C["recompute_distance_prefixes"]
C --> D["BlockSplitter::split<br/>literals, commands, distances"]
D --> E["build_histograms_with_context"]
E --> F["cluster literals → literal_context_map"]
E --> G["cluster distances → distance_context_map"]
F --> H["MetaBlockSplit"]
G --> H
D --> H
H --> I["optimize_histograms"]
I --> J["MetaBlockWriter::store_meta_block"]
7. The block splitter
block_splitter.rs sees a whole symbol stream at once. It seeds a handful of
entropy codes from pseudo-random samples, refines them with more samples, then
solves for the cheapest assignment of codes to symbols by dynamic programming,
repeats that a few times, and finally clusters the resulting blocks down to at
most 256 types.
The sampling generator is seeded at seven and multiplied by 16807. The sequence decides which stretches of the stream seed the entropy codes, so it is part of the output: change it and a different partition falls out.
Switching block type is discounted over the first 2000 symbols, which lets the partition adapt quickly before the statistics settle.
Each split stream keeps a SplitArena. Buffers whose length follows the
stream (histograms, costs, block ids and lengths, cluster lists) are retained
Vecs; the four per-batch tables of the 64-block pre-clustering (sizes,
new_clusters, symbols, remap) always hold exactly 64 entries, so they are
inline [u32; 64] arrays zeroed per split. The block ids, insertion costs,
running costs, switch bitmap and id remap are resized to the stream without a
preceding clear(): find_blocks and remap_block_ids write every entry they
later read, so a kept buffer is not zeroed again (the switch bitmap, up to
thirteen bytes per symbol, was previously zeroed twice per split). Refinement adds each sample
histogram into its target directly. Insertion costs are built row by row: the
log2 totals of at most 100 histograms go to a stack array, each symbol’s row
is written from it, and its padding lanes are zeroed explicitly.
Block lengths come from chunk_by over the assigned ids, whose run count is the
block count the trace-back produced, and per-block histograms are counted with
add_vector over each block’s slice. Together with clustering without copies,
q10/q11 compression runs 0.7-2.3% fewer instructions (callgrind, alice29.txt,
mapsdatazrh, 1 KiB and 64 KiB random inputs), with byte-identical output.
flowchart TD
Ids["block ids after find_blocks + remap"] --> Runs["chunk_by equal ids → block_lengths"]
Runs --> Batch["64-block batch: add_vector per block"]
Batch --> Scratch["inline [u32; 64] sizes / new_clusters / symbols / remap"]
Scratch --> Combine["combine_batch"]
Combine --> Final["combine surviving clusters"]
Final --> Assign["assign: add_vector per block, move_cost per cluster"]
8. Context mode
Quality ten is the first quality that considers anything but the UTF-8 literal
context model. ChooseContextMode runs BrotliIsMostlyUTF8 over the whole
pending meta-block; data that does not look like text is modelled as signed
integers instead. The chosen mode reaches both the histogram builder and the bit
writer, which stores its two-bit code once per literal block type.
Copy-length pricing uses the shared compile-time class table for lengths 10 through 2117. Short lengths retain direct subtraction and larger lengths use class 23. This preserves the reference classes and floating-point addition order in the Zopfli cost loop.
9. SIMD dispatch
HqEncoder retains the Backend detected when the Compressor was built.
Stitching, block assignment and Zopfli reference search each match it with
fearless_simd::dispatch! at their block-level call and call #[simd] functions
(fearless_simd_macros): stitch_to_previous_block, assign_blocks,
create_zopfli_backward_references and create_hq_zopfli_backward_references.
Each inner search is monomorphized on that concrete token and on whether a
prefix is attached (§11); no block reselects the backend. update_nodes,
BinaryTreeMatcher::find_all_matches and BinaryTreeMatcher::store are
#[simd] as well, allowing comparisons to inline within these separately
compiled functions.
#[simd] makes the body an always-inlined closure and calls it through a
feature-enabled entry generated for the token’s level, with each argument
passed separately. When LLVM keeps such a function out of line — store runs
once per position and is not inlined into the Zopfli loop — the call crosses
from a baseline function into the feature-enabled entry at every position.
Simd::vectorize passed the captured arguments there as one struct behind a
pointer; the macro passes them in registers.
sequenceDiagram
participant Driver as Zopfli loop (S features)
participant Store as BinaryTreeMatcher::store (baseline)
participant Entry as generated entry (S features)
Driver->>Store: store(simd, data, mask, ix)
Store->>Entry: token level folds to S, self, data, mask, ix as arguments
Entry->>Entry: inlined body: store_and_find_matches
Entry-->>Driver: return
``` The tree traversal is always inlined into
`find_all_matches` or the feature-enabled `store` body, and `scan_recent_positions`
takes the same token for its `u8x32` prefilter, which every backend implements
(the fallback lane by lane). `update_nodes` uses the token the same way for its
`u8x16`/`u32x16` distance-cache prefilter (§4), inlined into its body.
`dictionary::all_matches::find_all` is `#[simd]` too: `find_all_matches`
crosses into its feature entry once per position, and its four word families
are inlined there, so each candidate word — at most 24 bytes — is compared
with one `u8x32` `simd_eq` and the mask's trailing ones. The case transforms
flip the uppercase bit of the word's ASCII lowercase letters (all of them, or
lane 0 alone, which must also be lowercase) before comparing. Fewer than 32
readable bytes on either side — the dictionary's last words, the end of the
input — fall back to the scalar comparison. Quality ten on text: -5.6 to -5.8%
instructions (`alice29.txt`, `lcet10.txt`), quality eleven -2.5%. Copy extension dispatches the retained
backend into `extend_last_command`, as described in
[encoder workspace](encoder-workspace.md).
```mermaid
graph LR
selected[retained Backend: dispatch! per call] --> stitch[boundary stitching]
selected --> search[Zopfli search]
selected --> assignment[SIMD block-assignment forward pass]
search --> arena[flat retained match arena]
prefix[retained prefix scratch] --> merge[backwards in-place merge]
arena --> merge
merge --> costs[retained costs and nodes]
costs --> mb[retained MetaBlockSplit and builder]
mb --> assignment
mb --> writer[retained entropy-code arrays]
Prefix matches merge backwards into the flat arena tail. Earlier positions
remain untouched; equal tree matches precede equal prefix matches, preserving
dictionary length codes. No temporary merged vector or split_off allocation
is needed. Meta-block splits, histogram clusters, entropy-code arrays and cost
histograms survive reset and are included in retained-byte accounting.
Alongside shared find_match_length, the block splitter vectorizes its forward
assignment pass. MetaBlockBuilder::build, BlockSplitter::split, and
find_blocks take the retained Backend; find_blocks dispatches it. Once insertion costs are
prepared, one assign_blocks call enters a feature-enabled loop over the entire
symbol stream. No backend detection or dynamic dispatch occurs per symbol.
The borrowed BlockCosts holds f64 insertion costs, mutable accumulated
costs, switch bits, and block ids; histogram counts fit in a byte and each symbol
addresses a complete row. find_blocks lays rows out at
padded_histograms(n), the histogram count rounded up to whole eight-lane
vectors (COST_LANES), and the retained arena is sized for the first, largest
count of a split. Padding lanes carry a zero price and a NaN cost: NaN < x
and NaN >= x are both false, so a padding lane never becomes the minimum,
never sets a switch bit and stays NaN, and the scalar oracle over the same
padded rows gives the same answer. The switch bitmap keeps ceil(n / 8) bytes
per symbol, which the padded width fills exactly; traceback only reads bits of
real ids.
Eight histogram lanes update independently with fearless_simd::f64x8. Each
lane keeps its first strict minimum and that histogram’s id, carried as an
exact small integer in a second f64x8 (ids are below 256; u64 lanes have no
vector minimum before AVX-512 and fearless_simd lowers them lane by lane).
After the row, reduce_min gives the minimum cost, and a masked reduce_min
over the ids of the lanes equal to it gives the smallest tied id — the
scalar scan’s tie order, including ties whose smaller id sits in a higher lane
of a later chunk. Unclaimed lanes hold NO_HISTOGRAM (+∞), which converts to
the u64::MAX id the kernel reports when nothing undercut the 1e99 start.
Subtraction and clamping retain the scalar arithmetic order, including the
first 2000 symbols’ switch-cost adjustment. Each eight-lane comparison supplies
one switch bitmap byte. A row that is not a whole number of vectors (only unit
tests pass one) finishes with scalar operations. The fallback backend executes
the original scalar pass. Traceback and clustering stay scalar. No public API
is introduced. Differential tests compare cost bits, ids, and switch bitmaps on
every available backend, including ties, cross-chunk ties, NaN padding, tails,
empty streams, and the prologue boundary; full encoder tests also check C
output identity.
flowchart TD
Row["symbol row: padded_histograms(n) lanes"] --> Chunk["per f64x8 chunk:<br/>cost += price, store"]
Chunk --> Lt{"cost < lane minimum?<br/>(false for NaN padding)"}
Lt -->|yes| Keep["lane minimum = cost<br/>lane id = chunk id"]
Lt -->|no| Next[next chunk]
Keep --> Next
Next --> Chunk
Next -->|row done| Min["min_cost = minima.reduce_min()"]
Min --> Id["best_id = select(minima == min_cost, ids, +inf).reduce_min()"]
Id --> Sub["per chunk: delta = cost - min_cost<br/>delta >= switch cost → clamp, set bit"]
Sub --> Out["block_id[byte] = best_id"]
Copy-length codes below 2118 use a compile-time table derived from the format’s copy-length bases. Lengths below ten retain their direct subtraction, and longer lengths retain the final unbounded code. This removes logarithms from repeated Zopfli length pricing without changing the order of floating-point cost sums.
flowchart LR
Length[Candidate copy length] --> Small{Below ten?}
Small -->|yes| Direct[Length minus two]
Small -->|no| Bounded{Below 2118?}
Bounded -->|yes| Table[Compile-time copy-code table]
Bounded -->|no| Final[Code 23]
Direct --> Price[Existing command and distance pricing]
Table --> Price
Final --> Price
10. Verification
| Layer | How it is checked |
|---|---|
| Static dictionary all-match search | Every word × every transform × every prefix, plus 3000 random inputs and a sliding window over alice29.txt, compared against BrotliFindAllStaticDictionaryMatches through the workspace shim, each on the host’s SIMD level and the scalar fallback and again padded past max_length so the u8x32 comparison engages |
| Distance-cache prefilter | cache_probe module tests: lane tables against the probe table, derived_distances against prepare_distance_cache, the candidate mask as a superset of a scalar oracle over 20 000 random sites per level (short rings, wraps, extreme distances), exactness on contiguous runs, forced runs |
H10 | Brute-force oracle over 4000 positions: every reported match is real, lengths strictly increase, and the longest is at least what a full scan finds |
| Zopfli search | Command stream, distance cache, literal count and trailing literals compared against BrotliCreate{,Hq}ZopfliBackwardReferences through the shim, over nine fixtures and 343 prefixes |
| Block splitter | All three partitions compared against BrotliSplitBlock, six fixtures × both qualities |
| Meta-block builder | Distance alphabet, three splits, histogram counts and both context maps compared against BrotliBuildMetaBlock, five fixtures × two qualities × two context modes × modelling on and off |
| Whole encoder | Byte-for-byte against BrotliEncoderCompress over the structural, boundary and vendor corpora at every window size |
The shims live in brotli-ffi/shim/, outside vendor/, and expose four
encoder-internal functions that have no public header. They exist only so the
port can be compared against the reference it was translated from.
10.1. Input sizes in the integration sweeps
These qualities are expensive: in the debug build the tests run in, quality eleven costs about a second per hundred and fifty kilobytes, against a hundredth of that at quality nine. Left unbounded, the sweeps that run every quality over every corpus at every window size take tens of minutes.
support::prefix_for therefore caps qualities ten and eleven at 64 KiB in the
sweeps that exist to cover shapes — differential_c, randomized,
roundtrip, simd_backends and the per-corpus vendor tests. The cap is under
their 256 KiB default block, so those runs exercise a single block only.
Large-input coverage moves to the tests built for it, which run these qualities unbounded:
vendor_corpus::multi_fragment_input_matches_the_c_encoder— a 2 MiB prefix ofbb.binast, eight blocks at their default, at three window sizes.streaming.rs— chunk-boundary independence and one-shot equivalence.
The tradeoff is deliberate and worth naming: a regression that needs both a large input and one of the capped sweeps’ shapes would be missed.
11. The attached prefix
An attached RFC 9841 prefix reaches these qualities in three places, all of them the reference’s:
- Collection. After the binary tree has produced a position’s matches,
SharedContextInner::find_all_matchescollects up to sixty-four more from the attachments, from a minimum length of three, andmerge_prefix_matchesmerges the two ascending-by-length sequences into one —MergeMatches. The dynamic program then prices them together, indistinguishably. - Cached distances.
update_nodesgains the branch that the reference’s comment calls the way out of the “gray area”: a cached distance pastmax_distancebut at mostdictionary_startis unusable, while one pastdictionary_startaddresses the prefix and is measured against it. That branch lives inprefix_copy_length, the cold arm the probe loop takes for every distance outside1..=max_distance. Without an attachmentgapis zero, the prefix path is unreachable, and the arm only skips. - Coding.
gapshiftsdictionary_starteverywhere a distance is classified, socreate_commandsmarks a prefix reference as a dictionary reference and leaves the distance cache alone, andevaluate_node’s shortcut chain skips it for the same reason.
flowchart TD
pos["position"] --> tree["BinaryTreeMatcher::find_all_matches<br/>dictionary_start + gap"]
pos --> pre{"context attached?"}
pre -->|no| prices
pre -->|yes| lookup["SharedContextInner::find_all_matches<br/>min length 3, at most 64, dictionary_start"]
tree --> merge
lookup --> merge["merge_prefix_matches<br/>ascending by length, then distance"]
merge --> prices["update_nodes prices every candidate"]
prices --> cache{"cached distance?"}
cache -->|"<= max_distance"| ring["measure against the ring buffer"]
cache -->|"> dictionary_start"| dict["measure against the prefix"]
cache -->|otherwise| grey["gray area: skip"]
The match itself stops at the end of the attachment it was found in, which is
the reference’s limit; only extend_last_command runs on across seams.
Whether a prefix is attached is a type-level choice, as on the greedy path.
HqEncoder::create_references dispatches the backend, matches on the quality
and on attached.is_some(), and calls
create_zopfli_backward_references or create_hq_zopfli_backward_references
with const ENABLE_PREFIX: bool. The parameter reaches
zopfli_compute_shortest_path, zopfli_iterate and update_nodes. With it
false, the entry points replace attached with a constant None, so gap is
a constant zero and every prefix test folds away, and the probe loop in
update_nodes loses its prefix_copy_length arm. On inputs without a prefix
this cut executed instructions by 0.1-2.6% at quality ten and 0.2-2.9% at
quality eleven, with identical output.
flowchart LR
hq["create_references: dispatch!(backend)"] --> q{"quality, attached?"}
q -->|"Q10, none"| a["create_zopfli_backward_references::<S, false, I>"]
q -->|"Q10, some"| b["create_zopfli_backward_references::<S, true, I>"]
q -->|"Q11, none"| c["create_hq_zopfli_backward_references::<S, false, I>"]
q -->|"Q11, some"| d["create_hq_zopfli_backward_references::<S, true, I>"]
Known gaps
- Large-window history stops at 30 bits.
Window::largereaches these qualities andDistanceParams::for_windowcomputes the widened RFC 9841 alphabet and itsalphabet_size_limit, but retained history is capped at 30 bits byResolvedWindow::encoder_bits, so the binary tree never indexes a window wider than that. See shared-brotli.md. - Custom static dictionaries and offsets are experimental. The immutable flat index merges per-length transformed candidates into Zopfli matches using the current meta-block literal context. Extended packed length modifiers keep long transforms’ base lengths intact. Headerless continuations shift logical dictionary placement but not history availability. See rfc9841-encoding.md for both flows and their limits.
- Histogram merging runs at the baseline instruction set.
BlockSplitter::splitand the histogram helpers it calls (Histogram::add_histogram, block histogram rebuilds) sit outside every#[simd]region, so on x86-64 their autovectorized loops use SSE2xmmregisters even on AVX2 hosts (about 1.6% of quality 10 onmapsdatazrh). Only the block-assignment pass enters the selected backend. Building the whole program forx86-64-v3sped qualities 10 and 11 up by at most 2% (i7-13700KF, 2026-10-08), so moving them is not pursued. hotpathinstrumentation. Encoder block operations, Zopfli search passes, literal-cost estimation, andfind_blockshave timing hooks. Individual per-candidate updates and clustering do not have separate timing hooks; sampling or instruction profiling is needed to resolve their costs.
Independent parallel fragments
The parallel fragment adapter builds HqEncoder<true> once per worker.
The specialized Zopfli path excludes cache-relative distance edges and dictionary
candidates. begin_fragment starts headerless and seeds the ring and literal
context from the common raw prefix. The serial specialization retains both
search mechanisms. See parallel compression for reset
ownership, alignment checks, and current cancellation limitations.
Owned sessions
EncoderSessionOwned and DecoderSessionOwned are the owning counterparts of
EncoderSession and DecoderSession. FramedEncoderSessionOwned and
FramedDecoderSessionOwned (behind experimental) do the same for the framed
sessions; see Framed sessions. A borrowed session holds &mut to its
codec and borrows its dictionary; an owned session takes the Compressor or
Decompressor by value and, when attached, the dictionary too, so it has no
lifetime parameter. Both shapes run one shared state machine per codec. Only
ownership differs: the bytes, progress counts, statuses, errors and cleanup are
the same. Owned sessions are synchronous and available wherever the codec is,
including no_std with alloc. They let wrappers such as async adapters store
codec state as a plain value. No async API or dependency is involved.
Types and ownership
classDiagram
class EncoderSession~'c, 'd~ {
core: SessionCore
}
class SessionCore~'c, 'd~ {
compressor: &mut Compressor
dictionary: Option~&PreparedDictionary~
operation: OperationState
}
class EncoderSessionOwned~D = PreparedDictionary~ {
core: OwnedSessionCore~D~
}
class OwnedSessionCore~D~ {
compressor: Compressor
dictionary: Option~D~
operation: OperationState
}
class EncOperationState["compressor::core::session::OperationState"] {
process(compressor, dictionary, input, output, op)
process_uninit(compressor, dictionary, input, output, op)
is_finished(compressor)
release(compressor)
}
EncoderSession --> SessionCore
EncoderSessionOwned --> OwnedSessionCore
SessionCore --> EncOperationState
OwnedSessionCore --> EncOperationState
class DecoderSession~'d, 'dict~ {
decoder: &mut Decompressor
dictionary: Option~DictionaryRef~
operation: OperationState
}
class DecoderSessionOwned~D = DecodeDictionary~ {
decoder: Decompressor
dictionary: Option~D~
operation: OperationState
}
class DecOperationState["decompressor::core::session::OperationState"] {
start(decoder, stream)
process(decoder, dictionary, input, output, op, collect)
process_uninit(decoder, dictionary, input, output, op)
release(decoder)
}
DecoderSession --> DecOperationState
DecoderSessionOwned --> DecOperationState
- Neither
OperationStateholds its codec or dictionary. Every call receives both. That is what lets a borrowed wrapper and an owned wrapper share one implementation. - The decoder refactor. The decoder’s per-operation fields used to sit
directly in
DecoderSession: counters, window, final-input latch, boundary, finished and failed flags. They moved intodecompressor::core::session::OperationState, andDecoderSessionbecame a thin wrapper over it. The encoder already had this split. - Owned dictionaries. The encoder takes any
D: AsRef<PreparedDictionary> + 'staticand the decoder anyD: AsRef<DecodeDictionary> + 'static. Both dictionary types implementAsRef<Self>, so a dictionary can be passed by value, as anArc, or as a&'staticreference. The decoder bound namesDecodeDictionarybecausePreparedDictionarydoes not exist in decode-only builds. - The default type parameter.
Ddefaults to the dictionary type itself.into_sessionstoresNone, so the default is never constructed. - No
Dropimpl on owned sessions. Dropping one drops its codec with it. WithoutDrop,into_compressorandinto_decompressorcan destructure the session in safe Rust. - Auto traits. Owned sessions are
Sendexactly whenDis. They add noSyncbound of their own.
Construction
sequenceDiagram
participant Caller
participant Compressor
participant Decompressor
Caller->>Compressor: into_session(stream) / into_session_with_dictionary(D, stream)
Compressor->>Compressor: begin(dictionary.as_ref(), stream) — same path as start
Compressor-->>Caller: EncoderSessionOwned<D>
Caller->>Decompressor: into_session(stream) / into_session_with_dictionary(D, stream)
Decompressor->>Decompressor: OperationState::start — same path as start
Decompressor-->>Caller: DecoderSessionOwned<D>
Owned constructors run the same validation as the borrowed ones:
- Encoder.
Compressor::beginchecks for an abandoned session, the stream offset, dictionary support at the configured quality, the stream position, and workspace acquisition. - Decoder.
OperationState::startchecks for an abandoned session and an exact output size over the output budget. It also releases a workspace that is over the workspace budget, then resets it.
On error, the consumed codec is dropped together with the error. Callers that
need to keep the codec after a rejected start should use the borrowed start.
Release
flowchart TD
Borrowed[drop EncoderSession / DecoderSession] --> Release
Owned[into_compressor / into_decompressor] --> Release
Release[OperationState::release] --> E{encoder?}
E -->|yes| EncRelease[invalidate unfinished encoder, clear staging and pending, active = false, retention trim]
E -->|no| DecRelease[active = false, reset workspace, retention trim]
EncRelease --> Ready[codec ready for the next operation]
DecRelease --> Ready
DropOwned[drop owned session] --> Gone[codec dropped with the session]
Each codec has exactly one release path. Borrowed sessions run it from Drop,
and owned sessions run it when they hand the codec back. It applies to every
exit state: finished, unfinished, flushed, or failed.
Lifecycle
stateDiagram-v2
[*] --> Owned: into_session*
Owned --> Owned: process (Process / Flush / Finish)
Owned --> Terminal: Finished or failure
Terminal --> Terminal: process (idempotent Finished / InvalidState)
Owned --> Returned: into_compressor / into_decompressor
Terminal --> Returned: into_compressor / into_decompressor
Owned --> Owned: reinit (release + begin)
Terminal --> Owned: reinit (release + begin)
Owned --> Terminal: reinit rejected (poisoned)
Owned --> [*]: drop (codec dropped)
Terminal --> [*]: drop (codec dropped)
Returned --> [*]
Inside process, the transitions are the ones specified for the borrowed
sessions in compressor and
native decompressor.
Invariants
- One state machine per codec. Owned
processis a one-line delegation to the sameOperationState::processthe borrowed session calls. - Same dictionary on every call. The decoder state passes the dictionary to
Stream::runper call. Each wrapper always passes the dictionary it started with. - Safe Rust throughout. No
unsafe, raw pointers,Pinor self-references. The only extra storage is the ownedDitself. - No hot-path changes. Encoder and decoder algorithms, SIMD dispatch and output bytes are unchanged.
Input-free flush and finish
Every session, borrowed or owned, plus FramedResourceSession, has
flush(output) and finish(output). Each is a one-line call to that type’s own
process, so it inherits every contract of process unchanged.
| Session | flush(output) | finish(output) |
|---|---|---|
EncoderSession, EncoderSessionOwned | process(&[], output, Operation::Flush) | process(&[], output, Operation::Finish) |
FramedResourceSession | process(&[], output, Operation::Flush) | process(&[], output, Operation::Finish) |
FramedEncoderSession, FramedEncoderSessionOwned | process(output, FramedEncodeOperation::Process): drain queued wire bytes | process(output, FramedEncodeOperation::Finish) |
DecoderSession, DecoderSessionOwned | process(&[], output, DecodeOperation::Process): deliver output for accepted input, no EOF | process(&[], output, DecodeOperation::Finish): EOF with an empty suffix |
FramedDecoderSession, FramedDecoderSessionOwned | as the raw decoder; events are still lent | as the raw decoder |
- Decoders have no
Flushoperation. Theirflushonly drains output for input that has already been accepted. A stored payload is accepted only when there is room to deliver it, so after aNeedsOutputit may have nothing to drain. - The final-suffix contract still applies after
Finish. Once a decoder has seenFinish,flushreportsInvalidState, because it would change the operation.finishis correct only once every input byte has been accepted: stopping mid-member reports truncation, and leaving a suffix offered to an earlierFinishunconsumed reportsInvalidState. - Tests. Each test file drives the same schedule twice, once through
processwith empty input and once through the shorthands, on both the borrowed and the owned session, and requires identical traces. It also checks the idempotentFinishedstate and theFinishcontract errors above.
Reinitialization
All four owned sessions have a reinit(&mut self, stream) method. It ends the
current operation and starts an independent one in place, keeping the owner,
its backend, its retained storage (subject to retention policy), and the owned
dictionary or resolver.
flowchart TD
Call["reinit(stream)"] --> Release["release the current operation:\nraw OperationState::release / framed cancel"]
Release --> Begin["the shared begin path:\nCompressor::begin / decoder OperationState::start /\nframed begin_session"]
Begin -->|Ok| Fresh[replace the stream state with a fresh one]
Begin -->|Err| Poison["poison: raw phase Failed / decoder failed flag /\nframed engine failed or Phase::Failed"]
Poison --> Refuse[process and commands return InvalidState]
Refuse --> Call
Fresh --> Ready[next operation is identical to a fresh borrowed session]
- The same paths as drop-then-start. Release is the path a borrowed
session’s
Dropruns, and the begin step is the onestartandinto_sessionuse. Soreinitbehaves exactly like dropping a borrowed session and starting a new one, whether the previous operation finished, was waiting for input or output, was just past an event, or failed. - A rejected start leaves the session failed. The previous operation has
already been released, so the only state left to mark is the failure.
Nothing else runs until a later
reinitsucceeds. The owner can still be handed back, since release is idempotent. - The dictionary is kept.
reinitrestarts with the session’s own dictionary or resolver. Changing it goes through the consuminginto_*methods: hand the owner back, then start a new owned session.
Framed sessions
Framed sessions keep all operation state in the owner, in its private Engine.
So the owned framed sessions hold the owner itself and add no operation state.
graph TD
FC[FramedCompressor: raw Compressor + Engine] --> BE[FramedEncoderSession: &mut owner, Drop cancels]
FC --> OE[FramedEncoderSessionOwned: owner by value, no Drop]
BE --> Helpers[shared private owner helpers: session_metadata, open_resource, open_shared_resource]
OE --> Helpers
BE --> EEngine[framing core Engine]
OE --> EEngine
Helpers --> EEngine
BE --> Guard[FramedResourceSession: &mut FramedCompressor]
OE --> Guard
FD[FramedDecompressor: Engine + Backend] --> BD[FramedDecoderSession: &mut owner + DictionaryResolverRef, Drop cancels]
FD --> OD["FramedDecoderSessionOwned<R = NoDictionaries>: owner + Option<R>, no Drop"]
BD --> DHelpers[shared private owner helpers: session_step, session_process]
OD --> DHelpers
DHelpers --> DEngine[framed decoder Engine::process and event]
- Start.
FramedCompressor::startandinto_sessionboth go through the privatebegin_session: abandoned-session check, buffer-budget recovery,Engine::start, andactive = true.FramedDecompressor::start,start_with_dictionaries,into_sessionandinto_session_with_dictionariesall go through the decoder’sbegin_session: abandoned-session check, exact output size against the budget,fits_policyrecovery, stream installation, andactive = true. - Commands and processing. Encoder methods call the same
Enginemethods, or the private owner helpers for metadata and resource guards. Decoderprocesscallssession_process, which runssession_step(that is,Engine::process) and then maps theTagto a status and lends the event. The borrowed session’sstep, used by the one-shot APIs and the reader, is the samesession_step. - Resources. The owned encoder session opens resources through the
existing
FramedResourceSessionguard, which borrows the owner inside the session. While a guard is live, the borrow checker rejects both container commands andinto_framed_compressor. A rustdoccompile_failexample covers this. - Lending. Owned
process<'a>(&'a mut self, …, output: &'a mut [u8])returnsFramedDecodeProgress<'a>, exactly like the borrowed session, so a live event blocks the next call. A rustdoccompile_failexample covers this too. - Owned resolvers. The decoder resolver is owned as
R: DictionaryResolver + 'static. Each call borrows it as aDictionaryResolverRef, which the engine uses only for that call.DictionaryResolveris implemented for&T,Box<T>andArc<T>. TheArcimpl needstarget_has_atomic = "ptr".into_sessionstoresNone, which is exactly what borrowedstartdoes, soNoDictionariesis only a default type and is never asked to resolve anything. Used explicitly as a resolver, it resolves nothing. That produces a different error than having no resolver at all.
- Release.
into_framed_compressorandinto_framed_decompressorcall the owner’s existingcancel, the same path the borrowedDropruns. - Auto traits.
FramedEncoderSessionOwnedisSend.FramedDecoderSessionOwned<R>isSendexactly whenRis. The borrowed framed decoder session is neverSend, because its resolver reference has noSyncbound.
stateDiagram-v2
[*] --> Owned: into_session*
Owned --> Guarded: resource / resource_with_dictionary / uncompressed_resource
Guarded --> Owned: guard dropped
Owned --> Lent: process returns Event
Lent --> Owned: event's last use
Owned --> Returned: into_framed_compressor / into_framed_decompressor (cancel)
Owned --> Owned: reinit (cancel + begin_session)
Owned --> [*]: drop (owner dropped)
Returned --> [*]
Verification
Each test file drives one set of schedules through both the borrowed and the owned session and requires identical traces: output bytes, per-call progress, failures, and totals.
-
tests/encoder_owned_session.rs- Inputs: empty, tiny, several KiB and 1 MiB payloads.
- Qualities 0, 5 and 11. Quality 11 is capped at 64 KiB.
- Chunked schedules, with and without flushes, with exact and unknown sizes.
Finishthrough 0–3 byte buffers.- Decoding of flushed prefixes.
- Reuse after finished, unfinished, flushed and failed sessions
(
experimental). - Retention, start errors, and dictionaries by value,
Arcand&'static. Send.
-
tests/decoder_owned_session.rs— the same parity across:- chunk and output sizes, including 0–2 byte buffers;
- truncated and empty input with exact failure progress, and a changed final suffix;
- reuse after finished, unfinished and failed sessions, and retention;
OutputSize, input, output and workspace limits, window limits, and both member modes;- dictionaries by value,
Arcand&'static; Send.
-
tests/framed_encoder_owned_session.rs— borrowed and owned facades run through the same driver across output widths (including 0–2 bytes), payload chunks and flushes. Traces must match byte for byte, including each call’s result and counters, and must equalcompresswhen there are no flushes.- Containers: empty; one resource; Brotli, uncompressed, Brotli; global, resource and footer metadata with padding and repeats; and a dictionary resource.
- A direct resource guard.
- Reuse after finished, header-pending, unfinished and failed containers.
- A start after a leaked session.
Send.
-
tests/framed_decoder_owned_session.rs— per-call results and events are recorded as debug text while each borrow is alive, and must be identical.- Inputs: hand-built containers and the checked-in writer fixtures, across input splits (including one-byte splits) and output widths (including 0–2 bytes).
- Every truncated prefix, the final suffix contract, and Auto raw input.
- External dictionaries resolved through
Arc, by value,&'staticandBox<dyn>. - Numeric limits and exact output size.
- Reuse after finished, needs-input, needs-output, post-event and failed operations, and a start after a leaked session.
Send.
-
reinittests in all four files end a first operation in each supported way: finished, needs input, needs output, flushed, after an event, failed, or truncated. They then reinitialize and require the next operation’s trace to equal a fresh borrowed session’s, across several schedules and stream configurations. They also check that a rejectedreinitleavesInvalidStateuntil a laterreinitsucceeds, and that the dictionary or resolver is kept.- A framed encoder start can only fail on allocation. Its poisoned state is
therefore covered by a unit test in
compressor::framing::sessionthat poisons the engine directly.
- A framed encoder start can only fail on allocation. Its poisoned state is
therefore covered by a unit test in
Known gaps
- The consumed codec is lost on a rejected start. A failed
into_session*drops the codec. No variant returns it with the error. - No
PreparedDictionaryfor owned decoders. An owned decoder session cannot decode with aPreparedDictionary. The borrowed session still can, throughDictionaryRef::Prepared. - The framed seek reader and the std readers and writers still borrow their owners. No owned variants were added.
- The fuzz targets don’t exercise owned sessions yet. Parity with the borrowed sessions is established by the integration tests.
Parallel compression
This subsystem is omitted when no_std is enabled. See
feature boundaries for the alloc-backed API that remains available.
The caller schedules tasks that encode fixed segments, each independently of the others’ encoding: at qualities 2-9 a segment may copy from the input before it (its history), which the decoder holds by then. Input that arrives in chunks uses the streaming session over the same tasks and workers. Private fragment encoders produce aligned non-final blocks, and the batch assembles them in source order into one stream. The API supports qualities 0–11 with standard windows; dictionary, Large Window, and framing extensions are unsupported.
Public and ownership boundaries
compressor::parallel exposes ParallelCompressor, ParallelConfig,
BatchConfig, validated SegmentSize and TaskCount, memory/directory staging,
source wrappers (including SeekSource<R>), task/batch types, polling, statistics,
retention, and errors.
The compressor module is public; its core remains private. Serial APIs are
also re-exported at the crate root.
Validated integers use TryFrom<usize> and get(); standard values use
SegmentSize::DEFAULT, TaskCount::ONE, and Default. Configuration/staging
conversions use From. Source wrappers use From<Arc<[u8]>> and
TryFrom<File>. These trait-based spellings follow repository API conventions.
There is no mandatory executor trait, runtime dependency, or hidden thread pool.
Rayon is a development dependency for examples of scheduling in tests/benchmarks.
tempfile is a development dependency for test and benchmark fixtures only.
The private parallel::core::spool module uses the standard library for exclusive
spool creation and cleanup.
Public source rustdoc includes a custom immutable snapshot with a version token,
absolute reads through SeekSource, and a compile-checked FileSource::open
example. Source identity is compared as caller-supplied bytes; it is not a
library-computed content hash. An open file handle does not freeze file contents,
and callers must keep input immutable throughout a batch. FileSource uses
positional reads on Unix and Windows and returns Unsupported elsewhere.
graph TD
Public[compressor::parallel public wrappers] --> Planner[parallel::core::Compressor and Plan]
Public --> Batch[parallel::core::batch]
Public --> Task[parallel::core::task]
Public --> Source[RandomAccessSource / SeekSource / FileSource / ArcBytesSource]
Planner --> Reservoir[exclusive idle Workers]
Task --> Worker[owned Worker: fragment codec, serial codec, input buffer]
Worker --> Fragment[compressor::core::fragment]
Fragment --> Driver[serial core::driver::Encoder]
Driver --> Families[Fast / Greedy / HQ]
Families --> Dispatch[Encoder true: dispatch! retained Backend per block]
Task --> Artifact[private memory artifact or Spool]
Artifact --> Spool[parallel::core::spool: File + cleanup guard]
Task --> Slot[one completion slot per task]
Slot --> Batch
Batch --> Destination[Vec / Write]
A task’s Job is either Planned (a batch task’s contiguous segments, read from
its input) or Streamed (one segment a stream session already moved into the
worker); both run through the same Task::run and per-segment encode helper.
Each ScopedParallelTask<'input> owns its worker, artifact, immutable input
handle, cancellation observer and completion capability. It never borrows the
batch or parent compressor. OwnedParallelTask is the 'static specialization;
OwnedParallelBatch similarly specializes the shared batch implementation. This
supports both scoped borrowing and detached execution without duplicated engines.
ParallelCompressor is Send, non-Clone, and owns no global state.
Planning, source reads, and memory
Fixed segment sizes range from 64 KiB to 16 MiB, with a 4 MiB default. Boundaries
are i * segment_size .. min((i + 1) * segment_size, source_len) using u64 file
coordinates. Effective task count is min(requested, segments). Contiguous
balanced grouping gives the first remainder tasks one additional segment.
Neither grouping nor completion order changes bytes.
Empty input creates no tasks and uses canonical serial encoding. A nonempty input
below minimum_parallel_size (default 8 MiB) uses serial encoding only if it also
fits one segment. That fallback still creates one caller-run task. Restricting
fallback to one segment prevents user-selected thresholds from causing a
whole-file allocation. Setting the threshold to zero forces independent parts.
Borrowed slices go directly into codec calls. Detached sources fill one retained
segment buffer per task, reading each segment’s history together with it
(Plan::history_start), so a source read covers history + segment bytes. FileSource retains an open regular file and uses Unix
read_at or Windows seek_read, retrying interruptions and short reads. Other
platforms return Unsupported for positional reads. File handles are never used
with a shared seek cursor. Metadata snapshots include length and modification
time, plus device, inode and change time on Unix. Metadata checks cannot prove
immutability against every in-place write; callers can provide stronger immutable
snapshot sources. Arbitrary blocking source calls cannot be forcibly interrupted.
The active-worker estimate scales segment + history rather than the segment
alone, since a worker’s window and match finder hold both.
Planning checks staged-byte bounds, descriptor sizes, active-worker estimates,
completion metadata, assembly scratch and currently retained workspaces before
returning tasks. Staged payload allowance is 2 * input + 1024 * max(segments,1).
Memory staging also charges descriptor storage to its explicit limit.
A memory artifact reserves its first part exactly, so a single-segment task
hands over a buffer without spare capacity, and then doubles its capacity,
capped at the task’s staged-payload bound, so the accounted bound also bounds
its spare capacity. Reserving each part exactly reallocated on every append;
with four tasks on 64 KiB segments of a 256 MiB input that took 143-191 ms
against 114-119 ms doubling (4 MiB segments: about 4% apart).
BatchConfig::auto(tasks) selects the same memory staging with usize::MAX as
the caller ceiling. Preparation still computes checked per-task payload bounds,
checks payload plus descriptors fits usize, and enforces the aggregate limit.
It neither queries free RAM nor switches storage backends. Source length comes
from the slice or the source’s len(); task count is capped by segment count.
ParallelSizeEstimate::maximum_staging_memory_bytes() exposes payload plus
segment_count * size_of::<Descriptor>(), using the same private checked helper
as memory preflight. It returns a u64 bound for either staging kind; callers
convert it with usize::try_from for an explicit memory budget. The existing
maximum_staged_bytes field remains payload-only. Overflow propagates as
ParallelEncodeError::SizeOverflow, including for caller-modified estimates.
Descriptor storage remains included once in the aggregate workspace estimate.
flowchart TD
Input[Slice length or source len] --> Plan[Checked segments and effective tasks]
Config[auto tasks or explicit memory ceiling] --> Plan
Plan --> Bound[Payload bound plus descriptor storage]
Bound --> Fit{Fits usize and caller ceiling?}
Fit -->|no| Error[SizeOverflow or MemoryStagingLimit]
Fit -->|yes| Aggregate{Aggregate memory limit satisfied?}
Aggregate -->|no| WorkerError[WorkerMemoryLimit]
Aggregate -->|yes| Prepare[Create bounded artifacts and caller-run tasks]
Bound --> Estimate[maximum_staging_memory_bytes]
Workspace estimates are deliberately conservative: per task, 4 MiB + 16 times segment size for q0/q1, 64 MiB + 128 times segment size for q2–q4, and 256 MiB + 256 times segment size above q4. These ceilings are not measured RSS. File staging keeps payload RAM proportional to active workers and segment size; descriptor memory is proportional to segment count and included in the aggregate ceiling.
Generic source entry point and seekable readers
prepare_source<S, T> accepts S: RandomAccessSource + ?Sized and
T: Into<Arc<S>>. Owned sources, shared concrete sources, custom conversions, and
Arc<dyn RandomAccessSource> use this single entry point. FileSource is a
source adapter. Arc and custom
conversion arguments may need explicit source types due to Into ambiguity:
prepare_source::<FileSource, _>(shared_file, config) or
prepare_source::<dyn RandomAccessSource, _>(erased_source, config).
The conversion occurs once before planning. A private sized SharedSource<S>
bridge lets both sized and unsized sources enter the existing erased core path.
It forwards length, identity and reads. Tasks clone the outer shared handle;
source bytes are never copied by this bridge. The bridge adds one small shared
allocation per prepared batch, not per task or segment.
SeekSource<R> takes ownership through From<R>. For R: Read + Seek + Send + 'static, it implements RandomAccessSource without requiring R: Sync.
The public wrapper delegates all locking and I/O to parallel::core::source.
Each length query holds the mutex while seeking to the end. Each range read
holds that same mutex for its absolute seek and the entire read_exact call.
The reader’s original cursor is ignored; all future reads seek explicitly.
A short read or interruption is handled by Read::read_exact; seek/read errors
propagate as I/O errors into existing batch metadata/read errors. A reader panic
poisons the mutex and subsequent access returns an I/O error. Worker panic
handling continues to discard the affected codec workspace.
sequenceDiagram
participant Caller
participant Public as prepare_source / SeekSource
participant Core as core::source
participant Reader as R: Read + Seek + Send
participant Task
Caller->>Public: source.into() to Arc<S>
Public->>Core: wrap shared source for type erasure
Core->>Reader: lock, seek End(0), unlock
Note over Core,Reader: planning snapshots current length
Task->>Core: read_exact_at(offset, segment buffer)
Core->>Reader: lock, seek Start(offset)
Core->>Reader: read_exact (retry interrupted/short reads)
Core-->>Task: unlock, bytes or I/O error
Task->>Task: compress without the reader lock
Seekable input reads are serialized; task compression still overlaps. No
metadata identity can be inferred from the generic traits, so this adapter
provides live length checks only and callers must keep bytes immutable.
FileSource retains positional reads and file identity verification; it remains
the preferred file adapter when concurrent input reads are desired.
Fragment format and encoder state
Format plan version 1 uses uniform two-byte raw prefixes. Qualities 0-1 and
10-11 write explicit distances throughout every segment; qualities 2-9 write a
short distance code only for a cache slot the segment’s own copies filled (see
Recent distances), and copies from the segment’s
history as from earlier input of the same stream. It follows RFC
7932 sections 11.2 and 11.3. Every part
starts at a byte boundary (except the first part also owns the window header),
contains only non-final data blocks, and ends aligned. Alignment metadata uses
the existing flush writer’s when-needed empty metadata blocks. The assembler
only copies validated bytes and appends the final-empty byte 0x03 at the proven
byte boundary. It never parses or edits complete streams.
sequenceDiagram
participant Task
participant Fragment as FragmentEncoder
participant Codec as retained Encoder
participant Artifact
Task->>Fragment: encode(history, segment bytes, first role)
Fragment->>Codec: reset semantic state, preserve allocations
Fragment->>Codec: prepend_history(history) (greedy only)
Note over Codec: history fills the window and match finder, emits nothing
Fragment->>Codec: begin_fragment(prefix)
Note over Codec: greedy fills its four remembered distances with -16
Note over Codec: greedy/HQ seed local ring and prior bytes via two-byte flush
Fragment->>Fragment: optional header + raw min(length,2) prefix
loop complete body input blocks
Fragment->>Codec: non-final encode, flush final body block
Codec-->>Fragment: completed bytes
end
Fragment->>Codec: verify no pending partial bits
Fragment-->>Task: sealed AlignedFragment
Task->>Artifact: append bytes + source/segment descriptor
| State | Reset/ownership rule |
|---|---|
| Header and pending bits | Common prefix writer owns the sole window header; body codecs start headerless. Every successful fragment proves zero pending bits. |
| Ring and input coordinates | reset_for resets local positions and history. Greedy then takes the segment’s history as already-emitted input (positions, window, prior bytes and match finder); HQ and fast codecs start with nothing before the segment. Greedy/HQ then seed the two current prefix bytes. |
| Greedy matcher | Existing dirty/partial-prepare protocol clears logical candidates while retaining allocations. Dictionary statistics use the permanently disabled sentinel. |
| HQ matcher/DP | Existing tree preparation resets roots; all-match and Zopfli arenas are rebuilt from this segment. Static-dictionary match distance is outside the representable range. |
| Recent distances | Inherited decoder cache values are never consulted. Greedy (q2-9) starts each fragment with -16 in all four remembered slots and encodes distances with the serial compute_distance_code, so short codes appear once the fragment’s own copies have filled a slot. HQ excludes cached-distance DP edges and writes explicit distances; q0/q1 write explicit distances. |
| q0/q1 commands | Insert commands copy two bytes; the remaining copy and its distance are explicit. q0’s independent starter/adaptive histograms include symbols 17/18 for three/four-byte copy remainders. q1 preserves compact symbol 40 at wire symbol 128 instead of overwriting it with the unused insert-zero alias, preserving the canonical Huffman order. |
| Literal contexts | Greedy/HQ prefix seeding advances prior-byte state exactly once. Fast codecs use no literal contexts. Prefix data is emitted exactly once by the common writer. |
| Entropy and splits | Rebuilt locally; q0 adaptive trees never cross a segment reset. |
| SIMD | Backend detected by the planner once; the fragment adapter builds Encoder<true> on it and retains it across reset. Serial encoders are Encoder<false>, separately monomorphized, so serial-only builds never link the fragment kernels. |
Recent distances
The decoder’s distance cache carries over between meta-blocks, so at a
fragment’s start it holds whatever the fragments before it left there, which the
encoder cannot know. Greedy fragments make those slots unusable instead of
avoiding the cache altogether: GreedyEncoder::begin_fragment writes -16 to the
four remembered distances (and to saved_dist_cache, which a re-encoded
meta-block restores). A copy distance is at least 1, so neither an exact match
nor a ±1..3 neighbour of -16 ever equals it, compute_distance_code never
returns a short code for such a slot, and the searcher’s cached-distance probe
rejects the position it computes. Every copy pushes its distance as in a serial
stream; after four distinct pushes the cache is entirely the fragment’s own and
the rest of the fragment codes distances exactly as serial encoding does.
flowchart TD
Start[begin_fragment] --> Seed["dist_cache[0..4] = -16<br/>saved_dist_cache = -16"]
Seed --> Copy{copy at distance d}
Copy --> Code["compute_distance_code(d, cache)"]
Code --> Slot{"d equals, or is ±1..3 of,<br/>a filled slot?"}
Slot -- yes --> Short[short code 0-15]
Slot -- "no, or the slot still holds -16" --> Full["explicit code d + 15"]
Short --> Push{code > 0}
Full --> Push
Push -- yes --> Shift[shift d into dist_cache]
Push -- no --> Copy
Shift --> Copy
History
A part is decoded after every part before it, so the decoder already holds the
input before a segment. Greedy fragments let copies reach into the last
Compressor::history_len bytes of it: a quarter of a segment, capped at the
window’s backward limit (1 << lgwin) - 16, and nothing at the other qualities.
GreedyEncoder::prepend_history mirrors brotli 1.0’s
BrotliEncoderSetCustomDictionary: it trims the history to the backward limit,
copies it into the ring buffer, sets input_pos, last_processed_pos and
last_flush_pos past it and the prior bytes to its last two, prepares the match
finder for a stream that is not one-shot, and stores the history positions
whose lookahead lies inside it (MatchFinder::prepend,
HasherPrependCustomDictionary); the boundary positions are stitched when the
segment arrives, as between any two blocks. No meta-block is written for the
history.
Unlike the reference, prepend skips positions inside short-period runs
(history_ranges): where the history repeats a period of at most 1 KiB for
more than four periods, only the run’s last four periods are stored. That is
the rule commit_match applies to a stream’s own copies (a copy shorter than
four times its distance stores everything, a longer one only its last
4 * distance positions), so the buckets hold what a serial stream’s would.
Storing every position put all of a zero run or a repeated 256-byte record
under a few keys, and every search there measured each candidate to the full
run length: 1 MiB of repeated bytes 0-255 at q9 took 7.8 ms instead of 0.4 ms.
Runs are found through a 4096-entry table of each 4-byte key’s last position
and confirmed eight bytes at a time; each byte is measured at most once.
Every part shares the configured lgwin, which the first part’s header
declares, and the encoder never emits a distance beyond
min(position, backward limit), its position counting the history. The
decoder’s position is at least as far, so such a copy is an ordinary backward
reference there, never a static-dictionary one. The size hint covers history
and segment, so the match finder is sized for every position it stores.
flowchart LR
Input[input before the segment] --> Tail["last history_len bytes<br/>(batch: Plan::history_start,<br/>stream: history buffer)"]
Tail --> Prepend["prepend_history:<br/>ring, positions, prior bytes,<br/>prepare match finder"]
Prepend --> Runs["history_ranges:<br/>skip all but the last four periods<br/>of short-period runs"]
Runs --> Store["store_range for kept positions"]
Store --> Begin
Begin["begin_fragment:<br/>header off, -16 distances,<br/>two-byte prefix"]
Begin --> Body["segment body:<br/>copies reach into history"]
A quarter of a segment keeps most of the ratio a whole segment of history gives
at about a quarter of the hashing time. With 1 MiB segments (one task, best of 7,
Apple M5 Pro): alice29.txt repeated to 4 MiB is 51,261 bytes at q6 with either a
19- or a 22-bit window, against 203,708 for its segments compressed separately;
for seq, three copies of /usr/share/dict/words and a mixed binary input the
gain was 0-6%, and q9 with a 22-bit window took 11-12% longer than the separate
segments (a whole segment of history: up to 45%).
Dictionary overloads are absent. Parallel Large Window configuration returns a typed configuration error.
Completion, cancellation, and reuse
stateDiagram-v2
[*] --> Prepared
Prepared --> Running: caller runs consumed task
Prepared --> Abandoned: dropped task guard
Running --> Success: commit artifact
Running --> Error: source / codec / staging failure
Running --> Panicked: catch_unwind, discard worker
Running --> Cancelled: cancellation checkpoint
Success --> Published
Error --> Published
Panicked --> Published
Cancelled --> Published
Abandoned --> Published
Published --> [*]: one slot and one completion ID
An unbounded channel carries exactly one small ID per task. Payloads and workers move through single-assignment slots, protected only during ownership transfer. Codec execution holds no mutex. Sending never blocks, so tasks do not need a coordinator to drain their payload or another task to progress.
Each task catches unwinding panics and discards the affected worker. Ordinary
errors return resettable workers. The drop guard reports abandonment once.
panic=abort remains unrecoverable. Cancellation uses a batch-local relaxed
atomic flag, carrying no other published state; checks run around reads and
segment encoding and before artifact commit. Successful bytes do not depend on
cancellation timing.
The coordinator collects by task ID and chooses the lowest failing ID after all
completions, preferring originating errors over cancellation fallout. Repeated
poll/wait errors use BatchFailed(Arc<ParallelEncodeError>) to preserve the typed
cause and its source chain. Blocking wait requires the caller’s executor to remain
runnable. A timeout does not cancel. Dropping a batch cancels, drops untaken tasks,
collects only already-published results, and never waits for detached work. Late
tasks release their own resources. The default retention count is zero; callers
can retain compatible workspaces and trim them by aggregate size.
Encoder reconfiguration
ParallelCompressor::reconfigure(EncoderConfig) delegates to the private
core::Compressor, using the same validation path as construction. Invalid
encoder settings propagate as ParallelConfigError::Encoder with their source;
otherwise valid Large Window settings return UnsupportedParallelWindow.
Validation completes before any mutation, so rejection preserves both settings
and idle workers. Identical settings preserve workers as well.
Changing encoder settings drops the idle worker reservoir and its allocations. The next batch plans with the new configuration and lazily constructs matching fragment and serial fallback encoders. Parallel configuration and the backend selected at construction remain unchanged; no SIMD detection is repeated. A live batch holds an exclusive borrow of the parent, preventing reconfiguration. After a batch is dropped, detached tasks keep their old exclusive workers and release them on completion; they cannot return them to a reconfigured parent. The parallel Criterion suite validates identical output after reconfiguration and measures reconfiguration plus encoding with one and four tasks, alongside retained-worker and C/Rust serial cases on the same corpora and codec settings.
flowchart TD
Request[reconfigure encoder settings] --> Validate[Validate encoder and standard window]
Validate -->|invalid| Error[Return typed error without mutation]
Validate -->|valid| Compare{Settings changed?}
Compare -->|no| Keep[Keep idle workers]
Compare -->|yes| Drop[Release idle workers and store settings]
Drop --> Next[Next batch plans and creates workers with new settings]
Keep --> Reuse[Next batch can reuse workers]
Assembly and output failure
Directory staging resolves the existing directory to an absolute path. Each
task uses a fresh RandomState to hash candidate names, then atomically opens a
read/write file with OpenOptions::create_new(true). Existing files and symlinks
are never opened or overwritten. Name collisions retry up to 128 times; other
filesystem errors propagate immediately through the existing staging I/O error
path. Unix files are created with mode 0600 (subject to the process umask);
other platforms use their default file permissions.
The spool owns its File before a separate path cleanup guard in declaration
order, so dropping an artifact closes the handle before attempting removal,
including on Windows. Success, errors, cancellation and unwinding all use this
same ownership cleanup. Removal is best effort because destructors cannot return
I/O errors. Callers must keep the staging directory stable and trusted; path
replacement, process termination or filesystem errors can prevent cleanup.
Spooling adds no SIMD dispatch or changes to encoded bytes.
stateDiagram-v2
[*] --> Creating: canonicalize directory
Creating --> Creating: candidate already exists, retry within limit
Creating --> Failed: filesystem error or collision limit
Creating --> Open: exclusive read/write create
Open --> Open: append / validate length / seek and copy
Open --> Closed: artifact dropped, File drops first
Closed --> Removed: path guard removes file
Closed --> LeftBehind: removal fails, error ignored
Failed --> [*]
Removed --> [*]
LeftBehind --> [*]
flowchart TD
Wait[all task completions] --> Verify[source snapshot + ordered descriptors + spool lengths]
Verify --> Valid{all valid?}
Valid -->|no| Error[return typed error, destination untouched]
Valid -->|yes| Kind{destination}
Kind --> Vec[reserve exact append size, rollback length on error]
Kind --> Write[copy in order, count every accepted short write]
Before the first destination write, assembly validates segment order and exact
source ranges, artifact offsets/nonzero lengths, total segment count, and actual
spool length. Directory artifacts use sequential reads and a 128 KiB copy buffer.
Interrupted, short reads/writes and WriteZero are handled. A generic writer
failure returns its ownership and exact accepted byte count. It does not flush
or sync implicitly and is not retryable through the consumed batch.
Files are ordinary Write destinations. Callers own file creation, buffering,
flushing, durability, and any temporary-file publication policy. No path-specific
finish API or file commit policy is exposed.
Verification and known gaps
The crate-level documentation in src/lib.rs demonstrates automatic memory
staging and two scoped-thread tasks over an 8 MiB input at quality 5. Its doctest
checks that both tasks are used and the assembled output is smaller than the
repetitive input.
ParallelCompressor rustdoc examples execute the borrowed-slice workflow with
scoped standard-library threads and Rayon parallel iterators at quality 0 to
keep doctest execution fast: prepare from
input.as_bytes(), take and run tasks, then finish into a vector. Both examples
propagate configuration, preparation, and assembly errors through a Result.
They run as doctests with the default feature set; Rayon remains a development
dependency and must be added explicitly by callers who choose that executor.
tests/parallel.rs covers all qualities, standard windows, host backends, modes,
segment edges, fallback, source/staging identity, std/Rayon scoped and detached
execution, reverse task order, worker reuse, source panics/errors/mutation,
abandonment, timeout, cancellation, file output, and every output failure byte
for a representative fast stream. Private fragment tests independently decode
joined parts, and regressions cover q0 short explicit copies omitted by the serial starter
tree and q1 copy-two depth/ordering aliases found by AFL. The full existing serial differential/streaming suite is
also required. fuzz/afl adds the parallel target and committed seeds.
benches/parallel.rs validates before timing and prints sizes alongside scaling
and serial C/Rust measurements; those policies are labeled separately.
Known gaps
- Cancellation occurs around segment encoding, not inside q11 optimization. A running codec call or blocking source read may delay cancellation.
- Segments lose dictionary coding and any history beyond a quarter of a segment (repetition further back, such as a file repeated after 2.4 MB, is not found), and at qualities 0-1 and 10-11 they take no history at all, so their ratio and speed are distinct from serial encoding.
- q0/q1 and q10-11 fragments still write every distance explicitly. Input
that repeats one distance compresses several times worse there than
serially: 9.3 MB of
seq 1 1300000at q1 with 1 MiB segments gave 4.2 MB instead of 1.0 MB. Greedy qualities 2-9 resume the cache as described in Recent distances. - Source metadata checks cannot detect every in-place mutation.
- Spool cleanup is best effort; process termination and filesystem errors can leave files behind.
- Positional file reads are implemented for Unix and Windows. Other platforms
return
Unsupported.
Parallel streaming sessions
This subsystem is omitted when no_std is enabled. It extends
parallel compression from a known input to input that
arrives in chunks: a session accepts bytes incrementally, turns every full
segment into a task on a shared queue, and delivers finished parts in input
order while later segments still encode.
Public and ownership boundaries
compressor::parallel exposes:
| Item | Role |
|---|---|
ParallelCompressor::start(window) | Borrowed session ParallelEncoderSession<'c> plus a ParallelTaskQueue. |
ParallelCompressor::into_session(window) | Owned ParallelEncoderSessionOwned plus a queue; into_compressor returns the parent. |
ParallelEncoderSession{,Owned} | process, process_uninit, flush, finish, wait, wait_timeout, is_finished. |
ParallelEncoderStatus, ParallelProgress | NeedsInput, NeedsOutput, Pending, Finished, with exact consumed/produced. |
ParallelTaskQueue, ParallelTasks | Clone + Send + Sync handle with blocking recv, non-blocking try_recv, and a blocking iterator. |
ParallelCompressor::writer(sink, window) | ParallelEncoderWriter<'c, W: Write> plus a queue. |
ParallelCompressor::reader(source, window) | ParallelEncoderReader<'c, R: Read> plus a queue. |
Operation and WaitStatus are reused from the serial session and the batch
API. Errors are ParallelEncodeError, with two variants added for streams:
StreamFailed(Arc<ParallelEncodeError>) and TaskQueueClosed. The adapters
reuse mbrotli::io::FinishError and mbrotli::io::EncoderReaderParts, and
report session failures through From<ParallelEncodeError> for std::io::Error.
The public module compressor::parallel::session only wraps the private
parallel::core::stream module. The private module compressor::parallel::io
wraps a borrowed ParallelEncoderSession in turn and is re-exported from
compressor::parallel; it never reaches into core. There is still no executor trait, no
runtime dependency and no hidden thread: the caller’s executors take tasks from
the queue.
graph TD
Adapters[parallel::io: ParallelEncoderWriter, ParallelEncoderReader] --> Public
Public[parallel::session public wrappers] --> Borrowed[core::BorrowedStream: &mut Compressor + Stream]
Public --> Owned[core::OwnedStream: Compressor + Stream]
Public --> Handle[core::QueueHandle]
Borrowed --> Stream[core::Stream]
Owned --> Stream
Stream --> Queue[Queue: Mutex VecDeque Task + Condvar]
Handle --> Queue
Stream --> Slots[completion Slots + mpsc ID channel]
Stream --> Flight[flight: VecDeque Entry in input order]
Stream --> Filling[filling segment buffer]
Stream --> History[history: last history_len bytes before filling]
Queue --> Task[core::Task with Job::Streamed]
Task --> Worker[Worker: FragmentEncoder, serial Compressor, input buffer]
Task --> Slots
Stream holds every piece of stream state except the parent compressor, which
each operation takes as an argument; the borrowed and owned sessions differ only
in where the parent lives, as the serial sessions do. BorrowedStream’s Drop
and OwnedStream::into_parent run the same release path. Stream’s own
Drop cancels and closes the queue, so an owned session dropped without
into_compressor still stops executors.
Tasks and the shared job type
core::Task carries a Job:
Job::Planned { range, input, plan }encodes a batch task’s contiguous segments read from its slice or source.Job::Streamed { segment, source, first, serial, history }encodes one segment that the session has already copied into the worker’s input buffer, after thehistorybytes of input that precede it.
Both kinds share Task::run (unwind capture, cancellation checks, single
publication into a slot) and one encode helper that writes either a fragment
or the serial fallback into a memory Artifact and records its descriptor.
ScopedParallelTask::segment_range reports segment..segment + 1 for streamed
tasks; id is the completion slot, at most window - 1.
The completion channel is an unbounded mpsc channel of slot IDs for both
batches and streams. A stream may outlive any fixed task count, and a bounded
channel would preallocate window entries.
Data flow
sequenceDiagram
participant Caller
participant Session as Stream
participant Queue
participant Executor
participant Task
Caller->>Session: process(input, output, operation)
Session->>Session: collect published completions (try_recv)
Session->>Caller: drain finished parts in input order
loop while input remains and the window has a free slot
Session->>Session: copy into filling until segment_size
Session->>Session: worker input = history + filling
Session->>Session: history = last history_len bytes of input so far
Session->>Queue: push Task(Job::Streamed)
end
Session-->>Caller: ParallelProgress (Pending if input left or Flush/Finish waits)
Executor->>Queue: recv
Queue-->>Executor: Task
Executor->>Task: run
Task->>Session: slot = Completion, send slot ID
Caller->>Session: wait()
Session->>Session: recv one ID, then collect the rest
Input is copied into filling, then at dispatch once more into the worker’s
input buffer, after the session’s history: the last
Compressor::history_len bytes of input before the segment (a quarter of a
segment within the window’s backward limit at qualities 2-9, nothing
otherwise; see parallel compression).
The history then moves on to the input’s last history_len bytes, so after a
Flush cut short segments it is gathered from several of them. A serial part
is the whole input and takes no history. Both buffers are reserved before the
part takes a completion slot; a failed reservation fails the stream with
Allocation. The
batch reads the same bytes before each segment, so a stream without flushes
produces the batch’s bytes. Output is copied once from a
finished part into the caller’s slice; a part may take several calls.
Stream::process and drain are generic over a private Sink trait, so the
same code delivers into &mut [u8] (process) and &mut [MaybeUninit<u8>]
(process_uninit). Delivery only writes, exactly the bytes it counts in
produced; the two methods give identical bytes, counts, statuses and errors
and may be mixed on one stream. See Uninitialized output.
Window and backpressure
window (a TaskCount) bounds the parts that are queued, running, or finished
but not yet fully delivered. Each such part owns one completion slot; slots are
created on demand up to window and freed only when the part’s last byte is
delivered. Workers come from the parent’s idle reservoir or are created, and
return to it on completion, so a session creates at most window workers.
When the window is full and filling is full, process stops consuming input
and reports Pending. Memory is therefore bounded by window parts (worker,
input with history and staged output each) plus the filling and history
buffers.
With an aggregate memory limit configured, start checks
window * (worker estimate + 3 * segment + history + 5 KiB) + segment + history + retained bytes
and returns WorkerMemoryLimit or SizeOverflow before creating anything. The
worker estimate is the batch planner’s conservative per-quality ceiling.
Byte identity and the serial fallback
Segments are segment_size bytes at stream offsets k * segment_size; the
first queued fragment carries the window header. A stream without Flush is
therefore byte-identical to prepare_slice over the same bytes, for every
chunking, window and execution order. Only the first segment is special: the
batch encodes an input serially when len == 0 or
len < minimum_parallel_size && len <= segment_size. A full first segment is
held while that is still undecided, until a following byte arrives (fragments)
or Finish (serial, if the rule holds).
flowchart TD
Finish[Finish with all input consumed] --> Any{fragment already queued?}
Any -->|yes| Last[queue the non-empty filling as a fragment]
Any -->|no| Rule{len == 0, or len < minimum and len <= segment?}
Rule -->|no| Last
Rule -->|len == 0| Empty[encode empty input inline with a worker's serial compressor]
Rule -->|yes| Serial[queue filling as a serial task]
Last --> Trailer[append the final byte 0x03 as an inline part]
Empty --> Finishing[phase Finishing]
Serial --> Finishing
Trailer --> Finishing
A queued part that cannot get a slot leaves the phase Open; the same Finish
call returns Pending and the next call retries.
Flush queues the non-empty filling buffer as a short fragment and reports
NeedsInput once every accepted byte has been delivered. Every fragment ends
byte-aligned, so the delivered prefix is decodable. A flush moves all later
segment boundaries, and once any fragment has been queued the serial fallback is
gone.
Lifecycle and status
stateDiagram-v2
[*] --> Open: start(window)
Open --> Open: Process / Flush
Open --> Finishing: Finish queued the last part
Finishing --> Finished: last part and 0x03 delivered
Open --> Failed: task, allocation or queue failure
Finishing --> Failed
Finished --> [*]: queue closed, idle workers trimmed
Failed --> [*]: queue closed, StreamFailed on every call
A call computes its status after draining:
flowchart TD
Start[after drain] --> Front{front part finished but undelivered?}
Front -->|yes| NeedsOutput
Front -->|no| Phase{phase}
Phase -->|Finishing, flight empty| Finished
Phase -->|Finishing, parts running| Pending
Phase -->|Open| Input{input left over?}
Input -->|yes| Pending
Input -->|no| Op{operation}
Op -->|Process| NeedsInput
Op -->|Flush, nothing pending| NeedsInput
Op -->|Flush, parts pending| Pending
Op -->|Finish not yet queued| Pending
After Finish has queued the last part, calls consume no input whatever their
operation and only deliver. Finished is idempotent.
process never blocks. wait blocks for one completion, or returns Ready at
once when nothing is running; wait_timeout returns TimedOut without
cancelling. A session whose queue handles are alive but never drained cannot
progress, which is why single-thread callers run try_recv tasks before
waiting.
Stopping executors is not itself an error and has no automatic timeout. If a
needed task stays queued or is held by an executor without being run or dropped,
wait can block indefinitely. The blocking adapters use this wait internally:
reader read and writer write, flush, try_finish and finish may therefore
block too. Executors must remain able to run until finishing returns. A bounded
pool must leave execution capacity for tasks instead of filling every worker
with callers waiting for those tasks. wait_timeout bounds a session wait only;
it does not configure a timeout for the adapters.
Queue closure, cancellation and errors
The queue is a Mutex<VecDeque<Task>> with a Condvar, a closed flag, and a
count of live handles. Only bookkeeping runs under its lock. It closes when the
stream finishes, fails, is released or dropped, and when the last handle drops.
Closing wakes every blocked recv, which then returns None, so
for task in queue loops end. Tasks still queued at closure are dropped outside
the lock; each publishes TaskAbandoned, returning its worker through its slot.
Tasks already taken by executors remain owned by those executors and must still
be run or dropped; dropping all queue handles cannot resolve a task held forever.
Errors propagate as follows:
| Cause | Stream observes |
|---|---|
| Every handle dropped before a push | TaskQueueClosed |
| Every handle dropped with tasks queued, or a taken task dropped | TaskAbandoned |
| Encoding, panic, or a staged-length violation in a task | the task’s typed error |
| Allocation while filling or preparing a part | Allocation |
| Malformed or duplicate completion | FragmentInvariant |
Any failure sets the shared cancellation flag, closes the queue and moves the
stream to Failed(Arc<cause>); that call and every later one return
StreamFailed with the cause as its source. A failing task sets the flag before
publishing, so another task can publish Cancelled first. The stream waits for
the originating error while parts still run and then reports the
lowest-segment non-cancellation cause.
Dropping or releasing a session sets the flag, closes the queue, collects the completions already published without waiting, and trims the parent to its retention policy. Tasks an executor already took keep their worker and release it on completion; it cannot return to the parent.
Reader and writer adapters
ParallelEncoderWriter and ParallelEncoderReader hold a borrowed session and
drive it exactly as a caller of process would; they make no segmentation,
scheduling or delivery decision of their own, so their bytes are the session’s.
Each constructor calls start(window) and returns the queue beside the adapter.
Unlike process, the adapters block, in ParallelEncoderSession::wait. They
never run tasks themselves, so executors must run on other threads; if every
queue handle is dropped, the session fails instead (TaskQueueClosed or
TaskAbandoned) and the adapter returns that failure.
classDiagram
class ParallelEncoderWriter~W~ {
Box~ParallelEncoderSession~ session
W sink
Vec~u8~ outbox
usize head
usize end
State state
}
class ParallelEncoderReader~R~ {
ParallelEncoderSession session
R source
Vec~u8~ input
usize head
usize end
bool eof
bool finished
}
class ParallelEncoderSession
ParallelEncoderWriter --> ParallelEncoderSession
ParallelEncoderReader --> ParallelEncoderSession
ParallelEncoderWriter ..> FinishError : finish failure
ParallelEncoderReader ..> EncoderReaderParts : into_parts
The writer boxes its session: the stream state is about 300 bytes, and
finish returns the whole adapter by value inside FinishError.
Writer
The writer follows EncoderWriter’s transactional rules. Compressed bytes are
pulled from the session into a retained 128 KiB outbox, and head..end is
the part the sink has not taken. A pull happens only when the outbox is empty,
and every call drains first, so a short write, Interrupted (retried),
WouldBlock or a sink error leaves the exact undelivered suffix in place.
flowchart TD
Write["write(buf)"] --> Open{state Open?}
Open -->|no| Refuse[InvalidInput]
Open -->|yes| Drain[drain outbox to sink; error returns, nothing consumed]
Drain --> Empty{buf empty?}
Empty -->|yes| Zero["Ok(0)"]
Empty -->|no| Pump[process buf, Process, into outbox]
Pump --> Took{consumed > 0?}
Took -->|yes| Best[drain, ignoring sink errors] --> Count["Ok(consumed)"]
Took -->|no: window full| Drain2[drain]
Drain2 --> Produced{produced > 0?}
Produced -->|yes| Pump
Produced -->|no| Wait[session.wait] --> Pump
Accepted bytes belong to the adapter: a sink error after the session consumed input is not reported with the count, so the caller never repeats bytes; it surfaces on the next drain.
flush and try_finish repeat pump(&[], Flush | Finish) then drain until
the session reports NeedsInput (flush) or Finished (finish). A step that
produced nothing while Pending waits for one completion; a step that produced
bytes is simply repeated, since more may already be ready. try_finish moves
the writer to Finishing after its first finish step, so later writes are
refused even if the sink fails before the end, then flushes the sink and moves
to Finished. Retrying a failed try_finish resumes the same stream; a
finished writer returns Ok at once. flush after finishing only flushes the
sink.
stateDiagram-v2
[*] --> Open: ParallelCompressor::writer
Open --> Open: write / flush
Open --> Finishing: first Finish step of try_finish
Finishing --> Finishing: sink or session error, retry try_finish
Finishing --> Finished: session Finished, outbox drained, sink flushed
Finished --> Finished: try_finish is a no-op; write is refused
Reader
The reader follows EncoderReader: source bytes are read in 64 KiB fills into a
retained buffer and handed to the session from a cursor, with Process until
the source reports end of file and Finish after it. Compressed bytes go
straight into the caller’s slice.
sequenceDiagram
participant Caller
participant Reader as ParallelEncoderReader
participant Source
participant Session
participant Executor
Caller->>Reader: read(buf)
loop until produced > 0 or Finished
opt buffered input empty and not EOF
Reader->>Source: read 64 KiB (retry Interrupted)
end
Reader->>Session: process(buffered, buf, Process or Finish)
alt status Pending and nothing produced
Reader->>Session: wait()
Executor-->>Session: completion
end
end
Reader-->>Caller: Ok(produced) or Ok(0) once Finished
A read keeps filling and queuing segments until the window is full, so up to
window segments encode while the caller consumes the first. A source error
propagates with nothing lost: buffered bytes stay at the cursor and the next
read continues. into_parts returns the source and the bytes read but not yet
accepted by the session; bytes the session accepted belong to the abandoned
stream.
Errors and dropping
From<ParallelEncodeError> for std::io::Error keeps the error as the source and
takes the kind of the root cause through StreamFailed and BatchFailed:
OutOfMemory for allocation and memory limits, InvalidInput for
configuration and size overflow, InvalidData for a changed source, an I/O
cause’s own kind, the encoder’s kind for Encode, and Other for task and
queue failures. It never yields Interrupted or WouldBlock, which std::io
callers retry, because a failed stream stays failed.
Dropping an adapter drops its session, which cancels unstarted tasks, closes the queue and trims the parent, as dropping a session does.
SIMD and dispatch
The session adds no dispatch. Workers build their fragment and serial encoders
on the backend the ParallelCompressor selected at construction, exactly as
batch workers do.
Verification
tests/parallel_stream.rs: byte identity withprepare_sliceat every quality for whole, 1000-, 4097- and 64 KiB-byte chunks, windows 1–4, inline and 1–4 thread executors, and output buffers down to one byte; serial fallback and segment edges (0–3 bytes, one segment ±1, two segments) with and without the threshold; window backpressure; flush prefixes decoded by C; finishing and idempotentFinished; queue closure and task abandonment; session drop; owned sessions; memory limits;Send/SyncandDebug.process_uninitand calls mixed withprocessmatch the batch across qualities, windows, executors and output sizes, never write pastproduced, and report failures asprocessdoes, borrowed and owned.- Private unit tests cover malformed completions, cancellation-versus-cause ordering, queue-handle counting and single-segment artifact extraction.
fuzz/afltargetparallelstreams each input with input-derived chunk size, window and optional mid-stream flush, running queued tasks in reverse order, alternatingprocessandprocess_uninitwith a sentinel check pastproduced, and requires batch identity (or a round trip after a flush). It then repeats the stream through the writer (with the same flush) and the reader, each with one executor thread.tests/parallel_io.rs: writer and reader byte identity withprepare_sliceat every quality for windows 1-4, 1-4 executor threads, writes and source reads from 1 byte to a whole segment, and read buffers down to one byte; serial fallback and segment edges; parts larger than one 128 KiB pull; flush prefixes decoded by C and an empty re-flush; sinks failing withWouldBlock,InterruptedandOtherat every fifth call while writing short, withfinishretried throughFinishError; a sink accepting nothing (WriteZero); writes refused after finishing; dropped queues reported asStreamFailed(TaskQueueClosed)throughstd::io::Error; interrupted and failing sources;into_partsread-ahead; adapter drop closing the queue; memory limits;SendandDebug.- Unit tests in
parallel::errormap everyParallelEncodeErrorvariant to itsio::ErrorKindand check that wrapped causes keep their kind and chain. benches/parallel.rsmeasuresstream-tasks/{1,4}beside the batchtasks/{1,4}on the same corpora, after checking identical output.
Known gaps
- The reader and writer borrow their
ParallelCompressor; there are no owned adapter variants overParallelEncoderSessionOwned. - The adapters never run tasks on the calling thread, so they cannot be used with the queue drained only on that thread.
- The writer copies each part twice (session to outbox, outbox to sink), and
benches/parallel.rsdoes not measure the adapters yet. - No directory staging: streamed parts are always staged in memory, bounded by the window.
- Cancellation granularity is that of batch tasks: a running segment finishes its codec call.
waitcannot detect executors that hold queue handles but never run tasks.
Decompression
The native decoder, its performance paths and the framed container readers.
| Specification | Scope |
|---|---|
| Native decompressor | Parsing, sessions, history, the single-exit command loop, dictionaries, budgets and I/O. |
| Owned decoder output | Stored members, allocation transfer, linear slice history and read-ahead accounting. |
| Literal and stored-header decoding | Literal batches, per-context roots, byte-exact literal runs and stored-member recognition. |
| Decoder SIMD | Command-loop dispatch and bounded fixed-size history copies. |
| Decoder verification | C/RFC oracles, feature profiles and test limitations. |
| Framed decoder | Structured containers, detection, events, dictionaries, validation, budgets and lending reader. |
| Framed seek reader | Footer/directory indexing, lazy metadata, sparse dependency decoding and resource Read. |
Architecture overview and module map
Native decompressor
The decompression feature enables the public decompressor module, which owns configuration, reusable decoder ownership,
operation sessions, typed errors, and synchronous adapters. Its private core
owns wire parsing and regeneration. Production decoding uses safe Rust and
alloc, without a C or third-party decoder dependency. no_std disables I/O
adapters and profiling. Serialized/custom dictionaries require experimental;
RAW prefixes, built-in words/transforms, large windows, and ordinary context maps
need only decompression. Shared window, backend and retention types remain
available without the encoder. Prepared dictionary views additionally require
compression; see codec features.
graph TD
API[decompressor: config, decoder, session, error] --> Core[private decompressor::core]
IO[decompressor::io: reader / writer] --> API
Facade[mbrotli::io] --> IO
Facade --> Finish[private finish_error: shared FinishError]
Facade -->|compression enabled| EncoderIO[compressor::io]
Core --> Stored[stored: borrow a validated complete raw member]
Stored --> Bits[bits / header]
Core --> Bits
Core --> Entropy[huffman / block / context_map]
Core --> Regen[stream / distance / dictionary]
Core --> Memory[memory: fallible live workspace accounting]
Regen --> Dispatch[validated backend: command loop or ring-copy stage]
Dispatch --> Copies[fearless_simd: 16 / 32-byte history copies]
Regen --> Shared[private crate::shared: wire tables, dictionary bytes, transforms]
Dict[dictionary: DecodeDictionary / PreparedDictionary] --> View[DictionaryRef borrowed view]
View --> API
Dict --> Store[shared::decode_dictionary / experimental decode_serialized]
Public ownership and APIs
Decompressor::new and builder create an allocation-free workspace. A validated
Backend is selected once at construction. The scalar state machine dispatches
the command loop and bulk ring-copy stages through that validated token. The
command loop passes its concrete fearless_simd::Simd token to 16- and 32-byte
history-copy helpers; there are no feature checks or dynamic calls per command
or vector. Headers, raw blocks, and dictionary resolution outside the command
loop remain shared scalar code. fork_empty copies policy/backend into an independent empty workspace.
config, retention, retained_bytes, trim, recover, and reconfigure
manage reuse without exposing core storage.
DecoderConfig combines WindowLimit, MemberMode, and DecodeLimits.
Default is Single, extended windows up to 62 bits, and no numeric budgets;
DecoderConfig::new() is the same value as a const fn, and Default
delegates to it.
DecodeStreamConfig optionally specifies OutputSize::Exact(u64), which is a
validation contract, never an allocation hint. Limits count accepted compressed
bytes, regenerated output, and live decoder-owned requested heap bytes. Input
and output totals span all concatenated members. Caller destinations, immutable
borrowed dictionaries, and fixed adapter buffers are excluded from workspace.
A bounded policy example:
use mbrotli::{DecodeLimits, DecoderConfig, Decompressor, WindowLimit};
let config = DecoderConfig::default()
.with_window_limit(WindowLimit::standard(24)?)
.with_limits(DecodeLimits::default()
.with_max_input_bytes(Some(1 << 20))
.with_max_output_bytes(Some(8 << 20))
.with_max_workspace_bytes(Some(32 << 20)));
let mut decoder = Decompressor::new(config)?;
Ok::<(), Box<dyn std::error::Error>>(())
decompress returns a Vec, decompress_into appends and returns its range, and
decompress_to_slice fills the caller’s initialized slice, and
decompress_to_uninit an uninitialized one, zeroed one meta-block (at most
32 KiB) ahead of the decoder so it can serve as linear history too (see
uninitialized output); sessions have process_uninit,
which delivers through the ring. Eligible complete
stored members use the borrowed-payload shortcut described below, in decompress,
decompress_into and, when the payload fits, in decompress_to_uninit;
stored_one_shot holds the eligibility check they share. Other Vec shapes first
run the session with no destination at all, which parses up to the first byte
of output. A fresh decoder writing a fresh vector in single-member mode
collects into its window, which the vector takes (see owned decoder
output); a member longer than the window continues
through the ring. Every other shape (a reused decoder, a vector with contents
or capacity, a dictionary, concatenated members) decodes linearly into the
vector: it reserves what the meta-block declares (declared_remaining, at
least a small geometric step, at most 16 MiB), so a stream usually gets one
reservation rather than a chain of copies, but zero-fills only
ZERO_AHEAD_STEP (32 KiB) of it ahead of the decoder at a time, and passes
dst[start..] with Delivery::Linear, so the bytes already decoded are the
first member’s history; later members are delivered from the ring at their
offset. The reservation grows only when decoding reaches its end, not per
zeroing step: growing it per step left a 32 MiB vector for a 481 KB output
(appending_reserves_in_proportion_to_the_output guards this). The unused
tail is trimmed. Criterion decompress/reused (one decoder and vector,
q0-q11, default release profile) took 0.61 times the time of the earlier
ring-and-copy path in geometric mean: 1 MiB text 0.69-0.82, data compressed
beyond 100:1 0.42-0.65, stored members, now copied straight from the input,
0.08-0.35, literary text and binary 0.93-1.02, and mapsdatazrh 1.00-1.03,
where the extra decoding call per 32 KiB step outweighs the saved copy. A
member that outgrew the collect path stays on the ring: switching it to
linear delivery measured 1.8 times slower. Any Vec append is
rolled back on failure; a slice preserves the written prefix and untouched
suffix. Single one-shot calls reject tails. Dictionary variants accept
impl Into<DictionaryRef<'dict>> before the compressed source. Sessions borrow
the decoder and dictionary independently, without copying/rebuilding indexes.
Session lifecycle and exact progress
stateDiagram-v2
Idle --> Active: start / start_with_dictionary
Active --> Active: Process / NeedsInput / NeedsOutput
Active --> Finishing: first Finish latches total_in + input.len
Finishing --> Finishing: Finish with exact unconsumed suffix
Active --> Complete: validated Single member
Finishing --> Complete: validated member and final input boundary
Active --> Failed: format / resource error
Finishing --> Failed: error / truncation / changed final-input contract
Active --> Abandoned: mem::forget
Finishing --> Abandoned: mem::forget
Complete --> Abandoned: mem::forget
Failed --> Abandoned: mem::forget
Active --> Idle: Drop
Finishing --> Idle: Drop
Complete --> Idle: Drop
Failed --> Idle: Drop
Abandoned --> Idle: recover / successful reconfigure
DecodeProgress and DecodeFailure both report exact per-call consumed/produced
prefix lengths. Counters include failing-call progress. A failed session remains
terminal; a completed session returns zero progress and Finished again. Single
sessions stop exactly at the member boundary. Concatenated sessions reset window,
history, distances, contexts and entropy state between members, but keep aggregate
counters and policy; a boundary is NeedsInput until final EOF confirms success.
At least one member is required.
Per-operation fields live in the private core::session::OperationState, which
holds neither the decoder nor the dictionary. DecoderSession wraps it over
&mut Decompressor and runs OperationState::release on drop.
DecoderSessionOwned wraps the same state over an owned decoder and dictionary;
see owned sessions.
A session stores the dictionary borrow itself. The reusable workspace never
stores that reference, so forgetting a session cannot leave a dereferenceable
stale pointer. The owner active flag survives trim; only explicit recovery or
successful reconfiguration clears abandonment. Drop clears operation state and
applies the configured retention policy without I/O. Aggressive retains capacity;
CurrentConfig additionally releases it on configuration changes; Bounded releases
all workspace above its threshold; ReleaseAll releases on each operation drop.
Parsing and regeneration
flowchart TD
Window[Demand-read window header] --> Header[Transactional meta-block header]
Header -->|metadata| Skip[Consume aligned bytes without allocation/output]
Header -->|uncompressed| Raw[Regenerate bytes into caller output and history]
Header -->|compressed| Types[Three independent block streams]
Types --> Maps[Context modes and RLE / inverse-MTF maps]
Maps --> Trees[Resumable simple / complex Huffman descriptions]
Trees --> Command[Insert/copy command and extra bits]
Command --> Literals[Context-dependent literal symbols]
Literals --> Distance[Short cache / direct / extra-bit distance]
Distance --> History[Overlapping history copy]
Distance --> Prefix[Borrowed ordered RAW segments]
Distance --> Static[Resolve static word then apply transform with fixed-size copies]
Prefix -->|crossing reference| History
History --> Command
Static --> Command
Raw --> End[Validate block end and padding]
Skip --> End
Command -->|remaining = 0| End
End -->|more blocks| Header
End -->|last| Member[Complete member]
Bits uses a demand-driven 64-bit reservoir. The slow path accepts one byte at
a time and never accepts a byte beyond the field that needs it, so limits and
per-call progress stay byte-exact; it reads fields up to 56 bits and splits a
62-bit distance extra field across two reads. Small headers are parsed
transactionally; partial peeks retain bits, and a validated field commits by
dropping exactly its width. Padding must be zero.
The hot path uses a whole-word refill, which loads a full little-endian word
while at least eight acceptable bytes remain before the input limit and accepts
the whole bytes that keep at least 56 bits buffered ((!count >> 3) & 7 bytes,
masked so the slice advance needs no check beyond the eight-byte one, after
which the count is count | 56), and a matching unread,
which returns every whole speculatively buffered byte. The word is ORed in
unmasked: bits above the buffered count then hold the input bytes that follow,
which a later byte load ORs in again unchanged, so no mask sits on the decoding
chain. Stream::run clears those bits (settle) before every return that keeps
the reservoir, and unread clears them at output pauses and member ends, so
between calls the reservoir holds only accepted bits. The
Input computes its acceptable prefix (fast_end) once at construction. The
command loop holds the unread part of that prefix as a shrinking slice and
refills through refill_rest, so each refill tests one length (at least eight
bytes left) instead of an index and a length; the loop writes the cursor back
from the slice’s length when it leaves. The byte-exact stages refill through
refill_from with an index cursor. Input pauses retain the reservoir for incomplete fields. Output pauses and
member boundaries return whole buffered bytes accepted during that call.
Older bytes belonging to a partial field remain buffered. Returning read-ahead
at output pauses prevents a long pending copy from stranding the next member
in a previous call when it later completes without consuming more input. Every description reader (Builder,
ContextMap, Block) decodes symbols through decode_refilling, which takes a
whole word when the input allows and otherwise falls back to byte-exact loading.
Huffman codes are two-level lookup tables. A 256-entry root is indexed by the
next eight stream bits; a code longer than eight bits selects a second-level
table through a root entry that carries the combined width and the absolute
offset of that table in the group’s storage. An entry is one padding-free
u32 (width in the low byte, symbol or offset above), so storage zero-fills
as a plain memory set. Complete codes fill every entry, so decode_fast needs
no per-symbol validity check, and a single-symbol code fills the root with
zero-width entries and consumes no bits. A Group keeps every code of one
kind for a meta-block in one vector: the roots first, one per code, then the
second-level tables appended as codes are built, so only tables that exist are
written. The vector’s length is a high-water mark that only grows (zeroed only
when it grows) and used is the logical end, so a warm meta-block writes no
fill at all; prepare restarts the logical end without touching storage.
Tables borrows the whole group once so a loop that selects a code per symbol
pays one bounds check on the root. A single-slot group is an owned Huffman.
The command loop holds codes as Roots, a single pointer to a 256-entry root,
and passes each decode its group’s shared second-level storage, so the loop
keeps that storage once per group rather than in every table it holds.
The resumable Builder reads a simple or complex description into an intrusive
linked list per code length, which is already canonical order, so building a
table walks only the symbols with a code rather than the alphabet: fill
counts canonical codes upward, turns each into its table key with a byte
bit-reversal table, and replicates it across the root or the current
second-level table, having grown the vector once to the exact number of
second-level entries the code appends. second_size counts those entries by
replaying the canonical walk over the length counts alone, so a cold build
zero-fills only slots the fill overwrites rather than a loose worst-case bound.
build or build_slot fills an owned table or a group slot; reset abandons a
partial description. The code-length code of a complex description (18 symbols
of at most five bits) is a 32-entry table held inline in the builder and rebuilt
per description by a counting sort of its 18 lengths, so reading a description
allocates nothing and never fills a 256-entry root for it. A description’s start only zeroes the 18 code lengths when
it is complex, since those are the only lengths it may leave unset; both shapes
clear the per-length symbol lists themselves before filling them.
classDiagram
class Code {
u32: width low byte, symbol or offset above
bits() u32
value() usize
}
class Group {
codes: Vec~Code~ roots then second-level tables
count: usize
used: usize logical end
prepare(count, alphabet, memory)
table(tree) Table
tables() Tables
}
class Tables {
roots: &[[Code; 256]]
second: &[Code] whole storage
table(tree) Table
root(tree) Root
second() &[Code]
}
class Root {
&[Code; 256]
decode_fast(second, bits) usize
}
class Table {
root: &[Code; 256]
second: &[Code]
decode_fast(bits) usize
decode(bits, input) Option~usize~
decode_refilling(bits, input) Option~usize~
}
class Builder {
next: [u16; 1128]
head, tail, counts: [u16; 16]
code: LengthCode inline 32 entries
read(alphabet, bits, input)
build_slot(alphabet, group, tree, memory)
fill(codes, start, used, memory)
}
Group *-- Code
Group --> Tables : borrows
Tables --> Table : selects
Tables --> Root : selects
Table --> Root : decodes through
Builder --> Group : fills a slot
The block-switch state, context maps, prefix-code groups, distance layout and
dictionary-transform scratch a compressed meta-block needs live in a separate
workspace the stream creates on its first compressed meta-block and keeps with
the rest. A decoder that only ever sees stored members, metadata or an empty
stream never allocates or initializes it, so Decompressor::new and every such
decode stay allocation-free and copy a small state. The workspace is created
by copying the constant Tables::EMPTY (built from the EMPTY constants of
its builder, blocks, context maps, groups and distance layout) straight into
its allocation, rather than constructing it on the stack and moving it. The
builder’s per-symbol links are part of that constant but never read before a
symbol is pushed.
The stream keeps a power-of-two history ring. run first attempts a fast path
that decodes whole commands while a whole-word refill and output room allow: it
reads the command, literal run and distance from the reservoir and writes
literals and copies straight into the ring. Stage::Literals re-enters the
same loop mid-command whenever a whole-word refill is possible, so a run that
paused on output resumes in bulk and only its byte-exact remainder decodes one
symbol at a time. Stage::Command enters the loop only while a whole word of
input remains, since within the last word it could only pause again. Every hot
quantity lives in a local for the duration of the loop and is written back
only when the loop leaves: the bit reservoir, the input cursor, position and
the four-slot recent-distance cache. The meta-block’s end and the end of the
call’s output space are held as positions (meta_end, space_end and their
minimum limit_end), so the remaining length and free space are differences
from position rather than counters decremented per write.
Every pause and failure leaves through one labeled break to a single
write-back after the loop, so the loop has one exit edge. The command fields
the resumable stages read (pending literals, copy length, implicit flag,
distance and distance code) are not carried between iterations: each
iteration decodes them into locals, and only a pause that leaves them pending
writes the ones its stage reads (save!). A pause before the next command’s
fields, or a failure, which poisons the session, writes none. Rare branches
(block switches, ring growth, references beyond history, wrapping copies, and
every pause and failure) call an empty #[cold] function, which gives the
register allocator the block frequencies it needs to keep the per-command
quantities in registers and to place spills on the rare paths. A block switch
itself runs out of line in switch_block, which takes and returns the
reservoir by value so it never becomes addressable memory. Ring
bytes are delivered to the caller when a write reaches the ring end, when the
loop pauses and when an error returns, and the output space still free is
tracked as a running count that includes pending ring bytes, so no per-command
delivery is needed. A linear member makes no per-write ring-end test at all: its
history is the caller’s slice, delivered once when the loop leaves. State that
depends only on a block type (the literal context lookup, the root of each of
the block’s 64 literal contexts, whether those contexts share one tree and that
tree’s root, the command root, and the root of each of the four distance
contexts) is set when the loop is entered and refreshed only by the block
switch that changes it, so neither a command nor a literal compares block types
and a contextual literal or a distance symbol pays no bounds check on its code.
Each category’s Block counts down the elements left in its current block. A
category with two or more types reads a new type and count when the count is
zero and another element is needed. A category with one type starts at
16777216 (RFC 7932 section 10) and has no type code to switch with, so needing
an element after that count runs out is a meta-block error. This matches C
Brotli’s BROTLI_DECODER_ERROR_FORMAT_BLOCK_SWITCH, the check behind
CVE-2026-111497 (fixed in Brotli 1.2.1). Literals cannot exceed the count,
because a meta-block holds at most 2^24 bytes. Commands and explicit distances
can, through transformed dictionary words that decode to nothing. The fast
switch runs only when switch_ready reports a complete header and more than one
type. Otherwise the loop pauses and prepare takes the byte-exact switch or
returns the error, so the hot loop pays no extra test.
stateDiagram-v2
[*] --> Counting: header, count = BLEN or 16777216 for one type
Counting --> Counting: advance (count - 1)
Counting --> Exhausted: count = 0
Exhausted --> Counting: element needed, types >= 2 (switch_fast or prepare reads type and BLEN)
Exhausted --> Error: element needed, one type (prepare reports meta-block error)
Exhausted --> [*]: meta-block ends
For a trivial literal context map, the loop decodes three symbols per reservoir check when at least 45 bits are available. Each symbol consumes at most 15 bits. The scalar loop handles short input and the final one or two symbols; batching does not cross the existing block, output-space or ring boundary.
flowchart TD
Resume[Stage::Literals: saved insert, copy, implicit] --> Lit
Start[refill; command block switch?] --> Cmd[command symbol -> COMMANDS entry]
Cmd --> Extra[insert extra, copy extra]
Extra -->|insert > 0| Lit[literal run bounded by literals, block, space, ring end]
Lit -->|trivial block| One[one table per symbol]
Lit -->|context block| Ctx[context from p1, p2 -> per-context root]
One --> More{run done?}
Ctx --> More
More -->|reservoir short| PauseL[pause: Stage::Literals]
More -->|done| Dist
Extra -->|insert = 0| Dist{implicit?}
Dist -->|yes| Pop[cache.pop]
Dist -->|no| Sym[distance block switch?; distance symbol]
Sym -->|code 0| Pop
Sym -->|codes 1..15| Short[cache offset]
Sym -->|others| Tab[layout table entry: base + extra << postfix]
Pop --> Ref
Short --> Ref
Tab --> Ref{distance > position or max backward?}
Ref -->|yes| Dict[prefix: pause Resolve / static word, 16/32-byte chunk when it fits]
Ref -->|no| Limit{length > min remaining, space?}
Limit -->|past remaining| Corrupt[error: meta-block]
Limit -->|past space| PauseC[cache.push; pause: Stage::Copy]
Limit -->|fits| Push[cache.push; grow ring once per call]
Push --> Snap{length <= 32, no overlap, snapshot windows fit?}
Snap -->|yes| Word[16/32-byte array snapshot]
Snap -->|no| Copy{no wrap?}
Copy -->|yes, distance >= length| Exact[copy_within]
Copy -->|yes, overlapping| Overlap[fill, byte loop, or doubling]
Copy -->|wraps| Ring[copy_ring pieces]
Word --> Start
Exact --> Start
Overlap --> Start
Ring --> Start
Dict --> Start
The copy length is tested once against the smaller of the meta-block’s
remaining length and the free output space; only a failing length is then told
apart as corrupt input or as a full output, which pushes the distance and
pauses at Stage::Copy. A non-overlapping copy of at most 32 bytes goes
straight to a fixed-width helper: both of its complete windows fitting implies
that neither side of the copy wraps, so the loop tests the copy’s exact extent
only when the helper declines. The fixed-width copy helpers (copy_chunk
behind copy16 and copy32) move a fixed-size array: they test the later of
the two windows against the last start that fits, load the entire source
before storing it, and compile to vector loads and stores under the
dispatched backend’s target features without a library call. They accept
unaligned, initialized ring slices; if either window is short they leave the
ring unchanged and the caller uses copy_within for the exact length. A
static dictionary word of at most 32 bytes is written from the transform
scratch the same way (put_word), with the same fifteen-byte bound. A word
that a transform reduces to nothing writes nothing: the loop moves on to the
next command before put_word. Growth for a write happens only when the write
extends past the ring, so an empty word grows nothing. When position equals
the length of a ring still smaller than the window, position & mask is 0, and
a chunk store there would overwrite the oldest live history instead of
unwritten space. A 16-byte copy serves lengths 1–16; a
32-byte copy serves lengths 17–32, so at most fifteen bytes ahead of actual
output are overwritten. The ring’s sixteen unreachable history slots make
those extra writes unobservable. The snapshot is also correct when ring slots
physically overlap: all actual source bytes precede any stores. Logical LZ77
expansion still uses fill, sequential bytes, or prefix doubling; it cannot use
a snapshot of bytes not yet generated. A unit-distance run that must grow the
ring grows it with the repeated byte in one pass through repeat_grow, shared
with the byte-exact Stage::Copy, rather than zeroing the new region and then
overwriting it. On AVX2 a 32-byte move uses one vector;
SSE2/SSE4.2 split it into two 128-bit moves. Fallback remains available for
internal differential tests. Neither copy helper allocates or uses unsafe.
flowchart TD
Stage[Scalar run_stages] --> Kind{stage}
Kind -->|Command / resumable Literals| Dispatch[dispatch stored Backend]
Dispatch --> Fast[Specialized command loop]
Fast --> Copy[copy16 / copy32 inside the backend's feature context]
Fast --> Word{static word length}
Word -->|0| Next[next command, nothing written]
Word -->|1-32 and the chunk fits| Put[put_word: 16- or 32-byte store]
Word -->|otherwise| Ring[write_ring: exact bytes]
Kind -->|Copy with limited input or output| Bulk[dispatch once for copy_ring]
Bulk --> Copy
Kind -->|headers / raw / other stages| Scalar[Shared scalar implementation]
Copy --> Bounds{both full vector windows fit?}
Bounds -->|yes| Snapshot[load entire vector, then store]
Bounds -->|no| Exact[caller copies exact byte count]
The command symbol indexes a 1024-slot COMMANDS table (the 704-symbol
alphabet padded to a power of two, so a mask replaces a bounds check) that
carries both length bases and extra-bit widths, the implicit-distance flag, the
copy code for resumption and the distance context. Distances above the short
codes resolve through a per-symbol table that yields the extra width and the
base distance in one lookup. The common standard-window layout without postfix
or direct codes uses a shared constant table, so a fresh decoder neither fills
nor allocates it; other layouts fill a per-meta-block table, reused when the
layout repeats. The recent-distance cache is a ring with a push
index, like the reference’s dist_rb: an implicit distance or explicit code 0
pops the most recent slot and the copy pushes it back, so every command ends
with one slot write and no branch; a command that pauses before its copy, or a
prefix or static-dictionary reference, pushes the popped value back first. The
fast path returns to the byte-exact stages at every point where input or output
can run short, and those stages produce identical results and errors for any
chunking. Raw blocks, copies and prefix runs regenerate in bulk ring pieces
rather than one byte at a time; metadata is skipped in bulk. A copy near the end
of a small, highly expanding input still bulk-copies because Stage::Copy
copies a whole run.
Regenerated bytes update caller output and the ring together; the two most recent
output bytes for literal context are read back from the ring. Raw blocks update
the same ring as compressed blocks. The ring grows on actual output: the fast
path grows it once per call to cover what the call can still produce (bounded
by the meta-block’s remaining length and the caller’s output room, never by
the end of a literal insert longer than that room, so a workspace limit fails
only for history the call writes), otherwise it doubles up to the window size, so declaring a large window does not allocate it
eagerly, and a full ring wraps rather than growing. Distances and cumulative
positions use u64; a meta-block header validates position + MLEN once so the
hot path adds without checks, while the byte-exact stages use checked
operations and u128 for large-window wire distances. The theoretical large
alphabet is 16 + NDIRECT + (124 << NPOSTFIX) even for small large-window
headers. Every reachable distance symbol must have a maximum represented
distance no larger than (1 << 63) - 4, which also keeps every table entry a
decoded symbol can select within u64.
Prefix addresses traverse attachments in order, including empty slots. A copy
crossing the prefix end continues through the history present at command start.
If its distance exceeds retained window length, reusable prefix_history scratch
preserves original history before emitted prefix bytes overwrite it; overlapping
copies repeat the original virtual prefix/history sequence. This follows
RFC 9841 §3.2; C 1.2.0
rejects references crossing the prefix end, so this path has independent fixture
and unit-test evidence. Static addresses are
resolved before emitting, and transformed byte count reduces the meta-block’s
remaining output. A built-in word is transformed by apply_builtin, which
copies prefix, word and suffix as fixed 8- and 32-byte moves into the scratch
(shared-primitives.md). Zero-output references with distance <= 120 are invalid, avoiding
zero-bit command loops. Above that distance a transform that consumes its whole
word — OmitLast4 over a four-byte word, for instance — is legal and emits
nothing, so the ring write tolerates an empty write: as a member’s first command
it arrives with the ring still unallocated, and the ring mask is only derived
once there is something to store. Dictionary references do not enter the history distance
cache; normal/prefix references follow short-code cache update rules.
Owned one-shot output
core::stored recognizes a complete empty member or one raw meta-block followed
by an empty final block, using the same window/meta-block parsers and padding
validation as streaming. decompress borrows that payload and copies it into
one fallibly reserved Vec when resource limits are unset, member mode is single,
and the decoder is not abandoned. Other shapes use the regular session, which
also preserves configured resource-error ordering.
A fresh workspace decoding into a zero-capacity Vec without a dictionary, in single-member mode, can collect output in history. After the ordinary empty-output probe accepts the window, a private session call uses the window size as output capacity. The same state machine emits every byte into the ring and counts progress without copying it to a second slice. On successful completion and exact input consumption, ownership of the ring moves to the result, its length is truncated to actual output, and its capacity leaves workspace accounting. If the window fills before completion, copy that prefix into the caller Vec and resume ordinary delivery while retaining history. Appends, preallocated output, retained workspaces, dictionaries and concatenation use ordinary delivery.
flowchart TD
Owned[decompress] --> Stored{complete stored member and eligible policy?}
Stored -->|yes| Borrow[borrow payload; fallible reserve and one copy]
Stored -->|no| Probe[session with empty output: accept headers]
Probe --> Fresh{fresh workspace and zero-capacity Vec; single; no dictionary?}
Fresh -->|yes| Collect[same state machine; ring is destination]
Collect --> End{member completed before wrap?}
End -->|yes| Transfer[validate tail; transfer ring allocation to result]
End -->|no| Prefix[copy prefix; retain ring history]
Prefix --> Stream[ordinary session output]
Fresh -->|no| Stream
Before a ring wraps, raw growth reserves the new power-of-two allocation, writes raw bytes into existing initialized slots and appends the remainder, then initializes only padding. A unit-distance copy in the resumable copy stage can fill new ring storage directly with its final byte; the existing unwritten suffix gets the same byte. Both check the workspace reservation before changing history or position. Other copies and wrapped history keep the established SIMD/baseline kernels. No uninitialized slices or new unsafe code are used. See owned decoder output for evidence and limitations.
Allocation and dictionary boundaries
Memory::resize checks arithmetic and workspace policy before try_reserve_exact,
growing a non-empty buffer to at least twice its capacity so a workspace that
fills in steps reallocates a logarithmic number of times; Memory::reserve is
the capacity-only half. Growth conservatively includes both old and replacement
buffers, since an allocator may implement realloc as allocate/copy/free.
Reported retained bytes count current owned capacities. The history ring,
context maps, Huffman groups, the distance symbol table and rare
prefix-crossing scratch reuse capacity across operations; Huffman group storage
never shrinks, so a smaller meta-block does not reinitialize it. Buffers whose
every entry is written before it is read are refilled instead of zeroed first:
a context map with more than one tree reserves its size and appends each
decoded entry and zero run in order (a one-tree map is the only one zero-filled),
the distance table is cleared and extended with every symbol’s split, and
prefix-crossing scratch is extended with the history bytes it preserves. Their
allocations stay; only their lengths follow the current meta-block.
flowchart LR
Trees{"context map trees"} -->|one| Zero["Memory::resize: zero-filled map"]
Trees -->|several| Reserve["Memory::reserve(size)"]
Reserve --> Append["Entries push / Repeat resize(end, 0)"]
Append --> Full["len == size before any read"]
Layout["new distance layout"] --> Clear["table.clear + Memory::reserve"]
Clear --> Extend["extend with every symbol's split"]
``` There is no allocation per output byte or transform. Header-only and metadata-only
streams need no heap workspace.
`DecodeDictionary` copies RAW payloads without encoder search structures. Source
budget counts all supplied attachment bytes; owned budget includes peak storage
while replacing serialized descriptions. RAW is never magic-sniffed. Empty RAW
attachments consume one of 15 slots; an empty embedded serialized prefix does not.
Experimental `Description` owns source bytes and compact offsets/layouts, applies
C attachment replacement rules, validates list/transform/context indexes, and
resolves combinations through the borrowed view. The old custom mapping remains
live until a successful replacement; prefix-only descriptions reset it to built-in.
Prepared dictionaries resolve through their existing immutable static index and
prefix representation. Common transform application lives in `shared::dictionary`.
## Executable API contracts
Rustdoc examples beside the public decoder APIs exercise complete Vec/slice
decoding, append rollback, unchanged slice tails, output backpressure with a
two-byte buffer, exact-size validation, concatenated members, session recovery,
retention and independent workspaces. The session example advances by per-call
counts and reoffers precisely the remaining suffix under `Finish`; it also
checks aggregate counters and the accepted window header.
Reader examples recover protocol suffixes by chaining `unread_input` before the
returned source. Writer examples explicitly finalize and distinguish codec
completion from delivery and sink flushing. Decode-only dictionary examples run
without compression; the encoder/decoder prefix round-trip example is gated by
`compression`. They document that dictionary identity is a caller contract.
```mermaid
flowchart LR
Docs[Public rustdoc examples] --> Buffers[Vec rollback and slice prefix checks]
Docs --> Session[Finish suffix and progress checks]
Docs --> IO[Reader suffix and writer finalization checks]
Docs --> Dictionary[Owned dictionary and borrowed session checks]
I/O and error propagation
sequenceDiagram
participant Caller
participant Writer as DecoderWriter
participant Session
participant Sink
Caller->>Writer: write(compressed)
Writer->>Sink: drain pending outbox
Writer->>Session: process(input, outbox, Process)
Session-->>Writer: progress or failure with counts
Writer->>Sink: write output, track exact cursor
Writer-->>Caller: Ok(consumed) if input accepted, defer error
Caller->>Writer: try_finish / retry
Writer->>Session: Finish(empty), once and without new input
Writer->>Sink: drain suffix, flush
Writer-->>Caller: success or recoverable sink failure
Reader owns an 8 KiB compressed-input buffer and writes directly into caller output.
Empty reads do nothing. Interrupted retries; WouldBlock preserves state; physical
EOF declares Finish. An error after produced bytes is deferred to the next
nonempty read. Codec errors remain terminal; no false EOF follows them. Single
mode preserves read-ahead; into_parts returns source, unread suffix and verified
finished state.
Writer owns an 8 KiB output outbox with cursors. An operation accepting input
returns Ok(n) even if a codec or sink error follows; that error is deferred.
Produced bytes survive errors until delivered. Delivery of a failed codec’s
outbox can retry without re-entering the codec. Interrupted retries, short writes
advance only their prefix, and write-zero is an error. Flush does not declare
EOF. Once finalization begins, new input is refused; only sink delivery/flush
can retry. finish uses the original io::FinishError<Self> type and retains the
adapter on failure. Drop performs no I/O.
thiserror public non-exhaustive errors categorize wire violations and resource,
size, policy, tail and lifecycle failures. Core returns these high-level categories
without exposing private implementation errors. DecodeFailure preserves the
source chain; conversion to std::io::Error retains the concrete codec error.
Known gaps
The decoder specializes the command loop and its fixed-size history copies,
while Huffman symbol decoding retains its serial dependency chain. Long logical
overlap uses fill, byte copies or doubling rather than independent SIMD lanes;
context-map transforms and table construction remain scalar. The new SIMD
kernels have x86_64 execution evidence only; Arm/NEON and AVX-512 still need
native validation. See decoder SIMD measurements for the
profile, benchmark results and code-size tradeoff. The prefix-crossing
history path and short static-dictionary words still emit byte by byte, which
does not affect the primary corpora. A first decode on a fresh workspace pays
about a dozen separate allocations (ring, three Huffman groups, block and
context-map codes, context maps, the distance table) and the one-time zero fill
of the storage that is still zero-filled (ring tail, Huffman groups, one-tree
context maps), which dominates decoding of payloads of a kilobyte and below
in the cold shape; the per-meta-block tables are not yet folded into one arena.
Appended and reused Vec destinations still zero-fill newly exposed output
slices; fresh owned results can instead take the history allocation. Completely
stored members need no history. Small compressed streams still pay entropy-table
initialization and session setup. Compatibility evidence and remaining acceptance gaps
are tracked separately in
decompressor compatibility. There is no container
parser, authentication, seek, or async runtime in this raw decoder.
Experimental framed driver
The separate decompressor::framing::core uses the existing private Stream
directly. It owns content boundaries and passes short-lived dictionary views.
set_framed_workspace_limit adjusts the remaining outer heap ceiling before
a framed content call; it changes no raw session transitions or hot loops.
See framed decoder for the independent public lifecycle.
graph LR
Raw[raw DecoderSession] --> Stream[private core::Stream]
Framed[framing::core driver] --> Stream
Framed --> Budget[aggregate framing and dictionary budgets]
Budget --> Limit[remaining raw workspace ceiling]
Limit --> Stream
Owned decoder output
The owned Vec path can recognize stored members directly or transfer its history allocation to the caller. The one-shot slice path decodes its first member with the caller’s slice as history. The same decoder state machine enforces decoded bytes, trailing-data policy, window limits, dictionaries and resource budgets.
Ownership, control and data flow
Private core::stored first recognizes the exact three-byte header consisting
of a four-bit standard window, a non-final raw block and a 16-bit length. It
checks the declared window, exact payload length and final byte 0x03. Other
shapes use the existing demand-driven window/meta-block parsers to recognize
an empty member or one raw block followed by a final empty block.
It verifies window policy, all padding, exact payload bounds and the absence of
a tail, then returns a borrowed slice. Unsupported or malformed shapes return
None and enter the normal driver, which owns public error reporting.
decompress uses this path only for single-member operations without numeric
limits and without an abandoned session. It reserves fallibly, copies once,
and applies retention policy. No full state machine or history is necessary.
Otherwise the Vec driver probes with empty output. A zero-capacity destination,
fresh workspace, single-member mode and absent dictionary permit collection.
The private session passes Output::collect = Some(window_size) to the same
state machine. Ready/fast-end still enforce output policies. Emit and flush
advance progress but do not copy ring bytes to an external slice. Collection
stops before history wraps. Successful complete input transfers the ring’s
allocation, truncates its logical length to produced bytes and subtracts its
capacity from live workspace accounting. If the member is larger than its
window, the collected prefix is copied once into the destination, history is
retained and ordinary streaming delivery resumes. Appends, preallocated Vecs,
retained workspaces, dictionaries and concatenation use ordinary delivery.
flowchart TD
Owned[Owned decode] --> Stored{eligible complete stored member?}
Stored -->|yes| Borrow[borrow validated payload]
Borrow --> Copy[fallible allocation and one copy]
Stored -->|no| Probe[probe existing session with empty output]
Probe --> Eligible{fresh single member and empty allocation?}
Eligible -->|yes| Collect[decode into history; retain progress and limits]
Eligible -->|no| Stream[ordinary slice delivery]
Collect --> Complete{finished before wrap?}
Complete -->|yes| Tail[validate exact input end]
Tail --> Transfer[move allocation to caller; subtract workspace bytes]
Complete -->|no| Prefix[copy prefix; preserve history]
Prefix --> Stream
Before the first wrap, raw growth reserves a power-of-two allocation, copies into existing initialized slots and appends the remaining raw bytes, then zeroes only unused padding. A growing unit-distance copy in the resumable copy stage fills newly allocated slots with their final byte. Both reserve under workspace accounting before modifying history/position. Wrapped and other copies retain the existing baseline/SIMD kernels. All slices remain initialized, all output bytes are actually materialized and SIMD dispatch stays outside the command and copy loops.
flowchart LR
Grow[history must grow] --> Reserve[check peak workspace; fallible reserve]
Reserve --> Raw[raw: append source bytes then initialize padding]
Reserve --> Repeat[unit distance: resize with repeated byte]
Reserve --> Other[other paths: ordinary zeroed growth]
Raw --> History[initialized power-of-two history]
Repeat --> History
Other --> History
Linear slice history
decompress_to_slice and decompress_with_dictionary_to_slice end their
session with one call, so no later call needs the history of a paused member.
They call the private finish_linear, which passes Delivery::Linear to the
operation; the appending Vec path of decompress_into (and of decompress
on a reused decoder) calls it once per growth step with dst[start..], and
decompress_to_uninit once per zeroing step. The operation turns it into Output::linear while the first member
is still being decoded (members == 0 and no pending boundary), so the
member begins at dst[0] and every history position equals its slice index.
A Linear slice holds everything the operation produced so far in front of
the space a call may write: each call resumes at total_out
(Output::produced starts there and total_before is total_out less it),
which lets decompress_to_uninit and decompress_into grow one zeroed
buffer across calls without losing history (see uninitialized
output and
decompressor). A vector that reallocates between calls
keeps its contents, so its indices stay the history positions. A slice shorter than
total_out fails the operation with InternalInvariant. The member boundary
clears the flag; later members in concatenated mode use the ring as before,
delivered at their offset in the same slice.
Output::linear is read once per call, by Stream::run, and nowhere below it.
run picks one of two instantiations of the state machine:
run_stages::<O, true> when the sink can be history (Sink::LINEAR, true
only for &mut [u8]) and the flag is set, run_stages::<O, false> otherwise.
The const LINEAR: bool parameter reaches fast, emit, history,
flush_ring, copy_ring and write_ring, so every “linear or ring?” test is
a compile-time constant. An uninitialized sink never instantiates the linear
machine: its Sink::LINEAR is false, so the linear arm is unreachable for it
and is not generated.
The linear command loop therefore carries none of the ring machinery. Ring
growth (reach!, grow_ring), the unit-distance growth fill
(repeat_grow), the wrapping copy_ring fallback, the growth limit and the
ring allocation are all absent, and mask is the constant u64::MAX, so a
position is used directly as a slice index. The command loop is
register-bound, and the dead ring code had shared its registers. Removing it
cut decoded instructions for decompress_to_slice by 6.6% on average and
by up to 12.8% on text (see Known gaps for the one corpus
where time did not follow).
With linear set the caller’s slice is the member’s history. The ring is
neither written nor grown. emit, raw copies and the byte-exact literal run
write only the slice. Context bytes, prefix-crossing history and every copy read
it. Delivery (flush_ring) advances produced without copying, as in
collection, so the linear command loop skips the ring-end test after each
literal run and copy and delivers once, when it leaves. The command loop and the resumable copy and prefix stages take the
slice out of Output (core::mem::take) for their bulk work and put it back
on every exit. The loop’s leave! does this on pauses and errors.
Bulk work sees the slice only up to history_end: the smaller of the call’s
fast end and position + remaining of the current meta-block. The 16- and
32-byte copy kernels may write up to fifteen bytes past a copy, and the bound
keeps those bytes inside output this meta-block writes. On success or
OutputTooSmall the slice matches ring delivery byte for byte, including an
untouched suffix. After other errors, bytes past the delivered prefix may hold
partial output of the failing meta-block. The method has always documented
that written bytes are not rolled back.
history_mask indexes both buffers: a power-of-two length gives len - 1
(the ring wraps; a power-of-two slice is never indexed at or past its length,
so the mask is the identity), any other length gives u64::MAX. The copy
kernels and previous_bytes share it, so the SIMD and baseline copies are the
same code in both modes. The linear command loop skips it and uses u64::MAX,
the identity on every position it holds.
A copy in the linear command loop always fits unsplit. space bounds it by
out_end - position (produced equals position for a linear member), and
remaining bounds it by the meta-block, so position + copy <= history_end,
which is the slice length the loop sees. If this invariant ever broke, the
branch that the ring machine spends on copy_ring returns
DecodeError::InternalInvariant instead of panicking.
sequenceDiagram
participant API as decompress_to_slice
participant Op as OperationState
participant Stream as Stream::run
participant Fast as fast / Copy / Prefix
API->>Op: finish_linear(src, dst)
Op->>Op: linear = fresh operation
Op->>Stream: Output { bytes: dst, linear }
Stream->>Stream: O::LINEAR && linear → run_stages::<O, true>
Stream->>Fast: take dst, bound to history_end
Fast->>Fast: decode and copy inside dst
Fast-->>Stream: restore dst, produced += delivered
Stream-->>Op: Stop::Member
Op->>Op: linear = false for later members
Op-->>API: progress
flowchart TD
Call[Stream::run] --> Pick{Sink::LINEAR and Output.linear?}
Pick -->|yes| LinearMachine[run_stages::<O, true>]
Pick -->|no| RingMachine[run_stages::<O, false>]
LinearMachine --> Slice[write dst at position; no ring code compiled]
RingMachine --> Ring[ensure ring; write position & mask]
Ring --> Deliver{delivers?}
Deliver -->|slice delivery| CopyOut[flush copies ring bytes to dst]
Deliver -->|collect| Count[flush advances produced]
Slice --> Count
Read-ahead accounting
At output pauses and member boundaries, Bits::unread returns speculative whole
bytes accepted by the current call so the caller can reoffer the exact suffix.
At input pauses, incomplete fields retain accepted bits. Bytes accepted by an
earlier call are never subtracted from the current call’s consumed count.
stateDiagram-v2
Decode --> InputPause: field incomplete
InputPause --> Decode: retain bits; accept next chunk
Decode --> OutputPause: destination or collection window full
OutputPause --> Decode: unread current-call whole bytes; reoffer suffix
Decode --> MemberEnd: validate padding and unread tail
MemberEnd --> Decode: reset for next member
Public error conversion and lifecycle rules remain in the decoder API and state machine. Boundary tests exercise stored recognition, transfer, history wrap, concatenation, limits and read-ahead.
Known gaps
- Recognition covers empty members and one raw block plus terminator; metadata and mixed/multiple data blocks use the full driver.
- Collection requires a fresh workspace and destination; transfer gives the caller a power-of-two allocation and leaves no history window for reuse.
- Appended and reused Vec output initializes newly exposed destination slices.
- Linear history covers only the first member of a one-shot operation; sessions, later concatenated members and a collected member that outgrew its window deliver from the ring. Switching the latter to linear delivery after the collected prefix was copied out measured 1.8 times slower (8 MiB mixed corpus, q5/q9) and was not kept.
- The linear and ring command loops are separate instantiations, which adds
about 60 KiB of
.textto a program that uses bothdecompress_to_sliceand a ring path. Removing the ring code from the linear loop left its register allocation worse in one place: the second-level Huffman lookup of the three-symbol trivial-context literal batch.mapsdatazrhat q1, q5 and q9, whose literals often take that path, decodes about 2% slower throughdecompress_to_sliceeven though it executes 7% fewer instructions. 1 MiB text decodes 3-6% faster. The ring instantiations execute the same instructions as before (+0.1%) but measured 0.2-1.3% slower, and uninitialized output about 3% slower, which is at the edge of what the benchmark resolves. - Small compressed streams still build entropy tables and session state.
Decoder literals and stored headers
Private decompressor::core::stream owns literal decoding inside the
backend-specialized command loop. core::stored recognizes complete stored
members for eligible owned-output calls. No additional public API is exposed.
Literal batches
The context-free literal loop decodes three symbols per reservoir check. A Huffman symbol consumes at most 15 bits, so 45 buffered bits suffice. Whole-word refill leaves at least 56 bits; it reads only a checked eight-byte slice inside the input budget. The batch is bounded by the pending literal count, literal block count, output space and ring end. It cannot cross a block switch or wrap. Contextual literals continue using a context-dependent loop, which reads each literal’s code from the block type’s 64 per-context roots, resolved when the block type is set, so it pays no bounds check on the tree index.
flowchart TD
Run[bounded literal run] --> Context{trivial context map?}
Context -->|no| Existing[context-dependent loop]
Context -->|yes| Batch{at least 3 output slots?}
Batch -->|yes| Bits{45 bits buffered or refill succeeds?}
Bits -->|yes| Three[decode 3 symbols; consume at most 45 bits]
Three --> Batch
Bits -->|no| Scalar[scalar remainder: check 15 bits per symbol]
Batch -->|no| Scalar
Scalar --> State[update position, remaining counts and resumable stage]
Existing --> State
The scalar remainder also runs when input cannot refill a batch but still has one or two decodable symbols.
Byte-exact literal runs
Within eight bytes of the input’s acceptable end the command loop cannot refill,
so the resumable Stage::Literals decodes the rest of the run. It settles the
pending block switch, the context mode, the context-map row and the run bound
(pending literals, block remainder and output room up to the call’s fast end)
once, then loops over symbols with byte-exact loading, writing the ring and the
destination directly. Counters are committed before any pause or error leaves,
so progress matches the per-symbol path. When the command loop could still
refill, the stage decodes one symbol and re-enters it instead, since it stopped
at a boundary it does not cross. Small streams, whose whole input lies inside
that tail, decode all their literals here.
flowchart TD
Stage[Stage::Literals] --> Ready{output ready?}
Ready -->|no| PauseOut[Stop::Output]
Ready -->|yes| Room{eight bytes before fast end?}
Room -->|yes| Fast[command loop]
Fast --> Again{literals pending and eight bytes still left?}
Again -->|yes| One[one symbol] --> Stage
Again -->|no| Settle
Room -->|no| Settle[block switch, context row, run bound]
Settle --> Loop[decode byte-exactly up to the bound]
Loop -->|input exhausted| Commit[commit counters; Stop::Input]
Loop -->|bound reached| Stage
``` Input/output pauses, consumed/produced counts,
resource failures and public error propagation follow the shared decoder contract.
SIMD dispatch remains outside the command loop; batching adds no feature
selection and uses safe ordinary Rust because the entropy dependency is serial.
## Stored headers
Stored recognition checks a common fixed header directly: four bits of
standard window declaration (18–24), non-final flag, four length nibbles and raw
flag occupy exactly 24 bits. The shape check is `(header & 0x800071) == 0x800001`, with a
nonzero window code in bits 1–3. The payload length is bits 7–22 plus one. The
window must satisfy the configured limit, total length must equal payload plus
four bytes, and the last byte must be exactly `0x03` (final empty block with zero
padding). Checked slicing borrows the payload. Other header shapes retain the
general parser. Rejected recognition returns to the full driver,
which produces the public typed error. Numeric limits and abandoned-session
ordering are guarded by the public owned-decode entry point.
```mermaid
flowchart TD
Input[complete input, eligible owned decode] --> Header{three-byte raw header shape?}
Header -->|yes| Exact{window allowed, exact length, final byte 03?}
Exact -->|yes| Borrow[borrow payload then fallible reserve and one copy]
Exact -->|no| Driver[full decoder: typed error or supported alternative]
Header -->|no| General[general stored parser]
General -->|recognized| Borrow
General -->|not recognized| Driver
Invariants and known gaps
Batching adds no allocation or feature detection. Literal decoding is serial entropy work; the selected backend is passed into the surrounding command loop. Tests compare output, progress and failures against the baseline and available host backends, including truncated headers and short output buffers.
Contextual literals use their own loop, and stored shapes outside the recognizer use the full decoder. See owned output for eligibility and retention, and decoder mechanics for errors and lifecycle.
Decoder SIMD dispatch
The constructor validates and stores a backend. Private decompressor::core
dispatches before command loops and bulk copy stages, passing a concrete
fearless_simd::Simd token into generic helpers. Headers, raw blocks and
resumable parsing stages share scalar control flow.
The SIMD choice is the innermost of three compile-time selections.
Stream::run first fixes the sink type and whether the member decodes into
linear slice history (run_stages::<O, LINEAR>). Each of those state machines
then dispatches the backend at its command loop and copy stages, so every
backend is compiled once per instantiation.
flowchart LR
Run[Stream::run] --> Machine[run_stages per sink type and LINEAR]
Machine --> Backend[validated Backend]
Backend[validated Backend] --> Dispatch[dispatch outside command or copy loop]
Dispatch --> Control[serial Huffman and command dependencies]
Dispatch --> Copy[16/32-byte array snapshots]
Control --> State[shared output, progress, limits and errors]
Copy --> State
The dispatch gives the command loop its backend’s target features: the bit
operations compile to BMI2 shifts and masks where available, and the short
copies are fixed-size array moves (copy_chunk) that those features turn into
one or two vector loads and stores. Copies load the complete source before
storing and accept unaligned initialized slices. One test of the later window
against the last start that fits covers both windows; a short boundary uses an
exact-length copy. Speculative writes remain within at most fifteen
unreachable ring bytes. Long overlapping expansions use fill, byte copying or
prefix doubling. Feature checks and virtual calls do not occur per byte or command.
Native decompressor specifies wrap, overlap, dictionary and error invariants. Owned output specifies history growth and transfer; those ownership rules are shared across backends.
Verification and known gaps
Unit tests use a bytewise ring oracle and snapshot-copy cases. Differential tests explicitly exercise fallback and every backend available on the host, checking payload, consumed input, errors, limits and streaming progress.
Run the root decompress Criterion benchmark for retained slices, streaming and
backend comparisons; the separate library comparison
measures cold owned APIs. Profile with hotpath before changing these kernels.
Huffman construction and context-map transforms remain scalar. Backend tests cannot validate instruction sets absent from their execution host; compilation alone does not establish runtime correctness or speed.
Native decoder verification
The decoder is checked against the pinned Google C decoder and independent wire fixtures. Decoder mechanics describe the implementation; validation describes the library-wide contracts.
Oracle and data flow
google-brotli-ffi is a development dependency. C encoding in
tests/decode_support/c_encoder.rs produces input independently of the Rust
encoder. tests/decode_support/wire.rs writes explicit RFC fields. Experimental
oracle builds enable BROTLI_EXPERIMENTAL; extended decoding enables C’s Large
Window option. Both decoders receive the same ordered dictionary attachments.
flowchart LR
CProducer[C one-shot, streaming and CLI] --> Bytes[compressed bytes]
Wire[independent RFC field assembler] --> Bytes
Bytes --> C[C decoder]
Bytes --> Rust[Rust decoder in isolated feature profiles]
C --> Compare[payload and consumed member boundary]
Rust --> Compare
Expected[original input or fixture expectation] --> Compare
Rust --> Contracts[chunking, limits, allocation, lifecycle and I/O faults]
The fixture manifest records producer, flags, dictionary recipes, schedules, lengths and hashes for saved fixtures. Vendored compressed fixtures with unknown original flags establish format compatibility, not a specific producer parameter combination.
Coverage by boundary
| Boundary | Checks |
|---|---|
| Parameters | Source qualities 0–11, modes, windows, block sizes, size hints and all legal distance-parameter pairs |
| Wire format | Raw/compressed/metadata/final blocks, Huffman repeats, context maps, padding and built-in transforms |
| Scheduling | PROCESS/FLUSH/FINISH, C TakeOutput, continuations, every split of small fixtures and short output buffers |
| Dictionaries | Ordered RAW attachments, prepared/decode-only views; serialized/custom transforms and contexts with experimental |
| Completion | Truncation, tails, concatenated members, aggregate budgets and reader read-ahead |
| Resources | Allocation failures, exact input/output/workspace limits, retention, recovery and I/O retry |
| Large inputs | Ignored release tests for real 25–30-bit distances and cumulative output beyond 4 GiB |
| Platforms | Fallback and host SIMD execution; isolated codec imports and alloc-only compile checks |
These checks run in separate std and no_std profiles, each with and without
experimental. I/O tests require std APIs. --all-features activates no_std
and does not replace the std profile. Feature isolation scripts also verify that
unsupported imports fail to compile.
Running checks
Use development for workspace, coverage and AFL commands. The isolated decoder gates and an alloc-only target check are:
python3 scripts/check_decoder_features.py
cargo check --lib --no-default-features --features no_std,decompression --target thumbv7em-none-eabi --locked
Run ignored heavy tests separately; they regenerate large output through bounded buffers and can require nearly 3 GiB of combined history:
cargo test --release --locked --no-default-features --features std,decompression --test decompress_heavy -- --ignored --nocapture --test-threads=1
Repeat with std,decompression,experimental, no_std,decompression, and
no_std,decompression,experimental. CI runs this matrix on v* tags
and manual dispatch. Miri, AddressSanitizer and the decoder AFL targets provide
additional checks; committed regressions replay through the same target bodies.
Known gaps and oracle limits
- C verifies windows only through 30 bits. Windows 31–62 use independent headers and a full-width distance model, without allocating those physical windows.
- Rust supports prefix-to-history copies that the pinned C decoder rejects when a copy exceeds the complete prefix. Independent fixtures check this extension.
- The RAW continuation producer uses nonzero caller output buffers: a TakeOutput-only schedule can leave C’s restart bytes pending and fail its own round trip. Ordinary TakeOutput cases are tested separately.
- A bare-metal compile check is not a 32-bit or Arm runtime test. Backend correctness needs execution on a host supporting that backend.
- The raw decoder does not decode Shared Brotli framing containers.
- Passing bounded tests and fuzz campaigns does not prove absence of bugs.
Framed decoder
Implementation work follows specifications/mbrotli-framed-decoder-llm-spec.md.
The shared wire facade is framing; the public decoder facade is
decompressor::framing, with private parsing and state in its core.
Wire validation traceability
| RFC 9841 section | Parser / validator | Positive tests | Negative tests |
|---|---|---|---|
| 4 | core::wire::number | nonminimal varints | ninth continuation byte |
| 8.1 | detection | split signature, both profiles | version, dictionary input |
| 8.2 | core::wire::header, codec driver | codecs, continuation | short header, invalid codec |
| 8.3 | core::wire::metadata | ordered custom fields | reserved fields, truncation |
| 8.4.1 | padding phase | zero-length, terminal padding | nonzero padding |
| 8.4.2 | metadata/order | resource metadata | orphan metadata |
| 8.4.3–8.4.6 | resource/order | full and partial resources | partial order, flags |
| 8.4.7–8.4.8 | metadata/order | footer/global metadata | invalid reserved fields |
| 8.4.9 | repeats | selected fields and dependencies | mismatched copies |
| 8.4.10 | directory | exact original header bytes | offset/header mismatch |
| 8.4.11 | footer | unspecified and specified sizes | size/directory mismatch |
| 8.4.12 | order | padding-transparent order | interrupted partial resource |
The behavior groups above are exercised by the independent integration fixtures and private wire tests. Execution evidence and its scope are recorded below.
graph TD
Facade[framing: shared types and codec re-exports] --> Decoder[FramedDecompressor]
Decoder --> Session[exclusive FramedDecoderSession]
Session --> Core[private framing::core]
Core --> Wire[bounded wire parser]
Core --> Raw[existing decompressor::core::Stream]
Core --> Resolver[session-borrowed dictionary resolver]
Core --> Output[caller output: one resource fragment per event]
stateDiagram-v2
[*] --> Detection
Detection --> Raw: Auto and first byte is not 0x91
Detection --> Framed: signature and header
Raw --> Complete: first member ends
Framed --> Chunk: parse header
Chunk --> Content: semantic events
Content --> Chunk: local completion
Chunk --> Complete: validated footer or footerless EOF
Complete --> [*]
Detection --> Failed: invalid input
Chunk --> Failed: invalid structure
Content --> Failed: codec or budget error
Bit 2 follows RFC 9841 §8.1: set means a mandatory footer. This is the product specification’s resolution of the contradictory final paragraphs of §8.4.12, not a claim of official errata. The full profile permits zero resources and metadata-only objects. Nonminimal varints are accepted within the nine-byte, 63-bit grammar.
Known gaps
No async runtime, extraction, authentication, automatic dictionary fetching,
or concatenated top-level objects are provided. Indexed random access is described
in Framed seek reader.
Only the current host can execute its available SIMD levels; other architectures
need native execution evidence. Runtime checks and measurements are recorded below.
Workspace accounting (Engine::framing_bytes) sums every chunk and metadata
record on each budget check, so a container with many records costs time
quadratic in their number; the 2026-09-26 AFL campaign saved two such inputs
(0.57 s standalone under instrumentation). A running total would remove it.
Public ownership and lifecycle
FramedDecodeConfig::new() returns the default policy as a const fn
(framed input only, the DecoderConfig::new() window limit, default
FramedDecodeLimits, Retain); Default delegates to it.
FramedDecompressor owns configuration, the selected Backend, retention policy,
raw Stream workspace and framing bookkeeping. start and
start_with_dictionaries create an exclusive FramedDecoderSession;
into_session and into_session_with_dictionaries create a
FramedDecoderSessionOwned that owns the decoder and a
R: DictionaryResolver + 'static resolver. Both go through one private
begin_session, one session_process event path over Engine::process, and
cancel on release. See owned sessions. Sessions and the std seek reader borrow resolvers through
DictionaryResolverRef. Dictionary-taking
entry points accept impl Into<DictionaryResolverRef<'dict>>; borrowed concrete
resolvers convert without allocation. The wrapper privately erases the resolver
type for attachment-boundary dispatch. Raw decoder state receives short-lived dictionary
views during run; neither workspace stores dereferenceable external pointers.
mem::forget(session) or forgetting a seek reader leaves the owner’s active flag set. trim preserves that
flag; recover and successful reconfigure clear it. Drop clears logical records
and dictionary attachments, retaining reusable outer buffer capacities and raw
workspace according to retention. Nested header and metadata allocations are
transferred to output or released. fork_empty copies policies without storage.
classDiagram
FramedDecompressor *-- Engine
Engine *-- Stream
FramedDecoderSession --> FramedDecompressor : exclusive borrow
FramedDecoderSession --> DictionaryResolver : immutable operation borrow
FramedDecoderSessionOwned *-- FramedDecompressor
FramedDecoderSessionOwned *-- DictionaryResolver : optional owned R
FramedReader *-- FramedDecoderSession
FramedReader *-- BufRead
Engine --> FramedOutput : transfer validated metadata and layout
One-shot methods use the same event driver as incremental decoding. decompress
collects directly into one Vec per resource. decompress_into appends only payload
and returns absolute ranges, rolling back the original length on every error.
decompress_to_slice returns slice-relative ranges and preserves its written
prefix on failure. Its failure aggregates accepted/delivered counts but attributes
only the last written fragment to its resource. Returned structures borrow neither
source, resolver, owner nor destination. Resource checksums are recorded, never
verified. Names remain unnormalized data.
Every process call produces at most one event. A successful payload write always
returns ResourceData borrowing exactly the written output prefix. Headers,
metadata, directory entries and footer are separate events. Event borrows prevent
further mutable session access. ResourceDataEnd is local payload completion;
late directory/footer checks can still reject the object.
sequenceDiagram
participant Caller
participant Session
participant Engine
participant Raw as raw Stream
Caller->>Session: process(input, output, operation)
Session->>Engine: validate final boundary, advance one event
Engine->>Engine: bounded header and ordering validation
Engine->>Raw: content slice bounded by chunk end, selected backend
Raw-->>Engine: accepted bytes, produced bytes, stop reason
Engine->>Engine: aggregate limits, content-size validation, cache policy
Engine-->>Session: owned event descriptor and exact counts
Session-->>Caller: borrowed header, metadata or output view
The first Finish fixes the absolute final-input boundary. Subsequent calls must
use Finish and the exact remaining suffix length; bytes themselves remain the
caller’s responsibility. Failed sessions reject all further calls with zero new
progress. Completed calls return zero-progress Finished repeatedly. Raw and full
framing stop at their first complete object and leave a transport suffix. Strict
one-shot calls reject that suffix. Footerless framing needs EOF after its one
resource and accepts terminal padding before EOF.
Content, references and validation
core::wire accepts bounded nine-byte varints without imposing minimal encoding.
The incremental header buffer holds only the current header; exact original bytes
move into ChunkInfo. Padding consumes zero bytes without storing their content.
Directory parsing stages one entry and compares its offset and original header
bytes with every content record in order. Footer reversed varints are bounded by
18 bytes and checked against actual object size and directory offset.
Brotli state is independent of logical resource state. KeepDecoder resumes an
unfinished preceding compressed stream, including metadata-to-resource and
repeat-to-repeat transitions. Padding is transparent. A fresh codec or an
uncompressed content chunk cannot discard an unfinished compressed stream; a
completed stream cannot be continued. This is the implementation’s explicit
interpretation of the preceding-stream language in RFC 9841 §8.2. Declared sizes
are checked against actual per-chunk regeneration, not the raw session’s whole
member exact-size guard. Standard Brotli rejects Large Window headers even when
the configured ceiling otherwise permits them; Shared Brotli and raw Auto obey
the user’s window ceiling.
Metadata retains original decoded bytes and a field index. Reserved codes are validated by scope; uppercase bytes and duplicate custom codes remain ordered. Repeated records are paired with originals in original order, preserve explicit origin/scope, and validate selected fields with their full multiplicity. A fixed 678-code table prevents repeated custom codes from multiplying the final consistency checks. Repeated series may retain their own decoder and reference an earlier repeated chunk, but cannot depend on earlier non-repeated content.
Internal resource payload uses one contiguous cache with chunk/resource spans;
metadata references borrow the original metadata serialization. A resource target
must start at a full/first data chunk and be complete. A chunk target must have
finished local decoding. External references resolve only through the supplied
resolver, in wire order, once when preparing the attachment set. Prefix content
is never sniffed as serialized content. Preparation uses DecodeDictionary,
including effective prefix slot limits and serialized dictionary validation.
Memory and dispatch
Numeric ceilings distinguish None and zero. Input counts accepted wire bytes
once. Output counts all delivered resource payload, including hidden resources.
Decoded bytes include resources and metadata; repeated metadata counts again.
Per-resource limits reset only at resource starts. Other counters span the whole
object, including codec resets and repeated series.
Framing storage includes headers, metadata bytes/field indexes, resource records,
directory validation records and temporary output bookkeeping. Dictionary storage
includes retained resource bytes and prepared dictionary allocations. The outer
workspace ceiling includes both categories and raw codec workspace. Reservations
account for old plus replacement capacity during reallocations. Output payload
belongs to the caller, but result structure and metadata are budgeted before
transfer. The reader’s fixed 8 KiB output array is inline, not heap storage.
Retain can exhaust the dictionary ceiling before the first internal reference;
Reject avoids caching resource payload and reports a policy error on references.
Strict validation requires O(number of chunks) records; memory is not constant.
Raw workspace receives the remaining outer ceiling before a content call through
Stream::set_framed_workspace_limit. Existing raw session behavior and symbol
loops are unchanged. The backend is selected once at owner construction and
passed through the existing raw dispatch boundary.
Reader and errors
FramedReader<R: BufRead> drains pending semantic events and codec output before
asking the source for more bytes. fill_buf/consume preserve unaccepted protocol
tails. Interrupted retries, WouldBlock preserves state, and only physical EOF
starts Finish. The lending reader returns None only after successful completion;
it never implements an iterator over self-borrowed events. A codec failure’s
written prefix remains available through partial_output. Drop and into_inner
perform no I/O.
thiserror errors retain codec/dictionary sources with detection context. The
128-byte by-value failure intentionally carries progress without allocating on
an allocation-failure path; method-local Clippy expectations document this
contract instead of boxing the failure. Location offsets describe detection,
not a purported exact corrupt bit.
Verification artifacts
tests/framed_decoder.rs covers independent wire fixtures and writer round trips,
all input split positions for small fixtures, small/zero outputs, continuation,
repeated metadata, references, limits, rollback and reader faults.
tests/decompress_memory.rs injects every allocation failure through a framed
append and observes retained/peak storage through the allocator. Private wire
unit tests cover varint and header boundaries; a private backend test explicitly
includes scalar fallback and every available host backend.
fuzz/afl contains experimental framed_decode (arbitrary incremental bytes) and
framed_roundtrip (writer-generated resources) targets and .bin regression
seeds. benches/framed_decompress.rs measures Auto, stored/compressed framing,
small resources, metadata, and dictionaries with both retention policies, plus
31-byte input / 4 KiB output incremental workloads.
benches/framed_raw_regression.rs provides fixed raw/C workloads for comparison
with an isolated baseline checkout. Execution results are recorded separately
below; source presence alone is not execution evidence.
Local validation record (2026-09-11)
Target: Apple M5 Pro, aarch64-apple-darwin, Rust 1.98.1 / LLVM 22.1.8.
MSRV checks also use Rust 1.89.0. Local logs and generated coverage/benchmark
artifacts live under target/ and are not committed.
| Check | Command / scope | Result |
|---|---|---|
| Formatting | cargo fmt --all; cargo fmt --all -- --check | pass |
| Workspace lint | cargo clippy --workspace --all-targets --all-features --locked -- -D warnings | pass |
| Workspace tests | cargo test --workspace --all-features --locked | pass; final CARGO_PROFILE_TEST_OPT_LEVEL=1 run passes 1,034 tests |
| std workspace | CARGO_PROFILE_TEST_OPT_LEVEL=1 cargo test --workspace --no-default-features --features std,compression,decompression,experimental --locked | 1,246 tests pass |
| Framed integration | cargo test --features experimental --test framed_decoder --locked | 28 pass |
| Decoder documentation | cargo test --doc --no-default-features --features std,decompression,experimental --locked and alloc-only counterpart | pass; runnable examples on every new inherent public method |
| Consumer feature gates | python3 scripts/check_codec_features.py | all profiles pass, including old encoder import paths |
| MSRV | cargo +1.89.0 check --lib --no-default-features --features std,decompression,experimental --locked and no_std,decompression,experimental | both pass |
| Allocation failure / ceilings | cargo test --features experimental --test decompress_memory --locked through the coverage run | every injected framed append failure preserves prefix; allocator peaks remain bounded |
| Function coverage | cargo llvm-cov --features experimental --lib --test framed_decoder --test decompress_memory --locked --json --output-path target/framed-coverage.json | 172/172 functions in the eight framed implementation files; changed raw workspace setter also covered |
| Miri | cargo +nightly miri test --lib --no-default-features --features std,decompression,experimental decompressor::framing::core::wire::tests | 3 pass; separate metadata-to-resource continuation test also passes |
| ASan | RUSTFLAGS=-Zsanitizer=address RUSTDOCFLAGS=-Zsanitizer=address cargo +nightly test --release --target aarch64-apple-darwin --features experimental --test framed_decoder | 28 pass |
| AFL package | fmt, Clippy -D warnings, and cargo afl test, each with --no-default-features and with/without --features experimental | both configurations pass; each regression run has 7 registry/oracle tests |
Miri’s nightly toolchain reports existing manifest and unused development-crate warnings; these are distinct from the successful stable Clippy checks. Coverage percentages above describe the changed subsystem, not a claim that a targeted run covers every existing encoder function. Host backend tests explicitly include the scalar oracle and every supported host backend; no x86 execution is claimed.
Raw allocation comparison uses the unchanged
retained_storage_matches_allocator_and_warm_calls_allocate_nothing test in
tests/decompress_memory.rs, run with cargo test --release --test decompress_memory retained_storage_matches_allocator_and_warm_calls_allocate_nothing -- --nocapture at baseline c09ba92 and in this checkout. For the identical
132,000-byte text / 69-byte compressed input, both report 8 cold allocations,
270,436 bytes peak and retained storage, and zero allocations on the warm call.
CPU and allocation profiles use cargo run --release --example profile_framed_decoder --features experimental,hotpath-cpu and the
hotpath-alloc counterpart. The fixture has 256 resources, 65,536 payload bytes,
79,007 wire bytes, and 86,032 retained bytes after each owned result is dropped.
Across 128 operations, the CPU profile attributes 114.25 ms / 67.60% of instrumented
wall time to framing_bytes (787,971 calls). These are nested profile timings,
not uninstrumented throughput. The allocation profile reports roughly 2.2 GB of
cumulative requested allocation traffic, predominantly drive; this is turnover,
not simultaneous live memory. The optional localhost metrics server is blocked by
the sandbox; command-line profiling reports were produced successfully.
The measured metadata-heavy costs are repeated live-capacity scans and exact single-entry descriptor growth. They remain explicit performance limitations: capacity accounting can perform quadratic work as chunk counts grow. The current implementation prioritizes strict peak accounting and fallible allocation; it makes no constant-memory or framing-speed improvement claim. These costs are separate from the unchanged raw Brotli loops.
Final AFL smoke campaigns (30 seconds each) used cargo afl fuzz -i regressions/TARGET -o /tmp/mbrotli-framed-TARGET-final-20260911 -V 30 -c - -- target/release/TARGET from fuzz/afl; the parser also used
-x dictionaries/framed.dict. framed_decode completed 758,211 executions with
99.96% stability; framed_roundtrip completed 13,130 with 99.97% stability. Both
saved zero crashes and zero hangs. The committed seeds include all chunk types,
signature fragments, internal/external references, malformed metadata and
mutated directory/footer entries. These short campaigns are smoke evidence,
not exhaustive fuzzing.
Criterion evidence
Build commands: cargo bench --bench framed_raw_regression --no-run and
cargo bench --bench framed_decompress --features experimental --no-run.
The raw baseline is an isolated git archive of c09ba92 with the identical
new raw benchmark source added. No baseline source or vendor algorithms were
modified. Both raw builds use default features. Raw trials invoke each built
benchmark with --bench --sample-size 30 --warm-up-time 0.5 --measurement-time 1,
in baseline/current/current/baseline order. Framed trials use
--bench --sample-size 20 --warm-up-time 0.3 --measurement-time 0.8.
Benchmarks run after builds, fuzz campaigns and required test workloads stop.
Raw inputs are fixed in benches/framed_raw_regression.rs: repeated ASCII text,
65,536 xorshift bytes with seed 1, and a 34-byte small message, all at Q5 and the
default window. These table ranges span the two Criterion point estimates; they
are not confidence intervals. Throughput is derived from payload bytes / time.
| Raw input | Payload / wire bytes | Baseline µs | Current µs | Baseline MiB/s | Current MiB/s |
|---|---|---|---|---|---|
| text | 59,392 / 64 | 1.7868–1.8437 | 1.7636–1.7755 | 30721.2–31699.5 | 31901.2–32116.5 |
| binary | 65,536 / 65,540 | 1.0647–1.3443 | 1.0739–1.0840 | 46492.6–58702.0 | 57656.8–58199.1 |
| small | 34 / 34 | 0.6182–0.6193 | 0.6176–0.6440 | 52.4–52.4 | 50.3–52.5 |
The repeated raw trials show inter-run variation (especially the binary baseline
and second small-input current trial), rather than a consistent slowdown across
trials. A longer small-input confirmation (--bench small/rust --sample-size 50 --warm-up-time 1 --measurement-time 5) measured baseline 634.71 ns / 51.09 MiB/s
and current 616.34 ns / 52.61 MiB/s, so the earlier small-input slowdown did not
repeat. No framing check was added to the raw symbol loops. The C oracle validates
identical raw payloads before timing; its decoder is created per iteration, while
Rust reuses its owner, so these are explicitly different lifetime costs. Container
throughput is not presented as a C-framing comparison: the C crate has no framing
decoder.
Framed input is the fixed 64,512-byte repeated text in
benches/framed_decompress.rs; many-small has 256 resources and metadata-heavy
adds two fields per resource. Dictionary input is 32 copies of a 1,216-byte
external prefix. All payloads are validated before timing. Owned-output cases
include result allocation and destruction; incremental cases include session and
event processing with 31-byte input offers and a preallocated 4 KiB output buffer.
The raw presized case excludes output allocation. Point estimates follow:
| Mode | Payload / wire bytes | Time µs | MiB/s |
|---|---|---|---|
| raw-presized | 64,512 / 60 | 2.171 | 28336.1 |
| c-raw-presized | 64,512 / 60 | 22.880 | 2689.0 |
| auto-Retain | 64,512 / 60 | 5.079 | 12112.8 |
| stored-Retain | 64,512 / 64,542 | 4.749 | 12955.3 |
| stream-stored-Retain | 64,512 / 64,542 | 110.800 | 555.3 |
| compressed-Retain | 64,512 / 88 | 6.807 | 9038.7 |
| stream-compressed-Retain | 64,512 / 88 | 5.448 | 11293.7 |
| many-small-Retain | 64,512 / 68,048 | 174.460 | 352.7 |
| stream-many-small-Retain | 64,512 / 68,048 | 266.560 | 230.8 |
| metadata-heavy-Retain | 64,512 / 77,981 | 1022.300 | 60.2 |
| stream-metadata-heavy-Retain | 64,512 / 77,981 | 1078.300 | 57.1 |
| dictionary-heavy-Retain | 38,912 / 3,180 | 84.224 | 440.6 |
| auto-Reject | 64,512 / 60 | 5.101 | 12062.2 |
| stored-Reject | 64,512 / 64,542 | 3.645 | 16877.5 |
| stream-stored-Reject | 64,512 / 64,542 | 101.650 | 605.2 |
| compressed-Reject | 64,512 / 88 | 5.950 | 10340.6 |
| stream-compressed-Reject | 64,512 / 88 | 4.311 | 14271.9 |
| many-small-Reject | 64,512 / 68,048 | 171.960 | 357.8 |
| stream-many-small-Reject | 64,512 / 68,048 | 259.450 | 237.1 |
| metadata-heavy-Reject | 64,512 / 77,981 | 1013.700 | 60.7 |
| stream-metadata-heavy-Reject | 64,512 / 77,981 | 1058.900 | 58.1 |
| dictionary-heavy-Reject | 38,912 / 3,180 | 83.652 | 443.6 |
Framed seek reader
The experimental, std-only framing facade exports FramedSeekReader,
ResourceReader, ResourceInfo and FramedSeekError. Version 0.4.0 adds the
FramedDecompressor::framed_seek_reader and
framed_seek_reader_with_dictionaries factories. The sequential event API is
unchanged. Logical identity is ResourceIndex; metadata id is not a key.
Every public seek method has a runnable rustdoc example, using wire fixtures that
also work with only std, decompression and experimental features enabled.
Ownership and modules
graph TD
Owner[FramedDecompressor] --> Facade[framing::seek public facade]
Facade --> Index[private core::seek::Index]
Facade --> Lease[private cancellation Lease]
Facade --> Reader[ResourceReader: Read]
Reader --> Operation[private core::seek::Operation]
Operation --> Engine[existing private Engine]
Engine --> Raw[retained raw Stream workspace]
Index --> Wire[shared core::wire parser]
Operation --> Wire
Operation --> Source[Read + Seek positional reads]
The parent owns its source, directory-derived chunk/resource tables and lazy
metadata cache. A lease borrows the reusable owner exclusively. A child borrows
the source, index and owner; its operation owns the dependency bitmap, fixed
8 KiB input buffer and logical execution state. No public API exposes the plan,
raw codec, SIMD types or private errors. Dropping an operation cancels owner state
and applies RetentionPolicy. Dropping the parent also cancels, including after a
forgotten child. Neither drop nor into_inner performs I/O. The parent lease keeps the owner’s
active flag set between child operations. Forgetting the parent requires the
existing FramedDecompressor::recover before starting another owner session;
forgetting only a child can be recovered by the next parent operation. The physical cursor
is unspecified. Source byte zero is the container signature; embedded slices need
a source adapter. Mutating source contents invalidates the index and metadata
cache assumptions.
Opening and index validation
sequenceDiagram
participant Caller
participant Index
participant Source
Caller->>Index: framed_seek_reader(source)
Index->>Source: seek end, read signature
Index->>Source: bounded reversed footer numbers and header
Index->>Index: validate size, profile and directory offset
Index->>Source: read directory header and entries
Index->>Index: parse copied headers, validate ordered disjoint extents
Index->>Source: read padding headers in gaps, skip their bodies
Index->>Index: assemble logical resources and metadata associations
Index->>Index: check partial order, repeat identity and backward references
Index-->>Caller: structural index, no decompression
Only full containers with a nonzero, in-bounds directory pointer are accepted.
Missing directories use CentralDirectoryRequired; malformed directory structure
uses the existing typed InvalidDirectory error. Header parsing uses the same
bounded varints and field grammar as the sequential decoder, preserving exact
nonminimal header bytes. The footer’s two reversed varints are individually
bounded to nine bytes. Declared object size, when nonzero, must equal source length.
Copied chunk extents must be ordered, disjoint and end before the directory.
Partial chains, before/after metadata, repeated-series pointer/count/kinds and
internal backward references are checked from the index. Resources expose hidden
flags, checksums and the sum of declared/literal sizes, with no payload allocation.
Opening is not full validation. Gaps between indexed content chunks can contain
padding; opening reads only their bounded headers, checks that they describe
padding extents and counts every padding chunk under max_chunks. Their bodies
are skipped. An omitted content chunk in such a gap is rejected. Access checks each used original
header byte against its exact copy before reading payload. A mismatch becomes
DirectoryMismatch. Unused payload, metadata and padding bodies remain unvalidated.
Full validation remains the sequential decoder’s responsibility. Checksums are
recorded without authentication, matching the existing decoder.
Dependency execution
flowchart TD
Target[Requested resource or metadata chunk] --> Select[Mark requested chunks]
Select --> Closure[Reverse traversal of backward dependencies]
Closure --> Partial[Include preceding fragments of affected resources]
Closure --> Keep[KeepDecoder: include actual preceding content]
Closure --> Dict[Internal reference: mark dictionary chunk or whole resource]
Partial --> Run[Execute selected chunks in wire order]
Keep --> Run
Dict --> Run
Run --> Verify[Positional read and original header identity check]
Verify --> Prepare[Existing Engine::prepare and dictionary resolver]
Prepare --> Decode[Existing Engine::payload with selected Backend]
Decode --> Retain[Cache only marked dictionary payload]
Decode --> Discard[Discard prerequisite resource bytes]
Decode --> Deliver[Deliver selected resource bytes to caller buffer]
Every dependency edge points to an earlier indexed chunk, so one reverse pass
computes closure without recursion. Whole-resource dictionary references must
address the first data fragment and end before the referencing chunk. Prefix
chunk references include only that chunk’s bytes as dictionary content, while
preceding partial fragments are decoded to enforce resource-size contracts.
Dependency dictionaries are materialized before use and retained for the current
operation. Unrelated resources are never decoded. Reject rejects internal
references; Retain permits selective materialization instead of retaining all
previous payload. The default policy therefore streams a large independent target
without allocating its complete decoded bytes as a potential dictionary.
The existing codec preparation and payload driver enforce dictionary attachment
order, serialized dictionary rules, standard/large window ceilings, decoded chunk
sizes and budgets. External IDs use the existing DictionaryResolverRef contract
and typed missing/preparation errors. Codec state survives selected KeepDecoder
edges across resources and metadata. A skipped interval cannot supply codec state.
At local completion, a compressed stream must be unfinished exactly when the next
indexed content chunk uses KeepDecoder. Padding is transparent. The selected
owner backend is passed to the existing raw driver; seek adds no SIMD dispatch.
Metadata uses original chunks, even when repeats exist, preserving all original fields when repeats contain only a subset. Loading is lazy and cached by original chunk identity. Metadata needed for codec state or a dictionary is decoded and validated inside the operation. Repeated payload consistency is left to full sequential validation because the seek path uses originals.
stateDiagram-v2
[*] --> Planned: resource(index)
Planned --> Header: first Read
Header --> Payload: original header verified; prepare codec
Payload --> Payload: deliver target or discard/cache prerequisite bytes
Payload --> Header: local chunk complete; next selected chunk
Payload --> EOF: selected path complete and local sizes validated
Header --> Failed: source/header/dictionary error
Payload --> Failed: source/codec/size/limit error
Planned --> Cancelled: Drop
Payload --> Cancelled: early Drop
Failed --> Cancelled: Drop
EOF --> Cancelled: Drop
Cancelled --> [*]: no I/O; parent reusable
Each independent access resets aggregate input, output, decoded, resource and metadata counters. Output/decoded limits also account for prerequisite resources that are internally discarded. The opening input budget counts the main header, indexed directory and footer wire extents plus padding headers; access input counts used original headers and payload. The chunk limit includes indexed content records, directory, footer and every padding chunk. The resource count limit bounds the complete index. Framing/workspace budgets include persistent index and cached metadata, operation bitmap, temporary header copies, engine records and raw workspace. Dictionary limits cover retained dependency bytes and prepared storage. Reservations account for old and replacement capacities before growth. Decoder workspace is reused subject to retention; per-operation cached dependency bytes and semantic identities are cleared on cancellation.
Read failures store a FramedSeekError inside io::Error. Its Io variant
preserves the original I/O error; Decode preserves existing codec/dictionary
source chains. Invalid resource indices remain distinct from codec failures.
After an error a child rejects subsequent nonempty reads. Dropping it allows a
fresh operation. An empty caller buffer always returns zero without work.
Verification and known gaps
tests/framed_seek.rs covers independent wire fixtures, header tampering, lazy
metadata, repeated subsets, continuation, actual/transitive dictionary bytes,
limits, arbitrary ordering, abandonment, source faults and no-I/O destruction.
Private core tests explicitly execute the scalar backend and all host backends.
framed_decode AFL checks indexed reads and metadata against valid sequential
results, including reverse access and early drop; framed_roundtrip reaches the
same oracle on writer-generated containers. framed_decompress Criterion adds
opening and reverse streaming access cases alongside sequential and C raw controls.
C Brotli has no RFC 9841 container reader; its raw control is not an equivalent
indexed-container benchmark.
No decoded Seek, resource ranges, lookup by metadata value, async I/O, fallback
index scan, public access plan or automatic dictionary fetching is implemented.
Global metadata has no convenience accessor. Repeated metadata is not used as an
optimized source. The operation bitmap is linear in directory entries; dictionary
storage is released semantically per operation rather than cached across accesses.
EOF of one resource does not authenticate its checksum or validate untouched
container portions. Other architectures require native backend execution.
Local execution evidence (2026-09-12)
On aarch64-apple-darwin, Rust 1.98.1 / LLVM 22.1.8, the focused integration
suite passes 18 tests and the private test exercises scalar plus all host backends.
cargo llvm-cov --features experimental --locked --test framed_seek --json --output-path target/seek-coverage-final.json reports 100% function coverage for
both new implementation files (36 core functions and 23 facade functions).
The JSON report is a local, uncommitted artifact.
Criterion smoke command:
cargo bench --bench framed_decompress --features experimental --locked -- \
'seek-corpus' --sample-size 10 --warm-up-time 0.1 --measurement-time 0.2
All rows decode the identical enclosed raw bitstream and validate against the
original bytes before timing. Indexed includes opening and Read through EOF;
sequential uses decompress_to_slice; C decodes the raw member, with no container
parsing. Destinations and fixtures are allocated before timing. Times below are
sample medians in microseconds, not a claim of equivalent container throughput.
This short validation run shared the host with checks; use longer isolated runs
for performance decisions. There is no pre-change indexed implementation.
| Corpus | Payload bytes | Raw / framed bytes | Indexed µs | Sequential µs | C raw µs |
|---|---|---|---|---|---|
| Small text | 26 | 22 / 46 | 1.50 | 1.35 | 0.51 |
| Repeated text | 64,512 | 60 / 88 | 3.66 | 3.46 | 24.50 |
| Binary byte cycle | 65,536 | 217 / 249 | 4.84 | 4.42 | 26.69 |
| Seeded xorshift bytes | 65,536 | 65,540 / 65,576 | 4.83 | 2.24 | 2.02 |
| Large zero payload | 1,048,576 | 209 / 489 | 33.65 | 33.39 | 445.04 |
The same benchmark also measures directory-only opening and reverse streaming
access across stored, compressed, many-small and metadata-heavy containers.
AFL 0.18.2 / AFL++ 4.40c smoke runs use the existing framed_decode and
framed_roundtrip targets with -V 60 -t 2000 -c -, committed regression corpora,
AFL_SKIP_CPUFREQ=1, AFL_NO_AFFINITY=1, and
AFL_I_DONT_CARE_ABOUT_MISSING_CRASHES=1. Findings remain under /tmp, not in the
repository. An initial decode campaign recorded one timeout during concurrent
checks; its 114-byte input completed successfully on three direct replays
(0.68 seconds cold, about 3 milliseconds warm), with no reproducible failure.
After the final lifecycle, budget and padding checks, fresh 60-second campaigns
completed with zero saved crashes and zero saved hangs: framed_decode executed
493,966 inputs at 99.97% stability; framed_roundtrip executed 36,193 inputs at
99.98% stability. Final findings are in
/tmp/mbrotli-seek-{decode,roundtrip}-final-20260912/smoke/.
Extended formats
Large Window, shared dictionaries and the experimental framing container.
| Specification | Scope |
|---|---|
| Shared Brotli | Large Window declarations and prepared prefix dictionaries. |
| Serialized dictionaries | Experimental wire format, transforms and resource limits. |
| Custom encoding and continuations | Static indexes, contexts and headerless stream offsets. |
| Framed compressor | Reusable owner, structured input, sessions, std adapters, metadata and wire compatibility. |
Architecture overview and module map
Shared Brotli (RFC 9841)
What this crate implements of RFC 9841 today, and where the boundaries of that implementation are. RFC 9841 updates RFC 7932 with three separable features:
| Feature | State |
|---|---|
| Large Window Brotli | implemented for qualities 3 to 11 |
| LZ77 prefix dictionaries: preparation, indexes, addressing, search | implemented |
| LZ77 prefix dictionaries: use by an encoder | implemented for qualities 5 to 11 — refused below, not ignored |
| Serialized shared dictionaries and custom static encoding | implemented behind experimental; see rfc9841-encoding.md |
| Framing container format | experimental writer and decoder |
This file covers Large Window and prefix mechanics. The linked specifications cover the experimental extensions; remaining limitations are recorded below.
1. Module boundaries
mbrotli::dictionary is the public home of RFC 9841’s prefix dictionaries: the
immutable PreparedDictionary, the builder that indexes one, the limits it is
built under, and the error preparation reports. Large Window Brotli is not
there, because it is a configuration value rather than a dictionary — it is one
of the two headers a Window can carry — so it lives in EncoderConfig
alongside the quality and the mode.
mbrotli::compressor::shared is what is left: a private module holding the
low-level error the encoders raise, which the public DictionaryError,
ConfigError and EncodeError are built from.
Below the API, compressor::core::rfc9841 owns the wire primitives. It is
distinct from shared, which predates it and means “code more
than one quality needs”.
graph TD
subgraph public["Public API"]
comp["mbrotli::EncoderConfig<br/>Window, WindowEncoding, ConfigError"]
entry["Compressor<br/>compress_with_dictionary,<br/>compress_with_dictionary_into,<br/>compress_with_dictionary_to_slice,<br/>start_with_dictionary,<br/>writer_with_dictionary, reader_with_dictionary"]
sharedmod["mbrotli::dictionary"]
ctx["PreparedDictionary, DictionaryBuilder,<br/>DictionaryLimits"]
err["DictionaryError, EncodeError<br/>::DictionaryUnsupportedForQuality"]
end
subgraph private["Private implementation"]
sharederr["compressor::shared<br/>SharedBrotliError"]
rfc["core::rfc9841"]
window["core::rfc9841::window<br/>ResolvedWindow"]
inner["core::rfc9841::context<br/>SharedContextInner, Budget,<br/>SharedDictionaryData,<br/>PreparedDictionaryIndexes"]
pfx["core::rfc9841::prefix<br/>PrefixSources<br/>(addressing, match scan)"]
prep["core::rfc9841::prepared<br/>PreparedPrefix, Candidates"]
search["core::rfc9841::search<br/>find_match, find_all_matches<br/>(the match finders' view)"]
driver["core::driver<br/>check_large_window, check_shared,<br/>quality routing"]
gsearch["core::greedy::backward_references<br/>(q5 to q9)"]
hsearch["core::hq::zopfli<br/>(q10, q11)"]
cmd["shared::command<br/>extend_last_command"]
dist["shared::distance<br/>DistanceParams, distance_code_limit"]
mlen["prefix::common_prefix_len<br/>(scalar word scan)"]
gparams["core::greedy::params::GreedyParams"]
hparams["core::hq::params::HqParams"]
fast["core::fast::FastEncoder"]
gmeta["core::hq::metablock<br/>choose_distance_params"]
bitstream["shared::bitstream<br/>meta-block writer"]
ring["shared::ringbuffer::RingBuffer"]
end
comp --> entry
entry --> sharedmod
sharedmod --> ctx
sharedmod --> err
ctx --> inner
ctx --> sharederr
entry --> driver
driver --> window
driver --> fast
driver --> inner
rfc --> window
rfc --> inner
inner --> pfx
inner --> prep
inner --> search
search --> pfx
search --> prep
search --> gsearch
search --> hsearch
search --> cmd
pfx --> mlen
window --> gparams
window --> hparams
window --> fast
gparams --> dist
hparams --> dist
hparams --> gmeta
gmeta --> dist
dist --> bitstream
gparams --> ring
hparams --> ring
fast -->|refuses| err
driver -->|refuses a non-empty context| err
classDef privateNode fill:#f6e8c3,stroke:#8a6d3b;
class sharederr,rfc,window,inner,pfx,prep,search,driver,dist,mlen,gparams,hparams,fast,gmeta,bitstream,ring,gsearch,hsearch,cmd privateNode;
2. Selecting a large window
Large Window mode is reached one way only: the constructor a caller names. It is never inferred from the size, the input, the quality, the target, or anything else.
let config = EncoderConfig::default()
.with_quality(Quality::Q5)
.with_window(Window::large(30)?);
Window carries both the size and the header encoding. Its constructors validate
their ranges: standard accepts 10..=24, and large accepts 10..=62.
Window::large(22) and Window::standard(22) select different headers and
distance alphabets at the same size.
There is no separate large_window flag that could disagree with the size, and
nothing downstream re-checks a range, because no Window can exist that no
header can express.
classDiagram
class EncoderConfig {
-Quality quality
-Window window
+new() EncoderConfig
+with_window(Window) EncoderConfig
+window() Window
}
class Window {
-u8 bits
-WindowEncoding encoding
+MIN_BITS = 10
+MAX_STANDARD_BITS = 24
+MAX_LARGE_BITS = 62
+DEFAULT = standard(22)
+standard(u8) Result
+large(u8) Result
+bits() u8
+encoding() WindowEncoding
}
class WindowKind {
<<private enum, internal::WindowBits>>
Standard(u8)
Large(u8)
}
class ResolvedWindow {
-usize declared_bits
-bool large
+new(&CompressParams) ResolvedWindow
+at_least(usize) ResolvedWindow
+is_large() bool
+encoder_bits() usize
+header() (u16, u32)
}
EncoderConfig *-- Window
Window ..> WindowKind : lowers into
ResolvedWindow ..> WindowKind : resolved from
3. Declared window versus retained history
ResolvedWindow separates two numbers that an ordinary stream never has to
distinguish:
- declared bits — what the header says,
10..=62; - encoder bits — what the encoder actually keeps history for, capped at
MAX_ENCODER_WINDOW_BITS(30).
Everything that costs memory or bounds a distance is derived from the encoder
bits: the ring buffer size, the block size, the match finders’ reach, and the
largest backward distance. Only header() reads the declared bits.
flowchart TD
p["EncoderConfig::window()"] --> q{"encoding == Large?"}
q -->|no| std["ResolvedWindow<br/>declared = bits (10..=24)<br/>large = false"]
q -->|yes| lw["ResolvedWindow<br/>declared = bits (10..=62)<br/>large = true"]
std --> hdr{"header()"}
lw --> hdr
hdr -->|"large"| h14["14 bits:<br/>0b00010001 then 6 window bits"]
hdr -->|"lgwin == 16"| h1["1 bit: 0"]
hdr -->|"lgwin == 17"| h7a["7 bits"]
hdr -->|"lgwin > 17"| h4["4 bits"]
hdr -->|"lgwin < 16"| h7b["7 bits"]
std --> enc["encoder_bits() = declared"]
lw --> encl["encoder_bits() = min(declared, 30)"]
enc --> use["ring buffer, block size,<br/>match reach, max backward distance"]
encl --> use
This is what makes a 62-bit declaration free: it allocates nothing, and the emitted payload is byte-identical to the same stream declared at 30 bits.
4. The distance alphabet
Large Window is not only a header. The distance alphabet written to the stream
is sized for 62 distance bits instead of 24, while the symbols that may actually
occur stop at MAX_ALLOWED_DISTANCE ((1 << 31) - 4). alphabet_size_max and
alphabet_size_limit, which coincide for every RFC 7932 stream, genuinely
differ here — and the meta-block writer already kept them apart, which is why
that layer needed no change.
distance_code_limit ports BrotliCalculateDistanceCodeLimit: it finds the
largest alphabet that cannot express a distance past the limit, cut on a
complete interleaved group so neither side ever sees a half-represented group.
Its widest result over every legal (NPOSTFIX, NDIRECT) pair is exactly 544,
the size of a distance histogram — which is a unit test, not a comment.
sequenceDiagram
participant P as GreedyParams / HqParams
participant W as ResolvedWindow
participant D as DistanceParams
participant M as hq::metablock
participant B as bitstream
P->>W: new(¶ms)
W-->>P: { declared, large }
P->>D: for_window(large, npostfix, ndirect)
alt large
D->>D: alphabet_size_max = 16 + ndirect + (62 << (npostfix+1))
D->>D: distance_code_limit(0x7FFFFFFC, npostfix, ndirect)
D-->>P: limit < max
else ordinary
D->>D: alphabet_size_max = 16 + ndirect + (24 << (npostfix+1))
D-->>P: limit == max
end
Note over M: quality 10 and 11 only
M->>D: for_window(large, candidate npostfix, ndirect)
M-->>M: keeps the cheapest candidate
P->>B: dist
B->>B: num_distance_symbols = alphabet_size_max
B->>B: num_effective_distance_symbols = alphabet_size_limit
The per-meta-block retune at qualities 10 and 11 is the subtle part: it walks
candidate (NPOSTFIX, NDIRECT) pairs and replaces the block’s alphabet with the
cheapest. Building a candidate with the RFC 7932 constructor there would emit a
64-symbol alphabet under a header that promised 140, and every stream past the
first few bytes would fail to decode. for_window carries the flag so it
cannot.
5. The prepared dictionary
A PreparedDictionary is the caller’s. It owns the dictionary bytes, it is
built by a builder that consumes them, and it is handed to a compression call by
shared borrow. There is no Arc, no Rc, no Mutex, no RwLock, no atomic,
no global registry and no interior mutability anywhere in it or below it — the
type is Send and Sync because its fields are, and because nothing in it is
mutable, any number of compressors may borrow one at the same time, on any
number of threads, with no synchronisation of this crate’s making. A caller who
wants shared ownership wraps it in an Arc, and that is their policy.
Building one takes no quality. The indexes a dictionary carries are the same whichever quality later reads them, so one prepared dictionary serves every quality that can consult one.
classDiagram
class DictionaryBuilder {
<<public, consuming>>
-DictionaryLimits limits
-Vec~Box~u8~~ attachments
+new() Self
+add_prefix(B: Into~Box~u8~~) Self
+with_limits(DictionaryLimits) Self
+build() Result~PreparedDictionary, DictionaryError~
}
class PreparedDictionary {
<<public, immutable, Send + Sync>>
+attachment_count() usize
+source_bytes() usize
+retained_bytes() usize
+backward_distance(u64, u64) Option~u64~
+prefix_offset(u64, u64) Option~u64~
+longest_match(&u8) Option~PrefixMatch~
}
class SharedContextInner {
<<private>>
-SharedDictionaryData dictionaries
-PreparedDictionaryIndexes prepared
+longest_prefix_match(S, &u8) Option~PrefixMatch~
}
class SharedDictionaryData {
<<private, immutable>>
-PrefixSources prefix
}
class PrefixSources {
<<private, immutable>>
-Box~Box~u8~~ segments
-Box~u64~ starts
+locate(u64) Option
+address_of(u64, u64) Option~u64~
+distance_of(u64, u64) Option~u64~
+match_length(S, u64, history, target, limit) usize
}
class PreparedDictionaryIndexes {
<<private, immutable>>
-Box~PreparedPrefix~ prefixes
}
class PreparedPrefix {
<<private, immutable>>
-u32 bucket_bits
-u32 slot_bits
-Box~u32~ slot_offsets
-Box~u16~ heads
-Box~u32~ items
+candidates(u64) Candidates
}
DictionaryBuilder ..> PreparedDictionary : build()
PreparedDictionary *-- SharedContextInner
SharedContextInner *-- SharedDictionaryData
SharedContextInner *-- PreparedDictionaryIndexes
SharedDictionaryData *-- PrefixSources
PreparedDictionaryIndexes *-- PreparedPrefix
Every collection that has stopped growing is a Box<[_]>, not a Vec<_>: the
capacity word is dead weight after preparation, and dropping it removes push
and truncate from the type, so “immutable after preparation” is a property
of the type rather than a comment. The builder’s attachment list is the one
collection that grows, and it is a Vec.
5.1. Preparation is a transaction
build is all-or-nothing, and every check runs before the first table is
allocated — including the allocation check, which compares a computed upper
bound rather than the finished size, so a dictionary that would not fit its
limit is never built and thrown away. A dictionary with no bytes in it is
refused outright rather than behaving like no dictionary at all: the two would
be indistinguishable, which is exactly the confusion this crate avoids
elsewhere by refusing a dictionary a quality cannot read.
flowchart TD
b["DictionaryBuilder::build"] --> z{"any bytes at all?"}
z -->|no| e0["Err(Empty)"]
z -->|yes| c{"attachments ≤ 15?"}
c -->|no| e1["Err(TooManyAttachments)"]
c -->|yes| d{"each segment ≤ 2^31 - 1?"}
d -->|no| e2["Err(TooLarge)"]
d -->|yes| f{"total ≤ max_prefix_bytes<br/>and max_source_bytes?"}
f -->|no| e2
f -->|yes| g{"peak estimate ≤<br/>max_retained_bytes?"}
g -->|no| e3["Err(PreparationTooLarge)"]
g -->|yes| h["build one PreparedPrefix per attachment"]
h --> i["PrefixSources::new: cumulative offsets"]
i --> j["PreparedDictionary"]
The estimate bounds the build’s peak, not the finished size: step 3 fills the
slim tables while step 1’s and step 2’s scratch is still alive, so preparation
costs roughly eight bytes per source byte at its high-water mark, and the peak
is what a limit should refuse. It over-counts on purpose — the item table is
bounded at one entry per hashable position, the most step 1 can chain, and
every other table is counted exactly — so it also bounds the finished context.
context::the_estimate_is_never_smaller_than_the_context_it_predicts is what
keeps the two in step.
5.2. Virtual concatenation, and the distances that address it
The attachments are never copied into one buffer. PrefixSources gives them
one logical address space with a cumulative offset table, and everything else
works in that space. Attachment order is prefix order: the first attachment
holds the oldest bytes, the last one the bytes immediately before the stream’s
own output.
flowchart LR
subgraph logical["Logical prefix: one address space"]
direction LR
a0["attachment 0<br/>oldest"] --> a1["attachment 1"] --> a2["attachment 2<br/>newest"]
end
logical --> stream["stream output<br/>(ordinary sliding window)"]
d1["distance 1..=M"] -.->|ordinary history| stream
d2["distance M+1"] -.->|last prefix byte| a2
d3["distance M+D"] -.->|first prefix byte| a0
For a total prefix length D and an ordinary maximum backward distance M:
address_of(distance, M) = D - (distance - M) for M < distance <= M + D
distance_of(address, M) = M + (D - address) for 0 <= address < D
Both are checked u64 arithmetic and return None rather than wrapping,
outside the prefix range in either direction.
prefix::addressing_round_trips_through_the_distance walks every address of a
three-attachment prefix through both directions, and
dictionary::a_dictionary_reports_its_own_shape does the same through the
public PreparedDictionary::backward_distance and
PreparedDictionary::prefix_offset.
5.3. The prepared index
One index per attachment, a port of CreatePreparedDictionary. The shape is
the reference’s because the shape is observable: the bucket count, the hash
width and the per-bucket cap decide which candidates a search sees and in what
order, and those candidates become the commands an encoder emits.
flowchart TD
s["source bytes"] --> t{"len < 8?"}
t -->|yes| empty["empty index<br/>(no hashable position)"]
t -->|no| shape["shape_for(len):<br/>bucket_bits 17..=22, slot_bits = bucket_bits - 10"]
shape --> s1["step 1 — bloated hasher<br/>chain every position into its bucket,<br/>newest first, count capped at 32"]
s1 --> s2["step 2 — slot limits<br/>shorten chains until every head offset<br/>in a slot fits sixteen bits"]
s2 --> s3["step 3 — slim index<br/>slot_offsets, heads, items;<br/>top bit of the last item ends the chain"]
s3 --> out["PreparedPrefix"]
prepared::the_index_is_identical_to_the_c_reference builds both and compares
bucket_bits, slot_bits, slot_offsets, heads and items entry for entry
over six corpora, including one large enough to trigger the shape scaling. The
C side is reached through brotli-ffi/shim/static_dict_probe.c, which copies
the reference’s three tables out of the single flat allocation it carves them
from.
5.4. The search
sequenceDiagram
participant C as PreparedDictionary::longest_match
participant I as SharedContextInner
participant P as PreparedPrefix
participant S as PrefixSources
participant M as prefix::common_prefix_len
C->>I: longest_prefix_match(input)
I->>I: head = first 8 bytes, little-endian
loop attachment 0..n, oldest first
I->>P: candidates(head)
P-->>I: source offsets, newest first, chain-capped
loop each candidate
I->>S: match_length(base + offset, history, input, limit)
loop each segment run from that address
S->>M: compare run against the unmatched tail
M-->>S: bytes agreed
end
S-->>I: total, across segment seams and into history
I->>I: keep only a strictly longer match
end
end
I-->>C: Option<PrefixMatch { offset, length }>
This is the standalone diagnostic PreparedDictionary::longest_match exposes
behind the diagnostics feature, not the search a match finder runs; that one
is §5.5. It is feature-gated because the candidate order it breaks ties by is an
implementation detail no application should depend on.
The scan uses scalar whole-word comparisons with a byte tail. It is separate
from the encoders’ vector kernel in shared::match_len.
Being scalar, the answer cannot depend on the backend, so there is no identity test to run for it. The tie rule is the reference’s: strictly-longer wins, so of two equally long matches the one in the older attachment, and within an attachment the one at the newer position, is kept.
A match may begin in one attachment and run into the next, and on into the stream’s own history — the virtual concatenation RFC 9841 allows. The candidate that starts a match must still be indexed, so its own eight hashed bytes have to lie inside one attachment.
5.5. How a match finder consults a prefix
core::rfc9841::search is the attached context in the form a match finder
needs it. It ports FindCompoundDictionaryMatch,
LookupCompoundDictionaryMatch, FindAllCompoundDictionaryMatches and
LookupAllCompoundDictionaryMatches.
The whole integration rests on one number, the reference’s gap: the total
attached bytes. The concatenated prefix ends exactly where the stream begins,
so a backward distance addresses the window while it is at most
max_ring_buffer_distance, and the prefix beyond that. Written the reference’s
way, logical address zero of attachment d sits at distance
max_ring_buffer_distance + gap - chunk_start(d).
graph LR
subgraph addr["one distance axis"]
direction RL
a0["attachment 0"] --- a1["attachment 1"] --- a2["attachment 2"] --- win["the stream's own window"] --- cur["current position"]
end
note["distance grows leftwards:<br/>0 at the position,<br/>max_ring_buffer_distance at the window edge,<br/>+ gap at the far end of attachment 0"]
Three things shift together wherever a prefix is attached, and all three
collapse to their ordinary form when gap is zero:
| Site | Without a prefix | With one |
|---|---|---|
| Static-dictionary boundary handed to the matcher | dictionary_start | dictionary_start + gap |
| Prefix search boundary | — | dictionary_start |
compute_distance_code, distance-cache guard | dictionary_start | dictionary_start + gap |
find_match improves a SearchResult in place, attachment by attachment in
attachment order, replacing the incumbent only on a strictly higher score — so
a tie leaves the earlier finder’s match. It probes the four cached distances
first, exactly as the reference does, then walks the bucket chain behind a
four-byte pre-filter at best_len. find_all_matches is the high-quality
sibling: no cached distances, every length improvement reported, at most
sixty-four per position, with min_length carried forward between attachments.
A candidate is measured inside the attachment it was found in and stops at
its end, even though the addressing spans every attachment. That is the
reference’s behaviour, not an omission. The one place a copy does run across a
seam is extend_last_command, which continues an already emitted command into
the concatenation using PrefixSources::run_from.
Which qualities reach this at all is fixed by the reference: it compiles its
compound-dictionary search only for H5, H6, H40, H41, H42, H55,
H65 and the binary tree, so qualities five and above consult a prefix and
qualities zero to four have nowhere to put a match. Where the reference then
silently ignores the dictionary, this crate refuses.
5.6. What a dictionary compression call does
stateDiagram-v2
[*] --> Build: Compressor::new(config)
Build --> LW: large window at quality 0, 1 or 2
LW --> [*]: Err(ConfigError::LargeWindowUnsupportedForQuality)
Build --> Q: a compressor
Q --> Low: quality below 5
Low --> [*]: Err(EncodeError::DictionaryUnsupportedForQuality)
Q --> Attached: quality 5 to 11
Attached --> Consulted: nonempty input consults the prefix
Consulted --> [*]: a stream whose distances may address the prefix
Attached --> EmptyInput: input empty
EmptyInput --> [*]: shared finish preserves configured header
The order is fixed and every check runs before any input is consumed: the window against the quality when the compressor is built, then the quality against the dictionary when the operation starts.
Below quality five, dictionary operations return
EncodeError::DictionaryUnsupportedForQuality before consuming input. The
compressor remains usable for an ordinary call. DictionaryBuilder::build
rejects an empty dictionary; an operation without a dictionary uses the ordinary
encoding path.
An empty input follows the same encoder finish path in every API shape, preserving the configured stream header. Dictionary validation still applies, although an empty stream cannot reference dictionary bytes. Native C one-shot’s empty shortcut is deliberately omitted; see universal-encoding.md.
Every entry point that takes a dictionary — the three one-shot forms, the
session, the writer and the reader — reaches the same bytes for the same
declared size, which dictionary::every_dictionary_entry_point_reaches_the_same_bytes
checks. A flush carries the dictionary too, so the bytes after one are still
compressed against it.
5.7. Reuse determinism
Nothing a dictionary owns is stream state. There is no LZ77 history in it, no distance cache, no pending command, no meta-block state and no input position — only the caller’s bytes and indexes derived from them by a pure function. That is what makes a shared borrow sound, and it makes the reuse contract hold by construction rather than by a reset.
The compressor is the mutable half, and it does reset:
dictionary::reusing_one_compressor_with_a_dictionary_is_deterministic runs a
deliberate failure, an ordinary call and an abandoned session between two
dictionary calls and requires the same bytes from both.
6. Where a large window is refused
stateDiagram-v2
[*] --> Check: Compressor::new / reconfigure
Check --> Ordinary: the window is not large
Check --> Q012: quality 0, 1 or 2
Check --> Large: quality 3..=11
Q012 --> [*]: Err(ConfigError::LargeWindowUnsupportedForQuality)
Ordinary --> Encode: any operation
Large --> Encode: any operation
Encode --> Empty: input empty
Empty --> [*]: shared finish preserves configured header
Encode --> [*]: stream
The check runs when the compressor is built, so a refused request never reaches
an operation at all and cannot be silently dropped, even for empty input. The
encoders keep their own copies of the refusal in FastEncoder::new and
GreedyParams::new, which is unreachable from a validated configuration and
is reported as EncodeError::InternalInvariant if it ever fires.
Qualities 0, 1 and 2 are refused rather than downgraded because all three may write distances through a code built for the 64-symbol RFC 7932 alphabet: the fast qualities always do, and quality 2 does whenever a meta-block carries at most a hundred and twenty-eight commands.
7. Error propagation
graph LR
lw["large window at q0, q1, q2"] --> cfg["ConfigError::LargeWindowUnsupportedForQuality<br/>(Compressor::new / reconfigure)"]
tm["TooManyPrefixDictionaries"] --> shared
dl["DictionaryTooLarge"] --> shared
ct["SharedContextTooLarge"] --> shared
shared["SharedBrotliError<br/>(private)"] --> de["DictionaryError<br/>(DictionaryBuilder::build)"]
q5["dictionary below quality 5"] --> ee["EncodeError::DictionaryUnsupportedForQuality<br/>(the operation)"]
de --> caller["the caller"]
cfg --> caller
ee --> caller
ee -->|"From<EncodeError>"| io["std::io::Error<br/>(the io adapters)"]
The split is by domain rather than by layer: a window the quality cannot carry
is a configuration mistake and is reported when the compressor is built; a
dictionary that cannot be prepared is a dictionary mistake and is reported
when it is built; a dictionary the quality cannot read needs an operation to
happen at all. SharedBrotliError is private and exists only to carry the
low-level refusals up to whichever public error owns them.
8. Determinism and SIMD
The window touches no SIMD dispatch at all. ResolvedWindow is resolved once
per session from CompressParams alone; no window decision depends on the
instruction set, the machine, or the input.
every_backend_produces_the_same_large_window_stream runs every backend the
host supports at four declared windows across all nine supporting qualities and
requires byte identity.
The shared context adds no dispatch point at all, and touches none of the existing ones. Preparation and the prefix search are both scalar, both pure functions of their inputs. Section 42.3 of the implementation specification lists the prefix hash loop and the prefix match length as kernels to profile before vectorising, and this repository’s rules require measurement before optimisation; Section 45 puts scalar parity first in any case.
The consequence is worth stating plainly: prepared indexes, candidate order and search results are identical on every machine, so a context prepared once may be used by a compressor that later resolves a different backend, and no test has to prove it.
9. Verification topology
| Layer | What it checks |
|---|---|
core::rfc9841::window unit tests | header bits for all 53 large windows and every ordinary window; retained history never exceeds the declaration |
shared::distance unit tests | the large alphabet against every legal (NPOSTFIX, NDIRECT) pair; the 544-symbol histogram ceiling; the degenerate branches of distance_code_limit |
tests/large_window.rs | header golden bits; round trips through the pinned C decoder with BROTLI_DECODER_PARAM_LARGE_WINDOW for 10..=30; header-only equivalence for 31..=62; refusal at qualities 0, 1 and 2; empty and tiny inputs; the bound; streaming and one-shot agreement over sixteen chunk sizes; backend identity |
core::rfc9841::prefix unit tests | attachment ordering; addressing over empty attachments; the distance round trip; saturating arithmetic at u64::MAX; the match scan against a materialised oracle over every start, seam and limit; the word scan against a byte-by-byte comparison at every shared length and limit |
core::rfc9841::prepared unit tests | the shape ladder; every hashable position indexed once; newest-first, capped bucket chains; entry-for-entry equality with CreatePreparedDictionary through the workspace shim, over six corpora including one that triggers shape scaling |
core::rfc9841::context unit tests | attachment order and per-attachment indexes; every construction limit; the allocation estimate bounding the real size; the search’s longest-match, seam-crossing and longest-over-nearest behaviour |
tests/dictionary.rs | byte identity with the C encoder over six dictionary-and-payload shapes at qualities 5 to 11, with the same bytes prepared by BrotliEncoderPrepareDictionary and attached by BrotliEncoderAttachPreparedDictionary; a round trip through the C decoder with the same dictionaries attached; a ratio floor that fails if the dictionary were ignored; agreement between all six dictionary entry points; the refusal below quality 5 on every one of them; attachment order changing the stream and both orders matching the reference; one dictionary shared by four threads; flush with a dictionary attached; every preparation limit; reuse determinism across a failure and an abandoned session; and that a dictionary call never changes the next ordinary one |
fuzz/afl/src/bin/dictionary.rs | the same oracles driven from fuzz input: attachment counts past the format limit, impossible budgets, the addressing round trip, and the refusal below quality five |
tests/differential_c.rs, tests/roundtrip.rs, and the rest | byte identity with equivalent C streaming FINISH settings and independent C decoding; native C one-shot rewrites are intentionally excluded |
Known gaps
- An attached prefix reaches qualities 5 to 11 only. The reference compiles
its compound-dictionary search for
H5,H6,H40,H41,H42,H55,H65and the binary tree, so qualities 0 to 4 have no match finder that could carry a prefix match. Where the reference then ignores the dictionary, every dictionary entry point refuses withEncodeError::DictionaryUnsupportedForQuality. - Custom dictionaries and continuations are experimental. Their current search, context selection, transformed-index limits and checked stream offsets are specified in rfc9841-encoding.md. Parser mechanics remain in serialized-dictionary.md.
- Framing has no pinned C container oracle. Independent wire fixtures and writer/decoder round trips validate containers; C checks compressed payload streams. See the writer and decoder for supported forms, verification and limits.
- Large window is refused at qualities 0, 1 and 2. See §6.
- Declared windows above 30 bits lack independent end-to-end decoding. The pinned C decoder rejects their original headers. Tests check those headers separately and compare the payload with a 30-bit declaration.
- Retained history stops at 30 bits. Matchers and distance arithmetic use this physical ceiling regardless of the declared window.
Serialized shared dictionaries (RFC 9841 section 5)
The serialized dictionary API requires the experimental Cargo feature and
may change in a patch release. It parses and writes dictionary descriptions;
preparation and compression are described in rfc9841-encoding.md.
Rust API/backend identity and independent decoder compatibility apply. Full
custom static search has no blanket C encoder byte-identity oracle. The C test
build enables its serialized parser with BROTLI_EXPERIMENTAL.
1. Module boundaries
The codec is private and the description is public. Nothing above
core::rfc9841 knows the wire layout, and nothing below dictionary knows the
public type names.
graph TD
subgraph public["mbrotli::dictionary (public)"]
sd["SerializedDictionary<br/>SerializedDictionaryBuilder"]
wl["WordList / WordListBuilder<br/>WordListView"]
tl["TransformList / TransformListBuilder<br/>TransformListView"]
parts["DictionaryCombination, ListSelector,<br/>ContextMap, TransformOperation,<br/>OmitLength"]
lim["DictionaryLimits<br/>(seven experimental ceilings)"]
derr["SerializedDictionaryError<br/>WordListError, TransformListError"]
db["DictionaryBuilder::add_serialized"]
end
subgraph core["compressor::core::rfc9841 (private)"]
ser["serialized<br/>(parse, parse_exact, serialize,<br/>SerializedDictionaryData, Combination,<br/>ListRef, SerializedLimits, SerializedError)"]
wor["words<br/>(WordList: size bits, offsets, word bytes)"]
tra["transform<br/>(TransformList: stringlets, triples,<br/>parameters, cutoffs, apply)"]
var["varint<br/>(base-128 read/write)"]
end
sd --> ser
wl --> wor
tl --> tra
parts --> ser
lim --> ser
derr --> ser
db --> ser
ser --> wor
ser --> tra
ser --> var
wor -.borrows.-> builtin["shared::dictionary<br/>(BUILTIN_WORDS and its offsets)"]
tra -.borrows.-> files["builtin_prefix_suffix.bin<br/>builtin_transforms.bin"]
Ownership: a SerializedDictionary owns every byte it describes and borrows
nothing from what it was parsed from. The two built-in lists are the exception
in the other direction — WordList::builtin() and TransformList::builtin()
hold Cow::Borrowed views of static tables, so naming the built-in list costs
the offset arrays and nothing else.
WordListView and TransformListView exist because the owned wrappers are
newtypes over the codec’s types: a parsed dictionary hands out a borrow rather
than cloning a word list that may be a megabyte.
2. Wire format
RFC 9841 section 5. Every field below is checked before the bytes it describes are copied.
2 bytes magic 0x91 0x00
varint LZ77_DICTIONARY_LENGTH
N bytes the LZ77 prefix
1 byte NUM_CUSTOM_WORD_LISTS (0..=64)
per list:
28 bytes SIZE_BITS_BY_LENGTH, lengths 4..=31, each <= 15
N bytes the words, shortest length first
1 byte NUM_CUSTOM_TRANSFORM_LISTS (0..=64)
per list:
2 bytes PREFIX_SUFFIX_LENGTH (>= 1)
N bytes length-prefixed stringlets, terminated by a zero length
that must be the block's last byte, at most 256 stringlets
1 byte NTRANSFORMS
3N bytes (prefix id, operation, suffix id) per transform
2N bytes parameters, present if and only if some operation shifts
if either count is non-zero:
1 byte NUM_DICTIONARIES (1..=64)
2N bytes (word list index, transform list index) per combination,
where the index equal to the custom count names the built-in
1 byte CONTEXT_ENABLED (0 or 1)
64 bytes CONTEXT_MAP, each entry < NUM_DICTIONARIES
3. Parse flow
sequenceDiagram
participant C as caller
participant P as dictionary::SerializedDictionary
participant S as core::rfc9841::serialized
participant W as core::rfc9841::words
participant T as core::rfc9841::transform
C->>P: parse(bytes, DictionaryLimits)
P->>S: parse_exact(bytes, SerializedLimits)
S->>S: total size vs limit
S->>S: magic, then LZ77 length varint
S->>S: prefix length vs format ceiling, then vs limit
S->>S: copy the prefix
loop each word list
S->>S: 28 size bits, derive the byte count
S->>S: running word bytes vs limit
S->>W: from_parts(size bits, data)
W-->>S: WordList or WordListError
end
loop each transform list
S->>S: stringlet block, triples, parameters
S->>T: from_parts(block, triples, params)
T-->>S: TransformList or TransformListError
S->>S: running transform bytes vs limit
end
S->>S: combinations, then the context map
S->>S: assemble: every cross reference
S-->>P: SerializedDictionaryData and consumed length
P->>P: refuse a tail
P-->>C: SerializedDictionary or SerializedDictionaryError
parse reports how many bytes it consumed so an embedding container can carry
on; parse_exact is what the public API calls and refuses a tail.
4. Transform application
The private core::rfc9841::transform::TransformList::apply is a port of
BrotliTransformDictionaryWord. It writes into a caller-held TransformScratch,
so a search allocates nothing per candidate. Public TransformList::apply and
TransformListView::apply copy that result into a new Vec for inspection.
Both truncate input to 31 bytes before applying a transform; an out-of-range
index returns that truncated input unchanged. Rustdoc examples exercise both
valid transforms and this fallback. operation(index) returns None for a
missing transform, allowing callers to distinguish it from identity.
flowchart TD
start["apply(index, word, scratch)"] --> truncate["truncate word to 31 bytes"]
truncate --> bounds{"index < len?"}
bounds -- no --> copy["copy the word, return"]
bounds -- yes --> pre["write the prefix"]
pre --> narrow{"operation"}
narrow -- "OmitLast n" --> cut["drop n trailing bytes"]
narrow -- "OmitFirst n" --> skip["drop n leading bytes"]
narrow -- other --> whole["keep the whole word"]
cut --> body["copy the word bytes"]
skip --> body
whole --> body
body --> rewrite{"operation"}
rewrite -- FermentFirst --> f1["uppercase one rune"]
rewrite -- FermentAll --> fa["uppercase to the word end"]
rewrite -- ShiftFirst --> s1["shift one scalar"]
rewrite -- ShiftAll --> sa["shift to the word end"]
rewrite -- other --> none["leave it"]
f1 --> suf["write the suffix"]
fa --> suf
s1 --> suf
sa --> suf
none --> suf
suf --> out["return the written prefix of the scratch"]
Transform casing and scratch behavior follow the reference:
- the casing model is an exclusive-or — bit five for ASCII and for the second byte of a two-byte sequence, and the constant five for the third byte of a three-byte one. It is not a Unicode mapping and is not locale aware;
- uppercasing may write one byte past the word when the word ends mid-sequence.
The reference relies on that, and the suffix overwrites it.
TransformScratchis sized for the longest possible output rather than the exact one, so the same behaviour costs no out-of-bounds write. Everything the port writes is inside a[u8; 541].
Shift arithmetic follows section 3.1.1: the sixteen-bit parameter is
zero-extended and 0xFF0000 added when its high bit is set, and the result is
added to the 7-, 11-, 16- or 21-bit scalar the encoding pattern describes.
5. Canonical encoding
SerializedDictionary::to_bytes writes one encoding per dictionary:
- the shortest varint for the prefix length;
- the combination block present exactly when a custom list is;
- the parameter block present exactly when some transform shifts;
- the context map present exactly when the dictionary is context based;
- the stringlet table in first-use order with duplicates merged, the zero-length terminator last and doubling as the empty prefix or suffix.
write_to appends the same encoding to a caller-owned Vec without an
intermediate Vec or an enclosing length prefix. Its rustdoc example checks
the original prefix, appended length, and parsing of the appended slice.
The RFC permits a longer varint than a value needs. Such an encoding is accepted on parse and normalised on write, which is the documented treatment of the one noncanonical encoding the format allows.
The pinned C helper reads prefix lengths with ReadVarint32 and rejects a
fifth byte with its high bit set, even for a valid six-to-nine-byte redundant
encoding of a small length. RFC 9841 permits those encodings. AFL minimized
this oracle disagreement to the committed zero-length and 17-byte-prefix
fixtures. The Rust parser retains RFC behavior; differential fuzzing exempts
only that structural C width limit and still requires canonical reserialization
to parse and compressed payloads to decode with C.
WordListBuilder pads each length group to a power of two by repeating its last
word, because the format addresses a word by a fixed-width index and spells “no
words of this length” as a zero exponent — so a group of one word is stored as
two.
6. Resource limits
DictionaryLimits gained seven ceilings, the last six behind experimental.
Every one is checked before the allocation it bounds.
| Limit | Default | What it bounds |
|---|---|---|
max_attachments | 15 | prefix dictionaries per prepared dictionary |
max_serialized_bytes | 128 MiB | the whole stream, before any field is read |
max_prefix_bytes | 64 MiB | the LZ77 prefix, declared length first |
max_word_lists | 64 | custom word lists |
max_word_bytes | 16 MiB | word bytes, accumulated as the lists are parsed |
max_transform_lists | 64 | custom transform lists |
max_transform_bytes | 8 MiB | transform wire bytes, accumulated |
max_combinations | 64 | word-and-transform-list combinations |
The format’s own ceilings are enforced regardless: sixty-four of any list, fifteen size bits, two hundred and fifty-five transforms, two hundred and fifty-six stringlets, and an LZ77 prefix no larger than large-window Brotli’s widest sliding window.
Checked arithmetic: the word byte count is derived from the size bits, whose
product is bounded by 31 << 15 per length and cannot overflow a u32; the
varint reader caps at sixty-three bits; every slice is taken through a checked
take that reports truncation rather than panicking.
7. Parser and transform boundaries
- The LZ77 prefix ceiling is
(1 << 30) - 16. - Public parsing rejects bytes after a complete structure; the C parser can accept the same structure while ignoring trailing input.
- Applying a transform index beyond the list returns the word unchanged.
- The Rust prefix-length reader accepts up to nine varint bytes, including
redundant encodings. C’s
ReadVarint32rejects continuation in byte five. Differential tests account for this narrower C reader and require canonical serialization to parse with C.
8. Verification
| Where | What is checked |
|---|---|
core::rfc9841::varint | every bit position round trips, truncation, the ninth-byte cap, noncanonical encodings, trailing bytes left for the caller |
core::rfc9841::words | the built-in tables tile every length, addressing by length and index, a zero exponent meaning no words, the exponent and data-length refusals, the largest list a length may hold, wire round trip |
core::rfc9841::transform | the built-in list is the reference’s 121 transforms with the cutoff table the packed constant already encodes; every operation; the casing model on a two-byte rune; sign-extended and wrapping shifts; a rune truncated at the word end; eight malformed-list refusals; the longest possible output fitting the scratch |
core::rfc9841::serialized | round trips for prefix, word list, transform list and context map dictionaries; every truncation; ten field-level refusals; the noncanonical varint; limit refusals before allocation |
tests/serialized_dictionary.rs | differential against the C parser: every truncation and every single-byte mutation of a rich dictionary, ten hand-written malformed streams, and the structure the reference recovered field for field; differential against BrotliTransformDictionaryWord: every operation over ASCII, two-byte, three-byte and truncated-rune words, for the custom list and for the built-in one; canonical fixture bytes; each resource limit |
tests/differential_c.rs, tests/dictionary.rs, and the rest | default encoding matches equivalent C streaming settings and all Rust API shapes; native C one-shot differences are specified in universal-encoding.md |
The C oracle exists because brotli-ffi’s own experimental feature compiles
the vendored library with BROTLI_EXPERIMENTAL and adds two shim entry points:
mbrotli_shim_parse_shared_dictionary, which reports the parsed structure the
public API keeps opaque, and mbrotli_shim_transform_dictionary_word. The flag
gates only the serialized parser, custom dictionary construction and one
static_dict.c branch a built-in dictionary never takes; the byte-identity
suite passing unchanged with it defined is what shows no ordinary output moved.
Known gaps
Preparation uses SerializedDictionaryData::allocation_bound to account for the
owned description while prefix and static indexes are constructed. Word and
transform bytes, stringlet indexes, list/combination vector capacities and the
embedded prefix remain part of that peak until the description is dropped.
The preparation flow and allocator-backed regression are described in
rfc9841-encoding.md.
- Custom static compression is implemented by the immutable
core::rfc9841::static_indexdescribed in rfc9841-encoding.md. Its expansion limits aremax_transformed_word_bytes,max_static_entriesand the aggregate retained byte ceiling. A flat index is used instead of a trie. - Container writing is described in framing.md.
- The
serialized_dictionaryAFL target covers parse/serialize, bounded preparation and attached-dictionary C decoding. Extended combination and transform search has no equivalent blanket C encoder byte-identity authority.
RFC 9841 encoding: custom dictionaries and continuations
This specification describes the experimental encoder extensions. The wire
authority is RFC 9841, not the
limitations of the pinned C encoder’s experimental search tables. The existing
serialized dictionary codec owns parsing, canonical
serialization, list validation and transform semantics.
Ownership and preparation
graph TD
description[SerializedDictionary: validated owned description] --> builder[DictionaryBuilder]
prefix[Raw prefix attachments] --> builder
builder --> prepared[PreparedDictionary: immutable, caller-owned]
prepared --> context[private core::rfc9841::SharedContextInner]
context --> prefixes[PrefixSources and PreparedPrefix indexes]
context --> static[static_index::StaticIndex]
static --> combinations[Owned words/transforms and immutable indexes per combination]
prepared -. borrowed per stream .-> session[EncoderSession]
DictionaryBuilder::add_serialized attaches the embedded prefix at that call’s
position and copies the custom static description. The last custom description
replaces an earlier one; additional prefix-only descriptions leave it intact.
build returns the same PreparedDictionary as raw prefix preparation. A
static-only dictionary is valid and has zero prefix attachments. Diagnostics
include its custom source bytes and retained index storage.
StaticIndex::prepare makes two passes over words and transforms. The first
applies transforms into stack scratch, checks transformed byte count, entry
count and the aggregate retained estimate, and records exact capacities. Only
then does the second pass allocate flat entry/byte arrays and shallow hash
tables. The budget includes the owned serialized description that remains alive
during preparation, attachment-vector staging during prefix preparation, and the
built-in transform list’s temporary stringlet index. It bounds the preparation
peak, not only the final index. tests/dictionary_memory.rs checks this with an
allocation-counting system allocator. No transformed word or candidate allocates
during compression.
Each combination owns its word list, transform list, a 32,768-slot shallow hash
table, and entries sorted by (first four bytes, output length, address, base length). The full index permits the entire transform output, up to 541 bytes,
including outputs larger than the pinned C encoder’s scratch limit. Built-in
lists can be named explicitly in combinations. The transform implementation
uses RFC byte operations, never locale-sensitive casing.
The added DictionaryLimits ceilings default to 128 MiB of transformed bytes
and 8,000,000 flat static entries. max_retained_bytes also bounds their total
storage; max_source_bytes includes custom word and transform bytes. The flat
entry count is the implementation’s equivalent of a trie-node limit.
Preparation rechecks description byte/list/combination ceilings even when the
description was parsed earlier under more permissive limits. These refusals use
the public DictionaryError::LimitExceeded; index expansion uses
PreparationTooLarge. Allocation estimates are capped at isize::MAX even if
a caller raises the 64-bit budget further.
Candidate flow and dispatch
flowchart TD
position[Current input position] --> context[Previous two available bytes; zero before stream start]
context --> mode{Encoder family}
mode -->|q5..9| utf8[UTF-8 literal context]
mode -->|q10..11| hqmode[Current meta-block literal context mode]
utf8 --> selected[64-entry map selects first combination]
hqmode --> selected
selected --> shallow[Greedy: C-compatible shallow identity / cutoff probe]
selected --> extended[Greedy: flat-index search for other transforms]
selected --> full[HQ: exact flat-index search across combinations]
full --> address[Selected combination first, remaining combinations in wire order]
address --> merge[Merge deterministic per-length candidates with history and prefixes]
shallow --> score[Existing dictionary score and probe statistics]
extended --> score
merge --> zopfli[Existing Zopfli cost model]
Dictionary distances start after the real/logical sliding window and attached
prefix. Each preceding combination adds its base-length word count multiplied
by its transform count. HQ retains the smallest (distance, base length) for
each output length. A custom index suppresses the implicit built-in search;
the built-in dictionary participates only where the serialized description
names it. Greedy retains the reference’s shallow search and cutoff ordering for
representable transforms, then scores all other matching transforms in its
selected combination using the flat index. Prefix/suffix, omit-first, uppercase,
shift, long outputs, and cutoff layouts the C table cannot represent therefore
participate at q5..9 as well. The extended probe replaces a candidate only for
a strictly better score; sorted entry order resolves equal extended candidates
deterministically. Identity-only fixtures retain their C byte identity.
The existing top-level encoder SIMD dispatch is unchanged. Index preparation
and lookup are scalar; history comparisons continue to receive the selected
SIMD capability. Plain greedy streams keep the ENABLE_PREFIX = false
specialization. All index fields and extra branches compile out without
experimental.
Long transformed words need a base copy length different from their output length. Unused packed modifier ranges represent that base length directly: command modifiers 32..63 and HQ node modifiers 96..127. Existing ordinary command layouts and struct sizes are unchanged. The decoder still receives the RFC base word length, not the expanded length.
Headerless continuation streams
StreamConfig::with_stream_offset supports nonzero offsets at qualities 2..11
with experimental; other combinations return UnsupportedStreamOffset.
The caller must concatenate the output after a compatible, byte-aligned Brotli
flush. A continuation alone is not a Brotli stream.
sequenceDiagram
participant Caller
participant Session as EncoderSession
participant Core as GreedyEncoder / HqEncoder
Caller->>Session: start(offset, optional exact size)
Session->>Session: check offset + declared length <= 2^63 - 1
Session->>Core: suppress header, poison distance cache, clamp logical base to retained window
Caller->>Session: Process / Flush / Finish
Session->>Session: check offset + accepted input before consumption
Session->>Core: first two bytes, flush if more follows
Session->>Core: normal blocks with shifted dictionary addresses
Core-->>Caller: headerless encoded continuation
Only logical dictionary placement includes the offset; history probes remain
limited to bytes actually present in the ring. The first two bytes form the
reference encoder’s restart flush (unless the stream ends within them), so
unavailable preceding literal contexts cannot influence later commands. The
first four distance-cache entries are poisoned and the stored cache is reset
consistently. Logical position overflow returns StreamPositionOverflow before
accepting input. Fresh streams reset the offset, header, caches and staging.
Declared large windows still retain at most 30 bits of history. Clamping an offset to that effective window does not allocate history or authorize reading unavailable bytes. Resource starts in the framing API require offset zero; partial chunks share one encoder session instead.
Verification and known gaps
tests/serialized_dictionary.rsverifies C decoding, C-identical identity dictionaries on every host SIMD level, context combinations, split input, preparation limits, all transform operations and the long-transform packed length regression.- Every transform operation is exercised without an identity fallback at q5..9 on scalar and all available host SIMD levels. These tests assert actual dictionary use in addition to successful decoding. Long transformed-word commands are checked at greedy and HQ qualities.
tests/stream_offset.rschecks C byte identity, decoding after a flushed prefix, empty/one/two-byte restarts, one-byte writes, tiny output buffers, backend identity, overflow and fresh-stream recovery.- AFL’s serialized target parses and reserializes arbitrary bytes, prepares under small budgets, then checks q5/q11 streams with C’s attached-dictionary decoder. Native decoder targets additionally exercise the compact decode-only dictionary representation (see decoder mechanics).
- C byte identity is authoritative only for equivalent exposed behavior. Its experimental HQ encoder searches fewer combinations and shorter transformed words than RFC 9841 permits. Extended cases require deterministic output and independent decoding, not an artificial restriction to those C search limits.
- The native decoder supports declared windows through 62 bits. Independent fixtures and arithmetic models cover the wider range; C verifies up to 30.
Framed compressor
compression,experimental exposes a reusable FramedCompressor, configuration,
borrowed structured input, one-shot operations and native sessions with std or
alloc. no_std excludes only FramedWriter, ResourceWriter,
FramedEncoderReader and transport errors. Decoder types and raw Compressor
remain independent. The shared framing facade re-exports the encoder types;
owner/configuration/error/progress/session types also have crate-root exports.
Ownership and dispatch
graph TD
Facade[framing: neutral types and public re-exports] --> Owner[FramedCompressor]
Owner --> Raw[Compressor: Aggressive inner retention]
Owner --> Engine[private framing core::engine::Engine]
Engine --> Container[Container: wire ordering, queue and directory]
Engine --> Resource[Resource: input/content staging and operation state]
Resource --> Operation[private raw OperationState: no borrows]
RawSession[raw EncoderSession / SessionCore] --> Operation
Operation --> Scheduler[existing StreamState scheduler]
Scheduler --> Workspace[raw retained encoder workspace]
Workspace --> SIMD[fearless_simd backend selected once at owner construction]
Guard[FramedResourceSession] -. borrowed per call .-> Dictionary[external PreparedDictionary]
Guard --> Owner
Borrowed[FramedEncoderSession: &mut owner] --> Owner
Owned[FramedEncoderSessionOwned: owner by value] --> Owner
FramedEncoderSession borrows the owner and FramedEncoderSessionOwned owns
it; neither holds state of its own. Both go through the private begin_session
start path, the same Engine calls and resource helpers, and cancel on
release. See owned sessions.
The owner stores no sink, input slices or dictionary references. The raw operation
state was separated from SessionCore’s compressor/dictionary borrows; both raw
and framed paths invoke its common process driver and StreamState. Native
resource guards retain external dictionary borrows, while the lazy input driver
looks up its borrowed description on each call. Forgetting either guard cannot
leave dereferenceable external references in owner storage. No self-reference,
unsafe lifetime extension, dictionary clone or second raw scheduler is used.
Public modules contain configuration, API guards and std adapters. Private core
contains command admission, logical state, bounded resource staging, metadata,
wire serialization and the borrowed-input cursor. core does not import I/O.
Public input and API
FramedEncodeConfig combines EncoderConfig and the existing public-field
FramingConfig; builder selects backend and outer retention. Defaults remain
full container plus directory, no repeats, 65,536 bytes per chunk, 1 MiB metadata,
8 MiB framing storage, 10,000 resources and 1,000,000 chunks. Profiles and chunk
size (1..=16 MiB with conservative staging allowance) are validated before use.
FramedEncodeConfig::new() is the same default as a const fn, built from
EncoderConfig::new(); Default delegates to it.
FramedInput borrows an ordered slice of FramedItem: complete resources,
metadata or padding. FramedResource carries payload, visibility/checksum,
StreamConfig, and ResourceEncoding::{Uncompressed,Brotli,Shared}. Slice
conversion chooses Exact length; explicit Unknown is preserved. Metadata and
hidden resources remain in input order. References are absolute offsets from the
new container’s start, independent of any destination prefix.
compress, append/rollback compress_into, compress_to_slice and
framed_reader use one lazy core::driver::Driver. It stores item/offset cursors
and advances the same engine as native guards. No destination stages the entire
container. Append restores length and prefix on every error. Slice failure keeps
the written prefix and untouched tail, reporting cumulative progress. Reader
returns already produced bytes before a deferred terminal error; nonempty reads
return EOF only after full suffix delivery. Its into_inner returns the original
input description and cancels.
sequenceDiagram
participant Caller
participant API as Session / borrowed-input driver
participant Engine
participant Raw as Shared raw operation
participant Out as Caller slice / std transport
Caller->>API: start(stream aggregate)
API->>Engine: queue main header, set active owner
Caller->>API: process(output, Process)
Engine->>Out: deliver header prefix
Caller->>API: resource(options, stream, dictionary)
API->>Engine: validate and commit resource
Caller->>API: process(input, output, Finish)
Engine->>Raw: Flush complete nonfinal chunks, Finish final chunk
Raw-->>Engine: compressed bounded chunk
Engine->>Out: framing header and content prefixes
Caller->>API: container Finish
Engine->>Out: repeats, directory, footer
Container process consumes zero payload. Its NeedsInput admits the next command;
NeedsOutput requires draining. Structural methods reject OutputPending before
commit and retain no borrowed metadata/reference lists. Container Exact counts
accepted payload across all resources, including hidden ones, excluding metadata
and padding. It is independent of the raw per-resource size hint; new resources
require zero stream offset. Exceeding a size contract is rejected before accepting
the offered bytes, and underflow prevents successful Finish.
State, cancellation and retry
stateDiagram-v2
[*] --> HeaderPending: start
HeaderPending --> Idle: drain header
Idle --> Pending: metadata or padding committed
Pending --> Idle: drain output
Idle --> Resource: commit resource guard
Resource --> Resource: Process or Flush
Resource --> Idle: final chunk delivered and guard released
Resource --> Abandoned: unfinished guard dropped or forgotten
Idle --> Finishing: Finish
Finishing --> Finishing: drain suffix chunks
Finishing --> Complete: footer delivered
Resource --> Failed: process error
Finishing --> Failed: process error
Abandoned --> Cancelled: container Drop
Failed --> Cancelled: container Drop
Complete --> Cancelled: container Drop applies retention
Cancelled --> [*]
A full input chunk stays staged until another byte, Flush or Finish determines whether it is partial. Exact first-chunk fills followed by Finish remain type 2. Flush/Finish record absolute input boundaries; retries must keep the operation and remaining suffix length. Repeated empty Flush emits nothing. Resource Finished means its final chunk was delivered; only then does the completed-resource count advance. Container Finished includes delivery of all suffix bytes.
Process errors are terminal, with exact per-call accepted/produced counts and
location. Correctable structural errors before commit do not poison the engine.
Locations distinguish resource index/input offset, structured item index, and
container-relative delivered wire offset. Limits include category and maximum;
allocation/overflow/size-contract/raw-codec errors remain distinct and typed.
FramingError aliases FramedEncodeError; owning FramingFinishError retains a
whole std writer for retry. Transport conversion preserves original ErrorKind.
flowchart TD
Commit[Engine queues one complete wire chunk] --> Q[Advance logical queued offset once]
Q --> Copy[Copy bounded prefix into caller output]
Copy --> Native[Advance delivered wire count]
Native --> Transport[Writer holds fixed 8192-byte inline buffer]
Transport --> Write[Sink write]
Write -->|short write| Cursor[Advance transport cursor by accepted count]
Cursor --> Write
Write -->|Interrupted| Write
Write -->|WouldBlock, zero, oversized count, error| Retain[Keep unwritten suffix for retry]
Retain --> Write
Write -->|all accepted| Next[Request next engine prefix]
Next --> Copy
Next -->|container complete| Flush[Flush sink; retry failure without new suffix]
ResourceWriter::write returns accepted payload before reporting a later codec
failure; pending wire bytes and a deferred error survive. Sink failures retain
transport cursors. Structural writer methods drain previous output before issuing
a command. Resource flush drives raw Flush then transport flush; container flush
only drains and flushes transport. No destructor writes. finish preserves the
complete writer on error; into_inner cancels. get_mut is for sink repair:
inserting/removing wire bytes would invalidate offsets.
Normal container Drop releases the owner for reuse. Forgotten container guards leave abandoned protection; trim does not clear it. Recover releases raw and framing storage, preserving policy/backend. Reconfigure validates first, so a bad configuration preserves both old policy and abandoned protection. A successful reconfigure cancels and applies the new policy. Raw retention remains Aggressive within a container; outer ReleaseAll/Bounded applies at the container boundary.
Wire serialization
The main header is 91 0a 42 52 FLAGS; bit 2 is set for the full profile and clear
for footerless single-resource framing, preserving the repository’s established
interpretation. Varints are minimal and bounded to 63 bits. All content offsets
are relative to the container, independent of transport writes.
| Type | Emission |
|---|---|
| 0 | Zero-filled explicit padding; metadata adjacency is preserved |
| 1 | Resource metadata preceding its resource |
| 2 | Single complete resource, including empty payload |
| 3 / 4 / 5 | First / middle / last chunks of one continuing resource |
| 6 | Footer metadata following its resource |
| 7 | Global metadata |
| 8 | Repeated resource/footer metadata, preserving order and selected fields |
| 9 | Repeated-metadata offset and exact type 1–8 content headers/offsets |
| 10 | Reversed file-size/directory varints; size includes the footer itself |
Codec 0 is verbatim, codec 2 starts ordinary Brotli, codec 3 starts Shared Brotli,
and codec 1 retains decoder state across compressed partial chunks. Large Window
without a dictionary uses Shared Brotli with zero references. Compressed partials
share one raw operation. Metadata compression starts independent streams;
original and repeated copies can have different encodings. None selects all
repeated fields; an empty selection still emits each required empty repeated
chunk. Reserved metadata codes, UTF-8 id, eight-byte mt, field multiplicity,
ordering, profile restrictions and reference limits remain validated.
Reference serialization permits at most one serialized and fifteen prefix attachments. Internal pointers must address earlier recorded content, with whole resources starting at type 2/3. Resource commands can only follow complete earlier resources. Shared repeated metadata permits external references only.
Memory and verification
Framing allocations use fallible reservations and checked/budgeted sizes. Storage comprises bounded input/content buffers, pending output/cursor, exact directory records, encoded repeats and field selection. Directory/repeats scale with the number and content of commands; there is no O(1) promise for a whole container. Raw encoder workspace, external dictionaries and caller destinations are separate from the framing budget. Owner retention sums raw and framing owned capacities. The fixed inline writer transport buffer has no heap allocation.
Legacy fixtures in testdata/framing-legacy were captured at 910653f before
serializer changes. tests/framing.rs compares them before independent wire
parsing and retains sink fault injection. tests/framed_encoder.rs compares
native/one-shot/slice/reader/writer schedules, lifecycle, dictionaries and metadata.
tests/framed_encoder_memory.rs measures retained/peak requested heap and
injects framing allocation failures. framed_encode adds AFL schedules, tiny
output and invalid command/reference coverage. benches/framed_compress.rs
measures owner reuse and validates bytes outside timing; C only oracles raw output.
See the validation record.
Known boundaries: there is no C whole-container oracle, checksum computation, automatic dictionary resolver or symbolic resource-index references. Metadata emission uses independent streams rather than decoder-only exotic continuation forms. Reader consumes borrowed complete slices; gradually arriving payload uses native guards or ResourceWriter. Retained-byte accounting is requested heap storage, not allocator overhead or an RSS ceiling.
Language bindings
| Specification | Scope |
|---|---|
| C ABI | mbrotli-ffi: one-shot and session C functions, pointer validation, panic poisoning and status mapping. |
Architecture overview and module map
C ABI (mbrotli-ffi)
mbrotli-ffi is a separate workspace crate that exports mbrotli’s one-shot
codecs and owned streaming sessions to C. It builds libmbrotli_ffi as a cdylib and a staticlib (plus
an rlib for its Rust tests) and ships the declarations in
mbrotli-ffi/include/mbrotli.h. It depends on mbrotli with std,
compression and decompression, and adds no codec logic of its own beyond
reproducing two special cases of Google’s one-shot encoder.
Module and ownership boundaries
graph TD
Header[include/mbrotli.h] -. declares .-> Root
C[C or C++ caller] --> Root[mbrotli_ffi crate root: exported symbols and MbrotliResult]
Root --> Core[private mbrotli_ffi::core]
Core --> Validate[LengthSlot / Region / Arguments: pointer validation]
Core --> Guard[guarded: catch_unwind]
Core --> Sessions[private core::sessions: owning handles and poison guard]
Sessions --> Owned[EncoderSessionOwned / DecoderSessionOwned]
Core --> Codec[compress / decompress / compress_bound]
Codec --> Compressor[mbrotli::Compressor::compress_to_uninit]
Codec --> Decompressor[mbrotli::Decompressor::decompress_to_uninit]
Codec --> Stored[stored_stream: uncompressed fallback]
Tests[tests/c_abi.rs, benches/one_shot.rs, fuzz c_abi] --> Root
Tests --> Google[google-brotli-ffi oracle]
The crate root owns only the ABI surface: the #[repr(C)] MbrotliResult
enum, opaque session types, operation constants and #[unsafe(no_mangle)] extern "C" functions with their contract
documentation. Everything else is in the private core module, which owns the
one unsafe pointer-to-slice conversion (the output becomes
&mut [MaybeUninit<u8>], so a C buffer need not be initialized), the panic guard, parameter
conversion, the private thiserror error FfiError, and its reduction to a
status code. mbrotli’s src/ stays free of unsafe; every unsafe block is in
this crate, each with a // SAFETY: comment.
Public API surface
| C | Rust | Behaviour |
|---|---|---|
mbrotli_result | MbrotliResult (#[repr(C)], c_int-sized) | MBROTLI_OK = 0, MBROTLI_INVALID_PARAMETER = 1, MBROTLI_OUTPUT_TOO_SMALL = 2, MBROTLI_ERROR = 3; streaming adds NEEDS_INPUT = 4, NEEDS_OUTPUT = 5, FINISHED = 6 |
mbrotli_compress | unsafe extern "C" fn | One complete stream; quality 0..=11, lgwin 10..=24 (RFC 7932 windows only) |
mbrotli_decompress | unsafe extern "C" fn | Exactly one complete stream; standard and RFC 9841 large windows |
mbrotli_compress_bound | safe extern "C" fn | Google’s BrotliEncoderMaxCompressedSize; 0 on overflow |
*output_len is capacity on entry and bytes written on return: the count on
MBROTLI_OK, and 0 on every other status except a null or misaligned
output_len, which is never written.
mbrotli_compress is byte-identical to BrotliEncoderCompress(quality, lgwin, BROTLI_MODE_GENERIC, ...). That holds because compress_to_uninit declares
the input length as the size hint, as the C one-shot API does, and because
core::compress reproduces the two places where the C one-shot API does not
simply return its streaming encoder’s bytes:
- an empty input is the single byte
0x06, whatever the window; - a stream longer than the bound, or one that does not fit in an output of at
least the bound, is replaced by
MakeUncompressedStream’s stored stream.
Control and data flow
sequenceDiagram
participant C as C caller
participant Root as extern "C" fn
participant Core as core::call
participant Codec as core::compress / decompress
participant M as mbrotli
C->>Root: pointers, lengths, parameters
Root->>Core: forward (unsafe contract)
Core->>Core: LengthSlot::new(output_len)
alt null or misaligned
Core-->>C: INVALID_PARAMETER (length untouched)
end
Core->>Core: read capacity, Arguments::new (null, size, overlap)
Core->>Core: slices() — the only from_raw_parts, output as MaybeUninit
Core->>Codec: guarded(|| codec(src, dst))
Codec->>M: Compressor::new + compress_to_slice or compress_to_uninit / Decompressor::new + decompress_to_slice or decompress_to_uninit
M-->>Codec: Result<usize, EncodeError / DecodeError>
Codec-->>Core: Result<usize, FfiError>
Core->>Core: write written or 0 to output_len
Core-->>C: MbrotliResult
Each one-shot call builds its codec, runs it and drops it, so no state is shared between one-shot calls or threads. SIMD dispatch is mbrotli’s: the backend is detected once per codec construction, which happens once per call here, never per block.
Compression decision logic
flowchart TD
Start[mbrotli_compress] --> P{quality 0..=11 and lgwin 10..=24?}
P -->|no| Invalid[INVALID_PARAMETER]
P -->|yes| Empty{input empty?}
Empty -->|yes| One{capacity >= 1?}
One -->|yes| Byte[write 0x06, OK]
One -->|no| Small[OUTPUT_TOO_SMALL]
Empty -->|no| Encode[Compressor::compress_to_uninit]
Encode --> Fits{Ok and written <= bound?}
Fits -->|yes| Ok[OK]
Fits -->|no| Cap{capacity >= bound?}
Cap -->|yes| Store[stored_stream, OK]
Cap -->|no| Fail[OUTPUT_TOO_SMALL or ERROR]
The bound is input_len + 2 + 4 * (input_len >> 14) + 4 (2 for empty
input). The stored stream is a 10-bit window header and empty metadata block
(0x21 0x03), one uncompressed meta-block per 2^24 bytes with a 3- or 4-byte
header using the fewest length nibbles, and an empty last meta-block (0x03).
It never exceeds the bound, so an output of mbrotli_compress_bound bytes
always succeeds.
Destination paths
flowchart TD
C[mbrotli_compress] --> CU[compress_to_uninit]
D[mbrotli_decompress] --> L{"capacity <= LINEAR_SMALL (1 KiB)?"}
L -->|yes| ZD["BorrowedBuf::ensure_init zeroes it"] --> Linear["decompress_to_slice: output is the first member's history"]
L -->|no| DU[decompress_to_uninit]
DU --> Stored{one complete stored member that fits?}
Stored -->|yes| Copy[copy payload from input]
Stored -->|no| Ahead["zero one meta-block (at most 32 KiB) ahead, decode with output as history"]
Each pair of paths returns the same bytes, counts and errors.
compress_to_uninit copies each block out of the encoder’s scratch, which on
the harness corpora (q0 and q1, a fresh encoder per call as here) took as long
as writing fragments in place into an initialized slice; zeroing the
destination first, as earlier versions did at q0/q1, made the same calls up to
21% slower on repetitive data. decompress_to_uninit decodes into the
destination as history and zeroes it only one meta-block ahead of the decoder
(see uninitialized output). Earlier versions zeroed the
whole destination for decompress_to_slice when its capacity exceeded the
input but stayed within max(64 KiB, 4 x input_len), and decoded through the
history ring otherwise. On exact-sized destinations (harness corpora: text,
binary, repetitive and stored data, q1/q5/q9/q11, i7-13700KF, one core) the
current paths take 9% less time in geometric mean: 15-20% on 1 MiB of text,
which compresses past the ratio limit and took the ring, 28-41% on data
compressed more than 100:1, where decoding outruns a separate up-front
zeroing pass, and up to 12% on an 8 MiB mixed corpus; no case got slower by
more than noise. Only up to 1 KiB does
zeroing everything win: one decoding call instead of one per meta-block,
about 7% on 1 KiB of text.
Error propagation
classDiagram
class FfiError {
<<private, thiserror>>
NullLength
MisalignedLength
NullBuffer
OversizedBuffer
Overlap
Quality
Window
Config(ConfigError)
Encode(EncodeError)
DecodeConfig(DecodeConfigError)
Decode(DecodeError)
Panic
}
class MbrotliResult {
<<public, repr C>>
Ok
InvalidParameter
OutputTooSmall
Error
}
FfiError ..> MbrotliResult : conversion from FfiError reference
Argument and parameter failures map to InvalidParameter; EncodeError or
DecodeError::OutputTooSmall to OutputTooSmall; everything else, including
corrupt, truncated or trailing compressed input and a caught panic, to
Error. Codec errors are kept as #[from] sources inside FfiError, but only
the status code crosses the ABI.
Invariants
- Pointer validation before any dereference.
output_lenmust be non-null and aligned before it is read or written. Buffers may be null only with a zero length; lengths aboveisize::MAXor ending past the address space are refused; input, output and theoutput_lenslot must be pairwise disjoint, since&[u8]and&mut [MaybeUninit<u8>]may not alias. Empty buffers become empty slices regardless of their pointer. - Output is never read before it is written. The output is viewed as
&mut [MaybeUninit<u8>], so an uninitialized C buffer is sound. Compression always callscompress_to_uninit. Decompression zeroes a destination of at mostLINEAR_SMALL, 1 KiB, throughborrowed_buf::BorrowedBuf::ensure_init, which hands back the zeroed bytes as&mut [u8]withoutunsafein this crate, and decodes withdecompress_to_slice; larger destinations go todecompress_to_uninit. See uninitialized output. - Undetectable misuse remains undefined. Dangling or too-short buffers, and concurrent access by another thread, are the caller’s contract.
- No unwinding into C.
catch_unwindwraps each codec call; the closure isAssertUnwindSafebecause the only state it can leave half-updated is the output buffer, whose contents are unspecified after any failure. - Output contents after failure. Unspecified, except that
MBROTLI_OUTPUT_TOO_SMALLfrommbrotli_decompressleaves the decoded prefix in the full buffer. - Decoding stops at the first failure it meets. A stream that is corrupt
or truncated only after the point where the output fills up returns
MBROTLI_OUTPUT_TOO_SMALL; telling it apart from a valid stream would mean decoding past the buffer into nowhere. A larger buffer then yieldsMBROTLI_ERROR. Google’s one-shot decoder reports both cases as its single error result.
Verification
mbrotli-ffi/src/core.rsunit tests: region validation and overlap, parameter ranges, panic guard, status mapping and error sources, stored headers at every nibble boundary, stored streams up to 2^24 + 1 bytes, the fallback and the bound.mbrotli-ffi/tests/c_abi.rs: byte identity withBrotliEncoderCompressfor qualities 0–11 at windows 10/16/22/24 on text, binary, incompressible, one-byte and empty inputs; identical accept/reject at capacities between the stream and the bound; bound equality withBrotliEncoderMaxCompressedSize; round-trips through both decoders; every rejected argument; concurrent calls; and that the header’s enumerators matchMbrotliResult.fuzz/afltargetc_abi(see fuzzing).mbrotli-ffi/benches/one_shot.rs: both one-shot APIs on identical inputs, validated for identity and round-trip before timing.
Streaming sessions
MbrotliEncoderSession / MbrotliDecoderSession are opaque C handles. Their
private allocation is core::sessions::Handle<T>: an owned Rust session and a
poison flag. Handle<T> deliberately has Rust layout and stores the session
inline. Its layout is not the C ABI: the header declares incomplete struct
types, every exported signature passes pointers, and only this library
allocates, accesses and destroys the allocation. Callers never need its size
or alignment. Changing the Rust session, field order or handle allocation size
therefore does not change the C ABI. A second Box<T> or repr(C) on the
private storage would add no compatibility guarantee. The public Rust opaque
marker structs also do not describe the private allocation and must never be
instantiated or passed by value. Handles belong to the library instance that
created them; this contract does not support moving live allocations between
separately loaded library versions.
There are no borrowed codecs, self-references or caller buffers
stored in the wrapper. create returns null on invalid parameters or failure;
destroy drops the allocation exactly once, and accepts null. Independent
handles may run concurrently; each individual handle requires exclusive access.
C suffix (after mbrotli_encoder_session_ / mbrotli_decoder_session_) | Contract |
|---|---|
create | Encoder takes quality/window, decoder uses defaults; input/output sizes unknown. |
process | Takes input, in/out input count, output, in/out output count and integer operation. |
flush | Empty-input encoder Flush or decoder Process; no EOF. |
finish | Empty-input Finish, valid once the final offered input is fully consumed. |
reinit | Abandons current stream and restarts with default stream settings and retained workspace. |
destroy | Releases even unfinished or failed sessions. |
MBROTLI_PROCESS=0, MBROTLI_FLUSH=1, MBROTLI_FINISH=2 are passed as int
so unknown discriminants can be rejected safely. Decoder process rejects Flush;
its separate flush function uses Process. Both process functions return
MBROTLI_NEEDS_INPUT=4, MBROTLI_NEEDS_OUTPUT=5, or MBROTLI_FINISHED=6 on
success. Existing one-shot status discriminants 0–3 remain unchanged.
sequenceDiagram
participant C as C caller
participant S as core::sessions
participant R as Owned Rust session
C->>S: create(configuration)
S->>R: new + into_session(default stream)
S-->>C: opaque owning pointer
C->>S: process(handle, buffers, counts, operation)
S->>S: validate handle, slots and all pairwise regions
S->>S: mark poisoned, enter catch_unwind
S->>R: process_uninit(input, output, operation)
R-->>S: progress or failure
S->>S: clear poison only on success, publish exact counts
S-->>C: NeedsInput / NeedsOutput / Finished / Error
C->>S: reinit or destroy
S->>R: reinit(default stream) or drop
On entry counts describe available input and output capacity; on return they are consumed/produced counts for that call. Input is advanced by consumed; NeedsOutput requires another output buffer even when input is empty. The Finish operation must be retried with its exact unconsumed suffix. Encoder Flush ends with NeedsInput and permits more input afterward. The default single-member decoder leaves protocol suffix bytes unconsumed on Finished. Streaming encoding follows the Rust session API, without one-shot empty-input normalization or a stored-stream fallback to enforce the one-shot bound.
All three output-taking functions use process_uninit: a C output buffer need
not be initialized. Success initializes exactly the reported prefix and never
writes beyond it. Decoder codec errors also report exact progress and preserve
that guarantee; encoder errors and panics return zero counts with unspecified
output. Null/misaligned arguments, unknown operations, impossible lengths and
region overlaps return InvalidParameter without changing counts or state.
Dangling pointers, undersized allocations, wrong-kind handles and concurrent
access remain caller violations. Validation precedes all slice/handle references.
stateDiagram-v2
[*] --> Active: create
Active --> Active: process / flush / NeedsOutput
Active --> Finished: Finished
Finished --> Finished: idempotent process/finish
Active --> Poisoned: codec error or panic
Active --> Active: invalid arguments (no change)
Finished --> Active: reinit succeeds
Poisoned --> Active: reinit succeeds
Active --> Active: reinit succeeds
Active --> Poisoned: reinit fails
Poisoned --> Poisoned: process returns Error
Active --> [*]: destroy
Finished --> [*]: destroy
Poisoned --> [*]: destroy
The guard sets poison before invoking codec code and retains it on failure or panic. Reinit is guarded too and clears poison only after success. Destruction also catches unwinding. SIMD detection happens at codec construction and is retained across calls and reinitialization; no dispatch is added to inner loops.
Verification: tests/sessions.rs exercises every exported session function with
uninitialized output, tiny buffers, empty input, flush/finish, trailing input,
invalid pointers/overlaps and reuse after failures. Private unit tests inject
panics into construction, processing and reinitialization. These run under
Miri and ASan via scripts/check_simd_uninit.py (shard 1, or --ffi-only). The
existing AFL/libFuzzer c_abi body adds bounded session round trips and reuse.
benches/sessions.rs compares q5/w22 streaming C calls with Google’s encoder
and decoder, with creation/destruction included, 4 KiB output chunks and
validation outside timing; it reports compressed sizes alongside throughput.
examples/sessions.c compiles and runs as C or C++, linking only the public
header and library. An attempted sizeof(mbrotli_encoder_session) or
sizeof(mbrotli_decoder_session) is rejected by C as an incomplete type.
Focused verification commands (workspace root unless stated otherwise):
cargo test -p mbrotli-ffi --locked
cargo llvm-cov -p mbrotli-ffi --lib --test sessions --locked
python3 scripts/check_simd_uninit.py miri --ffi-only
RUSTFLAGS=-Ctarget-cpu=x86-64-v3 CARGO_TARGET_DIR=target/miri-v3 \
python3 scripts/check_simd_uninit.py miri --ffi-only --target x86_64-apple-darwin
python3 scripts/check_simd_uninit.py asan --ffi-only
cargo bench -p mbrotli-ffi --bench sessions --locked
# From fuzz/afl, after cargo afl build --release --bin c_abi:
AFL_MAP_SIZE=1048576 AFL_SKIP_CPUFREQ=1 AFL_NO_AFFINITY=1 \
AFL_I_DONT_CARE_ABOUT_MISSING_CRASHES=1 \
cargo afl fuzz -i regressions/c_abi -o /tmp/mbrotli-c-sessions-afl \
-S smoke -V 30 -c - -- target/release/c_abi
The 1 MiB AFL coverage map fits the local macOS shared-memory limit without system tuning; the fuzzer still negotiates the instrumented target’s map size. The initial ARM64 smoke run (AFL++ 4.40c, cargo-afl 0.18.2, Rust 1.99.1, non-experimental release target) executed 94,363 inputs with 99.93% stability, zero saved crashes and zero saved hangs. Generated reports and findings remain local artifacts.
Known gaps
- Large-window compression (
lgwin25–30, whichBrotliEncoderCompressaccepts) and compression modes other than generic are not exposed. - Sessions expose default stream settings and reusable codec state, but no dictionaries, input-size hints, decoder budgets, member modes or configuration changes. Recreate a handle to change encoder quality or window.
- On aarch64, clang contracts the C oracle’s floating-point multiply-adds into
FMA instructions, so Google’s qualities 10–11 can differ from mbrotli on
rare inputs; built with
-ffp-contract=offthe C library agrees. The tests and the fuzz target skip that comparison there for the input that triggers it. - There is no
no_stdbuild: the crate needsstdforcatch_unwind, the system allocator and runtime SIMD detection. - Decoding into a C buffer still zeroes every output byte once, just ahead
of the decoder. On exact-sized buffers (i7-13700KF, one core, harness
corpora)
mbrotli_decompresstherefore takes 1.2% longer thandecompress_to_sliceinto an already initialized buffer (geometric mean; previous versions 11.9%), most on data compressed more than 100:1 and on 1 MiB of repetitive text, where decoding runs as fast as zeroing; stored, ordinary text and binary data are at parity.
Development tools
How the repository measures, tests, proves and publishes itself.
| Specification | Scope |
|---|---|
| Implementation comparison | Isolated library comparisons, validation, provenance and charts. |
| Fuzzing | Target inputs, oracles, campaigns, regression replay and the libFuzzer adapters. |
| Continuous integration | Triggers, checks, Miri and AddressSanitizer, fuzz campaigns, coverage gating and release publishing. |
| Documentation publishing | Source staging, navigation, section pages, recipe execution, link validation and Pages deployment. |
| Formal verification | Charon/Aeneas translation to Lean 4, function specifications, round-trip theorems and the trusted base. |
Architecture overview and module map
Implementation comparison
benchmarks/comparison/ is an unpublished standalone Cargo workspace with pinned
competitors and its own lockfile. The library exposes run and run_decoders;
private core modules own corpora, adapters, validation and timing. Production
codec APIs and dependencies are separate.
flowchart TD
Corpus[8 deterministic corpora] --> Encode[5 encoder adapters]
Encode --> Validate[C decoder validates every output]
Corpus --> Prepare[C produces 96 immutable streams]
Prepare --> Decode[4 decoder adapters restore identical inputs]
Validate --> EncTimer[Criterion: 432 cold encoding cases]
Decode --> DecTimer[Criterion: 384 cold decoding cases]
EncTimer --> Export[report.py joins baseline and sizes.csv]
DecTimer --> Export
Export --> CSV[validated complete CSV]
CSV --> Docs[quality_docs.py and decoder_docs.py]
CSV --> Plot[plot.py]
Docs --> Pages[quality pages, medians and SVG charts]
Plot --> Pages
Pages --> EncoderDocs[encoders: index, quality pages and charts]
Pages --> DecoderDocs[decoders: index, quality pages and charts]
Measurement contract
| Suite | Implementations | Result directory |
|---|---|---|
implementations | C, mbrotli, Rust brotli, SIMD Brotli at q0–q11; Burli at q0–q5 | target/criterion |
decoders | C, mbrotli, Rust brotli and Burli on source q0–q11 | target/criterion-decoders |
Both use generic mode, window 22 and the same eight corpus definitions. Complete matrix validation happens before timing, including filtered runs. Unsupported qualities are omitted. Encoder output must round-trip through C; decoder output must equal the original bytes. Corpus construction and validation stay outside timing. Each codec chooses its native backend.
Cold iterations include codec construction, allocation, transformation and disposal. C decoding receives the known output capacity; Rust Brotli’s helpers include 4 KiB I/O adaptation. The encoder comparison includes C one-shot output rewrites and does not require compressed byte identity across libraries.
The decoder suite retains identical C-produced streams for every adapter.
Throughput counts restored bytes; quality belongs to the source encoder.
SIMD Brotli is omitted from decoding because its API re-exports the Rust
brotli-decompressor project. The lockfile can resolve different versions of that
project; their performance is not assumed equal.
Errors and reporting
Preflight errors stop the run before timing or replacing sizes.csv. Private typed
errors identify adapter failures and payload mismatches. Timed errors are captured
and returned after the affected Criterion case. FFI receives disjoint initialized
buffers with checked capacities; empty C output reserves at least one byte.
report.py joins a named baseline to the matching size manifest by complete case
ID. It rejects duplicate/incomplete matrices and inconsistent lengths. Exports
include mean latency, 95% confidence bounds, throughput and output sizes.
Generators validate unique supported keys, finite timings and shared dimensions. They derive speed ratios per dataset, then take the median across all eight inputs, including empty and tiny inputs. Encoder size ratios use the same rule. The decoder Burli column takes direct per-dataset Burli/mbrotli time ratios; it does not divide C-relative medians.
Pages retain every workload and exact value. Overview qualities are ordered by mbrotli median speed relative to C; dataset charts retain confidence bounds. Bars start at zero, and throughput bounds invert latency bounds. Empty input has latency but no meaningful throughput or compressed/input ratio. The optional paired renderer accepts a separate complete 96-case before/after CSV.
Published layout
docs/benchmarks/encoder-comparison.{md,csv} and
encoder-comparison-environment.json hold the encoder report, measurements and
provenance. Decoder files use the corresponding decoder-comparison names.
Each codec has its own encoders/ or decoders/ directory containing an index,
q0.md–q11.md, and charts/overview.svg alongside detailed charts.
quality_docs.py generates encoder quality pages; plot.py writes their shared
overview, throughput and size charts. decoder_docs.py generates decoder pages
and charts together. Generators compute links relative to their output directory.
The published CSVs are the enc-2026-10-30-amd and dec-2026-10-30-amd
sweeps of 2026-10-30 on the AMD Ryzen 9 7950X Linux VDS. Both matrices run
serially, one process on logical CPU 1, with ordinary release code generation,
runtime backends and 20 samples/1 s warmup/3 s requested measurement per case.
Each environment record links source/lockfile hashes, the exact frozen measured
executable and its complete CSV. The archived size manifests live under
encoder-comparison-2026-10-30/ and decoder-comparison-2026-10-30/.
Current encoder/decoder quality pages and charts are regenerated from those
respective CSVs. Superseded local reports, dated manifests and intermediate AMD
profiling exports are removed from the published docs; local raw artifacts stay
separate. The root compress suite’s 682 byte-identical Rust/C API pairs use a
different measurement contract and are not merged into these native-API results.
Encoder and decoder SVG writers omit timestamps and strip trailing whitespace so
generated artifacts remain reproducible and pass repository whitespace checks.
Verification and known gaps
Criterion test mode exercises the full matrix. Python tests check export pairing, malformed records, medians, generated pages and SVG structure. See benchmarking for commands and results for published data.
- Archive
sizes.csvwith its named baseline; later preflight runs replace it. Matching lengths alone cannot prove that two artifacts came from the same source. - Equal quality numbers do not imply equal compressed size or search policy.
- These suites measure cold serial APIs. Root benchmarks separately cover retained
state, slices, streaming, parallel tasks and experimental formats.
The root
compresscold group includes 1 MiB incompressible and repeated-byte inputs at every quality, covering deep sparse-pool growth and long-copy search. Its 682 Rust/C pairs require exact output identity with matched C streaming settings before timing, unlike the native-API comparison above. - Fixed implementation order and shared hosts can bias timings; confidence bounds do not capture every environmental effect or establish a significant lead.
The experimental framed_decompress suite also includes indexed opening and
resource reads. See framed seek measurements
for the corpus, command and limits of the raw C control; C has no framed container
reader. Fixtures and compressed sizes are validated before timing.
Fuzzing subsystem
AFL++ coverage-guided fuzzing for every quality the crate implements, the
streaming state machine, the prepared dictionary and the compressor lifecycle.
This document describes the fuzz/afl package as it exists today: its module
boundaries, the input model, where each oracle comes from, how a finding travels
back into the test suite, and which boundaries are still unfuzzed.
Ownership boundaries
fuzz/afl is a separate, unpublished package, deliberately excluded from the
root workspace (Cargo.toml, exclude = ["fuzz/afl"]) so that AFL’s
instrumentation and its runtime never reach an ordinary root cargo test or
cargo clippy. It depends on mbrotli and on google-brotli-ffi by path.
Backend selection goes through mbrotli::Backend using production backends;
the fuzz package has no direct dependency on the SIMD implementation crate.
A second excluded package, fuzz/libfuzzer, runs the same target bodies under
libFuzzer for ClusterFuzzLite; see
libFuzzer adapters and ClusterFuzzLite.
The fuzz package has no default features. Its opt-in experimental feature
forwards to both mbrotli/experimental and google-brotli-ffi/experimental,
enabling the serialized dictionary parser and its C oracle together. The targets
serialized_dictionary, framing, decode_serialized, framed_decode, and
framed_roundtrip require it: their
Cargo binary entries, target bodies, private helpers, C helpers, and TARGETS
entries share the gate. The default build contains 30 targets; enabling the
feature contains all 36.
flowchart TD
Build[Fuzz package feature selection] --> Stable[30 stable targets and regression corpora]
Build --> Enabled{experimental enabled?}
Enabled -->|yes| Dependencies[Rust experimental APIs and C experimental oracle]
Dependencies --> Extra[Serialized dictionary, framing and decode_serialized binaries and bodies]
Extra --> Registry[TARGETS includes both experimental corpora]
Enabled -->|no| Omit[Experimental binaries and registry entries omitted]
The package is split so that the AFL dependency stops at the binary layer:
graph TD
subgraph engine["Engine layer (depends on afl)"]
bins["src/bin/ — 27 stable and 5 experimental afl::fuzz! adapters"]
end
subgraph neutral["Engine-neutral layer (no afl dependency)"]
targets["src/targets.rs and src/decode_targets.rs<br/>TARGETS registry, one fn per target"]
lib["src/lib.rs<br/>Context, decode_case, cap,<br/>host_levels, C oracles"]
end
subgraph replay["Replay layer"]
regtest["tests/regressions.rs"]
corpus["regressions/ — one directory per target"]
end
subgraph under["Under test / oracles"]
mbrotli["mbrotli public API"]
ffi["google-brotli-ffi<br/>(C encoder and decoder)"]
simd["mbrotli::Backend"]
end
bins --> targets
regtest --> targets
regtest --> corpus
targets --> lib
targets --> mbrotli
lib --> ffi
lib --> simd
classDef engineNode fill:#f9d6d5,stroke:#a94442;
class bins engineNode;
Only src/bin/ names afl. Every target body is a plain
fn(&Context, &[u8]), so the same code runs under the fuzzer, under
cargo afl test, and under a debugger. That is what makes a minimised crash
reproducible without an instrumented binary.
Context is built once per process and holds the detected Backend and the
deduplicated list of host backends. Each iteration creates its compressors
through Context::encoder and drops its owned stream state afterward. Parallel
batches use their own synchronization and cancellation state. No compressor or
batch state is shared between fuzz inputs, and fuzz_with_reset! is not used.
Input model
The common payload and parameter input shapes cap data at MAX_PAYLOAD
(128 KiB). Specialized lifecycle, serialized dictionary, framing, and parallel
targets decode their own bounded command or format structures.
flowchart TD
input["AFL input bytes"] --> shape{"target shape"}
shape -->|payload only| raw["whole input is the payload<br/>q0 to q11 roundtrip"]
raw --> capA["cap to MAX_PAYLOAD"]
capA --> fixed["config = (fixed quality, Window::DEFAULT)"]
shape -->|settings header| hdr["decode_case: 6 header bytes"]
hdr --> q["byte 0 — IMPLEMENTED_QUALITIES indexed by b mod 12"]
hdr --> w["byte 1 — Window 10 + b mod 15<br/>spans the ordinary range, always legal"]
hdr --> c["byte 2 — chunk = 1 shl (b mod 18), always at least 1"]
hdr --> f["byte 3 — mode in the low two bits,<br/>literal context modelling in bit 2"]
hdr --> bl["byte 4 — zero leaves lgblock to the encoder,<br/>otherwise BlockBits 16 + b mod 9"]
hdr --> dc["byte 5 — postfix bits and direct groups,<br/>falling back to the default pair when unrepresentable"]
hdr --> capB["remainder capped to MAX_PAYLOAD,<br/>StreamConfig declares InputSize::Exact(len)"]
shape -->|numeric settings| pp["parameter_parsing: 2 header bytes"]
pp --> qn["byte 0 — quality value b mod 20<br/>reaches 10 and 12 and above, which are illegal"]
pp --> wn["byte 1 — window value b, 0 to 255<br/>reaches below 10 and above 24"]
shape -->|"large window"| lw["large_window: 1 byte, then decode_case"]
lw --> lwn["byte 0 — declared window b mod 70<br/>reaches below 10 and above 62, both illegal"]
lw --> lwr["remainder — a whole decode_case input,<br/>so quality and distance layout still vary"]
shape -->|"dictionary"| sc["dictionary: 2 bytes, then decode_case"]
sc --> scn["byte 0 — attachments b mod 18<br/>reaches 16 and 17, both past the format's limit"]
sc --> scs["byte 1 — every fourth value squeezes<br/>DictionaryLimits to an impossible budget"]
sc --> scr["remainder — a whole decode_case input;<br/>its payload is cut into the attachments<br/>and then matched against them"]
shape -->|"lifecycle"| cl["compressor_lifecycle: 8 bytes, then decode_case"]
cl --> cln["each byte mod 8 — one command:<br/>compress, append, short destination, trim,<br/>read retained bytes, reconfigure,<br/>abandon a session, leak one and recover"]
cl --> clr["remainder — a whole decode_case input"]
shape -->|"C ABI"| ca["c_abi: 3 header bytes"]
ca --> caq["byte 0 — C quality b mod 16 minus 2<br/>reaches -2, -1, 12 and 13, all illegal"]
ca --> caw["byte 1 — C lgwin b mod 32<br/>reaches below 10 and above 24"]
ca --> cac["byte 2 — output capacity selector for both directions"]
ca --> car["remainder capped to MAX_PAYLOAD —<br/>compressed as plaintext and decoded as a stream"]
decode_case is closed over the legal domain by construction: its window index
covers exactly the ordinary 10..=24, so the unwrap_or(DEFAULT) fallback is
unreachable, chunk is never zero, and an unrepresentable distance layout falls
back to DistanceParams::Auto. The declared size is the payload’s true length,
which is what the one-shot entry points declare for themselves, so the streaming
and one-shot targets stay comparable with each other and with the C reference.
That keeps the equivalence and differential targets focused on encoder
behaviour. parameter_parsing exists because of that closure — it is the only
target that can reach the validating conversions and the large-window refusal.
Targets and oracles
| Target | Input | Oracle |
|---|---|---|
q0_roundtrip | payload | no panic, compressed.len() <= Compressor::max_compressed_size, C decoder round-trip |
q1_roundtrip | payload | same, at quality 1 |
q3_roundtrip | payload | same, at quality 3 |
q4_roundtrip | payload | same, at quality 4 |
q5_roundtrip | payload | same, at quality 5 |
q6_roundtrip | payload | same, at quality 6 |
q7_roundtrip | payload | same, at quality 7 |
q8_roundtrip | payload | same, at quality 8 |
q9_roundtrip | payload | same, at quality 9 |
q10_roundtrip | payload | same, at quality 10 |
q11_roundtrip | payload | same, at quality 11 |
decode_roundtrip | shared encoder header | independent C compression, exact native decoding and streaming equivalence; plaintext capped at 64 KiB |
params_roundtrip | header | bound, round-trip, and that a reused compressor, a second call on it and a fresh one all agree, over every legal setting |
simd_equivalence | header | every distinct host backend emits identical bytes |
differential_c | header | byte identity with Google Brotli v1.2.0 streaming FINISH configured with the same quality, window, mode, block size, size hint, distance layout and context setting, including empty input |
streaming_equivalence | header | vector, append, exact slice, uninitialized slice (nothing written past the stream), writer, reader and low-level session emit identical bytes with declared size at arbitrary chunk sizes, including empty and incompressible inputs; every process call that moved nothing reports why; the stream round-trips |
encoder_session | header, then a plan byte last | borrowed and owned sessions reach compress’s bytes with process and process_uninit mixed per call by complementary plans and the flush/finish shorthands; the same flushing schedule (every one to four blocks, spread to at most 64 flushes so that q10/q11 inputs stay within libFuzzer’s timeout) gives both shapes identical bytes that round-trip through C; every call reports no more than it was given, and process_uninit writes nothing past produced; a finished session consumes and produces nothing through any method; reinit after finishing, abandoning, flushing and a rejected restart (which fails the session until the next one) starts a stream identical to a fresh one; both compressors still match a fresh one |
output_capacity | header | exactly sized dst accepted, one byte short reported as OutputTooSmall by compress_to_slice and compress_to_uninit, appending preserves the destination’s prefix and returns the range it added, and a failed call does not change the next one |
parameter_parsing | numeric | TryFrom and Window contracts hold; every legal quality compresses and round-trips; Compressor::new refuses a large window at qualities 0 to 2 and accepts it above |
large_window | large window | Window::large contract holds; qualities 0, 1 and 2 refuse when the compressor is built rather than dropping the request; bound, determinism, backend identity; C decoder round-trip up to 30 declared bits, and above it the stream differs from the 30-bit stream only in the six header bits |
dictionary | dictionary | preparation is a transaction — an empty, count or limit refusal yields no dictionary; the accessors agree with what was attached; the offset-to-distance mapping round-trips and saturates at both ends; below quality 5 every entry point refuses rather than ignoring, and the compressor still works afterwards; at quality 5 and above the three entry points agree, the output fits the bound, and a dictionary call never changes the next ordinary one |
serialized_dictionary | dictionary stream | parser validity versus C, excluding its five-byte varint limit and ignored trailing bytes; canonical reserialization; bounded preparation of prefixes/custom indexes; q5/q11 compression independently decoded by C with the serialized dictionary attached |
framing | settings byte and bounded resource bytes | resource/metadata sequences with bounded chunks, independent metadata compression and selected repeats; identical bytes under one-byte, 37-byte and 2048-byte caller writes; directory completeness including type 8; C decoding of metadata streams; successful finalization or typed validation failure |
parallel | bounded task/source settings | deterministic task schedules, slice/seek-source equivalence, staged assembly and C decoding; streamed session, writer and reader reproduce the batch or round-trip after a flush |
c_abi | C ABI | mbrotli-ffi’s exported functions, plus session round trips and reinit with at most 512 payload bytes, qualities 0–9, and uninitialized output chunks of 1–64 bytes: out-of-range quality or window returns MBROTLI_INVALID_PARAMETER with *output_len == 0; otherwise a mbrotli_compress_bound buffer always suffices, the stream round-trips, one byte short decodes as MBROTLI_OUTPUT_TOO_SMALL, and output matches Google’s BrotliEncoderCompress byte for byte, including accept/reject at a selected smaller capacity (qualities 10–11 are skipped on aarch64, where clang’s FMA contraction moves the C oracle); the payload decoded as a stream agrees with Google’s large-window streaming decoder: a fully consumed success is identical; trailing data or malformed input is MBROTLI_ERROR, or MBROTLI_OUTPUT_TOO_SMALL when the output fills before the malformation, in which case a 1 MiB buffer must still not succeed |
compressor_lifecycle | lifecycle | whatever sequence of reuse, appending, deliberate failure, trimming, reconfiguration, abandoned and leaked sessions the input asks for, the compressor still emits the bytes a fresh one would for the configuration it ended up with |
Byte comparison checks the equivalent C encoding policy. Independent C decoding checks stream validity and content. Rust API/backend comparisons check consistency, while bounds and capacity tests check output-buffer contracts.
Per-iteration flow
sequenceDiagram
participant AFL as AFL++ forkserver
participant Bin as src/bin adapter
participant Body as targets body
participant Lib as mbrotli
participant C as google-brotli-ffi
Note over Bin: Context::default() once, before the loop<br/>(SIMD detection, backend enumeration)
AFL->>Bin: persistent iteration, input bytes
Bin->>Body: body(&ctx, data)
Body->>Body: decode_case / cap
Body->>Lib: Compressor::new, compress, compress_into,<br/>compress_to_slice, start, writer, reader
Lib-->>Body: compressed bytes, or ConfigError / EncodeError
alt oracle needs the reference
Body->>C: BrotliEncoderCompress
C-->>Body: reference bytes
end
Body->>C: BrotliDecoderDecompress
C-->>Body: decoded bytes
Body->>Body: assert oracle
Body-->>Bin: return, or panic on violation
Bin-->>AFL: iteration result
A panic is the signal; nothing catches it. Errors that are part of the API
contract — LargeWindowUnsupportedForQuality, OutputTooSmall,
DictionaryUnsupportedForQuality, AbandonedSession, the TryFrom
rejections — are asserted on rather than treated as crashes.
SIMD dispatch point
host_levels delegates to Backend::available(), which returns each supported
backend once, from lower to higher SIMD levels. Scalar is included only on
targets that require it; forced scalar equivalence is covered by library unit
tests. Context::default() detects and enumerates before the persistent loop.
The fuzz package has no direct dependency on fearless_simd; unsupported
implementation tokens cannot cross the public API.
Finding lifecycle
stateDiagram-v2
[*] --> Campaign: cargo afl fuzz
Campaign --> Crash: oracle violated
Crash --> Minimised: cargo afl tmin
Minimised --> Committed: committed as crash-*.bin
Committed --> Failing: cargo afl test must fail
Failing --> Fixed: fix the encoder, not the harness
Fixed --> Passing: cargo afl test must pass
Passing --> Campaign: resume with -i -
Passing --> [*]
tests/regressions.rs walks the TARGETS registry, and for each entry replays
every .bin file under regressions/<name>/ through that target’s body.
decode_roundtrip aliases the encoder’s regressions/params_roundtrip corpus. It
checks that experimental targets are registered exactly when their feature is
enabled. CI and local completion checks run Clippy and cargo afl test both
with --no-default-features and with
--no-default-features --features experimental. The replay
also asserts that no target has an empty corpus, so adding a target without
seeding it fails the suite. The corpus holds hand-written boundary-*.bin
cases — empty input, truncated and extreme headers, minimum and maximum window
sizes, smallest and largest chunk sizes, incompressible payloads — plus
crash-*.bin reproducers as findings arrive.
Seed corpora
Seeds are generated, not committed: prepare-seeds.sh derives them from the
vendored submodule at brotli-ffi/vendor/brotli/tests/testdata, and
minimise-seeds.sh reduces each corpus with cargo afl cmin, keeping the
unminimised original alongside as seeds/*.raw. seeds/generic contains raw test data; seeds/params wraps it in parameter
headers, including small-input Q5–Q11 cases. Dictionary seeds vary attachment
counts at 0, 1, 15 and 16 with both generous and impossible budgets. Corpus counts
depend on generation and coverage minimization.
seeds/serialized is the exception: RFC 9841 dictionary streams have no
counterpart in the upstream test data, so the seeds are copies of the committed
regression corpus — valid streams of every shape the format allows, plus the
malformed ones worth starting a campaign from. The target also prefixes the
magic bytes when an input lacks them, so a mutation spends its effort on the
fields rather than on the two-byte signature.
seeds/large_window is each parameter seed behind one more byte, at four
declared windows — the floor, the default, the widest the pinned C decoder
reads, and the widest the format allows.
Minimisation cuts the file count, not the byte count: the large fixtures carry
coverage the small ones miss and survive cmin. Per-iteration cost is bounded
by MAX_PAYLOAD, not by the corpus. No dictionary is used — the targets
consume arbitrary payload bytes rather than a token grammar.
minimise-seeds.sh exports AFL_NO_FORKSRV=1 for afl-cmin folder-mode
coverage collection. This runs the target once per input and avoids persistent
forkserver timeouts during corpus minimization. It measures coverage with the
target/release build unless TARGET_DIR names another one.
Campaign structure
campaign.sh runs encoder targets plus decode_roundtrip: one AFL++ worker per
target per
feature configuration, each with its own seed corpus and output directory, all
bounded by the same wall-clock duration and a fixed execution timeout. The
experimental feature reaches into the encoder, so its 24 stable targets are
fuzzed twice — once from each build — and the two experimental-only targets
once. The builds occupy separate target directories, because the shared
binaries have the same names.
flowchart TD
Prepare["prepare-seeds.sh<br/>vendored testdata → seeds/*"] --> Minimise
Minimise["minimise-seeds.sh<br/>cargo afl cmin per corpus"] --> Builds
Builds["cargo afl build --release<br/>target/stable, target/experimental"] --> Choice
Choice{"CAMPAIGN_PARALLEL"}
Choice -->|"unset: phases in sequence,<br/>one thread per worker"| Stable
Choice -->|"1: phases together,<br/>46 workers oversubscribed"| Both
Stable["stable phase: 22 workers"] --> Experimental["experimental phase: 24 workers"]
Experimental --> Findings
Both["stable 22 + experimental 24"] --> Findings
Findings["findings/<root>/<build>/<target><br/>queue, crashes, hangs, fuzzer_stats"] --> Triage["tmin → regressions/ → cargo afl test"]
A fixed -t matters: with a trailing + AFL++ calibrates the timeout from the
seeds and treats the value only as a ceiling, which discards slow quality 10
and 11 mutations as timeouts instead of executing them. The default is 30000
milliseconds, about four times the slowest observed instrumented execution of a
128 KiB payload at quality 11 across three backends.
The encoder and decoder scripts can run together when host resources permit. Oversubscription reduces executions per worker and distorts timeout observations. Reproduce saved hangs standalone; inspect elapsed/user time, page faults and peak memory before deciding whether the cause is search cost, allocation or a loop.
Known gaps
- Decoder targets and their independent C oracle are described below. Encoder round-trip targets continue to use Google’s C decoder.
- The two campaign scripts do not overlap.
campaign.shfuzzes the encoder surface anddecode_roundtrip;decoder-campaign.shfuzzes the decoder surface. Running only one leaves the other unfuzzed. - Framing fault injection is deterministic, not fuzz-driven.
tests/framing.rsinjects short writes and retryable failures at each tested offset; the fuzz target varies valid resource/metadata sequences and chunking. - Payloads are capped at 128 KiB. Inputs longer than that are truncated, so
no fuzzed stream reaches the multi-fragment sizes
tests/vendor_corpus.rscovers, including its 12 MiB case. A capped payload does still span more than one encoder block when the case header pins the block size: sixteen bits makes any payload over 64 KiB non-final in its first block. - CI smoke campaigns are bounded evidence.
.github/workflows/ci-fuzz.ymlruns manual campaigns including serialized dictionaries and framing; a short campaign is not a substitute for longer fuzzing. - Most regression corpora are seeded, not found. Every
boundary-*.binis hand-written. The two noncanonical-varint serialized fixtures are minimized AFL findings documenting the C helper’s narrower integer reader. prepare-seeds.shdoes not emit acompressor_lifecyclecorpus. That target’s committed cases are hand-written command sequences; a campaign starts from those rather than from the vendored test data.- ClusterFuzzLite does not run the fixed-quality
q*_roundtriptargets.params_roundtripanddifferential_creach every quality through the parameter header; the fixed-quality targets remain AFL-only.
libFuzzer adapters and ClusterFuzzLite
fuzz/libfuzzer is a cargo-fuzz package excluded from the root workspace, like
fuzz/afl. It owns no target logic: each of its 25 fuzz_targets/*.rs files
is a libfuzzer_sys::fuzz_target! adapter around the body the AFL binary of
the same name runs, taken from the mbrotli-afl library by path. That library
names no AFL symbol; only fuzz/afl/src/bin/ calls afl::fuzz!, and nothing
in the libFuzzer package links those binaries. A libFuzzer finding therefore
replays through cargo afl test once its input is committed under
fuzz/afl/regressions/<target>/.
graph TD
subgraph afl["fuzz/afl"]
aflbins["src/bin/ — afl::fuzz! adapters"]
bodies["library: TARGETS bodies, Context, C oracles"]
regs["regressions/ and tests/regressions.rs"]
end
subgraph lf["fuzz/libfuzzer"]
lfbins["fuzz_targets/ — fuzz_target! adapters<br/>19 stable, 6 experimental"]
end
subgraph cflite[".clusterfuzzlite"]
docker["Dockerfile: base-builder-rust"]
build["build.sh"]
end
aflbins --> bodies
regs --> bodies
lfbins --> bodies
docker --> build
build -->|"cargo fuzz build, or cargo build under coverage"| lfbins
build -->|prepare-seeds.sh, regressions| corpora["OUT/target_seed_corpus.zip"]
Each adapter holds the Context in a LazyLock static, so backend detection
runs once per process as it does in the AFL adapters. The experimental
adapters carry required-features = ["experimental"], the gate their bodies
already share; the package’s experimental feature only forwards to
mbrotli-afl/experimental.
The 19 stable targets are every stable AFL target except the eleven
fixed-quality round trips. The six experimental ones are decode_serialized,
framed_decode, framed_encode, framed_roundtrip, framing and
serialized_dictionary.
Build and corpora
build.sh runs inside ClusterFuzzLite’s Rust image. It fetches the pinned
Brotli submodule when the checkout left it empty, because every C oracle needs
it. It then builds twice, each time with -O --debug-assertions, which also
turns on overflow checks (the coverage build differs, as described below):
flowchart TD
Start[build.sh] --> Sub{vendored Brotli present?}
Sub -->|no| Fetch[git submodule update at the pinned commit]
Sub -->|yes| Stable
Fetch --> Stable[cargo fuzz build into WORK/stable]
Stable --> ShipS[copy every built target to OUT]
ShipS --> Exp[cargo fuzz build --features experimental into WORK/experimental]
Exp --> ShipE[copy only targets not yet in OUT]
ShipE --> Seeds[prepare-seeds.sh and prepare-decoder-seeds.sh]
Seeds --> Zip[per target: mapped seeds plus regressions, each at most 64 KiB, zipped]
Zip --> Dict[framed.dict copied as framed_decode.dict]
The stable binaries come from a build without experimental, so they fuzz
the default surface a dependant gets; the experimental build’s copies of the
same targets are not shipped. The two builds use separate target directories.
Under the coverage sanitizer the image’s cargo wrapper rewrites
cargo fuzz build into cargo build --bins run from fuzz/. It drops
--fuzz-dir, --target-dir and --features, and the build then reaches the
root crate, which has no binaries. The coverage build therefore runs
cargo build --bins inside fuzz/libfuzzer directly, with the same target
directories and features. It relies on the image’s RUSTFLAGS for
--cfg fuzzing and the coverage instrumentation, adds the wrapper’s
--remap-path-prefix for fuzz_targets/, stays unoptimized as the wrapper
does, and ships from debug/ instead of <host>/release/.
flowchart LR
San{SANITIZER} -->|address| Fuzz["cargo fuzz build -O --debug-assertions<br/>ships WORK/build/host/release"]
San -->|coverage| Plain["cd fuzz/libfuzzer; cargo build --bins<br/>ships WORK/build/debug"]
Fuzz --> Out[OUT]
Plain --> Out
Seed selection follows the AFL campaigns: parameter-header targets take
seeds/params; decompress, decode_streaming, decode_io_limits and
decoder_session take seeds/decoder; dictionary, large_window and
serialized_dictionary take their own generated corpora. Every target also
gets its committed regressions/<target>/ inputs. Files get a seeds- or
regressions- prefix so equal names cannot collide. Seeds over 64 KiB are
left out, since every target caps its payload at 128 KiB or less.
The workflows that run these binaries are described in continuous integration.
Parallel boundary target
parallel uses the public task API, 64 KiB segments and at most 128 KiB of input. It compares one-task and reverse three-task output, all available host backends, retained workers, and independent C decoding. Each backend runs four batches: original quality, adjacent quality, original quality again, and unchanged quality. Output is compared per configuration; rejected Large Window reconfiguration must preserve settings and retained bytes. The engine-neutral body and per-quality seeds are replayed by the existing regression runner.
flowchart LR
Input[bounded bytes + quality/expansion controls] --> Settings[original, adjacent, restored, unchanged quality]
Settings --> Reject[reject Large Window without mutation]
Reject --> Plan[public parallel planner]
Plan --> One[one task]
Plan --> Reverse[three tasks, reverse order]
One --> Equal[exact byte equality per configuration across tasks and backends]
Reverse --> Equal
Equal --> C[C decoder: one stream round trip]
The parallel target checks the full payload-and-descriptor memory bound: an
explicit ceiling at that bound succeeds and one byte less is rejected. It
compares explicit memory staging with BatchConfig::auto across task counts
and backends.
The parallel target also compares borrowed slice input with an owned
SeekSource<Cursor<Vec<u8>>> through the generic prepare_source API, exercising
absolute offsets and length checks under the same decode/determinism oracle.
After the batches, each backend streams the same input three ways, with a chunk size and window (1-3) taken from the control bytes and an optional flush at half the input:
- a
ParallelEncoderSessionfed in chunk-sizedprocesscalls through a 97-byte output buffer, running each batch of queued tasks in reverse order on the fuzzing thread beforewait; - a
ParallelEncoderWritergiven chunk-sizedwrite_allcalls, withWrite::flushat the flush point and one executor thread; - a
ParallelEncoderReaderover the whole input, read into buffers of at most 4 KiB, with one executor thread.
Without a flush, all three must equal the batch output for the original quality; with one, the session and the writer must round-trip. The reader never flushes, so it must always equal the batch.
flowchart LR
Batch[batch output, original quality] --> Cmp{flush requested mid-stream?}
Session[session: inline reverse-order tasks] --> Cmp
Writer[writer: one executor thread] --> Cmp
Cmp -->|no| Equal[session and writer equal the batch]
Cmp -->|yes| Round[session and writer round-trip]
Reader[reader: one executor thread] --> Same[equals the batch]
Native decoder boundaries
decode_targets adds decompress, decode_roundtrip, decode_streaming, decode_dictionary,
decode_lifecycle, and decode_io_limits in the base profile, with
decode_serialized gated at binary, body and registry levels by experimental.
The first target compares independent C results and exact member consumption;
streaming checks one-shot equivalence and cumulative progress; decompress
also compares decompress_to_uninit, which decodes linearly into memory it
zeroes one meta-block ahead, with ring delivery, and the same warm decoder’s
decompress_into onto a prefixed vector (linear append) with the collected
decompress result, including rollback of a failed append. session_targets
adds decoder_session: borrowed and owned sessions driven by complementary
plans that mix process, process_uninit, flush and finish reach the same
bytes and ending as each other and as one-shot decoding, process_uninit
writes nothing past produced even on failure, a finished session stays
inert, reinit after finishing, failing or half a stream reproduces the same
outcome, and both decoders still match a fresh one. A successful
bounded arbitrary-input decode is replayed with default limits to exercise
complete stored-member recognition; the first decode proves that this replay
can produce at most 64 KiB. Malformed inputs retain the bounded path. Raw/serialized
attachments feed attached decoding; lifecycle covers forgotten sessions and
recovery; writer faults combine output budgets with cursor-preserving retries.
graph LR
Seed[committed regression / AFL mutation] --> Budget[bounded input / output / workspace]
Budget --> Rust[native decoder or adapter]
Budget --> Oracle[C decoder: typed outcome]
Rust --> Accepted{bounded decode succeeded?}
Accepted -->|yes| Owned[default-limit owned decode; compare bytes]
Owned --> Check[bytes / member boundary / progress / lifecycle]
Rust --> Check
Oracle --> Check
Check --> Regression[deterministic regression replay]
decode_oracle distinguishes success with exact consumption, invalid data,
insufficient input, output budget, allocation refusal, rejected attachment and
C-unsupported wide windows. Resource refusal is not treated as a format verdict.
scripts/fuzz_decoder.sh base|experimental 60 executes reproducible campaigns
against prebuilt binaries and fails on saved crashes/hangs. Build the matching
profile first. Decoder fixtures and upstream seeds are described in
fuzz/afl/regressions/decoder-provenance.md and the decoder compatibility report.
Decoder campaigns
campaign.sh covers the encoder surface and decode_roundtrip.
decoder-campaign.sh covers this one: the six base targets and, in the
experimental build, decode_serialized, thirteen workers in all. Both phases
run at once, because thirteen workers fit an ordinary host without
oversubscription, and the two builds again occupy separate target directories.
The three targets that read a compressed member directly — decompress,
decode_streaming and decode_io_limits — take seeds/decoder, which
prepare-decoder-seeds.sh materialises from the committed regression inputs and
from Google Brotli’s own *.compressed* fixtures below 50 KiB. Those targets
accept arbitrary bytes and need no seeds to run, but bytes that already decode
reach the grammar sooner than mutations toward a valid header. decode_roundtrip
reads the encoder’s parameter header and uses seeds/params; the rest carry
their own input models and use their committed corpora. SEED_ROOT overrides a
worker’s corpus with $SEED_ROOT/<config>-<target> when that directory exists,
which is how a cmin-reduced queue from an earlier campaign is carried forward.
flowchart TD
Prepare["prepare-decoder-seeds.sh<br/>regressions + vendored *.compressed*"] --> Corpus
Corpus["seeds/decoder"] --> Choice
Params["seeds/params, regressions/<target>"] --> Choice
Carry["SEED_ROOT/<config>-<target><br/>cmin of an earlier queue"] -->|when present| Choice
Choice{"seeds_for config target"} --> Both
Both["stable 6 workers + experimental 7 workers,<br/>run together, fixed -t"] --> Findings
Findings["findings/<root>/<build>/<target>"] --> Triage["tmin → regressions/ → cargo afl test"]
The execution timeout is fixed at five seconds rather than calibrated, for the
reason the encoder campaign uses a fixed thirty: a trailing + would let AFL++
derive its own ceiling from the seeds and discard slower decodes as timeouts.
Five seconds is far above any decode of a 128 KiB member, so anything reaching
it is a finding rather than a slow input.
C encoder to native decoder round-trip
decode_roundtrip reuses the encoder’s decode_case six-byte parameter header
and seeds/params corpus. Byte zero selects one quality with % 12; the small
parameter seeds include Q0–Q11. Each iteration compresses at one quality, not
all twelve. Windows 10–24, mode, block size, literal context and distance settings
use the same legal configuration mapping as the encoder targets. Plaintext is
capped at 64 KiB before calling c_compress_with, whose size hint uses that
actual capped length.
The independent C streaming encoder produces a valid stream; a fresh native
Decompressor using the context’s already-selected backend must return the
exact plaintext. Every decoding error is a failure here, including resource
refusal: these generated inputs must fit the 64 KiB output and 8 MiB workspace
budgets. The same compressed bytes also reach decode_streaming, which checks
chunked equivalence, exact consumption, cumulative progress and termination.
Encoder state and decoder state are owned by the iteration; no state survives
between fuzz inputs, and no production API or SIMD dispatch boundary changes.
flowchart LR
Input[encoder seeds/params or AFL mutation] --> Header[decode_case: Q0–Q11 and legal settings]
Header --> Cap[plaintext capped at 64 KiB]
Cap --> C[independent C encoder through FFI]
C --> Rust[native one-shot decoder: selected host backend]
Cap --> Equal[assert exact plaintext equality]
Rust --> Equal
C --> Stream[existing chunked decoder oracle]
Stream --> Progress[assert equivalence and bounded progress]
Regression replay aliases this target to regressions/params_roundtrip, so it
shares the encoder’s committed inputs without duplicating files. Focused tests
exercise every quality, all modes, both standard-window endpoints and every
available host backend, plus empty, short and capped payloads. CI and
scripts/fuzz_decoder.sh run this target in base and experimental profiles;
fuzz/afl/campaign.sh includes it in both of its phases. Generate seeds/params
with prepare-seeds.sh before running the decoder campaign script.
Known gaps: generated streams use ordinary standard windows and no attached dictionaries. Arbitrary-byte and dictionary decoder targets remain responsible for malformed streams and attachment boundaries. AFL mutation does not guarantee equal time on each quality, and corpus minimization may drop quality-specific seeds; deterministic tests retain the full quality matrix.
The arbitrary-byte decompress and decode_streaming corpora also contain
8/32/256-byte deterministic pseudorandom seeds, consumed directly with no
compression step. Errors from malformed input are expected; panics and oracle
violations fail replay. A focused test feeds arbitrary byte arrays of lengths
0, 1, 2, 3, 7, 31, 256 and 4096 through both targets on every host backend.
Structured framed decoding
framed_decode incrementally parses arbitrary bytes with bounded input, output,
metadata and chunk counts, varies input/output slices, compares successful owned
and streaming payload, checks progress, and verifies whole-Vec rollback.
framed_roundtrip creates compressed resources with metadata/repeats through the
writer, verifies the owned result, then runs the incremental oracle. Both targets,
binaries, registry entries and seed corpora are experimental-only. The byte-parser
corpus contains independent signature, raw, stored, metadata and footer fixtures.
dictionaries/framed.dict supplies wire tokens without constraining mutations.
Both targets run in the experimental matrix of .github/workflows/ci-fuzz.yml
for ten minutes with five-second per-input timeouts and their committed regression
seeds. CI passes the wire dictionary to framed_decode and fails on saved crashes
or hangs while archiving findings.
graph LR
Bytes[arbitrary bytes] --> Parser[framed_decode]
Structured[payload bytes] --> Writer[bounded framing writer]
Writer --> Roundtrip[framed_roundtrip: verify owned payload]
Roundtrip --> Parser
Parser --> Progress[fragment identity and bounded progress]
Parser --> Rollback[owned versus streaming and Vec rollback]
Build with cargo afl build --release --features experimental --bin framed_decode --bin framed_roundtrip. Run each with cargo afl fuzz -i regressions/TARGET -o /tmp/UNIQUE-FINDINGS -S smoke -V 30 -c - -- target/release/TARGET. Findings
remain local; deterministic seeds use .bin so regression replay includes them.
No system-wide AFL tuning is required by these commands.
Native framed encoding schedules
framed_encode is experimental in its binary, body, registry and seeds. It caps
payload to 1024 bytes and compares native output of widths 1/2/7/31 with writer
output under identical Flush/Finish boundaries. Without explicit flush it also
compares borrowed one-shot and lazy Read. Modes cover stored/Brotli/Shared,
hidden/checksum flags, zero output, rejected metadata and internal references.
Non-shared results round-trip through FramedDecompressor. CI runs the target from
its committed regression seeds; generated campaigns remain local artifacts.
flowchart LR
Seed[bounded selector and payload] --> Native[native resource schedule]
Seed --> Writer[Write with matching Flush]
Native --> Equal[wire equality]
Writer --> Equal
Seed --> NoFlush{no explicit Flush}
NoFlush --> OneShot[one-shot and lazy Read]
OneShot --> Equal
Equal --> Decode[framed decode oracle for non-shared modes]
Indexed framing
The experimental framed_decode target also opens FramedSeekReader on arbitrary
bytes, reads up to sixteen resources in reverse order after an early-drop probe,
and loads resource/footer metadata. Sequentially valid inputs must produce the
same resource bytes and metadata. Indexed acceptance alone does not imply full
container validation. framed_roundtrip sends writer-generated indexed containers
through this oracle. regressions/framed_decode/seek-resource.bin seeds the path.
Continuous integration
Documentation website
pages.yml builds the mdBook on documentation, tooling, and codec-source changes
in pull requests and pushes to master, and supports manual dispatch. It installs
pinned mdBook and mdbook-mermaid releases, compiles/runs the documented Rust
recipes, builds all docs/ and architecture/ pages, and validates local links
and anchors, including the root README’s links into the published site. Only a push or manual run on master can deploy to GitHub Pages;
the deployment job alone receives Pages/OIDC write permissions. See
documentation publishing for source and artifact boundaries.
flowchart LR
Change[Docs or codec source change] --> Recipes[Compile and run recipes]
Recipes --> Book[Build mdBook and validate links]
Book --> Artifact[Upload Pages artifact]
Artifact --> Branch{master and not PR?}
Branch -->|yes| Deploy[Deploy to GitHub Pages]
Branch -->|no| Validated[Validation only]
Workflow definitions live in .github/workflows/.
Codec-test checkouts include vendored submodules. Local commands are in
development.
Triggers and checks
| Workflow | Trigger | Scope |
|---|---|---|
ci.yml | Push to master, pull request | Formatting, Clippy, docs, packaging, API compatibility, feature checks, tests and AFL replay |
ci-lean.yml | Push to master, pull request | Lean translation drift check and Lean proofs with axiom audit; see formal verification |
ci-coverage.yml | Manual | Std and no_std function coverage; 100% gate and HTML report |
ci-benchmarks.yml | Manual | Criterion validation and timing on Linux x86-64 and ARM64 |
ci-fuzz.yml | Every tag, manual | Bounded AFL campaigns in stable and experimental profiles |
ci-cflite-pr.yml | Pull request touching code, fuzz or build files | ClusterFuzzLite libFuzzer/ASan run on the changed code |
ci-cflite-cron.yml | Daily, manual | ClusterFuzzLite batch fuzzing, corpus pruning and coverage report |
ci-miri.yml | Every tag, manual | SIMD and uninitialized-memory tests on x86-64 (x86-64-v3) and ARM64, four shards each; experimental decoder goldens |
ci-sanitizer.yml | Every tag, manual | AddressSanitizer: SIMD and uninitialized-memory tests on x86-64 and ARM64, plus integration tests |
ci-decompressor-heavy.yml | v* tag, manual | Actual 25–30-bit history and counters beyond 4 GiB in four decoder profiles |
release.yml | Manual, from a v* tag | Publishes mbrotli to crates.io through trusted publishing |
pages.yml | Documentation or codec-source changes on master and pull requests; manual | Builds mdBook, checks links/anchors, runs recipes; publishes only master |
flowchart TD
Branch[master push or pull request] --> Routine[ci.yml]
Routine --> Build[lint, docs, package and semver]
Build --> Docs[deny rustdoc warnings: all-features and std with experimental]
Routine --> Features[std, no_std and isolated codec consumers]
Routine --> Tests[platform tests and AFL regression replay]
Routine --> LibFuzzer[libFuzzer adapter fmt and Clippy]
Branch --> Lean[ci-lean.yml: translation check and proofs]
Branch -->|pull request touching code| CFPR[ci-cflite-pr.yml: code-change fuzzing]
Daily[daily schedule] --> CFCron[ci-cflite-cron.yml: batch, prune, coverage]
CFCron -. coverage report selects targets .-> CFPR
Tag[any pushed tag] --> HeavyTools[fuzz, Miri and ASan]
Release[v* tag] --> Decoder[ignored heavy decoder tests]
Manual[workflow_dispatch] --> HeavyTools
Manual --> Decoder
Manual --> Bench[benchmarks]
Manual --> Coverage[std tests plus no_std overrides]
Manual --> Publish[release.yml: publish mbrotli]
Coverage --> Gate[100% function coverage and HTML artifact]
Routine tests cover Linux x86-64, Linux ARM64 and macOS, with an MSRV 1.89 job.
All-features builds activate no_std; separate std checks retain I/O, parallel
and framing coverage. Alloc-only jobs also compile thumbv7em-none-eabi.
Consumer scripts check available imports and imports that must fail.
Documentation builds deny warnings in both the all-features and std experimental profiles, covering alloc-only and std-only API links. Crate-level type links use shortcut references with feature-dependent definitions: enabled APIs resolve to local items, while disabled APIs link to the feature-selection section. This preserves navigation without redundant explicit link targets.
Every workflow declares its GITHUB_TOKEN permissions. The verification
workflows (ci.yml, ci-lean.yml, ci-coverage.yml, ci-benchmarks.yml,
ci-fuzz.yml, ci-miri.yml, ci-sanitizer.yml and
ci-decompressor-heavy.yml) set contents: read at workflow level: they only
check out code, use the Actions cache and upload artifacts, none of which
needs a broader token. The ClusterFuzzLite workflows are read-only too; only
release.yml and the hotpath workflows add more, per job (an OIDC
id-token: write, and actions/pull-requests: read for the relay).
The semver job compares the default mbrotli API with the latest published
release. Experimental APIs and the development-only C FFI crate are outside
that gate. A violation requires a sufficient package version bump.
Release publishing
release.yml runs only on manual dispatch and refuses any ref that is not a
v* tag. It checks that the tag equals v plus the mbrotli package version,
then exchanges the job’s GitHub OIDC token for a short-lived crates.io token
with rust-lang/crates-io-auth-action and runs cargo publish --package mbrotli, which builds the packaged crate before uploading it. No registry
token is stored in the repository. The job runs in the release environment,
which must match the trusted publisher configured on crates.io. The
development-only mbrotli-ffi and google-brotli-ffi crates are not published.
sequenceDiagram
actor Maintainer
participant GH as release.yml
participant CIO as crates.io
Maintainer->>GH: workflow_dispatch on a v* tag
GH->>GH: fail unless ref is a v* tag
GH->>GH: fail unless tag == v + mbrotli version
GH->>CIO: OIDC token (id-token: write)
CIO-->>GH: short-lived publish token
GH->>CIO: cargo publish --package mbrotli
Miri and AddressSanitizer
Both tools run scripts/check_simd_uninit.py, which owns the list of unit-test
modules (every module with a SIMD kernel, a backend dispatch or a
MaybeUninit destination), integration binaries and the uninit doc tests.
It builds once, lists each binary’s tests with --list and its ignored ones
with --list --ignored, and keeps the runnable names that start with a listed
prefix. A prefix with no runnable test fails the run: cargo test itself
passes when a filter matches nothing, and a substring filter such as
compressor::core::stream would also match decompressor::core::stream.
--shard K/N deals the selected tests round-robin and keeps every N-th; shard
1 also runs the doc tests. The tests in the script’s SLOW list, which take
Miri minutes each, are dealt first, so no shard draws two of the slowest, and
each runs in a process of its own, started first. The other tests run in
batches of up to --batch (4) per process, passed as --exact names, since
starting cargo miri costs seconds per process; a batch takes every n-th
test of its binary, so one module’s tests spread across batches. Each
selected test must report ok in libtest’s output. The script prints a
per-prefix summary, the five slowest processes and the slowest tests by
libtest’s --report-time clock, which under Miri counts interpreted work
rather than wall time.
Miri reports runtime-detected CPU features as absent, so it interprets only the
SIMD levels compiled in. The x86-64 Miri jobs set
RUSTFLAGS=-Ctarget-cpu=x86-64-v3, which makes Backend::available() list
SSE2, SSE4.2 and AVX2; native is not used, because Miri has no shims for the
AVX-512 intrinsics and the hosted runner pool mixes AVX2 and AVX-512 machines.
On ARM64, Neon is part of the baseline. The script copies RUSTFLAGS into an
unset RUSTDOCFLAGS, since a doc test built without the library’s target
features calls its kernels without them, which Miri reports as undefined
behavior. It also sets -Zmiri-deterministic-floats, without which Miri
perturbs log2 and exact cost comparisons fail. Miri lacks shims for the Neon vsliq_n_u8 and
vminq_f64 intrinsics behind fearless_simd’s mask8x32::to_bitmask and
f64x2::min; tests reaching them, and three doc tests the script skips by
name, do not run under Miri on ARM64. Tests that call the C oracle, read files,
or are scalar-only and slow carry cfg_attr(miri, ignore); differential SIMD
tests shrink their inputs under cfg!(miri) instead. Each quality 10 or 11
stream spends most of a minute in histogram clustering under Miri, so only
hq::encoder’s backend comparison and the hq kernel tests encode at those
qualities there. tests/uninit_output.rs reads back only reported prefixes of
destinations that start uninitialized, so Miri checks that every reported byte
was written. Shards are sized to finish within ten minutes, under a 15-minute
job timeout.
AddressSanitizer runs the same list natively in release mode, with real feature
detection, which reaches the Neon paths Miri cannot. Its integration list also
includes the C-oracle tests encode_uninit and decode_uninit. The older
ASan job keeps its integration-test list.
flowchart TD
Tag[v* tag or manual] --> Miri[ci-miri.yml]
Tag --> Asan[ci-sanitizer.yml]
Miri --> MX86[4 x86-64 shards, target-cpu=x86-64-v3]
Miri --> MArm[4 ARM64 shards, Neon baseline]
Miri --> Exp[experimental goldens and wire tests]
Asan --> AX86[x86-64 runner]
Asan --> AArm[ARM64 runner]
Asan --> Legacy[integration-test list]
MX86 --> Script[scripts/check_simd_uninit.py]
MArm --> Script
AX86 --> Script
AArm --> Script
Script --> List[list tests and ignored tests, select runnable by prefix]
List --> Empty{every prefix has a runnable test?}
Empty -->|no| Fail[fail]
Empty -->|yes| Deal[deal SLOW tests, then the rest, round-robin]
Deal --> Shard[keep every N-th test for shard K]
Shard --> Solo[each SLOW test in its own process, started first]
Shard --> Batch[other tests in batches of up to 4 per process]
Solo --> Report[per-prefix pass count, slowest processes and tests]
Batch --> Report
AFL execution and artifacts
AFL jobs cache Cargo downloads/tools but disable target caching: instrumented
builds can use CPU-specific instructions. Each job force-installs the pinned
cargo-afl and runs cargo afl config --build --force to restore its sources
and runtime for the current compiler.
Campaign runners configure direct core dumps before starting AFL. Target-specific
seed selection uses generated corpora or committed lifecycle, framing and parallel
regressions. Base decoder targets run in both feature profiles; custom targets
require experimental.
The experimental matrix includes framed_decode, framed_roundtrip and framed_encode, each
using its committed regressions/TARGET seeds. The arbitrary-byte framed decoder
also uses dictionaries/framed.dict. Campaigns run for ten minutes, with a
five-second per-input timeout for decoder targets and a one-second default for
other targets.
flowchart LR
Matrix[experimental framed targets] --> Seeds[committed regression seeds]
Seeds --> Parser[framed_decode with framing dictionary]
Seeds --> Roundtrip[framed_roundtrip]
Seeds --> Encode[framed_encode: native and I/O schedule agreement]
Parser --> Campaign[ten-minute AFL campaign]
Roundtrip --> Campaign
Encode --> Campaign
Campaign --> Check[fail on saved crashes or hangs]
Campaign --> Archive[upload archived findings even on failure]
Saved crashes or hangs fail the job. Findings are archived as tar.gz and uploaded when present, including after failures; this preserves AFL filenames with colons. Regression replay does not start a campaign or require host core-dump configuration.
ClusterFuzzLite
Both ClusterFuzzLite workflows build .clusterfuzzlite/Dockerfile with
google/clusterfuzzlite/actions/build_fuzzers@v1 and run the binaries that
build.sh produces from fuzz/libfuzzer (see
fuzzing). The actions do
their own checkout, so the workflows have no checkout step and build.sh
fetches the vendored Brotli submodule itself. Runs use libFuzzer with
AddressSanitizer, the only engine and sanitizer ClusterFuzzLite supports for
Rust, and the coverage job builds with the coverage sanitizer. All jobs use
read-only permissions; corpora, crashes and coverage reports are kept as
workflow artifacts, ClusterFuzzLite’s default storage on GitHub.
sequenceDiagram
participant PR as pull request
participant Cron as daily schedule
participant CF as ClusterFuzzLite actions
participant Store as workflow artifacts
PR->>CF: build (address), run code-change, 600 s
Store-->>CF: latest corpus and coverage report
CF-->>PR: job fails on a crash, with the reproducer
Cron->>CF: batch: build (address), fuzz 17400 s on all cores
CF->>Store: grown corpus, crashes
Cron->>CF: prune: minimize corpus, 14400 s
CF->>Store: pruned corpus
Cron->>CF: coverage: build (coverage), report
CF->>Store: coverage report
ci-cflite-pr.yml runs only when a pull request touches src/,
brotli-ffi/, mbrotli-ffi/, fuzz/, Cargo.toml, .clusterfuzzlite/,
.dockerignore or the workflow itself, and a newer push cancels the run in
progress. Code-change mode runs only the targets whose coverage reaches the
changed lines once a coverage report exists, and every target before that.
The 600 seconds are split among the targets it runs.
Batch fuzzing gets 17400 seconds (4 h 50 min) with parallel-fuzzing, which
runs one libFuzzer process per runner core for each target; the targets still
take turns, 696 seconds each. GitHub-hosted jobs stop at six hours including
the image and fuzzer builds, which the job’s timeout-minutes: 360 mirrors.
Outside its share, each target also spends about 45 seconds downloading its
corpus, reproducing any crash and uploading. With 25 targets and a 28-minute
build, the earlier 19800 seconds overran the limit by two minutes in run
41189734130, and the runs before it finished with under two minutes to spare.
17400 seconds totals about 5 h 36 min, which leaves room for a slower build
and a few more targets.
Pruning gets 14400 seconds. ClusterFuzzLite visits the targets in a shuffled
order and gives each one the remaining budget divided by the targets still to
run, as the timeout of that target’s corpus merge; time a fast merge leaves
unused passes to the later targets. The first timeout fails the whole job.
600 seconds left 24 seconds per target, not enough to merge c_abi’s 5035
inputs after the first batch run. 7200 seconds left about five minutes, and
parallel, whose merge runs at about one input per second under
AddressSanitizer, timed out after 333 seconds on 539 inputs when it came early
in the order; it needs about 320 seconds for 600 inputs. 14400 seconds gives
each target about ten minutes. The job takes only as long as the merges do,
about 50 minutes, so the larger budget costs nothing unless a merge needs it.
In ci-cflite-cron.yml the jobs are chained: pruning follows batch fuzzing
(ClusterFuzzLite requires pruning whenever batch fuzzing runs), and coverage
follows pruning, so the report reflects the pruned corpus. A failed batch job
still lets pruning and coverage run.
.dockerignore keeps target directories, local AFL seeds and findings, and
Lean’s verification/lean/.lake package cache out of the image, but keeps
.git, which the submodule fetch needs.
Coverage and limitations
Coverage cleans workspace profiles first, then accumulates a std workspace run
with explicit experimental, diagnostics and profiling features plus a no_std
unit/API run. Both use test optimization level 1 and CARGO_INCREMENTAL=1 to keep
coverage code generation consistent. The report enforces 100% function coverage;
HTML rendering and upload run even if tests or the gate fail.
The only scheduled workflow is ci-cflite-cron.yml, and publishing never
starts on its own: a pushed tag runs checks only. Each manual dispatch starts only its selected
workflow. Apart from ten minutes of ClusterFuzzLite fuzzing on pull requests
that touch code, ordinary branches do not run heavy tools; coverage and
benchmarks always require explicit dispatch. Bounded fuzz campaigns and shared-runner timings
provide limited evidence, not exhaustive verification or stable speed guarantees.
Documentation publishing
The repository publishes a static mdBook through GitHub Pages. The book
contains every Markdown page and associated asset in docs/ and architecture/.
These directories remain the canonical sources; generated book sources, Mermaid
assets, consumer examples, and HTML are local artifacts under ignored target/.
Modules and ownership
graph TD
Guides[docs: user guides, examples, benchmark data] --> Prepare[scripts/docs.py: prepare]
Specs[architecture: specifications and index] --> Prepare
Index[architecture/README.md: architecture navigation and section pages] --> Prepare
Prepare --> Source[target/book-src: generated Markdown and assets]
Mermaid[mdbook-mermaid 0.17.1: bundled JS] --> Assets[target/book-assets]
Config[book.toml, book-theme/custom.css and diagram-zoom.js] --> Build[mdbook 0.5.4]
Source --> Build
Assets --> Build
Build --> HTML[target/book: static HTML and search]
HTML --> Check[Python local-link and anchor validation]
Check --> Pages[GitHub Pages artifact]
The root book.toml owns renderer, theme, search, folding, output path, and
Mermaid preprocessor settings. book-theme/custom.css overrides the palette of
mdBook’s light and navy themes and styles tables, the sidebar and the
landing page. book-theme/diagram-zoom.js moves a rendered Mermaid SVG into a
modal dialog at its natural size and returns it to its page position on close;
it borrows the node because Mermaid scopes diagram styles by the SVG’s id. Search, keyboard navigation,
theme selection, Rust syntax highlighting, and code copying come from mdBook.
Rust playground execution is disabled because its environment lacks mbrotli.
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 master]
Relative --> Summary[Generate SUMMARY with every Markdown page and the section pages]
GitHub --> Summary
Summary --> Complete{All source pages indexed?}
Complete -->|no| Fail[Fail preparation]
Complete -->|yes| Render[mdBook preprocessors and HTML renderer]
Render --> Validate[Validate href and src paths, HTML IDs and README site URLs]
Validate --> Artifact[Publishable static directory]
The generated SUMMARY.md has five parts: Start here (getting started,
the API chooser in docs/README.md, examples), Guides, Performance and
correctness, How it works, and Contributing. GUIDE_PARTS and
CONTRIBUTING in scripts/docs.py hold the curated order; a tuple nests its
tail under its first page, as the I/O and Sans I/O guides nest under streaming.
Benchmark quality pages nest two levels below the results index. book.toml
folds every nested level, so the sidebar opens with top-level chapters only and
mdBook expands the branch of the current page.
Architecture chapters follow the section headings and specification tables in
architecture/README.md. For each ## section that contains a specification
table, preparation writes a generated landing page
architecture/sections/<slug>.md holding that section’s text, with links
rewritten for its location, and nests the section’s specifications under it.
These are the only published pages without a canonical source file. Unindexed
architecture Markdown fails preparation. New guide pages are appended to
Guides automatically. No source guide or specification is excluded merely
because it has no manually configured navigation entry.
Preparation preserves fenced code. Relative links to files outside the published
directories are checked for existence and rewritten to GitHub blob or tree
URLs. Staging maps README pages to index.md and the introduction to the root
index.md, adjusting relative links and summary entries to their final locations.
CSV, JSON, and SVG assets retain their source directory layout. All generated
HTML local destinations and fragments are checked after rendering. The root
README.md is not published, but it links into the site: the same check
resolves each of its https://m-compressors.github.io/mbrotli/ URLs, with its
fragment, against the built HTML.
The preprocessor’s install command writes bundled Mermaid assets into a small
generated book under target/book-assets/; the real book references those files.
The site needs no runtime CDN, Node package manager, or application server.
Navigation
graph LR
Book[SUMMARY.md] --> Overview[Overview: docs/introduction.md]
Book --> Start[Start here]
Book --> Guides[Guides]
Book --> Evidence[Performance and correctness]
Book --> Internals[How it works]
Book --> Contributing[Contributing]
Start --> StartPages[getting started, API chooser, examples]
Guides --> GuidePages[configuration, memory, decompression, streaming, parallel, frames, dictionaries]
GuidePages --> Nested[folded: I/O and Sans I/O under streaming]
Evidence --> Bench[correctness, benchmark index]
Bench --> Quality[folded: codec indexes, quality pages, run reports]
Internals --> Sections[overview, generated section pages]
Sections --> Specs[folded: specifications from architecture/README.md tables]
Contributing --> Dev[development, benchmarking, fuzz campaigns, website]
Examples and deployment
sequenceDiagram
participant Source as Markdown recipes
participant Consumer as Temporary Cargo consumer
participant CI as Pages build job
participant Deploy as Pages deploy job
Source->>Consumer: Extract every rust fence from the recipe guides
Consumer->>Consumer: Release build with local mbrotli + experimental + 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 master push or manual run on master
Deploy->>Deploy: Configure Pages and deploy with OIDC
else pull request
Deploy->>Deploy: Skip publication
end
Recipes in the guides listed in RECIPES (getting started, configuration,
buffers, decompression, streaming, parallel, frames, Sans I/O
compression/decompression, and the I/O guide) are complete Rust programs. test-examples generates a separate package beneath
target/book-examples/, enables default std codecs plus experimental, 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, master 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/mbrotli/.
Known gaps
- The Pages source setting must be configured to GitHub Actions in repository settings before the first deployment. Adding a workflow alone does not enable it.
servewatches generated files. Restart the command after editing canonical sources so preparation runs again.- The checker validates local destinations and anchors, not external network URLs or Mermaid syntax. Browser QA is needed for rendered diagrams.
- The diagram dialog and theme have no automated test; they were checked in a desktop browser in the light and navy themes.
- Recipe validation covers the guides in
RECIPES, not every historical code fragment in the implementation specifications.
Formal verification
Machine-checked Lean 4 proofs about the Rust implementation. The Rust source of selected items is translated into Lean by Charon and Aeneas; hand-written theorems then prove properties of that translation. Today the proofs cover, on both sides of the codec, the distance prefix codes of RFC 7932 section 4 and RFC 9841, and the insert-and-copy length codes of section 5 with the distance context of section 7.2. This document describes what is verified, how the translation stays tied to the Rust code, what the proofs trust, and what is not covered.
Ownership boundaries
| Path | Owner | Contents |
|---|---|---|
scripts/extract_lean.py | hand-written | Pins the Aeneas release, lists the translated items, regenerates or checks the translation |
verification/lean/Mbrotli/Code/Types.lean, Code/Funs.lean | generated, never edited | Aeneas translation of the listed Rust items |
verification/lean/Mbrotli/Code/FunsExternal.lean | hand-written models | The four standard-library functions the translation calls and the Aeneas library does not model |
verification/lean/Mbrotli/Model.lean | hand-written specification | RFC distance arithmetic over unbounded naturals |
verification/lean/Mbrotli/Rfc.lean | hand-written transcription | The section 4 short codes and initial ring, the section 5 length code tables and cell diagram and the section 7.2 rule, as printed, with consistency checks |
verification/lean/Mbrotli/CommandExtras.lean | hand-written proofs | Packed command-field layout, both table-initialization loops and agreement with every valid RFC symbol |
verification/lean/Mbrotli/*.lean (others) | hand-written proofs | Specifications of the generated functions, the round-trip and uniqueness theorems |
verification/lean/lakefile.lean, lean-toolchain, lake-manifest.json | hand-written pins | Lean 4.35.0 and the Aeneas Lean library at the same commit as the binaries |
The Lean project is not part of the Cargo workspace and nothing in src/ refers
to it: no attribute, feature or dependency was added to the crate. Charon
compiles the ordinary crate with its own nightly toolchain into
target/charon/, so the stable build is untouched.
graph TD
Rust[src: command.rs, shared/distance.rs, shared/format.rs, fast_log.rs, decompressor/core/distance.rs, commands in decompressor/core/stream.rs] -->|Charon: rustc MIR| LLBC[LLBC]
LLBC -->|Aeneas -split-files| Code[Mbrotli/Code/Types.lean, Funs.lean: generated]
Ext[Mbrotli/Code/FunsExternal.lean: std models] --> Code
Script[scripts/extract_lean.py] -.drives and pins.-> LLBC
Script -.->|--check compares| Code
Script -.EXTERNALS must match.-> Ext
AeneasLib[Aeneas Lean library: Result monad, scalar models, step tactic] --> Code
Mathlib[Mathlib] --> Model
Model[Mbrotli/Model.lean: RFC formulas] --> Dec
Model --> Enc
Code --> Basic[Mbrotli/Basic.lean: platform facts, log2 spec]
Basic --> Dec[Mbrotli/DecoderDistance.lean]
Basic --> Enc[Mbrotli/EncoderDistance.lean]
Dec --> RT[Mbrotli/DistanceRoundTrip.lean]
Enc --> RT
RT --> Uniq[Mbrotli/DistanceUniqueness.lean]
RT --> Audit[Mbrotli/Audit.lean: axiom check]
Uniq --> Audit
Rfc[Mbrotli/Rfc.lean: RFC tables] --> Tables[Mbrotli/LengthTables.lean]
Basic --> Tables
Tables --> Cmd[Mbrotli/DecoderCommands.lean]
Tables --> EncL[Mbrotli/EncoderLength.lean]
Cmd --> LRT[Mbrotli/LengthRoundTrip.lean]
EncL --> LRT
LRT --> EncC[Mbrotli/EncoderCommand.lean]
EncL --> Extras[Mbrotli/CommandExtras.lean: packed fields and table loops]
Code --> Extras
Extras --> EncC
Enc --> EncC
EncC --> Audit
LRT --> Audit
Basic --> Ring[Mbrotli/DistanceCache.lean]
Rfc --> Ring
Ring --> Res[Mbrotli/DecoderDistanceResolve.lean]
Dec --> Res
Res --> Audit
Translation
START in the script names the items to translate; Charon follows everything
they call. EXCLUDE drops the decoder methods that allocate (table,
fill_table, which call Vec::try_reserve_exact) and the SIMD-generic
command-extension search in shared::command.
Aeneas runs with -split-files, writing the types to Code/Types.lean and the
functions to Code/Funs.lean. Standard-library functions the Aeneas library
has no model for land in a template of axioms; the script compares their names
with EXTERNALS and fails if the set changes. Today there are four:
u64::try_from(u128), u64::checked_add_signed, Option::ok_or_else and
Option::filter, modelled by hand in Code/FunsExternal.lean after their
documented behaviour. No axiom reaches the proofs.
Aeneas runs with -loops-to-rec. The translated code lives in Aeneas’
Result monad: every +, -, *, shift, cast and index becomes an operation
that fails on overflow, out-of-range shift or out-of-bounds index, exactly as a
debug build panics. wrapping_* calls are modelled as wrapping. A proof that a
function returns ok is therefore a proof that it cannot panic on those inputs.
Aeneas records each item’s source line range; the script strips the ranges and
keeps the file name, so moving code without changing it leaves the
translation unchanged. --check regenerates into a temporary directory and fails on any
difference.
flowchart TD
Start[extract_lean.py] --> Bundle{target/aeneas/RELEASE present?}
Bundle -->|no| Download[download aeneas-OS-ARCH.tar.gz from the pinned release]
Bundle -->|yes| Toolchain
Download --> Toolchain{nightly from bundle rust-toolchain installed?}
Toolchain -->|no| Rustup[rustup toolchain install with rustc-dev, rust-src, llvm-tools, miri]
Toolchain -->|yes| Charon
Rustup --> Charon[charon cargo --preset aeneas --start-from START --exclude EXCLUDE]
Charon --> Aeneas[aeneas -backend lean -loops-to-rec -split-files]
Aeneas --> Ext{template axioms equal EXTERNALS?}
Ext -->|no| Fail[exit: model the new item in FunsExternal.lean]
Ext -->|yes| Strip[strip source line ranges]
Strip --> Mode{--check?}
Mode -->|no| Write[write Code/Types.lean and Code/Funs.lean]
Mode -->|yes| Compare{identical to the committed files?}
Compare -->|no| Stale[exit: translation is stale]
Compare -->|yes| Ok[exit 0]
Specification
Mbrotli/Model.lean restates the arithmetic with unbounded naturals. For
NPOSTFIX = p, NDIRECT = nd and an encoder distance code dc past the
short and direct codes:
dist p nd dc = 2^(p+2) + dc - 16 - nd, the value the encoder splits;nbits,prefixBit,postfixBits,extraandsymbolare the encoder’s split ofdist(the bucket width, the bit below the leading one, the lowpbits, the extra field and the emitted symbol);width p nd symanddecode p nd sym exare the decoder side, the formulas of RFC 7932 section 4 verbatim.
RFC 7932 fixes only the decoder. decode and width are the specification;
the encoder-side definitions describe what this encoder computes and are
justified only by the round-trip theorem below, not by agreement with the C
library.
Pure lemmas over the model:
| Lemma | Statement |
|---|---|
decompose | every d ≥ 2^(p+2) equals (2 + prefixBit)·2^(nbits+p) + extra·2^p + postfixBits, with nbits ≥ 1, prefixBit < 2, extra < 2^nbits, postfixBits < 2^p |
symbol_fields | the decoder’s code >> p & 1, code & (2^p - 1) and 1 + (code >> (p+1)) of an emitted symbol are its prefix bit, postfix bits and width |
decode_symbol | decode p nd (symbol p nd d) (extra p d) + 2^(p+2) = d + nd + 1, and the extra field fits the width the decoder reads |
symbol_lt | d < 2^(n+p+2) keeps the symbol below 16 + nd + 2n·2^p and the width at most n |
split_unique | the converse of decompose: a value written in that form has exactly those fields |
encode_decode | for every symbol past the direct codes and every extra field of its width, re-encoding the decoded value returns the same symbol and field |
decode_injective | two symbols ≥ 16 with extra fields of their widths that decode to the same distance are equal, field included; direct codes decode to at most nd, all others to more |
Function specifications
Each theorem is tagged @[step] and states, in the f args ⦃ r => P r ⦄
form, that the generated function returns ok r (no panic, no overflow) and
that r satisfies P.
| Rust item | Preconditions | Result |
|---|---|---|
shared::fast_log::log2_floor_non_zero | value ≠ 0 | Nat.log 2 value |
shared::command::prefix_encode_copy_distance | p ≤ 3, nd ≤ 120, 16 + nd ≤ dc ≤ 2^31 + 15 | code = 1024·nbits + symbol, extra = Model.extra |
shared::distance::DistanceParams::new | p ≤ 3, nd ≤ 120 | alphabet_size_max = 16 + nd + 48·2^p, max_distance + 2^(p+2) = nd + 2^(26+p) |
DistanceLayout::from_header | header < 64 | postfix = header % 4, direct = header / 4 · 2^(header % 4) |
DistanceLayout::alphabet | postfix ≤ 3, direct ≤ 120 | 16 + direct + (48 or 124)·2^postfix |
DistanceLayout::extra_bits | layout bounds, symbol < 2048 | Model.width |
DistanceLayout::short_window_distance | 16 ≤ symbol < 16 + nd + 48·2^p, extra < 2^width | Model.decode, computed in u64 |
DistanceLayout::long_distance | 16 ≤ symbol < 16 + nd + 124·2^p, extra < 2^width | Model.decode, computed in u128 |
shared::format::INS_BASE, INS_EXTRA, COPY_BASE, COPY_EXTRA (indexing) | i < 24 | the RFC row’s first length or extra-bit count |
decompressor::core::stream::CELLS (indexing) | k < 11 | the RFC cell’s first insert code plus its first copy code divided by eight |
decompressor::core::stream::commands | none | for every symbol s < 704, the entry is Command.Rfc s (see below) |
shared::command::insert_length_code | insertlen ≤ 16799809 | the code whose RFC range holds insertlen |
shared::command::COPY_LENGTH_CODES | none | entry l is the code of length l, for 2 ≤ l < 2118 |
shared::command::copy_length_code | 2 ≤ copylen ≤ 16779333 | the code whose RFC range holds copylen |
shared::command::combine_length_codes | both codes < 24 | combineModel, the RFC cell’s symbol (see below) |
shared::command::length_code | both lengths in range | a symbol below 704 whose RFC codes hold both lengths, implicit exactly when asked and allowed |
shared::command::prefix_encode_copy_distance (spec_direct, spec_any) | dc < 16 + nd; any dc ≤ 2^31 + 15 | the code is dc with no extra bits; the low ten bits are 0 exactly for dc = 0 |
Command::new | lengths in range, copylen < 2^25, -64 ≤ delta ≤ 63, dc ≤ 2^31 + 15 | Command.Lengths: the insert length, copy_len = copylen + deltaField delta · 2^25, and a symbol whose RFC codes hold the insert length and copylen + delta, implicit only for dc = 0 |
Command::copy_len_code | copy_len = L + deltaField d · 2^25, L < 2^25, -64 ≤ d ≤ 63, 0 ≤ L + d | L + d |
shared::command::COMMAND_EXTRAS | none | every symbol below 704 stores the insert/copy bases and widths of its RFC classes |
Command::extra_bits | as above, lengths in range, symbol below 704 and its insert/copy classes contain both lengths | the insert field insertlen - lo in the low INS_EXTRA bits, the copy field K - lo above, width the sum of both extra-bit counts |
Command::distance_context | none | encoderContext, which equals the section 7.2 context of every copy length the symbol can carry |
Command::has_distance | none | a copying command writes a distance symbol exactly when its symbol is not implicit |
Command::insert_only | insertlen ≤ 16799809 | Command.Lengths with no copy and copy code of length 4, never implicit |
Cache::default | none | ring = [4, 11, 15, 16], the RFC’s initial ring, most recent first |
Cache::recent | back < 4 | the back-th most recent distance |
Cache::push | none | ring' = d :: ring.take 3 |
Cache::pop | none | returns the most recent distance; ring' = ring.tail ++ [most recent], so pop then push y gives y :: ring.tail (Cache.pop_push) |
DistanceLayout::short | symbol < 16 | Ok of the RFC value (the listed past distance plus its offset) when it lies in 1..=2^63 - 4, Err(InvalidData(Distance)) otherwise |
DistanceLayout::resolve_large | as long_distance | Ok(Model.decode) when it fits in u64, the distance error otherwise |
DistanceLayout::resolve | symbol in the layout’s alphabet, extra of its width | Ok(rfcDistance): short symbols against the ring, the others by Model.decode; the distance error exactly as short and resolve_large give it |
DistanceLayout::validate_symbol | layout bounds | Ok exactly for ValidSymbol: inside the alphabet and, past the short codes, unable to exceed 2^63 - 4 with any extra field |
All statements hold for both 32- and 64-bit usize: the proofs only use
32 ≤ System.Platform.numBits ≤ 64.
Round-trip theorems
distance_round_trip (standard window) and large_distance_round_trip
(RFC 9841) compose the encoder and decoder specifications:
sequenceDiagram
participant H as header byte (NPOSTFIX, NDIRECT >> NPOSTFIX)
participant E as prefix_encode_copy_distance
participant L as DistanceLayout::from_header
participant D as alphabet / extra_bits / short_window_distance or long_distance
H->>E: NPOSTFIX, NDIRECT (as the encoder's u32 parameters)
Note over E: dc with 16 + NDIRECT ≤ dc and dc - 15 ≤ max_distance
E-->>D: symbol = code & 0x3FF, extra field
H->>L: same six bits
L-->>D: layout
D->>D: symbol < alphabet = DistanceParams::new(..).alphabet_size_max
D->>D: extra_bits(symbol) = code >> 10, the width the encoder wrote
D-->>H: distance + 15 = dc
For every header, every encoder alphabet DistanceParams::new(p, nd) and every
distance code with 16 + nd ≤ dc and dc - 15 ≤ max_distance:
- the encoder returns without panicking;
- the decoder’s alphabet for that header equals the encoder’s
alphabet_size_max, and contains the emitted symbol; - the decoder reads exactly the width the encoder packed into bits 10..15;
short_window_distancereturnsdc - 15, the distance.
The large-window theorem gives the same for every distance up to
MAX_ALLOWED_DISTANCE (2^31 - 4), the 124-bucket alphabet and
long_distance.
Uniqueness theorems
distance_code_unique and large_distance_code_unique take the same
hypotheses as the round-trip theorems and state the converse: for every symbol
s ≥ 16 inside the alphabet and every field shorter than the width
extra_bits(s) returns, if short_window_distance (respectively
long_distance) resolves them to the distance, then s = code & 0x3FF and the
field is the encoder’s extra field.
flowchart TD
Pair[symbol s ≥ 16, field f with f < 2^extra_bits s] --> Kind{s < 16 + NDIRECT?}
Kind -->|direct code| Direct[distance = s - 15 ≤ NDIRECT, f = 0]
Kind -->|bucket code| Bucket[distance > NDIRECT]
Bucket --> Inverse[encode_decode: the encoder's split of the distance is s, f]
Direct --> Same{same distance as another valid pair?}
Inverse --> Same
Same -->|always| Equal[the pairs are equal]
The statements cover explicit distance codes only. The sixteen short codes refer to the decoder’s cache of recent distances, so an encoder may also write a distance as one of them (this one and Google’s C encoder do, on a cache hit). Among explicit codes, any encoder that produces a decodable stream, Google’s included, must write the pair this encoder writes.
Length codes
RFC 7932 section 5 sends an insert length and a copy length as one of 704
insert-and-copy length symbols plus two extra fields. Mbrotli/Rfc.lean
transcribes the two code tables (extra bits and printed range for codes 0 to
23), the cell diagram (the first insert and copy code of each 64-symbol block),
the rule that symbols below 128 reuse the last distance, and the section 7.2
distance context. It checks its own transcription: every printed range holds
2^extra lengths and each range starts where the previous one ends.
flowchart TD
Rfc[Rfc.lean: RFC tables as printed] --> Consistent[ranges are 2^extra wide and contiguous]
Rfc --> Tables[LengthTables: INS_*, COPY_*, CELLS equal the RFC values]
Tables --> Cmd[commands: every entry of the decoder table is the RFC row]
Tables --> Enc[insert_length_code, copy_length_code: the code whose range holds the length]
Rfc --> Comb[combineModel: cell symbol for two codes, checked over all 1152 inputs]
Comb --> CombRust[combine_length_codes computes combineModel]
Enc --> LC[length_code]
CombRust --> LC
LC --> RT[length_round_trip]
Cmd --> RT
LC --> U[length_code_unique]
Comb --> U
Command.Rfc s e says the decoder’s entry e for symbol s holds the insert
and copy codes the cell diagram gives s, their first lengths and extra-bit
counts, implicit = (s < 128), and, for every copy length in the copy code’s
range, the distance context section 7.2 assigns to that length. The decoder
stores the context of the code’s first length; distanceContext_of_code shows
that this is the context of every length of the code, because codes starting at
four or below have no extra bits.
combineModel restates the encoder’s bit arithmetic, including the constant
0x520D40 that packs the cell order. Over all 24 · 24 · 2 inputs, checked by
decide +kernel, it lands in the RFC cell of its two codes and is implicit
exactly when the caller asked for the last distance and the codes allow it
(insert code below 8, copy code below 16); over all 704 symbols it inverts the
cell diagram, so the diagram is a bijection.
length_round_trip: for every insert length up to 16799809 and copy length
from 2 to 16779333, the decoder’s entry for the encoder’s symbol has
insert_base ≤ insertlen < insert_base + 2^insert_extra, the same for the copy
length, the implicit flag the encoder asked for, and the copy length’s distance
context.
length_code_unique: every symbol whose RFC codes cover the same two lengths
and which agrees on reusing the last distance is the encoder’s symbol, so any
encoder emitting these lengths with this distance choice emits the same symbol.
Short codes and the ring of last distances
Mbrotli/Rfc.lean transcribes section 4’s list of the sixteen short symbols
(which past distance each refers to and the offset added to it) and the ring
the stream starts with, 4, 11, 15, 16 most recent first. Cache.ring reads
the decoder’s four slots off in that order: the k-th most recent distance is
slot (index + 3 - k) mod 4, which Cache::recent computes as
(index - 1 - k) & 3 with a wrapping subtraction, equal because the width of
usize is a multiple of four.
flowchart TD
S[symbol, extra, ring] --> Short{symbol < 16?}
Short -->|yes| Ring[ring position and offset from the RFC list]
Ring --> Range{0 < value ≤ 2^63 - 4?}
Range -->|yes| OkS[Ok value]
Range -->|no| ErrS[Err InvalidData Distance]
Short -->|no| Large{large window?}
Large -->|no| Std[short_window_distance: Model.decode in u64]
Large -->|yes| Wide[long_distance: Model.decode in u128]
Wide --> Fit{fits u64?}
Fit -->|yes| OkL[Ok value]
Fit -->|no| ErrL[Err InvalidData Distance]
Std --> OkD[Ok value]
validated_resolve: for a symbol validate_symbol accepted, past the short
codes, every extra field of its width resolves to Ok(Model.decode). The
distance grows with the extra field (decode_mono), the header checked the
largest one against 2^63 - 4, and that is below u64::MAX, so the
large-window conversion cannot fail, as the comment in resolve claims.
RFC 7932 rejects a short symbol that resolves to zero or a negative value. The
decoder also rejects values above 2^63 - 4; that bound is an implementation
limit, stricter than the RFC, and no window or dictionary reference reaches it.
Commands
Command (in shared/command.rs) stores the copy length in bits 0..24 of
copy_len and a seven-bit signed delta in bits 25..31, the difference between
the length a static-dictionary match copies and the length its symbol encodes.
deltaField d is that seven-bit field. Command::new writes it as
delta as i8 as u8 as u32 shifted by 25; copy_len_code reads it back by
copying bit 6 into bit 7 and reading the byte as i8. Both are checked over all
128 deltas from -64 to 63.
CommandExtras.lean verifies the prefix-indexed table used by extra_bits.
Its entry packs the insert base in bits 0–15, copy base in 16–31, insert width
in 32–36 and total width in 40–45. Finite kernel proofs establish the field
layout against the transcribed RFC rows. Inductive specifications of both
translated initialization loops show that writing explicit and implicit
symbols completes each insert/copy class pair while preserving previous
entries. After all 24 × 24 pairs, every valid symbol below 704 has exactly its
RFC fields. The 1024-entry table also contains padding slots; emitted
commands never rely on their contents.
Command::extra_bits.spec assumes the symbol is valid and its RFC classes
contain the stored insert length and reconstructed copy length. These are
already conclusions of Command::new.spec and Command::insert_only.spec.
The lookup masks the symbol with 1023, which leaves a valid symbol unchanged,
then subtracts the proved bases and packs the two fields. It does not guarantee
length-based behavior for a private Command whose prefix contradicts its
lengths. This explicit invariant replaces the old helper theorem’s broader
claim for arbitrary prefixes; the composed valid-command round-trip statement
retains its original hypotheses and guarantees.
sequenceDiagram
participant N as Command::new
participant C as Command
participant P as COMMAND_EXTRAS[cmd_prefix]
participant X as extra_bits / distance_context
participant T as decoder table commands()[cmd_prefix]
N->>C: insert_len, copy_len = copylen + deltaField(delta) << 25
N->>C: cmd_prefix = length_code(insertlen, copylen + delta, dc = 0)
C->>P: valid symbol with matching RFC length classes
P-->>X: proved insert/copy bases and widths
C->>X: copy_len_code = copylen + delta
X-->>T: n = insert_extra + copy_extra
X-->>T: bits mod 2^insert_extra = insertlen - insert_base
X-->>T: bits >> insert_extra = copylen + delta - copy_base
X-->>T: encoder distance context = entry's distance context
command_round_trip: for every command Command::new builds from lengths and
a delta the format can carry, the decoder’s table entry for its symbol reads the
command’s extra bits back as both lengths: the low insert_extra bits added to
insert_base give the insert length, the bits above them added to copy_base
give copylen + delta, and bits < 2^n. The entry is implicit only for
distance code 0, and the encoder’s distance_context equals the entry’s, the
section 7.2 context of the copy length. Reading the low bits as the insert field
is the RFC’s order on the assumption, not yet proved, that the bit writer emits
a field least significant bit first.
Mbrotli/Audit.lean checks with #guard_msgs that the four distance theorems,
length_round_trip, length_code_unique, commands.spec,
command_round_trip, resolve.spec, validated_resolve, Cache.pop_push and
Cache.default.spec depend only on
propext, Classical.choice and Quot.sound; a sorry, native_decide or
new axiom fails lake build.
Trusted base
A theorem here is a statement about the Rust code only if these hold:
- Charon and Aeneas translate the MIR of the listed items faithfully, including overflow, shift and cast semantics;
- the Aeneas Lean models of the scalar operations used (
leading_zeros, shifts, casts,wrapping_*) match Rust; - rustc’s MIR for those items matches the code the release build runs (the translation is of the nightly MIR Charon sees, without optimizations);
- the models in
Code/FunsExternal.leanofu64::try_from(u128),u64::checked_add_signed,Option::ok_or_elseandOption::filtermatch the standard library; each is a few lines and follows its documentation; - Lean’s kernel and the three standard axioms;
Model.decodeandModel.widthtranscribe RFC 7932 section 4 correctly, andMbrotli/Rfc.leantranscribes the section 4 short codes and initial ring, the section 5 tables and the section 7.2 rule correctly. Both are short enough to read against the RFC; the tables also check their own widths and contiguity.
Finite facts (table contents, the 1152 symbol combinations, the 704-symbol
inverse) are checked with decide +kernel, which the kernel evaluates; no
native_decide is used.
Nothing relies on a hand-written restatement of the Rust code.
A mutation check confirms the coupling: changing - 4 to - 5 in
short_window_distance makes extract_lean.py --check fail, and after
regeneration DistanceLayout.short_window_distance.spec no longer proves.
Changing the last entry of the decoder’s CELLS from 18 to 17 does the same to
the table proof in LengthTables.lean, and widening the sign bit in
copy_len_code with << 2 instead of << 1 breaks Command.copy_len_code.spec,
and starting the ring at 17, 15, 11, 4 instead of 16, 15, 11, 4 breaks
Cache.default.spec.
Checks
python3 scripts/extract_lean.py --check # translation matches the Rust source
cd verification/lean && lake exe cache get && lake build # proofs and axiom audit
ci-lean.yml runs both on every push to master and every pull request. The
proofs check in under a minute once Mathlib’s prebuilt cache is present.
Known gaps
Inside the verified items
Open RFC items are tracked in the coverage checklist; these are the limits of what is already proved.
- The decoder’s command loop, which adds the extra fields to
insert_baseandcopy_base, lives indecompressor/core/stream.rsand is not translated; the proofs cover the table it reads, not the addition. Likewise the constantCOMMANDSiscommands()by its Rust definition, which is not translated. The loop’s slow path,Stage::CommandtoStage::CopyExtra, recomputes the insert and copy codes fromCELLSinline instead of reading the table, so that copy of the cell formula is not covered either. - The
experimentalbranches ofCommand::newandCommand::copy_len_code, which store an absolute length for long RFC 9841 transforms, are outside the default-feature translation. - When the decoder pushes to the ring is decided in the command loop of
decompressor/core/stream.rs, which is not translated: that symbol 0 and static dictionary references are not pushed is not proved, only thatpopthenpushreplaces the most recent distance andpushadds one. - That the bit writer emits each field least significant bit first, which turns
command_round_trip’s split ofbitsinto the RFC’s insert-then-copy order, is the bit-order item of the checklist. - Uniqueness says nothing about when an encoder should prefer a short (cache) code over the explicit one; that choice affects compression only.
- Byte-for-byte agreement with Google’s C library is outside the proofs; it stays with the differential tests and fuzzing.
Coverage checklist
Progress against RFC 7932, ticked when an item’s theorems build and pass the axiom audit. RFC 7932 constrains the format and the decoder; the encoder only has to emit what that decoder accepts and reads back, so match finding, block splitting, clustering and cost models stay out of scope. Levels are ordered by cost; each later level builds on the earlier ones.
graph TD
L1[Level 1: arithmetic and tables] --> Bits[Level 2: bit order]
Bits --> L1H[Level 1 headers: §9.1, §9.2, §6 read through the bit reader]
Bits --> Prefix[Level 2: §3 prefix codes]
Prefix --> CMap[Level 2: §7.3 context map]
L1H --> Ref[Level 3: Lean reference decoder, §10]
CMap --> Ref
Dict[Level 2: §8 dictionary transforms] --> Ref
Ref --> Refine[Level 3: Rust decoder refines the reference]
Level 1: arithmetic and tables
- §4 explicit distance codes: encoder and decoder arithmetic, round trip
and uniqueness in standard and large-window streams
(
DistanceRoundTrip.lean,DistanceUniqueness.lean). - §4 short codes: the sixteen codes’ indices and offsets as the RFC lists
them, rejection of zero and overflowing distances (
DistanceLayout::short,DecoderDistanceResolve.lean). - §4 distance cache: the four-slot ring with its initial
4, 11, 15, 16(most recent first),push,popandrecent(DistanceCache.lean). - §4 full resolution:
DistanceLayout::resolve,resolve_largeandvalidate_symbolagainst the RFC, andvalidated_resolve(DecoderDistanceResolve.lean). - §4 ring update policy: symbol 0 and static dictionary references are not
pushed (the command loop in
decompressor/core/stream.rs). - §4 table-driven split:
DistanceLayout::entryandstandard_tablegivebase + (extra << NPOSTFIX)equal to the RFC distance. - §4 large-window alphabet limit:
DistanceParams::new_largeanddistance_code_limitagainst RFC 9841. - §5 length code tables:
INS_BASE,INS_EXTRA,COPY_BASE,COPY_EXTRAandCELLSequal the RFC’s (LengthTables.lean). - §5 decoder command table: every entry of
commands(), including the §7.2 distance context (DecoderCommands.lean). - §5 encoder length codes:
insert_length_code,copy_length_code,combine_length_codes,length_code; round trip and uniqueness (EncoderLength.lean,LengthRoundTrip.lean). - §5 packed command-extra table, both initializer loops and RFC field layout
(
CommandExtras.lean). - §5 commands:
Command::new,copy_len_code,extra_bits,distance_context,has_distance,insert_onlyandcommand_round_trip(EncoderCommand.lean). - §5 decoder addition of the extra fields in the command loop
(
decompressor/core/stream.rs; needs the cell formula and the additions factored out of the streaming state machine). - §9.1 WBITS: one-, four- and seven-bit forms round-trip, are unique and
reject the reserved value; the RFC 9841 large-window form
(
decompressor/core/header.rswindow, the encoder’s header writer). - §9.2 MNIBBLES and MLEN round-trip; a nibble count above four with a zero
top nibble is rejected (
shared/bitstream.rsencode_mlen,header.rsmetablock). - §6 block switches: block type codes (previous, last + 1, explicit) on
both sides; the 26 block count codes equal the RFC table
(
decompressor/core/block.rs,PREFIX_CODE_RANGESinshared/format.rs). - §7.1 context lookup: the LSB6, MSB6, UTF8 and Signed tables equal the
RFC formulas (
CONTEXT_LUT_*inshared/format.rs,context_lutinstream.rs).
The §9 and §6 decoder functions read through the bit reader, so their decoder-side proofs need the level 2 bit-order lemmas first; the encoder-side arithmetic does not.
Level 2: stateful decoding
- Bit order (section 2): what
shared::bits::BitWriterwrites least significant bit first is what the decoder’sBitsreservoir reads; the reservoir keepscount ≤ 64, andrefillandunreadlose no byte. - §3.2 canonical prefix codes from code lengths.
- §3.4 simple prefix codes with NSYM and tree-select.
- §3.5 complex prefix codes: the code length code order, repeat codes 16
and 17 including accumulated repeats, and the requirement that
32768 >> lengthsums to 32768. - Decoder Huffman tables: the two-level tables in
decompressor/core/huffman.rsdecode like a bit-by-bit canonical walk, and incomplete or oversubscribed codes are rejected. - Encoder Huffman lengths (
shared/huffman.rs): at most 15, Kraft with equality, and their run-length form reads back to them. - §7.3 context maps: RLEMAX zero runs and inverse move-to-front in
decompressor/core/context_map.rsagainst the encoder’s move-to-front inshared/bitstream.rs. - §8 and appendix B: the 121 transforms equal appendix B;
fermentfollows the RFC’s UTF-8 uppercasing; the word address follows NDBITS. The 122 KiB word list itself is better compared with the RFC data by a test.
Level 3: the decoder as a whole
- A Lean reference decoder transcribing the section 10 pseudo-code, compiled and used as a second oracle beside C in the fuzz targets and tests: evidence rather than proof, but independent of the C implementation.
- A refinement proof that the baseline path of the Rust decoder in
decompressor/core/stream.rscomputes the reference decoder’s output for any chunking of the input. SIMD paths, which Aeneas cannot translate, stay with the differential tests, Miri and AddressSanitizer.
Outside RFC coverage, SIMD kernels, the parallel and framed layers, match finding and the rest of the crate are not verified.
Development
Requires Rust 1.89 or later. The workspace includes google-brotli-ffi, which
builds vendored C Brotli for tests and benchmarks and requires a C compiler,
and mbrotli-ffi, the C ABI crate described in
architecture/c-abi.md.
git submodule update --init --recursive
cargo run --example compress
cargo doc --workspace --all-features --no-deps --locked
Workspace checks
The documentation website uses mdBook. For tool installation, local preview, link checks, and executable recipes, see Documentation website.
Run from the repository root:
cargo fmt --all
cargo fmt --all -- --check
cargo clippy --workspace --all-targets --all-features --locked -- -D warnings
cargo test --workspace --all-features --locked
For faster execution of the compression-heavy suite, the test profile can be optimized while retaining debug assertions and overflow checks:
CARGO_PROFILE_TEST_OPT_LEVEL=1 cargo test --workspace --all-features --locked
--all-features activates no_std, which removes std-only adapters and parallel
APIs. Also exercise the std surface and experimental extensions:
cargo test --workspace --locked
cargo test --workspace --features experimental --locked
Coverage
The repository requires every changed Rust function to be exercised and targets 100% function coverage for repository-owned Rust code.
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_PROFILE_TEST_OPT_LEVEL=1 cargo llvm-cov --workspace --features experimental,diagnostics,hotpath-cpu,hotpath-alloc --locked --no-report
CARGO_INCREMENTAL=1 CARGO_PROFILE_TEST_OPT_LEVEL=1 cargo llvm-cov --workspace --all-features --locked --lib --test no_std --no-report
cargo llvm-cov report --workspace --html --fail-under-functions 100
The explicit incremental setting matches CI. Disabling it at optimization level 1 can lose coverage counters for small public APIs during automatic cross-crate inlining, even when tests call them. Clean workspace coverage artifacts before the first run; keep the profiles from both feature configurations for the combined report.
Inspect the report in target/llvm-cov/html/; passing tests alone do not show
which functions executed. Coverage reports are local artifacts.
Fuzzing
The AFL package is excluded from the workspace and has its own checks. After
changes there, or to a public API its targets call, run from fuzz/afl/:
cargo fmt --all -- --check
cargo clippy --all-targets --no-default-features -- -D warnings
cargo clippy --all-targets --no-default-features --features experimental -- -D warnings
cargo afl test --no-default-features
cargo afl test --no-default-features --features experimental
cargo afl test links the AFL runtime and replays committed regressions.
See the AFL guide for installation, corpora, campaigns,
and crash minimization.
The libFuzzer package in fuzz/libfuzzer/ wraps the same target bodies for
ClusterFuzzLite. After changes there, or to a target body it wraps, run from
fuzz/libfuzzer/:
cargo fmt --all -- --check
cargo clippy --all-targets --locked -- -D warnings
cargo clippy --all-targets --locked --features experimental -- -D warnings
The libFuzzer guide covers local sanitizer builds and reproducing ClusterFuzzLite findings.
Formal verification
The distance prefix codes are proved correct in Lean 4 against a translation
of the Rust source. After changing an item listed in START of
scripts/extract_lean.py, or anything it calls, regenerate the translation
and rebuild the proofs:
python3 scripts/extract_lean.py # rewrites verification/lean/Mbrotli/Code/
cd verification/lean
lake exe cache get # once: prebuilt Mathlib
lake build # proofs and axiom audit
The script downloads the pinned Aeneas release into target/aeneas/ and
installs the nightly toolchain Charon needs; Lean comes from
elan. --check only verifies that the
committed translation is current. See
formal verification for what is
proved and what is trusted.
Repository map
| Path | Contents |
|---|---|
src/ | Public codec APIs and private implementations |
tests/ | Public API, compatibility, and integration tests |
examples/ | Runnable usage and profiling examples |
benches/ | Criterion comparisons with C |
benchmarks/comparison/ | Isolated, pinned comparison of five Brotli implementations |
brotli-ffi/ | C bindings, build, and test shims |
fuzz/afl/ | Isolated fuzz package and regression corpus |
fuzz/libfuzzer/ | libFuzzer adapters over the AFL target bodies |
verification/lean/ | Lean 4 translation of selected Rust items and proofs about it |
.clusterfuzzlite/ | ClusterFuzzLite image and build script |
docs/ | User and contributor guides |
architecture/ | Current subsystem specifications and diagrams |
specifications/ | Externally authored source specifications |
Repository rules are in AGENTS.md. Public APIs own ergonomic
configuration and errors; private core modules own algorithms and state.
Changes to these boundaries include corresponding architecture updates.
Vendored upstream files and externally authored specifications are maintained
separately.
Routine CI runs formatting, lint, documentation, packaging, tests, and AFL regression replay; pull requests that touch code also get ten minutes of ClusterFuzzLite fuzzing, which runs daily as well. Separate workflows cover coverage and benchmarks; release tags also trigger Miri, sanitizers, fuzz campaigns and heavy decoder checks. See CI mechanics and benchmark commands.
SIMD kernels and uninitialized-output paths run under Miri and
AddressSanitizer with python3 scripts/check_simd_uninit.py miri and
python3 scripts/check_simd_uninit.py asan; the
contributing guide covers the
-Ctarget-cpu flags that select Miri’s SIMD levels and its known limits.
Codec feature isolation is checked with python3 scripts/check_codec_features.py.
It compiles independent consumers for all codec combinations in std and no_std,
with and without experimental APIs, including imports that must fail. Run
python3 scripts/check_decoder_features.py for prepared/decode-only dictionary
interoperability gates. When disabling defaults, explicitly select each codec
under test alongside std or no_std.
Benchmarks and profiling
The independent implementation comparison
measures mbrotli, Google C Brotli, Rust brotli, simd-brotli, and burli.
Browse the benchmark results index for one page per
compression quality, with medians across all eight datasets, vertical speed/size
bars, and complete timings. The strongest mbrotli comparisons appear first;
the pages explain the ranking and retain every dataset.
The index also links the recorded run reports, raw data, and limitations.
Both codecs use the same documentation layout under docs/benchmarks/:
| Artifact | Encoder | Decoder |
|---|---|---|
| Report | encoder-comparison.md | decoder-comparison.md |
| Measurements | encoder-comparison.csv | decoder-comparison.csv |
| Environment | encoder-comparison-environment.json | decoder-comparison-environment.json |
| Index and quality pages | encoders/README.md, encoders/q0.md–q11.md | decoders/README.md, decoders/q0.md–q11.md |
| Charts | encoders/charts/ | decoders/charts/ |
Each chart directory contains overview.svg. Install plotting support in an
isolated environment if needed:
python3 -m venv /tmp/brotli-plots
/tmp/brotli-plots/bin/python -m pip install matplotlib==3.10.8
Use that environment’s Python for the generation commands below.
Encoder comparison
Run from the repository root:
git submodule update --init --recursive
cargo bench --manifest-path benchmarks/comparison/Cargo.toml --bench implementations --locked -- --test
cargo bench --manifest-path benchmarks/comparison/Cargo.toml --bench implementations --locked -- --save-baseline my-comparison
python3 benchmarks/comparison/report.py --baseline my-comparison --csv /tmp/my-comparison.csv
Use a fresh baseline name for each complete run. The standalone package owns its
lockfile and writes to benchmarks/comparison/target/criterion/. Competitor crates
are dependencies only of this unpublished package. Existing harnesses, their
dependency resolution, and root target/criterion/ results remain independent.
The suite contains 432 cases: eight corpora, q0–q11 for four implementations, and q0–q5 for Burli. Unsupported qualities are omitted, never clamped. Every case uses generic mode, window bits 22, automatic block size, a complete known input, no custom dictionary, and no explicit flushes. Equal numeric quality describes each encoder’s effort policy; it does not guarantee equal output size.
| Implementation | Version | Timed API |
|---|---|---|
| Google C Brotli | 1.2.0, pinned submodule | BrotliEncoderCompress |
| mbrotli | Local checkout | Construct Compressor, then compress |
| Rust brotli | 9.0.0 | BrotliCompress |
| simd-brotli | 10.0.1 | BrotliCompress |
| burli | 0.3.3 | compress_with_options |
This is cold end-to-end compression: configuration, construction, scratch and
destination allocation, encoding, and disposal occur inside each iteration.
The two BrotliCompress helpers include their native 4 KiB I/O adaptation.
No implementation retains workspace. Native C one-shot output rewrites are
included; validation requires decoded equality, not compressed byte identity.
The corpora are empty input, a 44-byte text, vendored alice29.txt, Alice repeated
to 1 MiB, structured 64 KiB binary, deterministic pseudorandom bytes at 64 KiB and
1 MiB, and 1 MiB of repeated a bytes. Construction and C-decoder validation of
all 432 outputs finish before timing starts. Missing vendor data fails the build.
sizes.csv records the validated output lengths.
Defaults are 20 samples, one second of warmup, and three seconds of measurement per case. Criterion options override them. A focused timing run is:
cargo bench --manifest-path benchmarks/comparison/Cargo.toml --bench implementations --locked -- 'alice29/q5/'
Filtered runs still validate the full matrix. The exporter rejects incomplete
timing sweeps and checks input lengths. Export immediately after the matching run:
sizes.csv describes the most recent preflight. Archive that file with the named
baseline for later analysis. CSV columns include mean nanoseconds and 95%
confidence bounds, MiB/s, C time / encoder time, compressed bytes, and
compressed / input. For empty input, throughput is zero and the compressed
fraction is undefined; compare latency.
Rebuild the checked-in encoder pages and charts with Matplotlib 3.10.8:
python3 benchmarks/comparison/quality_docs.py \
--csv docs/benchmarks/encoder-comparison.csv \
--environment docs/benchmarks/encoder-comparison-environment.json \
--report docs/benchmarks/encoder-comparison.md \
--output docs/benchmarks/encoders
python3 benchmarks/comparison/plot.py \
--csv docs/benchmarks/encoder-comparison.csv \
--output docs/benchmarks/encoders/charts \
--subtitle 'AMD Ryzen 9 7950X; 20 samples; CPU 1; 2026-10-30'
quality_docs.py writes the index, twelve quality pages and their detailed
charts. plot.py writes overview.svg, throughput.svg and size.svg into
the same charts directory. All eight corpora contribute to the medians.
The published encoder CSV is enc-2026-10-30-amd, with its
size manifest; the decoder CSV is
dec-2026-10-30-amd, with its
size manifest. Both were recorded on
2026-10-30 using Rust 1.99.1 and GCC 15.2 on an AMD Ryzen 9 7950X Linux VDS,
one process at a time pinned to logical CPU 1. Sampling is 20 samples, 1 s warmup
and 3 s requested measurement per case. No builds, tests, fuzzers or other
benchmarks ran concurrently. Each environment record identifies the measured
uncommitted checkout, lockfiles and frozen executable. Regenerating charts uses
the CSV and does not rerun measurements.
Decoder comparison
The decoder results compare Google C, mbrotli,
Rust brotli and Burli on identical compressed streams, prepared by Google C
at q0–q11, generic mode and window 22, from the same eight inputs as the encoder
comparison: empty, 44-byte tiny text, Alice, cyclic Alice at 1 MiB, structured
binary at 64 KiB, random bytes at 64 KiB and 1 MiB, and repeated a at 1 MiB.
All 384 cases must restore the original bytes before timing starts.
Burli decodes all source qualities. SIMD Brotli is omitted because its decoder
re-exports the Rust brotli-decompressor project. The lockfile resolves 6.0.1
for Rust brotli and 5.0.3 for SIMD Brotli; this comparison measures the former.
cargo bench --manifest-path benchmarks/comparison/Cargo.toml --bench decoders --locked -- --test
cargo bench --manifest-path benchmarks/comparison/Cargo.toml --bench decoders --locked -- --save-baseline my-decoders
python3 benchmarks/comparison/report.py --decoding --baseline my-decoders --csv /tmp/my-decoders.csv
Results live in benchmarks/comparison/target/criterion-decoders/, independent
of encoder and root API measurements. Archive its sizes.csv with each fresh
named baseline. Filtering, for example 'alice29/q5/', still validates the
complete matrix; a full sweep is required for export. Sampling defaults match
the encoder comparison and accept the same Criterion overrides.
| Decoder | Version | Timed API |
|---|---|---|
| Google C | 1.2.0, pinned vendor revision | BrotliDecoderDecompress into a newly allocated, initialized output |
| mbrotli | Local checkout | Construct Decompressor, then decompress |
| Rust brotli | 9.0.0 | BrotliDecompress into a new Vec, native 4 KiB I/O buffers |
| Burli | 0.3.3 | burli::decompress |
This measures cold native APIs, including construction, allocation, decode and
disposal. C requires the known output capacity; the Rust Vec helpers discover
output size while decoding. No decoder retains state between iterations.
Throughput counts restored bytes, not compressed input bytes. In the CSV,
input_bytes is the original corpus length and compressed_bytes is the
identical compressed input size. Quality is the source encoder’s setting.
Empty input has latency but no meaningful throughput or compression fraction.
Rebuild the checked-in overview, twelve quality pages and thirteen SVG charts with Matplotlib 3.10.8:
python3 benchmarks/comparison/decoder_docs.py \
--csv docs/benchmarks/decoder-comparison.csv \
--environment docs/benchmarks/decoder-comparison-environment.json \
--report docs/benchmarks/decoder-comparison.md \
--output docs/benchmarks/decoders
The overview takes the median of eight per-dataset C-time/decoder-time ratios, with equal weight. A separate column computes direct Burli-time/mbrotli-time ratios before taking their median. Empty and tiny inputs have equal weight in the median and use latency panels. Per-quality panels retain all individual mean confidence bounds. Shared compressed sizes are listed alongside restored lengths instead of ranking decoders by size. See the run report for environment and limitations.
Sharded runs
The published encoder and decoder sweeps run serially, one process at a time. For exploratory runs on a host with idle cores, the matrix can be split by corpus. Every implementation of a case must still run under the same recorded conditions. Concurrent shards share caches, memory bandwidth and the package’s turbo budget; their latency and ratios can differ from an otherwise idle run. Record all lanes and concurrent workloads explicitly.
Build the harness once, then run its executable directly so that concurrent
cargo bench invocations do not wait on the build directory lock. The
harness writes to fixed directories under benchmarks/comparison/target/,
separate benchmark IDs land in separate directories, and every shard writes
the same deterministic sizes.csv during its preflight:
cargo bench --manifest-path benchmarks/comparison/Cargo.toml --bench implementations --bench decoders --locked --no-run
cd benchmarks/comparison
enc=$(ls -t target/release/deps/implementations-* | grep -v '\.d$' | head -1)
dec=$(ls -t target/release/deps/decoders-* | grep -v '\.d$' | head -1)
lane() { taskset -c "$1" "$2" --bench --save-baseline "$3" "$4"; }
{ lane 0 "$enc" my-enc '^implementations/cold/(empty|tiny-text)/'; lane 0 "$enc" my-enc '^implementations/cold/(alice29|text-1m)/'; } &
{ lane 2 "$enc" my-enc '^implementations/cold/(binary-64k|random-64k)/'; lane 2 "$enc" my-enc '^implementations/cold/(random-1m|repeated-1m)/'; } &
{ lane 4 "$dec" my-dec '^decoders/cold/(empty|tiny-text)/'; lane 4 "$dec" my-dec '^decoders/cold/(alice29|text-1m)/'; } &
{ lane 6 "$dec" my-dec '^decoders/cold/(binary-64k|random-64k)/'; lane 6 "$dec" my-dec '^decoders/cold/(random-1m|repeated-1m)/'; } &
wait
cd ../..
python3 benchmarks/comparison/report.py --baseline my-enc --csv /tmp/my-enc.csv
python3 benchmarks/comparison/report.py --decoding --baseline my-dec --csv /tmp/my-dec.csv
Choose one logical CPU per physical core (lscpu -e=CPU,CORE). The example
above is an optional exploratory layout, not the published run’s topology.
Use a serial sweep when publishing comparisons between libraries, and do not
mix measurements from different host-load conditions into one named baseline.
The root API suites remain independent and should also record their exact
affinity, concurrency and API mode.
API benchmarks
Run these harnesses to compare specific API modes:
cargo bench --bench compress --locked
cargo bench --bench decompress --locked
cargo bench --bench parallel --locked
cargo bench --bench track_b --features experimental --locked
compress covers cold, reused, presized, tiny, streaming, flush, dictionary,
and universal cases against a matched C streaming oracle. parallel covers
task counts and source adapters. track_b covers experimental formats.
Their contracts differ from the cold native one-shot comparison. The
per-case 95%-of-C gate and its Python tests remain available:
cargo bench --bench compress --locked -- --save-baseline candidate
python3 scripts/compare_benchmarks.py --baseline candidate --expected-cases 682 --csv /tmp/candidate.csv
python3 -m unittest discover -s scripts -p 'test_*.py'
Checks and profiling
The standalone package needs its own checks:
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
python3 -m unittest discover -s benchmarks/comparison -p 'test_*.py'
cargo llvm-cov --manifest-path benchmarks/comparison/Cargo.toml --release --locked --benches -- --test
Keep instrumented profiling separate from timing:
cargo run --release --features hotpath-cpu --example profile_compressor -- brotli-ffi/vendor/brotli/tests/testdata/alice29.txt
cargo run --release --features hotpath-alloc --example profile_compressor -- brotli-ffi/vendor/brotli/tests/testdata/alice29.txt
On x86_64 Linux, hotpath-callgrind adds per-function instruction counts from
Valgrind Callgrind, which are reproducible across runs of one commit (at most
0.05% apart). The hotpath benchmark workflow runs in merged mode on every
pull request: one build runs natively for timing and allocations, then under
Callgrind, which merges its instruction counts into the native report and
uploads it to hotpath Cloud, judged by hotpath/policy.toml. Counts are
judged, timing is shown but not judged; compare counts only within one
Valgrind version, Rust toolchain and runner image:
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 brotli-ffi/vendor/brotli/tests/testdata/alice29.txt
HOTPATH_OUTPUT_FORMAT=json HOTPATH_MERGE_REPORT=native.json HOTPATH_OUTPUT_PATH=merged.json \
valgrind -q --tool=callgrind --instr-atstart=no \
target/release/examples/profile_compressor brotli-ffi/vendor/brotli/tests/testdata/alice29.txt
Criterion benchmarks that import from hotpath::criterion (currently
benches/compress.rs and benches/decompress.rs) can produce the same reports. With
HOTPATH_CRITERION_ITERATIONS set, one benchmark id runs exactly that many
times in Criterion’s test mode and writes one hotpath report; without it,
cargo bench is unchanged:
HOTPATH_CRITERION_ITERATIONS=1000 cargo bench --bench decompress --features hotpath-alloc \
-- --test --exact decompress/cold/q11/mbrotli/vendor-alice29.txt
The hotpath criterion workflow runs the ids listed in its matrix this way,
in merged mode, as one hotpath Cloud series each. Adding an id takes one matrix
entry (series, bench target, Criterion id and iteration count) and one entry in the criterion
matrix of hotpath-relay.yml.
Record revision, lockfile, compilers, CPU, target flags, affinity, corpus identity, settings, API mode, sizes, and timing intervals. Generated Criterion and coverage files are local artifacts. Short runs on shared hosts provide exploratory evidence, not universal performance claims.
Fuzz campaign records
Completed AFL++ campaigns, newest first. Each record states the revision, host, duration and outcome of one bounded run. They are historical evidence for compatibility and validation, not a guarantee for later revisions. To run a campaign yourself, follow the AFL guide; fuzzing mechanics describes the targets and oracles.
Interrupted AFL campaign — 2026-10-09
After the distance-cache prefilter (q7–q11), the u8x32 dictionary compare and
the UTF-8 early exit (q10/q11), fresh builds of the uncommitted working tree on
v0.5.6 ran campaign.sh with CAMPAIGN_PARALLEL=1 (24 stable and 26
experimental compression-side targets) and decoder-campaign.sh (7 stable and
8 experimental decoder targets): 65 AFL++ workers on the i7-13700KF/WSL2
host’s 24 threads, fixed 30 s and 5 s timeouts. The run was planned for 4.5
hours and stopped by hand after 1 h 40 min (5,988–6,066 s per worker) to
free the host for benchmarks.
| Group | Workers | Executions |
|---|---|---|
| Encoder, stable | 24 | 9,825,192 |
| Encoder, experimental | 26 | 13,365,353 |
| Decoder, stable | 7 | 59,577,320 |
| Decoder, experimental | 8 | 106,854,256 |
| Total | 65 | 189,622,121 |
No crashes, hangs or correctness-oracle failures were recorded; stability
stayed at or above 99.39%. The changed boundaries are slow under
instrumentation on this oversubscribed host: q11_roundtrip and
q10_roundtrip reached 10,067–14,330 executions, encoder_session 16,578, so
this run is shallow for q10/q11. The committed regression corpus replayed
cleanly through cargo afl test in both builds before the campaign.
Four-hour AFL campaign — 2026-10-01
After the greedy-encoder changes of 2026-10-01 (slice-fed range stores for the
deep q7–q9 bucket shapes, a flat position and tag index for q5–q6, slice-fed
literal splitting in the meta-block builder), fresh builds of the working tree
ran for 4 hours: campaign.sh with CAMPAIGN_PARALLEL=1 (24 stable and 26
experimental compression-side targets) and decoder-campaign.sh (7 stable and
8 experimental decoder targets), 65 AFL++ workers on the
i7-13700KF/WSL2 host’s 24 threads, with the scripts’ fixed 30 s and 5 s
timeouts. Every worker ran its full 14,400 s.
| Group | Workers | Executions |
|---|---|---|
| Encoder, stable | 24 | 29,978,741 |
| Encoder, experimental | 26 | 28,474,966 |
| Decoder, stable | 7 | 148,112,839 |
| Decoder, experimental | 8 | 179,724,677 |
| Total | 65 | 386,291,223 |
No crashes or correctness-oracle failures were recorded; stability stayed at or
above 99.39%. encoder_session (stable) saved five hangs, none of them a loop
or touching the changed code. Four are quality 11 over 128 KiB in 4 KiB chunks:
one finishes in 12.7 s of CPU time standalone under instrumentation, 35.5 s of
wall time on the oversubscribed host, above the 30 s timeout. The fifth is the
quality 10, one-byte-chunk case of the 2026-09-27 campaign: 125 s of CPU time
standalone, again search cost scaling with payload length. Both inputs exit
cleanly. The committed regression corpus replayed cleanly through
cargo afl test in both builds before the campaign.
Decoder command-loop smoke campaign — 2026-09-30
After the command-loop changes of 2026-09-30 (slice-cursor refill,
per-block-type distance tables, snapshot copies tried before the exact-extent
test, one combined copy-limit test, no per-write ring-end test for linear
history), decoder-campaign.sh ran every decoder target of both builds for
20 minutes with its fixed 5 s timeout: 15 target/build pairs, 22,352,094
stable and 29,512,959 experimental executions (51,865,053 in total). No
crashes or hangs were recorded; stability stayed at or above 99.39%. The
committed regression corpus replays cleanly through cargo afl test in both
builds.
Eight-hour AFL campaign and follow-up — 2026-09-27/28
After uninitialized output landed (17b3d315), every fuzz target of both
feature builds ran for 8 hours on 12 threads of the i7-13700KF/WSL2 host:
66 target/build pairs (30 stable, 36 experimental, compression and
decompression) scheduled through twelve slots. The 14 pairs whose oracles
reach uninitialized output (encoder_session, decoder_session,
streaming_equivalence, output_capacity, decompress, decode_streaming,
c_abi) ran 2 hours each and the others 75 minutes, with the fixed timeouts of
campaign.sh (30 s) and decoder-campaign.sh (5 s).
| Build | Pairs | Executions |
|---|---|---|
| Stable | 30 | 103,259,280 |
| Experimental | 36 | 170,088,423 |
| Total | 66 | 273,347,703 |
No crashes or correctness-oracle failures were recorded; stability stayed at or
above 99.38%. encoder_session saved one hang per build. Both inputs finish
in about 120 s standalone under instrumentation, and the time scales linearly
with payload length (14, 31, 60 and 119 s for an eighth, a quarter, a half and
all of it): quality 10 with one-byte chunks and a flush every three blocks,
over roughly ten compressions of a 125 KiB payload. That is search cost, not a
loop. AFL++ 4.40 ended four workers at start-up with an expired -V limit
(reporting “total runtime infty days”); each was rerun for its full slot.
Performance fixes to the decoder and the C ABI followed, and the twelve
decoder and C ABI target/build pairs they touch ran for 30 minutes more on
12 threads (36,517,398 executions). decompress saved nine crashes, all one
bug that predates uninitialized output: a session growing its history ring for
the whole of a long literal insert failed a workspace limit that
decompress_to_slice into the same buffer never meets. The minimized input is
fuzz/afl/regressions/decompress/crash-long-insert-ring-growth.bin and the
a_long_insert_grows_the_ring_only_for_output_the_call_can_hold test; both
failed before the fix, and all nine crashes pass after it. On the final tree,
decompress, decoder_session, decode_streaming and c_abi in both builds,
decode_io_limits and decode_lifecycle in the stable build, and
framed_decode and decode_serialized in the experimental one ran
15 minutes more (25,065,107 executions) with no crashes or hangs.
Six-hour AFL campaign — 2026-09-26
After the decoder changes of 2026-09-26 (unmasked refill, literal tail runs,
linear slice history), every fuzz target ran for 6 hours from fresh builds of
the working tree: campaign.sh with CAMPAIGN_PARALLEL=1 (22 stable and 24
experimental encoder-side targets), decoder-campaign.sh (6 stable and 7
experimental decoder targets) and the three framed targets that neither script
covers (framed_decode, framed_encode, framed_roundtrip, experimental build),
for 62 AFL++ workers on the i7-13700KF/WSL2 host’s 24 threads.
| Group | Workers | Executions |
|---|---|---|
| Encoder, stable | 22 | 43,668,477 |
| Encoder, experimental | 24 | 48,598,336 |
| Decoder, stable | 6 | 204,316,217 |
| Decoder, experimental | 7 | 327,495,724 |
| Framed, experimental | 3 | 6,915,108 |
| Total | 62 | 630,993,862 |
No crashes or correctness-oracle failures were recorded; stability stayed at or
above 99.38%. The decompress target now also checks that
decompress_to_slice matches ring-backed session delivery, byte for byte.
framed_decode saved two hangs at its 5 s timeout. Both inputs finish in 0.57 s
standalone under instrumentation, so the timeouts came from oversubscription,
not an unbounded loop. Their time goes to framing::core::Engine::framing_bytes:
it recomputes workspace accounting over every chunk and metadata record on
each budget check, which is quadratic in the number of records. This campaign
found that slow path in the experimental framed decoder; it is open, and it is
not a decoding error.
Eight-hour AFL campaign — 2026-09-11
The recorded campaign exercised compression and decompression in stable and experimental builds for 8 hours, using 59 AFL++ workers on an Intel Core i7-13700KF/WSL2 host. Across 914,882,807 executions, no crashes or correctness-oracle failures were recorded. Checks included C compatibility, round trips, API/chunking/backend equivalence, dictionaries, limits and lifecycle.
The run found one allocation-related slowdown in large_window (19 saved hangs).
It was fixed and covered by hq_forest_allocation.
Both affected feature builds were then fuzzed for 45 minutes each, starting
with the saved inputs: 18,400 further executions, no crashes or hangs.
This is a completed validation record, not a guarantee for all possible inputs
or later revisions.
Documentation website
The mbrotli book is built with
mdBook and published through GitHub Pages.
All pages in docs/ and architecture/ are included, along with their charts,
CSV measurements, and environment records. Edit those files directly: there is
no second maintained copy of the documentation.
Build locally
Requires Python 3.10+ and the pinned book tools:
cargo install mdbook --version 0.5.4 --locked
cargo install mdbook-mermaid --version 0.17.1 --locked
python3 scripts/docs.py build
Open target/book/index.html, or serve the book with:
python3 scripts/docs.py serve
The server listens on http://127.0.0.1:3000. Stop and rerun it after editing
source Markdown: the server watches the generated source tree rather than the
canonical directories. No Node.js installation or runtime CDN is needed.
Mermaid assets come from the pinned preprocessor and are served with the book.
How source files become pages
scripts/docs.py copies docs/ and architecture/ into ignored
target/book-src/, creates a table of contents, extracts Mermaid’s bundled
JavaScript into target/book-assets/, then invokes mdbook build using the root
book.toml. The output goes to target/book/. In the staged copy, README pages
are renamed to index.md and the introductory page becomes the root index.md;
their links are rewritten to match.
The sidebar has five parts in reading order: Start here, Guides,
Performance and correctness, How it works, and Contributing.
GUIDE_PARTS and CONTRIBUTING in scripts/docs.py hold the curated order.
Nested chapters, such as the per-quality benchmark pages, stay folded until
their parent is opened.
Architecture entries follow the tables in architecture/README.md. Each
## section there becomes a generated landing page under
architecture/sections/, with its specifications nested beneath it. Every
source Markdown file must appear in the table of contents: a missing
architecture entry fails preparation. A new guide page is included
automatically at the end of Guides until it receives a curated position.
Relative links between published pages stay local. Links to source files outside
the published directories point to GitHub’s master branch. Code fences are
preserved. The staged index pages become index.html pages. The build checks
local files and heading anchors after rendering,
including HTML links, images, and downloaded benchmark data. It also resolves
every link from the root README.md into the published site, so renaming a
guide without updating the README fails the build.
Theme and diagrams
book-theme/custom.css sets the palette for the default light theme and the
navy dark theme, and styles tables, the sidebar, and the landing page
(docs/introduction.md). book-theme/diagram-zoom.js opens a Mermaid diagram
at its natural size when it is clicked or activated from the keyboard; Close
or Esc returns it to the page. Both files are plain CSS and
JavaScript with no build step.
Check the examples
python3 scripts/docs.py test-examples
This compiles and runs each Rust program in the guides listed in RECIPES in
scripts/docs.py against the local crate with std and experimental features,
plus Rayon. A fenced rust block in those guides must be a complete program. Each program checks its decoded result. Temporary consumer source and
build products remain under target/book-examples/.
Publish with GitHub Pages
In the repository settings, select Pages → Build and deployment → Source →
GitHub Actions once. .github/workflows/pages.yml then:
- Builds and validates the book on pull requests and pushes to
master. - Compiles and runs the documented recipes.
- Uploads the static book as a Pages artifact.
- Deploys only on
masterpushes or a manual run onmaster.
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 /mbrotli/, and
relative asset links support that prefix and local previews.
See documentation architecture for the source/build boundaries and the CI specification.