align / code /scripts /ppo.py
viai957's picture
Add files using upload-large-folder tool
bcc8d35 verified
Raw History Blame Contribute Delete
21.2 kB
#!/usr/bin/env python
"""Process-supervised PPO entry: gates -> worker -> banner -> train -> stage manifest.
CUDA_VISIBLE_DEVICES=0,1,2 torchrun --nproc_per_node=3 scripts/ppo.py \
configs/ppo_stage.toml
# tiny end-to-end smoke (3 prompts x 4 samples x 256 tokens x 4 iterations):
CUDA_VISIBLE_DEVICES=0,1,2 torchrun --nproc_per_node=3 scripts/ppo.py \
configs/ppo_stage.toml --smoke
Ported from mistral-prm-ppo `scripts/train_ppo.py` and re-seated on the align stack the way
scripts/grpo.py is: stage config -> PPOTrainModule -> the shared Trainer. What replaced what:
peft LoRA -> align.ppo.lora (named adapters on the SAME resident base)
DeepSpeed ZeRO-3 -> FSDP2 (align.train.fsdp); ZeRO-3 + Adafactor silently did not train
HF generate rollout -> align.rollout.client over the file protocol (vLLM worker, own venv)
transformers config -> align.model.hf_config.read_config (own config.json reader)
torch DDP of LoRA -> not needed: FSDP shards the frozen base, adapters ride the same wrap
Topology: N FSDP train ranks + one dedicated engine GPU (default 3, NMO_ROLLOUT_GPU). Rank 0
owns all protocol I/O. Checkpoint saves ARE the weight sync (the engine reloads the latest
checkpoint), and a LoRA run saves MERGED weights (PPOTrainModule.full_model_state_dict), so the
engine loads an ordinary HF-layout dir. Save cadence is DERIVED from the rollout shape, never
typed: a save lands exactly at the end of every sync_every_iterations-th iteration.
Two arms share this one script and differ only in the config's `arm` and reward table:
arm = "prm" process supervision (PRM adapter on the shared backbone scores each step)
arm = "orm" the MANDATORY control: identical pipeline, process term off. Without it nothing
is attributable to process supervision (Math-Shepherd's PRM-attributable delta
over its own ORM-PPO control is +1.7pp MATH, ~2/3 of the headline is plain RL).
Launch gates, ordered by cost: config validation (struct mode, eta, lam_actor, cadence, canary)
-> ceiling receipt (0/100-perfect-policy trap) -> topology check -> eos/boundary derivation
(derived, never hardcoded) -> module construction refusals (reference, scorer) -> horizon
consistency (scheduler vs policy steps).
"""
from __future__ import annotations
import argparse
import json
import logging
import os
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from align.utils.guard import apply_resource_guards # noqa: E402
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
logger = logging.getLogger("ppo")
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("config")
ap.add_argument("--smoke", action="store_true", help="tiny shapes, 4 iterations")
ap.add_argument("--band-file", default=None, help="override the configured band file")
ap.add_argument("--strict-host-check", default=True, action=argparse.BooleanOptionalAction)
ap.add_argument("--heavy-lock", default=True, action=argparse.BooleanOptionalAction)
args = ap.parse_args()
apply_resource_guards(
cpu_threads=8,
heavy=args.heavy_lock,
label="ppo-stage",
strict_host_check=args.strict_host_check,
)
from align.utils.dist import init_distributed
from align.ppo.stage_config import load_stage_config as _early_stage_config
# Run dir from the config (parsed before the process group: a bad TOML fails
# in milliseconds, and the NCCL flight-recorder dump lands under it).
init_distributed(run_dir=_early_stage_config(args.config, smoke=args.smoke).run_dir)
from align.train.stage_gates import assert_distinct_devices
assert_distinct_devices()
import torch
import torch.distributed as dist
from align.data.schema import build_prompt, extract_answer, register_answer_suffixes
from align.data.sources import DataSources, gsm8k_gold_answer, read_gsm8k_test
from align.data.tokenization import load_boundary_spec
from align.eval.verifier import OutcomeVerifier, VerifierConfig
from align.model import checkpoint as ckpt
from align.model.mistral import NmoMistralForCausalLM
from align.ppo.lora import inject_lora, mark_only_adapter_trainable
from align.ppo.stage_config import load_stage_config
from align.ppo.trainer import (
AdapterProbeView,
PPOBatchSource,
PPOTrainModule,
sync_params_from_rank0,
)
from align.ppo.value_head import PRMConfig, ProcessRewardScorer, ValueHead, load_prm_state
from align.rl.canary import TaskAccuracyCanary
from align.rollout.client import RolloutClient, spawn_worker, terminate_worker
from align.rollout.protocol import RolloutRequest
from align.train.dpo_stage import resolve_sft_checkpoint
from align.train.trainer import Trainer, WarmupCosineSchedule
from align.utils.guard import assert_paths_off_root
rank = dist.get_rank() if dist.is_initialized() else 0
world = dist.get_world_size() if dist.is_initialized() else 1
cfg = load_stage_config(args.config, smoke=args.smoke)
if args.band_file:
cfg.data.band_file = args.band_file
assert_paths_off_root([cfg.run_dir])
ceiling = cfg.validate_ceiling_receipt()
cfg.validate_world(world)
register_answer_suffixes("</s>") # BEFORE any extraction (golden.py's incident)
# -- data: the band's prompt rows ---------------------------------------
prompt_rows = []
with open(cfg.data.band_file) as f:
for line in f:
if line.strip():
prompt_rows.append(json.loads(line))
logger.info("band file %s: %d prompts", cfg.data.band_file, len(prompt_rows))
# -- derived ids: the boundary and EOS are READ, never hardcoded ----------
spec = load_boundary_spec(cfg.boundary_spec_path)
sft_checkpoint, ckpt_source = resolve_sft_checkpoint(cfg.sft_checkpoint, None)
logger.info("substrate: %s (%s)", sft_checkpoint, ckpt_source)
from align.model.hf_config import read_config
from align.tokenizer import load_tokenizer
model_cfg = read_config(sft_checkpoint)
tokenizer = load_tokenizer(sft_checkpoint)
pad_id = tokenizer.pad_token_id if tokenizer.pad_token_id is not None else 0
with open(os.path.join(sft_checkpoint, "config.json")) as f:
snapshot_eos = json.load(f).get("eos_token_id")
if not (cfg.ppo.eos_token_id == snapshot_eos == tokenizer.eos_token_id):
raise RuntimeError(
f"EOS ids disagree: ppo.eos_token_id={cfg.ppo.eos_token_id}, substrate "
f"config.json={snapshot_eos}, tokenizer={tokenizer.eos_token_id}. Truncation would be "
"mis-flagged: GAE would bootstrap terminal rows (or treat cut-off rows as terminal). "
"Derived-vs-hardcoded incident class."
)
# -- policy: LoRA on the shared base ---------------------------------------
model = NmoMistralForCausalLM.from_state_dict(
model_cfg,
ckpt.hf_to_nmo(ckpt.load_hf_safetensors(sft_checkpoint)),
attn_implementation=cfg.train.attn_implementation,
device="cuda",
).to("cuda", dtype=torch.bfloat16)
# Identical adapter init on every rank: FSDP2 shards what each rank already holds and does
# NOT broadcast, so a per-rank random A would be a different adapter per shard.
gen = torch.Generator().manual_seed(cfg.train.seed)
inject_lora(model, cfg.lora, adapter="policy", generator=gen)
scorer = None
if cfg.arm == "prm":
from align.ppo.lora import LoRAConfig
inject_lora(
model,
LoRAConfig(
r=cfg.prm.lora_r, alpha=cfg.prm.lora_alpha, target_modules=cfg.lora.target_modules
),
adapter="prm",
generator=gen,
)
scorer = ProcessRewardScorer(
model,
model_cfg.hidden_size,
PRMConfig(
head_type=cfg.prm.head_type,
adapter="prm",
label_token_ids=cfg.prm.label_token_ids,
),
)
load_prm_state(scorer, cfg.prm.checkpoint)
scorer.to("cuda")
for p in scorer.parameters():
p.requires_grad_(False)
n_trainable = mark_only_adapter_trainable(model, "policy") # AFTER both adapters exist
logger.info("policy adapter: %d trainable parameters", n_trainable)
from align.train.fsdp import apply_activation_checkpointing, apply_fsdp
model = apply_activation_checkpointing(model)
if world > 1:
model = apply_fsdp(model)
logger.info("post-shard cuda allocated: %.2f GiB", torch.cuda.memory_allocated() / 2**30)
value_head = ValueHead(
model_cfg.hidden_size, detach_hidden=not cfg.train.value_shares_backbone
).to("cuda")
sync_params_from_rank0(value_head)
# -- rollout worker (rank 0 owns it) ------------------------------------------
spool = os.path.join(cfg.run_dir, "spool")
worker_proc = None
if rank == 0:
worker_proc = spawn_worker(
spool,
gpu=int(os.environ.get("NMO_ROLLOUT_GPU", "3")),
log_path=os.path.join(cfg.run_dir, "worker.log"),
)
logger.info("rollout worker spawned (pid %d)", worker_proc.pid)
client = RolloutClient(spool, timeout_s=cfg.rollout_timeout_s)
# -- module + trainer ----------------------------------------------------------
from align.train.offload_adamw import make_optimizer
source = PPOBatchSource(
prompt_rows,
prompts_per_iteration=cfg.rollout.prompts_per_iteration,
n_samples_per_prompt=cfg.rollout.n_samples_per_prompt,
max_response_len=cfg.rollout.max_response_len,
temperature=cfg.rollout.temperature,
iterations=cfg.train.iterations,
ppo_cfg=cfg.ppo,
seed=cfg.train.seed,
prompt_builder=build_prompt,
world_size=world,
)
run_cfg = _run_config(cfg, model_cfg, world, source.micros_per_iteration)
policy_params = [p for p in model.parameters() if p.requires_grad]
optimizer = make_optimizer(run_cfg, policy_params)
value_optimizer = torch.optim.AdamW(
value_head.parameters(), lr=cfg.train.lr * cfg.train.value_lr_mult, weight_decay=0.0
)
def _sum_across_ranks(t):
if dist.is_initialized() and world > 1:
t = t.clone()
dist.all_reduce(t, op=dist.ReduceOp.SUM)
return t
def _mean_across_ranks(g):
if dist.is_initialized() and world > 1:
g = g.clone()
dist.all_reduce(g, op=dist.ReduceOp.AVG)
return g
score_fn = _make_score_fn(scorer, "cuda") if scorer is not None else None
module = PPOTrainModule(
model,
value_head,
optimizer,
value_optimizer,
cfg.ppo,
score_steps_fn=score_fn,
reduce_fn=_sum_across_ranks if world > 1 else None,
value_grad_reduce=_mean_across_ranks if world > 1 else None,
)
# HORIZON: warmup iterations move only the critic, so the schedule spans POLICY steps.
scheduler = WarmupCosineSchedule(
base_lr=cfg.train.lr,
total_steps=source.policy_optimizer_steps,
warmup_frac=cfg.train.warmup_frac,
)
trainer = Trainer(
run_cfg,
AdapterProbeView(model, "policy"),
source,
module._ppo_loss_adapter,
run_dir=cfg.run_dir,
stage="ppo",
train_module=module,
scheduler=scheduler,
)
# The Trainer logs scheduler.lr() but (in this tree) does not write it into the optimizer; the
# module applies it at every step so the logged LR is the LR that ran.
module.lr_schedule = lambda: trainer.scheduler.lr()
if trainer.scheduler.total_steps != source.policy_optimizer_steps:
raise RuntimeError(
f"horizon mismatch: scheduler={trainer.scheduler.total_steps} vs policy optimizer "
f"steps={source.policy_optimizer_steps}"
)
# -- canary (R4, rank 0) -----------------------------------------------------------
canary = None
if rank == 0:
sources = DataSources.from_local_hub()
gsm_rows = read_gsm8k_test(sources)[: cfg.canary.n_problems]
canary_problems = [
{"prompt": build_prompt(r["question"]), "gold": gsm8k_gold_answer(r)} for r in gsm_rows
]
def canary_generate(prompts: list[str]) -> list[str]:
req = RolloutRequest(
request_id=f"canary-{trainer.optimizer_step}-{os.getpid()}",
weights_dir=_latest_weights(trainer, sft_checkpoint),
prompts=prompts,
n_samples_per_prompt=1,
max_tokens=cfg.rollout.max_response_len,
temperature=0.0,
)
client.submit(req)
result = client.collect(req.request_id)
return [texts[0] for texts in result.texts]
canary = TaskAccuracyCanary(
problems=canary_problems,
generate_fn=canary_generate,
verifier=OutcomeVerifier(VerifierConfig(workers=4)),
extract_fn=extract_answer,
every=cfg.canary.every,
)
source.bind(
client=client,
verifier=OutcomeVerifier(VerifierConfig(workers=4)) if rank == 0 else None,
module=module,
weights_dir_fn=lambda: _latest_weights(trainer, sft_checkpoint),
newline_id=spec.newline_id,
pad_token_id=pad_id,
rank=rank,
world_size=world,
trainer=trainer,
canary=canary,
canary_log_path=os.path.join(cfg.run_dir, "canary.jsonl"),
)
trainer.loss_adapter = source.lazy_adapter(module)
banner = {
"stage": f"ppo_{cfg.arm}" + ("_smoke" if args.smoke else ""),
"arm": cfg.arm,
"substrate": sft_checkpoint,
"band_file": cfg.data.band_file,
"band_prompts": len(prompt_rows),
"shape": {
"prompts_per_iteration": cfg.rollout.prompts_per_iteration,
"n_samples_per_prompt": cfg.rollout.n_samples_per_prompt,
"max_response_len": cfg.rollout.max_response_len,
"iterations": cfg.train.iterations,
"ppo_epochs": cfg.ppo.ppo_epochs,
"mini_batch_rows": cfg.ppo.mini_batch_rows,
"micro_batch_rows": cfg.ppo.micro_batch_rows,
"grad_accum": cfg.ppo.grad_accum,
"micros_per_iteration": source.micros_per_iteration,
"policy_optimizer_steps": source.policy_optimizer_steps,
"value_warmup_iterations": cfg.ppo.value_warmup_iterations,
},
"reward": {
"transform": cfg.ppo.reward.transform,
"eta": cfg.ppo.reward.eta,
"w_prm": cfg.ppo.reward.w_prm,
"w_outcome": cfg.ppo.reward.w_outcome,
"kl_coef(reward)": cfg.ppo.reward.kl_coef,
"kl_loss_coef": cfg.ppo.kl_loss_coef,
"lam_actor": cfg.ppo.reward.lam_actor,
"lam_critic": cfg.ppo.reward.lam_critic,
},
"lora": {"r": cfg.lora.r, "alpha": cfg.lora.alpha, "trainable": n_trainable},
"ceiling": {k: v["verify_correct_rate"] for k, v in ceiling["sources"].items()},
"boundary": {"newline_id": spec.newline_id, "tokenizer_hash": spec.tokenizer_hash},
"topology": {"train_ranks": world, "rollout_gpu": os.environ.get("NMO_ROLLOUT_GPU", "3")},
"rectifications": (
f"R3 clip={cfg.ppo.max_grad_norm:.1f}, R4 canary every={cfg.canary.every}, "
"R5 pin-on-alarm, explicit minibatch denominators, ratio tripwire armed+self-tested"
),
}
logger.info("BANNER %s", json.dumps(banner, indent=1, default=str))
# -- train ------------------------------------------------------------------------------
try:
# RESUME. Without this a relaunch silently restarts from the SFT substrate with a fresh
# optimizer while the run dir holds the real checkpoints. Resume BEFORE the first
# rollout: the rollout engine's weights come from `_latest_weights`, which reads the
# pointer restored here.
resumed = _resume(trainer)
if resumed:
logger.info("RESUMED PPO run from %s", resumed)
summary = trainer.train()
checkpoint = trainer.save_checkpoint()
finally:
if worker_proc is not None:
terminate_worker(worker_proc)
if trainer.stop_exit_code:
# A coordinated stop (SIGTERM / train.max_wall_seconds) saved at an
# optimizer-step boundary; the stage is NOT finished. Exit with the
# documented status instead of evaluating / reporting a half-trained arm.
raise SystemExit(trainer.stop_exit_code)
result = {
"stage": banner["stage"],
"summary": summary,
"checkpoint": checkpoint,
"banner": banner,
"canary_history": canary.history if canary else [],
"alarms": source.alarms,
"ratio_tripwire": module.ratio_tripwire.as_log_dict(),
"ratio_tripwire_alarms": module.ratio_tripwire.alarms,
}
if rank == 0:
path = os.path.join(cfg.run_dir, "stage_result.json")
with open(path + ".partial", "w") as f:
json.dump(result, f, indent=1, default=str)
os.replace(path + ".partial", path)
logger.info("stage result: %s", path)
return 0
def _resume(trainer) -> str | None:
"""Resume from the newest checkpoint, if any. Returns its path (None = fresh run).
THE STALE-SUBSTRATE DEFECT (closed in `Trainer.resume_from`, pinned here). `checkpoints_written`
is a per-process list that `_latest_weights` keys off; it used to be empty after a resume, so
the first post-resume rollout was generated by the SFT SUBSTRATE -- the policy before every
update -- while the trainer computed ratios against the resumed policy. The Trainer now
registers the resumed checkpoint itself (generic: GRPO has the same `_latest_weights` shape and
keep-N pruning needed it too), so this wrapper no longer patches the list from the outside."""
return trainer.resume_from_latest()
def _latest_weights(trainer, sft_checkpoint: str) -> str:
"""The engine's weights source: latest saved (merged, HF-layout) checkpoint, else the
substrate. During value warmup the policy has not moved, so the substrate IS the policy."""
if trainer.checkpoints_written:
return os.path.join(trainer.ckpt_root, f"micro_{trainer.checkpoints_written[-1]:08d}")
return sft_checkpoint
def _make_score_fn(scorer, device: str, chunk_rows: int = 8):
"""PRM scores for a `PPORollout`: P(step correct) `[B, S]`, chunked so the scoring forward
stays inside the activation budget. The rollout stores delimiter ACTIONS in the label frame;
the PRM reads the hidden state AT the delimiter token, one input position later."""
import torch
@torch.no_grad()
def score(rollout):
B = rollout.n_rows
out = torch.zeros(rollout.step_positions.shape, dtype=torch.float32)
for lo in range(0, B, chunk_rows):
sl = slice(lo, min(B, lo + chunk_rows))
T = int(rollout.attention_mask[sl].sum(-1).max())
p = scorer.score_steps(
rollout.input_ids[sl, :T].to(device),
rollout.attention_mask[sl, :T].to(device),
(rollout.step_positions[sl] + 1).to(device),
rollout.step_mask[sl].to(device),
)
out[sl] = p.float().cpu()
return out
return score
def _run_config(cfg, model_cfg, world_size: int, micros_per_iteration: int):
"""The Trainer's RunConfig, built FROM the stage config. Pure and import-light so the
config-translation test calls it directly and pins every crossing field (nmo silent-failure
#6: a field that fails to cross trains at the trainer's default while the banner shows the
pre-registered number)."""
from align.config import RunConfig
run = RunConfig()
run.model = model_cfg
run.optimizer.name = cfg.train.optimizer_name
run.optimizer.lr = cfg.train.lr
run.optimizer.grad_accum = cfg.ppo.grad_accum
run.train.world_size = world_size
run.train.epochs = (
1 # the horizon is iterations x epochs; the source enumerates its own handles
)
run.train.micro_batch_size = cfg.ppo.micro_batch_rows
run.train.save_every_micro_steps = micros_per_iteration * cfg.train.sync_every_iterations
run.train.keep_checkpoints = cfg.train.keep_checkpoints
run.train.warmup_frac = cfg.train.warmup_frac
run.train.full_ft = False # LoRA: the offload-economics accum>=4 rule is a full-FT rule
run.train.seed = cfg.train.seed
run.train.max_wall_seconds = cfg.train.max_wall_seconds
run.train.max_consecutive_skipped_steps = cfg.train.max_consecutive_skipped_steps
run.train.max_skipped_fraction = cfg.train.max_skipped_fraction
run.tripwires.armed = tuple(cfg.armed_tripwires)
run.run_name = f"ppo_{cfg.arm}"
return run
if __name__ == "__main__":
from align.exit_codes import run_with_exit_codes
raise SystemExit(run_with_exit_codes(main))