File size: 20,483 Bytes
41dd7e0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
"""Training loop: exactly ``training.max_steps`` optimizer updates, with EMA weights, gradient clipping and
validation events.

A validation event fires after global update ``step`` when ``step % val_every_updates == 0``, at ``max_steps`` and
at every step listed in ``val_extra_steps`` (0 = the untrained model). An event evaluates the training objective on
the validation pairs (``val_loss``, and ``val_loss_ema`` for the EMA weights), writes one row of ``train_log.csv``
(``event`` = 1), saves ``checkpoints/checkpoint_step<NNNNNNNN>.pt`` and the full-state ``restart_latest.pt``. With
``log_loss_every_epochs: k`` a loss-only row (``event`` = 0, no checkpoint) is written at the end of every k-th epoch.
``train_loss`` of a row is the mean over the updates since the previous row. The validation passes run under a
forked RNG with a fixed seed and restore the loader's generator state, so they never perturb the training
trajectory. ``checkpoints/checkpoint.pt`` holds the final weights; every checkpoint stores the raw and the EMA
weights and the run configuration.

EMA and LR ramp-up follow the convention of NVlabs/edm (``training/training_loop.py``): ``ema_halflife_kimg``,
``ema_rampup_ratio`` and ``lr_rampup_kimg`` come from the training section (overrides) or the method section.
"""
from __future__ import annotations

import copy
import os
import random
import time
from pathlib import Path
from typing import Any, Dict

import numpy as np
import torch

RESTART_SCHEMA = "restart_v1"
RESTART_NAME = "restart_latest.pt"
LOG_COLUMNS = "step,epoch,batch_index,event,train_loss,val_loss,val_loss_ema,train_eval_loss,train_eval_loss_ema,lr,elapsed_s"


def _ema_beta(cur_nimg: int, batch_size: int, ema_halflife_kimg: float, ema_rampup_ratio: float | None) -> float:
    ema_halflife_nimg = ema_halflife_kimg * 1000
    if ema_rampup_ratio is not None:
        ema_halflife_nimg = min(ema_halflife_nimg, cur_nimg * ema_rampup_ratio)
    return 0.5 ** (batch_size / max(ema_halflife_nimg, 1e-8))


def _set_rampup_lr(optimizer: torch.optim.Optimizer, base_lr: float, cur_nimg: int, lr_rampup_kimg: float) -> None:
    factor = min(cur_nimg / max(lr_rampup_kimg * 1000, 1e-8), 1.0)
    for g in optimizer.param_groups:
        g["lr"] = base_lr * factor


def _eval_loss(model, loader, method, device, max_batches=None) -> float:
    losses = []
    with torch.no_grad():
        for i, batch in enumerate(loader):
            if max_batches is not None and i >= max_batches:
                break
            batch = tuple(t.to(device) for t in batch)
            losses.append(method.loss(model, batch).item())
    return float(np.mean(losses)) if losses else float("nan")


def _save_checkpoint(path: Path, model, ema_model, cfg: Dict[str, Any], epoch: int, step: int) -> None:
    payload = {"model_state_dict": model.state_dict(), "epoch": epoch, "config": cfg, "step": int(step)}
    if ema_model is not None:
        payload["ema_state_dict"] = ema_model.state_dict()
    torch.save(payload, path)


def _save_restart(path: Path, payload: Dict[str, Any]) -> None:
    tmp = path.with_name(path.name + ".tmp")            # atomic: a job killed mid-save leaves no truncated file
    torch.save(payload, tmp)
    os.replace(tmp, path)


def _truncate_log(log_path: Path, keep_step: int) -> int:
    """Drop the rows after ``keep_step`` (and a partial final line)."""
    lines = log_path.read_text().splitlines()
    header, rows = lines[0], lines[1:]
    kept = []
    for line in rows:
        try:
            if int(line.split(",", 1)[0]) <= keep_step:
                kept.append(line)
        except ValueError:
            continue
    log_path.write_text("\n".join([header] + kept) + "\n")
    return len(rows) - len(kept)


class Trainer:
    def __init__(self, model, method, train_loader, val_loader, cfg: Dict[str, Any], run_dir: Path, device: str):
        self.model = model
        self.method = method
        self.train_loader = train_loader
        self.val_loader = val_loader
        self.cfg = cfg
        self.run_dir = Path(run_dir)
        self.device = device

        tr, m = cfg["training"], cfg["method"]
        allow_tf32 = tr.get("allow_tf32")
        if allow_tf32 is not None:                    # one arithmetic class on every GPU (False = strict FP32)
            torch.backends.cuda.matmul.allow_tf32 = bool(allow_tf32)
            torch.backends.cudnn.allow_tf32 = bool(allow_tf32)
            print(f"precision: allow_tf32={bool(allow_tf32)} (matmul + cudnn)")
        if bool(tr.get("deterministic", False)):      # a resumed run is bit-identical to an uninterrupted one
            os.environ.setdefault("CUBLAS_WORKSPACE_CONFIG", ":4096:8")
            torch.backends.cudnn.deterministic = True
            torch.backends.cudnn.benchmark = False
            torch.use_deterministic_algorithms(True)
            print("determinism: use_deterministic_algorithms(True), cudnn.deterministic, benchmark off, "
                  "CUBLAS_WORKSPACE_CONFIG=" + os.environ["CUBLAS_WORKSPACE_CONFIG"])

        self.batch_size = int(tr["batch_size"])
        if "lr" in tr:
            self.base_lr = float(tr["lr"])
        elif "lr" in m:
            self.base_lr = float(m["lr"])
        else:
            raise KeyError("'lr' must be set in the training or the method section")
        self.val_seed = int(tr.get("val_seed", 777))
        self.restart_checkpointing = bool(tr.get("restart_checkpointing", True))

        if tr.get("max_steps") is None or tr.get("val_every_updates") is None:
            raise KeyError("training.max_steps and training.val_every_updates are required")
        self.max_steps = int(tr["max_steps"])
        self.val_every_updates = int(tr["val_every_updates"])
        if self.max_steps <= 0 or self.val_every_updates <= 0:
            raise ValueError("max_steps and val_every_updates must be positive")
        self.val_extra_steps = tr.get("val_extra_steps")
        if self.val_extra_steps is not None:
            if not isinstance(self.val_extra_steps, (list, tuple)) or \
                    any(isinstance(s, bool) or int(s) != s for s in self.val_extra_steps):
                raise ValueError("training.val_extra_steps must be a list of integers")
            xs = sorted({int(s) for s in self.val_extra_steps})
            bad = [s for s in xs if s < 0 or s >= self.max_steps]
            if bad:
                raise ValueError(f"training.val_extra_steps must lie in [0, max_steps): {bad}")
            self.val_extra_steps = xs
        self.log_loss_every_epochs = tr.get("log_loss_every_epochs")
        if self.log_loss_every_epochs is not None:
            k = self.log_loss_every_epochs
            if isinstance(k, bool) or int(k) != k or int(k) <= 0:
                raise ValueError("training.log_loss_every_epochs must be a positive integer")
            self.log_loss_every_epochs = int(k)

        def _opt(key, default=None):                  # training section overrides the method section
            return tr[key] if key in tr else m.get(key, default)

        self.ema_halflife_kimg = _opt("ema_halflife_kimg")           # None: no EMA
        self.ema_rampup_ratio = _opt("ema_rampup_ratio", 0.05)
        self.lr_rampup_kimg = _opt("lr_rampup_kimg")                 # None: no LR ramp
        self.max_grad_norm = _opt("max_grad_norm")                   # None: no clipping
        self.weight_decay = _opt("weight_decay")
        self.use_ema = self.ema_halflife_kimg is not None and float(self.ema_halflife_kimg) > 0
        self.use_lr_rampup = self.lr_rampup_kimg is not None and float(self.lr_rampup_kimg) > 0

    def fit(self, resume: Dict[str, Any] | None = None) -> None:
        self.run_dir.mkdir(parents=True, exist_ok=True)
        ckpt_dir = self.run_dir / "checkpoints"
        ckpt_dir.mkdir(parents=True, exist_ok=True)

        model = self.model.to(self.device)
        if self.use_ema:
            ema_model = copy.deepcopy(model).eval().to(self.device)
            for p in ema_model.parameters():
                p.requires_grad_(False)
        else:
            ema_model = None
        optimizer = self.method.configure_optimizer(model, {"lr": self.base_lr, "weight_decay": self.weight_decay})

        start_epoch = 1
        if resume is not None:
            if resume.get("schema") != RESTART_SCHEMA:
                raise AssertionError(f"restart checkpoint schema {resume.get('schema')!r} != {RESTART_SCHEMA}")
            model.load_state_dict(resume["model_state_dict"])
            if ema_model is not None:
                if resume.get("ema_state_dict") is None:
                    raise AssertionError("EMA is on but the restart checkpoint has no EMA state")
                ema_model.load_state_dict(resume["ema_state_dict"])
            optimizer.load_state_dict(resume["optimizer_state_dict"])
            start_epoch = int(resume["epoch"])        # the interrupted epoch is replayed

        n_train_pairs = len(self.train_loader.dataset)
        steps_per_epoch = len(self.train_loader)
        n_epochs = max(1, -(-self.max_steps // steps_per_epoch))
        print(f"schedule: max_steps = {self.max_steps}, validation + checkpoint every {self.val_every_updates} updates "
              f"(+ at max_steps); {n_train_pairs} training pairs, batch {self.batch_size}, {steps_per_epoch} steps/epoch -> "
              f"{n_epochs} epochs (last one partial: {self.max_steps - (n_epochs - 1) * steps_per_epoch} steps)")
        if self.use_ema:
            seen_kimg = (self.max_steps - 1) * self.batch_size / 1000.0
            eff_kimg = min(float(self.ema_halflife_kimg), float(self.ema_rampup_ratio) * seen_kimg)
            print(f"EMA: half-life {self.ema_halflife_kimg} kimg, ramp-up ratio {self.ema_rampup_ratio} "
                  f"(effective half-life at the last update ~= {eff_kimg:.1f} kimg)")
        else:
            print("EMA: disabled")
        print(f"LR ramp-up: {self.lr_rampup_kimg if self.use_lr_rampup else 'disabled'}; "
              f"gradient clipping: {self.max_grad_norm if self.max_grad_norm is not None else 'disabled'}")

        log_path = self.run_dir / "train_log.csv"
        if resume is not None:
            if int(resume["step"]) >= self.max_steps:
                raise AssertionError(f"resume step {int(resume['step'])} >= max_steps {self.max_steps} (nothing to continue)")
            if not log_path.exists():
                raise FileNotFoundError(f"--resume requires the existing {log_path}")
            dropped = _truncate_log(log_path, int(resume["step"]))
            print(f"resume: continuing at step {int(resume['step'])} (epoch {start_epoch}, batch "
                  f"{int(resume['batch_index'])}; {dropped} log row(s) dropped)")
        else:
            log_path.write_text(LOG_COLUMNS + "\n")

        cur_nimg = 0
        if resume is not None:                        # RNG streams restored last, after every setup step
            cur_nimg = int(resume["cur_nimg"])
            torch.set_rng_state(resume["torch_rng_state"])
            if str(self.device).startswith("cuda"):
                torch.cuda.set_rng_state_all(resume["cuda_rng_state_all"])
            np.random.set_state(resume["numpy_rng_state"])
            random.setstate(resume["python_rng_state"])
            gen = self.train_loader.generator
            if resume.get("loader_generator_state_epoch_start") is not None:
                if gen is None:
                    raise AssertionError("restart checkpoint has a loader generator state but the train loader has no generator")
                gen.set_state(resume["loader_generator_state_epoch_start"])
            print(f"resume: restored optimizer, EMA, counters (cur_nimg={cur_nimg}), RNG streams and loader generator", flush=True)
        self._fit_updates(model=model, ema_model=ema_model, optimizer=optimizer, ckpt_dir=ckpt_dir, log_path=log_path,
                          resume=resume, start_epoch=start_epoch, cur_nimg=cur_nimg, n_val_batches=len(self.val_loader))

    def _fit_updates(self, *, model, ema_model, optimizer, ckpt_dir, log_path, resume, start_epoch, cur_nimg, n_val_batches):
        max_steps, every = self.max_steps, self.val_every_updates
        extra_steps = set(self.val_extra_steps or [])
        loss_every = int(self.log_loss_every_epochs) if self.log_loss_every_epochs else 0
        devices_all = [self.device] if str(self.device).startswith("cuda") else []
        losses_since_epoch_start: list[float] = []
        last_event_step = -1
        t_row = time.time()
        if resume is not None:
            losses_since_epoch_start = [float(x) for x in resume.get("losses_since_epoch_start", [])]
            last_event_step = int(resume["step"])
        step, start_batch = 0, 0
        if resume is not None:
            step, start_batch = int(resume["step"]), int(resume["batch_index"])
        epoch = start_epoch
        losses_since_event: list[float] = []
        t_event = time.time()
        done = False
        gen = self.train_loader.generator

        def _event(epoch_i: int, batch_i: int, gen_state_epoch_start):
            nonlocal losses_since_event, t_event, last_event_step, t_row
            train_loss = float(np.mean(losses_since_event)) if losses_since_event else float("nan")
            cur_lr = optimizer.param_groups[0]["lr"]
            model.eval()
            g_state = gen.get_state() if gen is not None else None
            with torch.random.fork_rng(devices=devices_all):
                torch.manual_seed(self.val_seed)
                val_loss = _eval_loss(model, self.val_loader, self.method, self.device)
                train_eval = _eval_loss(model, self.train_loader, self.method, self.device, max_batches=n_val_batches)
                if self.use_ema:
                    val_loss_ema = _eval_loss(ema_model, self.val_loader, self.method, self.device)
                    train_eval_ema = _eval_loss(ema_model, self.train_loader, self.method, self.device, max_batches=n_val_batches)
                else:
                    val_loss_ema = train_eval_ema = float("nan")
            if g_state is not None:
                gen.set_state(g_state)
            elapsed = time.time() - t_event
            t_event = t_row = time.time()
            last_event_step = step
            print(f"step {step} (epoch {epoch_i}, batch {batch_i}): train_loss={train_loss:.6f}  val={val_loss:.6f} "
                  f"val_ema={val_loss_ema:.6f}  lr={cur_lr:.2e}  elapsed={elapsed:.2f}s", flush=True)
            with open(log_path, "a") as f:
                f.write(f"{step},{epoch_i},{batch_i},1,{train_loss},{val_loss},{val_loss_ema},"
                        f"{train_eval},{train_eval_ema},{cur_lr},{elapsed}\n")
            losses_since_event = []
            ckpt = ckpt_dir / f"checkpoint_step{step:08d}.pt"
            _save_checkpoint(ckpt, model, ema_model, self.cfg, epoch_i, step=step)
            print(f"  saved: {ckpt.name}", flush=True)
            if self.restart_checkpointing:
                _save_restart(ckpt_dir / RESTART_NAME, {
                    "schema": RESTART_SCHEMA, "epoch": epoch_i, "step": step, "batch_index": batch_i, "cur_nimg": cur_nimg,
                    "model_state_dict": model.state_dict(),
                    "ema_state_dict": (ema_model.state_dict() if ema_model is not None else None),
                    "optimizer_state_dict": optimizer.state_dict(),
                    "torch_rng_state": torch.get_rng_state(),
                    "cuda_rng_state_all": (torch.cuda.get_rng_state_all() if str(self.device).startswith("cuda") else []),
                    "numpy_rng_state": np.random.get_state(), "python_rng_state": random.getstate(),
                    "loader_generator_state_epoch_start": gen_state_epoch_start,
                    "losses_since_epoch_start": list(losses_since_epoch_start),
                    "config": self.cfg})
            model.train()

        def _loss_row(epoch_i: int, batch_i: int):
            nonlocal losses_since_epoch_start, t_row
            if not losses_since_epoch_start or step == last_event_step:
                losses_since_epoch_start = []
                return                                # nothing to log, or an event just logged this step
            train_loss = float(np.mean(losses_since_epoch_start))
            cur_lr = optimizer.param_groups[0]["lr"]
            model.eval()
            g_state = gen.get_state() if gen is not None else None
            with torch.random.fork_rng(devices=devices_all):
                torch.manual_seed(self.val_seed)
                val_loss = _eval_loss(model, self.val_loader, self.method, self.device)
                val_loss_ema = (_eval_loss(ema_model, self.val_loader, self.method, self.device) if self.use_ema else float("nan"))
            if g_state is not None:
                gen.set_state(g_state)
            model.train()
            nan = float("nan")
            elapsed = time.time() - t_row
            t_row = time.time()
            with open(log_path, "a") as f:
                f.write(f"{step},{epoch_i},{batch_i},0,{train_loss},{val_loss},{val_loss_ema},{nan},{nan},{cur_lr},{elapsed}\n")
            losses_since_epoch_start = []

        if 0 in extra_steps and resume is None and step == 0:
            # the untrained model: its train_loss is a forward-only loss on the first batch; RNG and loader untouched
            g_state = gen.get_state() if gen is not None else None
            model.eval()
            with torch.random.fork_rng(devices=devices_all):
                torch.manual_seed(self.val_seed)
                with torch.no_grad():
                    b0 = tuple(t.to(self.device) for t in next(iter(self.train_loader)))
                    loss0 = float(self.method.loss(model, b0).item())
            model.train()
            if g_state is not None:
                gen.set_state(g_state)
            losses_since_event = [loss0]
            _event(epoch, 0, gen.get_state() if gen is not None else None)

        while not done:
            gen_state_epoch_start = gen.get_state() if gen is not None else None
            replay = resume is not None and epoch == start_epoch and start_batch > 0
            if replay:                                # the interrupted epoch: same permutation, consumed batches skipped
                gen_state_epoch_start = resume["loader_generator_state_epoch_start"]
            model.train()
            if not replay:
                losses_since_epoch_start = []
            it = iter(self.train_loader)
            skip = start_batch if (resume is not None and epoch == start_epoch) else 0
            for _ in range(skip):
                next(it)
            batch_i = skip
            for batch in it:
                batch = tuple(t.to(self.device) for t in batch)
                if self.use_lr_rampup:
                    _set_rampup_lr(optimizer, self.base_lr, cur_nimg, float(self.lr_rampup_kimg))
                optimizer.zero_grad()
                loss = self.method.loss(model, batch)
                loss.backward()
                if self.max_grad_norm is not None:
                    torch.nn.utils.clip_grad_norm_(model.parameters(), float(self.max_grad_norm))
                optimizer.step()
                if self.use_ema:
                    beta = _ema_beta(cur_nimg, self.batch_size, float(self.ema_halflife_kimg), self.ema_rampup_ratio)
                    for p_ema, p_net in zip(ema_model.parameters(), model.parameters()):
                        p_ema.copy_(p_net.detach().lerp(p_ema, beta))
                    for b_ema, b_net in zip(ema_model.buffers(), model.buffers()):
                        b_ema.copy_(b_net)
                cur_nimg += batch[0].shape[0]
                _li = loss.item()
                losses_since_event.append(_li)
                losses_since_epoch_start.append(_li)
                step += 1
                batch_i += 1
                if step % every == 0 or step == max_steps or step in extra_steps:
                    _event(epoch, batch_i, gen_state_epoch_start)
                if step >= max_steps:
                    done = True
                    break
            if not done and loss_every and epoch % loss_every == 0:
                _loss_row(epoch, batch_i)
            epoch += 1

        print(f"trained exactly {max_steps} updates", flush=True)
        _save_checkpoint(ckpt_dir / "checkpoint.pt", model, ema_model, self.cfg, epoch - 1, step=max_steps)