In che modo R8 ha reso le coroutine Kotlin su Android due volte più veloci
7 minuti di lettura
A partire da AGP 9.2.0, R8 ottimizza la maggior parte delle chiamate Atomic*FieldUpdater in varianti Unsafe che offrono prestazioni da 2 a 4 volte migliori nelle operazioni comuni. Questo ha un impatto particolarmente significativo sulla libreria kotlinx.atomicfu che implementa le operazioni atomiche per kotlinx.coroutines, rendendo l'avvio e l'annullamento delle coroutine fino a 2 volte più veloci. Per usufruire dei vantaggi, aggiorna AGP alla versione 9.2.0 o successive.
Con la maggior parte delle app Android che adottano Kotlin come linguaggio principale, kotlinx.coroutines è diventato uno standard di fatto per la programmazione asincrona. La libreria offre un modo ben progettato e strutturato per gestire i flussi simultanei che è nativo di Kotlin. Jetpack Compose non fa eccezione, adottando le coroutine per la gestione di eventi puntatore, animazioni e altre interazioni. Al momento della stesura, la maggior parte delle API simultanee in Compose chiama le funzioni suspend in background e avvia e/o annulla le coroutine per gestire gli aggiornamenti.
Quando il team di Compose ha iniziato a esaminare il rendimento, le coroutine si sono rivelate un collo di bottiglia per molte operazioni che avvengono al di fuori della composizione. Ad esempio, l'80% del tempo dedicato alla creazione e all'aggiornamento di Modifier.clickable è stato utilizzato per avviare e annullare le coroutine interne che gestivano gli aggiornamenti di InteractionSource. Sulla base di queste osservazioni, gran parte del lavoro iniziale sul rendimento si è concentrato sulla rimozione delle coroutine dal percorso predefinito e sul ritardo dell'inizializzazione fino a quando non è necessario.
Il costo di una coroutine
Il modo più semplice per analizzare il comportamento interno di una funzione su Android è acquisire una traccia del metodo Android Runtime (ART). Una traccia metodo ART è uno strumento che registra il flusso di esecuzione di un'app, mostrando esattamente quali metodi vengono chiamati, il loro ordine e il tempo trascorso in ciascuno, consentendo agli sviluppatori di identificare i colli di bottiglia del rendimento. Per una chiamata LaunchedEffect { } vuota, sarebbe simile alla seguente:
La traccia del metodo sopra riportata può essere suddivisa in tre parti:
- Inizializzazione di una nuova coroutine
- Avvio della coroutine
- Completamento della coroutine (perché esce immediatamente)
L'annullamento di LaunchedEffect è simile al completamento normale, tranne per il fatto che crea anche un CancellationException.
Dal profilo sopra riportato, una cosa che è immediatamente sospetta sono le chiamate frequenti a java.util.concurrent.AtomicReferenceFieldUpdater (riquadri viola o verdi con etichette j…). Sebbene ogni chiamata sia relativamente veloce, la frequenza è preoccupante; qualsiasi overhead non trascurabile distribuito su più invocazioni potrebbe comportare una regressione notevole. Se si ingrandisce una chiamata, si scopre che la maggior parte del tempo viene spesa per… i controlli di riflessione?
Le coroutine implementano una struttura ad albero senza blocco per le relazioni padre-figlio che rende possibile la simultaneità strutturata. Si scopre che la libreria kotlinx.atomicfu implementa operazioni atomiche senza blocco utilizzando una primitiva JVM nota, AtomicReferenceFieldUpdater. Il programma di aggiornamento utilizza un riferimento di classe e un nome di campo per eseguire operazioni atomiche in fase di runtime e deve eseguire diversi controlli di sicurezza riflessivi per assicurarsi che il campo esista e sia accessibile. Ogni operazione nelle coroutine (avvio, sospensione, annullamento, completamento) chiama almeno un'operazione atomica, quindi se è lenta, le coroutine non funzioneranno bene.
Esame di AtomicReferenceFieldUpdater
Ma non corriamo troppo.AtomicReferenceFieldUpdater è in realtà ben ottimizzato su JVM da oltre 10 anni e le tracce dei metodi potrebbero acquisire un overhead che viene completamente rimosso da un'ottimizzazione a livello di VM: compilazioni just-in-time (JIT) o ahead-of-time (AOT). Per verificare il rendimento, scriviamo alcuni benchmark per misurare la differenza tra i riferimenti atomici di kotlinx.atomicfu e 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'esecuzione di questo benchmark su un Pixel 5 (assicurandosi che AtomicReferenceFieldUpdater#compareAndSet sia compilato JIT durante il riscaldamento) produce i seguenti risultati su Pixel 5 (API 33):
50.7 ns atomicReference_compareAndSet 135 ns atomicRef_compareAndSet
Le misurazioni confermano il divario, con la versione kotlinx.atomicfu che è chiaramente circa 2,7 volte più lenta. Ciò conferma che ART non esegue alcuna ottimizzazione nascosta e che i controlli di accesso riflessivi aggiungono un overhead reale durante il runtime.
Tornando alla traccia del metodo originale, l'unico lavoro significativo eseguito da AtomicReferenceFieldUpdater è la chiamata interna a Unsafe.getObjectVolatile che esegue effettivamente l'operazione atomica sottostante. Nella maggior parte dei casi, l'inizializzatore del programma di aggiornamento è statico e può essere dimostrato che è sempre corretto in base alla struttura della classe circostante. Pertanto, è possibile analizzare staticamente la maggior parte degli utilizzi di AtomicReferenceFieldUpdater e sostituirli con una variante Unsafe interna durante la compilazione. Inoltre, la toolchain di compilazione di Android ha il suo compilatore di ottimizzazione che può fare esattamente questo.
Ottimizzazione con R8
Le Atomic*FieldUpdater classi supportano un utilizzo sottile, dinamico e basato sulla riflessione, ma vengono spesso utilizzate in pattern staticamente ovvi. Questo spiega sia il lento rendimento di base sia la necessità di ottimizzazione. R8 è un compilatore di ottimizzazione a programma completo ed è adatto a individuare i pattern più semplici per ridurre l'overhead dei controlli di sicurezza riflessivi. R8 riceve il bytecode JVM dopo il compilatore Java o Kotlin, ma per facilitare la leggibilità questi esempi sono presentati nella sintassi Java. Questo è il motivo per cui non esistono argomenti di tipo per AtomicReferenceFieldUpdater.
class Example { volatile String data = ""; static final AtomicReferenceFieldUpdater updater = AtomicReferenceFieldUpdater.newUpdater(Example.class, String.class, "data"); void example() { // ... updater.compareAndSet(this, "", "new"); // ... } }
L'esempio di base crea un programma di aggiornamento statico finale che accede a un campo volatile con semplici argomenti costanti per il titolare, il tipo e il nome del campo. La riflessione utilizzata è completamente trasparente. È chiaro che questo programma di aggiornamento fa riferimento a un campo valido e che il sito di creazione del programma di aggiornamento ha un accesso valido al campo.
Nella sua essenza, Atomic*FieldUpdater è un wrapper attorno a un offset di campo e alle chiamate a Unsafe. Lo scenario migliore per l'ottimizzazione è sostituire il campo dell'aggiornamento con un campo di offset e sostituire le chiamate dell'aggiornamento con chiamate a Unsafe.
Ottimizzazione di Atomic*FieldUpdater
L'ottimizzazione viene implementata in tre parti: strumentazione, sostituzione ed eliminazione.
Strumentazione
Il primo passo consiste nell'introdurre i campi di offset insieme al campo dell'aggiornamento per facilitare l'accesso diretto tramite la chiamata Unsafe .
static final long updater$offset = SyntheticUnsafe.UNSAFE.objectFieldOffset(Example.class.getDeclaredField("data"))
Si accede al campo tramite la riflessione e Unsafe viene utilizzato per estrarre l'offset del campo nella classe. Questo codice rappresenta gli elementi interni di Atomic*FieldUpdater se si ignora la convalida della riflessione. Invece, il tipo di titolare del programma di aggiornamento e il tipo di campo del campo volatile vengono monitorati staticamente nel compilatore.
Tieni presente che il campo originale e la relativa inizializzazione vengono lasciati così come sono. La procedura di ottimizzazione facilita e ottimizza in modo ottimistico gli utilizzi e poi li pulisce. Questo è un approccio semplice all'implementazione, ma consente anche l'ottimizzazione parziale dei campi del programma di aggiornamento, in cui alcuni utilizzi vengono lasciati così come sono, mentre altri vengono ottimizzati.
Sostituzione
A questo punto del compilatore, dopo un punto di unione di simultaneità appropriato, abbiamo un elenco di campi di aggiornamento strumentati. Ciò significa che possiamo ottimizzare ogni sito di chiamata individualmente in base ad alcune condizioni. Considera una chiamata di esempio:
updater.compareAndSet(holder, expectedValue, newValue);
Le condizioni richieste da Atomic*FieldUpdater sono le seguenti:
updaterproviene da un campo strumentato? Ovvero, l'analisi statica può tracciare il valore dell'oggetto fino a una lettura del campo di un programma di aggiornamento strumentato?holderè la stessa classe o una sottoclasse del tipo di titolare definito originariamente?newValueè la stessa classe o una sottoclasse del tipo di campo definito originariamente?
Se tutte le condizioni sono soddisfatte, la chiamata viene sostituita da una chiamata a Unsafe senza nessuno dei controlli di riflessione.
SyntheticUnsafe.UNSAFE.compareAndSwapObject(holder, Example.updater$offset, expectedValue, newValue)
Questa nuova chiamata è più veloce e semplice, ma differisce dalla chiamata originale per quanto riguarda la gestione dei valori null in updater e holder. A meno che non siano esclusi staticamente, vengono inseriti controlli null per entrambi.
Eliminazione
A questo punto, la classe di contenimento ha il campo dell'aggiornamento originale e il nuovo campo di offset, insieme ai siti di chiamata che potrebbero utilizzare uno dei due. Se nessuno dei siti di chiamata è stato ottimizzato, il campo di offset deve essere rimosso e, se tutti i siti di chiamata sono stati ottimizzati, il campo dell'aggiornamento deve essere rimosso. In entrambi i casi, anche la chiamata di inizializzazione deve essere eliminata. L'eliminazione dei campi inutilizzati e la rimozione del codice non utilizzato vengono già eseguite nel compilatore, ma la rimozione del codice di inizializzazione qui richiede alcuni trucchi in più.
Sia la chiamata a newUpdater sia getDeclaredField potrebbero avere effetti collaterali, in quanto possono generare eccezioni (e la loro implementazione è sconosciuta perché dipende dalla versione dell'API). Ciò significa che, tramite l'ottimizzazione generica, non possono essere rimossi in sicurezza. Pertanto, questa pulizia richiedeva una considerazione esplicita dei campi strumentati, poiché è noto staticamente che sono privi di eccezioni.
Alla fine, il semplice esempio di programma di aggiornamento mostrato sopra ha il seguente aspetto dopo l'ottimizzazione:
Risultati
Dopo queste ottimizzazioni, kotlinx.atomicfu e la maggior parte degli utilizzi espliciti di AtomicInt/Long/ReferenceFieldUpdater ora corrispondono al rendimento di AtomicReference con R8 applicato. In effetti, è ancora più veloce in alcuni benchmark; kotlinx.atomicfu ha un plug-in del compilatore che può incorporare le istanze atomic nei campi, riducendo le allocazioni necessarie per creare un campo aggiornato in modo atomico.
Jetpack Compose è stato il principale beneficiario di questo lavoro. Il runtime di Compose ha una serie di microbenchmark che monitorano molto attentamente il rendimento delle coroutine per rilevare precocemente le regressioni del rendimento. Quando i benchmark sono stati aggiornati a una nuova versione di R8, abbiamo notato un miglioramento di 2 volte durante l'avvio e l'annullamento delle coroutine in LaunchedEffect!
Oltre a ciò, il team ART sta implementando queste ottimizzazioni in modo nativo a livello di VM. Se la tua app ha come target l'API 36 e viene eseguita su una versione recente di Android, è possibile che il tuo dispositivo stia già ottimizzando le coroutine in modo simile. I benchmark delle coroutine sopra riportati hanno osservato un miglioramento del rendimento di circa il 15% dopo gli aggiornamenti JIT nelle versioni recenti di ART.
La tua app riceverà questa ottimizzazione per impostazione predefinita quando esegui l'upgrade ad AGP 9.2.0 o utilizzi direttamente R8 9.2.0. Per ulteriori informazioni, vedi D8 dexer e R8 shrinker.
-
Case studyLe regressioni del rendimento sono notoriamente difficili da riprodurre, il che le rende un enorme collo di bottiglia per gli sviluppatori di app per dispositivi mobili.
Alice Yuan, Arti Arutiunov, Nikita Ogorodnikov • 4 minuti di lettura -
Case studyDi recente, FotMob ha registrato il più grande aumento in un solo giorno su Wear OS tra il suo pubblico installato in 5 anni, con una media giornaliera di 2-3 volte. Il segreto? Un semplice flusso di installazione cross-device che aiuta gli utenti a scoprire la loro app per Wear OS direttamente dal loro smartphone.
Garan Jenkin • 3 minuti di lettura -
Case studyL'app di mindfulness Gratitude incoraggia la coerenza attraverso micro-registrazioni giornaliere, affermazioni e bacheche di visione. L'app ha oltre 6 milioni di download, 150.000 valutazioni a 5 stelle e 100 milioni di voci di diario registrate.
Amrit Sanjeev, Ash Nohe • 3 minuti di lettura
Ricevi ogni settimana nella tua casella di posta le ultime informazioni sullo sviluppo di Android.