vllm-ascend PR #18961 解读:给 GLM 的 draft 层 attention 换上 CANN FlashAttn
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 章术语速查)
- MTP draft / DFlash2(架构件):GLM 架构里主模型旁边的那件小模型——推理时先连猜几个 token、再由主模型一次前向验证;dflash 是这代 draft 的方法名;
- enforce_eager(框架开关):对应手写 PyTorch 推理时"逐层即时执行"的朴素方式——禁用图捕获、每层现场跑;
- CANN FlashAttn(算子):CANN 算子库里的融合注意力算子——QKᵀ、遮罩、softmax、·V 一个算子出齐,算子本体住在 CANN(vllm-ascend 只管喂参数);
- A5(950DT)/ CANN 9.2(硬件与算子库版本):本 PR 的门槛条件——能力位挂在 device/ 目录的硬件画像里;
- SFA(③里的后端):GLM DSA 主模型现在选的稀疏注意力后端(attention/ 目录)——本 PR 不动它,只给 draft 层换引擎。
一句话总结
draft 层的 attention 数学与主模型一字不差,但"被调用的方式"特殊:每步要连猜 k 次、序列极短、每步都要重新备料。改前它与最普通的 dense 层查同一张后端选路表,拿到通用后端——每层自建元数据、长度依赖 CPU 镜像(拒绝采样后可能过期)、每层重建 tiling 计划,备料开销吃进每 token 时延。本 PR 在选路表前加一道七重条件窄门,全过才给 draft 层换上 CANN FlashAttn 专用后端:device 长度取一次、tiling 计划一步一建层间复用;主模型 target 的选路一个字不动。
图 1 · PR 改动全景:3 个源码文件=「引擎三件」,6 个测试文件组成回归网
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 里相当一块是备料,不是数学。
图 2 · 改动前:Ⓐ draft 与 dense 同表选路,Ⓑ 备料逐层重复——TPOT 的大头不在数学在备料
图 3 · target 与 draft 的分工:一轮「猜-验」循环里,draft 的 attention 跑 k 次、备料也随之重复 k 次
ⒶⒷ 速查(后文正文与代码注释均用此编号)
- Ⓐ 选路同表:draft 层的 attention 与最普通的 dense 层查同一张 backend_map 选路表,落格到通用后端(attention_v1)——没有 draft 专用路径;
- Ⓑ 备料重复:每层各自建注意力元数据、长度依赖 CPU 镜像(拒绝采样后可能过期)、每层各自重建 tiling 计划——一步 k 次猜 × N' 层,重复 N'×(k+1) 次。
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:
图 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):
图 5 · 模型架构 → 框架流程:模型的每一件在 vllm-ascend 里的落点与调用者(★ = 本 PR 改动落点)
看懂这张图,再定位本 PR。架构件一个没变、链子一段没短——变的是"引擎层":draft 层的 attention 在选路处(platform.py)多了道窄门,全过就换到新后端(attention/fa_v1.py);门槛挂在硬件画像(device/)上。这就是为什么图 5 的 ★ 落在引擎三件上,而不是任何注意力算子上。
4.2 先认目录:与 attention 相关的目录,一个一个认
图 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),左列十行自上而下,右列是框架接手每行的落点:
图 7 · 流水线(原本):十行示例在 vllm-ascend 里的完成过程(target 主模型这条路)
十行示例的每一行,框架都有对应落点。这套"逐行挂门牌"的完成过程就是 vllm-ascend 平时跑 attention 的样子——本 PR 不改这条链的任何一环,它改的是链子入口处"这条链配不配给 draft 用"的选路判断。
4.4 全链时序:谁调用谁,每一棒先自己干再交棒
图 8 · 全链时序图:从引擎到后端,每个类"自己做什么 / 委托什么"——每框写明跟十行的关系与省什么(文件与行号本地实测)
时序图末尾那串自转圈(后端九步)是主模型 target 的 SFA 路径;draft 层的老路更简单——通用后端没有九步流水线,但每一棒"先自己备料再开算"的节奏是一样的。本 PR 动的是第一棒之前的选路与备料,不动调用链本身。
4.5 为什么这么实现:逐步收益
还剩最后一个问题: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——数学只占其中一块,备料吃掉的是纯开销。从引擎点进这段代码(改前户口):
图 10 · 改前户口:draft 的引擎在 backend_map 落格处被派定为通用后端(Ⓐ),备料在选路之后逐层重复(Ⓑ)
4.7 这次 PR:七重条件窄门 + 新后端三件套
改法两句话:选路总闸在查表之前插一道窄门(七重条件全过 → 返回新后端;任一不满足 → 照旧查表,静默回老路);新后端 fa_v1.py 三件套接住 draft 的调用方式。改后户口:
图 11 · 改后户口:窄门在 backend_map 之前,七重全过 → fa_v1.AscendFlashAttentionBackend;任一不满足回老路
老路 ↔ PR 做法,逐行对照(左列=老路一步的备料与开算,中列=PR 做法,右列=门牌与为什么):
图 12 · 流水线对照:draft attention 一步的六行对照——窄门换引擎、device 长度一次、plan 层间复用、CANN 算子收尾
窄门里到底查什么?七重条件一棵树(任一"否"都回老路——所以叫窄门):
图 13 · 七重条件树:硬件、runner、draft/eager、目标模型、draft 形态与精度、特性排除——窄门的全部内容
新后端三件套各自干了什么(fa_v1.py,关系化拆解):
- 元数据件(Builder):干了什么——从 device 侧精确长度建注意力元数据;跟 draft 什么关系——拒绝采样刚淘汰 token,CPU 镜像可能还没同步,draft 每步都踩这个坑,所以只信 device;省什么——算错窗口重来的返工;
- 计划件(plan 缓存):干了什么——tiling 计划按几何特征(头数/KV 头数/head_dim/窗口/布局)做 key 缓存在元数据里;跟 draft 什么关系——一步 k 次猜 × N' 层的调用里,同几何的层共用一份计划;省什么——host 侧重建计划的纯开销,TPOT 那 26% 的主力;
- 算子件(Impl):干了什么——在 KV 传输窗口内调 CANN flash_attn,结果 copy_ 回 vLLM 的输出缓冲;跟朴素代码什么关系——就是十行示例那几行数学的 CANN 融合版;省什么——融合算子中间结果不落显存(省带宽),且只在用到它的 worker 里 import CANN。
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 主模型 | SFA | SFA——一个字不动,"缓存的 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 配置 ×2 | A5 能力位登记;精度用例路由到 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. 与我们排查问题的关系
- 硬件对上了:门槛条件二的 A5(950DT)就是《Profiling 实战解读》那台机器——本 PR 合入并升 CANN 9.2 后,我们环境里 GLM 家族 + draft + eager 的组合直接受益;
- TPOT 拆解多了一个怀疑对象:以前拆每 token 时延,眼睛盯着算子耗时( DispatchFFNCombine 那类);这个 PR 提醒还有第四种账——备料(host 侧建元数据/建计划的纯开销)。用姊妹篇的三板斧能直接看:draft 窗口的 op_statistic 里 host 侧 API 计数掉没掉;
- eager 不再等于慢:排查时切 enforce_eager 复测是常用手段,以前要付出"draft 变慢"的代价——这条 PR 把代价摘了,手段更干净;
- "参数生效了吗"多了一个样本:换引擎属于「换实现」类改动(四分类里的第一类),验证就是 diff op_statistic——draft 的 attention kernel 名换成 CANN FlashAttn 一族即生效(《算子与 vLLM 参数》1 节四分类的实战案例);
- 家族延续:#18671 拆布局(PP=1)、#18870 补边界(PP>1)、本篇换引擎(draft)——同一条 GLM 部署线的第三块拼图。
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 开放中,以合入版为准。
对我们的行动建议
- 跟进合入;合入后在 A5 + CANN 9.2 环境用真实 GLM/DFlash2 配置跑一遍 4.10 的选路单测,确认窄门开;
- 按《算子与 vLLM 参数》三板斧验生效:diff 前后 op_statistic,draft 的 attention kernel 应换成 CANN FlashAttn 一族;
- 再压一轮 TPOT 对比 10.97 ms 这条线——回移植版本值得有自己的数字。
8. 术语速查
| 术语 | 本 PR 语境下的含义 |
| target / draft | 主模型与它的草稿小模型(①的 MTP 模块):draft 连猜 k 个 token,target 一次前向验证 |
| DFlash2 | 这代 draft 的方法名——条件七用 is_dflash2_draft 判定(method="dflash" 且架构 DFlash2DraftModel) |
| enforce_eager | 逐层即时执行的框架开关(对应朴素写法的执行方式)——条件五,图模式 draft 不走此门 |
| CANN FlashAttn | CANN 算子库的融合注意力算子;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_window | KV 池传输窗口上下文——flash_attn 在窗口内调用,与 KV 卸载/传输协同 |
| DEVICE_LENGTH_FLASH_ATTN | device/ 硬件画像新增的能力位——条件二的挂牌处,当前仅 A5 开启 |
| mask_mode | CANN 算子的遮罩模式:0 无遮罩 / 3 因果 / 4 滑动窗口 |
来源:github.com/vllm-project/vllm-ascend PR #18961(开放中,base: releases/v0.30.0,+594/−0,9 文件)· 上游 #18717(性能数字出处)· 本页为技术解读,行号与条件以 PR diff 为准