raw_block put_many — io_uring 단일 제출 배칭 (core)
TL;DR —
put_many가 여러 키를 쓸 때 키마다 io_uring submit + 완료 대기를 반복하던 것을, io_uring 환경에서 N개 키의 헤더+페이로드 2N 버퍼를 한 번의batched_write로 제출하는 경로(_put_many_batch_io)로 바꿨다. 디바이스가 N건을 병렬 처리(NVMe NCQ)할 수 있게 된다. 이 변경은core.py단독이며 호출부(plugin) 변경은 없다 — 전제 인프라 PR #3274가 이미 io_uring+N>1을 단일put_many호출로 모아주기 때문이다. 실패는 all-or-nothing이고, 버퍼 준비 단계까지 롤백 보증한다.관련 문서: raw_block put_many 쓰기 경로 최적화 — 동일 아이디어를 plugin dedup/자원정리까지 함께 묶은 통합본. 본 문서는 그 중 core 배칭만 분리해 정제한 버전이며, plugin 개선은 별도 후속으로 분리한다.
| 항목 | 값 |
|---|---|
| 종류 | Performance optimization — put_many 내부 병렬 I/O |
| 영역 | lmcache/v1/storage_backend/raw_block/core.py (단독) |
| 인터페이스 변경 | 없음 (put_many 반환 타입 동일) |
| 분기 조건 | io_engine == "io_uring" AND len(keys) > 1 |
| 전제 | PR #3274 — io_uring + uring_cmd |
| 상태 | 구현 완료, 실 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은 io_uring 경로에서 헤더+페이로드 2 entry짜리 batched_write를 한 번
발행한다. 즉 한 슬롯의 submit은 1회로 묶이지만, put_many가 키마다 _write_buffers를
따로 호출하기 때문에 키 N개를 쓰면 device 큐에는 매번 2개씩만 쌓였다 빠진다 — SQ가
여러 쓰기를 동시에 in-flight로 들고 있을 수 있는 능력을 전혀 안 쓴다.
2. 변경 — _put_many_batch_io (core 단독)
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 write_plan으로 2N개 버퍼 리스트(헤더+페이로드 × N) 구성
Phase C (락 밖) _write_buffers(2N) 1회 호출
→ batched_write(2N) → 한 번의 io_uring_submit → NCQ 병렬 → wait 1회
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 한 번에 |
wait_iouring | 키마다 (N회) | 1회 |
| commit 락 | 키마다 (N회) | 1회 |
헤더는
block_align배수로 강제되어 io_uring 경로에서 페이로드와 함께 묶인다. 즉 2N개 entry(헤더 N + 페이로드 N)가 한 번의batched_write로 제출되어 device 큐를 채우는 것이 핵심이다.
2.1 plugin은 왜 안 건드리나
호출부(plugin rust_raw_block_backend.py)는 PR #3274에서 이미 io_uring + N>1이면 N개
키를 단일 core.put_many(N) 호출로 모아주는 라우팅을 갖고 있다. 따라서 core가 그 N개를
실제로 병렬 I/O로 처리하도록 바꾸기만 하면 배칭 이득이 완성된다 — plugin 변경 불필요.
(plugin 측 dedup 비용 절감·자원 정리 같은 직교 개선은 별도 후속 PR로 분리한다. §5)
3. 실패 처리 — all-or-nothing (버퍼 준비까지 롤백 보증)
_put_many_batch_io는 단일 io_uring_submit이라 키별 부분 성공을 보고할 수 없다.
그래서 결합된 쓰기가 실패하면 batch의 모든 슬롯을 free list로 반환하고 아무 키도
commit하지 않는다.
중요한 보증: 실패 범위는 실제 쓰기뿐 아니라 버퍼 준비(Phase B)까지 포함한다. 헤더
인코딩이나 페이로드 준비에서 예외가 나도 Phase A에서 잡아둔 inflight 엔트리·할당 슬롯이
전부 롤백된다. 구조는 write_succeeded 플래그 + 단일 commit/rollback 루프로, 성공
경로와 실패 경로가 한 군데에서 갈린다(롤백 코드 중복 없음). in-flight I/O 카운터 증감은
실제 쓰기에만 묶여 있어, 버퍼 준비가 먼저 실패해도 카운터가 어긋나지 않는다.
기존 순차 put_many 시맨틱은 그대로 보존한다:
- 이미 index에 있는 키 → 다시 쓰지 않고 성공으로 보고
- 빈 슬롯이 없는 키 → 그 키만 개별 실패(나머지 키는 정상 배칭·commit)
- 결과 순서 = 입력 키 순서
4. 검증 — fake io_uring device 단위 테스트
io_uring 실장(실 NVMe/커널) 없이 계약을 직접 검증하기 위해, Rust raw-device의
batched/posix 인터페이스를 흉내 내는 fake device로 테스트한다(test_raw_block_core.py).
CI의 전용 raw-block 잡이 이 파일을 실행하므로 계약이 회귀로부터 보호된다.
- 단일 제출 계약: N=10 put_many →
batched_write정확히 1회, 엔트리 2N=20개,wait_iouring1회. (배칭의 핵심 목표를 직접 단언) - 헤더 버퍼 길이 가드: 각 버퍼 byte 길이 ≥ total_len (정렬 padding 후 OOB 방지)
- round-trip: N=10 write → load 전부 일치
- already-indexed skip: 중복 5 + 신규 5 → 신규 5만 stored
- all-or-nothing 롤백:
batched_write예외 주입 → 전 키 실패, 슬롯 전량 복원, inflight 비움 - 부분 슬롯 소진: 슬롯 3개만 두고 5키 → 앞 3개 commit·뒤 2개 개별 실패,
batched_write1회·6엔트리, 가용 슬롯 0 (개별-실패 시맨틱 단언) - posix 회귀 가드: posix 엔진은
batched_write미사용·키별 pwrite 유지 확인
⚠️ fake device는 "N건을 한 번에 제출·완료"로 모델링하므로, 이 테스트가 증명하는 것은 배칭 경로의 구조(2N SQE를 한 번에 제출)와 정합성이다. 실 NVMe에서 NCQ가 실제로 병렬화하는지는 증명하지 못한다 — §6 실측 계획 참조.
5. 후속 — plugin 개선 (별도 PR)
호출부(plugin batched_submit_put_task)의 다음 개선은 core 배칭과 직교하므로 별도
PR로 분리한다(정확성 수정이 아니라 비용 절감/자원 정리):
- dedup 일괄화: 키마다
contains_key(FFI)+_put_lock반복 →exists_many(배치 1회)_put_lock1회. (core.put_many가 이미 멱등이라 stale해도 중복 write는 없음 — best-effort 최적화)
- dispatch 실패 시 ref/inflight 정리: N개 객체 ref 상향 후 background task 띄우는 사이 예외(주로 셧다운 레이스) 시 올린 만큼만 되돌리고 키 집합 정리.
6. 미해결 / 실측 계획
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%면 구조는 맞으나 실효 없음으로 본다.
- 큰 배치(N≥1000)의 Phase A 단일 락 슬롯 할당 병목 여지 — 별도 최적화 대상.
- 커맨드 coalescing 후속: batch io는 submit/wait만 1회로 묶었고 디바이스 커맨드는 여전히
2N개다. 키 내부 헤더+페이로드를 단일 벡터드 커맨드로 합치는 Tier 1 계획 →
../raw_block/io_uring/iouring-batch-coalesce-writev-plan.md.
7. 참고
- 전제 인프라: PR #3274 — io_uring + uring_cmd
- 통합본(plugin 포함): raw_block put_many 쓰기 경로 최적화
- 분기 조건:
io_engine == "io_uring"ANDlen(keys) > 1(그 외는 기존 경로) - 인터페이스 무변경 → 운영 중 revert 시 마이그레이션 불필요