Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
65e7539
feat: custom gathered-expert MoE combine for silu/FP8
NatTuck Aug 13, 2026
358964f
feat: native-fp8 wide-GEMM up-projection for MoE combine
NatTuck Aug 14, 2026
0b83b5a
Merge remote-tracking branch 'origin/main' into feature/fast-fp8-moe-…
NatTuck Aug 15, 2026
669335c
Merge branch 'main' into feature/fast-fp8-moe-combine
iboiko-habana Aug 19, 2026
5ae760f
Merge branch 'main' into feature/fast-fp8-moe-combine
iboiko-habana Aug 21, 2026
07834df
fix: VLLM_-prefix custom FP8 MoE gather env vars (PR review)
NatTuck Aug 22, 2026
3da402b
fix: in-memory EP verify for FP8 MoE gather combine, drop filesystem …
NatTuck Aug 26, 2026
c52f51e
Merge branch 'main' into feature/fast-fp8-moe-combine
iboiko-habana Aug 26, 2026
b9f9375
fix: keep static-FP8 MoE on stock path (skip gathered-expert combine)
NatTuck Aug 26, 2026
b8a4f8e
Merge feature/fast-fp8-moe-combine from nat remote
NatTuck Aug 26, 2026
1ae293c
Merge branch 'main' into feature/fast-fp8-moe-combine
iboiko-habana Aug 27, 2026
b6a63d7
fix: silence mypy None-assignment error in FP8 MoE gather gate
NatTuck Aug 27, 2026
6149ca4
Merge branch 'main' into feature/fast-fp8-moe-combine
iboiko-habana Aug 29, 2026
a5eadb2
Run pre-commit for formatting.
NatTuck Aug 30, 2026
29b54b5
fix: gate gather on per-channel scale layout; E4M3 out-of-range verif…
NatTuck Sep 5, 2026
6f7bbbf
Merge origin/main into feature/fast-fp8-moe-combine
NatTuck Sep 5, 2026
29ed8d6
fix: reparameterize MoE gather gate from MAX_TP to GATHER_RATIO
NatTuck Sep 6, 2026
6162ef0
fix: keep out-of-range FP8 verify signal separate from in-range ULP (…
NatTuck Sep 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions docs/configuration/env_variables.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,31 @@ This document lists the supported diagnostic and profiling, as well as performan
| `VLLM_MINIMAX_M3_MOE_DECODE_GATHER` | Enables the MiniMax-M3 routed-expert gather path for low-token decode. Set to `0` or `false` to use the dense expert path. | `true` |
| `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. Verification runs both the custom and stock
paths and reduces their maximum FP8-ULP over the expert-parallel group in-memory
without writing model-derived tensors to disk. Note that verify mode adds a
**per-layer host sync** (``max_ulp.item()`` on CPU during every forward pass),
so it must not be enabled on performance runs.

The default `VLLM_HPU_MOE_GATHER_RATIO` of `0.4` is based on a crossover sweep
across the Qwen 3.5 MoE family (35B / 122B / 397B) at several expert-parallel
levels; this optimization has only been observed to help that family. The win/loss
cutoff most closely tracks the gathered-to-local-experts ratio and lands around
this value, so raise it only if you have measured the gather path to still win at
higher ratios on your model/config, and lower it for configs where it loses sooner
(e.g. high-EP deployments with wide experts).

| 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 ratio is exceeded. | `false` |
| `VLLM_HPU_MOE_GATHER_RATIO` | Fraction of this rank's `local_experts` up to which the custom gather path is used. The gathered count is `min(local_experts, tokens * top_k)`; above `ratio * local_experts` the stock fused op is used. | `0.4` |
| `VLLM_HPU_MOE_GATHER_VERIFY` | Runs both the custom and stock paths and reduces their maximum FP8-ULP across the EP group in-memory. Logs an info line at startup when enabled and warns if any element exceeds 2 ULP. Requires `VLLM_HPU_MOE_GATHER`. | `false` |

Use `VLLM_BUCKETING_STRATEGY=exp` for the default exponential warm-up, `VLLM_BUCKETING_STRATEGY=lin` for explicitly configured linear ranges, or `VLLM_BUCKETING_STRATEGY=pad` for padding-aware ranges with absolute and relative padding limits.

Leave `VLLM_EXPONENTIAL_BUCKETING` unset when using `VLLM_BUCKETING_STRATEGY`. The legacy flag is checked for backward compatibility and still overrides the selected strategy when present.
Expand Down
21 changes: 21 additions & 0 deletions vllm_gaudi/envs.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@
VLLM_MINIMAX_M3_MOE_DECODE_GATHER: bool = True
VLLM_MINIMAX_M3_MOE_GATHER_MAX_TOKENS: int = 16
VLLM_MM_WARMUP_OUTSIDE_COMPILE_ONLY: bool = False
VLLM_HPU_MOE_GATHER: bool = False
VLLM_HPU_MOE_GATHER_RATIO: float = 0.4
VLLM_HPU_MOE_GATHER_VERIFY: bool = False
VLLM_COMPACT_GDN: bool = False

# The begin-* and end* here are used by the documentation generator
Expand Down Expand Up @@ -89,6 +92,24 @@
"VLLM_MM_WARMUP_OUTSIDE_COMPILE_ONLY":
lambda: os.environ.get("VLLM_MM_WARMUP_OUTSIDE_COMPILE_ONLY", "false").strip().lower() in ("1", "true"),

# EXPERIMENTAL custom gathered-expert FP8 MoE combine (silu only).
# Off by default; falls back to the stock Habana fused op when disabled or
# when the number of gathered experts (min(local_experts, tokens*top_k))
# would exceed this fraction of this rank's local_experts. Parameterizing on
# the ratio to local_experts (rather than an absolute count) keeps the gate
# sane across EP configs, which change local_experts.
#
# Default rationale (0.4): this optimization has only been observed to help
# the Qwen 3.5 MoE family. A crossover sweep across Qwen3.5 35B/122B/397B at
# several EP levels shows the custom-gather win/loss cutoff tracks
# g/local_experts most closely, and it lands around this ratio.
"VLLM_HPU_MOE_GATHER":
lambda: os.environ.get("VLLM_HPU_MOE_GATHER", "0").lower() in ("1", "true"),
"VLLM_HPU_MOE_GATHER_RATIO":
lambda: float(os.environ.get("VLLM_HPU_MOE_GATHER_RATIO", "0.4")),
"VLLM_HPU_MOE_GATHER_VERIFY":
lambda: os.environ.get("VLLM_HPU_MOE_GATHER_VERIFY", "0").lower() in ("1", "true"),

# Use the compact recurrent-state (conv/ssm) layout for gated delta net
# models. The model runner auto-detects and sets this during init, so read
# it lazily rather than caching it at import time.
Expand Down
139 changes: 132 additions & 7 deletions vllm_gaudi/ops/hpu_fp8.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_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).

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):

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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"

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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:]))


Expand Down
Loading