Ines-1 / mini_v41 /utils.py
Endikavi's picture
Ines-1 RC1 (private staging; release commit b7f5644)
61b6fb9 verified
Raw History Blame Contribute Delete
8.96 kB
"""Seeding, device/dtype selection, LR schedules, parameter groups, logging."""
from __future__ import annotations
import json
import math
import os
import random
import time
from pathlib import Path
import numpy as np
import torch
__all__ = [
"set_seed",
"resolve_device",
"torch_dtype",
"build_param_groups",
"build_scheduler",
"grad_norm",
"group_grad_norms",
"MetricLogger",
"estimate_mfu",
"peak_flops",
"human",
]
def set_seed(seed: int, deterministic: bool = False) -> None:
random.seed(seed)
np.random.seed(seed % (2**32))
torch.manual_seed(seed)
if torch.cuda.is_available():
torch.cuda.manual_seed_all(seed)
if deterministic:
os.environ.setdefault("CUBLAS_WORKSPACE_CONFIG", ":4096:8")
torch.use_deterministic_algorithms(True, warn_only=True)
torch.backends.cudnn.deterministic = True
torch.backends.cudnn.benchmark = False
def resolve_device(spec: str = "auto") -> torch.device:
if spec == "auto":
return torch.device("cuda" if torch.cuda.is_available() else "cpu")
return torch.device(spec)
def torch_dtype(name: str) -> torch.dtype:
return {"bf16": torch.bfloat16, "fp16": torch.float16, "fp32": torch.float32}[name]
def human(n: float) -> str:
for unit, scale in (("T", 1e12), ("B", 1e9), ("M", 1e6), ("K", 1e3)):
if abs(n) >= scale:
return f"{n / scale:.2f}{unit}"
return f"{n:.0f}"
# --------------------------------------------------------------------------- #
# Optimizer
# --------------------------------------------------------------------------- #
def build_param_groups(model: torch.nn.Module, optim_cfg) -> list[dict]:
"""Three groups: decayed matrices, undecayed vectors, Engram tables.
Engram tables are separated because they are sparsely touched -- a row is
updated only on the steps where some n-gram hashed to it. Weight decay would
shrink every untouched row on every step, which is exactly wrong for a
memory. They also get their own LR multiplier.
"""
decay, no_decay, engram = [], [], []
seen: set[int] = set()
for name, param in model.named_parameters():
if not param.requires_grad or id(param) in seen:
continue
seen.add(id(param))
if name.startswith("engrams.") and ".table." in name:
engram.append(param)
elif param.ndim >= 2:
decay.append(param)
else: # norms, biases, gates, sinks
no_decay.append(param)
groups = [
{"params": decay, "weight_decay": optim_cfg.weight_decay, "name": "decay"},
{"params": no_decay, "weight_decay": 0.0, "name": "no_decay"},
]
if engram:
groups.append(
{
"params": engram,
"weight_decay": 0.0,
"lr": optim_cfg.lr * optim_cfg.engram_lr_mult,
"name": "engram",
}
)
return [g for g in groups if g["params"]]
def build_scheduler(optimizer, optim_cfg, total_steps: int):
"""Warmup + cosine/linear decay to `min_lr_ratio * lr`."""
warmup = max(0, optim_cfg.warmup_steps)
floor = optim_cfg.min_lr_ratio
kind = optim_cfg.lr_schedule
def lr_lambda(step: int) -> float:
if warmup and step < warmup:
return (step + 1) / warmup
if kind == "constant":
return 1.0
progress = (step - warmup) / max(1, total_steps - warmup)
progress = min(1.0, max(0.0, progress))
if kind == "linear":
scale = 1.0 - progress
else: # cosine
scale = 0.5 * (1.0 + math.cos(math.pi * progress))
return floor + (1.0 - floor) * scale
return torch.optim.lr_scheduler.LambdaLR(optimizer, lr_lambda)
def grad_norm(params) -> float:
total = 0.0
for p in params:
if p.grad is not None:
total += p.grad.detach().float().norm(2).item() ** 2
return total**0.5
def group_grad_norms(model: torch.nn.Module) -> dict[str, float]:
"""Gradient norms per component, so a dead Engram or a dead router is
visible in the logs rather than inferred from the loss curve."""
groups: dict[str, float] = {}
for name, p in model.named_parameters():
if p.grad is None:
continue
if ".ffn.router." in name:
key = "router"
elif ".ffn.experts." in name:
key = "routed_experts"
elif ".ffn.shared_expert." in name:
key = "shared_expert"
elif name.startswith("engrams.") and ".table." in name:
key = "engram_table"
elif name.startswith("engrams."):
key = "engram_proj"
elif name.startswith("global_kv"):
key = "global_kv"
elif ".attn." in name:
key = "attention"
elif name.startswith("embed") or name.startswith("lm_head"):
key = "embedding"
else:
key = "other"
groups[key] = groups.get(key, 0.0) + p.grad.detach().float().norm(2).item() ** 2
return {k: v**0.5 for k, v in groups.items()}
# --------------------------------------------------------------------------- #
# MFU
# --------------------------------------------------------------------------- #
# Dense BF16 peak, TFLOP/s. Used only for a rough MFU estimate; unknown GPUs
# report MFU as None rather than a made-up number. Dense, not the marketing
# "with sparsity" figure -- 989 for H100/H200 is the 2:4-sparsity number, and
# mixing conventions would make H100 MFU read ~2x lower than A100 MFU.
_PEAK_BF16_TFLOPS = {
"H200": 494.7, "H100": 494.7, "A100": 312.0,
"RTX 5090": 210.0, "RTX 5080": 170.0, "RTX 5060 Ti": 90.0,
"RTX 4090": 165.2, "L40S": 181.0,
}
def peak_flops(device: torch.device) -> float | None:
if device.type != "cuda":
return None
name = torch.cuda.get_device_name(device)
for key, tflops in _PEAK_BF16_TFLOPS.items():
if key in name:
return tflops * 1e12
return None
def estimate_mfu(
active_params: int, tokens_per_second: float, device: torch.device
) -> float | None:
"""Rough MFU from the 6*N*tokens rule (fwd+bwd), attention FLOPs excluded.
For an MoE model N is the *active* parameter count, which is why
parameter_report() computes it. Attention's quadratic term is omitted; at
L=2048 with these hidden sizes it is a few percent of the total, so treat
this as a lower bound.
"""
peak = peak_flops(device)
if not peak or tokens_per_second <= 0:
return None
return (6.0 * active_params * tokens_per_second) / peak
# --------------------------------------------------------------------------- #
# Logging
# --------------------------------------------------------------------------- #
class MetricLogger:
"""TensorBoard + JSONL + console, all optional, same call site."""
def __init__(self, out_dir: str | Path, use_tensorboard: bool = True,
use_wandb: bool = False, wandb_project: str = "mini-v41", config: dict | None = None):
self.out_dir = Path(out_dir)
self.out_dir.mkdir(parents=True, exist_ok=True)
self.jsonl = (self.out_dir / "metrics.jsonl").open("a", encoding="utf-8")
self.writer = None
self.wandb = None
self.t0 = time.time()
if use_tensorboard:
try:
from torch.utils.tensorboard import SummaryWriter
self.writer = SummaryWriter(str(self.out_dir / "tb"))
except Exception as exc: # pragma: no cover
print(f"[log] tensorboard unavailable: {exc}")
if use_wandb:
try:
import wandb
wandb.init(project=wandb_project, dir=str(self.out_dir), config=config)
self.wandb = wandb
except Exception as exc: # pragma: no cover
print(f"[log] wandb unavailable: {exc}")
def log(self, step: int, metrics: dict, prefix: str = "") -> None:
flat = {}
for key, value in metrics.items():
if value is None:
continue
name = f"{prefix}{key}"
if isinstance(value, (int, float)) and math.isfinite(float(value)):
flat[name] = float(value)
elif isinstance(value, (int, float)):
flat[name] = float(value) # keep inf/nan in jsonl; skip for TB
record = {"step": step, "wall": time.time() - self.t0, **flat}
self.jsonl.write(json.dumps(record) + "\n")
self.jsonl.flush()
if self.writer is not None:
for name, value in flat.items():
if math.isfinite(value):
self.writer.add_scalar(name, value, step)
if self.wandb is not None:
self.wandb.log(record, step=step)
def close(self) -> None:
self.jsonl.close()
if self.writer is not None:
self.writer.close()