raw_block put_many 쓰기 경로 최적화
TL;DR —
put_many가 여러 키를 쓸 때 키마다 개별 I/O 제출 + 완료 대기를 반복하던 것을, io_uring 환경에서 2N개 버퍼를 한 번의batched_write로 제출하는 경로(_put_many_batch_io)로 바꿨다. 디바이스가 N건을 병렬 처리(NVMe NCQ)할 수 있게 된다. 시뮬레이션상 N=100·지연 50µs 구간에서 fanout 대비 약 −28%. 단, 시뮬레이션이 NCQ 병렬성을 모델 가정으로 깔고 있어 실 NVMe 측정이 전제다. 함께, 호출부(plugin)의 dedup 경합·자원 누수 버그도 수정했다.전제 인프라: raw_block io_uring I/O 인프라
| 항목 | 값 |
|---|---|
| 종류 | Performance optimization + correctness fix |
| 영역 | lmcache/v1/storage_backend/raw_block/core.py (perf), .../plugins/rust_raw_block_backend.py (fix) |
| 인터페이스 변경 | 없음 (put_many 반환 타입 동일) |
| 분기 조건 | io_engine == "io_uring" AND len(keys) > 1 |
| 상태 | 구현 완료, 실 NVMe 실측 미검증 |
1. 배경 — put_many 쓰기 루프
put_many(keys, objs)는 키마다 아래 3단계를 직렬로 반복한다.
for key, obj in zip(keys, objs):
with lock: # ① 슬롯 할당 + inflight 등록
offset = allocate_slot()
inflight[key] = ...
write_one(offset, key, obj) # ② 헤더+페이로드 I/O (락 밖)
with lock: # ③ index에 commit
index[key] = ...
②의 write_one은 인프라 문서 §3에서 본 대로 슬롯
헤더 정렬 불일치 때문에 can_batch=False → io_uring에서도 **키당 write_uring 2회
- 완료 대기**다. 즉 키 N개를 써도 device에는 한 번에 명령이 2개씩만 쌓인다.
2. 문제 — dispatch만 묶어선 부족하다
호출부(plugin)에서 N개 키를 1개의 background task로 묶어 put_many를 한 번 호출하면
asyncio dispatch 비용은 줄어든다. 하지만 put_many 내부가 직렬이면 이득이
상쇄된다.
비교 대상이 되는 기존 동작은 키마다 별도 task를 띄우는 fanout이다. fanout은 N개 task가 thread pool에서 동시에 I/O를 돌린다. 반면 batch는 1개 task가 N건을 순차로 돈다.
→ 실제 I/O 지연이 있으면 fanout의 thread pool 병렬성이 batch의 dispatch 절약을 압도한다. 측정해 보면 batch로 묶기만 한 경우 오히려 느려진다(regression):
| N | 지연 | fanout | batch(순차) | 결과 |
|---|---|---|---|---|
| 100 | 50µs | 22.7ms | 27.0ms | batch가 +19% 느림 |
| 100 | 200µs | 43.2ms | 58.0ms | batch가 +34% 느림 |
dispatch를 줄이는 것만으로는 안 되고, put_many 내부의 직렬 I/O 자체를 병렬로
바꿔야 한다.
3. 해결 — _put_many_batch_io
put_many에 분기를 추가한다.
def put_many(self, keys, objs):
if self.io_engine == "io_uring" and len(keys) > 1:
return self._put_many_batch_io(keys, objs)
# 기존 순차 루프 (posix 또는 단일 키) — 변경 없음
...
POSIX는 thread pool 병렬성이 이미 잘 작동하므로 분기하지 않는다. io_uring + 다중 키일 때만 새 경로를 탄다.
_put_many_batch_io는 4단계다.
Phase A (락 1회) N개 키 dedup → N개 슬롯 할당 → inflight N개 등록 → write_plan 구성
Phase B+C write_plan으로 2N개 버퍼 리스트(헤더+페이로드 × N) 구성
→ _write_buffers(2N) 1회 호출
→ can_batch면 batched_write(2N) → 한 번의 io_uring_submit → NCQ 병렬
Phase D (락 1회) 기록 성공한 키를 index에 일괄 commit, inflight 정리
핵심은 Phase B+C에서 _write_buffers를 단 한 번 호출하는 것이다. 키마다
부르던 것을 2N개 항목 한 리스트로 모으면, io_uring이 2N개 SQE를 한 번에 제출하고
디바이스가 병렬 처리한다.
기존 대비:
| 기존 순차 루프 | _put_many_batch_io | |
|---|---|---|
| 슬롯 할당 락 | 키마다 (N회) | 1회 |
_write_buffers 호출 | 키마다 (N회) | 1회 |
| io_uring 제출 | 키마다 2 SQE | 2N SQE 한 번에 |
| commit 락 | 키마다 (N회) | 1회 |
실패 처리는 all-or-nothing이다. _write_buffers가 예외를 던지면 batch의 모든
슬롯을 free list로 반환하고 아무 키도 commit하지 않는다. 이미 index에 있는 키는
쓰지 않고 성공으로 보고한다(기존 시맨틱 유지).
헤더(32B)는 여전히
can_batch조건을 못 맞춰 페이로드와 따로 제출될 수 있다. 그래도 N개 페이로드(수십~수백 KB)가 한 번에 제출되므로 device 큐가 채워지는 효과가 핵심이다.
4. 검증
4.1 시뮬레이션 결과
fake device로 4가지 패턴을 비교했다. fake device의 batched_write는 "N건을 한 번에
제출하고 한 번의 지연으로 모두 완료"하도록 모델링했다.
패턴은 셋이다: fanout(키마다 task, thread pool 병렬), batch/posix(1 task로
묶되 내부 순차), batch+io_uring(_put_many_batch_io, batched_write로 N건 한 번에).
obj=64B (음수 = batch+io_uring이 fanout보다 빠름)
N 지연 fanout batch/posix batch+io_uring vs fanout
100 0µs 6.96ms 0.92ms 0.92ms -86.8% ✓
100 50µs 22.67ms 27.04ms 16.24ms -28.4% ✓
100 200µs 43.20ms 57.95ms 31.16ms -27.9% ✓
1000 50µs 321.6 ms 265.8 ms 164.1 ms -49.0% ✓
1000 200µs 232.5 ms 579.6 ms 309.6 ms +33.2%
- N=100, 지연 50µs(NVMe 현실 구간): batch+io_uring이 fanout 대비 −28%. dispatch만 묶은 batch/posix는 +19% regression이었던 것을 뒤집는다.
- N=1000, 지연 200µs: batch+io_uring이 +33% 느림. Phase A의 단일 락 구간에서 1000개 슬롯을 순차 할당하는 부분이 병목 — 큰 배치에서는 Phase A 최적화 여지가 있다.
4.2 ⚠️ 시뮬레이션의 한계 (반드시 인지)
이 측정은 NCQ 병렬성을 모델 가정으로 박아넣었다:
def batched_write(self, offsets, buffers, total_lens):
if latency > 0:
time.sleep(latency) # N건을 한 번의 지연으로 완료 = "완벽 병렬" 가정
...
즉 "batch가 NCQ로 빨라진다"는 결론을 전제로 깔고 측정한 것이라, 이 벤치마크가 증명하는 것은 제한적이다:
- ✅ batch 경로의 구조가 N-SQE를 한 번에 제출할 수 있는 형태임
- ✅ dispatch/락 오버헤드 측면에서 batch가 유리할 여지
- ❌ 실제 NVMe에서 NCQ가 정말 병렬화하는지 — 증명 못 함
검증되지 않은 실제 변수: io_uring_submit 오버헤드, 2N 버퍼 준비 비용, 디바이스
큐 깊이·NAND 채널 수에 따른 실 병렬도, wait_iouring 완료 수거 비용.
4.3 실측 계획
io_engine="io_uring"을 실제로 태우려면 실 NVMe 디바이스 + io_uring 5.19+ 커널이
필요하다. 측정 항목:
- 순차
put_manyvs_put_many_batch_iowall-clock (N=10/100/1000, payload 4KB~256KB) iostat로 디바이스 평균 큐 깊이(QD) 동시 관찰 — batch에서 QD가 올라가면 NCQ 활용의 직접 증거- 판정: N=100에서 순차 대비 >10% 빠르고 QD 상승 확인 시 의미 있음. 차이 <5%면 구조는 맞으나 실효 없음으로 본다.
4.4 correctness 테스트
POSIX core에서 _put_many_batch_io를 직접 호출해 슬롯 할당/commit 정합성을 검증
(io_uring 실장 비의존):
- round-trip: N=10 write → load 일치
- 이미 index된 키 skip: 중복 5 + 신규 5 → 신규 5만 stored
- 쓰기 실패 롤백:
_write_buffers예외 시 모든 슬롯 반환, inflight 비움
5. 함께 수정한 correctness 버그 (호출부)
다중 키 쓰기 진입점(plugin의 batched_submit_put_task)에서 두 버그를 같이 고쳤다.
이쪽은 성능이 아니라 정확성 수정이고, put_many 최적화와 독립적인 별도 변경이다.
5.1 dedup TOCTOU 경합
키 중복 필터링 시 "이미 저장됐는지" 조회를 락 밖에서 했다. 조회와 in-flight 집합 등록 사이에 다른 스레드가 같은 키를 commit하면, stale한 "없음" 결과로 같은 키가 두 번 받아들여질 수 있었다. → 조회를 락 안으로 옮겨 비트맵 조회와 집합 등록을 한 critical section으로 묶었다.
5.2 dispatch 실패 시 자원 누수
N개 객체의 ref count를 먼저 올린 뒤 background task를 띄우는데, 그 사이 예외(loop
중단 등)가 나면 ref count가 올라간 채로, 키가 in-flight 집합에 영구히 남았다.
→ try/except로 감싸 실패 시 올린 만큼만 되돌리고 집합에서 키를 정리한다.
6. 참고
- 전제 인프라: raw_block io_uring I/O 인프라
- 분기 조건:
io_engine == "io_uring"ANDlen(keys) > 1(그 외는 기존 경로) - 인터페이스 무변경 → 운영 중 revert 시 마이그레이션 불필요
- 미해결: 실 NVMe 실측, 큰 배치(N≥1000)의 Phase A 슬롯 할당 병목