Kompilator Go stosuje technikę zwaną GCshape stenciling, gdy kompiluje generiki wprowadzony w wersji 1.18. Historycznie, języki implementowały generiki poprzez pełną monomorfizację — generując osobny kod maszynowy dla każdej instancji typu, co prowadziło do nadmiaru binarnego — lub poprzez opakowywanie — usuwając typy kosztem narzutu czasowego i alokacji. Problem, z którym zmagał się Go, polegał na wspieraniu programowania systemowego o wysokiej wydajności, gdzie rozmiar binarny ma znaczenie, bez całkowitego poświęcania prędkości wykonywania.
Rozwiązanie polega na grupowaniu konkretnych typów według ich kształtu GC, określonego przez ich rozmiar i bitmapę wskaźników (wzór wskaźników w typie). Kompilator generuje jedną instancję funkcji dla wszystkich typów dzielących ten sam kształt GC, przekazując zestaw danych uruchomieniowych jako niejawny parametr zawierający metadane typu.
// Zarówno *int, jak i *string dzielą tę samą instancję // ponieważ mają identyczny kształt GC (pojedynczy wskaźnik). func Identity[T any](x T) T { return x } func main() { Identity((*int)(nil)) // Używa instancji #1 Identity((*string)(nil)) // Używa instancji #1 (ten sam kształt) Identity(42) // Używa instancji #2 (skalar, brak wskaźników) }
Nasz zespół budował wydajny pipeline do przetwarzania zdarzeń, używając ogólnych handlerów middleware Handler[T Event]. Musieliśmy przetworzyć pięćdziesiąt różnych typów zdarzeń przy zachowaniu niskiej latencji i rozsądnego rozmiaru binarnego dla kontenerowych wdrożeń.
Pierwsze podejście wykorzystało interface{} z asercjami typów, polegając na runtime'owych przełącznikach typów. Dało to elastyczność i działało w starszych wersjach Go, ale wprowadziło znaczną nadmierną alokację — każde zdarzenie opakowane w interfejs wymagało alokacji na stercie — oraz wyeliminowało bezpieczeństwo typów w czasie kompilacji, prowadząc do paniki w produkcji w przypadku niezgodności typów.
Drugie podejście polegało na generacji kodu w czasie kompilacji przy użyciu go generate z narzędziami firm trzecich do stworzenia HandlerClickEvent, HandlerPurchaseEvent itd. To zapewniło optymalną wydajność bez narzutu czasowego, ale powiększyło nasz rozmiar binarny o 40MB przy wsparciu pięćdziesięciu typów zdarzeń, i stworzyło koszmary w utrzymaniu podczas aktualizacji szablonów generatora.
Wybraliśmy trzecie podejście: natywne Go generiki z starannym zwróceniem uwagi na kształty GC. Upewniliśmy się, że nasze typy zdarzeń to wskaźniki do struktur (jednolity kształt GC), co pozwoliło kompilatorowi ponownie wykorzystać instancje. Przyjęliśmy niewielki narzut na wyszukiwanie słowników dla wywołań metod w zamian za zwiększenie rozmiaru binarnego o zaledwie 2MB. Rezultatem była redukcja latencji o 15% w porównaniu do interface{} oraz znośny rozmiar binarny w porównaniu do pełnej generacji kodu.
Jak słownik uruchomieniowy dostarcza informacje specyficzne dla typu do wspólnych instancji ogólnych?
Słownik to struktura zawierająca wskaźniki do deskryptorów typów (_type), tabel metod (itab) i metadanych GC. Gdy kompilator generuje kod dla funkcji ogólnej jak func Print[T any](x T), przekazuje słownik jako niejawny pierwszy argument. Aby wywołać metodę x.String(), generowany kod wyszukuje wskaźnik metody w słowniku zamiast kompilować bezpośrednie wywołanie, umożliwiając temu samemu kodowi maszynowemu obsługę T=bytes.Buffer i T=strings.Builder mimo różnych implementacji metod.
Dlaczego dwa różne typy wskaźników mogą dzielić jedną instancję ogólną, podczas gdy ich typy elementów wymagają oddzielnych?
Go klasyfikuje typy według GCshape, biorąc pod uwagę tylko układ pamięci mający znaczenie dla zbieracza śmieci i alokatora. Zarówno *int, jak i *string składają się z pojedynczego słowa maszynowego zawierającego wskaźnik, umieszczając je w tej samej klasie kształtów. Z kolei int nie zawiera wskaźników i jest dopasowany do określonego rozmiaru, podczas gdy string to struktura składająca się z dwóch słów, zawierająca wskaźnik i długość. Ponieważ ich układy pamięci różnią się, wymagają oddzielnych ścieżek kodu generowanego aby zająć się właściwym zbieraniem śmieci i adresowaniem pamięci.
Jaki jest wpływ na wydajność używania odbiorników wartości w porównaniu do odbiorników wskaźników w ograniczeniach ogólnych?
Gdy funkcja ogólna wywołuje metodę na parametrze typu T, kompilator musi wygenerować kod, który działa dla dowolnego możliwego T. Jeśli ograniczenie wymaga odbiornika wartości func (T) Method(), ale konkretny typ jest duży, kompilator może być zmuszony do przekazywania słowników i wykonywania pośrednich wywołań, co uniemożliwia inline'owanie. Używanie odbiorników wskaźników func (*T) Method() często pozwala na lepszą optymalizację, ponieważ typy wskaźników częściej dzielą kształty GC, a kompilator może łatwiej zdepersonalizować wywołania, gdy konkretny typ znany jest w czasie kompilacji w określonych kontekstach instancji.