Prhub

#37511 Size the unified read-table grid from bs, and fuse the allocator's tombstone scatters

原始 PR 作者 ch-wan 合并时间 2026-09-03 07:54 文件变更 5 提交数 3 评论 1 代码增减 +245 / -44

执行摘要

优化 CUDA 图读表网格大小与融合分配器墓碑写入,大幅提升解码性能。

根据 PR body 描述,统一内存池热路径存在两个独立开销:

  1. build_kv_read_table 在 CUDA 图捕获路径下,其内核网格大小被 max_context_len 决定,导致每次解码回放时启动大量无效块,其开销不随序列长度变化,是纯块调度开销。
  2. 分配器的 _free_lazy 释放路径发起了五个内核(v2p gather, 两个 index_fill_,两个 torch.cat),而其中 v2p 查找和两次墓碑散布可合并为单个内核。这些开销在大批次(cuda_graph_max_bs)下变得显著。

这是一个经过精心设计、附有详尽性能数据和测试验证的重要性能优化 PR。其核心思想(基于实际需求调整 CUDA 图网格、融合内核操作)值得团队学习。PR 作为四部分系列的第一部分,其变更相对独立且为后续优化奠定了基础。建议技术管理者和相关领域工程师(尤其是关注内存管理、内核性能和 CUDA 图优化的工程师)精读此 PR,特别是 kv_read_table.py 的网格调整逻辑和 virtual_slot.py 的新融合内核实现。

讨论亮点

PR 评论区仅包含 Codex 机器人自动评论,无实质性技术讨论。核心设计决策与权衡均在 PR body 中已由作者详细阐述,包括网格大小优化的性能数据、内核融合的理论依据、以及测试策略的变更原因。

实现拆解

1. 重构 build_kv_read_table 内核网格与循环结构

  • 涉及文件:python/sglang/kernels/ops/kvcache/kv_read_table.py
  • 关键变更:
    1. 将内核 _BLOCK_COLS 从 256 调整为 512,并引入 _TARGET_BLOCKS=1024 作为目标块数。
    2. 修改 build_kv_read_table_kernel 内核实现:将原本基于 (req, blk) 的二维网格启动,改为基于 (req, col_program) 的启动,其中每个 req 程序通过内部循环(for start in range(...))遍历其拥有的列,循环边界由设备端 seq_lens 决定。
    3. 修改 build_kv_read_table Python 接口的网格大小计算:将列方向程序数(col_programs)计算为 min(cdiv(_TARGET_BLOCKS, bs), cdiv(max_pages, _BLOCK_COLS)),从而将网格大小与批次大小 bs 关联,并传递 col_stride(col_programs * _BLOCK_COLS)给内核。
  • 原因:确保 CUDA 图捕获的网格大小静态且合理,避免在回放时因序列实际长度远小于 max_context_len 而浪费大量无效块。内部循环由实际序列长度控制,保证了性能随序列长度变化。
  • 影响:该内核是解码步骤中构建 KV 读表的关键,其性能直接影响每次解码步骤的延迟。优化后,在短序列场景下可获得数十至上百倍的内核启动速度提升。

2. 新增融合 Triton 内核 free_unbind_inplace_kernel 与 bind_inplace_kernel

  • 涉及文件:python/sglang/kernels/ops/memory/virtual_slot.py
  • 关键变更:
    1. 新增 free_unbind_inplace_kernel:该内核接受虚拟页 ID 数组,对于每个页面,从 v2p 表中读取对应的物理页 ID,然后将 v2p 和 p2v 表中的对应条目置为 -1(墓碑化),并将读取到的物理页 ID 输出。这是一个每个 lane 独立的操作,无跨 lane 依赖。
    2. 新增 bind_inplace_kernel:与 alloc_bind_inplace_kernel 类似,但接受调用者提供的物理页 ID 数组(而非内核生成递增范围),用于空洞合并的慢速路径。
    3. 新增对应的 Python 接口函数 free_unbind_inplace 和 bind_inplace。当设备为 CPU 时,回退到纯 PyTorch 参考实现。
  • 原因:将 _free_lazy 路径中分散的 v2p 查找和两次 index_fill_ 坟碑操作融合为一次内核启动,减少了内核启动次数和设备同步开销。同时为 bind 操作提供了基于内核的高效实现。
  • 影响:为分配器路径的进一步融合奠定了基础。

3. 更新 MultiEndedAllocator 以使用新融合内核

  • 涉及文件:python/sglang/srt/mem_cache/multi_ended_allocator.py
  • 关键变更:
    1. 导入新增的 bind_inplace 和 free_unbind_inplace。
    2. 修改 MultiEndedAllocator.bind 方法:将原来的两次索引赋值(self.virtual_to_physical[virtual_ids] = physical_ids 等)替换为调用 bind_inplace(virtual_ids, physical_ids, self.virtual_to_physical, self.physical_to_virtual)。
    3. 修改 MultiEndedAllocator._free_lazy 方法:移除显式的 v2p 查找(freed_p_pages = self.virtual_to_physical[free_v_pages])和两次 index_fill_ 调用,替换为一次调用 freed_p_pages = free_unbind_inplace(free_v_pages, self.virtual_to_physical, self.physical_to_virtual)。
  • 原因:实际应用新的融合内核,减少 _free_lazy 路径的内核启动数量。
  • 影响:直接减少了释放和绑定操作的内核启动次数和执行时间,降低了分配器路径的总体延迟。

4. 更新墓碑写入测试以适应新机制

  • 涉及文件:test/registered/unit/mem_cache/test_unified_free_no_host_sync.py
  • 关键变更:
    1. 修改测试 test_free_paths_actually_write_a_tombstone:原来断言源码中包含 index_fill_,现在改为断言源码中包含 _NO_SYNC_TOMBSTONE_FORMS(("index_fill_", "free_unbind_inplace"))中任一形式,以支持新融合内核作为墓碑写入的合法机制。
    2. 新增测试类 TestFusedTombstoneWritesBothTables:包含三个 CUDA 门控的测试,分别验证 free_unbind_inplace 在随机绑定下的正确性、与 CPU 参考实现的一致性、以及空输入的 no-op 行为。
  • 原因:确保测试验证的是设计不变量(墓碑值 -1 不穿越主机总线),而非具体的实现细节(index_fill_),从而在更换实现机制时测试仍然有效并验证新机制。
  • 影响:增强了测试的健壮性和对实现变更的适应性,并直接覆盖了新内核的关键逻辑。
文件 模块 状态 重要度
python/sglang/kernels/ops/memory/virtual_slot.py 内核 modified 7.28
python/sglang/kernels/ops/kvcache/kv_read_table.py 内核 modified 5.33
test/registered/unit/mem_cache/test_unified_free_no_host_sync.py 内存池测试 modified 7.04
python/sglang/srt/mem_cache/multi_ended_allocator.py 分配器 modified 6.9

关键符号

build_kv_read_table free_unbind_inplace bind_inplace MultiEndedAllocator.bind MultiEndedAllocator._free_lazy

关键源码片段

python/sglang/kernels/ops/memory/virtual_slot.py infrastructure

新增了两个核心融合 Triton 内核 `free_unbind_inplace_kernel` 和 `bind_inplace_kernel`,这是解决分配器路径多次内核启动问题的关键实现。

# 内核 `free_unbind_inplace_kernel`,是 `alloc_bind_inplace_kernel` 的逆操作。
# 每个 lane 独立处理一个虚拟页:读取 v2p 表得到物理页 ID,然后将 v2p 和 p2v
# 表中的对应条目置为 -1(墓碑化),并输出物理页 ID。要求输入的 v_pages 唯一。
@triton.jit
def free_unbind_inplace_kernel(
    v_pages_ptr, # in: [N] int64 — 被释放的虚拟页 ID
    v2p_ptr, # in/out: int64 — virtual_to_physical 表
    p2v_ptr, # in/out: int64 — physical_to_virtual 表
    out_phys_ptr, # out: [N] int64 — 被释放的物理页 ID
    N, # 运行时参数:要释放的页数
    BLOCK: tl.constexpr,
):
    pid = tl.program_id(0)
    offs = pid * BLOCK + tl.arange(0, BLOCK)
    mask = offs < N
​
    v = tl.load(v_pages_ptr + offs, mask=mask, other=0).to(tl.int64)
    p = tl.load(v2p_ptr + v, mask=mask, other=0).to(tl.int64)
​
    tl.store(out_phys_ptr + offs, p, mask=mask)
    tl.store(v2p_ptr + v, -1, mask=mask) # 墓碑化 v2p
    tl.store(p2v_ptr + p, -1, mask=mask) # 墓碑化 p2v
​
# 内核 `bind_inplace_kernel`,为调用者提供的物理页范围进行绑定。
# 与 alloc 路径的递增范围不同,这里用于空洞合并等场景。
@triton.jit
def bind_inplace_kernel(
    v_pages_ptr, # in: [N] int64 — 虚拟页 ID
    p_pages_ptr, # in: [N] int64 — 要绑定的物理页 ID
    v2p_ptr, # in/out: int64
    p2v_ptr, # in/out: int64
    N, # 运行时参数
    BLOCK: tl.constexpr,
):
    pid = tl.program_id(0)
    offs = pid * BLOCK + tl.arange(0, BLOCK)
    mask = offs < N
​
    v = tl.load(v_pages_ptr + offs, mask=mask, other=0).to(tl.int64)
    p = tl.load(p_pages_ptr + offs, mask=mask, other=0).to(tl.int64)
​
    tl.store(v2p_ptr + v, p, mask=mask)
    tl.store(p2v_ptr + p, v, mask=mask)
python/sglang/kernels/ops/kvcache/kv_read_table.py infrastructure

修改了 `build_kv_read_table` 内核的网格计算和内部循环,解决了 CUDA 图捕获下网格固定导致的无效块调度问题。

# 修改后的 `build_kv_read_table_kernel` 内核核心逻辑
# 每个 `req` 程序(第一维)处理一个序列,通过内部循环处理多个列块。
# 循环的范围由实际序列长度 (`seq_lens`) 决定,而非固定的 `max_pages`。
@triton.jit
def build_kv_read_table_kernel(
    req_to_token_ptr, req_pool_indices_ptr, seq_lens_ptr,
    v2p_ptr, out_ptr, req_stride, out_stride, mult,
    col_stride, # 新增:每次循环迭代,程序在列方向上前进的步长
    PAGE_SIZE: tl.constexpr, BLOCK: tl.constexpr,
):
    bid = tl.program_id(0)
    req = tl.load(req_pool_indices_ptr + bid).to(tl.int64)
    seqlen = tl.load(seq_lens_ptr + bid)
    n_pages = (seqlen + PAGE_SIZE - 1) // PAGE_SIZE
​
    row_in = req_to_token_ptr + req * req_stride
    row_out = out_ptr + bid.to(tl.int64) * out_stride
​
    # 每个程序通过循环,处理从 `tl.program_id(1) * BLOCK` 开始,
    # 步长为 `col_stride`,直到 `n_pages` 的所有列块。
    for start in range(tl.program_id(1) * BLOCK, n_pages, col_stride):
        cols = start + tl.arange(0, BLOCK)
        mask = cols < n_pages
        # ... 加载 tok, 计算 page, phys, entry ...
        tl.store(row_out + cols, entry, mask=mask)
​
# 在 Python 层,网格大小计算与 `bs` 关联
col_programs = min(
    triton.cdiv(_TARGET_BLOCKS, bs),
    triton.cdiv(max_pages, _BLOCK_COLS)
)
build_kv_read_table_kernel[(bs, col_programs)](
    ..., col_programs * _BLOCK_COLS, # 传递列步长
    ...
)

评论区精华

没有提炼出高价值讨论线程

当前评论区没有形成足够清晰的争议点或结论,后续有更多讨论时会体现在这里。

风险与影响

  1. 内核正确性风险:新引入的 free_unbind_inplace_kernel 直接操作 v2p 和 p2v 表。其正确性依赖于调用者确保输入的虚拟页 ID (v_pages) 是唯一的。PR 中明确指出了这一前提("That holds only because the caller's ids are unique")。如果调用方违反此约定,可能导致表项竞争。
  2. 性能回归风险:build_kv_read_table 的网格大小计算从固定的 max_pages 变为依赖 bs 的动态计算。虽然作者进行了充分的性能测试(覆盖 bs 1..256 × seq 1k..128k),但极端场景(如极小 bs 或极长 seq)的性能需要持续监控。col_programs 被 min 函数限制,避免了 bs 过小导致的过度并行。
  3. 测试覆盖风险:TestFusedTombstoneWritesBothTables 测试类被 @unittest.skipUnless(torch.cuda.is_available(), ...) 装饰,意味着在无 CUDA 设备的 CI 环境中,新内核逻辑不会被执行。PR body 提到已添加 CUDA-gated 覆盖,但需要确保 CI 矩阵包含 CUDA 环境来运行这些测试。
  1. 用户影响:对于使用统一内存池(--enable-unified-memory)并启用 CUDA 图的用户,预期可观察到解码速度提升。PR 中提供了端到端测试数据,在 Kimi-Linear-48B TP2 场景下,解码吞吐量提升约 1.5%。
  2. 系统影响:优化位于内存管理核心路径(分配/释放)和 KV 缓存准备路径(读表构建),影响所有使用统一内存池的推理工作负载。减少内核启动次数有助于降低 GPU 调度开销和整体系统延迟。
  3. 团队影响:引入了新的内核融合模式(free_unbind_inplace),为后续进一步优化分配器路径(如 PR body 中提及的 out 缓冲区大小优化 #37512)铺平了道路。测试策略的变更(验证不变量而非具体操作)也为其他类似优化提供了参考。
内核正确性风险 CI 测试覆盖范围 性能基准测试要求

关联 Issue

未识别关联 Issue

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

完整报告

参与讨论