去年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

拆开看:

  1. Q × K^T → 输出 [batch, heads, seq_q, seq_k]
  2. 除以 √d_k
  3. Softmax(逐 token 归一化)
  4. 乘 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

Logo

中国智能体开发者社区,聚焦智能体与大模型开发,提供前沿资讯、实用工具链、开源项目及行业案例。通过技术沙龙、开发者大赛等活动,促进经验交流与协作,助力开发者快速构建创新智能应用。

更多推荐