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
Zrecord is the typed persistent store; 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 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 by the private native store engine.
- An immortal (endless) step-based data loader, rather than the traditional epoch-based design—better aligned with modern ML training practices.
- Dense and ragged are separate contracts, and the loader serves both. A dense stream stacks into one array per batch; a ragged one comes back as a list. Neither is padded, and equal length is never treated as a special case of variable length.
设计文档
Quick Start
import numpy as np
from loaderx.zrecord import DenseStore
from loaderx.dataloader import DataLoader
data = np.load('data.npy', mmap_mode='r')
label = np.load('label.npy', mmap_mode='r')
with DenseStore.create('train_data', data.dtype, data.shape[1:]) as ds:
ds.append(data)
with DenseStore.create('train_label', label.dtype, label.shape[1:]) as ds:
ds.append(label)
data_store = DenseStore.open('train_data')
label_store = DenseStore.open('train_label')
loader = DataLoader({'data': data_store, 'label': label_store},
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.
Creating a dense store
import numpy as np
from loaderx.zrecord import DenseStore
data = np.load('data.npy', mmap_mode='r')
with DenseStore.create('train_data', data.dtype, data.shape[1:]) as ds:
ds.append(data)
One record per slice along axis 0; a 1-D array (the usual shape of a label set)
becomes a store of scalar records. The caller controls each append batch and can
slice a large or mmapped array to set its own memory bound. Creation and opening
are explicit: create requires a new path, while open requires an existing
store. Open the result with DenseStore.open:
ds = DenseStore.open('train_data')
batch = ds[:]
ds.close()
Python defines the exact record schema: dtype plus the dense item_shape,
or only dtype for ragged stores. The MsgPack bytes live opaquely in the
static page at the front of meta.zr; Zig persists them but never interprets
them. The schema accepts no user metadata. Python selects the geometry and gives
the private native engine only the runtime record boundaries it needs. Each Ragged record
carries its shape in an inline little-endian u64 prefix. One native physical
engine consumes the trusted Dense stride or Ragged offsets. Raw
bytes and pre-encoded images use a
RaggedStore.create(path, dtype=np.uint8) rather than a second public storage API.
Records
One persistent format, two native execution contracts. DenseStore is the dense contract
where every record is exactly one row of the recorded item_shape; reads
are fixed-stride gathers and the batch shape follows from the schema, so no
per-record metadata is touched:
import numpy as np
from loaderx.zrecord import DenseStore
data = np.arange(64, dtype=np.float32).reshape(8, 2, 4)
with DenseStore.create('data', data.dtype, data.shape[1:]) as ds:
ds.append(data)
ds = DenseStore.open('data')
ds[0, 5, 2] # (3, 2, 4) — shape from the persisted schema
ds.close()
A store is a collection of records, not an ndarray, so ds[0, 5, 2] is a
record set — never ds[0][5][2]. A scalar selects one record; indices must be
in 0..len(ds)-1. The ragged example below reads the same way.
RaggedStore is the ragged contract for variable-length records. It is a
separate contract: :class:RaggedStore hands back a list of arrays, so a
loader never has to carry row_splits around. dtype is unified and
explicit; each record keeps its own shape, recorded per record as it is
written and restored exactly on read — so records may differ in shape
arbitrarily, and nothing is ever inferred from the source (an iterator can't
tell you what its later records look like). Scalar shape == () is preserved;
zero-byte arrays are rejected because physical records are nonempty. Densifying a list into a dense
batch is the model's call — a plain numpy loop, wherever you need it:
from loaderx.zrecord import RaggedStore
seqs = [np.arange(L, dtype=np.int32) for L in (3, 1, 4, 1, 5)]
with RaggedStore.create('tokens', np.int32) as rs:
rs.append(seqs) # dtype explicit; each record keeps its shape
rs = RaggedStore.open('tokens')
records = rs[0, 2, 4] # list of ndarray — one per record, exact shapes
rs.close()
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
A DataLoader dynamically composes a dict of dense and ragged streams.
Collation is the transform — a batch dict in, a batch dict out:
def collate(batch):
return {'input_ids': batch['tokens'], 'label': batch['label']}
loader = DataLoader({'tokens': dense_tokens, 'label': labelset}, batch_size=32,
transform=collate)
batch = next(loader)
loader.close()
Writable stores
The Store classes are loaderx's public persistence API: they are read-write,
and append is explicit — one container of records becomes one native batch.
Dense append is synchronous and borrows an already-contiguous ndarray without a
snapshot copy. Ragged packing produces an owned buffer, so RaggedStore feeds it
through one fixed, one-slot writer queue while Python prepares the next batch.
Nothing is inferred.
from loaderx.zrecord import DenseStore, RaggedStore
ds = DenseStore.create('mnist/x', dtype=np.uint8, item_shape=(28, 28))
ds.append(images[i:i + 1024]) # synchronous native batch; returns None
ds.append(single_image[None]) # one sample is batch_size 1 — add the axis yourself
ds[:4] # read path is direct
tok = RaggedStore.create('tokens', dtype=np.int32)
tok.append([seq_a, seq_b, seq_c])
The Ragged writer commits packed batches in FIFO order. A second queued batch
applies natural backpressure until the writer takes the first. len, gather,
stats, sync, delete, compact, and close drain that queue before acting;
Ragged append and gather therefore never overlap. Worker errors surface at the
next append or draining operation. sync remains the explicit durability
barrier.
Stores own a native handle (and RaggedStore owns its writer), so call
close() when their application lifetime ends. Store and DataLoader also
support with: use it for scoped construction or rewrites, and keep ordinary
objects for long-lived indexing or training loops, closing them at the lifecycle
boundary.
The exact schema is declared at creation and encoded by Python as MsgPack. Dense
schema contains only dtype and item_shape; Ragged schema contains only
dtype. Structured, subarray, object, and metadata-bearing dtypes are not
supported: their semantics do not round-trip through one canonical NumPy dtype
string. The encoded schema has 4064 bytes available in the fixed 4096-byte
metadata page. Ragged schema size is effectively fixed; Dense schema size grows
only with the integer item_shape, so the physical limit is far above any
practical NumPy array rank.
Unexpected fields are rejected. append validates dtype and shape in Python,
then passes the derived byte width to the private Dense operation.
Dictionary training stays explicit and separate; creating a
zstd_dict store requires the completed dictionary, so no valid store is
published without one.
The store's maintenance pass-throughs are on the same object, normalized like reads (scalar, slice, array, bool mask):
ds.delete(np.arange(0, len(ds), 2)) # swap-last: index space stays dense, survivors reindex
ds.stats() # {'records', 'live_bytes', 'data_bytes', 'reclaimable'}
ds.compact() # reclaim the deleted bytes, in place — offline only
ds.close()
tok.close()
Deletion and compaction move records, so a multi-stream index you keep yourself
must tolerate it — the guarantee is only that the live records are exactly
0..len(ds).
Codec notes
"zstd" compresses each record independently with plain zstd (level 3). Use it
for general-purpose compression — it is fast and the default.
"zstd_dict" trains a shared dictionary on a sample of the data before writing
any record, then compresses every record against it at level 19. The dictionary
captures structure shared across records that per-record compression cannot see —
a large win for many small, similar records (image tiles, token sequences).
The dictionary's cost is a cache footprint: every record's decompression
references the shared dictionary window, so a larger dictionary means more
cache misses on gather — the path a loader pays forever. Three named tiers
(loaderx.zrecord.DICT_TIERS) preset the whole tradeoff — the dictionary size
and how much data trains it — so a caller picks a tier, never a number:
| tier | dict | sample | tradeoff |
|---|---|---|---|
"fast" |
32 KiB | 4 MiB | fastest gather and training; ratio barely above zstd |
"balanced" |
128 KiB | 16 MiB | default — most of the ratio at a fraction of the gather cost |
"max" |
1 MiB | 64 MiB | best ratio; slowest gather and training |
The sample is the byte budget the dictionary trains on (a strided subset of the
records), so each tier costs the same training time whatever the record size.
At realistic image scale (768 KiB records) the tiers converge — on the earlier
measurement box's structured data "balanced" and "max" both gather
~1.6 GiB/s at a 1.74x ratio — because a dictionary is a small fraction of a
large frame. The tiers still matter at small record sizes, where the dict is
most of a record and "max" trades gather throughput for ratio.
"zstd_dict" records can only be read from a store that has the dictionary
(dict.zr). The dictionary is loaded on open and shared, lock-free, across all
reader threads.
A dictionary must train on the settled, complete data. :func:train_dict is the
standalone, manual training step — a numpy array, an iterable of records, or
a typed store all train the same way, sized by a tier — and its bytes are handed to
a store-writing path via dict_bytes. zstd_dict never trains by itself: a
write without a dictionary is an error. A stream cannot train its own
dictionary, but it can write with one trained on the settled data:
from loaderx.utils import train_dict
from loaderx.zrecord import DenseStore, RaggedStore
# train once on the settled data — a standalone, reusable artifact
d = train_dict(settled_array, tier="balanced")
# then any new store can install it and append explicitly
with RaggedStore.create('tokens', np.int32, codec='zstd_dict', dict_bytes=d) as ds:
ds.append(token_generator)
with DenseStore.create('data', data.dtype, data.shape[1:],
codec='zstd_dict', dict_bytes=d) as ds:
ds.append(data)
Changing an existing store's codec is a rewrite, not a store mutation: read the
records as numpy values and append them to a new store with the new codec. A
whole-store slice preserves index order, which keeps multi-stream alignment.
There is no dedicated recode or store-to-store path because the ordinary
read and append contracts already express the operation:
from loaderx.utils import train_dict
from loaderx.zrecord import DenseStore
with DenseStore.open("src") as s, \
DenseStore.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
RaggedStore is the same shape: s[:] returns list[np.ndarray], which
is exactly its append input, and dtype is all the destination needs. The
native compression path bounds its own working memory; there is no public chunk
parameter. dst must not already hold a store.
Important: Do not use "zstd_dict" while data is still changing (through
deletes or compaction below the Python layer). The dictionary captures a snapshot
of the data; training it before the data settles wastes compression. Train the
dictionary once preprocessing is complete and the content is final.
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 DenseStore, RaggedStore
from loaderx.dataloader import DataLoader
root = "xsub/train"
with DenseStore.create(root + "/joint", joint.dtype, joint.shape[1:]) as s:
s.append(joint)
with DenseStore.create(root + "/label", label.dtype, label.shape[1:]) as s:
s.append(label)
with RaggedStore.create(root + "/token", np.int32) as s:
s.append(seqs)
streams = {
"joint": DenseStore.open(root + "/joint"),
"label": DenseStore.open(root + "/label"),
"token": RaggedStore.open(root + "/token"),
}
streams["joint"][0, 5, 2] # each stream keeps its own index API
loader = DataLoader(streams, batch_size=256)
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 — DenseStore (one
fixed-shape record per sample) and RaggedStore (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.
CPU → GPU transfer
loaderx hands over CPU batches; getting them to the accelerator is the
transform's job — the one place your framework is already imported. The batch
dict is a plain {name: numpy array}, zero-copy on the way out, so a device
transfer is one call per stream:
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": DenseStore.open(root + "/joint"),
"label": DenseStore.open(root + "/label"),
"token": RaggedStore.open(root + "/token"),
}
loader = DataLoader(streams, transform=to_device)
for batch in loader:
model(batch) # already on device
Call loader.close() when the training loop exits. The streams remain
caller-owned and should be closed at the application lifecycle boundary.
A non_blocking=True copy is genuinely asynchronous only when its source is
pinned. loaderx does not pin memory for you — pinning is framework-owned
(torch's .pin_memory(), CUDA's cudaHostAlloc), and a vendor-free core stops
exactly at the CPU batch. Pin in the transform what you copy:
def to_device(batch):
return {k: torch.from_numpy(v).pin_memory().to(device, non_blocking=True)
for k, v in batch.items()}
JAX is the same shape — jax.device_put is already an asynchronous handoff on
GPU:
import jax
def to_device(batch):
return {k: jax.device_put(v) for k, v in batch.items()}
The transfer runs on the transform stage and never touches loaderx internals:
the copy overlaps the next batch's gather/transform, and any pinned pool is the
caller's to own and reuse. This is the entire H2D answer — there is no pin=
hook or device backend, because the only unified thing a multi-framework loader
can own is the CPU batch.
For practical integration examples, please refer to the Data2Latent repository
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,
so CFFI, NumPy allocation, and Ragged list/shape reconstruction are timed.
Methodology
The current snapshots below are each one qualitative pass on a warm page cache.
Within one workload every backend receives identical source records and random
index plans. Before gather timing, the benchmark sweeps every record and
validates every planned result for exact
dtype, shape, order, and values. logical write and logical gather divide
uncompressed NumPy payload bytes by elapsed time; they measure bytes accepted or
returned by the public API, not physical storage bandwidth. Write times each
backend's public build path, so durability semantics are backend-specific: for
example, LMDB's default transaction commit is inside its timer, while Zrecord's
final bulk sync()/close barrier is outside. Reusable input preparation is
outside that timer: in particular, zstd_dict trains its standalone dictionary
first, while Store creation, its initial metadata sync, dictionary installation
and sync, and append remain timed. Durability must therefore be compared with a
separate explicit benchmark rather than inferred from this column.
Disk size is allocated blocks, not sparse apparent size. krecords/s is gather
record throughput and p95 is the 95th-percentile latency of one random gather
batch. Results are comparable within one workload table, not across payload
distributions or geometries.
Timed gather repeats the complete random plan until at least one second has
elapsed; this prevents fast small-record paths from being reported from only a
few milliseconds of samples.
Every backend name states its actual codec; the full default set is required
rather than silently skipped when a package is missing.
Dense and Ragged use the same CHW RGB image generator, record count, batch plan
and seed. Dense fixes every image at (3, 224, 224); Ragged changes only H and W.
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.3+deb13-amd64, x86_64 |
| python | 3.14.7, numpy 2.5.2 |
The benchmark process sees all 24 threads and is not memory-limited by cgroup. The 16 GiB shared-memory mount accommodates the four-worker, 64 MiB-batch torch pipeline. Store reads run on the ordinary page cache.
Large Vision Records
Fixed-Shape Dense
Zrecord against array-store alternatives: random batch gather, 2,500 CHW RGB
records, batch 256. Every image is (3, 224, 224) and comes from the same
spatial model used by the variable-shape benchmark.
Fixed-resolution vision records — 147 KiB per record, 36.8 MiB per batch:
| backend | logical write | logical gather | krecords/s | p95 | disk | ratio |
|---|---|---|---|---|---|---|
| zrecord-zstd | 4547 MiB/s | 5795 MiB/s | 40.4 | 7.37 ms | 24.4 MiB | 14.69x |
| zrecord-zstdict | 27 MiB/s | 6130 MiB/s | 42.7 | 6.94 ms | 15.8 MiB | 22.74x |
| zrecord-raw | 2539 MiB/s | 8076 MiB/s | 56.3 | 5.35 ms | 358.9 MiB | 1.00x |
| npy-mmap-raw | 2662 MiB/s | 3779 MiB/s | 26.3 | 11.65 ms | 358.9 MiB | 1.00x |
| hdf5-raw | 2358 MiB/s | 1652 MiB/s | 11.5 | 25.66 ms | 359.0 MiB | 1.00x |
| hdf5-gzip | 239 MiB/s | 550 MiB/s | 3.8 | 78.27 ms | 26.1 MiB | 13.73x |
| lmdb-raw | 755 MiB/s | 3327 MiB/s | 23.2 | 15.16 ms | 361.4 MiB | 0.99x |
| arrow-ipc-raw | 1766 MiB/s | 3006 MiB/s | 20.9 | 13.77 ms | 358.9 MiB | 1.00x |
| arrow-ipc-zstd | 589 MiB/s | 163 MiB/s | 1.1 | 257.45 ms | 22.6 MiB | 15.86x |
| parquet-raw | 1141 MiB/s | 420 MiB/s | 2.9 | 96.29 ms | 358.9 MiB | 1.00x |
| parquet-zstd | 549 MiB/s | 146 MiB/s | 1.0 | 284.06 ms | 22.6 MiB | 15.86x |
| arrayrecord-raw | 1616 MiB/s | 1882 MiB/s | 13.1 | 22.75 ms | 359.2 MiB | 1.00x |
| arrayrecord-zstd | 815 MiB/s | 1144 MiB/s | 8.0 | 38.81 ms | 25.1 MiB | 14.32x |
| tiledb-raw | 746 MiB/s | 670 MiB/s | 4.7 | 61.58 ms | 359.0 MiB | 1.00x |
| tiledb-zstd | 1200 MiB/s | 1295 MiB/s | 9.0 | 32.73 ms | 26.9 MiB | 13.36x |
At 147 KiB per record, Zrecord-raw reaches 7.9 GiB/s and is 2.1x npy-mmap-raw; plain zstd gathers at 5.7 GiB/s while reducing the corpus 14.69x. 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. DenseStore demonstrates that typed record ownership and per-record compression do not turn fixed tensors into an object-store slow path.
Variable-Shape Ragged
This workload contains 2,500 variable-resolution CHW RGB images. Height and
width are independently lognormal and clipped to 64..512 (observed medians
223 and 224), totaling 417.1 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-zstd | 2026 MiB/s | 4739 MiB/s | 28.6 | 10.95 ms | 26.9 MiB | 15.52x |
| zrecord-zstdict | 28 MiB/s | 5276 MiB/s | 31.8 | 9.26 ms | 17.7 MiB | 23.58x |
| zrecord-raw | 1365 MiB/s | 6766 MiB/s | 40.8 | 7.55 ms | 417.2 MiB | 1.00x |
| hdf5-raw | 1150 MiB/s | 1160 MiB/s | 7.0 | 41.25 ms | 418.0 MiB | 1.00x |
| hdf5-gzip | 216 MiB/s | 107 MiB/s | 0.6 | 456.83 ms | 29.9 MiB | 13.94x |
| lmdb-raw | 881 MiB/s | 6299 MiB/s | 38.0 | 8.51 ms | 422.2 MiB | 0.99x |
| arrow-ipc-raw | 1313 MiB/s | 3809 MiB/s | 23.0 | 15.07 ms | 417.2 MiB | 1.00x |
| arrow-ipc-zstd | 568 MiB/s | 176 MiB/s | 1.1 | 270.58 ms | 25.9 MiB | 16.10x |
| parquet-raw | 879 MiB/s | 433 MiB/s | 2.6 | 109.24 ms | 417.2 MiB | 1.00x |
| parquet-zstd | 419 MiB/s | 158 MiB/s | 1.0 | 294.91 ms | 25.9 MiB | 16.10x |
| arrayrecord-raw | 1484 MiB/s | 2469 MiB/s | 14.9 | 19.56 ms | 417.6 MiB | 1.00x |
| arrayrecord-zstd | 825 MiB/s | 1413 MiB/s | 8.5 | 38.20 ms | 27.2 MiB | 15.31x |
| tiledb-raw | 403 MiB/s | 47 MiB/s | 0.3 | 962.58 ms | 417.2 MiB | 1.00x |
| tiledb-zstd | 619 MiB/s | 118 MiB/s | 0.7 | 385.66 ms | 27.0 MiB | 15.46x |
Zrecord-raw is the fastest raw record path, with LMDB close behind; Arrow IPC is the strongest raw typed-file alternative. Zrecord-zstd delivers 4.6 GiB/s of logical payload while reducing the corpus to 26.9 MiB. HDF5, Arrow IPC, Parquet, ArrayRecord and TileDB show the same framework/codec tradeoffs in both tables; compressed batch, chunk and row-group formats pay read amplification on random records.
The shared generator makes compression ratios directly comparable across contracts: Zrecord zstd is 14.69x Dense versus 15.52x Ragged, and zstdict is 22.74x versus 23.58x. The remaining difference comes from the H/W distribution and Ragged shape metadata, not a different image entropy model.
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 of 100 random batches gathers 256 records.
| backend | logical write | logical gather | krecords/s | p95 | disk | ratio |
|---|---|---|---|---|---|---|
| zrecord-zstd | 360 MiB/s | 1552 MiB/s | 794.8 | 0.41 ms | 193.2 MiB | 2.02x |
| zrecord-zstdict | 40 MiB/s | 1707 MiB/s | 873.7 | 0.39 ms | 153.8 MiB | 2.54x |
| zrecord-raw | 2300 MiB/s | 6224 MiB/s | 3186.9 | 0.10 ms | 393.7 MiB | 0.99x |
| npy-mmap-raw | 2576 MiB/s | 9648 MiB/s | 4939.8 | 0.07 ms | 390.6 MiB | 1.00x |
| lmdb-raw | 347 MiB/s | 1036 MiB/s | 530.4 | 0.78 ms | 786.3 MiB | 0.50x |
| arrow-ipc-raw | 2075 MiB/s | 208 MiB/s | 106.5 | 3.19 ms | 390.8 MiB | 1.00x |
| arrayrecord-raw | 800 MiB/s | 143 MiB/s | 73.2 | 5.87 ms | 401.4 MiB | 0.97x |
| arrayrecord-zstd | 110 MiB/s | 124 MiB/s | 63.3 | 6.38 ms | 201.3 MiB | 1.94x |
The contiguous NumPy baseline is strongest when the whole corpus is one fixed typed matrix. Zrecord-raw reaches 3.19 Mrecords/s while retaining independent record semantics; per-record zstd halves disk and still returns 0.79–0.87 Mrecords/s. 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,
100-batch 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-zstd | 120 MiB/s | 157 MiB/s | 297.1 | 1.06 ms | 67.8 MiB | 1.56x |
| zrecord-zstdict | 37 MiB/s | 164 MiB/s | 310.8 | 1.03 ms | 49.4 MiB | 2.14x |
| zrecord-raw | 290 MiB/s | 180 MiB/s | 341.8 | 1.02 ms | 112.0 MiB | 0.95x |
| lmdb-raw | 205 MiB/s | 96 MiB/s | 181.3 | 2.24 ms | 153.0 MiB | 0.69x |
| arrow-ipc-raw | 572 MiB/s | 34 MiB/s | 65.1 | 5.84 ms | 109.9 MiB | 0.96x |
| arrayrecord-raw | 262 MiB/s | 25 MiB/s | 46.6 | 8.45 ms | 118.8 MiB | 0.89x |
| arrayrecord-zstd | 54 MiB/s | 25 MiB/s | 47.1 | 7.41 ms | 76.2 MiB | 1.39x |
Here the record contract, not bulk byte bandwidth, is the useful scale. Zrecord's three codecs return 297–342 krecords/s with approximately 1 ms p95; the dictionary gives the best disk ratio and is slightly ahead of plain zstd in this pass.
Sampler
Index generation on its own, IID (with replacement), 1M index space, against NumPy's modern API. The µs-scale figures fluctuate with box load; the stable signal is the approximately 1.9x margin across batch sizes.
| sampler | batch | per batch | vs default_rng |
|---|---|---|---|
| numpy default_rng | 256 | 3.9 µs | 1.00x |
| zsampler | 256 | 2.0 µs | 1.95x |
| numpy default_rng | 1024 | 5.3 µs | 1.00x |
| zsampler | 1024 | 3.0 µs | 1.75x |
| numpy default_rng | 8192 | 22.6 µs | 1.00x |
| zsampler | 8192 | 10.7 µs | 2.10x |
End-to-End DataLoader
The full input pipeline comparison (sample, fetch, collate, hand over a batch)
uses 4 transform workers and batch 256 (64 MiB). Throughput is the median of
three 200-batch rounds. Memory is measured in a separate steady-state pass so
reading /proc cannot perturb the timed rounds. peak PSS sums proportional
set size across the process tree, apportioning shared and copy-on-write pages
instead of counting each once per worker. aggregate RSS deliberately sums
each process's full resident set: on Linux it double-counts shared/COW pages,
which explains process-tree RSS inflation but is neither physical memory nor a
projection of Windows committed memory. The explicit spawn row is the relevant
no-fork control; Windows itself still requires a native run.
Grain setup remains optional through --only grain and was run separately with
the same workload.
storage is the actual backing store used by each pipeline. This is an
end-to-end systems comparison, not a scheduler-only comparison over one shared
storage layer: Torch reads read-only NumPy mmap files, Loaderx reads Zrecord,
and Grain reads ArrayRecord.
| loader | storage | batches/s | peak PSS | aggregate RSS |
|---|---|---|---|---|
| loaderx | zrecord-zstd | 60.0 | 1029 MiB | 1033 MiB |
| loaderx-raw | zrecord-raw | 121.6 | 1068 MiB | 1072 MiB |
| torch | npy-mmap-raw | 63.4 | 2216 MiB | 7794 MiB |
| torch-spawn | npy-mmap-raw | 67.6 | 3286 MiB | 6071 MiB |
| grain | arrayrecord-zstd | 26.6 | 2202 MiB | 2641 MiB |
At 64 MiB per batch the per-batch gather dominates the tiny sampler cost, and
the transform threads overlap Python-side collation with the next gather. The
memory is the source, Store and bounded in-flight batches. loaderx prefetches in
threads inside one process, so workers share one interpreter, one NumPy runtime
and one set of gather buffers; PSS prevents the inherited source and mmap pages
in torch and Grain workers from being counted repeatedly. Compressed loaderx is
within 6% of torch in this pass; loaderx-raw is 1.92x faster than torch and 4.57x
faster than Grain. Torch's aggregate RSS is 7.5x loaderx because Linux fork
mappings are counted repeatedly; that figure diagnoses the old RSS table, not
Windows memory. The explicit torch-spawn row removes fork/COW dependence: its
Linux PSS rises to 3.2x loaderx. Because this Dataset keeps only mmap paths, spawn does
not copy the full corpus into every worker; a Windows Dataset holding Python
lists or in-memory arrays would be a different, deliberately harsher workload.
Free-threaded Python. loaderx targets free-threaded builds (no GIL), and
the key sections are also measured on 3.14t — the question is whether anything
gains when nothing is GIL-limited. These are opt-in runs (a second interpreter
and --transform augment); they are not part of the default all:
-
Sampler — zsampler draws 1.8–2.2x faster than free-threaded NumPy, the same broad margin as on the GIL build: a batch is one Zig call either way.
-
Historical Store snapshot — these older rows predate the symmetric benchmark matrix and are retained only for the GIL/free-threaded comparison. The free-threaded numbers track the GIL table closely.
zrecord-rawgathers 8.9 vs 8.8 GiB/s andzrecord-zstd4.7 vs 4.6 GiB/s. The no-GIL table was run without blosc2 so its import could not change the process mode. blosc2 was measured separately at 678 MiB/s; its extension emits a warning and re-enables the GIL, so that cell is not a no-GIL result. ArrayRecord has no usable 3.14t extension. Random gather, 256 KiB records:store CPython 3.14 (GIL) free-threaded 3.14t zrecord-raw 8803 MiB/s 8878 MiB/s zrecord-zstd 4603 MiB/s 4707 MiB/s npy-mmap-raw 4408 MiB/s 3974 MiB/s hdf5-raw 2288 MiB/s 2210 MiB/s blosc2-zstd 646 MiB/s 678 MiB/s (GIL enabled) tensorstore-zarr3-gzip 299 MiB/s 323 MiB/s -
Loader, identity — the same broad range on both interpreters at 64 MiB per batch:
loader CPython 3.14 free-threaded 3.14t GIL PSS/RSS 3.14t PSS/RSS loaderx 60.0 batches/s 60.9 batches/s 1029/1033 MiB 1011/1015 MiB loaderx-raw 121.6 batches/s 114.1 batches/s 1068/1072 MiB 1013/1017 MiB -
Loader, CPU-heavy transform — the one place the free-threaded build matters. A transform runs on the transform threads, so the GIL serializes it on standard CPython — the case where worker processes win, and the reason for the free-threaded build.
--transform augment(a Python-bound per-sample loop), 4 workers. Torch and Grain have no free-threaded rows, so their GIL worker processes are shown only as context:loader CPython 3.14 free-threaded 3.14t GIL PSS/RSS 3.14t PSS/RSS loaderx 26.9 batches/s 38.4 batches/s 1338/1341 MiB 1294/1298 MiB loaderx-raw 31.3 batches/s 46.4 batches/s 1376/1380 MiB 1364/1367 MiB torch 30.4 batches/s — 2193/7773 MiB — torch-spawn 19.4 batches/s — 3352/6148 MiB — grain 11.8 batches/s — 2246/2684 MiB — Free-threaded wins because the transform parallelizes across the prefetch threads: 1.43x on compressed and 1.48x on raw loaderx at 256 KiB. The margin is bounded by the cost of gathering each 64 MiB batch.
Conclusion — why the numbers look like this.
Every hot path is batched natively. Zsampler draws a whole batch of indices; DenseStore gathers and decompresses a whole fixed-shape batch in one CFFI call; RaggedStore gathers each shape-prefixed record once before Python validates its prefix and reconstructs the exact arrays. The speedup is not bought with sampling shortcuts: the IID draw is unbiased like NumPy's (Lemire with rejection, so uniformity costs nothing over a real index space).
The layouts match what a training loader does. Zrecord is built for random record access: DenseStore gathers fixed-width records directly into one ndarray; RaggedStore restores independently shaped records from inline shape/payload entries. Array stores are built primarily for contiguous scans, so a scattered batch fights their layout. A dense raw gather fans out across the shared Executor budget where NumPy fancy indexing is one thread; RaggedStore instead trades some raw specialization for compression and a complete variable-shape persistence model.
Compression is in the storage kernel, and there is one codec. zrecord-zstd is not
"storage plus a codec": the layout, the multi-core decompress and the GIL-free
copy are one path, so turning compression on costs part of a margin, not an
order of magnitude. The ratio is the data, not the
format: in the current Dense structured-vision workload, plain zstd reaches
14.69x and the balanced dictionary reaches 22.74x.
Loader results combine architecture and storage. loaderx uses threads and never ends an epoch, so a step pays no IPC and never waits on an epoch boundary; torch uses finite shuffled epochs, worker processes and shared-memory handoff. Here compressed loaderx is 0.95x torch and 2.26x Grain; raw loaderx is 1.92x and 4.57x faster, respectively. Storage also differs per loader — each reads from what it was built for — so the loader table is a different comparison from either store table, not a rerun. The one crack in the thread model is a CPU-heavy transform, which the GIL serializes — that is exactly what free-threaded Python removes, so loaderx is developed and benchmarked against free-threaded builds first.
What these numbers do not claim. Everything runs with a warm page cache: this
measures the access path, not cold storage or disk. disk is allocated
blocks, and zrecord files grow to their written frontier. write follows each
backend's build API rather than normalizing durability: Zrecord excludes its
final bulk barrier but includes initial metadata and dictionary installation
syncs, while LMDB's default transaction commit is timed. This is a single
qualitative pass —
the µs-scale sampler timings and the loader batches/s fluctuate with box load
(on these 12 cores the compressed loader trails the raw one substantially), so
treat the absolute numbers as ballpark and the cross-backend margins as the
signal.
Reproduction
python3 scripts/bench_dense.py
python3 scripts/bench_ragged.py
python3 scripts/prepare_tokens.py wiki.train.tokens /tmp/wikitext-gpt2
python3 scripts/bench_dense.py --workload tokens \
--token-corpus /tmp/wikitext-gpt2 --records 200000 --batches 100
python3 scripts/bench_ragged.py --workload tokens \
--token-corpus /tmp/wikitext-gpt2 --records 200000 --batches 100
python3 scripts/bench.py sampler
python3 scripts/bench.py loader --only loaderx,loaderx-raw,torch --workers 4
python3 scripts/bench.py loader --only torch-spawn --workers 4
python3 scripts/bench.py loader --only grain --workers 4
python3 scripts/bench.py loader --only loaderx,loaderx-raw,torch \
--workers 4 --transform augment
python3 scripts/bench.py loader --only torch-spawn --workers 4 --transform augment
python3 scripts/bench.py loader --only grain --workers 4 --transform augment
python3.14t scripts/bench.py sampler
python3.14t scripts/bench.py loader --only loaderx,loaderx-raw --workers 4
python3.14t scripts/bench.py loader --only loaderx,loaderx-raw \
--workers 4 --transform augment
prepare_tokens.py also accepts Hugging Face WikiText Parquet shards directly.
The published run used Salesforce/wikitext, config wikitext-103-raw-v1,
revision refs/convert/parquet, train shards 0000.parquet then
0001.parquet; their SHA-256 values are respectively
74da360f23826045b3e6ac6375411fdb15f003030aa74f2596ed08b857cb9212 and
ba090ac30dbf5461e8dcbdd1a1b8e6f3cf9c2c756d64f0c1220450acd514f720.
The focused token defaults omit formats that are already represented in the
larger vision matrix; --only can select any registered backend explicitly.
The loader rows used Torch 2.13.0 and Grain 0.2.18; store dependencies are
pinned in scripts/requirements-bench.txt. Temporary stores used the ordinary
disk-backed /tmp filesystem, not /dev/shm.
Real-data verification: NTU RGB-D skeletons
The vision tables above are synthetic; the token tables use real WikiText-103.
As a separate historical ground-truth check,
loaderx was run end to end on NTU RGB-D skeleton data — 114,480 raw .skeleton files,
120 action classes, 25 joints — processed into the ST-GCN N C T V M layout
(per-sample (3, 300, 25, 2) float32) for the xsub/xview protocols. The
.npy outputs of the standard preprocessing pipeline were treated as ground
truth. This verification was not rerun with the synthetic benchmarks above;
its throughput is retained as a separate historical 12-core result.
Correctness — the read path is bit-exact against the ground truth:
- Full scan of all 228,356 records (joint float32 + label int64, all four
splits) through
DenseStore: byte-for-byte identical to the reference npy. - A
DataLoaderover joint + label + an index stream, run under all three sampler modes (sequential,iid,cyclic): every received batch is bit-exact to the ground truth at its own declared indices, and the streams stay index-aligned. - Sampler semantics hold on real index spaces:
sequentialwalks in order,cyclicdraws a full cycle without replacement,iidis deterministic per seed.
Storage — zstd on this data:
| store | on disk | ratio |
|---|---|---|
| npy (raw float32) | 6.4 GB | 1.00x |
| zrecord raw | 6.86 GB | 1.00x |
| zrecord zstd | 0.79 GB | 8.66x |
| zrecord zstd_dict | 0.75 GB | 9.13x |
Sizes above are for one split (xview/val, 38,132 records); across all four
splits the zstd joint stores total 4.97 GB against 41 GB of raw npy (~8x).
Throughput (180 KB per record, warm page cache, 12 physical cores):
| path | throughput |
|---|---|
| random-batch gather, zstd store | 4.1–4.5 GiB/s |
| same, npy-mmap fancy indexing | 0.6–1.3 GiB/s |
| DataLoader, 4 prefetch threads | 5.7–6.6 GiB/s (123–144 batches/s) |
zstd decompression reads ~8x fewer bytes than raw storage, so the compressed store gathers faster than the raw one (zstd 4587 MiB/s vs raw 1792 MiB/s on the same split).
The npy intermediate is optional. Parse the skeleton files in parallel and
feed each fixed-shape ndarray produced by the parser directly to
DenseStore.append. This writes the store in one pass with no npy staging or
second read; zrecord bounds its compression working memory independently.
Current Limitations
- Single-host only; multi-host training is not supported.
- A single sample must be at most 2 GiB (2^31 bytes). There is no fixed record
count:
lengthis a u64 and the record table grows on demand. Practical store size is bounded by disk and the platform's positional file-offset range. - Metadata is read and written as the host's struct layout, 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 # native store suite, in both Debug and ReleaseFast
python3 scripts/test_loaderx.py # Python integration suite against the real build
uv pip install -r scripts/requirements-bench.txt
python3 scripts/bench_dense.py # fixed-shape store comparison
python3 scripts/bench_ragged.py # variable-length store comparison
python3 scripts/bench.py # machine, sampler, and dense loader layers
The Zig side is tested for behaviour only; throughput is measured from Python, through the binding a client actually uses. Optional benchmark contenders are skipped when 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. Every mode is a
pure function of (seed, step), so a run resumes exactly by seeking to a step —
there is no epoch to track, in keeping with the endless step-based loader.
from loaderx.zsampler import Sampler
sampler = Sampler(1_000_000, 256, Sampler.Mode.IID, seed=42)
indices = sampler.next()
- Sequential — traverse the index space in order through a fixed-size sliding window, treating the space as a circular queue so the tail never truncates.
- IID — draw each index uniformly at random with replacement. Unbiased (Lemire with rejection), matching NumPy. Simplest, but coverage is uneven over any short run.
- Cyclic — without replacement, round-robin. Each cycle traverses a fresh
permutation of the whole index space, so within a cycle every record appears
exactly once and no batch repeats an index — coverage is even by construction,
which keeps how often each sample is seen uniform. The permutation is a
stateless bijection (a small Feistel network over the index space, brought
into range by cycle-walking), so a million-record shuffle materializes nothing
the size of the dataset and reshuffling each cycle is free. Every batch is
exactly
batch_size: an endless step-based loader has no final partial batch to special-case, so whenbatch_sizedoes not divide the length the cycle's remainder is dropped — a different remainder each cycle, since the permutation changes, so every record is still reached over time.
Zrecord
Zrecord is loaderx's typed persistent record store. Its private native runtime is
the byte-oriented engine beneath the public DenseStore and RaggedStore contracts.
The private Python CFFI surface is kept together in loaderx/_store.py because
both contracts share one libstore, error model, lifecycle and opaque Store
handle. Python owns all schema semantics; Zig stores the opaque schema bytes in
meta.zr. In Zig,
src/store.zig adapts each Dense stride or Ragged offsets call to the shared
physical engine and is the sole C ABI export/composition root. zrecord.py
remains the unified Python-facing API.
Trust boundary. Python and Zig are one zrecord implementation, not two
independently supported products. loaderx/_store.py, the C ABI in
src/store.zig, and the native handles are private implementation details; no
compatibility or defensive-validation contract is provided to code that calls
them directly. Public inputs are normalized and validated once, in whichever
half can express the rule most simply, and the internal Python→CFFI→Zig call
then trusts that contract instead of repeating it at every layer. This is not a
license to trust storage or the operating system: native code still validates
persisted addresses and lengths, buffer bounds and integer overflow, short or
failed I/O, codec output, locking, and commit ordering. Those checks protect
normal I/O behavior, basic malformed-store rejection and native memory safety; checks that only
defend against bypassing the public Python API do not belong in zrecord.
RecordEngineis 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.- It hands the Store layer 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. Named streams are composed dynamically by a plain Python dict;DataLoadervalidates that the independent stores have equal lengths. - The engine reads and writes byte ranges. Python owns the record schema and
selects Dense or Ragged geometry; the private ABI receives only the runtime
byte geometry. Dense stores persist
one fixed-width physical record per logical record. Ragged stores
persist one variable-width physical record per logical record:
[u64le ndim][u64le dims...][payload]. Shape and payload therefore share one location, codec frame, append commit, and swap-delete operation. - The IO model (
append | read | delete) is batch-oriented and shape-agnostic. Per-call adapters expose record boundaries through a compile-time source interface; the engine has one append operation and carries no Dense, Ragged, dtype, or array-shape semantics. A single-record operation is just thebatch_size == 1case. - The engine owns its temporary memory internally — allocation and release are explicit.
- A store has exactly one codec in its native physical header, fixed at creation and immutable afterwards. Every record is compressed and decompressed independently:
| tag | name | algorithm |
|-------|-----------|----------------------------------------|
| 0 | raw | none |
| 1 | zstd | zstd (plain, level 3) |
| 2 | zstdict | zstd with a trained dictionary (level 19) |
- Compression is transparent to the client:
- Compression runs concurrently across the shared Executor budget. A compressed store never
falls back to raw: each record is stored as the codec's output, even when
an incompressible record's frame is larger than its input — write
rawif the data does not compress. - Decompression writes straight into the caller's destination memory
(
gather), with no intermediate buffer and no extra copy. - zstd is the one transparent codec — faster than Deflate at both ends and a better ratio, so there is no reason to carry a second. It is vendored C, built for every platform by Zig, so the one-wheel-per-platform story is unchanged.
zstd_dictadditionally trains one dictionary on a sample of the data (stored asdict.zr) and compresses every record against it. Because each record is still independent, random access is unchanged — but the dictionary carries the structure shared across records, which per-record compression cannot see. On many small, similar records (image tiles, token sequences) this is a large win: the current Dense structured-vision set is 14.69x with plain zstd and 22.74x with the balanced dictionary. The dictionary is loaded once on open and shared, lock-free, across all reader threads. The dictionary size is chosen from theDICT_TIERSpresets (see Codec notes).- A
zstd_dictstore needs its dictionary to read every record; araworzstdstore rejects an unexpected dictionary as malformed state.
- Compression runs concurrently across the shared Executor budget. A compressed store never
falls back to raw: each record is stored as the codec's output, even when
an incompressible record's frame is larger than its input — write
Persistence format
Zrecord is a rebuildable training-data container, not a transactional database
or the authority for irreplaceable source data. It strictly checks normal I/O,
bounds and basic format invariants, and sync() flushes data before metadata.
It deliberately does not add a WAL, checksums, rollback, torn-sector
recovery or crash recovery for offline maintenance; keep source archives or a
reproducible build pipeline, and rebuild a Store after an interrupted
delete/compact or storage failure.
Native storage uses a fixed file set:
store/
├── meta.zr 4096-byte static Header/schema page + RecordLoc table
├── data.zr payload stream
└── dict.zr zstd dictionary (only in dict stores)
Metadata (meta.zr)
Files are read and written positionally — pread/pwrite at computed offsets,
no mmap. meta.zr starts with one fixed 4096-byte static page: a naturally
aligned 32-byte Header, then the opaque MsgPack schema and unused zero padding.
An array of 16-byte RecordLocs starts at offset 4096. Record i is one
pread/pwrite at 4096 + i * 16; there is no variable table base, segment
mapping, or rollover fd table.
1. Python schema — bytes 32..32+schema_length are exactly one immutable
MsgPack object. Dense stores contain only dtype and item_shape; Ragged
stores contain only dtype. Native create persists these bytes together with
the physical Store but does not decode them. Open acquires the native lifetime
lock before copying the schema to Python for validation, so schema and physical
metadata are one locked snapshot. Dense record width is derived once from
dtype/item_shape and passed to the native handle as runtime geometry; it is not
independently persisted as a second authority. There is no format version or
legacy kind dispatch.
2. Physical header — the first 32 bytes of meta.zr. The format
deliberately carries no payload or metadata checksum.
codecis the store's one compression method, stamped at creation and immutable — there is no per-record tag anywhere.length(u64) is the physical record count; it equals logical length for both dense stores and inline ragged stores.tail_offset(u64) is the absolute committed frontier indata.zr.schema_length(u32) is the occupied prefix of the static schema area and must be in1..4064.
const Codec = enum(u8) { raw = 0, zstd = 1, zstdict = 2, _ };
const Header = extern struct {
length: u64,
tail_offset: u64,
schema_length: u32,
reserved: [11]u8,
codec: u8,
};
3. Record table — contiguous 16-byte entries start at offset 4096 in
meta.zr and grow as location runs are written. offset is an absolute byte offset in data.zr;
phys_length/logic_length are the stored and original sizes. The
codec is not here: it is the header's, so a record is stored exactly the way the
store is declared.
const RecordLoc = extern struct {
offset: u64,
phys_length: u32,
logic_length: u32,
};
There is no liveness flag. Every entry below length is live, because deletion
swaps the tail into the hole rather than tombstoning.
4. No fixed record-count cap. The table and payload stream grow naturally in their fixed files. The practical bounds are the u64 count, supported positional file offsets, 2 GiB per record, and disk.
Executor
1. Write. Writes are append-only; everything else is offset redirection.
The codec is immutable store state, so append and gather dispatch once at
their entry points into separate raw, zstd, or zstd-dictionary implementations.
Their contexts and workers are deliberately not unified: only validation,
location bounds, locking, and final publication are shared.
Geometry is equally explicit across the whole stack: Python derives Dense
record width from its schema and passes it to each fixed-stride operation,
while RaggedStore supplies offsets for its shape-prefixed records.
The private ABI turns those inputs into compile-time record sources and
destinations; the engine has one append and one gather operation. Its shared
opaque handle remains private and carries no typed-store geometry.
- Compressed append: workers claim individual records, compress into independent
slots, reserve physical offsets in completion order through a short frontier
lock, and issue positional payload writes in parallel. Logical IDs remain in
the loc table, so physical completion order does not change random gather.
The caller waits for every payload before publishing the new locations and
in-process length. Append does not overwrite the on-disk Header and is
intentionally lazy.
sync()flushesdata.zr, writes the current Header, then flushesmeta.zr; successful return makes all payload and metadata durable without promising transactional recovery from an interrupted sync. Every compressed frame owns one absolute range indata.zr; positional writes extend the file to the current frontier. Python budgets the process-wide executor at three quarters of the logical CPUs available to the process, leaving headroom for packing, transforms, and the caller without encoding a platform-specific thread count. Each producer configures its CCtx or shared immutable CDict once, then starts every independent record frame withZSTD_compress2. - Raw append preserves the stronger invariant already supplied by Python: every
record in one Dense or packed Ragged batch is a boundary inside one contiguous
source buffer. After locating the records, the engine consumes that buffer in
plan order and passes each contiguous planned range directly to
writePositionalAllondata.zr. There is no per-record iovec construction, byte-budget flush, payload copy, or platform-specific syscall path. Zig'sstd.Iohandles short writes and maps the same positional operation to POSIX and Windows implementations. - 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 and
lengthis published through an atomic. The fixedmeta.zranddata.zrhandles require no rollover fd-table synchronization; record I/O itself stays lock free. - Every record is read at the offset its table entry records — the record table is addressed by pure arithmetic, so random access is one pread for the location and one for the bytes, with no batching assumptions about layout. Each lane reads one location and immediately reads/decompresses that record; there is no separate metadata phase or sequential-run special case. Compressed records use a per-lane staging buffer and decode in place into the destination.
3. Internal fan-out. Io.Group.async fans work out up to the executor lane
budget; lanes for which the runtime cannot reserve concurrency run inline on the
calling thread. The budget is the same runtime three-quarter proportion on every
platform, not a recorded physical- or logical-core count.
- Lanes receive contiguous blocks rather than a strided subset, keeping each worker's reads and writes sequential.
- Each lane creates one zstd context (
ZSTD_CCtxto write,ZSTD_DCtxto read) and reuses it across every record it handles, rather than paying that setup per record. The dictionary (ZSTD_CDict/ZSTD_DDict) is immutable, so all lanes share one, lock-free. - Decompression writes straight into the caller's destination buffer, so there is no intermediate copy.
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.
- Writing a record cannot land on the bytes of a record not yet loaded, so the uninterrupted rewrite is safe in place with no scratch copy of the store. Compaction is not a transaction: interruption or I/O failure may require the Store to be rebuilt from its source data.
- 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.
data.zris truncated to the new absolute frontier, releasing its old tail.- Delete has already made the live location table a dense
0..lengthprefix, so compact truncatesmeta.zrto exactly4096 + length * 16bytes. stats()reportslive_bytesagainstdata_bytes; their difference is the exact payload space compaction can reclaim.
5. File access.
- Metadata: one naturally growing
meta.zr, containing the fixed Header/schema page and loc table. - Payload: one naturally growing
data.zr, accessed concurrently throughreadPositionalAll/writePositionalAll. No path depends on filesystem sparse-file support.
Execution model. A Store executes one native operation at a time. Dense
append is synchronous. Ragged append uses one bounded Python queue, so preparing
the next packed batch can overlap the current native append; reads and
maintenance drain that queue first. Each native append or gather still fans out
internally across the shared Executor. A lifetime, nonblocking exclusive lock on
meta.zr permits only one native handle or process per Store; another open fails
with StoreBusy.
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-1.10.2.tar.gz.
File metadata
- Download URL: loaderx-1.10.2.tar.gz
- Upload date:
- Size: 621.1 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/7.0.0 CPython/3.14.7
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
5f181286525dc3cd62e0c54a8afbd9d0dcb5c4ec4e1fd1ac646f6092766110d2
|
|
| MD5 |
f79ddf7bbca1bf2f01f9e85bfdf8af1d
|
|
| BLAKE2b-256 |
72a634a29c5ea59f40aa6296f13e4c63e4a6b69bce9d13b0f241223a0b695760
|
File details
Details for the file loaderx-1.10.2-py3-none-win_arm64.whl.
File metadata
- Download URL: loaderx-1.10.2-py3-none-win_arm64.whl
- Upload date:
- Size: 387.7 kB
- Tags: Python 3, Windows ARM64
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/7.0.0 CPython/3.14.7
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
044d6e1f5dc400cdb653cf75be742f42e10a3526ab5e3e03be7757deb89cc965
|
|
| MD5 |
7876fd9f2655ff70246dcb7789e81103
|
|
| BLAKE2b-256 |
b0a6c9f23f9ef1331d22617a90cb10d43a5546cf40314c1029f741f0025c3684
|
File details
Details for the file loaderx-1.10.2-py3-none-win_amd64.whl.
File metadata
- Download URL: loaderx-1.10.2-py3-none-win_amd64.whl
- Upload date:
- Size: 532.5 kB
- Tags: Python 3, Windows x86-64
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/7.0.0 CPython/3.14.7
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
1e9ccda1c571608a57f90f6dcf64df15e926c2fe6f14b6552495fd42c31df484
|
|
| MD5 |
4a38c5f0096e25bd0a0953a2bcf8a9f2
|
|
| BLAKE2b-256 |
1679307da477e867f23b8333bf7efa231bd618e2306573d63b5aade8869e8023
|
File details
Details for the file loaderx-1.10.2-py3-none-musllinux_1_2_x86_64.whl.
File metadata
- Download URL: loaderx-1.10.2-py3-none-musllinux_1_2_x86_64.whl
- Upload date:
- Size: 463.8 kB
- Tags: Python 3, musllinux: musl 1.2+ x86-64
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/7.0.0 CPython/3.14.7
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
f866ac98a29ec836fb9ffe7dfea08804dc4bbaf902d96e786ab959190ce7b944
|
|
| MD5 |
c3d15147d3fcab7404d57ae39aec06eb
|
|
| BLAKE2b-256 |
231fa2f44bb02e4a1c5c5a8bc655baa302f0d7951e9dbb22316c02e808badcc7
|
File details
Details for the file loaderx-1.10.2-py3-none-musllinux_1_2_aarch64.whl.
File metadata
- Download URL: loaderx-1.10.2-py3-none-musllinux_1_2_aarch64.whl
- Upload date:
- Size: 389.7 kB
- Tags: Python 3, musllinux: musl 1.2+ ARM64
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/7.0.0 CPython/3.14.7
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
cf3b48324ed56d1778da1b7057f349d39362b5a7d1845a7749619be27da857dc
|
|
| MD5 |
be76bc7683cd19fddde48dc1d0329803
|
|
| BLAKE2b-256 |
dda0b925fe039ecb667280e31164f93e59b8355addbffd285cd70de871e12fd0
|
File details
Details for the file loaderx-1.10.2-py3-none-manylinux_2_17_x86_64.whl.
File metadata
- Download URL: loaderx-1.10.2-py3-none-manylinux_2_17_x86_64.whl
- Upload date:
- Size: 453.7 kB
- Tags: Python 3, manylinux: glibc 2.17+ x86-64
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/7.0.0 CPython/3.14.7
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
2762507694908e11814cf93999b6407fa3c5d507f241517801eb9f3e9fd4b9e0
|
|
| MD5 |
4f2ad48d5d8638c55406032e9bfef039
|
|
| BLAKE2b-256 |
6d3bc1bcf4e6c695517cb4f374717c35773c09abc26292cf1419238ec5baef69
|
File details
Details for the file loaderx-1.10.2-py3-none-manylinux_2_17_aarch64.whl.
File metadata
- Download URL: loaderx-1.10.2-py3-none-manylinux_2_17_aarch64.whl
- Upload date:
- Size: 381.0 kB
- Tags: Python 3, manylinux: glibc 2.17+ ARM64
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/7.0.0 CPython/3.14.7
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
d3d58d4f0d4a94c19133acbfb5a3b2fccecf9e788ebd46f3d607bf99d220ebcb
|
|
| MD5 |
159c6e003fc5da55d8250242ca69a81e
|
|
| BLAKE2b-256 |
95be337f535180da642bcfcd49d2db6d9af09d38cc9d8fab6a429c6a55ae89b7
|
File details
Details for the file loaderx-1.10.2-py3-none-macosx_11_0_x86_64.whl.
File metadata
- Download URL: loaderx-1.10.2-py3-none-macosx_11_0_x86_64.whl
- Upload date:
- Size: 430.0 kB
- Tags: Python 3, macOS 11.0+ x86-64
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/7.0.0 CPython/3.14.7
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
acd191a4fb7bcb336a259756330cf4cc715f014896ca25a77727c0dc63098d76
|
|
| MD5 |
7e9f773b32e2fafb2829094beb8f430b
|
|
| BLAKE2b-256 |
60d44a57e083c14c704e2696b259fa39a794cf06650e562e0186919596482b47
|
File details
Details for the file loaderx-1.10.2-py3-none-macosx_11_0_arm64.whl.
File metadata
- Download URL: loaderx-1.10.2-py3-none-macosx_11_0_arm64.whl
- Upload date:
- Size: 366.3 kB
- Tags: Python 3, macOS 11.0+ ARM64
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/7.0.0 CPython/3.14.7
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
58a95d00a3995dfb19145644589322914d53c5b83a498549b6f88121a81a8e98
|
|
| MD5 |
d105bc2e628659929123c23be74f10ff
|
|
| BLAKE2b-256 |
4100c7ff2547e6d554dc9e0048a374f17d8d8338950fec396dad8b23ca99d0ce
|