Upload src/xscript/data/loader.py with huggingface_hub
Browse files- src/xscript/data/loader.py +176 -0
src/xscript/data/loader.py
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Deterministic streaming loader over packed uint16 shards.
|
| 2 |
+
|
| 3 |
+
- One PackedStream per language: contiguous (seq_len+1)-token windows,
|
| 4 |
+
epoch-level window permutation (seeded), transparent epoch rollover with
|
| 5 |
+
epoch counting (matters for AR, whose pool may be smaller than the budget).
|
| 6 |
+
- MixedStream: bilingual token-level mixing. For every global sample slot a
|
| 7 |
+
stateless hash of (seed, slot) picks the language, so the mixture is exactly
|
| 8 |
+
reproducible, independent of world size, and resumable from a slot counter.
|
| 9 |
+
|
| 10 |
+
Mixing mode (thesis decision, configurable in the run config):
|
| 11 |
+
token-level p=0.5 matches ATLAS's compute-matched framing: each language
|
| 12 |
+
contributes half the training *tokens*; under a starved tokenizer the
|
| 13 |
+
cross-script partner therefore sees less *content* -- that is exactly the
|
| 14 |
+
effect under audit. `content` mode instead sets p from the byte premiums so
|
| 15 |
+
content is matched and token counts differ.
|
| 16 |
+
"""
|
| 17 |
+
import json
|
| 18 |
+
from pathlib import Path
|
| 19 |
+
|
| 20 |
+
import numpy as np
|
| 21 |
+
|
| 22 |
+
from ..paths import shard_dir
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def _splitmix64(x: int) -> int:
|
| 26 |
+
x = (x + 0x9E3779B97F4A7C15) & 0xFFFFFFFFFFFFFFFF
|
| 27 |
+
z = x
|
| 28 |
+
z = ((z ^ (z >> 30)) * 0xBF58476D1CE4E5B9) & 0xFFFFFFFFFFFFFFFF
|
| 29 |
+
z = ((z ^ (z >> 27)) * 0x94D049BB133111EB) & 0xFFFFFFFFFFFFFFFF
|
| 30 |
+
return z ^ (z >> 31)
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
class PackedStream:
|
| 34 |
+
def __init__(self, lang: str, tok_name: str, seq_len: int, seed: int):
|
| 35 |
+
self.lang, self.seq_len, self.seed = lang, seq_len, seed
|
| 36 |
+
d = shard_dir(lang, tok_name)
|
| 37 |
+
index = json.loads((d / "index.json").read_text())
|
| 38 |
+
# Pool checkpoints can create complete but empty zstd shards. Packing
|
| 39 |
+
# those historically left zero-token .bin entries in index.json; mmap
|
| 40 |
+
# cannot open a zero-byte file when a window crosses that boundary.
|
| 41 |
+
# They carry no data, so filter them losslessly at load time.
|
| 42 |
+
names = [name for name in sorted(index) if index[name] > 0]
|
| 43 |
+
self.paths = [d / name for name in names]
|
| 44 |
+
self.lens = np.array([index[name] for name in names], dtype=np.int64)
|
| 45 |
+
self.cum = np.concatenate([[0], np.cumsum(self.lens)])
|
| 46 |
+
self.total_tokens = int(self.cum[-1])
|
| 47 |
+
self.n_windows = self.total_tokens // (seq_len + 1)
|
| 48 |
+
if self.n_windows == 0:
|
| 49 |
+
raise RuntimeError(f"shards for {lang}/{tok_name} too small")
|
| 50 |
+
self._mm = [None] * len(self.paths)
|
| 51 |
+
self._epoch = -1
|
| 52 |
+
self._perm = None
|
| 53 |
+
self.consumed = 0 # windows consumed (resume state)
|
| 54 |
+
|
| 55 |
+
def _memmap(self, i: int):
|
| 56 |
+
if self._mm[i] is None:
|
| 57 |
+
self._mm[i] = np.memmap(self.paths[i], dtype=np.uint16, mode="r")
|
| 58 |
+
return self._mm[i]
|
| 59 |
+
|
| 60 |
+
def _window(self, w: int) -> np.ndarray:
|
| 61 |
+
start = w * (self.seq_len + 1)
|
| 62 |
+
i = int(np.searchsorted(self.cum, start, side="right") - 1)
|
| 63 |
+
out = np.empty(self.seq_len + 1, dtype=np.uint16)
|
| 64 |
+
filled = 0
|
| 65 |
+
while filled < self.seq_len + 1:
|
| 66 |
+
off = start + filled - self.cum[i]
|
| 67 |
+
take = int(min(self.lens[i] - off, self.seq_len + 1 - filled))
|
| 68 |
+
out[filled:filled + take] = self._memmap(i)[off:off + take]
|
| 69 |
+
filled += take
|
| 70 |
+
i += 1
|
| 71 |
+
return out
|
| 72 |
+
|
| 73 |
+
def get(self, k: int) -> np.ndarray:
|
| 74 |
+
"""k-th window in the global consumption order (deterministic)."""
|
| 75 |
+
epoch, pos = divmod(k, self.n_windows)
|
| 76 |
+
if epoch != self._epoch:
|
| 77 |
+
rng = np.random.default_rng([self.seed, epoch, 0xDA7A])
|
| 78 |
+
self._perm = rng.permutation(self.n_windows)
|
| 79 |
+
self._epoch = epoch
|
| 80 |
+
return self._window(int(self._perm[pos]))
|
| 81 |
+
|
| 82 |
+
def next(self) -> np.ndarray:
|
| 83 |
+
x = self.get(self.consumed)
|
| 84 |
+
self.consumed += 1
|
| 85 |
+
return x
|
| 86 |
+
|
| 87 |
+
@property
|
| 88 |
+
def epochs(self) -> float:
|
| 89 |
+
return self.consumed / self.n_windows
|
| 90 |
+
|
| 91 |
+
def state_dict(self):
|
| 92 |
+
return {"consumed": self.consumed}
|
| 93 |
+
|
| 94 |
+
def load_state_dict(self, s):
|
| 95 |
+
self.consumed = s["consumed"]
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
class MixedStream:
|
| 99 |
+
"""Token-level language mixing across one or two PackedStreams."""
|
| 100 |
+
|
| 101 |
+
def __init__(self, langs: list[str], tok_name: str, seq_len: int, seed: int,
|
| 102 |
+
probs: list[float] | None = None):
|
| 103 |
+
self.langs = langs
|
| 104 |
+
self.streams = {l: PackedStream(l, tok_name, seq_len, seed + j)
|
| 105 |
+
for j, l in enumerate(langs)}
|
| 106 |
+
self.probs = probs or [1.0 / len(langs)] * len(langs)
|
| 107 |
+
assert abs(sum(self.probs) - 1.0) < 1e-6
|
| 108 |
+
self.seed = seed
|
| 109 |
+
self.slot = 0 # global sample counter (resume state)
|
| 110 |
+
|
| 111 |
+
def _choose(self, slot: int) -> str:
|
| 112 |
+
if len(self.langs) == 1:
|
| 113 |
+
return self.langs[0]
|
| 114 |
+
u = _splitmix64(self.seed * 0x100000001 + slot) / 2**64
|
| 115 |
+
acc = 0.0
|
| 116 |
+
for l, p in zip(self.langs, self.probs):
|
| 117 |
+
acc += p
|
| 118 |
+
if u < acc:
|
| 119 |
+
return l
|
| 120 |
+
return self.langs[-1]
|
| 121 |
+
|
| 122 |
+
def next_batch(self, n: int) -> tuple[np.ndarray, dict[str, int]]:
|
| 123 |
+
"""n windows -> array (n, seq_len+1); also per-language window counts."""
|
| 124 |
+
rows, counts = [], {l: 0 for l in self.langs}
|
| 125 |
+
for _ in range(n):
|
| 126 |
+
l = self._choose(self.slot)
|
| 127 |
+
rows.append(self.streams[l].next())
|
| 128 |
+
counts[l] += 1
|
| 129 |
+
self.slot += 1
|
| 130 |
+
return np.stack(rows), counts
|
| 131 |
+
|
| 132 |
+
def draw(self) -> tuple[str, int]:
|
| 133 |
+
"""Advance one global slot; return (lang, occurrence-index) WITHOUT reading.
|
| 134 |
+
|
| 135 |
+
Occurrence-index is the k-th window of that language in global order, so
|
| 136 |
+
stream.get(occ) is a pure deterministic lookup. All DDP ranks call draw()
|
| 137 |
+
for every slot (keeping counters identical) but only materialise their own
|
| 138 |
+
rows via streams[lang].get(occ) -- giving each rank a disjoint, reproducible
|
| 139 |
+
1/world shard of the global batch.
|
| 140 |
+
"""
|
| 141 |
+
l = self._choose(self.slot)
|
| 142 |
+
occ = self.streams[l].consumed
|
| 143 |
+
self.streams[l].consumed += 1
|
| 144 |
+
self.slot += 1
|
| 145 |
+
return l, occ
|
| 146 |
+
|
| 147 |
+
def rank_batch(self, n_global: int, rank: int, world: int
|
| 148 |
+
) -> tuple[np.ndarray, dict[str, int]]:
|
| 149 |
+
"""n_global windows drawn globally; rows n_global/world owned by `rank`."""
|
| 150 |
+
rows, counts = [], {l: 0 for l in self.langs}
|
| 151 |
+
for j in range(n_global):
|
| 152 |
+
l, occ = self.draw()
|
| 153 |
+
if j % world == rank:
|
| 154 |
+
rows.append(self.streams[l].get(occ))
|
| 155 |
+
counts[l] += 1
|
| 156 |
+
return np.stack(rows), counts
|
| 157 |
+
|
| 158 |
+
def skip_to(self, slot: int) -> None:
|
| 159 |
+
"""Fast-forward mixture state without reading data (for resume)."""
|
| 160 |
+
assert slot >= self.slot
|
| 161 |
+
for s in range(self.slot, slot):
|
| 162 |
+
self.streams[self._choose(s)].consumed += 1
|
| 163 |
+
self.slot = slot
|
| 164 |
+
|
| 165 |
+
def state_dict(self):
|
| 166 |
+
return {"slot": self.slot,
|
| 167 |
+
"streams": {l: s.state_dict() for l, s in self.streams.items()}}
|
| 168 |
+
|
| 169 |
+
def load_state_dict(self, s):
|
| 170 |
+
self.slot = s["slot"]
|
| 171 |
+
for l, st in s["streams"].items():
|
| 172 |
+
self.streams[l].load_state_dict(st)
|
| 173 |
+
|
| 174 |
+
def stats(self) -> dict:
|
| 175 |
+
return {l: {"windows": s.consumed, "epochs": round(s.epochs, 4)}
|
| 176 |
+
for l, s in self.streams.items()}
|