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_buffers를 100번 따로 호출하기 때문이다 (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 batch | 2 |
| put_many 전체 (100키) | 외부 루프 직렬 | 항상 ≤ 2 |
문제는 "batch 안 됨"이 아니라 **"100키를 처리하는 루프가 직렬이라 device 큐에 한 번에 2건씩만 쌓임"**이다.
Q4. Big ring vs Standard ring 차이
관련 섹션: §5.1 IoUringWrapper
차이는 SQE/CQE 자료구조의 크기 그 자체.
| SQE 크기 | CQE 크기 | 추가 가능 opcode | 커널 | |
|---|---|---|---|---|
| Standard | 64 B | 16 B | READ, WRITE, ... | 5.4+ |
| Big | 128 B | 32 B | + URING_CMD | 5.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_io는 Layer 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_CMDopcode를 쓰는 것. 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.md | Q3, Q6, Q8의 _put_many_batch_io 상세 |
| core.py | Python dispatcher 구현 |
| lib.rs | Rust io_uring 엔진 구현 |