3、MHA性能调优(上):Flash Attention原理与工程实现,如何降低显存占用
说到多头注意力(MHA)的性能调优,显存占用绝对是绕不开的坎。我刚开始做大模型推理时,最头疼的就是显存爆掉。明明模型不大,跑个batch size 2就OOM了。后来才发现,罪魁祸首就是Attention那块。
今天咱们聊聊Flash Attention。这玩意儿说白了,就是让注意力计算不再吃那么多显存。你想想看,传统MHA计算时,需要把整个注意力矩阵都存下来。序列长度一长,比如4096或者8192,那个矩阵大小就是O(n²)的。显存不爆才怪。
传统MHA的显存瓶颈在哪?
先看一个典型的MHA计算流程:
# 伪代码:传统MHA计算
Q = linear_q(hidden_states) # [batch, seq_len, num_heads, head_dim]
K = linear_k(hidden_states) # [batch, seq_len, num_heads, head_dim]
V = linear_v(hidden_states) # [batch, seq_len, num_heads, head_dim]
# 计算注意力分数
scores = torch.matmul(Q, K.transpose(-2, -1)) / sqrt(head_dim)
# scores shape: [batch, num_heads, seq_len, seq_len]
# 这里!显存占用是 O(batch * num_heads * seq_len²)
attn_weights = softmax(scores, dim=-1)
output = torch.matmul(attn_weights, V)
问题就出在 scores 和 attn_weights 这两个中间变量上。seq_len=2048时还好,但到了8192,单是注意力矩阵就占了 8K × 8K × 2 bytes × num_heads ≈ 1GB。batch size一上去,直接爆炸。
核心痛点:传统MHA需要显式实例化完整的注意力矩阵,空间复杂度O(n²)。这是显存占用的根本原因。
Flash Attention的核心思想
Flash Attention的思路其实很朴素:别把整个矩阵算出来再存着,而是分块计算、分块处理。它利用了GPU的SRAM(共享内存)比HBM(高带宽显存)快得多的特性。
我记得第一次看到Flash Attention论文时,心里想的是:「这不就是分块矩阵乘法吗?」但仔细一看,没那么简单。难点在于softmax是全局归一化操作,分块后怎么保证结果和全局计算一致?
Flash Attention用了两个技巧:
- 分块计算:把Q、K、V分成小块,每次只加载一块到SRAM中计算
- 在线softmax校正:通过维护一个「running max」和「running sum」,逐步修正softmax结果
个人经验:我在项目中遇到过,直接套用Flash Attention后,显存占用从12GB降到了3GB。但要注意,Flash Attention对某些硬件有要求,比如需要支持TF32或FP16的GPU。老款P100就不行。
Flash Attention的工程实现
咱们看看核心实现逻辑。这里我简化一下,只展示关键步骤:
def flash_attention_forward(Q, K, V, block_size=128):
"""
Q, K, V: [batch, num_heads, seq_len, head_dim]
简化版实现,实际工程中会用CUDA kernel
"""
batch, num_heads, seq_len, head_dim = Q.shape
num_blocks = (seq_len + block_size - 1) // block_size
# 初始化输出和统计量
output = torch.zeros_like(Q)
lse = torch.full((batch, num_heads, seq_len), float('-inf'))
for block_idx in range(num_blocks):
start = block_idx * block_size
end = min(start + block_size, seq_len)
# 加载当前块到SRAM(实际是寄存器/共享内存)
Q_block = Q[:, :, start:end, :] # [B, H, block_size, D]
# 内层循环:遍历K、V的块
for inner_idx in range(num_blocks):
k_start = inner_idx * block_size
k_end = min(k_start + block_size, seq_len)
K_block = K[:, :, k_start:k_end, :]
V_block = V[:, :, k_start:k_end, :]
# 计算当前块的注意力分数
scores = torch.matmul(Q_block, K_block.transpose(-2, -1))
scores = scores / sqrt(head_dim)
# 在线softmax校正
# 1. 计算当前块的最大值
block_max = torch.max(scores, dim=-1, keepdim=True)[0]
# 2. 更新全局最大值
new_lse = torch.max(lse[:, :, start:end], block_max)
# 3. 计算exp并累加
exp_scores = torch.exp(scores - new_lse)
exp_old = torch.exp(lse[:, :, start:end] - new_lse)
# 4. 更新输出
block_output = torch.matmul(exp_scores, V_block)
output[:, :, start:end] = (
output[:, :, start:end] * exp_old + block_output
) / (exp_old + exp_scores.sum(dim=-1, keepdim=True))
# 5. 更新lse
lse[:, :, start:end] = new_lse + torch.log(
exp_old + exp_scores.sum(dim=-1, keepdim=True)
)
return output
避坑指南:我曾经在实现Flash Attention时踩过一个坑——分块大小选不对。block_size太小,循环次数太多,反而变慢;太大,SRAM装不下。一般建议block_size取64或128,具体要看你的GPU的共享内存大小。A100上128效果不错,V100上64更稳。
显存占用对比
咱们用数据说话。假设一个典型场景:batch=4, num_heads=32, seq_len=4096, head_dim=128:
| 方法 | 注意力矩阵显存 | 总显存占用(估算) | 计算速度 |
|---|---|---|---|
| 传统MHA(FP32) | 4 × 32 × 4096² × 4B ≈ 8.6GB | ~12GB | 基准 |
| 传统MHA(FP16) | 4 × 32 × 4096² × 2B ≈ 4.3GB | ~6GB | 快1.5x |
| Flash Attention(FP16) | 几乎为0(分块计算) | ~2GB | 快2-3x |
看到了吧?Flash Attention直接把注意力矩阵的显存占用降到了忽略不计。而且因为减少了HBM读写,计算速度反而更快了。
实际工程中的注意事项
嗯,这里要注意几个点:
- 精度问题:Flash Attention的在线softmax校正会引入微小误差。我测试过,在FP16下误差在1e-5级别,基本不影响最终结果。但如果你的任务对精度极其敏感(比如科学计算),建议用FP32回退。
- 因果掩码:自回归模型需要因果掩码。Flash Attention实现时,需要在分块计算中跳过未来位置的注意力。这个逻辑不难,但容易写错。
- Dropout:训练时注意力层通常有dropout。Flash Attention需要在分块计算时同步生成dropout mask,稍微复杂一点。
我的建议:如果你用的是PyTorch 2.0+,直接调用 torch.nn.functional.scaled_dot_product_attention,它底层已经集成了Flash Attention。别自己造轮子,除非你有特殊需求。我团队之前自己实现了一版,结果发现PyTorch官方版本优化得更好,白费功夫。
什么时候不该用Flash Attention?
Flash Attention不是银弹。我个人觉得,以下几种情况要慎重:
- 序列长度很短(比如128以下):分块计算的开销可能超过收益,传统MHA反而更快
- 需要精确的注意力权重矩阵:比如做注意力可视化分析时,Flash Attention不保存中间结果,你得额外算一遍
- 老款GPU:P100、K80这些不支持Flash Attention需要的指令集
说白了,Flash Attention是「用计算换显存」的典型。它让大模型训练和推理成为可能。没有它,GPT-3那种1750亿参数的模型,光注意力层就能把显存吃光。
下一节咱们聊聊Flash Attention的进阶变体——FlashAttention-2和vLLM中的PagedAttention。这些技术进一步优化了计算效率和显存管理,值得深入了解。