Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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.

QualityCompressed sizeCompressDecompress
037.0%549 MB/s585 MB/s
232.1%186 MB/s703 MB/s
528.1%78.2 MB/s788 MB/s
827.0%21.8 MB/s862 MB/s
1123.8%1.23 MB/s713 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

What you can rely on

  • Safe implementation, not a C wrapper. Production code forbids unsafe; SIMD goes through fearless_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

Choose your API

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

Both codecs

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

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 shapeEntry point
Split one known input across your threadsprepare_slice / prepare_source → ScopedParallelBatch
Stream chunked input across your threadsstart → ParallelEncoderSession + ParallelTaskQueue
Same, with the session owning the compressorinto_session → ParallelEncoderSessionOwned
Pull parallel output through std::io::Readreader → ParallelEncoderReader + ParallelTaskQueue
Push input through std::io::Write across your threadswriter → ParallelEncoderWriter + ParallelTaskQueue

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

Guides by task

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

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

Examples

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

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

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(())
}
QualityEncoderSearch
0–1Fast encoderFragment-based fast paths with direct entropy coding
2–9Greedy encoderHash-table match search with match scoring
10–11High-quality encoderTree 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:

SettingTypeBehavior
QualityQuality0–11; default 11
WindowWindowWindow::standard(bits) for 10–24 bits; Window::large(bits) for 10–62 bits
Input block sizeBlockSize / BlockBitsAutomatic or explicit 16–24 bits
Data modeCompressionModeGeneric, text, or font
Distance codingDistanceParamsValidated postfix bits and direct distance groups
Literal contextsLiteralContextModeControls 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 capabilityAvailability
Standard Brotli (RFC 7932)Qualities 0–11
Large Window BrotliQualities 3–11
Prepared LZ77 prefix dictionariesQualities 5–11
Serialized dictionaries and custom static dictionary encodingexperimental; qualities 5–11
Headerless stream continuationsexperimental; qualities 2–11
Shared Brotli framed compressor, structured input and native sessionscompression,experimental; std or alloc
Framed encoder writer/reader adapterscompression,experimental; not available with no_std
Framed decoder, structured output and native eventsdecompression,experimental; std or alloc
Framed decoder event readerdecompression,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.

ConfigurationDependency
Default std encoder + decodermbrotli = "0.5.21"
Encoder onlymbrotli = { version = "0.5.21", default-features = false, features = ["std", "compression"] }
Decoder onlymbrotli = { version = "0.5.21", default-features = false, features = ["std", "decompression"] }
Framing and experimental formatsmbrotli = { version = "0.5.21", features = ["experimental"] }
Alloc-backed codecs without stdmbrotli = { version = "0.5.21", default-features = false, features = ["no_std", "compression", "decompression"] }
FeatureSurface
stdRead / Write adapters and parallel compression
compression, decompressionOne codec each; they compile independently
no_stdAlloc-backed build; takes precedence over std when both are enabled
experimentalSerialized shared dictionaries, custom static encoding, nonzero stream offsets, and framing. This API may change in a patch release
diagnosticsPreparedDictionary::longest_match prefix coverage diagnostic
hotpath, hotpath-cpu, hotpath-allocOptional 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 toUse
Get a fresh Vec<u8>compress / decompress
Append to a Vec you already owncompress_into / decompress_into
Write into a fixed slicecompress_to_slice / decompress_to_slice
Cap what the codec keeps between callsA 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 policyEffect
Aggressive (default)Keeps allocated buffers between operations
CurrentConfigReleases incompatible storage when configuration changes
Bounded { max_bytes }Releases retained storage when its accounting exceeds the ceiling
ReleaseAllReleases storage after each operation

Set the policy with Compressor::builder(config).with_retention(policy).build(). 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

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

Streaming and completion

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

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

Readers and writers require std. Sessions 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/statusMeaning
Operation::ProcessAccept input without ending the stream
Operation::FlushEmit accepted input and a decoding boundary
Operation::FinishEnd the stream after the supplied input
EncoderStatus::NeedsInputSupply more input or an explicit flush/finish
EncoderStatus::NeedsOutputSupply output space and continue with unconsumed input
EncoderStatus::FinishedAll 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

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

The library has no dedicated framed decoder Write adapter. Its event API keeps resource boundaries and metadata visible; forward each payload event to an appropriate Write destination. It also has no parallel decoder: parallel compression produces one ordinary raw 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

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

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

Sans I/O compression

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

Output formatSession entry pointScheduling
Raw BrotliCompressor::start(stream)Call process yourself
One raw stream encoded across workersParallelCompressor::start(window)Drive process and run its task queue
Framed resources · experimentalFramedCompressor::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.

InputDecoder
Ordinary raw BrotliDecompressor::start
Output of ParallelCompressorThe same Decompressor::start
Framing container · experimentalFramedDecompressor::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(())
}
  • process never blocks. Pending means the window is full or a flush or finish waits for queued tasks; call wait, which blocks until one completes.
  • window bounds the segments queued, running or awaiting delivery, and so the session’s memory. A full window stops input from being consumed.
  • Without a flush, the output is byte-identical to prepare_slice over the same bytes, however the input was chunked. Operation::Flush ends the current segment early, which moves later segment boundaries.
  • On a single thread, run queue.try_recv() tasks before wait; nothing else will. Dropping every queue handle fails the stream instead of hanging.
  • into_session(window) returns an owned session without a lifetime; into_compressor returns the compressor.

Reader and writer adapters

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

use 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, and finish waits for every part. Run the queue on threads other than the one using the adapter.
  • Output is byte-identical to prepare_slice over the same bytes. The writer’s flush is Operation::Flush: it makes everything written decodable and moves later segment boundaries.
  • The writer keeps every compressed byte until the sink takes it, so short writes, WouldBlock and sink errors lose nothing, and finish hands the adapter back inside FinishError for a retry, as EncoderWriter does. reader.into_parts() returns source bytes read ahead of the session.
  • Session failures arrive as std::io::Error with the ParallelEncodeError as their source. Dropping an adapter abandons the stream and closes the queue.

Segments and compressed size

Segment size defaults to 4 MiB and accepts 64 KiB–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

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

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

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

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

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

Output and cancellation

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

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

The 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.

ConceptMeaning
ContainerThe enclosing structure, with resources and optional metadata/directory/footer
ResourceOne logical payload, compressed or stored verbatim
ChunkA bounded portion of a resource on the wire; several chunks can share decoder state
DirectoryIndex 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

FeatureSurface
experimentalSerialized shared dictionaries, custom static encoding, nonzero stream offsets, and framing
diagnosticsPreparedDictionary::longest_match prefix coverage diagnostic
hotpath, hotpath-cpu, hotpath-allocOptional 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

SurfaceClaimOracle
Default features, qualities 0–11, windows 10–24Byte 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 decoderC encoder and decoder through google-brotli-ffi
Large Window Brotli, qualities 3–11Decodable by the C decoder up to its 30-bit limit; above it, identical to the 30-bit stream apart from the header bitsC decoder; header comparison
Prepared prefix dictionaries, qualities 5–11Byte identity with C where C supports the same attachment, and C decoding with the dictionary attachedC encoder and decoder
Native decoder, any input bytesA 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 panicsC decoder through google-brotli-ffi
Native decoder, streamingChunked sessions agree with one-shot decoding on bytes, on cumulative progress, and on termination, at any chunk and output sizeCross-entry-point comparison
All serial entry pointsSame bytes from compress, compress_into, compress_to_slice, writer, reader, and start at any chunk size, with any backend, from a fresh or reused compressorCross-API and cross-backend comparison
Parallel compressionOne valid stream, deterministic across task countsC decoder; cross-schedule comparison
experimental: serialized dictionaries, custom static encoding, continuations, framingRFC 9841 conformance validated by the C parser and decoder built with BROTLI_EXPERIMENTAL, byte identity with C for continuations, and this crate’s own fixturesC decoder; fixtures
Memory safetyThe 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 fileWhat it compares
tests/differential_c.rsStructural corpora, boundary lengths, every window size, reused and reconfigured compressors against the C encoder
tests/greedy_qualities.rsQualities 2–9 byte for byte
tests/vendor_corpus.rsGoogle’s own test corpus, including a 12 MiB multi-fragment input, against the C encoder, through the C decoder, and across backends
tests/randomized.rsDeterministically seeded structured inputs against the C encoder and decoder
tests/large_window.rs, tests/window_bits.rsLarge Window headers and bounds
tests/dictionary.rs, tests/serialized_dictionary.rs, tests/stream_offset.rsPrefix dictionaries, serialized dictionaries and continuations against the C encoder and decoder
tests/roundtrip.rs, tests/framing.rs, tests/parallel.rsDecoding 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.

CompareResultsData
Compression speed and sizeQualities 0–11CSV, conditions, sizes
Decompression of identical C streamsSource qualities 0–11CSV, 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.

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

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.

LevelCompress mbrotliCompress brotliDecompress mbrotliDecompress brotliRatio, %
-110.67 MB/s0.56 MB/s371 MB/s383 MB/s23.78
-814.4 MB/s12.6 MB/s419 MB/s443 MB/s26.96
-550.0 MB/s53.3 MB/s388 MB/s419 MB/s28.10
-2127 MB/s139 MB/s349 MB/s378 MB/s32.12
-0341 MB/s341 MB/s291 MB/s322 MB/s37.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

Benchmark index

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.

Qualitymbrotli speed / C ↑mbrotli output / C ↓Datasets
Quality 94.323×1.000×8
Quality 111.493×1.000×8
Quality 01.349×1.000×8
Quality 101.243×1.000×8
Quality 41.183×1.000×8
Quality 11.123×1.000×8
Quality 31.089×1.000×8
Quality 81.022×1.000×8
Quality 21.020×1.000×8
Quality 51.017×1.000×8
Quality 61.008×1.000×8
Quality 71.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.

ImplementationMedian speed / C ↑Median output / C ↓Datasets
Google C1.000×1.000×8
mbrotli1.349×1.000×8
Rust brotli0.090×1.095×8
SIMD Brotli0.100×1.095×8
Burli2.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
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
Google C14.814.79–14.814,222.403,3695.141%1.000×
mbrotli10.2510.24–10.266,095.003,3695.141%1.443×
Rust brotli301.1300.9–301.3207.5851,60378.740%0.049×
SIMD Brotli308307.5–308.7202.9551,60378.740%0.048×
Burli13.3413.33–13.354,683.793,4335.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
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
Google C297.2296.4–297.9488.0265,79543.261%1.000×
mbrotli327.9327.6–328.2442.4065,79543.261%0.907×
Rust brotli1,0211,020–1,022142.0478,21751.428%0.291×
SIMD Brotli932.8932.2–933.4155.4978,21751.428%0.319×
Burli244.4243.2–245.7593.4574,80249.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
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
Google C95.4495.34–95.5710,478.251,048,581100.000%1.000×
mbrotli76.0275.98–76.0613,154.611,048,581100.000%1.255×
Rust brotli4,6044,596–4,612217.221,049,345100.073%0.021×
SIMD Brotli5,1125,108–5,117195.611,049,345100.073%0.019×
Burli50.0650.04–50.0919,975.661,048,581100.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
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
Google C8.9088.905–8.9117,016.1865,540100.006%1.000×
mbrotli8.238.217–8.2477,593.7365,540100.006%1.082×
Rust brotli241.4241.1–241.7258.9565,585100.075%0.037×
SIMD Brotli269.4269.2–269.6232.0365,585100.075%0.033×
Burli4.2094.205–4.21414,849.3865,540100.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
ImplementationMean, ns95% mean interval, nsMiB/sOutput bytesOutput / inputSpeed / C
Google C17.6817.62–17.76—1—1.000×
mbrotli34.7134.5–35.07—1—0.509×
Rust brotli195.9193.7–198.2—1—0.090×
SIMD Brotli193.3192.6–194.4—1—0.091×
Burli123.5123.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
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
Google C131.7131.6–131.97,590.517120.068%1.000×
mbrotli84.4284.32–84.5411,844.897120.068%1.560×
Rust brotli1,4541,453–1,455687.6912,3731.180%0.091×
SIMD Brotli1,2241,223–1,225817.1312,3731.180%0.108×
Burli33.3233.29–33.3630,008.99210.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
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
Google C0.90.8989–0.901246.6248109.091%1.000×
mbrotli0.10670.1063–0.1073393.4048109.091%8.438×
Rust brotli4.0694.066–4.07110.3148109.091%0.221×
SIMD Brotli3.9263.918–3.93310.6948109.091%0.229×
Burli0.041550.04134–0.041841,009.9348109.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
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
Google C4,5074,504–4,513221.86190,49118.167%1.000×
mbrotli2,4332,431–2,435410.96190,49118.167%1.852×
Rust brotli7,1397,136–7,142140.08537,43251.254%0.631×
SIMD Brotli6,5796,572–6,584152.01537,43251.254%0.685×
Burli306.7306–307.43,260.5574,8287.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.

ImplementationMedian speed / C ↑Median output / C ↓Datasets
Google C1.000×1.000×8
mbrotli1.123×1.000×8
Rust brotli0.107×1.146×8
SIMD Brotli0.141×1.146×8
Burli0.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
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
Google C69.4769.43–69.5214,394.781900.018%1.000×
mbrotli26.8126.78–26.8737,295.571900.018%2.591×
Rust brotli594593.6–594.41,683.465,3770.513%0.117×
SIMD Brotli372.2371.6–372.92,686.745,3770.513%0.187×
Burli55.9655.93–55.9917,870.66210.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
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
Google C18.5918.58–18.63,361.873,2024.886%1.000×
mbrotli12.7812.77–12.784,892.133,2024.886%1.455×
Rust brotli366.2366–366.5170.6649,87776.106%0.051×
SIMD Brotli311.9311.7–312200.4249,87776.106%0.060×
Burli19.1319.12–19.143,267.733,2044.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
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
Google C120119.8–120.18,334.971,048,581100.000%1.000×
mbrotli97.0596.92–97.1910,304.491,048,581100.000%1.236×
Rust brotli5,4795,471–5,487182.521,049,345100.073%0.022×
SIMD Brotli4,8424,772–4,915206.531,049,345100.073%0.025×
Burli684.9684.2–6861,460.021,048,625100.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
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
Google C7.6457.636–7.6588,175.0565,540100.006%1.000×
mbrotli6.4266.423–6.439,726.2065,540100.006%1.190×
Rust brotli290.8290.3–291.3214.9565,585100.075%0.026×
SIMD Brotli234.2233.1–235.7266.8865,585100.075%0.033×
Burli46.8746.85–46.891,333.5865,540100.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
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
Google C1.5731.572–1.57526.6848109.091%1.000×
mbrotli1.4891.487–1.49128.1848109.091%1.057×
Rust brotli2.2622.258–2.26718.5548109.091%0.696×
SIMD Brotli2.1122.105–2.11919.8748109.091%0.745×
Burli2.3222.32–2.32518.0748109.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
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
Google C485.4484.8–486.1298.7960,29239.643%1.000×
mbrotli471.7471.1–472.2307.5260,29239.643%1.029×
Rust brotli1,2131,213–1,214119.5777,84751.185%0.400×
SIMD Brotli958.1957.1–959.3151.3977,84751.185%0.507×
Burli345.5342.8–348.4419.8063,13941.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
ImplementationMean, ns95% mean interval, nsMiB/sOutput bytesOutput / inputSpeed / C
Google C17.6917.65–17.73—1—1.000×
mbrotli34.6234.41–34.98—1—0.511×
Rust brotli184.1182.5–185.7—1—0.096×
SIMD Brotli185.3184.8–185.7—1—0.095×
Burli124.9124.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
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
Google C1,3131,312–1,314761.75138,77713.235%1.000×
mbrotli1,3041,304–1,305766.68138,77713.235%1.006×
Rust brotli8,4408,435–8,446118.48536,26851.143%0.156×
SIMD Brotli6,7076,703–6,710149.11536,26851.143%0.196×
Burli434.3433.2–435.42,302.6963,1566.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.

ImplementationMedian speed / C ↑Median output / C ↓Datasets
Google C1.000×1.000×8
mbrotli1.020×1.000×8
Rust brotli0.355×1.000×8
SIMD Brotli0.336×1.000×8
Burli0.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
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
Google C468.3468.1–468.42,135.46230.002%1.000×
mbrotli51.1451.08–51.2119,553.21230.002%9.156×
Rust brotli1,2111,209–1,213826.07230.002%0.387×
SIMD Brotli1,3891,382–1,396719.93230.002%0.337×
Burli136.9136.6–137.17,307.17230.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
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
Google C47.347.27–47.331,321.362,6734.079%1.000×
mbrotli23.0623.04–23.072,710.712,6734.079%2.051×
Rust brotli124.1123.9–124.4503.572,6734.079%0.381×
SIMD Brotli131.6131.5–131.9474.762,6734.079%0.359×
Burli27.8527.8–27.912,244.262,6884.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
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
Google C1,3081,308–1,309764.3657,4035.474%1.000×
mbrotli959.6958.7–960.51,042.1157,4035.474%1.363×
Rust brotli2,2622,255–2,271441.9957,3895.473%0.578×
SIMD Brotli2,3712,365–2,377421.7957,3895.473%0.552×
Burli1,0591,058–1,059944.4258,0065.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
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
Google C25.7825.74–25.822,424.5765,540100.006%1.000×
mbrotli24.9924.96–25.022,501.2065,540100.006%1.032×
Rust brotli105.2105–105.5593.9265,540100.006%0.245×
SIMD Brotli101.2101.1–101.3617.6065,540100.006%0.255×
Burli46.8846.85–46.91,333.3165,540100.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
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
Google C434.3434.1–434.72,302.301,048,581100.000%1.000×
mbrotli430.8430.6–4312,321.381,048,581100.000%1.008×
Rust brotli1,3581,351–1,366736.141,048,581100.000%0.320×
SIMD Brotli1,2941,292–1,296772.831,048,581100.000%0.336×
Burli684.8684.4–685.31,460.291,048,625100.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
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
Google C893.4892.7–894.2162.3557,38337.730%1.000×
mbrotli897.6896.5–899.2161.6057,38337.730%0.995×
Rust brotli1,3141,306–1,324110.3857,37237.723%0.680×
SIMD Brotli1,2711,270–1,273114.1057,37237.723%0.703×
Burli939.2938.4–939.9154.4457,98938.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
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
Google C0.99110.9886–0.994742.3448109.091%1.000×
mbrotli1.0021–1.00441.8848109.091%0.989×
Rust brotli3.0213.018–3.02613.8948109.091%0.328×
SIMD Brotli3.0273.024–3.0313.8648109.091%0.327×
Burli1.9481.946–1.95121.5448109.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
ImplementationMean, ns95% mean interval, nsMiB/sOutput bytesOutput / inputSpeed / C
Google C17.8117.72–17.93—1—1.000×
mbrotli34.9834.53–35.53—1—0.509×
Rust brotli1,9021,900–1,905—1—0.009×
SIMD Brotli1,8801,877–1,882—1—0.009×
Burli125.1124.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.

ImplementationMedian speed / C ↑Median output / C ↓Datasets
Google C1.000×1.000×8
mbrotli1.089×1.000×8
Rust brotli0.409×1.000×8
SIMD Brotli0.401×1.000×8
Burli0.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
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
Google C469.1468.8–469.52,131.53130.001%1.000×
mbrotli52.0651.94–52.2119,209.32130.001%9.012×
Rust brotli1,2121,211–1,214825.03130.001%0.387×
SIMD Brotli1,3811,378–1,384724.14130.001%0.340×
Burli226.6226.5–226.74,413.12130.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
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
Google C1,4891,487–1,490671.8156,8795.424%1.000×
mbrotli1,0851,084–1,086921.4656,8795.424%1.372×
Rust brotli2,3952,382–2,408417.5456,8935.426%0.622×
SIMD Brotli2,5112,501–2,526398.2056,8935.426%0.593×
Burli1,4611,461–1,462684.2957,5175.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
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
Google C54.0654.02–54.111,156.102,6874.100%1.000×
mbrotli23.7323.72–23.752,633.302,6874.100%2.278×
Rust brotli128.3128.2–128.5486.952,6534.048%0.421×
SIMD Brotli136.1135.9–136.3459.172,6534.048%0.397×
Burli30.8830.85–30.922,023.712,7604.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
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
Google C34.0434.02–34.071,835.9765,540100.006%1.000×
mbrotli30.9930.96–31.012,017.0665,540100.006%1.099×
Rust brotli108.7108.5–109.1574.8365,540100.006%0.313×
SIMD Brotli108.7108.6–108.8574.9565,540100.006%0.313×
Burli46.946.87–46.931,332.7165,540100.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
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
Google C607.3606.5–608.31,646.551,048,581100.000%1.000×
mbrotli562.8562.4–563.11,776.941,048,581100.000%1.079×
Rust brotli1,5331,522–1,543652.521,048,581100.000%0.396×
SIMD Brotli1,4991,496–1,503666.961,048,581100.000%0.405×
Burli685.4684.9–685.91,458.981,048,625100.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
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
Google C1.9111.909–1.91221.9646104.545%1.000×
mbrotli1.8941.893–1.89622.1546104.545%1.009×
Rust brotli4.0864.081–4.09110.2746104.545%0.468×
SIMD Brotli4.0514.048–4.05410.3646104.545%0.472×
Burli2.3692.366–2.37317.7148109.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
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
Google C1,0821,079–1,084134.0856,86337.388%1.000×
mbrotli1,0241,023–1,025141.6556,86337.388%1.056×
Rust brotli1,4261,416–1,438101.7156,87637.397%0.759×
SIMD Brotli1,4081,405–1,411103.0556,87637.397%0.769×
Burli999.6999–1,000145.1058,70238.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
ImplementationMean, ns95% mean interval, nsMiB/sOutput bytesOutput / inputSpeed / C
Google C17.8917.85–17.93—1—1.000×
mbrotli34.6434.39–35.09—1—0.516×
Rust brotli1,9151,912–1,918—1—0.009×
SIMD Brotli1,9001,897–1,904—1—0.009×
Burli125.2125–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.

ImplementationMedian speed / C ↑Median output / C ↓Datasets
Google C1.000×1.000×8
mbrotli1.183×1.000×8
Rust brotli0.529×1.000×8
SIMD Brotli0.488×1.000×8
Burli1.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
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
Google C477476.6–477.62,096.23130.001%1.000×
mbrotli77.3377.25–77.412,932.26130.001%6.169×
Rust brotli906.8901–913.11,102.84130.001%0.526×
SIMD Brotli1,0501,049–1,052952.25130.001%0.454×
Burli232.2231.9–232.44,307.28130.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
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
Google C1,7231,722–1,725580.2955,9155.332%1.000×
mbrotli1,3621,360–1,365734.2255,9155.332%1.265×
Rust brotli2,4322,413–2,455411.1155,8915.330%0.708×
SIMD Brotli2,5212,509–2,536396.7355,8915.330%0.684×
Burli1,6961,694–1,698589.7957,7735.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
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
Google C45.4745.43–45.521,374.4465,540100.006%1.000×
mbrotli41.3341.3–41.361,512.2165,540100.006%1.100×
Rust brotli126.6126.2–127493.6865,540100.006%0.359×
SIMD Brotli124.4124.2–124.5502.6065,540100.006%0.366×
Burli46.9246.87–46.971,332.1365,540100.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
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
Google C4.5974.593–4.6029.1348109.091%1.000×
mbrotli3.1623.157–3.16813.2748109.091%1.454×
Rust brotli8.6568.647–8.6664.8548109.091%0.531×
SIMD Brotli8.8228.818–8.8264.7648109.091%0.521×
Burli3.2453.242–3.24712.9348109.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
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
Google C1,5611,559–1,56392.9455,50436.494%1.000×
mbrotli1,5101,509–1,51196.0455,50436.494%1.033×
Rust brotli1,9211,913–1,93075.5155,48936.485%0.812×
SIMD Brotli1,9091,906–1,91175.9955,48936.485%0.818×
Burli1,4661,465–1,46798.9457,81338.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
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
Google C56.1856.14–56.221,112.532,5543.897%1.000×
mbrotli40.2340.2–40.261,553.512,5543.897%1.396×
Rust brotli157.4157.1–157.7397.082,5673.917%0.357×
SIMD Brotli157.5157–158396.842,5673.917%0.357×
Burli34.7134.68–34.741,800.782,5933.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
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
Google C917.7917.1–918.31,089.701,048,581100.000%1.000×
mbrotli895.9894.9–896.81,116.241,048,581100.000%1.024×
Rust brotli1,3121,309–1,315762.411,048,581100.000%0.700×
SIMD Brotli1,3101,305–1,317763.371,048,581100.000%0.701×
Burli685.9685.3–686.51,458.041,048,625100.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
ImplementationMean, ns95% mean interval, nsMiB/sOutput bytesOutput / inputSpeed / C
Google C18.1317.97–18.38—1—1.000×
mbrotli34.7234.46–35.16—1—0.522×
Rust brotli3,4983,496–3,500—1—0.005×
SIMD Brotli3,5243,520–3,527—1—0.005×
Burli125.4125.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.

ImplementationMedian speed / C ↑Median output / C ↓Datasets
Google C1.000×1.000×8
mbrotli1.017×1.000×8
Rust brotli0.442×1.000×8
SIMD Brotli0.355×1.000×8
Burli0.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
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
Google C475.2474.9–475.52,104.23130.001%1.000×
mbrotli93.6793.58–93.7710,675.73130.001%5.073×
Rust brotli955.8950.8–9611,046.23130.001%0.497×
SIMD Brotli1,1661,161–1,173857.68130.001%0.408×
Burli953.3952.9–953.81,048.96130.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
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
Google C2,7872,783–2,791358.8651,2284.885%1.000×
mbrotli2,3892,385–2,394418.6251,2284.885%1.167×
Rust brotli3,9133,896–3,932255.5752,2384.982%0.712×
SIMD Brotli4,1874,182–4,194238.8251,2384.886%0.665×
Burli3,4823,479–3,485287.1952,6645.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
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
Google C74.0273.9–74.12844.391,5882.423%1.000×
mbrotli75.8975.8–75.97823.551,5882.423%0.975×
Rust brotli219.8219.6–220284.381,5882.423%0.337×
SIMD Brotli337.9337.3–338.6184.951,5882.423%0.219×
Burli102.5102.4–102.6609.861,5932.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
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
Google C2,3622,361–2,36461.4052,80934.722%1.000×
mbrotli2,2562,255–2,25864.2952,80934.722%1.047×
Rust brotli3,0183,013–3,02448.0552,80834.722%0.783×
SIMD Brotli3,4033,394–3,41442.6252,80834.722%0.694×
Burli2,1812,179–2,18466.4954,22835.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
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
Google C4.7664.763–4.7698.8048109.091%1.000×
mbrotli3.3673.364–3.36912.4648109.091%1.416×
Rust brotli14.2314.2–14.262.9548109.091%0.335×
SIMD Brotli17.0517.04–17.072.4648109.091%0.279×
Burli3.113.107–3.11313.4948109.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
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
Google C1,1691,168–1,170855.601,048,581100.000%1.000×
mbrotli1,1831,182–1,184845.211,048,581100.000%0.988×
Rust brotli2,5482,545–2,550392.501,048,581100.000%0.459×
SIMD Brotli2,6062,602–2,609383.781,048,581100.000%0.449×
Burli686.8686–687.71,455.921,048,625100.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
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
Google C72.872.74–72.86858.5465,540100.006%1.000×
mbrotli82.6482.38–82.9756.2765,540100.006%0.881×
Rust brotli171.5171.1–171.8364.5265,540100.006%0.425×
SIMD Brotli240.1239.9–240.4260.3065,540100.006%0.303×
Burli46.8746.84–46.891,333.5965,540100.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
ImplementationMean, ns95% mean interval, nsMiB/sOutput bytesOutput / inputSpeed / C
Google C17.8717.84–17.9—1—1.000×
mbrotli34.5334.28–34.96—1—0.518×
Rust brotli8,3998,392–8,408—1—0.002×
SIMD Brotli1.107e+041.106e+04–1.109e+04—1—0.002×
Burli124.7124.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.

ImplementationMedian speed / C ↑Median output / C ↓Datasets
Google C1.000×1.000×8
mbrotli1.008×1.000×8
Rust brotli0.404×1.000×8
SIMD Brotli0.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
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
Google C473.4473–4742,112.56130.001%1.000×
mbrotli117.3117.1–117.48,526.35130.001%4.036×
Rust brotli961957.3–965.11,040.61130.001%0.493×
SIMD Brotli1,1981,194–1,202834.99130.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
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
Google C4.6414.637–4.6479.0448109.091%1.000×
mbrotli3.2913.288–3.29412.7548109.091%1.410×
Rust brotli21.4821.46–21.51.9548109.091%0.216×
SIMD Brotli26.126.07–26.121.6148109.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
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
Google C3,0133,009–3,017331.9050,8234.847%1.000×
mbrotli2,5842,577–2,594386.9350,8234.847%1.166×
Rust brotli4,6794,652–4,705213.7451,4214.904%0.644×
SIMD Brotli4,5064,495–4,518221.9550,8304.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
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
Google C2,6332,631–2,63555.0851,96734.169%1.000×
mbrotli2,5232,522–2,52557.4851,96734.169%1.043×
Rust brotli3,7283,718–3,73638.9151,96934.170%0.706×
SIMD Brotli3,7023,694–3,71239.1851,96934.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
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
Google C1,2511,249–1,254799.081,048,581100.000%1.000×
mbrotli1,2881,287–1,289776.501,048,581100.000%0.972×
Rust brotli2,9242,915–2,934341.981,048,581100.000%0.428×
SIMD Brotli2,7732,750–2,800360.591,048,581100.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
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
Google C74.7274.59–74.83836.481,5882.423%1.000×
mbrotli104.5104.2–104.7598.331,5882.423%0.715×
Rust brotli251.5250.4–252.9248.501,5882.423%0.297×
SIMD Brotli358357.3–358.7174.601,5882.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
ImplementationMean, ns95% mean interval, nsMiB/sOutput bytesOutput / inputSpeed / C
Google C17.9117.86–17.97—1—1.000×
mbrotli34.5734.33–34.98—1—0.518×
Rust brotli1.616e+041.615e+04–1.618e+04—1—0.001×
SIMD Brotli1.981e+041.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
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
Google C76.5976.5–76.69816.0665,540100.006%1.000×
mbrotli158157.7–158.2395.6565,540100.006%0.485×
Rust brotli201.8200.3–203.1309.7565,540100.006%0.380×
SIMD Brotli262.1261.7–262.6238.4565,540100.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.

ImplementationMedian speed / C ↑Median output / C ↓Datasets
Google C1.000×1.000×8
mbrotli1.004×1.000×8
Rust brotli0.360×1.000×8
SIMD Brotli0.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
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
Google C474.5474–475.22,107.29130.001%1.000×
mbrotli160.4159.9–161.16,232.49130.001%2.958×
Rust brotli1,0871,081–1,095919.66130.001%0.436×
SIMD Brotli1,2131,209–1,218824.50130.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
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
Google C4.6984.694–4.7028.9348109.091%1.000×
mbrotli3.4523.449–3.45512.1648109.091%1.361×
Rust brotli67.0566.95–67.140.6348109.091%0.070×
SIMD Brotli19.9319.92–19.952.1148109.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
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
Google C3,3683,363–3,375296.8950,5804.824%1.000×
mbrotli2,9032,891–2,915344.4950,5804.824%1.160×
Rust brotli5,1885,167–5,215192.7550,9094.855%0.649×
SIMD Brotli5,4765,467–5,486182.6250,9094.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
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
Google C1,8791,877–1,881532.181,048,581100.000%1.000×
mbrotli1,8251,821–1,830547.861,048,581100.000%1.029×
Rust brotli3,2093,202–3,215311.651,048,581100.000%0.586×
SIMD Brotli3,4843,453–3,523287.011,048,581100.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
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
Google C2,9592,953–2,96549.0251,45133.830%1.000×
mbrotli3,0213,016–3,02748.0151,45133.830%0.979×
Rust brotli4,2914,276–4,30833.8051,45133.830%0.690×
SIMD Brotli4,5124,479–4,54832.1451,45133.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
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
Google C84.9984.85–85.13735.4065,540100.006%1.000×
mbrotli113.3112.6–114551.5965,540100.006%0.750×
Rust brotli300.7300.1–301.3207.8665,540100.006%0.283×
SIMD Brotli313.5312.5–314.6199.3665,540100.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
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
Google C71.9771.79–72.16868.361,5882.423%1.000×
mbrotli103102.7–103.3606.641,5882.423%0.699×
Rust brotli334.4333.3–335.6186.921,5882.423%0.215×
SIMD Brotli365.6360–371.9170.971,5882.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
ImplementationMean, ns95% mean interval, nsMiB/sOutput bytesOutput / inputSpeed / C
Google C17.5617.53–17.6—1—1.000×
mbrotli34.5534.33–34.94—1—0.508×
Rust brotli6.088e+046.079e+04–6.098e+04—1—0.000×
SIMD Brotli6.035e+046.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.

ImplementationMedian speed / C ↑Median output / C ↓Datasets
Google C1.000×1.000×8
mbrotli1.022×1.000×8
Rust brotli0.291×1.000×8
SIMD Brotli0.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
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
Google C475.2474.9–475.52,104.39130.001%1.000×
mbrotli75.8475.74–75.9313,186.47130.001%6.266×
Rust brotli1,3581,328–1,392736.56130.001%0.350×
SIMD Brotli1,4531,437–1,473688.17130.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
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
Google C4.8274.823–4.8328.6948109.091%1.000×
mbrotli3.5463.54–3.55611.8348109.091%1.361×
Rust brotli168.7168.1–169.50.2548109.091%0.029×
SIMD Brotli19.9319.92–19.942.1148109.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
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
Google C136.9136.8–137.1456.5165,540100.006%1.000×
mbrotli113.5112.7–114.4550.4465,540100.006%1.206×
Rust brotli590.7572.3–609.3105.8165,540100.006%0.232×
SIMD Brotli583565.1–602.8107.2165,540100.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
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
Google C3,7903,777–3,805263.8350,4734.813%1.000×
mbrotli3,5813,557–3,612279.2250,4734.813%1.058×
Rust brotli6,7366,705–6,770148.4550,6484.830%0.563×
SIMD Brotli6,6636,647–6,682150.0850,6484.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
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
Google C2,8052,798–2,814356.461,048,581100.000%1.000×
mbrotli2,8472,807–2,892351.191,048,581100.000%0.985×
Rust brotli4,5424,530–4,554220.171,048,581100.000%0.618×
SIMD Brotli4,8244,797–4,855207.311,048,581100.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
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
Google C3,4373,433–3,44042.2151,18733.656%1.000×
mbrotli3,5873,582–3,59240.4451,18733.656%0.958×
Rust brotli5,6965,667–5,72625.4651,18733.656%0.603×
SIMD Brotli5,6045,540–5,67225.8851,18733.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
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
Google C77.7277.55–77.87804.171,5882.423%1.000×
mbrotli105.9105.8–106.1589.911,5882.423%0.734×
Rust brotli565.1559.5–573.6110.591,5882.423%0.138×
SIMD Brotli557.7554.9–560.9112.061,5882.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
ImplementationMean, ns95% mean interval, nsMiB/sOutput bytesOutput / inputSpeed / C
Google C17.917.87–17.93—1—1.000×
mbrotli34.5434.32–34.93—1—0.518×
Rust brotli1.648e+051.636e+05–1.661e+05—1—0.000×
SIMD Brotli1.64e+051.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.

ImplementationMedian speed / C ↑Median output / C ↓Datasets
Google C1.000×1.000×8
mbrotli4.323×1.000×8
Rust brotli0.828×1.000×8
SIMD Brotli1.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
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
Google C63.4663.4–63.530.6648109.091%1.000×
mbrotli3.5763.573–3.57911.7348109.091%17.746×
Rust brotli66.4966.38–66.610.6348109.091%0.954×
SIMD Brotli23.3223.31–23.341.8048109.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
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
Google C425.7425.6–4262,348.81130.001%1.000×
mbrotli75.5775.52–75.6113,233.41130.001%5.634×
Rust brotli1,1401,134–1,146877.51130.001%0.374×
SIMD Brotli1,2021,195–1,210831.89130.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
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
Google C2,4542,450–2,45825.471,3492.058%1.000×
mbrotli116115.7–116.3538.751,3492.058%21.151×
Rust brotli2,8142,803–2,82622.211,3492.058%0.872×
SIMD Brotli235.2235.1–235.4265.691,3492.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
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
Google C9,4489,395–9,5246.6265,540100.006%1.000×
mbrotli138.7138.1–139.3450.6065,540100.006%68.117×
Rust brotli1.041e+041.033e+04–1.051e+046.0065,540100.006%0.907×
SIMD Brotli257.1256.8–257.3243.1465,540100.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
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
Google C1.379e+041.37e+04–1.39e+0410.5251,05433.569%1.000×
mbrotli4,5794,575–4,58431.6851,05433.569%3.011×
Rust brotli1.695e+041.689e+04–1.703e+048.5651,05633.570%0.813×
SIMD Brotli5,5015,497–5,50626.3751,05633.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
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
Google C1.516e+041.513e+04–1.519e+0465.9550,6084.826%1.000×
mbrotli1.515e+041.501e+04–1.53e+0466.0250,6084.826%1.001×
Rust brotli1.84e+041.824e+04–1.858e+0454.3650,5104.817%0.824×
SIMD Brotli1.773e+041.762e+04–1.788e+0456.4050,5104.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
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
Google C1.408e+041.402e+04–1.414e+0471.041,048,581100.000%1.000×
mbrotli1.418e+041.392e+04–1.448e+0470.511,048,581100.000%0.992×
Rust brotli1.692e+041.683e+04–1.704e+0459.081,048,581100.000%0.832×
SIMD Brotli1.727e+041.7e+04–1.757e+0457.911,048,581100.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
ImplementationMean, ns95% mean interval, nsMiB/sOutput bytesOutput / inputSpeed / C
Google C17.8417.79–17.89—1—1.000×
mbrotli34.5934.38–34.97—1—0.516×
Rust brotli6,3356,321–6,348—1—0.003×
SIMD Brotli6,3876,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.

ImplementationMedian speed / C ↑Median output / C ↓Datasets
Google C1.000×1.000×8
mbrotli1.243×1.000×8
Rust brotli0.839×1.000×8
SIMD Brotli0.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
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
Google C8,8258,820–8,830113.32140.001%1.000×
mbrotli4,8894,868–4,915204.54140.001%1.805×
Rust brotli1.124e+041.118e+04–1.132e+0488.95140.001%0.785×
SIMD Brotli1.104e+041.1e+04–1.109e+0490.56140.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
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
Google C4,7574,754–4,76113.1465,540100.006%1.000×
mbrotli3,4293,422–3,43918.2365,540100.006%1.387×
Rust brotli5,4545,446–5,46311.4665,540100.006%0.872×
SIMD Brotli5,1505,145–5,15612.1465,540100.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
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
Google C5.012e+044.985e+04–5.045e+0419.9547,4924.529%1.000×
mbrotli3.709e+043.697e+04–3.721e+0426.9647,4924.529%1.352×
Rust brotli5.442e+045.425e+04–5.462e+0418.3847,5164.531%0.921×
SIMD Brotli5.054e+045.047e+04–5.062e+0419.7947,5164.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
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
Google C4.371e+044.362e+04–4.383e+043.3247,47731.217%1.000×
mbrotli3.369e+043.366e+04–3.372e+044.3147,47731.217%1.297×
Rust brotli4.741e+044.729e+04–4.757e+043.0647,48831.224%0.922×
SIMD Brotli4.354e+044.341e+04–4.367e+043.3347,48831.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
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
Google C1.259e+051.25e+05–1.273e+057.941,048,581100.000%1.000×
mbrotli1.06e+051.053e+05–1.067e+059.441,048,581100.000%1.189×
Rust brotli1.375e+051.366e+05–1.389e+057.271,048,581100.000%0.916×
SIMD Brotli1.428e+051.421e+05–1.438e+057.001,048,581100.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
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
Google C1,6101,608–1,61238.831,0631.622%1.000×
mbrotli1,4821,481–1,48442.161,0631.622%1.086×
Rust brotli2,3142,307–2,32027.011,0631.622%0.696×
SIMD Brotli1,7581,755–1,76035.561,0631.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
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
Google C125.4125.2–125.60.3348109.091%1.000×
mbrotli130128.3–133.20.3248109.091%0.965×
Rust brotli155.7155.5–1560.2748109.091%0.805×
SIMD Brotli155.6154.9–156.20.2748109.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
ImplementationMean, ns95% mean interval, nsMiB/sOutput bytesOutput / inputSpeed / C
Google C17.8917.87–17.92—1—1.000×
mbrotli34.5134.29–34.88—1—0.518×
Rust brotli1.755e+041.75e+04–1.761e+04—1—0.001×
SIMD Brotli1.084e+041.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.

ImplementationMedian speed / C ↑Median output / C ↓Datasets
Google C1.000×1.000×8
mbrotli1.493×1.000×8
Rust brotli0.834×1.000×8
SIMD Brotli0.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
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
Google C8,2088,200–8,2177.6165,540100.006%1.000×
mbrotli4,8224,818–4,82512.9665,540100.006%1.702×
Rust brotli9,5159,503–9,5326.5765,540100.006%0.863×
SIMD Brotli8,8298,814–8,8457.0865,540100.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
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
Google C1.278e+041.276e+04–1.28e+0478.26140.001%1.000×
mbrotli7,7777,755–7,802128.58140.001%1.643×
Rust brotli1.595e+041.583e+04–1.607e+0462.68140.001%0.801×
SIMD Brotli1.549e+041.541e+04–1.559e+0464.55140.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
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
Google C1.843e+051.832e+05–1.855e+055.431,048,581100.000%1.000×
mbrotli1.29e+051.284e+05–1.296e+057.751,048,581100.000%1.429×
Rust brotli2.083e+052.059e+05–2.112e+054.801,048,581100.000%0.885×
SIMD Brotli2.086e+052.056e+05–2.123e+054.791,048,581100.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
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
Google C1.268e+051.264e+05–1.272e+057.8946,4914.434%1.000×
mbrotli8.139e+048.094e+04–8.19e+0412.2946,4914.434%1.558×
Rust brotli1.267e+051.265e+05–1.27e+057.8946,4914.434%1.001×
SIMD Brotli1.147e+051.14e+05–1.154e+058.7246,4914.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
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
Google C1.219e+051.217e+05–1.22e+051.1946,48730.566%1.000×
mbrotli7.724e+047.713e+04–7.741e+041.8846,48730.566%1.578×
Rust brotli1.189e+051.188e+05–1.19e+051.2246,49330.570%1.025×
SIMD Brotli1.056e+051.053e+05–1.06e+051.3746,49330.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
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
Google C2,9102,907–2,91321.481,0651.625%1.000×
mbrotli2,1522,150–2,15429.041,0651.625%1.352×
Rust brotli3,7533,734–3,77316.651,0651.625%0.775×
SIMD Brotli3,0873,083–3,09120.251,0651.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
ImplementationMean, µs95% mean interval, µsMiB/sOutput bytesOutput / inputSpeed / C
Google C132.9132.7–133.10.3248109.091%1.000×
mbrotli132.3132.2–132.40.3248109.091%1.004×
Rust brotli165164.7–165.60.2548109.091%0.805×
SIMD Brotli163.7162.9–164.40.2648109.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
ImplementationMean, ns95% mean interval, nsMiB/sOutput bytesOutput / inputSpeed / C
Google C17.7117.68–17.74—1—1.000×
mbrotli34.5434.32–34.94—1—0.513×
Rust brotli1.831e+041.829e+04–1.834e+04—1—0.001×
SIMD Brotli1.156e+041.154e+04–1.157e+04—1—0.002×

Decoder comparison

Benchmark index

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 qualityGoogle CmbrotliRust brotliBurlimbrotli / Burli
q41.000×3.461×0.574×3.407×1.003×
q21.000×3.325×0.570×3.362×1.056×
q11.000×3.196×0.445×3.129×1.020×
q01.000×3.059×0.446×2.873×1.036×
q111.000×2.847×0.584×2.662×1.067×
q101.000×2.770×0.581×2.632×1.051×
q71.000×2.574×0.562×2.511×0.982×
q51.000×2.572×0.574×2.555×0.982×
q61.000×2.568×0.569×2.545×0.982×
q91.000×2.553×0.569×2.536×0.986×
q31.000×2.548×0.563×2.536×0.986×
q81.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.

DecoderMedian speed / C
Google C1.000×
mbrotli3.059×
Rust brotli0.446×
Burli2.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%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
Google C0.15450.1541–0.1551271.551.000×
mbrotli0.022640.02181–0.023471,853.826.827×
Rust brotli1.8111.81–1.81223.170.085×
Burli0.034670.03448–0.034891,210.164.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%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
Google C35.8535.83–35.881,743.211.000×
mbrotli10.1110.1–10.126,180.743.546×
Rust brotli78.7878.73–78.82793.370.455×
Burli11.1711.16–11.185,595.613.210×

repeated-1m

1 MiB of repeated a bytes.

712 compressed → 1,048,576 restored bytes; compressed / original: 0.068%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
Google C206.2206–206.54,848.701.000×
mbrotli45.5645.53–45.5921,949.934.527×
Rust brotli230.7230.2–231.34,334.880.894×
Burli46.6646.6–46.7221,432.984.420×

random-64k

64 KiB prefix of the deterministic pseudorandom corpus.

65,540 compressed → 65,536 restored bytes; compressed / original: 100.006%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
Google C2.1682.167–2.1728,826.641.000×
mbrotli0.84280.842–0.843674,159.302.573×
Rust brotli35.8935.86–35.931,741.370.060×
Burli0.85480.8541–0.855673,117.622.536×

text-1m

Alice text repeated cyclically to 1 MiB.

190,491 compressed → 1,048,576 restored bytes; compressed / original: 18.167%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
Google C902.8902.1–903.71,107.641.000×
mbrotli895894–8961,117.371.009×
Rust brotli1,2151,214–1,216823.170.743×
Burli938.5938–939.11,065.510.962×

random-1m

1 MiB of deterministic xorshift64 pseudorandom bytes.

1,048,581 compressed → 1,048,576 restored bytes; compressed / original: 100.000%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
Google C39.9239.9–39.9525,048.141.000×
mbrotli15.9715.95–15.9962,628.722.500×
Rust brotli91.5191.22–91.8510,927.830.436×
Burli15.9915.97–1662,555.462.497×

alice29

Vendored Alice in Wonderland text.

65,795 compressed → 152,089 restored bytes; compressed / original: 43.261%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
Google C289.9289.6–290.2500.371.000×
mbrotli304.4304.2–304.5476.560.952×
Rust brotli426.8426.3–427.5339.820.679×
Burli330.1329.8–330.3439.440.878×

empty

Empty input; measures API overhead.

1 compressed → 0 restored bytes; compressed / original: undefined.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
Google C0.1010.1008–0.1011—1.000×
mbrotli0.020240.01956–0.02094—4.989×
Rust brotli1.7421.74–1.745—0.058×
Burli0.015430.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.

DecoderMedian speed / C
Google C1.000×
mbrotli3.196×
Rust brotli0.445×
Burli3.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%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
Google C0.14890.1484–0.1495281.851.000×
mbrotli0.022490.02166–0.023341,865.556.619×
Rust brotli1.8441.842–1.84622.750.081×
Burli0.03470.03452–0.03491,209.374.291×

text-1m

Alice text repeated cyclically to 1 MiB.

138,777 compressed → 1,048,576 restored bytes; compressed / original: 13.235%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
Google C795.2793.7–796.31,257.581.000×
mbrotli681.3680.9–681.81,467.701.167×
Rust brotli939.6938.6–940.91,064.310.846×
Burli747.8747.4–748.21,337.291.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%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
Google C35.3535.34–35.371,767.801.000×
mbrotli9.279.261–9.2816,741.993.814×
Rust brotli77.6977.61–77.78804.460.455×
Burli9.5459.54–9.556,547.743.704×

random-64k

64 KiB prefix of the deterministic pseudorandom corpus.

65,540 compressed → 65,536 restored bytes; compressed / original: 100.006%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
Google C2.1772.176–2.17928,703.961.000×
mbrotli0.84430.8438–0.844974,023.092.579×
Rust brotli35.7835.72–35.841,746.900.061×
Burli0.85270.852–0.853573,298.502.554×

random-1m

1 MiB of deterministic xorshift64 pseudorandom bytes.

1,048,581 compressed → 1,048,576 restored bytes; compressed / original: 100.000%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
Google C39.9939.94–40.0525,006.641.000×
mbrotli15.915.89–15.9262,881.562.515×
Rust brotli91.8991.59–92.2210,882.590.435×
Burli15.9715.94–16.0262,598.412.503×

alice29

Vendored Alice in Wonderland text.

60,292 compressed → 152,089 restored bytes; compressed / original: 39.643%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
Google C268.5268.2–268.9540.141.000×
mbrotli281.7281.4–282514.890.953×
Rust brotli399.7399.5–399.9362.860.672×
Burli332.3331.8–332.9436.450.808×

empty

Empty input; measures API overhead.

1 compressed → 0 restored bytes; compressed / original: undefined.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
Google C0.11360.1135–0.1138—1.000×
mbrotli0.020240.01954–0.02095—5.614×
Rust brotli1.7351.734–1.737—0.065×
Burli0.015440.01539–0.01552—7.360×

repeated-1m

1 MiB of repeated a bytes.

190 compressed → 1,048,576 restored bytes; compressed / original: 0.018%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
Google C373.3372.8–373.82,678.881.000×
mbrotli32.4332.38–32.4830,839.3811.512×
Rust brotli234233.6–234.44,274.341.596×
Burli18.8118.79–18.8353,166.0319.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.

DecoderMedian speed / C
Google C1.000×
mbrotli3.325×
Rust brotli0.570×
Burli3.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%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
Google C0.18460.1842–0.1851227.341.000×
mbrotli0.022520.02169–0.023371,863.598.197×
Rust brotli1.8451.842–1.84922.740.100×
Burli0.035130.03499–0.035291,194.485.254×

text-1m

Alice text repeated cyclically to 1 MiB.

57,403 compressed → 1,048,576 restored bytes; compressed / original: 5.474%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
Google C659.7659–660.41,515.891.000×
mbrotli276.1275.9–276.43,621.522.389×
Rust brotli877.3876.6–877.91,139.920.752×
Burli308.2307.8–308.73,244.402.140×

repeated-1m

1 MiB of repeated a bytes.

23 compressed → 1,048,576 restored bytes; compressed / original: 0.002%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
Google C1,2271,226–1,227815.311.000×
mbrotli10.5210.52–10.5395,016.53116.541×
Rust brotli1,2581,257–1,259794.770.975×
Burli11.5511.54–11.5686,549.46106.156×

random-64k

64 KiB prefix of the deterministic pseudorandom corpus.

65,540 compressed → 65,536 restored bytes; compressed / original: 100.006%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
Google C2.2012.2–2.20228,391.281.000×
mbrotli0.840.8393–0.840774,404.662.621×
Rust brotli35.8435.81–35.861,744.070.061×
Burli0.8520.8492–0.857473,355.282.584×

random-1m

1 MiB of deterministic xorshift64 pseudorandom bytes.

1,048,581 compressed → 1,048,576 restored bytes; compressed / original: 100.000%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
Google C39.8839.83–39.9225,077.781.000×
mbrotli15.8615.83–15.8863,067.512.515×
Rust brotli83.8283.69–83.9711,930.950.476×
Burli15.8615.84–15.8863,057.552.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%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
Google C34.7334.7–34.771,799.571.000×
mbrotli8.6218.617–8.6257,250.004.029×
Rust brotli46.5246.47–46.571,343.560.747×
Burli8.3888.382–8.3957,450.754.140×

alice29

Vendored Alice in Wonderland text.

57,383 compressed → 152,089 restored bytes; compressed / original: 37.730%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
Google C245.9245.4–246.4589.901.000×
mbrotli254.7254.4–255569.480.965×
Rust brotli370.1369.8–370.4391.940.664×
Burli286.6285.9–287.8506.070.858×

empty

Empty input; measures API overhead.

1 compressed → 0 restored bytes; compressed / original: undefined.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
Google C0.11330.113–0.1135—1.000×
mbrotli0.020250.01957–0.02095—5.593×
Rust brotli1.7281.726–1.729—0.066×
Burli0.015510.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.

DecoderMedian speed / C
Google C1.000×
mbrotli2.548×
Rust brotli0.563×
Burli2.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%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
Google C647.6646.9–648.31,544.181.000×
mbrotli262.3262.1–262.53,812.972.469×
Rust brotli863.3862.5–864.11,158.390.750×
Burli299.7299.5–299.93,337.012.161×

random-64k

64 KiB prefix of the deterministic pseudorandom corpus.

65,540 compressed → 65,536 restored bytes; compressed / original: 100.006%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
Google C2.212.209–2.21128,277.401.000×
mbrotli0.84340.8426–0.844374,108.592.621×
Rust brotli35.935.87–35.931,740.850.062×
Burli0.85290.8524–0.853573,276.482.591×

random-1m

1 MiB of deterministic xorshift64 pseudorandom bytes.

1,048,581 compressed → 1,048,576 restored bytes; compressed / original: 100.000%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
Google C39.639.56–39.6425,253.031.000×
mbrotli15.9915.98–16.0162,531.822.476×
Rust brotli84.3184.1–84.5511,861.190.470×
Burli15.9715.96–15.9862,627.602.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%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
Google C34.5234.5–34.551,810.611.000×
mbrotli8.3158.308–8.3247,516.374.151×
Rust brotli46.246.14–46.281,352.700.747×
Burli8.1028.098–8.1077,713.854.260×

repeated-1m

1 MiB of repeated a bytes.

13 compressed → 1,048,576 restored bytes; compressed / original: 0.001%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
Google C1,2251,225–1,226816.091.000×
mbrotli8.5478.538–8.556117,004.43143.372×
Rust brotli1,2581,256–1,259795.080.974×
Burli8.1998.189–8.209121,971.40149.459×

alice29

Vendored Alice in Wonderland text.

56,863 compressed → 152,089 restored bytes; compressed / original: 37.388%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
Google C233.2233–233.5621.851.000×
mbrotli243.9243.8–244.1594.630.956×
Rust brotli355.8355.6–356.1407.660.656×
Burli278.5278.3–278.6520.850.838×

empty

Empty input; measures API overhead.

1 compressed → 0 restored bytes; compressed / original: undefined.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
Google C0.10980.1096–0.11—1.000×
mbrotli0.020320.01965–0.02106—5.402×
Rust brotli1.731.726–1.735—0.063×
Burli0.015430.01538–0.0155—7.116×

tiny-text

44-byte quick-brown-fox sentence.

46 compressed → 44 restored bytes; compressed / original: 104.545%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
Google C0.76070.7597–0.762155.161.000×
mbrotli0.73690.7351–0.738356.951.032×
Rust brotli3.4633.46–3.46612.120.220×
Burli0.52040.5172–0.52380.641.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.

DecoderMedian speed / C
Google C1.000×
mbrotli3.461×
Rust brotli0.574×
Burli3.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%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
Google C0.16890.1684–0.1695248.431.000×
mbrotli0.022350.02146–0.02331,877.777.558×
Rust brotli1.8131.812–1.81523.140.093×
Burli0.035190.03507–0.035331,192.564.800×

text-1m

Alice text repeated cyclically to 1 MiB.

55,915 compressed → 1,048,576 restored bytes; compressed / original: 5.332%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
Google C655.2640.5–670.31,526.331.000×
mbrotli239.6239.4–239.84,173.382.734×
Rust brotli827.7827.3–828.11,208.190.792×
Burli291.7291.4–2923,428.352.246×

random-64k

64 KiB prefix of the deterministic pseudorandom corpus.

65,540 compressed → 65,536 restored bytes; compressed / original: 100.006%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
Google C2.1862.185–2.18728,590.911.000×
mbrotli0.84250.8419–0.843274,185.892.595×
Rust brotli35.7835.76–35.81,746.730.061×
Burli0.85370.8533–0.854173,211.672.561×

random-1m

1 MiB of deterministic xorshift64 pseudorandom bytes.

1,048,581 compressed → 1,048,576 restored bytes; compressed / original: 100.000%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
Google C40.1840.12–40.2324,890.591.000×
mbrotli15.8815.85–15.962,986.652.531×
Rust brotli90.9890.92–91.0510,991.000.442×
Burli15.7515.73–15.7763,501.842.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%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
Google C34.6634.64–34.681,803.361.000×
mbrotli8.2748.265–8.2847,553.654.189×
Rust brotli46.746.66–46.741,338.460.742×
Burli8.158.145–8.1547,669.114.253×

repeated-1m

1 MiB of repeated a bytes.

13 compressed → 1,048,576 restored bytes; compressed / original: 0.001%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
Google C1,2261,225–1,228815.471.000×
mbrotli8.5078.504–8.51117,554.83144.156×
Rust brotli1,2531,253–1,254797.770.978×
Burli8.1898.178–8.201122,112.05149.744×

alice29

Vendored Alice in Wonderland text.

55,504 compressed → 152,089 restored bytes; compressed / original: 36.494%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
Google C220.1219.6–220.7658.931.000×
mbrotli234.5234.3–234.7618.510.939×
Rust brotli312311.8–312.2464.880.706×
Burli291.8291.4–292.2497.140.754×

empty

Empty input; measures API overhead.

1 compressed → 0 restored bytes; compressed / original: undefined.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
Google C0.10980.1097–0.1099—1.000×
mbrotli0.020260.01958–0.02096—5.419×
Rust brotli1.7281.727–1.73—0.064×
Burli0.015450.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.

DecoderMedian speed / C
Google C1.000×
mbrotli2.572×
Rust brotli0.574×
Burli2.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%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
Google C668.6662.3–675.61,495.771.000×
mbrotli272.5272.2–272.83,669.632.453×
Rust brotli841.7840.9–842.81,188.010.794×
Burli358.6358.4–3592,788.301.864×

random-64k

64 KiB prefix of the deterministic pseudorandom corpus.

65,540 compressed → 65,536 restored bytes; compressed / original: 100.006%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
Google C2.1762.175–2.17728,723.631.000×
mbrotli0.8360.8352–0.836874,764.922.603×
Rust brotli35.8135.79–35.831,745.400.061×
Burli0.84770.8471–0.848473,728.712.567×

random-1m

1 MiB of deterministic xorshift64 pseudorandom bytes.

1,048,581 compressed → 1,048,576 restored bytes; compressed / original: 100.000%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
Google C40.0440.01–40.0624,977.091.000×
mbrotli15.7615.74–15.7863,450.302.540×
Rust brotli91.2191.15–91.2910,963.550.439×
Burli15.7415.72–15.7563,542.422.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%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
Google C34.7934.75–34.841,796.641.000×
mbrotli8.7178.708–8.7287,170.033.991×
Rust brotli47.9447.87–48.031,303.800.726×
Burli8.4088.401–8.4177,433.374.137×

repeated-1m

1 MiB of repeated a bytes.

13 compressed → 1,048,576 restored bytes; compressed / original: 0.001%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
Google C1,2251,224–1,225816.531.000×
mbrotli8.5568.542–8.573116,872.96143.133×
Rust brotli1,2571,256–1,258795.380.974×
Burli8.2098.205–8.212121,824.59149.197×

alice29

Vendored Alice in Wonderland text.

52,809 compressed → 152,089 restored bytes; compressed / original: 34.722%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
Google C207.7207.4–208698.211.000×
mbrotli225.6225.3–225.8643.060.921×
Rust brotli293292.8–293.2495.000.709×
Burli284.2283.9–284.6510.300.731×

empty

Empty input; measures API overhead.

1 compressed → 0 restored bytes; compressed / original: undefined.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
Google C0.1040.1038–0.1042—1.000×
mbrotli0.020270.01958–0.02102—5.129×
Rust brotli1.7271.725–1.729—0.060×
Burli0.015420.01538–0.0155—6.744×

tiny-text

44-byte quick-brown-fox sentence.

48 compressed → 44 restored bytes; compressed / original: 109.091%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
Google C0.70050.7–0.701159.901.000×
mbrotli0.87510.8744–0.875847.950.801×
Rust brotli3.4323.429–3.43512.230.204×
Burli0.58910.5875–0.590771.231.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.

DecoderMedian speed / C
Google C1.000×
mbrotli2.568×
Rust brotli0.569×
Burli2.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%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
Google C687.5678.8–695.71,454.591.000×
mbrotli267.9267.6–268.23,732.562.566×
Rust brotli837.5836.7–838.51,194.010.821×
Burli355.3354.9–355.62,814.861.935×

random-64k

64 KiB prefix of the deterministic pseudorandom corpus.

65,540 compressed → 65,536 restored bytes; compressed / original: 100.006%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
Google C2.1652.164–2.16628,866.351.000×
mbrotli0.84240.8417–0.843174,194.162.570×
Rust brotli35.8835.83–35.931,741.820.060×
Burli0.85360.8532–0.85473,220.642.537×

random-1m

1 MiB of deterministic xorshift64 pseudorandom bytes.

1,048,581 compressed → 1,048,576 restored bytes; compressed / original: 100.000%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
Google C40.0439.99–40.0924,976.271.000×
mbrotli15.6915.67–15.763,744.072.552×
Rust brotli92.1792.08–92.2610,849.920.434×
Burli15.6815.67–15.6963,767.512.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%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
Google C34.6734.65–34.691,802.661.000×
mbrotli8.7518.748–8.7567,141.743.962×
Rust brotli46.4246.36–46.491,346.290.747×
Burli8.448.417–8.4627,405.274.108×

repeated-1m

1 MiB of repeated a bytes.

13 compressed → 1,048,576 restored bytes; compressed / original: 0.001%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
Google C1,2281,226–1,229814.581.000×
mbrotli8.4588.45–8.469118,232.82145.146×
Rust brotli1,2681,266–1,269788.880.968×
Burli8.0548.047–8.061124,165.77152.430×

alice29

Vendored Alice in Wonderland text.

51,967 compressed → 152,089 restored bytes; compressed / original: 34.169%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
Google C202.4201.9–203716.481.000×
mbrotli220.8220.6–221656.860.917×
Rust brotli287.6287.3–287.9504.350.704×
Burli281.1280.9–281.3515.950.720×

empty

Empty input; measures API overhead.

1 compressed → 0 restored bytes; compressed / original: undefined.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
Google C0.10370.1036–0.1038—1.000×
mbrotli0.020230.01954–0.02095—5.126×
Rust brotli1.7351.734–1.737—0.060×
Burli0.015430.01539–0.01551—6.720×

tiny-text

44-byte quick-brown-fox sentence.

48 compressed → 44 restored bytes; compressed / original: 109.091%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
Google C0.69160.6911–0.692360.671.000×
mbrotli0.87310.8722–0.874148.060.792×
Rust brotli3.4363.433–3.44112.210.201×
Burli0.58610.5846–0.587371.601.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.

DecoderMedian speed / C
Google C1.000×
mbrotli2.574×
Rust brotli0.562×
Burli2.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%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
Google C691.9674.7–709.81,445.281.000×
mbrotli268.7268.3–2693,722.262.575×
Rust brotli838.5837.9–839.21,192.540.825×
Burli367366.5–367.42,725.061.885×

random-64k

64 KiB prefix of the deterministic pseudorandom corpus.

65,540 compressed → 65,536 restored bytes; compressed / original: 100.006%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
Google C2.172.168–2.17128,804.191.000×
mbrotli0.84350.8426–0.844474,096.582.572×
Rust brotli35.4435.42–35.471,763.360.061×
Burli0.86510.8648–0.865472,248.232.508×

random-1m

1 MiB of deterministic xorshift64 pseudorandom bytes.

1,048,581 compressed → 1,048,576 restored bytes; compressed / original: 100.000%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
Google C40.2440.17–40.3224,853.211.000×
mbrotli16.0416.02–16.0662,348.512.509×
Rust brotli94.5394.41–94.6310,578.930.426×
Burli16.0116–16.0262,459.592.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%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
Google C34.7334.71–34.751,799.591.000×
mbrotli8.7568.751–8.7617,137.993.966×
Rust brotli46.4446.38–46.511,345.730.748×
Burli8.4538.437–8.477,394.004.109×

repeated-1m

1 MiB of repeated a bytes.

13 compressed → 1,048,576 restored bytes; compressed / original: 0.001%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
Google C1,2271,226–1,229814.871.000×
mbrotli8.4868.476–8.497117,840.80144.613×
Rust brotli1,2631,262–1,264791.840.972×
Burli8.0478.044–8.051124,265.58152.498×

alice29

Vendored Alice in Wonderland text.

51,451 compressed → 152,089 restored bytes; compressed / original: 33.830%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
Google C198.4197.9–199731.031.000×
mbrotli214.6214.4–214.9675.810.924×
Rust brotli283.9283.7–284.1510.840.699×
Burli278.2277.9–278.5521.410.713×

empty

Empty input; measures API overhead.

1 compressed → 0 restored bytes; compressed / original: undefined.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
Google C0.11270.1126–0.1128—1.000×
mbrotli0.020220.01954–0.02093—5.573×
Rust brotli1.7361.734–1.738—0.065×
Burli0.015440.01539–0.01552—7.300×

tiny-text

44-byte quick-brown-fox sentence.

48 compressed → 44 restored bytes; compressed / original: 109.091%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
Google C0.6950.6942–0.695860.381.000×
mbrotli0.87470.8736–0.875947.970.795×
Rust brotli3.4353.43–3.4412.220.202×
Burli0.58480.5838–0.585771.751.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.

DecoderMedian speed / C
Google C1.000×
mbrotli2.547×
Rust brotli0.572×
Burli2.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%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
Google C647.9646.8–649.51,543.521.000×
mbrotli268.7268.4–2693,721.352.411×
Rust brotli838.8838–8401,192.110.772×
Burli364.5364.2–364.92,743.191.777×

random-64k

64 KiB prefix of the deterministic pseudorandom corpus.

65,540 compressed → 65,536 restored bytes; compressed / original: 100.006%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
Google C2.212.208–2.21228,276.441.000×
mbrotli0.85970.8591–0.860372,700.102.571×
Rust brotli35.4535.43–35.481,762.840.062×
Burli0.8670.8666–0.867672,085.132.549×

random-1m

1 MiB of deterministic xorshift64 pseudorandom bytes.

1,048,581 compressed → 1,048,576 restored bytes; compressed / original: 100.000%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
Google C40.0139.98–40.0624,991.581.000×
mbrotli15.8715.84–15.8963,031.532.522×
Rust brotli92.4892.24–92.7210,813.620.433×
Burli15.915.89–15.9262,876.702.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%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
Google C34.6834.62–34.791,802.121.000×
mbrotli8.6598.655–8.6627,218.304.005×
Rust brotli46.8646.83–46.891,333.680.740×
Burli8.4278.4–8.4557,416.644.116×

repeated-1m

1 MiB of repeated a bytes.

13 compressed → 1,048,576 restored bytes; compressed / original: 0.001%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
Google C1,2271,225–1,229815.201.000×
mbrotli8.4618.453–8.47118,187.86144.980×
Rust brotli1,2701,268–1,273787.340.966×
Burli8.0718.067–8.075123,898.16151.984×

alice29

Vendored Alice in Wonderland text.

51,187 compressed → 152,089 restored bytes; compressed / original: 33.656%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
Google C200.7200.5–200.9722.571.000×
mbrotli233.4233.2–233.6621.530.860×
Rust brotli282.4281.9–282.8513.700.711×
Burli286.7286.4–287.1505.880.700×

empty

Empty input; measures API overhead.

1 compressed → 0 restored bytes; compressed / original: undefined.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
Google C0.110.1098–0.1102—1.000×
mbrotli0.020270.0196–0.02098—5.426×
Rust brotli1.7111.709–1.713—0.064×
Burli0.015430.01538–0.01551—7.130×

tiny-text

44-byte quick-brown-fox sentence.

48 compressed → 44 restored bytes; compressed / original: 109.091%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
Google C0.69260.6921–0.693160.591.000×
mbrotli0.90570.9049–0.906646.330.765×
Rust brotli3.543.536–3.54311.850.196×
Burli0.58690.5854–0.588371.491.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.

DecoderMedian speed / C
Google C1.000×
mbrotli2.553×
Rust brotli0.569×
Burli2.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%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
Google C652.6652.2–653.11,532.261.000×
mbrotli271.6269.4–275.33,681.692.403×
Rust brotli860.4859.4–861.81,162.200.758×
Burli373.8373.3–374.22,675.391.746×

random-64k

64 KiB prefix of the deterministic pseudorandom corpus.

65,540 compressed → 65,536 restored bytes; compressed / original: 100.006%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
Google C2.2192.21–2.23128,163.351.000×
mbrotli0.8580.8577–0.858372,842.622.586×
Rust brotli35.4335.4–35.461,764.110.063×
Burli0.86490.8642–0.865672,266.702.566×

random-1m

1 MiB of deterministic xorshift64 pseudorandom bytes.

1,048,581 compressed → 1,048,576 restored bytes; compressed / original: 100.000%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
Google C40.0740.04–40.124,959.001.000×
mbrotli15.915.9–15.9162,877.122.519×
Rust brotli92.2492.16–92.3410,841.680.434×
Burli15.9815.95–16.0362,569.632.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%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
Google C34.4534.43–34.481,814.241.000×
mbrotli8.3858.376–8.3947,454.194.109×
Rust brotli46.6446.61–46.671,340.020.739×
Burli8.1098.096–8.1217,707.054.248×

repeated-1m

1 MiB of repeated a bytes.

13 compressed → 1,048,576 restored bytes; compressed / original: 0.001%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
Google C1,2261,225–1,227815.751.000×
mbrotli8.4658.459–8.473118,128.32144.809×
Rust brotli1,2691,265–1,274788.310.966×
Burli8.058.043–8.057124,226.86152.285×

alice29

Vendored Alice in Wonderland text.

51,054 compressed → 152,089 restored bytes; compressed / original: 33.569%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
Google C200.5200.4–200.7723.341.000×
mbrotli214.9214.7–215.1674.870.933×
Rust brotli284.6284.4–284.9509.590.704×
Burli287.9287.6–288.1503.870.697×

empty

Empty input; measures API overhead.

1 compressed → 0 restored bytes; compressed / original: undefined.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
Google C0.11290.1128–0.1131—1.000×
mbrotli0.020250.01955–0.02097—5.575×
Rust brotli1.7091.708–1.71—0.066×
Burli0.015450.0154–0.01553—7.309×

tiny-text

44-byte quick-brown-fox sentence.

48 compressed → 44 restored bytes; compressed / original: 109.091%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
Google C0.79980.7984–0.801352.471.000×
mbrotli0.90370.9031–0.904346.430.885×
Rust brotli3.5113.507–3.51511.950.228×
Burli0.58560.5846–0.586571.651.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.

DecoderMedian speed / C
Google C1.000×
mbrotli2.770×
Rust brotli0.581×
Burli2.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%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
Google C0.19080.1901–0.1916219.931.000×
mbrotli0.022480.02164–0.023341,866.718.488×
Rust brotli1.8421.84–1.84422.780.104×
Burli0.035160.03497–0.035381,193.445.426×

text-1m

Alice text repeated cyclically to 1 MiB.

47,492 compressed → 1,048,576 restored bytes; compressed / original: 4.529%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
Google C707.4706.4–708.61,413.561.000×
mbrotli315.1314.8–315.33,173.842.245×
Rust brotli909.5908.9–910.11,099.520.778×
Burli442441.5–442.62,262.291.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%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
Google C37.8537.81–37.881,651.391.000×
mbrotli12.6612.65–12.664,937.502.990×
Rust brotli51.751.61–51.841,208.950.732×
Burli13.8413.83–13.864,514.472.734×

random-64k

64 KiB prefix of the deterministic pseudorandom corpus.

65,540 compressed → 65,536 restored bytes; compressed / original: 100.006%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
Google C2.212.208–2.21228,278.341.000×
mbrotli0.86660.866–0.867372,121.072.550×
Rust brotli35.4535.42–35.491,763.080.062×
Burli0.87380.8733–0.874371,527.362.529×

alice29

Vendored Alice in Wonderland text.

47,477 compressed → 152,089 restored bytes; compressed / original: 31.217%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
Google C283.2282.6–283.7512.231.000×
mbrotli281280.7–281.2516.171.008×
Rust brotli378.1377.8–378.5383.570.749×
Burli423422.5–423.6342.900.669×

random-1m

1 MiB of deterministic xorshift64 pseudorandom bytes.

1,048,581 compressed → 1,048,576 restored bytes; compressed / original: 100.000%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
Google C40.1140.08–40.1524,929.991.000×
mbrotli15.9215.9–15.9662,805.312.519×
Rust brotli93.4593.33–93.5910,701.030.429×
Burli15.8915.88–15.9162,921.462.524×

repeated-1m

1 MiB of repeated a bytes.

14 compressed → 1,048,576 restored bytes; compressed / original: 0.001%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
Google C1,2271,226–1,228815.001.000×
mbrotli8.9558.948–8.963111,664.27137.011×
Rust brotli1,2641,262–1,267791.110.971×
Burli8.0628.052–8.074124,040.42152.197×

empty

Empty input; measures API overhead.

1 compressed → 0 restored bytes; compressed / original: undefined.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
Google C0.11310.113–0.1133—1.000×
mbrotli0.020250.01956–0.02096—5.588×
Rust brotli1.7111.709–1.712—0.066×
Burli0.015430.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.

DecoderMedian speed / C
Google C1.000×
mbrotli2.847×
Rust brotli0.584×
Burli2.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%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
Google C0.19590.1952–0.1968214.181.000×
mbrotli0.023310.02255–0.02411,800.108.405×
Rust brotli1.8531.852–1.85522.640.106×
Burli0.035060.03493–0.035211,196.795.588×

text-1m

Alice text repeated cyclically to 1 MiB.

46,491 compressed → 1,048,576 restored bytes; compressed / original: 4.434%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
Google C665.7662.9–6691,502.221.000×
mbrotli273272.6–273.63,663.142.438×
Rust brotli850.5849.8–851.11,175.840.783×
Burli385.3384.9–385.62,595.331.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%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
Google C37.8437.81–37.871,651.781.000×
mbrotli12.1812.18–12.195,129.383.105×
Rust brotli51.3851.34–51.421,216.370.736×
Burli13.713.69–13.714,562.062.762×

random-64k

64 KiB prefix of the deterministic pseudorandom corpus.

65,540 compressed → 65,536 restored bytes; compressed / original: 100.006%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
Google C2.2072.206–2.20828,319.231.000×
mbrotli0.85250.8521–0.852973,314.542.589×
Rust brotli35.6435.48–35.891,753.460.062×
Burli0.86110.8607–0.861472,584.432.563×

random-1m

1 MiB of deterministic xorshift64 pseudorandom bytes.

1,048,581 compressed → 1,048,576 restored bytes; compressed / original: 100.000%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
Google C40.1740.14–40.2224,895.161.000×
mbrotli16.0316–16.0662,396.032.506×
Rust brotli92.3592.2–92.5210,828.280.435×
Burli1615.99–16.0162,489.752.510×

alice29

Vendored Alice in Wonderland text.

46,487 compressed → 152,089 restored bytes; compressed / original: 30.566%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
Google C239.2238.5–239.9606.351.000×
mbrotli248.2247.8–248.5584.490.964×
Rust brotli326.5326.3–326.9444.170.733×
Burli367.9367.5–368.3394.250.650×

repeated-1m

1 MiB of repeated a bytes.

14 compressed → 1,048,576 restored bytes; compressed / original: 0.001%.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
Google C1,2261,225–1,227815.691.000×
mbrotli8.9478.936–8.958111,771.06137.027×
Rust brotli1,2631,262–1,265791.610.970×
Burli8.0438.035–8.053124,330.40152.424×

empty

Empty input; measures API overhead.

1 compressed → 0 restored bytes; compressed / original: undefined.

DecoderMean µs95% mean interval µsDecoded MiB/sSpeed / C
Google C0.11330.1131–0.1135—1.000×
mbrotli0.020260.01958–0.02098—5.589×
Rust brotli1.7121.71–1.715—0.066×
Burli0.015440.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 qualityGoogle CmbrotliRust brotliBurlimbrotli / Burli
q01.000×3.059×0.446×2.873×1.036×
q11.000×3.196×0.445×3.129×1.020×
q21.000×3.325×0.570×3.362×1.056×
q31.000×2.548×0.563×2.536×0.986×
q41.000×3.461×0.574×3.407×1.003×
q51.000×2.572×0.574×2.555×0.982×
q61.000×2.568×0.569×2.545×0.982×
q71.000×2.574×0.562×2.511×0.982×
q81.000×2.547×0.572×2.533×0.988×
q91.000×2.553×0.569×2.536×0.986×
q101.000×2.770×0.581×2.632×1.051×
q111.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.

QualityGoogle CmbrotliRust brotliSIMD BrotliBurlimbrotli output / C
q01.000×1.349×0.090×0.100×2.011×1.000×
q11.000×1.123×0.107×0.141×0.825×1.000×
q21.000×1.020×0.355×0.336×0.793×1.000×
q31.000×1.089×0.409×0.401×0.952×1.000×
q41.000×1.183×0.529×0.488×1.201×1.000×
q51.000×1.017×0.442×0.355×0.942×1.000×
q61.000×1.008×0.404×0.344×—1.000×
q71.000×1.004×0.360×0.331×—1.000×
q81.000×1.022×0.291×0.285×—1.000×
q91.000×4.323×0.828×1.681×—1.000×
q101.000×1.243×0.839×0.899×—1.000×
q111.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 default RUST_TARGET_CPU=native, so both sides target the same CPU.
  • AMD Ryzen 9 7950X VDS (2 vCPU, KVM), Linux 7.0, default CPU governor and boost. The 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 nameCompressionDecompress.Compr. sizeRatioFilename
brotli 1.2.0 -0531 MB/s452 MB/s7846682937.02../silesia.tar
mbrotli 0.5.20 -0549 MB/s585 MB/s7846682937.02../silesia.tar
brotli 1.2.0 -2197 MB/s542 MB/s6807063432.12../silesia.tar
mbrotli 0.5.20 -2186 MB/s703 MB/s6807063432.12../silesia.tar
brotli 1.2.0 -571.1 MB/s601 MB/s5956458128.10../silesia.tar
mbrotli 0.5.20 -578.2 MB/s788 MB/s5956458128.10../silesia.tar
brotli 1.2.0 -815.5 MB/s662 MB/s5714984026.96../silesia.tar
mbrotli 0.5.20 -821.8 MB/s862 MB/s5714984026.96../silesia.tar
brotli 1.2.0 -110.80 MB/s587 MB/s5048836023.82../silesia.tar
mbrotli 0.5.20 -111.23 MB/s713 MB/s5050392923.83../silesia.tar

Repeatability

A second run of qualities 2 and 5 right after the sweep measured:

Compressor nameCompressionDecompress.
brotli 1.2.0 -2204 MB/s539 MB/s
mbrotli 0.5.20 -2203 MB/s706 MB/s
brotli 1.2.0 -577.2 MB/s605 MB/s
mbrotli 0.5.20 -583.3 MB/s793 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 understandRead, in order
How a compress call becomes bytesCompressor → the encoder for your quality (fast, greedy, high-quality) → Bit output
How a stream is decodedNative decompressor → Decoder SIMD → Owned decoder output
Why memory is reused between callsEncoder workspace → Uninitialized output
How one input is split across threadsParallel compression → Parallel streaming sessions
How containers and dictionaries workShared Brotli → Framed compressor → Framed decoder
How correctness is establishedDecoder verification → Fuzzing → Formal verification
Which features compile which codeCodec 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.

SpecificationScope
Codec featuresCodec gates, shared types and isolated consumer checks.
No standard libraryAlloc-backed APIs, feature precedence and compile-time SIMD.
Shared primitivesCommon data, transforms, fixed boxed tables, the baseline SIMD level, prefetch and mask scans.
Uninitialized outputWrite-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.

SpecificationScope
CompressorPublic API, sessions, size-hint inference, I/O, dispatch and errors.
Encoder workspaceRetained output backing, reset, parked match-finder tables, accounting and backpressure.
Bit outputDestinations, command-prefix lookup, literal tables, direct Vec appends, bit operations and overflow.
Serial output identityShared scheduling, empty finalization and C compatibility.
Fast encoderQualities 0–1: fragments, direct encoding, entropy coding, prefix-code builders and SIMD scans.
Greedy encoderQualities 2–9: hash tables and their growth, SIMD range hashing, match scoring, distance-cache prefilter and meta-blocks.
High-quality encoderQualities 10–11: tree search, SIMD distance-cache and dictionary filters, command pricing and clustering.
Owned sessionsLifetime-free raw and framed sessions, reinit and input-free flush/finish.
Parallel compressionPlanning, encoder reconfiguration, caller-run tasks, staging and assembly.
Parallel streaming sessionsChunked 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.

SpecificationScope
Native decompressorParsing, sessions, history, the single-exit command loop, dictionaries, budgets and I/O.
Owned decoder outputStored members, allocation transfer, linear slice history and read-ahead accounting.
Literal and stored-header decodingLiteral batches, per-context roots, byte-exact literal runs and stored-member recognition.
Decoder SIMDCommand-loop dispatch and bounded fixed-size history copies.
Decoder verificationC/RFC oracles, feature profiles and test limitations.
Framed decoderStructured containers, detection, events, dictionaries, validation, budgets and lending reader.
Framed seek readerFooter/directory indexing, lazy metadata, sparse dependency decoding and resource Read.

Extended formats

Large Window, shared dictionaries and the experimental framing container.

SpecificationScope
Shared BrotliLarge Window declarations and prepared prefix dictionaries.
Serialized dictionariesExperimental wire format, transforms and resource limits.
Custom encoding and continuationsStatic indexes, contexts and headerless stream offsets.
Framed compressorReusable owner, structured input, sessions, std adapters, metadata and wire compatibility.

Language bindings

SpecificationScope
C ABImbrotli-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.

SpecificationScope
Implementation comparisonIsolated library comparisons, validation, provenance and charts.
FuzzingTarget inputs, oracles, campaigns, regression replay and the libFuzzer adapters.
Continuous integrationTriggers, checks, Miri and AddressSanitizer, fuzz campaigns, coverage gating and release publishing.
Documentation publishingSource staging, navigation, section pages, recipe execution, link validation and Pages deployment.
Formal verificationCharon/Aeneas translation to Lean 4, function specifications, round-trip theorems and the trusted base.

Common boundaries

Feature gates and primitives that both codecs share.

SpecificationScope
Codec featuresCodec gates, shared types and isolated consumer checks.
No standard libraryAlloc-backed APIs, feature precedence and compile-time SIMD.
Shared primitivesCommon data, transforms, fixed boxed tables, the baseline SIMD level, prefetch and mask scans.
Uninitialized outputWrite-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.

FeaturePublic surface
compressioncompressor, encoder configuration/errors/sessions, prepared and experimental serialized dictionaries, encoder I/O, parallel compression and experimental framing
decompressiondecompressor, decoder configuration/errors/sessions, decode-only dictionaries and borrowed decoding views, decoder I/O
Either codecBackend, RetentionPolicy, Window, WindowEncoding, ConfigError, and the dictionary facade
Either codec without no_stdio facade and its shared FinishError
Neither codecNo 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

InitializedUninitializedOwner
EncoderSession::processEncoderSession::process_uninitcompressor::session
EncoderSessionOwned::processEncoderSessionOwned::process_uninitcompressor::session
ParallelEncoderSession::processParallelEncoderSession::process_uninitcompressor::parallel::session
ParallelEncoderSessionOwned::processParallelEncoderSessionOwned::process_uninitcompressor::parallel::session
Compressor::compress_to_sliceCompressor::compress_to_uninitcompressor::encoder
DecoderSession::processDecoderSession::process_uninitdecompressor::session
DecoderSessionOwned::processDecoderSessionOwned::process_uninitdecompressor::session
Decompressor::decompress_to_sliceDecompressor::decompress_to_uninitdecompressor::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 Destination variants once per call or per helper invocation, never inside the SIMD copy kernels. The parallel session is monomorphized per Sink type, as the decoder is. The decoder does not choose at run time at all: it is monomorphized per Sink type, so the initialized decode loop carries no uninitialized-output code.

Verification

  • tests/encode_uninit.rs: process_uninit and mixed calls against process for 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 past produced, owned sessions, and compress_to_uninit against compress_to_slice including short and empty destinations.
  • tests/decode_uninit.rs: process_uninit against process across qualities, input chunks and output windows, a corrupt stream’s failure progress, owned sessions, and decompress_to_uninit against decompress_to_slice for success, OutputTooSmall, trailing and truncated input; outputs many zeroing steps long, OutputTooSmall at every step boundary, concatenated members, output limits and corrupt input, each against decompress_to_slice with sentinels past the result.
  • tests/parallel_stream.rs: process_uninit and 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_session and decoder_session drive borrowed and owned sessions with process and process_uninit mixed call by call, check that nothing is written past produced, and restart owned sessions with reinit from every state; streaming_equivalence and output_capacity compare compress_to_uninit with the initialized one-shots, and decompress compares decompress_to_uninit with ring delivery; parallel alternates process and process_uninit on its streamed session under the same sentinel check and batch-identity oracle.
  • Unit tests in shared::uninit (including zero_extend), resumable Delivery::Linear in decompressor::core::session, and the mbrotli-ffi core tests, which run on uninitialized destinations.

Known gaps

  • q0/q1 process_uninit and compress_to_uninit copy each block once from scratch; the initialized methods write it in place when the destination has room for a whole fragment.
  • decompress_to_uninit writes every output byte twice, zero first: 1.2% slower than decompress_to_slice into 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_uninit one more OperationState::process call. On 1 KiB outputs this leaves it about 7% behind zeroing the whole destination; the C ABI does that below 1 KiB.
  • process_uninit still delivers through the ring.
  • No uninitialized variants for dictionary one-shots or framed sessions.
  • A program that calls process_uninit links a second instantiation of the decode loop: about 106 KiB more .text on x86_64. decompress_to_uninit runs the initialized instantiation and does not pay it.
  • In a program that links both decompress_to_slice and decompress_to_uninit, OperationState::process has two one-shot callers and is no longer inlined into decompress_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.

SpecificationScope
CompressorPublic API, sessions, size-hint inference, I/O, dispatch and errors.
Encoder workspaceRetained output backing, reset, parked match-finder tables, accounting and backpressure.
Bit outputDestinations, command-prefix lookup, literal tables, direct Vec appends, bit operations and overflow.
Serial output identityShared scheduling, empty finalization and C compatibility.
Fast encoderQualities 0–1: fragments, direct encoding, entropy coding, prefix-code builders and SIMD scans.
Greedy encoderQualities 2–9: hash tables and their growth, SIMD range hashing, match scoring, distance-cache prefilter and meta-blocks.
High-quality encoderQualities 10–11: tree search, SIMD distance-cache and dictionary filters, command pricing and clustering.
Owned sessionsLifetime-free raw and framed sessions, reinit and input-free flush/finish.
Parallel compressionPlanning, encoder reconfiguration, caller-run tasks, staging and assembly.
Parallel streaming sessionsChunked 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:

FamilyCompatibility key
FastQuality; the fragment limit and stream header follow the new window
GreedyQuality, 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
HQResolved 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&#58;&#58;Exact
    [*] --> Open: InputSize&#58;&#58;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:

ParkedFromTaken byWhy no zeroing is needed
Dense positions and tagsa bucket finder’s dense layout, or the storage it had not used yetthe 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 slotsa chain finderthe 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:

OraclePurpose
C streaming FINISH, same settingsCanonical byte differential for Rust one-shot and exact-size sessions
C streaming with matching flush boundariesFlush semantics and dictionary continuation checks
Native C one-shotExplicit regression demonstrating intentional API-specific differences
C decoderIndependent 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&#58;&#58;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 past ip after comparing, and as both tests are pure the port tests repeated < ip first, 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_match on 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 - 1 and ip, taken from a single word load;
  • ShouldMergeBlock uses stride 43 and the current literal depths;
  • the final size guard rewrites the whole fragment verbatim when the compressed form exceeds 31 + 8 * len bits.

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:

FieldWidthSource
ISLAST1always 0 for a data block
MNIBBLES - 424, 5 or 6 nibbles by length
MLEN - 116, 20 or 24block length
ISUNCOMPRESSED10 for a compressed block
block splits / contexts13always zero on the fast path
literal prefix codevariablebuild_and_store_huffman_tree_fast
command prefix codevariablefull 704 symbol alphabet
distance prefix codevariable64 symbols
commands, extras, literalsvariablethe 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::&lt;true&gt; 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::&lt;false&gt;"]
    I --> M["merge_nodes, set_depth"]
    B --> M
    F --> M
    M -->|"too deep: count_limit *= 2"| H
  • create_huffman_tree breaks ties by descending symbol, so its order is total and any sort that reaches it gives the reference’s bytes. sort_leaves reaches 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, bucket b spans rank[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_fast compares counts only, as the reference’s literal builder does. Shell sort is not stable, so its order among equal counts is whatever SortHuffmanTreeItems leaves, 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&lt;INDEPENDENT&gt;::encode_block / encode_block_append"]
    A --> kernel["dispatch!(backend) once per block"]
    kernel -->|"#[simd] entry, no re-selection"| B["encode_fragment&lt;S: Simd, INDEPENDENT&gt;"]
    B --> C["q0::compress_fragment&lt;S&gt;"]
    B --> D["q1::compress_fragment&lt;S&gt;"]
    C --> E0["match_len_at: first word settled scalar"]
    D --> E0
    E0 -->|"first word equal"| E["match_len_windows&lt;S&gt; 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&lt;S, TABLE_BITS, ENTRIES&gt;(table: &amp;mut [i32; ENTRIES])"]
    G --> I["compress_fragment_impl&lt;S, TABLE_BITS, ENTRIES, MIN_MATCH&gt;(table: &amp;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:

LoopShape
match_len_wordsas_chunks::<8> over both windows, zipped
match_len_vectors_*as_chunks::<LANES> plus load_array_ref
match_len_byteszip + take_while over the tails
histogram::accumulateas_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 PreparedDictionary is 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 holds ip + 16 inside 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.
  • ShouldMergeBlock and ShouldCompress use floating point. They reproduce the reference’s double arithmetic, including its single-precision log2 table, because the decisions are observable in the output.
  • The command prefix code carries stale depths for symbols with a zero count. create_huffman_tree writes 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:

  1. Input is copied into a ring buffer that keeps the whole sliding window, so a match may reach back past the current block.
  2. 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.
  3. Commands accumulate until a meta-block is worth emitting.
  4. 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

Featureq2q3q4q5q6q7q8q9
Default lgblock14141616161616min(18, lgwin)
Block splittingnonoyesyesyesyesyesyes
Non-zero distance parametersnonoyesyesyesyesyesyes
Histogram optimisationnonoyesyesyesyesyesyes
Extensive delayed searchnononoyesyesyesyesyes
Literal context modellingnononoyesyesyesyesyes
Three-context model eligiblenononononoyesyesyes
Large window allowednoyesyesyesyesyesyesyes
Attached prefix consultednononoyesyesyesyesyes
Sparse-search threshold64646464646464512
Bucket candidates———163264128256
Cached distances probed44444101016
Small-window matcher———H40H40H41H41H42
Chain hops———163256112224
Meta-block storagefasttrivialgreedy splitgreedy splitgreedy splitgreedy splitgreedy splitgreedy 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.

PlanHash bytesBucket bitsSlots per bucketStatic dictionary
H25161 sweep slotyes, shallow
H35161 sweep slotno
H45174 sweep slotsyes, shallow
H547204 sweep slotsno
H40 / H41415forgetful chain, one 65,536-slot bankyes
H42415forgetful chain, 512 banks of 512yes
H5414 (q5, q6) or 151 << (quality - 1)yes
H68151 << (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_SIZE entries, 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 when best_len changes;
  • both sides are read from MatchQuery::window, the ring buffer cut to the mask, so at < window.len() is both the reference’s at <= ring_buffer_mask test 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 HashBytes keeps 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:

LayoutChosen whenIndexBlocksCost of a new stream
Compactone-shot input of at most 1024 bytes while the matcher is still compactKeyMap, sized two entries per input byte, probed once per position: counter and chain head per bucketone chain: Vec<u64> of `positionnext << 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
Sparsea known input below the dense limit; an input of unknown length on a deep shape; unless the dense table already existsa boxed [u64; BUCKETS]: generation stamp, block index with a starter flag, countertyped 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 shapesbump the generation
Denseon 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] countersone flat Box<[u32; SLOTS]> with SLOTS = BUCKETS * BLOCK, viewed as [[u32; BLOCK]; BUCKETS] for a run, zeroed once per matcherzero 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:

MatcherRun types
QuickMatcherQuickRun<&mut [u32; BUCKETS]> over the table, or QuickRun<&mut SmallSlots> over the map
BucketMatcherDenseRun over three array references; CompactRun over compact storage; SparseRun over sparse storage plus the current size hint
ChainMatcherthe 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&#58;&#58;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 0x2FFF literals 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 tokenHash/depthYoung scan permitted
AVX2H5 or H6, N64/N128yes
AVX-512H6 wide hash, N128yes
AVX-512H5, or H6 with N64no: general rebased scan
SSE2, SSE4.2, Neon, Wasm SIMD or scalar fallbackanyno: 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 + gap rather than dictionary_start, which pushes the built-in dictionary past the attached one;
  • SharedContextInner::find_match runs after the ordinary search at each position and may replace its result, using dictionary_start itself as the boundary between window and prefix;
  • compute_distance_code and the distance-cache update both compare against dictionary_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"]
StreamMinimum blockSplit thresholdAlphabet measured
literals512400256
commands1024500704
distances51210064

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 &lt;&lt; rb_bits bytes<br/>the sliding window"]
        tail["1 &lt;&lt; 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_start records that skipped range, preserving its first-byte sentinel 241 until 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&lt;INDEPENDENT&gt; retains the Backend"]
    D -->|"dispatch!(backend) once per block"| E["select concrete matcher and run once per block"]
    E --> V["SearchLoop::visit&lt;R&gt;"]
    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 targetWhat 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_matchesRebased/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_matchesYoung/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_boundariesWhole 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.rsByte 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.rsByte identity over the shared corpora, including Google’s own multi-megabyte test data.
tests/simd_backends.rsByte identity between the scalar fallback and every SIMD backend the host supports.
tests/streaming.rsChunk-size independence and one-shot equivalence.
tests/shared_dictionary.rsByte 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::large reaches these qualities, so a large window is declared and the widened distance alphabet is used, but ResolvedWindow::encoder_bits caps retained history at 30 bits and every matcher is sized from that. The reference’s H35, H55 and H65 composite 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, H55 and H65 only, so H2, H3, H4 and H54 have 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:

  1. Input is copied into the same ring buffer the greedy path uses.
  2. A binary-tree matcher reports every match at a position, ordered by increasing length, rather than a single best one.
  3. A cost model puts an f32 price on every literal, command symbol and distance symbol.
  4. 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.
  5. 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.
  6. 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.

Behaviorq10q11Reference
MatcherH10H10ChooseHasher
Matches heldone position at a timeevery position, up frontBrotliCreate*ZopfliBackwardReferences
Cost-model passes12same
Start positions expanded15MaxZopfliCandidates
Distinct copy-length cap150325MaxZopfliLen
Short backward scan16 positions64 positionsFindAllMatches
Splitter refinement passes310SplitByteVector
Default lgblockmin(18, lgwin) above 16sameComputeLgBlock

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:

  1. 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.
  2. 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_PROBES packs kDistanceCacheIndex and kDistanceCacheOffset into 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 private compressor::core::cache_probe module 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 one u8x16 in lane order (LANE_CODES); one simd_eq against the continuation, turned into code order by two 256-entry tables (LANE_CODE_BITS), and one u32x16 range test over the sixteen distances derived_distances builds 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 for prefix_copy_length to decide. When a copy extends best_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. On alice29.txt this cut quality eleven from 1.479 to 1.156 billion instructions and mapsdatazrh from 3.396 to 2.743 billion (callgrind, one compression), with identical output.
  • backward.wrapping_sub(1) < max_distance admits exactly the distances the reference’s three tests admit — backward > dictionary_start + gap, backward <= max_distance, and prev_ix >= cur_ix — because a zero distance wraps below the bound, a negative one wraps above it, and max_distance is never more than dictionary_start. Everything else goes to the cold prefix_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 one get. 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 + len and calling ZopfliNode::record on each node, instead of indexing nodes[pos + l] per length; the tree-match loop does the same over pos + 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
  • ZopfliCostModel rearranges 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 f32 bits. No distance or extra-bit price is folded into them: additions keep their original order. The implicit last-distance command condition remains exactly code == 0, insert code below eight, and copy code below sixteen. Tests compare every legal length-code pair across both model initializers.

  • FastLog2 returns a double in the reference and is narrowed to f32 at 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 f32 sum 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 the LiteralCostArena otherwise. Every read lies inside pos..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 * 2000 bytes, so every such count is below LOG2_WINDOW_END (4001). Counts below 256 read the shared LOG2_TABLE; counts from 256 read a Box<[f64; 3745]> tail that LiteralCostArena builds 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 from shared::fast_log::log2, the library logarithm fast_log2 itself 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, computes log2 instead. Every path returns the same f64 the library call would, so nothing rounds differently; tests compare the tail bit for bit with fast_log2 and 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

LayerHow it is checked
Static dictionary all-match searchEvery 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 prefiltercache_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
H10Brute-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 searchCommand stream, distance cache, literal count and trailing literals compared against BrotliCreate{,Hq}ZopfliBackwardReferences through the shim, over nine fixtures and 343 prefixes
Block splitterAll three partitions compared against BrotliSplitBlock, six fixtures × both qualities
Meta-block builderDistance alphabet, three splits, histogram counts and both context maps compared against BrotliBuildMetaBlock, five fixtures × two qualities × two context modes × modelling on and off
Whole encoderByte-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 of bb.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:

  1. Collection. After the binary tree has produced a position’s matches, SharedContextInner::find_all_matches collects up to sixty-four more from the attachments, from a minimum length of three, and merge_prefix_matches merges the two ascending-by-length sequences into one — MergeMatches. The dynamic program then prices them together, indistinguishably.
  2. Cached distances. update_nodes gains the branch that the reference’s comment calls the way out of the “gray area”: a cached distance past max_distance but at most dictionary_start is unusable, while one past dictionary_start addresses the prefix and is measured against it. That branch lives in prefix_copy_length, the cold arm the probe loop takes for every distance outside 1..=max_distance. Without an attachment gap is zero, the prefix path is unreachable, and the arm only skips.
  3. Coding. gap shifts dictionary_start everywhere a distance is classified, so create_commands marks a prefix reference as a dictionary reference and leaves the distance cache alone, and evaluate_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::&lt;S, false, I&gt;"]
    q -->|"Q10, some"| b["create_zopfli_backward_references::&lt;S, true, I&gt;"]
    q -->|"Q11, none"| c["create_hq_zopfli_backward_references::&lt;S, false, I&gt;"]
    q -->|"Q11, some"| d["create_hq_zopfli_backward_references::&lt;S, true, I&gt;"]

Known gaps

  • Large-window history stops at 30 bits. Window::large reaches these qualities and DistanceParams::for_window computes the widened RFC 9841 alphabet and its alphabet_size_limit, but retained history is capped at 30 bits by ResolvedWindow::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::split and the histogram helpers it calls (Histogram::add_histogram, block histogram rebuilds) sit outside every #[simd] region, so on x86-64 their autovectorized loops use SSE2 xmm registers even on AVX2 hosts (about 1.6% of quality 10 on mapsdatazrh). Only the block-assignment pass enters the selected backend. Building the whole program for x86-64-v3 sped qualities 10 and 11 up by at most 2% (i7-13700KF, 2026-10-08), so moving them is not pursued.
  • hotpath instrumentation. Encoder block operations, Zopfli search passes, literal-cost estimation, and find_blocks have 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 OperationState holds 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 into decompressor::core::session::OperationState, and DecoderSession became a thin wrapper over it. The encoder already had this split.
  • Owned dictionaries. The encoder takes any D: AsRef<PreparedDictionary> + 'static and the decoder any D: AsRef<DecodeDictionary> + 'static. Both dictionary types implement AsRef<Self>, so a dictionary can be passed by value, as an Arc, or as a &'static reference. The decoder bound names DecodeDictionary because PreparedDictionary does not exist in decode-only builds.
  • The default type parameter. D defaults to the dictionary type itself. into_session stores None, so the default is never constructed.
  • No Drop impl on owned sessions. Dropping one drops its codec with it. Without Drop, into_compressor and into_decompressor can destructure the session in safe Rust.
  • Auto traits. Owned sessions are Send exactly when D is. They add no Sync bound 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::begin checks for an abandoned session, the stream offset, dictionary support at the configured quality, the stream position, and workspace acquisition.
  • Decoder. OperationState::start checks 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 process is a one-line delegation to the same OperationState::process the borrowed session calls.
  • Same dictionary on every call. The decoder state passes the dictionary to Stream::run per call. Each wrapper always passes the dictionary it started with.
  • Safe Rust throughout. No unsafe, raw pointers, Pin or self-references. The only extra storage is the owned D itself.
  • 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.

Sessionflush(output)finish(output)
EncoderSession, EncoderSessionOwnedprocess(&[], output, Operation::Flush)process(&[], output, Operation::Finish)
FramedResourceSessionprocess(&[], output, Operation::Flush)process(&[], output, Operation::Finish)
FramedEncoderSession, FramedEncoderSessionOwnedprocess(output, FramedEncodeOperation::Process): drain queued wire bytesprocess(output, FramedEncodeOperation::Finish)
DecoderSession, DecoderSessionOwnedprocess(&[], output, DecodeOperation::Process): deliver output for accepted input, no EOFprocess(&[], output, DecodeOperation::Finish): EOF with an empty suffix
FramedDecoderSession, FramedDecoderSessionOwnedas the raw decoder; events are still lentas the raw decoder
  • Decoders have no Flush operation. Their flush only drains output for input that has already been accepted. A stored payload is accepted only when there is room to deliver it, so after a NeedsOutput it may have nothing to drain.
  • The final-suffix contract still applies after Finish. Once a decoder has seen Finish, flush reports InvalidState, because it would change the operation. finish is correct only once every input byte has been accepted: stopping mid-member reports truncation, and leaving a suffix offered to an earlier Finish unconsumed reports InvalidState.
  • Tests. Each test file drives the same schedule twice, once through process with empty input and once through the shorthands, on both the borrowed and the owned session, and requires identical traces. It also checks the idempotent Finished state and the Finish contract 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 Drop runs, and the begin step is the one start and into_session use. So reinit behaves 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 reinit succeeds. The owner can still be handed back, since release is idempotent.
  • The dictionary is kept. reinit restarts with the session’s own dictionary or resolver. Changing it goes through the consuming into_* 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&lt;R = NoDictionaries&gt;: owner + Option&lt;R&gt;, no Drop"]
    BD --> DHelpers[shared private owner helpers: session_step, session_process]
    OD --> DHelpers
    DHelpers --> DEngine[framed decoder Engine::process and event]
  • Start. FramedCompressor::start and into_session both go through the private begin_session: abandoned-session check, buffer-budget recovery, Engine::start, and active = true. FramedDecompressor::start, start_with_dictionaries, into_session and into_session_with_dictionaries all go through the decoder’s begin_session: abandoned-session check, exact output size against the budget, fits_policy recovery, stream installation, and active = true.
  • Commands and processing. Encoder methods call the same Engine methods, or the private owner helpers for metadata and resource guards. Decoder process calls session_process, which runs session_step (that is, Engine::process) and then maps the Tag to a status and lends the event. The borrowed session’s step, used by the one-shot APIs and the reader, is the same session_step.
  • Resources. The owned encoder session opens resources through the existing FramedResourceSession guard, which borrows the owner inside the session. While a guard is live, the borrow checker rejects both container commands and into_framed_compressor. A rustdoc compile_fail example covers this.
  • Lending. Owned process<'a>(&'a mut self, …, output: &'a mut [u8]) returns FramedDecodeProgress<'a>, exactly like the borrowed session, so a live event blocks the next call. A rustdoc compile_fail example covers this too.
  • Owned resolvers. The decoder resolver is owned as R: DictionaryResolver + 'static. Each call borrows it as a DictionaryResolverRef, which the engine uses only for that call.
    • DictionaryResolver is implemented for &T, Box<T> and Arc<T>. The Arc impl needs target_has_atomic = "ptr".
    • into_session stores None, which is exactly what borrowed start does, so NoDictionaries is 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_compressor and into_framed_decompressor call the owner’s existing cancel, the same path the borrowed Drop runs.
  • Auto traits. FramedEncoderSessionOwned is Send. FramedDecoderSessionOwned<R> is Send exactly when R is. The borrowed framed decoder session is never Send, because its resolver reference has no Sync bound.
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.
    • Finish through 0–3 byte buffers.
    • Decoding of flushed prefixes.
    • Reuse after finished, unfinished, flushed and failed sessions (experimental).
    • Retention, start errors, and dictionaries by value, Arc and &'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, Arc and &'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 equal compress when 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, &'static and Box<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.
  • reinit tests 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 rejected reinit leaves InvalidState until a later reinit succeeds, 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::session that poisons the engine directly.

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 PreparedDictionary for owned decoders. An owned decoder session cannot decode with a PreparedDictionary. The borrowed session still can, through DictionaryRef::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
StateReset/ownership rule
Header and pending bitsCommon prefix writer owns the sole window header; body codecs start headerless. Every successful fragment proves zero pending bits.
Ring and input coordinatesreset_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 matcherExisting dirty/partial-prepare protocol clears logical candidates while retaining allocations. Dictionary statistics use the permanently disabled sentinel.
HQ matcher/DPExisting tree preparation resets roots; all-match and Zopfli arenas are rebuilt from this segment. Static-dictionary match distance is outside the representable range.
Recent distancesInherited 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 commandsInsert 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 contextsGreedy/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 splitsRebuilt locally; q0 adaptive trees never cross a segment reset.
SIMDBackend 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 1300000 at 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:

ItemRole
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, ParallelProgressNeedsInput, NeedsOutput, Pending, Finished, with exact consumed/produced.
ParallelTaskQueue, ParallelTasksClone + 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 the history bytes 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:

CauseStream observes
Every handle dropped before a pushTaskQueueClosed
Every handle dropped with tasks queued, or a taken task droppedTaskAbandoned
Encoding, panic, or a staged-length violation in a taskthe task’s typed error
Allocation while filling or preparing a partAllocation
Malformed or duplicate completionFragmentInvariant

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&#58;&#58;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 with prepare_slice at 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 idempotent Finished; queue closure and task abandonment; session drop; owned sessions; memory limits; Send/Sync and Debug. process_uninit and calls mixed with process match the batch across qualities, windows, executors and output sizes, never write past produced, and report failures as process does, borrowed and owned.
  • Private unit tests cover malformed completions, cancellation-versus-cause ordering, queue-handle counting and single-segment artifact extraction.
  • fuzz/afl target parallel streams each input with input-derived chunk size, window and optional mid-stream flush, running queued tasks in reverse order, alternating process and process_uninit with a sentinel check past produced, 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 with prepare_slice at 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 with WouldBlock, Interrupted and Other at every fifth call while writing short, with finish retried through FinishError; a sink accepting nothing (WriteZero); writes refused after finishing; dropped queues reported as StreamFailed(TaskQueueClosed) through std::io::Error; interrupted and failing sources; into_parts read-ahead; adapter drop closing the queue; memory limits; Send and Debug.
  • Unit tests in parallel::error map every ParallelEncodeError variant to its io::ErrorKind and check that wrapped causes keep their kind and chain.
  • benches/parallel.rs measures stream-tasks/{1,4} beside the batch tasks/{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 over ParallelEncoderSessionOwned.
  • 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.rs does 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.
  • wait cannot detect executors that hold queue handles but never run tasks.

Decompression

The native decoder, its performance paths and the framed container readers.

SpecificationScope
Native decompressorParsing, sessions, history, the single-exit command loop, dictionaries, budgets and I/O.
Owned decoder outputStored members, allocation transfer, linear slice history and read-ahead accounting.
Literal and stored-header decodingLiteral batches, per-context roots, byte-exact literal runs and stored-member recognition.
Decoder SIMDCommand-loop dispatch and bounded fixed-size history copies.
Decoder verificationC/RFC oracles, feature profiles and test limitations.
Framed decoderStructured containers, detection, events, dictionaries, validation, budgets and lending reader.
Framed seek readerFooter/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&#58;&#58;forget
    Finishing --> Abandoned: mem&#58;&#58;forget
    Complete --> Abandoned: mem&#58;&#58;forget
    Failed --> Abandoned: mem&#58;&#58;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::&lt;O, true&gt;]
    Pick -->|no| RingMachine[run_stages::&lt;O, false&gt;]
    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 .text to a program that uses both decompress_to_slice and 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. mapsdatazrh at q1, q5 and q9, whose literals often take that path, decodes about 2% slower through decompress_to_slice even 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

BoundaryChecks
ParametersSource qualities 0–11, modes, windows, block sizes, size hints and all legal distance-parameter pairs
Wire formatRaw/compressed/metadata/final blocks, Huffman repeats, context maps, padding and built-in transforms
SchedulingPROCESS/FLUSH/FINISH, C TakeOutput, continuations, every split of small fixtures and short output buffers
DictionariesOrdered RAW attachments, prepared/decode-only views; serialized/custom transforms and contexts with experimental
CompletionTruncation, tails, concatenated members, aggregate budgets and reader read-ahead
ResourcesAllocation failures, exact input/output/workspace limits, retention, recovery and I/O retry
Large inputsIgnored release tests for real 25–30-bit distances and cumulative output beyond 4 GiB
PlatformsFallback 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 sectionParser / validatorPositive testsNegative tests
4core::wire::numbernonminimal varintsninth continuation byte
8.1detectionsplit signature, both profilesversion, dictionary input
8.2core::wire::header, codec drivercodecs, continuationshort header, invalid codec
8.3core::wire::metadataordered custom fieldsreserved fields, truncation
8.4.1padding phasezero-length, terminal paddingnonzero padding
8.4.2metadata/orderresource metadataorphan metadata
8.4.3–8.4.6resource/orderfull and partial resourcespartial order, flags
8.4.7–8.4.8metadata/orderfooter/global metadatainvalid reserved fields
8.4.9repeatsselected fields and dependenciesmismatched copies
8.4.10directoryexact original header bytesoffset/header mismatch
8.4.11footerunspecified and specified sizessize/directory mismatch
8.4.12orderpadding-transparent orderinterrupted 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.

CheckCommand / scopeResult
Formattingcargo fmt --all; cargo fmt --all -- --checkpass
Workspace lintcargo clippy --workspace --all-targets --all-features --locked -- -D warningspass
Workspace testscargo test --workspace --all-features --lockedpass; final CARGO_PROFILE_TEST_OPT_LEVEL=1 run passes 1,034 tests
std workspaceCARGO_PROFILE_TEST_OPT_LEVEL=1 cargo test --workspace --no-default-features --features std,compression,decompression,experimental --locked1,246 tests pass
Framed integrationcargo test --features experimental --test framed_decoder --locked28 pass
Decoder documentationcargo test --doc --no-default-features --features std,decompression,experimental --locked and alloc-only counterpartpass; runnable examples on every new inherent public method
Consumer feature gatespython3 scripts/check_codec_features.pyall profiles pass, including old encoder import paths
MSRVcargo +1.89.0 check --lib --no-default-features --features std,decompression,experimental --locked and no_std,decompression,experimentalboth pass
Allocation failure / ceilingscargo test --features experimental --test decompress_memory --locked through the coverage runevery injected framed append failure preserves prefix; allocator peaks remain bounded
Function coveragecargo llvm-cov --features experimental --lib --test framed_decoder --test decompress_memory --locked --json --output-path target/framed-coverage.json172/172 functions in the eight framed implementation files; changed raw workspace setter also covered
Miricargo +nightly miri test --lib --no-default-features --features std,decompression,experimental decompressor::framing::core::wire::tests3 pass; separate metadata-to-resource continuation test also passes
ASanRUSTFLAGS=-Zsanitizer=address RUSTDOCFLAGS=-Zsanitizer=address cargo +nightly test --release --target aarch64-apple-darwin --features experimental --test framed_decoder28 pass
AFL packagefmt, Clippy -D warnings, and cargo afl test, each with --no-default-features and with/without --features experimentalboth 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 inputPayload / wire bytesBaseline µsCurrent µsBaseline MiB/sCurrent MiB/s
text59,392 / 641.7868–1.84371.7636–1.775530721.2–31699.531901.2–32116.5
binary65,536 / 65,5401.0647–1.34431.0739–1.084046492.6–58702.057656.8–58199.1
small34 / 340.6182–0.61930.6176–0.644052.4–52.450.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:

ModePayload / wire bytesTime µsMiB/s
raw-presized64,512 / 602.17128336.1
c-raw-presized64,512 / 6022.8802689.0
auto-Retain64,512 / 605.07912112.8
stored-Retain64,512 / 64,5424.74912955.3
stream-stored-Retain64,512 / 64,542110.800555.3
compressed-Retain64,512 / 886.8079038.7
stream-compressed-Retain64,512 / 885.44811293.7
many-small-Retain64,512 / 68,048174.460352.7
stream-many-small-Retain64,512 / 68,048266.560230.8
metadata-heavy-Retain64,512 / 77,9811022.30060.2
stream-metadata-heavy-Retain64,512 / 77,9811078.30057.1
dictionary-heavy-Retain38,912 / 3,18084.224440.6
auto-Reject64,512 / 605.10112062.2
stored-Reject64,512 / 64,5423.64516877.5
stream-stored-Reject64,512 / 64,542101.650605.2
compressed-Reject64,512 / 885.95010340.6
stream-compressed-Reject64,512 / 884.31114271.9
many-small-Reject64,512 / 68,048171.960357.8
stream-many-small-Reject64,512 / 68,048259.450237.1
metadata-heavy-Reject64,512 / 77,9811013.70060.7
stream-metadata-heavy-Reject64,512 / 77,9811058.90058.1
dictionary-heavy-Reject38,912 / 3,18083.652443.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.

CorpusPayload bytesRaw / framed bytesIndexed µsSequential µsC raw µs
Small text2622 / 461.501.350.51
Repeated text64,51260 / 883.663.4624.50
Binary byte cycle65,536217 / 2494.844.4226.69
Seeded xorshift bytes65,53665,540 / 65,5764.832.242.02
Large zero payload1,048,576209 / 48933.6533.39445.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.

SpecificationScope
Shared BrotliLarge Window declarations and prepared prefix dictionaries.
Serialized dictionariesExperimental wire format, transforms and resource limits.
Custom encoding and continuationsStatic indexes, contexts and headerless stream offsets.
Framed compressorReusable 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:

FeatureState
Large Window Brotliimplemented for qualities 3 to 11
LZ77 prefix dictionaries: preparation, indexes, addressing, searchimplemented
LZ77 prefix dictionaries: use by an encoderimplemented for qualities 5 to 11 — refused below, not ignored
Serialized shared dictionaries and custom static encodingimplemented behind experimental; see rfc9841-encoding.md
Framing container formatexperimental 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(&params)
    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 &le; 15?"}
    c -->|no| e1["Err(TooManyAttachments)"]
    c -->|yes| d{"each segment &le; 2^31 - 1?"}
    d -->|no| e2["Err(TooLarge)"]
    d -->|yes| f{"total &le; max_prefix_bytes<br/>and max_source_bytes?"}
    f -->|no| e2
    f -->|yes| g{"peak estimate &le;<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 &lt; 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.

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:

SiteWithout a prefixWith one
Static-dictionary boundary handed to the matcherdictionary_startdictionary_start + gap
Prefix search boundary—dictionary_start
compute_distance_code, distance-cache guarddictionary_startdictionary_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&#58;&#58;new(config)
    Build --> LW: large window at quality 0, 1 or 2
    LW --> [*]: Err(ConfigError&#58;&#58;LargeWindowUnsupportedForQuality)
    Build --> Q: a compressor
    Q --> Low: quality below 5
    Low --> [*]: Err(EncodeError&#58;&#58;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&#58;&#58;new / reconfigure
    Check --> Ordinary: the window is not large
    Check --> Q012: quality 0, 1 or 2
    Check --> Large: quality 3..=11
    Q012 --> [*]: Err(ConfigError&#58;&#58;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&lt;EncodeError&gt;"| 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

LayerWhat it checks
core::rfc9841::window unit testsheader bits for all 53 large windows and every ordinary window; retained history never exceeds the declaration
shared::distance unit teststhe large alphabet against every legal (NPOSTFIX, NDIRECT) pair; the 544-symbol histogram ceiling; the degenerate branches of distance_code_limit
tests/large_window.rsheader 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 testsattachment 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 teststhe 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 testsattachment 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.rsbyte 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.rsthe 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 restbyte 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, H65 and 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 with EncodeError::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. TransformScratch is 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.

LimitDefaultWhat it bounds
max_attachments15prefix dictionaries per prepared dictionary
max_serialized_bytes128 MiBthe whole stream, before any field is read
max_prefix_bytes64 MiBthe LZ77 prefix, declared length first
max_word_lists64custom word lists
max_word_bytes16 MiBword bytes, accumulated as the lists are parsed
max_transform_lists64custom transform lists
max_transform_bytes8 MiBtransform wire bytes, accumulated
max_combinations64word-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 ReadVarint32 rejects continuation in byte five. Differential tests account for this narrower C reader and require canonical serialization to parse with C.

8. Verification

WhereWhat is checked
core::rfc9841::varintevery bit position round trips, truncation, the ninth-byte cap, noncanonical encodings, trailing bytes left for the caller
core::rfc9841::wordsthe 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::transformthe 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::serializedround 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.rsdifferential 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 restdefault 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_index described in rfc9841-encoding.md. Its expansion limits are max_transformed_word_bytes, max_static_entries and the aggregate retained byte ceiling. A flat index is used instead of a trie.
  • Container writing is described in framing.md.
  • The serialized_dictionary AFL 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.rs verifies 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.rs checks 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.

TypeEmission
0Zero-filled explicit padding; metadata adjacency is preserved
1Resource metadata preceding its resource
2Single complete resource, including empty payload
3 / 4 / 5First / middle / last chunks of one continuing resource
6Footer metadata following its resource
7Global metadata
8Repeated resource/footer metadata, preserving order and selected fields
9Repeated-metadata offset and exact type 1–8 content headers/offsets
10Reversed 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

SpecificationScope
C ABImbrotli-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

CRustBehaviour
mbrotli_resultMbrotliResult (#[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_compressunsafe extern "C" fnOne complete stream; quality 0..=11, lgwin 10..=24 (RFC 7932 windows only)
mbrotli_decompressunsafe extern "C" fnExactly one complete stream; standard and RFC 9841 large windows
mbrotli_compress_boundsafe extern "C" fnGoogle’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_len must be non-null and aligned before it is read or written. Buffers may be null only with a zero length; lengths above isize::MAX or ending past the address space are refused; input, output and the output_len slot 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 calls compress_to_uninit. Decompression zeroes a destination of at most LINEAR_SMALL, 1 KiB, through borrowed_buf::BorrowedBuf::ensure_init, which hands back the zeroed bytes as &mut [u8] without unsafe in this crate, and decodes with decompress_to_slice; larger destinations go to decompress_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_unwind wraps each codec call; the closure is AssertUnwindSafe because 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_SMALL from mbrotli_decompress leaves 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 yields MBROTLI_ERROR. Google’s one-shot decoder reports both cases as its single error result.

Verification

  • mbrotli-ffi/src/core.rs unit 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 with BrotliEncoderCompress for 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 with BrotliEncoderMaxCompressedSize; round-trips through both decoders; every rejected argument; concurrent calls; and that the header’s enumerators match MbrotliResult.
  • fuzz/afl target c_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
createEncoder takes quality/window, decoder uses defaults; input/output sizes unknown.
processTakes input, in/out input count, output, in/out output count and integer operation.
flushEmpty-input encoder Flush or decoder Process; no EOF.
finishEmpty-input Finish, valid once the final offered input is fully consumed.
reinitAbandons current stream and restarts with default stream settings and retained workspace.
destroyReleases 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 (lgwin 25–30, which BrotliEncoderCompress accepts) 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=off the C library agrees. The tests and the fuzz target skip that comparison there for the input that triggers it.
  • There is no no_std build: the crate needs std for catch_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_decompress therefore takes 1.2% longer than decompress_to_slice into 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.

SpecificationScope
Implementation comparisonIsolated library comparisons, validation, provenance and charts.
FuzzingTarget inputs, oracles, campaigns, regression replay and the libFuzzer adapters.
Continuous integrationTriggers, checks, Miri and AddressSanitizer, fuzz campaigns, coverage gating and release publishing.
Documentation publishingSource staging, navigation, section pages, recipe execution, link validation and Pages deployment.
Formal verificationCharon/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

SuiteImplementationsResult directory
implementationsC, mbrotli, Rust brotli, SIMD Brotli at q0–q11; Burli at q0–q5target/criterion
decodersC, mbrotli, Rust brotli and Burli on source q0–q11target/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.csv with 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 compress cold 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

TargetInputOracle
q0_roundtrippayloadno panic, compressed.len() <= Compressor::max_compressed_size, C decoder round-trip
q1_roundtrippayloadsame, at quality 1
q3_roundtrippayloadsame, at quality 3
q4_roundtrippayloadsame, at quality 4
q5_roundtrippayloadsame, at quality 5
q6_roundtrippayloadsame, at quality 6
q7_roundtrippayloadsame, at quality 7
q8_roundtrippayloadsame, at quality 8
q9_roundtrippayloadsame, at quality 9
q10_roundtrippayloadsame, at quality 10
q11_roundtrippayloadsame, at quality 11
decode_roundtripshared encoder headerindependent C compression, exact native decoding and streaming equivalence; plaintext capped at 64 KiB
params_roundtripheaderbound, round-trip, and that a reused compressor, a second call on it and a fresh one all agree, over every legal setting
simd_equivalenceheaderevery distinct host backend emits identical bytes
differential_cheaderbyte 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_equivalenceheadervector, 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_sessionheader, then a plan byte lastborrowed 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_capacityheaderexactly 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_parsingnumericTryFrom 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_windowlarge windowWindow::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
dictionarydictionarypreparation 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_dictionarydictionary streamparser 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
framingsettings byte and bounded resource bytesresource/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
parallelbounded task/source settingsdeterministic 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_abiC ABImbrotli-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_lifecyclelifecyclewhatever 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/&lt;root&gt;/&lt;build&gt;/&lt;target&gt;<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.sh fuzzes the encoder surface and decode_roundtrip; decoder-campaign.sh fuzzes the decoder surface. Running only one leaves the other unfuzzed.
  • Framing fault injection is deterministic, not fuzz-driven. tests/framing.rs injects 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.rs covers, 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.yml runs 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-*.bin is hand-written. The two noncanonical-varint serialized fixtures are minimized AFL findings documenting the C helper’s narrower integer reader.
  • prepare-seeds.sh does not emit a compressor_lifecycle corpus. 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*_roundtrip targets. params_roundtrip and differential_c reach 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 ParallelEncoderSession fed in chunk-sized process calls through a 97-byte output buffer, running each batch of queued tasks in reverse order on the fuzzing thread before wait;
  • a ParallelEncoderWriter given chunk-sized write_all calls, with Write::flush at the flush point and one executor thread;
  • a ParallelEncoderReader over 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/&lt;target&gt;"] --> Choice
    Carry["SEED_ROOT/&lt;config&gt;-&lt;target&gt;<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/&lt;root&gt;/&lt;build&gt;/&lt;target&gt;"] --> 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

WorkflowTriggerScope
ci.ymlPush to master, pull requestFormatting, Clippy, docs, packaging, API compatibility, feature checks, tests and AFL replay
ci-lean.ymlPush to master, pull requestLean translation drift check and Lean proofs with axiom audit; see formal verification
ci-coverage.ymlManualStd and no_std function coverage; 100% gate and HTML report
ci-benchmarks.ymlManualCriterion validation and timing on Linux x86-64 and ARM64
ci-fuzz.ymlEvery tag, manualBounded AFL campaigns in stable and experimental profiles
ci-cflite-pr.ymlPull request touching code, fuzz or build filesClusterFuzzLite libFuzzer/ASan run on the changed code
ci-cflite-cron.ymlDaily, manualClusterFuzzLite batch fuzzing, corpus pruning and coverage report
ci-miri.ymlEvery tag, manualSIMD and uninitialized-memory tests on x86-64 (x86-64-v3) and ARM64, four shards each; experimental decoder goldens
ci-sanitizer.ymlEvery tag, manualAddressSanitizer: SIMD and uninitialized-memory tests on x86-64 and ARM64, plus integration tests
ci-decompressor-heavy.ymlv* tag, manualActual 25–30-bit history and counters beyond 4 GiB in four decoder profiles
release.ymlManual, from a v* tagPublishes mbrotli to crates.io through trusted publishing
pages.ymlDocumentation or codec-source changes on master and pull requests; manualBuilds 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:

CommandBehavior
prepareRecreate the staged source tree, navigation, and bundled diagram assets
buildPrepare, build HTML, then check local links and heading anchors
servePrepare, then serve the generated tree on loopback port 3000
check-linksValidate a previously built HTML tree
test-examplesCompile and run Rust recipes in a separate Cargo consumer
flowchart TD
    Copy[Copy docs and architecture] --> Links[Inspect destinations outside fenced code]
    Links --> Local{Target in published directories?}
    Local -->|yes| Relative[Keep relative link]
    Local -->|no| GitHub[Link to source on GitHub 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.

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.
  • serve watches generated files. Restart the command after editing canonical sources so preparation runs again.
  • The checker validates local destinations and anchors, not external network URLs or Mermaid syntax. Browser QA is needed for rendered diagrams.
  • The diagram dialog and theme have no automated test; they were checked in a desktop browser in the light and navy themes.
  • Recipe validation covers the guides in RECIPES, not every historical code fragment in the implementation specifications.

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

PathOwnerContents
scripts/extract_lean.pyhand-writtenPins the Aeneas release, lists the translated items, regenerates or checks the translation
verification/lean/Mbrotli/Code/Types.lean, Code/Funs.leangenerated, never editedAeneas translation of the listed Rust items
verification/lean/Mbrotli/Code/FunsExternal.leanhand-written modelsThe four standard-library functions the translation calls and the Aeneas library does not model
verification/lean/Mbrotli/Model.leanhand-written specificationRFC distance arithmetic over unbounded naturals
verification/lean/Mbrotli/Rfc.leanhand-written transcriptionThe 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.leanhand-written proofsPacked command-field layout, both table-initialization loops and agreement with every valid RFC symbol
verification/lean/Mbrotli/*.lean (others)hand-written proofsSpecifications of the generated functions, the round-trip and uniqueness theorems
verification/lean/lakefile.lean, lean-toolchain, lake-manifest.jsonhand-written pinsLean 4.35.0 and the Aeneas Lean library at the same commit as the binaries

The Lean project is not part of the Cargo workspace and nothing in src/ refers to it: no attribute, feature or dependency was added to the crate. Charon compiles the ordinary crate with its own nightly toolchain into target/charon/, so the stable build is untouched.

graph TD
    Rust[src: 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, extra and symbol are the encoder’s split of dist (the bucket width, the bit below the leading one, the low p bits, the extra field and the emitted symbol);
  • width p nd sym and decode p nd sym ex are 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:

LemmaStatement
decomposeevery 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_fieldsthe 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_symboldecode 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_ltd < 2^(n+p+2) keeps the symbol below 16 + nd + 2n·2^p and the width at most n
split_uniquethe converse of decompose: a value written in that form has exactly those fields
encode_decodefor 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_injectivetwo 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 itemPreconditionsResult
shared::fast_log::log2_floor_non_zerovalue ≠ 0Nat.log 2 value
shared::command::prefix_encode_copy_distancep ≤ 3, nd ≤ 120, 16 + nd ≤ dc ≤ 2^31 + 15code = 1024·nbits + symbol, extra = Model.extra
shared::distance::DistanceParams::newp ≤ 3, nd ≤ 120alphabet_size_max = 16 + nd + 48·2^p, max_distance + 2^(p+2) = nd + 2^(26+p)
DistanceLayout::from_headerheader < 64postfix = header % 4, direct = header / 4 · 2^(header % 4)
DistanceLayout::alphabetpostfix ≤ 3, direct ≤ 12016 + direct + (48 or 124)·2^postfix
DistanceLayout::extra_bitslayout bounds, symbol < 2048Model.width
DistanceLayout::short_window_distance16 ≤ symbol < 16 + nd + 48·2^p, extra < 2^widthModel.decode, computed in u64
DistanceLayout::long_distance16 ≤ symbol < 16 + nd + 124·2^p, extra < 2^widthModel.decode, computed in u128
shared::format::INS_BASE, INS_EXTRA, COPY_BASE, COPY_EXTRA (indexing)i < 24the RFC row’s first length or extra-bit count
decompressor::core::stream::CELLS (indexing)k < 11the RFC cell’s first insert code plus its first copy code divided by eight
decompressor::core::stream::commandsnonefor every symbol s < 704, the entry is Command.Rfc s (see below)
shared::command::insert_length_codeinsertlen ≤ 16799809the code whose RFC range holds insertlen
shared::command::COPY_LENGTH_CODESnoneentry l is the code of length l, for 2 ≤ l < 2118
shared::command::copy_length_code2 ≤ copylen ≤ 16779333the code whose RFC range holds copylen
shared::command::combine_length_codesboth codes < 24combineModel, the RFC cell’s symbol (see below)
shared::command::length_codeboth lengths in rangea 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 + 15the code is dc with no extra bits; the low ten bits are 0 exactly for dc = 0
Command::newlengths in range, copylen < 2^25, -64 ≤ delta ≤ 63, dc ≤ 2^31 + 15Command.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_codecopy_len = L + deltaField d · 2^25, L < 2^25, -64 ≤ d ≤ 63, 0 ≤ L + dL + d
shared::command::COMMAND_EXTRASnoneevery symbol below 704 stores the insert/copy bases and widths of its RFC classes
Command::extra_bitsas above, lengths in range, symbol below 704 and its insert/copy classes contain both lengthsthe 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_contextnoneencoderContext, which equals the section 7.2 context of every copy length the symbol can carry
Command::has_distancenonea copying command writes a distance symbol exactly when its symbol is not implicit
Command::insert_onlyinsertlen ≤ 16799809Command.Lengths with no copy and copy code of length 4, never implicit
Cache::defaultnonering = [4, 11, 15, 16], the RFC’s initial ring, most recent first
Cache::recentback < 4the back-th most recent distance
Cache::pushnonering' = d :: ring.take 3
Cache::popnonereturns the most recent distance; ring' = ring.tail ++ [most recent], so pop then push y gives y :: ring.tail (Cache.pop_push)
DistanceLayout::shortsymbol < 16Ok 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_largeas long_distanceOk(Model.decode) when it fits in u64, the distance error otherwise
DistanceLayout::resolvesymbol in the layout’s alphabet, extra of its widthOk(rfcDistance): short symbols against the ring, the others by Model.decode; the distance error exactly as short and resolve_large give it
DistanceLayout::validate_symbollayout boundsOk 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:

  1. the encoder returns without panicking;
  2. the decoder’s alphabet for that header equals the encoder’s alphabet_size_max, and contains the emitted symbol;
  3. the decoder reads exactly the width the encoder packed into bits 10..15;
  4. short_window_distance returns dc - 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.lean of u64::try_from(u128), u64::checked_add_signed, Option::ok_or_else and Option::filter match the standard library; each is a few lines and follows its documentation;
  • Lean’s kernel and the three standard axioms;
  • Model.decode and Model.width transcribe RFC 7932 section 4 correctly, and Mbrotli/Rfc.lean transcribes 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_base and copy_base, lives in decompressor/core/stream.rs and is not translated; the proofs cover the table it reads, not the addition. Likewise the constant COMMANDS is commands() by its Rust definition, which is not translated. The loop’s slow path, Stage::Command to Stage::CopyExtra, recomputes the insert and copy codes from CELLS inline instead of reading the table, so that copy of the cell formula is not covered either.
  • The experimental branches of Command::new and Command::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 that pop then push replaces the most recent distance and push adds one.
  • That the bit writer emits each field least significant bit first, which turns command_round_trip’s split of bits into 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, pop and recent (DistanceCache.lean).
  • §4 full resolution: DistanceLayout::resolve, resolve_large and validate_symbol against the RFC, and validated_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::entry and standard_table give base + (extra << NPOSTFIX) equal to the RFC distance.
  • §4 large-window alphabet limit: DistanceParams::new_large and distance_code_limit against RFC 9841.
  • §5 length code tables: INS_BASE, INS_EXTRA, COPY_BASE, COPY_EXTRA and CELLS equal 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_only and command_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.rs window, 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.rs encode_mlen, header.rs metablock).
  • §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_RANGES in shared/format.rs).
  • §7.1 context lookup: the LSB6, MSB6, UTF8 and Signed tables equal the RFC formulas (CONTEXT_LUT_* in shared/format.rs, context_lut in stream.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::BitWriter writes least significant bit first is what the decoder’s Bits reservoir reads; the reservoir keeps count ≤ 64, and refill and unread lose 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 >> length sums to 32768.
  • Decoder Huffman tables: the two-level tables in decompressor/core/huffman.rs decode 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.rs against the encoder’s move-to-front in shared/bitstream.rs.
  • §8 and appendix B: the 121 transforms equal appendix B; ferment follows 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.rs computes the reference decoder’s output for any chunking of the input. SIMD paths, which Aeneas cannot translate, stay with the differential tests, Miri and AddressSanitizer.

Outside RFC coverage, 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

PathContents
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/:

ArtifactEncoderDecoder
Reportencoder-comparison.mddecoder-comparison.md
Measurementsencoder-comparison.csvdecoder-comparison.csv
Environmentencoder-comparison-environment.jsondecoder-comparison-environment.json
Index and quality pagesencoders/README.md, encoders/q0.md–q11.mddecoders/README.md, decoders/q0.md–q11.md
Chartsencoders/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.

ImplementationVersionTimed API
Google C Brotli1.2.0, pinned submoduleBrotliEncoderCompress
mbrotliLocal checkoutConstruct Compressor, then compress
Rust brotli9.0.0BrotliCompress
simd-brotli10.0.1BrotliCompress
burli0.3.3compress_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.

DecoderVersionTimed API
Google C1.2.0, pinned vendor revisionBrotliDecoderDecompress into a newly allocated, initialized output
mbrotliLocal checkoutConstruct Decompressor, then decompress
Rust brotli9.0.0BrotliDecompress into a new Vec, native 4 KiB I/O buffers
Burli0.3.3burli::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.

GroupWorkersExecutions
Encoder, stable249,825,192
Encoder, experimental2613,365,353
Decoder, stable759,577,320
Decoder, experimental8106,854,256
Total65189,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.

GroupWorkersExecutions
Encoder, stable2429,978,741
Encoder, experimental2628,474,966
Decoder, stable7148,112,839
Decoder, experimental8179,724,677
Total65386,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).

BuildPairsExecutions
Stable30103,259,280
Experimental36170,088,423
Total66273,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.

GroupWorkersExecutions
Encoder, stable2243,668,477
Encoder, experimental2448,598,336
Decoder, stable6204,316,217
Decoder, experimental7327,495,724
Framed, experimental36,915,108
Total62630,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:

  1. Builds and validates the book on pull requests and pushes to master.
  2. Compiles and runs the documented recipes.
  3. Uploads the static book as a Pages artifact.
  4. Deploys only on master pushes or a manual run on master.

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.