Skip to content
Merged
Show file tree
Hide file tree
Changes from 36 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
36 changes: 35 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,35 @@ async def finalize_train_batch(self, batch: TrainBatch) -> None:
f"({n_trainable / len(batch.rollouts):.1%}) — consider reviewing task difficulty / filter config"
)

# Ship batch ``step`` only once the trainer has published v{step-1-TARGET_LAG}.
# Without this, fast envs fill batches from buffered rollouts, the
# orchestrator finishes early, and its teardown strands the trainer inside
# an in-memory broadcast handshake that needs the live weight watcher.
# Always satisfiable: the trainer skips only the final TARGET_LAG+1
# in-memory broadcasts. 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

# Stamp each rollout's true staleness: batch ``step`` trains on policy
# v{step-1}, so a rollout generated from v{k} is (step-1)-k versions
# off-policy — queue time included, unlike the dispatcher's in-flight
# counter, which only sees weight updates during generation. Frozen-
# sourced rollouts stay 0 (their sampler doesn't follow the policy).
for r in batch.rollouts:
if self.train_envs.get(r.env_name).sampler.samples_from_live_policy:
r.off_policy_steps = (step - 1) - r.policy_version

# 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 +935,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
15 changes: 13 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,13 @@ 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 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 +141,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: 2 additions & 3 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import os
import shutil
import signal
import socket
import subprocess
Expand Down Expand Up @@ -74,11 +73,11 @@ def branch_name() -> str:

@pytest.fixture(scope="session")
def output_dir(tmp_path_factory: pytest.TempPathFactory) -> Generator[Path, None, None]:
"""Fixture for temporary output directory for tests with automatic cleanup"""
"""Fixture for the tests' output directory. Never deleted — CI uploads run logs
from it on failure; runs start with ``--clean-output-dir`` for a fresh workspace."""
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)


@pytest.fixture(scope="session")
Expand Down
1 change: 1 addition & 0 deletions tests/integration/test_alphabet_sort.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ def rl_process(
"rl",
"@",
"configs/ci/integration/alphabet_sort.toml",
"--clean-output-dir",
"--wandb.project",
wandb_project,
"--wandb.name",
Expand Down
1 change: 1 addition & 0 deletions tests/integration/test_reverse_text_moe.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ def rl_process(
"rl",
"@",
"configs/ci/integration/reverse-text-moe/start.toml",
"--clean-output-dir",
"--trainer.model.impl",
"custom",
"--wandb.project",
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