dataloader-rs
PyTorch's DataLoader model — dataset, sampler, collator, loader — as a typed Rust library,
with a Python binding that runs the same pipeline on threads instead of worker processes.
- Familiar model, typed end to end:
DataLoader<Dataset, Sampler, Collator>is generic and monomorphized; batches come out in epoch order asResult<Batch>. - Threads, not processes: workers share the dataset through
Arc, so samples are handed over by reference instead of being pickled across processes. Againsttorch.utils.data.DataLoaderon 4 cores, the Python binding is 4.9–6.6× faster on the loader's own overhead, 3.2–3.9× on network-bound datasets, 2.5–3.5× on array samples with free-threaded Python, and starts an epoch in milliseconds (BENCH.md). - Two explicit levels of parallelism:
num_workersbatches in parallel anditems_in_parallelitems of each batch — on one thread pool built with the loader and reused by every epoch. - Bounded, ordered prefetch: at most
num_workers + prefetch_depthbatches are in flight or waiting, even behind one slow batch; they come out in epoch order. - Failures surface: a dataset or collator error is returned for its batch, a panic is re-raised on the consumer, and Python exceptions keep their type.
- Python binding:
PyDataloadermirrors the PyTorch arguments (batch_size,shuffle,sampler,collate_fn,drop_last,seed,__getitems__); wheels for CPython 3.13 to 3.15 and free-threaded 3.14t / 3.15t on Linux (x86_64, arm64) and macOS (arm64). - No unsafe code:
#![forbid(unsafe_code)].
When PyTorch is faster. The workers are threads in one process, not processes. On regular CPython (the GIL build, such as 3.13), only one thread runs Python code at a time: a
__getitem__that spends its time in pure Python (tokenising text, parsing records, Python loops) gets no faster with more workers, while PyTorch's worker processes each run their own interpreter and are 2.4–2.6× faster on such datasets. Threads win when the work releases the GIL (file and network I/O, NumPy, image decoding), and on free-threaded CPython 3.14t / 3.15t. Even there, threads that all read one shared Python dict or list contend on its reference count, and PyTorch is 2.5–2.9× faster: give each thread its own copy (threading.local). Measurements: BENCH.md.
Contents
- Quick start: Rust
- Quick start: Python
- Concepts
- Configuration
- Feature flags
- Performance
- Documentation
- Project layout
- Building and testing
- Minimum supported Rust version
- License
Quick start: Rust
[]
= "0.1"
use ;
/// One sample per file; `get` is called from worker threads.
;
cargo run --example basic runs a complete example.
Quick start: Python
=
return
return
=
# a list of 32 `bytes`, or whatever collate_fn returns
...
The full Python guide — arguments, samplers, collate_fn, __getitems__, errors, threads and
the GIL — is python/README.md.
Concepts
| Piece | Trait / type | Provided | Guide |
|---|---|---|---|
| Dataset | Dataset: get(index), len(), optional get_batch(indices) |
— (yours) | src/README.md |
| Sampler | Sampler: the index order of one epoch |
SequentialSampler, RandomSampler, DistributedSampler |
src/sampler |
| Collator | Collator<Item>: merge a batch's samples |
VecCollator (default), DefaultCollator, TorchPinnedCollator |
src/collator |
| Loader | DataLoader / DataLoaderBuilder / DataLoaderIter |
— | src/loader |
One epoch: the sampler produces indices, BatchSampler cuts them into batches of batch_size,
each batch is fetched (Dataset::get_batch, or get per item in parallel) and collated, and the
iterator yields the batches in order. How the threads, the bounded prefetch and the reorder
buffer fit together is in ARCHITECTURE.md.
Configuration
Every setting is a DataLoaderBuilder method; the Python argument of the same name maps to it.
| Builder method | Default | Effect |
|---|---|---|
batch_size(n) |
1 |
samples per batch |
drop_last(bool) |
false |
drop a final batch smaller than batch_size |
sampler(s) |
SequentialSampler |
index order of each epoch |
collator(c) |
VecCollator |
how a batch's samples are merged |
num_workers(n) |
0 |
batches processed in parallel; 0 processes batches inside next() |
items_in_parallel(k) |
1 |
items of one batch fetched in parallel (1: one get_batch call per batch) |
prefetch_depth(n) |
2 |
how far batches may run ahead: at most num_workers + n in flight or waiting |
pin_memory(bool) |
— | pin tch::Tensor batches in host memory (feature torch-rs) |
At most max(num_workers, 1) × items_in_parallel fetches run at once, on that many threads.
Rules of thumb: num_workers = 0 is the fastest path for cheap samples (no thread hand-off);
use workers once a batch costs well more than its hand-off, about 0.5 µs in Rust (a few
microseconds through Python); for blocking I/O, items_in_parallel so that
num_workers × items_in_parallel requests are in flight, far more than cores; the default
prefetch_depth 2 is enough when loading keeps up, beyond that it only holds memory
(BENCH.md).
Feature flags
| Feature | Default | Enables |
|---|---|---|
ndarray |
off | DefaultCollator for ndarray::Array (stacked on a new axis 0) |
torch-rs |
off | DefaultCollator for tch::Tensor (stacked), TorchPinnedCollator, pin_memory |
python |
off | the PyO3 module behind the dataloader-rs Python package |
Performance
Measured on an Intel Core i9-13900H pinned to 4 cores, the size of the CI runners, on CPython 3.13, 3.14t and 3.15t; method, every table and the caveats are in BENCH.md. Continuous results per commit: dashboards for Rust and Python 3.14t.
Python workload, against torch.utils.data.DataLoader (best configuration of each) |
Result |
|---|---|
| Loader overhead (in-memory samples) | 4.9–6.6× faster |
Network-bound samples (10 ms each), items_in_parallel |
3.2–3.9× faster; first batch 21 ms against 320+ ms |
| First batch of an epoch | milliseconds, against 11–334 ms for torch's fresh worker processes |
| Array samples: read + normalise a file, augment an image (4 workers) | 2.5–3.5× faster on 3.14t / 3.15t, 1.0–1.5× on 3.13; with the allocator settings of BENCH.md, section 8 |
| Pure-Python CPU work, per-thread tables, free-threaded 3.14t / 3.15t | ≈ 1.2× faster |
| Pure-Python CPU work on shared objects, or under the GIL | torch 2.4–2.9× faster (processes share nothing) |
Documentation
| Document | Content |
|---|---|
| ARCHITECTURE.md | execution paths, threads, backpressure, ordering, errors, panics, the Python binding |
| BENCH.md | what is measured and how, results, comparison with PyTorch, caveats, how to rerun |
| python/README.md | the Python package (also its PyPI page) |
| src/README.md | code map, and the Dataset contract |
| src/loader, src/sampler, src/collator, src/python | one guide per module (also on docs.rs) |
| bench/, benches/, tests/, examples/ | what each script, suite and test file covers |
| CHANGELOG.md | release notes (generated by semantic-release) |
Project layout
| Path | What |
|---|---|
src/ |
the crate: dataset, sampler, collator, loader, python |
dataloader_rs/ |
Python package: re-exports and type stubs |
python/ |
Python package README (PyPI) |
tests/ |
Rust integration tests and the pytest suite |
benches/ |
Criterion benchmarks (Rust) |
bench/ |
Python benchmarks: pytest-benchmark guards and comparisons with PyTorch |
examples/ |
runnable Rust examples |
Building and testing
Prerequisites: Rust ≥ 1.95, uv. The development environment uses
free-threaded CPython 3.14t (.python-version); for a GIL build prefix commands with
uv run --python 3.13.
# Rust
# Python
# Lint, as CI does
CI=1
Benchmarks: BENCH.md, section 9.
Commits follow Conventional Commits; releases, the
changelog, the crate and the wheels are published by semantic-release from main.
Minimum supported Rust version
Rust 1.95 (rust-version in Cargo.toml), checked in CI by the Rust tests (MSRV) job.
License
MIT — see LICENSE.