Trong C++ (và mọi ngôn ngữ hướng đối tượng), một lời gọi hàm ảo — object->method() với method là virtual — không nhảy thẳng tới một hàm cố định. Nó nạp con trỏ hàm từ bảng ảo (vtable) của đối tượng rồi nhảy gián tiếp tới đó, vì kiểu thật của đối tượng chỉ biết lúc chạy. Danh tiếng của hàm ảo là "chậm, tránh nếu cần tốc độ". Tôi vào đo cái chậm đó lớn tới đâu, và tìm ra nó phụ thuộc một thứ mà lời khuyên "tránh virtual" bỏ qua hoàn toàn.
Devirtualization: khi compiler biết kiểu thật
Devirtualization là khi compiler xác định được kiểu thật của đối tượng ngay lúc biên dịch — ví dụ một đối tượng cục bộ khai kiểu cụ thể, một lớp final, hay qua phân tích luồng — rồi thay lời gọi ảo bằng lời gọi trực tiếp. Lời gọi trực tiếp thì nội tuyến được, mở tiếp mọi tối ưu khác.
Tôi kiểm trong container gcc:13 với g++ -O2 (ARM AArch64). Một hàm gọi method ảo trên một đối tượng Derived cục bộ (compiler biết chắc kiểu) biên dịch thành:
known_type:
add w0, w0, w0, lsl 1 (= x*3, thân của Derived::f)
ret
Không vtable, không nhảy gián tiếp — compiler devirtualize và nội tuyến thẳng thân hàm (x*3, còn được giảm sức mạnh thành cộng-dịch). Ngược lại, một hàm nhận con trỏ Base* mà compiler không biết kiểu thật biên dịch thành:
via_base:
ldr ... (nạp con trỏ vtable)
ldr ... (nạp con trỏ hàm f từ vtable)
br ... (nhảy gián tiếp tới f)
Hai lần nạp bộ nhớ rồi một nhảy gián tiếp. Câu hỏi thật: cái nhảy gián tiếp đó tốn bao nhiêu?
Đo: monomorphic rẻ, megamorphic mới đắt
Tôi đo một vòng gọi method ảo 40 triệu lần trên một mảng con trỏ, thay đổi phân bố kiểu qua site đó:
devirt (gọi trực tiếp, được inline) : ~0 ns/gọi (cả vòng gập thành công thức)
ảo MONO (mọi phần tử cùng 1 kiểu) : 0,722 ns/gọi
ảo MEGA (4 kiểu trộn ngẫu nhiên) : 4,769 ns/gọi -> chậm 6,6 lần
Ba con số này lật hẳn định kiến. Trường hợp monomorphic — site gọi ảo mà mọi lần chỉ thấy một kiểu thật — chỉ tốn 0,722 ns, không hề thảm họa. Vì cái nhảy gián tiếp tuy "gián tiếp" nhưng luôn tới cùng một đích, và bộ dự đoán nhánh gián tiếp của CPU học được đích đó chỉ sau vài lần, rồi đoán đúng gần như mọi lần. Một hàm ảo gọi trên một kiểu duy nhất gần như miễn phí.
Trường hợp megamorphic — bốn kiểu trộn ngẫu nhiên qua cùng site — mới chậm: 4,769 ns, gấp 6,6 lần. Vì giờ cái nhảy gián tiếp có bốn đích ngẫu nhiên, CPU không đoán được, và mỗi lần đoán sai phải xả ống lệnh — đúng cơ chế phạt đoán sai của bảng nhảy switch. Cái đắt không phải bản thân vtable, mà là đích nhảy khó đoán.
Còn devirt xuống ~0 ns là điểm sáng nhất: khi compiler biết kiểu và gọi trực tiếp, nó nội tuyến f, rồi cả vòng cộng i*3 gập thành một công thức đóng — chạy trong thời gian gần như không đo được. Đó là đo hớ của tôi làm rõ: tôi tưởng "hàm ảo luôn chậm nên tránh". Đo ra cái chậm chỉ hiện khi phân bố kiểu khó đoán; và giá trị lớn nhất của devirtualization không phải bỏ một lệnh nhảy, mà là mở đường nội tuyến để mọi tối ưu sau đó ập vào.
Ba nấc đa hình mà bộ dự đoán thấy
Kết quả trên soi rõ một bậc thang mà phần cứng thật sự trải qua. Monomorphic — một đích duy nhất tại site — là ca dễ nhất: bộ dự đoán nhánh gián tiếp chỉ cần nhớ một địa chỉ, đoán đúng gần 100%. Bimorphic hay vài đích ổn định vẫn khá tốt: các CPU hiện đại có bộ đệm đích nhánh (BTB) nhớ được vài đích gần đây theo lịch sử, nên hai-ba kiểu xen kẽ theo mẫu vẫn đoán được phần lớn. Megamorphic — nhiều đích trộn không quy luật — mới là ca tệ, vì không lịch sử nào giúp đoán, và mỗi lần trượt là một lần xả ống lệnh.
Đây là lý do các máy ảo có JIT (Java HotSpot, V8 của JavaScript) đầu tư mạnh vào speculative devirtualization: chúng đo lúc chạy xem site nào monomorphic rồi biên dịch một lối tắt gọi trực tiếp có kiểm tra kiểu, chỉ rơi về gọi ảo đầy đủ khi gặp kiểu lạ. Trong C++ tĩnh, final trên lớp hoặc method là cách bạn nói cho compiler rằng không có kiểu con nào khác, giúp nó devirtualize mà không cần đoán — một chú thích rẻ mở ra cùng cái lợi.
Vì sao điều này quan trọng khi lập trình
Hệ quả đầu tiên: đừng tránh virtual theo phản xạ. Nếu một điểm gọi ảo trong thực tế chỉ thấy một kiểu (rất phổ biến — một interface thường chỉ có một triển khai chạy trong một tải cụ thể), CPU xử lý nó gần như miễn phí. Thay virtual bằng một switch trên kiểu hay một chuỗi if chỉ để "né vtable" thường không nhanh hơn, mà làm code khó bảo trì hơn. Trừu tượng ảo có giá của nó, nhưng cái giá đó nhỏ khi phân bố kiểu ổn định.
Hệ quả thứ hai: cái đắt thật là site megamorphic — và đó là chỗ đáng đo, đáng cấu trúc lại. Nếu một điểm gọi nóng thật sự thấy nhiều kiểu trộn ngẫu nhiên, chi phí là thật (6,6 lần ở đây), và cách chữa không phải bỏ virtual khắp nơi mà là giảm tính đa hình tại điểm nóng đó: gom xử lý theo kiểu (sort theo kiểu để mỗi lô monomorphic), hay tách một fast-path cho kiểu phổ biến nhất. PGO còn giúp bằng speculative devirtualization: nó đo kiểu nào hay gặp nhất rồi chèn một kiểm tra "nếu là kiểu X thì gọi trực tiếp (inline), không thì gọi ảo".
Hệ quả thứ ba là bài học đo lường lặp lại: danh tiếng một cấu trúc thường bỏ qua điều kiện quyết định. "Hàm ảo chậm" đúng cho megamorphic, sai cho monomorphic — khác nhau 6,6 lần, và bạn chỉ biết mình ở đâu bằng cách đo phân bố kiểu thật tại site đó, không phải suy từ việc có chữ virtual hay không. Con số mang theo: gọi ảo là nạp vtable + nhảy gián tiếp; devirtualization (compiler biết kiểu thật) thay bằng gọi trực tiếp rồi nội tuyến — thắng lớn vì mở inline (ở đây gập cả vòng thành ~0 ns); nhưng gọi ảo thật KHÔNG luôn chậm: monomorphic (một kiểu qua site) chỉ 0,722 ns vì CPU đoán đích hoàn hảo, chỉ megamorphic (nhiều kiểu khó đoán) mới chậm 6,6 lần (4,769 ns) do nhảy gián tiếp đoán sai — nên đừng tránh virtual mù quáng, đo phân bố kiểu tại site nóng. Cái đắt là đích khó đoán, không phải từ khóa virtual.
Thử ba mươi giây
Viết một lớp Base với method ảo và hai lớp con. Tạo một mảng lớn con trỏ Base*, đo một vòng gọi p[i]->f(). Chạy hai lần: lần một điền mảng toàn một kiểu (monomorphic), lần hai trộn ngẫu nhiên hai kiểu (polymorphic). Bọc bằng clock_gettime. Bạn sẽ thấy bản monomorphic nhanh hơn nhiều lần — dù cùng số lời gọi ảo — vì bộ dự đoán nhánh gián tiếp của CPU đoán đúng đích khi chỉ có một, và đoán sai liên tục khi trộn. Ba mươi giây đó cho bạn thấy chi phí thật của virtual nằm ở tính đa hình của dữ liệu, không phải ở cú pháp.