loaderx 0.5.0__py3-none-win_arm64.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- loaderx/__init__.py +5 -0
- loaderx/_lib.py +45 -0
- loaderx/_sampler.py +97 -0
- loaderx/_zrecord.py +283 -0
- loaderx/dataloader.py +98 -0
- loaderx/dataset.py +222 -0
- loaderx/lib/sampler.dll +0 -0
- loaderx/lib/zrecord.dll +0 -0
- loaderx-0.5.0.dist-info/METADATA +334 -0
- loaderx-0.5.0.dist-info/RECORD +13 -0
- loaderx-0.5.0.dist-info/WHEEL +5 -0
- loaderx-0.5.0.dist-info/licenses/LICENSE +21 -0
- loaderx-0.5.0.dist-info/top_level.txt +1 -0
loaderx/__init__.py
ADDED
loaderx/_lib.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Locate the bundled native library for the running platform.
|
|
2
|
+
|
|
3
|
+
A wheel carries the shared objects for exactly one platform, under the name
|
|
4
|
+
that platform uses (`lib*.so`, `lib*.dylib`, `*.dll`). Resolving by name here
|
|
5
|
+
keeps that detail out of the binding modules.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
LIB_DIR = Path(__file__).resolve().parent / "lib"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _filename(stem):
|
|
15
|
+
if sys.platform == "win32":
|
|
16
|
+
return f"{stem}.dll"
|
|
17
|
+
if sys.platform == "darwin":
|
|
18
|
+
return f"lib{stem}.dylib"
|
|
19
|
+
return f"lib{stem}.so"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def path(stem):
|
|
23
|
+
"""
|
|
24
|
+
Absolute path to the bundled `stem` library.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
stem (str): Library name without prefix or extension, e.g. "zrecord".
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
str: Path to pass to `ffi.dlopen`.
|
|
31
|
+
|
|
32
|
+
Raises:
|
|
33
|
+
ImportError: If the wheel carries no library for this platform.
|
|
34
|
+
"""
|
|
35
|
+
found = LIB_DIR / _filename(stem)
|
|
36
|
+
if found.is_file():
|
|
37
|
+
return str(found)
|
|
38
|
+
|
|
39
|
+
present = sorted(p.name for p in LIB_DIR.glob("*") if p.suffix in (".so", ".dylib", ".dll"))
|
|
40
|
+
raise ImportError(
|
|
41
|
+
f"loaderx has no {stem} library for {sys.platform} ({found.name} not found in {LIB_DIR}).\n"
|
|
42
|
+
f"Present: {present or 'nothing'}.\n"
|
|
43
|
+
"From a source checkout, run `zig build` to produce it; otherwise this "
|
|
44
|
+
"wheel was built for a different platform."
|
|
45
|
+
)
|
loaderx/_sampler.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import time
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
from cffi import FFI
|
|
5
|
+
|
|
6
|
+
from . import _lib
|
|
7
|
+
|
|
8
|
+
ffi = FFI()
|
|
9
|
+
|
|
10
|
+
ffi.cdef("""
|
|
11
|
+
typedef struct Sampler Sampler;
|
|
12
|
+
|
|
13
|
+
Sampler* cinit(
|
|
14
|
+
uint64_t length,
|
|
15
|
+
uint64_t batch_size,
|
|
16
|
+
uint8_t mode,
|
|
17
|
+
uint64_t seed
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
void cdeinit(Sampler* s);
|
|
21
|
+
|
|
22
|
+
void cnext(Sampler* s, uint64_t* indices_ptr, size_t indices_len);
|
|
23
|
+
""")
|
|
24
|
+
|
|
25
|
+
lib = ffi.dlopen(_lib.path("sampler"))
|
|
26
|
+
|
|
27
|
+
class Sampler:
|
|
28
|
+
class Mode:
|
|
29
|
+
SEQUENTIAL = 0
|
|
30
|
+
IID = 1
|
|
31
|
+
|
|
32
|
+
def __init__(self, length: int, batch_size: int, mode: int, seed: int = 42):
|
|
33
|
+
# sampler
|
|
34
|
+
self.sampler = lib.cinit(length, batch_size, mode, seed)
|
|
35
|
+
if self.sampler == ffi.NULL:
|
|
36
|
+
raise MemoryError("Failed to create sampler")
|
|
37
|
+
# indices
|
|
38
|
+
self.indices = np.zeros(batch_size, dtype=np.uint64)
|
|
39
|
+
|
|
40
|
+
# step
|
|
41
|
+
def next(self):
|
|
42
|
+
ptr = ffi.cast("uint64_t *", self.indices.ctypes.data)
|
|
43
|
+
lib.cnext(self.sampler, ptr, self.indices.size)
|
|
44
|
+
# return copy to avoid loss
|
|
45
|
+
return self.indices.copy()
|
|
46
|
+
|
|
47
|
+
# iterator
|
|
48
|
+
def __iter__(self):
|
|
49
|
+
return self
|
|
50
|
+
def __next__(self):
|
|
51
|
+
return self.next()
|
|
52
|
+
|
|
53
|
+
# clean
|
|
54
|
+
def __del__(self):
|
|
55
|
+
if hasattr(self, "sampler") and self.sampler != ffi.NULL:
|
|
56
|
+
lib.cdeinit(self.sampler)
|
|
57
|
+
self.sampler = ffi.NULL
|
|
58
|
+
|
|
59
|
+
def run():
|
|
60
|
+
from itertools import islice
|
|
61
|
+
length = 10
|
|
62
|
+
batch_size = 4
|
|
63
|
+
n_steps = 5
|
|
64
|
+
|
|
65
|
+
print("=== Sampler SEQUENTIAL ===")
|
|
66
|
+
sampler = Sampler(length, batch_size, Sampler.Mode.SEQUENTIAL)
|
|
67
|
+
for indices in islice(sampler, n_steps):
|
|
68
|
+
print(indices)
|
|
69
|
+
|
|
70
|
+
print("=== Sampler IID ===")
|
|
71
|
+
sampler = Sampler(length, batch_size, Sampler.Mode.IID)
|
|
72
|
+
for indices in islice(sampler, n_steps):
|
|
73
|
+
print(indices)
|
|
74
|
+
|
|
75
|
+
# Benchmark, zig-sampler speeder 3.66x
|
|
76
|
+
def bench():
|
|
77
|
+
length = 1_000_000
|
|
78
|
+
batch_size = 8192
|
|
79
|
+
n_steps = 10_000
|
|
80
|
+
|
|
81
|
+
print("=== NumPy IID ===")
|
|
82
|
+
t0 = time.time()
|
|
83
|
+
batch = np.zeros(batch_size, dtype=np.uint64)
|
|
84
|
+
for _ in range(n_steps):
|
|
85
|
+
batch[:] = np.random.randint(0, length, size=batch_size, dtype=np.uint64)
|
|
86
|
+
print(f"{(time.time() - t0)*1000:.2f} ms")
|
|
87
|
+
|
|
88
|
+
print("=== Sampler IID ===")
|
|
89
|
+
t0 = time.time()
|
|
90
|
+
sampler = Sampler(length, batch_size, Sampler.Mode.IID)
|
|
91
|
+
for _ in range(n_steps):
|
|
92
|
+
sampler.next()
|
|
93
|
+
print(f"{(time.time() - t0)*1000:.2f} ms")
|
|
94
|
+
|
|
95
|
+
if __name__ == "__main__":
|
|
96
|
+
run()
|
|
97
|
+
bench()
|
loaderx/_zrecord.py
ADDED
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
"""Thin CFFI binding over the Zig zrecord runtime.
|
|
2
|
+
|
|
3
|
+
zrecord stores opaque byte records; type interpretation is the client's job
|
|
4
|
+
(see :mod:`loaderx.dataset`). Every call below releases the GIL, so several
|
|
5
|
+
loader threads can gather concurrently.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
from cffi import FFI
|
|
10
|
+
|
|
11
|
+
from . import _lib
|
|
12
|
+
|
|
13
|
+
ffi = FFI()
|
|
14
|
+
|
|
15
|
+
ffi.cdef("""
|
|
16
|
+
typedef struct Zrecord Zrecord;
|
|
17
|
+
|
|
18
|
+
Zrecord* zr_open(const char* path_ptr, size_t path_len, int32_t* code);
|
|
19
|
+
void zr_close(Zrecord* zr);
|
|
20
|
+
|
|
21
|
+
uint32_t zr_len(Zrecord* zr);
|
|
22
|
+
int32_t zr_sync(Zrecord* zr);
|
|
23
|
+
uint32_t zr_max_record_len(void);
|
|
24
|
+
|
|
25
|
+
int32_t zr_lengths(Zrecord* zr, const uint64_t* idxs, size_t n, uint32_t* out);
|
|
26
|
+
|
|
27
|
+
int32_t zr_gather(Zrecord* zr, const uint64_t* idxs, size_t n,
|
|
28
|
+
uint8_t* dst, size_t dst_len, size_t stride);
|
|
29
|
+
int32_t zr_gather_var(Zrecord* zr, const uint64_t* idxs, size_t n,
|
|
30
|
+
uint8_t* dst, size_t dst_len, const uint64_t* offsets);
|
|
31
|
+
|
|
32
|
+
int64_t zr_append(Zrecord* zr, const uint8_t* src, size_t src_len, size_t n,
|
|
33
|
+
size_t stride, size_t item_len, uint8_t codec);
|
|
34
|
+
int64_t zr_append_var(Zrecord* zr, const uint8_t* src, size_t src_len, size_t n,
|
|
35
|
+
const uint64_t* offsets, const uint32_t* lens, uint8_t codec);
|
|
36
|
+
|
|
37
|
+
int32_t zr_delete(Zrecord* zr, const uint64_t* idxs, size_t n);
|
|
38
|
+
int32_t zr_compact(Zrecord* zr);
|
|
39
|
+
void zr_stats(Zrecord* zr, uint32_t* records, uint64_t* live_bytes, uint64_t* chunk_bytes);
|
|
40
|
+
""")
|
|
41
|
+
|
|
42
|
+
lib = ffi.dlopen(_lib.path("zrecord"))
|
|
43
|
+
|
|
44
|
+
# Compression tags. Must match `Codec` in src/zrecord.zig.
|
|
45
|
+
CODECS = {"raw": 0, "flate": 1}
|
|
46
|
+
|
|
47
|
+
MAX_RECORD_LEN = lib.zr_max_record_len()
|
|
48
|
+
|
|
49
|
+
# Must match `Code` in src/zrecord.zig.
|
|
50
|
+
_ERRORS = {
|
|
51
|
+
-1: "out of memory",
|
|
52
|
+
-2: "file I/O failed",
|
|
53
|
+
-3: "index out of range",
|
|
54
|
+
-4: f"record exceeds the {MAX_RECORD_LEN} byte limit",
|
|
55
|
+
-5: "store is full",
|
|
56
|
+
-6: "buffer too small",
|
|
57
|
+
-7: "stored record failed to decode",
|
|
58
|
+
-8: "not a zrecord store, or written by a newer version",
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class ZrecordError(RuntimeError):
|
|
63
|
+
def __init__(self, code):
|
|
64
|
+
super().__init__(_ERRORS.get(code, f"unknown zrecord error {code}"))
|
|
65
|
+
self.code = code
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _check(code):
|
|
69
|
+
if code < 0:
|
|
70
|
+
raise ZrecordError(code)
|
|
71
|
+
return code
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _u64(array):
|
|
75
|
+
"""View `array` as a C-contiguous uint64 buffer, copying only if needed."""
|
|
76
|
+
return np.ascontiguousarray(array, dtype=np.uint64)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _ptr(ctype, array):
|
|
80
|
+
return ffi.cast(ctype, array.ctypes.data)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _flat(array, name):
|
|
84
|
+
"""Assert `array` is safe to hand over as a flat byte range."""
|
|
85
|
+
if not array.flags.c_contiguous:
|
|
86
|
+
raise ValueError(f"{name} must be C-contiguous")
|
|
87
|
+
return array
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class Zrecord:
|
|
91
|
+
"""A zrecord store: an unordered, append-only collection of byte records.
|
|
92
|
+
|
|
93
|
+
Indices are stable — once assigned, a record's index never changes meaning.
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
def __init__(self, path, codec="flate"):
|
|
97
|
+
"""
|
|
98
|
+
Open (or create) the store at `path`.
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
path (str | Path): Directory holding the store.
|
|
102
|
+
codec (str): Default compression for `append`; "raw" or "flate".
|
|
103
|
+
"""
|
|
104
|
+
if codec not in CODECS:
|
|
105
|
+
raise ValueError(f"unknown codec {codec!r}, expected one of {sorted(CODECS)}")
|
|
106
|
+
|
|
107
|
+
# cffi converts `bytes` to `const char*` and keeps it alive for the call.
|
|
108
|
+
raw = str(path).encode()
|
|
109
|
+
code = ffi.new("int32_t*", -2)
|
|
110
|
+
self._zr = lib.zr_open(raw, len(raw), code)
|
|
111
|
+
if self._zr == ffi.NULL:
|
|
112
|
+
raise ZrecordError(code[0])
|
|
113
|
+
self.codec = codec
|
|
114
|
+
|
|
115
|
+
def __len__(self):
|
|
116
|
+
return lib.zr_len(self._zr)
|
|
117
|
+
|
|
118
|
+
def sync(self):
|
|
119
|
+
"""Flush chunk data and the record table to disk."""
|
|
120
|
+
_check(lib.zr_sync(self._zr))
|
|
121
|
+
|
|
122
|
+
def lengths(self, idxs):
|
|
123
|
+
"""
|
|
124
|
+
Return the logical (uncompressed) size of each requested record.
|
|
125
|
+
|
|
126
|
+
Args:
|
|
127
|
+
idxs (array-like of int): Record indices.
|
|
128
|
+
|
|
129
|
+
Returns:
|
|
130
|
+
np.ndarray: uint32 sizes in bytes.
|
|
131
|
+
"""
|
|
132
|
+
idxs = _u64(idxs)
|
|
133
|
+
out = np.empty(idxs.size, dtype=np.uint32)
|
|
134
|
+
_check(lib.zr_lengths(
|
|
135
|
+
self._zr, _ptr("const uint64_t*", idxs), idxs.size, _ptr("uint32_t*", out),
|
|
136
|
+
))
|
|
137
|
+
return out
|
|
138
|
+
|
|
139
|
+
def gather(self, idxs, dst, stride):
|
|
140
|
+
"""
|
|
141
|
+
Decompress records `idxs` straight into `dst`, one every `stride` bytes.
|
|
142
|
+
|
|
143
|
+
`dst` is written in place — no intermediate buffer is allocated.
|
|
144
|
+
|
|
145
|
+
Args:
|
|
146
|
+
idxs (np.ndarray): uint64 record indices.
|
|
147
|
+
dst (np.ndarray): C-contiguous destination buffer.
|
|
148
|
+
stride (int): Byte distance between consecutive record slots.
|
|
149
|
+
"""
|
|
150
|
+
idxs = _u64(idxs)
|
|
151
|
+
_flat(dst, "dst")
|
|
152
|
+
_check(lib.zr_gather(
|
|
153
|
+
self._zr, _ptr("const uint64_t*", idxs), idxs.size,
|
|
154
|
+
_ptr("uint8_t*", dst), dst.nbytes, stride,
|
|
155
|
+
))
|
|
156
|
+
|
|
157
|
+
def gather_var(self, idxs, dst, offsets):
|
|
158
|
+
"""
|
|
159
|
+
Decompress ragged records `idxs` into `dst` at explicit byte `offsets`.
|
|
160
|
+
|
|
161
|
+
Args:
|
|
162
|
+
idxs (np.ndarray): uint64 record indices.
|
|
163
|
+
dst (np.ndarray): C-contiguous destination buffer.
|
|
164
|
+
offsets (np.ndarray): uint64 byte offset of each record within `dst`.
|
|
165
|
+
"""
|
|
166
|
+
idxs = _u64(idxs)
|
|
167
|
+
offsets = _u64(offsets)
|
|
168
|
+
_flat(dst, "dst")
|
|
169
|
+
_check(lib.zr_gather_var(
|
|
170
|
+
self._zr, _ptr("const uint64_t*", idxs), idxs.size,
|
|
171
|
+
_ptr("uint8_t*", dst), dst.nbytes, _ptr("const uint64_t*", offsets),
|
|
172
|
+
))
|
|
173
|
+
|
|
174
|
+
def append(self, src, n, item_len, codec=None):
|
|
175
|
+
"""
|
|
176
|
+
Append `n` equally sized records taken from `src`.
|
|
177
|
+
|
|
178
|
+
Args:
|
|
179
|
+
src (np.ndarray): C-contiguous buffer of `n * item_len` bytes.
|
|
180
|
+
n (int): Number of records.
|
|
181
|
+
item_len (int): Size of each record in bytes.
|
|
182
|
+
codec (str, optional): Override the store's default compression.
|
|
183
|
+
|
|
184
|
+
Returns:
|
|
185
|
+
int: Index assigned to the first appended record.
|
|
186
|
+
"""
|
|
187
|
+
tag = CODECS[codec or self.codec]
|
|
188
|
+
_flat(src, "src")
|
|
189
|
+
return _check(lib.zr_append(
|
|
190
|
+
self._zr, _ptr("const uint8_t*", src), src.nbytes,
|
|
191
|
+
n, item_len, item_len, tag,
|
|
192
|
+
))
|
|
193
|
+
|
|
194
|
+
def append_var(self, src, offsets, lens, codec=None):
|
|
195
|
+
"""
|
|
196
|
+
Append ragged records; record `i` is `src[offsets[i]:][:lens[i]]`.
|
|
197
|
+
|
|
198
|
+
Args:
|
|
199
|
+
src (np.ndarray): C-contiguous source buffer.
|
|
200
|
+
offsets (np.ndarray): uint64 byte offset of each record.
|
|
201
|
+
lens (np.ndarray): uint32 byte length of each record.
|
|
202
|
+
codec (str, optional): Override the store's default compression.
|
|
203
|
+
|
|
204
|
+
Returns:
|
|
205
|
+
int: Index assigned to the first appended record.
|
|
206
|
+
"""
|
|
207
|
+
tag = CODECS[codec or self.codec]
|
|
208
|
+
_flat(src, "src")
|
|
209
|
+
offsets = _u64(offsets)
|
|
210
|
+
lens = np.ascontiguousarray(lens, dtype=np.uint32)
|
|
211
|
+
return _check(lib.zr_append_var(
|
|
212
|
+
self._zr, _ptr("const uint8_t*", src), src.nbytes, lens.size,
|
|
213
|
+
_ptr("const uint64_t*", offsets), _ptr("const uint32_t*", lens), tag,
|
|
214
|
+
))
|
|
215
|
+
|
|
216
|
+
def delete(self, idxs):
|
|
217
|
+
"""
|
|
218
|
+
Remove records, keeping the index space dense.
|
|
219
|
+
|
|
220
|
+
The tail of the table is swapped into each hole, so afterwards the live
|
|
221
|
+
records are exactly `0..len(self)`. The trade-off is that deletion moves
|
|
222
|
+
records: an index naming a survivor may name a different survivor
|
|
223
|
+
afterwards. Indices are stable only while nothing is deleted.
|
|
224
|
+
|
|
225
|
+
Only the table changes. The removed bytes stay on disk until `compact`.
|
|
226
|
+
|
|
227
|
+
Must not run concurrently with reads.
|
|
228
|
+
|
|
229
|
+
Args:
|
|
230
|
+
idxs (array-like of int): Record indices to remove. Duplicates and
|
|
231
|
+
any order are fine.
|
|
232
|
+
"""
|
|
233
|
+
idxs = _u64(idxs)
|
|
234
|
+
_check(lib.zr_delete(self._zr, _ptr("const uint64_t*", idxs), idxs.size))
|
|
235
|
+
|
|
236
|
+
def compact(self):
|
|
237
|
+
"""
|
|
238
|
+
Reclaim the space left behind by `delete`, in place.
|
|
239
|
+
|
|
240
|
+
Offline operation: nothing else may touch the store while it runs. Safe
|
|
241
|
+
to interrupt — records are moved one at a time and the table is updated
|
|
242
|
+
as each lands, so re-running finishes the job.
|
|
243
|
+
"""
|
|
244
|
+
_check(lib.zr_compact(self._zr))
|
|
245
|
+
|
|
246
|
+
def stats(self):
|
|
247
|
+
"""
|
|
248
|
+
Report storage usage.
|
|
249
|
+
|
|
250
|
+
Returns:
|
|
251
|
+
dict: `records`, `live_bytes` (what the live records occupy),
|
|
252
|
+
`chunk_bytes` (how far the chunk files have grown) and
|
|
253
|
+
`reclaimable` (an upper bound on what `compact` would free —
|
|
254
|
+
it also counts the chunk-boundary slack compaction cannot
|
|
255
|
+
remove).
|
|
256
|
+
"""
|
|
257
|
+
records = ffi.new("uint32_t*")
|
|
258
|
+
live = ffi.new("uint64_t*")
|
|
259
|
+
chunk = ffi.new("uint64_t*")
|
|
260
|
+
lib.zr_stats(self._zr, records, live, chunk)
|
|
261
|
+
return {
|
|
262
|
+
"records": records[0],
|
|
263
|
+
"live_bytes": live[0],
|
|
264
|
+
"chunk_bytes": chunk[0],
|
|
265
|
+
"reclaimable": chunk[0] - live[0],
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
# statement
|
|
269
|
+
def __enter__(self):
|
|
270
|
+
return self
|
|
271
|
+
|
|
272
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
273
|
+
self.close()
|
|
274
|
+
|
|
275
|
+
# close
|
|
276
|
+
def close(self):
|
|
277
|
+
zr = getattr(self, "_zr", ffi.NULL)
|
|
278
|
+
if zr != ffi.NULL:
|
|
279
|
+
lib.zr_close(zr)
|
|
280
|
+
self._zr = ffi.NULL
|
|
281
|
+
|
|
282
|
+
def __del__(self):
|
|
283
|
+
self.close()
|
loaderx/dataloader.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import threading
|
|
2
|
+
import numpy as np
|
|
3
|
+
from queue import Queue
|
|
4
|
+
|
|
5
|
+
from ._sampler import Sampler
|
|
6
|
+
|
|
7
|
+
class DataLoader:
|
|
8
|
+
def __init__(self, dataset, labelset, num_workers=4, batch_size=256, prefetch_size=4, mode=1, seed=42, transform=(lambda x: x)):
|
|
9
|
+
"""
|
|
10
|
+
Initialize a DataLoader.
|
|
11
|
+
|
|
12
|
+
Args:
|
|
13
|
+
dataset (BaseDataset): The dataset to load from.
|
|
14
|
+
labelset (BaseDataset): The labelset to load from.
|
|
15
|
+
batch_size (int, optional): The batch size to use. Defaults to 256.
|
|
16
|
+
prefetch_size (int, optional): The number of batches to prefetch. Defaults to 8.
|
|
17
|
+
mode (int, optional):
|
|
18
|
+
seed (int, optional): The seed to use for shuffling. Defaults to 42.
|
|
19
|
+
transform (callable, optional): A function to apply to the data and label. Defaults to lambda x: x.
|
|
20
|
+
|
|
21
|
+
Raises:
|
|
22
|
+
ValueError: If the dataset and labelset have different lengths.
|
|
23
|
+
"""
|
|
24
|
+
self.dataset = dataset
|
|
25
|
+
self.labelset = labelset
|
|
26
|
+
if len(dataset) != len(labelset):
|
|
27
|
+
raise ValueError("dataset and labelset must have the same length")
|
|
28
|
+
|
|
29
|
+
self.indices = Queue(maxsize=prefetch_size)
|
|
30
|
+
self.batches = Queue(maxsize=prefetch_size)
|
|
31
|
+
|
|
32
|
+
self.sampler = Sampler(len(dataset), batch_size, mode, seed)
|
|
33
|
+
|
|
34
|
+
self.stop_signal = threading.Event()
|
|
35
|
+
|
|
36
|
+
self.threads = [
|
|
37
|
+
threading.Thread(target=self._sampler),
|
|
38
|
+
*[threading.Thread(target=self._prefetch, args=(transform, )) for _ in range(num_workers)]
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
for thread in self.threads:
|
|
42
|
+
thread.daemon = True
|
|
43
|
+
thread.start()
|
|
44
|
+
|
|
45
|
+
def _sampler(self):
|
|
46
|
+
"""
|
|
47
|
+
Sample indices from the dataset and put them into the index queue.
|
|
48
|
+
"""
|
|
49
|
+
while not self.stop_signal.is_set():
|
|
50
|
+
self.indices.put(self.sampler.next())
|
|
51
|
+
|
|
52
|
+
def _prefetch(self, transform):
|
|
53
|
+
"""
|
|
54
|
+
Fetch the data and label from the dataset and labelset based on the indices
|
|
55
|
+
in the index queue and put them into the batches queue.
|
|
56
|
+
"""
|
|
57
|
+
while not self.stop_signal.is_set():
|
|
58
|
+
idxs = self.indices.get()
|
|
59
|
+
data, label = transform((self.dataset.__getitems__(idxs), self.labelset.__getitems__(idxs)))
|
|
60
|
+
self.batches.put({'data': data, 'label': label})
|
|
61
|
+
|
|
62
|
+
def __len__(self):
|
|
63
|
+
"""
|
|
64
|
+
Raises a TypeError since an external loader has no length.
|
|
65
|
+
"""
|
|
66
|
+
raise TypeError("Eternal loader has no length.")
|
|
67
|
+
|
|
68
|
+
# iterator
|
|
69
|
+
def __iter__(self):
|
|
70
|
+
return self
|
|
71
|
+
def __next__(self):
|
|
72
|
+
# debug: monitor bottlenecks
|
|
73
|
+
# print(self.indices.qsize(), self.batches.qsize())
|
|
74
|
+
return self.batches.get()
|
|
75
|
+
|
|
76
|
+
# statement
|
|
77
|
+
def __enter__(self):
|
|
78
|
+
return self
|
|
79
|
+
|
|
80
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
81
|
+
self.close()
|
|
82
|
+
|
|
83
|
+
# close
|
|
84
|
+
def close(self):
|
|
85
|
+
self.stop_signal.set()
|
|
86
|
+
|
|
87
|
+
for queue in [self.indices, self.batches]:
|
|
88
|
+
while not queue.empty():
|
|
89
|
+
try:
|
|
90
|
+
queue.get_nowait()
|
|
91
|
+
except:
|
|
92
|
+
break
|
|
93
|
+
|
|
94
|
+
for thread in self.threads:
|
|
95
|
+
thread.join()
|
|
96
|
+
|
|
97
|
+
def __del__(self):
|
|
98
|
+
self.close()
|
loaderx/dataset.py
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""NumPy-shaped view over a zrecord store.
|
|
2
|
+
|
|
3
|
+
zrecord returns raw bytes, so the tensor type lives here: a small `schema.json`
|
|
4
|
+
inside the store records the per-sample dtype and shape. Reads decompress
|
|
5
|
+
directly into the freshly allocated output array — the bytes are never staged
|
|
6
|
+
in a temporary buffer.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
import numpy as np
|
|
13
|
+
|
|
14
|
+
from ._zrecord import MAX_RECORD_LEN, CODECS, Zrecord
|
|
15
|
+
|
|
16
|
+
SCHEMA = "schema.json"
|
|
17
|
+
FORMAT = "loaderx-zrecord"
|
|
18
|
+
|
|
19
|
+
# Bytes of source data handed to a single append call. Caps the compression
|
|
20
|
+
# arena the Zig side allocates while still giving every worker plenty to chew on.
|
|
21
|
+
WRITE_CHUNK_BYTES = 64 << 20
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Dataset:
|
|
25
|
+
def __init__(self, path):
|
|
26
|
+
"""
|
|
27
|
+
Open a dataset.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
path (str | Path): Directory of a zrecord store written by
|
|
31
|
+
`Dataset.write`.
|
|
32
|
+
|
|
33
|
+
Raises:
|
|
34
|
+
FileNotFoundError: If `path` holds no loaderx schema.
|
|
35
|
+
"""
|
|
36
|
+
self.path = Path(path)
|
|
37
|
+
|
|
38
|
+
schema_path = self.path / SCHEMA
|
|
39
|
+
if not schema_path.is_file():
|
|
40
|
+
raise FileNotFoundError(
|
|
41
|
+
f"{schema_path} not found; {self.path} is not a loaderx dataset"
|
|
42
|
+
)
|
|
43
|
+
schema = json.loads(schema_path.read_text())
|
|
44
|
+
if schema.get("format") != FORMAT:
|
|
45
|
+
raise ValueError(f"{schema_path} is not a {FORMAT} schema")
|
|
46
|
+
|
|
47
|
+
self.dtype = np.dtype(schema["dtype"])
|
|
48
|
+
self.shape = tuple(schema["shape"])
|
|
49
|
+
self.codec = schema["codec"]
|
|
50
|
+
|
|
51
|
+
self._itemsize = int(np.prod(self.shape, dtype=np.int64)) * self.dtype.itemsize
|
|
52
|
+
self._store = Zrecord(self.path, codec=self.codec)
|
|
53
|
+
|
|
54
|
+
def __len__(self):
|
|
55
|
+
"""
|
|
56
|
+
Return the number of samples in the dataset.
|
|
57
|
+
|
|
58
|
+
Returns:
|
|
59
|
+
int: The number of samples in the dataset.
|
|
60
|
+
"""
|
|
61
|
+
return len(self._store)
|
|
62
|
+
|
|
63
|
+
def __getitem__(self, idx):
|
|
64
|
+
"""
|
|
65
|
+
Get the item at index idx from the dataset.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
idx (int | slice | array-like): Index, slice or fancy index.
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
np.ndarray: A single sample for an integer index, otherwise a
|
|
72
|
+
stacked batch.
|
|
73
|
+
"""
|
|
74
|
+
if isinstance(idx, (int, np.integer)):
|
|
75
|
+
if idx < 0:
|
|
76
|
+
idx += len(self)
|
|
77
|
+
return self.__getitems__(np.array([idx], dtype=np.uint64))[0]
|
|
78
|
+
if isinstance(idx, slice):
|
|
79
|
+
idx = np.arange(*idx.indices(len(self)))
|
|
80
|
+
return self.__getitems__(idx)
|
|
81
|
+
|
|
82
|
+
def __getitems__(self, idxs):
|
|
83
|
+
"""
|
|
84
|
+
Get the items at index idxs from the dataset.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
idxs (array-like of int): indices of the items to retrieve.
|
|
88
|
+
|
|
89
|
+
Returns:
|
|
90
|
+
np.ndarray: The items at index idxs, shaped `(len(idxs), *self.shape)`.
|
|
91
|
+
"""
|
|
92
|
+
idxs = np.asarray(idxs)
|
|
93
|
+
# A boolean mask would otherwise cast to a run of 0s and 1s.
|
|
94
|
+
if idxs.dtype == np.bool_:
|
|
95
|
+
idxs = np.flatnonzero(idxs)
|
|
96
|
+
idxs = np.ascontiguousarray(idxs, dtype=np.uint64)
|
|
97
|
+
out = np.empty((idxs.size, *self.shape), dtype=self.dtype)
|
|
98
|
+
self._store.gather(idxs, out, self._itemsize)
|
|
99
|
+
return out
|
|
100
|
+
|
|
101
|
+
# write
|
|
102
|
+
|
|
103
|
+
@classmethod
|
|
104
|
+
def write(cls, path, array, codec="flate", chunk_bytes=WRITE_CHUNK_BYTES):
|
|
105
|
+
"""
|
|
106
|
+
Persist `array` as a zrecord dataset, one record per leading-axis slice.
|
|
107
|
+
|
|
108
|
+
Streams through `array` in bounded chunks, so a `np.load(...,
|
|
109
|
+
mmap_mode='r')` array converts without being read into memory at once.
|
|
110
|
+
|
|
111
|
+
Args:
|
|
112
|
+
path (str | Path): Destination directory. Appends if it already
|
|
113
|
+
holds a compatible dataset.
|
|
114
|
+
array (array-like): Samples stacked along axis 0. A 1-D array is a
|
|
115
|
+
dataset of scalars, which is what a label set usually is.
|
|
116
|
+
codec (str): "flate" for transparent compression, "raw" to store
|
|
117
|
+
verbatim. Records that fail to shrink are stored raw regardless.
|
|
118
|
+
chunk_bytes (int): Upper bound on the bytes submitted per call.
|
|
119
|
+
|
|
120
|
+
Returns:
|
|
121
|
+
Dataset: The dataset, opened for reading.
|
|
122
|
+
|
|
123
|
+
Raises:
|
|
124
|
+
ValueError: If a sample exceeds the zrecord record size limit, or
|
|
125
|
+
the array does not match an existing dataset at `path`.
|
|
126
|
+
"""
|
|
127
|
+
if codec not in CODECS:
|
|
128
|
+
raise ValueError(f"unknown codec {codec!r}, expected one of {sorted(CODECS)}")
|
|
129
|
+
|
|
130
|
+
array = np.asanyarray(array)
|
|
131
|
+
if array.ndim < 1:
|
|
132
|
+
raise ValueError("array must have a sample axis")
|
|
133
|
+
|
|
134
|
+
dtype = np.dtype(array.dtype)
|
|
135
|
+
shape = tuple(int(d) for d in array.shape[1:])
|
|
136
|
+
itemsize = int(np.prod(shape, dtype=np.int64)) * dtype.itemsize
|
|
137
|
+
if itemsize > MAX_RECORD_LEN:
|
|
138
|
+
raise ValueError(
|
|
139
|
+
f"sample of {itemsize} bytes exceeds the {MAX_RECORD_LEN} byte record limit"
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
path = Path(path)
|
|
143
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
144
|
+
|
|
145
|
+
schema = {
|
|
146
|
+
"format": FORMAT,
|
|
147
|
+
"version": 1,
|
|
148
|
+
"dtype": dtype.str,
|
|
149
|
+
"shape": list(shape),
|
|
150
|
+
"codec": codec,
|
|
151
|
+
}
|
|
152
|
+
schema_path = path / SCHEMA
|
|
153
|
+
if schema_path.is_file():
|
|
154
|
+
existing = json.loads(schema_path.read_text())
|
|
155
|
+
if (existing.get("dtype"), tuple(existing.get("shape", ()))) != (dtype.str, shape):
|
|
156
|
+
raise ValueError(
|
|
157
|
+
f"{path} holds {existing.get('dtype')}{tuple(existing.get('shape', ()))} "
|
|
158
|
+
f"records, cannot append {dtype.str}{shape}"
|
|
159
|
+
)
|
|
160
|
+
schema = existing
|
|
161
|
+
else:
|
|
162
|
+
schema_path.write_text(json.dumps(schema, indent=2) + "\n")
|
|
163
|
+
|
|
164
|
+
step = max(1, chunk_bytes // max(itemsize, 1))
|
|
165
|
+
with Zrecord(path, codec=schema["codec"]) as store:
|
|
166
|
+
for start in range(0, len(array), step):
|
|
167
|
+
block = np.ascontiguousarray(array[start:start + step], dtype=dtype)
|
|
168
|
+
store.append(block, len(block), itemsize)
|
|
169
|
+
store.sync()
|
|
170
|
+
|
|
171
|
+
return cls(path)
|
|
172
|
+
|
|
173
|
+
def delete(self, idxs):
|
|
174
|
+
"""
|
|
175
|
+
Remove samples, keeping the index space dense.
|
|
176
|
+
|
|
177
|
+
`len(self)` shrinks and the survivors stay addressable as
|
|
178
|
+
`0..len(self)`, which is what the sampler assumes. The tail of the
|
|
179
|
+
dataset is swapped into each hole, so surviving samples may change
|
|
180
|
+
index — hold no indices across a delete.
|
|
181
|
+
|
|
182
|
+
The removed bytes stay on disk until `compact`. Must not run
|
|
183
|
+
concurrently with reads, so do not call it while a `DataLoader` is
|
|
184
|
+
running over this dataset.
|
|
185
|
+
|
|
186
|
+
Args:
|
|
187
|
+
idxs (array-like of int): Indices to remove.
|
|
188
|
+
"""
|
|
189
|
+
self._store.delete(idxs)
|
|
190
|
+
|
|
191
|
+
def compact(self):
|
|
192
|
+
"""
|
|
193
|
+
Reclaim the space left behind by `delete`.
|
|
194
|
+
|
|
195
|
+
Offline operation: nothing else may touch the dataset while it runs.
|
|
196
|
+
"""
|
|
197
|
+
self._store.compact()
|
|
198
|
+
|
|
199
|
+
def stats(self):
|
|
200
|
+
"""
|
|
201
|
+
Report storage usage; see `loaderx.Zrecord.stats`.
|
|
202
|
+
|
|
203
|
+
Returns:
|
|
204
|
+
dict: record count and byte counters, including `reclaimable`.
|
|
205
|
+
"""
|
|
206
|
+
return self._store.stats()
|
|
207
|
+
|
|
208
|
+
# statement
|
|
209
|
+
|
|
210
|
+
def __enter__(self):
|
|
211
|
+
return self
|
|
212
|
+
|
|
213
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
214
|
+
self.close()
|
|
215
|
+
|
|
216
|
+
# close
|
|
217
|
+
|
|
218
|
+
def close(self):
|
|
219
|
+
store = getattr(self, "_store", None)
|
|
220
|
+
if store is not None:
|
|
221
|
+
store.close()
|
|
222
|
+
self._store = None
|
loaderx/lib/sampler.dll
ADDED
|
Binary file
|
loaderx/lib/zrecord.dll
ADDED
|
Binary file
|
|
@@ -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,13 @@
|
|
|
1
|
+
loaderx/__init__.py,sha256=g-tkCE23AVFH3E1-Wy3Gm4r6-m9GTIzy1UlN7pQQAoQ,131
|
|
2
|
+
loaderx/_lib.py,sha256=kj4ZoHy_U6kU5KVjZQeyfPu8dmEl7diWLoldUhZZaoo,1343
|
|
3
|
+
loaderx/_sampler.py,sha256=4ImeP_RBysfgsEmz118i0e84TJNisqoL28bKR74Tmfs,2370
|
|
4
|
+
loaderx/_zrecord.py,sha256=PKW0IGab-s5EN1LJqIXV7RGBW_M47tDJXEBEQH1rJQQ,9334
|
|
5
|
+
loaderx/dataloader.py,sha256=dmj-86YFxLualASdU28QPDSoidNer6PjVULSG72mPuM,3229
|
|
6
|
+
loaderx/dataset.py,sha256=IyZXH4SLWh715vqDGwsXzyMgXNkmeJN6mOdgEZi-QgA,7442
|
|
7
|
+
loaderx/lib/sampler.dll,sha256=KdCUyy4cQ8ReiPO_irLE1jYzMp372aHyNnBRiKU_5WU,210432
|
|
8
|
+
loaderx/lib/zrecord.dll,sha256=t8lGwrDO8FnwEC7_rEt5yNltOzxWLbZBnIcXgb_BFFk,776704
|
|
9
|
+
loaderx-0.5.0.dist-info/licenses/LICENSE,sha256=6TzYUKn7HWuu0ug9ZepYbKY7s1LOh43uBNWIN6i7nsI,1075
|
|
10
|
+
loaderx-0.5.0.dist-info/METADATA,sha256=k4MF7ux4SVR_dPWhRpdZQDC5fJ4kWY-Y0pIrSQMx1Wg,15394
|
|
11
|
+
loaderx-0.5.0.dist-info/WHEEL,sha256=84gBEa4fGSDR6CJj9KMeLIZ46AIp14gh61YGBn-qQNM,97
|
|
12
|
+
loaderx-0.5.0.dist-info/top_level.txt,sha256=g8WeEbc7sZyfT8dsWS7dJy-Q-W1_PVlG4bNkPWNmQfM,8
|
|
13
|
+
loaderx-0.5.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 EOELAB AI Research
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
loaderx
|