Skip to content

Adam/AdamW/LAMB/AdEMAMix: weight decay applied in wrong order in default and Triton backends, diverging from CUDA kernel #2010

Description

@ErenAta16

System Info

bitsandbytes 0.49.2 (current PyPI release, pip install bitsandbytes), Python 3.12.10, Windows, CPU only (torch 2.x). Also confirmed present on the current main branch source. No GPU/CUDA needed to reproduce, since this is about the default and triton backend implementations diverging from the CUDA reference kernel, not about CUDA itself.

Reproduction

For Adam/AdamW/LAMB (optimizer_id == 3, i.e. adam/lamb share the same code path) and AdEMAMix (optimizer_id == 5), the default backend (bitsandbytes/backends/default/ops.py, used for MPS and any device without a dedicated compiled kernel) and the triton backend (bitsandbytes/backends/triton/kernels_optim.py) apply weight decay to the parameter before adding the optimizer update, while the CUDA reference kernel (csrc/kernels.cu) applies it after. This is a real, systematic numerical divergence between backends for the same optimizer with the same hyperparameters, not a floating-point rounding artifact.

CUDA reference, csrc/kernels.cu lines 700-710 (kOptimizer32bit2State, case ADAM):

s1_vals[j] = s1_vals[j] * beta1 + ((1.0f - beta1) * (float)g_vals[j]);
s2_vals[j] = s2_vals[j] * beta2 + ((1.0f - beta2) * (float)g_vals[j] * (float)g_vals[j]);
p_vals[j] = ((float)p_vals[j]) +
            (update_scale * step_size * (s1_vals[j] / (sqrtf(s2_vals[j]) + (eps * correction2))));
if (weight_decay > 0.0f)
    p_vals[j] = ((float)p_vals[j]) * (1.0f - (lr * weight_decay));   // decay AFTER the update

i.e. p_new = (p_old + step_size·update) · (1 − lr·wd). The same "decay after the update" placement is used in the 8-bit blockwise CUDA kernel (kOptimizerStatic8bit2StateBlockwise, lines 1097-1104) and for ADEMAMIX (lines 690-697).

default backend, bitsandbytes/backends/default/ops.py lines 460-471 (installed package, optimizer_id == 3):

if optimizer_id == 3:  # ADAM
    s1_vals = state1 * beta1 + (1.0 - beta1) * g_vals
    s2_vals = state2 * beta2 + (1.0 - beta2) * g_vals * g_vals
    correction1 = 1.0 - beta1**step
    correction2 = sqrt(1.0 - beta2**step)
    step_size = -lr * correction2 / correction1

    if weight_decay > 0.0:
        p_vals = p_vals * (1.0 - lr * weight_decay)   # decay BEFORE the update

    update_val = update_scale * step_size * (s1_vals / (torch.sqrt(s2_vals) + eps * correction2))
    p_vals = p_vals + update_val

i.e. p_new = p_old·(1 − lr·wd) + step_size·update. The Triton backend (backends/triton/kernels_optim.py lines 196-211) has the identical ordering, and so does ADEMAMIX in both files. I confirmed all of this directly against the files shipped inside the installed bitsandbytes==0.49.2 wheel, not just against main.

The two expressions expand to:

  • CUDA: p_old·(1−lr·wd) + step_size·update·(1−lr·wd)
  • default/triton: p_old·(1−lr·wd) + step_size·update

They differ by a factor of (1−lr·wd) on the update term, a genuine per-step bias of magnitude ≈ |update|·lr·wd, so anyone training with weight_decay > 0 (the normal AdamW case) gets a measurably different trajectory depending on whether their run happens to execute on the CUDA kernel vs. the default/Triton backend.

I verified this numerically with a standalone script implementing both formulas exactly as shown above (CPU only, torch tensors, no bitsandbytes compilation needed):

import math, torch

def cuda_step(p, g, s1, s2, step, lr, beta1, beta2, eps, weight_decay):
    correction1 = 1.0 - beta1**step
    correction2 = math.sqrt(1.0 - beta2**step)
    step_size = -lr * correction2 / correction1
    s1.mul_(beta1).add_(g, alpha=1 - beta1)
    s2.mul_(beta2).addcmul_(g, g, value=1 - beta2)
    p.add_(step_size * s1 / (s2.sqrt() + eps * correction2))
    if weight_decay > 0.0:
        p.mul_(1.0 - lr * weight_decay)  # matches csrc/kernels.cu:708-709

def default_backend_step(p, g, s1, s2, step, lr, beta1, beta2, eps, weight_decay):
    correction1 = 1.0 - beta1**step
    correction2 = math.sqrt(1.0 - beta2**step)
    step_size = -lr * correction2 / correction1
    if weight_decay > 0.0:
        p.mul_(1.0 - lr * weight_decay)  # matches backends/default/ops.py:468-469
    s1.mul_(beta1).add_(g, alpha=1 - beta1)
    s2.mul_(beta2).addcmul_(g, g, value=1 - beta2)
    p.add_(step_size * s1 / (s2.sqrt() + eps * correction2))

lr, wd, beta1, beta2, eps = 1e-2, 0.1, 0.9, 0.999, 1e-8
n = 4096
torch.manual_seed(0)
p0 = torch.randn(n)
p_cuda, s1_cuda, s2_cuda = p0.clone(), torch.zeros(n), torch.zeros(n)
p_def, s1_def, s2_def = p0.clone(), torch.zeros(n), torch.zeros(n)

torch.manual_seed(1)
for step in range(1, 501):
    g = torch.randn(n) * 0.01
    cuda_step(p_cuda, g.clone(), s1_cuda, s2_cuda, step, lr, beta1, beta2, eps, wd)
    default_backend_step(p_def, g.clone(), s1_def, s2_def, step, lr, beta1, beta2, eps, wd)

print("max abs diff:", (p_cuda - p_def).abs().max().item())
print("mean abs diff:", (p_cuda - p_def).abs().mean().item())

Output:

max abs diff: 0.0006633400917053223
mean abs diff: 0.00014304943033494055

As a control, running the same loop with weight_decay=0.0 gives max abs diff: 0.0 exactly, both formulas are identical when there's no decay, which isolates the discrepancy to the weight-decay term specifically.

Expected behavior

The default and triton backends should produce (up to float precision) the same optimizer trajectory as the CUDA kernel for the same optimizer and hyperparameters. Weight decay should be applied in the same order (after the update, per the CUDA reference) in all backends.

Not a duplicate of #1992

#1992 ("Lion applies coupled (L2) weight decay instead of decoupled in 32-bit CUDA, Triton, and the default backend") is about Lion specifically using the wrong kind of weight decay (coupled/L2 folded into the gradient vs. decoupled/AdamW-style applied to the parameter). That was fixed in #1993. This is a different bug, affecting Adam/AdamW/LAMB/AdEMAMix specifically, both of which already correctly use decoupled decay in every backend, the issue here is the order decoupled decay is applied relative to the update, not which kind of decay is used. I checked PR #1993's diff and confirmed it didn't touch the ADAM/ADEMAMIX branches in any of these files.

Additional context

I also checked the CPU-specific backend (bitsandbytes/backends/cpu/ops.py) on the current main branch, it has the same decay-before-update bug for Adam/LAMB/AdEMAMix as well, but that particular file's optimizer support doesn't exist yet in the released 0.49.2 wheel (it's newer, in-development code), so I'm not claiming that part is live for released-package users today, only that it has the identical bug pattern and would need the same fix once it ships. The default and triton findings above, by contrast, are confirmed present in the current stable release.

Metadata

Metadata

Assignees

No one assigned

    Labels

    CUDAIssues and PRs related to the CUDA backend, excluding installation/support help.OptimizersIssues or feature requests relating to optimizers

    Type

    Fields

    No fields configured for Bug.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions