- Điểm khởi đầu là một báo cáo rằng việc đọc tệp trong binding Python của Apache OpenDAL chậm hơn
open().read()tích hợp sẵn của Python, nhưng nút thắt không nằm ở bản thân OpenDAL hay PyO3 - Trong benchmark đọc tệp 64MiB,
python-fs-readđược đo khoảng 15~19ms, còn Ruststd::fsvà bản triển khai C khoảng 23ms, khiến Rust/C trông có vẻ chậm hơn Python - Khi lần theo
strace,eBPF,perf, khác biệt được liên hệ với offset của bộ đệm đích của syscallreadbên trong trang bộ nhớ, và hiện tượng giảm hiệu năng được tái hiện quanh0x10 - Hiện tượng tương tự được xác nhận trên các dòng AMD Ryzen 9 5900X, Ryzen 7 5700X, Ryzen 9 5900HX; hiệu năng thực thi
rep movsbbên trong_copy_to_itercủa kernel là manh mối chính - Không phải Python vốn nhanh hơn, mà kết quả này do lỗi CPU liên quan đến FSRM/
rep movsbtrên AMD Zen 3 và sự ngẫu nhiên của offset bộ nhớ; cải thiện khi dùngjemalloccũng không phải do bản thân allocator, mà do offset khác
Benchmark kỳ lạ bắt đầu từ binding Python của OpenDAL
- Apache OpenDAL là lớp truy cập dữ liệu để đọc và ghi dữ liệu theo cách thống nhất trên nhiều dịch vụ lưu trữ, và binding Python được cung cấp thông qua PyO3
- Một người dùng báo rằng đoạn mã đọc tệp 150MB bằng binding Python của OpenDAL chậm hơn đọc tệp tích hợp sẵn của Python
open(...).read()tích hợp sẵn của Python, 100 lần:4.470868484000675- Binding Python của OpenDAL, 100 lần:
8.993250704006641
- Ngay cả khi đơn giản hóa thành đọc tệp 64MiB, binding OpenDAL vẫn chậm hơn
python-fs-read: trung bình 15.9mspython-opendal-read: trung bình 32.9ms- Đọc tích hợp sẵn của Python được đo nhanh hơn binding OpenDAL 2.07 lần
Lần theo xuống Rust OpenDAL rồi đến std::fs
- Khi triển khai cùng logic bằng dịch vụ
fscủa OpenDAL trong Rust, nó vẫn chậm hơn đọc tích hợp sẵn của Pythonrust-opendal-fs-read: trung bình 23.8mspython-fs-read: trung bình 15.6ms- Đọc tích hợp sẵn của Python được đo nhanh hơn triển khai Rust OpenDAL 1.52 lần
- Vì dịch vụ
fscủa OpenDAL sử dụng std::fs của Rust, một bản triển khai riêng dựa trênstd::fsđã được viết để kiểm tra chi phí của chính OpenDAL - Với triển khai trực tiếp bằng Rust
std::fs, xu hướng tương tự vẫn tiếp diễnrust-std-fs-read: trung bình 23.1mspython-fs-read: trung bình 15.2ms- Đọc tích hợp sẵn của Python được đo nhanh hơn Rust
std::fs1.52 lần
Syscall và mmap nhìn từ strace
- Phân tích bằng
stracecho thấy cả Rust lẫn Python đều dùngmmapcho các cấp phát bộ đệm lớn - Khi chạy Rust
std::fs, luồng xử lý là mở/tmp/file, đọc 64MiB một lần, gọireadđể kiểm tra EOF rồi đóng tệp - Đọc tích hợp sẵn của Python thực thi nhiều syscall hơn như
newfstatat,ioctl,lseek, nhưng tổng thời gian lại ngắn hơn - Lệnh gọi
mmap(NULL, 67112960, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0)được dùng cho cấp phát bộ nhớ ẩn danh, không phải ánh xạ tệp67112960là kích thước 64MiB cộng thêm 4KiBMAP_ANONYMOUSnghĩa là cấp phát bộ nhớ không liên quan đến tệp
- Bản build mặc định
x86_64-unknown-linux-gnucủa Rust dùngmalloccủaglibc, vàglibccó thể dùngmmapcho các cấp phát lớn
Rust nhanh hơn với jemalloc và kết luận tạm thời bị đảo ngược
- Khi đổi allocator toàn cục của Rust sang
jemallocator::Jemalloc, Rust trở nên nhanh hơn Pythonrust-std-fs-read-with-jemalloc: trung bình 9.7mspython-fs-read: trung bình 15.8ms- Triển khai Rust dùng jemalloc được đo nhanh hơn Python 1.64 lần
- Ở thời điểm này, nguyên nhân trông có vẻ là
mmaphoặc allocator bộ nhớ mặc định, nhưng diễn giải sau đó đã được chỉnh lại trong bản cập nhật - Theo cập nhật ngày 2023-12-01, khác biệt không phải do
jemalloc,pymalloc,mimallocvốn nhanh hơnglibc malloc - Khác biệt thực sự đến từ offset trong trang của bộ đệm mà allocator tạo ra
rust-std-fs-read: đọc tại offset0x10từ địa chỉ bắt đầu củammaprust-std-fs-read-with-jemalloc: đọc tại offset0x740từ địa chỉ bắt đầu củammap
- Vùng có vấn đề được thu hẹp còn phạm vi
0x00..0x10bên trong trang, và cũng có thể tái hiện cùng vấn đề vớijemalloc
Vấn đề có tính tái hiện theo thiết bị hơn là theo cấu hình phần mềm
- Khi thảo luận tiếp diễn, người ta xác nhận rằng hiện tượng Rust chậm hơn Python đặc biệt rõ trên máy của tác giả
- CPU của tác giả là AMD Ryzen 9 5950X 16-Core Processor, với cấu hình bộ nhớ DDR4 3200 MT/s 16GB DIMM
- Ngay cả khi thay đổi nhiều cấu hình, chênh lệch hiệu năng tương đối vẫn không biến mất
- Bật lại
mitigations=offcủa Linux kernel cũng không làm kết quả thay đổi - Khi đổi Transparent Hugepage giữa
always,madvise,never, giá trị tuyệt đối thay đổi nhưng tỷ lệ tương đối vẫn giữ nguyên - Dùng
core_affinityđể ghim vào một lõi CPU cụ thể cũng cho kết quả như vậy
- Bật lại
- Đo độ trễ syscall
readdựa trên eBPF cũng cho thấy phía Rust chậm hơn- Python
read file: 8,134,049ns - Rust
std::fsread file: 24,636,975ns
- Python
- Quan sát cho thấy khó có thể giải thích khác biệt chỉ bằng OpenDAL, PyO3 hay thư viện chuẩn Rust; thời gian đã bị kéo giãn ngay từ cấp syscall
Manh mối offset bộ nhớ lộ ra trong bản triển khai C
- Khi triển khai cùng thao tác đọc tệp 64MiB bằng C
fopen/malloc/fread, nó cũng chậm hơn Pythonc-fs-read: trung bình 23.8mspython-fs-read: trung bình 19.1ms- Đọc tích hợp sẵn của Python được đo nhanh hơn triển khai C 1.25 lần
- Khi kiểm tra địa chỉ con trỏ bằng
strace -e raw=read,mmap, offset bắt đầu bộ đệm của C và Python khác nhau- C:
readtại offset0x10từ địa chỉ trả về củammap - Python:
readtại offset0x30từ địa chỉ trả về củammap
- C:
- Khi điều chỉnh offset trong triển khai C theo cùng cách, hiệu năng cải thiện đáng kể
c-fs-read-with-offset: trung bình 8.9ms- Nhanh hơn Python 2.15 lần, và nhanh hơn triển khai C ban đầu 2.68 lần
- Vấn đề này cũng được tái hiện trên AMD Ryzen 9 5900X và AMD Ryzen 7 5700X
- Trong chủ đề Std::fs::read slow? của cộng đồng Rust, hiện tượng tương tự cũng được báo cáo, và mối liên hệ giữa offset vùng nhớ với hiệu năng syscall đã được chỉ ra
Phân tích perf chỉ tới rep movsb
- Một nhà phát triển kernel đã tái hiện
c-fs-readvà phiên bản có áp dụng offset trênAMD Ryzen 9 5900HX, rồi phân tích bằngperf - Tùy có offset hay không, các giá trị
L1-dcache-prefetchesvàL1-dcache-loadskhác nhau rất lớn- Không có offset:
L1-dcache-loadskhoảng 127,845,213,L1-dcache-prefetcheskhoảng 1,843,493 - Có offset:
L1-dcache-loadskhoảng 13,965,813,L1-dcache-prefetcheskhoảng 395,578
- Không có offset:
- Điểm nóng nằm trên đường đi
readcủa kernel, theo chuỗishmem_file_read_iter→copy_page_to_iter→_copy_to_iter - Assembly cốt lõi bên trong
_copy_to_iterlàrep movsb, và phần lớn mẫu tập trung vào lệnh này - Trong phân tích sau đó, manh mối quan trọng hơn được tổng kết là hiện tượng
rep movsbcó hiệu năng kém với dữ liệu được căn chỉnh theo trang, và tốt hơn khi việc căn chỉnh theo trang bị phá vỡ, thay vì bản thân L1 prefetch
FSRM và vấn đề AMD Zen 3
- Báo cáo lỗi Ubuntu glibc được chia sẻ, Terrible memcpy performance on Zen 3 when using rep movsb, cũng đề cập đến vấn đề hiệu năng của
rep movsb - Ví dụ trong báo cáo đó giải thích rằng với bản sao 2113 byte, đường đi
rep movsbcho khoảng 3.2GB/s, nhưng nếu đổi kích thước thành 2111 byte thì tăng lên hơn 100GB/s - FSRM là viết tắt của Fast Short REP MOV, một tính năng nhằm làm cho
rep movsbvàrep movsdnhanh hơn - FSRM bắt đầu từ Intel và cũng được đưa vào AMD; trên các CPU tuyên bố hỗ trợ,
glibcmặc định sử dụng FSRM - Vì vậy, không phải Python vốn nhanh hơn C/Rust, mà là do lỗi CPU AMD khiến đường đọc của C/Rust chậm đi ở các offset bộ nhớ cụ thể
Cập nhật: AMD có biết hay không và phản ứng của glibc
- Theo cập nhật ngày 2023-12-01, có vẻ AMD đã biết lỗi này từ năm 2021
- Sau khi bài viết được công bố, nhiều độc giả đã gửi liên kết cho AMD, nên có thể xem là AMD đã biết vấn đề này
- Tác giả cho rằng AMD nên chịu trách nhiệm sửa lỗi này trong
amd-ucode, nhưng theo thông tin chưa được xác nhận, việc sửa bằngamd-ucodetrên Zen 3 có thể khó khăn - Hy vọng thực tế là glibc sẽ vô hiệu hóa FSRM khi cần
- Phía glibc đang tiến hành công việc x86: Improve ERMS usage on Zen3
Mã tái hiện và tài liệu liên quan
- Xuanwo/when-i-find-rust-is-slow: tập hợp các đoạn mã và script đã được sử dụng
- Std::fs::read slow?: báo cáo tương tự từ cộng đồng Rust
- Terrible memcpy performance on Zen 3 when using rep movsb: vấn đề hiệu năng
rep movsbtrên Zen 3 được báo cáo cho Ubuntu glibc - binding/python: rust std fs is slower than python fs: issue liên quan đến binding Python của OpenDAL
1 bình luận
Ý kiến trên Hacker News
Có tới hai cờ tính năng CPU chuyên dụng cho biết
REP STOS/MOVnhanh và có thể dùng làm chuỗi lệnh ngắn chomemset/memcpyNỗi khổ phải viết lại thủ công các routine tối ưu hóa cho mỗi thế hệ CPU mới đã kéo dài hàng chục năm, vậy mà đến giờ vẫn còn tình trạng này; tôi tự hỏi chẳng phải nó nên nằm trong bộ kiểm thử timing của các nhà cung cấp CPU sao
Có thể
rep movsnhanh với căn chỉnh theo trang đã gặp vấn đề, hoặc dễ bị một kiểu tấn công nào đó nên bị vô hiệu hóaTôi không biết bản sửa nên theo hướng nào, có cần thứ như kiểm tra runtime hay không
Nếu có một triển khai “phần mềm” nhanh hơn, tôi thắc mắc vì sao
REP MOVSít nhất không được làm để thực hiện cùng việc đó trong microcodeBug glibc liên quan nằm ở đây. Tuy nhiên trường hợp này là Zen 4: https://sourceware.org/bugzilla/show_bug.cgi?id=30994
Ban đầu đọc bài tôi đã chuẩn bị cười nhạo vì nghĩ tác giả dùng sai
std::fs, nhưng thực ra đây lại là một bài viết thú vị, kéo theo một hố thỏ debugging và một bí ẩnViết hay và rất thú vị
Tiền đề hơi gây nhầm lẫn. Đây không phải là so sánh code Python thuần với code C/Rust native, mà là so sánh phương thức đọc file của Python, một wrapper Python trên code native, với OpenDAL, một wrapper khác trên code native
Việc có chênh lệch hiệu năng vẫn thú vị, nhưng diễn đạt là “chậm hơn Python” thì khá kỳ lạ. Có phải người ta kỳ vọng toàn bộ thư viện chuẩn Python được viết bằng Python thuần không? Ngược lại, tôi còn nghĩ các triển khai hàm trong thư viện chuẩn Python là native và được tối ưu hóa cao ở mức riêng lẻ
Việc kết luận liên quan đến cách code native hoạt động thì không gây ngạc nhiên, nhưng đáp án cụ thể lại bất ngờ. Chỉ là phần mở đầu hơi gây lẫn lộn, còn bản thân bài viết thì rất thú vị
Ngoài ra, tiêu đề “C is slower than Python with specified offset” với người bản ngữ sẽ được hiểu là “C vẫn chậm hơn Python ngay cả khi đã chỉ định offset”. Thực tế ý lại ngược lại: khi chỉ định cho C cùng offset đã dùng trong Python thì C trở nên nhanh hơn
Một tác vụ đơn giản như đọc file mà thư viện chuẩn Rust chậm hơn thư viện chuẩn Python là chuyện đáng ngạc nhiên. Ngay cả khi biết các lời gọi thư viện chuẩn Python kiểu này được viết bằng C, ta vẫn kỳ vọng lời gọi thư viện chuẩn Rust có tốc độ tương tự
Vì vậy thông thường người ta sẽ đoán là dùng sai cách hoặc thư viện chuẩn Rust có hành vi lạ, nhưng lần này cả hai đều không phải; đó là một vách hiệu năng xuất hiện trên phần cứng cụ thể tùy theo căn chỉnh cấp phát
Ta có thể kỳ vọng đọc hệ thống file trong Python được tối ưu tốt, nhưng cũng nghĩ Rust cũng sẽ như vậy; do đó việc phía Rust chậm hơn nhiều là điều đáng ngạc nhiên, và càng ngạc nhiên hơn vì nó phụ thuộc vào phần cứng và allocator
Nếu code viết bằng Python chạy nhanh thì với tôi đó là Python nhanh. Việc phần triển khai được viết bằng ngôn ngữ khác hay vì lý do nào khác không quan trọng lắm
Những gì xảy ra trong bài gốc gần như thuần túy là tình cờ. Code C của CPython thậm chí còn không bận tâm đến tính nhất quán
const, có nhiều cấp phát bộ nhớ động và nhiều lời gọi phụ/trợ tiện ích. Ngay cả các phép toán số học cũng cấp phát bộ nhớ độngNếu từng làm việc với CPython, thường bạn sẽ không kỳ vọng hiệu năng tốt. Khi muốn cải thiện hiệu năng, bạn sẽ muốn đi vòng qua các tính năng nó cung cấp
Ngoài ra Python không có chuẩn, nên nói chặt chẽ thì cũng không có thư viện chuẩn; các thư viện được phân phối kèm phần lớn được viết bằng Python. Một số được viết bằng C, nhưng trong số code C đó cũng có khá nhiều phần về cơ bản là chuyển cơ học code Python sang C. Ví dụ triển khai tìm kiếm nhị phân của Python ban đầu được viết bằng Python, sau đó được dịch sang C bằng Python C API
Điều có thể kỳ vọng chỉ là các tính năng ánh xạ đơn giản tới chức năng của hệ điều hành sẽ có wrapper tương đối mỏng. Tức là đọc file về bản chất đi thẳng vào giao diện hệ thống, nên sẽ không cần nhiều code binding
Sau khi những bài tương tự xuất hiện hàng chục lần, mọi người đều đã nhận ra điều đó
Bài viết tự thân đã rất xuất sắc và có nhiều thông tin thú vị liên quan đến vấn đề này
Tuy nhiên, điều khiến tôi quan tâm và lo ngại hơn là vấn đề được báo cáo, ghi nhận như thế nào, và việc giao tiếp được xử lý ra sao
Báo cáo được thực hiện trên Discord, một môi trường độc quyền, không được lập chỉ mục, khó tìm kiếm và cũng không được lưu trữ lâu dài. Thảo luận diễn ra trên Discord và Telegram, mà trong bối cảnh này Telegram có khi còn tệ hơn
Bài blog này và kho GitHub là tất cả dấu vết còn lại. Nếu Xuanwo không viết lên blog thì nó đã biến mất trong dòng thời gian rồi. Một tình huống khá thú vị
Hầu như không có trình nhắn tin nào mặc định lập chỉ mục/tìm kiếm các log có thể truy cập công khai. Không phải máy chủ IRC nào cũng cung cấp log công khai, các nhóm Matrix cũng vậy. Tôi không hiểu vì sao lại cho rằng các cuộc thảo luận ở đó sẽ không biến mất trong dòng thời gian
Lý do có thể cung cấp log công khai không phải vì nó không độc quyền, mà vì có API cho phép ghi log. Telegram cũng có API như vậy, và nhóm thảo luận của chúng tôi cũng có log có thể tìm kiếm tại đây: https://luoxu-web.vercel.app/#g=1264662201
Việc không lập chỉ mục công khai chủ yếu là vì quyền riêng tư, chứ không phải vì nền tảng đó độc quyền
Ngày trước, mọi bài viết đều có thể được tìm kiếm gọn gàng trên DejaNews, rồi sau này là Google
Các trao đổi quan trọng của những dự án mã nguồn mở quan trọng như stack Internet/WWW và các công cụ, thư viện lập trình cốt lõi cần quay lại với tiêu chuẩn mở
Đây là bài thú vị nhất tôi đọc trong tuần này. Tổng hợp rất tốt
Việc hiển nhiên cần làm có vẻ là gửi một bản vá cho phương thức kernel
copy_user_genericNếu phát hiện CPU có vấn đề và gây ra lỗi làm chậm căn chỉnh bộ nhớ, chỉ cần khiến nó dùng một triển khai sao chép bộ nhớ khác là được
Một bản sửa đủ để người không có kinh nghiệm kernel chấp nhận sẽ không hề đơn giản. Quan trọng hơn, cách kích hoạt workaround cũng không rõ ràng. Có lẽ tốt nhất là đo ở thời điểm boot, còn nếu không thì khá mơ hồ làm sao biết được model và stepping nào bị ảnh hưởng
Biện pháp giảm nhẹ bằng phần mềm cũng sẽ phức tạp. Lý do là kernel thực ra không thể dùng các lệnh vector thường được dùng ở đường thay thế khi không thể dùng ERMS
jemalloclà allocator mặc định của Rust cho đến năm 2018https://internals.rust-lang.org/t/jemalloc-was-just-removed-...
Tôi thắc mắc về đoạn “lập trình viên Rust có thể cân nhắc chuyển sang
jemallocatorđể cải thiện hiệu năng”Không rõ liệu ai cũng có thể gần như miễn phí nhận được cải thiện hiệu năng hay có điểm gì cần lưu ý. Tôi cũng tò mò liệu codebase C có hưởng lợi được không, và liệu đây có phải là phần hiệu năng hiện đang bị bỏ lỡ hay không
jemalloc, doMADV_FREEsẽ phát sinh vấn đề về khả năng quan sát.htopsẽ không còn hiển thị chính xác lượng bộ nhớ thực sự đang được sử dụnghttps://github.com/jemalloc/jemalloc/issues/387#issuecomment...
https://gitlab.haskell.org/ghc/ghc/-/issues/17411
Hiện có vẻ
jemallocgọiMADV_DONTNEED10 giây sauMADV_FREE: https://github.com/JuliaLang/julia/issues/51086#issuecomment...Vì vậy, nó có “sửa” vấn đề này, nhưng sẽ có một độ trễ gây nhầm lẫn giữa thời điểm giải phóng bộ nhớ và thời điểm có thể quan sát điều đó trong
htopTuy nhiên, theo https://jemalloc.net/jemalloc.3.html, có thể đặt
opt.muzzy_decay_ms = 0để loại bỏ độ trễDù vậy, tác giả của musl vẫn dè dặt về việc dùng
jemalloclàm mặc định: https://www.openwall.com/lists/musl/2018/04/23/2Ý chính là có các vấn đề như phình to nghiêm trọng, làm suy yếu ASLR, và tối ưu hóa thiên về việc làm nhanh nhất có thể mà không quan tâm đến mức dùng bộ nhớ. Các giá trị tinh chỉnh trên có thể giảm nhẹ phần nào, nhưng xu hướng tổng thể là tập trung vào hiệu năng hay mức dùng bộ nhớ nhiều khả năng vẫn là một sự đánh đổi
Không nhất thiết lúc nào cũng nhanh hơn trong mọi tình huống, nhưng gần như trong đa số trường hợp sẽ nhanh hơn. Rust trước đây cũng từng dùng
jemalloclàm mặc định, nhưng đã đổi vì có người thấy lựa chọn mặc định đó là bất ngờNó phụ thuộc rất nhiều vào workload, nên cần profiling và benchmarking. Dù vậy, các ngôn ngữ cấp thấp như C/C++/Rust nên có khả năng chọn những allocator như vậy
Một điểm cần lưu ý là kích thước binary. Allocator tùy chỉnh sẽ thêm byte vào file thực thi
jemalloclàm mặc định, nhưng khoảng năm 2018 đã quay lại dùngmalloccủa hệ thống[0]Hiện Rust có trait
GlobalAllocvà thuộc tính#[global_allocator], nên nếu ứng dụng muốn thì có thể dùngjemalloclàm allocator. Tôi không rõ người dùng có thể ghi đè bằng cách nhưLD_PRELOADhay khôngjemallockhông phải lúc nào cũng là lựa chọn tốt nhất cho mọi workload và use case. Allocator hệ thống thường còn xa mới hoàn hảo, nhưng ít nhất nó đã được kiểm thử rộng rãi như một allocator đa dụng[0] https://github.com/rust-lang/rust/issues/36963
jemalloccó thể là lựa chọn phù hợp cho một số ứng dụng, nhưng trong trường hợp khác, allocator khác có thể nhanh hơn. Hoặc dù chậm hơn, nó có thể phù hợp hơn với các mục tiêu như ít bộ nhớ bẩn hơn, khả năng quan sát tốt hơn, hay một số bảo đảm bảo mật cụ thểTôi đã gửi nội dung này cho những người phù hợp