feat(sglang): engine fault tolerance for rollouts - #3613
Conversation
0c651a6 to
40a296c
Compare
Kh4L
left a comment
There was a problem hiding this comment.
Self-review (/review-pr-team), per the contributor self-review policy
Ran the four reviewer roles the skill specifies — rl-expert, bug-finder, design-reviewer, test-agent — as a real agent team this time (CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1), so the mandatory devil's-advocate challenge could interrogate the reporting agent across both rounds instead of re-deriving findings alone. Scoped to this PR's increment only, not the refit layer below it.
Three blockers, all reproduced by executing the real source rather than by reading it, all fixed in 5314f53 and be50156. Every one of them is invisible today, because all five shipped sglang recipes set use_fault_tolerance: false and nothing in CI turns it on. The feature as submitted could not be enabled, and would have been broken once enabled.
1. _recover disarmed the weight refit on every refit — silently, on the colocated path
_recover ran unconditionally, including the overwhelmingly common case where nothing died. _start_engines rewrites num_new_engines regardless of whether the previous value has been consumed, so a no-op recovery reset the count to 0 before _refit read it. That count is the only gate on _connect, and _connect is the only place the trainer-side transport is ever built.
The consequence differs by transport, and the difference matters:
- Colocated (
weight_transfer_mode: ipc, what all five shipped recipes use) — silent.send_hf_buckets_via_ipc_actor_implreads_ipc_gather_group/_ipc_gather_src/_ipc_engine_indexout of aworker_statethatconnect_colocate_topologynever populated, finds all threeNone, and takes the placeholder-rank early return on every rank. Training then continues against stale rollout weights, forever, with no error anywhere and no metric that would show it. - Disaggregated (
broadcast) — loud. Rank 0 raisesRuntimeError("connect_sglang_rollout_engines_distributed must be called before ...").
Fixed by returning early from _recover when dead_indices is empty.
Worth recording that the first proposed fix — num_new_engines += len(...) at the _start_engines write site — was wrong, and the devil's advocate caught it: __init__ leaves the count at N, the first _recover adds 0, and the assert self.num_new_engines == len(dead_indices) immediately below then fires. The agent that proposed it withdrew it.
2. The boot grace period made the monitor never probe anything
_need_first_wait was re-armed on every resume(). resume() runs once per training step, so any generation phase shorter than rollout_health_check_first_wait (default 60s) left the monitor permanently inside its grace period. Enabled, and dead — it would never health-check an engine, which is the entire feature.
Replaced with an absolute monotonic deadline armed at start() and after a recovery, never reset by resume(). Time spent paused now counts toward it, and a pause part-way through no longer restarts the clock. Restarted engines re-arm it explicitly, since a freshly booted SGLang server is still loading weights and would be killed by a probe.
Over a representative generate/pause cycle, probes went 0 → 21. The unit-test version of that scenario sees 0 → 2.
3. Flipping use_fault_tolerance: true in any shipped recipe raised KeyError
Config and code disagreed in both directions:
use_fault_tolerancewas declared required, but both readers use.get()and three of the five shipped sglang recipes never set it. The declaration was unenforced —PolicyConfig.generationis typed as the baseGenerationConfig, which has nosglang_cfgfield, sotest_config_validation.pynever reachesSglangSpecificArgsat all — but it documented a contract the recipes do not honor. NowNotRequired[bool], matchinguse_external_routerthree declarations above it.- The three
rollout_health_check_*knobs were declaredNotRequiredand read with bare subscripts. Every sglang recipe inheritsgrpo_math_1B.yaml, which carries no sglang keys, so none of them pick these up fromgrpo_math_1B_sglang.yaml. Turning the feature on therefore raisedKeyError: 'rollout_health_check_interval'from a daemon-thread constructor. They stayNotRequired— they genuinely only matter when the feature is on — butRolloutHealthMonitornow asserts up front, naming every missing key and where the documented values live. This mirrors the existingsglang_router_ip/sglang_router_porthandling, which is conditionally required the same way.
Tests
Three regressions added, all of which fail against the pre-fix source and pass after: the missing-key message, _recover leaving num_new_engines alone when nothing died, and _recover re-arming the grace period for restarted engines. test_first_wait_delays_checks_after_resume is renamed to test_first_wait_delays_the_initial_checks — it asserted semantics the fix deliberately removed, and it passed both before and after, so it was never load-bearing.
What the adversarial pass rejected
- A claim that
SGLANG_ENABLE_HEALTH_ENDPOINT_GENERATIONmust be set for/health_generateto exist — disputed at confidence 99 and dropped. It would have sent the author chasing a non-issue. - The unguarded
self._health_monitor.pause()inrecover_updatable_engineswas reported as a null-deref and downgraded to a nit: its only caller is already behind the sameuse_fault_tolerancegate that creates the monitor, so it is unreachable. Left as the one inline comment below rather than changed unilaterally.
Environment caveats
No GPU on the review machine and no sglang extra, so nothing runtime-dependent was executed against a real engine. The three blockers were instead reproduced by loading the real fault_tolerance.py and the real SGLangGeneration._recover bytes with ray stubbed — that is stronger than reading, weaker than a GPU run. pytest could not be run locally (no ray/torch/omegaconf, no network to install them); the new tests were validated by executing their bodies against both the pre-fix and post-fix source. A GPU re-run with use_fault_tolerance: true would be the real confirmation, and is worth doing before merge.
Generated by Claude Code
|
Addendum to the self-review above — a fourth defect, found after posting.
I originally judged this path unreachable and left it out. That was wrong. The join budget is Reproduced by driving fixed in 00b8af6 Severity is moderate, not blocker-grade: a stray traceback from a daemon thread during teardown, not wrong training results. Flagging it rather than folding it in silently, since the review above said three findings. |
40d161c to
ba3f5af
Compare
ba3f5af to
4ba11c5
Compare
4ba11c5 to
487027b
Compare
|
/ok to test 487027b |
487027b to
25f3a01
Compare
|
/ok to test 25f3a01 |
A hung or dead SGLang engine currently wedges the whole run: the refit's `ray.get` on its actor never returns, and the router keeps sending `/generate` to it. Add `RolloutHealthMonitor`, a daemon thread that polls `/health_generate` on every node-0 engine and, on failure, kills and restarts the actor. The refit picks the survivors up through the engine registry this PR's base already exposes (`get_updatable_engines_and_lock`), so a recovered engine is reconnected on the next weight update. The monitor must not run while the engines are offloaded: `/health_generate` always executes a real one-token generation (the `SGLANG_ENABLE_HEALTH_ENDPOINT_GENERATION=false` bypass only covers `/health`), so probing a released engine reports a false failure and kills a live actor. It is therefore wired to the generation lifecycle — resumed by `prepare_for_generation` once the KV cache is back, paused by `finish_generation` and for the duration of a refit. `pause()` blocks on an in-flight probe rather than only setting a flag, so a probe cannot overlap `release_memory_occupation`. `_kill_engine` bounds its graceful-shutdown `ray.get` and kills the actor even when that shutdown fails, which is the exact case the monitor exists to handle. Signed-off-by: zhihaow6 <zhihaow6@illinois.edu> (cherry picked from commit f563fa9)
…grace Two defects made the fault-tolerance path unusable once enabled. Both are invisible today because every shipped recipe sets `use_fault_tolerance: false`, so `_recover` is never reached. `_recover` ran unconditionally on every refit, including the common case where nothing died. `_start_engines` rewrites `num_new_engines` without regard for whether the previous value has been consumed, so the no-op recovery reset the count to 0 before the refit read it. That count is the only gate on `_connect` in the weight synchronizer, and `_connect` is the only place the trainer-side transport is ever built. What that costs depends on the transport. Colocated (`weight_transfer_mode: ipc`, which is what all five shipped sglang recipes use) fails silently: `send_hf_buckets_via_ipc_actor_impl` reads `_ipc_gather_group` / `_ipc_gather_src` / `_ipc_engine_index` out of a `worker_state` that `connect_colocate_topology` never populated, finds all three None, and takes the placeholder-rank early return on every rank. Training then continues against stale rollout weights with no error anywhere. Disaggregated (`broadcast`) at least fails loudly, since rank 0 raises RuntimeError from `update_weights_to_sglang_distributed`. Return early when there are no dead engines. The boot grace period was a `_need_first_wait` boolean re-armed on every `resume()`. `resume()` runs once per training step, so any generation phase shorter than `rollout_health_check_first_wait` left the monitor permanently inside its grace period and no probe ever ran -- the feature was enabled but dead. Replace the flag with an absolute monotonic deadline that is armed at `start()` and after a recovery, and is not reset by `resume()`. Time spent paused now counts toward the deadline, and a pause part-way through no longer restarts the clock. Restarted engines re-arm it explicitly, since a freshly booted server is still loading weights and a probe would kill it. Verified by executing the real source: `num_new_engines` survives at 4 across a no-op recovery, and health probes over a representative generate/pause cycle went from 0 to 21. Signed-off-by: Serge Panev <spanev@nvidia.com>
`use_fault_tolerance` was declared required while both readers used `.get()`, and three of the five shipped sglang recipes never set it at all. The declaration was unenforced -- `PolicyConfig.generation` is typed as the base `GenerationConfig`, which has no `sglang_cfg` field, so `test_config_validation.py` never validates `SglangSpecificArgs` -- but it documented a contract the recipes do not honor. Declare it `NotRequired[bool]`, matching `use_external_router` in the same file and the `.get()` truthiness reads that already exist. The three `rollout_health_check_*` knobs had the inverse problem: declared `NotRequired`, read with bare subscripts. Every sglang recipe inherits `grpo_math_1B.yaml`, which carries no sglang keys, so none of them pick the knobs up from the sglang exemplar -- flipping `use_fault_tolerance: true` in any shipped recipe raised `KeyError: 'rollout_health_check_interval'` from a daemon-thread constructor. They stay `NotRequired`, since they only matter when the feature is on, but `RolloutHealthMonitor` now asserts on them up front and names every missing key plus where the documented values live. This follows the existing precedent for `sglang_router_ip`/`sglang_router_port`, which are conditionally required in the same way. Tests cover all three blockers fixed on this branch: the missing-key message, `_recover` leaving `num_new_engines` alone when nothing died, and `_recover` re-arming the boot grace period for restarted engines. Against the pre-fix source those three fail (KeyError, `_start_engines` invoked, no re-arm) and the first-wait regression sees 0 probes where the fixed code sees 2. `test_first_wait_delays_checks_after_resume` is renamed to `test_first_wait_delays_the_initial_checks`: the grace period is armed at `start()` now, not on every resume. Signed-off-by: Serge Panev <spanev@nvidia.com>
…hread When the join times out, `stop` logged a warning and then cleared `_thread`, `_stop_event` and `_pause_event` regardless. The thread it failed to reap is still inside `_health_monitor_loop`, so its next iteration dereferenced the now-`None` event and the thread died with `AttributeError: 'NoneType' object has no attribute 'wait'` at the `self._stop_event.wait(self._check_interval)` at the bottom of the loop. The join budget is not generous enough to treat that path as unreachable. It is `timeout + interval + 5`, but a single probe is bounded at `2 * timeout` by the outer `ray.get`, and a probe that times out then calls `_kill_engine`, which spends up to another `timeout` on the graceful `shutdown` before `ray.kill`. With the shipped 60s timeout that is up to 180s of work against a 125s budget -- and it happens exactly when an engine is hung, which is the case the feature exists for. Clear `_is_checking_enabled` either way, since checking really has stopped, but leave the events in place when the thread outlived the join so it can observe the set stop event and exit on its own. The regression test drives `stop` against an in-flight health check that outlasts the join. Against the pre-fix source it fails on the first assertion and the monitor thread raises the AttributeError above; after the fix the thread exits cleanly and `threading.excepthook` records nothing. It cannot run faster than the join's fixed +5s floor. Signed-off-by: Serge Panev <spanev@nvidia.com>
The pinned SGLang server replaced disable_piecewise_cuda_graph with per-phase graph backends. The real fault-tolerance smoke already disables CUDA graphs entirely, so remove the stale argument that would otherwise fail ServerArgs construction. Signed-off-by: Serge Panev <spanev@nvidia.com>
25f3a01 to
0ae89f8
Compare
|
/ok to test 0ae89f8 |
|
HSG real-GPU validation passed at
|
yuki-97
left a comment
There was a problem hiding this comment.
Re-cut of #3187, so I re-checked all 14 of my review threads there against this tree. This pass covers the #3187 carry-over only; findings on the new code are held for a follow-up pass.
#3187 carry-over
11 of the 14 are addressed here: the module logger, the ray.get-level probe timeout, the config placement, both test asks, and the four "where is this called" questions.
One is now obsolete — please treat it as closed. r3655023291 asked for rollout_engine_lock to be built only under use_fault_tolerance, the way _health_monitor is. Since #3612 merged, the lock is unconditional on main and every refit acquires it (policy/utils.py:1071), fault tolerance or not — gating it would break the non-FT refit path.
Two still apply, one comment each:
- r3654957493 —
use_fault_tolerancewas applied as required, then reverted toNotRequiredbyaeb93c081. The revert rests on the shipped recipes not picking the knobs up from the sglang exemplar, and they do: all six chain to grpo_math_1B_sglang.yaml:42-45. Comment onconfig.py, listing the three places whose wording rests on that claim. - r3654976078, second half — recovery is reachable only from
_refit, so an engine that dies during generation is not restarted until the next one. Comment onsglang_weight_synchronizer.py.
Nothing here blocks the GPU evidence: HSG 6556006 and the green CI on this exact head both stand.
| # Restart engines that died since the last refit, so the topology read | ||
| # below reflects the survivors. No-op when nothing died. | ||
| if self._generation.sglang_cfg["sglang_cfg"].get("use_fault_tolerance"): | ||
| self._generation.recover_updatable_engines() |
There was a problem hiding this comment.
Carried over from the #3187 review (#3187 (comment)), second half — still open.
This is the only production caller of recover_updatable_engines, so recovery is reachable only from _refit: an engine that dies during generation stays dead until the next one.
The in-between state is not benign. _kill_engine sets the slot to None (fault_tolerance.py:268), and the lifecycle calls filter e is not None (sglang_generation.py:406-427), so those degrade to the survivors. Generation does not go through them — it POSTs to the router, which is never told the engine is gone. A request that does fail is retried 3x and then re-raised (http_utils.py:208), and the asyncio.gather at sglang_generation.py:657 has no return_exceptions, so it propagates out of generate() and takes the training step down.
There was a problem hiding this comment.
Working through where recovery can actually run, the reachable cases come out like this. Only _kill_engine ever sets all_engines[i] = None (fault_tolerance.py:268), and the monitor only probes between prepare_for_generation and finish_generation, so detection and survival need different things from the same window:
| engine dies | monitor probing? | outcome |
|---|---|---|
| generation window, nothing in flight | yes | detected, restarted at the next refit — the case test_fault_tolerance_real.py constructs |
| generation window, requests in flight | yes | depends on the router, which I have not verified — could you confirm what the installed sglang-router does with requests already dispatched to the dead engine? |
| training, or the refit's weights-only stage | no | never detected: the slot stays non-None, _recover finds dead_indices empty and returns, and the refit then broadcasts to a dead handle. No RayActorError handling on that path |
Row 1 is the one the design actually handles, and it is also the least likely to occur: it needs the engine to die inside the generation window and with nothing in flight. During generation the engines are busy, and outside it the monitor is paused.
Is that the intended scope for this cut? The PR description — "a health monitor that detects a dead SGLang engine, restarts it, and rejoins it to the refit weight-update group" — reads as covering all three, and the HSG run backing it crashes the engine with _simulate_crash, which is row 1.
On row 3, to be clear that the gap is structural rather than an oversight: the probe is health_generate, a real generation request, so it only means anything against an engine that can serve. finish_generation has released the weights and KV cache, and the refit's weights-only stage is mid-write — a probe in either window fails, and a failed probe is _kill_engine, so an unpaused monitor would kill healthy engines every training phase. The pause is load-bearing, which is exactly why row 3 cannot be closed by dropping it. It needs a liveness signal that does not depend on serving — Ray already reports actor death, and that holds while offloaded and mid-refit alike, with health_generate left to answer the narrower "can it still serve" question.
| # each engine and restarts hung/dead actors. Absent is equivalent to False, which | ||
| # is what the recipes that predate this feature rely on; the three fields below | ||
| # are required iff it is True, and ``RolloutHealthMonitor`` asserts on them. | ||
| use_fault_tolerance: NotRequired[bool] |
There was a problem hiding this comment.
Every sglang recipe resolves this key. All six chain to grpo_math_1B_sglang.yaml:42-45 — three inherit it directly, three through a sibling recipe — and ec7e0a6cc is the commit that adds use_fault_tolerance: false plus the three knobs at 60 there. Nothing in the tree relies on the key being absent, so declare it required and let the exemplar be the single source of the default.
| use_fault_tolerance: NotRequired[bool] | |
| use_fault_tolerance: bool |
The three knobs below can stay NotRequired — they only matter when this is on, and the constructor asserts on them. Both readers can keep their bare .get(), which is what lets a pre-existing out-of-tree config load with fault tolerance off.
Three comments justify the current shape with the opposite claim, and need updating with it:
- lines 101-104, directly above — "Absent is equivalent to False, which is what the recipes that predate this feature rely on".
- fault_tolerance.py:46-49 — "the recipes that ship with it off do not carry them". It also says a missing key would be "a bare KeyError from a thread", but the monitor is constructed at sglang_generation.py:145 on the calling thread and
start()runs after it, so that would be an ordinary init-timeKeyErrorwith a full traceback. - test_fault_tolerance.py:488-491 — the
test_monitor_names_the_missing_tuning_keysdocstring. Every sglang recipe does inheritgrpo_math_1B.yaml, transitively, but the chain runs throughgrpo_math_1B_sglang.yaml, so "flippinguse_fault_tolerance: truein one of them" does not reach the assert. The scenario the test covers is a hand-written or out-of-tree config.
The assert itself is worth keeping either way: it names all three missing keys at once where a KeyError names only the first. Only the stated reason for it is wrong.
yuki-97
left a comment
There was a problem hiding this comment.
Scope: the code this PR adds on top of #3612 — fault_tolerance.py, the recovery path in sglang_generation.py, and its single wiring point in sglang_weight_synchronizer.py. Separate pass from the #3187 carry-over review I just submitted.
The one thing worth resolving before the rest: #3613 (comment) works through when recovery is actually reachable, and lands on three cases.
- Row 1 — the engine dies inside the generation window with nothing in flight — is the one the design handles, and it is what the GPU run exercises.
- Row 2, a death with requests in flight, is unresolved, and it decides how much this feature is worth: on our side nothing tolerates a failed request, so whether the run survives rests entirely on the router re-routing those requests, which I could not confirm for the installed
sglang-routerversion. If it does not, then the only deaths this feature recovers from are the ones that were not going to take the run down anyway — which is close to no coverage at all. Please check that against a version you can verify. - Row 3, a death while the monitor is paused, is never detected.
If row 2 does re-route, row 2 is the case a functional test should pin — see #3613 (comment).
Also checked:
- Router registration lifecycle across kill and restart — followed
DELETE /workers/{id}to its only call site. Same version gap as above: the router's retry, health-check and circuit-breaker defaults differ between the sglang rev this branch pins and the PyPI package it installs, so that comment states the asymmetry and leaves the severity to you. - Test coverage of the new wiring, against CI's own coverage report at this head. This includes
_recover's offload/onload block, uncovered on both tiers: I walked the sequence againstprepare_for_generation's tag gating and it is correct. - Production consumers of every new symbol — writes vs reads, source vs tests.
- Restart policy against the existing
fleet_health.pymodel. _recover's remaining guards: the redundantand dead_indicesand the assert that cannot fire by construction are both harmless. The innerhealth_generatetimeout is passed but never asserted; only the outer one is covered.
The remaining comments are lower severity — one [TEST], a test-only method on the production worker, two symbols with no production consumer, an outdated comment, an unbounded restart loop, a return-shape question, and a .get() nit contingent on the use_fault_tolerance: bool change in the carry-over review.
| try: | ||
| ray.get(engine.shutdown.remote(), timeout=self._check_timeout) | ||
| except Exception as e: | ||
| logger.warning( |
There was a problem hiding this comment.
shutdown issues DELETE /workers/{id} before killing the process (sglang_worker.py:239-253); this path does not. A failed health probe is exactly when shutdown() is wedged, and ray.kill does not deregister, so an engine the monitor kills stays in the router's worker table and eviction is left to the router's own health check. The HSG run does not exercise this either — _simulate_crash is self.shutdown(), so its engine deregisters on the way out.
Suggest fix: send DELETE /workers/{id} from the monitor before ray.kill, so both teardown paths leave the same router state.
What it costs at runtime I could not settle. _start_router passes a bare RouterArgs(), and at the sglang rev this branch pins those defaults carry retry_max_retries=5 with per-attempt worker re-selection plus a circuit breaker, which would absorb most of a stale entry — but the installed package is PyPI sglang-router==0.3.2, which is not that tree. If you know how that version behaves, it decides whether this is cosmetic.
|
|
||
| # Restart engines that died since the last refit, so the topology read | ||
| # below reflects the survivors. No-op when nothing died. | ||
| if self._generation.sglang_cfg["sglang_cfg"].get("use_fault_tolerance"): |
There was a problem hiding this comment.
[TEST] The use_fault_tolerance: true arm here is executed by no test. CI's own coverage report at this head names line 137 as one of only two uncovered statements in the file: sglang_weight_synchronizer.py 101 2 98% 96, 137.
_mock_sglang_generation (test_weight_synchronizer.py:245) builds sglang_cfg as a plain dict without the key, so all 20 synchronizer tests take the false branch, and the GPU test calls recover_updatable_engines() directly rather than through sync_weights.
The monitor and _recover themselves are well covered; this is the one line that wires them into the refit path, so it can break with every existing test still green. If it regressed, the next refit dies with AttributeError: 'NoneType' object has no attribute 'update_weights_from_tensor' inside policy/utils.py — nowhere near anything named fault tolerance.
Suggest fix: give _mock_sglang_generation a use_fault_tolerance kwarg and assert recover_updatable_engines runs before get_updatable_engines_and_lock. It is already a MagicMock, so no fixture change is needed.
|
|
||
| self._recover() | ||
|
|
||
| return ( |
There was a problem hiding this comment.
This 5-tuple is returned to nobody. The only production caller discards it (sglang_weight_synchronizer.py:137) and re-reads the identical tuple eight lines later via get_updatable_engines_and_lock (:145).
The two return expressions are textually identical, and _recover is synchronous with the monitor paused throughout, so the two reads cannot differ. Only test_recover_updatable_engines_reports_engine_state consumes it, which pins a shape nothing else uses.
Suggest fix: annotate -> None and end the method at self._recover(); the sibling test just below already covers num_new_engines surviving a no-op recovery.
| @@ -139,6 +141,11 @@ def __init__( | |||
| # when recovery support lands in #3613. | |||
There was a problem hiding this comment.
Outdated — this is #3613, and recovery never takes this lock. The only acquire/release is policy/utils.py:1071 / :1104, trainer rank 0 only. Suggest:
# Serializes weight-update broadcasts. Acquired per bucket by trainer
# rank 0 in policy/utils.py; nothing on the generation side takes it.Same correction applies to that function's docstring (policy/utils.py:1050-1052): it credits the lock with keeping health-check pings off the broadcast, but that comes from the monitor's pause.
| ] | ||
| ) | ||
|
|
||
| def _recover(self) -> None: |
There was a problem hiding this comment.
An engine that boots fine and then dies again every step is restarted forever — there is no attempt cap and no floor on surviving engines.
A hard failure propagates out of _refit and ends the run, so that case is covered. The unbounded one is a flaky node: every step pays a full actor create, weight load and _connect NCCL rebuild, with no escalation and no abort. fleet_health.py already models this — max_restart_attempts_per_shard / min_healthy_shards (:105-106), the check at :304, and a terminal RETIRED state. Reusing it, or just capping and aborting here, would avoid a silent throughput cliff.
| # when recovery support lands in #3613. | ||
| self.rollout_engine_lock = Lock.options(num_cpus=0, num_gpus=0).remote() | ||
|
|
||
| if sglang_cfg["sglang_cfg"].get("use_fault_tolerance"): |
There was a problem hiding this comment.
Nit, and only if you take the use_fault_tolerance: bool change in #3613 (comment): once the field is required, read it directly. config-conventions allows a bare .get(key) precisely for NotRequired fields, and asks for direct access once a key is required.
| if sglang_cfg["sglang_cfg"].get("use_fault_tolerance"): | |
| if sglang_cfg["sglang_cfg"]["use_fault_tolerance"]: |
Same read at sglang_weight_synchronizer.py:136.
This supersedes the "both readers can keep their bare .get()" line in that thread — I hedged it for out-of-tree configs, but the exemplar is what supplies the default, and the convention is explicit that the call site should not carry one.
| def is_checking_enabled(self) -> bool: | ||
| """Return whether health checking is currently enabled (not paused).""" | ||
| return self._is_checking_enabled | ||
|
|
There was a problem hiding this comment.
Dead state, both halves. The accessor is read only by the two new test files, and once it goes the field itself has four writes and no reader at all.
It is also derivable: checking is enabled exactly when _pause_event is cleared and _stop_event is not set. That holds at every write site — start leaves the pause event set, resume clears it, pause sets it, and stop clears the pause event but sets the stop event first. test_stop_leaves_events_intact_when_the_join_times_out shows this directly: the line above its is_checking_enabled() assertion already asserts _stop_event.is_set(), which is what makes the flag False.
Suggest dropping the accessor here, plus self._is_checking_enabled and its four writes (lines 73, 131, 157, 170). The tests already assert on _thread, _stop_event and _pause_event, so they can assert the two events instead.
| def is_checking_enabled(self) -> bool: | |
| """Return whether health checking is currently enabled (not paused).""" | |
| return self._is_checking_enabled |
| # Restart engines that died since the last refit, so the topology read | ||
| # below reflects the survivors. No-op when nothing died. | ||
| if self._generation.sglang_cfg["sglang_cfg"].get("use_fault_tolerance"): | ||
| self._generation.recover_updatable_engines() |
There was a problem hiding this comment.
Working through where recovery can actually run, the reachable cases come out like this. Only _kill_engine ever sets all_engines[i] = None (fault_tolerance.py:268), and the monitor only probes between prepare_for_generation and finish_generation, so detection and survival need different things from the same window:
| engine dies | monitor probing? | outcome |
|---|---|---|
| generation window, nothing in flight | yes | detected, restarted at the next refit — the case test_fault_tolerance_real.py constructs |
| generation window, requests in flight | yes | depends on the router, which I have not verified — could you confirm what the installed sglang-router does with requests already dispatched to the dead engine? |
| training, or the refit's weights-only stage | no | never detected: the slot stays non-None, _recover finds dead_indices empty and returns, and the refit then broadcasts to a dead handle. No RayActorError handling on that path |
Row 1 is the one the design actually handles, and it is also the least likely to occur: it needs the engine to die inside the generation window and with nothing in flight. During generation the engines are busy, and outside it the monitor is paused.
Is that the intended scope for this cut? The PR description — "a health monitor that detects a dead SGLang engine, restarts it, and rejoins it to the refit weight-update group" — reads as covering all three, and the HSG run backing it crashes the engine with _simulate_crash, which is row 1.
On row 3, to be clear that the gap is structural rather than an oversight: the probe is health_generate, a real generation request, so it only means anything against an engine that can serve. finish_generation has released the weights and KV cache, and the refit's weights-only stage is mid-write — a probe in either window fails, and a failed probe is _kill_engine, so an unpaused monitor would kill healthy engines every training phase. The pause is load-bearing, which is exactly why row 3 cannot be closed by dropping it. It needs a liveness signal that does not depend on serving — Ray already reports actor death, and that holds while offloaded and mid-refit alike, with health_generate left to answer the narrower "can it still serve" question.
|
|
||
| ( | ||
| rollout_engines, | ||
| _rollout_engine_lock, |
There was a problem hiding this comment.
Which of these two is intended? The lock comes back in the tuple and is discarded here, and then _send_buckets reads it off the generation object instead (:288):
rollout_engine_lock=self._generation.rollout_engine_lock,- If the attribute read is the intended source, the lock should come out of the return shape — a method named
get_updatable_engines_and_lockwhose only caller throws the lock away is misleading, and the same slot is dead inrecover_updatable_enginestoo. - If the returned one is meant to be used, drop the leading underscore and pass it into
_send_bucketsrather than re-reading the attribute.
They are the same object today, so this is a readability question rather than a bug. Related to the return-shape comment on sglang_generation.py.
| num_gpus_per_engine: ${policy.generation.sglang_cfg.tp_size} | ||
| sglang_router_config: | ||
| use_external_router: false | ||
| # Fault tolerance (RolloutHealthMonitor). Off by default; when enabled, |
There was a problem hiding this comment.
would be good to add a functional test to show this feature works well, like tests/functional/grpo_dp_single_controller_chaos.sh.
it depends on #3613 (comment) though — of the three cases there, row 1 is what the GPU test already covers, row 3 isn't implemented, and row 2's expected outcome isn't settled. once we know whether a mid-generation death is supposed to survive, the test can assert that, the same way the SC lane splits EXPECT=survival from the bounded-failure variant.
Original work: #3187 by @xiuhu17. Re-cut at his request so it can be carried through review on his behalf. His commit is preserved with its original authorship and
Signed-off-by.1 of 3 remaining in the stack.
this → #3614 (mxfp8) → #3615 (nvfp4). #3612 has merged, and this branch is rebased directly onto the resultingmain. Review the five commits on top ofmain.What it adds over #3612
Engine fault tolerance for rollouts: a health monitor that detects a dead SGLang engine, restarts it, and rejoins it to the refit weight-update group, plus a
_simulate_crashhook so the path can be exercised deliberately.use_fault_tolerancegates it and defaults off.The follow-up fixes keep refit armed across recovery, align the configuration with its reader, make
stop()safe while the monitor thread is live, and remove the obsoletedisable_piecewise_cuda_graphserver argument.Verification
Full CI is green on exact head
0ae89f8b1f8484ee82cebf6613bb10342d08be9a, including unit, functional, H100, and GB200 jobs.HSG job
6556006passed the real GPU fault-tolerance path on that exact head: it launched SGLang, deliberately crashed an engine, recovered it, and completed post-recovery generation. Decode CUDA graphs remained enabled; only prefill capture was disabled. Result:PR3613_FAULT_TOLERANCE_REAL_GRAPH_OK job=6556006 commit=0ae89f8b1f8484ee82cebf6613bb10342d08be9a.