Swift początkowo opierał się wyłącznie na kontenerach egzystencjalnych (aktualnie pisanych jako any) do abstrakcji protokołu, co wymagało pakowania typów wartościowych na stercie i wykorzystania tabel świadków do dynamicznego dyspozytora. W Swift 5.1 język wprowadził typy wynikowe o nieprzezroczystości za pomocą słowa kluczowego some w celu wdrożenia odwrotnych generyków, co pozwoliło funkcjom ukryć szczegóły implementacji, zachowując jednocześnie informacje o konkretnym typie dla kompilatora. Ta ewolucja rozwiązała kary wydajnościowe związane z usuwaniem typów — w szczególności alokację na stercie i utratę możliwości optymalizacji — bez poświęcania abstrakcji, przygotowując grunt pod wyraźne rozróżnienie typów egzystencjalnych i nieprzezroczystych w Swift 5.6.
Kontenery egzystencjalne (any) przechowują wartości przy użyciu reprezentacji trzech słów: bufora wartości inline (lub wskaźnika na alokację na stercie dla dużych typów), wskaźnika na tabelę świadka wartości oraz wskaźnika na tabelę świadka protokołu. Ten mechanizm pakowania wymusza alokację na stercie dla typów wartościowych i nakazuje dynamiczny dyspozytor dla wywołań metod, uniemożliwiając kompilatorowi przeprowadzanie specjalizacji lub inlining. W rezultacie kod używający any cierpi z powodu zwiększonego nacisku na pamięć, nadmiaru ARC oraz braków w pamięci podręcznej, co jest szczególnie szkodliwe w systemach o dużej przepustowości lub czasie rzeczywistym, gdzie krytyczna jest wydajność deterministyczna.
Typy nieprzezroczyste (some) wykorzystują podejście odwrotne do generyków, w którym konkretny typ jest znany kompilatorowi, ale ukryty przed wywołującym, eliminując potrzebę pakowania i umożliwiając alokację na stosie. Kompilator traktuje typy zwracane przez some podobnie do parametrów typów generycznych, przekazując metadane typu jako ukryty parametr i wykorzystując naturalny układ pamięci konkretnej wartości bez pośrednictwa. To umożliwia statyczny dyspozytor, specjalizację funkcji i agresywne optymalizacje inlining, jednocześnie utrzymując stabilność ABI, ponieważ konkretny typ może ewoluować bez zmiany układu pamięci publicznego interfejsu.
Opracowywaliśmy procesor danych rynkowych o wysokiej częstotliwości, gdzie implementacje protokołu MarketDataEvent różniły się w zależności od giełdy (NYSEEvent, NASDAQEvent). System musiał analizować miliony zdarzeń na sekundę z opóźnieniem poniżej 10 mikrosekund.
Opis problemu: Początkowa architektura używała func parse() -> any MarketDataEvent, co prowadziło do alokacji na stercie dla każdego analizowanego zdarzenia z powodu egzystencjalnego pakowania. Podczas zmienności rynku generowało to 50,000+ alokacji na sekundę, wywołując cykle utrzymywania/uwalniania ARC oraz burzę w pamięci podręcznej CPU, co zwiększało opóźnienie do 25 mikrosekund, naruszając nasze umowy serwisowe.
Rozwiązanie 1: Kontynuować używanie any MarketDataEvent. Zalety: Pozwoliło na heterogeniczne typy zwracane z jednej funkcji oraz proste kolekcje heterogeniczne. Wady: Obowiązkowa alokacja na stercie dla wszystkich zdarzeń typów wartościowych, nadmiarowy koszt dyspozytora dla każdego wywołania metody oraz zapobieganie optymalizacjom kompilatora, takim jak inlining krytycznej logiki parsowania.
Rozwiązanie 2: Przyjąć some MarketDataEvent (typy nieprzezroczyste). Zalety: Wyeliminowano alokacje na stercie, przechowując zdarzenia bezpośrednio na stosie, umożliwiono statyczny dyspozytor i pełną specjalizację kompilatora, zmniejszono opóźnienie o 65%. Wady: Wymagało, aby wszystkie ścieżki kodu w funkcji zwracały ten sam konkretny typ, zmuszając do refaktoryzacji logiki warunkowej parsowania do osobnych funkcji lub parserów specyficznych dla typów.
Rozwiązanie 3: Użyj sygnatur funkcji generycznych <T: MarketDataEvent> func parse() -> T. Zalety: Maksymalny potencjał optymalizacji dzięki monomorfizacji. Wady: Odsłonięto konkretne typy przed wywołującymi przez inferencję typów, co spowodowało znaczny wzrost rozmiaru pliku binarnego, gdy kompilator generował specjalizowane kopie dla każdego miejsca wywołania i łamał enkapsulację szczegółów implementacji.
Wybrane rozwiązanie: Wdrożyliśmy Rozwiązanie 2, refaktoryzując parser do protokołu z ograniczeniami typów skojarzonych i używając typów wynikowych o nieprzezroczystości dla głównej ścieżki gorącej. Dla rzadkich wymagań dotyczących heterogenicznych kolekcji wprowadziliśmy lekkie opakowanie enum. Dlaczego: Zyski wydajnościowe z alokacji na stosie i devirtualizacji przeważyły nad architektonicznym ograniczeniem jednorodnych typów zwracanych, a refaktoryzacja rzeczywiście poprawiła separację obaw poprzez usunięcie logiki warunkowej z parsera.
Rezultat: Opóźnienie spadło do 3,5 mikrosekundy, wskaźnik alokacji na stercie zmniejszył się o 99,7%, a wskaźniki trafień w pamięci podręcznej CPU poprawiły się o 40%, co pozwoliło systemowi obsłużyć 4-krotność wolumenu danych rynkowych bez aktualizacji sprzętu, zachowując stabilne zużycie pamięci.
1. Dlaczego typy wynikowe o nieprzezroczystości nie mogą być używane jako właściwości przechowywane w odpornych strukturach, a jak ta ograniczenia współdziałają z wymaganiami stabilności ABI?
Typy nieprzezroczyste wymagają, aby kompilator znał konkretny typ bazowy w miejscu deklaracji, aby obliczyć stały układ pamięci, rozmiar i wyrównanie. Odporné biblioteki muszą utrzymywać stabilność ABI między wersjami, co oznacza, że właściwości przechowywane w publicznych strukturach wymagają stałych offsetów i rozmiarów widocznych dla klientów. Ponieważ typy some ukrywają konkretny typ przed publicznym interfejsem, ale wiążą go w czasie kompilacji, zmiana implementacji bazowej zmieniłaby układ binarny struktury, łamiąc istniejące skompilowane klienty. Egzystencjalne (any) unikają tego, korzystając z konsekwentnej trójsłownej warstwy pośrednictwa, która izoluje ABI od zmian w konkretnych typach, co czyni je jedyną opcją dla właściwości przechowywanych w odpornych kontekstach, gdzie wymagana jest ewolucja implementacji.
2. Jak kompilator różnie traktuje dyspozytor metod dla typów nieprzezroczystych przy przekraczaniu granic modułu w porównaniu do tych w tym samym module, i kiedy przechodzi do dyspozytora tabeli świadków?
W tym samym module kompilator zwykle specjalizuje funkcje zwracające typy nieprzezroczyste w miejscu wywołania, inliningując konkretną implementację i eliminując całkowicie wirtualny dyspozytor. Jednak przy przekraczaniu granicy modułu z włączoną ewolucją biblioteki, konkretny typ może być ukryty, zmuszając kompilator do korzystania z dyspozytora tabeli świadków podobnie jak w przypadku generyków. W przeciwieństwie do egzystencjalnych, które zawsze używają tabel świadków przechowywanych w kontenerze egzystencjalnym, typy nieprzezroczyste przekazują metadane typu jako ukryty parametr generyczny, umożliwiając uruchomieniu zlokalizowanie poprawnej tabeli świadków poprzez metadane, a nie samą wartość. Przechodzenie do dyspozytora tabeli świadków występuje szczególnie wtedy, gdy kompilator nie może dokonać specjalizacji z powodu granic nieprzezroczystości, ale nawet wtedy dyspozytor unika podwójnego pośrednictwa kontenerów egzystencjalnych, utrzymując lepsze cechy wydajnościowe.
3. Jakie konkretne różnice metadanych czasu wykonywania istnieją między rzutowaniem typu nieprzezroczystego a egzystencjalnego przy użyciu as? lub refleksji Mirror, i dlaczego typy nieprzezroczyste czasami mogą nie przechodzić rzutowań, które powiodłyby się w przypadku egzystencjalnych?
Kontenery egzystencjalne (any) noszą swoją tabelę świadków protokołu i metadane typu w swojej strukturze trójsłownej, co umożliwia natychmiastową identyfikację zgodności w czasie wykonywania i wspiera rzutowanie na typ egzystencjalny lub jego podległy typ konkretny. Typy nieprzezroczyste (some) zachowują pełne metadane konkretnego typu, ale ukrywają je za granicą abstrakcji; rzutowanie za pomocą as? na inny protokół wymaga od kompilatora emitowania wyszukiwania w czasie wykonywania przez metadane konkretnego typu w celu znalezienia świadków zgodności. Typ nieprzezroczysty może nie przejść rzutowania na protokoły, do których konkretny typ nie świadczy wyraźnie, nawet jeśli deklaracja nieprzezroczystości obiecała inny protokół, ponieważ uruchomienie waliduje zgodność wobec metadanych konkretnego typu. Z drugiej strony, egzystencjalne pamiętają swoją główną zgodność protokołu, co sprawia, że niektóre rzutowania są szybsze, ale potencjalnie ukrywają pełne możliwości danego typu konkretnego, chyba że zostaną rozpakowane i ponownie zapakowane.