Hình dung hai chiếc xe: một chiếc cơ bắp nhiều mã lực, một chiếc compact nhỏ gọn. Trên đường cao tốc thoáng, chiếc cơ bắp vọt lên rõ rệt. Nhưng trong phố kẹt xe, đèn đỏ liên tục, cái mã lực dư thừa kia gần như vô dụng — chiếc compact bám sát ngay sau, mà lại tốn ít xăng và dễ luồn lách hơn. Chiếc compact không phải "xe chậm"; nó là một đánh đổi khác, và trong đúng hoàn cảnh nó chẳng thua bao nhiêu. Cờ -Os của gcc chính là chiếc compact đó, và tôi đã dán nhầm cho nó cái nhãn "bản chậm".

Ở 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 đo cả kích thước lẫn tốc độ của nó trong container gcc:13 (ARM), và thấy cái nhãn "chậm" đó chỉ đúng một nửa.

-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 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. Đây là chiếc compact trên cao tốc thoáng: thua rõ. 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 — mà 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. Đây là chiếc compact trong phố kẹt: mã lực dư của xe cơ bắp vô dụng. Thậm chí -O3 ở kernel này không nhanh hơn -O2 chút nào mà chỉ phình mã.

Đây là chỗ tôi nhầm. 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 có -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ề đến code hằng ngày

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

Điều thứ hai nối với bài -O3 so -O2: mã lớn không miễn phí — 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ô.

Và cái căng thẳng "nhỏ gọn hay nhiều sức mạnh" này lặp lại ở mọi tầng phần mềm, đáng để mang theo cách nghĩ. Web: bundle JavaScript tối ưu cho kích thước tải về trước, vì với mạng di động, một bundle nhỏ tới tay và chạy được sớm hơn thắng một bundle "nhanh hơn khi chạy" mà tải cả giây mới xong — đúng bài toán -Os cho binary qua mạng. Java/JVM lật ngược ưu tiên: nó ship bytecode nhỏ gọn rồi để JIT phình mã nóng lúc chạy (inline, unroll) — tách bạch "kích thước lúc phân phối" khỏi "tốc độ lúc chạy", thứ mà C phải chọn một lần lúc biên dịch. CSDL cũng có trục này: nén dữ liệu (columnar, compression) đánh đổi CPU giải nén lấy ít I/O và ít dung lượng — và ở kho dữ liệu lớn, nhỏ-hơn thường lại nhanh hơn vì I/O mới là nút thắt. Mẫu số chung: "nhanh nhất" và "nhỏ nhất" là hai trục khác nhau, và trục nào thắng tùy nút thắt thật của bạn (flash, mạng, i-cache, I/O) — mặc định chọn tốc độ là bỏ quên nửa bản đồ.

Muốn tự thấy thì lấy một chương trình C của bạn, build hai lần: gcc -O2 t.c -o a và gcc -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 ./a và time ./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".