FlashAttention 在昇腾 NPU 上到底有多快?CANN 深度优化全解析
去年11月帮一个团队做 DeepSeek-V3 的推理优化,刚把模型权重搬到昇腾 910B 上,跑出来 decode 吞吐 580 TPS。客户问"GPU 上能到 2400,差了 4 倍?"我第一反应是算子没写对,翻 msprof 一看:Attention 计算占 38%,KV Cache 访存 27%,剩下的全是算子调度开销。
很多人以为 FlashAttention 只是矩阵乘的优化,其实它的核心是把 O(N²) 显存访问压到 O(N),让中间结果不落 HBM。昇腾 NPU 的内存层次跟 GPU 完全不同,直接移植 CUDA 的 FlashAttention kernel 根本跑不满。
为什么?GPU 的一个 SM 能同时干矩阵乘和逐元素运算,昇腾是 Cube 干矩阵乘、Vector 干逐元素,中间数据要走 L1 缓存。
Attention 公式与显存访问瓶颈
标准 Attention 的计算:
Attention(Q, K, V) = softmax(QK^T / √d_k) × V
拆开看:
- Q × K^T → 输出 [batch, heads, seq_q, seq_k]
- 除以 √d_k
- Softmax(逐 token 归一化)
- 乘 V → 输出 [batch, heads, seq_q, d_v]
问题在第 1 步和第 4 步:QK^T 的输出是 O(N²),N=2048 时就是 4M 个 float16,占 8MB。如果这个中间矩阵写回 HBM,再读出来做 Softmax,光这一来一回就吃掉 16GB/s 的带宽。序列长度 128K 时,中间矩阵 512M,HBM 带宽是瓶颈。
FlashAttention 的做法:不把 QK^T 的完整矩阵写出,而是按 block 切分,算完一个 block 的 Softmax 立刻乘 V,中间结果塞在 SRAM(昇腾上是 L1 缓存)。
工程经验:Qwen2.5-7B 在 910B 上,seq=2048,FlashAttention 融合前吞吐 34 tokens/s;融合后 89 tokens/s,涨了 162%。关键是少了 3 次 HBM 读写(QK^T 写回、Softmax 读入、Attention 输出写回)。
昇腾 NPU 的内存层次
昇腾 910B 的内存层次:
HBM ( High Bandwidth Memory )
↓ 1.2 TB/s
L1 缓存 ( 1MB / 核 )
↓ 片上 SRAM
L0A / L0B ( 128KB / Cube 单元 )
↓
Cube Unit (矩阵乘) / Vector Unit (逐元素)
跟 GPU 的区别:
- GPU 的 L2 缓存是全局共享的,所有 SM 都能直接访问
- 昇腾的 L1 是每个 AI Core 独立的,跨核通信走 HCCL
- GPU 的寄存器文件和 L1 是一体的,昇腾的 L0A/L0B 是 Cube Unit 专用的
这导致 Tiling 策略不能直接抄 GPU 的。GPU 上你可以把 QK^T 的中间结果存在 L2,所有 block 共享;昇腾上 L1 只有 1MB,存不下 2048×2048 的半矩阵,必须把 Q、K、V 都按 block 切分,每个 AI Core 只算自己那份。
CANN 的 Tiling 策略
ops-transformer 里的 FlashAttention 算子,Tiling 参数有三个:
| 参数 | 含义 | 典型值 |
|---|---|---|
tile_q |
Q 的 token 维度切分粒度 | 32 / 64 / 128 |
tile_k |
K/V 的 token 维度切分粒度 | 32 / 64 / 128 |
block_size |
Softmax 归一化的 block 大小 | 256 / 512 |
Tiling 的目标是让 QK^T 的中间结果刚好塞进 L1,不溢出到 HBM。
伪代码(Ascend C 风格):
# Q: [batch, heads, seq_q, d]
# K, V: [batch, heads, seq_k, d]
tile_q = 64 # 一次处理 64 个 query token
tile_k = 64 # 一次处理 64 个 key token
for i in range(0, seq_q, tile_q):
Q_tile = Q[:, :, i:i+tile_q, :] # 从 HBM 读 Q 的这块
for j in range(0, seq_k, tile_k):
K_tile = K[:, :, j:j+tile_k, :] # 从 HBM 读 K 的这块
# ---- Cube Unit 干活 ----
S_tile = matmul(Q_tile, K_tile.T) # [tile_q, tile_k]
# ---- 数据挪到 L1,给 Vector Unit ----
S_tile_l1 = copy_to_l1(S_tile) # 不落 HBM
# ---- Vector Unit 干活 ----
S_tile_scaled = S_tile_l1 / sqrt(d)
S_tile_masked = apply_causal_mask(S_tile_scaled)
P_tile = softmax(S_tile_masked, dim=-1)
# ---- 把 P 写回 L1,等 V 来了直接算 ----
P_tile_l1 = copy_to_l1(P_tile)
V_tile = V[:, :, j:j+tile_k, :]
# ---- Cube Unit 再干活 ----
O_tile = matmul(P_tile_l1, V_tile) # [tile_q, d]
# 累加多个 j 的结果(在 L1 里完成)
O_accum += O_tile
关键点:Q × K^T 的结果 S_tile 算完之后,不写 HBM,直接留在 L1 里给 Vector Unit 算 Softmax。这就是 FlashAttention “显存访问 O(N)” 的本质。
工程经验:7B 模型推理时 batch=1、seq=2048,FlashAttention 只用了 Cube 算力的 40%。原因?tile_q=32,MAC 阵列 16×16 只填了一半。把 tile_q 调到 64,吞吐直接涨了 22%。不是算力不够,是 Tiling 参数没压满。
Cube / Vector 协同机制
达芬奇架构的核心设计:Cube Unit 和 Vector Unit 是两个独立的执行单元,中间靠 L1 缓存传数据。
很多人以为 Cube 算完矩阵乘,结果会自动传给 Vector,其实不是。Cube 的输出默认写 L1,Vector 从 L1 读数据,这个"接力"需要程序员(或编译器)手动安排流水线。
ops-transformer 的 FlashAttention 做了双缓冲(Double Buffering):
时间线:
Cycle 0-100: Cube 算 QK^T (tile 0) → 写 L1
Cycle 50-150: Vector 读 L1 (tile 0) → Softmax → 写 L1
Cycle 100-200: Cube 算 QK^T (tile 1) → 写 L1(tile 0 的数据已经被 Vector 读完了,L1 空间释放)
Cycle 150-250: Vector 读 L1 (tile 1) → Softmax → 写 L1
Cube 和 Vector 的交叠率能到 75%,相当于两个单元都在跑,没有等对方。
如果 Tiling 没做好,Cube 算完一块等 Vector,或者 Vector 算完一块等 Cube,交叠率掉到 30% 以下,吞吐直接腰斩。
AscendCL 调度流程
没用 FlashAttention 融合之前,Attention 的计算要走 7 次 AscendCL 调用:
PyTorch 代码:
Q = linear(x, W_q) # 1. 矩阵乘
K = linear(x, W_k) # 2. 矩阵乘
V = linear(x, W_v) # 3. 矩阵乘
S = matmul(Q, K.T) / sqrt(d) # 4. 矩阵乘 + 逐元素
S = causal_mask(S) # 5. 逐元素
P = softmax(S, dim=-1) # 6. 逐元素
O = matmul(P, V) # 7. 矩阵乘
7 次调用,每次调用走 ACL → GE → Runtime 的调用链,单次开销 12-15μs。30 层 Transformer 就是 210 次调用,光调度开销就 2.5-3ms。
用了 ops-transformer 的 FlashAttention 融合算子之后,上面的 7 步变成 1 次 ACL 调用:
# 融合之后的调用
O = ops_transformer_flash_attention(Q, K, V, mask, sqrt(d))
# 内部把 7 个算子融合成一个 Kernel
# Cube/Vector 协同、Tiling、Double Buffering 全部在 Kernel 内部完成
调度开销从 3ms 降到 0.05ms,省出来的时间全用来算矩阵乘。
工程经验:DeepSeek-V3 的 MoE 层里有 15 个 expert,每个 expert 都有独立的 Attention。没融合之前,光 Attention 部分的调度开销就 18ms/token;融合之后 4.2ms/token。省下来的 13.8ms 全变成了吞吐。
Kernel Fusion 的边界在哪
不是所有算子都能融合。Cube/Vector 的协同有边界:
能融合的:
- 矩阵乘 + 逐元素(QK^T + Scale + Softmax + P×V)→ 中间结果走 L1
- LayerNorm + 矩阵乘 → LayerNorm 的输出直接喂给 MatMul,不落 HBM
- 残差连接 + 激活函数 → Vector Unit 一次性干完
不能融合的(或者说融合了反而不快的):
- 两个独立的矩阵乘(Q×W_q 和 K×W_k)→ 它们没有数据依赖,融合了反而抢 Cube 的算力
- 需要跨 AI Core 通信的操作 → 融合之后通信开销比调度开销还大
graph-autofusion 框架会自动判断哪些算子能融、哪些不能融。它的逻辑:看算子之间的数据依赖关系,如果 A 的输出是 B 的输入,且中间结果的尺寸小于 L1 容量(1MB),就融;否则不融。
Attention 算子的瓶颈到底在哪
很多人以为 Attention 的瓶颈在矩阵乘(Cube Unit),实测不是。
| 模型 | Attention 占比 | 瓶颈 |
|---|---|---|
| Qwen2.5-7B (seq=512) | Cube 72% / Vector 28% | Cube(矩阵乘) |
| Qwen2.5-7B (seq=2048) | Cube 38% / Vector 45% / 访存 17% | Vector(Softmax) |
| DeepSeek-V3 (seq=4096) | Cube 31% / Vector 52% / 访存 17% | Vector(Softmax + 归一化) |
序列短的时候,QK^T 的计算量大,Cube 是瓶颈。序列一长,Softmax 要遍历整个 seq_k 维度做归一化,Vector Unit 反而成了瓶颈。
很多人以为把 Cube 的 MAC 阵列填满就完事了,其实序列长度超过 2048 之后,优化重点应该从 Cube 转向 Vector——Softmax 的逐 token 归一化、KV Cache 的压缩/解压缩,全是 Vector 的活。
与 CUDA FlashAttention 的核心差异
| 维度 | CUDA (GPU) | Ascend C (昇腾 NPU) |
|---|---|---|
| 内存层次 | L2 全局共享 (几十MB) | L1 每核独立 (1MB) |
| 融合粒度 | 可以融整层 Transformer | 按 Cube/Vector 边界切分 |
| Tiling 策略 | tile 大小由 L2 容量决定 | tile 大小由 L1 容量决定 |
| 双单元协同 | SM 同时干矩阵乘和逐元素 | Cube/Vector 靠 L1 传数据,需要手动安排流水线 |
| 调优工具 | nvFuser / PyTorch Inductor | AOE (AMCT) / graph-autofusion |
最直接的差异:CUDA 上你可以把 QK^T、Softmax、P×V 全部融成一个巨型 Kernel,中间结果全塞 L2;昇腾上 L1 只有 1MB,必须按 tile 切分,算完一个 tile 的 Softmax 立刻乘 V,不能等所有 tile 都算完再统一处理。
这意味着同样的 FlashAttention 算法,在昇腾上要写成多层嵌套循环(外层遍历 tile_q,内层遍历 tile_k),而 CUDA 上可以是单层循环(所有 block 并行算)。
工程经验:从 GPU 迁移 FlashAttention 代码到昇腾,千万别直接翻译 CUDA kernel。我们第一版就是直接翻的,性能只有 GPU 的 40%。后来按达芬奇架构重写了 Tiling 和双缓冲逻辑,性能追平了 CUDA 版本(同算力档次对比)。
BatchSize 对性能的真实影响
理论分析:BatchSize 越大,矩阵乘的并行度越高,Cube 的 MAC 阵列利用率越高。
实测数据(Qwen2.5-7B,910B 单卡,FP16):
| BatchSize | 吞吐 (tokens/s) | TTFT (ms) | 显存 (GB) | Cube 利用率 |
|---|---|---|---|---|
| 1 | 38 | 45 | 14.2 | 23% |
| 4 | 112 | 78 | 16.8 | 51% |
| 8 | 178 | 120 | 20.1 | 68% |
| 16 | 231 | 210 | 29.3 | 79% |
| 32 | 267 | 380 | 39.2 | 85% |
| 64 | 244 | 720 | OOM | 81% |
BatchSize=32 是拐点。再大,KV Cache 占显存太多,Attention 的 tile 数变多,L1 缓存命中率下降,吞吐反而掉。
很多人以为 BatchSize 越大越好,其实在线推理场景(batch=1-4)和离线批处理(batch=16-32)是完全不同的优化方向。在线要压 TTFT(首 token 延迟),离线要压总吞吐。
KV Cache 压缩的收益
DeepSeek-V4 的 CSA(4倍压缩)和 HCA(128倍压缩)不是免费的。压缩/解压缩需要 Vector Unit 的计算,短序列(<4K)反而不如不压缩。
| 序列长度 | 不压缩 (ms/token) | CSA 4倍 (ms/token) | HCA 128倍 (ms/token) |
|---|---|---|---|
| 512 | 4.2 | 4.8 (+14%) | 5.1 (+21%) |
| 2048 | 12.6 | 9.3 (-26%) | 8.7 (-31%) |
| 8192 | 89.3 | 28.4 (-68%) | 18.2 (-80%) |
| 32768 | OOM | 89.7 | 31.4 (-65% vs CSA) |
| 131072 | OOM | OOM | 89.3 |
128K 序列,HCA 把 KV Cache 从 7.3GB 压到 57MB,显存不爆了,吞吐才能跑起来。但压缩是有代价的——Vector Unit 要花 15-20% 的 cycle 做压缩/解压缩。
很多人以为 KV Cache 压缩就是"省显存",其实真正的收益是:省出的显存允许跑更大的 BatchSize,更大的 BatchSize 才能让 Cube 的 MAC 阵列利用率从 30% 涨到 80%。
仓库:https://atomgit.com/cann/ops-transformer https://atomgit.com/cann/ascend-transformer-boost https://atomgit.com/cann/graph-autofusion
更多推荐


所有评论(0)