系列:《0.8MB 跑通 Qwen:从零实现 ARM 零依赖纯 C 推理引擎》(30 天 × 90 篇) | 适配模型:Qwen3-VL-8B-Instruct(千问3_VL_8B_Instruct)· Qwen3-VL-2B-Instruct · Qwen3-30B-A3B | 测试设备:RK3588(4×Cortex-A76 + 4×Cortex-A55,aarch64)
系列总纲:《0.8MB 跑通 Qwen》30 天 90 篇 · 总纲(阿里云社区)
上一篇:11-2《前缀缓存键:怎么知道"一样"》 | 下一篇:第 12 天《磁盘 KV(L3 与跨进程恢复)》
真机实测通过:本文实验已在 RK3588 板端实测完成(2026-09;方法学与原始记录见仓库 docs 与《实验脚本》目录)
一句话导读:缓存的一生:推理引擎的 KV 区是加载时一次性预分配的内存,命中只是续用、不命中只是覆盖、进程一死全没——本篇沿"出生、续用或覆盖、死亡"三段生命周期,回答 KV 占多少内存、何时被淘汰、能否指望它救断电。
关键词:手搓 Qwen 推理引擎、千问大模型推理、Qwen3-VL、零依赖纯 C、KV cache、生命周期、内存淘汰、预分配
导语:KV 缓存的一生其实只有三个状态:模型加载时按 max_seq 一次性预分配,请求命中只是续用、不命中只是逻辑覆盖(内存并不还给 OS),进程一死全没。本篇沿出生、续用或覆盖、死亡三段,回答 KV 占多少内存、何时被淘汰,以及为什么进程内缓存救不了断电。
11-1、11-2 讲清了"命中时多快"。今天问三个更实在的问题:那些 KV 行占多少内存?不命中的时候它们去哪了?能不能指望它救断电? 答案一句话:KV 区是加载时一次性预分配的内存,命中只是"续用",不命中只是"覆盖",进程一死全没。
1. 知识点:KV 缓存的"生命周期"其实只有三个状态
把 KV 区当作一个对象,它的一生只有三个阶段:
- 出生(模型加载时):按
max_seq(本引擎 8192)一次性分配好整个 KV 区。Qwen3-VL-2B 是 28 层、q8 KV 每 (token, 层) 2112 字节(Day 9-1 的量),整个区 = 2112 × 28 × 8192 ≈ 484 MB——请求还没来,这笔内存就已经占下了(引擎启动日志的max_seq=8192, kv_bs=32就是它的出生证明); - 续用或覆盖(每次请求):请求来了,11-1 的
ist_reset二选一——命中就cache_len[l] = keep(续用前段),没命中就cache_len[l] = 0(逻辑清空,等待被新一轮 prefill 覆盖)。注意是"逻辑清空":内存没有还给 OS,旧的 KV 字节还躺在原地,只是从cache_len这个"指针"上解绑了; - 死亡(进程退出):只有
st_qwen_inference_free/ 进程退出才真正释放。所以进程内缓存救不了断电、救不了重启——那需要把 KV 落盘(Day 12 的磁盘 KV)。
推论(都反直觉但真实):
- 命中不是"分配",是"跳过写入":命中和不命中,RSS 几乎一样——KV 区早就分配好了;
- 淘汰不是"删除",是"下次覆盖":没有 LRU、没有逐出器。旧缓存被淘汰的唯一方式是下一个请求用新的 KV 把它盖掉;
- 容量上限 = max_seq:KV 区不会超过 8192 token 的窗口,超长 prompt 在 serve 层就被钳制(
DISKKV_MAX_TOKENS8192 是 Day 12 的主题之一)。
2. 对应代码:生命周期在哪儿实现
出生:推理状态初始化时按 max_kv_slots(= max_seq)分配各层 KV(引擎启动日志"inference state ready (max_seq=8192, kv_bs=32)")。KV 区字节数的算法见 Day 9-1(q8 2112 B/(token·层)),可以自己在引擎日志里对着 max_seq 复核。
续用/覆盖:11-1 的 ist_reset(vllm_server.c 第 405 行起)。命中分支只改 cache_len/seq_len(第 412–413 行),不碰内存;未命中分支把 cache_len 清零(第 420–421 行)——两分支都没有 malloc/free,这就是"预分配 + 覆盖"的代码证据。
谁还记得上一轮:ctx->last_ids(vllm_server.c 第 1338–1349 行在每次请求成功后把"prompt+生成"的 token 序列存下来)——它和 cache_len 一起,构成"缓存键 + 缓存状态"两件套。
观察窗:管理面 /admin/api/status(vllm_admin.c 第 363 行起)把引擎活着的证据摊开:busy(忙不忙)、active/queued(几个请求在跑/在等)、total_requests(活过多少请求)、rss_mb(本进程内存,来自 /proc/self/status,第 70 行的读取器)、mem_avail_mb(板子还剩多少内存)。想亲眼验证"KV 预分配、命中不涨内存",就用它。
3. 改动后果:换了个话题,缓存键立刻失效——板端对照
口径:RK3588 / Qwen3-VL-2B / 2026-09 / serve 同进程贪婪(前缀复用默认开)。
实验:同进程连发两个"完全不同上下文"的长请求
| 请求 | prompt 内容 | prefill token 数 | prefill 耗时 | [KV-PREFIX] 命中? |
|---|---|---|---|---|
| R1:长事实文 + Alice 问题 | 事实填充 + 提问 | 2044 | 46.30 s | 无(首轮无缓存可比) |
| R2:完全不相关的 fox 长文 + 提问 | 另一主题长文 | 4324 | 167.22 s | 无(LCP < 16) |
判读:
- 换话题 = 缓存键失配:R2 与 R1 的 token 序列从头就不一样(
kv_lcp返回 <16),keep=0,ist_reset走清零分支——旧缓存被逻辑清空,R2 全量重算; - R2 的 prefill 代价和它的长度成正比:4324 token 花了 167.22s(其中注意力占 63.3%),等于把 11-1 省下的钱原样又花了一遍——这不是 bug,是"前缀复用只在话题连续时才有效"的边界:真实多轮对话恰好是连续的,但换人/换任务/后台批处理时,这个优化帮不上忙;
- 对照 11-1 那组命中数字(2044→31 token):同一个优化,命中时省 27×,不命中时一分不省——前缀缓存不是"加速器",是"去掉重复劳动的加速器"。
实验:RSS 观察(KV 预分配的实证)
同一台板子,
/admin/api/status的rss_mb读到的实测值:
| 时刻 | rss_mb(/admin/api/status) | 说明 |
|---|---|---|
| 模型加载完、尚未请求 | 2407 | 权重 mmap 未触页;KV 区已预分配 |
| R1(短问答)之后 | 3336 | 第一次前向把权重触页进 RSS(+929 MB) |
| R2(复用命中)之后 | 3361 | 只 +25 MB(增量 KV 行 + 输出缓冲) |
R1→R2 复用命中的那几秒,RSS 只涨了 25 MB——因为 KV 区是"出生"时就买断的,命中只是续用。真正让 RSS 从 2407 跳到 3336 MB 的是模型权重的首次触页(VQF mmap 懒加载,Day 3 的机制),不是 KV。
4. 学员调试任务
- A 档(板端动手):复刻实验二:加载后
curl -s http://127.0.0.1:PORT/admin/api/status | jq '.rss_mb,.mem_avail_mb,.busy,.total_requests',发一短一长两个请求,再 curl 一次,记录 rss 涨跌并解释每一项变化来自权重触页还是 KV 分配。再复刻实验一(换话题),确认日志里没有[KV-PREFIX]。 - B 档(纯读源码):读
ist_reset两分支(vllm_server.c 405–438),回答:① 命中与未命中分支的cache_len各被设成什么?② 为什么两分支都不释放内存?③ 若你把max_seq调小一半重编,KV 区内存怎么变(用 Day 9-1 的 2112 B 公式算)?
预期输出:你能画出 KV 缓存"出生(预分配)→ 续用/覆盖 → 进程死"的一生,并解释"为什么进程内缓存救不了重启"。
收尾
- 本篇源码点名:vllm_server.c(
ist_reset405–438、last_ids1338–1349)、vllm_admin.c(/admin/api/status363 行起、rss_mb405–406、/proc 读取器 70 行)、main.c(max_seq默认与--no-prefix-kv)。 - 开源仓库:Kestrel-LLM (Gitee)(AGPL-3.0-or-later 或商业许可,二选一)
- 下篇预告:进程内缓存再快,进程一崩/一重启就清零。Day 12 上磁盘 KV(
--disk-kv):把 KV 快照落盘、重启后按 LCP 找回——复现仓库报告里那个"13.1× 跨进程恢复"。