Actualités des produits

Compilations 18 % plus rapides, sans aucun compromis

Temps de lecture : 8 min

L'équipe Android Runtime (ART) a réduit le temps de compilation de 18 % sans compromettre le code compilé ni aucune régression de la mémoire maximale. Cette amélioration s'inscrivait dans le cadre de notre initiative 2025 visant à améliorer le temps de compilation sans sacrifier l'utilisation de la mémoire ni la qualité du code compilé.

Il est essentiel d'optimiser la vitesse de compilation pour ART. Par exemple, lors de la compilation juste-à-temps (JIT), cela a un impact direct sur l'efficacité des applications et sur les performances globales de l'appareil. Des compilations plus rapides réduisent le temps avant que les optimisations ne se déclenchent, ce qui permet d'offrir une expérience utilisateur plus fluide et plus réactive. De plus, pour les compilations JIT et AOT (Ahead-Of-Time), les améliorations de la vitesse de compilation se traduisent par une réduction de la consommation de ressources pendant le processus de compilation, ce qui est bénéfique pour l'autonomie de la batterie et la température de l'appareil, en particulier sur les appareils bas de gamme.

Certaines de ces améliorations de la vitesse de compilation ont été lancées dans la version Android de juin 2025, et le reste sera disponible dans la version Android de fin d'année. De plus, tous les utilisateurs d'Android 12 ou version ultérieure peuvent bénéficier de ces améliorations grâce aux mises à jour mainline.

Optimiser le compilateur d'optimisation

L'optimisation d'un compilateur est toujours un jeu de compromis. Vous ne pouvez pas obtenir de la vitesse sans frais, vous devez faire un sacrifice. Nous nous sommes fixé un objectif très clair et ambitieux : rendre le compilateur plus rapide, mais sans introduire de régression de mémoire et, surtout, sans dégrader la qualité du code qu'il produit. Si le compilateur est plus rapide, mais que les applications s'exécutent plus lentement, nous avons échoué.

La seule ressource que nous étions prêts à dépenser était notre propre temps de développement pour creuser, enquêter et trouver des solutions astucieuses qui répondaient à ces critères stricts. Examinons de plus près comment nous identifions les points à améliorer et trouvons les solutions adaptées aux différents problèmes.

Identifier les optimisations potentielles intéressantes

Avant de pouvoir optimiser une métrique, vous devez être en mesure de la mesurer. Sinon, vous ne pourrez jamais être sûr de l'avoir amélioré ou non. Heureusement pour nous, la vitesse de compilation est assez constante tant que vous prenez certaines précautions, comme utiliser le même appareil pour les mesures avant et après une modification, et vous assurer que votre appareil n'est pas soumis à une limitation thermique. De plus, nous disposons également de mesures déterministes telles que les statistiques du compilateur, qui nous aident à comprendre ce qui se passe en coulisses.

 

Comme la ressource que nous sacrifiions pour ces améliorations était notre temps de développement, nous voulions pouvoir itérer aussi rapidement que possible. Cela signifiait que nous devions sélectionner un certain nombre d'applications représentatives (un mélange d'applications propriétaires, d'applications tierces et du système d'exploitation Android lui-même) pour prototyper des solutions. Nous avons ensuite vérifié que l'implémentation finale en valait la peine grâce à des tests manuels et automatisés à grande échelle.

 

Avec cet ensemble d'APK triés sur le volet, nous déclenchons une compilation manuelle en local, obtenons un profil de la compilation et utilisons pprof pour visualiser où nous passons notre temps.

image.png

Exemple de graphique de type "flamme" d'un profil dans pprof

L'outil pprof est très puissant et nous permet de segmenter, filtrer et trier les données pour voir, par exemple, quelles phases ou méthodes du compilateur prennent le plus de temps. Nous n'entrerons pas dans les détails concernant pprof. Sachez simplement que si la barre est plus grande, cela signifie que la compilation a pris plus de temps.

L'une de ces vues est la vue "de bas en haut", qui vous permet de voir quelles méthodes prennent le plus de temps. Dans l'image ci-dessous, nous pouvons voir une méthode appelée "Kill", qui représente plus de 1 % du temps de compilation. Nous aborderons également certaines des autres méthodes les plus efficaces plus loin dans cet article de blog.

image.png

Vue de profil de bas en haut

Dans notre compilateur d'optimisation, il existe une phase appelée "Global Value Numbering" (GVN). Vous n'avez pas à vous soucier de ce qu'il fait dans son ensemble, mais la partie pertinente est de savoir qu'il possède une méthode appelée "Kill" qui supprimera certains nœuds en fonction d'un filtre. Cette opération prend du temps, car elle doit parcourir tous les nœuds et les vérifier un par un. Nous avons remarqué que, dans certains cas, nous savons à l'avance que la vérification sera fausse, quels que soient les nœuds actifs à ce moment-là. Dans ce cas, nous pouvons ignorer complètement l'itération, ce qui réduit le taux de 1,023 % à environ 0,3 % et améliore le temps d'exécution de GVN d'environ 15 %.

Implémenter des optimisations intéressantes

Nous avons vu comment mesurer et détecter où le temps est dépensé, mais ce n'est que le début. L'étape suivante consiste à optimiser le temps passé à la compilation.

En général, dans un cas comme celui de "Kill" ci-dessus, nous examinons la façon dont nous parcourons les nœuds et nous essayons de le faire plus rapidement, par exemple en effectuant les opérations en parallèle ou en améliorant l'algorithme lui-même. C'est d'ailleurs ce que nous avons essayé au début. Ce n'est que lorsque nous n'avons rien trouvé à faire que nous avons eu un moment de réflexion et que nous avons réalisé que la solution était (dans certains cas) de ne pas itérer du tout ! Lorsque vous effectuez ce type d'optimisations, il est facile de se concentrer sur les détails et de perdre de vue l'ensemble.

Dans d'autres cas, nous avons utilisé différentes techniques, y compris :

  • utiliser des heuristiques pour déterminer si une optimisation ne produira pas de résultats intéressants et peut donc être ignorée.
  • utiliser des structures de données supplémentaires pour mettre en cache les données calculées.
  • modifier les structures de données actuelles pour gagner en rapidité ;
  • Calcul différé des résultats pour éviter les cycles dans certains cas
  • utiliser la bonne abstraction (les fonctionnalités inutiles peuvent ralentir le code) ;
  • éviter de suivre un pointeur fréquemment utilisé à travers de nombreuses charges ;

Comment savoir si les optimisations valent la peine d'être poursuivies ?

C'est le côté pratique : vous n'avez rien à faire. Après avoir détecté qu'une zone consomme beaucoup de temps de compilation et après avoir consacré du temps de développement pour essayer de l'améliorer, il arrive parfois que vous ne trouviez pas de solution. Peut-être n'y a-t-il rien à faire, l'implémentation prendra trop de temps, une autre métrique régressera de manière significative, la complexité de la base de code augmentera, etc. Pour chaque optimisation réussie que vous pouvez voir dans cet article de blog, sachez qu'il en existe d'innombrables autres qui n'ont tout simplement pas abouti.

Si vous vous trouvez dans une situation similaire, essayez d'estimer l'amélioration de la métrique en faisant le moins de travail possible. Cela signifie, dans l'ordre :

  1. Estimer à l'aide d'une métrique que vous avez déjà collectée ou simplement au feeling
  2. Estimer avec un prototype rapide et approximatif
  3. Implémentez une solution.

N'oubliez pas d'estimer les inconvénients de votre solution. Par exemple, si vous prévoyez de vous appuyer sur des structures de données supplémentaires, quelle quantité de mémoire êtes-vous prêt à utiliser ?

Pour aller plus loin

Sans plus attendre, examinons quelques-uns des changements que nous avons apportés.

Nous avons mis en œuvre une modification pour optimiser une méthode appelée FindReferenceInfoOf. Cette méthode effectuait une recherche linéaire dans un vecteur pour trouver une entrée. Nous avons mis à jour cette structure de données pour qu'elle soit indexée par l'ID de l'instruction, de sorte que FindReferenceInfoOf soit O(1) au lieu de O(n). De plus, nous avons pré-alloué le vecteur pour éviter le redimensionnement. Nous avons légèrement augmenté la mémoire, car nous avons dû ajouter un champ supplémentaire qui comptait le nombre d'entrées insérées dans le vecteur. Cependant, il s'agissait d'un petit sacrifice à faire, car la mémoire maximale n'a pas augmenté. Cela a permis d'accélérer la phase LoadStoreAnalysis de 34 à 66 %, ce qui a entraîné une amélioration du temps de compilation de 0,5 à 1,8 %.

Nous avons une implémentation personnalisée de HashSet que nous utilisons à plusieurs endroits. La création de cette structure de données prenait beaucoup de temps, et nous avons découvert pourquoi. Il y a de nombreuses années, cette structure de données n'était utilisée que dans quelques endroits qui utilisaient de très grands HashSets. Elle a été modifiée pour être optimisée à cet effet. Cependant, de nos jours, il est utilisé dans le sens inverse, avec peu d'entrées et une durée de vie courte. Cela signifiait que nous perdions des cycles en créant cet énorme HashSet, mais que nous ne l'utilisions que pour quelques entrées avant de le supprimer. Avec cette modification, nous avons amélioré le temps de compilation d'environ 1,3 à 2 %. De plus, l'utilisation de la mémoire a diminué d'environ 0,5 à 1 % puisque nous n'utilisions plus des structures de données aussi volumineuses qu'avant.

Nous avons amélioré le temps de compilation d'environ 0,5 à 1 % en transmettant les structures de données par référence au lambda pour éviter de les copier. Cette erreur avait échappé à la revue initiale et était restée dans notre code pendant des années. C'est en examinant les profils dans pprof que nous avons remarqué que ces méthodes créaient et détruisaient de nombreuses structures de données, ce qui nous a incités à les examiner et à les optimiser.

Nous avons accéléré la phase d'écriture de la sortie compilée en mettant en cache les valeurs calculées, ce qui s'est traduit par une amélioration du temps de compilation total d'environ 1,3 à 2,8 %. Malheureusement, la comptabilité supplémentaire était trop importante et nos tests automatisés nous ont alertés de la régression de la mémoire. Plus tard, nous avons réexaminé le même code et implémenté une nouvelle version qui a non seulement corrigé la régression de mémoire, mais qui a également amélioré le temps de compilation d'environ 0,5 à 1,8 % supplémentaires. Pour cette deuxième modification, nous avons dû refactoriser et repenser le fonctionnement de cette phase afin de nous débarrasser de l'une des deux structures de données.

Notre compilateur d'optimisation comporte une phase qui insère les appels de fonction pour améliorer les performances. Pour choisir les méthodes à intégrer, nous utilisons à la fois des heuristiques avant tout calcul et des vérifications finales après avoir effectué le travail, mais juste avant de finaliser l'intégration. Si l'un d'eux détecte que l'intégration n'en vaut pas la peine (par exemple, si trop de nouvelles instructions seraient ajoutées), nous n'intégrons pas l'appel de méthode.

Nous avons déplacé deux vérifications de la catégorie "Vérifications finales" vers la catégorie "Heuristique" afin d'estimer si une intégration réussira ou non avant d'effectuer des calculs coûteux en temps. Comme il s'agit d'une estimation, elle n'est pas parfaite.Toutefois, nous avons vérifié que nos nouvelles heuristiques couvrent 99,9 % de ce qui était intégré auparavant, sans affecter les performances. L'une de ces nouvelles heuristiques concernait les registres DEX nécessaires (amélioration d'environ 0,2 à 1,3 %), et l'autre le nombre d'instructions (amélioration d'environ 2 %).

Nous avons une implémentation personnalisée d'un BitVector que nous utilisons à plusieurs endroits. Nous avons remplacé la classe BitVector redimensionnable par une classe BitVectorView plus simple pour certains vecteurs de bits de taille fixe. Cela élimine certaines indirections et vérifications de plage d'exécution, et accélère la construction des objets de vecteur de bits.

De plus, la classe BitVectorView a été modélisée sur le type de stockage sous-jacent (au lieu d'utiliser toujours uint32_t comme l'ancien BitVector). Cela permet à certaines opérations, par exemple Union(), de traiter deux fois plus de bits à la fois sur les plates-formes 64 bits. Les échantillons des fonctions concernées ont été réduits de plus de 1 % au total lors de la compilation de l'OS Android. Cela a été fait à travers plusieurs modifications [123456]

Si nous devions parler en détail de toutes les optimisations, nous serions ici toute la journée ! Si vous souhaitez découvrir d'autres optimisations, consultez les autres modifications que nous avons apportées :

Conclusion

Nous nous sommes efforcés d'améliorer la vitesse de compilation d'ART, ce qui a permis d'obtenir des améliorations significatives. Android est ainsi devenu plus fluide et plus efficace, ce qui a également contribué à améliorer l'autonomie de la batterie et la dissipation thermique des appareils. En identifiant et en implémentant avec diligence les optimisations, nous avons démontré qu'il était possible d'obtenir des gains de temps de compilation importants sans compromettre l'utilisation de la mémoire ni la qualité du code.

Notre parcours a impliqué le profilage avec des outils tels que pprof, une volonté d'itérer et parfois même d'abandonner les pistes moins fructueuses. Les efforts collectifs de l'équipe ART ont non seulement permis de réduire le temps de compilation d'un pourcentage notable, mais ont également jeté les bases de futurs progrès.

Toutes ces améliorations sont disponibles dans la mise à jour Android de fin d'année 2025, ainsi que pour Android 12 et versions ultérieures grâce aux mises à jour mainline. Nous espérons que cette présentation détaillée de notre processus d'optimisation vous a permis de mieux comprendre la complexité et les avantages de l'ingénierie des compilateurs.

Lire la suite