- 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 - pipes-speed-test에 공개되어 있음
- 영감을 준 것은 고성능 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 계열은 설계상 위험할 수 있으며, 일부 커널 개발자들에게는 오랜 논쟁 거리임