Skip to content

[HIP] [Feat] Single-kernel Lamport fused all-reduce + RMSNorm (opt-in) - #4977

Draft
EricKing626 wants to merge 7 commits into
ROCm:mainfrom
EricKing626:lamport-ar-rmsnorm
Draft

[HIP] [Feat] Single-kernel Lamport fused all-reduce + RMSNorm (opt-in)#4977
EricKing626 wants to merge 7 commits into
ROCm:mainfrom
EricKing626:lamport-ar-rmsnorm

Conversation

@EricKing626

Copy link
Copy Markdown

Motivation

custom_fused_ar_rms runs as two kernels — two-shot all-reduce, then RMSNorm — separated by an
end_sync grid barrier. On ROCm there is no Programmatic Dependent Launch, so the two strictly
serialise.

On GLM-5.2 FP4 decode (isl 8192 / osl 1024 / conc 64, TP4) this pair is the largest single
contributor to our remaining MI355X latency gap. Removing the serialisation means collapsing the
two kernels into one, which means removing the barrier between them.

Approach

Lamport wait-free signalling: the data is the flag.

  • The buffer is pre-armed with a sentinel (-0.0).
  • Producers canonicalise -0.0 -> +0.0, so a written value can never be the sentinel.
  • A pack counts as arrived once no word in it equals the sentinel.

end_sync is no longer needed, so reduce and norm become one kernel — one launch, no gap.

Two supporting details worth reviewing:

  1. start_sync_acqrel — RELEASE/ACQUIRE variant of the existing RELAXED start_sync, so the
    re-arming sentinel stores of iteration N cannot be reordered past a peer's producer stores of
    iteration N+1.
  2. Occupancy-clamped grid — the kernel is persistent-style; a block spins on data produced by
    other blocks of the same grid. The grid is clamped to the co-resident block count via
    hipOccupancyMaxActiveBlocksPerMultiprocessor. Without the clamp, unscheduled producer blocks
    can never run behind spinning consumers and the kernel deadlocks.

Changes

Confined to csrc/include/custom_all_reduce.cuh. Adds a Lamport sentinel/arm/spin helper set,
start_sync_acqrel, and the fused kernel plus its dispatch. Nothing existing is modified.

Enabling

Off by default. Opt in with AITER_AR_RMSNORM_LAMPORT=1.

The fast path also requires: n % pack_size == 0, packs_per_row >= 64, 1 <= n_loop <= 4,
(m * packs_per_row) % world_size == 0, and world_size in {2, 4, 8}. Any failure falls through
to the existing 1-stage / 2-stage paths unchanged.

Testing status

Not yet compiled or run on hardware. Opened early for design review of the Lamport protocol
and the occupancy clamp. Correctness and performance numbers will be posted before merge.

Planned: TP=4/8, n=7168, m in {1, 64, 256}, elementwise vs AITER_AR_RMSNORM_LAMPORT=0;
several thousand back-to-back iterations for the re-arming race; end-to-end GLM-5.2 decode A/B.

Known gaps

  • Graph capture records the lazy lamport_ensure_armed call; needs a warm-up before capture.
  • tnum fixed at 512, not swept.
  • Only local_device_load_rmsnorm semantics ported; _naive, _512n and quantised variants keep
    the two-kernel path.
  • No unit test yet.

…STED)

Motivation
----------
On MI355X the fused AR+RMSNorm path runs as two kernels on one stream:

  reduce_scatter_cross_device_store   (start_sync ... end_sync)
  local_device_load_rmsnorm

Because ROCm has no PDL (Programmatic Dependent Launch) equivalent, two
kernels on the same stream serialise strictly.  B200 hides ~27% of this
pair by overlapping rmsNormLamport under twoshotAllreduceKernel via PDL;
we cannot.  The only way to recover that time on ROCm is to make it one
kernel -- which requires removing the end_sync barrier between the two
phases, since a device-wide barrier inside a single kernel is what the
grid-level launch boundary used to provide.

Approach
--------
Lamport-style wait-free signalling: the data *is* the flag.  The IPC
staging buffer is pre-filled with a sentinel (-0.0, i.e. 0x8000 per bf16
lane / 0x80000000 per fp32).  Producers canonicalise -0.0 -> +0.0 before
storing, so the sentinel is unambiguous.  A consumer polls its 16-byte
pack and treats it as arrived only when *no* word still equals the
sentinel.  There is no separate flag, therefore no flag-vs-data
visibility ordering problem and no end_sync.

New in this commit (all in csrc/include/custom_all_reduce.cuh):

  LamportSentinel<T>                  sentinel word per dtype
  lamport_canonicalize<T,pack>        producer-side -0.0 -> +0.0
  lamport_spin_load<T,pack>           volatile poll + s_sleep backoff
  lamport_arm<T,pack>                 re-arm the pack after consuming
  start_sync_acqrel<ngpus>            RELEASE/ACQUIRE variant of start_sync
  lamport_prefill_sentinel<T>         first-use arming kernel
  fused_allreduce_rmsnorm_lamport<..> the fused kernel
  ar_rmsnorm_lamport_enabled()        env gate
  lamport_ensure_armed<T>             host-side lazy arming (mutex + map)

Phase A of the fused kernel is reduce_scatter_cross_device_store's body
verbatim (same warp_id / lane_id / part indexing) plus canonicalisation
and minus end_sync.  Phase B is local_device_load_rmsnorm's body with
lamport_spin_load replacing the plain load and lamport_arm immediately
after; each pack is consumed by exactly one thread, so re-arming needs
no extra synchronisation.

Correctness notes
-----------------
* start_sync_acqrel is required, not cosmetic: the stock start_sync uses
  RELAXED atomics, which would let the *previous* iteration's re-arming
  sentinel stores land after a peer's fresh producer stores and clobber
  them.
* No deadlock within a rank: every block finishes all of its producer
  work -- which depends on no one -- before it ever spins.
* The kernel is persistent-style, so the grid is clamped to the
  co-resident block count via hipOccupancyMaxActiveBlocksPerMultiprocessor
  for the exact instantiation.  An over-large grid would leave producer
  blocks unscheduled behind spinning consumers.
* Phase B adds a trailing __syncthreads() per token iteration that the
  original did not have.  This is required because the LDS is aliased
  between phase A (T* tmp_smem) and phase B (float* smem); it also closes
  a pre-existing latent race on smem[0] across bid iterations.

Status
------
DEFAULT OFF.  Nothing changes unless AITER_AR_RMSNORM_LAMPORT=1.

*** THIS CODE IS UNCOMPILED AND UNTESTED. ***  It was written without
access to a build environment or a GPU.  Do not enable it outside of
bring-up.

Known gaps / follow-ups
-----------------------
* CUDA-graph capture: lamport_ensure_armed launches a kernel lazily on
  first use and on any growth in m.  If that first use happens during
  graph capture the arming kernel gets captured (or rejected) rather than
  executed once.  Needs an explicit warmup/arming call before capture.
* tnum is fixed at 512; no 256-thread variant, so the 256-thread tuning
  the two-kernel path can pick is unavailable here.
* Only the local_device_load_rmsnorm semantics are ported.  The _naive,
  _512n and the quantising variants still take the two-kernel path.
* No unit test.  Suggested bring-up: TP=4/8, n=7168, m in {1,64,256},
  compare against AITER_AR_RMSNORM_LAMPORT=0 elementwise, then loop it a
  few thousand iterations to shake out the re-arming race.

Cheaper thing to try first
--------------------------
allreduce_fusion_kernel_1stage is already a single kernel with only
start_sync, and is disabled for our shapes purely by a byte threshold
(total_bytes <= 128*7168*2/world_size).  A/B AITER_AR_1STAGE=1 vs 0 on
the target shape before investing in this path -- if 1-stage wins, most
of the gap is free and Lamport only matters above the crossover.
@EricKing626
EricKing626 requested a review from a team August 25, 2026 03:16
@github-actions

Copy link
Copy Markdown
Contributor

🏷️ CI Guide

Runs automatically on every PR:

  • ✅ Pre-checks (submodule verification, code formatting)
  • ✅ Aiter op tests (gfx942 + gfx950)
  • ✅ Triton tests on MI35X (only when aiter/ops/triton/** or related paths are changed)

Extended tests (opt-in via labels):

Label Tests
ci:gfx1250-ffm-triton Run the five-shard gfx1250 FFM Triton test suite
ci:triton-300x Run an additional Triton test job on MI300X in PRs; main branch always runs both MI35X and MI300X
ci:sglang SGLang integration tests: DeepSeek-R1-MXFP4 accuracy, Qwen 3.5 accuracy
ci:atom ATOM benchmark: DeepSeek-R1-0528, GPT-OSS-120B
ci:atom_full ATOM accuracy suite for PR and main models from ATOM models_accuracy.json
ci:vllm vLLM benchmark: GPT-OSS-120B, DeepSeek-R1-0528, Kimi-K2.5
ci:all All standard extended tests (excludes ci:atom_full)

Only add ci:atom_full for FlyDSL or Triton upgrades.
Add labels via the sidebar or gh pr edit 4977 --add-label <label>

PR title tags:
Component tags ([Triton/Gluon], [HIP], [CK], [ASM], ...) are added to the PR title automatically from the changed files and re-synced on every push — change-type tags like [fix]/[Perf] and op tags like [MLA] are left untouched. Add the no-auto-title label to opt this PR out of title tagging.

@github-actions github-actions Bot changed the title [Feat] Single-kernel Lamport fused all-reduce + RMSNorm (opt-in) [HIP] [Feat] Single-kernel Lamport fused all-reduce + RMSNorm (opt-in) Aug 25, 2026
EricKing626 and others added 6 commits August 25, 2026 14:17
Bring-up on MI355X (TP=4, n=7168, bf16) hit two deadlocks. Both are fixed
here; with them the kernel is numerically correct across
m in {1,8,16,32,64,128,256}, eager and CUDA graph, vs F.rms_norm at
atol=rtol=1e-2.

1. Grid exceeded the signal-slot limit.

   Signal::start/end/_flag are sized [kMaxBlocks = 80] and start_sync_acqrel
   indexes them by blockIdx.x. The grid was sized min(m, num_cu * 2), which is
   512 on MI355X, so any m > 80 wrote past the signal region and the barrier
   never completed. m=1 and m=64 passed; m=256 hung with three GPUs pinned at
   100% and one idle. Clamped to kMaxBlocks. Both phases are grid-stride
   loops, so the smaller grid only costs throughput.

2. Sentinel arming was captured into the graph.

   lamport_ensure_armed launched the prefill on the caller's stream. Under
   capture that becomes a graph node, so every replay re-armed the whole
   staging buffer, overwriting producer stores the peers had already landed
   and leaving the consumers spinning. Intermittent; a profiled replay loop
   reproduced it 3/3 with the prefill captured and 0/3 when the buffer was
   armed before capture. Now detects capture and runs the prefill on a private
   non-capturing stream, keeping it out of the graph. Steady state stays armed
   because phase B re-arms every pack it consumes. No sync is issued: it is
   illegal under global capture mode, and capture is always followed by a
   device sync before the first replay.

Performance note (CUDA graph, TP=4, n=7168, bf16, us/iter): Lamport loses at
every comparable shape - m=1 12.1 vs 6.7, m=8 12.4 vs 7.4, m=16 13.3 vs 9.3,
m=32 15.6 vs 13.2. Eager m=256 is 75.5 vs 48.9. Cause: phase B is one block
per token so the grid is min(m, 80) and phase A inherits it; at small m the
reduce-scatter runs on a single block while the two-kernel path launches the
producer with up to 80 blocks and sizes the consumer from occupancy. Removing
one launch and one device-wide barrier is worth a few us at most, less than
the lost parallelism. Decoupling the two phase grids is the prerequisite for
this path to be competitive. Kept opt-in and default-off.

Unrelated: the stock fused AR+RMSNorm path hangs at (64, 7168) TP=4 bf16
under CUDA graph. Reproduces with AITER_AR_RMSNORM_LAMPORT=0 and with the
pre-patch .so, so it predates this branch. To be filed separately.
Phase A's grid was tied to m, so a single-token decode ran the whole
reduce-scatter on one block. Decoupling it exposed a latent hazard and,
once measured, a different bottleneck than expected.

Block-uniform phase A trip count. The loop body contains __syncthreads()
but the bound was per-thread (`idx < part`). With the grid pinned to m
the waves happened to agree; with the grid sized independently they do
not, and the tail block's waves execute different numbers of barriers.
This was already latent before this commit and would have fired at m=2.
The trip count now comes from the block's base index and out-of-range
lanes are masked off. All ngpus warp groups share lane_id, so they agree
on both `idx` and `active`, which is what warp 0's cross-rank reduction
assumes.

Grid policy. A sweep over pinned grids (AITER_AR_RMSNORM_LAMPORT_BLOCKS,
added here as a tuning knob) shows the limiter is not phase A width but
start_sync_acqrel, which costs ~0.1us per block because every block
signals every peer. At (1, 7168) the cost climbs 11.7 -> 18.3us going
from 16 to 80 blocks with no work added. The optimum is one block per
token up to a crossover near 64, with a small floor so tiny m does not
serialise onto a single CU.

MI355X, TP=4, bf16, n=7168, CUDA graph, us/iter:

  m     grid old -> new   old      new      delta
    1     1 ->  4         12.10    11.26    -6.9%
    8     8 ->  8         12.40    12.46    +0.5%
   16    16 -> 16         13.27    13.24    -0.2%
   32    32 -> 32         15.57    15.63    +0.4%
   64    64 -> 64             -    23.32
  128    80 -> 64         39.90    38.10    -5.6%
  256    80 -> 64         68.50    66.62    -2.5%

Correctness passes for m in {1,8,16,32,64,128,256} in both eager and
graph mode. The fused path is still slower than the two-kernel baseline
(+16% at m=32, +68% at m=1); grid tuning alone does not close that, and
the remaining gap is the barrier and the sentinel re-arm write traffic.

Co-authored-by: Cursor <cursoragent@cursor.com>
Chasing the grid-scaling cost from the previous commit found the wrong
culprit. It is not start_sync_acqrel: replacing it with a variant where
only block 0 does the cross-rank handshake and the rest wait on a local
gate left the curve unchanged (at (1, 7168) still 11.2 -> 20.2us from 4
to 80 blocks) while adding ~1.5us of serialisation, so that variant is
not kept.

It is the __threadfence_system() that published phase A's stores. Every
block issues one and they all serialise on the same L2 writeback, at
~0.09us per block. Dropping it flattens (1, 7168) to 10.6 -> 11.6us
across the same grid range -- on a shape where 78 of those 80 blocks
publish nothing at all. Scope is not the issue: __threadfence() costs
the same, so it is the fence itself, not what it makes visible.

The fence was vestigial, inherited from the two-kernel producer where an
end_sync flag followed it. An LL/Lamport protocol needs no publish
fence: the data is its own flag, so there is no separate flag store to
order the payload before, and word order within a pack does not matter
either because the producer canonicalises the sentinel away and a
consumer seeing any word still holding it just keeps spinning. Same as
ll_store_b128 in custom_all_reduce_gfx1250.cuh and the QuickReduce send
path, neither of which fences. Peer stores are now non-temporal, the
quarter that lands in this rank's own buffer stays cached because phase
B reads it back (non-temporal there cost 4% at m=256).

With the grid nearly free (~0.01us per block, all barrier) the phase A
decoupling finally pays: size the grid for the reduce-scatter rather
than for m, floored so a small decode does not serialise onto a couple
of CUs.

MI355X, TP=4, bf16, n=7168, CUDA graph, us/iter:

  m     grid   before   after    vs before   baseline
    1     16    11.26   10.31       -8.4%       6.70
    8     16    12.46   11.40       -8.5%       9.39
   16     28    13.24   12.22       -7.7%       9.37
   32     56    15.63   14.50       -7.2%      13.46
   64     80    23.32   19.40      -16.8%      hangs
  128     80    38.10   32.73      -14.1%      hangs
  256     80    66.62   60.39       -9.4%      hangs

Correctness passes for m in {1,8,16,32,64,128,256} in eager and graph,
repeated. Still short of the two-kernel baseline where that baseline
runs (+7.7% at m=32, +54% at m=1); what is left is the sentinel re-arm
write traffic and the spin latency, not the grid.

Co-authored-by: Cursor <cursoragent@cursor.com>
Two findings from re-baselining, one of which invalidates the numbers in
the previous two commits.

The baseline was never the two-kernel path. dispatchFusedAllReduceRMSNorm
checks the Lamport path before MAYBE_DISPATCH_1S_KERNEL, and the plain
fused rmsnorm entry point selects one-stage for total_bytes <= 128*7168*2
/world_size, which at TP=4 and n=7168 is 448 KB -- every shape up to and
including m=32. So all the "baseline" figures reported so far were the
one-shot kernel, and enabling the Lamport path was silently displacing it.
A reduce-scatter cannot beat a one-shot kernel on small messages: the
one-shot reads each peer's input once and stages nothing. Gate on
!use_1stage so this path only ever displaces the two-kernel pair.

Measured at TP=4, bf16, n=7168, CUDA graph, us/iter:

  m      1-stage   Lamport    delta
    1       7.97     9.95     +25%
    8       7.47    11.37     +52%
   16       9.40    12.30     +31%
   32      13.40    14.63     +9%
   64      22.44    19.47     -13%
  128      39.37    33.38     -15%
  256      74.62    60.13     -19%

The crossover lands within a factor of two of the existing 448 KB
threshold, so the gate puts the Lamport path exactly where it wins.

The two-kernel path it replaces cannot be compared under a graph at all:
forcing AITER_AR_1STAGE=0 hangs at every shape, not just the m>=64 ones
where it is the default. That is pre-existing (it reproduces on the
pre-patch .so) and is why the earlier runs looked like "baseline hangs at
m=64" -- m<=32 was quietly taking the one-stage path. In eager, where it
does run, it is still ahead: 48.97us vs 60.13us at m=256.

Also adds AITER_AR_RMSNORM_LAMPORT_ARM_REPEAT, which repeats the
consumer's idempotent re-arm store so the extra passes time the re-arm
without invalidating the result. One pass costs ~12us of the 60us at
m=256 (~300 GB/s of uncached stores, so bandwidth-bound and a fair
estimate there); below m=64 the repeat serialises on the same address and
only bounds the cost from above.

Co-authored-by: Cursor <cursoragent@cursor.com>
!use_1stage alone leaves the gate at the mercy of whoever calls in, and
the callers disagree. aiter's own communicator picks one-stage below
128*7168*2/world_size, which is 448 KB at TP=4 -- the same place the
measured crossover sits, so the previous commit's gate is exactly right
there. sglang does not: it uses a flat 128 KB
(parallel_state.py, use_1stage_ar = total_bytes <= 128 * 1024), so with
that caller every shape from m=10 up would take this path, including the
m=16 and m=32 shapes where it is 31% and 9% slower than the one-shot
kernel.

Gate on the measured crossover directly instead. AITER_AR_RMSNORM_LAMPORT
_MIN_BYTES overrides it for sweeps; set to 0 to disable the floor and
recover the previous behaviour.

The floor is TP-independent by construction: it is a per-rank input size,
so the same byte count means the same amount of work per GPU whatever the
world size. Whether the crossover itself moves with world size has not
been measured -- only TP=4 was swept -- so 448 KB should be re-checked at
TP=8 before this is relied on there.
The Lamport path shared the general tmp region (get_tmp_buf) with the
2-stage all-reduce, reduce-scatter and allgather kernels. That is a
silent-corruption bug, not a performance detail.

Lamport encodes arrival in the data, so the region must hold sentinels at
the start of every call, and the only thing that restores them is the
Lamport consumer re-arming each pack it reads. Any other kernel staging
through the same region overwrites the sentinels with real data.
lamport_ensure_armed will not notice: it tracks a high-water mark and
still believes the buffer is primed. The next Lamport call reads
non-sentinel words, treats stale data as arrived, and returns the previous
iteration's values. No hang, wrong results.

The microbenchmark cannot see this because m is fixed for the whole run.
End-to-end it is unavoidable: decode's m moves with batch size and MTP, so
shapes below the Lamport floor take the 2-stage path and poison the buffer
for the shapes above it.

Re-arming defensively does not fix it under CUDA graphs. The pollution
comes from replaying a graph captured at a different shape, and the re-arm
cannot be recorded into the Lamport graph -- replaying it would clobber
producer stores the peers have already landed, which is the hang fixed in
14a918f. A private region is the only thing that holds across replays.

So carve kLamportScratchBytes (32 MiB) out of the meta buffer right after
Signal and move get_tmp_buf past it, mirroring how the gfx1250 path
already appends its LL staging scratch. Callers size the meta allocation
as meta_size() + 2 * max_size, so reporting the scratch from meta_size()
is all that is needed to reserve it -- no change on the Python side.

32 MiB covers m=2048 at n=7168 bf16. Larger shapes fail lam_ok and fall
back to the two-kernel path.
@EricKing626
EricKing626 marked this pull request as draft August 25, 2026 10:47
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