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
}

Ảnh chụp đoạn mã Go nền tối minh hoạ devirtualization khi lời gọi interface thành lời gọi thẳng, nếu compiler chứng minh được kiểu động sau interface lúc biên dịch nó thay dispatch động bằng lời gọi trực tiếp rồi inline luôn, một devirtualize được kiểu cụ thể biết tĩnh func dungKieuCuThe float64 var h Hinh bằng Vuong 3 compiler biết h là Vuong return h Dientich viết như gọi interface nhưng devirtualize thành Vuong Dientich rồi inline, hai không devirtualize kiểu động thay đổi lúc chạy var mau bằng Hinh Vuong 2 Tron 3 slice trộn kiểu func khongBiet float64 i bằng i cộng 1 và 1 return mau i Dientich kiểu động đổi mỗi vòng dispatch động thật, ba xác nhận gcflags trừ m devirtualizing x Gia to A2 thay dispatch động inlining call to A2 Gia rồi dán thẳng vào, bốn assembly dispatch biến mất var x Bd2 bằng A2 n return x Gia với Gia bằng x cộng 1 main devirtDuoc STEXT ADD 1 R0 R0 chỉ còn x cộng 1 RET không nạp itab không CALL gián tiếp hai bước gộp thành một phép cộng devirtualize cộng inline lời gọi phương thức qua interface co lại thành mã của chính phương thức đó không còn tra itab không nhảy gián tiếp

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):

Ảnh chụp bảng kết quả đo thật nền tối devirtualization tiết kiệm bao nhiêu go test bench phương thức Gia trả int tích luỹ integer Go 1.23 arm64 10 core, devirtualize cộng inline vs dispatch động thật Devirt2 kiểu cụ thể biết tĩnh 0,4707 ns/op devirtualize cộng inline Dynamic2 kiểu đổi lúc chạy 1,796 ns/op dispatch động thật tra itab cộng nhảy gián tiếp devirtualize cộng inline nhanh hơn khoảng 3,8 lần 0,47 so 1,80 ns khi biết kiểu cụ thể compiler bỏ hẳn tra itab và nhảy gián tiếp biến lời gọi thành mã trực tiếp inline được, điều kiện để devirtualize tình huống var h Iface bằng KieuCuThe M có kiểu tĩnh biết gán kiểu cụ thể rồi gọi trong cùng hàm có slice map trộn nhiều kiểu kiểu đổi lúc chạy không phải dispatch tham số interface từ hàm khác kiểu ẩn thường không devirtualize chỉ xảy ra khi compiler chứng minh được kiểu động thường trong phạm vi một hàm ngay sau một phép gán kiểu cụ thể, cốt lõi devirtualize thay dispatch động bằng gọi trực tiếp kiểu biết tĩnh cộng inline lời gọi phương thức co thành mã phương thức bài 41 xác nhận trừ m in devirtualizing assembly mất itab CALL đo thật 0,47 ns vs 1,80 ns khoảng 3,8x khi devirt được điều kiện kiểu động chứng minh được lúc biên dịch

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ồi h.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ề

  1. 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ông CALL) — xác nhận bằng -gcflags=-m in dòng "devirtualizing".
  2. 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ể).
  3. 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.

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.