raw_block 종단 분석
[!tldr] 업무 관점 takeaway FDP 삽입 최우선 지점은 H1 (
Core._write_one→ placement_id 인자) + H2 (Rustpwrite_from_buffer→ io_uring FDP passthru) + H5 (체크포인트 write → 별도 PLID). io_uring fixed buffer 인프라는 코드에 있지만 현재 미사용 상태 — P1 최적화 후보. HC-SSD 방향에서 가장 위험한 곳은_snapshot_state의 전체 인덱스 lock + JSON 직렬화.
분석 대상
docs/design/v1/distributed/l2_adapters/raw_block.mdlmcache/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)rust/raw_block/src/lib.rs(2030 LOC)
L1 — 계층 구조와 책임 분리
계층 구조
StoreController / PrefetchController (MP 경로)
StorageManager (Non-MP legacy 경로)
│ │
▼ ▼
RawBlockL2Adapter RustRawBlockBackend
(L2AdapterInterface) (StoragePluginInterface)
│ │
└────────┬───────────┘
▼
RawBlockCore
(슬롯 할당 / 인덱스 / 체크포인트 / lock refcount)
│
▼
RawBlockDevice (Rust PyO3)
(POSIX or io_uring 엔진, 실제 I/O)
│
▼
raw block device / file (/dev/nvme*n* or pre-sized file)
레이어별 책임
| 레이어 | 책임 | 안 하는 일 |
|---|---|---|
RawBlockL2Adapter | submit/pop/query 비동기 계약, eventfd 3개, ThreadPoolExecutor 3개(size 2/1/4), task_id 발급 | 슬롯 할당, 인덱스, 디바이스 I/O |
RustRawBlockBackend | Non-MP StoragePluginInterface, prefix-only contains/get | MP eventfd 비동기 계약 |
RawBlockCore | 디바이스 open/close, in-memory 키 인덱스, free slot list, _inflight 추적, lock refcount, 메타데이터 체크포인트+복구 | 비동기 계약, 워커 스레드 풀 |
RawBlockDevice (Rust) | POSIX pread/pwrite 또는 io_uring 엔진, register_fixed_buffers, AlignedBuf (O_DIRECT), 단일 fd + 단일 ring + 단일 워커 | 슬롯 / 인덱스 / 체크포인트 / 키 |
raw_block은 일반 block storage abstraction이 아니다. overwrite 없음 / append-like 할당 / delete = logical free / checkpoint + recovery replay — 사실상 mini log-structured object store (KV cache persistence layer).
MP Adapter 핵심 invariant
| 계약 | 위치 |
|---|---|
| eventfd 3개 분리 (store / lookup / load) | adapter:331-333 |
| submit은 non-blocking (ThreadPoolExecutor.submit → 즉시 task_id 반환) | adapter:407-415 |
결과 회수: pop_completed_*, query_*_result (한 번 꺼내면 dict에서 제거) | adapter:417-455 |
L2 lock = exists_many(.., lock=True) → _lock_refcnt 증가, unlock_many가 감소 | core.py:523-547 |
delete(force=False) = locked 슬롯 보존 | core.py:670-692 |
| caller가 load 목적지 버퍼 제공 (adapter는 새 메모리 할당 안 함) | adapter:462-497 |
L2 — I/O 경로 상세
디바이스 레이아웃
0 device_size
├── meta_total_bytes (기본 256 MiB) ──────────────────┤
│ meta container 0 │ meta container 1 │ data slots... │
│ (mirror copy) │ (mirror copy) │ slot 0, slot 1 │
│ header(4KB) + json │ header(4KB) + json │ │
└─────────────────────┴─────────────────────┴─────────────────┘
- 슬롯 = 고정 크기
slot_bytes(기본 1 MiB) - 슬롯 헤더 =
header_bytes(기본 4 KiB) — LMCBLK01 magic + slot_identity + payload_len. 이게 slot의 commit marker (recovery 시 header validity로 slot 신뢰 여부 결정) - 메타데이터 = mirror copy 2개, JSON + zlib CRC32,
_meta_seq증가하며 round-robin
put 흐름 (submit_store_task)
StoreController → Adapter.submit_store_task → task_id (즉시 return)
│
▼ (별도 스레드)
Core.put_many(specs, objs)
for each (key, obj):
_lock → 중복/inflight 체크 → _allocate_slot_locked() → offset
_inflight[encoded] = (offset, meta)
_lock 해제
Rust.pwrite(offset, header) ← 헤더 먼저 (commit marker)
Rust.pwrite(offset+header_bytes, payload)
_lock → _inflight pop → _index 등록 → meta_dirty++
│
▼
_finish_store_task → store_efd.notify()
StoreController가 poll(store_efd) 깨어나서 pop
핵심: header / payload 별도 pwrite 2회 — header가 먼저 기록돼야 slot identity가 디스크에 확정됨.
O_DIRECT + enable_zero_copy=True이면 _build_direct_odirect_view가 ctypes로 raw memoryview → zero-copy pwrite.
_inflight 구조 — two-phase visibility 프로토콜
allocate slot → _inflight 등록 → pwrite → _index publish
이 순서가 지키는 것:
- duplicate put race: 이미 inflight면 skip
- delete during write:
delete_many가_inflight[k].canceled = True표시 → put 완료 후 free list로 반환 - recovery ambiguity: 크래시 시 inflight는 복구 불가 → 다음 재기동에서 버려짐
get / load 흐름
Caller가 destination buffer를 미리 할당해서 넘김. Adapter는 절대 새 메모리 할당 안 함.
core.load_many_into(encoded_keys, objs):
with lock: items = [(k, _index.get(k)) for k in encoded_keys]
for (k, entry) in items:
if entry is None: skip (miss)
raw_dev.pread_into(entry.offset + header_bytes, buf, payload_len)
objs[i].metadata.cached_positions = entry.meta.cached_positions
→ bitmap 변환 후 load_efd.notify()
lookup-and-lock / evict 흐름
core.exists_many(keys, lock=True):
with lock:
if found and lock: _lock_refcnt[k] += 1 ← 순수 in-memory, I/O 없음
core.delete_many(keys, force=False):
with lock:
if locked and not force: 보존 (lookup-lock 중인 슬롯 안전)
removed = _index.pop(k); _free_slots.append(slot)
_inflight[k].canceled = True ← 진행 중 write와의 race 방지
슬롯 자체에 즉시 쓰기 없음 — free list에 회수만, 다음 put이 덮어씀.
체크포인트 흐름
_checkpoint_loop (백그라운드 daemon):
every meta_checkpoint_interval_sec:
if meta_idle_quiet_ms(=100ms) 동안 I/O 없을 때만 실행
_snapshot_state(): _lock 잡고 전체 dict JSON 직렬화 ← ⚠️ 대형 인덱스 위험
_write_checkpoint(): pwrite payload → pwrite header → meta_seq++
sustained write 중에는 체크포인트가 안 일어남 → 크래시 시 복구 윈도 길어짐.
부팅 시 복구: mirror 2개 중 seq 큰 쪽 선택 → CRC32 검증 → apply_loaded_state로 _index 재구성.
L3 — FDP / HC-SSD 삽입 후보 지점
FDP 삽입 지점 (H1~H8)
| # | 위치 | 추가할 것 | 우선순위 |
|---|---|---|---|
| H1 | Core._write_one (core.py:898-938) → pwrite_from_buffer 호출 | placement_id 인자 추가 — Rust까지 흘려보냄. 모든 write가 통과하는 단일 진입점 | ★★★ |
| H2 | RawBlockDevice.pwrite_from_buffer / batched_write (lib.rs:1078, 1710) | IORING_OP_URING_CMD (NVMe passthru) 또는 RWF_* write hint. kernel 6.8+ io_uring FDP 패치 필요 | ★★★ |
| H3 | Core.put_many (core.py:434-521) — slot 할당 직후 | cache_salt / cached_positions 기반 PLID 분류 정책 결정. 정책이 들어가는 곳 | ★★ |
| H4 | _allocate_slot_locked (core.py:1004-1013) | PLID별 free list 분리 — RU 경계와 슬롯 그룹 정렬 | ★★ |
| H5 | 메타데이터 체크포인트 write (_write_checkpoint, core.py:1161-1198) | 별도 PLID (가장 긴 lifetime) — 메타 GC가 데이터 슬롯 GC와 섞이지 않게 | ★★★ |
| H6 | delete_many → free slot 회수 | IORING_OP_URING_CMD로 DSM deallocate 또는 FDP RU reset hint | ★ |
| H7 | RawBlockL2AdapterConfig | fdp_plid_* 필드, placement_strategy enum — config surface | ★ |
| H8 | register_fixed_buffers (lib.rs:1017) | 등록된 버퍼당 PLID 메타데이터 — WriteFixed 시 PLID 자동 적용 | ★ (P2) |
핵심 원칙: FDP policy는 Core 레벨, FDP transport는 Rust/io_uring 레벨. 두 레이어를 섞으면 유지보수 불가.
데이터 분류 → PLID 매핑 후보
| 데이터 | 수명 | PLID 그룹 | 근거 |
|---|---|---|---|
| 메타데이터 체크포인트 | 매우 길다 (영구) | PLID 0 (별도) | mirror 2개, 매 60s, 거의 안 지워짐 |
동일 cache_salt KV 슬롯 | 함께 만료 | 같은 PLID | 함께 쓰이고 함께 만료될 가능성 |
| hot 슬롯 (자주 hit) | 길다 | hot PLID | on_l2_keys_accessed 빈도 기반 |
| cold 슬롯 (한 번 쓰고 evict) | 짧다 | cold PLID | LRU score 기반 |
cache_salt별 분류는 이미 adapter._bytes_by_cache_salt (adapter:597-605)에서 사용 중 → 정책 입력으로 자연스럽게 끌어 쓸 수 있음.
HC-SSD 스케일 위험 지점 (C1~C6)
| # | 문제 | 위치 | 성격 |
|---|---|---|---|
| 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 spike |
| C3 | _free_slots: list[int] — slot in self._free_slots 멤버십 체크 O(n) | 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 중 체크포인트 skip | core.py:1200-1211 | crash window 길어짐 |
| C6 | 단일 ring + 단일 worker (Rust) | lib.rs:439, 479 | TB급 throughput 한계 |
HC-SSD 방향으로 가면 C1/C2/C3가 "capacity scaling failure point"가 될 수 있음. incremental checkpoint 또는 sharded index가 필요해질 가능성 높음.
L4 — io_uring 사용 현황 분석
엔진 / 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) | lib.rs:48, 439, 976 |
| setup flags | 없음 (default) — setup_iopoll, setup_sqpoll 등 전부 비활성화 | lib.rs:439 |
Op / 제출 패턴
| 항목 | 값 |
|---|---|
| 사용 op | Read, Write, ReadFixed, WriteFixed (4종) |
| Vectored I/O | 없음 (Readv/Writev 미사용) |
| Fsync | 없음 |
| 배치 | 워커가 큐에서 사용 가능한 만큼 SQE 일괄 제출 후 submit() |
| 완료 대기 | Condvar + 10µs timeout 폴링 (busy-wait 아님) |
| 짧은 I/O 처리 | bytes_transferred < len이면 offset/len 조정해서 재제출 (lib.rs:505-585) |
Registered Buffers — 인프라는 있지만 현재 미사용
| 항목 | 값 |
|---|---|
IORING_REGISTER_BUFFERS | 코드 존재 (register_fixed_buffers, lib.rs:1017-1069) |
IORING_REGISTER_FILES | 미호출 |
| 실제 사용 여부 | LMCache 코드 경로에서 register_fixed_buffers 미호출 (RawBlockCore._rawdev()에서 호출 안 함, core.py:280-299) |
| 결론 | fixed buffer 인프라 존재, 사용 안 됨 → zero-copy path + WriteFixed path + FDP-aware registered pool 전부 P1 최적화 후보 |
NVMe Passthru — 현재 없음
- 현재
BLKGETSIZE64ioctl만 사용 (디바이스 크기 조회, lib.rs:153) IORING_OP_URING_CMD등 NVMe passthru 경로 없음 ← FDP 추가할 핵심 자리
다음 단계 후보
register_fixed_buffers실제 호출 여부 grep — 호출 안 됨이 확인되면 lifetime/alignment 문제 원인 파악 (lmcache/v1/memory_management.py확인 필요)- FDP plugin skeleton 작성 —
RawBlockL2Adapter의 ThreadPoolExecutor 3개 + eventfd 3개 패턴을 베껴 베이스로 사용 - H1 + H2 시그니처 변경 최소 패치 —
Core._write_one에 placement_id 추가 → Rustpwrite/batched_write까지 흘리는 경로 - 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 구간 실측 필요meta_total_bytes기본 256 MiB가 실제 인덱스 페이로드(JSON 직렬화)보다 항상 충분한지 — 키 길이 × 엔트리 수로 추정 필요
관련 페이지
- [[raw_block-내부구조]] — 슬롯 구조, write/read path, checkpoint Q&A
- [[raw_block-성능-우선순위]] — 종합 개선 우선순위(T1~P3) + io_uring P0/P1
- [[raw_block-io_uring-cmd]] — PR #3274 io_uring_cmd NVMe passthrough 분석
- [[raw_block-개선-Task]] — H1/H2/M1/M2/S2 Task 목록 및 착수 조건
- [[LMCache-MP-NonMP-모드]] — MP/Non-MP 분기, FDP Plugin은 MP(L2AdapterInterface) 전용
- [[NVMe-FDP]] — FDP 삽입 지점 H1-H8의 기반 기술 (RUH, dspec)