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, ottimizzare le sue prestazioni e garantire che si carichi rapidamente e offra interazioni fluide è fondamentale per l'esperienza utente e il successo dell'applicazione. Un modo per farlo è analizzare l'attività di un'applicazione utilizzando strumenti di profilazione per vedere cosa succede in background durante l'esecuzione in un intervallo di tempo. Il riquadro Prestazioni 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 sta facendo il browser durante l'esecuzione dell'applicazione. Comprendere questa attività può aiutarti a identificare schemi, colli di bottiglia e hotspot prestazionali su cui puoi intervenire per migliorare le prestazioni.

L'esempio seguente illustra come utilizzare il riquadro Rendimento.

Impostazione e nuova creazione del nostro scenario di profilazione

Di recente abbiamo fissato l'obiettivo di migliorare il rendimento del riquadro Rendimento. In particolare, volevamo che caricasse più rapidamente grandi volumi di dati sulle prestazioni. Questo è il caso, ad esempio, durante la profilazione di processi complessi o di lunga durata o l'acquisizione di dati ad alta granularità. Per ottenere questo risultato, era prima necessario capire come stava prestando l'applicazione e perché funzionava in quel modo, utilizzando uno strumento di profilazione.

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

Una volta pronta la configurazione, lo scenario da profilare deve essere ricreato e registrato. Per evitare confusione, la finestra DevTools originale sarà indicata come "prima istanza DevTools" e la finestra che esamina la prima istanza sarà indicata come "seconda istanza DevTools".

Uno screenshot di un'istanza DevTools che ispeziona gli elementi in DevTools.
DevTools su DevTools: ispezione di DevTools con DevTools.

Nella seconda istanza DevTools, il riquadro Prestazioni, che d'ora in poi sarà denominato pannello perf, osserva la prima istanza DevTools per ricreare lo scenario, che carica un profilo.

Nella seconda istanza 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 profilare con precisione le prestazioni nell'elaborazione di input di grandi dimensioni. Al termine del caricamento di entrambe le istanze, i dati di profilazione delle prestazioni, comunemente chiamati trace, vengono visualizzati nella seconda istanza DevTools del riquadro perf che carica un profilo.

Lo stato iniziale: identificare le opportunità di miglioramento

Al termine del caricamento, nello screenshot successivo abbiamo osservato quanto segue sulla nostra seconda istanza del panel perf. Imposta lo stato attivo sull'attività del thread principale, visibile sotto la traccia denominata Principale. Si può vedere che ci sono cinque grandi gruppi di attività nel diagramma a fiamme. Si tratta delle attività in cui il caricamento richiede più tempo. Il tempo totale di queste attività è stato di circa 10 secondi. Nello screenshot seguente, il riquadro del rendimento viene utilizzato per concentrarsi su ciascuno di questi gruppi di attività per vedere cosa è possibile trovare.

Uno screenshot del riquadro delle prestazioni in DevTools che esamina il caricamento di una traccia delle prestazioni nel riquadro delle prestazioni di un'altra istanza DevTools. Il caricamento del profilo richiede circa 10 secondi. Questo periodo di tempo è suddiviso principalmente in cinque gruppi principali di attività.

Primo gruppo attività: lavoro non necessario

È emerso chiaramente che il primo gruppo di attività era il codice legacy ancora in esecuzione, ma in realtà non era necessario. In sostanza, tutto quello che si trova sotto il blocco verde processThreadEvents è stato sprecato. Quella è stata una vittoria rapida. La rimozione della chiamata di funzione ha consentito circa un secondo e mezzo di tempo. Interessante!

Secondo gruppo attività

Nel secondo gruppo di attività, la soluzione non era semplice come nel primo. buildProfileCalls ha richiesto circa 0, 5 secondi e non poteva essere evitato.

Uno screenshot del riquadro delle prestazioni in DevTools che ispeziona 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 relativo ai risultati per effettuare ulteriori accertamenti e abbiamo notato che anche l'attività buildProfileCalls utilizzava molta memoria. Qui puoi vedere come il grafico a linee blu salti improvvisamente nel momento in cui viene eseguito 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. L'ispettore suggerisce che la funzione buildProfileCalls è responsabile di una perdita di memoria.

Per dare seguito a questo sospetto, abbiamo usato il riquadro Memoria (un altro riquadro in DevTools, diverso dal riquadro a scomparsa Memoria nel riquadro perf) per effettuare accertamenti. Nel riquadro Memoria, è stato selezionato il tipo di profilazione "Campionamento di allocazione", che ha registrato lo snapshot dell'heap per il riquadro perf che carica il profilo CPU.

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

Lo screenshot seguente mostra l'istantanea heap raccolta.

Uno screenshot del profiler di memoria, in cui è selezionata un'operazione basata su Set che richiede un uso intensivo della memoria.

Da questo snapshot dell'heap, è stato osservato che la classe Set stava consumando molta memoria. Controllando i punti di chiamata, abbiamo scoperto che stavamo assegnando inutilmente proprietà di tipo Set agli oggetti creati in grandi volumi. I costi erano aumentati e veniva consumata molta memoria, al punto che era comune che l'applicazione si arrestasse in modo anomalo su input di grandi dimensioni.

Gli insiemi sono utili per archiviare elementi univoci e forniscono operazioni che sfruttano l'unicità dei loro contenuti, ad esempio deduplicare i set di dati e fornire ricerche più efficienti. Tuttavia, queste funzionalità non erano necessarie poiché i dati archiviati erano garantiti essere univoci 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 un array semplice. Dopo aver applicato questa modifica, è stato acquisito un altro snapshot heap ed è stata osservata una riduzione dell'allocazione della memoria. Nonostante non abbia ottenuto miglioramenti significativi della velocità con questa modifica, il vantaggio secondario era che l'applicazione si arrestava in modo anomalo meno frequentemente.

Uno screenshot del profiler della memoria. L'operazione basata su Set, che in precedenza utilizzava memoria, è stata modificata in modo da utilizzare un array semplice, il che ha ridotto in modo significativo il costo della memoria.

Terzo gruppo di attività: valutare i compromessi nella struttura dei dati

La terza sezione è peculiare: si può vedere nel diagramma a fiamme che è costituita da colonne strette ma alte, che indicano chiamate di funzioni profonde e ricorsione profonde in questo caso. In totale, questa sezione è durata circa 1,4 secondi. Nella parte inferiore di questa sezione, è emerso che la larghezza di queste colonne era determinata dalla durata di una funzione: appendEventAtLevel, il che suggeriva che si trattasse di un collo di bottiglia.

Nell'implementazione della funzione appendEventAtLevel, un aspetto è stato in evidenza. Per ogni singola voce di dati nell'input (noto nel codice come "evento"), è stato aggiunto un elemento a una mappa che monitorava la posizione verticale delle voci della sequenza temporale. Questo problema creava problemi, perché la quantità di articoli archiviati era molto grande. Le mappe sono veloci per le ricerche basate su chiave, ma questo vantaggio non è senza costi. Man mano che una mappa si ingrandisce, l'aggiunta di dati può, ad esempio, diventare costosa a causa del rimaneggiamento. Questo costo diventa più evidente quando alla mappa vengono aggiunti successivamente grandi quantità di elementi.

/**
 * 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 richiedeva l'aggiunta di un elemento in una mappa per ogni voce nel grafico a fiamme. Il miglioramento è stato significativo, a conferma del fatto che il collo di bottiglia era correlato all'overhead sostenuto dall'aggiunta di tutti i dati alla mappa. Il tempo impiegato dal gruppo di attività si è ridotto da circa 1,4 secondi a circa 200 millisecondi.

Prima:

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

Dopo:

Uno screenshot del riquadro delle prestazioni dopo le ottimizzazioni apportate alla funzione appendEventAtLevel. Il tempo totale di esecuzione della funzione è stato di 207,2 millisecondi.

Quarto gruppo attività: rimandare il lavoro non critico e memorizzare i dati nella cache per evitare lavori duplicati

Aumentando lo zoom su questa finestra, si può vedere che ci sono due blocchi quasi identici di chiamate di funzione. Osservando il nome delle funzioni chiamate, si può dedurre che questi blocchi sono costituiti da codice che crea 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 mostrare gli alberi, l'utente deve invece selezionare una visualizzazione ad albero (le schede "Dal basso verso l'alto", "Albero chiamate" e "Log eventi" nel riquadro a scomparsa). Inoltre, come puoi vedere dallo screenshot, il processo di creazione dell'albero è stato eseguito due volte.

Uno screenshot del riquadro del rendimento che mostra diverse attività ripetitive che vengono eseguite anche se non sono necessarie. Queste attività potrebbero essere differite per l'esecuzione on demand, piuttosto che in anticipo.

Ci sono due problemi che abbiamo identificato con questa immagine:

  1. Un'attività non critica ha ostacolato 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 differimento del calcolo ad albero 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 per due volte è stato di circa 3,4 secondi, quindi il differimento ha fatto una differenza significativa nel tempo di caricamento. Stiamo ancora cercando di memorizzare nella cache anche questi tipi di attività.

Quinto gruppo attività: evita gerarchie di chiamate complesse quando possibile

Esaminando attentamente questo gruppo, è emerso chiaramente che una determinata catena di chiamate veniva richiamata ripetutamente. Lo stesso pattern è apparso 6 volte in punti diversi nel diagramma 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 minimappa di traccia, ognuna delle quali ha stack di chiamata 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 il motivo di questo fenomeno più volte, ma di certo non è stato necessario ripeterlo sei volte. Infatti, l'output del codice dovrebbe rimanere aggiornato se non vengono caricati altri profili. In teoria, il codice dovrebbe essere eseguito una sola volta.

Dall'indagine è emerso che il codice correlato è stato chiamato in seguito a più parti nella pipeline di caricamento, chiamando direttamente o indirettamente la funzione che calcola la minimappa. Questo perché la complessità del grafico delle chiamate del programma si è evoluta nel tempo e, inconsapevolmente, sono state aggiunte più dipendenze a 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 leggermente 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 visto questa previsione delle 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 mini mappa avviene due volte, non una volta. Questo accade perché per ogni profilo vengono tracciate due minimappe: 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 tipi di contenuti hanno gli stessi contenuti, quindi una dovrebbe essere in grado di riutilizzarla per l'altra.

Poiché queste minimap sono entrambe immagini disegnate su una tela, si è trattato di utilizzare l'utilità Canvas di drawImage e di eseguire successivamente 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 sequenza temporale di caricamento del profilo è stata la seguente:

Prima:

Uno screenshot del riquadro del rendimento che mostra il caricamento delle tracce prima delle ottimizzazioni. Il processo ha richiesto circa dieci secondi.

Dopo:

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

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

È importante sottolineare che questi numeri sono specifici di un profilo utilizzato come argomento di studio. Il profilo ci è stato interessante perché era particolarmente grande. Ciononostante, poiché la pipeline di elaborazione è la stessa per ogni profilo, il miglioramento significativo ottenuto si applica a ogni profilo caricato nel riquadro perf.

Concetti chiave

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

1. Utilizza strumenti di profilazione per identificare i pattern delle prestazioni di runtime

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

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

2. Evitare gerarchie di chiamate complesse

Se possibile, evita di rendere troppo complicato il grafico delle chiamate. Con gerarchie di chiamate complesse, è facile introdurre regressioni delle prestazioni e difficile comprendere perché il codice viene eseguito così com'è, rendendo difficile ottenere miglioramenti.

3. Identificare le opere non necessarie

È comune che i codebase meno recenti contengano codice che non è più necessario. Nel nostro caso, il codice legacy e non necessario prendeva una parte significativa del tempo di caricamento totale. Rimuoverla era il frutto più basso.

4. Utilizzare le strutture di dati in modo appropriato

Utilizza le strutture di dati per ottimizzare le prestazioni, ma anche per comprendere i costi e i compromessi derivanti da ogni tipo di struttura di dati 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 archiviarne i risultati per la prossima volta che sarà necessario. Ha senso farlo se l'operazione viene eseguita più volte, anche se ogni singolo periodo non è particolarmente costoso.

6. Rimanda i lavori non critici

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

7. Usare algoritmi efficienti su input di grandi dimensioni

Per gli input di grandi dimensioni, gli algoritmi di complessità temporale ottimale diventano fondamentali. Non abbiamo considerato questa categoria in questo esempio, ma la loro importanza difficilmente può essere sottostimata.

8. Bonus: confronta le tue pipeline

Per fare in modo che la velocità del codice in evoluzione sia sempre veloce, è consigliabile 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 il successo a lungo termine.