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 về nó 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. Bài này đo cả ba mặt của inline — 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á phải trả khi lạm dụng — và phát hiện lý do người ta inline không phải cái người ta hay nói.
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í. Nếu lợi ích của inline chỉ là bỏ lệnh call, nó là một tối ưu tí hon. Chỗ đo hớ của tôi là đã tưởng đó là lý do inline. Con số thật nằm chỗ khác.
Một lần tôi đo hớ: 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 có thể. Inline phá bỏ ranh giới ấy, để tham số cụ thể của chỗ gọi ngấm vào thân hàm. Đâ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.
Đo hớ 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. Với một hàm lớn gọi từ nhiều nơi, con số này còn tệ hơn nữa. 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ì sao điều này quan trọng khi lập trình
Hệ quả đầ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. Cái 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ể của mình, không phải theo cảm tính "inline chắc nhanh hơn".
Hệ quả 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) mà bài sau trong sê-ri sẽ đo: 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.
Hệ quả thứ ba là bài học đo lường của bài: một tối ưu có thể tốt vì lý do khác với lý do bạn tưởng — và có mặt trái phải đo. Con số mang theo: bỏ một lệnh call chỉ tiết kiệm ~0,15 ns (call trên CPU hiện đại rất rẻ); lợi thật của inline là tối ưu xuyên biên — gọi g(x,0) hằng số, inline giúp gấp hằng và khử nhánh chết, nhanh 1,8 lần; nhưng inline một hàm lớn vào 24 chỗ làm mã phình từ 1749 lên 3469 byte, ép cache lệnh. Inline không phải "luôn nhanh hơn"; nó là một đánh đổi mà trình biên dịch cân nhắc từng lần, và bạn chỉ nên can thiệp khi đồng hồ nói heuristic của nó sai.
Thử ba mươi giây
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 nó. 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á.