bài về nội tuyến hàm, ta thấy inline chỉ là gợi ý và -O mới quyết. Bài này đi sâu 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 muốn. 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 của tôi, rồi phép đo thứ hai cho thấy vì sao ngưỡng đó là bạn chứ không phải thù.

Giới hạn nội tuyến

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 thử chứng minh nó, thực tế phức tạp 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à kỳ vọng thấy 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.

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. 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.

Một lần tôi đo hớ: é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ó có thắng cái quyết định của trình biên dịch. Đo được: 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 ngưỡng đang cố ngăn.

Câu hỏi thật: đổ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%. Chỉ một phần trăm — đổi lấy mã to gấp 14 lần. Lý do rõ: 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ể; khi thân hàm nhẹ, gcc đã tự nội tuyến rồi.

Bài học đo lường kép, cả hai đều là chủ đề của sê-ri: thứ nhất, kiểm soát biến ẩn — tôi đo hớ vì quên rằng "số điểm gọ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 cù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ì sao điều này quan trọng khi lập trình

Hệ quả đầ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 rồi 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), không phải cho hàm nặng.

Hệ quả 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 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.

Hệ quả thứ ba 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 đè. Con số mang theo: 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.

Thử ba mươi giây

Xem các ngưỡng nội tuyến của trình biên dịch 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: bạn sẽ thấy mã phình lên, và đó là lời nhắc rằng nội tuyến không miễn phí, và ngưỡng của gcc đang giữ hộ bạn cái ví.