Prhub

#39983 Add token-offset based selective offload in OffloadConnector

原始 PR 作者 ruocco 合并时间 2026-05-28 22:11 文件变更 2 提交数 7 评论 23 代码增减 +113 / -0

执行摘要

为 OffloadConnector 添加按 token 数的选择性卸载

根据 RFC #39305,允许用户限制每个请求卸载的 token 数量,以控制卸载开销或适应不同卸载设备的能力。PR body 中说明本变更目的是为 OffloadConnector 添加基于 token 偏移的选择性卸载。

值得精读。该 PR 展示了在 vLLM 的 KV 卸载调度器中添加可选参数的最佳实践:参数解析集中在 RequestOffloadState.__post_init__,避免散落在调度逻辑中;类型校验严格(要求 int 且非负);明确标记为实验性。对于需要在卸载连接器中添加其他参数的开发者,是很好的参考。

讨论亮点
  • 字段命名:orozery 建议将 offload_prompt_tokens 重命名为 max_offload_tokens,作者采纳。
  • 解析位置:orozery 建议将解析逻辑从 _build_store_jobs 移到 RequestOffloadState.__post_init__,作者采纳。
  • 测试整合:orozery 建议将单独的测试文件合并到已有的 test_scheduler.py,作者采纳。
  • 类型校验:orozery 建议使用 type(raw) is int 避免子类问题,并添加负数校验。
  • 实验性标记:orozery 要求添加注释说明该字段实验性,作者添加。
  • 早期错误:gemini-code-assist 指出早期版本中百分比应用逻辑错误,但最终版本已改为绝对 token 数,不再存在该问题。

实现拆解

  1. RequestOffloadState 数据类中新增 max_offload_tokens: int | None 字段,默认 None。在 __post_init__ 中从 req.kv_transfer_params 中解析 max_offload_tokens 键,仅当值为非负整数时设置;否则记录警告并忽略。
  2. OffloadScheduler._build_store_jobs 方法中,计算 num_offloadable_tokens 后,若 req_status.max_offload_tokens 不为 None,则取 min(num_offloadable_tokens, max_offload_tokens) 进行截断。
  3. 在测试文件 tests/v1/kv_connector/unit/offloading_connector/test_scheduler.py 中添加 test_max_offload_tokens_validation 测试,验证 None、字符串、浮点数、负数、布尔值、0 和正整数值的行为。
文件 模块 状态 重要度
vllm/distributed/kv_transfer/kv_connector/v1/offloading/scheduler.py 卸载调度器 modified 6.64
tests/v1/kv_connector/unit/offloading_connector/test_scheduler.py 调度器测试 modified 6.88

关键符号

RequestOffloadState.__post_init__ OffloadScheduler._build_store_jobs test_max_offload_tokens_validation

关键源码片段

tests/v1/kv_connector/unit/offloading_connector/test_scheduler.py test-coverage

新增测试用例覆盖 max_offload_tokens 的各种输入情况

@pytest.mark.parametrize("async_scheduling", [True, False])
def test_max_offload_tokens_validation(request_runner, async_scheduling: bool):
    """验证 max_offload_tokens 的类型强制转换、边界值和上限截断。
    设置:3 个 offloaded blocks × 3 个 GPU blocks 每个 = 9 个 GPU block 偏移(0–8)。
    """
    gpu_block_size = 4
    block_size_factor = 3
    offloaded_block_size = gpu_block_size * block_size_factor # 12
    num_gpu_blocks = 100
    all_offsets = (0, 1, 2, 3, 4, 5, 6, 7, 8)
​
    def make_runner():
        return request_runner(block_size=gpu_block_size,
                              num_gpu_blocks=num_gpu_blocks,
                              async_scheduling=async_scheduling,
                              block_size_factor=block_size_factor)
​
    def setup(r, max_offload_tokens):
        r.new_request(token_ids=[0] * offloaded_block_size * 3)
        req = r.scheduler.requests[str(r.req_id)]
        req.kv_transfer_params = {"max_offload_tokens": max_offload_tokens}
        r.manager.prepare_store.side_effect = (
            lambda keys, req_context: generate_store_output(keys)
        )
​
    # 同步与异步调度下的 flush 行为不同
    flushed_all = all_offsets if not async_scheduling else ()
    flushed_two = (0, 1, 2, 3, 4, 5) if not async_scheduling else ()
​
    # None -> 不限制,存储全部 9 个偏移
    r = make_runner()
    setup(r, None)
    r.run(decoded_tokens=[EOS_TOKEN_ID], expected_stored=all_offsets,
          expected_flushed=flushed_all)
​
    # 字符串 -> 警告并回退为不限制
    r = make_runner()
    setup(r, "24")
    r.run(decoded_tokens=[EOS_TOKEN_ID], expected_stored=all_offsets,
          expected_flushed=flushed_all)
​
    # 浮点数 -> 警告并回退为不限制
    r = make_runner()
    setup(r, 24.5)
    r.run(decoded_tokens=[EOS_TOKEN_ID], expected_stored=all_offsets,
          expected_flushed=flushed_all)
​
    # 负数 -> 警告并回退为不限制
    r = make_runner()
    setup(r, -1)
    r.run(decoded_tokens=[EOS_TOKEN_ID], expected_stored=all_offsets,
          expected_flushed=flushed_all)
​
    # 布尔值 -> 拒绝(type(True) 是 bool,不是 int),回退为不限制
    r = make_runner()
    setup(r, True)
    r.run(decoded_tokens=[EOS_TOKEN_ID], expected_stored=all_offsets,
          expected_flushed=flushed_all)
​
    # 0 -> 有效,不卸载任何 blocks
    r = make_runner()
    setup(r, 0)
    r.run(decoded_tokens=[EOS_TOKEN_ID], expected_stored=())
​
    # 正整数上限 -> 限制为前 2 个 offloaded blocks(偏移 0–5)
    r = make_runner()
    setup(r, 24) # 24 tokens = 2 offloaded blocks × 12 tokens
    r.run(decoded_tokens=[EOS_TOKEN_ID], expected_stored=(0, 1, 2, 3, 4, 5),
          expected_flushed=flushed_two)

评论区精华

字段命名:offload_prompt_tokens vs max_offload_tokens 设计

orozery 建议使用 max_offload_tokens 更自解释,作者接受

结论:字段重命名为 max_offload_tokens · 已解决

解析位置:_build_store_jobs vs __post_init__ 设计

orozery 建议将解析从 _build_store_jobs 移到 RequestOffloadState 创建时,避免每次调度都解析

结论:解析移至 __post_init__ · 已解决

测试文件组织:单独文件 vs 合并到现有文件 测试

orozery 建议将新测试函数添加到已有 test_scheduler.py 而不是创建新文件

结论:测试合并到 test_scheduler.py · 已解决

实验性说明注释 documentation

orozery 要求添加注释说明该字段实验性,可能将来变更

结论:作者添加了注释 · 已解决

风险与影响

该功能为实验性,输入参数未经严格校验可能导致误用(字符串、浮点数被静默忽略)。由于只在 num_offloadable_tokens 上添加了一个 min 操作,对现有卸载流程无影响,回归风险低。max_offload_tokens 设置过小可能导致有意卸载的 token 未能全部卸载,设置过大则与不设置等效。性能开销可忽略。

用户:新增一个可选参数 max_offload_tokens,通过 kv_transfer_params 传递,不影响已有请求。系统:若无此参数,行为与之前完全一致。团队:新增一个实验性字段,未来可能调整或移除。

实验性参数 输入校验静默失败

关联 Issue

未识别关联 Issue

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

完整报告

参与讨论