Skip to content

[Example] EP v2 intranode dispatch/combine - #68

Draft
Rachmanino wants to merge 29 commits into
mainfrom
deepep-v2-intranode
Draft

[Example] EP v2 intranode dispatch/combine#68
Rachmanino wants to merge 29 commits into
mainfrom
deepep-v2-intranode

Conversation

@Rachmanino

@Rachmanino Rachmanino commented Aug 10, 2026

Copy link
Copy Markdown
Collaborator

A TileScale-native MoE all-to-all following DeepEP EPv2's impls/dispatch.cuh
and impls/combine.cuh, scoped to intranode NVLink on SM100. Dispatch payload
is bf16 or fp8 (per-token, per-128-element scales, DeepEP's
per_token_cast_to_fp8 layout); combine is bf16.

Design

dispatch runs three phases in one kernel with no host round trip: count
(dedup per (token, destination) with T.match_any_sync, tally into shared
memory with no atomics), exchange (publish this rank's count vector to every
peer so all ranks can derive send_base[d]), scatter (one warp per token).

Rows go straight to their final compact index, so there is no copy epilogue.
DeepEP needs one because its expand layout hides the final position at send
time; here the full count matrix makes send_base[d] + slot final, which saves
a whole local read+write of the payload and costs a ~19µs wait for the exchange.

combine stores back into a slot unique per (contributing rank, source
token) — no atomics — then reduces locally, one block per source token.

Both kernels bracket themselves with tl::barrier_blocks on private slots and
run on a private CUDA stream, so neither needs a dist.barrier.

Performance

8x B200, 8192 tokens/rank, hidden 7168, top-8, 256 experts, 64 SMs, bottleneck
rank. Compared against DeepEP's tests/elastic/test_ep.py on the same machine.
DeepEP's headline covers dispatch_impl/combine_impl only, so its epilogues
are added back here — a plain dispatch → expert → combine loop has nothing to
hide them behind:

DeepEP ours
dispatch (fp8) 442 + 129 µs 510–518 µs
combine (bf16) 839 + 156 µs 965–974 µs
total ~1566 µs 1475–1492 µs

DeepEP is ahead on the cross-rank movement itself (745 vs 723 GB/s on combine's
store-back, which is this port's put_warp roofline) and gives it back to the
epilogues.

At 24 SMs: 589–599 GB/s dispatch, 557–562 combine — −13%/−12% against DeepEP's
−11%/−9% over the same range.

DeepEP's figures are GPU time only; this port's are whole Python calls.

Compiler change

LegalizeSafeMemoryAccess rewrote the argument of address_of into
if_then_else(cond, load, safe), which every consumer rejects with
"address_of argument must be a BufferLoad". Callers had to work around it by
clamping every such index into range by hand and disabling LoopUnswitching.

Taking an address is not a dereference: the bound belongs to whoever
dereferences the pointer. address_of now visits its argument's indices but
keeps it a BufferLoad, exactly as tl.access_ptr already did. No working IR
could have depended on the old behaviour, since it only ever produced IR that
failed downstream.

Testing

pytest examples/distributed/deepep_v2/test_example_deepep_v2.py (2 GPU smoke

  • 8 GPU headline shape). Correctness is bit-reproducible across runs
    (rel_l2 0.003129 bf16 / 0.025973 fp8) and the compact list carries no duplicate
    (src_rank, src_token) pairs — both checked again after thousands of
    back-to-back calls, since every race this port has shipped was invisible on a
    first call.

A TileScale-native MoE all-to-all following DeepEP EPv2, scoped to intranode
NVLink on SM100. Dispatch payload is bf16 or fp8 (per-token, per-128-element
scales); combine is bf16.

Dispatch runs three phases in one kernel -- count, count exchange, scatter --
and writes rows straight to their final compact index, so there is no copy
epilogue. Combine stores back into a slot unique per (contributing rank, source
token) and reduces locally, one block per source token.

Measured on 8x B200, 8192 tokens/rank, hidden 7168, top-8, 256 experts, 64 SMs,
against DeepEP tests/elastic/test_ep.py on the same machine. Per collective,
including the epilogues DeepEP reports separately:

  dispatch (fp8)   DeepEP 442+129us    this 510-518us
  combine  (bf16)  DeepEP 839+156us    this 965-974us
  total            DeepEP ~1566us      this 1475-1492us

Also fixes address_of handling in LegalizeSafeMemoryAccess: bounds checking
rewrote its argument into an if_then_else, which every consumer rejects, so
callers had to clamp indices by hand and disable LoopUnswitching. Taking an
address is not a dereference; the bound belongs to whoever dereferences it,
exactly as for tl.access_ptr.
@coderabbitai

coderabbitai Bot commented Aug 10, 2026

Copy link
Copy Markdown

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: fb2cac6a-bed9-4e52-91a4-2558a004a6bb

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@github-actions

Copy link
Copy Markdown

👋 Hi! Thank you for contributing to the TileScale project.

Please remember to run pre-commit run --all-files in the root directory of the project to ensure your changes are properly linted and formatted. This will help ensure your contribution passes the format check.

We appreciate you taking this step! Our team will review your contribution, and we look forward to your awesome work! 🚀

`topk_idx == -1` is DeepEPs no selection marker and reaches dispatchs dedup
and slot claiming as a destination of -1. The guard was there but nothing
exercised it: every test generated indices with `randint(0, num_experts)`.

Adds a masked 8-GPU case and a `--masked-ratio` knob to both example scripts,
and makes the reference exclude unselected entries. A token with no selections
at all now combines back to zero, which the relative-error check has to special
-case since the expected tensor is then zero.

Also lists the DeepEP intranode features this port does not implement, so the
boundary is explicit rather than inferred.
ruff-check: drop an unused idx local in dispatch.py's scatter loop (the
per-destination idx_k inside the topk loop is what's actually used).
ruff-format: apply formatter line-wrapping to buffer.py, dispatch.py,
example_dispatch_combine_benchmark.py, example_dispatch_combine_correctness.py,
reference.py and test_example_deepep_v2.py (the latter four needed it after
rebasing onto the masked-topk commit, which landed unformatted). codespell:
whitelist "agrs" (DeepEP's own AllGather-Reduce-Scatter term, confirmed
against the DeepEP submodule) instead of letting it rewrite to "args".
`cp_warp_impl` reinterprets both pointers as `int4` and moves
`N * sizeof(dtype_t) / sizeof(int4)` of them, so any element past the last
whole 16-byte unit was never copied -- and when `N` is small enough that
`N_int4 == 0`, neither the unrolled loop nor the drain loop ran at all and
the transfer silently moved nothing.

The shape that hit it: deepep_v2's FP8 dispatch moved its per-token scale
with `put_warp(size=1)` on a `float32`, i.e. 4 bytes. `recv_x_scales` came
back all zero on every rank, with no error anywhere -- the payload copy
beside it was large and worked fine, so the kernel looked correct and merely
inaccurate.

Adds the remainder loop through the original, unreinterpreted pointers
(`N` and `dtype_t` are compile-time, so the tail bounds are too), and a
regression test covering `n=1,2,3` (under one `int4`) and `n=5` (one full
`int4` plus a dropped element) for both `put_warp` and `get_warp`.
FP8 dispatch used to move each token's scale in a second `put_warp` beside
the payload. A remote store's fixed cost (peer-address translation, warp
setup, the NVLink round trip) is noise against 7168 payload bytes and
dominates a 224-byte scale: 566.9us with the second store, 493.1us without,
while splitting a *large* transfer into two calls costs nothing (901.8 vs
894.8us). So `per_token_cast_to_fp8` now returns one packed uint8 row --
payload, then the per-group fp32 scales -- and the scatter moves it in a
single store. Quantisation had to write its output somewhere regardless.

The packed row is padded to 16 bytes always (`put_warp`'s bulk path
reinterprets it as `int4`; `hidden=128` packs to 132 raw bytes and faults
without this) and to 512 -- 32 lanes x one `int4`, which keeps
`cp_warp_impl`'s drain loop warp-uniform -- only when the padding costs
under 5%. That threshold is measured, not guessed: 512-alignment is 8.9%
slower at hidden=2048 and 6.8% slower at 4096, where it costs +21% and +9%
padding, and 2.4% faster at 7168, where it costs +3.9%. Worth noting the
trade is invisible in GB/s, which credits the padding bytes as useful work
-- an isolated `put_warp` roofline reads 594 GB/s at 7392 bytes against 618
at 7680 while the wall time is identical.

`kernels/dispatch.py` takes the resulting width as `row_bytes` instead of
recomputing it, so the rule has one owner.

Also: FP8 correctness tests at both the smoke and the V3 shape (these are
what the `cp_warp` tail bug was found through), a `--fp8` flag on the
benchmark, and a fix to combine's reported bandwidth, which divided by
dispatch's byte count -- identical for bf16, wrong for FP8.

All performance figures in the README re-measured, four samples each, every
one taken in a verified-idle window: bf16 dispatch 686-688 GB/s, combine
623-625, FP8 dispatch 589-591 (~632 on the wire, counting the scales and
padding the payload-only figure excludes).
Two DeepEP EP features that are additive to this port's layout.

`dispatch(cumulative_local_expert_recv_stats=t)` accumulates this rank's
received token count per local expert into a caller-owned uint32 tensor --
DeepEP's load-balance counter, accumulate-not-overwrite so the caller picks
the window by choosing when to zero it.

DeepEP derives it inside notify, from a per-expert count vector it already
exchanges because its expanded layout needs per-expert offsets. This port has
no such exchange, and widening the count matrix from `num_ranks` to
`num_experts` entries per rank to avoid a local scan is a bad trade, so the
count is taken on the receiver: `recv_topk_idx` already holds the local expert
for every received (row, top-k slot), so it is a scan of something this rank
was handed anyway, behind the exit barrier where every peer's stores have
landed. That costs ~25us of dispatch's ~896 (2.8%) at the V3 shape -- enough
that it compiles a separate kernel variant instead of being always-on, so the
default path is byte-identical to before. It becomes free if the expanded
layout ever lands, which would bring the per-expert exchange with it.

`combine(bias=b)` or `combine(bias=(b0, b1))` adds DeepEP's `bias_0`/`bias_1`
to the output. They seed the reduce accumulator rather than being added after
it: the accumulator was going to be written once regardless, so seeding
replaces the clear and costs nothing. It also means a token whose every
selection was masked off comes back as its bias instead of zero, which is the
behaviour a bias is for.

Three tests: per-expert counts against an all-gathered torch reference over
three accumulating dispatches with a quarter of the selections masked, and
bias at one and two tensors, including the fully-masked-token case.

Not implemented, and now documented as deliberate rather than missing:
combine's `topk_weights` side output. DeepEP carries the weights back because
its expanded layout reorders them; in this port's rank layout the source rank
still holds them in the original order, so it would be a collective that
returns its own input.
DeepEP's `do_expand`: one received row per (token, expert) instead of one per
(token, rank), rows grouped by local expert so each expert's are the contiguous
block a grouped GEMM consumes. `expert_alignment` rounds each segment up and
`zero_padding` clears the gap, as DeepEP's `kDoZeroPadding` does.

DeepEP expands in a receiver-side copy epilogue, which can afford an atomic
bump per expert because it is already reading every staged row. This port has
no epilogue -- rows land at their final index straight from the sender -- so
the sender has to know that index. The only thing in the way was the
granularity of the count exchange: phase 1 already tallies per destination,
phase 2 turns tallies into a base, phase 3 adds a locally-claimed slot, and
expanding just redefines destination from rank to expert. So `n_dst` is
`num_experts` rather than `num_ranks` and the same three phases produce the
expert-major layout. The one new term is the segment base: an exclusive prefix
sum over the destination's local experts of their aligned counts, which every
rank derives from the count matrix it already holds.

Capacity is the one place the two layouts genuinely differ. Deduplicated, a
peer sends at most `num_max_tokens_per_rank` rows, so `num_ranks` times that
cannot be exceeded. Expanded, the bound is `min(topk, experts_per_rank)` times
higher -- every one of a token's experts on one rank -- which at the V3 shape
is 7 GiB of receive buffer against 0.88. Balanced routing needs only the 0.88,
so `expand_factor` sizes it and phase 2 checks: every rank derives the same
total from the same count matrix, so an overflow is known before a byte of
payload moves and the destination is skipped rather than corrupted.
`handle.expand_overflow` reports how many rows were needed.

The rank layout's generated code is unchanged. Everything above is trace-time,
including the two places where the generalisation would otherwise have put a
warp-collective instruction in a per-token loop for an answer it already had:
the rank mask's dedup (`dst` *is* `dst_rank` when not expanding) and the peer
shuffle in the scatter.

`combine` asserts rather than accepting an expanded dispatch. Its store-back
slot is `comm_x[rank][src_token]`, unique only because dispatch deduplicated;
expanded, a token with two experts on one rank has two rows that collide. The
fix is DeepEP's `kDoExpandedSend`, summing a token's local-expert rows before
sending, which needs a (src_rank, src_token) -> rows inversion dispatch does
not record yet. That is the next piece, and until it lands the expanded layout
is dispatch-only.

Tested against `reference.expanded_layout` at 8 ranks: per-expert counts, the
aligned segment offsets, the exact set of (src_rank, src_token) in every
segment, the payload of every row (each token carries its own identity, so a
misrouted row cannot look plausible), and that padding is zeroed and marked
unoccupied.

Also splits `test_combine_bias` in two. It called `_run` twice in one process,
which tears the process group down and rebuilds it on the same port; that is
intermittently refused, and it failed once here for that reason.
…ditions

The async support was half a feature: `dispatch` had `async_finish`, `combine`
had nothing and always joined the streams, the event came back as a bare
`torch.cuda.Event` on the handle, and nothing in the tree exercised any of it.
Since combine is the longer of the two collectives (986us against dispatch's
896), it was the half with more to overlap.

Now both take `previous_event` / `async_finish` / `allocate_on_comm_stream` and
both return an `EventOverlap`, DeepEP's wrapper, with `current_stream_wait` and
the `with event:` context manager. It is returned either way -- synchronously it
wraps `None` -- so callers do not branch on the mode they asked for.

`allocate_on_comm_stream` is the one worth explaining. The old path called
`Tensor.record_stream` on this call's temporaries to hand them to the compute
stream, which is exactly what DeepEP avoids: `record_stream` is incompatible
with CUDA graph capture, which is why its event carries `extra_tensors`
instead. Setting the flag keeps the temporaries owned by the communication
stream and alive through the returned event, so the call is capture-safe.
`previous_event` requires it, as in DeepEP.

`pipeline_depth=0` now disables the CPU run-ahead throttle. It blocks the
*host*, so a bounded run-ahead and an asynchronous call are not the same thing,
and a caller driving its own overlap should be able to opt out. The default
stays 2 -- it exists because a rank queued several calls behind stalls every
other rank inside the cross-rank barrier.

Not ported: `combine`'s `combined_topk_weights`, for the reason already
documented -- in this port's rank layout it would return the caller its own
input.

Two tests, since none of this was covered before: a round trip with both
collectives asynchronous, real work enqueued inside the `with` block and the
combine chained off the dispatch's event, which fails if the events do not
actually order the streams; and the contract that a synchronous call still
returns an `EventOverlap` wrapping `None`.

Also three simplifications to what landed earlier today, from AGENTS.md's rules
on dead code and special cases:

- `dst = T.alloc_var(T.int32)` in both of dispatch's per-token loops allocated
  a variable the next line's rebinding threw away.
- Phase 3's slot claim was split into expanded and deduplicated arms to keep one
  `send_base` load off the rank layout's path, on an assumption never measured.
  One condition covers both: deduplicated, `send_base` is never negative.
- The handle carried `psum_recv_count` from the *previous* call in the expanded
  layout, which never computes it, behind a docstring warning. It is `None`
  there now.

All 12 tests pass.
An AGENTS.md pass over what accumulated today. Three findings, all in
`buffer.py`.

`dispatch(num_sms=N)` silently did nothing. It resolved the value, stored it on
the handle, and `combine` read it back at the top and never used it again --
both kernels were built from `self.num_sms`. A parameter that travels through
two APIs and a handle field and changes no behaviour is worse than an absent
one, and DeepEP's `num_sms` is a working knob. The kernel caches are keyed on
it now and it is validated against the device's SM count, which both kernels
need anyway since they rendezvous grid-wide. Worth having rather than deleting:
the SM sweep measured 64 -> 128 blocks at about 4%.

The three expanded-layout outputs were cloned twice -- once inside the
comm-stream block and again into the handle -- and deduplicated, where nothing
produces them, the first three were allocated, carried in `temporaries`, and
thrown away on every call. Cloned once now, and only when there is something to
snapshot.

And `min(x + slack, hard + slack)` where the line above had already clamped
`x` to `hard`.

Left alone deliberately, since they are DeepEP's handle contract rather than
accidents: `EPHandle.num_experts`, `num_max_tokens_per_rank`, `topk_idx` and
`psum_recv_count` are unread today, but `topk_idx` is exactly what a cached
`handle=` dispatch has to reuse.

12 tests pass. Separately verified that a per-call `num_sms` now compiles its
own kernel and stays correct at two different values.
Phases 1-2 compute a layout and phase 3 moves the payload; they are two kernels
now. The launch boundary hands phase 3 everything phase 2 published, which is
exactly what the in-kernel spin on `exchange_done` was buying, so the split
costs nothing: 881.7us against 883.7 fused at the V3 shape, the extra launch
absorbed by the spinner it replaces.

What it buys is a constraint removed. The fused kernel ends in `T.sync_grid()`,
so every block has to be resident and the grid is pinned to `num_sms` -- and
that pinned the scatter too, though the scatter needs no grid-wide rendezvous
of its own until the reset. ncu had already found the cost: 64 blocks on a
148-SM device, 0.22 waves/SM, 25% occupancy, issuing on 1.51% of cycles. With
the scatter free to be wider, `scatter_sms=128` gives 849.1us bf16 and 487.0
fp8 against 883.7 and 507.4 fused, about 4% each.

Not DeepEP's split, and worth being clear about why. DeepEP cuts *after* the
movement into a copy epilogue that compacts rows staged per sender; this port's
scatter already writes rows at their final index, so there is nothing there to
extract and adding one would cost the ~120us that is currently the margin over
DeepEP. This cut is before the movement, and the piece it isolates -- 34.7us of
layout work, constant across dtypes -- is what a cached `handle=` dispatch
would skip outright. Fused, that was welded into the same kernel.

`scatter_sms` defaults to `num_sms`, so the default path is unchanged: a caller
who capped `num_sms` to leave the device for expert compute did not thereby ask
for a scatter that takes it back.

13 tests, including a new one that runs the scatter on a wider grid than the
notify -- `scatter_sms` has to stride the token loop, the alignment-padding
loop and the stats scan, and any of the three left on the old stride drops or
double-writes rows.
…worthy

Every figure re-measured on the current tree. The default path is unchanged, as
it should be -- the kernel split is free and `scatter_sms` defaults to
`num_sms` -- so bf16 dispatch is still 686-687 GB/s and combine 623-624. New
rows for the wider scatter grid: 703-716 GB/s bf16 and 613-617 fp8, about 3.4%
and 4.0%.

The gating note is rewritten because the old rule was wrong twice in one day.
It waited for zero foreign GPU *processes*, which is neither necessary nor
sufficient: an 8-way inference server holding 167 GB/GPU at 0% SM blocks it
forever while disturbing nothing, and a job under this same account is
invisible to a by-other-user check while pinning a GPU at 100%. Both happened,
and both produced numbers 30-60% off that looked perfectly stable. The test is
now whether any process that is not ours *used the SMs* during the run, sampled
throughout with `nvidia-smi pmon`.

The "DeepEP's figures are GPU time, this port's are whole Python calls" caveat
is replaced by a like-for-like table. Measured the same way on the same idle
machine, fp8 dispatch is 439us of movement plus 113-130us of compaction for
DeepEP against 35us of layout plus ~470us of movement and no compaction here:
DeepEP is about 7% faster at moving the bytes, this port about 9% faster
overall for not having a second pass. Host overhead is 8-10us.

Also records that `async_finish` changes neither side's latency -- 901.2us
synchronous against 901.4 asynchronous with the event waited on -- and that
timing an asynchronous call without waiting reports 3.0us, which is the launch.
Phases 1-2 depend only on the routing, so a dispatch whose `topk_idx` has not
changed recomputes a layout it already has. `dispatch(x, handle=h)` skips it:
`cached` traces the notify kernel away entirely -- verified in the generated
source, one `__global__` instead of two -- leaving the scatter to read
`send_base`, `send_rank_mask` and `num_recv` exactly as the previous call left
them. None is touched by the end-of-call reset, which clears only what phases
1-2 consume.

Splitting the kernel first is what makes this worth anything. Fused, notify was
welded to the scatter and skipping one meant skipping the payload too. DeepEP
has the same problem from the other side: its cached dispatch measures 441-444us
against 437-439 uncached, saving host work rather than GPU time, because its
notify sits in a kernel that still has to run. Here a whole 35us kernel goes.

The entry barrier moves with it. It is not about the layout -- it stops a peer's
next round landing on data this rank is still reading -- so it opens whichever
kernel runs first.

A stale handle is guarded because the failure is silent. `send_base` and
`send_rank_mask` are updated in place, so a handle from before some other
dispatch does not fail, it routes to the wrong slots and returns plausible
numbers. `Buffer._layout_generation` increments whenever a layout is computed
and a cached dispatch asserts the handle still matches.

API follows DeepEP: `topk_idx` and `topk_weights` must be `None` when a handle
is passed, and `handle.topk_idx` is what gets replayed -- the field flagged as
unread three commits ago, now doing the job it was kept for.

14 tests. The new one checks fresh, cached and cached-twice agree, and that a
handle whose layout has been overwritten is rejected.
The docstring claimed the whole 34us notify kernel disappears. It does not, and
the estimate was about twice the truth. Measured, three clean samples per row:
fp8 dispatch 514 -> 497us whole call (3.3%), bf16 888 -> 871 (2.0%).

The gap is visible in the kernel times: the cached scatter runs ~481us against
the uncached one's ~470. The entry barrier moves into it rather than
disappearing -- it is about peers not overwriting data this rank is still
reading, which holds however the layout was obtained -- so roughly 10us of
notify's 36 is work that has to happen either way.

Also fixes ep2_scratch/run8.sh, which is what wedged the host: it put a timeout
on the wrapper only, so a rank blocked in a rendezvous that never completed
outlived it, and eight stranded ranks per attempt eventually left sshd unable
to fork. Ranks now carry their own timeout, run under setsid so the trap takes
their children, and the launcher picks a port it has verified is free rather
than a random one that collided with TIME_WAIT.
The expanded layout was dispatch-only: combine refused it, because its
store-back slot `comm_x[rank][src_token]` is unique only when dispatch has
deduplicated. Expanded, a token with two experts on one rank has two rows and
they collide there. So `do_expand` could not be used end to end at all.

DeepEP's answer is `kDoExpandedSend` -- sum a token's local-expert rows before
sending -- and that is what this does, so one row per (rank, token) still
crosses NVLink and `comm_x` and the reduce are untouched. It needs the inverse
of what dispatch records: dispatch gives row -> (src_rank, src_token), and this
needs (src_rank, src_token) -> rows. A third kernel builds it by bucketing each
received row under its source, metadata only, never the payload.

The store-back then runs one warp per group, elected by the group's first row.
The common case is unchanged: with routing spread over ranks most groups hold a
single row, sent straight from `x` with no summing and no staging. Only groups
of two or more accumulate into `reduce_scratch` first.

Dispatch had to change too, and this was the subtle part. Deduplicated, a row
carries every local expert of its token and the epilogue weights it by their
sum. Expanded, the token has one row *per* expert, so leaving that alone would
weight every row of the token by the same sum and combine would then add the
duplicates -- correct-looking rows, wrong total. An expanded row now marks only
the expert it belongs to.

Two tracing constraints worth recording, both already documented elsewhere in
this example for other reasons:

- `alloc_var` names bind for the whole traced function, not per `T.Kernel`, so
  the bucketing kernel's locals had to be named distinctly from the
  store-back's or they read as one immutable variable escaping its region.
- the scratch row is indexed by the unrotated block/warp pair. `warp` folds in
  `my_rank`, whose range the compiler cannot prove, and the resulting bounds
  check wraps the index in an `if_then_else` that `address_of` rejects.

16 tests. Two new ones cover the expanded round trip, at top-8 over 8 ranks so
collisions are common rather than incidental, and with half the selections
masked so the grouping pass has to skip the alignment padding dispatch writes.
It hides about 30us, and that is host and launch overhead rather than any part
of the collective. Beside a 673us GEMM the dispatch still costs 1538us against
a 1569us serial, where real overlap would be ~892.

Three explanations ruled out by measurement rather than argument: it is not the
`wait_stream` dependency, because `previous_event` gives 1538.7 against 1538.4;
it is not SM starvation, because 8, 16 and 32 SMs all hide the same ~35us; and
it is not that the saving is proportional and small, because the same ~30us is
hidden whether the GEMM is 99us or 673us.

What is left is that both dispatch kernels end in `T.sync_grid()` and are
therefore cooperative launches, which do not co-schedule with other work. That
would make it structural rather than a bug in the API, and suggests a fix --
the only thing needing a grid-wide rendezvous is the end-of-call reset, so
moving it to the front of the next call would let the scatter be an ordinary
launch. Recorded as untested.

The API itself is correct and DeepEP-shaped; this is about what it is worth
today, which the tests could not have told us.
The README speculated that `async_finish` hides so little because both dispatch
kernels end in `T.sync_grid()` and a cooperative grid does not co-schedule with
other work, and offered a structural fix. Tested, and it is not the cause.

The end-of-call reset really is the only thing needing a grid-wide rendezvous,
and moving it to the front of the next call does make the scatter an ordinary
launch -- correct on all 8 ranks, since peers only publish into `count_matrix`
after the entry barrier and the reset now runs before it. Overlap went from
1538.4us to 1552.8: nothing. Discarded rather than landed, per the rule that a
change which does not clear the threshold gets reverted.

Dropping the rendezvous also permits a scatter grid larger than the device's SM
count, which turns out to be worse as well: 450us at 128 blocks against 621 at
256 and 541 at 512.

That makes four explanations measured and rejected -- the `wait_stream`
dependency, SM starvation, proportionality, and now this. The remaining
candidate, untested, is that a bandwidth-bound collective gated by its slowest
rank simply has little to give when a large GEMM is slowing every rank's
participation.
Run through the identical harness on the same idle machine, DeepEP hides 81% of
a 673us GEMM behind its dispatch -- 1177.8us overlapped against a 1724.7us
serial -- where this port hides 4%. So the previous conclusion, that a
bandwidth-bound collective gated by its slowest rank has little to give, was
wrong. The reversal is worth stating plainly: this port is faster serially
(1566 against 1725) and 23% slower overlapped.

The cause is `comm_stream.wait_stream(compute_stream)` at the top of
`dispatch`, taken whenever no `previous_event` is given. It makes the
collective wait for everything already queued on the caller's stream,
including work it has no dependency on. Recording an event once before any
compute and passing it in, so the comm stream never waits on the compute
stream, drops the overlapped time to 675us -- the collective disappears behind
the GEMM. That configuration is not usable as it stands, since it also lets
successive dispatches overlap each other on shared buffers, but it isolates
the mechanism.

Closing the gap means `async_finish` no longer implying that wait, which is a
semantic change rather than a bug fix: DeepEP puts the ordering on the caller
via `previous_event` and `allocate_on_comm_stream`, and this port currently
takes it on itself. Recorded, not yet done.

Also corrects the earlier note: the cooperative-launch theory was tested and
refuted, along with SM starvation, proportionality, and `previous_event`
recorded inside the loop.
…lap gap

The previous commit named `comm_stream.wait_stream(compute_stream)` as the
cause. It is not. DeepEP's `stream_control_prologue` takes exactly the same
dependency when no `previous_event` is given -- which is how it was measured --
and overlaps anyway. The reasoning was that removing the wait fixes it for us,
which is true but does not make it the cause, and the same experiment had
already been called unusable for letting successive dispatches overlap on
shared buffers.

Two further things measured since, both negative and both narrowing it:

A notify-only dispatch, 63us against the full 892us, hides 28.2us where the
full one hides 29.7. So the amount hidden is constant across a 14x range of
dispatch length as well as a 7x range of compute. That is the signature of
overlapping the enqueue gap and nothing else, and it rules out both the scatter
specifically and barrier spinning -- a dispatch that is almost entirely
barriers hides the same absolute amount as one moving 850us of payload.

Six explanations now measured and rejected: SM starvation, proportionality,
the cooperative launch, grids beyond the SM count, `previous_event` recorded
inside the loop, and the stream dependency itself. The section says so rather
than offering another guess.
…ther

Best-motivated theory yet, and wrong. `sync_grid` is
`cudaLaunchCooperativeKernel`, which requires every block co-resident and so
cannot be co-scheduled with other work, and EPv2 has no `this_grid().sync()` in
its intranode path at all -- only in the legacy `internode_ll`. That is a real
structural difference between a dispatch that overlaps and one that does not.

It had been tested twice, badly, and both times only half of it: the original
arrangement leaves the scatter cooperative and the notify ordinary, and moving
the reset to the front of the next call swaps which one is which. Every variant
measured still contained exactly one cooperative launch, which is why "no
change" meant nothing.

Removed properly by moving the padding, stats and reset into a third kernel --
a kernel boundary supplies the same ordering the grid sync did, since
`barrier_blocks` rendezvouses ranks rather than a rank's own blocks. Dispatch
then has no cooperative launch anywhere, is correct on all 8 ranks, and hides
27.3us out of 1534.8. Indistinguishable from every other variant.

Seven explanations now measured and rejected. The constant is striking: ~30us
hidden regardless of dispatch length, compute length, SM count, launch mode or
stream dependency, while removing the compute-stream dependency entirely gives
full overlap. Further A/B is the wrong instrument; this needs a timeline.
Seven A/B experiments could only ever say "not this one". A timeline says what
actually happens, and it is a strict alternation: notify, GEMM, scatter, GEMM,
notify... with the GEMM starting 9-10us after the scatter ends, every
iteration, and never running during it. The only concurrency is the next
notify overlapping the GEMM's tail by 40-48us -- which is the constant ~30us
that every measurement all day has been reporting.

The dependency cycle accounts for it exactly. Iteration N+1's dispatch calls
`wait_stream(comm, compute)` after iteration N's GEMM is already queued on the
compute stream, so the communication waits for the previous GEMM; `with event:`
puts the event wait on the compute stream, so the next GEMM waits for the
previous dispatch. Each stream ends up waiting on the other's last item.

DeepEP traced identically does not alternate: its GEMM starts 112us and 0.6us
into two successive dispatch kernels and runs entirely inside them. Same stream
plumbing on both sides -- verified in their source -- so the difference is in
how the kernels are launched or admitted, not in the API.

Records the numbers rather than another theory. This is where to pick it up.
The async path was already sound; it hid 4% of a concurrent GEMM because a
private stream buys eligibility, not admission. The trace shows the collective
becoming eligible the instant the previous scatter ends and then waiting 624us
anyway, for an SM. A GEMM worth hiding behind is also large enough to hold the
whole device -- 2048 blocks, each taking a full SM of registers and 213KB of
shared memory, so 148 are resident and fourteen waves pending -- and block
admission is greedy, so every SM that frees goes to the next GEMM block. The
collective gets in only as the last wave drains, a fixed ~48us window, which is
why the amount hidden was a constant ~30us regardless of dispatch length,
compute length or SM count.

The experiment that settled it: `torch.cuda._sleep` calibrated to the same
674us as the GEMM occupies the compute stream just as long but uses one block,
and is hidden 100%. Same streams, same events, same dependencies, 147 SMs free
instead of none. Occupancy, not ordering.

Stream priority biases exactly that admission decision. 29.7us hidden -> 286.3
(4% -> 43%) at the V3 shape. Any raised priority does it -- -1, -2 and -3 all
measure ~285us -- so the buffer takes whatever the device offers and a caller
scheduling several streams can override it. `num_sms` matters now for the first
time, being how many blocks are queued for admission: 158us at 16, 223.6 at 32,
285.3 at 64.

Standalone performance is unchanged, which is the point: 898-900us bf16
dispatch, 984-985 combine, 522 fp8, all inside the spread of the existing
numbers. 16 tests pass.

Supersedes the six rejected explanations recorded in the previous commits; the
README now carries all of them with their numbers.
The number was already there; this is the mechanism. Before the priority
change the GEMM started 9-10us after the scatter ended, every iteration, and
the two were never resident together. After, they are:

    scatter [2916.8 -> 3770.6]   GEMM [2605.8 -> 3505.6]   588.8us concurrent
    scatter [4057.4 -> 4892.2]   GEMM [3781.8 -> 4704.4]   647.0us concurrent

Also records that 64 SMs is already the peak -- 158us hidden at 16, 223.6 at
32, 285.3 at 64, 267.7 at 96, 96.9 at 128, where enough blocks are queued for
admission that the collective starves itself -- so the existing default needs
no change.

DeepEP still hides 81% against this port's 43%, and that difference is stated
as unexplained rather than guessed at. Its dispatch is one kernel where this
one is two, which gives the GEMM a seam to fall into.

Trace archived alongside the before/after pair in ep2_agent_docs/overlap-traces.
…tream

Worth 144.6us of hidden time, 43% to 64%, for moving two lines above a wait.

The admission stall that the stream-priority fix addressed is not paid
uniformly. There is exactly one moment per iteration when it is free -- the
~2.5us window after the previous scatter releases its SMs and before the GEMM
refills them -- and whichever operation is queued first on the communication
stream gets it. Everything behind it pays 90-140us.

That slot was going to `topk_idx.to(int32)`, a 3us elementwise kernel issued
inside the communication-stream block, which won it 0.4us ahead of the GEMM and
left the 850us collective behind it to pay 129.9us. Converting on the caller's
stream puts the collective first in the queue; `wait_stream` is issued after the
conversions, so the dependency still covers them.

Isolated from the caller side first, by pre-converting the tensors so the
buffer's cast becomes a no-op: 285.0us hidden with it, 425.7 without. Hoisting
inside the buffer then measures 430.9 of 675.8. 16/16 tests pass and standalone
dispatch is 890.9us, unchanged.

Also corrects a claim in e4e519b, which is pushed and cannot be amended: EPv2's
intranode path does use `this_grid().sync()`, at common/comm.cuh:233,251,269
from impls/dispatch.cuh:74,398. Cooperative launch was never the structural
difference that commit said it was -- the test it motivated stands, since it
measured our own kernels and found nothing.
Worth 126.6us of hidden time, 64% to 83%, which puts this port past DeepEP's 81%
on the same benchmark. Standalone is unchanged: 899.0us bf16 dispatch, 982.8
combine, 522.8 fp8.

With the stream priority and the hoisted cast in, one steady-state iteration:

    cast     1170.8 ->  1173.4     2.6us
    GEMM     1173.1 ->  2070.4   897.3us  g=2048
    notify   1299.3 ->  1325.9    26.6us           126.2us after the GEMM
    scatter  1444.6 ->  2279.3   834.6us           118.7us after the notify

The scatter overruns the GEMM by 208.9us and that is all of what stays unhidden.
Not because it runs slowly while sharing the device -- 834.6us here against
844.1 solo -- but because it starts 271.5us late. Of that, 126.2us is the first
admission, which DeepEP pays too, and 118.7us is the gap between our own two
kernels. Solo that gap is 1.9us. Sixty-two times wider under contention, because
the notify releases all 64 SMs on the way out and the GEMM has twelve waves
queued to take them back.

Programmatic dependent launch is how DeepEP avoids a second admission -- its
epilogue seam measures -30.9us -- and TileLang exposes it, so it was tried
first: T.pdl_sync() on the dependent kernels, verified in the generated CUDA and
in the launch path, worth 8.2us. Pre-admission places the dependent grid while
the primary is resident, but a GEMM CTA holds 213KB of the SM's 228KB of shared
memory, so there is nothing for a comm block to be placed beside and the
placement still waits for an SM to drain.

Not giving the SMs up at all is the fix, and that means one kernel. The kernel
was one before 17123c6 split it for scatter_sms, and that commit's own numbers
put the fusion at 883.7us against 881.7 split -- free on an idle device, which
is where it was measured. The split survives for the case that motivated it, a
scatter grid wider than the notify's, which one sync_grid-bearing kernel cannot
express; scatter_sms=128 still gives 868.4us there.

Mechanically the phases become T.macros so both packagings share them. They sit
in dispatch_kernel rather than inside main: the eager builder rewrites a
prim_func body into a generated function and hoists nested defs out of its
scope, so a helper defined inside main is not in scope at its own call site.

16/16 tests pass, including the one that runs the scatter on a wider grid than
the notify, which is now the only caller of the split path.
kernels/dispatch.py 729 -> 583 lines, and the whole example 2767 -> 2606.

scatter_sms let the scatter run on a wider grid than the notify, worth about 4%
standalone. Fusing the two phases into one launch -- worth nineteen points of
overlap -- cannot express two grids, so the fusion kept both packagings: two
T.macros carrying thirty-two buffer parameters between them, a `fused` flag and
a duplicate launch site.

Measured, none of that earns its place. A fused kernel simply given the wider
grid beats the arrangement it was preserving:

    num_sms=64                    898.2 us
    num_sms=96                    873.6
    num_sms=128                   861.0     <- one grid, fused
    num_sms=64, scatter_sms=128   867.1     <- two grids, split

So scatter_sms=128 is num_sms=128 spelled less clearly and 6.5us slower, and
what it bought the notify -- 64 blocks instead of 128 for 26us of work -- is
worth nothing. The knob goes, the split goes with it, and the kernel is one
grid with one stride throughout. The test that covered it now covers num_sms=128
on the fused grid, which exercises the same three strided loops.

Also, in passing and each measured only as unchanged:

- alloc_var declarations followed by a first assignment fold into init=, which
  is what the rest of the file does
- combine's group reduce loops to the runtime count instead of unrolling to the
  compile-time maximum and predicating back down; T.serial already takes a
  runtime bound, as the token loop above it shows
- combine seeds the accumulator from bias_0 with T.copy rather than a Parallel
  loop, since T.copy carries the cast
- dispatch and combine had twelve duplicated lines of stream handover; they are
  _begin_comm and _end_comm now, which is also where the rule about what may be
  queued first on the communication stream belongs, since that is easy to break
  by adding a line in the wrong place. DeepEP factors the same pair.

16/16 tests pass. Standalone 898.1us bf16 dispatch, 984.5 combine, 521.3 fp8,
all within noise of before; overlap 82.8-83.0%, likewise.
The DeepEP column was bench_kineto GPU time, split into main kernel and
epilogue, sitting next to whole-Python-call figures on this side. That is not a
comparison: it charges this port for host overhead and launches that DeepEP's
column does not carry, while splitting DeepEP's dispatch into two rows neither
of which is what a caller pays.

Both columns now come from one harness -- same do_bench, same warmup and rep
counts, same shape, same 5s clock warm-up, same eight ranks, run back to back
on one idle machine. DeepEP runs with do_cpu_sync=False and do_handle_copy=False,
work this port has no equivalent of. Two rounds each, both reported.

                           DeepEP              this port
    dispatch bf16          1047.7 / 1049.2us    890.2 / 892.1    -15%
    dispatch fp8            566.7 / 568.1       512.0 / 512.6    -10%
    dispatch cached        1045.4 / 1049.2      874.6 / 875.8    -16%
    combine bf16            988.9 / 992.1       954.4 / 954.9    -3.7%

    overlapped (8192 GEMM) 1176.3 / 1176.2     1002.7 / 1006.0
    hidden                  546.6 / 548.2 82%   558.4 / 559.4 83%

A whole layer's collectives come to 2039us against 1846 in bf16 and 1558
against 1467 with an fp8 dispatch.

handle= is the row where the two differ in kind rather than degree: DeepEP
saves nothing measurable, because its layout work lives inside the main kernel,
where here it is a phase that can be skipped outright.

Also records what the numbers do not show and matters more than any of them --
DeepEP is a production multi-node implementation and this has no RDMA path --
along with where DeepEP is ahead (745 against 723 GB/s on the movement itself,
and a gentler slope as SMs are taken away), and the size of the two intranode
paths: ~2110 lines of CUDA against 828 of TileLang, 1107 lines of host Python
against 790. The two epilogues and the separate layout module are most of that
difference.
8e2ab99 was meant to replace the "Against DeepEP" section and instead
duplicated 230 lines of the document, leaving the stale comparison it was
replacing still in place below the new one. 712 lines where there should be 482,
with "### Overlapping", "## Performance" and "### Against DeepEP" each appearing
twice.

The patch bounded the region to replace with s.index("## Overlap"), which
prefix-matches the "### Overlapping" heading 165 lines *above* the section being
replaced. End before start, so s[:start] + new + s[end:] re-appended everything
in between. Nothing checked the result, which is the actual mistake -- the
section boundaries had been printed a few commands earlier and showed exactly
this.

Same content as intended, bounded correctly this time.
…emeasure

Buffer defaults to 1024 threads for both collectives; the benchmark script still
asked for 512 and 256, so every figure in the README's Performance section
described a configuration the library does not use. A/B at 64 SMs, two rounds,
interleaved:

    threads       dispatch          combine
    512 / 256     897.4 / 897.9     985.4 / 984.3
    1024 / 1024   892.3 / 891.9     955.1 / 955.4
    1024 / 512    892.0 / 892.3     962.0 / 962.1

1024/1024 wins both, by 0.6% on dispatch and 3.0% on combine, so the script asks
for what the library already does and the tables are remeasured against it:

    bf16              690.8 GB/s   892.0us    combine 644.5-645.4  954.7-956.1
    fp8               595.4-596.8  516.2-517.5        644.8-645.0  955.4-955.7
    bf16 num_sms=128  718.2-718.3  857.9-858.0        659.0-659.6  934.1-935.1
    bf16 num_sms=24   599.5-599.6  1027.7-1027.8      567.4-568.8  1083.3-1085.9

So the slope from 64 to 24 SMs is -13% / -12%, not -13% / -13%.

The per-phase breakdown is remeasured from a trace too. Dispatch is 865.9us of
kernel inside an 892.0us call. It is one kernel now, so the phases inside it no
longer have a launch boundary to be separated at, and the old decomposition into
scatter, barriers and count exchange cannot be reproduced -- what is left is the
total, the count exchange and the barriers. Combine is 866.6us of store-back and
114.6us of local reduce, which sum to more than its 955us call because the trace
isolates each call behind a full synchronise and so pays the cross-rank entry
barrier in full every iteration.
…ded dispatch

The comparison section was all timings, while what is and is not supported sat
split between the API section and Not implemented at opposite ends of the
document -- so the one place a reader goes for "how does this compare" answered
only half the question. Eleven rows now, pointing at both for the reasoning.

Writing it down is what caught the stale part. The README still claimed combine
cannot consume an expanded dispatch and asserts rather than returning a wrong
answer. That stopped being true at ca1430d: there are two passing round-trip
tests, a bucketing kernel in kernels/combine.py that builds the
(src_rank, src_token) -> rows inversion the claim says is missing, and no assert
anywhere that would block it. The Not implemented entry for
kAllowMultipleReduction repeated the same error and is corrected with it -- the
capability is covered, by summing on the sender rather than the receiver, and it
is the flag rather than the feature that is absent.
@Rachmanino Rachmanino changed the title [Example] DeepEP EPv2 intranode dispatch/combine [Example] EP v2 intranode dispatch/combine Aug 17, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant