Skip to content

Commit 281a035

Browse files
committed
Add Experts4bit for 4-bit quantization of fused MoE experts (#1849)
transformers v5 stores fused MoE experts as a single 3D nn.Parameter (e.g. OlmoeExperts, Qwen3MoeExperts), which the nn.Linear-based 4-bit walker skips. The experts stay in full precision and load_in_4bit barely shrinks the model (issue #1849). Experts4bit holds gate_up_proj and down_proj packed in NF4/FP4 as plain nn.Parameter buffers, with per-expert absmax kept on the module itself. The forward pass dequantizes one expert at a time (a per-expert loop), mirroring the reference fused-experts forward. There is no Params4bit tensor-subclass machinery, so the module serializes through the default state_dict with no custom hooks. - from_float() quantizes existing bf16/fp16 expert stacks - enforces in_features % blocksize == 0 for clean per-expert blocking - double-quant (compress_statistics) and grouped-GEMM intentionally deferred for a first cut - tests: quant round-trip, forward vs. full-precision reference, state_dict round-trip, and validation guards
1 parent 3343bac commit 281a035

3 files changed

Lines changed: 415 additions & 0 deletions

File tree

bitsandbytes/nn/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
#
33
# This source code is licensed under the MIT license found in the
44
# LICENSE file in the root directory of this source tree.
5+
from .experts import Experts4bit
56
from .modules import (
67
Embedding,
78
Embedding4bit,

bitsandbytes/nn/experts.py

Lines changed: 258 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,258 @@
1+
# Copyright (c) Facebook, Inc. and its affiliates.
2+
#
3+
# This source code is licensed under the MIT license found in the
4+
# LICENSE file in the root directory of this source tree.
5+
from collections.abc import Callable
6+
from typing import Optional
7+
8+
import torch
9+
import torch.nn as nn
10+
import torch.nn.functional as F_nn
11+
12+
import bitsandbytes.functional as F
13+
from bitsandbytes.functional import QuantState
14+
15+
16+
class Experts4bit(nn.Module):
17+
"""4-bit quantized storage for fused Mixture-of-Experts expert weights.
18+
19+
A growing number of models in the Hugging Face ecosystem store their MoE expert
20+
weights as a single 3D ``nn.Parameter`` of shape ``[num_experts, out_features,
21+
in_features]`` (e.g. ``OlmoeExperts``, ``Qwen3MoeExperts``) rather than as a
22+
collection of ``nn.Linear`` layers. The default 4-bit quantization walker only
23+
replaces ``nn.Linear`` modules, so these fused experts are silently skipped and
24+
stay in full precision — the dominant contribution to the model's memory footprint
25+
(see https://github.com/bitsandbytes-foundation/bitsandbytes/issues/1849).
26+
27+
``Experts4bit`` holds the two expert projections (``gate_up_proj`` and ``down_proj``)
28+
in 4-bit NF4/FP4 precision. Unlike :class:`Linear4bit`, the packed weights are kept
29+
as plain ``nn.Parameter`` buffers and the per-expert quantization statistics
30+
(``absmax``) live on the module as ordinary buffers. This avoids bending
31+
:class:`Params4bit`'s tensor-subclass and device-movement machinery around a 3D
32+
stack, and it means the module serializes through the standard ``state_dict``
33+
mechanism with no custom save/load hooks.
34+
35+
The forward pass dequantizes a single expert at a time (a per-expert loop), mirroring
36+
the reference fused-experts forward. Grouped-GEMM is intentionally left for future
37+
work.
38+
39+
<Tip warning={true}>This feature is experimental and may change in future releases.</Tip>
40+
41+
Args:
42+
num_experts (`int`): Number of experts in the layer.
43+
hidden_dim (`int`): Model hidden size (the ``in_features`` of ``gate_up_proj`` and
44+
the ``out_features`` of ``down_proj``).
45+
intermediate_dim (`int`): Expert intermediate size (the ``in_features`` of
46+
``down_proj``).
47+
has_gate (`bool`, *optional*, defaults to `True`): Whether ``gate_up_proj`` packs a
48+
gate and an up projection (SwiGLU-style). When `False`, the projection is a
49+
plain up projection of size ``intermediate_dim``.
50+
activation (`Callable`, *optional*): The activation applied to the gate. Defaults
51+
to ``torch.nn.functional.silu`` (SwiGLU), matching OLMoE / Qwen3-MoE.
52+
compute_dtype (`torch.dtype`, *optional*): The dtype expert weights are
53+
dequantized to for the matmul. When `None`, the input's dtype is used.
54+
quant_type (`str`, *optional*, defaults to `"nf4"`): The 4-bit data type, ``nf4``
55+
or ``fp4``.
56+
blocksize (`int`, *optional*, defaults to `64`): The quantization block size.
57+
device (*optional*): The device for the (empty) packed buffers.
58+
59+
Raises:
60+
ValueError: If ``quant_type`` is invalid, or if ``hidden_dim`` / ``intermediate_dim``
61+
is not divisible by ``blocksize`` (required so per-expert quantization blocks
62+
never straddle an expert boundary).
63+
"""
64+
65+
def __init__(
66+
self,
67+
num_experts: int,
68+
hidden_dim: int,
69+
intermediate_dim: int,
70+
has_gate: bool = True,
71+
activation: Optional[Callable[[torch.Tensor], torch.Tensor]] = None,
72+
compute_dtype: Optional[torch.dtype] = None,
73+
quant_type: str = "nf4",
74+
blocksize: int = 64,
75+
device=None,
76+
):
77+
super().__init__()
78+
79+
if quant_type not in ("nf4", "fp4"):
80+
raise ValueError(f"quant_type must be 'nf4' or 'fp4', got {quant_type!r}")
81+
82+
# Each expert is quantized independently, so an expert occupies a contiguous
83+
# `out_features * in_features` run of elements. Requiring the in_features dim to
84+
# be a multiple of the blocksize guarantees `out_features * in_features` is too,
85+
# so blocks tile each expert exactly and absmax reshapes cleanly to
86+
# [num_experts, blocks_per_expert]. (gate_up in_features is hidden_dim; down_proj
87+
# in_features is intermediate_dim.)
88+
for name, in_features in (("hidden_dim", hidden_dim), ("intermediate_dim", intermediate_dim)):
89+
if in_features % blocksize != 0:
90+
raise ValueError(
91+
f"{name} ({in_features}) must be divisible by blocksize ({blocksize}) "
92+
"so per-expert quantization blocks align with expert boundaries"
93+
)
94+
95+
self.num_experts = num_experts
96+
self.hidden_dim = hidden_dim
97+
self.intermediate_dim = intermediate_dim
98+
self.has_gate = has_gate
99+
self.act_fn = activation if activation is not None else F_nn.silu
100+
self.compute_dtype = compute_dtype
101+
self.quant_type = quant_type
102+
self.blocksize = blocksize
103+
104+
gate_up_out = 2 * intermediate_dim if has_gate else intermediate_dim
105+
self._gate_up_shape = (gate_up_out, hidden_dim)
106+
self._down_shape = (hidden_dim, intermediate_dim)
107+
108+
gate_up_numel = gate_up_out * hidden_dim
109+
down_numel = hidden_dim * intermediate_dim
110+
111+
# Packed 4-bit weights as plain (frozen) parameters: two 4-bit values per byte.
112+
self.gate_up_proj = nn.Parameter(
113+
torch.empty(num_experts, gate_up_numel // 2, dtype=torch.uint8, device=device),
114+
requires_grad=False,
115+
)
116+
self.down_proj = nn.Parameter(
117+
torch.empty(num_experts, down_numel // 2, dtype=torch.uint8, device=device),
118+
requires_grad=False,
119+
)
120+
121+
# Per-expert quantization scales.
122+
self.register_buffer(
123+
"gate_up_absmax",
124+
torch.empty(num_experts, gate_up_numel // blocksize, dtype=torch.float32, device=device),
125+
)
126+
self.register_buffer(
127+
"down_absmax",
128+
torch.empty(num_experts, down_numel // blocksize, dtype=torch.float32, device=device),
129+
)
130+
131+
# The 4-bit codebook is identical for every expert and fully determined by
132+
# quant_type, so it is reconstructed at init rather than serialized.
133+
self.register_buffer("code", F.get_4bit_type(quant_type, device=device), persistent=False)
134+
135+
@classmethod
136+
def from_float(
137+
cls,
138+
gate_up_proj: torch.Tensor,
139+
down_proj: torch.Tensor,
140+
has_gate: bool = True,
141+
activation: Optional[Callable[[torch.Tensor], torch.Tensor]] = None,
142+
compute_dtype: Optional[torch.dtype] = None,
143+
quant_type: str = "nf4",
144+
blocksize: int = 64,
145+
) -> "Experts4bit":
146+
"""Build an :class:`Experts4bit` by quantizing full-precision expert weights.
147+
148+
Args:
149+
gate_up_proj (`torch.Tensor`): Shape ``[num_experts, gate_up_out, hidden_dim]``,
150+
where ``gate_up_out`` is ``2 * intermediate_dim`` when ``has_gate`` else
151+
``intermediate_dim``.
152+
down_proj (`torch.Tensor`): Shape ``[num_experts, hidden_dim, intermediate_dim]``.
153+
154+
Returns:
155+
`Experts4bit`: A module holding the quantized weights on the inputs' device.
156+
"""
157+
if gate_up_proj.dim() != 3 or down_proj.dim() != 3:
158+
raise ValueError("gate_up_proj and down_proj must be 3D [num_experts, out, in] tensors")
159+
160+
num_experts, _, hidden_dim = gate_up_proj.shape
161+
intermediate_dim = down_proj.shape[2]
162+
163+
module = cls(
164+
num_experts,
165+
hidden_dim,
166+
intermediate_dim,
167+
has_gate=has_gate,
168+
activation=activation,
169+
compute_dtype=compute_dtype if compute_dtype is not None else gate_up_proj.dtype,
170+
quant_type=quant_type,
171+
blocksize=blocksize,
172+
device=gate_up_proj.device,
173+
)
174+
175+
gate_up_packed, gate_up_absmax = module._quantize_stack(gate_up_proj)
176+
down_packed, down_absmax = module._quantize_stack(down_proj)
177+
178+
module.gate_up_proj = nn.Parameter(gate_up_packed, requires_grad=False)
179+
module.down_proj = nn.Parameter(down_packed, requires_grad=False)
180+
module.gate_up_absmax = gate_up_absmax
181+
module.down_absmax = down_absmax
182+
return module
183+
184+
def _quantize_stack(self, weights: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
185+
"""Quantize a ``[num_experts, out, in]`` stack to packed bytes + per-expert absmax."""
186+
packed = []
187+
absmax = []
188+
for e in range(weights.shape[0]):
189+
q, state = F.quantize_4bit(
190+
weights[e].contiguous(),
191+
blocksize=self.blocksize,
192+
compress_statistics=False,
193+
quant_type=self.quant_type,
194+
)
195+
packed.append(q.reshape(-1))
196+
absmax.append(state.absmax.reshape(-1))
197+
return torch.stack(packed), torch.stack(absmax)
198+
199+
def _dequantize_expert(
200+
self,
201+
packed: torch.Tensor,
202+
absmax: torch.Tensor,
203+
shape: tuple[int, int],
204+
expert_idx: int,
205+
dtype: torch.dtype,
206+
) -> torch.Tensor:
207+
"""Dequantize a single expert's 2D weight ``[out, in]`` for the matmul."""
208+
quant_state = QuantState(
209+
absmax=absmax[expert_idx],
210+
shape=torch.Size(shape),
211+
code=self.code,
212+
blocksize=self.blocksize,
213+
quant_type=self.quant_type,
214+
dtype=dtype,
215+
)
216+
# Restore the [packed, 1] layout quantize_4bit emits (and which keeps the
217+
# transpose back-compat shim — keyed on A.shape[0] == 1 — from firing).
218+
return F.dequantize_4bit(packed[expert_idx].reshape(-1, 1), quant_state=quant_state)
219+
220+
def forward(
221+
self,
222+
hidden_states: torch.Tensor,
223+
top_k_index: torch.Tensor,
224+
top_k_weights: torch.Tensor,
225+
) -> torch.Tensor:
226+
compute_dtype = self.compute_dtype if self.compute_dtype is not None else hidden_states.dtype
227+
hidden_states = hidden_states.to(compute_dtype)
228+
229+
# Accumulate in float32 for numerical stability with bf16/fp16 routing weights.
230+
final_hidden_states = torch.zeros_like(hidden_states, dtype=torch.float32)
231+
232+
with torch.no_grad():
233+
expert_mask = F_nn.one_hot(top_k_index, num_classes=self.num_experts)
234+
expert_mask = expert_mask.permute(2, 1, 0)
235+
expert_hit = torch.greater(expert_mask.sum(dim=(-1, -2)), 0).nonzero(as_tuple=False).view(-1)
236+
237+
for expert_idx in expert_hit:
238+
top_k_pos, token_idx = torch.where(expert_mask[expert_idx])
239+
current_state = hidden_states[token_idx]
240+
241+
gate_up_w = self._dequantize_expert(
242+
self.gate_up_proj, self.gate_up_absmax, self._gate_up_shape, expert_idx, compute_dtype
243+
)
244+
proj = F_nn.linear(current_state, gate_up_w)
245+
if self.has_gate:
246+
gate, up = proj.chunk(2, dim=-1)
247+
current_hidden = self.act_fn(gate) * up
248+
else:
249+
current_hidden = self.act_fn(proj)
250+
251+
down_w = self._dequantize_expert(
252+
self.down_proj, self.down_absmax, self._down_shape, expert_idx, compute_dtype
253+
)
254+
current_hidden = F_nn.linear(current_hidden, down_w)
255+
current_hidden = current_hidden * top_k_weights[token_idx, top_k_pos, None]
256+
final_hidden_states.index_add_(0, token_idx, current_hidden.to(final_hidden_states.dtype))
257+
258+
return final_hidden_states.to(hidden_states.dtype)

0 commit comments

Comments
 (0)