Ở bài về nội tuyến hàm, ta thấy inline chỉ là gợi ý và -O mới là bên quyết. Bài này tôi đào vào chính cái quyết đó: trình biên dịch có một ngân sách nội tuyến với những ngưỡng cụ thể, và một hàm quá lớn sẽ không được nội tuyến dù bạn có muốn tới đâu. Tôi đo các ngưỡng ấy trong container — và một lần nữa, phép đo đầu tiên bác bỏ mô hình trong đầu tôi, rồi phép đo thứ hai cho thấy vì sao cái ngưỡng đó là bạn chứ không phải thù.

Ngân sách nội tuyến

Nội tuyến không miễn phí: nó dán thân hàm vào mỗi chỗ gọi, nên gọi từ nhiều nơi thì mã phình lên. Để không phình vô hạn, gcc đặt các ngưỡng, xem được bằng gcc -O2 -Q --help=params:

max-inline-insns-single = 70   ; hàm lớn hơn ngần này (đơn vị nội bộ) thì không tự nội tuyến
max-inline-insns-auto   = 15   ; ngưỡng cho hàm không khai 'inline'
inline-unit-growth      = 40   ; cả đơn vị dịch được phép phình tối đa 40%

Ý tưởng: hàm nhỏ thì nội tuyến (rẻ, đỡ phí gọi); hàm lớn thì cân nhắc, và nếu nội tuyến sẽ làm mã phình quá ngân sách thì giữ lại lời gọi. Nghe rõ ràng. Nhưng khi tôi bắt tay chứng minh nó, thực tế phức tạp hơn hẳn.

Đo: ngưỡng chỉ cắn khi nội tuyến làm phình mã

Tôi viết một hàm big có khoảng 80 phép tính (vượt xa ngưỡng 70), rồi gọi nó trong một vòng lặp, và ngồi chờ trình biên dịch không nội tuyến nó. Đọc objdump: bl <big> = 0 — nó đã được nội tuyến! Ngược hẳn dự đoán của tôi.

Lý do hé lộ khi tôi để ý big là hàm static và chỉ được gọi từ một chỗ. Với một hàm static dùng đúng một lần, nội tuyến nó không làm phình mã — nó chỉ dời thân hàm vào chỗ gọi rồi xóa bản gốc, tổng kích thước gần như không đổi. Nên gcc nội tuyến bất kể hàm lớn cỡ nào; ngưỡng kích thước đơn giản là không áp dụng ở đây. Mô hình "hàm lớn thì không nội tuyến" của tôi thiếu một mệnh đề: ngưỡng chỉ cắn khi nội tuyến sẽ làm phình — tức khi hàm được gọi từ nhiều nơi (và bản gốc phải giữ lại).

Tôi sửa thí nghiệm: gọi big từ sáu hàm khác nhau. Giờ ngưỡng mới cắn:

small (nhỏ), 6 điểm gọi:  bl <small> = 0   (nội tuyến khắp nơi — nhỏ, rẻ)
big  (>70),  6 điểm gọi:  bl <big>   = 6   (GIỮ lời gọi ở cả 6 nơi + một hàm chung)

small nhỏ nên được nội tuyến vào cả sáu chỗ; big lớn nên gcc từ chối nội tuyến tại sáu chỗ, giữ lại một hàm dùng chung và sáu lời gọi thật. Ngưỡng đã làm đúng việc: nội tuyến big sáu lần sẽ phình mã vượt ngân sách, nên nó không làm.

Ép vượt ngưỡng: đổi 14 lần kích thước lấy 1%

Vậy nếu tôi ép nội tuyến bằng __attribute__((always_inline)) thì sao? Nó thắng được cái quyết định của trình biên dịch. Đo: bl <bigfi> = 0 (nội tuyến khắp nơi), nhưng tổng mã của sáu hàm gọi nhảy từ 154 lên 2183 lệnh — phình 14 lần. Đúng cái mà ngưỡng đang cố ngăn.

Câu hỏi thật là: đổi 14 lần kích thước đó lấy được bao nhiêu tốc độ? Tôi đo một vòng lặp 20 triệu lần, một bản giữ lời gọi (big khai noinline), một bản ép nội tuyến:

giữ lời gọi (bl mỗi vòng): 1163 ms   (hàm gọi chỉ 25 lệnh)
ép nội tuyến             : 1151 ms   (hàm gọi 362 lệnh)

Nhanh hơn khoảng 1%. Đúng một phần trăm — đổi lấy mã to gấp 14 lần. Lý do rõ mồn một: phí của một lời gọi hàm (bl + ret, dự đoán nhánh chuẩn) là vài chu kỳ, tí xíu so với 80 phép tính trong thân big. Khi thân hàm nặng, việc bỏ lời gọi hầu như không đáng kể; còn khi thân hàm nhẹ, gcc đã tự nội tuyến từ trước rồi.

Đây là bài học kép, cả hai đều là chủ đề xuyên suốt sê-ri. Thứ nhất, kiểm soát biến ẩn: tôi đoán trật vì quên rằng "số điểm gọi" mới là biến quyết định ngưỡng có cắn hay không; một hàm dùng một lần luôn được nội tuyến bất kể lớn. Thứ hai, và cốt lõi: ngưỡng nội tuyến của gcc là một lá chắn chống phình mã, và ép vượt nó thường lỗ. Nó không phải một giới hạn tùy tiện cản bạn tối ưu; nó là kết quả của đúng phép cân đo tôi vừa làm — phí gọi bé so với thân hàm lớn, nên nội tuyến hàm lớn ở nhiều nơi chỉ tổ phình mã, hại cache lệnh, mà gần như không nhanh thêm. "Inline là nhanh" là một trực giác sai khi hàm đủ lớn.

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

Điều đầu tiên: đừng rải always_inline theo niềm tin "nội tuyến luôn nhanh hơn". Với hàm nhỏ, gcc đã tự nội tuyến nên thuộc tính thừa; với hàm lớn gọi nhiều nơi, ép nội tuyến đổi kích thước lấy gần như không tốc độ, và mã phình có thể làm chậm vì đẩy mã nóng ra khỏi cache lệnh (đúng cái bài sắp mã nóng lạnh đo). Hãy để always_inline cho những hàm thật sự nhỏ mà bạn cần chắc được nội tuyến (một wrapper mỏng, một hàm truy cập), đừng dùng cho hàm nặng.

Điều thứ hai: hiểu rằng "số điểm gọi" quyết định chi phí nội tuyến. Một hàm gọi một lần thì nội tuyến miễn phí (chỉ dời); gọi từ N nơi thì nội tuyến tốn thêm (N−1) bản thân hàm. Đây là lý do các hàm tiện ích nhỏ gọi khắp nơi vẫn nên giữ nhỏ — để tổng chi phí nội tuyến chúng ở nhiều nơi vẫn nằm trong ngân sách. Nếu bạn có một hàm nặng gọi nhiều nơi và thật sự muốn nội tuyến nó ở một chỗ nóng cụ thể, hãy nội tuyến thủ công đúng chỗ đó, đừng ép nội tuyến toàn cục.

Và điều cuối, cũng là bài học mang theo: giới hạn mặc định của trình biên dịch thường là quyết định đúng — hãy đo trước khi ghi đè. Gói lại bằng số: hàm lớn hơn max-inline-insns-single (70) gọi từ nhiều nơi thì không được tự nội tuyến (giữ lời gọi), nhưng hàm dùng một lần thì nội tuyến bất kể lớn vì chỉ dời không phình; ép always_inline vượt ngưỡng đổi 14 lần kích thước mã lấy ~1% tốc độ — nên ngưỡng là lá chắn chống phình, đừng ép vượt theo linh cảm. Trình biên dịch đã cân đo phí gọi so với chi phí phình mã; tin nó, và chỉ ghi đè khi đồng hồ — không phải trực giác — bảo bạn nên làm.

Cái tinh thần "runtime tự cân đo nội tuyến, ép tay thường lỗ" này không riêng gì C. JVM còn quyết liệt hơn: JIT của HotSpot có MaxInlineSize (mặc định 35 byte bytecode) và FreqInlineSize, và nó nội tuyến theo hồ sơ chạy thật — hàm nào nóng mới được ưu ái, nên một hàm to gọi hiếm sẽ không bao giờ được nội tuyến dù bạn có chú thích gì. Java thậm chí không cho bạn ép always_inline; bạn chỉ có thể giữ hàm nhỏ để nó đủ điều kiện. Go thì công khai ngưỡng qua "inline budget" và bạn xem quyết định bằng go build -gcflags=-m — đúng vai objdump ở đây. Ngay cả cơ sở dữ liệu cũng có phiên bản của bài học: PostgreSQL nội tuyến các hàm SQL đơn giản nhưng giữ ranh giới hàm cho hàm phức tạp, và ép plpgsql vào một truy vấn nóng hiếm khi thắng cái mà planner đã tự cân. Mẫu số chung ở mọi tầng: cái runtime/compiler chọn mặc định thường là kết quả của một phép cân đo bạn chưa làm — hãy đo trước khi tin rằng mình khôn hơn nó.

Muốn tự thấy thì xem các ngưỡng nội tuyến bằng gcc -O2 -Q --help=params | grep inline — bạn sẽ thấy max-inline-insns-single, max-inline-insns-auto, inline-unit-growth với những con số cụ thể. Rồi viết một hàm khá lớn, gọi nó từ một chỗ, biên dịch gcc -O2 -c và đếm bl <tên_hàm> trong objdump -d: sẽ là 0 (nội tuyến, vì dùng một lần). Giờ gọi nó từ năm sáu chỗ và đếm lại: bạn sẽ thấy các lời gọi thật xuất hiện — ngưỡng đã cắn. Thử thêm __attribute__((always_inline)) và so kích thước .text (bằng lệnh size) trước và sau: mã phình lên, và đó là lời nhắc rằng nội tuyến không miễn phí, ngưỡng của gcc đang giữ hộ bạn cái ví.