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, 19:45 UTC
[V1] Detokenizer: Respect Stop Tokens + not include_stop_str_in_output (#14624)
Signed-off-by: Andrew Feldman <afeldman@neuralmagic.com>
afeldman-nm committed
Mar 13, 2025 at 15:07 UTC
02fcaa3d0a91364da4534ff2f3f6c867eb559149
4 files changed
+215
-18
tests/v1/engine/test_output_processor.py
+170
-9
@@ -470,22 +470,184 @@ def test_logprobs_processor(request_output_kind: RequestOutputKind,
470
assert not output_processor.has_unfinished_requests()
471
472
473
+@pytest.mark.parametrize(
474
+ "include_stop_str_in_output,stop_token_type,ignore_eos,num_sample_logprobs",
475
+ [(False, "stop_token_ids", False, None),
476
+ (True, "stop_token_ids", False, None),
477
+ (False, "stop_token_ids", False, NUM_SAMPLE_LOGPROBS_UNDER_TEST),
478
+ (True, "stop_token_ids", False, NUM_SAMPLE_LOGPROBS_UNDER_TEST),
479
+ (False, "eos_token_id", False, None), (True, "eos_token_id", False, None),
480
+ (False, "eos_token_id", True, None)])
481
+def test_stop_token(include_stop_str_in_output: bool,
482
+ num_sample_logprobs: Optional[int], stop_token_type: str,
483
+ ignore_eos: bool, dummy_test_vectors):
484
+ """Test output processor EOS/stop token handling.
485
+
486
+ Send mock engine core request to mock engine core and pass core outputs
487
+ to output processor. Validate output processor tokens, text and
488
+ (if enabled) sample logprobs. Batch-size one.
489
+
490
+ The test emulates a scenario where a model outputs text tokens followed
491
+ by two identical control tokens:
492
+ <token><token>...<token><control><control>
493
+
494
+ If EOS is under test, the control tokens are EOS; otherwise, they are
495
+ some other token id.
496
+
497
+ Test behavior:
498
+
499
+ * If EOS is under test and `ignore_eos=True`, the detokenized string
500
+ should be <token><token>...<token><control><control> and the finish
501
+ reason should be "length" (i.e. no stop occurs)
502
+
503
+ * else, if `include_stop_str_in_output==True`, the detokenized
504
+ string should be <token><token>...<token><control> and the finish
505
+ reason should be "stop" (i.e. first control token causes stop
506
+ and is represented in output text)
507
+
508
+ * else, the detokenized string should be
509
+ <token><token>...<token> and the finish reason should be "stop"
510
+ (i.e. first control token causes stop but is not represented
511
+ in output text.)
512
+
513
+ Note: some test details are tuned for meta-llama/Llama-3.2-1B,
514
+ another model should work only if the test is modified.
515
+
516
+ Args:
517
+ include_stop_str_in_output: stop token str appears in output text
518
+ num_sample_logprobs: number of sample logprobs (`None` for no logprobs)
519
+ stop_token_type: "eos_token_id" for EOS, "stop_token_ids" for stop token
520
+ ignore_eos: if True, EOS stops are disabled
521
+ dummy_test_vectors: dummy engine core outputs and other data structures
522
+ """
523
+ model_id = dummy_test_vectors.tokenizer.name_or_path
524
+ if model_id != 'meta-llama/Llama-3.2-1B':
525
+ raise AssertionError("Test requires meta-llama/Llama-3.2-1B but "
526
+ f"{model_id} is in use.")
527
+ do_logprobs = num_sample_logprobs is not None
528
+ # EOS under test; if False, stop_token_ids under test
529
+ is_eos_test = stop_token_type == "eos_token_id"
530
+ # EOS under test but ignore_eos enabled
531
+ is_eos_ignore_test = is_eos_test and ignore_eos
532
+ eos_token_id = (
533
+ dummy_test_vectors.tokenizer.eos_token_id if is_eos_test else None
534
+ ) # '<|end_of_text|>'
535
+ stop_token_ids = [128009] if not is_eos_test else None # '<|eot_id|>'
536
+
537
+ output_processor = OutputProcessor(dummy_test_vectors.tokenizer_group,
538
+ log_stats=False)
539
+ # Dummy engine core outputs, with control tokens suffixed to test stops
540
+ suffix_token = ([eos_token_id] if is_eos_test else stop_token_ids)
541
+ assert suffix_token is not None and isinstance(suffix_token[0], int)
542
+ generation_string = dummy_test_vectors.generation_strings[0]
543
+ generation_tokens = (dummy_test_vectors.generation_tokens[0] +
544
+ 2 * suffix_token)
545
+ if do_logprobs:
546
+ generation_logprobs = (
547
+ dummy_test_vectors.generation_logprobs[0] +
548
+ 2 * [dummy_test_vectors.generation_logprobs[0][-1]])
549
+ prompt_string = dummy_test_vectors.prompt_strings[0]
550
+ prompt_tokens = dummy_test_vectors.prompt_tokens[0]
551
+ engine_core = MockEngineCore(
552
+ tokens_list=[generation_tokens],
553
+ generated_logprobs_raw=[generation_logprobs] if do_logprobs else None,
554
+ prompt_logprobs_raw=None,
555
+ eos_token_id=eos_token_id,
556
+ stop_token_ids=stop_token_ids,
557
+ ignore_eos=ignore_eos)
558
+
559
+ # Make request.
560
+ request_id = "request-0"
561
+ request = EngineCoreRequest(
562
+ request_id=request_id,
563
+ prompt=prompt_string,
564
+ prompt_token_ids=prompt_tokens,
565
+ arrival_time=0,
566
+ mm_inputs=None,
567
+ mm_hashes=None,
568
+ mm_placeholders=None,
569
+ eos_token_id=eos_token_id,
570
+ lora_request=None,
571
+ sampling_params=SamplingParams(
572
+ skip_special_tokens=False,
573
+ spaces_between_special_tokens=False,
574
+ output_kind=RequestOutputKind.DELTA,
575
+ stop=[],
576
+ stop_token_ids=stop_token_ids,
577
+ include_stop_str_in_output=include_stop_str_in_output,
578
+ logprobs=num_sample_logprobs,
579
+ prompt_logprobs=None,
580
+ ignore_eos=ignore_eos,
581
+ ))
582
+
583
+ # Add request to the detokenizer.
584
+ output_processor.add_request(request)
585
+
586
+ # Loop over engine core steps; run output processor
587
+ gen_string = ""
588
+ gen_tokens = []
589
+ gen_logprobs = []
590
+ while True:
591
+ # Mock output from the EngineCore.
592
+ outputs = engine_core.get_outputs()
593
+ if len(outputs) == 0:
594
+ break
595
+
596
+ # Step the Detokenizer.
597
+ processed_outputs = output_processor.process_outputs(outputs)
598
+ request_outputs = processed_outputs.request_outputs
599
+ assert len(request_outputs) == 1
600
+ # Stop token does not rely on abort
601
+ assert not processed_outputs.reqs_to_abort
602
+
603
+ # Update tracking.
604
+ request_output = request_outputs[0]
605
+ if request_output.finished:
606
+ finish_reason = ("length" if is_eos_ignore_test else "stop")
607
+ assert request_output.outputs[0].finish_reason == finish_reason
608
+
609
+ gen_string += request_output.outputs[0].text
610
+ gen_tokens.extend(request_output.outputs[0].token_ids)
611
+ if do_logprobs:
612
+ gen_logprobs.extend(request_output.outputs[0].logprobs)
613
+
614
+ # Validate generated text
615
+ control_token = '<|end_of_text|>' if is_eos_test else '<|eot_id|>'
616
+ if is_eos_ignore_test:
617
+ # Length-based stop; expect full string
618
+ ref_str = generation_string + 2 * control_token
619
+ elif include_stop_str_in_output:
620
+ # Stop token triggered; include in output
621
+ ref_str = generation_string + control_token
622
+ else:
623
+ # Stop token triggered but not in output
624
+ ref_str = generation_string
625
+ assert gen_string == ref_str, (f"{gen_string=}, {ref_str=}")
626
+
627
+ if do_logprobs:
628
+ # Validate number of sample logprobs
629
+ num_tokens = len(gen_tokens)
630
+ num_logprobs = len(gen_logprobs)
631
+ assert num_tokens == num_logprobs, (
632
+ f"Token count ({num_tokens}) != logprobs count ({num_logprobs})")
633
+
634
+ # Check requests are finished
635
+ assert output_processor.get_num_unfinished_requests() == 0
636
+ assert not output_processor.has_unfinished_requests()
637
+
638
+
639
@pytest.mark.parametrize("include_stop_str_in_output", [True, False])
640
@pytest.mark.parametrize("num_sample_logprobs",
641
[None, NUM_SAMPLE_LOGPROBS_UNDER_TEST])
476
-@pytest.mark.parametrize("num_prompt_logprobs",
477
- [None, NUM_PROMPT_LOGPROBS_UNDER_TEST])
642
def test_stop_string(include_stop_str_in_output: bool,
479
- num_sample_logprobs: Optional[int],
480
- num_prompt_logprobs: Optional[int], dummy_test_vectors):
643
+ num_sample_logprobs: Optional[int], dummy_test_vectors):
644
output_processor = OutputProcessor(dummy_test_vectors.tokenizer_group,
645
log_stats=False)
646
engine_core = MockEngineCore(
647
tokens_list=dummy_test_vectors.generation_tokens,
648
generated_logprobs_raw=dummy_test_vectors.generation_logprobs
649
if num_sample_logprobs else None,
487
- prompt_logprobs_raw=dummy_test_vectors.prompt_logprobs
488
- if num_prompt_logprobs else None)
650
+ prompt_logprobs_raw=None)
651
652
# Make N requests.
653
request_id_list = [
@@ -510,7 +672,7 @@ def test_stop_string(include_stop_str_in_output: bool,
672
stop=STOP_STRINGS,
673
include_stop_str_in_output=include_stop_str_in_output,
674
logprobs=num_sample_logprobs,
513
- prompt_logprobs=num_prompt_logprobs,
675
+ prompt_logprobs=None,
676
)) for idx, (prompt, prompt_tokens) in enumerate(
677
zip(dummy_test_vectors.prompt_strings,
678
dummy_test_vectors.prompt_tokens))
@@ -594,8 +756,7 @@ def test_stop_string(include_stop_str_in_output: bool,
756
# Confirmed tracked logprobs match what we expect
757
_validate_logprobs(gen_tokens, gen_logprobs, gen_prompt_logprobs,
758
gen_cumulative_logprobs, dummy_test_vectors,
597
- request_id_list, num_sample_logprobs,
598
- num_prompt_logprobs)
759
+ request_id_list, num_sample_logprobs, None)
760
761
assert output_processor.get_num_unfinished_requests() == 0
762
assert not output_processor.has_unfinished_requests()
tests/v1/engine/utils.py
+20
-3
@@ -20,7 +20,7 @@ NUM_SAMPLE_LOGPROBS_UNDER_TEST = 5
20
# Number of prompt logprobs to request when testing prompt logprobs
21
NUM_PROMPT_LOGPROBS_UNDER_TEST = 7
22
23
-TOKENIZER_NAME = "mistralai/Mistral-7B-Instruct-v0.3"
23
+TOKENIZER_NAME = "meta-llama/Llama-3.2-1B"
24
25
FULL_STRINGS = [
26
"My name is Robert from Neural Magic and I love working on vLLM so much!",
@@ -330,13 +330,21 @@ class MockEngineCore:
330
# each matrix has dimensions
331
# (num prompt toks) x (num prompt logprobs+1)
332
prompt_logprobs_raw: Optional[list[LogprobsTensors]] = None,
333
+ eos_token_id: Optional[int] = None,
334
+ stop_token_ids: Optional[list[int]] = None,
335
+ ignore_eos: bool = False,
336
) -> None:
337
+ self.num_requests = len(tokens_list)
338
self.tokens_list = tokens_list
339
self.current_idx = 0
340
self.generated_logprobs_raw = generated_logprobs_raw
341
self.do_logprobs = generated_logprobs_raw is not None
342
self.prompt_logprobs_raw = prompt_logprobs_raw
343
self.do_prompt_logprobs = prompt_logprobs_raw is not None
344
+ self.request_finished = [False for _ in range(self.num_requests)]
345
+ self.eos_token_id = eos_token_id
346
+ self.stop_token_ids = stop_token_ids
347
+ self.ignore_eos = ignore_eos
348
349
def get_outputs(self) -> list[EngineCoreOutput]:
350
do_logprobs = self.do_logprobs
@@ -345,7 +353,7 @@ class MockEngineCore:
353
354
outputs = []
355
for req_idx, token_ids in enumerate(self.tokens_list):
348
- if len(token_ids) > token_idx:
356
+ if not self.request_finished[req_idx]:
357
if do_logprobs:
358
assert self.generated_logprobs_raw is not None
359
(logprobs_token_ids_, logprobs_, sampled_token_ranks_) = (
@@ -365,14 +373,23 @@ class MockEngineCore:
373
prompt_logprobs = None
374
else:
375
prompt_logprobs = None
376
+ new_token_id = token_ids[token_idx]
377
output = EngineCoreOutput(
378
request_id=f"request-{req_idx}",
370
- new_token_ids=[token_ids[token_idx]],
379
+ new_token_ids=[new_token_id],
380
new_logprobs=logprobs,
381
new_prompt_logprobs_tensors=prompt_logprobs,
382
)
383
if token_idx == len(token_ids) - 1:
384
+ output.finish_reason = FinishReason.LENGTH
385
+ self.request_finished[req_idx] = True
386
+ if not self.ignore_eos and new_token_id == self.eos_token_id:
387
output.finish_reason = FinishReason.STOP
388
+ self.request_finished[req_idx] = True
389
+ if new_token_id in (self.stop_token_ids or ()):
390
+ output.finish_reason = FinishReason.STOP
391
+ output.stop_reason = new_token_id
392
+ self.request_finished[req_idx] = True
393
outputs.append(output)
394
395
self.current_idx += 1
vllm/v1/engine/detokenizer.py
+22
-3
@@ -88,7 +88,8 @@ class IncrementalDetokenizer:
88
stop_buffer_length=stop_buffer_length,
89
)
90
91
- def update(self, new_token_ids: list[int]) -> Optional[str]:
91
+ def update(self, new_token_ids: list[int],
92
+ stop_terminated: bool) -> Optional[str]:
93
"""
94
Update RequestState for the request_id by:
95
1) Detokenize the new token ids incrementally.
@@ -96,11 +97,22 @@ class IncrementalDetokenizer:
97
98
Return matched stop string or None.
99
"""
99
-
100
+ if not new_token_ids:
101
+ # Skip detokenization if no new token ids
102
+ return None
103
if self.tokenizer is None:
104
+ # Skip detokenization if no tokenizer
105
self.token_ids.extend(new_token_ids)
106
return None
107
108
+ if stop_terminated and not self.include_stop_str_in_output:
109
+ # If stop-terminated, exclude last token from detokenization
110
+ # based on include_stop_str_in_output parameter.
111
+ skipped_stop_token_id = new_token_ids[-1]
112
+ new_token_ids = new_token_ids[:-1]
113
+ else:
114
+ skipped_stop_token_id = None
115
+
116
# 1) Detokenize the new token ids incrementally.
117
# TODO(woosuk): This method becomes very inefficient when the number of
118
# new_token_ids is more than 1. We need to optimize this.
@@ -127,7 +139,14 @@ class IncrementalDetokenizer:
139
140
self.output_text += decoded_text
141
130
- # 2) Evaluate stop criteria.
142
+ if stop_terminated:
143
+ if skipped_stop_token_id is not None:
144
+ # Cleanup after skipping detokenization
145
+ self.token_ids.append(skipped_stop_token_id)
146
+ # Stop token triggered; skip stop string check
147
+ return None
148
+
149
+ # 2) Evaluate stop strings.
150
stop_string = None
151
if self.stop:
152
stop = StopChecker.check_stop_strings(
vllm/v1/engine/output_processor.py
+3
-3
@@ -299,9 +299,9 @@ class OutputProcessor:
299
# in the EngineCore.
300
req_state.is_prefilling = not new_token_ids
301
302
- # 2) Detokenize the token ids into text and check for stop
303
- # strings.
304
- stop_string = req_state.detokenizer.update(new_token_ids)
302
+ # 2) Detokenize the token ids into text and perform stop checks.
303
+ stop_string = req_state.detokenizer.update(
304
+ new_token_ids, finish_reason == FinishReason.STOP)
305
if stop_string and finish_reason != FinishReason.STOP:
306
finish_reason = FinishReason.STOP
307
stop_reason = stop_string