File size: 8,993 Bytes
2a49d9a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""
Shared fineweb-edu token cache.

Every job trains on the same corpus (HuggingFaceFW/fineweb-edu), so instead of
each run streaming and re-tokenizing the dataset, the Space builds ONE flat
uint16 token file in a background thread at startup and all trainers read
slices of it. 300M tokens x 2 bytes = 600 MB on disk, which comfortably fits
Spaces' ephemeral storage and makes the GPUs data-bound never again.

Documents are packed end-to-end separated by <|endoftext|> (id 0).
"""

import os
import threading
import time

import numpy as np

from . import store
from .config import MAX_TOKENS
from .db import DATA_DIR

TOKENS_PATH = os.path.join(DATA_DIR, "fineweb_edu_tokens.u16")
META_PATH = TOKENS_PATH + ".meta"

DATASET_ID = "HuggingFaceFW/fineweb-edu"
DATASET_CONFIG = "sample-350BT"

# Build a little past the maximum a user can request so the last run never
# stalls on the writer. At 2 bytes per token a 1B-token budget is ~2 GB on
# disk, which is nothing next to the Space's ephemeral storage.
TARGET_TOKENS = MAX_TOKENS + 5_000_000

_state = {
    "ready_tokens": 0,
    "status": "idle",     # idle|building|ready|error
    "error": None,
    "started": None,
    "rate": 0.0,
}
_state_lock = threading.Lock()
_build_thread = None


def state():
    with _state_lock:
        return dict(_state)


def _set(**kw):
    with _state_lock:
        _state.update(kw)


def _load_meta():
    if os.path.exists(META_PATH):
        try:
            with open(META_PATH) as f:
                return int(f.read().strip())
        except Exception:
            return 0
    return 0


def _save_meta(n):
    tmp = META_PATH + ".tmp"
    with open(tmp, "w") as f:
        f.write(str(n))
    os.replace(tmp, META_PATH)


def build_token_cache(tokenizer, log=print):
    """Stream fineweb-edu, tokenize, append to the flat uint16 file."""
    from datasets import load_dataset

    # A previous container may have already paid for this. Downloading ~2.8 GB
    # beats re-tokenizing 1.5B tokens by a wide margin.
    have = _load_meta()
    if have < TARGET_TOKENS:
        restored = store.fetch_token_cache(TOKENS_PATH, META_PATH, log)
        if restored > have:
            have = restored
            _set(ready_tokens=have)

    if have >= TARGET_TOKENS:
        _set(ready_tokens=have, status="ready")
        log(f"[data] cache already complete: {have:,} tokens")
        return

    _set(status="building", started=time.time(), ready_tokens=have)
    log(f"[data] building token cache ({have:,} / {TARGET_TOKENS:,})")

    eot = 0  # <|endoftext|>
    t0 = time.time()
    written = have

    # Truncate to the last known-good offset, then append.
    mode = "r+b" if os.path.exists(TOKENS_PATH) else "wb"
    f = open(TOKENS_PATH, mode)
    f.seek(written * 2)
    f.truncate()

    try:
        ds = load_dataset(DATASET_ID, name=DATASET_CONFIG,
                          split="train", streaming=True)
        # Skip ahead deterministically if resuming a partial build.
        buf_docs = []
        BATCH = 512
        for row in ds:
            buf_docs.append(row["text"])
            if len(buf_docs) < BATCH:
                continue
            encs = tokenizer.encode_batch(buf_docs)
            buf_docs = []
            flat = []
            for e in encs:
                flat.extend(e.ids)
                flat.append(eot)
            arr = np.asarray(flat, dtype=np.uint16)
            f.write(arr.tobytes())
            written += arr.size
            if written % (1 << 22) < arr.size:   # ~every 4M tokens
                f.flush()
                _save_meta(written)
                _touch_lock()
                dt = max(time.time() - t0, 1e-6)
                _set(ready_tokens=written, rate=(written - have) / dt)
                log(f"[data] {written:,} tokens "
                    f"({(written - have) / dt / 1e6:.2f}M tok/s)")
            if written >= TARGET_TOKENS:
                break
        f.flush()
        _save_meta(written)
        _set(ready_tokens=written, status="ready")
        log(f"[data] cache ready: {written:,} tokens in {time.time() - t0:.0f}s")
        f.close()
        store.save_token_cache(TOKENS_PATH, META_PATH, log)
    except Exception as exc:  # keep the Space alive; jobs will wait / use what exists
        _save_meta(written)
        _set(ready_tokens=written, status="error", error=repr(exc))
        log(f"[data] ERROR: {exc!r} — the partial cache of {written:,} tokens is still usable")
    finally:
        f.close()


LOCK_PATH = TOKENS_PATH + ".lock"
LOCK_STALE_SECONDS = 150
_HEARTBEAT = 30


def _acquire_build_lock():
    """
    Only one process may append to the shared token file. A rolling deploy runs
    two containers against the same volume for a while, and two appenders would
    corrupt it. The lock is a file the holder keeps touching, so a crashed
    holder's lock goes stale instead of blocking forever.
    """
    now = time.time()
    try:
        fd = os.open(LOCK_PATH, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644)
        os.write(fd, str(os.getpid()).encode())
        os.close(fd)
        return True
    except FileExistsError:
        try:
            age = now - os.path.getmtime(LOCK_PATH)
        except OSError:
            return False
        if age < LOCK_STALE_SECONDS:
            return False
        # Stale: the previous holder is gone. Take it over.
        try:
            os.utime(LOCK_PATH, (now, now))
            with open(LOCK_PATH, "w") as f:
                f.write(str(os.getpid()))
            return True
        except OSError:
            return False
    except OSError:
        return True          # can't lock (read-only fs) — proceed anyway


def _touch_lock():
    try:
        os.utime(LOCK_PATH, None)
    except OSError:
        pass


def _release_lock():
    try:
        os.unlink(LOCK_PATH)
    except OSError:
        pass


def _guarded_build(tokenizer, log):
    if not _acquire_build_lock():
        log("[data] another process is building the token cache — "
            "using it read-only")
        _set(status="following")
        # Track the other process's progress so jobs know what's available.
        while True:
            have = _load_meta()
            _set(ready_tokens=have)
            if have >= TARGET_TOKENS:
                _set(status="ready")
                return
            try:
                if time.time() - os.path.getmtime(LOCK_PATH) > LOCK_STALE_SECONDS:
                    break        # holder died; take over below
            except OSError:
                break
            time.sleep(10)
        if not _acquire_build_lock():
            _set(status="ready" if _load_meta() else "error")
            return
    try:
        build_token_cache(tokenizer, log)
    finally:
        _release_lock()


def start_background_build(tokenizer, log=print):
    global _build_thread
    if _build_thread and _build_thread.is_alive():
        return _build_thread
    _build_thread = threading.Thread(
        target=_guarded_build, args=(tokenizer, log),
        name="token-cache", daemon=True)
    _build_thread.start()
    return _build_thread


def available_tokens():
    with _state_lock:
        return _state["ready_tokens"]


class TokenStream:
    """
    Sequential reader over the shared token file, yielding (B, T) int64
    batches. Each job gets its own random start offset so two runs of the same
    size do not see the identical token order.

    The batch is used as BOTH input_ids and labels: LlamaForCausalLM shifts
    labels internally, so handing it a pre-shifted target would train the model
    to predict two tokens ahead while generation samples one ahead.
    """

    def __init__(self, seq_len, batch_size, seed=1337):
        self.seq_len = seq_len
        self.batch_size = batch_size
        self.chunk = batch_size * seq_len
        rng = np.random.default_rng(seed)
        avail = max(available_tokens(), self.chunk + 1)
        self.pos = int(rng.integers(0, max(avail - self.chunk, 1)))
        self._mm = None
        self._mm_len = 0

    def _ensure(self):
        n = available_tokens()
        if self._mm is None or n > self._mm_len:
            if n < self.chunk + 1:
                return False
            self._mm = np.memmap(TOKENS_PATH, dtype=np.uint16, mode="r", shape=(n,))
            self._mm_len = n
        return self._mm is not None

    def next_batch(self):
        """One (B, T) int64 batch. Pass it as input_ids AND labels."""
        import torch
        deadline = time.time() + 300
        while not self._ensure():
            if time.time() > deadline:
                raise RuntimeError("token cache unavailable (timed out waiting for data)")
            time.sleep(2)
        if self.pos + self.chunk > self._mm_len:
            self.pos = 0
        buf = np.asarray(self._mm[self.pos:self.pos + self.chunk], dtype=np.int64)
        self.pos += self.chunk
        return torch.from_numpy(buf.reshape(self.batch_size, self.seq_len))