main
py 650 lines 21.1 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4 import io
5 from collections.abc import Sequence
6 from dataclasses import dataclass
7 from typing import Any
8
9 import pybase64
10 import pytest
11 import torch
12
13 from vllm.config import ModelConfig
14 from vllm.exceptions import VLLMValidationError
15 from vllm.inputs import SingletonPrompt
16 from vllm.renderers import TokenizeParams
17 from vllm.renderers.hf import HfRenderer
18 from vllm.renderers.inputs.preprocess import parse_model_prompt, prompt_to_seq
19
20 MODEL_NAME = "openai-community/gpt2"
21
22
23 @dataclass
24 class MockHFConfig:
25 model_type: str = "any"
26
27
28 @dataclass
29 class MockModelConfig:
30 runner_type = "generate"
31 model: str = MODEL_NAME
32 tokenizer: str = MODEL_NAME
33 trust_remote_code: bool = False
34 tokenizer_revision = None
35 tokenizer_mode = "auto"
36 hf_config = MockHFConfig()
37 encoder_config: dict[str, Any] | None = None
38 enable_prompt_embeds: bool = True
39 skip_tokenizer_init: bool = False
40 is_encoder_decoder: bool = False
41 is_multimodal_model: bool = False
42 supports_multimodal_inputs: bool = False
43 renderer_num_workers: int = 1
44 hidden_size: int = 768
45 dtype: torch.dtype = torch.float32
46
47 def get_hidden_size(self) -> int:
48 return self.hidden_size
49
50
51 @dataclass
52 class MockParallelConfig:
53 _api_process_rank: int = 0
54
55
56 @dataclass
57 class MockVllmConfig:
58 model_config: MockModelConfig
59 parallel_config: MockParallelConfig
60
61
62 @dataclass
63 class DummyTokenizer:
64 truncation_side: str = "left"
65 max_chars_per_token: int = 1
66 # Deliberately outside the range of ids `encode` returns, so a test can
67 # tell a pad token apart from a real one.
68 pad_token_id: int = 99999
69
70 def __post_init__(self) -> None:
71 self._captured_encode_kwargs: dict = {}
72 self._captured_text_len: int = 0
73
74 def decode(self, tokens: list[int]):
75 return str(tokens)
76
77 def encode(self, text: str, **kwargs):
78 self._captured_encode_kwargs = kwargs
79 self._captured_text_len = len(text)
80
81 in_length = len(text)
82 truncation = kwargs.get("truncation")
83 max_length = kwargs.get("max_length")
84 if truncation and max_length is not None:
85 return list(range(min(in_length, max_length)))
86
87 return list(range(in_length))
88
89 def __call__(self, text: str, **kwargs):
90 # BaseRenderer._tokenize_prompt calls the tokenizer via __call__ (to
91 # unify the output type), so mirror a real tokenizer's BatchEncoding.
92 return {"input_ids": self.encode(text, **kwargs)}
93
94
95 def _build_renderer(
96 model_config: MockModelConfig,
97 *,
98 truncation_side: str = "left",
99 max_chars_per_token: int = 1,
100 ):
101 renderer = HfRenderer(
102 MockVllmConfig(model_config, parallel_config=MockParallelConfig()),
103 tokenizer=(
104 None
105 if model_config.skip_tokenizer_init
106 else DummyTokenizer(
107 truncation_side=truncation_side,
108 max_chars_per_token=max_chars_per_token,
109 )
110 ),
111 )
112
113 return renderer
114
115
116 def _preprocess_prompt(
117 model_config: ModelConfig,
118 prompt_or_prompts: SingletonPrompt | bytes | Sequence[SingletonPrompt | bytes],
119 ):
120 return [
121 (
122 prompt
123 if isinstance(prompt, bytes)
124 else parse_model_prompt(model_config, prompt)
125 )
126 for prompt in prompt_to_seq(prompt_or_prompts)
127 ]
128
129
130 class TestValidatePrompt:
131 def test_empty_input(self):
132 renderer = _build_renderer(MockModelConfig())
133
134 with pytest.raises(ValueError, match="at least one prompt"):
135 renderer.render_prompts(_preprocess_prompt(renderer.model_config, []))
136
137 def test_invalid_type(self):
138 renderer = _build_renderer(MockModelConfig())
139
140 with pytest.raises(TypeError, match="should be a list of integers"):
141 renderer.render_prompts(
142 _preprocess_prompt(renderer.model_config, [[1, 2], ["foo", "bar"]])
143 )
144
145
146 class TestRenderPrompt:
147 def test_tokens_input(self):
148 renderer = _build_renderer(MockModelConfig())
149
150 tokens = [101, 7592, 2088]
151 prompts = renderer.render_prompts(
152 _preprocess_prompt(renderer.model_config, tokens)
153 )
154 results = renderer.tokenize_prompts(
155 prompts,
156 TokenizeParams(max_total_tokens=100),
157 )
158
159 assert len(results) == 1
160 assert results[0]["prompt_token_ids"] == tokens
161
162 def test_token_list_input(self):
163 renderer = _build_renderer(MockModelConfig())
164
165 token_lists = [[101, 7592, 2088], [102, 1234, 5678, 9012], [103, 4567]]
166 prompts = renderer.render_prompts(
167 _preprocess_prompt(renderer.model_config, token_lists)
168 )
169 results = renderer.tokenize_prompts(
170 prompts,
171 TokenizeParams(max_total_tokens=100),
172 )
173
174 assert len(results) == 3
175 assert results[0]["prompt_token_ids"] == [101, 7592, 2088]
176 assert results[1]["prompt_token_ids"] == [102, 1234, 5678, 9012]
177 assert results[2]["prompt_token_ids"] == [103, 4567]
178
179 def test_text_input(self):
180 renderer = _build_renderer(MockModelConfig())
181
182 text_input = "x" * 10
183 prompts = renderer.render_prompts(
184 _preprocess_prompt(renderer.model_config, text_input)
185 )
186 results = renderer.tokenize_prompts(
187 prompts,
188 TokenizeParams(max_total_tokens=100),
189 )
190
191 assert len(results) == 1
192 assert len(results[0]["prompt_token_ids"]) == 10
193
194 def test_text_list_input(self):
195 renderer = _build_renderer(MockModelConfig())
196
197 text_list_input = ["x" * 10, "x" * 12, "x" * 14]
198 prompts = renderer.render_prompts(
199 _preprocess_prompt(renderer.model_config, text_list_input)
200 )
201 results = renderer.tokenize_prompts(
202 prompts,
203 TokenizeParams(max_total_tokens=100),
204 )
205
206 assert len(results) == 3
207 for text_input, result in zip(text_list_input, results):
208 assert len(result["prompt_token_ids"]) == len(text_input)
209
210 def test_zero_truncation(self):
211 renderer = _build_renderer(MockModelConfig())
212
213 prompts = renderer.render_prompts(
214 _preprocess_prompt(renderer.model_config, "x" * 200)
215 )
216 results = renderer.tokenize_prompts(
217 prompts,
218 TokenizeParams(max_total_tokens=100, truncate_prompt_tokens=0),
219 )
220
221 assert len(results) == 1
222 assert len(results[0]["prompt_token_ids"]) == 0
223
224 def test_pos_truncation(self):
225 renderer = _build_renderer(MockModelConfig())
226
227 prompts = renderer.render_prompts(
228 _preprocess_prompt(renderer.model_config, "x" * 200)
229 )
230 results = renderer.tokenize_prompts(
231 prompts,
232 TokenizeParams(max_total_tokens=100, truncate_prompt_tokens=50),
233 )
234
235 assert len(results) == 1
236 assert len(results[0]["prompt_token_ids"]) == 50
237
238 def test_neg_truncation(self):
239 renderer = _build_renderer(MockModelConfig())
240
241 prompts = renderer.render_prompts(
242 _preprocess_prompt(renderer.model_config, "x" * 200)
243 )
244 results = renderer.tokenize_prompts(
245 prompts,
246 TokenizeParams(max_total_tokens=100, truncate_prompt_tokens=-1),
247 )
248
249 assert len(results) == 1
250 assert len(results[0]["prompt_token_ids"]) == 100 # max_total_tokens
251
252 def test_truncation_left(self):
253 renderer = _build_renderer(MockModelConfig(), truncation_side="left")
254
255 long_tokens = [100, 101, 102, 103, 104, 105, 106, 107, 108, 109] # 10 tokens
256 prompts = renderer.render_prompts(
257 _preprocess_prompt(renderer.model_config, long_tokens)
258 )
259 results = renderer.tokenize_prompts(
260 prompts,
261 TokenizeParams(max_total_tokens=100, truncate_prompt_tokens=5),
262 )
263
264 assert len(results) == 1
265 # Should keep the last 5 tokens: [105, 106, 107, 108, 109]
266 assert results[0]["prompt_token_ids"] == [105, 106, 107, 108, 109]
267
268 def test_truncation_right(self):
269 renderer = _build_renderer(MockModelConfig(), truncation_side="right")
270
271 long_tokens = [100, 101, 102, 103, 104, 105, 106, 107, 108, 109] # 10 tokens
272 prompts = renderer.render_prompts(
273 _preprocess_prompt(renderer.model_config, long_tokens)
274 )
275 results = renderer.tokenize_prompts(
276 prompts,
277 TokenizeParams(max_total_tokens=100, truncate_prompt_tokens=5),
278 )
279
280 assert len(results) == 1
281 # Should keep the first 5 tokens: [100, 101, 102, 103, 104]
282 assert results[0]["prompt_token_ids"] == [100, 101, 102, 103, 104]
283
284 def test_text_max_length_exceeded_obvious(self):
285 renderer = _build_renderer(MockModelConfig(), max_chars_per_token=1)
286
287 # Exceeds max_total_tokens and max_total_tokens * VLLM_MAX_CHARS_PER_TOKEN
288 long_tokens = "x" * 150
289 prompts = renderer.render_prompts(
290 _preprocess_prompt(renderer.model_config, long_tokens)
291 )
292
293 with pytest.raises(
294 VLLMValidationError,
295 match="maximum context length is",
296 ):
297 renderer.tokenize_prompts(
298 prompts,
299 TokenizeParams(max_total_tokens=100),
300 )
301
302 # Should not even attempt tokenization
303 assert renderer.tokenizer._captured_encode_kwargs == {}
304
305 def test_text_max_length_exceeded_nonobvious(self):
306 renderer = _build_renderer(MockModelConfig(), max_chars_per_token=2)
307
308 # Exceeds max_total_tokens but not max_total_tokens * VLLM_MAX_CHARS_PER_TOKEN
309 long_tokens = "x" * 150
310 prompts = renderer.render_prompts(
311 _preprocess_prompt(renderer.model_config, long_tokens)
312 )
313
314 with pytest.raises(
315 VLLMValidationError,
316 match="maximum context length is",
317 ):
318 renderer.tokenize_prompts(
319 prompts,
320 TokenizeParams(max_total_tokens=100),
321 )
322
323 # Should only tokenize the first max_total_tokens + 1 tokens
324 assert renderer.tokenizer._captured_encode_kwargs["truncation"] is True
325 assert renderer.tokenizer._captured_encode_kwargs["max_length"] == 101
326
327 def test_token_max_length_exceeded(self):
328 renderer = _build_renderer(MockModelConfig())
329
330 long_tokens = list(range(150)) # Exceeds max_total_tokens=100
331 prompts = renderer.render_prompts(
332 _preprocess_prompt(renderer.model_config, long_tokens)
333 )
334
335 with pytest.raises(
336 VLLMValidationError,
337 match="maximum context length is",
338 ):
339 renderer.tokenize_prompts(
340 prompts,
341 TokenizeParams(max_total_tokens=100, truncate_prompt_tokens=None),
342 )
343
344 def test_no_tokenizer_for_text(self):
345 renderer = _build_renderer(MockModelConfig(skip_tokenizer_init=True))
346
347 prompts = renderer.render_prompts(
348 _preprocess_prompt(renderer.model_config, "Hello world")
349 )
350
351 with pytest.raises(ValueError, match="`skip_tokenizer_init=True`"):
352 renderer.tokenize_prompts(
353 prompts,
354 TokenizeParams(max_total_tokens=100),
355 )
356
357 def test_tokens_input_with_needs_detokenization(self):
358 renderer = _build_renderer(MockModelConfig())
359
360 tokens = [1, 2, 3, 4]
361 prompts = renderer.render_prompts(
362 _preprocess_prompt(renderer.model_config, tokens)
363 )
364 results = renderer.tokenize_prompts(
365 prompts,
366 TokenizeParams(
367 max_total_tokens=100,
368 needs_detokenization=True,
369 ),
370 )
371
372 assert len(results) == 1
373 assert results[0]["prompt_token_ids"] == tokens
374 assert results[0]["prompt"] == "[1, 2, 3, 4]"
375
376 def test_explicit_side_tokenizer_unbounded(self):
377 renderer = _build_renderer(MockModelConfig())
378
379 prompts = renderer.render_prompts(
380 _preprocess_prompt(renderer.model_config, "x" * 500)
381 )
382 results = renderer.tokenize_prompts(
383 prompts,
384 TokenizeParams(
385 max_total_tokens=100,
386 truncate_prompt_tokens=4,
387 truncation_side="left",
388 ),
389 )
390
391 assert len(results) == 1
392 assert len(results[0]["prompt_token_ids"]) == 4
393
394 kwargs = renderer.tokenizer._captured_encode_kwargs
395 assert kwargs["truncation"] is False
396
397 def test_explicit_side_left_text(self):
398 renderer = _build_renderer(MockModelConfig())
399
400 prompts = renderer.render_prompts(
401 _preprocess_prompt(renderer.model_config, "x" * 50)
402 )
403 results = renderer.tokenize_prompts(
404 prompts,
405 TokenizeParams(
406 max_total_tokens=100,
407 truncate_prompt_tokens=5,
408 truncation_side="left",
409 ),
410 )
411
412 assert len(results) == 1
413 assert len(results[0]["prompt_token_ids"]) == 5
414 assert results[0]["prompt_token_ids"] == list(range(45, 50))
415
416 def test_explicit_side_right_text(self):
417 renderer = _build_renderer(MockModelConfig())
418
419 prompts = renderer.render_prompts(
420 _preprocess_prompt(renderer.model_config, "x" * 50)
421 )
422 results = renderer.tokenize_prompts(
423 prompts,
424 TokenizeParams(
425 max_total_tokens=100,
426 truncate_prompt_tokens=5,
427 truncation_side="right",
428 ),
429 )
430
431 assert len(results) == 1
432 assert len(results[0]["prompt_token_ids"]) == 5
433 assert results[0]["prompt_token_ids"] == list(range(5))
434
435 def test_padding_with_left_truncation_keeps_the_prompt(self):
436 """Padding must not be truncated away.
437
438 `padding` and `truncate_prompt_tokens`/`truncation_side` are all
439 settable on one pooling request. Padding to the full input length
440 before truncating from the left leaves a prompt made entirely of pad
441 tokens, and the request still succeeds -- so the model embeds nothing
442 but padding.
443 """
444 renderer = _build_renderer(MockModelConfig())
445 pad_id = renderer.tokenizer.pad_token_id
446
447 prompts = renderer.render_prompts(
448 _preprocess_prompt(renderer.model_config, "x" * 50)
449 )
450 results = renderer.tokenize_prompts(
451 prompts,
452 TokenizeParams(
453 max_total_tokens=100,
454 pad_prompt_tokens=-1,
455 truncate_prompt_tokens=5,
456 truncation_side="left",
457 ),
458 )
459
460 assert len(results) == 1
461 token_ids = results[0]["prompt_token_ids"]
462
463 # The sentinel: on a padding-first pipeline every surviving id is a
464 # pad token, so the prompt reaches the model with no content at all.
465 assert set(token_ids) != {pad_id}
466
467 # Transformers semantics: truncate to 5, then pad out to the full
468 # input length.
469 assert token_ids[:5] == list(range(45, 50))
470 assert token_ids[5:] == [pad_id] * 95
471
472 def test_padding_without_truncation_is_unchanged(self):
473 renderer = _build_renderer(MockModelConfig())
474 pad_id = renderer.tokenizer.pad_token_id
475
476 prompts = renderer.render_prompts(
477 _preprocess_prompt(renderer.model_config, "x" * 50)
478 )
479 results = renderer.tokenize_prompts(
480 prompts,
481 TokenizeParams(max_total_tokens=100, pad_prompt_tokens=-1),
482 )
483
484 assert len(results) == 1
485 assert results[0]["prompt_token_ids"] == list(range(50)) + [pad_id] * 50
486
487 def test_explicit_side_text_pretokenization_guard(self):
488 renderer = _build_renderer(MockModelConfig(), max_chars_per_token=1)
489
490 prompts = renderer.render_prompts(
491 _preprocess_prompt(renderer.model_config, "x" * 500)
492 )
493 results = renderer.tokenize_prompts(
494 prompts,
495 TokenizeParams(
496 max_total_tokens=100,
497 truncate_prompt_tokens=4,
498 truncation_side="left",
499 ),
500 )
501
502 assert len(results) == 1
503 assert len(results[0]["prompt_token_ids"]) == 4
504
505 assert renderer.tokenizer._captured_text_len <= 100
506
507
508 class TestRenderEmbedPrompt:
509 def _create_test_embed_bytes(self, tensor: torch.Tensor) -> bytes:
510 """Helper to create base64-encoded tensor bytes."""
511 buffer = io.BytesIO()
512 torch.save(tensor, buffer)
513 buffer.seek(0)
514 return pybase64.b64encode(buffer.read())
515
516 def test_single_prompt_embed(self):
517 renderer = _build_renderer(MockModelConfig())
518
519 # Create a test tensor
520 tensor_input = torch.randn(10, 768, dtype=torch.float32)
521 embed_bytes = self._create_test_embed_bytes(tensor_input)
522
523 prompts = renderer.render_prompts(
524 _preprocess_prompt(renderer.model_config, embed_bytes)
525 )
526 results = renderer.tokenize_prompts(
527 prompts,
528 TokenizeParams(max_total_tokens=100),
529 )
530
531 assert len(results) == 1
532 assert torch.equal(results[0]["prompt_embeds"], tensor_input)
533
534 def test_multiple_prompt_embeds(self):
535 hidden_size = 512
536 renderer = _build_renderer(MockModelConfig(hidden_size=hidden_size))
537
538 # Create multiple test tensors
539 tensor_inputs = [
540 torch.randn(8, hidden_size, dtype=torch.float32),
541 torch.randn(12, hidden_size, dtype=torch.float32),
542 ]
543
544 prompts = renderer.render_prompts(
545 _preprocess_prompt(
546 renderer.model_config,
547 [self._create_test_embed_bytes(t) for t in tensor_inputs],
548 )
549 )
550 results = renderer.tokenize_prompts(
551 prompts,
552 TokenizeParams(max_total_tokens=100),
553 )
554
555 assert len(results) == 2
556 for i, result in enumerate(results):
557 assert torch.allclose(result["prompt_embeds"], tensor_inputs[i])
558
559 def test_prompt_embed_truncation(self):
560 renderer = _build_renderer(MockModelConfig())
561
562 # Create tensor with more tokens than truncation limit
563 tensor_input = torch.randn(20, 768, dtype=torch.float32)
564
565 prompts = renderer.render_prompts(
566 _preprocess_prompt(
567 renderer.model_config, self._create_test_embed_bytes(tensor_input)
568 )
569 )
570 results = renderer.tokenize_prompts(
571 prompts,
572 TokenizeParams(
573 max_total_tokens=100,
574 truncate_prompt_tokens=10,
575 ),
576 )
577
578 assert len(results) == 1
579 # Should keep last 10 tokens
580 expected = tensor_input[-10:]
581 assert torch.equal(results[0]["prompt_embeds"], expected)
582
583 def test_prompt_embed_different_dtypes(self):
584 hidden_size = 256
585 # Test different supported dtypes
586 dtypes = [torch.float32, torch.float16, torch.bfloat16]
587
588 for dtype in dtypes:
589 renderer = _build_renderer(
590 MockModelConfig(hidden_size=hidden_size, dtype=dtype)
591 )
592 tensor_input = torch.randn(5, hidden_size, dtype=dtype)
593
594 prompts = renderer.render_prompts(
595 _preprocess_prompt(
596 renderer.model_config, self._create_test_embed_bytes(tensor_input)
597 )
598 )
599 results = renderer.tokenize_prompts(
600 prompts,
601 TokenizeParams(max_total_tokens=100),
602 )
603
604 assert len(results) == 1
605 assert results[0]["prompt_embeds"].dtype == dtype
606
607 def test_prompt_embed_squeeze_batch_dim(self):
608 renderer = _build_renderer(MockModelConfig())
609
610 # Test tensor with batch dimension gets squeezed
611 tensor_input = torch.randn(1, 10, 768, dtype=torch.float32)
612
613 prompts = renderer.render_prompts(
614 _preprocess_prompt(
615 renderer.model_config, self._create_test_embed_bytes(tensor_input)
616 )
617 )
618 results = renderer.tokenize_prompts(
619 prompts,
620 TokenizeParams(max_total_tokens=100),
621 )
622
623 assert len(results) == 1
624 # Should be squeezed to 2D
625 assert results[0]["prompt_embeds"].shape == (10, 768)
626
627 def test_both_prompts_and_embeds(self):
628 hidden_size = 256
629 renderer = _build_renderer(MockModelConfig(hidden_size=hidden_size))
630
631 text_input = "Hello world"
632 tensor_input = torch.randn(5, hidden_size, dtype=torch.float32)
633
634 prompts = renderer.render_prompts(
635 _preprocess_prompt(
636 renderer.model_config,
637 [text_input, self._create_test_embed_bytes(tensor_input)],
638 )
639 )
640 results = renderer.tokenize_prompts(
641 prompts,
642 TokenizeParams(max_total_tokens=100),
643 )
644
645 assert len(results) == 2
646 # First should be tokens prompt
647 assert "prompt_token_ids" in results[0]
648 assert len(results[0]["prompt_token_ids"]) == len(text_input)
649 # Second should be embed prompt
650 assert torch.equal(results[1]["prompt_embeds"], tensor_input)