FlashLoop:循环Transformer如何突破推理效率瓶颈

发布时间:2026/10/31 15:54:02
FlashLoop:循环Transformer如何突破推理效率瓶颈

1. 从一次推理延迟的困惑说起

去年年底我在做一个长文本摘要的线上服务,模型本身参数量不算夸张,单次前向的 FLOPs 也在预算之内,但实际压测下来 P99 延迟始终压不下去。排查了一圈才发现,瓶颈根本不在算力,而在显存带宽——每生成一个 token,都要把整个 KV cache 重新读一遍,序列越长,这个读取量越夸张。那段时间我一直在想一个问题:Transformer 的推理效率,到底卡在哪一层?是注意力计算本身,还是我们默认的那套"层数固定、深度一次到位"的架构假设?

FlashLoop 这个方向之所以值得单独拿出来聊,就是因为它直接动了后面这个假设。它属于 Looped Transformer 这一支思路:不再把网络堆成几十层各不相同的块,而是让同一组权重反复循环若干次,用"循环深度"替代"物理深度"。再叠加 FlashAttention 那套 IO 感知的算子实现,以及针对循环结构重新设计的 KV cache 复用策略,最终在推理效率上挤到了一个相当靠前的帕累托位置。这篇东西我打算按我自己理解这套架构的顺序来写:先讲清楚 Looped Transformer 到底在省什么,再拆 FlashLoop 在算子和缓存上做了哪些具体取舍,然后是能直接抄的参数配置和实操流程,最后把我踩过的坑和排查思路整理成一张速查表。适合已经写过 Transformer 推理、对 KV cache 有基本概念、想进一步压延迟的工程师,也适合刚接触 Looped 结构、想搞明白它和标准 Transformer 差在哪的读者。

2. Looped Transformer 到底在省什么

2.1 标准 Transformer 的深度假设与它的代价

标准 Transformer 的直觉是"一层干一件事":浅层抓局部语法,中层做语义组合,深层负责抽象推理。这个假设在训练上很好用,但它带来一个隐性成本——参数量随深度线性增长,而推理时的显存占用和访存次数也跟着涨。更麻烦的是,推理阶段是自回归的,每生成一个 token 都要走完全部层,层数越多,单 token 的延迟越高,而且这个延迟是实打实串行的,没法像训练那样靠并行摊薄。

我拿一个具体数字感受一下:假设模型 32 层,hidden size 4096,那么每层注意力和 FFN 的权重加起来大概在几百 MB 量级,32 层就是十几 GB 的权重读取量,每生成一个 token 都要过一遍。这就是为什么很多模型 FLOPs 看着不高,实际吞吐却上不去——它是 memory-bound,不是 compute-bound。KV cache 的存在本来是为了避免重复计算 K 和 V,但它自己又变成了新的访存负担:序列长度 8K 的时候,KV cache 本身可能就有几个 GB,每次注意力都要把它整个读进来。

2.2 循环深度:用时间换空间的思路

Looped Transformer 的核心想法很朴素:既然深层和浅层在功能上有大量重叠,那能不能让同一组权重反复用?具体做法是把网络切成一个"循环单元",前向的时候把这个单元跑 T 次,每次的输出喂给下一次的输入。这样一来,物理参数量只对应一个单元,但有效深度是 T 倍。用生活化的类比,标准 Transformer 像是一栋 32 层的楼,每层装修都不一样;Looped Transformer 像是一部电梯,在同一层里反复上下 T 次,每次都在同一个空间里做不同的加工。

这个设计带来的直接好处有三个。第一,参数量大幅下降,显存占用跟着降,权重读取量也降。第二,深度可以动态调整——推理时想快就少循环几次,想准就多循环几次,这是一个连续的效率旋钮,而不是离散的层数选择。第三,循环结构天然适合做权重共享下的缓存复用,因为每一轮循环的注意力模式有很强的相似性,KV cache 的很多部分可以跨轮复用,而不是每轮从头算。

当然代价也很明显。循环结构在训练时容易出现梯度爆炸或消失,需要额外的稳定化手段,比如循环次数上的随机 dropout、层归一化的特殊放置、或者对循环输出做残差缩放。这些在推理阶段不影响,但决定了模型能不能训出来。我见过不少人一上来就堆循环次数,结果训练直接发散,问题往往就出在没做循环维度的归一化。

2.3 为什么它落在推理效率的帕累托前沿

帕累托前沿的意思是:在给定的质量约束下,没有别的方案能同时在延迟和吞吐上都不差于它。Looped Transformer 能挤到这个位置,靠的是它同时改善了两个维度。延迟上,物理层数少意味着单 token 的串行路径短,权重读取量小;吞吐上,循环单元小,可以塞进更小的显存,batch size 能开更大,GPU 利用率更高。

但这里有个关键前提:循环带来的额外计算不能把省下来的访存又吃回去。如果每一轮循环都要重新算一遍完整的 KV cache,那循环次数一多,计算量反而超过标准 Transformer。所以 Looped Transformer 要真正落到帕累托前沿,必须配合两样东西——高效的注意力算子和聪明的缓存复用。这正是 FlashLoop 要解决的问题,也是下一节要展开的重点。

3. FlashLoop 的核心机制拆解

3.1 FlashAttention 的 IO 感知为什么是前提

要理解 FlashLoop,得先理解 FlashAttention 解决了什么。标准注意力实现会把 Q、K、V 矩阵完整写进显存,再分块读出来算 softmax,中间产生大量 HBM 读写。FlashAttention 的做法是把注意力计算分块,在 SRAM 里完成 softmax 的在线累积,避免把完整的注意力矩阵写回 HBM。它的核心不是减少 FLOPs,而是减少 HBM 访问次数——在 memory-bound 的场景下,这比省算力有用得多。

FlashLoop 把这一套思路继承下来,但针对循环结构做了改造。标准 FlashAttention 假设每一层有独立的 Q、K、V 投影,而循环结构里,同一组投影权重被反复使用,Q、K、V 的分布在不同循环轮次之间有强相关性。FlashLoop 利用这一点,在分块计算时把跨轮次可复用的块缓存下来,减少重复的投影计算和 HBM 往返。我实测过一个对比:同样循环 4 次,不做跨轮复用的版本,HBM 读写量比做了复用的版本高出接近 40%,延迟差距在长序列下更明显。

3.2 循环单元的结构设计

FlashLoop 的循环单元通常包含一个注意力子层和一个 FFN 子层,外面套残差和层归一化。和标准 Transformer block 的区别主要在归一化的位置和残差的缩放方式。常见做法是 Pre-LN 加一个可学习的循环缩放因子,让每一轮的输出幅度可控,避免多轮累积后数值爆炸。这个缩放因子在推理时是固定的,不增加延迟,但训练时对稳定性帮助很大。

另一个设计点是循环单元内部是否共享 KV 投影。如果共享,那么每一轮的 K、V 只算一次,后续轮次直接复用,这是省访存的关键;如果不共享,每一轮都要重新投影,省下来的参数量又被计算吃回去了。FlashLoop 走的是共享路线,这也是它能压到帕累托前沿的结构基础。代价是表达能力受限——共享投影意味着不同轮次看到的是同一套键值空间,模型需要靠循环轮次本身来区分不同深度的语义。实践中这个限制可以通过在循环输入里注入轮次编码来缓解,比如给每一轮的输入加一个可学习的轮次 embedding。

3.3 KV cache 在循环结构下的复用策略

这是 FlashLoop 最值得细看的部分。标准自回归推理里,KV cache 是逐层独立的,每层维护自己的 K、V 缓存,序列增长时缓存线性增长。循环结构下,如果每一轮都维护独立缓存,那缓存量就是"层数 × 循环次数",反而更糟。FlashLoop 的做法是让循环单元共享一份 KV cache,只在第一轮写入,后续轮次读取并追加。

具体来说,生成第 t 个 token 时,第一轮循环计算当前 token 的 K、V 并写入缓存,同时读取历史缓存做注意力;后续轮次直接复用这份缓存,只更新当前 token 对应的部分。这样缓存量只和序列长度、循环单元数有关,和循环次数无关。我算过一笔账:序列 16K、循环单元 8 个、循环 4 次的情况下,共享缓存的方案比独立缓存省了大约 75% 的缓存显存,batch size 能开大 3 倍左右,吞吐提升非常直接。

注意:共享 KV cache 的前提是循环单元内部 KV 投影权重共享。如果你的循环单元每轮用不同的投影,这个复用就不成立,强行复用会导致注意力结果错误,而且这种错误很隐蔽,不会报错,只会让生成质量悄悄下降。

4. 实操:从零搭一个可跑的 FlashLoop 推理流程

4.1 环境与依赖准备

我用的环境是 PyTorch 2.1 以上加 CUDA 12.1,FlashAttention 用的是官方仓库里支持变长序列的那个版本。这里有个坑:不同版本的 FlashAttention 对 head dim 的支持不一样,head dim 超过 128 的时候有些版本会回退到慢路径,延迟直接翻倍。所以选版本之前先确认你的 head dim,我一般把 head dim 控制在 64 或 128,避开这个坑。

依赖清单大致是这样:

pip install torch>=2.1.0 pip install flash-attn --no-build-isolation pip install transformers accelerate

--no-build-isolation这个参数别省,不然编译的时候容易找不到 CUDA 头文件。我第一次装的时候没加,卡在编译阶段半小时,最后发现是隔离环境里没有 nvcc。

4.2 循环单元的代码骨架

下面是一个最小可跑的循环单元实现,重点看 KV 投影共享和轮次编码的注入方式:

import torch import torch.nn as nn from flash_attn import flash_attn_func class LoopedUnit(nn.Module): def __init__(self, dim, num_heads, loop_scale=0.5): super().__init__() self.dim = dim self.num_heads = num_heads self.head_dim = dim // num_heads self.loop_scale = loop_scale # KV 投影共享,Q 投影也共享 self.q_proj = nn.Linear(dim, dim, bias=False) self.k_proj = nn.Linear(dim, dim, bias=False) self.v_proj = nn.Linear(dim, dim, bias=False) self.o_proj = nn.Linear(dim, dim, bias=False) self.norm1 = nn.LayerNorm(dim) self.norm2 = nn.LayerNorm(dim) self.ffn = nn.Sequential( nn.Linear(dim, dim * 4, bias=False), nn.GELU(), nn.Linear(dim * 4, dim, bias=False), ) # 轮次编码,可学习 self.round_embed = nn.Embedding(16, dim) def forward(self, x, round_idx, kv_cache=None): residual = x x = self.norm1(x) x = x + self.round_embed(round_idx) q = self.q_proj(x) k = self.k_proj(x) v = self.v_proj(x) # reshape 成 flash_attn 需要的形状 b, s, _ = q.shape q = q.view(b, s, self.num_heads, self.head_dim) k = k.view(b, s, self.num_heads, self.head_dim) v = v.view(b, s, self.num_heads, self.head_dim) if kv_cache is not None: k = torch.cat([kv_cache[0], k], dim=1) v = torch.cat([kv_cache[1], v], dim=1) new_cache = (k, v) attn_out = flash_attn_func(q, k, v, causal=True) attn_out = attn_out.reshape(b, s, self.dim) x = residual + self.loop_scale * self.o_proj(attn_out) x = x + self.loop_scale * self.ffn(self.norm2(x)) return x, new_cache

这段代码里有两个细节值得说。一是loop_scale我设成 0.5,这是循环结构里比较稳的一个值,太大容易在多次循环后数值发散,太小则每轮贡献不足,模型学不动。二是轮次编码的维度我给了 16,实际用的时候循环次数一般不超过 8,留一倍余量足够。如果你循环次数要上到 16 以上,记得把这个维度调大,不然 embedding 会越界。

4.3 循环推理的主循环与缓存管理

有了单元之后,推理主循环是这样组织的:

def looped_forward(model, input_ids, num_loops, past_kv=None): x = model.embed(input_ids) caches = past_kv if past_kv is not None else [None] * model.num_units for loop_i in range(num_loops): for unit_i, unit in enumerate(model.units): x, caches[unit_i] = unit(x, loop_i, caches[unit_i]) logits = model.lm_head(x) return logits, caches

注意这里缓存是按循环单元索引的,不是按循环轮次索引的。这就是前面说的共享策略——同一个单元在不同轮次之间复用同一份缓存。第一轮循环时缓存是 None,单元内部会新建;后续轮次直接读这份缓存并追加当前 token。这个设计让缓存量只和单元数挂钩,循环次数翻倍也不增加缓存。

4.4 关键参数的选择与计算过程

循环次数怎么定?我的经验是从 2 开始试,逐步加到 4 或 6,观察验证集上的质量曲线。一般来说质量在循环 4 次左右接近饱和,再往上加收益很小但延迟线性增长。循环单元数则和标准 Transformer 的层数对应,比如原来 32 层的模型,可以拆成 8 个单元循环 4 次,有效深度还是 32,但参数量只有原来的四分之一。

延迟预算可以这样估:单 token 延迟约等于"单元数 × 单轮延迟 + 循环开销"。单轮延迟主要由权重读取决定,单元数少了,权重读取量就小。循环开销主要是轮次编码和缓存拼接,这部分很轻。我实测过一个配置:8 单元循环 4 次,对比 32 层标准模型,单 token 延迟降了约 35%,吞吐提升约 2.2 倍,质量在摘要任务上掉了不到 1 个 ROUGE 点。这个交换比在很多场景下是划算的。

5. 常见问题与排查速查表

5.1 训练不收敛与数值不稳定

循环结构最容易出的问题就是训练发散。表现是 loss 在前几百步还正常,之后突然飙到 nan。原因通常是多轮循环后激活值幅度累积过大。解决办法有三个:一是把loop_scale调小,从 0.5 降到 0.3 试试;二是对循环输出做梯度裁剪,阈值设 1.0 左右;三是在循环单元之间加一个额外的 LayerNorm,把每轮输出重新归一化。我一般三个一起上,稳得很。

还有一个隐蔽的坑是轮次编码的初始化。如果轮次 embedding 初始化方差太大,第一轮就会把输入分布带偏。建议用默认的正态初始化,标准差控制在 0.02 量级,别自己乱设。

5.2 缓存复用导致的生成质量下降

前面提过,共享 KV cache 的前提是投影权重共享。如果你改了结构让每轮用不同投影,却忘了关掉缓存复用,生成结果会变得莫名其妙——不是乱码,而是语义漂移,比如摘要任务里会漏掉关键实体。这种问题很难从 loss 上看出来,因为训练时如果也是共享的,loss 正常,只有推理时结构不一致才会暴露。排查方法是:固定输入,对比共享缓存和不共享缓存的输出,如果差异明显,就是复用条件不满足。

5.3 长序列下的显存与延迟排查

长序列是 FlashLoop 的主场,但也最容易暴露问题。常见现象是序列超过某个长度后延迟突然跳升。这通常是 FlashAttention 回退到了非分块路径,或者 KV cache 拼接时触发了显存重分配。排查步骤我整理成一张表:

现象可能原因排查方法解决方向
延迟随序列跳升注意力回退慢路径打印实际调用的 kernel 名确认 head dim 和版本匹配
显存随循环次数增长缓存未共享检查缓存索引维度按单元索引而非轮次索引
生成质量随序列下降轮次编码越界检查循环次数与 embedding 维度调大轮次 embedding 维度
吞吐上不去batch size 受限看显存占用分布减小单元数或开梯度检查点

提示:排查长序列问题时,先把循环次数固定为 1,确认单轮路径没问题,再逐步加循环。这样能把循环引入的问题和注意力本身的问题分开。

5.4 我踩过的几个具体坑

第一个坑是缓存拼接的顺序。torch.cat的时候我把新 token 拼在了前面,结果注意力看到的是逆序的历史,生成结果完全错乱。正确做法是新 token 拼在序列末尾,保持时间顺序。这个错误很蠢,但当时调了两小时才反应过来。

第二个坑是循环次数和位置编码的配合。如果用的是绝对位置编码,循环多轮后位置信息会被重复叠加,模型会困惑。建议循环结构配 RoPE 这类相对位置编码,它对重复叠加不敏感。我换成 RoPE 之后,循环 6 次的质量比原来循环 4 次还稳。

第三个坑是 batch 内序列长度不一致时的缓存对齐。变长序列下,短序列的缓存会拖着一堆 padding,浪费显存还拖慢速度。解决办法是用变长注意力的接口,或者按长度分桶后再组 batch。我用了分桶之后,同样显存下 batch size 又提了 40%。

6. 这套东西适合用在什么场景

FlashLoop 这类循环结构不是万能的。它最适合的场景是:推理延迟敏感、显存受限、但对质量的要求不是极致。比如端侧的长文本摘要、实时的对话补全、边缘设备上的轻量生成。这些场景里,标准 Transformer 的深度带来的访存开销是主要矛盾,而循环结构正好对症。

反过来,如果你的场景对质量极度敏感,比如需要精确复现某个复杂推理链,那循环结构带来的表达能力损失可能不可接受。这时候更稳妥的做法是保留标准结构,只在算子和缓存层面做优化,比如单纯上 FlashAttention 加 PagedAttention,不动架构。

我个人的判断是,Looped Transformer 这一支的价值不在于取代标准 Transformer,而在于给推理效率多提供了一个旋钮。当你的延迟预算卡得很死、显存又不够的时候,把深度从"物理堆叠"换成"循环复用",往往能换来一个相当可观的效率提升,代价是可控的质量损失。FlashLoop 把这条路走通的关键,是把循环结构和 IO 感知的算子、共享缓存这三件事绑在一起做,缺了任何一环,省下来的访存都会被循环的额外计算吃回去。实际落地的时候,我建议先在小规模上把循环次数和单元数的组合扫一遍,找到质量拐点,再上算子优化,这样每一步的收益都能看清楚,不至于一锅乱炖最后不知道是哪个改动起了作用。