Fallstudien

Wie R8 Kotlin-Koroutinen unter Android doppelt so schnell macht

7 Minuten Lesezeit

Ab AGP 9.2.0 optimiert R8 die meisten Atomic*FieldUpdater-Aufrufe in Unsafe-Varianten, die bei häufigen Vorgängen 2- bis 4-mal besser abschneiden. Dies hat besonders große Auswirkungen auf die Bibliothek kotlinx.atomicfu, die Atome für kotlinx.coroutines implementiert. Dadurch werden das Starten und Abbrechen von Koroutinen um bis zu 2-mal beschleunigt. Wenn Sie von diesen Vorteilen profitieren möchten, aktualisieren Sie AGP auf 9.2.0 oder höher.

Da die meisten Android-Apps Kotlin als Hauptsprache verwenden, hat sich kotlinx.coroutines zum De-facto-Standard für die asynchrone Programmierung entwickelt. Die Bibliothek bietet eine gut durchdachte und strukturierte Möglichkeit, gleichzeitige Abläufe zu verwalten, die nativ in Kotlin ist. Jetpack Compose ist keine Ausnahme und verwendet Koroutinen zum Verwalten von Zeigerereignissen, Animationen und anderen Interaktionen. Zum Zeitpunkt der Erstellung dieses Artikels rufen die meisten gleichzeitigen APIs in Compose im Hintergrund suspend-Funktionen auf und starten und/oder brechen Koroutinen ab, um Updates zu verarbeiten.

Als das Compose-Team mit der Untersuchung der Leistung begann, stellten sich Koroutinen als Engpass für viele Vorgänge heraus, die außerhalb der Komposition stattfinden. Beispielsweise wurden 80% der Zeit, die für das Erstellen und Aktualisieren von Modifier.clickable aufgewendet wurde, für das Starten und Abbrechen interner Koroutinen verwendet, die InteractionSource-Updates verarbeitet haben. Aufgrund dieser Beobachtungen konzentrierte sich ein Großteil der frühen Leistungsarbeit darauf, Koroutinen aus dem Standardpfad zu entfernen und die Initialisierung zu verzögern, bis sie erforderlich ist. 

Die Kosten einer Koroutine

Die einfachste Möglichkeit, das interne Verhalten einer Funktion unter Android zu analysieren, besteht darin, einen ART-Methodentrace (Android Runtime) zu erfassen. Ein ART-Methodentrace ist ein Tool, das den Ausführungsablauf einer App aufzeichnet und genau zeigt, welche Methoden aufgerufen werden, in welcher Reihenfolge und wie viel Zeit für jede Methode aufgewendet wird. So können Entwickler Leistungsengpässe erkennen. Für einen leeren LaunchedEffect { }-Aufruf sieht das so aus:

pic01_enhanced.png
In der Perfetto-UI visualisierter LaunchedEffect-Methodentrace

Der Methodentrace oben kann in drei Teile unterteilt werden:

  • Neue Koroutine initialisieren
  • Koroutine starten
  • Koroutine abschließen (da sie sofort beendet wird)

Das Abbrechen von LaunchedEffect ähnelt dem normalen Abschluss, es wird jedoch auch eine CancellationException erstellt.

Im Profil oben fällt sofort auf, dass häufig java.util.concurrent.AtomicReferenceFieldUpdater aufgerufen wird (lila oder grüne Kästchen mit j…-Labels). Jeder Aufruf ist zwar relativ schnell, aber die Häufigkeit ist bedenklich. Jeder nicht unerhebliche Overhead, der auf mehrere Aufrufe verteilt ist, kann zu einer spürbaren Regression führen. Wenn wir uns einen Aufruf genauer ansehen, stellen wir fest, dass die meiste Zeit für Reflexionsprüfungen aufgewendet wird.

pic02-enhanced.png
Nahaufnahme des Methodentrace von AtomicReferenceFieldUpdater.get während der LaunchedEffect-Initialisierung

Koroutinen implementieren eine sperrenfreie Baumstruktur für Eltern-Kind-Beziehungen, die eine strukturierte Parallelität ermöglicht. Die kotlinx.atomicfu Bibliothek implementiert sperrenfreie atomare Vorgänge mit einem bekannten JVM-Primitiv, AtomicReferenceFieldUpdater. Der Updater verwendet eine Klassenreferenz und einen Feldnamen, um atomare Vorgänge zur Laufzeit auszuführen. Außerdem muss er mehrere reflektierende Sicherheitsprüfungen durchführen, um sicherzustellen, dass das Feld vorhanden und zugänglich ist. Bei jedem Vorgang in Koroutinen (Starten, Anhalten, Abbrechen, Abschließen) wird mindestens ein atomarer Vorgang aufgerufen. Wenn dieser langsam ist, funktionieren Koroutinen nicht gut.

AtomicReferenceFieldUpdater untersuchen

Aber gehen wir nicht zu schnell vor.AtomicReferenceFieldUpdater ist in der JVM seit über 10 Jahren gut optimiert. Methodentraces erfassen möglicherweise Overhead, der durch eine Optimierung auf VM-Ebene vollständig entfernt wird: Just-in-time- (JIT) oder Ahead-of-time-Kompilierungen (AOT). Um die Leistung zu überprüfen, schreiben wir einige Benchmarks, um den Unterschied zwischen atomaren Referenzen aus kotlinx.atomicfu und java.util.concurrent.atomic zu messen.

@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 */
}

Wenn wir diesen Benchmark auf einem Pixel 5 ausführen und sicherstellen, dass AtomicReferenceFieldUpdater#compareAndSet während des Aufwärmens JIT-kompiliert wird, erhalten wir die folgenden Ergebnisse auf dem Pixel 5 (API 33):

 50.7 ns  atomicReference_compareAndSet
135   ns  atomicRef_compareAndSet

Die Messungen bestätigen die Lücke.Die Version von kotlinx.atomicfu ist deutlich etwa 2, 7-mal langsamer. Das bestätigt, dass ART keine versteckte Optimierung durchführt und reflektierende Zugriffsprüfungen zur Laufzeit echten Overhead verursachen.

Wenn wir uns den ursprünglichen Methodentrace ansehen, ist die einzige sinnvolle Arbeit, die von AtomicReferenceFieldUpdater ausgeführt wird, der interne Aufruf von Unsafe.getObjectVolatile, der den zugrunde liegenden atomaren Vorgang tatsächlich ausführt. In den meisten Fällen ist der Updater-Initialisierer statisch und kann anhand der Struktur der umgebenden Klasse immer als korrekt nachgewiesen werden. Daher könnten die meisten Verwendungen von AtomicReferenceFieldUpdater statisch analysiert und während der Kompilierung durch eine interne Unsafe-Variante ersetzt werden. Die Android-Build-Toolchain hat auch einen eigenen optimierenden Compiler, der genau das kann.

Optimierung mit R8

Die Atomic*FieldUpdater Klassen unterstützen eine subtile, dynamische und reflektierende Verwendung,  werden aber häufig in statisch offensichtlichen Mustern verwendet. Das erklärt sowohl die langsame Baseline-Leistung als auch den Wunsch nach Optimierung. R8 ist ein Compiler zur Optimierung des gesamten Programms und eignet sich gut, um die einfacheren Muster zu erkennen und den Overhead der reflektierenden Sicherheitsprüfungen zu reduzieren. R8 empfängt JVM-Bytecode nach dem Java- oder Kotlin-Compiler. Zur besseren Lesbarkeit werden diese Beispiele jedoch in der Java-Syntax dargestellt. Daher gibt es keine Typargumente für AtomicReferenceFieldUpdater.

class Example {
    volatile String data = "";
    static final AtomicReferenceFieldUpdater updater =
        AtomicReferenceFieldUpdater.newUpdater(Example.class, String.class, "data");

    void example() {
        // ...
        updater.compareAndSet(this, "", "new");
        // ...
    }
}

Im Basisbeispiel wird ein statischer finaler Updater erstellt, der auf ein flüchtiges Feld mit einfachen konstanten Argumenten für den Inhaber, den Typ und den Namen des Felds zugreift. Die verwendete Reflexion ist völlig transparent. Es ist klar zu erkennen, dass dieser Updater auf ein gültiges Feld verweist und dass der Ort der Updater-Erstellung gültigen Zugriff auf das Feld hat.

Im Wesentlichen ist Atomic*FieldUpdater ein Wrapper um einen Feld-Offset und Aufrufe von Unsafe. Im besten Fall wird das Updater-Feld durch ein Offset-Feld ersetzt und die Updater-Aufrufe durch Aufrufe von Unsafe.

Atomic*FieldUpdater optimieren

Die Optimierung ist in drei Teile unterteilt: Instrumentierung, Ersetzen und Bereinigen. 

Instrumentierung

Im ersten Schritt werden Offset-Felder neben dem Updater-Feld eingeführt, um den direkten Zugriff über den Unsafe Aufruf zu ermöglichen.

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

Auf das Feld wird über die Reflexion zugegriffen und Unsafe wird verwendet, um den Feld-Offset in der Klasse zu extrahieren. Dieser Code stellt die Interna von Atomic*FieldUpdater dar, wenn Sie die Reflexionsvalidierung ignorieren. Stattdessen werden der Inhabertyp des Updaters und der Feldtyp des flüchtigen Felds statisch im Compiler verfolgt.

 

Das ursprüngliche Feld und seine Initialisierung bleiben unverändert. Der Optimierungsprozess erleichtert und optimiert die Verwendung und bereinigt später. Dies ist ein einfacher Ansatz für die Implementierung, ermöglicht aber auch eine teilweise Optimierung von Updater-Feldern, bei der einige Verwendungen unverändert bleiben, während andere optimiert werden.

Ersetzen

An dieser Stelle im Compiler, nach einem geeigneten Parallelitäts-Join-Punkt, haben wir eine Liste instrumentierter Updater-Felder. Das bedeutet, dass wir jede Aufrufstelle basierend auf einigen Bedingungen einzeln optimieren können. Ein Beispielaufruf:

updater.compareAndSet(holder, expectedValue, newValue);

Die Bedingungen, die Atomic*FieldUpdater erfordert, sind folgende:

  • Stammt updater aus einem instrumentierten Feld? Das heißt, kann die statische Analyse den Wert des Objekts bis zu einem Feldlesen eines instrumentierten Updaters zurückverfolgen?
  • Ist holder dieselbe Klasse oder eine Unterklasse des ursprünglich definierten Inhabertyps?
  • Ist newValue dieselbe Klasse oder eine Unterklasse des ursprünglich definierten Feldtyps?

Wenn alle Bedingungen erfüllt sind, wird der Aufruf durch einen Aufruf von Unsafe ohne Reflexionsprüfungen ersetzt.

SyntheticUnsafe.UNSAFE.compareAndSwapObject(holder, Example.updater$offset, expectedValue, newValue)

Dieser neue Aufruf ist schneller und einfacher, unterscheidet sich aber vom ursprünglichen Aufruf in Bezug auf die Verarbeitung von Nullwerten in updater und holder. Sofern nicht statisch ausgeschlossen, werden für beide Nullprüfungen eingefügt. 

Bereinigen

An dieser Stelle enthält die Halteklasse das ursprüngliche Updater-Feld und das neue Offset-Feld sowie Aufrufstellen, die eines der beiden verwenden können. Wenn keine der Aufrufstellen optimiert wurde, sollte das Offset-Feld entfernt werden. Wenn alle Aufrufstellen optimiert wurden, sollte das Updater-Feld entfernt werden. In beiden Fällen sollte auch der Initialisierungsaufruf gelöscht werden. Das Löschen nicht verwendeter Felder und das Entfernen von Dead Code erfolgt bereits im Compiler. Das Entfernen des Initialisierungscodes erfordert jedoch einige weitere Tricks.

Sowohl der Aufruf von newUpdater als auch von getDeclaredField kann Nebeneffekte haben, da sie Ausnahmen auslösen können (und ihre Implementierung ist auch unbekannt, da sie von der API-Version abhängt). Daher können sie durch generische Optimierung nicht sicher entfernt werden. Für diese Bereinigung mussten die instrumentierten Felder explizit berücksichtigt werden, da sie statisch als ausnahmefrei bekannt sind.

Am Ende sieht das einfache Updater-Beispiel oben nach der Optimierung so aus:

Ergebnisse

Nach diesen Optimierungen entspricht kotlinx.atomicfu und die meisten expliziten Verwendungen von AtomicInt/Long/ReferenceFieldUpdater jetzt der Leistung von AtomicReference mit angewendetem R8. In einigen Benchmarks ist es sogar schneller. kotlinx.atomicfu hat ein Compiler-Plug-in, das atomic-Instanzen in Felder einfügen kann, wodurch die Zuweisungen reduziert werden, die zum Erstellen eines atomar aktualisierten Felds erforderlich sind.

Jetpack Compose war der Hauptnutzer dieser Arbeit. Die Compose-Laufzeit hat eine Reihe von Microbenchmarks, die die Leistung von Koroutinen sehr genau verfolgen, um Leistungsregressionen frühzeitig zu erkennen. Als die Benchmarks auf eine neue Version von R8 aktualisiert wurden, stellten wir eine 2-fache Verbesserung beim Starten und Abbrechen von Koroutinen in LaunchedEffect fest.

pic03_enhanced.png
Benchmark-Diagramm, das die Zeit zeigt, die zum Starten und Abbrechen von Koroutinen in LaunchedEffect benötigt wird (niedriger ist besser). Die Änderung im Diagramm entspricht einem R8-Update, das eine 2-fache Verbesserung zeigt.

Außerdem implementiert das ART-Team diese Optimierungen nativ auf VM-Ebene. Wenn Ihre App auf API 36 ausgerichtet ist und auf einer aktuellen Version von Android ausgeführt wird, optimiert Ihr Gerät Koroutinen möglicherweise bereits auf ähnliche Weise. In den oben genannten Koroutinen-Benchmarks wurde nach JIT-Updates in den letzten Versionen von ART eine Leistungssteigerung von etwa 15% beobachtet.

Diese Optimierung wird standardmäßig auf Ihre App angewendet, wenn Sie auf AGP 9.2.0 aktualisieren oder R8 9.2.0 direkt verwenden. Weitere Informationen finden Sie unter D8-Dexer und R8-Shrinker.

Geschrieben von:
Weiterlesen