Od AGP 9.2.0 R8 optymalizuje większość wywołań Atomic*FieldUpdater do wariantów Unsafe, które w przypadku typowych operacji działają 2–4 razy lepiej. Ma to szczególnie duży wpływ na bibliotekę kotlinx.atomicfu, która implementuje operacje atomowe dla kotlinx.coroutines, dzięki czemu uruchamianie i anulowanie współprogramów jest nawet 2 razy szybsze. Aby skorzystać z tych zalet, zaktualizuj AGP do wersji 9.2.0 lub nowszej.
Większość aplikacji na Androida używa Kotlina jako głównego języka, dlatego kotlinx.coroutines stał się de facto standardem programowania asynchronicznego. Biblioteka oferuje dobrze zaprojektowany i uporządkowany sposób zarządzania współbieżnymi przepływami, który jest natywny dla Kotlina. Jetpack Compose nie jest wyjątkiem – używa współprogramów do zarządzania zdarzeniami wskaźnika, animacjami i innymi interakcjami. W chwili pisania tego artykułu większość współbieżnych interfejsów API w Compose wywołuje funkcje suspend w tle oraz uruchamia i/lub anuluje współprogramy w celu obsługi aktualizacji.
Gdy zespół Compose zaczął badać wydajność, okazało się, że współprogramy są wąskim gardłem w przypadku wielu operacji wykonywanych poza kompozycją. Na przykład 80% czasu poświęconego na tworzenie i aktualizowanie Modifier.clickable było zużywane na uruchamianie i anulowanie wewnętrznych współprogramów, które obsługiwały aktualizacje InteractionSource. Na podstawie tych obserwacji większość wczesnych prac nad wydajnością skupiała się na usuwaniu współprogramów ze ścieżki domyślnej i opóźnianiu inicjowania do momentu, gdy będzie to konieczne.
Koszt współprogramu
Najłatwiejszym sposobem na analizę wewnętrznego działania funkcji na Androidzie jest przechwycenie śladu metody środowiska wykonawczego Androida (ART). Ślad metody ART to narzędzie, które rejestruje przepływ wykonywania aplikacji, pokazując dokładnie, które metody są wywoływane, w jakiej kolejności i ile czasu jest poświęcane na każdą z nich. Dzięki temu deweloperzy mogą identyfikować wąskie gardła wydajności. W przypadku pustego wywołania LaunchedEffect { } wyglądałoby to mniej więcej tak:
Ślad metody powyżej można podzielić na 3 części:
- Inicjowanie nowego współprogramu
- Uruchamianie współprogramu
- Kończenie współprogramu (ponieważ natychmiast się kończy)
Anulowanie LaunchedEffect jest podobne do normalnego zakończenia, z tą różnicą, że tworzy też CancellationException.
W profilu powyżej jedną z rzeczy, która od razu budzi podejrzenia, są częste wywołania java.util.concurrent.AtomicReferenceFieldUpdater (fioletowe lub zielone pola z etykietami j…). Każde wywołanie jest stosunkowo szybkie, ale częstotliwość jest niepokojąca. Każdy niepomijalny narzut, który jest rozłożony na wiele wywołań, może spowodować zauważalną regresję. Przybliżenie wywołania ujawnia, że większość czasu jest poświęcana na… sprawdzanie odbicia?
Współprogramy implementują bezblokującą strukturę drzewa dla relacji nadrzędnych i podrzędnych, która umożliwia uporządkowaną współbieżność. Okazuje się, że biblioteka kotlinx.atomicfu implementuje bezblokujące operacje atomowe za pomocą dobrze znanego prymitywu JVM – AtomicReferenceFieldUpdater. Aktualizator używa odniesienia do klasy i nazwy pola do wykonywania operacji atomowych w czasie działania. Musi też przeprowadzić kilka odblaskowych kontroli bezpieczeństwa, aby upewnić się, że pole istnieje i jest dostępne. Każda operacja we współprogramach (uruchamianie, wstrzymywanie, anulowanie, kończenie) wywołuje co najmniej 1 operację atomową, więc jeśli jest ona powolna, współprogramy nie będą działać dobrze.
Badanie AtomicReferenceFieldUpdater
Ale nie wyprzedzajmy faktów.AtomicReferenceFieldUpdater jest dobrze zoptymalizowany w JVM od ponad 10 lat, a ślady metod mogą przechwytywać narzut, który jest całkowicie usuwany przez optymalizację na poziomie maszyny wirtualnej: kompilację just-in-time (JIT) lub ahead-of-time (AOT). Aby sprawdzić wydajność, napiszmy kilka testów porównawczych, które zmierzą różnicę między odniesieniami atomowymi z kotlinx.atomicfu i java.util.concurrent.atomic.
@RunWith(AndroidJUnit4::class) class AtomicReferenceBenchmark { @get:Rule val benchmarkRule = BenchmarkRule() private val atomicReference = java.util.concurrent.atomic.AtomicReference(false) private val atomicRef = kotlinx.atomicfu.atomic<Boolean>(false) @Test fun atomicReference_compareAndSet() { benchmarkRule.measureRepeated { atomicReference.compareAndSet(true, false) atomicReference.compareAndSet(false, true) } } @Test fun atomicRef_compareAndSet() { benchmarkRule.measureRepeated { atomicRef.compareAndSet(true, false) atomicRef.compareAndSet(false, true) } } /* measuring other methods from the method traces above */ }
Uruchomienie tego testu porównawczego na Pixelu 5 (przy jednoczesnym upewnieniu się, że AtomicReferenceFieldUpdater#compareAndSet jest kompilowany przez JIT podczas rozgrzewki) daje te wyniki na Pixelu 5 (API 33):
50.7 ns atomicReference_compareAndSet 135 ns atomicRef_compareAndSet
Pomiary potwierdzają różnicę – wersja kotlinx.atomicfu jest wyraźnie około 2, 7 razy wolniejsza. Potwierdza to, że ART nie wykonuje żadnej ukrytej optymalizacji, a sprawdzanie dostępu odblaskowego dodaje rzeczywisty narzut podczas działania.
Wracając do pierwotnego śladu metody, jedyną znaczącą pracą wykonywaną przez AtomicReferenceFieldUpdater jest wewnętrzne wywołanie Unsafe.getObjectVolatile, które faktycznie wykonuje podstawową operację atomową. W większości przypadków inicjator aktualizatora jest statyczny i można udowodnić, że jest zawsze poprawny na podstawie struktury otaczającej klasy. Dlatego podczas kompilacji można statycznie przeanalizować większość zastosowań AtomicReferenceFieldUpdater i zastąpić je wewnętrznym wariantem Unsafe. Narzędzia do tworzenia aplikacji na Androida mają własny kompilator optymalizujący, który może to zrobić.
Optymalizacja za pomocą R8
Klasy Atomic*FieldUpdater obsługują subtelne, dynamiczne i oparte na odbiciu użycie, ale często są używane w statycznie oczywistych wzorcach. Wyjaśnia to zarówno powolną wydajność bazową, jak i potrzebę optymalizacji. R8 to kompilator optymalizujący cały program, który dobrze radzi sobie z prostszymi wzorcami, aby zmniejszyć narzut związany z odblaskowymi kontrolami bezpieczeństwa. R8 otrzymuje kod bajtowy JVM po kompilatorze Java lub Kotlin, ale aby ułatwić czytanie, te przykłady są przedstawione w składni Java. Dlatego nie ma argumentów typu dla AtomicReferenceFieldUpdater.
class Example { volatile String data = ""; static final AtomicReferenceFieldUpdater updater = AtomicReferenceFieldUpdater.newUpdater(Example.class, String.class, "data"); void example() { // ... updater.compareAndSet(this, "", "new"); // ... } }
Podstawowy przykład tworzy statyczny finalny aktualizator, który uzyskuje dostęp do pola volatile za pomocą prostych argumentów stałych dla posiadacza, typu i nazwy pola. Używane odbicie jest całkowicie przejrzyste. Widać, że ten aktualizator odwołuje się do prawidłowego pola, a miejsce utworzenia aktualizatora ma prawidłowy dostęp do tego pola.
W istocie Atomic*FieldUpdater to otoka wokół przesunięcia pola i wywołań Unsafe. Najlepszym scenariuszem optymalizacji jest zastąpienie pola aktualizatora polem przesunięcia i zastąpienie wywołań aktualizatora wywołaniami Unsafe.
Optymalizowanie Atomic*FieldUpdater
Optymalizacja jest implementowana w 3 częściach: narzędzia, zamiennik i porządkowanie.
Narzędzia
Pierwszym krokiem jest wprowadzenie pól przesunięcia obok pola aktualizatora, aby ułatwić bezpośredni dostęp za pomocą Unsafe wywołania.
static final long updater$offset = SyntheticUnsafe.UNSAFE.objectFieldOffset(Example.class.getDeclaredField("data"))
Dostęp do pola jest uzyskiwany za pomocą odbicia, a Unsafe służy do wyodrębnienia przesunięcia pola w klasie. Ten kod reprezentuje wewnętrzne działanie Atomic*FieldUpdater , jeśli zignorujesz sprawdzanie odbicia. Zamiast tego typ posiadacza aktualizatora i typ pola volatile są śledzone statycznie w kompilatorze.
Pamiętaj, że oryginalne pole i jego inicjowanie pozostają bez zmian. Proces optymalizacji optymistycznie ułatwia i optymalizuje użycie, a następnie porządkuje. Jest to proste podejście do implementacji, ale umożliwia też częściową optymalizację pól aktualizatora, w przypadku których niektóre zastosowania pozostają bez zmian, a inne są optymalizowane.
Zamiennik
W tym momencie kompilatora, po odpowiednim punkcie łączenia współbieżności, mamy listę instrumentowanych pól aktualizatora. Oznacza to, że możemy zoptymalizować każde miejsce wywołania indywidualnie na podstawie kilku warunków. Rozważmy przykładowe wywołanie:
updater.compareAndSet(holder, expectedValue, newValue);
Warunki, których wymaga Atomic*FieldUpdater, to:
- Czy
updaterpochodzi z instrumentowanego pola? Czy analiza statyczna może śledzić wartość obiektu z powrotem do odczytu pola instrumentowanego aktualizatora? - Czy
holderjest tą samą klasą lub podklasą pierwotnie zdefiniowanego typu posiadacza? - Czy
newValuejest tą samą klasą lub podklasą pierwotnie zdefiniowanego typu pola?
Jeśli wszystkie warunki są spełnione, wywołanie jest zastępowane wywołaniem Unsafe bez żadnych kontroli odbicia.
SyntheticUnsafe.UNSAFE.compareAndSwapObject(holder, Example.updater$offset, expectedValue, newValue)
To nowe wywołanie jest szybsze i prostsze, ale różni się od pierwotnego wywołania pod względem obsługi wartości null w updater i holder. O ile nie zostanie to statycznie wykluczone, w obu przypadkach wstawiane są sprawdzania wartości null.
Porządkowanie roszczeń
W tym momencie klasa zawierająca ma oryginalne pole aktualizatora i nowe pole przesunięcia wraz z miejscami wywołania, które mogą używać jednego z tych dwóch. Jeśli żadne z miejsc wywołania nie zostało zoptymalizowane, pole przesunięcia należy usunąć. Jeśli wszystkie miejsca wywołania zostały zoptymalizowane, pole aktualizatora należy usunąć. W obu przypadkach należy też usunąć wywołanie inicjujące. Usuwanie nieużywanych pól i martwego kodu jest już wykonywane w kompilatorze, ale usunięcie kodu inicjującego wymaga kilku dodatkowych sztuczek.
Zarówno wywołanie newUpdater, jak i getDeclaredField mogą mieć efekty uboczne, ponieważ mogą zgłaszać wyjątki (a ich implementacja jest też nieznana, ponieważ zależy od wersji interfejsu API). Oznacza to, że nie można ich bezpiecznie usunąć za pomocą optymalizacji ogólnej. Dlatego porządkowanie wymagało wyraźnego uwzględnienia instrumentowanych pól, ponieważ wiadomo, że nie zawierają one wyjątków.
Po optymalizacji prosty przykład aktualizatora pokazany powyżej wygląda tak:
Wyniki
Po tych optymalizacjach kotlinx.atomicfu i większość jawnych zastosowań AtomicInt/Long/ReferenceFieldUpdater odpowiadają teraz wydajności AtomicReference z zastosowanym R8. W niektórych testach porównawczych jest nawet szybszy. kotlinx.atomicfu ma wtyczkę kompilatora, która może wstawiać instancje atomic do pól, co zmniejsza liczbę alokacji wymaganych do utworzenia pola aktualizowanego atomowo.
Jetpack Compose był głównym beneficjentem tej pracy. Środowisko wykonawcze Compose ma kilka mikrotestów porównawczych, które bardzo dokładnie śledzą wydajność współprogramów, aby wcześnie wykrywać regresje wydajności. Gdy testy porównawcze zostały zaktualizowane do nowej wersji R8, zauważyliśmy 2-krotny wzrost wydajności podczas uruchamiania i anulowania współprogramów w LaunchedEffect!
Oprócz tego zespół ART implementuje te optymalizacje natywnie na poziomie maszyny wirtualnej. Jeśli Twoja aplikacja jest kierowana na interfejs API 36 i działa w najnowszej wersji Androida, możliwe, że Twoje urządzenie optymalizuje już współprogramy w podobny sposób. W testach porównawczych współprogramów powyżej zaobserwowano około 15% wzrost wydajności po aktualizacjach JIT w najnowszych wersjach ART.
Twoja aplikacja otrzyma tę optymalizację domyślnie po uaktualnieniu do AGP 9.2.0 lub bezpośrednim użyciu R8 9.2.0. Więcej informacji znajdziesz w artykule D8 dexer i R8 shrinker.
-
Studia przypadkówRegresje wydajności są wyjątkowo trudne do odtworzenia, co sprawia, że są one ogromnym wąskim gardłem dla deweloperów mobilnych.
Alice Yuan, Arti Arutiunov, Nikita Ogorodnikov • 4 min czytania -
Studia przypadkówFotMob odnotował niedawno największy jednodniowy wzrost liczby użytkowników Wear OS wśród zainstalowanych odbiorców w ciągu 5 lat – 2–3 razy większy niż średnia dzienna. Jaki jest sekret? Prosty proces instalacji na różnych urządzeniach, który pomaga użytkownikom odkrywać aplikację na Wear OS bezpośrednio na telefonie.
Garan Jenkin • 3 min czytania -
Studia przypadkówAplikacja do medytacji Gratitude zachęca do regularności dzięki codziennym mikro dziennikom, afirmacjom i tablicom wizji. Aplikacja ma ponad 6 milionów pobrań, 150 tysięcy ocen 5-gwiazdkowych i 100 milionów wpisów w dzienniku.
Amrit Sanjeev, Ash Nohe • 3 min czytania
Otrzymuj co tydzień najnowsze informacje o tworzeniu aplikacji na Androida na swoją skrzynkę odbiorczą.