본문으로 건너뛰기

L2 Adapters — Contract & 등록 흐름

변경 검증 가이드 (다음 fetch 후):

git log eaa2bfee..HEAD -- lmcache/v1/distributed/l2_adapters/base.py lmcache/v1/distributed/l2_adapters/factory.py lmcache/v1/distributed/l2_adapters/config.py lmcache/v1/distributed/l2_adapters/__init__.py
  • base.py 의 추상 메서드 시그니처가 변경되면 L1 contract 표 (메서드 11개 + 선택 4개) 와 AdapterUsage 표 다시 써야 함.
  • factory.py 의 lazy import / 두 레지스트리 분리 구조가 바뀌면 L2 등록 흐름 섹션 통째로 재작성.
  • config.py:173 의 serde wrapper 위치가 옮겨지면 "Serde 끼어드는 지점" 단락 무효.

원문: lmcache/v1/distributed/l2_adapters/base.py, factory.py, config.py


L1 — Contract

추상 메서드 목록 (L2AdapterInterface, base.py:78)

메서드필수 여부설명
get_store_event_fd()필수store 완료 신호용 fd
get_lookup_and_lock_event_fd()필수lookup 완료 신호용 fd
get_load_event_fd()필수load 완료 신호용 fd
submit_store_task(keys, objects)필수배치 store 제출 → L2TaskId
pop_completed_store_tasks()필수완료된 store 드레인 → dict[id, bool]
submit_lookup_and_lock_task(keys)필수lookup + lock 제출 → L2TaskId
query_lookup_and_lock_result(id)필수lookup 결과 one-shot 조회 → Bitmap|None
submit_unlock(keys)필수fire-and-forget unlock
submit_load_task(keys, objects)필수배치 load 제출 → L2TaskId
query_load_result(id)필수load 결과 one-shot 조회 → Bitmap|None
close()필수리소스 해제
pop_completed_store_task_bytes()선택실제 전송된 bytes 수 (기본 {})
delete(keys)선택eviction 시 삭제 (기본 no-op)
get_usage()선택사용량 스냅샷 반환 (기본 base class 구현)
report_status()선택헬스 체크 (기본 is_healthy: True)

base class 가 대신 처리해주는 것

새 어댑터를 만들 때 직접 구현하지 않아도 되는 것들:

  • byte 회계 (_usage_lock + _total_bytes_used + _bytes_by_cache_salt): _notify_keys_stored(keys, sizes) / _notify_keys_deleted(keys, sizes) 를 호출하면 base class 가 자동 집계
  • get_usage(): _notify_* 만 제대로 호출하면 base class 구현이 그대로 작동
  • Listener 관리: register_listener() + _notify_keys_accessed() 포함해서 base class 가 관리

→ 새 어댑터는 추상 메서드 11개만 구현하고, _notify_keys_stored / _notify_keys_deleted 를 올바른 시점에 호출하면 됨.

AdapterUsage (base.py:35)

AdapterUsage (frozen dataclass)
├─ total_bytes_used: int ← 전체 사용 bytes
├─ total_capacity_bytes: int ← 최대 용량 (0 = unknown)
├─ bytes_by_cache_salt: Mapping ← salt 별 사용 bytes (read-only)
└─ usage_fraction: float ← 사용률 [0, 1], 용량 미지 → -1.0
  • bytes_by_cache_saltMappingProxyType (불변 스냅샷) — 호출자가 수정 불가
  • usage_fraction == -1.0 → eviction 신호 없음 (레거시 sentinel 유지)
  • cache_salt = user/vLLM deployment/격리 단위 어떤 것이든 (어댑터는 무관심)

supports_global_eviction (base.py:457)

@property
def supports_global_eviction(self) -> bool:
return self._max_capacity_bytes > 0
  • max_capacity_bytes=0 으로 생성하면 전역 eviction 비활성화
  • per-cache_salt quota eviction 은 이 플래그와 무관하게 작동 가능

L2 — 등록 흐름과 생성 경로

두 레지스트리

_L2_ADAPTER_CONFIG_REGISTRY (config.py)
name → config class (예: "raw_block" → RawBlockConfig)

_L2_ADAPTER_FACTORY_REGISTRY (factory.py)
name → factory callable (예: "raw_block" → _create_raw_block_adapter)

두 레지스트리는 분리되어 있고 모두 self-register 패턴으로 채워짐:

  • *_l2_adapter.py 모듈이 import 되는 순간 모듈 하단의 register_l2_adapter_type(...) + register_l2_adapter_factory(...) 가 실행됨

Lazy import 흐름

__init__.py (pkgutil 스캔)
→ add_pending_module("lmcache.v1.distributed.l2_adapters.raw_block_l2_adapter")
→ (실제 import 는 하지 않음, _PENDING_MODULES 에만 추가)

-- 사용자가 --l2-adapter '{"type":"raw_block",...}' 지정 --

parse_args_to_l2_adapters_config()
→ type_name = "raw_block"
→ _ensure_config_loaded("raw_block")
→ ensure_adapter_loaded("raw_block") (factory.py)
→ _PENDING_MODULES 에서 하나씩 pop → importlib.import_module(mod_path)
→ raw_block_l2_adapter.py import 됨
→ register_l2_adapter_type("raw_block", RawBlockConfig)
→ register_l2_adapter_factory("raw_block", _create_raw_block)
→ 레지스트리에 등록 확인 → 완료
→ config_cls = _L2_ADAPTER_CONFIG_REGISTRY["raw_block"] → RawBlockConfig
→ adapter_cfg = RawBlockConfig.from_dict(d)
→ adapter_cfg.eviction_config = _parse_eviction_config(d) ← JSON "eviction" 키
→ adapter_cfg.persist_config = _parse_persist_config(d) ← JSON "persist_enabled" 키
→ adapter_cfg.serde_config = _parse_serde_config(d) ← JSON "serde" 키

-- 실제 어댑터 생성 --

create_l2_adapter_from_registry(config, l1_memory_desc)
→ name = get_type_name_for_config(config) ← 역방향 lookup (config 클래스 → 이름)
→ ensure_adapter_loaded(name) ← 이미 로드됐으면 no-op
→ factory = _L2_ADAPTER_FACTORY_REGISTRY[name]
→ return factory(config, l1_memory_desc) ← 어댑터 인스턴스 생성

Serde 끼어드는 지점 (config.py:173)

adapter_cfg.serde_config 가 None 이 아니면
StorageManager 가 어댑터를 SerdeL2AdapterWrapper 로 감쌈
컨트롤러 입장에서는 평범한 L2AdapterInterface 로 보임
serde (예: fp8 quantization) 가 store/load 전후에 투명하게 실행됨

즉 컨트롤러는 serde 를 모르고, 어댑터도 serde 를 모름. 중간에 wrapper 가 끼어드는 구조.

Config JSON 구조 예시

{
"type": "raw_block",
"path": "/dev/nvme0n1",

"eviction": {
"eviction_policy": "LRU",
"trigger_watermark": 0.8,
"eviction_ratio": 0.2
},

"persist_enabled": true,

"serde": {
"type": "fp8",
"fp8_dtype": "float8_e4m3fn"
}
}

eviction_policy 가능 값: "LRU", "IsolatedLRU", "noop"


L3 — FDP / HC-SSD 삽입 지점 예비 후보

  • _notify_keys_stored(keys, sizes) 호출 시점 직전: key 의 cache_salt 기반으로 FDP placement ID(RUH) 를 결정할 수 있음
  • serde_config + SerdeL2AdapterWrapper 의 store/load 훅: HC-SSD 측 compression offload 의 자연스러운 끼워넣기 지점

L4 — io_uring (해당 없음)

base.py / factory.py / config.py 수준에서는 io_uring 관련 없음. raw_block/core.py 에서 분석 (TODO 4).


Open Questions

  • get_type_name_for_config(config) 가 레지스트리를 선형 탐색 (type(config) is cls 비교) 함. 어댑터가 수십 개일 때 성능 영향은 무시 가능한 수준인지? (생성 시 1회라 무시 가능할 듯)
  • pop_completed_store_task_bytes() 가 선택 메서드인데, 구현 안 하면 L2 throughput 히스토그램이 "제출된 bytes" 기준으로 계산됨. raw_block 은 fast-path (중복 key skip) 가 있으므로 구현 필요 여부 확인 필요 (TODO 4에서 확인).