Download training/scripts/ccslm/train.py from Samonte/mt_slm_ctx_compress: direct link, hf CLI and curl.
- Browser
- Download file 20.1 kB
-
https://huggingface.co/Samonte/mt_slm_ctx_compress/resolve/main/training/scripts/ccslm/train.py
- Command line
-
hf download hf://Samonte/mt_slm_ctx_compress/training/scripts/ccslm/train.py
-
curl -L -o train.py https://huggingface.co/Samonte/mt_slm_ctx_compress/resolve/main/training/scripts/ccslm/train.py
20.1 kB
| """Supervised fine tuning of Granite 4.0 H Micro on {prompt, completion} pairs, loss on the completion only. | |
| The prompt is the arena's own slm_prompt with the item's system and user fields, tokenised exactly | |
| as vLLM tokenises a raw completion request (Granite adds no BOS), and the completion ends with | |
| <|end_of_text|>, the arena's stop token. Sessions run to ~120k tokens, so the forward pass keeps | |
| only the logits of the completion positions (`logits_to_keep`), which is what makes a 100k token | |
| example fit: the hidden states are checkpointed per layer and the 100k x 100k vocabulary logits are | |
| never built. Batch size is one example; gradients accumulate over `--accumulate` examples. | |
| LoRA targets the attention projections, the Mamba input projection and the shared MLP. The Mamba | |
| `out_proj` is left out: with the mamba_ssm kernels the training path fuses it and reads its raw | |
| weight, so an adapter there would never train. `--method full` trains every weight. | |
| python -m ccslm.train --pairs out/data/train/pairs.jsonl --eval out/data/val/pairs.jsonl \ | |
| --out out/runs/r1 --method lora --rank 64 --epochs 1 | |
| python -m ccslm.train ... --resume # continue from out/runs/r1/checkpoint | |
| """ | |
| from __future__ import annotations | |
| import argparse | |
| import json | |
| import math | |
| import platform | |
| import random | |
| import shutil | |
| import time | |
| from pathlib import Path | |
| from typing import Any | |
| from ccslm.common import BASE, BASE_REVISION, iter_jsonl, log, tokenizer, write_json, write_portable_config | |
| LORA_TARGETS = r".*\.(self_attn\.(q_proj|k_proj|v_proj|o_proj)|mamba\.in_proj|shared_mlp\.(input_linear|output_linear))$" | |
| TOKENIZER_FILES = ( | |
| "tokenizer.json", | |
| "tokenizer_config.json", | |
| "special_tokens_map.json", | |
| "vocab.json", | |
| "merges.txt", | |
| "added_tokens.json", | |
| "chat_template.jinja", | |
| ) | |
| EOS = "<|end_of_text|>" | |
| def encode_pairs(path: Path, model_dir: Path, max_tokens: int) -> list[dict[str, Any]]: | |
| tok = tokenizer(str(model_dir)) if (model_dir / "tokenizer.json").is_file() else tokenizer() | |
| eos = tok.token_to_id(EOS) | |
| rows = [] | |
| for pair in iter_jsonl(path): | |
| prompt = tok.encode(pair["prompt"], add_special_tokens=False).ids | |
| completion = tok.encode(pair["completion"], add_special_tokens=False).ids + [eos] | |
| if len(prompt) + len(completion) > max_tokens: | |
| continue | |
| rows.append({"id": pair["item_id"], "prompt": prompt, "completion": completion}) | |
| return rows | |
| def lr_at(step: int, total: int, peak: float, warmup: int) -> float: | |
| if step < warmup: | |
| return peak * (step + 1) / max(1, warmup) | |
| progress = (step - warmup) / max(1, total - warmup) | |
| return peak * (0.1 + 0.9 * 0.5 * (1 + math.cos(math.pi * min(1.0, progress)))) | |
| def load_model(model_dir: Path, method: str, rank: int, device: str, dtype_name: str) -> Any: | |
| import torch | |
| from transformers import AutoModelForCausalLM | |
| dtype = {"bf16": torch.bfloat16, "fp16": torch.float16, "fp32": torch.float32}[dtype_name] | |
| import importlib.util | |
| if device.startswith("cuda"): | |
| try: | |
| import causal_conv1d # noqa: F401 | |
| import mamba_ssm # noqa: F401 | |
| except ImportError as exc: | |
| # transformers would fall back to its reference PyTorch scan, which runs out of memory | |
| # on a single 16k token example; see the kernel block in scripts/setup_gpu.sh. | |
| raise SystemExit(f"Mamba training kernels missing ({exc}); run scripts/setup_gpu.sh") from exc | |
| flash = importlib.util.find_spec("flash_attn") is not None | |
| attention = "flash_attention_2" if flash and device.startswith("cuda") else "sdpa" | |
| model = AutoModelForCausalLM.from_pretrained(str(model_dir), dtype=dtype, attn_implementation=attention) | |
| model.config.use_cache = False | |
| model.to(device) | |
| model.gradient_checkpointing_enable(gradient_checkpointing_kwargs={"use_reentrant": False}) | |
| if method == "lora": | |
| from peft import LoraConfig, get_peft_model | |
| config = LoraConfig( | |
| r=rank, | |
| lora_alpha=2 * rank, | |
| lora_dropout=0.0, | |
| target_modules=LORA_TARGETS, | |
| task_type="CAUSAL_LM", | |
| ) | |
| model.enable_input_require_grads() | |
| model = get_peft_model(model, config) | |
| return model | |
| def completion_loss(model: Any, example: dict[str, Any], device: str) -> tuple[Any, int]: | |
| import torch | |
| import torch.nn.functional as F | |
| ids = example["prompt"] + example["completion"] | |
| keep = len(example["completion"]) + 1 | |
| input_ids = torch.tensor([ids], device=device) | |
| out = model(input_ids=input_ids, logits_to_keep=keep, use_cache=False) | |
| logits = out.logits[0, :-1].float() | |
| target = torch.tensor(example["completion"], device=device) | |
| return F.cross_entropy(logits, target, reduction="sum"), len(example["completion"]) | |
| def evaluate(model: Any, rows: list[dict[str, Any]], device: str) -> float: | |
| import torch | |
| model.eval() | |
| total, tokens = 0.0, 0 | |
| with torch.no_grad(): | |
| for example in rows: | |
| loss, n = completion_loss(model, example, device) | |
| total += float(loss) | |
| tokens += n | |
| model.train() | |
| return total / max(1, tokens) | |
| class MasterAdam: | |
| """AdamW on fp32 master copies of bf16 weights, for full fine tuning. | |
| AdamW applied to bf16 weights directly loses most updates: a step of lr * m / sqrt(v) is about | |
| 1e-4 of a weight, while bf16 resolves about 4e-3, so the update rounds away. The masters take the | |
| fp32 update and the bf16 weights are refreshed from them after each step. With bitsandbytes the | |
| moments are 8 bit (~6 GB instead of ~26 GB for 3.2B weights), which keeps a full fine tune with | |
| 100k token examples inside 80 GB. | |
| """ | |
| def __init__(self, params: list[Any], lr: float, eight_bit: bool) -> None: | |
| import torch | |
| self.params = list(params) | |
| self.masters = [p.detach().float().clone() for p in self.params] | |
| self.eight_bit = False | |
| if eight_bit: | |
| try: | |
| import bitsandbytes as bnb | |
| self.inner = bnb.optim.AdamW8bit(self.masters, lr=lr, betas=(0.9, 0.95), weight_decay=0.0) | |
| self.eight_bit = True | |
| except ImportError: | |
| log("bitsandbytes is not installed: fp32 AdamW moments (~26 GB more for 3.2B weights)") | |
| if not self.eight_bit: | |
| self.inner = torch.optim.AdamW(self.masters, lr=lr, betas=(0.9, 0.95), weight_decay=0.0) | |
| self.param_groups = self.inner.param_groups | |
| def zero_grad(self, set_to_none: bool = True) -> None: | |
| for p in self.params: | |
| p.grad = None | |
| def step(self) -> None: | |
| import torch | |
| for p, m in zip(self.params, self.masters, strict=True): | |
| m.grad = None if p.grad is None else p.grad.detach().float() | |
| self.inner.step() | |
| with torch.no_grad(): | |
| for p, m in zip(self.params, self.masters, strict=True): | |
| p.copy_(m) | |
| m.grad = None | |
| def state_dict(self) -> dict[str, Any]: | |
| return {"inner": self.inner.state_dict(), "masters": [m.detach().cpu() for m in self.masters], "eight_bit": self.eight_bit} | |
| def load_state_dict(self, state: dict[str, Any]) -> None: | |
| import torch | |
| self.inner.load_state_dict(state["inner"]) | |
| with torch.no_grad(): | |
| for p, m, saved in zip(self.params, self.masters, state["masters"], strict=True): | |
| m.copy_(saved.to(m.device)) | |
| p.copy_(m) | |
| def save_checkpoint(model: Any, optimizer: Any, state: dict[str, Any], where: Path, method: str) -> None: | |
| import torch | |
| temp = where.with_name(where.name + ".tmp") | |
| shutil.rmtree(temp, ignore_errors=True) | |
| temp.mkdir(parents=True) | |
| if method == "lora": | |
| model.save_pretrained(str(temp)) | |
| else: | |
| torch.save({k: v.detach().cpu() for k, v in model.state_dict().items()}, temp / "model.pt") | |
| torch.save(optimizer.state_dict(), temp / "optimizer.pt") | |
| write_json(temp / "state.json", state) | |
| shutil.rmtree(where, ignore_errors=True) | |
| temp.rename(where) | |
| def save_snapshot(model: Any, state: dict[str, Any], run: Path, method: str, keep: int) -> Path: | |
| """A numbered weights-only checkpoint under run/checkpoints/step-NNNNN, kept for later export. | |
| LoRA saves the adapter (small); full saves the bf16 weights. `keep` > 0 deletes all but the | |
| newest `keep` snapshots; 0 keeps every one. `python -m ccslm.export` merges any of them. | |
| """ | |
| root = run / "checkpoints" | |
| where = root / f"step-{state['step']:05d}" | |
| temp = where.with_name(where.name + ".tmp") | |
| shutil.rmtree(temp, ignore_errors=True) | |
| temp.mkdir(parents=True) | |
| model.save_pretrained(str(temp), safe_serialization=True) | |
| write_json(temp / "state.json", {**state, "method": method, "saved_at": time.time()}) | |
| shutil.rmtree(where, ignore_errors=True) | |
| temp.rename(where) | |
| if keep > 0: | |
| found = sorted(p for p in root.glob("step-*") if p.is_dir() and not p.name.endswith(".tmp")) | |
| for old in found[:-keep]: | |
| shutil.rmtree(old, ignore_errors=True) | |
| return where | |
| def load_checkpoint(model: Any, optimizer: Any, where: Path, method: str) -> dict[str, Any]: | |
| import torch | |
| if method == "lora": | |
| from peft import set_peft_model_state_dict | |
| from safetensors.torch import load_file | |
| set_peft_model_state_dict(model, load_file(str(where / "adapter_model.safetensors"))) | |
| else: | |
| model.load_state_dict(torch.load(where / "model.pt", map_location="cpu")) | |
| optimizer.load_state_dict(torch.load(where / "optimizer.pt", map_location="cpu")) | |
| return json.loads((where / "state.json").read_text(encoding="utf-8")) | |
| def export(model: Any, method: str, model_dir: Path, base_dir: Path, out: Path) -> None: | |
| """Merged bf16 safetensors, the base's tokenizer files byte for byte and its config.json.""" | |
| import torch | |
| merged = model.merge_and_unload() if method == "lora" else model | |
| merged = merged.to(torch.bfloat16) | |
| merged.config.use_cache = True | |
| shutil.rmtree(out, ignore_errors=True) | |
| out.mkdir(parents=True) | |
| merged.save_pretrained(str(out), safe_serialization=True, max_shard_size="5GB") | |
| for name in TOKENIZER_FILES: | |
| source = base_dir / name | |
| if source.is_file(): | |
| shutil.copyfile(source, out / name) | |
| if (model_dir / "generation_config.json").is_file(): | |
| shutil.copyfile(model_dir / "generation_config.json", out / "generation_config.json") | |
| write_portable_config(out, base_dir) | |
| def main() -> None: | |
| parser = argparse.ArgumentParser(description="fine tune Granite 4.0 H Micro on ctx_compress pairs") | |
| parser.add_argument("--pairs", required=True) | |
| parser.add_argument("--eval", default="") | |
| parser.add_argument("--out", required=True, help="run folder: checkpoint, metrics, merged model") | |
| parser.add_argument("--model", default="", help="model folder to start from (default: the pinned base)") | |
| parser.add_argument("--method", choices=["lora", "full"], default="lora") | |
| parser.add_argument("--optim", choices=["adamw", "adamw8bit"], default="adamw8bit", help="full method: moments in fp32 or 8 bit (bitsandbytes)") | |
| parser.add_argument("--rank", type=int, default=64) | |
| parser.add_argument("--lr", type=float, default=0.0, help="default 1e-4 lora, 1e-5 full") | |
| parser.add_argument("--epochs", type=float, default=1.0) | |
| parser.add_argument("--accumulate", type=int, default=16) | |
| parser.add_argument("--max-tokens", type=int, default=126_000) | |
| parser.add_argument("--warmup", type=float, default=0.03) | |
| parser.add_argument("--eval-every", type=int, default=50, help="optimizer steps") | |
| parser.add_argument("--eval-items", type=int, default=24) | |
| parser.add_argument("--save-every", type=int, default=25, help="optimizer steps between numbered checkpoints") | |
| parser.add_argument("--keep-checkpoints", type=int, default=0, help="numbered checkpoints to keep (0 = all)") | |
| parser.add_argument("--max-steps", type=int, default=0, help="stop after this many optimizer steps; the LR schedule still follows the full run") | |
| parser.add_argument("--schedule-steps", type=int, default=0, help="steps the cosine schedule spans (default: the full run of --epochs)") | |
| parser.add_argument("--dtype", choices=["bf16", "fp16", "fp32"], default="bf16") | |
| parser.add_argument("--device", default="") | |
| parser.add_argument("--seed", type=int, default=0) | |
| parser.add_argument("--resume", action="store_true") | |
| parser.add_argument("--no-export", action="store_true") | |
| args = parser.parse_args() | |
| import torch | |
| torch.manual_seed(args.seed) | |
| device = args.device or ("cuda" if torch.cuda.is_available() else "cpu") | |
| out = Path(args.out) | |
| out.mkdir(parents=True, exist_ok=True) | |
| from ccslm.common import base_dir as pinned | |
| base_dir = Path(args.model) if args.model else pinned() | |
| # Tokenizer files always come from the pinned base so they stay byte identical to it. | |
| original_base = pinned() | |
| lr = args.lr or (1e-4 if args.method == "lora" else 1e-5) | |
| train_rows = encode_pairs(Path(args.pairs), base_dir, args.max_tokens) | |
| eval_rows = encode_pairs(Path(args.eval), base_dir, args.max_tokens)[: args.eval_items] if args.eval else [] | |
| rng = random.Random(args.seed) | |
| order: list[int] = [] | |
| for _ in range(max(1, math.ceil(args.epochs))): | |
| epoch = list(range(len(train_rows))) | |
| rng.shuffle(epoch) | |
| order += epoch | |
| order = order[: max(1, round(len(train_rows) * args.epochs))] | |
| run_steps = max(1, len(order) // args.accumulate) | |
| # The schedule spans the whole run so a short --max-steps run and its resumes follow one curve. | |
| schedule_steps = args.schedule_steps or run_steps | |
| total_steps = min(run_steps, args.max_steps) if args.max_steps else run_steps | |
| warmup = max(1, int(args.warmup * schedule_steps)) | |
| log( | |
| f"{len(train_rows)} train pairs, {len(eval_rows)} eval pairs, stop at step {total_steps} of {run_steps}, " | |
| f"schedule over {schedule_steps} steps, lr {lr}" | |
| ) | |
| model = load_model(base_dir, args.method, args.rank, device, args.dtype) | |
| trainable = [p for p in model.parameters() if p.requires_grad] | |
| log(f"trainable parameters {sum(p.numel() for p in trainable):,}") | |
| if args.method == "full" and args.dtype != "fp32": | |
| optimizer: Any = MasterAdam(trainable, lr, eight_bit=args.optim == "adamw8bit") | |
| else: | |
| optimizer = torch.optim.AdamW(trainable, lr=lr, betas=(0.9, 0.95), weight_decay=0.0) | |
| state = {"step": 0, "cursor": 0, "seen_tokens": 0, "train_pairs": len(train_rows), "schedule_steps": schedule_steps} | |
| checkpoint = out / "checkpoint" | |
| if args.resume and checkpoint.is_dir(): | |
| state = load_checkpoint(model, optimizer, checkpoint, args.method) | |
| if state.get("train_pairs", len(train_rows)) != len(train_rows): | |
| raise SystemExit( | |
| f"resume: the checkpoint saw {state['train_pairs']} train pairs, now {len(train_rows)}; " | |
| "keep --pairs, --max-tokens and --seed as in the first run" | |
| ) | |
| if not args.schedule_steps and state.get("schedule_steps"): | |
| schedule_steps = int(state["schedule_steps"]) | |
| warmup = max(1, int(args.warmup * schedule_steps)) | |
| state.update(train_pairs=len(train_rows), schedule_steps=schedule_steps) | |
| log(f"resumed at step {state['step']}") | |
| write_json( | |
| out / "config.json", | |
| { | |
| "base_model": BASE, | |
| "base_revision": BASE_REVISION, | |
| "start_from": str(base_dir), | |
| "method": args.method, | |
| "rank": args.rank if args.method == "lora" else None, | |
| "lora_targets": LORA_TARGETS if args.method == "lora" else None, | |
| "learning_rate": lr, | |
| "epochs": args.epochs, | |
| "accumulate": args.accumulate, | |
| "max_tokens": args.max_tokens, | |
| "seed": args.seed, | |
| "pairs": str(args.pairs), | |
| "train_pairs": len(train_rows), | |
| "eval": str(args.eval), | |
| }, | |
| ) | |
| metrics = (out / "metrics.jsonl").open("a", encoding="utf-8") | |
| model.train() | |
| started = time.time() | |
| window_loss, window_tokens, window_seq = 0.0, 0, 0 | |
| while state["step"] < total_steps: | |
| for group in param_groups(optimizer): | |
| group["lr"] = lr_at(state["step"], schedule_steps, lr, warmup) | |
| span = order[state["cursor"] : state["cursor"] + args.accumulate] | |
| skipped = state.setdefault("skipped", []) | |
| while True: | |
| # An example that runs out of memory is skipped for good (kept in the state, so a resume | |
| # skips it too) and the batch is redone without it: a partial backward leaves gradients | |
| # that cannot be told apart from the rest of the batch. | |
| optimizer.zero_grad(set_to_none=True) | |
| batch = [i for i in span if i not in skipped] | |
| tokens = sum(len(train_rows[i]["completion"]) for i in batch) | |
| window_loss, window_tokens, window_seq = 0.0, 0, 0 | |
| failed = None | |
| for i in batch: | |
| try: | |
| loss, n = completion_loss(model, train_rows[i], device) | |
| (loss / tokens).backward() | |
| except torch.OutOfMemoryError: | |
| failed = i | |
| break | |
| window_loss += float(loss.detach()) | |
| window_tokens += n | |
| window_seq += len(train_rows[i]["prompt"]) + n | |
| if failed is None: | |
| break | |
| loss = None | |
| optimizer.zero_grad(set_to_none=True) | |
| torch.cuda.empty_cache() | |
| skipped.append(failed) | |
| seq = len(train_rows[failed]["prompt"]) + len(train_rows[failed]["completion"]) | |
| log(f"out of memory on {train_rows[failed]['id']} ({seq} tokens): skipped, redoing step {state['step'] + 1}") | |
| norm = torch.nn.utils.clip_grad_norm_(trainable, 1.0) | |
| optimizer.step() | |
| state["step"] += 1 | |
| state["cursor"] += len(span) | |
| state["seen_tokens"] += window_seq | |
| row: dict[str, Any] = { | |
| "step": state["step"], | |
| "loss": round(window_loss / max(1, window_tokens), 5), | |
| "lr": optimizer.param_groups[0]["lr"], | |
| "grad_norm": round(float(norm), 4), | |
| "sequence_tokens": window_seq, | |
| "elapsed_s": round(time.time() - started, 1), | |
| } | |
| if device.startswith("cuda"): | |
| row["max_memory_gb"] = round(torch.cuda.max_memory_allocated() / 1e9, 2) | |
| window_loss, window_tokens, window_seq = 0.0, 0, 0 | |
| if eval_rows and (state["step"] % args.eval_every == 0 or state["step"] == total_steps): | |
| row["eval_loss"] = round(evaluate(model, eval_rows, device), 5) | |
| metrics.write(json.dumps(row) + "\n") | |
| metrics.flush() | |
| log(json.dumps(row)) | |
| if state["step"] % args.save_every == 0 or state["step"] == total_steps: | |
| snapshot = save_snapshot(model, {**state, **{k: row[k] for k in ("loss", "eval_loss") if k in row}}, out, args.method, args.keep_checkpoints) | |
| save_checkpoint(model, optimizer, state, checkpoint, args.method) | |
| log(f"checkpoint {snapshot}") | |
| metrics.close() | |
| write_json( | |
| out / "env.json", | |
| { | |
| "libraries": { | |
| "torch": torch.__version__, | |
| "transformers": __import__("transformers").__version__, | |
| "peft": __import__("peft").__version__, | |
| }, | |
| "gpu": torch.cuda.get_device_name(0) if torch.cuda.is_available() else "", | |
| "python": platform.python_version(), | |
| "hours": round((time.time() - started) / 3600, 3), | |
| }, | |
| ) | |
| if not args.no_export: | |
| export(model, args.method, base_dir, original_base, out / "merged") | |
| log(f"merged model at {out / 'merged'}") | |
| def param_groups(optimizer: Any) -> list[dict[str, Any]]: | |
| return list(optimizer.param_groups) | |
| if __name__ == "__main__": | |
| main() | |