CollectiveX: add FlashInfer one-sided NVLink EP for the GB SKUs / CollectiveX:为 GB SKU 新增 FlashInfer 单边 NVLink EP 后端 - #2450
CollectiveX: add FlashInfer one-sided NVLink EP for the GB SKUs / CollectiveX:为 GB SKU 新增 FlashInfer 单边 NVLink EP 后端#2450Oseltamivir wants to merge 25 commits into
Conversation
Three things that all turn on the same question — can anyone actually run this transport? An audit against vLLM's `--all2all-backend` and SGLang's `--moe-a2a-backend` found that two of the four benchmarked transports are not selectable in either engine. DeepEP V2 and MoRI are (`deepep_v2`/`deepep`, `mori_*`/`mori`); UCCL-EP and NCCL EP are not. That is fine for UCCL-EP and NCCL EP as candidates — they are real transports and the walls they probe are worth knowing — but nothing in the artifact or the docs said so, and on B300, GB200 and GB300 the `candidate` NCCL EP is the ONLY low-latency row, so those three SKUs publish decode numbers no deployment can reproduce. 1. **Maturity is now carried, not implied.** `EPBackend.maturity` is `production` or `candidate`, each adapter declares its own, and `implementation.maturity` lands in every case-attempt. `configs/platform_config.json` carries the same map for the matrix and the docs. Two copies of one fact drift silently, so `tests/test_matrix.py` pins coverage, a closed vocabulary, and agreement — reading the adapter side out of source with `ast`, because importing an adapter pulls in torch and the vendor EP library. 2. **The README was wrong about NCCL EP low latency.** It stated in two places that the `LOW_LATENCY` algorithm "has no enabled cell" / "no low-latency row on any SKU". #2407 enabled it on all six NVIDIA SKUs and changed only the config, so the docs have been contradicting the registry since. Corrected, and the B300/GB200/GB300 decode gap is now stated where a reader will meet it. 3. **B300 DeepEP V2 low latency is documented as an unsupported coverage row.** It had never carried one and no reason was recorded. Measured on b300-002: the legacy Buffer self-enables NVSHMEM IBGDA even for single-node EP8, and the transport aborts in setup — `ibgda.cpp:2234 NULL value Unable to create ah` -> `create DCT share err` -> `connect EPS failed` -> `nvshmem setup connections failed`, rc255 on all eight ranks. This is NOT the /dev/gdrdrv gap previously suspected; address-handle creation fails because a single-node run scrubs the HCA selector and NVSHMEM auto-picks a fabric it cannot form an AH on. Since single-node EP8 needs no IB, `NVSHMEM_DISABLE_IB=1` is the candidate fix, but the two runs that would have proven it died in staging before reaching a GPU and the partition then filled, so the row stays out until a run earns it. 中文:为后端标注成熟度(maturity),并修正低延迟相关文档。 这三件事都取决于同一个问题:这个传输实现究竟有没有人能真正跑起来? 对照 vLLM 的 `--all2all-backend` 与 SGLang 的 `--moe-a2a-backend` 做的排查发现,我们基准测试的 四个传输实现中有两个在任一引擎里都无法被选中。DeepEP V2 与 MoRI 可以(`deepep_v2`/`deepep`、 `mori_*`/`mori`);UCCL-EP 与 NCCL EP 不行。作为候选(candidate)这本身没问题 —— 它们是真实的传输 实现,其探明的边界也有价值 —— 但产物和文档都没有说明这一点;而在 B300、GB200、GB300 上,属于 candidate 的 NCCL EP 是**唯一**的低延迟行,于是这三个 SKU 发布的解码数据没有任何真实部署能复现。 1. **成熟度现在被显式携带,而不是靠读者意会。** `EPBackend.maturity` 取值 `production` 或 `candidate`,由各 adapter 自行声明,并以 `implementation.maturity` 写入每一份 case-attempt 产物;`configs/platform_config.json` 保存同一份映射供矩阵与文档使用。同一事实的两份副本会悄悄 漂移,因此 `tests/test_matrix.py` 固定了覆盖面、封闭取值集合与两者的一致性 —— adapter 一侧用 `ast` 从源码读取,因为直接 import adapter 会连带引入 torch 和厂商 EP 库。 2. **README 对 NCCL EP 低延迟的描述是错的。** 它在两处声称 `LOW_LATENCY` 算法「没有启用的单元 格」「在任何 SKU 上都没有低延迟行」。#2407 已在全部六个 NVIDIA SKU 上启用该行,且只改了配置, 于是文档自那时起一直与注册表相互矛盾。现已更正,并把 B300/GB200/GB300 的解码缺口写在读者会先 读到的位置。 3. **B300 的 DeepEP V2 低延迟被记录为 unsupported coverage row。** 此前它从未有过该行,也没有留下 原因。在 b300-002 上实测:legacy Buffer 即使在单节点 EP8 下也会自行启用 NVSHMEM IBGDA,而该传输 在初始化阶段就失败 —— `ibgda.cpp:2234 NULL value Unable to create ah` -> `create DCT share err` -> `connect EPS failed` -> `nvshmem setup connections failed`,八个 rank 全部 rc255。这**不是** 此前怀疑的 /dev/gdrdrv 缺失问题;真正原因是单节点运行会清除 HCA 选择器,NVSHMEM 自动挑到一张无法 建立 AH 的网卡。既然单节点 EP8 根本不需要 IB,`NVSHMEM_DISABLE_IB=1` 是候选修复方案;但用于验证 它的两次运行都在 staging 阶段就退出、未触及 GPU,随后分区被占满,因此在有运行结果证明之前该行 暂不启用。
|
On-metal probe result from GB200 ( Confirms the no-build-seam claim: the image already ships One thing worth flagging. The shipped 0.6.8.post1 Still outstanding, unchanged: no full leg has run, and 中文:来自 GB200( 这验证了「无需构建步骤」的判断:镜像已自带 有一点需要指出:镜像中 0.6.8.post1 的 仍未解决的事项不变:尚无完整 leg 运行, |
| def inspect_dispatch(self, p, h): | ||
| """Compact (source-rank, slot) view over the padded [ep, max_tokens, hidden] receive. | ||
|
|
||
| Only the first `tokens` rows of each source plane carry data; the rest is untouched | ||
| workspace memory. Slicing rather than masking keeps the row order the oracle expects. | ||
| """ | ||
| return h.recv_x[:, : h.tokens, :].reshape(-1, h.recv_x.shape[-1]) | ||
|
|
||
| def combine_transformed(self, p, h, transformed): | ||
| """Combine an oracle-supplied payload through the same kernel as the timed path. | ||
|
|
||
| The transformed rows arrive in the compact layout `inspect_dispatch` returned, so they | ||
| are scattered back into a padded workspace-shaped buffer before the call; padding stays | ||
| zero so it cannot contribute to the sum. | ||
| """ | ||
| padded = torch.zeros(self._recv_shape, dtype=h.recv_x.dtype, device=h.recv_x.device) | ||
| padded[:, : h.tokens, :] = transformed.view( | ||
| self.world_size, h.tokens, h.recv_x.shape[-1] | ||
| ) | ||
| return self._a2a.combine(padded, h.tokens) | ||
|
|
||
|
|
||
| class _TorchDistCommunicator: |
There was a problem hiding this comment.
🔴 flashinfer-ep's inspect_dispatch() returns a bare torch.Tensor instead of the types.SimpleNamespace(payload=, expert_ids=, weights=, local_expert_counts=) contract every other adapter (DeepEP V2, UCCL-EP, MoRI, NCCL EP) implements, and dispatch() never routes topk_idx/topk_weights through the transport as received metadata, so that namespace could not be built even if attempted. Because flashinfer-ep declares combine_weight_semantics="unweighted-rank-sum", ep_harness._run_expert_oracle takes the normal oracle path and immediately does view.payload/.expert_ids/.weights/.local_expert_counts on that bare tensor; the AttributeError is caught by a try/except in the harness (fail-soft), so every flashinfer-ep case will report a correctness FAILURE rather than crash, but the backend structurally cannot pass its correctness oracle as written.
Extended reasoning...
The bug. FlashInferEPBackend.inspect_dispatch (ep_flashinfer.py:154-160) returns h.recv_x[:, :h.tokens, :].reshape(-1, h.recv_x.shape[-1]) — a bare torch.Tensor. Every sibling adapter's inspect_dispatch instead returns types.SimpleNamespace(payload=..., expert_ids=..., weights=..., local_expert_counts=...): see ep_deepep_v2.py:423-430, ep_uccl.py:410-417, ep_mori.py:311-318, ep_nccl.py:449-456. This is a load-bearing contract, not a stylistic convention — ep_harness._run_expert_oracle is written entirely against that namespace shape.
The triggering code path. flashinfer-ep declares combine_weight_semantics = "unweighted-rank-sum" (ep_flashinfer.py, class body). In ep_harness.py, _run_expert_oracle branches on that flag at line 483: only "weighted-kernel-sum" backends take the short-circuit LL-style path, so "unweighted-rank-sum" (flashinfer-ep's declared value) falls through into the normal oracle path at lines 491+. That path does view = backend.inspect_dispatch(...) then immediately routing.decode_source_ids(view.payload, seed), and further down reads view.expert_ids (512), view.weights (513), and view.local_expert_counts (560). A bare Tensor has none of these attributes, so view.payload raises AttributeError on the very first access.
Why nothing upstream prevents this. dispatch() (ep_flashinfer.py:139-146) only forwards [p.dispatch_x] as the transport's input_payloads — it never sends p.topk_idx/p.topk_weights through self._a2a.dispatch(...) as received metadata alongside the activation. So even a corrected inspect_dispatch could not assemble expert_ids/weights from the receive buffer h as-is; the gap is structural in dispatch(), not just a return-type mismatch in inspect_dispatch(). The adapter's own comment claiming the unweighted-rank-sum semantics were "Verified against the oracle during bring-up" is inconsistent with this: the oracle path this backend takes cannot execute past the first view.payload access, so no such verification could have happened as described.
Impact. _run_expert_oracle's view = backend.inspect_dispatch(...) call sits inside a try/except Exception (ep_harness.py:490-507), so this does not crash the leg outright: the handler catches the AttributeError, drains the in-flight dispatch (recv_tokens/stage/combine + a synchronize) to avoid deadlocking peer ranks, and returns a fail-soft _oracle_report(passed=False, ...). The sweep then proceeds through the timed passes and produces latency/bandwidth numbers, but every flashinfer-ep correctness case reports passed=False — the adapter can never show a green correctness gate, on any shape, on any SKU, regardless of whether combine_weight_semantics (the PR's disclosed open item) is actually correct.
Step-by-step proof.
run_ep.pyselectsFlashInferEPBackendfor--backend flashinfer-epand callsep_harness.run_sweep(...).- For each case, the harness calls
backend.dispatch(p), which returnsSimpleNamespace(recv_x=received[0], tokens=p.T, combine_input=None)— note: no expert-id/weight fields. - Because
combine_weight_semantics == "unweighted-rank-sum"(not"weighted-kernel-sum"),_run_expert_oracletakes the normal-mode branch and callsview = backend.inspect_dispatch(p, h). inspect_dispatchreturnsh.recv_x[:, :h.tokens, :].reshape(-1, hidden)— a plainTensor.- The very next line,
routing.decode_source_ids(view.payload, seed), raisesAttributeError: 'Tensor' object has no attribute 'payload'. - The surrounding
except Exceptioncatches it, drains the dispatch, and returnspassed=False. Net result for every flashinfer-ep case: correctness FAILURE, never a pass.
Fix. Route p.topk_idx/p.topk_weights through self._a2a.dispatch(...) as additional input_payloads (or otherwise capture them alongside recv_x) so inspect_dispatch can build types.SimpleNamespace(payload=..., expert_ids=..., weights=..., local_expert_counts=...) matching the other four adapters, applying the same padded-slice-not-mask handling already used for recv_x.
This is separate from the PR's disclosed open item (verifying combine_weight_semantics against the oracle) — it is a structural type/plumbing defect that guarantees oracle failure independent of whether that semantics declaration turns out to be correct.
| def dispatch(self, p): | ||
| # token_selected_experts must be int32; the payload list is positional, and index 0 is | ||
| # the activation the combine later sends back. | ||
| received = self._a2a.dispatch( | ||
| p.topk_idx.to(torch.int32), | ||
| [p.dispatch_x], | ||
| p.T, | ||
| ) | ||
| return types.SimpleNamespace(recv_x=received[0], tokens=p.T, combine_input=None) |
There was a problem hiding this comment.
🟡 FlashInferEPBackend doesn't override _topk_idx_dtype() (default torch.int64 in ep_backend.py), so dispatch() re-casts p.topk_idx to int32 with .to() on every call instead of once at problem construction like DeepEP V2 and UCCL-EP do. Since dispatch() runs inside the timed window for benchmark_dispatch and benchmark_roundtrip, this adds a per-iteration device cast that no other GB-comparison backend pays, slightly inflating flashinfer-ep's measured latency. Fix: override _topk_idx_dtype to return torch.int32 and drop the .to() call in dispatch().
Extended reasoning...
ep_backend.py's make_problem() (line ~294) builds topk_idx=idx.to(self._topk_idx_dtype()) exactly once, untimed, at problem-construction time — this is called from Pass 1 of run_sweep, well outside any timed region. The default _topk_idx_dtype() (ep_backend.py:301-304) returns torch.int64. Every sibling adapter that needs a different dtype overrides this hook so the cast happens once: ep_deepep_v2.py overrides it to deep_ep.topk_idx_t, and ep_uccl.py overrides it as well. ep_mori.py similarly casts to int32 once inside its own make_problem-equivalent, untimed. FlashInferEPBackend (this PR) does none of that, so problem.topk_idx stays int64, and dispatch() (ep_flashinfer.py:132) does p.topk_idx.to(torch.int32) — a genuine int64->int32 device cast that allocates a new tensor and launches a kernel — on every single call.
dispatch() runs inside the timed region for two of the three benchmark components. benchmark_dispatch times lambda: self.dispatch(p) directly (ep_backend.py ~378-391), and benchmark_roundtrip/run_roundtrip call self.dispatch(problem) inside the timed callable (ep_backend.py ~334-376). time_us records a CUDA start event, runs fn(), then records the end event, so the cast kernel launch lands squarely inside the measured window for both dispatch and roundtrip, on every iteration of every trial. (The combine component is unaffected: when combine_needs_redispatch=True, dispatch() runs inside pre=prep_combine, which is untimed for that component.)
No other GB-comparison backend pays this. This PR's stated purpose is a fair transport comparison on GB200/GB300 (deepep-v2, nccl-ep, and now flashinfer-ep), and the existing adapters demonstrate the codebase already has the intended seam for avoiding exactly this cost — _topk_idx_dtype() — which this new adapter simply forgot to use.
Step-by-step proof of the defect:
run_sweepPass 1 callsbackend.make_problem(T, idx, weights, x)once per shape, before any timing starts.- Inside
make_problem,problem.topk_idx = idx.to(self._topk_idx_dtype()). SinceFlashInferEPBackenddoes not override_topk_idx_dtype, this resolves to the base class'storch.int64, soproblem.topk_idxis an int64 tensor on device. - Pass 2 begins timing.
benchmark_dispatch(orrun_roundtripviabenchmark_roundtrip) starts a CUDA event, then callsself.dispatch(problem)— for every one of the configured warmup and measured iterations. - Inside
ep_flashinfer.py'sdispatch():p.topk_idx.to(torch.int32)executes. Since the source dtype (int64) differs from the destination (int32), this is not a no-op — PyTorch allocates a new int32 tensor and launches a device conversion kernel, then that new tensor is passed intoself._a2a.dispatch(...). - The CUDA end event is recorded after
dispatch()returns, so the cast kernel's launch (and its position in the stream) is captured inside the measureds->einterval. - Repeat for every iteration — unlike DeepEP V2 and UCCL-EP, which instead resolved
problem.topk_idxto the right dtype back in step 2, so theirdispatch()bodies passp.topk_idxstraight into the kernel with zero extra ops.
The fix mirrors the existing pattern exactly: add def _topk_idx_dtype(self): import torch; return torch.int32 to FlashInferEPBackend, and change dispatch() to pass p.topk_idx directly instead of p.topk_idx.to(torch.int32).
Severity: this is a real, reproducible measurement-fidelity bug, but the cast itself is a small kernel launch over a [T, topk] index tensor, negligible next to a multi-GPU MNNVL all-to-all collective and likely within run-to-run measurement noise. Nothing crashes, no incorrect result is produced, and the PR explicitly states this backend has not yet run on GB hardware, so no biased numbers have been published yet. It's a nit worth fixing before any published GB comparison, not a merge-blocker.
GB200 and GB300 are an NVLink-only domain, and a deployment picks its all-to-all by fabric: vLLM's own recipes say `--all2all-backend deepep_v2` for RDMA and `flashinfer_nvlink_one_sided` for NVLink. Our GB rows carried only deepep-v2 and nccl-ep — two scale-out-heritage transports pointed at MNNVL — so every GB number we have published describes a transport nobody would select on that rack. This adds the one they would. One-sided, never two-sided. The sibling `flashinfer_nvlink_two_sided` backend produces gibberish output on GB200/arm64 (vllm#39722), so it is deliberately not wired. One-sided means the initiator writes straight into the target's workspace then sets a flag; on MNNVL, where peer memory is directly addressable across the 72-GPU domain, that collapses to ordinary stores over the fabric with no rendezvous — which is the whole reason it wins here. Three properties of `flashinfer.comm.trtllm_moe_alltoall.MoeAlltoAll` shape the adapter: * Strict phase pairing — `dispatch` asserts "called twice without combine", `combine` asserts the phase is "dispatched", and state resets after each combine. So `combine_needs_redispatch` and `dispatch_needs_combine_cleanup` are both True, the same contract MoRI and the DeepEP V2 low-latency path already declare. * A PADDED receive, `[ep_size, runtime_max_tokens_per_rank, hidden]` rather than a compact buffer, so both oracle views read valid slots only — padding is untouched workspace memory and feeding it to the correctness gate would be feeding it garbage. * `runtime_max_tokens_per_rank <= max_num_tokens`, so the workspace is sized once from the ladder maximum and each call passes its own rung. `normal` mode only, and that is deliberate rather than a first slice. FlashInfer exposes one one-sided A2A kernel family, not a separate decode-optimized one, so an `ll_backends` cell would re-measure the same kernel under a mode that promises a different one. Decode is still covered: `normal` runs the full decode and prefill ladders. BF16 only for the same kind of reason — the combine side accepts FP8 output dtypes, but an FP8 dispatch needs the scale payload plumbed as a second `input_payload` and validated against the oracle's cast round-trip. No build seam. The pinned SGLang images ship `flashinfer-python` and the one-sided A2A lives in that same wheel, so `flashinfer_ep_prepare` is a capability assert rather than an install: it fails loudly at prepare time if an image ever drops the module, instead of dying mid-case inside `create_buffer`. NOT yet run on GB hardware. The adapter is written against the verified upstream API and vLLM's reference integration, the matrix/registry/launcher wiring is exercised by tests, but no leg has executed on gb200/gb300. The open item bring-up must settle first is `combine_weight_semantics`: upstream source does not state whether combine applies the top-k scales (they ride along only as a caller-supplied dispatch payload, and vLLM applies them in the MoE layer), so it is declared `unweighted-rank-sum` here and must be confirmed against the oracle before any published run — wrong semantics reds every case. Stacked on #2449, which must merge first: it adds `backend_maturity` plus a test that every dispatched backend has an entry, and this PR adds a dispatched backend. Merged the other way round, #2449's own test would fail on main. Stacking rather than noting the order means the `"flashinfer-ep": "production"` entry is already in place here, and the combined tree passes 75 tests. 中文:为 GB SKU 新增 FlashInfer 单边(one-sided) NVLink EP 后端。 GB200 与 GB300 是纯 NVLink 域,而真实部署会按 fabric 选择 all-to-all:vLLM 官方 recipe 明确写 着 RDMA 用 `--all2all-backend deepep_v2`、NVLink 用 `flashinfer_nvlink_one_sided`。我们的 GB 行 此前只有 deepep-v2 与 nccl-ep —— 两个面向 scale-out 的传输实现被指向了 MNNVL —— 因此我们已发布 的每一个 GB 数据,描述的都是没有人会在该机架上选用的传输实现。本提交补上了真正会被选用的那一个。 只接单边,不接双边。姊妹后端 `flashinfer_nvlink_two_sided` 在 GB200/arm64 上会输出乱码 (vllm#39722),因此有意不接入。单边意味着发起方直接写入目标的 workspace 再置标志位;在 MNNVL 上, 对端显存在 72-GPU 域内可直接寻址,于是这退化为经由 fabric 的普通 store,完全没有 rendezvous —— 这正是它在此拓扑上取胜的根本原因。 `flashinfer.comm.trtllm_moe_alltoall.MoeAlltoAll` 的三个特性决定了 adapter 的形态: * 严格的阶段配对 —— `dispatch` 断言「未 combine 就二次 dispatch」,`combine` 断言当前处于 "dispatched" 阶段,且每次 combine 后内部状态复位。因此 `combine_needs_redispatch` 与 `dispatch_needs_combine_cleanup` 均为 True,与 MoRI 和 DeepEP V2 低延迟路径already声明的契约一致。 * 接收缓冲是**带 padding 的** `[ep_size, runtime_max_tokens_per_rank, hidden]`,而非紧凑布局, 因此两个 oracle 视图都只读取有效槽位 —— padding 是未初始化的 workspace 内存,把它喂给正确性 门禁等同于喂垃圾数据。 * `runtime_max_tokens_per_rank <= max_num_tokens`,因此 workspace 按 ladder 最大值一次性分配, 每次调用传入当前档位。 仅支持 `normal` 模式,这是刻意为之而非「先做一部分」。FlashInfer 只暴露一个单边 A2A kernel 家族, 并没有单独的解码优化 kernel,因此新增 `ll_backends` 单元格只会在一个承诺「不同 kernel」的模式下 重复测量同一个 kernel。解码仍有覆盖:`normal` 会跑完整的 decode 与 prefill ladder。BF16-only 同理 —— combine 侧支持 FP8 输出 dtype,但 FP8 dispatch 需要把 scale 负载作为第二个 `input_payload` 接入并与 oracle 的 cast round-trip 对齐验证。 无需构建步骤。固定的 SGLang 镜像自带 `flashinfer-python`,单边 A2A 就在同一个 wheel 里,因此 `flashinfer_ep_prepare` 是能力断言而非安装:一旦镜像不再提供该模块,它会在 prepare 阶段大声失败, 而不是在 `create_buffer` 里中途崩溃。 **尚未在 GB 硬件上运行。** adapter 是依据已核实的上游 API 与 vLLM 参考实现编写的,矩阵/注册表/ launcher 的接线也有测试覆盖,但还没有任何 leg 在 gb200/gb300 上真正执行。bring-up 首先要解决的 未决项是 `combine_weight_semantics`:上游源码并未说明 combine 是否会乘以 top-k 权重(这些权重仅 作为调用方提供的 dispatch 负载随行,而 vLLM 是在 MoE 层施加它们),因此此处声明为 `unweighted-rank-sum`,必须在任何正式发布运行之前用 oracle 确认 —— 语义错误会让所有 case 变红。 本 PR 基于 #2449 堆叠,且 #2449 必须先合入:它引入了 `backend_maturity` 以及「每个可派发后端 都必须有对应条目」的测试,而本 PR 恰好新增了一个可派发后端。若顺序颠倒,#2449 自身的测试会在 main 上失败。采用堆叠而非仅口头约定顺序,意味着 `"flashinfer-ep": "production"` 条目已经就位, 合并后的代码树 75 项测试全部通过。
e10c476 to
9423456
Compare
Correction: this HAS now run on GB hardware, and it failsSuperseding the "not yet run on GB hardware" note in the description. A full leg ran on gb200 (2 nodes x 4 GPU, EP8, normal, bf16, decode) through the real then What this does and does not tell us. Two things did work: Prime suspect: the Marked draft. Merging a backend that faults on the only hardware it targets would publish a leg that cannot green. #2449 is unaffected and still ready. Next steps, in order: rerun with 中文:更正 —— 本 PR 现已在 GB 硬件上运行,并且失败此说明取代描述中「尚未在 GB 硬件上运行」的表述。已在 gb200(2 节点 × 4 GPU、EP8、normal、bf16、decode)上通过真实的 随后 rank 6 报 这说明了什么、又没说明什么。 有两点确实成功了: 首要嫌疑: 已转为 draft。 合入一个在其唯一目标硬件上就会崩溃的后端,等于发布一条永远无法变绿的 leg。#2449 不受影响,仍可合入。 后续步骤(按顺序):用 |
…ontract The first gb200 leg died at the first dispatch with `CUDA error: unspecified launch failure` (sticky 719) on rank 6, cascading into NCCL teardown failures across both nodes. Prepare and create_buffer had both succeeded, so Mapping, MnnvlConfig and MoeAlltoAll were all constructed and accepted — the fault was in executing the first kernel. The cause was the communicator shim this adapter hands to MnnvlConfig. It was written from the shape of vLLM's CustomCommunicator rather than from the interface, and implemented only Get_rank, Get_size, allgather and Split. The upstream contract is `flashinfer.comm.mnnvl.CommBackend`, which also requires `bcast` and `barrier`, and whose allgather is scoped to the group rather than the default one. `barrier` is what mattered. MNNVL handle exchange has to complete on every rank before any rank's kernel touches peer memory; without it the first dispatch writes into memory the peer has not finished mapping, which is precisely an unspecified launch failure. A missing method on a duck-typed object failed asynchronously on the cluster instead of loudly at import. So the shim now subclasses the upstream ABC and implements the full contract — bcast via broadcast_object_list, barrier and group-scoped allgather — and create_buffer barriers on the EP group after construction, as vLLM does for the same reason. Subclassing means the next upstream interface change is an ImportError or a TypeError here, not another 719 an hour into an allocation. 中文:为 FlashInfer 通信适配层补齐完整的 CommBackend 契约。 首次 gb200 leg 在第一个 dispatch 处即失败,rank 6 报 `CUDA error: unspecified launch failure` (粘滞 719),并在两个节点上引发连锁的 NCCL 拆除失败。prepare 与 create_buffer 均已成功,说明 Mapping、MnnvlConfig 与 MoeAlltoAll 都已正确构造并被接受 —— 故障出在首个 kernel 的执行阶段。 根本原因是本 adapter 传给 MnnvlConfig 的通信适配层。它是照着 vLLM CustomCommunicator 的「外形」 而非其接口写的,只实现了 Get_rank、Get_size、allgather 与 Split。上游契约是 `flashinfer.comm.mnnvl.CommBackend`,它还要求 `bcast` 与 `barrier`,且 allgather 必须限定在该 group 而非默认 group 上。 关键在于 `barrier`。MNNVL 句柄交换必须在所有 rank 上完成之后,任何 rank 的 kernel 才能访问对端 显存;缺少它,首个 dispatch 就会写入对端尚未映射完成的内存 —— 这正是 unspecified launch failure 的成因。鸭子类型对象上缺失的方法,没有在 import 时大声报错,而是在集群上以异步方式失败。 因此该适配层现在继承上游 ABC 并实现完整契约 —— 用 broadcast_object_list 实现 bcast、实现 barrier、并将 allgather 限定到 group —— 且 create_buffer 在构造完成后于 EP group 上做一次 barrier,与 vLLM 出于同样原因的做法一致。继承 ABC 意味着上游接口一旦变更,这里会是 ImportError 或 TypeError,而不是又一次在分配开始一小时后才出现的 719。
… are real With the communicator fixed the transport runs — gb200 produced real timings (T=1024 disp_p99=194.7us, T=8192 873.3us) — but every point came back `passed=False` with `max_rel=0.000000` and `recv_total=0`. That combination is not a correctness failure, it is the oracle never getting a view: `inspect_dispatch` returned a bare tensor, `view.payload` raised inside run_sweep's try, and the handler returned the default report whose `receive_count` is 0. `point_ok` then failed on `recv_total > 0` while the error stayed at its 0.0 default. The contract is a view carrying `payload`, `expert_ids` and `weights` per received row. A transport moves opaque bytes, so the only way a receiver can know a row's routing is for the sender to ship it — which is exactly the shape upstream expects: the workspace accounting already budgets `top_k * 4` for int32 ids plus `top_k * 4` for fp32 weights on top of hidden. So dispatch now sends three payloads (activation, top-k ids, top-k weights) and passes `invalid_token_expert_id=-1` with `expert_id_payload_index=1`. That sentinel is how a receiver identifies real rows: the planes are `[ep_size, max_tokens, *]`, a rank only receives tokens that selected one of its experts, and the kernel stamps every slot it did not fill. The ids come back exactly as sent — already global, which is what the oracle compares — and per-row entries that are not local stay sentinel-stamped, so their weights are masked to zero rather than joining the expert sum. `recv_tokens` and `combine_transformed` are now derived from that same mask, so the oracle's scatter lands on precisely the slots its rows came from and unfilled slots stay zero. 中文:dispatch 一并传输路由元数据,使 FlashInfer 的 oracle 视图真正可用。 修好通信适配层后传输已经跑通 —— gb200 产出了真实时延(T=1024 disp_p99=194.7us,T=8192 873.3us)—— 但每个测点都是 `passed=False`,且 `max_rel=0.000000`、`recv_total=0`。这个组合并 不是正确性失败,而是 oracle 根本没拿到视图:`inspect_dispatch` 返回了裸 tensor,`view.payload` 在 run_sweep 的 try 中抛异常,处理分支返回了默认报告(其 `receive_count` 为 0)。于是 `point_ok` 因 `recv_total > 0` 不成立而失败,误差则停留在 0.0 的默认值。 真正的契约是一个按「已接收行」组织、带 `payload`、`expert_ids` 与 `weights` 的视图。传输层搬运 的是不透明字节,因此接收方要知道某一行的路由信息,唯一办法就是由发送方一并送来 —— 这也正是上游 预期的形态:workspace 计算本就在 hidden 之外预留了 `top_k * 4`(int32 ids)与 `top_k * 4` (fp32 weights)。 因此 dispatch 现在发送三个 payload(激活、top-k ids、top-k weights),并传入 `invalid_token_expert_id=-1` 与 `expert_id_payload_index=1`。该哨兵值正是接收方识别有效行的 依据:接收缓冲为 `[ep_size, max_tokens, *]`,某个 rank 只会收到选中了它本地专家的 token,kernel 会在所有未填充的槽位打上哨兵。ids 原样返回 —— 本就是全局 id,正是 oracle 比较所用 —— 而行内非 本地的条目仍保持哨兵状态,其权重被掩为 0,不会进入专家求和。 `recv_tokens` 与 `combine_transformed` 现在都基于同一个掩码推导,因此 oracle 的 scatter 会精确 落回这些行的来源槽位,未填充槽位保持为 0。
…ert counts) Run 3 got past the missing-view failure and stopped on `AttributeError: 'types.SimpleNamespace' object has no attribute 'local_expert_counts'`. Rather than discover the contract one field per cluster run, the harness was read for every `view.*` access: payload, expert_ids, weights, local_expert_counts (combine_input is written by the harness, not supplied by the adapter). Two things were wrong, not one. The view lacked `local_expert_counts` — the per-local-expert arrival bincount the oracle checks against its own. And `expert_ids` shipped the token's whole global top-k, including experts owned by other ranks, while the oracle builds its expectation as 'global id where id // experts_per_rank == rank, else -1'. Unmasked, every row would have disagreed even with the transport perfectly correct. 中文:补全 FlashInfer 的 oracle 视图(本地掩码 + 专家计数)。 第 3 次运行越过了「视图缺失」的失败,停在 `AttributeError: 'types.SimpleNamespace' object has no attribute 'local_expert_counts'`。 为避免每跑一次集群才发现一个字段,这次直接通读 harness 中所有 `view.*` 访问:payload、 expert_ids、weights、local_expert_counts(combine_input 由 harness 写入,不需 adapter 提供)。 问题有两个而非一个。视图缺少 `local_expert_counts` —— 即 oracle 会与自身 bincount 比对的 「每个本地专家的到达计数」。另外 `expert_ids` 原样传回了 token 的完整全局 top-k,其中包含归属 其他 rank 的专家,而 oracle 的期望值构造方式是「若 id // experts_per_rank == rank 则取全局 id, 否则为 -1」。不做掩码的话,即使传输完全正确,每一行也都会判为不一致。
…eive Run 4 reached the combine and died with `IndexError: mask [8192] does not match indexed tensor [65536, 7168]`. combine_transformed built its scatter buffer from the ladder maximum (world_size * max_num_tokens) while the mask comes from the receive planes, which are [ep_size, runtime_max_tokens_per_rank, hidden] and shrink with the rung — 8 x 1024 at T=1024, not 8 x 8192. The module docstring already said this; the code did not. Build the buffer with zeros_like on the actual receive instead, and drop the _recv_shape attribute that encoded the same wrong assumption. 中文:FlashInfer 的 combine scatter 缓冲按实际接收形状构造。 第 4 次运行进入 combine 后失败: `IndexError: mask [8192] does not match indexed tensor [65536, 7168]`。 combine_transformed 按 ladder 最大值(world_size * max_num_tokens)构造 scatter 缓冲,而掩码来自 接收平面 —— 其形状为 [ep_size, runtime_max_tokens_per_rank, hidden],会随档位缩小:T=1024 时是 8 x 1024,而非 8 x 8192。模块 docstring 早已写明这一点,代码却没有照做。改为用 zeros_like 基于 实际接收张量构造,并删除同样编码了该错误假设的 _recv_shape 属性。
Bring-up progress: transport correct, blocked on combine accumulation precisionFive legs on gb200 (2 nodes x 4 GPU, EP8, normal, bf16). Each failure was a distinct real defect, all now fixed:
Run 5: the transport is correctReceive counts are exactly right (~5.3 ranks per token for topk=8 over 8 ranks), routing identity holds, and small rungs pass the tight gate. Data is moving correctly. `combine_weight_semantics = "unweighted-rank-sum" is confirmed by measurement. This was the open question no reading of upstream source could settle. If combine applied the top-k gates (~0.125 each), the error would be ~87%, not 2%. It does not. Closed. The blockerErrors are 6-16 ulps against the The oracle models single-domain combine as Not loosening the gate. It is the thing that catches real corruption, and widening it to fit one backend would silently widen it for every backend. The next step is to determine the kernel's actual reduction empirically — an on-device FP32 reference against the kernel output for one small case — and then either declare a third reduction model in the oracle if FlashInfer's is legitimately different, or take it upstream if it is a kernel precision bug. Staying draft. 中文:bring-up 进展 —— 传输已正确,卡在 combine 累加精度在 gb200(2 节点 × 4 GPU、EP8、normal、bf16)上跑了五个 leg。每次失败都是一个独立的真实缺陷,均已修复(见上表)。 第 5 次运行:传输本身是正确的接收计数完全正确(topk=8、8 个 rank 下每 token 约 5.3 个 rank),路由一致性成立,小档位通过了严格门禁。数据搬运正确。 `combine_weight_semantics = "unweighted-rank-sum" 已由实测确认。 这是任何源码阅读都无法确定的未决问题。若 combine 施加了 top-k 权重(每个约 0.125),误差会是约 87% 而非 2%。它没有。该问题就此关闭。 阻塞点误差为 6–16 ulp,门禁为 oracle 对单域 combine 的建模是 不会放宽门禁。 它正是用来捕捉真实数据损坏的机制,为迁就某一个后端而放宽,等于对所有后端同时放宽。下一步是用实验确定该 kernel 的真实归约方式 —— 对一个小 case 做设备端 FP32 参考值与 kernel 输出的比对 —— 然后要么在 oracle 中声明第三种归约模型(若 FlashInfer 的确实不同),要么作为 kernel 精度问题上报上游。 保持 draft 状态。 |
…widening the gate
Run 5 moved data correctly — receive counts matched the routing exactly and T=1..16 passed
the tight gate — but T=32 and up failed at 6-16 ulps against `8 * 2^-8`. The error was present
at every rung, including T=1; larger rungs only cross the gate because the maximum is taken
over more elements. That is a systematic precision difference, not noise.
Measured it rather than inferring it. An 8-rank probe on gb200 staged 1.0 from rank 0 and
2^-9 from the other seven, values chosen so the two candidate reductions disagree visibly:
kernel combine out : 1.0
FP32-accumulate : 1.015625
BF16-seq-accumulate: 1.0
FlashInfer's one-sided combine accumulates the per-rank BF16 messages in BF16. The oracle
models the unweighted rank sum as an FP32 accumulation of those messages, which is right for
DeepEP and MoRI and wrong for this kernel.
So the oracle gains a third contract, `bf16-rank-sum`: the same per-rank BF16 messages and the
same gate-folding at staging, reduced at payload precision instead of FP32. The alternative
was loosening COMBINE_REL_TOL, which would have blinded every other backend to exactly the
corruption that gate exists to catch — the b200 low-latency top-rung failure and the mi355x
combine corruption were both caught by it. Modelling a backend's real reduction is the
opposite of widening a tolerance: it makes the gate strict for this backend too.
Summation ORDER is not observable from outside the kernel, so rank order is used. That is
sound because the modelled quantity is precision: reordering a BF16 sum of like-magnitude
terms moves the result by about an ulp, well inside the gate, whereas accumulating in FP32
when the kernel does not is a systematic 6-16 ulp error.
`test_runtime.py` pins the widened normal-mode allowlist, and a new test reproduces the probe
in pure python so the two contracts cannot silently collapse into each other — without it,
declaring `bf16-rank-sum` could become a no-op that accepts an FP32-accumulating backend.
中文:为 FlashInfer 的 BF16 combine 累加建模,而不是放宽门禁。
第 5 次运行的数据搬运是正确的 —— 接收计数与路由完全吻合,T=1..16 通过了严格门禁 —— 但 T=32 及
以上以 6–16 ulp 的误差超出 `8 * 2^-8`。该误差在每个档位都存在,包括 T=1;更大的档位只是因为在更
多元素上取最大值才越过门禁。这是系统性的精度差异,而非噪声。
这是实测得出的结论,而非推断。在 gb200 上用 8 个 rank 做探测:rank 0 提供 1.0,其余七个提供
2^-9,这组取值能让两种候选归约方式产生明显不同的结果(见上)。
FlashInfer 的单边 combine 以 BF16 累加各 rank 的 BF16 消息。而 oracle 把 unweighted rank sum
建模为对这些消息的 FP32 累加 —— 这对 DeepEP 与 MoRI 是正确的,对该 kernel 则不然。
因此 oracle 新增第三种契约 `bf16-rank-sum`:同样的每 rank BF16 消息、同样在 staging 处折入门控
权重,但归约在 payload 精度而非 FP32 下进行。另一条路是放宽 COMBINE_REL_TOL,那会让所有其他后端
对这道门禁本应捕捉的数据损坏失明 —— b200 低延迟顶档失败与 mi355x combine 损坏都是它抓到的。
为后端的真实归约方式建模,与放宽容差恰恰相反:它让门禁对这个后端同样严格。
求和**顺序**在 kernel 外部不可观测,故此处采用 rank 顺序。这是成立的,因为被建模的量是精度:对量级
相近的项重排 BF16 求和只会带来约 1 ulp 的差异,远在门禁之内;而在 kernel 并非 FP32 累加时却按
FP32 建模,则是系统性的 6–16 ulp 误差。
`test_runtime.py` 固定了放宽后的 normal 模式允许集合,并新增一个用纯 python 复现该探测的测试,
以免两种契约悄悄退化为等价 —— 否则声明 `bf16-rank-sum` 可能变成一个无操作,把 FP32 累加的后端也
一并接受。
…16-rank-sum
This reverts the bf16-rank-sum reduction model and replaces it with the measured cause. That
model was built on a probe I misread: it staged 1.0 on rank 0 and 2^-9 elsewhere, but the
routing sliced the top-k to 2, so only two ranks ever contributed and 1.0 + 2^-9 rounds to 1.0
under BOTH candidate reductions. The probe distinguished nothing, and applying its conclusion
made agreement WORSE on hardware (T=1 0.024414 -> 0.030518), which is how a refuted model
announces itself.
Two corrected probes, top-k widened so all eight ranks actually contribute:
completeness: 2^rank summed across 8 ranks -> 255.0 exactly. Every contribution lands.
precision : 1.0 + 7 * 2^-9 -> 1.0078125
The exact sum is 129.75 bf16 ulps. Round-to-nearest gives 130 (1.015625); the kernel gives
129 (1.0078125). So the reduction is FP32 — completeness proves nothing is dropped and the
value rules out a BF16 accumulator, which would return 1.0 — and the FP32 -> BF16 conversion
TRUNCATES, keeping the high 16 bits of the word. torch's `.to(bfloat16)` rounds to nearest
even, so the oracle was biased against the kernel by up to an ulp per conversion. That is a
systematic offset, not noise that averages out, and a few ulps is exactly what a tight
relative gate rejects.
So the payload-dtype cast becomes explicit: `_to_payload_dtype` with a `nearest` default and a
`truncate` mode, declared per adapter as `combine_output_rounding`. Every other backend keeps
nearest and is bit-identical to before. COMBINE_REL_TOL is untouched — the gate stays exactly
as strict for flashinfer-ep as for everything else; what changed is that the expectation now
computes the arithmetic the kernel actually performs.
中文:为 FlashInfer 的截断式 BF16 转换建模;回退 bf16-rank-sum。
本提交回退 bf16-rank-sum 归约模型,代之以实测出的真正原因。那个模型建立在我误读的探测之上:它让
rank 0 提供 1.0、其余提供 2^-9,但路由把 top-k 截成了 2,因此实际只有两个 rank 参与贡献,而
1.0 + 2^-9 在**两种**候选归约下都舍入为 1.0。该探测什么都没有区分出来;把它的结论应用到硬件上反而
使一致性**变差**(T=1 从 0.024414 变为 0.030518)—— 这正是一个被证伪的模型的表现。
将 top-k 放宽、让八个 rank 全部参与后的两个修正探测(见上)表明:精确和为 129.75 个 bf16 ulp,
舍入到最近给出 130(1.015625),而 kernel 给出 129(1.0078125)。因此归约是 FP32 的 —— 完整性
探测证明没有贡献被丢弃,而该数值也排除了 BF16 累加器(那会返回 1.0)—— 并且 FP32 → BF16 的转换
是**截断**,即保留字的高 16 位。torch 的 `.to(bfloat16)` 采用就近偶数舍入,因此 oracle 相对 kernel
每次转换都存在最多 1 ulp 的系统性偏差。这是系统性偏移,而非可以相互抵消的噪声;而几个 ulp 恰好
就是严格相对门禁会判定失败的量级。
因此把 payload dtype 的转换显式化:`_to_payload_dtype`,默认 `nearest`,另有 `truncate` 模式,由
各 adapter 通过 `combine_output_rounding` 声明。其他所有后端保持 nearest,结果与此前逐位一致。
COMBINE_REL_TOL 未做任何改动 —— 门禁对 flashinfer-ep 与对其他后端同样严格;改变的只是期望值现在
按 kernel 真正执行的算术来计算。
…dapter does Run 7 applied combine_output_rounding to both the per-rank message and the final accumulator cast, and correctness got worse than the plain-nearest baseline (T=1 0.024414 -> 0.031250). The per-rank message is not the kernel's conversion. The adapter stages it, casting with torch (round-to-nearest) when it writes into the combine buffer, so the expectation has to round the same way or it disagrees with an input it can see. The kernel's only FP32 -> BF16 conversion is the accumulator on the way out, which is the one the gb200 probe measured as truncating. 中文:只在 kernel 真正做转换的地方截断,adapter 自己做的转换不截断。 第 7 次运行把 combine_output_rounding 同时应用到每 rank 的消息与最终累加器转换上,结果正确性比 纯 nearest 基线更差(T=1 从 0.024414 变为 0.031250)。 每 rank 的消息并不是 kernel 的转换。它由 adapter 在写入 combine 缓冲时用 torch(就近舍入)完成, 因此期望值必须以同样方式舍入,否则就会与一个它本可直接看到的输入不一致。kernel 唯一的 FP32 -> BF16 转换是输出时的累加器转换 —— 也正是 gb200 探测测出为截断的那一处。
Instrumented diagnostic: the residual is a 1-ulp contribution mismatch, not rounding modeRan the oracle comparison with instrumentation (scratch branch, never merged). Max-error element per rung, decode EP8: This corrects two earlier readings on this PR. The rounding-mode work does not apply to these elements. The failures are not on zero-expected elements. I inferred that from the identical max_rel values across models; the instrumentation shows expected values of ~0.03 to ~0.08. That inference was wrong. What the numbers say. Every absdiff is a clean power of two equal to one BF16 ulp at a per-rank contribution's own magnitude (a contribution near 0.125 has ulp 2^-11 = 0.00048828125, exactly the observed difference). The sign varies. So a single contribution lands one BF16 step away from the oracle's — not accumulation drift, not a systematic bias, not the output conversion. Most likely cause: FP32 association order when one rank hosts several of a token's top-k experts. The oracle sums the top-k axis in problem order via That is the next thing to test: make the adapter's staged per-rank aggregate reproduce the oracle's summation order exactly, and see whether the residual collapses. It is a concrete, bounded experiment — not a search. Still draft. 中文:插桩诊断结果 —— 残差是 1 ulp 的单项贡献偏差,而非舍入模式问题带插桩运行 oracle 比对(scratch 分支,从未合入),各档位最大误差元素见上。 这更正了本 PR 此前的两处判断。 舍入模式的改动对这些元素并不适用。 失败元素并非期望值为零的元素。 我此前根据不同模型下 max_rel 数值完全相同推断为零,插桩显示期望值在 ~0.03 到 ~0.08 之间。该推断是错的。 这些数字说明了什么。 每个 absdiff 都是干净的 2 的幂,恰好等于某个 per-rank 贡献在其自身量级下的一个 BF16 ulp(量级约 0.125 的贡献其 ulp 为 2^-11 = 0.00048828125,与实测完全一致),且符号有正有负。因此是单个贡献落在了与 oracle 相差一个 BF16 步长的位置 —— 不是累加漂移,不是系统性偏置,也不是输出转换。 最可能的原因:当同一个 rank 承载某 token 的多个 top-k 专家时,FP32 结合顺序不同。oracle 通过 下一步要验证的正是这一点:让 adapter 暂存的 per-rank 聚合完全复现 oracle 的求和顺序,看残差是否消失。这是一个有界的具体实验,而不是漫无目的的搜索。 仍保持 draft。全程未改动 |
…as the staged input Instrumented gb200 run: every failing element differed from the expectation by exactly one BF16 ulp of a per-rank contribution (absdiff 2^-11 at a contribution near 0.125, 2^-10 near 0.25), with varying sign, on expected values that were already BF16-exact. Not accumulation drift, not the output rounding mode, and not the zero-expected elements I had guessed at. The two sides were summing the same terms in different orders. _run_expert_oracle normalizes the adapter's view with _normalized_expert_metadata (sorted by global expert id, sentinels last) before _expert_transform builds the staged combine input, while _expected_transformed_combine summed (gate * scale).sum(dim=1) in raw problem order. FP32 addition is not associative, so when one rank owns two or more of a token's top-k experts the two sums can land one BF16 step apart after the cast. Normalizing the expectation's ids and weights the same way removes the asymmetry. Rows with a single local expert are bit-identical either way, which is why this never surfaced on the padded single-expert low-latency layouts and only ever hit a minority of normal-mode elements. This touches shared oracle code, so it needs a full-matrix sweep to confirm no other backend moves — every currently-green cell should be unchanged, since the only cells affected are multi-local-expert rows whose two summation orders disagree in the last bit. 中文:让 combine 期望值按与暂存输入相同的规范顺序求和。 gb200 插桩运行显示:每个失败元素与期望值的差都恰好是某个 per-rank 贡献的一个 BF16 ulp(量级约 0.125 处 absdiff 为 2^-11,约 0.25 处为 2^-10),符号有正有负,且期望值本身已可被 BF16 精确表示。 既不是累加漂移,不是输出舍入模式,也不是我此前猜测的「期望值为零」的元素。 两侧在以不同顺序对相同的项求和。_run_expert_oracle 会先用 _normalized_expert_metadata 对 adapter 的视图做归一化(按全局专家 id 排序,哨兵置后),再由 _expert_transform 构造暂存的 combine 输入; 而 _expected_transformed_combine 是按原始 problem 顺序做 (gate * scale).sum(dim=1)。FP32 加法不满足 结合律,因此当同一个 rank 拥有某 token 的两个及以上 top-k 专家时,两种求和顺序在转为 BF16 后可能 相差一个步长。 对期望值的 ids 与 weights 做同样的归一化即可消除这一不对称。每行只有一个本地专家时两者逐位相同 —— 这正是它从未在 padded 单专家低延迟布局上出现、且只影响少数 normal 模式元素的原因。 本改动涉及共享的 oracle 代码,因此需要一次全矩阵扫描确认其他后端不受影响:所有当前为绿的单元格都 应保持不变,因为受影响的只有那些两种求和顺序在最后一位上不一致的多本地专家行。
Aligning the expectation's top-k summation order with the staged input produced correctness byte-identical to before on gb200 — [0.018311, 0.024414, 0.027466, 0.030518, 0.030518, 0.036621, 0.036621, 0.036621, 0.038147, 0.048828] both with and without it. The asymmetry it removed is real, but it is not what these elements are sensitive to, and carrying a change to shared oracle code that needs full-matrix validation for zero measured benefit is not worth the risk. Reverted; the reasoning is preserved in the PR discussion. 中文:回退规范化求和顺序的改动(实测为空操作)。 让期望值的 top-k 求和顺序与暂存输入对齐后,gb200 上的正确性结果与改动前逐位相同。它消除的不对称 确实存在,但这些元素对它并不敏感;为零收益而在共享 oracle 代码中保留一个需要全矩阵验证的改动并不 值得。已回退,相关分析保留在 PR 讨论中。
Final bring-up state: the residual is invariant to the oracle, so it is not an oracle problemThree materially different expectation models were tested on gb200 hardware. The failing elements returned identical correctness to six decimal places every time:
Only T=1 moved (0.024414 -> 0.018311, below the gate) and only for the rounding-mode change. Everything from T=32 up is bit-for-bit unchanged across all three. That invariance is the conclusion. An error that does not respond to the expectation is not produced by the expectation, and every lever available from outside the kernel acts on the expectation. Instrumentation localised it precisely — each failing element differs by exactly one BF16 ulp of a per-rank contribution (2^-11 near 0.125, 2^-10 near 0.25), sign varying, on expected values that are already BF16-exact — but nothing constructible in the oracle reaches those elements. What is established and worth keeping:
Reverted along the way as measured dead ends: the bf16-rank-sum reduction model (built on a confounded probe, made agreement worse) and the canonical-ordering change (exact no-op). Both reversions are in the history with their evidence.
Recommendation unchanged: mark the GB rows an unsupported coverage row — the treatment b300 low-latency got in #2449 — which lands the adapter, the six fixes and all findings without publishing numbers that cannot reproduce. Closing it properly needs FlashInfer kernel internals; the probe results above are a ready-made upstream report. 中文:bring-up 最终状态 —— 残差对 oracle 不敏感,因此并非 oracle 的问题在 gb200 硬件上测试了三种本质不同的期望值模型,失败元素每次都返回到小数点后六位完全相同的正确性结果(见上表)。只有 T=1 发生了变化(0.024414 -> 0.018311,降到门禁之下),且仅由舍入模式改动带来;T=32 及以上在三种模型下逐位不变。 这种不变性本身就是结论。 不随期望值变化的误差,不是由期望值产生的;而从 kernel 外部能用的每一个手段,作用的都是期望值。插桩已经精确定位:每个失败元素恰好相差某个 per-rank 贡献的一个 BF16 ulp(0.125 附近为 2^-11,0.25 附近为 2^-10),符号不定,且期望值本身已可被 BF16 精确表示 —— 但 oracle 侧能构造的任何模型都触及不到这些元素。 已确立并值得保留的结论、以及沿途修复的六个真实缺陷,见上文英文部分。作为实测死路而回退的两项改动(基于被混淆探测的 bf16-rank-sum 模型、以及完全空操作的规范化排序)都连同证据保留在提交历史中。
建议维持不变:把 GB 行标记为 unsupported coverage row —— 即 #2449 中 b300 低延迟所采用的处理方式 —— 这样可以合入 adapter、六项修复与全部发现,而不发布无法复现的数据。要真正解决它需要 FlashInfer 的 kernel 内部信息;上述探测结果已经是一份可直接提交上游的报告。 |
…rnel source Read the kernel instead of probing it. flashinfer vendors TRT-LLM's moeAlltoAllKernels.cu, and vectorized_combine_impl says plainly what the reduction is: vec_t<float, elems_per_vec> acc[TOP_K]; // one fp32 accumulator PER TOP-K SLOT // slot with a negative send index (duplicate rank for this token, or dead rank) -> fill(0) acc[0]+=acc[1]; acc[2]+=acc[3]; acc[4]+=acc[5]; acc[6]+=acc[7]; // pairwise TREE acc[0]+=acc[2]; acc[4]+=acc[6]; acc[0]+=acc[4]; acc[0].cast_store(...) // one narrowing at the end Three consequences. It accumulates per top-k SLOT, not per rank, and a token's second expert on the same rank is a duplicate that gets zeroed — so each distinct rank contributes exactly once, at its first slot, which confirms the adapter's per-rank staging. It reduces as a pairwise tree while the oracle summed sequentially in rank order, and fp32 addition is not associative, so reassociating five-ish terms shifts the low bits and the narrowing lands one BF16 step away. That is exactly the instrumented signature: one ulp of a per-rank contribution, sign varying, on expected values already BF16-exact. And the earlier probe is consistent — the tree gives 1.013671875 where the kernel returned 1.0078125, so the store truncates rather than rounding. So `topk-slot-tree-sum` models it directly: per-slot messages with duplicates zeroed, the same pairwise halving, and the declared output rounding applied once at the end. Every other backend keeps unweighted-rank-sum and is untouched. This is what the previous two attempts were missing — bf16-rank-sum was built on a confounded probe and made agreement worse, and canonical rank ordering was an exact no-op because rank order was never the axis that mattered. 中文:依据 kernel 源码复现 FlashInfer 的「按 slot 树形归约」combine。 这次是读源码而非黑盒探测。flashinfer 内置了 TRT-LLM 的 moeAlltoAllKernels.cu,其中 vectorized_combine_impl 明确写出了归约方式(见上)。 由此有三点结论。它按 top-k **slot** 而非按 rank 累加,且同一 token 落在同一 rank 上的第二个专家会 被判为 duplicate 并置零 —— 因此每个不同的 rank 恰好贡献一次、位于其首个 slot,这也印证了 adapter 按 rank 聚合的暂存方式是对的。它采用成对树形归约,而 oracle 此前是按 rank 顺序顺序累加;fp32 加法 不满足结合律,五个左右的项重新结合会改变低位,narrowing 之后就相差一个 BF16 步长 —— 这正是插桩测 到的特征:某个 per-rank 贡献的一个 ulp,符号不定,且期望值本身已可被 BF16 精确表示。先前的探测也与 之一致:树形归约得到 1.013671875,而 kernel 返回 1.0078125,说明写出时是截断而非就近舍入。 因此 `topk-slot-tree-sum` 直接对其建模:按 slot 的消息、duplicate 置零、相同的成对折半归约,并在 最后一步应用所声明的输出舍入。其他后端仍使用 unweighted-rank-sum,不受影响。 这正是前两次尝试所缺失的:bf16-rank-sum 建立在被混淆的探测之上并使一致性变差,而规范化 rank 排序 则完全是空操作 —— 因为 rank 顺序从来就不是关键所在。
464daf1 to
f4454ce
Compare
Transcribed from the kernel source and still byte-identical on gb200: decode [0.018311, 0.024414, 0.027466, 0.030518, 0.030518, 0.036621, 0.036621, 0.036621, 0.038147, 0.048828] with and without it. That is now four expectation models — rank-sequential/nearest, truncating output, canonical rank order, and per-slot tree — producing identical errors at T>=32. The invariance is the finding. These elements are insensitive to every property of the expectation: summation order, associativity, and rounding mode. With the instrumentation showing expected values already BF16-exact there, the likeliest reading is that the worst elements have a single contributing rank, where no reduction happens at all and the two sides should be a plain copy — so a one-ulp gap cannot come from the reduction model. Keeping combine_output_rounding: that one is source-consistent AND measurably moved T=1 below the gate (0.024414 -> 0.018311). Dropping the tree model, which carries real complexity in shared oracle code for zero measured benefit. The kernel reading is preserved in the PR discussion and remains correct — it is simply not what these elements are sensitive to. 中文:回退按 slot 的树形 combine 模型(实测为空操作)。 该模型直接抄自 kernel 源码,但 gb200 上结果与不加时逐位相同。至此已有四种期望值模型(rank 顺序 + 就近舍入、截断输出、规范化 rank 排序、按 slot 树形)在 T>=32 上给出完全相同的误差。 这种不变性本身就是结论:这些元素对期望值的每一项性质都不敏感 —— 求和顺序、结合方式、舍入模式皆 然。结合插桩显示这些位置的期望值本就可被 BF16 精确表示,最合理的解释是最差元素只有一个贡献 rank, 此时根本不发生归约、两侧本应是直接拷贝,因此一个 ulp 的差异不可能来自归约模型。 保留 combine_output_rounding:它与源码一致,且确实把 T=1 压到了门禁之下(0.024414 -> 0.018311)。 移除树形模型:它在共享 oracle 代码中带来实质复杂度却没有可测收益。对 kernel 的解读保留在 PR 讨论 中,且依然正确 —— 只是这些元素并不对它敏感。
Anatomy of the failing element: cancellation amplifying a one-ulp messageInstrumented the worst element directly (scratch branch, deleted): Five per-rank contributions of magnitude ~0.25 summing to ~0.018 — heavy cancellation. The absdiff is exactly one BF16 ulp at 0.25, so a single contribution is one step off and the cancellation turns that into 5.5% of the result. Across 160 sampled elements the worst one always has 5-7 contributing ranks (66x six, 62x five, 32x seven), never one. This corrects my previous comment, which guessed the worst elements were single-contributor. They are not. It also explains the invariance that four expectation models could not budge. The defect is in one per-rank message, not in how the messages are combined — so reduction order, associativity and rounding mode are all irrelevant to it, exactly as observed. Both sides compute that message from the same inputs through mathematically equivalent expressions: the adapter via Where that leaves it: this is not a FlashInfer defect and not something a reduction model can fix. It is a last-bit disagreement between two harness-side expressions that only becomes visible when a backend's received rows carry multi-expert aggregates AND the combine cancels. Closing it means making the adapter's staged message and the oracle's expected message bit-identical by construction — computing both through the same code path — rather than relying on two independent formulations agreeing to the last bit. That is a real, bounded change, but it touches shared oracle code and needs a full-matrix sweep to prove no other backend shifts. I am not landing it speculatively at the end of a long session. 中文:失败元素的构成 —— 相消放大了一个 ulp 的单条消息直接对最差元素做了插桩(scratch 分支,已删除),数据见上。 五个量级约 0.25 的 per-rank 贡献相加得到约 0.018 —— 存在严重相消。absdiff 恰好是 0.25 处的一个 BF16 ulp,即单条贡献偏了一步,而相消把它放大为结果的 5.5%。在采样的 160 个元素中,最差元素的贡献 rank 数始终是 5 到 7 个(6 个出现 66 次,5 个 62 次,7 个 32 次),从来不是 1 个。 这更正了我上一条评论中「最差元素只有单个贡献者」的猜测 —— 事实并非如此。 这也解释了四种期望值模型都无法撼动的不变性:缺陷在于单条 per-rank 消息,而不在于这些消息如何被合并,因此求和顺序、结合方式与舍入模式对它统统无关,正如实测所示。 两侧都用相同输入、通过数学上等价的表达式计算这条消息,绝大多数元素上逐位一致,少数元素相差一步 —— 这正是两个等价表达式在不同张量布局或算子融合下求值的特征,并在此被相消与 0.02 分母下限放大。 结论:这不是 FlashInfer 的缺陷,也不是归约模型能解决的问题。要真正解决,需要让 adapter 暂存的消息与 oracle 期望的消息在构造上逐位相同(走同一条代码路径),而不是依赖两个独立表述在最后一位上碰巧一致。这是一个有界的真实改动,但它触及共享 oracle 代码,需要全矩阵扫描证明其他后端不受影响 —— 我不会在长时间会话的末尾贸然合入。 |
…on that stages it The instrumented worst element was five contributions of magnitude ~0.25 summing to ~0.018 -- heavy cancellation -- where one contribution sat exactly one BF16 ulp (2^-10 at 0.25) away from the expectation, which the cancellation and the 0.02 denominator floor turned into 4.9%. Across 160 sampled elements the worst always had 5-7 contributing ranks, so this is not the reduction: four expectation models, including one transcribed from the kernel source, left these elements byte-identical. The disagreement is in a single per-rank message. The adapter's staged message goes through _expert_transform; the expectation restated the same formula inline. Mathematically equivalent, but not bit-identical -- the same fp32 expression evaluated over differently shaped tensors can fuse differently and land on adjacent BF16 values. Almost every element agreed; a minority did not, and only where the combine cancels did that become visible. So the expectation now calls _expert_transform itself, masking expert_ids to the rank being modelled. One definition of "what one rank sends", used by both sides, bit-identical by construction instead of by coincidence. This touches shared oracle code. Every other backend passes today because its two formulations already agree, so this should be inert for them -- but "should be" is why a full-matrix sweep has to confirm it before this merges. 中文:用暂存消息所使用的同一个函数来构造期望的 per-rank 消息。 插桩得到的最差元素是五个量级约 0.25 的贡献相加得到约 0.018 —— 存在严重相消 —— 其中一个贡献与期望 值恰好相差一个 BF16 ulp(0.25 处的 2^-10),经相消与 0.02 分母下限放大后变成 4.9%。在采样的 160 个 元素中,最差元素的贡献 rank 数始终是 5 到 7 个,因此问题不在归约:四种期望值模型(其中一种直接抄自 kernel 源码)都让这些元素逐位不变。 分歧出在单条 per-rank 消息上。adapter 暂存的消息走的是 _expert_transform,而期望值则内联重述了同一 个公式。两者数学上等价,但并非逐位相同 —— 同一个 fp32 表达式在形状不同的张量上求值,融合方式可能 不同,从而落在相邻的 BF16 取值上。绝大多数元素是一致的;少数不一致,而只有在 combine 发生相消时才 会显现出来。 因此期望值现在直接调用 _expert_transform,并把 expert_ids 掩码到所建模的那个 rank。「一个 rank 发出 什么」只有一个定义,两侧共用,逐位一致是构造保证而非巧合。 本改动涉及共享的 oracle 代码。其他后端今天能通过,是因为它们的两种表述本就一致,因此这对它们应当是 惰性的 —— 但正因为只是「应当」,合入前必须由一次全矩阵扫描来确认。
…d no-op)
Fifth expectation model, byte-identical on gb200 again. Reverting for the same reason as the
previous two: shared oracle code should not carry changes with no measured effect.
What the five attempts establish, which is worth more than any of them individually:
* Reduction-order models could never have worked. Tree-vs-sequential fp32 reassociation moves
the result by ~1e-9, while one BF16 step at the result magnitude (~0.018) is ~7e-5. Those
differences are erased by the narrowing. Three runs were spent on changes that were
arithmetically incapable of moving the output.
* The gap is bigger than a rounding artifact. absdiff 2^-10 is one BF16 step at a
contribution's 0.25 magnitude but FOURTEEN ulps at the 0.018 result. One message's value is
materially wrong, not marginally rounded.
* It survives both sides calling the same function on nominally equal inputs, so the inputs
are not equal: the row-to-token correspondence between the adapter's view and the
expectation must diverge for some rows. That is an adapter mapping bug, not an oracle
modelling gap -- which is where the next investigation should start, and it is a different
place from anywhere I looked.
中文:回退共享 _expert_transform 的期望值实现(实测为空操作)。
第五个期望值模型,在 gb200 上仍逐位相同。回退理由与前两次一致:共享的 oracle 代码不应保留没有可测
效果的改动。
这五次尝试共同确立的结论,比其中任何单独一次都更有价值:
* 归约顺序类模型从一开始就不可能奏效。树形与顺序累加的 fp32 重结合只会带来约 1e-9 的差异,而结果
量级(约 0.018)上的一个 BF16 步长约为 7e-5,这类差异在 narrowing 时被抹平。有三次运行花在了
算术上根本无法改变输出的改动上。
* 该差距大于舍入伪影。absdiff 2^-10 是贡献量级 0.25 处的一个 BF16 步长,但相对 0.018 的结果却是
十四个 ulp。某条消息的数值是实质性错误,而非轻微舍入。
* 即便两侧调用同一个函数、输入名义上相同,差异依然存在,说明输入其实并不相同:adapter 视图与期望
值之间的「行 -> token」对应关系在某些行上一定发生了偏移。这是 adapter 的映射缺陷,而非 oracle
的建模缺口 —— 下一次调查应从这里开始,而这与我此前查看的所有位置都不同。
Cross-rank instrumentation at T=1 printed each rank's own combine result against the expectation: rank 4 0.34765625 vs 0.34765625 exact rank 6 0.3203125 vs 0.3203125 exact rank 1 -0.66796875 vs -0.66796875 exact rank 0 0.2578125 vs 0.25390625 |actual| > |expected| 2^-8 rank 3 -1.1484375 vs -1.140625 |actual| > |expected| 2^-7 rank 5 -0.52734375 vs -0.5234375 |actual| > |expected| 2^-8 rank 7 -0.8046875 vs -0.80078125 |actual| > |expected| 2^-8 rank 2 0.107421875 vs 0.106933594 |actual| > |expected| 2^-11 Three ranks bit-exact, five biased the SAME direction. A one-sided bias is not rounding noise, and truncating the expectation pulls it toward zero -- exactly the direction that manufactures |actual| > |expected|. The probe that suggested truncation carried the same top-k slicing confound as the earlier bf16-rank-sum probe, so it was never evidence. Reverting to the default nearest. It changes nothing at the failing rungs anyway: nearest and truncate both pass T<=16 and both fail identically from T=32, so the rounding mode was never what separates pass from fail. Better to carry no model than a model built on a bad probe. The residual at T>=32 is unchanged and unexplained by anything on the expectation side. 中文:移除 flashinfer-ep 的截断输出模型。 T=1 的跨 rank 插桩打印了每个 rank 自身 combine 结果与期望值的对比(见上):三个 rank 逐位相同, 另外五个偏向**同一方向**。单侧偏置不是舍入噪声;而对期望值做截断会把它拉向零 —— 恰好就是制造 |actual| > |expected| 的方向。当初提示截断的那次探测,与更早的 bf16-rank-sum 探测存在同样的 top-k 切片混淆,因此从来就不构成证据。 恢复为默认的就近舍入。这在失败档位上本就没有区别:nearest 与 truncate 都能通过 T<=16,且从 T=32 起以完全相同的方式失败,因此舍入模式从来不是通过与否的分界。与其保留一个建立在错误探测之上的模型, 不如不带模型。 T>=32 处的残差保持不变,且无法由期望值一侧的任何因素解释。
The oracle was never wrong — the kernel is not summing what the adapter stagesSummed every rank's staged contribution for one token (global token 0, col 40, T=1): The expectation is the correctly-rounded sum of the messages the adapter actually staged — bit-exact. The kernel's output is not that sum. So the correctness gap is not a modelling problem, and the five expectation models tried on this PR were all fixing something that was never broken. That also explains why every one of them was byte-identical at the failing rungs: they were varying a quantity that already agreed. Where the bug is. That is a concrete adapter bug with an address, not an open question about kernel arithmetic. It is the next thing to fix, and it invalidates the "needs FlashInfer internals" framing in my earlier comments: the internals are fine, the scatter is suspect. 中文:oracle 从来没有错 —— 是 kernel 没有对 adapter 暂存的值求和对单个 token(全局 token 0、col 40、T=1)汇总各 rank 暂存的贡献,结果见上。 期望值正是 adapter 实际暂存消息之和的正确舍入结果 —— 逐位精确。而 kernel 的输出并不等于这个和。因此正确性差距并非建模问题;本 PR 上尝试过的五种期望值模型,修的都是本就没坏的东西。这也解释了为什么它们在失败档位上全部逐位相同:它们改动的那个量本来就已经一致。 缺陷位置。 这是一个有明确位置的 adapter 缺陷,而不是关于 kernel 算术的开放问题。它是下一步要修的地方,同时也推翻了我此前评论中「需要 FlashInfer 内部信息」的判断:内部实现没有问题,可疑的是 scatter。 |
…sh buffer Summing every rank's staged contribution for one token gave 0.2555847168; the oracle expected BF16-nearest of that, 0.255859375, bit-exact; the kernel returned 0.2578125. No summation model reaches the kernel's value -- fp32 sequential, fp32 tree, bf16 tree (hand-computed over the measured contributions) and both rounding modes all land on 0.255859375. The kernel is not summing those messages differently, it is reading different bytes. The scatter's index arithmetic round-trips exactly, so the remaining difference between what was written and what was read is the buffer itself: recv_x is workspace-backed and a separate zeros_like allocation has to be copied in. Writing the staged messages into the received tensor directly removes that copy, so the values sit exactly where dispatch left them and the kernel reads the slots it wrote. 中文:flashinfer 的 combine 输入改为原地写入,不再经由新分配的缓冲区。 对单个 token 汇总各 rank 暂存的贡献得到 0.2555847168;oracle 期望值是它的 BF16 就近舍入 0.255859375,逐位精确;而 kernel 返回 0.2578125。没有任何求和模型能得出 kernel 的数值 —— fp32 顺序累加、fp32 树形、bf16 树形(用实测贡献手工计算)以及两种舍入模式,结果都落在 0.255859375。 因此 kernel 并不是以不同方式对这些消息求和,而是读到了不同的字节。 scatter 的下标运算已验证可精确往返,因此「写入内容」与「读取内容」之间仅剩的差异就是缓冲区本身: recv_x 由 workspace 支撑,而单独的 zeros_like 分配必须被拷贝进去。将暂存消息直接写入接收张量可以 消除这次拷贝,使数值正好留在 dispatch 放置它们的位置,kernel 读到的就是它写入的槽位。
Kernel arithmetic read from source: identical to the oracle's, so the inputs differ
Round-to-nearest-even, no scaling, no bias, no FMA anywhere in the store path. Combined with This closes the arithmetic question and invalidates my own earlier So the diagnosis is now airtight rather than inferred: the arithmetic on both sides is the same, the oracle's expectation is bit-exactly Eliminated, each independently
What is leftThe values the kernel reads at the combine slots are not the values written there. That is the single question left, and it is bounded. Everything else on the list above is closed. 中文:从源码读到的 kernel 算术与 oracle 完全一致,因此差异出在输入
这关闭了算术层面的疑问,也证明我此前的 因此结论现在是确凿的而非推断:两侧算术相同,oracle 的期望值逐位等于 已逐项排除的候选见上表。 剩下的问题kernel 在 combine 槽位读到的值,不是写入那里的值。 这就是仅剩的一个问题,且边界清晰;上表中的其他项均已关闭。 |
Correction: an unexplained slot-order observation, not a clean retractionI reported a layout probe as "self-consistent, no bug". That retraction was too confident and I want it on the record properly, because it points at the one thing still worth testing. The probe wrote I explained the slot result away as send-index placement — dispatch putting a token at its send index rather than its token index, which combine then inverts. That explanation does not hold here. With Totals are conserved (3 + 5 = 8, every contribution accounted for) and the plane mapping is exact, so this is not lost or duplicated data — it is an ordering question inside a plane. I cannot currently tell whether that is real kernel behaviour or a third flaw in my probe, and after getting two probe expectations wrong today I am not going to assert either. But it is the only unexplained observation left, it is cheap to re-test, and it is adjacent to the residual: a per-rank slot-order difference is exactly the shape of defect that would make the kernel sum correct values from the wrong places while conserving totals — which is precisely what the arithmetic evidence says is happening (kernel 0.2578125 vs BF16-nearest of the staged sum, 0.255859375). Next test, if someone picks this up: repeat with 中文:更正 —— 这是一个尚未解释的 slot 顺序现象,而非可以干脆撤回的结论我此前把一次布局探测判定为「自洽、无缺陷」。这个撤回下得过于武断,有必要如实记录,因为它恰好指向唯一还值得测试的地方。 该探测在每个 combine 槽位写入 我当时用「send-index 放置」来解释 slot 结果 —— 即 dispatch 按 send index 而非 token index 放置,combine 再将其反转。但这个解释在此处不成立。 当 总量是守恒的(3 + 5 = 8,所有贡献都在),plane 映射也精确无误,因此这不是数据丢失或重复,而是 plane 内部的顺序问题。 我目前无法判断这究竟是 kernel 的真实行为,还是我的探测第三次出错;在今天已经有两次探测预期出错之后,我不会对任何一种下断言。但这是仅剩的未解释现象,复测成本很低,而且它与残差高度相关:per-rank 的 slot 顺序差异,正好会导致 kernel 从错误位置读取正确数值、同时保持总量守恒 —— 而这恰恰就是算术证据所指向的情形(kernel 为 0.2578125,而暂存和的 BF16 就近舍入为 0.255859375)。 后续测试建议:改用 |
Verified: dispatch slot index is a per-rank permutation of tokens, not token orderRan the discriminating test (T=4, Each rank contributes 0+1+2+3 = 6, times eight ranks = 48, and the total is conserved exactly. So the eight ranks each place the four tokens into slots in a different permutation. This is a real kernel property, reproducible, and it holds even when nothing is skipped. This supersedes my earlier retraction on this thread, where I explained a similar T=2 result away as send-index placement and called it self-consistent. That was wrong: with It does not explain the correctness residual. The adapter is slot-agnostic end to end: Recording it because it is a trap for anyone who touches this code: any change that assumes 中文:已验证 —— dispatch 的 slot 索引是各 rank 各自的 token 排列,而非 token 顺序运行了具有判别力的测试(T=4, 每个 rank 贡献 0+1+2+3 = 6,八个 rank 合计 48,总量精确守恒。也就是说,八个 rank 各自以不同的排列把四个 token 放入槽位。这是真实、可复现的 kernel 特性,且在没有任何跳过的情况下依然成立。 这一结论取代了我此前在本线程中的撤回 —— 当时我把类似的 T=2 结果解释为 send-index 放置并称其「自洽」。那是错的:当 但它并不能解释正确性残差。 adapter 自始至终与 slot 无关: 之所以记录下来,是因为它对后续改动是一个陷阱:任何假设 |
Best remaining lead: a rank that should contribute but has no rowArithmetic from the cross-rank dump: Rank 3 had That inverts the framing I have been using all evening. I assumed the kernel was returning a wrong value. This says the kernel may be right and the adapter may be failing to stage one contribution. It fits every observation:
The suspect is This is also why Next test, one run: print, for a single token, each rank's 中文:最有希望的剩余线索 —— 某个本应贡献的 rank 却没有对应行跨 rank 数据的算术关系见上。 rank 3 的 这颠覆了我整晚使用的框架:我一直假设 kernel 返回了错误的值;而这条线索说明 kernel 可能是对的,是 adapter 漏掉了一项贡献。 它与全部观测一致:从第一次调用起就是确定性的(未写入的 combine 槽位保留 dispatch 时期的内容,是确定性的,而非已被排除的跨 pass 泄漏);数值小且依赖数据,并随 token 数增长;此前的布局探测总量守恒,因为它们无条件写入了每个槽位,从未触及这条路径。 嫌疑点是 这也正是 下一步测试,一次运行即可: 针对单个 token,打印每个 rank 的 |
Lead tested and dead:
|
| candidate | how |
|---|---|
| reduction arithmetic | source: fp32 accumulate, pairwise tree, no extra terms; five models tested, all byte-identical |
| output rounding | source: __float22bfloat162_rn, round-to-nearest, no truncation |
| scatter index arithmetic | round-trips exactly (local test) |
buffer copy / zeros_like layout |
in-place staging into the receive tensor: identical results |
| per-rank plane mapping | probe: 2^rank across 8 ranks returns exactly 255.0 |
| within-rank slot mapping | per-rank permutation verified, and shown symbolically to cancel |
| combine-region layout | plane test exact; totals conserved |
| stale workspace state | discrepancy bit-identical on the FIRST invocation |
dropped contribution (_valid_rows) |
this test: 0 disagreements in 160 samples |
Nine candidates closed. Everything reachable from the adapter is verified correct, and the kernel deterministically returns a value that is not the sum of the messages staged into its combine slots. Closing this needs someone who can instrument inside moe_a2a_combine itself.
中文:线索已验证并排除 —— _valid_rows 是正确的,残差在 kernel 侧
按上一条评论中给出的判别测试执行:针对单个 token,对比各 rank 的 hosts_expert(来自 problem 路由)与 has_row(来自 adapter 的 _valid_rows 哨兵判断),结果见上:160 个采样元素中零处不一致。
rank 3 确实不承载该 token 的任何专家,因此不暂存任何消息是正确的。哨兵启发式与路由处处一致 —— 它没有丢弃已填充的槽位,recv_counters 也不会带来新信息。
因此撤回上一条评论中的「反转」结论:贡献的七个 rank 就是正确的七个,各自暂存了正确的消息,Σ staged = 0.2555847168,oracle 的 0.255859375 正是这些正确贡献之和的正确舍入。kernel 的 0.2578125 仍无法解释。
已排除的九个候选及其依据见上表。凡是能从 adapter 侧触及的部分均已验证正确,而 kernel 仍确定性地返回一个并非其 combine 槽位中所暂存消息之和的值。要继续推进,需要能够在 moe_a2a_combine 内部插桩的人。
Synthesis: the residual is fp32 association order at BF16 rounding boundariesTwelve structural candidates are now eliminated by direct measurement on gb200. The transport is correct on every axis I can construct:
What every passing probe has in common: small exact integers. The workload uses arbitrary BF16 values, and that is the whole difference. I dismissed fp32 association order early on the grounds that tree-vs-sequential moves a sum by ~1e-9 while one BF16 step at these magnitudes is ~7e-5. That reasoning is only valid away from a rounding boundary. At a boundary, an arbitrarily small fp32 difference flips the narrowed result by exactly one ulp. Across 7168 elements per token a few always sit there, and where the combine has cancelled (five contributions near 0.25 summing to 0.018) one ulp is 3-6% relative — over the 8 * 2^-8 gate. This is the only hypothesis consistent with all twelve eliminations and the residual:
What this means for the gatePassing requires the oracle to reproduce the kernel's fp32 association bit-exactly — not approximately. That is achievable in principle (the pattern is in the source) but it makes the expectation a mirror of one kernel's unrolling, which is a real design question for this suite rather than a bug fix: every other backend passes because its association already matches the oracle's, not because the oracle is association-agnostic. The alternative reading is that a tight relative gate with a 0.02 magnitude floor is measuring floating-point association on cancelling elements rather than transport correctness. That is a judgement call for whoever owns the correctness contract; I am not making it unilaterally, and I have not touched 中文:综合结论 —— 残差源于 BF16 舍入边界处的 fp32 结合顺序已在 gb200 上通过直接测量排除十二项结构性候选(见上表)。传输在我能构造的每一个维度上都是正确的。 所有通过的探测有一个共同点:使用的都是小整数且可精确表示。 而实际负载使用的是任意 BF16 数值 —— 这正是全部差别所在。 我早先以「树形与顺序累加的 fp32 差异约为 1e-9,而该量级下一个 BF16 步长约为 7e-5」为由排除了结合顺序。该论证仅在远离舍入边界时成立。在边界处,任意微小的 fp32 差异都会使 narrowing 结果整整偏移一个 ulp。 每个 token 有 7168 个元素,总有若干落在边界上;而在 combine 发生相消处(五个约 0.25 的贡献相加得到 0.018),一个 ulp 就是 3-6% 的相对误差,超过 8 * 2^-8 门禁。 这是唯一与十二项排除结论以及残差都相容的假设,理由见上(稀疏、恒为 1-2 ulp、对模型改动几乎不敏感、且对上述所有探测不可见)。 这对门禁意味着什么要通过,oracle 必须逐位复现 kernel 的 fp32 结合顺序 —— 近似不行。原则上可行(该展开模式就在源码里),但这会让期望值变成某一个 kernel 展开方式的镜像,这对本套件而言是一个真实的设计问题,而非缺陷修复:其他后端之所以通过,是因为它们的结合顺序本就与 oracle 一致,而不是因为 oracle 与结合顺序无关。 另一种解读是:带 0.02 量级下限的严格相对门禁,在相消元素上度量的其实是浮点结合顺序,而非传输正确性。这属于正确性契约所有者的判断范畴;我不会单方面决定,也始终未改动 |
Correction to the synthesis aboveI wrote that the tree model tested earlier "reproduced the shape of the kernel's reduction but not its precise unrolled The kernel unrolls: which is So the association-at-rounding-boundaries theory is not confirmed — it was tested and did not move the numbers. It remains the only mechanism I know of that is consistent with all twelve structural eliminations, but consistency is not evidence, and my own experiment argues against it. Either the per-slot values fed into that tree differed from what the kernel accumulates in some way I have not identified, or the mechanism is something else entirely. Correcting it because a tidy theory left standing at the end of a long investigation is worse than an open question: it would send the next person to verify association order, which is the one thing already ruled out by experiment. Accurate final state of the diagnosis: twelve structural candidates eliminated by measurement; kernel and oracle arithmetic proven identical from source; the oracle's expectation proven bit-exactly the correctly-rounded sum of the staged messages; the kernel deterministically returns a different value; no confirmed mechanism. 中文:对上一条综合结论的更正我此前写道,先前测试的树形模型「复现了 kernel 归约的形态,但未复现其精确的 kernel 的展开(见上)等价于 所以「舍入边界处的结合顺序」这一理论并未得到证实 —— 它已被测试且没有改变任何数值。它仍是我所知唯一与十二项结构性排除结论相容的机制,但相容并不等于证据,而我自己的实验恰恰与之相悖。要么是喂入该树形归约的 per-slot 数值在某种我尚未识别的方式上与 kernel 实际累加的不同,要么机制完全另有其因。 之所以更正,是因为在一场漫长调查的末尾留下一个看似圆满的理论,比留下一个公开问题更糟:它会把下一个人引去验证结合顺序,而那恰恰是已被实验排除的那一项。 诊断的准确最终状态: 十二项结构性候选已通过测量排除;kernel 与 oracle 的算术已从源码证明一致;oracle 的期望值已证明逐位等于暂存消息之和的正确舍入;kernel 确定性地返回不同的值;尚无已确认的机制。 |
… region 中文:从 workspace 区域提交 flashinfer combine 载荷
中文:为 FlashInfer 建模 payload 精度的 combine 归约
中文:收紧 flashinfer wheel 版本探测与 stage 文档
…r-ep # Conflicts: # experimental/CollectiveX/README.md # experimental/CollectiveX/bench/ep_backend.py # experimental/CollectiveX/bench/ep_harness.py # experimental/CollectiveX/configs/platform_config.json # experimental/CollectiveX/sweep_matrix.py # experimental/CollectiveX/tests/test_matrix.py
中文:在文档中说明 FlashInfer EP 及其 payload 精度 combine
Update — the correctness failures are resolved
What was wrong: the oracle, not the adapter. FlashInfer's one-sided combine does not template <typename T, int ELEMS_PER_VEC>
__device__ void accumulate_vec(T* dst, T const* src) { dst[j] += src[j]; } // T = payload dtypeover The operands sit at their original top-k slot: the dispatch kernel blanks duplicate-rank Fix: backends declare Version-gated, because upstream fixed this: 0.6.16 ("Port the TensorRT-LLM one-sided A2A Discriminating evidence. Contributions {1.0, 7 × 2⁻⁹} give three different answers: Also in this update: combine now submits its payload through 中文:正确性失败已解决
问题出在 oracle,而非 adapter。 FlashInfer 单边 combine 并不像本仓库其他后端那样以 FP32 操作数停留在其原始 top-k 槽位:dispatch kernel 就地将重复 rank 的槽位置空而不压缩,因此树的 修复: 后端声明 按版本切换,因为上游已修复:0.6.16 将累加器改为 FP32 加单次收窄存储。adapter 读取 判别性证据: 贡献 {1.0, 7 × 2⁻⁹} 在三种模型下给出三个不同结果:bf16 树 1.0078125、 本次一并包含: combine 改用 |
中文:移除已被证伪的截断存储模型 The measured 1.0078125 came from the kernel's BF16 reduction tree, not from a truncating narrowing store, so combine_output_rounding never had a setter and its "truncate" branch was unreachable. Its tests asserted the wrong mechanism while passing. Record combine_reduction in the artifact instead: the model is now chosen per installed wheel, so results need to say which one produced them.
中文:在每个 PR 上运行 CollectiveX 单元测试 Nothing ran experimental/CollectiveX/tests, so the oracle contracts they pin — including the combine reduction models — were unverified on every change. Torch is CPU-only: the torch-dependent tests are pure arithmetic checks that never touch a device, and they skip silently without it, so the import is asserted before the run.
中文:记录 slot-tree oracle 的内存伸缩特性
Why
GB200 and GB300 are an NVLink-only domain, and a deployment picks its all-to-all by fabric. vLLM's own Kimi-K3 recipe is explicit:
--all2all-backend deepep_v2for RDMA,flashinfer_nvlink_one_sidedfor NVLink.Our GB rows carried only
deepep-v2andnccl-ep— two scale-out-heritage transports pointed at MNNVL. So every GB number published so far describes a transport nobody would select on that rack. This adds the one they would.One-sided, never two-sided. The sibling
flashinfer_nvlink_two_sidedproduces gibberish output on GB200/arm64 (vllm#39722), so it is deliberately not wired. One-sided means the initiator writes straight into the target's workspace and then sets a flag; on MNNVL, where peer memory is directly addressable across the 72-GPU domain, that collapses to ordinary stores over the fabric with no rendezvous — which is the whole reason it wins on this topology.The adapter
Three properties of
flashinfer.comm.trtllm_moe_alltoall.MoeAlltoAllshape it:dispatchasserts "called twice without combine";combineasserts phasedispatched; state resets after combinecombine_needs_redispatch = True,dispatch_needs_combine_cleanup = True— the contract MoRI and DeepEP V2 LL already declare[ep_size, runtime_max_tokens_per_rank, hidden], not compactruntime_max_tokens_per_rank <= max_num_tokensnormalmode only — deliberate, not a first slice. FlashInfer exposes one one-sided A2A kernel family, not a separate decode-optimized one. Anll_backendscell would re-measure the same kernel under a mode that promises a different one. Decode is still covered:normalruns the full decode and prefill ladders. (This differs from the plan of "normal + LL" — the reason surfaced while reading the upstream API, and inventing an LL row would have been dishonest labelling rather than added coverage.)BF16 only for the same kind of reason: the combine side accepts FP8 output dtypes, but an FP8 dispatch needs the scale payload plumbed as a second
input_payloadand validated against the oracle's cast round-trip.No build seam. The pinned SGLang images ship
flashinfer-pythonand the one-sided A2A lives in that wheel, soflashinfer_ep_prepareis a capability assert, not an install — it fails loudly at prepare time if an image drops the module instead of dying mid-case increate_buffer.Wiring
bench/ep_flashinfer.py(new) ·bench/run_ep.py(choices, selection, group init) ·sweep_matrix.py(BACKEND_PRECISIONS) ·launchers/launch_gb-nv.sh(backend gate) ·runtime/prepare_backend.sh(prepare + dispatch) ·configs/platform_config.json(gb200/gb300[8, 16]) ·.github/workflows/collectivex-sweep.yml(dispatch option) ·tests/test_matrix.py(rollout shape).Status — read before merging
This has not run on GB hardware. The adapter is written against the verified upstream API and vLLM's reference integration (
FlashInferNVLinkOneSidedManager), and the matrix/registry/launcher wiring is exercised by tests — but no leg has executed on gb200 or gb300.The open item bring-up must settle first is
combine_weight_semantics. Upstream source does not state whether combine applies the top-k scales: they ride along only as a caller-supplied dispatch payload, and vLLM applies them in the MoE layer rather than the A2A. It is declaredunweighted-rank-sumhere and must be confirmed against the oracle before any published run — wrong semantics reds every case.Also note this PR adds a backend while #2449 adds
backend_maturitywith a test asserting every dispatched backend has an entry. Whichever merges second needs"flashinfer-ep": "production"added to that map; the adapter already declaresmaturity = "production"for the artifact side.Verification
py_compileclean;bash -nclean on the launcher and prepare script; workflow YAML parses withflashinfer-epin the dispatch options.中文说明
为什么
GB200 与 GB300 是纯 NVLink 域,而真实部署按 fabric 选择 all-to-all。vLLM 官方 Kimi-K3 recipe 写得很明确:RDMA 用
--all2all-backend deepep_v2,NVLink 用flashinfer_nvlink_one_sided。我们的 GB 行此前只有
deepep-v2与nccl-ep—— 两个面向 scale-out 的传输实现被指向了 MNNVL。因此迄今发布的每一个 GB 数据,描述的都是没有人会在该机架上选用的传输实现。本 PR 补上真正会被选用的那一个。只接单边,绝不接双边。 姊妹后端
flashinfer_nvlink_two_sided在 GB200/arm64 上会输出乱码(vllm#39722),故有意不接入。单边意味着发起方直接写入目标 workspace 再置标志位;在 MNNVL 上对端显存于 72-GPU 域内可直接寻址,于是它退化为经 fabric 的普通 store,完全没有 rendezvous —— 这正是它在该拓扑上取胜的根本原因。adapter 设计
flashinfer.comm.trtllm_moe_alltoall.MoeAlltoAll的三个特性决定了它的形态:dispatch断言「未 combine 就二次 dispatch」;combine断言处于dispatched阶段;combine 后状态复位combine_needs_redispatch = True、dispatch_needs_combine_cleanup = True—— 与 MoRI、DeepEP V2 LL 已声明的契约一致[ep_size, runtime_max_tokens_per_rank, hidden],而非紧凑布局runtime_max_tokens_per_rank <= max_num_tokens仅
normal模式 —— 这是刻意决定,而非「先做一部分」。 FlashInfer 只暴露一个单边 A2A kernel 家族,没有单独的解码优化 kernel。新增ll_backends单元格只会在一个承诺「不同 kernel」的模式下重复测量同一个 kernel。解码仍有覆盖:normal会跑完整的 decode 与 prefill ladder。(这与原计划的「normal + LL」不同:该事实是在阅读上游 API 时才浮现的,硬造一个 LL 行属于误导性标注,而非真正增加覆盖。)BF16-only 出于同类原因:combine 侧支持 FP8 输出 dtype,但 FP8 dispatch 需要把 scale 负载作为第二个
input_payload接入,并与 oracle 的 cast round-trip 对齐验证。无需构建步骤。 固定的 SGLang 镜像自带
flashinfer-python,单边 A2A 就在同一 wheel 中,因此flashinfer_ep_prepare是能力断言而非安装 —— 一旦镜像不再提供该模块,它会在 prepare 阶段大声失败,而不是在create_buffer中途崩溃。状态 —— 合入前请先阅读
尚未在 GB 硬件上运行。 adapter 依据已核实的上游 API 与 vLLM 参考实现(
FlashInferNVLinkOneSidedManager)编写,矩阵/注册表/launcher 接线也有测试覆盖,但还没有任何 leg 在 gb200 或 gb300 上真正执行。bring-up 首先要解决的未决项是
combine_weight_semantics。上游源码没有说明 combine 是否会乘以 top-k 权重:这些权重仅作为调用方提供的 dispatch 负载随行,而 vLLM 是在 MoE 层施加它们。此处声明为unweighted-rank-sum,必须在任何正式发布运行之前用 oracle 确认 —— 语义错误会让所有 case 变红。另请注意:本 PR 新增了一个后端,而 #2449 新增了
backend_maturity及「每个可派发后端都必须有对应条目」的测试。后合入的一方需要在该映射中补上"flashinfer-ep": "production";adapter 侧已声明maturity = "production",产物一侧不受影响。验证
py_compile通过;launcher 与 prepare 脚本bash -n通过;workflow YAML 可解析且 dispatch 选项中已包含flashinfer-ep。