main
py 700 lines 26 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 from collections.abc import Sequence
4 from dataclasses import dataclass
5 from typing import Any, Literal, cast
6
7 import torch
8 from openai.types.chat import (
9 ChatCompletionContentPartImageParam,
10 ChatCompletionContentPartTextParam,
11 )
12 from openai.types.chat.chat_completion_content_part_image_param import ImageURL
13
14 from vllm import PoolingParams
15 from vllm.entrypoints.chat_utils import (
16 ChatCompletionContentPartParam,
17 ChatCompletionMessageParam,
18 CustomChatCompletionMessageParam,
19 )
20 from vllm.inputs import tokens_input
21 from vllm.logger import init_logger
22 from vllm.outputs import PoolingOutput, PoolingRequestOutput
23 from vllm.renderers import merge_kwargs
24 from vllm.renderers.hf import resolve_chat_template
25 from vllm.utils.collection_utils import chunk_list
26 from vllm.utils.mistral import is_mistral_tokenizer
27
28 from ..base.io_processor import PoolingIOProcessor
29 from ..scoring.io_processor import JinaRankingIOProcessorMixin
30 from ..typing import (
31 AnyOfflineInputsContext,
32 AnyRenderParam,
33 ChunkedEmbeddingMetadata,
34 EncodeChatRenderParams,
35 OfflineEncodeInputsContext,
36 PoolingChatLikeRequest,
37 PoolingCompletionLikeRequest,
38 PoolingEngineInput,
39 PoolingServeContext,
40 RequestFactory,
41 )
42 from .protocol import (
43 CohereEmbedContent,
44 CohereEmbedInput,
45 CohereEmbedRequest,
46 EmbeddingBatchChatInputRequest,
47 EmbeddingBatchChatRequest,
48 EmbeddingChatInputRequest,
49 EmbeddingChatRequest,
50 EmbeddingCompletionRequest,
51 )
52
53 logger = init_logger(__name__)
54
55
56 @dataclass
57 class _ChunkedPromptAggregator:
58 weighted_sum: torch.Tensor | None = None
59 total_weight: int = 0
60 num_chunks: int = 0
61 all_chunks_l2_normalized: bool = True
62
63
64 class EmbedIOProcessor(PoolingIOProcessor):
65 name = "embed"
66
67 def __init__(self, *args, **kwargs):
68 super().__init__(*args, **kwargs)
69 assert self.model_config.pooler_config is not None
70
71 self.pooler_config = self.model_config.pooler_config
72 self.enable_chunked_processing = self.pooler_config.enable_chunked_processing
73
74 # Load task instructions from HF config or sentence-transformers config
75 self.task_instructions: dict[str, str] | None = self._load_task_instructions(
76 self.model_config.hf_config
77 ) or self._load_st_prompts(self.model_config.model, self.model_config.revision)
78 if self.task_instructions:
79 logger.info(
80 "Loaded prompt prefixes for input_type: %s",
81 list(self.task_instructions.keys()),
82 )
83
84 def get_request_factory_online(
85 self, ctx: PoolingServeContext
86 ) -> Sequence[AnyRenderParam]:
87 if isinstance(ctx.request, CohereEmbedRequest):
88 requests = self._get_request_factory_cohere_online(ctx)
89 elif isinstance(
90 ctx.request,
91 (
92 EmbeddingChatRequest,
93 EmbeddingBatchChatRequest,
94 EmbeddingChatInputRequest,
95 EmbeddingBatchChatInputRequest,
96 ),
97 ):
98 requests = self._get_request_factory_chat_input_online(ctx)
99 else:
100 requests = super().get_request_factory_online(ctx)
101
102 return requests
103
104 def post_process_online(
105 self,
106 ctx: PoolingServeContext,
107 ):
108 if ctx.final_res_batch is None:
109 raise ValueError("Final response batch not available")
110
111 if not self.enable_chunked_processing:
112 self._enforce_cohere_max_tokens(ctx)
113 return super().post_process_online(ctx)
114
115 self._post_process_chunked(ctx)
116 self._enforce_cohere_max_tokens(ctx)
117
118 #################################################################
119 # Long Text Embedding with Chunked Processing
120 # PTAL: examples/pooling/embed/openai_embedding_long_text
121 #################################################################
122
123 def maybe_pre_process_chunked(self, ctx: PoolingServeContext) -> None:
124 if not self.enable_chunked_processing:
125 return None
126
127 if ctx.engine_inputs is None:
128 raise ValueError("Engine prompts not available")
129
130 ctx.original_engine_inputs = ctx.engine_inputs
131 request_id = ctx.request_id
132 max_model_len = self.model_config.max_model_len
133 chunked_engine_inputs: list[PoolingEngineInput] = []
134 prompt_request_ids: list[str] = []
135 chunked_embedding_metadata: list[ChunkedEmbeddingMetadata] = []
136 for prompt_idx, engine_input in enumerate(ctx.engine_inputs):
137 token_ids = engine_input["prompts"].get("prompt_token_ids", None)
138 if token_ids is None:
139 raise NotImplementedError(
140 "Long Text Embedding with Chunked Processing does "
141 "not support EmbedsPrompt and EncoderDecoderInput."
142 )
143
144 prompt_token_ids = cast(list[int], token_ids)
145
146 for chunk_idx, chunk_tokens in enumerate(
147 chunk_list(prompt_token_ids, max_model_len)
148 ):
149 chunked_engine_inputs.append(
150 PoolingEngineInput(
151 prompts=tokens_input(prompt_token_ids=chunk_tokens),
152 params=engine_input["params"],
153 lora_requests=engine_input["lora_requests"],
154 priorities=engine_input["priorities"],
155 )
156 )
157 prompt_request_ids.append(
158 f"{request_id}-prompt-{prompt_idx}-chunk-{chunk_idx}"
159 )
160 chunked_embedding_metadata.append(
161 ChunkedEmbeddingMetadata(
162 prompt_index=prompt_idx,
163 chunk_index=chunk_idx,
164 )
165 )
166
167 ctx.engine_inputs = chunked_engine_inputs
168 ctx.prompt_request_ids = prompt_request_ids
169 ctx.chunked_embedding_metadata = chunked_embedding_metadata
170
171 return None
172
173 def _post_process_chunked(self, ctx: PoolingServeContext) -> None:
174 # Online aggregation for chunked requests to
175 # minimize memory usage
176 # Track aggregation state for each prompt
177 if ctx.chunked_embedding_metadata is None:
178 raise ValueError("Chunked embedding metadata not available")
179 if len(ctx.chunked_embedding_metadata) != len(ctx.final_res_batch):
180 raise ValueError(
181 "Chunked embedding metadata count does not match result count"
182 )
183
184 prompt_aggregators: dict[int, _ChunkedPromptAggregator] = {}
185 for result, chunk_metadata in zip(
186 ctx.final_res_batch, ctx.chunked_embedding_metadata
187 ):
188 prompt_idx = chunk_metadata.prompt_index
189 aggregator = prompt_aggregators.setdefault(
190 prompt_idx, _ChunkedPromptAggregator()
191 )
192
193 # MEAN pooling with online weighted averaging
194 # Ensure result is PoolingRequestOutput
195 # for embedding processing
196 if not isinstance(result, PoolingRequestOutput):
197 raise ValueError(
198 f"Expected PoolingRequestOutput for "
199 f"chunked embedding, got "
200 f"{type(result).__name__}"
201 )
202 if result.prompt_token_ids is None:
203 raise ValueError(
204 "prompt_token_ids cannot be None for chunked processing"
205 )
206
207 weight = len(result.prompt_token_ids)
208 embedding_data = result.outputs.data
209 float_embedding = embedding_data.to(dtype=torch.float32)
210 if ctx.pooling_params.use_activation:
211 # Custom activations need not L2-normalize; allow BF16 rounding.
212 embedding_norm = torch.linalg.vector_norm(float_embedding)
213 aggregator.all_chunks_l2_normalized &= torch.allclose(
214 embedding_norm,
215 torch.ones_like(embedding_norm),
216 rtol=1e-2,
217 atol=1e-5,
218 )
219 weighted_embedding = float_embedding * weight
220
221 if aggregator.weighted_sum is None:
222 # First chunk
223 aggregator.weighted_sum = weighted_embedding
224 else:
225 # Accumulate
226 aggregator.weighted_sum += weighted_embedding
227
228 aggregator.total_weight += weight
229 aggregator.num_chunks += 1
230
231 if ctx.original_engine_inputs is None:
232 raise ValueError("Original engine inputs not available")
233
234 original_engine_inputs = ctx.original_engine_inputs
235 num_prompts = len(original_engine_inputs)
236
237 # Finalize aggregated results
238 final_res_batch: list[PoolingRequestOutput] = []
239 for prompt_idx in range(num_prompts):
240 if prompt_idx in prompt_aggregators:
241 # Finalize MEAN aggregation for this chunked prompt
242 aggregator = prompt_aggregators[prompt_idx]
243
244 weighted_sum = aggregator.weighted_sum
245 total_weight = aggregator.total_weight
246
247 if (
248 weighted_sum is not None
249 and isinstance(weighted_sum, torch.Tensor)
250 and isinstance(total_weight, (int, float))
251 and total_weight > 0
252 ):
253 # Compute final mean embedding
254 final_embedding = weighted_sum / total_weight
255 if (
256 ctx.pooling_params.use_activation
257 and aggregator.num_chunks > 1
258 and aggregator.all_chunks_l2_normalized
259 ):
260 final_embedding = torch.nn.functional.normalize(
261 final_embedding, dim=-1
262 )
263
264 # Create a PoolingRequestOutput
265 # for the aggregated result
266 pooling_output_data = PoolingOutput(data=final_embedding)
267
268 # Get original prompt token IDs for this prompt
269 original_prompt = original_engine_inputs[prompt_idx]
270 token_ids = original_prompt["prompts"].get("prompt_token_ids", None)
271 if token_ids is None:
272 raise NotImplementedError(
273 "Long Text Embedding with Chunked Processing does "
274 "not support EmbedsPrompt and EncoderDecoderInput."
275 )
276
277 original_token_ids = cast(list[int], token_ids)
278 pooling_request_output = PoolingRequestOutput(
279 request_id=f"{ctx.request_id}-prompt-{prompt_idx}",
280 prompt_token_ids=original_token_ids,
281 outputs=pooling_output_data,
282 num_cached_tokens=0,
283 finished=True,
284 )
285
286 final_res_batch.append(pooling_request_output)
287 else:
288 raise ValueError(
289 f"Failed to aggregate chunks for prompt {prompt_idx}"
290 )
291 else:
292 raise ValueError(f"Result not found for prompt {prompt_idx}")
293
294 ctx.final_res_batch = final_res_batch
295
296 return None
297
298 #################################################################
299 # Chat input Request Preprocessing & Postprocessing
300 #################################################################
301
302 def _get_request_factory_chat_input_online(
303 self,
304 ctx: PoolingServeContext,
305 ) -> Sequence[AnyRenderParam]:
306 request = ctx.request
307 renderer = self.renderer
308
309 self._validate_chat_template(
310 request_chat_template=request.chat_template,
311 chat_template_kwargs=request.chat_template_kwargs,
312 trust_request_chat_template=self.trust_request_chat_template,
313 )
314
315 if isinstance(
316 request, (EmbeddingBatchChatRequest, EmbeddingBatchChatInputRequest)
317 ):
318 all_messages = request.messages
319 else:
320 all_messages = [request.messages]
321 num_requests = len(all_messages)
322
323 default_template_kwargs = merge_kwargs(
324 self.template_kwargs,
325 dict(
326 tools=self.tool_dicts,
327 tokenize=is_mistral_tokenizer(renderer.tokenizer),
328 ),
329 )
330
331 mm_config = self.model_config.multimodal_config
332 tok_params = request.build_tok_params(self.model_config)
333 chat_params = request.build_chat_params(
334 self.chat_template, self.chat_template_content_format
335 ).with_defaults(
336 default_template_kwargs,
337 default_media_io_kwargs=(mm_config.media_io_kwargs if mm_config else None),
338 )
339
340 params_seq = self._params_to_seq(ctx.pooling_params, num_requests)
341 seq_lora_requests = self._lora_request_to_seq(ctx.lora_request, num_requests)
342 seq_priority = self._priority_to_seq(ctx.priorities, num_requests)
343
344 requests = [
345 EncodeChatRenderParams(
346 conversations=all_messages[i],
347 chat_params=chat_params,
348 tok_params=tok_params,
349 prompt_extras=ctx.prompt_extras,
350 skip_mm_cache=False,
351 params=params_seq[i],
352 lora_requests=seq_lora_requests[i],
353 priorities=seq_priority[i],
354 )
355 for i in range(num_requests)
356 ]
357
358 return requests
359
360 #################################################################
361 # Cohere Request Preprocessing & Postprocessing
362 #################################################################
363
364 @staticmethod
365 def _load_task_instructions(hf_config: Any) -> dict[str, str] | None:
366 """Extract ``task_instructions`` from the HF model config."""
367 ti = getattr(hf_config, "task_instructions", None)
368 if not isinstance(ti, dict) or not ti:
369 return None
370 return {k: v for k, v in ti.items() if isinstance(v, str)}
371
372 @staticmethod
373 def _load_st_prompts(
374 model: str | Any,
375 revision: str | None,
376 ) -> dict[str, str] | None:
377 """Load ``task_instructions`` from ``config_sentence_transformers.json``."""
378 from vllm.transformers_utils.repo_utils import get_hf_file_to_dict
379
380 try:
381 cfg = get_hf_file_to_dict(
382 "config_sentence_transformers.json", str(model), revision
383 )
384 except (ValueError, OSError):
385 return None
386
387 if cfg is None:
388 return None
389 prompts = cfg.get("prompts")
390 if not isinstance(prompts, dict) or not prompts:
391 return None
392 return {k: v for k, v in prompts.items() if isinstance(v, str)}
393
394 @staticmethod
395 def _mixed_input_to_messages(
396 inp: CohereEmbedInput,
397 *,
398 task_prefix: str | None = None,
399 ) -> list[ChatCompletionMessageParam]:
400 """Build chat messages from a mixed text+image input.
401
402 When *task_prefix* is given, it is used as the system prompt.
403 """
404 messages: list[ChatCompletionMessageParam] = []
405 if task_prefix is not None:
406 messages.append(
407 CustomChatCompletionMessageParam(
408 role="system",
409 content=[
410 ChatCompletionContentPartTextParam(
411 type="text", text=task_prefix
412 )
413 ],
414 )
415 )
416
417 parts: list[ChatCompletionContentPartParam] = []
418 for item in inp.content:
419 if item.type == "text" and item.text is not None:
420 parts.append(
421 ChatCompletionContentPartTextParam(type="text", text=item.text)
422 )
423 elif item.type == "image_url" and item.image_url is not None:
424 parts.append(
425 ChatCompletionContentPartImageParam(
426 type="image_url",
427 image_url=ImageURL(url=item.image_url["url"]),
428 )
429 )
430 messages.append(CustomChatCompletionMessageParam(role="user", content=parts))
431 return messages
432
433 @staticmethod
434 def _check_cohere_max_tokens(
435 outputs: list[PoolingRequestOutput],
436 max_tokens_check: int | None,
437 ) -> None:
438 """Raise if any output exceeds *max_tokens_check* tokens.
439
440 Used to enforce ``truncate=NONE`` with an explicit ``max_tokens``:
441 the pipeline runs without truncation and we reject afterwards.
442 """
443 if max_tokens_check is None:
444 return
445 for out in outputs:
446 n = len(out.prompt_token_ids)
447 if n > max_tokens_check:
448 raise ValueError(
449 f"Input of {n} tokens exceeds max_tokens={max_tokens_check} "
450 "with truncate=NONE. Set truncate to END or START to "
451 "allow truncation."
452 )
453
454 @staticmethod
455 def _resolve_cohere_truncation(
456 request: CohereEmbedRequest,
457 ) -> tuple[int | None, Literal["left", "right"] | None]:
458 """Return ``(truncate_prompt_tokens, truncation_side)``."""
459 if request.truncate == "NONE":
460 return None, None
461 if request.truncate == "START":
462 tokens = request.max_tokens if request.max_tokens is not None else -1
463 return tokens, "left"
464 if request.max_tokens is not None:
465 return request.max_tokens, None
466 return -1, None
467
468 def create_pooling_params(self, request):
469 if isinstance(request, CohereEmbedRequest):
470 return PoolingParams(
471 task="embed",
472 dimensions=request.output_dimension,
473 )
474 return super().create_pooling_params(request)
475
476 def _get_request_factory_cohere_online(
477 self, ctx: PoolingServeContext
478 ) -> Sequence[AnyRenderParam]:
479 """Convert a ``CohereEmbedRequest`` into engine prompts.
480
481 If a model has a chat template the task instruction are rendered
482 as a system prompt. Otherwise they are just prepended to the input text.
483
484 Images and mixed inputs are always batch-rendered through the chat
485 template in one ``render_chat`` call.
486 """
487 request = ctx.request
488 assert isinstance(request, CohereEmbedRequest)
489
490 if request.texts is None and request.images is None and request.inputs is None:
491 raise ValueError("One of texts, images, or inputs must be provided")
492
493 truncate_prompt_tokens, truncation_side = self._resolve_cohere_truncation(
494 request
495 )
496 input_type = request.input_type
497 self._validate_input_type(input_type)
498
499 if request.images is not None:
500 input: list[CohereEmbedInput] = [
501 CohereEmbedInput(
502 content=[
503 CohereEmbedContent(type="image_url", image_url={"url": uri})
504 ]
505 )
506 for uri in request.images
507 ]
508 elif request.inputs is not None:
509 input = request.inputs
510 else:
511 texts = request.texts or []
512 task_prefix = self._get_task_instruction_prefix(input_type)
513
514 if task_prefix is None:
515 return self._get_request_factory_cohere_text_completion(
516 ctx,
517 texts,
518 truncate_prompt_tokens,
519 truncation_side,
520 )
521
522 all_messages = [
523 self._mixed_input_to_messages(
524 CohereEmbedInput(
525 content=[CohereEmbedContent(type="text", text=text)]
526 ),
527 task_prefix=task_prefix,
528 )
529 for text in texts
530 ]
531 if self._has_chat_template():
532 return self._batch_render_chat(
533 ctx,
534 all_messages,
535 truncate_prompt_tokens,
536 truncation_side,
537 )
538 else:
539 return self._get_request_factory_cohere_text_completion(
540 ctx,
541 self._apply_task_instruction(texts, input_type),
542 truncate_prompt_tokens,
543 truncation_side,
544 )
545
546 task_prefix = self._get_task_instruction_prefix(input_type)
547 all_messages = [
548 self._mixed_input_to_messages(inp, task_prefix=task_prefix) for inp in input
549 ]
550
551 return self._batch_render_chat(
552 ctx,
553 all_messages,
554 truncate_prompt_tokens,
555 truncation_side,
556 )
557
558 def _has_chat_template(self) -> bool:
559 return (
560 resolve_chat_template(
561 self.renderer.tokenizer,
562 chat_template=self.chat_template,
563 tools=None,
564 model_config=self.model_config,
565 )
566 is not None
567 )
568
569 def _get_request_factory_cohere_text_completion(
570 self,
571 ctx: PoolingServeContext,
572 texts: list[str],
573 truncate_prompt_tokens: int | None,
574 truncation_side: Literal["left", "right"] | None,
575 ) -> Sequence[AnyRenderParam]:
576 request = ctx.request
577 proxy = EmbeddingCompletionRequest(
578 model=request.model,
579 input=texts,
580 dimensions=request.output_dimension,
581 encoding_format="float",
582 truncate_prompt_tokens=truncate_prompt_tokens,
583 truncation_side=truncation_side,
584 )
585 ctx.request = proxy
586 requests = super().get_request_factory_online(ctx)
587 ctx.request = request
588 return requests
589
590 def _batch_render_chat(
591 self,
592 ctx: PoolingServeContext,
593 all_messages: Sequence[list[ChatCompletionMessageParam]],
594 truncate_prompt_tokens: int | None,
595 truncation_side: Literal["left", "right"] | None,
596 ) -> Sequence[AnyRenderParam]:
597 """Batch-render multiple conversations through the chat template."""
598 request = ctx.request
599 proxy = EmbeddingBatchChatRequest(
600 model=request.model,
601 messages=all_messages,
602 dimensions=request.output_dimension,
603 encoding_format="float",
604 truncate_prompt_tokens=truncate_prompt_tokens,
605 truncation_side=truncation_side,
606 )
607 ctx.request = proxy
608 requests = self._get_request_factory_chat_input_online(ctx)
609 ctx.request = request
610 return requests
611
612 def _validate_input_type(self, input_type: str | None) -> None:
613 """Raise if *input_type* is not supported by this model."""
614 if input_type is None:
615 return
616 if self.task_instructions is None:
617 raise ValueError(
618 f"Unsupported input_type {input_type!r}. "
619 "This model does not define any input_type task instructions."
620 )
621 if input_type not in self.task_instructions:
622 supported = ", ".join(sorted(self.task_instructions))
623 raise ValueError(
624 f"Unsupported input_type {input_type!r}. Supported values: {supported}"
625 )
626
627 def _apply_task_instruction(
628 self,
629 texts: list[str],
630 input_type: str | None,
631 ) -> list[str]:
632 """Prepend the task-instruction prefix for *input_type*.
633
634 Returns *texts* unchanged when no matching prefix is configured.
635 """
636 prefix = self._get_task_instruction_prefix(input_type)
637 if not prefix:
638 return texts
639 return [prefix + t for t in texts]
640
641 def _get_task_instruction_prefix(self, input_type: str | None) -> str | None:
642 """Return the task-instruction prefix for *input_type*, or ``None``."""
643 if not self.task_instructions or input_type is None:
644 return None
645 return self.task_instructions.get(input_type) or None
646
647 def _enforce_cohere_max_tokens(self, ctx: PoolingServeContext) -> None:
648 if isinstance(ctx.request, CohereEmbedRequest):
649 request = ctx.request
650 if request.truncate == "NONE" and request.max_tokens is not None:
651 self._check_cohere_max_tokens(ctx.final_res_batch, request.max_tokens)
652
653
654 class TokenEmbedIOProcessor(PoolingIOProcessor):
655 name = "token_embed"
656
657
658 class JinaRankingTokenEmbedIOProcessor(
659 TokenEmbedIOProcessor, JinaRankingIOProcessorMixin
660 ):
661 def get_request_factory_online(
662 self, ctx: PoolingServeContext
663 ) -> Sequence[AnyRenderParam]:
664 request = ctx.request
665 if isinstance(request, PoolingCompletionLikeRequest):
666 prompts = request.input
667 if not isinstance(prompts, Sequence) or len(prompts) < 2:
668 raise ValueError("The JinaForRanking model requires at least 2 inputs.")
669
670 text_prompts = self.ensure_str(prompts)
671
672 # The JinaForRanking model concatenates docs first, then query.
673 # Let's stay consistent with this novel design.
674 prompt_input = self.format_docs_prompts_func(
675 query=text_prompts[-1], docs=text_prompts[:-1]
676 )
677
678 request.input = prompt_input
679 return super().get_request_factory_online(ctx)
680 elif isinstance(request, PoolingChatLikeRequest):
681 raise ValueError("The JinaForRanking does not support chat Request.")
682 else:
683 raise ValueError(f"Invalid {self.name} request type")
684
685 def get_request_factory_offline(
686 self, ctx: AnyOfflineInputsContext
687 ) -> tuple[RequestFactory, int]:
688 assert isinstance(ctx, OfflineEncodeInputsContext)
689 if not isinstance(ctx.prompts, Sequence) or len(ctx.prompts) < 2:
690 raise ValueError("The JinaForRanking model requires at least 2 inputs.")
691
692 text_prompts = self.ensure_str(ctx.prompts)
693
694 # The JinaForRanking model concatenates docs first, then query.
695 # Let's stay consistent with this novel design.
696 ctx.prompts = self.format_docs_prompts_func(
697 query=text_prompts[-1], docs=text_prompts[:-1]
698 )
699
700 return super().get_request_factory_offline(ctx)