PR #3274 — io_uring 인프라 복구 + NVMe io_uring_cmd passthrough
TL;DR — MP 모드 통합 rebase 중 누락됐던 io_uring 코드를 되살리고, NVMe
io_uring_cmdpassthrough 경로를 신규로 추가한다. 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 (리뷰 중) |
| Base | dev (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 사용 여부:
| 호출자 | 호출당 N | can_batch | 비고 |
|---|---|---|---|
슬롯 write (_write_one) | 2 (헤더+페이로드) | True | io_uring 모드에서 헤더 payload_len = hdr_total로 패스 (core.py:1380-1388) |
| 다중 load (entry당 1회 호출, payload만) | 1 | True | core.py:792-808 |
슬롯 헤더 검증 (_read_slot_header) | 1 | True | header_bytes는 block_align 배수 (core.py:239-240) |
| 체크포인트 meta header read | 1 | True | core.py:1509-1514 |
| 체크포인트 meta payload read | 1 | True | core.py:1540 |
| 체크포인트 write (header+payload) | 2 | True | 둘 다 block_align 정렬 (core.py:1657-1662) |
헤더가
block_align배수로 강제되는 이유.RawBlockCore는header_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 크기 | 필요 커널 | 용도 |
|---|---|---|---|---|
Standard | 64 B | 16 B | 5.4+ | 일반 read/write |
Big | 128 B | 32 B | 5.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(())
}
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_prep의 dspec 파라미터가 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 / −282 | IoUringWrapper, 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 / −8 | use_uring_cmd 설정 전달, fixed buffer 등록 호출 |
lmcache/v1/distributed/l2_adapters/raw_block_l2_adapter.py | +28 / −2 | MP L2 어댑터 분기 |
tests/v1/storage_backend/test_raw_block_uring_cmd.py | +320 / 0 | uring_cmd 신규 단위 테스트 |
tests/v1/distributed/test_raw_block_l2_adapter.py | +45 / 0 | L2 어댑터 테스트 |
benchmarks/storage_backend_io/storage_backend_io_benchmark.py | +40 / −3 | uring_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