Prhub

#36852 [ROCm][Bugfix] Use token-level KV indices in the aiter ASM context-prefill gather

原始 PR 作者 omirosh 合并时间 2026-08-29 08:17 文件变更 2 提交数 2 评论 1 代码增减 +170 / -40

执行摘要

修复 aiter ASM prefill 在 page_size>1 时 KV 索引错误

36758 为 gfx950 的 fp8 上下文块 prefill 增加了 ASM varlen 路径,并在默认 --page-size 1 下测得 +4-10% 吞吐提升。但该路径把 kv_indptr/kv_indices 当作页粒度,而实际上 AiterIndicesUpdaterPrefill 生成 kv_indptr = cumsum(seq_lens),create_flashinfer_kv_indices_triton 为每个 token 写一条 kv_indices,token t 的池槽位就是 kv_indices[kv_indptr[i] + t],无需 page 换算。page_size>1 时旧公式要么越界回退、要么静默读错槽位,PR body 给出 gsm8k 准确率从 0.995 跌到 0.53/0.26 的严重回退;page_size=1 下两种口径数字相同,缺陷被掩盖。

值得精读,尤其是对 AMD 注意力内核与 KV 元数据口径感兴趣的同学。核心收益有三点:一是识别出 kv_indptr/kv_indices 的 token 级语义并简化 gather;二是把不可测试的内联逻辑提取为模块级纯函数并补回退日志;三是用随机打乱页面的构造暴露 page_size=1 掩盖边界缺陷的测试设计。

讨论亮点

本 PR 没有实质性审查分歧:HaiShaw 直接批准,zijiecode 仅留 Thank you! LGTM。最有价值的讨论集中在 PR body 的根因分析:作者指出 kv_indptr/kv_indices 在每种 page_size 下都是 token 级,AiterIndicesUpdaterPrefillcumsum(seq_lens) 生成 kv_indptrcreate_flashinfer_kv_indices_triton 逐 token 写 kv_indices,因此旧代码中的 // page% page 是双重换算;page_size=1 时两种口径数值完全相同,加上单序列 + 页对齐布局的测试无法暴露错误,导致该缺陷长期未被发现。

实现拆解

  1. 修正索引口径(python/sglang/srt/layers/attention/aiter_backend.py):删除 page = self.page_size 参与的旧公式(page_slot = kv_indptr[seq_ids] + pos_in_seq // pagetok_idx = kv_pages[page_slot] * page + pos_in_seq % page),改为 token 级 kv_slot = kv_indptr[seq_ids] + pos_in_seqtok_idx = kv_indices[kv_slot]self.page_size 不再出现在 gather 中。
  2. 提取模块级纯函数:从 forward_extend 中抽出 _asm_context_prefill_gather_indices(kv_indptr, kv_indices, seq_lens, num_kv_slots, forward_mode),纯张量运算可在 CPU 上直接验证;调用点通过返回 None 表示元数据不一致并回退到 ck_tile,返回 (tok_idx, cu_k) 供 ASM 内核使用。
  3. 规范回退日志:第二道边界检查(tok_idx 越出池)原先静默回退,现与第一道一样打印 forward_modebs、越界值与表长,便于线上判断 ASM 路径是否真的命中,还是悄悄转换成 ck_tile
  4. 新增 CPU 单测(test/registered/attention/test_aiter_asm_prefill_gather.py):用 _paged_pool 随机打乱页面布局模拟真实分配器,覆盖 page_size 1/16/64、多序列、未对齐 seq_lens、单序列以及元数据过短/池越界回退分支,直接校验 tok_idxcu_k。测试注册到 stage-b-test-1-gpu-small-amd,仅需 CPU,成本很低。
文件 模块 状态 重要度
python/sglang/srt/layers/attention/aiter_backend.py 注意力后端 modified 7.67
test/registered/attention/test_aiter_asm_prefill_gather.py 索引测试 added 7.18

关键符号

_asm_context_prefill_gather_indices _paged_pool _token_level_metadata _check test_page_sizes test_single_sequence test_unaligned_seq_lens test_falls_back_when_metadata_is_short test_falls_back_when_slot_exceeds_pool

关键源码片段

python/sglang/srt/layers/attention/aiter_backend.py core-logic

核心修改文件:修正 gfx950 ASM 上下文块 prefill 的 KV gather 索引口径,从页粒度改为 token 级,并提取模块级纯函数、补齐回退日志。

def _asm_context_prefill_gather_indices(
    kv_indptr: torch.Tensor,
    kv_indices: torch.Tensor,
    seq_lens: torch.Tensor,
    num_kv_slots: int,
    forward_mode=None,
):
    """计算 ASM 上下文块 prefill 的 KV 池槽位映射。    kv_indptr/kv_indices 在任意 page_size 下都是 token 级:
    AiterIndicesUpdaterPrefill 设置 kv_indptr = cumsum(seq_lens),
    而 create_flashinfer_kv_indices_triton 为每个 token 写一条 kv_indices,
    所以序列 i 的 token t 位于 kv_indices[kv_indptr[i] + t],
    这里不需要再做任何 page 算术。
    """
    # 把元数据统一成 long,并截断到 kv_indices 实际拥有的条目数,
    # 避免 mixed/spec 批次下 gather 越过表尾。
    bs = kv_indptr.numel() - 1
    device = kv_indices.device
    kv_indptr = kv_indptr.to(torch.long)
    seq_lens = seq_lens.to(device=device, dtype=torch.long)
    seq_lens = torch.minimum(seq_lens, kv_indptr[1:] - kv_indptr[:bs])
​
    # 构造 token 级索引:cu_k 是每个序列的累计起点,
    # seq_ids/pos_in_seq 把扁平位置还原成 (seq, pos) 对。
    total_k = int(seq_lens.sum().item())
    cu_k = torch.zeros(bs + 1, dtype=torch.long, device=device)
    torch.cumsum(seq_lens, 0, out=cu_k[1:])
    seq_ids = torch.repeat_interleave(torch.arange(bs, device=device), seq_lens)
    pos_in_seq = torch.arange(total_k, device=device) - cu_k[seq_ids]
    kv_slot = kv_indptr[seq_ids] + pos_in_seq
​
    # 第一道边界检查:kv_slot 必须落在 kv_indices 表内,
    # 否则说明元数据口径不一致,回退到 paged kernel。
    if total_k and int(kv_slot.max().item()) >= kv_indices.numel():
        logger.warning(
            "[asm-context-prefill] metadata mismatch, falling back:"
            " mode=%s bs=%s kv_slot_max=%s kv_indices=%s seq_lens=%s kv_indptr=%s",
            forward_mode,
            bs,
            int(kv_slot.max().item()),
            kv_indices.numel(),
            seq_lens.tolist(),
            kv_indptr.tolist(),
        )
        return None
​
    # 直接按 token 级表 gather 出池槽位,再做第二道边界检查:
    # tok_idx 必须落在 KV 池内,否则同样回退并通过日志暴露原因。
    tok_idx = kv_indices[kv_slot].to(torch.long)
    if total_k and int(tok_idx.max().item()) >= num_kv_slots:
        logger.warning(
            "[asm-context-prefill] gather index out of pool, falling back:"
            " mode=%s bs=%s tok_idx_max=%s num_kv_slots=%s",
            forward_mode,
            bs,
            int(tok_idx.max().item()),
            num_kv_slots,
        )
        return None
​
    # 返回 (token 到池槽的映射 , 每个序列的累计 token 数 ),供 ASM 内核使用。
    return tok_idx, cu_k
test/registered/attention/test_aiter_asm_prefill_gather.py test-coverage

新增 CPU 单测,用随机打乱页面的池验证 page_size 1/16/64 下 gather 索引正确与回退分支,是回归防护的关键。

def _paged_pool(seq_lens, page_size, seed, headroom=2):
    """用随机打乱的页面把每条序列铺在池中,模拟 page allocator 行为。    返回 (req_to_token, num_kv_slots):req_to_token[i][t] 是序列 i 第 t 个
    token 所在的池槽位。页面随机发放,保证正确的 gather 不能依赖序列在池中
    连续,从而在单测里复现 page_size>1 才会触发的索引错误。
    """
    num_pages = headroom * sum((n + page_size - 1) // page_size for n in seq_lens)
    g = torch.Generator().manual_seed(seed)
    free_pages = torch.randperm(num_pages, generator=g).tolist()
    req_to_token = []
    for n in seq_lens:
        slots = []
        for _ in range((n + page_size - 1) // page_size):
            base = free_pages.pop() * page_size
            slots.extend(range(base, base + page_size))
        req_to_token.append(slots[:n])
    return req_to_token, num_pages * page_size
​
​
def _token_level_metadata(req_to_token):
    """按 AiterIndicesUpdaterPrefill 的方式构造 kv_indptr/kv_indices。"""
    seq_lens = torch.tensor([len(s) for s in req_to_token], dtype=torch.long)
    kv_indptr = torch.zeros(len(req_to_token) + 1, dtype=torch.long)
    torch.cumsum(seq_lens, 0, out=kv_indptr[1:])
    kv_indices = torch.tensor(
        [slot for slots in req_to_token for slot in slots], dtype=torch.int32
    )
    return kv_indptr, kv_indices, seq_lens

评论区精华

批准与合并 other

HaiShaw 无说明批准;zijiecode 留 `Thank you! LGTM`。PR body 中作者解释了根因:kv_indptr/kv_indices 是 token 级、page_size=1 时两种口径重合,导致问题难以发现。

结论:无实质分歧,已合并。 · 已解决

风险与影响

主要风险集中在 AMD/gfx950 专用的 fp8 上下文块 prefill 路径:(1)若未来出现真正页粒度的 kv_indices 生产者,token 级公式会失效,但目前所有生产者均按 token 级写入,且文件内已有页粒度与 token 级两种口径并存,靠两道边界检查兜底,建议后续统一元数据口径;(2)回退仍依赖边界检查,num_kv_slotskv_indices 长度错误会改变是回退还是越界读的判断,新增测试覆盖了这两类场景;(3)forward_extend 调用点任何元数据不一致都会导致 ASM 路径静默退化为 ck_tile,虽然正确但丢掉性能,新增日志可辅助排查;(4)本变更只影响 page_size > 1 的运行,默认 page_size=1 无行为变化,对现有 CUDA/默认部署无回归面。

影响范围主要为使用 ROCm/gfx950(如 MI355X)与 fp8 KV cache、head_dim=256 并开启上下文块 prefill 的用户。修复后 page_size>1 不再出现静默错误结果,且能真正命中 ASM 优化路径;同时把索引逻辑变成可在 CPU 上单测的纯函数,降低后续内核改动风险。默认 --page-size 1 部署完全不变。团队侧新增一个 AMD CI 注册的小型单测(stage-b-test-1-gpu-small-amd),测试成本低。PR 页面显示的三个 CI 状态均为失败,合并时需确认是否与本次改动相关。

AMD/gfx950 专用路径 回退依赖边界检查 核心注意力路径改动 默认 page_size=1 无行为变化

关联 Issue

未识别关联 Issue

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

完整报告

参与讨论