File size: 9,609 Bytes
644b0cd
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
398d16e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
644b0cd
 
 
 
 
 
 
 
 
 
 
398d16e
644b0cd
 
398d16e
 
 
 
 
644b0cd
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
398d16e
 
 
 
 
 
644b0cd
 
398d16e
cfc38f1
 
 
 
398d16e
 
cfc38f1
 
 
398d16e
644b0cd
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c588f4b
 
 
 
 
 
 
 
 
644b0cd
 
 
 
 
c588f4b
 
644b0cd
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
# Map-style reader over the built mix, designed so that resumption is an index advance, not a replay.

# Why this file exists in this shape (docs/01-plan.md §5, "the resume trap"): `transformers.Trainer` can
# resume an `IterableDataset` only by *decoding and discarding* the batches before the checkpoint step,
# which costs O(steps) of I/O after every interruption -- up to 3,815 steps of nothing. A map-style
# reader over a concatenated token store makes the same thing a one-line offset. §3.1's "same position"
# is therefore a number in the checkpoint, not a feeling.
#
# Sampling policy, stated so it can be reproduced:
#   stream   = the uint16 id arrays of manifest["shards"] in manifest order, concatenated
#   sample i = stream[i*L : (i+1)*L]  with L = seq_len, no padding, documents packed back to back
#   order    = a single global permutation of sample indices from numpy's PCG64(seed)
#
# Packing without a document boundary means a few windows per shard splice two unrelated documents and
# let attention cross the seam. That is what TinyLlama, SmolLM and GPT-2-era pretraining all do; a
# block-diagonal mask would cost activation memory we do not have at bs 2x2048 on a T4. Recorded as a
# known, deliberate deviation rather than a silence.
#
# The permutation is over ~490k indices (1B tokens / 2048), i.e. ~2 MB, so a *global* shuffle is free
# here -- no need for the windowed approximations that streaming readers settle for.
#
# torch is imported lazily-ish: the byte-level part of this module is usable in a CPU-only check.

import bisect
import json
import os
import struct

import numpy as np


def read_shard_index(path):
    """(vocab, n_docs, n_tokens, offset_bytes, ids_bytes) for one shard without loading its ids."""
    with open(path, "rb") as f:
        vocab, n_docs, n_tokens, flags = struct.unpack("<IIII", f.read(16))
    if flags != 0:
        raise ValueError(f"{path}: unknown shard flags {flags}")
    return vocab, n_docs, n_tokens, 16 + 4 * (n_docs + 1), 2 * n_tokens


class PackedTokenStore:
    """Random-access uint16 view of the concatenated mix, in manifest order."""

    def __init__(self, root, manifest=None, files=None, vocab=None):
        man = manifest if manifest is not None else json.load(
            open(os.path.join(root, "manifest.json")))
        self.vocab = vocab or man["tokenizer"]["vocab_size"]
        names = files if files is not None else [s["file"] for s in man["shards"]]
        self.shards, self.starts, self._maps = [], [], []
        tot = 0
        for name in names:
            p = name if os.path.isabs(name) else os.path.join(root, name)
            v, n_docs, n_tokens, ob, ib = read_shard_index(p)
            if v != self.vocab:
                raise ValueError(f"{p}: shard vocab {v} != manifest vocab {self.vocab}")
            # memmap, never load: the whole mix is ~2.6 GB and a GPU session has 30 GiB shared with
            # two CUDA contexts. Page faults are the point.
            mm = np.memmap(p, dtype=np.uint16, mode="r", offset=ob, shape=(n_tokens,))
            self.shards.append((p, mm, n_tokens))
            self.starts.append(tot)
            tot += n_tokens
        self.total_tokens = tot
        if tot == 0:
            # Zero-length store must not look like a valid empty dataset (E-017's shape).
            raise ValueError("no tokens in the mix -- nothing was built or nothing was restored")

    def tokens(self, g0, n):
        """Global token span [g0, g0+n) as a contiguous uint16 array, across shard boundaries."""
        out = np.empty(n, dtype=np.uint16)
        done = 0
        while done < n:
            si = bisect.bisect_right(self.starts, g0 + done) - 1
            p, mm, nt = self.shards[si]
            local = (g0 + done) - self.starts[si]
            take = min(n - done, nt - local)
            if take <= 0:
                raise IndexError(f"span [{g0},{g0+n}) runs past the end of the mix ({self.total_tokens})")
            out[done:done + take] = mm[local:local + take]
            done += take
        return out


def perm_sha(n_samples, seed, base=None):
    """Identity of the visit order itself, not just the seed that produced it.

    `seed` pins the order only for a fixed numpy. Kaggle updates its notebook image underneath us, and a
    session that resumed after such a bump would read a different permutation from the same seed -- some
    windows twice, others never -- while every other guard (weights, cursor arithmetic, fingerprint,
    batch size) still agreed. Hashing the full permutation is ~10 ms and makes that state impossible to
    enter quietly. Computed before the resume slice, so two sessions at different offsets agree on it.
    `base` lets a caller that already generated the permutation avoid generating it twice.
    """
    import hashlib
    if base is None:
        base = np.random.default_rng(np.random.PCG64(seed)).permutation(n_samples)
    return hashlib.sha256(np.ascontiguousarray(base).tobytes()).hexdigest()[:16]


def make_dataset(store, seq_len, seed, start_sample=0, count=None, shuffle=True):
    """A map-style torch Dataset of (input_ids, labels, attention_mask) with an exact resume cursor.

    start_sample is the number of samples ALREADY consumed: the permutation is generated in full and
    then sliced, so the sample at index j of the resumed run is the same global sample it would have
    been in an uninterrupted run. That is the property Gate 3 has to demonstrate.
    """
    import torch

    n_samples = store.total_tokens // seq_len
    perm = None
    order_sha = "unshuffled"
    if shuffle:
        # Seed must be identical on every resume or the two runs read different data orders. Stored in
        # the checkpoint (cursor.json) and asserted by the trainer -- alongside perm_sha(), which catches
        # the case the seed alone cannot: the same seed on a different numpy.
        base = np.random.default_rng(np.random.PCG64(seed)).permutation(n_samples)
        order_sha = perm_sha(n_samples, seed, base=base)
        perm = base[start_sample:]
    else:
        perm = np.arange(start_sample, n_samples, dtype=np.int64)
    if count is not None:
        perm = perm[:count]

    class _DS(torch.utils.data.Dataset):
        def __len__(self):
            return int(len(perm))

        def __getitem__(self, j):
            s = int(perm[j])
            ids = store.tokens(s * seq_len, seq_len).astype(np.int64)
            x = torch.from_numpy(ids)
            return {"input_ids": x,
                    "labels": torch.cat((x[1:], x[:1])),
                    "attention_mask": torch.ones_like(x)}

        # Trainer calls remove_unused_columns on models whose forward signature it inspects; exposing
        # these names keeps it from dropping anything.
        column_names = ["input_ids", "labels", "attention_mask"]

    ds = _DS()
    # Instance attributes, not class attributes: `_DS.perm = perm` would let a second make_dataset() call
    # in the same process overwrite the first one's order.
    ds.perm = perm
    ds.order_sha = order_sha
    return ds


def cursor_dict(seq_len, samples_consumed, seed, dataset_files_sha=None, step=None, order_sha=None):
    """The whole of the data-position state. Written into every checkpoint, checked on every resume.

    `step` is recorded alongside `samples_consumed` so the two can be cross-checked on resume: they are
    redundant by construction (samples = step x sequences-per-step), and a cursor where they disagree is a
    corrupted or hand-edited file, not a position to continue from. `shuffle_perm_sha` is the identity of
    the visit order, which the seed alone does not pin (see perm_sha).
    """
    return {"seq_len": seq_len, "samples_consumed": samples_consumed,
            "tokens_consumed": samples_consumed * seq_len, "shuffle_seed": seed,
            "shuffle_perm_sha": order_sha, "step": step, "dataset_files_sha": dataset_files_sha}


def load_cursor(dirpath):
    p = os.path.join(dirpath, "cursor.json")
    if not os.path.exists(p):
        return None
    with open(p) as f:
        c = json.load(f)
    if not isinstance(c.get("samples_consumed"), int) or c["samples_consumed"] < 0:
        raise ValueError(f"{p}: samples_consumed missing or nonsense -- refusing a silent restart")
    return c


def save_cursor(dirpath, cur):
    os.makedirs(dirpath, exist_ok=True)
    with open(os.path.join(dirpath, "cursor.json"), "w") as f:
        json.dump(cur, f, indent=1, sort_keys=True)


def mix_fingerprint(root, manifest=None):
    """Cheap, stable identity for the exact data being trained on. Used by the trainer to refuse a resume
    onto a different corpus, so it has to be computed the same way in every session.

    Names alone (or names and sizes, as this function did until the E-031 review) are too weak: a rebuilt
    mix whose shards happen to land on the same sizes is a different corpus, and the whole point of the
    check is to catch a corpus swap between sessions. The manifest already certifies a sha256 per shard,
    so folding it in costs nothing and needs no file reads; the local size is included as well so a
    truncated file on disk cannot hide behind a manifest that still describes the good one.
    """
    import hashlib
    man = manifest or json.load(open(os.path.join(root, "manifest.json")))
    h = hashlib.sha256()
    for s in man["shards"]:
        p = os.path.join(root, s["file"])
        h.update(f"{s['file']}:{s.get('sha256')}:"
                 f"{os.path.getsize(p) if os.path.exists(p) else 'missing'}\n".encode())
    return h.hexdigest()[:16]