[Rust Frontend] Feed parser tests and benchmarks attributed input (#59048)

Signed-off-by: Bugen Zhao <i@bugenzhao.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

Bugen Zhao committed Sep 29, 2026 at 14:23 UTC e05095c9e1ae37f208c1ad70b08c17f1152878fc
7 files changed +334 -142
rust/src/chat/tests/roundtrip.rs
+27 -7
@@ -780,10 +780,29 @@ fn decoded_completion_stream(
780 assistant_stop_suffix, text
781 )
782 })?;
783 + // The engine returns token ids, so attribute the rendered text
784 + // through the production decoder, then split it into small char
785 + // chunks to exercise marker splits.
786 + let token_ids =
787 + tokenizer.encode(body, false).context("failed to encode text completion")?;
788 + let mut rest = vllm_text::DecodedText::default();
789 + for chunk in incremental_decode_chunks(
790 + tokenizer,
791 + &prompt_token_ids,
792 + &token_ids,
793 + TOKEN_COMPLETION_CHUNK_TOKENS,
794 + )? {
795 + rest.append(chunk.delta);
796 + }
797 + ensure!(
798 + rest.text == body,
799 + "text completion must roundtrip through the tokenizer\nbody: {body:?}\ndecoded: {:?}",
800 + rest.text
801 + );
802 split_by_chars(body, TEXT_COMPLETION_CHUNK_CHARS)
803 .into_iter()
804 .map(|delta| DecodedCompletionChunk {
786 - delta,
805 + delta: rest.drain_prefix(delta.len()),
806 token_ids: Vec::new(), // unused for text-level roundtrip cases
807 })
808 .collect()
@@ -836,7 +855,7 @@ fn decoded_completion_stream(
855 sampling_mask: None,
856 });
857 events.push(DecodedTextEvent::TextDelta {
839 - decoded: vllm_text::DecodedText::unattributed(chunk.delta),
858 + decoded: chunk.delta,
859 sampled: vllm_text::SampledDelta {
860 token_ids: chunk.token_ids,
861 logprobs: None,
@@ -851,7 +870,8 @@ fn decoded_completion_stream(
870
871 /// One decoded completion chunk fed into the output processor.
872 struct DecodedCompletionChunk {
854 - delta: String,
873 + /// Attributed for token-id completions, as the production decoder emits it.
874 + delta: vllm_text::DecodedText,
875 token_ids: Vec<u32>,
876 }
877
@@ -893,11 +913,11 @@ fn incremental_decode_chunks(
913 let mut decoder = tokenizer.create_decode_stream(prompt_token_ids, false, 0);
914 let mut chunks = Vec::new();
915 for chunk_token_ids in split_by_count(token_ids, chunk_size) {
896 - let mut delta = String::new();
916 + let mut delta = vllm_text::DecodedText::default();
917 for token_id in chunk_token_ids.iter().copied() {
918 decoder.push_token(token_id)?;
919 while let Some(chunk) = decoder.next_chunk() {
900 - delta.push_str(&chunk.text);
920 + delta.append(chunk);
921 }
922 }
923 chunks.push(DecodedCompletionChunk {
@@ -909,10 +929,10 @@ fn incremental_decode_chunks(
929 let (last_chunk, _) = decoder.flush(None)?;
930 if let Some(last_chunk) = last_chunk {
931 if let Some(delta) = chunks.last_mut() {
912 - delta.delta.push_str(&last_chunk.text);
932 + delta.delta.append(last_chunk);
933 } else {
934 chunks.push(DecodedCompletionChunk {
915 - delta: last_chunk.text,
935 + delta: last_chunk,
936 token_ids: Vec::new(),
937 });
938 }
rust/src/parser/Cargo.toml
+5
@@ -81,5 +81,10 @@ name = "granite4"
81 harness = false
82 required-features = ["test-util"]
83
84 +[[bench]]
85 +name = "kimi_k3"
86 +harness = false
87 +required-features = ["test-util"]
88 +
89 [lints]
90 workspace = true
rust/src/parser/benches/gemma4.rs
+33 -19
@@ -1,15 +1,17 @@
1 // SPDX-License-Identifier: Apache-2.0
2 // SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4 +use std::sync::Arc;
5 use std::time::Duration;
6
7 use criterion::{BatchSize, Criterion, Throughput, black_box, criterion_group, criterion_main};
7 -use vllm_parser::tool::test_utils::{split_by_chars, test_tools};
8 -use vllm_parser::tool::{Tool, ToolParser};
8 +use vllm_parser::tool::Tool;
9 +use vllm_parser::tool::test_utils::test_tools;
10 use vllm_parser::unified::Gemma4UnifiedParser;
11 +use vllm_tokenizer::test_utils::TestTokenizer;
12
13 mod utils;
12 -use utils::{UnifiedToolParserAdapter, feed_parser};
14 +use utils::{attributed_chunks, feed_unified_parser};
15
16 const CHUNK_CHARS: usize = 7;
17 const LONG_NORMAL_TEXT_REPEATS: usize = 2048;
@@ -71,8 +73,17 @@ fn long_tool_argument_fixture() -> String {
73 )
74 }
75
74 -fn parser(tools: &[Tool]) -> Box<dyn ToolParser> {
75 - UnifiedToolParserAdapter::<Gemma4UnifiedParser>::create(tools)
76 +fn tokenizer() -> TestTokenizer {
77 + TestTokenizer::new()
78 + .with_special_token("<|tool_call>", 256)
79 + .with_special_token("<tool_call|>", 257)
80 + .with_special_token("<|\"|>", 258)
81 + .with_special_token("<|channel>", 259)
82 + .with_special_token("<channel|>", 260)
83 +}
84 +
85 +fn parser(tools: &[Tool], tokenizer: &TestTokenizer) -> Gemma4UnifiedParser {
86 + Gemma4UnifiedParser::new(tools, Arc::new(tokenizer.clone()))
87 .expect("Gemma4 unified parser should initialize")
88 }
89
@@ -85,7 +96,14 @@ fn run_stream_group(
96 expected_normal_text: &str,
97 expected_calls_len: usize,
98 ) {
88 - let chunks = split_by_chars(text, chunk_chars);
99 + let tokenizer = tokenizer();
100 + let chunks = attributed_chunks(&tokenizer, text, chunk_chars);
101 +
102 + // Check the fixture once, outside the measurement: a marker lost to
103 + // missing attribution would silently turn the whole stream into content.
104 + let summary = feed_unified_parser(&mut parser(tools, &tokenizer), &[], chunks.clone());
105 + assert_eq!(summary.normal_text, expected_normal_text, "{name}");
106 + assert_eq!(summary.calls_len, expected_calls_len, "{name}");
107
108 let mut group = c.benchmark_group(name);
109 group.sample_size(50);
@@ -94,23 +112,19 @@ fn run_stream_group(
112 group.throughput(Throughput::Bytes(text.len() as u64));
113
114 group.bench_function("reuse_parser", |b| {
97 - let mut parser = parser(tools);
98 - b.iter(|| {
99 - let result = feed_parser(&mut *parser, black_box(&chunks));
100 - debug_assert_eq!(result.0, expected_normal_text);
101 - debug_assert_eq!(result.1, expected_calls_len);
102 - black_box(result);
103 - })
115 + let mut parser = parser(tools, &tokenizer);
116 + b.iter_batched(
117 + || chunks.clone(),
118 + |chunks| black_box(feed_unified_parser(&mut parser, &[], black_box(chunks))),
119 + BatchSize::SmallInput,
120 + )
121 });
122
123 group.bench_function("create_parser", |b| {
124 b.iter_batched(
108 - || parser(tools),
109 - |mut parser| {
110 - let result = feed_parser(&mut *parser, black_box(&chunks));
111 - debug_assert_eq!(result.0, expected_normal_text);
112 - debug_assert_eq!(result.1, expected_calls_len);
113 - black_box(result);
125 + || (parser(tools, &tokenizer), chunks.clone()),
126 + |(mut parser, chunks)| {
127 + black_box(feed_unified_parser(&mut parser, &[], black_box(chunks)))
128 },
129 BatchSize::SmallInput,
130 )
rust/src/parser/benches/kimi_k3.rs new
+191
@@ -0,0 +1,191 @@
1 +// SPDX-License-Identifier: Apache-2.0
2 +// SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +
4 +use std::sync::Arc;
5 +use std::time::Duration;
6 +
7 +use criterion::{BatchSize, Criterion, Throughput, black_box, criterion_group, criterion_main};
8 +use vllm_parser::unified::KimiK3UnifiedParser;
9 +use vllm_tokenizer::Tokenizer;
10 +use vllm_tokenizer::test_utils::TestTokenizer;
11 +
12 +mod utils;
13 +use utils::{attributed_chunks, feed_unified_parser};
14 +
15 +const CHUNK_CHARS: usize = 7;
16 +const LONG_REASONING_REPEATS: usize = 2048;
17 +const LONG_TOOL_ARGUMENT_REPEATS: usize = 256;
18 +
19 +/// The chat generation prompt for thinking mode ends inside the `think`
20 +/// channel, so the output starts with reasoning.
21 +const THINKING_PROMPT_TAIL: &str = "<|open|>think<|sep|>";
22 +
23 +fn tokenizer() -> TestTokenizer {
24 + TestTokenizer::new()
25 + .with_special_token("<|open|>", 256)
26 + .with_special_token("<|close|>", 257)
27 + .with_special_token("<|sep|>", 258)
28 + .with_special_token("<|end_of_msg|>", 259)
29 +}
30 +
31 +fn argument(key: &str, arg_type: &str, value: &str) -> String {
32 + format!(
33 + "<|open|>argument key=\"{key}\" type=\"{arg_type}\"<|sep|>{value}<|close|>argument<|sep|>"
34 + )
35 +}
36 +
37 +fn call(tool: &str, index: usize, body: &str) -> String {
38 + format!("<|open|>call tool=\"{tool}\" index=\"{index}\"<|sep|>{body}<|close|>call<|sep|>")
39 +}
40 +
41 +fn message(reasoning: &str, response: &str, calls: &str) -> String {
42 + format!(
43 + "{reasoning}<|close|>think<|sep|>\
44 + <|open|>response<|sep|>{response}<|close|>response<|sep|>\
45 + <|open|>tools<|sep|>{calls}<|close|>tools<|sep|>\
46 + <|close|>message<|sep|>"
47 + )
48 +}
49 +
50 +fn mixed_fixture() -> String {
51 + let calls = [
52 + call(
53 + "convert",
54 + 1,
55 + &[
56 + argument("whole", "number", "114.514"),
57 + argument("flag", "boolean", "true"),
58 + argument("name", "string", "demo"),
59 + argument("payload", "object", r#"{"count":42,"tags":["red","blue"]}"#),
60 + ]
61 + .concat(),
62 + ),
63 + call("update_record", 2, &argument("id", "integer", "7")),
64 + ]
65 + .concat();
66 + message(
67 + "I should inspect the data first.",
68 + "I will convert it.",
69 + &calls,
70 + )
71 +}
72 +
73 +fn long_reasoning_fixture() -> String {
74 + let line = "Ordinary reasoning text with no K3 channel markers at all.\n";
75 + message(&line.repeat(LONG_REASONING_REPEATS), "Done.", "")
76 +}
77 +
78 +fn long_tool_argument_fixture() -> String {
79 + let line = "<section><p>Literal <tag> and quotes \" inside content.</p></section>\n";
80 + let content = line.repeat(LONG_TOOL_ARGUMENT_REPEATS);
81 + let body = [
82 + argument("path", "string", "index.html"),
83 + argument("content", "string", &content),
84 + ]
85 + .concat();
86 + message(
87 + "Write the file.",
88 + "I will write the file.",
89 + &call("write_file", 1, &body),
90 + )
91 +}
92 +
93 +fn parser(tokenizer: &TestTokenizer) -> KimiK3UnifiedParser {
94 + KimiK3UnifiedParser::new(&[], Arc::new(tokenizer.clone()))
95 + .expect("Kimi K3 unified parser should initialize")
96 +}
97 +
98 +fn run_stream_group(
99 + c: &mut Criterion,
100 + name: &str,
101 + text: &str,
102 + expected_normal_text: &str,
103 + expected_reasoning_len: usize,
104 + expected_calls_len: usize,
105 +) {
106 + let tokenizer = tokenizer();
107 + let prompt_token_ids = tokenizer.encode(THINKING_PROMPT_TAIL, false).unwrap();
108 + let chunks = attributed_chunks(&tokenizer, text, CHUNK_CHARS);
109 +
110 + // Check the fixture once, outside the measurement: a marker lost to
111 + // missing attribution would silently turn the whole stream into content.
112 + let summary = feed_unified_parser(&mut parser(&tokenizer), &prompt_token_ids, chunks.clone());
113 + assert_eq!(summary.normal_text, expected_normal_text, "{name}");
114 + assert_eq!(
115 + summary.reasoning_text.len(),
116 + expected_reasoning_len,
117 + "{name}"
118 + );
119 + assert_eq!(summary.calls_len, expected_calls_len, "{name}");
120 +
121 + let mut group = c.benchmark_group(name);
122 + group.sample_size(50);
123 + group.warm_up_time(Duration::from_millis(300));
124 + group.measurement_time(Duration::from_secs(2));
125 + group.throughput(Throughput::Bytes(text.len() as u64));
126 +
127 + group.bench_function("reuse_parser", |b| {
128 + let mut parser = parser(&tokenizer);
129 + b.iter_batched(
130 + || chunks.clone(),
131 + |chunks| {
132 + black_box(feed_unified_parser(
133 + &mut parser,
134 + &prompt_token_ids,
135 + black_box(chunks),
136 + ))
137 + },
138 + BatchSize::SmallInput,
139 + )
140 + });
141 +
142 + group.bench_function("create_parser", |b| {
143 + b.iter_batched(
144 + || (parser(&tokenizer), chunks.clone()),
145 + |(mut parser, chunks)| {
146 + black_box(feed_unified_parser(
147 + &mut parser,
148 + &prompt_token_ids,
149 + black_box(chunks),
150 + ))
151 + },
152 + BatchSize::SmallInput,
153 + )
154 + });
155 +
156 + group.finish();
157 +}
158 +
159 +fn bench_kimi_k3(c: &mut Criterion) {
160 + run_stream_group(
161 + c,
162 + "kimi_k3/mixed_complex_tool_call",
163 + &mixed_fixture(),
164 + "I will convert it.",
165 + "I should inspect the data first.".len(),
166 + 2,
167 + );
168 +
169 + let long_reasoning = long_reasoning_fixture();
170 + let reasoning_len = long_reasoning.find("<|close|>think").unwrap();
171 + run_stream_group(
172 + c,
173 + "kimi_k3/long_reasoning",
174 + &long_reasoning,
175 + "Done.",
176 + reasoning_len,
177 + 0,
178 + );
179 +
180 + run_stream_group(
181 + c,
182 + "kimi_k3/long_tool_argument",
183 + &long_tool_argument_fixture(),
184 + "I will write the file.",
185 + "Write the file.".len(),
186 + 1,
187 + );
188 +}
189 +
190 +criterion_group!(benches, bench_kimi_k3);
191 +criterion_main!(benches);
rust/src/parser/benches/utils/adapter.rs deleted
-114
@@ -1,114 +0,0 @@
1 -// SPDX-License-Identifier: Apache-2.0
2 -// SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 -
4 -use std::sync::Arc;
5 -
6 -use vllm_parser::tool::{Result, Tool, ToolParser, ToolParserError, ToolParserOutput};
7 -use vllm_parser::unified::{
8 - UnifiedParser, UnifiedParserError, UnifiedParserEvent, UnifiedParserOutput,
9 -};
10 -use vllm_tokenizer::{DecodedText, Tokenizer};
11 -
12 -/// Tokenizer stub used by unified-parser benchmarks.
13 -struct BenchTokenizer;
14 -
15 -impl Tokenizer for BenchTokenizer {
16 - fn encode(&self, text: &str, _add_special_tokens: bool) -> vllm_tokenizer::Result<Vec<u32>> {
17 - Ok(text.chars().map(|_| u32::MAX).collect())
18 - }
19 -
20 - fn encode_ordinary(&self, text: &str) -> vllm_tokenizer::Result<Vec<u32>> {
21 - self.encode(text, false)
22 - }
23 -
24 - fn decode(
25 - &self,
26 - token_ids: &[u32],
27 - _skip_special_tokens: bool,
28 - ) -> vllm_tokenizer::Result<String> {
29 - Ok("\u{FFFD}".repeat(token_ids.len()))
30 - }
31 -
32 - fn token_to_id(&self, _token: &str) -> Option<u32> {
33 - Some(u32::MAX)
34 - }
35 -
36 - fn id_to_token(&self, _id: u32) -> Option<String> {
37 - Some("\u{FFFD}".to_string())
38 - }
39 -}
40 -
41 -/// Bench-only adapter that exposes a unified parser through the tool-parser
42 -/// benchmark harness.
43 -///
44 -/// Returns error if the unified parser produces reasoning events.
45 -pub struct UnifiedToolParserAdapter<T> {
46 - inner: Box<dyn UnifiedParser>,
47 - _marker: std::marker::PhantomData<T>,
48 -}
49 -
50 -fn map_unified_error(error: UnifiedParserError) -> ToolParserError {
51 - ToolParserError::ParsingFailed {
52 - message: format!("unified parser failed: {error}"),
53 - }
54 -}
55 -
56 -fn append_unified_output(
57 - output: UnifiedParserOutput,
58 - tool_output: &mut ToolParserOutput,
59 -) -> Result<()> {
60 - for event in output.events {
61 - match event {
62 - UnifiedParserEvent::Text(text) => tool_output.push_text(text),
63 - UnifiedParserEvent::ToolCall(call) => tool_output.push_call(call),
64 - UnifiedParserEvent::Reasoning(_) => {
65 - return Err(ToolParserError::ParsingFailed {
66 - message: "unified parser emitted reasoning in tool-parser adapter".to_string(),
67 - });
68 - }
69 - }
70 - }
71 - Ok(())
72 -}
73 -
74 -impl<T: UnifiedParser> ToolParser for UnifiedToolParserAdapter<T> {
75 - fn create(tools: &[Tool]) -> Result<Box<dyn ToolParser>>
76 - where
77 - Self: Sized + 'static,
78 - {
79 - let inner = T::create(tools, Arc::new(BenchTokenizer)).map_err(map_unified_error)?;
80 - Ok(Box::new(Self {
81 - inner,
82 - _marker: std::marker::PhantomData,
83 - }))
84 - }
85 -
86 - fn preserve_special_tokens(&self) -> bool {
87 - self.inner.preserve_special_tokens()
88 - }
89 -
90 - fn tool_call_id(&self, tool_index: usize) -> Option<&str> {
91 - self.inner.tool_call_id(tool_index)
92 - }
93 -
94 - fn parse_into(&mut self, chunk: &str, output: &mut ToolParserOutput) -> Result<()> {
95 - let mut unified_output = UnifiedParserOutput::default();
96 - let result = self
97 - .inner
98 - .parse_into(DecodedText::unattributed(chunk), &mut unified_output)
99 - .map_err(map_unified_error);
100 - append_unified_output(unified_output, output)?;
101 - result
102 - }
103 -
104 - fn finish(&mut self) -> Result<ToolParserOutput> {
105 - let unified_output = self.inner.finish().map_err(map_unified_error)?;
106 - let mut output = ToolParserOutput::default();
107 - append_unified_output(unified_output, &mut output)?;
108 - Ok(output)
109 - }
110 -
111 - fn reset(&mut self) -> String {
112 - self.inner.reset()
113 - }
114 -}
rust/src/parser/benches/utils/mod.rs
+2 -2
@@ -6,12 +6,12 @@
6 #![allow(dead_code)]
7 #![allow(unused_imports)]
8
9 -mod adapter;
9 +mod unified;
10
11 -pub(super) use adapter::UnifiedToolParserAdapter;
11 use futures::FutureExt as _;
12 use openai_protocol::common::{Function as OpenAiFunction, Tool as OpenAiTool};
13 use tool_parser::traits::ToolParser as ExternalToolParser;
14 +pub(super) use unified::{attributed_chunks, feed_unified_parser};
15 use vllm_parser::tool::test_utils::collect_stream;
16 use vllm_parser::tool::{Tool, ToolParser};
17
rust/src/parser/benches/utils/unified.rs new
+76
@@ -0,0 +1,76 @@
1 +// SPDX-License-Identifier: Apache-2.0
2 +// SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +
4 +use std::collections::BTreeSet;
5 +
6 +use vllm_parser::tool::test_utils::split_by_chars;
7 +use vllm_parser::unified::{UnifiedParser, UnifiedParserEvent, UnifiedParserOutput};
8 +use vllm_tokenizer::{DecodedText, Tokenizer};
9 +
10 +/// Split `text` into token-attributed chunks of at most `chunk_chars`
11 +/// characters, as the frontend streams them to a unified parser.
12 +///
13 +/// The whole text is encoded with special tokens and run through the
14 +/// incremental decoder once before splitting, so a marker cut by a chunk
15 +/// boundary keeps its special-token anchor. Prepare chunks outside the measured
16 +/// routine.
17 +pub fn attributed_chunks(
18 + tokenizer: &dyn Tokenizer,
19 + text: &str,
20 + chunk_chars: usize,
21 +) -> Vec<DecodedText> {
22 + let mut decoder = tokenizer.create_decode_stream(&[], false, 0);
23 + for token_id in tokenizer.encode(text, false).expect("fixture should encode") {
24 + decoder.push_token(token_id).expect("fixture token should decode");
25 + }
26 + let mut rest = decoder.flush(None).expect("decoder should flush").1;
27 + assert_eq!(
28 + rest.text, text,
29 + "fixture should round-trip through the tokenizer"
30 + );
31 +
32 + split_by_chars(text, chunk_chars)
33 + .into_iter()
34 + .map(|chunk| rest.drain_prefix(chunk.len()))
35 + .collect()
36 +}
37 +
38 +/// Event totals of one parsed stream, for benchmark sanity checks.
39 +#[derive(Debug, Default)]
40 +pub struct UnifiedStreamSummary {
41 + pub normal_text: String,
42 + pub reasoning_text: String,
43 + pub calls_len: usize,
44 +}
45 +
46 +/// Run one request stream through `parser`: initialize it from the prompt,
47 +/// feed every chunk, and finish.
48 +pub fn feed_unified_parser(
49 + parser: &mut dyn UnifiedParser,
50 + prompt_token_ids: &[u32],
51 + chunks: Vec<DecodedText>,
52 +) -> UnifiedStreamSummary {
53 + parser.initialize(prompt_token_ids).expect("parser should initialize");
54 +
55 + let mut output = UnifiedParserOutput::default();
56 + for chunk in chunks {
57 + parser.parse_into(chunk, &mut output).expect("chunk should parse");
58 + }
59 + output.append(parser.finish().expect("stream should finish"));
60 +
61 + let mut summary = UnifiedStreamSummary::default();
62 + let mut tool_indices = BTreeSet::new();
63 + for event in output.events {
64 + match event {
65 + UnifiedParserEvent::Text(text) => summary.normal_text.push_str(&text),
66 + UnifiedParserEvent::Reasoning(reasoning) => {
67 + summary.reasoning_text.push_str(&reasoning.text);
68 + }
69 + UnifiedParserEvent::ToolCall(call) => {
70 + tool_indices.insert(call.tool_index);
71 + }
72 + }
73 + }
74 + summary.calls_len = tool_indices.len();
75 + summary
76 +}