본문으로 건너뛰기

raw_block 종단 분석

[!tldr] 업무 관점 takeaway FDP 삽입 최우선 지점은 H1 (Core._write_one → placement_id 인자) + H2 (Rust pwrite_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.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)
  • 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)

레이어별 책임

레이어책임안 하는 일
RawBlockL2Adaptersubmit/pop/query 비동기 계약, eventfd 3개, ThreadPoolExecutor 3개(size 2/1/4), task_id 발급슬롯 할당, 인덱스, 디바이스 I/O
RustRawBlockBackendNon-MP StoragePluginInterface, prefix-only contains/getMP 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)

#위치추가할 것우선순위
H1Core._write_one (core.py:898-938) → pwrite_from_buffer 호출placement_id 인자 추가 — Rust까지 흘려보냄. 모든 write가 통과하는 단일 진입점★★★
H2RawBlockDevice.pwrite_from_buffer / batched_write (lib.rs:1078, 1710)IORING_OP_URING_CMD (NVMe passthru) 또는 RWF_* write hint. kernel 6.8+ io_uring FDP 패치 필요★★★
H3Core.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와 섞이지 않게★★★
H6delete_many → free slot 회수IORING_OP_URING_CMD로 DSM deallocate 또는 FDP RU reset hint
H7RawBlockL2AdapterConfigfdp_plid_* 필드, placement_strategy enum — config surface
H8register_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 PLIDon_l2_keys_accessed 빈도 기반
cold 슬롯 (한 번 쓰고 evict)짧다cold PLIDLRU 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-1021free list 길어지면 delete 핫패스 저하
C4meta_total_bytes 기본 256 MiB 고정adapter.py:77대용량에서 부족 가능
C5meta_idle_quiet_ms=100 — sustained write 중 체크포인트 skipcore.py:1200-1211crash window 길어짐
C6단일 ring + 단일 worker (Rust)lib.rs:439, 479TB급 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 boollib.rs:50-61
ring 인스턴스1 디바이스 = 1 ring, Arc<Mutex>lib.rs:439, 441
워커 스레드1 디바이스 = 1 dedicated worker threadlib.rs:479
queue depthiouring_queue_depth (기본 256)lib.rs:48, 439, 976
setup flags없음 (default) — setup_iopoll, setup_sqpoll 등 전부 비활성화lib.rs:439

Op / 제출 패턴

항목
사용 opRead, 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 — 현재 없음

  • 현재 BLKGETSIZE64 ioctl만 사용 (디바이스 크기 조회, lib.rs:153)
  • IORING_OP_URING_CMD 등 NVMe passthru 경로 없음FDP 추가할 핵심 자리

다음 단계 후보

  1. register_fixed_buffers 실제 호출 여부 grep — 호출 안 됨이 확인되면 lifetime/alignment 문제 원인 파악 (lmcache/v1/memory_management.py 확인 필요)
  2. FDP plugin skeleton 작성RawBlockL2Adapter의 ThreadPoolExecutor 3개 + eventfd 3개 패턴을 베껴 베이스로 사용
  3. H1 + H2 시그니처 변경 최소 패치Core._write_one에 placement_id 추가 → Rust pwrite/batched_write까지 흘리는 경로
  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 구간 실측 필요
  • 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)