loaderx 0.5.4__tar.gz → 0.7.3__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 (101) hide show
  1. {loaderx-0.5.4 → loaderx-0.7.3}/MANIFEST.in +4 -0
  2. {loaderx-0.5.4/loaderx.egg-info → loaderx-0.7.3}/PKG-INFO +182 -82
  3. {loaderx-0.5.4 → loaderx-0.7.3}/README.md +181 -81
  4. {loaderx-0.5.4 → loaderx-0.7.3}/build.zig +80 -21
  5. loaderx-0.7.3/loaderx/__init__.py +5 -0
  6. {loaderx-0.5.4 → loaderx-0.7.3}/loaderx/_sampler.py +12 -5
  7. {loaderx-0.5.4 → loaderx-0.7.3}/loaderx/_zrecord.py +243 -3
  8. {loaderx-0.5.4 → loaderx-0.7.3}/loaderx/dataloader.py +35 -21
  9. loaderx-0.7.3/loaderx/dataset.py +226 -0
  10. {loaderx-0.5.4 → loaderx-0.7.3/loaderx.egg-info}/PKG-INFO +182 -82
  11. loaderx-0.7.3/loaderx.egg-info/SOURCES.txt +94 -0
  12. {loaderx-0.5.4 → loaderx-0.7.3}/scripts/bench.py +28 -20
  13. {loaderx-0.5.4 → loaderx-0.7.3}/src/zrecord.zig +242 -69
  14. loaderx-0.7.3/src/zsampler.zig +325 -0
  15. loaderx-0.7.3/src/zstd/c.zig +735 -0
  16. loaderx-0.7.3/tests/test_loaderx.py +430 -0
  17. loaderx-0.7.3/vendor/zstd/COPYING +339 -0
  18. loaderx-0.7.3/vendor/zstd/LICENSE +30 -0
  19. loaderx-0.7.3/vendor/zstd/lib/common/allocations.h +55 -0
  20. loaderx-0.7.3/vendor/zstd/lib/common/bits.h +200 -0
  21. loaderx-0.7.3/vendor/zstd/lib/common/bitstream.h +457 -0
  22. loaderx-0.7.3/vendor/zstd/lib/common/compiler.h +450 -0
  23. loaderx-0.7.3/vendor/zstd/lib/common/cpu.h +249 -0
  24. loaderx-0.7.3/vendor/zstd/lib/common/debug.c +30 -0
  25. loaderx-0.7.3/vendor/zstd/lib/common/debug.h +116 -0
  26. loaderx-0.7.3/vendor/zstd/lib/common/entropy_common.c +340 -0
  27. loaderx-0.7.3/vendor/zstd/lib/common/error_private.c +63 -0
  28. loaderx-0.7.3/vendor/zstd/lib/common/error_private.h +168 -0
  29. loaderx-0.7.3/vendor/zstd/lib/common/fse.h +640 -0
  30. loaderx-0.7.3/vendor/zstd/lib/common/fse_decompress.c +313 -0
  31. loaderx-0.7.3/vendor/zstd/lib/common/huf.h +286 -0
  32. loaderx-0.7.3/vendor/zstd/lib/common/mem.h +426 -0
  33. loaderx-0.7.3/vendor/zstd/lib/common/pool.c +371 -0
  34. loaderx-0.7.3/vendor/zstd/lib/common/pool.h +90 -0
  35. loaderx-0.7.3/vendor/zstd/lib/common/portability_macros.h +158 -0
  36. loaderx-0.7.3/vendor/zstd/lib/common/threading.c +182 -0
  37. loaderx-0.7.3/vendor/zstd/lib/common/threading.h +150 -0
  38. loaderx-0.7.3/vendor/zstd/lib/common/xxhash.c +18 -0
  39. loaderx-0.7.3/vendor/zstd/lib/common/xxhash.h +7020 -0
  40. loaderx-0.7.3/vendor/zstd/lib/common/zstd_common.c +48 -0
  41. loaderx-0.7.3/vendor/zstd/lib/common/zstd_deps.h +111 -0
  42. loaderx-0.7.3/vendor/zstd/lib/common/zstd_internal.h +392 -0
  43. loaderx-0.7.3/vendor/zstd/lib/common/zstd_trace.h +163 -0
  44. loaderx-0.7.3/vendor/zstd/lib/compress/clevels.h +134 -0
  45. loaderx-0.7.3/vendor/zstd/lib/compress/fse_compress.c +625 -0
  46. loaderx-0.7.3/vendor/zstd/lib/compress/hist.c +181 -0
  47. loaderx-0.7.3/vendor/zstd/lib/compress/hist.h +75 -0
  48. loaderx-0.7.3/vendor/zstd/lib/compress/huf_compress.c +1464 -0
  49. loaderx-0.7.3/vendor/zstd/lib/compress/zstd_compress.c +7153 -0
  50. loaderx-0.7.3/vendor/zstd/lib/compress/zstd_compress_internal.h +1534 -0
  51. loaderx-0.7.3/vendor/zstd/lib/compress/zstd_compress_literals.c +235 -0
  52. loaderx-0.7.3/vendor/zstd/lib/compress/zstd_compress_literals.h +39 -0
  53. loaderx-0.7.3/vendor/zstd/lib/compress/zstd_compress_sequences.c +442 -0
  54. loaderx-0.7.3/vendor/zstd/lib/compress/zstd_compress_sequences.h +54 -0
  55. loaderx-0.7.3/vendor/zstd/lib/compress/zstd_compress_superblock.c +688 -0
  56. loaderx-0.7.3/vendor/zstd/lib/compress/zstd_compress_superblock.h +32 -0
  57. loaderx-0.7.3/vendor/zstd/lib/compress/zstd_cwksp.h +748 -0
  58. loaderx-0.7.3/vendor/zstd/lib/compress/zstd_double_fast.c +770 -0
  59. loaderx-0.7.3/vendor/zstd/lib/compress/zstd_double_fast.h +50 -0
  60. loaderx-0.7.3/vendor/zstd/lib/compress/zstd_fast.c +968 -0
  61. loaderx-0.7.3/vendor/zstd/lib/compress/zstd_fast.h +38 -0
  62. loaderx-0.7.3/vendor/zstd/lib/compress/zstd_lazy.c +2199 -0
  63. loaderx-0.7.3/vendor/zstd/lib/compress/zstd_lazy.h +202 -0
  64. loaderx-0.7.3/vendor/zstd/lib/compress/zstd_ldm.c +730 -0
  65. loaderx-0.7.3/vendor/zstd/lib/compress/zstd_ldm.h +117 -0
  66. loaderx-0.7.3/vendor/zstd/lib/compress/zstd_ldm_geartab.h +106 -0
  67. loaderx-0.7.3/vendor/zstd/lib/compress/zstd_opt.c +1576 -0
  68. loaderx-0.7.3/vendor/zstd/lib/compress/zstd_opt.h +80 -0
  69. loaderx-0.7.3/vendor/zstd/lib/compress/zstdmt_compress.c +1882 -0
  70. loaderx-0.7.3/vendor/zstd/lib/compress/zstdmt_compress.h +113 -0
  71. loaderx-0.7.3/vendor/zstd/lib/decompress/huf_decompress.c +1944 -0
  72. loaderx-0.7.3/vendor/zstd/lib/decompress/zstd_ddict.c +244 -0
  73. loaderx-0.7.3/vendor/zstd/lib/decompress/zstd_ddict.h +44 -0
  74. loaderx-0.7.3/vendor/zstd/lib/decompress/zstd_decompress.c +2407 -0
  75. loaderx-0.7.3/vendor/zstd/lib/decompress/zstd_decompress_block.c +2215 -0
  76. loaderx-0.7.3/vendor/zstd/lib/decompress/zstd_decompress_block.h +73 -0
  77. loaderx-0.7.3/vendor/zstd/lib/decompress/zstd_decompress_internal.h +240 -0
  78. loaderx-0.7.3/vendor/zstd/lib/dictBuilder/cover.c +1261 -0
  79. loaderx-0.7.3/vendor/zstd/lib/dictBuilder/cover.h +152 -0
  80. loaderx-0.7.3/vendor/zstd/lib/dictBuilder/divsufsort.c +1913 -0
  81. loaderx-0.7.3/vendor/zstd/lib/dictBuilder/divsufsort.h +67 -0
  82. loaderx-0.7.3/vendor/zstd/lib/dictBuilder/fastcover.c +766 -0
  83. loaderx-0.7.3/vendor/zstd/lib/dictBuilder/zdict.c +1133 -0
  84. loaderx-0.7.3/vendor/zstd/lib/zdict.h +474 -0
  85. loaderx-0.7.3/vendor/zstd/lib/zstd.h +3089 -0
  86. loaderx-0.7.3/vendor/zstd/lib/zstd_errors.h +114 -0
  87. loaderx-0.5.4/loaderx/__init__.py +0 -5
  88. loaderx-0.5.4/loaderx/dataset.py +0 -222
  89. loaderx-0.5.4/loaderx.egg-info/SOURCES.txt +0 -23
  90. loaderx-0.5.4/src/zsampler.zig +0 -94
  91. loaderx-0.5.4/tests/test_loaderx.py +0 -301
  92. {loaderx-0.5.4 → loaderx-0.7.3}/LICENSE +0 -0
  93. {loaderx-0.5.4 → loaderx-0.7.3}/build.zig.zon +0 -0
  94. {loaderx-0.5.4 → loaderx-0.7.3}/loaderx/_lib.py +0 -0
  95. {loaderx-0.5.4 → loaderx-0.7.3}/loaderx.egg-info/dependency_links.txt +0 -0
  96. {loaderx-0.5.4 → loaderx-0.7.3}/loaderx.egg-info/requires.txt +0 -0
  97. {loaderx-0.5.4 → loaderx-0.7.3}/loaderx.egg-info/top_level.txt +0 -0
  98. {loaderx-0.5.4 → loaderx-0.7.3}/pyproject.toml +0 -0
  99. {loaderx-0.5.4 → loaderx-0.7.3}/scripts/build_wheels.py +0 -0
  100. {loaderx-0.5.4 → loaderx-0.7.3}/setup.cfg +0 -0
  101. {loaderx-0.5.4 → loaderx-0.7.3}/setup.py +0 -0
@@ -7,5 +7,9 @@ recursive-include src *.zig
7
7
  recursive-include tests *.py
8
8
  recursive-include scripts *.py
9
9
 
10
+ # Vendored zstd: the sdist rebuilds with Zig, so it needs the C sources.
11
+ recursive-include vendor *.c *.h
12
+ include vendor/zstd/LICENSE vendor/zstd/COPYING
13
+
10
14
  exclude loaderx/lib/lib_here
11
15
  recursive-exclude loaderx/lib *.so *.dylib *.dll *.pdb *.lib
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: loaderx
3
- Version: 0.5.4
3
+ Version: 0.7.3
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
@@ -67,12 +67,17 @@ loaderx is built around several core principles:
67
67
 
68
68
  ## Quick Start
69
69
  ```python
70
- from loaderx import Dataset, DataLoader
70
+ from loaderx import DataLoader, Dataset, write_dataset
71
71
 
72
- dataset = Dataset('train_data.zr')
73
- labelset = Dataset('train_label.zr')
72
+ # Write (zrecord layer — one‑time conversion from NumPy)
73
+ write_dataset('train_data.zr', np.load('data.npy', mmap_mode='r'))
74
+ write_dataset('train_label.zr', np.load('label.npy', mmap_mode='r'))
74
75
 
75
- loader = DataLoader(dataset, labelset)
76
+ # Read (loader layer — pure loader, no mutations)
77
+ loader = DataLoader({
78
+ 'data': Dataset('train_data.zr'),
79
+ 'label': Dataset('train_label.zr'),
80
+ })
76
81
 
77
82
  for i, batch in enumerate(loader):
78
83
  if i >= 256:
@@ -82,34 +87,91 @@ print(batch['data'].shape)
82
87
  print(batch['label'].shape)
83
88
  ```
84
89
 
90
+ A batch is a dict with one entry per stream. Every stream is gathered at the same
91
+ indices, so record `i` lines up across them — a multi-field sample is just more
92
+ streams sharing a length: `DataLoader({'image': ..., 'label': ..., 'meta': ...})`.
93
+
85
94
  ### Converting a NumPy tensor
86
95
  ```python
87
96
  import numpy as np
88
- from loaderx import Dataset
97
+ from loaderx import Dataset, write_dataset
98
+
99
+ write_dataset('train_data.zr', np.load('data.npy', mmap_mode='r'))
100
+ write_dataset('train_label.zr', np.load('label.npy', mmap_mode='r'))
89
101
 
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'))
102
+ # many small, similar records compress far better against a trained dictionary:
103
+ write_dataset('train_data.zr', images, codec='zstd_dict')
92
104
  ```
93
105
  One record per slice along axis 0; a 1-D array (the usual shape of a label set)
94
106
  becomes a dataset of scalars. The conversion streams in bounded chunks, so an
95
- mmapped array is never fully materialized.
107
+ mmapped array is never fully materialized. `codec` selects compression —
108
+ `"zstd"` (default), `"raw"`, or `"zstd_dict"` (see [Zrecord](#zrecord)). Open
109
+ the result with :class:`Dataset`::
110
+
111
+ ds = Dataset('train_data.zr')
96
112
 
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
113
+ The store carries a single ``schema.json`` recording the per-sample dtype and
114
+ shape — that is the only loaderx-level metadata. Everything beneath it is
115
+ plain Zrecord, reachable through ``loaderx.Zrecord`` when you want raw byte
100
116
  records (ragged samples, pre-encoded images) instead of tensors.
101
117
 
102
- ### Removing samples
118
+ ### Variable-length records
119
+ When records share a dtype but not a length — token sequences, waveforms, point
120
+ sets — use `RaggedDataset`. It writes an iterable of variable-length arrays and
121
+ reads a batch back in the packed layout Zrecord stores natively: one flat buffer
122
+ of every row plus `row_splits`, gathered in place across cores with nothing
123
+ padded. The store stays a plain blob of bytes; how a batch becomes dense is a
124
+ separate, visible step.
125
+
103
126
  ```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': ...}
127
+ import numpy as np
128
+ from loaderx import RaggedDataset, pad, write_ragged
129
+
130
+ seqs = [np.arange(L, dtype=np.int32) for L in (3, 1, 4, 1, 5)]
131
+ write_ragged('tokens.zr', seqs) # dtype and per-row shape inferred
132
+ rds = RaggedDataset('tokens.zr') # open for reading
133
+
134
+ values, row_splits = rds[[0, 2, 4]] # packed: values is flat, row_splits marks edges
135
+ first = values[row_splits[0]:row_splits[1]] # record 0's rows — the client slices
136
+
137
+ padded, lengths = pad(values, row_splits) # dense (B, max_len, *tail) + row counts
138
+ padded, lengths = rds.getitems_padded([0, 2, 4]) # or fuse the gather and the pad in one pass
108
139
  ```
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.
140
+
141
+ Records may carry a fixed trailing shape (`item_shape=(3,)` for sequences of
142
+ 3-vectors); a plain 1-D array is a sequence of scalar rows. In a `DataLoader`,
143
+ padding is just the collate `transform` (a batch dict in, a batch dict out), so
144
+ ragged streams and fixed-shape streams flow through the same loader:
145
+
146
+ ```python
147
+ def collate(batch):
148
+ values, row_splits = batch['tokens']
149
+ padded, lengths = pad(values, row_splits)
150
+ return {'input_ids': padded, 'lengths': lengths, 'label': batch['label']}
151
+
152
+ loader = DataLoader({'tokens': rds, 'label': labelset}, batch_size=32, transform=collate)
153
+ ```
154
+
155
+ ### Codec notes
156
+
157
+ `"zstd"` compresses each record independently with plain zstd (level 3). Use it
158
+ for general-purpose compression — it is fast and the default.
159
+
160
+ `"zstd_dict"` trains a shared dictionary on a sample of the data before writing
161
+ any record, then compresses every record against it at level 19. The dictionary
162
+ captures structure shared across records that per-record compression cannot see —
163
+ a large win for many small, similar records (image tiles, token sequences).
164
+ Dictionary size adapts to the training data automatically; pass `dict_size` to
165
+ cap it.
166
+
167
+ `"zstd_dict"` records can only be read from a store that has the dictionary
168
+ (`dict.zr`). The dictionary is loaded on open and shared, lock-free, across all
169
+ reader threads.
170
+
171
+ **Important:** Do not use `"zstd_dict"` while data is still changing (through
172
+ deletes or compaction below the Python layer). The dictionary captures a snapshot
173
+ of the data; training it before the data settles wastes compression. Train the
174
+ dictionary once preprocessing is complete and the content is final.
113
175
 
114
176
  ### Integrating with JAX/Flax
115
177
 
@@ -117,7 +179,7 @@ For practical integration examples, please refer to the **[Data2Latent](https://
117
179
 
118
180
  ## Benchmarks
119
181
 
120
- `scripts/bench.py` measures loaderx against the alternatives in three layers —
182
+ `scripts/bench.py` measures zrecord and loaderx against the alternatives in
121
183
  the index sampler, the record store, and the full data loader — each through the
122
184
  binding a client actually uses, so cffi, the GIL and the NumPy allocation are all
123
185
  inside the timings. The numbers below are one run on a 24-core machine, Python
@@ -141,40 +203,44 @@ space, against both NumPy random APIs.
141
203
  Zsampler draws indices about 2x faster than NumPy's faster API — more against the
142
204
  legacy `randint` most code still calls — and the lead is widest at the small
143
205
  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.
206
+ NumPy's vectorised fill has nothing to amortise. The IID draw is unbiased like
207
+ NumPy's: it uses Lemire's method with rejection, which over any real index space
208
+ effectively never redraws, so uniformity costs nothing here.
147
209
 
148
- **2. Store** — random batch gather, 20000 records × 12 KiB `uint8(3,64,64)`,
210
+ **2. Store** — Zrecord against the alternatives: random batch gather, 20000
149
211
  batch 256 × 200, structured (mildly compressible) data.
150
212
 
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
213
+ | store | write | gather | per batch | on disk | ratio |
214
+ |-------------------|------------|-------------|------------|---------|-------|
215
+ | zrecord-zstd | 531 MiB/s | 10157 MiB/s | 0.30 ms | 216 MiB | 1.08x |
216
+ | zrecord-zstdict | 40 MiB/s | 4387 MiB/s | 0.68 ms | 201 MiB | 1.17x |
217
+ | zrecord-raw | 794 MiB/s | 21317 MiB/s | 0.14 ms | 235 MiB | 1.00x |
218
+ | npy-mmap | 2633 MiB/s | 10057 MiB/s | 0.30 ms | 234 MiB | 1.00x |
219
+ | arrayrecord | 329 MiB/s | 744 MiB/s | 4.03 ms | 217 MiB | 1.08x |
220
+ | hdf5 | 1105 MiB/s | 486 MiB/s | 6.18 ms | 236 MiB | 0.99x |
221
+ | hdf5-gzip | 78 MiB/s | 193 MiB/s | 15.51 ms | 211 MiB | 1.11x |
222
+ | lmdb | 666 MiB/s | 4655 MiB/s | 0.64 ms | 313 MiB | 0.75x |
223
+ | lmdb-zlib | 70 MiB/s | 306 MiB/s | 9.81 ms | 235 MiB | 1.00x |
224
+ | blosc2 | 30 MiB/s | 217 MiB/s | 13.81 ms | 216 MiB | 1.09x |
225
+ | zarr | 78 MiB/s | 23 MiB/s | 131.13 ms | 217 MiB | 1.08x |
226
+ | tensorstore | 545 MiB/s | 89 MiB/s | 33.76 ms | 214 MiB | 1.09x |
227
+
228
+ The gather column is what the store is built for. zrecord-raw returns a scattered
166
229
  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,
230
+ copies straight into the destination across all cores with the GIL released; and
231
+ zrecord-zstd keeps most of that speed *while* decompressing — 10 GiB/s, several
232
+ times faster than any other compressed store (hdf5-gzip, lmdb-zlib, blosc2, zarr,
233
+ tensorstore) and faster even than the uncompressed `.npy`. That is the point of
234
+ one fast codec: turning compression on barely costs a read. The array stores lag
235
+ on gather because a scattered single-record read fights a layout meant for
236
+ contiguous slabs — exactly the access pattern a training loader has. Where zrecord
237
+ does not lead is write: it pays compression and its own layout up front, a
238
+ one-time cost it does not chase (`zrecord-zstdict` most of all, at level 19).
239
+ Compression is modest here (1.08–1.17x) only because this data is barely
240
+ compressible; on the smooth-image set of the [Zrecord](#zrecord) notes plain zstd
241
+ reaches 7.6x and the dictionary 16.5x.
242
+
243
+ The reads are not all single-threaded: zrecord and blosc2 decompress across cores,
178
244
  the rest one record at a time. Zarr and TensorStore are sharded so `on disk`
179
245
  reflects compression rather than per-file block rounding, and TensorStore is given
180
246
  gzip (its zarr3 default is uncompressed). `on disk` is allocated blocks, which is
@@ -187,20 +253,21 @@ for: loaderx from Zrecord, Grain from ArrayRecord, torch from a memory-mapped
187
253
 
188
254
  | loader | batches/s | per batch | samples/s | throughput |
189
255
  |----------------|-----------|-----------|-----------|-------------|
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.
256
+ | **loaderx** | 4113.9 | 0.24 ms | 1,053,168 | 12342 MiB/s |
257
+ | loaderx-raw | 4838.9 | 0.21 ms | 1,238,752 | 14517 MiB/s |
258
+ | grain | 62.9 | 15.90 ms | 16,097 | 189 MiB/s |
259
+ | torch | 961.0 | 1.04 ms | 246,005 | 2883 MiB/s |
260
+ | jax-dataloader | 3176.8 | 0.31 ms | 813,270 | 9531 MiB/s |
261
+
262
+ End to end, loaderx serves batches several times faster than torch or Grain and
263
+ ahead of jax-dataloader — and it does so from disk-backed *compressed* storage,
264
+ where jax-dataloader's number is an in-memory ceiling with no storage path at all.
265
+ zstd decompresses fast enough that the compressed loader nearly matches the raw
266
+ one (4114 vs 4839 batches/s), so on-disk compression is close to free here.
267
+ loaderx and jax use threads; torch and Grain use worker processes that escape the
268
+ GIL but pay IPC per batch. loaderx never ends an epoch, so no step waits on a
269
+ boundary; the others restart per epoch, a cost that is in their numbers as it
270
+ would be in training.
204
271
 
205
272
  ## Current Limitations
206
273
  * Single-host only; multi-host training is not supported.
@@ -244,12 +311,26 @@ The sdist ships sources only. Installing from it runs `zig build` through
244
311
  ---
245
312
 
246
313
  # Zsampler
247
- Index Generator: a high-performance sampler implemented in Zig
248
-
249
- 1. Sequential generation: indices are produced by traversing the index space in order.
250
- * 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.
251
- 2. Random generation: indices are sampled randomly from the index space.
252
- * Global random: a set of samples is drawn randomly from the entire index space.
314
+ Index Generator: a high-performance sampler implemented in Zig. Every mode is a
315
+ pure function of `(seed, step)`, so a run resumes exactly by seeking to a step —
316
+ there is no epoch to track, in keeping with the endless step-based loader.
317
+
318
+ 1. **Sequential** — traverse the index space in order through a fixed-size sliding
319
+ window, treating the space as a circular queue so the tail never truncates.
320
+ 2. **IID** — draw each index uniformly at random with replacement. Unbiased
321
+ (Lemire with rejection), matching NumPy. Simplest, but coverage is uneven over
322
+ any short run.
323
+ 3. **Cyclic** — without replacement, round-robin. Each cycle traverses a fresh
324
+ permutation of the whole index space, so within a cycle every record appears
325
+ exactly once and no batch repeats an index — coverage is even by construction,
326
+ which keeps how often each sample is seen uniform. The permutation is a
327
+ stateless bijection (a small Feistel network over the index space, brought
328
+ into range by cycle-walking), so a million-record shuffle materializes nothing
329
+ the size of the dataset and reshuffling each cycle is free. Every batch is
330
+ exactly `batch_size`: an endless step-based loader has no final partial batch
331
+ to special-case, so when `batch_size` does not divide the length the cycle's
332
+ remainder is dropped — a different remainder each cycle, since the permutation
333
+ changes, so every record is still reached over time.
253
334
 
254
335
  ---
255
336
 
@@ -271,10 +352,11 @@ A record-based data runtime, focused on delivering extreme throughput and low la
271
352
  6. Each record is compressed and decompressed independently:
272
353
 
273
354
  ```
274
- | tag | name | algorithm |
275
- |-------|---------|-----------|
276
- | 0 | raw | none |
277
- | 1 | flate | Deflate |
355
+ | tag | name | algorithm |
356
+ |-------|-----------|----------------------------------------|
357
+ | 0 | raw | none |
358
+ | 1 | zstd | zstd (plain, level 3) |
359
+ | 2 | zstdict | zstd with a trained dictionary (level 19) |
278
360
  ```
279
361
 
280
362
  7. Compression is transparent to the client:
@@ -283,6 +365,22 @@ A record-based data runtime, focused on delivering extreme throughput and low la
283
365
  data itself.
284
366
  * Decompression writes straight into the caller's destination memory
285
367
  (`gather`), with no intermediate buffer and no extra copy.
368
+ * zstd is the one transparent codec — faster than Deflate at both ends and a
369
+ better ratio, so there is no reason to carry a second. It is vendored C,
370
+ built for every platform by Zig, so the one-wheel-per-platform story is
371
+ unchanged.
372
+ * `zstd_dict` additionally trains one dictionary on a sample of the data
373
+ (stored as `dict.zr`) and compresses every record against it. Because each
374
+ record is still independent, random access is unchanged — but the
375
+ dictionary carries the structure shared across records, which per-record
376
+ compression cannot see. On many small, similar records (image tiles, token
377
+ sequences) this is a large win: a smooth-image set that plain zstd takes to
378
+ 7.6x compresses 16.5x with a dictionary. The dictionary is loaded once on
379
+ open and shared, lock-free, across all reader threads. The dictionary size
380
+ adapts to the training data automatically.
381
+ * Records stored with `zstd_dict` are explicitly tagged; they cannot be read
382
+ by a store that lacks the dictionary. Plain `zstd` records never use a
383
+ dictionary even if one is present.
286
384
 
287
385
  ## Persistence format
288
386
  Zrecord storage is metadata plus chunked data:
@@ -329,7 +427,7 @@ a physical address is what makes random access efficient.
329
427
  the algorithm.
330
428
 
331
429
  ```zig
332
- const Codec = enum(u8) { raw = 0, flate = 1, _ };
430
+ const Codec = enum(u8) { raw = 0, zstd = 1, zstdict = 2, _ };
333
431
  const RecordLoc = extern struct {
334
432
  offset: u32, phys_length: u32, logic_length: u32,
335
433
  chunk_id: u16, compress: Codec, _reserved: u8,
@@ -376,10 +474,12 @@ side (executed on async threads).
376
474
  beyond the limit run inline on the calling thread.
377
475
  * Shards receive contiguous blocks rather than a strided subset, keeping each
378
476
  worker's reads and writes sequential.
379
- * Each shard reuses one compressor scratch (`flate.Compress` is ~230 KiB — far
380
- too large to rebuild per record, let alone to place on a task stack).
381
- * Decompression runs in direct mode: matches are resolved against the bytes
382
- already written into the destination, so there is no window buffer.
477
+ * Each shard creates one zstd context (`ZSTD_CCtx` to write, `ZSTD_DCtx` to
478
+ read) and reuses it across every record it handles, rather than paying that
479
+ setup per record. The dictionary (`ZSTD_CDict`/`ZSTD_DDict`) is immutable, so
480
+ all shards share one, lock-free.
481
+ * Decompression writes straight into the caller's destination buffer, so there
482
+ is no intermediate copy.
383
483
 
384
484
  **4. Garbage collection.** Deletion leaves the record's bytes stranded, so space
385
485
  is reclaimed by an offline `compact` that rewrites the live records in place.
@@ -400,7 +500,7 @@ is reclaimed by an offline `compact` that rewrites the live records in place.
400
500
  that compaction cannot remove.
401
501
 
402
502
  **5. File access.**
403
- * Metadata: a 192 MiB file created at init, accessed by mmap thereafter.
503
+ * Metadata: a 256 MiB file created at init, accessed by mmap thereafter.
404
504
  * Chunk data: 4 GiB files created at init, accessed concurrently through
405
505
  `readPositionalAll`/`writePositionalAll`.
406
506