3792 字
19 分钟

论文阅读:FlexInfer

把大语言模型跑在手机、边缘盒子这类资源受限的硬件上,最直接的拦路虎是内存。一个 4-bit 量化后的 Llama2-70B 也要 36 GB 左右,远远超过大多数端侧设备的可用内存。常见的应对办法是把模型改小——蒸馏、量化、剪枝,但这些手段都会不同程度地损伤模型能力,而且一旦确定了模型规模、量化位宽或稀疏度,占用的内存就固定了,无法随着设备的空闲内存多少灵活调整。

另一条路是 offloading(卸载):把放不下的参数留在存储(SSD/闪存)里,推理时按需读进内存。这条路的好处是完全不动模型、能力无损,但代价是引入了大量存储 I/O,而 I/O 恰恰是端侧最慢的一环。FlexInfer 这篇发表在 EuroMLSys ‘25 上的工作,目标就是把 offloading 这条路走顺:在不改动模型的前提下,通过一套内存与 I/O 的调度策略,让受限内存下的推理吞吐尽可能接近全内存的水平,并且能适应用户给出的任意内存预算。

背景:offloading 慢在哪里#

论文先用一组实验说明「朴素 offloading 到底有多慢」。作者用 llama.cpp 作为基线,它默认用 mmap 把模型文件映射进地址空间,参数不在内存里时靠缺页中断从存储加载。用 cgroup 限制可用内存后,测 4-bit 量化 Llama2-70B(约 36.2 GB,全内存吞吐 31.14 tokens/s)的解码吞吐:

可用内存 (GB)5101520253035
吞吐 (tokens/s)0.510.490.490.460.501.412.06

结果相当刺眼:只要内存装不下整个模型,吞吐就从 31 tokens/s 直接塌到 0.5 tokens/s 上下,而且从 5 GB 加到 25 GB 几乎没有任何改善,一直要到内存接近模型总大小(30、35 GB)才略有回升。原因在于两点:一是解码时几乎每次访问权重都会触发一次缺页 I/O,mmap 的同步随机读效率极低;二是被换入的参数在下次用到之前就已经被换出内存,多给的内存等于白给。

由此论文归纳出 offloading 式端侧推理要解决的三个问题:I/O 开销高无法充分利用已有内存难以适配不同的内存大小。FlexInfer 的三个技术模块正好各自对应其中一点。

核心思路#

FlexInfer 不碰模型本身,而是把「参数放哪、什么时候搬、怎么搬」这件事做到极致,由三个协同工作的模块组成:

  • 异步预取(asynchronous prefetching):让 I/O 和计算并行起来,用计算时间掩盖加载时间,降低 I/O 开销;
  • 均衡内存锁定(balanced memory locking):把空闲内存用来常驻一部分参数,从根本上减少每轮要搬的数据量,并且要「均匀地」占用,避免各层忙闲不均;
  • 灵活张量保留(flexible tensor preservation):根据用户给定的内存预算,决定哪些参数常驻、哪些卸载,让方案能适配任意内存大小。

FlexInfer 架构:存储里放完整模型,FlexInfer 依据资源预算与模型元信息决定每层的锁定/预取划分,内存里只放锁定部分加一个小的预取窗口

三者的先后关系是:灵活张量保留先根据预算和模型结构算出一份「保留计划」,均衡内存锁定据此把模型切成「常驻内存」和「按需预取」两部分,运行时再由异步预取模块把预取部分并行地读进来。下面逐个展开。

一个前提:解码阶段没有参数局部性#

理解后面的设计需要先注意端侧解码的一个特点。单请求、逐 token 的自回归解码里,生成每个 token 都要完整走一遍所有层的所有权重,且每个参数在一次生成中只会被访问一次。这意味着传统缓存里「保留热数据」的局部性优化在这里完全失效——没有哪个参数比别的更「热」。

反过来看这其实是好事:一个参数用完就可以立刻释放,因为要到下一个 token 才会再用到它,而那时它无论如何都得重新读。于是整个推理的内存峰值不取决于模型多大,而取决于同时「在途」的层数,也就是预取窗口(prefetch window)的大小。设模型有 nn 层、预取窗口为 kk 层,纯预取方案的内存占用相对完整模型约为 k/nk/n。FlexInfer 默认 k=3k=3,对一个几十层的模型来说这是极小的常驻开销。

论文的场景是 CPU 推理——资源受限的端侧设备通常没有强力 GPU,所以后面的性能模型都以 CPU 计算延迟和存储带宽为主角。

方法一:异步预取#

同步 offloading 的吞吐可以写成(论文式 3):

Tsync=1tcpu+SIOBIOT_{\text{sync}} = \frac{1}{\,t_{\text{cpu}} + \dfrac{S_{\text{IO}}}{B_{\text{IO}}}\,}

其中 tcput_{\text{cpu}} 是单 token 的 CPU 计算延迟,SIOS_{\text{IO}} 是每轮要加载的参数量,BIOB_{\text{IO}} 是存储带宽。计算和加载是串行相加的关系,两者互相等待。

只要让 I/O 线程和计算线程并行——算第 ii 层的时候,提前把第 i+1i+1 层预取进来——串行的「相加」就变成了并行的「取最大」(论文式 4):

Tasync=1max ⁣(tcpu, SIOBIO)T_{\text{async}} = \frac{1}{\,\max\!\left(t_{\text{cpu}},\ \dfrac{S_{\text{IO}}}{B_{\text{IO}}}\right)\,}

同步执行逐层串行,加载与计算相加;异步预取让 I/O 线程和计算线程重叠,算第 i 层时预取第 i+1 层,总时间取二者的较大值

这个公式点明了 offloading 优化的两个方向:尽量让 I/O 与计算重叠,以及尽量把存储带宽吃满。FlexInfer 用一套张量级的多线程预取来达成后者:输入、输出的嵌入层常驻内存,策略只作用在结构一致的解码层上;多个 I/O 线程协作处理同一层,每个线程负责加载一个张量(如 WQW_QWKW_KWVW_V),加载完通过共享变量上的原子操作同步,计算线程等某层参数齐了再开算,然后大家一起推进到下一层。之所以按张量粒度分工,是因为若按更细的粒度读会产生大量小块随机 I/O,反而压不满带宽;张量级的连续大块读才能把 BIOB_{\text{IO}} 用满。

需要注意的是,预取只能把 I/O 藏到计算背后,并不能减少 SIOS_{\text{IO}} 本身。当模型很大、内存很小时,SIO/BIOS_{\text{IO}}/B_{\text{IO}} 会远大于 tcput_{\text{cpu}},此时 TasyncBIO/SIOT_{\text{async}} \approx B_{\text{IO}}/S_{\text{IO}},性能被存储带宽死死卡住。要再往上走,就得真正减少每轮的 I/O 量——这正是第二个模块要做的事。

方法二:均衡内存锁定#

预取解决了「搬得慢」,但没解决「搬得多」。而且前面那张表已经说明:单纯多给内存并不会自动变快,因为朴素方案不会主动利用这些内存。均衡内存锁定的思路是把空闲内存用起来,把一部分参数锁定(lock)在内存里常驻不动,这样这部分参数每轮就不需要 I/O,直接减小了式 4 里的 SIOS_{\text{IO}}

关键在于「锁哪些参数」。一个直觉的做法是按层次序,把前几层整层锁进内存、剩下的层留在存储。但这样会带来严重的忙闲不均:锁定的层因为不需要 I/O,计算飞快;没锁定的层又要等一次完整的层加载。结果就是计算线程冲到没锁定的层时被迫干等 I/O,而 I/O 线程在处理锁定层的那段时间里无事可做。两类线程互相等待,流水线被打断。

FlexInfer 的做法是把每一层都切成两部分:一部分锁进内存,另一部分按需预取,并让各层锁定的比例保持一致。这样每层残余的 I/O 量都一样多,I/O 负载在整个推理过程中平稳,计算和预取能够稳定地全程重叠,不再出现某一层突然要等一大块加载的情况。

非均衡锁定按层次序整层锁定,导致计算冲到未锁定层时干等 I/O、I/O 线程又有空闲;均衡锁定让每层只锁一部分、其余均匀预取,计算全程不停顿,因而更早完成

换句话说,同样是用掉这么多内存常驻参数,「按层锁」和「按比例锁」省下的总 I/O 量是一样的,但后者把这份 I/O 均摊到了每一层,避免了流水线气泡,实际吞吐要高得多。

方法三:灵活张量保留#

均衡内存锁定隐含了一个假设:每层里可锁定的单元大小相同,均分才有意义。但 Transformer 一层里的张量大小并不一致。参数主要分布在注意力张量(WQW_QWKW_KWVW_VWOW_O)和 FFN 张量(WgateW_{gate}WupW_{up}WdownW_{down})上,单个 FFN 张量和单个注意力张量大小之比大约是 3:1。到底优先锁哪一类,取决于内存预算:

  • 内存紧张时优先锁注意力张量:它们小,同样的内存能锁住更多个张量,于是每轮需要发起的 I/O 次数更少;把大块的 FFN 留在存储,正好用连续大块读来跑满带宽。
  • 内存充裕时优先锁 FFN 张量:把大块都锁进内存后,各层残余的、需要预取的部分大小更接近,层间 I/O 更均匀,内存碎片也更少。

一层的张量构成:4 个小的注意力张量加 3 个大的 FFN 张量;内存紧张时优先锁注意力张量以减少 I/O 次数,内存充裕时优先锁 FFN 张量以让各层残余 I/O 更均匀

论文把这套判断写成了一个贪心的启发式算法(Algorithm 1):给定注意力张量大小、FFN 张量大小、层数和内存预算,按内存从多到少分档——够锁全部 FFN 时就把每层 3 个 FFN 张量全锁上,其次退到每层锁 2 个、1 个 FFN 张量;FFN 分配完后,再把剩余内存尽量用来一个一个地锁注意力张量。当内存少到连「每层锁一个 FFN 张量」都做不到时,几个 FFN 分档都不成立,算法就直接去锁注意力张量——这正对应了「内存紧张时注意力优先」。整套逻辑只用几个阈值判断,简单但抓住了「张量数量 vs. 单次 I/O 大小」这对权衡。(论文补充:对使用 GQA 的模型,注意力里更小的 K、V 投影会被优先保留,同样是为了在有限内存里多锁几个张量。)

实现#

FlexInfer 在 llama.cpp 基础上用约 828 行 C/C++ 实现,新增了控制可用内存和线程数的参数。预取窗口默认设为 3 层,兼顾内存占用和延迟。一个值得一提的实现细节是:I/O 线程走 direct I/O 绕过页缓存——因为基线 mmap 的病根之一就是依赖页缓存、在内存受限时反复换入换出,direct I/O 把加载行为完全交给 FlexInfer 自己的调度来掌控。

效果#

实验在一台 512 GB 内存、AMD 7995WX CPU 的服务器上进行,用 cgroup 限制可用内存、用 taskset 限制 CPU 核数来模拟资源受限的端侧设备。测了 Llama2 系列四个尺寸:Llama2-7B(3.8 GB,全内存 12.72 tokens/s)、Llama2-13B(7.3 GB,6.74)、Codellama-34B(17.9 GB,2.6)、Llama2-70B(36.4 GB,1.3)。(这里的全内存吞吐比前面动机实验的 31.14 低很多,是因为评测额外用 taskset 限制了核数,更贴近端侧;动机实验只限了内存。)

主要结论是,mmap 基线在各种内存下都只有 0.08–0.67 tokens/s,且加内存几乎不涨;FlexInfer 在不同内存条件下相比 mmap 分别取得 5.2–12.5×(7B)、5–11.8×(13B)、4.2–10.6×(34B)、5–11×(70B)的吞吐提升,各尺寸模型上的最好情况在 10.6–12.5× 之间。

消融实验(逐个叠加三个模块)能看清每一步的贡献:

  • 先把 mmap 换成多线程大块读(Sync Read),在内存受限时就有 2.6–3× 提升,印证了 I/O 是主要瓶颈;
  • 再加异步预取(让计算和 I/O 并行),提升 34.8–59.4%,随着内存增多最高能到 69.9–118.8%;
  • 再加均衡锁定,在最小内存下相比按层锁定只提升 9.2–11.1%(因为能锁的参数很少,两种策略差别不大),内存越多差距越明显,最高提升 56.8–83.3%;
  • 灵活张量保留相比朴素的 Attn-first 策略最高提升 21.9%(7B)、7.8%(13B),相比 FFN-first 提升 12%(7B)、14.6%(13B)。34B 和 70B 因为用了 GQA,两种策略在多数情况下都退化成和 Attn-first 一致,论文因此只给了 7B/13B 的对比。

(表格与消融里的数字较为密集,若要引用具体数值,建议再对照原文核对一遍。)

局限与讨论#

FlexInfer 的定位很清楚——不改模型、能力无损地把 offloading 做快,因此它和模型压缩、量化、投机解码等方向是正交的,理论上可以叠加。但也正因为不改模型,它有几个天花板:

  • 受限于存储带宽。预取只是把 I/O 藏到计算背后,均衡锁定只是用内存换掉一部分 I/O,但都不能凭空消灭 I/O。当模型远大于内存时,绝大多数权重每个 token 都得从存储流一遍,性能被 BIOB_{\text{IO}} 卡死,这是硬上限。相比之下,PowerInfer、LLM in a flash 这类基于稀疏性的方法能从算法层面直接少读参数,天花板更高,但代价是依赖稀疏性、可能损伤模型能力——两者是不同的取舍。
  • 面向单请求的解码场景。整套设计建立在「每个参数每 token 只访问一次、没有局部性」之上,天然契合端侧单用户逐 token 解码,但不适用于需要复用参数的批处理场景(那类场景是 FlexGen、vLLM 等 GPU、批量服务系统的地盘)。
  • 只针对权重 I/O。论文优化的是模型权重的搬运,没有讨论 KV cache 的卸载与增长;在端侧短上下文、单序列下 KV cache 相对权重通常不大,但长上下文时这块开销不能忽略。
  • 启发式偏经验。灵活张量保留是一套手工阈值的贪心策略,建立在「FFN:注意力 ≈ 3:1」这类结构假设上;换成结构差异较大的模型,档位划分未必最优。

总的来说,这是一篇思路清晰、工程性很强的 workshop 论文:它没有提出什么新颖的模型或算法,而是把「offloading 到底该怎么调度内存和 I/O」这件事拆成预取、锁定、保留三层,各自解决一个具体瓶颈,并用一套简单的启发式把它们串起来,在受限内存下把朴素 offloading 的吞吐拉高了一个数量级。

论文阅读:FlexInfer
https://blog.gzher.com/posts/paper-flexinfer/
作者
中会 / Claude Opus 4.8
发布于
2026-07-09
许可协议
CC BY-NC-SA 4.0