Trong suốt sê-ri trước, tôi liên tục phải vật lộn với trình biên dịch: nó xóa mã, hoisting vòng lặp, biến đệ quy thành lặp, tự vector hóa. Sê-ri này quay ống kính đo vào chính những tối ưu đó. Bắt đầu từ thứ cơ bản nhất mà ai cũng gõ nhưng ít khi đo: cái cờ -O. Bạn biên dịch -O2 cho bản phát hành vì "nghe nói nó nhanh hơn" — nhưng nhanh hơn bao nhiêu, và vì sao, và -O3 có đáng không? Bài này lấy một chương trình, biên dịch nó ở bốn mức, đo thời gian nhìn thẳng vào mã máy sinh ra.

Mức tối ưu -O0 đến -O3

Bốn mức, một mã nguồn

GCC (và Clang) có các mức tối ưu đặt bằng cờ -O: -O0 là mặc định, gần như không tối ưu — dịch thẳng, dễ gỡ lỗi; -O1 bật một loạt tối ưu cơ bản; -O2 là mức thường dùng cho bản phát hành, bật hầu hết tối ưu an toàn; -O3 mạnh nhất, thêm những tối ưu tích cực như bung vòng lặp và vector hóa. Cùng một file .c, mỗi mức sinh ra mã máy khác nhau — và câu hỏi đo được là: khác nhau bao nhiêu, và khác ở đâu.

Tôi viết một hàm nóng: duyệt một mảng, với mỗi phần tử làm vài phép trộn số học (nhân, xor, dịch) rồi cộng dồn vào một biến tích lũy. Biên dịch nó ở -O0, -O1, -O2, -O3 thành bốn chương trình riêng, rồi đo thời gian mỗi phần tử — nhớ chặn dead-code bằng một biến volatile (nếu không, -O2 sẽ xóa sạch vòng và cho 0 ns, đúng cái bẫy bài đo lường vi mô của sê-ri trước đã đo).

Đo: -O0 chậm gần năm lần, và vì sao

Cùng code, thời gian mỗi phần tử (min của nhiều lần):
  -O0: 1,631 ns
  -O1: 0,357 ns   (nhanh hơn -O0 4,6 lần)
  -O2: 0,350 ns
  -O3: 0,351 ns

-O0 chậm hơn -O2 4,6 lần. Đó là một khoảng cách lớn, và nó không đến từ phép thuật gì cao siêu — nó đến từ cách trình biên dịch giữ biến. Để thấy, tôi đếm mã máy (bằng gcc -S) của đúng hàm nóng ấy:

Mã máy hàm compute (AArch64):
  -O0: 148 lệnh, 72 lệnh load/store (ldr/str)
  -O1: 104 lệnh, 26 lệnh load/store
  -O2: 149 lệnh, 30 lệnh load/store
  -O3: 149 lệnh, 30 lệnh load/store

Nhìn cột load/store. Ở -O0, hàm có 72 lệnh chạm bộ nhớ — vì -O0 để mọi biến (biến đếm i, biến tích lũy s, biến tạm x) nằm trên ngăn xếp. Mỗi lần dùng một biến, CPU phải load nó từ bộ nhớ vào thanh ghi, tính, rồi store trả lại — chạm bộ nhớ liên tục trong vòng lặp nóng. Lên -O1, con số này rơi xuống 26: trình biên dịch làm cấp phát thanh ghi (register allocation), giữ các biến nóng trong thanh ghi CPU suốt vòng lặp, chỉ chạm bộ nhớ khi thật cần. Bỏ được gần năm mươi lần chạm bộ nhớ mỗi vòng chính là nguồn của cú tăng tốc 4,6 lần. -O0 chậm không phải vì nó làm nhiều phép tính hơn, mà vì nó cất phép tính ở sai chỗ.

Một lần tôi đo hớ: cú nhảy lớn nằm ở -O0 đến -O1, và -O3 không khác -O2

Tôi vào bài với hai định kiến, và đo bác cả hai. Định kiến đầu: "-O0 cũng đủ nhanh cho hầu hết việc, tối ưu là chuyện vặt". Sai — -O0 chậm gần năm lần, và nó là mặc định nếu bạn quên bật cờ. Định kiến thứ hai ngược lại: "muốn nhanh thì bật -O3, và -O3 hẳn nhanh hơn -O2 đáng kể". Cũng sai: nhìn bảng thời gian, -O2-O3 gần như bằng nhau (0,350 so với 0,351 ns — chênh lệch nằm trong nhiễu). Và nhìn mã máy: cả hai sinh ra 149 lệnh, 30 load/store; khi tôi so trực tiếp mã của hàm ở hai mức, chúng chỉ khác đúng hai dòng (một khác biệt căn lề, không phải phép tính). Với chương trình này, -O3 không làm gì thêm so với -O2.

Bức tranh thật là: phần lớn lợi ích của tối ưu nằm ở cú nhảy -O0-O1 (từ 1,631 xuống 0,357 ns — chiếm khoảng 87% toàn bộ khoảng cách). -O1-O2 chỉ nhích thêm chút (0,357 → 0,350), và -O2-O3 gần như không đổi. Điều này hợp lý khi hiểu mỗi mức làm gì: -O1 mang lại những tối ưu "nền tảng, tác động lớn" như cấp phát thanh ghi; -O2 thêm nhiều tối ưu tinh vi hơn nhưng lợi ích cộng dồn nhỏ dần; -O3 bật những tối ưu tích cực (bung vòng, vector hóa) chỉ giúp với đúng loại code hợp — và với code không hợp, nó không giúp gì, đôi khi còn làm mã phình to hơn (ảnh hưởng cache lệnh) và chậm hơn. -O3 không phải "nút tăng tốc" mà là "thử thêm vài tối ưu mạo hiểm hơn".

Vì sao điều này quan trọng khi lập trình

Hệ quả đầu tiên, rất thực tế: luôn bật ít nhất -O2 cho bản phát hành, và đừng để -O0 lọt vào production. -O0 là mặc định của nhiều lệnh gcc file.c trần và của nhiều bản build gỡ lỗi; nếu một benchmark hay một dịch vụ vô tình chạy mã -O0, nó chậm gấp mấy lần mà không có lỗi nào báo. Đây cũng là một cái bẫy benchmark: đo một thuật toán trên bản -O0 rồi kết luận "nó chậm" là đo nhầm — bạn đang đo trình biên dịch lười, không phải thuật toán.

Hệ quả thứ hai: -O3 không phải mặc định đúng chỉ vì nó "cao hơn". Với phần lớn code, -O2 cho gần như toàn bộ lợi ích mà mã gọn hơn và thời gian biên dịch ngắn hơn. -O3 đáng thử cho các nhân tính toán nặng, hợp vector hóa — nhưng phải đo xem nó có thật sự nhanh hơn -O2 trên code của bạn không, vì đôi khi mã phình ra làm chậm đi. "Cao hơn" không đồng nghĩa "nhanh hơn"; chỉ đồng nghĩa "thử nhiều tối ưu hơn", và mỗi tối ưu là một canh bạc có thể thắng hoặc thua.

Hệ quả thứ ba là bài học đo lường mở màn cho sê-ri này: tối ưu của trình biên dịch là thứ nhìn thấy được — hãy nhìn. Con số mang theo: cùng một code, -O0 chậm 4,6 lần -O2 vì giữ mọi biến trên ngăn xếp (72 lệnh load/store so với 26 khi lên -O1); cú nhảy tốc độ lớn nhất nằm ở -O0-O1 (cấp phát thanh ghi); và -O2 với -O3 cho mã gần như y hệt — -O3 không đảm bảo nhanh hơn. Bạn không phải đoán trình biên dịch làm gì: gcc -S cho bạn xem thẳng mã máy, và đồng hồ cho bạn biết mức nào thật sự nhanh. Cả sê-ri này sẽ mở từng tối ưu ra như vậy — đo, đừng đoán.

Thử ba mươi giây

Lấy một file C nhỏ có một vòng lặp tính toán, biên dịch hai lần và so mã máy: gcc -O0 -S -o o0.s file.cgcc -O2 -S -o o2.s file.c, rồi mở hai file .s ra. Ở o0.s bạn sẽ thấy đầy lệnh ldr/str (hay mov với [rbp-...] trên x86) — đó là biến bị đẩy ra ngăn xếp, load/store mỗi lần dùng. Ở o2.s phần lớn biến mất, thay bằng phép tính thẳng trên thanh ghi. Rồi thử gcc -O2 -S so với gcc -O3 -S trên cùng file: rất có thể hai file gần như giống hệt. Ba mươi giây đó cho bạn thấy tận mắt vì sao -O0 chậm và vì sao -O3 thường không khác -O2 — không cần tin lời ai, mã máy tự nói.