Hình dung một chiếc GPS đã dày kinh nghiệm, biết rõ đường nào nhanh vào giờ này. Bạn ngồi ghế phụ mà cứ hét "rẽ vào đường kia đi!" — phần lớn thời gian bạn hét đúng con đường GPS đã định chọn, nên tiếng hét chẳng thay đổi gì, chỉ là tiếng ồn. Nhưng nếu bạn cấm nó đi đường cao tốc, bắt luồn hẻm, thì bạn thực sự làm chuyến đi chậm hẳn. Heuristic nội tuyến của trình biên dịch đúng là chiếc GPS đó, và hai công tắc ghi đè của C — một cái để "hét", một cái để "cấm" — hóa ra tác động rất khác nhau, ngược với tôi tưởng.
Ở bài nội tuyến hàm ta thấy compiler tự quyết định chép thân hàm nhỏ vào chỗ gọi. Nhưng C cho bạn hai công tắc ghi đè: __attribute__((always_inline)) ép nội tuyến bất kể chi phí, và __attribute__((noinline)) cấm nội tuyến hoàn toàn. Lời khuyên thường gặp là "ép always_inline cho hàm nóng để chắc chắn nhanh". Tôi đo cả hai trong container gcc:13 (ARM), và thấy cả hai đầu đều dễ dùng sai — nhưng theo cách ngược với tôi tưởng.
Hai công tắc ghi đè compiler
Trình biên dịch có một heuristic cân nhắc lợi (bỏ chi phí gọi, mở tối ưu xuyên hàm) và hại (phình mã) rồi tự quyết nội tuyến hay không. always_inline và noinline bỏ qua heuristic đó, buộc theo ý bạn. Câu hỏi thực tế: ép tay có tốt hơn để compiler tự lo không?
Đo: always_inline thường thừa, noinline mới đáng sợ
Hàm nhỏ hot. Một hàm mix(x) = x*3 + 1 gọi trong vòng 300 triệu lần, ba biến thể:
mặc định (compiler tự quyết) : 0,3081 ns/phần tử (objdump: không có 'bl')
always_inline (ép) : 0,3085 ns/phần tử (không có 'bl') -> BẰNG
noinline (cấm) : 0,7145 ns/phần tử (có 'bl') -> chậm 2,3 lần
Bản mặc định và bản always_inline giống hệt — cả hai đều không có lệnh gọi bl, nghĩa là compiler đã tự nội tuyến hàm nhỏ này. always_inline không thêm gì; nó thừa, đúng như hét đúng con đường GPS đã chọn. Cái đáng chú ý là noinline: cấm nội tuyến làm vòng chậm 2,3 lần (0,71 so với 0,31 ns). Không chỉ vì chi phí gọi mỗi vòng, mà vì lời gọi chặn các tối ưu xuyên hàm — compiler không còn vector hóa được vòng khi thân nó là một lời gọi mờ đục.
Hàm to. Đây là chỗ tôi chắc sẽ thấy always_inline phản đòn: một hàm băm nặng (một chuỗi dài phép trộn bit) gọi ở tám chỗ trong vòng. Tôi đoán ép nội tuyến sẽ chép mã tám lần, phình i-cache, chậm hơn để nó là một lời gọi:
noinline (bigf là lời gọi) : 11,51 ns/phần tử (hàm run: 48 lệnh)
always_inline (chép 8 chỗ) : 5,97 ns/phần tử (hàm run: 155 lệnh, mã 3,2 lần)
Đo ra ngược: always_inline vẫn nhanh 2 lần, dù thân hàm run phình từ 48 lên 155 lệnh (gấp 3,2). Vì với hàm băm thuần (không chạm bộ nhớ), bỏ tám lời gọi mỗi vòng cộng với tối ưu xuyên tám lời gọi đó (compiler sắp xếp, đan xen các phép) thắng cái giá phình mã — và phình 3,2 lần của một hàm nhỏ vẫn chưa tràn i-cache. Truyền thuyết "nội tuyến hàm to thì chậm" không hiện ra ở quy mô này; nó chỉ đúng khi mã phình đủ lớn để tràn i-cache, cần hàm to hơn nhiều hoặc nhiều chỗ gọi hơn.
Vậy cú vấp của tôi có hai lớp. Tôi tin always_inline vừa luôn giúp (ép là tốt) vừa phản đòn với hàm to (phình là hại). Đo ra cả hai đều lệch: với hàm nhỏ nó thừa (compiler đã làm), với hàm to ở đây nó vẫn thắng (bloat chưa tràn cache). Còn kẻ có tác động thật, đáng sợ, lại là noinline — cấm nội tuyến làm chậm 2,3 lần.
Vì sao heuristic thường thắng ép tay
Kết quả "hàm to vẫn nhanh khi nội tuyến" thoạt nghe mâu thuẫn với nỗi sợ phình mã, nhưng nó soi rõ cách heuristic của compiler nghĩ. Nội tuyến không chỉ tiết kiệm một lệnh gọi; nó mở các tối ưu mà ranh giới hàm chặn — sau khi chép thân hàm vào chỗ gọi, compiler thấy toàn cảnh và có thể gấp hằng số, khử biểu thức chung giữa các lời gọi, sắp lại lệnh của tám bản băm cho đan xen nhau. Cái lợi đó thường lớn hơn cái giá vài chục lệnh thêm — miễn là tổng mã còn nằm gọn trong i-cache.
Chính chữ "miễn là" đó là thứ heuristic của compiler ước lượng, và ước lượng tốt: nó có mô hình chi phí biết kích thước hàm, số chỗ gọi, mức tối ưu, và một ngưỡng phình mã. always_inline bỏ qua mô hình ấy, ép nội tuyến cả khi mô hình sẽ nói không — nên nó chỉ đúng khi bạn biết điều gì mô hình không biết (ví dụ một hàm chỉ nóng trong một tải cụ thể). Đoán mò thì thường thua chính cái mô hình bạn đang ghi đè.
Về đến code hằng ngày
Điều đầu tiên: đừng rắc always_inline như một câu thần chú tăng tốc. Với hàm nhỏ nóng, compiler đã nội tuyến rồi — thêm attribute chỉ là tiếng ồn. Heuristic của gcc/clang đã trưởng thành qua hàng chục năm và thường đúng; ép tay hiếm khi cải thiện được, và khi cải thiện thì bạn nên có số đo chứng minh, không phải cảm tính. always_inline đúng chỗ của nó là ép một hàm mà compiler sai lầm từ chối nội tuyến — một trường hợp hiếm, phải đo mới biết.
Điều thứ hai: cẩn thận với noinline trong mã nóng — nó là con dao hai lưỡi. Cấm nội tuyến không chỉ thêm chi phí gọi; nó dựng một bức tường chặn vector hóa, loại biểu thức con chung, và mọi tối ưu xuyên hàm, nên có thể làm chậm nhiều lần chứ không phải chút ít. Đừng dùng noinline để "tổ chức mã cho gọn" ở đường nóng mà không đo. Chỗ noinline thật sự hữu ích là để đo đúng (cô lập một hàm, chặn compiler hoisting phép tính ra khỏi vòng đo) và để giữ một đường lạnh (xử lý lỗi hiếm) ra khỏi thân hàm nóng — công dụng tổ chức, không phải tăng tốc.
Và đây là chỗ đáng nhớ nhất, vì "để máy tự quyết hay ép tay" là một câu hỏi lặp lại ở mọi tầng có bộ tối ưu. JVM là ví dụ cực đoan: nó hoàn toàn tự quyết inline lúc chạy dựa trên hồ sơ thật (hàm nào nóng, callsite nào monomorphic), và bạn gần như không có cách ép — triết lý ngược hẳn C, đặt niềm tin tuyệt đối vào heuristic động. Ngay cả các gợi ý cũ như final cũng chỉ giúp JIT chứ không ép. Ở tầng CSDL, đây chính là chuyện query hint: bạn có thể ép Postgres/Oracle dùng một index cụ thể, nhưng guideline luôn cảnh báo — planner có thống kê bạn không thấy, và một hint đóng đinh hôm nay có thể thành xiềng xích khi dữ liệu đổi phân bố; y hệt always_inline ép một quyết định mà mô hình chi phí lẽ ra nên tự cập nhật. Mẫu số chung: bộ tối ưu trưởng thành thường biết nhiều hơn bạn tại điểm quyết định; ghi đè nó chỉ đúng khi bạn nắm một sự thật nó không có — và kèm số đo — chứ không phải theo linh cảm.
Muốn tự thấy thì viết một hàm nhỏ (int f(int x){ return x*3+1; }) gọi trong một vòng lớn, ba bản: mặc định, always_inline, và noinline. Biên dịch gcc -O2 -S -o - t.c và tìm trong hàm gọi: bản mặc định và always_inline sẽ không có lệnh bl f (đã nội tuyến), còn bản noinline có một bl f rõ ràng. Rồi đo thời gian ba bản bằng clock_gettime: hai bản đầu bằng nhau, bản noinline chậm hơn hẳn — thường gấp đôi trở lên, vì lời gọi chặn vector hóa. Ba mươi giây đó cho bạn thấy always_inline thường là tiếng ồn, còn noinline mới là công tắc thực sự đổi mã — theo hướng chậm đi.