Skip to content

feat(moe_ep): fault-tolerance rank mask (NCCL-EP + NIXL-EP) - #4183

Merged
Anerudhan merged 10 commits into
flashinfer-ai:mainfrom
Anerudhan:moe-ep-ft-mask
Jul 28, 2026
Merged

Anerudhan merged 10 commits into
flashinfer-ai:mainfrom
Anerudhan:moe-ep-ft-mask

Conversation

@Anerudhan

@Anerudhan Anerudhan commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator

Why

NCCL-EP gained fault tolerance (nccl_ep.h enable_mask + ncclEpMask*): a peer that times out during dispatch/combine is masked and skipped instead of tripping a GPU trap(). NIXL-EP has the equivalent (update/query/clean_mask_buffer). flashinfer.moe_ep exposed neither — it never set enable_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_ep is 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 FleetParams fields — FleetParams is the frozen sizing dataclass, while optional transport features already live in the knob namespace (cf. FleetAlgoKnobTopologyCapacity). The runtime API lands on the Fleet ABC as concrete raising defaults, not @abstractmethod, so no existing or out-of-tree Fleet breaks.

1. Probe, then enable

from flashinfer.moe_ep import (
    FleetAlgoKnobFaultTolerance, FleetAlgoKnobTopologyCapacity,
    MoEEpLayer, supports_fault_tolerance,
)

# Needs more than the backend being built: nccl_ep also needs an nccl4py with
# GroupConfig.enable_mask AND a libnccl_ep exporting ncclEpMask*. Never raises.
assert supports_fault_tolerance("nccl_ep")

knobs = [FleetAlgoKnobFaultTolerance(timeout_ms=5000)]   # 0 = transport default
# nixl only: size the capacity for the largest world you will ever reach
knobs.append(FleetAlgoKnobTopologyCapacity(n=32))

layer = MoEEpLayer(bootstrap, fleet_params, weights, fleet_knobs=knobs,
                   backend=SplitConfig(comm=NCCLEPConfig(), kernel=IdentityConfig()))

FleetAlgoKnobFaultTolerance(enabled=True, timeout_ms=0, reconcile_timeout_s=30.0, coordinator_takeover_s=10.0). LOW_LATENCY only on both transports — validate_fleet_params rejects 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

fleet = layer._ensure_fleet()

for step in ...:
    layer.forward(t)

    # Between iterations only — both transports read the mask LIVE from the
    # dispatch/combine kernels, so mutating it mid-collective is a race.
    if fleet.query_fault():                       # free on nccl (pinned host flag)
        agreed = fleet.reconcile_active_mask()    # store-collective, death-tolerant
        fleet.clear_faults(readmit=False)         # re-arm detection; keep serving DEGRADED

        # ... later, if the peer comes back (collective over survivors, blocking):
        # fleet.clear_faults(readmit=True)
Method Collective? Blocks host? Stream-ordered?
supports_fault_tolerance no no
query_fault() local nccl no / nixl yes (small D2H) nccl no / nixl yes
query_active_mask(out=None) local no yes
set_active_mask(mask) local (must be applied identically everywhere) no yes
reconcile_active_mask() store-collective, tolerates dead ranks yes (≤ timeout) yes
clear_faults(readmit=False) local no no
clear_faults(readmit=True) collective over survivors yes yes
active_mask_epoch no no

Canonical mask: int32[world_size], 1 = active, CUDA tensor — matching ncclEpMaskQuery and vLLM's query_active_mask() naming.

3. Rules that will bite you

  1. The steady state is read-only. The transport discovers the fault and masks the peer; the application's job is to notice (query_fault / query_active_mask). set_active_mask is the exceptional reconciliation path, not a per-step call — most callers only reach it via reconcile_active_mask(). If you do write the mask, write it only between iterations (kernels read it live).
  2. All survivors must reconcile in the same iteration slot — they must pass the same active_mask_epoch.
  3. clear_faults(readmit=True) and update_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 new ncclComm_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, MaskClean must come first — it needs a live handle on the current group.)
  4. No FT call during CUDA-graph capture — but note dispatch and combine themselves ARE safe to capture: neither transport compacts the surviving ranks' layout, so they re-read the mask on every replay and a rank that fails later is still skipped. What must not be captured is a decision about fault state. 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.)
  5. A rank can be told it is dead. reconcile_active_mask() raises MoEEpRankEvictedError when 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).
  6. Dropped tokens are not renormalized, and experts are not re-homed. 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 with reconcile_active_mask()clear_faults(readmit=False) — no update_topology, no new communicator. Runbook has the opt-in renormalization snippet.

Call stacks

Group/Buffer creation (where masking is switched on)

nccl_ep                                        nixl_ep
-------                                        -------
NcclEpFleet.__init__                           NixlEpFleet.__init__
 ├ _index_knobs -> self._ft                     ├ _index_knobs -> self._ft
 ├ validate_fleet_params(fault_tolerance=)      ├ validate_fleet_params(fault_tolerance=)
 ├ _check_ft_supported()                        ├ nixl_ep.Buffer(..., timeout_ms=)   [TypeError -> actionable]
 │   ├ dataclasses.fields(GroupConfig)          ├ update_memory_buffers(cap, ...)
 │   │   -> needs "enable_mask"                 │   -> allocates mask_buffer[capacity], 0xFF-memset
 │   └ mask_ffi().available                     └ connect_ranks([0, world))
 │       -> needs the 5 ncclEpMask* symbols         -> unmasks [0, world); tail stays masked
 └ _build_group_config()
     ├ kwargs["enable_mask"] = True
     ├ kwargs["timeout_ns"] = timeout_ms * 1e6      (omitted when 0)
     └ nccl.ep.Group.create -> ncclEpCreateGroup
         -> allocates mask_buffer[nRanks] + pinned async-error flag

query_fault()

nccl_ep                                        nixl_ep
NcclEpFleet.query_fault                        NixlEpFleet.query_fault
 ├ reject_graph_capture("query_fault")          ├ self.query_active_mask()      (kernel + D2H)
 │   ^ NOT because it touches a stream --        │   ^ which itself rejects capture
 │     because it does NOT: a host read          └ compare against self._ft_applied
 │     cannot be captured, so it would
 │     return the capture-time answer and       (no host error flag on this transport,
 │     freeze the branch into the graph          so the applied mask IS the state)
 └ mask_ffi().get_async_error(group)
    ├ group.get_async_error()  [native first]
    └ ctypes ncclEpGetAsyncError(group.ptr, &o)
        -> libnccl_ep.so -> reads PINNED HOST flag: no stream, no sync, free

query_active_mask()

nccl_ep                                        nixl_ep
 ├ _reject_graph_capture()                      ├ _reject_graph_capture()
 ├ _ft_bufs() -> device int32[world]            ├ _ft_raw() -> device int32[CAPACITY]
 └ mask_ffi().mask_query(group, dev_ptr, str)   ├ buffer.query_mask_buffer(raw)
     -> ncclEpMaskQuery                         │   -> nixl_ep_cpp -> kernel copy
     -> D2D copy of mask_buffer                 │   (asserts numel == max_num_ranks)
     -> ALREADY 1 = active, identity            └ (raw[:world] == 0).to(int32)
                                                    ^ NOT 1-raw: buffer is 0xFF-memset so
                                                      untouched entries read back as -1

set_active_mask(mask)

nccl_ep                                        nixl_ep
 ├ _normalize_mask -> list[int], rejects        ├ _normalize_mask (same guard)
 │   masking the local rank                     └ for each CHANGED rank r != self:
 ├ host.copy_(...)   PINNED staging buffer          buffer.update_mask_buffer(r, mask=(a==MASKED))
 │   ^ pinned because Update is stream-ordered:      -> atomicExch kernel, one launch EACH
 │     a pageable buffer mutated next call is        -> so we push only the DIFF; a blind
 │     a use-after-write race                          range(world) loop would inject `world`
 └ mask_ffi().mask_update(group, host_ptr, str)        launches into the steady state
     -> ncclEpMaskUpdate  (HOST ptr, unlike Query)

reconcile_active_mask() — shared, transport-free

FaultToleranceMixin.reconcile_active_mask          (both backends)
 ├ local = self.query_active_mask().cpu().tolist()
 ├ store = self._ft_store()          -> resolve_rendezvous_store(subsystem="ft")
 └ reconcile_masks_via_store(store, rank, world, local, epoch=active_mask_epoch, ...)
     ├ 1. store.set("ft/gen{E}/local/{rank}", bytes(view))
     ├ 2. poll ONLY ranks we still believe alive, until timeout_s
     ├ 3. elementwise-AND what arrived; mask believed-alive ranks that never reported
     ├ 4. coord = min(active); coord publishes via ATOMIC compare_set("ft/gen{E}/decision")
     │      everyone else adopts whatever that key holds  <- kills split brain
     │      (coordinator itself dead -> mask it, re-elect, bounded by world_size)
     └ 5. _adopt(): raises MoEEpRankEvictedError if the decision masks US
 └ self.set_active_mask(agreed)   -> backend-specific path above

Deliberately not a torch.distributed allreduce: that would hang on exactly the rank being masked out. With a store, a missing key is the death signal.

clear_faults()

nccl_ep                                        nixl_ep
readmit=False:                                 readmit=False:
 └ mask_ffi().error_clear -> ncclEpErrorClear    └ return    (no sticky flag to re-arm)

readmit=True:                                  readmit=True:
 ├ guard: a handle must exist                   ├ buffer.clean_mask_buffer()
 │   (MaskClean asserts on the LL staging       │   -> zeroes ALL `capacity` entries,
 │    buffer -> would SIGABRT from C)           │      marking the never-connected tail ACTIVE
 ├ warn under EXPERT_MAJOR                      ├ _ft_applied = [ACTIVE] * world
 │   (MaskClean computes reset offsets          └ re-mask [world, capacity)   <- fixes that bug
 │    assuming RANK_MAJOR)
 ├ mask_clean -> ncclEpMaskClean
 │   COLLECTIVE over survivors; internally cudaStreamSynchronize's
 └ error_clear -> ncclEpErrorClear
     ^ always paired: MaskClean does NOT clear the flag, which is exactly why
       `readmit` is a flag on one method rather than two a caller can mis-sequence

Three transport facts that shaped the code

  1. NIXL's polarity is "nonzero = masked", not "1 = masked". The buffer is 0xFF-memset at allocation (an untouched entry reads back as -1) and the kernels test != 0. The normalization must be (raw == 0); the obvious 1 - raw yields 2 for never-connected capacity-tail ranks and silently poisons every downstream sum()/bool().
  2. clean_mask_buffer zeroes all capacity entries, marking the never-connected tail active — a live bug on any fleet sized above its world.
  3. disconnect_ranks is 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

nccl4py binds GroupConfig.enable_mask/timeout_ns but its Group stops at create/create_handle/destroy/.ptr — the five mask functions are unreachable from Python. This adds a ctypes shim on Group.ptr that tries a native Group method first, so it retires itself with no call-site churn once those bindings land. Note the symbols live in libnccl_ep.so, not libnccl.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_params now rejects a nixl topology capacity below world size — it previously sailed through and went out of bounds inside the transport.
  • update_topology now rejects growing past the capacity, for the same reason. test_update_topology_diffs_ranks was exercising that invalid config (4→6 on capacity 4); it now passes capacity=8, keeping its intent.

Testing

254 passed in tests/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 base 6258e522.

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) and smoke_ft_ep.py (hard SIGTERM kill). This host has 1 GPU and no transport built. bash tests/moe_ep/run_tests.sh ft runs 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 in gpu_model_runner.py); the FlashInfer-EP managers just hardcode False, 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/deepep query_active_mask() return raw buffers with the opposite polarity, and that self._fleets holds 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 via query_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

  1. FleetAlgoKnobFaultTolerance + Fleet FT API surface (no backend touched)
  2. promote nixl_ep's store resolver to core.bootstrap_utils (pure move)
  3. TCPStore-based active-mask reconciliation
  4. ctypes shim for the ncclEpMask* API
  5. wire nccl_ep enable_mask/timeout_ns + FT methods
  6. wire nixl_ep timeout_ms + FT methods (carries the polarity/capacity/tail fixes)
  7. multirank fault injection + FT smoke + run_tests.sh ft
  8. docs + vLLM design note
  9. fix: guard query_fault against graph capture + correct the capture rationale (review follow-up)

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Added opt-in fault tolerance for MoE expert-parallel fleets, including timeouts and coordinator takeover.
    • Introduced public APIs to query faults, manage active-rank masks, reconcile fleet state, clear faults, and track mask epochs.
    • Added backend capability detection to ensure fault tolerance only activates when supported.
  • Documentation
    • Added design docs and an operational runbook describing recovery behavior, ordering rules, and transport-specific constraints.
    • Added a vLLM integration design note for future wiring into the fault-tolerance flow.
  • Tests
    • Added host-only unit tests, backend-specific mocks, NCCL ctypes-shim tests, multi-GPU end-to-end coverage, and FT smoke tests.

Anerudhan and others added 8 commits July 27, 2026 10:46
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>
@gemini-code-assist

Copy link
Copy Markdown
Contributor

Caution

The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased.

@coderabbitai

coderabbitai Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Adds 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.

Changes

MoE EP fault tolerance

Layer / File(s) Summary
API contracts and validation
flashinfer/moe_ep/{__init__.py,algo_knobs.py,errors.py}, flashinfer/moe_ep/core/comm/fleet.py, flashinfer/moe_ep/core/validation/common.py, flashinfer/moe_ep/modes/split_layer.py, docs/design_docs/*, tests/moe_ep/test_fault_tolerance_api.py, tests/moe_ep/test_constraints.py
Adds the fault-tolerance knob, public capability and Fleet APIs, related errors, LOW_LATENCY validation, split-layer wiring, and API-level tests.
Store-based mask reconciliation
flashinfer/moe_ep/core/bootstrap_utils.py, flashinfer/moe_ep/core/comm/fault_tolerance.py, tests/moe_ep/test_fault_tolerance_reconcile.py
Adds namespaced rendezvous stores and coordinated active-mask reconciliation with takeover and rank-eviction behavior.
NCCL-EP mask transport
flashinfer/moe_ep/backends/split/comm/nccl_ep/*, tests/moe_ep/nccl_ep/*
Adds NCCL-EP mask FFI bindings, group configuration, mask buffers, fault queries, clearing, and mock coverage.
NIXL-EP mask transport
flashinfer/moe_ep/backends/split/comm/nixl_ep/fleet.py, tests/moe_ep/nixl_ep/test_fleet_mock.py
Adds NIXL timeout handling, mask normalization, capacity checks, incremental mask updates, and readmission behavior.
Operational verification and integration notes
docs/design_docs/{moe_ep_runbook.md,vllm_moe_ep_integration.md}, tests/moe_ep/{run_tests.sh,smoke_ft_ep.py,test_moe_ep_fault_tolerance_multirank.py}
Documents FT operation and future vLLM wiring, and adds host-only, smoke, and multirank recovery tests.

Estimated code review effort: 5 (Critical) | ~120 minutes

Suggested labels: op: moe

Suggested reviewers: aleozlx, yzh119, saltyminty, bkryu, sricketts

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
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 30.73% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly names the new fault-tolerance rank-mask work across NCCL-EP and NIXL-EP.
Description check ✅ Passed The description is detailed and covers the change, usage, and tests, though it omits some template sections.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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 win

Narrow the TypeError translation so unrelated ctor errors aren't misreported.

Any TypeError raised inside Buffer.__init__ (bad arg type, internal call) is currently reported as "build does not accept timeout_ms" whenever buf_kwargs is 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 win

Silent pass when neither backend supports FT.

If supports_fault_tolerance is false for both nccl_ep and nixl_ep, the loop body never sets rc=1, so run_ft returns 0 and run_tests.sh ft reports "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 value

Add 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

📥 Commits

Reviewing files that changed from the base of the PR and between d536efd and e5bba68.

📒 Files selected for processing (24)
  • docs/design_docs/MoE_EP_impl.md
  • docs/design_docs/moe_ep_architecture.md
  • docs/design_docs/moe_ep_runbook.md
  • docs/design_docs/vllm_moe_ep_integration.md
  • flashinfer/moe_ep/__init__.py
  • flashinfer/moe_ep/algo_knobs.py
  • flashinfer/moe_ep/backends/split/comm/nccl_ep/_mask_ffi.py
  • flashinfer/moe_ep/backends/split/comm/nccl_ep/fleet.py
  • flashinfer/moe_ep/backends/split/comm/nixl_ep/fleet.py
  • flashinfer/moe_ep/core/bootstrap_utils.py
  • flashinfer/moe_ep/core/comm/fault_tolerance.py
  • flashinfer/moe_ep/core/comm/fleet.py
  • flashinfer/moe_ep/core/validation/common.py
  • flashinfer/moe_ep/errors.py
  • flashinfer/moe_ep/modes/split_layer.py
  • tests/moe_ep/nccl_ep/test_fleet_mock.py
  • tests/moe_ep/nccl_ep/test_mask_ffi.py
  • tests/moe_ep/nixl_ep/test_fleet_mock.py
  • tests/moe_ep/run_tests.sh
  • tests/moe_ep/smoke_ft_ep.py
  • tests/moe_ep/test_constraints.py
  • tests/moe_ep/test_fault_tolerance_api.py
  • tests/moe_ep/test_fault_tolerance_reconcile.py
  • tests/moe_ep/test_moe_ep_fault_tolerance_multirank.py

Comment on lines +229 to +232
@functools.cache
def mask_ffi() -> _MaskFfi:
"""Process-wide shim instance (symbol binding is done once)."""
return _MaskFfi()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🎯 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.

Suggested change
@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.

Comment on lines +179 to +181
self._ft_epoch = 0
self._ft_store_obj = None
self._any_handle_created = False

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🩺 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 = None

Also 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.

Comment on lines +234 to +235
self._ft_applied = [ACTIVE] * bootstrap.world_size
self._hot_ft_bufs = None

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🎯 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.

Suggested change
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 = {}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🩺 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 + 1

Also 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.

Comment on lines +162 to +226
# --- 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()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🩺 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.

Suggested change
# --- 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.

Comment thread tests/moe_ep/test_moe_ep_fault_tolerance_multirank.py
@lrbison

lrbison commented Jul 27, 2026

Copy link
Copy Markdown

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":

  1. Mutate the mask only between iterations (kernels read it live).

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.

  1. clear_faults(readmit=True) before update_topology, never after: ncclEpMaskClean needs a live handle on the current group, which update_topology destroys.

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.

  1. No FT call during CUDA-graph capture — a captured query replays stale offsets, a captured set freezes one mask into every replay. All stream-ordered entry points raise on capture.

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?

@Anerudhan

Copy link
Copy Markdown
Collaborator Author

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.

In steady state, query_fault() and query_active_mask() are read only.

set_active_mask() is not to be called, but if a kernel is timed out only the local rank will set the mask.

If there are two rank which have different mask we need to combine them with bitwise and, to have agreed survivors ?

Done via reconcile_active_mask().

Hope that makes sense. Let me know if my understanding is incorrect.

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.

Yes. update_topology creates new communicator, and then creates a new group over it.

  • clear_faults(readmit=True) --> ncclEpMaskClean on the communicator: re-admits a merely-delayed rank.
  • update_topology() → new communicator + new group: the only way to add or replace a process.

No FT call during CUDA-graph capture — a captured query replays stale offsets, a captured set freezes one mask into every replay. All stream-ordered entry points raise on capture.

Correct, will update the doc.

Question to you @lrbison
ncclEpMaskUpdate has no callerd, so I have kept the reconciliation "push" step separable. Is the framework pushing a
reconciled mask back via ncclEpMaskUpdate the use it was for ?

…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>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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 win

Do not use a clamp as the zero-survivor fallback.

When surviving_w == 0, clamping to 1e-6 does 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 win

Guard the expert-owner mapping against invalid topology shapes.

This formula assumes num_experts is evenly divisible by world_size; otherwise the final experts can map to owner world_size and alive[...] raises an out-of-bounds error. It also divides by zero when there are fewer than world_size experts. 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

📥 Commits

Reviewing files that changed from the base of the PR and between e5bba68 and b7cd997.

📒 Files selected for processing (7)
  • docs/design_docs/moe_ep_runbook.md
  • flashinfer/moe_ep/backends/split/comm/nccl_ep/fleet.py
  • flashinfer/moe_ep/backends/split/comm/nixl_ep/fleet.py
  • flashinfer/moe_ep/core/comm/fault_tolerance.py
  • flashinfer/moe_ep/core/comm/fleet.py
  • tests/moe_ep/nccl_ep/test_fleet_mock.py
  • tests/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

Comment thread docs/design_docs/moe_ep_runbook.md
Comment thread docs/design_docs/moe_ep_runbook.md
@lrbison

lrbison commented Jul 27, 2026

Copy link
Copy Markdown

@Anerudhan

Yes -- I think we are in agreement about query_active_mask / set_active_mask.

If there are two rank which have different mask we need to combine them with bitwise and, to have agreed survivors

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.

ncclEpMaskUpdate: Is the framework pushing a reconciled mask back via ncclEpMaskUpdate the use it was for ?

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).

@Anerudhan
Anerudhan enabled auto-merge (squash) July 28, 2026 00:24
@bkryu

bkryu commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator

/bot run tests/moe_ep

@flashinfer-bot

Copy link
Copy Markdown
Collaborator

GitLab MR !1066 has been created, and the CI pipeline #59999869 is currently running. I'll report back once the pipeline job completes.

@Anerudhan
Anerudhan requested review from mhoqueanik and removed request for mhoqueanik July 28, 2026 19:39
bkryu
bkryu previously requested changes Jul 28, 2026

@bkryu bkryu left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Hi @Anerudhan, CI is failing on test_deep_gemm_mega_kernel_vs_reference.py in cu130 configs. Plz take a look

@mhoqueanik

Copy link
Copy Markdown
Collaborator

Hi @bkryu! Filed a fix PR here: #4221

@Anerudhan

Copy link
Copy Markdown
Collaborator Author

#59999869

@mhoqueanik is going to be fixing this in a separate PR. Not introduced by this PR

@Anerudhan
Anerudhan merged commit b575db1 into flashinfer-ai:main Jul 28, 2026
33 checks passed
Anerudhan added a commit to Anerudhan/vllm that referenced this pull request Aug 17, 2026
…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>
Anerudhan added a commit to Anerudhan/vllm that referenced this pull request Aug 27, 2026
…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>
Anerudhan added a commit to Anerudhan/vllm that referenced this pull request Aug 27, 2026
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>
Anerudhan added a commit that referenced this pull request Sep 2, 2026
## 📌 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants