File size: 11,388 Bytes
bcc8d35 | 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 | #!/usr/bin/env python
"""SFT stage entry (section-11): gates -> wrap -> train -> eval -> quality gate.
torchrun --nproc_per_node=4 scripts/sft.py configs/sft.toml --optimizer.lr=1e-5
Thin by design. Everything this drives is already built and tested elsewhere;
what lives here is ORDER, and the order is the product:
golden_regression -> smoke_7b -> probe_20step -> rss_preflight
-> train -> eval -> quality_gate
IMPORT ORDER IS LOAD-BEARING. `apply_resource_guards()` sets
PYTORCH_CUDA_ALLOC_CONF, the /apps cache paths and the thread caps, and torch
reads all of them AT IMPORT. So this module imports only stdlib, the guard,
the config, and `stage_gates` (which is asserted torch-free); torch and every
align module that pulls it in are imported INSIDE main(), after the guards run.
A test enforces this by AST rather than by grep -- a literal search passes on
a file that mentions the guard in a comment and imports torch on the next line.
Resume semantics: rerunning after a kill picks up the gate ledger, skips gates
that already passed, and resumes the Trainer from the latest micro-step
checkpoint. Partial checkpoints are deleted at startup.
"""
from __future__ import annotations
import logging
import os
import sys
from pathlib import Path
# The package is not installed in the venv. Without this a bare `torchrun scripts/sft.py ...` (the launch
# line in docs/P2.md section 11 and in the config header) died with ModuleNotFoundError on all four ranks
# unless the operator remembered PYTHONPATH -- found by the P2 integration smoke loop.
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from align.config import load_config
from align.train.stage_gates import (
GateLedger,
assemble_sft_banner,
assert_build_is_current,
assert_probe_resolved,
eval_source_hash,
evaluate_and_gate,
parse_args,
record_golden_from_report,
require_golden_receipt,
run_entry_gates,
)
from align.utils.guard import apply_resource_guards
logger = logging.getLogger(__name__)
def build_model(cfg):
"""Imported here, not at module scope: this pulls torch in.
ARCH DISPATCH. `model.arch` selects the family; an unrecognised value RAISES (it must not
fall through to the Mistral converters, which would either fail late on a Qwen3 snapshot's
keys or load a different family into the wrong module).
"""
if cfg.model.arch == "mistral":
if cfg.lora.enabled:
raise ValueError(
"lora.enabled with model.arch='mistral': the dense-Mistral SFT path is full-FT only "
"(LoRA Mistral runs live in scripts/ppo.py). Refusing rather than training full-FT "
"while the config says LoRA."
)
from align.train.stage_smoke import build_stage_model
return build_stage_model(cfg)
if cfg.model.arch == "qwen3":
from align.train.qwen3_stage import build_qwen3_stage_model
return build_qwen3_stage_model(cfg)
raise ValueError(f"model.arch={cfg.model.arch!r} is not supported; known: ('mistral', 'qwen3')")
def run_gates(cfg, ledger, banner_only: bool = False):
"""The M5 entry sequence. Refuses to proceed on a stale data build or an
unvalidated eval stack before it touches a GPU.
Non-Mistral arches have their own entry (see `qwen3_entry_banner`: the golden/MATH/GSM8K
machinery certifies a Mistral checkpoint and does not apply)."""
if cfg.model.arch == "qwen3":
from align.train.qwen3_stage import qwen3_entry_banner
return qwen3_entry_banner(cfg, ledger)
if cfg.model.arch != "mistral":
raise ValueError(f"model.arch={cfg.model.arch!r} is not supported; known: ('mistral', 'qwen3')")
assert_build_is_current(cfg.data.manifest_path, cfg)
# Bridge the golden RUN into the ledger by re-deciding from its persisted
# report, immediately before the gate that reads the ledger. Without this
# a PASSING golden run still leaves golden_regression PENDING.
record_golden_from_report(ledger, cfg=cfg)
require_golden_receipt(ledger, cfg)
banner = run_entry_gates(cfg, ledger)
assert_probe_resolved(banner)
return banner
def train(cfg, ledger, model, banner):
"""One SFT epoch through the section-07 Trainer."""
from align.data.loader import collate_sft
from align.train.sft_loss import make_sft_loss_adapter
from align.train.stage_smoke import build_training_sampler
from align.train.trainer import Trainer
# The SAME sampler the entry gates measured the horizon from. Building a
# second one here is how a stage ends up timing, projecting and training
# on three different batch streams.
sampler, dataset = build_training_sampler(cfg)
def adapter(m, batch):
rows = [dataset[i] for i in batch]
collated = collate_sft(rows, pad_token_id=cfg.data.pad_token_id).to("cuda")
return make_sft_loss_adapter(require_fused=True)(m, (collated.input_ids, collated.labels))
run_dir = os.path.join(cfg.paths.checkpoint_dir, "sft")
extras = {}
if cfg.model.arch == "qwen3":
from align.train.qwen3_stage import qwen3_trainer_extras
extras = qwen3_trainer_extras(cfg, model, adapter, run_dir)
trainer = Trainer(
cfg=cfg,
# LoRA: the trunk-movement probe must watch the adapter's B matrices (the frozen base can
# never move and A provably does not on step one), so it gets a view, not the raw model.
model=extras.pop("probe_model", model),
sampler=sampler,
loss_adapter=adapter,
run_dir=run_dir,
stage="sft",
**extras,
)
trainer.resume_from_latest()
summary = trainer.train()
if cfg.model.arch == "mistral":
record_train_gate(ledger, trainer, summary)
else:
# The ledger's fixed sequence starts at the Mistral golden regression; a non-Mistral
# stage has not passed those gates, so recording "train" would raise (or be a lie).
logger.info("train finished (arch=%s): optimizer_step=%s", cfg.model.arch, summary.get("optimizer_step"))
return trainer
def record_train_gate(ledger, trainer, summary) -> dict:
"""Record the 'train' gate for what `trainer.train()` ACTUALLY did.
THE STOPPED-RECORDED-AS-PASSED DEFECT (H2 review, 2026-10-13). `train()`
returns normally after a COORDINATED STOP (SIGTERM, or train.max_wall_seconds)
-- it checkpoints and returns -- and the gate used to be recorded
`passed=True` unconditionally. `main` then (correctly) skipped the eval and
exited 128+n / 82, but the ledger on disk now certified a half-trained
checkpoint as a finished 'train' stage: anything that reads the ledger
instead of the exit status (a resumed `evaluate_and_gate`, an operator
reading `stage_gates.json`) was told the stage had trained.
A stopped run is neither finished nor failed: it is RESUMABLE. The ledger
has no third state, so it is recorded `passed=False` with a `STOPPED
(resumable)` detail; a rerun resumes from the checkpoint and records the
real verdict at the end of the real run.
"""
step = summary.get("optimizer_step")
if getattr(trainer, "stop_exit_code", 0):
return ledger.record(
"train",
passed=False,
detail=f"STOPPED (resumable): {trainer.stop_reason} stop at optimizer_step={step}; "
"checkpointed, not finished",
)
return ledger.record("train", passed=True, detail=str(step))
def main(argv=None) -> int:
args = parse_args(argv)
# FIRST: every env var torch reads at import, plus the heavy-job flock and
# the /apps path assertions. `heavy=True` also runs the host preflight;
# `rss_cfg` adds the full-FT RSS projection on top of the generic floor.
cfg = load_config(args.config, args.overrides)
apply_resource_guards(cpu_threads=8, heavy=True, label="sft-stage-11", rss_cfg=cfg)
logging.basicConfig(
level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s"
)
# BEFORE anything touches CUDA. THE DUPLICATE-GPU DEFECT: torchrun starts
# four processes but assigns no devices, so without this every rank's
# `.cuda()` lands on device 0 and NCCL dies with "Duplicate GPU detected:
# rank 2 and rank 0 both on CUDA device 1b0" -> ncclInvalidUsage. Section-01
# wrote `init_distributed` for exactly this (init_process_group +
# set_device(local_rank)); this script simply never called it.
from align.utils.dist import init_distributed
run_dir = os.path.join(cfg.paths.checkpoint_dir, "sft")
# run_dir: the NCCL flight-recorder dump lands under it (never /tmp).
init_distributed(run_dir=run_dir)
# Microseconds, and it converts the whole duplicate-GPU class into a named
# launch failure instead of an ncclInvalidUsage 400 lines into a forward.
from align.train.stage_gates import assert_distinct_devices
assert_distinct_devices()
ledger = GateLedger(run_dir, eval_source_hash=eval_source_hash())
banner = run_gates(cfg, ledger)
from align.registry import render_banner
print(render_banner(banner, title=f"align SFT stage :: {cfg.run_name}"))
model = build_model(cfg)
trainer = train(cfg, ledger, model, banner)
if trainer.stop_exit_code:
# A coordinated stop (SIGTERM / train.max_wall_seconds) saved and
# returned: the stage is NOT finished, so do not fall into its
# post-train eval or announce completion. 82 = wall budget (the
# supervisor will not relaunch it), 128+n = the signal.
print(f"{trainer.stop_reason} stop: checkpointed, exiting {trainer.stop_exit_code}", file=sys.stderr)
return trainer.stop_exit_code
# BARRIER, then RELEASE, then eval -- both are load-bearing:
# 1. train() returns minutes earlier on ranks 1..N-1 than on rank 0 (the
# sole checkpoint writer). Without this barrier they raced into the
# eval, OOM'd building a second 7B, and torchrun SIGTERM'd rank 0
# MID-SAVE -- whose cleanup handler then deleted the .partial. The
# epoch's tail died twice this way.
# 2. run_stage_eval builds its OWN model from the checkpoint; the
# training replica + optimizer must be freed first or the engine's
# KV-cache preflight refuses (1.2GB free vs 4.8GB required).
from align.utils.dist import barrier
barrier()
checkpoint = trainer.latest_checkpoint()
if cfg.model.arch != "mistral":
# The post-train MATH-500/GSM8K gate certifies the Mistral stack; a qwen3 checkpoint is
# judged by the Lean loop. Say so instead of silently skipping.
print(f"SFT stage complete (arch={cfg.model.arch}; no MATH/GSM8K gate applies). Checkpoint: {checkpoint}")
return 0
trainer.release() # model, optimizer AND the probe's references (U01-11)
del model
import gc
import torch
gc.collect()
torch.cuda.empty_cache()
decision = evaluate_and_gate(cfg, ledger, checkpoint=checkpoint)
if not decision.passed:
print(decision.halt_message, file=sys.stderr)
return 1
print(f"SFT stage complete. DPO base checkpoint: {checkpoint}")
return 0
if __name__ == "__main__":
from align.exit_codes import run_with_exit_codes
raise SystemExit(run_with_exit_codes(main))
|