Skip to content

eval: checkpoint-interfaced evaluation for fully-async training (dedicated fleet / pause-the-world / external service)#1740

Open
Zhichenzzz wants to merge 33 commits into
yueming/fully-async-class-apifrom
zhichen/fully-async-eval
Open

eval: checkpoint-interfaced evaluation for fully-async training (dedicated fleet / pause-the-world / external service)#1740
Zhichenzzz wants to merge 33 commits into
yueming/fully-async-class-apifrom
zhichen/fully-async-eval

Conversation

@Zhichenzzz

@Zhichenzzz Zhichenzzz commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

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:

Test run Real run (ckpts saved anyway)
No standalone eval Pause-the-world: eval shares the rollout engines; the producer pauses new submissions for the duration of the eval External service watches --save-hf output outside the job; pause-the-world if points must be strictly on-time
Standalone eval fleet Fleet + tmpfs snapshots: --eval-num-gpus N --eval-hf-dir /dev/shm/... — export is seconds, never disk-bound Fleet + checkpoint reuse: --eval-num-gpus N with no --eval-hf-dir — the snapshot IS the periodic --save-hf checkpoint, zero extra export

All four cells are flag composition over the same controller (pin / verify / skip / GC).

Semantics

  • Fire-and-forget: the trainer exports a snapshot and keeps training; the point lands at the dispatch-time step even when it completes later (eval/lag_steps reports how late).
  • Version pinning: the engine-stamped weight_version is verified after every load; eval/{ds}/weight_version/mean == eval/step and mixed_version_ratio == 0 prove each point measured exactly the intended weights.
  • Where the weights come from: pause-the-world needs no weight movement — training's own per-step update_weights broadcast 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 is a gate, not a retract: shared-engine eval only stops new producer submissions; in-flight rollout requests finish and buffer (no aborts, no wasted tokens). The generation pause around each weight broadcast (--pause-generation-mode) is training's per-step business — eval adds no pause, retract, or weight sync of its own, and works under both in_place and retract pause modes (retract additionally 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).
  • Training is never stalled: every failure mode — unhealthy fleet, incomplete snapshot, pin violation, crashed eval, overflow — degrades to a skipped point logged at that step (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).
  • Self-healing without FT: before each eval the controller probes every fleet engine, marks unreachable ones stopped, recovers them, and re-probes the router end-to-end (one-token generate) before dispatching. A zombie engine costs one skipped point, bounded by a per-attempt weight-load timeout — never a stalled trainer.

What's in the PR

  • miles/rollout/checkpoint_eval.py — retargeted-args helpers + EvalFleetSession (lazy eval-fleet state shared by both rollout fns)
  • FullyAsyncRolloutFn eval support — dedicated fleet when configured, pause-the-world otherwise
  • miles/ray/rollout/eval_dispatch.py — driver-side EvalDispatcher (bounded in-flight, overflow policy, drain-before-dispose)
  • RolloutManager._eval_on_dedicated_fleet — the pin/verify/skip/GC controller
  • export_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 .complete marker
  • tools/checkpoint_eval_service.py — standalone watcher with its own sglang server: marker/quiescence-gated snapshot discovery, idempotent ledger for restart/backfill, --catchup {all,latest}, --once
  • New args + validation matrix, docs (docs/user-guide/fully-async.md), example script, e2e CI test, ~25 unit tests

Verification (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):

  • Fleet + tmpfs: all 5 eval points landed pinned, gsm8k 0.640 → 0.882 over 20 steps, lag_steps ≤ 11, training uninterrupted
  • Fleet + reuse: zero-extra-export points 0.641 → 0.847; overflow under skip policy observed and attributed (eval/skipped_busy at the affected step)
  • Pause-the-world: producer pause/resume verified, points pinned with mixed_version_ratio == 0
  • External service: backfilled every checkpoint after the run; on the same checkpoint the service score matched the in-job score exactly

Kill 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-hf persistence, pause-the-world gsm8k eval): completed under both pause modes with statistically identical curves (in_place 0.651→0.936, retract 0.639→0.935), mixed_version_ratio == 0 throughout, 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/fast green; e2e tests/e2e/short/test_qwen2.5_0.5B_fully_async_eval.py green on 8×H200.

Stacked on #1717.

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.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread miles/backends/megatron_utils/model.py Outdated
Comment on lines +886 to +892
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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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.

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

Comment thread miles/utils/arguments.py
Comment on lines +2529 to +2559
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."
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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
  1. Use ValueError instead of assert for validating function or constructor arguments.

Comment thread tools/checkpoint_eval_service.py Outdated
Comment on lines +216 to +220
try:
await get(f"http://{ip}:{port}/health_generate", max_retries=1)
return
except Exception:
await asyncio.sleep(5)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Suggested change
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
  1. In health check endpoints or probes, avoid catching broad exceptions (like Exception). Only catch expected network, timeout, or unavailability exceptions.

Comment thread tools/checkpoint_eval_service.py Outdated
Comment on lines +237 to +239
assert (
str(info.get("weight_version")) == weight_version
), f"weight_version pin failed: engine reports {info.get('weight_version')}, expected {weight_version}"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Suggested change
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}"
)

Comment thread tools/checkpoint_eval_service.py Outdated
Comment on lines +253 to +254
if args.wandb_mode == "shared":
assert args.wandb_run_id, "--wandb-mode shared requires --wandb-run-id of the training run"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Use ValueError instead of assert for validating command-line arguments or configurations to prevent checks from being stripped under Python optimization (-O).

Suggested change
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
  1. Use ValueError instead of assert for validating function or constructor arguments.

Comment thread tools/checkpoint_eval_service.py Outdated
Comment on lines +261 to +262
watch_dir = Path(service_args.watch_dir)
assert watch_dir.is_dir(), f"--watch-dir {watch_dir} does not exist"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Use FileNotFoundError instead of assert for validating file or directory existence to prevent checks from being stripped under Python optimization (-O).

Suggested change
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
  1. 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
@Zhichenzzz Zhichenzzz changed the title Fully-async eval: dedicated eval fleet pinned to HF checkpoint snapshots eval: checkpoint-interfaced evaluation for fully-async training (dedicated fleet / pause-the-world / external service) Jul 22, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant