loaderx 0.3.0__tar.gz → 0.5.0__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 (33) hide show
  1. loaderx-0.5.0/MANIFEST.in +11 -0
  2. loaderx-0.5.0/PKG-INFO +334 -0
  3. loaderx-0.5.0/README.md +288 -0
  4. loaderx-0.5.0/build.zig +123 -0
  5. loaderx-0.5.0/build.zig.zon +10 -0
  6. loaderx-0.5.0/loaderx/__init__.py +5 -0
  7. loaderx-0.5.0/loaderx/_lib.py +45 -0
  8. {loaderx-0.3.0 → loaderx-0.5.0}/loaderx/_sampler.py +37 -9
  9. loaderx-0.5.0/loaderx/_zrecord.py +283 -0
  10. {loaderx-0.3.0 → loaderx-0.5.0}/loaderx/dataloader.py +1 -5
  11. loaderx-0.5.0/loaderx/dataset.py +222 -0
  12. loaderx-0.5.0/loaderx.egg-info/PKG-INFO +334 -0
  13. {loaderx-0.3.0 → loaderx-0.5.0}/loaderx.egg-info/SOURCES.txt +11 -2
  14. loaderx-0.5.0/loaderx.egg-info/requires.txt +2 -0
  15. {loaderx-0.3.0 → loaderx-0.5.0}/pyproject.toml +10 -3
  16. loaderx-0.5.0/scripts/build_wheels.py +159 -0
  17. loaderx-0.5.0/setup.py +67 -0
  18. loaderx-0.5.0/src/bench.zig +123 -0
  19. loaderx-0.5.0/src/zrecord.zig +1550 -0
  20. loaderx-0.5.0/src/zsampler.zig +94 -0
  21. loaderx-0.5.0/tests/test_loaderx.py +301 -0
  22. loaderx-0.3.0/PKG-INFO +0 -260
  23. loaderx-0.3.0/README.md +0 -216
  24. loaderx-0.3.0/loaderx/__init__.py +0 -5
  25. loaderx-0.3.0/loaderx/dataset.py +0 -93
  26. loaderx-0.3.0/loaderx/lib/libsampler.so +0 -0
  27. loaderx-0.3.0/loaderx/utils.py +0 -51
  28. loaderx-0.3.0/loaderx.egg-info/PKG-INFO +0 -260
  29. loaderx-0.3.0/loaderx.egg-info/requires.txt +0 -3
  30. {loaderx-0.3.0 → loaderx-0.5.0}/LICENSE +0 -0
  31. {loaderx-0.3.0 → loaderx-0.5.0}/loaderx.egg-info/dependency_links.txt +0 -0
  32. {loaderx-0.3.0 → loaderx-0.5.0}/loaderx.egg-info/top_level.txt +0 -0
  33. {loaderx-0.3.0 → loaderx-0.5.0}/setup.cfg +0 -0
@@ -0,0 +1,11 @@
1
+ # The sdist ships sources, not binaries: the shared objects are platform
2
+ # specific and `setup.py` rebuilds them with Zig when they are missing.
3
+ include build.zig
4
+ include build.zig.zon
5
+ include LICENSE
6
+ recursive-include src *.zig
7
+ recursive-include tests *.py
8
+ recursive-include scripts *.py
9
+
10
+ exclude loaderx/lib/lib_here
11
+ recursive-exclude loaderx/lib *.so *.dylib *.dll *.pdb *.lib
loaderx-0.5.0/PKG-INFO ADDED
@@ -0,0 +1,334 @@
1
+ Metadata-Version: 2.4
2
+ Name: loaderx
3
+ Version: 0.5.0
4
+ Summary: A record-based data runtime, focused on delivering extreme throughput and low latency
5
+ Author-email: Ben0i0d <ben0i0d@foxmail.com>
6
+ License: MIT License
7
+
8
+ Copyright (c) 2025 EOELAB AI Research
9
+
10
+ Permission is hereby granted, free of charge, to any person obtaining a copy
11
+ of this software and associated documentation files (the "Software"), to deal
12
+ in the Software without restriction, including without limitation the rights
13
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
14
+ copies of the Software, and to permit persons to whom the Software is
15
+ furnished to do so, subject to the following conditions:
16
+
17
+ The above copyright notice and this permission notice shall be included in all
18
+ copies or substantial portions of the Software.
19
+
20
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
21
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
22
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
23
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
24
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
25
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
26
+ SOFTWARE.
27
+
28
+ Project-URL: Homepage, https://codeberg.org/eoelab/loaderx
29
+ Project-URL: Documentation, https://codeberg.org/eoelab/loaderx
30
+ Project-URL: Source, https://codeberg.org/eoelab/loaderx
31
+ Project-URL: Bug Tracker, https://codeberg.org/eoelab/loaderx
32
+ Keywords: flax,python,dataloader
33
+ Classifier: Development Status :: 3 - Alpha
34
+ Classifier: Intended Audience :: Developers
35
+ Classifier: License :: OSI Approved :: MIT License
36
+ Classifier: Programming Language :: Python :: 3
37
+ Classifier: Operating System :: POSIX :: Linux
38
+ Classifier: Operating System :: MacOS :: MacOS X
39
+ Classifier: Operating System :: Microsoft :: Windows
40
+ Requires-Python: >=3.10
41
+ Description-Content-Type: text/markdown
42
+ License-File: LICENSE
43
+ Requires-Dist: numpy
44
+ Requires-Dist: cffi
45
+ Dynamic: license-file
46
+
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.
49
+
50
+ ```
51
+ pip install loaderx
52
+ ```
53
+
54
+ Wheels are published for Linux (glibc ≥ 2.17 and musl, x86-64 and arm64), macOS
55
+ (≥ 11.0, Intel and Apple Silicon) and Windows (x64 and arm64). The bindings use
56
+ cffi in ABI mode, so nothing links against the CPython ABI and one wheel per
57
+ platform serves every supported Python.
58
+
59
+ ## Design Philosophy
60
+
61
+ loaderx is built around several core principles:
62
+
63
+ 1. A pragmatic approach that prioritizes minimal memory overhead and minimal dependencies.
64
+ 2. A strong focus on single-machine training workflows.
65
+ 3. We implement based on NumPy semantics, persisted through the Zrecord storage runtime.
66
+ 4. An **immortal (endless) step-based data loader**, rather than the traditional epoch-based design—better aligned with modern ML training practices.
67
+
68
+ ## Quick Start
69
+ ```python
70
+ from loaderx import Dataset, DataLoader
71
+
72
+ dataset = Dataset('train_data.zr')
73
+ labelset = Dataset('train_label.zr')
74
+
75
+ loader = DataLoader(dataset, labelset)
76
+
77
+ for i, batch in enumerate(loader):
78
+ if i >= 256:
79
+ break
80
+
81
+ print(batch['data'].shape)
82
+ print(batch['label'].shape)
83
+ ```
84
+
85
+ ### Converting a NumPy tensor
86
+ ```python
87
+ import numpy as np
88
+ from loaderx import Dataset
89
+
90
+ Dataset.write('train_data.zr', np.load('data.npy', mmap_mode='r'))
91
+ Dataset.write('train_label.zr', np.load('label.npy', mmap_mode='r'))
92
+ ```
93
+ One record per slice along axis 0; a 1-D array (the usual shape of a label set)
94
+ becomes a dataset of scalars. The conversion streams in bounded chunks, so an
95
+ mmapped array is never fully materialized.
96
+
97
+ `Dataset` keeps a single `schema.json` inside the store recording the per-sample
98
+ dtype and shape — that is the only loaderx-level metadata. Everything beneath it
99
+ is plain Zrecord, reachable through `loaderx.Zrecord` when you want raw byte
100
+ records (ragged samples, pre-encoded images) instead of tensors.
101
+
102
+ ### Removing samples
103
+ ```python
104
+ ds = Dataset('train_data.zr')
105
+ ds.delete([7, 12, 40]) # len shrinks; survivors stay addressable as 0..len
106
+ ds.compact() # reclaim the freed space on disk
107
+ print(ds.stats()) # {'records': ..., 'live_bytes': ..., 'reclaimable': ...}
108
+ ```
109
+ Deletion keeps the index space dense, which is what the sampler needs — but it
110
+ does so by moving the tail into the hole, so surviving samples may change index.
111
+ Do not hold indices across a delete. Both calls require exclusive access; do not
112
+ run them while a `DataLoader` is iterating the dataset.
113
+
114
+ ### Integrating with JAX/Flax
115
+
116
+ For practical integration examples, please refer to the **[Data2Latent](https://codeberg.org/eoelab/Data2Latent)** repository
117
+
118
+ ## Current Limitations
119
+ * Single-host only; multi-host training is not supported.
120
+ * A single sample must be at most 1 MiB, and a store holds at most 2^24
121
+ (16777216) records.
122
+ * Metadata is mapped and used in place, so a store carries the host's byte
123
+ order and is not portable to a machine of the opposite endianness. Every
124
+ published platform is little-endian, so this only matters if you build for
125
+ one yourself.
126
+
127
+ ## Build
128
+ ```
129
+ zig build # host shared objects, into loaderx/lib/
130
+ zig build test # Zrecord suite, in both Debug and ReleaseFast
131
+ zig build bench # append/gather throughput
132
+ python3 tests/test_loaderx.py # Python layer, against whichever build is importable
133
+ ```
134
+
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.
143
+
144
+ ### Publishing
145
+ Zig cross-compiles every target from one machine, so releases need no CI matrix:
146
+
147
+ ```
148
+ zig build dist # every platform, into zig-out/dist/<wheel tag>/
149
+ python3 scripts/build_wheels.py # one wheel per platform, plus the sdist
150
+ ```
151
+
152
+ The dist directories are named after their Python wheel platform tag, so the tag
153
+ mapping lives in exactly one place (`dist_targets` in `build.zig`). glibc and
154
+ macOS minimums are pinned in the target triple, which is what makes
155
+ `manylinux_2_17` and `macosx_11_0` honest rather than aspirational. Each wheel is
156
+ checked after packing: it must carry this platform's libraries and no others.
157
+
158
+ The sdist ships sources only. Installing from it runs `zig build` through
159
+ `setup.py`, so it needs the Zig compiler; wheel users never hit that path.
160
+
161
+ ---
162
+
163
+ # Zsampler
164
+ Index Generator: a high-performance sampler implemented in Zig
165
+
166
+ 1. Sequential generation: indices are produced by traversing the index space in order.
167
+ * Sliding traversal: indices are obtained using a fixed-size sliding window. Note that in this case, the index space is treated as a circular queue to avoid truncation at the tail.
168
+ 2. Random generation: indices are sampled randomly from the index space.
169
+ * Global random: a set of samples is drawn randomly from the entire index space.
170
+
171
+ ---
172
+
173
+ # Zrecord
174
+ A record-based data runtime, focused on delivering extreme throughput and low latency.
175
+
176
+ 1. Zrecord is an unordered physical store made of N records. Records are
177
+ independent and carry no ordering, so every index and slice operation is
178
+ equivalent to a gather.
179
+ 2. Zrecord hands the client a dense index space: the live records are always
180
+ exactly `0..N`. Deletion preserves that by swapping the tail into the hole,
181
+ which means an index is stable only until something is deleted. A
182
+ multi-stream dataset is purely a client-side notion; the client keeps its
183
+ own stream index table pointing at records.
184
+ 3. Zrecord returns byte arrays. Type interpretation is the client's job.
185
+ 4. For performance, the IO model (`append | read | delete`) is batch-oriented;
186
+ a single-record operation is just the `batch_size == 1` case.
187
+ 5. Zrecord owns its memory internally — allocation and release are explicit.
188
+ 6. Each record is compressed and decompressed independently:
189
+
190
+ ```
191
+ | tag | name | algorithm |
192
+ |-------|---------|-----------|
193
+ | 0 | raw | none |
194
+ | 1 | flate | Deflate |
195
+ ```
196
+
197
+ 7. Compression is transparent to the client:
198
+ * Compression runs concurrently across all cores. A record that fails to
199
+ shrink is stored raw automatically, so reading never costs more than the
200
+ data itself.
201
+ * Decompression writes straight into the caller's destination memory
202
+ (`gather`), with no intermediate buffer and no extra copy.
203
+
204
+ ## Persistence format
205
+ Zrecord storage is metadata plus chunked data:
206
+ ```
207
+ zrecord/
208
+ ├── meta.zr
209
+ ├── 0.zr
210
+ └── 1.zr
211
+ ```
212
+
213
+ ### Metadata (meta.zr)
214
+
215
+ meta.zr is **used as the values it holds, not decoded into them**. Both structs
216
+ below are `extern`, 16 bytes, and naturally aligned; an mmap is page aligned, so
217
+ the header is a `*Header` and the record table is a `[]RecordLoc`. Looking up a
218
+ record is `table[idx]`, and there is no serializer anywhere in the code.
219
+
220
+ The fields are deliberately wider than the limits need — `chunk_id` is a `u16`
221
+ for 4096 chunks, the lengths are `u32` for a 1 MiB cap. Bit-packing them would
222
+ save 5 bytes per record and cost a hand-written codec on the hottest lookup
223
+ path. The slack buys a machine-shaped array; it is worth it.
224
+
225
+ **1. Header** — 16 bytes of global state.
226
+ * `magic` (`ZREC`) and `version` are ordinary fields, so opening a directory
227
+ that is not a Zrecord store fails immediately instead of decoding garbage.
228
+ * `length` is the total number of records | `tail_chunk`/`tail_offset` mark the
229
+ last write position.
230
+ * There is no chunk count. Chunks are created in order and the frontier is
231
+ always in the last one, so the store holds exactly chunks `0..=tail_chunk` —
232
+ a count would be a second copy of that fact to keep in sync.
233
+
234
+ ```zig
235
+ const Header = extern struct {
236
+ magic: [4]u8, version: u16,
237
+ tail_chunk: u16, tail_offset: u32,
238
+ length: u32,
239
+ };
240
+ ```
241
+
242
+ **2. Record table** — 16 bytes per entry, indexed directly. Mapping an index to
243
+ a physical address is what makes random access efficient.
244
+ * `chunk_id` is the containing chunk | `offset` is the position within it |
245
+ `phys_length`/`logic_length` are the stored and original sizes | `compress` is
246
+ the algorithm.
247
+
248
+ ```zig
249
+ const Codec = enum(u8) { raw = 0, flate = 1, _ };
250
+ const RecordLoc = extern struct {
251
+ offset: u32, phys_length: u32, logic_length: u32,
252
+ chunk_id: u16, compress: Codec, _reserved: u8,
253
+ };
254
+ ```
255
+
256
+ There is no liveness flag. Every entry below `length` is live, because deletion
257
+ swaps the tail into the hole rather than tombstoning.
258
+
259
+ **3. Maximum length.** Running out of space is effectively impossible, so the
260
+ metadata is sized statically at the maximum.
261
+ * Current design: up to 2^12 (4096) chunks, 2^32 (4 GiB) per chunk, 2^20 (1 MiB)
262
+ per record.
263
+ * Derivation: N = chunks × 2^32 (chunk size) / 2^20 (max record size) ⇒ a
264
+ maximum length of 2^24 (16777216). Equality here is the point: the store can
265
+ never run out of chunks before it runs out of record slots, which is what
266
+ lets the table be sized statically. It is a compile-time assertion.
267
+ * meta.zr is a fixed 16 + 2^24 × 16 bytes (256 MiB). It is sparse — a store
268
+ with one record allocates 4 KiB of it — and the mapping does not prefault.
269
+
270
+ ### Chunk data (x.zr)
271
+ Densely packed record data.
272
+
273
+ ## Executor
274
+
275
+ **1. Write.** Writes are append-only; everything else is offset redirection.
276
+ * Append: compress concurrently → assign physical locations serially → flush
277
+ concurrently → commit metadata. Data is durable-ordered before the record
278
+ table, and the table before `length`, so a crash truncates rather than
279
+ corrupts. A record never straddles two chunks; one that would not fit rolls
280
+ over to a fresh chunk.
281
+ * Delete: swap the last table entry into the deleted slot and drop the length by
282
+ one. A batch is applied in descending index order, so each swap pulls from a
283
+ slot no later target refers to. The index space stays dense — which is what
284
+ the sampler needs, since it draws uniformly from `0..N` and would otherwise
285
+ keep hitting holes. The deleted record's bytes become garbage.
286
+
287
+ **2. Read.** Fill the destination memory concurrently, in place from the Python
288
+ side (executed on async threads).
289
+ * Committed records are immutable, so the read path is lock free; `length` is
290
+ published to readers through an atomic.
291
+
292
+ **3. Concurrency model.** `Io.Group.async` shards work by CPU count, and shards
293
+ beyond the limit run inline on the calling thread.
294
+ * Shards receive contiguous blocks rather than a strided subset, keeping each
295
+ worker's reads and writes sequential.
296
+ * Each shard reuses one compressor scratch (`flate.Compress` is ~230 KiB — far
297
+ too large to rebuild per record, let alone to place on a task stack).
298
+ * Decompression runs in direct mode: matches are resolved against the bytes
299
+ already written into the destination, so there is no window buffer.
300
+
301
+ **4. Garbage collection.** Deletion leaves the record's bytes stranded, so space
302
+ is reclaimed by an offline `compact` that rewrites the live records in place.
303
+ * Records are visited in *physical* order — which after swap-last deletes no
304
+ longer matches table order — and repacked densely in that same order. A record
305
+ therefore never moves to a higher address than it already had.
306
+ * Two things follow. Writing a record can never land on the bytes of a record
307
+ not yet moved, so the rewrite is safe in place with no scratch copy of the
308
+ store. And each table entry can be updated the instant its record lands, so
309
+ the store is consistent at every point: an interrupted compaction leaves some
310
+ records moved and the rest where they were, and re-running finishes the job.
311
+ * Records that are already in the right place are skipped, so a store with a
312
+ small amount of garbage near the end is cheap to compact.
313
+ * Chunk files past the new frontier are closed and deleted.
314
+ * `stats()` reports `live_bytes` against `chunk_bytes` so callers can decide when
315
+ it is worth running. Note the difference is an upper bound: a record never
316
+ straddles a chunk boundary, so up to one record's worth per chunk is slack
317
+ that compaction cannot remove.
318
+
319
+ **5. File access.**
320
+ * Metadata: a 192 MiB file created at init, accessed by mmap thereafter.
321
+ * Chunk data: 4 GiB files created at init, accessed concurrently through
322
+ `readPositionalAll`/`writePositionalAll`.
323
+
324
+ **Concurrency contract.** `gather` and `append` are safe to call concurrently
325
+ from many threads. `delete` and `compact` mutate the table in ways a lock-free
326
+ reader would observe half-applied, so they require exclusive access to the
327
+ store.
328
+
329
+ ## Distributed extension
330
+ **Not implemented yet; architecture noted in advance.**
331
+
332
+ 1. A cluster layer is added; a node becomes a shard holding several chunks.
333
+ 2. An indirection table maps a global path to a specific node, so the index path
334
+ becomes `idx → indirection[idx] = node, lidx → offset[lidx]`.
@@ -0,0 +1,288 @@
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.
3
+
4
+ ```
5
+ pip install loaderx
6
+ ```
7
+
8
+ Wheels are published for Linux (glibc ≥ 2.17 and musl, x86-64 and arm64), macOS
9
+ (≥ 11.0, Intel and Apple Silicon) and Windows (x64 and arm64). The bindings use
10
+ cffi in ABI mode, so nothing links against the CPython ABI and one wheel per
11
+ platform serves every supported Python.
12
+
13
+ ## Design Philosophy
14
+
15
+ loaderx is built around several core principles:
16
+
17
+ 1. A pragmatic approach that prioritizes minimal memory overhead and minimal dependencies.
18
+ 2. A strong focus on single-machine training workflows.
19
+ 3. We implement based on NumPy semantics, persisted through the Zrecord storage runtime.
20
+ 4. An **immortal (endless) step-based data loader**, rather than the traditional epoch-based design—better aligned with modern ML training practices.
21
+
22
+ ## Quick Start
23
+ ```python
24
+ from loaderx import Dataset, DataLoader
25
+
26
+ dataset = Dataset('train_data.zr')
27
+ labelset = Dataset('train_label.zr')
28
+
29
+ loader = DataLoader(dataset, labelset)
30
+
31
+ for i, batch in enumerate(loader):
32
+ if i >= 256:
33
+ break
34
+
35
+ print(batch['data'].shape)
36
+ print(batch['label'].shape)
37
+ ```
38
+
39
+ ### Converting a NumPy tensor
40
+ ```python
41
+ import numpy as np
42
+ from loaderx import Dataset
43
+
44
+ Dataset.write('train_data.zr', np.load('data.npy', mmap_mode='r'))
45
+ Dataset.write('train_label.zr', np.load('label.npy', mmap_mode='r'))
46
+ ```
47
+ One record per slice along axis 0; a 1-D array (the usual shape of a label set)
48
+ becomes a dataset of scalars. The conversion streams in bounded chunks, so an
49
+ mmapped array is never fully materialized.
50
+
51
+ `Dataset` keeps a single `schema.json` inside the store recording the per-sample
52
+ dtype and shape — that is the only loaderx-level metadata. Everything beneath it
53
+ is plain Zrecord, reachable through `loaderx.Zrecord` when you want raw byte
54
+ records (ragged samples, pre-encoded images) instead of tensors.
55
+
56
+ ### Removing samples
57
+ ```python
58
+ ds = Dataset('train_data.zr')
59
+ ds.delete([7, 12, 40]) # len shrinks; survivors stay addressable as 0..len
60
+ ds.compact() # reclaim the freed space on disk
61
+ print(ds.stats()) # {'records': ..., 'live_bytes': ..., 'reclaimable': ...}
62
+ ```
63
+ Deletion keeps the index space dense, which is what the sampler needs — but it
64
+ does so by moving the tail into the hole, so surviving samples may change index.
65
+ Do not hold indices across a delete. Both calls require exclusive access; do not
66
+ run them while a `DataLoader` is iterating the dataset.
67
+
68
+ ### Integrating with JAX/Flax
69
+
70
+ For practical integration examples, please refer to the **[Data2Latent](https://codeberg.org/eoelab/Data2Latent)** repository
71
+
72
+ ## Current Limitations
73
+ * Single-host only; multi-host training is not supported.
74
+ * A single sample must be at most 1 MiB, and a store holds at most 2^24
75
+ (16777216) records.
76
+ * Metadata is mapped and used in place, so a store carries the host's byte
77
+ order and is not portable to a machine of the opposite endianness. Every
78
+ published platform is little-endian, so this only matters if you build for
79
+ one yourself.
80
+
81
+ ## Build
82
+ ```
83
+ zig build # host shared objects, into loaderx/lib/
84
+ zig build test # Zrecord suite, in both Debug and ReleaseFast
85
+ zig build bench # append/gather throughput
86
+ python3 tests/test_loaderx.py # Python layer, against whichever build is importable
87
+ ```
88
+
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.
97
+
98
+ ### Publishing
99
+ Zig cross-compiles every target from one machine, so releases need no CI matrix:
100
+
101
+ ```
102
+ zig build dist # every platform, into zig-out/dist/<wheel tag>/
103
+ python3 scripts/build_wheels.py # one wheel per platform, plus the sdist
104
+ ```
105
+
106
+ The dist directories are named after their Python wheel platform tag, so the tag
107
+ mapping lives in exactly one place (`dist_targets` in `build.zig`). glibc and
108
+ macOS minimums are pinned in the target triple, which is what makes
109
+ `manylinux_2_17` and `macosx_11_0` honest rather than aspirational. Each wheel is
110
+ checked after packing: it must carry this platform's libraries and no others.
111
+
112
+ The sdist ships sources only. Installing from it runs `zig build` through
113
+ `setup.py`, so it needs the Zig compiler; wheel users never hit that path.
114
+
115
+ ---
116
+
117
+ # Zsampler
118
+ Index Generator: a high-performance sampler implemented in Zig
119
+
120
+ 1. Sequential generation: indices are produced by traversing the index space in order.
121
+ * Sliding traversal: indices are obtained using a fixed-size sliding window. Note that in this case, the index space is treated as a circular queue to avoid truncation at the tail.
122
+ 2. Random generation: indices are sampled randomly from the index space.
123
+ * Global random: a set of samples is drawn randomly from the entire index space.
124
+
125
+ ---
126
+
127
+ # Zrecord
128
+ A record-based data runtime, focused on delivering extreme throughput and low latency.
129
+
130
+ 1. Zrecord is an unordered physical store made of N records. Records are
131
+ independent and carry no ordering, so every index and slice operation is
132
+ equivalent to a gather.
133
+ 2. Zrecord hands the client a dense index space: the live records are always
134
+ exactly `0..N`. Deletion preserves that by swapping the tail into the hole,
135
+ which means an index is stable only until something is deleted. A
136
+ multi-stream dataset is purely a client-side notion; the client keeps its
137
+ own stream index table pointing at records.
138
+ 3. Zrecord returns byte arrays. Type interpretation is the client's job.
139
+ 4. For performance, the IO model (`append | read | delete`) is batch-oriented;
140
+ a single-record operation is just the `batch_size == 1` case.
141
+ 5. Zrecord owns its memory internally — allocation and release are explicit.
142
+ 6. Each record is compressed and decompressed independently:
143
+
144
+ ```
145
+ | tag | name | algorithm |
146
+ |-------|---------|-----------|
147
+ | 0 | raw | none |
148
+ | 1 | flate | Deflate |
149
+ ```
150
+
151
+ 7. Compression is transparent to the client:
152
+ * Compression runs concurrently across all cores. A record that fails to
153
+ shrink is stored raw automatically, so reading never costs more than the
154
+ data itself.
155
+ * Decompression writes straight into the caller's destination memory
156
+ (`gather`), with no intermediate buffer and no extra copy.
157
+
158
+ ## Persistence format
159
+ Zrecord storage is metadata plus chunked data:
160
+ ```
161
+ zrecord/
162
+ ├── meta.zr
163
+ ├── 0.zr
164
+ └── 1.zr
165
+ ```
166
+
167
+ ### Metadata (meta.zr)
168
+
169
+ meta.zr is **used as the values it holds, not decoded into them**. Both structs
170
+ below are `extern`, 16 bytes, and naturally aligned; an mmap is page aligned, so
171
+ the header is a `*Header` and the record table is a `[]RecordLoc`. Looking up a
172
+ record is `table[idx]`, and there is no serializer anywhere in the code.
173
+
174
+ The fields are deliberately wider than the limits need — `chunk_id` is a `u16`
175
+ for 4096 chunks, the lengths are `u32` for a 1 MiB cap. Bit-packing them would
176
+ save 5 bytes per record and cost a hand-written codec on the hottest lookup
177
+ path. The slack buys a machine-shaped array; it is worth it.
178
+
179
+ **1. Header** — 16 bytes of global state.
180
+ * `magic` (`ZREC`) and `version` are ordinary fields, so opening a directory
181
+ that is not a Zrecord store fails immediately instead of decoding garbage.
182
+ * `length` is the total number of records | `tail_chunk`/`tail_offset` mark the
183
+ last write position.
184
+ * There is no chunk count. Chunks are created in order and the frontier is
185
+ always in the last one, so the store holds exactly chunks `0..=tail_chunk` —
186
+ a count would be a second copy of that fact to keep in sync.
187
+
188
+ ```zig
189
+ const Header = extern struct {
190
+ magic: [4]u8, version: u16,
191
+ tail_chunk: u16, tail_offset: u32,
192
+ length: u32,
193
+ };
194
+ ```
195
+
196
+ **2. Record table** — 16 bytes per entry, indexed directly. Mapping an index to
197
+ a physical address is what makes random access efficient.
198
+ * `chunk_id` is the containing chunk | `offset` is the position within it |
199
+ `phys_length`/`logic_length` are the stored and original sizes | `compress` is
200
+ the algorithm.
201
+
202
+ ```zig
203
+ const Codec = enum(u8) { raw = 0, flate = 1, _ };
204
+ const RecordLoc = extern struct {
205
+ offset: u32, phys_length: u32, logic_length: u32,
206
+ chunk_id: u16, compress: Codec, _reserved: u8,
207
+ };
208
+ ```
209
+
210
+ There is no liveness flag. Every entry below `length` is live, because deletion
211
+ swaps the tail into the hole rather than tombstoning.
212
+
213
+ **3. Maximum length.** Running out of space is effectively impossible, so the
214
+ metadata is sized statically at the maximum.
215
+ * Current design: up to 2^12 (4096) chunks, 2^32 (4 GiB) per chunk, 2^20 (1 MiB)
216
+ per record.
217
+ * Derivation: N = chunks × 2^32 (chunk size) / 2^20 (max record size) ⇒ a
218
+ maximum length of 2^24 (16777216). Equality here is the point: the store can
219
+ never run out of chunks before it runs out of record slots, which is what
220
+ lets the table be sized statically. It is a compile-time assertion.
221
+ * meta.zr is a fixed 16 + 2^24 × 16 bytes (256 MiB). It is sparse — a store
222
+ with one record allocates 4 KiB of it — and the mapping does not prefault.
223
+
224
+ ### Chunk data (x.zr)
225
+ Densely packed record data.
226
+
227
+ ## Executor
228
+
229
+ **1. Write.** Writes are append-only; everything else is offset redirection.
230
+ * Append: compress concurrently → assign physical locations serially → flush
231
+ concurrently → commit metadata. Data is durable-ordered before the record
232
+ table, and the table before `length`, so a crash truncates rather than
233
+ corrupts. A record never straddles two chunks; one that would not fit rolls
234
+ over to a fresh chunk.
235
+ * Delete: swap the last table entry into the deleted slot and drop the length by
236
+ one. A batch is applied in descending index order, so each swap pulls from a
237
+ slot no later target refers to. The index space stays dense — which is what
238
+ the sampler needs, since it draws uniformly from `0..N` and would otherwise
239
+ keep hitting holes. The deleted record's bytes become garbage.
240
+
241
+ **2. Read.** Fill the destination memory concurrently, in place from the Python
242
+ side (executed on async threads).
243
+ * Committed records are immutable, so the read path is lock free; `length` is
244
+ published to readers through an atomic.
245
+
246
+ **3. Concurrency model.** `Io.Group.async` shards work by CPU count, and shards
247
+ beyond the limit run inline on the calling thread.
248
+ * Shards receive contiguous blocks rather than a strided subset, keeping each
249
+ worker's reads and writes sequential.
250
+ * Each shard reuses one compressor scratch (`flate.Compress` is ~230 KiB — far
251
+ too large to rebuild per record, let alone to place on a task stack).
252
+ * Decompression runs in direct mode: matches are resolved against the bytes
253
+ already written into the destination, so there is no window buffer.
254
+
255
+ **4. Garbage collection.** Deletion leaves the record's bytes stranded, so space
256
+ is reclaimed by an offline `compact` that rewrites the live records in place.
257
+ * Records are visited in *physical* order — which after swap-last deletes no
258
+ longer matches table order — and repacked densely in that same order. A record
259
+ therefore never moves to a higher address than it already had.
260
+ * Two things follow. Writing a record can never land on the bytes of a record
261
+ not yet moved, so the rewrite is safe in place with no scratch copy of the
262
+ store. And each table entry can be updated the instant its record lands, so
263
+ the store is consistent at every point: an interrupted compaction leaves some
264
+ records moved and the rest where they were, and re-running finishes the job.
265
+ * Records that are already in the right place are skipped, so a store with a
266
+ small amount of garbage near the end is cheap to compact.
267
+ * Chunk files past the new frontier are closed and deleted.
268
+ * `stats()` reports `live_bytes` against `chunk_bytes` so callers can decide when
269
+ it is worth running. Note the difference is an upper bound: a record never
270
+ straddles a chunk boundary, so up to one record's worth per chunk is slack
271
+ that compaction cannot remove.
272
+
273
+ **5. File access.**
274
+ * Metadata: a 192 MiB file created at init, accessed by mmap thereafter.
275
+ * Chunk data: 4 GiB files created at init, accessed concurrently through
276
+ `readPositionalAll`/`writePositionalAll`.
277
+
278
+ **Concurrency contract.** `gather` and `append` are safe to call concurrently
279
+ from many threads. `delete` and `compact` mutate the table in ways a lock-free
280
+ reader would observe half-applied, so they require exclusive access to the
281
+ store.
282
+
283
+ ## Distributed extension
284
+ **Not implemented yet; architecture noted in advance.**
285
+
286
+ 1. A cluster layer is added; a node becomes a shard holding several chunks.
287
+ 2. An indirection table maps a global path to a specific node, so the index path
288
+ becomes `idx → indirection[idx] = node, lidx → offset[lidx]`.