Trong C++, mỗi khi bạn gọi một hàm ảo (virtual), phía sau là cả một cơ chế: đối tượng đa hình mang một con trỏ ẩn vptr trỏ tới vtable — bảng địa chỉ các hàm ảo của lớp nó. Một lời gọi obj->f() phải đọc vptr từ đối tượng, đọc địa chỉ f từ vtable, rồi gọi gián tiếp tới địa chỉ đó. Nghe như hai lần chạm bộ nhớ cộng một lời gọi gián tiếp — đắt, và đó là lý do nhiều người né virtual ở đường nóng. Nhưng phần trước đã cho thấy gọi gián tiếp nhanh hay chậm tùy đích đoán được không; phần này đo riêng cơ chế vtable của C++, cộng thêm một vũ khí mà trình biên dịch có: devirtualization. Tôi đo trong container gcc:13 trên host ARM.
Vtable: hai lần đọc rồi gọi gián tiếp — nhưng có thể bị xóa
Khi một lớp có hàm ảo, mỗi đối tượng của nó bắt đầu bằng một con trỏ ẩn vptr. Vptr trỏ tới vtable của lớp — một mảng chứa địa chỉ các hàm ảo. Gọi obj->f() do đó thành ba bước ở mức máy: (1) đọc vptr từ đầu đối tượng, (2) đọc địa chỉ f từ ô tương ứng trong vtable, (3) gọi gián tiếp tới địa chỉ vừa đọc — và bước (3) này được CPU dự đoán đích bằng BTB, y hệt con trỏ hàm.
Nhưng có một lối thoát mà C++ tận dụng: devirtualization. Nếu trình biên dịch biết chắc kiểu thật của đối tượng — vì bạn gọi trên một đối tượng cụ thể, vì lớp được đánh dấu final, hoặc vì phân tích thoát chứng minh được — nó đổi gọi ảo thành gọi trực tiếp và inline luôn, bỏ sạch vtable lẫn gọi gián tiếp. Vậy chi phí một gọi ảo không phải một hằng số: nó tùy đối tượng có đơn hình (một kiểu, dễ đoán) hay đa hình loạn (nhiều kiểu khó lường), và tùy trình biên dịch có devirtualize được không. Tôi đo cả ba trường hợp.
Đo: đơn hình 0,69 ns, đa hình 4,64 ns — chênh 6,72 lần
Tôi dựng một lớp cơ sở với hàm ảo và bốn lớp con (thân f đều là x*3+1), tạo đối tượng qua một factory noinline (để trình biên dịch không thấy kiểu thật trong vòng lặp — buộc gọi ảo thật), rồi gọi hàng tỉ lần:
ns mỗi lần gọi (thân f = x*3+1), host ARM, g++ -O2:
cách gọi | ns/gọi | ghi chú
--------------------------------|--------|-----------------------------------
devirt (kiểu final -> inline) | 0,68 | trình biên dịch biết kiểu, bỏ vtable
virtual MONOMORPHIC (1 kiểu) | 0,69 | gọi ảo thật, BTB trúng -> 1,01x
virtual POLYMORPHIC ngẫu nhiên | 4,64 | BTB trật -> 6,72x, phạt ~3,95 ns/gọi
Đọc ra ba điều. Một: khi trình biên dịch biết kiểu (gọi qua một lớp final mà nó devirtualize + inline), chi phí là 0,68 ns — coi như một phép tính thường, không dấu vết vtable.
Hai — bất ngờ dễ chịu: gọi ảo đơn hình (mọi đối tượng cùng một kiểu A, gọi qua con trỏ cơ sở mà trình biên dịch không thấy kiểu) tốn 0,69 ns — chỉ 1,01 lần so với devirt. Đây là gọi ảo thật (đọc vptr, đọc vtable, gọi gián tiếp), nhưng vì mọi đối tượng cùng kiểu nên vptr luôn trỏ tới cùng một vtable (nằm sẵn trong cache), và BTB đoán trúng đích mọi lần. Hai lần đọc vtable chồng lấp với độ trễ thân hàm và gần như miễn phí. Cơ chế vtable nghe đắt, nhưng khi đơn hình thì không đắt.
Ba — cú sốc: gọi ảo đa hình (tập đối tượng trộn bốn kiểu ngẫu nhiên) vọt lên 4,64 ns — chậm 6,72 lần. Vì mỗi đối tượng có thể là kiểu khác, vptr và đích đổi khó lường, BTB đoán trật liên tục, mỗi lần là một flush pipeline, phạt ~3,95 ns mỗi lời gọi — đúng cái giá misprediction của phần trước. Cùng một hàm ảo, cùng thân, mà đơn hình hay đa hình quyết định nhanh gấp gọi thường hay chậm gần 7 lần.
Một lần tôi đo hớ: "virtual luôn đắt" và "virtual là chi phí cố định"
Tôi vào đo với định kiến bi quan: "gọi ảo luôn tốn — hai lần đọc bộ nhớ để lần vtable cộng một gọi gián tiếp — nên tránh OOP ở đường nóng". Đo phá tan: gọi ảo đơn hình chỉ 0,69 ns, gần bằng một gọi thường (1,01x). Hai lần đọc vtable mà tôi sợ thì nằm trong cache và chồng lấp với thân hàm, còn gọi gián tiếp thì BTB đoán trúng vì vptr ổn định. Và trong rất nhiều trường hợp trình biên dịch còn devirtualize hẳn (kiểu final, đối tượng cụ thể) rồi inline, xóa sạch chi phí. Tránh mọi virtual một cách máy móc là tối ưu cho một cái giá thường không tồn tại.
Nhưng đo cũng chỉnh niềm tin ngược: "gọi ảo chỉ là một lần tra bảng cố định, chi phí lúc nào cũng như nhau, khỏi lo". Sai — chi phí virtual rất biến thiên. Khi đối tượng đa hình thật (một vòng lặp duyệt tập đối tượng nhiều kiểu xen kẽ khó đoán), BTB đoán trật và mỗi gọi ảo trả giá flush pipeline — 4,64 ns, chậm 6,72 lần. Thêm nữa, việc trình biên dịch có devirtualize được hay không thay đổi chi phí hàng lần: cùng dòng code obj->f(), nếu biết kiểu thì 0,68 ns, nếu không thì 0,69–4,64 ns tùy đơn/đa hình. "Chi phí cố định" là một mô hình sai che mất khoảng cách gần 7 lần này.
Bài học đo lường: gọi ảo = đọc vptr -> đọc vtable -> gọi gián tiếp (BTB đoán đích). Chi phí KHÔNG cố định: ĐƠN HÌNH (mọi obj cùng kiểu) -> BTB trúng + vtable trong cache -> 0,69 ns = 1,01x gọi thường; ĐA HÌNH ngẫu nhiên -> BTB trật -> flush: 4,64 ns = 6,72x, phạt ~3,95 ns/gọi (như phần 35); và DEVIRTUALIZATION (kiểu final/cụ thể) -> inline, bỏ sạch (0,68 ns). 'Virtual luôn đắt (2 load vtable + indirect), tránh OOP' SAI (đơn hình + devirt ~= gọi thường); 'virtual là chi phí cố định' SAI (đa hình loạn 6,72x; devirt hay không đổi chi phí hàng lần). Nếu tin "virtual luôn đắt" tôi bỏ đa hình vô ích; nếu tin "chi phí cố định" tôi coi thường cái giá đa hình thật.
Vì sao điều này quan trọng khi lập trình
Hệ quả đầu tiên: dùng hàm ảo thoải mái khi đơn hình hoặc devirtualize được — đừng sợ OOP theo phản xạ. Một vòng lặp gọi virtual trên tập đối tượng cùng kiểu (hoặc kiểu đổi có quy luật) gần như miễn phí. Đánh dấu lớp/hàm final khi có thể để trình biên dịch devirtualize; gọi qua kiểu cụ thể thay vì con trỏ cơ sở ở chỗ nóng cũng giúp nó inline. Thiết kế đa hình sạch sẽ không phải kẻ thù hiệu năng ở phần lớn code.
Hệ quả thứ hai: khi đa hình thật sự ở đường cực nóng, hãy làm đích đoán được — gom theo kiểu. Nếu bạn duyệt một tập đối tượng nhiều kiểu trộn lẫn (cây biểu thức, cảnh đồ họa, node AST), cân nhắc sắp xếp/nhóm theo kiểu 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 hình, hoặc thay điều phối ảo bằng cấu trúc dữ liệu hướng-dữ-liệu (như SoA và ECS). Đây là gốc của "type-based batching" trong engine game và cơ sở dữ liệu.
Hệ quả thứ ba là tinh thần đo lường: chi phí một gọi ảo là hàm của tính đơn hình và của việc devirtualize — không phải một hằng số phần cứng. Con số mang theo: đơn hình + devirt ~= gọi thường (~0,68–0,69 ns); đa hình ngẫu nhiên -> flush như phần 35 (6,72x, ~3,95 ns phạt). Giữ đích đơn hình/dùng final ở đường nóng; đừng né virtual một cách máy móc. Cùng một virtual, nhanh hay chậm gần 7 lần chỉ tùy các đối tượng bạn duyệt cùng kiểu hay trộn loạn — đo mới biết đường của bạn rơi vào đâu.
Thử ba mươi giây
Định nghĩa một lớp cơ sở Base với một hàm ảo và vài lớp con ghi đè nó (thân giống nhau), rồi tạo một mảng Base*. Chạy hai phiên bản: (a) mảng toàn một kiểu (đơn hình), (b) mảng trộn các kiểu ngẫu nhiên (đa hình). Gọi obj->f() trong vòng lặp lớn và bấm giờ: bản đơn hình sẽ gần bằng một gọi hàm thường, còn bản đa hình chậm gấp nhiều lần — cùng một dòng obj->f(), khác nhau chỉ ở việc các đối tượng có cùng kiểu hay không, vì BTB đoán trúng vptr/đích ở bản đơn hình và trật ở bản đa hình. Rồi thử đánh dấu một lớp final và gọi qua kiểu cụ thể của nó: trình biên dịch sẽ devirtualize và inline, cho tốc độ như gọi thường — bạn có thể kiểm bằng cách xem assembly (-S) và thấy lời gọi biến mất. Ba mươi giây đó cho bạn thấy điều mà cả "virtual luôn đắt" lẫn "virtual là chi phí cố định" đều giấu đi: gọi ảo là một gọi gián tiếp qua vtable mà CPU dự đoán và trình biên dịch đôi khi xóa hẳn — nên muốn đa hình nhanh, hãy giữ cho đích đơn hình hoặc để trình biên dịch nhìn thấy kiểu.