loaderx 0.7.3__tar.gz → 0.9.4__tar.gz

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