러스트 std fs가 파이썬보다 느린가? 아니, 하드웨어 문제임
(xuanwo.io)- Apache OpenDAL의 Python 바인딩에서 파일 읽기가 Python 내장
open().read()보다 느리다는 제보가 출발점이었지만, 병목은 OpenDAL이나 PyO3 자체가 아니었음 - 64MiB 파일 읽기 벤치마크에서
python-fs-read는 약 15~19ms, Ruststd::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
- Python 내장
- 단순화한 64MiB 파일 읽기에서도 OpenDAL 바인딩이 더 느렸음
python-fs-read: 평균 15.9mspython-opendal-read: 평균 32.9ms- Python 내장 읽기가 OpenDAL 바인딩보다 2.07배 빠르게 측정됨
Rust OpenDAL과 std::fs까지 내려간 추적
- 같은 로직을 Rust의 OpenDAL
fs서비스로 구현해도 Python 내장 읽기보다 느렸음rust-opendal-fs-read: 평균 23.8mspython-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.1mspython-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기본 빌드는glibc의malloc을 사용하고,glibc는 큰 할당에mmap을 사용할 수 있음
jemalloc으로 빨라진 Rust와 뒤집힌 중간 결론
- Rust 전역 할당기를
jemallocator::Jemalloc으로 바꾸자 Python보다 빨라졌음rust-std-fs-read-with-jemalloc: 평균 9.7mspython-fs-read: 평균 15.8ms- jemalloc 사용 Rust 구현이 Python보다 1.64배 빠르게 측정됨
- 이 시점에는
mmap또는 기본 메모리 할당기가 원인처럼 보였지만, 이후 업데이트에서 해석이 수정됨 - 2023-12-01 업데이트 기준으로
jemalloc,pymalloc,mimalloc이glibc 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 코어에 묶어도 결과는 같았음
- Linux 커널
- eBPF 기반
read시스템콜 지연 측정에서도 Rust 쪽이 더 느렸음- Python
read file: 8,134,049ns - Rust
std::fsread file: 24,636,975ns
- Python
- 관찰 결과 OpenDAL, PyO3, Rust 표준 라이브러리만으로는 차이를 설명하기 어려웠고, 시스템콜 수준에서 이미 시간이 벌어졌음
C 구현에서 드러난 메모리 오프셋 단서
- 같은 64MiB 파일 읽기를 C
fopen/malloc/fread로 구현해도 Python보다 느렸음c-fs-read: 평균 23.8mspython-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 구현에서 같은 방식으로 오프셋을 조정하자 성능이 크게 개선됨
c-fs-read-with-offset: 평균 8.9ms- Python보다 2.15배, 기존 C 구현보다 2.68배 빠름
- 이 문제는 AMD Ryzen 9 5900X와 AMD Ryzen 7 5700X에서도 재현됨
- Rust 커뮤니티의 Std::fs::read slow?에서도 비슷한 현상이 보고됐고, 메모리 영역 오프셋과 시스템콜 성능의 관련성이 지적됨
perf 분석이 가리킨 rep movsb
- 커널 개발자가
AMD Ryzen 9 5900HX에서c-fs-read와 오프셋 적용 버전을 재현하고perf로 분석함 - 오프셋 유무에 따라
L1-dcache-prefetches와L1-dcache-loads값이 크게 달랐음- 오프셋 없음:
L1-dcache-loads약 127,845,213,L1-dcache-prefetches약 1,843,493 - 오프셋 있음:
L1-dcache-loads약 13,965,813,L1-dcache-prefetches약 395,578
- 오프셋 없음:
- 핫스팟은 커널의
read경로 안에서shmem_file_read_iter→copy_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 movsb도
rep movsb성능 문제를 다룸 - 해당 보고서의 예시는 2113바이트 복사에서
rep movsb경로가 약 3.2GB/s를 보이고, 크기를 2111바이트로 바꾸면 100GB/s 이상으로 올라간다고 설명함 - FSRM은 Fast Short REP MOV의 약자로,
rep movsb와rep 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 작업이 진행 중임
재현 코드와 관련 자료
- Xuanwo/when-i-find-rust-is-slow: 사용된 코드 조각과 스크립트 모음
- Std::fs::read slow?: Rust 커뮤니티의 유사 보고
- Terrible memcpy performance on Zen 3 when using rep movsb: Ubuntu glibc에 보고된 Zen 3
rep movsb성능 문제 - binding/python: rust std fs is slower than python fs: OpenDAL Python 바인딩 관련 이슈
댓글과 토론
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
- AMD도 조사 중임: https://inbox.sourceware.org/libc-alpha/20231115190559.29112...
- 이 버그는 Zen 3도 포함하고, 글쓴이의 CPU인 5900X도 언급함
-
처음엔 글을 읽고 작성자가
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
지금은jemalloc이MADV_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 쪽에 보냈다는 뜻인가?