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 phe sợ: gọi hàm tốn kém, chia code thành nhiều hàm nhỏ là làm chậm, gộp hết vào một chỗ cho nhanh. Phe kia lạc quan quá đà: inline luôn nhanh hơn, cứ force_inline sạch. Cả hai đúng một nửa, và phép đo cho biết nửa nào.

Hình dung mỗi lời gọi hàm như cái thủ tục ra vào một căn phòng làm việc: gõ cửa, chào hỏi, cởi áo khoác treo lên, xong việc thì mặc lại áo, chào tạm biệt, đóng cửa. Nếu việc trong phòng chỉ mất hai giây, cái thủ tục ra vào kia tốn hơn cả việc chính. Nhưng nếu bạn vào đó ngồi làm cả tiếng, thì mấy giây chào hỏi chẳng còn nghĩa lý gì. Gọi hàm y hệt: CPU phải nhảy tới địa chỉ hàm, xếp tham số vào thanh ghi, lưu rồi khôi phục vài thanh ghi, cuối cùng nhảy về. Inline là phá luôn bức tường, làm việc ngay tại chỗ, khỏi thủ tục — mà lợi hơn nữa là compiler được nhìn thấy cả hai bên nên tối ưu xuyên qua được (gộp hằng số, giữ giá trị trong thanh ghi, vector hóa).

Con số

Tôi gọi hàm trong một vòng lặp mà các lần lặp độc lập (để chi phí gọi lộ rõ), biên dịch hai cách — ép giữ lời gọi và ép chép thân — trên máy ARM:

Trường hợp Có gọi (noinline) Inline Tỉ số
Chi phí gọi thuần (hàm identity) 0,68 ns 0,23 ns ~0,45 ns mỗi cú gọi
Hàm nhỏ (thân một phép nhân) 0,68 ns 0,23 ns 3,0x
Hàm lớn (~24 phép phụ thuộc) 1,48 ns 1,35 ns chỉ 1,1x

Bản thân một cú gọi hàm tốn chừng 0,45 nano-giây, cỡ hai chu kỳ CPU. Nhỏ, nhưng không phải bằng không. Với hàm bé tí thân chỉ một phép nhân, cái 0,45 ns đó át hẳn thân, nên bản có gọi chậm gấp ba bản inline. Nhưng với hàm lớn có việc thật, cùng 0,45 ns đó chìm nghỉm bên cạnh 1,35 ns thân hàm, chênh chỉ 10%.

Còn một chi tiết trung thực nữa từ lần đo đầu của tôi: khi tôi để hàm nhỏ trong một chuỗi phụ thuộc (acc = f(acc)), noinline với inline ra bằng nhau y hệt. Vì mỗi vòng phải đợi độ trễ phép nhân trong thân, mà CPU chạy call/ret chồng lấp ngay trong lúc chờ đó — chi phí gọi bị che sạch. Nó chỉ ló ra khi vòng lặp đủ nhanh (throughput-bound) để thấy. Cùng một hàm, chi phí gọi có đáng kể hay không còn tùy vòng lặp quanh nó.

Hai định kiến, cả hai cùng nửa đúng

Định kiến bi quan: gọi hàm luôn đắt, phải tránh chia hàm nhỏ, gộp lại cho nhanh. Đo phá tan — chi phí gọi chỉ 0,45 ns, và chỉ đáng kể với hàm thật bé; hàm có việc thật thì gọi thêm 10%, code latency-bound thì gọi tàng hình. Quan trọng nhất: -O2 đã tự inline hộ những hàm nhỏ nóng rồi (bản inline 0,23 ns, đúng bằng không gọi gì). Bạn không cần hy sinh sự rõ ràng của code để né một chi phí mà compiler đã xóa hộ ở đúng chỗ.

Định kiến ngược lại cũng chỉ nửa đúng: inline luôn nhanh, cứ force-inline hết. Không. Inline chỉ thắng lớn với hàm nhỏ; hàm lớn inline khắp nơi làm mã máy phình to, ép cache lệnh, và có thể làm cả chương trình chậm hơn — 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: inline cái nhỏ, giữ cái lớn. Ép always_inline bừa là ghi đè lên sự cân nhắc đó, thường thua.

Ngoài đời thì sao

Cứ chia code thành hàm nhỏ, rõ ràng, đừng sợ chi phí gọi — compiler lo hộ ở đường nóng. Viết sạch trước, chỉ khi đo thấy một lời gọi cụ thể là nút cổ chai (hàm bé trong vòng siêu nóng, hoặc gọi gián tiếp qua con trỏ/hàm ảo mà compiler không inline được) mới can thiệp. Muốn giúp nó thì đặt hàm nhỏ nóng trong header cho nó thấy được, tránh gọi gián tiếp ở hot path, cân nhắc bật LTO — chứ đừng ép inline hàm lớn.

Chuyện này không riêng gì C++. JIT của Java inline còn hung hơn: nó tự động inline các method nhỏ và nóng theo ngưỡng kích thước bytecode (MaxInlineSize, FreqInlineSize), nên viết getter/setter hay method một dòng thoải mái, JIT xóa sạch cú gọi lúc chạy. Go cũng có một inliner theo "ngân sách" chi phí, tự quyết hàm nào đủ nhỏ để inline. Ở mọi ngôn ngữ, bài học giống nhau: đừng bẻ cong cấu trúc code vì sợ chi phí gọi — cái nỗi sợ ấy phần lớn thời gian không có thật, và trình biên dịch đã dọn hộ bạn đúng chỗ.

Muốn tự thấy thì viết một hàm nhỏ một phép (long f(long i){ return i*2654435761L; }), gọi trong vòng s ^= f(i) vài trăm triệu lần, đo hai bản noinline và always_inline (nhớ -fno-tree-vectorize cho công bằng): bản có gọi chậm gấp mấy lần. Đổi sang một hàm lớn vài chục phép: hai bản gần bằng nhau. Cuối cùng đặt hàm nhỏ vào một chuỗi phụ thuộc acc = f(acc): chi phí gọi biến mất, vì cú gọi chồng lấp với độ trễ của chuỗi. Chi phí gọi hàm là một con số nhỏ, cố định, chỉ ló ra khi thân hàm còn bé hơn nó — và compiler thường đã xóa nó ở đúng chỗ rồi.