io_uring 사용 분석 (PR #3274 post-merge 가정)
이 노트의 위치: raw_block_line.md §L4 의 io_uring 분석은 PR #3274 머지 전 상태. 본 노트는 PR #3274 가 머지된다는 가정에서 io_uring 라인 전체를 다시 본 결과. PR 의 변경/미변경 매핑은 raw-block-perf-findings_vs_pr3274.md 가 이미 끝낸 작업이라 본 노트는 "머지 후 io_uring 이 어떻게 동작하는가" 에 집중. perf 항목별 우선순위는 vs_pr3274 참조.
라인 번호 주의: PR #3274 가 dev 에 미머지 상태라 본 노트의 post-merge 라인 번호는 diff 적용 가정의 추정값이다. 실제 머지 후 패치된 라인으로 갱신 필요. "현 dev 라인" 은 eaa2bfee 기준 정확값.
0. 한 눈에 보는 변경 지도
┌─────────────────────── Python (lmcache/v1/) ────────────────────────┐
│ │
│ L2Adapter / Plugin │
│ ├─ RawBlockL2AdapterConfig: +use_uring_cmd, +max_data_transfer │
│ ├─ RawBlockL2Adapter: io_uring 인 경우 l1_align ≥ block_align 강제 │
│ │ MP 경로는 fixed buffer 미등록 경고만 │
│ └─ RustRawBlockBackend: io_uring 이면 register_fixed_buffers_from_ │
│ allocator 자동 호출 + _submit_put_many │
│ │
│ RawBlockCore (core.py) │
│ ├─ NEW: _write_buffers / _read_buffers ← 모든 I/O 단일 진입점 │
│ ├─ NEW: _write_uring_cmd_buffers / _read_uring_cmd_buffers │
│ ├─ NEW: register_fixed_buffers_from_allocator │
│ ├─ NEW: _resolve_max_data_transfer_size (sysfs MDTS 자동 검출) │
│ └─ MOD: _write_one / _validate_loaded_entries / 체크포인트 R/W │
│ → 모두 _write_buffers/_read_buffers 경유 │
└─────────────────────────────┬───────────────────────────────────────┘
│ PyO3
┌─────────────────────────────▼───────────────────────────────────────┐
│ rust/raw_block/src/lib.rs │
│ │
│ NEW: IoUringWrapper { Standard | Big } │
│ ├─ Big = IoUring<Entry128, Entry32> (kernel 5.19+) │
│ └─ Standard = IoUring<SqueueEntry, Entry> (fallback) │
│ │
│ NEW: NvmeCmdData { nsid, lba_shift, dtype, dspec } │
│ NEW: nvme_identify_ns / nvme_get_nsid_from_fd / nvme_get_lba_* │
│ NEW: nvme_uring_cmd_prep │
│ │
│ REFACTOR: build_and_submit_sqe / handle_completion_result / │
│ decrement_in_flight / copy_from_bounce_buffer │
│ (워커 루프 4곳 중복 제거) │
│ │
│ NEW: write_uring (sync io_uring write — 체크포인트용) │
│ MOD: register_fixed_buffers — IoUringWrapper 분기 │
│ MOD: RawBlockDevice.new — use_uring_cmd 인자 + char dev 검증 │
└─────────────────────────────────────────────────────────────────────┘
한 줄 요약:
- io_uring 경로 자체는 정리/리팩토링 (워커 루프 헬퍼 추출, ring wrapper, fixed buffer 자동 등록).
- uring_cmd (NVMe passthru) 경로 신설 — 기존 io_uring 와 직교한 두 번째 모드. char device + kernel 5.19+ 필수.
- 새 단일 dispatcher (
_write_buffers/_read_buffers) 로 Python 측 호출자가 모드별 분기에서 해방됨. 단 호출자 다수가 길이 1 list 만 보내서 batched_* 의 의미는 못 살림 (vs_pr3274 §2.4 🆕 신규 이슈).
1. Rust io_uring 엔진 (rust/raw_block/src/lib.rs)
1.1 IoUringWrapper — Standard / Big 듀얼 ring
PR 이 도입한 가장 큰 구조 변경. 기존엔 Arc<Mutex<IoUring>> (단일 타입) 이었는데
이제 IoUringWrapper { Standard(...) | Big(...) } 두 분기.
// post-merge 추정 위치 (rust.diff:14-52)
enum IoUringWrapper {
Standard(Arc<Mutex<IoUring<SqueueEntry, Entry>>>), // SQE 64B / CQE 16B
Big(Arc<Mutex<IoUring<Entry128, Entry32>>>), // SQE 128B / CQE 32B
}
| 항목 | Standard | Big |
|---|---|---|
| SQE 크기 | 64 B (SqueueEntry) | 128 B (Entry128) |
| CQE 크기 | 16 B (Entry) | 32 B (Entry32) |
| 메모리 | qd=256 → SQ 16KB / CQ 4KB | SQ 32KB / CQ 8KB (2배) |
| 지원 op | Read/Write/ReadFixed/WriteFixed | + UringCmd80 (NVMe passthru) |
| 커널 요구 | 5.4+ | 5.19+ (Entry128 ABI 도입) |
선택 로직 (rust.diff:493-524, 추정 lib.rs:805~830 부근):
let ring = match IoUring::<Entry128, Entry32>::builder()
.build(iouring_queue_depth as u32)
{
Ok(big_ring) => IoUringWrapper::Big(...), // 5.19+ → 무조건 Big
Err(_) => {
if use_uring_cmd { return Err("kernel 5.19+ required") }
IoUringWrapper::Standard(...) // 5.4-5.18 fallback
}
};
- 왜 Big 을 우선 시도? uring_cmd 가 128B SQE 를 요구 → 같은 코드 베이스로 두 모드 다 지원하려면 ring 을 Big 으로 만들어야 cmd 경로도 push 가능.
- 부수 효과 —
use_uring_cmd=False여도 5.19+ 환경이면 자동으로 Big 경로. 일반 read/write 도 SQE/CQE 두 배 메모리 사용. perf-findings 에 없던 새 트레이드오프 (vs_pr3274 §2.4 🆕). - 일반 Read/Write 를 Big 으로 push 할 때는
let sqe128: Entry128 = sqe.into();로 Entry → Entry128 자동 변환 (rust.diff:732).
1.2 워커 루프 — 4가지 헬퍼로 분해
기존 (eaa2bfee): 워커 스레드가 (a) 정상 완료 (b) 정상 종료 시 잔여 완료 (c) shutdown 시 큐 정리 (d) 잔여 in-flight 정리 — 네 곳에서 동일한 코드 (CQE 처리 + bounce copy + in_flight 카운터 감소 + batch 카운터 감소) 가 복붙.
PR 후: 4개 헬퍼 추출 (rust.diff:541-746).
| 헬퍼 | 역할 | 위치 추정 |
|---|---|---|
copy_from_bounce_buffer | bounce → original 메모리 복사 (memcpy) | lib.rs:870 |
handle_completion_result | CQE 결과 → IoCompletion::Ok/Err 변환 + bounce copy + uring_cmd 분기 | lib.rs:880 |
decrement_in_flight | 전체 + 배치 in_flight 카운터 감소 + cvar.notify_all | lib.rs:950 |
build_and_submit_sqe | IoSubmission → SQE (Read/Write/ReadFixed/WriteFixed/UringCmd80) 빌드 + ring push | lib.rs:980 |
의미:
- 헬퍼 자체는 동작 동일 — perf-findings 의 "in_flight 카운터 contention" (1-4), "batch_in_flight HashMap lock" (1-5) 는 그대로.
- 단, lock 획득 빈도는 약간 증가.
submission_len(),submission_sync()헬퍼가 매번ring.lock().unwrap()함. 기존엔 "let mut ring = ..." 한 번 잡고 안에서 처리하던 게 잘게 쪼개짐. 보유 시간↓ / 빈도↑ 트레이드오프. build_and_submit_sqe는 중복 제거의 큰 승리 — 이전엔 short I/O 재제출 경로에서 SQE 빌드 코드가 또 한 번 나왔는데 이제 한 자리.
1.3 short I/O 재제출 경로
제약 추가: uring_cmd 는 short I/O 재제출 안 함 (rust.diff:794, 933).
if cqe_result >= 0
&& (cqe_result as usize) < sub.len
&& sub.nvme_cmd_data.is_none() // ← 추가
{
// 재제출
}
근거: NVMe uring_cmd 의 CQE result 의미가 다름 — 0 이면 성공, 그 외는 NVMe status code (음수 = -errno 가 아님). bytes_transferred 로 해석하면 안 됨.
cmd 모드에선 NLB (number of LBAs) 를 cdw12 에 미리 박아 보내고 디바이스가 그 단위로 완전 처리 또는 에러 — 부분 전송이라는 개념이 없음.
1.4 register_fixed_buffers — IoUringWrapper 분기
기존 (eaa2bfee:1017-1069) 은 Arc<Mutex<IoUring>> 한 종류.
PR 후 (rust.diff:1370-1397) 는 wrapper 분기 추가:
let result = match ring {
IoUringWrapper::Standard(ring) => {
let ring = ring.lock().unwrap();
ring.submitter().register_buffers(&iovecs)
}
IoUringWrapper::Big(ring) => {
let ring = ring.lock().unwrap();
ring.submitter().register_buffers(&iovecs)
}
};
register_buffers 자체는 ring 타입 무관 (커널 syscall) 이라 두 분기 동작 동일.
uring_cmd 모드에서도 fixed buffer 사용 가능 — UringCmd80::buf_index(Some(idx)) 로 등록된 버퍼 인덱스 지정 (rust.diff:680).
단 vs_pr3274 §2.4 🆕 가 지적한 대로:
rust_raw_block_backend.py:140의 자동 등록은if self._core.io_engine == "io_uring"만 보고use_uring_cmd검사 안 함 → cmd 모드도 자동 등록 시도됨- 페이지 정렬과 LBA 정렬이 다를 때 SQE 빌드 단계에서 실패 가능성. NVMe 가 등록된 iovec 중 정렬 안 맞는 게 있으면 register_buffers 가 EINVAL.
1.5 새 PyO3 메서드 4개
| 메서드 | 시그니처 | 용도 |
|---|---|---|
nvme_nsid() | → u32 | uring_cmd 모드에서만 NS ID 조회 (체크용) |
nvme_lba_shift() | → u32 | log2(LBA size) |
nvme_lba_size() | → u32 | LBA 바이트 크기 |
write_uring(offset, data, payload_len, total_len) | → None | 동기 io_uring write (체크포인트 경로용) — 한 건씩 보내고 wait |
write_uring 은 새 사실 — 기존엔 동기 쓰기는 무조건 pwrite_from_buffer (POSIX) 였다. 이제 io_uring 모드에서도 batch 안 묶고 한 건씩 동기 쓰기 가능. dispatcher 의 fallback path 에서 사용 (can_batch=False 인 헤더+페이로드 길이 불일치 케이스).
1.6 수정 영향 없는 것 (확인용)
| 항목 | 상태 |
|---|---|
queue depth (iouring_queue_depth, 기본 256) | 그대로 |
setup flags (setup_iopoll/setup_sqpoll/...) | 여전히 default — 켜진 거 없음 |
| 워커 1개 / ring 1개 / 디바이스 1개 | 그대로 |
10µs busy-wait timeout (wait_timeout_while) | 그대로 |
| Vectored I/O (Readv/Writev) | 여전히 미사용 |
| Fsync op | 여전히 미사용 |
BLKGETSIZE64 ioctl (block dev 크기) | 그대로 (단 cmd 모드는 char dev 라 NVMe identify 로 대체) |
2. Python RawBlockCore dispatcher (core.py)
2.1 핵심 변경: 단일 진입점 도입
기존 (eaa2bfee): 모든 I/O 호출자가 raw_dev.pwrite_from_buffer(...) / raw_dev.pread_into(...) 직접 호출. POSIX 든 io_uring 이든 차이 없이 동기 한 건.
PR 후: 모든 I/O 가 _write_buffers / _read_buffers 단일 dispatcher 통과.
호출자 (put_one / load_many_into / _validate_loaded_entries / 체크포인트 R/W)
│
▼
_write_buffers(offsets, buffers, payload_lens, total_lens)
_read_buffers(...)
│
├─ io_engine != "io_uring":
│ └─ for each: raw_dev.pwrite_from_buffer / raw_dev.pread_into (POSIX, 기존)
│
├─ io_engine == "io_uring" && use_uring_cmd:
│ └─ _write_uring_cmd_buffers / _read_uring_cmd_buffers
│ ├─ MDTS chunk split
│ └─ chunk 단위 batched_write 또는 read_uring (read 는 동기 한 건)
│
└─ io_engine == "io_uring" (block 모드):
├─ can_batch (모든 payload_len == total_len): batched_write/batched_read + wait_iouring
└─ 아니면: write_uring / read_uring (동기 한 건씩)
2.2 can_batch 분기의 의미
payload_len == total_len 모든 항목에 대해 성립할 때만 batched_write/batched_read 사용. 왜?
batched_write는 single-shot SQE 묶음. 한 SQE = 한 (offset, buf, len). buffer 안에서 padding/short write 같은 개념 없음.- 헤더 쓰기는
len(header)가 32B 인데 O_DIRECT 로 4096B 정렬 round-up 됨 (hdr_total = round_up(32, 4096)= 4096). payload_len(=32) ≠ total_len(=4096) → batched 불가능. - 정렬 안 맞으면 Rust
batched_write가 즉시 ValueError (eaa2bfee lib.rs:1631 — bounce buffer 안 씀).
결론: 사실상 can_batch 가 True 인 경우는 "헤더 없이 페이로드만, payload 가 이미 정렬돼 있을 때". _write_one 의 헤더+페이로드 페어는 페이로드는 batch 가능하지만 헤더가 안 맞아서 → 두 개를 묶으면 can_batch=False → write_uring 동기 두 번 호출.
vs_pr3274 §2.4 🆕 가 지적한 "호출자가 길이 1 list 만 보낸다" 의 정확한 형태:
| 호출자 | offsets/buffers 길이 | can_batch | 실제 경로 |
|---|---|---|---|
_write_one (put_many 루프 안) | 2 (header + payload) | False (header 정렬 불일치) | write_uring × 2 |
load_many_into (루프 안) | 1 (payload) | True (payload aligned) | batched_read([1개]) + wait |
_validate_loaded_entries | 1 (header) | True (header_bytes == block_align) | batched_read([1개]) + wait |
| 체크포인트 read | 1 | True | batched_read([1개]) + wait |
| 체크포인트 write | 2 (payload + header_block) | True (둘 다 block_align 배수) | batched_write([2개]) + wait |
→ 체크포인트 write 만 진짜 batched_write 의미 가 있고 나머지는 길이 1~2짜리 batched 로 사실상 single. perf-findings 1-1 (notify_one 폭풍) 이 그대로 남는 이유.
2.3 _write_one 변경 — 헤더+페이로드 페어 dispatcher 통과
# post-merge (core.diff:553-585 추정 위치 core.py:1359 부근)
self._write_buffers(
[offset, offset + self.header_bytes],
[header_buf, buf],
[hdr_total if self.io_engine == "io_uring" else len(header), payload_len],
[hdr_total, total_len],
)
기존: pwrite_from_buffer 두 번 (offset, offset+header_bytes) → 동기 두 번.
PR 후: dispatcher 한 번 호출. 단, 위 표대로 io_uring 모드에서는 dispatcher 안에서 can_batch=False → write_uring 두 번. 호출 횟수 동일하고 ring 우회만 한 단계 더 늘어남.
흥미로운 디테일: payload_lens 계산이 io_engine 분기 들어감.
- POSIX:
len(header)(32B) — POSIXpwrite가 partial write 처리 가능 - io_uring:
hdr_total(4096B, 정렬됨) — io_uring 동기 path 가 정렬 강제
= header_buf 도 io_uring 모드면 padding 까지 포함된 4096B 버퍼로 조정됨 (core.diff:572-576).
2.4 _write_buffers / _read_buffers 의 미묘한 차이
# write 쪽 (core.diff:483-500)
if can_batch:
batched_write(...)
else:
for ...: write_uring(...) # io_uring 동기 한 건씩
# read 쪽 (core.diff:533-551)
if can_batch and all(_is_buffer_aligned(buf) for buf in buffers):
batched_read(...)
else:
for ...: read_uring(...) # io_uring 동기 한 건씩
read 만 추가 정렬 검증 (_is_buffer_aligned). 왜?
- write 쪽
batched_write는 buffer 정렬 안 맞으면 Rust 에서 ValueError 인데, 호출자가 정렬된 buffer 만 넘기는 컨벤션이 이미 있음 (_build_direct_odirect_viewzero-copy 결과는 항상 정렬). - read 쪽은 호출자가 임의 bytearray 를 destination 으로 줄 수 있음 —
bytearray(total_len)이 정렬 안 됨. dispatcher 가 미리 검증해서 fallback (read_uring동기) 으로 빠짐.
→ read 측에서 fallback 비율이 더 높을 수 있음. perf 측정 시 _is_buffer_aligned False 빈도 추적이 의미 있음.
2.5 신규 헬퍼 4개
| 메서드 | 위치 추정 | 역할 |
|---|---|---|
raw_device() | core.py:340 부근 | _rawdev() 의 public wrapper. plugin 이 _raw property 로 재노출 |
set_raw_device_for_testing(raw) | 같은 영역 | 테스트용 raw 교체 hook |
register_fixed_buffers_from_allocator(alloc) | core.py:355 부근 | alloc.get_paged_buffers() 호출해서 자동 등록. allocator 가 메서드 안 노출하면 warning 후 skip |
_resolve_max_data_transfer_size(configured) | core.py:380 부근 | configured > 0 이면 그대로, 아니면 /sys/block/nvme<C>n<N>/queue/max_hw_sectors_kb 읽어 KB→bytes (block_align 배수로 round-down) |
2.6 sysfs MDTS 자동 검출
_resolve_sysfs_queue_dir(device_path) 가 /dev/ng0n1 → /sys/block/nvme0n1/queue 로 매핑.
# core.diff:19-26
match = re.fullmatch(r"ng(\d+)n(\d+)", base_name)
if match:
ctrl, nsid = match.groups()
return f"/sys/block/nvme{ctrl}n{nsid}/queue"
시사점:
- char dev
/dev/ng<C>n<N>이름과 sysfs 의 block devnvme<C>n<N>이 1:1 매핑이라는 가정에 의존 — Linux NVMe driver 의 표준 명명 규칙이라 보통 안전하지만 rebind/multipath 시 깨질 여지. max_hw_sectors_kb만 보고 다른 limit (e.g.max_sectors_kbqueue limit) 는 안 봄. 사용자가 sysfs 로 max_sectors 를 줄여놨으면 무시됨.- aligned_bytes =
(KB*1024 / block_align) * block_align— block_align (보통 4096) 배수로 절삭. KB*1024 이 4KB 의 정수배가 아니면 절삭 손실 (드물긴 함).
2.7 io_uring 입장에서 perf-findings §2 (Python) 항목 재평가
| ID | 항목 | dispatcher 도입 영향 |
|---|---|---|
| 2-1 | put_many lock 2N회 | 그대로. 루프 자체는 손 안 댐 |
| 2-2 | put_many 직렬 I/O | dispatcher 한 번 통과로 정리됐지만 여전히 키 1개씩 루프. 진짜 batched_write 활용은 안 됨 |
| 2-3 | load_many_into 직렬 read | 동일 — 키 1개씩 _read_buffers([1개]) |
| 2-6 | _validate_loaded_entries 슬롯마다 pread | dispatcher 통과만 추가 — 약간 느려질 가능성 |
| 2-7 | 체크포인트 JSON (write) | dispatcher 사용 — 페이로드+헤더 한 번에 batched_write. 이건 진짜 batched 의미 살림 |
→ 체크포인트 write 가 dispatcher 의 유일한 진짜 수혜자. 나머지는 dispatcher 인프라만 깔린 상태.
2.8 O_DIRECT 강제 비활성화 (uring_cmd 모드)
# core.diff:58-64
if self.use_uring_cmd and self.use_odirect:
logger.warning(
"RawBlockCore: use_odirect is ignored for NVMe namespace "
"character devices when use_uring_cmd=true"
)
self.use_odirect = False
근거: char device (/dev/ng*n*) 는 page cache 를 안 거치는 raw IO 통로 자체. O_DIRECT 는 block dev 의 page cache bypass 플래그라 char dev 에선 무의미 + 일부 커널/드라이버는 EINVAL.
부수 효과: vs_pr3274 §2.4 🆕 가 지적한 대로 _is_buffer_aligned 가 use_odirect=False 면 항상 True 반환 (core.diff:309-310). 즉 cmd 모드에선 buffer pointer 정렬 검증이 dispatcher 단에서 사라짐. 정렬 안 된 buffer 가 NVMe 까지 흘러갔다가 디바이스 거부 시 NVMe error 로 late fail.
3. uring_cmd / NVMe passthrough 경로 (PR 신규)
이 섹션이 사용자 (FDP/HC-SSD) 작업과 가장 직접 연결.
3.1 진입 조건 (4중 검증)
| # | 검증 위치 | 조건 | 실패 시 |
|---|---|---|---|
| 1 | RawBlockL2AdapterConfig.from_dict (l2.diff:194-195) | use_uring_cmd=True → io_engine == "io_uring" | ValueError |
| 2 | RawBlockCore.__init__ (core.diff:71-72) | 같은 조건 — Core 단에서 한 번 더 | ValueError |
| 3 | RawBlockCore.__init__ (core.diff:74-93) | os.stat(path).st_mode 가 char device + 이름 ^ng\d+n\d+$ | ValueError |
| 4 | Rust new_internal (rust.diff:447-455) | is_character_device(path) + (Big ring 빌드 성공) | PyValueError |
의도: char dev 가 아니거나 kernel 5.19 미만이면 시작도 못 함. block device (/dev/nvme0n1) 는 cmd 모드 못 쓴다는 게 hard constraint.
장점: 사용자가 우연히 잘못된 device path 줘도 빠르게 명확한 에러. 제약: HC-SSD vendor SDK 가 char dev 를 제공 안 하면 cmd 경로 자체 봉쇄. block dev wrapper 만 있다면 이 PR 의 cmd 경로는 못 씀.
3.2 NvmeCmdData 구조
// rust.diff:336-342
struct NvmeCmdData {
nsid: u32, // Namespace ID
lba_shift: u32, // log2(LBA size) — slba/nlb 계산에 사용
dtype: u8, // Directive Type — FDP placement (4=FDP)
dspec: u16, // Directive Specific — placement ID (PLID)
}
매 IoSubmission 에 nvme_cmd_data: Option<NvmeCmdData> 필드 추가. None 이면 일반 read/write, Some 이면 uring_cmd.
→ 같은 ring 안에서 일반 op 와 cmd op 가 섞일 수 있음 (단 ring 이 Big 이어야). PR 코드에서는 한 디바이스가 cmd 면 모든 sub 가 cmd, block 이면 모든 sub 가 block — 디바이스 단위로만 분기.
3.3 nvme_uring_cmd_prep — FDP directive 의 자리
// rust.diff:281-322
fn nvme_uring_cmd_prep(
cmd: &mut NvmeUringCmd,
is_write: bool,
nsid: u32,
offset: u64,
len: usize,
lba_shift: u32,
ptr: *const u8,
dtype: u8,
dspec: u16,
) -> Result<(), PyErr> {
let slba = offset >> lba_shift;
let nlb = (len >> lba_shift) - 1; // NLB는 0-based
cmd.opcode = if is_write { NVME_IO_WRITE } else { NVME_IO_READ };
cmd.nsid = nsid;
cmd.cdw10 = (slba & 0xFFFFFFFF) as u32;
cmd.cdw11 = (slba >> 32) as u32;
cmd.cdw12 = nlb as u32 | ((dtype as u32) << 20); // ← FDP dtype
cmd.cdw13 = (dspec as u32) << 16; // ← FDP dspec (PLID)
cmd.addr = ptr as u64;
cmd.data_len = len as u32;
...
}
FDP 관점에서 핵심:
cdw12의 bits 20-23 = DTYPE (Directive Type). FDP 는 4.cdw13의 bits 16-31 = DSPEC (Directive Specific) = Placement Identifier (PLID).- 이 두 필드만 채우면 FDP 동작.
현재 호출자: RawBlockDevice._build_nvme_cmd_data(0, 0) — 모든 cmd 가 dtype=0, dspec=0 으로 고정 (rust.diff:1411-1414, 1432-1438, 1455 등 곳곳).
fn _build_nvme_cmd_data(&self, dtype: u8, dspec: u16) -> PyResult<Option<NvmeCmdData>> {
if !self.use_uring_cmd { return Ok(None); }
Ok(Some(NvmeCmdData { nsid, lba_shift, dtype, dspec }))
}
→ 시그니처에는 dtype/dspec 인자가 있지만 호출 모두 (0, 0). FDP 가 인프라 단에서는 준비됐는데 정책 hook 이 비어있는 상태. 사용자 plugin 이 끼워 넣을 자리 = 이 함수 호출자.
3.4 cmd 빌드 — UringCmd80 + buf_index
// rust.diff:656-700
let mut uring_cmd = opcode::UringCmd80::new(Fd(sub.fd), NVME_URING_CMD_IO).cmd(cmd_bytes);
if let Some(idx) = sub.fixed_buffer_idx {
uring_cmd = uring_cmd.buf_index(Some(idx));
}
let sqe128 = uring_cmd.build().user_data(user_data);
match ring {
IoUringWrapper::Big(ring) => { ring.lock().unwrap().submission().push(&sqe128) }
IoUringWrapper::Standard(_) => {
return Err(PyRuntimeError::new_err(
"io_uring_cmd requires big entries (kernel 5.19+)"
));
}
}
NVME_URING_CMD_IO=0xC048_4E80— io_uring_cmd ABI 가 정의한 NVMe IO opcode (read/write 공통, 실제 read/write 구분은NvmeUringCmd.opcode안에 박힘).buf_index(Some(idx))로 fixed buffer 사용 가능 — uring_cmd 도 zero-copy 경로 살아있음.- Standard ring 에 cmd push 시도하면 명확한 에러 (kernel 미만 케이스 방어).
3.5 Python 측 chunk split (MDTS)
NVMe 디바이스는 한 번에 받을 수 있는 데이터 크기 제한 (MDTS = Max Data Transfer Size) 이 있음. 보통 128KB ~ 4MB. PR 은 이걸 sysfs 에서 읽어 (§2.6) Python 단에서 chunk 분할.
write 경로 (core.diff:331-393):
def _write_uring_cmd_buffers(self, offsets, buffers, payload_lens, total_lens):
chunk_offsets = []; chunk_buffers = []; chunk_lens = []
for offset, buf, payload_len, total_len in zip(...):
# padding 처리
view = self._byte_view(buf)
if len(view) < total_len: ...padded...
cursor = 0
while cursor < total_len:
chunk_len = min(self.max_data_transfer_size, total_len - cursor)
chunk_offsets.append(offset + cursor)
chunk_buffers.append(view[cursor:cursor+chunk_len])
chunk_lens.append(chunk_len)
cursor += chunk_len
batch_id = raw_dev.batched_write(chunk_offsets, chunk_buffers, chunk_lens)
raw_dev.wait_iouring(batch_id)
read 경로 (core.diff:395-447):
def _read_uring_cmd_buffers(self, ...):
for offset, buf, payload_len, total_len in zip(...):
...
cursor = 0
while cursor < total_len:
chunk_len = min(self.max_data_transfer_size, total_len - cursor)
read_uring(offset+cursor, target[cursor:cursor+chunk_len], chunk_len, chunk_len)
cursor += chunk_len
비대칭이 있다:
| write | read | |
|---|---|---|
| chunk 모음 방식 | 모든 chunk 를 list 로 모아 batched_write 한 번 | chunk 마다 read_uring 동기 호출 |
| 병렬성 | ring 에 chunk 동시 in-flight | 순차 — chunk N 끝나야 N+1 시작 |
vs_pr3274 §2.4 🆕 가 지적한 부분 그대로 — read 큰 페이로드는 cmd 모드의 io_uring 병렬성 못 활용. 1MB payload + MDTS 128KB → 8 chunk 직렬 read.
원인 추정:
read_uring는 한 호출당 wait 까지 동기.batched_read를 chunk 모아서 부르려면 destination buffer 들이 모두 동시 살아있어야 하는데, slice (target[cursor:cursor+chunk_len]) 가 같은 bytearray 참조라 OK 일 것 같지만 PyO3 buffer protocol + 병렬 release 처리 등에서 위험할 수 있어 보수적으로 동기 직렬로 둔 듯.- read fast path 자체가 미완성일 가능성 — write 에 비해 우선순위 낮춰 합쳐진 모양.
3.6 device 크기 계산이 다름
block dev: BLKGETSIZE64 ioctl 한 번.
char dev (cmd 모드): NVMe Identify Namespace 명령 (nvme_identify_ns) 으로 LBA 수 받고 nsze * lba_size 곱셈 (rust.diff:471-478).
let size = if use_uring_cmd {
nvme_ns_size_bytes(&id_ns, lba_size) // = id_ns.nsze * lba_size
} else {
fd_size_bytes(fd)?
};
부수효과: identify 가 admin command (nsid=0 으로 ioctl) 라 디바이스 권한이 강함. NS 수준 권한만 있으면 못 받아옴. cmd 모드 진입에 디바이스 측에서 인증서/SR-IOV 같은 추가 제약 있을 수 있음.
3.7 metadata 검증
// rust.diff:215-221
let ms = id_ns.lbaf[lbaf_index].ms;
if ms != 0 {
return Err(PyValueError::new_err(
"Device is formatted with metadata, can't be supported."
));
}
LBA format 의 metadata size != 0 면 거부 (예: 4096+8 byte protection info). HC-SSD 가 metadata 동반 포맷 (T10-PI, end-to-end CRC 등) 이면 cmd 모드 못 씀. 사용자 환경에서 NVMe format 이 어떤 LBAF 인지 확인 필요.
3.8 perf-findings 관점에서 cmd 경로의 새 현황
| 항목 | cmd 모드 영향 |
|---|---|
| fixed buffer 지원 | ✅ UringCmd80.buf_index(idx) 사용. block 모드와 동등 |
| short I/O 재제출 | 🚫 비활성 — NLB 단위라 partial 개념 없음 |
| bounce buffer | ✅ alignment 안 맞으면 사용. 단 cmd 모드는 use_odirect=False 강제라 alignment 검증이 dispatcher 단에서 빠짐 → bounce 경로 거의 안 탐 |
| MDTS chunk split | Python 단에서 처리. write 는 batched, read 는 동기 직렬 |
| CQE result 해석 | uring_cmd 는 0=성공, 그 외=NVMe status code. 일반 op 의 -errno 와 다름 |
4. Plugin / L2 adapter 통합 경로
4.1 변경 요약 (둘 다 io_uring 직접 코드는 거의 안 건드림)
| 파일 | 변경 |
|---|---|
rust_raw_block_backend.py (legacy in-process plugin) | (a) io_uring 인 경우 register_fixed_buffers_from_allocator 자동 호출 (b) _submit_put_many 신설 — io_uring 모드에 키 ≥ 2 이면 1 task 로 묶음 (c) _raw property 추가 (테스트 호환) (d) use_uring_cmd / max_data_transfer_size config key 전달 |
raw_block_l2_adapter.py (MP) | (a) use_uring_cmd / max_data_transfer_size config 추가 (b) io_uring 인 경우 l1_align_bytes >= block_align 강제 (c) MP 경로는 io_uring 인 경우 fixed buffer 미등록 경고만 출력 |
4.2 In-process plugin: fixed buffer 자동 등록
기존: register_fixed_buffers 가 Rust 에 있지만 Python 어디서도 호출 안 함 (vs raw_block_line.md §L4 의 Open Question).
PR 후 (plugin.diff:9-19):
if self._core.io_engine == "io_uring":
try:
self._core.register_fixed_buffers_from_allocator(
self.local_cpu_backend.get_memory_allocator()
)
except Exception as e:
logger.warning("...failed to register io_uring fixed buffers: %s. "
"Falling back to non-fixed buffer mode.", e)
→ RawBlockCore.register_fixed_buffers_from_allocator (§2.5) 가 allocator 의 get_paged_buffers() 를 호출 → 페이지 포인터 list → Rust register_fixed_buffers.
시사점:
- in-process (legacy) 는 fixed buffer 자동 활성화. block 모드 zero-copy 살아남.
- 단
if self._core.io_engine == "io_uring"만 보고use_uring_cmd검사 안 함 → cmd 모드에도 자동 등록 시도 (§1.4 와 동일 지적). 페이지 정렬과 LBA 정렬 차이로 실패하면 warning 후 non-fixed 로 fallback — 안전망은 있음. - allocator 가
get_paged_buffers메서드 안 노출하면 warning + skip (core.diff:206-211).
4.3 In-process plugin: _submit_put_many
기존: 키 1개당 _submit_put_one task 생성 → asyncio loop 의 store-pool 에서 키마다 별도 task → 같은 _core._lock 위에서 N번 충돌.
PR 후 (plugin.diff:88-105):
if self._core.io_engine == "io_uring" and len(pending) > 1:
fut = asyncio.run_coroutine_threadsafe(
self._submit_put_many(pending, on_complete_callback),
loop,
)
return [fut] # ← 단일 future
_submit_put_many 내부 (plugin.diff:114-159):
put_result = await asyncio.to_thread(
self._core.put_many, # ← 한 번에 모든 spec/obj
specs, memory_objs,
)
의미:
_core.put_many는 그 자체로 키 N개 받지만 (eaa2bfee core.py:463-516), 내부는 여전히 키 단위 루프 — vs_pr3274 §2.2 의 평가대로 루프 자체는 손 안 댐.- 다만 plugin 단에서 1개 background task 로 통합 → asyncio store-pool 의 worker 충돌이 사라짐. 진짜 batched_write 의 의미는 못 살리지만, 워커 슬롯 contention 은 완화.
- block 모드 (
io_engine != "io_uring") 는 이 분기 안 탐 — 여전히 key-by-key task. 이건 의도적 — POSIX 는 어차피 동기 syscall 이라 batch 의미가 약하고 atomic 단위가 작은 게 cancellation 측면에서 유리하기 때문으로 보임.
4.4 MP adapter (raw_block_l2_adapter.py): 의도적 미구현 영역
fixed buffer 등록은 안 함, 경고만:
# l2.diff:99-105
if config.io_engine == "io_uring":
logger.warning(
"RawBlockL2Adapter: MP raw_block uses io_uring without "
"fixed-buffer registration; zero-copy fixed buffers are "
"disabled unless registered by a future MP allocator path"
)
왜 안 함?:
- MP 모드는 adapter 가 caller 의 destination buffer 를 받아서 일하는 구조. l2 adapter 자신은 메모리 풀 소유 안 함.
- "MP allocator path" 라는 미래 인터페이스가 필요하다고 명시 — 누가 어떻게 paged buffer 를 노출할지 합의 안 됨.
- legacy plugin 은 자기 안에
local_cpu_backend.get_memory_allocator()가 있어서 그걸 등록하면 됐음. MP 는 그 계층이 없음.
부수효과:
- MP 모드의 io_uring 은 fixed buffer 항상 미사용 → 일반 Read/Write op 만 사용. 호스트 ↔ 커널 메모리 복사 (mmap/copy_to_user) 가 매 I/O 마다 발생.
- vs_pr3274 §2.1 1-6 표 기호: ✅ block 모드만, MP 는 ⏸.
4.5 MP adapter: l1_align 검증 강화
# l2.diff:83-93
if (
(config.use_odirect or config.io_engine == "io_uring")
and l1_memory_desc is not None
and l1_memory_desc.align_bytes < config.block_align
):
raise ValueError(
"raw_block requires l1_align_bytes >= block_align when "
"use_odirect=true or io_engine=io_uring"
)
기존: use_odirect=True 인 경우만 검증.
PR 후: io_uring 인 경우도. 이유 = io_uring 의 batched_write 가 alignment 안 맞으면 즉시 ValueError (Rust). 미리 잡아 주는 게 진단성 측면에서 좋음.
→ MP 환경에서 io_uring 켜려면 L1 메모리 풀의 align 이 block_align 이상이어야 함. L1 메모리 풀 설정 (l1_align_bytes) 의 자동/수동 결정 흐름을 사용자 plugin 설계 시 인지 필요.
4.6 in-process vs MP 의 io_uring 활용 격차 정리
| 기능 | in-process plugin | MP adapter |
|---|---|---|
| io_engine = io_uring | ✅ | ✅ |
| use_uring_cmd | ✅ | ✅ |
| fixed buffer 자동 등록 | ✅ (auto) | ❌ (경고만) |
_submit_put_many 통합 task | ✅ (io_uring 모드) | ❌ (기존 그대로) |
| per_tp_device_paths | ✅ | ❌ (거부, raw_block_line.md §L1) |
→ in-process 가 io_uring 활용도가 더 높음. MP 는 io_uring 켜도 일반 op 경로 + 키-단위 직렬 store. MP 권장 (CLAUDE.md / quickstart 의 권장 모드) 과 io_uring 최적 활용이 미스매치.
5. 종합 — io_uring 사용 분석 (post-merge)
5.1 raw_block_line.md §L4 표 갱신
| 항목 | eaa2bfee (pre) | post-merge | 변경 |
|---|---|---|---|
| 엔진 선택 | io_engine / legacy use_iouring | + use_uring_cmd (3번째 차원) | NEW dimension |
| ring 인스턴스 | Arc<Mutex<IoUring>> 1개 | IoUringWrapper { Standard | Big } | 듀얼 ring 타입 |
| ring 빌드 | IoUring::new(qd) | Big 우선 시도 → fallback Standard | kernel 의존 |
| 워커 | 1 디바이스 1 워커 | 동일 | — |
| queue depth | 256 | 동일 | — |
| setup flags | default | 동일 | iopoll/sqpoll 여전히 미사용 |
| 사용 op | Read/Write/ReadFixed/WriteFixed | + UringCmd80 | NEW |
| short I/O 재제출 | 모든 op | regular op 만 (cmd 제외) | 분기 |
| Vectored I/O | 미사용 | 동일 | — |
| Fsync | 미사용 | 동일 | — |
| registered buffer | 인프라만, 미호출 | legacy plugin 자동 호출, MP 미호출 | 부분 활성화 |
| NVMe passthru | 없음 | UringCmd80 (cmd 모드) | NEW |
| MDTS 자동 검출 | N/A | sysfs max_hw_sectors_kb | NEW |
5.2 raw_block_line.md §L3 (FDP 삽입 후보) 표 갱신
| # | pre 위치 | post 위치 | 변경 |
|---|---|---|---|
| H1 | _write_one → pwrite_from_buffer | _write_one → _write_buffers | dispatcher 통과. placement_id 인자를 dispatcher 까지 흘려야 함 |
| H2 | Rust pwrite/batched_write | Rust _build_nvme_cmd_data(dtype, dspec) | 이미 시그니처 존재. dtype/dspec 흘릴 plumbing 만 필요 |
| H3 | put_many 의 slot 할당 | 동일 | 정책 결정 자리. 변경 없음 |
| H4 | _allocate_slot_locked | 동일 | — |
| H5 | metadata checkpoint write | dispatcher 통과 (체크포인트 write 가 진짜 batched_write) | batched_write 의 첫 진짜 사용처 |
| H6 | delete_many | 동일 | DSM dealloc 후보. 변경 없음 |
| H7 | config | + use_uring_cmd, max_data_transfer_size | config surface 확장 |
| H8 | register_fixed_buffers 인프라 | legacy plugin 자동 호출 | P2 → 부분 해결. MP 는 여전히 P2 |
5.3 사용자 작업 시사점 (FDP / HC-SSD plugin 설계)
기회:
- FDP plugin 의 hook 자리가 매우 좁아짐 —
_build_nvme_cmd_data(dtype, dspec)한 함수의 호출자만 바꾸면 모든 cmd 가 PLID 받음. PR 머지 후 사용자 plugin 코드량 = 작은 wrapper. max_data_transfer_size가 PLID 별로 다를 수 있는지 별도 검증 필요 — sysfs 의max_hw_sectors_kb는 NS 단위. FDP RU 단위 split 정책이 따로 있다면 PR 의 sysfs 자동 검출 결과와 충돌 가능성.UringCmd80opcode 분기를 NVME_URING_CMD_IO 단일 → zone append 등 추가 가능. zone append (NVME_IO_ZONE_APPEND = 0x7D) 는nvme_uring_cmd_prep의cmd.opcode분기에 추가. PR 의 구조가 확장 가능하게 짜여 있음.
제약:
- char device 강제 — vendor SDK 가 char dev 를 노출 안 하면 cmd 경로 못 씀.
- kernel 5.19+ 필수 — Big SQE/CQE ABI 도입 시점. 이전 커널은
cmd 모드자체 거부. - LBA format metadata size != 0 이면 거부 — T10-PI 같은 end-to-end protection 환경 미지원.
- MP 모드에서 fixed buffer 자동 활성화 안 됨 — MP 권장 환경에서 zero-copy 이득 못 봄. MP allocator path 정의 후속 작업 필요.
5.4 perf-findings 우선순위 재평가 (vs_pr3274 §3 확장)
| 우선순위 | 항목 | 머지 후 평가 |
|---|---|---|
| P0 (그대로) | notify_one 폭풍 | dispatcher 도입과 무관. 1-1 그대로 |
| P0 (그대로) | put_many/load_many 다중 키 batched | dispatcher 인프라 깔림, 호출자 미적응 |
| P0 (NEW) | dispatcher 호출자가 길이 1 list 만 보냄 | PR 가 만든 빈 그릇 — 채워야 의미 |
| P1 (그대로) | wait_iouring busy-wait | 변경 없음 |
| P1 (NEW) | uring_cmd read 가 chunk 직렬 | write 와 비대칭. read fast path 미완 |
| P1 (NEW) | MP 에 fixed buffer 등록 hook 없음 | 경고만, 인터페이스 합의 필요 |
| P1 (승격) | CQ drain Vec 복사 + lock 빈도 | wrapper 가 lock 획득을 더 자주 함 |
| P1 (해결) | register_fixed_buffers 미호출 | legacy plugin 만 ✅, MP 는 P2 잔존 |
5.5 기존 노트들과의 정합성
이 노트가 갱신/대체하는 것:
- raw_block_line.md §L4 (io_uring 사용 분석) — pre 상태. 본 노트가 post 버전.
- raw-block-perf-findings_vs_pr3274.md — 변경/미변경 매핑은 그대로 유효. 본 노트는 머지 후 정상 동작 시 io_uring 이 어떻게 보이는가 의 관점.
이 노트가 미루는 것:
- 실측 (Phase 0 측정 항목) — 코드 분석만, 벤치마크 미포함
- L1 memory pool 의 paged buffer 제공 인터페이스 (
get_paged_buffers) 가 어떻게 생겼는지 — 별도 확인 필요 - MP allocator path 설계 — PR 외 작업
6. 다음 액션 후보
- PR #3274 의 review 코멘트 / 토론 본문 확인 — vs_pr3274 §2.4 의 🆕 항목들이 이미 지적됐는지 (특히 dispatcher 호출자 길이 1 list 이슈, uring_cmd read 비대칭)
-
local_cpu_backend.get_memory_allocator().get_paged_buffers()가 실제로 어떤 buffer 를 반환하는지 코드 확인 —register_fixed_buffers가 의미 있게 동작할 alignment/contiguity 조건 만족하는지 - MP allocator path 의 잠재적 인터페이스 (fixed buffer 등록 hook) 후보 위치 식별 —
RawBlockL2Adapter.__init__의 l1_memory_desc 인자 활용 가능성 - FDP placement plugin 설계 sketch —
_build_nvme_cmd_data(dtype, dspec)의 호출자 변경 최소 패치 형태 (sub-class? monkey-patch? config-driven?) - kernel < 5.19 환경에서 Big 빌드 실패 후 Standard fallback 의 실제 동작 확인 — Big 우선 시도 자체의 비용 (실패하는데 걸리는 시간) 측정 가치
변경 검증 가이드 (다음 fetch 후)
# PR #3274 머지 여부 확인
git log --all --oneline | grep -iE "3274|uring_cmd|nvme.*pass"
# 머지됐으면 라인 번호 갱신 — 본 노트의 추정값을 실제 값으로
git log eaa2bfee..HEAD -- rust/raw_block/src/lib.rs \
lmcache/v1/storage_backend/raw_block/core.py \
lmcache/v1/storage_backend/plugins/rust_raw_block_backend.py \
lmcache/v1/distributed/l2_adapters/raw_block_l2_adapter.py
라인 번호 갱신이 필요한 위치:
- §1 Rust 헬퍼 4개의
lib.rs:870/880/950/980— 머지 후 정확한 라인 - §2.5
raw_device()등 신규 메서드의core.py:340/355/380— 정확한 라인 - §3.3
nvme_uring_cmd_prep— 머지 후 lib.rs 의 정확한 라인
변경되면 본 노트가 stale 해지는 영역:
- IoUringWrapper 의 Standard/Big 분기 자체가 다른 형태로 바뀌면 §1.1, §1.2, §1.4 모두 무효
_write_buffers/_read_buffers시그니처가 바뀌면 §2 전반 무효_build_nvme_cmd_data(dtype, dspec)시그니처가 dtype/dspec 인자를 받지 않게 바뀌면 §3.3, §5.3 의 FDP plugin sketch 가 무효