LabHub
开始
学习 学习路径 课程

LLM 服务

用 LRU 与两级缓存模拟器验证 KV 缓存下放

在 LabHub 中继续学习

目标

用自己实现的模拟器,验证交替读取的文档多于 GPU 上 KV 缓存容量时,LRU 缓存是如何失效的,并观察加入 CPU 一级之后有什么不同。最后,从真实测量留下的逐个请求的文件中,亲自计算倍率。不使用 GPU,只用 Python。

为什么重要

在前面附上一篇长文档、只更换问题的请求中,复用文档的 KV 缓存是最大的节省。但 GPU 上用来存放这些缓存的容量很小,文档数量一旦超过容量,就会从最旧的条目开始被挤出。这种挤出一旦遇到循环访问,容量只差一点点,命中数也会变成 0——这是本实验的第一个发现。第二个发现是,把被挤出的内容下放到 CPU 内存,就可以加载回来而不必重新计算;在 2026-10-03 的测量中,这一差别是到首个令牌的时间 576ms 对 48.6ms。第三点是局限。模拟器以文档为单位,而测量的条件是并发度为 1、模型为 1.5B、只有一张 GPU、使用合成文档。实验中的 CPU 容量 56000 个令牌,是把测量中的 CPU 上限 1.5GB 除以每篇文档的 KV 大小(4,864 个令牌对应 0.1299GB)得到的近似值。第 7 步(CPU 容量不足时)是模拟器的预测,不是测量结果。

步骤

  1. 在 /root/kvlab/kvsim.py 中创建 LRUCache(capacity)。access(key, size) 在命中时返回 True,并把该条目移到最近位置。未命中时返回 False,从最旧的条目开始淘汰,直到有足够的空间,然后存入。大于容量的条目不存入,也不淘汰其他条目。key in cache 不改变顺序,cache.used 是已存条目的大小之和(属性),cache.keys() 是从最旧的条目开始的键列表。
  2. 创建 /root/kvlab/scenario.py。参数有 --gpu(默认 21520)、--docs(默认 10)、--tokens(默认 5011)、--passes(默认 fwd,fwd,rev)。文档编号从 0 到 docs-1 是键,大小都是 tokens。fwd 遍从 0 开始依次读取,rev 遍倒序读取。标准输出只打印一个 JSON:{"passes": [{"order": "fwd", "gpu_hits": 정수, "cpu_hits": 0, "misses": 정수, "hit_docs": [적중한 문서 번호를 읽은 순서대로]}, ...]}(占位符依次为命中数与未命中数这两个整数,以及按读取顺序排列的命中文档编号),每遍一个对象。把默认场景以 --passes fwd,fwd 运行,并将输出保存到 /root/kvlab/cyclic.json。
  3. 将以 --passes fwd,fwd,rev 运行的输出保存到 /root/kvlab/baseline.json。查看第三遍的 hit_docs 中有哪些文档,想一想为什么是这些文档。
  4. 把 GPU 容量依次改为 21520、30000、40000、45000、50109、50110,并以 --passes fwd,fwd 运行,取每个容量下第二遍的命中数(gpu_hits 与 cpu_hits 之和),按 {"자리": 적중 수} 的格式(占位符依次为容量与命中数)保存到 /root/kvlab/cliff.json。键为字符串。
  5. 在 /root/kvlab/kvsim.py 中添加 TwoTierCache(gpu_capacity, cpu_capacity)(LRUCache 保持不变)。属性 gpu 和 cpu 都是 LRUCache,access(key, size) 返回 "gpu"、"cpu"、"miss" 之一。(1) 在 GPU 命中时返回 "gpu",且不碰 CPU。(2) 在 GPU 未命中但 CPU 中有时返回 "cpu",把 CPU 中的副本设为最近使用,并同时放入 GPU。(3) 两边都未命中时返回 "miss",并同时存入 GPU 和 CPU。
  6. 在 scenario.py 中添加 --cpu(默认 0),用 TwoTierCache 运行。输出中的 gpu_hits 是 GPU 命中数,cpu_hits 是从 CPU 加载的数量,hit_docs 是两者合并后的文档编号。不传 --cpu 或传 0 时,结果必须与第 2 步相同。把 --gpu 21520 --cpu 56000 --passes fwd,fwd,rev 的输出保存到 /root/kvlab/offload.json。
  7. 将 GPU 容量固定为 21520,把 CPU 容量依次改为 0、20000、30000、40000、50109、50110、56000,并以 --passes fwd,fwd 运行。取每个 CPU 容量下第二遍的未命中数(misses),按 {"CPU 자리": 미적중 수} 的格式(占位符依次为 CPU 容量与未命中数)保存到 /root/kvlab/cpu_cliff.json。键为字符串。
  8. 用 cp /opt/fixtures/kvoffload/ttft.jsonl /root/kvlab/ttft.jsonl 复制实测材料(不要修改内容)。然后创建 /root/kvlab/summarize.py <입력.jsonl> <출력.json>(占位符依次为输入文件与输出文件)。输入的每一行是一个 JSON 对象(config、pass、doc、doc_tokens、ttft_ms、total_ms),空行跳过。输出为 {"baseline": {패스: {"n", "p50_ms", "mean_ms"}}, "lmcache": {패스: {...}}, "ratio": {패스: {"p50", "mean"}}}(占位符为遍次名称)。p50 是中位数(个数为偶数时取中间两个值的平均),ms 保留一位小数,ratio 是 baseline 除以 lmcache 的值,保留两位小数。遍次名称与输入中的完全一致(pass1-cold、pass2-replay、pass3-reverse)。用复制的文件运行,并把结果保存到 /root/kvlab/measured.json。

参考

实现 LRU 缓存

在 /root/kvlab/kvsim.py 中创建 LRUCache(capacity)。access(key, size) 在命中时返回 True,并把该条目移到最近位置。未命中时返回 False,从最旧的条目开始淘汰,直到有足够的空间,然后存入。大于容量的条目不存入,也不淘汰其他条目。key in cache 不改变顺序,cache.used 是已存条目的大小之和(属性),cache.keys() 是从最旧的条目开始的键列表。

Python 的 OrderedDict 有把条目移到末尾的 move_to_end,也有取出最前面条目的 popitem(last=False),正适合这件事。命中时如果忘了调整顺序,得到的就不是 LRU,而是按进入顺序淘汰的 FIFO。遇到大于容量的条目时,如果先清空缓存,连完好的条目也会丢掉。

按同样顺序读两遍

创建 /root/kvlab/scenario.py。参数有 --gpu(默认 21520)、--docs(默认 10)、--tokens(默认 5011)、--passes(默认 fwd,fwd,rev)。文档编号从 0 到 docs-1 是键,大小都是 tokens。fwd 遍从 0 开始依次读取,rev 遍倒序读取。标准输出只打印一个 JSON:{"passes": [{"order": "fwd", "gpu_hits": 정수, "cpu_hits": 0, "misses": 정수, "hit_docs": [적중한 문서 번호를 읽은 순서대로]}, ...]}(占位符依次为命中数与未命中数这两个整数,以及按读取顺序排列的命中文档编号),每遍一个对象。把默认场景以 --passes fwd,fwd 运行,并将输出保存到 /root/kvlab/cyclic.json。

各遍连续使用同一个缓存(换一遍时不要清空缓存)。命中的文档按读取顺序放入 hit_docs。输出里如果混入说明性文字,评分器就无法按 JSON 读取。保存时可以像 python3 scenario.py --passes fwd,fwd > cyclic.json 这样做。

倒序读取时还剩下什么

让同一场景依次执行首次读取、按同样顺序重读、倒序读取,即以 --passes fwd,fwd,rev 运行,并将输出保存到 /root/kvlab/baseline.json。查看第三遍的 hit_docs 中有哪些文档,想一想为什么是这些文档。

不需要写新代码。把第 2 步的 scenario.py 换个参数再运行即可。想一想第三遍开始时缓存里还剩下什么,以及倒序读取时会按什么顺序遇到它们。

容量要增加到多少才会命中

把 GPU 容量依次改为 21520、30000、40000、45000、50109、50110,并以 --passes fwd,fwd 运行,取每个容量下第二遍的命中数(gpu_hits 与 cpu_hits 之和),按 {"자리": 적중 수} 的格式(占位符依次为容量与命中数)保存到 /root/kvlab/cliff.json。键为字符串。

有一个区间,即使把容量增加近一倍也不会出现命中。算出十篇文档的大小之和,再分别用比它少 1 个令牌的容量和恰好相等的容量运行,对比结果。不要手动运行六次,用循环运行并生成 JSON。

GPU 与 CPU 两级缓存

在 /root/kvlab/kvsim.py 中添加 TwoTierCache(gpu_capacity, cpu_capacity)(LRUCache 保持不变)。属性 gpu 和 cpu 都是 LRUCache,access(key, size) 返回 "gpu"、"cpu"、"miss" 之一。规则有三条。(1) 在 GPU 命中时返回 "gpu",且不碰 CPU。(2) 在 GPU 未命中但 CPU 中有时返回 "cpu",把 CPU 中的副本设为最近使用,并同时放入 GPU。(3) 两边都未命中时返回 "miss",并同时存入 GPU 和 CPU。

每一级用一个 LRUCache 即可。要知道是否命中,必须在调用 access 之前先用 in 查询(access 在未命中时会连存储也一并做了)。CPU 容量为 0 时也要能正常工作,此时它与单级缓存相同。

下放到 CPU 后第二遍会怎样

在 scenario.py 中添加 --cpu(默认 0),用 TwoTierCache 运行。输出中的 gpu_hits 是 GPU 命中数,cpu_hits 是从 CPU 加载的数量,hit_docs 是两者合并后的文档编号。不传 --cpu 或传 0 时,结果必须与第 2 步相同。把 --gpu 21520 --cpu 56000 --passes fwd,fwd,rev 的输出保存到 /root/kvlab/offload.json。

把 run 创建的缓存从 LRUCache 换成 TwoTierCache,再分别统计 access 返回值的三种情况。不带 --cpu 再运行一次,确认前面步骤的场景没有被破坏。把读到的结果与测量对比:倒序读取的那一遍里,GPU 命中和 CPU 命中各有几篇,是否与测量中按约 30ms 和约 48ms 区分出的文档数一致?

CPU 容量不足时

将 GPU 容量固定为 21520,把 CPU 容量依次改为 0、20000、30000、40000、50109、50110、56000,并以 --passes fwd,fwd 运行。取每个 CPU 容量下第二遍的未命中数(misses),按 {"CPU 자리": 미적중 수} 的格式(占位符依次为 CPU 容量与未命中数)保存到 /root/kvlab/cpu_cliff.json。键为字符串。

与第 4 步用同一个循环,只是改变的参数和读取的值不同。看结果,判断 CPU 一级所需的容量是“放不进 GPU 的那一部分”,还是“全部文档”。这个值是模拟器的预测。这次测量只测了 CPU 上限能装下全部文档的情形。

从实测文件计算倍率

用 cp /opt/fixtures/kvoffload/ttft.jsonl /root/kvlab/ttft.jsonl 复制实测材料(不要修改内容)。然后创建 /root/kvlab/summarize.py <입력.jsonl> <출력.json>(占位符依次为输入文件与输出文件)。输入的每一行是一个 JSON 对象(config、pass、doc、doc_tokens、ttft_ms、total_ms),空行跳过。输出为 {"baseline": {패스: {"n", "p50_ms", "mean_ms"}}, "lmcache": {패스: {...}}, "ratio": {패스: {"p50", "mean"}}}(占位符为遍次名称)。p50 是中位数(个数为偶数时取中间两个值的平均),ms 保留一位小数,ratio 是 baseline 除以 lmcache 的值,保留两位小数。遍次名称与输入中的完全一致(pass1-cold、pass2-replay、pass3-reverse)。用复制的文件运行,并把结果保存到 /root/kvlab/measured.json。

对每一对 config 和 pass,收集 ttft_ms,再使用 statistics.median 和 statistics.mean。读结果时,看 ratio 是大于还是小于 1。第一遍中小于 1 的值意味着什么?评分器会用文档个数和顺序都不同的输入来运行这个脚本。