Skip to content
Merged
Show file tree
Hide file tree
Changes from 33 commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
36d7d43
fix(orchestrator): enforce target lag strictly at ship time
mikasenghaas Jul 16, 2026
d97d1cf
remove staleness metrics from ship path
mikasenghaas Jul 16, 2026
1ab3826
feat(orchestrator): step-based staleness with demand-driven dispatch
mikasenghaas Jul 16, 2026
3e167d7
Merge remote-tracking branch 'origin/main' into fix/strict-target-lag
mikasenghaas Jul 17, 2026
fa0aed0
review pass: bool dispatch contract, oversampling-aware demand, inlin…
mikasenghaas Jul 17, 2026
278d0dd
require max_off_policy_steps >= 1 at the field
mikasenghaas Jul 17, 2026
58c8392
fix CI regressions: partial-group dispatch deadlock + serialized weig…
mikasenghaas Jul 17, 2026
7508ec6
raise multi-run integration timeout to 600s
mikasenghaas Jul 17, 2026
ffecd2a
Merge remote-tracking branch 'origin/main' into fix/strict-target-lag
mikasenghaas Jul 17, 2026
1242261
Merge remote-tracking branch 'origin/main' into fix/strict-target-lag
mikasenghaas Jul 17, 2026
a7aa4d3
retract stale-dropped rollouts from the payload-size estimate
mikasenghaas Jul 17, 2026
c31b84c
upload integration run logs as artifacts on failure
mikasenghaas Jul 18, 2026
d1fcbef
keep CI test outputs for the failure-artifact upload
mikasenghaas Jul 18, 2026
bc022da
dispatch lookahead: cover TARGET_LAG extra batches when freshness allows
mikasenghaas Jul 18, 2026
78b9915
raise reverse_text mismatch-KL budget to 0.02
mikasenghaas Jul 18, 2026
19f28e7
Merge remote-tracking branch 'origin/main' into fix/strict-target-lag
mikasenghaas Jul 18, 2026
269e106
raise alphabet_sort and multi-run integration budgets
mikasenghaas Jul 18, 2026
b3a8048
raise multi-run inner milestone waits to 600s
mikasenghaas Jul 20, 2026
a0c857f
Merge remote-tracking branch 'origin/main' into fix/strict-target-lag
mikasenghaas Jul 20, 2026
81e0e6e
count group-scored partial arrivals as batch coverage
mikasenghaas Jul 20, 2026
a4dc7f5
guard token-export STABLE markers against deleted run dirs
mikasenghaas Jul 20, 2026
7e4400f
recalibrate multi-run early-step reward floor for strict pacing
mikasenghaas Jul 20, 2026
67ec049
raise rl_sft integration budget to 900s
mikasenghaas Jul 20, 2026
9897ad7
lower multi-run final reward floor to 0.6
mikasenghaas Jul 20, 2026
f9c2db4
Merge remote-tracking branch 'origin/main' into fix/strict-target-lag
mikasenghaas Jul 21, 2026
0143423
Merge remote-tracking branch 'origin/main' into fix/strict-target-lag
mikasenghaas Jul 22, 2026
420e97c
bound dispatch freshness at DISPATCH_LAG instead of the drop cap
mikasenghaas Jul 22, 2026
330d934
ci: retrigger GPU tests
mikasenghaas Jul 22, 2026
fd5e0dc
Merge remote-tracking branch 'origin/main' into fix/strict-target-lag
mikasenghaas Jul 22, 2026
a752e27
tolerate trainer tail-flush when deleting run dirs in multi-run test
mikasenghaas Jul 22, 2026
2cb062d
restore NIXL ModelExpress handshake signals in the version hooks
mikasenghaas Jul 22, 2026
5edfcbf
reduce to the minimal ship-hold fix + staleness docs
mikasenghaas Jul 22, 2026
c504f1d
fold staleness spec into docs/training.md
mikasenghaas Jul 22, 2026
cd40dd9
drop the docs section for now, trim the hold comment
mikasenghaas Jul 22, 2026
f4963b4
never delete the test output dir; clean at run start instead
mikasenghaas Jul 22, 2026
e467d6b
stamp true consumption staleness on shipped rollouts
mikasenghaas Jul 22, 2026
daf5c8c
use the deletion-retry helper for alpha's run dir too
mikasenghaas Jul 22, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .github/workflows/gpu_tests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -121,3 +121,11 @@ jobs:
PYTEST_OUTPUT_DIR: /tmp/outputs
VLLM_WORKER_MULTIPROC_METHOD: spawn
run: uv run pytest -vvs ${{ matrix.pytest_args }}
- name: Upload run logs on failure
if: failure()
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.test }}-logs
path: /tmp/outputs/**/*.log
retention-days: 7
if-no-files-found: ignore
82 changes: 82 additions & 0 deletions docs/training.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ This page covers everything you need to launch, observe, checkpoint, and recover
- [Useful Knobs](#useful-knobs)
- [Algorithms](#algorithms)
- [Important Metrics](#important-metrics)
- [Staleness and Pacing](#staleness-and-pacing)
- [SFT Trainer](#sft-trainer)
- [Dataset Format](#dataset-format)
- [Launch](#launch-1)
Expand Down Expand Up @@ -131,6 +132,87 @@ Pulled from the console logs and mirrored to W&B.
| trainer | `time/wait_for_batch` | **high → orchestrator bottleneck** |
| orchestrator | `time/wait_for_ckpt` | **high → trainer bottleneck** |

### Staleness and Pacing

How the async RL pipeline bounds off-policy staleness, when dispatch starts and
stops, and why shutdown is race-free. Two counters govern everything:

- `N` — the batch the orchestrator is collecting (`progress.step`, 1-indexed).
- `v` — the trainer's policy version (0-indexed). `v` advances when the trainer
*publishes* a version; inference applies it immediately and pauses generation
during the update, so published and applied versions are indistinguishable to
every consumer.

A rollout is stamped with the version it was generated from (`v_gen`). If it is
consumed in batch `N`, it trains `lag = (N-1) - v_gen` versions behind — batch
`N` produces `v{N}` from `v{N-1}`, so `lag = 0` is fully on-policy.

#### The three rules

Each rule bounds `N - v` (or a rollout's `lag`) at one lifecycle point. They are
independent: each one prevents a failure mode the others cannot.

| Rule | Bound | Enforced at | Failure mode it prevents |
|---|---|---|---|
| **START** | dispatch pauses while `(N-1) - v > TARGET_LAG` | dispatch gate | generating data that is born stale |
| **ADVANCE** | batch `N` ships only once `v ≥ N-1-TARGET_LAG` | ship hold | the batch counter outrunning the trainer |
| **DIE** | rollouts that straddle more than `max_off_policy_steps` weight updates in flight are cancelled | weight-update hook | long-running stragglers aging without bound |

With `TARGET_LAG = 1`, START and ADVANCE together keep consumption lag at ≤ 2
in steady state (≤ 3 with batch-boundary spill); DIE only fires on rollouts
whose own duration spans many trainer steps (agentic long tails).

#### The two worlds

Which rule binds depends on the ratio of trainer step time `T` to per-batch
generation time `G`. The rules never change — only which one saturates:

**World 1 — generation-bound (`T < G`, the trainer waits).** `v` catches up
after every ship, so `(N-1) - v ≤ 1` always: START and ADVANCE never bind and
DIE stays quiet. The pipeline is paced by generation; the trainer idles between
batches. All staleness rules are inert insurance.

**World 2 — trainer-bound (`T > G`, inference races).** Batches fill faster
than versions arrive. ADVANCE binds every step (the ship hold paces `N` to
`v`); START closes dispatch between version publishes (inference alternates
generate/idle, idle fraction `1 - G/T`). Without ADVANCE this world is where
the orchestrator finishes all its batches from buffered rollouts, exits, and
strands the trainer (see below).

The crossover is smooth: the same predicates are evaluated everywhere, and no
configuration switch distinguishes the worlds.

#### Shutdown correctness

For in-memory weight transports (NCCL, NIXL) the trainer *blocks* inside each
broadcast until the orchestrator's weight watcher completes the matching apply.
Two guarantees make teardown race-free:

1. **ADVANCE is the liveness guarantee.** The orchestrator cannot exit before
shipping its final batch `M`, which requires `v ≥ M-2` — and `v{M-2}` is
exactly the last version whose broadcast needs a live watcher (the trainer
skips the final `TARGET_LAG + 1` in-memory broadcasts because their receiver
is torn down; this is also why the hold never waits for an unpublishable
version).
2. **The watcher drains before dying.** `WeightWatcher.stop()` waits for an
in-flight apply to complete before cancelling, so a shutdown that races the
last handshake finishes it instead of stranding the trainer. The
orchestrator's global teardown budget bounds this wait.

No other component participates in shutdown ordering: the trainer only ever
waits on the watcher, and the watcher outlives every wait.

#### Who starts and stops dispatch

Nobody signals — dispatch is *pulled*. The dispatcher re-checks the gate before
each scheduling decision, so stop/resume is a stateless predicate over
`(N, v)`, re-evaluated on its own loop. The single push-style signal in the
system is the version-advance event that wakes a held ship. Throughput is
identical to an unpaced orchestrator in both worlds: in World 1 no rule ever
binds, and in World 2 the trainer is the bottleneck regardless — the hold only
changes *where* a finished batch waits (in the orchestrator instead of the
trainer's queue), never when the trainer gets to consume it.

## SFT Trainer

`uv run sft` runs supervised fine-tuning from a HF dataset. It shares model loaders, FSDP setup, checkpointing, and the chat-template plumbing with the RL trainer, so a typical workflow is _SFT → RL → SFT → …_ without any reformatting.
Expand Down
32 changes: 31 additions & 1 deletion src/prime_rl/orchestrator/orchestrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,8 @@ def __init__(self, config: OrchestratorConfig) -> None:
self.eval_triggered_at = {}
self.consecutive_empty_batches = 0
self.gate_closed_at = None
# Pulsed by the version hooks so a held ship can re-check ``policy.version``
self.version_advanced = asyncio.Event()
self.wait_for_policy_time = 0.0
self.component_tasks = []

Expand Down Expand Up @@ -601,6 +603,31 @@ async def finalize_train_batch(self, batch: TrainBatch) -> None:
f"({n_trainable / len(batch.rollouts):.1%}) — consider reviewing task difficulty / filter config"
)

# ADVANCE rule (docs/training.md#staleness-and-pacing): batch ``step`` ships only once the
# trainer has published policy v{step-1-TARGET_LAG}. On fast envs the sink
# otherwise fills batches from buffered rollouts faster than the trainer
# consumes them; the orchestrator would finish all its steps, tear down the
# weight watcher, and strand the trainer inside its next in-memory broadcast
# handshake (the watcher serves the receive side). Holding here paces the
# batch counter to the trainer and keeps the watcher alive for every
# broadcast the trainer will make. The requirement is always satisfiable:
# the trainer skips only the final TARGET_LAG+1 in-memory broadcasts, so
# v{max_steps-1-TARGET_LAG} is the last one published — exactly what the
# final batch needs. Bench runs have no trainer, so they ship freely.
required_version = step - 1 - TARGET_LAG
if not config.bench and self.policy.version < required_version:
get_logger().info(
f"Holding batch {step} until the trainer publishes policy v{required_version} "
f"(currently v{self.policy.version})"
)
hold_start = time.perf_counter()
while True:
self.version_advanced.clear()
if self.policy.version >= required_version:
break
await self.version_advanced.wait()
Comment thread
cursor[bot] marked this conversation as resolved.
self.wait_for_policy_time += time.perf_counter() - hold_start

# The effective (clean, trained-on) subset lands in the per-step ``effective`` trace file
# at ship time; the full arrival window already streamed into ``all`` on arrival.
# to_record drops the per-node training tensors — they're for training, not the rollout
Expand Down Expand Up @@ -904,11 +931,14 @@ def update_dispatch_gate(self) -> None:
gate.set()

async def on_version_pending(self, step: int) -> None:
"""``VersionObserver`` hook, fired at publish confirmation (pre-apply):
``policy.version`` already carries the new version, so wake a held ship."""
if self.model_express is not None:
await asyncio.to_thread(self.model_express.set_status, p2p_pb2.SOURCE_STATUS_READY)
self.version_advanced.set()
Comment thread
mikasenghaas marked this conversation as resolved.

async def on_new_version(self, step: int) -> None:
"""``VersionObserver`` hook: the watcher just advanced ``policy.version``;
"""``VersionObserver`` hook: the weight update completed;
re-evaluate the dispatch gate (may resume if the trainer caught up)."""
if self.model_express is not None:
await asyncio.to_thread(self.model_express.set_status, p2p_pb2.SOURCE_STATUS_INITIALIZING)
Expand Down
16 changes: 14 additions & 2 deletions src/prime_rl/orchestrator/watcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,12 @@ async def start(self) -> None:

async def stop(self) -> None:
self.stopped.set()
# Let an in-flight apply finish before dying: the trainer blocks inside
# its in-memory broadcast until the apply completes, so cancelling
# mid-apply would strand it. The orchestrator's global teardown budget
# bounds this wait.
async with self.update_lock:
pass
Comment thread
cursor[bot] marked this conversation as resolved.
if self.task is not None:
await safe_cancel(self.task)
self.task = None
Expand Down Expand Up @@ -103,6 +109,14 @@ async def apply_policy_update(self, next_step: int) -> None:
await wait_for_path(stable_marker)
self.last_wait_for_ckpt_time = time.perf_counter() - t0

# Publish confirmed: the policy version advances here, before the
# apply — inference pauses during the update, so nothing can generate
# under the new number from the old weights, and a ship held on this
# version (see docs/training.md#staleness-and-pacing) releases without waiting out the
# inference weight reload.
self.ckpt_step = next_step
self.policy.version = next_step
Comment thread
cursor[bot] marked this conversation as resolved.

# Drain off-policy rollouts BEFORE pausing the inference engines.
# Aborting a rollout triggers vLLM's KV-connector cleanup (NIXL's
# ``_reqs_not_processed``), which is only propagated to the workers
Expand All @@ -128,8 +142,6 @@ async def apply_policy_update(self, next_step: int) -> None:
self.update_count += 1
get_logger().debug(f"Updated weights to step {next_step} in {format_time(self.last_update_weights_time)}")

self.ckpt_step = next_step
self.policy.version = next_step
if self.lora_name is not None:
self.inference.update_model_name(self.lora_name)
self.policy.model_name = self.lora_name
Expand Down
8 changes: 7 additions & 1 deletion src/prime_rl/trainer/rl/token_export.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

from prime_rl.configs.trainer import DefaultLossConfig, TrainerConfig
from prime_rl.trainer.rl.loss import compute_importance_ratio_and_mismatch_kl
from prime_rl.utils.logger import get_logger

SCHEMA_VERSION = 1

Expand Down Expand Up @@ -93,7 +94,12 @@ def mark_stable(self, ready_run_ids: set[str] | None = None) -> None:
ready_run_ids = ready_run_ids or set()
for run_id in [rid for rid in self._pending_stable_dirs if rid is None or rid in ready_run_ids]:
for stable_dir in self._pending_stable_dirs.pop(run_id):
(stable_dir / "STABLE").touch()
try:
(stable_dir / "STABLE").touch()
except FileNotFoundError:
# A multi-run run dir can be deleted while its tail steps are
# still flushing (mirrors the broadcast paths' guard)
get_logger().warning(f"Run dir for {run_id} is deleted, skipping token-export STABLE marker")

def _export_dir(self, export_step: int, run_id: str | None) -> Path:
if run_id is not None:
Expand Down
5 changes: 4 additions & 1 deletion tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,10 @@ def output_dir(tmp_path_factory: pytest.TempPathFactory) -> Generator[Path, None
output_dir = Path(os.environ.get("PYTEST_OUTPUT_DIR", tmp_path_factory.mktemp("outputs")))
output_dir.mkdir(parents=True, exist_ok=True)
yield output_dir
shutil.rmtree(output_dir, ignore_errors=True)
# In CI the workspace is discarded after the job; keep the outputs so the
# failure-artifact upload step can collect the run logs.
if os.environ.get("USERNAME_CI") != "CI_RUNNER":
shutil.rmtree(output_dir, ignore_errors=True)


@pytest.fixture(scope="session")
Expand Down
15 changes: 13 additions & 2 deletions tests/integration/test_reverse_text_multi_run.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,17 @@
ORCHESTRATOR_NAMES = ["alpha", "beta", "gamma"]


def remove_run_dir(run_dir: Path) -> None:
"""Delete a run dir, tolerating the shared trainer still flushing its tail
(token exports recreate directories mid-rmtree -> 'Directory not empty')."""
for _ in range(10):
shutil.rmtree(run_dir, ignore_errors=True)
if not run_dir.exists():
return
time.sleep(1)
shutil.rmtree(run_dir)


def wait_for_file(
file_path: Path,
timeout: int = 600,
Expand Down Expand Up @@ -305,7 +316,7 @@ def multi_run_result(
shutil.copy(run_dir / "logs" / "orchestrator.log", log_dir / "beta_orchestrator.log")
shutil.copytree(beta_ckpt_dir, tmp_path / "beta_ckpt_step_20")
print(f"Copied {beta_ckpt_dir} to {tmp_path / 'beta_ckpt_step_20'}")
shutil.rmtree(run_dir)
Comment thread
cursor[bot] marked this conversation as resolved.
remove_run_dir(run_dir)

# =====================
# Queue beta's resume
Expand All @@ -329,7 +340,7 @@ def multi_run_result(
timeout=TIMEOUT,
)
shutil.copy(output_dir / "run_gamma" / "logs" / "orchestrator.log", log_dir / "gamma_orchestrator.log")
shutil.rmtree(output_dir / "run_gamma")
remove_run_dir(output_dir / "run_gamma")

# ================================================
# Wait for alpha_resume and beta_resume to finish
Expand Down
8 changes: 4 additions & 4 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading