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 de pointe. Cette amélioration s'inscrivait dans le cadre de notre initiative de 2025 visant à réduire le temps de compilation sans sacrifier l'utilisation de la mémoire ni la qualité du code compilé.
L'optimisation de la vitesse de compilation est essentielle pour ART. Par exemple, lors de la compilation juste-à-temps (JIT), elle a un impact direct sur l'efficacité des applications et les performances globales de l'appareil. Des compilations plus rapides réduisent le délai avant l'activation des optimisations, ce qui permet 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 améliore l'autonomie de la batterie et la température de l'appareil, en particulier sur les appareils d'entrée 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 sur les versions 12 et ultérieures sont éligibles pour recevoir ces améliorations via les mises à jour principales.
Optimiser le compilateur d'optimisation
L'optimisation d'un compilateur est toujours un compromis. Vous ne pouvez pas obtenir de la vitesse sans frais. Vous devez faire des concessions. Nous nous sommes fixé un objectif très clair et ambitieux : rendre le compilateur plus rapide, mais sans introduire de régressions 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 approfondir, enquêter et trouver des solutions intelligentes qui répondaient à ces critères stricts. Examinons de plus près comment nous procédons pour identifier les points à améliorer et trouver les bonnes solutions aux différents problèmes.
Identifier les optimisations potentielles intéressantes
Avant de pouvoir commencer à optimiser une métrique, vous devez pouvoir la mesurer. Sinon, vous ne pourrez jamais être sûr de l'avoir améliorée 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 mesurer avant et après une modification, et vous assurer de ne pas limiter la température de votre appareil. De plus, nous disposons également de mesures déterministes, telles que les statistiques du compilateur, qui nous aident à comprendre ce qui se passe sous le capot.
Étant donné que la ressource que nous sacrifiions pour ces améliorations était notre temps de développement, nous voulions pouvoir itérer aussi rapidement que possible. Nous avons donc sélectionné quelques 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. Plus tard, nous avons vérifié que l'implémentation finale 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éclenchions une compilation manuelle en local, obtenions un profil de la compilation et utilisions pprof pour visualiser où nous passions notre temps.
Exemple de graphique de type "flamme" d'un profil dans pprof
L'outil pprof est très puissant et nous permet de segmenter, de filtrer et de 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 de pprof lui-même. 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 "ascendante", dans laquelle vous pouvez voir quelles méthodes prennent le plus de temps. Dans l'image ci-dessous, nous voyons une méthode appelée Kill, qui représente plus de 1 % du temps de compilation. Certaines des autres méthodes les plus importantes seront également abordées plus loin dans cet article de blog.
Vue ascendante d'un profil
Dans notre compilateur d'optimisation, il existe une phase appelée Global Value Numbering (GVN). Vous n'avez pas à vous soucier de ce qu'elle fait dans son ensemble, mais la partie pertinente est de savoir qu'elle comporte une méthode appelée `Kill` qui supprime certains nœuds en fonction d'un filtre. Cela 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 que nous avons actifs à ce moment-là. Dans ces cas, nous pouvons complètement ignorer l'itération, ce qui la fait passer de 1,023 % à environ 0,3 % et améliore l'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 consacré à la compilation.
En général, dans un cas comme celui de `Kill` ci-dessus, nous examinons comment nous itérons sur les nœuds et le faisons plus rapidement, par exemple en effectuant des opérations en parallèle ou en améliorant l'algorithme lui-même. En fait, c'est ce que nous avons essayé au début, et ce n'est que lorsque nous n'avons rien trouvé à faire que nous avons eu un moment de réflexion et avons vu que la solution consistait (dans certains cas) à ne pas itérer du tout ! Lorsque vous effectuez ce type d'optimisations, il est facile de ne pas voir l'ensemble du problème.
Dans d'autres cas, nous avons utilisé plusieurs techniques différentes, dont les suivantes :
- Utiliser des méthodes 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 accélérer le processus
- Calculer les résultats de manière différée pour éviter les cycles dans certains cas
- Utiliser l'abstraction appropriée : les fonctionnalités inutiles peuvent ralentir le code
- Éviter de suivre un pointeur fréquemment utilisé dans de nombreuses charges
Comment savoir si les optimisations valent la peine d'être poursuivies ?
C'est là que ça devient intéressant : vous ne le savez pas. Après avoir détecté qu'une zone consomme beaucoup de temps de compilation et après avoir consacré du temps de développement à essayer de l'améliorer, vous ne trouvez parfois pas de solution. Il n'y a peut-être rien à faire, l'implémentation prendra trop de temps, elle régressera considérablement une autre métrique, augmentera la complexité de la base de code, 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 que vous allez apporter à la métrique en effectuant le moins de travail possible. Cela signifie, dans l'ordre :
- Estimer avec une métrique que vous avez déjà collectée ou simplement avec votre intuition
- Estimer avec un prototype rapide et approximatif
- Implémenter une solution.
N'oubliez pas d'estimer les inconvénients de votre solution. Par exemple, si vous comptez utiliser des structures de données supplémentaires, quelle quantité de mémoire êtes-vous prêt à utiliser ?
Explorer en détail
Sans plus attendre, examinons certaines des modifications que nous avons implémentées.
Nous avons implémenté une modification pour optimiser une méthode appelée FindReferenceInfoOf. Cette méthode effectuait une recherche linéaire d'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). Nous avons également 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 que nous avions insérées dans le vecteur, mais c'était un petit sacrifice à faire, car la mémoire de pointe n'a pas augmenté. Cela a accéléré notre phase LoadStoreAnalysis de 34 à 66 %, ce qui se traduit par une amélioration du temps de compilation d'environ 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, et elle a été modifiée pour être optimisée pour cela. Cependant, de nos jours, elle était utilisée dans le sens inverse, avec seulement quelques entrées et une courte durée de vie. Cela signifiait que nous perdions des cycles en créant cet énorme HashSet, mais 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 %, car 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 des structures de données par référence au lambda pour éviter de les copier. Cette erreur avait été manquée lors de la revue initiale et était restée dans notre base de 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 conduits à 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 examiné le même code et implémenté une nouvelle version qui non seulement a corrigé la régression de la mémoire, mais a également amélioré le temps de compilation d'environ 0,5 à 1,8 % supplémentaires ! Dans 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 intègre les appels de fonction afin d'améliorer les performances. Pour choisir les méthodes à intégrer, nous utilisons à la fois des méthodes heuristiques avant d'effectuer un calcul et des vérifications finales après avoir effectué le travail, mais juste avant de finaliser l'intégration. Si l'une de ces méthodes détecte que l'intégration n'en vaut pas la peine (par exemple, si trop de nouvelles instructions sont 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 "Méthode heuristique" pour estimer si une intégration réussira ou non avant d'effectuer un calcul coûteux en temps. Comme il s'agit d'une estimation, elle n'est pas parfaite, mais nous avons vérifié que nos nouvelles méthodes heuristiques couvrent 99,9 % de ce qui était intégré auparavant sans affecter les performances. L'une de ces nouvelles méthodes 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 ensemble sur les plates-formes 64 bits. Les exemples de fonctions concernées ont été réduits de plus de 1 % au total lors de la compilation du système d'exploitation Android. Cela a été effectué sur plusieurs modifications [1, 2, 3, 4, 5, 6]
Si nous parlions en détail de toutes les optimisations, nous serions ici toute la journée ! Si vous êtes intéressé par d'autres optimisations, consultez d'autres modifications que nous avons implémentées :
- Ajouter une comptabilité pour améliorer les temps de compilation d'environ 0,6 à 1,6 %.
- Calculer les données de manière différée pour éviter les cycles, si possible.
- Refactoriser notre code pour ignorer le travail de précalcul lorsqu'il ne sera pas utilisé.
- Éviter certaines chaînes de chargement dépendantes lorsque l'allocateur peut être facilement obtenu à partir d'autres emplacements.
- Autre cas d'ajout d'une vérification pour éviter un travail inutile.
- Éviter les branchements fréquents sur le type de registre (core/FP) dans l'allocateur de registre.
- S'assurer que certains tableaux sont initialisés au moment de la compilation. Ne pas compter sur clang pour le faire.
- Nettoyer certaines boucles. Utiliser des boucles de plage que clang peut mieux optimiser, car il n'a pas besoin de recharger les pointeurs internes du conteneur en raison des effets secondaires de la boucle. Éviter d'appeler la fonction virtuelle `HInstruction::GetInputRecords()` dans la boucle via `InputAt(.)` intégré pour chaque entrée.
- Éviter les fonctions Accept() pour le modèle de visiteur en exploitant une optimisation du compilateur.
Conclusion
Notre engagement à améliorer la vitesse de compilation d'ART a permis d'obtenir des améliorations significatives, ce qui rend Android plus fluide et plus efficace, tout en contribuant à une meilleure autonomie de la batterie et à une meilleure température de l'appareil. En identifiant et en implémentant avec diligence des 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 réduit le temps de compilation d'un pourcentage notable, mais ont également jeté les bases de futures avancées.
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 via les mises à jour principales. Nous espérons que cette présentation détaillée de notre processus d'optimisation vous fournira des informations précieuses sur les complexités et les avantages de l'ingénierie des compilateurs !
Lire la suite
-
Actualités des produits
Il n'a jamais été aussi facile de tester les interactions multi-appareils qu'avec Android Emulator.
Steven Jenkins • Temps de lecture : 2 min
-
Actualités des produits
Le workflow et les besoins de chaque développeur en matière d'IA sont uniques. Il est donc important de pouvoir choisir comment l'IA vous aide dans votre développement. En janvier, nous avons introduit la possibilité de choisir n'importe quel modèle d'IA local ou distant pour alimenter les fonctionnalités d'IA dans Android Studio.
Matthew Warner • Temps de lecture : 2 min
-
Actualités des produits
Android Studio Panda 3 est désormais stable et prêt à être utilisé en production. Cette version vous offre encore plus de contrôle et de personnalisation sur vos workflows basés sur l'IA, ce qui facilite plus que jamais la création d'applications Android de haute qualité.
Matt Dyor • Temps de lecture : 3 min
Restez informé
Recevez chaque semaine les dernières informations sur le développement Android directement dans votre boîte de réception.