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
Build documentation / build (push) Waiting to run
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
770 lines
32 KiB
Python
770 lines
32 KiB
Python
# Copyright 2026-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.
|
|
"""Generate a machine-readable capability matrix of all PEFT methods.
|
|
|
|
For each registered PEFT method, a fixed set of checks ("tasks") determines which user-facing features the method
|
|
supports, e.g. which quantization backends it integrates with, which layer types it can target, or whether its adapters
|
|
can be merged into the base weights. The result is written as JSON and is intended as a generic data source, e.g. for
|
|
documentation pages or the PEFT shop app (method_comparison/peft-shop).
|
|
|
|
Each value is annotated with the *source* of the information:
|
|
|
|
- "introspection": determined by statically inspecting the classes of the installed PEFT package
|
|
- "file_check": determined from the presence of integration modules in the PEFT source tree
|
|
- "probe": determined empirically by exercising the feature on a tiny model on CPU
|
|
- "error": the check itself failed; the value is "unknown" and the note contains the reason
|
|
|
|
Values are never guessed: if a check cannot determine a feature, the value is reported as "unknown" together with a
|
|
note. In particular, probing a method requires that its config can be instantiated with default arguments; methods that
|
|
need more than that must have an entry in PROBE_CONFIG_OVERRIDES.
|
|
|
|
The script requires PEFT to be installed (e.g. `pip install -e .`), runs on CPU, downloads nothing, and is idempotent:
|
|
running it twice on the same environment produces identical output.
|
|
|
|
Usage examples:
|
|
|
|
# check all methods, write JSON to method_capabilities.json
|
|
python scripts/generate_method_capabilities.py
|
|
|
|
# check only LoRA and IA3, write to a custom file
|
|
python scripts/generate_method_capabilities.py --methods lora ia3 --output capabilities.json
|
|
|
|
# show which checks would run, without running them
|
|
python scripts/generate_method_capabilities.py --dry-run
|
|
|
|
Tasks are collected up-front before any of them runs (see `collect_tasks`). This makes `--dry-run` trivial and leaves
|
|
the door open to run independent tasks in parallel later, should runtime ever become an issue.
|
|
"""
|
|
|
|
import argparse
|
|
import dataclasses
|
|
import enum
|
|
import inspect
|
|
import json
|
|
import logging
|
|
import re
|
|
import sys
|
|
import warnings
|
|
from abc import ABC, abstractmethod
|
|
from collections.abc import Callable
|
|
from copy import deepcopy
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import Any, ClassVar
|
|
|
|
import torch
|
|
from torch import nn
|
|
from tqdm import tqdm
|
|
from transformers import LlamaConfig, LlamaForCausalLM
|
|
from transformers.pytorch_utils import Conv1D
|
|
|
|
import peft
|
|
from peft import get_peft_model
|
|
from peft.config import PeftConfig, PromptLearningConfig
|
|
from peft.mapping import PEFT_TYPE_TO_CONFIG_MAPPING, PEFT_TYPE_TO_MIXED_MODEL_MAPPING, PEFT_TYPE_TO_TUNER_MAPPING
|
|
from peft.tuners.tuners_utils import BaseTuner, BaseTunerLayer
|
|
from peft.utils.hotswap import CONFIG_KEYS_TO_CHECK
|
|
from peft.utils.peft_types import PeftType
|
|
|
|
|
|
logger = logging.getLogger("generate_method_capabilities")
|
|
|
|
UNKNOWN = "unknown"
|
|
NOT_APPLICABLE = "not_applicable"
|
|
|
|
# Probe models use this hidden dimension throughout. It is chosen to be highly divisible, since several methods have
|
|
# divisibility constraints between their block/rank settings and the layer dimensions (e.g. C3A, VBLoRA, RoAd).
|
|
HIDDEN_DIM = 64
|
|
|
|
# Quantization integration modules inside a tuner package, mapped to the backend names they provide. The bnb module
|
|
# always contains both the 8-bit and the 4-bit integration.
|
|
QUANT_FILE_BACKENDS: dict[str, tuple[str, ...]] = {
|
|
"aqlm": ("aqlm",),
|
|
"awq": ("awq",),
|
|
"bnb": ("bnb_8bit", "bnb_4bit"),
|
|
"eetq": ("eetq",),
|
|
"gptq": ("gptq",),
|
|
"hqq": ("hqq",),
|
|
"inc": ("inc",),
|
|
"torchao": ("torchao",),
|
|
}
|
|
|
|
# Backends covered by the generic quantization integration (peft.utils.quantization_utils). Methods that call
|
|
# resolve_quantization_backend don't need per-backend integration modules; they support everything the resolver
|
|
# handles (merging may still be unavailable for the forward-only backends, which is a property of the backend, not of
|
|
# the PEFT method).
|
|
GENERIC_QUANT_BACKENDS: tuple[str, ...] = (
|
|
"aqlm",
|
|
"awq",
|
|
"bnb_4bit",
|
|
"bnb_8bit",
|
|
"eetq",
|
|
"gptq",
|
|
"hqq",
|
|
"inc",
|
|
"torchao",
|
|
)
|
|
|
|
# Extra config arguments required to instantiate a method's config for probing, on top of the generic arguments
|
|
# (target_modules for adapter methods, task_type/num_virtual_tokens for prompt learning). If probing a method reports
|
|
# "unknown" because its config could not be instantiated, add an entry here.
|
|
PROBE_CONFIG_OVERRIDES: dict[PeftType, dict[str, Any]] = {
|
|
# total_step is a required argument
|
|
PeftType.ADALORA: {"total_step": 10},
|
|
# IA3 needs feedforward_modules
|
|
PeftType.IA3: {"feedforward_modules": []},
|
|
# the default block_size of 256 does not divide HIDDEN_DIM
|
|
PeftType.C3A: {"block_size": 16},
|
|
# token_indices defaults to an empty list, which trains nothing
|
|
PeftType.TRAINABLE_TOKENS: {"token_indices": [0, 1]},
|
|
# the default vector_length of 256 does not divide HIDDEN_DIM
|
|
PeftType.VBLORA: {"num_vectors": 32, "vector_length": 16},
|
|
PeftType.ADAPTION_PROMPT: {"adapter_layers": 1, "adapter_len": 4, "task_type": "CAUSAL_LM"},
|
|
}
|
|
|
|
# Methods that cannot be probed on a self-contained tiny model. Probe-based checks report "unknown" for these.
|
|
PROBE_SKIP: dict[PeftType, str] = {
|
|
PeftType.XLORA: "requires pre-trained LoRA adapter checkpoints to instantiate",
|
|
}
|
|
|
|
# Method-specific config switches that are worth surfacing as "extras". This is a curated list: reporting every config
|
|
# field would drown the relevant information in noise. Note that target_parameters is not listed here, as it is
|
|
# already covered by the target_layer_types check.
|
|
NOTABLE_CONFIG_FIELDS: tuple[str, ...] = (
|
|
"alpha_pattern",
|
|
"layer_replication",
|
|
"rank_pattern",
|
|
"use_dora",
|
|
"use_rslora",
|
|
)
|
|
|
|
# Docs page slugs that differ from the lower-cased PEFT method name.
|
|
DOCS_SLUG_OVERRIDES: dict[str, str] = {
|
|
"ADAPTION_PROMPT": "llama_adapter",
|
|
"CARTRIDGE": "cartridges",
|
|
"LN_TUNING": "layernorm_tuning",
|
|
}
|
|
|
|
# paper links as they appear in the docs intro paragraphs and in the config/model class docstrings
|
|
PAPER_URL_RE = re.compile(
|
|
r"https://(?:huggingface\.co/papers/|arxiv\.org/(?:abs|pdf)/|openreview\.net/forum\?id=)[^\s)\"'>]+"
|
|
)
|
|
|
|
|
|
class Source(enum.StrEnum):
|
|
INTROSPECTION = "introspection"
|
|
FILE_CHECK = "file_check"
|
|
PROBE = "probe"
|
|
ERROR = "error"
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Finding:
|
|
value: Any
|
|
source: Source
|
|
note: str | None = None
|
|
|
|
def to_json(self) -> dict[str, Any]:
|
|
result: dict[str, Any] = {"value": self.value, "source": str(self.source)}
|
|
if self.note:
|
|
result["note"] = self.note
|
|
return result
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class MethodInfo:
|
|
peft_type: PeftType
|
|
config_cls: type[PeftConfig]
|
|
model_cls: type | None
|
|
category: str # "adapter", "prompt_learning", or "other"
|
|
|
|
@property
|
|
def name(self) -> str:
|
|
return self.peft_type.value
|
|
|
|
@classmethod
|
|
def from_peft_type(cls, peft_type: PeftType) -> "MethodInfo":
|
|
config_cls = PEFT_TYPE_TO_CONFIG_MAPPING[peft_type]
|
|
model_cls = PEFT_TYPE_TO_TUNER_MAPPING.get(peft_type)
|
|
if issubclass(config_cls, PromptLearningConfig):
|
|
category = "prompt_learning"
|
|
elif (model_cls is not None) and issubclass(model_cls, BaseTuner):
|
|
category = "adapter"
|
|
else:
|
|
# e.g. adaption prompt, whose model class manages adapters without subclassing BaseTuner
|
|
category = "other"
|
|
return cls(peft_type=peft_type, config_cls=config_cls, model_cls=model_cls, category=category)
|
|
|
|
|
|
def _layer_classes(method: MethodInfo) -> list[type[BaseTunerLayer]]:
|
|
"""Return the tuner layer classes defined in the method's main layer module.
|
|
|
|
Quantization-specific layer variants (bnb.py etc.) are deliberately not considered: importing them depends on the
|
|
installed quantization libraries, and the main layer module is what determines baseline support.
|
|
"""
|
|
tuner_layer_cls = getattr(method.model_cls, "tuner_layer_cls", None)
|
|
if tuner_layer_cls is None:
|
|
return []
|
|
module = sys.modules[tuner_layer_cls.__module__]
|
|
return [
|
|
obj
|
|
for obj in vars(module).values()
|
|
if isinstance(obj, type) and issubclass(obj, BaseTunerLayer) and obj.__module__ == module.__name__
|
|
]
|
|
|
|
|
|
def _format_exception(exc: BaseException, limit: int = 250) -> str:
|
|
msg = f"{type(exc).__name__}: {exc}"
|
|
return msg if len(msg) <= limit else msg[: limit - 3] + "..."
|
|
|
|
|
|
class ProbeError(Exception):
|
|
"""Raised when a probe cannot be set up; results in an 'unknown' finding, never in a false positive/negative."""
|
|
|
|
|
|
class SingleLayerModel(nn.Module):
|
|
"""Minimal host model providing a single named module ("layer") for PEFT to target."""
|
|
|
|
def __init__(self, layer: nn.Module) -> None:
|
|
super().__init__()
|
|
self.layer = layer
|
|
|
|
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
|
return self.layer(x)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class LayerSpec:
|
|
label: str
|
|
build: Callable[[], nn.Module]
|
|
|
|
|
|
# The layer types whose support is probed per method. Probing checks injection only (i.e. whether the layer gets
|
|
# wrapped), no forward pass, since a successful wrap is the support signal and a broken forward would be a bug.
|
|
LAYER_SPECS: tuple[LayerSpec, ...] = (
|
|
LayerSpec("Linear", lambda: nn.Linear(HIDDEN_DIM, HIDDEN_DIM)),
|
|
LayerSpec("Embedding", lambda: nn.Embedding(16, HIDDEN_DIM)),
|
|
LayerSpec("Conv1d", lambda: nn.Conv1d(HIDDEN_DIM, HIDDEN_DIM, 3)),
|
|
LayerSpec("Conv2d", lambda: nn.Conv2d(HIDDEN_DIM, HIDDEN_DIM, 3)),
|
|
LayerSpec("Conv3d", lambda: nn.Conv3d(HIDDEN_DIM, HIDDEN_DIM, 3)),
|
|
LayerSpec("LayerNorm", lambda: nn.LayerNorm(HIDDEN_DIM)),
|
|
LayerSpec("MultiheadAttention", lambda: nn.MultiheadAttention(HIDDEN_DIM, num_heads=4)),
|
|
LayerSpec("Conv1D (transformers)", lambda: Conv1D(HIDDEN_DIM, HIDDEN_DIM)),
|
|
)
|
|
|
|
|
|
class ProbeContext:
|
|
"""Builds tiny throwaway models to exercise features on CPU.
|
|
|
|
The tiny transformer used for prompt learning methods is constructed from a config (no download) and cached; each
|
|
probe receives a deepcopy so that probes cannot contaminate each other.
|
|
"""
|
|
|
|
def __init__(self) -> None:
|
|
self._tiny_lm: nn.Module | None = None
|
|
|
|
def make_config(self, method: MethodInfo, **kwargs: Any) -> PeftConfig:
|
|
if method.peft_type in PROBE_SKIP:
|
|
raise ProbeError(f"not probed: {PROBE_SKIP[method.peft_type]}")
|
|
kwargs = PROBE_CONFIG_OVERRIDES.get(method.peft_type, {}) | kwargs
|
|
try:
|
|
return method.config_cls(**kwargs)
|
|
except Exception as exc:
|
|
raise ProbeError(
|
|
f"could not instantiate {method.config_cls.__name__} for probing "
|
|
f"(consider adding an entry to PROBE_CONFIG_OVERRIDES): {_format_exception(exc)}"
|
|
) from exc
|
|
|
|
def _probe_layer_and_input(self, method: MethodInfo) -> tuple[nn.Module, torch.Tensor]:
|
|
if method.peft_type == PeftType.TRAINABLE_TOKENS:
|
|
# trainable tokens only target embedding layers
|
|
return nn.Embedding(16, HIDDEN_DIM), torch.randint(0, 16, (2, 5))
|
|
return nn.Linear(HIDDEN_DIM, HIDDEN_DIM), torch.randn(2, HIDDEN_DIM)
|
|
|
|
def adapter_model(self, method: MethodInfo) -> tuple[nn.Module, torch.Tensor]:
|
|
"""Return a PEFT model wrapping a single-layer host, plus a suitable example input."""
|
|
torch.manual_seed(0)
|
|
layer, example_input = self._probe_layer_and_input(method)
|
|
host = SingleLayerModel(layer)
|
|
config = self.make_config(method, target_modules=["layer"])
|
|
try:
|
|
return get_peft_model(host, config), example_input
|
|
except Exception as exc:
|
|
raise ProbeError(f"could not build probe model: {_format_exception(exc)}") from exc
|
|
|
|
def transformer_model(self, method: MethodInfo) -> nn.Module:
|
|
"""Return a PEFT model on a tiny transformer, for methods that require one (prompt learning etc.)."""
|
|
if self._tiny_lm is None:
|
|
torch.manual_seed(0)
|
|
tiny_config = LlamaConfig(
|
|
vocab_size=64,
|
|
hidden_size=32,
|
|
intermediate_size=64,
|
|
num_hidden_layers=2,
|
|
num_attention_heads=4,
|
|
num_key_value_heads=4,
|
|
max_position_embeddings=64,
|
|
)
|
|
self._tiny_lm = LlamaForCausalLM(tiny_config)
|
|
kwargs: dict[str, Any] = {"task_type": "CAUSAL_LM"}
|
|
if method.category == "prompt_learning":
|
|
kwargs["num_virtual_tokens"] = 4
|
|
config = self.make_config(method, **kwargs)
|
|
try:
|
|
return get_peft_model(deepcopy(self._tiny_lm), config)
|
|
except Exception as exc:
|
|
raise ProbeError(f"could not build probe model: {_format_exception(exc)}") from exc
|
|
|
|
def second_config(self, method: MethodInfo) -> PeftConfig:
|
|
"""A config suitable for adding a second adapter to a model built by this context."""
|
|
if method.category == "adapter":
|
|
return self.make_config(method, target_modules=["layer"])
|
|
kwargs: dict[str, Any] = {"task_type": "CAUSAL_LM"}
|
|
if method.category == "prompt_learning":
|
|
kwargs["num_virtual_tokens"] = 4
|
|
return self.make_config(method, **kwargs)
|
|
|
|
|
|
class Task(ABC):
|
|
"""A single feature check for a single method. Never raises; failures become 'unknown' findings."""
|
|
|
|
feature: ClassVar[str]
|
|
description: ClassVar[str]
|
|
|
|
def __init__(self, method: MethodInfo, probe: ProbeContext) -> None:
|
|
self.method = method
|
|
self.probe = probe
|
|
|
|
@abstractmethod
|
|
def check(self) -> Finding: ...
|
|
|
|
def run(self) -> Finding:
|
|
try:
|
|
# Probing emits plenty of warnings that are expected and irrelevant here (e.g. about adapter
|
|
# initialization or fan_in_fan_out). Suppression is re-asserted per task instead of once globally, since
|
|
# libraries imported lazily during probing may manipulate the global warning filters.
|
|
with warnings.catch_warnings():
|
|
warnings.simplefilter("ignore")
|
|
return self.check()
|
|
except ProbeError as exc:
|
|
return Finding(value=UNKNOWN, source=Source.PROBE, note=str(exc))
|
|
except Exception as exc:
|
|
return Finding(value=UNKNOWN, source=Source.ERROR, note=_format_exception(exc))
|
|
|
|
|
|
class CategoryTask(Task):
|
|
feature = "category"
|
|
description = "whether the method is a layer-wrapping adapter, a prompt learning method, or something else"
|
|
|
|
def check(self) -> Finding:
|
|
return Finding(value=self.method.category, source=Source.INTROSPECTION)
|
|
|
|
|
|
class TargetLayerTypesTask(Task):
|
|
feature = "target_layer_types"
|
|
description = (
|
|
"which layer types (incl. nn.Parameter) can be targeted, probed by injecting into single-layer models"
|
|
)
|
|
|
|
def check(self) -> Finding:
|
|
if self.method.category != "adapter":
|
|
return Finding(
|
|
value=NOT_APPLICABLE, source=Source.INTROSPECTION, note="method does not wrap target layers"
|
|
)
|
|
|
|
results: dict[str, bool] = {}
|
|
first_error: str | None = None
|
|
for spec in LAYER_SPECS:
|
|
torch.manual_seed(0)
|
|
host = SingleLayerModel(spec.build())
|
|
config = self.probe.make_config(self.method, target_modules=["layer"])
|
|
try:
|
|
model = get_peft_model(host, config)
|
|
except Exception as exc:
|
|
results[spec.label] = False
|
|
if first_error is None:
|
|
first_error = f"{spec.label}: {_format_exception(exc)}"
|
|
else:
|
|
results[spec.label] = any(isinstance(module, BaseTunerLayer) for module in model.modules())
|
|
|
|
if not any(results.values()):
|
|
# if not even one layer type can be wrapped, the probe setup is likely at fault, not the method
|
|
raise ProbeError(f"no layer type could be wrapped, probe presumably mis-configured; {first_error}")
|
|
|
|
# Directly targeting nn.Parameter (crucial e.g. for MoE layers) is governed by the target_parameters config
|
|
# option; its presence is the support signal, no injection probe is needed.
|
|
field_names = {f.name for f in dataclasses.fields(self.method.config_cls)}
|
|
results["nn.Parameter"] = "target_parameters" in field_names
|
|
return Finding(
|
|
value=results,
|
|
source=Source.PROBE,
|
|
note="nn.Parameter support is based on the presence of the target_parameters config option",
|
|
)
|
|
|
|
|
|
class QuantizationTask(Task):
|
|
feature = "quantization_backends"
|
|
description = "supported quantization backends, from integration modules and use of the generic backend resolver"
|
|
|
|
def check(self) -> Finding:
|
|
if self.method.category == "prompt_learning":
|
|
return Finding(
|
|
value=NOT_APPLICABLE,
|
|
source=Source.INTROSPECTION,
|
|
note="prompt learning does not wrap target layers and generally works regardless of quantization",
|
|
)
|
|
if self.method.category != "adapter":
|
|
return Finding(value=UNKNOWN, source=Source.INTROSPECTION, note="no known quantization signal")
|
|
|
|
backends: set[str] = set()
|
|
package_dir = Path(inspect.getfile(self.method.model_cls)).parent
|
|
for stem, names in QUANT_FILE_BACKENDS.items():
|
|
if (package_dir / f"{stem}.py").exists():
|
|
backends.update(names)
|
|
|
|
# methods using the generic quantization integration support all backends the resolver handles
|
|
module_names = {self.method.model_cls.__module__}
|
|
if (tuner_layer_cls := getattr(self.method.model_cls, "tuner_layer_cls", None)) is not None:
|
|
module_names.add(tuner_layer_cls.__module__)
|
|
for module_name in module_names:
|
|
if "resolve_quantization_backend" in inspect.getsource(sys.modules[module_name]):
|
|
backends.update(GENERIC_QUANT_BACKENDS)
|
|
break
|
|
|
|
return Finding(value=sorted(backends), source=Source.FILE_CHECK)
|
|
|
|
|
|
class MultipleAdaptersTask(Task):
|
|
feature = "multiple_adapters"
|
|
description = "whether several adapters can be loaded on the same model, probed via add_adapter"
|
|
|
|
def check(self) -> Finding:
|
|
if self.method.category == "adapter":
|
|
model, _ = self.probe.adapter_model(self.method)
|
|
else:
|
|
model = self.probe.transformer_model(self.method)
|
|
try:
|
|
model.add_adapter("second", self.probe.second_config(self.method))
|
|
model.set_adapter("second")
|
|
except Exception as exc:
|
|
return Finding(value=False, source=Source.PROBE, note=_format_exception(exc))
|
|
return Finding(value=True, source=Source.PROBE)
|
|
|
|
|
|
class MixedAdapterBatchesTask(Task):
|
|
feature = "mixed_adapter_batches"
|
|
description = "whether one batch can mix several adapters via the adapter_names argument"
|
|
|
|
def check(self) -> Finding:
|
|
if self.method.category != "adapter":
|
|
return Finding(
|
|
value=False, source=Source.INTROSPECTION, note="only supported by layer-wrapping adapter methods"
|
|
)
|
|
# Support requires both halves of the mechanism: the model must install the forward hooks that distribute
|
|
# adapter_names, and the tuner layers must implement _mixed_batch_forward (possibly inherited, e.g. from
|
|
# LoRA). Checking only one of them would over-report.
|
|
model_supports_hooks = hasattr(self.method.model_cls, "_enable_peft_forward_hooks")
|
|
layer_classes = _layer_classes(self.method)
|
|
layer_supports = any(hasattr(cls, "_mixed_batch_forward") for cls in layer_classes)
|
|
return Finding(value=model_supports_hooks and layer_supports, source=Source.INTROSPECTION)
|
|
|
|
|
|
class MergeTask(Task):
|
|
feature = "merging"
|
|
description = "whether adapters can be merged into the base weights, verified via merge_and_unload"
|
|
|
|
def check(self) -> Finding:
|
|
if self.method.category == "prompt_learning":
|
|
return Finding(
|
|
value=False, source=Source.INTROSPECTION, note="virtual tokens cannot be merged into base weights"
|
|
)
|
|
if self.method.category != "adapter":
|
|
return Finding(value=False, source=Source.INTROSPECTION, note="method does not implement merging")
|
|
|
|
layer_classes = _layer_classes(self.method)
|
|
# BaseTunerLayer.merge raises NotImplementedError, so an unchanged merge attribute means no support
|
|
implemented = any(cls.merge is not BaseTunerLayer.merge for cls in layer_classes)
|
|
if not implemented:
|
|
return Finding(value=False, source=Source.INTROSPECTION, note="no tuner layer class implements merge()")
|
|
|
|
try:
|
|
model, example_input = self.probe.adapter_model(self.method)
|
|
model.eval()
|
|
with torch.no_grad():
|
|
output_before = model(example_input)
|
|
merged = model.merge_and_unload()
|
|
output_after = merged(example_input)
|
|
except ProbeError as exc:
|
|
return Finding(
|
|
value=True, source=Source.INTROSPECTION, note=f"merge() is implemented, but probing failed: {exc}"
|
|
)
|
|
except NotImplementedError as exc:
|
|
return Finding(value=False, source=Source.PROBE, note=_format_exception(exc))
|
|
except Exception as exc:
|
|
return Finding(
|
|
value=True,
|
|
source=Source.INTROSPECTION,
|
|
note=f"merge() is implemented, but probing failed: {_format_exception(exc)}",
|
|
)
|
|
|
|
note = None
|
|
if not torch.allclose(output_before, output_after, atol=1e-4):
|
|
note = "merged model outputs deviate from unmerged outputs beyond tolerance"
|
|
return Finding(value=True, source=Source.PROBE, note=note)
|
|
|
|
|
|
class MixedMethodModelTask(Task):
|
|
feature = "peft_mixed_model"
|
|
description = "whether the method can be combined with other method types in a PeftMixedModel"
|
|
|
|
def check(self) -> Finding:
|
|
# filled by register_peft_method(..., is_mixed_compatible=True)
|
|
value = self.method.peft_type in PEFT_TYPE_TO_MIXED_MODEL_MAPPING
|
|
return Finding(value=value, source=Source.INTROSPECTION)
|
|
|
|
|
|
class LoraConversionTask(Task):
|
|
feature = "lora_conversion"
|
|
description = "whether adapters of this method can be converted to a LoRA adapter"
|
|
|
|
def check(self) -> Finding:
|
|
if self.method.peft_type == PeftType.LORA:
|
|
return Finding(value=True, source=Source.INTROSPECTION, note="already a LoRA adapter")
|
|
if self.method.category != "adapter":
|
|
return Finding(value=False, source=Source.INTROSPECTION, note="method does not wrap target layers")
|
|
|
|
try:
|
|
model, _ = self.probe.adapter_model(self.method)
|
|
except ProbeError as exc:
|
|
# fall back to a static signal; an override does not guarantee support, hence the note
|
|
layer_classes = _layer_classes(self.method)
|
|
overridden = any(
|
|
cls.supports_lora_conversion is not BaseTunerLayer.supports_lora_conversion for cls in layer_classes
|
|
)
|
|
return Finding(
|
|
value=overridden,
|
|
source=Source.INTROSPECTION,
|
|
note=f"based on presence of a supports_lora_conversion override; probing failed: {exc}",
|
|
)
|
|
|
|
layer = next(module for module in model.modules() if isinstance(module, BaseTunerLayer))
|
|
return Finding(value=bool(layer.supports_lora_conversion("default")), source=Source.PROBE)
|
|
|
|
|
|
class WeightedAdapterTask(Task):
|
|
feature = "add_weighted_adapter"
|
|
description = "whether several adapters can be combined into a new one, probed via add_weighted_adapter"
|
|
|
|
def check(self) -> Finding:
|
|
# Methods without the API don't support the feature; methods that inherit it but override it with a stub
|
|
# that raises (e.g. AdaLoRA) are caught by the probe below.
|
|
if getattr(self.method.model_cls, "add_weighted_adapter", None) is None:
|
|
return Finding(value=False, source=Source.INTROSPECTION)
|
|
|
|
model, _ = self.probe.adapter_model(self.method)
|
|
# combining several adapters requires loading several adapters in the first place; methods that already fail
|
|
# here cannot support add_weighted_adapter either
|
|
try:
|
|
model.add_adapter("second", self.probe.second_config(self.method))
|
|
except Exception as exc:
|
|
return Finding(
|
|
value=False,
|
|
source=Source.PROBE,
|
|
note=f"a second adapter could not be added: {_format_exception(exc)}",
|
|
)
|
|
try:
|
|
model.add_weighted_adapter(adapters=["default", "second"], weights=[0.5, 0.5], adapter_name="combined")
|
|
except Exception as exc:
|
|
return Finding(value=False, source=Source.PROBE, note=_format_exception(exc))
|
|
return Finding(value=True, source=Source.PROBE)
|
|
|
|
|
|
class HotswapTask(Task):
|
|
feature = "hotswapping"
|
|
description = "whether adapters can be hot-swapped in place (peft.utils.hotswap)"
|
|
|
|
def check(self) -> Finding:
|
|
return Finding(value=self.method.peft_type in CONFIG_KEYS_TO_CHECK, source=Source.INTROSPECTION)
|
|
|
|
|
|
class AuxiliaryModulesTask(Task):
|
|
feature = "auxiliary_modules"
|
|
description = "whether the config supports modules_to_save and trainable_token_indices"
|
|
|
|
def check(self) -> Finding:
|
|
field_names = {f.name for f in dataclasses.fields(self.method.config_cls)}
|
|
value = {
|
|
"modules_to_save": "modules_to_save" in field_names,
|
|
"trainable_token_indices": "trainable_token_indices" in field_names,
|
|
}
|
|
return Finding(value=value, source=Source.INTROSPECTION)
|
|
|
|
|
|
class ExtrasTask(Task):
|
|
feature = "extras"
|
|
description = "notable method-specific config options (curated list)"
|
|
|
|
def check(self) -> Finding:
|
|
field_names = {f.name for f in dataclasses.fields(self.method.config_cls)}
|
|
value = sorted(field_names.intersection(NOTABLE_CONFIG_FIELDS))
|
|
return Finding(value=value, source=Source.INTROSPECTION)
|
|
|
|
|
|
class PaperLinkTask(Task):
|
|
feature = "paper_url"
|
|
description = "link to the method's paper, from the docs intro or class docstrings (omitted when ambiguous)"
|
|
|
|
# default assumes the script lives in scripts/ of a repository checkout; can be overridden via --docs-dir
|
|
docs_dir: ClassVar[Path] = Path(__file__).parent.parent / "docs" / "source" / "package_reference"
|
|
|
|
def _docs_intro(self) -> str | None:
|
|
"""The first paragraph after the first heading of the method's docs page (the pages start with a license
|
|
comment, a `# Title` heading, and a prose paragraph)."""
|
|
slug = DOCS_SLUG_OVERRIDES.get(self.method.name, self.method.name.lower())
|
|
try:
|
|
text = (self.docs_dir / f"{slug}.md").read_text()
|
|
except OSError:
|
|
return None
|
|
text = re.sub(r"<!--.*?-->", "", text, flags=re.DOTALL)
|
|
match = re.search(r"^# .+?$\s+(.+?)(?:\n\s*\n|$)", text, flags=re.MULTILINE | re.DOTALL)
|
|
return match.group(1) if match else None
|
|
|
|
def check(self) -> Finding:
|
|
# Sources in order of preference; the first one containing exactly one distinct paper URL wins. A source
|
|
# with no link, or with several different ones (e.g. docstrings that also cite related methods), is skipped
|
|
# as ambiguous -- a missing paper link is better than a wrong one.
|
|
sources: list[tuple[str, str | None]] = [
|
|
("docs intro", self._docs_intro()),
|
|
("config class docstring", self.method.config_cls.__doc__),
|
|
("model class docstring", self.method.model_cls.__doc__ if self.method.model_cls is not None else None),
|
|
]
|
|
for source_name, text in sources:
|
|
if not text:
|
|
continue
|
|
urls = set(PAPER_URL_RE.findall(text))
|
|
if len(urls) == 1:
|
|
return Finding(value=urls.pop(), source=Source.FILE_CHECK, note=f"from the {source_name}")
|
|
return Finding(
|
|
value=None,
|
|
source=Source.FILE_CHECK,
|
|
note="no unambiguous paper link in the docs intro or the config/model class docstrings",
|
|
)
|
|
|
|
|
|
# the order here determines the order of the features in the output
|
|
TASK_CLASSES: tuple[type[Task], ...] = (
|
|
CategoryTask,
|
|
TargetLayerTypesTask,
|
|
QuantizationTask,
|
|
MultipleAdaptersTask,
|
|
MixedAdapterBatchesTask,
|
|
MergeTask,
|
|
MixedMethodModelTask,
|
|
LoraConversionTask,
|
|
WeightedAdapterTask,
|
|
HotswapTask,
|
|
AuxiliaryModulesTask,
|
|
ExtrasTask,
|
|
PaperLinkTask,
|
|
)
|
|
|
|
|
|
def collect_methods(selected: list[str] | None) -> list[MethodInfo]:
|
|
registered = sorted(PEFT_TYPE_TO_CONFIG_MAPPING, key=lambda peft_type: peft_type.value)
|
|
if selected is not None:
|
|
valid = {peft_type.value for peft_type in registered}
|
|
requested = [name.upper() for name in selected]
|
|
if unknown := [name for name in requested if name not in valid]:
|
|
raise SystemExit(
|
|
f"Unknown PEFT method(s): {', '.join(unknown)}. Valid choices: {', '.join(sorted(valid))}"
|
|
)
|
|
registered = [peft_type for peft_type in registered if peft_type.value in requested]
|
|
return [MethodInfo.from_peft_type(peft_type) for peft_type in registered]
|
|
|
|
|
|
def collect_tasks(methods: list[MethodInfo], probe: ProbeContext) -> list[Task]:
|
|
return [task_cls(method, probe) for method in methods for task_cls in TASK_CLASSES]
|
|
|
|
|
|
def run_tasks(tasks: list[Task]) -> dict[str, dict[str, Any]]:
|
|
results: dict[str, dict[str, Any]] = {}
|
|
for task in tqdm(tasks, desc="Checking capabilities", unit="check"):
|
|
method = task.method
|
|
entry = results.setdefault(
|
|
method.name,
|
|
{
|
|
"config_class": method.config_cls.__name__,
|
|
"model_class": method.model_cls.__name__ if method.model_cls is not None else None,
|
|
"features": {},
|
|
},
|
|
)
|
|
entry["features"][task.feature] = task.run().to_json()
|
|
return results
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> None:
|
|
parser = argparse.ArgumentParser(
|
|
description="Generate a JSON capability matrix of all PEFT methods.",
|
|
epilog="Example: python scripts/generate_method_capabilities.py --methods lora ia3 --output capabilities.json",
|
|
)
|
|
parser.add_argument(
|
|
"--methods",
|
|
"-m",
|
|
nargs="+",
|
|
default=None,
|
|
metavar="METHOD",
|
|
help="restrict the analysis to these PEFT methods (case-insensitive, e.g. 'lora ia3'); default: all",
|
|
)
|
|
parser.add_argument(
|
|
"--output",
|
|
"-o",
|
|
type=Path,
|
|
default=Path("method_capabilities.json"),
|
|
help="output JSON file (default: %(default)s)",
|
|
)
|
|
parser.add_argument("--dry-run", action="store_true", help="only list the checks that would run")
|
|
parser.add_argument(
|
|
"--docs-dir",
|
|
type=Path,
|
|
default=PaperLinkTask.docs_dir,
|
|
help="directory containing the package_reference docs pages, used for the paper link check",
|
|
)
|
|
args = parser.parse_args(argv)
|
|
PaperLinkTask.docs_dir = args.docs_dir
|
|
logging.basicConfig(level=logging.INFO, format="%(message)s") # logs to stderr
|
|
|
|
methods = collect_methods(args.methods)
|
|
probe = ProbeContext()
|
|
tasks = collect_tasks(methods, probe)
|
|
|
|
if args.dry_run:
|
|
for method in methods:
|
|
print(f"{method.name}: {', '.join(task_cls.feature for task_cls in TASK_CLASSES)}")
|
|
print(f"\n{len(methods)} methods x {len(TASK_CLASSES)} checks = {len(tasks)} tasks")
|
|
return
|
|
|
|
output = {
|
|
"schema_version": 1,
|
|
"peft_version": peft.__version__,
|
|
"methods": run_tasks(tasks),
|
|
}
|
|
# The results deliberately go to a file, not to stdout: probing can trigger subprocesses whose output is written
|
|
# directly to stdout (e.g. BOFT compiling its CUDA extension via ninja), which would interleave with the JSON.
|
|
args.output.write_text(json.dumps(output, indent=2) + "\n")
|
|
logger.info(f"Wrote capabilities of {len(methods)} methods to {args.output}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|