Please check the build logs for more information.
See Builds for ideas on how to fix a failed build, or Metadata for how to configure docs.rs builds.
If you believe this is docs.rs' fault, open an issue.
olympipe-rs
Parallel data pipeline library for Rust, inspired by olympipe.
Each stage runs in its own thread (or several threads for task(..., count)), communicating via bounded crossbeam channels. Back-pressure is automatic: a slow downstream stage stalls upstream producers.
The default channel holds 4 messages. A message is a single item when the buffer is 1 or 2. With a larger buffer, a stage packs up to 64 items that it already has into one message, and flushes a partial message as soon as the next input would block (a trickle source, the last items, or a slow callback). A full channel therefore holds at most buffer_size items for a one-item packet, and at most buffer_size * 64 items otherwise (256 with the default buffer of 4). Worker threads are named olympipe-<stage>.
.with_max_packet(1) opts out of that packing for every stage added afterwards. The feeder honours it too when you set it before the first stage or terminal call (Pipeline::new(...).with_max_packet(1)), because the feeder does not pull the source until then. After the feeder has started, a later with_max_packet only affects subsequent stages. The feeder channel is sized by new / new_with_buffer and holds at most buffer_size * packet items. with_max_packet(1) restores the old per-item messages: a channel holds at most buffer_size items. new_with_buffer keeps the default packet size (64 when the buffer is larger than 2). Call with_max_packet after with_buffer; with_buffer resets the packet size of later stages to that default and does not change the feeder.
Use case: ML inference pipelines
A common bottleneck in machine learning workloads is keeping the GPU busy. olympipe-rs makes it straightforward to overlap CPU-bound preprocessing and postprocessing with GPU inference in separate threads:
use Pipeline;
let image_paths = ;
new
.with_max_packet // large tensors: one item per message, not 64
.task // 4 CPU threads decode & normalise
.task // 1 GPU thread, always fed
.task // 4 CPU threads decode results
.for_each;
Because each stage runs independently and channels act as bounded buffers, the GPU thread is never starved waiting for preprocessing, and preprocessed batches don't pile up unboundedly in memory. .with_max_packet(1) keeps that bound at buffer_size items (4 by default) instead of buffer_size * 64, which matters when each item is an image or a tensor.
Quick start
[]
= "1"
use Pipeline;
let result = new
.filter
.task // 4 parallel workers
.limit
.collect;
assert_eq!;
Benchmarks
Comparisons with Python olympipe 2.1.0, and the before/after optimization figures, are in BENCHMARKS.md. cargo bench runs the criterion harness; cargo run --release --example bench_report prints the cross-language report workloads.
API
Constructors
| Method | Description |
|---|---|
Pipeline::new(iter) |
Feed any IntoIterator into the pipeline |
Pipeline::new_with_buffer(iter, n) |
Like new, but the feeder channel also holds n messages. Default packet size stays 64 when n > 2 |
.with_buffer(n) |
Override message capacity for subsequent stages (default: 4). Does not resize the feeder. Resets later stages' packet size to the default for n |
.with_max_packet(n) |
Clamp the packet size to 1..=64 for later stages, and for the feeder if it has not started pulling. 1 means at most buffer_size items in a channel |
Stages (consume self, return a new Pipeline)
| Method | Description |
|---|---|
.task(f, count) |
Map 1→1; count parallel workers share the channel. 0 runs one worker, which preserves order |
.cached_task(step, dir, f, count) |
Same map, with a JSON file cache (feature cache). 0 runs one worker. step is the cache identity |
.class_task(init, method, count) |
Stateful map. init runs once per worker thread (S need not be Send). 0 runs one worker |
.class_task_with_close(init, method, close, count) |
class_task plus close(state) when the worker stops (not if init / method panicked) |
.task_or(f, on_error) |
Map with fallback — requires T: Clone. Item-aware callback form |
.try_task(f, count, mode) |
Result-returning map. ErrorMode::FailFast (default) cancels; Skip / SkipAndLog / Replace recover, and also catch a panic per item |
.task_with_mode(f, count, mode) |
Infallible map. FailFast is task. Other modes catch panics per item |
.filter(pred) |
Keep items where pred(&item) is true |
.batch(n) |
Group into Vec<T> of at most n items |
.temporal_batch(duration) |
Group items arriving within duration of each other |
.explode(f) |
FlatMap: f(item) returns an iterator |
.split(f) |
Route to two bounded branches; consume them concurrently. Returns (Pipeline<A>, Pipeline<B>) |
.split_unbounded(f) |
Like split, but unbounded outputs (no back-pressure); sequential consumption is safe |
.split_by(n, f) |
Route to n bounded branches by index f(&item). Index >= n fails the pipeline (stage "split") |
.split_by_unbounded(n, f) |
Like split_by, unbounded outputs |
.split_array(f) |
f returns [Option<U>; N]: an item may go to several branches or none. Bounded |
.split_array_unbounded(f) |
Like split_array, unbounded outputs |
.gather(others) |
Merge self and others into one |
Pipeline::gather_all(pipes) |
Merge a non-empty Vec of pipelines (what split_by returns) |
.reduce(init, f) |
Fold to a single value, emitted as the sole output item |
.checkpoint(initial, reducer, emitter) |
Fold every item into a state, then stream emitter(state) once the input is exhausted |
.limit(n) |
Pass at most n items downstream |
.timeout(duration) |
Fail the pipeline with PipelineError::Timeout if no item arrives within duration |
.debug() |
println! each item and forward it |
Terminal methods
| Method | Description |
|---|---|
.try_collect() |
Drain into Result<Vec<T>, PipelineError>, join all threads |
.try_for_each(f) |
Apply f to each item; Result<(), PipelineError> |
.try_wait_for_completion() |
Drain and discard; Result<(), PipelineError> |
.collect() |
Like try_collect, but panics on pipeline failure |
.for_each(f) |
Like try_for_each, but panics on pipeline failure |
.wait_for_completion() |
Like try_wait_for_completion, but panics on pipeline failure |
.try_wait_for_results(others) / .wait_for_results(others) |
Drain self then others concurrently into Vec<Vec<T>>. The panicking form uses the same message as collect |
.try_wait_for_all_completions(others) / .wait_for_all_completions(others) |
Same concurrent drain, discarding output |
.try_wait_and_reduce(init, f) / .wait_and_reduce(init, f) |
Fold with (acc, item) -> acc and return the accumulator. Empty input returns init |
try_collect_pair(a, b) |
Concurrent drain of two pipelines whose item types may differ |
Split / Gather
split writes each branch to a bounded channel (capacity = buffer size in messages, default 4). A branch that nobody reads applies back-pressure on the router, so both branches must be consumed concurrently (std::thread::scope, or gather). Draining one branch to completion before starting the other can block the router once a buffer fills and stall the pipeline. Dropping a branch disconnects it: the router keeps feeding the other branch and does not block on the dead one.
use Pipeline;
use thread;
let = new.split;
scope;
split_unbounded keeps the old unbounded outputs (no back-pressure). It is the opt-in for callers that need to drain one branch fully before the other; the router can retain the whole input in memory.
use Pipeline;
let = new.split_unbounded;
let e = evens.collect; // sequential consumption is safe
let o = odds.collect;
assert_eq!;
assert_eq!;
use Pipeline;
let p1 = new;
let p2 = new;
let mut result = p1.gather.collect;
result.sort;
assert_eq!;
Error handling
A panic in any worker, including the feeder that pulls the source iterator, fails the whole pipeline. The first failure is kept as PipelineError::WorkerPanicked { stage, message } (stage is "task", "filter", "split", "reduce", "checkpoint", "feeder", …). The pipeline is cancelled: every stage checks a relaxed atomic between items and stops, and channel disconnection unblocks anyone waiting on a send or recv. .timeout(duration) records PipelineError::Timeout the same way (it does not only log).
try_collect, try_for_each, and try_wait_for_completion drain, join every worker, and return that error. collect, for_each, and wait_for_completion keep their old signatures and panic with olympipe-rs pipeline failed: {err} — the message includes the original panic text — so a stage failure reaches the caller the way an exception does in Python olympipe. The library does not print the panic itself; the default panic hook already does.
task_or is unchanged and is the item-aware callback form: a Result::Err from the closure does not fail the pipeline. A panic inside task_or still does. try_task / task_with_mode are the mode-based form (ErrorMode); see below.
For recoverable errors in a stage, use task_or:
use Pipeline;
let values = new
.task_or
.collect;
assert_eq!;
Stateful workers
class_task builds one value per worker thread, inside that thread, and lends it to the method as &mut. count == 0 is one worker and keeps input order, same as task. class_task_with_close receives the state when the worker stops: input exhausted, downstream disconnected, or the pipeline cancelled. It is not called if init or the method panics (Drop of the state still runs on unwind once init has returned).
use Pipeline;
let values = new
.class_task
.collect;
assert_eq!;
N-way split
split stays the two-branch path. split_by sends each item to branch f(&item) (an index >= n fails the pipeline, stage "split"). split_array is the optional-tuple form: one item can be copied to several branches or to none. Bounded variants use buffer_size and must be consumed concurrently (gather, gather_all, wait_for_results, or one thread per branch). The _unbounded variants can be drained one after another.
use Pipeline;
let mut values = gather_all
.collect;
values.sort;
assert_eq!;
Error modes
ErrorMode defaults to FailFast. try_task maps a Result: FailFast cancels the pipeline with PipelineError::TaskFailed { stage: "try_task", .. } and does not catch panics. Skip drops the item, SkipAndLog also writes olympipe-rs: [try_task] skipped item: {err} to stderr, and Replace(v) emits a clone of v. In those three modes a panic inside the closure is a per-item failure too. task_with_mode is the same policy for an infallible closure; FailFast is exactly task (panic stage "task").
use ;
let values = new
.try_task
.collect;
assert_eq!;
Waiting on several pipelines
wait_for_results / try_wait_for_results drain this pipeline and the others at the same time and return one Vec per pipeline, self first. Sequential drains deadlock when the pipelines are bounded branches of one split. wait_for_all_completions discards output. wait_and_reduce folds with (accumulator, item). try_collect_pair is the two-pipeline form for different item types. A failure cancels pipelines that share state, and every terminal is still joined so the others return.
use Pipeline;
let sum = new.wait_and_reduce;
assert_eq!;
use ;
let = try_collect_pair
.expect;
assert_eq!;
assert_eq!;
Design notes
| olympipe (Python) | olympipe-rs (Rust) |
|---|---|
multiprocessing.Process per stage |
std::thread::spawn per stage |
multiprocessing.Queue(maxsize=4) |
bounded(4) messages; up to 64 items each when the buffer is larger than 2, or one item with .with_max_packet(1) |
count=N → N processes sharing queues |
count → N threads, cloned Receiver (MPMC). count == 0 is one thread |
| Manager DAG for shutdown | Cancellation flag + channel disconnect; first error is kept |
Exception in a stage fails wait / wait_for_completion |
PipelineError from try_*; collect / for_each / wait_for_completion panic |
task(..., on_error=) |
try_task / task_with_mode (ErrorMode); task_or is the item-aware callback |
class_task constructs the instance in the worker |
class_task runs init on the worker thread; S need not be Send |
split → N queues, optional tuple |
split (2), split_by (index), split_array ([Option<U>; N]). Bounded; _unbounded opt-in |
gather → N identity processes |
gather / gather_all → one forwarder thread per source, forwarding whole packets |
wait_for_results / wait_and_reduce |
Same names, plus try_ forms. Several pipelines are drained concurrently |
checkpoint folds (item, state) then emits an iterable |
checkpoint; reducer is (state, item), same as reduce |
cached_task + uncache (CacheEnvelope) |
cached_task (feature cache) yields U directly. No uncache |
Checkpoint
checkpoint keeps one worker. reducer(state, item) folds every input into initial. When the input is exhausted and the pipeline has not failed, emitter(state) is streamed downstream (same packetised channel and back-pressure as other stages). A failure or cancellation emits nothing. A panic in either closure is stage "checkpoint".
use Pipeline;
let sorted = new.checkpoint;
assert_eq!;
Cached task
cached_task is behind the cache feature (serde_json + sha2). The default build does not depend on them.
[]
= { = "1", = ["cache"] }
Rust cannot hash a closure, so the first argument is a step name you choose. Change it when the computation changes ("resize-v2"). The key is hex SHA-256 of the raw SHA-256 of that name concatenated with the serde_json bytes of the input. Files live at {cache_dir}/{sha256(step)[:16]}/{key}.json. A hit does not call f. A miss calls f and writes the JSON by renaming a temporary file in the same directory. A corrupt file is recomputed and overwritten. An error while writing the cache still forwards the value. There is no uncache: the stage yields U directly. Keys follow serde_json, so prefer BTreeMap over HashMap when the input contains a map.
#
#
#
#
# demo;
Python olympipe parity
| Python | Rust |
|---|---|
task |
task |
task(..., on_error=) |
try_task / task_with_mode (ErrorMode); task_or is the item-aware callback |
class_task |
class_task, class_task_with_close |
explode |
explode |
batch |
batch (the last incomplete batch is always emitted) |
batch(..., keep_incomplete_batch=False) |
not ported |
temporal_batch |
temporal_batch |
filter |
filter (a predicate is required; dropping only None is not a separate method) |
gather |
gather, gather_all |
split |
split, split_by, split_array (bounded; _unbounded has no Python equivalent) |
debug |
debug |
reduce |
reduce |
checkpoint |
checkpoint (reducer is (state, item), not (item, state)) |
cached_task |
cached_task (feature cache; step stands in for hashing the closure) |
uncache |
not needed: cached_task yields U, not a CacheEnvelope |
limit |
limit |
timeout |
timeout |
wait_for_completion |
wait_for_completion, try_wait_for_completion, wait_for_all_completions |
wait_for_result |
collect, try_collect |
wait_for_results |
wait_for_results, try_wait_for_results |
wait_and_reduce |
wait_and_reduce, try_wait_and_reduce |
server |
not ported |
print_graph, debug_graph |
not ported |
monitoring, enable_monitoring, queues |
not ported |
are_pipelines_running |
not ported |
kill_generator_source |
not ported |