Con trỏ hàm, callback, std::function, bảng điều phối, và hàm ảo — tất cả đều là gọi gián tiếp: địa chỉ hàm cần nhảy tới không nằm cứng trong mã, mà lấy từ một biến lúc chạy. Và tất cả đều bị mang tiếng "gọi gián tiếp là chậm, tránh đi cho lành". Bài trước đo chi phí gọi hàm thẳng; bài này đo cái thêm của gọi gián tiếp, và hóa ra nó là đúng câu chuyện dự đoán nhánh ở đầu sê-ri, chỉ khác là dự đoán đích thay vì hướng.

Hình dung một bác tài taxi quen tuyến. Nếu khách nào lên xe cũng bảo "về sân bay", bác rồ ga sẵn theo hướng đó, khách vừa dứt lời là xe đã lăn bánh. Nhưng nếu mỗi khách lại đọc một địa chỉ khác nhau, khó đoán, thì lần nào bác cũng phải dừng lại nghe cho rõ rồi mới quyết định rẽ đâu — chậm hẳn. CPU xử lý cú gọi gián tiếp y như thế. Với gọi thẳng, địa chỉ nằm ngay trong lệnh, CPU biết trước và nạp lệnh đích sớm. Với gọi gián tiếp, địa chỉ phải đọc từ một biến mà lúc đó có khi chưa sẵn sàng — nên CPU đoán đích, dựa vào một cấu trúc tên là BTB nhớ "lần trước chỗ này nhảy đi đâu", rồi chạy tiếp theo dự đoán.

Đích đoán được thì gần như miễn phí

Tôi gọi một hàm nhỏ hàng tỉ lần qua con trỏ hàm, đổi cách chọn đích, và so với gọi thẳng. Trên máy ARM:

Cách gọi (thân hàm x*3+1) ns mỗi lần
Gọi thẳng 0,68
Gián tiếp, đích cố định 0,69 — gần bằng gọi thẳng
Gián tiếp, mẫu lặp 0,1,2,3 0,69 — vẫn rẻ
Gián tiếp, đích ngẫu nhiên 4,28 — chậm 6,2 lần

Điều đầu tiên phá tan định kiến: gọi gián tiếp với đích cố định chỉ tốn 0,69 ns, chênh gọi thẳng đúng 1%. Bác tài đoán trúng mọi lần nên phần "gián tiếp" gần như không tốn gì.

Điều thứ hai còn thú vị hơn: ngay cả khi bốn hàm khác nhau thay phiên theo mẫu lặp đều 0,1,2,3, nó vẫn rẻ y như vậy. BTB học được cả cái nhịp lặp ấy. "Đoán được" không có nghĩa là chỉ có một đích duy nhất, mà là có quy luật.

Cú sốc nằm ở dòng cuối. Khi đích nhảy ngẫu nhiên mỗi lần, thời gian vọt lên 4,28 ns — chậm 6,2 lần. Với bốn đích ngẫu nhiên, bác tài đoán trật chừng ba trên bốn lần, mỗi lần trật là một cú flush pipeline, phạt gần 3,6 ns. Đây chính là cái giá đoán sai, không phải "một lần đọc bộ nhớ thêm" như người ta hay nghĩ. Cùng một cú gọi gián tiếp, cùng thân hàm — chỉ khác ở chỗ đích có đoán được hay không.

Hai định kiến trái ngược

Định kiến bi quan quen thuộc trong dân C++: hàm ảo, con trỏ hàm luôn chậm vì gọi gián tiếp, phải né mọi thứ ở đường nóng. Sai — khi đích ổn định, mà đây là cảnh rất hay gặp, nó gần bằng gọi thẳng. Cái duy nhất bạn chắc chắn mất là khả năng inline, nhưng với thân hàm đủ nặng thì điều đó cũng chẳng đáng.

Định kiến ngược lại, của người quen tối ưu: "con trỏ hàm chỉ tốn thêm một lần đọc bộ nhớ để lấy địa chỉ, chẳng đáng bao nhiêu". Cũng sai. Khi đích ngẫu nhiên hay đa hình thật, BTB đoán trật liên tục, mỗi cú là một flush pipeline — chậm 6 lần. Đó không phải một load, mà là cái giá misprediction cộng với việc mất inline.

Ngoài đời thì sao

Vậy nên đừng sợ con trỏ hàm hay hàm ảo khi đích ổn định — dùng đa hình, callback thoải mái ở nơi kiểu ít đổi. Chỉ khi đích thật sự khó đoán ở một vòng lặp cực nóng mới cần lo, và cách chữa không phải bỏ gián tiếp, mà là làm cho đích đoán được: gom nhóm theo loại trước khi duyệt (xử lý hết loại A rồi tới loại B, biến đích ngẫu nhiên thành từng khối ổn định), hoặc thay điều phối bằng một bảng tra / switch khi số loại nhỏ. Đây chính là lý do các engine hiệu năng cao — ECS trong game, kho cột trong cơ sở dữ liệu — cố tình nhóm dữ liệu cùng loại lại với nhau.

Chuyện này lặp lại ở mọi ngôn ngữ có gọi gián tiếp. Các engine JavaScript như V8 dựng inline cache đúng theo nguyên tắc này: điểm gọi chỉ thấy một kiểu thì nhanh, thấy quá nhiều kiểu (megamorphic) thì rơi về đường chậm. Trong C++, std::function gói callback bằng một cú gọi gián tiếp, nên nhét nó vào vòng trong siêu nóng với đích loạn là dễ dính đúng cái phạt này — nhiều thư viện hiệu năng cao vì thế dùng template thay cho std::function ở hot path. Java có lambda và functional interface, Go có func value, đều cùng câu chuyện. Bài học vượt khỏi C++: giữ cho các điểm gọi gián tiếp của bạn nhìn thấy đích ổn định.

Muốn tự thấy thì tạo bốn hàm nhỏ giống nhau và một mảng con trỏ hàm, rồi gọi fns[idx[i]](acc) trong vòng lặp lớn với ba cách chọn idx: luôn 0, mẫu lặp i & 3, và ngẫu nhiên. Hai cách đầu gần bằng gọi thẳng, cách cuối chậm mấy lần. Cùng một dòng code, nhanh hay chậm gấp 6 lần chỉ tùy CPU có đoán nổi bạn sắp gọi ai.