Skip to content

Commit 47023e4

Browse files
committed
feat: custom gathered-expert MoE combine for silu/FP8
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>
1 parent b85e1c5 commit 47023e4

2 files changed

Lines changed: 187 additions & 6 deletions

File tree

vllm_gaudi/ops/hpu_fp8.py

Lines changed: 91 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import os
12
from functools import partial
23
from typing import Optional
34

@@ -24,6 +25,68 @@
2425
)
2526

2627

28+
# EXPERIMENTAL custom MoE combine: replace the Habana mixture_of_experts op (a
29+
# fixed per-layer stage pipeline) with a pure-PyTorch gathered-expert path.
30+
# Default stock. `HPU_MOE_GATHER_VERIFY=1` runs BOTH the custom path and the
31+
# Habana op on the same inputs and records FP8-ULP per layer.
32+
_HPU_MOE_GATHER = bool(os.environ.get("HPU_MOE_GATHER"))
33+
_HPU_MOE_GATHER_VERIFY = bool(os.environ.get("HPU_MOE_GATHER_VERIFY"))
34+
# Max tokens*topk (== gathered-expert count g) for which the custom gather path
35+
# is used. The gathered pure-PyTorch path wins below ~g=64 and LOSES to the stock
36+
# fused op once g approaches E (the dense gather + fp32 bmm path is slower than
37+
# the Habana op).
38+
_HPU_MOE_GATHER_MAX_TP = int(os.environ.get("HPU_MOE_GATHER_MAX_TP", "64"))
39+
if _HPU_MOE_GATHER:
40+
from vllm_gaudi.ops.hpu_moe_combine import gather_silu_fp8_moe # noqa: E402
41+
else:
42+
gather_silu_fp8_moe = None
43+
44+
_HPU_MOE_GATHER_VERIFY_DIR = os.environ.get("HPU_MOE_GATHER_VERIFY_DIR")
45+
_HPU_MOE_GATHER_VERIFY_LAYERS = int(os.environ.get("HPU_MOE_GATHER_VERIFY_LAYERS", "40"))
46+
47+
48+
def _verify_rank() -> int:
49+
"""TP/expert-parallel rank for namespacing VERIFY captures (multi-rank runs
50+
would otherwise collide on identical filenames). Falls back to env, then 0."""
51+
try:
52+
if torch.distributed.is_available() and torch.distributed.is_initialized():
53+
return torch.distributed.get_rank()
54+
except Exception:
55+
pass
56+
for _k in ("LOCAL_RANK", "RANK"):
57+
_v = os.environ.get(_k)
58+
if _v is not None:
59+
try:
60+
return int(_v)
61+
except ValueError:
62+
pass
63+
return 0
64+
65+
66+
def _record_moe_combine_ulp(stock, custom, topk_ids):
67+
"""Save (stock, custom) output pairs for offline FP8-ULP comparison.
68+
69+
Only called in verify mode (HPU_MOE_GATHER_VERIFY=1). The outputs are tiny
70+
([T,H] bf16), so torch.save here is cheap; the dynamo graph-break it causes
71+
is acceptable for a validation run. All env values are module-level constants
72+
so the non-verify compiled path stays fully specializable. Filenames are
73+
rank-scoped so TP/expert-parallel ranks don't overwrite each other.
74+
"""
75+
if not _HPU_MOE_GATHER_VERIFY_DIR:
76+
return
77+
_n = topk_ids.shape[0]
78+
_r = _verify_rank()
79+
_cnt = getattr(_record_moe_combine_ulp, "_cnt", {})
80+
if _cnt.get(_n, 0) < _HPU_MOE_GATHER_VERIFY_LAYERS:
81+
os.makedirs(_HPU_MOE_GATHER_VERIFY_DIR, exist_ok=True)
82+
_c = _cnt.get(_n, 0)
83+
torch.save({"stock": stock.detach().cpu(), "custom": custom.detach().cpu(),
84+
"T": _n, "rank": _r},
85+
os.path.join(_HPU_MOE_GATHER_VERIFY_DIR, f"moecomb_T{_n}_n{_c}_r{_r}.pt"))
86+
_cnt[_n] = _c + 1
87+
_record_moe_combine_ulp._cnt = _cnt
88+
89+
2790
class HPUPerTensorTorchFP8ScaledMMLinearKernel(PerTensorTorchFP8ScaledMMLinearKernel):
2891

2992
@classmethod
@@ -268,13 +331,35 @@ def apply_monolithic(
268331
topk_ids = topk_ids.view(-1, topk_ids.shape[-1])
269332
topk_weights = topk_weights.view(-1, topk_weights.shape[-1])
270333

271-
output = layer.moe_op(
272-
x,
273-
topk_ids,
274-
topk_weights,
275-
permuted_weights=True,
276-
activation=_normalize_moe_activation(layer.activation),
334+
activation = _normalize_moe_activation(layer.activation)
335+
# Use the custom gathered-expert combine only when it wins: g = tokens*K
336+
# must stay small (below the dense crossover). Beyond that (large batch /
337+
# long prefill) fall back to the stock fused op, which is faster and keeps
338+
# the graph shapes fixed. `tokens`/`K` are static (T, K from x/topk_ids).
339+
use_gather = (
340+
_HPU_MOE_GATHER
341+
and activation == "silu"
342+
and x.shape[0] * topk_ids.shape[-1] <= _HPU_MOE_GATHER_MAX_TP
277343
)
344+
if use_gather:
345+
# EXPERIMENTAL custom combine: gather only the routed experts
346+
# (bypasses the Habana op's fixed per-layer stage pipeline).
347+
if _HPU_MOE_GATHER_VERIFY:
348+
stock = layer.moe_op(x, topk_ids, topk_weights,
349+
permuted_weights=True, activation=activation)
350+
custom = gather_silu_fp8_moe(layer, x, topk_ids, topk_weights)
351+
_record_moe_combine_ulp(stock, custom, topk_ids)
352+
output = custom
353+
else:
354+
output = gather_silu_fp8_moe(layer, x, topk_ids, topk_weights)
355+
else:
356+
output = layer.moe_op(
357+
x,
358+
topk_ids,
359+
topk_weights,
360+
permuted_weights=True,
361+
activation=activation,
362+
)
278363
return output.view(*(output.size(0), *input_shape[1:]))
279364

280365

vllm_gaudi/ops/hpu_moe_combine.py

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
# SPDX-License-Identifier: Apache-2.0
2+
"""Pure-PyTorch gathered-expert MoE combine for silu + FP8-per-channel weights.
3+
4+
Replaces the Habana ``mixture_of_experts`` combine (a fixed per-layer launch
5+
pipeline) with a leaner active-expert gather + GEMM + weighted-reduce path,
6+
mirroring ``vllm_gaudi.ops.hpu_fused_moe._gather_swigluoai_moe`` but for a silu
7+
gated activation and the FP8 per-channel weight layout
8+
(``extension/ops.py:fp8_channel_moe_prepare_weights``).
9+
10+
Only ``g = min(E_local, tokens * K)`` distinct routed experts are read from HBM
11+
and computed (<= 8 at BS=1 for K=8), instead of the Habana op's fixed stage
12+
pipeline. The gathered count is static (no ``torch.nonzero``/host branch), so the
13+
path captures cleanly into a compiled HPU graph.
14+
15+
Numeric fidelity: ``x`` and the weights are FP8; ``x`` is quantized the same way
16+
the op does (``dynamic_quant``), everything is dequantized to fp32, and the two
17+
GEMMs + silu + weighted sum accumulate in fp32, rounding to the output dtype only
18+
at the end. Exact bit-equality with the black-box op is NOT expected; correctness
19+
is assessed via an FP8-ULP bar.
20+
"""
21+
from __future__ import annotations
22+
23+
import torch
24+
25+
26+
def _dynamic_quant(data):
27+
# import lazily to avoid pulling heavy deps at module import
28+
from vllm_gaudi.extension.ops import dynamic_quant
29+
return dynamic_quant(data)
30+
31+
32+
def gather_silu_fp8_moe(layer, x, topk_ids, topk_weights, activation="silu"):
33+
"""x [T,H] bf16 -> MoE output [T,H] bf16.
34+
35+
topk_ids [T,K] int64 (global expert ids), topk_weights [T,K] bf16.
36+
Mirrors the Habana op's data flow: fp8-quantize x, per-token weighted sum
37+
over the routed experts, each expert computed as silu(w13 x) w2.
38+
"""
39+
assert activation == "silu", f"custom combine supports silu, got {activation}"
40+
41+
T, H = x.shape
42+
K = topk_ids.shape[-1]
43+
w13 = layer.w13_weight # [E, 2I, H] fp8
44+
w2 = layer.w2_weight # [E, H, I] fp8
45+
s13 = layer.w13_weight_scale_inv # [E, 2I]
46+
s2 = layer.w2_weight_scale_inv # [E, H]
47+
E = w13.shape[0]
48+
I = w13.shape[1] // 2
49+
50+
# ---- FP8-quantize x per token (match the op's input quantization) ----
51+
x_fp8, x_scale = _dynamic_quant(x) # x_fp8 [T,H], x_scale [T,1] f32
52+
x_f = x_fp8.to(torch.float32) * x_scale # [T,H] f32
53+
54+
# ---- per-token expert combine weights (scatter over E) ----
55+
# topk_ids are GLOBAL expert ids; remap to this rank's local ids and mask
56+
# experts owned by other EP ranks (they contribute +0.0 to this rank's
57+
# partial, which is then reduce-scattered by the runner). At TP=1
58+
# ep_rank=0 -> experts_min=0, identical to the unremapped path.
59+
experts_min = int(layer.moe_config.ep_rank * layer.local_num_experts)
60+
local_ids = topk_ids - experts_min # [T,K]
61+
in_range = (local_ids >= 0) & (local_ids < E)
62+
safe_ids = torch.where(in_range, local_ids, torch.zeros_like(local_ids))
63+
safe_w = torch.where(in_range, topk_weights, torch.zeros_like(topk_weights)).to(torch.float32)
64+
gate_w = x.new_zeros(T, E, dtype=torch.float32)
65+
gate_w.scatter_add_(1, safe_ids, safe_w) # [T,E] f32
66+
67+
# ---- static gathered-expert count (>= # distinct hit experts) ----
68+
# The number of distinct routed experts is provably <= tokens * K, so with
69+
# g = min(E, tokens*K) every real hit is included and the extra (zero-weight)
70+
# padding experts contribute exactly +0.0 to the weighted sum. Keeping `g`
71+
# static (no torch.nonzero, no `if G == 0`) lets this capture cleanly into a
72+
# compiled HPU graph (mirrors _gather_swigluoai_moe).
73+
g = min(E, T * K)
74+
hit = (gate_w != 0).float().sum(0) # [E]
75+
gather_ids = torch.topk(hit, g, sorted=False).indices # [G]
76+
gather_ids, _ = torch.sort(gather_ids) # ascending ids
77+
78+
# ---- gather only the active experts' weights + per-channel scales ----
79+
w13_g = w13.index_select(0, gather_ids) # [G, 2I, H] fp8
80+
w2_g = w2.index_select(0, gather_ids) # [G, H, I] fp8
81+
s13_g = s13.index_select(0, gather_ids) # [G, 2I]
82+
s2_g = s2.index_select(0, gather_ids) # [G, H]
83+
w13_f = w13_g.to(torch.float32) * s13_g.unsqueeze(-1) # [G, 2I, H] f32
84+
w2_f = w2_g.to(torch.float32) * s2_g.unsqueeze(-1) # [G, H, I] f32
85+
86+
# ---- per-expert MLP ----
87+
xe = x_f.unsqueeze(0).expand(g, T, H) # [G, T, H]
88+
h = torch.bmm(xe, w13_f.transpose(1, 2)) # [G, T, 2I] f32
89+
gate, up = h[..., :I], h[..., I:]
90+
act = gate * torch.sigmoid(gate) * up # silu(gate)*up, [G,T,I]
91+
y = torch.bmm(act, w2_f.transpose(1, 2)) # [G, T, H] f32
92+
93+
# ---- weighted sum over experts -> [T, H] ----
94+
gate_wg = gate_w.index_select(1, gather_ids).t() # [G, T]
95+
out = (y * gate_wg.unsqueeze(-1)).sum(0) # [T, H] f32
96+
return out.to(x.dtype)

0 commit comments

Comments
 (0)