File size: 9,795 Bytes
f0ab913 | 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 | # -*- coding: utf-8 -*-
"""Single-step train / eval primitives."""
from __future__ import annotations
import contextlib
from dataclasses import dataclass, field
from typing import Any
import torch
import torch.nn as nn
from Model.config import IGNORE_INDEX, TrainingConfig
from Model.training.optim import recurrent_steps_for_step
@dataclass
class TrainState:
step: int = 0
tokens_seen: int = 0
last_loss: float = float("nan")
extra: dict[str, Any] = field(default_factory=dict)
def _autocast_ctx(precision: str, device_type: str):
if precision == "fp32":
return contextlib.nullcontext()
dtype = torch.bfloat16 if precision == "bf16" else torch.float16
return torch.autocast(device_type=device_type, dtype=dtype)
def _new_cuda_grad_scaler():
"""Build a CUDA ``GradScaler`` using the non-deprecated API when available."""
try:
return torch.amp.GradScaler("cuda")
except (AttributeError, TypeError):
return torch.cuda.amp.GradScaler()
def _grad_scaler_for(model: nn.Module, cfg: TrainingConfig, device_type: str):
"""Return a fp16 ``GradScaler`` (sharded under FSDP) or ``None``.
fp16 autocast without loss scaling silently underflows small gradients to
zero and can diverge to NaN. bf16/fp32 have enough exponent range and need
no scaler. CPU has no fp16 GradScaler support, so we leave it unscaled.
"""
if cfg.precision != "fp16" or device_type != "cuda":
return None
# FSDP shards gradients across ranks; a plain GradScaler.unscale_ would see
# only the local shard, so use the sharded variant when the model is FSDP.
if hasattr(model, "clip_grad_norm_"):
try:
from torch.distributed.fsdp.sharded_grad_scaler import (
ShardedGradScaler,
)
return ShardedGradScaler()
except Exception: # pragma: no cover - older torch without ShardedGradScaler
return _new_cuda_grad_scaler()
return _new_cuda_grad_scaler()
def _to_device(batch: dict[str, Any], device: torch.device) -> dict[str, Any]:
out: dict[str, Any] = {}
for k, v in batch.items():
if isinstance(v, torch.Tensor):
out[k] = v.to(device, non_blocking=True)
elif isinstance(v, dict):
# Pixel batches arrive as ``{"images": ..., "vertical_patches": ...}``;
# we have to recurse so every leaf tensor lands on the model's
# device. Non-tensor leaves (rare) pass through untouched.
out[k] = {
kk: vv.to(device, non_blocking=True) if isinstance(vv, torch.Tensor) else vv
for kk, vv in v.items()
}
else:
out[k] = v
return out
def train_one_step(
model: nn.Module,
batch_iter,
optimizer: torch.optim.Optimizer,
scheduler: torch.optim.lr_scheduler._LRScheduler,
cfg: TrainingConfig,
state: TrainState,
*,
device: torch.device,
target_recurrent_steps: int | None = None,
) -> dict[str, float]:
"""Run one optimizer step (covers ``grad_accum_steps`` micro batches)."""
model.train()
optimizer.zero_grad(set_to_none=True)
device_type = device.type if device.type in ("cuda", "cpu") else "cpu"
loss_terms: list[torch.Tensor] = []
token_count = 0
# fp16 needs loss scaling; persist the scaler across steps via state.extra
# so its dynamic scale factor is preserved (and can be checkpointed).
scaler = state.extra.get("grad_scaler")
if scaler is None and cfg.precision == "fp16":
scaler = _grad_scaler_for(model, cfg, device_type)
if scaler is not None:
pending = state.extra.pop("grad_scaler_state", None)
if pending is not None:
scaler.load_state_dict(pending)
state.extra["grad_scaler"] = scaler
rec_steps = None
if target_recurrent_steps is not None:
rec_steps = recurrent_steps_for_step(state.step, cfg, target_recurrent_steps)
for _ in range(cfg.grad_accum_steps):
batch = next(batch_iter)
# Count tokens on the CPU mask **before** moving to device so the
# ``.sum()`` does not force a host-sync against the GPU stream.
cpu_mask = batch.get("attention_mask")
if isinstance(cpu_mask, torch.Tensor):
token_count += int(cpu_mask.sum().item())
batch = _to_device(batch, device)
with _autocast_ctx(cfg.precision, device_type):
out = model(
input_ids=batch["input_ids"],
attention_mask=batch.get("attention_mask"),
labels=batch["labels"],
word_pos=batch.get("word_pos"),
morph_depth=batch.get("morph_depth"),
pixel_values=batch.get("pixel_values"),
steps=rec_steps,
bptt_window=cfg.bptt_window,
return_logits=not cfg.use_loss_chunking,
)
loss = out["loss"] / cfg.grad_accum_steps
if scaler is not None:
scaler.scale(loss).backward()
else:
loss.backward()
# Keep the per-microstep loss as a detached tensor; we only sync
# to host once per optimizer step to avoid stalling the training
# loop on the device queue.
loss_terms.append(loss.detach())
if scaler is not None:
# Unscale before clipping so grad_clip is applied to true gradients.
scaler.unscale_(optimizer)
if cfg.grad_clip and cfg.grad_clip > 0:
if hasattr(model, "clip_grad_norm_"):
grad_norm = model.clip_grad_norm_(cfg.grad_clip)
else:
grad_norm = torch.nn.utils.clip_grad_norm_(
model.parameters(), cfg.grad_clip
)
grad_norm_val = float(grad_norm)
else:
grad_norm_val = float("nan")
if scaler is not None:
# GradScaler.step() may SKIP the underlying optimizer update when the
# unscaled grads contain inf/NaN. An overflow step lowers the dynamic
# scale on update(); a real step keeps or grows it. Only advance the LR
# scheduler when the optimizer actually stepped, so fp16 overflow steps
# don't silently consume warmup/decay slots and desync the schedule.
scale_before = scaler.get_scale()
scaler.step(optimizer)
scaler.update()
stepped = scaler.get_scale() >= scale_before
else:
optimizer.step()
stepped = True
if stepped:
scheduler.step()
if loss_terms:
# One host-sync per optimizer step instead of per micro-step.
loss_sum = torch.stack(loss_terms).sum().item() * cfg.grad_accum_steps
else:
loss_sum = 0.0
state.step += 1
state.tokens_seen += token_count
state.last_loss = loss_sum / max(1, cfg.grad_accum_steps)
return {
"loss": state.last_loss,
"grad_norm": grad_norm_val,
"lr": float(scheduler.get_last_lr()[0]),
"tokens": float(token_count),
}
@torch.no_grad()
def evaluate(
model: nn.Module,
batches,
cfg: TrainingConfig,
*,
device: torch.device,
max_batches: int = 32,
) -> dict[str, float]:
model.eval()
device_type = device.type if device.type in ("cuda", "cpu") else "cpu"
# The model reduces its loss using its *own* ``ignore_index`` (RDTConfig),
# which may be overridden away from the module-level default. Read it from
# the unwrapped module so the eval denominator counts exactly the positions
# the model treated as targets (DDP/FSDP expose the real module via
# ``.module``).
core = model
while hasattr(core, "module"):
core = core.module
ignore_index = getattr(getattr(core, "cfg", None), "ignore_index", IGNORE_INDEX)
total_loss = 0.0
total_targets = 0
seen = 0
for batch in batches:
if seen >= max_batches:
break
# Count every consumed batch toward the limit *before* any skip so an
# infinite streaming dataloader yielding all-ignore rows can't spin
# forever — ``max_batches`` must bound batches read, not just averaged.
seen += 1
batch = _to_device(batch, device)
with _autocast_ctx(cfg.precision, device_type):
out = model(
input_ids=batch["input_ids"],
attention_mask=batch.get("attention_mask"),
labels=batch["labels"],
word_pos=batch.get("word_pos"),
morph_depth=batch.get("morph_depth"),
pixel_values=batch.get("pixel_values"),
return_logits=not cfg.use_loss_chunking,
)
# Report the **forward (causal) LM loss** — the perplexity-relevant
# term — weighted by its own valid next-token count. Labels are shifted
# internally (logits[:, :-1] vs labels[:, 1:]); image/pad positions are
# ignore_index. Using the forward part keeps the cross-batch average
# exact regardless of the reverse/ponder terms a bidirectional training
# objective folds into ``out["loss"]`` (those use a different shift and
# token count, which would bias a combined-loss weighting).
labels = batch["labels"]
n_targets = int((labels[:, 1:] != ignore_index).sum().item())
if n_targets == 0:
continue
loss_parts = out.get("loss_parts") or {}
batch_loss = loss_parts.get("forward")
if batch_loss is None:
batch_loss = float(out["loss"].item())
total_loss += float(batch_loss) * n_targets
total_targets += n_targets
avg = total_loss / max(1, total_targets)
return {"eval_loss": avg, "eval_tokens": float(total_targets)}
__all__ = ["TrainState", "evaluate", "train_one_step"]
|