parallel data prep, data on GPU, trackio, safetensors
Browse files- 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
|
|
|
|
|
|
|
|
|
|
| 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
|
| 65 |
-
|
| 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 |
-
|
| 75 |
-
|
| 76 |
-
if turns:
|
| 77 |
-
yield "\n\n".join(turns)
|
| 78 |
|
| 79 |
|
| 80 |
-
def
|
| 81 |
-
"""Training senders only: the eval's held-out senders
|
| 82 |
import hashlib
|
| 83 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 84 |
from datasets import load_dataset
|
| 85 |
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 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(
|
| 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((
|
| 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
|
| 147 |
-
"""
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
|
|
|
|
|
|
| 155 |
for at in range(0, len(ids), seq - 8):
|
| 156 |
chunk = ids[at: at + seq - 8]
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 170 |
|
| 171 |
|
| 172 |
# ---- Model -----------------------------------------------------------------------------------------------------
|
|
@@ -261,32 +297,57 @@ class BrowserStep(nn.Module):
|
|
| 261 |
# ---- Training --------------------------------------------------------------------------------------------------
|
| 262 |
|
| 263 |
|
| 264 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 =
|
| 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:
|
| 277 |
steps = int(args.epochs * len(rows) // args.batch)
|
| 278 |
-
decay = [p for
|
| 279 |
-
no_decay = [p for
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 287 |
for step in range(steps):
|
| 288 |
-
index =
|
| 289 |
-
chunk =
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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=
|
| 379 |
-
parser.add_argument("--tokens", type=float, default=
|
|
|
|
| 380 |
parser.add_argument("--epochs", type=float, default=1.0)
|
| 381 |
-
parser.add_argument("--batch", type=int, default=
|
| 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=
|
| 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.
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 412 |
tokenizer.save(str(output / "tokenizer.json"))
|
| 413 |
-
|
| 414 |
-
|
| 415 |
-
|
| 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
|
| 421 |
started = time.time()
|
| 422 |
-
history = train(model,
|
| 423 |
-
|
| 424 |
|
| 425 |
model.eval()
|
| 426 |
-
|
|
|
|
|
|
|
| 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, "
|
|
|
|
| 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 |
-
|
|
|
|
| 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,
|
| 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 |
|