feat: custom gathered-expert MoE combine for silu/FP8 (specifically Qwen 3.5 family) - #1731
feat: custom gathered-expert MoE combine for silu/FP8 (specifically Qwen 3.5 family)#1731NatTuck wants to merge 18 commits into
Conversation
Replace the Habana mixture_of_experts combine (a fixed per-layer stage pipeline) with a pure-PyTorch gathered-expert path that reads only the routed experts, gated by HPU_MOE_GATHER. A tokens*topk guard falls back to the stock op beyond the low-batch win regime, and a gated FP8-ULP verify capture is included for correctness validation. Signed-off-by: Nat Tuck <nat@ferrus.net>
47023e4 to
65e7539
Compare
Replace the fp32 dequant bmm in the UP/GATE projection of the gathered-expert MoE combine with a single native-fp8 wide GEMM over concatenated expert weights (torch.ops.hpu.fp8_gemm_v2, fp32 internal accumulation, per-channel B_scale). x is shared across routed experts, so the G per-expert GEMMs collapse into one wide GEMM [T,H] x [H, G*2I]; gathered w13 is never dequantized to fp32. DOWN projection stays fp32 (silu output has a wide dynamic range that cannot be fp8-quantized within the 2-ULP bar). Body matches the validated experiments/moe_combine/moe_combine.py (commit 00d7838).
|
This should be up to date with main as of now. It doesn't look like this approach applies to Minimax M2.7, so this is just Qwen 3.5 family (including Qwen 3.6 35B-A3B and probably Qwen 3.8). I think this is ready for someone to take a look at it. |
iboiko-habana
left a comment
There was a problem hiding this comment.
@NatTuck thanks for contribution. Please apply next comments
-
Please keep general vllm env vars convention
All env vars in this plugin are prefixed with VLLM_ (e.g. VLLM_MINIMAX_M3_MOE_DECODE_GATHER, VLLM_HPU_FORCE_CHANNEL_FP8). The new knobs HPU_MOE_GATHER* don't follow this. Please rename them to VLLM_HPU_MOE_GATHER, VLLM_HPU_MOE_GATHER_MAX_TP, VLLM_HPU_MOE_GATHER_VERIFY, VLLM_HPU_MOE_GATHER_VERIFY_DIR, and VLLM_HPU_MOE_GATHER_VERIFY_LAYERS for consistency and discoverability. -
Register them in envs.py (add to the TYPE_CHECKING block and the environment_variables dict), and read them as envs.VLLM_HPU_MOE_GATHER in the op instead of raw os.environ.
i.e.
diff --git a/vllm_gaudi/envs.py b/vllm_gaudi/envs.py
--- a/vllm_gaudi/envs.py
+++ b/vllm_gaudi/envs.py
@@ -15,6 +15,11 @@ if TYPE_CHECKING:
VLLM_MINIMAX_M3_MOE_TOKEN_TILE: int = 512
VLLM_MINIMAX_M3_MOE_DECODE_GATHER: bool = True
VLLM_MINIMAX_M3_MOE_GATHER_MAX_TOKENS: int = 16
+ VLLM_HPU_MOE_GATHER: bool = False
+ VLLM_HPU_MOE_GATHER_MAX_TP: int = 64
+ VLLM_HPU_MOE_GATHER_VERIFY: bool = False
+ VLLM_HPU_MOE_GATHER_VERIFY_DIR: Optional[str] = None
+ VLLM_HPU_MOE_GATHER_VERIFY_LAYERS: int = 40
# The begin-* and end* here are used by the documentation generator
# to extract the used env vars.
@@ -78,6 +83,29 @@ environment_variables: dict[str, Callable[[], Any]] = {
"VLLM_MINIMAX_M3_MOE_GATHER_MAX_TOKENS":
lambda: int(os.environ.get("VLLM_MINIMAX_M3_MOE_GATHER_MAX_TOKENS", "16")),
+
+ # EXPERIMENTAL custom gathered-expert FP8 MoE combine (silu only).
+ # Off by default; falls back to the stock Habana fused op when disabled
+ # or when tokens*top_k exceeds VLLM_HPU_MOE_GATHER_MAX_TP.
+ "VLLM_HPU_MOE_GATHER":
+ lambda: os.environ.get("VLLM_HPU_MOE_GATHER", "0").lower() in ("1", "true"),
+
+ # Upper bound on tokens*top_k for which the custom gather path is used.
+ # Above this the stock fused op is faster and is used instead.
+ "VLLM_HPU_MOE_GATHER_MAX_TP":
+ lambda: int(os.environ.get("VLLM_HPU_MOE_GATHER_MAX_TP", "64")),
+
+ # Validation mode: run both the custom and stock paths and record output
+ # pairs for offline FP8-ULP comparison. Only effective together with
+ # VLLM_HPU_MOE_GATHER.
+ "VLLM_HPU_MOE_GATHER_VERIFY":
+ lambda: os.environ.get("VLLM_HPU_MOE_GATHER_VERIFY", "0").lower() in ("1", "true"),
+
+ # Directory for verify-mode output captures. If unset, nothing is written.
+ "VLLM_HPU_MOE_GATHER_VERIFY_DIR":
+ lambda: os.environ.get("VLLM_HPU_MOE_GATHER_VERIFY_DIR", None),
+
+ # Max captures saved per token count T (per rank) in verify mode.
+ "VLLM_HPU_MOE_GATHER_VERIFY_LAYERS":
+ lambda: int(os.environ.get("VLLM_HPU_MOE_GATHER_VERIFY_LAYERS", "40")),
}
# end-env-vars-definition
- Please read via envs instead of os.environ
diff --git a/vllm_gaudi/ops/hpu_fp8.py b/vllm_gaudi/ops/hpu_fp8.py
--- a/vllm_gaudi/ops/hpu_fp8.py
+++ b/vllm_gaudi/ops/hpu_fp8.py
@@ EXPERIMENTAL custom MoE combine header
-# Default stock. `HPU_MOE_GATHER_VERIFY=1` runs BOTH the custom path and the
-# Habana op on the same inputs and records FP8-ULP per layer.
-_HPU_MOE_GATHER = bool(os.environ.get("HPU_MOE_GATHER"))
-_HPU_MOE_GATHER_VERIFY = bool(os.environ.get("HPU_MOE_GATHER_VERIFY"))
-# Max tokens*topk (== gathered-expert count g) for which the custom gather path
-# is used. The gathered pure-PyTorch path wins below ~g=64 and LOSES to the stock
-# fused op once g approaches E (the dense gather + fp32 bmm path is slower than
-# the Habana op).
-_HPU_MOE_GATHER_MAX_TP = int(os.environ.get("HPU_MOE_GATHER_MAX_TP", "64"))
+# Default stock. `VLLM_HPU_MOE_GATHER_VERIFY=1` (with VLLM_HPU_MOE_GATHER=1)
+# runs BOTH the custom path and the Habana op on the same inputs and records
+# FP8-ULP per layer.
+import vllm_gaudi.envs as envs
+_HPU_MOE_GATHER = envs.VLLM_HPU_MOE_GATHER
+# Only meaningful together with the gather path.
+_HPU_MOE_GATHER_VERIFY = envs.VLLM_HPU_MOE_GATHER_VERIFY and _HPU_MOE_GATHER
+# Max tokens*topk (== gathered-expert count g) for which the custom gather path
+# is used. The gathered pure-PyTorch path wins below ~g=64 and LOSES to the stock
+# fused op once g approaches E (the dense gather + fp32 bmm path is slower than
+# the Habana op).
+_HPU_MOE_GATHER_MAX_TP = envs.VLLM_HPU_MOE_GATHER_MAX_TP
if _HPU_MOE_GATHER:
from vllm_gaudi.ops.hpu_moe_combine import gather_silu_fp8_moe # noqa: E402
else:
gather_silu_fp8_moe = None
-_HPU_MOE_GATHER_VERIFY_DIR = os.environ.get("HPU_MOE_GATHER_VERIFY_DIR")
-_HPU_MOE_GATHER_VERIFY_LAYERS = int(os.environ.get("HPU_MOE_GATHER_VERIFY_LAYERS", "40"))
+_HPU_MOE_GATHER_VERIFY_DIR = envs.VLLM_HPU_MOE_GATHER_VERIFY_DIR
+_HPU_MOE_GATHER_VERIFY_LAYERS = envs.VLLM_HPU_MOE_GATHER_VERIFY_LAYERS
- Please add description of new env vars to readme. i.e.
diff --git a/docs/configuration/env_variables.md b/docs/configuration/env_variables.md
--- a/docs/configuration/env_variables.md
+++ b/docs/configuration/env_variables.md
@@
| `VLLM_MINIMAX_M3_MOE_GATHER_MAX_TOKENS` | Maximum token count for the MiniMax-M3 routed-expert gather path. Larger batches use the dense expert path. | `16` |
+
+## Experimental: Custom FP8 MoE Gather Combine
+
+These variables control an **experimental** pure-PyTorch gathered-expert MoE
+combine for silu + FP8-per-channel weights, an alternative to the Habana
+`mixture_of_experts` op. It is off by default and intended for low-token
+(small batch / decode) workloads. The `VERIFY` knobs run both the custom and
+stock paths and dump output pairs for offline FP8-ULP comparison — use them
+only for validation runs, not in production (they double compute and force a
+graph break).
+
+| Parameter name | Description | Default value |
+| ----------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------- |
+| `VLLM_HPU_MOE_GATHER` | Enables the custom gathered-expert FP8 MoE combine (silu only). Falls back to the stock fused op when disabled or when the gather crossover is exceeded. | `false` |
+| `VLLM_HPU_MOE_GATHER_MAX_TP` | Upper bound on `tokens * top_k` for which the custom gather path is used. Above this the stock fused op is faster and is used instead. | `64` |
+| `VLLM_HPU_MOE_GATHER_VERIFY` | Validation mode: runs both the custom and stock paths on the same inputs and records output pairs for offline FP8-ULP comparison. Requires `VLLM_HPU_MOE_GATHER`. | `false` |
+| `VLLM_HPU_MOE_GATHER_VERIFY_DIR` | Directory where verify-mode output pairs are saved (`moecomb_T<T>_n<n>_r<rank>.pt`). If unset, nothing is written even when verify mode is on. | `None` |
+| `VLLM_HPU_MOE_GATHER_VERIFY_LAYERS` | Maximum number of captures saved per token count `T` (per rank) in verify mode. | `40` |
- Please correct order is ("RANK", "LOCAL_RANK"): try the globally-unique value first, fall back to LOCAL_RANK only for single-node launchers that set only LOCAL_RANK, then 0 if neither is present.
for _k in ("RANK", "LOCAL_RANK"):
- Please make folder creation more safety
if not os.path.isdir(_HPU_MOE_GATHER_VERIFY_DIR):
try:
os.makedirs(_HPU_MOE_GATHER_VERIFY_DIR, exist_ok=True)
except Exception as e:
raise RuntimeError(
f"Failed to create MoE-gather verify output directory: "
f"{_HPU_MOE_GATHER_VERIFY_DIR}") from e
New review will be done after these changes
Address PR feedback on feature/fast-fp8-moe-combine: - Rename HPU_MOE_GATHER* to VLLM_HPU_MOE_GATHER* and register them in vllm_gaudi/envs.py (TYPE_CHECKING + environment_variables), reading via envs. instead of raw os.environ. - VERIFY mode now only effective together with VLLM_HPU_MOE_GATHER. - _verify_rank: try RANK (globally unique) before LOCAL_RANK, then 0. - Guard os.makedirs in the verify output-dir path with a RuntimeError. - Document the new env vars in docs/configuration/env_variables.md. Signed-off-by: nat
|
@NatTuck thanks for comments. PR can be changes in next way, it is onpy implementation proposal: The rank helper was also unnecessary and unreliable for this purpose. It used what replaced the filesystem capture with in-memory verification:
EP is used because each rank owns a local expert shard. This validates every |
…capture Replaces the filesystem-based VERIFY_DIR/VERIFY_LAYERS capture with an in-memory check that reduces the max FP8-ULP over the expert-parallel group (no files, no os.makedirs, no torch.save). Logs an info line at startup when verify is enabled and warns when any element exceeds 2 ULP. Also moves the silu gather combine into the package and removes the activation param per review. Signed-off-by: Nat Tuck <nat@ferrus.net>
|
@NatTuck please add 1 more critical fix, and CI will be run Problem However, HPUFp8MoEMethod also supports FP8 models configured with activation_scheme == "static". For those models, the existing Habana MoE path must use checkpoint-provided scales: Solution |
Signed-off-by: Nat Tuck <nat@ferrus.net>
|
CI run is failed on pre-commit issue: https://github.com/vllm-project/vllm-gaudi/actions/runs/33056998066/job/98466244312 For local pre-commit check please install and use next commands |
gather_silu_fp8_moe is imported only when VLLM_HPU_MOE_GATHER is set, but mypy sees the else-branch None assignment as conflicting with the inferred Callable type from the import. Annotate the stock fallback with a scoped type-ignore so the conditional import pattern type-checks (CI mypy-3.12). Co-authored-by: Agent Signed-off-by: Nat Tuck
|
@NatTuck please fix pre-commit issue |
Signed-off-by: Nat Tuck <nat@ferrus.net>
✅ CI PassedAll checks passed successfully against the following vllm commit: |
| num_topk = topk_ids.shape[-1] | ||
| w13 = layer.w13_weight # [E, 2I, H] fp8 | ||
| w2 = layer.w2_weight # [E, H, I] fp8 | ||
| scale13 = layer.w13_weight_scale_inv # [E, 2I] |
There was a problem hiding this comment.
gather_silu_fp8_moe reads layer.w13_weight_scale_inv / layer.w2_weight_scale_inv unconditionally, but under HPUFp8MoEMethod those parameters exist in only one configuration. Upstream names the parameter by layout (weight_scale_name = "weight_scale_inv" if self.block_quant else "weight_scale" in vLLM's layers/quantization/fp8.py), and the per-channel [E, 2I] / [E, H] form assumed here is created only at vllm_gaudi/extension/ops.py:1127-1128, i.e. by fp8_block_moe_prepare_weights with force_channel_fp8=True. fp8_channel_moe_prepare_weights guards with hasattr(layer, "w13_weight_scale_inv") at extension/ops.py:1146 for exactly that reason. Two reachable configurations break with VLLM_HPU_MOE_GATHER=1:
- non-block FP8 checkpoint (
weight_block_sizeabsent,activation_scheme: dynamic): the attribute never exists, so the first MoE layer raisesAttributeError. - block FP8 checkpoint with
VLLM_HPU_FORCE_CHANNEL_FP8=0, or withQUANT_CONFIGset (which forces that flag false atvllm_gaudi/envs.py:37-39): the attribute exists but holds block scales shaped[E, 2I/block_n, H/block_k], andreshape(-1)feeds them tofp8_gemm_v2whileB_scale_shapedeclares[G*2I].
Suggest extending the gate the same way the static-FP8 case was handled: capture self._moe_gather_ok = self.block_quant and envs.VLLM_HPU_FORCE_CHANNEL_FP8 in process_weights_after_loading (next to has_moe_quant_config) and require it in use_gather. Reading w13_weight_scale as a fallback would not work, since on a non-block checkpoint that scale is per tensor ([E, 2]), not per channel.
| """ | ||
| a_fp32 = a.float() | ||
| b_fp32 = b.float() | ||
| a_bits = a_fp32.to(torch.float8_e4m3fn).view(torch.uint8).int() |
There was a problem hiding this comment.
_fp8_ulp casts both tensors to float8_e4m3fn before differencing bit patterns, so every element above the E4M3 max of 448 collapses onto the NaN code (0x7F / 0xFF) and compares equal. Running this exact function on CPU torch 2.6: stock 500 vs custom 50000 returns 0 ULP, and stock 448 vs custom 1e9 returns 1 ULP. Both pass the 2-ULP bar, so verify mode is blind precisely on the large-magnitude activation outliers where an FP8 up-projection is most likely to diverge. The docstring's "adjacent same-sign representable values differ by exactly +/-1 in uint8" also stops holding at 0x7F, which is not a finite value.
Suggest flagging out-of-range elements rather than folding them into the ULP count, for example warn when (stock.abs() > 448) | (custom.abs() > 448) holds anywhere, or compute a relative error for those elements only (their denominator is at least 448, so this cannot false-positive on small values).
| # must stay small (below the dense crossover). Beyond that (large batch / | ||
| # long prefill) fall back to the stock fused op, which is faster and keeps | ||
| # the graph shapes fixed. `tokens`/`K` are static (T, K from x/topk_ids). | ||
| use_gather = (_HPU_MOE_GATHER and activation == "silu" and self.quant_config.activation_scheme != "static" |
There was a problem hiding this comment.
The comment just above at hpu_fp8.py:41-44 states the crossover as "LOSES to the stock fused op once g approaches E", but this gate only bounds tokens * top_k by 64 and never compares it against local_num_experts. Since g = min(local_experts, tokens * K) (hpu_moe_combine.py:90), any deployment whose local expert count is at or below 64 sits inside the gate with g == local_experts: a Mixtral-style FP8 MoE with 8 experts at top_k 2 always takes the custom path with every expert gathered, and a 512-expert model at EP=8 reaches g == 64 == local_num_experts at 8 tokens with top_k 8. Those are the cases your own note says the stock op wins, and they also pay the full [G, H, I] fp32 w2 dequant at hpu_moe_combine.py:137.
Do you have the g-sweep behind VLLM_HPU_MOE_GATHER_MAX_TP=64, and was it measured at a single local_num_experts? If the real crossover is g versus E_local, adding and x.shape[0] * topk_ids.shape[-1] < layer.local_num_experts to the gate would express it directly.
There was a problem hiding this comment.
Honestly, that was from an early test just with Qwen 3.6 35B on one HPU at concurrency 1.
I just did a longer test sweep over all three sizes (it looks like this just hits Qwen 3.5, so 35B, 122B, and 397B) and it looks like fraction of experts is, in fact, more related than the current gate. So I've swapped that in instead of the MAX_TP thing. Doesn't seem like it lines up perfectly, and the threshold isn't entirely clear, so it'll still be a tunable.
Now I've just got to wait for this one verification run to finish. Feature request: make vllm-gaudi load faster
|
|
||
|
|
||
| def _verify_moe_combine(stock: torch.Tensor, custom: torch.Tensor) -> torch.Tensor: | ||
| if not _HPU_MOE_GATHER_VERIFY: |
There was a problem hiding this comment.
Two small things, grouped:
- This early return is dead code: the only call site (
hpu_fp8.py:346-349) is already insideif _HPU_MOE_GATHER_VERIFY. It also makes the function return either the stock tensor or a scalar ULP depending on a flag, and the call site discards the result. Dropping the guard and annotating-> Nonewould be clearer. - Verify mode reads a device tensor on the host at
hpu_fp8.py:86(max_ulp > ...) once per MoE layer per forward, on top of running both paths. The docs table only mentions the extra compute, so worth stating the per-layer host sync there too so nobody enables it on a perf run.
…y; clean verify fn (PR review) Signed-off-by: nat
Replace VLLM_HPU_MOE_GATHER_MAX_TP (tokens*top_k absolute, default 64) with
VLLM_HPU_MOE_GATHER_RATIO (fraction of local_experts, default 0.4). The
arbitrary absolute threshold did not generalise across EP configs (which change
local_experts) or model sizes (which change per-expert width I). A crossover
sweep across Qwen3.5 35B/122B/397B at several EP levels showed the win/loss
cutoff tracks g/local_experts most closely, and the four measured configs land
around a 0.3-0.6 range, so 0.4 is the conservative compromise.
The ratio gate:
tokens * top_k <= local_num_experts * VLLM_HPU_MOE_GATHER_RATIO
gathered_experts = min(local_experts, tokens*top_k) is the number of distinct
routed experts the custom path reads from HBM. When this approaches
local_experts the custom path loses to the Habana fused op (the dense gather +
fp32 bmm overhead dominates), so the ratio gate naturally switches to stock.
Also update the documentation to note this optimization has only been observed
to help the Qwen 3.5/3.6 MoE family and the default ratio rationale.
Signed-off-by: Nat Tuck <nat@ferrus.net>
accc60c to
29ed8d6
Compare
| op=torch.distributed.ReduceOp.MAX, | ||
| group=ep_group.device_group, | ||
| ) | ||
| if max_ulp == float('inf'): |
There was a problem hiding this comment.
Thanks - the mask does close the blind spot above 448, but folding it into the same scalar opens a second one. _fp8_ulp now returns inf at out-of-range positions and _verify_moe_combine reduces with amax, so a single out-of-range element anywhere makes max_ulp infinite, this branch fires, and the elif that reports the actual ULP number never runs. Measured with this exact code on CPU torch 2.6:
stock = custom = [600.0, 1.0], bit-identical: warnsout-of-range element(s) detected.stock = [600.0, 1.0]vscustom = [600.0, 1.5]: same out-of-range warning, and the real in-range mismatch (4 ULP on that second element) is never printed.
So for any model whose MoE output holds one element above 448, verify mode emits the same message on every layer whether the two paths agree bit for bit or diverge badly inside the finite range. The docs row still promises "warns if any element exceeds 2 ULP", which is the part that stops being reachable.
Suggest keeping the two signals separate: have _fp8_ulp return finite ULPs plus the mask, take amax over in-range positions only, and reduce a 2-element tensor [max_in_range_ulp, any_out_of_range] in the one existing all_reduce. Both warnings can then fire independently and the numeric bar keeps working. If you would rather not hear about out-of-range elements when both paths agree on them, gate that warning on a relative error over those positions instead of on their presence.
| # from x/topk_ids); local_num_experts is fixed per layer. | ||
| use_gather = (_HPU_MOE_GATHER and activation == "silu" and self.quant_config.activation_scheme != "static" | ||
| and self._moe_gather_ok | ||
| and x.shape[0] * topk_ids.shape[-1] |
There was a problem hiding this comment.
The pinned yapf 0.43.0 hook wants this comparison joined onto one line - it fits inside the 120-column limit:
- and x.shape[0] * topk_ids.shape[-1]
- <= layer.local_num_experts * _HPU_MOE_GATHER_RATIO)
+ and x.shape[0] * topk_ids.shape[-1] <= layer.local_num_experts * _HPU_MOE_GATHER_RATIO)python -m yapf -d -r vllm_gaudi/ at this SHA flags this file as the only one in the tree needing a reformat, so pre-commit run --all-files --hook-stage manual will fail on it. ruff 0.11.7 and pymarkdown 0.9.29 are clean on the changed files.
…PR review) _verify_moe_combine folded the out-of-range flag into the same scalar as the max FP8-ULP by returning inf at saturated positions and reducing with amax, so a single element above the E4M3 max (448) made the mismatch branch unreachable: every layer warned about out-of-range presence whether the two paths agreed bit for bit or diverged inside the finite range. Separate the two signals: - _fp8_ulp now returns (ulp, out_of_range); out-of-range positions read as 0 in the finite ULP tensor so they no longer poison the amax. - _verify_moe_combine reduces [max_in_range_ulp, max_out_of_range_rel] through the single existing all_reduce and warns on each independently. Out-of-range positions (where the fp8 cast saturates, so ULP is meaningless) are compared by relative error instead; the denominator is >= 448 there, so agreeing out-of-range values stay silent. Signed-off-by: Nat Tuck <nat@ferrus.net>
This speeds up Qwen3.5/3.6 token generation significantly at low batch size on Gaudi 2 - like 2x on Qwen 3.6 35B-A3B at BS=1. Also speeds up Qwen3.5-122B (nearly 2x) and 397B (1.5x) significantly, with smaller gains as BS increases up to about BS=8.
Vibe-coded based on PR #1673 for Minimax M3. Specific to fp8, probably doesn't need to be. Gated on an env var that probably wants to get cleaned up.
I think there's more performance to be gained on these MoE models, and this probably also applies to stuff like Minimax M2, but this looks like a big enough win that it's worth sharing before I keep going.
This seems to work based from:
vllm-gaudi: 9cae8b6
[CI] Fix Scorecard publish break; move SARIF filter to a separate job (#1718)
vllm core: 0820125ae99f719f3cd8b0c26c0d3f9789e187e3
[Bugfix][Parser] Emit REASONING_END for Inkling tool calls that follow no thinking block (#50528)