Ở bài nội tuyến hàm ta thấy compiler chép thân một hàm nhỏ vào chỗ gọi để bỏ chi phí gọi và mở khóa các tối ưu tiếp theo. Nhưng nội tuyến có một biên giới ít ai để ý: nó chỉ làm được khi compiler nhìn thấy thân hàm lúc dịch. Mà một chương trình C thật gồm nhiều file .c, mỗi file dịch riêng. Hàm ở file này gọi hàm ở file kia thì sao? Tôi vào đo, với niềm tin rằng -O2 đã vắt kiệt mọi thứ — và niềm tin đó sai.
Ranh giới file chặn tối ưu
gcc dịch mỗi file .c thành một đơn vị dịch (translation unit) độc lập, ra một file .o. Khi dịch main.c, compiler chỉ thấy nội dung main.c cộng các khai báo trong header — nó không thấy thân các hàm định nghĩa ở file khác. Sau đó trình liên kết (linker) mới ghép các .o lại thành chương trình, nhưng theo cách truyền thống linker chỉ nối mã đã sinh, không tối ưu gì thêm.
Hệ quả: nếu main.c gọi hàm mix() định nghĩa ở math.c, thì lúc dịch main.c compiler chưa có thân mix để chép vào — nó buộc phải để lại một lệnh gọi thật (bl mix trên ARM). Dù mix nhỏ xíu và nằm trong vòng lặp nóng chạy hàng trăm triệu lần, ranh giới file vẫn chặn không cho nội tuyến. -O2 đầy sức mạnh, nhưng nó chỉ nhìn được một file mỗi lần.
LTO (Link-Time Optimization — tối ưu lúc liên kết, bật bằng -flto) phá đúng biên giới đó. Thay vì sinh mã máy cuối cùng khi dịch từng file, -flto nhét biểu diễn trung gian của compiler vào mỗi .o, rồi hoãn phần tối ưu nặng tới lúc liên kết — thời điểm linker thấy toàn bộ chương trình cùng lúc. Lúc này compiler mới có thể nội tuyến mix vào main, khử mã chết xuyên file, lan truyền hằng số xuyên file.
Đo: nhanh 1,59 lần nhờ nội tuyến xuyên file
Tôi dựng hai file thật trong container gcc:13 (ARM AArch64). math.c chứa một hàm nhỏ hot:
int mix(int x, int y){
int a = x * 1103515245 + 12345;
int b = y ^ (y >> 3);
return (a + b) & 0x7fffffff;
}
main.c chỉ khai báo mix (không thấy thân), rồi gọi nó 300 triệu lần trong một vòng, đổi hạt giống giữa các lần lặp để chặn compiler nâng kết quả ra ngoài vòng (bài học hoisting từ đo lường vi mô đúng cách), và ghi kết quả vào một biến volatile để chặn khử mã chết.
So hai cách build, cùng -O2:
KHÔNG LTO: gcc -O2 -c main.c; gcc -O2 -c math.c; gcc -O2 main.o math.o
CÓ LTO: gcc -O2 -flto ... (thêm -flto ở cả dịch lẫn liên kết)
Kết quả thời gian (lấy lần nhanh nhất qua nhiều vòng):
KHÔNG LTO : 1,813 ns / lần gọi
CÓ -flto : 1,141 ns / lần gọi -> nhanh ~1,59 lần (giảm 37%)
Cả hai cho sink giống hệt nhau (1592506500) — LTO không đổi kết quả, chỉ đổi cách sinh mã. Con số 1,59 lần không nhỏ với một thay đổi chỉ là thêm một cờ; và nó là thứ -O2 một mình không thể đạt.
objdump: lệnh gọi biến mất
Thời gian nói LTO nhanh hơn, nhưng để chắc vì sao, tôi mổ mã máy bằng objdump -d và tìm trong hàm main:
KHÔNG LTO, trong <main>:
bl 400570 <clock_gettime@plt>
bl 4007d0 <mix> <- lệnh gọi mix, còn nguyên
bl 400570 <clock_gettime@plt>
bl 4005a0 <printf@plt>
CÓ -flto, trong <main>:
bl 400570 <clock_gettime@plt>
bl 400570 <clock_gettime@plt>
bl 4005a0 <printf@plt>
(KHÔNG còn bl mix — thân mix đã được chép thẳng vào vòng)
Bản không LTO có bl <mix> trong vòng nóng: mỗi lần lặp là một lệnh gọi thật, tốn nhảy tới, lưu/khôi phục thanh ghi, nhảy về. Bản -flto không còn lệnh gọi đó — mix đã nội tuyến vào main, vòng lặp giờ chạy thẳng phép tính không rời khỏi hàm.
Bằng chứng thứ hai: hàm mix như một thực thể độc lập. Trong binary không LTO, objdump còn thấy nhãn <mix>: — một bản mã riêng của hàm. Trong binary LTO, nhãn đó biến mất hoàn toàn: sau khi nội tuyến vào chỗ gọi duy nhất, bản đứng riêng thành mã chết và bị khử — đúng cơ chế khử mã chết nhưng lần này xuyên file, thứ chỉ làm được khi thấy cả chương trình. Binary vì thế còn nhỏ hơn chút (70.584 so với 70.696 byte), dù đã nội tuyến — vì bản mix thừa bị bỏ đi.
Đo hớ: tưởng -O2 đã là trần
Tôi vào bài với định kiến rất phổ biến: "-O2 là mức tối ưu đầy đủ cho production, còn -flto chỉ tổ làm chậm build và phình bộ nhớ liên kết, chương trình chẳng nhanh thêm là bao". Nếu tin thế, tôi đã bỏ qua cả một tầng tối ưu.
Đo ra nó sai ở chỗ căn bản: -O2 mạnh, nhưng phạm vi nhìn của nó là một file. Mọi tối ưu xuyên hàm — nội tuyến, lan truyền hằng, khử mã chết — dừng lại ở ranh giới đơn vị dịch. Cái tôi tưởng là "đã tối ưu hết" thực ra là "đã tối ưu hết trong từng file". Với chương trình một file thì không khác gì; nhưng phần mềm thật chia thành hàng chục, hàng trăm file, và mọi lời gọi xuyên file đều là một bức tường mà -O2 không trèo qua. -flto hạ bức tường đó xuống.
Điều đáng nói thứ hai, để trung thực: LTO không phải phép màu đều tay. Nó lợi nhất khi hàm nhỏ, hot, và nằm khác file với chỗ gọi — đúng ca tôi dựng. Nếu hàm to (nội tuyến không đáng), hoặc gọi thưa (không nằm trong vòng nóng), hoặc vốn đã cùng file (đã nội tuyến sẵn ở -O2), thì -flto cho lợi ít, chỉ còn cái giá build chậm hơn. Con số 1,59 lần của tôi là trần trên của một ca thuận lợi, không phải mức mong đợi cho mọi chương trình.
Vì sao điều này quan trọng khi lập trình
Hệ quả đầu tiên: với binary hiệu năng cao, bật -flto cho bản release là món hời rẻ. Bạn không đổi một dòng code, chỉ thêm một cờ ở cả bước dịch lẫn liên kết, và nhận lại phần nội tuyến/khử mã chết xuyên file mà -O2 bỏ lỡ. Nhiều dự án lớn (nhân Linux, trình duyệt) bật LTO chính vì lý do này.
Hệ quả thứ hai: chia file để dễ đọc không phải trả giá hiệu năng — nếu bạn dùng LTO. Không có LTO, tách một hàm hot sang file riêng cho gọn gàng lại vô tình chặn nội tuyến và làm chậm. Với LTO, bạn được cả hai: mã sạch theo file và tối ưu như thể tất cả nằm chung một chỗ. Ranh giới file trở lại đúng vai trò của nó — tổ chức mã cho người đọc, không phải rào cản cho compiler.
Con số mang theo: -O2 tối ưu theo từng file, ranh giới đơn vị dịch chặn nội tuyến xuyên hàm — objdump thấy rõ bl <mix> còn nguyên trong vòng nóng; bật -flto phá ranh giới đó, mix được nội tuyến (lệnh gọi biến mất, bản hàm riêng bị khử), thời gian từ 1,813 xuống 1,141 ns mỗi lần gọi, nhanh 1,59 lần — thứ -O2 một mình không chạm tới, và lợi nhất khi hàm nhỏ-hot-khác-file. -O2 không phải trần; nó là trần của một file.
Thử ba mươi giây
Tạo hai file. f.c: int sq(int x){ return x*x; }. m.c:
int sq(int);
int main(void){ long s=0; for(int i=0;i<100000000;i++) s+=sq(i); return (int)s; }
Build hai cách và so mã: gcc -O2 m.c f.c -o a && objdump -d a | grep -A20 '<main>:' | grep 'bl.*sq' — bạn sẽ thấy một lệnh gọi sq. Rồi gcc -O2 -flto m.c f.c -o b && objdump -d b | grep -A20 '<main>:' | grep 'bl.*sq' — lệnh gọi biến mất (đã nội tuyến). Thêm time ./a và time ./b để thấy chênh lệch thời gian. Hai lệnh, và bạn tận mắt thấy ranh giới file tan đi lúc liên kết.