Ở 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ái công tắc ghi đè quyết định đó: __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 vào đo cả hai công tắc, 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? Tôi đo trong container gcc:13 (ARM AArch64).
Đ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, vì heuristic đã làm đúng việc bạn định ép. 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 đo hớ 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ì sao điều này quan trọng khi lập trình
Hệ quả đầ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.
Hệ quả 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.
Hệ quả thứ ba, và là chỗ noinline thật sự hữu ích: đo lường và tổ chức, không phải tốc độ. Xuyên suốt sê-ri này, tôi dùng noinline để đo đúng — nó cô lập một hàm để đo riêng, chặn compiler nội tuyến rồi hoisting phép tính ra khỏi vòng đo. Và trong mã thật, noinline giữ một đường lạnh (xử lý lỗi hiếm) ra khỏi thân hàm nóng để i-cache đường nóng gọn. Đó là những công dụng đúng — không phải "cho nhanh". Con số mang theo: always_inline ép nội tuyến nhưng với hàm nhỏ hot compiler đã tự làm nên nó thừa (0,308 = 0,308 ns), với hàm to (băm, 8 chỗ gọi) vẫn nhanh 2 lần dù mã phình 3,2 lần (bloat chưa tràn i-cache, folklore "big inline chậm" không hiện); còn noinline cấm nội tuyến làm chậm 2,3 lần (chặn vector hóa + chi phí gọi) — nó là công cụ để ĐO và tổ chức mã, không phải để tăng tốc. Heuristic nội tuyến của compiler thường đúng; hai công tắc ép tay là cho nhu cầu đặc biệt, không phải nút chỉnh thường ngày.
Thử ba mươi giây
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.