Bốn mươi lăm phần, một thông điệp duy nhất: tối ưu là chuyện đo, không phải tín ngưỡng. Ta đã đi qua hành vi không xác định, LTO, PGO, vector hóa, căn lề, builtin, IPCP, constexpr, và cả cách đọc assembly cùng cách đo một cờ cho đúng. Bài kết này gom tất cả lại thành một câu hỏi thực dụng: trước một chương trình thật, bạn nên bật những cờ nào? Câu trả lời không phải một danh sách — mà là một phương pháp. Và tôi đóng sê-ri bằng đúng thứ đã mở nó: một phép đo.
Đo một workload qua bảy tổ hợp cờ
Niềm tin phổ biến — và là đo hớ tổng kết của cả sê-ri — là: "cứ bật hết cho chắc: -O3 -Ofast -march=native -funroll-loops thì nhanh nhất". Tôi lấy một workload đại diện (tích vô hướng số thực, N=4096, nằm trong L1), đo đúng cách như phần 44 đã dạy — volatile sink, warmup, lặp lấy min — qua bảy tổ hợp cờ trong container gcc:13:
-O0 : 2616 ns/lần
-O2 (mặc định an toàn) : 2078 ns/lần
-O3 : 2078 ns/lần -> = -O2, không đổi
-O3 -funroll-loops : 2017 ns/lần -> chỉ nhanh ~3%
-O3 -march=armv8.2-a : 2078 ns/lần -> = -O2
-O3 -ffast-math : 720 ns/lần -> nhanh 2,9 lần
-Ofast -march=armv8.2-a : 720 ns/lần -> nhanh 2,9 lần
Nhìn bảng này, "bật hết cho chắc" tan rã. Chuyển từ -O0 lên -O2 cho cú nhảy lớn nhất và an toàn nhất (1,26 lần). Nhưng từ -O2 lên -O3? Không đổi gì — hai con số y hệt. Thêm -march=armv8.2-a? Cũng không đổi. -funroll-loops chỉ vắt ra 3%. Ba cờ mà nhiều người bật theo phản xạ hầu như vô ích ở workload này.
Cái nhanh nhất đi kèm đánh đổi
Cờ duy nhất tạo khác biệt lớn là -ffast-math: 720 ns, nhanh 2,9 lần. Lý do đã gặp nhiều lần trong sê-ri: phép cộng số thực không có tính kết hợp, nên mặc định compiler không dám sắp lại để vector hóa phép tổng; -ffast-math cho phép nó, và vector hóa reduction mở khóa tốc độ. Nhưng nhìn kỹ output: tổng đổi từ 8376185856 sang 8376193536. Kết quả số đã khác. -ffast-math (và -Ofast bao gồm nó) đánh đổi độ chính xác lấy tốc độ — đúng như bài ffast-math cảnh báo. Với đồ họa hay game có thể chấp nhận; với tính toán tài chính hay khoa học thì không.
Vậy bức tranh đầy đủ: cú nhanh 2,9 lần có thật, nhưng nó không miễn phí — nó đổi kết quả. Và -march=native (mà tôi thay bằng -march=armv8.2-a cho ổn định) đánh đổi tính khả chuyển: nhị phân biên dịch cho chip này có thể không chạy trên chip khác, một cái giá đã đo ở bài -march khi assembler từ chối lệnh sdot. "Bật hết cho chắc" hóa ra là bật nhiều rủi ro để lấy rất ít lợi.
Một lần tôi đo hớ: không có cờ thần
Đo hớ khép sê-ri: tôi (và gần như mọi người mới) từng tin có một "combo cờ thần" bật lên là nhanh mọi nơi. Bảng đo phá tan điều đó. Ở workload này: -O3 = -O2, -march = -O2, -funroll chỉ 3%, và cái nhanh thật (-ffast-math) thì đổi kết quả. Ở một workload khác (ví dụ tích vô hướng số nguyên ở phần 44), -O3 vector hóa lại còn chậm hơn -O2. Không có cờ nào luôn thắng. Mỗi cờ là một đánh đổi phụ thuộc workload: nhanh hơn ở đâu, đổi lấy gì (độ chính xác, khả chuyển, kích thước mã, thời gian biên dịch), và có thật sự áp dụng cho code của bạn không.
Bài học chốt của bốn mươi lăm phần: mọi khẳng định về tối ưu — của tài liệu, của đồng nghiệp, của chính bài viết này — đều phải cúi đầu trước một phép đo ở đúng máy, đúng cờ, đúng workload của bạn. Và phép đo đó chỉ đáng tin khi đi qua bốn rào (đo lường vi mô đúng cách): chặn khử mã chết, chặn hoisting, chọn working set, lặp lấy min — cộng với đọc assembly để xác nhận cờ thật sự đổi mã.
Ba nhóm cờ, ba mức rủi ro
Nhìn lại cả sê-ri, các cờ tối ưu rơi vào ba nhóm theo mức rủi ro. Nhóm an toàn — bật thoải mái: -O2, và các cờ không đổi ngữ nghĩa như builtin, căn lề, inline hợp lý; chúng chỉ làm mã nhanh hơn mà giữ nguyên kết quả và tính khả chuyển. Nhóm có điều kiện — đo rồi mới bật: -O3, -funroll-loops, LTO, PGO; chúng có thể nhanh hơn nhưng cũng có thể không đổi hoặc chậm hơn tùy workload (như bảng đo cho thấy), và đôi khi làm mã phình to; chỉ bật khi đo được lợi. Nhóm đánh đổi thật — cân nhắc kỹ: -ffast-math/-Ofast (đổi kết quả số thực), -march=native (mất khả chuyển); chúng cho tốc độ lớn nhất nhưng lấy đi thứ bạn có thể cần. Biết một cờ thuộc nhóm nào là bước đầu để quyết định — và với hai nhóm sau, quyết định luôn dựa trên số đo, không phải thói quen.
Vì sao điều này quan trọng khi lập trình
Hệ quả đầu tiên: dùng -O2 làm mặc định, rồi đo trước khi thêm cờ. -O2 là điểm cân bằng an toàn: nhanh, không đổi ngữ nghĩa, khả chuyển. Chỉ thêm -O3, -march, -ffast-math, -funroll khi bạn đo được chúng giúp workload cụ thể của mình, và chấp nhận được đánh đổi của chúng. Bật theo phản xạ là rước rủi ro mà thường chẳng nhanh hơn.
Hệ quả thứ hai: tối ưu đúng chỗ, đo đúng chỗ. Trước khi chỉnh cờ, hãy tìm điểm nóng thật (hồ sơ hóa), vì phần lớn thời gian dồn vào một phần nhỏ code. Một cờ giúp 2,9 lần ở một vòng chiếm 1% thời gian tổng thì gần như vô nghĩa; giúp 3% ở vòng chiếm 90% mới đáng. Đo để biết đâu cần tối ưu trước khi đo cờ nào giúp.
Hệ quả thứ ba, và là lời khép của sê-ri: đo, đừng đoán; đọc, đừng tin. Con số mang theo: không có 'combo cờ thần' — trên workload này -O3 = -O2, -march không đổi, -funroll chỉ 3%, chỉ -ffast-math nhanh 2,9 lần nhưng đổi kết quả số thực (8376185856 -> 8376193536); mỗi cờ là một đánh đổi phụ thuộc workload, và cách duy nhất để chọn đúng là ĐO ở đúng máy/cờ/workload qua 4 rào đo lường, ĐỌC assembly xác nhận, rồi cân nhắc đánh đổi. Cảm ơn bạn đã đi hết bốn mươi lăm phần. Nếu chỉ mang theo một câu: khi ai đó nói "bật cờ này cho nhanh", hãy mỉm cười và mở terminal — rồi đo.
Thử ba mươi giây
Lấy chương trình thật của bạn và đo thời gian nó với -O2, rồi với -O3, rồi với -O3 -march=native, rồi -Ofast — mỗi lần chạy vài lần lấy nhanh nhất. Rất có thể bạn sẽ ngạc nhiên: nhiều cờ không đổi gì, và cái đổi nhiều nhất có thể kèm một cái giá (kết quả khác, hoặc chỉ chạy trên máy này). So thêm output số để chắc -ffast-math không làm sai kết quả bạn cần. Ba mươi giây đó là toàn bộ sê-ri gói lại: đừng hỏi "cờ nào nhanh nhất", hãy hỏi "cờ nào nhanh nhất cho code của tôi, và tôi trả giá gì" — rồi để đồng hồ trả lời.