| 1 | # SPDX-License-Identifier: Apache-2.0 |
| 2 | # SPDX-FileCopyrightText: Copyright contributors to the vLLM project |
| 3 | """This script contains: |
| 4 | 1. test multi loras service with tp >= 2 |
| 5 | 2. test multi loras request |
| 6 | """ |
| 7 | |
| 8 | import os |
| 9 | |
| 10 | import pytest |
| 11 | |
| 12 | from tests.utils import multi_gpu_test |
| 13 | from vllm import LLM, SamplingParams |
| 14 | from vllm.exceptions import VLLMValidationError |
| 15 | from vllm.lora.request import LoRARequest |
| 16 | |
| 17 | MODEL_PATH = "Qwen/Qwen3-0.6B" |
| 18 | LORA_NAME_PATH_MAP = { |
| 19 | "Alice": "charent/self_cognition_Alice", |
| 20 | "Bob": "charent/self_cognition_Bob", |
| 21 | "Cat": "charent/self_cognition_Bob", # same as Bob |
| 22 | } |
| 23 | |
| 24 | LORA_NAME_ID_MAP = {} |
| 25 | INCREASE_LORA_ID = 0 |
| 26 | LORA_RANK = 8 |
| 27 | |
| 28 | LORA_TEST_PROMPTS = ["What is GitHub?", "Hi, tell me about you"] |
| 29 | LORA_TEST_EXPECTED = [ |
| 30 | "GitHub is an open-source platform that provides a way to manage and develop software projects. It allows developers to store and manage code, collaborate on projects, and automate tasks.", # noqa: E501 |
| 31 | "I am Alice, an AI assistant developed by GitHub/Charent.", |
| 32 | ] |
| 33 | |
| 34 | |
| 35 | def format_chatml_messages( |
| 36 | prompt: str, system_prompt: str = "You are a helpful assistant." |
| 37 | ) -> list[dict[str, str]]: |
| 38 | return [ |
| 39 | {"role": "system", "content": system_prompt}, |
| 40 | {"role": "user", "content": prompt}, |
| 41 | ] |
| 42 | |
| 43 | |
| 44 | @pytest.fixture(autouse=True) |
| 45 | def set_mrv2_env(): |
| 46 | original = os.environ.get("VLLM_USE_V2_MODEL_RUNNER", "0") |
| 47 | os.environ["VLLM_USE_V2_MODEL_RUNNER"] = "1" |
| 48 | yield |
| 49 | |
| 50 | if original is None: |
| 51 | os.environ.pop("VLLM_USE_V2_MODEL_RUNNER", None) |
| 52 | else: |
| 53 | os.environ["VLLM_USE_V2_MODEL_RUNNER"] = original |
| 54 | |
| 55 | |
| 56 | def make_add_lora_request(name: str, path: str): |
| 57 | global INCREASE_LORA_ID, LORA_NAME_ID_MAP |
| 58 | |
| 59 | INCREASE_LORA_ID += 1 |
| 60 | LORA_NAME_ID_MAP[name] = INCREASE_LORA_ID |
| 61 | |
| 62 | return LoRARequest( |
| 63 | lora_name=name, |
| 64 | lora_int_id=INCREASE_LORA_ID, |
| 65 | lora_path=path, |
| 66 | ) |
| 67 | |
| 68 | |
| 69 | @multi_gpu_test(num_gpus=2) |
| 70 | def test_multi_loras_with_tp_sync(): |
| 71 | llm = LLM( |
| 72 | model=MODEL_PATH, |
| 73 | enable_lora=True, |
| 74 | max_loras=2, # ensure max_loras < max_cpu_loras |
| 75 | max_lora_rank=LORA_RANK, |
| 76 | max_model_len=512, |
| 77 | gpu_memory_utilization=0.5, |
| 78 | tensor_parallel_size=2, # ensure tp >= 2 |
| 79 | max_cpu_loras=4, # ensure max_cpu_loras >= 2 |
| 80 | ) |
| 81 | |
| 82 | def run_check_lora(fn, args, expected: list): |
| 83 | fn(args) |
| 84 | assert set(llm.llm_engine.list_loras()) == set(expected) |
| 85 | |
| 86 | # simulate add loras with CLI args |
| 87 | # likes: `--lora-modules Alice=/path/to/Alice Bob=/path/to/Bob` |
| 88 | run_check_lora( |
| 89 | llm.llm_engine.add_lora, |
| 90 | make_add_lora_request("Alice", LORA_NAME_PATH_MAP["Alice"]), |
| 91 | [1], |
| 92 | ) |
| 93 | run_check_lora( |
| 94 | llm.llm_engine.add_lora, |
| 95 | make_add_lora_request("Bob", LORA_NAME_PATH_MAP["Bob"]), |
| 96 | [1, 2], |
| 97 | ) |
| 98 | run_check_lora( |
| 99 | llm.llm_engine.add_lora, |
| 100 | make_add_lora_request("Cat", LORA_NAME_PATH_MAP["Cat"]), |
| 101 | [1, 2, 3], |
| 102 | ) |
| 103 | |
| 104 | # set temperature = 0 for greedy search |
| 105 | sampling_params = SamplingParams(temperature=0, max_tokens=64) |
| 106 | |
| 107 | def call_llm_get_outputs(prompt: str, lora_name: str): |
| 108 | lora_request = LoRARequest( |
| 109 | lora_name=lora_name, |
| 110 | lora_int_id=LORA_NAME_ID_MAP[lora_name], |
| 111 | lora_path=LORA_NAME_PATH_MAP[lora_name], |
| 112 | ) |
| 113 | messages = format_chatml_messages(prompt) |
| 114 | outputs = llm.chat( |
| 115 | [messages], |
| 116 | sampling_params, |
| 117 | chat_template_kwargs={ |
| 118 | "enable_thinking": False |
| 119 | }, # for those loras, ensure enable_thinking=False |
| 120 | lora_request=lora_request, |
| 121 | use_tqdm=False, |
| 122 | ) |
| 123 | output_text = outputs[0].outputs[0].text |
| 124 | return output_text |
| 125 | |
| 126 | def reload_lora(name: str): |
| 127 | """Reload a lora to simulate the case: |
| 128 | setting `VLLM_ALLOW_RUNTIME_LORA_UPDATING=true` |
| 129 | for dynamic lora loading and unloading |
| 130 | """ |
| 131 | remove_lora_response = llm.llm_engine.remove_lora( |
| 132 | lora_id=LORA_NAME_ID_MAP[name] |
| 133 | ) |
| 134 | |
| 135 | add_lora_response = llm.llm_engine.add_lora( |
| 136 | make_add_lora_request(name, LORA_NAME_PATH_MAP[name]) |
| 137 | ) |
| 138 | |
| 139 | print(f"{remove_lora_response=}, {add_lora_response=}") |
| 140 | |
| 141 | def check_outputs(outputs: str, expected: str): |
| 142 | print(f"{prompt=}.\n{expected_output=}\n{output_text=}") |
| 143 | print("\n----------------------------\n") |
| 144 | assert outputs == expected |
| 145 | |
| 146 | for prompt, expected_output in zip(LORA_TEST_PROMPTS, LORA_TEST_EXPECTED): |
| 147 | output_text = call_llm_get_outputs(prompt, "Alice") |
| 148 | check_outputs(output_text, expected_output) |
| 149 | |
| 150 | # call Bob, ignore what it is output |
| 151 | call_llm_get_outputs(prompt, "Bob") |
| 152 | print("After call Bob:") |
| 153 | |
| 154 | # call Alice |
| 155 | output_text = call_llm_get_outputs(prompt, "Alice") |
| 156 | check_outputs(output_text, expected_output) |
| 157 | |
| 158 | # reload Bob Lora |
| 159 | reload_lora("Bob") |
| 160 | print("After reload Bob:") |
| 161 | |
| 162 | # call Alice |
| 163 | output_text = call_llm_get_outputs(prompt, "Alice") |
| 164 | check_outputs(output_text, expected_output) |
| 165 | |
| 166 | # reload Alice Lora |
| 167 | reload_lora("Alice") |
| 168 | print("After reload Alice:") |
| 169 | |
| 170 | output_text = call_llm_get_outputs(prompt, "Alice") |
| 171 | check_outputs(output_text, expected_output) |
| 172 | |
| 173 | |
| 174 | def test_multiple_lora_requests(): |
| 175 | llm = LLM( |
| 176 | model=MODEL_PATH, |
| 177 | enable_lora=True, |
| 178 | max_loras=4, |
| 179 | max_lora_rank=LORA_RANK, |
| 180 | max_model_len=512, |
| 181 | gpu_memory_utilization=0.5, |
| 182 | ) |
| 183 | PROMPTS = ["Hello, my name is"] * 2 |
| 184 | LORA_NAME = "Alice" |
| 185 | lora_request = [ |
| 186 | LoRARequest(LORA_NAME + str(idx), idx + 1, LORA_NAME_PATH_MAP[LORA_NAME]) |
| 187 | for idx in range(len(PROMPTS)) |
| 188 | ] |
| 189 | # Multiple SamplingParams should be matched with each prompt |
| 190 | outputs = llm.generate(PROMPTS, lora_request=lora_request) |
| 191 | assert len(PROMPTS) == len(outputs) |
| 192 | |
| 193 | # Exception raised, if the size of params does not match the size of prompts |
| 194 | with pytest.raises(VLLMValidationError): |
| 195 | outputs = llm.generate(PROMPTS, lora_request=lora_request[:1]) |
| 196 | |
| 197 | # Single LoRARequest should be applied to every prompt |
| 198 | single_lora_request = lora_request[0] |
| 199 | outputs = llm.generate(PROMPTS, lora_request=single_lora_request) |
| 200 | assert len(PROMPTS) == len(outputs) |
| 201 | |
| 202 | |
| 203 | def test_load_inplace_offline_reload( |
| 204 | qwen3_meowing_lora_files: str, qwen3_woofing_lora_files: str |
| 205 | ) -> None: |
| 206 | """Test that load_inplace=True allows reloading LoRA adapters with the same ID |
| 207 | in offline mode (using LLM class directly). |
| 208 | """ |
| 209 | llm = LLM( |
| 210 | model=MODEL_PATH, |
| 211 | enable_lora=True, |
| 212 | max_loras=2, |
| 213 | max_lora_rank=LORA_RANK, |
| 214 | max_model_len=512, |
| 215 | gpu_memory_utilization=0.5, |
| 216 | ) |
| 217 | adapter_id = 1 |
| 218 | messages = format_chatml_messages( |
| 219 | "Make your favorite animal noise.", |
| 220 | system_prompt="Follow the instructions to make animal noises", |
| 221 | ) |
| 222 | sampling_params = SamplingParams(temperature=0, max_tokens=10) |
| 223 | |
| 224 | # Load meowing LoRA with load_inplace=True |
| 225 | meowing_request = LoRARequest( |
| 226 | lora_name="test-adapter", |
| 227 | lora_int_id=adapter_id, |
| 228 | lora_path=qwen3_meowing_lora_files, |
| 229 | ) |
| 230 | |
| 231 | outputs = llm.chat([messages], sampling_params, lora_request=meowing_request) |
| 232 | first_output = outputs[0].outputs[0].text.strip() |
| 233 | assert "Meow Meow Meow" in first_output, ( |
| 234 | f"Expected meowing output, got: {first_output}" |
| 235 | ) |
| 236 | |
| 237 | # Reload with woofing LoRA (same ID, different weights, load_inplace=True) |
| 238 | woofing_request = LoRARequest( |
| 239 | lora_name="test-adapter-woof", |
| 240 | lora_int_id=adapter_id, # Same ID |
| 241 | lora_path=qwen3_woofing_lora_files, # Different weights |
| 242 | load_inplace=True, # Force reload |
| 243 | ) |
| 244 | |
| 245 | outputs = llm.chat([messages], sampling_params, lora_request=woofing_request) |
| 246 | second_output = outputs[0].outputs[0].text.strip() |
| 247 | assert "Woof Woof Woof" in second_output, ( |
| 248 | f"Expected woofing output, got: {second_output}" |
| 249 | ) |
| 250 | |
| 251 | |
| 252 | def test_load_inplace_false_no_reload( |
| 253 | qwen3_meowing_lora_files: str, qwen3_woofing_lora_files: str |
| 254 | ) -> None: |
| 255 | """Test that load_inplace=False prevents reloading when an adapter |
| 256 | with the same ID already exists. |
| 257 | """ |
| 258 | llm = LLM( |
| 259 | model=MODEL_PATH, |
| 260 | enable_lora=True, |
| 261 | max_loras=2, |
| 262 | max_lora_rank=LORA_RANK, |
| 263 | max_model_len=512, |
| 264 | gpu_memory_utilization=0.5, |
| 265 | ) |
| 266 | adapter_id = 2 |
| 267 | messages = format_chatml_messages( |
| 268 | "Make your favorite animal noise.", |
| 269 | system_prompt="Follow the instructions to make animal noises", |
| 270 | ) |
| 271 | sampling_params = SamplingParams(temperature=0, max_tokens=10) |
| 272 | |
| 273 | # Load meowing LoRA first with load_inplace=True |
| 274 | meowing_request_initial = LoRARequest( |
| 275 | lora_name="test-adapter-2", |
| 276 | lora_int_id=adapter_id, |
| 277 | lora_path=qwen3_meowing_lora_files, |
| 278 | ) |
| 279 | |
| 280 | outputs = llm.chat( |
| 281 | [messages], sampling_params, lora_request=meowing_request_initial |
| 282 | ) |
| 283 | first_output = outputs[0].outputs[0].text.strip() |
| 284 | assert "Meow Meow Meow" in first_output, ( |
| 285 | f"Expected meowing output, got: {first_output}" |
| 286 | ) |
| 287 | |
| 288 | # Try to load woofing LoRA with same ID but load_inplace=False |
| 289 | # This should NOT reload (adapter 2 already exists) |
| 290 | woofing_request_no_reload = LoRARequest( |
| 291 | lora_name="test-adapter-2-woof", |
| 292 | lora_int_id=adapter_id, # Same ID |
| 293 | lora_path=qwen3_woofing_lora_files, |
| 294 | ) |
| 295 | |
| 296 | outputs = llm.chat( |
| 297 | [messages], sampling_params, lora_request=woofing_request_no_reload |
| 298 | ) |
| 299 | second_output = outputs[0].outputs[0].text.strip() |
| 300 | # Should still get meowing output because it didn't reload |
| 301 | assert "Meow Meow Meow" in second_output, ( |
| 302 | f"Expected meowing output (no reload), got: {second_output}" |
| 303 | ) |