8P by GN⁺ 1일전 | ★ favorite | 댓글 1개
  • 이 글은 Linux에서 구현된 Unix 파이프의 성능을 점진적 최적화를 통해 분석함
  • 최초 간단한 파이프 프로그램의 대역폭은 약 3.5GiB/s로 측정되며, 프로파일링과 시스템콜 변경을 통해 이를 20배 이상 향상하는 과정을 다룸
  • vmsplice, splice 같은 Zero-Copy 시스템콜을 활용해 불필요한 데이터 복사를 줄이고, 페이지 사이즈를 키우는 등 다양한 최적화 기법을 설명함
  • Huge Page 사용과 바쁜루프(busy loop) 기법 적용으로 병목을 해결해 최대 62.5GiB/s의 처리속도를 기록함
  • 파이프, 페이징, 동기화 비용, Zero-Copy 등 고성능 서버와 커널 프로그래밍에서 중요한 요소들에 대한 인사이트를 제공함

개요 및 도입

  • 이 글에서는 Linux에서 Unix 파이프가 어떻게 구현되는지, 파이프를 통해 데이터를 읽고 쓰는 테스트 프로그램을 직접 작성해가며 점진적으로 성능을 최적화하는 과정을 다루는 내용임
  • 처음에는 대략 3.5GiB/s의 대역폭을 가진 단순 프로그램으로 시작해, 다양한 최적화를 거쳐 약 20배의 성능 향상을 달성함
  • 각 단계의 최적화는 perf 툴을 사용한 프로파일링 결과를 바탕으로 결정하며, 관련 소스코드는 GitHub에 공개되어 있음
  • 영감을 준 것은 고성능 FizzBuzz 프로그램(36GiB/s)에서 파이프를 이용한 데이터 처리 속도를 보며 시작한 고찰임
  • C언어에 대한 기초 수준의 지식만 있다면 내용 이해에 무리가 없음

파이프 성능 측정: 첫 느린 버전

  • 고성능 FizzBuzz 프로그램의 예시 실행 결과, 파이프를 통해 초당 36GiB의 데이터를 처리하는 것을 확인함
  • FizzBuzz는 L2 캐시 크기(256KiB) 블록 단위로 출력하여 메모리 접근과 IO 오버헤드 사이 균형을 맞춤
  • 이 글에서 작성한 파이프 성능 테스트 프로그램도 256KiB 블록 단위로 반복 출력(read/write)하며, 측정을 위해 read와 write 양 끝단을 모두 직접 구현
  • write.cpp는 동일한 256KiB 버퍼를 반복적으로 쓰고, read.cpp는 10GiB를 읽고 종결하며 처리량을 표시하는 구조임
  • 테스트 결과, 파이프를 통한 read/write는 3.7GiB/s로, FizzBuzz 대비 10배 느림이 관찰됨

write 동작의 병목과 내부 구조

  • perf 툴로 프로그램 실행 시 콜그래프를 추적한 결과, 전체 시간의 절반 가량이 파이프 쓰기(즉, pipe_write) 단계에서 소모됨을 확인함
  • pipe_write 내에서는 대다수 시간이 메모리 페이지 복사 및 할당(copy_page_from_iter, __alloc_pages) 에 소모됨
  • Linux 파이프는 원형버퍼(ring buffer) 형태로 구현되어 있으며, 각 엔트리는 실제 데이터가 저장된 페이지를 참조함
  • 파이프의 전체 버퍼 크기는 고정되어 있으며 파이프가 가득 차면 write가 블로킹되고, 비어있으면 read가 블로킹 상태가 됨
  • C 구조체(pipe_inode_info, pipe_buffer)에서 head와 tail은 각각 쓰기/읽기 위치를 나타내며, 개별 페이지의 오프셋과 길이 정보를 포함함

파이프의 읽기/쓰기 로직

  • pipe_write는 다음과 같은 순서로 동작함
    • 파이프가 가득 차 있으면 공간이 생길 때까지 대기
    • 현재 head에서 남는 공간을 우선 채움
    • 공간이 더 있다면 새 페이지 할당, 버퍼에 데이터 복사, head 갱신
  • 모든 연산은 락(lock)으로 보호되어 동기화 오버헤드가 발생함
  • 읽기(read)는 동일한 구조로 tail을 이동하며 읽은 페이지를 해제함
  • 본질적으로 유저 메모리에서 커널로, 커널에서 다시 유저 공간으로 두 번 복사가 이루어져 상당한 오버헤드 발생함

Zero-Copy: Splice/vmsplice로의 최적화

  • 빠른 IO를 위한 일반적 방법론은 커널을 우회(bypass)하거나 복사를 최소화하는 것임
  • Linux는 splice와 vmsplice 시스템콜을 통해 파이프와 유저 공간 간 데이터 이동 시 복사를 생략할 수 있도록 지원함
    • splice: 파이프와 파일 디스크립터 간 데이터 이동
    • vmsplice: 유저 메모리와 파이프 간 데이터 이동
  • 두 시스템콜 모두 참조만 옮길 뿐 실제 데이터 이동 없이 처리할 수 있음
  • 예를 들어, vmsplice 활용 시 256KiB 버퍼를 반으로 나누고, 더블버퍼링 방식으로 각 절반을 번갈아 파이프에 vmsplice 함
  • 실제로 vmsplice 적용 시 3배 이상 속도가 향상(약 12.7GiB/s) 되며, read 측에 splice를 적용하면 32.8GiB/s로 추가 향상

페이지 관련 병목과 Huge Page 활용

  • perf 분석 결과, vmsplice의 병목은 파이프 락(mutex_lock)페이지 획득(iov_iter_get_pages) 에 집중됨
  • iov_iter_get_pages는 유저 메모리(virtual address)를 실제 물리 페이지(physical page)로 변환하여 파이프 내에 참조를 저장하는 역할임
  • Linux의 페이징은 4KiB 단위 페이지만을 사용하는 것이 아니며, 아키텍처에 따라 2MiB(huge page) 등 다양한 크기 지원
  • Huge Page(예: 2MiB) 활용 시, page table 관리 및 참조 횟수 감소로 인해 페이지 변환 오버헤드가 현저히 줄어듦
  • 프로그램에서 huge page 적용 시 최대 처리량이 51.0GiB/s로 약 50% 추가 증가

바쁜루프(busy loop) 적용

  • 남은 병목은 파이프에 쓸 공간이 생기길 기다리는 wait, reader를 깨우는 wake과 같은 동기화 처리
  • SPLICE_F_NONBLOCK 옵션 사용 및 EAGAIN 발생 시 바쁜루프로 반복 호출하여 커널의 스케줄링 오버헤드 제거
  • 해당 기법 적용 시 최대 처리량이 62.5GiB/s로 25% 더 향상
  • 바쁜루프는 CPU 자원을 100% 소모하나, 고성능 서버에서는 흔히 쓰이는 패턴임

정리 및 기타 사항

  • perf와 Linux 소스 코드 분석을 통해 step-by-step으로 파이프 성능을 비약적으로 향상하는 방법을 설명함
  • 파이프, splice, 페이징, Zero-Copy, 동기화 비용 등 고성능 프로그래밍의 주요 이슈들을 실제 예제와 함께 체험할 수 있음
  • 실제 코드에서는 버퍼를 서로 다른 페이지에 할당해 refcount contention을 줄이는 등 추가적인 성능 튜닝이 적용됨
  • 테스트는 각각의 프로그램 프로세스를 개별 코어에 고정(taskset)시켜 실행함
  • Splice 계열은 설계상 위험할 수 있으며, 일부 커널 개발자들에게는 오랜 논쟁 거리임

감사의 글

  • 본 글의 초안 리뷰 및 get_user_pages 함수의 세부 동작 이해에 도움을 준 여러 개발자들에게 감사 인사를 전함
Hacker News 의견
  • Linux 파이프 기반 애플리케이션을 Windows로 포팅했던 경험을 잊지 못함, POSIX 표준이니 성능이 크게 다르지 않을 거라 생각했는데 엄청나게 느렸던 기억, 파이프 연결을 대기하는 상황에서 Windows 전체가 거의 멈추는 수준까지 갔던 문제점, 몇 년 뒤 Win10에서 C#으로 같은 걸 다시 구현했을 때는 조금 나아졌지만 성능 차는 여전히 큰 부끄러움이었던 기억

    • 최근 몇 년 사이 Windows에 AF_UNIX 소켓이 추가된 걸로 아는데, Win32 파이프 대비 어느 쪽 성능이 나은지 궁금함, 내 예상엔 더 나을 거란 추측

    • "성능이 엉망이었다"고 할 때, 파이프가 이미 연결된 이후의 I/O를 말하는 건지, 아니면 연결 이전의 과정인지 궁금증, 이미 연결된 후라면 의외이겠지만, 연결/해제 반복이 문제라면 OS가 최적화하지 않았을 가능성 인정, 실제로 그럴 필요는 거의 없으니까 사용 사례에 따라 다르게 받아들임

    • 내가 최근에 확인해본 결과 Windows에서 로컬 TCP의 성능이 파이프보다 훨씬 뛰어나다는 사실

    • POSIX는 동작만 정의하고 성능은 정의하지 않는다는 점, 각 플랫폼과 OS마다 고유의 성능 특이점이 있음을 상기

    • 옛날에 반대 경우의 경험이 있었음, 파이프는 아니지만, Linux에서 PHP 앱이 .NET 기반 SOAP API와 통신했을 때 .NET 구현 쪽 응답 속도가 더 좋았던 기억

  • 참고로 readv() / writev(), splice(), sendfile(), funopen(), io_buffer() 등 여러 방법이 있음, splice()는 파이프와 UNIX 소켓 사이에서 zero-copy로 대용량 데이터 전달 시 매우 뛰어난데 Linux 전용임, splice()는 데이터 전송 시 사용자 공간 메모리 할당, 추가 버퍼 관리, memcpy(), iovec 탐색 없이 직접 처리하는 가장 빠른 방법, BSD 계열에서는 파이프에 대해 readv()/writev()가 최적이 맞는지 여부에 대한 확인 요청도 함께, 어쨌거나 이 기사 매우 인상적이라는 평

    • sendfile()은 파일→소켓 zero-copy 방식의 매우 높은 성능 제공, Linux와 BSD 양쪽에서 사용 가능, 단 파일→소켓만 지원함, sendmsg()는 일반적인 파이프에는 사용 불가, UNIX 도메인/INET/기타 소켓용임, 참고로 Linux에서는 sendfile이 내부적으로 splice로 구현된 덕분에 파일→블록 디바이스 전송에도 실제로 사용해본 경험

    • splice()가 리눅스에서 파이프 간 초고속 대량 데이터 전송의 최고지만, io_uring을 제대로 쓰면 비슷하거나 오히려 앞지르는 성능 기대 가능

    • shm_open 같은 공유 메모리와 파일 디스크립터 전달 방식이 실제론 더 빠르고, 완전히 포터블함

  • 지난 HN에서 이 기사에 대해 토론이 활발하게 이뤄졌던 링크라며 https://news.ycombinator.com/item?id=31592934 (200개 댓글), https://news.ycombinator.com/item?id=37782493 (105개 댓글)로 안내

  • 정말 멋진 기사라며, 주기적으로 다시 언급되는 것도 매우 반가운 점

    • 오타 정정이라며 comes → comes up 명시
  • 아직 아무 댓글도 없어서 아쉽다는 감상과, splice를 더 많이 쓰고 싶으나 글 마지막에 언급된 보안이나 ABI 호환성 이슈가 걱정, splice가 앞으로도 계속 유지될지, 그리고 성능 향상을 위해 기본 파이프가 splice를 항상 쓰도록 패치하는 난이도도 궁금증 제기

  • 최신 Linux에 SunOS의 Doors와 유사한 무언가가 있는지 질문, 지연 시간이 매우 민감한 작은 데이터 교환이 필요한 임베디드 애플리케이션에서 AF_UNIX보다 나은 기술을 찾는 중인 상황

    • 공유 메모리가 지연 시간 측면에서 가장 빠르지만, 태스크 깨우기(보통 futex 활용)가 필요함, Google이 FUTEX_SWAP 시스템 콜을 개발하고 있었는데, 한 태스크에서 다른 태스크로 직접 핸드오버 가능할 예정이었으나 이후 상황은 잘 모름

    • 'Doors'가 너무 일반적인 단어라 검색이 어려워 설명 요청

    • 현재 AF_UNIX에 대해 어떤 점이 문제인지, 필요한 기능이 없는지, 원하는 것보다 지연이 높은지, 아니면 서버/클라이언트 소켓 API 구조가 맞지 않는 것인지 추가 정보 요청

  • 기사 작성 시점을 2022년으로 간결하게 정보 추가