Skip to content

Commit f2233a6

Browse files
egeozkocclaude
andauthored
fix(optim): honor user-provided max_unorm in LAMB (+ 8-bit arg guards) (#1998)
* fix(optim): honor user-provided max_unorm in LAMB (+ 8-bit arg guards) The LAMB, LAMB8bit and LAMB32bit constructors accepted a max_unorm argument but passed a hardcoded 1.0 to the base optimizer, so the user value never reached the optimizer config. On the 32-bit LAMB path (LAMB with the default optim_bits=32, and LAMB32bit) max_unorm drives the trust-ratio clipping of the update, so this silently ignored the setting -- e.g. a tighter max_unorm had no effect and max_unorm could not be changed from 1.0. Thread the argument through in all three classes. Note: the 8-bit blockwise update kernel does not apply update-norm clipping, so max_unorm has no numerical effect on LAMB8bit; the value is now stored consistently and this limitation is documented in the docstring. Also complete the 8-bit optimizer signature/doc cleanup started for Adam8bit/AdamW8bit (relates to #1261), mirroring their guards on parameters the base optimizer ignores: - LAMB8bit: raise on amsgrad=True (unused) and note it in the docstring. - Adagrad8bit: raise on optim_bits != 8 (always 8-bit) and note it. Tests (tests/test_optim.py): - test_lamb_max_unorm_changes_update: behavioral check on the 32-bit path -- a tight vs loose max_unorm now yields different updates (fails on main where the value was ignored). - test_lamb_max_unorm_threaded_to_config: the value reaches optimizer.args for all three LAMB classes. - guards for LAMB8bit amsgrad and Adagrad8bit optim_bits. Relates to #1261 Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> * Reject non-default max_unorm in LAMB8bit (review feedback) The 8-bit blockwise update does not implement update-norm clipping, so threading max_unorm through LAMB8bit left a silent no-op: a user passing e.g. max_unorm=0.1 would reasonably assume it takes effect. Reject any non-default value instead, consistent with the amsgrad guard (the default 1.0 is allowed for signature compatibility). Config-threading test now covers the 32-bit classes only; add a guard test for LAMB8bit max_unorm. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent a229e65 commit f2233a6

3 files changed

Lines changed: 83 additions & 4 deletions

File tree

bitsandbytes/optim/adagrad.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ def __init__(
9595
The epsilon value prevents division by zero in the optimizer.
9696
optim_bits (`int`, defaults to 8):
9797
The number of bits of the optimizer state.
98+
Note: This parameter is not used in Adagrad8bit as it always uses 8-bit optimization.
9899
args (`object`, defaults to `None`):
99100
An object with additional arguments.
100101
min_8bit_size (`int`, defaults to 4096):
@@ -110,6 +111,10 @@ def __init__(
110111
raise ValueError("Initial accumulator value != 0.0 not supported!")
111112
if lr_decay != 0.0:
112113
raise ValueError("Lr Decay != 0.0 not supported!")
114+
if optim_bits != 8:
115+
# We allow the default value of 8 to maintain compatibility with the function signature,
116+
# but any other value is invalid since Adagrad8bit always uses 8-bit optimization
117+
raise ValueError("Adagrad8bit only supports optim_bits=8 (default value for compatibility)")
113118
super().__init__(
114119
"adagrad",
115120
params,

bitsandbytes/optim/lamb.py

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ def __init__(
6060
optim_bits,
6161
args,
6262
min_8bit_size,
63-
max_unorm=1.0,
63+
max_unorm=max_unorm,
6464
)
6565

6666

@@ -97,15 +97,29 @@ def __init__(
9797
The weight decay value for the optimizer.
9898
amsgrad (`bool`, defaults to `False`):
9999
Whether to use the [AMSGrad](https://hf.co/papers/1904.09237) variant of Adam that uses the maximum of past squared gradients instead.
100+
Note: This parameter is not supported in LAMB8bit and must be False.
100101
adam_w_mode (`bool`, defaults to `True`):
101102
Whether to use the AdamW variant.
102103
args (`object`, defaults to `None`):
103104
An object with additional arguments.
104105
min_8bit_size (`int`, defaults to 4096):
105106
The minimum number of elements of the parameter tensors for 8-bit optimization.
106107
max_unorm (`float`, defaults to 1.0):
107-
The maximum gradient norm.
108+
The maximum update norm for trust-ratio clipping.
109+
Note: This parameter is not supported in LAMB8bit and must be left at the
110+
default 1.0. The 8-bit blockwise update does not implement update-norm
111+
clipping; it is honored by the 32-bit LAMB / LAMB32bit optimizers.
108112
"""
113+
# Validate unsupported parameters
114+
if amsgrad:
115+
raise ValueError("LAMB8bit does not support amsgrad=True")
116+
117+
if max_unorm != 1.0:
118+
# We allow the default value of 1.0 to maintain compatibility with the function
119+
# signature, but the 8-bit blockwise update does not implement update-norm
120+
# clipping, so any other value would be silently ignored.
121+
raise ValueError("LAMB8bit only supports max_unorm=1.0 (default value for compatibility)")
122+
109123
super().__init__(
110124
"lamb",
111125
params,
@@ -116,7 +130,7 @@ def __init__(
116130
8,
117131
args,
118132
min_8bit_size,
119-
max_unorm=1.0,
133+
max_unorm=max_unorm,
120134
)
121135

122136

@@ -172,5 +186,5 @@ def __init__(
172186
32,
173187
args,
174188
min_8bit_size,
175-
max_unorm=1.0,
189+
max_unorm=max_unorm,
176190
)

tests/test_optim.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -681,3 +681,63 @@ def test_ademamix_state_dict_no_nan(optim_name, optim_factory, device):
681681

682682
for p_a, p_b in zip(model.parameters(), model2.parameters()):
683683
torch.testing.assert_close(p_a, p_b)
684+
685+
686+
@pytest.mark.parametrize("optim_cls", [bnb.optim.LAMB, bnb.optim.LAMB32bit], ids=id_formatter("opt"))
687+
@pytest.mark.parametrize("max_unorm", [0.0, 0.5, 2.0], ids=id_formatter("max_unorm"))
688+
def test_lamb_max_unorm_threaded_to_config(optim_cls, max_unorm):
689+
# The LAMB constructors accepted a `max_unorm` argument but passed a hardcoded 1.0
690+
# to the base optimizer, so the user value never reached the optimizer config.
691+
# (LAMB8bit rejects non-default values instead, since the 8-bit blockwise kernel
692+
# does not apply update-norm clipping; see test_lamb8bit_rejects_non_default_max_unorm.)
693+
p = [torch.nn.Parameter(torch.randn(8, 8))]
694+
opt = optim_cls(p, max_unorm=max_unorm)
695+
assert opt.args.max_unorm == max_unorm
696+
697+
698+
def test_lamb_max_unorm_changes_update():
699+
# Behavioral regression: on the 32-bit LAMB path, `max_unorm` drives trust-ratio
700+
# clipping of the update. Before the fix the argument was ignored (always 1.0), so a
701+
# tight and a loose value produced identical updates. Runs on CPU, which implements
702+
# the 32-bit LAMB kernel.
703+
def one_step(max_unorm):
704+
torch.manual_seed(0)
705+
p = torch.nn.Parameter(torch.randn(128, 128))
706+
opt = bnb.optim.LAMB([p], lr=1e-1, max_unorm=max_unorm)
707+
p.grad = torch.randn(128, 128) * 5.0
708+
opt.step()
709+
return p.detach().clone()
710+
711+
tight = one_step(1e-4) # aggressive clipping
712+
loose = one_step(10.0) # effectively no clipping
713+
assert not torch.allclose(tight, loose)
714+
715+
716+
def test_lamb8bit_rejects_amsgrad():
717+
# amsgrad is unused by the base optimizer; mirror the Adam8bit/AdamW8bit guard (relates to #1261).
718+
p = [torch.nn.Parameter(torch.randn(8, 8))]
719+
with pytest.raises(ValueError):
720+
bnb.optim.LAMB8bit(p, amsgrad=True)
721+
# default (amsgrad=False) still constructs
722+
bnb.optim.LAMB8bit(p)
723+
724+
725+
def test_lamb8bit_rejects_non_default_max_unorm():
726+
# The 8-bit blockwise update does not implement update-norm clipping, so a
727+
# non-default max_unorm would be silently ignored; reject it instead.
728+
p = [torch.nn.Parameter(torch.randn(8, 8))]
729+
with pytest.raises(ValueError):
730+
bnb.optim.LAMB8bit(p, max_unorm=0.5)
731+
with pytest.raises(ValueError):
732+
bnb.optim.LAMB8bit(p, max_unorm=0.0)
733+
# default (max_unorm=1.0) still constructs
734+
bnb.optim.LAMB8bit(p, max_unorm=1.0)
735+
736+
737+
def test_adagrad8bit_rejects_non_8_optim_bits():
738+
# optim_bits is ignored (Adagrad8bit always uses 8-bit); guard invalid values (relates to #1261).
739+
p = [torch.nn.Parameter(torch.randn(8, 8))]
740+
with pytest.raises(ValueError):
741+
bnb.optim.Adagrad8bit(p, optim_bits=32)
742+
# default (optim_bits=8) still constructs
743+
bnb.optim.Adagrad8bit(p)

0 commit comments

Comments
 (0)