Files
OpenJarvis/scripts/orchestrator/run_sft_fsdp.py
T
Andrew Park e6c47e9532 orchestrator: track the SFT training pipeline and fix ToolOrchestra tracing
The orchestrator SFT pipeline was living entirely in the working tree and
never got committed. One script in that lineage (clean_sft_data.py) has
already been lost with no way to recover it, so track the rest before the
same thing happens again:

  run_sft_fsdp.py      full-parameter FSDP trainer (launched via accelerate)
  sft_tokenize.py      conversation -> tokens + assistant-only masking
  render_sft_data.py   record -> chat-template rendering
  make_splits.py       train / holdout / overfit splits
  chat_orchestrator.py REPL for eyeballing a trained checkpoint's routing
  format_eval_sample.py, upload_to_braintrust.py
  scripts/train/       fsdp4.yaml, fsdp8.yaml, eval sbatch

toolorchestra/tracing.py was also untracked despite being imported by
unified.py and rollout.py, which left the branch broken for a fresh
checkout. Add it along with the rest of the ToolOrchestra package split.

Also here: mmlu_pro / supergpqa scorers, expert pricing for the OpenRouter
Qwen builds (estimates, flagged in-file), an expanded calculator/web_search
tool surface, and the reject-sampling clean gate that run_sft_fsdp reads
via --require-clean.

GRPO is left at its upstream state -- the RL stage hasn't been run yet, so
it stays out of this change. Paths in the sbatch are env-driven rather than
hardcoded to one cluster home.
2026-07-11 12:23:47 -07:00

339 lines
14 KiB
Python

#!/usr/bin/env python
"""Full-parameter SFT for the orchestrator policy via FSDP (multi-GPU).
Launch with accelerate so the model + optimizer shard across GPUs (an 8-9B full
fine-tune won't fit one L40S otherwise):
accelerate launch --config_file <fsdp.yaml> \
scripts/orchestrator/run_sft_fsdp.py \
--data data/runs/<...>/data.jsonl [--data <more> ...] \
--variant correct --model Qwen/Qwen3.5-9B \
--out checkpoints/sft_correct --epochs 3 --batch-size 8 --grad-accum 8 \
--wandb-project orchestrator-sft
--variant selects which trajectories to train on:
all -> every record
correct -> only records the verifier marked correct
correct_routed -> only correct records that delegated to a model expert
By default (--require-clean) records explicitly marked clean=False by the clean
gate (bloated / garbled / unrouted) are also dropped; rows missing the flag
(legacy data) are kept. Pass --no-require-clean to skip this filter.
Reuses the conversation->tokens + assistant-only masking from sft_tokenize.py.
On these no-NVLink L40S, the accelerate/FSDP NCCL env (NCCL_P2P_DISABLE=1 etc.)
must be set by the launcher.
"""
from __future__ import annotations
import argparse
import json
import math
import os
import re
import sys
import time
from pathlib import Path
# reuse the data builder from the LoRA launcher
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from sft_tokenize import build_example # noqa: E402
MODEL_EXPERTS = {"gpt_5_5", "claude_opus_4_8", "qwen3_5_9b", "qwen3_6_27b_fp8",
"qwen3_5_122b_a10b_fp8", "qwen3_5_397b_a17b_fp8"}
def record_is_correct(r: dict) -> bool:
c = r.get("correct")
if c is None:
c = r.get("metrics", {}).get("correct")
return bool(c)
def record_routed_to_expert(r: dict) -> bool:
"""True if the trajectory called a model expert (resolving anon labels)."""
amap = r.get("metrics", {}).get("anon_map", {})
names = re.findall(r'"name"\s*:\s*"([a-z0-9_]+)"', json.dumps(r.get("conversations", [])))
for n in names:
real = amap.get(n, n)
if real in MODEL_EXPERTS:
return True
return False
def record_clean_flag(r: dict):
"""The record's `clean` flag: True/False, or None if absent (legacy data).
None means the clean gate never ran on this row, so we can't judge it -> we
keep it (legacy behavior) rather than silently dropping older datasets.
"""
c = r.get("clean")
if c is None:
c = r.get("metrics", {}).get("clean")
return c
def select(records, variant: str, require_clean: bool = True):
"""Filter by --variant, then (default) drop records explicitly marked unclean.
require_clean drops rows whose `clean` flag is False. Rows missing the flag
(legacy) are kept regardless, matching pre-clean-gate behavior.
"""
if variant == "all":
base = list(records)
elif variant == "correct":
base = [r for r in records if record_is_correct(r)]
elif variant == "correct_routed":
base = [r for r in records if record_is_correct(r) and record_routed_to_expert(r)]
else:
raise ValueError(f"unknown variant {variant}")
if not require_clean:
return base
return [r for r in base if record_clean_flag(r) is not False]
def main() -> int:
p = argparse.ArgumentParser()
p.add_argument("--data", action="append", required=True, help="JSONL(s) (repeatable)")
p.add_argument("--val-data", default=None,
help="Held-out JSONL for val-loss (excluded from --data). Eval'd per epoch.")
p.add_argument("--variant", choices=["all", "correct", "correct_routed"], default="correct")
p.add_argument("--require-clean", action=argparse.BooleanOptionalAction, default=True,
help="Drop records whose `clean` flag is False (bloated / garbled / "
"unrouted). Rows missing the flag (legacy data) are kept. "
"Use --no-require-clean to disable.")
p.add_argument("--model", default="Qwen/Qwen3.5-9B")
p.add_argument("--out", required=True)
p.add_argument("--epochs", type=float, default=3.0)
p.add_argument("--batch-size", type=int, default=8)
p.add_argument("--grad-accum", type=int, default=8)
p.add_argument("--lr", type=float, default=1e-5)
p.add_argument("--max-seq", type=int, default=8192)
p.add_argument("--supervise-last-only", action="store_true",
help="Legacy: supervise ONLY the final assistant turn. Default "
"(off) supervises every assistant turn incl. routing.")
p.add_argument("--warmup-ratio", type=float, default=0.03)
p.add_argument("--seed", type=int, default=42)
p.add_argument("--wandb-project", default="orchestrator-sft")
p.add_argument("--wandb-name", default="")
args = p.parse_args()
import random
from datetime import timedelta
import torch
from torch.utils.data import DataLoader
from accelerate import Accelerator
from accelerate.utils import set_seed, InitProcessGroupKwargs
from transformers import AutoModelForCausalLM, AutoTokenizer
set_seed(args.seed)
# 30-min collective timeout: a slow first-step shard gather / graph build on a
# contended node was tripping the default 10-min NCCL watchdog (job 3892 died
# at step 1). This only delays a *real* hang's crash; it fixes spurious ones.
pg_timeout_min = int(os.environ.get("SFT_PG_TIMEOUT_MIN", "30"))
accelerator = Accelerator(
gradient_accumulation_steps=args.grad_accum,
kwargs_handlers=[InitProcessGroupKwargs(timeout=timedelta(minutes=pg_timeout_min))])
is_main = accelerator.is_main_process
def log(m):
if is_main:
print(f"[fsdp-sft] {time.strftime('%H:%M:%S')} {m}", flush=True)
tok = AutoTokenizer.from_pretrained(args.model, trust_remote_code=True)
if tok.pad_token_id is None:
tok.pad_token = tok.eos_token
# ---- data ----
records = []
for f in args.data:
records += [json.loads(l) for l in open(f) if l.strip()]
sel = select(records, args.variant, require_clean=args.require_clean)
log(f"variant={args.variant}: {len(sel)}/{len(records)} records")
if args.require_clean:
pre_clean = select(records, args.variant, require_clean=False)
n_unclean = len(pre_clean) - len(sel)
log(f"require_clean=True: dropped {n_unclean} unclean rows "
f"({len(pre_clean)}->{len(sel)}); use --no-require-clean to keep them")
else:
log("require_clean=False: NOT filtering on `clean` flag")
examples = []
for r in sel:
ex = build_example(tok, r.get("conversations", []), args.max_seq,
supervise_all_turns=not args.supervise_last_only)
if ex:
examples.append(ex)
log(f"built {len(examples)} training examples")
if not examples:
log("no examples; abort")
return 1
random.Random(args.seed).shuffle(examples)
def collate(batch):
maxlen = max(len(b["input_ids"]) for b in batch)
ii, lab, am = [], [], []
for b in batch:
pad = maxlen - len(b["input_ids"])
ii.append(b["input_ids"] + [tok.pad_token_id] * pad)
lab.append(b["labels"] + [-100] * pad)
am.append([1] * len(b["input_ids"]) + [0] * pad)
return (torch.tensor(ii), torch.tensor(lab), torch.tensor(am))
dl = DataLoader(examples, batch_size=args.batch_size, shuffle=True, collate_fn=collate)
# ---- held-out val set (leak-free; excluded from --data upstream) ----
val_dl = None
if args.val_data:
vrecs = [json.loads(l) for l in open(args.val_data) if l.strip()]
vex = []
for r in vrecs:
ex = build_example(tok, r.get("conversations", []), args.max_seq,
supervise_all_turns=not args.supervise_last_only)
if ex:
vex.append(ex)
log(f"val: {len(vex)} examples from {args.val_data}")
if vex:
val_dl = DataLoader(vex, batch_size=args.batch_size, shuffle=False, collate_fn=collate)
model = AutoModelForCausalLM.from_pretrained(
args.model, dtype=torch.bfloat16, trust_remote_code=True,
attn_implementation="sdpa")
if os.environ.get("SFT_NO_GRAD_CKPT") == "1":
log("gradient checkpointing DISABLED (SFT_NO_GRAD_CKPT=1)")
else:
model.gradient_checkpointing_enable(gradient_checkpointing_kwargs={"use_reentrant": False})
model.config.use_cache = False
opt = torch.optim.AdamW(model.parameters(), lr=args.lr, weight_decay=0.0)
model, opt, dl = accelerator.prepare(model, opt, dl)
if val_dl is not None:
val_dl = accelerator.prepare(val_dl)
@torch.no_grad()
def evaluate():
# Corpus-level mean token loss over the holdout. FSDP needs every rank to
# run the forward collectively, so each rank evals its shard and we reduce
# token-weighted sums (not a mean-of-batch-means, which would mis-weight).
model.eval()
tot_loss = torch.zeros((), device=accelerator.device)
tot_tok = torch.zeros((), device=accelerator.device)
for ii, lab, am in val_dl:
out = model(input_ids=ii, attention_mask=am, labels=lab)
ntok = (lab != -100).sum()
tot_loss += out.loss.detach() * ntok
tot_tok += ntok
tot_loss = accelerator.reduce(tot_loss, reduction="sum")
tot_tok = accelerator.reduce(tot_tok, reduction="sum")
model.train()
return (tot_loss / tot_tok.clamp(min=1)).item()
steps_per_epoch = math.ceil(len(dl) / args.grad_accum)
total_steps = max(1, int(steps_per_epoch * args.epochs))
warmup = max(1, int(total_steps * args.warmup_ratio))
def lr_at(s):
if s < warmup:
return s / warmup
return 0.5 * (1 + math.cos(math.pi * (s - warmup) / max(1, total_steps - warmup)))
use_wandb = False
if is_main:
try:
import wandb
base_name = args.wandb_name or f"{Path(args.model).name}-{args.variant}-fsdp"
run_name = f"{base_name}-{time.strftime('%m%d-%H%M', time.gmtime())}"
wandb.init(project=args.wandb_project,
name=run_name,
config=vars(args) | {"n_examples": len(examples), "total_steps": total_steps})
use_wandb = True
except Exception as e:
log(f"wandb off ({e})")
log(f"total_steps={total_steps} steps/epoch={steps_per_epoch} warmup={warmup}")
def _save_ckpt(out_path):
# Full-model checkpoint (gathers FSDP shards to rank0). Called per-epoch
# so a long run leaves usable partial models + lets us eval intermediate
# checkpoints (e.g. epoch1/epoch2) for the data-scaling sweep.
accelerator.wait_for_everyone()
unwrapped = accelerator.unwrap_model(model)
p = Path(out_path)
if is_main:
p.mkdir(parents=True, exist_ok=True)
tok.save_pretrained(str(p))
unwrapped.save_pretrained(
str(p), is_main_process=is_main,
save_function=accelerator.save,
state_dict=accelerator.get_state_dict(model))
if is_main:
log(f"checkpoint saved -> {p}")
model.train()
gstep = 0
t0 = time.time()
if val_dl is not None:
vloss = evaluate()
log(f"val/loss (baseline, step 0) = {vloss:.4f}")
if use_wandb:
wandb.log({"val/loss": vloss}, step=0)
for epoch in range(math.ceil(args.epochs)):
for ii, lab, am in dl:
with accelerator.accumulate(model):
out = model(input_ids=ii, attention_mask=am, labels=lab)
accelerator.backward(out.loss)
if accelerator.sync_gradients:
lr = args.lr * lr_at(gstep)
for g in opt.param_groups:
g["lr"] = lr
accelerator.clip_grad_norm_(model.parameters(), 1.0)
opt.step()
opt.zero_grad()
if accelerator.sync_gradients:
gstep += 1
# accelerator.gather() is a COLLECTIVE — EVERY rank must call it.
# Guarding it behind `is_main` made only rank0 run the [1]-float
# all-gather while ranks 1-3 marched on to the next step's FSDP
# param all-gather, desyncing the process group and hanging the job
# at the step1->step2 boundary (NCCL collective-shape mismatch).
loss = accelerator.gather(out.loss.detach()).mean().item()
if is_main:
log(f"step {gstep}/{total_steps} loss={loss:.4f} lr={lr:.2e}")
if use_wandb:
wandb.log({"train/loss": loss, "train/lr": lr}, step=gstep)
if gstep >= total_steps:
break
_save_ckpt(Path(args.out) / f"epoch{epoch + 1}")
if val_dl is not None:
vloss = evaluate()
log(f"val/loss (epoch {epoch + 1}) = {vloss:.4f}")
if use_wandb:
wandb.log({"val/loss": vloss, "epoch": epoch + 1}, step=gstep)
if gstep >= total_steps:
break
accelerator.wait_for_everyone()
log("saving full model...")
unwrapped = accelerator.unwrap_model(model)
out_dir = Path(args.out)
if is_main:
out_dir.mkdir(parents=True, exist_ok=True)
tok.save_pretrained(str(out_dir))
unwrapped.save_pretrained(
str(out_dir), is_main_process=is_main,
save_function=accelerator.save,
state_dict=accelerator.get_state_dict(model))
if is_main:
log(f"saved -> {out_dir}")
if use_wandb:
try:
wandb.finish()
except Exception:
pass
print("FSDP_SFT_DONE " + json.dumps({"variant": args.variant, "steps": gstep,
"wall_s": round(time.time() - t0, 1), "out": str(out_dir)}))
return 0
if __name__ == "__main__":
raise SystemExit(main())