← 返回首页

vllm-ascend PR #18961 解读:给 GLM 的 draft 层 attention 换上 CANN FlashAttn

PR 地址:vllm-project/vllm-ascend · Pull Request #18961([v0.30.0][Feature][Attention] CANN FlashAttn for A5 V1 GLM DFlash2 eager drafts)
target 一根手指不动——七重条件窄门里,draft 层的 attention 换 CANN 9.2 引擎:device 长度取一次、tiling 计划一步一建层间复用,两节点 P/D 实测 mean TPOT 14.90 → 10.97 ms

1. PR 档案

项目内容
标题[v0.30.0][Feature][Attention] CANN FlashAttn for A5 V1 GLM DFlash2 eager drafts
作者 / 状态开放中(950DT + CANN 9.2 上 16 个 eager 精度用例已过;性能数字引自上游 #18717)
目标分支releases/v0.30.0——与 #18671、#18870 同一条版本线
改动规模9 个文件,+594 / −0 行(3 个源码文件 + 6 个测试文件;diff 实测,纯新增)
涉及模块vllm_ascend/attention/fa_v1.py(新增后端)、vllm_ascend/platform.py(选路闸门)、vllm_ascend/device/hardware_profile.py(能力位)
关联 PR上游 #18717(本篇是其 v0.30.0 回移植,性能数字出自它);前作 #18671 / #18870(本站已解读,同一条 GLM 布局线)
生效场景A5(950DT)+ V1 runner + GLM MoE DSA 主模型 + DFlash2 draft + enforce_eager + FP16/BF16 未量化 KV——条件不全自动回老路
读前须知:五个名词 30 秒(更多缩写见文末第 8 章术语速查)
一句话总结 draft 层的 attention 数学与主模型一字不差,但"被调用的方式"特殊:每步要连猜 k 次、序列极短、每步都要重新备料。改前它与最普通的 dense 层查同一张后端选路表,拿到通用后端——每层自建元数据、长度依赖 CPU 镜像(拒绝采样后可能过期)、每层重建 tiling 计划,备料开销吃进每 token 时延。本 PR 在选路表前加一道七重条件窄门,全过才给 draft 层换上 CANN FlashAttn 专用后端:device 长度取一次、tiling 计划一步一建层间复用;主模型 target 的选路一个字不动。
图 1 · PR 改动全景:3 个源码文件=「引擎三件」,6 个测试文件组成回归网
PR #18961 改动全景:+594 / −0 · 9 个文件 源码 ×3(选路 → 后端 → 门槛,全在「引擎」上) attention/fa_v1.py(新增) 新后端三件套:元数据(device 长度)→ plan 缓存 → CANN flash_attn 调用与回填 platform.py(+52) 选路总闸:backend_map 查表前插入 _validate_flash_attn_backend 七重条件窄门 device/hardware_profile.py(+3) 能力位 DEVICE_LENGTH_FLASH_ATTN,A5 开 测试 ×6(回归网) ut/attention/test_flash_attn.py(+240) 精度对金标准 + 选路矩阵 + plan 复用三道锁 e2e/test_flash_attn_precision.py(+99) 16 个 eager NPU 精度用例(FP16/BF16) ut/spec_decode/test_dflash2_proposer.py draft 侧选 fa 后端、不覆写 causality ut/device/test_hardware_profile.py(+1) A5 能力位登记 CI 配置 ×2(test_config / estimated_times) 精度用例路由到 A5 的 CANN 9.2 镜像 门卫(ops/)、SFA 九步(sfa_v1.py)、模型层(models/)一行没动——本 PR 全部改在「draft 层的引擎与备料」。 前两作拆的是布局(聚散与边界);本篇换引擎——同一族 GLM 部署里,把"快"从通信省到备料省。 口径:+594/−0 纯新增,9 文件;行号与条件以 PR diff 为准。

2. 问题:draft 层的 attention 借了别人的引擎(改动前)

GLM 的架构(①)里除了 N 层主模型,还有一件 MTP 模块——前两篇讲布局时它一直在图角落里挂着黄框。这次它当主角:推理时 draft 先连猜 k 个 token,主模型一次前向验证(一轮"猜-验"见下图 3 上半部)。draft 层自己也是一层层 Transformer,每层也有 attention——数学与主模型一字不差;差别全在"被调用的方式":序列极短、每步 k 次连猜、验证淘汰后下一步重来。

进了框架,每层的 attention 都要由框架配引擎(③的 platform.py 管这件事)。主模型 target 顺利选上 SFA(稀疏注意力后端);draft 层呢——它查的是同一张选路表,而表里它的特征是"非 MLA、非稀疏、非压缩",落格到通用 attention 后端,与最普通的 dense 层同桌吃饭(Ⓐ)。通用后端为"大而全"设计:每层各自建注意力元数据、长度依赖 CPU 镜像——拒绝采样刚淘汰过 token,CPU 镜像可能还没同步;每个 draft step 的每层还要各自重建 tiling 计划,一步 k 次猜 × N' 层,备料重复 N'×(k+1) 次(Ⓑ)。这些开销全部吃进每 token 时延:上游 #18717 两节点 P/D 实测,mean TPOT 14.90 ms 里相当一块是备料,不是数学。

改动前 · 左=GLM DSA V1 主线(原样),右=框架线(draft attention 的老路) 用户输入:3500 字 prompt Token 化(词表 128k) ids [1, 3500] Token 嵌入 ≈ 1.05B 128k×8192 → [1, 3500, 8192] Block × N(每层 ≈ 1.1B):前 k 层 dense 前缀 → 后续连续 MoE 层 LayerNorm [1, 219, 8192] self_attn(MLA/DSA) latent KV · indexer top-2048 稀疏 Q/KV 低秩投影 latent 压缩(步 3) 稀疏注意力(top-2048) Q·Kᵀ/√d → softmax → ·V O 投影 残差连接 + LayerNorm [1, 219, 8192] MoE(路由 + 共享专家) 共享专家 每 token 必走 路由专家 top-k 选通 共享 + 路由加权合并 SP:各卡各算 219 分片 already_sequence_parallel 残差连接 + MTP 模块(投机解码,MTP=3) draft 层:与主模型层同构 LM Head(与嵌入共享权重) 8192×128k → logits [1, 128k] 生成下一个 Token decode 重复整条链,KV 从缓存取 框架 · 每层 attention 配引擎 target 选 SFA;draft 查同一张表 Ⓐ backend_map 同表选路 draft=(非MLA,非稀疏,非压缩) →通用后端,与 dense 同桌 Ⓐ target 享受专用路径 SFA 为稀疏注意力定制; draft 的小步快跑没有对应物 Ⓑ 备料逐层重复 每层建元数据;长度取 CPU 镜像 (拒绝采样后可能过期) Ⓑ 每 step 每层重建 tiling 计划 k 次猜×N' 层;吃进 TPOT 14.90ms 读法:左列=GLM DSA V1 的模型架构(dense 前缀+连续 MoE+MLA/DSA+MTP);右列=框架线里 draft attention 的老路;虚线=挂点。Ⓐ 是选路,Ⓑ 是备料。 问题不在数学,在引擎:与 dense 同桌选路(Ⓐ),备料逐层重复(Ⓑ)——全吃进每 token 时延。
图 2 · 改动前:Ⓐ draft 与 dense 同表选路,Ⓑ 备料逐层重复——TPOT 的大头不在数学在备料
先看①的原物:一轮完整的「猜-验」循环,再看 target 与 draft 的分工 主模型出 1 个 token(N 层前向) draft 连猜 k 个 小模型 · 本 PR 主角 主模型一次前向 验证 k 个猜的 收下命中的 淘汰没中的 下一轮 decode 循环 每一轮里 draft 的 attention 要跑 k 次、主模型跑 1 次——draft 单次虽小,次数是主模型的 k 倍,备料开销随次数放大。 target(主模型,N 层) GLM DSA V1:dense 前缀 + 连续 MoE attention 选 SFA(稀疏,top-2048) 本 PR 一根手指不动(测试钉死选路不变) 图 5 至图 9 讲的就是它这条路 draft(MTP 小模型,N' 层) 与主模型层同构 · enforce_eager 逐层即时跑 改前:与普通 dense 层查同一张选路表(Ⓐ) 备料逐层重复(Ⓑ)——本 PR 换引擎的对象 数学与主模型一字不差 读法:上半部=①里一轮「猜-验」的原物;下半部=同一个框架里 target 与 draft 的引擎分工——本 PR 只动右框。 draft 的每层 attention:序列短(1 至 k+1 个 token)、每步重来——这是它与主模型调用方式的全部差别。
图 3 · target 与 draft 的分工:一轮「猜-验」循环里,draft 的 attention 跑 k 次、备料也随之重复 k 次

ⒶⒷ 速查(后文正文与代码注释均用此编号)

3. 核心思路:窄门换引擎,target 不动

问题住在引擎与备料上,数学没病——那就不动数学,换引擎。本 PR 三条思路:一、新后端:attention/fa_v1.py 三件套(元数据从 device 长度建、tiling 计划按几何特征缓存并在一个 draft step 内层间复用、CANN flash_attn 算子出结果后回填 vLLM 的输出缓冲);二、窄门:只在七重条件全过时启用——A5 硬件、V1 runner、本层是 draft、draft 必须 eager、target 是 glm_moe_dsa、draft 是 DFlash2、FP16/BF16 未量化 KV(另排除五个未实现的特性),任一不满足静默回老路,行为与改前一致;三、target 不动:主模型照旧选 SFA,"缓存的 target 选路不变"有测试钉死。改后的样子见图 4:

改动后 · 业务主线与图 2 完全一致;只动框架线:窄门换引擎(蓝/绿)、ⒶⒷ 消失(灰) 用户输入:3500 字 prompt Token 化(词表 128k) ids [1, 3500] Token 嵌入 ≈ 1.05B 128k×8192 → [1, 3500, 8192] Block × N(每层 ≈ 1.1B):前 k 层 dense 前缀 → 后续连续 MoE 层 LayerNorm [1, 219, 8192] self_attn(MLA/DSA) latent KV · indexer top-2048 稀疏 Q/KV 低秩投影 latent 压缩(步 3) 稀疏注意力(top-2048) Q·Kᵀ/√d → softmax → ·V O 投影 残差连接 + LayerNorm [1, 219, 8192] MoE(路由 + 共享专家) 共享专家 每 token 必走 路由专家 top-k 选通 共享 + 路由加权合并 SP:各卡各算 219 分片 already_sequence_parallel 残差连接 + MTP 模块(投机解码,MTP=3) draft 层:与主模型层同构 LM Head(与嵌入共享权重) 8192×128k → logits [1, 128k] 生成下一个 Token decode 重复整条链,KV 从缓存取 框架 · 配引擎(target 不变) target 仍选 SFA——测试钉死 蓝 · 窄门:七重条件全过 A5·V1·draft·eager·glm_moe_dsa ·DFlash2·FP16/BF16·KV 未量化 绿 · 新后端 fa_v1.py device 长度一次 · plan 层间复用 CANN flash_attn + copy_ 回填 灰 · Ⓐ 消失:不再同表 draft 专用引擎,不与 dense 同桌 蓝 · Ⓑ 消失:备料一步一次 TPOT 14.90→10.97ms(#18717) 读法:左列与图 2 逐格一致(=GLM DSA V1 架构),一个框都没动;右列=引擎三件:窄门(platform.py)、新后端(fa_v1.py)、门槛(device/)。 target(主模型)的选路一个字不动;条件不全静默回老路——行为与改前一致。
图 4 · 改动后:业务主线一个框都没动;七重窄门换引擎(蓝)、fa_v1 三件套(绿)、ⒶⒷ 消失(灰)

4. 主线讲解:从架构全貌到这次 PR

先对齐地基。本章走两条线:线一·模型架构线——从模型架构看到 vllm-ascend 框架,看模型的每一件住哪、谁调它,4.1 先摆这块推理的朴素 PyTorch 写法并定位到框架加的壳(图 5 给出全局定位 + 4.2 目录地图·图 6);线二·PyTorch 实现线——从十行示例的 PyTorch 实现看到框架实现,看它被谁一步步算出来、每步为什么(4.3 流水线·图 7 → 4.4 全链时序·图 8 → 4.5 逐步收益·图 9);两条线在 4.6 的问题处交汇,4.7 讲这次 PR(图 10 至图 13),4.8 至 4.10 三张表收拢。先说结论:attention 的数学一行没动;本 PR 改的全是"引擎与备料"——draft 层选哪个后端、元数据怎么建、tiling 计划建几次、调哪个算子。

4.1 先看这块推理的原始写法:朴素 PyTorch

老规矩,先立对比基准——这块推理(draft 层的投机解码一步)用 PyTorch 朴素写出来长什么样。自己动手写带草稿模型的推理就会这么写:单卡、没有任何框架概念(有删节)。draft 层的 attention 数学就在 layer 里,与主模型一字不差:

# 朴素 PyTorch 写法:投机解码的一步(单卡,无框架)
draft_kv = []                                 # draft 层自己的 KV 缓存
for t in range(k):                            # draft 连猜 k 个 token
    x = embed(last_token)
    for layer in draft_layers:                # draft 模型的 N' 层
        h = layer.self_attn(layer.norm(x), cache=draft_kv)
        # attention:QKV 投影 → QKᵀ → softmax → ·V → o_proj
        h = layer.moe(h)                      # 前馈;GLM 这层是 MoE
        x = h + x                             # 残差
    last_token = lm_head(x).argmax()          # 猜出下一个 token

accepted = verify(draft_tokens)               # 主模型一次前向,验证 k 个猜的
# 收下命中的、淘汰没中的,下一轮 decode 循环重来

这次 PR 动的不是这段数学——数学一行没动。动的是 vLLM 框架给这段朴素代码加的壳:进了框架,每层的 attention 要由框架配引擎——选哪个后端(Ⓐ)、配料怎么备(Ⓑ:元数据/长度/tiling 计划)。裸 PyTorch 里根本没有"配引擎"这件事,attention 就是 layer 里那几行;draft 层被调用方式的特殊(每步 k 次、序列短、每步重来),框架的老壳没有为它定制——问题全住在壳上(ⒶⒷ 代码长相见第 2 章)。

这段代码在全局的什么位置?把模型架构到框架的全貌画出来(图 5):

模型架构 → 框架流程:模型的每一件,在 vllm-ascend 里住哪、谁调它 模型架构(自上而下) 框架落点 · 调用者(文件:行号实测) Token 嵌入 词表 128k · hidden 8192 上游 runner 喂 ids [1,3500],嵌入成 [1,3500,8192]; pad 到 3504 在 Runner 做(model_runner_v1.py:2935) × N 层 DecoderLayer 每层≈1.1B,外壳串四件 层外壳 forward:input LN → self_attn → post LN → MoE (deepseek_v2.py:1315);★ 选路总闸 platform.py 与新后端 fa_v1.py 挂在引擎层(见 4.7) └ self_attn(十行示例) MLA/DSA:latent KV+top-2048 门卫 ops/mla.py:151 放行 → 后端 sfa_v1.py:1503 九步流水线开算;GLM 与 DeepSeek 共用这条实现链 └ MoE(路由+共享) SwiGLU,≈0.8B/层 SP MoE:各卡各算自己 219 个 token 的分片,天然免通信 (mlp 收 already_sequence_parallel=True,不再整理布局) MTP 模块(投机解码) 环境 MTP=3 ★ vllm_ascend/models/deepseek_mtp.py ★:本 PR 换装闸门在此—— 本 PR 不动; 改的落点在引擎三件(见 ★) LM Head(与嵌入共享) logits [1,128k] Runner 收回输出送采样;decode 每出一个 token, 再从第 2 行整条链走一遍(KV 从页表缓存取) 读法:左列=模型自上而下的架构件;右列=它在框架里的落点与调用者;黄行 MTP(draft)是本 PR 换引擎的对象(★)。链子:Runner→层外壳→门卫→后端九步。 下一步:先认目录分工(图 6),再从十行示例进流水线(图 7);完整调用时序见时序图(图 8),每步为什么这么实现见展开图(图 9)。
图 5 · 模型架构 → 框架流程:模型的每一件在 vllm-ascend 里的落点与调用者(★ = 本 PR 改动落点)

看懂这张图,再定位本 PR。架构件一个没变、链子一段没短——变的是"引擎层":draft 层的 attention 在选路处(platform.py)多了道窄门,全过就换到新后端(attention/fa_v1.py);门槛挂在硬件画像(device/)上。这就是为什么图 5 的 ★ 落在引擎三件上,而不是任何注意力算子上。

4.2 先认目录:与 attention 相关的目录,一个一个认

与 attention 相关的目录:左 = 目录名,右 = 它管什么、为什么有它 vllm_ascend/worker/ 执行与喂料 接引擎的批 → pad 到 3504 → 调模型、收回输出 为什么有它:上游执行器绑死 NVIDIA,昇腾必须有自己的执行器 vllm_ascend/models/ 模型结构 层怎么搭、forward 怎么走 —— 十行示例的调用者 为什么有它:模型结构上游都有,昇腾只改个别层,继承上游小改 vllm_ascend/ops/ 算子门面 把昇腾算子包成 vLLM 认识的"层";attention 的门卫(mla.py)在这 为什么有它:调用约定不同,模型层只认门面、不认具体硬件 vllm_ascend/attention/ 注意力实现 ★(fa_v1.py) self_attn 的后端:九步流水线(sfa_v1.py:1503)在这里真正开算 为什么有它:注意力后端可插拔(不同硬件/并行形态各一套),策略独立成模块 …/attention/context_parallel/ DSA-CP 特化(车间) 校验 · 跨卡取命中 KV · 收口 —— 上下文并行的专属逻辑 为什么有它:上下文并行(KV 与计算再切多卡)的特化单独放一个子目录 platform.py(选路总闸)★ 与 device/(硬件画像)★ 是本 PR 另两处落点;patch/(运行时补丁)知道即可
图 6 · 目录介绍:★ = 本 PR 落点(attention/ 新后端 fa_v1.py;platform.py 选路;device/ 硬件画像)

地图没变,落点变了:本 PR 的三个源码文件分别落在 attention/(新后端 fa_v1.py)、platform.py(选路总闸)与 device/(硬件画像能力位)——一个管"跑什么",一个管"谁上场",一个管"哪台机器能上场"。

4.3 实现线:attention 在 vllm-ascend 原本是怎么完成的

架构全貌看完了;现在换一条线——从 PyTorch 实现切进去,看它被怎么实现。拿十行 attention 示例当解剖标本(target 主模型这条路;draft 层的老路见 4.6),左列十行自上而下,右列是框架接手每行的落点:

十行 attention 示例 → vllm-ascend 怎么完成它:左列实线是十行的流向,虚线弧线指向它在昇腾里的落点 十行 attention(样例代码) vllm-ascend 的实现流水线 在哪个模块 · 门牌(最全) L1 Q 投影 q = x @ wq L2 K/V 投影 k=x@wk, v=x@wv L3 RoPE 只转 Q、K L4 KV Cache cat 追加历史 L5 GQA repeat 复用 KV 给 Q 头 L6 打分 q @ kᵀ/√dk L7 因果遮罩 遮住未来 token L8 softmax 归一化 L9 加权和 w @ v_full L10 输出投影 out @ wo 层外壳发起 self_attn 门卫放行 → 后端 步3 Q/KV 联合低秩投影(KV 段) 步4 K 侧 rope · 步6 Q 侧 rope 步4 写本卡缓存 · 步7 全量写回 无此步(latent KV 全头共享) 步8 稀疏注意力算子(QKᵀ) 步8 内:causal 遮罩处理 步8 内:softmax 归一化 步8 内:·V 加权求和 步9 收口(o_proj)→交回层外壳 attention/ 步3:1576 (Q 低秩段)· 步6:1614 Q 上投影 矩阵乘原样,拆低秩两段 attention/ 步3(KV 段) 步4 :1595 exec_kv 投影+rmsnorm+写cache 融成一个昇腾算子 attention/ :1595(K 侧) :1616(Q 侧) 同一旋转,没变 融进算子,数学一致 attention/ :1626 reshape_and_cache slot 页表逐 token 定位 代替整块 cat 追加 attention/ MLA·DSA 特性 latent KV 全头共享 目的相同(省 KV) 手段不同,无独立步骤 attention/ 步8 :1683 内 QKᵀ/√dk 原样 只在 top-2048 块上算 稀疏,不扫全量历史 attention/ 步8 算子内 causal 遮罩由算子处理 prefill 生效 decode 无需遮罩 attention/ 步8 算子内 softmax 原样 归一化没变 数值在算子内部完成 attention/ 步8 算子内 ·V 加权原样 输出 [219,全头] 等待收口 attention/ :1695 o_proj 原路:直接投影 draft 另有引擎安排 (本 PR,见 4.7) 读法:左列实线 = 十行示例自上而下,一行一格、零省略;弯曲虚线 = 每一行在 vllm-ascend 里的落点;右列门牌最全(模块 / 文件:行号 / 为什么)。 此时还没有 PR 的事——这就是 vllm-ascend 平时跑这份十行示例的样子(target 这条路;draft 老路见 4.6)。
图 7 · 流水线(原本):十行示例在 vllm-ascend 里的完成过程(target 主模型这条路)

十行示例的每一行,框架都有对应落点。这套"逐行挂门牌"的完成过程就是 vllm-ascend 平时跑 attention 的样子——本 PR 不改这条链的任何一环,它改的是链子入口处"这条链配不配给 draft 用"的选路判断。

4.4 全链时序:谁调用谁,每一棒先自己干再交棒

vllm-ascend 跑十行 attention 示例:全链时序——每个函数先"自己:"干完,再"委托:"下一棒(文件:行号实测) 上游 vLLM 引擎 组批·调度 core.py:595 Worker 昇腾插件 NPUWorker worker.py:643→669 执行器·喂料 NPUModelRunner model_runner_v1.py :2035 主入口 模型层 models/ AscendDeepSeekMTP deepseek_mtp.py:23 层外壳上游 :1315 门卫 ops/mla.py AscendMultiHead LatentAttention :151/:163/:181 后端 attention/ AscendSFAImpl / AscendSFADSACPImpl sfa_v1.py:1503 sfa_cp.py:292 自己:组批——调度器把多条请求 拼成一个批;每条请求的 attention 各自算各自的(按请求边界遮罩,互不 串);批越大 NPU 一趟吃越满(core.py:595) 委托:execute_model(批)——整批交给 Worker 引擎此后只收 output,再组下一批 自己:不碰 attention 数学——监控统计; PP 按层切段部署时,段与段之间收发 hidden(下一段 attention 就吃这份输入) (worker.py:643→669) 委托:model_runner.execute_model(:669) ——喂料员接手,把批变成算子要的形状 自己:喂料——pad 3500→3504(16 的倍数, 昇腾矩阵单元才吃得满);再组注意力 元数据:每条请求的边界+KV slot 起点 ——attention 算子按它切请求,免造 整块大 mask 张量(:2935·build :3294) 委托:模型 forward——层外壳逐层发起, 每层一次 attention+MoE 自己:MTP 投影层(draft,与主层同构—— 这里也住着一个 attention)——加载期 rot 一次性把权重排成后端要的形状, 免得每次 forward 重排(deepseek_mtp.py:23) 自己(层外壳):LN→AG 聚全量(每卡 219 hidden 做 AG 拼成 3504——十行示例的 hidden_states 输入,就是聚完的它)→ self_attn→RS 散回→postLN→MoE(:1315) 委托:self_attn(positions,hidden)—— 十行示例,从这一行进入框架 自己:预建输出张量 output——十行 的 attn_output 落这;预分配免碎片 (mla.py:151) 自己:custom op 直通车(:163)—— 编译器到此止步,防图优化把后端 的融合算子拆散——护住省带宽的融合 自己:取层权重/KV 句柄/批元数据, 凑齐后端开算要的三样再递进去 (mla_forward 函数体) 委托:impl.forward(层名,hidden,kv, 元数据,output)(:181)——进车间 impl 谁干活,加载期 resolve_sfa_impl 四 选一定死(:1303)——运行时每层直接调, 零选路开销;开 DSA-CP 选 AscendSFADSACPImpl(sfa_cp.py:292) 步准备:DSACPContext 立坐标——本卡管 哪段:local_start=rank×219;全量 3504=round_up(3500,16)(:1527/CP:188) 后面九步全按这套坐标切活:Q 按卡 切段(各 219),KV 不切(全量各存) 步3:Q/KV 联合低秩投影(:1576)——十行 的 q/k/v_proj 三个矩阵乘合成一枪; KV 压成低秩 latent——cache 按它记账 不按头数记,这就是省显存的源头 步4:exec_kv 融合算子(:1595)——十行 里 K/V 侧的投影+rmsnorm+rope+写 cache 融成一枪;中间结果不落显存(省带宽) 跨卡取命中 KV 拼全量(:1605)——算 QKᵀ 要全量历史 K,本卡只存了 1/16; 只取 indexer 命中的块,不全搬—— 通信量按命中块数走,不按历史长度走 步6:Q 投影+Q 侧 rope(:1614/:1616)—— 十行的 q_proj+rope(q);Q 不进 cache, 随算随转,不占显存 步7:写回全量 cache(_store_parallel_kv :1626)——本卡算出的 latent KV 每卡 各存一份,供别卡日后跨卡来取—— 代价 KV×16 份,拿显存换互通 选块:indexer 选 top-2048 块(:1670)—— 十行里没有这步(DSA 加的):长历史里 真正相关的只有少数块,先挑后算 步8:稀疏注意力算子(:1683)——十行的 QKᵀ/遮罩/softmax/·V 四步数学在这 一个算子里出齐;分数矩阵全程不落 显存——省带宽,这正是融合的意义 步9:收口 o_proj(:1695)——十行的 o_proj 原样;输出只剩本卡的 219 段, 交回门卫→层外壳,后面 RS 散回各卡 (原路 :1384) output 原路返回:后端→门卫→层外壳 →Runner→Worker→引擎,再进下一层 读法:"自己:"框=自己干的活——每框写清两件事:跟十行示例什么关系、省什么(显存/带宽/算力);"委托:"箭头=交给下一棒;右侧自转圈=九步流水线(同图7 中列)。 每层都走一遍右半边,本图只画一次;impl 在加载期定死。本 PR(18961)不动这条调用链——它改的是链子入口"选哪个后端"与备料(见 4.7)。
图 8 · 全链时序图:从引擎到后端,每个类"自己做什么 / 委托什么"——每框写明跟十行的关系与省什么(文件与行号本地实测)

时序图末尾那串自转圈(后端九步)是主模型 target 的 SFA 路径;draft 层的老路更简单——通用后端没有九步流水线,但每一棒"先自己备料再开算"的节奏是一样的。本 PR 动的是第一棒之前的选路与备料,不动调用链本身。

4.5 为什么这么实现:逐步收益

还剩最后一个问题:target 这条路每一步为什么这么实现?各自省的是什么?把流水线中列原样搬到最左,向右逐层展开——怎么做(带门牌)→ 为什么这么设计 → 收益点:

每一步为什么这么实现:流水线(最左)→怎么做→为什么→收益点,逐层展开 昇腾实现流水线 (=图7 中列) 怎么做 · 门牌 (本地实测) 为什么这么设计 收益点(省什么) 层外壳 self_attn 门卫放行 → 后端 十行 L1 的调用者 层外壳串 LN→AG→attn →RS;门卫 :151 放行 层管布局,门卫管接入—— 模型代码不碰硬件细节 接法与算法分家 换后端只动门卫 模型层零改动 多卡逻辑收在壳上 步3 Q/KV 联合 低秩投影(KV 段) fused_qkv_a_proj(:1576) 一次矩阵乘同出 Q 路 1536 与 KV latent,后段再上投影 MLA 先压低秩 latent, 数学等价;KV 账本按 latent 记,不按全头记 KV Cache 不随头数 翻倍——省显存是大头; 三投影合一省访存 步4 K 侧 rope 步6 Q 侧 rope K 侧旋转融进 exec_kv (:1595);Q 侧 rope_single (:1616)就地转 K 转后才写 cache, Q 不进 cache 随算随转 ——顺序不能反 少一次中间张量读写; 旋转数学与十行的 L3 逐位一致 步4 写本卡缓存 步7 全量写回 slot 页表逐 token 定位写 (:1626);DSA-CP 跨卡拼 全量、每卡各存一份(:1605) 页表代替整块 cat; 每卡全存是跨卡取块 命中块的前提 无大拷贝、无碎片; 代价 KV×16 份—— 拿显存换正确性 无此步(GQA) latent 全头共享 GQA 的 repeat 没有对应物: latent KV 生来全头共用 (:1595 段内) GQA 用 repeat 凑复用; MLA 把复用直接做进 结构里 显存不随 Q 头数翻倍; 省掉 repeat 的拷贝 与算力(对比十行的 L5) 步8 稀疏注意力 算子(QKᵀ) indexer 先选 top-2048 块 (:1670);算子只在选中块 上 QKᵀ/√dk(:1683) 长历史里相关的只有 少数块;索引器先挑, 注意力再算 算力从扫全量历史降到 2048 块;序列越长 省得越多 步8 内: causal 遮罩处理 遮罩由算子内部按 prefill/decode 自动处理 (:1683 算子内) 遮罩是算子固定语义, 不必在外面造 mask 张量 省 mask 张量的显存 与生成耗时;prefill 遮未来,decode 免遮 步8 内: softmax 归一化 softmax 在算子内部完成, 与 QKᵀ 连续执行 (:1683 算子内) 三步数学融为一个 算子,中间结果不落地 分数矩阵不写显存—— 省带宽,这正是融合 的意义 步8 内: ·V 加权求和 ·V 加权同算子出结果 [219,全头],停在收口前 (:1683 算子内) 同一条算子流水线, 一次出全部分数结果 每卡只算自己 219 个 token 的输出,天然 按卡并行 步9 收口(o_proj) →交回层外壳 _finalize_o_proj(:1695; 原路 :1384)投影后交回; draft 层另有引擎安排(本 PR) 输出投影保持原样; 通信只在 SP 需要 时才加 不开 SP 零额外通信; 开 SP 归约只做一次 (o_proj 两路图) 读法:最左列=图7 中列的流水线原样搬来;向右三列逐层展开——怎么做(带门牌)→为什么这么设计→收益点。口径:3504 全量/219 每卡/16 卡/top-2048 块。 本图讲"原本为什么这么实现"。本 PR(18961)九步一行没动——它给 draft 层换引擎(fa_v1,见 4.7),target 这条路照旧。
图 9 · 昇腾实现展开图:最左列 = 图 7 中列流水线;向右逐层展开「怎么做 → 为什么 → 收益点」

逐行看收益,"省"分三种。省显存的:低秩 latent 让 KV Cache 不随头数翻倍;省带宽的:融合算子让中间结果不落显存;省算力的:稀疏注意力只算选中的块。本 PR 省的是第四种——省备料:不算得更快,是"准备算"更快(device 长度一次取齐、tiling 计划一步一建),这是 TPOT 那 26% 的来源。

4.6 问题:draft 层的 attention 借了别人的引擎

两条线在此交汇。先用 4.1 朴素基准的口径说清毛病:框架给这段朴素代码加的壳,没有为 draft 的调用方式定制——draft 每步 k 次连猜、序列极短、验证淘汰后重来,而老壳按"通用层"对待它:选路查同桌的表(Ⓐ),备料每层每步重做(Ⓑ,长度还可能取到拒绝采样前的旧镜像)。症状是端侧的:两节点 P/D 下 mean TPOT 14.90 ms——数学只占其中一块,备料吃掉的是纯开销。从引擎点进这段代码(改前户口):

改前户口:draft 层的引擎在哪一行被派定 vLLM 引擎 为每层 attention 配引擎 platform.py · 选路总闸 get_attn_backend_cls backend_map 查表 (非MLA,非稀疏,非压缩) Ⓐ 通用后端 attention_v1 与 dense 同桌 选路之后(Ⓑ 的家):每层自建注意力元数据 · 长度取 CPU 镜像(拒绝采样后可能过期)· 每层重建 tiling 计划——一步 k 次猜 × N' 层重复 N'×(k+1) 次。 为什么讲它:draft 的 TPOT 开销不在数学在这条链——换引擎要先看清它每一环住哪。 口径:选路与通用后端均为 v0.30.0 现状(PR diff 的改前基线);行号以 diff 为准。
图 10 · 改前户口:draft 的引擎在 backend_map 落格处被派定为通用后端(Ⓐ),备料在选路之后逐层重复(Ⓑ)

4.7 这次 PR:七重条件窄门 + 新后端三件套

改法两句话:选路总闸在查表之前插一道窄门(七重条件全过 → 返回新后端;任一不满足 → 照旧查表,静默回老路);新后端 fa_v1.py 三件套接住 draft 的调用方式。改后户口:

改后户口:窄门插在查表之前,全过换新后端 vLLM 引擎 为每层 attention 配引擎 platform.py · 选路总闸 get_attn_backend_cls ★ 窄门(新增) _validate_flash_attn _backend 七重条件 ★ 新后端 fa_v1.py CANN FlashAttn 任一条件不满足: 静默回 backend_map 老路 门槛住 device/:DEVICE_LENGTH_FLASH_ATTN 能力位,当前仅 A5 的画像开。 为什么讲它:换没换上引擎、什么条件下换,全由这行窄门说了算——排查时先看它。
图 11 · 改后户口:窄门在 backend_map 之前,七重全过 → fa_v1.AscendFlashAttentionBackend;任一不满足回老路

老路 ↔ PR 做法,逐行对照(左列=老路一步的备料与开算,中列=PR 做法,右列=门牌与为什么):

draft attention 一步:老路 ↔ 这次 PR 老路(ⒶⒷ 的家) PR 做法 门牌 · 为什么 draft 层被调用 一步猜 k 次 × N' 层 序列短,每步重来 同一个调用(不变) 数学一行没动;变的是 引擎与备料 调用方式:draft 特有 (每步 k 次/短序列) 这就是要定制的理由 Ⓐ 查 backend_map 同表 (非MLA,非稀疏,非压缩) → 通用后端,与 dense 同桌 ★ 窄门:七重条件全过 → fa_v1.AscendFlash AttentionBackend platform.py draft 专用窄门, 不再与 dense 同桌 每层自建注意力元数据 每层一套,不复用 元数据从 device 长度建 Builder 只信 device 侧, 一次取齐 fa_v1.py Builder CPU 镜像不再被 拒绝采样弄脏 Ⓑ 长度取 CPU 镜像 拒绝采样后可能过期 算错窗口就要重来 query_start_loc / seq_lens 直接取 device 张量, clamp+cummax 防 dummy 负边界 fa_v1.py Impl 惰性算一次, 存在 metadata 里 Ⓑ 每层重建 tiling 计划 一步重复 N'×(k+1) 次 纯 host 侧开销 plan 按 plan_key 缓存 (头数,KV头数,head_dim, 窗口,布局)·层间复用 fa_v1.py Impl 一个 draft step 一建, N' 层共享 通用算子开算 结果另开新张量 CANN flash_attn 一枪 attention_transfer_window 内 copy_ 回填 vLLM 输出缓冲 cann_ops(CANN 家) 只在用它的 worker import; 回填=对齐 vLLM 约定 TPOT 14.90 ms 两节点 P/D(#18717) TPOT 10.97 ms(−26%) 吞吐 484.39→491.31 tok/s 省的是备料(host 侧), 不是数学——第四种省 读法:左列实线=老路一步的自上而下;中列=PR 逐行替换;右列=门牌(文件:对象)与为什么。灰=老路痛点,蓝/绿=PR 改动,颜色语义与图 2/图 4 一致。
图 12 · 流水线对照:draft attention 一步的六行对照——窄门换引擎、device 长度一次、plan 层间复用、CANN 算子收尾

窄门里到底查什么?七重条件一棵树(任一"否"都回老路——所以叫窄门):

_validate_flash_attn_backend:七重条件,全过才换引擎 条件一 · 这层的特征是普通 dense attention backend_key=(非MLA,非稀疏,非压缩)——MLA/稀疏/压缩各有专用后端,不在此列 条件二 · 机器是 A5(950DT) device/ 硬件画像 supports(DEVICE_LENGTH_FLASH_ATTN)——能力位当前仅 A5 开 条件三 · 用的是 V1 runner use_v2_model_runner 的不走此门——新执行底座另行适配 条件四 · 投机解码开着,且本层是 draft runner_type="draft"——target(主模型)永远不走此门,照旧 SFA 条件五 · draft 是 eager(enforce_eager) 图模式的元数据路径不同,先只在 eager 生效;target 的图模式保留 条件六 · 主模型是 GLM MoE DSA 家族 target.hf_text_config.model_type="glm_moe_dsa"——先服务我们这条 GLM 部署线 条件七 · draft 是 DFlash2 + FP16/BF16 + KV 未量化,五个未实现特性都不碰 is_dflash2_draft · dtype ∈ FP16/BF16 · kv_cache_dtype 未量化 排除:DCP / 多模态前缀 / RSWA / 批不变 / 逐头 KV 量化 七重全过 fa_v1 后端 CANN FlashAttn 任一"否":静默回老路 backend_map 查表,行为与改前一致 读法:自上而下逐条查,全过走右门; 窄=可控——每一条都有测试钉死(见 4.10)。
图 13 · 七重条件树:硬件、runner、draft/eager、目标模型、draft 形态与精度、特性排除——窄门的全部内容

新后端三件套各自干了什么(fa_v1.py,关系化拆解):

4.8 对照总表:老路 vs 这次 PR,逐项对照

维度老路(改前)这次 PR
选路draft 与普通 dense 层查同一张 backend_map,落格通用后端(Ⓐ)查表前先过七重窄门,全过 → fa_v1 专用后端;任一不满足静默回老路
元数据长度每层自建,长度取 CPU 镜像(拒绝采样后可能过期)只信 device 张量:query_start_loc / seq_lens 直接取,clamp+cummax 防 dummy 负边界,惰性算一次
tiling 计划每层每步各自重建——一步 k 次猜 × N' 层重复 N'×(k+1) 次(Ⓑ)按 plan_key 缓存在元数据里,一个 draft step 一建、层间复用
算子通用 attention 路径,结果另开新张量CANN flash_attn 一枪(mask_mode 0/3/4、TND/PA_BNBD),attention_transfer_window 内调用,copy_ 回填
target 主模型SFASFA——一个字不动,"缓存的 target 选路不变"有测试钉死
数学十行示例同款十行示例同款——一行没动

4.9 PR 落点总表

文件改动为什么落在这
attention/fa_v1.py(新增,+114)AscendFlashAttention 三件套:元数据(device 长度 + plan 缓存槽)、Builder、Backend/Impl新引擎必须是独立后端——③的 attention/ 目录就是放后端的地方,选路返回类名字符串即可生效
platform.py(+52)get_attn_backend_cls 查表前插 _validate_flash_attn_backend;七重条件与五特性排除选路总闸住 platform——谁上场由平台说了算,draft 专用判断也归它管
device/hardware_profile.py(+3)能力位 DEVICE_LENGTH_FLASH_ATTN,A5 画像开启硬件门槛要有挂牌处——③的 device/ 就是"哪台机器会什么"的户口本

4.10 测试(6 个文件:精度 + 选路矩阵 + plan 复用三道锁)

测试文件锁住什么
ut/attention/test_flash_attn.py(+240)三道锁全在这:精度对金标准(dtype×window×layout×causal 参数化);选路矩阵——只对 A5 V1 DFlash2 eager drafts 选 fa,其他 drafts/图模式/其他 target 架构/不支持特性全部保留老后端;plan 在 draft step 内复用、不同几何不共享、Builder 不物化精确 device 长度
e2e/test_flash_attn_precision.py(+99)16 个 eager NPU 精度用例:FP16/BF16、strided paged KV、causal/non-causal、sliding window、rejection 更新后的长度
ut/spec_decode/test_dflash2_proposer.py(+78)draft 侧加载器选 fa 后端时不覆写 causality
ut/device/test_hardware_profile.py(+1)+ CI 配置 ×2A5 能力位登记;精度用例路由到 A5 的 CANN 9.2 镜像

5. 前后代码对照(带注释)

5.1 选路总闸:查表之前插窄门(platform.py)

目录地图(③)里 platform.py 管选路。改前,draft 层走到这就直接查表;改后,先过窄门:

# platform.py · get_attn_backend_cls(改后,有删节)
# PCP keeps its distributed backend; eligible local draft layers use CANN FlashAttn.
if not attn_selector_config.use_pcp and _validate_flash_attn_backend(backend_key, attn_selector_config):
    # vLLM resolves this import path to the backend class.
    return "vllm_ascend.attention.fa_v1.AscendFlashAttentionBackend"   # ★ 新窄门:全过 → 新后端

backend_map = {                                                       # 老表原样保留
    (True, False, False):  "vllm_ascend.attention.mla_v1.AscendMLABackend",
    (False, False, False): "vllm_ascend.attention.attention_v1.AscendAttentionBackend",  # Ⓐ draft 改前落这格
    ...
}

窄门本体 _validate_flash_attn_backend 就是图 13 的七重条件直译(backend_key 三元组、硬件能力位、V1 config、spec 存在、runner_type=="draft"、enforce_eager、target 的 model_type、is_dflash2_draft、dtype 与 KV 精度、五特性排除)——每一条"否"都 return False 回老路。

5.2 新后端核心:备料一次,层间复用(fa_v1.py)

# fa_v1.py · AscendFlashAttentionImpl.forward_impl(有删节)
# 只在用这个后端的 worker 里才 import CANN——别的部署不多背一个依赖
from cann_ops_transformer.ops import flash_attn, flash_attn_metadata

# Ⓑ 反面一:长度只信 device,惰性算一次,存在 metadata 里
if metadata.flash_attn_lengths is None:
    boundaries = metadata.query_start_loc_gpu[: num_reqs + 1].int()
    boundaries = boundaries.clamp(min=0, max=query.shape[0]).cummax(dim=0).values  # dummy 请求可能有重复/负边界
    lengths = metadata.seq_lens_gpu[:num_reqs].int().clamp(min=1)
    metadata.flash_attn_lengths = boundaries, lengths

# Ⓑ 反面二:tiling 计划按几何特征做 key,一个 draft step 内层间复用
plan_key = (self.num_heads, self.num_kv_heads, query.shape[-1], self.sliding_window, layout_kv)
cached = metadata.flash_attn_plans.get(plan_key)
if cached is None:
    attrs = dict(... mask_mode=4 if ... else 3 if metadata.causal else 0,   # 0 无遮罩/3 因果/4 滑窗
                 layout_q="TND", layout_kv=layout_kv, layout_out="TND")
    cached = flash_attn_metadata(...), attrs
    metadata.flash_attn_plans[plan_key] = cached

with attention_transfer_window():          # KV 池传输窗口内调用
    result, _ = flash_attn(query, self.key_cache, self.value_cache,
                           block_table=..., metadata=plan, **attrs)
    output[: query.shape[0]].copy_(result)  # CANN 返回新张量;vLLM 要自己的输出缓冲——回填
    return output

三个细节值得停一下:clamp+cummax——dummy 请求的边界可能重复甚至为负,不做这两步会算出非法窗口;plan_key 五元组——同几何才共享,换头数/换窗口自动分家("不同几何不共享 plan"有测试);copy_ 回填——CANN 的返回是新张量,vLLM 的约定是写进它给的缓冲,一行 copy_ 把两个约定接上。

5.3 硬件门槛:能力位三行(device/hardware_profile.py)

# device/hardware_profile.py(+3)
class HardwareCapability(Enum):
    ...
    # CANN device-side FlashAttn metadata with strided paged GQA K/V.
    DEVICE_LENGTH_FLASH_ATTN = auto()      # ★ 新能力位
# A5 的 supports 列表加上它——条件二的"户口"就在这三行里

6. 与我们排查问题的关系

7. 收益、风险与待验证

收益 上游 #18717 两节点 P/D 实测:mean TPOT 14.90 → 10.97 ms(约 −26%),output throughput 484.39 → 491.31 tok/s——省的是备料不是数学。950DT + CANN 9.2 上 16 个 eager 精度用例全过(FP16/BF16、strided paged KV、causal/non-causal、滑窗、rejection 后长度)。
安全性设计 窄门七重条件 + 静默回退:任一不满足行为与改前一致;target 选路有测试钉死;不同几何不共享 plan;CANN 只在用到它的 worker 里 import。
待验证(PR 自述) 性能数字出自 #18717 原始修订,本回移植未重新压测;依赖 CANN 9.2 与 A5 能力位;条件不满足是静默回退——不主动验证就会"以为换了其实没换"。PR 开放中,以合入版为准。
对我们的行动建议

8. 术语速查

术语本 PR 语境下的含义
target / draft主模型与它的草稿小模型(①的 MTP 模块):draft 连猜 k 个 token,target 一次前向验证
DFlash2这代 draft 的方法名——条件七用 is_dflash2_draft 判定(method="dflash" 且架构 DFlash2DraftModel)
enforce_eager逐层即时执行的框架开关(对应朴素写法的执行方式)——条件五,图模式 draft 不走此门
CANN FlashAttnCANN 算子库的融合注意力算子;vllm-ascend 经 cann_ops_transformer.ops 调用,算子本体住在 CANN
device 长度query_start_loc / seq_lens 的 device 侧张量——只信它,不信 rejection 后可能过期的 CPU 镜像
tiling 计划 / plan算子开算前的分块方案;本 PR 按 plan_key(头数/KV 头数/head_dim/窗口/布局)缓存,一个 draft step 内层间复用
attention_transfer_windowKV 池传输窗口上下文——flash_attn 在窗口内调用,与 KV 卸载/传输协同
DEVICE_LENGTH_FLASH_ATTNdevice/ 硬件画像新增的能力位——条件二的挂牌处,当前仅 A5 开启
mask_modeCANN 算子的遮罩模式:0 无遮罩 / 3 因果 / 4 滑动窗口

来源:github.com/vllm-project/vllm-ascend PR #18961(开放中,base: releases/v0.30.0,+594/−0,9 文件)· 上游 #18717(性能数字出处)· 本页为技术解读,行号与条件以 PR diff 为准

华杉的技术博客 · 苏ICP备2026063680号-1
首页