Xây dựng lại gói Ubuntu để nhanh hơn 90%
(gist.github.com/jwbee)- Khi xây dựng lại cùng mã nguồn
jqvà thay đổi allocator, thời gian xử lý GeoJSON 500MB giảm từ 4,606 giây xuống 2,428 giây, nhanh hơn 1,90 lần so với binary của Ubuntu - Benchmark được thực hiện trên Ryzen 9 9950X với parcel map của Alameda County Assessor, bằng cách trích xuất
SitusCitytheo điều kiệnTotalNetValue < 193000 - Chỉ riêng việc rebuild đơn giản cũng cải thiện 2~4%, còn tổ hợp
clang-18,-O3,-flto,-DNDEBUGđạt hiệu năng 1,20 lần so với gói Ubuntu - Profile cho thấy chi phí cấp phát bộ nhớ rất lớn, nên đã so sánh
TCMalloc,jemalloc,mimalloc; trong thử nghiệmLD_PRELOAD,mimallocnhanh nhất - Bản build cuối cùng liên kết với
mimalloccũng ghi nhận 0,755 giây so với 1,424 giây trong một trường hợp xử lý JSON 2,2GB riêng, cho thấy tùy workload có thể có chênh lệch lớn so với bản build mặc định của distro
Workload chuẩn và cách đo
- Đối tượng thử nghiệm là công cụ xử lý JSON
jq, dữ liệu đầu vào là tệp GeoJSON 500MB chứa parcel map của Alameda County Assessor - Truy vấn được chạy sẽ in ra
SitusCitycủa các mục trong danh sách parcel thỏa điều kiệnTotalNetValue < 193000.features[] | select(.properties.TotalNetValue < 193000) | .properties.SitusCity
/usr/bin/jqmặc định của Ubuntu mất khoảng 5 giây khi tệp đã được cache, và benchmark chi tiết được đo lặp lại bằnghyperfine- Để giảm biến động khi chạy, tiến trình được cố định vào CPU logic 2 bằng
taskset -c 2- Đây là thiết lập nhằm tránh ảnh hưởng từ interrupt hệ thống chạy trên CPU 0 và việc migrate CPU
Rebuild đơn giản từ cùng mã nguồn
- Tải
mã nguồn jqmà Ubuntu sử dụng, rồi chạy configure và build mà không thêm flag riêng - Chỉ với bản rebuild đơn giản này, tốc độ đã nhanh hơn gói binary của Ubuntu khoảng 2~4%
- Binary rebuild: trung bình 4,517 giây
- Ubuntu
/usr/bin/jq: trung bình 4,641 giây - Kết quả là hiệu năng khoảng 1,03 lần
Áp dụng clang và các flag tối ưu hóa
- Ở bước tiếp theo,
clang-18, mức tối ưu hóa cao hơn, LTO, cùng các flag liên quan đến debug/profiling được áp dụng cùng lúc - Các flag chính ảnh hưởng đến hiệu năng là
-O3,-flto,-DNDEBUG-O3dùng mức tối ưu hóa cao hơn-O2-fltobật tối ưu hóa ở thời điểm liên kết-DNDEBUGgiảm chi phí assertion vốn hiện lên đáng kể trong profile
- Ví dụ configure đã áp dụng như sau
CC=clang-18LDFLAGS="-flto -g -Wl,--emit-relocs -Wl,-z,now -Wl,--gc-sections -fuse-ld=lld"CFLAGS="-flto -DNDEBUG -fno-omit-frame-pointer -gmlt -march=native -O3 -mno-omit-leaf-frame-pointer -ffunction-sections -fdata-sections"
- Bản build này nhanh hơn binary Ubuntu 1,20 lần
- Rebuild tối ưu hóa: trung bình 3,853 giây
- Ubuntu
/usr/bin/jq: trung bình 4,631 giây
Thử nghiệm thay allocator
jqlà một chương trình C phức tạp, và profile cho thấy cấp phát bộ nhớ là chi phí lớn nhất- Trước tiên, build lại bằng cách liên kết với
TCMallocdo Ubuntu đóng gói- Thêm
-L/usr/lib/x86_64-linux-gnu -ltcmalloc_minimalvàoLDFLAGS - Binary rebuild: trung bình 3,253 giây
- Ubuntu
/usr/bin/jq: trung bình 4,611 giây - Kết quả nhanh hơn 1,42 lần so với binary Ubuntu
- Thêm
- Ngay cả khi chỉ đổi allocator bằng
LD_PRELOADtrên binary Ubuntu mặc định, vẫn có thể cải thiện phần nào- Mặc định: trung bình 4,601 giây
- Preload TCMalloc: trung bình 4,082 giây
- Nhanh hơn 1,13 lần so với mặc định
Preload động và thiết lập THP
- So sánh
jemalloc,mimalloc,TCMallocdo Ubuntu cung cấp bằngLD_PRELOAD - So sánh này thu được sau khi thiết lập các biến môi trường sau
MIMALLOC_LARGE_OS_PAGES=1MALLOC_CONF="thp:always,metadata_thp:always"GLIBC_TUNABLES=glibc.malloc.hugetlb=1
- Kết quả là mimalloc nhanh nhất
- glibc mặc định: trung bình 4,123 giây
- Preload TCMalloc: trung bình 4,130 giây
- Preload jemalloc: trung bình 3,510 giây
- Preload mimalloc: trung bình 3,154 giây
- Bật THP mang lại lợi ích cho cả glibc allocator, jemalloc và mimalloc
THP + mimallocnhanh hơn 31% so vớiTHP + glibc, và nhanh hơn 48% so với giá trị mặc định của glibc
Kết quả cuối cùng của bản build liên kết mimalloc
- Do preload động được xem là không lý tưởng về mặt hiệu năng, ở bước cuối cùng
jqđược build lại bằng cách liên kết vớimimalloc - Bản build cuối cùng nhanh hơn gói binary Ubuntu 1,90 lần
- Rebuild với
mimalloc: trung bình 2,428 giây - Ubuntu
/usr/bin/jq: trung bình 4,606 giây - Mỗi benchmark dựa trên 10 lần chạy
- Rebuild với
- Cùng bản build cũng được dùng cho một ứng dụng khác
- Xử lý JSON 2,2GB nằm trong 13.000 tệp
- Dùng
rushđể song song hóa jqrebuild vớimimalloc: 0,755 giâyjqtrong gói Ubuntu: 1,424 giây
- Trong trường hợp riêng này, mức tăng tốc cũng gần 2 lần
1 bình luận
Ý kiến trên Hacker News
Kiểu tiêu đề câu view như “build lại một gói Ubuntu và đổi trình cấp phát bộ nhớ để nhanh hơn 90%” làm tôi chỉ muốn đấm cho một cú qua TCP/IP. Thực ra chỉ là một gói thôi, và một phần cải thiện thậm chí cũng không nhờ biên dịch lại
Dù vậy, tôi từng thử chèn jemalloc vào một chương trình bằng
LD_PRELOADđể thay implementationmalloc, và kết quả khá tốt. Tôi không đo hiệu năng, nhưng mức dùng bộ nhớ của ứng dụng đó ổn định hơn và vấn đề trông giống rò rỉ bộ nhớ cũng biến mất. Trên thực tế, rất có thể đó không phải lỗi của chính ứng dụng mà là do phân mảnh bộ nhớ củamalloctiêu chuẩnfree(), bộ nhớ cũng không thực sự được giải phóng ra bên ngoài trừ những trường hợp ngoại lệCàng nhiều luồng và càng nhiều lõi CPU thì vấn đề này càng nghiêm trọng. Một cách khắc phục đơn giản là đặt biến môi trường “ma thuật”
MALLOC_ARENA_MAX=2để giới hạn số lượng cache. Cách khác là để ứng dụng gọimalloc_trim()định kỳ nhằm dọn cache, nhưng cách này cần sửa mã nguồnhttps://www.joyfulbikeshedding.com/blog/2019-03-14-what-caus...
Ngược lại, may là họ không biên dịch bằng
-O3, vốn có thể tiềm ẩn lỗi. Nó có thể tốt cho một số thứ mà hiệu năng là quan trọng, nhưng tôi không muốn toàn bộ hệ thống được biên dịch với-O3Từ lâu tôi đã bắt đầu tự build Mozilla và kernel Linux của mình theo ý thích, và thường thu được mức cải thiện hiệu năng tương đối. Xét cho cùng, toàn bộ mục đích của bản phân phối Gentoo Linux, chẳng hạn, là mức tăng hiệu năng có được nhờ biên dịch tối ưu mọi thứ từ mã nguồn
jq,grep,ffmpeg,ocrmypdf, và các tiện ích Unix phổ biến như vậy thường được build cho mục đích dùng chung chứ không phải cho một ứng dụng cụ thểKỹ thuật là sự đánh đổi. Trong bài viết, phần lớn lợi ích đến từ việc chuyên biệt hóa bộ cấp phát bộ nhớ. Cần nhớ rằng có những dự án đa luồng, nơi một luồng cấp phát, luồng khác ghi dữ liệu và luồng thứ ba giải phóng nó
Bộ cấp phát phải xử lý được việc này, nên việc tăng tốc cho một dự án có thể lại gây xung đột ở dự án khác. Chiến lược tái cấp phát cũng là một vấn đề. Có chương trình cấp phát trước rồi không bao giờ đụng tới
mallocnữa, nhưng cũng có chương trình cứ liên tục giải phóng rồi cấp lại. Khả năng xử lý phân mảnh tốt đến đâu, thời gian chạy là 10 giây hay 10 năm, tất cả đều quan trọng. Đôi khi việc chọn bộ cấp phát là khác biệt giữa độ ổn định dài hạn và tốc độ ngắn hạnKhi làm trình biên tập video để thử video 4K và cache các khung hình, tôi đã thử nhiều bộ cấp phát khác nhau. 32MB mỗi khung, 60fps tức gần 2GB mỗi giây cho một track. Rất nhanh sẽ chạm trần giới hạn của bộ cấp phát, và tôi nhận ra ít nhất thì bộ cấp phát mặc định của glibc cho độ ổn định dài hạn tốt nhất. Nhưng trong benchmark ngắn thì nó chậm nhất
Mimalloc là bộ cấp phát đa dụng giống JEMalloc / TCMalloc. glibc được biết đến là một bộ cấp phát khá tệ, còn MIMalloc hay TCMalloc mới hơn, tức các bản không phải bản mặc định đi kèm Ubuntu, thì vượt glibc rất xa
Dĩ nhiên mức tăng tốc có thể khác nhau, nhưng benchmark nhìn chung cho thấy cải thiện tổng thể. Việc điều đó có ý nghĩa với một ứng dụng cụ thể hay không lại là chuyện hoàn toàn khác. Về crash cũng vậy, đây đều là các bộ cấp phát đa dụng đa luồng nên không vận hành khác glibc, và bug thì glibc cũng có thể gặp y hệt
Tôi cũng xử lý khung hình video 8K lớn [1]. Nếu nói về chính các khung hình, thì cấp phát 60 lần mỗi giây chẳng là gì cả. glibc chậm chỉ vì một lý do. Mỗi lần cấp phát đều vượt
DEFAULT_MMAP_THRESHOLD_MAX, mà trên nền tảng 64-bit giá trị này là 32MiB, nên không thể thuyết phục glibc cache chúng như đã được mô tả trong tài liệumalloptMỗi lần nó đều gọi
mmapđể xin bộ nhớ trực tiếp từ kernel rồi trả lại bằngmunmap. Các system call này hơi chậm, và trong trường hợp của tôi, chi phí page fault trên từng trang bộ nhớ ở lần truy cập đầu tiên đủ chậm để không đạt được mục tiêu hiệu năng. Cách giải quyết thật ra rất đơn giản. Chỉ riêng với các khung hình video, hãy dùng bộ cấp phát riêng hoặc danh sách rỗi tự viết trênmmap. Vì các lần cấp phát cùng đúng một kích thước lặp lại rất ổn định nên cách này hoạt động tốt[1] Ở định dạng UYVY thì nhỏ hơn một chút so với 64MiB, còn ở định dạng I420 thì nhỏ hơn một chút so với 48MiB
Tôi thấy hơi khó hiểu bình luận này. Tôi không rành về C hay trình biên dịch C, nhưng tôi đã đọc toàn bộ gist, học được khá nhiều và thấy nó có giá trị
Nhưng rồi đọc bình luận top này khiến tôi lo là mình đã hiểu sai hoàn toàn bài viết. Giọng điệu của nó nghe như thể những gì gist nói tuyệt đối không bao giờ nên làm, và đó là một đề xuất tồi vì đã bỏ qua toàn bộ sự phức tạp này. Có ai có thể giúp tôi hiểu gist ban đầu có phải là một bài viết hay không, có điểm nào hợp lý không, hay hoàn toàn vô giá trị? Trước khi thấy bình luận này tôi nghĩ nó có ích, nhưng giờ tôi nhận ra mình không đủ hiểu biết để tự phân biệt
Đó là lý do dùng một bộ cấp phát duy nhất cho mọi thứ trên đời là một ý tưởng tệ. Thật khủng khiếp khi mọi ứng dụng đơn luồng, thậm chí cả ứng dụng đa luồng quản lý tài nguyên rất chặt, đều phải trả chi phí cho tính an toàn luồng
Đây là nỗi khổ rất quen thuộc. Nếu bạn dùng đúng ngôn ngữ cho dự án, người ta vẫn sẽ bảo bạn lẽ ra nên dùng ngôn ngữ khác vì đủ thứ lý do
Nếu bạn dùng đúng thuật toán nén cho dữ liệu, người ta vẫn sẽ giải thích vì sao bạn ngu ngốc và lẽ ra nên dùng thuật toán khác. Gần đây tôi phải nén một chuỗi JSON dài cụ thể để đưa vào Dynamo, và sau khi kiểm thử kỹ toàn bộ các thuật toán phổ biến thì Brotli vượt lên hẳn. Nhưng điều đó vẫn không ngăn được người đi ngang qua nào cũng bảo zlib tốt hơn. Đôi khi thật sự rất mệt mỏi
Gentoo Linux về cơ bản là một bản phân phối được tạo ra cho kiểu người như vậy, để họ có thể tối ưu hóa chiếc máy Linux của mình đúng theo nhu cầu sử dụng
Sau khi thiết lập ban đầu xong thì nó khá đơn giản và dễ dùng. Tôi nhớ mình đã kết được nhiều bạn trong kênh Gentoo Linux trên Matrix, đó là một thời vui vẻ
https://www.gentoo.org/
Một điều thú vị là ChromeOS thời kỳ đầu về cơ bản là một bản cài đặt Gentoo Linux tùy biến. Tôi không biết bây giờ nội bộ họ còn dùng Gentoo Linux hay không
Tôi đã dùng Gentoo suốt 20 năm nhưng chưa bao giờ dùng nó vì lý do hiệu năng. Gentoo rất tuyệt khi bạn biết mình muốn hệ thống hoạt động theo cách nào, và nó giúp bạn đạt tới điều đó
Mục tiêu của Gentoo là có một hệ điều hành trong đó mọi chương trình đều được build từ mã nguồn thay vì dùng các gói nhị phân dựng sẵn. Điều này cho phép tăng tốc ở mức nâng cao và tùy biến sâu, nhưng cũng đồng nghĩa ngay cả các thành phần cơ bản nhất như kernel cũng phải được biên dịch từ mã nguồn. Trong cộng đồng Linux, nó nổi tiếng là một hệ điều hành rất phức tạp vì quy trình cài đặt nặng nề. Một bản cài Gentoo cơ bản sẽ khởi động thẳng vào dấu nhắc lệnh, và người dùng phải tự chia phân vùng đĩa, tải xuống rồi giải nén một gói gọi là “Stage 3 tarball”, cài gói thủ công và tự dựng hệ thống. Người dùng mới hoặc ít kinh nghiệm thường không biết phải làm gì khi vào trình cài đặt mà không có màn hình đồ họa. Thành viên /g/ thường hay phóng đại giá trị của Gentoo để lừa người mới thử cài nó
Có thể sẽ trục trặc một hai lần, nhưng nếu bạn có đủ kinh nghiệm Linux tổng thể thì nhiều khả năng bạn sẽ vào recipe build để sửa, làm cho nó chạy theo nhu cầu, và đóng góp bản sửa ngược lên upstream. Đây là kết quả của việc tập trung ám ảnh vào chủ nghĩa tối giản và tránh mọi kiểu overengineering. Đó cũng là điều tôi đã nhớ suốt thời gian dùng Gentoo. Trong Gentoo, tôi luôn phải vọc USE flags và package masks theo những cách hầu như chẳng giúp ích gì cho người dùng khác. Hệ thống build quá phức tạp, đến mức suốt nhiều năm trời việc thật sự nắm vững nó và sửa vấn đề tận gốc để đóng góp lên upstream là quá khó. Void có thể là nền tảng lý tưởng cả khi bạn không muốn build toàn bộ hệ thống từ mã nguồn, nhưng vẫn muốn dùng lẫn giữa các gói nhị phân do bản phân phối cung cấp và các gói tự build từ source
Sau đó tôi chuyển sang ArchLinux, và nhìn chung nó khá ổn với tôi. Nếu bạn đang dùng một bộ xử lý khá tiêu chuẩn thì có lẽ Gentoo cũng không mang lại lợi thế lớn đến vậy
Làm vậy cũng có nghĩa bạn sẽ bỏ lỡ các bản cập nhật bảo mật không chỉ cho
jqmà còn cho onigurama, thứ phụ thuộc để parse bằng regex. Trước đây đã từng có cập nhật bảo mật cho onigurama, và nếu chuyện như vậy lặp lại thì có thể sẽ dễ bị tấn công.jqthường được dùng để parse JSON không đáng tin cậyNội dung khi đó là “Cập nhật bảo mật: sửa nhiều lỗi dereference con trỏ sai, hỏng bộ nhớ do ghi ngoài phạm vi, tràn bộ đệm ngăn xếp”, và vụ đó liên quan đến CVE-2017-9224, CVE-2017-9226, CVE-2017-9227, CVE-2017-9228, CVE-2017-9229
libonig5, và gói đó vẫn sẽ được cập nhật bình thườngTôi không cố hạ thấp giá trị của hệ thống CVE, nhưng cũng khó phủ nhận là tác động thực tế giữa các phát hiện có khác biệt rất lớn
Tôi đã xử lý mấy việc kiểu này từ khá lâu rồi, và theo trí nhớ của tôi thì ngay khi bạn đi vượt ra ngoài các flags mà upstream developer dùng, bạn sẽ dễ gặp những lỗi kỳ quặc, và khi lỗi xảy ra thì cũng nhận lại một mức độ thờ ơ khổng lồ. Ở đây tôi đang nói về upstream developer, chứ không phải người đóng gói của bản phân phối
Tôi chưa từng dùng
mallocnào ngoài libc, nhưng có vẻ nguyên lý vẫn giống nhauNhưng nếu ai cũng làm vậy thì sẽ thành đơn văn hóa, mà đơn văn hóa thì mong manh và tệ hại. Mã nguồn chỉ trở nên dù là hơi vững chắc hơn khi được build trong các bối cảnh khác nhau, tức trên các nền tảng, compiler, tùy chọn, thư viện khác nhau. Một lỗi mà nền tảng hay build flags chỉ tình cờ lướt ngay sát cái bẫy nên phần lớn thời gian không chạm phải thì vẫn là lỗi, và phát hiện rồi sửa nó sẽ tốt hơn cho mã nguồn. Với tư cách cá nhân, tất cả chúng ta đều được lợi khi mã nguồn nói chung trở nên vững chắc thay vì mong manh
Tất nhiên, tôi cũng từng nghĩ
-march=nativelà cải thiện chủ yếu mà mình thấy, nhưng bài này cho thấy không hẳn vậy. Có vẻ các ứng dụng dùng số thực dấu phẩy động có thể có nhiều góc cạnh hơnĐiều này gần với việc biên dịch lại bằng một allocator khác cho kết quả benchmark tốt hơn trong một quy trình công việc cụ thể
malloc. Việc các bản phân phối vẫn tiếp tục dùng glibcmallocthay vì mimalloc hay jemalloc về cơ bản là thiếu trách nhiệm trong công việcmallocphù hợp với loại tải công việc nào không?Tò mò không biết hiệu năng so với bản clone
jqviết bằng Rust này như thế nàocargo install --locked jaqNếu muốn bật tối ưu hóa cho một họ CPU cụ thể, cũng có thể thêm
RUSTFLAGS="-C target-cpu=native".cargo installlà một tính năng bị đánh giá thấp của Rust, rất hợp với kiểu mục đích được mô tả trong bài. Nó biên dịch công cụ từ mã nguồn, nên có thể chọn các tính năng hay tập lệnh theo nền tảng mà bình thường không có trong binary để giữ tương thích với CPU cũ. Cũng không cần phải clone kho mã hay tự tìm cách build, vì mọi thứ đi kèm sẵnjaq[1] vàyq[2] là lựa chọn tôi thường dùng mỗi khi đang dùngjqmà cần cải thiện hiệu năng nhanh và dễ[1] https://github.com/01mf02/jaq
[2] https://github.com/mikefarah/yq
jqvàgojq, dùng lời giảijqcủa tôi cho AoC 2022 day 13 để thử nghiệmhttps://gist.github.com/oguz-ismail/8d0957dfeecc4f816ffee79d...
Hiện tại nó vẫn chậm hơn cả hai
cargo installcòn có cờ--gitđể chỉ định URL kho mã. Có thể dùng khi không có gói công khai hoặc khi muốn lấy commit mới nhất chưa phát hànhTôi đã dùng cách này nhiều lần trước đây, đặc biệt hữu ích như một cách dễ dàng để nhanh chóng cài các công cụ cá nhân viết gấp sau khi đẩy lên kho mã, mà không cần thiết lập quy trình phát hành hay tự chép binary sang các máy riêng và theo dõi chính xác commit đã dùng để build
Nếu thực sự định làm vậy thì chỉ cần tải đúng gói mã nguồn mà Ubuntu muốn dùng. Trong trường hợp này là
apt-get source jqSau đó vào trong gói và biên dịch lại tùy ý. Cũng có thể đóng gói lại để phân phối hoặc lưu trữ. Làm vậy sẽ cho kết quả gần với Ubuntu upstream hơn rất nhiều, thay vì nhận về hàng loạt lỗi lạ và sự không nhất quán
Tiêu đề gây hiểu nhầm. Ý là thời gian giảm đi 90% so với trước, còn trên thực tế là nhanh hơn khoảng 45%
Nếu quan tâm tới cách chúng ta dùng ngôn ngữ thì điều này hơi thú vị. Người ta cũng có thể nói làm được nhiều việc hơn 90% trong cùng một khoảng thời gian, và cách nói đó phù hợp với những đơn vị tốc độ khác mà ta hay dùng như dặm mỗi giờ, từ mỗi phút, hay bit mỗi giây. Nhưng trong hiệu năng máy tính, thông lệ là đo thời gian cần cho một khối lượng công việc cố định. Có lẽ vì khối lượng công việc thường là cố định, còn thứ thay đổi là thời gian phải chờ. Trường hợp bài blog này cũng đúng hệt như vậy nên thời gian nằm ở tử số. Bản thân bài viết rất thú vị và được viết tốt, nhưng không phải là nhanh hơn 90%
Thêm nữa, nếu dùng đơn vị thời gian thì tôi sẽ không dùng từ “faster”. “45% less time” và “45% faster” là hai khẳng định rất khác nhau, và cả hai đều có ý nghĩa cả trong lẫn ngoài lập trình
Khi nói “đã giảm N% một thứ gì đó”, người ta thường mặc định N% đó là của chính thứ bị giảm, chứ không phải của một giá trị khác
Khi đọc rằng chỉ với một thay đổi đơn giản như vậy mà có thể đạt được mức tăng tốc lớn, điều đầu tiên tôi nghĩ tới là nên báo cho các tác giả jq biết. Có thể có những cạm bẫy cần lưu ý, hoặc sau khi kiểm thử họ có thể làm cho mọi người đều chạy nhanh hơn
Dù kết quả ra sao thì việc báo cho họ biết có vẻ vẫn hữu ích. Nhưng bài viết dường như thậm chí còn không cân nhắc lựa chọn đó, và trong phần bình luận ở đây tôi cũng không thấy. Có phải tôi đang bỏ sót điều gì không?
Tôi cũng không biết ở đó glibc allocator có phải mặc định hay không
https://en.m.wikipedia.org/wiki/Clear_Linux_OS