본문으로 건너뛰기

PR #3274 — io_uring 인프라 복구 + NVMe io_uring_cmd passthrough

TL;DR — MP 모드 통합 rebase 중 누락됐던 io_uring 코드를 되살리고, NVMe io_uring_cmd passthrough 경로를 신규로 추가한다. raw_block backend의 모든 device I/O가 단일 dispatcher(_write_buffers / _read_buffers)를 통과하도록 정리됐고, io_uring 엔진에서는 N개 버퍼를 한 번의 batched_write로 묶어 제출할 수 있다. 신규 passthrough 경로는 Block Layer를 우회해 NVMe 드라이버에 직접 명령을 보내며, NVMe 명령 구조체의 cdw13.dspec 필드를 노출해 향후 데이터 배치(예: FDP placement) 연동의 배관까지 깔린다.


1. PR 개요

항목내용
PR#3274
작성자Ankit Kumar (@ankit-sam)
상태Open (리뷰 중)
Basedev (head sha bf7d2ff1)
변경량11 files, +2,212 / −327
핵심 영역lmcache/v1/storage_backend/raw_block/core.py, rust/raw_block/src/lib.rs
커널 요건io_uring 사용 시 5.4+, io_uring_cmd 사용 시 5.19+ (Big SQE/CQE ABI)

2. 배경 — 왜 이 PR이 필요한가

2.1 io_uring 코드 복구

이전 MP 모드 통합 rebase 과정에서 io_uring 관련 변경이 일부 누락됐다(원본: #3119). 이 PR이 그 부분을 부분적으로 되살린다. 동시에, 모든 I/O 호출자(슬롯 write, 다중 load, 메타데이터 검증, 체크포인트 R/W)가 Rust 바인딩의 pwrite_from_buffer / pread_into를 직접 호출하던 구조를, 단일 dispatcher로 우회시키도록 정리한다. 호출 지점마다 엔진(POSIX vs io_uring) 분기를 넣지 않기 위함이다.

2.2 NVMe io_uring_cmd passthrough 신규 도입

리눅스 5.19에 도입된 IORING_OP_URING_CMD로 NVMe 명령을 raw로 제출하는 경로를 추가한다. 파일시스템과 Block Layer 대부분을 건너뛰고 NVMe 드라이버에 직접 명령이 들어간다. 단, 블록 디바이스가 아니라 NVMe namespace character device (/dev/ngXnY) 가 필요하다.


3. I/O 경로 비교

posix:
App → libc pwrite/pread → VFS → Block Layer → NVMe Driver → SSD

io_uring (일반):
App → io_uring (SQE 64B) → Block Layer → NVMe Driver → SSD

io_uring_cmd (이 PR이 추가):
App → io_uring_cmd (big SQE 128B) → NVMe Driver → SSD

Block Layer 완전 생략

4. Python 측: 단일 dispatcher

4.1 _write_buffers / _read_buffers

모든 I/O가 아래 두 함수를 통과한다. (core.py:1245-1348)

호출자 (슬롯 write / 다중 load / 메타 검증 / 체크포인트 R/W)


_write_buffers(offsets, buffers, payload_lens, total_lens)
_read_buffers (offsets, buffers, payload_lens, total_lens)

├─ io_engine != "io_uring":
│ └─ 버퍼마다: pwrite_from_buffer / pread_into (POSIX, 기존 동작)

├─ io_engine == "io_uring" + use_uring_cmd=True:
│ └─ _write_uring_cmd_buffers / _read_uring_cmd_buffers
│ (NVMe passthrough, MDTS 단위 chunk split)

└─ io_engine == "io_uring" (일반 block 모드):
├─ can_batch: batched_write / batched_read + wait_iouring
└─ else: write_uring / read_uring (개별 비동기)

인자는 모두 병렬 리스트다. offsets[i], buffers[i], payload_lens[i], total_lens[i]가 i번째 I/O 한 건을 기술한다. 리스트에 N개를 담아 한 번 호출하면 dispatcher가 엔진에 맞게 N건을 처리한다.

  • payload_len — 실제 의미 있는 바이트 수
  • total_len — 디바이스에 쓰는 전체 길이 (O_DIRECT 정렬 round-up 포함)

4.2 can_batch — batch 제출 조건

io_uring 일반 모드에서 dispatcher는 다음을 판정한다.

can_batch = all(payload_len == total_len for ... in zip(payload_lens, total_lens))

모든 버퍼에 대해 payload_len == total_len일 때만 batched_write를 쓴다. 이유:

  • batched_write는 single-shot SQE 묶음이다. 한 SQE = 한 (offset, buffer, len). 버퍼 내부의 padding이나 short-write 개념이 없다.
  • 정렬이 안 맞는 버퍼를 넘기면 Rust 측에서 즉시 ValueError (bounce buffer를 쓰지 않음).

read 경로는 한 단계 더 추가로, can_batch가 참이라도 모든 버퍼가 정렬되어 있어야 batched_read를 쓴다(O_DIRECT 요건).

4.3 호출자별 현황

각 호출 지점에서 한 번 부를 때 리스트 길이 N과 batch 사용 여부:

호출자호출당 Ncan_batch비고
슬롯 write (_write_one)2 (헤더+페이로드)Trueio_uring 모드에서 헤더 payload_len = hdr_total로 패스 (core.py:1380-1388)
다중 load (entry당 1회 호출, payload만)1Truecore.py:792-808
슬롯 헤더 검증 (_read_slot_header)1Trueheader_bytes는 block_align 배수 (core.py:239-240)
체크포인트 meta header read1Truecore.py:1509-1514
체크포인트 meta payload read1Truecore.py:1540
체크포인트 write (header+payload)2True둘 다 block_align 정렬 (core.py:1657-1662)

헤더가 block_align 배수로 강제되는 이유. RawBlockCoreheader_bytes % block_align != 0이면 초기화에서 ValueError를 던진다 (core.py:239-240). 즉 헤더 자체가 이미 정렬돼 있으므로 io_uring 경로에서 헤더의 payload_len을 padded total로 그대로 패스해도 된다 — 이것이 슬롯 write가 can_batch=True로 들어가는 이유다.

4.4 register_fixed_buffers_from_allocator — zero-copy

def register_fixed_buffers_from_allocator(self, memory_allocator) -> None:
buffers = memory_allocator.get_paged_buffers()
buffer_ptrs = [buf.data_ptr() for buf in buffers]
buffer_sizes = [buf.numel() * buf.element_size() for buf in buffers]
self._rawdev().register_fixed_buffers(buffer_ptrs, buffer_sizes)

CPU allocator의 페이지 버퍼를 io_uring에 미리 등록(register_buffers syscall)해서, 이후 해당 버퍼 I/O는 zero-copy. 할당자가 해당 메서드를 노출하지 않으면 경고 후 non-fixed 모드로 fallback한다.

4.5 max_hw_sectors_kb — 자동 전송 크기 분할

max_hw_sectors_kb = _read_sysfs_int(f"{queue_dir}/max_hw_sectors_kb")
resolved_bytes = max_hw_sectors_kb * 1024
aligned_bytes = (resolved_bytes // self.block_align) * self.block_align

NVMe 디바이스가 한 번에 처리할 수 있는 최대 바이트(MDTS)를 sysfs에서 읽어, 단일 청크가 이를 초과하면 _write_uring_cmd_buffers / _read_uring_cmd_buffers가 여러 NVMe 명령으로 분할 발행한다.


5. Rust 측: io_uring 엔진

5.1 IoUringWrapper — 커널 호환 dual ring

io_uring ring을 두 종류로 들고 있다. (lib.rs:39-74)

#[derive(Clone)]
enum IoUringWrapper {
Standard(Arc<Mutex<IoUring<SqueueEntry, Entry>>>), // 커널 5.4~5.18
Big(Arc<Mutex<IoUring<Entry128, Entry32>>>), // 커널 5.19+
}
모드SQE 크기CQE 크기필요 커널용도
Standard64 B16 B5.4+일반 read/write
Big128 B32 B5.19+NVMe uring_cmd (passthrough). NVMe 명령 구조체(80 B)를 SQE에 inline으로 담으려면 128-byte SQE가 필수

초기화 — Big 먼저 시도, 실패 시 Standard fallback: (lib.rs:1055-1106)

let ring = match IoUring::<Entry128, Entry32>::builder()
.build(iouring_queue_depth as u32)
{
Ok(big_ring) => IoUringWrapper::Big(Arc::new(Mutex::new(big_ring))),
Err(_) => {
if use_uring_cmd {
return Err(PyRuntimeError::new_err(
"io_uring_cmd requires kernel 5.19 or later",
));
}
// fallback: Standard
IoUringWrapper::Standard(Arc::new(Mutex::new(std_ring)))
}
};

5.2 batch 제출 API — batched_write / wait_iouring

fn batched_write(offsets: Vec<u64>, buffers: Vec<PyAny>, total_lens: Vec<usize>) -> u64
fn wait_iouring(batch_id: u64)
  • batched_write는 N개의 (offset, buffer, len)을 받아 한 번의 io_uring_submit으로 N개 SQE를 제출하고 batch_id를 반환한다 (lib.rs:1975).
  • wait_iouring(batch_id)는 그 배치의 모든 완료(CQE)를 수거할 때까지 대기한다 (lib.rs:2203).
  • batch_id별로 in-flight 카운트와 완료 핸들러를 따로 추적한다.

N개를 한 번에 제출하면 디바이스(NVMe NCQ)가 여러 명령을 병렬로 처리할 수 있다. 반대로 한 건씩 write_uring → 완료 대기를 N번 반복하면 직렬화된다.

5.3 register_fixed_buffers — 커널 등록

fn register_fixed_buffers(&self, buffer_ptrs: Vec<usize>, buffer_sizes: Vec<usize>) -> PyResult<()> {
let mut map = self.fixed_buffer_map.lock().unwrap();
for (idx, (ptr, size)) in buffer_ptrs.iter().zip(buffer_sizes.iter()).enumerate() {
map.insert(*ptr, (idx as u16, *size));
}
let iovecs: Vec<libc::iovec> = ...;
ring.submitter().register_buffers(&iovecs) // syscall 1번으로 N개 버퍼 등록
}

버퍼를 커널에 미리 등록해 두면 I/O 시 buf_index로 참조 가능 → 매번 주소 번역 불필요. CPU/GPU 메모리를 한 번 핀하고 재사용하는 구조 (lib.rs:1906-1966).

5.4 Worker thread 배치 submit 패턴

let batch: Vec<IoSubmission> = std::mem::take(&mut *q);
for sub in batch.iter().take(to_submit_count) {
build_and_submit_sqe(&ring_clone, sub, user_data); // SQ에 추가 (syscall 아님)
}
ring.submitter().submit() // syscall 1번으로 N개 한꺼번에 커널로

요청을 SQ에 쌓은 뒤 한 번의 syscall로 배치 제출 → N개 I/O에 syscall 1번 (lib.rs:1234 build_and_submit_sqe).


6. NVMe io_uring_cmd passthrough (PR 신규 영역)

6.1 NvmeUringCmd — NVMe 명령 구조체 (80 B)

#[repr(C)]
#[derive(Debug, Clone, Copy)]
struct NvmeUringCmd {
opcode: u8, // NVME_IO_READ(0x02) / NVME_IO_WRITE(0x01)
flags: u8,
rsvd1: u16,
nsid: u32, // NVMe Namespace ID
cdw2: u32,
cdw3: u32,
metadata: u64,
addr: u64, // 데이터 버퍼 주소
metadata_len: u32,
data_len: u32, // 전송 크기 (bytes)
cdw10: u32, // SLBA[31:0] — 시작 LBA 하위 32비트
cdw11: u32, // SLBA[63:32] — 시작 LBA 상위 32비트
cdw12: u32, // NLB (Number of Logical Blocks − 1) | dtype
cdw13: u32, // dspec — placement handle ID 등 (← 핵심 노출 지점)
cdw14: u32,
cdw15: u32,
rsvd2: [u32; 4],
}

(lib.rs:140-160) NVMe 명령 구조체가 80 B이기 때문에 일반 64 B SQE에는 들어갈 수 없고 Big (128 B SQE) ring이 필수다.

6.2 nvme_uring_cmd_prep — NVMe 명령 빌드

fn nvme_uring_cmd_prep(
cmd: &mut NvmeUringCmd,
is_write: bool,
nsid: u32,
offset: u64, // 바이트 오프셋
len: usize,
lba_shift: u32, // LBA 크기 = 1 << lba_shift (9=512B, 12=4KB)
ptr: *const u8,
dtype: u8, // Directive Type (FDP의 경우 2)
dspec: u16, // placement handle ID 등
) -> Result<(), PyErr> {
// offset과 len이 LBA 크기 배수인지, NLB가 16비트에 들어가는지 검증

let slba = offset >> lba_shift; // 바이트 오프셋 → LBA 번호
let nlb = (len >> lba_shift) - 1; // 전송 크기 → 블록 수 - 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);
cmd.cdw13 = (dspec as u32) << 16; // dspec은 cdw13 비트 16-31
cmd.addr = ptr as u64;
cmd.data_len = len as u32;
Ok(())
}

(lib.rs:430-499)

LBA 변환 요약:

바이트 오프셋 → SLBA = offset >> lba_shift
전송 크기 → NLB = (len >> lba_shift) - 1

6.3 전체 I/O 흐름 (io_uring_cmd 경로)

Python: put_many(keys, objs)
└─ _write_buffers(offsets, bufs, ...)
└─ use_uring_cmd=True
└─ _write_uring_cmd_buffers() (MDTS 단위로 chunk split)
└─ raw_dev.batched_write(chunk_offsets, chunk_buffers, chunk_lens)
└─ Worker: nvme_uring_cmd_prep(cmd, offset, len, ..., dspec)
└─ IoUringWrapper::Big → UringCmd80 → SQ push
└─ submitter().submit() [syscall 1번]
└─ NVMe HW: SLBA, NLB, (dtype/dspec) 처리
└─ wait_iouring(batch_id) → CQ 수거

6.4 cdw13.dspec — 데이터 배치 배관 노출

nvme_uring_cmd_prepdspec 파라미터가 NVMe 명령의 cdw13 비트 16-31에 인코딩된다 (lib.rs:492):

cmd.cdw13 = (dspec as u32) << 16;

이 비트는 NVMe 사양상 directive 종류에 따라 placement handle ID 등을 전달하는 자리다. 본 PR의 worker는 현재 dspec: 0으로 호출하지만 (lib.rs:2120 부근), Rust 헬퍼 자체는 dspec 파라미터를 받는 형태로 노출돼 있다 — 즉 PR이 설치한 것은 "passthrough 경로 + dspec 인코딩 위치"까지의 배관이다.


7. 변경 파일 요약 (11 files, +2,212 / −327)

파일+/−핵심
rust/raw_block/src/lib.rs+1107 / −282IoUringWrapper, NvmeUringCmd, nvme_uring_cmd_prep, register_fixed_buffers, batched_write/batched_read/wait_iouring, worker submit
lmcache/v1/storage_backend/raw_block/core.py+501 / −29_write_buffers/_read_buffers dispatcher, _write_uring_cmd_buffers/_read_uring_cmd_buffers, register_fixed_buffers_from_allocator, max_hw_sectors_kb
lmcache/v1/storage_backend/plugins/rust_raw_block_backend.py+97 / −8use_uring_cmd 설정 전달, fixed buffer 등록 호출
lmcache/v1/distributed/l2_adapters/raw_block_l2_adapter.py+28 / −2MP L2 어댑터 분기
tests/v1/storage_backend/test_raw_block_uring_cmd.py+320 / 0uring_cmd 신규 단위 테스트
tests/v1/distributed/test_raw_block_l2_adapter.py+45 / 0L2 어댑터 테스트
benchmarks/storage_backend_io/storage_backend_io_benchmark.py+40 / −3uring_cmd 벤치마크 옵션
docs/source/mp/l2_storage.rst, benchmarks/.../README.md, rust/raw_block/README.md, tests/v1/storage_backend/test_rust_raw_block_backend.py그 외문서·테스트 부수 변경

8. 현재 제약 / 리뷰 피드백

항목내용
fixed buffer + uring_cmd 조합아직 미구현 (PR 본문 명시)
MP 모드의 write 배치헤더와 페이로드 단위까지만 묶음 (요청 순서 보장이 필요해 추가 작업으로 미룸)
정렬 검증 로직오정렬 바이트 범위 검증 개선 요청 (DongDongJu 코멘트)
비정렬 I/O지원 안 함 (블록 정렬 전송만)
--use-uring-cmd UX--use-uring 없이 단독 사용 시 오해 소지 있는 에러 메시지
max_hw_sectors_kb 한계디바이스 reported max_data_transfer_size보다 작을 수 있음. hugepage 지원 머지 후 확장 가능

9. 참고

  • 커널 요건: io_uring 5.4+, io_uring_cmd (Big SQE/CQE) 5.19+
  • O_DIRECT 정렬: 모든 버퍼가 block_align (통상 4096 B) 배수여야 batch 제출 가능
  • NVMe character device: use_uring_cmd=True일 때 /dev/ngXnY (NVMe namespace character device) 필요
  • PR 링크: https://github.com/LMCache/LMCache/pull/3274