Hình dung bạn mang xe đi độ. Có người bảo "cứ vặn hết mọi núm lên max thì xe nhanh nhất". Nhưng thực tế: bản tune xuất xưởng (như -O2) đã cân bằng tốt, chạy mọi nơi. Vài món độ thêm (-O3, -march, -funroll) có thể giúp trên đường đua của bạn — hoặc chẳng đổi gì. Món cho công suất lớn nhất là tháo bộ lọc khí thải (-ffast-math): xe mạnh hẳn, nhưng giờ nó trượt kiểm định khí thải — tức đổi luôn cái kết quả bạn cần. Và bản tune đua riêng cho đúng động cơ này (-march=native) thì không chạy được trên xe khác. "Vặn hết núm" hóa ra là gom một đống rủi ro để đổi lấy rất ít — và cách duy nhất biết cái gì thật sự giúp là chạy thử trên chính đường đua của bạn.

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 (tháo bộ lọc khí thải: mạnh hơn nhưng trượt kiểm định). 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ôi trường đo: container gcc:13 trên host ARM AArch64, tích vô hướng số thực N=4096 trong L1, volatile sink + warmup + lặp lấy min. Con số tuyệt đối đổi theo máy và workload; điều bền vững là hình dạng: -O3/-march thường không hơn -O2, và cú nhảy lớn đến từ vector hóa reduction mà -ffast-math mở khóa — kèm đổi kết quả số.)

Hai niềm tin đối nghịch, cùng sai

Đo hớ khép sê-ri gom hai niềm tin ngược nhau mà cùng lệch. Niềm tin thứ nhất, của người thích "vặn hết núm": có một "combo cờ thần" bật lên là nhanh mọi nơi. Bảng đo phá tan — ở workload này: -O3 = -O2, -march = -O2, -funroll chỉ 3%, và cái nhanh thật (-ffast-math) thì đổi kết quả. Niềm tin đối nghịch, của người quá thận trọng: "cứ -O2 là đủ, đừng đụng gì thêm". Cũng lệch — vì -ffast-math thật sự cho 2,9 lần, và với workload chấp nhận được sai số (đồ họa, game, ML) thì bỏ qua nó là bỏ phí lớn.

Sự thật ở giữa: 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. Ở 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. 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 đọ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ý (tune xuất xưởng); 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 (món độ thêm); 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, 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) — món độ đua tháo lọc khí thải; 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ề đến code hằng ngày

Điều đầ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.

Điều 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.

Chỗ này đáng nói là các nền tảng cao cấp đã tự động hóa chính cái "đo workload rồi mới tối ưu" này, nên bạn ít phải vặn cờ tay hơn C/C++. JVM (trình JIT C2) và V8 không bắt bạn chọn -O: chúng chạy diễn giải trước, hồ sơ hóa lúc chạy xem hàm nào nóng, rồi mới biên dịch tối ưu đúng chỗ đó — PGO tích hợp sẵn, dựa trên workload thật của bạn thay vì đoán lúc build. Go cố ý ít núm: trình biên dịch gc ưu tiên biên dịch nhanh, không có cả một rừng mức -O, đúng tinh thần "đừng bày ra combo cờ để người ta bật bừa". Và ở database, query planner chi phí-cơ-sở làm y hệt: nó đo thống kê dữ liệu rồi chọn kế hoạch, còn "hint" ép kế hoạch là lựa chọn cuối — cùng triết lý "đo, đừng ép". Mẫu số chung xuyên mọi tầng: hệ thống tốt đo rồi mới tối ưu, và khi bạn phải tự chỉnh (C/C++ với cờ), hãy làm đúng điều JIT làm tự động — profile, đo, rồi mới bật.

Điều cuối, và là lời khép của sê-ri: đo, đừng đoán; đọc, đừng tin. Tóm lại một câu: 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.

Muốn tự thấy thì 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. Vài phút đó 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.