Skip to content

[AMD][gfx950] DSV4: fuse wo_b's GEMM with its all-reduce via mori cco - #40757

Closed
yangyuhuiling wants to merge 3 commits into
sgl-project:mainfrom
yangyuhuiling:xiangch/fused-wo-b-gemm-ar-main
Closed

yangyuhuiling wants to merge 3 commits into
sgl-project:mainfrom
yangyuhuiling:xiangch/fused-wo-b-gemm-ar-main

Conversation

@yangyuhuiling

@yangyuhuiling yangyuhuiling commented Sep 22, 2026 •

Copy link
Copy Markdown

What

wo_b in DeepSeek-V4 is a RowParallelLinear: a block-scale fp8 GEMM followed
by an NCCL all-reduce of the [M, hidden] result. The two can overlap.
mori.ops.gemm_ar's fused kernel writes each destination's row band straight
into a symmetric window and hands it to the SDMA copy engines as soon as that
band's tiles are done, so the transfer runs inside the GEMM instead of after
it.

Opt-in and ROCm gfx950 only. SGLANG_OPT_FUSED_WO_B_AR=1 turns it on.
fused_wo_b() returns None for everything it does not serve — decode, small
or ragged M, a weight that is not aiter-preshuffled, no SDMA queues — and
disables itself for the process on any failure, so the ordinary path is the
fallback rather than an error.

Numbers

8x MI355X (gfx950), DeepSeek-V4-Pro, TP8, input 4096 / output 512,
bench_one_batch_server, last_ttft:

bs base fused fused + fp8 gather
8 1136.1 ms −3.36% −6.04%
16 2232.0 ms −3.03% −6.29%
64 8890.9 ms −3.26% −6.44%

bs=1 sits at the fusing threshold (one row band per destination, nothing to
overlap) and is noise in either direction.

The layer itself goes 1460.3us → 1145.1 (−21.6%) at M=16384 N=7168 K=2048
on 8 ranks. End-to-end is smaller because wo_b is about 12% of the forward —
TTFT is the metric that moves, since the fused path only engages in prefill;
decode runs at M=batch_size and never fuses.

Fused and unfused agree to relL2 2.72e-05. Both quantise identically, so
what is left is bf16 accumulation order.

The optional fp8 gather, and what it costs

SGLANG_OPT_FUSED_WO_B_AR_FP8_GATHER=1 (also off by default) sends the
all-gather leg as e4m3 with a per-row scale. That leg grows linearly with M,
which is why the extra gain is near-constant across batch sizes (−2.8 / −3.4 /
−3.3% on top of the bf16 wire).

It is not free: relL2 on that leg goes 2.35e-3 → 2.49e-2, which is e4m3's
3-bit mantissa rather than a granularity that can be tuned away, and the
symmetric window grows 700 → 812 MiB. Off by default for that reason.

Worth a reviewer's attention

  • Two call sites own the all-reduce themselves and the fused path stands
    aside for both
    : mHC post-fusion passes skip_all_reduce=True, and a
    Mxfp8SwizzledInput arrives when wo_a fuses an MXFP8 quantization into its
    epilogue. Neither is reachable on ROCm today (mHC needs Blackwell and
    tp_size == attn_tp_size == 4), so this checks rather than relying on the
    platform.
  • BUILD_CCO_SDMA=ON is set where the image and CI build MORI. Without it
    cco has no SDMA queues and every put is a silent no-op;
    GemmAllReduceOp.self_test() then refuses to serve a collective that moves
    no bytes, so the flag would be accepted and immediately disable itself. The
    Dockerfile and amd_ci_install_dependency.sh must change together — the CI
    step rebuilds MORI over the image's copy and would otherwise undo it.
  • The window is VMM memory outside torch's allocator, sized from
    get_schedule().chunked_prefill_size and unable to grow afterwards, so a run
    that enables this needs a correspondingly lower --mem-fraction-static. It
    is taken from the chunked-prefill limit rather than the first request seen,
    so a short prompt arriving first cannot fix a window too small for a later
    full chunk.
  • The thresholds are on the SGLang side on purpose. _MIN_FUSED_M and
    _MIN_PAD_FILL answer "is fusing worth it against what the model runs
    today", which is not a property of the kernel; mori.ops.gemm_ar.supports()
    answers the separate question of whether the shape is expressible.

Not in this PR

A three-arm nightly perf suite (base / fused / fp8 + GSM8K) exists and runs,
but it is a 6-hour 8-GPU job for a feature that is off by default, so it is
held back rather than added to nightly-test-amd.yml here. Happy to bring it
in if maintainers would rather have the regression coverage up front.

Dependency

Needs mori with mori.ops.gemm_ar (ROCm/mori#662) built with
BUILD_CCO_SDMA=ON, and MORI_ENABLE_SDMA=1 at process start. SGLang already
depends on mori for moriep and the disaggregation transport; this is the
first use of it inside the model forward.

🤖 Generated with Claude Code


CI States

Latest PR Test (Base): ❌ Run #37813744419
Latest PR Test (Extra): ❌ Run #37813743891
Latest PR Test (AMD ROCm 10): ❌ Run #37813744414

yangyuhuiling and others added 3 commits October 8, 2026 09:54
`wo_b` is a RowParallelLinear: a block-scale fp8 GEMM followed by an NCCL
all-reduce of the [M, hidden] result. mori's fused kernel writes each
destination's row band straight into a symmetric window and hands it to the
SDMA copy engines as soon as that band's tiles are done, so the transfer runs
inside the GEMM instead of after it.

Opt-in and ROCm-only. `SGLANG_OPT_FUSED_WO_B_AR=1` turns it on;
`fused_wo_b()` returns None for everything it does not serve -- decode, small
or ragged M, a weight that is not aiter-preshuffled, no SDMA queues -- and
disables itself for the process on any failure, so the ordinary path is always
the fallback rather than an error.

Measured on 8x MI355X (gfx950), DeepSeek-V4-Pro, TP8, in 4096 / out 512:

    bs     base      fused    fp8 gather
     8   1136.1ms   -3.36%       -6.04%
    16   2232.0ms   -3.03%       -6.29%
    64   8890.9ms   -3.26%       -6.44%

(TTFT; bs=1 is at the fusing threshold and is noise either way.) The layer
itself goes 1460.3us -> 1145.1 at M=16384 N=7168 K=2048, i.e. -21.6%; the
end-to-end share is smaller because wo_b is about 12% of the forward. The
fused and unfused paths agree to relL2 2.72e-05 -- both quantise identically,
so what is left is bf16 accumulation order.

The optional fp8 all-gather leg (`..._FP8_GATHER=1`, also off by default)
halves that leg's bytes for a further ~3%, consistently across batch sizes
because the leg grows linearly with M. It costs accuracy: relL2 on the gather
goes 2.35e-3 -> 2.49e-2, which is e4m3's 3-bit mantissa rather than a
granularity that can be tuned, and the window grows 700 -> 812 MiB.

Two call sites of this layer own the all-reduce themselves and the fused path
stands aside for both: mHC post-fusion passes `skip_all_reduce=True`, and a
`Mxfp8SwizzledInput` arrives when wo_a fuses an MXFP8 quantization into its
epilogue. Neither is reachable on ROCm today, so this checks rather than
relies on the platform.

`BUILD_CCO_SDMA=ON` is set where the image and CI build MORI. Without it cco
has no SDMA queues, every put is a silent no-op, and
`GemmAllReduceOp.self_test` refuses to serve a collective that moves no bytes
-- so the flag would be accepted and then disable itself.

The window is VMM memory outside torch's allocator, sized from
`get_schedule().chunked_prefill_size` and unable to grow afterwards, so a run
that enables this needs a correspondingly lower --mem-fraction-static.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
….1-Flash

PR sgl-project#40757 fuses wo_b's GEMM with its all-reduce for V4-Pro, whose wo_b is a
128x128 block-scale fp8 linear. V4.1-Flash quantises at 32-wide ue8m0 and takes
the gfx950 native mxfp8 route instead, so `aiter_bpreshuffled` is never set and
the fused path declined every layer -- after allocating its 560 MiB window.

Ported from xiangch/mori-on-dsv41-amd-main:

- mori_gemm_ar: quantise the way V4.1's wo_b does, and accept the fp8-grid
  operand the gfx950 wo_a fork emits instead of requiring a plain tensor.
- mori_mxfp8_gemm / mori_mxfp8_common: the same multiply with no collective
  attached, behind SGLANG_OPT_MORI_MXFP8_GEMM. It serves wq_b, which is
  column-parallel and has nothing to fuse with, and any wo_b call the fused
  path declines. Gated on the GEMM's grid rather than on M.
- Both switches are independent on purpose: they are separate optimisations
  and have to be A/B-able separately.

The hooks are re-pointed at where upstream now puts these routes: the mHC
branch moved out of _project_wo_b into its caller's defer_all_reduce, and the
native mxfp8 linear is _apply_native in fp8_hip.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The harnesses were written against a tree where the parallel topology was an
argument, prepare_mxfp8_native_weight also returned a dequantised bf16 copy,
and the native linear took it as a fallback. Upstream has since moved the
topology into the published context and split the bf16-dequant route out into
bf16_dequant_blockscaled_linear, so every one of these runs failed to start.

- initialize_model_parallel() reads the published widths: publish them first.
- ServerArgs carries tp_size, or publishing it contradicts the groups already
  built and the topology validator rejects it.
- prepare_mxfp8_native_weight returns two tensors, and the native linear has
  no weight_bf16 parameter.
- native_route_plan is gone; the row loses that annotation rather than the run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant