Skip to content

Commit 0315ebf

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 — and the mechanism is scheme-agnostic (nf4/fp4/int8/fp8/passthrough), no per-scheme 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 fb1b247 commit 0315ebf

2 files changed

Lines changed: 128 additions & 5 deletions

File tree

bitsandbytes/nn/experts.py

Lines changed: 41 additions & 5 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
@@ -42,6 +43,34 @@ def _build_code(quant_type: str, device) -> Optional[torch.Tensor]:
4243
return code.to(device) if device is not None else code
4344

4445

46+
class _FrozenLinearRecomputeBackward(torch.autograd.Function):
47+
"""``F.linear`` against a frozen dequantized weight, re-dequantizing it in backward.
48+
49+
The weight produced by ``dequant_fn`` (a closure over the packed buffers) is an
50+
intermediate, not a Parameter, so a plain ``F.linear`` would stash it as a saved
51+
activation for the whole forward-to-backward window — one full-precision expert
52+
weight per projection per layer. Because the base is frozen, backward needs no
53+
gradient for the weight and only computes ``grad_output @ weight``; the weight can
54+
therefore be dropped after the forward matmul and re-dequantized on demand, keeping
55+
training memory independent of the number of experts held between forward and
56+
backward. Numerically identical to dequantize-then-``linear`` by construction — the
57+
forward *is* dequantize-then-linear; recomputation only changes what is saved, never
58+
what is computed.
59+
"""
60+
61+
@staticmethod
62+
def forward(ctx, x: torch.Tensor, dequant_fn: Callable[[], torch.Tensor]) -> torch.Tensor:
63+
ctx.dequant_fn = dequant_fn
64+
return F_nn.linear(x, dequant_fn())
65+
66+
@staticmethod
67+
def backward(ctx, grad_output: torch.Tensor):
68+
grad_x = None
69+
if ctx.needs_input_grad[0]:
70+
grad_x = grad_output @ ctx.dequant_fn()
71+
return grad_x, None
72+
73+
4574
class ExpertsNbit(nn.Module):
4675
"""Low-bit quantized storage for fused Mixture-of-Experts expert weights.
4776
@@ -72,8 +101,11 @@ class ExpertsNbit(nn.Module):
72101
``state_dict`` mechanism with no custom save/load hooks.
73102
74103
The forward pass dequantizes a single expert at a time (a per-expert loop), mirroring
75-
the reference fused-experts forward. Grouped-GEMM is intentionally left for future
76-
work.
104+
the reference fused-experts forward. In training, the dequantized weight is not kept
105+
as a saved activation: it is re-dequantized on demand in backward (see
106+
:class:`_FrozenLinearRecomputeBackward`), for every storage scheme, so activation
107+
memory stays independent of the number of experts. Grouped-GEMM is intentionally left
108+
for future work.
77109
78110
<Tip warning={true}>This feature is experimental and may change in future releases.</Tip>
79111
@@ -299,9 +331,13 @@ def _dequantize_expert(
299331
return F.dequantize_4bit(packed[expert_idx].reshape(-1, 1), quant_state=quant_state)
300332

301333
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)
334+
"""One expert projection: dequantize + ``linear``, re-dequantizing in backward.
335+
336+
Works identically for every storage scheme — the recompute closure is just
337+
:meth:`_dequantize_expert` — and never produces a gradient for the frozen storage.
338+
"""
339+
dequant_fn = functools.partial(self._dequantize_expert, packed, absmax, shape, expert_idx, compute_dtype)
340+
return _FrozenLinearRecomputeBackward.apply(x, dequant_fn)
305341

306342
def forward(
307343
self,

tests/test_experts4bit.py

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

0 commit comments

Comments
 (0)