Skip to content

perf(hybrid): don't split a channel into more parts than it has tokens for - #2

Open
whn09 wants to merge 2 commits into
amazon-contributing:mainfrom
whn09:clamp-parts-by-min-tokens
Open

perf(hybrid): don't split a channel into more parts than it has tokens for#2
whn09 wants to merge 2 commits into
amazon-contributing:mainfrom
whn09:clamp-parts-by-min-tokens

Conversation

@whn09

@whn09 whn09 commented Aug 21, 2026

Copy link
Copy Markdown

Re-post of Xuan-1998/DeepEP#43 against this repo, retargeted and re-measured on main (ec623f3). Two things changed versus the old PR:

  • The ec623f3 refactor split the hybrid kernel in two. The old patch touched hybrid_dispatch.cuh, which is now the ordered (upstream) kernel and has no sub-parts at all. This version targets hybrid_dispatch_unordered.cuh, the kernel EP_HYBRID_KERNEL selects by default and the one EFA actually runs.
  • All numbers below were re-measured on this tree. The old PR's numbers are not quoted.

Stacked on #1 — that PR adds EP_MIN_TOKENS_PER_PART to the JIT forwarding list, which is what makes the control run below possible in a single image. Review/merge #1 first; this branch contains it.

The problem

kNumParts — how many flush_part puts a channel's tokens leave in — is chosen today by compute_part_allocation() alone (common/gin_resource_alloc.cuh). That function only ever caps the count from above when the GIN indexed-signal budget is tight. It is never lowered because the geometry asks for it: kNumParts is never compared against kNumMaxTokensPerChannel, and there is no minimum-tokens-per-part threshold.

The budget is loosest exactly when a channel holds the fewest tokens (low --num-sms, small batch), so decode shapes settle on kMaxParts — the worst end of the axis — with no way to opt out. At 128 tokens / 12 SMs a channel holds 3 tokens and is described as 4 parts × 1 token: the last part is always empty, and 3 tokens leave as three separate single-token puts instead of one 3-token put.

Sub-parts already have exactly this guard — a clamp of kNumSubParts to kBatchSize, plus EP_SM100_MIN_SUB_TOKENS refusing to sub-split a part too small to be worth it. Parts have neither.

The change

kMinTokensPerPart, default 15 (copied from the sub-token precedent in the same file), overridable by EP_MIN_TOKENS_PER_PART. kNumParts becomes min(budget_parts, tokens_per_channel / kMinTokensPerPart).

EP_MIN_TOKENS_PER_PART=1 short-circuits to the old value rather than dividing by one — so it is an exact in-image control, not an approximation. (kNumMaxTokensPerChannel / 1 would still clamp whenever a channel holds fewer tokens than the budget allows parts, which is a different geometry from the old code and would not be a valid control.)

kNumMaxTokensPerChannel moves above the part count in the template parameter list; it depends only on already-declared parameters. Note the parentheses around the comparison in the new kNumGeomParts expression are load-bearing — an unparenthesized > inside a template parameter list closes the list instead of comparing, the same reason (kNumNotifyWarps > 0) above it is wrapped.

Measurement

tests/elastic/test_ep.py, 2 × p5en.48xlarge (8×H200 + 16 EFA each), EP8×2 = 16 ranks, --hidden=7168 --num-topk=8 --num-experts=256 --num-sms=12 --allow-hybrid-mode=1 --prefer-overlap-with-compute=0 --test-first-only. decode = --num-tokens=128, prefill = --num-tokens=8192.

Method: one image, four env-selected variants, 3 reps, variants interleaved within each rep (never all-A-then-all-B), each variant on its own EP_JIT_CACHE_DIR so a variant can never serve another's cubin, GPU memory asserted back to idle between rounds. Mean over all 16 ranks then over reps; ± is stdev across reps, not across ranks. All 48 rounds exited 0.

stock = EP_MIN_TOKENS_PER_PART=1, i.e. the exact pre-patch geometry in the same binary.

decode, 128 tokens — latency

op stock this PR (default 15) this PR + EP_NUM_SUB_PARTS=1
dispatch 367.0 ± 12.1 µs 239.8 ± 3.1 µs (−34.7%) 166.1 ± 0.4 µs (−54.7%)
expanded dispatch 366.1 ± 11.1 µs 239.7 ± 1.9 µs (−34.5%) 156.3 ± 0.9 µs (−57.3%)
cached dispatch 359.2 ± 10.1 µs 235.7 ± 1.4 µs (−34.4%) 150.8 ± 1.3 µs (−58.0%)
combine 178.1 ± 5.0 µs 178.3 ± 2.2 µs (+0.1%) 179.8 ± 1.2 µs (+0.9%)
reduced combine 196.9 ± 5.5 µs 196.3 ± 2.8 µs (−0.3%) 197.6 ± 1.2 µs (+0.4%)

Latency is the right metric at this size: 5.9 MB per rank, ~5 GB/s scale-out. This shape is message-rate bound, which is exactly what merging three single-token puts into one addresses. Combine is untouched, as expected — the change is on the dispatch scale-out path only.

prefill, 8192 tokens — unchanged

op stock this PR this PR + EP_NUM_SUB_PARTS=1
dispatch 1665.1 ± 12.4 µs 1689.7 ± 50.8 µs (+1.5%) 1633.3 ± 2.4 µs (−1.9%)
cached dispatch 1662.8 ± 8.7 µs 1657.8 ± 6.6 µs (−0.3%) 1634.0 ± 2.2 µs (−1.7%)
combine 3560.8 ± 9.2 µs 3552.0 ± 6.9 µs (−0.2%) 3544.2 ± 3.5 µs (−0.5%)
reduced combine 4243.9 ± 9.6 µs 4240.0 ± 9.7 µs (−0.1%) 4205.5 ± 1.4 µs (−0.9%)

Everything is within ±2%, and the one cell that looks like a regression (dispatch +1.5%) carries a ±50.8 µs across-rep stdev — a single noisy rep, not a trend. At 8192 tokens a channel holds far more than 15 tokens, so the clamp is inactive and this is the expected no-op.

Per-rank bandwidth at prefill is likewise flat: dispatch 72–75 GB/s scale-out / 233–246 GB/s scale-up in both arms (399.8 MB per rank). Note this bench's scale-out figure includes intra-node traffic unless --ignore-local-traffic is passed, so it is not a wire-rate number; it is quoted only to show the two arms match.

Caveats

  • Measured on H200 / sm_90 over EFA only. The default of 15 is a judgement call inherited from EP_SM100_MIN_SUB_TOKENS in the same file, not something tuned per architecture — happy to make it arch-conditional, or to default it to 1 (opt-in) if you would rather not change behaviour for shapes nobody has measured.
  • Single node count (2). The interaction with the indexed-signal budget is exactly where more nodes would change the picture, since compute_part_allocation()'s cap tightens with rank count.

Second architecture: B300 / sm_103 over EFA

Independently reproduced on Blackwell Ultra. The clamp holds, and on this
architecture it is the whole effect — EP_NUM_SUB_PARTS=1 adds nothing here
(see #1).

Setup. 2 × p6-b300 (8×B300 SXM6 + 16 EFA gen-3 each, EFA installer 1.50.0,
efa.ko 3.3.0g, driver 595.91.07, compute_cap=10.3), EP8×2 = 16 ranks, NCCL
2.31.2 GIN with NCCL_GIN_TYPE=5 (EFA-GDA). Two images: base 8e7b42e vs
this branch 5a594a5compare/8e7b42e...5a594a5 is ahead 2, behind 0
touching only csrc/jit/compiler.hpp and
deep_ep/include/deep_ep/impls/hybrid_dispatch_unordered.cuh, so the delta is
exactly #1 + #2 with no base skew. 3 reps per cell, dtype order rotated across
reps, GPU memory asserted idle before every run, all 57 rounds exited 0.

One JIT cache directory per image, not per env variant only. The cache key is
name$$compiler$$flags$$code and code is the generated wrapper — the impl
headers are only #included, so their content is not in the key, and with
EP_MIN_TOKENS_PER_PART unset flags is byte-identical between the two trees. A
shared cache dir therefore serves the base cubin to the patched image and the
patch measures as a no-op. (Env-selected variants within one image are safe, since
those do land in flags — that is the property #1 relies on.)

decode, 128 tokens, --num-sms=24tests/elastic/test_ep.py

Kernel+epilogue, median over the 16 ranks then over 3 reps, µs:

op base 8e7b42e this PR this PR + EP_NUM_SUB_PARTS=1
dispatch (bf16) 283.35 176.84 (−37.6%) 176.27 (−37.8%)
expanded dispatch (bf16) 284.39 177.72 (−37.5%) 176.83 (−37.8%)
cached dispatch (bf16) 263.33 167.52 (−36.4%) 168.20 (−36.1%)
combine (bf16) 178.04 179.27 (+0.7%) 178.70 (+0.4%)
reduced combine (bf16) 192.18 192.80 (+0.3%) 194.02 (+1.0%)
dispatch (fp8) 266.37 169.18 (−36.5%) 169.52 (−36.4%)
cached dispatch (fp8) 250.79 161.50 (−35.6%) 161.41 (−35.6%)
combine (fp8) 178.89 178.49 (−0.2%) 180.09 (+0.7%)
reduced combine (fp8) 192.85 192.92 (0.0%) 193.06 (+0.1%)

Same attribution as on H200: dispatch −37%, combine flat within 0.7%. The
change is on the dispatch scale-out path and nothing else moves.

End-to-end, and an in-image control

The same cells through a second, independent harness — a wall-clock
prepare + dispatch + combine region, slowest-rank CUDA elapsed, median of 100
iterations after 20 warmups, median over 3 reps. This one also carries the
in-image control, EP_MIN_TOKENS_PER_PART=1, which #1 makes possible:

arm fp8 µs bf16 µs
base 8e7b42e 647.9 504.4
this PR 554.2 (−14.5%) 392.9 (−22.1%)
this PR + EP_NUM_SUB_PARTS=1 570.3 397.0
this PR + EP_MIN_TOKENS_PER_PART=1 (clamp off) 649.0 504.9

The clamp-off arm lands on the base image to within 0.3% / 0.2%, in the same
binary, so the win is the clamp and not a build or environment difference.
Run-to-run CV is ≤2% on every arm above except two cells at 8–10%.

Kernel-only and wall-clock move together, which is the check that the effect is
really in the kernel: bf16 kernel 464.9 → 359.9 µs (−105.0) against wall
504.4 → 392.9 (−111.5), with the harness overhead flat at 39.4 → 33.0 µs.

The clamp also removes the SM dependence

--num-sms base, bf16 this PR, bf16
12 559.8 µs 393.3 µs (−29.7%)
24 504.4 µs 392.9 µs (−22.1%)

Unpatched, 12 SMs costs 11% over 24. Patched, the two are identical — 12 SMs is
enough and the rest can go to compute. (On H200 the optimum moved down to 12;
on B300 the curve just flattens.)

prefill, 4096 tokens, --num-sms=24 — unchanged

arm fp8 µs bf16 µs
base 8e7b42e 2202.5 2435.1
this PR 2207.5 (+0.23%) 2432.6 (−0.10%)
this PR + EP_NUM_SUB_PARTS=1 2201.6 (−0.04%) 2436.6 (+0.06%)

Run-to-run CV ≤0.9%, so these are genuinely flat rather than noise-covered: at
4096 tokens a channel holds far more than 15 tokens and the clamp is inactive.

Caveats on this architecture

  • Still 2 nodes (EP16). We have since measured the clamp at 4 nodes on
    H200/GIN type 5, where the decode-dispatch win drops to about −8%
    — the
    indexed-signal budget tightens with rank count, exactly as the caveat above
    predicted. Nothing here should be extrapolated past 2 nodes.
  • Arms were run in blocks per SM count rather than interleaved within a rep, as
    on H200. The clamp-off control ran at the end of each block and still matched
    the base image, which bounds any drift over a block.
  • The default of 15 remains untuned on sm_103; it is simply inactive at prefill
    shapes and active at decode shapes, which is the intent.

@whn09 whn09 changed the title perf(hybrid): dont split a channel into more parts than it has tokens for perf(hybrid): don't split a channel into more parts than it has tokens for Aug 21, 2026
@Xuan-1998 Xuan-1998 added the enhancement New feature or request label Aug 21, 2026
@whn09
whn09 force-pushed the clamp-parts-by-min-tokens branch from bde11bd to b097b03 Compare August 24, 2026 06:26
@whn09

whn09 commented Aug 24, 2026

Copy link
Copy Markdown
Author

Rebased onto main @ 02efc268; force-pushed. The change itself is unmodifiedgit patch-id --stable on the rebased commit matches the original.

The old branch showed a 3,000-line diff over 20 files, which was an artefact, not this change. It was cut from main @ ec623f3 (committed 08-21 00:05 UTC); main was then rewritten and force-pushed (cc55cce, committed 08-21 23:51 UTC, same author date). The four base commits exist in both histories under different SHAs, so ec623f3 became unreachable from main and the merge-base fell back six commits to 01dc3aa, attributing the whole unordered-kernel feature to this PR. Both files this PR touches are byte-identical between ec623f3 and main, so the replay was conflict-free.

Measurement caveat: the numbers in the description were taken on ec623f3, not on the rebased tree. ec623f3 → main is +124/−20 across six files, including csrc/kernels/backend/nccl.cu and csrc/kernels/elastic/combine.hpp, so I would not claim they carry over unchanged. The p5en pair used for them is no longer available to me; happy to re-run if you would like the numbers refreshed on main before merging.

// NOTES: the parentheses around the comparison are load-bearing -- an unparenthesized
// `>` inside a template parameter list closes the list instead of comparing (same
// reason `(kNumNotifyWarps > 0)` above is wrapped)
int kNumGeomParts = kMinTokensPerPart <= 1 ? kNumBudgetParts

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The geometry clamp is based on kNumMaxTokensPerChannel, i.e. buffer capacity, not the actual batch. If a deployment sizes one buffer for prefill and pushes decode batches through it, the clamp stays inactive and the many-tiny-parts problem remains. Sub-parts handle this at runtime via num_sub_parts_at(part_tokens). Is it possible for us to handle it at runtime too?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Good catch — the clamp keys off kNumMaxTokensPerChannel (buffer capacity), not the per-call token count, so a caller that sizes one buffer for prefill and reuses that instantiation for decode does stay on the many-tiny-parts side. What this PR covers is the case where the callsite instantiates a kernel per shape (the bench, and callers that size one buffer per mode); for those it lands the correct geometry at JIT time.

Doing it at runtime is more invasive than num_sub_parts_at(), because kNumParts is a compile-time constant used for stack arrays (int64_t rx_part_base[kNumParts], ~L796), workspace layout (constexpr int kNumSlotsPerChannel = kNumParts * kBatchSize, L253), EP_STATIC_ASSERT(kNumParts <= 32, "One lane drains one part") at L1081, and several unrolled for (p = 0; p < kNumParts; ++p) loops. kNumSubParts can go runtime cheaply because it is a within-part ceiling and the workspace layout does not change; a runtime effective_parts would need kNumParts to stay as the allocation ceiling and every part loop / flush / signal path to key off the runtime value and skip the unused tail.

Happy to do that as a follow-up — kept out of this PR because the change surface (loops + sync + tail-skip) is much larger and warrants its own testing. Would you rather we merge this once the other comments are settled, or hold it and land runtime + compile-time together?

// restore the previous behaviour exactly (a value of 1 must SHORT-CIRCUIT rather than divide by
// one: `kNumMaxTokensPerChannel / 1` still clamps whenever a channel holds fewer tokens than the
// budget allows parts, which is a different geometry from the old code and not a control).
#ifndef EP_MIN_TOKENS_PER_PART

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This should go to readme's env var list and clarify its default

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Done in the amended head — added EP_MIN_TOKENS_PER_PART (default 15, 1 disables the clamp) to the General list.

Comment thread csrc/jit/compiler.hpp Outdated
Comment thread csrc/jit/compiler.hpp Outdated
// device-only (no
// host caller reads them), so forwarding them as JIT defines cannot desync host and device,
// and `flags` is part of `kernel_signature` below, so a change re-JITs rather than
// reusing a stale cubin. Tuning them per network/arch currently requires editing the

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

after your change we don't need to edit the header and reinstall right?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Right — with this PR any of the four (EP_NUM_SUB_PARTS, EP_MIN_SUB_TOKENS, EP_SM100_MIN_SUB_TOKENS, EP_MIN_TOKENS_PER_PART) is overridable via env var and the #ifndef still supplies the default when unset or 0. Adjusted the comment tense ("currently requires ..." → "previously required ...") to match.

whn09 added 2 commits August 27, 2026 07:11
`hybrid_dispatch_unordered.cuh` gates the sub-part geometry behind `#ifndef`
(`EP_NUM_SUB_PARTS` 2, `EP_MIN_SUB_TOKENS` 1, `EP_SM100_MIN_SUB_TOKENS` 15), but
nothing in the tree sets those macros, so the only way to try a different split
is to edit the header and reinstall. Forward the three names as JIT `-D` flags,
following the `EP_NUM_TOPK_IDX_BITS` block immediately above (and its
`EP_JIT_EXTRA_FLAGS` TODO).

All three are device-only -- no host translation unit reads them -- so a
JIT-only define cannot desync host and device sizing. `flags` is part of
`kernel_signature`, so changing the env re-JITs instead of serving a cached
cubin. Unset => no behaviour change.

(cherry picked from commit 5118c2e)
…s for

`kNumParts` -- how many `flush_part` puts a channel's tokens leave in -- is
chosen today by `compute_part_allocation()` alone, which only ever caps the
count from ABOVE when the GIN indexed-signal budget is tight. It is never
lowered because the geometry asks for it: `kNumParts` is never compared against
`kNumMaxTokensPerChannel`, and there is no minimum-tokens-per-part threshold.

The budget is loosest exactly when a channel holds the fewest tokens (low
`--num-sms`, small batch), so decode shapes land on `kMaxParts` -- the worst end
of the axis -- with no way to opt out. At 128 tokens / 12 SMs a channel holds 3
tokens and is described as 4 parts x 1 token: the last part is always empty, and
3 tokens leave as three separate single-token puts instead of one 3-token put.

Give parts the guard sub-parts already have. Sub-parts have both a clamp of
`kNumSubParts` to `kBatchSize` and `EP_SM100_MIN_SUB_TOKENS` refusing to
sub-split a part too small to be worth it; parts have neither.
`kMinTokensPerPart` defaults to 15 (copied from the sub-token precedent in the
same file) and is overridable by `EP_MIN_TOKENS_PER_PART`.

`EP_MIN_TOKENS_PER_PART=1` short-circuits to the old value, so it is an exact
in-image control rather than an approximation. `kNumMaxTokensPerChannel` moves
above the part count in the template list; it depends only on already-declared
parameters.

(cherry picked from commit b097b03)
@whn09
whn09 force-pushed the clamp-parts-by-min-tokens branch from 5a594a5 to bfbdd15 Compare August 26, 2026 23:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants