LMCache Nixl Store L2 Adapter 设计解析:基于 Nixl 的静态与动态 KV 缓存卸载层
2026/9/15 12:57:40 网站建设 项目流程

LMCache Nixl Store L2 Adapter 设计解析:基于 Nixl 的静态与动态 KV 缓存卸载层

【免费下载链接】LMCacheLMCache: Supercharge Your LLM with the Fastest KV Cache Layer项目地址: https://gitcode.com/GitHub_Trending/lm/LMCache

导读

本文以 docs/design/v1/distributed/l2_adapters/nixl_store.md 设计文档为主体,结合 LMCache 仓库中的实际源码(lmcache/v1/distributed/l2_adapters/目录)与单元测试,系统讲解基于 Nixl 库的 KV-cache L2 卸载层(Offload Tier)实现:nixl_store静态适配器与nixl_store_dynamic动态适配器的架构差异、DMA 传输流程、线程模型、持久化恢复机制以及完整配置方法。读完本文,你将能够:理解 Nixl L2 适配器如何通过 DMA 将 KV 缓存从 L1(DRAM/VRAM)卸载到二级存储;掌握静态与动态两种模式各自的适用场景与取舍;并能够独立编写、校验并部署这两种适配器的 JSON 配置。

Nixl L2 适配器家族概览

在 LMCache 的多级缓存架构中,L2AdapterInterface(定义于 base.py)是所有二级存储适配器的统一抽象,它面向控制器提供三类非阻塞原语:

  1. Store:将一批与 key 关联的内存对象写入二级存储;
  2. Lookup and Lock:按 key 查询对象,并在加载前对命中的对象加"锁"(pin),防止其在加载期间被逐出;
  3. Load:按 key 将对象读回 L1 内存,结果以 Bitmap 逐 key 表示成功或失败。

Nixl L2 适配器家族正是该接口的一组实现,它借助 Nixl 库通过DMA将 KV 缓存对象从 L1 卸载到二级存储。设计文档将其分为两个变体:

AdapterType nameStorage modePersistBackends
NixlStoreL2Adapternixl_storeStatic(初始化时预分配文件)不支持GDS、GDS_MT、POSIX、HF3FS、OBJ、AZURE_BLOB
DynamicNixlStoreL2Adapternixl_store_dynamicDynamic(按操作逐文件)支持(默认开启)GDS、GDS_MT、POSIX、HF3FS

两者的核心区别在于存储资源的生命周期管理方式:

  • 静态适配器在初始化时一次性预分配所有存储文件,并将它们以**单一预制备描述符列表(prepped descriptor list)**注册给 Nixl,后续每次传输只需复用这批句柄;
  • 动态适配器在每次 store/load 操作时临时打开、注册文件,操作完成后立即注销并关闭文件,从而支持跨重启的 KV 元数据持久化与恢复,同时规避操作系统打开文件描述符(fd)数量的限制。

从源码看,两个适配器分别实现于 nixl_store_l2_adapter.py 与 nixl_store_dynamic_l2_adapter.py,并各自通过register_l2_adapter_typeregister_l2_adapter_factory完成自注册,因此可直接通过 JSON 配置中的"type"字段被工厂按名实例化。

静态适配器:NixlStoreL2Adapter

核心组件

NixlStoreL2Adapter(源码见 nixl_store_l2_adapter.py)由四个关键类协作完成:

NixlStoreObj

单个缓存对象在 Nixl 存储中的元数据记录,字段与设计文档一一对应:

  • page_indices:持有该对象数据的预分配存储槽位索引列表;
  • size:对象的字节大小;
  • layout:可选的MemoryLayoutDesc(shape/dtype 信息),用于对象重建;
  • pin_count:引用计数。加载进行中时对象被 pin,防止被逐出。源码中increase_pin_count()/decrease_pin_count()由对象自带的threading.Lock保护,且decrease在计数已为 0 时打印告警,避免负数状态。
NixlObjPool

线程安全的整数索引池,代表固定数量的预分配存储槽位(共pool_size个)。batched_allocate(num_objs)在槽位不足时返回空列表(而非抛异常),batched_free归还槽位。它在 store 前分配槽位,在传输失败或对象被逐出后释放槽位;get_slot_usage()返回槽位池的占用率。

NixlStorageAgent

Nixl agent API 的轻量封装,职责包括:

  • 注册 L1 内存缓冲init_mem_handlers):将 L1 的连续内存按align_bytes页粒度切分,通过register_memory注册并调用prep_xfer_dlist预制备内存侧传输句柄;
  • 注册存储槽位:文件类后端(GDS、GDS_MT、POSIX、HF3FS)调用init_storage_handlers_filepool_size个文件注册,每个文件可容纳file_size // page_size个页;对象类后端(OBJ、AZURE_BLOB)调用init_storage_handlers_object按页注册对象 key;
  • 生成预制备传输句柄get_mem_to_storage_handle(WRITE)与get_storage_to_mem_handle(READ)通过make_prepped_xfer把内存页索引与存储页索引组合成一次批量 DMA 传输;
  • 驱动异步传输post_non_blocking提交传输并轮询check_xfer_state直至DONE,期间以asyncio.sleep(0.01)让出事件循环,出错时抛出RuntimeError
NixlStoreL2Adapter

实现L2AdapterInterface的对外适配器本体,它拥有:

  • 一个运行在专用守护线程中的后台 asyncio 事件循环,所有 DMA 协程都在其中执行;
  • 三个 Linuxevent-fd(store / lookup / load),用于免轮询地向调用方通知任务完成;
  • 一个共享的dict[ObjectKey, NixlStoreObj]作为内存索引(_memory_objects);
  • 一把保护所有共享状态的threading.Lock

close()的时序也值得注意:它通过run_coroutine_threadsafe先取消事件循环内所有 in-flight 任务(带 5 秒超时),再stop循环、join线程,最后释放 Nixl 资源并关闭三个 event-fd;注释特别说明要基于is_closed()而非is_running()判断,以避免循环线程尚未进入run_foreverjoin永久阻塞。

操作流程

设计文档以伪代码形式给出了三条主链路,源码实现与之一一对应:

Store
submit_store_task(keys, objects) └─ schedules _execute_store_in_the_loop on the asyncio loop ├─ for each key/object: allocate storage slots, collect page indices ├─ issue single batched DMA write (mem → storage) ├─ on success: record key→NixlStoreObj in _memory_objects └─ on failure: free allocated slots; mark task failed └─ signals store event-fd

源码中的_execute_store_in_the_loop会先跳过已存在的 key(避免泄漏池槽位),按obj.meta.addressobj.meta.phy_size计算内存页索引,再从池中分配等量存储槽位;若池已空(返回[])则中断本次批次。全部索引收集完成后,通过一次批量 DMA WRITE 写入;成功后把key → NixlStoreObj记入_memory_objects(初始pin_count=1随后递减),并调用基类的_notify_keys_stored完成字节级用量记账;任何异常都会走batched_free释放已分配槽位,并将任务标记为失败。L2StoreResult同时编码成功标志与实际传输字节数。

Lookup & Lock
submit_lookup_and_lock_task(keys) └─ schedules _execute_lookup_in_the_loop (sync, via call_soon_threadsafe) ├─ for each key present: set bitmap bit, increment pin_count └─ records bitmap in _completed_lookup_tasks └─ signals lookup event-fd submit_unlock(keys) └─ schedules pin_count decrement for each key (fire-and-forget)

查找是同步任务,通过call_soon_threadsafe调度;命中即置位 Bitmap 并递增pin_countsubmit_unlock按接口契约不返回 task id,调用方假定解锁必然最终成功且永不重试。

Load
submit_load_task(keys, objects) └─ schedules _execute_load_in_loop on the asyncio loop ├─ for each found key: collect mem/storage page indices, set bitmap bit ├─ issue single batched DMA read (storage → mem) └─ records bitmap in _completed_load_tasks └─ signals load event-fd

加载是异步协程,通过run_coroutine_threadsafe调度;未命中的 key 静默跳过(Bitmap 位保持 0),命中的 key 合并为一次批量 DMA READ 读入调用方提供的MemoryObj。加载成功后调用_notify_keys_accessed通知 LRU 等监听器(该通知不涉及字节记账)。

线程模型

设计文档给出的线程分工在源码中得到完整印证:

ThreadRole
Caller thread(s)调用submit_*/query_*;绝不直接触碰存储
Event-loop thread执行所有 Nixl DMA 协程;独占_memory_objects的变更
Shared lock保护_memory_objects、任务结果字典与 task-id 计数器

查找为同步(call_soon_threadsafe),store 与 load 为异步协程(run_coroutine_threadsafe)。report_status()返回is_healthystored_object_countpinned_object_countpool_sizepool_free_slotsevent_loop_alive等状态字段,其中健康度直接由事件循环线程是否存活决定。

内存地址 → 页索引映射

L1 内存以单一连续缓冲注册给 Nixl,按align_bytes固定页大小切分。位于地址addr、大小为sz的内存对象映射到的页索引区间为:

[addr // align_bytes, addr // align_bytes + 1, ..., addr // align_bytes + sz // align_bytes - 1]

addrsz都必须为align_bytes的整数倍。源码get_memory_indices对非对齐输入直接抛出ValueError,页数为sz // align_bytes

动态适配器:DynamicNixlStoreL2Adapter

源码位于 nixl_store_dynamic_l2_adapter.py。

设计动机

静态适配器在初始化时预分配全部存储文件并注册给 Nixl,存在两个固有限制:

  1. OS 文件描述符上限:每个存储槽位都需要一个打开的 fd,实际限制了池的规模;
  2. 无法持久化/恢复:文件以随机 UUID 命名,且内存索引_memory_objects在进程退出后丢失。

动态适配器通过"按操作打开/注册文件"与"由ObjectKey确定性派生文件名"两条手段同时解决上述问题。

与静态适配器的关键差异

AspectStaticDynamic
File lifecycle初始化时全部打开,关闭时统一关闭每次 store/load 打开,传输完成后关闭
File naming随机 UUID(obj_{i}_{uuid}.bin可读字段({model}_{rank}_{group}_{hash}[@{cache_salt}].bin
Nixl registration单次预制备全部存储的 dlist每次操作 register → transfer → deregister
Pool / page indicesNixlObjPool管理固定槽位无池;NixlStoreObj.page_indices不使用([]
Capacity control池大小(槽位数)max_capacity_gb(字节粒度)
Persist/recover不支持支持
Batching每批 key 一次 DMA 传输每个 key 一次 DMA 传输(每个 key 对应独立文件)

关于文件命名的兼容性,设计文档特别说明:cache_salt为空的 key 沿用旧式文件名与 chunk-hash 分片;cache_salt非空的 key 在.bin扩展名前追加@<cache_salt>,与 S3 和文件系统 L2 适配器采用的尾部 salt 表示一致。分片目录层级仍使用 chunk hash,文件名负责 salt 隔离。

核心组件

DynamicNixlStorageAgent

动态 Nixl 存储代理的基类(见 dynamic_nixl_store_agent.py),拥有 Nixl agent、L1 内存注册、页索引计算、传输生命周期与关闭逻辑。后端特定子类直接以ObjectKey为操作对象,不向适配器暴露存储路径,从而让文件与对象存储后端共享同一套机制。基类还通过两个纯函数定义了确定性命名规则:

  • _object_key_to_filename(key){model}_{rank:08x}_{group:x}_{chunk_hex}[@{salt}].bin,其中模型名中的/替换为--
  • _object_key_to_relpath(key){chunk_hex[:2]}/{chunk_hex[2:4]}/{filename}的两级分片路径,分片与cache_salt无关。

抽象方法包括dynamic_storedynamic_loaddynamic_deleteget_stored_sizecleanup

FileDynamicNixlStorageAgent

文件后端实现(见 file_dynamic_nixl_store_agent.py),初始化时注册 L1 内存,每次操作为单文件做注册:

  • dynamic_store(mem_indices, key):创建该 key 的数据文件 → 注册给 Nixl → DMA 写 → 注销 → 关闭 fd;
  • dynamic_load(mem_indices, key):打开该 key 已有的数据文件 → 注册 → DMA 读 → 注销 → 关闭 fd;
  • dynamic_delete(key):以os.unlink()删除数据文件。

值得展开的源码细节是atomic publish(原子发布):store 的 DMA 写入目标是同目录下的<final_path>.tmp.<uuid>临时文件,只有传输完整成功后才通过os.rename()原子地改名为最终确定性路径,从而保证共享同一目录的读者(包括其他进程)永远看不到半写状态的文件;O_TRUNC标志则确保崩溃遗留的孤儿文件被截断而非残留陈旧尾部字节。cleanup()会在关闭时尽力清理遗留的*.tmp.*文件,作为崩溃 store 的兜底 GC(孤儿文件不影响正确性,因为确定性命名映射永远不会匹配它们)。

shard_dirs参数(默认"false",保持原始扁平布局)可开启两级子目录树来分散文件,并缓存已创建子目录以避免 store 热路径上的重复makedirs。此外,use_direct_io仅在系统支持O_DIRECT时才启用,否则回退到缓冲 I/O。

DynamicNixlStoreL2Adapter

与静态适配器实现同一L2AdapterInterface契约,差异点(均有源码依据):

  • Store:逐 key 调用dynamic_store;每次写入前在锁内检查_total_bytes + obj_size > _max_capacity_bytes,超限即跳过(并将任务标记为失败),_inflight_stores集合与_total_bytes在 DMA 之前预留、失败时回滚,保证并发协程(其他 store 或二级查找)能看到一致的容量状态;
  • Delete:除从_memory_objects移除 key 外,还在锁外执行dynamic_delete删除磁盘文件,避免文件 I/O 阻塞并发的 store/lookup/load;
  • Capacity:维护_total_bytes(store 与二级查找命中时增加、delete 时减少);get_usage()返回_total_bytes / _max_capacity_bytes供逐出控制器使用(该比值由基类AdapterUsage统一提供,见下节);
  • Close:先停止事件循环并等待 in-flight 任务;persist_enabled为真时保留磁盘数据文件,否则删除全部数据文件,随后执行cleanup()与 agent 关闭;
  • Lookup:未命中时总是落到磁盘做同步二级查找,详见下节。

动态加载的另一个亮点是并发:_execute_load_in_loop对一个请求内多个 key 的文件用asyncio.gather(..., return_exceptions=True)并发读取,避免多 chunk 请求逐文件串行付出 Nixl 延迟;单个 chunk 失败只会置空对应 Bitmap 位,不影响其余成功 chunk。

操作流程

Store
submit_store_task(keys, objects) └─ schedules _execute_store_in_the_loop on the asyncio loop ├─ for each key/object: │ ├─ check capacity (skip remaining if exceeded) │ ├─ compute deterministic file path from ObjectKey │ ├─ open file, register with Nixl, DMA write, deregister, close │ └─ record key→NixlStoreObj in _memory_objects, update _total_bytes └─ signals store event-fd
Load
submit_load_task(keys, objects) └─ schedules _execute_load_in_loop on the asyncio loop ├─ for each found key: │ ├─ compute file path from ObjectKey │ └─ open file, register with Nixl, DMA read, deregister, close └─ signals load event-fd

Lookup 与 unlock 与静态适配器完全一致(内存索引查找 + pin 计数管理),但额外叠加了磁盘二级查找。

持久化与二级查找(Persist / Secondary Lookup)

配置

PersistConfig定义于 l2_adapters/config.py,仅含一个布尔字段:

FieldDefaultPurpose
persist_enabledTrue为 True 时,关闭进程后数据文件保留在磁盘上。

该字段由L2AdapterConfigBase._parse_persist_config()从适配器 JSON 配置的"persist_enabled"键解析(bool(d.get("persist_enabled", True)))。要点如下:

  • 查找未命中时总是检查二级存储(磁盘),该行为不可配置;
  • 只有动态适配器(nixl_store_dynamic)使用 persist,静态适配器忽略该设置。

工作原理

L2AdapterInterface上没有独立的persist()recover()方法——持久化与恢复通过两个既有钩子隐式实现:

Persist(关闭时的文件保留)

close()中事件循环停止后:

  • persist_enabled,数据文件原样留在磁盘;
  • 否则_memory_objects中的每个文件都被os.unlink删除,避免孤儿存储。

不写任何元数据 JSON——确定性的ObjectKey → filename映射足以在重启时重新发现每个文件。

一个必须注意的升级提示:旧版本为"非空 salt"创建的文件存在歧义(旧文件名没有记录 salt),salted 查找不会回退到该路径。因此升级一个此前使用 salted 动态 NIXL 流量的部署前,应先清理或隔离旧缓存目录;未加盐路径保持兼容。

Secondary Lookup(懒式磁盘恢复)

_execute_lookup_in_the_loop在内存索引未命中时总是附加一次磁盘二级查找:

  1. ObjectKey计算确定性文件路径;
  2. os.stat(file_path)——文件存在即视为命中(动态模式下实际经由FileDynamicNixlStorageAgent.get_stored_size返回文件大小);
  3. 以 stat 得到的sizelayout=None懒填充_memory_objects[key]
  4. 更新_total_bytes并执行容量检查(超出则跳过)。

NixlStoreObj.layout在二级查找时保持None。布局信息只在加载时需要,届时由调用方提供的MemoryObj的 shape/dtype/phy_size 补足。被二级查找恢复的 key 同样会走_notify_keys_stored,使基类记账与磁盘状态保持一致;正在 in-flight store 的 key 会被跳过以避免_total_bytes重复计数。

配置指南

两种适配器的配置都通过可重复的--l2-adapter <JSON>命令行参数传入(解析逻辑见 config.py 的parse_args_to_l2_adapters_config:每个 JSON 必须包含"type"字段,from_dict负责校验并构建实例,顺序即适配器挂载顺序)。

静态适配器(nixl_store

{ "type": "nixl_store", "backend": "POSIX", "backend_params": { "file_path": "/path/to/storage", "use_direct_io": "false" }, "pool_size": 100 }

参数语义(依据 nixl_store_l2_adapter.py 中的NixlStoreL2AdapterConfig):

  • backend(必填):GDSGDS_MTPOSIXHF3FS(文件类)或OBJAZURE_BLOB(对象类),取值不合法直接抛ValueError
  • backend_params.file_path(文件类后端必填):存储文件所在目录,初始化时自动os.makedirs(exist_ok=True)
  • backend_params.use_direct_io(文件类后端必填):"true"时以O_DIRECT打开文件,系统不支持时告警回退缓冲 I/O;
  • backend_params.file_size(可选):每个存储文件槽位的字节大小,默认取 L1 页大小(l1_memory_desc.align_bytes),且必须是页大小的整数倍(否则抛ValueError),每文件页数pages_per_file = file_size // align_bytes
  • pool_size(必填,正整数):预分配的存储描述符数量。注意实际槽位总数 =pool_size × pages_per_file(文件类后端),对象类后端则为pool_size。适配器的字节容量max_capacity_bytes = pool.total_objs × align_bytes会传递给基类,作为get_usage()/supports_global_eviction的依据。

动态适配器(nixl_store_dynamic

{ "type": "nixl_store_dynamic", "backend": "POSIX", "backend_params": { "file_path": "/path/to/storage", "use_direct_io": "false", "max_capacity_gb": "10" }, "persist_enabled": true }

参数语义(依据 nixl_store_dynamic_l2_adapter.py):

  • backend(必填):仅支持文件类后端GDSGDS_MTPOSIXHF3FS(源码注释明确 OBJ 后端暂未支持);
  • backend_params.file_pathuse_direct_io(必填):同上;
  • backend_params.max_capacity_gb(必填,正数):容量上限,构造时max_capacity_gb <= 0直接抛ValueError,字节容量 =max_capacity_gb × 1024³
  • backend_params.shard_dirs(可选,默认"false"):开启两级 chunk-hash 子目录分片(xx/yy/...),缓解单目录文件过多的问题;
  • persist_enabled(可选,默认true):关闭进程时保留磁盘数据文件,使重启后可通过二级查找恢复。

容量与逐出

两个适配器的容量语义不同:静态模式受槽位总数约束(每次 store 需要等量槽位,池空即中断批次);动态模式受字节数max_capacity_bytes约束(store 与二级查找命中前都做字节预算检查)。二者都通过基类 base.py 的_notify_keys_stored/_notify_keys_deleted维护统一的AdapterUsage记账(含按cache_salt分桶的bytes_by_cache_salt),get_usage().usage_fraction供逐出控制器决策;delete()都会跳过pin_count > 0的 pinned 对象,避免与 in-flight 加载竞争,逐出控制器会在下个周期重试。

验证与测试

仓库在 tests/v1/distributed/test_nixl_store_l2_adapter.py 与 tests/v1/distributed/test_nixl_store_dynamic_l2_adapter.py 中分别覆盖了静态与动态适配器的核心行为,可作为深入理解实现细节与回归验证的入口。测试与源码共同印证了本文所述的关键事实:静态适配器的池槽位分配/释放语义、动态适配器的确定性文件名与容量记账、二级查找的懒恢复路径,以及关闭时persist_enabled对数据文件去留的控制。

总结

Nixl Store L2 适配器家族为 LMCache 提供了两条基于 DMA 的 KV 缓存卸载路径:nixl_store以预分配文件 + 预制备描述符换取低传输开销,适合存储规模固定、无需跨进程重启恢复的场景;nixl_store_dynamic以"每操作注册/注销文件"换取文件描述符的可扩展性与开箱即用的持久化恢复能力,适合长生命周期、需要跨重启复用缓存的生产部署。理解两者的组件分工(NixlStoreObj/NixlObjPool/NixlStorageAgent)、事件驱动 + 后台事件循环的线程模型,以及persist_enabled与二级查找的组合行为,是正确选型、配置与排障的关键。

【免费下载链接】LMCacheLMCache: Supercharge Your LLM with the Fastest KV Cache Layer项目地址: https://gitcode.com/GitHub_Trending/lm/LMCache

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询