TECH 으로 돌아가기
TECH HACKER NEWS 오늘 8분 읽기 22 READS

mmap을 io_uring로 바꿨더니 60% 느려졌다 — Conviva의 교훈

mmap을 io_uring로 바꿨더니 60% 느려졌다 — Conviva의 교훈
SOURCE IMAGE · HACKER NEWS

고성능 쿼리 엔진에서 파일 I/O를 어떻게 처리하느냐는 성능 전체를 좌우하는 문제다. 스트리밍 품질 분석 업체 Conviva는 하루 수조 건의 이벤트를 다루는 자사 쿼리 엔진을 Rust, DataFusion, Arrow, Rayon, Tokio 위에 구축했다. 이 회사가 최근 공개한 엔지니어링 기록은, 성능 개선을 위해 오래 써온 mmap을 걷어내고 io_uring으로 갈아탔지만 첫 구현이 오히려 60% 느려졌다는 실패의 과정을 담고 있다. 결론이 아니라 그 과정에서 드러난 원인 분석이 국내 실무자에게 특히 참고할 만하다.

mmap이 편했던 이유, 그리고 무너진 지점

Conviva의 데이터는 대부분 수치형인 자체 포맷으로 인코딩돼 클라우드에 저장되고, 로컬 NVMe로 복사된 뒤 3~5GB 크기의 Arrow IPC 파일로 읽힌다. Arrow IPC는 디스크 레이아웃과 메모리 레이아웃이 동일해 디코딩 비용이 거의 없고, mmap을 쓰면 파일을 그대로 메모리처럼 다루는 무복사(zero-copy) 임의 접근이 가능하다. 전형적인 쿼리는 8개 배치 파일에서 6개 컬럼을 건드리며 하루치 약 13GB를 훑는다. 부하가 낮을 때 이 조합은 훌륭했고, 원시 이벤트에서 수 초 만에 결과를 냈다.

문제는 동시성이 올라가면서 시작됐다. 부하가 커지면 CPU 경쟁으로 지연이 다소 늘어나는 것은 정상이지만, Conviva가 관측한 p95·p99는 선형 확장으로 설명되는 수준을 크게 벗어났고 코어당 스캔 행 수도 급락했다. 원인을 좁히기 위해 같은 호스트에서 1개 파드와 4개 파드를 같은 부하로 비교했는데, 더 많은 병렬성과 격리를 기대한 4개 파드가 오히려 졌다. 14일 범위 쿼리에서 1개 파드가 최대 41% 빨랐다. mmap의 페이지 캐시는 호스트에 존재하며 모든 파드가 공유하기 때문에, 파드들은 CPU가 아니라 페이지 캐시를 두고 싸우고 있었던 것이다. perf 기록에서는 커널 수준 락 경합이 100%로 나타났다.

병목은 디스크가 아니라 페이지 캐시였다

Conviva는 추측 대신 계측으로 파고들었다. 메모리 압박이 심해지면 RSS가 램의 99%까지 차오르고, 커널은 여전히 필요한 페이지를 축출한 뒤 다시 읽어들이는 메이저 폴트 폭풍을 일으킨다. 동시에 마이너 폴트는 초당 수백만 건 수준으로 지속됐다. 마이너 폴트 하나가 원자적 연산으로 캐시 라인을 건드리는데, 초당 200만 건이면 L1·L2 캐시를 통째로 무너뜨린다. 이는 큰 룩업 테이블을 캐시에 상주시켜 쓰는 애플리케이션에는 치명적이다. 마이너 폴트 하나의 비용이 0.5~1마이크로초이므로 초당 200만 건은 사실상 mmap이 버틸 수 있는 상한에 가깝다.

수많은 Arrow 파일을 매핑하면서 가상 주소 공간은 약 3TB까지 불어났고, 폴트마다 mmap 락을 거는 VMA 조회가 뒤따랐다. 문맥 전환은 웜 캐시 상태의 초당 1만4천 건 대비 초당 200만 건으로 150배 치솟았다. 콜드 실행에서 실제 쿼리 코드가 CPU에서 차지하는 비중은 45%에서 5%로 떨어졌는데, 일이 줄어서가 아니라 커널이 페이지 캐시를 재삽입하느라 훨씬 바빠졌기 때문이다. 정작 mmap이 실제로 낸 처리량은 하드웨어 상한의 약 16%인 3.44GB/s에 불과했다. 이 격차가 개선의 기대치였다.

io_uring으로 갈아탔지만 오히려 느려졌다

io_uring의 매력은 비동기 커널 I/O뿐 아니라 O_DIRECT로 페이지 캐시를 완전히 우회하는 직접 사용자 I/O에 있다. Conviva는 Rust 네이티브 래퍼인 compio를 써서, 8개 배치 × 5개 컬럼에 해당하는 40개 컬럼 읽기를 한꺼번에 비동기 future로 던지는 첫 버전을 만들었다. 결과는 극적이었지만 방향이 엇갈렸다. 메이저 폴트는 12만8957건에서 3647건으로 35배 줄었다. io_uring이 해결하기로 되어 있던 바로 그 문제를 정확히 해결한 것이다. 그러나 마이너 폴트는 8배 늘었고, 전체 쿼리 시간은 13.6초에서 21.8초로 오히려 60% 느려졌다. 한 종류의 폴트를 다른 종류와 맞바꾸고 손해를 본 셈이다.

원인은 아키텍처에 있었다. 이들은 io_uring 배관과 쿼리 엔진 사이에 배치 구체화 계층(BMT)을 두었는데, 이 계층 하나가 프리페치·머티리얼라이즈 호출 수용, 컬럼별 future 제출, 완료 대기, Arrow 디코딩, 캐시 관리, API 제공까지 다섯 가지 일을 단일 스레드·단일 런타임에서 처리하고 있었다. 화이트보드에서는 관심사 분리처럼 보였지만, 실제로는 한 곳의 수정이 다른 곳으로 파급되는 구조였다.

부분적 개선은 있었다. 처음엔 O_DIRECT조차 켜지 않아 읽기가 여전히 페이지 캐시를 통과하고 있었고, 이를 켜자 21.8초가 약 19초로 줄었다. 다음으로 arrow-rs의 기본 Buffer 생성 방식인 Buffer::from_slice_ref가 매번 새 메모리를 할당하고 memcpy하면서 페이지마다 마이너 폴트를 유발한다는 점을 발견했다. 13GB 읽기에 800만 건의 마이너 폴트가 여기서 나왔다. io_uring이 소유한 바이트로 Buffer를 직접 구성해 복사를 건너뛰자 19초가 약 16초까지 내려갔지만, arrow-rs의 불변식을 우회하는 코드라 유지보수가 어려웠다. 재사용 가능한 버퍼 풀은 향후 과제로 남겼다.

실무자가 가져갈 교훈

결국 16초는 첫 버전보다는 낫지만 여전히 mmap보다 느렸다. 이 기록이 주는 실질적 시사점은 세 가지다. 첫째, mmap의 페이지 캐시는 암묵적 공유 상태여서, 컨테이너 밀도를 높일수록 각 프로세스가 정작 지연에 가장 중요한 자원을 제어하지 못한다는 점이다. 이는 mmap만의 문제가 아니라 버퍼드 I/O 경로 전반이 겪을 수 있는 함정이다. 둘째, io_uring과 O_DIRECT는 만능 스위치가 아니며, 상위 레이어에서 memcpy와 메모리 할당이 커널의 메모리 관리 작업을 다시 불러들이면 기껏 우회한 이점을 스스로 반납하게 된다. 셋째, 40개 SQE를 한꺼번에 던지는 순진한 프리페치와 여러 책임을 한 계층에 몰아넣은 설계처럼, 새 기술 도입은 그 기술에 맞는 아키텍처 재설계가 병행되지 않으면 역효과를 낸다는 점이다. Conviva는 40개 동시 제출 자체가 진짜 병목이었다는 사실과 이후의 구조 재설계를 후속편에서 다루겠다고 예고했으며, 새로운 I/O 스택을 검토 중인 팀이라면 벤치마크 블로그의 낙관을 그대로 믿기보다 자기 워크로드에서 폴트 통계와 락 경합을 직접 계측하는 편이 안전하다.

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

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

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

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