Repository navigation
[Perf] [AMD] DSV4.1: Fuse wo_b's GEMM with its all-reduce via mori cco on gfx950 - #43480
yangyuhuiling wants to merge 24 commits into
Conversation
`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>
d935edb to
e5e2e77
Compare
…isation The fused wo_b above is written for DeepSeek-V4-Pro, whose wo_b is a 128x128 block-scale fp8 linear preshuffled by the aiter block-fp8 route. V4.1-Flash quantises at 32-wide ue8m0 and takes a gfx950 mxfp8 route instead, so `aiter_bpreshuffled` was never set and the path declined every layer -- after reserving its symmetric window. - Quantise the way V4.1's wo_b does, and take the fp8-grid operand the gfx950 wo_a fork emits rather than requiring a plain tensor. `Mxfp8Activation`, the other form that fork emits, is pre-quantised with no bf16 left to quantise, so it keeps the ordinary path; only decode produces it, which the M floor declines anyway. - Serve both gfx950 mxfp8 routes, not just the native one. They differ in the weight and nothing else -- native rebinds it to the shuffled [N/16, K/128, 2048], aiter leaves the checkpoint's [N, K] -- and `--fp8-gemm-backend aiter`, which the MI355X cookbook pins, takes the second. mori's own `preshuffle_b` is defined on exactly the layout aiter leaves, so the conversion branches and nothing else does. `mxfp8_aiter_ready` records which route ran, because the weight's shape cannot: a wrong guess reads a permuted weight as plain and returns an uncorrelated result. - Re-derive the thresholds against what V4.1 actually runs: the bf16 wire only pays from a padded M of 8192, the fp8 one from 2048. The hooks follow 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. Measured on 2x MI355X with the cookbook's flags, 16384-token prefill: 21 of 49 NCCL all-reduces move to the fused path, and end-to-end TTFT is -6.9% (medians of 6 fused and 7 base server instances; a single pair does not resolve it, the instance-to-instance spread of aiter's custom all-reduce is of the same order). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
e5e2e77 to
b250059
Compare
There was a problem hiding this comment.
The main concerns are when the fused path may replace wo_b's own reduction, and how failures are handled across ranks:
Should be fixed before merge
- DP attention: wrong reduction group, and a possible cross-rank hang (
deepseek_v4.py) - A rank-local disable deadlocks the TP group on mori's device barriers (
mori_gemm_ar.py)
Should fix
- A construction-time
ValueErroris retried on every call - A lazily allocated second copy of each
wo_bweight, outside the memory profiler - Window sizing when chunked prefill is off
Smaller
- Raw
os.environaccess for registered env vars - Duplicated ue8m0 scale rule
- Hot-path overhead of the gate
Details inline.
**DP attention: the fused path reduced over the wrong group, and could hang.** `wo_b` is declared `parallel_group="attn_tp"`, while the op always reduces over the full TP group. The call site gated only `defer_all_reduce`, and everything that made *that* false reached the fused path -- including `attn_tp_size == 1`, where the split path reduces nothing and fusing summed rows from unrelated DP ranks, and `1 < attn_tp_size < tp_size`, where `attn_tp_all_reduce` then ran on top of a full-TP sum. It could also hang: under DP attention each rank has its own M, so eligibility differed across ranks and mori's device-side barriers have no timeout. `_reduces_over_tp_group` now asks the layer, using `RowParallelLinear.forward`'s own conditions, and `fused_wo_b` checks it before anything collective. The env var's comment said "TP-only"; nothing enforced it. **A rank-local disable deadlocked the TP group.** `_disabled` was set only on the rank that raised, which then fell back to NCCL while its peers kept calling the op and spun on its barriers. Construction is now collective: every rank attempts it, the outcome is agreed over the CPU group, and ranks that succeeded tear their communicator back down if a peer did not. After construction there is no fallback left -- an OOM in `pad_rows` or `quantize_packed` is rank-local and `run` is collective, so returning None from either is a silent hang where dying is visible. Every remaining reason to decline is an explicit check that reads the same on all ranks. **A construction `ValueError` was retried on every call.** One `try` covered both construction and the per-call work, with a handler written for the latter: `_state` stayed None and each of the 61 eligible calls per forward repeated the broadcast, the VMM reservation and the teardown. Construction is attempted once and a failure is permanent. **The second copy of each `wo_b` weight is built before the profiler runs.** `mori_weight` re-lays the weight out -- a full second copy, since the split path still needs the original -- and did it lazily on the first fused call, after the memory profiler had sized the KV cache around its absence (61 layers, ~1.3 GiB at TP2). `prepare_wo_b_weight` now runs in `post_load_weights`. **The window is sized from the deployment's prefill ceiling.** With chunked prefill off, `m_max` fell back to the first eligible M -- the first-request sizing the docstring says it avoids -- and every larger M then declined silently for the life of the process. It now uses `max_prefill_buffer_tokens`, as the other buffer-sizing callers do, and logs once when a call exceeds the window. **Smaller** - `SGLANG_OPT_FUSED_WO_B_AR_SHAPE_LOG` and `..._GATHER_TRANSPORT` go through the `EnvField` API. The former was read at import and compared against "1", so the `EnvBool`-accepted `=true` was ignored and `override()` did nothing. - One ue8m0 scale rule: the quantiser calls `fp8_grid_quant` rather than restating it as `ceil(log2(amax / 448))`. The two are not the same function -- `fp8_grid_quant` reads the exponent off the IEEE bits and is exact at powers of two, where an approximate `log2` can land above the integer and `ceil` picks one exponent too high. Their agreeing is what the claim that re-quantising an `Fp8GridActivation` is lossless rests on. - The static gate memoises the env read, and `m_pad` is computed once per call instead of three times. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ists `post_load_weights` runs before `process_weights_after_loading`, so `mxfp8_ready` was false there and `prepare_wo_b_weight` returned without allocating -- leaving the second weight copy to be built lazily during serving, which is the thing it was meant to stop. Measured: KV cache and available GPU memory were byte-identical with the switch on and off. `precompile_kernels_after_loading` runs after the quant method has laid the weight out and before the memory pool is sized, which is the window this needs. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Thanks — all eight were real, and two of them were wrong in ways I had documented in a comment and then not enforced. Fixed in 3dd6ca3 and 18dd7fd, verified on an 8×MI355X node. 1. DP attention: wrong reduction group, possible hangYou're right on both counts, and the second one follows from the first. The gate was on Rather than gate at the call site I put it in Not gated: Verified end to end: TP2 with That also closes the hang, since eligibility can only diverge across ranks under DP attention, which no longer reaches 2. A rank-local disable deadlocks the TP groupAgreed, and the architecture was wrong rather than the handler. Construction is now collective: After construction there is no fallback left. An OOM in Verified with fault injection under torchrun, both 3. Construction
|
| tokens | |
|---|---|
| switch off | 34,249,728 |
| switch on | 33,768,960 |
−480,768 tokens. With four MLA KV source layers that is roughly 1.1 GB, against 40 × 21 MiB ≈ 840 MiB of extra weight — the right order, and the point is that the profiler now sees it at all.
5. Window sizing without chunked prefill
Switched to max_prefill_buffer_tokens() with max_prefill_tokens as the fallback, which is what the other buffer-sizing callers here use and also covers PP dynamic chunking's 1.25× probe. Added the one-line warning when a call exceeds the window.
Verified with --chunked-prefill-size -1 --max-prefill-tokens 20480, first eligible request M=8401:
mori fused wo_b: window 700 MiB, tp=2, M<=20480, N=5120, K=4096, gather=bf16
Sized from the ceiling, not from that first request — which under the old code would have pinned it at ~8448 and declined everything larger for the life of the process.
6. Raw os.environ
Both go through the EnvField API now, and os is no longer imported. Your sub-point was the sharper half: SHAPE_LOG=true was silently ignored and override() did nothing, since it was compared against "1" at import time. Checked both in the unit test.
7. Duplicated ue8m0 scale rule
The quantiser calls fp8_grid_quant and keeps only the packed store. You're right that they are not the same function — fp8_grid_quant takes the exponent off the IEEE bits and is exact at powers of two, where ceil(tl.log2(.)) can land above the integer and pick one exponent higher.
Verified rather than reasoned: operands with whole groups placed on exact powers of two of 448, comparing against fp8_grid_quantize. Exponents and fp8 codes are now byte-identical; 0 differing groups.
8. Hot-path overhead
The switch is memoised (_disabled stays dynamic and is checked first, so the off path is one bool), and m_pad is computed once per call instead of three times. I left the call-site caching alone — it's a model-file change for what is now a memoised read.
Also re-ran the TP-only path end to end after all of this: fusion still engages at TP2 (window 560 MiB, M<=16384), both ranks agree, generation is correct.
Duyi-Wang
left a comment
There was a problem hiding this comment.
Thanks for the thorough follow-up. I re-reviewed 18dd7fd4 against mori's cco and ops/gemm_ar sources. Items 1, 3, 5, 6 and 7 from the earlier round look resolved, and the DP-attention gate is cleaner where it is now than at the call site.
A few things remain, details inline:
- Collective construction: host-side failures look covered, but a rank failing between
register_windowand the end ofself_test()still leaves its peers stuck on device barriers, before the CPU agreement. Also a question about where the fault was injected in the--fail-ranktest. - Weight updates: the load-time
_mori_bsnapshot is never invalidated, so afterupdate_weights_*the fused prefill serves the oldwo_b. - Window allocation: still lazy, after the KV cache is sized. The reasoning that moved the weight copy to load time applies here too.
prepare_wo_b_weightscope: it also prepares DSPARK draft layers that never fuse, and copies that are never freed after a collective disable.prepare_wo_b_weightdocstring: it says "best effort", but there is notry.
**The fused path served a stale wo_b after a weight update.** `mori_weight` returned `_mori_b` whenever it existed, and nothing invalidated it: `end_weight_update` calls `post_load_weights` only when asked, and `precompile_kernels_after_loading` -- where the copy is built -- not at all. So after `update_weights_from_*`, fused prefills multiplied by the weight the server started with while decode and every declined call used the new one. No error, just wrong output, and certain in an RL flow that loads dummy weights and then syncs real ones. The cache is now keyed on the source weight's identity and version counters, so a replaced *or* an in-place-written weight invalidates it without depending on which hook re-ran. **A rank failing around `self_test()` still hung its peers.** The CPU agreement sat after `_FusedWoB` was fully built, and `self_test` runs the scatter/reduce/gather phases, which end in cross-rank barriers on the GPU. A rank that failed between `register_window` and launching its phases -- a lost SDMA queue in `create_dev_comm`, the FlyDSL compile raising -- left the peers that did launch spinning in `synchronize()`, never reaching the agreement. `self_test` moves out of `__init__`, and construction now agrees twice: once that every rank holds an op, then once on the test's result. Rank 0 failing to make a unique id also broadcasts a None payload rather than going on to the agreement while its peers sit in `broadcast_object_list` on the same group. **The window was still allocated during a forward.** It is the larger of the two allocations (560-700 MiB per rank against ~840 MiB of weight copies across 40 layers), and since it is sized from the prefill ceiling rather than the first request there is nothing left to wait for. `prepare_fused_wo_b` now builds both at load time, so an OOM lands at startup and the collective construction runs outside any forward. The per-call path keeps a fallback for a caller that never prepared, and says so once. **Preparation was over-broad and never released.** Walking every module with a `wo_b` also prepared DSPARK's, which calls its linear directly, and the draft wrapper borrows this method -- copies nothing reads, ahead of the profiler. It now takes the `MQALayer` layers explicitly. `_can_ever_fuse` additionally declines a shape `supports()` rejects and an M floor the deployment's prefill ceiling cannot reach, and a collective disable releases every copy. An instance that declares a large ceiling but never prefills still pays for the window; narrowing that needs a signal this layer does not have. **`prepare_wo_b_weight`'s docstring contradicted itself** -- "best effort" with no `try`, next to a claim that it turns a serving OOM into a startup one. It fails at startup, and now says so. Dropped the `hasattr` that `mxfp8_ready` already covers. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The numerics case only ever ran the bf16 wire -- FP8_GATHER defaults off -- so its 2e-2 bound was guarding a leg that measures 2.4e-3, and the fp8 leg had no bound at all. Reading that 2e-2 as the fp8 wire's budget is what a reviewer did, and nothing in the file said otherwise. Run both wires with a bound each: 2e-2 for bf16, 3e-2 for fp8, which carries e4m3's mantissa width (2.3e-2 measured on an MI355X TP4 microbench, 2.4e-2 end to end). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Turning the path on substitutes the GEMM, so the output moves -- and the env var said nothing about that, while the fp8 gather's comment right below it called out its accuracy cost, which reads as though this one has none. It is exact only where wo_a hands wo_b an Fp8GridActivation, which needs the gfx950 native mxfp8 route. The MI355X recipes pin --fp8-gemm-backend aiter, where wo_b gets a plain bf16 tensor and the fused and unfused paths each quantise it: relative L2 7.5e-3 to 8.2e-3 at M=16384 on TP4, with --fp8-gemm-backend auto landing in the same range. The collective itself is deterministic there -- the op against itself is 0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Thanks for the reproduction — that's a lot of careful work, and two of your points land. I've pushed four commits ( Point 4 first: you're right, and the claim is withdrawnI pulled the checkpoint and reran this on 4x MI355X with the cookbook flags.
That matches your 7.7e-3 to 9.6e-3. The earlier 0 does not reproduce on either backend value, so I've withdrawn it in the description rather than narrowing it. What does hold is the second column:
Points 1 and 3: neither failure reproduces on another MI355X hostYour two images unmodified on an 8x MI355X box (gfx950, all XGMI), same mori
All On the CI prediction specifically: the So I took your second option rather than the first. The variable now looks like the host:
Could you post Point 2: done
Being straight about the limit: a timeout does not make the process healthy. The stream stays wedged and the thread stays stuck. What it buys is a diagnosable failure instead of a server that logs nothing and never opens its port, which is the state you hit. Writing it turned up a bug worth naming: torch's current device and stream are thread-local, so the side thread defaulted to device 0 and ran every rank's phases against the wrong GPU. Caught by diffing against a baseline run rather than by the test passing. Both are carried across now. Point 5: one correction, and you're right about the gapThe But the gap underneath is real and worse than a wrong threshold: the fp8 wire had no coverage at all. Verification8x MI355X, both of your images:
Your two side observations are both correct — the fused output is a view into the symmetric window and is only safe within the layer, and the window does take from the KV pool. Neither has a caller that breaks today, so I've left them; happy to assert the first if you'd rather not rely on that staying true. |
|
Thanks @yangyuhuiling for reviewing Hubert's comment. I Additionally tested the latest PR on a mi355x machine. Similar to Hubert's observation, i too observed significant TTFT regression. On TP2/EP1, with a 16,384-token uncached prefill, 64 output tokens, concurrency 1, and the bf16 gather wire, the fused path is about three times slower than the baseline, and the later PR update does not change that. Initial PR (a8b0165): the baseline TTFT was 301.70 ms for one request and 299.90 ms across three. With SGLANG_OPT_FUSED_WO_B_AR=1, it was 944.19 ms and 945.34 ms, about +213% and +215%. Updated PR (19a53ef): the same bf16-wire run measured 944.70 ms for one request and 944.38 ms across three, still about +213% and +215% versus that baseline. I noted the difference in kernel and amdgpu version on my machine (kernel: Linux 5.15.0-70-generic , amdgpu: 6.16.13) v/s the one you noted. However, a 3x regression from kernel difference is unlikely. |
|
@hubertlu-tw @bmullick-amd Thanks for the detailed measurements. I think this is a known environment issue rather than something in the kernels or the integration. Both of your hosts run Linux 5.15, and the stock Ubuntu 22.04 5.15 kernel is built without What happens. mori cco allocates its symmetric window through the HIP VMM API. Why it matches your numbers
This also rules out the JIT-recompilation idea I raised earlier: graph replay doesn't recompile, and 3.3 ms is GPU time. How to check (no GPU needed): grep -E 'CONFIG_(PCI_P2PDMA|DMABUF_MOVE_NOTIFY)=' /boot/config-$(uname -r)Both must print For a direct runtime check, Fix. Install the HWE kernel ( Could you run the check above, and if possible re-measure on a host with the options enabled? I'd also expect the ROCm 10 symptom (SDMA puts not landing) to be connected to the same window allocation path, but I haven't confirmed that, so it would be good to see whether it persists on a fixed kernel. |
|
@yangyuhuiling Thanks, that explains it. I checked the host I measured on: It runs stock Ubuntu 5.15.0-70-generic, and its config has # CONFIG_PCI_P2PDMA is not set and # CONFIG_DMABUF_MOVE_NOTIFY is not set. We will re-measure after moving the host to the HWE kernel, and check whether the ROCm 10 SDMA symptom goes away too. Because this fails silently (correct results, about 3x slower TTFT at 16K), I think the PR should detect it rather than leave it to a doc. Checking the kernel version isn't enough: the cause is a build option, and a rebuilt 5.15 is fine. Reading the config file also isn't enough on its own. Inside the rocm/sgl-dev containers, /boot is empty, and stock Ubuntu doesn't provide /proc/config.gz (CONFIG_IKCONFIG is not set). So for most users that check would return "unknown". The per-GPU sysfs counters are visible in the container, though, so I'd suggest two checks: Host check before construction, put through _agreed() like the other stages. If the config is readable and the option is missing, every rank turns the fused path off with a message that names the fix. Something like In _FusedWoB.init, read the counter before Communicator.init and check it once the op exists. A failure here is caught by the existing "constructed" agreement, so every rank still falls back together:
This affects every cco user, EPv2 included, so the same check would also make sense in mori's Communicator.init. |
Check kernel configuration before collective construction and validate settled per-GPU VRAM growth after every rank has mapped the window. Agree on preflight and placement failures before launching device collectives. Wait out deferred driver frees after weight preparation to avoid false declines. Cover configuration and counter handling, rank-local failures, real communicator cleanup, and the complete initialization path in numerical tests.
|
@hubertlu-tw Added the two checks in bfc9acf238f, following your suggestion. They run during initialization, with The kernel check reads For placement, the baseline is taken before Real-model startup exposed another source of counter noise: deferred driver accounting after The preflight now synchronizes and empties the torch cache after weight preparation, with failures covered by the same CPU agreement. Both VRAM readings require 0.75 s of stability, with a 3 s limit. A counter that is unreadable or keeps changing produces an explicit Validation on MI355X:
This is one fresh server per arm, three measured requests each, a quick regression check rather than the original six-pair measurement. Each instance had one 16K/32 warmup followed by an explicit cache flush. Debug and shape logging were off during timing. These numbers do not establish a repeatable percent-level gain or downstream quality. The host has a working kernel configuration; bad-kernel and bad-placement paths were fault-injected, not reproduced on an actual affected 5.15 host. Stack: rootless Podman, TP4 launch and benchmark parametersRun from the PR checkout in the image above, with the real checkpoint mounted at the model path. All three arms use the same settings below. The two fusion switches are One loading-only adjustment is explicit: all arms set export PYTHONPATH="$PWD/python"
export MODEL=/models/DeepSeek-V4.1-Flash
export HIP_VISIBLE_DEVICES=0,1,2,3
export MORI_ENABLE_SDMA=1 MORI_SOCKET_IFNAME=lo
export SGLANG_USE_AITER=1 SGLANG_USE_ROCM700A=0
export AITER_BF16_FP8_MOE_BOUND=0
export SGLANG_MOE_COPY_WEIGHT_VIEWS_BEFORE_H2D=1
export SGLANG_ENABLE_DSV41_ENGRAM_HOST_TABLE=1
export SGLANG_DSV41_ENGRAM_HOST_TABLE_LAYOUT=per_rank
export TORCH_BLAS_PREFER_HIPBLASLT=1 TRITON_HIP_USE_ASYNC_COPY=0
export ROCM_QUICK_REDUCE_QUANTIZATION=NONE
export HSA_NO_SCRATCH_RECLAIM=0 GPU_MAX_HW_QUEUES=2
export SGLANG_DEBUG_FUSED_WO_B_AR=0 SGLANG_OPT_FUSED_WO_B_AR_SHAPE_LOG=0
export SGLANG_OPT_FUSED_WO_B_AR=1 SGLANG_OPT_FUSED_WO_B_AR_FP8_GATHER=0
python3 -m sglang.launch_server \
--model-path "$MODEL" --trust-remote-code \
--tp 4 --ep-size 1 --mem-fraction-static 0.78 \
--speculative-algorithm DSPARK --speculative-dspark-block-size 5 \
--enforce-shared-experts-fusion --fp8-gemm-backend aiter \
--cuda-graph-max-bs-decode 64 --enable-decoder-swa-bounded-replay \
--cuda-graph-backend-prefill disabled --chunked-prefill-size 16384 \
--reasoning-parser auto --tool-call-parser auto \
--host 127.0.0.1 --port 31300Client command, after readiness, from another shell with the same checkout and model path: PYTHONPATH="$PWD/python" python3 -m sglang.benchmark.serving \
--backend sglang --base-url http://127.0.0.1:31300 \
--model /models/DeepSeek-V4.1-Flash \
--dataset-name random-ids --tokenize-prompt \
--random-input-len 16384 --random-output-len 64 --random-range-ratio 1 \
--num-prompts 3 --max-concurrency 1 --request-rate inf \
--warmup-requests 1 --flush-cache --cache-report --output-details \
--seed 42 --disable-tqdm --output-file result.jsonlThe implementation is in the SGLang adapter for this PR; a common MORI check can follow separately. |
Reuse AITER's (16, 16) MXFP8 weight layout when the loader marks it as preshuffled; its bytes already match MORI's B layout. Preserve conversion for plain and native weights, including the derived scale cache. Update the native helper import after the kernels directory refactor and cover both AITER layouts, including the production weight preparation path.
|
@yangyuhuiling Thank you for the detailed explanation above. Following your comment I upgraded my machine from 5.15.0-70-generic to 6.8.0-138-generic. Every run used PR head Case ROCm 7.2.4, TP2 EP1: The FP8 gather run, against a unfused baseline, was +1.7% and +2.0% (275.97 ms and 277.45 ms versus 271.43 ms and 272.09 ms). You claim speedups here: −2.4% for BF16 and −5.8% for FP8 at TP2. Case ROCm 10, TP4 EP1: So I was able to get performance improvement on rocm10 image but still saw regression on rocm 7.2.4 |
|
Thanks for re-running after the kernel upgrade — and for posting both stacks side by side. The 3× slowdown disappearing on 6.8 confirms the 5.15 I tried to reproduce the ROCm 7.2.4 TP2 regression and could not. PR head
Every round agrees in sign (bf16 −2.6% to −3.6%, fp8 −6.4% to −7.5%), and the instance-to-instance spread on this box was about 1%, so your +5–6% on bf16 is well outside noise and I'm treating it as real. Both fused arms logged What stands out is the TP2 baseline. On ROCm 10 TP4 our unfused TTFTs are within 4% of each other (your 220.1 ms, my 230.1 ms), but at TP2 yours is 29% faster than mine (272.6 vs 350.4 ms). Going from TP4 to TP2 doubles the per-GPU prefill work: my TTFT rises 52%, yours only 24%. That suggests your TP2 run differs from your TP4 run in configuration, not just in parallel size, and the regression may come from whatever that difference is. My TP2 launch is the TP4 command from my previous comment with
With your configuration I can rerun the same six-round comparison and narrow down which setting turns the gain into a loss. |
|
@HaiShaw could you please help review the PR? |
|
Reviewed the whole diff, with a focus on correctness of the gating and on the question of whether any non-gfx950 platform can reach this path. Overall this is careful work — the collective-agreement staging in Correctness1.
|
- Decline under Megatron SP: RowParallelLinear reduce-scatters along the token dim there (row_parallel_gbar_matmul), so an all-reduced [M, N] is the wrong result and shape. sp_active is checked per forward in _substitutes_layer_all_reduce; it is rank-uniform. - mxfp8_shape reads N and K off the weight in its route's layout instead of the ue8m0 scale, whose ceil(N/32) rows overstated an N off the 32 grid and made the weight reshape fail at startup. - prepare_fused_wo_b copies only the layers the window can serve, the first candidate's (N, K); fused_wo_b declines every other shape. - Only a plain RowParallelLinear without a bias is fused: a LoRA wrapper moves the attributes under base_layer (startup raised AttributeError), and the fused GEMM has no bias term. - fused_wo_b_available() also requires is_gfx95_supported(), instead of relying on only the gfx950 routes setting the mxfp8 markers. - release_mori_weight restores the layer's previous _derived_weight_cache_error instead of clearing it. - fused_wo_b's docstring states how long the returned window view lives under DeepSeek-V4's op schedule, and what a prefill TBO yield would break. - environment_variables.mdx documents SGLANG_OPT_FUSED_WO_B_AR: the memory it reserves outside torch's allocator, that online weight updates are refused, and where it stays off. - The mori CI job also runs when the AMD install script changes. Tests: SP declines, a wrapped layer is declined rather than raising, N off the 32 grid, no copies for unserved shapes, the prior constraint is restored, and the gfx95 gate. The native-route test layer now has its real [N/16, K/128, 2048] shape. Co-Authored-By: Claude <noreply@anthropic.com>
Preview approval requiredThis pull request comes from a fork, so Mintlify won't build a preview until someone with write access approves commit |
|
@kkHuang-amd thanks for the careful pass. It is addressed in ba5efe3, as one commit on top. Correctness
The other points
Tests. Each of SP, a wrapped layer, N off the 32 grid, no copies for unserved shapes, restoring the prior constraint, and the gfx95 gate has a case in All three files ( |
This comment has been minimized.
This comment has been minimized.
test_kv_translate_ownership.py imported ForwardMode through forward_batch_info, which only re-imports it; check-indirect-imports rejects that, and the lint job runs it over every file. Co-Authored-By: Claude <noreply@anthropic.com>
What
wo_bin DeepSeek-V4.1-Flash is aRowParallelLinear: an mxfp8 GEMM followedby an all-reduce of the
[M, hidden]result. The two can overlap.mori.ops.gemm_ar's fused kernel writes each destination's row band straightinto 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. The reduce and all-gather still run as their own kernels.
Opt-in and ROCm gfx950 only.
fused_wo_b()returnsNonefor everything itdoes not serve — decode, small or ragged
M, a layer neither mxfp8 routeprepared, no SDMA queues — and disables itself for the process on any failure,
so the ordinary path is the fallback rather than an error.
Supersedes #40757, which could not be reopened after its branch was
force-pushed. Same branch, rebased onto current
main; the first commit is thatPR's content and the second takes it to V4.1-Flash.
Two switches, and they are not interchangeable
SGLANG_OPT_FUSED_WO_B_AR=1+ ..._FP8_GATHER=1The fp8 gather sends the all-gather leg as e4m3 with a per-row scale. It is
worth more than the fusing itself — and it is the half that costs accuracy, so
it stays off by default.
2x and 4x MI355X, DeepSeek-V4.1-Flash, 16384 in / 64 out, concurrency 1, the
MI355X cookbook's flags unchanged (including
--fp8-gemm-backend aiter).Medians over six fresh server instances per arm, arms interleaved. 48 instances,
24 pairs, every pair agreeing in sign.
Numerics, measured where the path engages
SGLANG_DEBUG_FUSED_WO_B_AR=1reports two things per fused call: the op againstthe unfused path, and the op against itself on the same inputs immediately
again. 42 samples per arm at M=16384:
Corrected after review (thanks @hubertlu-tw). This section claimed the bf16
wire was bit-identical on the cookbook flags. It is not, and the claim was the
wrong half of a real distinction: exactness needs
wo_ato handwo_banFp8GridActivation, whichwo_b_takes_fp8_grid()only returns for the gfx950native mxfp8 route. The MI355X recipes pin
--fp8-gemm-backend aiter, wherewo_breceives a plain bf16 tensor and the fused and unfused paths eachquantise it themselves.
Re-measured at M=16384, 4x MI355X, TP4, cookbook flags otherwise unchanged:
--fp8-gemm-backend aiter--fp8-gemm-backend autoThe earlier 0 could not be reproduced on either backend value, and the
native route it would require is not one these flags select, so treat it as
withdrawn rather than conditional. The second column staying zero is the part
that holds: the collective is deterministic, without which "lossy but
deterministic" and "non-deterministic" would look the same.
The fp8 wire's spread is 3% of its own value, which is what a fixed mantissa
width looks like rather than a numerical hazard. What it does to downstream
quality is not measured here: GSM8K cannot answer it — its 5-shot prompts give
a padded M of ~512 against a floor of 8192, so every arm runs the same code, and
two passes of that same code scored 0.906 and 0.898, which puts its resolution
above 1% anyway.
Worth a reviewer's attention
Both gfx950 mxfp8 routes are served, and the weight's shape cannot say which
one ran. The native route rebinds the weight to a shuffled
[N/16, K/128, 2048]; the aiter route — which--fp8-gemm-backend aitertakes, and which the MI355X cookbook pins — leaves the checkpoint's
[N, K].mxfp8_aiter_readyrecords it, because reading one layout as the otherreturns an uncorrelated result rather than failing. mori's own
preshuffle_bis defined on exactly what the aiter route leaves, so only the B conversion
branches.
The operand the gfx950
wo_afork emits decides whether this can serve thecall at all. An
Fp8GridActivationunwraps to the bf16 underneath, but anMxfp8Activationis already quantised with no bf16 left and the op does itsown quantisation, so that form keeps the ordinary path. Only decode produces
it, which the M floor declines anyway.
The window is VMM memory outside torch's allocator, sized from
get_schedule().chunked_prefill_sizeand unable to grow afterwards (560-640MiB at these shapes), so a run that enables this wants a correspondingly lower
--mem-fraction-static. It is taken from the chunked-prefill limit ratherthan the first request seen, so a short prompt arriving first cannot leave a
window too small for a later full chunk.
The thresholds are on the SGLang side on purpose.
_MIN_FUSED_M(8192 onthe bf16 wire, 2048 on the fp8 one) answers "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 theshape is expressible.
The MORI pin moves to
066fd10eso thatexport BUILD_CCO_SDMA=ONcancome out of the Dockerfile and the CI install script: build(cco): compile the SDMA path by default ROCm/mori#710 made the
cco SDMA path the default and normalised
1/TRUE/yes, which previouslybuilt a host library with SDMA and JITted a device wrapper without it. The
guard in
_FusedWoBstays — a build can still opt out, and the failure modeit catches is a collective that moves no bytes and therefore measures
faster.
How these were measured, because a single pair does not resolve them
Instance-to-instance spread on these boxes is the same order as the effect,
while repeats inside one server instance land within ~1%. An earlier three-pair
run of the same comparison came out at −0.1% with two of its three pairs landing
on the baseline; six pairs of fresh instances put it at −5.8% with all six
agreeing. So: interleave the arms, take the median across instances, keep both
arms in one lease on one node, and record a signal that each arm is what it
claims to be (
windowpresent,declineszero) rather than trusting the switch.Per-layer timings are deliberately not quoted. That harness synchronises
every call, so the transfer sits on the critical path with no neighbouring work
to hide behind — it cannot show an overlap benefit by construction, and its
baseline is the native route rather than the aiter one the server takes. On a
contended node it also inflates the NCCL baseline ~48% while leaving the SDMA
path untouched, which flips the sign.
Not in this PR
~25-30% faster than aiter's at these shapes and serves layers this has nothing
to fuse with, but it belongs in aiter rather than behind an SGLang switch, and
is going there instead.
wo_bis a 128x128 block-scalelinear preshuffled by the aiter block-fp8 route. V4.1-Flash quantises at
32-wide ue8m0 and takes a gfx950 mxfp8 route, so the second commit replaces
that path rather than adding to it — those numbers describe the first commit,
not this PR's head.
CI States
Latest PR Test (Base): ❌ Run #41960774917
Latest PR Test (Extra): ❌ Run #41960774850
Latest PR Test (AMD ROCm 10): ❌ Run #41960774926
Tip
Base CI only covers the core cases, and its workload may not exercise your change. Use
/rerun-testto run the tests your change touches, or copy this prompt for an agent: