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.
System Info
bitsandbytes 0.49.2 (current PyPI release,
pip install bitsandbytes), Python 3.12.10, Windows, CPU only (torch2.x). Also confirmed present on the currentmainbranch source. No GPU/CUDA needed to reproduce, since this is about thedefaultandtritonbackend implementations diverging from the CUDA reference kernel, not about CUDA itself.Reproduction
For Adam/AdamW/LAMB (
optimizer_id == 3, i.e.adam/lambshare the same code path) and AdEMAMix (optimizer_id == 5), thedefaultbackend (bitsandbytes/backends/default/ops.py, used for MPS and any device without a dedicated compiled kernel) and thetritonbackend (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.culines 700-710 (kOptimizer32bit2State,case ADAM):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).defaultbackend,bitsandbytes/backends/default/ops.pylines 460-471 (installed package,optimizer_id == 3):i.e.
p_new = p_old·(1 − lr·wd) + step_size·update. The Triton backend (backends/triton/kernels_optim.pylines 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 installedbitsandbytes==0.49.2wheel, not just againstmain.The two expressions expand to:
p_old·(1−lr·wd) + step_size·update·(1−lr·wd)p_old·(1−lr·wd) + step_size·updateThey 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 withweight_decay > 0(the normal AdamW case) gets a measurably different trajectory depending on whether their run happens to execute on the CUDA kernel vs. thedefault/Triton backend.I verified this numerically with a standalone script implementing both formulas exactly as shown above (CPU only,
torchtensors, no bitsandbytes compilation needed):Output:
As a control, running the same loop with
weight_decay=0.0givesmax abs diff: 0.0exactly, both formulas are identical when there's no decay, which isolates the discrepancy to the weight-decay term specifically.Expected behavior
The
defaultandtritonbackends 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 currentmainbranch, 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. Thedefaultandtritonfindings above, by contrast, are confirmed present in the current stable release.