Trong thế giới lập trình, các tối ưu hóa trình biên dịch được kỳ vọng sẽ làm cho mã chạy nhanh hơn, chứ không phải chậm hơn. Thế nhưng, một cuộc điều tra sâu gần đây về hiệu suất Rust đã tiết lộ một trường hợp đáng ngạc nhiên khi mức tối ưu hóa cao nhất thực sự làm tê liệt hiệu suất, châm ngòi cho một cuộc thảo luận rộng rãi trong giới lập trình viên về việc khi nào những cải tiến trình biên dịch có thể trở thành cái bẫy hiệu suất.
Trường Hợp Hiệu Suất Bất Thường
Một nhà phát triển Rust gần đây đã phát hiện ra rằng hàng đợi ưu tiên có giới hạn tùy chỉnh của họ chạy chậm hơn đáng kể khi được biên dịch với opt-level = 3 so với opt-level = 2. Mức phạt hiệu suất rất lớn - chậm hơn 113% trong các bài kiểm tra điểm chuẩn. Kết quả trái ngược này xảy ra bất chấp việc cả hai mức tối ưu hóa đều nhắm vào cùng kiến trúc Haswell và được thử nghiệm trên cả bộ xử lý AMD Zen 3 và Intel Haswell thực tế.
Đoạn mã có vấn đề liên quan đến một vector đã được sắp xếp sử dụng binary_search_by với một hàm so sánh mà trước tiên so sánh các khoảng cách số thực dấu phẩy động, sau đó mới đến các ID số nguyên. Mặc dù điều này có vẻ là một đoạn mã đơn giản, nhưng các chiến lược tối ưu hóa khác nhau của trình biên dịch đã tạo ra đầu ra mã assembly khác biệt hoàn toàn, dẫn đến sự chênh lệch hiệu suất.
Trong hàm có nhiều nhánh, id chỉ được so sánh nếu khoảng cách bằng nhau, và vì khoảng cách là một số float ngẫu nhiên, điều này hầu như không bao giờ xảy ra và nhánh tương ứng được dự đoán gần như hoàn hảo. Hàm không có nhánh luôn so sánh cả id và khoảng cách, trên thực tế thực hiện gấp đôi công việc.
So sánh hiệu suất: Mức tối ưu hóa O2 so với O3
- Tối ưu hóa O2: 44.1% mẫu trong binary_search_by, 25.68% trong hàm compare
 - Tối ưu hóa O3: 79.6% mẫu trong binary_search_by, 63.57% trong hàm compare
 - Mức độ giảm hiệu suất: chậm hơn +113% với O3 so với O2
 
Bí Ẩn Ở Cấp Độ Assembly
Khi các nhà phát triển đào sâu vào mã assembly, họ phát hiện ra rằng opt-level = 2 tạo ra mã đơn giản với các bước nhảy có điều kiện, trong khi opt-level = 3 tạo ra mã phức tạp hơn sử dụng các lệnh di chuyển có điều kiện. Các lệnh di chuyển có điều kiện thường được coi là hiện đại và hiệu quả hơn vì chúng tránh được việc dự đoán nhánh sai, nhưng trong trường hợp cụ thể này, chúng lại tạo ra các chuỗi phụ thuộc gây ra nút thắt cổ chai về hiệu suất.
Phân tích lý thuyết sử dụng các công cụ như uCA (uiCA) dự đoán rằng phiên bản sử dụng lệnh di chuyển có điều kiện sẽ có thông lượng thấp hơn 2,7 lần do các vấn đề về sự phụ thuộc. Điều này làm nổi bật cách mà sự phức tạp của CPU hiện đại - với các tính năng như xử lý song song cấp độ lệnh, dự đoán nhánh và thực thi suy đoán - đôi khi có thể phản tác dụng với mã được tối ưu hóa theo những cách không ngờ tới.
Sự khác biệt trong Assembly Code
- O2: Sử dụng conditional jumps (5 conditional jumps bao gồm cả kiểm tra NaN)
 - O3: Sử dụng conditional moves (4 conditional moves, 1 conditional jump)
 - Thông lượng lý thuyết: Assembly O3 được dự đoán thấp hơn 2.7 lần bởi công cụ phân tích uCA
 
Thử Nghiệm và Giải Pháp Từ Cộng Đồng
Cộng đồng lập trình đã phản hồi bằng cách thử nghiệm và phân tích rộng rãi. Một số nhà phát triển nhận thấy rằng việc thêm #[inline(always)] vào hàm so sánh có thể giảm mức phạt O3 khoảng 50%, mặc dù nó làm giảm nhẹ hiệu suất O2. Những người khác phát hiện ra rằng việc sử dụng total_cmp cho các phép so sánh số thực dấu phẩy động thay vì xử lý NaN thủ công tạo ra mã assembly khác nhưng vẫn có các vấn đề hiệu suất tương tự.
Một số người bình luận lưu ý rằng tối ưu hóa có hướng dẫn hồ sơ (PGO) có thể giúp LLVM đưa ra quyết định tốt hơn về thời điểm sử dụng lệnh di chuyển có điều kiện so với bước nhảy. Cuộc thảo luận cũng chạm đến bối cảnh lịch sử, với các tham chiếu đến những lần thoái lui hiệu suất Rust trong quá khứ liên quan đến tối ưu hóa tìm kiếm nhị phân và việc sử dụng lệnh di chuyển có điều kiện.
Các Giải Pháp Tiềm Năng Được Thảo Luận
- Thêm 
[inline(always)]vào hàm so sánh: cải thiện ~50% với O3, giảm 10% với O2 - Sử dụng 
std::hint::unlikelycho các nhánh hiếm gặp - Thay thế phép so sánh float thủ công bằng phương thức 
total_cmp - Tối ưu hóa hướng dẫn bởi profile (PGO) để trình biên dịch đưa ra quyết định tốt hơn
 
Hàm Ý Rộng Hơn
Nghiên cứu tình huống này tiết lộ những sự thật sâu xa hơn về các chiến lược tối ưu hóa trình biên dịch. Như một nhà phát triển đã lưu ý, LLVM không giả định rằng việc so sánh bằng nhau của số float ít xảy ra hơn so với các điều kiện khác, điều này có thể dẫn đến các lựa chọn tối ưu hóa dưới mức tối ưu cho các mẫu dữ liệu nhất định. Sự việc này nhắc nhở chúng ta rằng các tối ưu hóa trình biên dịch không phải là phép thuật - chúng là những thuật toán đưa ra các phỏng đoán có cơ sở mà đôi khi có thể sai đối với các mẫu mã cụ thể.
Cuộc thảo luận cũng làm nổi bật cách các lựa chọn ngôn ngữ lập trình giao thoa với đặc tính phần cứng. CPU hiện đại cực kỳ phức tạp, và đặc tính hiệu suất của chúng có thể bác bỏ trực giác đơn giản về những gì cấu thành mã được tối ưu hóa. Điều gì hiệu quả trên một kiến trúc hoặc với một mẫu dữ liệu có thể thất bại thảm hại trong những hoàn cảnh khác nhau.
Định Hướng Tránh Các Cạm Bẫy Tối Ưu Hóa
Đối với các nhà phát triển gặp phải vấn đề tương tự, cộng đồng đã đề xuất một số phương pháp. Sử dụng std::hint::unlikely trên các nhánh hiếm khi được thực thi có thể ảnh hưởng đến quyết định tối ưu hóa. Một số đề cập rằng __builtin_expect_with_probability của GCC/Clang với xác suất 0.5 có thể buộc sử dụng lệnh di chuyển có điều kiện khi thích hợp.
Điểm mấu chốt cần rút ra là tối ưu hóa hiệu suất đòi hỏi phải thử nghiệm thực nghiệm hơn là các giả định. Như một người bình luận đã nói ngắn gọn: Và đây là lý do tại sao bạn nên xem xét mã assembly trong godbolt để xem chuyện gì đang xảy ra. Trường hợp này chứng minh rằng ngay cả những nhà phát triển có kinh nghiệm cũng có thể bị bất ngờ bởi hành vi của trình biên dịch, nhấn mạnh tầm quan trọng của việc phân tích điểm chuẩn và phân tích cấp độ assembly đối với mã quan trọng về hiệu suất.
Nhóm phát triển trình biên dịch Rust trong lịch sử đã cân bằng cẩn thận các lựa chọn tối ưu hóa này, với những lần thoái lui hiệu suất trong quá khứ cho thấy lệnh di chuyển có điều kiện có thể nhanh hơn trong một số bài kiểm tra điểm chuẩn trong khi lại chậm hơn ở những bài khác. Tính biến đổi này nhấn mạnh lý do tại sao không có chiến lược tối ưu hóa nào phù hợp cho tất cả và tại sao các nhà phát triển trình biên dịch cung cấp nhiều cấp độ tối ưu hóa thay vì một thiết lập nhanh nhất duy nhất.
Trong bối cảnh công nghệ trình biên dịch và kiến trúc phần cứng không ngừng phát triển, những trường hợp như thế này đóng vai trò như những lời nhắc nhở quý giá về việc hiểu được sự tương tác giữa mã, trình biên dịch và CPU vẫn là điều cần thiết để viết được phần mềm thực sự hiệu năng cao.
Tham khảo: Khi O3 chậm hơn 2 lần so với O2
