A compact, high-performance persistent record store with zero-copy batch gathering and transparent per-record compression, designed for single-machine AI training and serving pipelines
Project description
Loaderx
A compact, high-performance persistent record store with zero-copy batch gathering and transparent per-record compression, designed for single-machine AI training and serving pipelines
pip install loaderx
Wheels are published for Linux (glibc ≥ 2.17 and musl, x86-64 and arm64), macOS (≥ 11.0, Intel and Apple Silicon) and Windows (x64 and arm64). The bindings use cffi in ABI mode, so nothing links against the CPython ABI and one wheel per platform serves every supported Python.
Design Philosophy
loaderx is built around several core principles:
- A pragmatic approach that prioritizes minimal memory overhead and minimal dependencies.
- A strong focus on single-machine training workflows.
- We implement based on NumPy semantics, persisted through the Zrecord storage runtime.
- An immortal (endless) step-based data loader, rather than the traditional epoch-based design—better aligned with modern ML training practices.
Quick Start
from loaderx import Dataset, DataLoader
dataset = Dataset('train_data.zr')
labelset = Dataset('train_label.zr')
loader = DataLoader(dataset, labelset)
for i, batch in enumerate(loader):
if i >= 256:
break
print(batch['data'].shape)
print(batch['label'].shape)
Converting a NumPy tensor
import numpy as np
from loaderx import Dataset
Dataset.write('train_data.zr', np.load('data.npy', mmap_mode='r'))
Dataset.write('train_label.zr', np.load('label.npy', mmap_mode='r'))
One record per slice along axis 0; a 1-D array (the usual shape of a label set) becomes a dataset of scalars. The conversion streams in bounded chunks, so an mmapped array is never fully materialized.
Dataset keeps a single schema.json inside the store recording the per-sample
dtype and shape — that is the only loaderx-level metadata. Everything beneath it
is plain Zrecord, reachable through loaderx.Zrecord when you want raw byte
records (ragged samples, pre-encoded images) instead of tensors.
Removing samples
ds = Dataset('train_data.zr')
ds.delete([7, 12, 40]) # len shrinks; survivors stay addressable as 0..len
ds.compact() # reclaim the freed space on disk
print(ds.stats()) # {'records': ..., 'live_bytes': ..., 'reclaimable': ...}
Deletion keeps the index space dense, which is what the sampler needs — but it
does so by moving the tail into the hole, so surviving samples may change index.
Do not hold indices across a delete. Both calls require exclusive access; do not
run them while a DataLoader is iterating the dataset.
Integrating with JAX/Flax
For practical integration examples, please refer to the Data2Latent repository
Benchmarks
scripts/bench.py measures loaderx against the alternatives in three layers —
the index sampler, the record store, and the full data loader — each through the
binding a client actually uses, so cffi, the GIL and the NumPy allocation are all
inside the timings. The numbers below are one run on a 24-core machine, Python
3.14, warm page cache. Reproduce with python3 scripts/bench.py all.
1. Sampler — index generation on its own, IID (with replacement), 1M index space, against both NumPy random APIs.
| sampler | batch | per batch | indices | vs default_rng |
|---|---|---|---|---|
| numpy randint | 256 | 5.9 µs | 44 M/s | 0.64x |
| numpy default_rng | 256 | 3.7 µs | 69 M/s | 1.00x |
| zsampler | 256 | 1.9 µs | 138 M/s | 2.01x |
| numpy randint | 1024 | 7.2 µs | 142 M/s | 0.83x |
| numpy default_rng | 1024 | 6.0 µs | 171 M/s | 1.00x |
| zsampler | 1024 | 3.0 µs | 340 M/s | 1.99x |
| numpy randint | 8192 | 25.6 µs | 320 M/s | 0.65x |
| numpy default_rng | 8192 | 16.6 µs | 493 M/s | 1.00x |
| zsampler | 8192 | 7.8 µs | 1048 M/s | 2.13x |
Zsampler draws indices about 2x faster than NumPy's faster API — more against the
legacy randint most code still calls — and the lead is widest at the small
batches a loader actually draws, where a batch is one cffi call either way and
NumPy's vectorised fill has nothing to amortise. The one concession: zsampler's
IID draw is modulo-biased where NumPy's is not, ~1e-13 over a u64 draw into a
million-record space, which is below anything training will notice.
2. Store — random batch gather, 20000 records × 12 KiB uint8(3,64,64),
batch 256 × 200, structured (mildly compressible) data.
| store | write | gather | per batch | on disk | ratio |
|---|---|---|---|---|---|
| loaderx-flate | 362 MiB/s | 1671 MiB/s | 1.80 ms | 209 MiB | 1.12x |
| loaderx-raw | 784 MiB/s | 21997 MiB/s | 0.14 ms | 235 MiB | 1.00x |
| npy-mmap | 3064 MiB/s | 13692 MiB/s | 0.22 ms | 234 MiB | 1.00x |
| arrayrecord | 374 MiB/s | 847 MiB/s | 3.54 ms | 217 MiB | 1.08x |
| hdf5 | 1327 MiB/s | 511 MiB/s | 5.87 ms | 236 MiB | 0.99x |
| hdf5-gzip | 83 MiB/s | 206 MiB/s | 14.57 ms | 211 MiB | 1.11x |
| lmdb | 677 MiB/s | 5337 MiB/s | 0.56 ms | 313 MiB | 0.75x |
| lmdb-zlib | 71 MiB/s | 336 MiB/s | 8.93 ms | 235 MiB | 1.00x |
| blosc2 | 32 MiB/s | 226 MiB/s | 13.27 ms | 216 MiB | 1.09x |
| zarr | 83 MiB/s | 28 MiB/s | 107.36 ms | 217 MiB | 1.08x |
| tensorstore | 531 MiB/s | 98 MiB/s | 30.69 ms | 214 MiB | 1.09x |
The gather column is what the store is built for. loaderx-raw returns a scattered
batch faster than everything here, a memory-mapped .npy included, because it
copies straight into the destination across all cores with the GIL released;
loaderx-flate keeps most of that speed while decompressing, and still gathers
several times faster than any other compressed store (hdf5-gzip, lmdb-zlib,
blosc2, zarr, tensorstore). The array stores lag on gather because a scattered
single-record read fights a layout meant for contiguous slabs — which is exactly
the access pattern a training loader has. Where loaderx does not lead is write: it
pays compression and its own layout up front, a one-time cost it does not chase.
Compression is modest (1.12x) only because this data is barely compressible; the
row's point is that turning it on costs almost nothing on read.
The reads are not all single-threaded: loaderx and blosc2 decompress across cores,
the rest one record at a time. Zarr and TensorStore are sharded so on disk
reflects compression rather than per-file block rounding, and TensorStore is given
gzip (its zarr3 default is uncompressed). on disk is allocated blocks, which is
why LMDB's preallocated map lands below 1.00x.
3. Loader — the full input pipeline end to end (sample, fetch, collate, hand
over a batch), same workload, 4 workers. Each loader reads from what it is built
for: loaderx from Zrecord, Grain from ArrayRecord, torch from a memory-mapped
.npy, jax-dataloader from an in-memory array.
| loader | batches/s | per batch | samples/s | throughput |
|---|---|---|---|---|
| loaderx | 724.2 | 1.38 ms | 185,406 | 2173 MiB/s |
| loaderx-raw | 5271.0 | 0.19 ms | 1,349,378 | 15813 MiB/s |
| grain | 67.3 | 14.85 ms | 17,240 | 202 MiB/s |
| torch | 832.2 | 1.20 ms | 213,038 | 2497 MiB/s |
| jax-dataloader | 3284.6 | 0.30 ms | 840,864 | 9854 MiB/s |
End to end, loaderx-raw serves batches several times faster than torch or Grain and ahead of jax-dataloader — and it does so from disk-backed storage, where jax-dataloader's number is an in-memory ceiling with no storage path at all. The default loaderx trades some of that for on-disk compression and still lands in torch's range while reading compressed records. loaderx and jax use threads; torch and Grain use worker processes that escape the GIL but pay IPC per batch. loaderx never ends an epoch, so no step waits on a boundary; the others restart per epoch, a cost that is in their numbers as it would be in training.
Current Limitations
- Single-host only; multi-host training is not supported.
- A single sample must be at most 1 MiB, and a store holds at most 2^24 (16777216) records.
- Metadata is mapped and used in place, so a store carries the host's byte order and is not portable to a machine of the opposite endianness. Every published platform is little-endian, so this only matters if you build for one yourself.
Build
zig build # host shared objects, into loaderx/lib/
zig build test # Zrecord suite, in both Debug and ReleaseFast
python3 tests/test_loaderx.py # Python layer, against whichever build is importable
python3 scripts/bench.py # throughput, against other stores
The Zig side is tested for behaviour only; throughput is measured from Python,
through the binding a client actually uses. scripts/bench.py runs in three
layers (sampler, store, loader, or all) and skips contenders that are not
installed. See Benchmarks.
Publishing
Zig cross-compiles every target from one machine, so releases need no CI matrix:
zig build dist # every platform, into zig-out/dist/<wheel tag>/
python3 scripts/build_wheels.py # one wheel per platform, plus the sdist
The dist directories are named after their Python wheel platform tag, so the tag
mapping lives in exactly one place (dist_targets in build.zig). glibc and
macOS minimums are pinned in the target triple, which is what makes
manylinux_2_17 and macosx_11_0 honest rather than aspirational. Each wheel is
checked after packing: it must carry this platform's libraries and no others.
The sdist ships sources only. Installing from it runs zig build through
setup.py, so it needs the Zig compiler; wheel users never hit that path.
Zsampler
Index Generator: a high-performance sampler implemented in Zig
- Sequential generation: indices are produced by traversing the index space in order.
- Sliding traversal: indices are obtained using a fixed-size sliding window. Note that in this case, the index space is treated as a circular queue to avoid truncation at the tail.
- Random generation: indices are sampled randomly from the index space.
- Global random: a set of samples is drawn randomly from the entire index space.
Zrecord
A record-based data runtime, focused on delivering extreme throughput and low latency.
- Zrecord is an unordered physical store made of N records. Records are independent and carry no ordering, so every index and slice operation is equivalent to a gather.
- Zrecord hands the client a dense index space: the live records are always
exactly
0..N. Deletion preserves that by swapping the tail into the hole, which means an index is stable only until something is deleted. A multi-stream dataset is purely a client-side notion; the client keeps its own stream index table pointing at records. - Zrecord returns byte arrays. Type interpretation is the client's job.
- For performance, the IO model (
append | read | delete) is batch-oriented; a single-record operation is just thebatch_size == 1case. - Zrecord owns its memory internally — allocation and release are explicit.
- Each record is compressed and decompressed independently:
| tag | name | algorithm |
|-------|---------|-----------|
| 0 | raw | none |
| 1 | flate | Deflate |
- Compression is transparent to the client:
- Compression runs concurrently across all cores. A record that fails to shrink is stored raw automatically, so reading never costs more than the data itself.
- Decompression writes straight into the caller's destination memory
(
gather), with no intermediate buffer and no extra copy.
Persistence format
Zrecord storage is metadata plus chunked data:
zrecord/
├── meta.zr
├── 0.zr
└── 1.zr
Metadata (meta.zr)
meta.zr is used as the values it holds, not decoded into them. Both structs
below are extern, 16 bytes, and naturally aligned; an mmap is page aligned, so
the header is a *Header and the record table is a []RecordLoc. Looking up a
record is table[idx], and there is no serializer anywhere in the code.
The fields are deliberately wider than the limits need — chunk_id is a u16
for 4096 chunks, the lengths are u32 for a 1 MiB cap. Bit-packing them would
save 5 bytes per record and cost a hand-written codec on the hottest lookup
path. The slack buys a machine-shaped array; it is worth it.
1. Header — 16 bytes of global state.
magic(ZREC) andversionare ordinary fields, so opening a directory that is not a Zrecord store fails immediately instead of decoding garbage.lengthis the total number of records |tail_chunk/tail_offsetmark the last write position.- There is no chunk count. Chunks are created in order and the frontier is
always in the last one, so the store holds exactly chunks
0..=tail_chunk— a count would be a second copy of that fact to keep in sync.
const Header = extern struct {
magic: [4]u8, version: u16,
tail_chunk: u16, tail_offset: u32,
length: u32,
};
2. Record table — 16 bytes per entry, indexed directly. Mapping an index to a physical address is what makes random access efficient.
chunk_idis the containing chunk |offsetis the position within it |phys_length/logic_lengthare the stored and original sizes |compressis the algorithm.
const Codec = enum(u8) { raw = 0, flate = 1, _ };
const RecordLoc = extern struct {
offset: u32, phys_length: u32, logic_length: u32,
chunk_id: u16, compress: Codec, _reserved: u8,
};
There is no liveness flag. Every entry below length is live, because deletion
swaps the tail into the hole rather than tombstoning.
3. Maximum length. Running out of space is effectively impossible, so the metadata is sized statically at the maximum.
- Current design: up to 2^12 (4096) chunks, 2^32 (4 GiB) per chunk, 2^20 (1 MiB) per record.
- Derivation: N = chunks × 2^32 (chunk size) / 2^20 (max record size) ⇒ a maximum length of 2^24 (16777216). Equality here is the point: the store can never run out of chunks before it runs out of record slots, which is what lets the table be sized statically. It is a compile-time assertion.
- meta.zr is a fixed 16 + 2^24 × 16 bytes (256 MiB). It is sparse — a store with one record allocates 4 KiB of it — and the mapping does not prefault.
Chunk data (x.zr)
Densely packed record data.
Executor
1. Write. Writes are append-only; everything else is offset redirection.
- Append: compress concurrently → assign physical locations serially → flush
concurrently → commit metadata. Data is durable-ordered before the record
table, and the table before
length, so a crash truncates rather than corrupts. A record never straddles two chunks; one that would not fit rolls over to a fresh chunk. - Delete: swap the last table entry into the deleted slot and drop the length by
one. A batch is applied in descending index order, so each swap pulls from a
slot no later target refers to. The index space stays dense — which is what
the sampler needs, since it draws uniformly from
0..Nand would otherwise keep hitting holes. The deleted record's bytes become garbage.
2. Read. Fill the destination memory concurrently, in place from the Python side (executed on async threads).
- Committed records are immutable, so the read path is lock free;
lengthis published to readers through an atomic.
3. Concurrency model. Io.Group.async shards work by CPU count, and shards
beyond the limit run inline on the calling thread.
- Shards receive contiguous blocks rather than a strided subset, keeping each worker's reads and writes sequential.
- Each shard reuses one compressor scratch (
flate.Compressis ~230 KiB — far too large to rebuild per record, let alone to place on a task stack). - Decompression runs in direct mode: matches are resolved against the bytes already written into the destination, so there is no window buffer.
4. Garbage collection. Deletion leaves the record's bytes stranded, so space
is reclaimed by an offline compact that rewrites the live records in place.
- Records are visited in physical order — which after swap-last deletes no longer matches table order — and repacked densely in that same order. A record therefore never moves to a higher address than it already had.
- Two things follow. Writing a record can never land on the bytes of a record not yet moved, so the rewrite is safe in place with no scratch copy of the store. And each table entry can be updated the instant its record lands, so the store is consistent at every point: an interrupted compaction leaves some records moved and the rest where they were, and re-running finishes the job.
- Records that are already in the right place are skipped, so a store with a small amount of garbage near the end is cheap to compact.
- Chunk files past the new frontier are closed and deleted.
stats()reportslive_bytesagainstchunk_bytesso callers can decide when it is worth running. Note the difference is an upper bound: a record never straddles a chunk boundary, so up to one record's worth per chunk is slack that compaction cannot remove.
5. File access.
- Metadata: a 192 MiB file created at init, accessed by mmap thereafter.
- Chunk data: 4 GiB files created at init, accessed concurrently through
readPositionalAll/writePositionalAll.
Concurrency contract. gather and append are safe to call concurrently
from many threads. delete and compact mutate the table in ways a lock-free
reader would observe half-applied, so they require exclusive access to the
store.
Distributed extension
Not implemented yet; architecture noted in advance.
- A cluster layer is added; a node becomes a shard holding several chunks.
- An indirection table maps a global path to a specific node, so the index path
becomes
idx → indirection[idx] = node, lidx → offset[lidx].
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distributions
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file loaderx-0.5.4.tar.gz.
File metadata
- Download URL: loaderx-0.5.4.tar.gz
- Upload date:
- Size: 56.4 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.14.6
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
0e50b59b2d6e0061f35e52e516b4743d8f2c7ae823f67fef3c6d25a5092db9f1
|
|
| MD5 |
196762f130dd47f97952c09f5698d6fe
|
|
| BLAKE2b-256 |
64621c5553ac9ee5000168b4cddf1edafbb8f5edbb19274270d1bac683541e98
|
File details
Details for the file loaderx-0.5.4-py3-none-win_arm64.whl.
File metadata
- Download URL: loaderx-0.5.4-py3-none-win_arm64.whl
- Upload date:
- Size: 401.9 kB
- Tags: Python 3, Windows ARM64
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.14.6
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
d82c703e0bcf02e9f2b504a76bcfa15a0b5c5fa79725a034dee1f1ae30c2bd0c
|
|
| MD5 |
16cdd9be29110ba441aeab17c0ca978f
|
|
| BLAKE2b-256 |
f9e41c3a411d05aac9851cdf4d6bc6671ad1e41db794944c196502f44e9a1b78
|
File details
Details for the file loaderx-0.5.4-py3-none-win_amd64.whl.
File metadata
- Download URL: loaderx-0.5.4-py3-none-win_amd64.whl
- Upload date:
- Size: 439.2 kB
- Tags: Python 3, Windows x86-64
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.14.6
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
4d2d5d00eccf79540dd047326b6926c99282f328c77d81af1ce9d93f74c90eaf
|
|
| MD5 |
8a3b7c4f701b1aa684455ebd1aab72b3
|
|
| BLAKE2b-256 |
6d3724d2bd4f0f974e6c6ab4c321c23341df56feb37a6ae9c7493cb3472eb250
|
File details
Details for the file loaderx-0.5.4-py3-none-musllinux_1_2_x86_64.whl.
File metadata
- Download URL: loaderx-0.5.4-py3-none-musllinux_1_2_x86_64.whl
- Upload date:
- Size: 1.1 MB
- Tags: Python 3, musllinux: musl 1.2+ x86-64
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.14.6
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
1f5cf00213661447bc2c841520d13acbb76bf4419e4ffdce8505f4084e4f4409
|
|
| MD5 |
b62e2aafceba9966165b9504d1552198
|
|
| BLAKE2b-256 |
23455c84035a434f4e872d844926e4141087e91ce9ed0e5f498aec5e845ce9ed
|
File details
Details for the file loaderx-0.5.4-py3-none-musllinux_1_2_aarch64.whl.
File metadata
- Download URL: loaderx-0.5.4-py3-none-musllinux_1_2_aarch64.whl
- Upload date:
- Size: 1.1 MB
- Tags: Python 3, musllinux: musl 1.2+ ARM64
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.14.6
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
70d85245ba81130743a0f40a54d43b6eca72734954c6c4a8d3e4c998ef2392ff
|
|
| MD5 |
f21830e7e0dbea48ec6796d9cc3e560e
|
|
| BLAKE2b-256 |
47009ccf1e371bc3d69f26621579bbac971105e3f96f04539025b044a7ad34ff
|
File details
Details for the file loaderx-0.5.4-py3-none-manylinux_2_17_x86_64.whl.
File metadata
- Download URL: loaderx-0.5.4-py3-none-manylinux_2_17_x86_64.whl
- Upload date:
- Size: 1.1 MB
- Tags: Python 3, manylinux: glibc 2.17+ x86-64
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.14.6
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
cf1628d58b0cde5007874fe37df4c58526fa4edfd6bff6e8d89e844088e85e90
|
|
| MD5 |
d35dc1682678e6e6d6ba62db1a578342
|
|
| BLAKE2b-256 |
4acd1ea55c290d2f4e4d2b51e55d5b64aff056b5183f901ef6f0b2491f84c254
|
File details
Details for the file loaderx-0.5.4-py3-none-manylinux_2_17_aarch64.whl.
File metadata
- Download URL: loaderx-0.5.4-py3-none-manylinux_2_17_aarch64.whl
- Upload date:
- Size: 1.0 MB
- Tags: Python 3, manylinux: glibc 2.17+ ARM64
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.14.6
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
b0ee7b3849a3ae49376b70db7787e6318fec744834ef6669068483ca4d9b87e2
|
|
| MD5 |
1341f46d7ba1efc9e478c6fd5b57444e
|
|
| BLAKE2b-256 |
9ef991b2ebfa67cc28f467256396273a36559ac72185b3141554d97c08e678ae
|
File details
Details for the file loaderx-0.5.4-py3-none-macosx_11_0_x86_64.whl.
File metadata
- Download URL: loaderx-0.5.4-py3-none-macosx_11_0_x86_64.whl
- Upload date:
- Size: 207.5 kB
- Tags: Python 3, macOS 11.0+ x86-64
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.14.6
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
4bb160a4f2cff8bc38c172893143bbde4433289c541f9fdc3446e02dec820e6b
|
|
| MD5 |
f1c7183b15b2766a9c8eef8edf47671d
|
|
| BLAKE2b-256 |
fcc1808f8da0f62d53661526b8ea138d63e446a07dd123bdf5def2308ebe3890
|
File details
Details for the file loaderx-0.5.4-py3-none-macosx_11_0_arm64.whl.
File metadata
- Download URL: loaderx-0.5.4-py3-none-macosx_11_0_arm64.whl
- Upload date:
- Size: 198.6 kB
- Tags: Python 3, macOS 11.0+ ARM64
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.14.6
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
b5ce41b3235775a9bbc191af29cb0f921813dc9e153b9fd235427490101696f0
|
|
| MD5 |
718c042506c6ea49f0f05eeeb74880d1
|
|
| BLAKE2b-256 |
15ea21d8986174bdb9aaeba131946b8d5afc301209d9777bcf1fc419d5c5474e
|