Skip to content
Merged
Show file tree
Hide file tree
Changes from 29 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
Original file line number Diff line number Diff line change
Expand Up @@ -539,8 +539,8 @@ class OrchestratorConfig(BaseConfig):
max_steps: int | None = None
"""Maximum training steps. If None, runs indefinitely."""

max_off_policy_steps: int = Field(8, ge=0)
"""Maximum policies allowed to generate a single rollout. Rollouts generated more than ``max_off_policy_steps`` ahead of training are discarded. Higher values yield better throughput at the cost of off-policy noise."""
max_off_policy_steps: int = Field(8, ge=1)
"""Maximum policy versions a rollout may train behind its generation policy, queue time included (batch N trains on policy v{N-1}, so a rollout generated by v{k} shipping in batch N is (N-1)-k versions off-policy). Rollouts already past this bound — in flight or buffered awaiting a batch — are discarded. Higher values yield better throughput at the cost of off-policy noise. Must be at least 1: under NCCL weight broadcast the trainer never broadcasts the final policy versions, so the last batch necessarily trains one version off-policy."""

bench: bool = False
"""Benchmark mode. Sets ``max_steps`` to 5 and disables W&B."""
Expand Down
141 changes: 77 additions & 64 deletions src/prime_rl/orchestrator/dispatcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,17 @@
schedule next. Transitions are level-triggered (driven by the eval
source's emptiness), so in-flight rollouts of the opposite kind drain
naturally on either side of an eval boundary.
- ``on_version_pending`` (called by the watcher before the engines pause for
the weight update) bumps ``off_policy_steps`` on in-flight train rollouts and
drops groups past ``max_off_policy_steps``.
Eval rollouts are measurements for the policy version they started with,
so they are allowed to finish even if training advances. Train rollouts
sampled from a frozen model never age — their sampler doesn't change
with policy updates.
- Train scheduling is demand-driven: the injected ``train_needed`` predicate
(owned by the orchestrator) says whether the batch being collected still
needs rollouts; ``fill_inflight`` re-evaluates it per scheduled group, so
dispatch stops the moment the batch is covered.
- ``cancel_stale_train_groups`` (driven by the orchestrator's
``on_version_pending``, before the engines pause for the weight update)
drops in-flight train groups whose generation policy fell below the
staleness cutoff. Eval rollouts are measurements for the policy version
they started with, so they are allowed to finish even if training
advances; train rollouts sampled from a frozen model never go stale —
their sampler doesn't change with policy updates.
Cancellations surface as synthetic ``Cancelled`` markers so the sink's
count-to-``group_size`` finalization still fires.
"""
Expand All @@ -29,7 +33,7 @@
from collections import Counter, defaultdict
from dataclasses import dataclass, field
from enum import Enum, auto
from typing import Literal
from typing import Callable, Literal

import verifiers.v1 as vf
from aiolimiter import AsyncLimiter
Expand Down Expand Up @@ -117,8 +121,9 @@ class RolloutDispatcher:
"""``await dispatcher.start()`` runs the dispatch loop until ``stop()``.
Pulls examples from ``TrainSource`` / ``EvalSource``, schedules
rollouts under shared capacity, and emits ``Rollout``\\ s to
``out_q``. The watcher drives ``on_version_pending`` for off-policy
cancellation; the orchestrator triggers eval epochs."""
``out_q``. The orchestrator supplies ``train_needed``, drives
``cancel_stale_train_groups`` on weight updates, and triggers eval
epochs."""

def __init__(
self,
Expand All @@ -131,7 +136,7 @@ def __init__(
policy: Policy,
max_inflight_rollouts: int,
tasks_per_minute: float | None,
max_off_policy_steps: int,
train_needed: Callable[[], bool],
) -> None:
self.policy = policy
self.train_envs = train_envs
Expand All @@ -141,7 +146,7 @@ def __init__(
self.policy_pool = policy_pool
self.train_source = train_source
self.eval_source = eval_source
self.max_off_policy_steps = max_off_policy_steps
self.train_needed = train_needed

self.max_inflight = max_inflight_rollouts
self.inflight_permits = 0
Expand All @@ -161,12 +166,9 @@ def __init__(
# winds down without scheduling new train rollouts
self.train_scheduling_disabled: bool = False
self.metrics = DispatcherMetrics()

# Orchestrator-owned gate. When clear, ``fill_inflight`` returns
# without scheduling new groups. The dispatcher itself doesn't know
# *why* — the orchestrator toggles this based on step / policy lead.
self.dispatch_allowed = asyncio.Event()
self.dispatch_allowed.set()
# Last observed value of ``train_needed`` so pause/resume transitions
# log once instead of every scheduling pass
self._train_paused = False

self.stopped = asyncio.Event()
self.task: asyncio.Task | None = None
Expand Down Expand Up @@ -223,16 +225,6 @@ def disable_train_scheduling(self) -> None:
triggered eval drain naturally."""
self.train_scheduling_disabled = True

@property
def max_off_policy_level(self) -> int:
steps = [m.off_policy_steps for m in self.inflight.values() if m.kind == "train"]
return max(steps) if steps else 0

@property
def mean_off_policy_level(self) -> float:
steps = [m.off_policy_steps for m in self.inflight.values() if m.kind == "train"]
return sum(steps) / len(steps) if steps else 0.0

# ── lifecycle ──────────────────────────────────────────────────────────

async def start(self) -> None:
Expand Down Expand Up @@ -267,48 +259,49 @@ async def stop(self) -> None:
await safe_cancel(self.task)
self.task = None

async def on_version_pending(self, step: int) -> None:
"""Bump off-policy counters and drop groups past
``max_off_policy_steps`` (drop_group emits ``Cancelled`` markers so
the sink still finalizes the partial group). Eval rollouts are not
aged because they are tied to their start-time policy version.

Runs *before* the inference engines are paused for the weight update so
the resulting aborts are processed while the engine is still stepping —
otherwise the orphaned KV transfers crash the decode engine on resume
(see ``WeightWatcher.apply_policy_update``)."""
async def cancel_stale_train_groups(self, min_policy_version: int) -> int:
"""Drop in-flight train groups generated by a policy older than
``min_policy_version`` (``drop_group`` emits ``Cancelled`` markers so
the sink still finalizes the partial group). Eval rollouts are tied
to their start-time policy version and never dropped; frozen-sourced
train rollouts never go stale — their sampler doesn't change with
policy updates.

Must run *before* the inference engines are paused for a weight update
so the resulting aborts are processed while the engine is still
stepping — otherwise the orphaned KV transfers crash the decode engine
on resume (see ``WeightWatcher.apply_policy_update``). The
orchestrator drives this from its ``on_version_pending`` hook."""
stale_groups: set[uuid.UUID] = set()
cancelled = 0
for meta in self.inflight.values():
if meta.kind != "train":
continue
# Frozen-sourced rollouts never go stale — their sampler doesn't
# change with policy updates.
if not self.train_envs.get(meta.env_name).sampler.samples_from_live_policy:
continue
meta.off_policy_steps += 1
if meta.off_policy_steps > self.max_off_policy_steps:
if meta.policy_version < min_policy_version:
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
stale_groups.add(meta.group_id)

cancelled = 0
for gid in stale_groups:
removed = await self.drop_group(gid)
cancelled += removed
cancelled += await self.drop_group(gid)

if cancelled:
get_logger().warning(
f"Cancelled {cancelled} train rollouts past max_off_policy_steps={self.max_off_policy_steps}. "
f"Cancelled {cancelled} in-flight train rollouts generated by policy < v{min_policy_version} — "
"they would exceed max_off_policy_steps by the time they train. "
"Consider increasing it to avoid this."
)

async def on_new_version(self, step: int) -> None:
"""No-op: the dispatcher drains in ``on_version_pending`` (pre-pause)."""
return cancelled

async def fill_inflight(self) -> None:
"""Schedule new rollouts up to ``max_inflight``, honoring
``self.mode``. Eval scheduling ignores the orchestrator's dispatch
gate (evals are version-pinned measurements); only train scheduling
respects it. When ``PREFER_EVAL``'s source exhausts we flip back to
``PREFER_TRAIN`` so the eval tail drains alongside fresh train."""
``self.mode``. Eval scheduling is unbounded by train demand (evals
are version-pinned measurements); train scheduling re-evaluates
``train_needed`` per group, so it stops the moment the batch being
collected is covered and resumes as soon as demand reappears (next
batch, staleness drops, errored arrivals). When ``PREFER_EVAL``'s
source exhausts we flip back to ``PREFER_TRAIN`` so the eval tail
drains alongside fresh train."""
while True:
if self.available_permits <= 0:
return
Expand All @@ -326,10 +319,23 @@ async def fill_inflight(self) -> None:
scheduled = await self.try_schedule("eval")
if not scheduled:
return
else: # PREFER_TRAIN — respects the orchestrator's dispatch gate
if not self.dispatch_allowed.is_set():
return
scheduled = await self.try_schedule("train")
else: # PREFER_TRAIN — fresh groups bounded by the orchestrator's train demand
needed = self.train_needed()
paused = not needed
if paused != self._train_paused:
self._train_paused = paused
get_logger().debug(
"Pausing fresh train dispatch (batch covered or policy too far behind)"
if paused
else "Resuming train dispatch"
)
# Demand only gates *opening* fresh groups. A group schedules its
# group_size rollouts one at a time, and the sink can only
# finalize it once all of them arrive — so cutting a group off
# mid-schedule would leave it unable to ever finalize while its
# arrived members still count as batch coverage, wedging the
# batch a few rollouts short with dispatch paused.
scheduled = await self.try_schedule("train", allow_fresh=needed)
if not scheduled:
return

Expand All @@ -340,11 +346,11 @@ def switch_mode(self, new_mode: DispatcherMode, *, reason: str) -> None:
get_logger().info(f"Switching dispatcher mode to prefer {prefer} rollouts because {reason}")
self.mode = new_mode

async def try_schedule(self, kind: RolloutKind) -> bool:
async def try_schedule(self, kind: RolloutKind, *, allow_fresh: bool = True) -> bool:
"""Schedule one rollout of ``kind``: prefer continuing an existing
group (keeps prefix-cache hits); otherwise open a fresh group from
the corresponding source. Returns False if nothing could be
scheduled."""
group (keeps prefix-cache hits); otherwise, when ``allow_fresh``, open
a fresh group from the corresponding source. Returns False if nothing
could be scheduled."""
if kind == "train" and self.train_scheduling_disabled:
return False
envs = self.train_envs if kind == "train" else self.eval_envs
Expand All @@ -359,6 +365,8 @@ async def try_schedule(self, kind: RolloutKind) -> bool:
if cost <= self.available_permits:
return await self.schedule_group_rollout(gid, group)

if not allow_fresh:
return False
fresh = self.next_fresh_group(kind, envs)
if fresh is None:
return False
Expand Down Expand Up @@ -558,7 +566,6 @@ async def emit_episode(self, meta: InflightRollout, group: GroupState | None, ro
rollout.env_name = meta.env_name
rollout.group_id = meta.group_id
rollout.policy_version = policy_version
rollout.off_policy_steps = meta.off_policy_steps
if meta.kind == "eval":
assert eval_step is not None, "eval rollout missing eval_step"
rollout.eval_step = eval_step
Expand Down Expand Up @@ -688,12 +695,18 @@ async def cancel_inflight_train_rollouts(self) -> int:

def gauges(self) -> dict[str, float]:
"""Instantaneous, read-only gauges sampled by the periodic logger."""
# Versions each in-flight live-sampled train rollout is behind the policy
lags = [
self.policy.version - m.policy_version
for m in self.inflight.values()
if m.kind == "train" and self.train_envs.get(m.env_name).sampler.samples_from_live_policy
]
return {
"dispatcher/inflight_train": float(self.inflight_train_count),
"dispatcher/inflight_eval": float(self.inflight_eval_count),
"dispatcher/queued/eval": float(self.queued_eval_examples),
"dispatcher/mode": float(self.mode == DispatcherMode.PREFER_EVAL),
"dispatcher/groups_in_flight": float(len(self.groups)),
"dispatcher/off_policy_level_max": float(self.max_off_policy_level),
"dispatcher/off_policy_level_mean": self.mean_off_policy_level,
"dispatcher/off_policy_level_max": float(max(lags) if lags else 0),
"dispatcher/off_policy_level_mean": sum(lags) / len(lags) if lags else 0.0,
}
Loading
Loading