23 KiB
L2 Store and Prefetch Controller Design
This document describes how the StoreController and PrefetchController interact with L2 adapters, the invariants they maintain, and the assumptions they rely on. It is intended for developers implementing new L2 adapters or modifying the controller logic.
Architecture Overview
┌────────────────────────┐
│ StorageManager │
│ submit_prefetch_task │
│ query_prefetch_status │
│ reserve/finish_write │
└────┬──────────┬─────────┘
│ │
┌──────────┘ └──────────┐
▼ ▼
┌────────────────────┐ ┌────────────────────┐
│ StoreController │ │ PrefetchController │
│ (background thread)│ │ (background thread) │
│ │ │ │
│ L1 write done │ │ external submit │
│ → store to L2 │ │ → lookup L2 │
│ → release locks │ │ → plan + load │
└────┬───────────────┘ │ → read-lock L1 │
│ └──────┬──────────────┘
│ │
▼ ▼
┌─────────────────────────────────────────────┐
│ L2AdapterInterface(s) │
│ store / lookup_and_lock / load / unlock │
│ │
│ Each adapter has 3 distinct event fds: │
│ store_efd, lookup_efd, load_efd │
└─────────────────────────────────────────────┘
Both controllers run a single background thread each, using select.poll()
on eventfds for event-driven I/O. They share the same set of L2 adapter
instances (thread-safe by contract) but use different eventfds.
L2 Adapter Interface
L2AdapterInterface (l2_adapters/base.py) provides the non-blocking I/O
primitives that both controllers call. All operations follow a
submit → poll eventfd → query result pattern.
Event Fds
Each adapter exposes three distinct eventfds:
| Method | Used by | Signaled when |
|---|---|---|
get_store_event_fd() |
StoreController | A store task completes |
get_lookup_and_lock_event_fd() |
PrefetchController | A lookup task completes |
get_load_event_fd() |
PrefetchController | A load task completes |
Critical invariant: All event fds across all adapters must be globally
unique. The controllers build fd → adapter_index maps; duplicate fds would
silently misroute events.
Store Operations
submit_store_task(keys, objects) -> L2TaskId
pop_completed_store_tasks() -> dict[L2TaskId, L2StoreResult]
- Caller provides buffers: The
objectslist containsMemoryObjreferences managed by the caller (StoreController holds L1 read locks on them). - Coarse-grained errors: A store task either fully succeeds or fully fails.
The completion dict maps each task id to an
L2StoreResult(lmcache.v1.distributed.internal_api.L2StoreResult) that encodes both the success flag and the bytes actually transferred. Useresult.is_successful()to check the outcome andresult.bytes_transferred()to read the real byte count written to L2 (always0on failure). Adapters that fast-path duplicate keys (e.g. skip the write when the key already exists in the backend) should report the real, non-skipped byte count here so the L2 throughput histogram reflects actual work — not submitted-but-skipped bytes. - Pop semantics:
pop_completed_store_tasks()drains all completed tasks. Each task appears exactly once.
Lookup and Lock Operations
submit_lookup_and_lock_task(keys) -> L2TaskId
query_lookup_and_lock_result(task_id) -> Bitmap | None
submit_unlock(keys) -> None
- Locking:
lookup_and_lockatomically checks which keys exist and acquires L2-side locks on found keys. This prevents L2 eviction between lookup and load. - Fine-grained results: Returns a
Bitmapwhere bitiis set ifkeys[i]was found and locked. - One-shot query:
query_lookup_and_lock_resultreturnsNonewhile pending, then theBitmapexactly once. Subsequent calls returnNone. - Unlock contract:
submit_unlockis fire-and-forget. The adapter must guarantee eventual success (retry internally if needed). The caller will never retry.
Load Operations
submit_load_task(keys, objects) -> L2TaskId
query_load_result(task_id) -> Bitmap | None
- Caller provides buffers: The
objectslist contains pre-allocated L1 write buffers. The adapter writes loaded data directly into these buffers. - Fine-grained results: Returns a
Bitmapwhere bitiis set ifkeys[i]was successfully loaded. - One-shot query: Same semantics as lookup — returns the Bitmap exactly once.
Thread Safety
The adapter must be safe for concurrent calls from the StoreController thread and the PrefetchController thread. In practice, the store operations and lookup/load operations use separate internal state, so this is usually straightforward with per-operation locks or lock-free queues.
Task ID Scope
L2TaskId values are only unique within a single adapter. When tracking
tasks across multiple adapters, use the composite key (adapter_index, task_id).
StoreController
Purpose: Asynchronously replicate L1 data to L2 after writes complete.
Source: storage_controllers/store_controller.py
Lifecycle
StorageManager.__init__
→ StoreController(l1_manager, l2_adapters, descriptors, policy)
→ controller.start() # spawns background thread
...
StorageManager.close()
→ controller.stop() # joins thread, releases locks
Event-Driven Loop
The StoreController's background thread polls on:
- StoreListener eventfd — fired by L1Manager when
finish_write()completes. The listener is anL1ManagerListenerregistered with L1Manager. - Per-adapter store eventfds — fired when L2 store tasks complete.
Data Flow
L1 finish_write()
│
▼ (L1Manager listener callback, inside L1 lock — must be non-blocking)
StoreListener.on_l1_keys_write_finished(keys)
│ appends keys + signals eventfd
▼
_store_loop: poll wakes up
│
▼
_process_new_keys(keys)
│
├─ 1. Group keys by shape (today: (model_name, kv_rank); L1 is a shared
│ pool so one drain may span models/parallelism configs with different
│ KV shapes, and each submit_store_task must see uniform (shape, dtype)).
│
├─ 2. For each per-shape group:
│ StorePolicy.select_store_targets(group_keys, adapters)
│ → dict[adapter_index, list[ObjectKey]]
│
├─ 3. For each adapter target:
│ L1Manager.reserve_read(target_keys) → get MemoryObj + read lock
│ adapter.submit_store_task(keys, objs)
│ Track as InFlightStoreTask
│
▼ (later, for each adapter whose store_efd signaled)
_drain_l2_store_completions(signaled_adapters)
│ adapter.pop_completed_store_tasks() → deposit L2StoreResult
│ (success flag + bytes_transferred) on each InFlightStoreTask
│
▼
_advance_request(task_key, task) [state transition]
│ skip if l2_store_result still None
│
▼
_finalize_store(task_key, task) [terminal execution]
│
├─ 4. L1Manager.finish_read(read_locked_keys) → release read locks
│
├─ 5. If success: StorePolicy.select_l1_deletions(keys) → delete from L1
│ If failure: log warning (best-effort, no retry)
│
▼
Done. Keys remain in L1 unless the policy deletes them.
Lock Invariants
| Phase | L1 Lock State | L2 Lock State |
|---|---|---|
| Before store | Unlocked | N/A |
| During store | Read-locked | N/A |
| After store | Unlocked | N/A |
- Read locks during store prevent eviction from removing L1 data while the adapter is reading it.
- Always released:
stop()calls_cleanup_in_flight_tasks()which releases all in-flight read locks, even if tasks haven't completed.
StorePolicy
The policy decides two things:
-
select_store_targets(keys, adapters) → dict[int, list[ObjectKey]]Which adapters get which keys. A key can go to multiple adapters.DefaultStorePolicy: all keys → all adapters. -
select_l1_deletions(keys) → list[ObjectKey]Which keys to evict from L1 after successful L2 store.DefaultStorePolicy: never delete (empty list).
Policies are selected by name via --l2-store-policy (default: "default").
New policies self-register with register_store_policy(name, cls) at import
time and are auto-discovered by storage_controllers/__init__.py.
PrefetchController
Purpose: Asynchronously load KV cache data from L2 into L1 ahead of a
serving request. Called by StorageManager.submit_prefetch_task() for keys
not already in L1.
Source: storage_controllers/prefetch_controller.py
Lifecycle
StorageManager.__init__
→ PrefetchController(l1_manager, l2_adapters, descriptors, policy)
→ controller.start() # spawns background thread
...
StorageManager.close()
→ controller.stop() # joins thread, releases all locks
External API (Thread-Safe)
# Called from the serving thread
request_id = controller.submit_prefetch_request(keys, layout_desc)
# Polled by the serving thread
result = controller.query_prefetch_result(request_id) # int | None
submit_prefetch_requestenqueues the request and signals the background thread via an eventfd. Returns immediately.query_prefetch_resultreturnsNonewhile in-progress, then the prefix hit count exactly once (pop semantics).
Prefix-Only Loading
Key invariant: Only the contiguous prefix of found keys is loaded.
If L2 has keys {0, 1, 3, 4} but not key 2, only keys {0, 1} are loaded.
The gap at index 2 means the vLLM engine cannot use keys 3 and 4 (it needs a
contiguous prefix of computed KV cache). Loading them would waste I/O bandwidth
and L1 memory.
This is enforced by trim_load_plan_to_prefix() after the policy computes the
raw load plan.
Event-Driven Loop
The PrefetchController's background thread polls on:
- Submission eventfd — signaled by
submit_prefetch_request(). - Per-adapter lookup eventfds — signaled when lookup tasks complete.
- Per-adapter load eventfds — signaled when load tasks complete.
Request State Machine
Each request goes through two phases:
LOOKUP ──────────────────────────► PLAN_AND_LOAD ──────────► COMPLETED
│ │
│ submit lookup_and_lock │ compute load plan
│ to ALL adapters │ reserve L1 write buffers
│ │ submit load tasks
│ wait for all lookups │ wait for all loads
│ to complete │ finalize
▼ ▼
Data Flow
submit_prefetch_request(keys, layout_desc)
│
▼ (cross-thread: submission queue + eventfd signal)
_drain_submission_queue → _pending_queue
│
▼ (if below max_in_flight)
_start_lookup_phase(request_id, keys, layout_desc)
│
├─ Submit lookup_and_lock_task(keys) to EVERY adapter
│
▼ (wait for all adapter lookups to complete)
_advance_request(request, signaled_adapters) [LOOKUP branch]
│ _poll_lookup_results(request, signaled_adapters[LOOKUP])
│ when all_lookups_done():
│
▼
_transition_to_load_phase(request)
│
├─ 1. PrefetchPolicy.select_load_plan(keys, lookup_results, adapters)
│ → dict[adapter_index, Bitmap]
│
├─ 2. trim_load_plan_to_prefix()
│ → only keep contiguous prefix keys
│
├─ 3. L1Manager.reserve_write(keys, is_temporary=True, mode="new")
│ → allocate L1 write buffers
│
├─ 4. Re-trim plan to only successfully reserved keys
│
├─ 5. Phase 1 unlock: unlock L2 keys locked in lookup but NOT in load plan
│
├─ 6. Submit load_task(keys, objs) per adapter
│
▼ (wait for all adapter loads to complete)
_advance_request(request, signaled_adapters) [PLAN_AND_LOAD branch]
│ _poll_load_results(request, signaled_adapters[PLAN_AND_LOAD])
│ when all_loads_done():
│
▼
_finalize_load(request)
│
├─ 7. Phase 2 unlock: unlock all L2 keys in the load plan
│
├─ 8. L1Manager.finish_write_and_reserve_read(loaded_keys)
│ → atomically: write unlock + read lock
│
├─ 9. L1Manager.finish_write(failed_keys) + delete(failed_keys)
│ → clean up partial failures
│
├─ 10. Release read locks for loaded keys beyond the prefix
│ (partial load failures can create gaps)
│
▼
_complete_request(request_id, prefix_hits)
│ store result, remove from in-flight tracking
Lock Invariants
| Phase | L1 Lock State | L2 Lock State |
|---|---|---|
| Lookup | None | Found keys are locked |
| After plan | Write-locked (reserved buffers) | Plan keys locked; others unlocked (phase 1) |
| During load | Write-locked | Plan keys locked |
| After load | Read-locked (prefix keys) | All unlocked (phase 2) |
| After finalize | Read-locked (prefix only) | All unlocked |
- L1 write buffers are temporary:
is_temporary=Trueallows the eviction controller to reclaim them if needed, although normally they are short-lived. - Atomic write→read transition:
finish_write_and_reserve_read()ensures no eviction window between write completion and read lock acquisition. - Post-finalize read locks: The prefix keys remain read-locked in L1 so the
serving engine can consume them. The caller (
StorageManager) releases these viafinish_read_prefetched()after use.
L2 Lock Management
L2 locks prevent adapter-side eviction between lookup and load. They must be released in all cases — success, failure, and shutdown.
Phase 1 unlock (_unlock_unneeded_keys): After the load plan is computed,
keys that were locked during lookup but are NOT in the final load plan are
unlocked immediately. This happens when:
- The policy assigned a key to a different adapter.
- The key was trimmed by prefix trimming.
- L1 write reservation failed.
Phase 2 unlock (_unlock_all_plan_keys): After load completes (regardless
of success or failure), all keys in the load plan are unlocked.
Shutdown cleanup (_cleanup_in_flight_requests): Releases all held L1 and
L2 locks for any in-flight requests.
PrefetchPolicy
select_load_plan(keys, lookup_results, adapters) → dict[int, Bitmap]
Receives lookup bitmaps from all adapters and produces a non-overlapping assignment of keys to adapters. Each key appears in at most one adapter's bitmap.
DefaultPrefetchPolicy: For each key, assign it to the first (lowest-indexed)
adapter that has it. This is a simple greedy approach.
Policies are selected by name via --l2-prefetch-policy (default: "default").
New policies self-register with register_prefetch_policy(name, cls) at import
time and are auto-discovered by storage_controllers/__init__.py.
Max In-Flight Limiting
The controller limits concurrent prefetch requests to max_in_flight (default: 8).
Requests beyond this limit are queued in _pending_queue and dequeued as
in-flight requests complete.
Note: This is a simple count-based limit. A future improvement would use a dynamic admission controller based on L1 memory usage of in-flight requests.
Integration: StorageManager
StorageManager (storage_manager.py) is the top-level entry point that wires
everything together.
Prefetch Flow (from the serving engine's perspective)
# 1. Submit: check L1 first, then delegate remainder to L2
handle = sm.submit_prefetch_task(keys, layout_desc)
# 2. Poll: busy-wait for completion
while True:
found_count = sm.query_prefetch_status(handle)
if found_count is not None:
break
# 3. Read: access the prefetched data (holds read locks)
with sm.read_prefetched_results(keys[:found_count]) as objs:
# use objs ...
pass
# 4. Release: drop read locks
sm.finish_read_prefetched(keys[:found_count])
PrefetchHandle
@dataclass(frozen=True)
class PrefetchHandle:
request_id: int # -1 if no L2 request needed
l1_prefix_hit_count: int # leading keys already in L1
total_requested_keys: int
submit_time: float # for latency logging
submit_prefetch_task first checks L1 for a contiguous prefix of hits:
- If all keys hit L1: returns handle with
request_id=-1(no L2 work). - If some keys miss: submits the remaining keys to PrefetchController.
query_prefetch_status combines L1 hits with L2 results:
total_hits = l1_prefix_hit_count + l2_prefix_hits.
Assumptions and Invariants Summary
-
All eventfds are globally unique across all adapters and all operation types. Violating this corrupts the poll-based dispatch.
-
L2 task IDs are per-adapter, not global. Use
(adapter_index, task_id)as composite keys. -
Query results are one-shot. Both
query_lookup_and_lock_result()andquery_load_result()return a non-None value exactly once per task. -
submit_unlockmust eventually succeed. The controllers will never retry. The adapter must handle retries internally. -
Prefix-only loading. Only the contiguous prefix of found keys is loaded from L2. Gaps break the prefix.
-
Listener callbacks run inside L1Manager's lock.
StoreListenermust be non-blocking (append + eventfd signal only). It must never call L1Manager methods (deadlock). -
L1 write buffers for prefetch are temporary. Allocated with
is_temporary=Trueto allow eviction if needed. -
Atomic write→read transition.
finish_write_and_reserve_read()prevents eviction between completing a prefetch write and acquiring the read lock for the serving engine. -
Both controllers release all locks on shutdown.
stop()always cleans up in-flight tasks, regardless of completion state. -
L2 adapters are thread-safe. Concurrent calls from the StoreController thread and PrefetchController thread are expected.
Implementing a New L2 Adapter
Pure-Python Adapters
Implement L2AdapterInterface directly. See mock_l2_adapter.py for a
reference implementation for an in-memory adapter, or
raw_block.md for a durable local-device adapter.
No existing files need to be modified. Create a new module
(e.g., my_l2_adapter.py) in the l2_adapters/ package and self-register at
module level:
# At the bottom of your module:
register_l2_adapter_type("my_type", MyL2AdapterConfig)
register_l2_adapter_factory("my_type", _create_my_l2_adapter)
The __init__.py uses pkgutil.iter_modules() to discover all
*_l2_adapter.py modules automatically, but imports them lazily — a
module (and its third-party dependencies) is only loaded when the
corresponding adapter type is actually requested at runtime.
Persist / Recover (Optional)
Adapters that support persisting cached data across restarts use
PersistConfig (parsed from the JSON key "persist_enabled", defaulting
to True). Lookup always checks secondary storage on miss. There is
no dedicated interface method — adapters integrate persist into their
existing close() (to keep data on disk) path. See
nixl_store_dynamic_l2_adapter.py for a reference implementation and
nixl_store.md for design details.
Native (C++/Rust) Storage Backends
For high-performance backends written in C++ or Rust, use the shared native
connector framework. A single C++ connector implementation works in both
non-MP mode (via ConnectorClientBase) and MP mode (via
NativeConnectorL2Adapter).
Full guide: csrc/storage_backends/README.md
The NativeConnectorL2Adapter (native_connector_l2_adapter.py) bridges any
pybind-wrapped IStorageConnector to the L2AdapterInterface:
- Creates 3 Python eventfds from the connector's single eventfd
- Runs a background demux thread that routes completions by operation type
- Handles
ObjectKeyserialization andMemoryObjbuffer extraction - Implements client-side locking (refcount dict) for remote backends
Reference implementation: The Redis (RESP) connector in
csrc/storage_backends/redis/ demonstrates all 5 steps of the integration
guide.
Implementing a New Store or Prefetch Policy
Both store and prefetch policies use a name-based registry with automatic
module discovery. To add a new policy, create a single file in
storage_controllers/ — no changes to any existing file are needed.
Store Policy
- Create a new file (e.g.,
storage_controllers/store_policy_tiered.py). - Subclass
StorePolicyand implementselect_store_targets()andselect_l1_deletions(). - Call
register_store_policy("tiered", TieredStorePolicy)at module level.
from lmcache.v1.distributed.storage_controllers.store_policy import (
StorePolicy,
AdapterDescriptor,
register_store_policy,
)
from lmcache.v1.distributed.api import ObjectKey
class TieredStorePolicy(StorePolicy):
def select_store_targets(self, keys, adapters):
# custom logic ...
...
def select_l1_deletions(self, keys):
return []
register_store_policy("tiered", TieredStorePolicy)
The policy is now available via --l2-store-policy tiered.
Prefetch Policy
Same pattern: subclass PrefetchPolicy, implement select_load_plan(),
and call register_prefetch_policy("name", cls).
How Discovery Works
storage_controllers/__init__.py uses pkgutil.iter_modules() to import
every module in the package at import time. When your module is imported,
the register_*_policy() call at module level adds it to the registry.
The --l2-store-policy and --l2-prefetch-policy CLI arguments use the
registry to populate their choices list.