Études de cas

Comment R8 a rendu les coroutines Kotlin sur Android deux fois plus rapides

7 minutes de lecture

À partir d'AGP 9.2.0, R8 optimise la plupart des appels Atomic*FieldUpdater en variantes Unsafe qui offrent des performances deux à quatre fois supérieures pour les opérations courantes. Cela a un impact particulièrement important sur la bibliothèque kotlinx.atomicfu, qui implémente des atomiques pour kotlinx.coroutines, ce qui permet de lancer et d'annuler des coroutines jusqu'à deux fois plus rapidement. Pour profiter de ces avantages, mettez à jour votre AGP vers la version 9.2.0 ou une version ultérieure.

La majorité des applications Android adoptant Kotlin comme langage principal, kotlinx.coroutines est devenu une norme de facto pour la programmation asynchrone. La bibliothèque offre un moyen bien conçu et structuré de gérer les flux simultanés, qui est natif de Kotlin. Jetpack Compose ne fait pas exception et adopte des coroutines pour gérer les événements de pointeur, les animations et d'autres interactions. Au moment de la rédaction de cet article, la plupart des API simultanées de Compose appellent des fonctions suspend en arrière-plan et lancent et/ou annulent des coroutines pour gérer les mises à jour.

Lorsque l'équipe Compose a commencé à étudier les performances, elle a découvert que les coroutines constituaient un goulot d'étranglement pour de nombreuses opérations qui se produisent en dehors de la composition. Par exemple, 80% du temps consacré à la création et à la mise à jour de Modifier.clickable était consommé par le lancement et l'annulation de coroutines internes qui géraient les mises à jour InteractionSource. Sur la base de ces observations, une grande partie des premiers travaux sur les performances ont été axés sur la suppression des coroutines du chemin par défaut et sur le report de l'initialisation jusqu'à ce que cela soit nécessaire. 

Le coût d'une coroutine

Le moyen le plus simple d'analyser le comportement interne d'une fonction sur Android consiste à capturer une trace de méthode Android Runtime (ART). Une trace de méthode ART est un outil qui enregistre le flux d'exécution d'une application, en indiquant exactement les méthodes appelées, leur ordre et le temps passé dans chacune d'elles, ce qui permet aux développeurs d'identifier les goulots d'étranglement affectant les performances. Pour un appel LaunchedEffect { } vide, cela ressemblerait à ceci :

pic01_enhanced.png
Trace de méthode LaunchedEffect visualisée dans l'interface utilisateur de Perfetto

La trace de méthode ci-dessus peut être divisée en trois parties :

  • Initialisation d'une nouvelle coroutine
  • Démarrage de la coroutine
  • Fin de la coroutine (car elle se termine immédiatement)

L'annulation de LaunchedEffect est semblable à une fin normale, sauf qu'elle crée également une CancellationException.

Dans le profil ci-dessus, une chose qui est immédiatement suspecte est la fréquence des appels à java.util.concurrent.AtomicReferenceFieldUpdater (cases violettes ou vertes avec des libellés j…). Bien que chaque appel soit relativement rapide, la fréquence est préoccupante. Tout coût non négligeable réparti sur plusieurs appels peut entraîner une régression notable. En zoomant sur un appel, on constate que la plupart du temps est consacrée à… des vérifications de réflexion ?

pic02-enhanced.png
Gros plan sur la trace de méthode d'AtomicReferenceFieldUpdater.get lors de l'initialisation de LaunchedEffect

Les coroutines implémentent une structure arborescente sans verrou pour les relations parent-enfant, ce qui rend possible la simultanéité structurée. Il s'avère que la bibliothèque kotlinx.atomicfu implémente des opérations atomiques sans verrou à l'aide d'une primitive JVM bien connue, AtomicReferenceFieldUpdater. L'outil de mise à jour utilise une référence de classe et un nom de champ pour effectuer des opérations atomiques au moment de l'exécution. Il doit exécuter plusieurs vérifications de sécurité par réflexion pour s'assurer que le champ existe et est accessible. Chaque opération dans les coroutines (démarrage, suspension, annulation, fin) appelle au moins une opération atomique. Par conséquent, si elle est lente, les coroutines ne fonctionneront pas correctement.

Étude d'AtomicReferenceFieldUpdater

Mais n'allons pas trop vite.AtomicReferenceFieldUpdater est en fait bien optimisé sur JVM depuis plus de 10 ans. Les traces de méthode peuvent capturer des frais généraux qui sont complètement supprimés par une optimisation au niveau de la VM : compilations juste à temps (JIT) ou anticipées (AOT). Pour vérifier les performances, écrivons quelques benchmarks afin de mesurer la différence entre les références atomiques de kotlinx.atomicfu et 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 */
}

L'exécution de ce benchmark sur un Pixel 5 (tout en s'assurant que AtomicReferenceFieldUpdater#compareAndSet est compilé JIT pendant l'échauffement) donne les résultats suivants sur le Pixel 5 (API 33) :

 50.7 ns  atomicReference_compareAndSet
135   ns  atomicRef_compareAndSet

Les mesures confirment l'écart, la version kotlinx.atomicfu étant clairement environ 2,7 fois plus lente. Cela confirme qu'ART n'effectue aucune optimisation masquée et que les vérifications d'accès par réflexion ajoutent un coût réel lors de l'exécution.

En revenant à la trace de méthode d'origine, le seul travail significatif effectué par AtomicReferenceFieldUpdater est l'appel interne à Unsafe.getObjectVolatile qui exécute l'opération atomique sous-jacente. Dans la plupart des cas, l'initialiseur de l'outil de mise à jour est statique et peut être prouvé comme étant toujours correct en fonction de la structure de la classe environnante. Ainsi, il est possible d'analyser statiquement la plupart des utilisations d'AtomicReferenceFieldUpdater et de les remplacer par une variante Unsafe interne lors de la compilation. Il se trouve également que la chaîne d'outils de compilation Android possède son propre compilateur d'optimisation qui peut faire exactement cela.

Optimisation avec R8

Les Atomic*FieldUpdater classes prennent en charge une utilisation subtile, dynamique et basée sur la réflexion,  mais sont souvent utilisées dans des modèles statiquement évidents. Cela explique à la fois les performances de référence lentes et le besoin d'optimisation. R8 est un compilateur d'optimisation de programme complet qui est bien adapté pour identifier les modèles plus simples afin de réduire le coût des vérifications de sécurité par réflexion. R8 reçoit le bytecode JVM après le compilateur Java ou Kotlin, mais pour faciliter la lisibilité, ces exemples sont présentés dans la syntaxe Java. C'est pourquoi il n'y a pas d'arguments de type pour AtomicReferenceFieldUpdater.

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

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

L'exemple de base crée un outil de mise à jour final statique qui accède à un champ volatile avec des arguments constants simples pour le détenteur, le type et le nom du champ. La réflexion utilisée est totalement transparente. Il est clair que cet outil de mise à jour fait référence à un champ valide et que le site de création de l'outil de mise à jour dispose d'un accès valide au champ.

Par essence, Atomic*FieldUpdater est un wrapper autour d'un décalage de champ et d'appels à Unsafe. Le meilleur scénario d'optimisation consiste à remplacer le champ de l'outil de mise à jour par un champ de décalage et à remplacer les appels de l'outil de mise à jour par des appels à Unsafe.

Optimisation d'Atomic*FieldUpdater

L'optimisation est implémentée en trois parties : instrumentation, remplacement et nettoyage. 

Instrumentation

La première étape consiste à introduire des champs de décalage à côté du champ de l'outil de mise à jour afin de faciliter l'accès direct via l'appel Unsafe .

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

Le champ est accessible par réflexion, et Unsafe est utilisé pour extraire le décalage de champ sur la classe. Ce code représente les éléments internes d'Atomic*FieldUpdater si vous ne tenez pas compte de la validation de la réflexion. Au lieu de cela, le type de détenteur de l'outil de mise à jour et le type de champ du champ volatile sont suivis statiquement dans le compilateur.

 

Notez que le champ d'origine et son initialisation sont laissés tels quels. Le processus d'optimisation facilite et optimise de manière optimiste les utilisations, puis effectue un nettoyage. Il s'agit d'une approche simple de l'implémentation, mais elle permet également une optimisation partielle des champs de l'outil de mise à jour, où certaines utilisations sont laissées telles quelles, tandis que d'autres sont optimisées.

Remplacement

À ce stade du compilateur, après un point de jonction de simultanéité approprié, nous disposons d'une liste de champs d'outil de mise à jour instrumentés. Cela signifie que nous pouvons optimiser chaque site d'appel individuellement en fonction de quelques conditions. Prenons l'exemple d'un appel :

updater.compareAndSet(holder, expectedValue, newValue);

Les conditions requises par Atomic*FieldUpdater sont les suivantes :

  • updater provient-il d'un champ instrumenté ? Autrement dit, l'analyse statique peut-elle suivre la valeur de l'objet jusqu'à une lecture de champ d'un outil de mise à jour instrumenté ?
  • holder est-il la même classe ou une sous-classe du type de détenteur défini à l'origine ?
  • newValue est-il la même classe ou une sous-classe du type de champ défini à l'origine ?

Si toutes les conditions sont remplies, l'appel est remplacé par un appel à Unsafe sans aucune vérification de réflexion.

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

Ce nouvel appel est plus rapide et plus simple, mais il diffère de l'appel d'origine en ce qui concerne la gestion des valeurs nulles dans updater et holder. Sauf si elles sont exclues statiquement, des vérifications de valeur nulle sont insérées pour les deux. 

Nettoyage

À ce stade, la classe de détention comporte le champ de l'outil de mise à jour d'origine et le nouveau champ de décalage, ainsi que des sites d'appel qui peuvent utiliser l'un ou l'autre. Si aucun des sites d'appel n'a été optimisé, le champ de décalage doit être supprimé. Si tous les sites d'appel ont été optimisés, le champ de l'outil de mise à jour doit être supprimé. Dans les deux cas, l'appel d'initialisation doit également être supprimé. La suppression des champs inutilisés et du code mort est déjà effectuée dans le compilateur, mais la suppression du code d'initialisation nécessite quelques astuces supplémentaires.

L'appel à newUpdater et getDeclaredField peut avoir des effets secondaires, car ils peuvent générer des exceptions (et leur implémentation est également inconnue, car elle dépend de la version de l'API). Cela signifie que, par optimisation générique, ils ne peuvent pas être supprimés en toute sécurité. Ce nettoyage a donc nécessité une prise en compte explicite des champs instrumentés, car ils sont statiquement connus pour être exempts d'exceptions.

Au final, l'exemple simple d'outil de mise à jour présenté ci-dessus se présente comme suit après optimisation :

Résultats

Après ces optimisations, kotlinx.atomicfu et la plupart des utilisations explicites d'AtomicInt/Long/ReferenceFieldUpdater correspondent désormais aux performances d'AtomicReference avec R8 appliqué. En fait, il est encore plus rapide dans certains benchmarks. kotlinx.atomicfu possède un plug-in de compilateur qui peut intégrer des instances atomic dans des champs, ce qui réduit les allocations nécessaires pour créer un champ mis à jour de manière atomique.

Jetpack Compose a été le principal bénéficiaire de ce travail. L'exécution de Compose comporte un certain nombre de microbenchmarks qui suivent de très près les performances des coroutines afin de détecter rapidement les régressions de performances. Lorsque les benchmarks ont été mis à jour vers une nouvelle version de R8, nous avons constaté une amélioration de 2x lors du lancement et de l'annulation de coroutines dans LaunchedEffect!

pic03_enhanced.png
Graphique de benchmark illustrant le temps nécessaire au démarrage et à l'annulation de coroutines dans LaunchedEffect (plus la valeur est faible, mieux c'est). La modification du graphique correspond à une mise à jour de R8, ce qui montre une amélioration de 2x.

En dehors de cela, l'équipe ART implémente ces optimisations de manière native au niveau de la VM. Si votre application cible l'API 36 et s'exécute sur une version récente d'Android, il est possible que votre appareil optimise déjà les coroutines de manière similaire. Les benchmarks de coroutines ci-dessus ont observé une amélioration d'environ 15% des performances après les mises à jour JIT dans les versions récentes d'ART.

Votre application recevra cette optimisation par défaut lors de la mise à niveau vers AGP 9.2.0 ou en utilisant directement R8 9.2.0. Pour en savoir plus, consultez D8 dexer et R8 shrinker.

Continuer la lecture