
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 缓存从 L1DRAM/VRAM卸载到二级存储掌握静态与动态两种模式各自的适用场景与取舍并能够独立编写、校验并部署这两种适配器的 JSON 配置。Nixl L2 适配器家族概览在 LMCache 的多级缓存架构中L2AdapterInterface定义于 base.py是所有二级存储适配器的统一抽象它面向控制器提供三类非阻塞原语Store将一批与 key 关联的内存对象写入二级存储Lookup and Lock按 key 查询对象并在加载前对命中的对象加锁pin防止其在加载期间被逐出Load按 key 将对象读回 L1 内存结果以 Bitmap 逐 key 表示成功或失败。Nixl L2 适配器家族正是该接口的一组实现它借助 Nixl 库通过DMA将 KV 缓存对象从 L1 卸载到二级存储。设计文档将其分为两个变体AdapterType nameStorage modePersistBackendsNixlStoreL2Adapternixl_storeStatic初始化时预分配文件不支持GDS、GDS_MT、POSIX、HF3FS、OBJ、AZURE_BLOBDynamicNixlStoreL2Adapternixl_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_type与register_l2_adapter_factory完成自注册因此可直接通过 JSON 配置中的type字段被工厂按名实例化。静态适配器NixlStoreL2Adapter核心组件NixlStoreL2Adapter源码见 nixl_store_l2_adapter.py由四个关键类协作完成NixlStoreObj单个缓存对象在 Nixl 存储中的元数据记录字段与设计文档一一对应page_indices持有该对象数据的预分配存储槽位索引列表size对象的字节大小layout可选的MemoryLayoutDescshape/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()返回槽位池的占用率。NixlStorageAgentNixl agent API 的轻量封装职责包括注册 L1 内存缓冲init_mem_handlers将 L1 的连续内存按align_bytes页粒度切分通过register_memory注册并调用prep_xfer_dlist预制备内存侧传输句柄注册存储槽位文件类后端GDS、GDS_MT、POSIX、HF3FS调用init_storage_handlers_file按pool_size个文件注册每个文件可容纳file_size // page_size个页对象类后端OBJ、AZURE_BLOB调用init_storage_handlers_object按页注册对象 key生成预制备传输句柄get_mem_to_storage_handleWRITE与get_storage_to_mem_handleREAD通过make_prepped_xfer把内存页索引与存储页索引组合成一次批量 DMA 传输驱动异步传输post_non_blocking提交传输并轮询check_xfer_state直至DONE期间以asyncio.sleep(0.01)让出事件循环出错时抛出RuntimeError。NixlStoreL2Adapter实现L2AdapterInterface的对外适配器本体它拥有一个运行在专用守护线程中的后台 asyncio 事件循环所有 DMA 协程都在其中执行三个 Linuxevent-fdstore / 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_forever时join永久阻塞。操作流程设计文档以伪代码形式给出了三条主链路源码实现与之一一对应Storesubmit_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.address与obj.meta.phy_size计算内存页索引再从池中分配等量存储槽位若池已空返回[]则中断本次批次。全部索引收集完成后通过一次批量 DMA WRITE 写入成功后把key → NixlStoreObj记入_memory_objects初始pin_count1随后递减并调用基类的_notify_keys_stored完成字节级用量记账任何异常都会走batched_free释放已分配槽位并将任务标记为失败。L2StoreResult同时编码成功标志与实际传输字节数。Lookup Locksubmit_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_count。submit_unlock按接口契约不返回 task id调用方假定解锁必然最终成功且永不重试。Loadsubmit_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 等监听器该通知不涉及字节记账。线程模型设计文档给出的线程分工在源码中得到完整印证ThreadRoleCaller thread(s)调用submit_*/query_*绝不直接触碰存储Event-loop thread执行所有 Nixl DMA 协程独占_memory_objects的变更Shared lock保护_memory_objects、任务结果字典与 task-id 计数器查找为同步call_soon_threadsafestore 与 load 为异步协程run_coroutine_threadsafe。report_status()返回is_healthy、stored_object_count、pinned_object_count、pool_size、pool_free_slots、event_loop_alive等状态字段其中健康度直接由事件循环线程是否存活决定。内存地址 → 页索引映射L1 内存以单一连续缓冲注册给 Nixl按align_bytes固定页大小切分。位于地址addr、大小为sz的内存对象映射到的页索引区间为[addr // align_bytes, addr // align_bytes 1, ..., addr // align_bytes sz // align_bytes - 1]addr与sz都必须为align_bytes的整数倍。源码get_memory_indices对非对齐输入直接抛出ValueError页数为sz // align_bytes。动态适配器DynamicNixlStoreL2Adapter源码位于 nixl_store_dynamic_l2_adapter.py。设计动机静态适配器在初始化时预分配全部存储文件并注册给 Nixl存在两个固有限制OS 文件描述符上限每个存储槽位都需要一个打开的 fd实际限制了池的规模无法持久化/恢复文件以随机 UUID 命名且内存索引_memory_objects在进程退出后丢失。动态适配器通过按操作打开/注册文件与由ObjectKey确定性派生文件名两条手段同时解决上述问题。与静态适配器的关键差异AspectStaticDynamicFile lifecycle初始化时全部打开关闭时统一关闭每次 store/load 打开传输完成后关闭File naming随机 UUIDobj_{i}_{uuid}.bin可读字段{model}_{rank}_{group}_{hash}[{cache_salt}].binNixl registration单次预制备全部存储的 dlist每次操作 register → transfer → deregisterPool / 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_store、dynamic_load、dynamic_delete、get_stored_size与cleanup。FileDynamicNixlStorageAgent文件后端实现见 file_dynamic_nixl_store_agent.py初始化时注册 L1 内存每次操作为单文件做注册dynamic_store(mem_indices, key)创建该 key 的数据文件 → 注册给 Nixl → DMA 写 → 注销 → 关闭 fddynamic_load(mem_indices, key)打开该 key 已有的数据文件 → 注册 → DMA 读 → 注销 → 关闭 fddynamic_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/loadCapacity维护_total_bytesstore 与二级查找命中时增加、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_exceptionsTrue)并发读取避免多 chunk 请求逐文件串行付出 Nixl 延迟单个 chunk 失败只会置空对应 Bitmap 位不影响其余成功 chunk。操作流程Storesubmit_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-fdLoadsubmit_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-fdLookup 与 unlock 与静态适配器完全一致内存索引查找 pin 计数管理但额外叠加了磁盘二级查找。持久化与二级查找Persist / Secondary Lookup配置PersistConfig定义于 l2_adapters/config.py仅含一个布尔字段FieldDefaultPurposepersist_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创建的文件存在歧义旧文件名没有记录 saltsalted 查找不会回退到该路径。因此升级一个此前使用 salted 动态 NIXL 流量的部署前应先清理或隔离旧缓存目录未加盐路径保持兼容。Secondary Lookup懒式磁盘恢复_execute_lookup_in_the_loop在内存索引未命中时总是附加一次磁盘二级查找由ObjectKey计算确定性文件路径os.stat(file_path)——文件存在即视为命中动态模式下实际经由FileDynamicNixlStorageAgent.get_stored_size返回文件大小以 stat 得到的size与layoutNone懒填充_memory_objects[key]更新_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 中的NixlStoreL2AdapterConfigbackend必填GDS、GDS_MT、POSIX、HF3FS文件类或OBJ、AZURE_BLOB对象类取值不合法直接抛ValueErrorbackend_params.file_path文件类后端必填存储文件所在目录初始化时自动os.makedirs(exist_okTrue)backend_params.use_direct_io文件类后端必填true时以O_DIRECT打开文件系统不支持时告警回退缓冲 I/Obackend_params.file_size可选每个存储文件槽位的字节大小默认取 L1 页大小l1_memory_desc.align_bytes且必须是页大小的整数倍否则抛ValueError每文件页数pages_per_file file_size // align_bytespool_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.pybackend必填仅支持文件类后端GDS、GDS_MT、POSIX、HF3FS源码注释明确 OBJ 后端暂未支持backend_params.file_path、use_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_saltget_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),仅供参考