LLM Infer Infra 学习资料库

LMCache:实验、基准与验收

白话导读:LMCache 的分级实验:A 级只需 Python,B 级需要 Linux/GPU,C 级需要 Mooncake,D 级做故障和总结。

  • 没有 GPU 也能做 A 级:容量与盈亏计算、前缀 hash 与缺口。
  • B2 专门排除引擎自带前缀缓存(APC)造成的“假收益”,做对照时别漏。
  • 所有故障实验只在独立测试实例上做。

结果按统一记录模板整理。

实验分四级,按条件选做:

级别需要什么练什么
A只要 Python 标准库容量与盈亏计算、前缀 hash 与缺口
B兼容的 Linux + GPU 推理环境冷写热读、排除 APC 干扰、L2 与性能矩阵
CMooncake 服务和 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)在起作用。下面的步骤把它排除掉:

  1. 保持 LMCache server 运行,确认第一轮的 STORE 已经完成。
  2. 在测试终端里优雅地停止并重启只有 vLLM,模型和配置不变。
  3. 再发同样的 prompt。新的引擎进程没有上一个进程的 GPU APC,只能从仍在运行的 LMCache 加载。
  4. 记录 LMCache 的 retrieve/load 次数、实际命中的 token 数和端到端数据。
  5. 改动 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、排队
chunk128、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 归谁管、要释放哪些资源、怎样回退、指标上应该看到什么:

有硬件时只在独立测试实例上验证这些场景,操作前先保存日志和配置。验收标准不是“程序没崩”,而是:输出正确、没有引用或锁泄漏、后续请求能恢复正常。故障注入方法见故障演练。

最终交付:一张架构图、一张时序图、一份版本清单、一张实测(或明确标为估算)的性能表、一份包含失败路径的设计复盘。再结合系统设计题准备一段 5 分钟的技术讲述。

输入主题、术语或问题