bài các mức -O0 đến -O3 ta xếp tối ưu theo trục tốc độ tăng dần. Nhưng gcc có một mức đứng ngoài trục đó: -Os, tối ưu cho kích thước. Nó bật hầu hết những gì -O2 bật, trừ những phép làm phình mã — bung vòng, nội tuyến hung hăng, chèn nop căn lề, vector hóa mở rộng. Danh tiếng của -Os là "bản chậm cho khi cần binary nhỏ". Tôi vào đo cả kích thước lẫn tốc độ của nó, và thấy cái nhãn "chậm" đó chỉ đúng một nửa.

Tối ưu kích thước

-Os đánh đổi tốc độ lấy kích thước

-Os = "optimize for size". Nó chạy bộ tối ưu của -O2 nhưng với một luật thêm: bất cứ phép biến đổi nào tăng kích thước mã để đổi tốc độ đều bị tắt hoặc hạn chế. Bung vòng (chép thân vòng nhiều lần) — tắt. Nội tuyến những hàm to — hạn chế. Chèn nop để căn lề đầu vòng/hàm — bỏ. Vector hóa mà cần prologue/epilogue lớn — dè dặt.

Tôi đo trong container gcc:13 (ARM AArch64) size phần .text của cùng một chương trình ở bốn mức:

-O0 : 2183 byte
-Os : 2007 byte   <- nhỏ nhất
-O2 : 2062 byte
-O3 : 2334 byte   <- lớn nhất (phình để nhanh)

-Os cho binary nhỏ nhất trong nhóm tối ưu — nhỏ hơn cả -O2, và nhỏ hơn đáng kể -O3. Nhìn riêng một hàm cộng mảng: -Os sinh 13 lệnh, còn -O3 sinh 53 lệnh (vector hóa với 9 lệnh NEON). Kích thước là chỗ -Os thắng rõ ràng. Nhưng cái giá tốc độ ra sao?

Đo: tốc độ hỗn hợp, không phải "luôn chậm"

Tôi đo hai kernel khác tính chất. Một kernel tính nặng — cộng a[i]*3+1 trên mảng lớn, dạng mà -O2/-O3 vector hóa và bung vòng được:

-Os : 0,465 ns/phần tử
-O2 : 0,280 ns/phần tử   -> -Os chậm 66%
-O3 : 0,229 ns/phần tử

Ở đây -Os chậm hơn hẳn — vì nó bỏ đúng những tối ưu (vector, unroll) mà kernel này hưởng lợi lớn. Nếu chỉ đo kernel kiểu này, "-Os chậm" nghe đúng. Nhưng tôi đo thêm một kernel nhiều nhánh — xử lý mỗi byte theo một chuỗi if-else phụ thuộc dữ liệu, khó vector hóa, nghẽn ở dự đoán nhánh:

-Os : 4,30 ns/phần tử
-O2 : 3,94 ns/phần tử   -> -Os chỉ chậm 9%

Với kernel này, -Os chỉ chậm 9% — gần bằng -O2 — nhưng mã nhỏ hơn (40 so với 46 lệnh). Lý do: kernel nghẽn ở dự đoán nhánh, không ở số học, nên unroll và vector hóa của -O2 đằng nào cũng không giúp mấy; bỏ chúng đi (như -Os làm) gần như không mất gì mà mã gọn hơn. Thậm chí -O3 ở kernel này không nhanh hơn -O2 chút nào mà chỉ phình mã.

Đây là đo hớ của tôi. Tôi tin "-Os luôn chậm hơn -O2 vì bỏ tối ưu". Đo ra sai: -Os cho mã nhỏ nhất, và tốc độ hỗn hợp — chậm rõ ở kernel tính nặng vector hóa được, nhưng gần bằng -O2 ở kernel nhiều nhánh nơi những tối ưu bị bỏ vốn không giúp. -Os không phải "bản chậm của -O2", mà là một điểm khác trên đường cong đánh đổi kích thước-tốc độ.

-Os giữ gì và bỏ gì

Đáng làm rõ -Os không phải -O2 rồi tắt hết tối ưu. Nó giữ nguyên phần lớn những tối ưu vừa nhanh vừa gọn hoặc trung tính về kích thước: gấp hằng số, khử mã chết, loại biểu thức con chung, sức mạnh giảm — tất cả đều làm mã nhỏ hơn hoặc bằng nên -Os vẫn dùng. Cái nó bỏ là những phép đổi kích thước lấy tốc độ: unroll (chép thân vòng), nội tuyến hàm to (chép mã), vector hóa cần khối prologue/epilogue lớn, và nop căn lề.

Vì thế -Os không tệ như một -O0 nhỏ — nó vẫn là mã được tối ưu tốt, chỉ không phình. Đó là lý do ở kernel nhiều nhánh nó gần bằng -O2: những tối ưu chung (khử mã chết, gấp hằng) vẫn còn, chỉ thiếu unroll/vector mà kernel đó không cần. Nếu bạn còn muốn nhỏ hơn nữa, clang-Oz (ép kích thước mạnh tay hơn -Os, hy sinh tốc độ nhiều hơn); gcc thì -Os là mức nhỏ nhất sẵn có.

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

Hệ quả đầu tiên: kích thước mã là một chỉ tiêu thật, và -Os là công cụ cho nó. Với firmware và hệ nhúng (bộ nhớ flash tính từng KB), với binary tải qua mạng (thời gian tải tỉ lệ kích thước), với thứ cần khởi động nhanh (mã nhỏ nạp nhanh hơn), -Os là lựa chọn đúng — và như đo cho thấy, ở nhiều kernel nó không hy sinh tốc độ bao nhiêu. Đừng mặc định -O2/-O3 khi ràng buộc thật của bạn là dung lượng.

Hệ quả thứ hai nối với bài -O3 so -O2: mã lớn không tự do — và đôi khi mã nhỏ còn nhanh hơn. -O3 phình mã để đổi tốc độ, nhưng phần phình đó có thể tràn i-cache trong một chương trình lớn với nhiều đường code nóng. -Os giữ mã gọn, giảm áp lực i-cache; ở mã lạnh nhiều nhánh, tốc độ gần -O2 mà chiếm ít cache lệnh hơn có thể là thắng ròng khi nhìn cả hệ thống, không chỉ một vòng vi mô.

Hệ quả thứ ba là bài học đo lường: một mức tối ưu không có "nhanh" hay "chậm" tuyệt đối — nó có một đường cong tùy kernel. -Os chậm 66% ở kernel này và 9% ở kernel kia; bạn chỉ biết mình mất bao nhiêu bằng cách đo chính kernel của mình. Con số mang theo: -Os tối ưu như -O2 nhưng ưu tiên kích thước (tắt unroll/vector/nop căn lề), cho binary nhỏ nhất trong nhóm tối ưu (.text 2007 so -O2 2062 so -O3 2334; hàm ksum 13 lệnh so -O3 53); tốc độ HỖN HỢP — kernel tính nặng vector hóa được thì -Os chậm 66% (0,465 so 0,280 ns) do mất vector, nhưng kernel nhiều nhánh -Os chỉ chậm 9% (4,30 so 3,94) vì unroll/vector đằng nào cũng không giúp — nên -Os là đánh đổi kích thước-tốc độ khác, không phải bản chậm. Chọn mức tối ưu theo ràng buộc thật (dung lượng hay tốc độ) và theo kernel bạn đo, không theo nhãn.

Thử ba mươi giây

Lấy một chương trình C của bạn, build hai lần: gcc -O2 t.c -o agcc -Os t.c -o b. So kích thước ngay: size a b — cột text của bản -Os sẽ nhỏ hơn, thường 5-20%. Rồi time ./atime ./b trên tải thật: nếu chương trình của bạn là số học nặng (nhân/cộng mảng, xử lý ảnh), bản -O2 có thể nhanh hơn rõ; nếu nó là logic nhiều nhánh (phân tích cú pháp, máy trạng thái), hai bản gần bằng nhau. Ba mươi giây đó cho bạn biết -Os mất bao nhiêu tốc độ cho đúng code của bạn — và với nhiều chương trình, câu trả lời là "gần như không, mà nhỏ hơn hẳn".