Parallel compression
mbrotli::compressor::parallel divides a known input into fixed independent
segments and assembles them into one Brotli stream. It supports qualities 0–11
with standard windows. Dictionaries, Large Window, and framing are unsupported
by this API.
The caller runs the tasks using scoped threads, Rayon, or another executor. The library does not create a thread pool.
Compressing a slice
use mbrotli::compressor::parallel::{
BatchConfig, ParallelCompressor, ParallelConfig, TaskCount,
};
use mbrotli::{Decompressor, EncoderConfig, Quality};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let input = vec![b'a'; 8 << 20];
let mut encoder = ParallelCompressor::new(
EncoderConfig::default().with_quality(Quality::Q5),
ParallelConfig::default(),
)?;
let mut batch = encoder.prepare_slice(
&input,
BatchConfig::auto(TaskCount::try_from(4)?),
)?;
let tasks = batch.take_tasks()?;
std::thread::scope(|scope| {
for task in tasks {
scope.spawn(move || task.run());
}
});
let mut output = Vec::new();
batch.finish_into(&mut output)?;
let mut decoder = Decompressor::new(Default::default())?;
assert_eq!(decoder.decompress(&output)?, input);
Ok(())
}
take_tasks transfers each task once. Every taken task must run or be dropped
so its completion can be reported. Finalization collects task results, validates
artifacts, and assembles them in input order. A blocking wait requires the
caller’s executor to remain runnable.
Running tasks with Rayon
Add rayon = "1.12" alongside mbrotli in your dependencies. Create a pool
once and reuse it across requests. Task completion order does not affect assembly
order or the resulting bytes for fixed segment settings.
use mbrotli::compressor::parallel::{
BatchConfig, ParallelCompressor, ParallelConfig, TaskCount,
};
use mbrotli::{Decompressor, EncoderConfig, Quality};
use rayon::prelude::*;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let pool = rayon::ThreadPoolBuilder::new().num_threads(4).build()?;
let mut encoder = ParallelCompressor::new(
EncoderConfig::default().with_quality(Quality::Q5),
ParallelConfig::default(),
)?;
let input = b"parallel compression with a reusable pool\n".repeat(220_000);
let mut batch = encoder.prepare_slice(
&input,
BatchConfig::auto(TaskCount::try_from(4)?),
)?;
let tasks = batch.take_tasks()?;
pool.install(|| tasks.into_par_iter().for_each(|task| task.run()));
let mut compressed = Vec::new();
batch.finish_into(&mut compressed)?;
let mut decoder = Decompressor::new(Default::default())?;
assert_eq!(decoder.decompress(&compressed)?, input);
Ok(())
}
For many unrelated small inputs, another option is one ordinary Compressor per
worker. That preserves serial compression decisions for each input. The batch
API above splits one input into a single assembled stream.
Streaming input
When input arrives in chunks, start(window) returns a session and a task
queue. Each full segment the session accepts becomes a task on that queue;
clone the queue for each executor and run for task in queue { task.run() }.
The session delivers finished parts in input order while later segments are
still encoding, and closes the queue when the stream finishes, fails, or is
dropped.
use mbrotli::compressor::parallel::{
ParallelCompressor, ParallelConfig, ParallelEncoderStatus, TaskCount,
};
use mbrotli::{EncoderConfig, Operation, Quality};
use std::io::{Read, Write};
fn compress(
mut reader: impl Read,
mut writer: impl Write,
) -> Result<(), Box<dyn std::error::Error>> {
let mut encoder = ParallelCompressor::new(
EncoderConfig::default().with_quality(Quality::Q5),
ParallelConfig::default(),
)?;
std::thread::scope(|scope| {
let (mut session, queue) = encoder.start(TaskCount::try_from(4)?)?;
for _ in 0..4 {
let queue = queue.clone();
scope.spawn(move || queue.into_iter().for_each(|task| task.run()));
}
drop(queue);
let mut chunk = vec![0; 1 << 16];
let mut output = vec![0; 1 << 16];
loop {
let read = reader.read(&mut chunk)?;
let operation = if read == 0 { Operation::Finish } else { Operation::Process };
let mut input = &chunk[..read];
loop {
let progress = session.process(input, &mut output, operation)?;
input = &input[progress.consumed..];
writer.write_all(&output[..progress.produced])?;
match progress.status {
ParallelEncoderStatus::Finished => return Ok(()),
ParallelEncoderStatus::Pending => session.wait()?,
ParallelEncoderStatus::NeedsInput if input.is_empty() => break,
_ => {}
}
}
}
})
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let input = b"incremental parallel input\n".repeat(350_000);
let mut compressed = Vec::new();
compress(input.as_slice(), &mut compressed)?;
let mut decoder = mbrotli::Decompressor::new(Default::default())?;
assert_eq!(decoder.decompress(&compressed)?, input);
Ok(())
}
processnever blocks.Pendingmeans the window is full or a flush or finish waits for queued tasks; callwait, which blocks until one completes.windowbounds the segments queued, running or awaiting delivery, and so the session’s memory. A full window stops input from being consumed.- Without a flush, the output is byte-identical to
prepare_sliceover the same bytes, however the input was chunked.Operation::Flushends the current segment early, which moves later segment boundaries. - On a single thread, run
queue.try_recv()tasks beforewait; nothing else will. Dropping every queue handle fails the stream instead of hanging. into_session(window)returns an owned session without a lifetime;into_compressorreturns the compressor.
Reader and writer adapters
writer(sink, window) and reader(source, window) wrap the same session in
std::io::Write and std::io::Read and return the task queue beside the
adapter, so the loop above becomes std::io::copy:
use mbrotli::compressor::parallel::{ParallelCompressor, ParallelConfig, TaskCount};
use mbrotli::io::FinishError;
use mbrotli::{EncoderConfig, Quality};
use std::io::{Read, Write};
fn compress<W: Write>(
mut reader: impl Read,
writer: W,
) -> Result<W, Box<dyn std::error::Error>> {
let mut encoder = ParallelCompressor::new(
EncoderConfig::default().with_quality(Quality::Q5),
ParallelConfig::default(),
)?;
std::thread::scope(|scope| {
let (mut sink, queue) = encoder.writer(writer, TaskCount::try_from(4)?)?;
for _ in 0..4 {
let queue = queue.clone();
scope.spawn(move || queue.into_iter().for_each(|task| task.run()));
}
drop(queue);
std::io::copy(&mut reader, &mut sink)?;
Ok(sink.finish().map_err(FinishError::into_error)?)
})
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let input = b"stream through caller-owned workers\n".repeat(250_000);
let compressed = compress(input.as_slice(), Vec::new())?;
let mut decoder = mbrotli::Decompressor::new(Default::default())?;
assert_eq!(decoder.decompress(&compressed)?, input);
Ok(())
}
- Unlike
process, the adapters block: a write waits while the window is full, a read waits for the next part, andfinishwaits for every part. Run the queue on threads other than the one using the adapter. - Output is byte-identical to
prepare_sliceover the same bytes. The writer’sflushisOperation::Flush: it makes everything written decodable and moves later segment boundaries. - The writer keeps every compressed byte until the sink takes it, so short
writes,
WouldBlockand sink errors lose nothing, andfinishhands the adapter back insideFinishErrorfor a retry, asEncoderWriterdoes.reader.into_parts()returns source bytes read ahead of the session. - Session failures arrive as
std::io::Errorwith theParallelEncodeErroras their source. Dropping an adapter abandons the stream and closes the queue.
Segments and compressed size
Segment size defaults to 4 MiB and accepts 64 KiB–16 MiB. Effective task count is at most the number of segments. For fixed input and segment settings, task grouping and completion order do not change output bytes.
Segments are encoded independently. At qualities 2-9 a segment may copy from the input before it, up to a quarter of a segment within the window, and codes distances as a serial stream does; at qualities 0-1 and 10-11 it starts from nothing and writes every distance explicitly, which costs most on input that repeats one distance, such as tables and logs. The compression ratio can be worse than serial compression, especially for repetition further back than that history. Compare both output size and elapsed time.
Empty input uses serial encoding without tasks. A nonempty input below the default 8 MiB parallel threshold uses a single serial task only when it also fits in one segment.
Sources and staging
| Input | Entry point |
|---|---|
| Borrowed slice | prepare_slice |
| Regular file | prepare_source(FileSource::open(path)?, config) |
| Owned seekable reader | prepare_source(SeekSource::from(reader), config) |
| Custom random-access source | prepare_source(source, config) with RandomAccessSource |
FileSource uses positional reads on Unix and Windows. SeekSource accepts
Read + Seek + Send, serializes seek/read operations, and lets compression run
concurrently. Sources must remain immutable for the batch lifetime; metadata
checks cannot detect every in-place mutation.
Owned sources and shared handles use the same generic entry point. For an
existing Arc<FileSource>, type inference can require
prepare_source::<FileSource, _>(shared_file, config).
BatchConfig::auto(tasks) stages in memory using bounds derived from source
length and configured segment size. It caps effective tasks at the segment
count and still enforces any configured aggregate memory limit. It does not
query available RAM or switch to files.
BatchConfig::memory(tasks, max_bytes) sets an explicit payload-and-descriptor
budget. To obtain its exact required preflight allowance, call estimate_source
with BatchConfig::auto(tasks), then convert
estimate.maximum_staging_memory_bytes()? using usize::try_from.
maximum_staged_bytes remains the payload-only bound. Planning also accounts
for worker and assembly storage. These estimates are not a process
RSS measurement. BatchConfig::directory(tasks, directory) stages output in
exclusive temporary files in an existing directory, keeping payload RAM
proportional to active workers and segment size. File cleanup is best effort
on drop.
Output and cancellation
finish_into appends to a vector and restores its length on failure.
finish_to_writer consumes the batch and returns the destination with
statistics on success. On failure it preserves destination ownership and the
number of accepted bytes; the consumed batch cannot retry assembly. The caller
controls flushing, file durability, and publication.
Cancellation is cooperative around source reads and segment encoding. A running codec call or blocking source read can delay it. Dropping a batch cancels work without waiting for detached tasks. Worker retention is configurable and defaults to retaining no workers.
The file example uses directory staging and creates a new output file:
cargo run --release --example parallel -- INPUT OUTPUT
See parallel mechanics for lifecycle, resource accounting, source checks, and error propagation.