본문으로 건너뛰기

PR #3274 Q&A — io_uring + uring_cmd 이해 노트

PR #3274 위키 문서 (PR-3274-io_uring-and-uring_cmd.md) 학습 중 나온 질문과 답변 정리. 개념 확인용 참고 문서.


Q1. posix / io_uring / io_uring_cmd 세 엔진은 어떻게 다른가

관련 섹션: §3 I/O 경로 비교, §4.1 dispatcher

세 축으로 비교: ① 동기/비동기, ② syscall 횟수, ③ 어느 계층까지 거치는가.

동기/비동기N건 syscall 횟수블록 레이어디바이스 노드
posix동기N번거침아무거나
io_uring비동기1번거침블록 디바이스
io_uring_cmd비동기1번건너뜀character device (/dev/ngXnY)

posix: pwrite() 한 번이 syscall 한 번. 호출 스레드가 완료까지 블록됨. 구현이 가장 단순.

io_uring: ring 인프라를 통해 N건을 SQ에 쌓고 submit() syscall 1번으로 커널에 넘김. 응답은 CQ에서 비동기 수거. 여전히 블록 레이어를 거침.

io_uring_cmd: 동일한 ring 인프라에 IORING_OP_URING_CMD opcode를 던짐. 커널이 NVMe 드라이버에 명령을 raw로 전달 → 블록 레이어 생략. /dev/ngXnY (NVMe namespace character device) 필요.

posix: 앱 → pwrite → VFS → 블록 레이어 → NVMe → SSD
io_uring: 앱 → SQ → submit() → 블록 레이어 → NVMe → SSD
io_uring_cmd: 앱 → SQ → submit() → NVMe (블록 레이어 없음) → SSD

Q2. "한 SQE = (offset, buffer, len) 한 쌍"은 NVMe 사양인가?

관련 섹션: §4.2 can_batch, §5.2 batched_write

아니다. io_uring SQE 사양이다.

io_uring SQE는 64바이트 고정 구조체로, (opcode, fd, offset, addr, len, ...) 한 쌍만 담는다. 이건 io_uring이 정한 자료구조 형식.

NVMe 사양은 오히려 더 유연하다. PRP(Physical Region Page) 또는 SGL(Scatter-Gather List)로 흩어진 여러 페이지를 한 명령에 담을 수 있다.

batched_write(N)의 실제 동작 (lib.rs:1975):

SQE₁ (offset₁, buf₁, len₁) ──┐
SQE₂ (offset₂, buf₂, len₂) ──┼── submit() 1번 ──► 커널 → NVMe 명령 N개 큐잉
SQE₃ (offset₃, buf₃, len₃) ──┘

한 NVMe 명령에 N개 버퍼를 넣는 게 아니라, N개의 SQE(= N개의 io_uring 요청)를 한 번의 syscall로 제출하는 것.


Q3. 호출당 N의 의미 — can_batch=True면 실제로 batch가 동작하나?

관련 섹션: §4.2 can_batch, §4.3 호출자별 현황

한 번의 _write_buffers() 호출에 리스트 길이 N으로 몇 건의 I/O를 넘기느냐가 "호출당 N".

can_batch=True이면 그 N개는 진짜로 한 번의 batched_write로 묶인다. 슬롯 write의 경우 N=2 (헤더+페이로드)가 한 번의 submit syscall로 들어간다. batch는 동작한다.

그런데 왜 "device 큐를 못 채운다"는 말이 나오나?

put_many가 키를 100개 쓸 때 _write_buffers100번 따로 호출하기 때문이다 (core.py:1350-1396):

for key, obj in zip(keys, objs): # 100번 반복
_write_one(...) # 호출마다
_write_buffers([N=2]) # 2 SQE batch
wait_for_completion() # 완료 대기
# 다음 키로
구간batch 동작device queue depth
한 슬롯 내부 (헤더+페이로드)✅ N=2 batch2
put_many 전체 (100키)외부 루프 직렬항상 ≤ 2

문제는 "batch 안 됨"이 아니라 **"100키를 처리하는 루프가 직렬이라 device 큐에 한 번에 2건씩만 쌓임"**이다.


Q4. Big ring vs Standard ring 차이

관련 섹션: §5.1 IoUringWrapper

차이는 SQE/CQE 자료구조의 크기 그 자체.

SQE 크기CQE 크기추가 가능 opcode커널
Standard64 B16 BREAD, WRITE, ...5.4+
Big128 B32 B+ URING_CMD5.19+

일반 R/W opcode는 SQE 64B로 충분하다. 그런데 URING_CMD는 NVMe 명령 본체(80B)를 SQE 안에 inline으로 박아 넣어야 해서 64B에 안 들어간다. 5.19에서 128B SQE 옵션이 추가됐고, 이를 Big ring이라 부른다.

Big ring은 일반 READ/WRITE도 지원한다. 한 ring으로 둘 다 커버 가능해서 이 PR이 Big을 우선 시도하는 이유가 여기 있다.

초기화 로직 (lib.rs:1055-1106):

match IoUring::<Entry128, Entry32>::builder().build(...) {
Ok(big) => IoUringWrapper::Big(big), // 5.19+: cmd 포함 모든 기능
Err(_) => {
if use_uring_cmd { return Err("5.19 required"); }
IoUringWrapper::Standard(std_ring) // fallback: 일반 R/W만
}
}

Q5. NVMe NCQ는 모든 NVMe에서 지원되나?

관련 섹션: §5.2 batched_write

용어 정정: NCQ는 SATA 용어다. NVMe에는 "NCQ"라는 별도 기능이 없고, multi-queue 아키텍처가 NVMe 사양 자체에 내장되어 있다.

NVMe 사양이 의무화한 것:

  • 여러 개의 Submission Queue (최대 64K개)
  • 큐 깊이 (수십~수K entries)
  • 컨트롤러가 여러 큐의 명령을 동시에 fetch해서 처리

모든 NVMe SSD는 명령 큐잉과 병렬 처리를 지원한다. 사양상 의무라 안 되면 NVMe가 아님.

다만 "지원함" ≠ "다 똑같이 잘 함":

요소영향
NAND 채널 수엔터프라이즈 16-32채널, 소비자 4-8채널
큐 깊이 포화점보통 32까지 선형, 64+ 부터 효과 둔화 디바이스 多
컨트롤러 SoC명령 dispatch 속도

코드 입장에서는 **"명령을 많이 쌓아주면 디바이스가 병렬화한다"**는 가정이 안전하다. 얼마나 빨라지느냐는 디바이스마다 다름.


Q6. raw-block-put-many-write-path가 §8 제약 2번을 해결하는가?

관련 섹션: §8 현재 제약, put_many 쓰기 경로 최적화

§8 제약 2번: "MP 모드의 write 배치 — 헤더와 페이로드 단위까지만 묶음 (요청 순서 보장이 필요해 추가 작업으로 미룸)"

이 제약에는 두 layer 문제가 섞여 있다:

Layer문제
A — core 단put_many가 키마다 _write_buffers를 따로 호출 → device 큐에 2건씩만
B — MP 단MP worker 요청을 batch 묶을 때 쓰기 ordering 보장 필요 → 미해결

raw-block-put-many-write-path.md_put_many_batch_ioLayer A를 해결한다 (core.py:1245-1348):

이전: for key in 100키 → _write_buffers([2개]) × 100번
이후: _put_many_batch_io → _write_buffers([200개]) × 1번

200개 SQE가 한 번에 submit되어 device 큐가 채워진다.

Layer B(MP ordering)는 별개 작업으로 남아있다. §8 제약 2번이 완전히 사라지는 게 아니라 Layer A 부분만 해소된다.

문서의 시뮬레이션 결과 "N=100, 지연 50µs에서 −28%"도 정확히 이 Layer A 효과.


Q7. io_uring과 io_uring_cmd의 관계

관련 섹션: §3 I/O 경로 비교, §5.1 IoUringWrapper

"io_uring은 ring 인프라를 쓰겠다는 의미이고, io_uring_cmd는 거기에 passthrough까지 쓰겠다는 뜻인가?"

거의 정확하다. 한 단계 더 정밀하게:

io_uring (ring 인프라)
├─ IORING_OP_READ / WRITE → 일반 비동기 R/W (블록 레이어 거침)
├─ IORING_OP_FSYNC → sync
├─ IORING_OP_ACCEPT / RECV → 네트워크
├─ IORING_OP_URING_CMD → 드라이버에 raw 명령 전달 ← io_uring_cmd
│ └─ NVMe 드라이버 handler → NVMe passthrough
└─ ...
  • io_uring = 커널의 비동기 I/O ring 인프라 자체. opcode는 별개.
  • io_uring_cmd = 그 ring 위에서 IORING_OP_URING_CMD opcode를 쓰는 것. NVMe 전용이 아니라 드라이버가 handler를 등록한 디바이스면 모두 가능.

후자는 전자 없이 못 쓴다. use_uring_cmd=True가 자동으로 use_iouring=True를 강제하는 이유 (lib.rs:978-981):

if use_uring_cmd && !use_iouring {
return Err("use_uring_cmd requires use_iouring to be enabled");
}

한 줄 비유: io_uring은 "통로(ring)", io_uring_cmd는 "그 통로에 던지는 특수 봉투(opcode)". 봉투를 쓰려면 통로가 있어야 하고, 봉투 내용(NVMe 명령 80B)이 일반 봉투(64B SQE)보다 커서 "큰 봉투(Big SQE 128B)"도 필요.


Q8. 왜 put_many는 처음부터 키마다 직렬로 호출하게 짜였나?

관련 섹션: put_many 쓰기 경로 최적화 §1~§3

세 가지 이유가 겹쳐 있다.

이유 1: 처음엔 io_uring이 없었다 — thread pool이 병렬성을 담당

POSIX 기반일 때 put_many의 병렬성은 바깥 thread pool에서 왔다:

Worker thread 1 → put_many([key₁~₃]) 직렬 루프
Worker thread 2 → put_many([key₄~₆]) 직렬 루프
Worker thread 3 → put_many([key₇~₉]) 직렬 루프

디바이스: 세 스레드의 pwrite가 OS 스케줄러에 의해 겹쳐져 device queue 채워짐

put_many 내부가 직렬이어도 괜찮았다. 병렬성이 바깥에서 오니까.

이유 2: offset 의존성 — "어디에 쓸지 알아야 쓸 수 있다"

# ① 슬롯 할당 (락, 빠름)
with lock:
offset = allocate_slot()
inflight[key] = offset

# ② 디스크 쓰기 (락 없음, 느림)
write_one(offset, ...) # ①의 offset이 있어야 실행 가능

# ③ commit (락, 빠름)
with lock:
index[key] = offset

②는 ①의 결과에 의존한다. 직렬 루프는 이 의존성을 자연스럽게 해결한다. N개를 한꺼번에 쓰려면 N개 offset을 먼저 전부 할당한 뒤 쓰는 순서 변경이 필요한데, 초기 설계에서는 그 추가 복잡도를 감수할 이유가 없었다.

이유 3: crash consistency가 단순해진다

직렬 루프의 crash safety는 분석이 쉽다:

key₁: 할당→쓰기→commit 완료
key₂: 할당→쓰기→commit 완료
key₃: 할당→쓰기→ ← crash

key₃는 commit이 안 됐으니 index에 없다 → 재시작 시 없는 것처럼 동작. 자동으로 per-key atomic.

N개 batch라면:

key₁~₁₀₀: 할당 N개 → 쓰기 N개 → ← crash

어느 키가 실제로 써졌는지 알려면 헤더를 일일이 읽어 확인해야 한다. 복구 로직이 복잡해진다.

io_uring 도입이 전제를 뒤집었다

io_uring은 single-issuer 모델에서 효율적이다. thread pool fanout + io_uring은 ring에 대한 lock contention으로 오히려 직렬화된다.

반면 한 스레드가 N건을 한 번에 제출하면:

single thread + batched_write(200):
200 SQE → submit() 1번 → device queue에 200건
→ NVMe 컨트롤러가 NAND 채널 전부 활용

**"io_uring의 이점을 제대로 보려면 put_many 내부 루프 자체를 batch로 바꿔야 한다"**는 새로운 전제가 생겼고, 이게 _put_many_batch_io가 필요해진 이유다.

_put_many_batch_io는 이유 2의 offset 의존성을 Phase A(N개 슬롯 한꺼번에 할당)로 풀고, 이유 3의 crash consistency를 all-or-nothing 시맨틱으로 명시했다.


참고 문서

문서내용
PR-3274-io_uring-and-uring_cmd.md이 Q&A의 원본 위키. PR 전체 구조
raw-block-put-many-write-path.mdQ3, Q6, Q8의 _put_many_batch_io 상세
core.pyPython dispatcher 구현
lib.rsRust io_uring 엔진 구현