Studia przypadków

Jak R8 2-krotnie przyspieszył działanie współprogramów Kotlin na Androidzie

Czas czytania: 7 minut

Od wersji 9.2.0 wtyczki AGP 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 korutyn jest nawet 2 razy szybsze. Aby skorzystać z tych korzyści, zaktualizuj AGP do wersji 9.2.0 lub nowszej.

Większość aplikacji na Androida używa Kotlina jako głównego języka programowania, dlatego kotlinx.coroutines stał się standardem w programowaniu asynchronicznym. Biblioteka zapewnia dobrze zaprojektowany i uporządkowany sposób zarządzania współbieżnymi przepływami, który jest natywny dla języka Kotlin. Jetpack Compose nie jest wyjątkiem. Korzysta z korutyn do zarządzania zdarzeniami wskaźnika, animacjami i innymi interakcjami. W momencie pisania tego artykułu większość współbieżnych interfejsów API w Compose wywołuje w tle funkcje suspend i uruchamia lub anuluje korutyny w celu obsługi aktualizacji.

Gdy zespół Compose zaczął badać wydajność, okazało się, że w przypadku wielu operacji wykonywanych poza kompozycją wąskim gardłem są korutyny. Na przykład 80% czasu poświęconego na tworzenie i aktualizowanie Modifier.clickable zajmowało uruchamianie i anulowanie wewnętrznych współprogramów, które obsługiwały aktualizacje InteractionSource. Na podstawie tych obserwacji wiele wczesnych prac nad wydajnością skupiało się na usuwaniu korutyn ze ścieżki domyślnej i opóźnianiu inicjowania do momentu, gdy jest to konieczne. 

Koszt współprogramu

Najprostszym sposobem analizowania wewnętrznego działania funkcji na Androidzie jest przechwytywanie ś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 zajmuje każda z nich. Dzięki temu deweloperzy mogą identyfikować wąskie gardła wydajności. W przypadku pustego wywołania LaunchedEffect { } będzie to wyglądać mniej więcej tak:

pic01_enhanced.png
Ślad metody LaunchedEffect wizualizowany w interfejsie Perfetto

Ślad metody powyżej można podzielić na 3 części:

  • Inicjowanie nowej korutyny
  • Uruchamianie korutyny
  • Zakończenie współprogramu (ponieważ natychmiast się kończy)

Anulowanie LaunchedEffect jest podobne do normalnego zakończenia, z tą różnicą, że tworzy też CancellationException.

Z powyższego profilu od razu widać, że podejrzane są częste wywołania funkcji 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 rozłożony na wiele wywołań może spowodować zauważalny regres. Przybliżenie rozmowy pokazuje, że większość czasu poświęcono na… sprawdzanie odbicia?

pic02-enhanced.png
Szczegółowe omówienie śledzenia metody AtomicReferenceFieldUpdater.get podczas inicjowania LaunchedEffect

Korutyny implementują strukturę drzewa bez blokad dla relacji nadrzędny-podrzędny, co umożliwia współbieżność strukturalną. Okazuje się, że biblioteka kotlinx.atomicfu implementuje operacje atomowe bez blokad przy użyciu dobrze znanego elementu pierwotnego JVM, czyli AtomicReferenceFieldUpdater. Aktualizator używa odwołania do klasy i nazwy pola, aby wykonywać operacje niepodzielne w czasie działania programu. Musi też przeprowadzać kilka kontroli bezpieczeństwa opartych na refleksji, aby upewnić się, że pole istnieje i jest dostępne. Każda operacja w korutynach (uruchamianie, wstrzymywanie, anulowanie, kończenie) wywołuje co najmniej jedną operację niepodzielną, więc jeśli jest powolna, korutyny nie będą działać dobrze.

Badanie klasy AtomicReferenceFieldUpdater

Ale nie wyprzedzajmy faktów. AtomicReferenceFieldUpdater jest dobrze zoptymalizowany w JVM od ponad 10 lat, a ślady metod mogą rejestrować narzut, który jest całkowicie usuwany przez optymalizację na poziomie maszyny wirtualnej: kompilacje just-in-time (JIT) lub ahead-of-time (AOT). Aby sprawdzić wydajność, napiszmy kilka testów porównawczych, które pozwolą zmierzyć różnicę między odwołaniami atomowymi z kotlinx.atomicfujava.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 (z zapewnieniem, że AtomicReferenceFieldUpdater#compareAndSet jest kompilowany JIT podczas rozgrzewki) daje na Pixelu 5 (API 33) te wyniki:

 50.7 ns  atomicReference_compareAndSet
135   ns  atomicRef_compareAndSet

Pomiary potwierdzają tę różnicę – wersja kotlinx.atomicfu jest około 2, 7 raza wolniejsza. Potwierdza to, że ART nie przeprowadza żadnej ukrytej optymalizacji, a sprawdzanie dostępu refleksyjnego powoduje rzeczywiste obciążenie w czasie działania.

Patrząc wstecz na pierwotny ślad metody, jedyną istotną pracą wykonywaną przez AtomicReferenceFieldUpdater jest wewnętrzne wywołanie Unsafe.getObjectVolatile, które faktycznie wykonuje podstawową operację niepodzielną. W większości przypadków inicjator aktualizatora jest statyczny i na podstawie struktury otaczającej klasy można udowodnić, że jest zawsze prawidłowy. Dzięki temu można statycznie przeanalizować większość użyć AtomicReferenceFieldUpdater i podczas kompilacji zastąpić je wewnętrzną wersją Unsafe. Narzędzia do kompilacji 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 odbiciach zastosowania, ale często są używane w statycznych, oczywistych wzorach. 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 prostymi wzorcami i pomija narzut związany z odblaskowymi kontrolami bezpieczeństwa. R8 otrzymuje kod bajtowy JVM po kompilatorze Java lub Kotlin, ale dla większej czytelności przykłady są przedstawione w składni Java. Dlatego w przypadku funkcji AtomicReferenceFieldUpdater nie ma argumentów typu.

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 obiekt aktualizujący, który uzyskuje dostęp do niestabilnego pola z prostymi stałymi argumentami dla obiektu, typu i nazwy pola. Użyte odbicie jest całkowicie przezroczyste. Widać, że aktualizator odwołuje się do prawidłowego pola, a witryna, w której został utworzony, ma do niego prawidłowy dostęp.

W zasadzie Atomic*FieldUpdater to otoczka wokół przesunięcia pola i wywołań Unsafe. Najlepszym rozwiązaniem w przypadku optymalizacji jest zastąpienie pola aktualizatora polem przesunięcia i zastąpienie wywołań aktualizatora wywołaniami funkcji Unsafe.

Optymalizacja Atomic*FieldUpdater

Optymalizacja jest wdrażana w 3 etapach: instrumentacja, zastępowanie i czyszczenie. 

Narzędzia

Pierwszym krokiem jest wprowadzenie pól przesunięcia obok pola aktualizatora, aby ułatwić bezpośredni dostęp za pomocą wywołania Unsafe .

static final long updater$offset =
    SyntheticUnsafe.UNSAFE.objectFieldOffset(Example.class.getDeclaredField("data"))

Dostęp do pola uzyskuje się za pomocą odbicia, a Unsafe służy do wyodrębniania przesunięcia pola w klasie. Ten kod reprezentuje wewnętrzne działanie funkcji Atomic*FieldUpdater , jeśli zignorujesz weryfikację odbicia. Zamiast tego typ posiadacza aktualizatora i typ pola niestabilnego są śledzone statycznie w kompilatorze.

 

Pamiętaj, że oryginalne pole i jego inicjowanie pozostają bez zmian. Proces optymalizacji ułatwia i optymalizuje użycie, a potem usuwa niepotrzebne elementy. Jest to proste podejście do implementacji, które umożliwia też częściową optymalizację pól aktualizatora, w której niektóre zastosowania pozostają bez zmian, a inne są optymalizowane.

Zamiennik

Na tym etapie kompilatora, po odpowiednim punkcie łączenia współbieżności, mamy listę instrumentowanych pól aktualizatora. Oznacza to, że możemy optymalizować każdą witrynę połączeń indywidualnie na podstawie kilku warunków. Przyjrzyjmy się przykładowemu połączeniu:

updater.compareAndSet(holder, expectedValue, newValue);

Warunki, które Atomic*FieldUpdater wymaga, to:

  • Czy wartość updater pochodzi z pola z instrumentacją? Czy analiza statyczna może śledzić wartość obiektu aż do odczytu pola z instrumentowanego modułu aktualizującego?
  • Czy holder to ta sama klasa lub podklasa pierwotnie zdefiniowanego typu uchwytu?
  • Czy newValue to ta sama klasa czy podklasa pierwotnie zdefiniowanego typu pola?

Jeśli wszystkie warunki są spełnione, połączenie jest zastępowane połączeniem z 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 parametrach updaterholder. Jeśli nie można statycznie wykluczyć wartości null, wstawiane są dla obu. 

Porządkowanie roszczeń

W tym momencie klasa przechowująca ma oryginalne pole aktualizatora i nowe pole przesunięcia wraz z miejscami wywołań, które mogą używać jednego z tych dwóch pól. Jeśli żadna z witryn połączeń nie została zoptymalizowana, należy usunąć pole przesunięcia. Jeśli wszystkie witryny połączeń zostały zoptymalizowane, należy usunąć pole aktualizatora. W obu przypadkach należy też usunąć połączenie inicjujące. Usuwanie nieużywanych pól i martwego kodu odbywa się już w kompilatorze, ale usunięcie kodu inicjującego wymaga kilku dodatkowych sztuczek.

Zarówno wywołanie funkcji newUpdater, jak i getDeclaredField może mieć skutki uboczne, ponieważ może powodować wyjątki (a ich implementacja jest nieznana, ponieważ zależy od wersji interfejsu API). Oznacza to, że nie można ich bezpiecznie usunąć za pomocą optymalizacji ogólnej. Dlatego czyszczenie wymagało wyraźnego uwzględnienia pól instrumentowanych, ponieważ wiadomo, że statycznie 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 odpowiada teraz wydajności AtomicReference z zastosowaniem R8. W niektórych testach wydajności 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 niepodzielnie.

Głównym beneficjentem tych prac był Jetpack Compose. Środowisko wykonawcze Compose ma wiele mikrotestów, które bardzo dokładnie śledzą wydajność współprogramów, aby wcześnie wykrywać regresje wydajności. Gdy zaktualizowaliśmy testy porównawcze do nowej wersji R8, zauważyliśmy 2-krotną poprawę podczas uruchamiania i anulowania korutyn w LaunchedEffect.

pic03_enhanced.png
Wykres porównawczy ilustrujący czas potrzebny na uruchomienie i anulowanie korutyn w LaunchedEffect (im niższa wartość, tym lepiej). Zmiana na wykresie odpowiada aktualizacji R8, która pokazuje 2-krotny wzrost.

Poza tym zespół ART wdraża te optymalizacje natywnie na poziomie maszyny wirtualnej. Jeśli Twoja aplikacja jest kierowana na interfejs API 36 i działa na najnowszej wersji Androida, urządzenie może już optymalizować współprogramy w podobny sposób. W przypadku testów porównawczych współprogramów w najnowszych wersjach ART zaobserwowano wzrost wydajności o około 15% po aktualizacjach JIT.

Ta optymalizacja zostanie domyślnie zastosowana w Twojej aplikacji 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.

Autorzy:
Czytaj dalej