Skip to content

Commit afffe4f

Browse files
authored
[ExecuTorch][llm] Add MoE source transformation with INT4 quantized packing (#21535)
This PR was created by the merge bot to help merge the original PR into the main branch. ghstack PR number: #21121 by @digantdesai ^ Please use this as the source of truth for the PR details, comments, and reviews ghstack PR base: https://github.com/pytorch/executorch/tree/gh/digantdesai/71/base ghstack PR head: https://github.com/pytorch/executorch/tree/gh/digantdesai/71/head Merge bot PR base: https://github.com/pytorch/executorch/tree/gh/digantdesai/70/orig Merge bot PR head: https://github.com/pytorch/executorch/tree/gh/digantdesai/71/orig Differential Revision: [D102381994](https://our.internmc.facebook.com/intern/diff/D102381994/) @diff-train-skip-merge Co-authored-by: Digant Desai <digantdesai@meta.com>
1 parent 1a6b1c7 commit afffe4f

2 files changed

Lines changed: 368 additions & 0 deletions

File tree

  • examples/models/llama

examples/models/llama/BUCK

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,7 @@ fbcode_target(_kind = runtime.python_library,
123123
"source_transformation/apply_spin_quant_r1_r2.py",
124124
"source_transformation/attention.py",
125125
"source_transformation/lora.py",
126+
"source_transformation/moe.py",
126127
"source_transformation/pre_quantization.py",
127128
"source_transformation/prune_vocab.py",
128129
"source_transformation/quantize.py",
Lines changed: 367 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,367 @@
1+
# Copyright (c) Meta Platforms, Inc. and affiliates.
2+
# All rights reserved.
3+
#
4+
# This source code is licensed under the BSD-style license found in the
5+
# LICENSE file in the root directory of this source tree.
6+
7+
# pyre-unsafe
8+
9+
"""
10+
Source transformation that replaces `MOEFeedForward`
11+
modules with a `QuantizedMoEFFN` module wrapping the
12+
`llama::quantized_moe_ffn` portable-runtime custom op.
13+
14+
The transform symmetrically INT4-quantizes each per-expert weight matrix
15+
(group_size=32 by default), packs each expert with torchao's
16+
`torchao::_pack_8bit_act_4bit_weight` op, and stacks the per-expert
17+
opaque blobs into `[E, packed_bytes]` buffers consumed by the custom op.
18+
"""
19+
20+
from __future__ import annotations
21+
22+
import logging
23+
24+
import torch
25+
26+
from executorch.examples.models.llama.llama_transformer import MOEFeedForward
27+
from torch import nn
28+
from torchao.quantization.quant_primitives import (
29+
choose_qparams_affine,
30+
MappingType,
31+
quantize_affine,
32+
)
33+
34+
35+
logger: logging.Logger = logging.getLogger(__name__)
36+
37+
38+
def _symmetric_quantize_per_group(
39+
w: torch.Tensor, group_size: int, n_bit: int = 4
40+
) -> tuple[torch.Tensor, torch.Tensor]:
41+
"""Symmetric per-group quantization using torchao primitives.
42+
43+
Returns:
44+
qvals: int8 tensor of shape [N, K].
45+
scales: float32 tensor of shape [N * (K // group_size)].
46+
"""
47+
qmin = -(1 << (n_bit - 1))
48+
qmax = (1 << (n_bit - 1)) - 1
49+
block_size = (1, group_size)
50+
scale, zero_point = choose_qparams_affine(
51+
w.float(),
52+
MappingType.SYMMETRIC,
53+
block_size,
54+
target_dtype=torch.int8,
55+
quant_min=qmin,
56+
quant_max=qmax,
57+
)
58+
qvals = quantize_affine(
59+
w.float(),
60+
block_size,
61+
scale,
62+
zero_point,
63+
output_dtype=torch.int8,
64+
quant_min=qmin,
65+
quant_max=qmax,
66+
)
67+
return qvals, scale.reshape(-1).to(torch.float32)
68+
69+
70+
def _torchao_pack_int4_weight(
71+
w: torch.Tensor, group_size: int, target: str = "universal"
72+
) -> torch.Tensor:
73+
"""Symmetric INT4 group-quantize + torchao pack a 2D weight `[N, K]`."""
74+
qvals, scales = _symmetric_quantize_per_group(w, group_size, n_bit=4)
75+
return torch.ops.torchao._pack_8bit_act_4bit_weight(
76+
qvals,
77+
scales,
78+
None,
79+
group_size,
80+
None,
81+
target,
82+
)
83+
84+
85+
def _torchao_pack_int8_weight(
86+
w: torch.Tensor, group_size: int, target: str = "universal"
87+
) -> torch.Tensor:
88+
"""Symmetric INT8 group-quantize + torchao pack a 2D weight `[N, K]`."""
89+
qvals, scales = _symmetric_quantize_per_group(w, group_size, n_bit=8)
90+
return torch.ops.torchao._pack_8bit_act_8bit_weight(
91+
qvals,
92+
scales,
93+
None,
94+
group_size,
95+
None,
96+
target,
97+
)
98+
99+
100+
class QuantizedMoEFFN(nn.Module):
101+
"""Drop-in replacement for `MOEFeedForward` that calls
102+
`torch.ops.llama.quantized_moe_ffn`.
103+
104+
Buffers (registered, not parameters):
105+
gate_weight [E, D] fp32 — copied from `MOEFeedForward.gate.weight`
106+
expert_bias [E] or [0] fp32 — empty when `use_expert_bias=False`
107+
packed_w1 [E, packed_bytes_w1] uint8 — torchao opaque blobs
108+
packed_w3 [E, packed_bytes_w3] uint8
109+
packed_w2 [E, packed_bytes_w2] uint8
110+
"""
111+
112+
def __init__(
113+
self,
114+
gate_weight: torch.Tensor,
115+
expert_bias: torch.Tensor | None,
116+
packed_w1: torch.Tensor,
117+
packed_w3: torch.Tensor,
118+
packed_w2: torch.Tensor,
119+
*,
120+
num_experts: int,
121+
num_activated_experts: int,
122+
hidden_dim: int,
123+
dim: int,
124+
group_size: int,
125+
weight_nbit: int,
126+
score_func: str,
127+
route_scale: float,
128+
) -> None:
129+
super().__init__()
130+
self.dim = dim
131+
self.hidden_dim = hidden_dim
132+
self.num_experts = num_experts
133+
self.num_activated_experts = num_activated_experts
134+
self.group_size = group_size
135+
self.weight_nbit = weight_nbit
136+
self.score_func = score_func
137+
self.route_scale = float(route_scale)
138+
139+
self.register_buffer(
140+
"gate_weight",
141+
gate_weight.dequantize().detach().clone().to(torch.float32),
142+
)
143+
# Always register an `expert_bias` buffer; size 0 means "not used".
144+
if expert_bias is None:
145+
expert_bias_buf = torch.empty(0, dtype=torch.float32)
146+
else:
147+
expert_bias_buf = expert_bias.to(torch.float32)
148+
self.register_buffer("expert_bias", expert_bias_buf)
149+
150+
# torchao packed blobs are int8 tensors; reinterpret the bytes as
151+
# uint8 (no value conversion) for the op schema.
152+
self.register_buffer("packed_w1", packed_w1.view(torch.uint8))
153+
self.register_buffer("packed_w3", packed_w3.view(torch.uint8))
154+
self.register_buffer("packed_w2", packed_w2.view(torch.uint8))
155+
self.shared_expert: nn.Module | None = None
156+
157+
# The C++ kernel requires fp32 gate_weight / expert_bias. The export
158+
# pipeline calls model.to(dtype) after quantisation which would downcast
159+
# these buffers. Override _apply so they stay fp32 regardless.
160+
_FP32_BUFFER_NAMES = frozenset({"gate_weight", "expert_bias"})
161+
162+
def _apply(self, fn, recurse=True):
163+
super()._apply(fn, recurse)
164+
for name in self._FP32_BUFFER_NAMES:
165+
buf = getattr(self, name, None)
166+
if (
167+
buf is not None
168+
and buf.is_floating_point()
169+
and buf.dtype != torch.float32
170+
):
171+
setattr(self, name, buf.to(torch.float32))
172+
return self
173+
174+
def forward(self, x: torch.Tensor) -> torch.Tensor:
175+
input_dtype = x.dtype
176+
x_flat = x.reshape(-1, self.dim).to(torch.float32)
177+
out = torch.ops.llama.quantized_moe_ffn(
178+
x_flat,
179+
self.gate_weight,
180+
self.expert_bias,
181+
self.packed_w1,
182+
self.packed_w3,
183+
self.packed_w2,
184+
self.num_activated_experts,
185+
self.num_experts,
186+
self.hidden_dim,
187+
self.dim,
188+
self.group_size,
189+
self.weight_nbit,
190+
self.score_func,
191+
self.route_scale,
192+
)
193+
if self.shared_expert is not None:
194+
out = out + self.shared_expert(x_flat)
195+
out = out.view(x.shape[:-1] + (self.dim,))
196+
return out.to(input_dtype) if input_dtype != torch.float32 else out
197+
198+
199+
def _stack_per_expert_packed(
200+
per_expert_blobs: list[torch.Tensor],
201+
) -> torch.Tensor:
202+
"""Stack `[E]` per-expert packed 1D blobs into a `[E, packed_bytes]`
203+
tensor. All blobs are required to have the same length (they will,
204+
because every expert has the same `[N, K]` shape and we use the same
205+
quant config)."""
206+
if not per_expert_blobs:
207+
raise ValueError("per_expert_blobs must be non-empty")
208+
expected_size = per_expert_blobs[0].numel()
209+
for i, blob in enumerate(per_expert_blobs):
210+
if blob.numel() != expected_size:
211+
raise ValueError(
212+
f"per-expert packed blob {i} size {blob.numel()} != {expected_size}"
213+
)
214+
return torch.stack([blob.reshape(-1) for blob in per_expert_blobs], dim=0)
215+
216+
217+
def _build_quantized_moe_ffn_from_eager(
218+
moe: MOEFeedForward,
219+
*,
220+
group_size: int,
221+
weight_nbit: int,
222+
) -> QuantizedMoEFFN:
223+
"""Construct a QuantizedMoEFFN from an existing eager MOEFeedForward."""
224+
# The op only implements sigmoid and (post-top-k) softmax routing. Reject
225+
# anything else (e.g. softmax_all) here with a clear message rather than
226+
# letting export hit an opaque kernel check.
227+
if moe.score_func not in ("sigmoid", "softmax"):
228+
raise NotImplementedError(
229+
f"quantized_moe_ffn supports score_func 'sigmoid' or 'softmax', "
230+
f"not '{moe.score_func}'"
231+
)
232+
cond = moe.cond_ffn
233+
e = cond.num_experts
234+
w1 = cond.w1 # [E, F, D]
235+
w3 = cond.w3 # [E, F, D]
236+
w2 = cond.w2 # [E, F, D]
237+
if not (w1.dim() == 3 and w3.dim() == 3 and w2.dim() == 3):
238+
raise ValueError(
239+
f"expert weights must be 3D [E, F, D], got "
240+
f"w1={tuple(w1.shape)}, w3={tuple(w3.shape)}, w2={tuple(w2.shape)}"
241+
)
242+
243+
f_dim, d_dim = w1.shape[1], w1.shape[2]
244+
245+
# torchao group-quantization requires the packed K dim to be a multiple of
246+
# group_size: K=D for w1/w3 and K=F for the transposed w2. Validate here so
247+
# non-default model dims / group sizes fail with a clear error rather than
248+
# an obscure torchao failure or a runtime-rejected packed buffer.
249+
if d_dim % group_size != 0:
250+
raise ValueError(
251+
f"w1/w3 K dim D={d_dim} not divisible by group_size={group_size}"
252+
)
253+
if f_dim % group_size != 0:
254+
raise ValueError(f"w2 K dim F={f_dim} not divisible by group_size={group_size}")
255+
256+
# w2 is [E, F, D] for the einsum path; our op packs [N, K] so
257+
# transpose to [E, D, F] before packing.
258+
w2_packed_in = w2.transpose(-2, -1).contiguous() # [E, D, F]
259+
260+
pack_fn = (
261+
_torchao_pack_int4_weight if weight_nbit == 4 else _torchao_pack_int8_weight
262+
)
263+
264+
packed_w1_list: list[torch.Tensor] = []
265+
packed_w3_list: list[torch.Tensor] = []
266+
packed_w2_list: list[torch.Tensor] = []
267+
for ei in range(e):
268+
# w1, w3 share the [F, D] shape, group along K=D.
269+
packed_w1_list.append(pack_fn(w1[ei], group_size))
270+
packed_w3_list.append(pack_fn(w3[ei], group_size))
271+
# w2 packed shape is [D, F], group along K=F.
272+
packed_w2_list.append(pack_fn(w2_packed_in[ei], group_size))
273+
274+
packed_w1 = _stack_per_expert_packed(packed_w1_list)
275+
packed_w3 = _stack_per_expert_packed(packed_w3_list)
276+
packed_w2 = _stack_per_expert_packed(packed_w2_list)
277+
278+
# `MOEFeedForward` registers `expert_bias` as a buffer that is None unless
279+
# the model enables it; gate on the buffer itself, not a nonexistent flag.
280+
expert_bias = (
281+
moe.expert_bias.detach().clone() if moe.expert_bias is not None else None
282+
)
283+
284+
replacement = QuantizedMoEFFN(
285+
gate_weight=moe.gate.weight.detach().clone(),
286+
expert_bias=expert_bias,
287+
packed_w1=packed_w1,
288+
packed_w3=packed_w3,
289+
packed_w2=packed_w2,
290+
num_experts=e,
291+
num_activated_experts=moe.num_activated_experts,
292+
hidden_dim=f_dim,
293+
dim=d_dim,
294+
group_size=group_size,
295+
weight_nbit=weight_nbit,
296+
score_func=moe.score_func,
297+
route_scale=moe.route_scale,
298+
)
299+
# The shared expert (when present) is intentionally left in its original
300+
# eager/fp32 form: it is a dense FFN, not a per-expert quantized MoE, so
301+
# it is not routed through `llama::quantized_moe_ffn`.
302+
if getattr(moe, "shared_expert", None) is not None:
303+
replacement.shared_expert = moe.shared_expert
304+
return replacement
305+
306+
307+
def _replace_moe_recursive(
308+
module: nn.Module, group_size: int, weight_nbit: int
309+
) -> None:
310+
for name, child in list(module.named_children()):
311+
if isinstance(child, MOEFeedForward):
312+
replacement = _build_quantized_moe_ffn_from_eager(
313+
child,
314+
group_size=group_size,
315+
weight_nbit=weight_nbit,
316+
)
317+
setattr(module, name, replacement)
318+
logger.info(
319+
"Replaced MOEFeedForward at %s with QuantizedMoEFFN "
320+
"(E=%d, A=%d, D=%d, F=%d, score=%s, route_scale=%s)",
321+
name,
322+
replacement.num_experts,
323+
replacement.num_activated_experts,
324+
replacement.dim,
325+
replacement.hidden_dim,
326+
replacement.score_func,
327+
replacement.route_scale,
328+
)
329+
else:
330+
_replace_moe_recursive(child, group_size, weight_nbit)
331+
332+
333+
def replace_moe_with_quantized_op(
334+
model: nn.Module,
335+
*,
336+
group_size: int = 32,
337+
weight_nbit: int = 4,
338+
) -> nn.Module:
339+
"""Walk `model` and swap every `MOEFeedForward` for a `QuantizedMoEFFN`.
340+
341+
Args:
342+
model: the eager model graph (typically the result of
343+
`Llama4BackboneModel.get_eager_model()`).
344+
group_size: torchao quantization group size. 32 matches MobileMoE-0.3B.
345+
weight_nbit: 4 (production) or 8 (debug).
346+
347+
Returns:
348+
The mutated model (same object).
349+
"""
350+
# Imported here rather than at module scope so that merely importing this
351+
# module has no op-registration side effect: export flows that pull in this
352+
# file (it lives in the shared llama source_transformation library) but do
353+
# not use the quantized MoE op must not load the custom-ops AOT library.
354+
from executorch.extension.llm.custom_ops import custom_ops # noqa: F401
355+
356+
if not hasattr(torch.ops.llama, "quantized_moe_ffn"):
357+
raise RuntimeError(
358+
"llama::quantized_moe_ffn is not registered. "
359+
"Ensure executorch.extension.llm.custom_ops.custom_ops is "
360+
"imported and the AOT library is loaded."
361+
)
362+
if weight_nbit not in (4, 8):
363+
raise ValueError(f"weight_nbit must be 4 or 8, got {weight_nbit}")
364+
if group_size <= 0:
365+
raise ValueError(f"group_size must be positive, got {group_size}")
366+
_replace_moe_recursive(model, group_size, weight_nbit)
367+
return model

0 commit comments

Comments
 (0)