返回博客

2026.10.12

缓存从 4GiB 加到 6GiB,账上还能多命中 8 到 10 个点:为什么它只活了 61 分钟?

一台 16GB 内存的游戏本跑本地模型,把 llama.cpp 的 prompt cache 从 1GiB 扩到 4GiB 之后,45K token 以上的长请求中位等待从 47 秒降到 3.4 秒。我按复用距离算出再加到 6GiB 还能多命中 8 到 10 个百分点,结果不到 50 分钟它就被容器内存上限杀了一次,61 分钟时又被我自己的护栏回滚。账没算错,漏的是一个加法:容器上限要装下缓存,还要装下缓存之外那 2.1GiB。

llmllama.cpp缓存内存方法论

按我的账,prompt cache 从 4GiB 加到 6GiB,命中率还能再涨 8 到 10 个百分点。

它活了 61 分钟。不到 50 分钟的时候被内核的 OOM killer 杀了一次,自动拉起来又跑了十来分钟,然后被我自己写的内存护栏切回了 4GiB。

账本身没算错。错在一个加法,我切换之前就该算出来,第 3 节讲的就是它,三个数字加减一下就够。想直接拿那条加法看第 3 节;想知道「命中率」这种数怎么骗人,看第 4 节。

1. 为什么要折腾:47 秒和 3.4 秒

机器是一台游戏本,RTX 3080 Laptop 16GB 显存,系统内存也是 16GB。模型跑在 WSL2 里的 llama.cpp 上,一个 14.3GB 的 35B MoE 量化版。

先说清楚,显存和内存是两块。权重和 KV 在显存里,这篇讲的 prompt cache 吃的是系统内存,WSL 那边一共只分到约 12GiB。

喂它的是我自己跑的一个多 agent 模拟。几个角色各自拖着三四万到五万多 token 的上下文,轮流来问。每一轮只多一小段新内容,前面几万 token 都是一样的。

prompt cache 干的事很朴素:把已经算过的长前缀状态存在内存里,同一个前缀再来,接着往下算就行,不用从头预填充。

不命中要等多久,是一个除法:

首字等待 ≈ prompt 长度 ÷ 预填充速度

这台机器预填充大约 1,078–1,130 tok/s。45K token ÷ 1,100 ≈ 41 秒。缓存只开 1GiB 的时候,45K 以上、输出很短的那批请求中位等待 47 秒,跟纯重算的账基本对得上,那一档里还有比 45K 更长的。

缓存扩到 4GiB 之后,同一档的中位数变成 3.4 秒。中位数掉到这么低,说明这一档里多半请求都命中了。

差 14 倍,所以值得折腾。

2. 第一刀:1GiB 只装得下两三条

旧配置是 --cache-ram 1024。我量了几条长前缀存下来有多大:

  • 54K token:约 552 MiB
  • 40K token:约 346 MiB
  • 30K token:约 417 MiB

大小跟长度不严格成正比,别硬套线性,直接拿实测范围算:

能装几条 ≈ cache-ram ÷ 单条状态大小
1024 ÷ (346~552) ≈ 2~3 条

几个角色轮着来,前一个刚存下就被下一个挤掉。缓存开着,基本等于没开。

扩到 4GiB,别的参数一个没动。跑满 6 小时,跟切换前等长的 6 小时比:

指标 1GiB 4GiB
被迫从头重算 545 次 311 次(-43%)
LRU 淘汰 535 次 278 次(-48%)
长 prompt 短输出的命中代理 20.0% 60.2%
30K–45K 档中位等待 55 秒 32 秒

效果很实在。但最后三个小时还是每小时淘汰约 31 次。缓存里躺着 13 条、4.11GiB,顶满了。这 6 小时里一共出现过 42 种不同的前缀。

13 条装 4.11GiB,平均每条约 324 MiB。按这个均值,6144 ÷ 324 ≈ 19 条。

3. 第二刀:6GiB 的账,和那个漏掉的加法

加多少,我没凭感觉,算的是复用距离。意思是:一个请求如果撞上一个以前见过的前缀,就数一数两次之间插进来过几种别的前缀。缓存能装 N 条、按最久没用先踢的规矩走,那么距离小于 N 的请求就能命中。

拿那 6 小时的真实请求一算:13 条(4GiB)→ 19 条(约 6GiB)还能多 8~10 个点;25 条(约 8GiB)再多约 7 个点,收益开始递减。

8GiB 我直接毙了。整机 16GB,4GiB 那会儿 Windows 侧空闲就只剩约 2.2GiB 了。6GiB 我配了两道保险:

  • 容器加 8GiB 硬上限,免得把整个 WSL 吃穿
  • 一个每分钟采一次内存的护栏:WSL 可用内存连续 3 次低于 2GiB,或者 Windows 空闲连续 3 次低于 0.75GiB,就自动切回 4GiB 的容器

8GiB 这个上限是怎么定的:切换之后缓存填到约 19 条时,我看到容器常驻 7.13GiB。离 8GiB 还差快 1GiB,我觉得够了。

不到 50 分钟,内核日志里出现了这个:

llama-server invoked oom-killer ... constraint=CONSTRAINT_MEMCG
Killed process ... (llama-server) anon-rss:8176388kB

CONSTRAINT_MEMCG 说的是撞的是容器自己的上限,整机内存还没用完。死之前最后一次记录的缓存状态:26 条,5,850 MiB。离 6,144 的线还差将近 300 MiB。

缓存还没满,进程先死了。漏掉的那个加法是:

容器内存上限 ≥ cache-ram + 缓存之外的常驻内存 + 余量

缓存之外的常驻有多少?拿死亡现场直接减:

anon-rss 8,176,388 kB ≈ 7,985 MiB
7,985 − 5,850(当时的缓存)≈ 2,135 MiB ≈ 2.1GiB

这 2.1GiB 是进程自己的运行开销,跟缓存开多大没关系。代回去:

6,144 + 2,135 = 8,279 MiB > 8,192 MiB

还没算任何余量,就已经超了 87 MiB。

也就是说,切换那一刻这道题就无解了。缓存只要长到离满还差一截的位置,容器一定先被杀。

我那个 7.13GiB 是缓存半路上的读数。19 条的时候缓存离 6,144 还远着呢,26 条的时候都才 5,850。我拿半路的数当成了满载的数,这就是全部的错。

4. 6GiB 命中 9.1%,4GiB 命中 51.3%:加了缓存反而变差了?

第二次复盘的时候,表里有这么两行:

  • 6GiB 那段:命中 9.1%
  • 回滚到 4GiB 之后的 5 小时:命中 51.3%

只看这两行,很容易下结论「缓存加大反而命中更低」。

先看分母。6GiB 那段一共活了约 1 小时,符合条件的长请求只有 11 条。11 条里命中 1 条,就是 9.1%。

单个样本的分量 = 100% ÷ n
n = 11 → 每条请求值 9.1 个百分点

多命中一条就是 18.2%,再多一条 27.3%。一个数如果一条请求就能把它推 9 个点,它说明不了任何事。

再看这 1 小时里发生了什么:中间被 OOM 杀了一次。重启之后缓存是空的,得重新攒,那十来分钟本来就不可能命中。

还有口径。这次复盘用的命中判据是:prompt 超过 2 万 token、输出少于 400,倒算出来的预填充速度超过 3,000 tok/s,就算命中。这台机器裸跑预填充只有 1,100 左右,超过 3,000 只可能是复用了缓存。

而第 2 节那个 20.0% → 60.2%,判据是看 llama.cpp 日志里有没有走重算。两种口径算出来的都叫「命中率」,数字之间不能直接拿来比。

所以看到别人贴缓存命中率,先问三句:怎么判定的命中,分母多少条,窗口里有没有重启。三句里答不上一句,这个数就先别信。

5. 护栏救了场,但最该响的那一下它没响

护栏的日志是这样的:

09:55:45 wsl_avail=8037948 win_free=576496  bad=1
09:56:55 wsl_avail=7867180 win_free=575744  bad=2
09:58:05 wsl_avail=7409412 win_free=437904  bad=3
09:58:10 ROLLBACK_OK restored remote 4GiB container

触发的是 Windows 那一支:6GiB 跑着的时候,Windows 侧空闲从约 2.6GiB 一路掉到 0.42GiB。三连之后 5 秒切回去了,干净利落。

但往前翻,OOM 那一刻护栏留下的是两行:

ERROR could not read remote WSL MemAvailable

进程被杀的那几秒,WSL 那边连远程读内存都响应不过来了。护栏的代码把「读不到」当成「这次没数据」,直接跳过,计数没加。全程最该报警的就是这一下。

读不到本身就是信号。护栏的判据里,「读失败」应该算一次坏,起码别当成没发生过。

还有一个占缓存的东西,跟模拟本身没关系。我在几台机器前面挂了个负载均衡,它每隔一阵就发一条几十 token 的健康检查。回滚后那 5 个小时,715 次预填充里有 357 次是不到 100 token 的请求。4GiB 的时候,31、33 token 那几种小条目占了缓存约 377 MiB,大概 9%。26 条才 5,850 MiB、平均每条只有 225 MiB,多半也是这些小条目把平均值拉低了。

我试过给健康检查加 cache_prompt:false,没用。翻了源码才明白,这个开关管的是「这次请求要不要复用已有的缓存」,而宿主内存那一层缓存,是在下一次槽位被替换的时候把旧状态整个存下来,不管这个状态是谁发的。名字看着像「别缓存我」,实际只是「别用缓存给我加速」。

6. 这次实验哪里不够硬

  • 6GiB 总共只跑了约 61 分钟,分两段,中间夹一次 OOM 重启。「多命中 8~10 个点」是按复用距离算出来的预测,从头到尾没被验证过,既没证实也没证伪。
  • 两个命中率都是代理指标,靠「长输入、短输出、快」去推断有没有复用,我没有逐条从缓存日志里核对。
  • 2.1GiB 的非缓存常驻来自一次 OOM 现场、一个模型、一个 llama.cpp 版本。换模型、换上下文长度、换并发槽数,这个数都会变,得自己量。
  • 内核日志里那个 anon-rss 只算匿名内存,容器的内存计数还包括别的部分,所以真实的非缓存开销只会比 2.1GiB 更大,我的减法是个下限。
  • 只有一台机器、跑在 WSL2 里。cache_prompt 那个行为是我用的这一版 llama.cpp 的源码里看到的,别的版本可能改过。

7. 下次给 prompt cache 扩容,按这个顺序

  1. 先量几条真实前缀存下来多大。cache-ram ÷ 单条大小 得到能装几条,跟同时活跃的前缀种类数比一比。
  2. 加多少用复用距离算,看多一条能多命中多少,收益递减的地方停。
  3. 容器或进程的内存上限按 cache-ram + 缓存之外的常驻 + 余量 定。缓存之外的常驻用「当前常驻 − 当前缓存大小」去减,别拿缓存半满时的总常驻当满载值。
  4. 再往外算一层:宿主机还剩多少。WSL、虚拟机、Docker 每一层都有自己的上限。
  5. 内存护栏里,「读不到」算一次坏。
  6. 健康检查这类短请求,别跟长会话共用同一份缓存。改不了的话,至少把它们占的那部分从容量账里扣掉。
  7. 拿命中率做比较之前,先对齐判据、看分母、查窗口里有没有重启。单条样本的分量是 100% ÷ n,n 只有十来条的就别比了。