W dzisiejszej notatce chciałbym Wam pokazać jak korzystając z modyfikatorów dostępu można w łatwy sposób zoptymalizować aplikację napisaną za pomocą Swift’a. Wydaje mi się, że jest to element bardzo często pomijany i sam muszę przyznać, że na ten temat trafiłem dość późno. Jest to związane prawdopodobnie z faktem, że stosunkowo nieduża liczba artykułów dostępnych w sieci porusza to zagadnienie.

Trzeba najpierw trochę pogrzebać w dokumentacji (przynajmniej tak było w moim przypadku) zanim trafi się na wzmiankę o takich mechanizmach jak static dispatch oraz dynamic dispatch. Tak wygląda ich skrócona definicja:

Dynamic Dispatch – kompilator nie ma pojęcia, która implementacja właściwości (pola) lub metody zostanie wykorzystana po uruchomieniu programu i musi wykonać dodatkowe operacje, aby to sprawdzić. Dzieje się tak dlatego, że Swift daje nam możliwość nadpisywania pól oraz metod w obiektach pochodnych. Oznacza to, że kompilator musi sprawdzić, czy chcemy skorzystać z implementacji obiektu bazowego, czy może raczej obiektu pochodnego.

Static Dispatch – sytuacja odwrotna. Kompilator jest pewien, która implementacja właściwości (pola) lub metody zostanie wykorzystana podczas działania programu. Można w ten sposób zaoszczędzić trochę zasobów, podkręcając tym samym wydajność. Użycie operatora final w definicji klasy da kompilatorowi do zrozumienia, że nie zostaną na jej podstawie utworzone żadne obiekty pochodne. Oznacza to, że kompilator nie musi zastanawiać się, którą z implementacji wybrać. To tylko jedna z możliwości. Podobnie zachowuje się operator static.

Problem z dynamic dispatch polega na tym, że nasz program będzie musiał podczas działania sprawdzić, z której implementacji ma skorzystać. Spowoduje to zwiększenie ilości zasobów potrzebnych do przeprowadzenia danej operacji. W przypadku static disptach, już na poziomie kompilacji zostanie jasno określone, która implementacja zostanie wykorzystana.

Zasada jest więc prosta. Zmniejszenie liczby dynamicznych wywołań, spowoduje wzrost wydajności naszej aplikacji. Osiągnięcie tego nie jest niczym skomplikowanym, o czym się za chwilę przekonacie 🤓.

 

Jak zredukować dynamic Dispatch?

 

Modyfikatory private oraz fileprivate

Dziedziczenie jest bardzo dobrym przykładem, pokazującym jak możemy nieświadomie utrudnić pracę kompilatorowi. Weźmy taki prosty przykład:

Sytuacja tutaj jest oczywista. Programista Kotlina dziedziczy po obiekcie BaseProgrammer, nadpisuje pole programmingLanguage oraz metodę writeCode(). Jeżeli nie użyjemy żadnego modyfikatora dostępu, to kompilator automatycznie założy, że ma korzystać z internal. Oznacza to, że pole lub metoda dostępne jest dla innych obiektów umieszczonych w tym samym module.

I tutaj pojawia się pewniem problem. Określenie funkcji writeCode() jako internal, jest dla kompilatora sygnałem, że jej zachowanie może zostać nadpisane w klasie pochodnej (tak jak w powyższym przykładzie). Program wykorzysta dynamic dispatch, aby sprawdzić jakiej implementacji powinien użyć. W rezultacie aplikacja będzie o te kilka milisekund dłużej wykonywała daną operację. Taka sama sytuacja ma miejsce w przypadku zmiennej programmingLanguage.

Oczywiście jeżeli celowo założymy, że funkcja writeCode() będzie korzystała z modyfikatora internal, to nie ma najmniejszego problemu. Pojawi się on w momencie, w którym dana funkcja będzie wykorzystywana jako prywatna implementacja, a my jasno tego nie określimy.

Rozwiązanie jest oczywiście banalne. Wystarczy użyć modyfikatora private lub fileprivate. W skutek tego kompilator będzie mógł bezpiecznie założyć, że dane pole lub metoda nie zostanie nadpisana w innym pliku, a co za tym idzie będzie on mógł skorzystać ze static dispatch. Kompilator niejawnie przypisze danemu obiektowi modyfikator final.

 

Modyfikator final

Jeżeli chcemy, aby dane pole lub metoda były dostępne publicznie, a jednocześnie wiemy, że będzie korzystał z nich tylko jeden obiekt, to możemy użyć modyfikatora final bezpośrednio:

W takim przypadku poniższy kod się nie skompiluje. Kompilator zacznie marudzić, że chcemy nadpisać pole oraz metodę, które nie zostały do tego przeznaczone:

Za pomocą modyfikatora final dajemy znać kompilatorowi, że implementacja nie zostanie nadpisany w innym miejscu. Dzięki temu kompilator może od razu skorzystać ze static disptach. Kolejny mały zysk na wydajności 🧐.

Jeżeli wiem, że każda element obiektu będziemy traktowali jako final, to możemy oznaczyć tym modyfikatorem całą klasę:

 

 

Optymalizacja na poziomie modułu

 

Jak już wcześniej wspominałem, domyślnym modyfikatorem dostępu w Swift jest internal, który określa dostępność na poziomie całego modułu. Standardowo Swift wszystkie pliki składające się na moduł kompiluje osobno. W takiej sytuacji kompilator nie jest w stanie określić, czy dana implementacja zostanie nadpisana w innym pliku.

Inaczej kwestia ta wygląda w przypadku skorzystania z Whole Module Optimization, które pozwala na kompilowanie wszystkich plików modułu w tym samym czasie i w ramach tego samego procesu. Kompilator uzyskuje dostęp do wszystkich plików, a więc będzie mógł sprawdzić, czy dana implementacja została nadpisana w innym miejscu. Jeżeli odkryje, że tak się nie stało to dany fragment oznaczy jako final. To z kolei będzie oznaczało, że można skorzystać ze static disptach. Ale to nie wszystko, bo Whole Module Optimization może nam pomóc w jeszcze inny sposób.

WMO jest mechanizmem kompilatora Swift, który pozwala na dodatkową optymalizację naszego kodu. W zależność od projektu wzrost wydajności kodu może zostać zwiększony nawet pięciokrotnie. Poczynając od Xcode 8, WMO jest włączane domyślnie dla wszystkich nowych projektów. Jeżeli z jakiegoś powodu będziecie chcieli zmienić to ustawienie (np. dla debug), to możecie zrobić to w zakładce Bulid Settings Waszego projektu. Tak jak na poniższym obrazku:

Bez włączonego WMO każdy plik w module aplikacji jest kompilowany osobno – kod jest optymalizowany, kompilator generuje kod maszynowy, a następnie dane zapisywane są do pliku. Na samym końcu wszystkie pliki są łączone i w zależności od przeznaczenia modułu, tworzona jest współdzielona biblioteka lub plik wykonywalny naszej aplikacji.

I tutaj właśnie pojawiają się pewne ograniczenia. Skoro optymalizacja wykonywana jest na poziomie pojedynczego pliku, kompilator ma ograniczone pole do popisu. Nie jest w stanie skorzystać z takich udogodnień jak function inlining lub generic specialization. Wszystko fajnie, ale co to właściwie oznacza? Już tłumaczę 🤓.

Function inlining jest mechanizmem kompilatora, który pozwala na redukowanie niepotrzebnej złożoności związanej z wywoływaniem oraz zwracaniem wartości z funkcji / metod. Tak to będzie wyglądało na przykładzie, który celowo został trochę wyolbrzymiony:

Podczas optymalizacji funkcja addNumbers() może zostać rozwinięta w miejscu wywołania. Właśnie ta operacja określana jest mianem function inlining:

Bez wsparcia WMO kompilator będzie kompilował każdą z klas osobno. W rezultacie podczas prac nad klasą SubstractNumbers będzie w stanie stwierdzić jedynie, że funkcja addNumbers() faktycznie istnieje, ale nie będzie mógł sprawdzić jak wygląda jej konkretna implementacja. Dzieje się tak oczywiście dlatego, że klasa AddNumbers będzie kompilowana osobno. Gdyby funkcja addNumbers() została zdefiniowana w klasie SubstractNumbers to oczywiście kompilator mógłby spokojnie skorzystać z function inlining.

Generic specialization pomaga natomiast w określeniu jak będzie wyglądała implementacja dla wywołania funkcji generycznej. Taki przykład (na podstawie oficjalnej dokumentacji):

W momencie kompilacji kompilator nie ma pojęcia jak wygląda implementacja funkcji getElement(). Wie tylko, że ona istnieje i musi utworzyć do niej wywołanie. W przypadku struktury Container, kompilator nie wie jaki typ danych zostanie użyty podczas wywołania. Wygeneruje on wersję generyczną, która jest znacznie wolniejsza od implementacji zawierającej zdefiniowane typy danych.

Jak się już zapewne domyślacie, w przypadku korzystania z optymalizacji na poziomie całego modułu, kompilator nie będzie miał takich problemów. Podczas kompilacji będzie w stanie podejrzeć każdy z plików dostępnych w danym module. Będzie mógł od ręki sprawdzić jak wygląda implementacja funkcji getElement() i z jakich typów danych będą korzystały jej wywołania.

Tak będą wyglądały obiekty z punktu widzenia kompilatora już po optymalizacji. W pierwszej kolejności kompilator utworzy konkretną implementację dla Container:

Następnie będzie w stanie określić z jakich typów będzie korzystała funkcja add():

A na koniec skorzysta jeszcze z function inlining:

Taka optymalizacja pozwoli na ograniczenie instrukcji generowanych w języku maszynowym, a w konsekwencji poprawi wydajność naszego kodu.

A jak wygląda czas kompilacji w przypadku użycia WMO? W końcowym procesie kompilacji (mocno tutaj skracam) LLVM odpowiedzialny m.in za generowanie kodu maszynowego, otrzymuje ponownie rozdzielone pliki, których kompilacja odbywać się będzie na osobnych wątkach. W przypadku opcji bez WMO, kompilacja każdego z plików rozpoczynana jest w osobnym procesie. Morał tej historii jest taki, że w obydwu przypadkach kompilacja pojedynczych plików może być wykonywana asynchronicznie. Whole Module Optimazation nie wpłynie zatem negatywnie na czas kompilacji naszego kodu.

 

Słowo na drogę

 

Notatka wyszła odrobinę dłuższa niż na początku zakładałem, ale nie chciałem pominąć żadnego szczegółu. Jak sami mogliście się przekonać, temat optymalizacji kodu można ciągnąć w nieskończoność, ale nie trzeba wcale wiele wysiłku, aby wprowadzić poznane metody do codziennej praktyki. Mam nadzieję, że artykuł ten dostarczył Wam kilku przydatnych porad. Do usłyszenia 🤓.


 

Dodaj komentarz

Twój adres email nie zostanie opublikowany. Pola, których wypełnienie jest wymagane, są oznaczone symbolem *