Skip to content

Commit 657c4bc

Browse files
pjordanandrsnclaude
andcommitted
mem: re-dequantize frozen expert weights in backward instead of saving them
Route every expert projection through _FrozenLinearRecomputeBackward, a small autograd Function whose forward computes dequantize + F.linear directly and whose backward re-dequantizes the frozen weight to form grad_output @ W: - Bit-exact by construction, in and out of grad mode, on every device: recomputation changes what is saved, never what is computed. (An earlier iteration routed through bnb.matmul_4bit instead; it was dropped because the fused gemm_4bit kernel it dispatches to for small token batches differs from dequantize+linear by accumulation order.) - The dequantized [out, in] expert weight never enters autograd's saved-tensor storage, so training activation memory stays independent of the number of experts held between forward and backward — no custom kernel needed. - Cost: one extra dequantize per projection in backward; subsumed by full gradient checkpointing when that is enabled. Tests: test_experts4bit_forward_is_bit_exact_dequantize_linear pins forward == plain dequantize+linear at rtol=0/atol=0 (grad and no_grad); test_experts4bit_backward_saves_no_dequantized_weight uses saved_tensors_hooks to assert nothing weight-shaped (either orientation) is saved while a plain dequantize+linear control does save it, and that gradients match the control exactly. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 68cc784 commit 657c4bc

2 files changed

Lines changed: 134 additions & 8 deletions

File tree

bitsandbytes/nn/experts.py

Lines changed: 47 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
# This source code is licensed under the MIT license found in the
44
# LICENSE file in the root directory of this source tree.
55
from collections.abc import Callable
6+
import functools
67
from typing import Optional
78

89
import torch
@@ -13,6 +14,34 @@
1314
from bitsandbytes.functional import QuantState
1415

1516

17+
class _FrozenLinearRecomputeBackward(torch.autograd.Function):
18+
"""``F.linear`` against a frozen dequantized weight, re-dequantizing it in backward.
19+
20+
The weight produced by ``dequant_fn`` (a closure over the packed buffers) is an
21+
intermediate, not a Parameter, so a plain ``F.linear`` would stash it as a saved
22+
activation for the whole forward-to-backward window — one full-precision expert
23+
weight per projection per layer. Because the base is frozen, backward needs no
24+
gradient for the weight and only computes ``grad_output @ weight``; the weight can
25+
therefore be dropped after the forward matmul and re-dequantized on demand, keeping
26+
training memory independent of the number of experts held between forward and
27+
backward. Numerically identical to dequantize-then-``linear`` by construction — the
28+
forward *is* dequantize-then-linear; recomputation only changes what is saved, never
29+
what is computed.
30+
"""
31+
32+
@staticmethod
33+
def forward(ctx, x: torch.Tensor, dequant_fn: Callable[[], torch.Tensor]) -> torch.Tensor:
34+
ctx.dequant_fn = dequant_fn
35+
return F_nn.linear(x, dequant_fn())
36+
37+
@staticmethod
38+
def backward(ctx, grad_output: torch.Tensor):
39+
grad_x = None
40+
if ctx.needs_input_grad[0]:
41+
grad_x = grad_output @ ctx.dequant_fn()
42+
return grad_x, None
43+
44+
1645
class Experts4bit(nn.Module):
1746
"""4-bit quantized storage for fused Mixture-of-Experts expert weights.
1847
@@ -33,8 +62,10 @@ class Experts4bit(nn.Module):
3362
mechanism with no custom save/load hooks.
3463
3564
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.
65+
the reference fused-experts forward. In training, the dequantized weight is not kept
66+
as a saved activation: it is re-dequantized on demand in backward (see
67+
:class:`_FrozenLinearRecomputeBackward`), so activation memory stays independent of
68+
the number of experts. Grouped-GEMM is intentionally left for future work.
3869
3970
<Tip warning={true}>This feature is experimental and may change in future releases.</Tip>
4071
@@ -217,6 +248,16 @@ def _dequantize_expert(
217248
# transpose back-compat shim — keyed on A.shape[0] == 1 — from firing).
218249
return F.dequantize_4bit(packed[expert_idx].reshape(-1, 1), quant_state=quant_state)
219250

251+
def _project(self, packed, absmax, shape, expert_idx, x, compute_dtype):
252+
"""One expert projection: dequantize + ``linear``, re-dequantizing in backward.
253+
254+
The recompute closure is just :meth:`_dequantize_expert`; no gradient is ever
255+
produced for the frozen packed storage.
256+
"""
257+
dequant_fn = functools.partial(self._dequantize_expert, packed, absmax, shape, expert_idx, compute_dtype)
258+
return _FrozenLinearRecomputeBackward.apply(x, dequant_fn)
259+
260+
220261
def forward(
221262
self,
222263
hidden_states: torch.Tensor,
@@ -238,20 +279,18 @@ def forward(
238279
top_k_pos, token_idx = torch.where(expert_mask[expert_idx])
239280
current_state = hidden_states[token_idx]
240281

241-
gate_up_w = self._dequantize_expert(
242-
self.gate_up_proj, self.gate_up_absmax, self._gate_up_shape, expert_idx, compute_dtype
282+
proj = self._project(
283+
self.gate_up_proj, self.gate_up_absmax, self._gate_up_shape, expert_idx, current_state, compute_dtype
243284
)
244-
proj = F_nn.linear(current_state, gate_up_w)
245285
if self.has_gate:
246286
gate, up = proj.chunk(2, dim=-1)
247287
current_hidden = self.act_fn(gate) * up
248288
else:
249289
current_hidden = self.act_fn(proj)
250290

251-
down_w = self._dequantize_expert(
252-
self.down_proj, self.down_absmax, self._down_shape, expert_idx, compute_dtype
291+
current_hidden = self._project(
292+
self.down_proj, self.down_absmax, self._down_shape, expert_idx, current_hidden, compute_dtype
253293
)
254-
current_hidden = F_nn.linear(current_hidden, down_w)
255294
current_hidden = current_hidden * top_k_weights[token_idx, top_k_pos, None]
256295
final_hidden_states.index_add_(0, token_idx, current_hidden.to(final_hidden_states.dtype))
257296

tests/test_experts4bit.py

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -347,3 +347,90 @@ def test_experts4bit_shapes(device, num_experts, hidden, inter):
347347
top_k_weights = torch.softmax(torch.randn(n_tok, TOP_K, device=device), dim=-1)
348348
out = module(hidden_states, top_k_index, top_k_weights)
349349
assert out.shape == (n_tok, hidden) and torch.isfinite(out).all()
350+
351+
352+
# --- recompute-in-backward projection path ----------------------------------------------
353+
# _project routes every expert projection through _FrozenLinearRecomputeBackward: the forward IS
354+
# dequantize + F.linear (bit-exact by construction, every device and grad mode), and the backward
355+
# re-dequantizes the frozen weight on demand instead of keeping it as a saved activation. These
356+
# tests pin both halves of that contract — numerics (grad mode changes nothing; matches a plain
357+
# dequantize+linear reference bit-for-bit) and memory (no [out, in] weight is ever saved).
358+
359+
360+
def _dequantized_expert_stacks(module):
361+
gate_up_deq = torch.stack(
362+
[
363+
module._dequantize_expert(
364+
module.gate_up_proj, module.gate_up_absmax, module._gate_up_shape, e, torch.float32
365+
)
366+
for e in range(NUM_EXPERTS)
367+
]
368+
)
369+
down_deq = torch.stack(
370+
[
371+
module._dequantize_expert(module.down_proj, module.down_absmax, module._down_shape, e, torch.float32)
372+
for e in range(NUM_EXPERTS)
373+
]
374+
)
375+
return gate_up_deq, down_deq
376+
377+
378+
@pytest.mark.parametrize("device", get_available_devices())
379+
def test_experts4bit_forward_is_bit_exact_dequantize_linear(device):
380+
"""Forward equals a plain dequantize+linear reference bit-for-bit, in and out of grad mode."""
381+
gate_up, down = _random_expert_weights(torch.float32, device)
382+
module = Experts4bit.from_float(gate_up, down, compute_dtype=torch.float32)
383+
hidden_states, top_k_index, top_k_weights = _random_routing(device)
384+
385+
gate_up_deq, down_deq = _dequantized_expert_stacks(module)
386+
ref = _reference_forward(gate_up_deq, down_deq, hidden_states, top_k_index, top_k_weights)
387+
388+
out_grad_mode = module(hidden_states, top_k_index, top_k_weights)
389+
with torch.no_grad():
390+
out_no_grad = module(hidden_states, top_k_index, top_k_weights)
391+
392+
torch.testing.assert_close(out_grad_mode, ref, rtol=0, atol=0)
393+
torch.testing.assert_close(out_no_grad, ref, rtol=0, atol=0)
394+
395+
396+
@pytest.mark.parametrize("device", get_available_devices())
397+
def test_experts4bit_backward_saves_no_dequantized_weight(device):
398+
"""The dequantized expert weights are dropped after each forward matmul (re-dequantized in
399+
backward), so nothing weight-shaped reaches autograd's saved-tensor storage — while a plain
400+
dequantize+linear control does save them. Gradients still match the control exactly."""
401+
gate_up, down = _random_expert_weights(torch.float32, device)
402+
module = Experts4bit.from_float(gate_up, down, compute_dtype=torch.float32)
403+
hidden_states, top_k_index, top_k_weights = _random_routing(device)
404+
# F.linear's backward may save the weight either as-is or pre-transposed (device/impl
405+
# dependent), so match both orientations.
406+
weight_shapes = set()
407+
for shape in (module._gate_up_shape, module._down_shape):
408+
weight_shapes.add(tuple(shape))
409+
weight_shapes.add(tuple(reversed(shape)))
410+
411+
def run_recording_saved_shapes(fn, x):
412+
saved = []
413+
414+
def pack(t):
415+
saved.append(tuple(t.shape))
416+
return t
417+
418+
with torch.autograd.graph.saved_tensors_hooks(pack, lambda t: t):
419+
out = fn(x)
420+
return out, saved
421+
422+
x_mod = hidden_states.detach().clone().requires_grad_(True)
423+
out_mod, saved_mod = run_recording_saved_shapes(lambda x: module(x, top_k_index, top_k_weights), x_mod)
424+
assert not (set(saved_mod) & weight_shapes)
425+
426+
gate_up_deq, down_deq = _dequantized_expert_stacks(module)
427+
x_ref = hidden_states.detach().clone().requires_grad_(True)
428+
out_ref, saved_ref = run_recording_saved_shapes(
429+
lambda x: _reference_forward(gate_up_deq, down_deq, x, top_k_index, top_k_weights), x_ref
430+
)
431+
assert set(saved_ref) & weight_shapes
432+
433+
out_mod.sum().backward()
434+
out_ref.sum().backward()
435+
torch.testing.assert_close(out_mod, out_ref, rtol=0, atol=0)
436+
torch.testing.assert_close(x_mod.grad, x_ref.grad, rtol=0, atol=0)

0 commit comments

Comments
 (0)