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 schneller sind. 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 die Vorteile nutzen 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 des Schreibens dieses Artikels rufen die meisten gleichzeitigen APIs in Compose im Hintergrund suspend-Funktionen auf und starten und/oder brechen Koroutinen ab, um Aktualisierungen zu verarbeiten.

Als das Compose-Team die Leistung untersuchte, stellte sich heraus, dass Koroutinen für viele Vorgänge außerhalb der Komposition einen Engpass darstellen. 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-Aktualisierungen verarbeitet haben. Aufgrund dieser Beobachtungen konzentrierte sich ein Großteil der frühen Leistungsarbeit darauf, Koroutinen aus dem Standardpfad zu entfernen und die Initialisierung bis zum Bedarf zu verzögern. 

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
Visualisierung des LaunchedEffect-Methodentrace in der Perfetto-UI

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, erzeugt aber auch eine CancellationException.

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 Sie einen Aufruf vergrößern, sehen Sie, 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 Über-/Untergeordnet-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. Dazu müssen mehrere reflektierende Sicherheitsprüfungen durchgeführt werden, 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, ist die Leistung von 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 von 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 Sie diesen Benchmark auf einem Pixel 5 ausführen und sicherstellen, dass AtomicReferenceFieldUpdater#compareAndSet während des Aufwärmens JIT-kompiliert wird, erhalten Sie 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 einen 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 oft in statisch offensichtlichen Mustern verwendet. Das erklärt sowohl die langsame Baseline-Leistung als auch den Wunsch nach Optimierung. R8 ist ein optimierender Compiler für das gesamte Programm 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 mit einfachen konstanten Argumenten für den Inhaber, den Typ und den Namen des Felds auf ein flüchtiges Feld 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 und die Updater-Aufrufe durch Aufrufe von Unsafe ersetzt.

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 optimistisch 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 diesem Punkt 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 auf einen Feldlesevorgang 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 diesem Punkt 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, aber das Entfernen des Initialisierungscodes erfordert hier einige weitere Tricks.

Sowohl der Aufruf von newUpdater als auch getDeclaredField können 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 mit der Zeit, 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 ein Upgrade auf AGP 9.2.0 durchführen oder R8 9.2.0 direkt verwenden. Weitere Informationen finden Sie unter D8-Dexer und R8-Shrinker.

Geschrieben von:
Weiterlesen