跳转至

架构

放置模式:目录式(p2p)与免目录式(slotmap)

PeerCache 有两种放置模式,通过后端 extra config 里的 mode 选择。两者共享控制面 (服务发现、成员、ring、传输、监控、磁盘分层),但在读取方如何定位一个页面上不同:

p2p(默认) slotmap
位置记录 分布式目录(key → {node,addr,rkey,len}),按一致性哈希分片 无 —— key 经哈希映射到固定物理槽
读取路径 目录查询 RPC + 单边 RDMA READ 一次对整个 N-way 桶的单边 RDMA READ
元数据再分片 有(成员变更时) 无(放置只是 ring + key 哈希的纯函数)
页面大小 任意 单一尺寸档(slot_max_page_bytes)
发布池(LRU) 有 无(槽本身即存储)
最适合 混合/热点负载、小集群 统一大小页面、时延敏感读取、大集群

本页先描述共享机制(控制/数据面、双 MR 模型、传输、磁盘分层、并发),再讲各模式的 细节(目录 vs 槽区域)。slotmap 模式有专门的分步讲解,见 slotmap.md。

主要场景:PD 分离的 SGLang 推理

PeerCache 专为 prefill/decode(PD)分离的 SGLang 部署而设计:prefill 与 decode worker 运行在不同节点上。prefill worker 计算出 prompt 的 KV 缓存,decode worker 需要 拿到这份 KV 缓存才能继续生成。PeerCache 就是把这些 KV 页面在节点间搬运的 L3 存储, 采用 RDMA 零拷贝,让 decode 直接从远端主机内存里读出 prefill 的 KV —— 没有中心 master,也不对 KV 做额外的网络拷贝。

flowchart LR
    subgraph P [Prefill 节点 - 生产者]
      P0[Prefill worker<br/>set KV 页面]
    end
    subgraph D [Decode 节点 - 消费者]
      D0[Decode worker<br/>get KV 页面]
    end
    P0 -->|"1 PUT 位置(极小 RPC)"| DIR[(一致性哈希<br/>目录分片)]
    D0 -->|"2 GET 位置(极小 RPC)"| DIR
    P0 ==>|"3 单边 RDMA READ(零拷贝)"| D0

在 slotmap 模式下这两次目录 RPC 消失了:读取方本地算出 owner = ring(key) 与 slot = hash(key),直接发起单边 READ。

  • KV 数据留在 prefill 节点(生产者)上,只把一条极小的位置记录发布到目录。
  • decode 节点主动拉取 KV:单边 RDMA READ 直接落入它自己已注册的主机缓冲区。
  • 它同样适用于非分离场景(任何节点都可既做生产者又做消费者);PD 分离只是它重点 调优的场景。

控制面与数据面

PeerCache 清晰地分为控制面(Python)与数据面(C++ / RDMA)。

flowchart TB
    subgraph cp [控制面 - Python, TCP]
      DISC[服务发现: 内嵌 meta]
      RING[一致性哈希环]
      DIR[目录分片 + 客户端]
      POOL[发布池 - LRU]
    end
    subgraph dp [数据面 - C++, libibverbs]
      TE[TransferEngine]
      CM[ConnectionManager - RC QP 池]
      MR[MR 注册表]
    end
    STORE[PeerCacheStore - HiCacheStorage] --> cp
    STORE --> dp

双 MR 模型(p2p 模式)

SGLang 的主机 KV 缓冲区是 L2 层,会被 HiCache 驱逐/覆盖,因此其地址不能直接发布到 目录里(会成为悬空引用)。为此每个节点注册两个内存区域(MR):

  1. 接收 MR = mem_pool_host.kv_buffer —— get 时单边 READ 的目标。
  2. 发布池 MR = 后端自有、带 LRU 的主机内存池 —— 远端节点 READ 的来源。set 把 页面 memcpy 进该池(节点本地、不走网络),并把 addr + rkey + len 发布到目录。 从池中驱逐会删除对应的目录条目,因此已发布的地址在被驱逐前始终有效。

slotmap 模式用槽区域 MR 取代发布池 MR(一个固定区域,大小 num_buckets × ways × slot_stride,只注册一次)。页面写入预先确定的槽而不是 LRU 池,因此既没有要发布的目录条目、也没有要删除的目录条目;见 slotmap.md。

写入路径(p2p 模式)

sequenceDiagram
    participant W as 节点 W(生产者)
    participant Dw as 目录归属者 = hash(key)
    W->>W: set(): 本地 memcpy 页面 -> 发布池 MR
    W->>Dw: PUT key -> {node, addr, rkey, len}
    Note over W,Dw: 数据从不离开 W;只发送一条极小的记录

写入开销 = 一次本地 memcpy + 一次小的目录 RPC。没有 master,也没有 KV 数据的网络拷贝。

slotmap 模式:写入 = 一次本地 memcpy 进入该 key 的槽(自有 key),或对目标桶 做一次批量 READ-modify-WRITE(远端 key)—— 完全没有目录 PUT。

读取路径(p2p 模式)

sequenceDiagram
    participant R as 节点 R(读取方)
    participant Dr as 目录归属者 = hash(key)
    participant W as 节点 W(数据节点)
    R->>Dr: GET key
    Dr-->>R: {node=W, addr, rkey, len}
    R->>W: 单边 RDMA READ (addr, rkey)
    W-->>R: 字节直接落入 R 的主机缓冲区(零拷贝)

如果目录显示数据就在读取方自身,读取会退化为一次本地 memcpy,完全不走网络。

slotmap 模式:目录这一步消失了 —— owner = ring(key)、 slot = bucket_hash(key),对整个桶做一次单边 READ,本地做头部校验。零拷贝落点相同, 每次读取少一次 RPC。

拷贝次数

核心目标就是尽量减少对(庞大的)KV 数据的拷贝。下面只统计 KV 数据的搬运(目录 RPC 只有几十字节,忽略不计):

操作 KV 数据拷贝次数 发生了什么
set(写,生产者) 1 次主机 memcpy 把页面从 SGLang 的主机 KV 缓冲区拷进后端发布池 MR(节点本地,不走网络)
get(远端读) 0 次 CPU 拷贝 单边 IBV_WR_RDMA_READ;网卡把字节从远端发布池直接 DMA 进读取方的主机 KV 缓冲区(真正零拷贝)
get(数据已在本地) 1 次主机 memcpy 发布池 → 主机 KV 缓冲区;不走网络

因此一次「生产者→消费者」的 KV 传输代价是 写端一次主机 memcpy + 读端一次零拷贝 RDMA READ —— 数据恰好跨网络一次,且传输期间两端 CPU 都不参与(由网卡完成 DMA)。

为什么写端这一次 memcpy 是必要的

SGLang 的主机 KV 缓冲区是 L2 层,会被 HiCache 驱逐/覆盖。如果直接发布它的地址, 远端 READ 可能落到一个已被复用的页面上(悬空引用 / 数据损坏)。后端自有的发布池由 LRU 管理、与 L2 解耦:发布进去要花一次 memcpy,但能保证 addr + rkey 在该条目被池 自身驱逐之前一直有效(驱逐同时会删除目录记录)。这是为正确性付出的标准代价;网络 传输本身依然是零拷贝。

磁盘持久化分层(L4)

内存池容量有限,一旦写满,被 LRU 淘汰的页面通常就丢失了。可选的磁盘分层会把 被淘汰的页面保存在本地磁盘上,以便之后(本地或远端读取方)再把它提升回内存,从而 大幅扩展有效容量。

  • 写透(异步):set 时,页面落入内存池后还会被排队异步写入磁盘(disk_path, 默认 /data/peercache/,容量上限 disk_size,默认 100GB,磁盘本身也按 LRU 约束)。
  • 淘汰 → 标记非驻留,而非删除:内存池淘汰某页时,其目录条目会被保留但标记为 resident=false(数据在磁盘上)。只有当该页最终也被磁盘淘汰时,目录条目才删除。
  • 读时提升(promote):get 解析到非驻留条目时会触发提升 —— 数据所属节点把 页面从磁盘读回内存池(一次磁盘读 + 一次 memcpy),把目录条目重新标记为驻留,然后 提供服务。
  • 本地读:节点提升自己的页面(即预取回内存池)。
  • 远端读:读取方向数据所属节点发送 data_promote RPC;所属节点把数据从磁盘 提升进内存池并返回新的 {addr, rkey},读取方随后照常发起零拷贝 RDMA READ。
  • exists 预热:由于非驻留条目仍保留在目录中,exists 对磁盘驻留页面本就返回 命中。命中时它还会尽力异步触发一次提升,使紧接着的 get 处于热状态。
flowchart LR
    SET[set 页面] --> POOL[(内存池 MR)]
    SET -. 异步写透 .-> DISK[(磁盘分层)]
    POOL -- LRU 淘汰 --> DISK
    POOL -- 淘汰 --> DIR{{目录: resident=false}}
    GET[get 页面] --> DIR2{{目录}}
    DIR2 -- resident=false --> PROMOTE[提升: 磁盘 -> 内存池]
    PROMOTE --> POOL
    PROMOTE --> RDMA[零拷贝 RDMA READ]

对拷贝次数的影响:写透在 set 时多一次主机拷贝(页面 → 磁盘,在后台线程上完成)。 一次提升会在数据节点上多一次磁盘读 + 一次 memcpy;跨节点传输本身仍为零拷贝。磁盘 分层是可选的(disk_enabled),并能优雅降级(若 disk_path 无法创建则自动禁用, 内存池退回为淘汰即删除)。

监控(metrics + 可视化页面)

每个节点可选地运行一个 metrics 服务(默认开启,端口 31997):

  • GET /metrics —— Prometheus 文本格式,供 Prometheus/Grafana 抓取。
  • GET / —— 内置、零外部依赖的 HTML 可视化页面(自动刷新),无需 Prometheus 栈即可 快速查看。

暴露的指标包括:内存池已用字节 / 容量 / key 数量,磁盘已用字节 / key 数量,读命中率, 读写请求数与字节数计数器(配合 rate() 得到窗口速率),淘汰 / 提升计数器,以及操作 时延汇总(读写的 p50/p90/p99 与平均值)。可用 metrics_enabled 关闭、用 metrics_port 改端口,或用 metrics_dashboard 仅关闭 HTML 页面。

一致性哈希目录(p2p 模式)

目录是 p2p 模式的位置存储(slotmap 模式完全没有目录 —— 见下文)。

  • 每个节点承载目录的一个分片:本地的 key -> DataLocation 映射。所有分片的 并集构成完整目录;不存在中心存储。
  • 虚拟节点环(默认每节点 160 个 vnode)决定每个 key 的归属者,从而让写入方与读取方 独立地就 key 条目所在位置达成一致。
  • directory_replicas > 1 会把每条条目写入接下来的 N 个归属者以实现高可用;读取在 副本之间回退。

槽区域(slotmap 模式)

在 mode="slotmap" 下目录不复存在。每个节点改而持有一块大小 num_buckets × ways × slot_stride 字节的固定槽区域 MR,并通过一次 layout RPC 公告 (base_addr, geometry, rkeys);对端缓存该 layout 后不再询问。

  • key -> owner:同一张一致性哈希环(data_owner_all)。
  • key -> bucket:对 key 独立取哈希(bucket_hash)。
  • key -> way:N-way 关联 —— pick_way 优先选空槽,其次选同一 key(覆盖),再次选 最老的序号(驱逐)。
  • 每个槽以一个带版本的 seqlock 头部(key_hash128、负载长度、序号)开始。 slot_matches 是唯一闸门:读取方绝不会返回别的 key 的页面、撕裂页面(序号为奇)或 长度不符的页面 —— miss 永远是干净的。

于是读取路径变成:owner = ring(key) → bucket = bucket_hash(key) → 对整个桶做一次 单边 RDMA READ → 命中时做头部校验后 memmove。没有元数据 RPC,也没有位置记录的再分片。

连接管理

  • 连接引导使用极小的 TCP 握手(交换 QpInfo:qp_num / psn / lid / gid),将设备选择 与连接建立完全解耦。随后 QP 经历 INIT → RTR → RTS 状态迁移。
  • 每对端通道池:每个对端维护一个有界的通道池,一个通道 = 一条 RC QP + 它自己 独立的完成队列(CQ)。通道惰性创建、通过空闲列表复用,并以 max_channels_per_peer 封顶。这样既避免 O(N²) 全连接网格,又允许多个读取者同时读取同一个对端。
  • 批次内的完成项通过 wr_id 匹配请求,并从该通道自己的 CQ 中轮询。

并发模型

PeerCache 在多线程 SGLang 下两侧都既安全又并行:

  • 服务端本就完全多线程:控制面 RPC 服务、数据面响应方(RDMA 响应 QP / TCP 服务 循环)、metrics 服务各自在独立线程处理请求。单边 RDMA READ 完全不消耗响应方 CPU。
  • 客户端读并行:batch_read 在整个 RDMA 传输期间释放 GIL。每次调用按对端租用一条 独立通道(QP + 私有 CQ),因此 N 个读取线程在 N 个独立 CQ 上各自 post/poll,没有共享 CQ 竞争。达到上限时,多余线程只是短暂等待某条通道被释放。
  • 客户端控制面并行:RPC 连接池(以及 TCP 回退下的 socket 池)为每个在途调用租用一条 连接,因此对同一归属者的目录查询与 promote 可以并发执行,而不会在单条连接上串行。连接 仅在调用成功后才归还池;出错的连接会被关闭,绝不复用。
  • 共享状态:已发布池、磁盘索引、以及 key → length 映射都由锁保护,确保并发的 set/get/驱逐回调保持一致。

通过 max_channels_per_peer(默认 16)在内存(QP/CQ/socket)与对单个热点对端的读并行度 之间做权衡。

故障处理与权衡

  • 驱逐竞争:池驱逐会删除目录条目;任何解析到陈旧/缺失条目的读取都会返回 miss, 让 SGLang 重新计算(安全降级)。
  • 内嵌多主服务发现:没有专用 meta 机器,也没有单点故障。每个 host 都在 meta 端口 上运行发现服务;当前 master 是 max_masters(默认 3)个 host,配置的 discovery_addr head 被钉在最前,其余随节点加入按主机名顺序提升。非 head 的 master 挂掉会自动顶替; 成员信息也在本地缓存,因此短暂的 master 中断不会影响已建立的读写。即使 head 本身 宕机,已连接的对端仍可经其他 master 继续服务——重启 head 只是为了让全新节点能引导。
  • 目录持久性:directory_replicas 默认 2,因此单节点丢失不会在 ring 重分片前 丢掉某分片的位置记录(每个生产者在成员变更时重发布自己的页)。最坏情况是解析返回 miss、SGLang 重算——安全降级。

成熟度

PeerCache 功能完备、已通过集成测试,但还不是 SGLang 部署的生产默认选项。具体来说:

已经扎实的部分(已验证)

  • 控制面正确性 —— 服务发现(内嵌多主)、一致性哈希环、目录分片/再分片、磁盘 分层(L4)、监控、TCP 回退传输:由仓库测试套件(tests/,77 个用例)覆盖并做过 端到端演练。
  • 两种模式的 SGLang 集成 —— PeerCacheStore 继承 sglang 真实的 HiCacheStorage;v1 与 v2(sidecar 池:DSA INDEXER、Mamba/SWA TRAILING_PAGES、DeepSeek-V4 多缓冲区、Draft MHA/MLA)契约测试在 sglang main 分支类型下全部通过;真实 sglang server + Qwen2.5-0.5B 在 L20 上把 KV 发布进 PeerCache(tests/sglang/)。
  • slotmap 跨节点读取 —— 真实 sglang server 的槽区域被一个独立节点逐页读回, 全程零目录查询(tests/sglang/test_sglang_2node_slotmap_read.py)。
  • RDMA 构建 —— C++ 数据面在 libibverbs 下编译通过(HAS_RDMA=True,全套用例 通过);无网卡机器可干净地回退到 TCP。

仍待生产验证的部分

项目 状态 阻塞点
跨主机 RoCE 单边 READ 方法学与工具已就绪(peercache-bench serve/drive,见 performance.md);数字是为 p2p 测的 需要双主机 RoCE 集群对当前代码、并对 slotmap 重跑(bench 尚不支持驱动 slotmap)
DSA / Mamba / DeepSeek-V4 / Draft 真实模型 e2e 接口契约已验证 需要对应模型权重 + GPU 才能跑完整 server
官方 sglang 注册 PR 材料已就绪(sglang-pr/),create_backend("peercache") 路径已验证 上游 review / 合入
slotmap 生产调参 默认几何参数在 0.5B 模型上可用 为真实页面大小与命中率目标确定 slot_max_page_bytes / slot_ways / slot_num_buckets
buffer_only 主机模式 sglang 0.5.9 还没有(仅 main) 带 --hicache-host-memory-mode buffer_only 的 sglang 版本,以便在 e2e 中强制走 L3 读取

结论

把 PeerCache 视为生产前 / 实验室可用:设计已端到端验证(控制面、两种放置模式、 sglang 集成、RDMA 构建),但生产采用应等跨主机 RoCE 数字与上游 sglang 注册落地。