ik_llama.cpp 慢速 KV Cache 删除剖析:DeepSeek-V3 混合卸载场景下的缓存回收与性能调优
2026/9/18 23:41:42 网站建设 项目流程

ik_llama.cpp 慢速 KV Cache 删除剖析:DeepSeek-V3 混合卸载场景下的缓存回收与性能调优

【免费下载链接】ik_llama.cppllama.cpp fork with additional SOTA quants and improved performance项目地址: https://gitcode.com/GitHub_Trending/ik/ik_llama.cpp

本文基于 ik_llama.cpp 仓库中 讨论 #586 "Slow KV cache rm operation" 展开。该讨论记录了一个真实的高负载 RAG 使用场景:在 DeepSeek-V3-0324(MoE)量化模型、Dense 层卸载到 GPU、专家保留在 CPU 的混合部署中,用户在 llama-server 里切换会话时需要等待数分钟才能完成 KV cache 删除(日志中的kv cache rm [p0, end))。本文将先还原该问题的现场与命令,再从源码层面剖析 KV cache 删除操作的真正开销来源,逐一回答讨论中的三个问题,最后给出可落地的缓解与调优建议。读完本文,你将理解llama_kv_cache_seq_rm的底层机制、锁页内存(pinned memory)分配失败的影响,以及如何针对"会话频繁切换 + RAG 长文档"场景优化配置。

问题现场:RAG 重负载下的"2-5 分钟等待"

讨论作者运行的硬件为 Intel Xeon QYFS(高核心数服务器 CPU)、512GB DDR5-4800 内存、NVIDIA RTX PRO 6000(48GB 级工作站 GPU),模型为 ubergarm 制作的DeepSeek-V3-0324-IQ4_K_R4量化。其核心诉求是:token 生成速度尚可接受(约 11-12 t/s),但在 RAG 重负载的真实使用中——喂入长文档、基于文档聊天、再穿插网络搜索,并在多个会话之间来回切换——每次切换会话都要等待2-5 分钟的 KV cache 移除,完全不可接受。

复现命令(sweep-bench 版,真实使用时把llama-sweep-bench换成llama-server并加上 host/port 即可):

CUDA_VISIBLE_DEVICES="0," \ ./build/bin/llama-sweep-bench \ --model /mnt/x/models/ubergarm/DeepSeek-V3-0324-GGUF/DeepSeek-V3-0324-IQ4_K_R4-00001-of-00010.gguf \ --alias ubergarm/DeepSeek-R1-V3-0324-IQ4_K_R4 \ --ctx-size 98304 \ -ctk q8_0 \ -mla 3 -fa \ -amb 8192 \ -fmoe \ --temp 0.3 \ --min-p 0.05 \ --n-gpu-layers 63 \ -ot "blk\.[3-9]\.ffn_.*=CUDA0" \ -ot exps=CPU \ -ub 8192 -b 8192 \ --parallel 1 \ --threads 57

该命令将显存占用推到 90376 / 97887 MiB,几乎用满。上下文初始化日志确认了关键配置:

llama_new_context_with_model: n_ctx = 98304 llama_new_context_with_model: n_batch = 8192 llama_new_context_with_model: n_ubatch = 8192 llama_new_context_with_model: flash_attn = 1 llama_new_context_with_model: mla_attn = 3 llama_new_context_with_model: attn_max_b = 8192 llama_new_context_with_model: fused_moe = 1 llama_new_context_with_model: freq_base = 10000.0 llama_new_context_with_model: freq_scale = 0.025 llama_kv_cache_init: CUDA0 KV buffer size = 3499.90 MiB llama_new_context_with_model: KV self size = 3499.88 MiB, c^KV (q8_0): 3499.88 MiB, kv^T: not used llama_new_context_with_model: CUDA_Host output buffer size = 0.49 MiB ggml_cuda_host_malloc: failed to allocate 3296.09 MiB of pinned memory: invalid argument llama_new_context_with_model: CUDA0 compute buffer size = 20496.03 MiB llama_new_context_with_model: CUDA_Host compute buffer size = 3296.09 MiB llama_new_context_with_model: graph nodes = 4219 llama_new_context_with_model: graph splits = 104

sweep-bench 的 PP/TG 基准数据(raw PP 正常,未出现不规则变慢):

PPTGN_KVT_PP sS_PP t/sT_TG sS_TG t/s
81922048065.721124.65173.99511.77
81922048819269.385118.07190.41610.76
819220481638473.025112.18199.02310.29
819220482457676.688106.82204.60710.01
819220483276879.945102.47208.3669.83

而真实 server 使用中的等待日志如下(用户估算 KV 移除耗时约 3 分钟):

INFO [ update_slots] kv cache rm [p0, end) | tid="125357154684928" timestamp=1751624758 id_slot=0 id_task=12104 p0=8410 INFO [ print_timings] prompt eval time = 128443.90 ms / 10172 tokens ( 12.63 ms per token, 79.19 tokens per second) | ... INFO [ print_timings] generation eval time = 10688.65 ms / 122 runs ( 87.61 ms per token, 11.41 tokens per second) | ...

注意两处关键细节:p0=8410表示从位置 8410 开始截断删除(系统提示词之前的内容被保留),而随后的 prompt eval 处理了10172 个 token、耗时约 128 秒。这个"等待"的大部分时长,实际发生在新 prompt 的重新处理上,而不只是删除操作本身——这一点将在下文源码分析中展开。

"kv cache rm" 在服务端发生在哪里

触发点一:update_slots 中的公共前缀截断

日志kv cache rm [p0, end)出自 examples/server/server-context.cpp 的update_slots流程。当新请求到来时,服务端会做"公共前缀保留 + 尾部删除":

  • slot.cache_tokens.keep_first(slot.n_past)仅保留与上一轮共同的 token 前缀(server-context.cpp);
  • 计算出删除起点p0(等于系统提示词长度加上保留的公共前缀位置,见 server-context.cpp);
  • 随后记录kv cache rm [p0, end)日志,表示要将该 slot 从位置p0到末尾的 KV cache 全部作废(server-context.cpp)。

也就是说,每次新请求只要前缀不完全重合,就会触发一次 KV 删除。在"多个会话来回切换"的场景里,新旧请求的公共前缀通常只有系统提示词,于是每次都要把几乎整个会话的 KV 清掉、再从长文档重新做 PP——这正是讨论作者感受到"等待几分钟"的直接来源。

触发点二:slot 释放时的整序列删除

当 slot 被释放(任务结束或会话切换)时,服务端还会执行llama_kv_cache_seq_rm(ctx, slot->id, -1, -1)做整序列删除(server-context.cpp)。参数-1, -1表示删除该序列从位置 0 到无穷大的全部内容。

触发点三:上下文溢出时的部分丢弃

长上下文溢出时,服务端通过discard_n_kv_and_cache_tokens丢弃一段 KV 并将后续位置前移(server-context.cpp),其核心同样调用llama_kv_cache_seq_rm删除区间[kv_keep, kv_keep + kv_discard),再用llama_kv_cache_seq_add对剩余 token 的位置做负偏移。这条路径主要服务于单会话内的长上下文管理,与讨论中的多会话切换问题不同。

底层实现:删除操作本身有多贵

llama_kv_cache_seq_rm:O(n_ctx) 的元数据遍历

llama_kv_cache_seq_rm的实现位于 src/llama.cpp。它做的事非常"轻":

  1. 归一化删除区间p0/p1(负数视为"直到末尾",见 src/llama.cpp);
  2. 遍历缓存中全部cache.size个 cell(src/llama.cpp),凡pos落在[p0, p1)区间内的,就调用cache.cells[i].seq_id.erase(seq_id)从该 cell 的序列标记中摘除当前序列;
  3. 若 cell 因此变空,则把pos置为 -1、递减used计数,并更新可复用起点new_head(src/llama.cpp)。

也就是说,删除操作本身只修改元数据(cell 的 pos、seq_id 集合),并不搬运或清零 KV 张量数据。以n_ctx = 98304为例,一次完整删除就是遍历约 9.8 万个 cell 的 CPU 循环,量级在毫秒级。真正昂贵的部分从来不是这个循环。

此外代码中有两处值得注意的特殊分支:

  • 递归/状态型模型(如 Mamba,cache.recurrent == true)不允许部分删除状态,区间不合法时会直接返回false(src/llama.cpp)。DeepSeek-V3 属于 MLA Transformer 架构,不在此列;
  • --swa-compress压缩窗口存在时,删除区间必须与压缩窗口的"单一连续区间"约束兼容,否则报错返回(src/llama.cpp)。讨论场景未启用该选项。

llama_kv_cache_clear:真正的全量清空

当需要清空整个缓存时,服务端走llama_kv_cache_clear(src/llama.cpp):遍历所有 cell 复位pos、清空seq_id,并调用ggml_backend_buffer_clear(buf, 0)把各 KV buffer 置零。注意这里的ggml_backend_buffer_clear会对 3.5 GiB 的量化 KV buffer 做整块清零(CUDA 后端通常按页/块执行),耗时与 buffer 大小相关,但仍远不足以解释"分钟级"等待。

结论:等待时间花在了哪

从源码结构看,llama_kv_cache_seq_rm的元数据遍历与ggml_backend_buffer_clear的显存清零都属于"次秒级"操作。讨论日志中kv cache rm之后紧跟的128 秒 prompt eval(10172 tokens,79.19 t/s)才是时间大头:多会话切换导致缓存命中率极低,长文档必须整体重新 PP。可以推断,用户感知的"KV cache removal 要 2-5 分钟",实际是"删除 + 公共前缀判定 + 长 prompt 全量重算"的整体时延,而非删除函数本身。

三个问题的逐一分析

Q1:ggml_cuda_host_malloc失败与删除慢有关吗?

日志ggml_cuda_host_malloc: failed to allocate 3296.09 MiB of pinned memory: invalid argument表示 CUDA锁页内存(pinned memory)的大块分配失败。锁页内存用于 CPU 与 GPU 之间的高效 DMA 传输,一次性分配 3.3 GiB 需要系统具备充足且连续的空闲物理内存;分配失败常见于系统内存被大量占用、内存碎片化,或超过可锁页上限。

从日志看,失败后服务端仍打印了CUDA_Host compute buffer size = 3296.09 MiB,说明它回退到了普通(非锁页)host 内存继续运行。这种回退对 token 生成的直接影响有限,但在本讨论的混合卸载拓扑(Dense 在 GPU、MoE 专家在 CPU)中,attention 与 FFN 的中间结果需要频繁跨 CPU-GPU 传输,锁页内存缺失会降低传输效率,从而压低 PP 吞吐。

与 KV 删除慢的关系:两者没有直接因果关系——删除是元数据操作,不依赖锁页内存。但ggml_cuda_host_malloc失败本身是系统内存压力的信号,值得单独处理:关闭占用内存的无关进程、留出足够空闲物理内存(本机 512GB 完全足够,重点排查同时运行的其它 CUDA 应用/缓存),或适当调低-ub/--parallel以减小计算缓冲需求。

Q2:60-120 SPP 对这个配置是否可预期?

讨论数据中 S_PP 随 N_KV 从 0 增长到 32768,从 124.65 t/s 缓降到 102.47 t/s;TG 稳定在 9.83-11.77 t/s。对这个具体配置(63 层 Dense 卸载到 RTX PRO 6000,全部专家留在 CPU 以 57 线程计算)而言,这个量级是可预期的

  • MoE 的专家计算(ffn_*_exps)完全落在 CPU,exps=CPU的 override 保证了这一点;CPU 专家吞吐是 PP 的主要瓶颈之一;
  • 上下文变长后 attention 与 KV 读取成本上升,S_PP 随 N_KV 下降是正常现象;
  • 作为参照,作者在 N_KV=0 时 PP 约 124 t/s,说明 GPU 侧 dense 部分工作正常。

若想提升 PP,方向是提高专家侧的并行效率(--threads与批大小匹配)、启用-fmoe的融合路径(已开启),或把更多专家层卸载到 GPU(受显存 90.4/97.9 GiB 限制,空间不大)。

Q3:KV 删除与 PP 是绑定的吗?

不是同一个东西,但时序上串行。从源码看:

  • KV 删除(llama_kv_cache_seq_rm)是一个独立的元数据操作,由 server 在update_slots中、于新 prompt 处理之前同步调用(server-context.cpp);
  • 它既不参与 PP 计算图,也不依赖 batch 处理;删除完成后,服务端才把新 prompt 加入 batch 做 PP;
  • 由于二者在同一调度循环里串行执行,删除(哪怕只是毫秒级)都会阻塞下一个请求的开始;而删除之后被迫重算的长 prompt PP(本例约 128 秒)则成为用户感知的"等待"主体。

换言之:删除本身是独立步骤,但它在关键路径上,且它的"后果"(缓存失效 → 长文档重算)才是真正耗时的部分。

混合卸载配置解读:-ot-mla-amb-ctk-fmoe

讨论命令里几个关键参数的含义与实现位置,可对照源码确认:

参数作用实现/佐证位置
-ot <pattern>=<BUFT>张量级 buffer 类型覆盖,把匹配张量放到指定设备解析在 common/common.cpp 的parse_buft_overrides,格式为tensor_pattern=buffer_type,支持正则;加载时编译为正则并应用,见 src/llama-load-tensors.cpp
-mla <n>启用/配置 MLA 注意力(日志mla_attn = 3参数解析 common/common.cpp
-amb <n>attention 计算的最大 batch(attn_max_b,低于 128 会被强制抬到 128)common/common.cpp
-ctk q8_0KV cache 以 q8_0 量化存储(日志c^KV (q8_0): 3499.88 MiBKV 初始化日志出自 src/llama.cpp 的上下文创建流程
-fmoe启用融合 MoE(fused_moe),默认开启,可用-no-fmoe关闭common/common.cpp、common/common.cpp

-ot "blk\.[3-9]\.ffn_.*=CUDA0"的含义是把第 3-9 层的 FFN 相关张量(blk.N.ffn_*)放到 CUDA0;-ot exps=CPU则把所有专家张量(ffn_*_exps)锁定在 CPU。二者组合正是"GPU 跑 dense 层、CPU 跑专家"的经典混合卸载拓扑,用以在有限显存(此处 90.4 GiB 已接近上限)内跑起千亿级 MoE 模型。

缓解与调优建议

针对讨论作者"会话频繁切换 + RAG 长文档"的真实负载,可操作的方向如下:

  1. 最大化公共前缀复用:把不变的长文档、系统指令稳定放在提示词最前部(系统提示词/固定前缀),服务端会通过keep_first保留公共部分(server-context.cpp)并用llama_kv_cache_seq_cp拷贝系统提示词(server-context.cpp),从而避免每次重算系统提示词的 PP。保持前缀稳定是当前版本下提升缓存命中率最有效的手段
  2. 明确"等待"的真实构成:通过print_timingsprompt eval timekv cache rm日志的时间戳对比,可以量化"删除"与"重算"各自的耗时(本例重算约 128 秒、远超删除本身),据此决定优化重点是缓存复用还是 PP 速度。
  3. 处理锁页内存分配失败:为cudaHostAlloc留出足够空闲物理内存(关闭无关 CUDA 进程、避免内存耗尽),必要时调低-ub/-b--parallel缩小 host 计算缓冲;锁页内存恢复后可提升混合卸载下 CPU-GPU 的传输效率。
  4. 长上下文成本控制--ctx-size 98304+ q8_0 KV 本身开销约 3.5 GiB(合理),但上下文越长、切换会话时被迫重算的 token 越多。若业务上允许,可考虑为"快速切换"场景维护更小的工作上下文。
  5. 关注上下文复用方向的进展:讨论 451 - Context reuse / context shift for long prompts 记录了社区对 token trie、缓存保存/恢复(关联 issue 436)等跨会话缓存复用方案的探讨,可跟踪其进展;在此之前,稳定的前缀结构是保证缓存命中率的现实手段。
  6. 用 sweep-bench 做 A/B 基准:作者已用 llama-sweep-bench 拿到稳定的 N_KV 递增曲线(124.65 → 102.47 t/s),后续任何参数调整都建议沿用同一基准对比,避免凭感觉优化。

小结

讨论 #586 的"慢 KV cache rm"问题,本质是多会话切换导致缓存命中率骤降llama_kv_cache_seq_rm本身是毫秒级的元数据操作(src/llama.cpp),真正的分钟级时延来自删除之后长 prompt 的全量重算;ggml_cuda_host_malloc的锁页内存失败虽不直接拖慢删除,却是混合卸载拓扑下 CPU-GPU 传输效率的隐患。通过保持提示词前缀稳定、量化删除与重算的时间构成、并恢复锁页内存分配,可以有效缩短会话切换的前置等待,让 RAG 重负载场景下的多会话体验回到可用水平。

【免费下载链接】ik_llama.cppllama.cpp fork with additional SOTA quants and improved performance项目地址: https://gitcode.com/GitHub_Trending/ik/ik_llama.cpp

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询