OSDI'26 | ECHO:稀疏 attention 省了算力,却把 HBM 撑爆了

OSDI’26 | ECHO:稀疏 attention 省了算力,却把 HBM 撑爆了

原文:ECHO: Efficient KV Cache Offloading with Lossless Prefetching for Serving Native Sparse Attention LLMs(OSDI 2026,上海交通大学 + 华为 + 贵州大学)

代码:https://github.com/sjtu-zhao-lab/ECHO


1. 前言

你有没有想过一个略微反直觉的问题:稀疏 attention 明明是为了省资源,为什么部署起来反而更容易 OOM?

按常理讲,稀疏 attention 只算 top-k 个 token 的注意力,计算量从 $O(L^2)$ 降到 $O(Lk)$,KV cache 的访问量也少了一大截,听起来全是好处。但真拿 DeepSeek-V3.2 这种 native sparse attention 模型去跑长上下文 serving,你会发现瓶颈根本不在算力上——卡在 GPU HBM 容量上,而且比 dense attention 卡得更死

原因很简单也很气人:稀疏 attention 只是”少算”,没有”少存”。KV cache 该多大还是多大,随 context 线性增长。更离谱的是,DSA(DeepSeek Sparse Attention)为了挑 token,额外引入了一个 indexer 模块,这个 indexer 自己还要存一份 K cache。所以稀疏 attention 的 KV cache 不但没变小,斜率还比 dense 更陡

今天想和大家聊聊 OSDI 2026 的这篇 ECHO。它要解决的就是这个矛盾:把 KV cache 卸载(offload)到 host 内存去,从而突破 HBM 容量限制,把 batch size 撑起来。这个思路本身不新,但 ECHO 把两件之前没做好的事做成了——cache 管理全部塞进 CUDA Graph 里跑,以及无损(lossless)的 KV prefetch

先说结论:在 8×H20 上服务 DeepSeek-V3.2,长上下文 workload 下 ECHO 相比 SGLang 拿到最高 2.15× 的 generation throughput,HBM 更紧张的时候能到 4.12×


2. 先建立概念

这篇文章涉及的名词有点多,先用大白话过一遍,后面看设计才不会懵。

MLA(Multi-head Latent Attention):DeepSeek 系列用的 attention 变体。它先把 hidden states 低秩压缩成 latent 向量 $C = W_{DKV}X \in \mathbb{R}^{L \times d_c}$,需要的时候再上投影还原出 K/V。好处是推理时只用缓存 latent 向量 $C$,比 GQA 更省。V3.2 里 $d_c = 512$,attention 头数 $H = 128$。

有个关键点后面会反复用到:MLA 的 KV cache 没法用 tensor parallelism(TP)切开。因为 latent 向量是所有头共享的,切不了。所以实际部署里 MLA 模块走 data parallelism(DP),也就是每个 DP worker 都得存一份完整的 KV cache

DSA 的 indexer:DeepSeek-V3.2 实现 training-time 稀疏 attention 的方式。它挂了一个轻量的 MQA-like 模块,给每个候选 token 算一个”重要性分数”(index score)。对于 query token $t$,token $s$ 的分数是:

\[\sum_{h=1}^{H_{index}} w^I_{h,t} \cdot \mathrm{ReLU}(q^I_{h,t}, k^I_s)\]

其中 $H_{index} = 64$、$d_{index} = 128$,都比 MLA 本体($H=128$、$d_c=512$)小得多,而且 $q^I / k^I$ 用 FP8,ReLU 也比 softmax 便宜。算完分数取 top-k(V3.2 里 $k = 2048$)送进稀疏 MLA。

注意这里有个隐含成本:indexer 要给所有历史 token 算分,所以它自己那份 $k^I$ 也得全部缓存下来。这就是前面说的”额外一份 indexer K cache”。

offload / eviction / recall:这三个词是本文主线。

  • offload:把 KV cache 从 GPU 写到 host 内存(DRAM)里备份。
  • eviction(驱逐):GPU pool 满了,把一些 token 的 KV 从 GPU 上踢掉腾位置。ECHO 里驱逐不需要传数据回 host,因为生成的时候就已经备份过了,改一下元数据就行。
  • recall(召回):indexer 选中的 token 如果不在 GPU 上,得从 host 通过 PCIe 拉回来。这一步是延迟大头。

CUDA Graph / graph execution:把一串 kernel launch 录制成一张确定的图,之后一次 replay 就全跑完,省掉大量 CPU 侧 launch 开销。decode 阶段每步计算量很小,launch 开销占比极高,所以生产环境基本必开。但它有个硬约束:录进图里的东西必须全在 GPU 上跑完,中间不能有任何 CPU 控制或同步,也不能出现动态长度的 tensor。 这条约束是 ECHO 第一个设计的全部动机。

top-k vs top-p:top-k 是”取分数最高的 k 个”,必须算完所有分数才知道谁入选。top-p 是”分数超过阈值 p 就入选”,可以边算边定。这个差别是 ECHO 第二个设计的全部动机。


3. 动机一:HBM 容量是怎么被吃干的

3.1 参数吃掉六成,KV cache 还不能切

如下图,是 DeepSeek-V3.2 在两种配置下的 HBM 占用。左边是 AWQ 4-bit 量化跑在 8×H20(96GB),右边是原始 FP8 跑在 8×H200(141GB),参数都用 TP8 切开。

DeepSeek-V3.2 的 HBM 占用构成

看数字:两种配置下参数都吃掉了 60% 以上的 HBM,剩下的空间分别只够 ~4.1M 和 ~5.2M token。

4M token 听起来很多对吧?但前面说了,MLA 的 KV cache 走 DP 不走 TP,每个 worker 要存全量。所以除以 8 之后,每个 DP worker 实际只有 ~511K(AWQ)和 ~655K(FP8)token 的额度。

实测更惨。作者在 8×H20 上用 SGLang(TP8 + MLA DP8)跑,因为还有 CUDA Graph 元数据这类运行时开销,每个 DP worker 最多只能装 380K token。这意味着面对 ~100K token 的请求,一个 DP worker 只能同时服务 3-4 个

并发上不去,硬件利用率自然上不去,吞吐就烂。

3.2 稀疏 kernel 让利用率问题雪上加霜

更难受的是,稀疏 attention kernel 本身的算术密度就低。如下图,是 FlashMLA 的 sparse 和 dense decode kernel 在 H20 上的实测 TFLOPS。

稀疏与稠密 FlashMLA kernel 的硬件利用率对比

对着图看两条线的差距:dense kernel 算术密度高,batch size 很小就能把硬件喂饱——64K context 下 batch size 8 就到 97% 利用率了。而 sparse kernel 因为访存不连续,同样条件下只有 53%,32K context / batch 16 也只有 63%,得靠更大的 batch 才能追上来。

这就形成了一个死结:稀疏 kernel 恰恰更需要大 batch,而 HBM 容量恰恰不给你大 batch。 论文里算了笔账:511K token 的额度撑 64K 上下文最多 8 个请求,利用率就卡在 53%。

3.3 现有 offloading 系统为什么不好用

Offload KV cache 到 host 不是新想法,但如下表,作者把代表性工作横向比了一遍:

KV cache offloading 系统横向对比

两列是关键。Graph Execution 一列,除 ECHO 全是 ✘KV Prefetch 一列,做了的全是 Lossy(有损)。另外注意最后一行 Model Scale:之前的动态 offloading 工作基本都在 7B~14B 的 dense 模型 + 单卡上验证,ECHO 是第一个做到 671B MoE + 多卡的。

先说 graph execution 为什么重要。如下图 (a),开 CUDA Graph 对 dense 的 DeepSeek-V3.1 是 1.2× 提升,而对稀疏的 V3.2 是 1.5×——稀疏模型算子更碎、kernel 更多,对 launch 开销更敏感,所以更依赖 graph

CUDA Graph 收益与 allocator 延迟对比

再看 (b)。现有系统(如 SGLang)的 allocator 靠动态 tensor 切片/拼接实现,一次 allocate + free 约 12 µs,而 ECHO 的 graph-friendly allocator 只要 6-8 µs,快 1.6-1.9×

但真正致命的不是这 6 µs 的差距,而是动态切片这件事本身依赖 CPU 侧控制,会导致整条 decode 路径没法被录成 graph——也就是说,图 (a) 里那 1.5× 的收益直接就没了。

那用 piecewise graph(动态部分 eager 跑、静态部分录成图片段)行不行?作者的回答是不行:eviction 和 recall 是每层、每个 decode step 都发生的,图会在所有层被打断,等于把 CPU 控制和 kernel launch 又请回了最怕它们的 decode 阶段。

所以第一个挑战是:要做动态 offloading,就必须让 cache 管理整个塞进 GPU graph 里。

3.4 Recall 开销,以及 indexer 留下的时间窗口

Host-GPU 互联(PCIe)带宽比 HBM 低一到两个数量级,recall 因此很贵。之前 InfiniGen / FreeKV / CLO 的办法是提前预测哪些 token 重要然后 prefetch,但它们靠的是”层间相似性”或”步间相似性”来近似分数——预测不准就选错 token,在稀疏 attention 本身的精度损失之上再叠一层损失,复杂任务上不敢用。

那有没有可能做到无损 prefetch?作者的关键观察是一组复杂度差异:

indexer 计算是 $O(L^2)$,而 recall 只有 $O(Lk)$。

具体到 decode:indexer 必须给全部 $L$ 个历史 token 算分,而 recall 最多只搬 $k$ 个($k=2048$ 是常数,$L$ 却随 context 一直长)。而且 GPU pool 本身缓存了一部分,实际要搬的往往远少于 $k$ 个。

如下图,左右分别是 batch size 1 和 2 下,indexer 计算延迟(橙线,随 context 增长)和不同 hit rate 下的 recall 延迟(蓝色虚线)。

indexer 计算延迟与不同命中率下的 recall 延迟

对着图看:橙线一路往上爬,当 context 接近 100K、hit rate 到 0.9 时,橙线已经超过了对应的蓝虚线——indexer 算的时间比 recall 搬的时间还长。而且两者抢的是不同资源(indexer 吃 GPU 算力,recall 吃 PCIe 带宽),天然可以重叠。H20 这里是 64GB/s 的 PCIe Gen5,换成 NVLink/SXM(600-900 GB/s)recall 只会更快。

结论就是:indexer 计算期间,PCIe 完全闲着,这是一个白白浪费的时间窗口。

问题是窗口用不上。因为 top-k 必须等所有分数算完才能确定谁入选,recall 只能排在 indexer 之后。

所以第二个挑战是:top-k 的全局依赖挡住了 prefetch,让 PCIe 在 indexer 计算期间白白空转。


4. ECHO 总体设计

两个挑战,两个模块。如下图是整体架构。

ECHO 总体架构

对着图走一遍数据流:

  1. 每个 DP attention worker 同时维护 GPU 和 host 两个 KV cache pool,host pool 可以远大于 GPU pool(实验里 host 1.8M token vs GPU 200K)。
  2. indexer 的 K cache 常驻 GPU pool(它每步都要全量访问,放 host 不现实),而 MLA 的 KV cache 两边都存:host 是全量备份,GPU pool 只是被选中 token 的一个 cache
  3. Graph-Friendly Cache Manager 维护两个 pool 的状态元数据。所有元数据都在 GPU 上,所有更新操作都能被录制/replay,decode 时整条路径在一张图里跑完
  4. indexer 计算的同时,Fused Lossless Prefetch 已经开始把一部分重要 token 搬过来了,prefetch 直接融进 indexer kernel 并做软件流水。
  5. indexer 算完后,对”选中但还没到 GPU 上”的 token 补一次 Guaranteed Recall(保证召回)——这一步是无损的关键:prefetch 只是提前搬,不改变最终选中的集合
  6. 最后根据 index score 更新 token 的驱逐优先级。

关于 PD 分离的一个工程 take:ECHO 两种部署都支持,但作者建议配 PD 分离(prefill / decode 分开部署),理由挺实在。第一,prefill 阶段每个 query token 独立选 top-k,总选中集合是所有 token 的并集,最坏情况会访问全部历史 token,把别人的 cache 全挤掉。第二,prefill 期间要往 host 写大量 KV,实测会和 recall 抢 PCIe。所以 ECHO 在 prefill 实例上直接关掉 offloading(反正 prefill 并发低,单个 128K 请求 HBM 装得下),decode 实例才做 offload + prefetch。


5. Graph-Friendly Cache Manager

5.1 元数据:全部是定长整数 tensor

因为 graph execution 不允许动态创建或变长 tensor,所以所有元数据都静态预分配、定长,而且都是 GPU 上的整数 tensor:

元数据 含义
GPUTokenFree bitmap,标记 GPU pool 每个 slot 是否空闲
GPUTokenPriority 每个 slot 的驱逐优先级,低的先被踢;空闲 slot 初始化为 -1
GPUIndicesBuffer 存放 allocate / free 操作产出的 GPU pool 索引
GPUTokenToHost GPU slot → host pool 索引的映射,初始 ∞ 表示空
HostTokenToGPU host slot → GPU pool 索引的映射,初始 ∞ 表示不在 GPU 上

有个设计细节值得单独说:GPU pool 按层(per-layer)管理,host pool 按模型(per-model)管理。

为什么?因为动态稀疏 attention 每层选中的 token 是不一样的,各层的 cache 状态天然分叉,没法像传统系统那样所有层统一分配/释放。而 host pool 存的是全量备份,不受 eviction/recall 影响,所以还能按模型统一管。

代价是元数据存储开销。设 host pool 有 $N_H$ 个 token、GPU pool 有 $N_G$ 个,总开销是 $4N_H + 13N_G$ 字节。代入实验配置 $N_H = 2M$、$N_G = 200K$:每层约 10MB,DeepSeek-V3.2 全部 61 层加起来约 610MB。相比省下来的 HBM 空间,这笔账是划算的。

5.2 三个并行 in-graph 操作

cache manager 只有三个基本操作:allocate、free、recall。因为改成 per-layer 管理,每层都要更新一次(而不是每次 forward 一次),所以每个操作都必须极致轻量。ECHO 的做法是全部用并行 GPU 操作实现,不引入任何变长 tensor

如下图,把三个操作的元数据变化画在了一起,我们对着走一遍。

graph-friendly cache manager 的元数据与更新操作

Allocate(图中第二栏):需要给新生成的 KV 或即将 recall 的 token 找位置时调用。传统实现靠动态切片,ECHO 换成了一个 atomic 技巧:

  • 所有 GPU 线程并行读 GPUTokenFree
  • 手上有空闲 slot 的线程对一个全局计数器做 atomicAdd
  • atomicAdd 返回的是加之前的旧值,靠这个返回值就能定出”我是第几个中签的”,从而决定哪些线程真正分配。

图里的例子是 size=2, host indices=(3,4):6 个线程拿到的返回值是 4 0 3 5 1 2,其中返回值 < 2 的是 thread 1(返回 0)和 thread 4(返回 1),所以分配 GPU slot 1 和 4。接着更新元数据:把这两个 slot 的优先级从 -1 抬起来,并在 GPUTokenToHost / HostTokenToGPU 之间建立 GPU slot (1,4) ↔ host slot (3,4) 的双向映射。最后把分到的索引写进 GPUIndicesBuffer 供后续使用。

关键在于全程没有变长 tensor——中签者的数量是动态的,但结果写进定长 buffer 里,靠 -1 表示无效。这就是能录进 graph 的原因。

Free(图中第三栏):空间不够时驱逐。步骤是:

  1. 先保护:把 indexer 本轮选中的 token 的优先级抬高,避免刚选中就被踢(图中 protected indices = (1, 5));
  2. 并行 argtopk:找出优先级最低的若干 slot,索引同样写进 GPUIndicesBuffer
  3. 并行 scatter:用这些索引更新 GPUTokenFreeGPUTokenToHost,并把 HostTokenToGPU 对应位置改回 ∞。

优先级低的先走,效果上是个 LRU-like 策略。前面提过但值得再强调:驱逐不需要传任何数据回 host,因为生成时就备份过了,纯改元数据。host pool 里的 token 只在请求结束时才释放。

Recall(图中第四栏):从 indexer 拿到选中 token 的 host 索引后,先用一次并行 scatter 查 HostTokenToGPU∞ 的就是缺的;然后调 free + allocate 腾出 slot;最后更新双向映射,并把 KV 从 host 搬到 GPU。

搬运这一步也有讲究:ECHO 用 unified virtual memory 让 GPU kernel 直接访问 host 内存,而不是走 host 侧发起的拷贝。这样既避免了 CPU 干预(保住了 graph execution),又提高了 PCIe 带宽利用率。

实现层面:元数据就是普通 PyTorch tensor,并行更新操作用 Triton 写。作者也承认,在 NVIDIA 卡上手写 CUDA kernel 能更快,但 Triton 兼容性更好,能覆盖更多硬件后端——这是个挺务实的取舍。


6. Fused Lossless Prefetching

这一节是 ECHO 我觉得最漂亮的部分:怎么在不改变 top-k 结果的前提下提前搬数据。

6.1 Intra-query prefetching(decode):把 top-k 变成 top-p

思路的起点是那句”top-p 可以边算边定”。如果能找到一个阈值 $p$,让”分数 > p”这件事等价于”进 top-k”,那 prefetch 就成立了。

问题是这个阈值就是第 k 大的分数,而它得算完才知道。

ECHO 发现的数值性质是:第 k 大的分数在相邻 decode step 之间高度可预测。 具体用 EMA(指数移动平均):

\[\hat{s}_{t+1} = \alpha \cdot \hat{s}_t + (1 - \alpha) \cdot s_t\]

其中 $s_t$ 是第 $t$ 步实际的第 $k$ 大分数,$\alpha = 0.5$。

如下图,是 DeepSeek-V3.2 第 3 层和第 50 层的第 2048 大分数(蓝线,ground truth)和 EMA 预测(橙线),用的是 ShareGPT 里一个 20K token 的请求。

第 2048 大 index score 的真实值与 EMA 预测

两层的分数量级完全不同(layer 3 在 2.0 附近,layer 50 在 -3.5 附近),但橙线都能贴着蓝线走。这也说明为什么阈值必须按层预测,不能全局取一个。

预测出阈值之后,indexer 算分的过程中,任何分数超过预测阈值的 token 立刻开始 prefetch。如下图是这样筛出来的候选 token 数量。

prefetch 候选 token 数量

蓝线是用 0 当阈值(相当于不预测),黄线是 EMA 预测。可以看到黄线大部分时候都贴在 2048 附近,说明预测出来的阈值确实卡得很准。

有些步候选数会超过 2048(阈值低估了真实值)。prefetch 太多会拖慢 indexer 计算,所以 ECHO 给 prefetch 数量设了上界,用一个全局计数器强制执行,上界与 context 长度成正比(因为 indexer 计算时间也与 context 成正比)。

为什么这是无损的? 因为 EMA 只用来决定”提前搬谁”,不用来决定”最终选谁”。预测偏乐观就多搬几个(浪费一点带宽),偏保守就少搬几个(少省一点时间),但 indexer 算完之后那次 guaranteed recall 会把真正 top-k 里缺的补齐。所以最终参与 attention 的 token 集合和不做 prefetch 时完全一致。这和 InfiniGen 那类”预测错就真的选错”有本质区别。

6.2 Inter-query prefetching(prefill):近似 top-k + 分数平移

prefill 阶段天然有另一个窗口。indexer 会把输入的 Q 切成多个 block 顺序处理,所以算 block $i+1$ 分数的同时,可以 prefetch block $i$ 选中的 token

但这里有个性能陷阱:在 kernel 里精确算 top-k 会严重拖慢 indexer。所以 ECHO 用近似 top-k,只求拿到 top-k 的一个子集去 prefetch(剩下的照样靠 guaranteed recall 兜底)。

做法基于 radix select top-k:按分数最高 8 位映射到 256 个 bin 建直方图,然后取最大的几个 bin。设 bin 大小为 $S_0, \dots, S_{255}$,选最高 $j$ 个 bin 满足 $S_{256-j} + \dots + S_{255} \le k$ 而再多一个就超了,那么 $S_{255-j}$ 这个 bin 叫阈值 bin

关键是这个不等式:$S = S_{256-j} + \dots + S_{255} > k - S_{255-j}$。也就是说,阈值 bin 越小,拿到的子集就越接近完整的 top-k

怎么把阈值 bin 做小?还是 EMA——用上一个 prefill chunk 末尾 token 的第 k 大分数预测当前 chunk 的阈值,然后建直方图时把所有分数减掉这个预测值。分数平移之后,关注区域正好落在直方图分辨率最高的地方。

如下图,箱线图对比了平移前后阈值 bin 的大小分布。

平移前后阈值 bin 大小的分布

平移之后阈值 bin 明显变小,近似 top-k 的精度就上来了。

6.3 融合 kernel:三级软件流水

prefetch 要真的省时间,就不能作为独立 kernel 串在后面,得融进 indexer kernel 里和计算重叠。ECHO 基于 DeepGEMM 的 indexer kernel 改,用了两个技术:

Warp specialization(warp 专门化):传统 SIMT 模型下一个 warp 内所有线程执行相同指令,但现代 GPU 有 tensor core、TMA(tensor memory accelerator)这些专用单元,SIMT 喂不满。warp specialization 就是给 CTA 内不同 warp group 分派不同角色——比如一部分 warp 专门用 TMA 搬数据,另一部分专门用 tensor core 算。

软件流水:配合异步 barrier,让不同 warp group 的执行互相重叠,形成多级流水。原始 DeepGEMM kernel 是两级流水(TMA 生产者 → GEMM 消费者),ECHO 加了第三级。

如下图是 decode 用的融合 kernel。

decode 阶段的融合 intra-query prefetch kernel

对着图走一遍。decode 时 query token 很少,所以按 indexer K cache 切分任务:每个 SM 负责一个 query 向量和一部分 K cache。query 向量常驻 shared memory,然后三级流水转起来:

  • TMA warps:把 indexer K block 从 global memory 迭代搬进 shared memory(图中 K0、K1);
  • GEMM warps:消费 K block,算出 index score 写回 shared memory;
  • Prefetch warps:消费这些分数,和预测阈值比较(图右下角那个 Scores > Est. TopK 的示意),超过阈值的就从 host 内存直接加载。

看右边时间线:TMA 算 K1 的时候 GEMM 在算 K0,GEMM 算 K1 的时候 prefetch 在处理 K0——三者叠起来了。为了防止 prefetch 拖慢主计算,ECHO 给 prefetch 分配了更多流水级,并用前面说的全局计数器卡住数量。

prefill 的融合 kernel 如下图,差别在于按 query 切分:每个 SM 负责若干 Q block 和对应的全部 K block,TMA 外层循环搬 Q、内层循环搬 K。

prefill 阶段的融合 inter-query prefetch kernel

prefetch warps 拿到 score block 后,先用预测值做平移,再扫一遍建直方图。当前 Q block 算完的瞬间,prefetch warps 查直方图把超阈值的 token 搬过来,同时 TMA 和 GEMM warps 已经在算下一个 Q block 了——这就是 inter-query 的重叠。


7. 实验结果

7.1 配置

  • 硬件:单节点 8×NVIDIA H20(96GB),PCIe Gen5 64GB/s,224 核 Intel Xeon Platinum 8480+,1.5TB DRAM。
  • 对比系统:vLLM v0.11.1、SGLang v0.5.4(CUDA 12.9 + PyTorch 2.8)。ECHO 和 SGLang 都是 attention DP8 + MoE TP8;vLLM 只能全 TP8(它不支持 DP+TP 组合,而 MoE 用 EP 在 8×H20 上显存不够起不来)。chunked prefill 都设 2048,三者都开 CUDA Graph
  • 模型:FP8 原版在 8×H20 上装不下,所以用 4-bit 量化的 DeepSeek-V3.2-Exp,indexer 和第 0、1、2、60 层不量化以保精度。
  • ECHO host pool:1.8M token,约 1000GB host 内存。
  • Workload:长上下文用 InfiniteBench 采样 318 个 80K-100K token 的请求(共约 26M input token);短上下文用 ShareGPT。

7.2 吞吐:主结果

如下图,是 PD 分离设定下的 generation throughput(8×H20 作为 decode 实例,KV cache 预先算好)。图上标注的数字是 effective batch size,即每个 decode step 平均处理的活跃请求数。

InfiniteBench 上不同请求率下的生成吞吐

先看 (a)(输出固定 256 token)。低请求率下三家差不多,但随请求率上升 ECHO 一路往上爬,到 Inf 请求率(所有请求同时发进来)时相比 SGLang 2.15×、相比 vLLM 4.1×

吞吐差距的来源就写在标注数字里:ECHO 的 effective batch size 是 102,SGLang 是 28,vLLM 只有 1.5。往回追:

  • SGLang 的 GPU KV pool 是 325K token,每卡只能装 3-4 个 InfiniteBench 请求,跨 DP worker 加起来 effective batch 约 30;
  • vLLM 因为纯 TP 部署,MLA KV cache 在 8 卡上重复存了 8 份,8 卡总 pool 只有 180K token,effective batch 掉到 1.5(不过 TP 部署单步延迟低,所以吞吐没按 batch 比例崩);
  • ECHO 的 host pool 有 1.8M token,batch 自然能撑起来。

再看 (b)(不限制输出长度),ECHO 对 SGLang 是 2.08×。这里有两个有意思的反向效应:低请求率下更长的生成反而提高了 DP worker 利用率,两家吞吐都涨;但高请求率下长尾生成造成 DP worker 负载不均,有的 worker 空转,两家吞吐都掉。vLLM 纯 TP 反而不受 DP 负载不均影响。

7.3 HBM 越紧张,ECHO 越占优

如果 MoE 参数更大、HBM 更紧张会怎样?如下图,作者把 GPU pool 压小重测。

受限 HBM 容量下的生成吞吐

  • (a) GPU pool 压到 200K token(ECHO 和 SGLang 同配置):优势从 2.15× 涨到 3.10×
  • (b) 进一步压到 110K token(三家同配置):SGLang 和 vLLM 显著退化(它们的 batch 直接被 pool 大小卡死),ECHO 靠 host pool 维持,达到 4.12×

作者还提了一句挺反直觉的观察:ECHO 在 GPU pool 更小的时候性能还略有提升,因为 pool 小了元数据管理开销也小了。

7.4 分任务看:收益并不均匀

如下图是不打输出长度上限、按 InfiniteBench 单任务分别测的结果,下方直方图是各任务的输出长度分布。

InfiniteBench 各任务的生成吞吐

ECHO 在 Code.Debug 上提升 27.07%、En.MC 2.83%、En.QA 7.11%,但在 Code.Run 上比 SGLang 低 1.74%

作者给的两个解释都很诚实:一是单任务请求数太少(分别只有 75、160、31、42 个),并发撑不起来,喂不满所有 DP worker;二是看下方直方图,单任务的输出长度分布更偏斜,少数长尾请求主导了整个任务的运行时间,这时候 SGLang 更低的 ITL 反而能抵消 ECHO 更大 batch 的优势。

7.5 延迟:这是有代价的

天下没有免费的午餐。如下图是 ShareGPT(短上下文、轻负载)下的延迟对比。

ShareGPT 上的 TTFT、ITL 和端到端延迟

  • TTFT 最多 +7.9%,主要来自 prefill 时把生成的 KV 备份到 host;
  • ITL+2.7% 到 +27.8%,因为 decode 每层都要做 cache 管理和 recall;
  • 端到端在请求率 0.1-0.2 时 +15.9%~19.2%,请求率 ≥0.3 时降到 ≤7.2%,0.5 到 Inf 之间 <4.6%

规律很清楚:开销在轻负载时最痛,因为每步处理的请求少,固定的管理和 recall 成本没东西可以摊薄。所以 ECHO 的定位就是面向吞吐的长上下文 serving,不是延迟敏感场景的银弹。这点作者在 discussion 里也直说了。

7.6 延迟拆解:开销到底花在哪

如下图,是 Inf 请求率下的延迟拆解。

ECHO 与 SGLang 的延迟拆解

(a) prefill 每层延迟:6.82ms → 7.42ms,+8.8%,增量主要落在 attention 部分(cache 管理 + offload)。

(b) decode 全层延迟:398.5ms → 409.8ms,只 +2.8%。注意这张图最重要的信息是MoE 占了约 70%(275.9ms,含 dequant、GEMM、AllReduce)。ECHO 给 graph replay 加了 5.9ms(CPU 侧 launch 和元数据开销),剩下约 6.8ms 主要来自 DP worker 间 ReduceScatter 同步——因为 ECHO 并发更大,通信抖动也更大。

(c) 一个 decode step 的全层 offloading 开销:allocate 0.167ms、free 0.572ms、recall 0.145ms、KV offload 0.261ms,总共 1.15ms,只占全层 decode 延迟的 0.28%

这个 0.28% 就是 graph-friendly cache manager 的成绩单。具体到规模:allocate/free 平均每步只处理 37 个 recall 进来的 token,KV offload 每个活跃请求写 1 个新 token,平均每 DP worker 13 个

7.7 命中率:为什么 recall 量这么小

上面 37 个 token 的数字背后是很高的 GPU pool 命中率。如下图是各层的平均命中率。

各层 GPU pool 命中率

大部分层稳定在 0.97-0.99,只有第 12 层(0.95)和第 17 层(0.88)低一些。这和已知的 attention pattern 观察一致——注意力里存在”竖线”结构,相邻 query token 选中的 token 高度重叠,所以上一步搬进来的这一步大概率还能用。

7.8 Prefetch 的消融

如下图是 intra-query prefetch 的微基准。

开关 intra-query prefetch 的延迟对比

hit rate 0.5 时总延迟降低 1.29×,0.9 时 1.51×——0.9 的情况下 recall 开销几乎被完全隐藏。但 hit rate 0.97 时提升就很小了,因为大部分 token 本来就在 GPU 上,没什么需要搬的。

端到端呢?如下图。

开关 intra-query prefetch 的端到端吞吐对比

只有 最多 4%。作者自己解释了这个落差:一是 InfiniteBench 上命中率本来就很高(0.97-0.99),需要 recall 的比例小;二是回看图 16(c),MoE 占了 decode 时间的 70%,省掉 recall 那一小块对总时间影响有限。作者认为换用 expert parallelism 或更高效的 MoE kernel 之后,prefetch 的端到端收益会更明显。

inter-query prefetch(prefill)的收益更有限,如下图,最多 1.1×

开关 inter-query prefetch 的延迟对比

原因是 prefill 时每个 query token 独立选 top-k,总选中量最坏是 query token 数 × k,recall 量比 decode 大得多,prefetch 那点提前量占比就小了。不过作者也说这个结果低估了潜力——评测里假设不同 query token 选中的 top-k 是随机分布的,而已有工作观察到它们之间重叠很大,利用这个重叠能砍掉大量冗余 recall,这留给future work。


8. 几个 discussion 里的 take

能推广到别的稀疏 attention 吗? 作者分得很清楚:cache manager 可以,prefetch 不一定。 cache manager 解决的是”动态稀疏模式下 KV 的驱逐与召回”这个通用系统问题,天然适配 token 级稀疏(如 DSA),改改元数据粒度、给 kernel 加上 GQA 的 KV-head 维度也能支持 block 级稀疏——它应该被看作动态稀疏 attention serving 的系统底座。但 intra/inter-query prefetch 依赖 DSA indexer 的两个具体性质(选择边界跨步可预测、prefill 按 query block 顺序暴露候选),换成别的机制就得看它有没有类似的”早期信号”。作者顺手提了个方向:能不能反过来在设计稀疏 attention 时就让选择机制 prefetch 友好——这个 co-design 的想法我觉得挺有意思。

吞吐-延迟的取舍会一直存在吗? 作者的判断是会收窄。管理开销可以靠更专门的 kernel 优化压下去,传输开销则吃互联带宽的红利——NVLink-C2C 在 Grace 上已经是 900GB/s,Vera 上预期 1.8TB/s。按这个趋势,offload 有机会从”吞吐优先”的技术慢慢变成延迟敏感场景也能用的技术。

KV cache 变便宜了,offload 是不是就没意义了? 这个问题问得好,毕竟量化、hybrid attention 都在降 per-token 成本,新卡 HBM 也上 200GB 了。作者的回答是两边同时在涨:一边 agentic workload 把 1M context 推成主流且还在往上走,另一边 MoE 参数在冲万亿规模、挤占本来给 KV cache 的空间。所以 KV cache 效率和 HBM 容量的改善会被 context 和模型规模的增长抵消掉,分层 KV cache 管理是个长期的系统问题


9. 我的一点私货

读完这篇,我觉得最值得拿走的不是 2.1× 这个数字,而是两个方法论层面的东西。

第一,”能不能进 CUDA Graph”正在变成推理系统设计的一等约束。 这篇里 graph execution 不是锦上添花的优化,而是倒推整个数据结构设计的源头。因为不能有变长 tensor,所以元数据全定长 + 用 -1/∞ 做哨兵;因为不能有 CPU 控制,所以 allocate 要用 atomicAdd 的返回值做无锁定序、free 要用 argtopk + scatter;因为 KV 搬运不能走 host 发起的拷贝,所以用 UVM 让 GPU kernel 直接读 host 内存。换句话说,”必须能被录制”这条约束把一个看起来很朴素的内存管理器逼成了一个全并行的 GPU 数据结构。 这跟我之前在 MoE 推理上踩的坑是同一类问题——你以为瓶颈在算法,实际上卡你的是”这段逻辑能不能不打断 GPU 执行流”。

第二,”无损”这个词值得反复推敲。 InfiniGen 那一路的 prefetch 也叫 prefetch,但它是用预测替代选择,预测错了 token 就真选错了,精度损失叠在稀疏 attention 本身的损失之上。ECHO 的做法是用预测决定”提前搬谁”,用精确计算决定”最终选谁”,中间靠一次 guaranteed recall 兜底。预测不准的代价只是多搬或少搬一点数据,不会传导到模型输出上。这个”把投机限制在性能维度、不让它泄漏到语义维度”的模式,其实和 speculative decoding 靠 verify 保证输出分布不变是同一个哲学。做系统优化的时候,先想清楚你的近似会污染哪一层,这个习惯比具体技巧值钱。

再补一个吐槽。这篇的 ablation 挺实诚:intra-query prefetch 微基准 1.51×,端到端只剩 4%,作者没藏着,还老老实实分析了是因为 MoE 占了 70% 的 decode 时间。这其实暴露了一个更大的现实——在 MoE 模型上做 attention 侧的优化,天花板是被 MoE kernel 效率压着的。你把 attention 和 KV 传输优化到极致,端到端也就动那么几个点。作为研究 MoE 系统的牛马看到这里还是挺有共鸣的:MoE 的稀疏激活优势和它的全量参数加载/通信开销,仍然是整个行业的核心矛盾,attention 这边省下来的时间,很容易被 MoE 那边的 dequant + GEMM + AllReduce 吃回去。

欢迎评论区交流,有理解错的地方也请指出。


顺带说一句题外话。这篇里让我印象最深的其实是”约束反推设计”这个思路——graph execution 这条硬约束,逼出了一整套全并行的元数据结构。这种”先把部署约束摆上桌,再倒推算法/结构怎么改”的做法,在自动化模型优化里也是一样的:搜索空间怎么定、什么算可行解,本质上都是被部署约束框出来的。我们把这方面的积累整理成了《动手学 AutoML:从 NAS 到大语言模型优化实战》,讲搜索空间设计、搜索策略、架构评估,以及 LLM 压缩(剪枝/量化)和模型融合这些偏落地的部分。需要说明的是,这本书里没有 KV cache offloading、CUDA Graph 这类推理框架工程内容,和本文是方向相邻而不重叠——如果你感兴趣的是”怎么自动化地把模型改小改快”,那它可能对得上;如果你想找的是 serving 系统源码级的东西,那它帮不上忙。

动手学AutoML书籍封面

Flag Counter