Repository navigation
fix(bnb): apply scaling to lora_bias merge/unmerge on 8-bit and 4-bit - #3757
Sravanjangam wants to merge 6 commits into
Conversation
Fixes huggingface#3741, includes scope from @yashdoke7, per @BenjaminBossan ('include those other fixes in the same PR'). - 8-bit + 4-bit merge/unmerge: bias now multiplied by self.scaling[active_adapter], matching lora/layer.py and forward (output = lora_B(lora_A(x)) * scaling scales bias too). - safe_merge bias guard: add missing .all() (was ambiguous-Boolean RuntimeError on multi-element bias instead of NaN ValueError). - merge: raise RuntimeError when base layer has no bias, like layer.py. Tests (tests/test_quantization.py, CPU-runnable with weight-dequant stub; bias formula untouched): - test_bnb_8bit_merge_bias_applies_scaling (merge + unmerge + NaN guard) - test_bnb_4bit_merge_bias_applies_scaling (merge + unmerge) - test_bnb_merge_bias_without_base_bias_raises Verification: - 3/3 pass twice; 3/3 FAIL on db7d68a / PASS with fix - sibling custom bias/merge 324 passed, 5 Frod pre-existing (stash-proven) - ruff check + format + doc-builder clean
|
On the GPU point: no GPU access here, so the bias formula is pinned by CPU tests with only the CUDA-only weight dequant stubbed (real merge/unmerge paths, scaling 8.0). Happy to bump the existing |
BenjaminBossan
left a comment
There was a problem hiding this comment.
Thanks for providing the fix. It looks good, but the testing needs some work. If you can't run the tests yourself, please let me know so that I can run them.
| # unlike the base path (`lora/layer.py`). CPU-runnable: int8 dequant needs CUDA, so | ||
| # only the weight dequant is stubbed — the bias formula under test is untouched. | ||
| # scaling 8.0 (r=4, lora_alpha=32) makes a missing factor show up as max-abs ~3.35. | ||
| import bitsandbytes as bnb |
There was a problem hiding this comment.
Generally, use global imports
There was a problem hiding this comment.
Done — the moved tests need zero new imports (all already global in that module). The old stub tests are deleted; the no-bias RuntimeError test stays in test_quantization.py since it needs no GPU dequant (branch coverage the GPU tests cannot reach — every real layer has a bias).
| check_outputs_similar(out_before, out_unloaded) | ||
|
|
||
| @pytest.mark.skipif(not is_bnb_available(), reason="bitsandbytes not available") | ||
| def test_bnb_8bit_merge_bias_applies_scaling(self): |
There was a problem hiding this comment.
These tests should live in test_gpu_examples.py in the PeftBnbGPUExampleTests class.
There was a problem hiding this comment.
Done in 2895298e (plain push): both scaling tests moved to PeftBnbGPUExampleTests as test_bnb_8bit/4bit_merge_bias_applies_scaling, marked single_gpu_tests.
| bnb_lora.dequantize_bnb_weight = lambda w, state=None: w.data.float() | ||
| try: | ||
|
|
||
| class BnbNet(torch.nn.Module): |
There was a problem hiding this comment.
Use a real model like peft-internal-testing/opt-125m please.
There was a problem hiding this comment.
Done — both tests load peft-internal-testing/opt-125m (verified enable_bias=True) with BitsAndBytesConfig + device_map='auto', selecting the first biased LoraLayer defensively.
| from peft import LoraConfig | ||
|
|
||
| orig_dequant = bnb_lora.dequantize_bnb_weight | ||
| bnb_lora.dequantize_bnb_weight = lambda w, state=None: w.data.float() |
There was a problem hiding this comment.
The test should work without monkeypatching this function. I think using a real Transformers model should be enough for that.
There was a problem hiding this comment.
Done — no stubs or monkeypatching anymore. The 8-bit/4-bit merge runs genuine dequant on the quantized model; assertions target the bias formula only.
|
@BenjaminBossan : One flag on the moved GPU tests ( |
BenjaminBossan
left a comment
There was a problem hiding this comment.
Thanks for the updates, but there are still issues with the testing. Please check my comments.
Moreover, I noticed that the tests have no empty lines at all. Please add some empty lines to group code that belongs together, check the surrounding tests for inspiration. I noticed that the new OpenAI models have a tendency to write very dense code, are you using those?
| def test_bnb_8bit_merge_bias_applies_scaling(self): | ||
| # Regression test for #3741: the 8-bit merge/unmerge dropped | ||
| # `* scaling` on the bias, unlike the base path (`lora/layer.py`). | ||
| # Runs on a real quantized model, so the dequant path is genuine. |
There was a problem hiding this comment.
| # Runs on a real quantized model, so the dequant path is genuine. |
Makes no sense to comment on that. Same for the test below.
There was a problem hiding this comment.
Fixed in b1627a9 — the comment block was condensed to one clear line, and both tests now group code with blank lines.
| # Runs on a real quantized model, so the dequant path is genuine. | ||
| # scaling 8.0 (r=4, lora_alpha=32) makes a missing factor show up. | ||
| model_id = "peft-internal-testing/opt-125m" | ||
| model = AutoModelForCausalLM.from_pretrained( |
There was a problem hiding this comment.
Please always load the models inside the hub_online_once context.
There was a problem hiding this comment.
Done in b1627a9 — both tests now load peft-internal-testing/opt-125m inside hub_online_once(model_id).
| check_outputs_similar(out_before, out_unloaded) | ||
|
|
||
| @pytest.mark.skipif(not is_bnb_available(), reason="bitsandbytes not available") | ||
| def test_bnb_merge_bias_without_base_bias_raises(self): |
There was a problem hiding this comment.
test_quantization is for testing the generic quantization backend in PEFT, which LoRA does not use. Therefore, delete this test. I also don't see what this test would cover that isn't already covered by the other tests.
There was a problem hiding this comment.
Deleted in b1627a9 — the no-bias RuntimeError test was removed from test_quantization.py; the migration + no-bias-path coverage now lives entirely in test_gpu_examples.py PeftBnbGPUExampleTests.
…ternals Per @BenjaminBossan review on huggingface#3757: - Compare logits pre/post merge and pre/post unmerge (MSE < 1.0) on a real quantized opt-125m instead of checking lora_B bias + scaling internals. - Load the model inside hub_online_once. - Group code with blank lines. - Delete test_bnb_merge_bias_without_base_bias_raises from test_quantization.py (generic quantization backend file; LoRA tests live in test_gpu_examples.py).
BenjaminBossan
left a comment
There was a problem hiding this comment.
Thanks for cleaning up the tests, I still have some comments about them, please take a look.
|
|
||
| # With `lora_bias=True`, merging must reproduce the LoRA contribution including `* scaling` | ||
| # (scaling is 8.0 for r=4, lora_alpha=32). A dropped factor shifts the logits by several units. | ||
| mse = ((out_before - out_merged) ** 2).mean() |
There was a problem hiding this comment.
Any specific reason why you check MSE and not call torch.allclose the logits?
|
|
||
| mse = ((out_before - out_unmerged) ** 2).mean() | ||
| assert mse < 1.0 | ||
|
|
There was a problem hiding this comment.
This is missing the safe merge check that exists in the 8bit test.
… cleanups - Drop the LoRA bnb bias scaling and its test, which huggingface#3757 covers; the bnb merge now only computes and checks the bias before writing the weight. - Clarify which layer the broken adapter test poisons and note that earlier layers stay merged if a later one fails. - torchao several adapters test: link huggingface#3728, load the model once inside hub_online_once and deepcopy it.
|
@Sravanjangam Are you still working on this? |
yes |
|
Gentle ping @Sravanjangam. LMK if you don't have time to work on this, so someone else can take over. |
I will finish this today |
- Wrap model loading in hub_online_once context - Check output parity with torch.allclose on logits instead of direct bias comparison or MSE - Add safe_merge NaN guard to 4-bit test (matching 8-bit test) - Delete test_bnb_merge_bias_without_base_bias_raises from test_quantization.py (LoRA does not use generic quantization backend) Per BenjaminBossan's review comments on huggingface#3757 (09-21, 09-28).
|
Hi @BenjaminBossan — answering both of your points; changes are in 1. MSE vs You were right that MSE was the wrong check, and I also found that
The comparison is therefore done in probability space — 2. Safe merge check in the 4-bit test Added the same guard the 8-bit test has: fill 3. Two things I found while re-reading the tests (
One caveat worth flagging: the tolerance was calibrated on the 8-bit path. The 4-bit test uses the same comparison, and 4-bit re-quantization is coarser, so if that constant turns out too tight on the GPU runner it is a single number to adjust — I could not measure the 4-bit path locally since it needs CUDA. |
torch.allclose on the raw logits at atol=1e-3 fails even with the correct scaling: bnb re-quantizes the weight on merge, so the outputs are never bit-identical. Measured on a CPU probe over 5 seeds, the re-quantization alone moves the softmax by ~4e-03 max-abs while the dropped factor moves it by ~2e-01, so the comparison is done in probability space with atol=1e-2/rtol=1e-2: 5/5 pass with the fix, 5/5 fail with the pre-fix formula, so the test still pins the bug. Also add the global torch.nn.functional import (F was imported locally inside the tests before).
- Assert the LoRA bias is non-zero before the parity checks: with a zero bias the merged and unmerged outputs agree for any scaling, so the regression assertions would pass vacuously. - Correct the comment: the merged-vs-unmerged comparison is what pins the bug; the round trip only checks that unmerge inverts merge, which the pre-fix formula satisfied too (it was wrong in both directions). - Reuse the layer lookup from the sanity check instead of looking it up twice.
Fixes #3741
Summary
Merging a LoRA adapter with
lora_bias=Trueinto bitsandbytes 8-bit/4-bit layers silently dropped the* scalingfactor on the bias, while the basenn.Linearpath applies it. Includes the wider scope from @yashdoke7 per @BenjaminBossan's direction to combine ("include those other fixes in the same PR").Coordination / approval
.all()guard, no-bias guard, test-home question.all()divergence, and the missing guardRoot cause
src/peft/tuners/lora/bnb.py: 8-bit merge didbase.bias.data + lora_B.bias(L127) and unmergebias.data -= lora_B.bias(L169); 4-bit twins at L404/L446 — none multiplied byself.scaling[adapter], unlikelora/layer.py:978and forward (output = lora_B(lora_A(x)) * scalingscales the bias too). Earlier partial backport (FIX: Error when merging LoRA bias with scale != 1 #2489) fixedlayer.pyonly.safe_mergebias guards usednot torch.isfinite(bias_data)without.all()→ ambiguous-BooleanRuntimeErroron multi-element bias instead of the intended NaNValueError(layer.pyuses.all()).RuntimeErrorguardlayer.pyraises when the base layer has no bias.Fix (
src/peft/tuners/lora/bnb.py, +20/-6)* self.scaling[active_adapter](8-bit merge/unmerge, 4-bit merge/unmerge).safe_mergebias guards now use.all().RuntimeError("Impossible to merge LoRA with ... has no bias.")matchinglayer.py.Tests (
tests/test_quantization.py, +108)test_bnb_8bit_merge_bias_applies_scaling— merge bias equalsbase + B.bias * 8.0, unmerge restores base, NaNsafe_mergeraisesValueError.test_bnb_4bit_merge_bias_applies_scaling— same merge/unmerge parity on the 4-bit path.test_bnb_merge_bias_without_base_bias_raises— no-bias base raisesRuntimeError.r=4, lora_alpha=32(scaling 8.0) so a missing factor shows as max-abs ~3.35. Note on test home (replying to @yashdoke7's question): GPU parity tests live intest_common_gpu.py(test_8bit/4bit_merge_lora_with_biasuse default scaling 1.0, which is why this never showed); these CPU tests pin the scaling factor where CI always runs. Happy to also bump those GPU configs tolora_alpha != ras a follow-up if wanted.Verification
db7d68a2/ PASS with fix (clean worktree +PYTHONPATH).match True, maxabs 0.0, unmerge restores base.test_custom_models.py -k "lora_bias or safe_merge or merge_with": 324 passed, 5 failed — all 5 areFrodConfig test_safe_merge[235-239], failing identically on base (stash-proven pre-existing).ruff check+ruff format --checkclean;doc-builder style --max_len 119exit 0 onbnb.py.test_common_gpu.pymerge tests.Environment: Python 3.12, torch 2.13.0+cpu, bitsandbytes 0.50.2, transformers 5.15.1, peft @
c759e1ec.