Note: This page does not support English, using the default language version
前面我们花了不少篇幅,去解释 vLLM Ascend 为了把图模式“做得像上游一样”,到底付出了哪些额外设计成本。写到这里,如果还只是把这些补丁当成“适配工作本来就该如此”,那未免有些过于老实了。
现在我们不妨换个角度,回头看一眼那些之前被我们默认接受的前提:这些下层设计,是否足够合理? 还是说,我们只是因为其他一些因素,只能在上层默默把坑填上,再安慰自己一句“这就是工程现实”?
我们先挑一个最扎眼,也最反复折磨图模式的问题来开刀:为什么算子需要 Host 输入?
如果只看上层体验,那么 actual_seq_lengths / actual_seq_lengths_kv 这类 Host 侧输入,天然会被视为“痛苦之源”。理由也很直接:每轮执行时,Host 还在继续参与某些“本该已经冻结”的事情。于是上层就不得不额外去做task group 标记、参数更新、流间同步、tiling 覆盖,最后把一条原本应该很干净的 replay 路径,硬生生改造成了一套“看似静态,实则半即时半重放”的混合流程。
如果只停在这里,那结论似乎很简单:Host 输入就是坏文明,最好全部消灭掉。可惜事情没这么简单。
对变长混合批次来说,query_start_loc / seq_lens / actual_seq_lengths 这类东西,本质上都在表达同一类信息:这一批拼接后的 token,哪些属于同一个请求,每个请求的有效边界在哪里。 如果没有这份边界信息,那么在 TND 或 packed token 的排布下,算子连“哪些 token 是一个请求,哪些不该混起来”都搞不清楚,自然也就无从谈起正确性。
那这份信息为什么会落到 Host 侧?
IFA/PFA 的 tiling 依赖
actual_seq_lengths的值,是问题的起源。
当前 FIA (IFA/PFA) 这条实现路径里,边界信息会参与 Host 侧的 tiling 决策。也就是说,在 launch 之前,它就得先被 Host 感知和消费,然后才能去做模板选择、参数准备和下发等一系列操作。
说到这里,一个再自然不过的问题就出现了:GPU 上的实现呢?为啥没这问题?
以 FlashAttention3 为例,cu_seqlens_q / cu_seqlens_k 会作为 device pointer 进入 kernel 参数;到了 Hopper 路径中,kernel 入口会构造 SeqlenInfo 这一类辅助结构,在 prologue 里按 batch 读取 cu_seqlens,再计算当前样本的真实 seqlen、offset,以及 tile 的有效范围。换句话说,这份“长度信息”被保留成了 Device 侧可以继续读取和解释的运行时数据。
注意,并不是说 GPU 完全没有 Host 参与。像 max_seqlen_q / max_seqlen_k、kernel variant 选择、launch config 这些,照样还是在 Host 侧决定的,只是它们通常更容易在 launch 或 capture 之前确定下来。而真正随批次变化的边界信息,很大一部分是留在了 kernel 内部来处理。 这就使得 query_start_loc / seq_lens 虽然同样动态,却仍然能够保持为 device tensor,而不必在每一步都退化成 Host 侧输入。
但问题并没有完全解释清楚:为什么在当前这条 Ascend 路径里,这些事情需要前移到 Host 侧,而不是像 CUDA 路径里那样,更多留给 kernel 内部处理?
这里我们就要讲到硬件设计和编程模型的差异了:在 NVIDIA 的 SM 内,围绕 SIMT 执行模型,本来就存在一整套负责 warp 调度、整型运算、分支控制、地址与索引计算 的能力。对于 seq_lens / query_lens 这种信息来说,kernel 真正要做的其实也没啥,无非就是:读边界、算 offset、决定循环上界、裁掉无效 tile、生成 mask。这些工作就属于控制流、整数计算和地址生成。
warp 作为基本调度单位执行同一条指令流,同时允许线程拥有各自的寄存器状态、访问不同地址,必要时也可以处理控制流分歧。再叠加现代 SM 里公开可见的 warp scheduler,以及整数与浮点资源并存的执行能力,结果就是:大量和边界、索引、地址相关的“小脑力活”,是可以被比较自然地塞进 kernel prologue 里处理的。
所以对于 FlashAttention3 这种实现来说,把 cu_seqlens 作为 device pointer 传进去,再由 kernel 入口的 SeqlenInfo 去读取、计算真实 seqlen 和 offset,并不奇怪。它并不是“完全没有代价”,而是这类代价在 CUDA 的执行模型里,往往还能被多 warp 调度和主计算流水掩盖掉,因此仍然是一个合理的工程选择。
反过来看昇腾这边,让我们看下 官方文档:
AI Core 里的 Scalar 单元虽然也负责标量运算、流程控制、地址和参数计算,但它的主要职责首先是核内指令发射和调度。

也正因如此,公开资料里才会反复强调:在性能调优时,应尽量减少分支判断及变量运算。
所以如果你把对应的 tiling 判定、边界推导、模板选择之类的工作,统统都压到 device 侧临场计算,那么在 Ascend 这边,很可能并不划算:要么 tiling 被迫做得更保守,要么核内 Scalar 承担过多控制负担,最终拖慢整体执行。
于是,就出现了我们现在看到的这条实现路线:与其让 device 在执行时自己“边跑边想”,不如让 Host 先把该想清楚的东西想清楚。 先在 Host 侧做完 tiling、task 切分和参数准备,再把结果下发给算子执行。这样做当然会牺牲图模式的优雅性,但对于当前这条硬件与软件栈组合来说,只能说是一个现实的性能取舍。
呼,我们终于把链路和对比讲清楚了,但是下一步呢?
问题依然存在,参数更新这个方案依然不好用。
是让下一代芯片设计时考虑增强这部分 device 侧能力?还是考虑利用好 AI CPU 的优势?这就不容我猜测了,只能在此遗憾打住。
这一项和前一个问题不太一样。前一个问题更像是典型的技术妥协,顺着硬件、软件栈和执行模型一路往下看,多少还能看出“为什么会变成这样”。但这个问题,硬要说的话,更像是用户心智、市场压力,以及内部评审机制共同塑形出来的结果。
先回忆一下 vLLM 里的背景:在实际推理时,算子原本完全可以接受这样一种状态:hidden_states.shape[0] = 8,而 query_start_loc[-1] = 5。这表示实际有效的只有 5 个 token,只不过由于图模式、并行策略等原因,把张量第 0 维填充到了 8。这种“shape 按固定规格对齐、语义按真实 token 数解释”的做法,在上层框架里本来就是完全正常的。
事实上,接口最开始也没有这个强制约束,只是在日志中给一个 WARNING,提示两者不相等,可能存在问题。
后来为什么会引入“末元素必须等于张量第 0 维”的强制校验?据我了解,一个比较直接的背景是:有客户在开发新功能时,把 query_start_loc 错配成了更大的值,结果在运行中触发了越界。出了问题,就要复盘,一旦进入复盘,很多技术之外的东西也会一起进入决策过程:默认的不信任感、开发过程中的无力感、跨团队反复拉扯带来的疲劳感,最后汇成一句愤怒的质问:“为什么不提前校验并直接报错?”
于是:内部高度重视并迅速响应客户意见,快速推进,在后续版本里加入了强制校验。站在会议室和版本说明的视角,这也算是一次及时、负责、有效的修复。皆大欢喜!
首先,我并不认为问题根源来自客户,事实上客户的诉求完全可以理解。 真正值得复盘的,是后面的决策是如何被做出来的。因为这里其实混在一起的是两类完全不同的情况:
如果把这两类情况都压成一个“必须严格相等”的规则,那么它虽然解决了第一类问题,却会把第二类原本合法、而且在 GPU 生态里很常见的运行方式一并判成非法。于是我们在上层看到的,就不再只是一次简单的参数校验,而是图模式、SP、DP、推测解码等一系列特性都得围着这个约束额外打补丁。
换句话说,问题不在于“要不要做校验”,而在于现在这条校验把边界画得过于保守,连原本合理的情况也一起拦掉了。
那为什么最后会落到这么保守的版本上?我“恶意揣测”一下:
在用户心智中,Ascend 的地位依然只是 Nvidia 的替代品,而且是不兼容 CUDA 生态的替代品,这会导致天然的不信任感。如果一个写法,GPU 上没错,NPU 上有错,那刻板印象直接加深。
那如果 GPU 和 NPU 上都有错呢?
如果 GPU 和 NPU 上都有错,客户往往也会要求你做到更好,为他们的开发兜底。这不是说客户“霸道”,也不是无中生有,而是很容易理解的一种补偿心理(或者看下张五常的那个“上河定律”):既然前面已经吃了不少苦头,那这次总得抓住机会,把不满和诉求完整地表达出来,不然谁也不知道下次再遇到类似问题时,还有没有机会推动平台侧去改。
到了“收到了严重客户反馈”这一步,其实事情仍然完全可以挽回,只要把校验语义定对就行了。可问题是,决策时大家的视野太窄,只把它当成一个算子侧 DFX 问题,没有把实际推理时的图模式、填充和并行策略一并纳入讨论。再加上此前本来就有 WARNING,把 WARNING 升级成 ERROR,看起来又非常顺手、非常合理,于是最后一道防护网也就失效了。
于是,一个原本并不复杂的问题(甚至严格来说,本来只是开发编写代码时的失误)最后被一层层放大,变成了后续维护中的长期地雷。
我们能如何预防这种问题呢?
短期来看,这对内部评审提出了更高的要求:不仅要敢于指出客户诉求里没有被说清楚、想周全的部分,也要在讨论时把合适的参与方真正拉进来,做充分讨论,而不是拍脑袋走形式。
长期来看,则还是那句老话:生态策略要更诚实。要么就大大方方跟随,强调一切尽量以 CUDA 的行为为准;要么如果确实要做差异化,就别再幻想“稳定性、易用性、性能、人力成本”可以同时兼得。因为很多时候,所谓差异化,最后并不会沉淀成能力,反而会沦为各方不断往系统里塞入自身诉求的入口,最终把生态本身拖得越来越难用。
简单来说,“革命尚未成功,同志仍需努力”。
首先,现在的支持范围还比较有限。据我了解,当前主要还是围绕 PyTorch 2.7.1 做了一些适配工作,其他版本不太行,而且不少地方是规避方案。最主要的,通用 Benchmark 的测试通过率也还不高,可用性较差。
其次,它真正能稳定管理的算子范围也比较有限。尤其 ACLNN 算子,应该是没法用的,会直接 fallback,而我们现在绝大多数使用的算子又是 ACLNN 的,这也直接限制它在真实场景里的应用。
再往深了说,以我和其他专家交流得到的信息来看,目前分核与 tiling 相关的一些逻辑实现本身也还有改进空间。不过具体问题这里就不展开了,毕竟相关方向应该已经有在安排整改;如果感兴趣,可以关注一下 Torch NPU 仓库里后续的相关提交。