Skip to content

fix(sglang): rebind checkpoint-engine refit peers across engine restarts - #3944

Draft
Kh4L wants to merge 27 commits into
NVIDIA-NeMo:mainfrom
Kh4L:feat/sglang-ckpt-engine-restart-rebind
Draft

fix(sglang): rebind checkpoint-engine refit peers across engine restarts#3944
Kh4L wants to merge 27 commits into
NVIDIA-NeMo:mainfrom
Kh4L:feat/sglang-ckpt-engine-restart-rebind

Conversation

@Kh4L

@Kh4L Kh4L commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

What

When SGLang's fault tolerance (#3613) restarts a crashed rollout engine, the checkpoint-engine refit transport (#3519) must not keep streaming weights to the dead peer. This PR makes the two features compose: the weight synchronizer detects recovered engines every refit, restarts dead ones atomically, and rebinds the paired NIXL fabric to only the replaced peers — survivors' connections are reused, not rebuilt.

Stacked PR — draft until #3519 and #3613 merge. The diff vs main includes their commits. Ours are the top five:
85d206865 (rebind implementation) · 4cd90f733 (4-GPU functional test) · a9985c138 (failure-path contracts) · 08e0fe5bc + c159bec92 (review-driven test hardening).

Failure contracts

  1. Recovery failure → atomic cohort rollback (retryable). _recover treats everything from _start_engines through the offload transition as one attempt: on failure the replacement cohort is gracefully shut down (router deregistration + server process tree, bounded by the health monitor's timeout), slots return to None, and the unconsumed engine count is restored. A failed rollback escalates to RecoveryRollbackError (terminal).
  2. Rebind failure → terminal. NIXL prepare()/add_remote_agent() are not transactional; retrying over partial state silently skips or double-registers. A failed (re)bind latches _terminal_error, and every later sync raises before issuing a single RPC.
  3. Transfer/end_weight_update failure after the first bucket moved → terminal, no resume. This deliberately diverges from the NCCL sibling's finally-resume: an interrupted NCCL broadcast can be redone next refit, interrupted one-sided NIXL work cannot, so serving is not resumed over a half-updated model. Public shutdown() also honors the latch (zero finalize RPCs over unknown transport state).

Setup-time guards: fault tolerance requires the built-in nixl backend, and the transport is BF16-only (quantized schemes are rejected before any transfer).

Validation

Layer Evidence
Unit (144 passed / 4 CUDA-skipped) mid-start + offload-transition rollback (nonzero pre-attempt count, bounded graceful shutdown), retryable-vs-terminal state machine, zero-RPC terminal retries asserted via direct lifecycle mocks, factory guards mutation-tested, fake-NIXL dead-peer replacement + 2×2 survivor-reconnect
Functional (4×GB200, aarch64) test_checkpoint_engine_recovery_real.py: baseline refit → _simulate_crash → autonomous health-monitor detection → recovery → rebind → SGLang weights_checker snapshot/reset/compare proves the replacement holds current bytes (311 tensors, 1.40 GiB) → replacement agent_name changed, survivor unchanged → direct /generate on the replacement + fleet generation. Passed in 18:41.

The design doc with the full contract rationale and review trail is available on request.

tianyi-zhang-02 and others added 27 commits August 26, 2026 11:31
Signed-off-by: Tianyi Zhang <123608656+tianyi-zhang-02@users.noreply.github.com>
Three gaps found reviewing this branch:

- tests/functional/grpo_sglang_nixl_non_colocated.sh was referenced by nothing,
  so it had never run. The CI guard only checks that L1_Functional*.sh shards
  appear in the workflow matrix, not that leaf scripts are called. Register it
  with L1_Functional_Tests_SGLang.sh, which is already in all three matrices.

- The only test of _aligned_checkpoint_engine_batches asserted weight names
  only. The aligner itself enforces name equality across ranks, so that
  assertion is invariant under any rank permutation. Mutating
  aligned[rank] -> aligned[len(pending)-1-rank] delivered every shard to the
  wrong TP rank and the suite stayed green. Assert the tensors too; the mutant
  now fails.

- docs/design-docs/checkpoint-engines.md and docs/guides/checkpoint-engine-refit.md
  both still said SGLang has no checkpoint-engine refit. Update both, record the
  actual limits (one node per logical engine, no shard_expert_weights), and
  generalize the 'Adding Another Backend' timing-line step, which named the vLLM
  line only.

Signed-off-by: Tianyi Zhang <123608656+tianyi-zhang-02@users.noreply.github.com>
Five properties that had no coverage; each was verified by mutation, i.e. the
test fails when the corresponding behaviour is broken and passes when it is not.

- Recycled receive buffers. nixl.py hands out views into a rotating buffer pool,
  so a fake engine that allocates fresh tensors every batch makes that entire
  bug class invisible. _RecyclingEngine poisons a buffer once it is recycled.
  The aligner is correct today because it only advances a rank whose deque is
  empty; making it prefetch turns the yielded tensors into NaN.
- Multi-dtype batches. NIXL packs buckets by bytes, not dtype, so a mixed
  bf16/fp32 batch is the normal production shape, but nothing drove more than
  one dtype group through the update path.
- weight_version across refits. Its semantics were pinned only for the first
  refit, so bumping per POST instead of per refit went undetected.
- base_gpu_id remapping. _to_local_gpu_id was stubbed to identity with
  base_gpu_id=0, which is exactly the case where remapping and doing nothing
  are indistinguishable.
- Payload index to SGLang rank. SGLang indexes serialized_named_tensors by its
  own TP rank, so a transposed list loads every shard onto the wrong GPU and
  still reports success.

Signed-off-by: Tianyi Zhang <123608656+tianyi-zhang-02@users.noreply.github.com>
refit.md still stated flatly that non-colocated SGLang generation is not
supported, which now contradicts checkpoint-engine-refit.md on the same click
(refit.md links to it). The statement is still true for every transport other
than checkpoint-engine refit, which factory.py:107-110 rejects, so narrow it
rather than delete it, and add SGLang to the NIXL full-weights row.

Signed-off-by: Tianyi Zhang <123608656+tianyi-zhang-02@users.noreply.github.com>
Signed-off-by: Tianyi Zhang <123608656+tianyi-zhang-02@users.noreply.github.com>
Two follow-ups on this PR's own changes.

The dp_size=1 guard (weight_sync/factory.py:90) was documented only in
checkpoint-engine-refit.md. design-docs/checkpoint-engines.md still said
'Both are rejected' for what is now three constraints, and refit.md's
constraint table did not mention it at all.

MetricSetupTiming.vllm_checkpoint_engine_init_time_s is no longer written
by anything: grpo.py:1504 moved to extras[f'{backend}_checkpoint_engine_
init_time_s']. For vLLM that formats to the same string, and to_dict()
merges extras over the typed fields, so the emitted metric name is
unchanged -- the field is just dead state now.

Signed-off-by: Tianyi Zhang <123608656+tianyi-zhang-02@users.noreply.github.com>
The factory guarded dp_size and shard_expert_weights but not pp_size.
The mixin creates one receiver per engine GPU, while SGLang indexes
serialized_named_tensors by TP rank -- and sglang_worker.py:386 asserts
tp_size == num_gpus_per_engine // pp_size, so with pp_size>1 the payload
list is pp_size times longer than the engine expects. Fail loudly at setup
instead. Recorded in all three docs alongside the other two limits.

Signed-off-by: Tianyi Zhang <123608656+tianyi-zhang-02@users.noreply.github.com>
Mirrors the dp_size case immediately above it.

Signed-off-by: Tianyi Zhang <123608656+tianyi-zhang-02@users.noreply.github.com>
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>
The SGLang branch of sync_weights only called prepare_for_generation,
which is resume_memory_occupation and nothing else, and then POSTed
update_weights_from_tensor directly. At the pinned sglang rev that is
rejected: the scheduler asserts on a session opened by
begin_weight_update. end_weight_update is also what rebuilds quantized
kernel layouts after the last bucket.

Wrap the transfer in the same envelope the sibling SGLang synchronizer
uses, so both transports drive the engine through one contract. The
pause is load-bearing too: the buckets arrive as several
update_weights_from_tensor calls, each taking the server's model update
lock on its own, so a request admitted between buckets would run against
a half-updated model.

Also port the in_place pause rejection, which this path lacked, and drop
the call-site config defaults the dp/pp guards were reading through.

The existing tests could not see any of this -- they stub the worker's
update_weights_from_tensor -- so add ordering and both failure paths.

Signed-off-by: Tianyi Zhang <123608656+tianyi-zhang-02@users.noreply.github.com>
Ray decides between a threaded and an asyncio actor with
has_async_methods, which is inspect.getmembers over the whole MRO. The
private coroutine on this mixin therefore turned SGLangGenerationWorker
into an asyncio actor, and Ray runs an asyncio actor's sync methods on
that event loop -- so the asyncio.run in the refit entry point raised
'asyncio.run() cannot be called from a running event loop' on the first
refit.

Making the entry point async would fix the crash but leave the flip in
place, changing the concurrency semantics of every pre-existing worker
RPC. Move the loop to a module-level coroutine instead: the mixin
defines no coroutine members, so the actor stays threaded.

The tests missed it because they drove the private coroutine on a plain
object; cover the public wrapper, and assert the no-coroutine-member
invariant, which is the thing that actually regresses.

While here: a refit that received zero tensors now raises instead of
bumping the weight version and reporting success, and the post-transfer
invalidate_kv_cache is dropped -- the synchronizer now flushes before
the buckets land.

Signed-off-by: Tianyi Zhang <123608656+tianyi-zhang-02@users.noreply.github.com>
…ad code

refit_cfg was NotRequired[Any] for a block normalized by exactly the
vLLM schema, so give it the same type. The refit_transport comments and
the grpo.py raise claimed null always means colocated CUDA-IPC, which is
wrong for non-colocated SGLang, where it selects the NCCL weight-update
group. get_rollout_engine_urls has no callers -- a leftover from the
earlier HTTP transport design.

Signed-off-by: Tianyi Zhang <123608656+tianyi-zhang-02@users.noreply.github.com>
With one one-GPU engine the payload-index -> TP-rank scatter never runs:
one stream has nothing to align, _validate_rank_batches compares
nothing, and update_weights_from_tensor gets a one-payload list. A
transposed payload list would load every shard onto the wrong GPU while
still succeeding, and only tp_size>1 can catch that.

Signed-off-by: Tianyi Zhang <123608656+tianyi-zhang-02@users.noreply.github.com>
Self-audit of the review fixes turned up four gaps.

The no-asyncio-actor guard restated Ray's rule instead of asking Ray.
Ray flips on iscoroutinefunction OR isasyncgenfunction, over the ACTOR
class; the test checked only iscoroutinefunction, only on the mixin. An
async generator moved back onto the mixin -- the exact shape at hand,
since the receive loop is one -- left it green. It now imports Ray's own
has_async_methods and asks about SGLangGenerationWorker.

The KV pool was re-acquired on the success path while the pause was
undone on every path, so a failed refit resumed the engines with the
pool still released. Both now live in the same finally, KV first so
requests are not readmitted before the memory is back.

Nothing tested the two invariants the comments assert: that
end_weight_update never closes a session begin_weight_update failed to
open, and that a pause which itself raises still resumes. Both are
covered now, as are the engine-reported-failure branch and the fact
that the in_place guard is SGLang-only.

The transport wording promised more than the code delivers: SGLang's
NCCL weight-update group is Megatron-policy only (factory.py:165), so a
DTensor user following the message would hit a second NotImplementedError.

Signed-off-by: Tianyi Zhang <123608656+tianyi-zhang-02@users.noreply.github.com>
Signed-off-by: Tianyi Zhang <123608656+tianyi-zhang-02@users.noreply.github.com>
Signed-off-by: Tianyi Zhang <123608656+tianyi-zhang-02@users.noreply.github.com>
Signed-off-by: Tianyi Zhang <123608656+tianyi-zhang-02@users.noreply.github.com>
Engine fault tolerance restarts a dead SGLang engine as a fresh Ray actor,
but the checkpoint-engine refit fabric never noticed: the replacement has no
NIXL receivers, its paired policy sender still binds the dead agent, and the
synchronizer stays 'ready' forever. This wires recovery into the
checkpoint-engine path as a rebind (never a teardown):

- sync_weights probes recover_updatable_engines() every refit when
  use_fault_tolerance is on; any new engines invalidate readiness and re-run
  init_communicator, which reuses surviving engine objects, constructs the
  missing receivers on replacements, and reconnects pairs through NIXL's
  disconnect-before-connect init.
- num_new_engines is consumed only after a successful communicator setup
  (the startup fleet reports through the same counter, so the first refit
  must not be misclassified as crash recovery).
- Failure contracts are split three ways: recovery failure rolls the whole
  attempted cohort back (kill actors, restore None slots and the pre-attempt
  count) and stays retryable, escalating to terminal only if rollback fails
  (new RecoveryRollbackError); a rebind failure is terminal (NIXL
  prepare/add_remote_agent are not transactional, so retrying over partial
  state is unsafe); a failure after the transfer starts is terminal with an
  enforced latch - later syncs raise immediately with zero RPCs, serving and
  the health monitor stay paused, and shutdown() no longer runs in the
  unconditional finally on that path. Pre-transfer failures keep the safe
  cleanup/resume behavior; cleanup errors never mask the primary exception.
- init_checkpoint_engine publishes checkpoint_engines only after every
  receiver constructs, so a mid-loop failure cannot arm the idempotency
  early-return and silently no-op the retry a rebind depends on.
- Setup guards: use_fault_tolerance requires the built-in nixl backend
  (custom checkpoint engines have no reconnect contract) and the bf16
  scheme (the sender streams raw BF16 tensors).

The transfer-failure divergence from the NCCL sibling (no serving resume
after a started transfer fails) is deliberate: an interrupted NCCL broadcast
can be redone next refit; interrupted one-sided NIXL work cannot.

Signed-off-by: Serge Panev <spanev@nvidia.com>
Two real PolicyCheckpointEngineMixin senders (NIXL, weights streamed from the
HF checkpoint) plus two real SGLang engines with fault tolerance on, driven by
the real CheckpointEngineWeightSynchronizer. Crashes one engine, waits for the
health monitor, recovers, garbages the replacement's weights with the
weights_checker reset_tensors control, runs the recovery refit, and proves via
weights_checker compare that real current bytes reached the replacement — plus
that only the replaced engine's NIXL agent_name changed and that a request
routed directly to the replacement's server generates. Needs 4 GPUs
(train_world_size >= rollout_world_size forces 2 senders for 2 engines).

Signed-off-by: Serge Panev <spanev@nvidia.com>
…aths

Address the five failure-path defects from the design re-review:

1. Move _start_engines inside the atomic recovery attempt: it publishes
   replacement actors and rewrites num_new_engines while it runs, so a
   synchronous mid-start failure already leaves partial state visible.
2. Extend the same attempt over the post-init work (count assert, health
   monitor arm, needs_offload release/resume): a failure there previously
   escaped with the cohort published, so the next recovery saw no dead
   slot and rebound a partially transitioned engine.
3. Roll back through the health monitor's kill path: a bounded
   best-effort graceful shutdown (router deregistration + spawned server
   process tree) before ray.kill, tolerating replacements whose init
   never reached self.process. The bound comes from the monitor's
   configured per-RPC timeout, exposed via a new check_timeout property.
4. Drop _checkpoint_engine_ready on any rolled-back (retryable) recovery
   failure instead of only latching on RecoveryRollbackError: readiness
   must not survive restored None slots.
5. Honor the terminal latch in the public shutdown(): after a terminal
   rebind/transfer failure the NIXL state is unknown and
   finalize_checkpoint_engine is not idempotent over it, so controller
   teardown becomes a zero-RPC no-op.

The recovery catches use BaseException, matching the synchronizer's
existing convention: a KeyboardInterrupt during the init wait must still
roll the published cohort back (and drop readiness) before propagating.

Tests: mid-start and offload-transition rollback (nonzero pre-attempt
count; pinned graceful-shutdown timeout), recovery-rebind failure
through sync_weights with zero RPCs on later syncs, terminal-latched
shutdown as a zero-RPC no-op, factory negative tests for the
fault-tolerance/quantization guards, and a 2x2 fake-NIXL survivor-peer
reconnect test.

Signed-off-by: Serge Panev <spanev@nvidia.com>
Two reviewer-requested tightenings: the recovery-rebind failure test now
lets receiver init and the prepare gather succeed and fails the third
ray.get — the non-transactional process-group rebind itself — asserting
no session/transfer/count-clear RPC escaped the first call; and a
mutation test bypasses schema validation via the scheme accessor to
prove the factory's own bf16 guard refuses non-bf16 schemes.

Signed-off-by: Serge Panev <spanev@nvidia.com>
…re test

begin/end_weight_update (and the pause/invalidate/continue envelope) are
called directly on the generation object, not through the
run_checkpoint_engine_method wrapper the previous assertion inspected.
Assert the direct lifecycle mocks are untouched after the failing rebind,
and that the terminal retry records zero generation method calls at all.

Signed-off-by: Serge Panev <spanev@nvidia.com>
@copy-pr-bot

copy-pr-bot Bot commented Sep 1, 2026

Copy link
Copy Markdown

This pull request requires additional validation before any workflows can run on NVIDIA's runners.

Pull request vetters can view their responsibilities here.

Contributors can view more details about this message here.

@github-actions github-actions Bot added the Documentation Improvements or additions to documentation label Sep 1, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Documentation Improvements or additions to documentation

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants