cfahlgren1 HF Staff commited on
Commit
9bba47b
·
verified ·
1 Parent(s): 88610cb

parallel data prep, data on GPU, trackio, safetensors

Browse files
Files changed (1) hide show
  1. v2/train.py +224 -104
v2/train.py CHANGED
@@ -4,21 +4,29 @@
4
  # "torch>=2.6,<2.8",
5
  # "datasets>=3.0",
6
  # "numpy<3",
 
7
  # "tokenizers>=0.20",
8
  # "transformers>=4.46",
9
  # "onnx>=1.17",
10
  # "onnxruntime>=1.20",
 
11
  # "huggingface_hub>=0.30",
 
 
12
  # ]
13
  # ///
14
  """tinynext v2: a small word-piece transformer for in-browser autocomplete that can also fill in the middle.
15
 
16
  One script does everything: it trains a tokenizer, builds a mix of general text, conversation and email,
17
  trains the model, scores it with eval.py (the same harness as tinynext v1 and SmolLM2), exports ONNX with a
18
- key/value cache for the browser, and uploads the lot to the model repo under v2/.
 
19
 
20
- hf jobs uv run --flavor a100-large --secrets HF_TOKEN train.py
21
- uv run train.py --smoke --no-push # a few minutes on a laptop, to check the whole pipeline
 
 
 
22
  """
23
 
24
  import argparse
@@ -28,6 +36,7 @@ import math
28
  import os
29
  import random
30
  import shutil
 
31
  import time
32
  from pathlib import Path
33
 
@@ -40,12 +49,21 @@ HERE = Path(__file__).parent
40
  SPECIAL = ["<|endoftext|>", "<|pre|>", "<|suf|>", "<|mid|>"]
41
  EOT, PRE, SUF, MID = range(4)
42
 
 
 
 
 
 
 
 
 
 
43
  # ---- Shared eval code ------------------------------------------------------------------------------------------
44
 
45
 
46
  def load_eval_module(repo: str, folder: str):
47
  """Uses eval.py and eval_set.json next to this script, or fetches them from the repo, so every model is scored
48
- by one implementation on the same text."""
49
  path = HERE / "eval.py"
50
  if not path.exists():
51
  from huggingface_hub import hf_hub_download
@@ -54,6 +72,7 @@ def load_eval_module(repo: str, folder: str):
54
  shutil.copy(hf_hub_download(repo, f"{folder}/{name}"), HERE / name)
55
  spec = importlib.util.spec_from_file_location("tinynext_eval", path)
56
  module = importlib.util.module_from_spec(spec)
 
57
  spec.loader.exec_module(module)
58
  return module
59
 
@@ -61,80 +80,69 @@ def load_eval_module(repo: str, folder: str):
61
  # ---- Data ------------------------------------------------------------------------------------------------------
62
 
63
 
64
- def fineweb_docs():
65
- from datasets import load_dataset
66
-
67
- for row in load_dataset("HuggingFaceFW/fineweb-edu", name="sample-10BT", split="train", streaming=True):
68
- yield row["text"]
69
 
 
70
 
71
- def smoltalk_docs():
72
- from datasets import load_dataset
73
 
74
- for row in load_dataset("HuggingFaceTB/smoltalk", "all", split="train", streaming=True):
75
- turns = [m["content"] for m in row["messages"] if m["role"] in ("user", "assistant")]
76
- if turns:
77
- yield "\n\n".join(turns)
78
 
79
 
80
- def enron_docs(ev):
81
- """Training senders only: the eval's held-out senders (and enron-autocomplete's val split) are skipped."""
82
  import hashlib
83
 
 
 
 
 
 
 
 
 
 
 
 
 
 
84
  from datasets import load_dataset
85
 
86
- for row in load_dataset("corbt/enron-emails", split="train", streaming=True):
87
- sender = (row["from"] or "").lower()
88
- if "@enron.com" not in sender or ev.BULK_SENDER.search(sender):
89
- continue
90
- if int(hashlib.sha1(sender.encode()).hexdigest(), 16) % 100 < 10:
91
- continue
92
- paragraphs = ev.clean_email(row["body"] or "")
93
- if paragraphs:
94
- yield "\n\n".join(paragraphs)
95
-
96
-
97
- def mixed_docs(ev, weights: dict[str, float], seed: int):
98
- """Interleaves the sources by weight, forever (until the caller stops)."""
99
- rng = random.Random(seed)
100
- sources = {"fineweb": fineweb_docs(), "smoltalk": smoltalk_docs(), "enron": enron_docs(ev)}
101
- names, probs = zip(*weights.items())
102
- counts = dict.fromkeys(names, 0)
103
- while True:
104
- name = rng.choices(names, probs)[0]
105
- try:
106
- doc = next(sources[name])
107
- except StopIteration:
108
- sources[name] = {"fineweb": fineweb_docs, "smoltalk": smoltalk_docs}.get(name, lambda: enron_docs(ev))()
109
- continue
110
- counts[name] += 1
111
- # Log the first document from each source and then every 50k, so a stalled download shows which one.
112
- if counts[name] == 1 or counts[name] % 50_000 == 0:
113
- print(f" data seed {seed}: {name} doc {counts[name]:,}", flush=True)
114
- yield doc
115
-
116
-
117
- def clean(text: str, ev) -> str:
118
- """The browser model only sees keyboard characters, so train on the same."""
119
- return text.translate(ev.SMART_CHARS).encode("ascii", "ignore").decode().strip()
120
 
121
 
122
- def train_tokenizer(docs, vocab: int, sample_docs: int):
123
  from tokenizers import Tokenizer, decoders, models, pre_tokenizers, trainers
124
 
 
 
 
 
 
 
125
  tokenizer = Tokenizer(models.BPE())
126
  tokenizer.pre_tokenizer = pre_tokenizers.ByteLevel(add_prefix_space=False)
127
  tokenizer.decoder = decoders.ByteLevel()
128
  trainer = trainers.BpeTrainer(vocab_size=vocab, special_tokens=SPECIAL, min_frequency=2,
129
  initial_alphabet=pre_tokenizers.ByteLevel.alphabet())
130
- tokenizer.train_from_iterator((next(docs) for _ in range(sample_docs)), trainer=trainer)
131
  return tokenizer
132
 
133
 
134
  def fim(ids: list[int], rng: random.Random, max_middle: int, max_suffix: int, max_prefix: int) -> list[int]:
135
  """Suffix first, then prefix, then the middle, so typing at the cursor only appends to the prompt."""
136
  if len(ids) < 8:
137
- return ids
138
  start = rng.randint(1, len(ids) - 2)
139
  end = min(len(ids), start + rng.randint(1, max_middle))
140
  prefix, middle, suffix = ids[max(0, start - max_prefix):start], ids[start:end], ids[end:end + max_suffix]
@@ -143,30 +151,58 @@ def fim(ids: list[int], rng: random.Random, max_middle: int, max_suffix: int, ma
143
  return [SUF, *suffix, PRE, *prefix, MID, *middle, EOT]
144
 
145
 
146
- def build_tokens(tokenizer, docs, ev, total: int, seq: int, fim_rate: float, seed: int) -> np.ndarray:
147
- """Packs documents into rows of seq+1 tokens. A share of chunks become fill-in-the-middle examples."""
148
- rng = random.Random(seed)
149
- out = np.empty(total + seq + 1, dtype=np.uint16)
150
- filled, started, next_report = 0, time.time(), 0
151
- while filled < total:
152
- batch = [clean(next(docs), ev) for _ in range(512)]
153
- for ids in tokenizer.encode_batch([b for b in batch if b]):
154
- ids = ids.ids
 
 
155
  for at in range(0, len(ids), seq - 8):
156
  chunk = ids[at: at + seq - 8]
157
- piece = fim(chunk, rng, 24, 64, seq - 100) if rng.random() < fim_rate else chunk + [EOT]
158
- take = min(len(piece), len(out) - filled)
159
- out[filled: filled + take] = piece[:take]
160
- filled += take
161
- if filled >= total:
162
- break
163
- if filled >= total:
164
- break
165
- if filled >= next_report:
166
- print(f" tokens {filled / 1e6:.0f}M / {total / 1e6:.0f}M ({time.time() - started:.0f}s)", flush=True)
167
- next_report += 20_000_000
168
- rows = total // (seq + 1)
169
- return out[: rows * (seq + 1)].reshape(rows, seq + 1)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
170
 
171
 
172
  # ---- Model -----------------------------------------------------------------------------------------------------
@@ -261,32 +297,57 @@ class BrowserStep(nn.Module):
261
  # ---- Training --------------------------------------------------------------------------------------------------
262
 
263
 
264
- def evaluate_loss(model, rows: np.ndarray, device, batch: int) -> float:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
265
  model.eval()
266
  losses = []
267
  with torch.no_grad(), torch.autocast(device.type, dtype=torch.bfloat16, enabled=device.type == "cuda"):
268
  for at in range(0, len(rows), batch):
269
- chunk = torch.from_numpy(rows[at: at + batch].astype(np.int64)).to(device)
270
  logits, _, _ = model(chunk[:, :-1])
271
  losses.append(F.cross_entropy(logits.float().flatten(0, 1), chunk[:, 1:].flatten()).item())
272
  model.train()
273
  return float(np.mean(losses))
274
 
275
 
276
- def train(model, rows: np.ndarray, val_rows: np.ndarray, args, device) -> list[dict]:
277
  steps = int(args.epochs * len(rows) // args.batch)
278
- decay = [p for n, p in model.named_parameters() if p.dim() >= 2]
279
- no_decay = [p for n, p in model.named_parameters() if p.dim() < 2]
280
  optimizer = torch.optim.AdamW([{"params": decay, "weight_decay": 0.1}, {"params": no_decay, "weight_decay": 0.0}],
281
  lr=args.lr, betas=(0.9, 0.95), fused=device.type == "cuda")
282
  warmup = max(1, steps // 50)
283
  schedule = lambda step: min(1, step / warmup) * (0.1 + 0.9 * 0.5 * (1 + math.cos(math.pi * min(1, step / steps))))
284
  scheduler = torch.optim.lr_scheduler.LambdaLR(optimizer, schedule)
285
  compiled = torch.compile(model) if device.type == "cuda" else model
286
- history, rng, started = [], np.random.default_rng(0), time.time()
 
 
 
 
287
  for step in range(steps):
288
- index = rng.integers(0, len(rows), args.batch)
289
- chunk = torch.from_numpy(rows[index].astype(np.int64)).to(device, non_blocking=True)
290
  with torch.autocast(device.type, dtype=torch.bfloat16, enabled=device.type == "cuda"):
291
  logits, _, _ = compiled(chunk[:, :-1])
292
  loss = F.cross_entropy(logits.float().flatten(0, 1), chunk[:, 1:].flatten())
@@ -295,12 +356,23 @@ def train(model, rows: np.ndarray, val_rows: np.ndarray, args, device) -> list[d
295
  torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
296
  optimizer.step()
297
  scheduler.step()
 
298
  if step % args.log_every == 0 or step == steps - 1:
299
- entry = {"step": step, "train_loss": round(loss.item(), 4), "minutes": round((time.time() - started) / 60, 2)}
 
 
 
 
 
 
 
 
300
  if step % (args.log_every * 10) == 0 or step == steps - 1:
301
  entry["val_loss"] = round(evaluate_loss(model, val_rows, device, args.batch), 4)
302
  history.append(entry)
 
303
  print(json.dumps(entry), flush=True)
 
304
  return history
305
 
306
 
@@ -365,6 +437,18 @@ def export(model: TinyNext, output: Path, device) -> dict:
365
  return report
366
 
367
 
 
 
 
 
 
 
 
 
 
 
 
 
368
  # ---- Main ------------------------------------------------------------------------------------------------------
369
 
370
 
@@ -375,58 +459,91 @@ def parse_args():
375
  parser.add_argument("--layers", type=int, default=8)
376
  parser.add_argument("--heads", type=int, default=5)
377
  parser.add_argument("--ffn", type=int, default=864)
378
- parser.add_argument("--seq", type=int, default=256)
379
- parser.add_argument("--tokens", type=float, default=800e6, help="Training tokens to build")
 
380
  parser.add_argument("--epochs", type=float, default=1.0)
381
- parser.add_argument("--batch", type=int, default=256)
382
  parser.add_argument("--lr", type=float, default=3e-3)
383
  parser.add_argument("--fim-rate", type=float, default=0.5)
384
  parser.add_argument("--mix", default="fineweb=0.6,smoltalk=0.3,enron=0.1")
 
 
385
  parser.add_argument("--tokenizer-docs", type=int, default=200_000)
386
  parser.add_argument("--eval-pauses", type=int, default=1000)
387
- parser.add_argument("--log-every", type=int, default=100)
388
  parser.add_argument("--repo", default="cfahlgren1/tinynext")
389
  parser.add_argument("--folder", default="v2")
390
  parser.add_argument("--name", default="tinynext-v2")
 
391
  parser.add_argument("--smoke", action="store_true", help="Tiny settings to check the pipeline end to end")
392
  parser.add_argument("--no-push", action="store_true")
393
  args = parser.parse_args()
394
  if args.smoke:
395
- args.vocab, args.dim, args.layers, args.heads, args.ffn = 2048, 64, 2, 2, 128
396
- args.tokens, args.batch, args.tokenizer_docs, args.eval_pauses, args.log_every = 3e6, 32, 3000, 20, 20
 
397
  args.name = "tinynext-v2-smoke"
398
  return args
399
 
400
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
401
  def main():
402
  args = parse_args()
403
  device = torch.device("cuda" if torch.cuda.is_available() else "mps" if torch.backends.mps.is_available() else "cpu")
404
  torch.manual_seed(0)
 
 
405
  output = HERE / "output"
406
  output.mkdir(exist_ok=True)
407
  ev = load_eval_module(args.repo, args.folder)
408
  weights = {k: float(v) for k, v in (part.split("=") for part in args.mix.split(","))}
409
- print(f"device {device}, mix {weights}", flush=True)
 
410
 
411
- tokenizer = train_tokenizer((clean(d, ev) for d in mixed_docs(ev, weights, seed=1)), args.vocab, args.tokenizer_docs)
 
 
 
412
  tokenizer.save(str(output / "tokenizer.json"))
413
- # Validation takes the first documents of the stream and training continues after them, so they never overlap.
414
- docs = mixed_docs(ev, weights, seed=2)
415
- val_rows = build_tokens(tokenizer, docs, ev, int(min(2e6, args.tokens / 20)), args.seq, args.fim_rate, seed=5)
416
- rows = build_tokens(tokenizer, docs, ev, int(args.tokens), args.seq, args.fim_rate, seed=3)
417
 
 
 
 
418
  model = TinyNext(tokenizer.get_vocab_size(), args.dim, args.layers, args.heads, args.ffn).to(device)
419
  parameters = sum(p.numel() for p in model.parameters())
420
- print(f"{parameters / 1e6:.2f}M parameters, {rows.size / 1e6:.0f}M tokens", flush=True)
421
  started = time.time()
422
- history = train(model, rows, val_rows, args, device)
423
- minutes = (time.time() - started) / 60
424
 
425
  model.eval()
426
- torch.save({"config": model.config, "state_dict": model.state_dict()}, output / "model.pt")
 
 
427
  scored = ev.score(as_token_lm(ev, args.name, model, tokenizer, device), ev.pauses(ev.load_eval_set(), args.eval_pauses))
428
  (output / "metrics").mkdir(exist_ok=True)
429
  (output / "metrics" / f"{args.name}.json").write_text(json.dumps(scored, indent=1))
 
430
  exported = export(model, output, device)
431
 
432
  config = {
@@ -434,16 +551,19 @@ def main():
434
  "prompt": "No text after the cursor: the text before it. Otherwise: <|suf|> after <|pre|> before <|mid|>.",
435
  }
436
  (output / "config.json").write_text(json.dumps(config, indent=1))
437
- report = {"args": vars(args), "parameters": parameters, "train_minutes": round(minutes, 1), "history": history, "exports": exported}
 
438
  (output / "report.json").write_text(json.dumps(report, indent=1))
439
  print(json.dumps(exported, indent=1))
440
  print(ev.table([output / "metrics" / f"{args.name}.json"]))
 
441
 
442
  for name in ("train.py", "eval.py", "eval_set.json"):
443
  shutil.copy(HERE / name, output / name)
444
  if (HERE / "metrics").exists():
445
  for path in (HERE / "metrics").glob("*.json"):
446
- shutil.copy(path, output / "metrics" / path.name)
 
447
  if args.no_push:
448
  print(f"Skipping upload; everything is in {output}")
449
  return
@@ -451,7 +571,7 @@ def main():
451
 
452
  HfApi(token=os.environ.get("HF_TOKEN")).upload_folder(
453
  folder_path=str(output), repo_id=args.repo, path_in_repo=args.folder, repo_type="model",
454
- commit_message=f"{args.name}: {parameters / 1e6:.1f}M params, trained {minutes:.0f} min",
455
  )
456
  print(f"Uploaded to https://huggingface.co/{args.repo}/tree/main/{args.folder}")
457
 
 
4
  # "torch>=2.6,<2.8",
5
  # "datasets>=3.0",
6
  # "numpy<3",
7
+ # "pyarrow>=15",
8
  # "tokenizers>=0.20",
9
  # "transformers>=4.46",
10
  # "onnx>=1.17",
11
  # "onnxruntime>=1.20",
12
+ # "safetensors>=0.4",
13
  # "huggingface_hub>=0.30",
14
+ # "nvidia-ml-py>=12",
15
+ # "trackio>=0.2",
16
  # ]
17
  # ///
18
  """tinynext v2: a small word-piece transformer for in-browser autocomplete that can also fill in the middle.
19
 
20
  One script does everything: it trains a tokenizer, builds a mix of general text, conversation and email,
21
  trains the model, scores it with eval.py (the same harness as tinynext v1 and SmolLM2), exports ONNX with a
22
+ key/value cache for the browser, and uploads the lot to the model repo under v2/. Training curves, throughput
23
+ and GPU use go to a Trackio dashboard.
24
 
25
+ hf jobs uv run --namespace huggingface --flavor a100-large --secrets HF_TOKEN train.py
26
+ hf jobs uv run --namespace huggingface --flavor a10g-small --secrets HF_TOKEN train.py --smoke --no-push
27
+
28
+ The data is prepared on every CPU core before training starts, and the whole token array then sits in GPU
29
+ memory, so the GPU never waits on Python between steps.
30
  """
31
 
32
  import argparse
 
36
  import os
37
  import random
38
  import shutil
39
+ import sys
40
  import time
41
  from pathlib import Path
42
 
 
49
  SPECIAL = ["<|endoftext|>", "<|pre|>", "<|suf|>", "<|mid|>"]
50
  EOT, PRE, SUF, MID = range(4)
51
 
52
+ FINEWEB = "hf://datasets/HuggingFaceFW/fineweb-edu/sample/10BT/{:03d}_00000.parquet"
53
+ SMOLTALK = "hf://datasets/HuggingFaceTB/smoltalk/data/all/train-{:05d}-of-00009.parquet"
54
+ ENRON = "hf://datasets/corbt/enron-emails/data/train-{:05d}-of-00003.parquet"
55
+
56
+
57
+ def cpus() -> int:
58
+ return len(os.sched_getaffinity(0)) if hasattr(os, "sched_getaffinity") else os.cpu_count() or 1
59
+
60
+
61
  # ---- Shared eval code ------------------------------------------------------------------------------------------
62
 
63
 
64
  def load_eval_module(repo: str, folder: str):
65
  """Uses eval.py and eval_set.json next to this script, or fetches them from the repo, so every model is scored
66
+ by one implementation on the same text. Registered as a module so worker processes can use it too."""
67
  path = HERE / "eval.py"
68
  if not path.exists():
69
  from huggingface_hub import hf_hub_download
 
72
  shutil.copy(hf_hub_download(repo, f"{folder}/{name}"), HERE / name)
73
  spec = importlib.util.spec_from_file_location("tinynext_eval", path)
74
  module = importlib.util.module_from_spec(spec)
75
+ sys.modules["tinynext_eval"] = module
76
  spec.loader.exec_module(module)
77
  return module
78
 
 
80
  # ---- Data ------------------------------------------------------------------------------------------------------
81
 
82
 
83
+ def to_ascii(text: str) -> str:
84
+ """The browser model only sees keyboard characters, so it trains on the same."""
85
+ import tinynext_eval as ev
 
 
86
 
87
+ return text.translate(ev.SMART_CHARS).encode("ascii", "ignore").decode().strip()
88
 
 
 
89
 
90
+ def smoltalk_text(batch):
91
+ return {"text": [to_ascii("\n\n".join(m["content"] for m in turns if m["role"] in ("user", "assistant"))) for turns in batch["messages"]]}
 
 
92
 
93
 
94
+ def enron_text(batch):
95
+ """Training senders only: the eval's held-out senders and enron-autocomplete's validation senders are skipped."""
96
  import hashlib
97
 
98
+ import tinynext_eval as ev
99
+
100
+ texts = []
101
+ for sender, body in zip(batch["from"], batch["body"]):
102
+ sender = (sender or "").lower()
103
+ keep = "@enron.com" in sender and not ev.BULK_SENDER.search(sender)
104
+ keep = keep and int(hashlib.sha1(sender.encode()).hexdigest(), 16) % 100 >= 10
105
+ texts.append(to_ascii("\n\n".join(ev.clean_email(body or ""))) if keep else "")
106
+ return {"text": texts}
107
+
108
+
109
+ def load_sources(args) -> dict:
110
+ """Downloads whole parquet files and cleans them on every core."""
111
  from datasets import load_dataset
112
 
113
+ procs = cpus()
114
+ fineweb = load_dataset("parquet", data_files=[FINEWEB.format(i) for i in range(args.fineweb_files)], split="train")
115
+ fineweb = fineweb.select_columns(["text"]).map(lambda b: {"text": [to_ascii(t) for t in b["text"]]}, batched=True, num_proc=procs)
116
+ smoltalk = load_dataset("parquet", data_files=[SMOLTALK.format(i) for i in range(args.smoltalk_files)], split="train")
117
+ smoltalk = smoltalk.map(smoltalk_text, batched=True, num_proc=procs, remove_columns=smoltalk.column_names)
118
+ enron = load_dataset("parquet", data_files=[ENRON.format(i) for i in range(3)], split="train")
119
+ enron = enron.map(enron_text, batched=True, num_proc=procs, remove_columns=enron.column_names)
120
+ sources = {"fineweb": fineweb, "smoltalk": smoltalk, "enron": enron}
121
+ return {name: ds.filter(lambda b: [len(t) >= 40 for t in b["text"]], batched=True, num_proc=procs) for name, ds in sources.items()}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
122
 
123
 
124
+ def train_tokenizer(sources: dict, weights: dict, vocab: int, sample_docs: int):
125
  from tokenizers import Tokenizer, decoders, models, pre_tokenizers, trainers
126
 
127
+ def sample():
128
+ for name, weight in weights.items():
129
+ ds = sources[name].shuffle(seed=0)
130
+ for row in ds.select(range(min(len(ds), int(sample_docs * weight)))):
131
+ yield row["text"]
132
+
133
  tokenizer = Tokenizer(models.BPE())
134
  tokenizer.pre_tokenizer = pre_tokenizers.ByteLevel(add_prefix_space=False)
135
  tokenizer.decoder = decoders.ByteLevel()
136
  trainer = trainers.BpeTrainer(vocab_size=vocab, special_tokens=SPECIAL, min_frequency=2,
137
  initial_alphabet=pre_tokenizers.ByteLevel.alphabet())
138
+ tokenizer.train_from_iterator(sample(), trainer=trainer)
139
  return tokenizer
140
 
141
 
142
  def fim(ids: list[int], rng: random.Random, max_middle: int, max_suffix: int, max_prefix: int) -> list[int]:
143
  """Suffix first, then prefix, then the middle, so typing at the cursor only appends to the prompt."""
144
  if len(ids) < 8:
145
+ return ids + [EOT]
146
  start = rng.randint(1, len(ids) - 2)
147
  end = min(len(ids), start + rng.randint(1, max_middle))
148
  prefix, middle, suffix = ids[max(0, start - max_prefix):start], ids[start:end], ids[end:end + max_suffix]
 
151
  return [SUF, *suffix, PRE, *prefix, MID, *middle, EOT]
152
 
153
 
154
+ def make_encoder(tokenizer_json: str, seq: int, fim_rate: float):
155
+ """Tokenizes a batch and turns each document into training pieces: plain chunks, or fill-in-the-middle examples."""
156
+
157
+ def encode(batch, indices):
158
+ from tokenizers import Tokenizer
159
+
160
+ tokenizer = Tokenizer.from_str(tokenizer_json)
161
+ rng = random.Random(indices[0])
162
+ out = []
163
+ for encoding in tokenizer.encode_batch(batch["text"]):
164
+ ids, pieces = encoding.ids, []
165
  for at in range(0, len(ids), seq - 8):
166
  chunk = ids[at: at + seq - 8]
167
+ pieces += fim(chunk, rng, 24, 64, seq - 100) if rng.random() < fim_rate else chunk + [EOT]
168
+ out.append(pieces)
169
+ return {"tokens": out}
170
+
171
+ return encode
172
+
173
+
174
+ def flatten(ds, budget: int) -> np.ndarray:
175
+ """All of a source's tokens as one array, cut to its share of the budget (repeated if the source is small)."""
176
+ import pyarrow.compute as pc
177
+
178
+ column = ds.data.column("tokens")
179
+ tokens = pc.list_flatten(column).to_numpy().astype(np.uint16)
180
+ if len(tokens) < budget:
181
+ print(f" only {len(tokens) / 1e6:.0f}M tokens, repeating to reach {budget / 1e6:.0f}M", flush=True)
182
+ tokens = np.tile(tokens, math.ceil(budget / len(tokens)))
183
+ return tokens[:budget]
184
+
185
+
186
+ def build_rows(sources: dict, tokenizer, weights: dict, args) -> tuple[np.ndarray, np.ndarray, dict]:
187
+ encode = make_encoder(tokenizer.to_str(), args.seq, args.fim_rate)
188
+ train_parts, val_parts, counts = [], [], {}
189
+ for name, weight in weights.items():
190
+ started = time.time()
191
+ split = sources[name].train_test_split(test_size=0.002, seed=0)
192
+ encoded = {part: split[part].map(encode, batched=True, batch_size=512, with_indices=True, num_proc=cpus(),
193
+ remove_columns=["text"]) for part in ("train", "test")}
194
+ budget = int(args.tokens * weight)
195
+ train_parts.append(flatten(encoded["train"], budget))
196
+ val_parts.append(flatten(encoded["test"], int(args.val_tokens * weight)))
197
+ counts[name] = {"docs": len(split["train"]), "train_tokens": int(len(train_parts[-1]))}
198
+ print(f" {name}: {len(split['train']):,} docs → {len(train_parts[-1]) / 1e6:.0f}M tokens in {time.time() - started:.0f}s", flush=True)
199
+
200
+ def rows(parts):
201
+ tokens = np.concatenate(parts)
202
+ count = len(tokens) // (args.seq + 1)
203
+ return tokens[: count * (args.seq + 1)].reshape(count, args.seq + 1)
204
+
205
+ return rows(train_parts), rows(val_parts), counts
206
 
207
 
208
  # ---- Model -----------------------------------------------------------------------------------------------------
 
297
  # ---- Training --------------------------------------------------------------------------------------------------
298
 
299
 
300
+ class GpuMonitor:
301
+ """Reads GPU utilization and memory through NVML, so the logs show whether the GPU is actually busy."""
302
+
303
+ def __init__(self):
304
+ try:
305
+ import pynvml
306
+
307
+ pynvml.nvmlInit()
308
+ self.nvml, self.handle = pynvml, pynvml.nvmlDeviceGetHandleByIndex(0)
309
+ except Exception as error: # no NVIDIA GPU (a laptop smoke run): report nothing rather than fail
310
+ print(f"GPU monitoring off: {error}", flush=True)
311
+ self.nvml = None
312
+
313
+ def read(self) -> dict:
314
+ if not self.nvml:
315
+ return {}
316
+ rates = self.nvml.nvmlDeviceGetUtilizationRates(self.handle)
317
+ memory = self.nvml.nvmlDeviceGetMemoryInfo(self.handle)
318
+ return {"gpu_util_percent": rates.gpu, "gpu_memory_gb": round(memory.used / 1e9, 1)}
319
+
320
+
321
+ def evaluate_loss(model, rows: torch.Tensor, device, batch: int) -> float:
322
  model.eval()
323
  losses = []
324
  with torch.no_grad(), torch.autocast(device.type, dtype=torch.bfloat16, enabled=device.type == "cuda"):
325
  for at in range(0, len(rows), batch):
326
+ chunk = rows[at: at + batch].long()
327
  logits, _, _ = model(chunk[:, :-1])
328
  losses.append(F.cross_entropy(logits.float().flatten(0, 1), chunk[:, 1:].flatten()).item())
329
  model.train()
330
  return float(np.mean(losses))
331
 
332
 
333
+ def train(model, rows: torch.Tensor, val_rows: torch.Tensor, args, device, tracker) -> list[dict]:
334
  steps = int(args.epochs * len(rows) // args.batch)
335
+ decay = [p for p in model.parameters() if p.dim() >= 2]
336
+ no_decay = [p for p in model.parameters() if p.dim() < 2]
337
  optimizer = torch.optim.AdamW([{"params": decay, "weight_decay": 0.1}, {"params": no_decay, "weight_decay": 0.0}],
338
  lr=args.lr, betas=(0.9, 0.95), fused=device.type == "cuda")
339
  warmup = max(1, steps // 50)
340
  schedule = lambda step: min(1, step / warmup) * (0.1 + 0.9 * 0.5 * (1 + math.cos(math.pi * min(1, step / steps))))
341
  scheduler = torch.optim.lr_scheduler.LambdaLR(optimizer, schedule)
342
  compiled = torch.compile(model) if device.type == "cuda" else model
343
+ parameters = sum(p.numel() for p in model.parameters())
344
+ gpu, history = GpuMonitor(), []
345
+ generator = torch.Generator(device=device).manual_seed(0)
346
+ window_started, window_tokens, started = time.time(), 0, time.time()
347
+ print(f"training {steps:,} steps of {args.batch * args.seq:,} tokens", flush=True)
348
  for step in range(steps):
349
+ index = torch.randint(0, len(rows), (args.batch,), device=device, generator=generator)
350
+ chunk = rows[index].long()
351
  with torch.autocast(device.type, dtype=torch.bfloat16, enabled=device.type == "cuda"):
352
  logits, _, _ = compiled(chunk[:, :-1])
353
  loss = F.cross_entropy(logits.float().flatten(0, 1), chunk[:, 1:].flatten())
 
356
  torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
357
  optimizer.step()
358
  scheduler.step()
359
+ window_tokens += args.batch * args.seq
360
  if step % args.log_every == 0 or step == steps - 1:
361
+ loss_value = loss.item() # syncs the GPU, so throughput below is real
362
+ seconds = time.time() - window_started
363
+ tokens_per_second = window_tokens / max(seconds, 1e-9)
364
+ entry = {
365
+ "step": step, "train_loss": round(loss_value, 4), "lr": round(scheduler.get_last_lr()[0], 6),
366
+ "tokens_per_second": round(tokens_per_second), "minutes": round((time.time() - started) / 60, 2),
367
+ # Training compute is about 6 FLOPs per parameter per token; an A100 does 312 TFLOP/s in bf16.
368
+ "mfu_percent": round(100 * 6 * parameters * tokens_per_second / 312e12, 1), **gpu.read(),
369
+ }
370
  if step % (args.log_every * 10) == 0 or step == steps - 1:
371
  entry["val_loss"] = round(evaluate_loss(model, val_rows, device, args.batch), 4)
372
  history.append(entry)
373
+ tracker.log(entry, step=step)
374
  print(json.dumps(entry), flush=True)
375
+ window_started, window_tokens = time.time(), 0
376
  return history
377
 
378
 
 
437
  return report
438
 
439
 
440
+ def headline(scored: dict) -> dict:
441
+ """The few eval numbers worth a chart: typing saved and 2+ word suggestions at the app's cutoff."""
442
+ table = scored["cutoffs"]["0.5"]
443
+ out = {}
444
+ for key, m in table.items():
445
+ slug = key.replace("/", "_")
446
+ out[f"eval/{slug}/saved_with_tab"] = m["saved_with_tab"]
447
+ out[f"eval/{slug}/right"] = m["right"]
448
+ out[f"eval/{slug}/multiword_shown"] = m["multiword_shown"]
449
+ return out
450
+
451
+
452
  # ---- Main ------------------------------------------------------------------------------------------------------
453
 
454
 
 
459
  parser.add_argument("--layers", type=int, default=8)
460
  parser.add_argument("--heads", type=int, default=5)
461
  parser.add_argument("--ffn", type=int, default=864)
462
+ parser.add_argument("--seq", type=int, default=512)
463
+ parser.add_argument("--tokens", type=float, default=1.5e9, help="Training tokens")
464
+ parser.add_argument("--val-tokens", type=float, default=4e6)
465
  parser.add_argument("--epochs", type=float, default=1.0)
466
+ parser.add_argument("--batch", type=int, default=512)
467
  parser.add_argument("--lr", type=float, default=3e-3)
468
  parser.add_argument("--fim-rate", type=float, default=0.5)
469
  parser.add_argument("--mix", default="fineweb=0.6,smoltalk=0.3,enron=0.1")
470
+ parser.add_argument("--fineweb-files", type=int, default=2, help="Each is about 2 GB of text, roughly 0.9B tokens")
471
+ parser.add_argument("--smoltalk-files", type=int, default=9)
472
  parser.add_argument("--tokenizer-docs", type=int, default=200_000)
473
  parser.add_argument("--eval-pauses", type=int, default=1000)
474
+ parser.add_argument("--log-every", type=int, default=50)
475
  parser.add_argument("--repo", default="cfahlgren1/tinynext")
476
  parser.add_argument("--folder", default="v2")
477
  parser.add_argument("--name", default="tinynext-v2")
478
+ parser.add_argument("--trackio-space", default="cfahlgren1/tinynext-trackio")
479
  parser.add_argument("--smoke", action="store_true", help="Tiny settings to check the pipeline end to end")
480
  parser.add_argument("--no-push", action="store_true")
481
  args = parser.parse_args()
482
  if args.smoke:
483
+ args.vocab, args.dim, args.layers, args.heads, args.ffn, args.seq = 2048, 64, 2, 2, 128, 256
484
+ args.tokens, args.val_tokens, args.batch, args.tokenizer_docs = 5e6, 2e5, 64, 5000
485
+ args.fineweb_files, args.smoltalk_files, args.eval_pauses, args.log_every = 1, 1, 20, 10
486
  args.name = "tinynext-v2-smoke"
487
  return args
488
 
489
 
490
+ class NoTracker:
491
+ def log(self, *_, **__):
492
+ pass
493
+
494
+ def finish(self):
495
+ pass
496
+
497
+
498
+ def start_tracker(args):
499
+ if args.no_push and not os.environ.get("HF_TOKEN"):
500
+ return NoTracker()
501
+ import trackio
502
+
503
+ trackio.init(project="tinynext", name=args.name, space_id=args.trackio_space, config=vars(args))
504
+ return trackio
505
+
506
+
507
  def main():
508
  args = parse_args()
509
  device = torch.device("cuda" if torch.cuda.is_available() else "mps" if torch.backends.mps.is_available() else "cpu")
510
  torch.manual_seed(0)
511
+ torch.backends.cuda.matmul.allow_tf32 = True
512
+ torch.backends.cudnn.allow_tf32 = True
513
  output = HERE / "output"
514
  output.mkdir(exist_ok=True)
515
  ev = load_eval_module(args.repo, args.folder)
516
  weights = {k: float(v) for k, v in (part.split("=") for part in args.mix.split(","))}
517
+ print(f"device {device}, {cpus()} CPUs, mix {weights}", flush=True)
518
+ tracker = start_tracker(args)
519
 
520
+ started = time.time()
521
+ sources = load_sources(args)
522
+ print(f"sources ready in {time.time() - started:.0f}s: " + ", ".join(f"{n} {len(d):,} docs" for n, d in sources.items()), flush=True)
523
+ tokenizer = train_tokenizer(sources, weights, args.vocab, args.tokenizer_docs)
524
  tokenizer.save(str(output / "tokenizer.json"))
525
+ rows, val_rows, counts = build_rows(sources, tokenizer, weights, args)
526
+ data_minutes = (time.time() - started) / 60
527
+ print(f"data ready in {data_minutes:.1f} min: {rows.size / 1e6:.0f}M train tokens, {val_rows.size / 1e6:.1f}M val", flush=True)
 
528
 
529
+ # The whole token array lives on the GPU; each step just indexes into it.
530
+ rows_gpu = torch.from_numpy(rows.astype(np.int32)).to(device)
531
+ val_gpu = torch.from_numpy(val_rows.astype(np.int32)).to(device)
532
  model = TinyNext(tokenizer.get_vocab_size(), args.dim, args.layers, args.heads, args.ffn).to(device)
533
  parameters = sum(p.numel() for p in model.parameters())
534
+ print(f"{parameters / 1e6:.2f}M parameters", flush=True)
535
  started = time.time()
536
+ history = train(model, rows_gpu, val_gpu, args, device, tracker)
537
+ train_minutes = (time.time() - started) / 60
538
 
539
  model.eval()
540
+ from safetensors.torch import save_file
541
+
542
+ save_file({k: v.contiguous() for k, v in model.state_dict().items()}, output / "model.safetensors")
543
  scored = ev.score(as_token_lm(ev, args.name, model, tokenizer, device), ev.pauses(ev.load_eval_set(), args.eval_pauses))
544
  (output / "metrics").mkdir(exist_ok=True)
545
  (output / "metrics" / f"{args.name}.json").write_text(json.dumps(scored, indent=1))
546
+ tracker.log(headline(scored))
547
  exported = export(model, output, device)
548
 
549
  config = {
 
551
  "prompt": "No text after the cursor: the text before it. Otherwise: <|suf|> after <|pre|> before <|mid|>.",
552
  }
553
  (output / "config.json").write_text(json.dumps(config, indent=1))
554
+ report = {"args": vars(args), "parameters": parameters, "data": counts, "data_minutes": round(data_minutes, 1),
555
+ "train_minutes": round(train_minutes, 1), "history": history, "exports": exported}
556
  (output / "report.json").write_text(json.dumps(report, indent=1))
557
  print(json.dumps(exported, indent=1))
558
  print(ev.table([output / "metrics" / f"{args.name}.json"]))
559
+ tracker.finish()
560
 
561
  for name in ("train.py", "eval.py", "eval_set.json"):
562
  shutil.copy(HERE / name, output / name)
563
  if (HERE / "metrics").exists():
564
  for path in (HERE / "metrics").glob("*.json"):
565
+ if not (output / "metrics" / path.name).exists():
566
+ shutil.copy(path, output / "metrics" / path.name)
567
  if args.no_push:
568
  print(f"Skipping upload; everything is in {output}")
569
  return
 
571
 
572
  HfApi(token=os.environ.get("HF_TOKEN")).upload_folder(
573
  folder_path=str(output), repo_id=args.repo, path_in_repo=args.folder, repo_type="model",
574
+ commit_message=f"{args.name}: {parameters / 1e6:.1f}M params, {train_minutes:.0f} min on GPU",
575
  )
576
  print(f"Uploaded to https://huggingface.co/{args.repo}/tree/main/{args.folder}")
577