-
Notifications
You must be signed in to change notification settings - Fork 151
feat: custom gathered-expert MoE combine for silu/FP8 (specifically Qwen 3.5 family) #1731
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
65e7539
358964f
0b83b5a
669335c
5ae760f
07834df
3da402b
c52f51e
b9f9375
b8a4f8e
1ae293c
b6a63d7
6149ca4
a5eadb2
29b54b5
6f7bbbf
29ed8d6
6162ef0
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,6 +2,8 @@ | |
| from typing import Optional | ||
|
|
||
| import torch | ||
| from vllm.distributed import get_ep_group | ||
| from vllm.logger import init_logger | ||
| from vllm_gaudi import envs | ||
| from torch.nn.parameter import Parameter | ||
| from vllm.model_executor.layers.fused_moe.layer import FusedMoEFactory as FusedMoE | ||
|
|
@@ -23,6 +25,99 @@ | |
| ChannelWiseTorchFP8ScaledMMLinearKernel, | ||
| ) | ||
|
|
||
| logger = init_logger(__name__) | ||
|
|
||
| # EXPERIMENTAL custom MoE combine: replace the Habana mixture_of_experts op (a | ||
| # fixed per-layer stage pipeline) with a pure-PyTorch gathered-expert path. | ||
| # 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 reduces their | ||
| # maximum FP8-ULP over the expert-parallel group in-memory (no files). | ||
| _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 | ||
| # Correctness bar for the verify path: any element exceeding this many FP8-ULP | ||
| # is an error (matches the archived offline analysis' "no element > 2 ULP" bar). | ||
| _HPU_MOE_GATHER_VERIFY_MAX_ULP = 2 | ||
| # Fraction of this rank's local_experts up to which the custom gather path is | ||
| # used. The gathered pure-PyTorch path reads only the routed experts, so it wins | ||
| # while the gathered count g stays well below local_experts (stock must touch all | ||
| # of them); it loses once g approaches local_experts (the dense gather + fp32 bmm | ||
| # path is slower than the Habana op). See envs.py for the default rationale. | ||
| _HPU_MOE_GATHER_RATIO = envs.VLLM_HPU_MOE_GATHER_RATIO | ||
| if _HPU_MOE_GATHER: | ||
| from vllm_gaudi.ops.hpu_moe_combine import gather_silu_fp8_moe # noqa: E402 | ||
| else: | ||
| gather_silu_fp8_moe = None # type: ignore[assignment] | ||
|
|
||
| if _HPU_MOE_GATHER_VERIFY: | ||
| logger.info("MoE gather combine VERIFY mode enabled: comparing custom vs stock " | ||
| "per layer (FP8-ULP bar = %d)", _HPU_MOE_GATHER_VERIFY_MAX_ULP) | ||
|
|
||
|
|
||
| def _fp8_ulp(a: torch.Tensor, b: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]: | ||
| """Sign-aware FP8-ULP distance + out-of-range mask. | ||
|
|
||
| Same-sign pairs use real E4M3 ulps: |uint8(quantize(a)) - uint8(quantize(b))|. | ||
| In E4M3FN adjacent same-sign representable values differ by exactly +/-1 in | ||
| uint8, so this IS the representable-step count. Cross-sign pairs use | ||
| universal subnormal units |a - b| / 2^-9. Returns elementwise Float32 ulps | ||
| (set to 0.0 wherever either input is out of range) plus an ``out_of_range`` | ||
| bool mask flagging those positions. | ||
|
|
||
| Elements above the E4M3 maximum representable value (448) saturate to | ||
| ``0x7F`` / ``0xFF`` (NaN/inf codes) during the uint8 cast and would falsely | ||
| compare as 0 ULP, so the caller must not fold them into the finite-ULP bar; | ||
| they are handled separately via ``out_of_range``. | ||
| """ | ||
| a_fp32 = a.float() | ||
| b_fp32 = b.float() | ||
| e4m3_max = 448.0 | ||
| out_of_range = (a_fp32.abs() > e4m3_max) | (b_fp32.abs() > e4m3_max) | ||
| a_bits = a_fp32.to(torch.float8_e4m3fn).view(torch.uint8).int() | ||
| b_bits = b_fp32.to(torch.float8_e4m3fn).view(torch.uint8).int() | ||
| same_sign = (a_bits >= 128) == (b_bits >= 128) | ||
| same_sign_ulp = (a_bits - b_bits).abs().float() | ||
| cross_ulp = (a_fp32 - b_fp32).abs() / (2.0**-9) | ||
| ulp = torch.where(same_sign, same_sign_ulp, cross_ulp) | ||
| ulp = torch.where(out_of_range, torch.zeros_like(ulp), ulp) | ||
| return ulp, out_of_range | ||
|
|
||
|
|
||
| # Relative-error bar for out-of-range elements (|a-b| / (|a|+|b|)). The | ||
| # denominator is at least the E4M3 max (448) out of range, so this cannot | ||
| # false-positive on small in-range values. | ||
| _HPU_MOE_GATHER_VERIFY_REL_ERR = 0.05 | ||
|
|
||
|
|
||
| def _verify_moe_combine(stock: torch.Tensor, custom: torch.Tensor) -> None: | ||
| ulp, out_of_range = _fp8_ulp(stock, custom) | ||
| max_in_range_ulp = ulp.amax() | ||
| # Out-of-range positions cannot be compared in ULP (the fp8 cast saturates | ||
| # them), so measure their divergence as a relative error instead. Both paths | ||
| # agreeing out of range (rel_err 0) stays quiet. | ||
| a_fp32 = stock.float() | ||
| b_fp32 = custom.float() | ||
| denom = a_fp32.abs() + b_fp32.abs() | ||
| rel_err = ((a_fp32 - b_fp32).abs() / denom.clamp(min=1e-9)).where(out_of_range, torch.zeros_like(a_fp32)) | ||
| max_out_of_range_rel = rel_err.amax() | ||
| reduced = torch.stack([max_in_range_ulp, max_out_of_range_rel]) | ||
| if torch.distributed.is_available() and torch.distributed.is_initialized(): | ||
| ep_group = get_ep_group() | ||
| if ep_group.world_size > 1: | ||
| torch.distributed.all_reduce( | ||
| reduced, | ||
| op=torch.distributed.ReduceOp.MAX, | ||
| group=ep_group.device_group, | ||
| ) | ||
| max_in_range_ulp, max_out_of_range_rel = reduced[0], reduced[1] | ||
| if max_out_of_range_rel.item() > _HPU_MOE_GATHER_VERIFY_REL_ERR: | ||
| logger.warning( | ||
| "MoE gather combine: out-of-range element(s) (value > 448, " | ||
| "outside E4M3 finite range) diverge by relative error %s", max_out_of_range_rel.item()) | ||
| if max_in_range_ulp.item() > _HPU_MOE_GATHER_VERIFY_MAX_ULP: | ||
| logger.warning("MoE gather combine mismatch: max FP8-ULP = %s exceeds %d", max_in_range_ulp.item(), | ||
| _HPU_MOE_GATHER_VERIFY_MAX_ULP) | ||
|
|
||
|
|
||
| class HPUPerTensorTorchFP8ScaledMMLinearKernel(PerTensorTorchFP8ScaledMMLinearKernel): | ||
|
|
||
|
|
@@ -164,6 +259,14 @@ def __init__(self, quant_config: Fp8Config, layer: torch.nn.Module): | |
| # Snapshot the (static) quant-config flag while the vLLM config context | ||
| # is set; the forward hot path reads this cached value instead. | ||
| self.has_moe_quant_config = model_has_quant_config() | ||
| # The custom gathered-expert path reads per-channel scales | ||
| # (w13_weight_scale_inv / w2_weight_scale_inv, shape [E, 2I] / [E, H]), | ||
| # which exist only when block_quant + force_channel_fp8 are true | ||
| # (fp8_block_moe_prepare_weights branch in process_weights_after_loading). | ||
| # Non-block FP8 checkpoints use a per-tensor scale (w13_weight_scale, | ||
| # shape [E, 2]) and block FP8 without force_channel_fp8 uses block-shaped | ||
| # scales; both would crash or silently mis-scale in gather_silu_fp8_moe. | ||
| self._moe_gather_ok = self.block_quant and envs.VLLM_HPU_FORCE_CHANNEL_FP8 | ||
|
|
||
| @property | ||
| def is_monolithic(self) -> bool: | ||
|
|
@@ -268,13 +371,35 @@ def apply_monolithic( | |
| topk_ids = topk_ids.view(-1, topk_ids.shape[-1]) | ||
| topk_weights = topk_weights.view(-1, topk_weights.shape[-1]) | ||
|
|
||
| output = layer.moe_op( | ||
| x, | ||
| topk_ids, | ||
| topk_weights, | ||
| permuted_weights=True, | ||
| activation=_normalize_moe_activation(layer.activation), | ||
| ) | ||
| activation = _normalize_moe_activation(layer.activation) | ||
| # Use the custom gathered-expert combine only when it wins: the number of | ||
| # distinct routed experts g = min(local_experts, tokens*K) must stay below | ||
| # a fraction of this rank's local_experts. Beyond that (large batch / long | ||
| # prefill, or few local experts) 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); local_num_experts is fixed per layer. | ||
| use_gather = (_HPU_MOE_GATHER and activation == "silu" and self.quant_config.activation_scheme != "static" | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The comment just above at Do you have the g-sweep behind
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 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 |
||
| and self._moe_gather_ok | ||
| and x.shape[0] * topk_ids.shape[-1] <= layer.local_num_experts * _HPU_MOE_GATHER_RATIO) | ||
| if use_gather: | ||
| # EXPERIMENTAL custom combine: gather only the routed experts | ||
| # (bypasses the Habana op's fixed per-layer stage pipeline). | ||
| if _HPU_MOE_GATHER_VERIFY: | ||
| stock = layer.moe_op(x, topk_ids, topk_weights, permuted_weights=True, activation=activation) | ||
| custom = gather_silu_fp8_moe(layer, x, topk_ids, topk_weights) | ||
| _verify_moe_combine(stock, custom) | ||
| del stock | ||
| output = custom | ||
| else: | ||
| output = gather_silu_fp8_moe(layer, x, topk_ids, topk_weights) | ||
| else: | ||
| output = layer.moe_op( | ||
| x, | ||
| topk_ids, | ||
| topk_weights, | ||
| permuted_weights=True, | ||
| activation=activation, | ||
| ) | ||
| return output.view(*(output.size(0), *input_shape[1:])) | ||
|
|
||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
_fp8_ulpcasts both tensors tofloat8_e4m3fnbefore 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: stock500vs custom50000returns 0 ULP, and stock448vs custom1e9returns 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 at0x7F, 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).