@tduncan / Nvidia-Optimization-Stack-VLLM Mirror · read-only / commits / 321331b8ae
Fork 0

A high-throughput and memory-efficient inference and serving engine for LLMs

Mirrored from https://github.com/tduncan-lum/Nvidia-Optimization-Stack---VLLM.git

Synced Nov 15, 08:30 UTC

[Core] Add Lora Support to Beam Search (#18346)

Signed-off-by: Alex-Brooks <Alex.Brooks@ibm.com>

Alex Brooks committed May 28, 2025 at 09:58 UTC 321331b8ae41f13e519a63f99a0c427dc3907126
7 files changed +150 -16
tests/entrypoints/openai/test_lora_adapters.py
+34
@@ -313,3 +313,37 @@ async def test_loading_invalid_adapters_does_not_break_others(
313 prompt=["Hello there", "Foo bar bazz buzz"],
314 max_tokens=5,
315 )
316 +
317 +
318 +@pytest.mark.asyncio
319 +async def test_beam_search_with_lora_adapters(
320 + client: openai.AsyncOpenAI,
321 + tmp_path,
322 + zephyr_lora_files,
323 +):
324 + """Validate that async beam search can be used with lora."""
325 +
326 + async def load_and_run_adapter(adapter_name: str):
327 + await client.post("load_lora_adapter",
328 + cast_to=str,
329 + body={
330 + "lora_name": adapter_name,
331 + "lora_path": str(zephyr_lora_files)
332 + })
333 + for _ in range(3):
334 + await client.completions.create(
335 + model=adapter_name,
336 + prompt=["Hello there", "Foo bar bazz buzz"],
337 + max_tokens=5,
338 + extra_body=dict(use_beam_search=True),
339 + )
340 +
341 + lora_tasks = []
342 + for i in range(3):
343 + lora_tasks.append(
344 + asyncio.create_task(load_and_run_adapter(f"adapter_{i}")))
345 +
346 + results, _ = await asyncio.wait(lora_tasks)
347 +
348 + for r in results:
349 + assert not isinstance(r, Exception), f"Got exception {r}"
tests/lora/test_qwen2vl.py
+60 -2
@@ -10,6 +10,7 @@ import vllm
10 from vllm.assets.image import ImageAsset
11 from vllm.lora.request import LoRARequest
12 from vllm.platforms import current_platform
13 +from vllm.sampling_params import BeamSearchParams
14
15
16 @pytest.fixture(autouse=not current_platform.is_cpu())
@@ -69,7 +70,7 @@ class Qwen2VLTester:
70 expected_outputs: list[str],
71 lora_id: Optional[int] = None,
72 temperature: float = 0,
72 - max_tokens: int = 5) -> list[str]:
73 + max_tokens: int = 5):
74
75 sampling_params = vllm.SamplingParams(
76 temperature=temperature,
@@ -97,7 +98,35 @@ class Qwen2VLTester:
98 generated), f"Generated text {generated} doesn't "
99 f"match expected pattern {expected}"
100
100 - return generated_texts
101 + def run_beam_search_test(self,
102 + images: list[ImageAsset],
103 + expected_outputs: list[list[str]],
104 + lora_id: Optional[int] = None,
105 + temperature: float = 0,
106 + beam_width: int = 2,
107 + max_tokens: int = 5):
108 +
109 + beam_search_params = BeamSearchParams(beam_width=beam_width,
110 + max_tokens=max_tokens,
111 + temperature=temperature)
112 +
113 + inputs = [{
114 + "prompt": self.PROMPT_TEMPLATE,
115 + "multi_modal_data": {
116 + "image": asset.pil_image
117 + },
118 + } for asset in images]
119 +
120 + lora_request = LoRARequest(str(lora_id), lora_id,
121 + self.config.lora_path)
122 + outputs = self.llm.beam_search(inputs,
123 + beam_search_params,
124 + lora_request=lora_request)
125 +
126 + for output_obj, expected_outs in zip(outputs, expected_outputs):
127 + output_texts = [seq.text for seq in output_obj.sequences]
128 + assert output_texts == expected_outs, \
129 + f"Generated texts {output_texts} do not match expected {expected_outs}" # noqa: E501
130
131
132 TEST_IMAGES = [
@@ -110,6 +139,14 @@ EXPECTED_OUTPUTS = [
139 "A majestic skyscraper stands tall, partially obscured by a vibrant canopy of cherry blossoms, against a clear blue sky.", # noqa: E501
140 ]
141
142 +# NOTE - beam search .text contains the whole text
143 +EXPECTED_BEAM_SEARCH_OUTPUTS = [
144 + [
145 + "<|im_start|>system\nYou are a helpful assistant.<|im_end|>\n<|im_start|>user\n<|vision_start|><|image_pad|><|vision_end|>What is in the image?<|im_end|>\n<|im_start|>assistant\nA majestic skyscraper stands", # noqa: E501
146 + "<|im_start|>system\nYou are a helpful assistant.<|im_end|>\n<|im_start|>user\n<|vision_start|><|image_pad|><|vision_end|>What is in the image?<|im_end|>\n<|im_start|>assistant\nA majestic tower stands tall", # noqa: E501
147 + ],
148 +]
149 +
150 QWEN2VL_MODEL_PATH = "Qwen/Qwen2-VL-2B-Instruct"
151 QWEN25VL_MODEL_PATH = "Qwen/Qwen2.5-VL-3B-Instruct"
152
@@ -130,6 +167,27 @@ def test_qwen2vl_lora(qwen2vl_lora_files):
167 lora_id=lora_id)
168
169
170 +@pytest.mark.xfail(
171 + current_platform.is_rocm(),
172 + reason="Qwen2-VL dependency xformers incompatible with ROCm")
173 +def test_qwen2vl_lora_beam_search(qwen2vl_lora_files):
174 + """Test Qwen 2.0 VL model with LoRA through beam search."""
175 + config = TestConfig(model_path=QWEN2VL_MODEL_PATH,
176 + lora_path=qwen2vl_lora_files)
177 + tester = Qwen2VLTester(config)
178 +
179 + # Test with different LoRA IDs
180 + for lora_id in [1, 2]:
181 + # NOTE currently, we only test cherry blossom since stop sign
182 + # output is slightly different for v1; - the root cause is likely
183 + # independent of the intent of this test, which is to ensure beam
184 + # search passes through lora through correctly.
185 + tester.run_beam_search_test(
186 + [ImageAsset("cherry_blossom")],
187 + expected_outputs=EXPECTED_BEAM_SEARCH_OUTPUTS,
188 + lora_id=lora_id)
189 +
190 +
191 @pytest.mark.xfail(
192 current_platform.is_rocm(),
193 reason="Qwen2.5-VL dependency xformers incompatible with ROCm",
vllm/beam_search.py
+4
@@ -3,6 +3,7 @@
3 from dataclasses import dataclass
4 from typing import TYPE_CHECKING, Any, Optional, Union
5
6 +from vllm.lora.request import LoRARequest
7 from vllm.sequence import Logprob
8
9 if TYPE_CHECKING:
@@ -19,6 +20,7 @@ class BeamSearchSequence:
20 # The tokens includes the prompt.
21 tokens: list[int]
22 logprobs: list[dict[int, Logprob]]
23 + lora_request: Optional[LoRARequest] = None
24 cum_logprob: float = 0.0
25 text: Optional[str] = None
26 finish_reason: Optional[str] = None
@@ -41,6 +43,7 @@ class BeamSearchInstance:
43 def __init__(
44 self,
45 prompt_tokens: list[int],
46 + lora_request: Optional[LoRARequest] = None,
47 logprobs: Optional[list[dict[int, Logprob]]] = None,
48 **kwargs,
49 ):
@@ -48,6 +51,7 @@ class BeamSearchInstance:
51 BeamSearchSequence(
52 tokens=prompt_tokens,
53 logprobs=[] if logprobs is None else list(logprobs),
54 + lora_request=lora_request,
55 **kwargs,
56 )
57 ]
vllm/engine/protocol.py
+14 -8
@@ -65,6 +65,7 @@ class EngineClient(ABC):
65 prompt: PromptType,
66 request_id: str,
67 params: BeamSearchParams,
68 + lora_request: Optional[LoRARequest] = None,
69 ) -> AsyncGenerator[RequestOutput, None]:
70
71 beam_width = params.beam_width
@@ -106,27 +107,31 @@ class EngineClient(ABC):
107 cum_logprob=0,
108 logprobs=[],
109 multi_modal_data=multi_modal_data,
109 - mm_processor_kwargs=mm_processor_kwargs)
110 + mm_processor_kwargs=mm_processor_kwargs,
111 + lora_request=lora_request)
112 ]
113 completed = []
114
115 for _ in range(max_tokens):
114 - prompts_batch = [
116 + prompts_batch, lora_req_batch = zip(*[(
117 TokensPrompt(prompt_token_ids=beam.tokens,
118 multi_modal_data=beam.multi_modal_data,
117 - mm_processor_kwargs=beam.mm_processor_kwargs)
118 - for beam in all_beams
119 - ]
119 + mm_processor_kwargs=beam.mm_processor_kwargs),
120 + beam.lora_request,
121 + ) for beam in all_beams])
122
123 tasks = []
124
125 request_id = f"beam_search-{random_uuid()}"
124 - for i, individual_prompt in enumerate(prompts_batch):
126 + for i, (individual_prompt,
127 + lora_req) in enumerate(zip(prompts_batch, lora_req_batch)):
128 request_id_item = f"{request_id}-{i}"
129 task = asyncio.create_task(
130 collect_from_async_generator(
128 - self.generate(individual_prompt, beam_search_params,
129 - request_id_item)))
131 + self.generate(individual_prompt,
132 + beam_search_params,
133 + request_id_item,
134 + lora_request=lora_req)))
135 tasks.append(task)
136
137 output = await asyncio.gather(*tasks)
@@ -159,6 +164,7 @@ class EngineClient(ABC):
164 tokens=current_beam.tokens + [token_id],
165 logprobs=current_beam.logprobs +
166 [logprobs],
167 + lora_request=current_beam.lora_request,
168 cum_logprob=current_beam.cum_logprob +
169 logprob_obj.logprob,
170 multi_modal_data=current_beam.
vllm/entrypoints/llm.py
+36 -6
@@ -522,10 +522,28 @@ class LLM:
522 executor = self.llm_engine.model_executor
523 return executor.apply_model(func)
524
525 + def _get_beam_search_lora_requests(
526 + self,
527 + lora_request: Optional[Union[list[LoRARequest], LoRARequest]],
528 + prompts: list[Union[TokensPrompt, TextPrompt]],
529 + ) -> list[Optional[LoRARequest]]:
530 + """Get the optional lora request corresponding to each prompt."""
531 + if isinstance(lora_request,
532 + Sequence) and len(lora_request) != len(prompts):
533 + raise ValueError(
534 + "Lora request list should be the same length as the prompts")
535 + return lora_request
536 +
537 + if lora_request is None or isinstance(lora_request, LoRARequest):
538 + return [lora_request] * len(prompts)
539 +
540 + raise TypeError(f"Invalid lora_request type {type(lora_request)}")
541 +
542 def beam_search(
543 self,
544 prompts: list[Union[TokensPrompt, TextPrompt]],
545 params: BeamSearchParams,
546 + lora_request: Optional[Union[list[LoRARequest], LoRARequest]] = None,
547 ) -> list[BeamSearchOutput]:
548 """
549 Generate sequences using beam search.
@@ -534,6 +552,7 @@ class LLM:
552 prompts: A list of prompts. Each prompt can be a string or a list
553 of token IDs.
554 params: The beam search parameters.
555 + lora_request: LoRA request to use for generation, if any.
556 """
557 # TODO: how does beam search work together with length penalty,
558 # frequency, penalty, and stopping criteria, etc.?
@@ -543,6 +562,9 @@ class LLM:
562 ignore_eos = params.ignore_eos
563 length_penalty = params.length_penalty
564
565 + lora_requests = self._get_beam_search_lora_requests(
566 + lora_request, prompts)
567 +
568 def sort_beams_key(x: BeamSearchSequence) -> float:
569 return get_beam_search_score(x.tokens, x.cum_logprob,
570 tokenizer.eos_token_id,
@@ -570,7 +592,7 @@ class LLM:
592 temperature=temperature)
593 instances: list[BeamSearchInstance] = []
594
573 - for prompt in prompts:
595 + for lora_req, prompt in zip(lora_requests, prompts):
596 # Add multimodal processor kwargs & data
597 mm_kwargs = {}
598 if "multi_modal_data" in prompt:
@@ -586,7 +608,12 @@ class LLM:
608 prompt_tokens = tokenizer.encode(prompt["prompt"])
609
610 instances.append(
589 - BeamSearchInstance(prompt_tokens, logprobs=None, **mm_kwargs))
611 + BeamSearchInstance(
612 + prompt_tokens,
613 + lora_request=lora_req,
614 + logprobs=None,
615 + **mm_kwargs,
616 + ), )
617
618 for _ in range(max_tokens):
619 all_beams: list[BeamSearchSequence] = list(
@@ -600,15 +627,17 @@ class LLM:
627 if len(all_beams) == 0:
628 break
629
603 - prompts_batch = [
604 - create_tokens_prompt_from_beam(beam) for beam in all_beams
605 - ]
630 + # create the corresponding batch entries for prompt & optional lora
631 + prompts_batch, lora_req_batch = zip(
632 + *[(create_tokens_prompt_from_beam(beam), beam.lora_request)
633 + for beam in all_beams])
634
635 # only runs for one step
636 # we don't need to use tqdm here
637 output = self.generate(prompts_batch,
638 sampling_params=beam_search_params,
611 - use_tqdm=False)
639 + use_tqdm=False,
640 + lora_request=lora_req_batch)
641
642 for (start, end), instance in zip(instance_start_and_end,
643 instances):
@@ -626,6 +655,7 @@ class LLM:
655 new_beam = BeamSearchSequence(
656 tokens=current_beam.tokens + [token_id],
657 logprobs=current_beam.logprobs + [logprobs],
658 + lora_request=current_beam.lora_request,
659 cum_logprob=current_beam.cum_logprob +
660 logprob_obj.logprob,
661 multi_modal_data=current_beam.multi_modal_data,
vllm/entrypoints/openai/serving_chat.py
+1
@@ -236,6 +236,7 @@ class OpenAIServingChat(OpenAIServing):
236 prompt=engine_prompt,
237 request_id=request_id,
238 params=sampling_params,
239 + lora_request=lora_request,
240 )
241 else:
242 generator = self.engine_client.generate(
vllm/entrypoints/openai/serving_completion.py
+1
@@ -186,6 +186,7 @@ class OpenAIServingCompletion(OpenAIServing):
186 prompt=engine_prompt,
187 request_id=request_id,
188 params=sampling_params,
189 + lora_request=lora_request,
190 )
191 else:
192 generator = self.engine_client.generate(