본문으로 건너뛰기

raw_block put_many — io_uring 단일 제출 배칭 (core)

TL;DRput_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 SQE2N 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_iouring 1회. (배칭의 핵심 목표를 직접 단언)
  • 헤더 버퍼 길이 가드: 각 버퍼 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_write 1회·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_lock 1회. (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_many vs _put_many_batch_io wall-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. 참고