# SPDX-License-Identifier: Apache-2.0 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project """MoE fuser: route an HF MoE block through `FusedMoE` with vLLM's own routing.""" import ast import inspect import textwrap import types from collections.abc import Iterator from dataclasses import dataclass from itertools import chain import torch from torch import fx, nn from vllm.distributed import tensor_model_parallel_all_gather from vllm.model_executor.layers.linear import ReplicatedLinear from vllm.model_executor.models.transformers.fx_utils import ( find_node, is_op, peel, trace, ) from vllm.model_executor.models.utils import maybe_prefix, sequence_parallel_chunk def named_state(module: nn.Module) -> Iterator[tuple[str, torch.Tensor]]: """`module`'s own state (i.e. named parameters and buffers).""" return chain(module.named_parameters(), module.named_buffers()) def _own_returns(node: ast.AST) -> Iterator[ast.Return]: """`return` statements in `node`'s own scope, not in nested functions.""" stack = list(ast.iter_child_nodes(node)) while stack: child = stack.pop() if isinstance(child, ast.Return): yield child elif not isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda)): stack.extend(ast.iter_child_nodes(child)) def _returns_tuple(cls: type[nn.Module]) -> bool: """Does `cls.forward()` return a tuple?""" try: source = textwrap.dedent(inspect.getsource(inspect.unwrap(cls.forward))) forward = ast.parse(source).body[0] except (OSError, SyntaxError, TypeError, IndexError): return True # Names bound to a tuple literal, e.g. `out = hidden, logits` then `return out`. tuple_names = { target.id for node in ast.walk(forward) if isinstance(node, ast.Assign) and isinstance(node.value, ast.Tuple) for target in node.targets if isinstance(target, ast.Name) } def yields_tuple(value: ast.expr | None) -> bool: if isinstance(value, ast.Tuple): return True if isinstance(value, ast.Name): return value.id in tuple_names if isinstance(value, ast.IfExp): return yields_tuple(value.body) or yields_tuple(value.orelse) return False return any(yields_tuple(ret.value) for ret in _own_returns(forward)) def _is_scalar_gate(module: nn.Module) -> bool: """A linear projecting to a single logit (the shared-expert sigmoid gate).""" weight = getattr(module, "weight", None) return ( isinstance(module, nn.Linear) and weight is not None and weight.ndim == 2 and weight.shape[0] == 1 ) def _reaches(node: fx.Node, key: str) -> set[fx.Node]: """Returns the set of nodes reachable from `node` by following `key` edges.""" seen: set[fx.Node] = set() stack = [node] while stack: n = stack.pop() if n in seen: continue seen.add(n) stack.extend(getattr(n, key)) return seen class SharedExpertMLP(nn.Module): """Wraps an HF shared expert, applying the output gating it is paired with.""" def __init__(self, shared_experts: nn.Module, gate: nn.Module | None = None): super().__init__() self.shared_experts = shared_experts self.gate = gate def forward(self, hidden_states: torch.Tensor) -> torch.Tensor: out = self.shared_experts(hidden_states) if self.gate is not None: out = torch.sigmoid(self.gate(hidden_states)[0]) * out return out def _moe_block_forward(self: nn.Module, hidden_states: torch.Tensor) -> torch.Tensor: """Standard MoE block forward. Routing and any shared experts are handled inside `self.experts: MoERunner`.""" orig_shape = hidden_states.shape hidden_states = hidden_states.reshape(-1, orig_shape[-1]) num_tokens = hidden_states.shape[0] is_sequence_parallel = self.experts.moe_config.is_sequence_parallel if is_sequence_parallel: hidden_states = sequence_parallel_chunk(hidden_states) out = self.experts(hidden_states, router_logits=hidden_states) if is_sequence_parallel: out = tensor_model_parallel_all_gather(out, 0)[:num_tokens] return out.reshape(orig_shape) @dataclass class MoEBlockFuser: """Fuser for MoE block `experts`, `gate` and `shared_experts` (optional).""" gate_name: str scoring_func: str shared_name: str | None shared_gate_name: str | None @staticmethod def _match_router(gate: nn.Module) -> str | None: """Matches `topk(score(linear(x)))`, `score` being `softmax`/`sigmoid`.""" if [name for name, _ in named_state(gate)] != ["weight"]: return None graph = trace(gate) if graph is None: return None topk = find_node(graph, lambda n: is_op(n, "topk")) if topk is None: return None # Exactly one scoring op upstream of the top-k, fed (transitively) by a linear. scorers = [ n for n in _reaches(topk, "all_input_nodes") if is_op(n, "softmax") or is_op(n, "sigmoid") ] if len(scorers) != 1: return None scorer = scorers[0] if not any(is_op(n, "linear") for n in _reaches(scorer, "all_input_nodes")): return None return "softmax" if is_op(scorer, "softmax") else "sigmoid" @staticmethod def _match_shared_experts( graph: fx.Graph, experts: str ) -> tuple[str | None, str | None]: """Detects the shared expert and its optional gate by dataflow.""" experts_predicate = lambda n: n.op == "call_module" and n.target == experts if (experts_node := find_node(graph, experts_predicate)) is None: return None, None from_experts = _reaches(experts_node, "users") for add in graph.nodes: if not is_op(add, "add"): continue operands = [a for a in add.args if isinstance(a, fx.Node)] # Exactly one side is the experts' output; the other is the shared path. sides = [a in from_experts for a in operands] if len(operands) != 2 or sides.count(True) != 1: continue cone = _reaches(operands[sides.index(False)], "all_input_nodes") modules = [n for n in cone if n.op == "call_module" and n.target != experts] # A sigmoid wrapping one of those modules marks the shared-expert gate. gate = next( ( src for n in cone if is_op(n, "sigmoid") and isinstance(src := peel(n.args[0]), fx.Node) and src in modules ), None, ) shared = [n for n in modules if n is not gate] if len(shared) != 1: return None, None return shared[0].target, (gate.target if gate is not None else None) return None, None @classmethod def match(cls, moe_block: nn.Module, experts_name: str) -> "MoEBlockFuser | None": # Standard MoE block returns a single tensor. if _returns_tuple(type(moe_block)): return None # Router: the child that scores + top-k selects. gate_name = scoring_func = None for name, child in moe_block.named_children(): if name != experts_name and (func := cls._match_router(child)) is not None: gate_name, scoring_func = name, func break if gate_name is None or scoring_func is None: return None # Shared expert: a child the block adds to the experts' output. shared_name = shared_gate_name = None others = [ n for n, _ in moe_block.named_children() if n not in {experts_name, gate_name} ] if others: graph = trace(moe_block) if graph is None: return None shared_name, shared_gate_name = cls._match_shared_experts( graph, experts_name ) if shared_gate_name is not None and not _is_scalar_gate( getattr(moe_block, shared_gate_name) ): return None # Fail closed: `rewrite_forward` runs only the experts and the detected # shared expert, so any other stateful child would be dropped. accounted = {experts_name, gate_name, shared_name, shared_gate_name} for name, child in moe_block.named_children(): if name not in accounted and next(named_state(child), None) is not None: return None return cls(gate_name, scoring_func, shared_name, shared_gate_name) def gate(self, moe_block: nn.Module, prefix: str) -> ReplicatedLinear: """Rebuild the HF gate as a `ReplicatedLinear` for vLLM's fused MoE.""" num_experts, hidden_size = getattr(moe_block, self.gate_name).weight.shape gate = ReplicatedLinear( hidden_size, num_experts, bias=False, prefix=maybe_prefix(prefix, self.gate_name), ) setattr(moe_block, self.gate_name, gate) return gate def shared_experts( self, moe_block: nn.Module, prefix: str ) -> SharedExpertMLP | None: """Build the HF shared expert (and its optional gate) as a `SharedExpertMLP` for vLLM's fused MoE.""" if self.shared_name is None: return None shared_experts = getattr(moe_block, self.shared_name) gate = None if self.shared_gate_name is not None: hf_gate = getattr(moe_block, self.shared_gate_name) gate = ReplicatedLinear( hf_gate.in_features, hf_gate.out_features, bias=hf_gate.bias is not None, prefix=maybe_prefix(prefix, self.shared_gate_name), ) setattr(moe_block, self.shared_gate_name, gate) return SharedExpertMLP(shared_experts, gate) def rewrite_forward(self, moe_block: nn.Module) -> None: """Rewrite `moe_block.forward` to route through vLLM's fused MoE.""" moe_block.forward = types.MethodType(_moe_block_forward, moe_block)