TECH 으로 돌아가기
TECH HACKER NEWS 오늘 9분 읽기 33 READS

mmap을 io_uring으로 바꿨더니 오히려 느려졌다: Rust 쿼리 엔진 실험이 알려주는 것

mmap을 io_uring으로 바꿨더니 오히려 느려졌다: Rust 쿼리 엔진 실험이 알려주는 것
SOURCE IMAGE · HACKER NEWS
mmap을 io_uring으로 바꿨더니 오히려 느려졌다: Rust 쿼리 엔진 실험이 알려주는 것

'최신 기술로 바꿨더니 느려졌다'는 정직한 실험 보고

스트리밍 서비스 품질 분석을 하는 Conviva라는 회사가 있어요. 영상 서비스의 시청 데이터를 실시간으로 모아서 분석하는 곳인데, 그 밑바닥에는 Rust로 직접 만든 쿼리 엔진이 돌아가요. 이 팀이 최근에 엔지니어링 블로그에 올린 글의 제목이 인상적이에요. 파일 읽기 방식을 mmap에서 io_uring으로 바꿨더니 오히려 느려졌다는 거예요.

보통 기술 블로그는 '바꿨더니 3배 빨라졌다' 같은 성공담을 올리잖아요. 그런데 이 글은 실패담이에요. 그래서 오히려 더 배울 게 많아요. 두 기술이 각각 뭔지, 왜 그런 결과가 나왔을지 차근차근 풀어볼게요.

mmap이 뭐냐면

mmap은 파일을 프로세스의 메모리 주소 공간에 '붙여넣는' 시스템 콜이에요. 파일을 열고 read()로 조각조각 읽어오는 대신, 파일 전체가 마치 거대한 배열처럼 메모리에 있는 것처럼 보이게 만들어요. 그 배열의 어떤 위치를 건드리면 그때 커널이 페이지 폴트(아직 메모리에 없는 부분을 요청했을 때 발생하는 인터럽트)를 처리하면서 디스크에서 해당 부분을 읽어와요.

mmap의 진짜 장점은 커널의 페이지 캐시를 그대로 쓴다는 점이에요. 리눅스는 한 번 읽은 파일 내용을 남는 메모리에 캐시로 보관하는데, mmap은 그 캐시 페이지를 프로세스 주소 공간에 직접 매핑해요. 즉 커널 버퍼에서 사용자 버퍼로 복사하는 과정이 없어요. 그리고 커널이 접근 패턴을 보고 앞부분을 미리 읽어두는 readahead 기능도 공짜로 따라와요.

단점도 분명해요. 페이지 폴트 자체가 비용이고, 언제 디스크 I/O가 발생할지 코드에서 제어하기 어려워요. 파일이 중간에 잘리면 프로그램이 SIGBUS로 죽어버리기도 하고요. 그래서 CMU의 Andy Pavlo 교수팀이 2022년에 '정말 DBMS에 mmap을 쓰고 싶으세요?'라는 제목의 논문까지 냈어요. 데이터베이스 엔진에서 mmap은 위험하다는 취지였죠.

io_uring이 뭐냐면

io_uring은 리눅스 5.1부터 들어온 비동기 I/O 인터페이스예요. 핵심 아이디어는 링 버퍼 두 개예요. 프로그램이 '이 파일의 이 위치를 읽어줘'라는 요청을 제출 큐에 넣으면, 커널이 처리한 뒤 완료 큐에 결과를 넣어줘요. 이 두 큐가 프로그램과 커널 사이에 공유 메모리로 존재하기 때문에, 요청 100개를 시스템 콜 한 번으로 보낼 수 있고 경우에 따라 시스템 콜 없이도 돌아가요.

시스템 콜은 사용자 모드에서 커널 모드로 전환하는 비용이 꽤 커요. 특히 Spectre 같은 취약점 완화 패치 이후로 더 비싸졌고요. 그래서 io_uring은 '시스템 콜을 줄이고, 디스크를 기다리는 동안 CPU가 다른 일을 하게 하자'는 철학의 결정판이라고 볼 수 있어요. 고성능 스토리지 엔진들이 앞다퉈 채택한 이유예요.

그런데 왜 느려졌을까요

이 글의 결과와 두 기술의 특성을 종합하면 몇 가지 이유가 유력해요.

첫째, 데이터가 이미 페이지 캐시에 올라와 있는 상황이에요. 분석 쿼리 엔진은 같은 데이터를 반복해서 읽는 경우가 많거든요. 이때 mmap은 사실상 그냥 메모리 접근이에요. 페이지 폴트도 첫 접근 이후로는 안 나고요. 반면 io_uring으로 읽으면 캐시에 있든 없든 커널 버퍼에서 사용자가 준비한 버퍼로 데이터를 한 번 복사해야 해요. 큰 데이터를 스캔하는 워크로드에서 이 복사 비용이 시스템 콜 절감 효과보다 클 수 있어요.

둘째, io_uring은 만능 비동기가 아니에요. 파일 시스템에 따라 진짜 비동기로 처리하지 못하는 연산은 커널 내부의 워커 스레드로 넘겨서 처리하는데, 이 경로가 생각보다 느려요. 페이지 캐시 히트일 때는 커널이 그 자리에서 처리하지만, 그렇지 않은 경우 스레드 전환이 생기거든요.

셋째, Rust와의 궁합 문제예요. io_uring은 커널이 사용자 버퍼에 직접 쓰기 때문에, 요청이 완료될 때까지 그 버퍼가 살아있어야 해요. Rust의 소유권 모델에서 이걸 안전하게 표현하려면 버퍼 소유권을 런타임에 넘기거나 unsafe를 써야 하고, 그 과정에서 추가 할당이나 복사가 끼어들기 쉬워요. tokio-uring, glommio, monoio 같은 크레이트들이 각자 다른 방식으로 이 문제를 풀고 있는데, 아직 정답이 없는 영역이에요.

넷째, mmap의 readahead를 손으로 재현하는 게 어렵다는 점이에요. 커널은 순차 접근을 감지하면 알아서 앞을 크게 미리 읽어두는데, io_uring으로 바꾸면 그 판단을 프로그램이 직접 해야 해요. 이걸 대충 하면 작은 요청이 잔뜩 날아가면서 오히려 효율이 떨어져요.

업계에서는 어떻게 하고 있나요

재미있는 건 성공한 엔진들의 선택이 갈린다는 점이에요. TigerBeetle은 처음부터 io_uring 위에 설계했고, ScyllaDB는 자체 비동기 I/O 프레임워크를 써요. 반면 LMDB는 mmap 위에 지어진 대표적인 성공 사례이고, DuckDB는 둘 다 안 쓰고 자체 버퍼 관리자를 통해 read()로 읽어요. 그러니까 정답은 기술이 아니라 워크로드에 있다는 거예요. 랜덤 쓰기가 많은 트랜잭션 엔진과 큰 파일을 순차로 훑는 분석 엔진은 최적의 I/O 전략이 달라요.

한국 개발자에게 주는 시사점

첫 번째 교훈은 벤치마크를 먼저 하라는 거예요. 특히 페이지 캐시가 비어 있는 콜드 상태와 데이터가 이미 올라와 있는 웜 상태를 분리해서 측정하세요. 이 둘의 결과가 정반대로 나오는 경우가 흔해요.

두 번째는 io_uring을 쓸 수 있는 환경인지 확인하는 거예요. 컨테이너 환경에서는 seccomp 정책으로 io_uring이 막혀 있는 경우가 있고, 일부 클라우드는 보안 이슈로 아예 비활성화해두기도 해요. 그래서 io_uring 경로와 폴백 경로를 둘 다 유지해야 하는 부담이 생겨요.

세 번째는 실패담도 공유할 가치가 있다는 거예요. 우리 팀에서도 '이거 해봤는데 안 됐어요'를 기록해두면 다음 사람이 같은 삽질을 안 하거든요.

정리하면

io_uring은 훌륭한 도구지만, 페이지 캐시가 따뜻한 분석 워크로드에서는 mmap의 무복사 접근을 이기기 어렵다는 게 이번 실험의 핵심이에요.

여러분 프로젝트에서는 파일 I/O를 어떤 방식으로 하고 계세요? mmap이나 io_uring으로 바꿔본 경험이 있다면 결과가 어땠는지 공유해주시면 좋겠어요.


🔗 출처: Hacker News

SOURCE · HACKER NEWS
원문 전체 보기 → https://www.conviva.ai/resource/we-replaced-mmap-with-io_uri...
SHARE
NEXT · CHOOSE

변화를 읽었다면,
내가 만들 수익 구조를 고릅니다.

정보를 더 모으는 데서 멈추지 않고, 광고·외주·판매·중개·구독 중 내 상황에 맞는 출발점을 정해보세요.

21가지 수익 구조 살펴보기
처리 중...