Novità sui prodotti

Compilazioni più veloci del 18%, 0% di compromessi

Tempo di lettura: 8 min

Il team di Android Runtime (ART) ha ridotto il tempo di compilazione del 18% senza compromettere il codice compilato o causare 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 JIT (just-in-time) influisce direttamente sull'efficienza delle applicazioni e sulle prestazioni complessive del dispositivo. Compilazioni più rapide riducono il tempo necessario per l'attivazione delle ottimizzazioni, con un'esperienza utente più fluida e reattiva. Inoltre, sia per JIT che per ahead-of-time (AOT), i miglioramenti della velocità di compilazione si traducono in un consumo ridotto di risorse durante il processo di compilazione, con un impatto positivo sulla durata della batteria e sulla temperatura del dispositivo, in particolare sui dispositivi di fascia bassa.

Alcuni di questi miglioramenti della velocità in fase 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 sono idonei a ricevere questi miglioramenti tramite gli aggiornamenti principali.

Ottimizzazione del compilatore di ottimizzazione

L'ottimizzazione di un compilatore è sempre un gioco di compromessi. La velocità non è un vantaggio senza costi, ma richiede un compromesso. Ci siamo posti un obiettivo molto chiaro e impegnativo: velocizzare il compilatore, ma senza introdurre regressioni della memoria e, soprattutto, senza compromettere la qualità del codice prodotto. 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, indagare e trovare soluzioni intelligenti che soddisfacessero questi criteri rigorosi. Diamo un'occhiata più da vicino a come lavoriamo per trovare 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 sapere con certezza se hai migliorato o meno il tuo sito. Fortunatamente per noi, la velocità del tempo di compilazione è abbastanza costante, a condizione che tu prenda alcune precauzioni, come l'utilizzo dello stesso dispositivo che usi per la misurazione prima e dopo una modifica e l'assicurarti di non limitare termicamente il dispositivo. Inoltre, abbiamo anche misurazioni deterministiche come le statistiche del compilatore che ci aiutano a capire cosa sta succedendo dietro le quinte.

 

Poiché la risorsa che stavamo sacrificando per questi miglioramenti era il nostro tempo di sviluppo, volevamo essere in grado di eseguire l'iterazione il più velocemente possibile. Ciò significa che abbiamo preso una manciata di app rappresentative (un mix di app proprietarie, app di terze parti e il sistema operativo Android stesso) per creare prototipi di soluzioni. In seguito, abbiamo verificato che l'implementazione finale fosse valida con test manuali e automatici su larga scala.

 

Con questo set di APK selezionati, attivavamo una compilazione manuale in locale, ottenevamo un profilo della compilazione e utilizzavamo pprof per visualizzare dove stavamo impiegando il nostro tempo.

image.png

Esempio di grafico a fiamme di un profilo in pprof

Lo strumento pprof è molto potente e ci consente di suddividere, filtrare e ordinare i dati per vedere, ad esempio, quali fasi o metodi del compilatore richiedono più tempo. Non entreremo nel dettaglio di pprof; sappi solo 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 principali verranno discussi più avanti nel blog post.

image.png

Vista dal basso verso l'alto di un profilo

Nel nostro compilatore di ottimizzazione, esiste una fase chiamata Global Value Numbering (GVN). Non devi preoccuparti di ciò che fa nel suo complesso, ma la parte pertinente è sapere che ha una chiamata al metodo chiamata "Kill" che eliminerà alcuni nodi in base a un filtro. Questa operazione richiede molto tempo, poiché deve eseguire l'iterazione di 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, portandola dall'1,023% a circa lo 0,3% e migliorando il runtime di GVN di circa il 15%.

Implementare ottimizzazioni utili

Abbiamo spiegato come misurare e rilevare dove viene impiegato il tempo, ma questo è solo l'inizio. Il passaggio successivo consiste nell'ottimizzare il tempo impiegato per la compilazione.

In genere, in un caso come quello di "Kill" riportato sopra, esaminiamo il modo in cui eseguiamo l'iterazione dei nodi e la velocizziamo, ad esempio eseguendo le operazioni in parallelo o migliorando l'algoritmo stesso. In effetti, è quello che abbiamo provato a fare all'inizio e solo quando non siamo riusciti a trovare nulla da fare abbiamo avuto un momento di "Aspetta un attimo…" e abbiamo capito che la soluzione era (in alcuni casi) non eseguire alcuna iterazione. Quando si eseguono questo tipo di ottimizzazioni, è facile perdere di vista il quadro generale.

In altri casi, abbiamo utilizzato una serie di tecniche diverse, tra cui:

  • Utilizzo di euristiche per decidere se un'ottimizzazione non produrrà risultati utili e, pertanto, può essere saltata
  • utilizzando strutture di dati aggiuntive per memorizzare nella cache i dati calcolati
  • modificare le strutture di dati attuali per ottenere una zona turbo
  • calcolo dei risultati in modalità lazy per evitare cicli in alcuni casi
  • utilizza l'astrazione corretta: le funzionalità non necessarie possono rallentare il codice
  • evitare di inseguire un puntatore utilizzato di frequente attraverso molti caricamenti

Come facciamo a sapere se vale la pena implementare le ottimizzazioni?

È proprio questo il bello: non devi farlo. Dopo aver rilevato che un'area consuma molto tempo di compilazione e dopo aver dedicato del tempo allo sviluppo per cercare di migliorarla, a volte non è possibile trovare una soluzione. Forse non c'è nulla da fare, l'implementazione richiederà troppo tempo, un'altra metrica subirà una regressione significativa, la complessità della codebase aumenterà e così via. Per ogni ottimizzazione riuscita che potete vedere in questo blog post, sappiate che ce ne sono innumerevoli altre che non hanno dato i risultati sperati.

Se ti trovi in una situazione simile, prova a stimare di quanto migliorerai la metrica svolgendo il minor lavoro possibile. Ciò significa, nell'ordine:

  1. Stima con metriche già raccolte o semplicemente con un'intuizione
  2. Stima con un prototipo rapido e approssimativo
  3. Implementa una soluzione.

Non dimenticare di valutare gli svantaggi della tua soluzione. Ad esempio, se intendi fare affidamento su 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 pre-allocato 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 è stato un piccolo sacrificio da fare, poiché la memoria di picco non è aumentata. In questo modo, la fase LoadStoreAnalysis è stata velocizzata del 34-66%, il che a sua volta ha comportato 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 di dati richiedeva molto tempo e abbiamo scoperto il motivo. Molti anni fa, questa struttura di dati veniva utilizzata solo in pochi punti che utilizzavano HashSet molto grandi ed è stata modificata per essere ottimizzata per questo scopo. Tuttavia, oggi è stato utilizzato nella direzione opposta con solo poche voci e con una breve durata. Ciò significava che stavamo sprecando cicli creando questo enorme HashSet, ma lo utilizzavamo solo per poche voci prima di eliminarlo. 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 grandi come prima.

Abbiamo migliorato di circa lo 0,5-1% il tempo di compilazione passando le strutture di dati per riferimento alla funzione lambda per evitare di copiarle. Questo aspetto è stato trascurato nella revisione originale ed è rimasto nel nostro codebase per anni. È stato grazie all'analisi dei 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 memorizzando nella cache i valori calcolati, il che ha comportato 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 nuovamente lo stesso codice e implementato una nuova versione che non solo ha risolto la regressione della memoria, ma ha anche migliorato il tempo di compilazione di un ulteriore ~0,5-1,8%. In questa seconda modifica abbiamo dovuto rifattorizzare e ripensare il funzionamento di questa fase per eliminare una delle due strutture dati.

Il nostro compilatore di ottimizzazione prevede una fase che inserisce le chiamate di funzione in linea 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 eseguito il lavoro, ma subito prima di finalizzare l'incorporamento. Se uno di questi controlli rileva che l'inlining non è conveniente (ad esempio, se verrebbero aggiunte troppe nuove istruzioni), non eseguiamo l'inlining della chiamata al metodo.

Abbiamo spostato due controlli dalla categoria "controlli finali" alla categoria "euristica" per stimare se un'incorporazione avrà esito positivo o meno prima di eseguire qualsiasi calcolo che richiede molto tempo. Poiché si tratta di una stima, non è perfetta, ma abbiamo verificato che le nostre nuove euristiche coprono il 99,9% di ciò che era in linea in precedenza senza influire sulle prestazioni. 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 BitVectorView più semplice per determinati vettori di bit di dimensioni fisse. In questo modo vengono eliminate alcune indirezioni e controlli dell'intervallo di runtime e viene velocizzata la creazione degli oggetti del vettore di bit.

Inoltre, la classe BitVectorView è stata basata su un modello del tipo di archiviazione sottostante (anziché utilizzare sempre uint32_t come il vecchio BitVector). In questo modo, alcune operazioni, ad esempio Union(), possono elaborare il doppio dei bit insieme su 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. Questo è stato fatto attraverso diverse modifiche [1, 2, 3, 4, 5, 6]

Se parlassimo in dettaglio di tutte le ottimizzazioni, non finiremmo più. Se ti interessano altre ottimizzazioni, dai un'occhiata ad alcune altre modifiche che abbiamo implementato:

Conclusione

Il nostro impegno per migliorare la velocità di compilazione di ART ha portato a miglioramenti significativi, rendendo Android più fluido ed efficiente e contribuendo al contempo a migliorare la durata della batteria e la temperatura del dispositivo. Identificando e implementando diligentemente le ottimizzazioni, abbiamo dimostrato che è possibile ottenere miglioramenti sostanziali del tempo di compilazione senza compromettere la memoria utilizzata o la qualità del codice.

Il nostro percorso ha incluso la profilazione con strumenti come pprof, la volontà di eseguire iterazioni 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 significativa, ma hanno anche gettato le basi per futuri progressi.

Tutti questi miglioramenti sono disponibili nell'aggiornamento di Android di fine anno 2025 e per Android 12 e versioni successive tramite gli aggiornamenti principali. 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