Prhub

#29432 Fix bookkeeping fields not encapsulated with real allocations in normal alloc, PD pre-alloc, DFlash and EAGLE

原始 PR 作者 fzyzcjy 合并时间 2026-07-15 14:52 文件变更 12 提交数 42 评论 32 代码增减 +345 / -264

执行摘要

封装 KV 分配记账到分配函数

消除记账与分配分离导致的不一致风险。PR body 指出:'Encapsulate the req.kv.kv_allocated_len bookkeeping with the real allocation in every path',这是 req_pool_idx / cache / owned-KV 解耦重构链的一部分,为后续全面 owned-KV 生命周期管理创造条件。

值得精读。此 PR 演示了如何在大型代码库中进行安全的大规模重构:使用机械验证确保纯移动正确,用等价性审查确认语义变更,通过多步提交渐进式推进。对于关注代码架构和重构技巧的工程师,这是很好的学习案例。

讨论亮点

Review 中 Gemini Code Assist 提出了两个潜在安全问题:

  • alloc_for_spec_decode 中直接访问 req.kv.kv_allocated_lenreq.kv 可能为 None,建议添加防御性初始化(未在最终代码中采纳)。
  • alloc_for_decode_prealloc 中对 prefix_indices 切片时未检查是否为 None,建议添加条件检查(未采纳)。
    此外,PR 作者在 CI triage 中严格区分了机械移动和语义变更,并通过字节级验证脚本确保了移动的正确性。

实现拆解

  1. 移动 Triton 辅助函数:将 assign_req_to_token_pool 及其调度函数从 cache_locs.py 搬迁至 allocation.py,并添加 CPU 分支(字节级验证)。
  2. 准备 decode.py:对 _pre_alloc 进行去 self 化重写,将 req.kv 的初始化与物理分配放在同一处。
  3. 提取前分配函数:从 DecodePreallocQueue 中提取 alloc_for_decode_preallocalloc_for_decode_prealloc_hisparse,封装 HiSparse 路径的分配和记账。
  4. 统一 spec 解码分配:将 EAGLE 和 DFlash 内联的分配逻辑重写为标准形式,提取公共 alloc_for_spec_decode,供 eagle_utils.pydflash_info_v2.py 调用,并删除分散的各处 kv_allocated_len 手工更新。
  5. 封装剩余记账:在 alloc_for_extendalloc_for_decode 末尾添加 req.kv.kv_allocated_len 更新;从 release_kv_cache 中提取 _release_overallocated_kv_indices 辅助函数。
文件 模块 状态 重要度
python/sglang/srt/mem_cache/allocation.py 内存缓存 modified 8.68
python/sglang/srt/disaggregation/decode.py 分离解码 modified 8.1
python/sglang/srt/mem_cache/common.py 内存缓存 modified 6.94
python/sglang/srt/speculative/eagle_utils.py 投机解码 modified 6.99
python/sglang/srt/speculative/dflash_info_v2.py 投机解码 modified 6.95
python/sglang/kernels/ops/speculative/cache_locs.py kernel 层 modified 6.02
test/registered/unit/spec/test_decode_bookkeeping_ownership.py 单元测试 modified 4.76
test/registered/unit/mem_cache/test_unified_radix_cache_unittest.py 单元测试 modified 4.11
test/registered/unit/mem_cache/test_hisparse_allocator.py 单元测试 modified 4.06

关键符号

alloc_for_extend alloc_for_decode alloc_for_spec_decode alloc_for_decode_prealloc alloc_for_decode_prealloc_hisparse _release_overallocated_kv_indices assign_req_to_token_pool_func assign_req_to_token_pool

关键源码片段

python/sglang/srt/mem_cache/allocation.py core-logic

核心变更文件:新增 `assign_req_to_token_pool`、`assign_req_to_token_pool_func`(从 `cache_locs.py` 移入)、`alloc_for_spec_decode`;在 `alloc_for_extend` 和 `alloc_for_decode` 末尾添加记账封装。

# alloc_for_spec_decode: 合并 EAGLE 和 DFlash 的 spec 解码分配路径
# 接收所有必要参数,单点处理物理分配和 req_to_token 更新from sglang.srt.managers.schedule_batch import ReqKvInfo@triton.jit
def assign_req_to_token_pool(
    req_pool_indices, req_to_token,
    start_offset, end_offset, out_cache_loc,
    pool_len: tl.constexpr, bs_upper: tl.constexpr,
):
    BLOCK_SIZE: tl.constexpr = 32
    pid = tl.program_id(axis=0)
    # 计算每个请求的起始偏移
    length_offset = tl.arange(0, bs_upper)
    start = tl.load(start_offset + length_offset, mask=length_offset < pid, other=0)
    end = tl.load(end_offset + length_offset, mask=length_offset < pid, other=0)
    out_offset = tl.sum(end - start, axis=0)
    # 循环写入 token pool
    save_offset = tl.arange(0, BLOCK_SIZE) + tl.load(start_offset + pid)
    load_offset = tl.arange(0, BLOCK_SIZE)
    num_loop = tl.cdiv(tl.load(end_offset + pid) - tl.load(start_offset + pid), BLOCK_SIZE)
    for _ in range(num_loop):
        mask = save_offset < tl.load(end_offset + pid)
        data = tl.load(out_cache_loc + out_offset + load_offset, mask=mask)
        tl.store(req_to_token + tl.load(req_pool_indices + pid) * pool_len + save_offset, data, mask=mask)
        save_offset += BLOCK_SIZE
        load_offset += BLOCK_SIZEdef assign_req_to_token_pool_func(
    req_pool_indices, req_to_token, start_offset, end_offset,
    out_cache_loc, batch_size,
):
    """调度函数:根据设备类型分发到 CPU 或 GPU 核"""
    if _is_cpu:
        from sgl_kernel import assign_req_to_token_pool_cpu
        assign_req_to_token_pool_cpu(
            req_pool_indices, req_to_token, start_offset, end_offset,
            out_cache_loc, req_to_token.shape[1],
        )
        return
    assign_req_to_token_pool[(batch_size,)](
        req_pool_indices, req_to_token, start_offset, end_offset, out_cache_loc,
        req_to_token.shape[1], next_power_of_2(batch_size),
    )def alloc_for_spec_decode(
    tree_cache, req_to_token_pool, *,
    reqs, req_pool_indices,
    cur_kv_lens, cur_kv_lens_cpu,
    nxt_kv_lens, nxt_kv_lens_cpu,
    num_needed_tokens, batch=None,
):
    """统一的 spec 解码分配:evict、alloc、assign_req_to_token、更新 kv_allocated_len"""
    if num_needed_tokens <= 0:
        return
    evict_from_tree_cache(tree_cache, num_needed_tokens * req_to_token_pool.page_size)
    last_loc = get_last_loc(
        req_to_token_pool.req_to_token, req_pool_indices, cur_kv_lens,
    )
    out_cache_loc = alloc_paged_token_slots_extend(
        tree_cache, cur_kv_lens, cur_kv_lens_cpu,
        nxt_kv_lens, nxt_kv_lens_cpu, last_loc, num_needed_tokens,
        req_pool_indices=req_pool_indices, batch=batch,
    )
    assign_req_to_token_pool_func(
        req_pool_indices, req_to_token_pool.req_to_token,
        cur_kv_lens, nxt_kv_lens, out_cache_loc, len(reqs),
    )
    # 更新每个请求的 kv_allocated_len 记账
    for i, req in enumerate(reqs):
        req.kv.kv_allocated_len = int(nxt_kv_lens_cpu[i])
    return out_cache_loc
python/sglang/srt/disaggregation/decode.py core-logic

提取了 `alloc_for_decode_prealloc` 和 `alloc_for_decode_prealloc_hisparse` 两个前分配函数,将物理分配和 `req.kv` 初始化封装在一起。

# alloc_for_decode_prealloc_hisparse: HiSparse 路径的前分配
# 封装了逻辑索引分配和 kv_allocated_len 同步def alloc_for_decode_prealloc_hisparse(
    allocator: BaseTokenToKVPoolAllocator, *,
    req: Req, fill_len: int,
    uses_swa_tail: bool, swa_tail_len: int,
) -> torch.Tensor:
    """为请求预分配 HiSparse 逻辑索引,并同步 kv_allocated_len"""
    # 初始化或更新 kv_allocated_len
    if req.kv is None:
        req.kv = ReqKvInfo(kv_allocated_len=fill_len, swa_evicted_seqlen=0)
    else:
        req.kv.kv_allocated_len = fill_len
​
    device = allocator.device
    prefix_lens = torch.tensor([0], dtype=torch.int64, device=device)
    seq_lens = torch.tensor([fill_len], dtype=torch.int64, device=device)
    last_loc = torch.tensor([-1], dtype=torch.int64, device=device)
​
    if uses_swa_tail:
        kv_loc = allocator.alloc_extend_swa_tail(
            prefix_lens=prefix_lens, seq_lens=seq_lens, last_loc=last_loc,
            extend_num_tokens=fill_len, swa_tail_len=swa_tail_len,
        )
        req.swa_evicted_seqlen = fill_len - swa_tail_len
    else:
        kv_loc = allocator.alloc_logical_only(
            prefix_lens=prefix_lens, seq_lens=seq_lens, last_loc=last_loc,
            extend_num_tokens=fill_len,
        )
    return kv_locdef alloc_for_decode_prealloc(
    allocator: BaseTokenToKVPoolAllocator, *,
    req: Req, prefix_indices, prefix_len, total_prefix_len, fill_len,
) -> torch.Tensor:
    # 类似封装,处理正常路径分配和记账
    ...

评论区精华

alloc_for_spec_decode 中 req.kv 可能为 None 导致 AttributeError 正确性

Gemini Code Assist 指出在 `alloc_for_spec_decode` 中直接访问 `req.kv.kv_allocated_len`,但 `req.kv` 可能为 `None`,建议加入防御性初始化。

结论:未采纳(最终代码中仍直接访问),可能因为调用路径保证 `req.kv` 已初始化。 · unresolved

alloc_for_decode_prealloc 中 prefix_indices 切片前未检查 None 正确性

Gemini Code Assist 建议在 `prefix_len > 0` 时添加 `prefix_indices is not None` 检查,防止类型错误。

结论:未采纳,但原代码逻辑依赖 `prefix_len > 0` 时 `prefix_indices` 必然非空。 · unresolved

CI 失败皆为基础设施抖动,非本 PR 回归 other

作者在 Issue 评论中详细分析了多个 CI 失败,确认是 OOM、网络初始化等基础设施问题,与代码变更无关。

结论:确认所有 CUDA 失败均为 flake,非 PR 引入。 · 已解决

风险与影响

主要风险是回归:修改了多条分配路径的记账位置。但由于以下因素风险可控:

  1. 所有纯移动提交均通过字节级验证脚本确认;
  2. 非移动的语义变更经过等价性审查;
  3. 测试覆盖率(包括单元测试和 CI)覆盖了主要路径。
    次要风险:DSV4 NPU 上 DFlash 路径行为改变(从不可能执行到使用预留路径),若该路径被意外激活可能触发新问题。
    无性能和安全风险。

对用户:无行为变化,完全透明。
对开发者:分配集中的记账逻辑更易维护,为后续 owned-KV 完整生命周期管理奠定基础。
对系统:重构后 allocation.py 成为 KV 分配的中心,spec_utils 等模块的依赖关系减少。
影响范围:中,涉及多个模块但不影响用户可见行为。

核心路径变更 DSV4 NPU 死代码激活 机械验证覆盖 缺少防御性检查

关联 Issue

未识别关联 Issue

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

完整报告

参与讨论