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, ottimizzarne le prestazioni e assicurarti che si carichi rapidamente e offra interazioni fluide è fondamentale per l'esperienza utente e il successo dell'applicazione. Un modo per farlo è ispezionare l'attività di un'applicazione utilizzando strumenti di profilazione per vedere cosa succede sotto il cofano durante l'esecuzione in un determinato periodo di tempo. Il riquadro Prestazioni in DevTools è un ottimo strumento di profilazione per analizzare e ottimizzare le prestazioni delle applicazioni web. Se l'app è in esecuzione in Chrome, viene visualizzata una panoramica visiva dettagliata delle attività del browser durante l'esecuzione dell'applicazione. Comprendere questa attività può aiutarti a identificare schemi, colli di bottiglia e hotspot di rendimento su cui puoi intervenire per migliorare il rendimento.

L'esempio seguente illustra come utilizzare il riquadro Rendimento.

Configurazione e ricreazione del nostro scenario di profilazione

Di recente, ci siamo prefissati di migliorare il rendimento del riquadro Rendimento. In particolare, volevamo che caricasse più rapidamente grandi volumi di dati sul rendimento. Questo accade, ad esempio, quando si esegue il profiling di processi complessi o di lunga durata o si acquisiscono dati ad alta granularità. Per raggiungere questo obiettivo, è stato necessario prima comprendere come e perché l'applicazione avesse un rendimento simile, il che è stato ottenuto utilizzando uno strumento di profilazione.

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

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

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

Nella seconda istanza di DevTools, il riquadro Rendimento, che d'ora in poi chiameremo riquadro Rendimento, osserva la prima istanza di DevTools per ricreare lo scenario, che carica 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 su disco. Viene caricato un file di grandi dimensioni per profilare con precisione le prestazioni dell'elaborazione di input di grandi dimensioni. Al termine del caricamento di entrambe le istanze, i dati del profilo delle prestazioni, comunemente chiamati traccia, vengono visualizzati nella seconda istanza di DevTools del riquadro delle prestazioni che carica un profilo.

Stato iniziale: identificare le opportunità di miglioramento

Al termine del caricamento, nello screenshot successivo è stato osservato quanto segue nella seconda istanza del riquadro delle prestazioni. Concentrati sull'attività del thread principale, visibile sotto la traccia etichettata come Principale. Nel grafico a forma di fiamma sono visibili cinque grandi gruppi di attività. Si tratta delle attività di cui il caricamento richiede più tempo. Il tempo totale di queste attività è stato di circa 10 secondi. Nello screenshot seguente, il riquadro sul rendimento viene utilizzato per concentrarsi su ciascuno di questi gruppi di attività per vedere cosa è possibile trovare.

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

Primo gruppo di attività: lavoro non necessario

È emerso che il primo gruppo di attività era costituito da codice legacy ancora in esecuzione, ma non necessario. In pratica, tutto ciò che si trova sotto il blocco verde contrassegnato come processThreadEvents è stato uno sforzo sprecato. È stato facile. La rimozione di questa chiamata di funzione ha risparmiato circa 1,5 secondi. Interessante!

Secondo gruppo di attività

Nel secondo gruppo di attività, la soluzione non era così semplice come nel primo. buildProfileCalls ha impiegato circa 0, 5 secondi e non era possibile evitare questa operazione.

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

Per curiosità, abbiamo attivato l'opzione Memoria nel riquadro delle prestazioni per effettuare ulteriori accertamenti e abbiamo notato che anche l'attività buildProfileCalls utilizzava molta memoria. Qui puoi vedere come il grafico a linee blu fa un salto improvviso intorno al 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 di memoria del riquadro Prestazioni. L'ispettore suggerisce che la funzione buildProfileCalls sia responsabile di una perdita di memoria.

Per verificare questo sospetto, abbiamo utilizzato il riquadro Memoria (un altro riquadro di DevTools, diverso dal riquadro Memoria nel riquadro Prestazioni) per effettuare accertamenti. Nel riquadro Memoria, è stato selezionato il tipo di profilazione "Campionamento allocazione", che ha registrato lo snapshot dell'heap per il riquadro delle prestazioni che carica il profilo della CPU.

Uno screenshot dello stato iniziale del profiler della memoria. L'opzione "campionamento allocazione" è evidenziata da una casella rossa e indica che è l'opzione migliore per il profiling della memoria JavaScript.

Lo screenshot seguente mostra lo snapshot dell'heap raccolto.

Uno screenshot del profiler della memoria con un'operazione basata su set ad alta intensità di memoria selezionata.

Da questo snapshot dell'heap è stato osservato che la classe Set stava consumando molta memoria. Controllando i punti di chiamata, è emerso che assegnavamo inutilmente proprietà di tipo Set a oggetti creati in grandi volumi. Questo costo aumentava e veniva consumata molta memoria, al punto che era normale che l'applicazione si arresti in modo anomalo con input di grandi dimensioni.

Gli insiemi sono utili per archiviare elementi univoci e fornire operazioni che utilizzano l'unicità dei relativi contenuti, ad esempio la deduplica dei set di dati e la fornitura di ricerche più efficienti. Tuttavia, queste funzionalità non erano necessarie poiché i dati archiviati erano garantiti come unici per l'origine. Di conseguenza, i set non erano necessari in primo luogo. Per migliorare l'allocazione della memoria, il tipo di proprietà è stato modificato da Set a un array normale. Dopo aver applicato questa modifica, è stato acquisito un altro snapshot dell'heap ed è stata osservata una riduzione dell'allocazione della memoria. Sebbene questa modifica non abbia consentito di ottenere miglioramenti considerevoli della velocità, il vantaggio secondario è stato che l'applicazione ha avuto arresti anomali meno frequentemente.

Uno screenshot del profiler della memoria. L'operazione basata su set che in precedenza richiedeva molta memoria è stata modificata in modo da utilizzare un array normale, il che ha ridotto notevolmente il costo della memoria.

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

La terza sezione è peculiare: nel grafico a forma di fiamma puoi vedere che è costituita da colonne strette ma alte, che indicano chiamate di funzioni 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, il che suggeriva che potesse essere un collo di bottiglia

All'interno dell'implementazione della funzione appendEventAtLevel, una cosa spicca. 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 era un problema, perché la quantità di elementi archiviati era molto elevata. Le mappe sono veloci per le ricerche basate su chiavi, ma questo vantaggio non è senza costi. Man mano che una mappa diventa più grande, l'aggiunta di dati può diventare costosa, ad esempio a causa del rehashing. Questo costo diventa evidente quando alla mappa vengono aggiunti in successione 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 del grafico a forma di fiamma. Il miglioramento è stato significativo, a conferma del fatto che il collo di bottiglia era effettivamente correlato al sovraccarico dovuto all'aggiunta di tutti i dati alla mappa. Il tempo impiegato dal gruppo di attività è passato da circa 1,4 secondi a circa 200 millisecondi.

Prima:

Uno screenshot del riquadro sul rendimento prima delle ottimizzazioni apportate alla funzione appendEventAtLevel. Il tempo totale di esecuzione della funzione è stato di 1372,51 millisecondi.

Dopo:

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

Quarto gruppo di attività: posticipare il lavoro non critico e memorizzare nella cache i dati per evitare il lavoro duplicato

Se aumenti lo zoom di questa finestra, puoi vedere che ci sono due blocchi di chiamate di funzioni quasi identici. Dal 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 inferiore del riquadro. La cosa interessante è che queste visualizzazioni ad albero non vengono mostrate subito dopo il caricamento. Per visualizzare le strutture ad albero, l'utente deve selezionare una visualizzazione ad albero (le schede "Dal basso verso l'alto", "Albero chiamate" e "Log eventi" nel riquadro laterale). Inoltre, come puoi vedere dallo screenshot, la procedura di creazione dell'albero è stata eseguita due volte.

Uno screenshot del riquadro sul rendimento che mostra diverse attività ripetitive che vengono eseguite anche se non sono necessarie. Queste attività potrebbero essere posticipate per essere eseguite su richiesta, anziché in anticipo.

Abbiamo identificato due problemi con questa immagine:

  1. Un'attività non critica stava ostacolando il rendimento del tempo di caricamento. Gli utenti non hanno sempre bisogno dell'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 siano cambiati.

Abbiamo iniziato posticipando il calcolo dell'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 di questo codice per due volte è stato di circa 3,4 secondi, quindi il differimento ha fatto una differenza significativa nel tempo di caricamento. Stiamo ancora valutando la possibilità di memorizzare nella cache anche questi tipi di attività.

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

Esaminando attentamente questo gruppo, è emerso chiaramente che una determinata catena di chiamate veniva invocata ripetutamente. Lo stesso pattern è apparso sei volte in punti diversi del grafico a forma di fiamma e la durata totale di questa finestra è stata di circa 2, 4 secondi.

Uno screenshot del riquadro sul rendimento che mostra sei chiamate di funzioni separate per generare la stessa minimappa della traccia, ognuna con stack di chiamate approfonditi.

Il codice correlato chiamato più volte è la parte che elabora i dati da visualizzare nella "minimappa" (la panoramica dell'attività della cronologia nella parte superiore del riquadro). Non era chiaro perché si verificasse più volte, ma di certo non doveva accadere 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.

Dall'indagine è emerso che il codice correlato è stato chiamato a causa di più parti della pipeline di caricamento che chiamano direttamente o indirettamente la funzione che calcola la minimappa. Questo perché la complessità del grafo delle chiamate del programma si è evoluta nel tempo e sono state aggiunte inconsapevolmente altre dipendenze a questo codice. Non esiste una soluzione rapida per questo problema. Il modo per risolverlo dipende dall'architettura della base di codice 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 ottenuto questa panoramica della sequenza temporale:

Uno screenshot del riquadro sul rendimento che mostra le sei chiamate di funzione separate per la generazione della stessa minimappa della traccia ridotta a due volte.

Tieni presente che l'esecuzione del rendering della minimappa avviene due volte, non una. Questo perché vengono disegnate due mini-mappe per ogni profilo: 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). Tuttavia, i due video hanno esattamente gli stessi contenuti, quindi uno dovrebbe essere riutilizzabile per l'altro.

Poiché queste mini mappe sono entrambe immagini disegnate su una tela, è stato sufficiente utilizzare l'drawImage utilità canvas ed eseguire successivamente il codice una sola volta per risparmiare un po' di tempo. Grazie a questo intervento, 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 sul rendimento che mostra il caricamento della traccia prima delle ottimizzazioni. Il processo ha richiesto circa dieci secondi.

Dopo:

Uno screenshot del riquadro sul rendimento che mostra il caricamento della traccia dopo le ottimizzazioni. Ora la procedura 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 ridotto, poiché la maggior parte delle operazioni eseguite consisteva in correzioni rapide. Ovviamente, identificare correttamente cosa fare inizialmente è stato fondamentale e il riquadro sul rendimento era lo strumento giusto per farlo.

È inoltre importante sottolineare che questi numeri sono specifici di un profilo utilizzato come oggetto di studio. Il profilo era 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

Da questi risultati possiamo trarre alcune lezioni in termini di ottimizzazione delle prestazioni dell'applicazione:

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

Gli strumenti di profilazione sono incredibilmente utili per capire cosa succede nell'applicazione durante l'esecuzione, in particolare per identificare opportunità di miglioramento del rendimento. Il pannello Prestazioni in Chrome DevTools è un'ottima opzione per le applicazioni web, in quanto è lo strumento di profilazione web nativo del browser e viene mantenuto attivamente aggiornato con le ultime funzionalità della piattaforma web. Inoltre, ora è notevolmente più veloce. 😉

Utilizza i sample che possono essere utilizzati come carichi di lavoro rappresentativi e scopri cosa puoi trovare.

2. Evita gerarchie di chiamate complesse

Se possibile, evita di rendere il grafico delle chiamate troppo complicato. Con gerarchie di chiamate complesse, è facile introdurre regressioni del rendimento ed è difficile capire perché il codice viene eseguito nel modo in cui viene eseguito, il che rende difficile apportare miglioramenti.

3. Identificare il lavoro non necessario

È normale che le basi di codice meno recenti contengano codice non più necessario. Nel nostro caso, il codice legacy e non necessario occupava una parte significativa del tempo di caricamento totale. La rimozione è stata la soluzione più semplice.

4. Utilizzare le strutture di dati in modo appropriato

Utilizza le strutture di dati per ottimizzare il rendimento, ma tieni conto anche dei costi e dei compromessi che ogni tipo di struttura di dati comporta quando decidi quali utilizzare. Non si tratta solo della complessità di spazio della struttura di dati stessa, ma anche della complessità temporale delle operazioni applicabili.

5. Memorizza nella cache i risultati per evitare di ripetere il lavoro per operazioni complesse o ripetitive

Se l'esecuzione dell'operazione è onerosa, ha senso memorizzare i risultati per la successiva occorrenza. Inoltre, ha senso farlo se l'operazione viene eseguita molte volte, anche se ogni singola volta non è particolarmente onerosa.

6. Posticipa il lavoro non critico

Se l'output di un'attività non è necessario immediatamente e l'esecuzione dell'attività sta estendendo il percorso critico, valuta la possibilità di differirlo chiamandolo in modo lazy quando il suo output è effettivamente necessario.

7. Utilizza algoritmi efficienti su input di grandi dimensioni

Per input di grandi dimensioni, gli algoritmi con complessità temporale ottimale diventano fondamentali. Non abbiamo esaminato questa categoria in questo esempio, ma la sua importanza è innegabile.

8. Bonus: esegui il benchmark delle tue pipeline

Per assicurarti che il codice in evoluzione rimanga veloce, è consigliabile monitorarne il comportamento e confrontarlo con gli standard. In questo modo, puoi identificare in modo proattivo le regressioni e migliorare l'affidabilità complessiva, preparandoti al successo a lungo termine.