Có hai niềm tin trái ngược mà lập trình viên hay mang theo về việc gọi hàm. Một phía sợ: "gọi hàm tốn kém, chia code thành nhiều hàm nhỏ sẽ làm chậm — gộp hết vào một chỗ cho nhanh". Phía kia lạc quan thái quá: "inline luôn nhanh hơn, cứ force_inline mọi thứ". Cả hai đều một nửa đúng, và phép đo cho biết nửa nào. Một lời gọi hàm thật sự tốn: lệnh call/ret, dàn xếp tham số vào thanh ghi, prologue/epilogue lưu-khôi phục thanh ghi, và nó chặn trình biên dịch tối ưu xuyên qua ranh giới hàm. Inline — chép thẳng thân hàm vào chỗ gọi — bỏ hết chi phí đó và mở ra tối ưu liên thủ tục. Câu hỏi đo lường: chi phí gọi thật là bao nhiêu, và khi nào nó đáng kể? Tôi đo trong container gcc:13 trên host ARM.
Gọi hàm tốn gì, và inline bỏ được gì
Khi CPU gọi một hàm, nó phải: nhảy tới địa chỉ hàm (call/bl), đặt tham số vào đúng thanh ghi theo quy ước gọi, có thể lưu vài thanh ghi ở đầu hàm (prologue) và khôi phục ở cuối (epilogue), rồi nhảy về (ret). Với một hàm nhỏ — thân chỉ một hai phép tính — bộ máy call/ret này có thể tốn hơn cả thân hàm. Với một hàm lớn, cùng chi phí đó chìm nghỉm giữa hàng chục phép tính bên trong.
Inline chép thân hàm vào ngay chỗ gọi, xóa lời gọi. Nó bỏ chi phí call/ret, nhưng lợi ích lớn hơn thường là tối ưu liên thủ tục: khi thân hàm nằm ngay trong ngữ cảnh gọi, trình biên dịch có thể gộp hằng số, khử biểu thức chung, vector hóa, giữ giá trị trong thanh ghi xuyên qua chỗ vốn là ranh giới hàm. Đổi lại, inline một hàm lớn ở nhiều chỗ gọi khiến mã máy phình to — và mã to hơn ép I-cache (cache lệnh), có thể làm chậm toàn cục. Vì thế -O2 không inline bừa: nó inline hàm nhỏ, giữ hàm lớn. Tôi đo để thấy ranh giới đó bằng con số.
Đo: hàm nhỏ inline nhanh 3 lần, hàm lớn chỉ 1,1 lần
Tôi gọi hàm trong một vòng lặp throughput-bound (các lần lặp độc lập, để chi phí gọi lộ ra rõ), biên dịch hai cách: __attribute__((noinline)) để ép có lời gọi, và always_inline để ép chép thân. Cả hai đều -fno-tree-vectorize để so sánh công bằng (không lẫn hiệu ứng vector hóa):
Throughput-bound (lặp độc lập), ns mỗi lần lặp, host ARM, g++ -O2 -fno-tree-vectorize:
trường hợp | noinline | inline | tỉ số | ghi chú
------------------------------|----------|--------|-------|---------------------------
chi phí gọi thuần (identity) | 0,68 | 0,23 | - | ~0,45 ns/gọi (~2 chu kỳ)
hàm NHỎ (thân 1 phép: a·C) | 0,68 | 0,23 | 3,0x | call/ret át hẳn thân
hàm LỚN (~24 phép phụ thuộc) | 1,48 | 1,35 | 1,1x | chi phí gọi không đáng kể
Đọc ra ba điều. Một (chi phí gọi thuần): gọi một hàm identity (chỉ trả lại tham số) tốn 0,68 ns mỗi lần, so với 0,23 ns khi không gọi gì — vậy bản thân lời gọi tốn khoảng 0,45 ns (~2 chu kỳ) trên host ARM này. Nhỏ, nhưng không phải không.
Hai (hàm nhỏ): một hàm thân một phép nhân — noinline mất 0,68 ns, inline chỉ 0,23 ns — nhanh gấp 3 lần. Toàn bộ khác biệt là chi phí gọi: thân hàm (một phép nhân) gần như miễn phí khi inline (chìm vào vòng lặp throughput-bound), còn khi gọi thì 0,45 ns overhead lấn át nó. Với hàm nhỏ, chi phí gọi là chi phí chính.
Ba (hàm lớn): một hàm ~24 phép tính phụ thuộc — noinline 1,48 ns, inline 1,35 ns — chỉ nhanh hơn 1,1 lần (10%). Cùng 0,45 ns chi phí gọi đó, nhưng giờ nó nhỏ bên cạnh ~1,35 ns thân hàm thực sự làm việc. Với hàm lớn, gọi hay inline gần như không khác.
Và một sự thật trung thực từ lần đo đầu của tôi: khi tôi để hàm nhỏ trong một vòng lặp latency-bound (một chuỗi phụ thuộc acc = f(acc, i)), noinline và inline ra bằng nhau y hệt (1,00x). Vì mỗi vòng phải đợi độ trễ của phép nhân trong thân, và lời gọi chồng lấp với độ trễ đó (CPU out-of-order chạy call/ret trong lúc chờ) — chi phí gọi bị che hoàn toàn. Nó chỉ lộ ra khi vòng lặp throughput-bound. Cùng một hàm, cùng chi phí gọi, mà có đáng kể hay không lại tùy vòng lặp quanh nó.
Một lần tôi đo hớ: "gọi hàm luôn đắt" và "inline luôn nhanh"
Tôi vào đo với niềm tin bi quan: "gọi hàm luôn tốn kém, phải tránh chia hàm nhỏ, gộp code lại cho nhanh". Đo phá tan: chi phí gọi thật chỉ ~0,45 ns, và quan trọng hơn — nó chỉ đáng kể với hàm thật nhỏ. Với hàm có việc thực sự (thân lớn), gọi chỉ thêm 10%. Với code latency-bound, gọi bị che hoàn toàn. Và điều then chốt: -O2 đã tự inline các hàm nhỏ nóng cho bạn rồi (bản inline 0,23 ns = như không gọi) — bạn không cần hy sinh tính mô-đun để né chi phí gọi, vì trình biên dịch xóa nó giúp bạn ở đúng chỗ đáng xóa. Chia code thành hàm nhỏ, rõ ràng, gần như không mất gì.
Nhưng đo cũng chỉnh niềm tin ngược: "inline luôn nhanh hơn, cứ force_inline hết cho chắc". Sai — inline chỉ thắng lớn với hàm nhỏ (3x); với hàm lớn nó chỉ nhỉnh 10%, và nếu bạn ép inline một hàm lớn ở nhiều chỗ gọi, mã máy phình to, ép I-cache, và có thể làm chương trình chậm hơn toàn cục — một cái giá không hiện trong microbenchmark một-hàm nhưng rất thật ở chương trình lớn. -O2 đã cân nhắc điều này bằng heuristic (inline hàm nhỏ, giữ hàm lớn); ép always_inline bừa là ghi đè lên sự cân nhắc đó, thường thua.
Bài học đo lường: lời gọi hàm tốn call/ret + dàn xếp tham số + chặn tối ưu liên thủ tục — đo ~0,45 ns/gọi (~2 chu kỳ) host ARM. Với hàm NHỎ (thân 1 phép) chi phí gọi ÁT thân: noinline 0,68 vs inline 0,23 = 3,0x; với hàm LỚN (~24 phép) chỉ 1,1x (không đáng); và trên chuỗi latency-bound call bị CHE hoàn toàn (1,0x). 'Gọi hàm luôn đắt, tránh chia hàm nhỏ' SAI (-O2 tự inline hàm nhỏ nóng; hàm lớn call không đáng); 'inline luôn nhanh, force-inline hết' SAI (hàm lớn inline phình mã, ép I-cache, có khi chậm — -O2 đã tự cân). Nếu tin "gọi luôn đắt" tôi hy sinh mô-đun vô ích; nếu tin "inline luôn nhanh" tôi phình mã và tự làm chậm.
Vì sao điều này quan trọng khi lập trình
Hệ quả đầu tiên: chia code thành hàm nhỏ, rõ ràng — đừng sợ chi phí gọi. -O2 tự inline các hàm nhỏ nóng, nên tính mô-đun gần như miễn phí ở đường nóng. Viết code sạch trước; chỉ khi đo thấy một lời gọi cụ thể là nút cổ chai (hàm nhỏ trong vòng lặp throughput-bound rất nóng, hoặc gọi qua con trỏ hàm/ảo mà trình biên dịch không inline được) mới can thiệp.
Hệ quả thứ hai: khi cần tối ưu, giúp trình biên dịch inline được — đừng ép inline hàm lớn. Đặt hàm nhỏ nóng trong header (để thấy được ở chỗ gọi), tránh gọi gián tiếp (con trỏ hàm, hàm ảo) ở đường nóng vì chúng chặn inline, và cân nhắc -O3/LTO cho tối ưu liên thủ tục rộng hơn. Nhưng với hàm lớn, để -O2 quyết định — ép always_inline ở nhiều chỗ gọi thường phình mã và hại I-cache nhiều hơn lợi.
Hệ quả thứ ba là tinh thần đo lường: chi phí gọi hàm là thật nhưng nhỏ và có điều kiện — nó chỉ đáng kể cho hàm rất nhỏ trong vòng lặp throughput-bound, và trình biên dịch thường đã xóa nó. Con số mang theo: gọi ~0,45 ns (~2 chu kỳ); inline thắng 3x cho hàm nhỏ, chỉ 1,1x cho hàm lớn, 0x trên code latency-bound; -O2 tự inline hàm nhỏ nóng và giữ hàm lớn. Viết mô-đun; chỉ ép inline khi đo bảo phải. Đừng bẻ cong cấu trúc code theo một nỗi sợ chi phí gọi mà phần lớn thời gian không tồn tại.
Thử ba mươi giây
Viết một hàm nhỏ tính một phép (ví dụ long f(long i){ return i*2654435761L; }) và gọi nó trong một vòng lặp cộng dồn s ^= f(i) vài trăm triệu lần. Biên dịch hai cách và bấm giờ: một lần đánh dấu hàm __attribute__((noinline)) (ép có lời gọi), một lần __attribute__((always_inline)) inline (ép chép thân) — nhớ -fno-tree-vectorize cả hai để so công bằng. Bạn sẽ thấy bản noinline chậm gấp vài lần bản inline, vì thân một phép của nó bị chi phí call/ret lấn át. Giờ làm lại với một hàm lớn (vài chục phép tính): lần này hai bản gần bằng nhau, vì chi phí gọi nhỏ bên cạnh thân. Cuối cùng, thử đặt hàm nhỏ trong một chuỗi phụ thuộc (acc = f(acc)) thay vì cộng dồn độc lập: chi phí gọi biến mất (hai bản bằng nhau), vì lời gọi chồng lấp với độ trễ của chuỗi. Ba mươi giây đó cho bạn thấy điều mà cả "gọi hàm luôn đắt" lẫn "inline luôn nhanh" đều giấu đi: chi phí gọi là một con số nhỏ, cố định, chỉ lộ ra khi thân hàm còn nhỏ hơn nó và vòng lặp đủ nhanh để thấy — và trình biên dịch thường đã xóa nó ở đúng chỗ, nên bạn cứ viết code rõ ràng và để phép đo chỉ chỗ nào thật sự cần can thiệp.