본문으로 건너뛰기

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 core
  • lmcache/v1/storage_backend/raw_block/key_codec.py (168 LOC)
  • lmcache/v1/storage_backend/raw_block/__init__.py
  • lmcache/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)

관련 노트:


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 RawBlockDevicerust/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 RawBlockCorelmcache/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 RawBlockL2Adapterlmcache/v1/distributed/l2_adapters/raw_block_l2_adapter.py:281MP 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_uring SQE 제출, O_DIRECT alignment, AlignedBufposix_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 onlymaturin develop 불필요
디바이스 I/O 엔진 (POSIX → io_uring), fixed buffer 등록Rust onlyPython 인터페이스 그대로
새 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 ↔ CoreRawBlockKeySpec, MemoryObj, encoded_keyeventfd, task_id, ThreadPool — adapter 안에서만
Core ↔ Rust(offset: u64, buf_ptr: *const u8, len: usize) 정도의 primitiveencoded_key, slot 번호, MemoryObj metadata — 전부 Python 안에서만
Rust ↔ kernelpread / 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 경계 너머로 가서 디버깅 어려움.
모두 Pythonpread/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 Choice
  • docs/design/v1/distributed/l2_adapters/raw_block.md:63-64"This avoids maintaining separate raw-block implementations for MP and non-MP mode."