LMCache:实验、基准与验收
白话导读:LMCache 的分级实验:A 级只需 Python,B 级需要 Linux/GPU,C 级需要 Mooncake,D 级做故障和总结。
- 没有 GPU 也能做 A 级:容量与盈亏计算、前缀 hash 与缺口。
- B2 专门排除引擎自带前缀缓存(APC)造成的“假收益”,做对照时别漏。
- 所有故障实验只在独立测试实例上做。
结果按统一记录模板整理。
实验分四级,按条件选做:
| 级别 | 需要什么 | 练什么 |
|---|---|---|
| A | 只要 Python 标准库 | 容量与盈亏计算、前缀 hash 与缺口 |
| B | 兼容的 Linux + GPU 推理环境 | 冷写热读、排除 APC 干扰、L2 与性能矩阵 |
| C | Mooncake 服务和 LMCache 的 native 扩展 | LMCache 接 Mooncake Store |
| D | 无硬件也能做 | 故障推演和结业报告 |
这里给的是操作步骤和验收标准,不代表这些 GPU/RDMA 实验已经在你的机器上跑通。不要在生产集群上清空缓存、停服务或做故障注入;所有实验都用独立的实例和数据目录。
0. 先记录版本和环境
在仓库根目录执行:
git rev-parse HEAD
python --version
python -m pip show lmcache vllm torch mooncake-transfer-engine
lmcache server --help
没装的包就记为“未安装”,不要为了跑实验先把所有依赖都升级一遍。安装方法见安装指南和兼容性说明,优先用已经验证过的 wheel 或容器组合。在没有 GPU 的 macOS 上只装源码,并不能跑 Linux eventfd、CUDA IPC 或 RDMA 这些路径。
还要记录:模型 revision、分词器和对话模板、KV dtype、TP/PP、GPU 和网卡型号、驱动、CPU NUMA、主机内存、chunk/block 大小、传输模式、adapter 配置。模型权重或运行时不同的结果不能直接比较。完整的环境记录表见实测与故障演练。
A1. 容量与盈亏(无 GPU)
先跑KV 原理里的容量小程序,再跑下面的敏感性分析。假设:8K token 的 KV 是 1 GiB,命中后能省 120 ms 的 Prefill,固定开销 3 ms,H2D 有效带宽 24 GiB/s。“serial”是串行估算,“ideal_pipeline”是理想两段流水的乐观下界:
# SPDX-License-Identifier: Apache-2.0
payload_gib = 1.0
saved_prefill_ms = 120.0
fixed_ms = 3.0
h2d_ms = payload_gib / 24.0 * 1000
for network_gib_s in (4.0, 8.0, 16.0, 24.0):
network_ms = payload_gib / network_gib_s * 1000
serial_ms = fixed_ms + network_ms + h2d_ms
pipelined_ms = fixed_ms + max(network_ms, h2d_ms)
print(
f"{network_gib_s:4.0f} GiB/s: serial={serial_ms:6.1f} ms, "
f"ideal_pipeline={pipelined_ms:6.1f} ms, "
f"serial_gain={saved_prefill_ms - serial_ms:6.1f} ms"
)
验收:网络 4 GiB/s 时串行约 294.7 ms,比重算还慢,应该重算或避开这条慢路径;24 GiB/s 时串行约 86.3 ms,在这些假设下有收益。再把命中量改成 64 MiB、固定延迟改成 10 ms,解释为什么小对象的结论不同。理想流水忽略了启动、收尾、争用和分块,只用来看边界,不是对实测的预测。也可以直接用互动盈亏计算器。
A2. 前缀 hash 与缺口(无 GPU)
这是一个教学模型,用 SHA-256 和 JSON 编码,和 LMCache 真实的 key 格式不兼容。它只用来验证三件事:前缀身份、命名空间隔离、只能复用连续前缀。
# SPDX-License-Identifier: Apache-2.0
import hashlib
import json
def prefix_keys(
tokens: list[int], chunk_size: int, namespace: str
) -> list[str]:
"""Return full-chunk teaching hashes for tokens in a namespace.
Args:
tokens: Input token IDs.
chunk_size: Positive number of tokens in each chunk.
namespace: Model/version/tenant identity for this exercise.
Returns:
Ordered SHA-256 hex digests for complete chunks.
Raises:
ValueError: If chunk_size is not positive.
"""
if chunk_size <= 0:
raise ValueError("chunk_size must be positive")
previous = hashlib.sha256(namespace.encode()).hexdigest()
result: list[str] = []
for start in range(0, len(tokens) - chunk_size + 1, chunk_size):
payload = json.dumps(
[previous, tokens[start : start + chunk_size]],
separators=(",", ":"),
).encode()
previous = hashlib.sha256(payload).hexdigest()
result.append(previous)
return result
def prefix_hit_count(keys: list[str], stored: set[str]) -> int:
"""Count consecutive available keys from the start.
Args:
keys: Ordered chunk identities.
stored: Available identities in the teaching store.
Returns:
Number of consecutive hits before the first miss.
"""
count = 0
for key in keys:
if key not in stored:
break
count += 1
return count
first = prefix_keys([1, 2, 3, 4, 5, 6], 2, "model-v1/tenant-a")
same_prefix = prefix_keys([1, 2, 3, 4, 9, 10], 2, "model-v1/tenant-a")
changed_history = prefix_keys([9, 9, 3, 4, 5, 6], 2, "model-v1/tenant-a")
other_tenant = prefix_keys([1, 2, 3, 4, 5, 6], 2, "model-v1/tenant-b")
assert first[:2] == same_prefix[:2]
assert first[1:] != changed_history[1:]
assert set(first).isdisjoint(other_tenant)
assert prefix_hit_count(first, {first[0], first[2]}) == 1
assert len(prefix_keys([1, 2, 3], 2, "model-v1/tenant-a")) == 1
print("prefix identity, isolation, gap and tail checks passed")
验收:改了第一块,后面所有 key 都会变;中间缺一块,只能复用缺口之前的部分。再想两个问题:如果每个 chunk 只对自己的 token 求 hash(不带前一块的 hash),哪条断言就不再成立?两个进程的序列化格式不同,即使算法名字一样,还能共享吗?
B1. MP 模式冷写与热读(Linux/GPU)
前提:已按兼容性文档装好,模型在纯 vLLM 上能正常运行。下面沿用仓库 quickstart 里的 Qwen/Qwen3-8B,请按显存换成你验证过的模型。4 GiB 的 L1 只是演示用的预算,不包括引擎、权重、系统和其他 buffer。
终端一,启动独立的 LMCache server:
lmcache server \
--host 127.0.0.1 --port 5555 \
--http-host 127.0.0.1 --http-port 8090 \
--l1-size-gb 4 --eviction-policy LRU --chunk-size 256
终端二,启动 vLLM。下面这种外部 connector 配置需要支持该加载方式的 vLLM(学习版本的兼容性文档写的是 vLLM ≥ 0.20.0):
vllm serve Qwen/Qwen3-8B \
--host 127.0.0.1 --port 8000 \
--kv-transfer-config \
'{"kv_connector":"LMCacheMPConnector","kv_connector_module_path":"lmcache.integration.vllm.lmcache_mp_connector","kv_role":"kv_both","kv_connector_extra_config":{"lmcache.mp.host":"127.0.0.1","lmcache.mp.port":5555}}'
更老的 vLLM 要用它自带的、受支持的 connector,并核对与 server 的协议是否匹配;不要只删掉 module path 就说任何版本都能用。示例端口只监听本机,跨机器时要改成可达地址,并配置访问控制。
终端三,用 Python 标准库连续发两次相同的请求:
# SPDX-License-Identifier: Apache-2.0
import json
import time
import urllib.request
prompt = (
"A cache stores reusable intermediate state. "
"Correctness requires matching model, tokens and context. "
) * 160 + "\nSummarize the main idea in one sentence."
payload = json.dumps(
{
"model": "Qwen/Qwen3-8B",
"prompt": prompt,
"max_tokens": 32,
"temperature": 0,
"stream": False,
}
).encode()
for run in range(2):
request = urllib.request.Request(
"http://127.0.0.1:8000/v1/completions",
data=payload,
headers={"Content-Type": "application/json"},
)
start = time.perf_counter()
with urllib.request.urlopen(request, timeout=180) as response:
body = json.load(response)
elapsed = time.perf_counter() - start
print(run, f"end_to_end_s={elapsed:.3f}", body["usage"])
print(body["choices"][0]["text"])
这只是功能冒烟测试,打印的是完整响应时间,不是 TTFT。用返回的 prompt token 数确认长度确实超过最终协商的 chunk 大小,不要按字符数猜 token 数。第一次请求应该产生可以保存的 KV;第二次可能被 APC 命中,也可能被 LMCache 命中,必须结合指标或 trace 才能判断是谁的功劳。
只读检查服务状态:
curl --fail http://127.0.0.1:8090/healthcheck
curl --fail http://127.0.0.1:8090/status
curl --fail http://127.0.0.1:8090/metrics
如果配置了 HTTP 认证,用你自己的合法凭据,不要把凭据写进报告或仓库。HTTP 200 的含义以接口文档为准,不能当作“所有后端都健康”的证明。
B2. 排除 APC 造成的假收益
第二次请求变快,可能只是 vLLM 自己的 GPU 前缀缓存(APC)在起作用。下面的步骤把它排除掉:
- 保持 LMCache server 运行,确认第一轮的 STORE 已经完成。
- 在测试终端里优雅地停止并重启只有 vLLM,模型和配置不变。
- 再发同样的 prompt。新的引擎进程没有上一个进程的 GPU APC,只能从仍在运行的 LMCache 加载。
- 记录 LMCache 的 retrieve/load 次数、实际命中的 token 数和端到端数据。
- 改动 prompt 的开头,验证命中变短;只改结尾,前面的部分应该仍能复用。
注意:新进程还有权重加载和 kernel 初始化的影响。正式测量前,先用不共享前缀的请求把计算路径预热;不要拿重启后的第一次请求和旧进程稳定后的结果比较。如果重启后缓存也没了,先检查 server 的生命周期、容量淘汰和 key 是否兼容,不要直接下结论说“LMCache 不能复用”。
B3. L2 与性能矩阵
先读文件系统 adapter。在专用的测试目录里配置文件 adapter,确认有权限、空间够,绝不能指向已有的生产数据。每次实验用一个新目录,不要靠删除旧数据来制造“冷缓存”。
lmcache server \
--host 127.0.0.1 --port 5555 \
--http-host 127.0.0.1 --http-port 8090 \
--l1-size-gb 4 --eviction-policy LRU --chunk-size 256 \
--l2-adapter '{"type":"fs","base_path":"/tmp/lmcache-study-run-001"}'
这条命令是用来替换 B1 的 server 配置的,不要两个同时占用同一个端口。等 L2 写完后,在测试环境里优雅地重启 LMCache 和引擎,再验证新进程能从同一目录读到数据。注意操作系统的 page cache 可能还是热的,要把“L1 冷、文件页热”和“磁盘真正冷”分开报告。
正式测 TTFT/ITL 要用支持流式 token 时间戳的压测工具,参考 Benchmarking;按你安装的 vLLM 版本的 vllm bench serve --help 选参数,不要照抄别的版本的脚本。模型、请求 trace、输出 token 上限和并发都固定,每次只改一个变量:
| 变量 | 建议取值 | 重点看 |
|---|---|---|
| 前缀共享比例 | 0%、25%、75%、接近全部共享 | token 命中、实际省下的 Prefill |
| 上下文长度 | 1K、8K、32K(不超过模型和显存能力) | 容量、查找、网络/H2D、排队 |
| chunk | 128、256、512(记录协商后的实际值) | 多取的浪费、对象数、尾部、提交开销 |
| 并发 | 1、4、16,逐步加到 SLO 边界 | goodput、P95/P99、staging 占用和抢占 |
| 层级 | APC、L1、文件 L2、远端 L2 | 到底是哪一层服务了命中 |
结果按下面的字段记录:
run_id / versions / model / dtype / TP / block / effective_chunk
request_count / input_distribution / output_distribution / concurrency
APC_tokens / LMCache_L1_tokens / LMCache_L2_tokens / queried_tokens
TTFT_p50_p95_p99 / ITL_p50_p95_p99 / goodput / errors
transferred_bytes / L1_resident / L1_staging / host_peak_memory
cold_or_warm_state / output_quality_check / raw_logs
C. LMCache 接 Mooncake Store
需要兼容的 Linux 环境、Mooncake Master 和元数据服务,以及 LMCache 的 native Mooncake 扩展。先用 TCP 验证功能,再到有合适网卡、驱动和权限的环境里用 RDMA 测性能;TCP 跑通不能证明 RDMA 注册或 GPUDirect 路径是对的。服务的安装和启动以 Mooncake 当前版本的文档为准:Store API。
下面假设在本机隔离环境里,元数据服务在 8080 端口、Master 在 50051 端口。先单独做一次 API 冒烟测试;测试 key 加随机后缀,避免覆盖别人的数据:
# SPDX-License-Identifier: Apache-2.0
import uuid
from mooncake.store import MooncakeDistributedStore
store = MooncakeDistributedStore()
try:
status = store.setup(
"127.0.0.1",
"http://127.0.0.1:8080/metadata",
512 * 1024 * 1024,
128 * 1024 * 1024,
"tcp",
"",
"127.0.0.1:50051",
)
if status != 0:
raise RuntimeError(f"Mooncake setup failed: {status}")
key = f"lmcache-study-{uuid.uuid4().hex}"
data = b"isolated KV storage smoke test"
status = store.put(key, data)
if status != 0:
raise RuntimeError(f"Mooncake put failed: {status}")
restored = store.get(key)
if restored != data:
raise RuntimeError("Mooncake read did not return the expected bytes")
print("Mooncake roundtrip passed")
finally:
store.close()
注意返回值不是“一律 0 表示成功”:get 返回数据,is_exist 返回是否存在,buffer 类接口可能返回字节数,异步接口返回任务或状态,要按各自的文档处理。
然后按 Mooncake adapter 指南编译 native 扩展,在独立环境里用下面的配置替换 B1 的 server。这个例子里 L1、Store segment 和本地 buffer 加起来超过 10 GiB,还没算引擎和系统,要预留足够内存,或按模型实际的 KV 大小调整。
lmcache server \
--host 127.0.0.1 --port 5555 \
--http-host 127.0.0.1 --http-port 8090 \
--l1-size-gb 4 --eviction-policy LRU --chunk-size 256 \
--l2-adapter '{"type":"mooncake_store","num_workers":4,"local_hostname":"127.0.0.1","metadata_server":"http://127.0.0.1:8080/metadata","master_server_addr":"127.0.0.1:50051","protocol":"tcp","local_buffer_size":"3221225472","global_segment_size":"3221225472"}'
验证顺序:STORE 完成 → 能在远端查到对象 → 用另一组兼容的引擎和 LMCache 实例读出来。贡献存储 segment 的那个进程必须一直活着;如果唯一的副本来自一个马上要停掉的客户端,重启它对象就没了——这不是共享缓存失效。做持久共享实验,最好用单独的 Store 贡献节点。
RDMA 阶段:改用可达地址、匹配的网卡、protocol: rdma 和正确的注册设置,并按文档考虑 --no-l1-use-lazy。采集网卡计数器、CPU 使用率、注册耗时和有效带宽,证明确实走了目标路径;不能只凭配置里写了 rdma 就说用上了 RDMA。常见硬件的公开带宽量级见公开参考数据,可以和你的实测对照。
D. 故障推演与结业报告
没有硬件也能做桌面推演。对下面每个场景,写出:还能用的有效前缀、各个 buffer 归谁管、要释放哪些资源、怎样回退、指标上应该看到什么:
- L2 只返回了部分命中;
- 某个 TP rank 缺了一块;
- 请求被取消;
- 元数据过时;
- 一个 Store 节点退出;
- 在途加载超出内存预算。
有硬件时只在独立测试实例上验证这些场景,操作前先保存日志和配置。验收标准不是“程序没崩”,而是:输出正确、没有引用或锁泄漏、后续请求能恢复正常。故障注入方法见故障演练。
最终交付:一张架构图、一张时序图、一份版本清单、一张实测(或明确标为估算)的性能表、一份包含失败路径的设计复盘。再结合系统设计题准备一段 5 分钟的技术讲述。