Bài trước ta đo được generics nhanh hơn interface any khoảng 25% cho phép tổng. Nhưng đó là một góc hẹp. Câu hỏi kỹ sư thực sự phải trả lời là: generics cài đặt thế nào ở tầng mã máy, và khi nào cái giá của nó lộ ra? Câu trả lời nằm ở hai khái niệm Go dùng để dịch generics: GC shape stenciling và dictionary. Hiểu hai thứ này, bạn thôi hỏi "generics hay interface nhanh hơn" một cách chung chung và bắt đầu đoán đúng cái nào thắng trong từng tình huống.

Go dịch generics thế nào

Có hai thái cực khi cài đặt generics. C++ dùng monomorphization: mỗi kiểu cụ thể sinh một bản mã hoàn toàn riêng — nhanh nhất nhưng binary phình to. Java dùng type erasure: một bản mã duy nhất, mọi thứ là Object, boxing khắp nơi — gọn nhưng chậm. Go chọn đường giữa gọi là GC shape stenciling: các kiểu có cùng "hình dạng bộ nhớ" (GC shape) dùng chung một bản mã (stencil), và runtime truyền vào một dictionary để phân biệt kiểu cụ thể khi cần.

"GC shape" là cách bộ thu gom rác nhìn một kiểu: nó lớn bao nhiêu byte, và những byte nào là con trỏ. Mọi kiểu con trỏ (*int, *string, *User) có cùng shape — 8 byte, toàn bộ là một con trỏ — nên chúng gộp chung một stencil. Còn int và float64 có shape khác nhau (không con trỏ, nhưng compiler sinh mã số học khác) nên mỗi kiểu một stencil riêng.

func Nhat[T any](xs []T) T { return xs[0] }

Nhat([]int{...})      // stencil riêng cho int
Nhat([]float64{...})  // stencil riêng cho float64
Nhat([]*int{...})     // *int    ┐ chung một stencil
Nhat([]*string{...})  // *string ┘ go.shape.*uint8

Ảnh chụp đoạn mã Go nền tối minh hoạ generics vs interface chi phí ở stencil và dictionary, ba cách một hàm nhiều kiểu, cách 1 generic trên kiểu giá trị func CongGen T So nhận a b T trả a cộng b compiler biết T lúc biên dịch, cách 2 interface đa hình động qua itab type Hinh interface DienTich trả float64 var h Hinh bằng HinhVuong gọi h.DienTich qua itab, cách 3 generic gọi method qua constraint đi đường dictionary func GopGen T Cong T nhận slice xs cộng dồn acc.Cong x, GC shape stenciling kiểu con trỏ dùng chung mã func Nhat T any nhận slice xs trả xs 0, Nhat của slice int stencil riêng cho int Nhat của slice float64 stencil riêng Nhat của slice con trỏ int và con trỏ string dùng chung một stencil go.shape con trỏ uint8, mọi con trỏ cùng hình dạng bộ nhớ nên một bản mã runtime truyền dictionary để phân biệt kiểu cụ thể, cốt lõi stencil là bản mã máy sinh theo GC shape của T kiểu giá trị mỗi kiểu một stencil pointer mọi con trỏ chung một stencil dictionary bảng runtime phân biệt kiểu thật interface đa hình động gọi method qua itab lúc chạy

Hình 1: Ba đường thực thi — generic value, interface qua itab, generic method qua dictionary. Với Nhat, kiểu giá trị sinh stencil riêng còn mọi con trỏ gộp vào go.shape.*uint8; runtime truyền dictionary để biết kiểu thật.

Bằng chứng: soi bảng ký hiệu

Đây không phải lý thuyết — nó nằm ngay trong binary. Biên dịch Nhat gọi với bốn kiểu (int, float64, *int, *string) rồi soi bằng go tool nm:

# Stencil mã máy — 4 kiểu nhưng CHỈ 3 bản:
main.Nhat[go.shape.*uint8]    // *int VÀ *string chung một bản
main.Nhat[go.shape.float64]
main.Nhat[go.shape.int]

# Dictionary — 4 bản, mỗi kiểu cụ thể một cái:
main..dict.Nhat[*int]      main..dict.Nhat[*string]
main..dict.Nhat[float64]   main..dict.Nhat[int]

Bốn kiểu chỉ sinh ba stencil vì *int và *string cùng shape go.shape.*uint8. Nhưng có bốn dictionary — mỗi kiểu cụ thể một cái. Dictionary là bảng mà runtime truyền vào lúc gọi, chứa thông tin kiểu thật (con trỏ tới *int thật, *string thật) để stencil dùng chung biết đang làm việc với kiểu nào khi cần. Đây chính là cái giá của kiểu con trỏ: một lớp gián tiếp qua dictionary mà kiểu giá trị (sinh mã riêng) không phải trả.

Đo thật: bốn đường thực thi

Benchmark bốn cách, cùng go-lab (Go 1.23, arm64):

func CongGen[T So](a, b T) T { return a + b }        // generic value
var h Hinh = HinhVuong{2}; h.DienTich()              // interface dispatch
func GopGen[T Cong[T]](xs []T) T { /* acc.Cong(x) */ }  // generic method (dict)
func GopIface(xs []Congg) Congg { /* x.CongI(...) */ }  // interface method

Ảnh chụp bảng kết quả đo thật nền tối generic method nhanh gần gấp đôi interface con trỏ chung stencil, go test bench benchmem cộng go tool nm Go 1.23 arm64 10 core, benchmark bốn đường thực thi Generic value CongGen phép cộng 0,5925 ns 0 alloc, Interface dispatch itab đơn hình 0,5284 ns 0 alloc, Generic method dictionary GopGen 4,065 ns 0 alloc, Interface method itab cộng type assert 8,108 ns 0 alloc, cộng đơn generic value và interface đơn hình gần ngang nhau khoảng 0,5 ns cả hai inline hoặc devirtualize được, gọi method lặp generic 4,07 ns nhanh khoảng 2 lần interface 8,11 ns vì interface còn phải type assert trong vòng, GC shape stenciling bằng chứng từ bảng ký hiệu stencil mã máy 4 kiểu chỉ 3 bản Nhat go.shape con trỏ uint8 con trỏ int và con trỏ string chung Nhat go.shape float64 riêng Nhat go.shape int riêng, dictionary 4 bản mỗi kiểu cụ thể một cái dict Nhat con trỏ int dict Nhat con trỏ string dict Nhat float64 dict Nhat int, cốt lõi 4 kiểu 3 stencil mọi con trỏ gộp vào go.shape con trỏ uint8 4 dictionary runtime truyền phân biệt kiểu thật kiểu con trỏ có 1 lớp gián tiếp qua dict chậm hơn generic value nhanh nhất compiler biết kiểu tĩnh

Hình 2: Cộng đơn giản — generic value (0,5925 ns) và interface đơn-hình (0,5284 ns) gần ngang nhau. Gọi method lặp — generic qua dictionary (4,065 ns) nhanh gần 2x interface method (8,108 ns). Tất cả 0 cấp phát.

Hai điều rút ra từ số liệu thật:

Với phép toán đơn giản, cả hai gần ngang nhau (~0,5 ns). CongGen và HinhVuong.DienTich() đều rơi vào vùng compiler tối ưu tốt: generic value được sinh mã trực tiếp, còn interface đơn-hình (chỉ một kiểu cụ thể trong vòng lặp) được devirtualize — compiler nhận ra kiểu cụ thể và gọi thẳng, bỏ qua itab. Ở quy mô này khác biệt là nhiễu.

Với vòng lặp gọi method, generic thắng gần gấp đôi. GopGen (4,065 ns) so với GopIface (8,108 ns). Lý do trung thực: bản interface phải làm o.(DiemI) — một type assertion — cho mỗi lần cộng bên trong vòng, còn bản generic giữ kiểu tĩnh nên không cần. Đây là điểm cần thành thật: so sánh này có lợi cho generic vì bài toán "gộp cùng kiểu" đúng là thế mạnh của nó. Nếu bạn thật sự cần chứa nhiều kiểu khác nhau, interface là bắt buộc và không có gì để so.

Đánh đổi cần cân nhắc

Kiểu con trỏ trong generic không miễn phí. Vì mọi con trỏ chung một stencil và phân biệt qua dictionary, một hàm generic thao tác nhiều trên *T có thêm lớp gián tiếp mà phiên bản viết tay cho kiểu cụ thể không có. Trong vài trường hợp cực nóng, generic trên con trỏ có thể chậm hơn interface được devirtualize tốt. Luôn benchmark chính bài toán của bạn, đừng suy từ "generics luôn nhanh hơn".

Binary lớn hơn theo số shape, không theo số kiểu. Vì kiểu cùng shape chung mã, dùng generic với 10 kiểu con trỏ khác nhau chỉ sinh một stencil — binary không phình như C++ monomorphization. Nhưng mỗi kiểu giá trị riêng (int8, int16, int32, int64, float32, float64...) vẫn sinh bản riêng. Generic trên nhiều kiểu số vẫn làm binary lớn lên.

Interface vẫn là công cụ đúng cho đa hình động thật. Khi kiểu chỉ biết lúc chạy (plugin, decode JSON vào any, danh sách heterogeneous), interface là câu trả lời — generics không giải được bài đó vì nó cần biết kiểu lúc biên dịch. Chọn generics khi tập kiểu đóng và biết trước; chọn interface khi cần mở rộng động.

Ba ý mang về

  1. Go dịch generics bằng GC shape stenciling: kiểu cùng "hình dạng bộ nhớ" chung một bản mã, phân biệt bằng dictionary lúc chạy — đo thật từ bảng ký hiệu, bốn kiểu (int, float64, *int, *string) chỉ sinh ba stencil vì hai con trỏ gộp vào go.shape.*uint8, nhưng có bốn dictionary.
  2. Chi phí thật của generic nằm ở kiểu con trỏ: chúng đi qua một lớp gián tiếp dictionary mà kiểu giá trị (sinh mã riêng) không phải trả — nên "generics luôn nhanh hơn interface" là sai, phải benchmark từng ca.
  3. Đo thật cho thấy tùy bài toán: phép cộng đơn giản generic value và interface đơn-hình gần ngang (~0,5 ns nhờ devirtualize), nhưng vòng lặp gọi method thì generic (4,065 ns) nhanh gần 2x interface (8,108 ns) vì tránh được type assertion — chọn generics khi biết kiểu, interface khi cần đa hình động.

Bài tập làm thử

Bài 1 (đọc hiểu). Bài đo được: biên dịch hàm Nhat[T any](xs []T) T gọi với bốn kiểu int, float64, *int, *string chỉ sinh ba stencil mã máy, nhưng có bốn dictionary. Giải thích tại sao số stencil ít hơn số kiểu, và dictionary dùng để làm gì.

Đáp án

Số stencil ít hơn vì Go dùng GC shape stenciling: các kiểu có cùng "hình dạng bộ nhớ" (GC shape — kích thước byte và vị trí byte nào là con trỏ) dùng chung một bản mã. *int và *string đều là con trỏ 8 byte, cùng shape go.shape.*uint8, nên chúng gộp chung một stencil; còn int và float64 có shape khác nhau nên mỗi kiểu một stencil riêng — tổng cộng 3 stencil cho 4 kiểu. Dictionary là bảng runtime truyền vào lúc gọi hàm, chứa thông tin về kiểu cụ thể thật (ví dụ đây đang là *int hay *string) để cùng một stencil dùng chung có thể phân biệt và xử lý đúng kiểu khi cần — vì vậy cần đủ 4 dictionary, một cho mỗi kiểu cụ thể, dù chỉ có 3 stencil.

Bài 2 (đọc số liệu đo thật). Bài đo bốn đường thực thi: generic value (0,5925 ns), interface dispatch đơn hình (0,5284 ns), generic method qua dictionary (4,065 ns), interface method (8,108 ns) — tất cả 0 cấp phát. Giải thích tại sao ở phép cộng đơn giản hai cách gần ngang nhau, nhưng ở vòng lặp gọi method thì generic nhanh gần gấp đôi.

Đáp án

Ở phép cộng đơn giản, cả hai đều rơi vào vùng compiler tối ưu tốt: generic value được sinh mã trực tiếp cho kiểu cụ thể, còn interface đơn-hình (chỉ một kiểu cụ thể xuất hiện trong vòng lặp) được compiler devirtualize — nhận ra kiểu cụ thể và gọi thẳng, bỏ qua bảng itab — nên khác biệt chỉ là nhiễu đo lường (~0,5 ns cả hai). Ở vòng lặp gọi method, bản interface phải làm một type assertion (o.(DiemI)) cho mỗi lần cộng bên trong vòng lặp, còn bản generic giữ kiểu tĩnh nên không cần bước đó — chi phí type assertion lặp lại nhiều lần khiến interface (8,108 ns) chậm gần gấp đôi generic (4,065 ns).

Bài 3 (vận dụng thực tế). Bạn viết một hàm generic func XuLy[T *NodeA | *NodeB](x T) { ... } xử lý nhiều thao tác lặp lại trên con trỏ trong một vòng lặp cực nóng. Dựa theo bài học về dictionary, bạn có nên kỳ vọng nó nhanh hơn một cách viết tay chuyên biệt cho từng kiểu con trỏ không? Giải thích.

Đáp án

Không nên kỳ vọng chắc chắn nó nhanh hơn. Vì *NodeA và *NodeB đều là con trỏ, chúng dùng chung một stencil và phải đi qua một lớp gián tiếp dictionary để phân biệt kiểu cụ thể lúc chạy — chi phí này mà một hàm viết tay chuyên biệt cho từng kiểu con trỏ cụ thể (biết chính xác kiểu, không cần dictionary) không phải trả. Bài học nói rõ: "trong vài trường hợp cực nóng, generic trên con trỏ có thể chậm hơn interface được devirtualize tốt" — nên nguyên tắc đúng là luôn benchmark chính bài toán của mình, không suy diễn "generics luôn nhanh hơn".

Bài 4 (đọc hiểu khái niệm). Giải thích ngắn gọn ba chiến lược cài đặt generics được nhắc đến (C++ monomorphization, Java type erasure, Go GC shape stenciling) và điểm mạnh/yếu chính của mỗi cái.

Đáp án
  • C++ monomorphization: mỗi kiểu cụ thể sinh một bản mã hoàn toàn riêng — nhanh nhất (không có lớp gián tiếp nào) nhưng khiến binary phình to theo số kiểu sử dụng.
  • Java type erasure: chỉ có một bản mã duy nhất, mọi thứ xử lý như Object, cần boxing khắp nơi — binary/bytecode gọn nhưng chậm hơn vì luôn phải box/unbox và không tận dụng được kiểu cụ thể.
  • Go GC shape stenciling: đường giữa — các kiểu cùng "hình dạng bộ nhớ" (GC shape) dùng chung một bản mã (stencil), phân biệt kiểu cụ thể qua dictionary truyền vào lúc chạy. Cân bằng giữa kích thước binary (không phình như C++) và tốc độ (nhanh hơn type erasure của Java vì kiểu giá trị vẫn có mã riêng), nhưng kiểu con trỏ phải trả thêm phí gián tiếp qua dictionary.

Bài 5 (bẫy/đánh đổi). Bài viết nói: "binary lớn hơn theo số shape, không theo số kiểu." Áp dụng nguyên tắc này, nếu một hàm generic được gọi với 10 kiểu số khác nhau (int8, int16, int32, int64, uint8, ..., float32, float64), binary có phình to như C++ monomorphization không? Còn nếu gọi với 10 kiểu con trỏ khác nhau (*TypeA, *TypeB, ..., *TypeJ) thì sao?

Đáp án

Với 10 kiểu số khác nhau: có, binary vẫn phình vì mỗi kiểu số (int8, int16, int32, int64, các kiểu uint, float32, float64...) có GC shape khác nhau (kích thước byte khác nhau hoặc cách xử lý số học khác nhau), nên mỗi kiểu vẫn sinh một stencil riêng — giống monomorphization ở khía cạnh này. Với 10 kiểu con trỏ khác nhau: không phình tương ứng, vì mọi con trỏ đều có cùng GC shape (8 byte, toàn bộ là con trỏ) nên chỉ sinh một stencil chung (go.shape.*uint8) cho cả 10 kiểu, dù vẫn cần 10 dictionary riêng để phân biệt lúc chạy. Đây chính là điểm khác biệt cốt lõi giữa Go và C++ monomorphization đối với kiểu con trỏ.

Phần sau ta chuyển sang một tính năng mới cùng thời generics đang định hình lại cách viết vòng lặp: Phần sau mổ xẻ iterator range-over-func — cơ chế for range trên hàm, chi phí, và khi nào nó thay được channel.