Files
wehub-resource-sync caf324b09d
tests / check_code_quality (push) Waiting to run
tests / tests (ubuntu-latest, 3.10) (push) Blocked by required conditions
tests / tests (ubuntu-latest, 3.11) (push) Blocked by required conditions
Deploy "method_comparison" Gradio to Spaces / deploy (push) Waiting to run
Deploy "PEFT shop" Gradio app to Spaces / deploy (push) Waiting to run
tests on transformers main / tests (push) Waiting to run
tests / tests (ubuntu-latest, 3.12) (push) Blocked by required conditions
tests / tests (ubuntu-latest, 3.13) (push) Blocked by required conditions
tests / tests (windows-latest, 3.10) (push) Blocked by required conditions
tests / tests (windows-latest, 3.11) (push) Blocked by required conditions
tests / tests (windows-latest, 3.12) (push) Blocked by required conditions
tests / tests (windows-latest, 3.13) (push) Blocked by required conditions
Secret Leaks / trufflehog (push) Waiting to run
CI security linting / zizmor latest via Cargo (push) Waiting to run
Build documentation / build (push) Failing after 0s
chore: import upstream snapshot with attribution
2026-07-13 13:24:42 +08:00

493 lines
23 KiB
Python

# Copyright 2024-present the HuggingFace Inc. team.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import functools
import inspect
import re
import packaging.version
import pytest
import torch
import transformers
from torch import nn
from transformers import AutoModelForCausalLM, AutoModelForImageTextToText
from peft import LoraConfig, PeftModel, get_peft_model
from peft.tuners import lora
from peft.utils import infer_device
from peft.utils.integrations import init_empty_weights, skip_init_on_device
from .testing_utils import hub_online_once
class MLP(nn.Module):
def __init__(self, bias=True):
super().__init__()
self.lin0 = nn.Linear(10, 20, bias=bias)
self.relu = nn.ReLU()
self.drop = nn.Dropout(0.5)
self.lin1 = nn.Linear(20, 2, bias=bias)
def get_mlp():
return MLP()
class TestInitEmptyWeights:
def test_init_empty_weights_works(self):
# this is a very rudimentary test, as init_empty_weights is copied almost 1:1 from accelerate and is tested
# there
with init_empty_weights():
mlp = get_mlp()
expected = torch.device("meta")
assert all(p.device == expected for p in mlp.parameters())
def test_skip_init_on_device_works(self):
# when a function is decorated with skip_init_on_device, the parameters are not moved to meta device, even when
# inside the context
decorated_fn = skip_init_on_device(get_mlp)
with init_empty_weights():
mlp = decorated_fn()
expected = torch.device("cpu")
assert all(p.device == expected for p in mlp.parameters())
def test_skip_init_on_device_works_outside_context(self):
# same as before, but ensure that skip_init_on_device does not break when no init_empty_weights context is used
decorated_fn = skip_init_on_device(get_mlp)
mlp = decorated_fn()
expected = torch.device("cpu")
assert all(p.device == expected for p in mlp.parameters())
def test_skip_init_on_device_not_permanent(self):
# ensure that after skip_init_on_device has been used, init_empty_weights reverts to its original functionality
# with decorator => cpu
decorated_fn = skip_init_on_device(get_mlp)
with init_empty_weights():
mlp = decorated_fn()
expected = torch.device("cpu")
assert all(p.device == expected for p in mlp.parameters())
# without decorator => meta
with init_empty_weights():
mlp = get_mlp()
expected = torch.device("meta")
assert all(p.device == expected for p in mlp.parameters())
def test_skip_init_on_device_nested(self):
# ensure that skip_init_on_device works even if the decorated function is nested inside another decorated
# function
@skip_init_on_device
def outer_fn():
@skip_init_on_device
def inner_fn():
return get_mlp()
mlp0 = inner_fn()
mlp1 = get_mlp()
return mlp0, mlp1
with init_empty_weights():
mlp0, mlp1 = outer_fn()
expected = torch.device("cpu")
assert all(p.device == expected for p in mlp0.parameters())
assert all(p.device == expected for p in mlp1.parameters())
# TODO Remove this once patch_moe_parameter_targeting is removed from Transformers
@pytest.fixture(params=[False, True], ids=["without_transformers_moe_patch", "with_transformers_moe_patch"])
def _transformers_moe_patch(request):
"""Parametrize tests over the MoE parameter-targeting patch being active/inactive.
The transformers patch `patch_moe_parameter_targeting` could hide a bug in PEFT when it comes to detecting the
correct in_features/out_features of a 3-dim MoE parameter. The patch is applied by transformers when their
`load_adapter` method is being called. Therefore, the order in which the tests were executed could hide or surface
the bug.
With this fixture, we ensure that each test is run twice, once without and once with the patch. This should mirror
real world usage, where the patch may or may not be active. At fixture exit, the patch is always removed.
For details, see discussion in #3165
"""
try:
from transformers.integrations.peft import patch_moe_parameter_targeting
except (ImportError, AttributeError):
patch_moe_parameter_targeting = None
should_patch = request.param
if should_patch and patch_moe_parameter_targeting is None:
pytest.skip(
"Transformers patch_moe_parameter_targeting no longer exists; skipping the 'patched' test variant."
)
is_patched = hasattr(lora.layer.ParamWrapper.update_layer, "__wrapped__")
orig_update_layer = inspect.unwrap(lora.layer.ParamWrapper.update_layer)
def new_update_layer(layer, *args, **kwargs):
# this is copied 1:1 from transformers:
# https://github.com/huggingface/transformers/blob/bc7ee236fca35e771b6b393178a192add1469243/src/transformers/integrations/peft.py#L394-L401
did_swap = getattr(layer, "_did_swap_in_out_features", False)
if not did_swap and layer.parameter_name in ("down_proj", "gate_up_proj"):
tmp_in_features = layer.in_features
layer.in_features = layer.out_features
layer.out_features = tmp_in_features
layer._did_swap_in_out_features = True
return orig_update_layer(layer, *args, **kwargs)
# 4 cases:
# should_patch | is_patched
# true | true
# true | false
# false | true
# false | false
if not should_patch and not is_patched:
yield
return
if not should_patch and is_patched:
lora.layer.ParamWrapper.update_layer = orig_update_layer
yield
return
if should_patch and is_patched:
try:
yield
finally:
lora.layer.ParamWrapper.update_layer = orig_update_layer
return
if should_patch and not is_patched:
try:
lora.layer.ParamWrapper.update_layer = functools.wraps(lora.layer.ParamWrapper.update_layer)(
new_update_layer
)
yield
finally:
lora.layer.ParamWrapper.update_layer = orig_update_layer
return
# this is unreachable
@pytest.mark.skipif(
packaging.version.parse(transformers.__version__) < packaging.version.parse("5.4.0"),
reason="PEFT weight conversion is fixed in transformers 5.4.0",
)
@pytest.mark.usefixtures("_transformers_moe_patch") # TODO remove once patch_moe_parameter_targeting is removed
class TestTransformersV5:
"""Unit tests intended to test proper working of PEFT with Transformers v5"""
torch_device = infer_device()
@pytest.fixture
def expected_logits(self):
# original logits were:
# tensor([[[ 0.2676, 0.3870, 0.2956, ..., 0.4624, 0.1966, 0.2539],
# [-0.6706, -0.0969, -0.6240, ..., -0.0201, 0.7099, -0.3099],
# [ 0.0663, 0.1653, 0.7189, ..., 0.5905, 0.0649, 0.5839],
# ...,
# [-0.2712, -0.6451, -0.0219, ..., -0.4344, 0.5471, -0.9355],
# [-0.3607, 0.4526, 0.2750, ..., 0.1082, 0.7179, 0.8487],
# [ 0.5826, -0.1407, -0.3131, ..., 0.1026, 0.6878, -0.3382]]],
# device='cuda:0')
expected_logits_0_to_3 = torch.Tensor(
[
[0.2676, 0.3870, 0.2956],
[-0.6706, -0.0969, -0.6240],
[0.0663, 0.1653, 0.7189],
]
).to(device=self.torch_device, dtype=torch.float16)
return expected_logits_0_to_3
def test_mixtral_v4_lora_weight_conversion_transformers_load_adapter(self, expected_logits):
# Load a PEFT adapter trained with transformers v4 on Mixtral, which now has converted weights (MoE), using the
# Transformers integration (model.load_adapter).
inputs = torch.arange(10).view(1, -1).to(device=self.torch_device)
model_id = "hf-internal-testing/Mixtral-tiny"
lora_id = "peft-internal-testing/mixtral-pre-v5-lora"
with hub_online_once(model_id):
model = AutoModelForCausalLM.from_pretrained(model_id)
# test AutoModel.load_adapter
model.load_adapter(lora_id)
model.to(self.torch_device)
with torch.inference_mode():
output = model(inputs).logits
# a little bit of deviation but that's fine
atol, rtol = 1e-3, 1e-4
assert torch.allclose(output[0, :3, :3], expected_logits, atol=atol, rtol=rtol)
def test_mixtral_v4_lora_weight_conversion_peft_model_from_pretrained(self, expected_logits):
# Load a PEFT adapter trained with transformers v4 on Mixtral, which now has converted weights (MoE), using
# PeftModel.from_pretrained
inputs = torch.arange(10).view(1, -1).to(device=self.torch_device)
model_id = "hf-internal-testing/Mixtral-tiny"
lora_id = "peft-internal-testing/mixtral-pre-v5-lora"
with hub_online_once(model_id):
model = AutoModelForCausalLM.from_pretrained(model_id).to(self.torch_device)
# test PeftModel.from_pretrained
model = PeftModel.from_pretrained(model, lora_id)
with torch.inference_mode():
output = model(inputs).logits
# a little bit of deviation but that's fine
atol, rtol = 1e-3, 1e-4
assert torch.allclose(output[0, :3, :3], expected_logits, atol=atol, rtol=rtol)
def test_mixtral_v4_lora_weight_conversion_peft_model_load_adapter(self, expected_logits):
# Same as the previous test, but using PeftModel.load_adapter
inputs = torch.arange(10).view(1, -1).to(device=self.torch_device)
model_id = "hf-internal-testing/Mixtral-tiny"
lora_id = "peft-internal-testing/mixtral-pre-v5-lora"
with hub_online_once(model_id):
model = AutoModelForCausalLM.from_pretrained(model_id).to(self.torch_device)
# create a PeftModel instance
model = get_peft_model(model, LoraConfig(target_modules=["q_proj"]))
# test PeftModel.load_adapter
model.load_adapter(lora_id, adapter_name="other")
model.set_adapter("other")
with torch.inference_mode():
output = model(inputs).logits
atol, rtol = 1e-3, 1e-4
assert torch.allclose(output[0, :3, :3], expected_logits, atol=atol, rtol=rtol)
def test_mixtral_save_load_roundtrip(self, expected_logits, tmp_path):
# Load the v4 checkpoint with PEFT, save it (now v5 format) and load it again
inputs = torch.arange(10).view(1, -1).to(device=self.torch_device)
model_id = "hf-internal-testing/Mixtral-tiny"
lora_id = "peft-internal-testing/mixtral-pre-v5-lora"
with hub_online_once(model_id):
model = AutoModelForCausalLM.from_pretrained(model_id).to(self.torch_device)
model = PeftModel.from_pretrained(model, lora_id)
model.save_pretrained(tmp_path)
del model
with hub_online_once(model_id):
model = AutoModelForCausalLM.from_pretrained(model_id).to(self.torch_device)
model = PeftModel.from_pretrained(model, tmp_path)
with torch.inference_mode():
output = model(inputs).logits
# a little bit of deviation but that's fine
atol, rtol = 1e-3, 1e-4
assert torch.allclose(output[0, :3, :3], expected_logits, atol=atol, rtol=rtol)
def test_add_lora_to_mixtral_v5_works(self):
# Ensure that using LoRA directly with a v5 model still works
inputs = torch.arange(10).view(1, -1).to(device=self.torch_device)
model_id = "hf-internal-testing/Mixtral-tiny"
with hub_online_once(model_id):
model = AutoModelForCausalLM.from_pretrained(model_id).to(self.torch_device)
with torch.inference_mode():
output_base = model(inputs).logits
lora_config = LoraConfig(
target_modules=["q_proj", "k_proj", "v_proj"],
target_parameters=["gate.weight", "experts.gate_up_proj", "experts.down_proj"],
)
model = get_peft_model(model, lora_config).eval() # no error
with torch.inference_mode():
output_lora = model(inputs).logits
# sanity check
assert torch.allclose(output_base, output_lora)
num_lora_layers = len([m for m in model.modules() if isinstance(m, lora.LoraLayer)])
# sanity check
expected_num_lora_layers = 12 # 2 layers with 6 lora layers each
assert num_lora_layers == expected_num_lora_layers
def test_qwen_v4_lora_weight_conversion_peft_model_from_pretrained(self):
# Load a PEFT adapter trained with transformers v4 on Qwen3 MoE, which now has converted weights (MoE), using
# PeftModel.from_pretrained
inputs = torch.arange(10).view(1, -1).to(device=self.torch_device)
expected_logits = torch.Tensor(
[
[0.3644, -0.7487, -0.3190],
[0.2413, -0.8686, -0.5683],
[0.0333, -0.8790, -0.6361],
],
).to(device=self.torch_device)
model_id = "hf-internal-testing/tiny-qwen3-moe"
lora_id = "peft-internal-testing/qwen3-moe-pre-v5-lora"
with hub_online_once(model_id):
model = AutoModelForCausalLM.from_pretrained(model_id).to(self.torch_device)
# test PeftModel.from_pretrained
model = PeftModel.from_pretrained(model, lora_id)
with torch.inference_mode():
output = model(inputs).logits
# a little bit of deviation but that's fine
atol, rtol = 1e-3, 1e-4
assert torch.allclose(output[0, :3, :3], expected_logits, atol=atol, rtol=rtol)
def test_qwen2_5_vl_works(self):
# https://github.com/huggingface/trl/issues/5428
# It can happen that a model returns an entry for get_checkpoint_conversion_mapping but there is nothing further
# to do because no weights are being fused (e.g. only renamed). In that case, we have no entry in
# _MOE_TARGET_MODULE_MAPPING. The bug was that we would call dict.__getitem__ instead of dict.get.
model_id = "trl-internal-testing/tiny-Qwen2_5_VLForConditionalGeneration"
with hub_online_once(model_id):
model = AutoModelForImageTextToText.from_pretrained(model_id)
config = LoraConfig(target_modules=["q_proj", "v_proj"])
get_peft_model(model, config) # does not raise
@pytest.mark.parametrize(
"target_modules", [["up_proj", "down_proj", "score"], r".*\.(up_proj|down_proj)"], ids=["list", "regex"]
)
def test_qwen3_moe_partial_fusion_raises(self, target_modules):
# https://github.com/huggingface/trl/issues/5428
# Targeting up_proj but not gate_proj must raise -- they are fused into gate_up_proj. Covers the list and regex
# paths and guards the error-message handling. See `test_qwen3_moe_works` for a valid config.
model_id = "trl-internal-testing/tiny-Qwen3MoeForCausalLM"
with hub_online_once(model_id):
model = AutoModelForCausalLM.from_pretrained(model_id)
msg = re.escape(
"Cannot convert PEFT target(s) up_proj without also targeting gate_proj because they are fused into "
"gate_up_proj."
)
with pytest.raises(ValueError, match=msg):
get_peft_model(model, LoraConfig(target_modules=target_modules))
def test_qwen3_moe_works(self):
# https://github.com/huggingface/trl/issues/5428
# When correctly targeting both up and gate projection, there should be no error (see previous test)
model_id = "trl-internal-testing/tiny-Qwen3MoeForCausalLM"
with hub_online_once(model_id):
model = AutoModelForCausalLM.from_pretrained(model_id)
# target up_proj and gate_proj
config = LoraConfig(target_modules=["gate_proj", "up_proj", "down_proj", "score"])
get_peft_model(model, config) # does not raise
@pytest.mark.parametrize(
"regex",
[
# the shape ms-swift's get_multimodal_target_regex emits: anchored, prefix lookahead
r"^(model(?=\.).*\.(q_proj|k_proj|v_proj|gate_proj|up_proj|down_proj))$",
# a plain "ends with one of these projections" pattern
r".*\.(q_proj|k_proj|v_proj|gate_proj|up_proj|down_proj)",
],
)
def test_qwen3_moe_regex_target_modules_works(self, regex):
# Regression test for https://github.com/huggingface/peft/issues/3229: the conversion used to `set()` the regex
# string, splitting it into characters. It is now resolved to concrete names against the model.
model_id = "trl-internal-testing/tiny-Qwen3MoeForCausalLM"
with hub_online_once(model_id):
model = AutoModelForCausalLM.from_pretrained(model_id)
peft_model = get_peft_model(model, LoraConfig(target_modules=regex)) # does not raise
config = peft_model.peft_config["default"]
# non-fused targets stay in target_modules; fused experts move to target_parameters (rank/alpha doubled for the
# 2-way gate_up_proj). The regex does not name the router, so `gate.weight` is *not* pulled in (contrast
# `test_qwen3_moe_all_linear_target_modules_works`, where "all-linear" does pull it in).
assert config.target_modules == {"q_proj", "k_proj", "v_proj"}
assert set(config.target_parameters) == {"gate_up_proj", "down_proj"}
assert config.rank_pattern == {r".*\.gate_up_proj": config.r * 2}
assert config.alpha_pattern == {r".*\.gate_up_proj": config.lora_alpha * 2}
# the experts are actually adapted, not just recorded in the config
assert any("experts" in name for name, _ in peft_model.named_modules() if name.endswith("lora_A.default"))
def test_qwen3_moe_single_attention_regex_target_modules_works(self):
# A regex resolving to a single attention projection (no experts) converts cleanly: nothing moves to parameters.
model_id = "trl-internal-testing/tiny-Qwen3MoeForCausalLM"
with hub_online_once(model_id):
model = AutoModelForCausalLM.from_pretrained(model_id)
peft_model = get_peft_model(model, LoraConfig(target_modules=r".*\.q_proj")) # does not raise
config = peft_model.peft_config["default"]
assert config.target_modules == {"q_proj"}
assert not config.target_parameters
assert not config.rank_pattern
adapted = {
name.rsplit(".lora_A", 1)[0].rsplit(".", 1)[-1]
for name, _ in peft_model.named_modules()
if name.endswith("lora_A.default")
}
assert adapted == {"q_proj"}
def test_qwen3_moe_all_linear_target_modules_works(self):
# "all-linear" is matched by module type, not as a regex. On v4 the experts *and* the router `gate` were
# nn.Linear, so "all-linear" targeted them; after fusion the experts are stacked parameters and the router is a
# custom module, so "all-linear" must still carry all of them into target_parameters (gate -> gate.weight).
model_id = "trl-internal-testing/tiny-Qwen3MoeForCausalLM"
with hub_online_once(model_id):
model = AutoModelForCausalLM.from_pretrained(model_id)
peft_model = get_peft_model(model, LoraConfig(target_modules="all-linear")) # does not raise
config = peft_model.peft_config["default"]
adapted = {
name.rsplit(".lora_A", 1)[0].rsplit(".", 1)[-1]
for name, _ in peft_model.named_modules()
if name.endswith("lora_A.default")
}
# attention projections resolved as modules; experts and router (linear in v4) carried into target_parameters
assert {"q_proj", "k_proj", "v_proj", "o_proj"} <= adapted
# the router `gate` is adapted too -- it is a parameter (`gate.weight`) on v5, so unlike the regex case it has
# to be added by name; this is exactly what distinguishes "all-linear" from a regex that omits the router
assert set(config.target_parameters) == {"gate.weight", "gate_up_proj", "down_proj"}
assert config.rank_pattern == {r".*\.gate_up_proj": config.r * 2}
assert config.alpha_pattern == {r".*\.gate_up_proj": config.lora_alpha * 2}
# the experts and the router gate are actually adapted, not just recorded in the config
assert any("experts" in name for name, _ in peft_model.named_modules() if name.endswith("lora_A.default"))
assert any(name.endswith(".mlp.gate.lora_A.default") for name, _ in peft_model.named_modules())
def test_qwen3_moe_regex_target_modules_save_load_roundtrip(self, tmp_path):
# Save/reload a regex-targeted adapter. Reload re-runs the conversion on the already-converted config, so this
# also guards idempotency.
inputs = torch.arange(10).view(1, -1).to(self.torch_device)
model_id = "trl-internal-testing/tiny-Qwen3MoeForCausalLM"
regex = r"^(model(?=\.).*\.(q_proj|k_proj|v_proj|gate_proj|up_proj|down_proj))$"
with hub_online_once(model_id):
model = AutoModelForCausalLM.from_pretrained(model_id).to(self.torch_device)
# init_lora_weights=False gives the adapter non-zero (random) weights so it actually changes the output
peft_model = get_peft_model(model, LoraConfig(target_modules=regex, init_lora_weights=False))
with torch.inference_mode():
logits_before = peft_model(inputs).logits
peft_model.save_pretrained(tmp_path)
del model, peft_model
model = AutoModelForCausalLM.from_pretrained(model_id).to(self.torch_device)
reloaded = PeftModel.from_pretrained(model, tmp_path)
with torch.inference_mode():
logits_after = reloaded(inputs).logits
assert torch.allclose(logits_before, logits_after, atol=1e-5, rtol=1e-5)
# reload re-runs the conversion on the already-resolved config -- idempotent
reloaded_config = reloaded.peft_config["default"]
assert reloaded_config.target_modules == {"q_proj", "k_proj", "v_proj"}
assert {"gate_up_proj", "down_proj"} <= set(reloaded_config.target_parameters)
assert reloaded_config.rank_pattern == {r".*\.gate_up_proj": reloaded_config.r * 2}