Gdy Swift kompiluje funkcje generyczne, konkretne typy podstawione dla parametrów generycznych mogą być zdefiniowane w oddzielnych modułach lub bibliotekach kompilowanych w różnym czasie. Wczesne podejścia do generyków w innych językach często wymagały monomorfizacji (generowania oddzielnego kodu dla każdego typu), co powoduje nadmiar kompilacji binarnej i uniemożliwia dynamiczne łączenie generyków. Swift potrzebował rozwiązania, które zrównoważy wydajność z elastycznością oddzielnej kompilacji i odpornością na zmiany w bibliotekach.
Problem: Funkcja generyczna taka jak func process<T>(_ value: T) musi być w stanie skopiować T do lokalnych zmiennych, przenieść go lub zniszczyć przy wychodzeniu z zakresu. Jednak kompilator nie może wiedzieć w czasie kompilacji, czy T jest trywialnym Int (8 bajtów), dużą strukturą (4KB), czy strukturą z licznikami referencyjnymi zawierającą bufor na stercie. Bez tej wiedzy funkcja nie może wiedzieć, ile miejsca na stosie zarezerwować, jak dostosować pamięć ani jak zarządzać cyklem życia wszelkich zasobów na stercie, które T może posiadać. Co więcej, dla typów Copy-on-Write (COW), takich jak Array czy Data, musimy upewnić się, że kopiowanie wartości struktury jedynie zwiększa liczniki referencyjne, a nie wykonuje kosztownych głębokich kopii bufora.
Rozwiązanie: Swift wykorzystuje Value Witness Tables (VWT). Każdy typ ma VWT (lub dzieli wspólny dla typów z kompatybilnym układem) zawierający wskaźniki funkcji dla istotnych operacji: size, alignment, stride, destroy, initializeWithCopy, assignWithCopy, initializeWithTake oraz assignWithTake. Podczas kompilacji kodu generycznego, LLVM generuje wywołania do tych funkcji obserwatorów zamiast instrukcji inline. W przypadku optymalizacji COW, wskaźnik initializeWithCopy dla takich typów wykonuje kopię płytką (zachowując odniesienie do bufora), podczas gdy rzeczywiste sprawdzenie unikalności i duplikacja bufora są odkładane do momentu mutacji za pomocą własnych metod typu. Umożliwia to algorytmom generycznym poprawne obsługiwanie dowolnego typu wartości przy zachowaniu charakterystyk wydajności COW.
Wyobraź sobie rozwój biblioteki do przetwarzania dźwięku o wysokiej wydajności, w której użytkownicy mogą definiować niestandardowe formaty próbek. Musisz zaimplementować generyczny RingBuffer<T>, który wydajnie przechowuje i rotuje próbki bez nadmiernego kopiowania. Bufor musi obsługiwać małe typy trywialne, takie jak Float (4 bajty) oraz duże, złożone typy, takie jak AudioPacket (struktura otaczająca bufor na stercie o wielkości 16KB z semantyką COW).
Jednym z rozważanych rozwiązań było wymuszenie na użytkownikach dostosowania się do protokołu Clonable z wyraźnymi metodami clone() i dispose(). To podejście zapewnia pełną kontrolę, ale zmusza użytkowników do pisania kodu szablonowego dla każdego typu, uniemożliwia bezpośrednie korzystanie z typów z biblioteki standardowej, takich jak Array, i naraża na wycieki pamięci, jeśli dispose() zostanie pominięte. Nie wykorzystuje również optymalizacji generowanych przez kompilator dla typów trywialnych.
Inne podejście polegało na użyciu UnsafeMutablePointer i memcpy do wszystkich operacji. Choć szybkie dla Float, to łamie się w przypadku struktur z licznikami referencyjnymi lub typów COW, duplikując wartości wskaźników bez ich zatrzymywania, co prowadzi do awarii z powodu użycia po zwolnieniu lub uszkodzenia bufora, gdy bufor rotacyjny nadpisuje stare dane. Wymaga to ręcznego zarządzania pamięcią, co jest podatne na błędy i omija gwarancje bezpieczeństwa Swift.
Wybrane rozwiązanie wykorzystało wbudowane mechanizmy generyczne Swift przez oparcie buforu rotacyjnego na ContiguousArray<T>, który wewnętrznie wykorzystuje VWT do wszystkich operacji na elementach. Dla logiki rotacji użyliśmy withUnsafeMutableBufferPointer w połączeniu z moveInitialize(from:count:), co wywołuje świadków przeniesienia VWT. Umożliwia to transfer własności wartości bez wywoływania konstruktorów kopiujących, zachowując semantykę COW poprzez unikanie niepotrzebnych zwiększeń liczby referencji. To podejście zostało wybrane, ponieważ utrzymuje bezpieczeństwo pamięci przy osiąganiu wydajności bliskiej optymalnej dzięki możliwości kompilatora do specjalizowania gorących ścieżek, jednocześnie cofa się do VWT w przypadku krawędzi.
Rezultatem był bufor rotacyjny, który osiągnął rotację bez kopiowania dla dużych pakietów audio COW, zachowując jednocześnie wydajność O(1) dla typów trywialnych, bez wymagań dotyczących niestandardowych protokołów czy niebezpiecznego kodu w publicznym API.
Dlaczego kopiowanie dużej struktury w ramach funkcji generycznej czasami wydaje się wolniejsze niż kopiowanie jej w wyspecjalizowanym kontekście niegenerycznym, nawet gdy oba korzystają z semantyki wartości?
W wyspecjalizowanym kontekście, gdzie typ konkretny jest znany, kompilator Swift może wstawić operację kopiowania bezpośrednio jako memcpy lub nawet z użyciem zaktualizowanych instrukcji SIMD. Jednak w niewyspecjalizowanym kodzie generycznym operacja kopiowania jest przekazywana przez wskaźnik funkcji initializeWithCopy w VWT. Ta indykcja uniemożliwia wstawianie w linii i blokuje subsequentne optymalizacje, takie jak eliminacja martwych danych czy wektoryzacja. Kompilator nie może dowieść, że kopiowanie nie ma skutków ubocznych (np. dla liczników referencyjnych), zmuszając go do generowania konserwatywnego, wolniejszego kodu. Zrozumienie tej różnicy jest kluczowe dla krytycznych pod względem wydajności algorytmów generycznych.
Jak Swift radzi sobie z niszczeniem częściowo zainicjowanych wartości, gdy inicjalizator generyczny generuje błąd w połowie przypisania właściwości?
Gdy inicjalizator generycznej struktury zgłasza błąd po zainicjowaniu niektórych właściwości, ale nie innych, Swift musi unikać wycieku już zainicjowanych wartości. Kompilator generuje ścieżkę czyszczenia błędów, która konsultuje świadka destroy VWT dla każdej zainicjowanej właściwości w odwrotnej kolejności inicjalizacji. Ponieważ VWT zna dokładny układ i procedurę czyszczenia dla konkretnego typu, może poprawnie zniszczyć częściowo skonstruowaną wartość, nie musząc znać, które konkretne właściwości zostały ustawione. Ten mechanizm zapewnia bezpieczeństwo pamięci nawet w scenariuszach awaryjnych przy złożonych typach wartości.
Jaka jest relacja między Tabelami Świadków Wartości a Kontenerami Egzystencjalnymi i dlaczego duże typy wartości są alokowane na stercie, gdy są zamieniane na protokoły any?
Kontener Egzystencjalny (pudełko dla any Protocol) ma wewnętrzne przechowywanie zazwyczaj 3 słów (24 bajty w systemach 64-bitowych). Gdy wartość większa niż ten bufor wewnętrzny zostaje zamieniona na typ egzystencjalny, Swift alokuje wartość na stercie i przechowuje wskaźnik w kontenerze. VWT podlegającego typu jest przechowywana obok metadanych typu w kontenerze. VWT dostarcza size i alignment potrzebne do alokacji stosu, oraz świadka destroy, aby oczyścić go po tym, jak egzystencjalny typ wyjdzie poza zakres. To rozdzielenie pozwala kontenerowi egzystencjalnemu mieć stały rozmiar, jednocześnie pomieszczając dowolnie duże typy wartości, jednak kosztem alokacji na stercie i indykcji dla dużych wartości.