Raw Block Storage Stack 분석
분석 범위: adapter layer 전체 I/O lifecycle
- MP path:
raw_block_l2_adapter.py→core.py→ Rust- legacy path:
rust_raw_block_backend.py→core.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 클램프가 반대 방향(과소 계상)만 막으므로 이 버그는 클램프에 걸리지 않는다.
재현 방법:
_write_one내 I/O 직전에time.sleep()mock- sleep 중 동일 key에 대해
delete()호출 - put 완료 후
_total_bytes_usedvs.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_needed는 pin_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_one의 inflight_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_one이 put_many([spec], [obj])를 1개씩 호출한다.
결과:
put_many의 배치 이점 전무 (슬롯 할당, lock 획득 모두 개별)- 이벤트루프 스케줄링 오버헤드 N배
측정: 배치 크기별 총 put latency 측정.
| 배치 크기 N | 현재 구조 | 배치 put_many 구조 |
|---|---|---|
| 1 | baseline | baseline |
| 10 | ~10× 오버헤드 | ~1× |
| 100 | ~100× 오버헤드 |
수정 방향: 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,
)
전체 우선순위
| 우선순위 | # | 파일 | 항목 | 수치 증명 |
|---|---|---|---|---|
| 즉시 | T1 | raw_block_l2_adapter.py:508 | delete() TOCTOU → _total_bytes_used 과대 계상 | ✅ mock sleep으로 재현 |
| 단기 | L1 | core.py:467,916 | put_many 4N lock → 2N | ✅ perf_counter |
| 단기 | L2 | rust_raw_block_backend.py:347 | legacy batched put 1-key 분해 | ✅ 배치 크기별 latency |
| 중기 | L3 | adapter config + core | io_uring SQ 활용률 2~4/256 | ✅ iostat avgqu-sz |
| 사소 | L4 | rust_raw_block_backend.py:561 | close() 10ms polling → Condition | ✅ shutdown timing |
| latent | T2 | rust_raw_block_backend.py:508 | batched_async_contains pin N×2 lock | 기능적으로 안전 |