PDBackend (预填充-解码分离后端) 源码分析
PDBackend (Prefill-Decode Backend) 是 LMCache 专为 预填充-解码分离 (Prefill-Decode Disaggregation) 架构设计的核心存储后端。在此模式下,计算负载被拆分为两个阶段:
- Prefill (预填充): 负责处理输入的 Prompt,生成 KV Cache。通常由高算力节点(如 H100)承担。
- Decode (解码): 负责逐个生成 Token。通常由低显存带宽节点(如 A10/L40)承担。
PDBackend 的核心职责是实现 KV Cache 从 Prefill 实例到 Decode 实例的低延迟、高吞吐、主动推送 (Push-based) 传输。
架构定位: 它不仅是一个存储后端 (Storage Backend),还取代了
LocalCPUBackend充当 内存分配器 (Allocator) 的角色,直接管理 Host 内存以实现零拷贝传输。
1. 核心架构设计
本章将从宏观视角解析 PDBackend 的设计理念。与传统的 P2PBackend(基于 Pull 模式,即“缺页中断”式拉取)不同,PDBackend 采用 Push 模式:Prefill 节点在生成 KV Cache 后,主动将其推送到 Decode 节点。这种设计消除了 Decode 阶段的首个 Token 生成延迟 (TTFT),确保推理计算无缝衔接。
1.1 角色分工
PDBackend 根据配置 (pd_role) 分为两种角色,分别对应 PD 分离架构的两端:
-
Sender (Prefiller):
- 行为: 生产者。不保留任何 KV Cache,生成数据后立即推送到 Receiver。
- 关键逻辑: 远程内存申请 -> 数据推送 -> 通知 Proxy。
- 存储: 本地
self.data始终为空,不进行本地存储。
-
Receiver (Decoder):
- 行为: 消费者。被动接收来自 Sender 的数据,并存储在本地内存中供推理引擎读取。
- 关键逻辑: 监听内存申请请求 -> 分配本地内存 -> 返回地址 -> 等待数据写入。
- 存储: 维护
self.data,保存接收到的 KV Cache。
1.2 通信协议
PD 模式引入了一套基于 MsgPack 的轻量级控制平面协议,用于协调内存分配。
- AllocRequest: Sender 向 Receiver 申请内存。包含 Key 列表、Shape、Dtype、Format、Last Chunk Tokens 等元数据。
- AllocResponse: Receiver 返回分配结果。包含
remote_indexes(接收端内存地址/索引) 和already_sent_indexes(已存在的 Key,避免重复传输)。 - ProxyNotif: Sender 通知全局 Proxy 传输完成,Proxy 随后调度 Decode 请求给 Receiver。
1.3 组件交互流程
下图展示了 PD 模式下 KV Cache 的流转过程:
graph LR
subgraph Sender [Prefill Instance]
SenderBackend(PDBackend)
Transfer_S(TransferChannel)
end
subgraph Receiver [Decode Instance]
RecvBackend(PDBackend)
Transfer_R(TransferChannel)
AllocThread(Alloc Loop Thread)
end
Proxy[Global Proxy]
%% Flow
SenderBackend -- 1. AllocRequest (ZMQ) --> AllocThread
AllocThread -- 2. Alloc Local Mem --> RecvBackend
AllocThread -- 3. AllocResponse (Remote Addr) --> SenderBackend
SenderBackend -- 4. Write Data (RDMA/TCP) --> Transfer_S
Transfer_S == Data Stream ==> Transfer_R
Transfer_R -.-> RecvBackend
SenderBackend -- 5. Notify Done --> Proxy
2. 核心代码解析
本章深入剖析 PDBackend 的实现细节。作为系统中最复杂的后端组件,PDBackend 不仅实现了标准的存储接口,还集成了 内存分配器 (Allocator)、ZMQ 控制平面 与 数据传输平面。接下来的分析将围绕 Sender (Prefiller) 与 Receiver (Decoder) 的状态流转展开,揭示其如何通过协同工作实现高效的 KV Cache 推送。
代码位于 lmcache/v1/storage_backend/pd_backend.py。
2.1 初始化与配置
PDConfig 类负责解析配置并确定节点角色。
- Sender: 需要配置
proxy_host/port以通知调度器。 - Receiver: 需要配置
peer_host/port以绑定监听端口。 - Buffer Device: 自动检测并设置 CUDA 设备,用于 GPU-CPU 内存传输。
# 初始化时根据 role 分支处理
if self.pd_config.role == "sender":
self._init_sender()
# Sender 需要记录已初始化的 Peer 和对应的内存分配 Socket
self.initialized_peers: set[str] = set()
self.mem_alloc_sockets: dict[str, zmq.Socket] = {}
elif self.pd_config.role == "receiver":
self._init_receiver()
2.2 Sender (Prefiller) 实现
Sender 的核心逻辑在 batched_submit_put_task 中,它重写了标准 StorageBackend 的 put 行为。
2.2.1 远程内存分配
在发送数据前,Sender 必须先知道“往哪里写”。它通过 ZMQ 向 Receiver 发送 AllocRequest。
def batched_submit_put_task(self, keys, memory_objs, transfer_spec):
# 1. 建立/确认连接 (Lazy Connection)
# 根据 transfer_spec 中的目标信息建立数据面和控制面连接
self._ensure_peer_connection(...)
# 2. 发送分配请求 (AllocRequest)
# 根据 memory_objs 的 shape/dtype/fmt 构造请求
alloc_request = self._get_remote_alloc_request(keys, memory_objs)
# 阻塞等待 Receiver 返回分配结果
alloc_response = self._remote_allocate(receiver_id, alloc_request)
# 3. 解析响应
already_sent_indexes = alloc_response.already_sent_indexes
remote_indexes = alloc_response.remote_indexes # 目标物理地址列表
2.2.2 数据推送
获得目标地址后,Sender 过滤掉已存在的 Key,利用 TransferChannel 直接将数据写入 Receiver 内存。
# 4. 过滤无需发送的数据
mem_objs_to_send = []
for idx, mem_obj in enumerate(memory_objs):
if idx in already_sent_indexes:
mem_obj.ref_count_down() # 释放本地引用
else:
mem_objs_to_send.append(mem_obj)
if mem_objs_to_send:
# 5. 构造传输规范
channel_transfer_spec = {
"receiver_id": receiver_id,
"remote_indexes": remote_indexes, # 关键:告诉底层往哪写
}
# 6. 执行零拷贝传输 (RDMA Write / TCP Send)
self.transfer_channel.batched_write(
objects=mem_objs_to_send,
transfer_spec=channel_transfer_spec,
)
2.2.3 通知代理
传输完成后,Sender 通知 Proxy,标志着 Prefill 阶段彻底完成,Decode 实例可以开始工作。
if transfer_spec.is_last_prefill:
# 发送 ProxyNotif 消息
notif_msg = ProxyNotif(req_id=transfer_spec.req_id)
notif_msg_bytes = msgspec.msgpack.encode(notif_msg)
self.proxy_side_channel.send(notif_msg_bytes)
2.3 Receiver (Decoder) 实现
Receiver 启动一个后台线程 _mem_alloc_loop 专门处理分配请求。
2.3.1 内存分配循环
def _mem_alloc_loop(self):
while self.running:
try:
# 1. 接收请求 (MsgPack 解码)
alloc_req_bytes = self.alloc_side_channel.recv()
alloc_req = msgspec.msgpack.decode(alloc_req_bytes, type=PDMsg)
# 2. 分配内存并注册到 backend
alloc_resp = self._allocate_and_put(alloc_req)
# 3. 返回响应 (包含分配到的内存地址)
self.alloc_side_channel.send(msgspec.msgpack.encode(alloc_resp))
except Exception as e:
logger.error(...)
2.3.2 预先注册与忙等待
_allocate_and_put 方法不仅分配内存,还提前将 MemoryObj 放入 self.data 字典中。如果分配失败(显存不足),会进入忙等待 (Busy Loop)。
def _allocate_and_put(self, alloc_request):
# ...
# 尝试分配内存
mem_obj = self.allocate(...)
# 如果分配失败,进入忙等待 (重试直到成功)
# TODO: 这里可能会阻塞 Alloc Loop,导致死锁,未来需要优化
wait_time = 0.01
while mem_obj is None:
time.sleep(wait_time)
mem_obj = self.allocate(...)
# 获取物理地址 (供 Sender 写入)
alloc_indexes.append(mem_obj.meta.address)
# 注册到本地存储 (Key -> MemoryObj)
# 注意:此时数据尚未到达,但容器已就绪
self.put(key, mem_obj)
return AllocResponse(..., remote_indexes=alloc_indexes)
注意: 这里存在一个隐式的同步假设。Receiver 提前
put了空的 MemoryObj,如果 Decode 引擎立即读取,可能会读到空数据。因此,全局 Proxy 的调度至关重要——它必须确保 Sender 发送完ProxyNotif后,才向 Receiver 发送推理请求。
3. 内存分配器实现细节
与 LocalCPUBackend 类似,PDBackend 也实现了 AllocatorBackendInterface,这意味着它不仅仅是 KV Cache 的存储者,更是物理内存的管理者。然而,由于 PD 场景的特殊性(高吞吐、单向流动),其内存管理策略与 L1 CPU 缓存有显著差异。
3.1 Allocator 的核心职责
在 PD 架构中,Allocator 负责管理 数据传输缓冲区。
- 对于 Sender: 分配的内存作为发送缓冲区。数据从计算流生成后,暂存于此,随后被 RDMA/TCP 引擎读取并发送。
- 对于 Receiver: 分配的内存作为接收缓冲区。这块内存的物理地址会被发送给 Sender,以便远程节点直接进行 RDMA Write 操作。
3.2 实现逻辑
PDBackend 复用了 PagedCpuGpuMemoryAllocator,通过指定 allocator_type="gpu" 来直接管理显存(或 Pinned Memory),以支持 GPUDirect RDMA 技术。
def allocate(self, shapes, dtypes, ...):
"""
PDBackend 的内存分配逻辑
"""
# 直接调用底层的 PagedCpuGpuMemoryAllocator
# allocator_type="gpu" 确保分配的是适合 RDMA 传输的显存或 Pinned 内存
return self.memory_allocator.allocate(
shapes,
dtypes,
fmt,
allocator_type="gpu"
)
3.3 与 LocalCPUBackend 的区别
虽然两者都扮演 Allocator 的角色,但设计目标截然不同:
| 特性 | LocalCPUBackend | PDBackend |
|---|---|---|
| 管理介质 | 主要管理 Host Memory (CPU RAM) | 主要管理 Device Memory (GPU VRAM) 或 Pinned Host Memory |
| 逐出策略 (Eviction) | 支持。内存不足时,通过 LRU 策略逐出旧数据 (Allocate-Evict Loop)。 | 不支持。PD 模式下的数据通常是一次性的流式数据,不涉及长期缓存,因此不进行复杂的逐出。 |
| 使用场景 | 通用 L1 缓存,服务于反复访问的热点数据。 | 专用的流式传输通道,服务于一次性的 Prefill-to-Decode 传输。 |
注意: PD 模式下通常不进行 Eviction。如果显存不足,分配请求会直接失败或阻塞,这依赖于上层调度器(Proxy)精确控制并发量,避免“爆显存”。
3.4 关于共存性的说明
Q: 启用 PD 模式后,是否还需要 LocalCPUBackend?
A: 不需要,两者在架构设计上是互斥的。
这不仅仅是功能重叠的问题,更核心的原因在于 KV Cache 在 PD 场景下的角色发生了根本转变:
-
流式传输 vs 静态缓存:
- 在 PD 架构中,KV Cache 是连接 Prefill 和 Decode 阶段的 一次性数据流 (Transient Stream)。Sender 生成数据后立即推送,Receiver 接收后立即使用,整个过程不涉及“缓存复用”。
-
分配器 (Allocator) 的排他性:
- LMCache 系统要求内存分配器是唯一的。PD 模式下,KV Cache 的生命周期(申请->传输->释放)由
PDBackend全权管理。LocalCPUBackend的 LRU 逐出策略并不适用于这种流式场景。
- LMCache 系统要求内存分配器是唯一的。PD 模式下,KV Cache 的生命周期(申请->传输->释放)由
4. 总结
PDBackend 作为 LMCache 对分离式推理架构的深度定制,体现了“存储服务于计算”的设计哲学:
- 消灭首字延迟 (TTFT): 采用 Push-based 主动推送机制,将 KV Cache 的传输隐藏在调度间隙中。相比于 P2P 的“按需拉取 (Pull)”,它彻底消除了首次解码时的网络 I/O 等待。
- 极致传输效率: 通过 控制面 (ZMQ) 与数据面 (RDMA/TCP) 分离,配合地址预协商机制,实现了 Sender 显存到 Receiver 显存的 零拷贝 (Zero-Copy) 直达,最大化利用物理带宽。
- 深度调度协同: 突破了传统存储后端的被动边界,通过 ProxyNotif 机制与全局调度器紧密耦合,确保了“数据到位即计算开始”的流水线并行能力。