Skip to content

Commit fb1b247

Browse files
pjordanandrsnclaude
andcommitted
feat: generalize Experts4bit to ExpertsNbit with 8-bit and 16-bit storage
Refactor the fused-MoE expert storage module into a generic ExpertsNbit parametrized by quant_type, with Experts4bit kept as a fully back-compatible fixed-4-bit subclass (same API, same state_dict layout, same tests): - "nf4" / "fp4": the existing 4-bit blockwise path, unchanged. - "int8" / "fp8": 8-bit blockwise via quantize_blockwise — one codebook index per byte, 2x compression at much higher fidelity than 4-bit ("int8" = the blockwise dynamic map, not LLM.int8() vectorwise; "fp8" = an e4m3 codebook via create_fp8_map). - "bf16" / "fp16": unquantized passthrough storage (no codebook/absmax) as a reference baseline and per-layer opt-out; skips the blocksize check. Tests: new tests/test_experts_nbit.py (roundtrip tolerances per scheme, int8-beats-nf4 fidelity ordering, forward/backward vs reference, strict state_dict roundtrip incl. absent absmax keys for passthrough, frozen-base LoRA training on int8, subclass quant_type restriction). Also folds in a one-line ruff-format fix to the #1849 regression test so `pre-commit run --all-files` passes on the branch. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 2748d76 commit fb1b247

4 files changed

Lines changed: 408 additions & 69 deletions

File tree

bitsandbytes/nn/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +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
5+
from .experts import Experts4bit, ExpertsNbit
66
from .modules import (
77
Embedding,
88
Embedding4bit,

bitsandbytes/nn/experts.py

Lines changed: 175 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -12,25 +12,64 @@
1212
import bitsandbytes.functional as F
1313
from bitsandbytes.functional import QuantState
1414

15-
16-
class Experts4bit(nn.Module):
17-
"""4-bit quantized storage for fused Mixture-of-Experts expert weights.
15+
# Storage bit-width per supported quantization scheme. The 4-bit schemes pack two values per
16+
# byte via quantize_4bit; the 8-bit schemes store one codebook index per byte via
17+
# quantize_blockwise ("int8" = the blockwise dynamic map, NOT the LLM.int8() vectorwise
18+
# scheme; "fp8" = an e4m3 float codebook). The 16-bit entries are unquantized passthrough
19+
# storage — no codebook, no absmax — useful as a reference baseline or a per-layer opt-out.
20+
_SCHEME_BITS = {
21+
"nf4": 4,
22+
"fp4": 4,
23+
"int8": 8,
24+
"fp8": 8,
25+
"bf16": 16,
26+
"fp16": 16,
27+
}
28+
29+
_PASSTHROUGH_DTYPES = {"bf16": torch.bfloat16, "fp16": torch.float16}
30+
31+
32+
def _build_code(quant_type: str, device) -> Optional[torch.Tensor]:
33+
"""The shared per-scheme codebook (`None` for 16-bit passthrough)."""
34+
if quant_type in ("nf4", "fp4"):
35+
return F.get_4bit_type(quant_type, device=device)
36+
if quant_type == "int8":
37+
code = F.create_dynamic_map()
38+
elif quant_type == "fp8":
39+
code = F.create_fp8_map(signed=True, exponent_bits=4, precision_bits=3, total_bits=8)
40+
else:
41+
return None
42+
return code.to(device) if device is not None else code
43+
44+
45+
class ExpertsNbit(nn.Module):
46+
"""Low-bit quantized storage for fused Mixture-of-Experts expert weights.
1847
1948
A growing number of models in the Hugging Face ecosystem store their MoE expert
2049
weights as a single 3D ``nn.Parameter`` of shape ``[num_experts, out_features,
2150
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
51+
collection of ``nn.Linear`` layers. The default quantization walkers only replace
52+
``nn.Linear`` modules, so these fused experts are silently skipped and stay in full
53+
precision — the dominant contribution to the model's memory footprint
2554
(see https://github.com/bitsandbytes-foundation/bitsandbytes/issues/1849).
2655
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.
56+
``ExpertsNbit`` holds the two expert projections (``gate_up_proj`` and ``down_proj``)
57+
at a configurable storage precision selected by ``quant_type``:
58+
59+
* ``"nf4"`` / ``"fp4"`` — 4-bit blockwise (two values per byte, 4x compression).
60+
:class:`Experts4bit` is the fixed-4-bit specialization of this class.
61+
* ``"int8"`` / ``"fp8"`` — 8-bit blockwise (one codebook index per byte, 2x
62+
compression at substantially higher fidelity than 4-bit). ``"int8"`` uses the
63+
blockwise dynamic map (`quantize_blockwise`'s default); it is *not* the
64+
LLM.int8() vectorwise scheme. ``"fp8"`` uses an e4m3 float codebook.
65+
* ``"bf16"`` / ``"fp16"`` — unquantized passthrough storage (no compression). A
66+
reference baseline and a per-layer opt-out inside an otherwise-quantized model.
67+
68+
The packed weights are kept as plain ``nn.Parameter`` buffers and the per-expert
69+
quantization statistics (``absmax``) live on the module as ordinary buffers. This
70+
avoids bending :class:`Params4bit`'s tensor-subclass and device-movement machinery
71+
around a 3D stack, and it means the module serializes through the standard
72+
``state_dict`` mechanism with no custom save/load hooks.
3473
3574
The forward pass dequantizes a single expert at a time (a per-expert loop), mirroring
3675
the reference fused-experts forward. Grouped-GEMM is intentionally left for future
@@ -51,17 +90,20 @@ class Experts4bit(nn.Module):
5190
to ``torch.nn.functional.silu`` (SwiGLU), matching OLMoE / Qwen3-MoE.
5291
compute_dtype (`torch.dtype`, *optional*): The dtype expert weights are
5392
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``.
93+
quant_type (`str`, *optional*, defaults to `"nf4"`): The storage scheme — one of
94+
``nf4``, ``fp4``, ``int8``, ``fp8``, ``bf16``, ``fp16``.
5695
blocksize (`int`, *optional*, defaults to `64`): The quantization block size.
96+
Ignored for the 16-bit passthrough schemes.
5797
device (*optional*): The device for the (empty) packed buffers.
5898
5999
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).
100+
ValueError: If ``quant_type`` is invalid, or if a quantized scheme is selected and
101+
``hidden_dim`` / ``intermediate_dim`` is not divisible by ``blocksize``
102+
(required so per-expert quantization blocks never straddle an expert boundary).
63103
"""
64104

105+
_ALLOWED_QUANT_TYPES: tuple = tuple(_SCHEME_BITS)
106+
65107
def __init__(
66108
self,
67109
num_experts: int,
@@ -76,21 +118,25 @@ def __init__(
76118
):
77119
super().__init__()
78120

79-
if quant_type not in ("nf4", "fp4"):
80-
raise ValueError(f"quant_type must be 'nf4' or 'fp4', got {quant_type!r}")
121+
allowed = type(self)._ALLOWED_QUANT_TYPES
122+
if quant_type not in allowed:
123+
raise ValueError(f"quant_type must be one of {allowed}, got {quant_type!r}")
124+
125+
self.bits = _SCHEME_BITS[quant_type]
81126

82127
# Each expert is quantized independently, so an expert occupies a contiguous
83128
# `out_features * in_features` run of elements. Requiring the in_features dim to
84129
# be a multiple of the blocksize guarantees `out_features * in_features` is too,
85130
# so blocks tile each expert exactly and absmax reshapes cleanly to
86131
# [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-
)
132+
# in_features is intermediate_dim.) Passthrough storage has no blocks.
133+
if self.bits < 16:
134+
for name, in_features in (("hidden_dim", hidden_dim), ("intermediate_dim", intermediate_dim)):
135+
if in_features % blocksize != 0:
136+
raise ValueError(
137+
f"{name} ({in_features}) must be divisible by blocksize ({blocksize}) "
138+
"so per-expert quantization blocks align with expert boundaries"
139+
)
94140

95141
self.num_experts = num_experts
96142
self.hidden_dim = hidden_dim
@@ -108,29 +154,39 @@ def __init__(
108154
gate_up_numel = gate_up_out * hidden_dim
109155
down_numel = hidden_dim * intermediate_dim
110156

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-
)
157+
# Packed weights as plain (frozen) parameters. 4-bit: two values per byte; 8-bit:
158+
# one codebook index per byte; 16-bit: the weights themselves in the storage dtype.
159+
if self.bits == 16:
160+
storage_dtype = _PASSTHROUGH_DTYPES[quant_type]
161+
gate_up_storage = torch.empty(num_experts, gate_up_numel, dtype=storage_dtype, device=device)
162+
down_storage = torch.empty(num_experts, down_numel, dtype=storage_dtype, device=device)
163+
else:
164+
packed_per_value = 2 if self.bits == 4 else 1
165+
gate_up_storage = torch.empty(
166+
num_experts, gate_up_numel // packed_per_value, dtype=torch.uint8, device=device
167+
)
168+
down_storage = torch.empty(num_experts, down_numel // packed_per_value, dtype=torch.uint8, device=device)
169+
self.gate_up_proj = nn.Parameter(gate_up_storage, requires_grad=False)
170+
self.down_proj = nn.Parameter(down_storage, requires_grad=False)
171+
172+
# Per-expert quantization scales (absent for passthrough storage — registering None
173+
# keeps attribute access uniform while leaving state_dict free of the keys).
174+
if self.bits < 16:
175+
self.register_buffer(
176+
"gate_up_absmax",
177+
torch.empty(num_experts, gate_up_numel // blocksize, dtype=torch.float32, device=device),
178+
)
179+
self.register_buffer(
180+
"down_absmax",
181+
torch.empty(num_experts, down_numel // blocksize, dtype=torch.float32, device=device),
182+
)
183+
else:
184+
self.register_buffer("gate_up_absmax", None)
185+
self.register_buffer("down_absmax", None)
130186

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)
187+
# The codebook is identical for every expert and fully determined by quant_type,
188+
# so it is reconstructed at init rather than serialized.
189+
self.register_buffer("code", _build_code(quant_type, device), persistent=False)
134190

135191
@classmethod
136192
def from_float(
@@ -142,8 +198,8 @@ def from_float(
142198
compute_dtype: Optional[torch.dtype] = None,
143199
quant_type: str = "nf4",
144200
blocksize: int = 64,
145-
) -> "Experts4bit":
146-
"""Build an :class:`Experts4bit` by quantizing full-precision expert weights.
201+
) -> "ExpertsNbit":
202+
"""Build an :class:`ExpertsNbit` by quantizing full-precision expert weights.
147203
148204
Args:
149205
gate_up_proj (`torch.Tensor`): Shape ``[num_experts, gate_up_out, hidden_dim]``,
@@ -152,7 +208,7 @@ def from_float(
152208
down_proj (`torch.Tensor`): Shape ``[num_experts, hidden_dim, intermediate_dim]``.
153209
154210
Returns:
155-
`Experts4bit`: A module holding the quantized weights on the inputs' device.
211+
`ExpertsNbit`: A module holding the quantized weights on the inputs' device.
156212
"""
157213
if gate_up_proj.dim() != 3 or down_proj.dim() != 3:
158214
raise ValueError("gate_up_proj and down_proj must be 3D [num_experts, out, in] tensors")
@@ -181,30 +237,55 @@ def from_float(
181237
module.down_absmax = down_absmax
182238
return module
183239

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."""
240+
def _quantize_stack(self, weights: torch.Tensor) -> tuple[torch.Tensor, Optional[torch.Tensor]]:
241+
"""Quantize a ``[num_experts, out, in]`` stack to packed storage + per-expert absmax.
242+
243+
For 16-bit passthrough the stack is stored as-is (flattened per expert) and the
244+
returned absmax is `None`.
245+
"""
246+
if self.bits == 16:
247+
storage_dtype = _PASSTHROUGH_DTYPES[self.quant_type]
248+
return weights.reshape(weights.shape[0], -1).to(storage_dtype).contiguous(), None
249+
186250
packed = []
187251
absmax = []
188252
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-
)
253+
if self.bits == 4:
254+
q, state = F.quantize_4bit(
255+
weights[e].contiguous(),
256+
blocksize=self.blocksize,
257+
compress_statistics=False,
258+
quant_type=self.quant_type,
259+
)
260+
else:
261+
q, state = F.quantize_blockwise(
262+
weights[e].contiguous(),
263+
code=self.code,
264+
blocksize=self.blocksize,
265+
)
195266
packed.append(q.reshape(-1))
196267
absmax.append(state.absmax.reshape(-1))
197268
return torch.stack(packed), torch.stack(absmax)
198269

199270
def _dequantize_expert(
200271
self,
201272
packed: torch.Tensor,
202-
absmax: torch.Tensor,
273+
absmax: Optional[torch.Tensor],
203274
shape: tuple[int, int],
204275
expert_idx: int,
205276
dtype: torch.dtype,
206277
) -> torch.Tensor:
207278
"""Dequantize a single expert's 2D weight ``[out, in]`` for the matmul."""
279+
if self.bits == 16:
280+
return packed[expert_idx].reshape(shape).to(dtype)
281+
if self.bits == 8:
282+
quant_state = QuantState(
283+
absmax=absmax[expert_idx],
284+
code=self.code,
285+
blocksize=self.blocksize,
286+
dtype=dtype,
287+
)
288+
return F.dequantize_blockwise(packed[expert_idx].reshape(shape), quant_state=quant_state)
208289
quant_state = QuantState(
209290
absmax=absmax[expert_idx],
210291
shape=torch.Size(shape),
@@ -217,6 +298,11 @@ def _dequantize_expert(
217298
# transpose back-compat shim — keyed on A.shape[0] == 1 — from firing).
218299
return F.dequantize_4bit(packed[expert_idx].reshape(-1, 1), quant_state=quant_state)
219300

301+
def _project(self, packed, absmax, shape, expert_idx, x, compute_dtype):
302+
"""One expert projection: dequantize + ``linear``."""
303+
weight = self._dequantize_expert(packed, absmax, shape, expert_idx, compute_dtype)
304+
return F_nn.linear(x, weight)
305+
220306
def forward(
221307
self,
222308
hidden_states: torch.Tensor,
@@ -238,21 +324,43 @@ def forward(
238324
top_k_pos, token_idx = torch.where(expert_mask[expert_idx])
239325
current_state = hidden_states[token_idx]
240326

241-
gate_up_w = self._dequantize_expert(
242-
self.gate_up_proj, self.gate_up_absmax, self._gate_up_shape, expert_idx, compute_dtype
327+
proj = self._project(
328+
self.gate_up_proj,
329+
self.gate_up_absmax,
330+
self._gate_up_shape,
331+
expert_idx,
332+
current_state,
333+
compute_dtype,
243334
)
244-
proj = F_nn.linear(current_state, gate_up_w)
245335
if self.has_gate:
246336
gate, up = proj.chunk(2, dim=-1)
247337
current_hidden = self.act_fn(gate) * up
248338
else:
249339
current_hidden = self.act_fn(proj)
250340

251-
down_w = self._dequantize_expert(
252-
self.down_proj, self.down_absmax, self._down_shape, expert_idx, compute_dtype
341+
current_hidden = self._project(
342+
self.down_proj,
343+
self.down_absmax,
344+
self._down_shape,
345+
expert_idx,
346+
current_hidden,
347+
compute_dtype,
253348
)
254-
current_hidden = F_nn.linear(current_hidden, down_w)
255349
current_hidden = current_hidden * top_k_weights[token_idx, top_k_pos, None]
256350
final_hidden_states.index_add_(0, token_idx, current_hidden.to(final_hidden_states.dtype))
257351

258352
return final_hidden_states.to(hidden_states.dtype)
353+
354+
355+
class Experts4bit(ExpertsNbit):
356+
"""4-bit quantized storage for fused Mixture-of-Experts expert weights.
357+
358+
The fixed-4-bit (``nf4`` / ``fp4``) specialization of :class:`ExpertsNbit` — see the
359+
parent class for the storage design, constructor arguments, and forward semantics.
360+
Kept as a distinct class so the 4-bit contract (two packed values per byte, 4x
361+
compression) has a stable name.
362+
363+
<Tip warning={true}>This feature is experimental and may change in future releases.</Tip>
364+
"""
365+
366+
_ALLOWED_QUANT_TYPES = ("nf4", "fp4")

tests/test_experts4bit.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -323,7 +323,8 @@ def __init__(self):
323323
)
324324
assert q.gate_up_proj.dtype == torch.uint8 and q.down_proj.dtype == torch.uint8
325325
quantized_bytes = (
326-
q.gate_up_proj.numel() + q.down_proj.numel() # uint8 packed
326+
q.gate_up_proj.numel()
327+
+ q.down_proj.numel() # uint8 packed
327328
+ (q.gate_up_absmax.numel() + q.down_absmax.numel()) * 4 # fp32 absmax
328329
)
329330
assert quantized_bytes < fp16_bytes / 3 # ~4x on the weights, minus small absmax overhead

0 commit comments

Comments
 (0)