Ở bài về các mức -O ta thấy -O2 nội tuyến hàm — dán thẳng thân hàm vào chỗ gọi. Nhưng nội tuyến giúp nhanh lên bao nhiêu, và cái gì thật sự kích hoạt nó? Nhiều người tin từ khóa inline là mệnh lệnh bắt trình biên dịch nội tuyến; thực ra nó chỉ là một lời đề nghị. Bài này tôi đo tốc độ nội tuyến mang lại, đọc assembly để thấy lời gọi biến mất — và chính lúc kiểm chứng bằng assembly, một cái grep cẩu thả đã suýt khiến tôi báo cáo sai.
Nội tuyến là dán thân hàm vào chỗ gọi
Bình thường, gọi một hàm tốn nhiều việc ngoài chính phép tính: chuẩn bị đối số vào thanh ghi theo quy ước, thực hiện lệnh nhảy bl tới hàm rồi ret nhảy về, và thường phải dựng một khung ngăn xếp để lưu địa chỉ trả về. Với một hàm bé xíu như x*x, phần "thủ tục" đó có thể còn lớn hơn cả phép tính thật.
Nội tuyến (inlining) xóa bỏ toàn bộ thủ tục đó: trình biên dịch dán thẳng thân hàm vào ngay chỗ gọi, như thể bạn tự tay chép mã vào. Không còn bl, không còn ret, không cần khung ngăn xếp. Và — quan trọng không kém — nó xóa ranh giới tối ưu: một khi thân hàm nằm ngay tại chỗ gọi, trình biên dịch có thể tối ưu xuyên qua nó (giữ biến trong thanh ghi, gộp hằng số, vector hóa) thay vì phải giả định "gọi ra ngoài thì có thể xảy ra bất cứ điều gì".
Đo: hàm nhỏ nội tuyến nhanh 2.46 lần
Tôi viết một hàm bình phương sq(x) = x*x và một vòng lặp cộng dồn sq(i) qua 500 triệu vòng, rồi so hai phiên bản: một có __attribute__((noinline)) (ép giữ lời gọi thật), một để trình biên dịch nội tuyến. Cùng -O2:
noinline (gọi thật mỗi vòng): 441 ms
inline (dán thân vào) : 179 ms -> nhanh 2.46 lần
Cả hai cho cùng kết quả. Đọc assembly của vòng lặp là thấy vì sao. Bản nội tuyến chỉ có mấy lệnh thuần thanh ghi:
.L14: mul w2, w1, w1 ; i*i ngay tại chỗ
add w1, w1, 1
add x0, x0, w2 ; cộng dồn
cmp w3, w1
bne .L14
Bản noinline thì mỗi vòng phải nạp đối số, gọi bl sq_ni, và — vì giờ hàm có thực hiện lời gọi — trình biên dịch còn phải dựng một khung ngăn xếp để lưu địa chỉ trả về. Toàn bộ phần thủ tục đó, nhân với 500 triệu vòng, chính là 2,46 lần chênh lệch. Với hàm càng nhỏ và vòng lặp càng nóng, tỷ lệ này càng lớn. Đến đây tôi cần kiểm chứng một điều tưởng đơn giản — "lời gọi đã biến mất chưa?" — và đó là lúc tôi vấp.
Cú cái grep nói dối hai lần
Thay vì đọc kỹ assembly, tôi làm nhanh: grep -c "bl" trên đoạn mã nội tuyến để đếm số lời gọi còn lại. Kết quả trả về 1. Tôi giật mình: "vẫn còn một lời gọi sao, tưởng đã nội tuyến hết?". Nhưng khi đọc thẳng assembly, không hề có bl nào gọi hàm — cái mà grep đếm là chuỗi bl nằm bên trong bne (lệnh nhảy vòng lặp branch-not-equal). grep "bl" khớp nhầm chuỗi con; số 1 đó là giả.
Tôi thử sửa: grep -cE "\tbl\t" để bắt đúng lệnh bl có tab hai bên. Lần này ra 0 ở khắp mọi nơi — kể cả bản noinline vốn chắc chắn có lời gọi. Lại sai. Lý do: trong biểu thức chính quy của grep, \t không phải ký tự tab, mà chỉ là chữ t bình thường; nên \tbl\t đi tìm chuỗi tblt không tồn tại. Hai lần khớp mẫu cẩu thả, hai lần cho số dối — một số quá cao, một số quá thấp.
Cái tôi rút ra, đúng linh hồn cả sê-ri: công cụ đo cũng là một biến, và một grep cẩu thả nói dối dễ như một benchmark cẩu thả. Tôi đã tin vào một lệnh đếm thay vì đọc cái nó đếm. Chỉ khi đọc trực tiếp assembly (và, khi cần đếm, dùng lớp ký tự [[:space:]] thay cho \t giả) tôi mới ra con số đúng: bản nội tuyến 0 lời gọi, bản noinline 1 lời gọi mỗi vòng. Kỷ luật "đọc assembly để kiểm chứng chứ đừng đoán" hóa ra phải mở rộng thành "đừng tin cả cái công cụ mình dùng để đọc, trừ khi đã kiểm nó".
Và khi đã đo tin cậy, một sự thật quan trọng lộ ra: từ khóa inline chỉ là gợi ý. Đếm lời gọi thật cho thấy hàm sq_in có khai inline vẫn không được nội tuyến ở -O0 (còn 1 lời gọi); còn hàm dbl không hề khai inline lại được nội tuyến ngon lành ở -O2 (0 lời gọi). Thứ quyết định nội tuyến là mức -O và kích cỡ hàm (heuristic của trình biên dịch), gần như không phải từ khóa. Ngược lại, __attribute__((noinline)) được tôn trọng chắc chắn — giữ lời gọi ở cả -O0 lẫn -O2.
Về đến code hằng ngày
Điều đầu tiên là đừng rải inline khắp nơi và tưởng đã tối ưu. Từ khóa đó gần như vô nghĩa với quyết định nội tuyến hiện đại — trình biên dịch tự cân nhắc dựa trên kích cỡ và mức -O. Vai trò còn lại của inline trong C chủ yếu là về quy tắc liên kết (cho phép định nghĩa hàm trong header mà không vi phạm một-định-nghĩa), chứ không phải ép nội tuyến. Muốn ép thật thì có always_inline; muốn cấm thì có noinline — và hãy dùng chúng có chủ đích, hiếm khi cần.
Điều thứ hai là nội tuyến không miễn phí, và không phải lúc nào cũng thắng. Dán thân hàm vào mọi chỗ gọi làm mã phình to; một hàm lớn được nội tuyến ở nhiều nơi có thể gây áp lực cache lệnh và làm chậm tổng thể — đó là lý do trình biên dịch chỉ tự nội tuyến hàm nhỏ. Nội tuyến tỏa sáng nhất với hàm bé, gọi nhiều, trong vòng lặp nóng — đúng ca đo được 2,46 lần ở trên. Với hàm lớn gọi thưa, để nó là lời gọi thật thường tốt hơn.
Và điều cuối là bài học đo lường mở rộng: kiểm chứng bằng công cụ nào thì phải kiểm luôn công cụ đó. Một grep sai mẫu, một time không có sink, một perf không hỗ trợ trên máy ảo — tất cả đều là "công cụ nói dối". Gói lại bằng số: nội tuyến bỏ lời gọi và mở khóa tối ưu xuyên ranh giới, cho hàm nhỏ nhanh vài lần (đo được 2,46x); từ khóa inline chỉ là gợi ý, còn -O và kích cỡ hàm mới quyết định; và khi kiểm bằng grep, hãy nhớ bl khớp cả bne và \t không phải tab — đọc assembly thật mới chắc. Nội tuyến là một trong những tối ưu đáng giá nhất, nhưng đo nó đúng đòi hỏi cẩn thận với cả cái thước đo.
Cái ý "nội tuyến mở khóa tối ưu xuyên ranh giới, nhưng runtime tự quyết chứ không phải từ khóa bạn gõ" này lặp lại rất đắt ở JVM. Java có từ khóa gì để ép inline không? Không — hoàn toàn do JIT quyết dựa trên kích cỡ (MaxInlineSize mặc định 35 byte bytecode) và độ nóng theo hồ sơ chạy, đúng như C dựa -O và kích cỡ; và inlining là tiền đề cho hầu hết tối ưu khác của JIT (scalar replacement, lock elision) y hệt việc nó xóa ranh giới tối ưu ở đây. Đó cũng là lý do một getter một dòng trong Java gần như miễn phí sau warmup, dù trên giấy nó là lời gọi ảo. Go inline hàm nhỏ lúc biên dịch và cho xem quyết định bằng -gcflags=-m. Cơ sở dữ liệu có phiên bản riêng: PostgreSQL inline các hàm SQL đơn giản và view vào truy vấn gọi, cho phép đẩy điều kiện WHERE xuyên qua và dùng index — đúng cái "mở khóa tối ưu xuyên ranh giới". Mẫu số chung: gỡ một ranh giới hàm trả cổ tức gấp bội phí gọi vì nó cho các tối ưu khác tràn qua, nhưng ở mọi tầng hệ thống mới là bên quyết định inline hay không, không phải chú thích của bạn — và cách biết chắc là đọc cái nó thật sự sinh ra.
Muốn tự thấy thì viết một hàm nhỏ int inc(int x){ return x+1; } gọi trong một vòng lặp, dịch gcc -O2 -S file.c -o file.s, rồi mở file.s tìm trong thân vòng lặp: bạn sẽ không thấy lệnh gọi hàm — chỉ thấy một add được dán thẳng vào. Giờ thêm __attribute__((noinline)) trước inc, dịch lại, mở .s: lần này xuất hiện bl inc (hoặc call inc trên x86) trong vòng lặp. Chỉ hai lần đọc .s là bạn thấy nội tuyến bằng mắt — và nhớ đọc chính assembly, đừng grep "bl" kẻo nó đếm nhầm cả bne.