Replace EP backend comparison with Decode-like and Prefill-like B200 benchmarks - #1238
Replace EP backend comparison with Decode-like and Prefill-like B200 benchmarks#1238KeitaW wants to merge 20 commits into
Conversation
|
|
||
| | EP ranks | Dispatch dtype | UCCL latency | DeepEP V1 latency | DeepEP V2 latency | V2 vs UCCL | V2 vs V1 | | ||
| |---:|---|---:|---:|---:|---:|---:| | ||
| | 16 ranks | FP8 | 475.540 us | 881.700 us | **384.994 us** | 19.04 percent lower | 56.34 percent lower | |
There was a problem hiding this comment.
What does latency here mean? why the latency here is lower but the bandwidth below is also lower? In DeepEPv2's benchmark bandwidth = (number of bytes) / latency
There was a problem hiding this comment.
Update: I removed the previous backend-native tables and reran the comparison with separate Decode-like and Prefill-like profiles.
Latency now means the slowest-rank CUDA elapsed time for one dispatch followed by one combine. The boundary starts with the BF16 input and route ready, includes required FP8 conversion, and ends after combine completion. Prefill-like also includes the dispatch layout required by the normal UCCL and DeepEP V1 APIs.
Cross-backend throughput is now calculated as common logical payload bytes divided by that same elapsed time. For a fixed profile, EP size, and dtype, every backend has the same byte numerator, so lower latency necessarily corresponds to higher logical GB/s/rank. FP8 and BF16 have different useful-byte numerators and remain separate cells. Backend-native SO, SU, and aggregate bandwidth values are no longer presented as interchangeable metrics.
The replacement B200 run contains 3 independent process starts per cell, 20 warmup iterations and 100 measured iterations per dtype/start, and 72 scored records. All records passed correctness and matrix validation; the maximum run-to-run CV is 3.31 percent. The updated method, Decode-like and Prefill-like tables, provenance, and scope limits are in the replacement report.
Removed outdated explanation regarding retired backend-native tables for clarity.
Updated the title of the results section for clarity.
Independent run on 2× p6-b300, plus a methodology reviewThanks for putting a single common timing boundary and a SHA-256-verified input/route on this — that part is a genuine improvement over what we do internally, and we're adopting the input+route hash check for our own comparisons. We rebuilt this harness on 2× 2 nodes is exactly EP16, so we cannot check your EP32 column or any 16→32 scaling conclusion. Everything below is 3 starts × (20 warmup + 100 measured), median of per-start medians, dtype order rotated between starts, a fresh process group and port per run. Reviewed at head 1. The FP8-slower-than-BF16 decode result is the harness, not DeepEPYour EP16 decode row has V2 at 0.6151 ms FP8 vs 0.4921 ms BF16 (1.250×) while FP8 puts fewer bytes on the wire. It reproduces on our hardware at every SM count and node count:
(logical payload 22.25 MB/rank FP8 vs 29.36 MB/rank BF16; UCCL is the control — it shows the expected FP8 win.) We then re-ran the same cells through DeepEP's own At kernel level the sign flips in every cell:
Kernel FP8/BF16 is 0.926 at 1 node and 0.973 at 2 nodes. FP8 dispatch is the faster kernel (272.8 vs 286.1 µs at 2N) and combine is identical to 0.1% (182.94 vs 182.78 µs) because combine is BF16 either way. There is no FP8 penalty in the kernels. Where it comes from. Two asymmetries, both against V2:
A supporting hint in your own data: the FP8 penalty is +123.0 µs at EP16 but only +16.5 µs at EP32 (0.9642 vs 0.9477 ms). A property of the FP8 dispatch kernels would not shrink 7× when you add two nodes. Fix (one line). Consequence for the headline: V2's FP8 dispatch+combine kernels are 453.4 µs against UCCL's 433.8 µs wall-clock on the same box, so the 1.54× FP8 decode gap is mostly harness. We can't close this properly — 2. The "95% bootstrap CI" is identically
|
| scale-out / total | remote assignments | remote nodes | over-count | |
|---|---|---|---|---|
| EP16 (2 nodes) | 126.42 / 252.84 = 0.500 | 4 of 8 | 1 | 4× |
| EP32 (4 nodes) | 78.91 / 105.21 = 0.750 | 6 of 8 | 3 | 2× |
Correcting each cell by its own factor:
| reported scale-out | corrected | ||
|---|---|---|---|
| EP16 FP8 prefill | 126.42 GB/s/rank | 31.6 GB/s/rank | |
| EP32 FP8 prefill | 78.91 GB/s/rank | 39.5 GB/s/rank | +25% |
So real per-rank scale-out bandwidth rises 25% from EP16 to EP32, while the reported metric falls 58% (252.84 → 105.21 GB/s/rank). The conclusion flips sign. Because the distortion is node-count dependent, this metric is not comparable along the EP-size axis at all — which is the only axis the prefill table has.
Your own Input throughput (tokens/s) column already tells a much more honest story across the same two cells: 23,272,859 → 19,368,582 tokens/s, a 16.8% drop, not 58%.
Fix: make latency (or the aggregate tokens/s you already compute) the prefill primary metric, or use a denominator that accounts for per-node deduplication. The "these are logical efficiency metrics, not observed wire bandwidth" disclaimer covers the absolute level but not the cross-EP-size comparison, which is where it actually misleads.
4. num_sms is uncontrolled, unrecorded, and derived from one NIC's bandwidth
You never pass num_sms, so ElasticBuffer auto-sizes. On b300 that does work at 2 nodes on EFA (at pin 8e7b42e, get_rdma_gbs reads sysfs before the ibstat fallback — which matters, because ibstat -l on p6-b300 lists only the two DOWN ConnectX-7 devices and never the 16 EFA ones). It prints:
rdma_gbs=50.0 … num_sms=64
[WARN] DeepEP GIN signal budget reduced the number of channels per SM from 4 to 3
50 GB/s is one EFA device's 400 Gb/s. A b300 GPU has two NICs, so the real per-GPU figure is 100 GB/s; the heuristic is fed half the bandwidth. Separately, prefer_overlap_with_compute=False floors the result at 64, so on this hardware the wrong input happens not to change the outcome — but the value is neither recorded in the results nor listed in the controls table, and it is the single largest knob we found.
Measured SM sensitivity on b300, 2N/EP16:
| profile | dtype | 12 SM | 24 SM | 48 SM | 64 SM |
|---|---|---|---|---|---|
| decode | bf16 | 569.3 | 517.5 | 499.8 | 502.4 |
| decode | fp8 | 710.9 | 667.1 | 659.6 | 642.5 |
| prefill | bf16 | 2843.9 | 2451.0 | 2196.7 | 2134.2 |
| prefill | fp8 | 2569.1 | 2218.7 | 1910.0 | 1894.9 |
(µs, medians of 3 starts.) Decode is essentially SM-flat above 24; prefill is not — 24 SM is 14.9% (bf16) / 17.1% (fp8) off the best point. The knee is shape-dependent, so there is no single value that could be hard-coded instead. Suggest recording num_sms in every result and adding it to the controls table.
5. EP_BUFFER_DEBUG=1 is set on the V2 arm only, and it is not free
run_ep_rank.sh:40 enables it for V2 and nowhere else; FI_EFA_USE_HW_CNTR=1 (:37) likewise. EP_BUFFER_DEBUG makes csrc/elastic/buffer.hpp printf ~24 numbers per dispatch inside the CPU-wait path, and you run do_cpu_sync=True.
A/B on b300, 2N/EP16/24 SM:
| dtype | debug on | debug off | cost |
|---|---|---|---|
| fp8 | 667.1 µs | 648.2 µs | +2.9% |
| bf16 | 517.5 µs | 504.3 µs | +2.6% |
Small in absolute terms, but ~5× the run-to-run CV of those cells (0.36% / 0.56%) and charged to one arm only. Likely worse on your side: ours goes to a file, yours to a Kubernetes pod log pipe. (FI_EFA_USE_HW_CNTR=1 we measured as neutral.)
6. runtime.nccl_version reports torch's constant, not the loaded library
ep_benchmark.py:641 records list(torch.cuda.nccl.version()), which is torch's compile-time constant. In our running V2 container /proc/self/maps shows /opt/nccl/build/lib/libnccl.so.2.31.2 actually loaded, and DeepEP itself prints DeepEP initialized with NCCL version: 2.31.2 (loaded library) while the record says (2, 28, 9). The field is misleading precisely in the source-built-NCCL image that GIN requires. Reading it from the loaded object (as DeepEP does) fixes it.
7. summarize_results.py cannot summarize anything smaller than a 4-node campaign
WORLD_SIZES = (16, 32) (:20) and validate() (:102-110) hard-require the full 3 arms × 2 world sizes × 2 dtypes × 2 profiles × 3 starts matrix, so a 2-node campaign is rejected outright rather than partially summarized. Anyone with 2 nodes — which is most people who will want to re-run this — has to write their own aggregator. We did, and we reused your result_io.load_result_log, which is genuinely necessary: a plain json.loads on a result line with native diagnostics appended raises Extra data. Nice catch, worth keeping.
8. Deleted material the "口径 doesn't match" rationale doesn't cover
RESULTS-p5.md and nccl-alltoall.yaml are deleted outright. The new harness maxes at 4 nodes, so several results in there can never be re-derived from it:
- the 32-node / 256-rank runnability result, and the source-level rank caps behind it (
deep_ep.cpp:158, the 64 < cap ≤ 128 PE low-latency limit, thenvshmemi_process_multisend_rma-11 (EAGAIN)fan-out limit at ≥128 PEs on EFA); - the NCCL all-to-all reference baseline — the only backend-independent absolute calibration in the whole comparison, whose loss compounds finding 3;
- your own note that DeepEP's default config uses only 20 SMs, i.e. that any default-config Blackwell number is a floor — which is finding 4.
benchmarks/README.md still keeps "An EP16 result cannot decide an EP32 or EP64 deployment" while now pointing at a matrix that is only EP16/EP32. (No dangling links, though — we checked.) Suggestion: keep the old file as a clearly-dated historical document rather than deleting it.
9. Smaller notes
- The route is perfectly balanced.
make_route(:94) is(t*17 + k*31 + seed) % 256with a stride coprime tonum_experts, so every token hits exactly 8 distinct experts and every expert carries identical load. V2's ownget_theoretical_num_smsdocstring warns it assumes a balanced gate and must not be used for V3's group-limited gate.test_ep.pyhas--unbalanced-ratio/--masked-ratio; rankings may not survive skew. We haven't tested this axis yet either. - API asymmetry in the decode cell. The PR body says decode uses "the low-latency API" for all arms; the README's API table is the correct one. V2 has only
ElasticBufferon EFA, so that cell pits UCCL/V1 purpose-built LL kernels against V2's general kernels. It penalises V2 and deserves a stated caveat. - Every iteration is a cold start. The per-iteration
end.synchronize()+ all-reduce +.item()(:554-559) measures single-shot latency, not pipelined steady state. Defensible, but it should be labelled — it is a large part of the wall-vs-kernel gap in finding 1, and it means these absolutes cannot be compared against kernel-time figures from any other benchmark. - No backend source commits in provenance. Only image digests, torch/cuda/nccl, and your repo's
git_commit. We have measured ~1.8× swings in 2-node decode dispatch between two DeepEP V2 commits a few weeks apart on the same nodes, so a digest alone doesn't let a reader know which DeepEP produced a number. Recording the DeepEP/UCCL SHAs and the EFA installer version would be cheap. combine_inputis all zeros in the timed loop (:534). Harmless as configured (BF16 combine, no logfmt), but it would matter the moment a compressed combine path is enabled.CUDA_DEVICE_MAX_CONNECTIONS=1(run_ep_rank.sh:23) is set for all three arms and may serialise comm/compute givenasync_with_compute_stream=True.- Controls table (
README.md:23-38) omits SM/channel count, QP count, route distribution character, and the per-arm env vars from finding 5. - Replicate outliers. Three of our single-node decode cells contain one replicate ~1.6× the others (277.5 vs ~170 µs; 526.6 vs ~331 µs). With n=3 that single replicate drives the run-to-run CV to 23-26%, so your 5% gate would reject the cell rather than report it. Medians across starts are unaffected, which is why we report those — but it's worth knowing the gate can silently drop cells rather than flagging a single bad start.
What reproduced
Your direction holds on different hardware with your own harness: UCCL wins decode, DeepEP V2 wins prefill. b300, 2N/EP16/24 SM, V2/UCCL ratio (>1 = V2 slower):
| profile | BF16 | FP8 |
|---|---|---|
| decode | 1.112 | 1.538 |
| prefill | 0.431 | 0.388 |
So "UCCL wins decode" survives — the BF16 figure is a real kernel result. The FP8 decode margin is mostly finding 1, and the prefill margin is larger than you report (+96.02% FP8 / +62.45% BF16 in your EP16 table vs 2.58×/2.32× here). One more note on prefill: FP8's advantage is larger at kernel level than a wall-clock boundary shows. Our wall-clock FP8/BF16 ratios are 0.87-0.91, while the kernels give 0.76 (1N) / 0.83 (2N) — the cast sits on the FP8 side in both arms here, so it compresses the measured gap.
Happy to share the full kit (launchers, table generator, raw logs) if that's useful, or to re-run any cell you'd like on this hardware.
|
Follow-up to my earlier review, on one point only: the decode column depends on which DeepEP tree the V2 arm is built from, and with two small kernel-side patches the ranking reverses. Same 2 × p6-b300, same harness, same nodes. Disclosure: the two DeepEP PRs below are mine, so please weight this accordingly — everything here is reproducible from the recipe at the bottom, including the negative control. The pinThis is not a stale-pin report. Our V2 arm is built at On that tree
Effect on this PR's own numbersYour
So BF16 decode goes from V2 losing by 6.4% to V2 winning by 17%. The clamp-off control lands on the old image to within 0.1%, in the same binary, so this is the clamp rather than a build or environment difference. Through DeepEP's own And the FP8 term from section 1, now measured exactlyIn this same cell the wall-clock-minus-kernel term is 33.0 µs for BF16 against 201.8 µs for FP8 (slowest rank, dispatch + combine, same code, same image). That 168.8 µs is With the clamp in and the cast hoisted out of the timed region, V2 wins decode in both dtypes on this hardware. SuggestionNo change is needed to your harness for the first part — only the DeepEP tree the V2 arm is built from. #1 and #2 are both still open, so the low-friction order is probably:
One reproduction trap, if you do build two images. DeepEP's JIT cache key is Caveats
|
Purpose
Relates to #1234.
Replace the previous backend-native EP result tables with a common-boundary B200 microbenchmark. The retired B300, B200, H100, and Decode-only result files are removed because their latency and bandwidth fields did not share one timing boundary or byte numerator.
This microbenchmark covers synthetic Decode-like and Prefill-like expert-parallel communication.
What changed
CAMPAIGN_COMPLETE.2026-08-25summary. Update the SGLang documentation that referenced the retired tables.B200 results
Decode-like primary metric, slowest-rank dispatch-plus-combine latency:
Prefill-like primary metric, common logical throughput:
Each box contains the 3 independent process-start medians for one backend arm and workload cell. The markers show all 3 underlying values.
UCCL has the lowest Decode-like latency in 3 cells of 4 cells, while DeepEP V2 has the lowest latency for EP16 BF16. DeepEP V2 has the highest Prefill-like logical throughput in all 4 cells. The maximum run-to-run CV is 3.31 percent. All 72 scored records passed correctness and matrix validation.
The full per-start values, 95 percent bootstrap intervals, paired improvements, provenance, and scope limits are in
RESULTS.md. The machine-readable summary isb200-ap-south-1-2026-08-25.json, SHA-25616f12a0bfbbc0c9c9f93eeb19d410cc8843ed6cbc836115df851cd97996a9603.Qualification and custody
ml-clusters-shared-ap-south-1inap-south-1c.p6-b200.48xlargenodes, each with 8 NVIDIA B200 GPUs and 8 allocatable EFA devices.2.13.0+cu130, CUDA13.0, and NCCL2.29.7across all scored records.Validation
ruff check,ruff format --check,py_compile,bash -n,shellcheck, andgit diff --check: passed.Final directory