| 1 | # SPDX-License-Identifier: Apache-2.0 |
| 2 | # SPDX-FileCopyrightText: Copyright contributors to the vLLM project |
| 3 | |
| 4 | # Adapted from: https://github.com/huggingface/peft/blob/main/src/peft/tuners/lora/config.py |
| 5 | |
| 6 | import json |
| 7 | import math |
| 8 | import os |
| 9 | from dataclasses import MISSING, dataclass, field, fields |
| 10 | from typing import Literal |
| 11 | |
| 12 | from vllm.config.lora import LoRAConfig |
| 13 | from vllm.logger import init_logger |
| 14 | from vllm.model_executor.model_loader.tensorizer import TensorizerConfig |
| 15 | |
| 16 | logger = init_logger(__name__) |
| 17 | |
| 18 | |
| 19 | @dataclass |
| 20 | class PEFTHelper: |
| 21 | """A helper class for PEFT configurations, specifically designed for LoRA. |
| 22 | This class handles configuration validation, compatibility checks for |
| 23 | various LoRA implementations. |
| 24 | """ |
| 25 | |
| 26 | # Required fields |
| 27 | r: int |
| 28 | lora_alpha: int |
| 29 | target_modules: list[str] | str |
| 30 | |
| 31 | bias: Literal["none"] = field(default="none") |
| 32 | modules_to_save: list[str] | None = field(default=None) |
| 33 | # True to use Rank-Stabilized LoRA (rsLoRA, see: https://arxiv.org/abs/2312.03732) |
| 34 | use_rslora: bool = field(default=False) |
| 35 | # True to use Weight-Decomposed Low-Rank Adaptation (DoRA, see: https://arxiv.org/abs/2402.09353) |
| 36 | use_dora: bool = field(default=False) |
| 37 | # Extra vllm field, start with 'vllm_' to avoid conflict |
| 38 | vllm_lora_scaling_factor: float = field(default=1.0) |
| 39 | vllm_max_position_embeddings: int | None = field(default=False) |
| 40 | |
| 41 | def _validate_features(self) -> list[str]: |
| 42 | """Check if there are any unsupported LoRA features.""" |
| 43 | error_msg = [] |
| 44 | if self.modules_to_save: |
| 45 | unsupported_modules = [ |
| 46 | m for m in self.modules_to_save if m not in ["classifier", "score"] |
| 47 | ] |
| 48 | if unsupported_modules: |
| 49 | error_msg.append( |
| 50 | "vLLM only supports modules_to_save being either None " |
| 51 | 'or ["classifier", "score"] for classification models. ' |
| 52 | f"Unsupported modules_to_save: {unsupported_modules}" |
| 53 | ) |
| 54 | if self.use_dora: |
| 55 | error_msg.append("vLLM does not yet support DoRA.") |
| 56 | return error_msg |
| 57 | |
| 58 | def __post_init__(self): |
| 59 | if self.r <= 0: |
| 60 | raise ValueError(f"LoRA rank `r` must be a positive integer, got {self.r}.") |
| 61 | if self.use_rslora: |
| 62 | logger.info_once("Loading LoRA weights trained with rsLoRA.") |
| 63 | self.vllm_lora_scaling_factor = self.lora_alpha / math.sqrt(self.r) |
| 64 | else: |
| 65 | self.vllm_lora_scaling_factor = self.lora_alpha / self.r |
| 66 | |
| 67 | @classmethod |
| 68 | def from_dict(cls, config_dict: dict) -> "PEFTHelper": |
| 69 | # Get all field information from the class |
| 70 | class_fields = {f.name: f for f in fields(cls)} |
| 71 | # Check for required fields |
| 72 | required_fields = { |
| 73 | name |
| 74 | for name, f in class_fields.items() |
| 75 | if f.default is MISSING and f.default_factory is MISSING |
| 76 | } |
| 77 | |
| 78 | # Identify any missing required fields |
| 79 | missing_fields = required_fields - set(config_dict.keys()) |
| 80 | if missing_fields: |
| 81 | raise ValueError(f"Missing required configuration fields: {missing_fields}") |
| 82 | |
| 83 | # Filter out fields that aren't defined in the class |
| 84 | filtered_dict = {k: v for k, v in config_dict.items() if k in class_fields} |
| 85 | return cls(**filtered_dict) |
| 86 | |
| 87 | @classmethod |
| 88 | def from_local_dir( |
| 89 | cls, |
| 90 | lora_path: str, |
| 91 | max_position_embeddings: int | None, |
| 92 | tensorizer_config_dict: dict | None = None, |
| 93 | ) -> "PEFTHelper": |
| 94 | lora_config_path = os.path.join(lora_path, "adapter_config.json") |
| 95 | |
| 96 | if tensorizer_config_dict: |
| 97 | tensorizer_config = TensorizerConfig(**tensorizer_config_dict) |
| 98 | tensorizer_args = tensorizer_config._construct_tensorizer_args() |
| 99 | from tensorizer.stream_io import open_stream |
| 100 | |
| 101 | tensorizer_dir = tensorizer_config.tensorizer_dir |
| 102 | if tensorizer_dir is None: |
| 103 | raise ValueError("tensorizer_dir must be set in tensorizer config.") |
| 104 | |
| 105 | lora_config_path = os.path.join(tensorizer_dir, "adapter_config.json") |
| 106 | with open_stream( |
| 107 | lora_config_path, mode="rb", **tensorizer_args.stream_kwargs |
| 108 | ) as f: |
| 109 | config = json.load(f) |
| 110 | |
| 111 | logger.info( |
| 112 | "Successfully deserialized LoRA config from %s", |
| 113 | tensorizer_config.tensorizer_dir, |
| 114 | ) |
| 115 | |
| 116 | else: |
| 117 | with open(lora_config_path) as f: |
| 118 | config = json.load(f) |
| 119 | |
| 120 | config["vllm_max_position_embeddings"] = max_position_embeddings |
| 121 | return cls.from_dict(config) |
| 122 | |
| 123 | def validate_legal(self, lora_config: LoRAConfig) -> None: |
| 124 | """Validates the LoRA configuration settings against application |
| 125 | constraints and requirements. |
| 126 | """ |
| 127 | error_msg = self._validate_features() |
| 128 | if self.r > lora_config.max_lora_rank: |
| 129 | error_msg.append( |
| 130 | f"LoRA rank {self.r} is greater than max_lora_rank" |
| 131 | f" {lora_config.max_lora_rank}." |
| 132 | ) |
| 133 | if self.bias != "none": |
| 134 | error_msg.append("Adapter bias is not supported.") |
| 135 | if error_msg: |
| 136 | raise ValueError(f"{' '.join(error_msg)}") |