Il team di Android Runtime (ART) ha ridotto il tempo di compilazione del 18% senza compromettere il codice compilato o eventuali regressioni della memoria di picco. Questo miglioramento fa parte della nostra iniziativa del 2025 per migliorare il tempo di compilazione senza sacrificare la memoria utilizzata o la qualità del codice compilato.
L'ottimizzazione della velocità di compilazione è fondamentale per ART. Ad esempio, la compilazione just-in-time (JIT) influisce direttamente sull'efficienza delle applicazioni e sulle prestazioni complessive del dispositivo. Compilazioni più rapide riducono il tempo prima che le ottimizzazioni vengano attivate, il che si traduce in un'esperienza utente più fluida e reattiva. Inoltre, sia per JIT che per AOT, i miglioramenti della velocità di compilazione si traducono in un ridotto consumo di risorse durante il processo di compilazione, a vantaggio della durata della batteria e della temperatura del dispositivo, soprattutto sui dispositivi di fascia bassa.
Alcuni di questi miglioramenti della velocità di compilazione sono stati lanciati nella release di Android di giugno 2025, mentre gli altri saranno disponibili nella release di Android di fine anno. Inoltre, tutti gli utenti Android con versioni 12 e successive possono ricevere questi miglioramenti tramite gli aggiornamenti mainline.
Ottimizzazione del compilatore di ottimizzazione
L'ottimizzazione di un compilatore è sempre un gioco di compromessi. Non puoi ottenere velocità senza costi, devi rinunciare a qualcosa. Ci siamo posti un obiettivo molto chiaro e ambizioso: rendere il compilatore più veloce, ma senza introdurre regressioni della memoria e, soprattutto, senza peggiorare la qualità del codice che produce. Se il compilatore è più veloce, ma le app vengono eseguite più lentamente, abbiamo fallito.
L'unica risorsa che eravamo disposti a spendere era il nostro tempo di sviluppo per approfondire, esaminare e trovare soluzioni intelligenti che soddisfacessero questi criteri rigorosi. Vediamo più da vicino come lavoriamo per trovare le aree di miglioramento e le soluzioni giuste ai vari problemi.
Trovare possibili ottimizzazioni utili
Prima di poter iniziare a ottimizzare una metrica, devi essere in grado di misurarla. In caso contrario, non potrai mai essere certo di averlo migliorato o meno. Fortunatamente per noi, la velocità di compilazione è abbastanza costante, a condizione che tu prenda alcune precauzioni, ad esempio utilizzare lo stesso dispositivo per la misurazione prima e dopo una modifica e assicurarti che il dispositivo non subisca throttling termico. Inoltre, disponiamo anche di misurazioni deterministiche come le statistiche del compilatore che ci aiutano a capire cosa succede in background.
Poiché la risorsa che stavamo sacrificando per questi miglioramenti era il nostro tempo di sviluppo, volevamo essere in grado di eseguire iterazioni il più rapidamente possibile. Ciò significava che abbiamo preso una manciata di app rappresentative (un mix di app proprietarie, app di terze parti e il sistema operativo Android stesso) per prototipare le soluzioni. Successivamente, abbiamo verificato che l'implementazione finale valesse la pena con test manuali e automatizzati in modo diffuso.
Con questo insieme di APK selezionati manualmente, attiviamo una compilazione manuale in locale, otteniamo un profilo della compilazione e utilizziamo pprof per visualizzare dove impieghiamo il nostro tempo.
Esempio di grafico a fiamme di un profilo in pprof
Lo strumento pprof è molto potente e ci consente di sezionare, filtrare e ordinare i dati per vedere, ad esempio, quali fasi o metodi del compilatore richiedono più tempo. Non entreremo nei dettagli di pprof, ma sappi che se la barra è più grande significa che la compilazione ha richiesto più tempo.
Una di queste visualizzazioni è quella "dal basso verso l'alto", in cui puoi vedere quali metodi richiedono più tempo. Nell'immagine seguente possiamo vedere un metodo chiamato Kill, che rappresenta oltre l'1% del tempo di compilazione. Alcuni degli altri metodi più efficaci verranno trattati più avanti nel post del blog.
Visualizzazione dal basso di un profilo
Nel nostro compilatore di ottimizzazione, esiste una fase chiamata Global Value Numbering (GVN). Non devi preoccuparti di cosa fa nel complesso, ma la parte pertinente è sapere che ha un metodo chiamato "Kill" che elimina alcuni nodi in base a un filtro. Questa operazione richiede molto tempo, in quanto deve eseguire l'iterazione su tutti i nodi e controllarli uno per uno. Abbiamo notato che in alcuni casi sappiamo in anticipo che il controllo sarà falso, indipendentemente dai nodi attivi in quel momento. In questi casi, possiamo saltare del tutto l'iterazione, riducendo il valore dall'1,023% a circa lo 0,3% e migliorando il runtime di GVN di circa il 15%.
Implementazione di ottimizzazioni utili
Abbiamo visto come misurare e rilevare dove viene impiegato il tempo, ma questo è solo l'inizio. Il passaggio successivo consiste nell'ottimizzazione del tempo dedicato alla compilazione.
Di solito, in un caso come quello di "Kill" riportato sopra, esaminiamo il modo in cui eseguiamo l'iterazione dei nodi e lo facciamo più velocemente, ad esempio eseguendo le operazioni in parallelo o migliorando l'algoritmo stesso. Infatti, è quello che abbiamo provato all'inizio e solo quando non siamo riusciti a trovare nessun risultato da fare abbiamo avuto un momento di illuminazione e abbiamo capito che la soluzione era (in alcuni casi) non eseguire l'iterazione affatto. Quando si eseguono questo tipo di ottimizzazioni, è facile perdersi nei dettagli.
In altri casi, abbiamo utilizzato diverse tecniche, tra cui:
- utilizzando l'euristica per decidere se un'ottimizzazione non produrrà risultati utili e quindi può essere ignorata
- utilizzando strutture di dati aggiuntive per memorizzare nella cache i dati calcolati
- modificando le strutture di dati attuali per aumentare la velocità
- calcolo pigro dei risultati per evitare cicli in alcuni casi
- utilizzare l'astrazione corretta: le funzionalità non necessarie possono rallentare il codice
- evitare di inseguire un puntatore utilizzato di frequente in molti caricamenti
Come facciamo a sapere se vale la pena perseguire le ottimizzazioni?
La cosa bella è che non devi farlo. Dopo aver rilevato che un'area consuma molto tempo di compilazione e dopo aver dedicato tempo allo sviluppo per cercare di migliorarla, a volte non riesci a trovare una soluzione. Forse non c'è nulla da fare, l'implementazione richiederà troppo tempo, un'altra metrica regredirà in modo significativo, la complessità della base di codice aumenterà e così via. Per ogni ottimizzazione riuscita che puoi vedere in questo post del blog, sappi che ce ne sono innumerevoli altre che non sono state realizzate.
Se ti trovi in una situazione simile, prova a stimare di quanto migliorerai la metrica svolgendo il minor lavoro possibile. Ciò significa, in ordine:
- Stima con metriche già raccolte o semplicemente con l'istinto
- Stima con un prototipo rapido e semplice
- Implementa una soluzione.
Non dimenticare di stimare gli svantaggi della tua soluzione. Ad esempio, se intendi utilizzare strutture di dati aggiuntive, quanta memoria sei disposto a utilizzare?
Approfondimenti
Senza ulteriori indugi, diamo un'occhiata ad alcune delle modifiche che abbiamo implementato.
Abbiamo implementato una modifica per ottimizzare un metodo chiamato FindReferenceInfoOf. Questo metodo eseguiva una ricerca lineare di un vettore per trovare una voce. Abbiamo aggiornato la struttura dei dati in modo che venga indicizzata in base all'ID dell'istruzione, in modo che FindReferenceInfoOf sia O(1) anziché O(n). Inoltre, abbiamo preassegnato il vettore per evitare il ridimensionamento. Abbiamo aumentato leggermente la memoria perché abbiamo dovuto aggiungere un campo aggiuntivo che contava il numero di voci inserite nel vettore, ma si è trattato di un piccolo sacrificio da fare, dato che la memoria di picco non è aumentata. In questo modo, la fase LoadStoreAnalysis è stata velocizzata del 34-66%, il che a sua volta comporta un miglioramento del tempo di compilazione di circa lo 0,5-1,8%.
Abbiamo un'implementazione personalizzata di HashSet che utilizziamo in diversi punti. La creazione di questa struttura dei dati richiedeva molto tempo e abbiamo scoperto il motivo. Molti anni fa, questa struttura di dati veniva utilizzata solo in pochi punti che utilizzavano HashSets molto grandi ed è stata modificata per essere ottimizzata per questo scopo. Tuttavia, al giorno d'oggi viene utilizzato nella direzione opposta, con poche voci e una durata breve. Ciò significava che sprecavamo cicli creando questo enorme HashSet, ma lo utilizzavamo solo per poche voci prima di scartarlo. Con questa modifica, abbiamo migliorato il tempo di compilazione di circa l'1,3-2%. Inoltre, la memoria utilizzata è diminuita di circa lo 0,5-1% perché non utilizzavamo strutture di dati di grandi dimensioni come prima.
Abbiamo migliorato il tempo di compilazione di circa lo 0,5-1% passando le strutture di dati per riferimento alla lambda per evitare di copiarle. Si tratta di un problema che non era stato rilevato nella revisione originale ed è rimasto nel nostro codice sorgente per anni. È stato grazie a un'occhiata ai profili in pprof che abbiamo notato che questi metodi creavano e distruggevano molte strutture di dati, il che ci ha portato a esaminarli e ottimizzarli.
Abbiamo velocizzato la fase di scrittura dell'output compilato mediante la memorizzazione nella cache dei valori calcolati, il che si è tradotto in un miglioramento del tempo di compilazione totale di circa l'1,3-2,8%. Purtroppo, la contabilità aggiuntiva era eccessiva e i nostri test automatici ci hanno avvisato della regressione della memoria. In seguito, abbiamo esaminato di nuovo lo stesso codice e implementato una nuova versione che non solo ha risolto il problema della regressione della memoria, ma ha anche migliorato il tempo di compilazione di un ulteriore 0,5-1,8% circa. In questa seconda modifica abbiamo dovuto eseguire il refactoring e ripensare il funzionamento di questa fase per eliminare una delle due strutture dati.
Il nostro compilatore di ottimizzazione ha una fase che in linea le chiamate di funzione per ottenere un rendimento migliore. Per scegliere i metodi da incorporare, utilizziamo sia l'euristica prima di eseguire qualsiasi calcolo sia i controlli finali dopo aver svolto il lavoro, ma prima di finalizzare l'incorporamento. Se uno di questi rileva che l'incorporamento non vale la pena (ad esempio, verrebbero aggiunte troppe nuove istruzioni), la chiamata al metodo non viene incorporata.
Abbiamo spostato due controlli dalla categoria "Controlli finali" alla categoria "Euristiche" per stimare se l'incorporamento avrà esito positivo o meno prima di eseguire qualsiasi calcolo dispendioso in termini di tempo. Poiché si tratta di una stima, non è perfetta, ma abbiamo verificato che la nostra nuova euristica copre il 99,9% di ciò che era inlined in precedenza senza influire sul rendimento. Una di queste nuove euristiche riguardava i registri DEX necessari (miglioramento di circa lo 0,2-1,3%) e l'altra il numero di istruzioni (miglioramento di circa il 2%).
Abbiamo un'implementazione personalizzata di un BitVector che utilizziamo in diversi punti. Abbiamo sostituito la classe BitVector ridimensionabile con una classe BitVectorView più semplice per determinati vettori di bit di dimensioni fisse. In questo modo vengono eliminate alcune indirezioni e i controlli dell'intervallo di runtime e viene accelerata la costruzione degli oggetti vettoriali di bit.
Inoltre, la classe BitVectorView è stata resa basata su modelli per il tipo di archiviazione sottostante (anziché utilizzare sempre uint32_t come il vecchio BitVector). Ciò consente ad alcune operazioni, ad esempio Union(), di elaborare il doppio dei bit insieme sulle piattaforme a 64 bit. I campioni delle funzioni interessate sono stati ridotti di oltre l'1% in totale durante la compilazione del sistema operativo Android. Questa operazione è stata eseguita in diverse modifiche [1, 2, 3, 4, 5, 6]
Se parlassimo in dettaglio di tutte le ottimizzazioni, ci vorrebbe un'intera giornata. Se ti interessano altre ottimizzazioni, dai un'occhiata ad altre modifiche che abbiamo implementato:
- Aggiungi la contabilità per migliorare i tempi di compilazione di circa lo 0,6-1,6%.
- Calcola i dati in modo differito per evitare cicli, se possibile.
- Riorganizzare il codice per saltare il lavoro di precalcolo quando non verrà utilizzato.
- Evita alcune catene di caricamento dipendenti quando l'allocatore può essere facilmente ottenuto da altre posizioni.
- Un altro caso di aggiunta di un controllo per evitare lavoro non necessario.
- Evita ramificazioni frequenti sul tipo di registro (core/FP) nell'allocatore di registri.
- Assicurati che alcuni array vengano inizializzati in fase di compilazione. Non fare affidamento su clang per farlo.
- Libera spazio da alcuni loop. Utilizza cicli di intervallo che clang può ottimizzare meglio perché non deve ricaricare i puntatori interni del contenitore a causa degli effetti collaterali del ciclo. Evita di chiamare la funzione virtuale `HInstruction::GetInputRecords()` nel ciclo tramite `InputAt(.)` incorporato per ogni input.
- Evita le funzioni Accept() per il pattern Visitor sfruttando un'ottimizzazione del compilatore.
Conclusione
Il nostro impegno per migliorare la velocità di compilazione di ART ha portato a miglioramenti significativi, rendendo Android più fluido ed efficiente, contribuendo al contempo a una migliore durata della batteria e termica del dispositivo. Identificando e implementando diligentemente le ottimizzazioni, abbiamo dimostrato che è possibile ottenere notevoli miglioramenti del tempo di compilazione senza compromettere la memoria utilizzata o la qualità del codice.
Il nostro percorso ha comportato la profilazione con strumenti come pprof, la volontà di iterare e, a volte, anche l'abbandono di strade meno fruttuose. Gli sforzi collettivi del team ART non solo hanno ridotto il tempo di compilazione di una percentuale notevole, ma hanno anche gettato le basi per i futuri progressi.
Tutti questi miglioramenti sono disponibili nell'aggiornamento di fine anno di Android 2025 e per Android 12 e versioni successive tramite gli aggiornamenti mainline. Ci auguriamo che questo approfondimento sul nostro processo di ottimizzazione fornisca informazioni preziose sulle complessità e sui vantaggi dell'ingegneria dei compilatori.
Continua a leggere
-
Novità sul prodotto
Rendere Google Play un'esperienza più sicura e affidabile possibile. Oggi annunciamo un nuovo insieme di aggiornamenti delle norme e una funzionalità di trasferimento dell'account per migliorare la privacy degli utenti e proteggere la tua attività dalle frodi.
Bennet Manuel • Lettura di 3 minuti
-
Novità sul prodotto
Testare le interazioni multi-dispositivo è ora più facile che mai con l'emulatore Android.
Steven Jenkins • Lettura di 2 minuti
-
Novità sul prodotto
Android Studio supporta Gemma 4: il nostro modello locale più potente per la programmazione agentica
Il flusso di lavoro e le esigenze di ogni sviluppatore in materia di AI sono unici ed è importante poter scegliere in che modo l'AI può aiutarti nello sviluppo. A gennaio abbiamo introdotto la possibilità di scegliere qualsiasi modello di AI locale o remoto per potenziare la funzionalità di AI in Android Studio
Matthew Warner • Lettura di 2 minuti
Segui gli aggiornamenti
Ricevi ogni settimana gli ultimi approfondimenti sullo sviluppo per Android direttamente nella tua casella di posta.