Fix decoupled weight decay ordering in the CUDA Adam/AdEMAMix kernels - #2040
Open
yentur wants to merge 1 commit into
Open
Fix decoupled weight decay ordering in the CUDA Adam/AdEMAMix kernels#2040yentur wants to merge 1 commit into
yentur wants to merge 1 commit into
Conversation
The CUDA kernels applied weight decay after adding the optimizer update, computing p = (p + step_size*update) * (1 - lr*wd). Expanded, that scales the update term by an extra (1 - lr*wd) that the reference does not have. AdamW Algorithm 2 in Loshchilov & Hutter (arXiv:1711.05101) and the AdEMAMix update rule in Pagliardini et al. (arXiv:2409.03137) both take the decay and the gradient-based update from the same previous parameter, which rearranges to p = p*(1 - lr*wd) + step_size*update. torch.optim.AdamW does the same, and so do the cpu, default and triton backends. Only the CUDA kernels differed. Three sites: the ADAM and ADEMAMIX branches of kOptimizer32bit2State and the 2-state branch of kOptimizerStatic8bit2StateBlockwise. The 1-state kernels already match, and Lion is unaffected. This changes training trajectories for CUDA runs that use Adam, AdamW, LAMB or AdEMAMix with weight_decay > 0. The per-step difference is lr*wd*|update|. Runs with weight_decay = 0 are unaffected. The existing tests could not see this. They run these optimizers at the default weight_decay of 0, and at lr=1e-3, wd=0.01 the two orderings stay 1.7e-7 apart, inside the 1e-6 tolerance. On an RTX 3090 the whole of test_optim.py passes on the unfixed kernel. The separation grows with lr*lr*wd while the kernels' own arithmetic error only grows with lr, so the new 32-bit test runs at lr=0.1, wd=0.1, where the orderings are 1.0e-3 apart and agreement with the reference is 3.9e-5. The 8-bit test cannot use a full run, because the state quantization error is 6.6e-4 per step against a 1.6e-6 ordering difference, so it checks one step from a fresh optimizer, where the state is still zero and quantizes exactly and the update has a closed form.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #2010.
The CUDA 2-state optimizer kernels apply weight decay after adding the update:
which expands to
p*(1 - lr*wd) + step_size*update*(1 - lr*wd). The extra factor on the update term is not in AdamW Algorithm 2 (Loshchilov and Hutter, arXiv:1711.05101) or in the AdEMAMix update rule (Pagliardini et al., arXiv:2409.03137). Both take the decay and the gradient-based update from the same previous parameter, which rearranges top*(1 - lr*wd) + step_size*update.torch.optim.AdamWdoes the same, and so do the cpu, default and triton backends here.Three sites in
csrc/kernels.cu: theADAMandADEMAMIXbranches ofkOptimizer32bit2State, and the 2-state branch ofkOptimizerStatic8bit2StateBlockwise. The change is a reorder, 9 insertions and 8 deletions. The 1-state kernels already use coupled L2 for momentum/rmsprop/adagrad and decoupled decay for Lion, matching the Python backends, so they are untouched.@ErenAta16 reported this and worked out in the thread that CUDA is the side that diverges. @egeozkoc confirmed it against the paper, pointed out that the 8-bit blockwise kernel is affected too, and offered to write the expanded weight-decay tests. I went ahead because the issue had been sitting for about a month. Happy to close this if either of you would rather carry it.
This changes results for existing users
CUDA runs using Adam, AdamW, LAMB or AdEMAMix with
weight_decay > 0will follow different training trajectories after this. The per-step difference islr*wd*|update|. Runs withweight_decay = 0are unaffected, as are the cpu, default, triton and XPU backends, which already used this ordering.Why the current tests do not catch it
They run these optimizers at the default
weight_decayof 0, where the two orderings coincide. At the lr=1e-3, wd=0.01 an Adam user would typically pick, 20 steps leave the orderings 1.7e-7 apart, inside the 1e-6 tolerance. The entire existingtest_optim.pypasses on the unfixed kernel:The two orderings separate as
lr*lr*wd, while the kernels' own arithmetic error grows only aslr. Sotest_optimizer32bit_weight_decayruns at lr=0.1, wd=0.1, where the orderings are 1.0e-3 apart and agreement with the reference is 3.9e-5.test_optimizer8bit_weight_decaycannot use a full run at all: the state quantization error is 6.6e-4 per step while the orderings differ by 1.6e-6, so the signal is buried. It instead takes one step from a fresh optimizer, where the state is still zero and therefore quantizes exactly, and the update reduces to-lr*g/(|g| + eps)(AdEMAMix also mixes inalpha*(1-b3)*g). At those hyperparameters the orderings are 1.0e-3 apart, and the fixed kernels sit 3.0e-6 from the closed form, which is__powfand__fdividefrather than the decay. Both tests are fp32; the 16-bit paths intest_optimizer32bitresynchronise the parameters every step, so at most one step of divergence is visible there and it stays inside the looser 16-bit tolerances.Verification
RTX 3090, sm_86, CUDA 12.4, torch 2.5.1+cu124, built from source with
-DCOMPUTE_CAPABILITY=86. Onlycsrc/kernels.cuwas reverted between the two runs; the tests were identical.Isolating the ordering on one step, same hardware, with the fix applied:
On cpu and mps, which already had the correct ordering, the new tests pass unchanged (
8 passedon cpu,4 passed, 4 skippedon mps; the 8-bit ones skip becauseoptimizer_update_8bit_blockwisehas no MPS kernel). To check they are not vacuous without a GPU, I also re-registered the cpu kernels with the CUDA ordering and confirmed all 8 fail.pre-commit run --all-filespasses.Not covered
bnb.optim.Adamapplies decoupled decay in every backend, so withweight_decay > 0it matchestorch.optim.AdamWrather thantorch.optim.Adam(on cpu, 60 steps at lr=1e-2, wd=0.1: max 5.3e-1 fromtorch.optim.Adam, 1.5e-7 fromtorch.optim.AdamW). That is the separate point you raised in the issue and it is not touched here. Theskip_zerospath still skips the decay along with the update when a gradient is exactly zero, which is existing behaviour I left alone. I verified on sm_86 only.