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 해야 함.
| 컨트롤러 | 어댑터에 요청하는 것 | 방향 |
|---|---|---|
StoreController | submit_store_task | L1 → L2 (쓰기) |
PrefetchController | submit_lookup_and_lock_task → submit_load_task | L2 → L1 (읽기) |
1줄 요약
L2 어댑터 = L2 스토리지에 데이터를 비동기로 넣고/찾고/꺼내는 기본 연산 3종 세트. 두 컨트롤러가 "언제/무엇을" 결정하면, 어댑터는 실제 I/O만 담당. 모든 작업은 submit → (eventfd 초인종 기다림) → query result 3단 패턴.
L1 — Contract
메서드 6쌍 (L2AdapterInterface, l2_adapters/base.py)
| 동작 | submit | 결과 조회 | 호출자 |
|---|---|---|---|
| Store | submit_store_task(keys, objects) → L2TaskId | pop_completed_store_tasks() → {id: bool} | StoreController |
| Lookup+Lock | submit_lookup_and_lock_task(keys) → L2TaskId | `query_lookup_and_lock_result(id) → Bitmap | None` |
| Load | submit_load_task(keys, objects) → L2TaskId | `query_load_result(id) → Bitmap | None` |
| Unlock | submit_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)
- Eventfds 전역 고유
L2TaskId는 per-adapter (전역 키는(adapter_index, task_id))- Query 는 one-shot (다시 호출하면 None)
submit_unlock은 어댑터 내부에서 반드시 결국 성공해야 함 (caller 가 retry 안 함)- Prefix-only loading: contiguous prefix 만 로드. 중간 gap 발생 시 그 뒤는 버림
StoreListenercallback 은 L1Manager lock 안에서 실행 → non-blocking + L1Manager 호출 금지 (deadlock)- Prefetch 의 L1 write buffer 는
is_temporary=True(eviction 허용) finish_write_and_reserve_read()로 write→read 락 전환은 원자적 (eviction window 없음)stop()은 항상 in-flight lock 정리- 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 | |
| NIXL | nixl_store_l2_adapter.py, nixl_store_dynamic_l2_adapter.py | 후자는 persist/recover 레퍼런스 |
| Plugin 브리지 | plugin_l2_adapter.py, native_plugin_l2_adapter.py | StoragePluginInterface → L2Adapter |
| Native 브리지 | native_connector_l2_adapter.py | C++/Rust IStorageConnector → L2Adapter (3 eventfd 분할 + demux 스레드) |
| 인프라 | base.py, factory.py, config.py, serde_wrapper.py | 공통 |
| 테스트 | mock_l2_adapter.py | 인메모리 레퍼런스 |
등록 메커니즘
l2_adapters/__init__.py가pkgutil.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 충족 여부가 다른 어댑터 작성 시 기준점으로 사용 가능한지.