Historia pytania
Historycznie, dyskryminowane unie w programowaniu systemowym wymagały jawnych pól znaczników lub ręcznego układu pamięci w celu odróżnienia wariantów. Swift ewoluował z braku bezpiecznych unii w Objective-C, co wymagało podejścia zarządzanego przez kompilator do układu enum, które gwarantowało bezpieczeństwo typów przy maksymalizacji efektywności pamięci. Wczesne wersje Swift już optymalizowały jednolity enum (jak Optional) przy użyciu dodatkowych mieszkańców, ale wielopłatne scenariusze wymagały bardziej zaawansowanej analizy na poziomie bitów, aby uniknąć wzrostu pamięci związanego z naiwnymi prefiksami bajtów znaczników.
Problem
Kiedy enum zawiera wiele przypadków z różnymi powiązanymi typami płatności (np. case text(String), number(Int), data([UInt8])), kompilator musi przechowywać wystarczające informacje, aby określić, który przypadek jest aktywny podczas dopasowywania wzorców w czasie wykonywania. Proste dodanie bajtu dyskryminacyjnego znacznie zwiększa całkowity rozmiar, szczególnie dla małych płatności, i łamie zgodność ABI z unijami w stylu C, gdzie krytyczne jest obciążenie pamięci. Wyzwanie polega na wykorzystaniu nieużywanych wzorców bitów w samych typach płatności (wolne bity), aby zakodować dyskryminator przypadku bez powiększania całkowitego rozmiaru alokacji.
Rozwiązanie
Swift wykorzystuje strategię układu wielopłatnego enum, która najpierw oblicza przecięcie nieużywanych wzorców bitów (wolnych bitów) wśród wszystkich typów płatności. Jeśli istnieje wystarczająca ilość wolnych bitów — na przykład, gdy String wykorzystuje swoje bity optymalizacji małych łańcuchów lub typy referencyjne wykorzystują przerwy w wyrównaniu wskaźników — kompilator przechowuje tag przypadku bezpośrednio w tych bitach, utrzymując rozmiar największej płatności. Gdy typy płatności wyczerpują dostępne wolne bity (np. dwa płatności Int64 bez przerwy w wyrównaniu), kompilator ucieka się do dodania dodatkowego bajtu (lub słowa) jako dyskryminanta, zapewniając jednoznaczną identyfikację przypadków, minimalizując jednocześnie obciążenie dzięki zachłannym heurystykom pakowania bitów.
Opis problemu
Podczas rozwijania parsera pakietów o wysokiej przepustowości dla klienta gry czasu rzeczywistego, zespół zdefiniował enum Packet z przypadkami ping(Int64), payload(Data) i error(UInt8). Profilowanie ujawniło, że obciążenie pamięci enum przekraczało linię cache L1 z powodu domyślnego pola dyskryminatora, co powodowało szum pamięci podczas przetwarzania wsadów pakietów i zwiększało opóźnienie ponad budżet ramki 16ms.
Rozważane różne rozwiązania
Rozwiązanie 1: Ręczna unia z surowymi bajtami
Zespół rozważał użycie UnsafeMutablePointer do ręcznego nałożenia płatności w struct z osobnym tagiem, naśladując unie C. To podejście oferowało zerowe obciążenie z tytułu rozróżnienia przypadków, ale poświęcało bezpieczeństwo typów Swift i wymagało ręcznego zarządzania pamięcią, zwiększając ryzyko błędów use-after-free podczas obsługi asynchronicznych wywołań zwrotnych sieci. Dodatkowo, to rozwiązanie łamało integrację ARC, wymagając ręcznych wywołań retain/release dla płatności z licznikiem odniesień, takich jak Data.
Rozwiązanie 2: Typ erozji opartego na protokole
Inne podejście polegało na zastąpieniu enum protokołem Packet i użyciu kontenerów egzystencjalnych (any Packet) lub generyków. Chociaż to zachowało abstrakcję, wprowadziło alokację na stercie dla każdego pakietu z powodu pakowania kontenerów egzystencjalnych i obciążenia przy dispatchu metod wirtualnych. Pogorszenie wydajności było nie do przyjęcia dla ścieżki gorącej, ponieważ podwajało wskaźnik alokacji i zwiększało presję na zbieranie śmieci w czasie wykonywania Swift.
Wybrane rozwiązanie
Zespół przekształcił enum, aby wykorzystać optymalizację wielopłatności Swift, przestawiając przypadki i używając typów płatności z wbudowanymi wolnymi bitami. Zastąpili Int64 niestandardową strukturą UInt56 (gdzie najwyższy bajt był zarezerwowany) i zapewnili, że error używa UInt32 zamiast UInt8, aby dostosować się do większych wzorców wolnych bitów płatności. To umożliwiło kompilatorowi spakowanie dyskryminatora przypadku w wolne bity Data i UInt56, eliminując dodatkowy bajt i zmniejszając rozmiar enum z 24 bajtów do 16 bajtów.
Wynik
Optymalizacja umożliwiła parserowi pakietów przetwarzanie wsadów w obrębie pojedynczej linii cache, co zmniejszyło opóźnienie ramek o 40% i wyeliminowało koszt alokacji pamięci dla samego enum. Kod zachował pełne bezpieczeństwo typów i możliwości dopasowywania wzorców bez uciekania się do niebezpiecznych wskaźników lub erozji typu protokołu.
Jak strategia układu enum Swift wpływa na interoperacyjność z C podczas importowania unii z nagłówków?
Kiedy Swift importuje unię C za pomocą nagłówków Clang, traktuje typ jako enum z jednym przypadkiem zawierającym krotkę wszystkich członków unii, lub używa @_NonBitwise, jeśli jest oznaczony w ten sposób. Jednakże Swift nie może zastosować swojej optymalizacji wolnych bitów wielopłatnej unii do importowanych unii C, ponieważ unie C nie mają metadanych typów Swift i gwarancji wyraźnej inicjalizacji. Kompilator musi założyć, że każdy wzór bitowy jest ważny dla unii C, co uniemożliwia wykorzystanie wolnych bitów do dyskryminacji przypadków. Kandydaci często błędnie zakładają, że Swift przestawia pola unii C lub dodaje domyślne znaczniki; zamiast tego Swift dokładnie zachowuje układ C i wymaga jawnego zarządzania przez wzorce OptionSet lub ręczne opakowanie struct, aby uzyskać korzyści z optymalizacji enum Swift.
Dlaczego dodanie nowego przypadku do odpornych wielopłatnych enum czasami zmusza kompilator do całkowitego porzucenia optymalizacji wolnych bitów?
Odpornym modułom (kompilowanym z włączoną ewolucją biblioteki) musi być zachowana stabilność ABI, co oznacza, że układ enum nie może się zmieniać w sposób łamiący zgodność binarną. Jeżeli nowy przypadek zostanie dodany do wielopłatnego enum w przyszłej wersji biblioteki, a nowy typ płatności zarezerwuje ostatni dostępny wolny bit, kompilator musi się cofnąć do jawnego bajtu dyskryminacyjnego, aby pomieścić rozszerzoną przestrzeń przypadków. Ponieważ oryginalny układ został zamrożony w metadanych odpornego modułu, kompilator nie może wstecznie odzyskać bitów z istniejących płatności. Kandydaci często przeoczyli, że granice odporności zamrażają nie tylko publiczny interfejs, ale także wewnętrzne heurystyki układu bitów, co często wymaga ręcznych atrybutów @frozen dla wydajnych enum, aby zapewnić, że optymalizacja wolnych bitów utrzymuje się przez wersje.
W jakich warunkach kompilator używa "dodatkowego mieszkańca" w porównaniu do "wolnego bitu" do dyskryminacji przypadków i jak wpływa to na wyrównanie pamięci enum?
Dodatkowi mieszkańcy odnoszą się do nieprawidłowych wzorców bitowych w obrębie jednego typu (jak wskaźniki nil w typach referencyjnych lub brak przypadku Optional), podczas gdy wolne bity to nieużywane wzorce bitów współdzielone między wieloma typami płatności w wielopłatnym enum. Dla jednolitych enum kompilator używa dodatkowych mieszkańców płatności do reprezentowania innych przypadków bez dodatkowego magazynowania. Dla wielopłatnych enum kompilator oblicza przecięcie wolnych bitów wśród wszystkich płatności. Ograniczenia wyrównania komplikują to: jeśli wolne bity istnieją w różnych przesunięciach w różnych płatnościach, kompilator może potrzebować dodać wypełnienie lub użyć tagu przepełnienia, aby wyrównać dyskryminatora w sposób spójny. Kandydaci często mylą te dwa pojęcia, nie zdając sobie sprawy, że dodatkowi mieszkańcy optymalizują scenariusze jednolite (jak Optional<T>), podczas gdy wolne bity optymalizują scenariusze wielopłatne, a ich mieszanie wymaga starannego rozważenia wymagań dotyczących wyrównania największej płatności.