Introduzione
Negli ultimi anni, le applicazioni web sono state notevolmente velocizzate. Molte applicazioni ora vengono eseguite abbastanza velocemente da farmi sentire alcuni sviluppatori chiedersi ad alta voce "il web è abbastanza veloce?". Per alcune applicazioni potrebbe esserlo, ma per gli sviluppatori che lavorano su applicazioni ad alte prestazioni sappiamo che non è abbastanza veloce. Nonostante i sorprendenti progressi nella tecnologia delle macchine virtuali JavaScript, uno studio recente ha dimostrato che le applicazioni Google trascorrono tra il 50% e il 70% del loro tempo all'interno di V8. La tua applicazione ha un tempo limitato, quindi ridurre i cicli di un sistema significa che un altro sistema può fare di più. Ricorda che le applicazioni in esecuzione a 60 f/s hanno solo 16 ms per frame, altrimenti si verifica un jank. Continua a leggere per scoprire come ottimizzare JavaScript e profilare le applicazioni JavaScript, in una storia di trincea dei detective delle prestazioni del team V8 che rintracciano un oscuro problema di prestazioni in Find Your Way to Oz.
Sessione di Google I/O 2013
Ho presentato questo materiale alla conferenza Google I/O 2013. Dai un'occhiata al video qui sotto:
Perché le prestazioni sono importanti?
I cicli della CPU sono un gioco a somma zero. Se una parte del sistema ne utilizza meno, puoi utilizzarne di più in un'altra o ottenere un funzionamento complessivamente più fluido. Eseguire le operazioni più velocemente e fare di più sono spesso obiettivi in competizione tra loro. Gli utenti richiedono nuove funzionalità, ma si aspettano anche che l'applicazione funzioni in modo più fluido. Le macchine virtuali JavaScript continuano a diventare più veloci, ma questo non è un motivo per ignorare i problemi di prestazioni che puoi risolvere oggi, come già sanno i molti sviluppatori che si occupano di problemi di prestazioni nelle loro applicazioni web. Nelle applicazioni in tempo reale con frequenza fotogrammi elevata, la pressione per evitare i jank è fondamentale. Insomniac Games ha prodotto uno studio che ha dimostrato che una frequenza fotogrammi solida e sostenuta è importante per il successo di un gioco: "Una frequenza fotogrammi solida è ancora un segno di un prodotto professionale e ben fatto". Sviluppatori web, prendete nota.
Risoluzione dei problemi di prestazioni
Risolvere un problema di prestazioni è come risolvere un crimine. Devi esaminare attentamente le prove, verificare le cause sospette e sperimentare diverse soluzioni. Durante tutto il processo, devi documentare le misurazioni per assicurarti di aver effettivamente risolto il problema. C'è pochissima differenza tra questo metodo e il modo in cui i detective della polizia risolvono un caso. I detective esaminano le prove, interrogano i sospettati ed eseguono esperimenti nella speranza di trovare la prova schiacciante.
V8 CSI: Oz
I fantastici maghi che hanno creato Find Your Way to Oz hanno contattato il team di V8 per un problema di prestazioni che non riuscivano a risolvere da soli. A volte Oz si bloccava, causando jank. Gli sviluppatori di Oz avevano eseguito alcune indagini iniziali utilizzando il pannello Cronologia in Chrome DevTools. Esaminando l'utilizzo della memoria, hanno riscontrato il temuto grafico a dente di sega. Una volta al secondo, il garbage collector raccoglieva 10 MB di dati inutili e le pause della garbage collection corrispondevano ai jank. Simile allo screenshot seguente della cronologia in Chrome DevTools:
I detective di V8, Jakob e Yang, hanno preso in carico il caso. Si è trattato di un lungo scambio di opinioni tra Jakob e Yang del team V8 e il team Oz. Ho riassunto questa conversazione negli eventi importanti che hanno contribuito a individuare il problema.
Prove
Il primo passaggio consiste nel raccogliere e studiare le prove iniziali.
Che tipo di applicazione stiamo esaminando?
La demo di Oz è un'applicazione 3D interattiva. Per questo motivo, è molto sensibile alle pause causate dalle garbage collection. Ricorda che un'applicazione interattiva in esecuzione a 60 f/s ha 16 ms per eseguire tutto il lavoro JavaScript e deve lasciare un po' di tempo a Chrome per elaborare le chiamate grafiche e disegnare lo schermo.
Oz esegue molti calcoli aritmetici su valori doppi ed effettua chiamate frequenti a WebAudio e WebGL.
Che tipo di problema di prestazioni stiamo riscontrando?
Stiamo assistendo a pause, ovvero frame drop, ovvero jank. Queste pause sono correlate alle esecuzioni della garbage collection.
Gli sviluppatori seguono le best practice?
Sì, gli sviluppatori di Oz conoscono bene le prestazioni della VM JavaScript e le tecniche di ottimizzazione. Vale la pena notare che gli sviluppatori di Oz utilizzavano CoffeeScript come linguaggio di origine e producevano codice JavaScript tramite il compilatore CoffeeScript. Questo ha reso alcune parti dell'indagine più complicate a causa della discrepanza tra il codice scritto dagli sviluppatori di Oz e quello utilizzato da V8. Chrome DevTools ora supporta le mappe di origine, che avrebbero semplificato questa operazione.
Perché viene eseguito il garbage collector?
La memoria in JavaScript viene gestita automaticamente per lo sviluppatore dalla VM. V8 utilizza un sistema di garbage collection comune in cui la memoria è divisa in due (o più) generazioni. La generazione giovane contiene oggetti che sono stati allocati di recente. Se un oggetto sopravvive abbastanza a lungo, viene spostato nella generazione precedente.
La generazione giovane viene raccolta con una frequenza molto più elevata rispetto alla generazione precedente. Questo è intenzionale, poiché la raccolta della generazione giovane è molto più economica. Spesso è sicuro presumere che le frequenti pause del GC siano causate dalla raccolta della generazione più recente.
In V8, lo spazio di memoria giovane è diviso in due blocchi di memoria contigui di uguali dimensioni. Solo uno di questi due blocchi di memoria è in uso in un determinato momento ed è chiamato spazio di destinazione. Finché rimane memoria nello spazio di destinazione, l'allocazione di un nuovo oggetto è economica. Un cursore nello spazio di destinazione viene spostato in avanti del numero di byte necessari per il nuovo oggetto. Questo processo continua fino a quando lo spazio di destinazione non è esaurito. A questo punto il programma viene interrotto e inizia la raccolta.
A questo punto, lo spazio di origine e lo spazio di destinazione vengono scambiati. Quello che era lo spazio "to" e ora è lo spazio "from" viene scansionato dall'inizio alla fine e tutti gli oggetti ancora attivi vengono copiati nello spazio "to" o promossi nell'heap della generazione precedente. Se vuoi maggiori dettagli, ti suggerisco di leggere l'articolo sull'algoritmo di Cheney.
Intuitivamente, dovresti capire che ogni volta che un oggetto viene allocato in modo implicito o esplicito (tramite una chiamata a new, [] o {}), la tua applicazione si avvicina sempre di più a una garbage collection e alla temuta pausa dell'applicazione.
È previsto un volume di dati inutili di 10 MB/sec per questa applicazione?
In breve, no. Lo sviluppatore non sta facendo nulla per aspettarsi 10 MB/sec di garbage.
Sospetti
La fase successiva dell'indagine consiste nell'identificare i potenziali sospettati e poi ridurli.
Sospetto n. 1
Chiamata di new durante il frame. Ricorda che ogni oggetto allocato ti avvicina sempre di più a una pausa del garbage collector. In particolare, le applicazioni che vengono eseguite con frequenze fotogrammi elevate dovrebbero puntare a zero allocazioni per fotogramma. In genere, ciò richiede un sistema di riciclo degli oggetti attentamente studiato e specifico per l'applicazione. I detective di V8 hanno contattato il team di Oz, che ha confermato di non chiamare "new". In effetti, il team di Oz era già ben consapevole di questo requisito e ha dichiarato: "Sarebbe imbarazzante". Puoi depennare questa attività dall'elenco.
Sospetto n. 2
Modifica della "forma" di un oggetto al di fuori del costruttore. Questo accade ogni volta che una nuova proprietà viene aggiunta a un oggetto al di fuori del costruttore. In questo modo viene creata una nuova classe nascosta per l'oggetto. Quando il codice ottimizzato vede questa nuova classe nascosta, viene attivata una deottimizzazione e il codice non ottimizzato viene eseguito finché non viene classificato come hot e ottimizzato di nuovo. Questo churn di deottimizzazione e riottimizzazione comporterà un jank,ma non è strettamente correlato alla creazione eccessiva di garbage. Dopo un'attenta verifica del codice, è stato confermato che le forme degli oggetti erano statiche, quindi il sospettato n. 2 è stato escluso.
Sospettato n. 3
Aritmetica nel codice non ottimizzato. Nel codice non ottimizzato, tutti i calcoli comportano l'allocazione di oggetti effettivi. Ad esempio, questo snippet:
var a = p * d;
var b = c + 3;
var c = 3.3 * dt;
point.x = a * b * c;
Vengono creati 5 oggetti HeapNumber. I primi tre sono per le variabili a, b e c. Il quarto è per il valore anonimo (a * b) e il quinto è per #4 * c; il quinto viene infine assegnato a point.x.
Oz esegue migliaia di queste operazioni per frame. Se uno di questi calcoli si verifica in funzioni che non vengono mai ottimizzate, potrebbe essere la causa della garbage collection. Questo perché i calcoli non ottimizzati allocano memoria anche per i risultati temporanei.
Sospettato n. 4
Memorizzazione di un numero a doppia precisione in una proprietà. È necessario creare un oggetto HeapNumber per memorizzare il numero e la proprietà modificata in modo che punti a questo nuovo oggetto. La modifica della proprietà in modo che rimandi a HeapNumber non produrrà dati inutili. Tuttavia, è possibile che molti numeri a doppia precisione vengano archiviati come proprietà degli oggetti. Il codice è pieno di istruzioni come la seguente:
sprite.position.x += 0.5 * (dt);
Nel codice ottimizzato, ogni volta che a x viene assegnato un valore appena calcolato, un'istruzione apparentemente innocua, viene allocato implicitamente un nuovo oggetto HeapNumber, avvicinandoci a una pausa di garbage collection.
Tieni presente che utilizzando un array tipizzato (o un array normale che contiene solo numeri con precisione doppia) puoi evitare completamente questo problema specifico, poiché lo spazio di archiviazione per il numero con precisione doppia viene allocato una sola volta e la modifica ripetuta del valore non richiede l'allocazione di un nuovo spazio di archiviazione.
Il sospettato n. 4 è una possibilità.
Servizi forensi
A questo punto, i detective hanno due possibili sospettati: la memorizzazione dei numeri heap come proprietà degli oggetti e il calcolo aritmetico che avviene all'interno di funzioni non ottimizzate. Era ora di andare in laboratorio e determinare definitivamente quale sospettato fosse colpevole. NOTA: in questa sezione utilizzerò una riproduzione del problema riscontrato nel codice sorgente effettivo di Oz. Questa riproduzione è di ordini di grandezza inferiore rispetto al codice originale, quindi è più facile da analizzare.
Esperimento n. 1
Controllo del sospetto n. 3 (calcolo aritmetico all'interno di funzioni non ottimizzate). Il motore JavaScript V8 ha un sistema di logging integrato che può fornire informazioni dettagliate su ciò che accade dietro le quinte.
Se Chrome non è in esecuzione, avvialo con i flag:
--no-sandbox --js-flags="--prof --noprof-lazy --log-timer-events"
e poi chiudendo completamente Chrome, verrà generato un file di log v8.log nella directory corrente.
Per interpretare i contenuti di v8.log, devi scaricare la stessa versione di v8 utilizzata da Chrome (controlla about:version) e crearla.
Dopo aver creato v8, puoi elaborare il log utilizzando il processore di tick:
$ tools/linux-tick-processor /path/to/v8.log
(Sostituisci mac o windows con linux a seconda della tua piattaforma). (Questo strumento deve essere eseguito dalla directory di origine di primo livello in v8).
Il processore di tick visualizza una tabella basata su testo delle funzioni JavaScript che hanno avuto il maggior numero di tick:
[JavaScript]:
ticks total nonlib name
167 61.2% 61.2% LazyCompile: *opt demo.js:12
40 14.7% 14.7% LazyCompile: unopt demo.js:20
15 5.5% 5.5% Stub: KeyedLoadElementStub
13 4.8% 4.8% Stub: BinaryOpStub_MUL_Alloc_Number+Smi
6 2.2% 2.2% Stub: BinaryOpStub_ADD_OverwriteRight_Number+Number
4 1.5% 1.5% Stub: KeyedStoreElementStub
4 1.5% 1.5% KeyedLoadIC: {12}
2 0.7% 0.7% KeyedStoreIC: {13}
1 0.4% 0.4% LazyCompile: ~main demo.js:30
Puoi vedere che demo.js aveva tre funzioni: opt, unopt e main. Le funzioni ottimizzate sono contrassegnate da un asterisco (*) accanto al nome. Osserva che la funzione opt è ottimizzata e unopt non è ottimizzata.
Un altro strumento importante nella borsa degli attrezzi del detective di V8 è plot-timer-event. Può essere eseguito in questo modo:
$ tools/plot-timer-event /path/to/v8.log
Dopo l'esecuzione, nella directory corrente viene visualizzato un file png denominato timer-events.png. Aprendolo, dovresti vedere qualcosa di simile:
Oltre al grafico in basso, i dati vengono visualizzati in righe. L'asse X è il tempo (ms). Il lato sinistro include le etichette per ogni riga:
La riga V8.Execute ha una linea verticale nera disegnata in corrispondenza di ogni tick del profilo in cui V8 stava eseguendo il codice JavaScript. V8.GCScavenger ha una linea verticale blu disegnata su ogni tick del profilo in cui V8 stava eseguendo una raccolta di nuova generazione. Lo stesso vale per il resto degli stati V8.
Una delle righe più importanti è "code kind being executed" (tipo di codice in esecuzione). Sarà verde ogni volta che viene eseguito il codice ottimizzato e un mix di rosso e blu quando viene eseguito il codice non ottimizzato. Il seguente screenshot mostra la transizione dal codice ottimizzato a quello non ottimizzato e poi di nuovo a quello ottimizzato:
In teoria, ma mai immediatamente, questa linea sarà verde fissa. Ciò significa che il tuo programma è passato a uno stato stabile ottimizzato. Il codice non ottimizzato verrà sempre eseguito più lentamente del codice ottimizzato.
Se hai seguito questa procedura, vale la pena notare che puoi lavorare molto più velocemente rifattorizzando l'applicazione in modo che possa essere eseguita nella shell di debug v8: d8. L'utilizzo di d8 consente di ridurre i tempi di iterazione con gli strumenti tick-processor e plot-timer-event. Un altro effetto collaterale dell'utilizzo di d8 è che diventa più facile isolare il problema effettivo, riducendo la quantità di rumore presente nei dati.
Il grafico degli eventi del timer del codice sorgente di Oz mostrava una transizione dal codice ottimizzato a quello non ottimizzato e, durante l'esecuzione del codice non ottimizzato, sono state attivate molte raccolte di nuova generazione, simili allo screenshot seguente (nota che il tempo è stato rimosso al centro):
Se osservi attentamente, puoi notare che le linee nere che indicano quando V8 sta eseguendo il codice JavaScript mancano esattamente negli stessi tempi di tick del profilo delle raccolte di nuova generazione (linee blu). Questo dimostra chiaramente che, mentre viene eseguita la garbage collection, lo script è in pausa.
Esaminando l'output del processore di tick dal codice sorgente di Oz, la funzione principale (updateSprites) non è stata ottimizzata. In altre parole, la funzione in cui il programma ha impiegato più tempo non era ottimizzata. Questo indica chiaramente che il sospettato n. 3 è il colpevole. Il codice sorgente di updateSprites conteneva loop simili ai seguenti:
function updateSprites(dt) {
for (var sprite in sprites) {
sprite.position.x += 0.5 * dt;
// 20 more lines of arithmetic computation.
}
}
Conoscendo V8 come loro, hanno subito capito che il costrutto del ciclo for-i-in a volte non viene ottimizzato da V8. In altre parole, se una funzione contiene un costrutto di loop for-i-in, potrebbe non essere ottimizzata. Questo è un caso speciale oggi e probabilmente cambierà in futuro, ovvero V8 potrebbe un giorno ottimizzare questo costrutto di loop. Poiché non siamo detective di V8 e non conosciamo V8 come le nostre tasche, come possiamo determinare perché updateSprites non è stato ottimizzato?
Esperimento n. 2
Esecuzione di Chrome con questo flag:
--js-flags="--trace-deopt --trace-opt-verbose"
Visualizza un log dettagliato dei dati di ottimizzazione e deottimizzazione. Se cerchiamo updateSprites nei dati, troviamo:
[disabled optimization for updateSprites, reason: ForInStatement is not fast case]
Proprio come ipotizzato dai detective, il costrutto del ciclo for-i-in era la causa.
Caso chiuso
Dopo aver scoperto il motivo per cui updateSprites non era ottimizzato, la correzione è stata semplice: è bastato spostare il calcolo nella propria funzione, ovvero:
function updateSprite(sprite, dt) {
sprite.position.x += 0.5 * dt;
// 20 more lines of arithmetic computation.
}
function updateSprites(dt) {
for (var sprite in sprites) {
updateSprite(sprite, dt);
}
}
updateSprite verrà ottimizzato, con un numero molto inferiore di oggetti HeapNumber e, di conseguenza, pause di GC meno frequenti. Dovrebbe essere facile confermarlo eseguendo gli stessi esperimenti con il nuovo codice. Il lettore attento noterà che i numeri doppi vengono ancora archiviati come proprietà. Se la profilazione indica che ne vale la pena, la modifica della posizione in un array di double o in un array di dati tipizzati ridurrebbe ulteriormente il numero di oggetti creati.
Epilogo
Gli sviluppatori di Oz non si sono fermati qui. Grazie agli strumenti e alle tecniche condivisi con loro dai detective di V8, sono riusciti a trovare alcune altre funzioni bloccate nell'inferno della deottimizzazione e hanno inserito il codice di calcolo in funzioni foglia ottimizzate, ottenendo prestazioni ancora migliori.
Esci e inizia a risolvere alcuni problemi di prestazioni.