Raw Block 성능 분석 결과
변경 검증 가이드 (다음 fetch 후):
git log eaa2bfee..HEAD -- rust/raw_block/src/lib.rs \lmcache/v1/storage_backend/raw_block/core.py \lmcache/v1/distributed/l2_adapters/raw_block_l2_adapter.py
lib.rs의 batched_write / 워커 루프 / register_fixed_buffers 가 손대지면 §1-1 ~ §1-7 항목 라인 번호가 어긋나고 P0/P1 우선순위 표를 다시 매겨야 한다.core.py의put_many/load_many_into/_snapshot_state/_free_slots가 변경되면 §2-1 ~ §2-7 의 "현재 상태" 진단이 깨진다 — 특히 io_uring batched API 도입 PR 가 들어오면 §2-2/§2-3 + §3-1 이 통째로 stale.raw_block_l2_adapter.py의_run_store_task시그니처가 바뀌면 §3-1 의 "워커 직렬 호출" 진단이 무효화된다.
상태: TODO 4 (raw_block 라인 종단 분석) 의 사전 메모. L1–L4 렌즈 (
docs/TODO.md §0) 기준 정식 노트는 추후private/docs/notes/raw_block_line.md에 작성한다. 이 문서는 그 때 L3 (FDP/HC-SSD 삽입 후보) / L4 (io_uring 사용 분석) 섹션의 입력 자료로 흡수된다.
코드 분석 대상:
rust/raw_block/src/lib.rs(2,030 LOC)lmcache/v1/storage_backend/raw_block/core.py(1,477 LOC)lmcache/v1/distributed/l2_adapters/raw_block_l2_adapter.py(770 LOC)
1. Rust io_uring 엔진
1-1. SQ 제출 루프: notify-one 폭풍 (높음)
위치: lib.rs:1238, lib.rs:1674
batched_write / batched_read에서 submission을 한 건씩 큐에 넣고
매번 batch_ready.notify_one()을 호출한다.
// batched_write, lib.rs:1235-1238
let mut q = queue.lock().unwrap();
q.push(sub);
batch_ready.notify_one(); // ← n번 호출
N개짜리 배치를 넣으면 condvar notify가 N번 발생한다. 워커는 그 중 일부를 합쳐서 처리하지만, 불필요한 lock/unlock + notify 오버헤드가 N배로 쌓인다.
개선 방향: 모든 submission을 큐에 한번에 extend한 뒤 notify_one을 1회만 호출.
{
let mut q = queue.lock().unwrap();
q.extend(submissions.into_iter().map(|(s, _)| s));
}
batch_ready.notify_one(); // 1회
1-2. 워커 루프: CQ drain과 SQ submit이 같은 ring lock 내에서 직렬화 (높음)
위치: lib.rs:491-625 (CQ drain), lib.rs:659-791 (SQ submit)
두 작업이 순차적으로 실행되고 각각 ring_clone.lock().unwrap()을 잡는다.
CQ를 비우는 동안 SQ 제출이 블로킹되고, SQ를 제출하는 동안 CQ 처리가 블로킹된다.
CQ drain에서 completions: Vec<_> = ring.completion().collect() 로
전체 CQE를 한 번에 Vec으로 복사하는 부분도 불필요한 heap 할당이다.
let completions: Vec<_> = ring.completion().collect(); // Vec 복사
for cqe in completions { ... }
개선 방향:
ring.completion()이터레이터를 직접 소비하여 Vec 할당 제거- io_uring은 CQ와 SQ가 독립적인 ring buffer이므로,
split submission/completion API를 사용하면 lock 분리 가능 (Rust
io-uringcrate 0.6+ 지원)
1-3. 워커 루프: 10μs busy-wait (중간)
위치: lib.rs:633
let timeout = Duration::from_micros(10);
let (mut q, _) = batch_ready_clone
.wait_timeout_while(q, timeout, |q| {
q.is_empty() && !shutdown_clone.load(Ordering::Relaxed)
})
.unwrap();
condvar timeout이 10μs이므로 큐가 비어있는 동안 초당 최대 100,000번 깨어난다. CPU를 낭비하고 다른 스레드의 스케줄링을 방해할 수 있다.
io_uring의 IORING_OP_TIMEOUT 또는 submit_and_wait(min_complete)를 사용하면
커널에서 completion 이벤트가 올 때까지 대기할 수 있어 CPU 소비가 없다.
개선 방향: ring.submitter().submit_and_wait(1)로 변경하거나,
timeout을 최소 100μs~1ms로 늘려 불필요한 wakeup 감소.
1-4. in_flight_count 전역 카운터의 contention (낮음)
위치: lib.rs:1225, lib.rs:1660
batched_write/batched_read 루프 안에서 submission마다:
in_flight_count.fetch_add(1, Ordering::Relaxed);
그리고 completion마다:
let prev = in_flight_count_clone.fetch_sub(1, Ordering::Relaxed);
가 호출된다. Relaxed ordering이므로 실제 성능 영향은 작으나,
completion 경로에서 if prev == 1 { in_flight_cvar.notify_all() }를 매번 평가한다.
셧다운 시에만 의미가 있는 카운터인데 정상 I/O 경로 전체에 포함되어 있다.
1-5. per-batch batch_in_flight HashMap lock (중간)
위치: lib.rs:1228-1233, lib.rs:1663-1668
submission마다 batch_in_flight.lock().unwrap()을 잡아 HashMap을 조회한다.
배치 크기가 클수록 이 lock이 반복적으로 잡혀 contention 가능성이 있다.
{
let batch_map = batch_in_flight.lock().unwrap(); // ← 매 submission마다
if let Some((batch_count, _)) = batch_map.get(&batch_id) {
batch_count.fetch_add(1, Ordering::Relaxed);
}
}
개선 방향: submission 준비 단계에서 Arc<AtomicU64>를 미리 복사해두고
루프 내에서는 HashMap lock 없이 직접 atomic 카운터에 접근.
1-6. fixed buffer 등록은 구현되어 있으나 Python 쪽에서 실제로 호출되지 않음 (중간)
위치: lib.rs:1017-1069 (register_fixed_buffers), core.py 전체
register_fixed_buffers(buffer_ptrs, buffer_sizes) 메서드가 Rust에 구현되어 있으나
core.py의 _rawdev() 초기화 코드 어디에도 호출하지 않는다.
zero-copy 경로(WriteFixed/ReadFixed)는 fixed_buffer_idx가 Some일 때만 사용되므로
현재는 항상 일반 Write/Read opcode를 사용한다.
개선 방향: RawBlockCore._rawdev() 에서 L1 메모리 풀의 aligned 버퍼를
register_fixed_buffers로 미리 등록하면 io_uring 경로에서 실제 zero-copy가 된다.
단, 버퍼 주소가 고정되어야 하므로 L1 메모리 풀의 pin 정책과 연동 필요.
1-7. wait_iouring 내부의 busy-wait (중간)
위치: lib.rs:1319-1328
py.allow_threads(move || {
let mutex = Mutex::new(());
let mut guard = mutex.lock().unwrap();
while batch_count.load(Ordering::Relaxed) > 0 {
let (g, _) = batch_cvar
.wait_timeout(guard, Duration::from_micros(10)) // ← busy-wait
.unwrap();
guard = g;
}
});
매 10μs마다 wakeup되어 카운터를 확인한다. I/O가 진행 중인 동안 CPU를 낭비하는 구조.
개선 방향: condvar wait에 timeout 대신 wait()를 사용하고,
worker thread가 카운터가 0이 되는 시점에 batch_cvar.notify_all()로 깨우는 방식으로 전환.
(현재 notify_all은 이미 있으므로, wait_timeout → wait로만 변경하면 됨)
2. Python RawBlockCore
2-1. put_many: write마다 lock을 두 번 잡는 구조 (높음)
위치: core.py:463-517
for i, (key, obj) in enumerate(zip(keys, objs)):
with self._lock: # lock #1: 슬롯 할당
...
offset = self._allocate_slot_locked()
self._inflight[key.encoded] = _Inflight(...)
success = self._write_one(key, obj, offset) # I/O (lock 없이)
with self._lock: # lock #2: 인덱스 업데이트
...
self._index[key.encoded] = _Entry(...)
키 개수 N만큼 lock을 2N번 잡는다. 각 lock 구간이 짧아 contention은 낮지만 Python GIL 위에서 threading.Lock이 추가로 올라가므로 스레드가 많을수록 오버헤드가 누적된다.
개선 방향: 슬롯 할당을 배치로 처리 — 루프 전에 한 번에 N개 슬롯을 할당하고 I/O를 병렬로 실행한 뒤 인덱스를 한 번에 업데이트.
2-2. put_many: 키 단위 직렬 I/O (높음)
위치: core.py:494
success = self._write_one(key, obj, offset) # ← 순차 실행
N개의 키를 순서대로 한 건씩 쓴다. io_engine="io_uring" 설정 시
Rust의 batched_write를 사용하면 N개를 한번에 비동기로 제출할 수 있는데
현재는 그 API를 전혀 활용하지 않는다.
개선 방향:
io_engine == "io_uring"일 때, 슬롯 할당 후raw_dev.batched_write(offsets, bufs, lens)호출raw_dev.wait_iouring(batch_id)로 완료 대기- 결과 반영은 배치로
2-3. load_many_into: 키 단위 직렬 읽기 (높음)
위치: core.py:589-631
for i, (encoded_key, entry) in enumerate(items):
...
raw_dev.pread_into(entry.offset + self.header_bytes, buf, payload_len, total_len)
쓰기와 동일하게 읽기도 순차적이다. batched_read API가 있으나 미사용.
개선 방향: io_engine == "io_uring" 시 batched_read + wait_iouring 사용.
2-4. _snapshot_state: lock 보유 중 전체 _index 직렬화 (높음)
위치: core.py:1103-1145
def _snapshot_state(self):
with self._lock:
...
snapshot = {
...
"entries": {
encoded_key: {...}
for encoded_key, entry in self._index.items() # ← lock 보유 중 dict comprehension
},
}
키가 많을수록(HC-SSD에서는 수백만 개) lock을 오래 보유한다.
이 기간에 put_many와 load_many_into가 블로킹된다.
개선 방향: 스냅샷을 위해 _index의 shallow copy를 lock 안에서만 만들고
JSON 직렬화는 lock 밖에서 수행.
def _snapshot_state(self):
with self._lock:
dirty_total = self._meta_dirty_total
index_snapshot = dict(self._index) # shallow copy만 lock 안에서
# lock 밖에서 직렬화
snapshot = { ..., "entries": { ... for k, v in index_snapshot.items() } }
2-5. _free_slots가 Python list (중간)
위치: core.py:246, core.py:1007-1008, core.py:1021
self._free_slots: list[int] = []
...
return self._slot_to_offset(self._free_slots.pop()) # O(1)
...
if slot in self._free_slots: # ← O(N) 선형 탐색
return
self._free_slots.append(slot)
_append_free_slot_locked에서 중복 체크를 위해 slot in self._free_slots를 사용한다.
슬롯 수가 많을 때 O(N) 탐색.
개선 방향: _free_slots를 set으로 바꾸거나,
별도의 _free_slot_set: set[int]를 유지하여 중복 체크를 O(1)으로.
2-6. meta_verify_on_load: 초기화 시 전체 슬롯 헤더를 순차 읽기 (중간)
위치: core.py:1407-1450 (_validate_loaded_entries)
for encoded_key, entry in items:
slot_hdr = self._read_slot_header(int(entry.offset)) # ← 슬롯마다 1회 pread
HC-SSD에서 수십만 슬롯이 체크포인트에 있을 경우 초기화 시간이 수십 초~수 분으로 늘어날 수 있다.
개선 방향:
- 초기화 시
batched_read로 슬롯 헤더를 병렬 읽기 - 또는
meta_verify_on_load=False를 기본값으로 낮추고 별도 검증 커맨드 제공
2-7. 체크포인트 payload에 JSON 사용 (낮음)
위치: core.py:1214
payload = json.dumps(snapshot, separators=(",", ":"), ensure_ascii=True).encode("utf-8")
키 수가 많을수록 JSON 직렬화/역직렬화 비용이 선형으로 증가.
msgpack 또는 struct 기반 바이너리 포맷으로 바꾸면 크기와 속도 모두 개선된다.
3. L2 어댑터 레이어
3-1. store 워커가 put_many를 통째로 직렬 호출 (높음)
위치: raw_block_l2_adapter.py:639-640
def _run_store_task(self, keys, objects):
specs = [encode_object_key(key) for key in keys]
put_result = self._core.put_many(specs, objects) # 직렬
_store_pool의 기본 워커 수가 2인데, put_many 내부가 키 단위 직렬이므로
두 워커가 경쟁하면서 self._lock에서 충돌한다.
io_uring batched write를 put_many 내부에서 활용하면
워커 스레드 하나가 N개 I/O를 비동기로 날리고 기다리는 패턴이 되어
스레드 수 늘리기보다 효율적이다.
3-2. lookup 워커가 1개 (낮음)
위치: raw_block_l2_adapter.py:88 (기본값 num_lookup_workers=1)
_run_lookup_task는 dict 조회만 하므로 GIL 아래서 빠르지만,
다중 사용자가 동시에 lookup을 요청하면 큐에서 대기하게 된다.
4. 개선 우선순위 요약
| 우선순위 | 항목 | 예상 효과 |
|---|---|---|
| P0 | put_many / load_many_into → io_uring batched API 활용 | 쓰기·읽기 처리량 대폭 향상 |
| P0 | io_uring notify_one N→1 회 감소 (Rust batched_write) | CPU 오버헤드 감소 |
| P1 | wait_iouring timeout busy-wait → condvar.wait() | idle CPU 낭비 제거 |
| P1 | _snapshot_state lock 구간 축소 | 대용량 인덱스에서 I/O 블로킹 방지 |
| P1 | register_fixed_buffers 실제 호출 (zero-copy 활성화) | io_uring 경로 copy 1회 제거 |
| P2 | CQ drain Vec 복사 제거 (Rust) | 소규모 heap 할당 감소 |
| P2 | batch_in_flight HashMap lock → 미리 복사한 Arc 사용 | lock contention 감소 |
| P2 | _free_slots set으로 변경 | HC-SSD 대용량 시 O(N)→O(1) |
| P3 | _validate_loaded_entries batched read | 초기화 시간 단축 |
| P3 | 체크포인트 포맷 JSON → msgpack/binary | 직렬화 비용 감소 |