Skip to content

perf(ep): InterNodeV1LL low-token latency on MI300X + CX7 - #614

Draft
jhchouuu wants to merge 38 commits into
mainfrom
jiahzhou/ep-v1ll-mi300x-4token-latency
Draft

perf(ep): InterNodeV1LL low-token latency on MI300X + CX7#614
jhchouuu wants to merge 38 commits into
mainfrom
jiahzhou/ep-v1ll-mi300x-4token-latency

Conversation

@jhchouuu

@jhchouuu jhchouuu commented Aug 28, 2026

Copy link
Copy Markdown
Collaborator

Draft, stacked on perf/ep-small-token-host-overhead (#604) — please merge that first.

Kernel-side latency work on InterNodeV1LL at low token counts, measured on 2x8 MI300X + ConnectX-7 InfiniBand (p19), EP16, hidden 6144 bf16.

Result

Against this branch's head (plus the mlx5 fix below, without which nothing runs on CX7), interleaved same-session A/B, medians:

4 tokens/rank dispatch combine
perf/ep-small-token-host-overhead 65.1 us 75.7 us
this PR 42.2 us 62.8 us
-35% -17%

At 64 tokens/rank: dispatch 181.7 -> 164.0 (-9.7%), combine 201.1 -> 196.7 (-2.2%).

Splitting kernel work from tuning work with a three-way round robin (same tables in the middle row, so the last step is the kernel changes alone):

4 tokens dispatch combine
A0 branch head 65.1 75.7
A1 stock kernels + this PR's tables 57.8 69.6
C this PR 42.2 62.8
tables alone (A0->A1) -7.3 -6.1
kernels alone (A1->C) -15.6 (-27%) -6.8 (-9.8%)

The two columns are not strictly additive: the tables were swept on the changed kernels, so A1 runs stock kernels at a geometry that is not their own optimum. The defensible statement is that with the shipped geometry held fixed, the kernel work is worth -15.6 / -6.8 us.

What is in it

Two of the wins are the same bug in different clothes — a work split sized against something that is not the real token count:

  • CombineInterNodeLLTyped relay split, 4 -> 8 warps per token (-5.5 us combine). The dynamic formula commented out above the constant divides by nodeCount, but the sender signals numChunks * warpSize + 1, so nodeCount is 64 whenever a rank sends one chunk — i.e. every shape in the low-latency regime, no matter that only ~8 tokens are real. The formula was measuring the padding, which is why it lost to a constant.
  • A floor under MultiWarpIter's slice (-2.9 us at 4 tokens, -4.2 at 64). warpsPerItem = ceil(globalWarpNum / numItems) had no lower bound, so the split was decided by how wide the grid happens to be: EpCombineAll runs 608 warps over 4 tokens and handed each warp 40 elements of a 6144-wide vector; EpDispatchCopyToStaging handed out 24 bytes. 512 elements is a measured optimum — 768 and 1024 are both worse.

Dispatch-side critical path, from profiler sub-spans:

  • Ring the NIC before scanning topk (-1.1 us). Nothing the put needs depends on the scan — source is the staging buffer, length is the chunk's token count, destination comes from a local counter. The scan only fills maps that combine reads much later.
  • Signal the chunk flag with an inline write instead of an IB atomic (-0.7 us). The flag slot has exactly one writer: proxyPe = node * gpuPerNode + (rank % gpuPerNode) pairs each rank with exactly one remote rank, and flagSlotId comes from a per-node counter on the sender. AMO_ADD makes the NIC issue a read-modify-write on the responder; AMO_SET is an 8-byte inline write ordered behind the payload for free.
  • Block-local resolve/copy split in the relay receive (__syncthreads(), not a grid barrier), flat XGMI stride in DispatchIntraNode, batched topk loads, and narrowing the send barrier to the blocks that actually send.

Also included:

  • fix(rdma/mlx5) — mori cannot run multi-QP on ConnectX-7 at all without this: rdma-core returns the ibv_context's singleton NC UAR from every devx_alloc_uar(MLX5DV_UAR_ALLOC_TYPE_NC), so the second QP's hipHostRegister aborts the process. Also pushed standalone as jiahzhou/mlx5-uar-single-register if you would rather take it separately.
  • fix(profiler)ENABLE_PROFILER=ON does not compile on main: intranode_ll.hpp binds IntranodeSlot but its slots are generated into IntranodeLlSlot.
  • perf(ep): honour the zero-copy combine inputget_registered_combine_input_buffer() is silently unusable on the internode path today; CombineSync staged over the caller's data. IntraNode already honours useExternalInpBuffer.

Two things worth knowing about the tuning table

  1. The lookup clamps. It takes the tightest ceiling match and falls back to the largest recorded rule when num_tokens runs past it, so a lone low-token rule shadows every larger count. Adding a single bf16/6144 4-token dispatch rule cost 22 us at 64 tokens before I noticed. A rule is not additive in this format.
  2. The 4-token combine rule is re-pointed from 16/10/4 to 64/42/2 (71.1 -> 62.2 us, 5/5 pairs). Not a criticism of the sweep that produced 16/10/4 — the optimum moved underneath it, because both split fixes above land in that same combine path.

More generally, on this box a straight sweep pick lost to the incumbent three times (by 2.3 us at 64 tokens, 3.9 us at 4, and 8.9 us here). The tuner's spread is larger than the differences it is ranking at 4 tokens. Every rule here was A/B'd against the incumbent before being written down.

Validation

--cmd test, 1000 rounds x 16 ranks, 0 errors at both 4 and 64 tokens per rank, hidden 6144 and 7168.

Not done

  • Only gfx942/MI300X is measured. The kernel changes are arch-independent in principle but the constants (relay split of 8, kMinDimPerWarp of 512) were tuned here.
  • The zero-copy combine path has no swept tuning rule, so a caller that uses it falls back to AUTO's hard-coded geometry. Separately: two rules differing only in zero_copy make rule selection nondeterministic — worth a look independently of this PR.
  • Remaining budget at 4 tokens is ~18 us of inter-kernel gaps in the combine chain, and ~5 us of serial uncached accesses before the NIC doorbell (would need warp-cooperative WQE construction in Mlx5PostWrite). Five attempts to shrink the first and three to shrink the second all measured zero or worse.

isytwu and others added 30 commits August 27, 2026 10:38
The winner threshold was an absolute 1.0 GB/s. That constant cannot work
across the operating range: at 4 tokens the whole sweep lives in 2.0-3.2
GB/s, so `bw > best + 1.0` never fires and the tuner returns candidate
[1/N] verbatim for every shape; at large token counts the same 1.0 GB/s
is under 2% and lets noise through. Dispatch happened to be fine because
candidate 1 is genuinely near-optimal for it, but combine wants the
opposite end of the space and was losing ~13% on EP16/MI300X.

Four changes, together aimed at "the saved config should be the best one
*and* should stop churning between re-tunes":

- relative margin (_BW_REL_MARGIN = 2%) instead of absolute GB/s
- score each config by the median over 9 rounds, not the mean over 5, so
  a single stalled round cannot decide a winner
- break ties inside the margin deterministically on block_num rather than
  letting whichever sample landed higher win -- two configs that are
  statistically indistinguishable must not alternate between runs
- require the same 2% margin before an existing rule is overwritten on
  save, so a re-tune that lands higher purely on noise leaves the
  checked-in JSON alone

Rounds per config go 5 -> 9, so a full sweep takes ~1.8x longer.

With this the tuner selects genuinely different geometries for dispatch
and combine, which it structurally could not do before.
dispatch() and combine() rebuilt six torch views over persistent shmem
buffers on every call. The addresses come from _dispatch_out_ptrs /
_combine_out_ptrs, which __init__ already snapshots once from handle-owned
symmetric memory, and the shapes are fixed by the config -- so every
rebuild produced an identical view, yet each one still walked
__cuda_array_interface__ and queried torch.cuda.current_device().

At small token counts that is not bookkeeping, it is latency. On EP16
(2x8 MI300X, hidden 6144, 4 tokens) host-side enqueue costs ~210us per
dispatch+combine round against ~222us of wall time, i.e. the host is what
paces the GPU, and cProfile attributes ~60us of that to this wrapping.
The regime is overhead-bound rather than data-bound: latency is flat from
hidden 1024 to 6144 and flat across 4/8/16 tokens.

Same-session A/B, 5 trials each:

  tokens   combine        wall/round     host enqueue/round
  4        150 -> 88 us   222 -> 164 us  211 -> 146 us
  8        146 -> 86 us   229 -> 162 us
  16       140 -> 93 us   232 -> 184 us

Cross-checked with the DeepEP-style bench (test_low_latency.py at 4
tokens): end-to-end 214 -> 157 us, while its kineto kernel-time metric is
unchanged -- that metric deliberately excludes CPU launch overhead, which
is why this went unnoticed.

Safety notes:

- The cache key includes the pointer, so a future reallocation misses the
  lookup and builds a correct view instead of returning a stale one.
- Entries are bounded by the distinct (shape, dtype) an op sees -- one for
  a fixed model. They alias handle-owned memory and share the op's
  lifetime, so nothing is retained beyond it.
- Callers get the same object rather than an equal one. Both alias the
  same buffer and the kernels overwrite it in place, so the only visible
  change is object identity.
- Stable output objects are also what makes the op capturable into a HIP
  graph; rebuilding views per call puts host logic inside the capture.
- MORI_EP_DISABLE_VIEW_CACHE=1 restores the old behaviour. It is read once
  in __init__ -- probing the environment per call would reintroduce the
  cost this removes.

Verified with --cmd test at 4 and 16 tokens, 500 rounds, 0 errors on all
16 ranks, and HIP graph replay bit-exact against eager on all 16 ranks.
test_low_latency.py had num_tokens / hidden / num_topk / num_experts
hardcoded in test_loop(), so measuring any shape other than the one baked
in meant editing the file. Expose them as arguments, along with the rank
count and the pressure-test loop that was a hardcoded `False`.

Defaults reproduce exactly the shape the test has always run
(128 / 7168 / 8 / 288), so existing invocations are unaffected.

  python examples/ops/dispatch_combine/test_low_latency.py \
    --num-tokens 4 --hidden 6144
Generated with MORI_TUNING_SCOPE=full (75 configs) on a 2x8 MI300X pair
for the small-token regime (4/8/16 tokens, hidden 6144, fp8_e4m3_fnuz
dispatch / bf16 combine, topk 8, num_qp 1).

                 dispatch          combine
  4 tokens       32 / 4 / 21       128 / 4 / 64    (block/warp/rdma_block)
  8 tokens       32 / 8 / 16       32 / 4 / 8
  16 tokens      32 / 8 / 16       32 / 4 / 16

Produced after the tuning-margin fix, so the two phases carry genuinely
different geometries -- previously every shape collapsed onto the first
swept candidate.

Known limitation, worth reading before trusting the 4-token row. The
tuner measures in the same host-bound regime this series is about, and
there its metric cannot separate good geometries: benchmarking these
against 32/4/8 and against 32/8/16 + 64/4/32 at 4 tokens gives 158.9 /
158.9 / 155.6 us, i.e. all three are inside the run-to-run spread. Take
the host out of the critical path and they separate clearly, with 32/4/8
ahead of what the tuner picked here. So these are a reproducible starting
point rather than a proven optimum, and the 4-token entry in particular
should be revisited once tuning can measure without the host in the way.

They only take effect with MORI_EP_LAUNCH_CONFIG_MODE=AUTO, and a
non-editable install reads the copy under site-packages rather than this
one.
The previous commit replaced an unusable absolute 1.0 GB/s threshold with
a 2% relative one. 2% is the right default for keeping a checked-in config
stable, but it is the wrong default for someone tuning to find the fastest
config: an improvement of 1% is discarded, and the margin also blocks
re-tunes from persisting a better result.

Default both margins to 0 -- take any improvement -- and expose them as
MORI_EP_TUNING_MARGIN for the cases that want stability back:

  MORI_EP_TUNING_MARGIN=0.02 <tuning command>

The two margins move together because they answer the same question at
different moments: which config wins inside a sweep, and whether a re-tune
may overwrite the saved rule.

The tradeoff is real and worth stating. Run-to-run spread on this workload
is 10-20%, so with no margin the winner is partly whichever config drew the
luckiest sample, and every re-tune is free to rewrite the JSON. The median
over 9 rounds introduced alongside the relative margin is the primary
defence against that and is unchanged here; the margin was only ever a
second, optional one. Exact ties still keep the earlier (smaller block_num)
candidate, so the sweep remains deterministic when two configs measure
identically.
…igurable

Customer data on a production MI308 rig showed the median-scored, no-margin
tuner (introduced two commits ago) regressing Worst and Average by 30-45%
at bs=16/32, while Best stayed essentially flat and the winning geometry
trended toward larger, more parallel configs (up to block=80 warp=16
rdma=53). That signature is not environment noise -- noise would not spare
Best while inflating Worst for one selection method only. It is what you
get when the scoring statistic cannot see a config's worst case: median
only asks "what does a typical round look like", so a geometry with a fine
median and a heavy tail (more blocks/warps contending for the same SMs and
RDMA queues) can win outright, whereas mean is pulled up hard by a single
bad round and would have penalized exactly that tail.

Revert the default to mean, matching what --cmd bench itself reports and
what main always used. Keep both knobs configurable without editing the
file:

  MORI_EP_TUNING_ROUNDS=<n>          # rounds per candidate, default 9
  MORI_EP_TUNING_STAT=mean|median    # scoring statistic, default mean

A full 75-config sweep at 4/8/16/32 tokens on an idle 2x8 MI300X pair (no
contention, different SKU from the customer's box) did not reproduce the
same magnitude -- mean's and median's picks came out within normal
run-to-run noise of each other there, sometimes in either direction. That
does not contradict the mechanism, it just means an idle, uncontended rig
does not stress the tail the way production load does; treat the fix as
warranted by the mechanism and the customer signature, not as bounded by
this sanity check.

Also regenerates the shipped MI300X EP16 configs with the new mean
default so the checked-in JSON matches what a fresh tune actually
produces now:

               dispatch          combine
  4 tokens     32/4/21           128/4/32
  8 tokens     64/4/32           128/6/64
  16 tokens    64/4/32           256/4/64

Verified with --cmd test at 4 and 16 tokens, 0 errors on all 16 ranks.
… history

Comments-only. The customer incident narrative belongs in the previous
commit's message, not as permanent inline prose everyone re-reads. Verified
the mean-branch computation is untouched and still matches main's formula
exactly: kept.mean(dim=0) -> same column indexing -> same * ll_scale ->
same .min().item(), for both dispatch and combine.
Selection compared a slowest-rank XGMI bandwidth figure
(min-across-ranks of each rank's own mean/median bandwidth) while the
"Average" column the final table -- and every re-run of --cmd bench --
actually shows is a different quantity: the grand mean of raw latency
across all ranks and all rounds. A config could clear the selection
bar on the first metric while looking worse than another candidate on
the second, because the two were never mathematically required to
agree. That gap is what let a customer ask "tuning says this config
won, so why does the printed bench table show it's worse" -- a
reasonable question, because the two numbers were never the same
thing to begin with.

Make the winner literally the config with the lowest
disp_stats["lat"][2] / comb_stats["lat"][2] -- the exact scalar
_build_phase_stats computes and _print_phase_table prints as
"Average". Verified directly: across a full 75-candidate sweep, the
winning config's printed Average (dispatch 49.19us, combine 73.54us)
equals the minimum seen across every candidate in the sweep (49.2us /
73.5us at the sweep's own print precision) -- selection and the
printed number are now the same value by construction, not just
usually close.

Consequences of tying selection to this specific quantity:

- Retires MORI_EP_TUNING_STAT (mean vs median). _build_phase_stats
  always reports a grand mean, never a median, so a "median" selection
  mode could no longer mean anything coherent once selection has to
  match what gets printed -- keeping the knob around would have meant
  it silently did nothing for this decision.
- _BW_REL_MARGIN is renamed _TUNING_MARGIN and now gates a
  lower-is-better latency comparison instead of a higher-is-better
  bandwidth one. MORI_EP_TUNING_MARGIN keeps its name and default (0)
  as a relative margin; only the quantity it's a margin *of* changed.
- The saved JSON's top-level bandwidth_gbps no longer comes from the
  selection variable (which is now a latency, not a bandwidth); it's
  computed post-hoc from the winning config's own already-computed
  stats (_headline_bw), so the field keeps meaning what it always
  meant -- LL bandwidth for LL/AsyncLL kernels, RDMA bandwidth
  otherwise -- decoupled from whatever criterion picked the winner.

Verified with --cmd test at 4 and 16 tokens, 0 errors on all 16 ranks.
…uner

The previous commit changes what the tuner selects (grand-mean latency,
matching the printed Average, instead of a slowest-rank bandwidth figure
that could diverge from it). The checked-in configs were tuned under the
old selection metric, so they no longer reflect what this branch's own
tuner would pick if run today -- regenerate them so the shipped JSON is
consistent with the code that ships alongside it.

               dispatch          combine
  4 tokens     64/4/42           64/4/32     (block/warp/rdma_block)
  8 tokens     64/4/42           32/4/21
  16 tokens    64/4/42           32/4/21

Verified with --cmd test at 4 and 16 tokens through the real
MORI_EP_LAUNCH_CONFIG_MODE=AUTO load path, 0 errors on all 16 ranks.
9 was a leftover from an abandoned median-scoring experiment (which wanted
extra samples to resist a single stalled round); that experiment was
reverted back to mean scoring, but the rounds default was never reverted
with it, silently paying a ~1.8x longer sweep for a defense mean scoring
doesn't need. Main has always used repeat=5.

Verified on an idle 2x8 MI300X pair: a full 75-candidate sweep at 4 tokens
with the new default finished in 12s and picked the same dispatch geometry
(48.2 -> 47.6us, within noise) and an equally good combine geometry (69.0 ->
69.4us, within noise) as the previous 9-round default.
… add 32-token

Full 75-candidate sweeps on an idle 2x8 MI300X pair (per-phase, per-token-count),
now running under the 5-round default instead of 9:

               dispatch                  combine
  4 tokens     32/4/21   -> 48.04us      64/4/32  -> 69.04us (unchanged)
  8 tokens     64/4/42   -> 48.10us (unchanged)   64/4/32  -> 69.11us
  16 tokens    64/4/42   -> 49.10us      32/4/21  -> 69.16us (unchanged)
  32 tokens    128/4/64  -> 52.44us (new)         64/4/32  -> 74.80us (new)

Every changed number is within this workload's documented run-to-run noise of
its 9-round predecessor; several entries didn't change at all because the
sweep re-picked the exact same geometry. Verified with --cmd test at 4/16/32
tokens, 0 errors across all 16 ranks.
… rounds/margin guards)

Four issues from review, all confirmed live:

- _headline_bw saved the grand-mean bandwidth (stats["ll"/"rdma"][2], the
  same number this file prints as "Average") as the JSON's bandwidth_gbps.
  save_tuning_result gates overwriting an existing rule on new_bw > old_bw,
  and every rule on disk -- including ones this PR never touches, e.g. other
  GPU models/kernel types -- was written under main's original definition,
  slowest-rank mean bandwidth. Grand mean is systematically >= slowest-rank
  mean, so the first re-tune with this code would silently overwrite a
  still-good rule with zero real improvement. Restored the slowest-rank
  semantics via two new _build_phase_stats fields (rdma_worst_rank,
  ll_worst_rank) so bandwidth_gbps means what it always meant; avg_latency_us
  and the avg_*_bandwidth_gbps fields (already grand-mean, and what _beats
  actually selects on) are untouched.

- _beats' "ties break on smaller block_num" branch was dead: the sweep visits
  block_num in ascending order, so a later candidate's block_num is never
  smaller than the incumbent's once one has won. It looked like a deliberate
  tie-break but was actually just first-found-wins via iteration order, which
  silently stops being deterministic if the sweep is ever reordered/
  parallelized. Switched to comparing the full (block_num, warp_per_block,
  rdma_block_num) tuple, which is a real tie-break independent of visitation
  order.

- MORI_EP_TUNING_ROUNDS had no lower bound. tuning_dispatch_combine drops
  round 0 as in-loop warmup (kept = all_data[1:]), so ROUNDS=1 leaves an
  empty tensor and _compute_stats' .min()/.max()/.mean() crash with a shape
  error that gives no hint the real cause is too few rounds. Now rejected
  at import time with a clear message.

- MORI_EP_TUNING_MARGIN="" (set but empty, not unset) reached float("") and
  crashed at import in both this file and tuning_config.py, since
  os.environ.get(k, default) only substitutes default when the key is
  absent. Both now fall back through `or "0.0"` so empty is treated the same
  as unset.

Regenerated the shipped MI300X EP16 JSON (dispatch+combine, 4/8/16/32
tokens) since bandwidth_gbps values shift under the corrected semantics;
avg_latency_us per entry is within noise of the prior commit's numbers.
Verified with --cmd test at all four token counts, 0 errors across all 16
ranks.
…gbps semantics

Companion to the previous commit's _headline_bw fix -- bandwidth_gbps now
reports slowest-rank mean bandwidth instead of the grand mean, so every
entry's number drops even though nothing about the winning geometry or its
actual latency changed. Regenerated fresh (not via a re-tune diff, which
would have been blocked from correcting the stale entries by the very bug
being fixed) on an idle 2x8 MI300X pair; avg_latency_us matches the prior
commit within noise. Verified with --cmd test at 4/8/16/32 tokens, 0 errors
across all 16 ranks.
Debug aid, no effect on selection or saved results: when set, prints every
candidate's full PrettyTable (the same _print_phase_table bench uses)
instead of just the one-line "disp sel=... comb sel=..." summary. Lets a
candidate's raw Best/Worst/Average be diffed directly against a --cmd bench
run of that same block/warp/rdma, which is what's needed to pin down why
tuning's self-reported latency for a config doesn't match bench's for the
same config.
The per-candidate progress header printed "block_num=X, warp=Y,
rdma_block_num=Z" while every table title (both the final Tuning Result and
the new MORI_EP_TUNING_VERBOSE per-candidate tables) prints "block=X warp=Y
rdma=Z" -- same fields, different names/punctuation, made grepping a
specific config across a sweep's output inconsistent. No behavior change.
The sweep's block_num candidates started at 32 (32, 64, 128, ... up to
sm_count) with no lower-range coverage. Measured directly on MI300X EP16
v1_ll:
  - --cmd test at block=2/4/6/8/16 (tok=4): all correct, 0 errors -- the
    kernel has no hard minimum block count, this was purely an unexplored
    region of the sweep.
  - --cmd bench across tok=4/8/16/32: block=2/4/6/8 lose everywhere (e.g.
    93.94us dispatch at block=2 vs ~48us for the eventual winner, at 4
    tokens). block=16 ties the winner at 4 tokens but is clearly worse from
    8 tokens up (56.29 vs 49.01us dispatch at 8 tokens).

Added only 8 and 16 (not 2/4/6), generically via sm_count rather than
hardcoded per architecture, so a re-tune on different hardware (e.g.
MI308's 80 CUs) empirically finds its own answer instead of assuming this
MI300X result transfers. A fresh full sweep with the wider candidate list
already found a new best for 4-token dispatch: block=16/warp=4/rdma=10 at
47.64us average, edging out the previous 64/4/42 at 48.46us. Verified: 0
crashes across a 105-candidate sweep (up from 75).
Re-tuned with the widened block_num candidate list (8/16 added). Adopted
only where an independent --cmd bench re-check confirmed the new pick
holds up:

  - 4 tokens: block 64->16 (dispatch), 128->16 (combine). Latency is a wash
    within noise (dispatch 47.99 vs 48.14us, combine 71.02 vs 70.93us on
    direct re-check) but block=16 uses a quarter to an eighth of the CUs
    for the same performance -- strictly better use of resources on a
    workload this small, with headroom to spare if the GPU is shared with
    other concurrent kernels.
  - 32 tokens: rdma_block_num 85->64 (dispatch), same geometry with
    refreshed numbers (combine). Not a real change, just a closer number
    from a repeat sweep.

8 and 16 tokens are deliberately left untouched: the widened sweep's own
picks for both (dispatch 16->51.69us at 16 tokens vs the existing 49.27us;
combine 32->76.75us at 16 tokens vs the existing 70.05us) turned out to be
a single noisy sample winning under 0-margin selection, not a real
improvement -- confirmed by re-benching both the old and new picks
independently and finding the existing config still faster. Re-tuning the
same hardware isn't guaranteed to reproduce or improve on a previous
result when the search space grows; each candidate here was verified
against the currently shipped config before being adopted, not taken from
the sweep's own printout.

Verified with --cmd test at 4/8/16/32 tokens, 0 errors across all 16 ranks.
…sion

Two bugs in bench's per-round debug print (--cmd bench, before the
Best/Worst/Average table):

- The print loop was phase-outer, round-inner: all dispatch rounds printed
  first (each correctly under its own "Round i"), then all combine rounds
  printed with no "Round i" header at all, since the header was gated on
  `cols is _labels[0][1]` (only true for dispatch). Combine's round 0 data
  ended up visually attached to dispatch's last round in the output, with
  no way to tell which combine line belonged to which round. Restructured
  to round-outer, phase-inner so one "Round i" header covers both phases'
  lines for that round, matching what the output already looked like it
  meant.
- The raw per-rank list used `.int().tolist()`, truncating every value to
  an integer (bandwidth/duration in the 1-10 range loses almost all its
  precision this way) even though the "avg" on the same line already kept
  2 decimals. Switched to round(v, 2) so the raw list and its average are
  consistent.
--cmd bench (repeat=10, hardcoded) and --cmd tuning (repeat=_TUNING_ROUNDS,
MORI_EP_TUNING_ROUNDS, default 5) measured on different round counts, which
was one of the two confirmed structural differences behind tuning's
self-reported latency for a config not matching an independent bench run
of that same config (the other being GPU DVFS/clock-ramp state, which is
an environment property, not something this change addresses).

Renamed to MORI_EP_ROUNDS, shared by both call sites, default 10 (bench's
old value). Tuning's default goes from 5 back to 10, doubling sweep time
again, but now the two are measured on equal footing by construction
instead of by coincidence. Verified MORI_EP_ROUNDS=1 still raises the
expected "must be >= 2" error (single-process check, no GPU needed --
the validation fires at import time before any distributed setup).
Mlx5QpContainer::CreateQueuePair allocates a UAR with
devx_alloc_uar(MLX5DV_UAR_ALLOC_TYPE_NC) and then hipHostRegister()s it for
every queue pair. rdma-core does not hand out a fresh UAR for that flag:

    // providers/mlx5/verbs.c, _mlx5dv_devx_alloc_uar()
    if (flags & MLX5_IB_UAPI_UAR_ALLOC_TYPE_NC)
            return mlx5_get_singleton_nc_uar(context);

It returns the ibv_context's singleton, i.e. the identical reg_addr on every
call (rdma-core e50a2af03f, v33+, so every modern distro). The second QP's
registration therefore fails with hipErrorHostMemoryAlreadyRegistered and
HIP_RUNTIME_CHECK exits the process.

The teardown path already assumed the sharing -- "Multiple qp may share the
same uar address, only unregister once" -- and guarded itself by probing the
pointer. Only the register side was missing a guard, and bnxt has had one all
along (BnxtDeviceContext::TryRegisterUar). Port it: Mlx5DeviceContext owns the
registered-UAR set, CreateQueuePair registers only on first use, and teardown
unregisters through the same bookkeeping instead of hipPointerGetAttributes --
that probe returns an error once the first QP has unregistered, which would
have exited(-1) during destruction.

Two ordering details the bookkeeping forces, both of which bnxt also gets right:
the set and its mutex are declared BEFORE the pools, because members die in
reverse declaration order and ~Mlx5QpContainer calls back into
TryUnregisterUar; and ~Mlx5DeviceContext clears the pools explicitly rather
than leaving it to implicit member destruction. Getting either wrong makes the
unregister lock a destroyed mutex and mutate a destroyed std::set, which shows
up as glibc heap corruption at shmem_finalize -- once per rank, long after the
last measurement, with no other symptom.

Found bringing EP16 up on 2x8 MI300X with ConnectX-7: every rank died at QP 2
of 32 with

    [.../providers/mlx5/mlx5.cpp:294] hip failed with part or all of the
    requested memory range is already mapped

With this, EP16 dispatch/combine runs clean on that cluster -- no abort, no
malloc_consolidate diagnostics, at 4 and 64 tokens per rank.
The block ladder starts at 32 and doubles, which never asks whether a
few-token shape wants fewer. At 4 tokens the LL send path has exactly one
warp with work to do, so 32 blocks is a guess, not a floor.
MORI_TUNING_BLOCKS / MORI_TUNING_WARPS make the sweep reachable from a
script without editing it.
generate_profiler_bindings.py groups slots by source-file stem, so the three
Dispatch* slots used by intranode_ll.hpp land in IntranodeLlSlot -- but the
file asks for INTRANODE_PROFILER_INIT_CONTEXT, which binds IntranodeSlot.
Any ENABLE_PROFILER=1 build therefore fails to compile:

  intranode_ll.hpp:267:30: error: no member named DispatchSendTokens in
  mori::profiler::dispatch_combine::IntranodeSlot

intranode_1250x.hpp has the same mismatch. It happens to compile because its
slot names coincide with intranode.hpp, but the ids it emits then come from
the wrong enum and the trace decodes to the wrong names.
DispatchIntraNode partitioned work by block over tokens:

    tokenPerBlock = ceil(curRankNumToken / xgmiBlockNum)

so whenever curRankNumToken < xgmiBlockNum most blocks got an empty range and
did nothing. At 4 tokens per rank with 16 XGMI blocks, 12 blocks idled and 16
warps carried all 32 token x expert copies. Measured with the kernel profiler
on 2x8 MI355X (EP16, hidden 7168 bf16, topk 8, 32/16/4): warps 64-79 spent
15-32 us in dispatch_intra while warps 80+ measured 0.6 us.

Stride the same work items flatly across every XGMI warp in the grid instead.
The set of (tokenId, expertOffset, inTokenExpertId) triples is unchanged --
the old block-local index i maps to startTokenIdx * K + i, which is exactly
the flat index -- so routing, dedup and replay are untouched. dispatch_intra
drops to 14 us.

Also unroll the intra-node hidden-payload peer write by 4, matching what the
inter-node recv path at :457/:567 already does.

This does NOT reduce end-to-end 4-token dispatch latency: at that size the
intra-node path finishes at ~19 us, which is when the cross-node data arrives,
so it is not on the critical path. Over 4 runs each at 32/16/4 the change is
inside the noise (orig 49.59 us sd 1.2, new 49.65 us sd 2.9). It is kept as a
starvation fix, not a latency win. No regression at 64/1024/4096 tokens.
DispatchInterNodeLLSend's routing scan read one topk index per loop iteration and
fed it straight into two dependent integer divides and a conditional store.
numExpertPerToken is a runtime value, so the compiler cannot unroll that loop, and
this is the one warp on the critical path to the doorbell -- at 4 tokens per rank
block 0 warp 1 is the only sender, and there is no other resident warp to hide the
load latency behind.

Issue all MAX_EXPERTS_PER_TOKEN loads into registers first, then consume them, and
collapse the two divides into one against a loop-invariant numExpertPerRank *
gpuPerNode. (x/a/b == x/(a*b) for non-negative x, and a negative sentinel expert
truncates to 0 under both forms, so routing is unchanged.)

Measured with the kernel profiler on 2x8 MI355X, EP16, 4 tokens/rank, hidden 7168
bf16, 32/16/4: the sending warp's scan span drops 3.10 us -> 2.28 us, moving the
doorbell earlier by the same amount.

This is a span-level result, not an end-to-end one. The dispatch ends in an 8-GPU
rendezvous that waits for every local rank, and it absorbs roughly 85% of any
purely local saving -- so ~0.8 us here is worth ~0.1 us of the ~48 us dispatch,
which is far below the +-3 us run-to-run spread at this size. Taken because it is
strictly less work on the critical path with no tradeoff, not because the
end-to-end number moved.

Also measured and rejected in the same pass: raising the receive-side hidden
WarpCopy from Unroll=4 to 8. It is slower (LLRecvCopy 8.34 -> 10.4 us) because
14336 B splits as 1x8192 + 6x1024 = 7 steps at Unroll=8 against 3x4096 + 2x1024 =
5 steps at Unroll=4, on top of 32 more VGPRs held live.
CombineSync is launched with one block per CU (304 on MI300X) but partitions
by token: tokenPerBlock = ceil(totalRecvTokenNum / blockNum). At 4 tokens/rank
EP16 a rank receives ~28 tokens, so tokenPerBlock is 1, only warp 0 of the
first 28 blocks enters the loop, and 28 warps out of 2432 carry every 14 KB
copy. Partition by (token, hidden slice) across the grid instead, which is
what DispatchIntraNode already had to be changed to do.
DispatchInterNodeLLSend distributes destination nodes as
`for (i = warpId; i < nNodes; i += warpNum)`, so with warpNum >= nNodes only
the first nNodes warps of a block ever enter it -- but every warp then had to
arrive at interNodeBlocksBarrier[1] before the last one could send the token
count to the proxy. At 2 nodes and 8 warps/block that is 6 useless arrivals
in 8 on a contended atomic, and it is the most likely reason the geometry
sweep keeps preferring warp_per_block=2 for dispatch at every token count.

Count min(warpNum, nNodes) warps per block instead and let the rest go
straight on to the resolve pass.
At 4 tokens/rank the relay has 32 (token, expert) items and the tuned
geometry gives it 84 RDMA warps, so one warp per item leaves most of them
idle while the busy ones each push a full hidden vector across the XGMI peer
aperture -- p90 8.2us at hidden 6144, and it lands on the critical path of
whichever rank arrives last.

Give each block a contiguous run of items instead of grid-striding them,
resolve one per warp into shared memory, __syncthreads(), then let every warp
of the block take a slice of every item. The sync is a block barrier, not a
grid barrier: an earlier version of this idea synchronised the whole grid and
cost more than the copy it parallelised.

Only taken when a block owns at most kMaxBlockRelayItems items, so large
batches keep the grid-strided path that pipelines chunk arrivals.
…end barrier

Block b of the LL send path owns tokens [blockChunkNum*b*warpSize, ...), so
every block past ceil(curRankNumToken / (blockChunkNum*warpSize)) has an empty
range and posts nothing. At one chunk per block -- every shape up to warpSize
tokens per rank, which is the whole low-latency regime -- that is exactly one
block, yet all rdmaBlockNum of them were counted. With the tuned geometry that
is ~84 arrivals on one contended address sitting directly in front of the
token-count signal the receiving node is waiting for.
warpsPerItem was ceil(globalWarpNum / numItems) with no floor on the slice, so
at low token counts the split is decided by how wide the grid happens to be
rather than by the work. EpCombineAll runs 608 warps over 4 tokens and handed
each warp 40 elements of a 6144-wide vector; EpDispatchCopyToStaging handed out
24 bytes.

The size to stop at was measured on the LL combine relay, which splits the same
way: 8 warps per token beat 4 and beat 16, so 512 elements is a floor with
margin on the side that was shown to help.
get_registered_combine_input_buffer() hands the caller combineInp so it can
write expert outputs straight in, and IntraNode combine already honours
useExternalInpBuffer that way. InterNodeV1/V1LL did not: CombineSync staged
inpTokenBuf over the top unconditionally, which both wasted the copy and
overwrote whatever the caller had put there -- so the accessor was unusable on
the internode path.

Gate the staging loop on the flag, as XG-zheng does on zxg/low_latency_ep, and
give the bench MORI_ZERO_COPY_COMBINE=1 to exercise it.
The branch tunes fp8 dispatch and bf16 combine at hidden 6144, which is the
serving shape. These fill in what is missing next to it: bf16 dispatch (6144
at 4 tokens, 7168 at 4-64) and bf16 combine at 7168. Where both have a rule --
bf16 combine at 6144 -- the branch keeps its own.

Swept and then A/B-verified against the incumbent, which matters here: a
straight sweep pick lost to the shipped default twice on this box, once by
2.3us at 64 tokens and once by 3.9us at 4.
The rule on this branch is 16/10/4. Measured against 64/42/2 on p19 (2x8
MI300X + CX7, EP16, interleaved A/B): combine 71.1 -> 62.2us, 5/5 pairs with
no overlap, dispatch unchanged.

Not a criticism of the sweep that produced 16/10/4 -- the optimum moved
underneath it. Widening the relay split (4 -> 8 warps per token) and putting a
floor under MultiWarpIter both change how much work a warp is worth, and both
land in this same combine path.
… clamp down

TuningConfigManager picks the tightest ceiling match and clamps to the largest
recorded rule when num_tokens runs past it. Adding a lone bf16/6144 4-token
dispatch rule therefore captured every larger count as well: measured at 64
tokens on p19, dispatch went 180 -> 202us against the branch with no bf16 rule
at all, because 64 tokens was being run at the 4-token geometry.

Fill 8/16/32/64 with the geometries already A/B-validated at hidden 7168, which
is the nearest measured shape. A lone low-token rule is a trap in this table
format -- it is not additive, it shadows everything above it.
fp8 dispatch + bf16 combine at hidden 6144 is the shape this branch targets,
so this is the rule that matters most. Measured on p19 (2x8 MI300X + CX7,
EP16, interleaved A/B): dispatch 44.7 -> 41.9us, 4/4 pairs with no overlap.

Same geometry the bf16 rule at this shape uses. The kernel changes in this PR
move the optimum for both dtypes the same way, since the relay split and the
MultiWarpIter floor are dtype-agnostic.
Base automatically changed from perf/ep-small-token-host-overhead to main August 30, 2026 13:30
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.

2 participants