train: add shard_dataset (Phase 3/4 glue)
Browse files- train/shard_dataset.py +159 -0
train/shard_dataset.py
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Map-style reader over the built mix, designed so that resumption is an index advance, not a replay.
|
| 2 |
+
|
| 3 |
+
# Why this file exists in this shape (docs/01-plan.md §5, "the resume trap"): `transformers.Trainer` can
|
| 4 |
+
# resume an `IterableDataset` only by *decoding and discarding* the batches before the checkpoint step,
|
| 5 |
+
# which costs O(steps) of I/O after every interruption -- up to 3,815 steps of nothing. A map-style
|
| 6 |
+
# reader over a concatenated token store makes the same thing a one-line offset. §3.1's "same position"
|
| 7 |
+
# is therefore a number in the checkpoint, not a feeling.
|
| 8 |
+
#
|
| 9 |
+
# Sampling policy, stated so it can be reproduced:
|
| 10 |
+
# stream = the uint16 id arrays of manifest["shards"] in manifest order, concatenated
|
| 11 |
+
# sample i = stream[i*L : (i+1)*L] with L = seq_len, no padding, documents packed back to back
|
| 12 |
+
# order = a single global permutation of sample indices from numpy's PCG64(seed)
|
| 13 |
+
#
|
| 14 |
+
# Packing without a document boundary means a few windows per shard splice two unrelated documents and
|
| 15 |
+
# let attention cross the seam. That is what TinyLlama, SmolLM and GPT-2-era pretraining all do; a
|
| 16 |
+
# block-diagonal mask would cost activation memory we do not have at bs 2x2048 on a T4. Recorded as a
|
| 17 |
+
# known, deliberate deviation rather than a silence.
|
| 18 |
+
#
|
| 19 |
+
# The permutation is over ~490k indices (1B tokens / 2048), i.e. ~2 MB, so a *global* shuffle is free
|
| 20 |
+
# here -- no need for the windowed approximations that streaming readers settle for.
|
| 21 |
+
#
|
| 22 |
+
# torch is imported lazily-ish: the byte-level part of this module is usable in a CPU-only check.
|
| 23 |
+
|
| 24 |
+
import bisect
|
| 25 |
+
import json
|
| 26 |
+
import os
|
| 27 |
+
import struct
|
| 28 |
+
|
| 29 |
+
import numpy as np
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def read_shard_index(path):
|
| 33 |
+
"""(vocab, n_docs, n_tokens, offset_bytes, ids_bytes) for one shard without loading its ids."""
|
| 34 |
+
with open(path, "rb") as f:
|
| 35 |
+
vocab, n_docs, n_tokens, flags = struct.unpack("<IIII", f.read(16))
|
| 36 |
+
if flags != 0:
|
| 37 |
+
raise ValueError(f"{path}: unknown shard flags {flags}")
|
| 38 |
+
return vocab, n_docs, n_tokens, 16 + 4 * (n_docs + 1), 2 * n_tokens
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
class PackedTokenStore:
|
| 42 |
+
"""Random-access uint16 view of the concatenated mix, in manifest order."""
|
| 43 |
+
|
| 44 |
+
def __init__(self, root, manifest=None, files=None, vocab=None):
|
| 45 |
+
man = manifest if manifest is not None else json.load(
|
| 46 |
+
open(os.path.join(root, "manifest.json")))
|
| 47 |
+
self.vocab = vocab or man["tokenizer"]["vocab_size"]
|
| 48 |
+
names = files if files is not None else [s["file"] for s in man["shards"]]
|
| 49 |
+
self.shards, self.starts, self._maps = [], [], []
|
| 50 |
+
tot = 0
|
| 51 |
+
for name in names:
|
| 52 |
+
p = name if os.path.isabs(name) else os.path.join(root, name)
|
| 53 |
+
v, n_docs, n_tokens, ob, ib = read_shard_index(p)
|
| 54 |
+
if v != self.vocab:
|
| 55 |
+
raise ValueError(f"{p}: shard vocab {v} != manifest vocab {self.vocab}")
|
| 56 |
+
# memmap, never load: the whole mix is ~2.6 GB and a GPU session has 30 GiB shared with
|
| 57 |
+
# two CUDA contexts. Page faults are the point.
|
| 58 |
+
mm = np.memmap(p, dtype=np.uint16, mode="r", offset=ob, shape=(n_tokens,))
|
| 59 |
+
self.shards.append((p, mm, n_tokens))
|
| 60 |
+
self.starts.append(tot)
|
| 61 |
+
tot += n_tokens
|
| 62 |
+
self.total_tokens = tot
|
| 63 |
+
if tot == 0:
|
| 64 |
+
# Zero-length store must not look like a valid empty dataset (E-017's shape).
|
| 65 |
+
raise ValueError("no tokens in the mix -- nothing was built or nothing was restored")
|
| 66 |
+
|
| 67 |
+
def tokens(self, g0, n):
|
| 68 |
+
"""Global token span [g0, g0+n) as a contiguous uint16 array, across shard boundaries."""
|
| 69 |
+
out = np.empty(n, dtype=np.uint16)
|
| 70 |
+
done = 0
|
| 71 |
+
while done < n:
|
| 72 |
+
si = bisect.bisect_right(self.starts, g0 + done) - 1
|
| 73 |
+
p, mm, nt = self.shards[si]
|
| 74 |
+
local = (g0 + done) - self.starts[si]
|
| 75 |
+
take = min(n - done, nt - local)
|
| 76 |
+
if take <= 0:
|
| 77 |
+
raise IndexError(f"span [{g0},{g0+n}) runs past the end of the mix ({self.total_tokens})")
|
| 78 |
+
out[done:done + take] = mm[local:local + take]
|
| 79 |
+
done += take
|
| 80 |
+
return out
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def make_dataset(store, seq_len, seed, start_sample=0, count=None, shuffle=True):
|
| 84 |
+
"""A map-style torch Dataset of (input_ids, labels, attention_mask) with an exact resume cursor.
|
| 85 |
+
|
| 86 |
+
start_sample is the number of samples ALREADY consumed: the permutation is generated in full and
|
| 87 |
+
then sliced, so the sample at index j of the resumed run is the same global sample it would have
|
| 88 |
+
been in an uninterrupted run. That is the property Gate 3 has to demonstrate.
|
| 89 |
+
"""
|
| 90 |
+
import torch
|
| 91 |
+
|
| 92 |
+
n_samples = store.total_tokens // seq_len
|
| 93 |
+
if drop_last is False:
|
| 94 |
+
raise ValueError("partial trailing windows are not supported; drop_last must stay True")
|
| 95 |
+
perm = None
|
| 96 |
+
if shuffle:
|
| 97 |
+
# Seed must be identical on every resume or the two runs read different data orders. Stored in
|
| 98 |
+
# the checkpoint (cursor.json) and asserted by the trainer.
|
| 99 |
+
perm = np.random.default_rng(np.random.PCG64(seed)).permutation(n_samples)
|
| 100 |
+
perm = perm[start_sample:]
|
| 101 |
+
else:
|
| 102 |
+
perm = np.arange(start_sample, n_samples, dtype=np.int64)
|
| 103 |
+
if count is not None:
|
| 104 |
+
perm = perm[:count]
|
| 105 |
+
|
| 106 |
+
class _DS(torch.utils.data.Dataset):
|
| 107 |
+
def __len__(self):
|
| 108 |
+
return int(len(perm))
|
| 109 |
+
|
| 110 |
+
def __getitem__(self, j):
|
| 111 |
+
s = int(perm[j])
|
| 112 |
+
ids = store.tokens(s * seq_len, seq_len).astype(np.int64)
|
| 113 |
+
x = torch.from_numpy(ids)
|
| 114 |
+
return {"input_ids": x,
|
| 115 |
+
"labels": torch.cat((x[1:], x[:1])),
|
| 116 |
+
"attention_mask": torch.ones_like(x)}
|
| 117 |
+
|
| 118 |
+
# Trainer calls remove_unused_columns on models whose forward signature it inspects; exposing
|
| 119 |
+
# these names keeps it from dropping anything.
|
| 120 |
+
column_names = ["input_ids", "labels", "attention_mask"]
|
| 121 |
+
|
| 122 |
+
_DS.perm = perm
|
| 123 |
+
return _DS()
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
def cursor_dict(seq_len, start_sample, seed, dataset_files_sha=None):
|
| 127 |
+
"""The whole of the data-position state. Written into every checkpoint, checked on every resume."""
|
| 128 |
+
return {"seq_len": seq_len, "samples_consumed": start_sample,
|
| 129 |
+
"tokens_consumed": start_sample * seq_len, "shuffle_seed": seed,
|
| 130 |
+
"dataset_files_sha": dataset_files_sha}
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
def load_cursor(dirpath):
|
| 134 |
+
p = os.path.join(dirpath, "cursor.json")
|
| 135 |
+
if not os.path.exists(p):
|
| 136 |
+
return None
|
| 137 |
+
with open(p) as f:
|
| 138 |
+
c = json.load(f)
|
| 139 |
+
if not isinstance(c.get("samples_consumed"), int) or c["samples_consumed"] < 0:
|
| 140 |
+
raise ValueError(f"{p}: samples_consumed missing or nonsense -- refusing a silent restart")
|
| 141 |
+
return c
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
def save_cursor(dirpath, cur):
|
| 145 |
+
os.makedirs(dirpath, exist_ok=True)
|
| 146 |
+
with open(os.path.join(dirpath, "cursor.json"), "w") as f:
|
| 147 |
+
json.dump(cur, f, indent=1, sort_keys=True)
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
def mix_fingerprint(root, manifest=None):
|
| 151 |
+
"""Cheap, stable identity for the exact data being trained on: sha of the manifest's shard list
|
| 152 |
+
plus each shard's size. Used by the trainer to refuse a resume onto a different corpus."""
|
| 153 |
+
import hashlib
|
| 154 |
+
man = manifest or json.load(open(os.path.join(root, "manifest.json")))
|
| 155 |
+
h = hashlib.sha256()
|
| 156 |
+
for s in man["shards"]:
|
| 157 |
+
p = os.path.join(root, s["file"])
|
| 158 |
+
h.update(f"{s['file']}:{os.path.getsize(p) if os.path.exists(p) else 'missing'}\n".encode())
|
| 159 |
+
return h.hexdigest()[:16]
|