Workspace IndexAlgorithms › Day 41

Kernel Bypass and Zero-Copy — io_uring TODO

Algorithms · Day 41 / 100 · C. Concurrency & Performance Engineering (Day 36-51)

Concept

Traditional socket I/O crosses the user-kernel boundary on every system call and copies data between kernel and user buffers, so for workloads with very many small requests, that overhead dominates the total cost. Zero-copy is a family of techniques that eliminates or reduces that copying — sendfile or splice for file transfer, which keep data from passing through user space, are the classic examples. io_uring provides an asynchronous interface built on two ring buffers shared between kernel and user space (a submission queue and a completion queue): you write requests into the queue and read completions out of it, and you can submit many requests in a single system call, or in polling mode make progress with no system calls at all. Kernel bypass goes further still, mapping NIC queues directly into a user-space driver and skipping the kernel network stack entirely — latency drops sharply, but the application now has to take on the protocol handling and protection the kernel used to provide. What all these techniques share is reducing the number of boundary crossings and the number of copies, at the cost of complexity and portability.

In matching engines or ultra-high-frequency RPC gateways where microsecond-level latency matters, the bottleneck is often system-call and copy overhead rather than application logic, so you need to be able to tell how much of the cost is kernel cost.

Code & Formula

# 커널 바이패스와 zero-copy — io_uring 의 핵심 아이디어(제출 큐 SQ / 완료 큐 CQ 를 유저-커널이 공유)를 순수 파이썬으로 흉내낸다.
# 진짜 io_uring 은 시스템 콜 없이 링 버퍼만으로 요청/완료를 주고받지만, 여기서는 그 인터페이스 모양만 재현한다.

from collections import deque

class TinyIoUring:
    def __init__(self):
        self.submission_queue = deque()   # SQ: 유저가 써넣고 커널이 소비
        self.completion_queue = deque()   # CQ: 커널이 써넣고 유저가 소비
        self._next_id = 0

    def submit(self, op, payload):
        """유저 공간: 요청을 SQ 에 밀어넣는다 — 요청마다 시스템 콜을 하지 않고 큐에만 쌓는다."""
        req_id = self._next_id
        self._next_id += 1
        self.submission_queue.append((req_id, op, payload))
        return req_id

    def kernel_process_batch(self):
        """커널 쪽 처리를 흉내: SQ 에 쌓인 요청을 한 번에(배치로) 처리해 CQ 에 완료를 채운다.
        여기서 핵심은 요청 N개를 시스템 콜 1번(=이 함수 호출 1번)으로 끝낸다는 것 — zero-copy 의 핵심도
        '경계를 넘는 횟수'와 '복사 횟수'를 줄이는 데 있다."""
        processed = 0
        while self.submission_queue:
            req_id, op, payload = self.submission_queue.popleft()
            if op == "read":
                result = f"data({payload})"      # 실제로는 유저 버퍼로 직접 DMA 되어 복사가 생략됨
            elif op == "write":
                result = f"written:{len(payload)}bytes"
            else:
                result = None
            self.completion_queue.append((req_id, result))
            processed += 1
        return processed

    def reap_completions(self):
        """유저 공간: CQ 에서 완료된 결과를 꺼낸다 — 이것도 시스템 콜 없이 공유 메모리 읽기만으로 끝난다."""
        out = []
        while self.completion_queue:
            out.append(self.completion_queue.popleft())
        return out

ring = TinyIoUring()

# 전통적 블로킹 I/O 라면 read() 5번 = 시스템 콜 5번이지만, 여기서는 SQ 에 5개를 한꺼번에 밀어넣는다.
req_ids = [ring.submit("read", f"/file{i}") for i in range(5)]
req_ids.append(ring.submit("write", "payload-bytes"))

requests_processed = ring.kernel_process_batch()   # 이 함수 호출 자체가 "시스템 콜 1회"에 대응한다
enter_syscalls = 1                                  # io_uring_enter() 를 딱 한 번만 부른 셈
completions = ring.reap_completions()

print("submitted requests:", len(req_ids))
print("requests completed in this batch:", requests_processed)
print("io_uring_enter() calls needed:", enter_syscalls,
      f"-> 전통적 blocking read/write였다면 {len(req_ids)}번의 시스템 콜이 필요했다")
print("completions:", completions)

Exercise

Implement the same echo server on epoll and on io_uring, and compare p99 latency and system calls per second (via strace -c or perf) under identical load.

Practical Connection

When a chain node or RPC proxy handling huge numbers of small JSON-RPC requests shows CPU time concentrated in kernel time, batched submission and reduced copying are worth checking before adding hardware.

Where it lands in Jayverse

Key expressions

Words and phrases from this page worth keeping, with the Korean meaning and the sentence they come from.

Expression뜻 · 쓰이는 자리
cross the boundary경계를 넘나들다 · 매 시스템콜마다 유저-커널 경계를 넘는 오버헤드를 설명 · "crosses the user-kernel boundary on every system call"
dominate(비용·시간에서) 압도적 비중을 차지하다 · 오버헤드가 전체 비용의 대부분을 차지함 · "that overhead dominates the total cost"
keep ~ from ~ing~가 ~하지 못하게 막다 · 데이터가 유저 공간을 거치지 않도록 막는 기법 · "keep data from passing through user space"
take on(책임·역할을) 떠맡다 · 커널이 하던 프로토콜 처리를 애플리케이션이 대신 떠맡음 · "the application now has to take on the protocol handling"
at the cost of~을 대가로, ~을 희생하고 · 복잡도와 이식성을 대가로 경계 넘나듦을 줄임 · "at the cost of complexity and portability"
make progress진전을 이루다, 작업을 처리하다 · 시스템콜 없이도 처리를 이어갈 수 있음 · "make progress with no system calls at all"
concentrated in~에 집중되어 있다 · CPU 시간이 커널 시간에 몰려 있는 현상 · "shows CPU time concentrated in kernel time"
io_uring아이오유링(io_uring) · 커널·유저 공간이 링 버퍼를 공유해 시스템콜 없이도 비동기 I/O를 처리하는 리눅스 인터페이스. "io_uring provides an asynchronous interface built on two ring buffers"
NIC네트워크 인터페이스 카드(Network Interface Card, NIC) · 커널 바이패스가 큐를 유저공간 드라이버로 직접 매핑하는 하드웨어. "mapping NIC queues directly into a user-space driver"
epoll이폴(epoll) · 리눅스의 전통적 이벤트 기반 I/O 다중화 API, io_uring과의 비교 대상. "Implement the same echo server on epoll and on io_uring"
p99p99 레이턴시(99번째 백분위수 지연) · 전체 요청 중 99%가 이 값 이하로 끝났음을 나타내는 성능 지표. "compare p99 latency and system calls per second"

If you study this on a given day, add a note link and a ✅ to this line in the source curriculum (docs/knowledge/dev-100-curriculum.md) and this spot will lead straight to the note body. You can also write directly on this page — but regenerating overwrites it, so it's safer to keep anything you want to save as markdown under docs/algorithms/.


한국어

커널 바이패스와 zero-copy TODO

Algorithms · Day 41 / 100 · C. 동시성·성능 엔지니어링 (Day 36–51)

io_uring

개념

전통적인 소켓 I/O는 시스템 콜마다 유저-커널 경계를 넘고 커널 버퍼와 유저 버퍼 사이에서 데이터를 복사하므로, 작은 요청이 매우 많은 워크로드에서는 이 오버헤드가 전체 비용을 지배한다. zero-copy는 그 복사를 제거하거나 줄이는 기법으로, 파일 전송의 sendfile이나 splice처럼 데이터가 유저 공간을 거치지 않게 하는 방식이 대표적이다. io_uring은 커널과 유저 공간이 공유하는 두 개의 링 버퍼(제출 큐와 완료 큐)를 두어 요청을 큐에 써 넣고 완료를 큐에서 읽는 비동기 인터페이스이며, 여러 요청을 한 번의 시스템 콜로 제출하거나 폴링 모드에서는 시스템 콜 없이도 진행시킬 수 있다. 커널 바이패스는 한 걸음 더 나아가 NIC 큐를 유저 공간 드라이버에 직접 매핑해 커널 네트워크 스택 자체를 건너뛰는 접근으로, 지연은 크게 줄지만 커널이 제공하던 프로토콜 처리와 보호를 애플리케이션이 떠안게 된다. 즉 이 계열 기법의 공통 축은 '경계 넘기 횟수'와 '복사 횟수'를 줄이는 것이며, 그 대가는 복잡도와 이식성이다.

지연이 마이크로초 단위로 중요한 매칭 엔진이나 초고빈도 RPC 게이트웨이에서는 애플리케이션 로직이 아니라 시스템 콜·복사 비용이 병목이 되므로, 어디까지가 커널 비용인지 구분할 줄 알아야 한다.

코드 · 수식

# 커널 바이패스와 zero-copy — io_uring 의 핵심 아이디어(제출 큐 SQ / 완료 큐 CQ 를 유저-커널이 공유)를 순수 파이썬으로 흉내낸다.
# 진짜 io_uring 은 시스템 콜 없이 링 버퍼만으로 요청/완료를 주고받지만, 여기서는 그 인터페이스 모양만 재현한다.

from collections import deque

class TinyIoUring:
    def __init__(self):
        self.submission_queue = deque()   # SQ: 유저가 써넣고 커널이 소비
        self.completion_queue = deque()   # CQ: 커널이 써넣고 유저가 소비
        self._next_id = 0

    def submit(self, op, payload):
        """유저 공간: 요청을 SQ 에 밀어넣는다 — 요청마다 시스템 콜을 하지 않고 큐에만 쌓는다."""
        req_id = self._next_id
        self._next_id += 1
        self.submission_queue.append((req_id, op, payload))
        return req_id

    def kernel_process_batch(self):
        """커널 쪽 처리를 흉내: SQ 에 쌓인 요청을 한 번에(배치로) 처리해 CQ 에 완료를 채운다.
        여기서 핵심은 요청 N개를 시스템 콜 1번(=이 함수 호출 1번)으로 끝낸다는 것 — zero-copy 의 핵심도
        '경계를 넘는 횟수'와 '복사 횟수'를 줄이는 데 있다."""
        processed = 0
        while self.submission_queue:
            req_id, op, payload = self.submission_queue.popleft()
            if op == "read":
                result = f"data({payload})"      # 실제로는 유저 버퍼로 직접 DMA 되어 복사가 생략됨
            elif op == "write":
                result = f"written:{len(payload)}bytes"
            else:
                result = None
            self.completion_queue.append((req_id, result))
            processed += 1
        return processed

    def reap_completions(self):
        """유저 공간: CQ 에서 완료된 결과를 꺼낸다 — 이것도 시스템 콜 없이 공유 메모리 읽기만으로 끝난다."""
        out = []
        while self.completion_queue:
            out.append(self.completion_queue.popleft())
        return out

ring = TinyIoUring()

# 전통적 블로킹 I/O 라면 read() 5번 = 시스템 콜 5번이지만, 여기서는 SQ 에 5개를 한꺼번에 밀어넣는다.
req_ids = [ring.submit("read", f"/file{i}") for i in range(5)]
req_ids.append(ring.submit("write", "payload-bytes"))

requests_processed = ring.kernel_process_batch()   # 이 함수 호출 자체가 "시스템 콜 1회"에 대응한다
enter_syscalls = 1                                  # io_uring_enter() 를 딱 한 번만 부른 셈
completions = ring.reap_completions()

print("submitted requests:", len(req_ids))
print("requests completed in this batch:", requests_processed)
print("io_uring_enter() calls needed:", enter_syscalls,
      f"-> 전통적 blocking read/write였다면 {len(req_ids)}번의 시스템 콜이 필요했다")
print("completions:", completions)

연습

같은 에코 서버를 epoll 기반과 io_uring 기반으로 각각 구현해 동일 부하에서 p99 지연과 초당 시스템 콜 수(strace -c 또는 perf)를 비교해 보라.

실무 · Verex 연결

체인 노드나 RPC 프록시처럼 수많은 작은 JSON-RPC 요청을 처리하는 구간에서 CPU가 커널 시간에 몰려 있다면, 배치 제출과 복사 감소가 하드웨어 증설보다 먼저 검토할 카드다.

Jayverse에서의 위치

핵심 표현

이 페이지의 영어 본문에서 배울 만한 단어와 표현, 뜻과 나온 자리.

Expression뜻 · 쓰이는 자리
cross the boundary경계를 넘나들다 · 매 시스템콜마다 유저-커널 경계를 넘는 오버헤드를 설명 · "crosses the user-kernel boundary on every system call"
dominate(비용·시간에서) 압도적 비중을 차지하다 · 오버헤드가 전체 비용의 대부분을 차지함 · "that overhead dominates the total cost"
keep ~ from ~ing~가 ~하지 못하게 막다 · 데이터가 유저 공간을 거치지 않도록 막는 기법 · "keep data from passing through user space"
take on(책임·역할을) 떠맡다 · 커널이 하던 프로토콜 처리를 애플리케이션이 대신 떠맡음 · "the application now has to take on the protocol handling"
at the cost of~을 대가로, ~을 희생하고 · 복잡도와 이식성을 대가로 경계 넘나듦을 줄임 · "at the cost of complexity and portability"
make progress진전을 이루다, 작업을 처리하다 · 시스템콜 없이도 처리를 이어갈 수 있음 · "make progress with no system calls at all"
concentrated in~에 집중되어 있다 · CPU 시간이 커널 시간에 몰려 있는 현상 · "shows CPU time concentrated in kernel time"
io_uring아이오유링(io_uring) · 커널·유저 공간이 링 버퍼를 공유해 시스템콜 없이도 비동기 I/O를 처리하는 리눅스 인터페이스. "io_uring provides an asynchronous interface built on two ring buffers"
NIC네트워크 인터페이스 카드(Network Interface Card, NIC) · 커널 바이패스가 큐를 유저공간 드라이버로 직접 매핑하는 하드웨어. "mapping NIC queues directly into a user-space driver"
epoll이폴(epoll) · 리눅스의 전통적 이벤트 기반 I/O 다중화 API, io_uring과의 비교 대상. "Implement the same echo server on epoll and on io_uring"
p99p99 레이턴시(99번째 백분위수 지연) · 전체 요청 중 99%가 이 값 이하로 끝났음을 나타내는 성능 지표. "compare p99 latency and system calls per second"

공부한 날 원본 커리큘럼(docs/knowledge/dev-100-curriculum.md)의 이 줄에 노트 링크와 ✅ 를 붙이면, 이 자리는 노트 본문으로 바로 이어집니다. 노트 없이 이 페이지에 바로 적어도 됩니다 — 다만 다시 생성하면 덮어쓰이므로, 남길 글은 docs/algorithms/ 의 마크다운으로 쓰는 편이 안전합니다.

← 1093. 브랜치 예측·프리페치·데이터 지향 설계1095. 이벤트 루프 vs 스레드 vs 액터 모델 →