streamft 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
streamft/cli.py ADDED
@@ -0,0 +1,450 @@
1
+ """streamft.cli: disk-backed (mmap) streaming QLoRA -- weights live on the SSD.
2
+
3
+ Companion to `exp52`. Instead of keeping the 4-bit base in host RAM, the
4
+ pre-quantized bnb-4bit safetensors are memory-mapped and each decoder block is
5
+ materialized on the GPU on demand and dropped afterwards. Host RAM becomes a
6
+ free, OS-managed page cache for the hot blocks, so the model size is bounded by
7
+ disk space (and step time), not by RAM.
8
+
9
+ Usage:
10
+ # 1) turn a local bf16 model into a bnb-4bit checkpoint (once)
11
+ python -m streamft.cli --prepare --model models/Qwen2.5-3B \
12
+ --save-quant checkpoints/qwen3b_bnb4bit
13
+ # 2) train LoRA on it straight from disk
14
+ python -m streamft.cli --model-dir checkpoints/qwen3b_bnb4bit \
15
+ --steps 5 --seq 128 --eval_chunks 1 --tag disk_qlora3b
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import argparse
21
+ import hashlib
22
+ import json
23
+ import sys
24
+ import time
25
+ from pathlib import Path
26
+
27
+ import torch
28
+
29
+ ROOT = Path(__file__).resolve().parents[1]
30
+ sys.path.insert(0, str(ROOT))
31
+
32
+ from streamft.data import TrainSampler, chunks_from_tokens, load_tokens # noqa: E402
33
+ from streamft.disk_quant_store import ( # noqa: E402
34
+ DiskQuantStore,
35
+ LayerCache,
36
+ build_skeleton,
37
+ load_layer,
38
+ materialize_shared,
39
+ release_layer,
40
+ )
41
+ from streamft.autotune import plan_for # noqa: E402
42
+ from streamft.lora import count_lora_params, lora_state_dict # noqa: E402
43
+ from streamft.metrics import append_jsonl, now # noqa: E402
44
+ from streamft.stream_quant_lora import StreamingQuantLoRATrainer # noqa: E402
45
+ from streamft.stream_train import stream_eval_ce # noqa: E402
46
+
47
+ try:
48
+ import psutil
49
+ _proc = psutil.Process()
50
+
51
+ def host_rss_gb():
52
+ return round(_proc.memory_info().rss / 1e9, 2)
53
+
54
+ def host_private_gb():
55
+ # private (non-shared) bytes: excludes the mmap'd weight pages, which the
56
+ # OS can evict -- that is the number that matters for "does it fit in RAM"
57
+ try:
58
+ return round(_proc.memory_info().private / 1e9, 2)
59
+ except Exception:
60
+ return None
61
+ except Exception:
62
+ def host_rss_gb():
63
+ return None
64
+
65
+ def host_private_gb():
66
+ return None
67
+
68
+
69
+ def adapter_sha(model) -> str:
70
+ """Stable fingerprint of the trainable (adapter) tensors."""
71
+ h = hashlib.sha256()
72
+ for n, p in sorted(model.named_parameters()):
73
+ if p.requires_grad:
74
+ h.update(n.encode())
75
+ h.update(p.detach().float().cpu().contiguous().numpy().tobytes())
76
+ return h.hexdigest()[:16]
77
+
78
+
79
+ def prepare(model: str, save_quant: str) -> int:
80
+ """Quantize a local bf16 model into a bnb-4bit checkpoint (uint8 + qstate)."""
81
+ from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig
82
+
83
+ src = str(ROOT / model)
84
+ dst = ROOT / save_quant
85
+ print(f"[prepare] {model} -> {save_quant}", flush=True)
86
+ bnb = BitsAndBytesConfig(
87
+ load_in_4bit=True,
88
+ bnb_4bit_quant_type="nf4",
89
+ bnb_4bit_compute_dtype=torch.bfloat16,
90
+ bnb_4bit_use_double_quant=True,
91
+ llm_int8_enable_fp32_cpu_offload=True,
92
+ )
93
+ t0 = time.time()
94
+ m = AutoModelForCausalLM.from_pretrained(
95
+ src, quantization_config=bnb, device_map="cpu", low_cpu_mem_usage=True)
96
+ print(f"[prepare] loaded {time.time()-t0:.1f}s rss={host_rss_gb()}GB", flush=True)
97
+ dst.mkdir(parents=True, exist_ok=True)
98
+ m.save_pretrained(str(dst), safe_serialization=True, max_shard_size="2GB")
99
+ AutoTokenizer.from_pretrained(src).save_pretrained(str(dst))
100
+ print(f"[prepare] saved {dst} rss={host_rss_gb()}GB", flush=True)
101
+ return 0
102
+
103
+
104
+ def _format_messages(row: dict) -> list[dict]:
105
+ user = row["instruction"]
106
+ if row.get("context"):
107
+ user = f"{user}\n\nContext:\n{row['context']}"
108
+ return [
109
+ {"role": "user", "content": user},
110
+ {"role": "assistant", "content": row["response"]},
111
+ ]
112
+
113
+
114
+ def _encode_chat(tokenizer, row: dict, seq: int):
115
+ """Chat-template encode with the prompt tokens masked out of the loss."""
116
+ messages = _format_messages(row)
117
+ full_text = tokenizer.apply_chat_template(messages, tokenize=False,
118
+ add_generation_prompt=False)
119
+ full = tokenizer(full_text, add_special_tokens=False).input_ids
120
+ prompt_text = tokenizer.apply_chat_template(messages[:-1], tokenize=False,
121
+ add_generation_prompt=True)
122
+ prompt = tokenizer(prompt_text, add_special_tokens=False).input_ids
123
+ full = full[:seq]
124
+ labels = list(full)
125
+ if 0 < len(prompt) < len(full) and full[:len(prompt)] == prompt[:len(full)]:
126
+ k = min(len(prompt), len(full))
127
+ labels = [-100] * k + labels[k:]
128
+ if len(labels) < 2:
129
+ return None
130
+ return (torch.tensor([full], dtype=torch.long),
131
+ torch.tensor([labels], dtype=torch.long))
132
+
133
+
134
+ @torch.no_grad()
135
+ def _masked_eval(trainer, rows, tokenizer, seq: int) -> float:
136
+ """Masked SFT loss through the streaming (disk-backed) forward."""
137
+ trainer.model.eval()
138
+ total, n = 0.0, 0
139
+ for row in rows:
140
+ enc = _encode_chat(tokenizer, row, seq)
141
+ if enc is None:
142
+ continue
143
+ ids, labels = enc
144
+ ys = torch.full_like(ids, -100)
145
+ ys[:, :-1] = labels[:, 1:]
146
+ h, _cache, _pos = trainer._forward_cached(ids)
147
+ z = trainer.norm(h)
148
+ logits = trainer.lm_head(z).float()
149
+ tgt = ys.reshape(-1)
150
+ valid = tgt != -100
151
+ v = int(valid.sum())
152
+ if v == 0:
153
+ continue
154
+ total += float(torch.nn.functional.cross_entropy(
155
+ logits.reshape(-1, logits.shape[-1])[valid],
156
+ tgt[valid].to(logits.device), reduction="sum"))
157
+ n += v
158
+ trainer.model.train()
159
+ return total / max(n, 1)
160
+
161
+
162
+ def main() -> int:
163
+ ap = argparse.ArgumentParser()
164
+ ap.add_argument("--prepare", action="store_true")
165
+ ap.add_argument("--model", default="models/Qwen2.5-3B")
166
+ ap.add_argument("--save-quant", default="checkpoints/qwen3b_bnb4bit")
167
+ ap.add_argument("--model-dir", default="checkpoints/qwen3b_bnb4bit")
168
+ ap.add_argument("--data", default="data/wt2_train.pt")
169
+ ap.add_argument("--eval_data", default="data/wt2_test.pt")
170
+ ap.add_argument("--steps", type=int, default=3)
171
+ ap.add_argument("--seq", type=int, default=128)
172
+ ap.add_argument("--chunk", type=int, default=64)
173
+ ap.add_argument("--lr", type=float, default=1e-4)
174
+ ap.add_argument("--lora_r", type=int, default=8)
175
+ ap.add_argument("--lora_alpha", type=int, default=16)
176
+ ap.add_argument("--targets", choices=["attn", "all"], default="attn")
177
+ ap.add_argument("--head", choices=["bf16", "nf4"], default="bf16")
178
+ ap.add_argument("--shared-dtype", choices=["fp32", "as-stored"], default="fp32",
179
+ help="fp32 matches prepare_model_for_kbit_training; 'as-stored' "
180
+ "keeps embed/head in the checkpoint dtype (big models)")
181
+ ap.add_argument("--blocks-on-gpu", default="adaptive",
182
+ help="'adaptive' = start at 1 block and grow from the measured "
183
+ "peak; 'auto' = static plan from free VRAM; or an integer")
184
+ ap.add_argument("--adapt-every", type=int, default=10,
185
+ help="re-measure and resize the block window every N steps "
186
+ "(0 = only after the first step)")
187
+ ap.add_argument("--vram-soft-gb", type=float, default=0.0,
188
+ help="grow towards this VRAM budget (0 = 80%% of the device)")
189
+ ap.add_argument("--vram-hard-gb", type=float, default=0.0,
190
+ help="never exceed this VRAM budget (0 = 88%% of the device); "
191
+ "useful to leave room for the display/other apps")
192
+ ap.add_argument("--eval_chunks", type=int, default=0)
193
+ ap.add_argument("--eval_seq", type=int, default=0,
194
+ help="evaluation sequence length (default = --seq); set it to a "
195
+ "common value to compare runs trained at different lengths")
196
+ ap.add_argument("--activation-cache", choices=["gpu", "cpu"], default="gpu",
197
+ help="where the per-layer inputs for the recompute-backward live; "
198
+ "'cpu' frees VRAM at a PCIe round-trip per layer and is what "
199
+ "lets context beyond ~1k fit on a 6 GB card")
200
+ ap.add_argument("--sft", action="store_true",
201
+ help="train on instruction data (Dolly-15k) with prompt masking")
202
+ ap.add_argument("--dataset", default="databricks/databricks-dolly-15k")
203
+ ap.add_argument("--train_size", type=int, default=256)
204
+ ap.add_argument("--eval_size", type=int, default=8)
205
+ ap.add_argument("--save", default="")
206
+ ap.add_argument("--tag", default="disk_qlora")
207
+ ap.add_argument("--verbose", action="store_true")
208
+ args = ap.parse_args()
209
+ if args.eval_seq <= 0:
210
+ args.eval_seq = args.seq
211
+
212
+ if args.prepare:
213
+ return prepare(args.model, args.save_quant)
214
+
215
+ model_dir = str(ROOT / args.model_dir)
216
+ print(f"[exp54] model_dir={Path(model_dir).name} steps={args.steps} seq={args.seq} "
217
+ f"lora_r={args.lora_r} targets={args.targets} head={args.head}", flush=True)
218
+
219
+ t0 = time.time()
220
+ model, cfg = build_skeleton(
221
+ model_dir, lora_r=args.lora_r, lora_alpha=args.lora_alpha,
222
+ targets=args.targets, head=args.head,
223
+ low_precision_shared=(args.shared_dtype == "as-stored"),
224
+ )
225
+ store = DiskQuantStore(model_dir, device="cuda")
226
+
227
+ # Plan before anything is materialized: torch.cuda.mem_get_info() then sees
228
+ # the real free VRAM (the LM head is loaded *after* this, and plan_for
229
+ # accounts for its size itself).
230
+ mode_blocks = str(args.blocks_on_gpu).lower()
231
+ if mode_blocks in ("auto", "adaptive"):
232
+ plan = plan_for(model_dir, seq=args.seq, lora_r=args.lora_r,
233
+ head=args.head, loss_chunk=args.chunk)
234
+ print(plan.summary(), flush=True)
235
+ n_blocks = plan.blocks_on_gpu
236
+ block_gb, n_layers = plan.block_gb, plan.n_layers
237
+ else:
238
+ plan = None
239
+ n_blocks = int(args.blocks_on_gpu)
240
+ block_gb, n_layers = 0.0, 0
241
+
242
+ # measured autotune: always start from the safest window (1 block) and let
243
+ # the first real step tell us how much room is actually left
244
+ start_blocks = 1 if mode_blocks == "adaptive" else n_blocks
245
+
246
+ base = materialize_shared(model, store, cfg, device="cuda")
247
+ print(f"[load] skeleton+shared {time.time()-t0:.1f}s keys={len(store.weight_map)} "
248
+ f"rss={host_rss_gb()}GB", flush=True)
249
+
250
+ layers = list(base.model.layers)
251
+ cache = LayerCache(store, layers, device="cuda", capacity=start_blocks)
252
+
253
+ # --- measured autotune -------------------------------------------------
254
+ adapt = None
255
+ adapt_hist: list = []
256
+ if mode_blocks == "adaptive" and plan is not None:
257
+ dev_total_gb = torch.cuda.get_device_properties(0).total_memory / 1e9
258
+ soft_gb = args.vram_soft_gb or dev_total_gb * 0.80 # grow towards this
259
+ hard_gb = args.vram_hard_gb or dev_total_gb * 0.88 # never exceed this
260
+ every = max(0, int(args.adapt_every))
261
+ state = {"step": 0, "hist": []}
262
+ adapt_hist = state["hist"]
263
+
264
+ def _marginal_gb() -> float:
265
+ """Measured VRAM cost of one extra resident block (fallback: nominal).
266
+
267
+ Scan back for the most recent pair of measurements taken at *different*
268
+ capacities; consecutive steps usually share the same window.
269
+ """
270
+ h = state["hist"]
271
+ for k in range(len(h) - 1, 0, -1):
272
+ c1, p1 = h[k]
273
+ c0, p0 = h[k - 1]
274
+ if c1 != c0:
275
+ return max(1e-6, (p1 - p0) / (c1 - c0))
276
+ return block_gb or 0.25
277
+
278
+ def adapt(peak_gb: float, i: int) -> None:
279
+ state["step"] += 1
280
+ n = state["step"]
281
+ state["hist"].append((cache.capacity, round(peak_gb, 3)))
282
+ if not (n == 1 or (every > 0 and n % every == 0)):
283
+ return
284
+ margin = _marginal_gb()
285
+ if peak_gb > hard_gb:
286
+ shrink = int((peak_gb - hard_gb) // max(margin, 1e-9)) + 1
287
+ cache.set_capacity(max(1, cache.capacity - shrink))
288
+ torch.cuda.empty_cache() # release what the allocator still holds
289
+ print(f"[autotune] step {n}: peak {peak_gb:.2f}GB > hard "
290
+ f"{hard_gb:.2f}GB -> {cache.capacity} blocks", flush=True)
291
+ return
292
+ head = soft_gb - peak_gb
293
+ extra = int((head * 0.9) // max(margin, 1e-9)) if head > 0 else 0
294
+ # never *grow* past the hard limit either: otherwise soft > hard
295
+ # configurations oscillate between grow and shrink
296
+ if extra > 0 and peak_gb + extra * margin > hard_gb:
297
+ extra = max(0, int((hard_gb - peak_gb) // max(margin, 1e-9)))
298
+ if extra >= 1 and cache.capacity < n_layers:
299
+ before = cache.capacity
300
+ cache.set_capacity(min(n_layers, before + extra))
301
+ print(f"[autotune] step {n}: peak {peak_gb:.2f}GB, marginal "
302
+ f"{margin * 1024:.0f}MB/block -> {before}->{cache.capacity} blocks",
303
+ flush=True)
304
+
305
+ def stage(layer, i):
306
+ cache.stage(layer, i)
307
+
308
+ def release(layer, i):
309
+ cache.release(layer, i)
310
+
311
+ print(f"[cache] keeping up to {cache.capacity} block(s) resident on the GPU",
312
+ flush=True)
313
+
314
+ tr = StreamingQuantLoRATrainer(
315
+ model, device="cuda", loss_chunk=args.chunk, lr=args.lr, clip=1.0,
316
+ verbose=args.verbose, stage=stage, release=release,
317
+ cache_device=args.activation_cache,
318
+ )
319
+ sha_before = adapter_sha(model)
320
+ print(f"[trainer] layers={len(tr.layers)} lora_params={tr.n_lora/1e6:.2f}M "
321
+ f"tied={tr.tied} rss={host_rss_gb()}GB params={count_lora_params(model)/1e6:.2f}M",
322
+ flush=True)
323
+
324
+ losses, times, peaks = [], [], []
325
+ ce0 = ce1 = None
326
+ wall = 0.0
327
+ t_all = time.time()
328
+
329
+ if args.sft:
330
+ from datasets import load_dataset
331
+ from transformers import AutoTokenizer
332
+
333
+ tok = AutoTokenizer.from_pretrained(model_dir)
334
+ if tok.pad_token is None:
335
+ tok.pad_token = tok.eos_token
336
+ ds = load_dataset(args.dataset, split="train").shuffle(seed=1234)
337
+ train_rows = [ds[i] for i in range(min(args.train_size, len(ds)))]
338
+ eval_rows = [ds[len(train_rows) + i]
339
+ for i in range(min(args.eval_size, len(ds) - len(train_rows)))]
340
+ print(f"[sft] {Path(args.dataset).name} train={len(train_rows)} "
341
+ f"eval={len(eval_rows)}", flush=True)
342
+ ce0 = _masked_eval(tr, eval_rows, tok, args.seq)
343
+ print(f"[eval] before loss={ce0:.4f}", flush=True)
344
+ for i in range(args.steps):
345
+ enc = _encode_chat(tok, train_rows[i % len(train_rows)], args.seq)
346
+ if enc is None:
347
+ continue
348
+ ids, labels = enc
349
+ ys = torch.full_like(ids, -100)
350
+ ys[:, :-1] = labels[:, 1:]
351
+ loss, dt, peak = tr.step(ids, ys, ignore_index=-100)
352
+ losses.append(round(loss, 6))
353
+ times.append(round(dt, 3))
354
+ peaks.append(round(peak, 3))
355
+ if adapt is not None:
356
+ adapt(peak, i)
357
+ if (i + 1) % 10 == 0:
358
+ print(f"[step] {i+1}/{args.steps} loss={loss:.4f} {dt:.2f}s "
359
+ f"peak={peak:.2f}GB rss={host_rss_gb()}GB", flush=True)
360
+ ce1 = _masked_eval(tr, eval_rows, tok, args.seq)
361
+ print(f"[eval] after loss={ce1:.4f} (delta {ce1-ce0:+.4f})", flush=True)
362
+ wall = time.time() - t_all
363
+ else:
364
+ tokens = load_tokens(str(ROOT / args.data))
365
+ sampler = TrainSampler(tokens, args.seq, generator=torch.Generator().manual_seed(1234))
366
+ batches = [sampler.batch(1) for _ in range(args.steps)]
367
+
368
+ if args.eval_chunks > 0:
369
+ ev = chunks_from_tokens(load_tokens(str(ROOT / args.eval_data)), args.eval_seq,
370
+ args.eval_chunks)
371
+ ce0 = sum(stream_eval_ce(tr, ev[i].unsqueeze(0), args.chunk)
372
+ for i in range(len(ev))) / len(ev)
373
+ print(f"[eval] before CE={ce0:.4f} on {args.eval_chunks} chunks of "
374
+ f"{args.eval_seq}", flush=True)
375
+
376
+ for i, (xs, ys) in enumerate(batches):
377
+ loss, dt, peak = tr.step(xs, ys)
378
+ losses.append(round(loss, 6))
379
+ times.append(round(dt, 3))
380
+ peaks.append(round(peak, 3))
381
+ if adapt is not None:
382
+ adapt(peak, i)
383
+ print(f"[step] {i+1}/{len(batches)} loss={loss:.4f} {dt:.2f}s peak={peak:.2f}GB "
384
+ f"rss={host_rss_gb()}GB", flush=True)
385
+
386
+ if args.eval_chunks > 0:
387
+ ce1 = sum(stream_eval_ce(tr, ev[i].unsqueeze(0), args.chunk)
388
+ for i in range(len(ev))) / len(ev)
389
+ print(f"[eval] after CE={ce1:.4f} (delta {ce1-ce0:+.4f})", flush=True)
390
+
391
+ wall = time.time() - t_all
392
+
393
+ saved = None
394
+ if args.save:
395
+ save_path = ROOT / args.save
396
+ save_path.mkdir(parents=True, exist_ok=True)
397
+ torch.save(lora_state_dict(model), str(save_path / "adapter.pt"))
398
+ (save_path / "adapter_config.json").write_text(json.dumps({
399
+ "r": args.lora_r, "alpha": args.lora_alpha, "targets": args.targets,
400
+ "base": Path(model_dir).name,
401
+ }, indent=2), encoding="utf-8")
402
+ saved = args.save
403
+ print(f"[save] {save_path}", flush=True)
404
+
405
+ row = {
406
+ "exp": "exp54_disk_qlora",
407
+ "tag": args.tag,
408
+ "mode": "sft" if args.sft else "lm",
409
+ "dataset": args.dataset if args.sft else None,
410
+ "model": Path(model_dir).name,
411
+ "weights": "mmap from disk (no RAM-resident base)",
412
+ "base_quant": "nf4",
413
+ "lora_r": args.lora_r,
414
+ "lora_alpha": args.lora_alpha,
415
+ "targets": args.targets,
416
+ "head": args.head,
417
+ "shared_dtype": args.shared_dtype,
418
+ "blocks_mode": args.blocks_on_gpu,
419
+ "blocks_on_gpu": cache.capacity,
420
+ "blocks_on_gpu_initial": start_blocks,
421
+ "blocks_history": adapt_hist,
422
+ "trainable_params": tr.n_lora,
423
+ "grad_layers": len(tr.grad_layers),
424
+ "n_layers": len(tr.layers),
425
+ "adapter_sha_before": sha_before,
426
+ "adapter_sha_after": adapter_sha(model),
427
+ "steps": len(losses),
428
+ "seq": args.seq,
429
+ "lr": args.lr,
430
+ "losses": losses,
431
+ "step_s_med": sorted(times)[len(times) // 2] if times else None,
432
+ "wall_s": round(wall, 1),
433
+ "peak_gb_max": max(peaks) if peaks else None,
434
+ "host_rss_gb": host_rss_gb(),
435
+ "host_private_gb": host_private_gb(),
436
+ "ce_before": round(ce0, 5) if ce0 is not None else None,
437
+ "ce_after": round(ce1, 5) if ce1 is not None else None,
438
+ "ce_delta": round(ce1 - ce0, 6) if (ce0 is not None and ce1 is not None) else None,
439
+ "saved": saved,
440
+ "ts": now(),
441
+ }
442
+ print(json.dumps(row, ensure_ascii=True), flush=True)
443
+ append_jsonl(str(ROOT / "results" / "results.jsonl"), row)
444
+ with open(ROOT / "results" / "exp54_disk_qlora.log", "a", encoding="utf-8") as f:
445
+ f.write(json.dumps(row, ensure_ascii=False) + "\n")
446
+ return 0
447
+
448
+
449
+ if __name__ == "__main__":
450
+ raise SystemExit(main())
streamft/data.py ADDED
@@ -0,0 +1,81 @@
1
+ """Data preparation: wikitext-2 (train/dev), wikitext-103 test, TinyStories validation."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ import torch
6
+
7
+
8
+ def _tok(tokenizer, text):
9
+ ids = tokenizer(text)["input_ids"]
10
+ return torch.tensor(ids, dtype=torch.int32)
11
+
12
+
13
+ def get_wikitext(config="wikitext-2-raw-v1", split="train"):
14
+ from datasets import load_dataset
15
+ ds = load_dataset("Salesforce/wikitext", config, split=split)
16
+ return "\n".join(ds["text"])
17
+
18
+
19
+ def get_tinystories(n_stories=6000):
20
+ from datasets import load_dataset
21
+ ds = load_dataset("roneneldan/TinyStories", split="validation")
22
+ n = min(n_stories, len(ds))
23
+ return "\n\n".join(ds[i]["text"] for i in range(n))
24
+
25
+
26
+ def build_all(tokenizer, data_dir, force=False):
27
+ os.makedirs(data_dir, exist_ok=True)
28
+ jobs = {
29
+ "wt2_train": lambda: get_wikitext("wikitext-2-raw-v1", "train"),
30
+ "wt2_test": lambda: get_wikitext("wikitext-2-raw-v1", "test"),
31
+ "wt103_test": lambda: get_wikitext("wikitext-103-raw-v1", "test"),
32
+ "tinystories_valid": lambda: get_tinystories(6000),
33
+ }
34
+ out = {}
35
+ for name, fn in jobs.items():
36
+ path = os.path.join(data_dir, f"{name}.pt")
37
+ if os.path.exists(path) and not force:
38
+ out[name] = path
39
+ continue
40
+ text = fn()
41
+ ids = _tok(tokenizer, text)
42
+ torch.save(ids, path)
43
+ out[name] = path
44
+ print(f"[data] {name}: {len(ids)} tokens -> {path}", flush=True)
45
+ return out
46
+
47
+
48
+ def load_tokens(path):
49
+ return torch.load(path, weights_only=True)
50
+
51
+
52
+ def chunks_from_tokens(tokens, seq_len, max_chunks=None):
53
+ """Non-overlapping chunks of seq_len tokens -> (n, seq_len) int64."""
54
+ n = len(tokens) // seq_len
55
+ if max_chunks is not None:
56
+ n = min(n, max_chunks)
57
+ return tokens[: n * seq_len].view(n, seq_len).long()
58
+
59
+
60
+ def samples_from_tokens(tokens, seq_len, max_samples=None, seed=0):
61
+ """Random non-overlapping windows for training."""
62
+ n = len(tokens) // seq_len
63
+ if max_samples is not None:
64
+ n = min(n, max_samples)
65
+ return tokens[: n * seq_len].view(n, seq_len).long()
66
+
67
+
68
+ class TrainSampler:
69
+ """Iterate random contiguous windows over a token stream (random offsets)."""
70
+
71
+ def __init__(self, tokens, seq_len, generator=None):
72
+ self.tokens = tokens
73
+ self.seq_len = seq_len
74
+ self.g = generator or torch.Generator().manual_seed(0)
75
+
76
+ def batch(self, batch_size):
77
+ max_start = len(self.tokens) - self.seq_len - 1
78
+ starts = torch.randint(0, max_start, (batch_size,), generator=self.g).tolist()
79
+ xs = torch.stack([self.tokens[s: s + self.seq_len].long() for s in starts])
80
+ ys = torch.stack([self.tokens[s + 1: s + self.seq_len + 1].long() for s in starts])
81
+ return xs, ys