Hình dung một nhân viên xử lý số liệu. Ở chế độ "làm đúng lời dặn, không nghĩ", mỗi lần cần một con số anh ta lại chạy ra tủ hồ sơ lấy, tính xong lại cất vào, con số kế tiếp lại chạy ra tủ — dù cái bàn ngay trước mặt thừa chỗ để giữ chúng. Chỉ cần anh ta bắt đầu nghĩ một chút — giữ những số đang dùng ngay trên bàn — tốc độ vọt lên mấy lần. Nhưng từ đó trở đi, cố "nghĩ nhiều hơn nữa" chỉ nhích thêm chút ít. Các mức tối ưu -O của trình biên dịch đúng là những nấc "cố gắng" đó, và cú nhảy lớn nhất không nằm ở nấc cao nhất như tôi tưởng.

Suốt sê-ri trước, tôi liên tục 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 nhanh hơn" — nhưng nhanh hơn bao nhiêu, vì sao, và -O3 có đáng không? Tôi lấy một chương trình, biên dịch ở bốn mức trong container gcc:13 (ARM), đo thời gian và nhìn thẳng vào mã máy.

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

GCC (và Clang) có các mức đặ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 — câu hỏi đo được là: khác 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 ở -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ô đã đ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 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ạy ra tủ hồ sơ 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 — giữ số ngay trên bà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ỗ.

Cú vấp: cú nhảy lớn ở -O0 → -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 và -O3 gần như bằng nhau (0,350 so với 0,351 ns — trong nhiễu). Và nhìn mã máy: cả hai sinh ra 149 lệnh, 30 load/store; khi so trực tiếp 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 (ép 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ề đến code hằng ngày

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

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

Và cái hình dạng "nấc đầu tiên ăn phần lớn lợi ích, các nấc sau giảm dần" là một quy luật vượt xa cờ -O, đáng mang theo. Ở tầng ngôn ngữ cao, cú nhảy tương đương là bật JIT: một vòng Java chạy thông dịch rồi được C1 biên dịch tạo cú nhảy lớn nhất, còn C2 (tier cao nhất) chỉ thêm chút — giống hệt -O0→-O1→-O2. Ở tầng hệ thống, đây là quy luật lợi-ích-giảm-dần quen thuộc: thêm index đầu tiên cho một truy vấn chậm là cú nhảy khổng lồ, index thứ năm gần như vô ích; cache tầng đầu ăn phần lớn, tầng sau nhỏ dần. Mẫu số chung, một cách nghĩ chống tối-ưu-mù: phần lớn lợi ích thường nằm ở bước cơ bản đầu tiên (bật tối ưu, giữ dữ liệu nóng đúng chỗ, thêm index đúng), còn leo lên nấc cao nhất cho phần thưởng nhỏ dần và rủi ro tăng dần — nên trước khi vặn hết cỡ, hãy đo xem bước đầu đã ăn hết miếng bánh chưa.

Muốn tự thấy thì lấy một file C nhỏ có 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.c và gcc -O2 -S -o o2.s file.c, rồi mở hai file. Ở o0.s bạn sẽ thấy đầy lệnh ldr/str (hay mov với [rbp-...] trên x86) — 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 so gcc -O2 -S với gcc -O3 -S: 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.