LocalDiskBackend 源码分析
LocalDiskBackend 构成了 LMCache 多级存储架构中的 L3 本地磁盘缓存层。它利用本地磁盘(强烈推荐 NVMe SSD)作为高容量、低成本的扩展缓冲池,主要用于接纳从 L1/L2 内存层溢出(Evict)的冷热数据。
与传统的文件存储不同,LocalDiskBackend 的设计目标并非数据的长期归档,而是作为 Runtime Swap 扩展 GPU/CPU 有限的内存容量。为了弥补磁盘 I/O 相对内存的延迟劣势,该模块深度集成了 O_DIRECT 直通 I/O、异步优先级调度流水线 以及 精细的内存生命周期管理 等高性能技术。
架构定位: 在
lmcache_storage_overview.md定义的层级中,它是 L3 层。需要特别注意的是,它不具备断电持久化能力(索引纯内存维护),且强依赖 L1 层(LocalCPUBackend)作为内存分配器(Allocator)和数据传输中转站。
1. 存储架构与文件布局
针对 AI 推理场景中 KV Cache 数据的不可变性及“一次写入,多次读取”(WORM)的访问特征,LMCache 设计了极简的底层存储格式,旨在消除传统存储方案中的元数据开销以最大化 I/O 吞吐量。
1.1 扁平化文件结构
LocalDiskBackend 摒弃了复杂的目录树,采用扁平化的文件结构。每个 KV Cache Chunk 对应一个独立的磁盘文件。
- 存储路径: 由配置项
config.local_disk指定(如/mnt/nvme/lmcache)。 - 文件名规则: 在
_key_to_path方法中实现,基于CacheEngineKey的字符串表示,将/替换为-,并追加.pt后缀。- Key:
model/layer/chunk_hash - Filename:
model-layer-chunk_hash.pt
- Key:
代码实现参考 _key_to_path:
def _key_to_path(
self,
key: CacheEngineKey,
) -> str:
return os.path.join(self.path, key.to_string().replace("/", "-") + ".pt")
1.2 Raw Bytes 存储格式
为了极致的 I/O 性能,磁盘文件不包含任何元数据头(Header),仅存储 Tensor 的原始字节流(Raw Bytes)。这是通过 write_file 和 read_file 方法直接操作 MemoryObj 的 byte_array 来实现的。
- 写入: 直接将内存中
MemoryObj的byte_array写入磁盘。代码参考 write_file。 - 读取: 由于文件不自描述,读取时必须依赖内存中的索引元数据来获知数据的 Shape、Dtype 和 Format,才能正确地将字节流还原为 Tensor。代码参考 read_file。
这种设计消除了序列化/反序列化的 CPU 开销,实现了 Memory-to-Disk 的零拷贝传输(配合 O_DIRECT)。
2. 索引机制与元数据管理
由于底层采用无 Header 的 Raw Bytes 格式存储,文件本身不具备自描述性,因此 LocalDiskBackend 必须在内存中维护一份完整的元数据索引,以实现数据的正确寻址与 Tensor 重构。
2.1 内存索引结构 (In-Memory Index)
为了在无元数据头的扁平文件中快速定位和还原 Tensor,LocalDiskBackend 在内存中维护了一个全局的元数据索引。这个索引是整个后端正常工作的核心,它弥补了文件系统无法自描述的缺陷。
- 索引结构:
self.dict(线程安全字典)。初始化时通过self.cache_policy.init_mutable_mapping()创建,参考 __init__。 - 映射关系:
CacheEngineKey->DiskCacheMetadata。 - DiskCacheMetadata 核心字段: (定义于 lmcache/utils.py)
path: 文件绝对路径。size: 数据字节大小。shape: Tensor 形状 (关键,用于还原)。dtype: 数据类型 (关键,用于还原)。fmt: 内存格式 (如KV_BLOB或VLLM)。cached_positions: 缓存的位置信息。pin_count: 引用计数,防止在使用时被驱逐。
2.2 并发控制与细粒度锁
LocalDiskBackend 使用 self.disk_lock 互斥锁来确保多线程环境下索引读写的一致性。
关键设计:细粒度锁 (Fine-grained Locking):为了最大化并发性能,系统严格遵循锁仅保护快速的内存元数据操作,而将耗时的磁盘 I/O 置于临界区(Critical Section)外执行的原则。
以下面的 get_blocking 方法为例,我们可以清晰地看到这一模式。代码参考 get_blocking:
def get_blocking(self, key: CacheEngineKey) -> Optional[MemoryObj]:
# 1. 获取锁:进入临界区
self.disk_lock.acquire()
if key not in self.dict:
self.disk_lock.release()
return None
# 2. 内存操作:更新 LRU 和拷贝元数据 (速度快,微秒级)
self.cache_policy.update_on_hit(key, self.dict)
disk_meta = self.dict[key]
path = disk_meta.path
# ... (获取 shape, dtype 等)
# 3. 释放锁:离开临界区
self.disk_lock.release()
# 4. 磁盘 I/O:在锁外执行 (速度慢,毫秒级)
# 此时其他线程可以继续访问 self.dict
memory_obj = self.load_bytes_from_disk(
key, path, dtype=dtype, shape=shape, fmt=fmt
)
return memory_obj
2.3 设计权衡:为何不持久化? (Design Rationale)
深入分析源码可以发现,self.dict 是纯内存结构,且在 LocalDiskBackend 初始化时并没有从磁盘加载元数据的逻辑。这意味着一旦进程重启,之前的磁盘缓存将全部失效(即使文件还在)。
设计考量:
- 定位为 Runtime Swap: 目前 LMCache 将本地磁盘视为内存的扩展(类似 Swap 分区),而非持久化数据库。其生命周期通常与推理服务进程绑定。
- 性能权衡: 如果要支持持久化,必须在每次写入时同步更新元数据文件(如 SQLite 或 JSON),这会引入额外的 I/O 开销(尤其是小 IOPS),削弱
O_DIRECT带来的高吞吐优势。 - 数据时效性: KV Cache 通常与特定的 Decoding Context 绑定,重启后之前的 Cache 往往不再适用(除非是 Prefix Caching 场景,但目前实现尚未针对此优化持久化),因此持久化的收益有限。
潜在隐患:
- 磁盘空间泄露: 如果进程非正常退出(Crash),遗留的
.pt文件不会被自动清理,长期运行可能导致磁盘占满。生产环境建议配合tmpwatch或启动脚本进行清理。
3. I/O 核心机制:O_DIRECT 与内存管理
本节深入剖析 LocalDiskBackend 的 I/O 加速机制,重点阐述其如何利用 O_DIRECT 技术消除操作系统页缓存带来的双重缓冲(Double Buffering)开销,并结合引用计数策略解决异步并发场景下的内存生命周期管理难题。
3.1 O_DIRECT 直通 I/O
Linux 的标准 I/O 会将数据缓存在 Page Cache 中,这在 KV Cache 场景下会导致双重缓存(LMCache 已有 L1 内存池)和额外的内存拷贝。
LocalDiskBackend 在 write_file 和 read_file 中实现了 O_DIRECT 模式。
以下是 write_file 的核心实现,清晰展示了 O_DIRECT 的启用条件与调用方式:
def write_file(self, buffer, path):
# ...
size = len(buffer)
# 1. 条件检查:必须同时满足 对齐要求 (fblock_aligned) 和 配置开启 (use_odirect)
# O_DIRECT 要求内存地址和大小通常需要对齐到 512 或 4096 字节
if size % self.os_disk_bs != 0 or not self.use_odirect:
# Fallback: 使用标准 Buffered I/O
with open(path, "wb") as f:
f.write(buffer)
else:
# 2. O_DIRECT 路径
# os.O_DIRECT: 绕过 Page Cache
# os.O_WRONLY | os.O_CREAT: 写模式,不存在则创建
fd = os.open(path, os.O_CREAT | os.O_WRONLY | os.O_DIRECT, 0o644)
try:
os.write(fd, buffer)
finally:
os.close(fd)
关键点:
- 对齐检查:
size % self.os_disk_bs == 0。这是O_DIRECT的硬性要求,否则内核会报错EINVAL。 - 降级机制: 如果条件不满足(例如数据块大小未对齐),代码会自动降级为普通的文件 I/O,保证了系统的鲁棒性。
3.2 完整写入流程:驱逐、锁定与异步 I/O
LocalDiskBackend 的写入流程(Put)不仅仅是简单的 I/O 提交,还涉及同步驱逐和内存生命周期管理。这是一个混合了同步阻塞与异步执行的复杂过程:
写入流程 (Put) 详解:
-
任务去重 (Deduplication):
- 首先检查该 Key 是否已有正在进行的写入任务。如果有,直接返回,避免重复 I/O。
- 详见 4.2 写入任务去重。
-
空间检查与同步驱逐 (Synchronous Eviction):
- 这是 Put 流程中唯一的阻塞点。
- 在提交任务前,检查磁盘缓存空间是否充足。如果不足,主线程会阻塞,循环调用
cache_policy.get_evict_candidates并执行删除,直到腾出足够空间。 - 源码位置: submit_put_task
# 伪代码逻辑 while self.current_cache_size + required_size > self.max_cache_size: evict_keys = self.cache_policy.get_evict_candidates(...) # 同步删除文件,释放空间 self.batched_remove(evict_keys, force=False) -
内存锁定 (Ref Count Up):
- 通过显式引用计数机制,确保在异步 I/O 期间内存不被回收。
- 主线程调用
memory_obj.ref_count_up()。
-
异步提交:
- 将任务提交给
LocalDiskWorker线程池(优先级为 2)。 - 此时主线程返回,后续 I/O 由后台线程处理。
- 将任务提交给
-
物理写入与释放 (Worker Thread):
- 后台线程执行
O_DIRECT写入。 - 写入完成后,调用
memory_obj.ref_count_down()释放内存引用。 - 更新
self.dict索引,标记 Key 为可用。
- 后台线程执行
读取流程 (Get):
读取流程采用 Caller-Allocated 模式,相对简单:
- 预分配: 主线程查询索引获取 Shape/Dtype,直接调用
self.local_cpu_backend.allocate(...)分配内存对象。 - 直接读取: 后台线程直接将磁盘数据读入该对象的
byte_array缓冲区。引用计数由调用者(上层 Cache Engine)管理。
4. 任务调度:LocalDiskWorker
为避免磁盘 I/O 的长尾延迟阻塞关键的推理计算路径,LocalDiskBackend 采用全异步设计,将所有耗时的磁盘交互(写入、删除、预取)封装为独立任务,全权委托给内部专用的 LocalDiskWorker 代理执行,从而实现计算与存储的 pipeline 并行。
4.1 优先级线程池
底层使用 AsyncPQThreadPoolExecutor 实现了一个基于优先级的异步线程池。它结合了 asyncio.PriorityQueue 和线程池(asyncio.to_thread),确保关键路径上的 I/O 操作优先执行。
源码位置: AsyncPQThreadPoolExecutor
调度策略:
- 优先级队列: 任务按
(priority, order, task)元组入队。priority数值越小,优先级越高。 - FCFS (First-Come-First-Served): 当优先级相同时,使用递增的计数器 (
itertools.count) 确保先提交的任务先执行。
优先级定义:
| 任务类型 | 优先级 | 代码实现 | 理由 |
|---|---|---|---|
| Prefetch | 0 (最高) | submit_task(..., priority=0) |
预取操作通常发生在 decoding 阶段的 retrieve 环节,直接阻塞 GPU 计算流水线,必须最快响应。 |
| Delete | 1 (中等) | submit_task(..., priority=1) |
及时释放磁盘空间,防止因磁盘满导致新的写入失败或触发同步驱逐(Synchronous Eviction),阻塞主线程。 |
| Put | 2 (最低) | submit_task(..., priority=2) |
写入是后台持久化操作,可以容忍一定的延迟。让出带宽给 Prefetch 可以提升端到端延迟性能。 |
4.2 写入任务去重 (Deduplication)
在高并发场景下(如多个 Request 引用了相同的 Prefix),可能会对同一个 Key 多次触发 put 操作。为了避免重复 I/O 浪费带宽,LocalDiskWorker 维护了一个正在进行的任务列表 put_tasks。
源码位置: LocalDiskWorker.put_tasks
工作流程:
- 提交检查: 调用
insert_put_task前,先检查 Key 是否已存在。- 如果存在,直接跳过并记录日志 (
Put task ... already in progress)。 - 代码: submit_put_task
- 如果存在,直接跳过并记录日志 (
- 任务注册: 将 Key 加入
put_tasks列表(由put_lock保护)。 - 任务完成: 磁盘写入完成后,调用
remove_put_task将 Key 移除,允许后续再次写入。
5. 配置项说明
LocalDiskBackend 的行为由 LMCacheEngineConfig 统一管理。以下是核心配置项及其对性能的影响:
- 源码位置: LMCacheEngineConfig
| 配置项 | 类型 | 默认值 | 说明 |
|---|---|---|---|
local_disk |
str |
None (必填) |
本地磁盘缓存目录的绝对路径。如果目录不存在,LocalDiskBackend 会尝试自动创建。 |
max_local_disk_size |
float |
0.0 |
允许使用的最大磁盘空间(单位:GB)。当缓存用量超过此阈值时,会触发同步驱逐策略。建议设置为磁盘容量的 80%-90%。 |
extra_config["use_odirect"] |
bool |
False |
是否开启 Linux O_DIRECT 直通 I/O 模式。1)开启建议: 使用 NVMe SSD 时强烈建议开启,可绕过 Page Cache 减少内存拷贝和 CPU 开销。2)注意: 开启后要求写入数据必须按 Block Size 对齐(通常 512B 或 4KB)。 |
cache_policy |
str |
"LRU" |
缓存淘汰策略。目前主要支持 LRU。该策略决定了当空间不足时哪些 Key 会被优先驱逐。 |
配置示例 (YAML):
local_disk: "/mnt/nvme0/lmcache"
max_local_disk_size: 500 # 500 GB
cache_policy: "LRU"
extra_config:
use_odirect: true