본문으로 건너뛰기

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
}
항목StandardBig
SQE 크기64 B (SqueueEntry)128 B (Entry128)
CQE 크기16 B (Entry)32 B (Entry32)
메모리qd=256 → SQ 16KB / CQ 4KBSQ 32KB / CQ 8KB (2배)
지원 opRead/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_bufferbounce → original 메모리 복사 (memcpy)lib.rs:870
handle_completion_resultCQE 결과 → IoCompletion::Ok/Err 변환 + bounce copy + uring_cmd 분기lib.rs:880
decrement_in_flight전체 + 배치 in_flight 카운터 감소 + cvar.notify_alllib.rs:950
build_and_submit_sqeIoSubmission → SQE (Read/Write/ReadFixed/WriteFixed/UringCmd80) 빌드 + ring pushlib.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()→ u32uring_cmd 모드에서만 NS ID 조회 (체크용)
nvme_lba_shift()→ u32log2(LBA size)
nvme_lba_size()→ u32LBA 바이트 크기
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=Falsewrite_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_entries1 (header)True (header_bytes == block_align)batched_read([1개]) + wait
체크포인트 read1Truebatched_read([1개]) + wait
체크포인트 write2 (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=Falsewrite_uring 두 번. 호출 횟수 동일하고 ring 우회만 한 단계 더 늘어남.

흥미로운 디테일: payload_lens 계산이 io_engine 분기 들어감.

  • POSIX: len(header) (32B) — POSIX pwrite 가 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_view zero-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 dev nvme<C>n<N> 이 1:1 매핑이라는 가정에 의존 — Linux NVMe driver 의 표준 명명 규칙이라 보통 안전하지만 rebind/multipath 시 깨질 여지.
  • max_hw_sectors_kb 만 보고 다른 limit (e.g. max_sectors_kb queue 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-1put_many lock 2N회그대로. 루프 자체는 손 안 댐
2-2put_many 직렬 I/Odispatcher 한 번 통과로 정리됐지만 여전히 키 1개씩 루프. 진짜 batched_write 활용은 안 됨
2-3load_many_into 직렬 read동일 — 키 1개씩 _read_buffers([1개])
2-6_validate_loaded_entries 슬롯마다 preaddispatcher 통과만 추가 — 약간 느려질 가능성
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_aligneduse_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중 검증)

#검증 위치조건실패 시
1RawBlockL2AdapterConfig.from_dict (l2.diff:194-195)use_uring_cmd=Trueio_engine == "io_uring"ValueError
2RawBlockCore.__init__ (core.diff:71-72)같은 조건 — Core 단에서 한 번 더ValueError
3RawBlockCore.__init__ (core.diff:74-93)os.stat(path).st_mode 가 char device + 이름 ^ng\d+n\d+$ValueError
4Rust 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)
}

IoSubmissionnvme_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

비대칭이 있다:

writeread
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 splitPython 단에서 처리. 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 pluginMP 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 Standardkernel 의존
워커1 디바이스 1 워커동일
queue depth256동일
setup flagsdefault동일iopoll/sqpoll 여전히 미사용
사용 opRead/Write/ReadFixed/WriteFixed+ UringCmd80NEW
short I/O 재제출모든 opregular op 만 (cmd 제외)분기
Vectored I/O미사용동일
Fsync미사용동일
registered buffer인프라만, 미호출legacy plugin 자동 호출, MP 미호출부분 활성화
NVMe passthru없음UringCmd80 (cmd 모드)NEW
MDTS 자동 검출N/Asysfs max_hw_sectors_kbNEW

5.2 raw_block_line.md §L3 (FDP 삽입 후보) 표 갱신

#pre 위치post 위치변경
H1_write_onepwrite_from_buffer_write_one_write_buffersdispatcher 통과. placement_id 인자를 dispatcher 까지 흘려야
H2Rust pwrite/batched_writeRust _build_nvme_cmd_data(dtype, dspec)이미 시그니처 존재. dtype/dspec 흘릴 plumbing 만 필요
H3put_many 의 slot 할당동일정책 결정 자리. 변경 없음
H4_allocate_slot_locked동일
H5metadata checkpoint writedispatcher 통과 (체크포인트 write 가 진짜 batched_write)batched_write 의 첫 진짜 사용처
H6delete_many동일DSM dealloc 후보. 변경 없음
H7config+ use_uring_cmd, max_data_transfer_sizeconfig surface 확장
H8register_fixed_buffers 인프라legacy plugin 자동 호출P2 → 부분 해결. MP 는 여전히 P2

5.3 사용자 작업 시사점 (FDP / HC-SSD plugin 설계)

기회:

  1. FDP plugin 의 hook 자리가 매우 좁아짐_build_nvme_cmd_data(dtype, dspec) 한 함수의 호출자만 바꾸면 모든 cmd 가 PLID 받음. PR 머지 후 사용자 plugin 코드량 = 작은 wrapper.
  2. max_data_transfer_size 가 PLID 별로 다를 수 있는지 별도 검증 필요 — sysfs 의 max_hw_sectors_kb 는 NS 단위. FDP RU 단위 split 정책이 따로 있다면 PR 의 sysfs 자동 검출 결과와 충돌 가능성.
  3. UringCmd80 opcode 분기를 NVME_URING_CMD_IO 단일 → zone append 등 추가 가능. zone append (NVME_IO_ZONE_APPEND = 0x7D) 는 nvme_uring_cmd_prepcmd.opcode 분기에 추가. PR 의 구조가 확장 가능하게 짜여 있음.

제약:

  1. char device 강제 — vendor SDK 가 char dev 를 노출 안 하면 cmd 경로 못 씀.
  2. kernel 5.19+ 필수 — Big SQE/CQE ABI 도입 시점. 이전 커널은 cmd 모드 자체 거부.
  3. LBA format metadata size != 0 이면 거부 — T10-PI 같은 end-to-end protection 환경 미지원.
  4. 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 다중 키 batcheddispatcher 인프라 깔림, 호출자 미적응
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 가 무효