极致 KVCache 推理服务效能优化与多芯软硬协同实践

0 阅读13分钟

​

本文整理自 26 年 9 月 22 日 SGLang Meetup 北京站活动的同名主题演讲。

在公众号回复「SGLang-Kunlun 2609」,可以获得此次 Meetup 上午的 3 个演讲主题材料。


大家好,我是来自百度智能云的张彦哲。今天我和同事王凯一起,分享我们在前缀缓存方向上的一些经验。

今天的分享分为三部分。首先,我会介绍百度百舸在 KVCache 命中率和 KVCache 性能损耗优化两方面开展的一些通用工作;接下来由王凯介绍百度百舸在多芯适配工作。

图片​

1.    Cache 命中率

KVCache 系统有两个非常重要的目标。

第一个目标是命中率。这是用户能够直接感知的指标。比如,用户使用 Agent 工具编程时,命中部分只收取原价十分之一或二十分之一的费用。这部分成本是用户非常关注的,也是我们 MaaS 服务的核心竞争力之一。

从服务本身来看,能否维持一个比较理想的命中率,直接决定了服务水位是否可控,也反映了这项服务的成本。

第二个目标是降低 Cache 性能损耗。管理 Cache 本身是有成本的,如果不能把这项成本控制得足够低,整个服务就没有成本优势。因此,在 KV Cache 系统中,我们一方面要实现理想的 Cache 命中率,另一方面要把 Cache 性能损耗降得更低,从而提供具有成本优势的 MaaS 服务。

图片​

这是百度千帆平台的三层调度架构。请求进入服务后,首先经过全局网关,再进入地域级和集群内调度。

在地域级 Cache-Aware 调度中,百度百舸平台会确保 Session 的地域亲和,让同一会话中的多轮请求由同一集群处理,从而复用已有的 KV Cache。

请求进入集群后,还会经过集群内 Cache-Aware 调度。调度器会综合考虑 Cache 命中和负载均衡,优化集群整体吞吐。

在低负载场景下,请求会尽量向 Cache 靠拢,使命中率更加可控;在高负载场景下,则通过池化将 Cache 搬运到请求所在节点,以保证集群整体负载均衡。池化机制能够弥补调度因负载均衡而损失的命中率。

图片​

紫色部分是百度百舸使用的 Cache 系统,由部署在各节点上的 Agent 和地域级 Master 组成。

Cache 系统采用独立进程部署,Cache 的生命周期独立于推理引擎,可以在集群中共享。系统将数据面和控制面解耦,通过事件驱动的异步机制构建全局索引。调度器订阅增量 KV 事件,在本地完成检索,从而及时感知全局 Cache 分布。

为了降低池化带来的性能损耗,我们实现了预取和预取可打断两项机制。调度器产生请求调度结果后,如果请求落到的节点不是 Cache 命中率最高的节点,就会通知该节点上的 Agent,将高命中节点上的 Cache 预取到本机 L2。

当引擎开始加载 Cache 到 L1 时,预取会立即停止,系统直接使用已经预取的数据,避免预取过程影响引擎运行。

预取分为本地和远程两部分:本地预取是从 SSD 预取到内存,远程预取则通过 RDMA 将数据从其他节点的内存拉取到本机内存。

图片​

在具体业务场景下,百度百舸也进行了一些针对性优化。

首先是动态 SWA 保存步长。针对 SWA 只能在保存位置命中的特点,我们在较短的长度区间内提高 SWA Cache 的保存频率,从而提升系统提示词的命中率。

再来看 PP 并行下的 Cache 管理。外置 Cache 数据会被所有 rank 共享读写,因此不同 rank 的 Match 长度很容易不一致。但是,GPU 需要进行形状一致的计算。

传统模式由引擎负责对齐,这也是 SGLang 当前的实现。但是,PP 并行无法在 stage 之间直接同步,与外置 Cache 难以兼容。因此,我们让 Cache 系统感知 PP stage:当 stage 0 进行 Match 时,系统为后续 stage 锁定命中的 Cache;等所有 stage 完成加载后,再释放这部分 Cache,从而在引擎外实现同步,确保各 stage 读取到的长度一致。

图片​

2.    Cache 性能损耗

在性能层面,我们采用如下方式评估 Cache 损耗:

Cache 损耗 = 1 - 开启 Cache 后处理未命中 Token 的 Prefill 吞吐 ÷ 关闭 Cache 时的吞吐

关闭 Cache 时,引擎处理 Prefill 的效率最高。开启 Cache 后,引擎总吞吐会提升,但处理未命中 Token 的效率会下降,下降的部分就是 Cache 损耗。

Cache 损耗可以拆分为计算损耗和间隙损耗两部分。计算损耗来自上下文增长带来的自然计算量增加;间隙损耗则来自 Cache 管理,使 GPU 两次计算之间的间隔增大、显卡利用率降低。在总 Cache 损耗中,间隙损耗占比超过 80%,主要由 API 拷贝和 Radix Tree 管理构成。

Cache 损耗直接决定了 Cache 折扣。以 80% 命中率为例,如果命中部分按一折收费,那么 Cache 损耗必须低于 28.6%,才能覆盖折扣带来的收入差额。

图片​

百度百舸的优化主要分为以下几个方面。

第一是 Rolling Hash。内部 Cache 系统在 L2 上通过 Rolling Hash 索引 KV Cache,这会在引擎上产生额外计算。我们把 Rolling Hash 计算前置到 Tokenizer,避免干扰 GPU 的整体运行。

第二是 Radix Tree 管理优化,包括使用更高效的 Bigram key 表示和索引,以及采用 C++ 实现 Radix Tree。这些工作帮助我们大幅降低了索引管理成本。

第三是 UVA 拷贝。由于 L2 Cache 采用 Page First 布局,而 L1 Cache 采用 Layer First 布局,拷贝粒度比较小。我们通过 UVA 拷贝降低 launch 开销,提升小拷贝粒度下的性能。

针对 MLA 类 Cache,我们还对读写过程做了进一步优化。由于各 rank 的 Cache 内容相同,每张卡只通过 PCIe 读取八分之一的数据,再通过 NVLink AllGather 汇聚;同时,H2D 拷贝与 NVLink 扩散按 Layer 并行,从而进一步降低 Cache 读写开销。

图片​

下一步工作仍将主要围绕命中率和 Cache 损耗两部分展开。

在命中率方面,我们会巡检用户行为特征,预警 Cache 空间需求,使命中率更加可控。

在 Cache 损耗方面,我们希望借鉴 LMCache 的思路,将 Cache 的管理和加载外置到独立进程。请求由独立进程发起并完成加载,使加载过程与 Forward 并行,从而进一步降低 Cache 损耗。

我这部分的介绍就到这里。接下来请同事王凯介绍多芯适配方面的工作。

图片​

3.    多芯适配

这一部分我主要讲百度百舸基于昆仑芯平台适配调优 HiCache  L2 的过程。

HiCache 把前缀缓存分三层:L1 在显存;L2 一般在内存;L3 再往外,容量更大,基本上在当前的推理场景中是必选项,但常规方案 L3 数据必须经 L2 中转,所以我们在 L2 上的优化实际上也能直接给 L3 带来收益。

我们主要基于昆仑芯 P800 和 P900 这两款 XPU,主机侧有多种机型,NUMA 拓扑和 PCIe 策略都不一样。所以给我们带来的挑战主要在三个层面:分别是算子层、框架层和主机层。

图片​

第一个面临的选择是 L2 的 KV Cache 布局。L1 侧固定是 layer_first,同一层连续;L3 侧要的是一个 Page 在地址上连续,这样一次大 IO 就能落盘。L2 在中间,两边都需要兼顾到。

Layer First 和 Device 侧一致,逐层直接对拷不用重排,Page 方向还能合并;代价是一个 Page 被切成几十段,到 L3 就退化成很多次小 IO,zero-copy 用不上,mooncake 后端直接不支持。

Page First Direct 正好反过来:整页连续,L3 一次大 IO 就够;代价是和 Device 侧不一致,每次搬运都要做布局转换。

所以说,如果只做 L2,可以优先选择 Layer First,需要 L3,Page First 系列布局是必选项。

图片​

以关闭 HiCache 为基线,同场景下我们测试了 Page First Direct 的默认实现,性能远远不如 Layer First。问题在于 DMA 传输段数。

在 Page First Direct 默认实现下,Host 侧不同 Page 的同一层之间隔着层数乘 page_size,永远合并不了;Device 侧每层又是独立 buffer,把层方向的合并机会也挡住了。两边叠加,段数固定在 page 数乘层数。以 32K tokens、page_size 64、78 层的模型推理场景为例,Page First Direct 需要进行 39000+ 段传输;换成 Layer First 合并之后理想情况只有 78 段下发,差两个数量级。每段都要独立算地址、下发一次 DMA 描述符,所以卡住我们的不是 PCIe 带宽,是这些细碎 DMA 在 CPU 侧的提交。

但布局不能换,L3 要整页连续,那就只能压提交次数。

做法是批量化:写回时一次性把各层 Gather 成连续 Page,加载时用 Scatter 还原成分层布局。布局没变,L3 照旧 zero-copy,下发次数大幅减少,Page First Direct 的性能追平了 Layer First,同时,加载场景还支持按层组 Chunk 进行 Scatter,从而兼容了 Forward 和加载 Overlap 的特性。

图片​

算子优化和适配之后我们的净收益仍然不是很明显,那么该如何继续提高我们的净收益呢?

净收益等于命中率提升省下的计算时间,减去跨层数据移动的额外开销。

减号后面那一项,我们需要尽量减少跨层移动开销,关注三件事:谁去提交、搬一次要花多少、哪些数据其实不用写;减号前面那一项,在 L2 主要靠去冗余、放大有效容量,这一点后面会详细介绍。

图片​

减少跨层移动开销的第一件事是双向异步,D2H 和 H2D 虽然是异步的,但是其算子下发提交都在调度主线程里,一次大块搬运的单次提交阻塞时间不容忽略,提交没返回,调度循环就 Pending 在那儿。

为此我们做的改动是 Backup 和 Load 各起一个独立线程,在专用 stream 上提交,主线程只投递任务、消费完成事件。在我们的测试中,双向异步同场景 E2E 相对同步提交降了 14%。

图片​

第二件事是主机侧的内存形态与链路优化。

一是 Pin Memory 的形态。Host KV Pool 默认 4KB 匿名页,映射条目极多。我们先 madvise 合成 2MB 大页,再 HostRegister 注册,映射条目降到约五百分之一,CPU 页表、IOMMU、设备 DMA 三条翻译路径同时受益。

二是 NUMA 亲和。注册内存会和其他资源抢同一条本地内存和 PCIe 路径。看这张表:不绑定时 HiCache 80GB 退化 20.6%、40GB 退化 11.5%、15GB 退化 8.8% —— 退化幅度跟注册内存规模正相关,不是跟传输量正相关。按 XPU 拓扑绑定后,收益会大大提高,且基本不会受到 HiCache Size 影响。

三是 PCIe 保序。在部分平台上强绑定 NUMA 后,两颗 XPU 共享一条上行链路,两路 D2H 写的来源 ID 和目标 NUMA 都不同,本应互不相干,严格保序却让它们在共享上行处互相等待。改成基于 ID 的保序后,D2H 带宽恢复为严格保序的 4 倍。

图片​

第三件事是框架的部分优化。

write_through_selective 写入策略原意是只写被复用过的前缀,判断依据是命中计数。但 L1 空间不足时,比如长上下文、并发量较高的场景,前缀还没等到第二次访问就被淘汰,计数归零,真实的复用永远等不到确认。

我们加入了 Ghost List:Page 从 L1 淘汰时 KV 丢掉,只留 64-bit 的 Page Hash,容量可配、进出 O(1),满了按 LRU 挤掉最旧的。下次这段前缀重建时在里面命中,就说明它确实被复用过,直接写进 L2。等于把"能被认出复用过"的窗口从 L1 驻留时间拉长到了 Ghost List 的记忆窗口,而这个窗口比 L1 原本的匹配空间大了不止一个数量级,从而让write_through_selective策略更加可用。

右边这项改动更轻。空闲 slot 原来的分配策略是 FIFO,分配指针全池扫,写入很散;改成 LIFO 优先复用刚释放的 slot,写入集中在一小组热页面上。它不影响淘汰语义和命中率,纯粹是一个内存局部性优化。

图片​

下面是 TP 组共享 Host KV,这项改动不仅减少了冗余,增大了 L2 的容量提升命中率,也大大降低了 KV Cache 的写入量。

MLA,或者说较新的 DeepSeek V4 系列的 CSA、HCA 这类 Attention 有个共性:HiCache Host 侧存的是组内完全复制的隐向量,各 rank 内容逐 bit 相同。比如 TP=8 就是 8 份一样的副本,L2 有效容量只有名义值的八分之一,还带来 8 倍 D2H 流量;开了 DP Attention 后可以缓解,但是只要 TP 不等于 1,冗余还是会存在。

我们的方案是一个 TP 组共用一份共享内存 Buffer:组内 rank 映射同一段物理内存,rank 0 写,其余只读。非 rank 0 不拷贝但照样上报完成事件,所以节点何时可读仍由 rank 0 写完决定,不引入新的跨 rank 同步。

结果是同样内存,TP=8 的场景下,有效容量放大 8 倍,D2H 需要下发次数和传输量也减少 8 倍,同场景 TTFT 再降 8.3%。不仅仅是 L2,对于 L3 来说,预取到 L2 的数据量同样也减少了 8 倍,对于 L3 到 L2 的同步也有较大收益。

图片​

共享之后这块大内存放哪儿,有两种选择:跨 NUMA 交织是容量优先,一份 Pool 摊到所有 NUMA Node,尽可能用到所有内存,不会有单点热区;按 Socket 分组是带宽时延优先,避免跨 CPU Socket 的内存访问,代价是容量减少 Socket 数量倍。

图片​

端到端测试 HiCache L2 的结果,PD 分离、Agent 真实数据集、未开启 HiCache L3 层级和叠加路由策略,以关闭 HiCache 为基线:初步适配 TTFT 降低 23.4%,叠加 Host 内存形态到 27.7%,TP 组共享到 33.7%,最后把 L2 容量收敛到命中率饱和点,TTFT 降低 36%。

后续我们的工作重心主要聚焦在 L3、L3.5 及 L4 上,探索存储池化、专用存储硬件等解决方案在 Agent 推理场景下的应用。

图片​

今天我们的分享就到这里,谢谢大家。

​