FlashDecoding 数学推导:传统 m/l/o 方法与 LSE 方法
参考网页:
一、公式作用概述
FlashDecoding 是一种用于大语言模型(LLM)推理解码阶段(Decoding Stage) 的高效注意力计算算法。在 Decoding 阶段,模型每次只生成一个新 token,因此 Query 的长度为 1,这导致 GPU 的并行度严重不足(大量流式多处理器 SM 处于空闲状态)。FlashDecoding 的核心思想是:将 KV Cache(键值缓存)沿序列维度切分为多个 Tile(子块),分配到不同的 SM 上并行计算局部注意力结果,最后通过数学合并技术将各局部结果合并为全局正确的输出。这种方法在不改变计算结果精度的前提下,显著提升了长序列解码的 GPU 利用率。
本文档将介绍两种数学上等价的 FlashDecoding 实现方法,并分别证明它们与标准 Attention 的等价性:
-
传统方法(m/l/o 三元组法):每个 Tile 存储局部最大值 m(b)、局部指数和 ℓ(b) 和局部输出 o(b)。
-
LSE 方法(Log-Sum-Exp 二元组法):每个 Tile 存储局部 LSE 值 S(b)=LSE(Tile b) 和局部输出 o(b)。
两种方法均与标准 Attention 数学等价(忽略浮点舍入误差),但 LSE 方法在通信量和实现简洁性上更具优势。
二、两种 FlashDecoding 方法的完整推导过程
2.1 问题背景与动机
2.1.1 标准 Attention 的定义
给定单个 Query 向量 q∈R1×d(解码阶段 Query 序列长度为 1),以及 Key 矩阵 K∈RNkv×d 和 Value 矩阵 V∈RNkv×d,其中 Nkv 是 KV Cache 的序列长度(可能很长),d 是注意力头维度。标准的缩放点积注意力(Scaled Dot-Product Attention)定义为:
Attention(q,K,V)=softmax(dqK⊤)V
展开为逐元素形式。设 s=dqK⊤∈R1×Nkv 为注意力分数向量,sj=dq⋅Kj 为第 j 个位置的分数,则:
o=Attention(q,K,V)=j=1∑Nkv∑k=1Nkvexp(sk)exp(sj)⋅Vj
【知识卡片:Softmax 函数】
- 定义:Softmax 函数将一个实数向量转换为概率分布,使得所有输出值在 (0,1) 之间且和为 1。
- 公式:对于向量 x=[x1,x2,…,xn],softmax(xi)=∑j=1nexp(xj)exp(xi)。
2.1.2 解码阶段的并行度瓶颈
在预填充阶段(Prefill Stage),输入序列长度 Nq 较大,Query 矩阵 Q∈RNq×d 有很多行。FlashAttention 的做法是:将 Q 沿行维度切分为多个 Tile,不同 Tile 分配给不同的 SM 并行计算。每个 SM 处理一部分 Query,但需要使用完整的 K 和 V。
然而,在解码阶段(Decoding Stage),模型正在自回归地生成新 token,每次只需要计算一个 Query(即新 token 的 Query),所以 q∈R1×d。这意味着:
- 无法通过切分 Query 来获得并行度(Query 只有一行)。
- 如果使用 FlashAttention 的原版策略,只有一个 SM(或少量 SM)在工作,大量 SM 空闲。
- 同时,KV Cache 的序列长度 Nkv 可能非常长(例如 32K、64K 甚至更长),读取 KV Cache 成为瓶颈。
2.1.3 FlashDecoding 的核心思想
FlashDecoding 的解决方案是反转并行策略:
-
不再切分 Query(因为 Query 只有一行)。
-
改为切分 KV Cache:将 K 和 V 沿序列维度切分为 B 个 Tile:
K=[K(1);K(2);…;K(B)],V=[V(1);V(2);…;V(B)]
其中每个 K(b),V(b)∈RNtile×d。
-
每个 SM 处理一个 KV Tile:SM b 计算 q 与 K(b),V(b) 的局部注意力结果。
-
使用数学合并技术合并局部结果:由于 softmax 涉及全局归一化,各 SM 的局部结果不能直接相加,需要通过数学技巧进行归一化合并。
2.2 传统方法(m/l/o 三元组法)
2.2.1 每个 Tile 的局部计算
对于第 b 个 Tile(b=1,2,…,B),SM b 计算以下三个局部统计量:
(1)局部最大值(running max):
m(b)=j=1maxNtilesj(b)
其中 sj(b)=dq⋅Kj(b) 是 Tile b 中第 j 个位置的注意力分数。m(b) 用于数值稳定,避免指数溢出。
(2)局部指数和(running sum):
ℓ(b)=j=1∑Ntileexp(sj(b)−m(b))
这是 Tile b 内所有(经数值稳定后的)指数值之和。
(3)局部加权输出:
o(b)=ℓ(b)∑j=1Ntileexp(sj(b)−m(b))⋅Vj(b)∈R1×d
这是 Tile b 的局部 softmax 结果,即 Value 的加权平均,权重来自 Tile 内部的 softmax 归一化。
2.2.2 全局合并公式
目标:给定所有 Tile 的局部统计量 {(m(b),ℓ(b),o(b))}b=1B,求全局正确的注意力输出 ofinal。
步骤 1:求全局最大值
mglobal=b=1maxBm(b)
q 对所有 Key 的注意力分数中的真正最大值。
步骤 2:计算全局归一化因子
ℓglobal=b=1∑Bexp(m(b)−mglobal)⋅ℓ(b)
推导依据:
ℓglobal=b=1∑Bj=1∑Ntileexp(sj(b)−mglobal)=b=1∑Bexp(m(b)−mglobal)j=1∑Ntileexp(sj(b)−m(b))=b=1∑Bexp(m(b)−mglobal)⋅ℓ(b)
步骤 3:合并局部输出
ofinal=ℓglobal∑b=1Bexp(m(b)−mglobal)⋅ℓ(b)⋅o(b)
推导依据:
ofinal=∑b=1B∑j=1Ntileexp(sj(b)−mglobal)∑b=1B∑j=1Ntileexp(sj(b)−mglobal)⋅Vj(b)
分子展开:
j=1∑Ntileexp(sj(b)−mglobal)⋅Vj(b)=exp(m(b)−mglobal)j=1∑Ntileexp(sj(b)−m(b))⋅Vj(b)=exp(m(b)−mglobal)⋅ℓ(b)⋅o(b)
代入即得全局合并公式。
2.2.3 传统方法与标准 Attention 的等价性证明
定理:传统 FlashDecoding 方法的分块计算结果与全序列直接计算的标准 Attention 结果完全等价。
证明:
全序列直接计算的标准 Attention 输出为:
odirect=∑j=1Nkvexp(sj−mglobal)∑j=1Nkvexp(sj−mglobal)⋅Vj
将序列按 Tile 划分后,分子和分母都可以按 Tile 分解。对于 Tile b 内部的元素,利用 m(b) 进行分解:
exp(sj−mglobal)=exp(sj−m(b))⋅exp(m(b)−mglobal)
因此,Tile b 对分子的贡献为:
j∈Tile b∑exp(sj−mglobal)⋅Vj=exp(m(b)−mglobal)j∈Tile b∑exp(sj−m(b))⋅Vj=exp(m(b)−mglobal)⋅ℓ(b)⋅o(b)
分母为:
j=1∑Nkvexp(sj−mglobal)=b=1∑Bexp(m(b)−mglobal)⋅ℓ(b)=ℓglobal
因此:
odirect=ℓglobal∑b=1Bexp(m(b)−mglobal)⋅ℓ(b)⋅o(b)=ofinal
证毕。
2.3 LSE 方法(Log-Sum-Exp 二元组法)
2.3.1 从三元组到二元组的思路
传统方法需要在 Tile 之间传递三个量 (m(b),ℓ(b),o(b))。LSE 方法的核心洞察是:可以将 m(b) 和 ℓ(b) 融合为一个标量 S(b),从而将通信量从三个量减少到两个量。
【知识卡片:Log-Sum-Exp (LSE) 函数】
- 定义:对于向量 x=(x1,x2,…,xn)∈Rn,LSE(x)=log(∑i=1nexp(xi))。
- 关键性质:exp(LSE(x))=∑i=1nexp(xi),恰好是 softmax 的分母。
- 数值稳定版本:LSE(x)=m+log(∑i=1nexp(xi−m)),其中 m=maxixi。
- 证明过程见: softmax 修正公式:Log-Sum-Exp (LSE) 方法
2.3.2 定义局部 LSE 值 S(b)
对于第 b 个 Tile,定义其局部 LSE 值为该 Tile 中所有注意力分数的 log-sum-exp:
S(b)=LSE(s(b))=log(j=1∑Ntileexp(sj(b)))
利用 safe LSE 技巧(以 m(b) 为参考最大值),数值稳定地计算:
S(b)=m(b)+log(j=1∑Ntileexp(sj(b)−m(b)))=m(b)+log(ℓ(b))
推导验证:
j=1∑Ntileexp(sj(b))=j=1∑Ntileexp(m(b)+sj(b)−m(b))=exp(m(b))⋅j=1∑Ntileexp(sj(b)−m(b))=exp(m(b))⋅ℓ(b)
取对数:
S(b)=log(exp(m(b))⋅ℓ(b))=m(b)+log(ℓ(b))
关键观察:S(b)=m(b)+log(ℓ(b)),这说明 S(b) 完全由 m(b) 和 ℓ(b) 决定,二者编码了相同的信息。
2.3.3 LSE 方法的局部计算
LSE 方法下,每个 Tile 只需计算和存储两个量:
| 符号 |
名称 |
计算公式 |
| S(b) |
局部 LSE 值 |
S(b)=log(∑j=1Ntileexp(sj(b)))=m(b)+log(ℓ(b)) |
| o(b) |
局部输出 |
o(b)=ℓ(b)∑j=1Ntileexp(sj(b)−m(b))⋅Vj(b)(与传统方法相同) |
2.3.4 LSE 方法的全局合并公式
目标:给定所有 Tile 的局部量 {(S(b),o(b))}b=1B,求全局正确的注意力输出 ofinal。
步骤 1:计算全局 LSE
Sglobal=LSE(S(1),S(2),…,S(B))=log(b=1∑Bexp(S(b)))
步骤 2:计算全局输出
ofinal=b=1∑Bexp(S(b)−Sglobal)⋅o(b)
推导依据:
全局正确的 Attention 输出为:
ofinal=∑b=1B∑j=1Ntileexp(sj(b))∑b=1B∑j=1Ntileexp(sj(b))⋅Vj(b)
分子(Tile b 的贡献):
j=1∑Ntileexp(sj(b))⋅Vj(b)=exp(m(b))⋅j=1∑Ntileexp(sj(b)−m(b))⋅Vj(b)=exp(m(b))⋅ℓ(b)⋅o(b)=exp(S(b))⋅o(b)
分母:
b=1∑Bj=1∑Ntileexp(sj(b))=b=1∑Bexp(S(b))=exp(Sglobal)
因此:
ofinal=exp(Sglobal)∑b=1Bexp(S(b))⋅o(b)=b=1∑Bexp(S(b)−Sglobal)⋅o(b)
2.3.5 LSE 合并算子 ⊕ 及其结合律
为书写简洁,定义合并算子 ⊕ 作用于二元组 (o,S):
[o(I∪J)S(I∪J)]=[o(I)S(I)]⊕[o(J)S(J)]
其中 ⊕ 的具体运算规则为:
[o1S1]⊕[o2S2]=exp(S1)+exp(S2)exp(S1)⋅o1+exp(S2)⋅o2log(exp(S1)+exp(S2))
关键性质:该算子 ⊕ 满足结合律(associative),即:
(A⊕B)⊕C=A⊕(B⊕C)
2.3.6 LSE 方法与标准 Attention 的等价性证明
定理:LSE FlashDecoding 方法的分块计算结果与全序列直接计算的标准 Attention 结果完全等价。
证明:
全序列直接计算的标准 Attention 输出为:
odirect=∑j=1Nkvexp(sj)∑j=1Nkvexp(sj)⋅Vj
将序列按 Tile 划分后,利用 exp(S(b))=∑j∈Tile bexp(sj)(由 LSE 定义直接可得),全局分母为:
j=1∑Nkvexp(sj)=b=1∑Bj∈Tile b∑exp(sj)=b=1∑Bexp(S(b))=exp(Sglobal)
全局分子中,Tile b 的贡献为:
j∈Tile b∑exp(sj)⋅Vj=exp(S(b))⋅o(b)
因此:
odirect=exp(Sglobal)∑b=1Bexp(S(b))⋅o(b)=b=1∑Bexp(S(b)−Sglobal)⋅o(b)=ofinal
证毕。
【直观理解】
两种 FlashDecoding 方法的本质区别在于信息编码方式:
- 传统方法将指数和 ∑exp(sj) 编码为两个数 (m(b),ℓ(b)) 的乘积形式 exp(m(b))⋅ℓ(b)。合并时需要先统一参考系(减去全局最大值 mglobal)。
- LSE 方法将指数和直接编码为对数空间的一个标量 S(b)=log(∑exp(sj))。合并时直接使用对数空间的加法规则。
两种编码方式完全等价(因为 S(b)=m(b)+log(ℓ(b))),只是"坐标系"不同。
三、两种 FlashDecoding 方法的完整算法流程与核心代码
3.1 传统方法(m/l/o 三元组法)
3.1.1 完整算法流程
输入:Query 向量 q∈R1×d,Key 矩阵 K∈RNkv×d,Value 矩阵 V∈RNkv×d,Tile 大小 Ntile。
输出:全局注意力结果 ofinal∈R1×d。
阶段 1:各 SM 并行计算局部结果(对每个 Tile 并行执行)
对每个 Tile b=1,2,…,B:
(1a) 提取 KV Tile:
K(b)=K[(b−1)⋅Ntile:min(b⋅Ntile,Nkv),:]
V(b)=V[(b−1)⋅Ntile:min(b⋅Ntile,Nkv),:]
(1b) 计算注意力分数:
s(b)=dqK(b)⊤
(1c) 计算局部统计量(数值稳定的 Online Softmax):
初始化:
m1=s1(b),ℓ1=1,o1=V1(b)
对 j=2,3,…,Ntile(b) 递推:
mj=max(mj−1,sj(b))
ℓj=ℓj−1⋅exp(mj−1−mj)+exp(sj(b)−mj)
oj=ℓjℓj−1⋅exp(mj−1−mj)⋅oj−1+exp(sj(b)−mj)⋅Vj(b)
Tile 最终结果:
m(b)=mNtile(b),ℓ(b)=ℓNtile(b),o(b)=oNtile(b)
数值稳定的 Online Softmax 推导见 FlashDecoding 数学推导(一):传统方法)
注:与 Online Softmax 相对的直接计算方式(先算完整 Tile 再做 softmax)
- 注意力分数:s(b)=dqK(b)⊤∈R1×Ntile
- 这是 Query q 与 Tile b 中所有 Key 的相似度分数向量。
- 局部最大值(running max):m(b)=maxj=1Ntilesj(b)
- 局部指数和(running sum):ℓ(b)=∑j=1Ntileexp(sj(b)−m(b))
- 该 Tile 内所有(经数值稳定后的)指数值之和,充当局部 softmax 的分母。
- 局部加权输出:o(b)=ℓ(b)∑j=1Ntileexp(sj(b)−m(b))⋅Vj(b)∈R1×d
- 该 Tile 的局部 softmax 结果,即 Value 的加权平均,权重来自局部 softmax。
阶段 2:全局归约合并(一个轻量级的 Reduce Kernel)
(2a) 求全局最大值:
mglobal=b=1maxBm(b)
(2b) 计算全局归一化因子:
ℓglobal=b=1∑Bexp(m(b)−mglobal)⋅ℓ(b)
(2c) 合并局部输出:
ofinal=ℓglobal∑b=1Bexp(m(b)−mglobal)⋅ℓ(b)⋅o(b)
3.1.2 核心代码示例(单流顺序合并版本)
import torch
import torch.nn.functional as F
import math
class FlashDecodingTraditional:
"""传统方法(m/l/o 三元组法)的 Flash-Decoding 实现"""
def __init__(self, d_model: int = 512, num_heads: int = 8):
self.d_model = d_model
self.num_heads = num_heads
self.head_dim = d_model // num_heads
def traditional_attention(self, q, k, v):
"""标准 Attention(用于验证正确性)"""
scores = torch.matmul(q, k.transpose(-2, -1)) / math.sqrt(self.head_dim)
attention_weights = F.softmax(scores, dim=-1)
output = torch.matmul(attention_weights, v)
return output, attention_weights
def flash_decoding_traditional(self, q, k, v, tile_size_kv: int = 256):
"""
传统方法 Flash-Decoding:存储 m^(b), l^(b), o^(b) 三个量
单流顺序处理所有 Tile
"""
batch_size, num_heads, seq_len_q, head_dim = q.shape
seq_len_kv = k.shape[2]
num_tiles = (seq_len_kv + tile_size_kv - 1) // tile_size_kv
# 初始化全局归约变量(对应全局 m_global, l_global, o_global)
global_max = torch.full(
(batch_size, num_heads, seq_len_q, 1),
-float('inf'), device=q.device, dtype=q.dtype
)
numerator = torch.zeros(
batch_size, num_heads, seq_len_q, head_dim,
device=q.device, dtype=q.dtype
)
denominator = torch.zeros(
batch_size, num_heads, seq_len_q, 1,
device=q.device, dtype=q.dtype
)
# 阶段 1:逐个 Tile 计算局部统计量并实时合并到全局
for tile_idx in range(num_tiles):
start_idx = tile_idx * tile_size_kv
end_idx = min(start_idx + tile_size_kv, seq_len_kv)
k_tile = k[:, :, start_idx:end_idx, :]
v_tile = v[:, :, start_idx:end_idx, :]
# (1b) 计算注意力分数
S_tile = torch.matmul(q, k_tile.transpose(-2, -1)) / math.sqrt(head_dim)
# (1c) 计算局部统计量
m_tile = S_tile.max(dim=-1, keepdim=True).values # m^(b)
exp_tile = torch.exp(S_tile - m_tile)
l_tile = exp_tile.sum(dim=-1, keepdim=True) # l^(b)
o_tile = torch.matmul(exp_tile, v_tile) / l_tile # o^(b)(已归一化)
# 阶段 2:将当前 Tile 合并到全局(实时归约)
# (2a) 更新全局最大值
new_global_max = torch.maximum(global_max, m_tile)
# (2b) 调整历史累积值到新的全局最大值尺度
if tile_idx > 0:
scale = torch.exp(global_max - new_global_max)
numerator = numerator * scale
denominator = denominator * scale
global_max = new_global_max
# (2c) 累加当前 Tile 的贡献(调整到全局尺度)
tile_scale = torch.exp(m_tile - global_max)
numerator = numerator + o_tile * l_tile * tile_scale
denominator = denominator + l_tile * tile_scale
# 最终归一化
final_output = numerator / denominator
return final_output
3.1.3 核心代码示例(多流分布式版本)
def flash_decoding_distributed_tiling(self, q, k, v,
tile_size_kv: int = 256,
num_streams: int = 5):
"""
传统方法 Flash-Decoding:多流并行处理,最后树形归约
每个流独立累积 (O, M, L),最后合并
"""
batch_size, num_heads, seq_len_q, head_dim = q.shape
seq_len_kv = k.shape[2]
num_tiles = (seq_len_kv + tile_size_kv - 1) // tile_size_kv
# 创建流数组:每个流有独立的 (O, M, L)
stream_O = [] # 加权和数组
stream_M = [] # 最大值数组
stream_L = [] # exp 和数组
for _ in range(num_streams):
stream_O.append(torch.zeros_like(q))
stream_M.append(torch.full(
(batch_size, num_heads, seq_len_q, 1),
-float('inf'), device=q.device, dtype=q.dtype
))
stream_L.append(torch.zeros_like(stream_M[-1]))
# 阶段 1:模拟流并行处理 tile
for tile_idx in range(num_tiles):
stream_id = tile_idx % num_streams
start_idx = tile_idx * tile_size_kv
end_idx = min(start_idx + tile_size_kv, seq_len_kv)
k_tile = k[:, :, start_idx:end_idx, :]
v_tile = v[:, :, start_idx:end_idx, :]
# 当前流的累加器
O_curr = stream_O[stream_id]
M_curr = stream_M[stream_id]
L_curr = stream_L[stream_id]
# 计算当前 tile
S_tile = torch.matmul(q, k_tile.transpose(-2, -1)) / math.sqrt(head_dim)
m_tile = S_tile.max(dim=-1, keepdim=True).values
# Online Softmax 递推:合并当前 tile 到流累加器
new_M = torch.maximum(M_curr, m_tile)
scale = torch.exp(M_curr - new_M)
O_curr = O_curr * scale
L_curr = L_curr * scale
exp_tile = torch.exp(S_tile - new_M)
l_tile = exp_tile.sum(dim=-1, keepdim=True)
stream_O[stream_id] = O_curr + torch.matmul(exp_tile, v_tile)
stream_L[stream_id] = L_curr + l_tile
stream_M[stream_id] = new_M
# 阶段 2:树形归约所有流
return self.reduce_stream_arrays(stream_O, stream_M, stream_L)
def reduce_stream_arrays(self, stream_O, stream_M, stream_L):
"""树形归约:两两合并流,直到只剩一个"""
current_O = stream_O.copy()
current_M = stream_M.copy()
current_L = stream_L.copy()
remaining = len(current_O)
while remaining > 1:
next_O, next_M, next_L = [], [], []
for i in range(0, remaining, 2):
if i + 1 < remaining:
O1, M1, L1 = current_O[i], current_M[i], current_L[i]
O2, M2, L2 = current_O[i+1], current_M[i+1], current_L[i+1]
# 全局合并公式
new_M = torch.maximum(M1, M2)
scale1 = torch.exp(M1 - new_M)
scale2 = torch.exp(M2 - new_M)
merged_O = O1 * scale1 + O2 * scale2
merged_L = L1 * scale1 + L2 * scale2
next_O.append(merged_O)
next_M.append(new_M)
next_L.append(merged_L)
else:
next_O.append(current_O[i])
next_M.append(current_M[i])
next_L.append(current_L[i])
current_O, current_M, current_L = next_O, next_M, next_L
remaining = len(current_O)
return current_O[0] / current_L[0]
| 代码变量 |
对应数学符号 |
含义 |
m_tile / M_curr |
m(b) |
局部/全局最大值 |
l_tile / L_curr |
ℓ(b) |
局部/全局指数和 |
o_tile / O_curr |
o(b) |
局部/全局加权输出 |
global_max |
mglobal |
全局最大值 |
numerator |
∑bexp(m(b)−mglobal)⋅ℓ(b)⋅o(b) |
全局分子 |
denominator |
ℓglobal |
全局归一化因子 |
scale = torch.exp(M1 - new_M) |
exp(m(1)−mglobal) |
尺度调整因子 |
3.2 LSE 方法(S/o 二元组法)
3.2.1 完整算法流程
输入:Query 向量 q∈R1×d,Key 矩阵 K∈RNkv×d,Value 矩阵 V∈RNkv×d,Tile 大小 Ntile,流数量 Nstreams。
输出:全局注意力结果 ofinal∈R1×d。
阶段 1:各 SM 并行计算局部结果(对每个 Tile 并行执行)
对每个 Tile b=1,2,…,B:
(1a) 提取 KV Tile(同传统方法)。
(1b) 计算注意力分数(同传统方法):
s(b)=dqK(b)⊤
(1c) 计算 LSE 二元组:
先计算局部最大值和局部指数和(用于数值稳定):
m(b)=j=1maxNtilesj(b),ℓ(b)=j=1∑Ntileexp(sj(b)−m(b))
再计算局部 LSE 值:
S(b)=m(b)+log(ℓ(b))
局部输出(与传统方法相同):
o(b)=ℓ(b)∑j=1Ntileexp(sj(b)−m(b))⋅Vj(b)
阶段 2:流内合并(每个流独立累积其分配的 Tile)
对于每个流 s=0,1,…,Nstreams−1:
初始化:(oacc(s),Sacc(s))=(zero-tensor,−∞-tensor)
对流中每个新 Tile (oi,Si),使用 LSE 合并算子 ⊕:
[oacc(s)Sacc(s)]←[oacc(s)Sacc(s)]⊕[oiSi]
其中数值稳定的合并实现为:
Smax=max(Sacc,Si),Smin=min(Sacc,Si)
Smerged=Smax+log(1+exp(Smin−Smax))
omerged=1oacc⋅exp(Sacc−Smerged)+oi⋅exp(Si−Smerged)
流内合并公式的完整推导
合并算子 ⊕ 的定义(已在 2.3.5 节给出)为:
[oaccSacc]⊕[oiSi]=exp(Sacc)+exp(Si)exp(Sacc)⋅oacc+exp(Si)⋅oilog(exp(Sacc)+exp(Si))
步骤 1:推导合并后的 LSE 值 Smerged
从定义出发:
Smerged=log(exp(Sacc)+exp(Si))
当 ∣Sacc−Si∣ 很大时,直接计算 exp(Sacc)+exp(Si) 可能数值溢出。使用 safe LSE 技巧:
设 Smax=max(Sacc,Si),Smin=min(Sacc,Si),则:
Smerged=log(exp(Sacc)+exp(Si))=log(exp(Smax)+exp(Smin))=log(exp(Smax)⋅(1+exp(Smax)exp(Smin)))=log(exp(Smax))+log(1+exp(Smin−Smax))=Smax+log(1+exp(Smin−Smax))
由于 Smin−Smax≤0,exp(Smin−Smax)∈(0,1],不会溢出。
步骤 2:推导合并后的输出 omerged
从 ⊕ 的定义出发:
omerged=exp(Sacc)+exp(Si)exp(Sacc)⋅oacc+exp(Si)⋅oi
分母恰好等于 exp(Smerged)(由 Smerged 的定义),因此:
omerged=exp(Smerged)exp(Sacc)⋅oacc+exp(Si)⋅oi
将分子两项分别除以分母:
omerged=oacc⋅exp(Sacc−Smerged)+oi⋅exp(Si−Smerged)
阶段 3:全局归约合并
(3a) 迭代计算全局 LSE:
Sglobal=Sacc(0)⊕Sacc(1)⊕⋯⊕Sacc(Nstreams−1)
(使用数值稳定的两数 LSE 迭代。)
(3b) 合并所有流的输出:
ofinal=s=0∑Nstreams−1exp(Sacc(s)−Sglobal)⋅oacc(s)
3.2.2 核心代码示例
import torch
import torch.nn.functional as F
import math
class FlashDecodingLSE:
"""LSE 方法(S/o 二元组法)的 Flash-Decoding 实现"""
def __init__(self, d_model: int = 512, num_heads: int = 8):
self.d_model = d_model
self.num_heads = num_heads
self.head_dim = d_model // num_heads
def traditional_attention(self, q, k, v):
"""标准 Attention(用于验证正确性)"""
scores = torch.matmul(q, k.transpose(-2, -1)) / math.sqrt(self.head_dim)
attention_weights = F.softmax(scores, dim=-1)
output = torch.matmul(attention_weights, v)
return output, attention_weights
def compute_tile_output(self, q, k_tile, v_tile):
"""
计算单个 Tile 的 LSE 二元组 (O_i, S_i)
返回: (O_i, S_i) 其中 S_i = m_i + log(l_i) = LSE(tile)
"""
# 计算注意力分数
S_tile = torch.matmul(q, k_tile.transpose(-2, -1)) / math.sqrt(self.head_dim)
# 计算局部最大值 m_i 和局部指数和 l_i
m_i = S_tile.max(dim=-1, keepdim=True).values
exp_tile = torch.exp(S_tile - m_i)
l_i = exp_tile.sum(dim=-1, keepdim=True)
# 计算局部输出 O_i(已归一化)
O_i = torch.matmul(exp_tile, v_tile) / l_i
# 计算局部 LSE 值 S_i = m_i + log(l_i)
S_i = m_i + torch.log(l_i + 1e-12)
return O_i, S_i
def merge_two_lse(self, O1, S1, O2, S2):
"""
数值稳定的两数 LSE 合并
对应公式: LSE(a, b) = max(a, b) + log(1 + exp(min(a, b) - max(a, b)))
"""
S_max = torch.maximum(S1, S2)
S_min = torch.minimum(S1, S2)
log_term = torch.log1p(torch.exp(S_min - S_max))
S_merged = S_max + log_term
# 修正两个部分的输出贡献
weight1 = torch.exp(S1 - S_merged)
weight2 = torch.exp(S2 - S_merged)
O_merged = O1 * weight1 + O2 * weight2
return O_merged, S_merged
def flash_decoding_lse(self, q, k, v,
tile_size_kv: int = 256,
num_streams: int = 4):
"""
LSE 方法 Flash-Decoding:存储 S^(b), o^(b) 两个量
"""
batch_size, num_heads, seq_len_q, head_dim = q.shape
seq_len_kv = k.shape[2]
num_tiles = (seq_len_kv + tile_size_kv - 1) // tile_size_kv
# 初始化流数组:每个流存储 (O_acc, S_acc)
streams_data = []
for _ in range(num_streams):
O_stream = torch.zeros_like(q)
S_stream = torch.full(
(batch_size, num_heads, seq_len_q, 1),
-float('inf'), device=q.device, dtype=q.dtype
)
streams_data.append((O_stream, S_stream))
# 阶段 1:处理每个 tile,分配到不同流
for tile_idx in range(num_tiles):
stream_id = tile_idx % num_streams
start_idx = tile_idx * tile_size_kv
end_idx = min(start_idx + tile_size_kv, seq_len_kv)
k_tile = k[:, :, start_idx:end_idx, :]
v_tile = v[:, :, start_idx:end_idx, :]
# 计算当前 tile 的 LSE 二元组
O_i, S_i = self.compute_tile_output(q, k_tile, v_tile)
# 获取当前流的累加器
O_acc, S_acc = streams_data[stream_id]
# 阶段 2:使用 LSE 合并算子将 tile 合并到流累加器
if torch.all(S_acc == -float('inf')):
# 第一个 tile,直接赋值
streams_data[stream_id] = (O_i, S_i)
else:
O_merged, S_merged = self.merge_two_lse(O_acc, S_acc, O_i, S_i)
streams_data[stream_id] = (O_merged, S_merged)
# 阶段 3:归约所有流的结果
return self.merge_all_streams(streams_data)
def merge_all_streams(self, streams_data):
"""
两步合并算法:
1. 迭代计算全局 S_global
2. 用 S_global 修正每个流的输出贡献
"""
if not streams_data:
return None
# 步骤 1: 迭代计算全局 S_global
S_list = [S_i for _, S_i in streams_data]
S_global = S_list[0].clone()
for i in range(1, len(S_list)):
S_i = S_list[i]
S_max = torch.maximum(S_global, S_i)
S_min = torch.minimum(S_global, S_i)
log_term = torch.log1p(torch.exp(S_min - S_max))
S_global = S_max + log_term
# 步骤 2: 修正每个流的输出贡献
O_global = torch.zeros_like(streams_data[0][0])
for O_i, S_i in streams_data:
weight = torch.exp(S_i - S_global)
O_global += O_i * weight
return O_global
| 代码变量 |
对应数学符号 |
含义 |
S_tile |
s(b) |
Tile b 上的注意力分数矩阵 |
m_i |
m(b) |
局部最大值 |
l_i |
ℓ(b) |
局部指数和 |
S_i |
S(b) |
局部 LSE 值 m(b)+log(ℓ(b)) |
O_i |
o(b) |
局部 softmax 加权输出 |
S_global |
Sglobal |
全局 LSE 值 |
torch.log1p(torch.exp(S_min - S_max)) |
log(1+exp(Smin−Smax)) |
数值稳定的两数 LSE 增量项 |
torch.exp(S_i - S_global) |
exp(S(b)−Sglobal) |
全局合并权重 |
3.3 两种方法的特性对比
| 特性 |
传统方法(m/l/o) |
LSE 方法(S/o) |
| 每个 Tile 存储量 |
3 个量:m(b),ℓ(b),o(b) |
2 个量:S(b),o(b) |
| 通信量 |
更多(3 个数/Tile) |
更少(2 个数/Tile) |
| 全局合并公式 |
需先求 mglobal,再做指数缩放 |
直接在对数空间迭代 LSE |
| 数值稳定性 |
需要显式处理 m(b)−mglobal |
自然包含在 LSE 迭代中 |
| 代码复杂度 |
需要分别处理 m、l、o 三个量 |
统一处理 S 和 o,更简洁 |
| 与标准 Attention 等价性 |
✓(2.2.3 节证明) |
✓(2.3.6 节证明) |
| 两种方法间等价性 |
✓(2.4 节证明) |
✓(2.4 节证明) |
| 适用场景 |
通用 |
通信受限场景更优 |
四、涉及的基本数学知识清单
| 概念名称 |
在本推导中的具体作用 |
一句话定义或公式表达 |
| Softmax 函数 |
将注意力分数转换为概率分布 |
softmax(xi)=∑jexp(xj)exp(xi) |
| 缩放点积注意力 |
定义 Query 与 KV 的交互方式 |
Attention(Q,K,V)=softmax(QKT/d)V |
| Max-Shift 数值稳定 |
避免 softmax 计算中的指数溢出 |
softmax(xi)=∑jexp(xj−m)exp(xi−m),m=maxjxj |
| Online Softmax |
支持增量式 softmax 计算 |
递推维护 mj=max(mj−1,xj) 和 ℓj=ℓj−1⋅exp(mj−1−mj)+exp(xj−mj) |
| Log-Sum-Exp (LSE) |
将对数空间的多个值合并为一个标量 |
LSE(x)=log(∑iexp(xi)) |
| Safe LSE |
数值稳定的 LSE 计算 |
LSE(x)=m+log(∑iexp(xi−m)),m=maxixi |
| LSE 合并算子 ⊕ |
将两个局部量合并为全局量 |
[o1,S1]⊕[o2,S2]=[eS1+eS2eS1o1+eS2o2,log(eS1+eS2)] |
| 结合律 |
保证多 Tile 合并顺序不影响结果 |
(A⊕B)⊕C=A⊕(B⊕C) |
| 指数乘法恒等式 |
将局部坐标系的指数值转换到全局坐标系 |
exp(a−c)=exp(a−b)⋅exp(b−c) |
| 对数运算性质 |
LSE 推导的核心代数依据 |
log(a⋅b)=log(a)+log(b) |
五、总结
FlashDecoding 是一种针对 LLM 推理解码阶段的高效注意力算法。本文档完整推导了两种数学上等价的实现方法,并分别证明了它们与标准 Attention 的等价性:
5.1 传统方法(m/l/o 三元组法)
- 局部计算:每个 Tile 计算 (m(b),ℓ(b),o(b))
- 全局合并:
mglobal=bmaxm(b),ℓglobal=b∑exp(m(b)−mglobal)⋅ℓ(b),ofinal=ℓglobal∑bexp(m(b)−mglobal)⋅ℓ(b)⋅o(b)
5.2 LSE 方法(S/o 二元组法)
- 局部计算:每个 Tile 计算 (S(b),o(b)),其中 S(b)=m(b)+log(ℓ(b))
- 全局合并:
Sglobal=log(b∑exp(S(b))),ofinal=b∑exp(S(b)−Sglobal)⋅o(b)
- 合并算子:[o1,S1]⊕[o2,S2]=[eS1+eS2eS1o1+eS2o2,log(eS1+eS2)]
5.3 等价性结论
- 传统方法 ⇔ 标准 Attention:已证(2.2.3 节)
- LSE 方法 ⇔ 标准 Attention:已证(2.3.6 节)
- 传统方法 ⇔ LSE 方法:已证(2.4 节),核心恒等式为 S(b)=m(b)+log(ℓ(b))
5.4 工程实践建议
| 场景 |
推荐方法 |
理由 |
| 一般推理加速 |
传统方法 |
实现成熟,生态完善 |
| 通信受限(多机/跨节点) |
LSE 方法 |
通信量减少 33%,更简洁 |
| Ring Attention 序列并行 |
LSE 方法 |
结合律保证任意合并顺序正确 |
| CUDA Kernel 手写优化 |
传统方法 |
与 FlashAttention 原版一致 |
该算法已被集成到 FlashAttention 2.2+、xFormers、FlashInfer 等主流推理加速库中,成为长序列 LLM 推理的标准优化技术。
评论