GABIRI's picture
Add official Mamba randomized verifier
f0ab913 verified
Raw
History Blame Contribute Delete
9.8 kB
# -*- 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"]