eval: checkpoint-interfaced evaluation for fully-async training (dedicated fleet / pause-the-world / external service)#1740
Conversation
run_eval_datasets moves next to eval_rollout_single_dataset so three consumers (InferenceRolloutFn, FullyAsyncRolloutFn, the checkpoint eval service) share one eval driver. checkpoint_eval.py retargets a shallow-copied args namespace at the eval fleet's router with eval GPU sizing, which fixes both the hardcoded router URL in the generate path and the concurrency semaphore in one place. eval_rollout_single_dataset drops ABORTED/reward-less samples (engine died mid-eval) with a failed_samples count instead of corrupting dataset metrics.
FullyAsyncRolloutFn now answers evaluation=True itself by running the standard eval datasets against the eval fleet's router (lazy GenerateState, cached eval prompt datasets), instead of raising unconditionally. Without --eval-num-gpus the raise remains, now pointing at the fleet flag and the external service.
--eval-num-gpus/--eval-num-gpus-per-engine size the fleet; --eval-hf-dir stages per-eval HF snapshots (tmpfs for the no-disk debug case) with --save-hf reuse as the zero-export production mode; --eval-dispatch/--eval-max-in-flight/ --eval-overflow-policy bound the fire-and-forget pipeline; --eval-keep-snapshots rings the staging dir and is validated >= max-in-flight so a pending eval's snapshot can never be GC'd.
The eval fleet is a second ModelConfig (name=eval, update_weights=False — the default inference would mark it updatable and trip the exactly-one-updatable assert) appended in _resolve_sglang_config; the placement group grows by eval_num_gpus at the tail of the rollout slice, past megatron's range, so the offload choreography skips it for free. rollout_num_gpus keeps meaning training rollout GPUs everywhere; the shared http client is sized for both fleets.
save_hf_model gains an explicit path, a raise_on_error mode for the on-demand eval snapshot path (the periodic --save-hf path keeps swallow-and-log), a rank-0 .complete marker written after a barrier so consumers can distinguish finished exports from partial ones, and a cached AutoBridge (it reloaded the HF config on every call). export_hf is exposed through MegatronTrainRayActor and RayTrainGroup.
RolloutManager.eval(rollout_id, hf_dir) pins the eval fleet to the snapshot before generating: an eval lock serializes load -> verify -> generate; every engine loads via update_weights_from_disk with weight_version=str(rollout_id) and the version is verified (one retry, then the point is skipped with eval/pin_violation). Fleet recovery is self-healing at eval time; a missing completeness marker or unhealthy fleet degrades to a skipped point logged at that rollout_id (eval/skipped_*), never a stall. Consumed staging snapshots are GC'd beyond the keep ring; --save-hf checkpoints are never touched. lag/duration/ export-time metrics ride the eval payload; log_eval_skip makes curve gaps attributable from the dashboard.
EvalDispatcher exports the HF snapshot for an eval-due step (or reuses the --save-hf checkpoint), fires eval.remote without awaiting, and bounds pending evals via --eval-max-in-flight with backpressure or skip overflow policy. Finished refs are reaped nonblocking each step; all pending evals are drained before dispose so the last points land and MetricChecker sees completed evals. Export failure degrades to a skipped point. The final rollout now always gets an eval point (num_rollout passed to should_run_periodic_action), and the pre-train eval uses the base checkpoint as its snapshot.
Watches --save-hf output (marker-gated, with an mtime-quiescence fallback for pre-marker checkpoints), pins its own sglang server per snapshot via /update_weights_from_disk with weight_version=str(rollout_id), runs the shared run_eval_datasets core, and logs at the snapshot's step — late points land at the right x because the step travels in the payload. A ledger file makes restarts idempotent and backfills missed snapshots; --catchup latest skips a backlog. No Ray, no GPU carve-out from the training job; works for colocate runs and after the run ends.
Qwen3.5-4B launch script with a 4+3+1 GPU split (actor/rollout/eval) and a fully-async docs section covering the two postures (tmpfs staging for on-time debug curves; --save-hf reuse and the external service for production), the weight_version namespace, and the skip counters.
Found in GPU verification: AutoBridge auto-selects Qwen35VLBridge for Qwen3.5, whose mapping matches none of the text spec's megatron params — it exports config/tokenizer but zero weight files while reporting success. export_hf now uses miles' own megatron->HF converters (the weight updater's machinery, so export coverage matches weight-sync coverage exactly), streaming safetensors shards from the gathered chunks with an index and metadata copied from the base checkpoint; bridge mode falls back to save_hf_model. The bridge path gains a no-weight-files check before writing the completeness marker, and the eval controller downgrades weight-load failures to a skipped point (pin_violation) instead of raising out of the fire-and-forget ref.
There was a problem hiding this comment.
Code Review
This pull request introduces a dedicated evaluation fleet for fully-async training, allowing evaluations to run on separate engines pinned to Hugging Face checkpoint snapshots without stalling training. It adds new CLI configuration options, an EvalDispatcher to manage async evaluation tasks, a standalone checkpoint evaluation service tool, and corresponding tests. The review feedback focuses on improving robustness and error handling, specifically suggesting to verify if the checkpoint path is a local directory before exporting, replacing assert statements with explicit exceptions (like ValueError and RuntimeError) to prevent them from being stripped under Python optimization, and narrowing exception handling in health check probes.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| if is_writer: | ||
| assert weight_map, f"HF export to {path} produced no weights" | ||
| for meta_file in Path(args.hf_checkpoint).iterdir(): | ||
| # Copy tokenizer/config metadata only — never base weight files or the | ||
| # base checkpoint's safetensors index (ours is written below). | ||
| if meta_file.is_file() and not any(s in meta_file.name for s in HF_METADATA_SKIP_SUFFIXES): | ||
| shutil.copy2(meta_file, path / meta_file.name) |
There was a problem hiding this comment.
If args.hf_checkpoint is a Hugging Face Hub model ID (e.g., Qwen/Qwen2.5-7B-Instruct) rather than a local directory, Path(args.hf_checkpoint).iterdir() will raise a FileNotFoundError and crash the direct HF export process. Check if the path is a directory before attempting to copy metadata files.
| if is_writer: | |
| assert weight_map, f"HF export to {path} produced no weights" | |
| for meta_file in Path(args.hf_checkpoint).iterdir(): | |
| # Copy tokenizer/config metadata only — never base weight files or the | |
| # base checkpoint's safetensors index (ours is written below). | |
| if meta_file.is_file() and not any(s in meta_file.name for s in HF_METADATA_SKIP_SUFFIXES): | |
| shutil.copy2(meta_file, path / meta_file.name) | |
| if is_writer: | |
| assert weight_map, f"HF export to {path} produced no weights" | |
| checkpoint_path = Path(args.hf_checkpoint) | |
| if not checkpoint_path.is_dir(): | |
| raise FileNotFoundError( | |
| f"hf_checkpoint '{args.hf_checkpoint}' is not a local directory. " | |
| f"Direct HF export requires a local directory containing the model metadata files." | |
| ) | |
| for meta_file in checkpoint_path.iterdir(): | |
| # Copy tokenizer/config metadata only — never base weight files or the | |
| # base checkpoint's safetensors index (ours is written below). | |
| if meta_file.is_file() and not any(s in meta_file.name for s in HF_METADATA_SKIP_SUFFIXES): | |
| shutil.copy2(meta_file, path / meta_file.name) |
| if args.eval_num_gpus > 0: | ||
| assert ( | ||
| enable_experimental_rollout_refactor() | ||
| ), "--eval-num-gpus requires the class-based rollout API (MILES_EXPERIMENTAL_ROLLOUT_REFACTOR=1)." | ||
| assert args.eval_interval is not None, "--eval-num-gpus requires --eval-interval." | ||
| assert args.eval_hf_dir is not None or args.save_hf is not None, ( | ||
| "--eval-num-gpus requires a snapshot source: set --eval-hf-dir (staging exports) " | ||
| "or --save-hf (reuse periodic HF checkpoints)." | ||
| ) | ||
| assert not args.colocate, ( | ||
| "--eval-num-gpus is not supported with --colocate; " | ||
| "use tools/checkpoint_eval_service.py against --save-hf checkpoints instead." | ||
| ) | ||
| assert ( | ||
| not args.debug_train_only and not args.debug_rollout_only | ||
| ), "--eval-num-gpus is not supported with debug_train_only/debug_rollout_only." | ||
| assert args.eval_num_gpus % args.eval_num_gpus_per_engine == 0, ( | ||
| f"eval_num_gpus ({args.eval_num_gpus}) must be divisible by " | ||
| f"eval_num_gpus_per_engine ({args.eval_num_gpus_per_engine})." | ||
| ) | ||
| assert args.eval_keep_snapshots >= args.eval_max_in_flight, ( | ||
| f"--eval-keep-snapshots ({args.eval_keep_snapshots}) must be >= --eval-max-in-flight " | ||
| f"({args.eval_max_in_flight}), otherwise a pending eval's snapshot could be GC'd." | ||
| ) | ||
| if args.eval_hf_dir is None: | ||
| # Reuse mode: every eval-due step must coincide with a save-due step. | ||
| assert args.save_interval is not None and args.eval_interval % args.save_interval == 0, ( | ||
| "Reusing --save-hf checkpoints for eval requires eval_interval to be a " | ||
| f"multiple of save_interval (got eval_interval={args.eval_interval}, " | ||
| f"save_interval={args.save_interval}). Set --eval-hf-dir for independent snapshots." | ||
| ) |
There was a problem hiding this comment.
Use ValueError instead of assert for validating command-line arguments. If Python is run with optimization (-O), assert statements are compiled away, which would silently bypass all validation checks.
if args.eval_num_gpus > 0:
if not enable_experimental_rollout_refactor():
raise ValueError("--eval-num-gpus requires the class-based rollout API (MILES_EXPERIMENTAL_ROLLOUT_REFACTOR=1).")
if args.eval_interval is None:
raise ValueError("--eval-num-gpus requires --eval-interval.")
if args.eval_hf_dir is None and args.save_hf is None:
raise ValueError(
"--eval-num-gpus requires a snapshot source: set --eval-hf-dir (staging exports) "
"or --save-hf (reuse periodic HF checkpoints)."
)
if args.colocate:
raise ValueError(
"--eval-num-gpus is not supported with --colocate; "
"use tools/checkpoint_eval_service.py against --save-hf checkpoints instead."
)
if args.debug_train_only or args.debug_rollout_only:
raise ValueError("--eval-num-gpus is not supported with debug_train_only/debug_rollout_only.")
if args.eval_num_gpus % args.eval_num_gpus_per_engine != 0:
raise ValueError(
f"eval_num_gpus ({args.eval_num_gpus}) must be divisible by "
f"eval_num_gpus_per_engine ({args.eval_num_gpus_per_engine})."
)
if args.eval_keep_snapshots < args.eval_max_in_flight:
raise ValueError(
f"--eval-keep-snapshots ({args.eval_keep_snapshots}) must be >= --eval-max-in-flight "
f"({args.eval_max_in_flight}), otherwise a pending eval's snapshot could be GC'd."
)
if args.eval_hf_dir is None:
# Reuse mode: every eval-due step must coincide with a save-due step.
if args.save_interval is None or args.eval_interval % args.save_interval != 0:
raise ValueError(
"Reusing --save-hf checkpoints for eval requires eval_interval to be a "
f"multiple of save_interval (got eval_interval={args.eval_interval}, "
f"save_interval={args.save_interval}). Set --eval-hf-dir for independent snapshots."
)References
- Use ValueError instead of assert for validating function or constructor arguments.
| try: | ||
| await get(f"http://{ip}:{port}/health_generate", max_retries=1) | ||
| return | ||
| except Exception: | ||
| await asyncio.sleep(5) |
There was a problem hiding this comment.
Avoid catching broad exceptions like Exception in health check probes. Only catch expected network, timeout, or unavailability exceptions (such as httpx.HTTPError and asyncio.TimeoutError) to prevent swallowing internal programming errors or bugs.
| try: | |
| await get(f"http://{ip}:{port}/health_generate", max_retries=1) | |
| return | |
| except Exception: | |
| await asyncio.sleep(5) | |
| import httpx | |
| try: | |
| await get(f"http://{ip}:{port}/health_generate", max_retries=1) | |
| return | |
| except (httpx.HTTPError, asyncio.TimeoutError): | |
| await asyncio.sleep(5) |
References
- In health check endpoints or probes, avoid catching broad exceptions (like Exception). Only catch expected network, timeout, or unavailability exceptions.
| assert ( | ||
| str(info.get("weight_version")) == weight_version | ||
| ), f"weight_version pin failed: engine reports {info.get('weight_version')}, expected {weight_version}" |
There was a problem hiding this comment.
Do not use assert for runtime checks that must always execute, as they are stripped when Python is run with optimization (-O). Use RuntimeError instead.
| assert ( | |
| str(info.get("weight_version")) == weight_version | |
| ), f"weight_version pin failed: engine reports {info.get('weight_version')}, expected {weight_version}" | |
| if str(info.get("weight_version")) != weight_version: | |
| raise RuntimeError( | |
| f"weight_version pin failed: engine reports {info.get('weight_version')}, expected {weight_version}" | |
| ) |
| if args.wandb_mode == "shared": | ||
| assert args.wandb_run_id, "--wandb-mode shared requires --wandb-run-id of the training run" |
There was a problem hiding this comment.
Use ValueError instead of assert for validating command-line arguments or configurations to prevent checks from being stripped under Python optimization (-O).
| if args.wandb_mode == "shared": | |
| assert args.wandb_run_id, "--wandb-mode shared requires --wandb-run-id of the training run" | |
| if args.wandb_mode == "shared" and not args.wandb_run_id: | |
| raise ValueError("--wandb-mode shared requires --wandb-run-id of the training run") |
References
- Use ValueError instead of assert for validating function or constructor arguments.
| watch_dir = Path(service_args.watch_dir) | ||
| assert watch_dir.is_dir(), f"--watch-dir {watch_dir} does not exist" |
There was a problem hiding this comment.
Use FileNotFoundError instead of assert for validating file or directory existence to prevent checks from being stripped under Python optimization (-O).
| watch_dir = Path(service_args.watch_dir) | |
| assert watch_dir.is_dir(), f"--watch-dir {watch_dir} does not exist" | |
| watch_dir = Path(service_args.watch_dir) | |
| if not watch_dir.is_dir(): | |
| raise FileNotFoundError(f"--watch-dir {watch_dir} does not exist") |
References
- Use ValueError instead of assert for validating function or constructor arguments.
…est) Running the service against real snapshots surfaced missing namespace fields the eval path consumes (rollout_max_context_len, sglang_speculative_algorithm, rm_url, log_reward_category, advantage_estimator, load_debug_rollout_data) and two health-probe bugs: miles' get() helper takes no kwargs and json-decodes the response body, but /health_generate returns a non-JSON 200 (probe now uses httpx directly), and the http client must be initialized before the probe.
Kill-testing surfaced a hang the design missed: killing an engine's sglang scheduler subprocess leaves the Ray actor alive but its backend dead — the update_weights_from_disk call is accepted and never answered (the shared http client has no timeout), holding the eval lock forever and eventually stalling the training loop through the dispatcher's backpressure await. Each load attempt is now bounded by a 600s timeout that degrades the point to eval/pin_violation and releases the lock.
The actor-kill test showed recover() never revives a dead eval engine unless fault tolerance's health monitor marked it stopped — without FT every subsequent eval skipped with pin_violation instead of self-healing. The controller now probes each allocated engine before recovery (60s bound) and marks unreachable ones stopped itself, which covers both the dead-actor and zombie-backend cases; the eval that observed the death still degrades to a skipped point, and the next eval runs on the revived engine.
The revival test showed the remaining gap: after an eval engine is revived, the router needs a health-check cycle to evict the dead worker, and a generate dispatched inside that window 503s — one raised request killed the whole eval point. The controller now proves the route end-to-end with a retried one-token probe before dispatching (skipping with eval/skipped_unhealthy if it never comes up), and eval sample generation tolerates individual raises (counted into failed_samples; the point is only abandoned if every sample failed).
Qwen2.5-0.5B on gsm8k, 2 actor + 5 rollout + 1 eval GPUs (FEW_GPU: 1+2+1), FullyAsyncRolloutFn with eval-interval 2 and tmpfs snapshot staging via the bridge export path (HF-format ref-load requires the bridge loader). Verified green on 8xH200: eval points at 0/1/3 with pinned weight versions, job succeeded.
The direct HF export no longer crashes when hf_checkpoint is a hub model id rather than a local dir (metadata copy is skipped with a warning); the eval service raises typed errors instead of asserts for its runtime checks; black formatting for the last round of edits.
miles/utils/hf_export.py was a three-symbol fragment whose name suggested it did exporting; the marker constant and the two HF-checkpoint-dir predicates belong with the existing HF checkpoint utilities in hf_config.py.
train_async.py goes back to being a thin procedural driver; the dispatcher is Ray orchestration and lives with RolloutManager.
With --eval-num-gpus unset, eval no longer raises: the producer pauses new submissions for the duration of the eval and resumes after. The weight version stays pinned without any snapshot machinery because the driver awaits eval, so no update_weights interleaves — in-flight groups finish and buffer, eval takes over the freed capacity, and rollout production resumes when the point lands. Costs roughly the eval duration in rollout stall; the dedicated fleet remains the zero-stall option.
A per-dataset rm override (sample metadata rm_type) may return scalar rewards while the training rm returns dicts keyed by --reward-key.
At the last rollout training is already over, so overflow backpressure costs nothing; dropping the most valuable point under skip policy was pure loss.
- fold the triplicated lazy-eval-state block into EvalFleetSession; drop the unused CheckpointEvalRolloutFn - report crashed async evals as eval/skipped_crashed so every missing point stays attributable - anchor snapshot rollout-id extraction to the trailing number - drop the service's dead --use-wandb flag; make resolve_eval_datasets public (cross-module use) - match HF export metadata skips by suffix, not substring - keyword-only path/raise_on_error on save_hf_model - share one wait_http_ok helper for router/server readiness probes - count raised samples in failed_samples; failure-path tests for eval, snapshot discovery, ledger, and force dispatch
Motivation
Fully-async rollout (#1717) has no evaluation story: the producer never stops, so there is no quiet window to evaluate in, and running eval on the training engines is structurally broken — eval requests get aborted by every weight update, and no well-defined weight version is measured. Heavy eval sets also steal training inference capacity even in sync mode.
Design
The only interface between training and eval is a (rollout_id, HF snapshot dir) pair. Eval engines never join training weight updates; weights reach them only through
update_weights_from_disk(hf_dir, weight_version=str(rollout_id)), which loads the snapshot and stamps the version atomically.Pick a posture by two questions — is this a test run or a real run (are checkpoints persisted anyway), and does eval get standalone GPUs:
--save-hfoutput outside the job; pause-the-world if points must be strictly on-time--eval-num-gpus N --eval-hf-dir /dev/shm/...— export is seconds, never disk-bound--eval-num-gpus Nwith no--eval-hf-dir— the snapshot IS the periodic--save-hfcheckpoint, zero extra exportAll four cells are flag composition over the same controller (pin / verify / skip / GC).
Semantics
eval/lag_stepsreports how late).weight_versionis verified after every load;eval/{ds}/weight_version/mean == eval/stepandmixed_version_ratio == 0prove each point measured exactly the intended weights.update_weightsbroadcast already put the step's weights on the shared engines, and pinning is by ordering (the driver awaits the eval, so the next update cannot interleave; this is also why shared-engine eval must stay blocking). The fleet and the external service sit outside the broadcast group, so they load the actor's exact step-R weights from the snapshot/checkpoint — which is what makes them safely asynchronous. Eval engines never join the training broadcast: collectives cannot skip members, so an in-group fleet would be rewritten by every update.--pause-generation-mode) is training's per-step business — eval adds no pause, retract, or weight sync of its own, and works under bothin_placeandretractpause modes (retractadditionally requires the flush-after-retract fix from [sglang-miles] Fix flush_cache() no-op after pause_generation in retract sgl-project/sglang#31962 + [Fix] retract-mode flush_cache no-op crash #1750).eval/skipped_{unhealthy,ckpt_missing,pin_violation,crashed,busy}). Overflow behavior is explicit (--eval-overflow-policy backpressure|skip), and the final eval point is never skipped (training is already over, so waiting is free).What's in the PR
miles/rollout/checkpoint_eval.py— retargeted-args helpers +EvalFleetSession(lazy eval-fleet state shared by both rollout fns)FullyAsyncRolloutFneval support — dedicated fleet when configured, pause-the-world otherwisemiles/ray/rollout/eval_dispatch.py— driver-sideEvalDispatcher(bounded in-flight, overflow policy, drain-before-dispose)RolloutManager._eval_on_dedicated_fleet— the pin/verify/skip/GC controllerexport_hf_model_direct— HF export through miles' direct megatron→HF converters, the same machinery as weight updates, so export coverage always matches weight-sync coverage (the AutoBridge silently exports zero weights for specs it has no mapping for, e.g. qwen3.5); every HF export now writes a.completemarkertools/checkpoint_eval_service.py— standalone watcher with its own sglang server: marker/quiescence-gated snapshot discovery, idempotent ledger for restart/backfill,--catchup {all,latest},--oncedocs/user-guide/fully-async.md), example script, e2e CI test, ~25 unit testsVerification (8×H200, Qwen3.5-4B, 4 actor TP2 + rollout engines ± 1 eval engine)
All four matrix cells ran end-to-end (dapo-math training, gsm8k eval):
lag_steps ≤ 11, training uninterruptedskippolicy observed and attributed (eval/skipped_busyat the affected step)mixed_version_ratio == 0Kill tests: an eval engine killed mid-run is detected by the pre-eval probe, marked stopped, revived by
recover(), the router probe rides out the post-revival 503 window, and the next eval lands pinned — training untouched throughout.Production-scale stress (512 in-flight requests,
global-batch 128→ 4 optimizer steps + weight broadcasts per rollout, real--save/--save-hfpersistence, pause-the-world gsm8k eval): completed under both pause modes with statistically identical curves (in_place0.651→0.936,retract0.639→0.935),mixed_version_ratio == 0throughout, and equivalent step time (~0.8 s per broadcast). At this concurrency the router must be sized for the eval burst (--router-queue-size/--router-queue-timeout-secs, circuit breaker off) — with default limits the burst 503s past the client retry budget.tests/fastgreen; e2etests/e2e/short/test_qwen2.5_0.5B_fully_async_eval.pygreen on 8×H200.Stacked on #1717.