系统工程 · GPU Kernel
学习舱 / 系统工程 / GPU Kernel

GPU Kernel 入门:Triton / FlashAttention

整理中 深度 · 应用 更新 2026-09-22

前置 推理优化、Attention

GPU Kernel 入门:Triton / FlashAttention

一句话定位:并行训练把活分给一群卡、推理优化与 MLA把显存管省,本篇再下探一层——一张卡内部怎么把单个算子跑快:GPU 的执行模型、Triton 编程语言,以及注意力算子的标准答案 FlashAttention(省的是搬运不是计算)。前置:Attention 与 Transformer——被优化的对象就是那篇的注意力计算;建议先读推理优化,看看显存被 KV Cache 吃得多紧——本篇把镜头从显存容量推进到卡内的搬运。

先看一个反直觉的现象:算力常常不是瓶颈

直觉里 GPU 慢是因为算不动。

真相是:现代 GPU 的算力增速远超显存带宽增速——多数时候计算单元在等数据到位,而不是在计算。衡量一个算子快不快,先要看它是「计算受限」还是「搬运受限」:矩阵乘有大比例的乘加复用,偏计算受限;注意力要生成并搬运一个随序列长度 N 平方增长的中间矩阵(N×N,N 为序列长度),搬运常常占大头。

那「跑快一个算子」的正确姿势是什么?

答案不在算法书里,而在 GPU 的存储结构里——数据放的位置,决定了它被送到计算单元的速度。先看全景:

数据的家在哪,速度就在哪 显存层级(一台 GPU 内部) 片上 SRAM:约 20 MiB(A100 量级)· 快约一个量级 HBM 显存:几十 GB · 慢(相对片上) 算子快慢的分水岭:数据在 SRAM 还是 HBM 两种注意力算法的搬运账 普通:Q·K 写出 N×N → 读回 → 算 softmax → N×N 矩阵在 HBM 上来回两趟 FlashAttention:分块驻留 SRAM 内算完 → N×N 从不落地,只回写最终结果 (结果精确相同——数学等价,非近似) Triton 的角色:用「一块一块数据」的视角写算子,块内排布交给编译器——不用手写底层指令 FlashAttention 就是 Triton 教程的经典案例:分块 + 在线 softmax = 既省搬运又精确 读法:左看显存层级,右看两种算法的搬运账,底部是 Triton 的角色——省的是 IO 不是计算

图:GPU 显存层级与算子优化全景——SRAM 快而小、HBM 大而慢;FlashAttention 靠分块让中间矩阵不落地,搬运省了、结果精确

渐进拆解:从硬件到算法

GPU 执行模型:一群计算单元与一张存储地形图

一块 GPU 上有上百个流式多处理器(SM,计算单元的「车间」),每个车间里线程按 32 个一组(warp)整齐调度。

存储则是一座山:山顶是寄存器(每个计算单元私有的最小号存储),往下一级是片上共享内存(SRAM,整卡合计约 20 MiB、带宽比 HBM 快约一个数量级),山脚是 HBM 显存(几十 GB、容量大但相对片上慢)。写算子就是在这张地形图上搬东西:数据从 HBM 搬上 SRAM、算完搬回去——搬运的趟数,往往比乘加的次数更决定耗时。

判断一个算子偏向哪边的粗口径是「每搬运一个数,能摊上几次乘加」:

算子每搬一数摊几次乘加受限侧
大矩阵乘摊得多(大比例乘加复用)偏计算受限
注意力摊得少(还要搬运 N×N 中间矩阵)搬运受限
逐元素操作摊得少搬运受限

这就是「IO 受限」算子的真相,也是本篇一切优化的出发点。

Triton:用「块」的视角写算子

传统路线(CUDA)要开发者亲自管理上千线程的排布,门槛高、易出错;Triton(由 Tillet 等提出,OpenAI 2021 年开源 1.0 版并主导发展)把编程单位升级成「数据块」——而需要指令级极致手工控制(榨干硬件特性的特化内核)时,CUDA 仍占优势,这是两者的分工线。

你写「这个块的数据怎么算」,编译器负责块内的线程排布与共享内存优化——至于块铺到哪些 SM 上,仍由开发者以网格形式指定。一行块级代码背后是编译器生成的底层排布——写算子的心智负担从「指挥每个工人」降到「给每个车间发一张工序卡」。

Triton 因此成为 AI 算子圈的通用方言:PyTorch 官方的编译方案用它生成内核,FlashAttention、各模型的专用算子都有 Triton 版本。内核源码离开 GPU 环境读着抽象,本篇不贴大段源码——Triton 官方教程的融合注意力内核函数主体约百行,值得读的是它的骨架:动手段提炼了一段块级最小例(四件套:程序号、块下标、搬运、写回),完整注意力内核见官方教程 06。

FlashAttention 与在线 softmax:难点与解法

难点在 softmax 是「全行统计」。注意力打分后要过 softmax(见注意力篇),而 softmax 的定义要求知道整行的最大值与总和——可分块计算时每块只看到行的一部分,怎么算出精确的整行 softmax?这是注意力分块化的真正拦路虎。FlashAttention 论文拿来攻克它的「在线 softmax」技巧,出自更早的在线归一化工作(2018,NVIDIA),FA 的贡献是把它与分块整合成 IO 感知的完整方案。

在线 softmax:带着「运行统计量」流式更新。 解法是让每一块计算都携带三个运行统计量——目前为止见过的最大值 m、指数和 l、加权输出累加 acc。每来一个新块:更新最大值,用新旧最大值之差把已有的 l 和 acc 按比例缩放,再并入新块的贡献。

数学上可以证明(本篇动手实算):这样流式算出的结果与「整行一次算」的 softmax 精确等价——分块不引入任何近似(浮点实现因求和顺序不同会有 1e-16 量级的舍入差,见动手一节)。至于第二处难关「中间矩阵必须落地」:既然块的统计量随身携带,N×N 打分矩阵就在 SRAM 里算完即弃、从不写回 HBM——省搬运与省显存,在这一刻成了同一件事。

在线 softmax:三个随身统计量流过每一块 一行打分 切 4 块流式处理 块 1 → 更新 m·l·acc 块 2 → 缩放+并入 块 3 → 缩放+并入 块 4 → 收尾归一 m:见过最大值(防指数溢出)| l:指数和(分母)| acc:输出累加(分子) 每来一块:更新 m → 用 m 的新旧差把 l、acc 缩放 → 并入新块贡献 这条流水线买到三样东西 ① 精确:流式结果与全行 softmax 精确等价 ② 省显存:N×N 矩阵不落 HBM(线性于 N) ③ 省 IO:SRAM 内算完即弃,只回写最终输出 实测感受量级(N=4096、bf16) N×N 中间矩阵:32 MiB 显存 + 64 MiB 往返 FlashAttention:两者全免(本篇动手实算) 论文口径:注意力算子提速 2~4 倍 · 显存最高省 20 倍 「省显存」与「省 IO」是一件事的两面:不落地的中间结果,既不占地方、也不花搬运费 读法:②③是 FlashAttention 的全部收益来源——它没有少算任何一步乘加,省的全是搬运

图:在线 softmax 分块流程——三个运行统计量流过每一块,N×N 从不落地;精确、省显存、省 IO 三合一

大白话:普通注意力像「把 500 页草稿纸搬上下楼、算完再对答案」,搬得人仰马翻;FlashAttention 是「草稿在桌上(SRAM)写完就撕」——省的全是上下楼的力气,题还是那些题,答案一字不差。Triton 则是不用学指挥千人的口令,写好每张工序卡就行。

技术定义:Triton 是以数据块为编程单位的算子语言——编译器负责块内的线程排布(warp 级)与访存优化,块到 SM 的铺排由开发者以网格形式指定。FlashAttention 是 IO 感知的精确注意力算法:分块(tiling)+在线 softmax(m/l/acc 三统计量随块更新)保证与全行 softmax 精确等价;N×N 矩阵不写入 HBM,显存由 O(N²) 降为 O(N),注意力算子提速 2~4 倍(端到端 15% 到 3 倍量级)。

动手:CPU 上验证「在线 softmax 是精确等式」

Triton 内核需要 GPU 环境才能跑(完整可运行的内核源码见 Triton 官方教程 06);无需 GPU 也能验证的,是在线 softmax 的数学等价性——它是纯逻辑。本节三段:

  1. 分块流式 vs 全行一次算,验证在线 softmax 是精确等式。
  2. 算一笔 N=4096 的搬运账,感受「省的是 IO」的量级。
  3. Triton 逐元素相加最小例,把「工序卡」心智落成代码。

第一段:分块流式 vs 全行一次算,结果应完全一致:

import numpy as np
rng = np.random.default_rng(7)
scores = rng.normal(size=(4, 16)) * 3        # 4 个查询 × 16 个键的打分

def softmax_row(x):                          # 基准:整行一次算
    e = np.exp(x - x.max(axis=1, keepdims=True))
    return e / e.sum(axis=1, keepdims=True)

full = softmax_row(scores)
acc = np.zeros((4, 16)); m = np.full(4, -np.inf); l = np.zeros(4)
for s in range(0, 16, 4):                    # 在线 softmax:每块 4 个键
    blk = scores[:, s:s+4]
    m_new = np.maximum(m, blk.max(axis=1))
    l = l * np.exp(m - m_new) + np.exp(blk - m_new[:, None]).sum(axis=1)
    acc = acc * np.exp(m - m_new)[:, None]   # 旧贡献按最大值变化缩放
    for j in range(blk.shape[1]):
        acc[:, s+j] = np.exp(blk[:, j] - m_new)
    m = m_new
print(np.abs(full - acc / l[:, None]).max())
# 实算输出:1.11e-16 —— 数学上精确等价、零近似;
# 1e-16 是浮点求和顺序不同带来的舍入差(非逐位一致)

第二段:算一笔搬运账,感受「省的是 IO」的量级(N=4096、bf16 两字节):

N = 4096
io_naive = 2 * N * N * 2 / 2**20             # N×N 打分矩阵写出+读回
mem = N * N * 2 / 2**20                      # 它占的显存
print(f"中间矩阵往返 {io_naive:.0f} MiB | 显存 {mem:.0f} MiB")
# 实算输出:中间矩阵往返 64 MiB | 显存 32 MiB —— FlashAttention 两项全免

账本读法:第一段的示意代码为了逐位对照保留了完整打分行,真实内核的输出累加只有 N×d 大小、从不存打分行——这正是「N×N 不落地」的实现形态。第一段证明「分块零近似」(省 IO 的前提是不算错),第二段证明「不落地省真金白银」——序列越长两项收益越大(都是 N² 增长)。这个「先证等价、再算搬运」的顺序,就是读任何内核优化的标准姿势。

第三段看 Triton 编程模型本尊——用最小例(逐元素相加,Triton 世界的 hello world;需 GPU 环境运行,结构即官方教程同款骨架)把「工序卡」心智落成代码:

import triton
import triton.language as tl

@triton.jit
def add_kernel(x_ptr, y_ptr, out_ptr, n, BLOCK: tl.constexpr):
    pid = tl.program_id(0)                     # 工序卡号:本程序负责第几个数据块
    offs = pid * BLOCK + tl.arange(0, BLOCK)   # 这张卡名下的元素下标
    mask = offs < n                            # 末块可能不满,越界位裁掉
    x = tl.load(x_ptr + offs, mask=mask)       # 搬运上片(HBM → 片上)
    y = tl.load(y_ptr + offs, mask=mask)
    tl.store(out_ptr + offs, x + y, mask=mask) # 算完写回显存

# out = add_kernel[grid](x, y, out, n, BLOCK=1024)  # grid=(块数,),如 (triton.cdiv(n, BLOCK),)——发车间

骨架读法:开发者只写「一张工序卡干什么」(本块的下标、搬什么、算什么、写回哪),块内线程排布与片上内存交给编译器——这就是「块为编程单位」的全部含义;tl.load/tl.store 两个搬运动作,正是上文「搬运的趟数决定耗时」发生在代码里的位置。注意力内核(官方教程 06)比它多的是循环与在线 softmax 统计量,骨架不变。

边界与取舍

什么时候需要自己写内核?

判据是「热点是否真的卡在这个算子」,按顺序走:

  1. 先用 profiler 定位热点;热点在数据加载或网络 → 写内核是南辕北辙,到此为止。
  2. 热点在注意力、且标准实现满足不了(自定义注意力变体、特殊掩码结构)→ 才值得动手。
  3. 用 Triton 重写该算子。

多数场景的最优解仍是组合现成算子——PyTorch 的融合注意力入口(scaled_dot_product_attention)背后就是 FlashAttention 系内核,先查它有没有覆盖你的形状。

FlashAttention 不是万能的

它优化的是注意力的搬运,不减少注意力本身的计算量(乘加次数一次没少)——序列不太长、瓶颈在别处时收益有限;它的分块依赖 SRAM 容量约束,块大小的选择与硬件绑定,跨硬件移植要重新调参(v2/v3 的演化很大程度就是适配新一代卡)。与本篇开头的判断呼应:它是「IO 受限」时代的答案,若未来算力与带宽的剪刀差收敛,下一代的优化目标会换位置。

坏味道预警:把「融合」当万能药

融合算子省的是中间结果的落地搬运,前提是「确有中间结果在搬运」——有人对已经融合好的库函数再手写一遍「融合版」,结果更慢(库版本的块调度与硬件适配更深)。写内核前先读 profiler 的搬运计数,确认「中间结果落地」真的在耗时间里,再动手。

自测

合上文章先自己答,再展开对照:

FlashAttention 快在省显存还是省 IO?为什么这两件事是一件事?

快在省 IO——它一次乘加都没少算,省的是 N×N 中间矩阵在 HBM 上的往返搬运(N=4096、bf16 口径约 64 MiB 往返)。「省显存」与「省 IO」是一件事的两面:在线 softmax 让分块计算随身携带运行统计量(最大值/指数和/累加输出),N×N 打分矩阵在片上 SRAM 内算完即弃、从不写入 HBM——同一件事的两份收益:不落地所以不占显存(O(N²) 降为 O(N),论文 4.3 节注意力算子显存基准:相对精确注意力基线最高省 20 倍),不落地所以不用搬运(注意力算子提速 2~4 倍)。它的「快」精确成立的前提也在这:分块结果与全行 softmax 精确等价(本篇动手实算差值 1.11e-16,浮点舍入量级),没有任何近似损失。

为什么注意力分块化的拦路虎是 softmax?在线 softmax 怎么解决的?

拦路虎在 softmax 的「全行统计」性质:它的定义需要整行的最大值(防指数溢出)与整行的指数和(分母),而分块计算时每块只看到行的一部分——拿不到这两样,就没法归一化。在线 softmax 的解法是给每个查询行配三个运行统计量:目前最大值 m、运行指数和 l、输出累加 acc;每来一个新块就更新 m,并利用 m 的新旧差把已有的 l 与 acc 按比例缩放(数学上等价于「如果早知道这个最大值,之前的部分和应该是什么」),再并入新块贡献;流到行尾时 acc÷l 就是精确结果。本篇动手段的 CPU 实算显示与全行 softmax 最大差 1.11e-16(浮点舍入量级)——分块零近似。

Triton 与手写 CUDA 的分工是什么?什么时候轮到写内核?

Triton 把编程单位从「线程」升级为「数据块」:开发者写每块数据怎么算,块内的线程排布与访存优化交给编译器,块铺到哪些 SM 由开发者指定——心智负担从指挥每个工人降到给车间发工序卡,因此成为 AI 算子的通用方言(PyTorch 编译方案与 FlashAttention 的 Triton 版都在其上)。CUDA 仍在需要极致手工控制(指令级排布、特殊硬件特性)的场合占优。轮到写内核的判据链:先用 profiler 确认热点真的在某个算子(而非数据加载或网络)→ 该算子是自定义结构、现成库没覆盖 → 再用 Triton 重写,并顺手核对「中间结果是否真的在落地搬运」(融合的收益来源)——多数场景组合现成算子仍是最优解。

参考与延伸

下一站进入 Harness 工程:模型与算子都已就位,怎么让模型稳定地替人干活——Agent 运行时的设计与纪律,从「什么是 Harness」讲起。

本节点大纲与自测题(4 个子点 · 3 问)

大纲

  • GPU 执行模型:SM、warp、显存层级
  • Triton 编程模型入门
  • FlashAttention 核心思想:tiling 与 online softmax
  • 融合算子的收益来源:省的是 IO 不是计算

自测——答不上来就是缺口

  • FlashAttention 快在省显存还是省 IO?为什么这两件事是一件事?
  • 为什么注意力分块化的拦路虎是 softmax?在线 softmax 怎么解决?
  • Triton 与手写 CUDA 的分工是什么?什么时候轮到写内核?