feat(moe_ep): fault-tolerance rank mask (NCCL-EP + NIXL-EP) - #4183
Conversation
Adds the fault-tolerance (FT) rank-mask API surface to flashinfer.moe_ep.
Both transports already implement per-rank masking in their LL kernels
(NCCL-EP's ncclEpGroupConfig_t.enable_mask + ncclEpMask*, NIXL-EP's
Buffer.{update,query,clean}_mask_buffer), but moe_ep neither enabled nor
exposed any of it: a peer that stopped responding tripped a GPU trap() and
took the job down.
This commit is the backend-agnostic half — no transport is touched, so
nothing existing can regress:
* FleetAlgoKnobFaultTolerance (enabled, timeout_ms, reconcile_timeout_s,
coordinator_takeover_s). A knob rather than FleetParams fields: FleetParams
is the frozen sizing dataclass, while optional transport features already
live in the knob namespace (cf. FleetAlgoKnobTopologyCapacity).
* MoEEpFaultToleranceUnsupportedError / MoEEpTransportError.
* Six Fleet methods: supports_fault_tolerance, query_active_mask, query_fault,
set_active_mask, reconcile_active_mask, clear_faults, active_mask_epoch.
Deliberately concrete raising defaults rather than @AbstractMethod --
making them abstract would break every existing and out-of-tree Fleet.
* Package-wide mask convention: int32[world_size], 1 = active. This matches
ncclEpMaskQuery and vLLM's query_active_mask() naming; NIXL's inverted,
capacity-length buffer gets normalized inside its own Fleet (later commit).
* validate_fleet_params rejects FT + HIGH_THROUGHPUT for both backends (nccl
leaves the mask buffer NULL under HT and the mask APIs then abort the
process; nixl has no HT mask at all), and now also rejects a nixl topology
capacity below world_size -- an incidental fix, since capacity sizes every
per-rank array and a short one indexes out of bounds inside the transport.
* supports_fault_tolerance(backend) probe, feature-detecting rather than
version-pinning. Returns False until the nccl ctypes shim lands.
AI-assisted (Claude Opus 5).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…utils
Fault-tolerance mask reconciliation needs a rendezvous store on BOTH
transports, but the resolver (and its group-identity prefixing) lived inside
the nixl_ep backend, where nccl_ep cannot reach it.
Pure move: _STORE_GENS + _resolve_store become
resolve_rendezvous_store(bootstrap, *, subsystem) in core/bootstrap_utils.py,
with `subsystem` slotted into both the generation key and the prefix
(flashinfer/moe_ep/{subsystem}/{group_id}/{gen}). The sha1 group-identity
digest is unchanged -- it is what stops overlapping EP subgroups such as
(0,1,2,3) and (0,2,4,6) from colliding on store keys.
nixl_ep keeps a thin _resolve_store alias, so the produced prefix is
byte-identical to before and the existing nixl_ep mock tests pass unmodified
(40 passed, no test file touched).
Also documents the resolve-once rule on the shared helper: each call bumps
the generation counter, so a caller that resolved per operation would put
each rank on a different prefix.
AI-assisted (Claude Opus 5).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
When a peer stops responding each survivor's transport masks it LOCALLY -- NCCL-EP's own header calls mask consistency "a framework-level concern". Two survivors can therefore disagree (A's kernel timed out on the straggler, B's did not), and running the next dispatch with disagreeing masks deadlocks. This adds the agreement protocol. core/comm/fault_tolerance.py is deliberately transport-free -- a pure function of a torch.distributed Store -- so the whole protocol is unit tested in-process with a HashStore and threads: no GPU, no transport, no torchrun. A store rather than a collective because a torch.distributed allreduce over the EP group would hang on exactly the rank being masked out; with a store, a missing key IS the death signal. Protocol: publish local view -> wait only on ranks we still believe alive -> elementwise-AND what arrived, mask what didn't -> lowest-numbered survivor publishes the agreed vector via an atomic compare_set, everyone else adopts whatever that key holds. The single-sourced decision is the crux. With a naive "everyone ANDs locally and applies", a straggler's key landing at T+9.9s is seen by survivor B but not by survivor A polling at T+9.8s, and the two apply DIFFERENT masks -- a split brain that deadlocks the next dispatch. compare_set(key, b"", v) is set-if-absent-else-return-current on both TCPStore and HashStore (verified), so even a coordinator-takeover race resolves to one value. There is a dedicated regression test for exactly this timing. Writing the tests surfaced a case the design had not covered: a rank that is alive and participating but that some peer already timed out on gets ANDed out of the group. It cannot apply that decision (both transports refuse to let a rank mask itself) and must not ignore it (its peers have stopped sending it tokens), so it now raises the new MoEEpRankEvictedError and lets the framework decide -- tear the worker down, or rejoin after the survivors call clear_faults(readmit=True). Note pre-existing, unrelated env failures in this tree: nvfp4/CuTeDSL JIT tests fail to ninja-build here on base commit 6258e52 too. AI-assisted (Claude Opus 5). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
nccl4py binds GroupConfig.enable_mask and timeout_ns -- so masking can be
switched ON from Python -- but its Group class stops at create /
create_handle / destroy / .ptr. The five functions needed to actually USE
the mask are not exposed:
ncclEpMaskQuery / ncclEpMaskUpdate / ncclEpMaskClean
ncclEpGetAsyncError / ncclEpErrorClear
Group.ptr is the raw ncclEpGroup_t, so this calls them directly until the
bindings catch up. Every wrapper tries a native Group method FIRST and only
falls back to ctypes, so the shim retires itself with zero call-site churn
the day nccl4py binds them (tested).
Correcting a wrong assumption from the design: these symbols live in
libnccl_ep.so, NOT libnccl.so.2, so reusing the backend's existing
libnccl.so.2 preload handle would have bound the wrong library. nccl4py
dlopens libnccl_ep.so with RTLD_GLOBAL and resolves via
dlsym(RTLD_DEFAULT, ...), so we bind the process-global namespace first:
that is the one resolution path where the library is *guaranteed* to be the
same one the caller's group came from. Resolving a path ourselves could bind
a second, different libnccl_ep when the wheel and LD_LIBRARY_PATH disagree,
and calling into it with a group created by the other is undefined. Path
resolution (nccl4py package dir, CONDA_PREFIX, SONAME) is only a fallback
for probing before any group exists.
argtypes/restype are declared explicitly for all five: without them ctypes
silently truncates 64-bit pointers, which a unit test now guards. Errors map
through _NCCL_RESULT_NAMES, with ncclInvalidUsage (5) carrying a targeted
"the group was created without enable_mask" hint -- that is what all five
return when FT was never enabled, and it is the likeliest user error.
Construction never raises: a host with no NCCL reports available=False plus
the list of missing symbols, so supports_fault_tolerance("nccl_ep") is safe
to call anywhere. Verified on this host, which has no transport built at
all: the probe returns False rather than throwing.
Tested with a fake library object (settable argtypes/restype is all ctypes
requires), so the marshalling contract is covered with no libnccl_ep.so
present. Behaviour against a real libnccl_ep needs a GPU host and is covered
by the multirank test in a later commit.
AI-assisted (Claude Opus 5).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Turns rank masking on for the NCCL-EP transport and implements the Fleet FT API against it via the ctypes shim. _build_group_config now sets enable_mask (which is what allocates the per-rank mask buffer and the pinned async-error flag inside ncclEpCreateGroup) and converts the knob's timeout_ms to timeout_ns. A zero timeout is left out entirely so the library default (~100 s) applies. Capability is checked at CONSTRUCTION rather than at first fault: if the installed nccl4py has no enable_mask field, or the loaded libnccl_ep does not export the mask symbols, the Fleet refuses to be built with an actionable message. Discovering that a "fault tolerant" deployment is not actually fault tolerant at the moment a rank dies is the worst possible time. Staging buffers absorb an asymmetry in the C API: ncclEpMaskQuery writes to DEVICE memory while ncclEpMaskUpdate reads from HOST memory. Both are Fleet-owned and anchored on the existing _hot_cache (which update_topology already clears, so they resize with the world -- tested). The host buffer is PINNED, not pageable: Update is stream-ordered, so if the library defers the H2D onto the stream, a buffer we mutate on the next call is a use-after-write race. A test asserts the same buffer is reused across calls. clear_faults(readmit=True) carries two guards: * it raises a clean Python error unless a handle has been created, because ncclEpMaskClean asserts on the LL staging buffer that only the first create_handle allocates -- otherwise the process SIGABRTs from C; * it warns under EXPERT_MAJOR, because MaskClean computes its buffer-reset offsets assuming RANK_MAJOR. EXPERT_MAJOR is what the vLLM path uses, so the documented recovery there is degraded serving or update_topology, not re-admission. A warning rather than a construction-time error, because degraded serving under EXPERT_MAJOR is entirely sound. It also always pairs MaskClean with ErrorClear, since the former deliberately does not clear the flag -- which is exactly why readmit is a flag on one method rather than two a caller could mis-sequence. All stream-ordered entry points reject CUDA-graph capture: a captured query replays stale offsets and a captured set freezes one mask into every replay. AI-assisted (Claude Opus 5). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Implements the Fleet FT API over NIXL-EP's native mask calls (update/query/clean_mask_buffer). Only timeout_ms needs wiring at construction: unlike nccl_ep's enable_mask, NIXL allocates its mask buffer unconditionally inside update_memory_buffers, which the Fleet already calls. Three corrections that reading the transport sources forced, each with a regression test: 1. NIXL's polarity is "NONZERO means masked", not "1 means masked". The buffer is 0xFF-memset at allocation (an untouched entry reads back as -1) and the kernels test `mask_buffer[r] != 0`. So the normalization to our canonical 1 = active must be `(raw == 0)`. The obvious `1 - raw` would yield 2 for never-connected capacity-tail ranks and silently poison every downstream sum()/bool(). 2. query_mask_buffer asserts the out tensor is exactly max_num_ranks long -- the topology CAPACITY, not the live world -- so the trim to world_size is mandatory, not cosmetic. 3. clean_mask_buffer zeroes ALL capacity entries, thereby marking the never-connected tail [world_size, capacity) ACTIVE. Calling it bare is a live correctness bug on any fleet sized above its world, so clear_faults(readmit=True) re-masks the tail immediately afterwards. set_active_mask pushes only the DIFF. Unlike nccl_ep's single-vector Update, each nixl update_mask_buffer is a kernel launch, so a blind range(world_size) loop would inject world_size launches into the steady state where nothing changed; a test asserts a repeated identical set issues nothing at all. query_fault diffs against the applied mask because this transport has no host-side error flag. Also fixes a pre-existing bug the new fake buffer exposed: update_topology would happily grow the world past the topology capacity, and since every per-rank array (RDMA, mask, sync) is sized to that capacity at construction, connecting a rank beyond it writes out of bounds inside the transport rather than failing cleanly. It now raises MoEEpConfigError. The existing test_update_topology_diffs_ranks grew 4 -> 6 on a capacity-4 fleet, i.e. it was exercising an invalid configuration; it now passes capacity=8, keeping its actual intent (rank-set diffing) intact. Documented on update_topology: disconnect_ranks requires the removed ranks to be a SUFFIX of the connected set. The range()-based diff is suffix-only by construction, which is why shrinking works but evicting a rank from the MIDDLE after a fault does not -- that case stays masked-and-degraded, the biggest functional asymmetry against nccl_ep. AI-assisted (Claude Opus 5). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…target Two complementary injection methods, split for a concrete reason. test_moe_ep_fault_tolerance_multirank.py stalls a rank instead of killing it. NIXL's own elastic test SIGTERMs a worker, but under `torchrun -m pytest` that tears down the whole job and takes the survivors' pytest session with it. Sleeping past the FT timeout produces the same thing the transports actually detect -- a peer whose data never arrives -- while every process stays alive, so the torch process group remains usable for the test's own barriers. The full state machine (healthy -> detect -> reconcile -> degraded -> re-admit -> healthy) runs in one job in ~10s on both backends. The victim is rank 2: a MIDDLE rank, so the test also covers the case NIXL cannot express as a topology change (disconnect_ranks is suffix-only) and exercises reconcile's coordinator election rather than the trivial rank-0 path. The victim asserts it gets MoEEpRankEvictedError -- it stalled long enough for the survivors to give up on it, and must learn that rather than keep serving. The degraded assertion is exact, not "close to x": a masked rank's experts are dropped and combine does NOT renormalize topk weights, so each token comes out scaled by its surviving weight fraction. The test computes that fraction from the agreed mask and asserts equality. A test that merely checked for finite output would pass even if masking silently dropped everything. smoke_ft_ep.py is the hard-kill counterpart: the victim installs a SIGTERM handler, tears down its Fleet and dies for real. It is a script and not a pytest test for the reason above, and success is judged by counting SMOKE_RESULT lines (nproc-1) rather than by exit code. It deliberately uses NO dist.barrier() after the kill -- any collective over the full group would hang on the dead rank, which is precisely why reconciliation goes through the store instead of an allreduce. One script with --backend rather than the two near-identical files the plan called for; the bodies differed only in the config object. run_tests.sh gains an `ft` target, gated per backend on supports_fault_tolerance(). NOT wired into run_all: it kills processes. Verified here: both tests collect, the smoke script runs (needs PYTHONPATH= repo root, as run_tests.sh sets), shell syntax checks. Actual execution needs >=4 GPUs; this host has 1, so the 4-GPU bodies are unrun so far. AI-assisted (Claude Opus 5). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
moe_ep_architecture.md: FT knob in Core types, plus a Fault tolerance section carrying the mask convention (int32[world], 1 = active) and the table of the two transports' differences -- NIXL's polarity is "nonzero = masked" with a 0xFF-memset buffer, and its buffer is capacity-length, not world-length. Both facts are easy to get wrong in exactly the way that produces silently bogus masks, so they are written down next to the correct normalization. moe_ep_runbook.md: the operational half -- the supports_fault_tolerance() pre-flight check, the recovery state machine, the four ordering rules (mutate only between iterations, reconcile in the same epoch, readmit before update_topology, never during graph capture), the backend asymmetries (nixl cannot evict a middle rank; nccl re-admission warns under EXPERT_MAJOR; capacity must be sized up front), the dropped-token formula with the opt-in renormalization snippet, what MoEEpRankEvictedError means, and how to run every tier of the FT tests. MoE_EP_impl.md: the new public symbols in the API table. vllm_moe_ep_integration.md section 8: the design note for a future vLLM commit, since no vLLM code ships here. Records the target manager shape and the four things that commit must get right -- the HT manager must keep support_fault_tolerance False (validation rejects FT+HT); self._fleets holds several fleets sharing one EP group so FT state must be hoisted to a primary fleet rather than reconciled per-fleet; vLLM's existing nixl/deepep query_active_mask() return RAW buffers whose polarity is inverted relative to ours, harmless today but not comparable across managers; and degraded serving has nowhere to live yet, which is precisely why FlashInfer exposes reconcile/clear at all. AI-assisted (Claude Opus 5). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Caution The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased. |
📝 WalkthroughWalkthroughAdds opt-in MoE expert-parallel fault tolerance with active-mask management, backend capability detection, NCCL-EP and NIXL-EP implementations, store-based reconciliation, rank eviction handling, validation, documentation, and distributed tests. ChangesMoE EP fault tolerance
Estimated code review effort: 5 (Critical) | ~120 minutes Suggested labels: Suggested reviewers: Sequence Diagram(s)sequenceDiagram
participant MoEEpLayer
participant Fleet
participant Transport
participant Store
MoEEpLayer->>Fleet: dispatch and combine with FT enabled
Transport-->>Fleet: report timeout or fault
Fleet->>Transport: query and update active mask
Fleet->>Store: reconcile rank views
Store-->>Fleet: agreed mask or eviction
Fleet->>MoEEpLayer: continue degraded or readmitted execution
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 6
🧹 Nitpick comments (4)
flashinfer/moe_ep/backends/split/comm/nixl_ep/fleet.py (1)
142-157: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winNarrow the
TypeErrortranslation so unrelated ctor errors aren't misreported.Any
TypeErrorraised insideBuffer.__init__(bad arg type, internal call) is currently reported as "build does not accepttimeout_ms" wheneverbuf_kwargsis non-empty. Gate on the argument actually being rejected.♻️ Suggested tightening
except TypeError as e: - if not buf_kwargs: + if not buf_kwargs or "timeout_ms" not in str(e): raise🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@flashinfer/moe_ep/backends/split/comm/nixl_ep/fleet.py` around lines 142 - 157, In the Buffer construction error handling, narrow the TypeError translation in the surrounding initialization method so it only raises MoEEpFaultToleranceUnsupportedError when the exception indicates the timeout_ms keyword is unsupported. Re-raise unrelated TypeErrors from nixl_ep.Buffer.__init__ unchanged, while preserving the existing behavior for the genuine staged-build argument rejection.tests/moe_ep/run_tests.sh (1)
221-242: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winSilent pass when neither backend supports FT.
If
supports_fault_toleranceis false for bothnccl_epandnixl_ep, the loop body never setsrc=1, sorun_ftreturns 0 andrun_tests.sh ftreports "PASS" while exercising zero FT tests. Consider tracking whether at least one backend ran and surfacing that distinctly (warn or fail) so CI doesn't silently certify FT as validated when it never ran.♻️ Proposed fix to surface a "no backend ran" state
run_ft() { local rc=0 local expected_ok=$(( NPROC_SMOKE - 1 )) + local ran_any=0 for backend in nccl_ep nixl_ep; do if ! "${PY}" -c "from flashinfer.moe_ep import supports_fault_tolerance as s; raise SystemExit(0 if s('${backend}') else 1)"; then echo "${backend} cannot serve the FT API here; skipping its FT tests" continue fi + ran_any=1 ... done + if [ "${ran_any}" -eq 0 ]; then + echo "no backend supports fault tolerance on this host; FT was not exercised" >&2 + rc=1 + fi return "${rc}" }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@tests/moe_ep/run_tests.sh` around lines 221 - 242, Update the FT backend loop in run_ft to track whether at least one backend passes supports_fault_tolerance and actually runs tests. After the loop, detect when none ran and return a distinct non-success outcome or warning that prevents run_tests.sh ft from reporting a misleading PASS, while preserving existing rc handling when a backend does run.docs/design_docs/moe_ep_runbook.md (1)
323-330: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueAdd a language hint to fenced diagrams (MD040).
Both blocks are plain-text diagrams; markdownlint flags the missing language identifier.
📝 Suggested fix
-``` +```text HEALTHY ──query_fault()──> FAULT_DETECTED (local; quiesce in-flight combine/complete)and similarly for the block at line 371.
Also applies to: 371-373
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@docs/design_docs/moe_ep_runbook.md` around lines 323 - 330, Add the text language identifier to the fenced code blocks containing the plain-text state diagrams, including the blocks around the HEALTHY/DEGRADED flow and the corresponding block near the later referenced section, without changing their diagram contents.Source: Linters/SAST tools
flashinfer/moe_ep/backends/split/comm/nccl_ep/_mask_ffi.py (1)
235-235: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win
__all__ordering flagged by Ruff (RUF022).-__all__ = ["mask_ffi", "_MaskFfi"] +__all__ = ["_MaskFfi", "mask_ffi"]🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@flashinfer/moe_ep/backends/split/comm/nccl_ep/_mask_ffi.py` at line 235, Reorder the entries in __all__ to satisfy Ruff RUF022, placing the public symbol mask_ffi before the private _MaskFfi symbol.Source: Linters/SAST tools
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@flashinfer/moe_ep/backends/split/comm/nccl_ep/_mask_ffi.py`:
- Around line 229-232: Update mask_ffi() and its caching behavior so an
unsuccessful _MaskFfi binding is not memoized, allowing later calls to retry
after nccl.ep initialization; retain memoization only once the shim has
successfully bound the library. Preserve the existing process-wide reuse for
successful bindings and ensure supports_fault_tolerance("nccl_ep") can recover
from an early probe.
In `@flashinfer/moe_ep/backends/split/comm/nccl_ep/fleet.py`:
- Around line 179-181: Reset the topology-scoped FT state in update_topology
alongside the existing cache clearing: set _any_handle_created to False and
_ft_store_obj to None after the NCCL group is recreated. Preserve these resets
for every topology update so MaskClean cannot use stale handle state or an FT
store from the previous rank set.
In `@flashinfer/moe_ep/backends/split/comm/nixl_ep/fleet.py`:
- Around line 234-235: Update the topology-reset logic around _ft_applied in
update_topology to re-derive the applied-state mirror from the current transport
mask after resizing, rather than initializing every rank to ACTIVE. Preserve the
transport’s existing masked state for ranks retained in the new world so a later
set_active_mask([1] * world_size) detects and clears those masks correctly.
In `@flashinfer/moe_ep/core/bootstrap_utils.py`:
- Line 55: Synchronize access to the _STORE_GENS counter in
resolve_rendezvous_store so concurrent calls for the same (subsystem, ranks) key
cannot reuse a generation number. Add a module-level lock and hold it across the
get-and-increment operation, while leaving store creation and unrelated logic
unchanged.
In `@tests/moe_ep/test_moe_ep_fault_tolerance_multirank.py`:
- Around line 212-221: Update the alive-mask construction near surviving_w to
use the explicit EXPERT_MAJOR rank-to-expert ownership mapping supported by
nixl_ep, rather than indexing expected with e // experts_per_rank. Preserve the
existing dead-rank masking and assertion behavior while ensuring each expert
resolves to its owning rank under expert-major layout.
- Around line 162-226: Fix the barrier mismatch in the reconciliation and
teardown flow of the fault-tolerance test: create and use a survivors-only
process group for the two post-eviction synchronization barriers, including the
degraded-forward synchronization and final cleanup, while keeping the victim’s
existing WORLD-group barriers before eviction. Ensure the survivors-only group
is destroyed or released appropriately after use.
---
Nitpick comments:
In `@docs/design_docs/moe_ep_runbook.md`:
- Around line 323-330: Add the text language identifier to the fenced code
blocks containing the plain-text state diagrams, including the blocks around the
HEALTHY/DEGRADED flow and the corresponding block near the later referenced
section, without changing their diagram contents.
In `@flashinfer/moe_ep/backends/split/comm/nccl_ep/_mask_ffi.py`:
- Line 235: Reorder the entries in __all__ to satisfy Ruff RUF022, placing the
public symbol mask_ffi before the private _MaskFfi symbol.
In `@flashinfer/moe_ep/backends/split/comm/nixl_ep/fleet.py`:
- Around line 142-157: In the Buffer construction error handling, narrow the
TypeError translation in the surrounding initialization method so it only raises
MoEEpFaultToleranceUnsupportedError when the exception indicates the timeout_ms
keyword is unsupported. Re-raise unrelated TypeErrors from
nixl_ep.Buffer.__init__ unchanged, while preserving the existing behavior for
the genuine staged-build argument rejection.
In `@tests/moe_ep/run_tests.sh`:
- Around line 221-242: Update the FT backend loop in run_ft to track whether at
least one backend passes supports_fault_tolerance and actually runs tests. After
the loop, detect when none ran and return a distinct non-success outcome or
warning that prevents run_tests.sh ft from reporting a misleading PASS, while
preserving existing rc handling when a backend does run.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: ddaf2258-1286-4310-86f8-e80a3572dd5b
📒 Files selected for processing (24)
docs/design_docs/MoE_EP_impl.mddocs/design_docs/moe_ep_architecture.mddocs/design_docs/moe_ep_runbook.mddocs/design_docs/vllm_moe_ep_integration.mdflashinfer/moe_ep/__init__.pyflashinfer/moe_ep/algo_knobs.pyflashinfer/moe_ep/backends/split/comm/nccl_ep/_mask_ffi.pyflashinfer/moe_ep/backends/split/comm/nccl_ep/fleet.pyflashinfer/moe_ep/backends/split/comm/nixl_ep/fleet.pyflashinfer/moe_ep/core/bootstrap_utils.pyflashinfer/moe_ep/core/comm/fault_tolerance.pyflashinfer/moe_ep/core/comm/fleet.pyflashinfer/moe_ep/core/validation/common.pyflashinfer/moe_ep/errors.pyflashinfer/moe_ep/modes/split_layer.pytests/moe_ep/nccl_ep/test_fleet_mock.pytests/moe_ep/nccl_ep/test_mask_ffi.pytests/moe_ep/nixl_ep/test_fleet_mock.pytests/moe_ep/run_tests.shtests/moe_ep/smoke_ft_ep.pytests/moe_ep/test_constraints.pytests/moe_ep/test_fault_tolerance_api.pytests/moe_ep/test_fault_tolerance_reconcile.pytests/moe_ep/test_moe_ep_fault_tolerance_multirank.py
| @functools.cache | ||
| def mask_ffi() -> _MaskFfi: | ||
| """Process-wide shim instance (symbol binding is done once).""" | ||
| return _MaskFfi() |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
A failed early probe is cached for the life of the process.
mask_ffi() is functools.cached, so if the first call happens before nccl.ep has initialized and _resolve_libnccl_ep() returns None (no package path, no CONDA_PREFIX, SONAME not on the loader path), available=False is memoized permanently. A later NcclEpFleet(..., FleetAlgoKnobFaultTolerance()) then fails _check_ft_supported() even though libnccl_ep.so is loaded by that point. supports_fault_tolerance("nccl_ep") is exactly the API users are told to call first, so this ordering is likely in practice.
Suggest only memoizing a successful binding.
♻️ Cache only when the shim actually bound
-@functools.cache
-def mask_ffi() -> _MaskFfi:
- """Process-wide shim instance (symbol binding is done once)."""
- return _MaskFfi()
+_CACHED: "_MaskFfi | None" = None
+
+
+def mask_ffi() -> _MaskFfi:
+ """Process-wide shim instance (successful symbol binding is done once).
+
+ An unavailable shim is NOT cached: the first call may land before
+ ``nccl.ep`` has dlopened libnccl_ep, and caching that miss would make FT
+ look unsupported for the rest of the process.
+ """
+ global _CACHED
+ if _CACHED is None or not _CACHED.available:
+ _CACHED = _MaskFfi()
+ return _CACHED📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| @functools.cache | |
| def mask_ffi() -> _MaskFfi: | |
| """Process-wide shim instance (symbol binding is done once).""" | |
| return _MaskFfi() | |
| _CACHED: "_MaskFfi | None" = None | |
| def mask_ffi() -> _MaskFfi: | |
| """Process-wide shim instance (successful symbol binding is done once). | |
| An unavailable shim is NOT cached: the first call may land before | |
| ``nccl.ep`` has dlopened libnccl_ep, and caching that miss would make FT | |
| look unsupported for the rest of the process. | |
| """ | |
| global _CACHED | |
| if _CACHED is None or not _CACHED.available: | |
| _CACHED = _MaskFfi() | |
| return _CACHED |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@flashinfer/moe_ep/backends/split/comm/nccl_ep/_mask_ffi.py` around lines 229
- 232, Update mask_ffi() and its caching behavior so an unsuccessful _MaskFfi
binding is not memoized, allowing later calls to retry after nccl.ep
initialization; retain memoization only once the shim has successfully bound the
library. Preserve the existing process-wide reuse for successful bindings and
ensure supports_fault_tolerance("nccl_ep") can recover from an early probe.
| self._ft_epoch = 0 | ||
| self._ft_store_obj = None | ||
| self._any_handle_created = False |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
_any_handle_created (and the FT store) survive update_topology(), defeating the MaskClean guard.
update_topology() destroys and re-creates the nccl.ep.Group, so the LL staging buffer that ncclEpMaskClean asserts on is gone until a new handle is created — but _any_handle_created stays True from the pre-resize group, so clear_faults(readmit=True) will pass the Python guard and SIGABRT from C. _ft_store_obj has the same staleness problem: it's cached against the old rank set, and reusing it across a topology change puts survivors on a prefix that no longer matches the group identity.
Reset both in update_topology():
🐛 Reset FT state on topology change
# in update_topology(), alongside self._hot_cache.clear()
self._any_handle_created = False
self._ft_store_obj = NoneAlso applies to: 290-290
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@flashinfer/moe_ep/backends/split/comm/nccl_ep/fleet.py` around lines 179 -
181, Reset the topology-scoped FT state in update_topology alongside the
existing cache clearing: set _any_handle_created to False and _ft_store_obj to
None after the NCCL group is recreated. Preserve these resets for every topology
update so MaskClean cannot use stale handle state or an FT store from the
previous rank set.
| self._ft_applied = [ACTIVE] * bootstrap.world_size | ||
| self._hot_ft_bufs = None |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
_ft_applied is reset to all-ACTIVE without re-syncing the transport mask.
If a rank was masked before update_topology and is still within the new world, the transport mask keeps it masked while _ft_applied now claims ACTIVE. A subsequent set_active_mask([1]*ws) then diffs to a no-op and never un-masks it, so the fleet stays silently degraded (and query_fault() reports a permanent fault). Re-derive the mirror from the transport instead of assuming all-active.
🐛 Suggested re-sync
- self._ft_applied = [ACTIVE] * bootstrap.world_size
self._hot_ft_bufs = None
+ if self._ft is not None:
+ # The transport mask survives connect/disconnect, so re-derive the
+ # mirror rather than assuming a clean slate.
+ self._ft_applied = [
+ int(v) for v in self.query_active_mask().cpu().tolist()
+ ]
+ else:
+ self._ft_applied = [ACTIVE] * bootstrap.world_size📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| self._ft_applied = [ACTIVE] * bootstrap.world_size | |
| self._hot_ft_bufs = None | |
| self._hot_ft_bufs = None | |
| if self._ft is not None: | |
| # The transport mask survives connect/disconnect, so re-derive the | |
| # mirror rather than assuming a clean slate. | |
| self._ft_applied = [ | |
| int(v) for v in self.query_active_mask().cpu().tolist() | |
| ] | |
| else: | |
| self._ft_applied = [ACTIVE] * bootstrap.world_size |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@flashinfer/moe_ep/backends/split/comm/nixl_ep/fleet.py` around lines 234 -
235, Update the topology-reset logic around _ft_applied in update_topology to
re-derive the applied-state mirror from the current transport mask after
resizing, rather than initializing every rank to ACTIVE. Preserve the
transport’s existing masked state for ranks retained in the new world so a later
set_active_mask([1] * world_size) detects and clears those masks correctly.
| # then never reuse a prior fleet's keys. A single process-wide counter would | ||
| # diverge when a process belongs to several EP subgroups and creates their | ||
| # fleets in a different interleaving than its peers. | ||
| _STORE_GENS: dict = {} |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
Unsynchronized read-modify-write on _STORE_GENS.
gen = _STORE_GENS.get(key, 0) followed by _STORE_GENS[key] = gen + 1 is not atomic. Concurrent calls to resolve_rendezvous_store from multiple threads in the same process for the same (subsystem, ranks) key can race and hand out a duplicate/lost generation number, which is exactly the collision this counter was introduced to prevent — the resulting store-prefix mismatch across ranks would silently break rendezvous rather than raise.
🔒 Suggested fix
+import threading
+
+_STORE_GENS_LOCK = threading.Lock()
_STORE_GENS: dict = {}
...
- gen = _STORE_GENS.get(key, 0)
- _STORE_GENS[key] = gen + 1
+ with _STORE_GENS_LOCK:
+ gen = _STORE_GENS.get(key, 0)
+ _STORE_GENS[key] = gen + 1Also applies to: 105-115
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@flashinfer/moe_ep/core/bootstrap_utils.py` at line 55, Synchronize access to
the _STORE_GENS counter in resolve_rendezvous_store so concurrent calls for the
same (subsystem, ranks) key cannot reuse a generation number. Add a module-level
lock and hold it across the get-and-increment operation, while leaving store
creation and unrelated logic unchanged.
| # --- HEALTHY --------------------------------------------------------- | ||
| y = layer.forward(t) | ||
| torch.cuda.synchronize() | ||
| torch.testing.assert_close(y, x, atol=5e-2, rtol=5e-2) | ||
| dist.barrier() | ||
|
|
||
| fleet = layer._ensure_fleet() | ||
| assert fleet.supports_fault_tolerance is True | ||
| assert fleet.query_active_mask().cpu().tolist() == [1] * world_size | ||
| assert fleet.query_fault() is False | ||
|
|
||
| # --- FAULT: the victim stalls past the timeout ------------------------ | ||
| if rank == _VICTIM: | ||
| time.sleep(_STALL_S) | ||
| else: | ||
| layer.forward(t) # survivors' kernels time out waiting on the victim | ||
| torch.cuda.synchronize() | ||
| dist.barrier() # the victim is alive, so the torch PG is still healthy | ||
|
|
||
| if rank in survivors: | ||
| assert fleet.query_fault() is True, "survivors must observe the fault" | ||
| mask = fleet.query_active_mask().cpu().tolist() | ||
| assert mask[_VICTIM] == 0, f"rank {_VICTIM} should be masked, got {mask}" | ||
|
|
||
| # --- RECONCILE ------------------------------------------------------- | ||
| # Every rank reconciles in the same iteration slot. The victim learns it | ||
| # was evicted (it stalled long enough for the survivors to give up). | ||
| from flashinfer.moe_ep.errors import MoEEpRankEvictedError | ||
|
|
||
| if rank == _VICTIM: | ||
| with pytest.raises(MoEEpRankEvictedError): | ||
| fleet.reconcile_active_mask() | ||
| print(f"rank {rank}: correctly evicted") | ||
| layer.destroy() | ||
| dist.barrier() | ||
| return | ||
|
|
||
| agreed = fleet.reconcile_active_mask().cpu().tolist() | ||
| assert agreed == expected, f"rank {rank} agreed {agreed}, expected {expected}" | ||
| fleet.clear_faults(readmit=False) | ||
| assert fleet.query_fault() is False, "clear_faults must re-arm detection" | ||
|
|
||
| # --- DEGRADED -------------------------------------------------------- | ||
| # The dead rank's experts are gone and combine does NOT renormalize, so | ||
| # each token comes out scaled by the surviving weight fraction rather | ||
| # than 1. Assert exactly that, not "close to x". | ||
| y_deg = layer.forward(t) | ||
| torch.cuda.synchronize() | ||
| assert torch.isfinite(y_deg.float()).all(), "degraded output must not be NaN/Inf" | ||
|
|
||
| experts_per_rank = num_experts // world_size | ||
| alive = torch.tensor( | ||
| [expected[e // experts_per_rank] for e in range(num_experts)], | ||
| device="cuda", | ||
| dtype=torch.bool, | ||
| ) | ||
| surviving_w = (topk_weights * alive[topk_ids]).sum(-1) # [num_tokens] | ||
| torch.testing.assert_close( | ||
| y_deg.float(), x.float() * surviving_w.unsqueeze(-1), atol=8e-2, rtol=8e-2 | ||
| ) | ||
| print(f"rank {rank}: degraded forward matches surviving-weight scaling") | ||
| dist.barrier() | ||
|
|
||
| layer.destroy() | ||
| dist.barrier() |
There was a problem hiding this comment.
🩺 Stability & Availability | 🔴 Critical | 🏗️ Heavy lift
Mismatched dist.barrier() collective counts between the victim and survivors will hang the survivors.
Victim calls dist.barrier() on the default (WORLD) process group 3 times in this test (lines 166, 179, 196) before returning at line 197. Survivors call it 4 times (166, 179, 223, 226). Since dist.barrier() collectives on a shared process group are matched by issue-order per rank (not by source line), this 1-call deficit persists for the rest of the module: test_readmit_restores_full_strength adds 4 symmetric calls per rank afterward, so the deficit surfaces at the very last barrier (line 303), which survivors will block on until _PG_TIMEOUT (60 min) elapses with no victim participant left to match it. This directly contradicts the file's own docstring claim that "every process stays alive, so the torch process group stays usable for the test's own barriers" (lines 8-15) — the victim is alive, it just stops calling barrier() two calls early.
This is very likely why the PR reports the 4-GPU multirank tests were never actually executed (dev host has 1 GPU) — the bug would only manifest under a real 4-rank run.
🔒 Proposed fix: give the two post-eviction syncs their own survivors-only group
survivors = [r for r in range(world_size) if r != _VICTIM]
expected = [1 if r in survivors else 0 for r in range(world_size)]
+ survivors_group = dist.new_group(survivors)
...
if rank == _VICTIM:
with pytest.raises(MoEEpRankEvictedError):
fleet.reconcile_active_mask()
print(f"rank {rank}: correctly evicted")
layer.destroy()
dist.barrier()
return
agreed = fleet.reconcile_active_mask().cpu().tolist()
assert agreed == expected, f"rank {rank} agreed {agreed}, expected {expected}"
fleet.clear_faults(readmit=False)
assert fleet.query_fault() is False, "clear_faults must re-arm detection"
...
print(f"rank {rank}: degraded forward matches surviving-weight scaling")
- dist.barrier()
+ dist.barrier(group=survivors_group)
layer.destroy()
- dist.barrier()
+ dist.barrier(group=survivors_group)(Alternatively, simply add two matching dist.barrier() calls in the victim branch before return, but a survivors-only group is more robust against future edits changing the call count on either side.)
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| # --- HEALTHY --------------------------------------------------------- | |
| y = layer.forward(t) | |
| torch.cuda.synchronize() | |
| torch.testing.assert_close(y, x, atol=5e-2, rtol=5e-2) | |
| dist.barrier() | |
| fleet = layer._ensure_fleet() | |
| assert fleet.supports_fault_tolerance is True | |
| assert fleet.query_active_mask().cpu().tolist() == [1] * world_size | |
| assert fleet.query_fault() is False | |
| # --- FAULT: the victim stalls past the timeout ------------------------ | |
| if rank == _VICTIM: | |
| time.sleep(_STALL_S) | |
| else: | |
| layer.forward(t) # survivors' kernels time out waiting on the victim | |
| torch.cuda.synchronize() | |
| dist.barrier() # the victim is alive, so the torch PG is still healthy | |
| if rank in survivors: | |
| assert fleet.query_fault() is True, "survivors must observe the fault" | |
| mask = fleet.query_active_mask().cpu().tolist() | |
| assert mask[_VICTIM] == 0, f"rank {_VICTIM} should be masked, got {mask}" | |
| # --- RECONCILE ------------------------------------------------------- | |
| # Every rank reconciles in the same iteration slot. The victim learns it | |
| # was evicted (it stalled long enough for the survivors to give up). | |
| from flashinfer.moe_ep.errors import MoEEpRankEvictedError | |
| if rank == _VICTIM: | |
| with pytest.raises(MoEEpRankEvictedError): | |
| fleet.reconcile_active_mask() | |
| print(f"rank {rank}: correctly evicted") | |
| layer.destroy() | |
| dist.barrier() | |
| return | |
| agreed = fleet.reconcile_active_mask().cpu().tolist() | |
| assert agreed == expected, f"rank {rank} agreed {agreed}, expected {expected}" | |
| fleet.clear_faults(readmit=False) | |
| assert fleet.query_fault() is False, "clear_faults must re-arm detection" | |
| # --- DEGRADED -------------------------------------------------------- | |
| # The dead rank's experts are gone and combine does NOT renormalize, so | |
| # each token comes out scaled by the surviving weight fraction rather | |
| # than 1. Assert exactly that, not "close to x". | |
| y_deg = layer.forward(t) | |
| torch.cuda.synchronize() | |
| assert torch.isfinite(y_deg.float()).all(), "degraded output must not be NaN/Inf" | |
| experts_per_rank = num_experts // world_size | |
| alive = torch.tensor( | |
| [expected[e // experts_per_rank] for e in range(num_experts)], | |
| device="cuda", | |
| dtype=torch.bool, | |
| ) | |
| surviving_w = (topk_weights * alive[topk_ids]).sum(-1) # [num_tokens] | |
| torch.testing.assert_close( | |
| y_deg.float(), x.float() * surviving_w.unsqueeze(-1), atol=8e-2, rtol=8e-2 | |
| ) | |
| print(f"rank {rank}: degraded forward matches surviving-weight scaling") | |
| dist.barrier() | |
| layer.destroy() | |
| dist.barrier() | |
| survivors_group = dist.new_group(survivors) | |
| # --- HEALTHY --------------------------------------------------------- | |
| y = layer.forward(t) | |
| torch.cuda.synchronize() | |
| torch.testing.assert_close(y, x, atol=5e-2, rtol=5e-2) | |
| dist.barrier() | |
| fleet = layer._ensure_fleet() | |
| assert fleet.supports_fault_tolerance is True | |
| assert fleet.query_active_mask().cpu().tolist() == [1] * world_size | |
| assert fleet.query_fault() is False | |
| # --- FAULT: the victim stalls past the timeout ------------------------ | |
| if rank == _VICTIM: | |
| time.sleep(_STALL_S) | |
| else: | |
| layer.forward(t) # survivors' kernels time out waiting on the victim | |
| torch.cuda.synchronize() | |
| dist.barrier() # the victim is alive, so the torch PG is still healthy | |
| if rank in survivors: | |
| assert fleet.query_fault() is True, "survivors must observe the fault" | |
| mask = fleet.query_active_mask().cpu().tolist() | |
| assert mask[_VICTIM] == 0, f"rank {_VICTIM} should be masked, got {mask}" | |
| # --- RECONCILE ------------------------------------------------------- | |
| # Every rank reconciles in the same iteration slot. The victim learns it | |
| # was evicted (it stalled long enough for the survivors to give up). | |
| from flashinfer.moe_ep.errors import MoEEpRankEvictedError | |
| if rank == _VICTIM: | |
| with pytest.raises(MoEEpRankEvictedError): | |
| fleet.reconcile_active_mask() | |
| print(f"rank {rank}: correctly evicted") | |
| layer.destroy() | |
| dist.barrier() | |
| return | |
| agreed = fleet.reconcile_active_mask().cpu().tolist() | |
| assert agreed == expected, f"rank {rank} agreed {agreed}, expected {expected}" | |
| fleet.clear_faults(readmit=False) | |
| assert fleet.query_fault() is False, "clear_faults must re-arm detection" | |
| # --- DEGRADED -------------------------------------------------------- | |
| # The dead rank's experts are gone and combine does NOT renormalize, so | |
| # each token comes out scaled by the surviving weight fraction rather | |
| # than 1. Assert exactly that, not "close to x". | |
| y_deg = layer.forward(t) | |
| torch.cuda.synchronize() | |
| assert torch.isfinite(y_deg.float()).all(), "degraded output must not be NaN/Inf" | |
| experts_per_rank = num_experts // world_size | |
| alive = torch.tensor( | |
| [expected[e // experts_per_rank] for e in range(num_experts)], | |
| device="cuda", | |
| dtype=torch.bool, | |
| ) | |
| surviving_w = (topk_weights * alive[topk_ids]).sum(-1) # [num_tokens] | |
| torch.testing.assert_close( | |
| y_deg.float(), x.float() * surviving_w.unsqueeze(-1), atol=8e-2, rtol=8e-2 | |
| ) | |
| print(f"rank {rank}: degraded forward matches surviving-weight scaling") | |
| dist.barrier(group=survivors_group) | |
| layer.destroy() | |
| dist.barrier(group=survivors_group) |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@tests/moe_ep/test_moe_ep_fault_tolerance_multirank.py` around lines 162 -
226, Fix the barrier mismatch in the reconciliation and teardown flow of the
fault-tolerance test: create and use a survivors-only process group for the two
post-eviction synchronization barriers, including the degraded-forward
synchronization and final cleanup, while keeping the victim’s existing
WORLD-group barriers before eviction. Ensure the survivors-only group is
destroyed or released appropriately after use.
|
from the NCCL perspective it looks about right to me. I scanned the documentation and the code. I have a few comments about these "gotchyas":
True, however the expectation is the kernel will discover the problem first, so torch application should be typically checking the mask/error rather than trying to update it.
As I understand it, update_topology() creates a new NCCL communicator? (it is the only option for NCCL to admit a new process). In that case the graph has changed as well.
I think I agree with this point just not the wording. I agree that querying if mask has changed or if there has been an error during a graph capture is going to capture a graph that ignores errors by assuming whatever the capture saw will be true forever. But regarding stale offsets, NCCL-EP doesn't "squeeze" the remaining ranks in data layout. Instead the data arriving from those ranks is left in an unknown state, and an error is flagged. The application may end up invoking the dispatch and combine again and again before the CUDA-graph is complete, and each time we respect the mask and don't wait on masked ranks. In fact, the application may choose to re-route experts away from the failed rank with something like EPLB, in which case the rank remains failed, but the application can clear the error state and continue with a partial mask (without update_topology). I think you call this clear_faults(readmit=False) right? |
In steady state,
If there are two rank which have different mask we need to combine them with bitwise and, to have agreed survivors ? Done via Hope that makes sense. Let me know if my understanding is incorrect.
Yes.
Correct, will update the doc. Question to you @lrbison |
…ure rationale Addresses review from @lrbison (NCCL) on PR flashinfer-ai#4183. The behavioural fix: NcclEpFleet.query_fault() had NO CUDA-graph capture guard. I had reasoned "host read, therefore capture-safe" -- which is exactly backwards. Because it is a host read rather than stream work it cannot be captured at all, so inside a capture region it returns the capture-time answer and the branch taken on it is frozen into the graph's structure permanently: a graph that ignores faults forever because none had happened when it was recorded. That is the quietest failure of the FT calls, and it was the only one left unguarded. It was also a backend inconsistency: nixl's query_fault() reaches query_active_mask() and therefore already raised, so the same API call raised on one transport and silently misbehaved on the other. Both now raise, pinned by a regression test on each backend. The rationale fix: the previous guard message and the runbook claimed a captured query "replays stale offsets". It doesn't -- as lrbison points out, NCCL-EP does not compact the surviving ranks' data layout. Data from a masked rank is left in an unknown state and the error flag is raised, so dispatch/combine are in fact SAFE to capture: they re-read the mask on every replay and a rank that fails later is still skipped. The hazard is never the data movement, only baking a *decision about* fault state into a graph. The single blanket guard message was therefore wrong for at least one call, so it is now per-operation (reject_graph_capture in core/comm/fault_tolerance.py, shared by both backends) and states the true reason for each: query_fault cannot be captured at all; set_active_mask bakes rank+value into the captured op; query_active_mask can only be consumed on the host. The message also says explicitly that dispatch/combine ARE capturable, since that was the misleading implication. Docs (no behaviour change): * new rule 0 -- the steady state is READ-only. The transport discovers the fault; the application notices. set_active_mask is the exceptional reconciliation path, not something callers drive per step. * rule 3 rewritten: clear_faults(readmit=True) and update_topology() are ALTERNATIVES, not a sequence. Confirmed against the code that update_topology destroys the group and builds a fresh communicator (unless one was adopted via BootstrapConfig.nccl_comm), so re-admitting after a rebuild is meaningless and before one is wasted work. * dropped-token section now states that FlashInfer does not re-home experts either -- that is the EPLB job -- and spells out the partial-mask flow (reconcile -> clear_faults(readmit=False) -> keep serving), which is what lrbison was checking. AI-assisted (Claude Opus 5). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 2
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (2)
docs/design_docs/moe_ep_runbook.md (2)
439-440: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick winDo not use a clamp as the zero-survivor fallback.
When
surviving_w == 0, clamping to1e-6does not drop the token; it can amplify residual output instead. Explicitly mask/drop zero-survivor tokens and divide only rows with positive surviving weight.Proposed handling
- out.div_(surviving_w.clamp_min(1e-6).unsqueeze(-1)) + valid = surviving_w > 0 + out[valid] /= surviving_w[valid].unsqueeze(-1) + out[~valid] = 0🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@docs/design_docs/moe_ep_runbook.md` around lines 439 - 440, Update the normalization logic around surviving_w so zero-survivor tokens are explicitly masked or dropped rather than handled with clamp_min. Divide only rows where surviving_w is positive, while preserving the existing normalization for valid rows and ensuring zero-survivor outputs are not amplified.
436-440: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick winGuard the expert-owner mapping against invalid topology shapes.
This formula assumes
num_expertsis evenly divisible byworld_size; otherwise the final experts can map to ownerworld_sizeandalive[...]raises an out-of-bounds error. It also divides by zero when there are fewer thanworld_sizeexperts. State and validate the precondition, or use the framework’s actual expert-ownership map.Proposed validation
+if num_experts <= 0 or num_experts % world_size != 0: + raise ValueError("num_experts must be a positive multiple of world_size") owner = torch.arange(num_experts, device=d) // (num_experts // world_size)🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@docs/design_docs/moe_ep_runbook.md` around lines 436 - 440, Update the expert-owner mapping around owner and alive indexing to validate that num_experts is at least world_size and evenly divisible by world_size before applying the formula; otherwise fail with a clear precondition error. Prefer the framework’s authoritative expert-ownership map if available, while preserving the existing masking and normalization behavior for valid topology shapes.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@docs/design_docs/moe_ep_runbook.md`:
- Line 424: Update the fenced code block in the runbook section to include a
language identifier, using text or another appropriate language after the
opening fence, while preserving the block’s contents.
- Around line 366-387: Update the CUDA-graph capture contract section to state
one definitive behavior for every guarded fault-tolerance API, preferably that
each call is rejected immediately during capture. Explicitly enumerate all four
APIs, including query_fault(), set_active_mask(), query_active_mask(), and the
remaining guarded operation identified elsewhere in the document, and remove
contradictory claims that query_fault() returns a capture-time value or that
only three operations are listed.
---
Outside diff comments:
In `@docs/design_docs/moe_ep_runbook.md`:
- Around line 439-440: Update the normalization logic around surviving_w so
zero-survivor tokens are explicitly masked or dropped rather than handled with
clamp_min. Divide only rows where surviving_w is positive, while preserving the
existing normalization for valid rows and ensuring zero-survivor outputs are not
amplified.
- Around line 436-440: Update the expert-owner mapping around owner and alive
indexing to validate that num_experts is at least world_size and evenly
divisible by world_size before applying the formula; otherwise fail with a clear
precondition error. Prefer the framework’s authoritative expert-ownership map if
available, while preserving the existing masking and normalization behavior for
valid topology shapes.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: b8871016-62c4-41f5-8ddb-1939ce65f0f4
📒 Files selected for processing (7)
docs/design_docs/moe_ep_runbook.mdflashinfer/moe_ep/backends/split/comm/nccl_ep/fleet.pyflashinfer/moe_ep/backends/split/comm/nixl_ep/fleet.pyflashinfer/moe_ep/core/comm/fault_tolerance.pyflashinfer/moe_ep/core/comm/fleet.pytests/moe_ep/nccl_ep/test_fleet_mock.pytests/moe_ep/nixl_ep/test_fleet_mock.py
🚧 Files skipped from review as they are similar to previous changes (6)
- tests/moe_ep/nixl_ep/test_fleet_mock.py
- flashinfer/moe_ep/core/comm/fault_tolerance.py
- tests/moe_ep/nccl_ep/test_fleet_mock.py
- flashinfer/moe_ep/core/comm/fleet.py
- flashinfer/moe_ep/backends/split/comm/nixl_ep/fleet.py
- flashinfer/moe_ep/backends/split/comm/nccl_ep/fleet.py
|
Yes -- I think we are in agreement about query_active_mask / set_active_mask.
The approach we took was to assume that communication would naturally drive eventual consistency. However you are right to explicitly synchronize masks at the FlashInfer level to be sure.
Yes, the use case is framework-detected updates. This could be recovering from a false-positive timeout: Some rank decided to move on and mask out a peer, but after the forward pass the framework did its own synchronization and decided that actually all peers are healthy and should rejoin without any process changes. Or it could be some separate scale-down signal that application is handling by masking and exiting some of the ranks. For scale-up or replacement the API is not useful unless NCCL eventually provides a way to admit new processes into an existing group (which isn't possible today). |
|
/bot run tests/moe_ep |
bkryu
left a comment
There was a problem hiding this comment.
Hi @Anerudhan, CI is failing on test_deep_gemm_mega_kernel_vs_reference.py in cu130 configs. Plz take a look
@mhoqueanik is going to be fixing this in a separate PR. Not introduced by this PR |
…all managers flashinfer.moe_ep gained an EP rank-mask API (flashinfer-ai/flashinfer#4183): a peer that stops responding during dispatch/combine is masked and skipped instead of tripping a GPU trap() that takes the job down. vLLM already owns the consumer contract -- All2AllManagerBase.support_fault_tolerance / query_active_mask / query_fault, plus the per-step check_ep_fault hook in gpu_model_runner -- but the FlashInfer-EP managers hardcoded support_fault_tolerance = False, so none of it was reachable. This connects the two ends. Because the manager base is transport-parameterized (_transport), both flashinfer_ep_low_latency (NCCL-EP) and flashinfer_ep_nixl (NIXL-EP) get fault tolerance from the same change. No gpu_model_runner change is needed: its hooks are generic and already gated on support_fault_tolerance. Opt-in AND capability-gated. VLLM_FLASHINFER_EP_FAULT_TOLERANCE (default 0) turns it on, but only takes effect if the backend can actually serve it: nccl_ep additionally needs an nccl4py whose GroupConfig carries enable_mask plus a libnccl_ep exporting the ncclEpMask* symbols. Asking for FT on a build that cannot provide it logs a warning and keeps the previous fail-fast behaviour rather than silently claiming to be fault tolerant. The new has_flashinfer_moe_ep_fault_tolerance() probe also returns False against a FlashInfer predating the FT API, so this is safe across versions. Three details worth calling out for review: * HT must override the capability to False rather than inherit it. Rank masking is LOW_LATENCY-only on both transports (nccl_ep leaves the mask buffer NULL under HT and the mask APIs then abort the process; nixl_ep has no HT mask path), and FlashInfer's validate_fleet_params rejects FT + HIGH_THROUGHPUT at fleet construction. * self._fleets holds one Fleet per distinct MoE sizing, but they all share a single EP process group, so their masks describe the same live-rank set. FT is therefore driven from one designated primary Fleet: single-valued state, and one store round per fault instead of one per fleet. Reconciling each independently would let them drift. * Polarity differs from the other managers. FlashInfer normalizes both transports to 1 = active, whereas NixlEPAll2AllManager and DeepEPLLAll2AllManager return their RAW buffers (nonzero = masked) despite the query_active_mask() name. Harmless today since consumers only diff a mask against itself, but the masks are not comparable across manager types; noted in the docstring. Normalizing the base contract to 1 = active would be a good follow-up. VLLM_FLASHINFER_EP_TIMEOUT_MS (default 0 = transport default, ~100s nccl_ep / 30s nixl_ep) sets the GPU wait-loop timeout; too low marks merely-slow ranks dead. Scope: detection parity with the native nixl_ep/DeepEP managers. vLLM still turns a detected fault into a RuntimeError -- degraded serving (reconcile -> clear_faults(readmit=False) -> keep going on a partial mask, with EPLB re-homing the dead rank's experts) has nowhere to live in the engine yet. FlashInfer exposes reconcile_active_mask/clear_faults for when it does. Verified: ruff check + ruff format clean; env plumbing exercised. The FT paths themselves need >=4 GPUs and a built EP transport, matching the rest of this PR's test story. AI-assisted (Claude Opus 5). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Anerudhan Gopal <agopal@nvidia.com>
…all managers flashinfer.moe_ep gained an EP rank-mask API (flashinfer-ai/flashinfer#4183): a peer that stops responding during dispatch/combine is masked and skipped instead of tripping a GPU trap() that takes the job down. vLLM already owns the consumer contract -- All2AllManagerBase.support_fault_tolerance / query_active_mask / query_fault, plus the per-step check_ep_fault hook in gpu_model_runner -- but the FlashInfer-EP managers hardcoded support_fault_tolerance = False, so none of it was reachable. This connects the two ends. Because the manager base is transport-parameterized (_transport), both flashinfer_ep_low_latency (NCCL-EP) and flashinfer_ep_nixl (NIXL-EP) get fault tolerance from the same change. No gpu_model_runner change is needed: its hooks are generic and already gated on support_fault_tolerance. Opt-in AND capability-gated. VLLM_FLASHINFER_EP_FAULT_TOLERANCE (default 0) turns it on, but only takes effect if the backend can actually serve it: nccl_ep additionally needs an nccl4py whose GroupConfig carries enable_mask plus a libnccl_ep exporting the ncclEpMask* symbols. Asking for FT on a build that cannot provide it logs a warning and keeps the previous fail-fast behaviour rather than silently claiming to be fault tolerant. The new has_flashinfer_moe_ep_fault_tolerance() probe also returns False against a FlashInfer predating the FT API, so this is safe across versions. Three details worth calling out for review: * HT must override the capability to False rather than inherit it. Rank masking is LOW_LATENCY-only on both transports (nccl_ep leaves the mask buffer NULL under HT and the mask APIs then abort the process; nixl_ep has no HT mask path), and FlashInfer's validate_fleet_params rejects FT + HIGH_THROUGHPUT at fleet construction. * self._fleets holds one Fleet per distinct MoE sizing, but they all share a single EP process group, so their masks describe the same live-rank set. FT is therefore driven from one designated primary Fleet: single-valued state, and one store round per fault instead of one per fleet. Reconciling each independently would let them drift. * Polarity differs from the other managers. FlashInfer normalizes both transports to 1 = active, whereas NixlEPAll2AllManager and DeepEPLLAll2AllManager return their RAW buffers (nonzero = masked) despite the query_active_mask() name. Harmless today since consumers only diff a mask against itself, but the masks are not comparable across manager types; noted in the docstring. Normalizing the base contract to 1 = active would be a good follow-up. VLLM_FLASHINFER_EP_TIMEOUT_MS (default 0 = transport default, ~100s nccl_ep / 30s nixl_ep) sets the GPU wait-loop timeout; too low marks merely-slow ranks dead. Scope: detection parity with the native nixl_ep/DeepEP managers. vLLM still turns a detected fault into a RuntimeError -- degraded serving (reconcile -> clear_faults(readmit=False) -> keep going on a partial mask, with EPLB re-homing the dead rank's experts) has nowhere to live in the engine yet. FlashInfer exposes reconcile_active_mask/clear_faults for when it does. Verified: ruff check + ruff format clean; env plumbing exercised. The FT paths themselves need >=4 GPUs and a built EP transport, matching the rest of this PR's test story. AI-assisted (Claude Opus 5). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Anerudhan Gopal <agopal@nvidia.com>
Addresses review feedback: both guards are dead code now that vLLM pins a FlashInfer containing these APIs. requirements/cuda.txt pins flashinfer-python==0.6.17, and the FT API merge commit (flashinfer-ai/flashinfer#4183, b575db17) is an ancestor of the v0.6.17 tag -- verified, not assumed -- so `flashinfer.moe_ep` and `supports_fault_tolerance` are both guaranteed importable. - has_flashinfer_moe_ep: the `find_spec` check above already covers the package being absent, so the try/except around `available_backends()` only masked real errors. - has_flashinfer_moe_ep_fault_tolerance: the ImportError arm existed purely for a FlashInfer predating the FT API, which the pin now rules out. The broad `except Exception` went with it: `supports_fault_tolerance` is documented never to raise (it reports False rather than propagating a probe failure), so nothing here needs catching. Docstring updated to match -- it previously promised compatibility with older FlashInfer, which is no longer what the code does. AI-assisted (Claude Code). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Anerudhan Gopal <agopal@nvidia.com>
## 📌 Description
A `moe_ep` split-path `Handle` is created per forward today, which makes
the path impossible to capture into a CUDA graph. A graph records the
device pointers it sees at capture time, so a handle created *and
destroyed* inside the captured forward leaves the replay dereferencing
freed memory.
Measured on 4×B200 (dp=4, Qwen3-30B-A3B, driven through vLLM's
`flashinfer_ep_low_latency` backend):
| Configuration | Result |
|---|---|
| `--enforce-eager` | rc=0, ~616 tok/s per rank across 4 ranks |
| CUDA graphs enabled | capture completes (PIECEWISE 35/35, FULL 35/35),
then the **first replay** raises `CUDA error: an illegal memory access
was encountered` |
Silent at capture, crash at replay — invisible to any test that doesn't
actually run inference under graphs.
**NCCL-EP itself supports capture.** `contrib/nccl_ep/ep_test.cu` has a
`--use_cuda_graph` mode, and it is *not* gated on the algorithm, so LL
and HT are both captured there. Its recipe is a split:
> Non-graph mode tests CreateHandle (combined Init+Update). Graph mode
tests the Init+Update split, where **InitHandle stays outside the
capture** (host-side allocation only) **and UpdateHandle is recorded
inside** the captured region.
> — `ep_test.cu:478-481`
and `nccl_ep.h:422` states the rule directly:
> to avoid CUDA graph invalidation, all Handles must be created before
the beginning of the CUDA graph capture.
Two changes are needed, and **the second is the one that actually makes
capture work**.
### 1. `Handle.update()` — the missing per-step half
`moe_ep` only ever called the combined `ncclEpCreateHandle`, so the
Init/Update split could not be expressed through this API.
```python
# Before: one handle per forward -- uncapturable
for step in ...:
handle = fleet.create_handle(HandleParams(topk_ids=topk_ids)) # Init + Update
handle.dispatch(...); handle.combine(...); handle.complete()
# After: one durable handle, per-step rebind -- capturable
handle = fleet.create_handle(HandleParams(topk_ids=topk_buf)) # outside capture
for step in ...:
handle.update(HandleParams(topk_ids=topk_buf)) # inside capture
handle.dispatch(...); handle.combine(...); handle.complete()
```
Optional capability with a raising default on the ABC, matching
`dispatch_send_only` / `dispatch_recv_only`, so `NixlEpHandle` and any
out-of-tree `Handle` are unaffected.
### 2. `NcclEpHandle._op_stream()` — issue transport work on the capture
stream
Every dispatch/combine/complete previously issued on `self._stream`, the
handle's **creation-time** stream (the `HandleAlgoKnobUserStream` value,
else the fleet's). That is correct for a per-forward handle, which is
created on the same stream it runs on. It is wrong for a persistent one:
it is created *before* the capture begins, so its stream is not the
stream being captured, and the transport work lands outside the graph
entirely — **the capture records nothing and the replay is a silent
no-op.**
`_op_stream()` returns the capture stream while capturing and
`self._stream` otherwise, so non-graph behaviour — including an explicit
`UserStream` — is byte-identical. Handle *creation* deliberately still
uses `self._stream`: `nccl_ep.h:422` requires it to happen outside any
capture.
This one is easy to miss because it fails silently rather than loudly;
`update()` alone is not sufficient.
## 🧪 Verification on 4×B200
`tests/moe_ep/test_moe_ep_cudagraph_multirank.py`, 4 ranks, **both
algorithms, all ranks passing**:
```
rank 0..3: low_latency capture + replay OK across changed routing
rank 0..3: high_throughput capture + replay OK across changed routing
2 passed
```
The test asserts three properties in increasing order of what they would
catch:
1. capture completes;
2. replay does not fault and reproduces the eager result — the
regression above;
3. **the transport's reported routing changes when `topk_ids` is
rewritten in place between replays.** This is the one that matters: an
identity round trip is routing-invariant, so comparing outputs cannot
distinguish "`update()` replayed" from "`update()` skipped". The test
interrogates the transport (`expert_counts`, falling back to
`recv_topk_idx`) instead.
Assertions are algorithm-aware. **HT's identity round trip is
deliberately not asserted, because HT does not have that property:**
`_dispatch_ht` sizes its recv buffer to `max_tokens_per_rank * world`
and dispatch only writes the slots that actually received tokens, so an
identity pass-through hands `combine` the unwritten remainder. Real HT
consumers compute over the whole static buffer or trim to
`recv_total_counter`. Capture correctness is still fully covered for HT
(replay must match eager, routing must track across replays); only the
numerical check is weaker than LL's.
The test also completes all collective work and tears the fleet down
*before* asserting — a bare assert mid-test aborts one rank inside a
collective and strands the rest at the next barrier, turning a one-line
failure into a wedged multi-hour job.
## 🔍 Related Issues
Follow-up to #4183 (EP fault tolerance). Consumer:
vllm-project/vllm#47948.
**No caller in this repo uses `update()` yet** — consuming it is the
vLLM follow-up, which pins `flashinfer-python` and so needs a release
first.
## 🚀 Pull Request Checklist
### ✅ Pre-commit Checks
- [x] I have installed `pre-commit` by running `pip install pre-commit`
(or used your preferred method).
- [x] I have installed the hooks with `pre-commit install`.
- [x] I have run the hooks manually with `pre-commit run --all-files`
and fixed any reported issues.
## 🧪 Tests
- [x] Tests have been added or updated as needed.
- [x] All tests are passing (`unittest`, etc.).
`tests/moe_ep/nccl_ep/test_handle_mock.py` gains eight cases:
- rebinding **reuses the native handle** rather than creating a second
one — the property that makes capture safe, and the one that would
regress silently
- `top_k` changes are rejected
- growing past the creating token count is rejected; **shrinking is
allowed** (decode steps are smaller than the capture shape)
- the ABC default raises `NotImplementedError`
- `update()` is capturable — i.e. contains no host sync, the failure
mode that makes a prepare path uncapturable
- the caller's buffer is **bound, not copied** (a copy would make every
replay re-run stale routing)
- outside capture, ops stay on the knob stream (pins that `_op_stream()`
did not change non-graph behaviour)
- under capture, ops move to the capture stream
`FakeHandle` in `tests/moe_ep/nccl_ep/conftest.py` gains an `update()`
mirroring `ncclEpUpdateHandle`'s contract (rebinds routing, never
reallocates).
**72 passed** in the `nccl_ep` mock suite, locally and on the 4×B200
rig; `ruff check` / `ruff format` clean; `pre-commit` clean.
AI-assisted (Claude Code); every change reviewed and the failure
reproduced end-to-end by the submitter.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit
* **New Features**
* Added support for updating existing communication handles with new
routing information without reallocating buffers.
* Enables handle reuse across forward passes and CUDA graph replays.
* Ensures communication operations use the appropriate stream during
CUDA graph capture.
* **Bug Fixes**
* Added validation for unsupported updates, routing changes, token
limits, and low-latency hidden sizes.
* Prevents dispatch operations from exceeding per-rank capacity limits.
* **Tests**
* Expanded coverage for handle reuse, CUDA graph replay, capacity
limits, routing validation, and stream behavior.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
---------
Signed-off-by: Anerudhan Gopal <agopal@nvidia.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Why
NCCL-EP gained fault tolerance (
nccl_ep.henable_mask+ncclEpMask*): a peer that times out during dispatch/combine is masked and skipped instead of tripping a GPUtrap(). NIXL-EP has the equivalent (update/query/clean_mask_buffer).flashinfer.moe_epexposed neither — it never setenable_mask, never called a mask API, and gave callers no way to learn a rank had died. One slow or dead EP rank killed the whole job.This adds the FT surface over both transports.
moe_epis pure Python, so there is no CUDA/C++ change here.How the FT API is used
FT is opt-in per Fleet via a knob, not new
FleetParamsfields —FleetParamsis the frozen sizing dataclass, while optional transport features already live in the knob namespace (cf.FleetAlgoKnobTopologyCapacity). The runtime API lands on theFleetABC as concrete raising defaults, not@abstractmethod, so no existing or out-of-tree Fleet breaks.1. Probe, then enable
FleetAlgoKnobFaultTolerance(enabled=True, timeout_ms=0, reconcile_timeout_s=30.0, coordinator_takeover_s=10.0). LOW_LATENCY only on both transports —validate_fleet_paramsrejects FT + HIGH_THROUGHPUT at construction, because nccl leaves the mask buffer NULL under HT and the mask APIs then abort the process.2. Serve, poll, recover
supports_fault_tolerancequery_fault()query_active_mask(out=None)set_active_mask(mask)reconcile_active_mask()clear_faults(readmit=False)clear_faults(readmit=True)active_mask_epochCanonical mask:
int32[world_size],1 = active, CUDA tensor — matchingncclEpMaskQueryand vLLM'squery_active_mask()naming.3. Rules that will bite you
query_fault/query_active_mask).set_active_maskis the exceptional reconciliation path, not a per-step call — most callers only reach it viareconcile_active_mask(). If you do write the mask, write it only between iterations (kernels read it live).active_mask_epoch.clear_faults(readmit=True)andupdate_topology()are alternatives, not a sequence. The former re-admits a merely-delayed rank on the same communicator; the latter destroys the group and builds a newncclComm_t, which is the only way to add or replace a process. Re-admitting after a rebuild is meaningless; before one it is wasted work. (If you do both,MaskCleanmust come first — it needs a live handle on the current group.)query_fault()is a host read, not stream work, so it can't be captured at all — it returns the capture-time answer and freezes the branch taken on it into the graph forever. All four FT entry points raise on capture with a per-operation reason. (Thanks @lrbison — an earlier draft of this said "stale offsets", which was wrong.)reconcile_active_mask()raisesMoEEpRankEvictedErrorwhen the survivors agreed this rank is gone. It can't apply that (a rank may not mask itself) and mustn't keep serving (peers stopped sending it tokens).y_degraded[t] == y_healthy[t] * Σ_{alive k} topk_weights[t][k]. Both omissions are deliberate: implicit renormalization would add a kernel to every forward, hide a serving-quality event, and divide by zero when a token's whole top-k died; re-homing the dead rank's experts is an EPLB-style job that belongs to the framework. Keep serving on a partial mask withreconcile_active_mask()→clear_faults(readmit=False)— noupdate_topology, no new communicator. Runbook has the opt-in renormalization snippet.Call stacks
Group/Buffer creation (where masking is switched on)
query_fault()query_active_mask()set_active_mask(mask)reconcile_active_mask()— shared, transport-freeDeliberately not a
torch.distributedallreduce: that would hang on exactly the rank being masked out. With a store, a missing key is the death signal.clear_faults()Three transport facts that shaped the code
0xFF-memset at allocation (an untouched entry reads back as-1) and the kernels test!= 0. The normalization must be(raw == 0); the obvious1 - rawyields2for never-connected capacity-tail ranks and silently poisons every downstreamsum()/bool().clean_mask_bufferzeroes all capacity entries, marking the never-connected tail active — a live bug on any fleet sized above its world.disconnect_ranksis suffix-only, so NIXL cannot evict a middle rank; masked-and-degraded is the terminal state there.Each has a regression test.
nccl4py binding gap
nccl4pybindsGroupConfig.enable_mask/timeout_nsbut itsGroupstops atcreate/create_handle/destroy/.ptr— the five mask functions are unreachable from Python. This adds a ctypes shim onGroup.ptrthat tries a nativeGroupmethod first, so it retires itself with no call-site churn once those bindings land. Note the symbols live inlibnccl_ep.so, notlibnccl.so.2; we bind the process-global namespace, which is the only resolution guaranteed to be the same library the caller's group came from.Reconciliation, and a hole the tests found
Each transport masks locally — NCCL-EP's header calls mask consistency "a framework-level concern" — so survivors can disagree, and disagreeing masks deadlock the next dispatch. The decision is published via a single atomic
compare_set, which is what prevents a split brain when a straggler's key lands between two survivors' polls; there is a dedicated regression test for that timing.Writing those tests surfaced a case the design missed: a rank that is alive but that some peer already timed out on gets ANDed out of the group. It can't apply that decision and can't ignore it, so it now raises
MoEEpRankEvictedError.Also fixed (incidental)
validate_fleet_paramsnow rejects a nixl topology capacity below world size — it previously sailed through and went out of bounds inside the transport.update_topologynow rejects growing past the capacity, for the same reason.test_update_topology_diffs_rankswas exercising that invalid config (4→6 on capacity 4); it now passescapacity=8, keeping its intent.Testing
254 passedintests/moe_ep/; 166 FT-related tests pass. The 6 failures in the full run are pre-existing and environmental (nvfp4/CuTeDSL ninja JIT) — confirmed failing identically on base6258e522.The protocol, shim and both backends' wiring are covered host-only (HashStore + threads; a fake ctypes library; fake transports), so they run in CI with no GPU.
Not yet run: the 4-GPU tiers —
test_moe_ep_fault_tolerance_multirank.py(stalls a middle rank and walks detect → reconcile → degrade → re-admit, asserting the degraded output exactly equals the surviving-weight scaling) andsmoke_ft_ep.py(hard SIGTERM kill). This host has 1 GPU and no transport built.bash tests/moe_ep/run_tests.sh ftruns both.vLLM
No vLLM code here, by design. vLLM already owns the contract (
support_fault_tolerance/query_active_mask/query_fault+ the per-step hook ingpu_model_runner.py); the FlashInfer-EP managers just hardcodeFalse, and since the manager is transport-parameterized, one base-class change covers both LL backends.docs/design_docs/vllm_moe_ep_integration.md§8 records the target shape and the four things that commit must get right — including that vLLM's existing nixl/deepepquery_active_mask()return raw buffers with the opposite polarity, and thatself._fleetsholds several fleets sharing one EP group so FT state must be hoisted to a primary fleet.Review follow-up (commit 9)
@lrbison's review caught a real defect:
query_fault()on nccl_ep had no capture guard, because I'd reasoned "host read, therefore capture-safe" — exactly backwards. Being a host read is why it can't be captured, so it silently returned the capture-time answer and froze the branch on it into the graph. NIXL's already raised (it goes viaquery_active_mask), so the same call behaved differently per backend. Both now raise, with a regression test each. The "stale offsets" rationale was also wrong and is replaced with per-operation reasons.Commits
FleetAlgoKnobFaultTolerance+ Fleet FT API surface (no backend touched)core.bootstrap_utils(pure move)ncclEpMask*APIenable_mask/timeout_ns+ FT methodstimeout_ms+ FT methods (carries the polarity/capacity/tail fixes)run_tests.sh ftfix: guardquery_faultagainst graph capture + correct the capture rationale (review follow-up)🤖 Generated with Claude Code
Summary by CodeRabbit