본문으로 건너뛰기

Raw Block Storage Stack 분석

분석 범위: adapter layer 전체 I/O lifecycle

  • MP path: raw_block_l2_adapter.pycore.py → Rust
  • legacy path: rust_raw_block_backend.pycore.py → Rust

core.py 내부 이슈는 [[core_py_analysis]] 참조.

변경 검증 가이드 (다음 fetch 후):

git log 29bbd553..HEAD -- \
lmcache/v1/distributed/l2_adapters/raw_block_l2_adapter.py \
lmcache/v1/storage_backend/plugins/rust_raw_block_backend.py \
lmcache/v1/storage_backend/raw_block/core.py
  • delete() 구현이 바뀌면 T1 진단이 stale (PR fix가 머지됐을 가능성).
  • batched_submit_put_task 가 배치 방식으로 변경되면 L2 stale.
  • _write_one 내 lock 구조가 바뀌면 L1 stale.

Stack 구조

[vLLM worker]

├── MP path ──────────────────────────────────────────┐
│ RawBlockL2Adapter │
│ ├── submit_store_task → ThreadPoolExecutor │
│ │ └── _run_store_task → core.put_many │
│ ├── submit_lookup_and_lock_task │
│ │ └── _run_lookup_task → core.exists_many │
│ ├── submit_load_task │
│ │ └── _run_load_task → core.load_many_into │
│ └── delete → core.get_metadata_many │ ← T1 TOCTOU
│ + core.delete_many │
│ │
└── legacy path ────────────────────────────────────┐ │
RustRawBlockBackend │ │
├── batched_submit_put_task │ │ ← L2 1-key 분해
│ └── N × _submit_put_one(put_many×1) │ │
├── _batched_get_prefix → core.load_many_into │ │
├── batched_async_contains │ │ ← T2 pin N lock
│ └── exists_many + N×_pin_if_needed │ │
└── close() → 10ms polling │ │ ← L4
│ │
RawBlockCore ←────────────────── ┘ ┘
├── put_many (4N lock per N keys) ← L1
├── load_many_into
├── exists_many / delete_many
└── _rawdev() → lmcache_rust_raw_block_io
└── NVMe / raw device

T — TOCTOU / 정확성 버그

T1. delete() TOCTOU → _total_bytes_used 영구 과대 계상 (high, 재현 가능)

위치: raw_block_l2_adapter.py:508-521

def delete(self, keys):
encoded_keys = [encode_object_key(key).encoded for key in keys]
metas = self._core.get_metadata_many(encoded_keys) # lock 획득 → 해제
deleted_bitmap = self._core.delete_many(encoded_keys) # lock 획득 → 해제
...
deleted_sizes.append(0 if meta is None else int(self._core.slot_bytes))

레이스 시나리오:

Thread A (store worker): put_many → _inflight에 key 등록, I/O 진행 중
Thread B (eviction): get_metadata_many → meta = None (아직 _index에 없음)
Thread A: I/O 완료 → key를 _index에 commit
Thread B: delete_many → deleted = True
결과: meta=None → deleted_size=0 → _notify_keys_deleted(size=0)
→ _total_bytes_used가 slot_bytes만큼 영구 과대 계상

결과: usage_fraction이 실제보다 높게 유지 → eviction threshold 조기 발동 → 불필요한 key 삭제. base.py:410의 underflow 클램프가 반대 방향(과소 계상)만 막으므로 이 버그는 클램프에 걸리지 않는다.

재현 방법:

  1. _write_one 내 I/O 직전에 time.sleep() mock
  2. sleep 중 동일 key에 대해 delete() 호출
  3. put 완료 후 _total_bytes_used vs. indexed_key_count × slot_bytes 비교

수정 방향: delete_many가 삭제된 entry의 slot_bytes를 atomic하게 함께 반환.

# 현재
metas = self._core.get_metadata_many(encoded_keys)
deleted_bitmap = self._core.delete_many(encoded_keys)

# 수정 후 (PR 제안 방식)
# delete_many가 RawBlockDeleteResult(deleted, was_indexed) 반환
results = self._core.delete_many(encoded_keys)
for key, result in zip(keys, results):
if not result.deleted:
continue
deleted_sizes.append(self._core.slot_bytes if result.was_indexed else 0)

T2. batched_async_contains pin — N회 lock 재진입 (low / latent)

위치: rust_raw_block_backend.py:508-529

results = self._core.exists_many(encoded_keys, lock=False) # step 1: lock 없음
# ... prefix_hits 계산 ...
for encoded_key in encoded_keys[:prefix_hits]:
if not self._pin_if_needed(encoded_key): # step 2: 매번 pin_lock → core_lock
break

_pin_if_neededpin_lock → exists_many(lock=True) 순으로 두 lock을 매번 획득한다. step 1과 step 2 사이에 key가 evict되면 중간 pin들이 낭비되고 prefix가 짧아진다. 기능적으로는 안전, 성능상 N × 2 lock 오버헤드.


L — Latency Source

L1. put_many — key당 4회 lock 획득/해제 (medium, perf_counter 측정 가능)

위치: core.py:467-516, core.py:916-934

per key:
lock#1 check + allocate + inflight 등록 (put_many)
lock#2 inflight_io_count += 1 (_write_one 진입)
pwrite header (I/O — lock 밖)
pwrite payload (I/O — lock 밖)
lock#3 inflight_io_count -= 1 (_write_one 종료)
lock#4 inflight.pop + _index 갱신 (put_many)

N=100 배치 → 400회 lock. Python threading.Lock 무경합 ~100ns, 경합 시 수 μs.

개선 방향: _write_oneinflight_io_count 업데이트를 put_many의 기존 lock#1, lock#4 구간으로 흡수하면 4N → 2N.

# 현재: _write_one이 별도 lock으로 io_count 관리
# 개선: put_many에서 io_count 일괄 관리
with self._lock:
for i, (key, obj) in enumerate(zip(keys, objs)):
# check + allocate + inflight 등록
self._inflight_io_count += 1 # 여기서 일괄 +=
# I/O (락 밖)
...
with self._lock:
# inflight.pop + index 갱신
self._inflight_io_count -= 1 # 여기서 일괄 -=

단, 이 구조로 바꾸면 배치 I/O 중간에 inflight_io_count가 실제 진행 상황을 반영하지 않게 되므로 checkpoint idle 판단에 영향이 있는지 확인 필요. ([[core_py_analysis]] D2 참조)

L2. legacy path — batched_submit_put_task 배치 분해 (medium, throughput 측정 가능)

위치: rust_raw_block_backend.py:347-382

for key, obj in zip(keys, objs):
...
fut = asyncio.run_coroutine_threadsafe(
self._submit_put_one(key, spec, obj, ...), # put_many([1개])
loop,
)

N개 key 배치를 N개의 개별 asyncio.run_coroutine_threadsafe 호출로 분해. 각 _submit_put_oneput_many([spec], [obj])를 1개씩 호출한다.

결과:

  • put_many의 배치 이점 전무 (슬롯 할당, lock 획득 모두 개별)
  • 이벤트루프 스케줄링 오버헤드 N배

측정: 배치 크기별 총 put latency 측정.

배치 크기 N현재 구조배치 put_many 구조
1baselinebaseline
10~10× 오버헤드~1×
100~100× 오버헤드1

수정 방향: N개를 한 번의 put_many(specs, objs) 호출로 묶은 뒤 완료 callback에서 개별 key 처리.

L3. io_uring SQ 활용률 저하 (medium, iostat 측정 가능)

위치: raw_block_l2_adapter.py:89, core.py:434

기본 설정:

num_store_workers = 2 → 동시 put_many 호출 최대 2개
num_load_workers = 4 → 동시 load_many_into 호출 최대 4개
iouring_queue_depth = 256

각 worker는 내부에서 key별 순차 I/O → 실제 SQ에 동시에 올라가는 SQE 수 = worker 수(2~4개). io_uring SQ depth 256 대비 실제 활용 12%.

측정:

iostat -x 1 # avgqu-sz: 평균 큐 깊이. worker 수 수준이면 SQ 낭비 중

근본 해결: Rust 바인딩에 pwrite_batch / pread_batch API 추가 → 한 번의 io_uring_enter로 N개 SQE 제출. ([[core_py_analysis]] P3 참조)

단기 대안: num_store_workers / num_load_workers 증가로 동시 SQE 수 확보 (Python GIL 영향은 I/O 대기 구간에서 release되므로 제한적).

L4. legacy close() 10ms polling (low)

위치: rust_raw_block_backend.py:561-568

def close(self):
deadline = time.monotonic() + 10.0
while True:
with self._put_lock:
pending = len(self._put_tasks)
if pending == 0 or time.monotonic() >= deadline:
break
time.sleep(0.01) # 10ms polling
self._core.close()

in-flight put 완료를 10ms 간격 polling으로 대기. _put_tasks가 빌 때 즉시 wakeup하는 threading.Condition으로 교체하면 shutdown latency를 최대 10ms 단축 가능.

# 개선
self._put_cond = threading.Condition(self._put_lock)

# _submit_put_one finally:
with self._put_cond:
self._put_tasks.discard(key)
if not self._put_tasks:
self._put_cond.notify_all()

# close():
with self._put_cond:
self._put_cond.wait_for(
lambda: len(self._put_tasks) == 0,
timeout=10.0,
)

전체 우선순위

우선순위#파일항목수치 증명
즉시T1raw_block_l2_adapter.py:508delete() TOCTOU → _total_bytes_used 과대 계상✅ mock sleep으로 재현
단기L1core.py:467,916put_many 4N lock → 2Nperf_counter
단기L2rust_raw_block_backend.py:347legacy batched put 1-key 분해✅ 배치 크기별 latency
중기L3adapter config + coreio_uring SQ 활용률 2~4/256iostat avgqu-sz
사소L4rust_raw_block_backend.py:561close() 10ms polling → Condition✅ shutdown timing
latentT2rust_raw_block_backend.py:508batched_async_contains pin N×2 lock기능적으로 안전