Cion-lab commited on
Commit
644b0cd
·
verified ·
1 Parent(s): 98e4956

train: add shard_dataset (Phase 3/4 glue)

Browse files
Files changed (1) hide show
  1. 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]