Skip to content

Commit 843f25e

Browse files
TimDettmersclaude
andcommitted
fix: Handle non-Int8Params weights in Linear8bitLt (tied weights)
When lm_head is converted to Linear8bitLt but its weight is tied to an embedding layer, weight tying replaces the Int8Params with a regular nn.Parameter that lacks SCB/CB attributes, causing AttributeError. Add an isinstance check in Linear8bitLt.forward() to fall back to F.linear when the weight is not Int8Params (e.g. due to weight tying). Also make _save_to_state_dict and _load_from_state_dict robust to non-Int8Params weights via getattr defaults. Fixes #1634 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent ed47966 commit 843f25e

2 files changed

Lines changed: 79 additions & 5 deletions

File tree

bitsandbytes/nn/modules.py

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1007,9 +1007,9 @@ def _save_to_state_dict(self, destination, prefix, keep_vars):
10071007
scb_name = "SCB"
10081008

10091009
# case 1: .cuda was called, SCB is in self.weight
1010-
param_from_weight = getattr(self.weight, scb_name)
1010+
param_from_weight = getattr(self.weight, scb_name, None)
10111011
# case 2: self.init_8bit_state was called, SCB is in self.state
1012-
param_from_state = getattr(self.state, scb_name)
1012+
param_from_state = getattr(self.state, scb_name, None)
10131013

10141014
key_name = prefix + f"{scb_name}"
10151015

@@ -1048,18 +1048,19 @@ def _load_from_state_dict(
10481048
for key in unexpected_copy:
10491049
input_name = key[len(prefix) :]
10501050
if input_name == "SCB":
1051-
if self.weight.SCB is None:
1051+
weight_scb = getattr(self.weight, "SCB", None)
1052+
if weight_scb is None:
10521053
# buffers not yet initialized, can't access them directly without quantizing first
10531054
raise RuntimeError(
10541055
"Loading a quantized checkpoint into non-quantized Linear8bitLt is "
10551056
"not supported. Please call module.cuda() before module.load_state_dict()",
10561057
)
10571058

10581059
input_param = state_dict[key]
1059-
self.weight.SCB.copy_(input_param)
1060+
weight_scb.copy_(input_param)
10601061

10611062
if self.state.SCB is not None:
1062-
self.state.SCB = self.weight.SCB
1063+
self.state.SCB = weight_scb
10631064

10641065
unexpected_keys.remove(key)
10651066

@@ -1085,6 +1086,11 @@ def to(self, *args, **kwargs):
10851086
return result
10861087

10871088
def forward(self, x: torch.Tensor):
1089+
# If weight is not Int8Params (e.g. due to weight tying with a non-quantized module
1090+
# like an embedding layer), fall back to regular linear. See issue #1634.
1091+
if not isinstance(self.weight, Int8Params):
1092+
return torch.nn.functional.linear(x, self.weight, self.bias)
1093+
10881094
self.state.is_training = self.training
10891095
if self.weight.CB is not None:
10901096
self.init_8bit_state()

tests/test_linear8bitlt.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -335,3 +335,71 @@ def test_linear8bitlt_device_movement(device):
335335

336336
# Accelerator outputs should match both times.
337337
torch.testing.assert_close(out_accelerator_2, out_accelerator, rtol=1e-8, atol=1e-8)
338+
339+
340+
class TiedWeightModel(torch.nn.Module):
341+
"""A minimal model with tied weights between an embedding and lm_head, mimicking
342+
architectures like OPT where lm_head.weight is shared with the embedding layer."""
343+
344+
def __init__(self, vocab_size, hidden_dim):
345+
super().__init__()
346+
self.embed_tokens = torch.nn.Embedding(vocab_size, hidden_dim)
347+
self.q_proj = torch.nn.Linear(hidden_dim, hidden_dim)
348+
self.v_proj = torch.nn.Linear(hidden_dim, hidden_dim)
349+
self.out_proj = torch.nn.Linear(hidden_dim, hidden_dim)
350+
self.lm_head = torch.nn.Linear(hidden_dim, vocab_size, bias=False)
351+
# Tie weights
352+
self.lm_head.weight = self.embed_tokens.weight
353+
354+
def forward(self, x):
355+
h = self.embed_tokens(x)
356+
h = self.out_proj(self.q_proj(h) + self.v_proj(h))
357+
return self.lm_head(h)
358+
359+
360+
@pytest.mark.parametrize("device", get_available_devices())
361+
def test_linear8bitlt_tied_weights_no_crash(device):
362+
"""Test that Linear8bitLt gracefully handles tied weights (issue #1634).
363+
364+
When lm_head is replaced with Linear8bitLt but its weight is tied to
365+
an embedding layer, the weight becomes a regular Parameter instead of
366+
Int8Params. The forward pass should still work via F.linear fallback.
367+
"""
368+
vocab_size, hidden_dim = 32, 64
369+
model = TiedWeightModel(vocab_size, hidden_dim)
370+
371+
skip_modules = ["q_proj", "v_proj"]
372+
373+
# Replace non-skipped linear layers with Linear8bitLt (simulating what
374+
# HuggingFace transformers does with llm_int8_skip_modules)
375+
from bitsandbytes.utils import replace_linear
376+
377+
model = replace_linear(
378+
model,
379+
lambda inf, outf, bias: Linear8bitLt(inf, outf, bias=bias, has_fp16_weights=False),
380+
skip_modules=skip_modules,
381+
copy_weights=True,
382+
)
383+
384+
# Re-tie weights (as transformers does after module replacement)
385+
model.lm_head.weight = model.embed_tokens.weight
386+
387+
model = model.to(device)
388+
389+
# Verify: skipped modules remain nn.Linear
390+
assert type(model.q_proj) is torch.nn.Linear, "q_proj should remain nn.Linear"
391+
assert type(model.v_proj) is torch.nn.Linear, "v_proj should remain nn.Linear"
392+
393+
# Verify: non-skipped, non-tied modules are Linear8bitLt
394+
assert isinstance(model.out_proj, Linear8bitLt), "out_proj should be Linear8bitLt"
395+
396+
# Verify: lm_head is Linear8bitLt but with a regular Parameter (from tying)
397+
assert isinstance(model.lm_head, Linear8bitLt), "lm_head should be Linear8bitLt"
398+
assert not isinstance(model.lm_head.weight, bnb.nn.Int8Params), (
399+
"lm_head.weight should be a regular Parameter due to tying"
400+
)
401+
402+
# Forward pass should NOT crash (this was the bug in issue #1634)
403+
x = torch.randint(0, vocab_size, (2, 8), device=device)
404+
output = model(x)
405+
assert output.shape == (2, 8, vocab_size)

0 commit comments

Comments
 (0)