Skip to main content

Loaderx

Zrecord is a rebuildable, typed, ordered record sequence built from authoritative source data and scripts. A creator consumes records in append order, close publishes one immutable container, and readers support scalar access, natural iteration, slices, and indexed gather without changing record identity. A scalar read returns the record itself, with no batch wrapper. To change content or order, rebuild it at a new path.

Zrecord is the typed on-disk container; Loaderx is the sampler and prefetch loader that consumes Zrecord streams. They currently ship together while both layers mature, but their public responsibilities remain separate.

pip install loaderx

Wheels are published for CPython 3.10+ on glibc Linux (x86-64 and ARM64), Apple Silicon macOS, and Windows AMD64. Free-threaded CPython 3.14 is also supported.

Design Philosophy

loaderx is built around several core principles:

  1. A pragmatic approach that prioritizes minimal memory overhead and minimal dependencies.
  2. A strong focus on single-machine training workflows.
  3. NumPy-native typed records with explicit schemas.
  4. An immortal (endless) step-based data loader, rather than the traditional epoch-based design—better aligned with modern ML training practices.
  5. Dense and ragged are separate contracts. Dense batches are arrays; ragged batches are lists of arrays. Loaderx never pads either representation.
  6. Logical IDs are stable sequence positions. Append order defines 0..N-1; published containers are immutable.

Usage

Quick Start

import numpy as np

from loaderx.zrecord import Dense
from loaderx.dataloader import DataLoader
from loaderx.sampler import Sampler

data = np.load('data.npy', mmap_mode='r')
label = np.load('label.npy', mmap_mode='r')
with Dense.create('train_data', data.dtype, data.shape[1:], codec='zstd') as ds:
    ds.append(data)
with Dense.create('train_label', label.dtype, label.shape[1:], codec='zstd') as ds:
    ds.append(label)

data_store = Dense.open('train_data')
label_store = Dense.open('train_label')
sampler = Sampler(len(data_store), 256, Sampler.Mode.CYCLIC, seed=42)
loader = DataLoader({'data': data_store, 'label': label_store}, sampler,
                    transform=lambda batch: batch)
for i, batch in enumerate(loader):
    if i >= 256:
        break

print(batch['data'].shape)
print(batch['label'].shape)

loader.close()
data_store.close()
label_store.close()

A batch is a dict {name: values}: each value is the stacked (batch_size, *item_shape) array for that stream. Every stream is gathered at the same indices, so record i lines up across them. The transform callback is the collate step — reshape, cast, stack — where values is the plain dense batch ready for the model.

Records

Dense stores one fixed-shape array per record. A scalar read or one step of iteration returns the record itself; a multi-record read returns a stacked array:

import numpy as np
from loaderx.zrecord import Dense

data = np.arange(64, dtype=np.float32).reshape(8, 2, 4)
with Dense.create('data', data.dtype, data.shape[1:], codec='zstd') as ds:
    ds.append(data)
with Dense.open('data') as ds:
    record = ds[0]                    # ndarray (2, 4), no leading batch axis
    batch = ds[0, 5, 2]               # ndarray (3, 2, 4), requested order retained
    for record in ds:                 # each record is ndarray (2, 4)
        consume(record)

A store is an ordered sequence of records, not an ndarray, so ds[0, 5, 2] selects sequence positions 0, 5 and 2 — never ds[0][5][2]. A scalar selects one unwrapped record, while any collection of indices selects a batch. Indices must be in 0..len(ds)-1. The ragged example below reads the same way.

Ragged stores variable-shape arrays with one shared dtype and ndim. Multi-record reads return a list; scalar reads and iteration return one array. Every record's exact shape is restored and every axis may vary; scalar records use Dense with item_shape=(). Padding remains an application policy:

from loaderx.zrecord import Ragged

seqs = [np.arange(L, dtype=np.int32) for L in (3, 1, 4, 1, 5)]
with Ragged.create('tokens', np.int32, ndim=1, codec='zstd') as rs:
    rs.append(seqs)                       # dtype/rank fixed; lengths remain per-record
with Ragged.open('tokens') as rs:
    record = rs[2]                    # ndarray (4,), not a one-element list
    records = rs[0, 2, 4]             # list of ndarray, exact per-record shapes
    for record in rs:                 # each item is one shape-restored ndarray
        consume(record)

lengths = np.array([len(r) for r in records])
padded = np.zeros((len(records), lengths.max()), dtype=records[0].dtype)
for i, r in enumerate(records):
    padded[i, :len(r)] = r             # (B, max_len) — your policy, your loop

Logical ID is the stable sequence position. Each append preserves input order, and successive calls extend the sequence. After close, the sequence is immutable: there is no delete, update, compaction, or reopen-for-append.

Creating containers

Dense.create and Ragged.create return append-only builders. The required codec is "raw", "zstd", or "zstd_dict". Schema, shape, and dtype are explicit and are never inferred from the input. Dense accepts the complete NumPy array or memory map; Ragged accepts the complete finite iterable of NumPy arrays. Pass the complete source to one append; the native Zig writer bounds its scheduling and scratch memory internally, so callers do not need to slice the source into memory-control batches.

A creator consumes a finite build stream and publishes an immutable sequence; readers do not tail an active writer. Append is synchronous and returns after the complete input has been persisted.

from loaderx.zrecord import Dense, Ragged

with Dense.create('mnist/x', dtype=np.uint8, item_shape=(28, 28),
                  codec='zstd', data_shards=4) as ds:
    ds.append(images)

with Ragged.create('tokens', dtype=np.int32, ndim=1, codec='zstd') as tok:
    tok.append(sequences)

with Dense.open('mnist/x') as ds:
    first_four = ds[:4]               # opened containers are read-only

data_shards controls write parallelism, accepts 1..255, and defaults to four. Readers discover it automatically.

Append errors are reported by the current call. A writer cannot be read and a reader cannot be appended to. Writer close() publishes the container; reader close() releases it. Dense, Ragged, and DataLoader support with.

Structured, subarray, object, metadata-bearing, and zero-itemsize dtypes are not supported. Append does not coerce Python containers or bytes; convert them with np.asarray or np.frombuffer first. Raw bytes and encoded files can be stored as np.uint8 Ragged records.

Codec notes

"zstd" compresses each record independently with plain zstd (level 3). Use it for general-purpose compression; it is fast and must be selected explicitly.

"zstd_dict" uses a shared dictionary trained from representative settled data. It is useful for large corpora of small, similar records such as token sequences and image tiles. Plain "zstd" or "raw" is usually better for large records and small corpora.

Train the dictionary manually with train_dict on settled data, then pass its bytes to dict_bytes. The source may be a NumPy array, an iterable of records, or an existing typed container. zstd_dict never trains automatically:

from loaderx.utils import train_dict
from loaderx.zrecord import Dense, Ragged

# train once on the settled data — a standalone, reusable artifact
d = train_dict(settled_array)

# then any new store can install it and append explicitly
with Ragged.create('tokens', np.int32, ndim=1,
                   codec='zstd_dict', dict_bytes=d) as ds:
    ds.append(token_generator)
with Dense.create('data', data.dtype, data.shape[1:],
                       codec='zstd_dict', dict_bytes=d) as ds:
    ds.append(data)

Changing an existing store's codec requires writing a new store:

from loaderx.utils import train_dict
from loaderx.zrecord import Dense

with Dense.open("src") as s, \
     Dense.create("dst", dtype=s.dtype, item_shape=s.item_shape,
                       codec="zstd_dict", dict_bytes=train_dict(s)) as d:
    d.append(s[:])                             # ndarray -> dense append

For Ragged, s[:] already returns the list accepted by append; create the destination with s.dtype and s.ndim. The destination path must be new.

Important: Train the dictionary from settled authoritative input before building the container. Training it before preprocessing is complete wastes compression and does not describe the final records.

Offline Hugging Face conversion

The optional converter uses Hugging Face Datasets for remote discovery, download, caching, revision handling and source-format decoding. It then writes explicit typed Zrecord streams, so training needs neither datasets nor Arrow:

pip install 'loaderx[converter]'
from loaderx.converter import convert, huggingface

dataset = huggingface("ylecun/mnist", revision="main", token=None)
convert(
    dataset,
    "mnist",
    codec="zstd",
)

Mainland China or a private Hub can select an endpoint directly without setting process environment variables:

dataset = huggingface(
    "ylecun/mnist",
    endpoint="https://hf-mirror.com",
)

huggingface resolves the requested revision to an immutable snapshot and returns its DatasetDict. Pass config=... when a repository has no default configuration, and token="hf_..." for private or gated repositories.

convert preserves numeric dtypes and record shapes. Fixed-shape columns become Dense stores and variable-shape columns become Ragged stores. Unsupported features fail explicitly; conversion does not filter, pad, tokenize, reshape, or apply a transform.

The result groups aligned streams under one published root:

mnist/
  train/
    image/
    label/
  test/
    image/
    label/

The output is published only after all columns have been written and their record counts agree. Each column remains an ordinary Zrecord store.

DataLoader and multi-stream stores

A zrecord store is one stream; a training sample is usually several named streams (skeleton + label + id, tokens + label, ...). Composition is a plain Python dict passed to DataLoader. There is no persistent wrapper, manifest, directory convention or bundle mutation API. DataLoader verifies that all streams have the same length, then gathers every stream with the same indices.

from loaderx.zrecord import Dense, Ragged
from loaderx.dataloader import DataLoader
from loaderx.sampler import Sampler

root = "xsub/train"
with Dense.create(root + "/joint", joint.dtype, joint.shape[1:], codec="zstd") as s:
    s.append(joint)
with Dense.create(root + "/label", label.dtype, label.shape[1:], codec="zstd") as s:
    s.append(label)
with Ragged.create(root + "/token", np.int32, ndim=1, codec="zstd") as s:
    s.append(seqs)

streams = {
    "joint": Dense.open(root + "/joint"),
    "label": Dense.open(root + "/label"),
    "token": Ragged.open(root + "/token"),
}
streams["joint"][0, 5, 2]            # each stream keeps its own index API
sampler = Sampler(len(streams["joint"]), 256, Sampler.Mode.CYCLIC, seed=42)
loader = DataLoader(streams, sampler)
batch = next(loader)                  # {name: values}, index-aligned

loader.close()
for stream in streams.values():
    stream.close()

Equal-length and variable-length are both just stores — Dense (one fixed-shape record per sample) and Ragged (variable row count per record). The loader does not interpret either: it fetches by index and packs a dict, so a dense stream's batch value is the stacked (B, *item_shape) array and a ragged stream's is a list of per-record arrays. No padding is imposed — densify to a fixed shape however the model needs (a plain numpy loop), or reshape/stack in the transform collate.

Collation is the transform: a batch dict in, an arbitrary result out.

def collate(batch):
    return {'input_ids': batch['tokens'], 'label': batch['label']}

sampler = Sampler(len(dense_tokens), 32, Sampler.Mode.CYCLIC, seed=42)
loader = DataLoader({'tokens': dense_tokens, 'label': labelset}, sampler,
                    transform=collate)
batch = next(loader)
loader.close()

The transform runs once per gathered batch. Calls may run concurrently and complete out of order, so the callback must be thread-safe. Its return value is passed to the consumer unchanged, and exceptions propagate to the consumer.

Numba can optionally accelerate a CPU-heavy Dense transform while releasing the GIL. Compile it before timing, then call it from the ordinary transform:

import numba
import numpy as np

@numba.njit(nogil=True, parallel=False)
def normalize_u8(x):
    out = np.empty(x.shape, dtype=np.float32)
    for i in range(x.size):
        out.flat[i] = x.flat[i] / 255.0
    return out

normalize_u8(np.zeros((1, 3, 224, 224), dtype=np.uint8))  # compile warmup

def transform(batch):
    batch["image"] = normalize_u8(batch["image"])
    return batch

Numba is optional. Compile it before measuring loader throughput.

CPU → GPU transfer

Loaderx produces CPU batches. Device transfer belongs in transform, where the training framework is already available:

import torch

device = "cuda:0"
def to_device(batch):
    return {k: torch.from_numpy(v).to(device, non_blocking=True)
            for k, v in batch.items()}

streams = {
    "joint": Dense.open(root + "/joint"),
    "label": Dense.open(root + "/label"),
}
sampler = Sampler(len(streams["joint"]), 256, Sampler.Mode.CYCLIC, seed=42)
loader = DataLoader(streams, sampler, transform=to_device)
for batch in loader:
    model(batch)                        # already on device

Call loader.close() when the training loop exits, then close its caller-owned streams. Do not close either from transform.

A non_blocking=True copy is asynchronous only from pinned memory. Loaderx does not pin memory; use the framework's API in the transform when needed:

def to_device(batch):
    return {k: torch.from_numpy(v).pin_memory().to(device, non_blocking=True)
            for k, v in batch.items()}

For JAX, use jax.device_put:

import jax

def to_device(batch):
    return {k: jax.device_put(v) for k, v in batch.items()}

For a practical JAX/Flax integration, see the MNIST layer-representation example.

Sampler

Sampler is a borrowed Python buffer over a stateless Cython batch function. DataLoader receives a sampler object rather than duplicating its batch size, mode, or seed. A run resumes in O(1) with seek(step), without replaying draws or tracking an epoch.

from loaderx.sampler import Sampler

sampler = Sampler(1_000_000, 256, Sampler.Mode.IID, seed=42)
indices = sampler.next()       # borrowed until this sampler's next draw
saved = indices.copy()         # retain across draws only when needed

next() and iteration return a view of one reusable uint64 batch buffer. The contents stay unchanged until the next explicit draw from that Sampler; copy only plans that must outlive it. DataLoader consumes each view synchronously before drawing again. DataLoader borrows the sampler and never closes it. Any user object can be injected instead; its next() should return a borrowed one-dimensional contiguous NumPy index array. This is a trusted hot-path contract rather than a normalized protocol, so custom policies can be ordinary NumPy code without Loaderx adapters or lifecycle methods.

  1. Sequential traverses records in order and wraps at the end.
  2. IID samples uniformly with replacement.
  3. Cyclic traverses a fresh permutation without replacement. It does not allocate a dataset-sized permutation. Full batches are returned; a different remainder is omitted on each cycle when the length is not divisible by the batch size.

Benchmarks

Dense and Ragged are measured separately because they expose different contracts, but every store table uses the same columns. scripts/bench_dense.py measures fixed-shape random gather, scripts/bench_ragged.py measures variable-shape records, and scripts/bench.py covers machine, sampler, and the end-to-end loader comparison. Every path runs through the public Python binding, including output allocation and Ragged reconstruction.

Methodology

The tables are one complete pass on a warm page cache, refreshed on 2.5.8 with the default data_shards=4. They are not three-run medians. Every Store backend receives the same prepared real records, then a full sweep and independent IID batches verify exact dtype, shape, order, and values before timing. Loader backends use the same source and seed but their own shipped samplers, so exact permutations may differ.

logical write and logical gather report uncompressed NumPy payload bytes per elapsed second, not physical storage bandwidth. Write timing includes public-API adaptation and logical finalization, but excludes source preparation, dictionary training, writer setup, cleanup, and stable-media sync. Zrecord writers receive the complete prepared source in one append and bound execution internally. Gather timing includes allocation, reads, decompression, and reconstruction; it excludes open, close, sampler time, and destruction after return. Fresh uniform IID gathers accumulate at least two timed seconds. krecords/s is record throughput, p95 is one random batch's 95th-percentile latency, and disk is allocated blocks. Compare results within a workload table, not across different record geometries. Every listed backend and codec is required for the published matrix.

The vision source is the Oxford-IIIT Pet train split prepared by scripts/prepare_vision.py. Dense uses RGB photographs resized on the short side to 256 and center-cropped to (3, 224, 224); Ragged uses the same ordered source at native RGB resolution. Both are mmap-loaded uint8 CHW records.

Machine — one local workstation (AMD Ryzen AI 9 HX PRO 370, 12 cores / 24 threads):

machine value
CPU AMD Ryzen AI 9 HX PRO 370 w/ Radeon 890M, 1 socket, 12 cores / 24 threads
frequency 605–5158 MHz
caches L1d 576 KiB, L1i 384 KiB, L2 12 MiB, L3 24 MiB
NUMA 1 node
memory 31 GiB (not limited by cgroup)
shared memory 16 GiB /dev/shm
OS Debian GNU/Linux forky/sid, kernel 7.1.8+deb13-amd64, x86_64
python CPython 3.14.7 (standard GIL build), numpy 2.5.2

The process sees all 24 threads, is not memory-limited by cgroup, and uses the ordinary page cache. The 16 GiB shared-memory mount accommodates the Torch run.

Large Vision Records

Fixed-Shape Dense

Zrecord against array-store alternatives: random batch gather over the first 2,500 Oxford-IIIT Pet train images, batch 256. Every prepared image is uint8[3,224,224]; metadata.json records the source revision, transform, decoder versions and logical SHA-256.

Fixed-resolution vision records — 147 KiB per record, 36.8 MiB per batch:

backend logical write logical gather krecords/s p95 disk ratio
zrecord-raw 3734 MiB/s 16134 MiB/s 112.4 2.56 ms 358.9 MiB 1.00x
npy-mmap-raw 2167 MiB/s 5411 MiB/s 37.7 7.42 ms 358.9 MiB 1.00x
hdf5-raw 2543 MiB/s 2180 MiB/s 15.2 20.64 ms 359.0 MiB 1.00x
lmdb-raw 1537 MiB/s 4459 MiB/s 31.1 11.34 ms 361.4 MiB 0.99x
arrow-ipc-raw 1734 MiB/s 3726 MiB/s 26.0 13.35 ms 358.9 MiB 1.00x
parquet-raw 1251 MiB/s 497 MiB/s 3.5 88.21 ms 358.9 MiB 1.00x
arrayrecord-raw 1853 MiB/s 2303 MiB/s 16.0 19.17 ms 359.2 MiB 1.00x
zrecord-zstd 1559 MiB/s 5161 MiB/s 36.0 8.82 ms 311.1 MiB 1.15x
zrecord-zstdict 1401 MiB/s 4656 MiB/s 32.4 9.21 ms 320.1 MiB 1.12x
hdf5-gzip 45 MiB/s 226 MiB/s 1.6 193.76 ms 301.8 MiB 1.19x
arrow-ipc-zstd 240 MiB/s 105 MiB/s 0.7 367.56 ms 305.9 MiB 1.17x
parquet-zstd 238 MiB/s 90 MiB/s 0.6 431.77 ms 305.9 MiB 1.17x
arrayrecord-zstd 228 MiB/s 1778 MiB/s 12.4 24.90 ms 320.1 MiB 1.12x

At 147 KiB per record, Zrecord-raw reaches 15.1 GiB/s and is 3.1x npy-mmap-raw. Decoded photographs have little remaining redundancy: plain zstd reduces them only 1.15x, while dictionary mode falls to 1.12x. This is why large real images should use plain zstd or raw. LMDB and Arrow IPC are competitive raw record stores, while codecs tied to whole IPC batches or Parquet row groups pay read amplification on random gathers. Dense demonstrates that typed record ownership and per-record compression do not turn fixed tensors into an object-store slow path.

Native-Resolution Ragged

This workload contains the first 2,500 native-resolution Oxford-IIIT Pet CHW RGB images. Height is 108..2606 (median 375) and width is 117..3264 (median 500), totaling 1252.3 MiB of logical uint8 payload. Each of 50 random batches contains 256 records. Every backend persists payload plus exact shape and must return an ordered list[np.ndarray] of (3, H, W) arrays; a flat byte list or a one-dimensional variable-length abstraction is not enough.

backend logical write logical gather krecords/s p95 disk ratio
zrecord-raw 3497 MiB/s 14169 MiB/s 28.2 14.09 ms 1252.4 MiB 1.00x
hdf5-raw 1697 MiB/s 2928 MiB/s 5.8 52.35 ms 1253.1 MiB 1.00x
lmdb-raw 1621 MiB/s 8779 MiB/s 17.4 23.38 ms 1257.0 MiB 1.00x
arrow-ipc-raw 1553 MiB/s 4582 MiB/s 9.1 46.16 ms 1252.4 MiB 1.00x
parquet-raw 720 MiB/s 558 MiB/s 1.1 259.87 ms 1252.4 MiB 1.00x
arrayrecord-raw 1696 MiB/s 2755 MiB/s 5.5 68.30 ms 1253.0 MiB 1.00x
zrecord-zstd 1358 MiB/s 3438 MiB/s 6.8 63.51 ms 1032.2 MiB 1.21x
zrecord-zstdict 888 MiB/s 3729 MiB/s 7.3 59.22 ms 1032.9 MiB 1.21x
arrayrecord-zstd 215 MiB/s 1919 MiB/s 3.8 106.11 ms 1054.6 MiB 1.19x

Zrecord-raw is 1.5x LMDB and 3.0x Arrow IPC in logical gather. Plain and dictionary zstd both reach 1.21x, confirming that dictionary mode is not useful for these large photographs. The Ragged matrix is intentionally asymmetric: HDF5 gzip, Arrow IPC zstd and Parquet zstd adapters are not implemented because the real 256-record batch took 1.1–1.7 seconds; TileDB variable queries took 8.6–9.6 seconds and the backend was removed entirely. ArrayRecord zstd remains because its batch p95 is 97 ms.

Small Token Records

Both token workloads come from the same real corpus: WikiText-103 raw train, tokenized with GPT-2 and stored as int32 IDs. Preparation is outside every measurement. scripts/prepare_tokens.py preserves nonempty text boundaries, combines fragments shorter than 16 tokens, and splits records at 512 tokens into tokens.npy plus offsets.npy; both benchmark scripts mmap those files.

Fixed Token Blocks

The Dense workload ignores text boundaries and packs the stream into 200,000 fixed int32[512] records: 2 KiB per record and 390.6 MiB logical payload. Each IID batch gathers 256 records; fresh draws continue until timed gathers accumulate at least two seconds.

backend logical write logical gather krecords/s p95 disk ratio
zrecord-raw 4087 MiB/s 5429 MiB/s 2779.6 0.12 ms 393.7 MiB 0.99x
npy-mmap-raw 2205 MiB/s 11436 MiB/s 5855.1 0.06 ms 390.6 MiB 1.00x
lmdb-raw 696 MiB/s 1158 MiB/s 592.8 0.69 ms 786.3 MiB 0.50x
arrow-ipc-raw 2183 MiB/s 240 MiB/s 122.7 2.61 ms 390.8 MiB 1.00x
arrayrecord-raw 947 MiB/s 174 MiB/s 89.1 4.84 ms 401.4 MiB 0.97x
zrecord-zstd 1181 MiB/s 1715 MiB/s 878.2 0.36 ms 193.2 MiB 2.02x
zrecord-zstdict 1400 MiB/s 1826 MiB/s 934.8 0.34 ms 168.5 MiB 2.32x
arrayrecord-zstd 122 MiB/s 150 MiB/s 76.6 4.26 ms 201.3 MiB 1.94x

The contiguous NumPy baseline is strongest for gather when the whole corpus is one fixed typed matrix. Zrecord-raw reaches 3.87 Mrecords/s while retaining independent record semantics and writes 2.3x faster than npy-mmap-raw; dictionary zstd writes 19% faster than plain zstd, uses 13% less disk and gathers 14% faster in this pass. LMDB's B-tree/page overhead is visible in both throughput and disk.

Variable Token Sequences

The Ragged workload keeps 200,000 real text records of 16..512 tokens: p10 28, median 129, mean 138.8, p90 254, totaling 105.9 MiB. It uses the same 256-record, random plan and every backend must return ordered list[np.ndarray] with exact int32 values and original one-dimensional shapes.

backend logical write logical gather krecords/s p95 disk ratio
zrecord-raw 2323 MiB/s 775 MiB/s 1464.2 0.22 ms 110.5 MiB 0.96x
lmdb-raw 370 MiB/s 120 MiB/s 226.1 1.73 ms 151.8 MiB 0.70x
arrow-ipc-raw 671 MiB/s 44 MiB/s 82.6 5.39 ms 109.1 MiB 0.97x
arrayrecord-raw 311 MiB/s 27 MiB/s 51.0 7.93 ms 118.0 MiB 0.90x
zrecord-zstd 637 MiB/s 554 MiB/s 1046.8 0.30 ms 67.4 MiB 1.57x
zrecord-zstdict 1109 MiB/s 637 MiB/s 1203.6 0.26 ms 53.0 MiB 2.00x
arrayrecord-zstd 65 MiB/s 32 MiB/s 59.9 5.35 ms 76.0 MiB 1.39x

Here the record contract, not bulk byte bandwidth, is the useful scale. Zrecord's three codecs return 1.17–1.74 Mrecords/s with 0.19–0.29 ms p95. Dictionary zstd writes 76% faster than plain zstd, uses 21% less disk and gathers 29% faster in this pass. Raw reaches 1902 MiB/s write.

Sampler

Index generation on its own, IID (with replacement), 1M index space, against NumPy's modern API. The counter-based Cython function is stateless at every step. The µs-scale figures fluctuate with box load; three 100K-draw runs show a 1.9–7.7x median margin across batch sizes.

sampler batch per batch vs default_rng
numpy default_rng 256 3.2 µs 1.00x
sampler 256 0.5 µs 7.67x
numpy default_rng 1024 4.3 µs 1.00x
sampler 1024 1.3 µs 3.39x
numpy default_rng 8192 17.5 µs 1.00x
sampler 8192 9.2 µs 1.94x

End-to-End DataLoader

The full pipeline comparison (sample, fetch, collate, handoff) uses the Dense vision source above: 2,500 (3,224,224) uint8 records, 4 workers, batch 256 (36.8 MiB), and one 200-batch pass after warmup. peak PSS apportions shared and copy-on-write mappings across the process tree; peak RSS sums each process's resident set and therefore double-counts shared pages on Linux. These are process-memory diagnostics, not total pipeline physical memory.

This is an end-to-end systems comparison with each loader's normal storage: Torch uses read-only NumPy mmap, Loaderx uses Zrecord, and Grain uses ArrayRecord. Fork represents normal Linux Torch operation; spawn is the no-fork control. Grain is selected explicitly with --only grain.

loader model storage batches/s p95 steady PSS peak PSS peak RSS
loaderx threads zrecord-zstd 112.2 14.49 ms 633 MiB 665 MiB 669 MiB
loaderx-raw threads zrecord-raw 171.4 11.79 ms 668 MiB 668 MiB 671 MiB
torch fork npy-mmap-raw 76.3 47.67 ms 1400 MiB 1542 MiB 3860 MiB
torch-spawn spawn npy-mmap-raw 99.7 34.89 ms 2627 MiB 2705 MiB 4676 MiB
grain processes arrayrecord-zstd 44.5 93.95 ms 1698 MiB 1764 MiB 1886 MiB

At 36.8 MiB per batch, gather dominates sampler cost and overlaps with transform work. With source geometry and entropy fixed, raw Loaderx is 1.53x compressed Loaderx. Compressed Loaderx is 1.47x Torch fork, 1.13x Torch spawn, and 2.52x Grain; raw Loaderx is 2.25x, 1.72x, and 3.85x faster, respectively. RSS must not be read as a physical-memory ratio because Linux counts shared mappings repeatedly and page-cache accounting differs from mmap.

The Torch-only worker sweep runs each count once. Worker 0 is the in-process baseline: 67.7 and 68.2 batches/s with 1411/1413 MiB peak PSS in two equivalent runs. The process comparison starts at one worker:

workers fork batches/s fork peak PSS fork peak RSS spawn batches/s spawn peak PSS spawn peak RSS
1 41.0 1429 MiB 1730 MiB 39.3 1708 MiB 1946 MiB
2 64.6 1510 MiB 2476 MiB 61.3 2064 MiB 2887 MiB
4 98.5 1611 MiB 3899 MiB 105.0 2714 MiB 4674 MiB
8 114.4 1799 MiB 6615 MiB 121.3 4038 MiB 8187 MiB

Spawn peak PSS grows from 1708 to 4038 MiB as workers rise from one to eight, while fork grows from 1429 to 1799 MiB because it retains COW sharing. At eight workers spawn uses 2.24x fork's peak PSS and throughput has already flattened. This demonstrates no-fork memory pressure, not OOM: the 31 GiB machine completed the run. Aggregate RSS remains diagnostic rather than physical memory.

CPU-heavy transform solutions. The same Python per-sample transform exposes the GIL bottleneck on standard CPython. Numba is an explicit solution, not the default: --transform numba-nogil compiles the equivalent batch transform with nogil=True. The other explicit solution runs the unchanged Python transform on free-threaded CPython. Numba compilation is warmed before timing; both interpreters use the same prepared real corpus.

loader GIL Python GIL + Numba nogil free-threaded Python Numba gain free-threaded gain
loaderx 48.0 batches/s 95.9 batches/s 78.0 batches/s 2.00x 1.63x
loaderx-raw 52.9 batches/s 117.5 batches/s 89.2 batches/s 2.22x 1.69x

Peak PSS for compressed/raw was 860/871 MiB with GIL Python, 933/957 MiB with Numba, and 879/887 MiB with free-threaded Python. These are two deployment solutions to the transform bottleneck, not claims that Store itself was optimized for either runtime.

Conclusion. Loaderx batches sampling, gather, decompression, and Ragged reconstruction while preserving exact record semantics. IID sampling remains uniform with replacement. Dense serves fixed-shape arrays directly; Ragged restores each record's original shape. This favors random training gathers, where formats organized around contiguous scans or larger storage groups can pay read amplification.

Compression results depend on the data. In the Dense photograph workload, plain zstd reaches only 1.15x and dictionary zstd 1.12x; dictionary mode is most useful for repeated small records, not as a universal default.

The loader table combines execution model and storage, rather than comparing schedulers over one shared backend. Compressed Loaderx is 1.47x Torch fork, 1.13x Torch spawn, and 2.52x Grain; raw Loaderx is 2.25x, 1.72x, and 3.85x faster, respectively. CPU-heavy Python transforms remain limited by the GIL; the measured alternatives are Numba nogil=True and free-threaded Python.

These numbers measure warm-cache access, not cold disk or durability. Disk means allocated blocks; write timing excludes stable-media sync and dictionary training. Arrow IPC and Parquet use 256-record groups, so their random gathers include group-level read amplification. This is one complete benchmark pass; Store reads accumulate at least two timed seconds, while sampler and loader figures can fluctuate with machine load. Treat absolute values as ballpark and cross-backend margins as the main signal.

Real-data verification

Production dataset-specific preprocessing and verification remain in the DataPipe repository. The built-in converter covers standardized Hugging Face datasets; DataPipe handles sources without a common remote protocol and implements derived modalities as loader transforms without NumPy dump intermediates. Oxford-IIIT Pet is only the shared benchmark fixture.

Current Limitations

  • Single-host only; multi-host training is not supported.
  • A single sample must be at most 2 GiB. Store size is practically bounded by disk capacity and platform file limits.
  • Stores are not portable between machines with different byte orders. All published platforms are little-endian.

设计文档

Build

python3 setup.py build_ext --inplace
zig build test
python3 scripts/test_loaderx.py

Build the release wheel matrix with:

python3 scripts/build_release.py

Source distributions are intentionally not published. The wheel matrix covers the supported platforms. Source builds require Zig.

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distributions

No source distribution files available for this release.See tutorial on generating distribution archives.

Built Distributions

If you're not sure about the file name format, learn more about wheel file names.

loaderx-2.5.9-cp314-cp314t-win_amd64.whl (921.7 kB view details)

Uploaded CPython 3.14tWindows x86-64

loaderx-2.5.9-cp314-cp314t-manylinux_2_17_x86_64.whl (3.0 MB view details)

Uploaded CPython 3.14tmanylinux: glibc 2.17+ x86-64

loaderx-2.5.9-cp314-cp314t-manylinux_2_17_aarch64.whl (2.8 MB view details)

Uploaded CPython 3.14tmanylinux: glibc 2.17+ ARM64

loaderx-2.5.9-cp314-cp314t-macosx_11_0_arm64.whl (559.6 kB view details)

Uploaded CPython 3.14tmacOS 11.0+ ARM64

loaderx-2.5.9-cp310-abi3-win_amd64.whl (912.3 kB view details)

Uploaded CPython 3.10+Windows x86-64

loaderx-2.5.9-cp310-abi3-manylinux_2_17_x86_64.whl (2.9 MB view details)

Uploaded CPython 3.10+manylinux: glibc 2.17+ x86-64

loaderx-2.5.9-cp310-abi3-manylinux_2_17_aarch64.whl (2.7 MB view details)

Uploaded CPython 3.10+manylinux: glibc 2.17+ ARM64

loaderx-2.5.9-cp310-abi3-macosx_11_0_arm64.whl (549.7 kB view details)

Uploaded CPython 3.10+macOS 11.0+ ARM64

File details

Details for the file loaderx-2.5.9-cp314-cp314t-win_amd64.whl.

File metadata

  • Download URL: loaderx-2.5.9-cp314-cp314t-win_amd64.whl
  • Upload date:
  • Size: 921.7 kB
  • Tags: CPython 3.14t, Windows x86-64
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/7.0.0 CPython/3.14.7

File hashes

Hashes for loaderx-2.5.9-cp314-cp314t-win_amd64.whl
Algorithm Hash digest
SHA256 b61d96ba9a13948857ef363726096df6944dedff3082e30cf023bea19be40056
MD5 9d19df331bde9d7942e8c854be0c8e92
BLAKE2b-256 b496bd30c294ef8a159d065a4a752ffcc1f1f4da1f01bab4dee8553506167523

See more details on using hashes here.

File details

Details for the file loaderx-2.5.9-cp314-cp314t-manylinux_2_17_x86_64.whl.

File metadata

File hashes

Hashes for loaderx-2.5.9-cp314-cp314t-manylinux_2_17_x86_64.whl
Algorithm Hash digest
SHA256 227bafcdd9a474b53cdb5aecd8bc24407c10ab7eae1371ae4a6e094efae6e3e5
MD5 1ea37b5c19124756ca87d27bffd53429
BLAKE2b-256 a620233da480f498508ea9349cab0337780cf4bc9508715a1639d2fd3a228d71

See more details on using hashes here.

File details

Details for the file loaderx-2.5.9-cp314-cp314t-manylinux_2_17_aarch64.whl.

File metadata

File hashes

Hashes for loaderx-2.5.9-cp314-cp314t-manylinux_2_17_aarch64.whl
Algorithm Hash digest
SHA256 3e38b11c50dd87673d94388f2233b0cb3a4cffa7b85725632c6884b0eaacfd97
MD5 9fef915ba14b5b5b98ecd89d70160ff4
BLAKE2b-256 e703aba9904c1dede55de36c8b995ae303d1e1b4d806290f3c08542e58c90bcd

See more details on using hashes here.

File details

Details for the file loaderx-2.5.9-cp314-cp314t-macosx_11_0_arm64.whl.

File metadata

File hashes

Hashes for loaderx-2.5.9-cp314-cp314t-macosx_11_0_arm64.whl
Algorithm Hash digest
SHA256 63b54c993a52f41e589edde6ce0945588846ccfef7a4d6c095c39d59da78a261
MD5 75da0d15af92692470bfe157c34ac311
BLAKE2b-256 25cc63b7c74fefbf3238e99faf01d4f884e45225d78e1554eabd99c79627d0e9

See more details on using hashes here.

File details

Details for the file loaderx-2.5.9-cp310-abi3-win_amd64.whl.

File metadata

  • Download URL: loaderx-2.5.9-cp310-abi3-win_amd64.whl
  • Upload date:
  • Size: 912.3 kB
  • Tags: CPython 3.10+, Windows x86-64
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/7.0.0 CPython/3.14.7

File hashes

Hashes for loaderx-2.5.9-cp310-abi3-win_amd64.whl
Algorithm Hash digest
SHA256 f76f8785533f6fb5be33155bbef4b2090fde906baf35484d9cb30ede8da76694
MD5 43e8f4235b9a80145c6eb0f77c6c4a20
BLAKE2b-256 b816a2927d7839fcf3b8b87a93277655158f93a967125fc8a7990baea3552408

See more details on using hashes here.

File details

Details for the file loaderx-2.5.9-cp310-abi3-manylinux_2_17_x86_64.whl.

File metadata

File hashes

Hashes for loaderx-2.5.9-cp310-abi3-manylinux_2_17_x86_64.whl
Algorithm Hash digest
SHA256 77d35913a300eb672e2a3089cfbe19efaca98a49dc7e109c4183e7cc9a91e40f
MD5 e0c092a33ce080f383477fb9c47c26ab
BLAKE2b-256 3cd003472ef3f8240a17140fc5099a9f6d279ea858d7baaef99e01fa5a948ad7

See more details on using hashes here.

File details

Details for the file loaderx-2.5.9-cp310-abi3-manylinux_2_17_aarch64.whl.

File metadata

File hashes

Hashes for loaderx-2.5.9-cp310-abi3-manylinux_2_17_aarch64.whl
Algorithm Hash digest
SHA256 bf5b8af24e80466742c5dbb726947252659a038aa13debbb17153dc1c98c1159
MD5 d0a64ffaddd085778c07f93ca318b161
BLAKE2b-256 a635463c291c0ce6719a634437b407f9b679c1e22190da04ac9e7ab94a8dfa4e

See more details on using hashes here.

File details

Details for the file loaderx-2.5.9-cp310-abi3-macosx_11_0_arm64.whl.

File metadata

File hashes

Hashes for loaderx-2.5.9-cp310-abi3-macosx_11_0_arm64.whl
Algorithm Hash digest
SHA256 695b29678cfaa0b447b421e34968b3d6541c53faadab84b48309331281e6ecec
MD5 13c8a6b9b81a28ea3e962a4ef9b4b6bc
BLAKE2b-256 76bd9833f4168541cc8a42dfc17532c206c90f5172348eb66aa8db6f3a6c9962

See more details on using hashes here.

Release history Release notifications | RSS feed

2.7.14

8 files

2.7.13

8 files

2.7.8

8 files

2.7.6

8 files

2.7.5

8 files

This release

2.5.9 This release

8 files

2.5.5

8 files

2.5.4

8 files

2.5.3

8 files

2.4.7

8 files

2.4.6

8 files

2.4.0

9 files

2.3.8

9 files

2.3.5

9 files

2.3.3

9 files

2.2.1

9 files

2.0.7

9 files

2.0.1

9 files

2.0.0

9 files

1.10.2

9 files

1.9.7

9 files

1.9.6

9 files

1.8.6

9 files

1.8.4

9 files

1.7.7

9 files

1.7.6

9 files

1.7.2

9 files

1.6.2

9 files

1.5.7

9 files

1.5.5

9 files

1.5.2

9 files

1.4.2

9 files

1.1.3

9 files

1.1.0

9 files

1.0.0

9 files

0.10.1

9 files

0.9.17

9 files

0.9.15

9 files

0.9.14

9 files

0.9.11

9 files

0.9.4

9 files

0.9.1

9 files

0.7.3

9 files

0.5.4

9 files

0.5.0

9 files

0.4.1

2 files

0.3.0

2 files

0.2.1

2 files

0.2.0

2 files

0.1.7

2 files

0.1.4

2 files

0.1.3

2 files

0.1.2

2 files

0.1.1

2 files

0.1.0

2 files

0.0.5

2 files

0.0.4

2 files

0.0.3

2 files

0.0.2

2 files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page