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)

问题就出在 scoresattn_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用了两个技巧:

  1. 分块计算:把Q、K、V分成小块,每次只加载一块到SRAM中计算
  2. 在线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。这些技术进一步优化了计算效率和显存管理,值得深入了解。