Prhub

#44708 [Benchmark] Auto-detect and correct client/server tokenizer mismatch for random dataset

原始 PR 作者 akii96 合并时间 2026-06-08 21:10 文件变更 1 提交数 2 评论 6 代码增减 +81 / -1

执行摘要

基准测试自动对齐 tokenizer 差异

基准测试时,client 端和 server 端使用的 tokenizer 可能不一致(例如 transformers 版本不同导致 DeepSeek-V3.2 回退到错误 tokenizer),导致 client 端统计的 input tokens 远大于 server 端实际处理的 token 数,伪造了高吞吐率。PR body 中验证显示:未修复时 total input tokens 为 46359(预期 10000),TTFT 高达 918.93ms;修复后 input tokens 为 10000,TTFT 降至 406.75ms,请求吞吐率从 1.39 提升到 1.92 req/s(实际是更真实的测量)。

值得精读:PR 展示了一个优雅的“即插即用”方案:零侵入、零配置、自动感知并修复,代码简洁(约 80 行)。其设计思路(探测 + 告警 + 全量修复)和并发控制方式可作为类似问题的参考。特别适合需要长时间运行 benchmark 并比较不同版本/模型的团队。

讨论亮点
  1. 设计替代方案:作者提到此 PR 是 #42532 的替代方案,后者修改数据集代码并新增 CLI 参数,而本方案零改动数据集和 CLI,基于 reviewer DarkLight1337 的反馈。
  2. tokenizer 不一致根因:询问为何 tokenizer 会不一致,作者解释 DeepSeek-V3.2 在 transformers >= 5.0 无原生支持,静默回退到错误 tokenizer,而 server 加载了正确版本。
  3. frida-andersson 的建议:建议在 /tokenize 返回 503/404 时添加警告日志,即使已用 except 兜底。作者已采纳并在 except 块中添加 print("WARNING: /tokenize unavailable, skipping alignment.")

实现拆解

  1. dataclasses 导入 replace,用于创建 SampleRequest 的副本并更新字段。
  2. 新增异步函数 _align_prompts_to_server_tokenizer,接收 server URL、模型 ID、请求列表和 SSL 上下文,返回对齐后的请求列表。
  3. 函数内部定义两个内嵌异步函数:
    • _tokenize(prompt):调用 server 的 /tokenize 端点,使用 add_special_tokens=False 避免添加特殊 token,返回 token ID 列表。
    • _detokenize(tokens):调用 server 的 /detokenize 端点,将 token ID 列表还原为文本。
  4. 用首个 prompt 进行探测,比较 server 返回的 token 数与 client 端期望的 prompt_len:
    • 如果相同,直接返回原列表(无操作);
    • 如果不匹配,打印警告并调用 _fix_one 对每个请求重新对齐:如果 token 数超出预期,则截断 token 并反 tokenize 回文本,同时保持 prompt_len 不变。
  5. 使用 asyncio.Semaphore(64) 控制并发,asyncio.gather 并行处理所有请求,异常时保留原始请求。
  6. main_async 中,对 randomprefix_repetition 两种数据集,在 get_samples 之后立即插入对齐步骤。
  7. 如果 /tokenize 端点不可用(如非 vLLM 后端),捕获异常并打印警告后跳过对齐。
文件 模块 状态 重要度
vllm/benchmarks/serve.py 基准测试 modified 8.03

关键符号

_align_prompts_to_server_tokenizer _tokenize _detokenize _fix_one

关键源码片段

vllm/benchmarks/serve.py core-logic

核心变更文件,新增 tokenizer 自动对齐逻辑。

async def _align_prompts_to_server_tokenizer(
    base_url: str,
    model_id: str,
    input_requests: list[SampleRequest],
    ssl_context: ssl.SSLContext | bool | None = None,
) -> list[SampleRequest]:
    """
    Re-align prompts if local/server tokenizers disagree.
    通过 server 的 /tokenize 和 /detokenize 端点重新对齐提示文本。
    """
    if not input_requests or not isinstance(input_requests[0].prompt, str):
        return input_requests
​
    tok_url = f"{base_url}/tokenize"
    detok_url = f"{base_url}/detokenize"
    connector = aiohttp.TCPConnector(ssl=ssl_context)
​
    async with aiohttp.ClientSession(connector=connector) as session:
        # 限制并发数为 64,避免过度占用 server 资源
        sem = asyncio.Semaphore(64)
​
        async def _tokenize(prompt: str) -> list[int]:
            async with (
                sem,
                session.post(
                    tok_url,
                    json={
                        "model": model_id,
                        "prompt": prompt,
                        "add_special_tokens": False, # 确保计数纯粹
                    },
                ) as r,
            ):
                r.raise_for_status()
                return (await r.json())["tokens"]
​
        async def _detokenize(tokens: list[int]) -> str:
            async with (
                sem,
                session.post(
                    detok_url, json={"model": model_id, "tokens": tokens}
                ) as r,
            ):
                r.raise_for_status()
                return (await r.json())["prompt"]
​
        # 先用第一个 prompt 快速探测 tokenizer 是否匹配
        try:
            first_tokens = await _tokenize(input_requests[0].prompt)
        except Exception:
            # 端点不可用时警告并跳过
            print("WARNING: /tokenize unavailable, skipping alignment.")
            return input_requests
​
        expected = input_requests[0].prompt_len
        if len(first_tokens) == expected:
            return input_requests
​
        print(
            f"WARNING: tokenizer mismatch "
            f"(server={len(first_tokens)}, expected={expected}), "
            f"re-aligning prompts."
        )
​
        async def _fix_one(req: SampleRequest) -> SampleRequest:
            tokens = await _tokenize(req.prompt)
            # 如果 server 端 token 数没有超出预期,说明 client 端低估了长度,直接保留
            if len(tokens) <= req.prompt_len:
                return req
            # 截断到期望长度,然后反 tokenize 回来
            corrected = await _detokenize(tokens[: req.prompt_len])
            # 使用 dataclasses.replace 创建新对象,保持 prompt_len 不变
            return replace(req, prompt=corrected, prompt_len=req.prompt_len)
​
        # 并行修复所有请求,异常时保留原始请求
        results = await asyncio.gather(
            *[_fix_one(r) for r in input_requests], return_exceptions=True
        )
        return [
            res if not isinstance(res, BaseException) else orig
            for orig, res in zip(input_requests, results)
        ]

评论区精华

异常处理中缺少警告 正确性

frida-andersson 建议:如果 `/tokenize` 返回 503/404 或端点不可用,当前 except 块仅 silently 返回原始请求,应添加警告。

结论:作者采纳,在 except 块中添加 `print("WARNING: /tokenize unavailable, skipping alignment.")`。 · 已解决

风险与影响

  1. 回归风险:仅在 randomprefix_repetition 数据集上启用对齐,不影响其他数据集逻辑。首次探测只请求一个 prompt,网络开销小。
  2. 性能开销:对齐过程需要每个 prompt 额外两次 HTTP 请求(tokenize + 可能 detokenize),但 benchmark 本身就会发送请求,且并发数限制为 64,影响可控。仅在首次探测发现不匹配时才会全量修复。
  3. 非标准后端兼容性:如果 server 不支持 /tokenize 端点(非 vLLM),会捕获异常并跳过,但有警告提示。
  4. 安全风险:无,仅 HTTP 请求。
  5. 兼容性:依赖 server 提供标准接口,对大多数 vLLM server 兼容。
  1. 用户视角:对使用 vllm bench 进行性能测试的用户透明,自动修复 tokenizer 不一致导致的性能指标失真,获得更准确的吞吐率、TTFT、TPOT 等数据。
  2. 系统视角:仅影响基准测试客户端逻辑,server 端无需任何修改。
  3. 团队视角:减少因 tokenizer 版本问题引发的错误性能报告,降低调试成本。
  4. 影响程度:中低,仅限于 benchmark 流程,且仅当检测到不一致时才有额外开销。
依赖 server 端点 触发条件依赖数据集类型

关联 Issue

未识别关联 Issue

当前没有检测到明确关联的 Issue 链接,后续同步到相关引用后会出现在这里。

完整报告

参与讨论