Ở 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 đ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.
Một lần tôi đo hớ: 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.
Bài học đo lường, đú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ì sao điều này quan trọng khi lập trình
Hệ quả đầ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.
Hệ quả 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.
Hệ quả thứ ba 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". Con số mang theo: 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.
Thử ba mươi giây
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.