Un riquadro Prestazioni il 400% più veloce grazie alla percezione

Andrés Olivares
Andrés Olivares
Nancy Li
Nancy Li

Indipendentemente dal tipo di applicazione che stai sviluppando, è fondamentale per l'esperienza utente e per il successo dell'applicazione, ottimizzare le sue prestazioni, garantire che carichi velocemente e che offra interazioni fluide. Un modo per farlo è esaminare l'attività di un'applicazione utilizzando gli strumenti di profilazione per vedere cosa sta succedendo dietro le quinte dell'esecuzione in un intervallo di tempo. Il riquadro Rendimento in DevTools è un ottimo strumento di profilazione per analizzare e ottimizzare le prestazioni delle applicazioni web. Se la tua app è in esecuzione in Chrome, ti offre una panoramica visiva dettagliata di ciò che fa il browser durante l'esecuzione dell'applicazione. Comprendere questa attività può aiutarti a identificare schemi, colli di bottiglia e hotspot delle prestazioni su cui puoi intervenire per migliorare le prestazioni.

L'esempio seguente illustra come utilizzare il riquadro Rendimento.

Imposta e ricrea il nostro scenario di profilazione

Di recente, abbiamo fissato l'obiettivo di migliorare il rendimento del riquadro Rendimento. In particolare, volevamo che caricasse grandi volumi di dati sul rendimento più rapidamente. Questo è il caso, ad esempio, della profilazione di processi complessi o a lunga esecuzione o dell'acquisizione di dati ad alta granularità. Per raggiungere questo obiettivo, è stata prima necessaria la comprensione di come era l'applicazione e perché funzionava in quel modo, ottenuta utilizzando uno strumento di profilazione.

Come forse saprai, DevTools stesso è un'applicazione web. Di conseguenza, può essere profilata utilizzando il riquadro Rendimento. Per profilare questo riquadro, puoi aprire DevTools e quindi aprire un'altra istanza DevTools collegata. In Google questa configurazione è nota come DevTools-on-DevTools.

Con la configurazione pronta, lo scenario da profilare deve essere ricreato e registrato. Per evitare confusione, verrà definita la "prima istanza DevTools" della finestra originale di DevTools, mentre quella che ispeziona la prima istanza sarà indicata come la "seconda istanza di DevTools".

Uno screenshot di un'istanza DevTools che controlla gli elementi in DevTools.
. DevTools-on-DevTools: controllo di DevTools con DevTools.

Nella seconda istanza di DevTools, il riquadro Prestazioni, che d'ora in poi si chiamerà riquadro delle prestazioni, osserva la prima istanza di DevTools per ricreare lo scenario, caricando un profilo.

Nella seconda istanza di DevTools viene avviata una registrazione in tempo reale, mentre nella prima istanza viene caricato un profilo da un file sul disco. Viene caricato un file di grandi dimensioni per delineare con precisione le prestazioni dell'elaborazione di input di grandi dimensioni. Al termine del caricamento di entrambe le istanze, i dati di profilazione delle prestazioni, comunemente chiamati traccia, vengono visualizzati nella seconda istanza DevTools del riquadro delle prestazioni che carica un profilo.

Stato iniziale: identificazione delle opportunità di miglioramento

Al termine del caricamento, nello screenshot successivo abbiamo osservato quanto segue nella nostra seconda istanza di panel di prestazioni. Concentrati sull'attività del thread principale, visibile sotto la traccia con l'etichetta Principale. Puoi notare che ci sono cinque grandi gruppi di attività nel grafico a fiamme. Si tratta delle attività in cui il caricamento sta richiedendo più tempo. Il tempo totale di queste attività è stato di circa 10 secondi. Nel seguente screenshot, il riquadro del rendimento viene utilizzato per concentrare l'attenzione su ciascuno di questi gruppi attività e scoprire cosa è possibile trovare.

Uno screenshot del riquadro delle prestazioni in DevTools che controlla il caricamento di un'analisi delle prestazioni nel riquadro delle prestazioni di un'altra istanza DevTools. Il caricamento del profilo richiede circa 10 secondi. Questo tempo è per lo più suddiviso in cinque gruppi principali di attività.

Primo gruppo attività: lavoro non necessario

È diventato evidente che il primo gruppo di attività era un codice legacy che veniva ancora eseguito, ma che in realtà non era necessario. In sostanza, tutto ciò che si trovava sotto il blocco verde processThreadEvents è stato uno spreco di risorse. Quella è stata una vittoria veloce. La rimozione della chiamata di funzione ha consentito di risparmiare circa 1,5 secondi. Interessante!

Secondo gruppo attività

Nel secondo gruppo attività, la soluzione non era semplice come nel primo. buildProfileCalls ha impiegato circa 0, 5 secondi e non era possibile evitare l'attività.

Uno screenshot del riquadro delle prestazioni in DevTools che controlla un'altra istanza del riquadro delle prestazioni. Un'attività associata alla funzione buildProfileCalls richiede circa 0,5 secondi.

Per curiosità, abbiamo attivato l'opzione Memoria nel riquadro delle prestazioni per analizzare ulteriormente il problema e abbiamo visto che anche l'attività buildProfileCalls utilizzava molta memoria. Qui puoi vedere come il grafico a linee blu salta improvvisamente rispetto all'esecuzione di buildProfileCalls, il che suggerisce una potenziale perdita di memoria.

Uno screenshot del profiler della memoria in DevTools che valuta il consumo della memoria del riquadro delle prestazioni. La finestra di ispezione suggerisce che la funzione buildProfileCalls è responsabile di una perdita di memoria.

Per dare seguito a questo sospetto, abbiamo utilizzato il riquadro Memoria (un altro riquadro in DevTools, diverso dal riquadro Memoria nel riquadro Prestazioni) per esaminare la situazione. Nel riquadro Memoria, la colonna "Campionamento di allocazione" è stato selezionato il tipo di profilazione, che ha registrato lo snapshot dell'heap per il riquadro perf durante il caricamento del profilo CPU.

Uno screenshot dello stato iniziale del profiler di memoria. Il campionamento di allocazione è evidenziata con una casella rossa e indica che questa è l'opzione migliore per la profilazione della memoria JavaScript.

Il seguente screenshot mostra l'istantanea dell'heap raccolta.

Uno screenshot del profiler di memoria, con un'operazione basata su set che richiede molta memoria.

Da questo snapshot dell'heap, è emerso che la classe Set utilizzava molta memoria. Controllando i punti di chiamata, abbiamo scoperto che stavamo assegnando proprietà di tipo Set inutilmente agli oggetti creati in grandi volumi. Questo costo si sommava e veniva consumata molta memoria, al punto che era normale che l'applicazione si arrestasse in modo anomalo su input di grandi dimensioni.

I set sono utili per archiviare elementi univoci e forniscono operazioni che utilizzano l'unicità dei loro contenuti, come la deduplicazione dei set di dati e l'esecuzione di ricerche più efficienti. Tuttavia, queste caratteristiche non sono state necessarie perché era garantita l'unicità dei dati archiviati rispetto all'origine. Di conseguenza, gli insiemi non erano necessari. Per migliorare l'allocazione della memoria, il tipo di proprietà è stato modificato da Set a array semplice. Dopo aver applicato questa modifica, è stato acquisito un altro snapshot dell'heap ed è stata osservata una riduzione dell'allocazione della memoria. Nonostante non abbia ottenuto miglioramenti della velocità considerevoli con questa modifica, il vantaggio secondario era che l'applicazione si arrestava in modo anomalo meno frequentemente.

Uno screenshot del profiler di memoria. L'operazione basata su set che in precedenza richiedeva molta memoria è stata modificata in modo da utilizzare un array semplice, il che ha ridotto in modo significativo i costi della memoria.

Terzo gruppo di attività: valutazione dei compromessi sulla struttura dei dati

La terza sezione è strana: nel grafico a fiamme è possibile vedere che è composta da colonne strette ma alte, che indicano chiamate di funzione profonde e, in questo caso, ricorsioni profonde. In totale, questa sezione è durata circa 1,4 secondi. Osservando la parte inferiore di questa sezione, è emerso che la larghezza di queste colonne era determinata dalla durata di una funzione: appendEventAtLevel, che suggeriva che potrebbe trattarsi di un collo di bottiglia

All'interno dell'implementazione della funzione appendEventAtLevel, abbiamo messo in evidenza un aspetto. Per ogni singola voce di dati nell'input (nota nel codice come "evento"), è stato aggiunto un elemento a una mappa che tracciava la posizione verticale delle voci della sequenza temporale. Questo era problematico perché la quantità di elementi archiviati era molto elevata. Maps è veloce per le ricerche basate su chiave, ma questo vantaggio non è senza costi. Man mano che una mappa diventa più grande, l'aggiunta di dati può, ad esempio, diventare costosa a causa del rehashing. Questo costo diventa evidente quando alla mappa vengono aggiunti molti elementi in successione.

/**
 * Adds an event to the flame chart data at a defined vertical level.
 */
function appendEventAtLevel (event, level) {
  // ...

  const index = data.length;
  data.push(event);
  this.indexForEventMap.set(event, index);

  // ...
}

Abbiamo sperimentato un altro approccio che non richiede l'aggiunta di un elemento in una mappa per ogni voce del grafico a fiamme. Il miglioramento è stato significativo, a conferma del fatto che il collo di bottiglia era effettivamente correlato ai costi generali sostenuti dall'aggiunta di tutti i dati alla mappa. Il tempo impiegato dal gruppo attività è stato ridotto da circa 1,4 secondi a circa 200 millisecondi.

Prima:

Uno screenshot del riquadro del rendimento prima che venissero apportate ottimizzazioni alla funzione appendEventAtLevel. Il tempo totale per l'esecuzione della funzione è stato di 1372,51 millisecondi.

Dopo:

Uno screenshot del riquadro del rendimento dopo le ottimizzazioni della funzione appendEventAtLevel. Il tempo totale per l'esecuzione della funzione è stato di 207,2 millisecondi.

Quarto gruppo attività: rinviare i dati di lavoro non critici e memorizzare nella cache i dati per evitare lavori duplicati

Aumentando lo zoom su questa finestra, puoi notare che sono presenti due blocchi quasi identici di chiamate di funzione. Osservando il nome delle funzioni chiamate, puoi dedurre che questi blocchi sono costituiti da codice che genera alberi (ad esempio, con nomi come refreshTree o buildChildren). Infatti, il codice correlato è quello che crea le visualizzazioni ad albero nel riquadro a scomparsa inferiore del riquadro. La cosa interessante è che queste visualizzazioni ad albero non vengono mostrate subito dopo il caricamento. Per visualizzare gli alberi, l'utente deve invece selezionare una visualizzazione ad albero (le schede "Dal basso in alto", "Struttura ad albero delle chiamate" e "Log eventi" nel riquadro a scomparsa). Inoltre, come si può vedere dallo screenshot, il processo di creazione dell'albero è stato eseguito due volte.

Uno screenshot del riquadro delle prestazioni che mostra varie attività ripetitive che vengono eseguite anche se non sono necessarie. Queste attività potrebbero essere differite per l'esecuzione on demand, anziché in anticipo.

Con questa immagine abbiamo identificato due problemi:

  1. Un'attività non critica stava ostacolando le prestazioni del tempo di caricamento. Gli utenti non hanno sempre bisogno del suo output. Di conseguenza, l'attività non è fondamentale per il caricamento del profilo.
  2. Il risultato di queste attività non è stato memorizzato nella cache. Ecco perché gli alberi sono stati calcolati due volte, nonostante i dati non cambino.

Abbiamo iniziato con il calcolo dell'albero rinviato al momento in cui l'utente ha aperto manualmente la visualizzazione ad albero. Solo allora vale la pena pagare il prezzo della creazione di questi alberi. Il tempo totale di esecuzione di questo due volte è stato di circa 3,4 secondi, quindi posticiparlo ha fatto una differenza significativa in termini di tempo di caricamento. Stiamo ancora cercando di memorizzare nella cache anche questi tipi di attività.

Quinto gruppo attività: se possibile, evita gerarchie di chiamate complesse

Esaminando attentamente il gruppo, è emerso che una determinata catena di chiamate veniva richiamata ripetutamente. Lo stesso schema è apparso 6 volte in punti diversi nel grafico a fiamme e la durata totale di questa finestra è stata di circa 2, 4 secondi!

Uno screenshot del riquadro delle prestazioni che mostra sei chiamate di funzione separate per la generazione della stessa minimap di traccia, ciascuna delle quali ha stack di chiamate profondi.

Il codice correlato che viene chiamato più volte è la parte che elabora i dati da visualizzare sulla "minimappa" (la panoramica dell'attività della sequenza temporale nella parte superiore del riquadro). Non era chiaro perché succedesse più volte, ma di sicuro non doveva succedere 6 volte. Infatti, l'output del codice dovrebbe rimanere aggiornato se non viene caricato nessun altro profilo. In teoria, il codice dovrebbe essere eseguito una sola volta.

Da un'indagine è emerso che il codice correlato è stato chiamato come conseguenza di più parti nella pipeline di caricamento che chiamavano direttamente o indirettamente la funzione che calcola la minimap. Questo perché la complessità del grafico delle chiamate del programma si è evoluta nel tempo e, inconsapevolmente, sono state aggiunte altre dipendenze da questo codice. Non esiste una soluzione rapida per questo problema. Il modo per risolverlo dipende dall'architettura del codebase in questione. Nel nostro caso, abbiamo dovuto ridurre un po' la complessità della gerarchia delle chiamate e aggiungere un controllo per impedire l'esecuzione del codice se i dati di input rimanevano invariati. Dopo l'implementazione, abbiamo ricavato queste tempistiche:

Uno screenshot del riquadro delle prestazioni che mostra le sei chiamate di funzione separate per la generazione della stessa minimap di traccia ridotta a solo due volte.

Tieni presente che l'esecuzione del rendering della minimap avviene due volte, non una volta. Questo perché per ogni profilo vengono disegnate due minimip: una per la panoramica nella parte superiore del riquadro e un'altra per il menu a discesa che seleziona il profilo attualmente visibile dalla cronologia (ogni elemento di questo menu contiene una panoramica del profilo selezionato). Ciononostante, questi due formati hanno esattamente gli stessi contenuti, quindi uno dovrebbe essere riutilizzato per l'altro.

Poiché queste mini mappa sono entrambe immagini disegnate su un canvas, è stato necessario usare l'utilità canvas drawImage e poi eseguire il codice una sola volta per risparmiare tempo aggiuntivo. Come risultato di questo sforzo, la durata del gruppo è stata ridotta da 2,4 secondi a 140 millisecondi.

Conclusione

Dopo aver applicato tutte queste correzioni (e un paio di altre più piccole qua e là), la modifica della cronologia di caricamento del profilo è stata il seguente:

Prima:

Uno screenshot del riquadro delle prestazioni che mostra il caricamento della traccia prima delle ottimizzazioni. Il processo ha richiesto circa dieci secondi.

Dopo:

Uno screenshot del riquadro delle prestazioni che mostra il caricamento delle tracce dopo le ottimizzazioni. Il processo ora richiede circa due secondi.

Il tempo di caricamento dopo i miglioramenti è stato di 2 secondi, il che significa che è stato ottenuto un miglioramento di circa l'80% con uno sforzo relativamente basso, poiché la maggior parte di ciò che è stato fatto consisteva in correzioni rapide. Naturalmente, identificare correttamente cosa fare inizialmente era fondamentale e il riquadro delle prestazioni era lo strumento giusto.

Inoltre, è importante sottolineare che questi valori sono specifici di un profilo utilizzato come oggetto di studio. Il profilo è stato interessante per noi perché era particolarmente grande. Tuttavia, poiché la pipeline di elaborazione è la stessa per ogni profilo, il miglioramento significativo ottenuto si applica a ogni profilo caricato nel riquadro delle prestazioni.

Concetti principali

Ci sono alcune lezioni da trarre da questi risultati in termini di ottimizzazione delle prestazioni della tua applicazione:

1. Utilizzare strumenti di profilazione per identificare pattern di prestazioni del runtime

Gli strumenti di profilazione sono incredibilmente utili per capire cosa succede nell'applicazione mentre è in esecuzione, soprattutto per identificare opportunità per migliorare le prestazioni. Il riquadro Prestazioni in Chrome DevTools è un'ottima opzione per le applicazioni web in quanto è lo strumento nativo di profilazione web nel browser ed è attivamente aggiornato con le funzionalità più recenti della piattaforma web. Inoltre, ora è molto più veloce. 😉

Usa esempi che possono essere utilizzati come carichi di lavoro rappresentativi e scopri cosa riesci a trovare.

2. Evita gerarchie di chiamate complesse

Se possibile, evita di rendere troppo complicato il grafico delle chiamate. Nel caso di gerarchie di chiamate complesse, è facile introdurre regressioni delle prestazioni e capire perché il codice viene eseguito in questo modo, rendendo difficile ottenere miglioramenti.

3. Identifica il lavoro non necessario

È comune che i codebase obsoleti contengano codice che non è più necessario. Nel nostro caso, il codice precedente e non necessario occupava una parte significativa del tempo di caricamento totale. Rimuoverla era il frutto più scarso.

4. Utilizza le strutture dati in modo appropriato

Utilizza strutture dati per ottimizzare le prestazioni, ma anche per comprendere i costi e i compromessi che ciascun tipo di struttura di dati comporta quando decidi quale utilizzare. Non si tratta solo della complessità spaziale della struttura dei dati stessa, ma anche della complessità temporale delle operazioni applicabili.

5. Memorizza nella cache i risultati per evitare lavori duplicati per operazioni complesse o ripetitive

Se l'esecuzione dell'operazione è costosa, ha senso archiviare i risultati per la prossima volta che sarà necessaria. Ha senso anche farlo se l'operazione viene eseguita molte volte, anche se ogni singola volta non è particolarmente costosa.

6. Rimanda il lavoro non critico

Se l'output di un'attività non è necessario immediatamente e l'esecuzione dell'attività estende il percorso critico, valuta la possibilità di rinviarlo chiamandolo lentamente quando il suo output è effettivamente necessario.

7. Utilizza algoritmi efficienti su input di grandi dimensioni

Per input di grandi dimensioni, gli algoritmi della complessità temporale ottimale diventano fondamentali. In questo esempio non abbiamo esaminato questa categoria, ma la loro importanza è difficilmente sottovalutata.

8. Bonus: esegui il benchmarking delle tue pipeline

Per assicurarti che il codice in evoluzione rimanga veloce, ti consigliamo di monitorare il comportamento e confrontarlo con gli standard. In questo modo, puoi identificare in modo proattivo le regressioni e migliorare l'affidabilità complessiva, preparandoti per un successo a lungo termine.