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.
Files changed (26) hide show
  1. {loaderx-0.5.0/loaderx.egg-info → loaderx-0.5.4}/PKG-INFO +95 -12
  2. {loaderx-0.5.0 → loaderx-0.5.4}/README.md +93 -10
  3. {loaderx-0.5.0 → loaderx-0.5.4}/build.zig +9 -19
  4. {loaderx-0.5.0 → loaderx-0.5.4}/loaderx/__init__.py +1 -1
  5. {loaderx-0.5.0 → loaderx-0.5.4}/loaderx/_sampler.py +1 -43
  6. {loaderx-0.5.0 → loaderx-0.5.4}/loaderx/dataloader.py +24 -4
  7. {loaderx-0.5.0 → loaderx-0.5.4/loaderx.egg-info}/PKG-INFO +95 -12
  8. {loaderx-0.5.0 → loaderx-0.5.4}/loaderx.egg-info/SOURCES.txt +1 -1
  9. {loaderx-0.5.0 → loaderx-0.5.4}/pyproject.toml +1 -1
  10. loaderx-0.5.4/scripts/bench.py +919 -0
  11. {loaderx-0.5.0 → loaderx-0.5.4}/src/zrecord.zig +0 -7
  12. loaderx-0.5.0/src/bench.zig +0 -123
  13. {loaderx-0.5.0 → loaderx-0.5.4}/LICENSE +0 -0
  14. {loaderx-0.5.0 → loaderx-0.5.4}/MANIFEST.in +0 -0
  15. {loaderx-0.5.0 → loaderx-0.5.4}/build.zig.zon +0 -0
  16. {loaderx-0.5.0 → loaderx-0.5.4}/loaderx/_lib.py +0 -0
  17. {loaderx-0.5.0 → loaderx-0.5.4}/loaderx/_zrecord.py +0 -0
  18. {loaderx-0.5.0 → loaderx-0.5.4}/loaderx/dataset.py +0 -0
  19. {loaderx-0.5.0 → loaderx-0.5.4}/loaderx.egg-info/dependency_links.txt +0 -0
  20. {loaderx-0.5.0 → loaderx-0.5.4}/loaderx.egg-info/requires.txt +1 -1
  21. {loaderx-0.5.0 → loaderx-0.5.4}/loaderx.egg-info/top_level.txt +0 -0
  22. {loaderx-0.5.0 → loaderx-0.5.4}/scripts/build_wheels.py +0 -0
  23. {loaderx-0.5.0 → loaderx-0.5.4}/setup.cfg +0 -0
  24. {loaderx-0.5.0 → loaderx-0.5.4}/setup.py +0 -0
  25. {loaderx-0.5.0 → loaderx-0.5.4}/src/zsampler.zig +0 -0
  26. {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.0
4
- Summary: A record-based data runtime, focused on delivering extreme throughput and low latency
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
- Two things about the shared objects are deliberate rather than incidental:
136
-
137
- * **libc is linked.** They are `dlopen`ed into CPython, and without libc they
138
- export an unresolved `__tls_get_addr`, so Zig's thread-locals never bind and
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
- Two things about the shared objects are deliberate rather than incidental:
90
-
91
- * **libc is linked.** They are `dlopen`ed into CPython, and without libc they
92
- export an unresolved `__tls_get_addr`, so Zig's thread-locals never bind and
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
- // ---- host-only development tools ----
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
- const host = b.graph.host;
95
-
96
- const bench = b.addExecutable(.{
97
- .name = "bench",
98
- .root_module = b.createModule(.{
99
- .root_source_file = b.path("src/bench.zig"),
100
- .target = host,
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
  }),
@@ -2,4 +2,4 @@ from ._zrecord import Zrecord, ZrecordError
2
2
  from .dataset import Dataset
3
3
  from .dataloader import DataLoader
4
4
 
5
- __version__ = "0.5.0"
5
+ __version__ = "0.5.4"
@@ -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.indices.put(self.sampler.next())
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
- idxs = self.indices.get()
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.put({'data': data, 'label': label})
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.0
4
- Summary: A record-based data runtime, focused on delivering extreme throughput and low latency
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
- Two things about the shared objects are deliberate rather than incidental:
136
-
137
- * **libc is linked.** They are `dlopen`ed into CPython, and without libc they
138
- export an unresolved `__tls_get_addr`, so Zig's thread-locals never bind and
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-based data runtime, focused on delivering extreme throughput and low latency"
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.