[FlyDSL] [CI] feat(mega_moe/gfx1250): quantize before dispatch, on an fp8 or fp4 wire - #4984
[FlyDSL] [CI] feat(mega_moe/gfx1250): quantize before dispatch, on an fp8 or fp4 wire#4984jhchouuu wants to merge 14 commits into
Conversation
Dispatch sends bf16 and every receiver quantizes each copy it gets, so a token
routed to topk peers is quantized topk times on the same values. Quantizing on
the sender does it once per LOCAL token and halves (fp8) or quarters (fp4) what
crosses the fabric; only the WMMA scale preshuffle has to stay on the receiver,
because its destination is the grouped row that rank assigns.
The wire must match what the expert GEMM wants for its A operand -- a8w4 -> fp8,
a4w4 -> fp4 -- so it is checked, not inferred: a mismatch is a row-width error
that would read into the next token's bytes rather than a slow path.
MegaMoEStage2Config.dispatch_wire: bf16 | fp8 | fp4 (mori backend only, which
is the one with a channel for the scale row). combine_token_nbytes stays bf16
and separate, so the combine slot stride does not follow the wire down.
The e8m0 row is padded to 128 B. Dword is all mori's validator asks for, but
TdmWholeOrSplit128 only yields a body for the part of a run that starts 128 B
aligned, and at the natural 224 B stride only every 4th token does.
mori is told hidden_dim in ELEMENTS at its own element size: fp8 and fp4 both
transport as byte8, so an fp4 wire halves the count itself.
Receiver side, the route gather learns a prequantized producer: it loads the
payload dwords and the e8m0 byte the quant pass would have computed, and the
store pass -- the only copy of the preshuffled-scale addressing -- is shared
verbatim. No software pipelining needed here: this tree's quant/store split
already lets the loads cluster.
test_mega_moe_gfx1250.py gains --mega_wire {auto,bf16,fp8,fp4}, allows
scatter_fused for a4w4_mxfp4, and drives AITER_FORCE_A8W4 off the quant key --
otherwise -q a4w4_mxfp4 silently measured a8w4.
Compile-checked only (no run): 8/8 route-gather variants, 6/6 mori dispatch
plans (bf16/fp8/fp4 x 8/16 warps) emit .hsaco, and the four host paths produce
the payload/scale widths the GEMM expects.
Three things kept an fp4 wire from ever being measurable, none of them in the wire itself. grouped_moe_gfx1250: mxfp4 weights arrive either as fp4x2 or as the uint8 view of the same bytes (ATOM's loader keeps them uint8; MegaMoE accepts both). Only the a8w4 arm said so, so a4w4-with-uint8-weights failed the eligibility test and fell through to the 2-stage mxfp4 kernels -- a silent detour to a different kernel family, which on this shape has no tuned config and aborts. The next statement already normalized both spellings for the CSV key, so the asymmetry was an oversight, not a rule. test harness: MegaMoE rejects anything but g1u1 interleave, while resolve_spec gave a4w4 the SEPARATED default, so -q a4w4_mxfp4 --combine scatter_fused could not start. Forcing INTERLEAVE alone then produced uncorrelated output, because shuffle_group still laid the weights out for the 2-stage family (e8m0_shuffle) rather than the grouped one (n32k4 + gguu->gugu rows) -- the MX keys differ only in ACTIVATION dtype, so under MegaMoE both take the grouped prep. run_matrix.sh: never check accuracy in a timed run. The fp32 reference is a per-expert torch loop; at 16384 tokens/rank it pins all four ranks for tens of minutes and reads as a hang. Correctness now runs once per wire at 256 tokens.
🏷️ CI GuideRuns automatically on every PR:
Extended tests (opt-in via labels):
PR title tags: |
The wire's e8m0 rows were being copied into a 128 B-strided buffer before dispatch, because mori laid a row down at exactly the width it was given and the alignment is what makes the transfer fast. mori now derives that stride itself, so the copy -- one extra kernel per dispatch, over every token's scales -- goes away and the quant op's output goes straight onto the wire. scale_nbytes is now what we SEND (hidden/32, packed); scale_dst_nbytes is what ARRIVES (mori's stride), and it is asked of mori rather than recomputed, so a change to the alignment cannot leave the two out of step.
50d147c to
d265dc6
Compare
Conflict was confined to the multi-GPU test: yanbo's a4w4 support landed upstream as ROCm#5052 in a revised form (gate_mode simplified, shuffle_group unified via an fp4x2 view, scatter_fused renamed fused, Communicator.init given per_rank_vmm). Resolved by taking main's file wholesale and re-applying only this PR's wire plumbing on top, so the renames stay consistent. AITER_FORCE_A8W4 is now driven by -q rather than setdefault, because the wire has to match the GEMM's A operand.
…ger exists ROCm#5052 narrowed --combine to base|fused; the CI line still says scatter_fused, so the job has been exiting on argparse rather than running the test.
Flipping it to mori was an out-of-scope behaviour change: a quantizing wire already requires the mori backend, and __post_init__ says so with a message naming the kwarg, so the default never had to move. The dataclass field default is unreachable anyway -- MegaMoEGfx1250.__init__ always supplies a value -- but the $MEGA_DISPATCH fallback beside it is live, and it decided the backend for every caller that sets neither.
…orked --dispatch_commu_dtype has had no effect since the file was created in ROCm#4785: resolve_spec wrote transport/prequant/is_fp8/fp8_dtype into the spec and nothing ever read them, quant_tokens_fp8() had no callers, and the base combine hardcodes transport_dtype = bf16. So this is not two overlapping knobs -- it is one live knob and one corpse. --mega_wire takes over the name (--dispatch_wire, matching the kwarg it feeds) and the dead half goes with the flag it belonged to. _FP8_DTYPE stays: the per_Token / per_128x128 weight quant still uses it.
…e dispatch wire Half the names this PR added already carried the direction (dispatch_token_nbytes, combine_token_nbytes); the other half did not, and a combine wire is coming. is_quant_wire is the one that mattered: it is a pure dispatch predicate with nine call sites, so 'if config.is_quant_wire' written inside _combine() would take the wrong branch AND RUN -- a bf16 reduce over fp8 bytes, wrong numbers, no error. _DispatchWire is deliberately not generalised: three of its four fields are structurally dispatch-only, so sharing the table would hand combine three dead ones.
… name is fatal Combine will want a wire of its own, so the unqualified name had to go while it is still unreleased. The stale name raises rather than falling back: an env var that is silently ignored sends a run that asked for fp4 down the bf16 path and reports nothing, which is exactly the failure a wire benchmark cannot survive. Setting both names to the same value is allowed, so a fleet can be rolled over one script at a time.
The parameter is the caller's per-token e8m0 rows; forty lines later the quant pass rebinds the same name to the preshuffled grouped scale. Both are uint8 with a plausible shape, so a wrong read is silent. The incoming one now has its own name and the rebinding introduces the grouped meaning exactly once.
Three renamed lines went past 88 columns, which the pre-checks job enforces. The env guard was also true of one caller only: the library evaluates it just when dispatch_wire is None, and the test always passes the kwarg, so the harness -- the launch path most likely to carry a stale MEGA_WIRE -- would have measured bf16 in silence. The helper is now public and the test's argparse default calls it, imported lazily so the mega package is not pulled in before FLYDSL_GPU_ARCH is set. _WIRE_FOR_QUANT picks up the dispatch prefix too; it was the same ambiguity the commit before this one exists to remove.
It carries geometry and the dispatch knobs and not one stage2 parameter. Stage2 in this package is the gemm2 epilogue fused into combine, which Stage2ScatterContext already names correctly and which takes nothing from here -- so the config was the only place the word was wrong. Name only: the fields, the flat kwargs and the public surface are unchanged, so no caller moves.
| for fp4 down the bf16 path and reports nothing, which is the one failure | ||
| mode a wire benchmark cannot survive. | ||
| """ | ||
| stale, current = os.environ.get("MEGA_WIRE"), os.environ.get("MEGA_DISPATCH_WIRE") |
There was a problem hiding this comment.
what about using one more readable name? These two looks quite similar.
There was a problem hiding this comment.
MEGA_WIRE is the old name, not a second knob, the live name is MEGA_DISPATCH_WIRE, it appears only in this guard. A launch script that still exports it fails loudly instead of silently serving bf16, and the reference goes away once those scripts are updated.
And these will also be refactor after dispatch + gemm1 were implemented.
cc @yanboshao
|
LGTM |
What
MegaMoE's EP dispatch can carry an already-quantized payload — fp8 or fp4 — instead of bf16.
The default is bf16, which is what every existing caller gets today.
Why
Dispatch ships bf16 and every receiver quantizes the copy it got, so a token routed to
topkpeers is quantizedtopktimes on the same values. Quantizing on the sender does it once per local token and shrinks the wire. At hidden 7168 a token crosses as:MX quant is destination-independent, so moving it changes no bytes — the numbers below are bit-identical, not "close enough".
Results
Module (
test_mega_moe_gfx1250.py, EP4, hidden 7168, topk 6, medians of 3 interleaved, ranges disjoint), per-layer wall clock at 16384 tokens/rank:512 tokens/rank is parity. Per-kernel attribution puts all of it in dispatch — 449 → 197 us/layer for fp4 — with both GEMMs and combine unchanged.
End to end, DSV4-Pro on 4x gfx1250, DP4+EP4, ISL/OSL 1024/1024, concurrency 2048, 8192 requests:
How it works
Sender.
per_1x32_mx_quant_hiponce per local token, into the wire dtype the GEMM wants. The wire must MATCH that dtype (a8w4 → fp8, a4w4 → fp4): a mismatch is a row-width error, not a slow path, so it is checked rather than inferred.Receiver. The route-gather kernel keeps its gather and its scale preshuffle and drops only the quant — the preshuffle cannot move to the sender, because its destination is the grouped row this rank assigns, which no sender knows. A branch inside
_emit_quant_block_looploads what the quant pass would have computed; the store pass, which owns the preshuffled-scale addressing, stays shared.Combine is untouched and still carries bf16.
combine_token_nbytesis split from the oldtoken_nbyteson purpose: deriving the combine slot stride from a now-wire-dependent token size would silently halve it while the rows staged for combine are still bf16.Naming. Everything that means the dispatch wire says so —
is_quant_dispatch_wire,dispatch_scale_nbytes,dispatch_scale_dst_nbytes,dispatch_wire_elem_count,_DispatchWire, and the arena regiondisp_out_scales._DispatchWireis deliberately not generalised: three of its four fields are structurally dispatch-only (mori carries no scales on a combine, a combine quant would happen inside the gemm2 epilogue on an LDS tile rather than host-side on a whole tensor, andrecv_dtypeexists only to build a torch view combine has no equivalent of), so sharing the table would hand a future combine wire three dead fields.Also fixed
a4w4 could not reach the grouped GEMM.
is_grouped_a4w4requiredq_dtype_w == fp4x2while the a8w4 arm also acceptedw1.dtype == uint8. mxfp4 weights arrive in both spellings (ATOM's loader keeps them uint8), so a4w4 with uint8 weights failed the eligibility test and fell through to the 2-stage mxfp4 kernels — a silent detour to a different kernel family, which on this shape has no tuned config and aborts.a1_scalenamed two different tensors ingrouped_gemm_gfx1250_a8w4: the parameter is the caller's per-token e8m0 rows, and the quant pass rebound the same name to the preshuffled grouped scale. Both are uint8 with a plausible shape, so a wrong read was silent.The gfx1250 MegaMoE CI job was not running. It passes
--combine scatter_fused, a value [UT] Support a4w4 in test_mega_moe #5052 removed, so it exited on argparse.Dependency
The quantizing wire needs a mori whose EP dispatch forwards a per-token scale row — ROCm/mori#593 (merged). The import is inside the property that needs it, so a bf16 wire works with any mori; asking for fp8/fp4 without one raises a message naming the PR.
Testing
4x gfx1250,
test_mega_moe_gfx1250.pyagainst an fp32 reference:The harness gains
--dispatch_wire {auto,bf16,fp8,fp4}and drivesAITER_FORCE_A8W4off the quant key, so-q a4w4_mxfp4cannot silently measure a8w4.Note for anyone with a launch script
MEGA_WIREwas the name during development and is nowMEGA_DISPATCH_WIRE, because combine will want a wire of its own. The old name raises rather than falling back: an env var that is silently ignored would send a run that asked for fp4 down the bf16 path and report nothing. Setting both names to the same value is allowed, so a fleet can be rolled over one script at a time.🤖 Generated with Claude Code