Samonte's picture
slm A-s00080
698535a verified
Raw History Blame Contribute Delete
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()