loaderx 0.7.3__tar.gz → 0.9.4__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {loaderx-0.7.3/loaderx.egg-info → loaderx-0.9.4}/PKG-INFO +159 -120
- {loaderx-0.7.3 → loaderx-0.9.4}/README.md +157 -119
- loaderx-0.9.4/loaderx/__init__.py +18 -0
- loaderx-0.9.4/loaderx/converter.py +224 -0
- {loaderx-0.7.3 → loaderx-0.9.4}/loaderx/dataloader.py +12 -12
- loaderx-0.9.4/loaderx/dataset.py +174 -0
- loaderx-0.9.4/loaderx/zrecord.py +281 -0
- {loaderx-0.7.3 → loaderx-0.9.4/loaderx.egg-info}/PKG-INFO +159 -120
- {loaderx-0.7.3 → loaderx-0.9.4}/loaderx.egg-info/SOURCES.txt +3 -2
- {loaderx-0.7.3 → loaderx-0.9.4}/loaderx.egg-info/requires.txt +1 -0
- {loaderx-0.7.3 → loaderx-0.9.4}/pyproject.toml +1 -1
- {loaderx-0.7.3 → loaderx-0.9.4}/scripts/bench.py +206 -177
- {loaderx-0.7.3 → loaderx-0.9.4}/src/zrecord.zig +103 -197
- {loaderx-0.7.3 → loaderx-0.9.4}/tests/test_loaderx.py +175 -160
- loaderx-0.7.3/loaderx/__init__.py +0 -5
- loaderx-0.7.3/loaderx/_zrecord.py +0 -523
- loaderx-0.7.3/loaderx/dataset.py +0 -226
- {loaderx-0.7.3 → loaderx-0.9.4}/LICENSE +0 -0
- {loaderx-0.7.3 → loaderx-0.9.4}/MANIFEST.in +0 -0
- {loaderx-0.7.3 → loaderx-0.9.4}/build.zig +0 -0
- {loaderx-0.7.3 → loaderx-0.9.4}/build.zig.zon +0 -0
- {loaderx-0.7.3 → loaderx-0.9.4}/loaderx/_lib.py +0 -0
- /loaderx-0.7.3/loaderx/_sampler.py → /loaderx-0.9.4/loaderx/sampler.py +0 -0
- {loaderx-0.7.3 → loaderx-0.9.4}/loaderx.egg-info/dependency_links.txt +0 -0
- {loaderx-0.7.3 → loaderx-0.9.4}/loaderx.egg-info/top_level.txt +0 -0
- {loaderx-0.7.3 → loaderx-0.9.4}/scripts/build_wheels.py +0 -0
- {loaderx-0.7.3 → loaderx-0.9.4}/setup.cfg +0 -0
- {loaderx-0.7.3 → loaderx-0.9.4}/setup.py +0 -0
- {loaderx-0.7.3 → loaderx-0.9.4}/src/zsampler.zig +0 -0
- {loaderx-0.7.3 → loaderx-0.9.4}/src/zstd/c.zig +0 -0
- {loaderx-0.7.3 → loaderx-0.9.4}/vendor/zstd/COPYING +0 -0
- {loaderx-0.7.3 → loaderx-0.9.4}/vendor/zstd/LICENSE +0 -0
- {loaderx-0.7.3 → loaderx-0.9.4}/vendor/zstd/lib/common/allocations.h +0 -0
- {loaderx-0.7.3 → loaderx-0.9.4}/vendor/zstd/lib/common/bits.h +0 -0
- {loaderx-0.7.3 → loaderx-0.9.4}/vendor/zstd/lib/common/bitstream.h +0 -0
- {loaderx-0.7.3 → loaderx-0.9.4}/vendor/zstd/lib/common/compiler.h +0 -0
- {loaderx-0.7.3 → loaderx-0.9.4}/vendor/zstd/lib/common/cpu.h +0 -0
- {loaderx-0.7.3 → loaderx-0.9.4}/vendor/zstd/lib/common/debug.c +0 -0
- {loaderx-0.7.3 → loaderx-0.9.4}/vendor/zstd/lib/common/debug.h +0 -0
- {loaderx-0.7.3 → loaderx-0.9.4}/vendor/zstd/lib/common/entropy_common.c +0 -0
- {loaderx-0.7.3 → loaderx-0.9.4}/vendor/zstd/lib/common/error_private.c +0 -0
- {loaderx-0.7.3 → loaderx-0.9.4}/vendor/zstd/lib/common/error_private.h +0 -0
- {loaderx-0.7.3 → loaderx-0.9.4}/vendor/zstd/lib/common/fse.h +0 -0
- {loaderx-0.7.3 → loaderx-0.9.4}/vendor/zstd/lib/common/fse_decompress.c +0 -0
- {loaderx-0.7.3 → loaderx-0.9.4}/vendor/zstd/lib/common/huf.h +0 -0
- {loaderx-0.7.3 → loaderx-0.9.4}/vendor/zstd/lib/common/mem.h +0 -0
- {loaderx-0.7.3 → loaderx-0.9.4}/vendor/zstd/lib/common/pool.c +0 -0
- {loaderx-0.7.3 → loaderx-0.9.4}/vendor/zstd/lib/common/pool.h +0 -0
- {loaderx-0.7.3 → loaderx-0.9.4}/vendor/zstd/lib/common/portability_macros.h +0 -0
- {loaderx-0.7.3 → loaderx-0.9.4}/vendor/zstd/lib/common/threading.c +0 -0
- {loaderx-0.7.3 → loaderx-0.9.4}/vendor/zstd/lib/common/threading.h +0 -0
- {loaderx-0.7.3 → loaderx-0.9.4}/vendor/zstd/lib/common/xxhash.c +0 -0
- {loaderx-0.7.3 → loaderx-0.9.4}/vendor/zstd/lib/common/xxhash.h +0 -0
- {loaderx-0.7.3 → loaderx-0.9.4}/vendor/zstd/lib/common/zstd_common.c +0 -0
- {loaderx-0.7.3 → loaderx-0.9.4}/vendor/zstd/lib/common/zstd_deps.h +0 -0
- {loaderx-0.7.3 → loaderx-0.9.4}/vendor/zstd/lib/common/zstd_internal.h +0 -0
- {loaderx-0.7.3 → loaderx-0.9.4}/vendor/zstd/lib/common/zstd_trace.h +0 -0
- {loaderx-0.7.3 → loaderx-0.9.4}/vendor/zstd/lib/compress/clevels.h +0 -0
- {loaderx-0.7.3 → loaderx-0.9.4}/vendor/zstd/lib/compress/fse_compress.c +0 -0
- {loaderx-0.7.3 → loaderx-0.9.4}/vendor/zstd/lib/compress/hist.c +0 -0
- {loaderx-0.7.3 → loaderx-0.9.4}/vendor/zstd/lib/compress/hist.h +0 -0
- {loaderx-0.7.3 → loaderx-0.9.4}/vendor/zstd/lib/compress/huf_compress.c +0 -0
- {loaderx-0.7.3 → loaderx-0.9.4}/vendor/zstd/lib/compress/zstd_compress.c +0 -0
- {loaderx-0.7.3 → loaderx-0.9.4}/vendor/zstd/lib/compress/zstd_compress_internal.h +0 -0
- {loaderx-0.7.3 → loaderx-0.9.4}/vendor/zstd/lib/compress/zstd_compress_literals.c +0 -0
- {loaderx-0.7.3 → loaderx-0.9.4}/vendor/zstd/lib/compress/zstd_compress_literals.h +0 -0
- {loaderx-0.7.3 → loaderx-0.9.4}/vendor/zstd/lib/compress/zstd_compress_sequences.c +0 -0
- {loaderx-0.7.3 → loaderx-0.9.4}/vendor/zstd/lib/compress/zstd_compress_sequences.h +0 -0
- {loaderx-0.7.3 → loaderx-0.9.4}/vendor/zstd/lib/compress/zstd_compress_superblock.c +0 -0
- {loaderx-0.7.3 → loaderx-0.9.4}/vendor/zstd/lib/compress/zstd_compress_superblock.h +0 -0
- {loaderx-0.7.3 → loaderx-0.9.4}/vendor/zstd/lib/compress/zstd_cwksp.h +0 -0
- {loaderx-0.7.3 → loaderx-0.9.4}/vendor/zstd/lib/compress/zstd_double_fast.c +0 -0
- {loaderx-0.7.3 → loaderx-0.9.4}/vendor/zstd/lib/compress/zstd_double_fast.h +0 -0
- {loaderx-0.7.3 → loaderx-0.9.4}/vendor/zstd/lib/compress/zstd_fast.c +0 -0
- {loaderx-0.7.3 → loaderx-0.9.4}/vendor/zstd/lib/compress/zstd_fast.h +0 -0
- {loaderx-0.7.3 → loaderx-0.9.4}/vendor/zstd/lib/compress/zstd_lazy.c +0 -0
- {loaderx-0.7.3 → loaderx-0.9.4}/vendor/zstd/lib/compress/zstd_lazy.h +0 -0
- {loaderx-0.7.3 → loaderx-0.9.4}/vendor/zstd/lib/compress/zstd_ldm.c +0 -0
- {loaderx-0.7.3 → loaderx-0.9.4}/vendor/zstd/lib/compress/zstd_ldm.h +0 -0
- {loaderx-0.7.3 → loaderx-0.9.4}/vendor/zstd/lib/compress/zstd_ldm_geartab.h +0 -0
- {loaderx-0.7.3 → loaderx-0.9.4}/vendor/zstd/lib/compress/zstd_opt.c +0 -0
- {loaderx-0.7.3 → loaderx-0.9.4}/vendor/zstd/lib/compress/zstd_opt.h +0 -0
- {loaderx-0.7.3 → loaderx-0.9.4}/vendor/zstd/lib/compress/zstdmt_compress.c +0 -0
- {loaderx-0.7.3 → loaderx-0.9.4}/vendor/zstd/lib/compress/zstdmt_compress.h +0 -0
- {loaderx-0.7.3 → loaderx-0.9.4}/vendor/zstd/lib/decompress/huf_decompress.c +0 -0
- {loaderx-0.7.3 → loaderx-0.9.4}/vendor/zstd/lib/decompress/zstd_ddict.c +0 -0
- {loaderx-0.7.3 → loaderx-0.9.4}/vendor/zstd/lib/decompress/zstd_ddict.h +0 -0
- {loaderx-0.7.3 → loaderx-0.9.4}/vendor/zstd/lib/decompress/zstd_decompress.c +0 -0
- {loaderx-0.7.3 → loaderx-0.9.4}/vendor/zstd/lib/decompress/zstd_decompress_block.c +0 -0
- {loaderx-0.7.3 → loaderx-0.9.4}/vendor/zstd/lib/decompress/zstd_decompress_block.h +0 -0
- {loaderx-0.7.3 → loaderx-0.9.4}/vendor/zstd/lib/decompress/zstd_decompress_internal.h +0 -0
- {loaderx-0.7.3 → loaderx-0.9.4}/vendor/zstd/lib/dictBuilder/cover.c +0 -0
- {loaderx-0.7.3 → loaderx-0.9.4}/vendor/zstd/lib/dictBuilder/cover.h +0 -0
- {loaderx-0.7.3 → loaderx-0.9.4}/vendor/zstd/lib/dictBuilder/divsufsort.c +0 -0
- {loaderx-0.7.3 → loaderx-0.9.4}/vendor/zstd/lib/dictBuilder/divsufsort.h +0 -0
- {loaderx-0.7.3 → loaderx-0.9.4}/vendor/zstd/lib/dictBuilder/fastcover.c +0 -0
- {loaderx-0.7.3 → loaderx-0.9.4}/vendor/zstd/lib/dictBuilder/zdict.c +0 -0
- {loaderx-0.7.3 → loaderx-0.9.4}/vendor/zstd/lib/zdict.h +0 -0
- {loaderx-0.7.3 → loaderx-0.9.4}/vendor/zstd/lib/zstd.h +0 -0
- {loaderx-0.7.3 → loaderx-0.9.4}/vendor/zstd/lib/zstd_errors.h +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: loaderx
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.9.4
|
|
4
4
|
Summary: 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
|
|
5
5
|
Author-email: Ben0i0d <ben0i0d@foxmail.com>
|
|
6
6
|
License: MIT License
|
|
@@ -42,6 +42,7 @@ Description-Content-Type: text/markdown
|
|
|
42
42
|
License-File: LICENSE
|
|
43
43
|
Requires-Dist: numpy
|
|
44
44
|
Requires-Dist: cffi
|
|
45
|
+
Requires-Dist: msgpack
|
|
45
46
|
Dynamic: license-file
|
|
46
47
|
|
|
47
48
|
# Loaderx
|
|
@@ -64,20 +65,27 @@ loaderx is built around several core principles:
|
|
|
64
65
|
2. A strong focus on single-machine training workflows.
|
|
65
66
|
3. We implement based on NumPy semantics, persisted through the Zrecord storage runtime.
|
|
66
67
|
4. An **immortal (endless) step-based data loader**, rather than the traditional epoch-based design—better aligned with modern ML training practices.
|
|
68
|
+
5. **Dense and ragged are separate contracts; the loader is dense-only.**
|
|
69
|
+
zrecord still speaks one language underneath — records are packed byte
|
|
70
|
+
ranges located by offsets, and every read/write is a batch operation where
|
|
71
|
+
the batch size 1 is the degenerate case. But the Python layer does not force
|
|
72
|
+
equal length to be a special case of variable length: :class:`DenseDataset`
|
|
73
|
+
reads fixed-shape records with the shape inferred straight from the schema
|
|
74
|
+
and no per-record metadata, while :class:`RaggedDataset` reads
|
|
75
|
+
variable-length records into a list. Two kinds, one store engine — and a
|
|
76
|
+
training pipeline only ever needs the dense one.
|
|
67
77
|
|
|
68
78
|
## Quick Start
|
|
69
79
|
```python
|
|
70
|
-
from loaderx import
|
|
80
|
+
from loaderx.converter import from_numpy
|
|
81
|
+
from loaderx.dataset import DenseDataset
|
|
82
|
+
from loaderx.dataloader import DataLoader
|
|
71
83
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
write_dataset('train_label.zr', np.load('label.npy', mmap_mode='r'))
|
|
84
|
+
from_numpy('train_data', np.load('data.npy', mmap_mode='r'))
|
|
85
|
+
from_numpy('train_label', np.load('label.npy', mmap_mode='r'))
|
|
75
86
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
'data': Dataset('train_data.zr'),
|
|
79
|
-
'label': Dataset('train_label.zr'),
|
|
80
|
-
})
|
|
87
|
+
loader = DataLoader({'data': DenseDataset('train_data'), 'label': DenseDataset('train_label')},
|
|
88
|
+
transform=lambda batch: batch)
|
|
81
89
|
|
|
82
90
|
for i, batch in enumerate(loader):
|
|
83
91
|
if i >= 256:
|
|
@@ -87,69 +95,79 @@ print(batch['data'].shape)
|
|
|
87
95
|
print(batch['label'].shape)
|
|
88
96
|
```
|
|
89
97
|
|
|
90
|
-
A batch is a dict
|
|
91
|
-
|
|
92
|
-
|
|
98
|
+
A batch is a dict ``{name: values}``: each value is the stacked
|
|
99
|
+
``(batch_size, *item_shape)`` array for that stream. Every stream is gathered
|
|
100
|
+
at the same indices, so record ``i`` lines up across them. The ``transform``
|
|
101
|
+
callback is the collate step — reshape, cast, stack — where ``values`` is the
|
|
102
|
+
plain dense batch ready for the model.
|
|
93
103
|
|
|
94
104
|
### Converting a NumPy tensor
|
|
95
105
|
```python
|
|
96
106
|
import numpy as np
|
|
97
|
-
from loaderx import
|
|
107
|
+
from loaderx.converter import from_numpy
|
|
108
|
+
from loaderx.dataset import DenseDataset
|
|
98
109
|
|
|
99
|
-
|
|
100
|
-
|
|
110
|
+
from_numpy('train_data', np.load('data.npy', mmap_mode='r'))
|
|
111
|
+
from_numpy('train_label', np.load('label.npy', mmap_mode='r'))
|
|
101
112
|
|
|
102
113
|
# many small, similar records compress far better against a trained dictionary:
|
|
103
|
-
|
|
114
|
+
from_numpy('train_data', images, codec='zstd_dict')
|
|
104
115
|
```
|
|
105
116
|
One record per slice along axis 0; a 1-D array (the usual shape of a label set)
|
|
106
117
|
becomes a dataset of scalars. The conversion streams in bounded chunks, so an
|
|
107
|
-
mmapped array is never fully materialized.
|
|
108
|
-
|
|
109
|
-
the result with :class:`Dataset`::
|
|
118
|
+
mmapped array is never fully materialized. Open the result with
|
|
119
|
+
:class:`DenseDataset`:
|
|
110
120
|
|
|
111
|
-
ds =
|
|
121
|
+
ds = DenseDataset('train_data')
|
|
112
122
|
|
|
113
|
-
The store carries a single ``schema.
|
|
114
|
-
shape — that is the only
|
|
115
|
-
|
|
116
|
-
|
|
123
|
+
The store carries a single ``schema.msgpack`` recording the kind (``"dense"``
|
|
124
|
+
or ``"ragged"``), per-sample dtype and row shape — that is the only
|
|
125
|
+
loaderx-level metadata. Everything beneath it is plain Zrecord, reachable
|
|
126
|
+
through ``loaderx.zrecord.Zrecord`` when you want raw byte records (ragged
|
|
127
|
+
samples, pre-encoded images) instead of tensors.
|
|
117
128
|
|
|
118
|
-
###
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
padded. The store stays a plain blob of bytes; how a batch becomes dense is a
|
|
124
|
-
separate, visible step.
|
|
129
|
+
### Records
|
|
130
|
+
One store engine, two kinds of view. ``from_numpy`` writes a **dense** store
|
|
131
|
+
where every record is exactly one row of the recorded ``item_shape``; reads
|
|
132
|
+
are fixed-stride gathers and the batch shape follows from the schema, so no
|
|
133
|
+
per-record metadata is touched:
|
|
125
134
|
|
|
126
135
|
```python
|
|
127
136
|
import numpy as np
|
|
128
|
-
from loaderx import
|
|
137
|
+
from loaderx.converter import from_numpy
|
|
138
|
+
from loaderx.dataset import DenseDataset
|
|
129
139
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
140
|
+
from_numpy('data', np.arange(64, dtype=np.float32).reshape(8, 2, 4))
|
|
141
|
+
ds = DenseDataset('data')
|
|
142
|
+
ds[[0, 5, 2]] # (3, 2, 4) — shape straight from the schema
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
``from_iterator`` writes a **ragged** store of variable-length records. It is a
|
|
146
|
+
separate contract: :class:`RaggedDataset` hands back a list of arrays, so a
|
|
147
|
+
loader never has to carry ``row_splits`` around. Padding a list into a dense
|
|
148
|
+
batch is a plain function, wherever you need it:
|
|
133
149
|
|
|
134
|
-
|
|
135
|
-
|
|
150
|
+
```python
|
|
151
|
+
from loaderx.converter import from_iterator
|
|
152
|
+
from loaderx.dataset import RaggedDataset, pad
|
|
153
|
+
|
|
154
|
+
seqs = [np.arange(L, dtype=np.int32) for L in (3, 1, 4, 1, 5)]
|
|
155
|
+
from_iterator('tokens', seqs)
|
|
156
|
+
rs = RaggedDataset('tokens')
|
|
136
157
|
|
|
137
|
-
|
|
138
|
-
padded, lengths =
|
|
158
|
+
records = rs[[0, 2, 4]] # list of ndarray — one per record
|
|
159
|
+
padded, lengths = pad(records) # dense (B, max_len) + row counts
|
|
139
160
|
```
|
|
140
161
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
padding is just the collate `transform` (a batch dict in, a batch dict out), so
|
|
144
|
-
ragged streams and fixed-shape streams flow through the same loader:
|
|
162
|
+
A ``DataLoader`` works with dense streams, so collation is just the
|
|
163
|
+
``transform`` — a batch dict in, a batch dict out:
|
|
145
164
|
|
|
146
165
|
```python
|
|
147
166
|
def collate(batch):
|
|
148
|
-
|
|
149
|
-
padded, lengths = pad(values, row_splits)
|
|
150
|
-
return {'input_ids': padded, 'lengths': lengths, 'label': batch['label']}
|
|
167
|
+
return {'input_ids': batch['tokens'], 'label': batch['label']}
|
|
151
168
|
|
|
152
|
-
loader = DataLoader({'tokens':
|
|
169
|
+
loader = DataLoader({'tokens': dense_tokens, 'label': labelset}, batch_size=32,
|
|
170
|
+
transform=collate)
|
|
153
171
|
```
|
|
154
172
|
|
|
155
173
|
### Codec notes
|
|
@@ -182,92 +200,110 @@ For practical integration examples, please refer to the **[Data2Latent](https://
|
|
|
182
200
|
`scripts/bench.py` measures zrecord and loaderx against the alternatives in
|
|
183
201
|
the index sampler, the record store, and the full data loader — each through the
|
|
184
202
|
binding a client actually uses, so cffi, the GIL and the NumPy allocation are all
|
|
185
|
-
inside the timings.
|
|
186
|
-
|
|
203
|
+
inside the timings. Numbers are the median of 3 sampler runs, 5 store runs and
|
|
204
|
+
5 loader runs on the box below, warm page cache. Reproduce
|
|
205
|
+
with `python3 scripts/bench.py all` — it prints this machine block first.
|
|
206
|
+
|
|
207
|
+
**Machine** — one box, inside a memory-limited container:
|
|
208
|
+
|
|
209
|
+
| machine | value |
|
|
210
|
+
|-----------|-------|
|
|
211
|
+
| CPU | AMD EPYC 7303 16-Core (Zen 3), 1 socket, 16 cores / 32 threads |
|
|
212
|
+
| frequency | 1500–3437 MHz |
|
|
213
|
+
| caches | L1d 512 KiB, L1i 512 KiB, L2 8 MiB, L3 64 MiB |
|
|
214
|
+
| NUMA | 4 nodes |
|
|
215
|
+
| memory | 16 GiB (16 GiB cgroup limit) |
|
|
216
|
+
| HugePages | 16384 × 2 MiB = 32 GiB configured, usable in this container |
|
|
217
|
+
| OS | Debian GNU/Linux forky/sid, kernel 6.12.95 |
|
|
218
|
+
| python | 3.14.6, numpy 2.5.1 |
|
|
219
|
+
|
|
220
|
+
The container sees all 32 threads and has no CPU quota. HugePages are configured
|
|
221
|
+
by the host and usable here, but the benchmark runs on the ordinary page cache
|
|
222
|
+
and does not depend on them.
|
|
187
223
|
|
|
188
224
|
**1. Sampler** — index generation on its own, IID (with replacement), 1M index
|
|
189
225
|
space, against both NumPy random APIs.
|
|
190
226
|
|
|
191
227
|
| sampler | batch | per batch | indices | vs default_rng |
|
|
192
228
|
|-------------------|-------|-----------|-----------|----------------|
|
|
193
|
-
| numpy randint | 256 |
|
|
194
|
-
| numpy default_rng | 256 |
|
|
195
|
-
| **zsampler** | 256 | 1
|
|
196
|
-
| numpy randint | 1024 |
|
|
197
|
-
| numpy default_rng | 1024 |
|
|
198
|
-
| **zsampler** | 1024 |
|
|
199
|
-
| numpy randint | 8192 |
|
|
200
|
-
| numpy default_rng | 8192 |
|
|
201
|
-
| **zsampler** | 8192 |
|
|
202
|
-
|
|
203
|
-
Zsampler draws indices about 2x faster than NumPy's faster API — more against the
|
|
204
|
-
legacy `randint` most code still calls — and the lead is widest at the small
|
|
205
|
-
batches a loader actually draws, where a batch is one cffi call either way and
|
|
206
|
-
NumPy's vectorised fill has nothing to amortise. The IID draw is unbiased like
|
|
207
|
-
NumPy's: it uses Lemire's method with rejection, which over any real index space
|
|
208
|
-
effectively never redraws, so uniformity costs nothing here.
|
|
229
|
+
| numpy randint | 256 | 6.1 µs | 42 M/s | 1.02x |
|
|
230
|
+
| numpy default_rng | 256 | 6.2 µs | 41 M/s | 1.00x |
|
|
231
|
+
| **zsampler** | 256 | 3.1 µs | 83 M/s | **2.00x** |
|
|
232
|
+
| numpy randint | 1024 | 9.0 µs | 114 M/s | 0.91x |
|
|
233
|
+
| numpy default_rng | 1024 | 8.2 µs | 125 M/s | 1.00x |
|
|
234
|
+
| **zsampler** | 1024 | 4.4 µs | 233 M/s | **1.86x** |
|
|
235
|
+
| numpy randint | 8192 | 37.4 µs | 219 M/s | 0.83x |
|
|
236
|
+
| numpy default_rng | 8192 | 31.1 µs | 263 M/s | 1.00x |
|
|
237
|
+
| **zsampler** | 8192 | 17.2 µs | 476 M/s | **1.81x** |
|
|
209
238
|
|
|
210
239
|
**2. Store** — Zrecord against the alternatives: random batch gather, 20000
|
|
211
|
-
batch 256 × 200, structured (mildly compressible) data.
|
|
240
|
+
records, batch 256 × 200, structured (mildly compressible) data.
|
|
212
241
|
|
|
213
242
|
| store | write | gather | per batch | on disk | ratio |
|
|
214
243
|
|-------------------|------------|-------------|------------|---------|-------|
|
|
215
|
-
| zrecord-zstd |
|
|
216
|
-
| zrecord-zstdict |
|
|
217
|
-
| zrecord-raw |
|
|
218
|
-
| npy-mmap |
|
|
219
|
-
| arrayrecord |
|
|
220
|
-
| hdf5 |
|
|
221
|
-
| hdf5-gzip |
|
|
222
|
-
|
|
|
223
|
-
|
|
|
224
|
-
| blosc2 | 30 MiB/s | 217 MiB/s | 13.81 ms | 216 MiB | 1.09x |
|
|
225
|
-
| zarr | 78 MiB/s | 23 MiB/s | 131.13 ms | 217 MiB | 1.08x |
|
|
226
|
-
| tensorstore | 545 MiB/s | 89 MiB/s | 33.76 ms | 214 MiB | 1.09x |
|
|
227
|
-
|
|
228
|
-
The gather column is what the store is built for. zrecord-raw returns a scattered
|
|
229
|
-
batch faster than everything here, a memory-mapped `.npy` included, because it
|
|
230
|
-
copies straight into the destination across all cores with the GIL released; and
|
|
231
|
-
zrecord-zstd keeps most of that speed *while* decompressing — 10 GiB/s, several
|
|
232
|
-
times faster than any other compressed store (hdf5-gzip, lmdb-zlib, blosc2, zarr,
|
|
233
|
-
tensorstore) and faster even than the uncompressed `.npy`. That is the point of
|
|
234
|
-
one fast codec: turning compression on barely costs a read. The array stores lag
|
|
235
|
-
on gather because a scattered single-record read fights a layout meant for
|
|
236
|
-
contiguous slabs — exactly the access pattern a training loader has. Where zrecord
|
|
237
|
-
does not lead is write: it pays compression and its own layout up front, a
|
|
238
|
-
one-time cost it does not chase (`zrecord-zstdict` most of all, at level 19).
|
|
239
|
-
Compression is modest here (1.08–1.17x) only because this data is barely
|
|
240
|
-
compressible; on the smooth-image set of the [Zrecord](#zrecord) notes plain zstd
|
|
241
|
-
reaches 7.6x and the dictionary 16.5x.
|
|
242
|
-
|
|
243
|
-
The reads are not all single-threaded: zrecord and blosc2 decompress across cores,
|
|
244
|
-
the rest one record at a time. Zarr and TensorStore are sharded so `on disk`
|
|
245
|
-
reflects compression rather than per-file block rounding, and TensorStore is given
|
|
246
|
-
gzip (its zarr3 default is uncompressed). `on disk` is allocated blocks, which is
|
|
247
|
-
why LMDB's preallocated map lands below 1.00x.
|
|
244
|
+
| zrecord-zstd | 375 MiB/s | 8938 MiB/s | 0.34 ms | 216 MiB | 1.08x |
|
|
245
|
+
| zrecord-zstdict | 10 MiB/s | 3846 MiB/s | 0.78 ms | 182 MiB | 1.29x |
|
|
246
|
+
| zrecord-raw | 421 MiB/s | 13565 MiB/s | 0.22 ms | 235 MiB | 1.00x |
|
|
247
|
+
| npy-mmap | 2465 MiB/s | 12004 MiB/s | 0.25 ms | 234 MiB | 1.00x |
|
|
248
|
+
| arrayrecord | 218 MiB/s | 641 MiB/s | 4.68 ms | 217 MiB | 1.08x |
|
|
249
|
+
| hdf5 | 548 MiB/s | 149 MiB/s | 20.18 ms | 236 MiB | 0.99x |
|
|
250
|
+
| hdf5-gzip | 46 MiB/s | 87 MiB/s | 34.33 ms | 211 MiB | 1.11x |
|
|
251
|
+
| blosc2 | 20 MiB/s | 75 MiB/s | 40.03 ms | 216 MiB | 1.09x |
|
|
252
|
+
| tensorstore | 562 MiB/s | 40 MiB/s | 74.10 ms | 214 MiB | 1.09x |
|
|
248
253
|
|
|
249
254
|
**3. Loader** — the full input pipeline end to end (sample, fetch, collate, hand
|
|
250
|
-
over a batch), same workload, 4 workers.
|
|
251
|
-
for: loaderx from Zrecord, Grain from ArrayRecord, torch from a memory-mapped
|
|
252
|
-
`.npy`, jax-dataloader from an in-memory array.
|
|
255
|
+
over a batch), same workload, 4 workers.
|
|
253
256
|
|
|
254
257
|
| loader | batches/s | per batch | samples/s | throughput |
|
|
255
258
|
|----------------|-----------|-----------|-----------|-------------|
|
|
256
|
-
| **loaderx** |
|
|
257
|
-
| loaderx-raw |
|
|
258
|
-
| grain |
|
|
259
|
-
| torch |
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
259
|
+
| **loaderx** | 3397.4 | 0.29 ms | 869,734 | 10192 MiB/s |
|
|
260
|
+
| loaderx-raw | 4104.9 | 0.24 ms | 1,050,854 | 12315 MiB/s |
|
|
261
|
+
| grain | 52.0 | 19.23 ms | 13,312 | 156 MiB/s |
|
|
262
|
+
| torch | 604.7 | 1.65 ms | 154,803 | 1814 MiB/s |
|
|
263
|
+
|
|
264
|
+
**Conclusion** — why the numbers look like this.
|
|
265
|
+
|
|
266
|
+
**Every hot path is one native call.** Zsampler draws a whole batch of indices,
|
|
267
|
+
zrecord gathers a whole batch and decompresses it, in a single cffi call into
|
|
268
|
+
Zig with the GIL released and the batch copied straight into its destination
|
|
269
|
+
buffer. The contenders do the same work one record at a time from Python. That
|
|
270
|
+
one fact runs through all three tables: the sampler's margin is widest at the
|
|
271
|
+
smallest batches (a batch costs one call either way, so the per-index work is
|
|
272
|
+
what divides them), and the store gather column is where one-record-per-call
|
|
273
|
+
costs the most. The speedup is not bought with distribution shortcuts: the IID
|
|
274
|
+
draw is unbiased like NumPy's (Lemire with rejection, so uniformity costs
|
|
275
|
+
nothing over a real index space).
|
|
276
|
+
|
|
277
|
+
**The layout matches what a training loader does.** zrecord is built for random
|
|
278
|
+
record access — the meta table is mmap'd, any record is found in O(1), a dense
|
|
279
|
+
store gathers at a fixed stride. Array stores are built for contiguous scans, so
|
|
280
|
+
a scattered batch — exactly what a loader reads — fights their layout. That is
|
|
281
|
+
why uncompressed `.npy`, which does no per-record work at all, still loses to
|
|
282
|
+
`zrecord-raw` on gather.
|
|
283
|
+
|
|
284
|
+
**Compression is in the kernel, and there is one codec.** `zrecord-zstd` is not
|
|
285
|
+
"storage plus a codec": the layout, the multi-core decompress and the GIL-free
|
|
286
|
+
copy are one path, so turning compression on costs part of a margin, not an
|
|
287
|
+
order of magnitude — ~8.7 GiB/s here, roughly two orders of magnitude ahead of
|
|
288
|
+
the other compressed stores (87–40 MiB/s) at the same ~1.1x ratio. The modest
|
|
289
|
+
ratio is the data, not the format: these samples are barely compressible, and on
|
|
290
|
+
a smooth image set plain zstd reaches 7.6x and the dictionary 16.5x.
|
|
291
|
+
|
|
292
|
+
**The loader gap is architecture, not storage.** loaderx uses threads and never
|
|
293
|
+
ends an epoch, so a step pays no IPC and never waits on an epoch boundary; torch
|
|
294
|
+
and Grain restart per epoch with worker processes. That is most of the 5–65x
|
|
295
|
+
end to end. Storage also differs per loader — each reads from what it was built
|
|
296
|
+
for — so the loader table is a different comparison than the store table, not a
|
|
297
|
+
rerun of it.
|
|
298
|
+
|
|
299
|
+
**What these numbers do not claim.** Everything runs with a warm page cache: this
|
|
300
|
+
measures the access path, not cold storage or disk. `on disk` is allocated
|
|
301
|
+
blocks, so zrecord's sparse preallocation is not charged to it while
|
|
302
|
+
TensorStore's sharding is; both are layout, not compression. Writing is a
|
|
303
|
+
one-time cost zrecord does not chase. The box is shared — the µs-scale sampler
|
|
304
|
+
timings and the loader `batches/s` fluctuate (with 16 physical cores the
|
|
305
|
+
compressed loader trails the raw one by ~17%; on a 24-core box they measured
|
|
306
|
+
within 2%), which is why the numbers above are medians of several runs.
|
|
271
307
|
|
|
272
308
|
## Current Limitations
|
|
273
309
|
* Single-host only; multi-host training is not supported.
|
|
@@ -346,8 +382,11 @@ A record-based data runtime, focused on delivering extreme throughput and low la
|
|
|
346
382
|
multi-stream dataset is purely a client-side notion; the client keeps its
|
|
347
383
|
own stream index table pointing at records.
|
|
348
384
|
3. Zrecord returns byte arrays. Type interpretation is the client's job.
|
|
349
|
-
4.
|
|
350
|
-
|
|
385
|
+
4. The IO model (`append | read | delete`) is batch-oriented and
|
|
386
|
+
shape-agnostic. A record is a packed byte range located by an offset; there
|
|
387
|
+
is no notion of fixed vs variable length, and a single-record operation is
|
|
388
|
+
just the `batch_size == 1` case. Equal length and single records are
|
|
389
|
+
special cases, not parallel code paths.
|
|
351
390
|
5. Zrecord owns its memory internally — allocation and release are explicit.
|
|
352
391
|
6. Each record is compressed and decompressed independently:
|
|
353
392
|
|