Hình dung bạn nâng cấp lên một chiếc xe động cơ to hơn. Trên đường cao tốc thoáng nó vọt hơn hẳn; nhưng trong phố kẹt xe thì cũng chỉ nhích như mọi xe khác — cái mã lực dư thừa không giúp gì. Có một điều luôn đúng dù đường thế nào: chiếc xe to hơn ngốn xăng hơn và chiếm chỗ hơn. Cờ -O3 của trình biên dịch đúng là chiếc xe đó: nó có thể nhanh hơn -O2 tùy đoạn đường, nhưng nó chắc chắn sinh ra binary to hơn. Tôi vào bài này định bắt tận tay một ca -O3 chậm hơn -O2 để chứng minh "số cao chưa chắc tốt" — và kết quả không như tôi định.

Ở bài các mức -O0 đến -O3 ta xếp các mức tối ưu theo thứ tự tăng dần. Có một niềm tin đi kèm: số càng cao càng nhanh, cứ -O3 cho chắc. Và một truyền thuyết ngược lại cũng phổ biến: -O3 có thể chậm hơn -O2 vì phình mã, nên cẩn thận. Tôi mang mục tiêu rõ ràng vào container gcc:13 (ARM): bắt tận tay một ca -O3 thua -O2.

-O3 hung hăng hơn ở chỗ nào

-O2 đã bật phần lớn tối ưu an toàn. -O3 thêm vào những phép hung hăng hơn: vector hóa mạnh tay hơn (chấp nhận cả những vòng mà lợi ích không chắc), nội tuyến nhiều hơn (ngưỡng chi phí cao hơn), bung vòng rộng hơn. Mỗi phép đều có mặt trái tiềm tàng:

  • Phình mã (code bloat): nội tuyến và bung vòng làm hàm to ra. Mã to hơn chiếm nhiều i-cache (bộ nhớ đệm lệnh) hơn; một vòng nóng gọi nhiều hàm đã nội tuyến có thể tràn i-cache và chậm hơn bản gọn ở -O2.
  • Phần thêm của vector hóa: để vector hóa một vòng, compiler chèn mã kiểm căn lề (prologue) và xử lý phần dư khi số lần lặp không chia hết cho bề rộng vector (epilogue). Với vòng ngắn hoặc lặp ít, phần thêm đó có thể lấn cả phần lợi.

Đó là lý thuyết. Tôi mang bốn kernel vào để tìm ca thực tế -O3 thua.

Đo: bốn kernel, không ca nào -O3 chậm

Tôi thử bốn dạng hay được nhắc là "-O3 dễ hại": một tích vô hướng vòng ngắn (n=9), một vòng có sqrt và nhánh, một đệ quy tuyến tính (phụ thuộc vòng, không song song hóa được), và một truy cập rải bước lớn. Mỗi kernel đo -O2 với -O3, lấy lần nhanh nhất qua nhiều vòng, chặn hoisting bằng cách đổi mảng, chặn khử mã chết bằng volatile:

kernel                   -O2              -O3              kết quả
tích ngắn (dotk n=9)     2,674 ns         0,758 ns         -O3 nhanh 3,5 lần
sqrt + nhánh             0,894 ns         0,895 ns         hòa
đệ quy (loop-carried)    0,916 ns         0,909 ns         hòa
truy cập rải (stride)    2,9  ns          2,4  ns          -O3 nhỉnh

Không kernel nào -O3 chậm hơn -O2. Nó hoặc thắng đậm, hoặc hòa. Truyền thuyết "-O3 chậm hơn" mà tôi định trưng ra đã không xuất hiện trên chiếc gcc 13/ARM này. Đây là một kết quả trung thực đáng ghi: trên trình biên dịch hiện đại, -O3 hiếm khi tự bắn vào chân — heuristic của nó đủ tốt để phần lớn thời gian không làm tệ đi.

Nhưng khi nhìn cột bên cạnh — số lệnh trong hàm — câu chuyện thật hiện ra.

Cú vấp: cái giá là kích thước, không phải tốc độ

Tôi vào bài tin hai điều mâu thuẫn nhưng cùng phổ biến: "-O3 dễ chậm hơn -O2" (chuyện cảnh báo) và "số càng cao càng tốt" (thói quen). Đo xong, cả hai đều lệch.

Điều thứ nhất không hiện: -O3 không hề chậm hơn ở ca nào. Nhưng điều thứ hai — "cao hơn thì luôn tốt hơn" — cũng sai, chỉ theo một cách khác tôi không ngờ. Nhìn hàm dotk (tích vô hướng n=9):

-O2: 15 lệnh (scalar) -> 2,674 ns
-O3: 47 lệnh (vector) -> 0,758 ns

Ở đây -O3 nhanh 3,5 lần — vì nó vector hóa cái vòng ngắn mà -O2 để nguyên scalar. Nhưng nó trả bằng mã gấp hơn ba lần: từ 15 lệnh lên 47. Với một hàm đứng riêng thì 47 lệnh chẳng sao. Vấn đề là ở hai kernel hòa (sqrt, đệ quy), -O3 cho đúng tốc độ -O2 nhưng vẫn có thể sinh nhiều mã hơn — bạn trả kích thước mà không nhận lại tốc độ nào. Đúng chiếc xe to hơn nhích ngang xe thường trong phố kẹt, mà vẫn ngốn xăng hơn.

Đó là hình phạt thật của -O3, và nó không nằm ở đồng hồ của một kernel nhỏ mà ở kích thước binary cộng dồn trên cả một chương trình lớn. Mỗi hàm phình ba lần, nhân với hàng nghìn hàm, thành một binary to hơn hẳn, chèn ép i-cache trong những vòng nóng gọi qua lại nhiều hàm — đúng cơ chế "tràn i-cache" mà lý thuyết cảnh báo, chỉ là nó không lộ ra trên một vi benchmark một-hàm. Tôi đi tìm hình phạt ở sai chỗ: nó không ở tốc độ của kernel, mà ở kích thước của cả chương trình.

Vì sao -O3 không thắng nhiều hơn để bù kích thước? Vì -O2 của gcc hiện đại (từ gcc 12) đã vector hóa phần lớn vòng dễ, như bài vector hóa tự động đo được. -O3 chỉ thêm phần hung hăng cho những vòng khó/ngắn — đôi khi được lớn (dotk), thường thì không. Nên đổi từ -O2 sang -O3, bạn chắc chắn trả thêm kích thước, nhưng chỉ có thể nhận thêm tốc độ.

Về đến code hằng ngày

Điều đầu tiên: -O2 là mặc định "khôn" vì cân bằng. Nó bắt gần hết phần lợi (kể cả vector hóa) với mã gọn, và đó là lý do phần lớn dự án dùng -O2 cho bản phát hành chứ không phải -O3. "Số cao hơn" không phải một nút chỉnh miễn phí; nó là một đánh đổi tốc-độ-lấy-kích-thước mà kết quả thay đổi theo từng workload.

Điều thứ hai: muốn dùng -O3, hãy đo trên chương trình thật, không suy từ vi benchmark. Một kernel nhỏ như dotk cho thấy -O3 nhanh 3,5 lần và bạn tưởng cả app sẽ vậy — nhưng vi benchmark giấu đi cái giá i-cache chỉ hiện khi nhiều hàm phình cùng chen trong một chương trình lớn. Chạy chính app của bạn ở cả hai mức, đo thời gian và xem size ./binary, rồi mới quyết. Đôi khi -O3 thắng; đôi khi nó chỉ cho bạn một binary to hơn với cùng tốc độ.

Và cái ý "nấc cao nhất không tự động tốt nhất, nó là một đánh đổi" lặp lại ở mọi tầng chỉnh hiệu năng, đáng mang theo. JVM: nhiều người tưởng cứ cho JIT tier cao nhất (C2) là nhanh nhất, nhưng C2 biên dịch tốn thời gian và bộ nhớ hơn, nên với hàm chạy ít lần, tier thấp (C1) hoặc thông dịch lại tổng nhanh hơn — chính vì thế JVM có tiered compilation chứ không nhảy thẳng C2. Ở tầng hệ thống, mức nén cao nhất (gzip -9, zstd -19) tốn CPU nhiều hơn hẳn -6 mà chỉ nhỏ hơn vài phần trăm — hiếm khi đáng. Ngay cả chỉ số CSDL: thêm index tăng tốc đọc nhưng luôn làm chậm ghi và phình dung lượng — "nhiều index hơn" không tự do. Mẫu số chung, một quy tắc chống chỉnh-mù: nấc tối ưu cao nhất gần như luôn có một cái giá cố định (thời gian, kích thước, bộ nhớ) mà bạn trả chắc chắn, đổi lấy một khoản lợi chỉ có thể xảy ra tùy workload — nên mặc định cân bằng (-O2, C1/C2 phối hợp, nén mức vừa) thường là lựa chọn khôn, và chỉ leo lên nấc cao nhất khi đã đo thấy nó đáng.

Muốn tự thấy thì lấy một chương trình C bất kỳ của bạn, build hai lần: gcc -O2 prog.c -o a và gcc -O3 prog.c -o b. So kích thước ngay: size a b (nhìn cột text — phần mã). Với gần như mọi chương trình thật, cột text của bản -O3 to hơn. Rồi time ./a và time ./b trên workload thật: nếu -O3 nhanh hơn rõ, cái kích thước thêm là xứng đáng; nếu hai bản chạy ngang nhau (rất hay gặp), bạn vừa thấy tận mắt vì sao -O2 là mặc định — bạn trả kích thước mà không mua được tốc độ. Đừng chọn mức tối ưu theo con số; chọn theo cái đồng hồ và cái size nói với bạn.