main
py 346 lines 13.9 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4 import os
5 from dataclasses import dataclass
6
7 import safetensors
8 import torch
9
10 from vllm.logger import init_logger
11 from vllm.lora.lora_weights import LoRAFullModuleWeights, LoRALayerWeights
12 from vllm.lora.peft_helper import PEFTHelper
13 from vllm.lora.utils import (
14 get_lora_id,
15 is_base_embedding_weights,
16 parse_fine_tuned_lora_name,
17 )
18 from vllm.model_executor.model_loader.tensorizer import TensorizerConfig
19 from vllm.model_executor.models.utils import WeightsMapper
20 from vllm.utils.torch_utils import PIN_MEMORY
21
22 logger = init_logger(__name__)
23
24
25 @dataclass(frozen=True)
26 class MoEEPLoadSpec:
27 """Per-expert-parallel slicing metadata for one FusedMoEFactory LoRA module.
28
29 Threaded into the LoRA loader so per-expert weights from EP ranks
30 other than this one can be skipped before they ever hit CPU memory.
31 """
32
33 ep_rank: int
34 local_num_experts: int
35 global_num_experts: int
36
37
38 _EXPERTS_SEPARATOR = ".experts."
39
40
41 def _is_remote_expert_key(raw_name: str, spec: "MoEEPLoadSpec") -> bool:
42 """Decide whether a checkpoint key belongs to a non-local expert."""
43 pos = raw_name.find(_EXPERTS_SEPARATOR)
44 if pos < 0:
45 return False
46 idx_start = pos + len(_EXPERTS_SEPARATOR)
47 idx_end = raw_name.find(".", idx_start)
48 if idx_end < 0:
49 return False
50 idx_str = raw_name[idx_start:idx_end]
51 if not idx_str.isdigit():
52 return False
53 expert_idx = int(idx_str)
54 local_start = spec.ep_rank * spec.local_num_experts
55 return not (local_start <= expert_idx < local_start + spec.local_num_experts)
56
57
58 class LoRAModel:
59 """A LoRA fine-tuned model."""
60
61 def __init__(
62 self,
63 lora_model_id: int,
64 rank: int,
65 loras: dict[str, LoRALayerWeights],
66 is_3d_lora_weight: bool = False,
67 *,
68 modules_to_save: dict[str, LoRAFullModuleWeights] | None = None,
69 ) -> None:
70 """Args:
71 lora_model_id: The integer id for the lora model.
72 rank: lora rank.
73 loras: module name -> weights for lora-replaced layers.
74 is_3d_lora_weight: Whether the on-disk MoE adapter is in the 3D
75 fused (gate_up_proj / down_proj) layout. Propagated from the
76 originating LoRARequest. Only consulted by the LoRA model
77 manager when enable_mixed_moe_lora_format is on.
78
79 """
80 self.id = lora_model_id
81
82 assert lora_model_id > 0, (
83 f"a valid lora id should be greater than 0, got {self.id}"
84 )
85 self.rank = rank
86 self.loras: dict[str, LoRALayerWeights] = loras
87 self.modules_to_save = modules_to_save or {}
88 self.is_3d_lora_weight = is_3d_lora_weight
89
90 def clone(self, lora_model_id: int) -> "LoRAModel":
91 """Return a copy of the object with different ids.
92
93 Will share the underlying tensors."""
94 return self.__class__(
95 lora_model_id,
96 rank=self.rank,
97 loras=self.loras.copy(),
98 is_3d_lora_weight=self.is_3d_lora_weight,
99 modules_to_save=self.modules_to_save.copy(),
100 )
101
102 def get_lora(self, module_name: str) -> LoRALayerWeights | None:
103 """Get LoRA for a given module by name."""
104 return self.loras.get(module_name, None)
105
106 def get_module_to_save(self, module_name: str) -> LoRAFullModuleWeights | None:
107 return self.modules_to_save.get(module_name)
108
109 def check_lora_name(self, lora_name: str) -> bool:
110 return lora_name in self.loras
111
112 @staticmethod
113 def _should_skip_module(module_name: str, skip_prefixes: list[str]) -> bool:
114 """Check if a module should be skipped based on skip prefixes."""
115 for prefix in skip_prefixes:
116 if f".{prefix}" in module_name or module_name.startswith(prefix):
117 return True
118 return False
119
120 @classmethod
121 def from_lora_tensors(
122 cls,
123 lora_model_id: int,
124 tensors: dict[str, torch.Tensor],
125 peft_helper: PEFTHelper,
126 device: str = "cuda",
127 dtype: torch.dtype | None = None,
128 model_vocab_size: int | None = None,
129 weights_mapper: WeightsMapper | None = None,
130 skip_prefixes: list[str] | None = None,
131 ) -> "LoRAModel":
132 """Create a LoRAModel from a dictionary of tensors."""
133 pin_memory = str(device) == "cpu" and PIN_MEMORY
134
135 modules_to_save_names = peft_helper.modules_to_save or []
136 full_parameters: dict[str, dict[str, torch.Tensor]] = {}
137 loras: dict[str, LoRALayerWeights] = {}
138 for tensor_name, tensor in tensors.items():
139 if is_base_embedding_weights(tensor_name):
140 continue
141 # Skip modules based on model-defined prefixes (e.g., MTP layers)
142 if skip_prefixes and cls._should_skip_module(tensor_name, skip_prefixes):
143 continue
144 module_name, is_lora_a = parse_fine_tuned_lora_name(
145 tensor_name, weights_mapper
146 )
147 if module_name in modules_to_save_names:
148 full_parameters.setdefault(module_name, {})[
149 tensor_name.split(".")[-1]
150 ] = tensor.to(device=device)
151 continue
152
153 if module_name not in loras:
154 loras[module_name] = LoRALayerWeights.from_config(
155 module_name, peft_helper
156 )
157
158 if is_lora_a:
159 if (
160 "lora_embedding_A" in tensor_name
161 and model_vocab_size is not None
162 and model_vocab_size != tensor.shape[1]
163 ):
164 raise RuntimeError(
165 f"The embedding LoRA size({tensor.shape[1]}) must be consistent"
166 f" with the base model's vocabulary size({model_vocab_size})."
167 )
168 loras[module_name].lora_a = tensor.to(device=device, dtype=dtype)
169 if pin_memory:
170 loras[module_name].lora_a = loras[module_name].lora_a.pin_memory()
171 else:
172 loras[module_name].lora_b = tensor.to(device=device, dtype=dtype)
173
174 if pin_memory:
175 loras[module_name].lora_b = loras[module_name].lora_b.pin_memory()
176
177 modules_to_save = {}
178 for module_name, parameters in full_parameters.items():
179 weight = parameters.get("weight")
180 if weight is None:
181 raise ValueError(f"Full module {module_name!r} is missing weight.")
182 modules_to_save[module_name] = LoRAFullModuleWeights(
183 module_name=module_name,
184 weight=weight,
185 bias=parameters.get("bias"),
186 )
187 if len(modules_to_save) > 1:
188 raise ValueError(
189 "Only one full classification module is supported per "
190 f"adapter, received {sorted(modules_to_save)}."
191 )
192
193 return cls(lora_model_id, peft_helper.r, loras, modules_to_save=modules_to_save)
194
195 @classmethod
196 def from_local_checkpoint(
197 cls,
198 lora_dir: str,
199 expected_lora_modules: set[str],
200 peft_helper: PEFTHelper,
201 *,
202 lora_model_id: int | None = None,
203 device: str = "cuda",
204 dtype: torch.dtype | None = None,
205 model_vocab_size: int | None = None,
206 weights_mapper: WeightsMapper | None = None,
207 tensorizer_config_dict: dict | None = None,
208 skip_prefixes: list[str] | None = None,
209 moe_ep_spec: MoEEPLoadSpec | None = None,
210 ) -> "LoRAModel":
211 """Create a LoRAModel from a local checkpoint.
212
213 Args:
214 lora_dir: The local path that has lora data.
215 expected_lora_modules: Name of modules that are expected to be
216 replaced by lora.
217 peft_helper: Loaded lora configuration information.
218 lora_model_id: LoRA model id. If not given, automatically set by
219 a global counter.
220 device: Device where the lora model is loaded.
221 dtype: dtype of the lora model weights.
222 model_vocab_size: Vocab size of the base model, used to size the
223 embedding deltas.
224 weights_mapper: Optional mapper rewriting checkpoint weight names
225 to vLLM names.
226 tensorizer_config_dict: Optional tensorizer config used to load
227 the checkpoint via tensorizer instead of from disk.
228 skip_prefixes: List of module name prefixes to skip during loading.
229 Models can define this to skip modules not used in inference
230 (e.g., MTP layers). Format: ["mtp."]
231 moe_ep_spec: When 2D FusedMoEFactory LoRA modules are present with
232 expert parallelism enabled, the (ep_rank, local, global)
233 slicing metadata shared across all MoE layers. Non-local
234 expert weights are skipped at read time instead of being
235 loaded and discarded later.
236
237 Returns:
238 Loaded LoRA Model.
239
240 """
241 lora_tensor_path = os.path.join(lora_dir, "adapter_model.safetensors")
242 lora_bin_file_path = os.path.join(lora_dir, "adapter_model.bin")
243 lora_pt_file_path = os.path.join(lora_dir, "adapter_model.pt")
244
245 tensors: dict[str, torch.Tensor] = {}
246 unexpected_modules: list[list[str] | str] = []
247
248 def check_unexpected_modules(modules: dict):
249 for lora_module in modules.keys(): # noqa
250 if is_base_embedding_weights(lora_module):
251 continue
252 # Handle PEFT file format where experts.base_layer is the
253 # gate_up_proj and experts is the down_proj
254 if "base_layer" in lora_module:
255 continue
256 # Skip modules based on model-defined prefixes
257 if skip_prefixes and cls._should_skip_module(
258 lora_module, skip_prefixes
259 ):
260 continue
261 module_name, _ = parse_fine_tuned_lora_name(lora_module, weights_mapper)
262 base_name = module_name.rsplit(".", 1)[-1]
263 # Case for expert lora weights
264 if ".experts" in module_name:
265 expert_idx = module_name.find(".experts")
266 expert_suffix = module_name[expert_idx + 1 :]
267 if expert_suffix not in expected_lora_modules:
268 unexpected_modules.append(module_name)
269
270 elif base_name not in expected_lora_modules and base_name not in (
271 peft_helper.modules_to_save or ()
272 ):
273 unexpected_modules.append(module_name)
274
275 if unexpected_modules:
276 raise ValueError(
277 f"While loading {lora_dir}, expected"
278 f" target modules in {expected_lora_modules}"
279 f" but received {unexpected_modules}."
280 f" Please verify that the loaded LoRA module is correct"
281 )
282
283 if tensorizer_config_dict:
284 from tensorizer import TensorDeserializer
285
286 tensorizer_config = TensorizerConfig(**tensorizer_config_dict)
287 tensorizer_dir = tensorizer_config.tensorizer_dir
288 if tensorizer_dir is None:
289 raise ValueError("tensorizer_dir must be set in tensorizer config.")
290 lora_tensor_path = os.path.join(tensorizer_dir, "adapter_model.tensors")
291 tensorizer_args = tensorizer_config._construct_tensorizer_args()
292 tensors = TensorDeserializer(
293 lora_tensor_path,
294 dtype=tensorizer_config.dtype,
295 device=device,
296 **tensorizer_args.deserialization_kwargs,
297 )
298 check_unexpected_modules(tensors)
299
300 elif os.path.isfile(lora_tensor_path):
301 # Find unexpected modules.
302 # Use safetensor key as a source of truth to find expected modules.
303 # in peft if you have target_modules A, B, C and C does not exist
304 # in the model it won’t error and model will be trained with A, B
305 # loraified. C won’t exist in the safetensor but it will exist in
306 # the target_modules of the adapter_config.json.
307 unexpected_modules = []
308 with safetensors.safe_open(lora_tensor_path, framework="pt") as f: # type: ignore
309 # Load tensors if there are only expected modules.
310 check_unexpected_modules(f)
311 for module in f.keys(): # noqa
312 if moe_ep_spec is not None and _is_remote_expert_key(
313 module, moe_ep_spec
314 ):
315 continue
316 tensors[module] = f.get_tensor(module)
317 elif os.path.isfile(lora_bin_file_path) or os.path.isfile(lora_pt_file_path):
318 lora_file_path = (
319 lora_bin_file_path
320 if os.path.isfile(lora_bin_file_path)
321 else lora_pt_file_path
322 )
323 tensors = torch.load(lora_file_path, map_location=device, weights_only=True)
324 check_unexpected_modules(tensors)
325 if moe_ep_spec is not None:
326 # `.bin`/`.pt` adapters can't be lazy-loaded, but pruning
327 # the dict here still frees the non-local expert tensors
328 # before the dtype cast / pin_memory work that follows.
329 tensors = {
330 k: v
331 for k, v in tensors.items()
332 if not _is_remote_expert_key(k, moe_ep_spec)
333 }
334 else:
335 raise ValueError(f"{lora_dir} doesn't contain tensors")
336
337 return cls.from_lora_tensors(
338 lora_model_id=get_lora_id() if lora_model_id is None else lora_model_id,
339 tensors=tensors,
340 peft_helper=peft_helper,
341 device=device,
342 dtype=dtype,
343 model_vocab_size=model_vocab_size,
344 weights_mapper=weights_mapper,
345 skip_prefixes=skip_prefixes,
346 )