Raw Block 모듈은 왜 Python / Rust 로 나뉘어 있는가
변경 검증 가이드 (다음 fetch 후):
git log eaa2bfee..HEAD -- rust/raw_block/src/lib.rs \rust/raw_block/README.md \lmcache/v1/storage_backend/raw_block/core.py \lmcache/v1/distributed/l2_adapters/raw_block_l2_adapter.py \lmcache/v1/storage_backend/plugins/rust_raw_block_backend.py \docs/design/v1/distributed/l2_adapters/raw_block.md
- Rust crate 가 슬롯/인덱스/체크포인트로 책임을 흡수하면 책임 분리표와 "분리 이유 4가지" 의 §1 / §4 가 통째로 다시 써야 한다.
core.py가 둘로 쪼개지거나 한쪽 facade 가 사라지면 §2 (DRY) 와 호출 흐름 다이어그램이 무효화된다.register_fixed_buffers가 옮겨지거나 시그니처가 바뀌면 §"경계 — 누가 무엇을 넘기는가" 의 Rust ↔ kernel 행과 FDP 후크 자리(H2) 위치가 어긋난다.- 인용한 design doc / README 의 문구가 바뀌면 §"참고 인용" 라인 번호를 다시 매겨야 한다.
대상 코드:
rust/raw_block/src/lib.rs(2,030 LOC) — Rust crate (PyO3)lmcache/v1/storage_backend/raw_block/core.py(1,477 LOC) — Python corelmcache/v1/storage_backend/raw_block/key_codec.py(168 LOC)lmcache/v1/storage_backend/raw_block/__init__.pylmcache/v1/distributed/l2_adapters/raw_block_l2_adapter.py(MP adapter)lmcache/v1/storage_backend/plugins/rust_raw_block_backend.py(legacy non-MP facade)
관련 노트:
- 종단 분석: raw_block_line.md
- 성능 관점: raw-block-perf-findings.md
- 인터페이스 계약: l2_adapters_contract.md
TL;DR
Rust = 디바이스 fd 1개를 잡고 syscall 만 친다. Python = 슬롯 / 인덱스 / 락 / 체크포인트 / 복구 같은 "정책과 상태" 를 관리한다.
Rust crate 는 의도적으로 좁게 유지된다 (rust/raw_block/README.md:9 "The Rust crate intentionally stays narrow").
Python core 하나를 MP adapter (RawBlockL2Adapter) 와 legacy non-MP backend (RustRawBlockBackend) 둘이 공유한다.
비유 — 도서관 모델
| 역할 | 누가 | 하는 일 |
|---|---|---|
| 사서 보조 | Rust | "10번 책장에 이 책 꽂아", "20번 책장 책 꺼내" 만 함. 책 내용 모름. 어디 비었는지도 모름. |
| 도서관 관리자 | Python | 어떤 책이 어느 책장에 있는지(인덱스), 빈 책장 어디인지(free slot), 누가 빌려갔는지(lock), 카드 목록 백업(checkpoint) 까지 다 함. |
관리자(Python) 가 "10번 책장에 꽂아" 시키면 보조(Rust) 가 실제로 꽂는다.
책임 분리표
| 레이어 | 위치 | 책임 | 안 하는 일 |
|---|---|---|---|
Rust RawBlockDevice | rust/raw_block/src/lib.rs:355 | 디바이스 open/close, pwrite_from_buffer / pread_into blocking primitive, POSIX vs io_uring 엔진 스위칭, AlignedBuf (O_DIRECT bounce), register_fixed_buffers, 단일 ring + 단일 worker thread | 슬롯 / 인덱스 / 키 / 체크포인트 / 락 — 전부 모름 |
Python RawBlockCore | lmcache/v1/storage_backend/raw_block/core.py:152 | 슬롯 할당 (_allocate_slot_locked), in-memory 키 인덱스 (_index), free slot list, _inflight 추적, lock refcount, 메타데이터 체크포인트 + 복구, slot header 검증, zero-copy view 구성 | 비동기 계약, 워커 풀 (Adapter 가 함), 디바이스 syscall 직접 |
Python RawBlockL2Adapter | lmcache/v1/distributed/l2_adapters/raw_block_l2_adapter.py:281 | MP L2AdapterInterface 구현, eventfd 3개 (store/lookup/load), ThreadPoolExecutor 3개, task_id 발급, listener 알림 | 슬롯 / 인덱스 / 디바이스 I/O |
Python RustRawBlockBackend (legacy facade) | lmcache/v1/storage_backend/plugins/rust_raw_block_backend.py:74 | 비-MP StoragePluginInterface, prefix-only contains/get, pin/unpin | (MP eventfd 비동기 계약 없음) |
분리 이유 4가지
1. 잘하는 게 다르다 — 도구 적합성
- 디바이스 직격 syscall (
pread/pwrite,io_uringSQE 제출, O_DIRECT alignment,AlignedBuf의posix_memalign) → Rust 가 빠르고 안전하다. unsafe 경계도 한 곳에 모인다. - 인덱스 관리, JSON 체크포인트, lock refcount, 슬롯 free list → Python 으로 짜는 게 훨씬 쉽고 충분히 빠르다. 이쪽은 hot path 가 아님 (디바이스 I/O 가 dominant).
2. Core 하나를 두 모드가 공유 — DRY
LMCache 에는 backend 모드가 둘이다:
non-MP (legacy) MP (현재 권장)
RustRawBlockBackend RawBlockL2Adapter
\ /
v v
RawBlockCore (Python) ← 슬롯/인덱스/체크포인트
|
v
RawBlockDevice (Rust) ← 디바이스 I/O
|
v
raw block device / file
두 모드 모두 똑같은 디스크 포맷, 똑같은 슬롯 할당, 똑같은 복구 로직이 필요하다. Core 를 따로 빼두지 않으면 같은 코드를 두 번 써야 한다. 설계 문서에도 명시:
"This avoids maintaining separate raw-block implementations for MP and non-MP mode." —
docs/design/v1/distributed/l2_adapters/raw_block.md:63-64
3. 변경 빈도가 다르다 — 수정 비용
| 종류 변경 | 만지는 곳 | 빌드 비용 |
|---|---|---|
| 슬롯 할당 정책, 체크포인트 주기, 락 룰 | Python only | maturin develop 불필요 |
| 디바이스 I/O 엔진 (POSIX → io_uring), fixed buffer 등록 | Rust only | Python 인터페이스 그대로 |
| 새 backend 모드 추가 | adapter / facade 만 | core 와 Rust 손 안 댐 |
정책은 자주 바뀌고 syscall 경계는 거의 안 바뀐다. 자주 바뀌는 쪽을 Python 에 둠으로써 Rust 재컴파일을 피한다.
4. PyO3 경계 최소화 — zero-copy 단순화
PyO3 FFI 표면이 좁을수록 zero-copy 경로가 단순해진다.
- Rust 가 노출하는 핵심 API 는
pwrite_from_buffer(offset, data, total_len)/pread_into(offset, out, payload_len, total_len)두 개뿐. - Python 의
_build_direct_odirect_view(core.py:801-854) 가 ctypes 로 raw memoryview 만들어 그대로 Rust 에 넘긴다 → payload 메모리 복사 0회. - 만약 슬롯/인덱스까지 Rust 로 갔으면 Python ↔ Rust 사이에 더 복잡한 객체 (slot entry, lock refcnt, MemoryObj metadata) 가 오가야 했을 것이다.
호출 흐름 (put 1건 기준)
StoreController
│
│ submit_store_task(keys, objs)
▼
RawBlockL2Adapter (Python) ← MP 비동기 계약, eventfd, ThreadPool
│ task_id 발급 + pool.submit
▼
RawBlockCore.put_many (Python) ← 슬롯 할당, _index, _inflight
│ _allocate_slot_locked → offset
│ pwrite header
│ pwrite payload ← header 와 payload 별도 2회
▼
RawBlockDevice.pwrite_from_buffer (Rust, PyO3)
│ POSIX pwrite 또는
│ io_uring SQE (Write / WriteFixed)
▼
raw block device / file
Python 은 "어디에 쓸지" (offset 계산) 까지 결정하고, Rust 는 받은 offset 에 그대로 syscall 친다.
경계 — 누가 무엇을 넘기는가
| 경계 | 넘어가는 것 | 안 넘어가는 것 |
|---|---|---|
| Adapter ↔ Core | RawBlockKeySpec, MemoryObj, encoded_key | eventfd, task_id, ThreadPool — adapter 안에서만 |
| Core ↔ Rust | (offset: u64, buf_ptr: *const u8, len: usize) 정도의 primitive | encoded_key, slot 번호, MemoryObj metadata — 전부 Python 안에서만 |
| Rust ↔ kernel | pread / pwrite syscall, io_uring SQE, BLKGETSIZE64 ioctl | (현재) NVMe passthru, FDP placement hint — 여기가 향후 H2 후크 자리 |
→ FDP / placement-hint 추가 시 Python _write_one 시그니처에 placement_id 가 추가되고, Rust pwrite_from_buffer 시그니처에도 같은 인자가 흘러야 한다 (raw_block_line.md L3 H1/H2).
안 나눠졌으면 어떻게 됐을까 — 반대 가정
| 가정 | 결과 |
|---|---|
| 모두 Rust | 슬롯 정책 / 체크포인트 / 락까지 Rust → MP adapter 도 Rust 호출. 빌드 비용 폭증, MP/non-MP 의 다른 lifecycle 관리가 PyO3 경계 너머로 가서 디버깅 어려움. |
| 모두 Python | pread/pwrite 도 Python → io_uring 사용 불가 (Python 바인딩 없음), O_DIRECT 정렬 / fixed buffer 같은 정밀 제어 어려움. throughput 한계. |
| Core 도 adapter 옆에 두고 두 번 구현 | MP / non-MP 각각 슬롯 / 인덱스 / 체크포인트 따로. 디스크 포맷이 갈라질 위험, 복구 로직 중복. |
지금 분리는 이 셋의 가장 합리적 절충이다.
관찰: Python 쪽이 책임이 많아서 비대해진다
core.py 1,477 LOC 는 작지 않다. 이미 raw_block_line.md §L2 / §C1–C6 에서 지적된 이슈가 모두 Python core 쪽에 있다:
_index: dict[str, _Entry]가 in-memory 전체 보관 — HC-SSD 시 RAM 압박_snapshot_state가_lock잡고 dict 전체 직렬화 — lock hold 폭발_free_slots: list[int]멤버십 체크 O(n)meta_idle_quiet_ms=100ms동안 sustained write 시 체크포인트 안 됨
분리 자체는 합리적이지만, "Python 쪽 책임이 무거워졌을 때 어디까지 Rust 로 내릴 것인가" 는 별도 설계 결정이 필요하다 (예: 인덱스 오프로드, free list 자료구조 교체 등).
참고 인용
rust/raw_block/README.md:9-12— "The Rust crate intentionally stays narrow: it owns the raw device handle and exposes blocking pwrite_from_buffer / pread_into primitives. Slotting, checkpointing, recovery, and MP task orchestration all live in Python."docs/design/v1/distributed/l2_adapters/raw_block.md:48-65— Key Design Choicedocs/design/v1/distributed/l2_adapters/raw_block.md:63-64— "This avoids maintaining separate raw-block implementations for MP and non-MP mode."