Cùng một file .c, thêm một cờ -O2 là chương trình chạy nhanh gấp mấy lần — đó là phép màu tối ưu của trình biên dịch. Nhưng "gấp mấy lần" là bao nhiêu, và có phải cứ -O cao hơn thì nhanh hơn không? Bài này đo năm mức -O0/-O1/-O2/-O3/-Os trên cùng một vòng lặp trong container, và tôi vấp hai ảo tưởng cùng lúc — cả hai chỉ lộ ra khi đọc assembly.

Các mức tối ưu -O

Năm mức tối ưu làm gì

Trình biên dịch cho bạn chọn mức nỗ lực tối ưu qua cờ -O:

  • -O0 (mặc định): không tối ưu. Biến được đổ ra stack và nạp lại mỗi lần dùng — chậm, nhưng mã máy bám sát mã nguồn nên dễ gỡ lỗi.
  • -O1: tối ưu cơ bản, quan trọng nhất là giữ biến trong thanh ghi thay vì stack.
  • -O2: mức phát hành thường dùng — nội tuyến hàm, gộp biểu thức, loại bỏ mã chết, và nhiều biến đổi khác.
  • -O3: quyết liệt hơn — mở vòng lặp, tự động vector hóa (SIMD). Có thể làm mã phình to và đôi khi chậm hơn vì áp lực cache lệnh.
  • -Os: tối ưu theo kích thước thay vì tốc độ.

Điều quan trọng: mọi mức phải cho cùng một kết quả chương trình — chỉ tốc độ và cỡ mã thay đổi. Tôi viết một vòng lặp cộng dồn i*i - (i>>1) qua 300 triệu vòng, có in kết quả ra, và đo từng mức.

Đo: cú nhảy lớn nằm ở -O0 sang -O1

Thời gian chạy (trung vị 3 lần) và kích thước đoạn mã .text:

Mức Thời gian .text
-O0 496 ms 1618 B
-O1 118 ms 1564 B
-O2 118 ms 1648 B
-O3 118 ms 1648 B
-Os 163 ms 1550 B

Cả năm mức in ra cùng một con số (333325833400000), nên đây là tối ưu đúng đắn, không phải cắt xén. Nhưng hình dạng của bảng không như tôi tưởng. Cú nhảy tốc độ lớn nhất nằm ở -O0 → -O1: từ 496 xuống 118 ms, nhanh 4,2 lần. Sau đó -O1, -O2, -O3 bằng nhau ở 118 ms. Đọc assembly thì rõ lý do: ở -O0, biến tích lũy s và biến đếm i bị ghi ra stack rồi đọc lại mỗi vòng — hàng trăm triệu lượt truy cập bộ nhớ thừa. Chỉ cần -O1 giữ chúng trong thanh ghi là xóa sạch phần đó, và với vòng lặp đơn giản này thì -O2/-O3 không tìm được gì thêm để cải thiện. -Os hơi chậm hơn (163 ms) vì nó ưu tiên mã nhỏ, và cho ra .text nhỏ nhất (1550 B).

Một lần tôi đo hớ: "nhanh 490 lần" mà thật ra là công việc biến mất

Tôi vào bài với hai giả định. Thứ nhất: một cái thang mượt mà -O0 > -O1 > -O2 > -O3, mỗi mức nhanh hơn mức trước. Bảng trên đã bác bỏ — cú nhảy chỉ có một, ở -O0→-O1, rồi phẳng lì. "Cao hơn thì nhanh hơn" là sai: lợi ích tối ưu lồi lõm và tùy mã, không phải một đường dốc đều.

Giả định thứ hai tệ hơn. Ban đầu, khi viết benchmark, tôi quên in kết quả — hàm compute(n) được gọi nhưng giá trị trả về bị vứt đi. Đo phiên bản đó:

naive (không dùng kết quả):
  -O0 = 490 ms
  -O2 = 1 ms      <- nhanh 490 lần?!

Suýt nữa tôi reo lên "-O2 tăng tốc 490 lần!". Nhưng con số vô lý là dấu hiệu công cụ đang nói dối — nên tôi đọc assembly của main:

main() ở -O2 gọi compute:  0 lần

main không hề gọi compute. Vì kết quả không được dùng và compute không có tác dụng phụ, trình biên dịch nội tuyến nó vào main rồi xóa toàn bộ phép tính như mã chết — vòng lặp 300 triệu vòng đơn giản là không bao giờ chạy. Đó không phải tăng tốc; đó là công việc biến mất. (Một chi tiết dễ đánh lừa: nếu bạn grep thân hàm compute đứng riêng trong assembly, vòng lặp vẫn còn ở đó — vì compute có liên kết ngoài nên trình dịch giữ một bản phòng khi file khác gọi tới; cái bị xóa là bản nội tuyến trong main. Phải đọc đúng main, không phải cái symbol compute sót lại.)

Bài học đo lường, đúng tinh thần cả sê-ri: một tăng tốc "quá đẹp" thường là công việc bị loại, không phải được làm nhanh. Cách đo tối ưu cho tử tế: (1) luôn dùng kết quả — in ra, hoặc ghi vào một biến volatile (sink) — để trình dịch không được phép xóa; (2) đọc assembly xem vòng lặp còn không; (3) tin đồng hồ nhưng luôn kiểm chứng bằng mã máy. Bản có printf ở bảng đầu chính là bản đã cắm sink, nên -O2 giữ nguyên vòng lặp và cho con số 118 ms trung thực.

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

Hệ quả đầu tiên là chọn mức -O có chủ đích. -O2 là điểm ngọt cho hầu hết mã phát hành: nhanh, ổn định, ít bất ngờ. -O3 đáng thử cho mã số học nặng có thể vector hóa, nhưng phải đo — nó có thể ngang hoặc chậm hơn -O2 do mã phình. -Os hợp cho firmware, thiết bị nhúng, hay khi kích thước nhị phân quan trọng. Và -O0 là để gỡ lỗi: mã máy bám sát nguồn, biến không bị tối ưu đi mất khi bạn đặt breakpoint. Đừng bao giờ đánh giá hiệu năng thuật toán bằng số đo -O0 — nó đo lưu lượng stack, không đo thuật toán.

Hệ quả thứ hai là luôn benchmark ở mức -O bạn sẽ ship, với kết quả được dùng. Đo -O0 rồi suy ra hiệu năng production (-O2) là sai hoàn toàn — như bảng cho thấy, chênh 4 lần. Và một microbenchmark không có sink ở -O2 gần như luôn cho số vô nghĩa vì phần đo bị xóa. Đây là cái bẫy khiến vô số "kết quả benchmark" trên mạng sai: người đo quên rằng trình biên dịch được phép xóa mọi thứ không quan sát được.

Hệ quả thứ ba là một góc nhìn về bản chất tối ưu: trình biên dịch tối ưu để giữ kết quả quan sát được, không để giữ công việc bạn viết. Nó tự do xóa, gộp, đảo, nội tuyến — miễn là cái chương trình xuất ra không đổi. Con số mang theo: cú nhảy tốc độ lớn nhất thường là -O0→-O1 (giữ biến trong thanh ghi), sau đó lồi lõm tùy mã; -O3 không đảm bảo nhanh hơn -O2; và một tăng tốc khổng lồ trên benchmark thường là dấu hiệu công việc đã bị loại, không phải được làm nhanh — hãy dùng kết quả và đọc assembly để phân biệt. Cờ -O là một công cụ mạnh, nhưng chỉ khi bạn đo nó đúng cách.

Thử ba mươi giây

Viết một vòng lặp cộng dồn nặng vào một biến s, rồi ở cuối main không dùng s. Dịch gcc -O2 và chạy time ./a.out — gần như tức thì. Giờ thêm printf("%ld\n", s); ở cuối, dịch lại -O2, chạy time: lần này nó tốn thời gian thật. Chênh lệch đó không phải do bạn "làm chậm" chương trình — mà do lần đầu trình biên dịch đã xóa cả vòng lặp vì s vô dụng. Chỉ hai lần time là bạn thấy tận mắt vì sao mọi benchmark phải dùng kết quả — và vì sao "đọc assembly" luôn là lời cuối.