Repository navigation
Description
System Info
- bitsandbytes 0.50.2 (PyPI), torch 2.11.0, Python 3.13.13, Linux x86_64
- Reproduced on the CPU backend (CUDA hidden with
CUDA_VISIBLE_DEVICES=""). The CUDA, default and Triton backends compute the same clip factor (see Root cause).
Reproduction
LAMB, LAMB32bit, LARS, LARS32bit and the reference PytorchLARS clip the update with the trust ratio s = max_unorm * ||p|| / ||u||. For a parameter with ||p|| = 0, s = 0, so the parameter stays exactly 0 forever, whatever its gradient. That covers every zero-initialised tensor: LayerNorm/BatchNorm biases (PyTorch's default init), zero-initialised Linear biases (BERT/GPT-style init), LoRA up-projection ("B") matrices, and zero-init residual gains (torchvision.models.resnet50(zero_init_residual=True), timm ResNets by default). LAMB8bit / LARS8bit are affected too, because tensors smaller than min_8bit_size (4096) take the 32-bit path.
# LAMB / LARS: a parameter whose norm is 0 (zero init: LoRA "B" matrix, LayerNorm/BatchNorm bias, zero-init residual
# gains) never moves, although its gradient is nonzero.
import torch
import bitsandbytes as bnb
print(f"bitsandbytes {bnb.__version__}, torch {torch.__version__}, device cpu")
torch.manual_seed(0)
x, y = torch.randn(256, 64), torch.randn(256, 32)
W = torch.randn(32, 64) / 8 # frozen base weight
def train(make_opt, steps=100):
torch.manual_seed(1)
A = torch.nn.Parameter(torch.randn(8, 64) / 8) # LoRA down projection (random init)
B = torch.nn.Parameter(torch.zeros(32, 8)) # LoRA up projection (zero init, as in PEFT)
ln = torch.nn.LayerNorm(32) # bias initialised to 0
opt = make_opt([A, B, ln.weight, ln.bias])
loss_fn = lambda: ((ln(x @ W.T + (x @ A.T) @ B.T) - y) ** 2).mean()
loss0 = loss_fn().item()
for _ in range(steps):
opt.zero_grad()
loss = loss_fn()
loss.backward()
opt.step()
gB = torch.autograd.grad(loss_fn(), B)[0]
return B.norm().item(), ln.bias.norm().item(), gB.norm().item(), loss0, loss_fn().item()
optimizers = {
"bnb.optim.LAMB(lr=1e-2)": lambda p: bnb.optim.LAMB(p, lr=1e-2),
"bnb.optim.LAMB8bit(lr=1e-2)": lambda p: bnb.optim.LAMB8bit(p, lr=1e-2),
"bnb.optim.LARS(lr=1e-1, momentum=0.9)": lambda p: bnb.optim.LARS(p, lr=1e-1, momentum=0.9),
"bnb.optim.PytorchLARS(lr=1e-1, momentum=0.9)": lambda p: bnb.optim.PytorchLARS(p, lr=1e-1, momentum=0.9),
"torch.optim.AdamW(lr=1e-2), for reference": lambda p: torch.optim.AdamW(p, lr=1e-2, weight_decay=0.0),
}
print(f"{'optimizer':46s} {'|B|':>9s} {'|ln.bias|':>10s} {'|dL/dB|':>8s} loss: start -> 100 steps")
for name, make_opt in optimizers.items():
nB, nb, gB, l0, l1 = train(make_opt)
print(f"{name:46s} {nB:9.3g} {nb:10.3g} {gB:8.3g} {l0:.4f} -> {l1:.4f}")
if name.startswith("bnb"):
assert nB == 0.0 and nb == 0.0 and gB > 0 # frozen at exactly 0 despite a nonzero gradient
print("BUG REPRODUCED: zero-norm parameters never move under bnb LAMB / LARS")Output:
bitsandbytes 0.50.2, torch 2.11.0+cu128, device cpu
optimizer |B| |ln.bias| |dL/dB| loss: start -> 100 steps
bnb.optim.LAMB(lr=1e-2) 0 0 0.0584 1.9588 -> 1.6019
bnb.optim.LAMB8bit(lr=1e-2) 0 0 0.0584 1.9588 -> 1.6019
bnb.optim.LARS(lr=1e-1, momentum=0.9) 0 0 0.0594 1.9588 -> 1.6309
bnb.optim.PytorchLARS(lr=1e-1, momentum=0.9) 0 0 0.0594 1.9588 -> 1.6309
torch.optim.AdamW(lr=1e-2), for reference 5.79 0.355 0.0049 1.9588 -> 0.8771
BUG REPRODUCED: zero-norm parameters never move under bnb LAMB / LARS
B and the LayerNorm bias are still exactly 0 after 100 steps, although dL/dB is nonzero. Because B = 0, the adapter's output is 0 whatever A learns, so LoRA training with these optimizers is a no-op.
On larger models (same mechanism, measured with 0.50.2):
torchvision.models.resnet50(zero_init_residual=True): all 69 zero-initialised tensors (53 BatchNorm biases and the 16 residual-branch BatchNorm weights) are still exactly 0 after training withLAMBorLARS, so every bottleneck branch outputs 0 forever.BertForMaskedLM(transformers init): every Linear bias and LayerNorm bias is frozen.- A CIFAR-10 ResNet-14 with zero-init residual gains trained with
LAMB: 37.4 / 37.8% test accuracy as released vs 45.7 / 44.6% with the fix below (8k-image training subset, 12 epochs, 2 seeds). - A LoRA fine-tune of a small MLP (r = 8, zero-init up-projection, sklearn digits shifted by one pixel): accuracy stays at the base model's 0.12-0.14 with
LAMB, vs 0.95-0.96 with the fix (3 seeds).
Expected behavior
A parameter with a nonzero gradient should move. The docs describe LAMB as clipping the trust ratio "to prevent overly large or small updates" (lamb.mdx). The LAMB paper (arXiv:1904.00962, Sec. 3) clips with phi(z) = min(max(z, gamma_l), gamma_u), with a positive lower bound. The common implementations use a trust ratio of 1 when ||w|| = 0: apex FusedLAMB, DeepSpeed FusedLamb, timm Lamb / Lars, torch-optimizer Lamb, Optax, and the LARS authors' NVCaffe implementation (rate = 1 unless w_norm > 0 and wgrad_norm > 0).
Root cause
functional.py#L1142-L1144(0.50.2, 08a9956):param_norm = torch.norm(p.data.float())is passed to the 32-bit update withmax_unorm.- The clip:
if (update_norm > max_unorm * param_norm) update_scale = max_unorm * param_norm / update_norm. It appears in CUDAkernels.cu#L633-L642(2-state, LAMB) andkernels.cu#L819-L828(1-state, LARS, with+ eps, butLARSpasseseps=0), and in the CPU backendbackends/cpu/ops.py#L327-L341. The default and Triton backends use the same rule. - With
param_norm = 0and any nonzero update,update_scale = 0. Weight decay of 0 is 0, so by induction the parameter stays 0. PytorchLARShas the same rule (optim/lars.py#L250-L255).
main (a616886) is unchanged at the same lines.
Proposed fix
Treat a zero-norm parameter as unclipped (trust ratio 1), in the one place every backend goes through, and the same guard in PytorchLARS. param_norm is already converted to a Python float by the op schema (float param_norm), so the comparison adds no extra device sync:
--- a/bitsandbytes/functional.py
+++ b/bitsandbytes/functional.py
@@ -1142,6 +1142,8 @@
param_norm = 0.0
if max_unorm > 0.0:
param_norm = torch.norm(p.data.float())
+ if param_norm == 0: # zero-norm tensor: trust ratio 1, i.e. do not clip
+ max_unorm = 0.0
is_on_gpu([g, p, state1, state2, unorm_vec])
torch.ops.bitsandbytes.optimizer_update_32bit(
--- a/bitsandbytes/optim/lars.py
+++ b/bitsandbytes/optim/lars.py
@@ -251,7 +251,7 @@
assert p.dtype == torch.float32
pnorm = torch.norm(p.detach())
unorm = torch.norm(update)
- if unorm > max_unorm * pnorm:
+ if pnorm > 0 and unorm > max_unorm * pnorm:
update_scale = max_unorm * pnorm / unorm
p.add_(update, alpha=-lr * update_scale)(An equivalent kernel-level fix is param_norm > 0.0f && in the two CUDA conditions and the same guard in the CPU, default and Triton backends.)
Verifying the fix
fix_check.py applies exactly this diff to the installed 0.50.2 functions in memory (F.optimizer_update_32bit and PytorchLARS.step) and re-runs the reproduction. It also re-runs the same model with no zero-norm parameters (B and the LayerNorm bias initialised randomly) and compares every parameter after 100 steps with the released code (torch.equal):
fix_check.py (patching part)
import inspect, textwrap
import bitsandbytes.functional as F
import bitsandbytes.optim.lars as lars
def patch(obj, module, edits):
src = textwrap.dedent(inspect.getsource(obj))
for old, new in edits:
assert src.count(old) == 1, old
src = src.replace(old, new)
ns = {}
exec(compile(src, module.__file__, "exec"), vars(module), ns)
return ns[obj.__name__]
F.optimizer_update_32bit = patch(F.optimizer_update_32bit, F, [(
" param_norm = torch.norm(p.data.float())\n",
" param_norm = torch.norm(p.data.float())\n"
" if param_norm == 0: # zero-norm tensor: trust ratio 1, i.e. do not clip\n"
" max_unorm = 0.0\n")])
lars.PytorchLARS.step = patch(lars.PytorchLARS.step, lars, [(
"if unorm > max_unorm * pnorm:", "if pnorm > 0 and unorm > max_unorm * pnorm:")])
# ... then the training loop of the reproduction, fixed vs releasedbitsandbytes 0.50.2, torch 2.11.0+cu128, device cpu
optimizer (fixed) |B| |ln.bias| loss after 100 steps (released) nonzero-init run: bit-identical to released
bnb.optim.LAMB(lr=1e-2) 0.199 0.0645 1.5883 (1.6019) True
bnb.optim.LAMB8bit(lr=1e-2) 0.199 0.0645 1.5883 (1.6019) True
bnb.optim.LARS(lr=1e-1, momentum=0.9) 0.00893 0.00375 1.6303 (1.6309) True
bnb.optim.PytorchLARS(lr=1e-1, momentum=0.9) 0.00893 0.00375 1.6303 (1.6309) True
FIX OK: zero-norm parameters train; runs without zero-norm parameters are unchanged
After the first (unclipped) step the parameter has a nonzero norm, and the usual trust-ratio clip applies from then on. Runs without zero-norm parameters are bit-identical to the released code.
I would be happy to open a PR with this fix and a test with a zero-initialised parameter for LAMB and LARS.
Activity
SalyyS1 commentedon Nov 12, 2026
For LoRA specifically, this bug is even worse than freezing
lora_Balone—it causes a permanent coupled deadlock of bothlora_Aandlora_B:lora_Ahas zero gradient (solora_Acannot move untillora_Bbecomes non-zero), whilelora_Bhas a non-zero gradient butif (unorm > max_unorm * pnorm)withpnorm == 0.0fand setsupdate_scale = max_unorm * pnorm / unorm = 0.0f,lora_Bremains identicallylora_Anorlora_Bever moves for the entire training run (and iflora_B.numel() < min_8bit_size = 4096, e.g.d_out = 512, r = 4, evenLAMB8bit/LARS8bithits this 32-bit path).In the original LAMB paper (You et al., ICLR 2020) and$|w_t|_2 == 0$ or $|u_t|_2 == 0$ :
apex.optimizers.FusedLAMB, the trust ratio is explicitly defined to fall back to1.0whenever eitherGuarding with
pnorm > 0.0facross the CPU, CUDA, and Triton kernels matchesFusedLAMBand unblocks step-1 escape from zero initialization.(We actually had to add a dedicated$|\Delta W|_F = |B A|_F = 0$ in
DEAD_LAYERcheck forloraspectraloraspectra auditbecause users training LoRAs with trust-ratio optimizers or rank-expansion schedules kept ending up with.safetensorscheckpoints wherelora_Bwas all zeros despite normal-looking training loss logs.)Andreas-Hum commentedon Nov 12, 2026
Thanks, that's right, and it makes the bug worse for LoRA. I checked it on a single LoRA layer (d = 512, PEFT's default init: Kaiming
lora_A, zerolora_B), training for 50 steps toward a low-rank target. With bitsandbytes 0.50.2:torch.optim.AdamW(reference)LAMB,LARS,PytorchLARSlora_Anorlora_BchangesLAMB8bitLAMB8bitis only affected at r = 4, becauselora_Bthen has 2048 <min_8bit_size= 4096 elements and takes the 32-bit path. At r = 16 it takes the 8-bit blockwise path, which doesn't implement the trust ratio at all.With #2117 applied, every one of them moves. ‖B‖ after 50 steps at r = 4 (r = 16 is similar, except that
LAMB8bittakes the 8-bit path and trains either way):LAMB, lr 1e-3LAMB8bit, lr 1e-3LARS/PytorchLARS, lr 1After that first step,$\text{lr} \cdot \lVert B \rVert$ , so a tensor that starts at zero grows by roughly a factor of $(1 + \text{lr})$ per step. LAMB as written in the paper (Algorithm 2 in pure PyTorch, with trust ratio 1 when a norm is 0) does the same: it reaches 4.1e-2, against 4.5e-2 for the fixed bitsandbytes
lora_Bstill grows slowly. The trust ratio limits each step to aboutLAMB, so this slow start comes from the method itself, not from bitsandbytes. For LoRA it is probably still worth excludinglora_Bfrom the layer adaptation, as many LAMB setups do for biases.Script:
lora_lamb.py