Prhub

#46425 [Perf][ThinkingBudget] reduce search space for thinking tokens

原始 PR 作者 walterbm 合并时间 2026-06-24 23:02 文件变更 2 提交数 2 评论 3 代码增减 +61 / -4

执行摘要

增量搜索减少思考标记预算开销

当前 Thinking Budget 逻辑在每步解码时都会用 _find_last_sequence_index 扫描整个已生成序列,随着序列增长扫描时间会暴增(PR body 给出 32K token 时达 34.7s 每步)。通过增量搜索大幅降低计算开销,使长思考场景下的强制结束标记检测变得可行。

值得精读。尽管改动量小,但展示了如何通过增量计算将 O(n²) 变为 O(1) 平摊的经典优化模式,且 review 中关于切片 vs 逐元素比较的性能讨论提供了 Python 微优化的实际经验。建议在类似序列扫描场景中复用该模式。

讨论亮点

Reviewer njhill 建议在 _find_last_sequence_index_from 中使用逐元素比较(all(target_list[i+j] == token_ids[j] for j in range(num_token_ids)))代替列表切片,以避免切片重复分配内存。PR 作者 walterbm 通过 benchmark 验证该变体反而慢 2.19×,原因是思考标记序列(start/end token ids)通常很短(2-3 个 token),Python 切片与列表比较的开销低于循环+生成器开销,njhill 认可了这一结论。

实现拆解

  1. 新增增量搜索方法:在 vllm/v1/sample/thinking_budget_state.py 中添加静态方法 _find_last_sequence_index_from(target_list, token_ids, search_start),从指定索引 search_start 开始向后扫描,避免每次从头遍历整个序列。
  2. 状态结构扩展:在 _init_state_entry 返回的状态字典中增加 start_search_posend_search_pos 两个字段(初始化为 0),分别记录 start_thinkingend_thinking 上次已扫描到的位置。
  3. 核心循环改造:在 _update_think_state 方法中,将原来对 _find_last_sequence_index 的两次调用替换为 _find_last_sequence_index_from,并传入当前位置偏移。如果未找到标记(返回 -1),则将搜索位置更新为当前 output_tok_ids 长度,保证后续只扫描新生成的 token。
  4. 测试覆盖:在 tests/v1/logits_processors/test_correctness.py 中新增 test_thinking_budget_long_thinking_section_end_marker_found_at_correct_index 测试,模拟 500 步长思考后添加结束标记,验证 end_thinking 索引正确。
文件 模块 状态 重要度
vllm/v1/sample/thinking_budget_state.py 采样器 modified 7.0
tests/v1/logits_processors/test_correctness.py 测试 modified 5.44

关键符号

_find_last_sequence_index_from _update_think_state

关键源码片段

vllm/v1/sample/thinking_budget_state.py core-logic

核心变更文件,新增 `_find_last_sequence_index_from` 方法并修改 `_update_think_state` 以使用增量搜索,同时在状态字典中添加游标字段。

# vllm/v1/sample/thinking_budget_state.py@staticmethod
def _find_last_sequence_index_from(
    target_list: list[int], token_ids: list[int], search_start: int
) -> int:
    """Last occurrence of ``token_ids`` at or after ``search_start``."""
    if not token_ids:
        return -1
    lo = max(0, search_start) # 确保不越界
    # 从后向前扫描,但只从 lo 位置开始
    for i in range(len(target_list) - len(token_ids), lo - 1, -1):
        if target_list[i : i + len(token_ids)] == token_ids:
            return i
    return -1def _init_state_entry(self, prompt_tok_ids, thinking_token_budget):
    # ... 原有逻辑 ...
    return {
        # ... 其他字段 ...
        "start_thinking": start_thinking,
        "end_thinking": -1,
        "start_search_pos": 0, # 新增:记录 start 标记已扫描到的位置
        "end_search_pos": 0, # 新增:记录 end 标记已扫描到的位置
        # ...
    }def _update_think_state(self, state):
    # ... 前置检查 ...
    output_tok_ids = state.get("output_tok_ids", [])
    if state["start_thinking"] == -1:
        seq_len = len(self.think_start_token_ids)
        start_thinking = self._find_last_sequence_index_from(
            output_tok_ids,
            self.think_start_token_ids,
            state["start_search_pos"] - (seq_len - 1),
        )
        state["start_thinking"] = start_thinking
        if start_thinking == -1:
            # 未找到则更新游标到当前序列末尾,下次只扫描新 token
            state["start_search_pos"] = len(output_tok_ids)
​
    if state["end_thinking"] == -1:
        seq_len = len(self.think_end_token_ids)
        end_thinking = self._find_last_sequence_index_from(
            output_tok_ids,
            self.think_end_token_ids,
            state["end_search_pos"] - (seq_len - 1),
        )
        state["end_thinking"] = end_thinking
        if end_thinking == -1:
            state["end_search_pos"] = len(output_tok_ids)
    # ... 后续逻辑 ...
tests/v1/logits_processors/test_correctness.py test-coverage

新增测试验证长思考序列下结束标记索引正确性,确保增量搜索不会遗漏标记。

# tests/v1/logits_processors/test_correctness.pydef test_thinking_budget_long_thinking_section_end_marker_found_at_correct_index():
    """长期思考结束后,end marker 应被正确检测到。"""
    h = ThinkingBudgetStateHolder(
        MockReasoningConfig(), 8, 0, torch.device("cpu"), False
    )
    h.sync_batch(
        BatchUpdate(
            batch_size=1,
            removed=(),
            added=[(0, SamplingParams(thinking_token_budget=10_000), None, [])],
            moved=(),
        )
    )
    start = MockReasoningConfig.reasoning_start_token_ids
    end = MockReasoningConfig.reasoning_end_token_ids
​
    out = list(start)
    h.update_state([out], None, None)
    for tok in range(500): # 500 个思考 token,每个单独一步
        out.append(tok)
        h.update_state([out], None, None)
        assert h._state[0]["end_thinking"] == -1 # 尚未出现 end marker
​
    expected_end_idx = len(out) # 记录添加 end marker 前的位置
    out.extend(end)
    h.update_state([out], None, None)
​
    assert h._state[0]["start_thinking"] == 0
    assert h._state[0]["end_thinking"] == expected_end_idx

评论区精华

切片 vs 逐元素比较性能 性能

njhill 建议用逐元素比较替代切片以避免重复切片分配内存;walterbm 通过 benchmark 发现切片版本在小标记序列(2-3 tokens)下仍更快,因为 Python 切片与列表比较的开销低于循环 + 生成器。

结论:保留原切片实现,njhill 认可理由(标记序列短)。 · 已解决

风险与影响

低风险。核心改动仅在 _update_think_state 中替换扫描方法,且新增的 start_search_pos / end_search_pos 游标仅在首次未找到标记时更新为序列长度,不影响其他逻辑。测试已覆盖长思考场景。但需注意:如果其他代码路径直接修改 _state 中的 output_tok_ids 而不更新游标,可能导致搜索位置偏移;当前仅在 _update_think_state 内使用游标,且游标更新时机与调用链路一致,安全。

影响范围:仅 ThinkingBudgetStateHolder 类,即 v1 调度器中处理 Thinking Budget 的模块。
影响程度:对长思考序列(例如数千 token)的性能提升可达数百至数千倍,但对短序列无显著变化。
对用户:使用 thinking_token_budget 参数的模型推理长思考内容时延迟大幅降低。
对系统:无外部 API 或配置变更,无兼容性问题。

缺少状态同步防御 仅单步测试覆盖

关联 Issue

未识别关联 Issue

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

完整报告

参与讨论