File size: 16,601 Bytes
61b6fb9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
"""Data pipeline: local files -> tokenized shards -> packed sequences.

Design goals, in order: reproducibility, resumability, then throughput.

A run is reproducible because (a) shards are content-addressed by a manifest
containing the tokenizer hash and packing parameters, and (b) the sampler order
is a deterministic function of (seed, epoch) with the position recorded in the
checkpoint, so a resume replays the identical token stream.
"""

from __future__ import annotations

import hashlib
import json
from dataclasses import asdict, dataclass
from pathlib import Path

import numpy as np
import torch

from .config import DataConfig
from .tokenizer import Tokenizer

__all__ = ["build_shards", "PackedDataset", "ShardedSampler", "make_dataloaders"]


def _iter_texts(source: Path, text_key: str):
    """Yield documents from .txt (whole file) and .jsonl (one field per line)."""
    paths = sorted(source.rglob("*")) if source.is_dir() else [source]
    for path in paths:
        if path.suffix == ".txt":
            yield path.read_text(encoding="utf-8", errors="replace")
        elif path.suffix in (".jsonl", ".ndjson"):
            with path.open(encoding="utf-8", errors="replace") as fh:
                for line in fh:
                    line = line.strip()
                    if not line:
                        continue
                    try:
                        obj = json.loads(line)
                    except json.JSONDecodeError:
                        continue
                    text = obj.get(text_key) if isinstance(obj, dict) else obj
                    if isinstance(text, str) and text:
                        yield text


# Bump when the packing scheme changes, so stale shards are rebuilt rather than
# silently reused (the manifest otherwise matches on sequence_length alone).
PACKING_VERSION = 2


@dataclass
class ShardManifest:
    packing_version: int
    tokenizer_sha256: str
    vocab_size: int
    sequence_length: int
    dtype: str
    n_train_sequences: int
    n_val_sequences: int
    n_tokens: int
    n_documents: int
    val_fraction: float
    seed: int
    source_fingerprint: str

    def matches(self, other: ShardManifest) -> bool:
        """Everything except the realised counts must agree for a cache hit."""
        keys = (
            "packing_version", "tokenizer_sha256", "vocab_size", "sequence_length",
            "dtype", "val_fraction", "seed", "source_fingerprint",
        )
        return all(getattr(self, k) == getattr(other, k) for k in keys)


def _source_fingerprint(source: Path) -> str:
    """Hash of (relative path, size, mtime) over the corpus. Cheap; catches
    edits without re-reading gigabytes."""
    h = hashlib.sha256()
    paths = sorted(source.rglob("*")) if source.is_dir() else [source]
    for p in paths:
        if p.is_file():
            st = p.stat()
            h.update(str(p.relative_to(source) if source.is_dir() else p.name).encode())
            h.update(f"{st.st_size}:{int(st.st_mtime)}".encode())
    return h.hexdigest()[:16]


def build_shards(cfg: DataConfig, tokenizer: Tokenizer, verbose: bool = True) -> ShardManifest:
    """Tokenize + pack the corpus into `train.bin` / `val.bin`.

    Packing is the standard concatenate-with-EOS-then-chunk scheme: documents are
    joined by <eos> into one stream and cut into windows of exactly
    `sequence_length` tokens.

    A window is used as both input and labels; the model shifts internally
    (`logits[:, :-1]` against `labels[:, 1:]`), so one window of L tokens yields
    L-1 next-token targets. We deliberately do NOT cut `L+1`-token windows to
    recover that last target: it would make the model process L+1 positions,
    so `sequence_length: 2048` with `max_sequence_length: 2048` would overrun
    RoPE by one. Losing 1 target in 2048 is cheaper than that off-by-one trap.
    """
    source = Path(cfg.source)
    cache = Path(cfg.cache_dir)
    cache.mkdir(parents=True, exist_ok=True)
    if not source.exists():
        raise FileNotFoundError(f"data.source {source} does not exist")

    dtype = "uint16" if tokenizer.vocab_size < 2**16 else "uint32"
    manifest_path = cache / "manifest.json"
    want = ShardManifest(
        packing_version=PACKING_VERSION,
        tokenizer_sha256=tokenizer.identity()["sha256"],
        vocab_size=tokenizer.vocab_size,
        sequence_length=cfg.sequence_length,
        dtype=dtype,
        n_train_sequences=0,
        n_val_sequences=0,
        n_tokens=0,
        n_documents=0,
        val_fraction=cfg.val_fraction,
        seed=cfg.seed,
        source_fingerprint=_source_fingerprint(source),
    )

    if manifest_path.exists() and not cfg.force_retokenize:
        try:
            have = ShardManifest(**json.loads(manifest_path.read_text()))
        except (TypeError, ValueError, json.JSONDecodeError) as exc:
            # A manifest written by an older packing scheme: treat it as a cache
            # miss and rebuild rather than crashing. Never reuse shards we cannot
            # fully validate -- a silently mismatched packing is worse than the
            # minutes it costs to retokenize.
            if verbose:
                print(f"[data] unreadable manifest ({exc}); rebuilding shards")
        else:
            if have.matches(want) and (cache / "train.bin").exists():
                if verbose:
                    print(f"[data] cache hit: {have.n_train_sequences} train / "
                          f"{have.n_val_sequences} val sequences")
                return have

    if verbose:
        print(f"[data] tokenizing {source} ...")
    np_dtype = np.uint16 if dtype == "uint16" else np.uint32
    stream: list[int] = []
    n_docs = 0
    for text in _iter_texts(source, cfg.text_key):
        ids = tokenizer.encode(text)
        if not ids:
            continue
        stream.extend(ids)
        stream.append(tokenizer.eos_id)
        n_docs += 1
    if not stream:
        raise ValueError(f"no text found under {source}")

    window = cfg.sequence_length
    n_seq = len(stream) // window
    if n_seq < 2:
        raise ValueError(
            f"corpus yields only {n_seq} sequences of length {window}; "
            f"lower data.sequence_length or add more text"
        )
    arr = np.asarray(stream[: n_seq * window], dtype=np_dtype).reshape(n_seq, window)

    # Deterministic split on a dedicated RNG, so changing train seed does not
    # reshuffle which sequences are held out.
    rng = np.random.default_rng(cfg.seed)
    perm = rng.permutation(n_seq)
    n_val = max(1, int(round(n_seq * cfg.val_fraction))) if cfg.val_fraction > 0 else 0
    n_val = min(n_val, n_seq - 1)
    val_idx, train_idx = perm[:n_val], perm[n_val:]

    arr[train_idx].tofile(cache / "train.bin")
    if n_val:
        arr[val_idx].tofile(cache / "val.bin")

    want.n_train_sequences = int(len(train_idx))
    want.n_val_sequences = int(n_val)
    want.n_tokens = int(len(stream))
    want.n_documents = n_docs
    manifest_path.write_text(json.dumps(asdict(want), indent=2), encoding="utf-8")
    if verbose:
        print(f"[data] {n_docs} docs -> {len(stream)} tokens -> "
              f"{want.n_train_sequences} train / {want.n_val_sequences} val sequences")
    return want


class PackedDataset(torch.utils.data.Dataset):
    """Memory-mapped view over a packed shard. Item i is [sequence_length] int64."""

    def __init__(self, path: str | Path, sequence_length: int, dtype: str = "uint16"):
        self.path = Path(path)
        self.window = sequence_length
        np_dtype = np.uint16 if dtype == "uint16" else np.uint32
        self.data = np.memmap(self.path, dtype=np_dtype, mode="r")
        if self.data.size % self.window != 0:
            raise ValueError(f"{path} size {self.data.size} is not a multiple of {self.window}")
        self.data = self.data.reshape(-1, self.window)

    def __len__(self) -> int:
        return self.data.shape[0]

    def __getitem__(self, idx: int) -> torch.Tensor:
        return torch.from_numpy(self.data[idx].astype(np.int64))


class ShardedSampler:
    """Deterministic, resumable index stream.

    `state_dict()` / `load_state_dict()` capture (epoch, offset) so a resumed run
    continues from the exact sequence it was about to consume -- the
    "dataset/shard position" the checkpoint spec asks for.
    """

    TERMINAL_POLICIES = ("partial", "wrap_v1")

    def __init__(self, n_items: int, batch_size: int, seed: int = 0,
                 shuffle: bool = True, terminal_policy: str = "partial"):
        """What happens at the end of an epoch is a POLICY, and it is recorded.

        `partial` (default): when fewer than `batch_size` items remain, hand
        back the short remainder and only then start the next epoch. Every
        sequence is consumed exactly once per epoch, and no global batch ever
        mixes two epochs.

        `wrap_v1`: the original behaviour, kept only so `1p6b-pretrain-v1` can
        be replayed. When fewer than `batch_size` items remain it skips them
        and wraps to a fresh permutation mid-batch. With an odd `n_items` and
        `batch_size` 2 that silently drops ONE sequence per epoch and starts
        replaying already-seen sequences to fill the batch. In the 1B run the
        dropped sequence was id 441274 and 20 sequences were replayed -- see
        docs/TERMINAL_TOKEN_AUDIT.md. Do not choose this for new runs.
        """
        if terminal_policy not in self.TERMINAL_POLICIES:
            raise ValueError(
                f"unknown terminal_policy {terminal_policy!r}; "
                f"expected one of {self.TERMINAL_POLICIES}"
            )
        self.n_items = n_items
        self.batch_size = batch_size
        self.seed = seed
        self.shuffle = shuffle
        self.terminal_policy = terminal_policy
        self.epoch = 0
        self.offset = 0
        self._order = self._make_order()

    def _make_order(self) -> np.ndarray:
        if not self.shuffle:
            return np.arange(self.n_items)
        # Order depends only on (seed, epoch): replayable from a checkpoint
        # without storing the permutation itself.
        return np.random.default_rng([self.seed, self.epoch]).permutation(self.n_items)

    def sequences_per_epoch(self) -> int:
        """How many sequences one epoch actually delivers under this policy.

        Not always `n_items`. A target derived from the dataset rather than
        from this number can be unreachable: the 1B run set its terminal
        milestone at the content of all 490,765 sequences while `wrap_v1` could
        only ever deliver 490,764, so the run had to cross into a second epoch
        to satisfy it.
        """
        if self.terminal_policy == "wrap_v1":
            return self.n_items - (self.n_items % self.batch_size)
        return self.n_items

    def epoch_order(self) -> np.ndarray:
        """The sequence ids this epoch will deliver, in order."""
        return self._order[: self.sequences_per_epoch()]

    def next_batch_indices(self) -> np.ndarray:
        if self.terminal_policy == "wrap_v1":
            if self.offset + self.batch_size > self.n_items:
                self.epoch += 1
                self.offset = 0
                self._order = self._make_order()
            idx = self._order[self.offset : self.offset + self.batch_size]
            self.offset += self.batch_size
            return idx

        # "partial": never skip the tail, never mix epochs inside one batch.
        if self.offset >= self.n_items:
            self.epoch += 1
            self.offset = 0
            self._order = self._make_order()
        idx = self._order[self.offset : self.offset + self.batch_size]
        self.offset += len(idx)
        return idx

    def rewind(self) -> None:
        """Return to the start of the current order.

        Used before each validation pass so every evaluation covers the same
        sequences. Without it a validation number is not comparable to the
        previous one, because it was measured on different data.
        """
        self.offset = 0
        self._order = self._make_order()

    def state_dict(self) -> dict:
        return {"epoch": self.epoch, "offset": self.offset, "seed": self.seed,
                "n_items": self.n_items, "batch_size": self.batch_size,
                "terminal_policy": self.terminal_policy}

    def load_state_dict(self, state: dict) -> None:
        if state.get("n_items") != self.n_items:
            raise ValueError(
                f"dataset changed size ({state.get('n_items')} -> {self.n_items}); "
                f"a resume would replay a different token stream"
            )
        # Checkpoints written before the policy existed carry no key. They were
        # all produced by the wrapping sampler, so that is what they mean --
        # assuming the current default would resume them onto a different
        # stream while reporting nothing.
        saved_policy = state.get("terminal_policy", "wrap_v1")
        if saved_policy != self.terminal_policy:
            raise ValueError(
                f"terminal_policy changed ({saved_policy} -> "
                f"{self.terminal_policy}); the end of every epoch would behave "
                f"differently, so this is a different token stream, not a resume"
            )
        saved_bs = state.get("batch_size", self.batch_size)
        if saved_bs != self.batch_size:
            raise ValueError(
                f"micro_batch_size changed ({saved_bs} -> {self.batch_size}); "
                f"the same sequence order would be regrouped into different "
                f"optimizer steps, which is a different run, not a resume"
            )
        self.epoch = state["epoch"]
        self.offset = state["offset"]
        self.seed = state["seed"]
        self._order = self._make_order()


class BatchStream:
    """Pairs a PackedDataset with a ShardedSampler and yields (inputs, labels)."""

    def __init__(self, dataset: PackedDataset, sampler: ShardedSampler, device: str = "cpu"):
        self.dataset = dataset
        self.sampler = sampler
        self.device = device

    def next(self) -> tuple[torch.Tensor, torch.Tensor]:
        idx = self.sampler.next_batch_indices()
        rows = np.stack([self.dataset.data[i] for i in idx]).astype(np.int64)
        batch = torch.from_numpy(rows)
        # Both are the full window: the model shifts internally (logits[:, :-1]
        # vs labels[:, 1:]), so one tensor of seq_len+1 serves as both.
        batch = batch.to(self.device, non_blocking=True)
        return batch, batch

    def state_dict(self) -> dict:
        return self.sampler.state_dict()

    def load_state_dict(self, state: dict) -> None:
        self.sampler.load_state_dict(state)


def make_domain_val_streams(
    cfg: DataConfig, manifest: ShardManifest, batch_size: int, device: str
) -> dict:
    """One validation stream per domain, in addition to the global one.

    Domains come from `data.val_domains` when the data agent supplies explicit
    paths; otherwise `<cache_dir>/val_<domain>.bin` is auto-discovered, which is
    the convention the packing stage writes. Non-shuffled, so a domain's
    validation number is comparable across checkpoints.
    """
    cache = Path(cfg.cache_dir)
    found: dict[str, Path] = {}

    for domain, path in (cfg.val_domains or {}).items():
        found[domain] = Path(path)
    if not found:
        for candidate in sorted(cache.glob("val_*.bin")):
            found[candidate.stem[len("val_"):]] = candidate

    streams = {}
    for domain, path in found.items():
        if not path.exists():
            print(f"[data] domain validation shard missing, skipping: {path}")
            continue
        ds = PackedDataset(path, cfg.sequence_length, manifest.dtype)
        streams[domain] = BatchStream(
            ds, ShardedSampler(len(ds), batch_size, cfg.seed, shuffle=False), device
        )
    return streams


def make_dataloaders(cfg: DataConfig, manifest: ShardManifest, batch_size: int, device: str):
    cache = Path(cfg.cache_dir)
    train_ds = PackedDataset(cache / "train.bin", cfg.sequence_length, manifest.dtype)
    train = BatchStream(train_ds, ShardedSampler(len(train_ds), batch_size, cfg.seed), device)
    val = None
    if manifest.n_val_sequences and (cache / "val.bin").exists():
        val_ds = PackedDataset(cache / "val.bin", cfg.sequence_length, manifest.dtype)
        val = BatchStream(
            val_ds, ShardedSampler(len(val_ds), batch_size, cfg.seed, shuffle=False), device
        )
    return train, val