loaderx 0.5.0__tar.gz → 0.5.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.5.0/loaderx.egg-info → loaderx-0.5.4}/PKG-INFO +95 -12
- {loaderx-0.5.0 → loaderx-0.5.4}/README.md +93 -10
- {loaderx-0.5.0 → loaderx-0.5.4}/build.zig +9 -19
- {loaderx-0.5.0 → loaderx-0.5.4}/loaderx/__init__.py +1 -1
- {loaderx-0.5.0 → loaderx-0.5.4}/loaderx/_sampler.py +1 -43
- {loaderx-0.5.0 → loaderx-0.5.4}/loaderx/dataloader.py +24 -4
- {loaderx-0.5.0 → loaderx-0.5.4/loaderx.egg-info}/PKG-INFO +95 -12
- {loaderx-0.5.0 → loaderx-0.5.4}/loaderx.egg-info/SOURCES.txt +1 -1
- {loaderx-0.5.0 → loaderx-0.5.4}/pyproject.toml +1 -1
- loaderx-0.5.4/scripts/bench.py +919 -0
- {loaderx-0.5.0 → loaderx-0.5.4}/src/zrecord.zig +0 -7
- loaderx-0.5.0/src/bench.zig +0 -123
- {loaderx-0.5.0 → loaderx-0.5.4}/LICENSE +0 -0
- {loaderx-0.5.0 → loaderx-0.5.4}/MANIFEST.in +0 -0
- {loaderx-0.5.0 → loaderx-0.5.4}/build.zig.zon +0 -0
- {loaderx-0.5.0 → loaderx-0.5.4}/loaderx/_lib.py +0 -0
- {loaderx-0.5.0 → loaderx-0.5.4}/loaderx/_zrecord.py +0 -0
- {loaderx-0.5.0 → loaderx-0.5.4}/loaderx/dataset.py +0 -0
- {loaderx-0.5.0 → loaderx-0.5.4}/loaderx.egg-info/dependency_links.txt +0 -0
- {loaderx-0.5.0 → loaderx-0.5.4}/loaderx.egg-info/requires.txt +1 -1
- {loaderx-0.5.0 → loaderx-0.5.4}/loaderx.egg-info/top_level.txt +0 -0
- {loaderx-0.5.0 → loaderx-0.5.4}/scripts/build_wheels.py +0 -0
- {loaderx-0.5.0 → loaderx-0.5.4}/setup.cfg +0 -0
- {loaderx-0.5.0 → loaderx-0.5.4}/setup.py +0 -0
- {loaderx-0.5.0 → loaderx-0.5.4}/src/zsampler.zig +0 -0
- {loaderx-0.5.0 → loaderx-0.5.4}/tests/test_loaderx.py +0 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: loaderx
|
|
3
|
-
Version: 0.5.
|
|
4
|
-
Summary: A record-
|
|
3
|
+
Version: 0.5.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
|
|
7
7
|
|
|
@@ -45,7 +45,7 @@ Requires-Dist: cffi
|
|
|
45
45
|
Dynamic: license-file
|
|
46
46
|
|
|
47
47
|
# Loaderx
|
|
48
|
-
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
|
|
48
|
+
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
|
|
49
49
|
|
|
50
50
|
```
|
|
51
51
|
pip install loaderx
|
|
@@ -115,6 +115,93 @@ run them while a `DataLoader` is iterating the dataset.
|
|
|
115
115
|
|
|
116
116
|
For practical integration examples, please refer to the **[Data2Latent](https://codeberg.org/eoelab/Data2Latent)** repository
|
|
117
117
|
|
|
118
|
+
## Benchmarks
|
|
119
|
+
|
|
120
|
+
`scripts/bench.py` measures loaderx against the alternatives in three layers —
|
|
121
|
+
the index sampler, the record store, and the full data loader — each through the
|
|
122
|
+
binding a client actually uses, so cffi, the GIL and the NumPy allocation are all
|
|
123
|
+
inside the timings. The numbers below are one run on a 24-core machine, Python
|
|
124
|
+
3.14, warm page cache. Reproduce with `python3 scripts/bench.py all`.
|
|
125
|
+
|
|
126
|
+
**1. Sampler** — index generation on its own, IID (with replacement), 1M index
|
|
127
|
+
space, against both NumPy random APIs.
|
|
128
|
+
|
|
129
|
+
| sampler | batch | per batch | indices | vs default_rng |
|
|
130
|
+
|-------------------|-------|-----------|-----------|----------------|
|
|
131
|
+
| numpy randint | 256 | 5.9 µs | 44 M/s | 0.64x |
|
|
132
|
+
| numpy default_rng | 256 | 3.7 µs | 69 M/s | 1.00x |
|
|
133
|
+
| **zsampler** | 256 | 1.9 µs | 138 M/s | **2.01x** |
|
|
134
|
+
| numpy randint | 1024 | 7.2 µs | 142 M/s | 0.83x |
|
|
135
|
+
| numpy default_rng | 1024 | 6.0 µs | 171 M/s | 1.00x |
|
|
136
|
+
| **zsampler** | 1024 | 3.0 µs | 340 M/s | **1.99x** |
|
|
137
|
+
| numpy randint | 8192 | 25.6 µs | 320 M/s | 0.65x |
|
|
138
|
+
| numpy default_rng | 8192 | 16.6 µs | 493 M/s | 1.00x |
|
|
139
|
+
| **zsampler** | 8192 | 7.8 µs | 1048 M/s | **2.13x** |
|
|
140
|
+
|
|
141
|
+
Zsampler draws indices about 2x faster than NumPy's faster API — more against the
|
|
142
|
+
legacy `randint` most code still calls — and the lead is widest at the small
|
|
143
|
+
batches a loader actually draws, where a batch is one cffi call either way and
|
|
144
|
+
NumPy's vectorised fill has nothing to amortise. The one concession: zsampler's
|
|
145
|
+
IID draw is modulo-biased where NumPy's is not, ~1e-13 over a u64 draw into a
|
|
146
|
+
million-record space, which is below anything training will notice.
|
|
147
|
+
|
|
148
|
+
**2. Store** — random batch gather, 20000 records × 12 KiB `uint8(3,64,64)`,
|
|
149
|
+
batch 256 × 200, structured (mildly compressible) data.
|
|
150
|
+
|
|
151
|
+
| store | write | gather | per batch | on disk | ratio |
|
|
152
|
+
|---------------|------------|-------------|------------|---------|-------|
|
|
153
|
+
| loaderx-flate | 362 MiB/s | 1671 MiB/s | 1.80 ms | 209 MiB | 1.12x |
|
|
154
|
+
| loaderx-raw | 784 MiB/s | 21997 MiB/s | 0.14 ms | 235 MiB | 1.00x |
|
|
155
|
+
| npy-mmap | 3064 MiB/s | 13692 MiB/s | 0.22 ms | 234 MiB | 1.00x |
|
|
156
|
+
| arrayrecord | 374 MiB/s | 847 MiB/s | 3.54 ms | 217 MiB | 1.08x |
|
|
157
|
+
| hdf5 | 1327 MiB/s | 511 MiB/s | 5.87 ms | 236 MiB | 0.99x |
|
|
158
|
+
| hdf5-gzip | 83 MiB/s | 206 MiB/s | 14.57 ms | 211 MiB | 1.11x |
|
|
159
|
+
| lmdb | 677 MiB/s | 5337 MiB/s | 0.56 ms | 313 MiB | 0.75x |
|
|
160
|
+
| lmdb-zlib | 71 MiB/s | 336 MiB/s | 8.93 ms | 235 MiB | 1.00x |
|
|
161
|
+
| blosc2 | 32 MiB/s | 226 MiB/s | 13.27 ms | 216 MiB | 1.09x |
|
|
162
|
+
| zarr | 83 MiB/s | 28 MiB/s | 107.36 ms | 217 MiB | 1.08x |
|
|
163
|
+
| tensorstore | 531 MiB/s | 98 MiB/s | 30.69 ms | 214 MiB | 1.09x |
|
|
164
|
+
|
|
165
|
+
The gather column is what the store is built for. loaderx-raw returns a scattered
|
|
166
|
+
batch faster than everything here, a memory-mapped `.npy` included, because it
|
|
167
|
+
copies straight into the destination across all cores with the GIL released;
|
|
168
|
+
loaderx-flate keeps most of that speed *while* decompressing, and still gathers
|
|
169
|
+
several times faster than any other compressed store (hdf5-gzip, lmdb-zlib,
|
|
170
|
+
blosc2, zarr, tensorstore). The array stores lag on gather because a scattered
|
|
171
|
+
single-record read fights a layout meant for contiguous slabs — which is exactly
|
|
172
|
+
the access pattern a training loader has. Where loaderx does not lead is write: it
|
|
173
|
+
pays compression and its own layout up front, a one-time cost it does not chase.
|
|
174
|
+
Compression is modest (1.12x) only because this data is barely compressible; the
|
|
175
|
+
row's point is that turning it on costs almost nothing on read.
|
|
176
|
+
|
|
177
|
+
The reads are not all single-threaded: loaderx and blosc2 decompress across cores,
|
|
178
|
+
the rest one record at a time. Zarr and TensorStore are sharded so `on disk`
|
|
179
|
+
reflects compression rather than per-file block rounding, and TensorStore is given
|
|
180
|
+
gzip (its zarr3 default is uncompressed). `on disk` is allocated blocks, which is
|
|
181
|
+
why LMDB's preallocated map lands below 1.00x.
|
|
182
|
+
|
|
183
|
+
**3. Loader** — the full input pipeline end to end (sample, fetch, collate, hand
|
|
184
|
+
over a batch), same workload, 4 workers. Each loader reads from what it is built
|
|
185
|
+
for: loaderx from Zrecord, Grain from ArrayRecord, torch from a memory-mapped
|
|
186
|
+
`.npy`, jax-dataloader from an in-memory array.
|
|
187
|
+
|
|
188
|
+
| loader | batches/s | per batch | samples/s | throughput |
|
|
189
|
+
|----------------|-----------|-----------|-----------|-------------|
|
|
190
|
+
| **loaderx** | 724.2 | 1.38 ms | 185,406 | 2173 MiB/s |
|
|
191
|
+
| loaderx-raw | 5271.0 | 0.19 ms | 1,349,378 | 15813 MiB/s |
|
|
192
|
+
| grain | 67.3 | 14.85 ms | 17,240 | 202 MiB/s |
|
|
193
|
+
| torch | 832.2 | 1.20 ms | 213,038 | 2497 MiB/s |
|
|
194
|
+
| jax-dataloader | 3284.6 | 0.30 ms | 840,864 | 9854 MiB/s |
|
|
195
|
+
|
|
196
|
+
End to end, loaderx-raw serves batches several times faster than torch or Grain
|
|
197
|
+
and ahead of jax-dataloader — and it does so from disk-backed storage, where
|
|
198
|
+
jax-dataloader's number is an in-memory ceiling with no storage path at all. The
|
|
199
|
+
default loaderx trades some of that for on-disk compression and still lands in
|
|
200
|
+
torch's range while reading compressed records. loaderx and jax use threads;
|
|
201
|
+
torch and Grain use worker processes that escape the GIL but pay IPC per batch.
|
|
202
|
+
loaderx never ends an epoch, so no step waits on a boundary; the others restart
|
|
203
|
+
per epoch, a cost that is in their numbers as it would be in training.
|
|
204
|
+
|
|
118
205
|
## Current Limitations
|
|
119
206
|
* Single-host only; multi-host training is not supported.
|
|
120
207
|
* A single sample must be at most 1 MiB, and a store holds at most 2^24
|
|
@@ -128,18 +215,14 @@ For practical integration examples, please refer to the **[Data2Latent](https://
|
|
|
128
215
|
```
|
|
129
216
|
zig build # host shared objects, into loaderx/lib/
|
|
130
217
|
zig build test # Zrecord suite, in both Debug and ReleaseFast
|
|
131
|
-
zig build bench # append/gather throughput
|
|
132
218
|
python3 tests/test_loaderx.py # Python layer, against whichever build is importable
|
|
219
|
+
python3 scripts/bench.py # throughput, against other stores
|
|
133
220
|
```
|
|
134
221
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
`std.Thread` deadlocks on the first batch.
|
|
140
|
-
* **Tests run in ReleaseFast too.** The shipped libraries are ReleaseFast, and a
|
|
141
|
-
codegen difference there has already produced a release-blocking bug that
|
|
142
|
-
Debug did not have. Test the configuration that ships.
|
|
222
|
+
The Zig side is tested for behaviour only; throughput is measured from Python,
|
|
223
|
+
through the binding a client actually uses. `scripts/bench.py` runs in three
|
|
224
|
+
layers (`sampler`, `store`, `loader`, or `all`) and skips contenders that are not
|
|
225
|
+
installed. See [Benchmarks](#benchmarks).
|
|
143
226
|
|
|
144
227
|
### Publishing
|
|
145
228
|
Zig cross-compiles every target from one machine, so releases need no CI matrix:
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
# Loaderx
|
|
2
|
-
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
|
|
2
|
+
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
|
|
3
3
|
|
|
4
4
|
```
|
|
5
5
|
pip install loaderx
|
|
@@ -69,6 +69,93 @@ run them while a `DataLoader` is iterating the dataset.
|
|
|
69
69
|
|
|
70
70
|
For practical integration examples, please refer to the **[Data2Latent](https://codeberg.org/eoelab/Data2Latent)** repository
|
|
71
71
|
|
|
72
|
+
## Benchmarks
|
|
73
|
+
|
|
74
|
+
`scripts/bench.py` measures loaderx against the alternatives in three layers —
|
|
75
|
+
the index sampler, the record store, and the full data loader — each through the
|
|
76
|
+
binding a client actually uses, so cffi, the GIL and the NumPy allocation are all
|
|
77
|
+
inside the timings. The numbers below are one run on a 24-core machine, Python
|
|
78
|
+
3.14, warm page cache. Reproduce with `python3 scripts/bench.py all`.
|
|
79
|
+
|
|
80
|
+
**1. Sampler** — index generation on its own, IID (with replacement), 1M index
|
|
81
|
+
space, against both NumPy random APIs.
|
|
82
|
+
|
|
83
|
+
| sampler | batch | per batch | indices | vs default_rng |
|
|
84
|
+
|-------------------|-------|-----------|-----------|----------------|
|
|
85
|
+
| numpy randint | 256 | 5.9 µs | 44 M/s | 0.64x |
|
|
86
|
+
| numpy default_rng | 256 | 3.7 µs | 69 M/s | 1.00x |
|
|
87
|
+
| **zsampler** | 256 | 1.9 µs | 138 M/s | **2.01x** |
|
|
88
|
+
| numpy randint | 1024 | 7.2 µs | 142 M/s | 0.83x |
|
|
89
|
+
| numpy default_rng | 1024 | 6.0 µs | 171 M/s | 1.00x |
|
|
90
|
+
| **zsampler** | 1024 | 3.0 µs | 340 M/s | **1.99x** |
|
|
91
|
+
| numpy randint | 8192 | 25.6 µs | 320 M/s | 0.65x |
|
|
92
|
+
| numpy default_rng | 8192 | 16.6 µs | 493 M/s | 1.00x |
|
|
93
|
+
| **zsampler** | 8192 | 7.8 µs | 1048 M/s | **2.13x** |
|
|
94
|
+
|
|
95
|
+
Zsampler draws indices about 2x faster than NumPy's faster API — more against the
|
|
96
|
+
legacy `randint` most code still calls — and the lead is widest at the small
|
|
97
|
+
batches a loader actually draws, where a batch is one cffi call either way and
|
|
98
|
+
NumPy's vectorised fill has nothing to amortise. The one concession: zsampler's
|
|
99
|
+
IID draw is modulo-biased where NumPy's is not, ~1e-13 over a u64 draw into a
|
|
100
|
+
million-record space, which is below anything training will notice.
|
|
101
|
+
|
|
102
|
+
**2. Store** — random batch gather, 20000 records × 12 KiB `uint8(3,64,64)`,
|
|
103
|
+
batch 256 × 200, structured (mildly compressible) data.
|
|
104
|
+
|
|
105
|
+
| store | write | gather | per batch | on disk | ratio |
|
|
106
|
+
|---------------|------------|-------------|------------|---------|-------|
|
|
107
|
+
| loaderx-flate | 362 MiB/s | 1671 MiB/s | 1.80 ms | 209 MiB | 1.12x |
|
|
108
|
+
| loaderx-raw | 784 MiB/s | 21997 MiB/s | 0.14 ms | 235 MiB | 1.00x |
|
|
109
|
+
| npy-mmap | 3064 MiB/s | 13692 MiB/s | 0.22 ms | 234 MiB | 1.00x |
|
|
110
|
+
| arrayrecord | 374 MiB/s | 847 MiB/s | 3.54 ms | 217 MiB | 1.08x |
|
|
111
|
+
| hdf5 | 1327 MiB/s | 511 MiB/s | 5.87 ms | 236 MiB | 0.99x |
|
|
112
|
+
| hdf5-gzip | 83 MiB/s | 206 MiB/s | 14.57 ms | 211 MiB | 1.11x |
|
|
113
|
+
| lmdb | 677 MiB/s | 5337 MiB/s | 0.56 ms | 313 MiB | 0.75x |
|
|
114
|
+
| lmdb-zlib | 71 MiB/s | 336 MiB/s | 8.93 ms | 235 MiB | 1.00x |
|
|
115
|
+
| blosc2 | 32 MiB/s | 226 MiB/s | 13.27 ms | 216 MiB | 1.09x |
|
|
116
|
+
| zarr | 83 MiB/s | 28 MiB/s | 107.36 ms | 217 MiB | 1.08x |
|
|
117
|
+
| tensorstore | 531 MiB/s | 98 MiB/s | 30.69 ms | 214 MiB | 1.09x |
|
|
118
|
+
|
|
119
|
+
The gather column is what the store is built for. loaderx-raw returns a scattered
|
|
120
|
+
batch faster than everything here, a memory-mapped `.npy` included, because it
|
|
121
|
+
copies straight into the destination across all cores with the GIL released;
|
|
122
|
+
loaderx-flate keeps most of that speed *while* decompressing, and still gathers
|
|
123
|
+
several times faster than any other compressed store (hdf5-gzip, lmdb-zlib,
|
|
124
|
+
blosc2, zarr, tensorstore). The array stores lag on gather because a scattered
|
|
125
|
+
single-record read fights a layout meant for contiguous slabs — which is exactly
|
|
126
|
+
the access pattern a training loader has. Where loaderx does not lead is write: it
|
|
127
|
+
pays compression and its own layout up front, a one-time cost it does not chase.
|
|
128
|
+
Compression is modest (1.12x) only because this data is barely compressible; the
|
|
129
|
+
row's point is that turning it on costs almost nothing on read.
|
|
130
|
+
|
|
131
|
+
The reads are not all single-threaded: loaderx and blosc2 decompress across cores,
|
|
132
|
+
the rest one record at a time. Zarr and TensorStore are sharded so `on disk`
|
|
133
|
+
reflects compression rather than per-file block rounding, and TensorStore is given
|
|
134
|
+
gzip (its zarr3 default is uncompressed). `on disk` is allocated blocks, which is
|
|
135
|
+
why LMDB's preallocated map lands below 1.00x.
|
|
136
|
+
|
|
137
|
+
**3. Loader** — the full input pipeline end to end (sample, fetch, collate, hand
|
|
138
|
+
over a batch), same workload, 4 workers. Each loader reads from what it is built
|
|
139
|
+
for: loaderx from Zrecord, Grain from ArrayRecord, torch from a memory-mapped
|
|
140
|
+
`.npy`, jax-dataloader from an in-memory array.
|
|
141
|
+
|
|
142
|
+
| loader | batches/s | per batch | samples/s | throughput |
|
|
143
|
+
|----------------|-----------|-----------|-----------|-------------|
|
|
144
|
+
| **loaderx** | 724.2 | 1.38 ms | 185,406 | 2173 MiB/s |
|
|
145
|
+
| loaderx-raw | 5271.0 | 0.19 ms | 1,349,378 | 15813 MiB/s |
|
|
146
|
+
| grain | 67.3 | 14.85 ms | 17,240 | 202 MiB/s |
|
|
147
|
+
| torch | 832.2 | 1.20 ms | 213,038 | 2497 MiB/s |
|
|
148
|
+
| jax-dataloader | 3284.6 | 0.30 ms | 840,864 | 9854 MiB/s |
|
|
149
|
+
|
|
150
|
+
End to end, loaderx-raw serves batches several times faster than torch or Grain
|
|
151
|
+
and ahead of jax-dataloader — and it does so from disk-backed storage, where
|
|
152
|
+
jax-dataloader's number is an in-memory ceiling with no storage path at all. The
|
|
153
|
+
default loaderx trades some of that for on-disk compression and still lands in
|
|
154
|
+
torch's range while reading compressed records. loaderx and jax use threads;
|
|
155
|
+
torch and Grain use worker processes that escape the GIL but pay IPC per batch.
|
|
156
|
+
loaderx never ends an epoch, so no step waits on a boundary; the others restart
|
|
157
|
+
per epoch, a cost that is in their numbers as it would be in training.
|
|
158
|
+
|
|
72
159
|
## Current Limitations
|
|
73
160
|
* Single-host only; multi-host training is not supported.
|
|
74
161
|
* A single sample must be at most 1 MiB, and a store holds at most 2^24
|
|
@@ -82,18 +169,14 @@ For practical integration examples, please refer to the **[Data2Latent](https://
|
|
|
82
169
|
```
|
|
83
170
|
zig build # host shared objects, into loaderx/lib/
|
|
84
171
|
zig build test # Zrecord suite, in both Debug and ReleaseFast
|
|
85
|
-
zig build bench # append/gather throughput
|
|
86
172
|
python3 tests/test_loaderx.py # Python layer, against whichever build is importable
|
|
173
|
+
python3 scripts/bench.py # throughput, against other stores
|
|
87
174
|
```
|
|
88
175
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
`std.Thread` deadlocks on the first batch.
|
|
94
|
-
* **Tests run in ReleaseFast too.** The shipped libraries are ReleaseFast, and a
|
|
95
|
-
codegen difference there has already produced a release-blocking bug that
|
|
96
|
-
Debug did not have. Test the configuration that ships.
|
|
176
|
+
The Zig side is tested for behaviour only; throughput is measured from Python,
|
|
177
|
+
through the binding a client actually uses. `scripts/bench.py` runs in three
|
|
178
|
+
layers (`sampler`, `store`, `loader`, or `all`) and skips contenders that are not
|
|
179
|
+
installed. See [Benchmarks](#benchmarks).
|
|
97
180
|
|
|
98
181
|
### Publishing
|
|
99
182
|
Zig cross-compiles every target from one machine, so releases need no CI matrix:
|
|
@@ -86,34 +86,24 @@ pub fn build(b: *std.Build) void {
|
|
|
86
86
|
}
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
-
// ----
|
|
89
|
+
// ---- tests ----
|
|
90
90
|
//
|
|
91
91
|
// Pinned to the host rather than `target`: a run step for a foreign binary
|
|
92
92
|
// cannot be configured on a machine that has no emulator for it, which
|
|
93
93
|
// would break `zig build -Dtarget=...` outright.
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
.optimize = optimize,
|
|
102
|
-
.link_libc = true,
|
|
103
|
-
}),
|
|
104
|
-
});
|
|
105
|
-
b.step("bench", "Benchmark zrecord append/gather throughput")
|
|
106
|
-
.dependOn(&b.addRunArtifact(bench).step);
|
|
107
|
-
|
|
108
|
-
// Run the suite at both ends of the optimizer. Debug alone is not enough:
|
|
109
|
-
// codegen differences around packed-struct stores have already produced a
|
|
110
|
-
// bug that only existed in the shipped ReleaseFast library.
|
|
94
|
+
//
|
|
95
|
+
// Run at both ends of the optimizer. Debug alone is not enough: codegen
|
|
96
|
+
// differences have already produced a bug that only existed in the shipped
|
|
97
|
+
// ReleaseFast library.
|
|
98
|
+
//
|
|
99
|
+
// Throughput is not measured here — it is measured through the binding that
|
|
100
|
+
// clients actually use, by `scripts/bench.py`.
|
|
111
101
|
const test_step = b.step("test", "Run zrecord tests");
|
|
112
102
|
for ([_]std.builtin.OptimizeMode{ .Debug, .ReleaseFast }) |mode| {
|
|
113
103
|
const tests = b.addTest(.{
|
|
114
104
|
.root_module = b.createModule(.{
|
|
115
105
|
.root_source_file = b.path("src/zrecord.zig"),
|
|
116
|
-
.target = host,
|
|
106
|
+
.target = b.graph.host,
|
|
117
107
|
.optimize = mode,
|
|
118
108
|
.link_libc = true,
|
|
119
109
|
}),
|
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
import time
|
|
2
|
-
|
|
3
1
|
import numpy as np
|
|
4
2
|
from cffi import FFI
|
|
5
3
|
|
|
@@ -54,44 +52,4 @@ class Sampler:
|
|
|
54
52
|
def __del__(self):
|
|
55
53
|
if hasattr(self, "sampler") and self.sampler != ffi.NULL:
|
|
56
54
|
lib.cdeinit(self.sampler)
|
|
57
|
-
self.sampler = ffi.NULL
|
|
58
|
-
|
|
59
|
-
def run():
|
|
60
|
-
from itertools import islice
|
|
61
|
-
length = 10
|
|
62
|
-
batch_size = 4
|
|
63
|
-
n_steps = 5
|
|
64
|
-
|
|
65
|
-
print("=== Sampler SEQUENTIAL ===")
|
|
66
|
-
sampler = Sampler(length, batch_size, Sampler.Mode.SEQUENTIAL)
|
|
67
|
-
for indices in islice(sampler, n_steps):
|
|
68
|
-
print(indices)
|
|
69
|
-
|
|
70
|
-
print("=== Sampler IID ===")
|
|
71
|
-
sampler = Sampler(length, batch_size, Sampler.Mode.IID)
|
|
72
|
-
for indices in islice(sampler, n_steps):
|
|
73
|
-
print(indices)
|
|
74
|
-
|
|
75
|
-
# Benchmark, zig-sampler speeder 3.66x
|
|
76
|
-
def bench():
|
|
77
|
-
length = 1_000_000
|
|
78
|
-
batch_size = 8192
|
|
79
|
-
n_steps = 10_000
|
|
80
|
-
|
|
81
|
-
print("=== NumPy IID ===")
|
|
82
|
-
t0 = time.time()
|
|
83
|
-
batch = np.zeros(batch_size, dtype=np.uint64)
|
|
84
|
-
for _ in range(n_steps):
|
|
85
|
-
batch[:] = np.random.randint(0, length, size=batch_size, dtype=np.uint64)
|
|
86
|
-
print(f"{(time.time() - t0)*1000:.2f} ms")
|
|
87
|
-
|
|
88
|
-
print("=== Sampler IID ===")
|
|
89
|
-
t0 = time.time()
|
|
90
|
-
sampler = Sampler(length, batch_size, Sampler.Mode.IID)
|
|
91
|
-
for _ in range(n_steps):
|
|
92
|
-
sampler.next()
|
|
93
|
-
print(f"{(time.time() - t0)*1000:.2f} ms")
|
|
94
|
-
|
|
95
|
-
if __name__ == "__main__":
|
|
96
|
-
run()
|
|
97
|
-
bench()
|
|
55
|
+
self.sampler = ffi.NULL
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import threading
|
|
2
2
|
import numpy as np
|
|
3
|
-
from queue import Queue
|
|
3
|
+
from queue import Queue, Empty, Full
|
|
4
4
|
|
|
5
5
|
from ._sampler import Sampler
|
|
6
6
|
|
|
@@ -42,12 +42,29 @@ class DataLoader:
|
|
|
42
42
|
thread.daemon = True
|
|
43
43
|
thread.start()
|
|
44
44
|
|
|
45
|
+
# Every blocking queue op polls stop_signal on a timeout instead of waiting
|
|
46
|
+
# forever. A plain get()/put() can outlive close(): once the sampler stops,
|
|
47
|
+
# a prefetch worker blocked on an empty index queue — or on a full batch
|
|
48
|
+
# queue with no consumer — never wakes, and join() hangs. Benchmarks build
|
|
49
|
+
# and tear down loaders in a loop, so this path is always hit.
|
|
50
|
+
_POLL = 0.1
|
|
51
|
+
|
|
52
|
+
def _put(self, queue, item):
|
|
53
|
+
"""Put `item`, returning False if close() fired before it landed."""
|
|
54
|
+
while not self.stop_signal.is_set():
|
|
55
|
+
try:
|
|
56
|
+
queue.put(item, timeout=self._POLL)
|
|
57
|
+
return True
|
|
58
|
+
except Full:
|
|
59
|
+
continue
|
|
60
|
+
return False
|
|
61
|
+
|
|
45
62
|
def _sampler(self):
|
|
46
63
|
"""
|
|
47
64
|
Sample indices from the dataset and put them into the index queue.
|
|
48
65
|
"""
|
|
49
66
|
while not self.stop_signal.is_set():
|
|
50
|
-
self.
|
|
67
|
+
self._put(self.indices, self.sampler.next())
|
|
51
68
|
|
|
52
69
|
def _prefetch(self, transform):
|
|
53
70
|
"""
|
|
@@ -55,9 +72,12 @@ class DataLoader:
|
|
|
55
72
|
in the index queue and put them into the batches queue.
|
|
56
73
|
"""
|
|
57
74
|
while not self.stop_signal.is_set():
|
|
58
|
-
|
|
75
|
+
try:
|
|
76
|
+
idxs = self.indices.get(timeout=self._POLL)
|
|
77
|
+
except Empty:
|
|
78
|
+
continue
|
|
59
79
|
data, label = transform((self.dataset.__getitems__(idxs), self.labelset.__getitems__(idxs)))
|
|
60
|
-
self.batches
|
|
80
|
+
self._put(self.batches, {'data': data, 'label': label})
|
|
61
81
|
|
|
62
82
|
def __len__(self):
|
|
63
83
|
"""
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: loaderx
|
|
3
|
-
Version: 0.5.
|
|
4
|
-
Summary: A record-
|
|
3
|
+
Version: 0.5.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
|
|
7
7
|
|
|
@@ -45,7 +45,7 @@ Requires-Dist: cffi
|
|
|
45
45
|
Dynamic: license-file
|
|
46
46
|
|
|
47
47
|
# Loaderx
|
|
48
|
-
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
|
|
48
|
+
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
|
|
49
49
|
|
|
50
50
|
```
|
|
51
51
|
pip install loaderx
|
|
@@ -115,6 +115,93 @@ run them while a `DataLoader` is iterating the dataset.
|
|
|
115
115
|
|
|
116
116
|
For practical integration examples, please refer to the **[Data2Latent](https://codeberg.org/eoelab/Data2Latent)** repository
|
|
117
117
|
|
|
118
|
+
## Benchmarks
|
|
119
|
+
|
|
120
|
+
`scripts/bench.py` measures loaderx against the alternatives in three layers —
|
|
121
|
+
the index sampler, the record store, and the full data loader — each through the
|
|
122
|
+
binding a client actually uses, so cffi, the GIL and the NumPy allocation are all
|
|
123
|
+
inside the timings. The numbers below are one run on a 24-core machine, Python
|
|
124
|
+
3.14, warm page cache. Reproduce with `python3 scripts/bench.py all`.
|
|
125
|
+
|
|
126
|
+
**1. Sampler** — index generation on its own, IID (with replacement), 1M index
|
|
127
|
+
space, against both NumPy random APIs.
|
|
128
|
+
|
|
129
|
+
| sampler | batch | per batch | indices | vs default_rng |
|
|
130
|
+
|-------------------|-------|-----------|-----------|----------------|
|
|
131
|
+
| numpy randint | 256 | 5.9 µs | 44 M/s | 0.64x |
|
|
132
|
+
| numpy default_rng | 256 | 3.7 µs | 69 M/s | 1.00x |
|
|
133
|
+
| **zsampler** | 256 | 1.9 µs | 138 M/s | **2.01x** |
|
|
134
|
+
| numpy randint | 1024 | 7.2 µs | 142 M/s | 0.83x |
|
|
135
|
+
| numpy default_rng | 1024 | 6.0 µs | 171 M/s | 1.00x |
|
|
136
|
+
| **zsampler** | 1024 | 3.0 µs | 340 M/s | **1.99x** |
|
|
137
|
+
| numpy randint | 8192 | 25.6 µs | 320 M/s | 0.65x |
|
|
138
|
+
| numpy default_rng | 8192 | 16.6 µs | 493 M/s | 1.00x |
|
|
139
|
+
| **zsampler** | 8192 | 7.8 µs | 1048 M/s | **2.13x** |
|
|
140
|
+
|
|
141
|
+
Zsampler draws indices about 2x faster than NumPy's faster API — more against the
|
|
142
|
+
legacy `randint` most code still calls — and the lead is widest at the small
|
|
143
|
+
batches a loader actually draws, where a batch is one cffi call either way and
|
|
144
|
+
NumPy's vectorised fill has nothing to amortise. The one concession: zsampler's
|
|
145
|
+
IID draw is modulo-biased where NumPy's is not, ~1e-13 over a u64 draw into a
|
|
146
|
+
million-record space, which is below anything training will notice.
|
|
147
|
+
|
|
148
|
+
**2. Store** — random batch gather, 20000 records × 12 KiB `uint8(3,64,64)`,
|
|
149
|
+
batch 256 × 200, structured (mildly compressible) data.
|
|
150
|
+
|
|
151
|
+
| store | write | gather | per batch | on disk | ratio |
|
|
152
|
+
|---------------|------------|-------------|------------|---------|-------|
|
|
153
|
+
| loaderx-flate | 362 MiB/s | 1671 MiB/s | 1.80 ms | 209 MiB | 1.12x |
|
|
154
|
+
| loaderx-raw | 784 MiB/s | 21997 MiB/s | 0.14 ms | 235 MiB | 1.00x |
|
|
155
|
+
| npy-mmap | 3064 MiB/s | 13692 MiB/s | 0.22 ms | 234 MiB | 1.00x |
|
|
156
|
+
| arrayrecord | 374 MiB/s | 847 MiB/s | 3.54 ms | 217 MiB | 1.08x |
|
|
157
|
+
| hdf5 | 1327 MiB/s | 511 MiB/s | 5.87 ms | 236 MiB | 0.99x |
|
|
158
|
+
| hdf5-gzip | 83 MiB/s | 206 MiB/s | 14.57 ms | 211 MiB | 1.11x |
|
|
159
|
+
| lmdb | 677 MiB/s | 5337 MiB/s | 0.56 ms | 313 MiB | 0.75x |
|
|
160
|
+
| lmdb-zlib | 71 MiB/s | 336 MiB/s | 8.93 ms | 235 MiB | 1.00x |
|
|
161
|
+
| blosc2 | 32 MiB/s | 226 MiB/s | 13.27 ms | 216 MiB | 1.09x |
|
|
162
|
+
| zarr | 83 MiB/s | 28 MiB/s | 107.36 ms | 217 MiB | 1.08x |
|
|
163
|
+
| tensorstore | 531 MiB/s | 98 MiB/s | 30.69 ms | 214 MiB | 1.09x |
|
|
164
|
+
|
|
165
|
+
The gather column is what the store is built for. loaderx-raw returns a scattered
|
|
166
|
+
batch faster than everything here, a memory-mapped `.npy` included, because it
|
|
167
|
+
copies straight into the destination across all cores with the GIL released;
|
|
168
|
+
loaderx-flate keeps most of that speed *while* decompressing, and still gathers
|
|
169
|
+
several times faster than any other compressed store (hdf5-gzip, lmdb-zlib,
|
|
170
|
+
blosc2, zarr, tensorstore). The array stores lag on gather because a scattered
|
|
171
|
+
single-record read fights a layout meant for contiguous slabs — which is exactly
|
|
172
|
+
the access pattern a training loader has. Where loaderx does not lead is write: it
|
|
173
|
+
pays compression and its own layout up front, a one-time cost it does not chase.
|
|
174
|
+
Compression is modest (1.12x) only because this data is barely compressible; the
|
|
175
|
+
row's point is that turning it on costs almost nothing on read.
|
|
176
|
+
|
|
177
|
+
The reads are not all single-threaded: loaderx and blosc2 decompress across cores,
|
|
178
|
+
the rest one record at a time. Zarr and TensorStore are sharded so `on disk`
|
|
179
|
+
reflects compression rather than per-file block rounding, and TensorStore is given
|
|
180
|
+
gzip (its zarr3 default is uncompressed). `on disk` is allocated blocks, which is
|
|
181
|
+
why LMDB's preallocated map lands below 1.00x.
|
|
182
|
+
|
|
183
|
+
**3. Loader** — the full input pipeline end to end (sample, fetch, collate, hand
|
|
184
|
+
over a batch), same workload, 4 workers. Each loader reads from what it is built
|
|
185
|
+
for: loaderx from Zrecord, Grain from ArrayRecord, torch from a memory-mapped
|
|
186
|
+
`.npy`, jax-dataloader from an in-memory array.
|
|
187
|
+
|
|
188
|
+
| loader | batches/s | per batch | samples/s | throughput |
|
|
189
|
+
|----------------|-----------|-----------|-----------|-------------|
|
|
190
|
+
| **loaderx** | 724.2 | 1.38 ms | 185,406 | 2173 MiB/s |
|
|
191
|
+
| loaderx-raw | 5271.0 | 0.19 ms | 1,349,378 | 15813 MiB/s |
|
|
192
|
+
| grain | 67.3 | 14.85 ms | 17,240 | 202 MiB/s |
|
|
193
|
+
| torch | 832.2 | 1.20 ms | 213,038 | 2497 MiB/s |
|
|
194
|
+
| jax-dataloader | 3284.6 | 0.30 ms | 840,864 | 9854 MiB/s |
|
|
195
|
+
|
|
196
|
+
End to end, loaderx-raw serves batches several times faster than torch or Grain
|
|
197
|
+
and ahead of jax-dataloader — and it does so from disk-backed storage, where
|
|
198
|
+
jax-dataloader's number is an in-memory ceiling with no storage path at all. The
|
|
199
|
+
default loaderx trades some of that for on-disk compression and still lands in
|
|
200
|
+
torch's range while reading compressed records. loaderx and jax use threads;
|
|
201
|
+
torch and Grain use worker processes that escape the GIL but pay IPC per batch.
|
|
202
|
+
loaderx never ends an epoch, so no step waits on a boundary; the others restart
|
|
203
|
+
per epoch, a cost that is in their numbers as it would be in training.
|
|
204
|
+
|
|
118
205
|
## Current Limitations
|
|
119
206
|
* Single-host only; multi-host training is not supported.
|
|
120
207
|
* A single sample must be at most 1 MiB, and a store holds at most 2^24
|
|
@@ -128,18 +215,14 @@ For practical integration examples, please refer to the **[Data2Latent](https://
|
|
|
128
215
|
```
|
|
129
216
|
zig build # host shared objects, into loaderx/lib/
|
|
130
217
|
zig build test # Zrecord suite, in both Debug and ReleaseFast
|
|
131
|
-
zig build bench # append/gather throughput
|
|
132
218
|
python3 tests/test_loaderx.py # Python layer, against whichever build is importable
|
|
219
|
+
python3 scripts/bench.py # throughput, against other stores
|
|
133
220
|
```
|
|
134
221
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
`std.Thread` deadlocks on the first batch.
|
|
140
|
-
* **Tests run in ReleaseFast too.** The shipped libraries are ReleaseFast, and a
|
|
141
|
-
codegen difference there has already produced a release-blocking bug that
|
|
142
|
-
Debug did not have. Test the configuration that ships.
|
|
222
|
+
The Zig side is tested for behaviour only; throughput is measured from Python,
|
|
223
|
+
through the binding a client actually uses. `scripts/bench.py` runs in three
|
|
224
|
+
layers (`sampler`, `store`, `loader`, or `all`) and skips contenders that are not
|
|
225
|
+
installed. See [Benchmarks](#benchmarks).
|
|
143
226
|
|
|
144
227
|
### Publishing
|
|
145
228
|
Zig cross-compiles every target from one machine, so releases need no CI matrix:
|
|
@@ -16,8 +16,8 @@ loaderx.egg-info/SOURCES.txt
|
|
|
16
16
|
loaderx.egg-info/dependency_links.txt
|
|
17
17
|
loaderx.egg-info/requires.txt
|
|
18
18
|
loaderx.egg-info/top_level.txt
|
|
19
|
+
scripts/bench.py
|
|
19
20
|
scripts/build_wheels.py
|
|
20
|
-
src/bench.zig
|
|
21
21
|
src/zrecord.zig
|
|
22
22
|
src/zsampler.zig
|
|
23
23
|
tests/test_loaderx.py
|
|
@@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta"
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "loaderx"
|
|
7
7
|
dynamic = ["version"]
|
|
8
|
-
description = "A record-
|
|
8
|
+
description = "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"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
# The bindings use cffi in ABI mode, so nothing links against the CPython ABI
|
|
11
11
|
# and the wheels are py3-none-<platform>. No version-specific constraint applies.
|