본문으로 건너뛰기

L2 Adapters — Overall

원문: docs/design/v1/distributed/l2_adapters/overall.md

계층 관계

StorageManager ← 외부 (serving engine) 가 부르는 진입점

├─ StoreController ← L1 쓰기 완료되면 → L2에 복제 요청
│ (컨트롤러 = 언제/무엇을 할지 결정하는 두뇌)
└─ PrefetchController ← 외부가 prefetch 요청하면 → L2에서 L1으로 로드 요청


L2AdapterInterface ← 실제 I/O 수행하는 손발 (raw_block, dax, nixl, s3 ...)

두 컨트롤러는 같은 어댑터 인스턴스를 공유하므로 어댑터는 thread-safe 해야 함.

컨트롤러어댑터에 요청하는 것방향
StoreControllersubmit_store_taskL1 → L2 (쓰기)
PrefetchControllersubmit_lookup_and_lock_tasksubmit_load_taskL2 → L1 (읽기)

1줄 요약

L2 어댑터 = L2 스토리지에 데이터를 비동기로 넣고/찾고/꺼내는 기본 연산 3종 세트. 두 컨트롤러가 "언제/무엇을" 결정하면, 어댑터는 실제 I/O만 담당. 모든 작업은 submit → (eventfd 초인종 기다림) → query result 3단 패턴.


L1 — Contract

메서드 6쌍 (L2AdapterInterface, l2_adapters/base.py)

동작submit결과 조회호출자
Storesubmit_store_task(keys, objects) → L2TaskIdpop_completed_store_tasks() → {id: bool}StoreController
Lookup+Locksubmit_lookup_and_lock_task(keys) → L2TaskId`query_lookup_and_lock_result(id) → BitmapNone`
Loadsubmit_load_task(keys, objects) → L2TaskId`query_load_result(id) → BitmapNone`
Unlocksubmit_unlock(keys) → None (fire-and-forget)PrefetchController

Event FD 3종

  • get_store_event_fd() / get_lookup_and_lock_event_fd() / get_load_event_fd()
  • invariant: 전역 고유. 모든 adapter × 모든 op 에 걸쳐 fd 가 중복되면 fd→adapter_index 매핑이 깨져 silent misroute.

핵심 invariant 10개 (문서 §Assumptions and Invariants Summary)

  1. Eventfds 전역 고유
  2. L2TaskIdper-adapter (전역 키는 (adapter_index, task_id))
  3. Query 는 one-shot (다시 호출하면 None)
  4. submit_unlock 은 어댑터 내부에서 반드시 결국 성공해야 함 (caller 가 retry 안 함)
  5. Prefix-only loading: contiguous prefix 만 로드. 중간 gap 발생 시 그 뒤는 버림
  6. StoreListener callback 은 L1Manager lock 안에서 실행 → non-blocking + L1Manager 호출 금지 (deadlock)
  7. Prefetch 의 L1 write buffer 는 is_temporary=True (eviction 허용)
  8. finish_write_and_reserve_read() 로 write→read 락 전환은 원자적 (eviction window 없음)
  9. stop() 은 항상 in-flight lock 정리
  10. Adapter 는 thread-safe 해야 함 (Store/Prefetch 두 스레드 동시 호출 전제)

에러 모델 차이

  • Store: coarse-grained (작업 전체 성공/실패 bool)
  • Lookup/Load: fine-grained (key 단위 Bitmap)

L2 — I/O 경로 (큰 그림)

Store 흐름 (L1 → L2)

L1.finish_write → StoreListener (eventfd signal)
→ _process_new_keys
→ group keys by (model_name, kv_rank) # 같은 shape/dtype 만 한 batch
→ StorePolicy.select_store_targets # adapter 별 분배
→ L1Manager.reserve_read (read lock)
→ adapter.submit_store_task # 비동기
── eventfd 시그널 ──
→ adapter.pop_completed_store_tasks
→ L1Manager.finish_read (lock 해제)
→ StorePolicy.select_l1_deletions (옵션)
  • 락 상태: 시작 unlocked → submit 후 read-locked → 완료 후 unlocked
  • 실패 시 retry 없음, warning 만 (best-effort)

Prefetch 흐름 (L2 → L1)

submit_prefetch_request(keys)
─ eventfd signal ─
→ _start_lookup_phase
→ submit_lookup_and_lock_task 를 모든 adapter 에
→ 전부 완료 대기
→ _transition_to_load_phase
→ PrefetchPolicy.select_load_plan # adapter↔key 비중복 할당 (Bitmap)
→ trim_load_plan_to_prefix # contiguous prefix 만 남김
→ L1Manager.reserve_write (is_temporary=True)
→ Phase 1 unlock: plan 에서 빠진 L2 key unlock
→ adapter.submit_load_task # 비동기
→ 전부 완료 대기
→ _finalize_load
→ Phase 2 unlock: plan 의 모든 L2 key unlock
→ L1Manager.finish_write_and_reserve_read # 원자적 write→read 전환
→ 실패 key 정리, prefix 뒤 read lock 해제
→ _complete_request(prefix_hits)
  • 최대 동시 처리: max_in_flight=8 (count-based, 향후 메모리 기반 admission control 예정)
  • 두 unlock 단계: lookup 직후 (안 쓸 key) + load 직후 (쓴 key)

StorageManager 진입점 (외부 API)

PrefetchHandle = sm.submit_prefetch_task(keys, layout_desc)
→ L1 prefix hit 먼저 체크 (전부 hit 면 request_id=-1)
→ L1 miss 부분만 PrefetchController 에 위임
found = sm.query_prefetch_status(handle)
→ total_hits = l1_prefix_hit_count + l2_prefix_hits
with sm.read_prefetched_results(keys[:found]) as objs: ...
sm.finish_read_prefetched(keys[:found])

어댑터 종류 분류

l2_adapters/ 디렉토리의 파일을 카테고리로 묶으면:

카테고리파일비고
로컬 파일/디스크fs_l2_adapter.py, fs_native_l2_adapter.py일반 FS
로컬 블록/메모리raw_block_l2_adapter.py, dax_l2_adapter.py★ 연구 타깃
원격 KV/오브젝트s3_l2_adapter.py, resp_l2_adapter.py (Redis), mooncake_store_l2_adapter.py
NIXLnixl_store_l2_adapter.py, nixl_store_dynamic_l2_adapter.py후자는 persist/recover 레퍼런스
Plugin 브리지plugin_l2_adapter.py, native_plugin_l2_adapter.pyStoragePluginInterface → L2Adapter
Native 브리지native_connector_l2_adapter.pyC++/Rust IStorageConnector → L2Adapter (3 eventfd 분할 + demux 스레드)
인프라base.py, factory.py, config.py, serde_wrapper.py공통
테스트mock_l2_adapter.py인메모리 레퍼런스

등록 메커니즘

  • l2_adapters/__init__.pypkgutil.iter_modules()*_l2_adapter.py 모두 자동 발견
  • 각 모듈은 자기 자신을 register_l2_adapter_type(name, ConfigCls) + register_l2_adapter_factory(name, factory_fn) 으로 self-register
  • lazy import: 실제로 그 type 이 요청될 때만 모듈 로드 (third-party deps 보호)

Policy 도 같은 패턴

  • StorePolicy / PrefetchPolicy: storage_controllers/ 안에 파일 추가 → register_*_policy(name, cls) self-register
  • CLI 플래그: --l2-store-policy, --l2-prefetch-policy (기본 default)

L3 — FDP / HC-SSD 삽입 지점 (이번 단계에서는 보류)

raw_block_l2_adapter.py + raw_block/core.py 까지 본 뒤 채움 (TODO 4). 지금 단계에서는 후보만:

  • submit_store_task 진입 직전 (key → placement 결정 가능 지점)
  • serde_wrapper.py 의 write/read hook (HC-SSD offload 후보)

L4 — io_uring 활용 방식 (이번 단계에서는 보류)

raw_block/core.py 본격 분석 시 채움 (TODO 4).


이해도 확인 Q&A

Q1. StoreController 와 PrefetchController 가 같은 어댑터 인스턴스를 공유해요. 근데 왜 eventfd 는 store용, lookup용, load용으로 3개 따로 써야 할까요? 하나로 합치면 안 되나요?

하나로 합치면 eventfd 가 울렸을 때 "store 완료인지, lookup 완료인지, load 완료인지" 구분이 안 돼요. 컨트롤러는 fd → adapter_index 맵으로 어떤 어댑터에서 온 이벤트인지 판단하는데, op 종류까지 fd 로 구분하지 않으면 잘못된 핸들러로 디스패치될 수 있어요. 3개를 따로 쓰는 이유는 "어떤 어댑터" + "어떤 작업"을 동시에 식별하기 위해서예요.


Q2. L2 에 key {0, 1, 2, 4, 5} 가 있어요. prefetch 요청이 key {0, 1, 2, 3, 4, 5} 로 들어왔을 때, 실제로 L1 에 로드되는 key 는 뭔가요? 그리고 왜요?

{0, 1, 2} 만 로드돼요.

key 3 이 L2 에 없어서 gap 이 생기고, trim_load_plan_to_prefix() 가 연속된 prefix 만 남기기 때문이에요. key 4, 5 는 L2 에 있어도 로드하지 않아요 — vLLM 은 KV 캐시를 반드시 연속된 prefix 로 사용해야 하기 때문에, 중간이 빠진 상태에서 4, 5 번을 로드해봤자 쓸 수 없고 I/O 와 L1 메모리만 낭비돼요.


Q3. submit_unlock 은 fire-and-forget 이에요. 컨트롤러가 unlock 성공 여부를 확인하지 않는데, unlock 이 실패하면 어떤 문제가 생기나요? 그래서 설계상 어떤 보장을 어댑터에 요구했나요?

L2 lock 의 목적은 "lookup 과 load 사이에 해당 key 가 evict 되는 것을 막는 것"이에요. load 가 끝난 뒤에도 unlock 이 실패하면 그 key 는 "곧 읽을 거야" 신호가 계속 켜진 채로 남아요. 결과적으로 그 key 는 영원히 evict 되지 못하고 L2 공간을 계속 점유하게 돼요 → 스토리지 누수.

그래서 설계상 어댑터에 이걸 요구해요:

submit_unlock 은 어댑터가 내부적으로 재시도해서 반드시 결국 성공시켜야 한다. 컨트롤러는 절대 재시도하지 않는다.


Open Questions

  • select_store_targets 가 "한 key 여러 adapter" 를 허용한다 (overall.md §StorePolicy). 반대로 prefetch 의 select_load_plan 은 "한 key 단일 adapter" 강제 (non-overlapping assignment). → 다중 adapter 환경에서 L2 간 정합성은 누가 보장? duplicate write 후 일관성 모델 정의가 어딘가 있는지 확인 필요.
  • (model_name, kv_rank) 그루핑은 store 만 언급 — prefetch 쪽도 동일 batch invariant 가 필요한지, 아니면 어댑터가 알아서 처리하는지.
  • mock_l2_adapter 는 in-memory 라고 했는데 thread-safety 와 invariant 충족 여부가 다른 어댑터 작성 시 기준점으로 사용 가능한지.