2P by GN⁺ | ★ favorite | 댓글 1개
  • Apache OpenDAL의 Python 바인딩에서 파일 읽기가 Python 내장 open().read()보다 느리다는 제보가 출발점이었지만, 병목은 OpenDAL이나 PyO3 자체가 아니었음
  • 64MiB 파일 읽기 벤치마크에서 python-fs-read는 약 15~19ms, Rust std::fs와 C 구현은 약 23ms로 측정되어 Rust/C가 Python보다 느려 보였음
  • strace, eBPF, perf를 따라가자 차이는 read 시스템콜의 목적지 버퍼가 페이지 안에서 놓이는 오프셋과 연결됐고, 0x10 근처에서 성능 저하가 재현됨
  • AMD Ryzen 9 5900X, Ryzen 7 5700X, Ryzen 9 5900HX 계열에서 유사 현상이 확인됐으며, 커널 _copy_to_iter 내부 rep movsb 실행 성능이 핵심 단서였음
  • Python이 본질적으로 더 빠른 것이 아니라 AMD Zen 3의 FSRM/rep movsb 관련 CPU 버그와 메모리 오프셋의 우연이 만든 결과였고, jemalloc 개선도 할당기 자체가 아니라 다른 오프셋 때문이었음

OpenDAL Python 바인딩에서 시작된 이상한 벤치마크

  • Apache OpenDAL은 여러 스토리지 서비스에서 데이터를 통합 방식으로 읽고 쓰기 위한 데이터 접근 계층이며, Python 바인딩은 PyO3를 통해 제공됨
  • 사용자는 OpenDAL Python 바인딩으로 150MB 파일을 읽는 코드가 Python 내장 파일 읽기보다 느리다고 제보함
    • Python 내장 open(...).read() 100회: 4.470868484000675
    • OpenDAL Python 바인딩 100회: 8.993250704006641
  • 단순화한 64MiB 파일 읽기에서도 OpenDAL 바인딩이 더 느렸음
    • python-fs-read: 평균 15.9ms
    • python-opendal-read: 평균 32.9ms
    • Python 내장 읽기가 OpenDAL 바인딩보다 2.07배 빠르게 측정됨

Rust OpenDAL과 std::fs까지 내려간 추적

  • 같은 로직을 Rust의 OpenDAL fs 서비스로 구현해도 Python 내장 읽기보다 느렸음
    • rust-opendal-fs-read: 평균 23.8ms
    • python-fs-read: 평균 15.6ms
    • Python 내장 읽기가 Rust OpenDAL 구현보다 1.52배 빠르게 측정됨
  • OpenDAL의 fs 서비스는 Rust std::fs를 사용하므로, OpenDAL 자체 비용을 확인하기 위해 std::fs 기반 구현을 따로 작성함
  • Rust std::fs 직접 구현에서도 같은 흐름이 이어짐
    • rust-std-fs-read: 평균 23.1ms
    • python-fs-read: 평균 15.2ms
    • Python 내장 읽기가 Rust std::fs보다 1.52배 빠르게 측정됨

strace로 본 시스템콜과 mmap

  • strace 분석에서 Rust와 Python 모두 큰 버퍼 할당에 mmap 을 사용했음
  • Rust std::fs 실행은 /tmp/file을 열고, 64MiB를 한 번 읽고, EOF 확인용 read를 호출한 뒤 닫는 흐름이었음
  • Python 내장 읽기는 newfstatat, ioctl, lseek 등 더 많은 시스템콜을 실행했지만 전체 시간은 더 짧았음
  • mmap(NULL, 67112960, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) 호출은 파일 매핑이 아니라 익명 메모리 할당에 쓰였음
    • 67112960은 64MiB에 4KiB를 더한 크기
    • MAP_ANONYMOUS는 파일과 관계없는 메모리 할당을 뜻함
  • Rust의 x86_64-unknown-linux-gnu 기본 빌드는 glibcmalloc을 사용하고, glibc는 큰 할당에 mmap을 사용할 수 있음

jemalloc으로 빨라진 Rust와 뒤집힌 중간 결론

  • Rust 전역 할당기를 jemallocator::Jemalloc으로 바꾸자 Python보다 빨라졌음
    • rust-std-fs-read-with-jemalloc: 평균 9.7ms
    • python-fs-read: 평균 15.8ms
    • jemalloc 사용 Rust 구현이 Python보다 1.64배 빠르게 측정됨
  • 이 시점에는 mmap 또는 기본 메모리 할당기가 원인처럼 보였지만, 이후 업데이트에서 해석이 수정됨
  • 2023-12-01 업데이트 기준으로 jemalloc, pymalloc, mimallocglibc malloc보다 본질적으로 빨라서 생긴 차이는 아니었음
  • 실제 차이는 할당기가 만든 버퍼의 페이지 내 오프셋에서 나왔음
    • rust-std-fs-read: mmap 시작 주소에서 0x10 오프셋에 읽음
    • rust-std-fs-read-with-jemalloc: mmap 시작 주소에서 0x740 오프셋에 읽음
  • 문제 구간은 페이지 안의 0x00..0x10 범위로 정리됐고, jemalloc에서도 같은 문제를 재현할 수 있음

소프트웨어 설정보다 기기별 재현성이 더 컸던 문제

  • 논의가 이어지면서 Rust가 Python보다 느린 현상은 작성자의 기기에서 특히 두드러진다는 점이 확인됨
  • 작성자의 CPU는 AMD Ryzen 9 5950X 16-Core Processor였고, 메모리는 DDR4 3200 MT/s 16GB DIMM 구성이었음
  • 여러 설정을 바꿔도 상대적인 성능 차이는 사라지지 않았음
    • Linux 커널 mitigations=off를 다시 켜도 결과 변화 없음
    • Transparent Hugepage를 always, madvise, never로 바꾸면 절대값은 달라지지만 상대 비율은 유지됨
    • core_affinity로 특정 CPU 코어에 묶어도 결과는 같았음
  • eBPF 기반 read 시스템콜 지연 측정에서도 Rust 쪽이 더 느렸음
    • Python read file: 8,134,049ns
    • Rust std::fs read file: 24,636,975ns
  • 관찰 결과 OpenDAL, PyO3, Rust 표준 라이브러리만으로는 차이를 설명하기 어려웠고, 시스템콜 수준에서 이미 시간이 벌어졌음

C 구현에서 드러난 메모리 오프셋 단서

  • 같은 64MiB 파일 읽기를 C fopen/malloc/fread로 구현해도 Python보다 느렸음
    • c-fs-read: 평균 23.8ms
    • python-fs-read: 평균 19.1ms
    • Python 내장 읽기가 C 구현보다 1.25배 빠르게 측정됨
  • strace -e raw=read,mmap로 포인터 주소를 확인하자 C와 Python의 버퍼 시작 오프셋이 달랐음
    • C: mmap 반환 주소에서 0x10 오프셋에 read
    • Python: mmap 반환 주소에서 0x30 오프셋에 read
  • C 구현에서 같은 방식으로 오프셋을 조정하자 성능이 크게 개선됨
    • c-fs-read-with-offset: 평균 8.9ms
    • Python보다 2.15배, 기존 C 구현보다 2.68배 빠름
  • 이 문제는 AMD Ryzen 9 5900XAMD Ryzen 7 5700X에서도 재현됨
  • Rust 커뮤니티의 Std::fs::read slow?에서도 비슷한 현상이 보고됐고, 메모리 영역 오프셋과 시스템콜 성능의 관련성이 지적됨

perf 분석이 가리킨 rep movsb

  • 커널 개발자가 AMD Ryzen 9 5900HX에서 c-fs-read와 오프셋 적용 버전을 재현하고 perf로 분석함
  • 오프셋 유무에 따라 L1-dcache-prefetchesL1-dcache-loads 값이 크게 달랐음
    • 오프셋 없음: L1-dcache-loads127,845,213, L1-dcache-prefetches1,843,493
    • 오프셋 있음: L1-dcache-loads13,965,813, L1-dcache-prefetches395,578
  • 핫스팟은 커널의 read 경로 안에서 shmem_file_read_itercopy_page_to_iter_copy_to_iter로 이어졌음
  • _copy_to_iter 내부 핵심 어셈블리는 rep movsb 였고, 샘플 대부분이 이 명령에 집중됨
  • 이후 분석에서는 L1 프리페치 자체보다, 페이지 정렬된 데이터에서 rep movsb 성능이 나쁘고 페이지 정렬이 깨지면 더 좋아지는 현상이 더 중요한 단서로 정리됨

FSRM과 AMD Zen 3 문제

  • 공유된 Ubuntu glibc 버그 보고서 Terrible memcpy performance on Zen 3 when using rep movsbrep movsb 성능 문제를 다룸
  • 해당 보고서의 예시는 2113바이트 복사에서 rep movsb 경로가 약 3.2GB/s를 보이고, 크기를 2111바이트로 바꾸면 100GB/s 이상으로 올라간다고 설명함
  • FSRM은 Fast Short REP MOV의 약자로, rep movsbrep movsd를 빠르게 만들기 위한 기능임
  • FSRM은 Intel에서 시작된 기능이며 AMD에도 도입됐고, 지원을 선언한 CPU에서는 glibc가 기본적으로 FSRM을 사용함
  • 따라서 Python이 C/Rust보다 본질적으로 빠른 것이 아니라, AMD CPU 버그 때문에 특정 메모리 오프셋에서 C/Rust의 읽기 경로가 느려진 것으로 정리됨

업데이트: AMD 인지 여부와 glibc 대응

  • 2023-12-01 업데이트에 따르면 AMD는 이 버그를 2021년부터 알고 있었던 것으로 파악됨
  • 글 공개 이후 여러 독자가 AMD에 링크를 전달했기 때문에 AMD가 해당 문제를 알고 있다고 봄
  • 작성자는 AMD가 amd-ucode에서 이 버그를 책임지고 수정해야 한다고 보지만, 확인되지 않은 정보로는 Zen 3에서 amd-ucode 수정이 어려울 수 있음
  • 현실적인 희망은 glibc에서 필요 시 FSRM을 비활성화하는 방식임
  • glibc 쪽에서는 x86: Improve ERMS usage on Zen3 작업이 진행 중임

재현 코드와 관련 자료

댓글과 토론

Hacker News 의견들
  • REP STOS/MOV가 빠르고 memset/memcpy의 짧은 명령 시퀀스로 쓸 수 있음을 나타내는 전용 CPU 기능 플래그가 두 개나 있음
    새 CPU 세대마다 최적화 루틴을 손으로 다시 짜야 하는 고통이 수십 년째 이어졌는데, 아직도 이런 상황이라니 CPU 벤더의 타이밍 테스트 스위트에 들어가 있어야 하는 것 아닌가 싶음

    • 완전히 추측이지만, 마지막 순간이나 출시 후 마이크로코드 업데이트로 들어간 버그 수정의 영향일 수도 있어 보임
      페이지 정렬된 빠른 rep movs에 문제가 있었거나 어떤 공격에 취약해서 비활성화됐을 가능성이 있음
    • 이해한 게 맞다면 특정 컴파일 시점 빌드마다 실행 파일을 두 개 만들어야 한다는 뜻인지, 아니면 특정 하드웨어에서 컴파일해야 한다는 뜻인지 궁금함
      수정은 어떤 식이어야 할지, 런타임 검사 같은 게 필요한 건지 모르겠음
    • CPU 벤더가 자기 CPU를 가장 잘 알 거라고 생각하기 쉬움
      더 빠른 “소프트웨어” 구현이 있다면 REP MOVS가 적어도 마이크로코드에서 같은 일을 하게 만들지 않는 이유가 궁금함
  • 관련된 glibc 버그는 여기 있음. 다만 이쪽은 Zen 4임: https://sourceware.org/bugzilla/show_bug.cgi?id=30994

  • 처음엔 글을 읽고 작성자가 std::fs를 잘못 썼다고 비웃을 준비를 했는데, 실제로는 디버깅 토끼굴과 미스터리가 이어지는 즐거운 글이었음
    잘 썼고 매우 흥미로웠음

    • 정말 좋은 글이었음. 테스트 프로그램을 만들어 계층을 하나씩 벗겨내는 디버깅 방식이 똑똑했고, 결론은 흥미롭고 예상 밖이었으며, 글도 명확해서 따라가기 쉬웠음
  • 전제가 조금 헷갈림. 순수 Python 코드와 네이티브 C/Rust 코드를 비교한 게 아니라, 네이티브 코드 위의 Python 래퍼인 Python 파일 읽기 메서드와 또 다른 네이티브 코드 래퍼인 OpenDAL을 비교한 것임
    성능 차이가 있다는 건 여전히 흥미롭지만, 이를 “Python보다 느리다”고 표현하는 건 꽤 이상함. Python 표준 라이브러리가 전부 순수 Python으로 작성됐다고 기대한 건가 싶음. 오히려 Python 표준 라이브러리의 함수 구현은 네이티브이고 개별적으로 고도로 최적화돼 있을 거라고 예상함
    결론이 네이티브 코드 동작 방식과 관련됐다는 건 놀랍지 않았지만, 구체적인 답은 의외였음. 다만 시작이 헷갈렸을 뿐 글 자체는 매우 흥미로웠음
    또 “C is slower than Python with specified offset”이라는 제목도 원어민 입장에서는 “오프셋을 지정한 상태에서도 C가 Python보다 느리다”로 읽힘. 실제로는 반대로, Python에서 쓰던 오프셋을 C에도 지정하자 C가 더 빨라졌다는 뜻이었음

    • 왜 헷갈리는지 오히려 잘 모르겠음
      파일 읽기처럼 단순한 작업이 Rust 표준 라이브러리에서 Python 표준 라이브러리보다 느리다는 건 놀라운 일임. 이런 Python 표준 라이브러리 호출이 C로 작성됐다는 걸 알아도, Rust 표준 라이브러리 호출도 비슷한 속도일 거라고 기대하게 됨
      그래서 보통은 사용법이 잘못됐거나 Rust 표준 라이브러리에 이상한 동작이 있다고 예상할 텐데, 이번에는 둘 다 아니었고 특정 하드웨어에서 할당 정렬에 따라 생기는 성능 절벽이었음
      파일 시스템 읽기가 Python에서 잘 최적화돼 있을 거라고 기대하지만 Rust에서도 마찬가지일 거라 생각하니, Rust 쪽이 훨씬 느렸던 점이 놀랍고 특히 하드웨어와 할당자에 의존했다는 점이 더 놀라움
    • Python은 느릴 때는 느린 언어라고 욕먹으면서, 빠를 때는 “진짜 Python이 아니다”라는 이유로 인정받지 못하는 게 이해되지 않음
      Python으로 작성한 코드가 빠르면 내게는 Python이 빠른 것임. 구현이 다른 언어로 돼 있어서 그런지, 다른 이유인지 별로 중요하지 않음
    • “개별적으로 고도로 최적화돼 있다”고 기대할 이유가 뭔지 모르겠음
      원글에서 벌어진 일은 순전히 우연에 가까움. CPython의 C 코드는 const 일관성조차 신경 쓰지 않고, 동적 메모리 할당과 보조/편의 호출이 많음. 산술 같은 것조차 동적 메모리 할당을 함
      CPython과 함께 일해 본 경험이 있다면 보통 성능이 좋을 거라고 기대하지 않음. 성능을 개선하고 싶을 때는 거기서 제공하는 기능을 우회하려고 하게 됨
      또한 Python에는 표준이 없으므로 엄밀히 말해 표준 라이브러리도 없고, 함께 배포되는 라이브러리는 대부분 Python으로 작성돼 있음. 일부는 C로 작성됐지만, 그 C 코드 중에도 사실상 Python 코드를 기계적으로 C로 옮긴 비중이 꽤 큼. 예를 들어 Python의 이진 탐색 구현은 원래 Python으로 작성됐다가 나중에 Python C API를 써서 C로 번역됐음
      기대할 만한 건 운영체제 기능에 단순히 매핑되는 기능에는 비교적 얇은 래퍼가 붙는다는 정도임. 즉 파일 읽기는 본질적으로 시스템 인터페이스로 바로 들어가므로 바인딩 코드가 많이 필요하지 않을 것임
    • 지적 고마움. 제목을 고쳤음
    • 전제는 “Python이 Rust보다 빠르다” 같은 문구를 쓰면 사실이 아니어도 페이지뷰가 나온다는 것임
      비슷한 글이 수십 번 올라온 뒤로 다들 그걸 알아차렸음
  • 글 자체는 훌륭하고 이 이슈와 관련된 흥미로운 정보가 많음
    다만 더 관심이 가고 걱정되는 부분은 이슈가 어떻게 보고·기록되고, 커뮤니케이션이 어떻게 처리되는지임
    보고는 Discord에서 이루어지는데, 독점 환경이고 색인되지 않으며 검색하기도 어렵고 보존되지도 않음. 논의는 Discord와 Telegram에서 이루어지는데, 이 맥락에서는 Telegram이 더 나쁠 수도 있음
    이 블로그 글과 GitHub 저장소가 그 흔적으로 남은 전부임. Xuanwo가 블로그에 쓰지 않았다면 타임라인 속에서 사라졌을 것임. 꽤 흥미로운 상황임

    • 독점 플랫폼인 건 맞고 좋지 않음. 하지만 색인이나 검색이 안 된다는 비난은 납득하기 어려움
      기본으로 공개 접근 가능한 로그를 색인·검색해 주는 메신저는 거의 없음. 모든 IRC 서버가 공개 로그를 제공하는 것도 아니고, Matrix 그룹도 마찬가지임. 거기서의 논의는 왜 타임라인 속으로 사라지지 않는다고 보는지 모르겠음
      공개 로그를 제공할 수 있는 이유는 독점이 아니어서가 아니라 로깅을 허용하는 API가 있기 때문임. Telegram에도 그런 API가 있고, 우리 논의 그룹도 검색 가능한 로그를 여기서 볼 수 있음: https://luoxu-web.vercel.app/#g=1264662201
      공개 색인이 안 되는 건 주로 프라이버시 때문이지, 플랫폼이 독점이라서가 아님
    • USENET의 몰락을 아쉬워할 때마다 “이제 Discord가 있잖아”라는 답을 받아들이지 않는 이유가 바로 이것임
      예전에는 모든 글을 DejaNews, 나중에는 Google에서 깔끔하게 검색할 수 있었음
      인터넷/WWW 스택과 핵심 프로그래밍 도구·라이브러리처럼 중요한 오픈소스 프로젝트의 중요한 커뮤니케이션은 개방 표준으로 돌아가야 함
  • 이번 주에 읽은 글 중 가장 흥미로웠음. 훌륭한 정리임

  • 당연히 할 일은 copy_user_generic 커널 메서드에 패치를 보내는 것처럼 보임
    문제가 있는 CPU가 감지되고 메모리 정렬이 느려지는 버그를 유발하는 경우에는 다른 메모리 복사 구현을 쓰게 만들면 됨

    • 당연하진 않음. 마이크로코드로 고칠 수 있다면 커널에 사실상 소프트웨어 패치 가능한 문제의 수정 코드를 흩뿌리기보다 사람들이 업데이트된 마이크로코드를 쓰게 하는 편이 나아 보임
      커널 경험이 없는 사람에게 받아들여질 만한 수정은 사소하지 않을 것임. 더 중요하게는 우회책을 어떤 방식으로 활성화해야 하는지도 명백하지 않음. 아마 부팅 시점에 측정하는 게 가장 나을 텐데, 그렇지 않으면 어떤 모델과 스테핑이 영향을 받는지 어떻게 알 수 있을지 애매함
    • 사소한 수정이 아님. AMD가 페이지 정렬에 가까운 주소에서 앨리어싱이 왜 깨지는지 파악해야 하므로 수정은 마이크로코드 쪽일 가능성이 큼
      소프트웨어 완화책도 복잡할 것임. 커널은 ERMS를 사용할 수 없을 때 보통 대체 경로에서 쓰는 벡터 명령을 실제로 사용할 수 없기 때문임
  • jemalloc은 2018년까지 Rust의 기본 할당자였음
    https://internals.rust-lang.org/t/jemalloc-was-just-removed-...

  • “Rust 개발자는 성능 향상을 위해 jemallocator로 바꾸는 걸 고려할 수 있다”는 부분이 궁금함
    누구나 거의 공짜 성능 향상을 얻을 수 있는 건지, 아니면 주의점이 있는지 모르겠음. C 코드베이스도 이득을 볼 수 있는지, 현재 그냥 놓치고 있는 성능인지 궁금함

    • jemalloc을 쓰면 MADV_FREE 때문에 관측 가능성 문제가 생긴다는 점을 알아야 함. htop이 더 이상 실제 사용 중인 메모리를 정확히 보여주지 않음
      https://github.com/jemalloc/jemalloc/issues/387#issuecomment...
      https://gitlab.haskell.org/ghc/ghc/-/issues/17411
      지금은 jemallocMADV_FREE 후 10초 뒤 MADV_DONTNEED를 호출하는 듯함: https://github.com/JuliaLang/julia/issues/51086#issuecomment...
      그래서 이 이슈를 “고치긴” 하지만, 메모리를 해제한 시점과 htop에서 그 사실을 관찰하는 시점 사이에 헷갈리는 지연이 생김
      다만 https://jemalloc.net/jemalloc.3.html에 따르면 opt.muzzy_decay_ms = 0을 설정해 지연을 없앨 수 있음
      그래도 musl 작성자는 jemalloc을 기본값으로 삼는 데 유보적임: https://www.openwall.com/lists/musl/2018/04/23/2
      심각한 비대화, ASLR 약화, 메모리 사용량을 신경 쓰지 않고 최대한 빠르게 만드는 데 치우친 최적화 문제가 있다는 요지임. 위 튜닝 값으로 어느 정도 완화할 수 있겠지만, 성능과 메모리 사용량 중 무엇에 집중하느냐라는 전반적 성향은 여전히 트레이드오프로 남을 가능성이 큼
    • 거의 공짜로 놓치고 있는 성능이라고 봄. 바이너리 크기가 조금 늘어나는 비용은 있음
      모든 상황에서 반드시 더 빠르지는 않겠지만, 거의 대부분에서는 더 빠를 것임. Rust도 예전에는 jemalloc을 기본으로 썼지만, 기본값으로는 의외라고 여긴 사람들이 있어서 바뀌었음
    • 기본이 아닌 할당자로 바꾼다고 항상 성능이 오르지는 않음
      워크로드에 크게 의존하므로 프로파일링과 벤치마킹이 필요함. 그래도 C/C++/Rust 같은 저수준 언어는 이런 할당자들을 선택할 수 있어야 함
      한 가지 주의점은 바이너리 크기임. 사용자 지정 할당자는 실행 파일에 바이트를 더 추가함
    • Rust는 예전에 jemalloc을 기본으로 썼지만 2018년쯤 다시 시스템 malloc으로 돌아갔음[0]
      지금은 Rust에 GlobalAlloc 트레이트와 #[global_allocator] 속성이 있으므로, 앱이 원하면 jemalloc을 할당자로 쓸 수 있음. 사용자가 LD_PRELOAD 같은 방식으로 덮어쓸 수 있는지는 잘 모르겠음
      jemalloc이 모든 워크로드와 사용 사례에 항상 최선은 아님. 시스템 할당자가 완벽과는 거리가 멀 때가 많지만, 적어도 범용 할당자로 널리 테스트돼 왔음
      [0] https://github.com/rust-lang/rust/issues/36963
    • 성능은 프로그램이 “느림”에서 “빠름”으로 이동하는 1차원 척도가 아님. 항상 다른 요소가 함께 작용함
      jemalloc이 어떤 애플리케이션에는 맞는 선택일 수 있지만, 다른 경우에는 다른 할당자가 더 빠를 수 있음. 또는 더 느리더라도 더 적은 더티 메모리, 더 나은 관측 가능성, 특정 보안 보장 같은 목표에 더 잘 맞을 수도 있음
  • 이 내용을 알맞은 사람들에게 보냈음

    • AMD 쪽에 보냈다는 뜻인가?