Visitar URL original
LAMB / LARS never update a parameter whose norm is zero (zero-initialised biases, LoRA B matrices, zero-init residual gains) · Issue #2116 · bitsandbytes-foundation/bitsandbytes · GitHub
Skip to content

LAMB / LARS never update a parameter whose norm is zero (zero-initialised biases, LoRA B matrices, zero-init residual gains) #2116

Open

Description

@Andreas-Hum

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 with LAMB or LARS, 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 with max_unorm.
  • The clip: if (update_norm > max_unorm * param_norm) update_scale = max_unorm * param_norm / update_norm. It appears in CUDA kernels.cu#L633-L642 (2-state, LAMB) and kernels.cu#L819-L828 (1-state, LARS, with + eps, but LARS passes eps=0), and in the CPU backend backends/cpu/ops.py#L327-L341. The default and Triton backends use the same rule.
  • With param_norm = 0 and any nonzero update, update_scale = 0. Weight decay of 0 is 0, so by induction the parameter stays 0.
  • PytorchLARS has 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 released
bitsandbytes 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

  1. SalyyS1 commented on Nov 12, 2026

    @SalyyS1

    For LoRA specifically, this bug is even worse than freezing lora_B alone—it causes a permanent coupled deadlock of both lora_A and lora_B:

    1. Under standard LoRA initialization ($A \sim \text{Kaiming}, B = \mathbf{0}$), the gradients at step $t=0$ are:
      $$\nabla_B \mathcal{L} = (\nabla_{\Delta W} \mathcal{L}) A^T \ne \mathbf{0}, \qquad \nabla_A \mathcal{L} = B^T (\nabla_{\Delta W} \mathcal{L}) = \mathbf{0}$$
    2. At step 1, lora_A has zero gradient (so lora_A cannot move until lora_B becomes non-zero), while lora_B has a non-zero gradient but $|B|_2 = 0$.
    3. Because the trust-ratio clip checks if (unorm > max_unorm * pnorm) with pnorm == 0.0f and sets update_scale = max_unorm * pnorm / unorm = 0.0f, lora_B remains identically $\mathbf{0}$ after step 1.
    4. Consequently, $\nabla_A \mathcal{L} = B^T (\nabla_{\Delta W} \mathcal{L})$ remains $\mathbf{0}$ at step 2, 3, ..., $\infty$ as well, so neither lora_A nor lora_B ever moves for the entire training run (and if lora_B.numel() < min_8bit_size = 4096, e.g. d_out = 512, r = 4, even LAMB8bit / LARS8bit hits this 32-bit path).

    In the original LAMB paper (You et al., ICLR 2020) and apex.optimizers.FusedLAMB, the trust ratio is explicitly defined to fall back to 1.0 whenever either $|w_t|_2 == 0$ or $|u_t|_2 == 0$:

    float update_scale = 1.0f;
    if (pnorm > 0.0f && unorm > max_unorm * pnorm) {
        update_scale = (max_unorm * pnorm) / unorm;
    }

    Guarding with pnorm > 0.0f across the CPU, CUDA, and Triton kernels matches FusedLAMB and unblocks step-1 escape from zero initialization.

    (We actually had to add a dedicated DEAD_LAYER check for $|\Delta W|_F = |B A|_F = 0$ in loraspectra loraspectra audit because users training LoRAs with trust-ratio optimizers or rank-expansion schedules kept ending up with .safetensors checkpoints where lora_B was all zeros despite normal-looking training loss logs.)

  2. Andreas-Hum commented on Nov 12, 2026

    @Andreas-Hum
    Author

    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, zero lora_B), training for 50 steps toward a low-rank target. With bitsandbytes 0.50.2:

    optimizer r = 4 r = 16
    torch.optim.AdamW (reference) trains, loss 0.0159 → 0.0090 trains, 0.0159 → 0.0011
    LAMB, LARS, PytorchLARS neither lora_A nor lora_B changes neither changes
    LAMB8bit neither changes trains

    LAMB8bit is only affected at r = 4, because lora_B then 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 LAMB8bit takes the 8-bit path and trains either way):

    optimizer 0.50.2 with #2117
    LAMB, lr 1e-3 0 4.5e-2
    LAMB8bit, lr 1e-3 0 4.5e-2
    LARS / PytorchLARS, lr 1 0 1.7e-3

    After that first step, lora_B still grows slowly. The trust ratio limits each step to about $\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 LAMB, so this slow start comes from the method itself, not from bitsandbytes. For LoRA it is probably still worth excluding lora_B from the layer adaptation, as many LAMB setups do for biases.

    Script:

    lora_lamb.py
    """Does LAMB/LARS freeze both LoRA factors when lora_B starts at zero? Pure LoRA layer, CPU."""
    import math
    import bitsandbytes as bnb
    import torch
    
    print("bitsandbytes", bnb.__version__, bnb.__file__)
    torch.manual_seed(0)
    d, n = 512, 1024
    W0 = torch.randn(d, d) / math.sqrt(d)
    dW = torch.randn(d, 8) @ torch.randn(8, d) / d  # low-rank target change
    X = torch.randn(n, d)
    Y = X @ (W0 + dW).T
    
    
    class PaperLAMB(torch.optim.Optimizer):
        """LAMB as in You et al. (2020), Algorithm 2, phi = identity, no weight decay; trust ratio 1 if a norm is 0."""
    
        def __init__(self, params, lr, betas=(0.9, 0.999), eps=1e-6):
            super().__init__(params, dict(lr=lr, betas=betas, eps=eps))
    
        @torch.no_grad()
        def step(self):
            for g in self.param_groups:
                for p in g["params"]:
                    st = self.state[p]
                    if not st:
                        st["t"], st["m"], st["v"] = 0, torch.zeros_like(p), torch.zeros_like(p)
                    st["t"] += 1
                    b1, b2 = g["betas"]
                    st["m"].mul_(b1).add_(p.grad, alpha=1 - b1)
                    st["v"].mul_(b2).addcmul_(p.grad, p.grad, value=1 - b2)
                    u = (st["m"] / (1 - b1 ** st["t"])) / ((st["v"] / (1 - b2 ** st["t"])).sqrt() + g["eps"])
                    wn, un = p.norm(), u.norm()
                    ratio = wn / un if wn > 0 and un > 0 else 1.0
                    p.sub_(g["lr"] * ratio * u)
    
    
    def run(name, make_opt, r, steps=50):
        torch.manual_seed(1)
        A = torch.nn.Parameter(torch.empty(r, d))
        torch.nn.init.kaiming_uniform_(A, a=math.sqrt(5))  # PEFT's default init
        B = torch.nn.Parameter(torch.zeros(d, r))
        A0 = A.detach().clone()
        opt = make_opt([A, B])
        loss0 = None
        for t in range(steps):
            loss = ((X @ W0.T + (X @ A.T) @ B.T - Y) ** 2).mean()
            opt.zero_grad()
            loss.backward()
            if t == 0:
                loss0 = loss.item()
                gA0 = A.grad.norm().item()
            opt.step()
        loss = ((X @ W0.T + (X @ A.T) @ B.T - Y) ** 2).mean().item()
        print(f"{name:34s} r={r:2d} |B|={B.detach().norm():.3e} |A-A0|={(A.detach() - A0).norm():.3e} "
              f"grad_A0={gA0:.0e} loss {loss0:.5f} -> {loss:.5f}")
    
    
    for r in (4, 16):
        run("torch.optim.AdamW (reference)", lambda p: torch.optim.AdamW(p, lr=1e-3, weight_decay=0), r)
        run("bnb LAMB", lambda p: bnb.optim.LAMB(p, lr=1e-3), r)
        run("bnb LAMB8bit", lambda p: bnb.optim.LAMB8bit(p, lr=1e-3), r)
        run("LAMB paper reference (pure torch)", lambda p: PaperLAMB(p, lr=1e-3), r)
        for lr in (1e-3, 1.0):
            run(f"bnb LARS lr={lr:g}", lambda p: bnb.optim.LARS(p, lr=lr, momentum=0.9), r)
            run(f"bnb PytorchLARS lr={lr:g}", lambda p: bnb.optim.PytorchLARS(p, lr=lr, momentum=0.9), r)
            run(f"torch.optim.SGD lr={lr:g} (reference)", lambda p: torch.optim.SGD(p, lr=lr, momentum=0.9), r)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions