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 è 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 la tua app è in esecuzione in Chrome, ti offre 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 delle prestazioni su cui puoi intervenire per migliorare le prestazioni.
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 comprendere come e perché l'applicazione avesse un rendimento simile, il che è stato possibile grazie a uno strumento di profilazione.
Come forse saprai, DevTools stesso è un'applicazione web. Di conseguenza, può essere profilata 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".
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 di profilazione delle prestazioni, comunemente chiamati tracce, 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.
Primo gruppo di attività: lavoro non necessario
È apparso evidente che il primo gruppo di attività era un codice legacy che veniva ancora eseguito, ma che in realtà non era necessario. In pratica, tutto ciò che si trova sotto il blocco verde contrassegnato come processThreadEvents
è stato uno sforzo sprecato. Quella è stata una vittoria veloce. 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.
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 fa un salto improvviso intorno al momento in cui viene eseguito buildProfileCalls
, il che suggerisce una potenziale 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.
Lo screenshot seguente mostra lo snapshot dell'heap raccolto.
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 rispetto all'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. 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.
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 (nota come "evento") nel codice, è stato aggiunto un elemento a una mappa che tracciava 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 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 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:
Dopo:
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. 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 cassetto inferiore del riquadro. La cosa interessante è che queste visualizzazioni ad albero non vengono mostrate subito dopo il caricamento. L'utente deve invece selezionare una visualizzazione ad albero (le schede "Dal basso verso l'alto", "Albero chiamate" e "Log eventi" nel riquadro laterale) per visualizzare gli alberi. Inoltre, come puoi vedere dallo screenshot, la procedura di creazione dell'albero è stata eseguita due volte.
Abbiamo identificato due problemi con questa immagine:
- 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.
- 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 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 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.
Il codice correlato 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é 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.
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 accade 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 ricavato queste tempistiche:
Tieni presente che l'esecuzione del rendering della minimap avviene due volte, non una volta. 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:
Dopo:
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. Naturalmente, identificare correttamente cosa fare inizialmente era fondamentale e il riquadro delle prestazioni era lo strumento giusto.
È inoltre importante sottolineare che questi numeri sono specifici di un profilo utilizzato come soggetto 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. 😉
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. 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. Rimuoverlo era il frutto più scarso.
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. Ha senso anche farlo se l'operazione viene eseguita molte volte, anche se ogni singola volta non è particolarmente costosa.
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.