深入 Android 端侧 AI 推理的 Transformer 算子优化全链路:从 Self-Attention 计算瓶颈到移动 GPU 计算着色器的 FlashAttention 加速

在端侧跑 1B 参数 LLM,prefill 阶段延迟总卡在 attention 算子。用 Mali Offline Compiler 和系统 profiler 抓数据,发现瓶颈不在 GEMM 算力,而在中间张量的读写。本文记录我把 FlashAttention 搬进 OpenGL ES Compute Shader 的过程。

Self-Attention 的瓶颈在访存不在算力

标准 attention 流程:S = QK^T,P = softmax(S),O = PV。

序列长度 n 时,中间矩阵 S 是 n×n。n=2048、fp16 下 S 占 8MB。这块矩阵要先写回全局内存,再读出来做 softmax,再读出来乘 V,三次全量读写。

移动 GPU 的 LPDDR 带宽比桌面显卡差一个数量级,进一步放大了这个矛盾。我在 Mali-G710 上实测,attention 算子的算术强度只有 GEMM 的 1/20 左右,计算单元大部分时间在等数据。

FlashAttention 的本质:重排计算顺序

FlashAttention 不改变数学结果,只改变计算顺序,核心就两点:

  1. 把 Q、K、V 切成小块,每次只算一个小 tile 的 attention,不生成完整的 S 矩阵。
  2. 用 Online Softmax 增量维护最大值和分母,最后统一归一化。

分块带来的是数值稳定性问题:softmax 减最大值是为了防指数溢出,但分块处理时新块的最大值可能更大,需要把之前累计的结果按比例缩放回去。

# Online Softmax 分块更新
m_new = max(m_old, max(S_block))           # 更新 running max
scale = exp(m_old - m_new)                 # 旧累计的缩放因子
l_new = l_old * scale + sum(exp(S_block - m_new))
O_new = O_old * scale + P_block @ V_block  # 加权累加输出

这样每个 tile 只进出内存一次,全局访存从 O(n²) 降到 O(n)。

移动 GPU 的约束:Compute Shader 怎么落地

端侧实现我选了 OpenGL ES 3.1 的 Compute Shader,而不是 Vulkan。理由很直接:老设备覆盖率高,NNAPI 的 GPU 后端也走这条技术路线,后续可以复用。

移动 GPU 和桌面 GPU 两个差异直接影响实现:

  • 没有高带宽 L2,shared memory 很小。Mali 的 workgroup 内存通常在 16KB 量级,tile 大小要贴着这个上限定。
  • 工作组分发开销高,所以一个 workgroup 要一次算完整一个输出 tile,内部循环把 K 方向的所有分块都迭代完。

GLSL 的 shared 内存声明:

// 每个 workgroup 缓存一个 K 方向 tile
shared mediump float Qs[BR][BD];
shared mediump float Ks[BC][BD];

mediump float 在 GLSL ES 规范里只保证 10 位精度。真正想用 fp16 存 shared,得看扩展支持,很多设备不支持就直接退化成 fp32,带宽翻倍,后面踩坑就在这。

一个完整的 tile 循环

核心循环结构:外层遍历 Q 的 row block,内层遍历 K/V 的 column block。

for (int j = 0; j < TC; j++) {         // 遍历 K/V 列块
    load_tile(Ks, K, j);               // 协作加载 K tile
    float S[BR][BC];
    matmul(S, Qs, Ks);                 // S = Qs @ Ks^T
    float m_new = max(m_old, row_max(S));
    float scale = exp(m_old - m_new);
    l = l * scale + row_sum(exp(S - m_new));
    load_tile(Vs, V, j);
    O = O * scale + exp(S - m_new) * Vs;   // 累加输出
    m_old = m_new;
}

S 全程留在寄存器里,从不写回全局内存,这是省带宽的关键。workgroup 结束时用 l 做一次归一化输出 O。

踩过的坑

fp16 精度:Mali 上 shared 内存存 fp16 需要 GL_EXT_shader_16bit_storage,不少中端芯片不支持,只能退 fp32,tile 大小直接砍半。我的做法是编译期生成两个 variant,运行时探测扩展再选路径。

bank conflict:加载 K tile 用 mat4 向量化读取能减少指令数,但 shared 内存布局不对会引入 bank conflict。让相邻线程访问相邻 bank,tile 按行主序还是列主序存储要想清楚,我用列主序后冲突明显下降。

数值稳定性:scale = exp(m_old - m_new) 在差距大时会下溢为 0,数学上正确,但部分 GPU 开启 fast-math 后会产生异常值,需要关掉 precision hint。

实测 Mali-G710 上 2048 序列的 attention 从 120ms 降到 18ms,6 倍多提升。

落地建议

选 tile 大小时,先查目标设备的 workgroup 内存上限,再留出 Q、K、V 三块加输出缓存的空间,别拍脑袋定。

编译期多生成 fp16 / fp32 两个 variant,运行时探测扩展选路径,比在 shader 里用 #ifdef 动态判断更省心。

优化顺序上先解决访存,再调 bank conflict,最后才动精度 hint。GPU 优化里 80% 的收益来自前两步,精度相关的微调经常得不偿失。