Ở 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, nên cứ -O3 cho chắc. Và một truyền thuyết ngược lại cũng phổ biến không kém: -O3 có thể chậm hơn -O2 vì phình mã, nên cẩn thận. Tôi vào bài này với mục tiêu rõ ràng: bắt tận tay một ca -O3 chậm hơn -O2 trên máy này. Kết quả không như tôi định.
-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 container gcc:13 (ARM AArch64) để 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.
Đo hớ: 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.
Đó 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ì sao điều này quan trọng khi lập trình
Hệ quả đầ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.
Hệ quả 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 độ.
Hệ quả thứ ba là bài học đo lường bao trùm: một truyền thuyết ("-O3 chậm hơn") không hiện ra khi đo không có nghĩa nó sai — có thể bạn đang đo sai chỗ nó biểu hiện. Tôi không bắt được -O3 chậm hơn ở đồng hồ, nhưng cái giá thật (kích thước) thì đo được ngay. Con số mang theo: trên gcc 13/ARM, -O3 không chậm hơn -O2 ở cả bốn kernel — thắng 3,5 lần ở tích ngắn (vector hóa cái -O2 để scalar) hoặc hòa — nhưng trả bằng kích thước: hàm dotk phình từ 15 lên 47 lệnh (gấp 3); -O3 chắc chắn thêm mã, chỉ có thể thêm tốc độ, nên -O2 cân bằng là mặc định khôn, và nhảy lên -O3 phải đo trên workload thật. Số cao hơn không tự động tốt hơn.
Thử ba mươi giây
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 của bạn: 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.