File size: 14,776 Bytes
9c6799a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
"""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()