先摆一个真实配置。一个 100 万 token 的仓库级代码会话打到推理集群,按 GLM-5.2 的账,这份 KV cache 要 97.52 GB。单张 Ascend910C 的一个 die 只有 64 GB HBM,扣掉每 die 25.36 GB 的权重和运行时的激活,留给 KV 的预算只剩 29 GB 上下。这个请求在解码节点上不是慢,是根本排不进去。

工程上的第一反应是把 KV cache 卸载到 host DRAM。这条路早就有人走:FlexGen 按层卸载,LMCache 把 KV 做成跨请求复用的存储层,HiSparse 和 ECHO 用 HBM–DRAM 两级缓存把 decode 的 KV 赶到内存。但所有真正做过这件事的人都会撞上同一堵墙——搬运本身有延迟,H2D 的带宽比 HBM 低一个数量级,一旦搬运暴露在关键路径上,并发提上去了,吞吐却没有同步提上去,首字延迟和单步延迟反而变差。

FlashOffloading 想证明这件事可以反过来:卸载之后,延迟还能压得住。它的答案是按阶段分开设计。prefill 用四个全层缓冲把 H2D 藏进逐层计算里;decode 用反向稀疏索引把 miss 查表融进 top-k 的同一次选择,再用融合 kernel 把 H2D 藏进稀疏注意力内部。全程不做近似、不丢 token、不改注意力集合,算出来的结果和不卸载时逐位相同。

代价也说清楚:在 32 卡 64 die 的 Ascend910C 上跑 64k 输入的端到端解码,每个 NPU 的批大小从 4 提到 12,输出吞吐涨 67.8%,但 TPOT 从 17.0 ms 涨到 30.2 ms。这篇解析要回答的就是这条线的两边分别值多少。

一分钟速览

  • 问题:1M token 请求的 KV cache 是 97.52 GB,超过解码节点 29 GB 的 KV 预算,这类请求不是慢而是不可服务。
  • 关键观察:prefill 有整层的计算窗口和粗粒度块访问,decode 只有 indexer 选出的约 1 KB 细粒度访问,而且必须等 top-k 算完才能开始搬。
  • prefill 做法:B0 常驻第一层,B1–B3 轮转,load、compute、writeback 三条流并行;KV 预分配从 36.75 GB 降到 9.04 GB,可服务序列从约 368k 提到约 1.6M。
  • decode 做法:cache_slots 反向索引把每次 miss 查表变成 O(1),LIM 把索引编进 int32 分数与 top-k 一次选出,CopySFA 在 KVgather 里按 hit/miss 分叉。
  • 消融数字:缓存预算从 8192 提到 16384 个 token,miss 少 43%–49%;管理开销 106–232 µs/层,21 层摊到每步约 3.4 ms。
  • 端到端:30 ms 量级的 TPOT 约束下,prefill 吞吐最高 1.38×,decode 吞吐最高 1.91×,512k 与 1M 从不可服务变成可服务。
  • 代价:TPOT 涨 42.2%–78.7%,1M 的 P99 TTFT 是 572.0 s,MoE dispatch 的同步等待最多 42.5 ms。

先把账算清楚:1M token 的 KV cache 是 97.52 GB

这一节只做三件事:把每 token 的 KV 开销算到底、看清两个阶段各自撞的是哪堵墙、把卸载多出来的延迟拆成三笔可命名的账。

每 token 95.23 kB 是怎么算出来的

KV cache 的大小是可以逐项加出来的,不需要测。设注意力层数 L_KV、KV 维度 D_KV、DSA indexer 的层数 L_index 与维度 D_index、每个元素字节数 P,则每个 token 的常驻开销是 KV 本体加 indexer 状态:

κbase=(LKVDKV+LindexDindex)P \kappa_{\mathrm{base}} = (L_{KV} D_{KV} + L_{index} D_{index}) P κbase​=(LKV​DKV​+Lindex​Dindex​)P

GLM-5.2 已经用了 MLA 做维度压缩,参数是 L_KV = 78、D_KV = 576、L_index = 21、D_index = 128,BF16 下每个元素 2 字节,代进去是 (78×576 + 21×128)×2 = 95.23 kB。这个数字不大,但乘上序列长度就变得很硬:64k 是 6.09 GB,512k 是 48.76 GB,1M 是 97.52 GB。

这里有个容易被忽略的点:indexer 的状态也要算钱。78 层 KV 本体占 89.86 kB,21 层 indexer 占 5.38 kB,后者只占 5.6%,但它随序列长度线性增长,而且在卸载方案里它必须留在 HBM,不能被搬走。后面 decode 的驻留公式里会看到,正是这一项让卸载后的收益打了折扣。

解码节点先崩在哪

解码节点的账更直接。扣掉权重和激活之后,KV 预算 M_KValloc 是 29 GB,于是序列长度 |S_b| 的请求,每个 DP rank 最多能同时跑 BS_max = M_KValloc / (κ_base·|S_b|) 个。把这条公式代进 GLM-5.2 的生产部署(双服务器 32 die,DP16/TP2/EP32),画出来就是两条断崖。

第一条在 512k:一个请求就要 48.76 GB,比整个 KV 预算还大,无论 batch 多小都放不下。第二条在 32k:BS_max 掉到个位数,而解码吞吐 = BS/TPOT,batch 掉下去吞吐就跟着掉。图 1 左边是每 die 的 HBM 占用拆解,右边是实测的 TPOT 与吞吐:8k 时 BS=16、吞吐 505 token/s、TPOT 31.7 ms;到 128k 只剩 BS=1、91 token/s;256k 是 47 token/s;512k 直接标成不可服务。

图 1
图 1|左列是每 die 的 HBM 占用拆解:权重固定 25.36 GB,KV 随序列长度线性涨到 24.38 GB 一格,512k 起标注不可服务;右列是同一组配置下实测的单 DP rank 吞吐与 TPOT。

这张图还藏了一个反直觉的细节:TPOT 从 31.7 ms 一路降到 21.1 ms。不是因为长序列更快,而是因为 batch 被迫变小,每一步要算的 token 少了。换句话说,容量墙不是让每一步变慢,而是让你没有足够的并发去把每一步填满。

prefill 节点撞的是另一堵墙

prefill 侧的问题不是并发,是能不能塞下一个 chunk。设历史长度 H、本次新调度 N 个 token,容量约束是 κ_base(H+N) + M_act(N) ≤ M_avail。H 越大,可行的 N 越小,chunk 只能切得更碎,前向次数变多,TTFT 就上去了;更极端的时候单请求都放不下。

论文给的部署数字很能说明问题:两服务器 32 die 的 prefill 节点(DP2/TP16/EP32),每 rank 的 KV 预算 20.12 GiB,一个 1M token 的请求要占 93 GiB,batch 等于 1 也放不下。到这一步,传统的 prefix 缓存和把不活跃请求 swap 出去都不解决问题——它们优化的是复用和不活跃状态,而这里的约束是活跃请求在执行 prefill 时的峰值 HBM。

卸载多出来的三笔延迟

卸载之所以在在线服务里难落地,是因为它往关键路径上加了三个可命名的项。解码一步的间隔可以写成:

ITLoffload=ITLbaseline+ Tcache-manageLindex+TH2D-loadLKV \mathrm{ITL}_{\mathrm{offload}} = \mathrm{ITL}_{\mathrm{baseline}} \quad + \, T_{\mathrm{cache\text{-}manage}} L_{index} + T_{\mathrm{H2D\text{-}load}} L_{KV} ITLoffload​=ITLbaseline​+Tcache-manage​Lindex​+TH2D-load​LKV​

第一笔是缓存管理,也就是 miss 查表、挑淘汰对象、改索引,它按 indexer 层数计费;第二笔是 H2D 加载,按注意力层数计费;第三笔不太出现在公式里,但是最要命——LI 要先算完 top-k 才知道该搬谁,SFA 要等搬完才能开始算,于是搬运被夹在两个 kernel 中间,天然无法重叠。

这三笔账还互相打架:

  • 想把 miss 数压下去,就得把热缓存 |C_b| 做大;可是传统实现的查表是 O(k) + O(|C_b|),缓存越大管理越慢。
  • 想靠预测来提前搬,就要引入近似,而论文明确不走这条路——它要求卸载只改数据放置、不改计算结果。
  • 想把搬运和计算重叠,就得知道该搬谁;可“该搬谁”要等 top-k 算完才知道,窗口天然被夹在中间。

所以问题不是“要不要卸载”,而是“能不能让这三项都不随容量增长”。论文后面所有的设计,本质上都在回答这一句。

用一个表把这三个矛盾摆在一起:

代价项从哪来论文里的量级论文的处理
缓存管理每步每层对 top-k 结果查驻留表朴素实现是 O(k) 加一次缓存容量扫描反向索引加融进 top-k,实测 106–232 µs/层
H2D 搬运每步把 miss 的 token 搬回 HBM每请求 miss 合并后约 300 token与 SFA 核内重叠,重叠率 90%–98%
串行等待LI 选完才能搬,搬完才能算拆分 SFA 时 AIC 空转单 kernel 内按 tile 分叉,只 1/4 tile 走 DRAM

架构:一份主副本、两套驻留策略

结论先给:这套架构只统一了一件事——KV 的权威副本放在 host DRAM;设备侧的驻留形态和搬运粒度完全按阶段分开。

逻辑统一、物理分相

逻辑上,prefill 和 decode 共用同一份 host DRAM 里的 KV 历史,共用 paged host block、block table 和一套控制面。物理上,两边的设备驻留完全不同:prefill 保留四个全层缓冲,decode 保留原生 indexer、一个 C slot 的热缓存、两个新 KV 尾部块,外加一张 cache_slots 驻留表。

阶段切换时的动作也不一样:prefill 结束后,KV 按部署方式复用或转移;设备驻留从“整层缓冲”切换成“token 级热缓存”。这个设计还有一个好处是适用范围清晰——只有 decode 依赖原生稀疏 indexer,prefill 的按层加载和回写处理的是完整层 KV,所以对稠密注意力同样成立。

4/L 与 (C+2b)/T:两个能背下来的近似

设 s_KV 是单卡单 token 单层的 K/V 字节数,T 是上下文长度,L 是层数,C 是每层 decode 热缓存容量,b 是 page size。全驻留基线是 M_base = L·T·s_KV,两个阶段的驻留量近似为:

Mprefill≈4TsKV M_{\mathrm{prefill}} \approx 4 T s_{KV} Mprefill​≈4TsKV​
Mdecode≈L(C+2b)sKV+Mindex+Mslots+Mmeta M_{\mathrm{decode}} \approx L(C + 2b) s_{KV} \quad + M_{\mathrm{index}} + M_{\mathrm{slots}} + M_{\mathrm{meta}} Mdecode​≈L(C+2b)sKV​+Mindex​+Mslots​+Mmeta​

第一式说 prefill 只留 4/L 的 K/V 数据,L = 78 时是 5.1%。第二式说 decode 只留 (C+2b)/T,当 C/T = 1/4、b 远小于 T 时接近四倍压缩。论文自己补了一句限定:indexer 状态、cache_slots 和其他元数据仍然随 T 增长,所以这两个式子量化的是 K/V 数据这一块,会略微高估总 HBM 的节省。这句话很重要,后面讲“什么场景值得卸载”时会用到。

和已有方案的分界线

把这项工作放进已有的一堆卸载方案里,位置其实很特殊:它既不是“把不活跃的 KV 挪走”,也不是“用近似把 KV 变小”,而是“把活跃请求的完整 KV 全放 host,同时把搬运藏起来”。

方案卸载什么粒度与本文的差别
vLLM 的 prefix 缓存与请求 swap可复用前缀、不活跃请求的 KVblock降的是复用与不活跃状态,不降活跃请求执行 prefill 时的峰值 HBM
FlexGen权重、激活、KV 一起按层面向受限显存下的高吞吐,对在线延迟不敏感
LMCache、Mooncake可复用 KVblock 或 token围绕分离式存储组织缓存,目标同样是复用而不是活跃峰值
HiSparse原生稀疏模型的 decode KVtoken命中检查走共享内存哈希交集,构建 O(k)、扫描随缓存容量涨;每步每层重写 LRU,实测 miss 率 13%–30%
ECHO原生稀疏模型的 decode KVtokentop-k 之后独立做 miss 查表,开销 O(BS·k)/步/层,淘汰是在整个缓存池上做 radix 选择,靠 EMA 预测 top-k 分数来重叠
ESS原生稀疏模型的 decode KVtoken提出 DualBatch-Attention 重叠,但忽略管理开销,拆核本身还带来额外延迟
AVSG原生稀疏模型的 decode KVtoken跨 decode 步复用 HBM 缓冲来降 H2D 流量,不改管理延迟
FlashOffloadingprefill 与 decode 全量层(prefill)、token(decode)管理延迟与容量解耦,搬运在核内重叠,且全程无损

这张表里最值得对比的一行是 HiSparse 和 ECHO。它们和本文做的是同一件事——都是原生稀疏模型的无损卸载,都保留完整的 token 选择——差别全在“管理开销能不能和容量解耦”。HiSparse 的淘汰要重写整个 LRU 顺序,ECHO 的淘汰是在整个缓存池上做 radix 选择,两者的开销都随缓存预算增长;于是它们被迫把缓存配小(HiSparse 典型配置是 8192),miss 率反而上去。本文的论证是:正因为反向索引把管理变成 O(k) + O(|Miss_b|),才敢把缓存配到 14336,miss 才降到 268–350。这是一条因果链,不是两个独立的调参。

图 2
图 2|左侧是 prefill 路径,四个全层缓冲里 B0 常驻、B1–B3 轮转,配合逐层计算;中间是 host 侧的统一 KV 存储,按请求、层、token block 分页;右侧是 decode 路径,原生 index 状态与 cache_slots 一起进 LIM,再由 copySFA 在有重叠的情况下取 miss,热缓存之外还有两个尾部块承接新 KV。

prefill 侧:四个全层缓冲把搬运藏进计算窗口

这一节的核心结论是:prefill 的卸载之所以能藏住,不是因为有更快的通道,而是因为 Transformer 的层生命周期天然给了你一个“只需要当前层完整 KV”的窗口。

为什么第一层要常驻

设计利用的是一个很朴素的事实:虽然每一层都拥有自己的 KV 状态,但任一时刻只有当前层会消费自己那整块 KV 张量。于是 host 侧按请求、层、paged token block 保存完整历史,设备侧只分配四个全层缓冲。

B0 在整个 prefill 期间绑定第 0 层,B1–B3 通过一张绑定表在所有后续层之间轮转。为什么不让四个都轮转?因为第一层如果没有常驻缓冲,计算必须等它的 H2D 到齐才能开始,起手就是一段裸露的等待;把 B0 钉在第 0 层之后,计算可以立刻开跑,用它的执行时间作为流水线的启动窗口,代价是少了一个可轮转的槽位。indexer 状态和 block table 元数据单独存放,不随缓冲轮转被覆盖。

为什么偏偏是四个?论文没有做缓冲数目的敏感性实验,但从依赖关系能推出来:一个缓冲在被复用之前,必须同时满足“计算消费完”和“D2H 回写完”两个条件,所以至少要有两个缓冲才能让加载和计算错开,三个才能让加载、计算、回写三条流同时活起来;第四个的作用是留出预取距离的余量,让 H2D 可以提前一层以上开始。原文明说“模型越深,host 存储变大,但设备缓冲数不变”,也就是说这个 4 是常数预算,不随层数涨——代价则是 4/L 这个比例会随层数变深而变小,78 层时只剩 5.1%。

一个 layer 的五步生命周期

实现复用了 vLLM KVConnector 暴露的层钩子。第 0 层直接用常驻的 B0,被卸载的层(ℓ ≥ 1)走这条生命周期:

  1. 绑定并加载:connector 从 B1–B3 里挑一个空闲缓冲绑定到第 ℓ 层,按请求的 block table 异步加载有效历史。新请求可以跳过加载,chunked prefill 只加载当前 chunk 之前的已分配块。
  2. 等就绪:注意力读历史之前,wait_for_layer_load 等这个缓冲的 H2D 事件。
  3. 追加并计算:当前 chunk 的新 K/V 直接写进调度器指定的槽位,原来的 prefill 注意力同时消费历史和新增项,不需要另一块 staging 张量。
  4. 回写并确认:当前层算完,save_kv_layer 异步把更新后的 KV 按块写回 host。host 侧 KV 在 D2H 完成前不能被后续阶段消费。
  5. 释放并预取:只有最后一个计算消费者和 D2H 都完成,缓冲才可复用;这个完成事件同时可以触发下一层的加载。

这五步对每个活跃的 prefill batch 都成立。连续批处理会更新请求的 block table,但事件依赖保证了缓冲归属不会乱;decode 开始之前,所有回写和 index 状态更新必须完成。

重叠什么时候会破

这是全文最该被工程同学记住的一段。设 d_on,ℓ 是从预取第 ℓ 层到消费它之间可用的独立层计算量,d_off,ℓ 是从算完到缓冲被复用之间可用的计算量,在 H2D 和 D2H 各自独立的前提下,暴露出来的延迟满足:

τon,exposed(l)≥max⁡(0, τH2D(l)−don,l τcompute) \tau^{(l)}_{\mathrm{on,exposed}} \geq \max\left(0,\ \tau^{(l)}_{H2D} - d_{on,l}\,\tau_{\mathrm{compute}}\right) τon,exposed(l)​≥max(0, τH2D(l)​−don,l​τcompute​)

也就是搬运时间减去可用计算窗口,减到负值就是完全藏住。第二条同理,只是把 H2D 换成 D2H、把预取距离换成复用距离。如果两个方向共用一条串行拷贝通路,左边的 τ 要换成 τ_H2D + τ_D2H,窗口需求直接翻倍——这一点在 PCIe 单向还是双向、有没有独立 DMA 引擎上差别很大。

窗口不够时有两条出路:加大预取距离,让 H2D 提前几层开始;或者让更多层常驻。前者的约束是目标缓冲必须已经计算完并回写完毕,后者直接吃 HBM 预算。论文的调度参数 onload_layer_map[i] = j 表达的就是前者——第 i 层到调度点时预取未来的第 j 层,j 不一定等于 i+1。

调度长什么样

把上面这套写成伪代码,逻辑大概是这个形状:

仅示意:四缓冲 prefill 调度(非实测代码)
B0 绑定 L0;free = [B1, B2, B3]
compute 从 L0 起跑(B0 常驻,不等)
for i in 0 .. L-1:
  if i: wait_for_layer_load(i)  # 等第 i 层
  compute(layer i, 历史 + 新 KV)
  j = onload_layer_map[i]       # 预取第 j 层
  if free 有缓冲且已回写完毕:
    bind(free.pop(), j)
    async_load(j, buf)
  save_kv_layer(i)              # 异步 D2H
  compute 与 D2H 都完成才释放

这套流水线的收益在端到端上体现为容量:prefill 卸载把 KV 预分配从 36.75 GB 压到 9.04 GB,省出来的空间转成了激活(1.67 → 13.26 GB),于是 max-num-batched-tokens 从 16384 提到 131072,可服务序列从约 368k 提到约 1.6M。代价是同一 chunk 大小下 TTFT 变差,因为不带卸载时复用的 prefix 只在第一次 forward 加载一次,带卸载时每个 forward 都要加载一遍。

图 3
图 3|三条流的时间线:H2D 依次把 L1 到 L5 装进 B1、B2、B3 再回收 B1、B2;计算侧 L0 从常驻的 B0 起跑,之后每层用刚装好的缓冲;D2H 侧前两格因为缓冲还没算完而空转,从 L1 起才开始回写。下方是四个缓冲的角色,B0 在整个 prefill 期间不复用。

decode 侧(一):把 miss 查表融进 top-k

decode 侧要解决的是三个互相纠缠的问题:miss 太多、管理太慢、搬运不能和注意力重叠。这一节讲前两个,第三个下一节讲。

先把一个请求在 decode 节点上的生命周期走一遍,后面所有细节都挂在这条线上。prefill 结束后,这个请求的 KV 通过 P→D 传输写进 decode 节点的 host DRAM KV block;第一步解码把 |C_b| 个 token 从 DRAM 灌进 HBM 的 KV block,这一步叫首次填充;之后进入稳态,每一步、每一层都执行一次换入换出。也就是说,首次填充的代价是一次性的,真正决定 TPOT 的是稳态下每步的搬运量。

cache_slots:把 O(|C|) 换成 O(1)

先看 decode 的访问形态为什么和 prefill 完全不同。有了 DSA 之后,LightningIndexer 每步从完整历史 S_b 里选出 top-k 个 token(GLM-5 里 k = 2048),SparseFlashAttention 只在这 k 个 token 上算,计算量降到 k/|S_b|。于是卸载变得可行:完整 KV 放 host DRAM,HBM 只留一个热缓存 C_b ⊂ S_b,每步加载 Miss_b = T_b − C_b,同时淘汰等量的 token,热缓存大小不变,稀疏注意力仍然是精确的。

问题在于“哪些 miss”。传统做法是一张 slot→token 的索引,查一次要扫 O(|C_b|) 项,管理时间变成 O(k) + O(|C_b|)。缓存小则搬运多,缓存大则管理慢,两条曲线正好抵消。

FlashOffloading 反过来存 token→slot:cache_slots 是每请求每层一条长度为 |S_b| 的一维 int32 张量,记录每个 token 当前驻留在哪个 slot,不驻留就是 -1。这样一次 miss 查表是 O(1),管理时间降到 O(k) + O(|Miss_b|);由于 |Miss_b| 远小于 |C_b|,挑淘汰和改索引也只碰 miss,延迟和容量解耦。效果是 |C_b|/|S_b| 可以从 1/8 提到 1/4,支撑接近四倍的 batch 扩容。

五步流程与 MTP3 的四路合并

在 MTP3 下,一步解码要跑 j = 0…3 四路 top-k,四路的 miss 集合会重叠。稳态流程是五步:

  1. miss 查表:按每路 top-k 的索引查 cache_slots,逐路得到驻留情况。
  2. miss 合并:把四路的 miss 取并集,这一步决定了本步真实的搬运量。
  3. 挑淘汰:从 C_b 里减去四路 top-k 的并集,按需挑出与 miss 等量的淘汰对象。
  4. 索引更新:把每个 miss 与一个被淘汰的 slot 一一配对,改写 cache_slots。
  5. H2D 加载:按配对结果把 miss 的 KV 从 DRAM 拷进配对的 HBM slot,然后做 SFA。

关键在第 2 步和第 3 步的配对关系:加载量和淘汰量一一对应,所以 |C_b^new| = |C_b|,热缓存大小永远不变。这也是它能把“容量”和“管理延迟”拆开的前提——如果允许热缓存动态涨缩,淘汰逻辑就必须每次重新规划。

LIM:把索引编进分数里

索引结构解决了 O(|C_b|),但 O(k) 那一项还在:传统实现要等 top-k 算完,再对选中的每个索引查一次驻留状态,每个 way 多 k 次内存访问。论文的做法是在 top-k 之前,把候选 token 的 index 和它的 cache_slots 值编码进同一个 int32,让它和分数一起参与选择;选完之后从获胜项里解出 index 和 slot,miss 结果自然就出来了。

用论文那个 20 个候选的例子最清楚:index 从 0 到 19,cache_slots 是 [4, 6, -1, 1, 7, -1, -1, -1, -1, 0, -1, -1, -1, 2, -1, -1, -1, -1, 3, 5],选出 top-3 是 [8, 4, 17],对应的 slot 是 [-1, 7, -1]。一眼就能读出:token 8 和 token 17 miss,token 4 命中 slot 7,查表开销被 top-k 本身吃掉了。这个优化在 Ascend 上实现成 LIM(LightningIndexerManage)kernel,输出两条路:给注意力用的 top-k slot 列表,和给第 5 步用的拷贝三元组(DRAM 源、HBM 目的、数量)。

这里有个实现上的隐含约束值得点出来:index 和 slot 要挤进同一个 int32,就意味着两者都得在有限的位宽里表示,于是单请求的序列长度和热缓存容量都有一个隐含上限,超出就得换更宽的编码或者分块。论文没有展开这一点,但它是“把索引编进分数”这种做法在超长序列上的真实边界。

数字上,LIM 比原生 LI 多出的管理延迟只有 106–232 µs。GLM-5.2 的 78 层里只有 21 层带 LI/LIM kernel,所以每步只多约 160 µs × 21 ≈ 3.4 ms,相对约 100 ms 的总 ITL 是个小量。

图 4
图 4|单请求单路 top-k 的稳态一步:上方两块是 HBM 热缓存的两个 block,中间一条是 cache_slots 的 20 个格子,下方是 DRAM 的 5 个 block。染色的是被选中的 token 8、4、17;token 4 命中 slot 7,token 8 和 17 miss,分别从 DRAM 的 block3、block5 拷进被淘汰的两个 slot 3 和 5,右侧列了五步的具体动作。

图 5
图 5|上格是不卸载时的 kernel 序列,LI 与 SFA 分开,中间夹 MoE;下格是本文的序列,LI 被 LIM 取代(多做①–④的缓存管理),SFA 被 CopySFA 取代(把⑤的 H2D 拷进来一起做),MoE 部分不变。注意 CopySFA 只在 L0、L1 出现,与 indexer 的层分布对齐。

decode 侧(二):CopySFA 把四次访存压到三次

这一节的结论是:把 SFA 拆成 hit 和 miss 两个 kernel 来腾出搬运用窗口,方向是对的,但拆法错了——拆分本身破坏了核内流水,重叠率上不去。

拆开 SFA 为什么反而更慢

先看 SFA 内部怎么走。它把 2048 个 token 分成 4 个 tile,每个 tile 依次做 KVgather(把散落在 HBM 的 token 挪到一块连续 workspace)、Q×K、softmax、A×V。ESS 提出的 DualBatch-Attention 是把 SFA 粗粒度拆成“先算 hit、再算 miss”两个 kernel,中间留出 H2D 的窗口。

问题是拆完之后 miss 那条路径要多走一遍访存:读 DRAM → 写 slot → 读 slot → 写 workspace,四次;而且 AIC 在 H2D 期间是空转的。也就是说,窗口是造出来了,但窗口里没有别的事可干。

在 KVgather 里分叉

CopySFA 的做法是把 H2D 搬进 KVgather 内部,在 miss/hit 处直接分叉:miss 的 token 从 host pin memory 经 PCIe/HCCS 直接取到片上,hit 的 token 照旧从 HBM 挪。由于 LIM 已经把 miss 集中到了 head,4 个 tile 里只有 1 个会触发 DRAM 搬运。

这样同时拿到两个好处。第一,少一遍 HBM 读:miss 走“读 DRAM → 同时写 slot 和 workspace”,三次访存,比分开方案少一次。第二,H2D 与 AIC 计算真正并行:DRAM 搬运跑在 hit tile 的 Q×K 期间,不再是空转窗口。这里用的是 host 侧的 pin memory,也就是锁页内存,避免搬运路径上再插一次页表拷贝。

为什么只有 1/4 的 tile 会碰 DRAM?因为 2048 个 top-k token 被切成 4 个 tile,而 LIM 在选出 top-k 的时候就把 miss 排到了队首,于是 miss 集中在第一个 tile,后三个 tile 全是命中。这个细节看起来只是排布优化,实际上决定了重叠能不能成立:如果 miss 均匀散在四个 tile 里,每个 tile 都要等 DRAM,重叠窗口就被切碎成四段而不是一整段。

图 6
图 6|横轴是 batch size,灰柱是单独跑 SFA 的时间,红柱是单独跑拷贝的时间,蓝柱是融合后的 CopySFA。batch 从 1 到 32,融合版始终低于两者之和,batch ≥16 时把拷贝几乎全藏进计算里。

数字:1.03× 到 1.27×,重叠率 90% 到 98%

定义重叠率 OverlapRatio = (T_copy + T_SFA − T_fused) / T_copy。batch 从 1 扫到 32,CopySFA 在所有配置上都是正收益,加速 1.03×–1.27×;batch ≥16 时重叠率达到 90%–98%。batch=32 那一组的具体数字是:SFA 单独 701 µs、拷贝 130 µs、融合后 714 µs,也就是说 130 µs 的搬运只暴露了 13 µs。

这也解释了为什么端到端里 CopySFA 的增量只有 +0.1 ms:在第 7 节的延迟分解里,同 batch 对照下 LIM 多 3.0 ms、CopySFA 只多 0.1 ms,搬运这一项基本被吃干净了。

实验:从 64k 到 1M,吞吐涨了但 TPOT 也涨了

这一节把论文的实验按“平台—单阶段消融—端到端”三层拆开,最后给延迟分解。所有数字都来自原文的图表,没有外推。

平台、模型与工作负载

硬件是 Ascend910C,每卡 2 个 die、每 die 64 GB HBM。端到端跑在 4 服务器 32 卡 64 die 的集群上,1P1D 分离:prefill 节点 32 die(DP2/TP16/EP32),decode 节点 32 die(DP16/TP2/EP32),P 节点做完 prefill 后通过 H2H 把 KV 写进 D 节点的 host DRAM。

模型是 GLM-5.2-w4a8c16,decode 统一开 MTP3。框架是改过的 vLLM 0.30 + vLLM-Ascend 0.30,prefill 卸载和 decode 卸载可以独立开关,这一点对读消融很关键——它让“只开一半”成为可测的对照组。

工作负载是一次突发并发:输入长度 64000–1000000 token,输出长度 1(只 prefill)或 8192,prefix 命中率设 90%。三种配置:都不开、只开 prefill(P-only)、两个都开(P+D)。kernel 级单测在单 die 上跑,稳态工作点是热缓存 12288 token、每请求 miss 并集约 300 token(这个 300 来自他们的 trace 测量)。粗算一下命中率:MTP3 的 4 路 top-k 各 2048 个位置,并集 300 个 miss,命中率在 96% 以上——这也解释了为什么热缓存配到 1/4 序列长度就够,再往上边际收益很小。

prefill 单独看:同一个 chunk 上卸载更慢,但你能开更大的 chunk

prefill 消融用一个 256000 token 的请求、输出 1 个 token、50% prefix 命中(前 128000 从 DRAM 复用,后 128000 重算),扫 max-num-batched-tokens,比 P90 TTFT。

结论有三条,第二条最容易被忽略:同一个 chunk 大小下,开卸载会让 TTFT 变差。原因是复用 prefix 在不卸载时只在第一次 forward 加载,卸载后每个 forward 都要加载一遍。但卸载把 KV 的 HBM 占用腾出来了,激活有了空间,chunk 能开大,算力利用率上来,TTFT 反而降。图 7 里 4096 的 chunk 上,卸载开和不开是 82,986 ms 对 21,755 ms;到 65,536 时变成 15,540 ms 对 14,632 ms,基本追平;再往上 131,072 时不卸载直接 OOM,卸载还能跑出 15,266 ms。

图 7
图 7|横轴是 max-num-batched-tokens,括号里是对应的激活占用;纵轴是 P90 TTFT。灰柱是不开卸载,蓝柱是开卸载。chunk 越大柱越矮,不卸载在 131,072 处直接 OOM。

decode 单独看:容量比淘汰策略重要

decode 消融的第一组回答了一个策略问题:把热缓存做大,和把淘汰策略做聪明,哪个更管用。方法是在 Open-SWE 长上下文任务上跑 GLM-5.2 + MTP3,收集每层每步的 top-2048 索引,再用离线模拟器在同一访问序列上重放,只换淘汰策略:Random、LRU、Score(淘汰低分 token)、LIM(分数阈值加 chunk 扫描)。数据集是 20 个请求,10 个短序列(66K–92K)、10 个长序列(102K–131K)。

结果是容量主导、策略次要:|C_b| 从 8192 提到 16384,miss 少 43%–49%;而在 16384 上,Score 相对 LIM 只再少 18%–20%,而且容量越大差距越小。典型配置(LIM,14336)的 miss 是 268–350,已经低于 HiSparse 典型配置(LRU,8192)的 380–406。这里有个漂亮的论证:更大的 |C_b| 确实多占 HBM,但它始终低于序列长度的 1/4,而第 2 节的实测显示吞吐在 batch≈32 就饱和了,所以多占的这部分 HBM 恰好是前人没用上的那部分。

图 8
图 8|上下两格分别是短序列组(66K–92K)和长序列组(102K–131K),横轴是热缓存预算,纵轴是每请求每层平均 miss token 数(4 路 MTP 求和)。四条线在 8K 处差距最大,到 24K 以后基本收拢,说明容量到位之后策略就没那么重要了。

端到端:512k 与 1M 从不可服务到可服务

端到端最有说服力的一条不是吞吐,是可服务性:512k 和 1M 的请求,不开卸载连一个都放不下(48.76 GB 和 97.52 GB 对 29 GB 预算),只有 P+D 能服务。其余结论按输入长度分开看。

输入长度配置批大小(每 NPU)P99 TTFT平均 TPOT输出吞吐
64kP-only4320.7 s16.9 ms3,787 token/s
64kP+D1296.7 s30.2 ms6,358 token/s
128kP-only2320.9 s16.6 ms1,928 token/s
128kP+D6122.8 s26.0 ms3,692 token/s
256kP-only1191.1 s16.1 ms994 token/s
256kP+D2128.9 s22.9 ms1,397 token/s
512kP-only—不可服务——
512kP+D1325.9 s24.2 ms661 token/s
1MP+D1572.0 s34.1 ms469 token/s

把这组数拆成三条:

  • prefill 侧:输出长度 1 时,P-only 把 P99 TTFT 降了 7.9%/4.7%/27.4%(64k/128k/256k),输入 TPS 涨了 8.6%/5.0%/37.8%,输入越长收益越大。
  • decode 侧:输出长度 8192 时,P+D 把实际 batch 提到 3×/3×/2×,代价是 TPOT 退化 78.7%/56.6%/42.2%,换来输出 TPS +67.8%/+91.5%/+40.5%。
  • 可服务性:512k 和 1M 只有 P+D 能跑,且 512k 的 batch 只能是 1、1M 的 P99 TTFT 是 572.0 s。

还有一个容易被误读的对照:P-only 下输出 8192 的 TTFT 比输出 1 还高(64k 是 320.7 s 对 86.4 s)。这不是 prefill 变慢了,而是 D 节点在给 PD 分离定速——输出越长,decode 占用 D 节点越久,prefill 就被堵得越厉害。这条也解释了为什么 decode 卸载的收益会反过来体现在 TTFT 上:P+D 在 64k 把 TTFT 从 320.7 s 拉到 96.7 s,主要功劳是 decode 不再堵着。

图 9
图 9|左格是 prefill 吞吐(输出 1 token),右格是 decode 吞吐(输出 8192 token),横轴是输入序列长度。灰色是不开卸载,蓝色是开卸载,512k 与 1M 在不卸载时标注为不可服务。

延迟分解:多花的 13.2 ms 在哪

64k 输入下,一步解码的 ITL 从 64.0 ms(不卸载、BS=4)涨到 79.1 ms(双卸载、BS=4,控制组)和 114.6 ms(双卸载、BS=12,生产工作点)。控制组的意义是把 batch 固定住,只看卸载本身带来了什么。

结论是卸载的固有增量集中在两个被优化的部件上:LIM 比 LI 多 3.0 ms(对应 21 层 × 约 144 µs,和第 5 节的单测一致),CopySFA 只比 SFA 多 0.1 ms,MoE 与 SFA 的计算时间完全不变。剩下 +8.8 ms 出在“其他 kernel”(比如 MLA 投影)上,来源是 MoE dispatch 的同步:卸载让不同 DP rank 的 miss 分布不一样,先算完的 rank 在同步点空等。论文把它归为部署级的负载不均,而不是卸载的固有开销。

生产工作点(BS=12 对 BS=4)的账更能说明规模效应:ITL 多 50.6 ms(+79%),其中 LIM 11.7 ms、CopySFA 18.5 ms,这两项随 batch × miss 并集增长;MoE 次线性涨到 25.9 ms;同步等待 42.5 ms。batch 涨 3 倍、ITL 只涨 1.79 倍,所以稳态解码吞吐提升约 1.7 倍。

图 10
图 10|三根柱子分别是 64k 输入下不卸载 BS=4、双卸载 BS=12、双卸载 BS=4(控制组)的一步解码耗时拆分。控制组和基线的差距是卸载的固有增量,生产组和基线的差距是增量加规模效应;图中还标了控制组里 LI 那 2.8 ms 的位置。

什么场景值得卸载:显存缺口、上下文长度与 DSA

这一节是我认为比方法本身更该被工程同学带走的部分。卸载不是免费的,它把 HBM 的压力换成 host 带宽和延迟,所以值不值得要按三个维度算。

显存缺口多大时划算

先给一条可以手算的判据:把你要的并发 batch 乘上上下文长度,再乘每 token 的 95.23 kB,和 KV 预算比。缺得越多,卸载越划算,因为卸载的固定成本(管理延迟、代码路径)会被摊薄。

论文的数字给了两个参照点。decode 侧,热缓存按 C/T = 1/4 配,单请求驻留从 6.09 GB(64k)降到 1.78 GB(16k 热缓存),于是每个 DP rank 能塞的请求从 4 个变成 15 个,生产上取 12 个来控制 TPOT。也就是说,缺口在 4 倍左右时,收益是线性的 batch 扩容。prefill 侧,KV 预分配从 36.75 GB 降到 9.04 GB,缺口 4 倍,换来的是 8 倍的 chunk 和 4.3 倍的可服务序列长度。

把这个判据真的算一遍,会看到很具体的东西。64k 上下文想跑 batch 8,KV 需求是 8×6.09 = 48.7 GB,预算是 29 GB,装不下,所以实际 BS_max 就是 4。128k 想跑 batch 6,需求是 6×12.19 = 73.1 GB,更装不下,BS_max 只有 2。卸载之后账变了:128k 的单请求驻留约 2.2 GB,拆开是 16384 个热缓存 token 的 1.47 GB(89.86 kB × 16384)加 131072 个 indexer token 的 0.71 GB(5.38 kB × 131072),6 个请求合计约 13 GB,和表里 12.96 GB 对得上,于是 batch 从 2 变成 6。

反过来,如果你的缺口只有 1.2 倍,卸载大概率不划算:LIM 那 3.0 ms 和 CopySFA 那 0.1 ms 是固定成本,MoE dispatch 那 8.8 ms 的不均衡还会额外出现,1.2 倍的 batch 扩容量根本盖不住这些。

上下文多长开始崩

不卸载的崩点很明确:32k 之后 BS_max 掉到个位数,512k 之后单请求都放不下。所以 32k 是一条分水岭——32k 以下,只要 batch 塞得下,没必要卸载;32k 到 512k 之间,卸载换的是 batch,收益体现在吞吐上,代价是 TPOT 涨 40%–80%;512k 以上,卸载不是优化而是唯一选项,因为不卸载根本不可服务。

这条分水岭也解释了论文为什么把 SLO 定在 30 ms 的 TPOT 上:不卸载的 TPOT 是 16–17 ms,卸载后 64k 是 30.2 ms、128k 是 26.0 ms、256k 是 22.9 ms。如果你的 SLO 是 20 ms,那 P+D 在 64k 和 128k 上就已经越线了;如果是 35 ms,1M 的 34.1 ms 也刚好卡进去。这个 SLO 余量比“吞吐涨了几倍”更决定你能不能上。

你的模型有没有 DSA

这是最硬的一条前置条件。decode 侧的整套设计——cache_slots、LIM、CopySFA——都建立在“模型自带 indexer、每步只访问 top-k 个 token”这个前提上。没有原生稀疏注意力的模型,decode 路径直接不成立;论文也明确说,只有 decode 依赖原生稀疏 indexer,prefill 的按层加载回写才同样适用于稠密注意力。

所以判断顺序应该是:先看模型是不是 DSA 系(DeepSeek-V3.2、GLM-5 这一支),是则 decode 侧可做;否则只能做 prefill 侧,收益变成“更大的 chunk 和更长的可服务序列”,而不是“更高的解码吞吐”。这条限制在论文里没有被包装成卖点,但它决定了这套方案的可迁移性。

局限与失效场景

这一节把论文自己承认的、以及从数字里能推出来的失效边界分开写。

模型侧的前置条件

第一,decode 卸载强依赖原生稀疏注意力。稠密模型只能吃到 prefill 那一半,而 prefill 那一半的收益是容量和 TTFT,不是解码吞吐。第二,indexer 状态必须常驻 HBM 且随序列长度线性增长(5.38 kB/token),所以卸载后的总 HBM 节省会略低于 4 倍,论文自己说这两个近似式“略微高估”。第三,方案是无损的,这一点是优点也是限制:它没有利用任何近似,所以当 top-k 本身很大(k 接近 |S_b|)时,miss 量会上去,热缓存的相对收益下降。

硬件与带宽侧

论文的所有实验都在 Ascend910C 上,H2D 走 PCIe/HCCS,用 host pin memory 做搬运。这意味着两件事没有被回答:在 GPU + NVLink-C2C 或 Grace-Hopper 这类 host 带宽更高的平台上,收益结构会变(重叠更容易成立,但卸载的必要性也下降);在 PCIe 单向带宽更窄、或者 H2D 与 D2H 共用串行通路的平台上,第 4 节那个 τ_H2D + τ_D2H 的窗口需求会翻倍,四缓冲可能不够。论文给的公式是通用的,但没有给带宽敏感性实验。

另一个没给的是 host DRAM 的实际带宽占用和容量成本。1M token 的 KV 在 host 侧是 97.52 GB,这不是“免费的容量”,一台机器的 DRAM 也就这个量级。多人并发时 host 侧会成为新的瓶颈,论文的测试是单请求突发,没有压到 host 带宽饱和。

论文没给的数字

按我读到的范围,至少缺四块:

  • 没有和 HiSparse、ECHO、ESS 做端到端对比。相关工作里引用了它们的 miss 率(13%–30%)和复杂度,但端到端表里的对照组全是“开/不开卸载”,没有一个“对另一个系统”的格子。
  • 没有质量评估。方法无损,这一点可以接受,但也意味着“无损”这个卖点只能靠构造保证,没有实验佐证。
  • 没有带宽敏感性实验。四缓冲够不够用,取决于 τ_H2D − d_on·τ_compute 这个差值,论文只在一个平台上验证过。
  • 1M 的 P99 TTFT 是 572.0 s、TE2E 是 842.4 s,这个量级已经不是交互式服务,论文没有讨论在什么业务下这个延迟可以接受。

还有一处工程细节值得追:+8.8 ms 的 MoE dispatch 同步等待,论文归因为“DP rank 之间 miss 分布不同导致先算完的 rank 空等”。这是一个可以被调度策略缓解的问题(比如按 miss 量做 rank 间的负载均衡),但论文只把它标成部署级现象,没有给解法。

落地判断:哪三种团队该试,哪三种别碰

把上面的账收成一份判断清单。

该试的三种团队

  • 已经在跑 DSA 系模型、上下文常态超过 128k 的团队。这类团队的现实是 decode 节点 batch 掉到 1–2,吞吐被容量卡死,卸载是唯一能把 batch 拉回两位数的办法,TPOT 从 17 ms 涨到 26–30 ms 换 1.7–1.9 倍吞吐,在 30 ms SLO 下是正的。
  • context 很长、偏 prefill 密集的业务,比如仓库级代码分析、长文档摘要。这类业务对 TTFT 敏感、对 TPOT 不敏感,prefill 卸载把 chunk 开到 131072、把可服务序列从 368k 提到 1.6M,收益直接落在 TTFT 和可服务性上,不需要 decode 侧那套复杂逻辑。
  • 显存缺口在 3–4 倍、并且愿意为 host DRAM 付钱的团队。这批人本来就在买更多机器堆 HBM,卸载把一部分需求转成更便宜的 DRAM,单位 token 成本是降的——前提是 host 带宽不成为新瓶颈,并且 DRAM 容量本身够放 1M token 的 97.52 GB。

别碰的三种情况

  • 上下文常态在 32k 以下、batch 本来就塞得下的业务。卸载的固定成本(管理延迟、MoE 同步不均、代码路径复杂度)在这里没有任何东西可以摊薄,纯亏。
  • TPOT SLO 在 20 ms 以内、或者对首字延迟有硬性交互要求的业务。卸载后 64k 的 TPOT 就是 30.2 ms,1M 的 TTFT 是 572 s,这两个数字直接排除实时对话和在线补全。
  • 模型不是原生稀疏注意力、又想要解码吞吐收益的团队。这套方案的 decode 半边拿不走,只剩 prefill 的容量收益;如果你的瓶颈在 decode 并发,它解决不了你的问题。

术语速查与论文信息

术语速查表

术语含义
KV cache每层注意力的 key 与 value 张量,随序列长度线性增长,是长上下文推理的主要显存开销
HBM高带宽显存,GPU/NPU 上的主存储;容量墙指它装不下全部 KV
TTFTTime To First Token,从请求到第一个输出 token 的延迟,prefill 阶段的主要指标
TPOTTime Per Output Token,稳态解码每 token 的平均耗时
ITLInter-Token Latency,相邻两个输出 token 的间隔,逐 step 统计
DSADeepSeek Sparse Attention,用轻量 indexer 选 top-k token 再算稀疏注意力
LI / LIMLightningIndexer 与其融合版 LightningIndexerManage,后者在算 top-k 的同时完成缓存管理
SFA / CopySFASparseFlashAttention 与其融合版,后者在 KVgather 里按 hit/miss 分叉并顺手做 H2D
PD 分离prefill 与 decode 部署在不同节点,各自扩缩容
热缓存 C_bdecode 阶段常驻 HBM 的那部分 KV,容量记为 C_b
miss 并集MTP 多路 top-k 各自治集后的去重并集,等于本步真实搬运量
cache_slots每请求每层一条 int32 张量,记录每个 token 驻留在哪个 slot,不驻留为 -1
MTPMulti-Token Prediction,一次前向猜多个 token,本文开 3 路
H2D / D2HHost to Device 与 Device to Host 的数据搬运方向
pin memory锁页内存,避免搬运路径上额外的页表拷贝
Logo

鲲鹏昇腾开发者社区是面向全社会开放的“联接全球计算开发者,聚合华为+生态”的社区,内容涵盖鲲鹏、昇腾资源,帮助开发者快速获取所需的知识、经验、软件、工具、算力,支撑开发者易学、好用、成功,成为核心开发者。

更多推荐