Prhub

#33676 [NPU] Support DeepSeek-V4 DSpark and refactor DSV4 cache management

原始 PR 作者 2044145178 合并时间 2026-08-17 16:27 文件变更 37 提交数 36 评论 12 代码增减 +2017 / -2025

执行摘要

NPU 支持 DSV4 DSpark 推测解码并重构缓存所有权

PR body 明确动机:"This PR adds NPU support for DeepSeek-V4 DSpark speculative decoding. It also consolidates the DSV4 NPU cache and memory-pool implementation and includes several small execution-path optimizations." 此前 DSV4 的 DSpark 推测解码只支持 CUDA,NPU 用户无法使用该能力;同时旧 NPU 缓存实现为融合压缩算子单独设计了一套 paged state pool(cache_mode=1),与 GPU 端 ring 所有权规则分叉,导致缓存分配、请求表、P/D 传输中存在重复与不一致的记账逻辑,需要收敛。

值得精读,尤其关注三点设计决策:一是 C128 sidecar 所有权模型——只让“完整物理页”进入 Radix 树、部分尾页归请求所有,避免回退到更短前缀的匹配退化和页级引用计数复杂度;二是 cache_mode=2 显式状态块表对 GPU 与 A3 ABI 的适配(_build_explicit_state_block_table 的掩码列 clamp + dummy 正数偏移),这是平台差异的优雅收敛点;三是“NPU 专属逻辑下沉到 hardware_backend/npu 并用 guard 隔离”的分层策略。建议后续补上 C128 sidecar 的单元测试与 compact/ragged verify 路径。

讨论亮点

评审共 5 条 comment,集中在三处:

  1. verify 宽度读取来源(AndyLi429):在 ascend_dsv4_backend.py_build_npu_compress_metadata 上提问 use spec_info.draft_token_num?,针对原实现用 self.speculative_num_draft_tokens 的问题。head 已采纳为 int(forward_batch.spec_info.draft_token_num),确保图重放时 verify 元数据与当前 speculative forward 的真实 draft 数量一致。

  2. 池类型判断方式(randgun):在 disaggregation/utils.py 第 1168 行建议 maybe use isinstance(token_to_kv_pool, DSV4NPUTokenToKVPool) is better?,属于可读性建议;材料中未见明确落地,需保留为待核对项。

  3. 多流不要覆盖 extend(randgun):在 deepseek_v4.py 第 1390 行要求多流启用条件 change to not extend。head 最终条件已带上 not forward_batch.forward_mode.is_extend_or_draft_extend_or_mixed(),即 NPU 多流只作用于 decode/verify。

实现拆解

本 PR 的变更入口有两个:python/sglang/srt/models/deepseek_v4_dspark.py(决定 NPU 上能否加载 DSpark 模型并跑三段 draft 前向)与 python/sglang/srt/hardware_backend/npu/attention/ascend_dsv4_backend.py(决定压缩器/索引器元数据是否匹配 A3 算子 ABI)。实现可拆成 5 步:

  1. DSpark 模型接入与 NPU 权重契约deepseek_v4_dspark.py 新增 _remap_dspark_weight_name_npu,只接受 mtp.* 前缀权重并按 self_attn/ffn/markov_head 等规则重映射;NPU checkpoint 自带 QuaRot 对齐的 embed/LM head,故 uses_own_vocab_modules = is_npu()attach_shared_modules 在 NPU 上不再共享 target 词表模块;shared_experts_fusion_disable_reason 在 NPU 上返回禁用原因(ModelSlim 权重映射不支持共享专家融合)。modelslim/modelslim.py 配套增加 W4A8/W8A8 的 NPU 兼容处理。

  2. 压缩器后端切到 cache_mode=2 显式状态表ascend_dsv4_backend.py 新增 _build_explicit_state_block_table,把 GPU 风格 state location 适配成 A3 的 table ABI:C4 状态经 full→SWA→state 两级翻译,C128 状态按 req_pool_idx + 绝对位置 寻址;无效/历史 padding 位置统一写入 dummy_state_loc(正数、清零行),规避 A3 算子不认 -1 的问题。删除原 _overlap_transform 及其 cache_mode=1 的 state page table 清零逻辑,prefill metadata 中 seqused 显式计算、dsv4_max_input_capacity 按批内最大 chunk 长度构造。

  3. 缓存与内存池所有权重构:这是 commit 0d5167e 的核心。dsv4_memory_pool.pyNPUCompressStatePool 从 paged 池退化为 GPU ring 状态池的薄适配层(继承分配/ring 所有权/地址翻译,仅补 FP32 断言、3-D 视图和 dummy 行);dsv4_allocator.py 删除 C4 KV 与两套状态分配器,C4 位置改由 _derive_c4_loc_from_full 从 full 槽位派生(% 4 == 3 判定完整组),C128 升级为独立物理页大小并带 c128_page_refcountdsv4_req_to_token_pool.py 把原来的 req_to_token_swa/c4/c128/c4_state/c128_state 五张表收敛为一张 req_to_c128_sidecar,并支持 Radix 匹配结果经 Req.c128_prefix_page_ids 临时挂载后由 alloc 安装;dsv4_common_hooks.py 收窄为只写 C128 sidecar、只构造 C128 KV 的 PD payload,SWA/C4 状态复用公共传输路径。

  4. 新增 Radix C128 sidecar 组件:全新 c128_sidecar_component.py 实现 C128SidecarComponent(TreeComponent),有意识地把“完整 C128 物理页”作为 Radix 树上可匹配的最小单元、部分尾页仍归请求所有;插入时按 128 * page_size 组边界物化树节点并 _attach page,驱逐时维护设备释放与引用计数,且 C128 页不参与公开 token 驱逐计数,配合会话覆盖推进/回退与节点分裂时的 SWA rebuild 对齐。

  5. 执行路径优化、图重放与 P/D 配套deepseek_v4.py 新增 _forward_prepare_multi_stream_npu,把 KV 投影与 Q 投影分别放到独立 NPU 流上与索引器/压缩器重叠,并显式管理事件依赖;条件带 not is_extend_or_draft_extend_or_mixed(),只覆盖 decode/verify。dspark_worker_v2.pydflash_info.py 适配 graph 重放与 DP-attention idle rank;新增 dflash_disaggregation.pyextra_ops_loader.py,后者通过 SGLANG_DSPARK_EXTRA_OPS_SO 环境变量加载并校验独立 .so 算子(npu_sparse_attn_sharedkv(_metadata))。

测试与部署配套test_npu_ascend_dsv4_backend.py 删除了 _overlap_transform 的 7 个单测(该函数被显式状态表取代);未新增 NPU DSpark 端到端单测,验证依赖单节点 16 NPU 手动启动脚本与 GPQA-Diamond 精度(0.894)。临时外部算子依赖(Compressor、DSpark 稀疏注意力)通过 ASCEND_CUSTOM_OPP_PATH / SGLANG_DSPARK_EXTRA_OPS_SO 注入,待 sgl-kernel-npu#689/#699/#708 合入后移除。

文件 模块 状态 重要度
python/sglang/srt/hardware_backend/npu/dsv4/c128_sidecar_component.py Radix 缓存 added 9.08
python/sglang/srt/hardware_backend/npu/attention/ascend_dsv4_backend.py 注意力后端 modified 8.65
python/sglang/srt/hardware_backend/npu/dsv4/dsv4_allocator.py 分配器 modified 8.69
python/sglang/srt/hardware_backend/npu/dsv4/dsv4_memory_pool.py 内存池 modified 8.69
python/sglang/srt/hardware_backend/npu/dsv4/dsv4_common_hooks.py 缓存钩子 modified 8.49
python/sglang/srt/hardware_backend/npu/dsv4/dsv4_req_to_token_pool.py 映射池 modified 8.29
python/sglang/srt/hardware_backend/npu/extra_ops_loader.py 算子加载 added 8.2
python/sglang/srt/models/deepseek_v4_dspark.py DSpark 模型 modified 8.07
python/sglang/srt/models/deepseek_v4.py 模型注意力 modified 7.37
python/sglang/srt/speculative/dflash_disaggregation.py 分离式调度 added 6.84
test/registered/unit/npu/attention/test_npu_ascend_dsv4_backend.py 注意力测试 modified 6.42

关键符号

C128SidecarComponent.commit_insert_component_data C128SidecarComponent._attach C128SidecarComponent.finalize_match_result_in_cache C128SidecarComponent.evict_component _build_explicit_state_block_table CompressorAscendBackendMixin._build_npu_compress_metadata CompressorAscendBackendMixin._build_npu_compress_metadata_prefill DSV4NPUTokenToKVPoolAllocator._derive_c4_loc_from_full DSV4NPUTokenToKVPoolAllocator.retain_c128_pages NPUCompressStatePool._replace_invalid_with_dummy NPUCompressStatePool.translate_from_swa_loc_to_state_loc DeepseekV4ForCausalLMDSpark._remap_dspark_weight_name_npu DeepSeekV4Attention._forward_prepare_multi_stream_npu DSV4ReqToTokenTablesMixin.write_c128 DSV4ReqToTokenTablesMixin.set_c128_prefix_pages TorchOpLoader.initialize build_dflash_family_disagg_draft_input

关键源码片段

python/sglang/srt/hardware_backend/npu/dsv4/c128_sidecar_component.py core-logic

本次缓存重构的核心新增:C128 sidecar 所有权模型,把完整 C128 物理页挂到 Radix 树节点,贯穿插入、分裂、驱逐、会话推进全生命周期。

class C128SidecarComponent(TreeComponent):
    """C128 sidecar 所有权模型:只把完整的物理 C128 page 暴露给 Radix 树。    部分尾页仍然由请求自身持有,避免把未闭合的压缩组写进共享前缀。
    """
    component_type = ComponentType.C128
​
    @property
    def allocator(self):
        # DSV4 NPU 分配器持有 C128 page 引用计数,归还由它统一处理。
        return self.cache.token_to_kv_pool_allocator
​
    def _attach(self, node: UnifiedTreeNode, pages: torch.Tensor) -> None:
        if pages.numel() == 0:
            return
        ct = self.component_type
        cd = node.component_data[ct]
        assert cd.value is None
        value = pages.clone() # 克隆后再挂树,避免后续写入污染共享前缀
        self.tree_core.set_component_device_value(node.id, ct, value)
        self.allocator.retain_c128_pages(value) # 增加 page 引用计数,防提前回收
​
    def commit_insert_component_data(
        self,
        node: UnifiedTreeNode,
        is_new_leaf: bool,
        params: InsertParams,
        result: InsertResult,
        cache_actions: list[CacheAction | ComponentAction],
    ) -> None:
        if not is_new_leaf:
            return
        assert params.key is not None
        assert params.c128_value is not None
​
        # Full/SWA 可能先把新后缀表示成一条长叶子;这里把每个完整的
        # C128 组边界都物化成树节点并挂上对应 page,让后续分支总能匹配到
        # 最近的完整 C128 page 前缀,而不是回退到更短的旧 Radix 节点。
        group_tokens = 128 * self.allocator.c128_attn_allocator.page_size
        first_boundary = (result.prefix_len // group_tokens + 1) * group_tokens
        for boundary in range(first_boundary, len(params.key) + 1, group_tokens):
            boundary_node = self._ensure_boundary_node(node, boundary, cache_actions)
            page_index = boundary // group_tokens - 1
            self._attach(boundary_node, params.c128_value[page_index : page_index + 1])
​
    def evict_component(
        self,
        node: UnifiedTreeNode,
        device_frees: dict[ComponentType, list[torch.Tensor]],
        host_frees: dict[ComponentType, list[torch.Tensor]],
        target: EvictLayer = EvictLayer.DEVICE,
    ) -> tuple[int, int]:
        cd = node.component_data[self.component_type]
        if EvictLayer.DEVICE in target and cd.value is not None:
            device_frees[self.component_type].append(cd.value)
            self.tree_core.component_evictable_size_[self.component_type] -= len(cd.value)
            cd.value = None
        # C128 页是 Full token 的辅助数据,不参与公开 token 驱逐计数。
        return 0, 0
python/sglang/srt/hardware_backend/npu/attention/ascend_dsv4_backend.py core-logic

压缩器 / 索引器后端从 cache_mode=1 paged 状态池切换到 cache_mode=2 显式状态块表,新增 A3 算子 ABI 适配,是本 PR 正确性最敏感的文件。

def _build_explicit_state_block_table(
    *,
    compress_ratio: int,
    coff: int,
    state_pool,
    token_to_kv_pool,
    req_to_token: torch.Tensor,
    req_pool_indices: torch.Tensor,
    start_pos: torch.Tensor,
    cu_seqlens: torch.Tensor,
    seqused: torch.Tensor,
    max_input_capacity: int,
) -> torch.Tensor:
    """把 GPU 风格的 state location 适配成 A3 cache_mode=2 的 table ABI。    cache_mode=2 需要一张显式的 state_block_table:历史列 + 当前输入列,
    列宽固定为 coff * ratio + max_input_capacity;无效/越界位置写入
    dummy_state_loc(正数、清零行),因为 A3 算子只接受无符号偏移。
    """
    req_pool_indices = req_pool_indices.to(torch.int64)
    capacities = cu_seqlens[1:] - cu_seqlens[:-1]
    history_size = coff * compress_ratio
    width = history_size + max_input_capacity
    columns = torch.arange(width, dtype=torch.int64, device=req_to_token.device)
    positions = start_pos[:, None] - history_size + columns
    within_capacity = columns[None, :] < history_size + capacities[:, None]
    valid = (seqused[:, None] > 0) & within_capacity & (positions >= 0)
​
    if compress_ratio == 4:
        # C4 状态跟随 SWA 物理页:先 full→SWA 再 SWA→state 两级翻译。
        # 掩码列仍会在 torch.where 之前被索引,所以先 clamp 防越界。
        safe_positions = positions.clamp(0, req_to_token.shape[1] - 1)
        full_locs = req_to_token[req_pool_indices[:, None], safe_positions]
        swa_locs = token_to_kv_pool.translate_loc_from_full_to_swa(full_locs)
        state_locs = state_pool.translate_from_swa_loc_to_state_loc(swa_locs)
    else:
        # C128 状态按 req_pool_idx + 绝对位置寻址,走独立侧车映射。
        state_locs = state_pool.translate_from_req_position_to_state_loc(
            req_pool_indices[:, None], positions
        )
​
    return torch.where(
        valid,
        state_locs.to(torch.int32),
        state_pool.dummy_state_loc,
    ).contiguous()
python/sglang/srt/hardware_backend/npu/dsv4/dsv4_memory_pool.py core-logic

NPUCompressStatePool 从 paged cache_mode=1 池改为 GPU ring 状态池薄适配层,C128 池引入独立物理页大小,是缓存重构的关键契约。

class NPUCompressStatePool(CompressStatePool):
    """A3 上的薄适配层:直接复用 GPU 版 ring 状态池的分配与所有权规则。    旧实现为 NPU 单独做了一套 paged state pool(cache_mode=1,block 0 作
    skip-sentinel);新实现改走 cache_mode=2 显式位置表,因此状态缓冲的
    分配、ring 所有权和地址翻译全部继承自 CompressStatePool。NPU 侧只需:
      1. 提供连续 3-D 视图;
      2. 强制 FP32 契约(A3 custom.compressor 只接受 FP32 state_cache);
      3. 把非法位置替换为已清零的“正数”dummy 行——显式模式下位置 0 合法,
         不能再用 -1 表示无效(A3 算子消费无符号偏移)。
    """
​
    def __init__(self, *, size, overlap, head_dim, dtype, device,
                 enable_memory_saver, ratio, ring_size, swa_page_size):
        assert ratio in (4, 128), f"NPUCompressStatePool 只支持 ratio 4/128,收到 {ratio}"
        assert dtype == torch.float32, f"Atlas A3 custom.compressor 需要 FP32 state_cache,收到 {dtype}"
        assert ring_size > 0, f"ring_size 必须为正,收到 {ring_size}"
​
        super().__init__(
            size=size, ring_size=ring_size, overlap=overlap, head_dim=head_dim,
            dtype=dtype, device=device, enable_memory_saver=enable_memory_saver,
            ratio=ratio, online=False, swa_page_size=swa_page_size,
            state_cache_page_size=ring_size,
        )
        # 末行预先清零,作为“无效”位置的落点;A3 算子吃无符号偏移。
        self.dummy_state_loc = self._size - 1
​
        if ratio == 128:
            # 共享池只会初始化 dummy 行;冷启动的 C128 请求 bank 在首次
            # 部分使用前需要把每一行都初始化好。
            self.kv_score_buffer.clear()
​
    def _replace_invalid_with_dummy(self, state_loc: torch.Tensor) -> torch.Tensor:
        return torch.where(
            state_loc < 0,
            torch.full_like(state_loc, self.dummy_state_loc),
            state_loc,
        )

评论区精华

verify 宽度改读 spec_info.draft_token_num 正确性

AndyLi429 在 ascend_dsv4_backend.py 的 _build_npu_compress_metadata 中对原实现 self.speculative_num_draft_tokens 提问 "use spec_info.draft_token_num?",意在确保图捕获 / 重放时使用的 verify 宽度与当前 speculative forward 一致。

结论:head 已改为 int(forward_batch.spec_info.draft_token_num),并配套抽出 n_draft 变量,评审意见被采纳。 · 已解决

DSV4 池类型判断建议用 isinstance 设计

randgun 在 disaggregation/utils.py 第 1168 行对 is_npu() 组合条件建议 "maybe use isinstance(token_to_kv_pool, DSV4NPUTokenToKVPool) is better?",属于类型导向的可读性 / 可维护性建议。

结论:材料中未见明确落地记录,建议保留为待核对项。 · 待处理

NPU 多流条件不要覆盖 extend 性能

randgun 在 deepseek_v4.py 第 1390 行对 multi-stream 启用条件(_is_npu 且 x.shape[0] <= limit 等)要求 "change to not extend",避免多流重叠影响 prefill 路径。

结论:head 最终条件已加上 not forward_batch.forward_mode.is_extend_or_draft_extend_or_mixed(),多流只作用于 decode/verify,反馈已落实。 · 已解决

风险与影响

技术风险集中在四个层面:

  1. 核心路径大重构:DSV4 NPU 缓存从五表 + paged 状态池改为 sidecar + ring 所有权,C4 位置从“独立分配”变为“从 full 派生”(依赖 % 4 == 3 的对齐假设)。任何 full 槽位分配顺序或段边界变化都会静默影响 C4 定位,回归风险高,dsv4_allocator.py / ascend_dsv4_backend.py 需要重点回归。

  2. 外部算子临时依赖:Compressor(torch.ops.custom.compressor)、npu_sparse_attn_sharedkv(_metadata) 等算子尚未进 sgl-kernel-npu 发布包,必须通过 SGLANG_DSPARK_EXTRA_OPS_SOASCEND_CUSTOM_OPP_PATH 手动注入;extra_ops_loader.py 只校验了算子存在与 Python ABI,未校验底层 CANN 版本兼容性,部署失败面较宽。

  3. 验证覆盖有限:PR 明确 NPU compact/ragged verify 未启用,仅验证 static verify;同时删除了 _overlap_transform 单测且未补等价覆盖,C128 sidecar 的 Radix 插入/驱逐/会话推进也没有对应单测,图捕获路径下的正确性主要靠 16 NPU 手工验证。

  4. CUDA 隔离依赖 guard:NPU 专属逻辑散落在 deepseek_v4.pydeepseek_v4_dspark.py 等共享模型文件中,靠 _is_npu 分支隔离;后续若有人调整共享路径(如 DSV4StateLens 结构体删除、forward_batch_info.py 中 -40 行),可能遗漏 NPU 分支的联动更新。

影响范围:对 NPU 用户而言是能力级提升——ModelSlim 量化的 DeepSeek-V4 搭配 DSpark 推测解码首次可在 Ascend A3 上运行,GPQA-Diamond 0.894 且打通 DP attention、DeepEP、P/D 分离;对 DSV4 缓存管理是一次跨模块重构,涉及分配器、内存池、请求映射池、Radix 缓存、公共钩子与 P/D 传输,改变了 NPU 与 CUDA 在压缩器状态所有权上的分叉程度(收敛到 GPU ring 规则),后续两平台维护成本降低但合入期回归面大。团队影响:36 个 commit、4 位作者(2044145178、randgun、Talantan1102、JiaruiChang5268)协作,含两个大 squash(perf 集成与缓存重构),主线合并频繁,属于典型的并行分支长期演进合入。

核心路径变更 外部算子临时依赖 验证覆盖受限 测试配套减少 CUDA 路径隔离依赖 多平台并行演进

关联 Issue

未识别关联 Issue

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

完整报告

参与讨论