1P by GN⁺ | ★ favorite | 댓글 1개
  • Linux 파이프 처리량을 perf와 커널 경로 분석으로 추적해, 단순 write/read 기반 3.7GiB/s 구현을 최종 62.5GiB/s까지 끌어올림
  • 병목의 출발점은 파이프가 4KiB 페이지 참조를 담는 링 버퍼로 동작하면서 복사, 페이지 할당, 락, 대기·깨우기 비용을 함께 치르는 데 있음
  • vmsplicesplice는 사용자 메모리와 커널 버퍼 사이의 복사를 줄여 처리량을 각각 12.7GiB/s, 32.8GiB/s 수준까지 높임
  • 이후에는 가상 주소 범위를 struct page로 바꾸는 비용이 두드러지고, 2MiB huge page를 쓰면 페이지 테이블 탐색 부담이 줄어 51.0GiB/s까지 올라감
  • 마지막 최적화인 SPLICE_F_NONBLOCKbusy loop는 처리량을 더 높이지만, 준비 상태를 기다리는 동안 CPU 코어를 계속 점유함

실험 목표와 기준 성능

  • 목표는 Linux에서 Unix 파이프가 어떻게 구현되는지 살펴보고, 파이프에 데이터를 쓰고 읽는 테스트 프로그램을 단계적으로 최적화하는 것임
  • 실험 환경은 Intel Skylake i7-8550U CPU와 Linux 5.17이며, Linux 내부 구현은 계속 바뀌므로 수치는 환경에 따라 달라질 수 있음
  • 출발점은 writeread 시스템 콜로 256KiB 버퍼를 반복해서 파이프에 흘리는 프로그램임
    • write는 같은 256KiB 버퍼를 계속 씀
    • read는 총 10GiB를 읽고 처리량을 출력함
    • 코드는 pipes-speed-test에 있음
  • 비교 대상으로 삼은 고도로 최적화된 FizzBuzz 프로그램은 pv 측정에서 약 36.2GiB/s로 출력함
    • FizzBuzz는 L2 캐시 크기와 같은 256KiB 블록을 사용함
    • 이 실험도 같은 256KiB 블록을 쓰지만, 출력 내용을 계산하지 않고 파이프 IO 상한에 가까운 값을 보려 함
  • 첫 구현 결과는 다음과 같음
    • ./write | ./read
    • 3.7GiB/s, 256KiB 버퍼, 40960회 반복, 총 10GiB 전송

writeread가 느린 이유

  • perf record -gperf report로 확인하면 write 쪽 시간의 약 47%pipe_write에서 소비됨
  • pipe_write 내부에서는 copy_page_from_iter__alloc_pages가 큰 비중을 차지함
    • 데이터 복사
    • 페이지 할당
    • 스케줄링
    • 대기·깨우기
    • 락 획득·해제
  • Linux 파이프는 페이지 참조를 담는 링 버퍼
    • pipe_inode_infohead, tail, pipe_buffer 배열을 가짐
    • pipe_bufferstruct page *page, offset, len을 가짐
    • x86-64의 일반 페이지 크기는 4KiB임
    • 기본 링 버퍼 슬롯 수는 16개이며, 예시의 8슬롯 파이프는 최대 32KiB를 담을 수 있음
  • head는 쓰기 끝을, tail은 읽기 끝을 나타냄
    • 파이프가 가득 차면 write는 블록됨
    • 파이프가 비어 있으면 read는 블록됨
  • pipe_write는 대체로 다음 순서로 진행됨
    • 파이프가 이미 가득 차 있으면 공간이 생길 때까지 기다림
    • 현재 head가 가리키는 버퍼에 공간이 있으면 먼저 채움
    • 빈 슬롯과 남은 데이터가 있으면 새 페이지를 할당하고 채우며 head를 갱신함
  • pipe_read는 반대로 페이지를 소비하고, 완전히 읽은 페이지를 해제하며 tail을 갱신함
  • 이 경로에서는 각 페이지가 두 번 복사됨
    • 사용자 메모리에서 커널로 한 번
    • 커널에서 사용자 메모리로 다시 한 번
  • 복사는 4KiB 페이지 단위로 일어나며, 중간에 동기화와 페이지 할당·해제가 섞임
  • 같은 머신에서 단일 스레드 순차 RAM 읽기는 약 16GiB/s였고, 파이프 경로의 복잡성을 고려하면 write/read가 약 4배 느린 것도 놀랍지 않음

vmsplicesplice로 복사 줄이기

  • 빠른 IO에서는 사용자 메모리와 커널 버퍼 사이의 복사 비용이 병목이 될 수 있음
  • Linux는 파이프와 관련해 복사 없는 이동을 위한 시스템 콜을 제공함
    • splice: 파이프와 파일 디스크립터 사이에서 데이터를 이동함
    • vmsplice: 사용자 메모리에서 파이프로 데이터를 이동함
  • vmsplicestruct iovec 배열로 파이프에 넣을 버퍼를 지정함
    • 반환값은 파이프에 실제로 들어간 바이트 수임
    • 파이프 링 버퍼 크기 제한 때문에 요청한 전체 크기가 한 번에 들어가지 않을 수 있음
  • vmsplice는 사용자 메모리를 복사하지 않고 파이프에 연결하므로, 읽기 쪽이 해당 데이터를 소비하기 전에 버퍼를 재사용하지 않도록 주의해야 함
  • FizzBuzz 프로그램은 이를 위해 double buffering을 사용함
    • 256KiB 버퍼를 두 개의 128KiB 절반으로 나눔
    • 파이프 크기를 128KiB로 설정해 4KiB 페이지 기준 32슬롯을 만듦
    • 두 절반 버퍼를 번갈아 채우고 vmsplice로 파이프에 넣음
  • 테스트 프로그램은 실제로 버퍼 내용을 다시 쓰지는 않지만, 실제 출력 생성 프로그램에 필요한 구조와 비슷하게 double buffering을 유지함
  • writevmsplice로 바꾸면 처리량이 12.7GiB/s로 증가함
    • 복사량이 절반으로 줄어듦
    • write/read 대비 3배 이상 개선됨
  • 읽기 쪽도 splice로 바꾸면 복사를 모두 제거해 32.8GiB/s까지 올라감
  • 단, vmsplice의 안전성에는 주의가 필요함
    • 페이지가 다시 splice되면 수명이 늘어날 수 있음
    • SPLICE_F_GIFT 없이도 안전하지 않은지 여부는 명확하지 않음
    • 복사 없는(zero-copy) 파이프를 안전하게 쓰려면 특별한 주의가 필요함

iov_iter_get_pages와 페이지 변환 비용

  • vmsplicesplice 적용 뒤 perf에서는 vmsplice 경로의 시간이 크게 보임
    • iov_iter_get_pages
    • __mutex_lock.constprop.0
    • add_to_pipe
  • iov_iter_get_pagesvmsplice에 전달된 struct iovec가상 메모리 범위를 파이프가 담을 수 있는 struct page 목록으로 바꿈
  • 프로세스는 물리 메모리 주소가 아니라 가상 주소를 사용함
    • CPU는 페이지 테이블을 통해 가상 주소를 물리 주소로 변환함
    • x86-64의 일반 페이지 크기는 4KiB임
    • x86-64 페이지 테이블은 4단계, 각 단계 512-way 트리 구조로 설명됨
  • 파이프의 pipe_bufferstruct page를 참조함
    • struct page는 커널이 물리 페이지와 관련 메타데이터를 다루는 핵심 구조임
    • 따라서 vmsplice는 입력 가상 메모리 범위를 물리 페이지 참조로 바꿔야 함
  • iov_iter_get_pages 내부 시간은 대부분 get_user_pages_fast에서 소비됨
    • 128KiB 버퍼와 4KiB 페이지 기준으로 총 32개 페이지를 얻어야 함
    • 파이프 코드는 구현상 한 번에 nr_pages = 16으로 호출하고 필요하면 반복하지만, 전체 spliced 페이지 수는 32개임
  • get_user_pages_fast는 CPU가 하는 것과 비슷하게 소프트웨어로 페이지 테이블을 걸어 struct page를 수집함
  • 호출자가 페이지를 쓰는 동안 물리 페이지가 재사용되지 않도록 struct page의 참조 카운트를 증가시켜야 함
    • 이후 put_page로 참조 카운트를 줄여야 함
  • 시작 시 memset으로 버퍼를 채우면 페이지 테이블 엔트리가 미리 만들어져 get_user_pages_fast의 느린 경로를 피할 수 있음
    • memset을 하지 않으면 예시에서 처리량이 25.0GiB/s로 낮아짐
    • huge page 사용 시에는 이 현상이 동일하게 나타나지 않음

huge page로 페이지 처리 비용 낮추기

  • x86-64는 4KiB 일반 페이지 외에 2MiB와 1GiB huge page를 지원함
    • 이후 실험은 2MiB huge page만 다룸
    • 1GiB 페이지는 비교적 드물고 이 작업에는 과하다고 봄
  • huge page는 같은 메모리 범위를 더 적은 페이지로 표현해 관리 비용을 낮춤
    • 가상 주소를 물리 주소로 변환할 때 페이지 테이블 단계도 하나 줄어듦
    • CPU의 TLB 부담을 줄이는 데도 도움이 될 수 있음
  • 이 실험의 직접 병목은 하드웨어 페이지 테이블 워크가 아니라 커널의 소프트웨어 경로인 get_user_pages_fast
  • Linux에서는 2MiB에 정렬된 메모리를 할당하고 madvise(..., MADV_HUGEPAGE)를 호출하는 방식으로 huge page 사용을 요청할 수 있음
  • huge page를 적용하면 처리량이 51.0GiB/s로 증가함
  • 성능 개선 이유는 struct page가 2MiB 페이지 하나를 그대로 가리키기 때문이 아님
    • 커널 코드는 대체로 struct page가 현재 아키텍처의 표준 페이지 크기를 가리킨다고 가정함
    • huge page는 head struct page와 여러 tail struct page로 표현됨
    • 2MiB huge page는 4KiB 기준 최대 512개의 struct page로 표현될 수 있음
  • 그래도 첫 엔트리를 찾은 뒤에는 이어지는 struct page들을 단순 루프로 생성할 수 있어, 페이지 테이블을 여러 번 탐색하는 비용이 줄어듦
  • Linux 5.17 이후 커널에는 head page를 명시적으로 식별하는 struct folio가 포함되어 런타임 head/tail 확인 필요를 줄이는 개선이 있음

busy loop로 동기화 비용 줄이기

  • huge page 적용 뒤 perf에서는 wait_for_space__wake_up_common_lock 시간이 두드러짐
    • 쓰기 가능한 공간을 기다리는 비용
    • 읽기 쪽을 깨우는 비용
  • 이 동기화 비용을 피하려면 vmsplicespliceSPLICE_F_NONBLOCK 을 사용할 수 있음
    • 파이프에 쓸 수 없으면 즉시 EAGAIN을 반환함
    • 호출자는 준비될 때까지 busy loop를 돎
  • busy loop를 적용하면 처리량이 62.5GiB/s까지 증가함
  • 비용도 명확함
    • vmsplicesplice가 준비되기를 기다리는 동안 CPU 코어를 완전히 점유함
    • CPU 사용량을 더 쓰는 대신 지연 시간이나 처리량을 얻는 방식임
  • 최종적으로 이 합성 벤치마크는 약 3.5GiB/s에서 약 65GiB/s 수준까지 개선됨

남은 세부사항과 실무적 주제

  • 최적화 과정은 perf 출력과 Linux 소스 코드를 함께 보며 진행됨
  • 다룬 주제는 파이프와 splicing 자체보다 넓은 고성능 프로그래밍 주제와 연결됨
    • 복사 없는 연산

      • 링 버퍼
      • 페이징과 가상 메모리
      • 동기화 오버헤드
      • 실제 코드에서는 두 버퍼를 별도로 할당해 페이지 테이블 경합을 줄임
      • get_user_pages가 페이지 테이블 엔트리의 참조 카운트를 올리고 put_page가 낮춤
      • 두 버퍼가 서로 다른 페이지 테이블 엔트리를 쓰면 참조 카운트 수정 경합이 줄어듦
      • 테스트는 taskset으로 ./write./read 프로세스를 두 코어에 고정해 실행함
      • 저장소에는 get_user_pages_fast합성 벤치마크도 포함됨
      • huge page 사용 여부에 따른 속도 차이를 측정할 수 있음
      • splicing은 여전히 애매하고 위험한 개념으로 남아 있으며, 관련 이슈는 커널 개발자들에게 계속 부담이 됨

댓글과 토론

Hacker News 의견들
  • 제대로 이해했다면, vmsplice는 읽는 쪽과 쓰는 쪽 양끝에서 동시에 쓰면 두 프로세스 사이의 작은 공유 메모리 메커니즘에 가까워 보임
    즉 두 프로세스 모두 버퍼를 언제 읽고 쓸지, 사용 후 어떻게 반환할지 극도로 조심해야 함. 빠르지만 동시에 무서운 방식이고, 모두가 작성할 법한 순진한 구현이 가능한 성능보다 20배 느리다는 점도 아쉬움

    • 20배 빠른 버전을 작성하려고 하면 동료들은 과하게 복잡하게 만들고 팀 플레이어답지 않다고 볼 것임
    • vmsplice가 두 프로세스 사이의 작은 공유 메모리 메커니즘인 것 같지는 않음. 사용자 메모리에서 파이프로 가는 무복사만 지원하고, 반대 방향은 복사가 발생함
      자세한 내용은 https://mazzo.li/posts/fast-pipes.html#fn10 참고
  • 파이프, 소켓, 파일, 메모리를 추상화하면서 이런 최적화까지 해주는 데이터 처리 라이브러리가 있는지 궁금함
    C, C++, Rust나 다른 시스템 언어에 그런 라이브러리가 있는지 알고 싶음. 글에 나온 splice()vmsplice() 같은 API에 익숙하지 않아서, 저수준 애플리케이션을 만들 때 가능한 경우 이런 최적화를 자동으로 활용해주는 라이브러리가 있을지 궁금해짐. libuv, tokio, Netty가 Linux에서 이를 자동 처리하는지도 궁금한데, 짧게 찾아본 바로는 아마 그럴 수도 있어 보임

    • 일반적인 흐름과는 다를 수 있지만, 이건 이식성이 없어서 추상화할 가치가 크지 않음. 필요한 곳마다 직접 구현하게 될 가능성이 높음
      상위 수준 코드는 이런 기능을 드물게만 쓰는데, 꽤 특수 목적이고 Linux에 맞춰 특화해야 하기 때문임. 데이터를 들여다보지 않고 Linux에서 옮기기만 한다면 splice가 유용함. TCP/UDP 프록시 같은 애플리케이션에는 확실히 필요하지만, 평범한 HTTP 서버에는 그다지 맞지 않음. 이런 앱을 만들고 있다면 zero copy 같은 키워드를 자주 만나게 되고, splice는 가장 먼저 보게 될 결과 중 하나임
    • tokio용 크레이트가 있음. 자동은 아니지만 흥미로울 수 있음: https://lib.rs/crates/tokio-splice
    • Cosh를 봐볼 만함. 지금 그 논문을 읽으며 고민 중인데, 최적화를 허용하면서도 메시지 전달 추상화를 제공하는 모델임
      연구 환경 밖에서는 잘 알려지지 않은 것 같고, 효율적인 Cosh 구현을 작성하려면 시간이 꽤 걸릴 가능성이 큼. 요약하면 전송 모드가 move, share, copy 세 가지임. 예를 들어 move 전송은 송신자가 읽기/쓰기 권한을 가진 데이터를 수신자에게 완전히 넘겨주며, 페이지 테이블의 가상 메모리 재매핑으로 구현될 수 있음. 또한 송신자와 수신자가 협력한다고 믿을 수 있는지, 아니면 가상 메모리 권한 재매핑으로 엄격히 격리해야 하는지를 나타내는 strong/weak 속성도 있음. 솔직히 말해 초고도로 최적화된 파이프 같은 것과 안정적으로 맞먹을 만큼 잘 최적화될 수 있을지는 모르겠고, “충분히 똑똑한 컴파일러” 문제가 될 수도 있음. 그래도 시도해볼 가치는 있다고 봄
      [1] https://barrelfish.org/publications/trios14-baumann-cosh.pdf
  • 2022년 이전 논의: https://news.ycombinator.com/item?id=31592934

  • 4년 전에 우연히 알게 된 놀라운 사실은, Linux 파이프를 쓰면 비결정적 동작이 생길 수 있다는 것임
    https://www.gibney.org/the_output_of_linux_pipes_can_be_inde...

    • 놀라운 일은 아님. 만든 파이프는 echo한 데이터 중 아무것도 실제로 전달하지 않음
      (echo red; echo green 1>&2) | echo blue| 기호를 사이에 둔 두 개의 서브셸을 만듦. 서브셸은 현재 셸의 자식 프로세스라서 열린 파일 서술자 테이블 같은 중요한 속성을 상속함. 두 서브셸은 동시에 실행되고, 부모 셸은 모든 자식 프로세스가 끝나길 wait()할 뿐임. 어느 자식이 먼저 실행될지는 대체로 예측할 수 없고, 멀티코어 시스템에서는 말 그대로 동시에 실행될 수도 있음. 왼쪽 서브셸의 표준 출력은 파이프의 쓰기 끝으로, 오른쪽 서브셸의 표준 입력은 읽기 끝으로 연결됨. 하지만 echo blue는 입력을 읽지 않고 출력만 하므로 파이프에서 아무것도 읽히지 않음. echo green >&2는 표준 출력을 파이프가 아니라 표준 오류가 가리키는 곳으로 보냄. 결국 echo greenecho blue가 같은 파일, 아마 터미널에 직접 쓰면서 경쟁 상태가 생기고, 누가 먼저 스케줄링되느냐에 따라 순서가 갈림
    • 조금 더 깊게 생각하면 완전히 자연스러움. 파이프라인의 프로그램은 동시에 실행
      그렇지 않다면 파이프라인은 유용하지 않음. 예를 들어 curl로 tar 파일을 내려받고 바로 풀어버리는 파이프라인에서, curl이 끝날 때까지 기다렸다가 tar를 실행하면 큰 중간 tar 파일을 어디에 저장할지 같은 문제가 생김. tarcurl이 실행되는 동안 함께 돌아야 버퍼를 작게 유지하고 빠르게 실행할 수 있음. 파이프라인 프로그램 사이의 유일한 제어 흐름은 표준 입력과 표준 출력으로 이루어짐. 예시에서는 표준 오류에 쓰고 있으니 당연히 결정적인 제어 흐름에 포함되지 않음
    • Linux에서 빠른 무복사 입출력을 좋아한다면 이 글도 볼 만함
      덧붙이면, 혼동을 피하려면 “Indeterministic”은 철학 용어이고 컴퓨터과학 용어는 “nondeterministic”임
      0. https://blog.superpat.com/zero-copy-in-linux-with-sendfile-a...
    • 그게 그렇게 놀라운가? 어떤 출력이 나올 거라고 예상했는지, 왜 그렇게 생각했는지를 알면 혼동을 풀기 쉬울 듯함
      이 명령은 아마 의도적으로 특이하게 생겼고, 코드 리뷰어라면 분명 고개를 갸웃했을 것임. echo red가 있지만 어디에도 전달되지 않음. “red herring” 농담일 수도 있음. echo green은 표준 오류로 가므로 echo blue보다 먼저 끝날 때만 보임. 정확한 순서는 출력 버퍼링에 의존하고, 이는 어느 타임 슬라이스가 먼저 배정되는지에 따라 달라지며 CPU 수와 부하에 따라 바뀜. 그래서 비결정적이지만, top이 비결정적인 것과 같은 방식임
    • 이런 일이 실제 문제를 일으키는 경우가 있나? 솔직히 이 예시는 꽤 인위적으로 보임
  • 요약하면, 두 프로그램이 가능한 한 최적으로 작성되었다고 가정할 때 파이프의 최대 속도는 시스템의 한 코어가 읽고 쓸 수 있는 속도에 가까움
    본질적으로 커널이 한 프로그램의 표준 출력에서 다른 프로그램의 표준 입력으로 같은 물리 메모리 페이지를 매핑하기 때문에, 작업이 무복사 또는 덜 최적인 상황에서는 빠른 단일 복사에 가까워짐. 이 사실을 알고 나면 파이프로 둘 이상의 도구를 붙이는 셸 스크립트로 매우 고성능 작업을 하는 일이 보람 있고 웃기기도 함. 도구상자에서 가장 유용한 도구 중 하나임

    • 파이프가 무복사인 건 splicevmsplice를 사용할 때뿐임. 이 Linux 전용 시스템 호출은 쓰기 어렵고, 특히 vmsplice가 그렇음
      대다수 프로그램과 셸 필터는 pv 같은 눈에 띄는 예외를 빼면 이를 쓰지 않아서, 커널 메모리로 복사하고 다시 꺼내는 비용을 치름
    • 파이프의 심각한 한계는 x86 Linux 기준으로 64KB / 16페이지만 버퍼링할 수 있다는 점으로 알고 있음. 일반적으로 코어-메모리 대역폭보다는 느릴 가능성이 큼
    • 그래서 스레드가 많은 프로그래머들이 생각하는 것만큼 중요하지는 않음
      만들고 있는 애플리케이션은 부하 특성에 따라 파이프+프로세스나 그린/사용자 공간 스레드로 더 깔끔하게 구현될 가능성이 큼. 덜 편할 수는 있지만, 메시지 전달은 대개 교착 상태 지옥보다 낫다
    • 사람들이나 팀이 더 나쁜 결과를 얻기 위해 몇 주와 큰돈을 쓰기 때문에 웃긴 것임
    • 이런 시스템 마법은 잘 모르겠는데, 데이터가 메모리까지 완전히 올라가야 하나? 아니면 캐시가 그 왕복을 막아주나?
  • 이 글은 Linux 파이프를 더 빠르게 만드는 방법을 다루지만, 공유 메모리나 메시지 큐 같은 다른 방법이 여전히 더 빠를 수 있음
    많은 데이터를 빠르게 옮겨야 하는 시스템에서는 파이프의 추가 단계가 속도를 늦출 수 있음. 여러 스레드가 데이터를 공유할 때도 파이프가 다른 방식보다 더 많은 문제를 만들 수 있음. 그래서 글의 개선이 속도가 중요한 실제 상황에서는 큰 도움이 안 될 수도 있음

    • 예를 들어줄 수 있나? 데이터를 묶어서 처리할 때는 io_uring 같은 것을 고르면 이점이 있음
      하지만 양방향 통신에서는 어느 쪽이든 데이터가 준비됐다는 알림이 필요함. CPU를 태우며 폴링하고 싶지 않을 수도 있고, 그런 선택지가 그 동기화를 파이프보다 어떻게 더 빠르게 처리하는지 잘 모르겠음
    • 메시지 큐 라이브러리를 쓰면 여러 플랫폼 간 비호환성을 덜 신경 써도 된다는 장점도 있음
  • 페이지 테이블 같은 건 알고 있었지만, 이를 perf를 통한 성능 분석과 연결해보니 처리량에서 얼마나 중심적인지 명확해짐

  • 파이프는 훌륭함. 다른 프로세스가 다른 CPU에 있든 다른 머신에 있든 솔직히 별로 상관없음
    https://github.com/nathants/s4/blob/master/examples/nyc_taxi...

  • 파이프는 cat, sed, awk, cut, grep, uniq, jq 등을 반복적으로 조합하기에 충분히 빠름