Prhub

#41181 [Bugfix] Fix `RuntimeError: Already borrowed` by adding thread-safe Hugging Face fast-tokenizer wrappers

原始 PR 作者 yzong-rh 合并时间 2026-05-05 22:04 文件变更 5 提交数 11 评论 49 代码增减 +107 / -35

执行摘要

修复 HF fast-tokenizer 并发 Already borrowed 错误

Issue #40949 报告高并发下 HF fast-tokenizer 的 Rust 后端在可变借用(如 encode)与不可变借用(decode)并发时抛出 Already borrowed。原有 workaround(deepcopy tokenizer 给多模态处理器)不够彻底。PR body 说明:"Uses a tokenizer pool that dispatches calls to borrowing methods to a free deepcopied tokenizer instance. Fix concurrency issues with bad_words sampling param. Removes the need to use deepcopy for multimodal processor."

此 PR 是解决核心并发问题的关键修复,设计思路(队列 + 动态子类化)值得学习。建议仔细阅读 vllm/tokenizers/hf.py 中 maybe_make_thread_pool 的实现,特别是动态类创建和 __reduce__ 的处理。未来若扩展 tokenizer 接口,需确保新方法也纳入包装范围。

讨论亮点

核心方案选择:作者最初实现了锁版本和 threading.local 版本,reviewers 建议改用更简洁的队列池。noooop 说 "I think using Queue() might be a bit simpler",作者采纳。

方法覆盖范围:DarkLight1337 问 "How to determine which methods we need to wrap?",noooop 建议 "always use _borrow_from_pool for all method calls",最终覆盖了所有公共入口。

FastIncrementalDetokenizer:作者担心 detokenizer 直接访问 _tokenizer 属性绕过池,noooop 确认它运行在单线程核心,暂无需处理。

pool 放置时机:almayne 质疑在 HfRenderer.__init__ 末尾才调用 maybe_make_thread_pool 是否安全,作者解释因为多模态处理器会突变 tokenizer(如添加特殊 token),必须在处理器初始化之后、但在使用之前包装。

回归风险:almayne 报告 Gemma3 多模态推理出现约 5% 性能下降,作者尚未确认原因;atalman 提出 PyTorch 2.12 上的测试失败,但作者无法稳定复现,怀疑与 torch 版本相关。

实现拆解

  1. 核心线程池包装 (vllm/tokenizers/hf.py):新增 maybe_make_thread_pool 函数,使用 queue.Queue 维护一组 deep-copied tokenizer 实例;定义 _borrow_from_pool 上下文管理器借出/归还;动态创建 TokenizerPool 类继承原始 tokenizer 并混入 ThreadSafeHFTokenizerMixin,重写 encode, decode, batch_encode, batch_decode, apply_chat_template, convert_tokens_to_ids, __call__ 等方法,全部通过 with _borrow_from_pool 委托给池中副本。新增 ThreadSafeHFTokenizerMixin 标记基类防止重复包装。

  2. 调用点集成 (vllm/renderers/hf.py):在 HfRenderer.__init__ 末尾(多模态处理器可能突变 tokenizer 之后)调用 maybe_make_thread_pool(self.tokenizer, renderer_num_workers+1),将 tokenizer 替换为线程安全版本;同时做防御性拷贝 copy.copy(tokenizer) 确保原始 tokenizer 不被意外修改。

  3. 移除旧 workaround (vllm/renderers/base.py):删除创建多模态处理器时对 tokenizer 的 copy.deepcopy,直接传递 self.tokenizer,依赖线程池保证安全。

  4. 删除重试逻辑 (vllm/multimodal/processing/context.py):移除 call_hf_processor 中针对 RuntimeError: Already borrowed 的 num_tries 重试循环(包括 time.sleep),因为线程池已杜绝此类错误。

  5. 导出符号 (vllm/tokenizers/__init__.py):将 maybe_make_thread_pool 加入 __all__ 以便外部模块使用。

文件 模块 状态 重要度
vllm/tokenizers/hf.py 分词器 modified 8.49
vllm/renderers/hf.py 渲染器 modified 6.52
vllm/renderers/base.py 渲染器 modified 5.99
vllm/multimodal/processing/context.py 多模态 modified 6.17
vllm/tokenizers/__init__.py 分词器 modified 4.89

关键符号

maybe_make_thread_pool _borrow_from_pool TokenizerPool.__init__ TokenizerPool.encode TokenizerPool.decode TokenizerPool.batch_encode TokenizerPool.batch_decode TokenizerPool.apply_chat_template TokenizerPool.convert_tokens_to_ids TokenizerPool.__call__ TokenizerPool.__reduce__ ThreadSafeHFTokenizerMixin

关键源码片段

vllm/tokenizers/hf.py dependency-wiring

核心实现:新增线程安全包装逻辑、TokenizerPool 动态类、maybe_make_thread_pool 函数。

import contextlib
import copy
import queue
from typing import TypeVar
​
from transformers import PreTrainedTokenizerFast
from .protocol import TokenizerLike
​
_T = TypeVar("_T", bound=TokenizerLike)
​
class ThreadSafeHFTokenizerMixin:
    """Mixin 标记类,用于标识已包装的 tokenizer 对象。"""
    pass
​
​
def maybe_make_thread_pool(tokenizer: _T, copies: int = 1) -> _T:
    """
    若 tokenizer 是 PreTrainedTokenizerFast,则原地修改其类型为 TokenizerPool,
    将所有公共方法调用通过深度复制池路由,实现线程安全。
    注意:不覆盖 _tokenizer 属性及 add_special_tokens 等突变方法。
    """
    # 只对 fast tokenizer 且尚未包装的进行包装
    if not isinstance(tokenizer, PreTrainedTokenizerFast) or isinstance(
        tokenizer, ThreadSafeHFTokenizerMixin
    ):
        return tokenizer
​
    og_tokenizer = copy.copy(tokenizer) # 浅拷贝作为原始模板
​
    # 初始化深度复制池
    tokenizer_pool: queue.Queue[PreTrainedTokenizerFast] = queue.Queue()
    for _ in range(copies):
        tokenizer_pool.put(copy.deepcopy(og_tokenizer))
​
    @contextlib.contextmanager
    def _borrow_from_pool():
        """从池中借出一个 tokenizer,用后归还。池空则临时 deepcopy 一个。"""
        try:
            tok = tokenizer_pool.get_nowait()
            yield tok
        except queue.Empty:
            tok = copy.deepcopy(og_tokenizer)
            yield tok
        finally:
            tokenizer_pool.put(tok)
​
    # 动态创建 TokenizerPool 类,继承原始 tokenizer 类并混入标记
    class TokenizerPool(tokenizer.__class__, ThreadSafeHFTokenizerMixin):
        def apply_chat_template(self, *args, **kwargs):
            with _borrow_from_pool() as tok:
                return tok.apply_chat_template(*args, **kwargs)
​
        def batch_decode(self, *args, **kwargs):
            with _borrow_from_pool() as tok:
                return tok.batch_decode(*args, **kwargs)
​
        def batch_encode(self, *args, **kwargs):
            with _borrow_from_pool() as tok:
                return tok.batch_encode(*args, **kwargs)
​
        def convert_tokens_to_ids(self, *args, **kwargs):
            with _borrow_from_pool() as tok:
                return tok.convert_tokens_to_ids(*args, **kwargs)
​
        def convert_ids_to_tokens(self, *args, **kwargs):
            with _borrow_from_pool() as tok:
                return tok.convert_ids_to_tokens(*args, **kwargs)
​
        def convert_tokens_to_string(self, *args, **kwargs):
            with _borrow_from_pool() as tok:
                return tok.convert_tokens_to_string(*args, **kwargs)
​
        def decode(self, *args, **kwargs):
            with _borrow_from_pool() as tok:
                return tok.decode(*args, **kwargs)
​
        def encode(self, *args, **kwargs):
            with _borrow_from_pool() as tok:
                return tok.encode(*args, **kwargs)
​
        def __call__(self, *args, **kwargs):
            with _borrow_from_pool() as tok:
                return tok(*args, **kwargs)
​
        def __reduce__(self):
            # 支持 pickle 序列化,反序列时重新创建池
            return maybe_make_thread_pool, (og_tokenizer, copies)
​
    TokenizerPool.__name__ = f"TokenizerPool{og_tokenizer.__class__.__name__}"
    tokenizer.__class__ = TokenizerPool
    return tokenizer

评论区精华

锁 vs 深度拷贝 vs 队列 设计

作者最初实现了锁版本和线程本地拷贝版本,reviewers 建议改用队列池。noooop 说 "I think using Queue() might be a bit simpler",作者最终采纳。

结论:采用基于 queue.Queue 的池化方案,兼顾简洁性和性能。 · RESOLVED

确定需要包装的方法 设计

DarkLight1337 问 "How to determine which methods we need to wrap?",noooop 建议 "always use _borrow_from_pool for all method calls, so that we don't need to worry about any methods being left uncovered."

结论:最终覆盖了所有公共调用入口(__call__, encode, decode, batch_encode, batch_decode, apply_chat_template, convert_tokens_to_ids 等)。 · RESOLVED

FastIncrementalDetokenizer 绕过问题 正确性

作者指出 detokenizer 直接访问 _tokenizer 属性会绕过线程安全池。noooop 回应称 detokenizer 运行在单线程核心,目前没有并发问题,可以暂不处理。

结论:当前安全,未来如需支持多线程再迁移。 · RESOLVED

maybe_make_thread_pool 放置位置 设计

almayne 质疑在 HfRenderer.__init__ 末尾调用是否安全,作者解释因为多模态处理器会突变 tokenizer,所以必须在处理器初始化之后、但在使用之前包装。

结论:当前顺序正确:先让 mm_processor 突变 tokenizer(添加特殊 token),再调用 maybe_make_thread_pool 包装。 · RESOLVED

Gemma3 多模态性能回归 性能

almayne 报告 Gemma3 多模态推理出现约 5% 性能下降,作者表示关注但尚未确认原因。atalman 报告 PyTorch 2.12 上测试失败,但作者无法稳定复现。

结论:pending:回归是否由本 PR 引起仍不确定,作者建议进一步调查。 · UNRESOLVED

风险与影响

  • 方法覆盖不足:若未来新增 tokenizer 公共方法未纳入池包装,仍可能触发 Already borrowed。当前已覆盖主要可疑方法(encode, decode, batch_*, apply_chat_template 等),review 确认充分。
  • 突变不传播:初始化后对 tokenizer 的运行时突变(如 add_special_tokens)不会同步到池中副本,可能导致不一致。当前所有 mutation 发生在初始化阶段(如 prompt_embeds 占位符),安全。
  • FastIncrementalDetokenizer 绕过:detokenizer 直接访问 _tokenizer 属性不经过池,但运行在单线程核心,目前无并发风险;未来若改为多线程则需适配。
  • 性能风险:引入 deepcopy 和队列操作,benchmark 未发现退化,但 almayne 报告 Gemma3 多模态 5% 下降,需进一步确认是否由本 PR 导致。
  • 类型兼容性:动态改变 tokenizer 的 __class__ 可能影响依赖 isinstance 或 type() 的代码,但 ThreadSafeHFTokenizerMixin 标记可缓解。
  • 用户:消除高并发下 RuntimeError: Already borrowed 错误,尤其是使用 bad_words 参数和多模态请求时更加稳定。
  • 系统:移除旧的 deepcopy 和重试逻辑,减少不必要的内存拷贝和延迟;线程池引入少量额外内存(每个 worker 一个 deep-copied 副本),通常可接受。
  • 团队:简化多模态处理器的 tokenizer 传递,不再需要手动 deepcopy,降低后续开发的心智负担。
方法覆盖不足 突变不传播 性能回归 类型兼容性

关联 Issue

未识别关联 Issue

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

完整报告

参与讨论