본문으로 건너뛰기

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.pyput_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-uring crate 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_uringIORING_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_idxSome일 때만 사용되므로 현재는 항상 일반 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를 전혀 활용하지 않는다.

개선 방향:

  1. io_engine == "io_uring" 일 때, 슬롯 할당 후 raw_dev.batched_write(offsets, bufs, lens) 호출
  2. raw_dev.wait_iouring(batch_id) 로 완료 대기
  3. 결과 반영은 배치로

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_manyload_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_slotsset으로 바꾸거나, 별도의 _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. 개선 우선순위 요약

우선순위항목예상 효과
P0put_many / load_many_into → io_uring batched API 활용쓰기·읽기 처리량 대폭 향상
P0io_uring notify_one N→1 회 감소 (Rust batched_write)CPU 오버헤드 감소
P1wait_iouring timeout busy-wait → condvar.wait()idle CPU 낭비 제거
P1_snapshot_state lock 구간 축소대용량 인덱스에서 I/O 블로킹 방지
P1register_fixed_buffers 실제 호출 (zero-copy 활성화)io_uring 경로 copy 1회 제거
P2CQ drain Vec 복사 제거 (Rust)소규모 heap 할당 감소
P2batch_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직렬화 비용 감소