fix(nn/Linear4bit): consume QuantState keys in _load_from_state_dict (#1946)#1958
fix(nn/Linear4bit): consume QuantState keys in _load_from_state_dict (#1946)#1958Anai-Guo wants to merge 1 commit into
Conversation
…itsandbytes-foundation#1946) Linear4bit overrides _save_to_state_dict to write weight.absmax / weight.quant_map / weight.nested_* / weight.quant_state.bitsandbytes__* alongside the packed weight, but inherits nn.Linear._load_from_state_dict which only consumes weight and bias. Result: - strict=True load raises Unexpected key(s) in state_dict for every QuantState component. - strict=False silently drops them and the destination layer keeps the freshly-quantized quant_state from the prior .to('cuda') call, which does not match the packed bytes that were just loaded. This mirrors what Linear8bitLt already does for SCB (_load_from_state_dict at modules.py:1119): walk unexpected_keys for entries under '<prefix>weight.', collect them into a qs_dict, reconstruct via QuantState.from_dict, install on self.weight, and remove the consumed keys from unexpected_keys. Fixes bitsandbytes-foundation#1946
|
@SunMarc Could you help me understand if this is reasonable from the transformers/accelerate integration side? |
|
I spent some time testing this against the current HuggingFace stack to help answer the transformers/accelerate question, since that (plus the peft sensitivity) is what led to the original 2023 version being walked back in #864. Posting the results in case they help. Environment: What the bug actually costsOne thing worth flagging: the impact is broader than the top-level
The Integration paths (with this PR applied)
So on current versions the transformers and PEFT load paths route 4-bit weights through their own mechanisms, and they either skip this override entirely or hit it as a safe no-op. I couldn't get it to error or change behavior in any of those flows, and the peft#1095-style breakage doesn't seem to reproduce anymore. The override only does real work on the plain One thing I couldn't rule out: a manual Suggested regression testThe existing class _Wrapper(torch.nn.Module):
def __init__(self, quant_type, compress_statistics):
super().__init__()
self.fc = bnb.nn.Linear4bit(
64, 64, bias=False, compute_dtype=torch.float32,
compress_statistics=compress_statistics, quant_type=quant_type,
)
@pytest.mark.parametrize("device", get_available_devices())
@pytest.mark.parametrize("quant_type", ["nf4", "fp4"])
@pytest.mark.parametrize("compress_statistics", TRUE_FALSE, ids=id_formatter("compress_statistics"))
@pytest.mark.parametrize("strict", TRUE_FALSE, ids=id_formatter("strict"))
def test_linear4bit_load_from_state_dict(device, quant_type, compress_statistics, strict):
def build(seed):
torch.manual_seed(seed)
net = _Wrapper(quant_type, compress_statistics)
with torch.no_grad():
net.fc.weight.data = torch.randn(64, 64) * 0.1
return net.to(device) # triggers 4-bit quantization
src = build(0)
dst = build(999) # different weights, so a no-op load is caught
result = dst.load_state_dict(src.state_dict(), strict=strict)
assert result.missing_keys == []
assert result.unexpected_keys == []
assert dst.fc.weight.quant_state is not None
assert torch.equal(src.fc.weight.quant_state.absmax, dst.fc.weight.quant_state.absmax)
x = torch.randn(8, 64, device=device)
with torch.no_grad():
assert torch.equal(src.fc(x), dst.fc(x))Happy to open a follow-up PR with this test (and a meta-device check) if that would help. |
Fixes #1946.
Problem
Linear4bit._save_to_state_dictwrites the packedweightand the QuantState components:weight.absmax,weight.quant_mapweight.nested_absmax,weight.nested_quant_map(whencompress_statistics=True)weight.quant_state.bitsandbytes__nf4/__fp4But
Linear4bitdoes not override_load_from_state_dict; it inheritsnn.Linear._load_from_state_dict, which only consumesweightandbias. So:load_state_dict(strict=True)raisesRuntimeError: Unexpected key(s) in state_dictfor every QuantState entry.load_state_dict(strict=False)silently drops them. The destination layer keeps the freshly-quantizedquant_statefrom the prior.to('cuda')call, which does not match the packed bytes that were just loaded — so forward passes return garbage. This is asymmetric vsLinear8bitLt, which already implements both halves (save atmodules.py:1095, load atmodules.py:1119).Fix
Add
Linear4bit._load_from_state_dict. After delegating tosuper(), walkunexpected_keysfor entries under<prefix>weight., collect them into aqs_dict, rebuild viaQuantState.from_dict(...), install onself.weight, and remove the consumed keys fromunexpected_keys. Mirrors the existingLinear8bitLtpattern.Reproducer (from the issue, abbreviated)
Notes
weight(andbias): when noqs_prefixkeys remain inunexpected_keys, the new code is a no-op early-return.load_state_dictmachinery already expects modules to implement when they extend_save_to_state_dict.Embedding4bithas the same issue but a separate_save_to_state_dictshape; happy to extend the fix there in a follow-up if maintainers prefer one PR per class.🤖 Generated with Claude Code