main
py 470 lines 17.9 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4 import itertools
5 from collections.abc import Callable, Sequence
6
7 import torch
8 from tqdm import tqdm
9
10 from vllm import RequestOutput, TextPrompt, TokensPrompt
11 from vllm.entrypoints.offline_utils import OfflineInferenceMixin
12 from vllm.logger import init_logger
13 from vllm.lora.request import LoRARequest
14 from vllm.pooling_params import PoolingParams
15 from vllm.sampling_params import (
16 BeamSearchParams,
17 SamplingParams,
18 StructuredOutputsParams,
19 )
20 from vllm.tokenizers import TokenizerLike
21 from vllm.v1.structured_output.backend_types import StructuredOutputBackend
22 from vllm.v1.structured_output.request import get_structured_output_key
23
24 from .utils import (
25 BeamSearchInstance,
26 BeamSearchOutput,
27 BeamSearchSequence,
28 create_sort_beams_key_function,
29 )
30
31 logger = init_logger(__name__)
32
33 # Engine-side cap on `SamplingParams.allowed_token_ids`; keep in sync with
34 # MAX_NUM_ALLOWED_TOKEN_IDS in vllm/v1/worker/gpu/sample/logit_bias.py.
35 _MAX_NUM_ALLOWED_TOKEN_IDS = 1024
36
37
38 _bitmask_cache: dict[int, tuple[torch.Tensor, torch.Tensor, torch.Tensor]] = {}
39
40
41 def _bitmask_to_token_ids(bitmask_row: torch.Tensor, vocab_size: int) -> list[int]:
42 """Convert a packed int32 bitmask row to a list of allowed token IDs."""
43 if vocab_size not in _bitmask_cache:
44 indices = torch.arange(vocab_size)
45 _bitmask_cache[vocab_size] = (
46 indices,
47 indices >> 5, # i // 32
48 indices & 31, # i % 32
49 )
50 indices, word_indices, bit_indices = _bitmask_cache[vocab_size]
51 mask = ((bitmask_row[word_indices] >> bit_indices) & 1).bool()
52 return indices[mask].tolist()
53
54
55 class BeamSearchOfflineMixin(OfflineInferenceMixin):
56 """Offline inference for beam search."""
57
58 def beam_search(
59 self,
60 prompts: list[TokensPrompt | TextPrompt],
61 params: BeamSearchParams,
62 lora_request: list[LoRARequest] | LoRARequest | None = None,
63 use_tqdm: bool = False,
64 concurrency_limit: int | None = None,
65 ) -> list[BeamSearchOutput]:
66 """Generate sequences using beam search.
67
68 Args:
69 prompts: A list of prompts. Each prompt can be a string or a list
70 of token IDs.
71 params: The beam search parameters.
72 lora_request: LoRA request to use for generation, if any.
73 use_tqdm: Whether to use tqdm to display the progress bar.
74 concurrency_limit: The maximum number of concurrent requests.
75 If None, the number of concurrent requests is unlimited.
76
77 """
78 # TODO: how does beam search work together with length penalty,
79 # frequency, penalty, and stopping criteria, etc.?
80 beam_width = params.beam_width
81 max_tokens = params.max_tokens
82 temperature = params.temperature
83 ignore_eos = params.ignore_eos
84 length_penalty = params.length_penalty
85 self.llm_engine.vllm_config._check_watermarking_unsupported(beam_search=True)
86
87 tokenizer = self.renderer.get_tokenizer()
88 eos_token_id = tokenizer.eos_token_id
89 sort_beams_key = create_sort_beams_key_function(eos_token_id, length_penalty)
90
91 engine_inputs = self._preprocess_cmpl(prompts)
92 lora_requests = self._lora_request_to_seq(lora_request, len(engine_inputs))
93
94 if use_tqdm and concurrency_limit is not None:
95 logger.warning(
96 "Progress bar is not supported when using concurrency_limit. "
97 "Disabling progress bar."
98 )
99 use_tqdm = False
100
101 if concurrency_limit is None:
102 concurrency_limit = len(engine_inputs)
103
104 structured_output_backend: StructuredOutputBackend | None = None
105 structured_output_key = None
106 structured_output_bitmask = None
107 if params.structured_outputs is not None:
108 (
109 structured_output_backend,
110 structured_output_key,
111 structured_output_bitmask,
112 ) = self._init_beam_search_structured_output(
113 params.structured_outputs, tokenizer
114 )
115
116 # generate 2 * beam_width candidates at each step
117 # following the huggingface transformers implementation
118 # at https://github.com/huggingface/transformers/blob/e15687fffe5c9d20598a19aeab721ae0a7580f8a/src/transformers/generation/beam_search.py#L534 # noqa
119 base_sampling_params = SamplingParams(
120 logprobs=2 * beam_width,
121 max_tokens=1,
122 temperature=temperature,
123 detokenize=False,
124 skip_clone=True, # Internal beam search, safe to skip clone
125 )
126 instances: list[BeamSearchInstance] = []
127
128 for lora_req, prompt in zip(lora_requests, engine_inputs):
129 if prompt["type"] == "embeds":
130 raise NotImplementedError(
131 "Embedding prompt not supported for beam search"
132 )
133
134 instances.append(
135 BeamSearchInstance(
136 prompt,
137 lora_request=lora_req,
138 logprobs=None,
139 ),
140 )
141
142 try:
143 for prompt_start in range(0, len(instances), concurrency_limit):
144 instances_batch = instances[
145 prompt_start : prompt_start + concurrency_limit
146 ]
147
148 token_iter = range(max_tokens)
149 if use_tqdm:
150 token_iter = tqdm(
151 token_iter,
152 desc="Beam search",
153 unit="token",
154 unit_scale=False,
155 )
156 logger.warning(
157 "The progress bar shows the upper bound on token "
158 "steps and may finish early due to stopping "
159 "conditions. It does not reflect instance-level "
160 "progress."
161 )
162 for _ in token_iter:
163 should_stop = self._beam_search_step(
164 instances_batch=instances_batch,
165 base_sampling_params=base_sampling_params,
166 eos_token_id=eos_token_id,
167 ignore_eos=ignore_eos,
168 beam_width=beam_width,
169 sort_beams_key=sort_beams_key,
170 structured_output_backend=structured_output_backend,
171 structured_output_key=structured_output_key,
172 structured_output_bitmask=structured_output_bitmask,
173 )
174 if should_stop:
175 break
176 finally:
177 if structured_output_backend is not None:
178 structured_output_backend.destroy()
179
180 outputs = []
181 for instance in instances:
182 instance.completed.extend(instance.beams)
183 sorted_completed = sorted(
184 instance.completed, key=sort_beams_key, reverse=True
185 )
186 best_beams = sorted_completed[:beam_width]
187
188 for beam in best_beams:
189 beam.text = tokenizer.decode(
190 beam.tokens, skip_special_tokens=params.skip_special_tokens
191 )
192
193 outputs.append(BeamSearchOutput(sequences=best_beams))
194
195 return outputs
196
197 def _beam_search_step(
198 self,
199 instances_batch: list[BeamSearchInstance],
200 base_sampling_params: SamplingParams,
201 eos_token_id: int | None,
202 ignore_eos: bool,
203 beam_width: int,
204 sort_beams_key: Callable,
205 structured_output_backend: StructuredOutputBackend | None,
206 structured_output_key: tuple | None,
207 structured_output_bitmask: torch.Tensor | None,
208 ) -> bool:
209 """Run one token step of beam search across a batch of instances.
210
211 Returns True if all beams are exhausted and search should stop.
212 """
213 all_beams: list[BeamSearchSequence] = list(
214 itertools.chain.from_iterable(
215 instance.beams for instance in instances_batch
216 )
217 )
218 pos = [0] + list(
219 itertools.accumulate(len(instance.beams) for instance in instances_batch)
220 )
221 instance_start_and_end: list[tuple[int, int]] = list(zip(pos[:-1], pos[1:]))
222
223 if len(all_beams) == 0:
224 return True
225
226 if structured_output_backend is not None:
227 assert (
228 structured_output_key is not None
229 and structured_output_bitmask is not None
230 )
231 beam_entries = self._build_beam_sampling_params(
232 all_beams,
233 base_sampling_params,
234 structured_output_backend,
235 structured_output_key,
236 structured_output_bitmask,
237 )
238 active_indices = [
239 i for i, entry in enumerate(beam_entries) if entry is not None
240 ]
241 for i, entry in enumerate(beam_entries):
242 if entry is None:
243 beam = all_beams[i]
244 assert beam.orig_prompt["type"] != "enc_dec"
245 prompt_len = len(beam.orig_prompt["prompt_token_ids"])
246 if len(beam.tokens) > prompt_len:
247 for (s, e), inst in zip(
248 instance_start_and_end,
249 instances_batch,
250 ):
251 if s <= i < e:
252 inst.completed.append(beam)
253 break
254
255 if not active_indices:
256 return True
257
258 active_beams = [all_beams[i] for i in active_indices]
259 active_params: Sequence[SamplingParams | PoolingParams] = [
260 beam_entries[i][0] # type: ignore[index]
261 for i in active_indices
262 ]
263 else:
264 active_indices = list(range(len(all_beams)))
265 active_beams = all_beams
266 active_params = self._params_to_seq( # type: ignore[assignment]
267 base_sampling_params, len(all_beams)
268 )
269
270 # only runs for one step
271 # we don't need to use tqdm here
272 active_output = self._render_and_run_requests(
273 prompts=(beam.get_prompt() for beam in active_beams),
274 params=active_params,
275 output_type=RequestOutput,
276 lora_requests=[beam.lora_request for beam in active_beams],
277 use_tqdm=False,
278 )
279
280 output: list[RequestOutput | None] = [None] * len(all_beams)
281 for idx, active_idx in enumerate(active_indices):
282 output[active_idx] = active_output[idx]
283
284 # Logprobs are computed from raw logits before
285 # allowed_token_ids masking, so they may contain
286 # tokens outside the grammar's allowed set. This filtering is also
287 # the only grammar enforcement for beams whose allowed set exceeds
288 # the engine-side allowed_token_ids cap.
289 allowed_sets: list[set[int] | None] = [None] * len(all_beams)
290 if structured_output_backend is not None:
291 for i, entry in enumerate(beam_entries):
292 if entry is not None:
293 allowed_sets[i] = set(entry[1])
294
295 for (start, end), instance in zip(instance_start_and_end, instances_batch):
296 instance_output = output[start:end]
297 if any(
298 result is not None and result.outputs[0].finish_reason == "abort"
299 for result in instance_output
300 ):
301 for beam, result in zip(all_beams[start:end], instance_output):
302 if result is not None:
303 beam.finish_reason = "abort"
304 instance.completed.append(beam)
305 instance.beams = []
306 continue
307
308 instance_new_beams = []
309 for i in range(start, end):
310 current_beam = all_beams[i]
311 result = output[i]
312
313 if result is None:
314 continue
315
316 if result.outputs[0].logprobs is not None:
317 logprobs = result.outputs[0].logprobs[0]
318 allowed = allowed_sets[i]
319 for token_id, logprob_obj in logprobs.items():
320 if allowed is not None and token_id not in allowed:
321 continue
322 new_beam = BeamSearchSequence(
323 current_beam.orig_prompt,
324 tokens=current_beam.tokens + [token_id],
325 logprobs=current_beam.logprobs + [logprobs],
326 lora_request=current_beam.lora_request,
327 cum_logprob=current_beam.cum_logprob + logprob_obj.logprob,
328 )
329
330 if token_id == eos_token_id and not ignore_eos:
331 instance.completed.append(new_beam)
332 else:
333 instance_new_beams.append(new_beam)
334 sorted_beams = sorted(
335 instance_new_beams,
336 key=sort_beams_key,
337 reverse=True,
338 )
339 instance.beams = sorted_beams[:beam_width]
340
341 return False
342
343 def _init_beam_search_structured_output(
344 self,
345 structured_outputs: StructuredOutputsParams,
346 tokenizer: TokenizerLike,
347 ) -> tuple[StructuredOutputBackend, tuple, torch.Tensor]:
348 """Initialize the structured output backend for beam search."""
349 vllm_config = self.llm_engine.vllm_config
350 so_config = vllm_config.structured_outputs_config
351 if so_config is None:
352 raise ValueError(
353 "structured_outputs_config is required for beam search "
354 "with structured outputs"
355 )
356
357 # Resolve the backend name from engine config if not already set.
358 if not structured_outputs._backend:
359 structured_outputs._backend = so_config.backend
360
361 backend_name = structured_outputs._backend
362 vocab_size = self.model_config.get_vocab_size()
363
364 backend: StructuredOutputBackend
365 if backend_name == "xgrammar":
366 from vllm.v1.structured_output.backend_xgrammar import (
367 XgrammarBackend,
368 )
369
370 backend = XgrammarBackend(
371 vllm_config=vllm_config,
372 tokenizer=tokenizer,
373 vocab_size=vocab_size,
374 )
375 elif backend_name == "guidance":
376 from vllm.v1.structured_output.backend_guidance import (
377 GuidanceBackend,
378 )
379
380 backend = GuidanceBackend(
381 vllm_config=vllm_config,
382 tokenizer=tokenizer,
383 vocab_size=vocab_size,
384 )
385 elif backend_name == "outlines":
386 from vllm.v1.structured_output.backend_outlines import (
387 OutlinesBackend,
388 )
389
390 backend = OutlinesBackend(
391 vllm_config=vllm_config,
392 tokenizer=tokenizer,
393 vocab_size=vocab_size,
394 )
395 elif backend_name == "lm-format-enforcer":
396 from vllm.v1.structured_output.backend_lm_format_enforcer import (
397 LMFormatEnforcerBackend,
398 )
399
400 backend = LMFormatEnforcerBackend(
401 vllm_config=vllm_config,
402 tokenizer=tokenizer,
403 vocab_size=vocab_size,
404 )
405 else:
406 raise ValueError(f"Unsupported structured output backend: {backend_name}")
407
408 structured_output_key = get_structured_output_key(structured_outputs)
409 bitmask = backend.allocate_token_bitmask(1)
410
411 return backend, structured_output_key, bitmask
412
413 def _build_beam_sampling_params(
414 self,
415 beams: list[BeamSearchSequence],
416 base_params: SamplingParams,
417 backend: StructuredOutputBackend,
418 structured_output_key: tuple,
419 bitmask: torch.Tensor,
420 ) -> list[tuple[SamplingParams, list[int]] | None]:
421 """Build per-beam SamplingParams and allowed token IDs from grammar.
422
423 Returns None for beams where the grammar has terminated.
424 """
425 vocab_size = self.model_config.get_vocab_size()
426 request_type, grammar_spec = structured_output_key
427 result: list[tuple[SamplingParams, list[int]] | None] = []
428
429 for beam in beams:
430 # Fresh grammar per beam, replaying generated tokens.
431 # Backends don't support cloning grammar state, so
432 # replay is needed to reconstruct the FSM position.
433 grammar = backend.compile_grammar(request_type, grammar_spec)
434 assert beam.orig_prompt["type"] != "enc_dec"
435 prompt_len = len(beam.orig_prompt["prompt_token_ids"])
436 generated_tokens = beam.tokens[prompt_len:]
437
438 if generated_tokens:
439 grammar.accept_tokens("beam", generated_tokens)
440
441 if grammar.is_terminated():
442 result.append(None)
443 continue
444
445 grammar.fill_bitmask(bitmask, 0)
446 allowed_ids = _bitmask_to_token_ids(bitmask[0], vocab_size)
447
448 if not allowed_ids:
449 result.append(None)
450 continue
451
452 # The engine caps the size of allowed_token_ids. While the
453 # grammar still allows more tokens than the cap (e.g. inside
454 # free-form strings), skip the engine-side constraint and rely
455 # on the logprobs filtering in _beam_search_step instead.
456 beam_params = SamplingParams(
457 logprobs=base_params.logprobs,
458 max_tokens=1,
459 temperature=base_params.temperature,
460 detokenize=False,
461 allowed_token_ids=(
462 allowed_ids
463 if len(allowed_ids) <= _MAX_NUM_ALLOWED_TOKEN_IDS
464 else None
465 ),
466 skip_clone=True,
467 )
468 result.append((beam_params, allowed_ids))
469
470 return result