Skip to content

Commit 7a2b9fd

Browse files
pjordanandrsnclaude
andcommitted
test: add backward + QLoRA-training coverage and demo for Experts4bit
Prove Experts4bit works as a frozen 4-bit QLoRA base. New tests in tests/test_experts4bit.py cover the autograd contract: gradients reach the input activations, the frozen packed weights never receive a gradient, and the backward matches a full-precision reference forward. Add examples/experts4bit_qlora_demo.py with a small per-expert ExpertsLoRA wrapper over a frozen base, plus a test that a real optimizer step reduces loss while the 4-bit base stays bit-identical. The wrapper is a reference pattern (PEFT/Unsloth territory), intentionally not part of the bitsandbytes public API. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 1e529cf commit 7a2b9fd

2 files changed

Lines changed: 292 additions & 0 deletions

File tree

examples/experts4bit_qlora_demo.py

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
"""QLoRA-style training of fused MoE experts on a frozen ``Experts4bit`` base.
2+
3+
This is a *reference pattern*, intentionally **not** part of the bitsandbytes public API. It
4+
shows that the ``Experts4bit`` 4-bit storage primitive can serve as the frozen base of a
5+
QLoRA-style fine-tune of fused Mixture-of-Experts weights: the 4-bit expert weights stay
6+
frozen, and small per-expert low-rank (LoRA) adapters are the only trainable parameters.
7+
8+
The adapter wiring shown here is the kind of thing that would ultimately live in PEFT /
9+
Unsloth rather than in bitsandbytes itself — the point of this file is to demonstrate that
10+
the base primitive is already differentiable and trainable as a frozen base today.
11+
12+
Run:
13+
python examples/experts4bit_qlora_demo.py
14+
"""
15+
16+
from __future__ import annotations
17+
18+
import torch
19+
import torch.nn as nn
20+
import torch.nn.functional as F
21+
22+
from bitsandbytes.nn import Experts4bit
23+
24+
25+
class ExpertsLoRA(nn.Module):
26+
"""Per-expert LoRA adapters over a frozen :class:`Experts4bit` base.
27+
28+
For each expert ``e``, the two frozen 4-bit projections are augmented with a trainable
29+
low-rank term ``scaling * (x @ A[e].T) @ B[e].T``:
30+
31+
* ``gate_up``: ``A[e]`` is ``[r, hidden]``, ``B[e]`` is ``[gate_up_out, r]``
32+
* ``down``: ``A[e]`` is ``[r, intermediate]``, ``B[e]`` is ``[hidden, r]``
33+
34+
``B`` is initialised to zero, so the adapted module is identical to the frozen base at
35+
step 0 and only departs from it as the adapters train (standard LoRA initialisation).
36+
"""
37+
38+
def __init__(self, base: Experts4bit, r: int = 8, alpha: int = 16, dtype: torch.dtype = torch.float32):
39+
super().__init__()
40+
self.base = base
41+
for p in self.base.parameters():
42+
p.requires_grad_(False)
43+
44+
self.r = r
45+
self.scaling = alpha / r
46+
47+
num_experts = base.num_experts
48+
gate_up_out, hidden = base._gate_up_shape # [2*intermediate (or intermediate), hidden]
49+
_, intermediate = base._down_shape # [hidden, intermediate]
50+
51+
self.gate_up_lora_A = nn.Parameter(torch.empty(num_experts, r, hidden, dtype=dtype))
52+
self.gate_up_lora_B = nn.Parameter(torch.zeros(num_experts, gate_up_out, r, dtype=dtype))
53+
self.down_lora_A = nn.Parameter(torch.empty(num_experts, r, intermediate, dtype=dtype))
54+
self.down_lora_B = nn.Parameter(torch.zeros(num_experts, hidden, r, dtype=dtype))
55+
56+
# A ~ small random, B = 0 => the initial LoRA delta is exactly zero.
57+
nn.init.normal_(self.gate_up_lora_A, std=1.0 / r)
58+
nn.init.normal_(self.down_lora_A, std=1.0 / r)
59+
60+
def _lora(self, x: torch.Tensor, A: torch.Tensor, B: torch.Tensor) -> torch.Tensor:
61+
# x: [n, in]; A: [r, in]; B: [out, r] -> [n, out]
62+
return self.scaling * F.linear(F.linear(x, A), B)
63+
64+
def forward(
65+
self,
66+
hidden_states: torch.Tensor,
67+
top_k_index: torch.Tensor,
68+
top_k_weights: torch.Tensor,
69+
) -> torch.Tensor:
70+
base = self.base
71+
compute_dtype = base.compute_dtype if base.compute_dtype is not None else hidden_states.dtype
72+
hidden_states = hidden_states.to(compute_dtype)
73+
74+
final_hidden_states = torch.zeros_like(hidden_states, dtype=torch.float32)
75+
76+
with torch.no_grad():
77+
expert_mask = F.one_hot(top_k_index, num_classes=base.num_experts).permute(2, 1, 0)
78+
expert_hit = torch.greater(expert_mask.sum(dim=(-1, -2)), 0).nonzero(as_tuple=False).view(-1)
79+
80+
for expert_idx in expert_hit:
81+
top_k_pos, token_idx = torch.where(expert_mask[expert_idx])
82+
x = hidden_states[token_idx]
83+
84+
# Frozen 4-bit base projection + trainable low-rank delta.
85+
gate_up_w = base._dequantize_expert(
86+
base.gate_up_proj, base.gate_up_absmax, base._gate_up_shape, expert_idx, compute_dtype
87+
)
88+
proj = F.linear(x, gate_up_w) + self._lora(
89+
x, self.gate_up_lora_A[expert_idx], self.gate_up_lora_B[expert_idx]
90+
)
91+
92+
if base.has_gate:
93+
gate, up = proj.chunk(2, dim=-1)
94+
current_hidden = base.act_fn(gate) * up
95+
else:
96+
current_hidden = base.act_fn(proj)
97+
98+
down_w = base._dequantize_expert(
99+
base.down_proj, base.down_absmax, base._down_shape, expert_idx, compute_dtype
100+
)
101+
current_hidden = F.linear(current_hidden, down_w) + self._lora(
102+
current_hidden, self.down_lora_A[expert_idx], self.down_lora_B[expert_idx]
103+
)
104+
105+
current_hidden = current_hidden * top_k_weights[token_idx, top_k_pos, None]
106+
final_hidden_states.index_add_(0, token_idx, current_hidden.to(final_hidden_states.dtype))
107+
108+
return final_hidden_states.to(hidden_states.dtype)
109+
110+
111+
def main() -> None:
112+
torch.manual_seed(0)
113+
114+
num_experts, hidden, intermediate = 8, 128, 256
115+
num_tokens, top_k = 64, 2
116+
117+
# A full-precision fused-expert stack (the shape transformers v5 stores MoE experts in).
118+
gate_up = torch.randn(num_experts, 2 * intermediate, hidden) * 0.1
119+
down = torch.randn(num_experts, hidden, intermediate) * 0.1
120+
121+
# Freeze it in 4-bit, then attach trainable LoRA adapters.
122+
base = Experts4bit.from_float(gate_up, down, quant_type="nf4", compute_dtype=torch.float32)
123+
model = ExpertsLoRA(base, r=8, alpha=16)
124+
125+
trainable = [p for p in model.parameters() if p.requires_grad]
126+
n_train = sum(p.numel() for p in trainable)
127+
n_base_bytes = base.gate_up_proj.numel() + base.down_proj.numel()
128+
print(f"trainable LoRA params: {n_train:,} frozen packed base bytes: {n_base_bytes:,}")
129+
130+
hidden_states = torch.randn(num_tokens, hidden)
131+
top_k_index = torch.randint(0, num_experts, (num_tokens, top_k))
132+
top_k_weights = torch.softmax(torch.randn(num_tokens, top_k), dim=-1)
133+
target = torch.randn(num_tokens, hidden)
134+
135+
gate_up_before = base.gate_up_proj.clone()
136+
137+
optimizer = torch.optim.Adam(trainable, lr=1e-2)
138+
print("\nstep loss")
139+
for step in range(50):
140+
optimizer.zero_grad()
141+
out = model(hidden_states, top_k_index, top_k_weights)
142+
loss = F.mse_loss(out, target)
143+
loss.backward()
144+
assert base.gate_up_proj.grad is None, "frozen base must never receive a gradient"
145+
optimizer.step()
146+
if step % 10 == 0 or step == 49:
147+
print(f"{step:4d} {loss.item():.5f}")
148+
149+
assert torch.equal(base.gate_up_proj, gate_up_before), "frozen base bytes must be unchanged"
150+
print("\nbase packed weights unchanged after training:", torch.equal(base.gate_up_proj, gate_up_before))
151+
152+
153+
if __name__ == "__main__":
154+
main()

tests/test_experts4bit.py

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,3 +154,141 @@ def test_experts4bit_blocksize_validation():
154154

155155
def test_experts4bit_is_exported():
156156
assert bnb.nn.Experts4bit is Experts4bit
157+
158+
159+
# --- Backward / autograd ---------------------------------------------------------------
160+
# Experts4bit is a *frozen* 4-bit base: the packed weights are requires_grad=False, so they
161+
# never receive gradients, but the per-expert dequant + linear + index_add_ forward is fully
162+
# differentiable w.r.t. the input activations. That makes it usable as the frozen base of a
163+
# QLoRA-style setup (gradients flow to adapters/earlier layers, not to the quantized weights).
164+
# These tests lock that contract in.
165+
166+
167+
@pytest.mark.parametrize("device", get_available_devices())
168+
@pytest.mark.parametrize("dtype", [torch.float16, torch.bfloat16, torch.float32], ids=describe_dtype)
169+
def test_experts4bit_backward_flows_to_input(device, dtype):
170+
gate_up, down = _random_expert_weights(dtype, device)
171+
module = Experts4bit.from_float(gate_up, down, compute_dtype=dtype)
172+
173+
hidden_states, top_k_index, top_k_weights = _random_routing(device)
174+
hidden_states = hidden_states.to(dtype).detach().requires_grad_(True)
175+
176+
out = module(hidden_states, top_k_index, top_k_weights)
177+
out.float().sum().backward()
178+
179+
# Gradient reaches the input activations, is finite, and is nonzero (every token is routed
180+
# to TOP_K experts here, so every row contributes).
181+
assert hidden_states.grad is not None
182+
assert torch.isfinite(hidden_states.grad).all()
183+
assert hidden_states.grad.float().abs().sum() > 0
184+
185+
186+
@pytest.mark.parametrize("device", get_available_devices())
187+
def test_experts4bit_base_weights_stay_frozen(device):
188+
gate_up, down = _random_expert_weights(torch.float32, device)
189+
module = Experts4bit.from_float(gate_up, down, compute_dtype=torch.float32)
190+
191+
# Packed weights are frozen by construction ...
192+
assert module.gate_up_proj.requires_grad is False
193+
assert module.down_proj.requires_grad is False
194+
195+
hidden_states, top_k_index, top_k_weights = _random_routing(device)
196+
hidden_states = hidden_states.requires_grad_(True)
197+
module(hidden_states, top_k_index, top_k_weights).sum().backward()
198+
199+
# ... and a backward pass leaves no gradient on them (so an optimizer can never nudge the
200+
# quantized base, and the absmax buffers are not trainable either).
201+
assert module.gate_up_proj.grad is None
202+
assert module.down_proj.grad is None
203+
204+
205+
@pytest.mark.parametrize("device", get_available_devices())
206+
def test_experts4bit_backward_matches_reference(device):
207+
# float32 throughout: the module's autograd path must match a plain full-precision forward
208+
# built from the *same* dequantized weights, isolating gradient correctness from quant error.
209+
gate_up, down = _random_expert_weights(torch.float32, device)
210+
module = Experts4bit.from_float(gate_up, down, compute_dtype=torch.float32)
211+
212+
gate_up_deq = torch.stack(
213+
[
214+
module._dequantize_expert(
215+
module.gate_up_proj, module.gate_up_absmax, module._gate_up_shape, e, torch.float32
216+
)
217+
for e in range(NUM_EXPERTS)
218+
]
219+
)
220+
down_deq = torch.stack(
221+
[
222+
module._dequantize_expert(module.down_proj, module.down_absmax, module._down_shape, e, torch.float32)
223+
for e in range(NUM_EXPERTS)
224+
]
225+
)
226+
227+
hidden_states, top_k_index, top_k_weights = _random_routing(device)
228+
hs_mod = hidden_states.detach().clone().requires_grad_(True)
229+
hs_ref = hidden_states.detach().clone().requires_grad_(True)
230+
231+
out_mod = module(hs_mod, top_k_index, top_k_weights)
232+
out_ref = _reference_forward(gate_up_deq, down_deq, hs_ref, top_k_index, top_k_weights)
233+
234+
out_mod.sum().backward()
235+
out_ref.sum().backward()
236+
237+
torch.testing.assert_close(out_mod, out_ref, rtol=1e-4, atol=1e-4)
238+
torch.testing.assert_close(hs_mod.grad, hs_ref.grad, rtol=1e-4, atol=1e-4)
239+
240+
241+
def _load_experts_lora():
242+
"""Load the ExpertsLoRA reference wrapper from examples/ (kept out of the bnb API)."""
243+
import importlib.util
244+
import os
245+
246+
path = os.path.join(os.path.dirname(__file__), "..", "examples", "experts4bit_qlora_demo.py")
247+
spec = importlib.util.spec_from_file_location("experts4bit_qlora_demo", path)
248+
module = importlib.util.module_from_spec(spec)
249+
spec.loader.exec_module(module)
250+
return module.ExpertsLoRA
251+
252+
253+
def test_experts4bit_lora_training_reduces_loss():
254+
# End-to-end QLoRA-style step: a frozen 4-bit Experts4bit base + trainable per-expert LoRA.
255+
# Proves the primitive supports training today — only the adapters move, the base stays put.
256+
torch.manual_seed(0)
257+
experts_lora = _load_experts_lora()
258+
259+
gate_up, down = _random_expert_weights(torch.float32, "cpu")
260+
base = Experts4bit.from_float(gate_up, down, compute_dtype=torch.float32)
261+
model = experts_lora(base, r=4, alpha=8)
262+
263+
# Only LoRA adapters are trainable; the 4-bit base is frozen.
264+
trainable_names = [name for name, p in model.named_parameters() if p.requires_grad]
265+
assert trainable_names and all("lora" in name for name in trainable_names)
266+
267+
gate_up_before = base.gate_up_proj.clone()
268+
down_before = base.down_proj.clone()
269+
270+
hidden_states, top_k_index, top_k_weights = _random_routing("cpu")
271+
target = torch.randn_like(hidden_states)
272+
273+
# Standard LoRA init (B=0) => the adapted forward equals the frozen base forward at step 0.
274+
torch.testing.assert_close(
275+
model(hidden_states, top_k_index, top_k_weights),
276+
base(hidden_states, top_k_index, top_k_weights),
277+
rtol=1e-5,
278+
atol=1e-5,
279+
)
280+
281+
optimizer = torch.optim.Adam([p for p in model.parameters() if p.requires_grad], lr=1e-2)
282+
losses = []
283+
for _ in range(30):
284+
optimizer.zero_grad()
285+
loss = torch.nn.functional.mse_loss(model(hidden_states, top_k_index, top_k_weights), target)
286+
loss.backward()
287+
assert base.gate_up_proj.grad is None and base.down_proj.grad is None
288+
optimizer.step()
289+
losses.append(loss.item())
290+
291+
assert losses[-1] < losses[0] # training reduces loss
292+
# The frozen 4-bit base is bit-identical before and after training.
293+
assert torch.equal(base.gate_up_proj, gate_up_before)
294+
assert torch.equal(base.down_proj, down_before)

0 commit comments

Comments
 (0)