Hình dung mỗi lần cần một quy trình nhỏ, bạn gọi điện cho một chuyên gia, nhờ họ làm rồi chờ trả lời. Cuộc gọi tốn chút thời gian, nhưng với điện thoại hiện đại thì chẳng đáng bao nhiêu. Bây giờ hình dung bạn thuộc lòng quy trình đó và tự làm ngay tại chỗ. Cái lợi lớn không phải tiết kiệm cuộc gọi — mà là khi quy trình nằm ngay trước mắt trong đúng ngữ cảnh của bạn, bạn nhận ra "à, ca này không cần bước 3, bỏ luôn", điều chuyên gia trên điện thoại không làm được vì họ phải làm quy trình tổng quát cho mọi khách. Đó chính là inline, và lý do thật khiến nó đáng giá không phải cái mà người ta hay nói.

Nội tuyến (inlining) là tối ưu nổi tiếng nhất của trình biên dịch, và lời giải thích phổ biến nhất cũng đơn giản nhất: "gọi hàm tốn thời gian, inline chép thân hàm vào chỗ gọi để khỏi phải gọi". Đúng, nhưng đó là phần ít quan trọng nhất của câu chuyện. Tôi đo cả ba mặt của inline trong container gcc:13 (ARM) — chi phí một lời gọi thật sự là bao nhiêu, lợi ích thật nằm ở đâu, và cái giá khi lạm dụng.

Gọi hàm tốn gì, và inline bỏ được gì

Một lời gọi hàm không miễn phí. Để gọi, CPU phải đặt tham số vào đúng thanh ghi theo quy ước (ABI), thực hiện lệnh nhảy bl (branch-and-link trên ARM, call trên x86), có thể lưu vài thanh ghi để hàm được gọi dùng, rồi khi xong chạy ret để quay về. Inline là trình biên dịch chép thẳng thân hàm vào chỗ gọi, xóa bỏ toàn bộ nghi thức đó. Tôi kiểm chứng bằng mã máy: một hàm noinline để lại đúng một lệnh bl gọi nó; cùng hàm ấy always_inline để lại không lệnh bl nào — thân hàm đã nằm ngay trong vòng lặp.

Câu hỏi: bỏ được lệnh bl đó tiết kiệm bao nhiêu? Tôi đo một hàm trộn số nhỏ, gọi 100 triệu lần trong vòng nóng:

Hàm nhỏ nóng, thời gian mỗi lời gọi:
  noinline (có lệnh 'bl' + ret) : 0,732 ns
  always_inline (chép thân)     : 0,582 ns
  -> chênh 0,150 ns

Chỉ 0,15 nano giây một lời gọi. Trên CPU hiện đại, một lời gọi hàm đã được đoán nhánh và pipeline tốt tới mức gần như miễn phí — đúng cuộc điện thoại rẻ tiền. Nếu lợi ích của inline chỉ là bỏ lệnh call, nó là một tối ưu tí hon. Chỗ tôi vấp là đã tưởng đó là lý do inline. Con số thật nằm chỗ khác.

Cú vấp: lợi thật là tối ưu xuyên biên

Lợi ích lớn của inline không phải bỏ lệnh call, mà là mở khóa các tối ưu khác — những tối ưu chỉ làm được khi thân hàm nằm ngay tại chỗ gọi, nơi trình biên dịch nhìn thấy ngữ cảnh cụ thể. Để đo, tôi viết một hàm g(x, flag) có một nhánh if(flag) ... else ..., rồi gọi nó với flag = 0 cố định:

Gọi g(x, 0) với flag hằng số, 100 triệu lần:
  noinline : 0,720 ns
  inline   : 0,393 ns
  -> inline nhanh hơn 1,8 lần

Inline nhanh hơn 1,8 lần — gấp hơn mười lần khoảng cách "bỏ call" ở trên. Vì sao? Khi g bị inline, trình biên dịch thấy flag luôn bằng 0, nên nó gấp hằng số: nhánh if(flag) không bao giờ chạy, cả nhánh x*7+3 bị khử mã chết, chỉ còn đúng phép x ^ k. Bản noinline không làm được điều đó — nó phải sinh một hàm tổng quát giữ cả hai nhánh, vì ABI đòi hàm đứng riêng phải chạy đúng với mọi giá trị flag. Inline phá bỏ ranh giới ấy, để tham số cụ thể của chỗ gọi ngấm vào thân hàm — đúng như thuộc quy trình rồi bỏ bước không cần cho ca của mình. Đây mới là lý do thật người ta inline: không phải để tránh một lệnh nhảy rẻ tiền, mà để trình biên dịch có thể tối ưu xuyên qua ranh giới hàm — gấp hằng, khử nhánh, giữ giá trị trong thanh ghi thay vì tuân thủ quy ước gọi. Với hàm nhỏ gọi nhiều, hiệu ứng cộng dồn này thường lớn hơn hẳn cái 0,15 ns kia.

Cú vấp thứ hai: inline không miễn phí, mã phình ra

Nếu inline tốt vậy, sao không inline mọi thứ? Vì chép thân hàm vào mỗi chỗ gọi nhân bản mã máy. Tôi lấy một hàm lớn (hai chục phép trộn số) và gọi nó từ 24 chỗ khác nhau, đo kích thước đoạn mã (.text) khi để nó thành hàm riêng so với khi inline hết:

Hàm lớn gọi từ 24 chỗ, kích thước .text:
  KHÔNG inline (một hàm dùng chung) : 1.749 byte
  CÓ inline (chép vào 24 chỗ)       : 3.469 byte
  -> phình gần gấp đôi

Inline làm mã phình gần gấp đôi — như chép cả một quy trình đồ sộ vào 24 trang sổ tay. Và mã phình không chỉ tốn dung lượng: nó ép cache lệnh (I-cache, thường chỉ 32 KB ở L1). CPU phải nạp lệnh từ bộ nhớ như nạp dữ liệu; nếu mã nóng của bạn phình tới mức không vừa I-cache, CPU liên tục phải chờ nạp lệnh — đúng kiểu trượt cache mà sê-ri trước đo, nhưng ở phía lệnh thay vì dữ liệu. Một hàm lớn inline vào nhiều chỗ có thể chậm hơn bản gọi riêng, dù đã bỏ được các lệnh call. Đây là lý do trình biên dịch có heuristic quyết định inline cái gì: nó ước lượng kích thước thân hàm và số chỗ gọi, chỉ inline khi lợi (bỏ call + tối ưu xuyên biên) vượt hại (phình mã). Ép always_inline lên một hàm lớn là ghi đè cái phán đoán đó — đôi khi đúng, thường là sai.

Về đến code hằng ngày

Điều đầu tiên: để trình biên dịch tự quyết inline trong hầu hết trường hợp. Ở -O2, GCC/Clang inline khá thông minh: hàm nhỏ, gọi ít chỗ, hàm static chỉ dùng một nơi — nó inline; hàm lớn gọi khắp nơi — nó giữ riêng. Từ khóa inline trong C hiện đại chủ yếu là gợi ý và điều khiển liên kết, không phải mệnh lệnh; trình biên dịch vẫn tự cân nhắc. Chỉ dùng always_inline/noinline khi bạn đã đo và biết chắc heuristic sai cho trường hợp cụ thể, không phải theo cảm tính "inline chắc nhanh hơn".

Điều thứ hai: hiểu inline là "mở khóa tối ưu" thay đổi cách bạn viết code cho tốc độ. Vì lợi thật của inline là tối ưu xuyên biên, những hàm nhỏ được gọi với đối số hằng số (cờ cấu hình, kích thước cố định, kiểu đã biết) hưởng lợi nhiều nhất — inline biến chúng thành mã chuyên biệt cho đúng chỗ gọi. Đây cũng là nền của LTO (tối ưu lúc liên kết): cho phép inline xuyên tệp, mở khóa tối ưu qua ranh giới mà -O2 một-tệp không thấy được.

Và đây là chỗ đáng nhớ, vì inline là một trong những chỗ máy ảo vượt biên dịch tĩnh theo cách bất ngờ. JIT của JVM inline dựa trên hồ sơ chạy — nó thấy method nào thực sự nóng rồi inline, kể cả xuyên qua lời gọi ảo (virtual call) mà C++ tĩnh phải để lại vì không biết kiểu thật lúc dịch; đây là một lý do code Java nóng đôi khi nhanh không thua C. Nhưng nó cũng có cùng cái bẫy phình mã: JVM có ngưỡng MaxInlineSize/FreqInlineSize chính vì lý do I-cache y hệt. Ở tầng khác, đây là cùng đánh đổi với denormalization trong CSDL: nhân bản dữ liệu vào nhiều bảng để khỏi join (như inline khỏi call) đổi lấy dung lượng phình và khó bảo trì. Mẫu số chung: "mang thứ được gọi tới ngay chỗ gọi" vừa bỏ được chi phí liên kết vừa mở khóa tối ưu theo ngữ cảnh — nhưng luôn trả bằng nhân bản, và cái ngưỡng đáng-hay-không tùy kích thước; các hệ thống trưởng thành đều để một heuristic quyết định thay vì làm mù quáng.

Muốn tự thấy thì viết một hàm nhỏ và một hàm gọi nó, biên dịch gcc -O2 -S -o out.s file.c, rồi tìm trong out.s xem có lệnh bl <tên_hàm> (ARM) hay call <tên_hàm> (x86) không. Nếu không thấy — trình biên dịch đã tự inline. Giờ thêm __attribute__((noinline)) vào hàm và biên dịch lại: lệnh bl xuất hiện, hàm đứng riêng. Rồi thử điều thú vị nhất: gọi hàm với một đối số hằng số (như f(x, 0)) và so mã máy khi inline với khi noinline — bản inline thường ngắn hơn hẳn vì trình biên dịch đã gấp hằng số và vứt bỏ phần không dùng. Ba mươi giây đó cho bạn thấy inline không chỉ bỏ một lời gọi, mà mở cửa cho cả một loạt tối ưu khác — chính là điều làm nó đáng giá.