Hàm ảo (virtual), con trỏ hàm, std::function, bảng điều phối (dispatch table) — tất cả đều là gọi gián tiếp: địa chỉ hàm cần nhảy tới không cố định trong mã, mà lấy từ một biến lúc chạy (một con trỏ, một ô trong vtable). Rất nhiều người mang định kiến "gọi gián tiếp luôn chậm, tránh mọi thứ virtual cho nhanh". Phần trước đo chi phí gọi hàm trực tiếp; phần này đo cái thêm của gọi gián tiếp — và câu trả lời hóa ra y hệt câu chuyện dự đoán nhánh ở đầu sê-ri, chỉ khác là dự đoán đích thay vì dự đoán hướng. Tôi đo trong container gcc:13 trên host ARM, và con số cho thấy gọi gián tiếp nhanh hay chậm hoàn toàn tùy đích có đoán được hay không.

Gọi gián tiếp qua con trỏ hàm

CPU dự đoán đích của lời gọi, y như dự đoán nhánh

Khi CPU gặp một lời gọi trực tiếp, địa chỉ hàm nằm ngay trong lệnh — CPU biết trước, nạp lệnh đích sớm, không khựng. Khi gặp một lời gọi gián tiếp, địa chỉ đích phải đọc từ một biến (con trỏ hàm, ô vtable) — mà giá trị đó có thể chưa sẵn sàng khi CPU cần biết nhảy đi đâu tiếp. Để không phải đứng chờ, CPU dự đoán đích bằng một cấu trúc gọi là BTB (Branch Target Buffer): nó nhớ "lần trước lời gọi ở đây nhảy tới đâu" và đoán lần này cũng vậy, rồi thực thi suy đoán.

Cơ chế này song song hoàn toàn với dự đoán nhánh:

  • Đoán trúng (đích ổn định, lặp lại): gần như miễn phí — CPU đã nạp đúng lệnh đích trước khi cần.
  • Đoán trật (đích đổi khó lường): flush pipeline — mọi lệnh đã nạp/thực thi suy đoán theo đích sai bị hủy, phạt hàng chục chu kỳ, y như một nhánh đoán sai. Cộng thêm: gọi gián tiếp chặn trình biên dịch inline và tối ưu qua ranh giới.

Vậy chi phí thật của gọi gián tiếp không phải một hằng số — nó tùy đích đoán được hay không. Tôi đo để thấy hai đầu.

Đo: đích ổn định 0,69 ns, đích ngẫu nhiên 4,28 ns — chênh 6,2 lần

Tôi gọi một hàm nhỏ (thân một phép x*3+1) hàng tỉ lần qua con trỏ hàm, chọn đích theo các mẫu khác nhau, và so với gọi trực tiếp:

ns mỗi lần gọi (thân hàm 1 phép x*3+1), host ARM, g++ -O2:

   cách gọi                        | ns/gọi | ghi chú
   --------------------------------|--------|----------------------------------
   trực tiếp (inline)              |  0,68  | đích cố định, biết trước
   trực tiếp (có call, noinline)   |  0,68  | đích cố định
   gián tiếp, đích CỐ ĐỊNH         |  0,69  | BTB đoán trúng -> ~= trực tiếp
   gián tiếp, mẫu LẶP 0,1,2,3      |  0,69  | vẫn đoán được -> vẫn rẻ
   gián tiếp, đích NGẪU NHIÊN      |  4,28  | BTB đoán trật -> 6,2x, phạt ~3,6 ns

Đọc ra ba điều. Một: gọi gián tiếp với đích cố định (luôn cùng một hàm) tốn 0,69 ns — gần như bằng gọi trực tiếp (0,68 ns), chênh vỏn vẹn 1%. BTB nhớ đích và đoán trúng mọi lần, nên phần "gián tiếp" gần như không tốn gì thêm. Định kiến "gọi gián tiếp luôn chậm" sai ngay ở đây.

Hai: ngay cả một mẫu lặp 0,1,2,3,0,1,2,3… (bốn hàm khác nhau xen kẽ đều đặn) cũng chỉ tốn 0,69 ns — vẫn rẻ. Bộ dự đoán đích học được mẫu lặp đều đó, y như dự đoán nhánh học được mẫu bật-tắt đều. "Đoán được" không có nghĩa "một đích duy nhất"; nó có nghĩa "có quy luật".

Ba — cú sốc: khi đích ngẫu nhiên mỗi lần (chọn bừa trong bốn hàm), 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, BTB đoán trật khoảng 3 trên 4 lần, và mỗi lần trật là một flush pipeline. Phần phạt trung bình ~3,6 ns mỗi lời gọi — chính là cái giá misprediction, không phải "một lần đọc bộ nhớ thêm". Cùng một lời gọi gián tiếp, cùng thân hàm, nhưng tính đoán được của đích quyết định nó nhanh gấp trực tiếp hay chậm 6 lần.

Một lần tôi đo hớ: "virtual luôn chậm" và "con trỏ hàm chỉ tốn một load"

Tôi vào đo với định kiến bi quan phổ biến trong dân C++: "hàm ảo, con trỏ hàm luôn chậm vì gọi gián tiếp, phải tránh mọi virtual ở đường nóng". Đo phá tan: khi đích ổn định (điều rất hay gặp — một vòng lặp gọi cùng một hàm ảo trên các đối tượng cùng kiểu, hay một mẫu lặp đều), BTB đoán trúng và gọi gián tiếp gần bằng gọi trực tiếp (0,69 vs 0,68 ns). Cái duy nhất bạn chắc chắn mất là khả năng inline — nhưng ở workload latency-bound hoặc thân hàm đủ lớn, ngay điều đó cũng không đáng kể. Tránh mọi virtual một cách máy móc là tối ưu cho một chi phí thường không tồn tại.

Nhưng đo cũng chỉnh niềm tin ngược mà người quen tối ưu dễ có: "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". Sai — khi đích ngẫu nhiên hoặc đa hình thật (một vòng lặp gọi hàm ảo trên các đối tượng nhiều kiểu xen kẽ khó lường), BTB đoán trật liên tục, mỗi lần là một flush pipeline — 4,28 ns, chậm 6,2 lần, phạt ~3,6 ns mỗi lời gọi. Đó không phải "một load"; đó là cái giá của misprediction, cộng với việc mất inline và tối ưu. Chi phí gọi gián tiếp khó lường đắt gấp bội cái mô hình "chỉ một load".

Bài học đo lường: gọi gián tiếp (con trỏ hàm/hàm ảo) = CPU dự đoán ĐÍCH bằng BTB, y như dự đoán nhánh. Đích ĐOÁN ĐƯỢC (cố định, hay mẫu lặp 0,1,2,3) -> BTB trúng -> gián tiếp ~= trực tiếp (đo 0,69 vs 0,68 ns, 1,01x). Đích NGẪU NHIÊN/đa hình thật -> BTB trật -> FLUSH pipeline: 4,28 ns = 6,2x, phạt ~3,6 ns/gọi. 'Virtual/con trỏ hàm luôn chậm, tránh hết' SAI (đích ổn định gần như miễn phí); 'con trỏ hàm chỉ tốn một load' SAI (đích khó đoán phạt như misprediction nhánh + mất inline). Nếu tin "virtual luôn chậm" tôi bỏ đa hình một cách vô ích; nếu tin "chỉ một load" tôi coi thường chi phí đa hình thật ở đường nóng.

Vì sao điều này quan trọng khi lập trình

Hệ quả đầu tiên: đừng sợ hàm ảo / con trỏ hàm khi đích ổn định — dùng đa hình thoải mái ở nơi kiểu ít đổi. Một vòng lặp gọi virtual trên một tập đối tượng cùng kiểu (hoặc kiểu đổi có quy luật) gần như không mất gì so với gọi trực tiếp. Thiết kế hướng đối tượng sạch sẽ không phải kẻ thù của hiệu năng ở phần lớn code — chỉ đường cực nóng với đích khó lường mới cần lo.

Hệ quả thứ hai: khi đích thật sự khó đoán ở đường nóng, hãy làm nó đoán được — hoặc bỏ gián tiếp. Nếu bạn duyệt một tập đối tượng đa hình mà kiểu xen kẽ loạn, cân nhắc gom nhóm theo kiểu (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 cố định), hoặc thay điều phối ảo bằng bảng tra/switch khi số loại nhỏ, hoặc tách thành các vòng lặp đơn hình mà trình biên dịch inline được. Đây chính là lý do các engine hiệu năng cao (ECS trong game, cột trong CSDL) nhóm dữ liệu cùng loại lại — để lời gọi trở nên đoán được.

Hệ quả thứ ba là tinh thần đo lường: gọi gián tiếp không có một cái giá cố định — giá của nó là tính đoán được của đích, y như nhánh. Con số mang theo: đích ổn định -> gián tiếp ~= trực tiếp (BTB trúng, ~0,69 ns); đích ngẫu nhiên -> flush như misprediction (6,2x, ~3,6 ns phạt) + mất inline. Giữ đích đoán được (gom theo kiểu) ở đường nóng; đừng bỏ đa hình một cách máy móc. Cùng một virtual, nhanh hay chậm gấp 6 lần chỉ tùy các đối tượng bạn duyệt có cùng kiểu hay xen kẽ loạn — và chỉ đo mới cho bạn biết đường của bạn rơi vào đâu.

Thử ba mươi giây

Tạo bốn hàm nhỏ giống nhau và một mảng con trỏ hàm trỏ tới chúng, rồi gọi trong một vòng lặp lớn acc = fns[idx[i]](acc). Chạy ba phiên bản khác nhau ở cách chọn idx: (a) luôn 0 (một đích cố định), (b) mẫu lặp i & 3 (0,1,2,3 tuần hoàn), (c) idx ngẫu nhiên. Bấm giờ mỗi lần gọi: (a) và (b) sẽ gần bằng một lời gọi trực tiếp, vì bộ dự đoán đích BTB đoán trúng; còn (c) sẽ chậm gấp nhiều lần, vì mỗi lời gọi là một cú đoán trật flush pipeline. Rồi đối chiếu với một vòng gọi trực tiếp cùng hàm: bạn sẽ thấy nó ngang với (a) và (b), và bỏ xa (c). Ba mươi giây đó cho bạn thấy điều mà cả "virtual luôn chậm" lẫn "con trỏ hàm chỉ tốn một load" đều giấu đi: gọi gián tiếp được CPU dự đoán đích y như nhánh — đích có quy luật thì gần như miễn phí, đích loạn thì trả giá flush pipeline — nên muốn đa hình nhanh, hãy giữ cho đích đoán được.