跳到主要内容
TileLang 系统教程

tilelang-tutorial.huandu.me

第 15 章从会用到敢用33 分钟14 习题8 自测题

算子实战 III:Attention 家族

从朴素 attention 的三趟访存推导到 online softmax 的一趟实现,逐级加上分块、流水与片上复用,并覆盖 causal、varlen、GQA、decode、paged KV 与 split-KV 等变体。

本章进度状态 未读完习题 0 / 14自测 未作答

学习目标

读完本章,你应当能够:

  1. 数清一次朴素 attention 到底在 HBM 上读写了几趟、每一趟搬了多少元素,并用同一套 记账法说明 FlashAttention 把哪几趟消掉了。
  2. 自己推导 online softmax:写出「分块 → 运行最大值 → 重标度 → 累加」的递推式, 说明为什么每一步的重标度因子是 exp(m_prev - m_new),并证明最终结果与先算完整 softmax 再乘 V 完全等价。不是背结论,而是知道每一步为什么可以这么做。
  3. 写出 attention 的四级递进实现:① 朴素版(materialize S×S)→ ② 分块 + online softmax → ③ 加流水与 shared memory 复用 → ④ 对齐官方 flash_attention 例子,并 说清每一级「上一版为什么不够、这一版改了什么」。
  4. 面对 causal / varlen / GQA / decode / paged KV / sliding window / attention sink 七个变体,能在锁定版本的 examples/ 里指出各自实现在哪个文件、改了哪几行。
  5. 说清昇腾上 attention 的瓶颈与 NVIDIA 有什么不同:为什么昇腾的 QK^T 结果必须 绕一趟 workspace,为什么「Cube 与 Vector 的核间流水」是主要矛盾。
  6. 识别数值稳定性、causal 边界、varlen 打包、split-KV 归约顺序这四类坑,并知道 每一类在锁定源码里的具体位置。

前置知识

  • 第 9 章:T.copy / T.gemm / T.reduce_max / T.reduce_sum / T.fill 的语义。
  • 第 10 章:shared memory 与 fragment 的区别、layout 为什么是接口。
  • 第 11 章:T.Pipelined、多缓冲、stage/order、「流水优化吞吐不优化延迟」。
  • 第 13 章:reduction 类算子的写法与 T.reduce_* 的 clear 语义。
  • 第 14 章:T.gemm 的 M/N/K 协议、warp 划分与 GemmWarpPolicy(本章大量复用)。
  • 第 6 章(可选):NV 的 SIMT + Tensor Core 与昇腾的 Cube/Vector 分工。

本章的验证边界(很重要):写作环境是 macOS (Apple Silicon),没有 NVIDIA GPU, 也没有昇腾 NPU。因此本章出现的数字只有两类,来源都写清楚:

  • 本机模型的输出:来自 code/01-traffic-model/–code/04-block-flash-attention/ 的纯 CPU 程序(标准库 + numpy), 可以在任意机器上复算;本章把它标为「CPU 逻辑验证」。
  • 厂商/上游自带的数字:来自锁定版本源码里的 README 与 benchmark 文档,带原始 文件路径与测量条件;本章把它标为「厂商自报,作者未复现」。

本章没有任何一条本机实测的 kernel 性能数字,也不声称任何 kernel 被运行过。 所有 code/ 目录里的 kernel 源码都只是「可以抄到有卡机器上跑」的骨架,验证级别写在 每个示例的 资料库索引 里。


1. 问题的由来:attention 的时间花在哪

分块融合的一趟访存Q (N·d)K (N·d)gemm:S_blk = QKᵀ片上:fragmentS/P 一次都不出片上online softmaxm / ℓ / O 递推V (N·d)gemm:O += P·VO (N·d)
S、P 只在片上:代价是 K/V 每个 Q 块重读一遍,用带宽换片上空间。
  • 每个 Q 块(共 N/block_m = 64 个)把 K/V 各重读一遍,换来 N² 项为 0
  • 「三趟变一趟」指的是 S/P 的落地次数,不是 K/V 的读取次数——优化后 K/V 反而读得更多
  • 总流量 = Q(1) + K(64) + V(64) + O(1) 个元素;这个「省」只在 prefill(Sq = Skv = N)形状成立
朴素 attention 的三趟访存写 S (N²)读 S (N²)写 P (N²)读 P (N²)Q (N·d)K (N·d)gemm:S = QKᵀHBM:S (N²)softmaxHBM:P (N²)gemm:O = PVV (N·d)O (N·d)
S 与 P 各写一次读一次:N=8192 时 4N² 就占了总流量的 98.5%。
  • N=8192 时 S/P 的读写共 268,435,456 个元素,而 Q/K/V/O 一共只有 4,194,304(占 1.5%)
  • 同一模型下分块融合版把 N² 项降到 0:总流量 272,629,760 → 136,314,880(本模型 2.00×)
  • decode 形状(Sq=1)下这个倍数变成 1×:瓶颈是并行度而不是流量

1.1 三个矩阵乘与一个 softmax

Attention 的形状约定本章统一用 BHSD(batch × head × seq × dim),因为它和 examples/flash_attention/example_mha_fwd_bhsd.py 的索引顺序一致;BSHD 只是换一下 转置,本章末尾会说明它的实现差别。

对每个 (batch, head) 对,输入 Q, K, V ∈ R^{N×d},输出 O ∈ R^{N×d}:

S = Q Kᵀ · s            # [N, N]   第一处矩阵乘,s = 1/√d
P = softmax(S, dim=-1)  # [N, N]   逐行的 softmax
O = P V                 # [N, d]   第二处矩阵乘

想要「会写 attention kernel」,第一步是看清楚:S 和 P 都是 N×N。当 N = 32K 时,N×N 在 fp32 下是 4 GB(单个 batch、单个 head)。这还没有算上读写它的时间。

1.2 朴素实现的三趟访存

把上面三行写成最直白的 GPU 实现(三个独立 kernel,或一个 kernel 里来回读写全局内存), 每个 (batch, head) 的 HBM 流量是:

步骤 读 写
① S = QKᵀ Q(Nd) + K(Nd) S(N²)
② P = softmax(S) S(N²) P(N²)
③ O = PV P(N²) + V(Nd) O(Nd)

合计约 4N² + 4Nd 个元素。N 一大,4N² 就是全部:计算是 O(N²d),访存也是 O(N²),但 d 只有 64–256,而 N 可以到 10⁵。这就是 1.3 节要说的「算得不多、 搬得很多」。

第 1 章引用的厂商资料给出过这两个数量级的差距:未命中缓存的全局访存是「hundreds of clock cycles」,而多数算术指令「typically 4 cycles」 (CUDA C++ Best Practices Guide,条件: 未缓存的 local/global 访存与 compute capability 7.0 设备)。所以把 N² 的中间结果 写出去再读回来,代价不是「多一点带宽」,而是把整个 kernel 的时间轴拉长。

1.3 算得不多,搬得很多

第 2 章给过算术强度(arithmetic intensity)的定义:每搬一个字节做了多少次浮点运算。 把 N×N 中间矩阵留在片上不出 HBM,等价于说「让人均算力更高的设备少等数据」。

一句话概括本章要解决的问题:

朴素 attention 的瓶颈不是 O(N²d) 的乘加,而是 O(N²) 的中间结果在 HBM 上来回。

FlashAttention 的贡献是:保持结果完全精确(没有近似),把中间结果的读写留在片上。 它在论文摘要里的原话是「an IO-aware exact attention algorithm that uses tiling to reduce the number of memory reads/writes between GPU high bandwidth memory (HBM) and GPU on-chip SRAM」(FlashAttention: Fast and Memory-Efficient Exact Att…)。

1.4 本章的四级阶梯

本章按 PyTorch tutorial 的方式,一次只改一件事:

级 版本 相比上一版唯一的改变 这一版为什么还不够
① 朴素版 —— S、P 落地到 HBM,4N² 流量
② 分块 + online softmax 中间结果只在片上;softmax 改成单趟递推 搬运与计算没有重叠;Q 反复从 HBM 读
③ 加流水与 shared memory 复用 T.Pipelined + 多缓冲 + 寄存器复用 形状写死、没有覆盖真实模型的变体
④ 对齐官方实现 与 examples/flash_attention/ 逐行对齐 ——

第 4、5 节再把 ④ 扩成「变体矩阵」与「两台机器的瓶颈差异」。


2. 概念与模型

online softmax 的状态递推初始m = −inf,ℓ = 0,O = 0块 1:S₁ = Q·K₁ᵀ·sm₁ = max(m₀, rowmax(S₁))c₁ = exp(m₀ − m₁)ℓ = c₁·ℓ + rowsum(exp(S₁ − m₁))O = c₁·O + exp(S₁ − m₁)·V₁块 2:S₂ = Q·K₂ᵀ·sm₂ = max(m₁, rowmax(S₂))c₂ = exp(m₁ − m₂)ℓ = c₂·ℓ + rowsum(exp(S₂ − m₂))O = c₂·O + exp(S₂ − m₂)·V₂输出 O / ℓ与完整 softmax 完全等价exp(−m) 在分子分母上约掉
每处理一块 key 只维护三个每行状态:运行最大值 m、分母 ℓ、未归一化分子 O。
  • c = exp(m_prev − m_new) 永远落在 (0, 1]:新块更大就等比缩小旧累加器,没有更大就 c = 1
  • 边界:某行所有分数都被 mask 掉时 m_prev = m_new = −∞,c = exp2(NaN) = NaN 会污染 acc_o,需要 check_inf
  • ℓ 和 O 并不单调——但两者被同一个 c 缩放,所以它们的比值始终不变

2.1 记号

固定一个 (batch, head) 和一行 query,令:

  • x_j = q · k_j 是第 j 个 key 的未缩放得分;s = 1/√d 是缩放因子;
  • v_j 是第 j 个 value 向量;
  • m 是任意常数(下面会说明它为什么可以是任意常数)。

标准的 safe softmax 写成:

P_j = exp(s·x_j − m) / Σ_k exp(s·x_k − m)
O   = Σ_j P_j · v_j

2.2 第一步:为什么可以减去任意常数

这是全部推导的基石,只有两行:

exp(s·x_j − m) / Σ_k exp(s·x_k − m)
  = [exp(s·x_j)·exp(−m)] / [Σ_k exp(s·x_k)·exp(−m)]
  = exp(s·x_j) / Σ_k exp(s·x_k)

exp(−m) 在分子分母上各出现一次,约掉了。 所以:

  • 数学上 m 取什么都一样;
  • 工程上 m 只有一个作用——让 exp 的指数不为正,避免上溢。

正因为它是「约掉」的,后面所有看起来复杂的重标度步骤,都只是在维护「当前用的是哪个 m」,而不是在改变结果。这一步想通了,online softmax 就没有魔法了。

2.3 第二步:单趟 softmax 的递推(连续情形先推一遍)

假设我们沿 key 方向分块处理,第 t 块记作 B_t。处理完前 t 块后,我们想 持有三个量,使得:

ℓ⁽ᵗ⁾ = Σ_{j ∈ B₁∪…∪B_t} exp(s·x_j − m⁽ᵗ⁾)          (分母,运行和)
O⁽ᵗ⁾ = Σ_{j ∈ B₁∪…∪B_t} exp(s·x_j − m⁽ᵗ⁾) · v_j     (分子,未归一化)

其中 m⁽ᵗ⁾ = max_{j ∈ B₁∪…∪B_t} (s·x_j) 是到目前为止见过的最大值。

处理新一块 B_{t+1} 时,会出现一个新候选最大值 M = max_{j ∈ B_{t+1}}(s·x_j), 于是新的运行最大值是

m⁽ᵗ⁺¹⁾ = max(m⁽ᵗ⁾, M)

问题来了:ℓ⁽ᵗ⁾ 和 O⁽ᵗ⁾ 是按旧的 m⁽ᵗ⁾ 归一化的,而现在要换成按 m⁽ᵗ⁺¹⁾ 归一化。代进去看:

exp(s·x_j − m⁽ᵗ⁺¹⁾) = exp(s·x_j − m⁽ᵗ⁾) · exp(m⁽ᵗ⁾ − m⁽ᵗ⁺¹⁾)

所以只要把旧的两个量和乘同一个因子 c = exp(m⁽ᵗ⁾ − m⁽ᵗ⁺¹⁾),再补上新块的贡献:

c        = exp(m⁽ᵗ⁾ − m⁽ᵗ⁺¹⁾)                    ← 重标度因子(rescale factor)
ℓ⁽ᵗ⁺¹⁾  = c · ℓ⁽ᵗ⁾  + Σ_{j ∈ B_{t+1}} exp(s·x_j − m⁽ᵗ⁺¹⁾)
O⁽ᵗ⁺¹⁾  = c · O⁽ᵗ⁾  + Σ_{j ∈ B_{t+1}} exp(s·x_j − m⁽ᵗ⁺¹⁾) · v_j

最终输出

O = O⁽T⁾ / ℓ⁽T⁾

这就是 online softmax 的全部推导。 只有两条性质被用到:

  1. 指数可以拆开:exp(a − b)·exp(b − c) = exp(a − c);
  2. 求和可以分段再合并(加法结合律)。

2.4 等价性证明(不是「看起来对」)

命题:按上面递推算出来的 O⁽T⁾/ℓ⁽T⁾,等于先算完整 softmax 再乘 V 的结果 Σ_j P_j v_j。

证明:用数学归纳法先证一个更强的结论——对任意 t,

O⁽ᵗ⁾ = exp(−m⁽ᵗ⁾) · Σ_{j≤t} exp(s·x_j) · v_j        (A)
ℓ⁽ᵗ⁾ = exp(−m⁽ᵗ⁾) · Σ_{j≤t} exp(s·x_j)              (B)

t = 1:O⁽¹⁾ = Σ_{j∈B₁} exp(s·x_j − m⁽¹⁾) v_j,把 exp(−m⁽¹⁾) 提出去就是 (A)。

归纳步:假设 (A)(B) 对 t 成立,讨论 t+1。

O⁽ᵗ⁺¹⁾ = exp(m⁽ᵗ⁾ − m⁽ᵗ⁺¹⁾) · O⁽ᵗ⁾ + Σ_{j∈B_{t+1}} exp(s·x_j − m⁽ᵗ⁺¹⁾) v_j
       = exp(m⁽ᵗ⁾ − m⁽ᵗ⁺¹⁾) · exp(−m⁽ᵗ⁾) · Σ_{j≤t} exp(s·x_j) v_j
         + exp(−m⁽ᵗ⁺¹⁾) · Σ_{j∈B_{t+1}} exp(s·x_j) v_j          ← 用归纳假设
       = exp(−m⁽ᵗ⁺¹⁾) · [ Σ_{j≤t} exp(s·x_j) v_j + Σ_{j∈B_{t+1}} exp(s·x_j) v_j ]
       = exp(−m⁽ᵗ⁺¹⁾) · Σ_{j≤t+1} exp(s·x_j) v_j

(B) 同理。归纳成立。

由 (A)(B),比值

O⁽ᵗ⁾ / ℓ⁽ᵗ⁾ = [Σ_{j≤t} exp(s·x_j) v_j] / [Σ_{j≤t} exp(s·x_j)]

exp(−m⁽ᵗ⁾) 又一次约掉了——所以运行最大值取大取小、什么时候更新,都不影响最终 结果,只影响中间数的动态范围。取 t = T,右边正是 Σ_j P_j v_j。∎

2.5 从「三趟」到「一趟」

把 2.3 节的递推放进 tile 级代码,就得到 FlashAttention 的主循环(NVIDIA 版,非 causal):

for k in 0 … ceil(N/block_N):
    S_blk = Q_blk · K_blkᵀ · s          # 只在片上:fragment
    m_new = max(m_prev, rowmax(S_blk))
    c     = exp(m_prev − m_new)          # 重标度因子
    P_blk = exp(S_blk − m_new)
    ℓ     = c · ℓ + rowsum(P_blk)
    O     = c · O + P_blk · V_blk
O_final = O / ℓ

对比 1.2 节的三趟:

朴素版 FlashAttention
S 是否落地 写 N²、读 N² 只在 fragment 里
P 是否落地 写 N²、读 N² 只在 fragment 里
需要几趟遍历 key 3 趟(max、sum、PV) 1 趟(块内同时更新 max/sum/O)
每个 key 被读几次 每趟一次,共 3 次 每个 Q 块读一次

论文摘要给出的定性结论是「it requires fewer HBM accesses than standard attention, and is optimal for a range of SRAM sizes」 (FlashAttention: Fast and Memory-Efficient Exact Att…)。 本 corpus 只保存了这三篇 FlashAttention 论文的标题与摘要(见 本章引用 的「待补资料」),所以本章不引用摘要之外的论文数字;具体的 4N² + 4Nd 记账是本章 code/01-traffic-model 里的模型,不是论文数据。

2.6 与第 11 章的关系:这里也有「搬运与计算」

flash attention 的循环体是「搬 K、算 QKᵀ、写 S 到 fragment、softmax、搬 V、 算 PV」。只要把 T.copy(K…) 与 T.gemm(Q, K) 放进同一个 T.Pipelined 循环, 第 11 章那套「搬运与计算重叠」就自动生效——这正是下一节 ③ 版要做的事。


3. 用法:四级递进实现

四个版本的完整代码都在 code/ 下,本节只讲「改了什么、为什么」。

attention 的四级递进实现1. ① 朴素版:把 N×N 物化S_shared 是 block_M × seq_kv,seq_kv=8K 时已经 2 MB;算分数与算输出被硬性串起来2. ② 分块 + online softmaxS/P 一次都不出片上,消掉 4N² 流量;但 copy 与 gemm 前后排着,Q 只在块内复用3. ③ 加流水与 shared memory 复用T.Pipelined + T.GemmWarpPolicy.FullRow + acc_s 复用;形状仍写死4. ④ 与官方实现对齐scale 折进 log2(e)、past_len、causal 循环裁剪、越界 mask、O_shared epilogue
每一级只改一件事:② 改「中间结果不出片上」,③ 改「搬运与计算的时间关系」,④ 改「覆盖真实形状」。
  • 片上占用:K_shared / V_shared 各 num_stages 份;block_M=block_N=dim=128、fp16 时每份 32 KB,ns=2 → 160 KB、ns=3 → 224 KB(还含 Q_shared 32 KB)
  • ② 和 ③ 解决的是不同问题:② 省 HBM 流量,③ 省时间轴上的等待
  • ④ 的每一处都能在 code/04 里被数值验证(full / causal / past_len / window+sink / varlen)

3.0 共同骨架

所有版本共享同一段 TileLang 骨架(以 BHSD、block_M × block_N 分块为例):

with T.Kernel(T.ceildiv(seq_q, block_M), heads, batch, threads=threads) as (bx, by, bz):
    Q_shared = T.alloc_shared([block_M, dim], dtype)
    K_shared = T.alloc_shared([block_N, dim], dtype)
    V_shared = T.alloc_shared([block_N, dim], dtype)
    acc_s    = T.alloc_fragment([block_M, block_N], accum_dtype)   # 分数
    acc_o    = T.alloc_fragment([block_M, dim], accum_dtype)       # 输出累加器

三个维度的含义与官方例子一致:bx 扫 query 方向、by 是 head、bz 是 batch。 这是 T.Kernel 的网格语义(第 8 章):一个 (bx, by, bz) 就是一个线程块, 块内所有 tile 都在片上。

3.1 ① 朴素版:把 N×N 物化出来

最直白的写法是「一个线程块负责一个 query 行块,把整行分数都算出来,softmax,再乘 V」:

with T.Kernel(T.ceildiv(seq_q, block_M), heads, batch, threads=threads) as (bx, by, bz):
    Q_shared = T.alloc_shared([block_M, dim], dtype)
    K_shared = T.alloc_shared([seq_kv, dim], dtype)      # ← 整个 K
    V_shared = T.alloc_shared([seq_kv, dim], dtype)      # ← 整个 V
    S_shared = T.alloc_shared([block_M, seq_kv], dtype)  # ← 整个 S
    P_local  = T.alloc_fragment([block_M, seq_kv], accum_dtype)
    acc_o    = T.alloc_fragment([block_M, dim], accum_dtype)
    ...

为什么不够。 三个理由,按严重程度排在下面:

  1. 放不下。 S_shared 是 block_M × seq_kv 的 fp16。seq_kv = 8192、block_M = 128 就是 128×8192×2 B = 2 MB,已经超过一块 GPU 的 shared memory 预算;seq_kv = 32K 直接不可能。
  2. 就算放得下也不划算。 中间结果在片上停留的时间再长,它也是「用完全部 key 才能 开始算输出」,一次只服务一个 Q 块。
  3. softmax 需要整行。 朴素版为了取行最大值必须先把整行算完,于是「算分数」与 「算输出」被硬性串起来。

code/04-block-flash-attention/block_flash_attention.py 里的 exact_masked_attention() 就是这个版本(直接构造完整的 [Sq, Skv] 分数矩阵,再整行 softmax),它用 numpy 在 CPU 上跑。它的存在价值是当参考实现——3.2 节的每一处改动,都以「结果跟它逐元素 相等」为判据。

3.2 ② 分块 + online softmax:把三趟折成一趟

改了什么:

  • 循环沿 key 方向分块,K_shared / V_shared 只装一个 block_N;
  • S 不再物化,只保留一个 block_M × block_N 的 fragment acc_s;
  • 引入三个「每行一个数」的状态:scores_max(运行最大值 m)、scores_sum(运行 分母 ℓ)、acc_o(未归一化分子 O);
  • 循环末尾统一 acc_o /= logsum。
for k in T.Pipelined(loop_range, num_stages=num_stages):
    T.copy(K[bz, by, k * block_N:(k + 1) * block_N, :], K_shared)
    T.clear(acc_s)                       # causal 时换成写 -∞ / 0 的 mask,见 3.4 节
    T.gemm(Q_shared, K_shared, acc_s, transpose_B=True, policy=T.GemmWarpPolicy.FullRow)

    T.copy(scores_max, scores_max_prev)          # 保存旧 m
    T.fill(scores_max, -T.infinity(accum_dtype)) # 清成 −∞ 再 reduce
    T.reduce_max(acc_s, scores_max, dim=1, clear=False)
    for i in T.Parallel(block_M):
        scores_max[i] = T.max(scores_max[i], scores_max_prev[i])   # m_new = max(m_prev, M)
    for i in T.Parallel(block_M):
        scores_scale[i] = T.exp2(scores_max_prev[i] * scale - scores_max[i] * scale)
    for i, j in T.Parallel(block_M, block_N):
        acc_s[i, j] = T.exp2(acc_s[i, j] * scale - scores_max[i] * scale)
    T.reduce_sum(acc_s, scores_sum, dim=1)
    for i in T.Parallel(block_M):
        logsum[i] = logsum[i] * scores_scale[i] + scores_sum[i]     # ℓ = c·ℓ + ℓ_blk
    T.copy(acc_s, acc_s_cast)
    for i, j in T.Parallel(block_M, dim):
        acc_o[i, j] *= scores_scale[i]                              # O = c·O
    T.copy(V[bz, by, k * block_N:(k + 1) * block_N, :], V_shared)
    T.gemm(acc_s_cast, V_shared, acc_o, policy=T.GemmWarpPolicy.FullRow)

这段代码与 2.3 节的公式是逐项对应的:

公式 代码
m⁽ᵗ⁺¹⁾ = max(m⁽ᵗ⁾, M) scores_max[i] = T.max(scores_max[i], scores_max_prev[i])
c = exp(m⁽ᵗ⁾ − m⁽ᵗ⁺¹⁾) scores_scale[i] = T.exp2(m_prev·scale − m_new·scale)
ℓ⁽ᵗ⁺¹⁾ = c·ℓ⁽ᵗ⁾ + ℓ_blk logsum[i] = logsum[i]*scores_scale[i] + scores_sum[i]
O⁽ᵗ⁺¹⁾ = c·O⁽ᵗ⁾ + P_blk·V_blk acc_o[i,j] *= scores_scale[i] 之后 T.gemm(acc_s_cast, V_shared, acc_o)
O / ℓ 循环后的 acc_o[i, j] /= logsum[i]

三个实现细节值得单独点出来:

  • 为什么用 exp2 而不是 exp。 官方例子的 scale 里乘了 log2(e): scale = (1.0 / dim) ** 0.5 * 1.44269504 (_vendor/tilelang-v0.1.15/examples/flash_attention/example_mha_fwd_bhsd.py:24)。 这样 exp(x) 变成 exp2(x·log₂e),硬件上 exp2 与乘加可以合成一条指令。
  • 为什么 T.fill(scores_max, -inf) 之后还要 clear=False。 T.reduce_max 的 clear=True 本来就会先把输出初始化成 -inf (_vendor/tilelang-v0.1.15/tilelang/language/reduce_op.py:176-204); 官方写成「显式 fill + clear=False」是同一个语义的另一种写法,读代码时不要以为 clear=False 在这里意味着「累加到旧值上」。
  • 为什么缩放只施在 exp 的指数上,而不缩放 scores_max 本身。 因为 x ↦ s·x 单调且 s > 0,max(s·x) = s·max(x),所以 reduce_max 拿到的未缩放 最大值乘以 scale 就是缩放后的最大值。这也是官方源码把 scale 放在 exp2 里、 而不放在 T.gemm 之后的原因。

为什么还不够。 这一版已经把 4N² 的流量干掉了,但留下了两件事:

  1. 搬运与计算没有重叠。 T.copy(K…) 与 T.gemm(Q, K…, acc_s) 在同一个循环体里 前后排着,num_stages=1 时它们是串行的(第 11 章 1.1 节)。
  2. Q 被反复读。 每个 Q 块只在块内复用;K、V 每个 Q 块都要重读一遍。 这不是 bug,而是「用带宽换片上空间」的必然结果。

3.3 ③ 加流水与片上缓冲复用(shared memory / register)

改了什么,只有三处:

  1. k 循环换成带 num_stages 的 T.Pipelined,让第 k+1 块的 K、V 搬运 与第 k 块的 QKᵀ、PV 重叠(第 11 章的机制)。
  2. policy=T.GemmWarpPolicy.FullRow:让每个 warp 负责 acc_s 的完整若干行。原因 见 4.2 节——reduce_max / reduce_sum 是行内归约,FullRow 让归约尽量落在 warp 内部。
  3. acc_s 复用:softmax 后的概率直接写回 acc_s,再用 T.copy(acc_s, acc_s_cast) 做一次 fp32 → fp16 转换给第二个 T.gemm。fp16 的 P 不能直接原地改, 因为 T.gemm 的 A 操作数要求 P 是低精度。
for k in T.Pipelined(loop_range, num_stages=num_stages):   # ← 改动 1
    T.copy(K[...], K_shared)
    ...
    T.gemm(Q_shared, K_shared, acc_s, transpose_B=True,
           policy=T.GemmWarpPolicy.FullRow)                # ← 改动 2
    ...
    T.copy(acc_s, acc_s_cast)                              # ← 改动 3
    T.copy(V[...], V_shared)
    T.gemm(acc_s_cast, V_shared, acc_o, policy=T.GemmWarpPolicy.FullRow)

片上占用要自己算。 加了 num_stages 之后,K_shared、V_shared 各变成 num_stages 份。以 block_M=block_N=128, dim=128, fp16 为例:

缓冲 单份大小 份数 合计
K_shared 128×128×2 B = 32 KB num_stages 32·ns KB
V_shared 32 KB num_stages 32·ns KB
Q_shared 128×128×2 B = 32 KB 1 32 KB

num_stages=2 就已经是 160 KB;num_stages=3 是 224 KB。这就是官方例子里 block_M=128, block_N=128, num_stages=2 作为回归配置的原因 (_vendor/tilelang-v0.1.15/examples/flash_attention/example_mha_fwd_bhsd.py:167)。 编译器不会替你把这个账算小:超了就是编译失败或启动失败,不是变慢。

为什么还不够。 这一版已经是一个可用的 flash attention 了,但它把形状写死了: 没有 causal 的循环裁剪、没有 past_len(KV cache 的前缀)、没有 varlen、没有 GQA。 下一节把它和官方实现对齐,就会看到这些「真实模型需要的东西」分别落在哪几行。

3.4 ④ 与官方 flash_attention 对齐

官方入口是 _vendor/tilelang-v0.1.15/examples/flash_attention/example_mha_fwd_bhsd.py (另有 example_mha_fwd_bshd.py / example_gqa_fwd_bshd.py / example_mha_fwd_varlen.py 等布局与变体)。把我们的 ③ 版和它逐行对齐,差别只有下面五组:

(1)scale 折进 log2(e),且把缩放放在 exp2 里。

scale = (1.0 / dim) ** 0.5 * 1.44269504  # log2(e)      # 第 24 行
...
acc_s[i, j] = T.exp2(acc_s[i, j] * scale - scores_max[i] * scale)   # 第 85 行

(2)支持 seq_kv ≥ seq_q(KV cache 场景),用一个 past_len 把因果对角线右移。

past_len = seq_kv - seq_q                              # 第 30 行
q_idx = bx * block_M + i + past_len                    # 第 69 行
k_idx = k * block_N + j                                # 第 70 行
acc_s[i, j] = T.if_then_else(q_idx >= k_idx, 0, -T.infinity(acc_s.dtype))

(3)causal 的循环裁剪:不是「扫完再 mask」,而是根本不去扫。

loop_range = (
    T.min(T.ceildiv(seq_kv, block_N), T.ceildiv((bx + 1) * block_M + past_len, block_N))
    if is_causal else T.ceildiv(seq_kv, block_N)
)                                                      # 第 59–63 行

(bx+1)*block_M + past_len 是这一块 query 里最大的 query 下标;比它大的 key 不可能被看到(causal 是 q ≥ k),所以直接不迭代。这一行把 causal 的算术量砍掉一半, 也把「mask 只在下三角附近出现」这件事变成了循环上界。

(4)非 causal 时也有一个边界 mask。

acc_s[i, j] = T.if_then_else(k * block_N + j >= seq_kv, -T.infinity(acc_s.dtype), 0)

(_vendor/tilelang-v0.1.15/examples/flash_attention/example_mha_fwd_bhsd.py:73-74)

seq_kv 不是 block_N 的整数倍时,最后一块 K 的尾部会越界。T.copy 会自动补零 (第 9 章 4.2 节),但补出来的 0 是真实得分 0,不是「该被忽略」,所以必须显式 把这些位置的得分设成 -∞。

顺带一提:causal 那一支不需要这个额外的边界 mask。因为 q_idx ≤ seq_q − 1 + past_len = seq_kv − 1 < k_idx,越界的 key 天然被 q_idx >= k_idx 判成不可见。 两分支的 mask 条件不一致,是读官方代码时最容易看漏的一处。

(5)epilogue 走一次 O_shared 再写回全局。

for i, j in T.Parallel(block_M, dim):
    acc_o[i, j] /= logsum[i]                            # 第 97–98 行
T.copy(acc_o, O_shared)                                 # 第 99 行
T.copy(O_shared, Output[bz, by, bx * block_M:(bx + 1) * block_M, :])   # 第 100 行

acc_o 是 fragment(寄存器布局,可能带 padding / swizzle),直接写全局内存会产生 非合并访问;先落到 shared memory 再按连续地址写出,是标准的 epilogue 写法。

3.5 四个版本放在一起

① 朴素 ② 分块 online ③ + 流水/复用 ④ 官方
S、P 落地 HBM 是 否 否 否
遍历 key 的趟数 3 1 1 1
搬运/计算重叠 否 否 是(T.Pipelined) 是
归约的 warp 划分 未指定 默认 Square FullRow FullRow
causal 无 无 无 循环裁剪 + mask
seq_kv > seq_q 无 无 无 past_len
epilogue 直接写 直接写 直接写 经 O_shared

4. 实现机制

attention 变体之间的依赖关系基础主循环(BHSD)Q,K,V → Ocausal:循环上界 + q ≥ ksliding window起点 + 上界 + check_infattention sink分母加一项 exp2(sink − m·scale)varlen:cu_seqlens + guardGQA / MQAkv_head_idx = head_idx // groupsGQA + varlen官方 example_gqa_fwd_varlen.pydecode:split-KV + combine两个 kernelpaged KVblock_table 间接寻址稀疏 / 索引类索引张量替代连续区间
变体不是重写 kernel,而是在主循环上改一处——这张图画出它们各自的来路。
  • causal = 循环裁剪 + q_idx ≥ k_idx mask;window 再加循环起点与 check_inf;sink 只在分母上加一项
  • decode 是唯一改变 kernel 数量的变体(split + combine 两个 kernel),因为瓶颈是并行度而不是流量
  • 稀疏 / 索引类是唯一改变「KV 从哪来」的变体,也是第 16 章的起点
昇腾上 attention 的 Cube / Vector 数据通路1. Cube:Q·Kᵀ → L0C分数 S_tile 落在 L0C2. ① L0C → GM(workspace_1)合法搬运对里没有 L0C → UB3. ② GM → UBVector:reduce_max / exp / reduce_sum,得到 P 与 ℓ4. ③ UB → GM(workspace_2)概率 P 转 fp16 写出5. ④ GM → L1 → L0A/L0B;⑤ P·V → L0CCube 再算一次矩阵乘6. ⑥ GM → UB:O += PV除以 ℓ 后写回 O
QKᵀ 落在 L0C,softmax 只能由 Vector 在 UB 上做;两者之间没有直接通道,必须绕一趟 GM。
  • 合法搬运对:GM→L1、GM→UB、UB→L1、UB→UB、L1→L0A、L1→L0B、L0C→GM、UB→GM;L0C 只有一条出边 L0C→GM
  • workspace 不是调试缓冲,而是数据通路的一环;适配器例子显式传 workspace_1/2/3 并配 set/wait_cross_flag
  • 两台机器的瓶颈不同:NV 是异步搬运的 overlap 与非矩阵指令占比;昇腾是 Cube 与 Vector 的核间流水与同步次数

4.1 一件事交给谁

想做的事 前端写的 谁实现
分数只在片上 T.alloc_fragment + T.gemm layout inference 给 fragment 定布局(第 21 章)
K/V 分块搬运 T.copy(global → shared) copy lowering 选指令:SIMT / cp.async / TMA(第 9 章 4.1)
搬运与计算重叠 T.Pipelined(..., num_stages) PipelinePlanning + InjectSoftwarePipeline(第 11 章 4.2/4.3)
行内归约 T.reduce_max / T.reduce_sum cross-thread allreduce,≥32 线程走 shared、<32 走 shuffle(第 9 章 4.4)
概率转 fp16 给第二个 GEMM T.copy(acc_s, acc_s_cast) 元素级 cast + layout 转换
越界 key 的 mask 显式 T.if_then_else 前端;编译器不会替你判断「补零 = 有效得分」

4.2 为什么是 FullRow

GemmWarpPolicy 有三个取值(_vendor/tilelang-v0.1.15/tilelang/tileop/base.py:5-12): Square(默认,按接近正方形的比例切)、FullRow(所有 warp 分到行方向)、 FullCol(所有 warp 分到列方向)。

对 attention 来说,后续的 reduce_max(acc_s, ..., dim=1) 与 reduce_sum(..., dim=1) 都是沿 block_N 的行内归约。用 FullRow 让每个 warp 负责整行,行内归约主要在 warp 内部完成,跨 warp 的 shared memory 归约最少。这是 T.gemm 的 policy 参数 第一次真正发挥作用的地方——GEMM 的 warp 划分不是性能细节,而是被后续算子绑定的 布局约束。第 14 章讲 GEMM 家族时会看到同一个参数在纯 GEMM 里的含义。

4.3 流水在 attention 上到底重叠了什么

第 11 章的机制原样适用:T.Pipelined 把循环体按数据依赖打上 stage/order 注解, 算出每个 buffer 需要的份数(use - def + 1),再重排成 prologue / body / epilogue。 在 attention 里,被重叠的是:

  • K_shared 的搬运 与 上一块的 QKᵀ + softmax;
  • V_shared 的搬运 与 上一块的 softmax + PV;
  • 第二个 T.gemm 与下一块的第一个 T.gemm(不同硬件单元)。

注意 acc_s 不能被流水:它是 T.gemm 的累加器,又同时被 softmax 读写,具有 跨语句的读写依赖。官方例子里 acc_s 只有一份,K_shared/V_shared 才是多份的。

4.4 Blackwell(sm100)变体:T.alloc_tmem 与 T.tcgen05_gemm

examples/flash_attention_sm100/ 的注释写得很直接:

Blackwell (SM100) Flash Attention Forward using TCGEN05MMA with TMEM accumulators. Replaces the Hopper WGMMA-based Flash Attention for Blackwell GPUs. —— _vendor/tilelang-v0.1.15/examples/flash_attention_sm100/mha_fwd_bshd.py:1-8

与 Hopper 版的差别集中在分配与指令:

S_tmem = T.alloc_tmem([block_M, block_N], accum_dtype)   # 分数落在 TMEM
D_tmem = T.alloc_tmem([block_M, dim], accum_dtype)       # 输出累加器落在 TMEM
mbar_s = T.alloc_barrier(1)
...
T.tcgen05_gemm(...)                                       # 显式异步 MMA

T.alloc_tmem 的 docstring 说明了这块内存的约束:按列分配、列数必须是 2 的幂且 ≥32、只能被 TCGEN5.MMA 与特定的 TMEM load/store 指令访问 (_vendor/tilelang-v0.1.15/tilelang/language/allocate.py:254-270)。 T.gemm 的 docstring 则说清了它和手工异步接口的关系:

For manual asynchronous scheduling, use T.wgmma_gemm(...) with T.wait_wgmma(...) on Hopper, or T.tcgen05_gemm(...) with T.mbarrier_wait_parity(...) on Blackwell. —— _vendor/tilelang-v0.1.15/tilelang/language/gemm_op.py:177-179

(sm100 的 variant="ss"/"ts"、warp specialization 等细节属于第 24 章的题目, 本章只把它当作「同一套 tile 抽象在不同硬件上换了指令」的证据。)

4.5 昇腾:为什么 QKᵀ 的结果必须绕一趟

昇腾 AI Core 把「矩阵计算」和「向量计算」放在不同的核上(Cube / Vector),中间隔着 几级专用存储。锁定版本的编程指南有一张搬运关系表 (_vendor/tilelang-ascend-v0.1.2.000/docs/TileLang-Ascend Programming Guide.md:627-636):

src dst 说明
GM L1 全局 → L1
L1 L0A / L0B L1 → L0A / L0B(Cube 的左/右矩阵)
L0C GM L0C → 全局
GM UB 全局 → Unified Buffer
UB L1 Unified Buffer → L1

注意这张表里没有 L0C → UB。 而 flash attention 的主循环恰恰要求「把 Cube 算出的 分数交给 Vector 做 softmax,再把概率交回 Cube 做 PV」。于是昇腾版必须绕:

Cube:  Q·Kᵀ → L0C → (GM workspace) ─┐
                                     ├─→ Vector: softmax / rescale
Vector: ─────────────────────────────┘  → (GM workspace) → L1 → L0 → Cube: P·V

锁定版本的适配器例子把这个绕行写成了显式参数:flash_attention_fwd 的签名里有 workspace_1 / workspace_2 / workspace_3 三个全局缓冲,分别是「分数」「概率」「部分输出」 的落脚点(_vendor/tilelang-ascend-v0.1.2.000/examples/flash_attention/flash_attn_bhsd.py:26-34), Cube 侧用 T.set_cross_flag("FIX", 0) 通知 Vector、Vector 侧 T.wait_cross_flag(0) 取数(同文件 :98-100 与 :130-131)。

主干仓库里的昇腾实现把这件事做得更进一步:编程指南 4.1.5.2 节说明编译器有一个 Workspace 消除(workspace reduction) pass,会把 T.copy(acc_s_l0c, acc_s_ub) 这种「跨核传递」自动改写成 copy_l0c_to_gm(写 workspace)+ copy_gm_to_ub(读 workspace)(_vendor/tilelang-ascend-v0.1.2.000/docs/TileLang-Ascend Programming Guide.md:1345-1390)。 实现就在 _vendor/tilelang-ascend-v0.1.2.000/src/transform/ascend_workspace_reduction.cc:999-1004: 那里有一张显式的改写表,copy_l0c_to_ub 被改写成 copy_l0c_to_gm(同文件 :1455-1481 是 pass 的注册入口)。 也就是说:同一句 T.copy,在 NVIDIA 上是 shared ↔ register,在昇腾上可能变成 「L0C → GM → UB」两跳。 这是第 25 章要展开的事情,但写 attention kernel 时必须 先知道它存在。

4.6 变体矩阵在代码里的落点

下表是本章承诺的「七个变体 → 官方文件」映射。全部路径都在锁定版本里核对过。

变体 锁定版本里的实现 关键改动
基础 MHA(BHSD) examples/flash_attention/example_mha_fwd_bhsd.py 3.4 节的五处
基础 MHA(BSHD) examples/flash_attention/example_mha_fwd_bshd.py 索引顺序改为 [b, s, h, d]
causal 同上,is_causal=True 循环裁剪 + q_idx >= k_idx mask
GQA / MQA examples/flash_attention/example_gqa_fwd_bshd.py(固定长度)、example_gqa_fwd_varlen.py(varlen) kv_head_idx = head_idx // groups,KV 头数 = heads // groups
varlen(padding 打包) examples/flash_attention/example_mha_fwd_varlen.py、example_gqa_fwd_varlen.py cu_seqlens_q / cu_seqlens_k + max_seqlen_q + 越界 guard
decode / split-KV examples/flash_decoding/example_mha_inference.py、example_gqa_decode.py 两个 kernel:split 与 combine,中间存 Output_partial + glse
paged KV _vendor/tilelang-ascend-v0.1.2.000/examples/flash_attention/paged_flash_attn_bhsd.py block_table[b, k] 间接寻址 KV 页
sliding window examples/attention_sink/example_mha_sink_fwd_bhsd.py 的 window_size 循环起点 start + 上界裁剪 + check_inf
attention sink 同上,Sinks 输入 epilogue 里 logsum += exp2(sink − m·scale)
block-causal(噪声/干净分区) examples/block_causal_attention/block_causal_attention.py 逐 tile 决定是否 mask、是否只走对角线

关于 BHSD 与 BSHD。 二者只是索引顺序不同:BHSD 下 T.copy(Q[bz, by, bx*block_M :(bx+1)*block_M, :], Q_shared) 是「batch、head 固定,搬一段 sequence」;BSHD 下对应 的是 T.copy(Q[bz, bx*block_M:(bx+1)*block_M, by, :], Q_shared)。搬的那一维在最后 意味着地址连续,这直接决定 T.copy 能不能走 TMA(第 9 章 4.1)。真实推理框架里 KV cache 常按 [batch, seq, head, dim] 存储,所以 BSHD 变体不是「重复劳动」。

4.7 昇腾侧同样有七个变体

_vendor/tilelang-ascend-v0.1.2.000/examples/ 下与 attention 相关的目录:

变体 文件 与 NV 版的差别
MHA forward(Cube/Vector 分离) flash_attention/flash_attn_bhsd.py 显式 T.Scope("C") / T.Scope("V") + 三个 workspace
paged KV flash_attention/paged_flash_attn_bhsd.py 与上同构,KV 用 block_table 间接寻址
sink(+ sliding window) mha_sink_fwd_bhsd/mha_sink_fwd_bhsd.py sink 用 mask-after-exp + 双缓冲;注释详细写了 m* = max(sink, m_final) 的原因
GQA varlen gqa_fwd_varlen/gqa_fwd_varlen.py 用 T.mma + L0A/L0B/L0C 双缓冲替代 T.gemm_v0,外部传入 Mask
稀疏索引(DSA/lightning indexer) lightning_indexer/example_lightning_indexer.py Cube 算 Q·Kᵀ,Vector 做加权 reduce + T.tile.topk
稀疏 flash attention sparse_flash_attention/ 按 top-k 索引 gather KV
训练/推理端到端 deepseek_v4/、xllm_kernels/ 面向具体模型的算子组合

mha_sink_fwd_bhsd.py 里有一段注释非常值得读,它把本章 5.1 节的数值问题讲清楚了:

Why m* (not m_i_final): the kernel uses mask-after-exp, so m_i_final may come from tail blocks (which contribute 0 via mask-after-exp). … When sinks > m_i_final, exp(sinks − m_i_final) overflows / dominates sumexp → output collapses to 0. —— _vendor/tilelang-ascend-v0.1.2.000/examples/mha_sink_fwd_bhsd/mha_sink_fwd_bhsd.py:565-577

4.8 三个「只为省带宽」的变体

4.6 节的表把七个变体并列了,但它们背后的动机并不一样。有三个变体完全不改变 单个 (query head, KV head) 对的数学,存在的唯一理由是省带宽或换并行度: GQA/MQA、paged KV、split-KV。它们最容易在设计评审里被问到,值得单独说清楚。

(1)MQA / GQA:让 KV cache 变小。

MHA 里每个 query head 都有自己的 K、V。MQA(multi-query attention)让所有 query head 共用一份 K/V;GQA(grouped-query attention)折中:把 heads 个 query head 分成 groups 组,每组共用一份 K/V。于是:

MHA:  kv_heads = heads          (每个 query head 一份 KV)
GQA:  kv_heads = heads / groups
MQA:  kv_heads = 1              (groups = heads)

对 kernel 来说改动只有两处(官方实现见 _vendor/tilelang-v0.1.15/examples/flash_attention/example_gqa_fwd_varlen.py:19-24,50-52):

head_kv = heads // groups          # KV 头数
kv_head_idx = head_idx // groups   # 本 query head 该读哪一份 KV

收益在访存量上:KV cache 的读取量正比于 KV 头数,GQA 把它降到 1/groups。 这对 decode 尤其关键——decode 阶段几乎没有计算、全是读 KV cache, KV cache 的字节数就是延迟。这也解释了为什么现在的开源模型普遍用 GQA: 不是因为它难写,而是因为它把推理时的带宽需求按 groups 倍砍掉了。

(2)paged KV:让 KV cache 能动态增长。

推理服务里每个请求的上下文长度不同、还会随时增长(continuous batching)。预分配 「最大长度 × batch」的连续 KV cache 会浪费大量显存,于是把 cache 切成固定大小的 页(page / block),用一张表记录「逻辑块 → 物理页」的映射。KV 的地址不再由算术 算出,而是查表:

bidx = block_table[bz, k]           # 逻辑第 k 块 → 物理页号
T.copy(K_cache[bidx, ...], K_shared)

(_vendor/tilelang-ascend-v0.1.2.000/examples/flash_attention/paged_flash_attn_bhsd.py:94; block_table 是 [batch, table_blocks] 的整数输入,见同文件 :50-58。)

对 kernel 的影响有三点:① 地址空间里多了一次索引加载;② T.copy 的源地址变成 「查表 + 偏移」,TMA 那种静态描述符不再直接适用(第 9 章 4.1 节说过 TMA 需要 编译期可描述的地址模式);③ 页与页在物理内存里不连续,跨页时要么拆成多次 copy、 要么放弃合并访问。「paged 不改变 attention 的数学,只改变地址来源」——这句话 要记住。

(3)split-KV:换并行度,不是省带宽。

decode 的算术强度极低:一行 query 要读完全部 K/V。按 1.2 节的记账,不管怎么切, K/V 反正都要读一遍,所以 split-KV 在带宽上没有任何收益(code/01-traffic-model/ 的第 3 段模型 也验证了:Sq = 1 时两种实现的流量比是 1x)。它买到的是并行度:把 KV 切成 num_split 段之后,可用的线程块数从 ceil(Sq/block_m) × heads × batch 变成它的 num_split 倍,长 context 下才够填满机器。

代价有两个,而且都必须付:① 多一个 combine kernel(多一趟读写部分结果的带宽); ② 数值上多一次归约——这正是 5.4 节要展开的坑。

把三个变体放在一起,可以总结成一句话:

GQA 减少要读的字节数;paged KV 改变字节在哪里;split-KV 让更多的核同时去读。 三者都不改变 attention 的数学定义,也都需要 kernel 作者显式处理——这就是「变体矩阵」 存在的意义。


5. 进阶与陷阱

5.1 数值稳定性:m 到底在保护什么

回到 2.3 节:c = exp(m_prev − m_new)。因为 m_new = max(m_prev, M) ≥ m_prev, 所以这个差永远 ≤ 0,c ∈ (0, 1]:

  • 如果新块里出现了更大的分数,m_new > m_prev,c < 1,旧的累加器被等比缩小;
  • 如果新块没有更大的分数,m_new = m_prev,c = 1,什么也不用做。

这就是 max 的全部作用:保证 exp 的指数永远不为正,因此永远不会上溢。

顺带说清一个容易被想当然的性质:ℓ 和 O 并不单调。 只要这一行至少有一个可见 key,取到运行最大值的那个 key 就贡献 exp(0) = 1,所以 ℓ ≥ 1、最后的除法安全; 但运行最大值一旦变大,c < 1 会把旧的 ℓ、O 一起按比例缩小,并且缩的是同一个 比例——所以它们的比值始终不变(这正是 2.4 节证明的东西)。把「ℓ 单增」 当成前提去写代码,就会在「新块带来一个远大于历史的分数」时判断错。

两个必须知道的边界:

(1)−∞ − (−∞) = NaN。 当一个 query 行在前若干块里一个可见 key 都没有 (sliding window 的早期块、或 causal 且 seq_q > seq_kv 的行), m_prev = m_new = −∞,于是 exp2(−∞ − (−∞)) = exp2(NaN) = NaN。 官方 sink/sliding-window 例子用 check_inf 修掉了它:

# NOTE(wt): check_inf is necessary for sliding window attention.
for i in T.Parallel(block_M):
    if window_size is not None:
        scores_max[i] = T.if_then_else(scores_max[i] == -T.infinity(accum_dtype), 0, scores_max[i])
    scores_scale[i] = T.exp2(scores_max_prev[i] * scale - scores_max[i] * scale)

(_vendor/tilelang-v0.1.15/examples/attention_sink/example_mha_sink_fwd_bhsd.py:103-109; 源码注释里把这一过程称作 FlashAttention-3 里的 Check_inf,但本 corpus 只保存了 FA3 论文的摘要,见 本章引用 的「待补资料」。)

(2)check_inf 只解决 NaN,不解决「整行都看不见」。 如果一个 query 行到最后 仍然没有任何可见 key,那么 ℓ = 0,O/ℓ 是 0/0。官方 varlen 例子显式处理了这 一情形:

# When sq > skv, some tokens can see nothing
acc_o[i, j] = 0 if is_causal and bx * block_M + i + offset < 0 else acc_o[i, j] / logsum[i]

(_vendor/tilelang-v0.1.15/examples/flash_attention/example_gqa_fwd_varlen.py:117-119)

(3)mask 用什么值,是一个真实的实现选择。 固定形状的例子用 -T.infinity(acc_s.dtype);varlen 例子用的是 -1e9 (example_gqa_fwd_varlen.py:84)。在 v0.1.15 里 T.infinity 落到 IEEE +∞ (_vendor/tilelang-v0.1.15/src/op/math.cc:80-108),而不是 dtype 的最大有限值。 这与「exp2(−∞ − m) = 0」是自洽的;换成 -1e9 则依赖 1e9 足够大到让结果下溢为 0。 读别人的 kernel 时,先看清它用的是哪一个。

5.2 causal mask 的边界

causal 的三处边界,任何一处写错都会得到「看起来对、只在某些形状错」的结果:

  1. 对角线是 q ≥ k,不是 q > k。 官方是 T.if_then_else(q_idx >= k_idx, 0, -inf)(example_mha_fwd_bhsd.py:71)。
  2. 有 past_len 时,q 的下标要加偏移:q_idx = bx*block_M + i + past_len (example_mha_fwd_bhsd.py:69)。否则 KV cache 场景下整条对角线会错位。
  3. k 的循环上界要用「本块 query 里最大的下标」: T.ceildiv((bx + 1) * block_M + past_len, block_N) (example_mha_fwd_bhsd.py:59-63)。用 T.ceildiv(seq_kv, block_N) 不会错, 但会白白算一半的块,而且多算的块必须被 mask 掉。

window 的边界另加一条:窗口是 q − window_size < k ≤ q,对应的判断在 example_mha_sink_fwd_bhsd.py:91-92;循环起点也要跟着裁: start = T.max(0, (bx*block_M + past_len − window_size) // block_N) (同文件 :84)。

5.3 varlen 的打包

varlen(variable length)解决的问题是:一个 batch 里序列长度不同,padding 到同一个 长度会浪费算术。做法是把整个 batch 的 token 拼成一条扁平序列,再用前缀和数组 cu_seqlens 记录每个样本的起止:

Q_unpad: [UQ, heads, dim]        # UQ = Σ_b seqlen_q[b]
cu_seqlens_q: [batch_size + 1]   # int32,cumsum
max_seqlen_q: int32              # 编译期常量,决定 grid 大小

四个容易踩的点:

  1. max_seqlen_q 是编译期常量。 T.Kernel(T.ceildiv(max_seqlen_q, block_M), ...) 要求它在 kernel 编译时就确定,所以不同 batch 的最大长度会触发重编译(第 7 章的 JIT 缓存)。
  2. grid 大小按 max_seqlen 开,短样本要靠 guard 跳过。 官方 varlen 例子的写回 有 if bx * block_M + i < q_current_seqlen(example_gqa_fwd_varlen.py:123)。
  3. causal 对齐方式是「右对齐」:offset = kv_current_seqlen − q_current_seqlen (example_gqa_fwd_varlen.py:68),注释写明 # always align on the right。 这是「增量解码 + 前缀 KV」的语义:最后一个 query token 与最后一个 key 对齐。
  4. cu_seqlens 是 int32,不是索引张量。它每次都从全局内存读一次,属于 「每个 block 读 4 个数」的极小开销。

5.4 split-KV 的归约顺序

decode 阶段只有一个 query(seq_q = 1),KV 却有几十万。此时 Q 块数量太少, GPU 根本喂不饱;Flash-Decoding 的做法是把 KV 方向切成 num_split 段,每段独立算 一份部分结果,再用第二个 kernel 合并。

官方实现在 _vendor/tilelang-v0.1.15/examples/flash_decoding/example_mha_inference.py:

split 段:每个 sid ∈ [0, num_split) 只扫 [sid·(seqlen_kv//num_split), (sid+1)·(seqlen_kv//num_split)),输出两样东西—— 归一化后的部分输出 Output_partial[…, sid, :],以及这一段的 log-sum-exp:

for i in T.Parallel(block_M):
    logsum[i] = T.log2(logsum[i]) + scores_max[i] * scale
T.copy(logsum, glse[bid, hid, sid, mid * block_M:(mid + 1) * block_M])

(同文件 :109-111)

combine 段(T.Kernel,同文件 :115-152):

T.reduce_max(lse_local, lse_max_local, dim=0, clear=True)     # 各 split 的 lse 最大值
for k in T.Pipelined(num_split):
    lse_logsum_local[i] += T.exp2(lse_local_split[i] - lse_max_local[i])
lse_logsum_local[i] = T.log2(lse_logsum_local[i]) + lse_max_local[i]   # logsumexp(lse)
...
scale_local[i] = T.exp2(lse_local_split[i] - lse_logsum_local[i])      # 权重
o_accum_local[i, j] += po_local[i, j] * scale_local[i]

为什么必须这样合并? 因为每一段输出已经各自除过自己的 ℓ:

O_s = (1/ℓ_s) Σ_{j∈段 s} exp(s·x_j − m_s) v_j

而真实的输出是

O = Σ_j P_j v_j = ( Σ_s ℓ_s O_s ) / ( Σ_s ℓ_s )

所以权重必须是 ℓ_s / Σ_{s'} ℓ_{s'},而 ℓ_s = 2^(lse_s)(代码里的 LSE 是 log2(ℓ) 的形式;注意固定形状的例子里 scale 已经折进 log2(e))。代码用 exp2 与 log2 的配对实现这个比值,并在求权重前先减一次最大值——这是 online softmax 的 同一个技巧用在了 split 维度上。

这一步把「数值稳定性」和「归约顺序」绑在了一起:

  • 数学上,Σ_s ℓ_s O_s / Σ_s ℓ_s 与求和顺序无关;
  • 浮点上,exp2(lse_s − max) 之后的相加顺序会改变舍入,而且如果 ℓ_s 用低精度 存(例如 fp16),大 context 下各段权重会明显失真。

昇腾的 backward 例子给了一个真实的量级:把 dQ 的 partial gradient 先转 bf16 再 对 64 个 KV tile 做原子累加,官方 README 报告的相对 L2 误差约 1.024% (dK/dV 分别是 0.316% / 0.037%),并明确说明「The larger dQ error comes from repeated BF16 rounding during atomic accumulation across 64 KV tiles」,同时提醒 「atomic reduction does not guarantee bitwise determinism」 (_vendor/tilelang-v0.1.15/examples/ascend/flash_attention/README.md,测量环境: Ascend 950DT、CANN/Bisheng 9.2.0、TileLang 8121f415,fast-math 打开; 厂商自报,作者未复现)。这是本章唯一一处「归约顺序影响正确性」的实测数字, 所以它值得记住。

5.5 稀疏与索引类:attention 侧只剩一个结构问题

第 16 章会把 DeepSeek NSA / DSA、mInference、kpool 这条线讲全,本章只回答一个问题: 稀疏 attention 在 kernel 结构上到底改了什么?

答案是:key 的遍历不再是一个连续区间,而是一个由索引张量给出的集合。

方案 索引的粒度 锁定版本里的实现
block-sparse block_M × block_N 的 0/1 块表 examples/blocksparse_attention/
NSA(native sparse attention) 每个 query 选 selected_blocks 个 block_size 块 examples/deepseek_nsa/example_tilelang_nsa_fwd.py
mInference(vertical-slash) 每个 query 一组「竖向 + 斜向」的列索引 examples/minference/example_vertical_slash_sparse_attn.py
DSA / lightning indexer 先算一个轻量打分,再 top-k 出 token 索引 _vendor/tilelang-ascend-v0.1.2.000/examples/lightning_indexer/example_lightning_indexer.py
HISA(两级) 先选 pool block,再选 block 内 token examples/dsa_hisa/hisa.py
SeerAttention 学习到的块稀疏 examples/seer_attention/block_sparse_attn_tilelang.py

NSA 的前向 kernel 把这一点写得很清楚——它的 kernel 签名里有一个 BlockIndices 张量,grid 直接按 seq_len 展开:

def native_sparse_attention(Q, K, V, BlockIndices, dim, is_causal, ...,
                            block_size=64, groups=1, selected_blocks=16):
    ...
    block_indices_shape = [batch, seq_len, head_kv, selected_blocks]

(_vendor/tilelang-v0.1.15/examples/deepseek_nsa/example_tilelang_nsa_fwd.py:18-29)

昇腾的 lightning indexer 则展示了「索引算子自己也是一个 GEMM + reduce + topk」: Cube 侧算 Q·Kᵀ 并写入 QK_RES,Vector 侧按 WEIGHTS 加权、沿 group 维 reduce、 最后 T.tile.topk 选出 TOP_K (_vendor/tilelang-ascend-v0.1.2.000/examples/lightning_indexer/example_lightning_indexer.py:34-124)。

结构上只有两类新增的东西:

  1. 间接寻址:KV block 的位置来自索引张量,而不是 k * block_N 这样的算术地址。 这会让 T.copy 的源地址变成「查表 + 偏移」,TMA 的静态描述符不再直接适用。
  2. top-k / gather 这一类的原语:它们本身不是 attention,但决定了 attention 能 不能省下带宽。第 16 章和第 9 章的 T.tile.topk 一节会展开。

5.6 昇腾与 NVIDIA:瓶颈为什么不一样

把 4.4、4.5 两节合起来看,差异可以归结成三句话:

NVIDIA 昇腾
谁算矩阵 Tensor Core,与 SIMT 单元同处一个线程块 Cube 核,与 Vector 核分开
分数怎么给 softmax T.gemm 直接累加到 fragment(寄存器) L0C → GM workspace → UB,必须绕(4.1.5.2 节)
主要矛盾 异步搬运(cp.async/TMA)与计算的 overlap、非矩阵指令的占比 Cube 与 Vector 的核间流水、跨核同步次数、scalar 指令下发

昇腾侧的调优文档把这件事说得很直:性能优化的核心原则是「掩盖短流水、减少气泡、 优化到单一 bound」,而核间优化的两步就是「找到最佳 num_stages」与「减少同步次数」 (_vendor/tilelang-ascend-v0.1.2.000/examples/flash_attention/fa_opt/flash_attention_performance_optimization_zh.md 第 4 节)。同一份文档给出一条只适用于昇腾的限制:

重要:T.pipelined 不支持嵌套使用。

(同文件「5.2 使用限制」;措辞已按锁定版本核对,code/06-ascend-map/ 会把这一行定位并打印出来。 未在 NPU 上运行验证。)

厂商自报的优化收益(厂商自报,作者未复现)为:把 7 项优化全部做上以后,FA 达到 原生 AscendC 算子 80% 的性能;其中 T.pipelined 核间流水一项对应「原生 AscendC 的 60%」,减少核间同步次数对应 72%,用 axpy 合并指令对应 80% (_vendor/tilelang-ascend-v0.1.2.000/examples/flash_attention/fa_opt/bench_mark_zh.md; 测试条件:B=1, Q_N=12, KV_N=1, D=128, S=32K/64K/128K, block_size=128)。

主干仓库里的昇腾 FA 报告了另一组数字:SEQ_LEN=4096, D=128 的 MHA 前向在 Ascend 950(dav-3510,bf16)上 26.8 µs / 320.1 TFLOPS,对照 Torch SDPA 的 28.94 µs / 296.8 TFLOPS;GQA 前向(S1=8192, G=32, S2=8192, D=128)则是 TileLang 3030 µs 对 Torch 2864 µs (_vendor/tilelang-v0.1.15/examples/ascend/flash_attention/README.md; 厂商自报,作者未复现)。注意这两组数字互相之间不可比:形状、硬件、 测量口径都不同。本章引用它们的唯一目的是说明「瓶颈在哪」。


6. 本章小结

把这一页当作复习卡片。

为什么要有 flash attention。 朴素 attention 的瓶颈不是 O(N²d) 的乘加,而是 O(N²) 的中间矩阵在 HBM 上来回:S 与 P 各写一次读一次,合计约 4N² 个元素。 flash attention 把它们留在片上,结果完全精确。

推导的主线只有两条性质。

  1. softmax 对指数平移不变:exp(−m) 在分子分母各出现一次、约掉;
  2. 求和可分段:块的贡献可以直接加到前面的累加器上。

把「换了一个运行最大值」这件事写成公式,就是

m_new = max(m_prev, M_blk)
c     = exp(m_prev − m_new)                     ← 永远 ≤ 1
ℓ     = c · ℓ      + rowsum(exp(S_blk − m_new))
O     = c · O      + exp(S_blk − m_new) · V_blk
O_out = O / ℓ

m 只负责让 exp 的指数不为正;它取什么值都不改变最终比值(2.4 节的归纳证明)。

四级阶梯。 ① 朴素版物化 N×N(放不下也不划算)→ ② 分块 + online softmax (4N² → 片上,但没有重叠)→ ③ 加 T.Pipelined 与 FullRow、寄存器复用 (形状写死)→ ④ 对齐官方实现(causal 循环裁剪、past_len、越界 mask、O_shared epilogue)。

三个参数各自绑着一件事。

  • num_stages 决定 K_shared/V_shared 的份数,也就决定了片上占用;
  • policy=T.GemmWarpPolicy.FullRow 让后面的行内 reduce_* 尽量待在 warp 内;
  • scale 折进 log2(e),让 exp 变成 exp2。

七个变体各自改了哪一行。 causal:循环上界 + q ≥ k;varlen:cu_seqlens + 越界 guard;GQA:kv_head_idx = head_idx // groups;decode:split 与 combine 两个 kernel;paged KV:block_table 间接寻址;sliding window:循环起点 + check_inf; sink:epilogue 里把 exp2(sink − m·scale) 加进分母。

四个坑。 ① −∞ − (−∞) = NaN(sliding window 必须 check_inf); ② causal 的对角线、偏移、循环上界三处都要对;③ varlen 的 max_seqlen 是编译期常量、 短样本靠 guard;④ split-KV 必须按 ℓ_s / Σℓ 加权合并,低精度累加会真的伤正确性 (昇腾 backward 的 dQ 相对 L2 误差约 1.024%,厂商自报)。

两台机器。 NVIDIA 上分数直接落在 fragment;昇腾上 QKᵀ 必须绕 L0C → workspace → UB,所以瓶颈从「异步搬运的 overlap」变成「Cube 与 Vector 的 核间流水与同步次数」。

延伸阅读

下面每条都指向本章 本章引用 里的条目 id。

  • 论文:R1(FA1,摘要给出「IO-aware exact attention」的定位)、R2(FA2, 并行划分与 warp 划分的动机)、R3(FA3,Hopper 上的 warp specialization 与低精度)。 注意:corpus 里这三篇只有标题与摘要,见 本章引用 的「待补资料」。
  • 官方例子(本章的主角):S1(example_mha_fwd_bhsd.py,3.4 节逐行对照的对象)、 S2(资料库索引,那篇把整个 kernel 贴出来的讲解)、S3(varlen + GQA)、 S4(decode / split-KV)、S5(sink + sliding window + check_inf)、 S6(block-causal)、S7(sm100 / TMEM)。
  • API 出处:S8(T.reduce_max 的 clear 语义)、S9(T.gemm 的 policy 与异步接口的关系)、S10(GemmWarpPolicy)、S11 (T.alloc_tmem 的约束)、S12(T.infinity 落到 IEEE +∞)。
  • 昇腾:A1(适配器版 flash_attn_bhsd.py,workspace + 跨核 flag 的写法)、 A2(mha_sink_fwd_bhsd.py 里关于 m* 的长注释)、A3(gqa_fwd_varlen.py)、 A4(lightning_indexer)、A5(Cube/Vector 调优文档)、A6(FA benchmark)、 A7(编程指南 4.1.5.2 workspace 消除)、A8(主干仓库的昇腾 FA 与它的性能表)、 A10(workspace 消除 pass 的实现与那张改写表)、A11(paged KV 的昇腾实现)。
  • 稀疏与索引:S13(NSA 前向)、S14(mInference / vertical-slash)、 S15(HISA 两级索引)——它们是第 16 章的入口,本章只讲「结构上多了什么」。
  • 代码:资料库索引 里有六个示例的验证级别总表;code/02-online-softmax/、code/03-split-kv-combine/、 code/04-block-flash-attention/ 是本机可运行的推理验证(CPU 逻辑验证)。

习题

14 题 · 答案在提交后展开

先在纸上或输入框里写下你的答案,再展开参考答案对照。客观判断请自己打勾:这三颗按钮 只影响本机保存的学习进度。

E1概念难度 ★无需硬件概念题 不需要硬件

请用三句话回答:

  1. 朴素 attention 的瓶颈为什么不是 O(N²d) 的乘加?
  2. FlashAttention 说自己「exact」(精确)是什么意思?它到底近似了什么、没有近似什么?
  3. 为什么把中间结果留在片上之后,K、V 反而可能被读得更多?

E2概念难度 ★无需硬件概念题 不需要硬件

写出分块 online softmax 的递推式(四个式子:m_new、c、ℓ、O),并且:

  1. 说明 c 的取值范围以及为什么;
  2. 用一句话说明「为什么 exp(−m) 可以约掉」;
  3. 如果 kappa 把运行最大值换成一个常量 m ≡ 0,结果在数学上还对不对? 在数值上会发生什么?

E3概念难度 ★★无需硬件概念题 不需要硬件

关于 masking 与数值:

  1. 在 v0.1.15 里,T.infinity("float32") 得到的是 IEEE +∞ 还是 float32 的最大 有限值?它的实现在哪个文件?(给出路径与行号区间。)
  2. 于是 -T.infinity(acc_s.dtype) 作为 mask 值时,exp2(x − m) 在什么条件下会得到 0?
  3. 一个 query 行在前若干块里一个可见 key 都没有,会发生什么?check_inf 修好了什么?
  4. example_gqa_fwd_varlen.py 用的是 -1e9 而不是 -T.infinity(...)。请说明这两种 取值在「整行全被 mask」这一极端情形下的行为差异。

E4概念难度 ★★无需硬件概念题 不需要硬件

设 Sq = Skv = 4096、d = 128、fp16(2 字节/元素),只考虑一个 (batch, head)。

  1. 按本章 1.2 节的记账法,朴素版的 HBM 元素流量是多少?其中 N² 项占多少?
  2. 分块融合版(block_m = block_n = 128)的流量是多少?请逐項列出。
  3. 两者相差几倍?如果换成 Sq = 1, Skv = 4096,结论会变成什么?为什么?
  4. 你可以用 code/01-traffic-model/traffic_model.py 验证上面三个答案。请说明这个模型 高估了什么、低估了什么。

E5代码阅读难度 ★无需硬件代码阅读题 不需要硬件

下面这段来自官方例子(example_mha_fwd_bhsd.py):

T.copy(scores_max, scores_max_prev)
T.fill(scores_max, -T.infinity(accum_dtype))
T.reduce_max(acc_s, scores_max, dim=1, clear=False)
for i in T.Parallel(block_M):
    scores_max[i] = T.max(scores_max[i], scores_max_prev[i])
  1. T.reduce_max 的 clear 参数语义是什么?(指出它在锁定版本源码里的位置。)
  2. 既然 clear=True 会自己把输出初始化成 -inf,为什么这里写成「先 T.fill 再 clear=False」?这两种写法在语义上等价吗?
  3. 最后那个 T.max 在公式里对应哪一步?

E6代码阅读难度 ★★无需硬件代码阅读题 不需要硬件

下面是一个 causal attention 的循环上界计算,它有一个真实的 bug:

past_len = seq_kv - seq_q
loop_range = T.ceildiv(seq_kv, block_N)          # ← 这里
for k in T.Pipelined(loop_range, num_stages=num_stages):
    T.copy(K[bz, by, k * block_N:(k + 1) * block_N, :], K_shared)
    for i, j in T.Parallel(block_M, block_N):
        q_idx = bx * block_M + i + past_len
        k_idx = k * block_N + j
        acc_s[i, j] = T.if_then_else(q_idx >= k_idx, 0, -T.infinity(acc_s.dtype))
    ...
  1. 这段代码结果正确吗?如果不正确,错在哪一行?
  2. 如果结果仍然正确但很慢,慢在哪?请给出官方的写法。
  3. 把 q_idx >= k_idx 写成 q_idx > k_idx 会得到什么?请描述它在 is_causal=True 且 past_len = 0 时对输出的影响(提示:思考对角线元素)。

E7代码阅读难度 ★★无需硬件代码阅读题 不需要硬件

下面是 flash decoding 的 combine kernel 的核心(example_mha_inference.py):

T.reduce_max(lse_local, lse_max_local, dim=0, clear=True)
for k in T.Pipelined(num_split):
    T.copy(lse_local[k, :], lse_local_split)
    for i in T.Parallel(block_M):
        lse_logsum_local[i] += T.exp2(lse_local_split[i] - lse_max_local[i])
for i in T.Parallel(block_M):
    lse_logsum_local[i] = T.log2(lse_logsum_local[i]) + lse_max_local[i]
for k in T.Pipelined(num_split, num_stages=2):
    T.copy(Output_partial[...k...], po_local)
    ...
    scale_local[i] = T.exp2(lse_local_split[i] - lse_logsum_local[i])
    o_accum_local[i, j] += po_local[i, j] * scale_local[i]
  1. lse_logsum_local 在数学上是什么量?
  2. scale_local 在数学上是什么?为什么它必须等于 ℓ_s / Σℓ?
  3. 如果偷懒写成 o_accum_local += po_local(直接相加),结果会怎样?请给出一个定性 的理由,并说明 code/03-split-kv-combine/ 里的实验验证了这一点。
  4. 为什么要先减去 lse_max_local,而不是直接 exp2(lse_local_split)?

E8代码阅读难度 ★★无需硬件代码阅读题 不需要硬件

下面摘自昇腾适配器版本的 flash attention(flash_attn_bhsd.py):

with T.Scope("C"):
    ... T.gemm_v0(q_l1, k_l1, acc_s_l0c, transpose_B=True, init=True)
    T.copy(acc_s_l0c, workspace_1[cid, :, :])
    T.set_cross_flag("FIX", 0)
    T.wait_cross_flag(1)
    ...
with T.Scope("V"):
    T.wait_cross_flag(0)
    T.copy(workspace_1[cid, ...], acc_s_ub_)
    ... softmax on acc_s_ub ...
    T.copy(acc_s_half, workspace_2[cid, ...])
    T.set_cross_flag("MTE3", 1)
  1. T.Scope("C") 与 T.Scope("V") 分别对应什么硬件?它们为什么不能直接共享片上缓冲?
  2. 为什么分数要经过 workspace_1?请引用锁定版本编程指南里的那条依据。
  3. workspace_2 / workspace_3 各装的是什么?
  4. 一个 KV 块的处理里,Cube 与 Vector 之间大约要同步几次?为什么「减少同步次数」 会出现在调优文档的收益清单里(见 fa_opt/bench_mark_zh.md)?

E9动手难度 ★★无需硬件动手题 不需要硬件

用 numpy(或你熟悉的语言里的数组库)实现一个分块 online softmax,并让它在 code/02-online-softmax/online_softmax.py 的基础上多满足一个要求:

  1. 每处理完一块,打印 (块号, m, c, ℓ, O/ℓ)——注意最后一项是当前的归一化输出, 它会随着块的推进而变化;
  2. 证明:当最后一块处理完时,O/ℓ 与「先算完整 softmax 再乘 V」逐元素相等(误差 < 1e-12);
  3. 把 key 的顺序倒过来再跑一次,观察中间的 m/c/O/ℓ 序列完全不同,但最终 结果相同。请用一句话解释为什么。

E10动手难度 ★★无需硬件动手题 不需要硬件

用 code/01-traffic-model/traffic_model.py 的模型做一次参数扫描:

  1. 固定 Sq = Skv = 8192、d = 128,让 block_m 取 32, 64, 128, 256, 512,把「tiled 的总流量」画成一张表;
  2. 找出这个模型里 tiled 流量最小的 block_m,并解释为什么它会随 block_m 单调下降 (提示:看 K/V 重读项的表达式);
  3. 然后回答:既然模型说「block_m 越大越好」,为什么真实 kernel 不把 block_m 一路加到 512 或 1024?至少给出两个理由(提示:片上容量、warp 划分、 尾部浪费、layout)。

E11概念难度 ★★无需硬件概念题 不需要硬件

sliding window + attention sink 的 epilogue 是:

for i in T.Parallel(block_M):
    logsum[i] += T.exp2(sinks[i] * 1.44269504 - scores_max[i] * scale)
for i, j in T.Parallel(block_M, dim):
    acc_o[i, j] /= logsum[i]

(example_mha_sink_fwd_bhsd.py:125-128)

  1. 这个 sink 在 softmax 里相当于什么?为什么它只影响分母、不影响分子?
  2. 写出它在数学上的等价形式(即:把 sink 当成一个额外的 key 之后,完整 softmax 长什么样)。
  3. 昇腾版 mha_sink_fwd_bhsd.py 的注释里说要用 m* = max(sink, m_i_final) 而不是 m_i_final。请用你上面写出的公式解释:为什么用 m_i_final 时 exp(sink − m_i_final) 可能溢出?

E12动手难度 ★★★无需硬件动手题 不需要硬件

把 code/04-block-flash-attention/block_flash_attention.py 当作规格说明,然后 完成下面两件事:

  1. 给 block_flash_attention() 增加一个 softcap 参数:在 softmax 之前对分数做 z = softcap * tanh(z / softcap)(这是 Gemma-2 一类的 logit soft-capping)。 同时修改参考实现,并让两者在 softcap ∈ {None, 50, 30} 下都一致。
  2. 说明:为什么 soft-capping 不能简单地放进 T.gemm 之后的那一行 acc_s[i,j] *= scale 里?(提示:exp2 的输入与 scores_max 的取值必须来自 同一个变换后的分数。)

不需要真的写 TileLang;本机用 numpy 验证逻辑即可。


E13代码阅读难度 ★★★无需硬件代码阅读题 不需要硬件

官方 varlen 例子里有这样一段:

offset = kv_current_seqlen - q_current_seqlen  # always align on the right
max_visible_k_idx = offset + (bx + 1) * block_M
loop_range = (T.min(T.ceildiv(max_visible_k_idx, block_N), T.ceildiv(kv_current_seqlen, block_N))
              if is_causal else T.ceildiv(kv_current_seqlen, block_N))
  1. offset 为什么是 kv_current_seqlen - q_current_seqlen?它体现了什么语义?
  2. 为什么 loop_range 里要取 T.min(...),两个上界分别防的是什么?
  3. 该例子的 mask 用的是 -1e9,请构造一个小例子(长度 3 的 query、长度 3 的 key、 is_causal=True),说明如果把 -1e9 换成 -T.infinity("float32"),哪一行会最先 出问题。(可以用 code/02-online-softmax/ 或 code/04-block-flash-attention/ 的代码改造验证。)

可选(需要硬件):在 NVIDIA GPU 上跑 python examples/flash_attention/example_gqa_fwd_varlen.py --q_seqlen 2048 --k_seqlen 2048 --groups 16 (需要 flash_attn 包做对照),比较 is_causal 打开与关闭时的一致性检查是否都通过。


E14动手难度 ★★★无需硬件动手题 可选

在真机上做一次「配置 → 现象」的对照实验(只在有卡的机器上做):

  1. 用 examples/flash_attention/example_mha_fwd_bhsd.py 跑 --seq_q 4096 --seq_kv 4096 --dim 128 --heads 16 --is_causal;
  2. 把 block_M、block_N、num_stages 改成三组: (64, 64, 1)、(128, 128, 2)、(128, 128, 4);
  3. 记录每组是否编译成功、是否通过正确性检查、以及 do_bench 的延迟;
  4. 用第 11 章 4.5 节的片上占用公式解释「哪一组会因为 shared memory 超限而失败」, 并解释「为什么 num_stages=1 的那一组最慢」。

报告要求:写明 GPU 型号、CUDA 版本、TileLang commit,以及你实际观察到的数字。 不要引用本章任何数字——本章没有实测数字。

章节自测

8 题 · 提交后本地评分

单选题。提交后立即给出分数与逐题解析;重做会清空选择,但会保留上一次的成绩记录。

  1. 1. 正文把朴素 attention 的瓶颈归结为什么?

  2. 2. 重标度因子 c = exp(m_prev − m_new) 的取值范围是什么?

  3. 3. 按 online softmax 递推算出的结果,与「先算完整 softmax 再乘 V」是什么关系?

  4. 4. attention 里为什么用 policy=T.GemmWarpPolicy.FullRow?

  5. 5. sliding window / attention sink 里的 check_inf 解决的是什么问题?

  6. 6. decode 阶段瓶颈是并行度而不是流量,因此对应的变体是什么?

  7. 7. 昇腾上 QKᵀ 的结果为什么要绕一趟全局内存(workspace)?

  8. 8. causal attention 把循环上界写成 ceil(((bx+1)·block_M + past_len) / block_N),为什么?

本章代码

13 个文件

正文里提到的每个示例都能在这里直接阅读(带语法高亮),也可以整章浏览。

浏览本章全部代码 →

本章引用

源码链接指向锁定 tag

本章写到的每个源码位置与外部资料都在下面列出:源码链接指向锁定 tag 的 GitHub 文件, 资料链接指向原文。查看全部资料索引 →

展开引用清单

第 15 章 参考资料

本章所有结论都基于下表的本地资料或锁定版本源码。版本以 版本锁定 为准:TileLang v0.1.15 (commit a35f8ddf45eba16c21211ec8822d56ce5363036f)、TileLang-Ascend TileLang-ascend-v0.1.2.000-release(commit 89a9364e47a341fffdd65ed94786b1d4f9b6a78c)。 文中不出现模糊的版本指代,一律写具体 tag 与 commit。

写作环境:macOS (Apple Silicon)、Python 3.11(miniconda);没有 NVIDIA GPU, 也没有昇腾 NPU。因此本章没有任何本机实测的性能或正确性结论。本章的数值结论有两类, 来源都写清楚:

  • 本机 CPU 逻辑验证(code/01-traffic-model/–code/04-block-flash-attention/):数学性质与访存记账,可复算;
  • 厂商自报(A5、A6、A8):锁定版本仓库自带的 benchmark 文档/README, 带原始测量条件,作者未复现。

引用分三类,核验状态只有三种口径:

  • 已核验(静态):在锁定版本工作树上 rg/sed 定位到该行并逐行阅读,行号与文件 内容一致;对本章最重要的部分,code/05-official-example-map/ 与 code/06-ascend-map/ 两个脚本会自动比对行号 (--strict),输出 drift/miss count: 0。这不是运行验证。
  • 已核验(本地全文):corpus 里保存了该来源的完整本地副本。
  • 未核验:明确写出原因。

一、NVIDIA 侧锁定源码引用(_vendor/tilelang-v0.1.15/)

1.1 官方 attention 例子(本章逐行对照的对象)

id 引用位置 用途 核验方式与状态
S1 _vendor/tilelang-v0.1.15/examples/flash_attention/example_mha_fwd_bhsd.py:24,30-31,40,45-52,54,59-63,65,67-75,77-95,97-100,167 本章的主对照对象:log2e 折进 scale、past_len、causal 循环上界、非 causal 的越界 mask、online softmax 五步、O_shared epilogue、回归配置 已核验(2026-10-01):sed -n 逐段阅读;code/05-official-example-map/ --strict 全部命中
S2 _vendor/tilelang-v0.1.15/examples/flash_attention/README.md 官方把整个 kernel 贴出来的讲解版(与 .py 的行文略有差异,正文以 .py 为准) 已核验(2026-10-01):本地全文
S3 _vendor/tilelang-v0.1.15/examples/flash_attention/example_mha_fwd_varlen.py;example_gqa_fwd_varlen.py:36,50-60,62,68-74,79-91,93,117-119,122-124 varlen 打包(cu_seqlens、右对齐 offset、越界 guard)、GQA 的 kv_head_idx、-1e9 mask 已核验(2026-10-01);code/04-block-flash-attention/ 的用例 4 在 CPU 上复现了结构
S4 _vendor/tilelang-v0.1.15/examples/flash_decoding/example_mha_inference.py:30-113,115-152 split-KV(flash decoding):split kernel 输出部分结果与逐段 LSE,combine kernel 用 logsumexp 求权重 已核验(2026-10-01);code/03-split-kv-combine/ 用 CPU 复现了合并公式
S5 _vendor/tilelang-v0.1.15/examples/attention_sink/example_mha_sink_fwd_bhsd.py:84,91-92,103-109,125-128 sliding window 的循环起点与双端裁剪、check_inf、attention sink 的 epilogue 已核验(2026-10-01);code/02-online-softmax//code/04-block-flash-attention/ 在 CPU 上验证了 check_inf 的必要性
S6 _vendor/tilelang-v0.1.15/examples/block_causal_attention/block_causal_attention.py:43-147 block-causal(噪声/干净分区)的逐 tile mask 决策 已核验(2026-10-01):T.Kernel 在 :74、is_noisy_diag 在 :98(code/05-official-example-map/ 探针)
S7 _vendor/tilelang-v0.1.15/examples/flash_attention_sm100/mha_fwd_bshd.py:1-8,54-56,89-93,145-148 Blackwell 变体:TMEM 累加器、mbarrier、T.tcgen05_gemm;文件头注释说明它取代 Hopper 的 WGMMA 版本 已核验(2026-10-01);未运行(无 sm100 设备)
S13 _vendor/tilelang-v0.1.15/examples/deepseek_nsa/example_tilelang_nsa_fwd.py:18-29,53 稀疏 attention 的结构性变化:BlockIndices 索引张量取代连续区间 已核验(2026-10-01)
S14 _vendor/tilelang-v0.1.15/examples/minference/example_vertical_slash_sparse_attn.py mInference 的 vertical/slash 稀疏索引(第 16 章入口) 已核验(2026-10-01):文件存在且可读;只做了结构引用,未逐行核对算法
S15 _vendor/tilelang-v0.1.15/examples/dsa_hisa/README.md(+ hisa.py) HISA 的两级索引(先选 pool block,再选 block 内 token) 已核验(2026-10-01):README 全文;hisa.py 未逐行阅读,正文只引用 README 描述
S16 _vendor/tilelang-v0.1.15/examples/seer_attention/block_sparse_attn_tilelang.py;examples/blocksparse_attention/ 学习型块稀疏与块稀疏的另两个实现(变体矩阵里列出) 已核验(2026-10-01):文件存在;未逐行阅读,正文只作条目列出
S17 _vendor/tilelang-v0.1.15/examples/flash_attention/example_mha_fwd_bshd.py;example_gqa_fwd_bshd.py BSHD 布局与 GQA 的固定长度版本(变体矩阵里列出) 已核验(2026-10-01):文件存在;行号为索引顺序差异

1.2 本章引用的 API 定义

id 引用位置 用途 核验方式与状态
S8 _vendor/tilelang-v0.1.15/tilelang/language/reduce_op.py:176-204,230-256 T.reduce_max 的 clear 语义;reduce_sum(clear=True) 的 temp buffer 说明 已核验(2026-10-01);code/05-official-example-map/ 固定行号 :176/:230
S9 _vendor/tilelang-v0.1.15/tilelang/language/gemm_op.py:160-210 T.gemm 的签名、policy、clear_accum,以及「手工异步请用 T.wgmma_gemm/T.tcgen05_gemm」的说明(:177-179) 已核验(2026-10-01)
S10 _vendor/tilelang-v0.1.15/tilelang/tileop/base.py:5-12 GemmWarpPolicy 三个取值(Square/FullRow/FullCol) 已核验(2026-10-01);code/05-official-example-map/ 固定行号 :5
S11 _vendor/tilelang-v0.1.15/tilelang/language/allocate.py:49,81,171,194,254-270 alloc_shared/alloc_fragment/alloc_global/alloc_barrier/alloc_tmem(TMEM 的列数约束) 已核验(2026-10-01)
S12 _vendor/tilelang-v0.1.15/src/op/math.cc:80-108;tilelang/language/tir/op.py:361-377 T.infinity 落到 IEEE +∞ 而不是 dtype 最大有限值 已核验(2026-10-01):读 infinity_op 全部分支
S18 _vendor/tilelang-v0.1.15/tilelang/language/loop.py:112;tilelang/language/copy_op.py:59-131 T.Pipelined 与 T.copy 的签名(正文引用其行为,机制见第 11 章) 已核验(2026-10-01)
S19 _vendor/tilelang-v0.1.15/tilelang/cuda/language/gemm_op.py:264 T.tcgen05_gemm 的定义位置 已核验(2026-10-01)

二、昇腾侧锁定源码引用

2.1 主干仓库 v0.1.15 的 Ascend 方言与例子

id 引用位置 用途 核验方式与状态
A8 _vendor/tilelang-v0.1.15/examples/ascend/flash_attention/README.md;core.py:1-25,66-99,102-199,200-256,336-420;example_mha.py 主干仓库的昇腾 FA:Cube+Vector 分工、SIMD softmax 打包、T.Stage 自动调度、性能表 已核验(2026-10-01):README 全文 + core.py 分块阅读;未在 NPU 上运行。性能数字为厂商自报,作者未复现
A9 _vendor/tilelang-v0.1.15/tilelang/ascend/language/allocate.py:15,26,37,46,55;copy_op.py:53;frame.py:179;schedule_hint.py:118;annotations.py:19 alloc_shared/alloc_l1/alloc_l0a/alloc_l0b/alloc_l0c、dual_copy、SimdVF、Stage、annotate_buffer_versions 已核验(2026-10-01);code/06-ascend-map/ 固定以上行号,--strict 无漂移

2.2 适配器仓库 v0.1.2.000

id 引用位置 用途 核验方式与状态
A1 _vendor/tilelang-ascend-v0.1.2.000/examples/flash_attention/flash_attn_bhsd.py:26-34,86-131 昇腾 FA 的显式 workspace 与跨核 flag 写法(T.Scope("C")/T.Scope("V")) 已核验(2026-10-01):逐行阅读;未运行
A2 _vendor/tilelang-ascend-v0.1.2.000/examples/mha_sink_fwd_bhsd/mha_sink_fwd_bhsd.py:34-101,176-240,378-577 sink + sliding window 的昇腾实现;:565-577 是关于 m* = max(sink, m_i_final) 的完整推理 已核验(2026-10-01):重点段逐行阅读;未运行
A3 _vendor/tilelang-ascend-v0.1.2.000/examples/gqa_fwd_varlen/gqa_fwd_varlen.py:31-63,148-172,210-260 昇腾 GQA + varlen:显式 Mask 输入、T.mma + L0A/L0B/L0C 双缓冲 已核验(2026-10-01);未运行
A4 _vendor/tilelang-ascend-v0.1.2.000/examples/lightning_indexer/example_lightning_indexer.py:19-125 DSA/lightning indexer:Cube 算 Q·Kᵀ、Vector 做加权 reduce + T.tile.topk/gather_mask/cast 已核验(2026-10-01):逐行阅读;未运行
A5 _vendor/tilelang-ascend-v0.1.2.000/examples/flash_attention/fa_opt/flash_attention_performance_optimization_zh.md(330 行) 昇腾 FA 的调优方法论:Double Buffer、单一 bound、最佳 num_stages、减少核间同步、scalar 向量化、T.pipelined 不支持嵌套 已核验(2026-10-01):本地全文;code/06-ascend-map/ 会定位并打印其中 5 处标题/句子
A6 _vendor/tilelang-ascend-v0.1.2.000/examples/flash_attention/fa_opt/bench_mark_zh.md 优化收益清单(L1 常驻 / 向量化 / 多 Buffer / 去冗余同步 / CV pipelined / 降同步次数 / axpy)与「达到原生 AscendC 80%」的结论 已核验(2026-10-01):本地全文。性能数字为厂商自报,作者未复现;条件:B=1, Q_N=12, KV_N=1, D=128, S=32K/64K/128K, block_size=128
A7 _vendor/tilelang-ascend-v0.1.2.000/docs/TileLang-Ascend Programming Guide.md:597,627-636,1345-1390,1422-1500 ①shared 层级 = L1/UB、fragment 层级 = L0A/L0B/L0C;②合法搬运对表(没有 L0C→UB);③4.1.5.2 节 workspace 消除;④Expert 模式的内存分配原语 已核验(2026-10-01):逐段阅读(文件共 2596 行);code/06-ascend-map/ 会解析并打印那张搬运表
A10 _vendor/tilelang-ascend-v0.1.2.000/src/transform/ascend_workspace_reduction.cc:882-920,999-1004,1455-1481 workspace 消除 pass 的实现:copy_l0c_to_ub → copy_l0c_to_gm 的显式改写表,以及 pass 注册入口 已核验(2026-10-01):grep -n 定位 + 逐段阅读;未编译该 pass
A11 _vendor/tilelang-ascend-v0.1.2.000/examples/flash_attention/paged_flash_attn_bhsd.py:16-140 paged KV:block_table 间接寻址 已核验(2026-10-01):结构与 A1 同构,:94 的 bidx = block_table[bz, k] 是唯一关键差异;未运行
A12 _vendor/tilelang-ascend-v0.1.2.000/tilelang/language/allocate.py:140,152,156;ascend.py:191,213,275,418;ascend_tile.py:343,550,608,1140,1927,2302;warpgroup.py:74 alloc_L1/alloc_L0C/alloc_ub、set_cross_flag/wait_cross_flag/barrier_all/gemm_v0、T.tile.{fill,topk,gather_mask,exp,cast,broadcast}、T.Scope 已核验(2026-10-01);code/06-ascend-map/ 固定以上行号,--strict 无漂移

三、corpus 资料引用(资料库索引)

抓取日期:2026-09-30(Asia/Shanghai),见 corpus 的 资料库索引。

id 本地路径 上游链接 用途 核验状态
R1 FlashAttention: Fast and Memory-Efficient Exact Att… https://arxiv.org/abs/2205.14135 FA1(IO-aware exact attention):只用到摘要里「uses tiling to reduce the number of memory reads/writes between HBM and on-chip SRAM」与「optimal for a range of SRAM sizes」两句定性表述 已核验(2026-10-01):本地为标题+摘要(12 行);全文不在 corpus 里,见「五、待补资料」
R2 FlashAttention-2: Faster Attention with Better Para… https://arxiv.org/abs/2307.08691 FA2(更好的并行与工作划分):论文摘要提到「suboptimal work partitioning … low-occupancy or unnecessary shared memory reads/writes」,这正是本章 3.3 节 FullRow 与 4.2 节的动机 已核验(2026-10-01):本地为标题+摘要(12 行);全文不在 corpus 里
R3 FlashAttention-3: Fast and Accurate Attention with … https://arxiv.org/abs/2407.08608 FA3(Hopper 上的 warp specialization 与低精度):本章只在「check_inf 这个名字来自 FA3」这一点上引用它,而那一点实际来自官方例子的注释 已核验(2026-10-01):本地为标题+摘要(12 行);全文不在 corpus 里
R4 CUDA C++ Best Practices Guide(相关段落约在第 3145、3629 行附近) https://docs.nvidia.com/cuda/cuda-c-programming-guide/index.html 「访存 thousands of cycles vs 算术指令 4 cycles」的量级对照(本章 1.2 节) 已核验(2026-10-01):与第 1、11 章引用同一段落;条件:未缓存 local/global 访存与 compute capability 7.0 设备
R5 tile-ai/tilelang/examples/、.../tilelang/benchmark/ https://github.com/tile-ai/tilelang 与锁定版本 examples/ 的对照快照:用于确认「某个例子是不是只存在于快照而不在锁定 tag 里」(本章未发现此类差异) 已核验(2026-10-01):按目录清单比对;未做逐文件 diff

说明:R5 的 corpus 快照 commit 与 版本锁定 锁定的 v0.1.15 不同。本章所有 行号与结论一律取锁定版本 _vendor/tilelang-v0.1.15/;R5 只用于「这个文件是否长期 存在」这种存在性判断。


四、本章的数值结论分别来自哪里

为了杜绝「来源不明的数字」,这里把本章出现的每一类数字与它的出处列全:

数字 出现在 出处 性质
4N² + 2(Sq+Skv)d、2·ceil(Sq/block_m)·Skv·d 与那张 2.00x 的表 1.2、2.5 节、图 1 code/01-traffic-model/traffic_model.py 本机 CPU 记账模型(整数运算),不是测量
online softmax 的轨迹与 1e-16 级误差 2.3–2.4 节、图 2 code/02-online-softmax/online_softmax.py 本机 CPU 逻辑验证(fp64)
split-KV 的 5.6e-17 / 4.7e-3 / 6.1e-01 三个误差 5.4 节 code/03-split-kv-combine/split_kv_combine.py 本机 CPU 逻辑验证(fp64 / fp16 模型)
五个变体的 <1e-12 一致性、check_inf 关闭时的 NaN 3、5.1、5.2 节 code/04-block-flash-attention/block_flash_attention.py 本机 CPU 逻辑验证
昇腾 FA「达到原生 AscendC 80%」、各项优化的 36%/50%/60%/72% 5.6 节 A6(fa_opt/bench_mark_zh.md) 厂商自报,作者未复现;条件见 A6
昇腾 MHA 26.8 us / 320.1 TFLOPS、GQA 3030 us vs Torch 2864 us 5.6 节 A8(资料库索引) 厂商自报,作者未复现;Ascend 950 / dav-3510 / bf16 / msprof_detail
dQ 相对 L2 误差 1.024%(dK 0.316%、dV 0.037%) 5.4 节 A8 厂商自报,作者未复现;Ascend950DT、CANN/Bisheng 9.2.0、TileLang 8121f415
attention-sink 的 Triton/TileLang TFLOPS 对比表 —— 资料库索引 本章刻意未引用:它是厂商自报的 H800 性能表,与本章要讲的「结构」无关,引入只会稀释主题

五、待补资料

本章有三处「想要但拿不到」的材料,全部登记在此,由后续版本处理:

缺什么 为什么需要 建议来源
FA1/FA2/FA3 的论文全文 现在 corpus 里只有标题+摘要(三个文件各 12 行)。于是:① FA1 的 IO 复杂度上界(Θ(N²d²M⁻¹))无法引用;② FA2 的「减少非矩阵 FLOP」与 warp 划分细节无法引用;③ FA3 的 warp specialization / TMA ping-pong / block quantization 细节无法引用;④ check_inf 这个名字只能引用官方例子里的注释,不能引用论文原文 arXiv 全文(https://arxiv.org/abs/2205.14135、/2307.08691、/2407.08608)的 HTML/PDF 快照,放到 论文 · 资料清单。核验方式:确认文件包含算法伪码(FA1 Algorithm 1、FA2 Algorithm 1、FA3 Algorithm 1)
block_causal_attention 的论文/设计说明 该例子实现了「噪声/干净分区 + block causal」的 mask,但锁定版本的例子目录里没有 README 说明它的建模背景,本章只能描述代码行为 上游 tile-ai/tilelang 的对应 PR 描述,或该算子背后的扩散语言模型论文
昇腾 T.pipelined 的教程页 A5 里引用的 tutorials/t_pipelined.md 在锁定 tag 的 docs 目录下未找到(该链接指向 ascendc_pto 分支) 上游 tile-ai/tilelang-ascend 对应版本的 docs/tutorials/t_pipelined.md;核验方式:find _vendor/tilelang-ascend-v0.1.2.000/docs -name 't_pipelined*' 为空即可确认缺口

本章未采用、也不建议补的材料:

  • 任何没有测量条件的 attention 性能数字(例如某些博客里的「x 倍加速」);
  • 昇腾 attention 的实测带宽/延迟——需要 NPU 与 msProf,本章的环境做不到, 读者要自己测(见习题 E14 的报告要求)。

六、引用规则提醒

  • 源码引用一律写成 _vendor/tilelang-v0.1.15/<相对路径> 或 _vendor/tilelang-ascend-v0.1.2.000/<相对路径>,并给出行号区间。
  • 资料引用一律写成 资料库索引。
  • 本章所有「v0.1.15 里有没有某某」的结论都在锁定工作树上核验过;code/05-official-example-map/ 与 code/06-ascend-map/ 两个脚本用 --strict 把这件事变成了可重复的检查。
  • 「已核验(静态)」≠「运行验证」。本章没有任何运行验证。

本章共 9 个二级小节;右上角目录在桌面端会跟随滚动高亮。