본문으로 건너뛰기

살아남기 - MP 모드 vs Non-MP 모드

--- name: mp-vs-non-mp description: LMCache 의 MP 모드 / Non-MP 모드 정의와 차이점, FDP plugin 입장에서 어디만 쓰면 되는지 --- # MP 모드 vs Non-MP 모드 원문: - docs/source/mp/index.rst (사용자 관점 MP 소개) - docs/source/mp/architecture.rst (개발자 관점 MP 구조) - docs/source/developer_guide/extending_lmcache/storage_plugins.rst (Non-MP plugin) - docs/design/v1/distributed/l2_adapters/overall.md (MP L2 adapter) - docs/design/v1/distributed/l2_adapters/raw_block.md --- ## 1. 한 줄 정의 | 모드 | 한 줄 | |---|---| | **MP (Multi-Process)** | LMCache 가 **별도 프로세스(서버)** 로 떠 있고, vLLM 들이 ZMQ 로 접속해서 캐시 요청을 보내는 형태 | | **Non-MP** | LMCache 가 **vLLM 프로세스 안 라이브러리** 로 동작 (vLLM 이 import lmcache 해서 직접 호출) | > "MP" = "별도 프로세스로 떠 있는 캐시 서버 모드" > "Non-MP" = "vLLM 안에 라이브러리로 임베드된 모드 (=legacy)" --- ## 2. 왜 두 모드가 존재하는가 LMCache 는 처음에 vLLM 안 라이브러리 (Non-MP) 로 시작했다. 그러다 다음 문제가 드러나서 MP 모드가 도입됐다 (docs/source/mp/index.rst): - vLLM 인퍼런스 스레드와 LMCache 의 Python GIL/CPU 작업 (해싱, 메모리 관리, L2 I/O) 이 **같은 프로세스 안에서 GIL 경쟁** → 인퍼런스 latency 영향 - 노드 위 vLLM pod 가 여러 개일 때 **L1 캐시를 공유 못 함** (각자 자기 프로세스 안에 들고 있음) - LMCache 쪽 버그가 나면 **vLLM 프로세스가 같이 죽음** - CPU 메모리(캐시) 와 GPU 메모리(인퍼런스) **자원 스케일이 묶여 있음** → LMCache 를 별도 프로세스로 분리하고, vLLM 들이 ZMQ 로 붙는 구조 (= MP 모드) 가 됐다. **현재 권장 = MP 모드.** Non-MP 는 legacy 호환 + 단일 프로세스 시나리오용으로 남아 있다. --- ## 3. 프로세스 / 통신 구조 비교 ### Non-MP (legacy, vLLM 임베드)

┌────────────────────────── vLLM 프로세스 ──────────────────────────┐
│ │
│ vLLM 인퍼런스 코드 │
│ │ │
│ │ Python 함수 호출 (in-process) │
│ ▼ │
│ LMCacheEngine │
│ ├─ L1 (CPU memory) │
│ └─ StorageBackendInterface │
│ └─ StoragePluginInterface (Mooncake, S3, Rust raw_block …) │
└──────────────────────────────────────────────────────────────────┘

▼ (옵션) Local SSD / Remote KV store

- 통신: **함수 호출** (같은 Python 프로세스) - 진입 인터페이스: StorageBackendInterface / StoragePluginInterface - 비동기 모델: **asyncio event loop** (vLLM 이 깔아 둠 → plugin 은 run_coroutine_threadsafe) ### MP (현재 권장, 별도 서버)

┌── vLLM Pod 1 ──┐ ┌── vLLM Pod 2 ──┐ ┌── vLLM Pod N ──┐
│ vLLM client │ │ vLLM client │ │ vLLM client │
└────────┬───────┘ └────────┬───────┘ └────────┬───────┘
│ │ │
└─── ZMQ (DEALER/ROUTER, tcp) ──────────┘


┌────────────────────────────────────────────┐
│ lmcache server (별도 프로세스) │
│ │
│ MessageQueueServer (mq.py) │
│ │ │
│ ▼ │
│ MPCacheEngine (server.py) │
│ │ │
│ ▼ │
│ StorageManager (distributed/) │
│ ├─ L1Manager (CPU memory + TTL lock) │
│ ├─ StoreController ─┐ │
│ ├─ PrefetchController ─┤ │
│ └─ EvictionController ─┘ │
│ │ │
│ ▼ │
│ L2AdapterInterface (raw_block, dax, │
│ nixl, s3, plugin, native_plugin …) │
└────────────────────────────────────────────┘

▼ Local SSD / Remote KV store

- 통신: **ZMQ DEALER/ROUTER** (tcp). RequestType enum 으로 명령 dispatch. - 진입 인터페이스: L2AdapterInterface (architecture.rst:29-37) - 비동기 모델: **eventfd 3개 + select.poll** (controller 가 직접 관리) - 서버 진입점 3종: lmcache server (권장, ZMQ + FastAPI), python -m lmcache.v1.multiprocess.server (legacy ZMQ-only), blend_server_v2 (CacheBlend) --- ## 4. 어떤 인터페이스를 쓰는가 (★ 우리가 짤 백엔드 입장) | | Non-MP | MP | |---|---|---| | 추상 클래스 | StoragePluginInterface (← StorageBackendInterface) | L2AdapterInterface | | 위치 | lmcache/v1/storage_backend/abstract_backend.py:424 | lmcache/v1/distributed/l2_adapters/base.py | | 메서드 형식 | async coroutine 위주 (batched_async_contains, batched_get_blocking 등) + prefix-only get/contains | submit_* (non-blocking) + pop_* / query_* (one-shot) + 3개 eventfd | | 비동기 트리거 | asyncio loop (vLLM 이 제공) | eventfd → controller select.poll 깨움 | | 등록 방법 | extra_config.storage_plugin.<name>.{module_path,class_name} (yaml/dict) | --l2-adapter JSON (type, module_path, class_name, adapter_params) + self-register register_l2_adapter_* | | 동시성 | asyncio task | controller 2개 (Store/Prefetch) 가 같은 인스턴스에 동시 호출 → **plugin 이 thread-safe 해야 함** | | 에러 모델 | per-key 결과 | Store 는 coarse (전체 success/fail bool), Lookup/Load 는 fine (Bitmap) | | 락 책임 | 없음 (asyncio 단일 스레드) | **L2-side lock refcount 직접 구현 필요** (lookup-and-lock → load → unlock) | | 데이터 분포 | prefix-only contains/get | bitmap-based 임의 패턴 | **같은 디바이스/스토리지를 두 모드 다 지원하려는 표준 패턴**: 공유 core 를 따로 두고, 두 wrapper 를 그 위에 얹는다. raw_block 가 정확히 이 패턴 (raw_block.md:46-64):

RawBlockCore
(durable I/O, slot 할당, lock refcount, checkpoint)
▲ ▲
│ │
┌──────────────┘ └──────────────┐
│ │
RustRawBlockBackend RawBlockL2Adapter
(StoragePluginInterface) (L2AdapterInterface)
= Non-MP wrapper = MP wrapper
prefix-only get/contains non-blocking + eventfd
asyncio.run_coroutine_threadsafe ThreadPoolExecutor 3개

**우리(FDP plugin) 입장에서 결론**: - **MP 만 짠다** (L2AdapterInterface + plugin 또는 native_plugin 타입). Non-MP 는 legacy 라 깊이 들어갈 필요 없음. - 그래도 **공유 core 패턴은 베껴서** 쓸 가치가 있다 — 나중에 Non-MP 호환을 요구받으면 wrapper 만 추가하면 됨. --- ## 5. 핵심 차이 요약 표 | 항목 | Non-MP (legacy) | MP (권장) | |---|---|---| | 프로세스 | vLLM 안 임베드 | 별도 서버 (lmcache server) | | 통신 | in-process 함수 호출 | ZMQ tcp (DEALER/ROUTER) | | L1 캐시 공유 | vLLM pod 별로 따로 | 노드 안 모든 pod 가 공유 | | GIL 경쟁 | 있음 (vLLM 과 같은 GIL) | 없음 (다른 프로세스) | | 추상 클래스 | StoragePluginInterface | L2AdapterInterface | | 비동기 모델 | asyncio | eventfd + poll | | per_tp_device_paths | 지원 (TP rank → device 매핑) | **거부** (raw_block_l2_adapter.py:147-150) | | 서버 entry point | (없음 — vLLM 안에서 import) | lmcache server / python -m lmcache.v1.multiprocess.server / blend_server_v2 | | 우리 백엔드 들어갈 자리 | (선택) RustRawBlockBackend 같은 wrapper | (필수) L2AdapterInterface 구현 — type: "plugin" | | 향후 권장 | 유지보수 모드 | 활성 개발 | --- ## 6. FDP plugin 짤 때 의미하는 것 - **목표 모드 = MP only.** Non-MP 호환은 후순위. 우선 L2AdapterInterface 만 구현. - 우리가 신경 써야 할 것: - eventfd 3개 (store/lookup/load) — lmcache.v1.platform.create_event_notifier() 로 생성 - submit_* 은 non-blocking, 결과는 pop_* / query_* 로 따로 회수 - thread-safe (StoreController + PrefetchController 동시 호출) - L2-side lock refcount (lookup_and_lockloadunlock) - 필요 없는 것: - asyncio event loop (단, plugin 이 내부에서 비동기 처리하고 싶으면 직접 만들어 써도 됨 — framework 가 안 깔아 줌, plugin_pipeline.md 참고) - vLLM 호환 인터페이스 (Non-MP 의 prefix-only get/contains 등) - **TP > 1 시나리오**: MP 는 현재 per_tp_device_paths 거부 → TP 별 디바이스 분산 필요하면 MP 쪽 별도 PR 이 선행돼야 함. Phase 0 에서 우리는 단일 디바이스로 시작. --- ## 참고 - 자세한 MP 내부 구조 (ZMQ RequestType enum, MPCacheEngine, StorageManager 책임 분리): docs/source/mp/architecture.rst - L2 adapter 구체 contract: [[l2_adapters_contract]] - Non-MP plugin 구체 contract: docs/source/developer_guide/extending_lmcache/storage_plugins.rst - 공유 core 패턴 사례 (raw_block): [[raw_block_line]]

# Raw Block 라인 종단 분석 (TODO 4) **분석 대상**: - docs/design/v1/distributed/l2_adapters/raw_block.md - lmcache/v1/distributed/l2_adapters/raw_block_l2_adapter.py (770 LOC) - lmcache/v1/storage_backend/plugins/rust_raw_block_backend.py (585 LOC, 비교용) - lmcache/v1/storage_backend/raw_block/core.py (1476 LOC) - lmcache/v1/storage_backend/raw_block/key_codec.py (168 LOC) - rust/raw_block/src/lib.rs (2030 LOC) **관련**: - 사전 메모: [raw-block-perf-findings.md](./raw-block-perf-findings.md) (성능 관점) - 인터페이스 계약: [l2_adapters_contract.md](./l2_adapters_contract.md) - plugin 구조: [plugin_pipeline.md](./plugin_pipeline.md) --- ## L1 — Contract / 종단 인터페이스 ### 계층 구조

mermaid
flowchart TD
subgraph MP["MP 모드 (현재 권장 경로)"]
SC["StoreController<br/>PrefetchController"]
Adapter["RawBlockL2Adapter<br/>raw_block_l2_adapter.py:281<br/>L2AdapterInterface 구현"]
end

subgraph Legacy["Legacy 비-MP 경로 (레거시 facade)"]
SM["StorageManager"]
Plugin["RustRawBlockBackend<br/>rust_raw_block_backend.py:74<br/>StoragePluginInterface 구현"]
end

Core["RawBlockCore<br/>core.py:152<br/>슬롯 할당 / 인덱스 / 체크포인트 / lock refcount"]

Rust["RawBlockDevice (Rust PyO3)<br/>rust/raw_block/src/lib.rs:355<br/>POSIX or io_uring 엔진"]

Dev[("raw block device / file<br/>/dev/nvme*n* or pre-sized file")]

SC --> Adapter
SM --> Plugin
Adapter --> Core
Plugin --> Core
Core --> Rust
Rust --> Dev

### 책임 분리 | 레이어 | 책임 | 안 하는 일 | |---|---|---| | RawBlockL2Adapter | submit/pop/query 비동기 계약, eventfd 3개 (store/lookup/load), ThreadPoolExecutor 3개 (size 2/1/4), task_id 발급, listener 알림 | 슬롯 할당, 인덱스, 디바이스 I/O | | RustRawBlockBackend (legacy) | 비-MP StoragePluginInterface, prefix-only contains/get, asyncio.run_coroutine_threadsafe 로 Core 호출, pin/unpin | (MP 와 같은 eventfd 비동기 계약 없음 — asyncio loop 외부에서 받음) | | RawBlockCore | 디바이스 open/close, in-memory 키 인덱스 (_index), free slot list, _inflight 추적, lock refcount, **메타데이터 체크포인트 + 복구**, slot header 검증 | 비동기 계약, 워커 스레드 풀 (Adapter 가 함) | | RawBlockDevice (Rust) | POSIX pread/pwrite 또는 io_uring 엔진, register_fixed_buffers, AlignedBuf (O_DIRECT bounce), 단일 디바이스 fd + 단일 ring + 단일 워커 스레드 | 슬롯 / 인덱스 / 체크포인트 / 키 | ### MP adapter 진입 / 탈출 invariant (raw_block.md + 코드 검증) | 계약 | 위치 | 비고 | |---|---|---| | eventfd 3개 분리 (store / lookup / load) | adapter:331-333 | create_event_notifier() 로 생성 | | submit 은 non-blocking | adapter:407-415 등 | ThreadPoolExecutor.submit 후 즉시 task_id 반환 | | 결과 회수: pop_completed_*, query_*_result | adapter:417-422, 452-455, 499-502 | 한 번 꺼내면 dict 에서 제거 | | L2 lock = exists_many(.., lock=True) | core.py:523-547 | hit 마다 _lock_refcnt 증가, unlock_many 가 감소 | | delete(force=False) 는 locked 슬롯 보존 | core.py:670-692 | MP 의 lookup-and-lock → load → unlock 사이 안전 | | caller 가 load 목적지 버퍼 제공 | adapter:462-497 | adapter 는 destination 할당 안 함 | | close() 순서: pool shutdown → core.close → eventfd close | adapter:535-561 | 진행 중 task 모두 끝낸 뒤 디바이스 닫음 | ### 미충족 / 제약 - per_tp_device_paths **MP 모드에서 거부됨** (adapter:147-150). non-MP RustRawBlockBackend 만 TP rank 별 디바이스 매핑 사용 (rust_raw_block_backend.py:108-130). - use_odirect=True 면 L1 alignment 가 block_align 이상 필수 (adapter:306-313). - slot_bytes, header_bytes, meta_total_bytes 모두 block_align 배수. - slot_bytes >= header_bytes + 1. --- ## L2 — I/O 경로 (put / get / lookup / evict) ### 디바이스 레이아웃

0 device_size
├──────── meta_total_bytes (256 MiB 기본) ────────┤
│ meta container 0 │ meta container 1 │ data slots... │
│ (mirror copy) │ (mirror copy) │ │
│ header(4KB) + json │ header(4KB) + json │ slot 0 slot 1 │
└─────────────────────┴─────────────────────┴─────────────────┘
↑ ↑
meta_copy_count=2 data_base_offset
= meta_total_bytes

- 슬롯 = 고정 크기 slot_bytes (기본 1 MiB), 슬롯 머리 header_bytes (기본 4 KiB) 에 LMCBLK01 magic + 64bit slot_identity + payload_len 기록 (core.py:940-950) - 메타데이터: 같은 디바이스 앞쪽에 mirror copy 2개, JSON 직렬화 + zlib CRC32, _meta_seq 증가하며 round-robin (core.py:1161-1198) ### put 흐름 (submit_store_task)

mermaid
sequenceDiagram
participant SC as StoreController
participant A as RawBlockL2Adapter
participant P as ThreadPool (rawblk-store)
participant C as RawBlockCore
participant R as RawBlockDevice (Rust)
participant D as 디바이스

SC->>A: submit_store_task(keys, objs)
A->>A: task_id 발급 + inflight++
A->>P: pool.submit(_run_store_task)
A-->>SC: task_id (즉시 return)

P->>C: put_many(specs, objs)
loop 각 (key, obj)
C->>C: _lock 획득
C->>C: 이미 indexed/inflight 면 skip
C->>C: _allocate_slot_locked() → offset
C->>C: _inflight[encoded] = (offset, meta)
C->>C: _lock 해제
C->>R: pwrite_from_buffer(offset, header)
R->>D: pwrite (POSIX) or SQE Write/WriteFixed (io_uring)
C->>R: pwrite_from_buffer(offset+header_bytes, payload)
R->>D: pwrite ...
C->>C: _lock 획득
C->>C: _inflight pop → _index 등록
C->>C: _meta_dirty_total++
end
C-->>P: RawBlockPutManyResult
P->>A: _finish_store_task (callback)
A->>A: _completed_store_tasks[task_id] = success
A->>A: _notify_keys_stored (listener)
A->>A: store_efd.notify()
SC-->>SC: poll(store_efd) 깨어남
SC->>A: pop_completed_store_tasks()

**핵심 포인트**: - header / payload **별도 pwrite 2회** — header 가 먼저 가야 slot identity 가 디스크에 박힘 - O_DIRECT 면 header 도 block_align 까지 round-up (core.py:919-924) - payload buffer 가 block_align aligned 이고 enable_zero_copy=True_build_direct_odirect_view 가 ctypes 로 raw memoryview 만들어 zero-copy (core.py:801-854) - _inflight_io_count++ / last_io_ts 갱신 → 체크포인트 idle quiet 판정 입력 ### get / load 흐름 (submit_load_task) 호출자가 destination buffer 를 미리 할당해서 넘긴다. Adapter 는 절대 새 메모리 할당 안 함.

core.load_many_into(encoded_keys, objs):
with lock: items = [(k, _index.get(k)) for k in encoded_keys]; inflight_io_count++
for (k, entry) in items:
if entry is None: continue (miss)
payload_len = entry.size
total_len = round_up(payload_len, block_align) if O_DIRECT else payload_len
direct_view = _build_direct_odirect_view(...)
raw_dev.pread_into(entry.offset + header_bytes, buf, payload_len, total_len)
objs[i].metadata.cached_positions = entry.meta.cached_positions
inflight_io_count--

→ 결과 bitmap 으로 변환 후 load_efd.notify(). ### lookup-and-lock 흐름

core.exists_many(keys, lock=True):
with lock:
for k in keys:
found = k in _index
if found and lock: _lock_refcnt[k] += 1

순수 in-memory 조회. 디바이스 I/O 없음. lock=True 가 핵심 — load 가 끝날 때까지 evict 못 하게 보호. ### evict / delete 흐름 (adapter.delete)

core.delete_many(keys, force=False):
with lock:
for k in keys:
if locked and not force: 보존, return False
removed = _index.pop(k)
_inflight[k].canceled = True (있으면 — race 방지)
_free_slots.append(slot)
_meta_dirty_total++

- 슬롯 자체에 즉시 쓰기 X — free list 에 회수만 됨, 다음 put 이 덮어씀 - _inflight.canceled = True 표시 → put 워커가 끝날 때 free list 로 되돌림 (core.py:501-507) - force=False 가 기본: lookup-and-lock 중인 슬롯 안전 ### 체크포인트 흐름

_checkpoint_loop (백그라운드 daemon thread):
every meta_checkpoint_interval_sec:
_checkpoint_once(force=False):
if not dirty: skip
if inflight_io_count > 0 or now - last_io_ts < meta_idle_quiet_ms: skip
_snapshot_state() → with _lock 잡고 dict 통째로 JSON 직렬화 (대형 인덱스 문제!)
_write_checkpoint(): pwrite payload → pwrite header → meta_seq++

meta_idle_quiet_ms=100ms 동안 I/O 없을 때만 체크포인트. 즉 sustained write 중에는 체크포인트가 안 일어남 → 크래시 시 복구 윈도 길어짐. → 부팅 시 _load_checkpoint_from_device: mirror 2 개 중 seq 큰 쪽 선택, CRC32 검증, apply_loaded_state_index 재구성. → meta_verify_on_load=True 면 슬롯 헤더의 slot_identity 까지 디바이스에서 읽어 일치 검증 (core.py:1407-1450). --- ## L3 — FDP / HC-SSD 삽입 후보 지점 설계 문서가 명시적으로 TODO 로 남긴 항목 (raw_block.md:42-44): - *FDP / placement-hint support* - *A raw NVMe command path* ### 후보 지점 표 | # | 위치 | 후크 가능 데이터 | 추가할 것 | 영향 범위 | 우선순위 | |---|---|---|---|---|---| | H1 | RawBlockCore._write_one (core.py:898-938) → pwrite_from_buffer 콜 | slot offset, key spec, header content, payload size | placement_id 인자 추가 — Rust 까지 흘려보냄 | **호스트 → 디바이스 entry point**. 단일 지점에서 모든 write 가 통과 | ★★★ | | H2 | RawBlockDevice.pwrite_from_buffer / batched_write (Rust lib.rs:1078, 1710) | fd, offset, buf, len | NVMe IORING_OP_URING_CMD (passthru) 또는 RWF_* write hint 플래그 | Rust ↔ kernel 경계. io_uring FDP 패치 (kernel 6.8+) 의존 | ★★★ | | H3 | RawBlockCore.put_many (core.py:434-521) — slot 할당 직후 | encoded_key, slot offset, MemoryObj metadata | cache_salt / model_name / cached_positions 기반 PLID 분류 정책 | **정책이 들어가는 곳**. hot/cold 결정 | ★★ | | H4 | _allocate_slot_locked (core.py:1004-1013) | next_slot, _free_slots | PLID 별 free list 분리 — RU 경계와 슬롯 그룹 정렬 | 슬롯 → RU 매핑 안정성 (회수 시 같은 RU 로 가도록) | ★★ | | H5 | metadata checkpoint write (_write_checkpoint, core.py:1161-1198) | 매우 긴 수명 데이터 (수십 분 ~ 영구) | 별도 PLID (가장 긴 lifetime PLID) 로 고정 | WAF 안정. 메타 영역 GC 가 데이터 슬롯 GC 와 섞이지 않음 | ★★★ | | H6 | delete_many (core.py:653-692) → free slot 회수 | encoded_key, offset, slot 인덱스 | IORING_OP_URING_CMD 으로 dataset management (DSM) deallocate 또는 FDP RU reset hint | 디바이스에 회수 알림. WAF 추가 절감 | ★ | | H7 | RustRawBlockBackend._build_core_config / RawBlockL2AdapterConfig | 사용자 JSON config | fdp_plid_* 필드, placement_strategy enum | config surface | ★ | | H8 | register_fixed_buffers (Rust lib.rs:1017) | 호스트 메모리 풀 시작 주소 | 등록된 버퍼당 PLID 메타데이터 — WriteFixed 시 매핑된 PLID 자동 적용 | 핫패스 zero-copy 와 PLID 자동 부여 동시 | ★ (P2) | ### HC-SSD (대용량) 관점에서 별도로 봐야 하는 곳 | # | 문제 | 위치 | 메모 | |---|---|---|---| | C1 | 키 인덱스 dict[str, _Entry] 가 in-memory 전체 보관 | core.py:241 | 수십 TB / 수백만 슬롯 시 RAM 압박. 인덱스 오프로드 필요 | | C2 | _snapshot_state_lock 잡고 전체 dict 순회 + JSON 직렬화 | core.py:1102-1145 | 인덱스 크면 lock hold 시간 폭발. 동시 I/O latency 스파이크 | | C3 | _free_slots: list[int] 선형 — slot in self._free_slots 멤버십 체크 O(n) (core.py:1019) | core.py:246, 1015-1021 | 대용량에서 free list 길어지면 delete 핫패스 저하 | | C4 | meta_total_bytes 기본 256 MiB 고정 | adapter.py:77 | 대용량에서 부족 가능. 용량 비례 자동 산정 필요 | | C5 | meta_idle_quiet_ms=100 — sustained write 중 체크포인트 안 됨 | core.py:1200-1211 | crash window 길어짐. 인덱스 fsync 별도 경로 고려 | | C6 | 단일 ring + 단일 worker (Rust 단계) | lib.rs:439, 479 | TB급 throughput 한계. 멀티 ring / NUMA 친화 스케줄 필요 | ### 정책 설계 시 데이터 분류 후보 | 데이터 | 수명 | 추정 PLID 그룹 | 근거 | |---|---|---|---| | 메타데이터 체크포인트 | 매우 길다 (영구) | 별도 PLID 0 | mirror copy 2개, 매 60s, 거의 안 지워짐 | | cache_salt 가 같은 KV 슬롯 묶음 | 같은 요청 cluster | 같은 PLID | 함께 쓰이고 함께 만료될 가능성 | | 자주 hit 되는 핫 슬롯 | 길다 | hot PLID | listener 의 on_l2_keys_accessed 빈도 기반 (adapter:728) | | 한 번 쓰고 곧 evict 되는 슬롯 | 짧다 | cold PLID | LRU score 기반 | cache_salt 별 분류는 이미 adapter._bytes_by_cache_salt 회계에 사용 중이라 (adapter:597-605) 정책 입력으로 자연스럽게 끌어 쓸 수 있음. --- ## L4 — io_uring 사용 분석 > 출처: Explore agent 분석 (rust/raw_block/src/lib.rs 12점 점검). ### 엔진 / ring 구조 | 항목 | 값 | 위치 | |---|---|---| | 엔진 선택 | io_engine 문자열 ("posix" / "io_uring") + legacy use_iouring bool | lib.rs:50-61 | | ring 인스턴스 | **1 디바이스 = 1 ring**, Arc<Mutex> | lib.rs:439, 441 | | 워커 스레드 | **1 디바이스 = 1 dedicated worker thread** | lib.rs:479 | | queue depth | iouring_queue_depth (기본 256), IoUring::new(qd) 그대로 | lib.rs:48, 439, 976 | | setup flags | **없음** (default) | lib.rs:439 | → setup_iopoll, setup_sqpoll, setup_single_issuer, setup_coop_taskrun, setup_defer_taskrun 전부 **활성화 안 됨**. kernel default 로 동작. ### Op / 제출 패턴 | 항목 | 값 | |---|---| | 사용 op | Read, Write, ReadFixed, WriteFixed (4종) | | Vectored I/O | **없음** (Readv/Writev 미사용) | | Fsync | **없음** | | 배치 | 워커가 큐에서 사용 가능한 만큼 SQE 일괄 제출 후 submit() (submit_and_wait 미사용) | | 완료 대기 | Condvar + 10µs timeout 폴링 (busy-wait 아님, 협력적 양보) | | 매칭 | submission 마다 user_data 부여 → CQE user_data() 로 inflight HashMap 조회 | | 짧은 I/O 처리 | bytes_transferred < len 이면 offset/len 조정해서 **재제출** (lib.rs:505-585) | ### Registered buffers / fixed file | 항목 | 값 | |---|---| | IORING_REGISTER_BUFFERS | **호출됨** (register_fixed_buffers Python 메서드, lib.rs:1017-1069) | | IORING_REGISTER_FILES | **미호출** | | WriteFixed / ReadFixed 사용 | 등록된 buffer pointer 일치 시 자동 사용 (lib.rs:544-550) | | 등록 lifecycle | close 시 unregister_buffers() (lib.rs:1990) | | 활용도 | **opt-in** — Python 측에서 register_fixed_buffers 명시 호출 안 하면 일반 Read/Write 만 사용 | → **현재 LMCache 에서 register_fixed_buffers 가 실제로 호출되는지** 별도 확인 필요. RawBlockCore._rawdev() 에서는 호출 안 함 (core.py:280-299). 즉 **fixed buffer 인프라는 있지만 사용 안 됨** → P1 개선 후보. ### O_DIRECT / alignment - AlignedBuf (lib.rs:170-222) — posix_memalign 으로 정렬 버퍼 할당 - 동기 경로 (pwrite_from_buffer, pread_into) 는 비정렬 시 bounce buffer fallback - **batched 경로 (batched_write / batched_read) 는 bounce buffer 안 씀** — 정렬 안 맞으면 즉시 ValueError (lib.rs:1631) → batched path 쓰려면 호스트에서 정렬 보장 필수 ### NVMe passthru / ioctl - 현재 ** BLKGETSIZE64 ioctl 만** 사용 (디바이스 크기 조회, lib.rs:153) - IORING_OP_URING_CMD 등 NVMe passthru 경로 **없음** ← 우리가 FDP 추가할 핵심 자리 ### LMCache 측 io_uring 활용 현황 요약 | 측정 항목 | 답 | |---|---| | 어디서 ring 만드는가 | RawBlockDevice.__init__ (Rust) — 디바이스 1개당 1개 | | queue depth 결정 | adapter config iouring_queue_depth (기본 256) → 그대로 ring entries | | 배치 정책 | 워커가 in-flight 큐 drain 후 사용 가능 SQE 만큼 일괄 submit | | op 종류 | Read / Write / ReadFixed / WriteFixed | | registered buffer | 코드 존재, **현재 LMCache 코드 경로에서는 미사용** | | per-TP device sharding | non-MP RustRawBlockBackend 만 지원 (TP rank → device path), MP 는 거부 | --- ## 다음 단계 후보 (코드 수정 시작 시점에 결정) 1. **(검증)** register_fixed_buffers 실제 호출 여부 grep + 호출되지 않는다면 그 이유 (lifetime 문제? L1 메모리 풀과의 alignment 불일치?). → lmcache/v1/memory_management.py 확인 필요 (TODO §11-3, §11-4 항목과 합쳐서) 2. **(설계)** plugin skeleton 작성: RawBlockL2Adapter 의 ThreadPoolExecutor 3개 + eventfd 3개 패턴을 그대로 베껴 FDP plugin 베이스로 사용 3. **(설계)** H1 (core _write_one 에 placement_id) + H2 (Rust pwrite/batched_write 시그니처에 hint) 두 자리에 PLID 흘리는 시그니처 변경의 최소 패치 작성 4. **(측정)** 현재 baseline: iouring_queue_depth=256, num_store_workers=2, num_load_workers=4 에서 단일 디바이스 throughput / latency / WAF 측정 — Phase 0 기준값으로 문서화 --- ## Open Questions - register_fixed_buffers 가 LMCache 어딘가에서 호출되는가? (코드에 인프라는 있으나 호출자 미확인) - L1 memory pool 의 data_ptr 가 FDP 적용 시 PLID 별로 분리된 풀이어야 하는가, 아니면 슬롯 단위로 PLID 인자만 다르게 흘려도 되는가? - _snapshot_state 의 lock hold 시간이 인덱스 100만 entry 시 얼마인지 — HC-SSD 구간에서 실측 필요 - _free_slots: list[int] 의 멤버십 체크 O(n) 가 실측에서 문제 되는 시점 (free slot 수) - meta_total_bytes 기본 256 MiB 가 실제 인덱스 페이로드(JSON 직렬화) 보다 항상 충분한지 — 키 길이 × 엔트리 수 로 추정해 보기 --- ## 참고 - 사전 메모 [raw-block-perf-findings.md](./raw-block-perf-findings.md) 의 Rust 7개 / Python 7개 / L2 adapter 2개 개선 항목 중, L4 분석으로 추가 확인된 것: **fixed buffer 인프라가 미사용 상태일 가능성** (P1). 그 외 항목은 라인 분석으로 모두 위치 재확인됨.

2/2

이 문서는 지금 단계에서 꽤 잘 정리되어 있어. 특히 좋은 점은 단순 기능 설명이 아니라:

  • 계층 책임 분리
  • contract/invariant
  • I/O lifecycle
  • 락/체크포인트/eviction race
  • FDP insertion point
  • HC-SSD scale risk
  • io_uring 실제 사용 수준

까지 전부 연결해서 봤다는 점이야.
단순 코드 리딩 메모가 아니라 “아키텍처 분석 문서” 수준으로 올라와 있음.

특히 아래 4개가 핵심 강점이야.


잘 잡힌 핵심 포인트

1. “Adapter vs Core vs Rust” 책임 분리가 명확함

이게 제일 중요해.

많은 경우 raw_block 를 보면:

  • adapter 가 비동기 담당인지
  • core 가 durability 담당인지
  • rust 가 scheduling 담당인지

섞여 보이는데, 너 문서는:

Layer핵심 책임
AdapterMP contract/eventfd/task lifecycle
Coreslot/index/checkpoint/lock
Rustactual I/O engine

로 아주 명확히 끊어놨음.

이 구조 이해가 되면 이후 FDP 삽입 위치도 자연스럽게 보임.


2. “lock semantics” 를 제대로 짚음

이 부분이 매우 중요함.

lookup_and_lock
→ load
→ unlock

이 flow 때문에:

delete(force=False)

가 중요한 거고,

exists_many(lock=True)

가 단순 contains 가 아니라
“eviction protection protocol” 이라는 걸 정확히 이해한 거야.

이건 단순 storage backend 가 아니라
“cache coherence contract” 관점으로 본 거라 좋음.


3. H1/H2 FDP insertion point 선정이 정확함

특히:

위치의미
H1policy → physical write path 연결
H2actual NVMe passthru/io_uring integration

이 2단계 분리가 매우 중요함.

보통 여기서 실수하는 게:

  • policy layer
  • transport layer
  • device command layer

를 섞는 건데, 너는 잘 분리했어.

실제로 구현 들어가면:

Core._write_one()

RawBlockDevice.pwrite_from_buffer()

io_uring SQE / uring_cmd

이 경로가 “FDP propagation pipeline” 이 됨.


4. HC-SSD 병목 후보를 잘 찾음

특히 이 3개 중요:

문제실제 위험
_snapshot_state global locktail latency spike
_free_slots list[int]O(n) degeneration
single ring + single workerthroughput ceiling

이건 단순 micro optimization 이 아니라,
“capacity scaling failure point” 에 가까움.

특히:

slot in self._free_slots

이건 free slot 수 커지면 꽤 위험할 수 있음.


추가로 보면 좋은 포인트

A. 현재 raw_block 는 “storage engine” 이 아니라 “durable cache”

이 관점을 문서 맨 위 somewhere 에 적으면 좋아.

왜냐면:

  • overwrite 없음
  • append-like allocation
  • delete = logical free
  • checkpoint metadata
  • recovery replay

패턴이 사실상:

mini log-structured object store

에 가까움.

즉 raw_block 는:

KV cache persistence layer

이지,
일반 block storage abstraction 은 아니야.

이 framing 넣으면 설계 이해가 훨씬 쉬워짐.


B. _inflight 구조는 실제로 매우 중요함

현재 문서에서도 나오긴 하는데,
의미를 더 강조할 가치 있음.

왜 중요하냐면:

allocate slot
→ inflight 등록
→ write
→ index publish

이 순서가:

  • duplicate put race
  • delete during write
  • recovery ambiguity

를 막는 핵심 protocol 이거든.

사실상:

two-phase visibility

역할을 함.


C. checkpoint 는 “metadata WAL 대체물”

지금 구조는:

full snapshot checkpoint

방식이지 WAL 이 아니야.

그래서 너가 짚은:

sustained write 동안 checkpoint skip

가 중요한 문제.

왜냐면 recovery window 가:

last successful snapshot
~
crash 시점

까지 날아갈 수 있으니까.

HC-SSD 방향 가면:

  • incremental checkpoint
  • metadata WAL
  • sharded index snapshot

중 하나는 거의 필요해질 가능성 높음.


FDP 관점에서 가장 중요한 insight

문서 전체에서 가장 중요한 포인트 하나만 고르면 이거야:

FDP policy 는 Core 레벨,
FDP transport 는 Rust/io_uring 레벨

즉:

레벨역할
Core“어떤 데이터가 어떤 lifetime 인가?”
Rust“그걸 어떻게 NVMe command 로 내보내는가?”

이 분리가 되어야 유지보수가 가능함.


지금 상태에서 다음 우선순위 추천

1순위 — 실제 write path tracing

진짜 중요.

특히:

submit_store_task
→ _run_store_task
→ core.put_many
→ _write_one
→ pwrite_from_buffer
→ io_uring SQE

를 실제 로그/trace 로 한 번 따라가봐.

이걸 해야:

  • 어디서 PLID 결정할지
  • 어디서 command encoding 할지
  • 어디서 batching 되는지

감이 정확해짐.


2순위 — fixed buffer 실제 사용 여부 확인

이거 진짜 중요.

왜냐면 현재 분석대로면:

registered buffer infra 존재
BUT 실제 미사용 가능성

상태인데,

만약 실제 미사용이면:

  • zero-copy path
  • WriteFixed path
  • FDP-aware registered pool

전부 future optimization 후보가 됨.

즉:
“latent optimization” 이 숨어 있는 거야.


3순위 — single ring bottleneck 실측

이건 HC-SSD 방향이면 꼭 필요.

특히:

측정의미
SQ occupancyring saturation
CQ drain latencyworker 병목
submit batching sizebatching efficiency
CPU core utilizationsingle worker ceiling

보면 좋음.


아주 작은 수정 추천

이 표현 하나만 바꾸면 더 정확해져:

현재:

header 가 먼저 가야 slot identity 가 디스크에 박힘

실제 의미는:

header write 완료 후에야 slot validity 가 성립

에 가까움.

왜냐면 recovery 시:

  • slot header validity
  • slot identity
  • payload_len

으로 slot 을 신뢰하기 때문.

즉 header 는 사실상:

commit marker

역할에 가까워.


전체적으로 보면 지금 문서는:

“raw_block 코드 설명”

수준이 아니라,

“LMCache raw_block storage architecture analysis”

수준까지 올라와 있어.
특히 FDP 삽입 후보(H1~H8) 정리는 이후 실제 구현 설계의 기반 문서로 써도 될 정도야.

Q1. raw_block 의 _inflight 구조가 정확히 어떤 race condition 들을 막고 있는지 상태 머신 형태로 정리해볼까?

Q2. io_uring 기반 FDP passthru (IORING_OP_URING_CMD) 를 넣는다면 현재 Rust worker loop 에 어떤 구조 변경이 필요한지 분석해볼까?

Q3. HC-SSD 규모(수백만~수천만 slot)에서 _snapshot_state_free_slots 가 실제 얼마나 위험한지 복잡도/메모리 기준으로 계산해볼까?