File size: 11,525 Bytes
f91d419
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Resumable GPU training with fixed validation masks and atomic recovery state."""
from contextlib import nullcontext
from dataclasses import asdict
import json
import math
from pathlib import Path
import shutil
import time

import numpy as np
import torch
from torch.nn import functional as F
from .data import load_arrays
from .model import Config, Denoiser, PAD, BOS, corrupt


def amp(device, precision):
    return torch.autocast("cuda", dtype=torch.bfloat16) if device == "cuda" and precision == "bf16" else nullcontext()


def atomic_save(value, path):
    temporary = path.with_suffix(".tmp")
    torch.save(value, temporary)
    temporary.replace(path)


@torch.inference_mode()
def evaluate(model, array, frequencies, *, device, precision, limit=256, batch_size=8):
    model.eval()
    reports = []
    for rate in (0.15, 0.5, 0.85):
        rng = torch.Generator(device=device).manual_seed(2718)
        total = correct = top5 = 0
        loss = baseline_loss = 0.
        baseline_correct = 0
        for start in range(0, min(limit, len(array)), batch_size):
            clean = torch.tensor(np.array(array[start:min(start+batch_size,limit)]), dtype=torch.long, device=device)
            noisy, selected, noise = corrupt(clean, rng, mode="random", rate=rate)
            with amp(device, precision):
                logits = model(noisy, noise, selected)
            targets = clean[selected]
            logits = logits.float()
            loss += F.cross_entropy(logits, targets, reduction="sum").item()
            correct += logits.argmax(-1).eq(targets).sum().item()
            top5 += logits.topk(5, -1).indices.eq(targets[:, None]).any(-1).sum().item()
            total += targets.numel()
            baseline_loss += -frequencies.to(device)[targets].log().sum().item()
            baseline_correct += targets.eq(int(frequencies.argmax())).sum().item()
        reports.append({"mask_rate": rate, "targets": total, "cross_entropy": loss / total,
                        "accuracy": correct / total, "top5_accuracy": top5 / total,
                        "unigram_cross_entropy": baseline_loss / total, "unigram_accuracy": baseline_correct / total})
    return reports


def train(args):
    if min(args.steps, args.batch_size, args.accumulation, args.eval_every, args.threads, args.eval_samples) < 1:
        raise ValueError("Training counts must be positive")
    torch.set_num_threads(args.threads)
    torch.manual_seed(args.seed)
    device = args.device
    if device == "cuda" and not torch.cuda.is_available():
        raise ValueError("CUDA/ROCm unavailable; choose --device cpu")
    if args.precision == "bf16" and device == "cuda" and not torch.cuda.is_bf16_supported():
        raise ValueError("BF16 unsupported; choose --precision fp32")
    arrays, manifest = load_arrays(args.data)
    config = Config(vocab_size=manifest["vocab_size"], width=args.width, heads=args.heads, layers=args.layers,
                    max_length=manifest["max_length"])
    out = Path(args.output)
    out.mkdir(parents=True, exist_ok=True)
    if (out / "last.pt").exists() and not args.resume:
        raise ValueError("Run already exists; use --resume or a new output directory")
    run_config = {k: v for k, v in vars(args).items() if k not in ("resume", "command")}
    model = Denoiser(config).to(device)
    lineage = None
    if getattr(args, "initialize_from", None):
        if args.resume:
            raise ValueError("Choose resume OR initialize-from")
        source = Path(args.initialize_from)
        lineage = json.loads((source / "training_info.json").read_text())
        if lineage["manifest"] != manifest or lineage.get("pretrained_weights") is not False:
            raise ValueError("Initialization must use this corpus and a from-scratch DiffuThink checkpoint")
        prior = Denoiser.from_pretrained(source)
        if prior.config != config:
            raise ValueError("Initialization model configuration differs")
        model.load_state_dict(prior.state_dict())
        del prior
    optimizer = torch.optim.AdamW(model.parameters(), lr=args.lr, betas=(0.9, 0.95), weight_decay=0.1)
    sampling = torch.Generator().manual_seed(args.seed)
    noise_rng = torch.Generator(device=device).manual_seed(args.seed + 1)
    counts = np.bincount(np.asarray(arrays["train"]).reshape(-1), minlength=config.vocab_size).astype(np.float64) + 1
    counts[[PAD, BOS]] = 1
    frequencies = torch.tensor(counts / counts.sum(), dtype=torch.float32)
    step, seen_tokens, elapsed, best = 0, 0, 0., float("inf")
    if args.resume:
        if not (out / "best" / "model.safetensors").exists():
            raise ValueError("Resume in the original output directory containing best/ and last.pt")
        state = torch.load(args.resume, map_location="cpu", weights_only=True)
        if state["manifest"] != manifest or state["model_config"] != asdict(config):
            raise ValueError("Resume data/model mismatch")
        for key in ("seed", "batch_size", "accumulation", "lr", "steps", "warmup", "precision", "device"):
            if state["run_config"][key] != run_config[key]:
                raise ValueError(f"Resume requires unchanged {key}; schedule must remain identical")
        model.load_state_dict(state["model"])
        optimizer.load_state_dict(state["optimizer"])
        sampling.set_state(state["sampling_rng"])
        noise_rng.set_state(state["noise_rng"])
        torch.set_rng_state(state["torch_rng"])
        if device == "cuda":
            torch.cuda.set_rng_state(state["cuda_rng"], device)
        step, seen_tokens, elapsed, best = state["step"], state["seen_tokens"], state["elapsed"], state["best"]
        lineage = state.get("lineage")
        del state
    else:
        initial = evaluate(model, arrays["validation"], frequencies, device=device, precision=args.precision, limit=args.eval_samples)
        (out / "initial_validation.json").write_text(json.dumps(initial, indent=2), encoding="utf-8")
    (out / "run_config.json").write_text(json.dumps(run_config, indent=2), encoding="utf-8")
    (out / "manifest.json").write_text(json.dumps(manifest, indent=2), encoding="utf-8")
    start = time.perf_counter()
    parameters = sum(p.numel() for p in model.parameters())
    print(json.dumps({"parameters": parameters, "device": device, "precision": args.precision, "start_step": step, "target_steps": args.steps}), flush=True)
    log = out / "metrics.jsonl"
    def save_state():
        atomic_save({"model": model.state_dict(), "optimizer": optimizer.state_dict(),
                     "model_config": asdict(config), "manifest": manifest, "run_config": run_config,
                     "step": step, "seen_tokens": seen_tokens, "elapsed": elapsed + time.perf_counter() - start,
                     "best": best, "sampling_rng": sampling.get_state(), "noise_rng": noise_rng.get_state(),
                     "lineage": lineage,
                     "torch_rng": torch.get_rng_state(),
                     "cuda_rng": torch.cuda.get_rng_state() if device == "cuda" else None}, out / "last.pt")
    try:
        while step < args.steps:
            model.train()
            lr_scale = min(1., (step + 1) / max(1, args.warmup))
            progress = max(0., (step - args.warmup) / max(1, args.steps - args.warmup))
            lr = args.lr * lr_scale * (0.1 + 0.9 * 0.5 * (1 + math.cos(math.pi * progress)))
            for group in optimizer.param_groups:
                group["lr"] = lr
            optimizer.zero_grad(set_to_none=True)
            loss_value = 0.
            for _ in range(args.accumulation):
                indices = torch.randint(len(arrays["train"]), (args.batch_size,), generator=sampling).numpy()
                clean = torch.tensor(np.array(arrays["train"][indices]), dtype=torch.long, device=device)
                noisy, selected, noise = corrupt(clean, noise_rng)
                with amp(device, args.precision):
                    logits = model(noisy, noise, selected)
                    loss = F.cross_entropy(logits.float(), clean[selected])
                if not torch.isfinite(loss):
                    raise RuntimeError("Non-finite loss; previous recovery checkpoint is preserved")
                (loss / args.accumulation).backward()
                loss_value += loss.item() / args.accumulation
                seen_tokens += int(clean.ne(PAD).sum())
            gradient = torch.nn.utils.clip_grad_norm_(model.parameters(), 1.)
            if not torch.isfinite(gradient):
                raise RuntimeError("Non-finite gradient; previous checkpoint is preserved")
            optimizer.step()
            step += 1
            if step == 1 or step % 25 == 0:
                wall = elapsed + time.perf_counter() - start
                row = {"step": step, "loss": loss_value, "lr": lr, "gradient_norm": float(gradient),
                       "seen_tokens": seen_tokens, "seconds": wall, "tokens_per_second": seen_tokens / wall}
                with log.open("a", encoding="utf-8") as f:
                    f.write(json.dumps(row) + "\n")
                print(json.dumps(row), flush=True)
            if step % args.eval_every == 0 or step == args.steps:
                metrics = evaluate(model, arrays["validation"], frequencies, device=device, precision=args.precision, limit=args.eval_samples)
                score = sum(r["cross_entropy"] for r in metrics) / len(metrics)
                with log.open("a", encoding="utf-8") as f:
                    f.write(json.dumps({"step": step, "validation": metrics, "validation_loss": score}) + "\n")
                if score < best:
                    best = score
                    model.save_pretrained(out / "best")
                    shutil.copy2(Path(args.data) / "tokenizer.json", out / "best" / "tokenizer.json")
                    (out / "best" / "training_info.json").write_text(json.dumps({"step": step, "validation_loss": score,
                        "parameters": parameters, "seen_tokens": seen_tokens, "pretrained_weights": False,
                        "manifest": manifest, "previous_phase": lineage}, indent=2), encoding="utf-8")
                save_state()
                print(json.dumps({"step": step, "validation_loss": score, "best": best}), flush=True)
    except KeyboardInterrupt:
        # An interruption in a partial step is not an exact resume point.
        print("Interrupted. Resume from the previous completed evaluation checkpoint.", flush=True)
        return
    model = Denoiser.from_pretrained(out / "best", device)
    report = {"status": "completed", "model_config": asdict(config), "parameters": parameters,
              "training": run_config, "seen_tokens": seen_tokens, "seconds": elapsed + time.perf_counter() - start,
              "torch_version": str(torch.__version__), "device_name": torch.cuda.get_device_name() if device == "cuda" else "cpu",
              "best": json.loads((out / "best" / "training_info.json").read_text()),
              "test": evaluate(model, arrays["test"], frequencies, device=device, precision=args.precision, limit=args.test_samples),
              "limitations": ["English synthetic stories, not a general chatbot", "No comparison to v1 byte accuracy: tokenizer and corpus differ",
                              "Custom masked-denoising objective, not a claimed diffusion likelihood bound"]}
    (out / "report.json").write_text(json.dumps(report, indent=2), encoding="utf-8")
    print(f"Training complete: {out / 'report.json'}", flush=True)