Broyojo's picture
Add code and README
9c6799a verified
Raw History Blame Contribute Delete
14.8 kB
"""Text LM training from a world-model (iVideoGPT) init vs. controls.
Every arm uses the same LlamaConfig as iVideoGPT's transformer, re-sized to the
GPT-2 vocab. Only the body (everything but embed_tokens / lm_head) differs:
--init scratch HF default random init
--init ivgpt iVideoGPT body weights; embeddings + head freshly initialized
--init shuffled iVideoGPT body with each tensor's entries randomly permuted
(same per-tensor weight statistics, no learned structure)
--init rescaled iVideoGPT body with every matrix scaled to std 0.02 (the scratch
init scale); keeps learned structure, removes the ~2.5x larger scale
--init sp shrink-and-perturb (Ash & Adams 2020): lambda * iVideoGPT + fresh init
(--sp_lambda); RMSNorm gains become lambda * g + (1 - lambda)
--init nobody no pretrained body at all: only the --extra_layers (baseline for them)
--freeze body train only embed_tokens, lm_head and RMSNorm gains (+ LoRA / extra layers)
--lora_rank R LoRA on every body linear (frozen base); merged into the weights at save
--extra_layers K append K freshly initialized trainable layers on top of the body
Launch: torchrun --nproc_per_node=N wp/train.py --init ivgpt --lr 1e-3 ...
"""
import argparse
import json
import math
import os
import time
from contextlib import nullcontext
from pathlib import Path
import numpy as np
import torch
import torch.distributed as dist
from huggingface_hub import hf_hub_download
from safetensors.torch import load_file
from transformers import AutoTokenizer, LlamaConfig, LlamaForCausalLM
GPT2_EOT = 50256
VOCAB = 50304 # GPT-2 vocab padded to a multiple of 128
IO_PREFIXES = ("model.embed_tokens.", "lm_head.")
def parse_args():
p = argparse.ArgumentParser()
p.add_argument("--init", choices=["scratch", "ivgpt", "shuffled", "rescaled", "sp", "nobody"], required=True)
p.add_argument("--freeze", choices=["none", "body"], default="none")
p.add_argument("--repo", default="thuml/ivideogpt-oxe-64-act-free-medium")
p.add_argument("--data_dir", default="/data/fineweb-edu-gpt2")
p.add_argument("--out_dir", required=True)
p.add_argument("--ckpt_dir", default=None, help="rolling checkpoint location (default: out_dir)")
p.add_argument("--final_dir", default=None, help="save final bf16 HF model here")
p.add_argument("--tokens", type=float, default=2e9, help="training token budget")
p.add_argument("--batch_tokens", type=int, default=524288, help="global tokens per step")
p.add_argument("--seq_len", type=int, default=1024)
p.add_argument("--micro_bs", type=int, default=32)
p.add_argument("--lr", type=float, default=1e-3)
p.add_argument("--min_lr_frac", type=float, default=0.1)
p.add_argument("--warmup_steps", type=int, default=200, help="-1: 5%% of steps, capped at 200")
p.add_argument("--wd", type=float, default=0.1)
p.add_argument("--grad_clip", type=float, default=1.0)
p.add_argument("--eval_every", type=int, default=100)
p.add_argument("--val_tokens", type=int, default=4_000_000)
p.add_argument("--ckpt_every", type=int, default=500)
p.add_argument("--seed", type=int, default=0)
p.add_argument("--no_compile", action="store_true")
p.add_argument("--sp_lambda", type=float, default=0.5)
p.add_argument("--sp_only", choices=["all", "attn", "mlp", "lower", "upper"], default="all",
help="apply S&P only to these weights; the rest keep the scratch init")
p.add_argument("--sp_shuffle", action="store_true", help="shrink-and-perturb the shuffled body (statistics-matched control)")
p.add_argument("--lora_rank", type=int, default=0)
p.add_argument("--extra_layers", type=int, default=0)
return p.parse_args()
def build_model(args):
cfg = LlamaConfig.from_pretrained(args.repo, subfolder="transformer")
n_body = 0 if args.init == "nobody" else cfg.num_hidden_layers
cfg.num_hidden_layers = n_body + args.extra_layers
cfg.vocab_size = VOCAB
cfg.bos_token_id = cfg.eos_token_id = GPT2_EOT
cfg.attention_dropout = 0.0
cfg.max_position_embeddings = args.seq_len
cfg.tie_word_embeddings = False
torch.manual_seed(args.seed)
model = LlamaForCausalLM(cfg)
model.n_body = n_body
if args.init in ("scratch", "nobody"):
return model
sd = load_file(hf_hub_download(args.repo, "transformer/model.safetensors"))
# action-conditioned checkpoints nest the Llama under "llm." and add an action input layer
sd = {k.removeprefix("llm."): v for k, v in sd.items() if not k.startswith("action_linear.")}
body = {k: v for k, v in sd.items() if not k.startswith(IO_PREFIXES) and "rotary_emb" not in k}
if args.init == "shuffled" or (args.init == "sp" and args.sp_shuffle):
g = torch.Generator().manual_seed(args.seed + 1)
body = {k: v.flatten()[torch.randperm(v.numel(), generator=g)].view_as(v) for k, v in body.items()}
if args.init == "rescaled":
body = {k: v * (cfg.initializer_range / v.std()) if v.dim() == 2 else v for k, v in body.items()}
if args.init == "sp":
fresh = model.state_dict() # the random init drawn above is the perturbation
lam = args.sp_lambda
def selected(k):
layer = int(k.split(".")[2]) if k.startswith("model.layers.") else -1
return {"all": True, "attn": "self_attn" in k or "input_layernorm" in k,
"mlp": ".mlp." in k or "post_attention_layernorm" in k,
"lower": 0 <= layer < cfg.num_hidden_layers // 2,
"upper": layer >= cfg.num_hidden_layers // 2}[args.sp_only]
body = {k: (lam * v + (fresh[k] if v.dim() == 2 else (1 - lam))) if selected(k) else fresh[k]
for k, v in body.items()}
missing, unexpected = model.load_state_dict(body, strict=False)
assert not unexpected, f"unexpected keys: {unexpected}"
is_extra = lambda k: k.startswith("model.layers.") and int(k.split(".")[2]) >= n_body
assert all(k.startswith(IO_PREFIXES) or is_extra(k) for k in missing), f"missing body keys: {missing}"
return model
class LoRALinear(torch.nn.Module):
"""y = W x + (B A x) * alpha/r with W frozen; B starts at zero so the init function is unchanged."""
def __init__(self, base, r, alpha):
super().__init__()
self.base = base
self.lora_A = torch.nn.Parameter(torch.empty(r, base.in_features))
torch.nn.init.kaiming_uniform_(self.lora_A, a=math.sqrt(5))
self.lora_B = torch.nn.Parameter(torch.zeros(base.out_features, r))
self.scale = alpha / r
def forward(self, x):
return self.base(x) + (x @ self.lora_A.t()) @ self.lora_B.t() * self.scale
def merged(self):
lin = self.base
lin.weight.data += (self.lora_B @ self.lora_A).to(lin.weight.dtype) * self.scale
return lin
LORA_TARGETS = {"self_attn": ("q_proj", "k_proj", "v_proj", "o_proj"), "mlp": ("gate_proj", "up_proj", "down_proj")}
def add_lora(model, r):
for layer in model.model.layers[: model.n_body]:
for parent, names in LORA_TARGETS.items():
mod = getattr(layer, parent)
for n in names:
setattr(mod, n, LoRALinear(getattr(mod, n), r, alpha=r))
def merge_lora(model):
for layer in model.model.layers:
for parent, names in LORA_TARGETS.items():
mod = getattr(layer, parent)
for n in names:
if isinstance(getattr(mod, n), LoRALinear):
setattr(mod, n, getattr(mod, n).merged())
def apply_freeze(model, freeze):
if freeze == "none":
return
for name, p in model.named_parameters():
extra = name.startswith("model.layers.") and int(name.split(".")[2]) >= model.n_body
p.requires_grad = name.startswith(IO_PREFIXES) or "norm" in name or "lora_" in name or extra
class Batches:
"""Random seq_len+1 windows from a uint16 token memmap; same order for every arm."""
def __init__(self, path, seq_len, micro_bs, seed, rank):
self.data = np.memmap(path, dtype=np.uint16, mode="r")
self.seq_len, self.micro_bs = seq_len, micro_bs
self.rng = np.random.default_rng([seed, rank])
def next(self, device):
idx = self.rng.integers(0, len(self.data) - self.seq_len - 1, self.micro_bs)
x = np.stack([self.data[i : i + self.seq_len + 1] for i in idx]).astype(np.int64)
x = torch.from_numpy(x).pin_memory().to(device, non_blocking=True)
return x[:, :-1], x[:, 1:]
@torch.no_grad()
def evaluate(model, val, args, rank, world, device):
"""Mean loss over a fixed set of non-overlapping windows from the start of val.bin."""
model.eval()
n_seq = args.val_tokens // args.seq_len
starts = np.arange(n_seq)[rank::world] * args.seq_len
tot, cnt = torch.zeros((), device=device), torch.zeros((), device=device)
for i in range(0, len(starts), args.micro_bs):
x = np.stack([val[s : s + args.seq_len + 1] for s in starts[i : i + args.micro_bs]]).astype(np.int64)
x = torch.from_numpy(x).to(device)
with torch.autocast("cuda", dtype=torch.bfloat16):
logits = model(input_ids=x[:, :-1]).logits
tot += torch.nn.functional.cross_entropy(
logits.float().flatten(0, 1), x[:, 1:].flatten(), reduction="sum"
)
cnt += x[:, 1:].numel()
if world > 1:
dist.all_reduce(tot)
dist.all_reduce(cnt)
model.train()
return (tot / cnt).item()
def lr_at(step, total, args):
if step < args.warmup_steps:
return args.lr * (step + 1) / args.warmup_steps
t = (step - args.warmup_steps) / max(1, total - args.warmup_steps)
return args.lr * (args.min_lr_frac + (1 - args.min_lr_frac) * 0.5 * (1 + math.cos(math.pi * t)))
def main():
args = parse_args()
ddp = "RANK" in os.environ
if ddp:
dist.init_process_group("nccl")
rank, world = dist.get_rank(), dist.get_world_size()
local = int(os.environ["LOCAL_RANK"])
else:
rank, world, local = 0, 1, 0
device = torch.device("cuda", local)
torch.cuda.set_device(device)
torch.backends.cuda.matmul.allow_tf32 = True
torch.backends.cudnn.allow_tf32 = True
master = rank == 0
out = Path(args.out_dir)
if master:
out.mkdir(parents=True, exist_ok=True)
tok_per_micro = args.micro_bs * args.seq_len * world
assert args.batch_tokens % tok_per_micro == 0, "batch_tokens must divide micro_bs*seq_len*world"
accum = args.batch_tokens // tok_per_micro
total_steps = int(args.tokens // args.batch_tokens)
if args.warmup_steps < 0:
args.warmup_steps = min(200, max(10, int(0.05 * total_steps)))
model = build_model(args)
if args.lora_rank:
add_lora(model, args.lora_rank)
model = model.to(device)
apply_freeze(model, args.freeze)
params = [p for p in model.parameters() if p.requires_grad]
n_total = sum(p.numel() for p in model.parameters())
n_train = sum(p.numel() for p in params)
decay = [p for p in params if p.dim() >= 2]
no_decay = [p for p in params if p.dim() < 2]
opt = torch.optim.AdamW(
[{"params": decay, "weight_decay": args.wd}, {"params": no_decay, "weight_decay": 0.0}],
lr=args.lr, betas=(0.9, 0.95), eps=1e-8, fused=True,
)
step = 0
ckpt_path = Path(args.ckpt_dir or out) / "ckpt.pt"
if master:
ckpt_path.parent.mkdir(parents=True, exist_ok=True)
if ckpt_path.exists():
ck = torch.load(ckpt_path, map_location=device)
model.load_state_dict(ck["model"])
opt.load_state_dict(ck["opt"])
step = ck["step"]
if master:
print(f"resumed from step {step}")
fwd = model if args.no_compile else torch.compile(model)
if ddp:
fwd = torch.nn.parallel.DistributedDataParallel(fwd, device_ids=[local])
train = Batches(Path(args.data_dir) / "train.bin", args.seq_len, args.micro_bs, args.seed, rank)
for _ in range(step * accum): # replay the sampler so resumed runs see the same data
train.rng.integers(0, len(train.data) - args.seq_len - 1, args.micro_bs)
val = np.memmap(Path(args.data_dir) / "val.bin", dtype=np.uint16, mode="r")
def log(rec):
if master:
print(json.dumps(rec), flush=True)
with open(out / "metrics.jsonl", "a") as f:
f.write(json.dumps(rec) + "\n")
if master and step == 0:
(out / "args.json").write_text(json.dumps(vars(args) | {"world": world, "accum": accum}, indent=2))
log({"step": 0, "tokens": 0, "val_loss": evaluate(model, val, args, rank, world, device),
"n_params": n_total, "n_trainable": n_train})
elif step == 0:
evaluate(model, val, args, rank, world, device)
t0 = time.time()
while step < total_steps:
lr = lr_at(step, total_steps, args)
for g in opt.param_groups:
g["lr"] = lr
loss_acc = 0.0
for micro in range(accum):
x, y = train.next(device)
sync = nullcontext() if (not ddp or micro == accum - 1) else fwd.no_sync()
with sync, torch.autocast("cuda", dtype=torch.bfloat16):
logits = fwd(input_ids=x).logits
loss = torch.nn.functional.cross_entropy(logits.float().flatten(0, 1), y.flatten())
(loss / accum).backward()
loss_acc += loss.item() / accum
gnorm = torch.nn.utils.clip_grad_norm_(params, args.grad_clip).item()
opt.step()
opt.zero_grad(set_to_none=True)
step += 1
if step % 10 == 0 and master:
dt = time.time() - t0
t0 = time.time()
log({"step": step, "tokens": step * args.batch_tokens, "train_loss": round(loss_acc, 4),
"lr": lr, "gnorm": round(gnorm, 3), "tok_per_s": int(10 * args.batch_tokens / dt)})
if step % args.eval_every == 0 or step == total_steps:
vl = evaluate(model, val, args, rank, world, device)
log({"step": step, "tokens": step * args.batch_tokens, "val_loss": vl})
if master and (step % args.ckpt_every == 0 or step == total_steps):
torch.save({"model": model.state_dict(), "opt": opt.state_dict(), "step": step}, ckpt_path.with_suffix(".tmp"))
os.replace(ckpt_path.with_suffix(".tmp"), ckpt_path)
if master:
if args.final_dir:
merge_lora(model)
model.to(torch.bfloat16).save_pretrained(args.final_dir)
AutoTokenizer.from_pretrained("gpt2").save_pretrained(args.final_dir)
(out / "DONE").touch()
if ddp:
dist.destroy_process_group()
if __name__ == "__main__":
main()