Juan S Santillana
fix GQA compat PyTorch 2.4 and strip _orig_mod. on checkpoint load
ef31fa4
Raw
History Blame Contribute Delete
12.7 kB
"""Training utilities: optimizer setup, LR schedule, checkpointing, cloud backup."""
import json
import math
import os
import threading
import time
from pathlib import Path
import torch
def cosine_with_warmup(step, warmup, total, max_lr, min_lr_ratio=0.1):
if step < warmup:
return max_lr * (step + 1) / warmup
progress = (step - warmup) / max(1, total - warmup)
progress = min(1.0, progress)
return min_lr_ratio * max_lr + 0.5 * (max_lr - min_lr_ratio * max_lr) * (1 + math.cos(math.pi * progress))
def wsd_scheduler(step, warmup, stable, total, max_lr, min_lr_ratio=0.1):
"""Warmup-Stable-Decay (WSD) learning rate schedule.
Allows pausing in the stable phase for evaluation or corpus extension.
Decay only happens in the final fraction β€” resuming mid-stable is safe.
"""
if step < warmup:
return max_lr * (step + 1) / warmup
if step < warmup + stable:
return max_lr
decay_step = step - warmup - stable
decay_total = max(1, total - warmup - stable)
progress = min(1.0, decay_step / decay_total)
return min_lr_ratio * max_lr + 0.5 * (max_lr - min_lr_ratio * max_lr) * (1 + math.cos(math.pi * progress))
def make_optimizer(model, lr, weight_decay=0.1, betas=(0.9, 0.95), fused=True):
"""AdamW with weight decay only on 2D weights (no decay on biases / norms / embeddings).
Per Loshchilov & Hutter; same convention as nanoGPT.
"""
decay, no_decay = [], []
for n, p in model.named_parameters():
if not p.requires_grad:
continue
if p.dim() >= 2 and "tok_emb" not in n:
decay.append(p)
else:
no_decay.append(p)
groups = [
{"params": decay, "weight_decay": weight_decay},
{"params": no_decay, "weight_decay": 0.0},
]
extra = {}
if fused and torch.cuda.is_available():
try:
return torch.optim.AdamW(groups, lr=lr, betas=betas, fused=True)
except TypeError:
pass
return torch.optim.AdamW(groups, lr=lr, betas=betas, **extra)
def save_checkpoint(path, model, optimizer, scheduler_state, step, extra=None):
path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True)
payload = {
"model": model.state_dict(),
"optimizer": optimizer.state_dict() if optimizer is not None else None,
"scheduler": scheduler_state,
"step": step,
"config": {k: getattr(model.cfg, k) for k in model.cfg.__dataclass_fields__},
"extra": extra or {},
}
tmp = path.with_suffix(path.suffix + ".tmp")
torch.save(payload, tmp)
os.replace(tmp, path)
def load_checkpoint(path, model, optimizer=None, map_location="cpu"):
payload = torch.load(path, map_location=map_location, weights_only=False)
state = payload["model"]
if any(k.startswith("_orig_mod.") for k in state):
state = {k.replace("_orig_mod.", "", 1): v for k, v in state.items()}
model.load_state_dict(state)
if optimizer is not None and payload.get("optimizer"):
optimizer.load_state_dict(payload["optimizer"])
return payload.get("step", 0), payload.get("extra", {})
def count_tokens(loader_output_iter, n_steps, block_size, batch_size):
"""Approximate; effective tokens consumed per step."""
return n_steps * block_size * batch_size
def log_jsonl(path, record):
with open(path, "a", encoding="utf-8") as f:
f.write(json.dumps(record, ensure_ascii=False) + "\n")
def _save_weights_only(src_path, dst_path):
"""Write a weights-only copy of a full checkpoint (drops optimizer state).
Returns dst_path. Used to produce the lighter artifact uploaded to HuggingFace.
"""
payload = torch.load(src_path, map_location="cpu", weights_only=False)
slim = {
"model": payload["model"],
"scheduler": payload.get("scheduler"),
"step": payload.get("step"),
"config": payload.get("config"),
"extra": payload.get("extra", {}),
}
dst_path = Path(dst_path)
dst_path.parent.mkdir(parents=True, exist_ok=True)
tmp = dst_path.with_suffix(dst_path.suffix + ".tmp")
torch.save(slim, tmp)
os.replace(tmp, dst_path)
return dst_path
def _parse_gcs_uri(uri):
"""Split gs://bucket/prefix into (bucket, prefix). Trailing slash stripped."""
if not uri.startswith("gs://"):
raise ValueError(f"GCS uri must start with gs://, got {uri!r}")
rest = uri[len("gs://"):]
parts = rest.split("/", 1)
bucket = parts[0]
prefix = parts[1].rstrip("/") if len(parts) > 1 else ""
return bucket, prefix
class CloudBackup:
"""Non-blocking checkpoint backup to GCS (primary) and HuggingFace (secondary).
Each destination runs a single-slot worker: at most one upload in flight per
destination. If a new trigger arrives while an upload is running, it is dropped
(logged) rather than queued, so a slow network can never stall training or build
an unbounded backlog. All failures are logged as warnings; training never stops.
GCS receives the full checkpoint (model + optimizer). HuggingFace receives a
weights-only copy. Successful uploads are recorded in {out_dir}/backup_state.json.
Layout:
GCS: {gcs_prefix}/phase{phase}/last.pt (overwritten every trigger)
{gcs_prefix}/phase{phase}/step_{N:07d}.pt (every `interval` steps)
HF: model_step_{N:07d}.pt (weights only, every `interval` steps)
"""
def __init__(self, out_dir, phase, gcs_uri=None, hf_repo=None,
interval=2000, hf_token=None, log=print):
self.out_dir = Path(out_dir)
self.phase = phase
self.interval = max(1, int(interval))
self.log = log
self.state_path = self.out_dir / "backup_state.json"
self._gcs_bucket = None
self._gcs = None
self._gcs_prefix = None
self._hf_api = None
self._hf_repo = None
self._state_lock = threading.Lock()
self._slots = {} # dest -> threading.Lock (held while uploading)
self._state = self._load_state()
if gcs_uri:
self._init_gcs(gcs_uri)
if hf_repo:
self._init_hf(hf_repo, hf_token)
@property
def enabled(self):
return self._gcs is not None or self._hf_api is not None
# ── destination setup ──────────────────────────────────────────────────────
def _init_gcs(self, gcs_uri):
try:
from google.cloud import storage
bucket_name, prefix = _parse_gcs_uri(gcs_uri)
client = storage.Client()
bucket = client.bucket(bucket_name)
bucket.reload() # forces auth + existence check
self._gcs = client
self._gcs_bucket = bucket
self._gcs_prefix = prefix
self._slots["gcs"] = threading.Lock()
self.log(f"[backup] GCS enabled β†’ gs://{bucket_name}/{prefix}")
except Exception as e:
self._gcs = None
self.log(f"[backup][warn] GCS disabled (init failed): {e}")
def _init_hf(self, hf_repo, hf_token):
try:
from huggingface_hub import HfApi
token = hf_token or self._resolve_hf_token()
if not token:
self.log("[backup][warn] HF disabled: no token (HF_TOKEN / ~/tok2.txt)")
return
api = HfApi(token=token)
api.create_repo(repo_id=hf_repo, repo_type="model",
private=True, exist_ok=True)
self._hf_api = api
self._hf_repo = hf_repo
self._slots["hf"] = threading.Lock()
self.log(f"[backup] HF enabled β†’ {hf_repo} (private)")
except Exception as e:
self._hf_api = None
self.log(f"[backup][warn] HF disabled (init failed): {e}")
@staticmethod
def _resolve_hf_token():
tok = os.environ.get("HF_TOKEN")
if tok:
return tok.strip()
tok_file = Path.home() / "tok2.txt"
if tok_file.exists():
t = tok_file.read_text(encoding="utf-8").strip()
return t or None
return None
# ── state file ─────────────────────────────────────────────────────────────
def _load_state(self):
if self.state_path.exists():
try:
return json.loads(self.state_path.read_text(encoding="utf-8"))
except Exception:
pass
return {"phase": self.phase, "gcs": {}, "hf": {}}
def _record(self, dest, step, uri):
with self._state_lock:
self._state.setdefault(dest, {})[str(step)] = {
"uri": uri, "ts": time.time(),
}
tmp = self.state_path.with_suffix(".json.tmp")
tmp.write_text(json.dumps(self._state, indent=2), encoding="utf-8")
os.replace(tmp, self.state_path)
# ── public trigger ─────────────────────────────────────────────────────────
def backup(self, ckpt_path, step, is_final=False):
"""Trigger backups for `ckpt_path` at `step`. Returns immediately.
Uploads `step_{N}.pt` / `model_step_{N}.pt` only on interval boundaries
(or when is_final). `last.pt` on GCS is refreshed on every trigger.
"""
if not self.enabled:
return
ckpt_path = str(ckpt_path)
keep_versioned = is_final or (step % self.interval == 0)
if self._gcs is not None:
self._dispatch("gcs", self._do_gcs, ckpt_path, step, keep_versioned)
if self._hf_api is not None and keep_versioned:
self._dispatch("hf", self._do_hf, ckpt_path, step, keep_versioned)
def _dispatch(self, dest, fn, ckpt_path, step, keep_versioned):
lock = self._slots[dest]
if not lock.acquire(blocking=False):
self.log(f"[backup][{dest}] busy, skipping step {step}")
return
def runner():
try:
fn(ckpt_path, step, keep_versioned)
except Exception as e:
self.log(f"[backup][{dest}][warn] step {step} failed: {e}")
finally:
lock.release()
threading.Thread(target=runner, name=f"backup-{dest}", daemon=True).start()
# ── workers ────────────────────────────────────────────────────────────────
def _do_gcs(self, ckpt_path, step, keep_versioned):
base = f"{self._gcs_prefix + '/' if self._gcs_prefix else ''}phase{self.phase}"
last_blob = f"{base}/last.pt"
self._gcs_bucket.blob(last_blob).upload_from_filename(
ckpt_path, timeout=1800)
if keep_versioned:
ver_blob = f"{base}/step_{step:07d}.pt"
self._gcs_bucket.blob(ver_blob).upload_from_filename(
ckpt_path, timeout=1800)
uri = f"gs://{self._gcs_bucket.name}/{ver_blob}"
else:
uri = f"gs://{self._gcs_bucket.name}/{last_blob}"
self._record("gcs", step, uri)
self.log(f"[backup][gcs] step {step} β†’ {uri}")
def _do_hf(self, ckpt_path, step, keep_versioned):
slim_path = self.out_dir / f".hf_upload_step_{step:07d}.pt"
try:
_save_weights_only(ckpt_path, slim_path)
dst = f"phase{self.phase}/model_step_{step:07d}.pt"
self._hf_api.upload_file(
path_or_fileobj=str(slim_path),
path_in_repo=dst,
repo_id=self._hf_repo,
repo_type="model",
commit_message=f"backup phase{self.phase} step {step}",
)
uri = f"hf://{self._hf_repo}/{dst}"
self._record("hf", step, uri)
self.log(f"[backup][hf] step {step} β†’ {uri}")
finally:
try:
slim_path.unlink(missing_ok=True)
except OSError:
pass
def wait(self, timeout=None):
"""Block until in-flight uploads on every destination finish (best effort)."""
for dest, lock in self._slots.items():
acquired = lock.acquire(timeout=timeout if timeout is not None else -1)
if acquired:
lock.release()
else:
self.log(f"[backup][{dest}][warn] still uploading at shutdown")