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