Bài interface internals cho thấy gọi phương thức qua interface tốn một lần tra itab và một nhảy gián tiếp — chậm hơn gọi trực tiếp. Nhưng có một tối ưu compiler làm mờ ranh giới đó: devirtualization. Khi compiler chứng minh được kiểu động cụ thể sau một interface lúc biên dịch, nó thay dispatch động bằng lời gọi trực tiếp — rồi thường inline luôn, khiến toàn bộ lời gọi biến mất. Bài này đo hiện tượng đó bằng assembly và benchmark, và làm rõ khi nào nó xảy ra.
Cơ chế: chứng minh kiểu rồi gọi thẳng
Ý tưởng đơn giản: nếu compiler biết chắc h là Vuong (không phải một kiểu nào đó thỏa Hinh), thì h.Dientich() không cần dispatch động — nó gọi thẳng Vuong.Dientich. Mẫu điển hình là gán một kiểu cụ thể ngay trước khi gọi:
func dungKieuCuThe() float64 {
var h Hinh = Vuong{3} // compiler BIẾT h là Vuong
return h.Dientich() // viết như gọi interface, nhưng devirtualize thành Vuong.Dientich
}
Ngược lại, khi kiểu động thay đổi lúc chạy — ví dụ lấy từ một slice trộn nhiều kiểu — compiler không chứng minh được, phải giữ dispatch động:
var mau = []Hinh{Vuong{2}, Tron{3}}
func khongBiet() float64 {
i = (i + 1) & 1
return mau[i].Dientich() // kiểu động đổi mỗi vòng → dispatch động thật
}

Hình 1: Devirtualize được khi kiểu cụ thể biết tĩnh (gán Vuong{3} rồi gọi); không được khi kiểu động đổi lúc chạy (mau[i]). Xác nhận qua -m và assembly.
Bằng chứng: dispatch biến mất trong assembly
Chạy -gcflags=-m in ra chính xác quyết định:
devirtualizing x.Gia to A2 // thay dispatch động bằng gọi trực tiếp
inlining call to A2.Gia // rồi dán thẳng phương thức vào
Và assembly của hàm (với Gia() trả x+1) chỉ còn:
main.devirtDuoc STEXT
ADD $1, R0, R0 // chỉ còn x + 1
RET
Không nạp itab, không CALL gián tiếp. Hai bước — devirtualize rồi inline — gộp một lời gọi phương thức qua interface thành đúng một phép cộng. Đây là chuỗi tối ưu đẹp: interface (linh hoạt runtime) mà khi kiểu biết tĩnh thì không trả giá gì so với gọi trực tiếp.
Đo thật: nhanh hơn ~3,8 lần
Benchmark so devirtualize được với dispatch động thật (dùng phương thức trả int và tích lũy integer để lộ chi phí dispatch, tránh bị latency phép cộng float che):

Hình 2: Devirt2 (kiểu cụ thể biết tĩnh) 0,4707 ns so với Dynamic2 (kiểu đổi lúc chạy) 1,796 ns — nhanh hơn ~3,8 lần. Bảng điều kiện để devirtualize xảy ra.
- Devirt2 (kiểu cụ thể biết tĩnh): 0,4707 ns — devirtualize + inline.
- Dynamic2 (kiểu đổi lúc chạy): 1,796 ns — dispatch động thật (tra itab + nhảy gián tiếp).
Nhanh hơn ~3,8 lần khi devirtualize được. Chênh lệch chính là chi phí dispatch động mà devirtualization loại bỏ.
Điều kiện để devirtualize xảy ra
Devirtualization chỉ xảy ra khi compiler chứng minh được kiểu động, thường trong phạm vi một hàm:
- Được:
var h Iface = KieuCuThe{...}rồih.M()ngay sau — kiểu tĩnh biết. - Được: gán một giá trị kiểu cụ thể vào biến interface rồi gọi trong cùng hàm.
- Không: slice/map trộn nhiều kiểu (
mau[i].M()), kiểu động thay đổi lúc chạy. - Thường không: tham số interface truyền từ hàm khác — kiểu động bị ẩn khỏi tầm nhìn của compiler.
Ứng dụng thực tế
Đừng "tối ưu sớm" bằng cách né interface. Nếu bạn dùng interface nhưng kiểu cụ thể thường biết tĩnh tại chỗ gọi, compiler tự devirtualize — bạn được cả tính trừu tượng lẫn tốc độ. Viết code sạch với interface trước, để compiler lo.
Devirtualization giải thích benchmark interface "quá nhanh". Nếu benchmark một lời gọi interface cho kết quả gần bằng gọi trực tiếp, rất có thể compiler đã devirtualize vì kiểu cụ thể biết tĩnh trong vòng lặp benchmark — không phản ánh chi phí dispatch thật ở code production nơi kiểu động thay đổi. Muốn đo dispatch thật, dùng slice trộn kiểu (như Dynamic2).
PGO (profile-guided optimization) mở rộng devirtualization. Từ Go 1.21, PGO có thể devirtualize cả những lời gọi mà profile cho thấy hầu hết thời gian là một kiểu cụ thể — chèn một kiểm tra kiểu rồi gọi trực tiếp cho nhánh nóng. Đây là devirtualization "suy đoán", vượt qua giới hạn "phải chứng minh tĩnh".
Đánh đổi cần cân nhắc
Devirtualization là quà tặng, không phải bảo đảm. Bạn không điều khiển trực tiếp được nó (không có directive //go:devirtualize). Nó phụ thuộc compiler chứng minh được kiểu — mà điều đó dễ vỡ: thêm một nhánh khiến kiểu không còn chắc chắn là mất devirtualize. Đừng thiết kế hiệu năng dựa vào nó ở code quan trọng; nếu cần chắc chắn không dispatch, dùng kiểu cụ thể hoặc generics.
Generics cho đa hình không-dispatch một cách tường minh. Nếu bạn muốn đảm bảo không có dispatch động (thay vì hi vọng devirtualize), generics giữ kiểu tĩnh xuyên suốt — compiler sinh mã riêng cho từng kiểu, không itab. Đây là lựa chọn chắc chắn hơn khi kiểu biết lúc biên dịch; interface + devirtualization là cho khi bạn cần linh hoạt runtime nhưng may mắn kiểu biết tĩnh tại chỗ.
Dispatch động không phải kẻ thù. ~1,8 ns cho một lời gọi động là rất rẻ tuyệt đối. Devirtualization đáng để ý ở vòng cực nóng gọi hàng trăm triệu lần, nhưng ở code thường, tính linh hoạt của interface đáng giá hơn ~1,3 ns tiết kiệm được. Đo trước khi tối ưu.
Ba ý mang về
- Devirtualization thay lời gọi interface bằng lời gọi trực tiếp khi kiểu động biết tĩnh, rồi thường inline luôn: đo thật, assembly của một lời gọi interface co xuống còn một lệnh
ADD(không itab, khôngCALL) — xác nhận bằng-gcflags=-min dòng "devirtualizing". - Devirtualize được nhanh hơn dispatch động ~3,8 lần (0,47 so 1,80 ns): chênh lệch chính là chi phí tra itab + nhảy gián tiếp mà nó loại bỏ — nhưng chỉ xảy ra khi compiler chứng minh được kiểu (thường trong một hàm, sau phép gán kiểu cụ thể).
- Devirtualization là quà tặng dễ vỡ, không phải bảo đảm: không có directive điều khiển, và thêm một nhánh có thể làm mất nó — nếu cần chắc chắn không dispatch, dùng generics; interface + devirtualize là cho khi cần linh hoạt runtime mà kiểu may mắn biết tĩnh.
Bài tập làm thử
Bài 1 (đọc hiểu). Hai hàm sau đều gọi h.Dientich() qua interface Hinh. Hàm nào được compiler devirtualize, hàm nào phải giữ dispatch động thật? Giải thích rõ điều kiện quyết định.
func dungKieuCuThe() float64 {
var h Hinh = Vuong{3}
return h.Dientich()
}
var mau = []Hinh{Vuong{2}, Tron{3}}
func khongBiet() float64 {
i = (i + 1) & 1
return mau[i].Dientich()
}
Đáp án
dungKieuCuThe được devirtualize: compiler chứng minh được h chắc chắn là kiểu Vuong (gán ngay trước khi gọi, trong cùng hàm), nên thay dispatch động bằng gọi trực tiếp Vuong.Dientich, thường rồi inline luôn. khongBiet phải giữ dispatch động thật: mau[i] là một slice trộn nhiều kiểu (Vuong, Tron) và i thay đổi mỗi lần gọi, nên compiler không thể chứng minh tĩnh kiểu động nào sẽ được truy cập.
Bài 2 (công cụ). Nêu hai bằng chứng cụ thể (một từ cờ compiler, một từ assembly) mà bài dùng để xác nhận một lời gọi interface đã được devirtualize và inline thành công.
Đáp án
Bằng chứng 1 — chạy go build -gcflags=-m thấy hai dòng: devirtualizing x.Gia to A2 (xác nhận thay dispatch động bằng gọi trực tiếp) và inlining call to A2.Gia (xác nhận sau đó còn được inline). Bằng chứng 2 — xem assembly (-gcflags=-S): hàm chỉ còn ADD $1, R0, R0 rồi RET, không có việc nạp itab và không có lệnh CALL gián tiếp nào — toàn bộ lời gọi phương thức qua interface co lại thành đúng một phép cộng.
Bài 3 (vận dụng thực tế). Một đồng nghiệp viết benchmark sau và thấy lời gọi qua interface "gần như miễn phí", kết luận rằng interface trong Go không có chi phí dispatch đáng kể:
func BenchmarkGoi(b *testing.B) {
var h Hinh = Vuong{3}
for i := 0; i < b.N; i++ {
_ = h.Dientich()
}
}
Kết luận này có đáng tin cho code production không? Vì sao, và nên sửa benchmark thế nào để đo đúng chi phí dispatch động thật?
Đáp án
Không đáng tin. Vì h được gán một kiểu cụ thể (Vuong{3}) ngay trong cùng hàm rồi gọi trong vòng lặp, compiler rất có thể đã devirtualize lời gọi này — kết quả đo phản ánh một phép cộng trực tiếp, không phải chi phí dispatch động thật mà code production (nơi kiểu động thường thay đổi qua nhiều lời gọi khác nhau) sẽ gặp. Muốn đo dispatch thật, phải dùng một slice/cấu trúc trộn nhiều kiểu cụ thể và chỉ số thay đổi mỗi vòng (giống mẫu Dynamic2 trong bài, dùng mau[i].Dientich() với i biến đổi), để compiler không thể chứng minh tĩnh kiểu động.
Bài 4 (bẫy/đánh đổi). Bài viết nói devirtualization là "quà tặng, không phải bảo đảm" và "dễ vỡ". Giải thích ý này, và nêu giải pháp thay thế khi cần chắc chắn không có dispatch động (thay vì hi vọng compiler tự devirtualize).
Đáp án
Không có directive nào (như //go:devirtualize) để chủ động ép compiler thực hiện tối ưu này — nó chỉ xảy ra khi compiler tự chứng minh được kiểu tĩnh, và điều kiện đó rất dễ mất: chỉ cần thêm một nhánh khiến biến interface có thể mang nhiều kiểu khác nhau là kiểu không còn "chắc chắn" nữa, devirtualize biến mất mà không có cảnh báo. Nếu code quan trọng cần đảm bảo không dispatch động, giải pháp chắc chắn hơn là dùng generics: compiler sinh mã riêng cho từng kiểu cụ thể, giữ kiểu tĩnh xuyên suốt mà không phụ thuộc vào việc chứng minh của compiler.
Bài 5 (đọc hiểu số liệu). Bài đo được Devirt2 (kiểu cụ thể biết tĩnh) ~0,4707 ns/op so với Dynamic2 (kiểu đổi lúc chạy, dispatch động thật) ~1,796 ns/op. Tính tỉ lệ chênh lệch xấp xỉ, và cho biết phần chênh lệch đó đến từ đâu.
Đáp án
Tỉ lệ ≈ 1,796 / 0,4707 ≈ 3,8 lần. Phần chênh lệch chính là chi phí của dispatch động thật mà devirtualization loại bỏ được: tra bảng itab để tìm địa chỉ phương thức đúng, rồi thực hiện một lời gọi gián tiếp (nhảy qua con trỏ hàm) — so với việc gọi thẳng một địa chỉ đã biết tĩnh (và ở đây còn được inline thành một phép cộng đơn).
Phần sau ta xem chính công cụ compiler dùng để làm mọi tối ưu này — biểu diễn trung gian: Phần sau mổ xẻ SSA (Static Single Assignment) — cách xem biểu diễn trung gian của Go, đọc các pha tối ưu, và hiểu compiler "suy nghĩ" thế nào về code của bạn.