File size: 18,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
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
#!/usr/bin/env python
"""Stage-b GRPO entry: gates -> worker -> banner -> train -> stage manifest.

    CUDA_VISIBLE_DEVICES=0,1,2 torchrun --nproc_per_node=3 scripts/grpo.py \
        configs/grpo_stageb.toml
    # tiny end-to-end smoke (2 prompts x G8 x 32 tokens x 10 steps):
    CUDA_VISIBLE_DEVICES=0,1,2 torchrun --nproc_per_node=3 scripts/grpo.py \
        configs/grpo_stageb.toml --smoke

Topology (v1, DESIGN v3 §B.1b): 3 FSDP train ranks + one dedicated vLLM
engine GPU (default 3, override NMO_ROLLOUT_GPU). Rank 0 owns all protocol
I/O; checkpoint saves ARE the weight sync (the worker reloads the engine
whenever a request's weights_dir changes). The loop is section-07's Trainer
verbatim -- GrpoBatchSource yields lazy handles and the loss-adapter wrapper
materialises each optimizer step's rollout at consumption time.

Launch gates, ordered by cost: config validation (denominator arithmetic,
cadence/sync alignment, R3 clipping, R4 canary) -> ceiling receipt
(0/100-perfect-policy trap) -> banding manifest -> topology check ->
A3 conformance + entropy-when-armed (module construction) -> horizon
consistency (scheduler vs configured steps).
"""

from __future__ import annotations

import argparse
import atexit
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("grpo")


#: The rollout worker runs in its OWN session (torchrun's teardown does not reach it) and used to
#: serve forever. If rank 0 is SIGKILLed it is orphaned holding ~85% of its GPU, so it now exits by
#: itself after this long with no request -- far above any step/checkpoint gap, far below "forever".
ROLLOUT_IDLE_EXIT_S = 7200.0


def _spawn_rollout_worker(spool: str, gpu: int, log_path: str):
    """Spawn the vLLM worker and tie its teardown to rank 0's exit.

    Everything between the spawn and the train try/finally (Trainer construction, the horizon
    check, canary data, resume_from_latest) can raise; atexit covers every uncaught-exception path.
    `terminate_worker` is idempotent, so the normal finally and this hook do not conflict.
    """
    from align.rollout import client as rollout_client

    proc = rollout_client.spawn_worker(spool, gpu=gpu, log_path=log_path, idle_exit_s=ROLLOUT_IDLE_EXIT_S)
    atexit.register(rollout_client.terminate_worker, proc)
    return proc


def main() -> int:
    ap = argparse.ArgumentParser()
    ap.add_argument("config")
    ap.add_argument("--smoke", action="store_true", help="tiny shapes, 10 steps")
    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="grpo-stageb",
        strict_host_check=args.strict_host_check,
    )

    from align.utils.dist import init_distributed
    from align.rl.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.eval.verifier import OutcomeVerifier, VerifierConfig
    from align.model import checkpoint as ckpt
    from align.model.mistral import NmoMistralForCausalLM
    from align.rl.batch_source import GrpoBatchSource
    from align.rl.canary import TaskAccuracyCanary
    from align.rl.grpo_module import GRPOTrainModule
    from align.rl.rewards import CompositeReward
    from align.rl.stage_config import load_stage_config
    from align.rl.tripwire import CollapseTripwire
    from align.rollout.client import make_rollout_client
    from align.rollout.protocol import RolloutRequest
    from align.train.dpo_stage import resolve_sft_checkpoint
    from align.train.trainer import Trainer
    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
        cfg.validate()
    assert_paths_off_root([cfg.run_dir])
    ceiling = cfg.validate_ceiling_receipt()
    cfg.validate_world(world)
    with open(cfg.data.banding_manifest) as f:
        banding_manifest = json.load(f)

    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))

    # -- policy + FSDP -------------------------------------------------------
    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

    tokenizer = load_tokenizer(sft_checkpoint)
    pad_id = tokenizer.pad_token_id if tokenizer.pad_token_id is not None else 0

    model_cfg = read_config(sft_checkpoint)
    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)

    from align.train.fsdp import apply_activation_checkpointing, apply_fsdp

    dist_model = apply_fsdp(apply_activation_checkpointing(model))
    logger.info("post-shard cuda allocated: %.2f GiB", torch.cuda.memory_allocated() / 2**30)

    # -- rollout backend (rank 0 owns it) ------------------------------------
    # 'spool': external vLLM worker process on NMO_ROLLOUT_GPU (own venv).
    # 'native': in-repo torch sampler on a STANDALONE engine copy living on that
    # same dedicated GPU, in rank 0's process. It must NOT be the FSDP2-sharded
    # model: decode forwards are collectives the other ranks would never join
    # (deadlock). Either way the GPU is outside the training ranks' devices.
    rollout_gpu = int(os.environ.get("NMO_ROLLOUT_GPU", "3"))
    spool = os.path.join(cfg.run_dir, "spool")
    worker_proc = None
    client = None
    if cfg.rollout.backend == "spool":
        if rank == 0:
            worker_proc = _spawn_rollout_worker(
                spool, rollout_gpu, os.path.join(cfg.run_dir, "worker.log")
            )
            logger.info("rollout worker spawned (pid %d) on GPU %s", worker_proc.pid, rollout_gpu)
        client = make_rollout_client("spool", spool=spool, timeout_s=cfg.rollout_timeout_s)
    elif rank == 0:
        client = _build_native_client(
            model_cfg, sft_checkpoint, tokenizer, rollout_gpu, world, cfg.rollout.max_response_len
        )
        logger.info("native rollout engine on cuda:%d (standalone copy of %s)", rollout_gpu, sft_checkpoint)

    # -- module + trainer -----------------------------------------------------
    from align.train.offload_adamw import make_optimizer

    run_cfg = _run_config(cfg)
    optimizer = make_optimizer(run_cfg, [p for p in dist_model.parameters() if p.requires_grad])
    collapse = CollapseTripwire()
    module = GRPOTrainModule(
        dist_model,
        optimizer,
        grad_accum=cfg.train.grad_accum,
        grpo_cfg=cfg.grpo,
        temperature=cfg.rollout.temperature,
        collapse_tripwire=collapse,
        max_grad_norm=cfg.train.max_grad_norm,
    )

    source = GrpoBatchSource(
        prompt_rows,
        prompts_per_step=cfg.rollout.prompts_per_step,
        n_samples_per_prompt=cfg.grpo.num_samples_per_prompt_rollout,
        max_response_len=cfg.rollout.max_response_len,
        temperature=cfg.rollout.temperature,
        optimizer_steps=cfg.train.optimizer_steps,
        grad_accum=cfg.train.grad_accum,
        seed=cfg.train.seed,
        prompt_builder=build_prompt,
        dead_group_retries=cfg.dead_group_retries,
    )
    source.bind_pad(pad_id)

    trainer = Trainer(
        run_cfg,
        dist_model,
        source,
        module._grpo_loss_adapter,
        run_dir=cfg.run_dir,
        stage="grpo",
        train_module=module,
    )
    # HORIZON CONSISTENCY (the Arm A zombie-LR class): the scheduler's total
    # must be exactly the configured optimizer_steps -- this loop generates
    # its own data, so any mismatch is arithmetic, not sampling.
    if trainer.scheduler.total_steps != cfg.train.optimizer_steps:
        raise RuntimeError(
            f"horizon mismatch: scheduler={trainer.scheduler.total_steps} vs "
            f"configured optimizer_steps={cfg.train.optimizer_steps}"
        )

    # -- canary (R4, rank 0) --------------------------------------------------
    canary = None
    canary_generate = 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,
        reward=CompositeReward(
            format_weight=cfg.reward.format_weight,
            overlong_max_len=cfg.rollout.max_response_len,
            overlong_cache_len=cfg.rollout.overlong_cache_len,
        ),
        verifier=OutcomeVerifier(VerifierConfig(workers=4)) if rank == 0 else None,
        old_logprob_fn=module.compute_logprobs_nograd,
        weights_dir_fn=lambda: _latest_weights(trainer, sft_checkpoint),
        rank=rank,
        world_size=world,
        device="cuda",
        canary=canary,
        collapse_tripwire=collapse,
        trainer=trainer,
        canary_log_path=os.path.join(cfg.run_dir, "canary.jsonl"),
    )
    trainer.loss_adapter = source.lazy_adapter(module)

    # -- resume ---------------------------------------------------------------
    # A relaunch continues the newest checkpoint in run_dir, if any. Before
    # this was wired, a relaunch restarted from the SFT substrate. What a
    # resume restores, and where each piece lives:
    #   weights + optimizer + schedule + stream offset   Trainer (resume.pt)
    #   CollapseTripwire windows and latch                module extra_state
    #   prompt order, cursor, dead-group stats, alarms    source state_dict
    #   rollout engine weights                            _latest_weights: the
    #       Trainer registers the resumed checkpoint, so the first post-resume
    #       rollout reloads from it instead of the substrate.
    resumed_from = trainer.resume_from_latest()
    if resumed_from is not None:
        logger.info(
            "RESUMED from %s at optimizer step %d / micro step %d",
            resumed_from,
            trainer.optimizer_step,
            trainer.micro_step,
        )

    banner = {
        "stage": "grpo_stageb" + ("_smoke" if args.smoke else ""),
        "substrate": sft_checkpoint,
        "resumed_from": resumed_from,
        "band_file": cfg.data.band_file,
        "band_prompts": len(prompt_rows),
        "shape": {
            "prompts_per_step": cfg.rollout.prompts_per_step,
            "G": cfg.grpo.num_samples_per_prompt_rollout,
            "max_response_len": cfg.rollout.max_response_len,
            "loss_denominator": cfg.grpo.loss_denominator,
            "optimizer_steps": cfg.train.optimizer_steps,
        },
        "ceiling": {k: v["verify_correct_rate"] for k, v in ceiling["sources"].items()},
        "banding_manifest_rows": banding_manifest.get("rows"),
        "topology": {"train_ranks": world, "rollout_gpu": os.environ.get("NMO_ROLLOUT_GPU", "3")},
        "rectifications": (
            f"R3 clip={cfg.train.max_grad_norm:.1f}, R4 canary every={cfg.canary.every}, "
            "R5 pin-on-fire, reduction-time denominator assert armed"
        ),
    }
    logger.info("BANNER %s", json.dumps(banner, indent=1))

    # -- train ----------------------------------------------------------------
    try:
        summary = trainer.train()
        checkpoint = trainer.save_checkpoint()
    finally:
        if worker_proc is not None:
            from align.rollout.client import terminate_worker

            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 [],
        "collapse_alarms": source.alarms,
        "collapse_tripwire": collapse.as_log_dict(),
        "dead_groups": source.filter_stats.as_log_dict(),
        "dropped_remainder_rows": source.dropped_remainder_rows,
    }
    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 _build_native_client(
    model_cfg, sft_checkpoint: str, tokenizer, rollout_gpu: int, world: int, max_response_len: int
):
    """Rank 0's native rollout backend on a dedicated, standalone engine copy.

    The copy is built from the SFT substrate (the policy's step-0 weights) and
    refreshed from the latest saved checkpoint whenever `weights_dir` changes
    (`checkpoint_weight_loader`). Refuses a device a training rank already
    owns: training ranks are bound to local_rank 0..world-1, so the engine GPU
    index must be >= world, and visible.
    """
    import torch

    from align.model import checkpoint as ckpt
    from align.model.mistral import NmoMistralForCausalLM
    from align.rollout import checkpoint_weight_loader, make_rollout_client
    from align.rollout.native import NativeRolloutConfig

    if rollout_gpu < world or rollout_gpu >= torch.cuda.device_count():
        raise RuntimeError(
            f"NMO_ROLLOUT_GPU={rollout_gpu} is not a free visible device: training ranks own "
            f"cuda:0..{world - 1} and {torch.cuda.device_count()} device(s) are visible. The "
            "engine copy must live on its own GPU (launch with all four GPUs visible)."
        )
    device = torch.device("cuda", rollout_gpu)
    engine_model = (
        NmoMistralForCausalLM.from_state_dict(
            model_cfg,
            ckpt.hf_to_nmo(ckpt.load_hf_safetensors(sft_checkpoint, dtype=torch.bfloat16)),
            device=str(device),
        )
        .to(device, dtype=torch.bfloat16)
        .eval()
    )
    return make_rollout_client(
        "native",
        model=engine_model,
        tokenizer=tokenizer,
        native_config=NativeRolloutConfig(
            max_seq_len_cap=2048,
            pad_token_id=tokenizer.pad_token_id if tokenizer.pad_token_id is not None else 0,
        ),
        load_weights_fn=checkpoint_weight_loader(engine_model),
        device=device,
    )


def _latest_weights(trainer, sft_checkpoint: str) -> str:
    """The engine's weights source: latest saved checkpoint, else the substrate."""
    if trainer.checkpoints_written:
        return os.path.join(trainer.ckpt_root, f"micro_{trainer.checkpoints_written[-1]:08d}")
    return sft_checkpoint


def _run_config(cfg):
    from align.config import RunConfig

    run = RunConfig()
    run.optimizer.name = cfg.train.optimizer_name
    run.optimizer.lr = cfg.train.lr
    run.optimizer.grad_accum = cfg.train.grad_accum
    run.train.epochs = 1  # the horizon is optimizer_steps; the source cycles prompts itself
    run.train.micro_batch_size = cfg.train.micro_batch_size
    run.train.save_every_micro_steps = cfg.train.save_every_micro_steps
    run.train.keep_checkpoints = cfg.train.keep_checkpoints
    run.train.per_rank_optimizer_state = cfg.train.per_rank_optimizer_state
    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.train.warmup_frac = cfg.train.warmup_frac
    run.train.full_ft = True
    run.train.seed = cfg.train.seed
    run.tripwires.armed = tuple(cfg.armed_tripwires)
    run.run_name = "grpo_stageb"
    return run


if __name__ == "__main__":
    from align.exit_codes import run_with_exit_codes

    raise SystemExit(run_with_exit_codes(main))