Skip to content

Commit 3c00e81

Browse files
authored
Optimize XML tool parsers with incremental streaming and fast-path buffering (#4664)
* Optimize XML tool parsers with incremental streaming and fast-path buffering * remove over-defensive code * Refine XML tool parser incremental state and remove redundant scanning.
1 parent c296beb commit 3c00e81

5 files changed

Lines changed: 697 additions & 61 deletions

File tree

Lines changed: 394 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,394 @@
1+
#!/usr/bin/env python3
2+
# Copyright (c) OpenMMLab. All rights reserved.
3+
"""Benchmark XML tool parsers: legacy (main) vs optimized implementations.
4+
5+
Usage:
6+
python benchmark/benchmark_xml_tool_parser.py
7+
python benchmark/benchmark_xml_tool_parser.py --iterations 5000 --value-chars 4096
8+
"""
9+
from __future__ import annotations
10+
11+
import argparse
12+
import json
13+
import time
14+
from collections.abc import Callable
15+
from dataclasses import dataclass
16+
from typing import Any
17+
18+
from lmdeploy.serve.openai.protocol import DeltaFunctionCall, DeltaToolCall
19+
from lmdeploy.serve.parsers.tool_parser.glm47_tool_parser import Glm47ToolParser
20+
from lmdeploy.serve.parsers.tool_parser.qwen3coder_tool_parser import Qwen3CoderToolParser
21+
22+
# ---------------------------------------------------------------------------
23+
# Legacy implementations (snapshot of main-branch logic before optimization)
24+
# ---------------------------------------------------------------------------
25+
26+
27+
class LegacyXmlToolParser:
28+
def __init__(self):
29+
self._tool_payload = ''
30+
self._function_param_schemas: dict[str, dict[str, dict[str, Any]]] = {}
31+
self._xml_has_emitted_json_start = False
32+
self._xml_json_closed = False
33+
self._xml_emitted_param_names: set[str] = set()
34+
self._active_tool_call_id = 'bench-tool'
35+
self._active_tool_index = 0
36+
self._name_emitted = False
37+
38+
def start_tool_call(self):
39+
self._tool_payload = ''
40+
self._xml_has_emitted_json_start = False
41+
self._xml_json_closed = False
42+
self._xml_emitted_param_names.clear()
43+
self._name_emitted = False
44+
45+
def decode_tool_incremental(self, added_text: str, *, final: bool) -> list[DeltaToolCall]:
46+
self._tool_payload += added_text
47+
func_name, raw_args_dict, is_closed = self._extract_incremental_state(self._tool_payload, final=final)
48+
args_dict = self._coerce_args_by_schema(func_name, raw_args_dict)
49+
50+
out: list[DeltaToolCall] = []
51+
if func_name and not self._name_emitted:
52+
out.append(
53+
DeltaToolCall(
54+
id=self._active_tool_call_id,
55+
index=self._active_tool_index,
56+
type='function',
57+
function=DeltaFunctionCall(name=func_name),
58+
))
59+
self._name_emitted = True
60+
61+
should_close = is_closed or final
62+
json_fragments: list[str] = []
63+
if not self._xml_has_emitted_json_start and (args_dict or should_close):
64+
json_fragments.append('{')
65+
self._xml_has_emitted_json_start = True
66+
67+
for key, value in args_dict.items():
68+
if key in self._xml_emitted_param_names:
69+
continue
70+
prefix = ', ' if self._xml_emitted_param_names else ''
71+
json_fragments.append(f'{prefix}\"{key}\": {json.dumps(value, ensure_ascii=False)}')
72+
self._xml_emitted_param_names.add(key)
73+
74+
if should_close and self._xml_has_emitted_json_start and not self._xml_json_closed:
75+
json_fragments.append('}')
76+
self._xml_json_closed = True
77+
78+
if json_fragments:
79+
out.append(
80+
DeltaToolCall(
81+
id=None,
82+
index=self._active_tool_index,
83+
type=None,
84+
function=DeltaFunctionCall(arguments=''.join(json_fragments)),
85+
))
86+
return out
87+
88+
def _coerce_args_by_schema(self, func_name: str | None, args_dict: dict[str, Any]) -> dict[str, Any]:
89+
if not func_name or not args_dict:
90+
return args_dict
91+
param_schemas = self._function_param_schemas.get(func_name, {})
92+
if not param_schemas:
93+
return args_dict
94+
coerced: dict[str, Any] = {}
95+
for key, value in args_dict.items():
96+
if not isinstance(value, str):
97+
coerced[key] = value
98+
continue
99+
schema = param_schemas.get(key)
100+
if not isinstance(schema, dict):
101+
coerced[key] = value
102+
continue
103+
schema_type = schema.get('type')
104+
if schema_type == 'integer':
105+
try:
106+
parsed = json.loads(value)
107+
coerced[key] = parsed if isinstance(parsed, int) else value
108+
except json.JSONDecodeError:
109+
coerced[key] = value
110+
else:
111+
coerced[key] = value
112+
return coerced
113+
114+
def _extract_incremental_state(self, payload: str, final: bool = False):
115+
raise NotImplementedError
116+
117+
118+
class LegacyGlm47ToolParser(LegacyXmlToolParser):
119+
arg_key_start_token = '<arg_key>'
120+
arg_key_end_token = '</arg_key>'
121+
arg_value_start_token = '<arg_value>'
122+
arg_value_end_token = '</arg_value>'
123+
124+
def _extract_incremental_state(self, payload: str, final: bool = False):
125+
func_name, args_dict = self._parse_payload(payload, final=final)
126+
return func_name, args_dict, False
127+
128+
def _parse_payload(self, payload: str, *, final: bool = False):
129+
payload = payload.strip()
130+
if not payload:
131+
return None, {}
132+
args_start_idx = payload.find(self.arg_key_start_token)
133+
if args_start_idx >= 0:
134+
func_name = payload[:args_start_idx].strip()
135+
args_text = payload[args_start_idx:]
136+
else:
137+
if not final:
138+
return None, {}
139+
func_name = payload.strip()
140+
args_text = ''
141+
if not func_name:
142+
return None, {}
143+
144+
args_dict: dict[str, str] = {}
145+
search_idx = 0
146+
while True:
147+
key_start = args_text.find(self.arg_key_start_token, search_idx)
148+
if key_start < 0:
149+
break
150+
key_content_start = key_start + len(self.arg_key_start_token)
151+
key_end = args_text.find(self.arg_key_end_token, key_content_start)
152+
if key_end < 0:
153+
break
154+
key = args_text[key_content_start:key_end].strip()
155+
value_start = args_text.find(self.arg_value_start_token, key_end + len(self.arg_key_end_token))
156+
if value_start < 0:
157+
break
158+
value_content_start = value_start + len(self.arg_value_start_token)
159+
value_end = args_text.find(self.arg_value_end_token, value_content_start)
160+
if value_end < 0:
161+
break
162+
if key:
163+
args_dict[key] = args_text[value_content_start:value_end]
164+
search_idx = value_end + len(self.arg_value_end_token)
165+
return func_name, args_dict
166+
167+
168+
class LegacyQwen3CoderToolParser(LegacyXmlToolParser):
169+
func_prefix = '<function='
170+
func_suffix = '</function>'
171+
param_prefix = '<parameter='
172+
param_suffix = '</parameter>'
173+
174+
def _extract_incremental_state(self, payload: str, final: bool = False):
175+
return self._extract_params(payload)
176+
177+
def _extract_params(self, content: str):
178+
content = content.replace('<tool_call>', '').replace('</tool_call>', '').strip()
179+
func_name = None
180+
func_start = content.find(self.func_prefix)
181+
if func_start != -1:
182+
name_start = func_start + len(self.func_prefix)
183+
terminators = [idx for idx in (content.find('>', name_start), content.find('\n', name_start)) if idx != -1]
184+
if terminators:
185+
func_name = content[name_start:min(terminators)].strip()
186+
187+
args_dict = {}
188+
search_idx = 0
189+
while True:
190+
param_start = content.find(self.param_prefix, search_idx)
191+
if param_start == -1:
192+
break
193+
name_start = param_start + len(self.param_prefix)
194+
terminators = [idx for idx in (content.find('>', name_start), content.find('\n', name_start)) if idx != -1]
195+
if not terminators:
196+
break
197+
name_end = min(terminators)
198+
param_name = content[name_start:name_end].strip()
199+
val_start = name_end + 1
200+
val_end = content.find(self.param_suffix, val_start)
201+
if val_end == -1:
202+
break
203+
param_val_str = content[val_start:val_end].strip()
204+
try:
205+
parsed_val = json.loads(param_val_str)
206+
val = parsed_val if isinstance(parsed_val, str) else param_val_str
207+
except json.JSONDecodeError:
208+
val = param_val_str
209+
args_dict[param_name] = val
210+
search_idx = val_end + len(self.param_suffix)
211+
is_func_closed = self.func_suffix in content
212+
return func_name, args_dict, is_func_closed
213+
214+
215+
# ---------------------------------------------------------------------------
216+
# Benchmark helpers
217+
# ---------------------------------------------------------------------------
218+
219+
220+
@dataclass
221+
class BenchResult:
222+
name: str
223+
legacy_s: float
224+
optimized_s: float
225+
226+
@property
227+
def speedup(self) -> float:
228+
return self.legacy_s / self.optimized_s if self.optimized_s > 0 else float('inf')
229+
230+
231+
def _run_stream(parser_factory: Callable[[], Any], chunks: list[str], iterations: int) -> float:
232+
start = time.perf_counter()
233+
for _ in range(iterations):
234+
parser = parser_factory()
235+
parser.start_tool_call()
236+
for chunk in chunks:
237+
parser.decode_tool_incremental(chunk, final=False)
238+
parser.decode_tool_incremental('', final=True)
239+
return time.perf_counter() - start
240+
241+
242+
def _glm47_chunks(value_chars: int) -> list[str]:
243+
value = 'x' * value_chars
244+
return [
245+
'get_weather',
246+
'<arg_key>location</arg_key><arg_value>',
247+
*list(value),
248+
'</arg_value>',
249+
'<arg_key>unit</arg_key><arg_value>celsius</arg_value>',
250+
'</tool_call>',
251+
]
252+
253+
254+
def _qwen3coder_chunks(value_chars: int) -> list[str]:
255+
header = ['<function=search_docs>', '<parameter=query>']
256+
value = list('y' * value_chars)
257+
tail = ['</parameter>', '</function>']
258+
return header + value + tail
259+
260+
261+
def _qwen3coder_tokenized_chunks(value_chars: int) -> list[str]:
262+
"""Simulate token-by-token streaming like Qwen3.5 reference tests."""
263+
chunks = [
264+
'<function=get_current_temperature>',
265+
'\n',
266+
'<parameter=location>',
267+
'\n',
268+
]
269+
value = 'Beijing, China'
270+
if value_chars > len(value):
271+
value = value + ('.' * (value_chars - len(value)))
272+
chunks.extend(list(value))
273+
chunks.extend(['\n', '</parameter>', '\n', '</function>'])
274+
return chunks
275+
276+
277+
def _attach_schema(parser: Any) -> None:
278+
parser._function_param_schemas = {
279+
'get_weather': {
280+
'location': {
281+
'type': 'string'
282+
},
283+
'unit': {
284+
'type': 'string'
285+
},
286+
},
287+
}
288+
289+
290+
def _bench_pair(name: str,
291+
legacy_factory: Callable[[], Any],
292+
optimized_factory: Callable[[], Any],
293+
chunks: list[str],
294+
iterations: int) -> BenchResult:
295+
legacy_s = _run_stream(legacy_factory, chunks, iterations)
296+
optimized_s = _run_stream(optimized_factory, chunks, iterations)
297+
return BenchResult(name=name, legacy_s=legacy_s, optimized_s=optimized_s)
298+
299+
300+
def _assert_equivalent(legacy_factory: Callable[[], Any],
301+
optimized_factory: Callable[[], Any],
302+
chunks: list[str]) -> None:
303+
legacy = legacy_factory()
304+
optimized = optimized_factory()
305+
legacy.start_tool_call()
306+
optimized.start_tool_call()
307+
legacy_out: list[DeltaToolCall] = []
308+
optimized_out: list[DeltaToolCall] = []
309+
for chunk in chunks:
310+
legacy_out.extend(legacy.decode_tool_incremental(chunk, final=False))
311+
optimized_out.extend(optimized.decode_tool_incremental(chunk, final=False))
312+
legacy_out.extend(legacy.decode_tool_incremental('', final=True))
313+
optimized_out.extend(optimized.decode_tool_incremental('', final=True))
314+
315+
def _normalize(calls: list[DeltaToolCall]) -> list[tuple]:
316+
rows = []
317+
for call in calls:
318+
fn = call.function
319+
rows.append((fn.name if fn else None, fn.arguments if fn else None))
320+
return rows
321+
322+
assert _normalize(legacy_out) == _normalize(optimized_out)
323+
324+
325+
def main() -> None:
326+
parser = argparse.ArgumentParser(description='Benchmark XML tool parser optimizations')
327+
parser.add_argument('--iterations', type=int, default=2000, help='Repeat count per scenario')
328+
parser.add_argument('--value-chars', type=int, default=2048, help='Long parameter value length')
329+
args = parser.parse_args()
330+
331+
glm_chunks = _glm47_chunks(args.value_chars)
332+
qwen_chunks = _qwen3coder_chunks(args.value_chars)
333+
qwen_token_chunks = _qwen3coder_tokenized_chunks(min(args.value_chars, 256))
334+
335+
def legacy_glm():
336+
p = LegacyGlm47ToolParser()
337+
_attach_schema(p)
338+
return p
339+
340+
def optimized_glm():
341+
p = Glm47ToolParser()
342+
_attach_schema(p)
343+
return p
344+
345+
def legacy_qwen():
346+
return LegacyQwen3CoderToolParser()
347+
348+
def optimized_qwen():
349+
return Qwen3CoderToolParser()
350+
351+
_assert_equivalent(legacy_glm, optimized_glm, glm_chunks)
352+
_assert_equivalent(legacy_qwen, optimized_qwen, qwen_chunks)
353+
_assert_equivalent(legacy_qwen, optimized_qwen, qwen_token_chunks)
354+
355+
results = [
356+
_bench_pair(
357+
f'GLM47 long value ({args.value_chars} chars, {len(glm_chunks)} chunks)',
358+
legacy_glm,
359+
optimized_glm,
360+
glm_chunks,
361+
args.iterations,
362+
),
363+
_bench_pair(
364+
f'Qwen3Coder long value ({args.value_chars} chars, {len(qwen_chunks)} chunks)',
365+
legacy_qwen,
366+
optimized_qwen,
367+
qwen_chunks,
368+
args.iterations,
369+
),
370+
_bench_pair(
371+
f'Qwen3Coder tokenized stream ({len(qwen_token_chunks)} chunks)',
372+
legacy_qwen,
373+
optimized_qwen,
374+
qwen_token_chunks,
375+
args.iterations * 4,
376+
),
377+
]
378+
379+
print('XML Tool Parser Benchmark')
380+
print('=' * 72)
381+
print(f'Iterations per scenario: {args.iterations}')
382+
print()
383+
print(f'{"Scenario":<50} {"Legacy":>8} {"Optimized":>10} {"Speedup":>8}')
384+
print('-' * 72)
385+
for result in results:
386+
print(
387+
f'{result.name:<50} {result.legacy_s:7.3f}s {result.optimized_s:9.3f}s {result.speedup:7.2f}x')
388+
print('-' * 72)
389+
avg_speedup = sum(r.speedup for r in results) / len(results)
390+
print(f'Average speedup: {avg_speedup:.2f}x')
391+
392+
393+
if __name__ == '__main__':
394+
main()

0 commit comments

Comments
 (0)