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 è esaminare 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 Rendimento in DevTools è un ottimo strumento di profilazione per analizzare e ottimizzare il rendimento 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 pattern, colli di bottiglia e punti caldi delle prestazioni su cui intervenire per migliorare il rendimento.
L'esempio seguente ti guida nell'utilizzo del pannello Rendimento.
Configurazione e ricreazione dello scenario di profilazione
Di recente, abbiamo fissato l'obiettivo di migliorare il rendimento del pannello Rendimento. In particolare, volevamo che caricasse più rapidamente grandi volumi di dati sul rendimento. Questo è il caso, ad esempio, della profilazione di processi complessi o di lunga durata o dell'acquisizione di dati ad alta granularità. Per raggiungere questo obiettivo, è stato necessario innanzitutto comprendere come funzionava l'applicazione e perché funzionava in quel modo, cosa che è stata ottenuta utilizzando uno strumento di profilazione.
Come saprai, DevTools è un'applicazione web. Pertanto, può essere profilato utilizzando il riquadro Prestazioni. Per profilare 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à chiamata "prima istanza di DevTools", mentre la finestra che ispeziona la prima istanza verrà chiamata "seconda istanza di DevTools".

Nella seconda istanza di DevTools, il riquadro Rendimento, che d'ora in poi chiameremo riquadro perf, osserva la prima istanza di DevTools per ricreare lo scenario, che carica un profilo.
Nella seconda istanza di DevTools viene avviata una registrazione live, 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 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 di DevTools del riquadro Perf che carica un profilo.
Lo stato iniziale: identificare le opportunità di miglioramento
Al termine del caricamento, nella seconda istanza del pannello delle prestazioni è stato osservato quanto segue nello screenshot successivo. Concentrati sull'attività del thread principale, visibile nella traccia etichettata Principale. Si può notare che nel grafico a fiamma sono presenti cinque grandi gruppi di attività. 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 pannello del rendimento viene utilizzato per concentrarsi su ciascuno di questi gruppi di attività per vedere cosa si può trovare.

Primo gruppo attività: lavoro non necessario
È diventato evidente che il primo gruppo di attività era un codice legacy ancora in esecuzione, ma non realmente necessario. In sostanza, tutto ciò che si trova sotto il blocco verde con l'etichetta processThreadEvents
è stato uno sforzo inutile. Questa è stata una vittoria rapida. La rimozione di questa chiamata di funzione ha consentito di risparmiare circa 1,5 secondi. Interessante!
Secondo gruppo attività
Nel secondo gruppo di attività, la soluzione non era semplice come nel primo. L'operazione buildProfileCalls
ha richiesto circa 0, 5 secondi e non poteva essere evitata.

Per curiosità, abbiamo attivato l'opzione Memoria nel pannello delle prestazioni per ulteriori indagini e abbiamo notato che anche l'attività buildProfileCalls
utilizzava molta memoria. Qui puoi vedere come il grafico a linee blu salta improvvisamente intorno al momento in cui viene eseguito buildProfileCalls
, il che suggerisce una potenziale perdita di memoria.

Per approfondire questo sospetto, abbiamo utilizzato il riquadro Memoria (un altro riquadro in DevTools, diverso dal riquadro estraibile Memoria nel riquadro Prestazioni) per eseguire un'indagine. Nel riquadro Memoria è stato selezionato il tipo di profilazione "Campionamento allocazione", che ha registrato lo snapshot dell'heap per il caricamento del profilo CPU nel riquadro Prestazioni.

Lo screenshot seguente mostra lo snapshot dell'heap raccolto.

Da questo snapshot dell'heap è stato osservato che la classe Set
consumava molta memoria. Controllando i punti di chiamata, è stato rilevato che stavamo assegnando inutilmente proprietà di tipo Set
a oggetti creati in grandi volumi. Questo costo si è accumulato ed è stata consumata molta memoria, al punto che era comune che l'applicazione si arrestasse in modo anomalo con input di grandi dimensioni.
I set sono utili per archiviare elementi unici e forniscono operazioni che utilizzano l'unicità dei contenuti, ad esempio la deduplicazione dei set di dati e ricerche più efficienti. Tuttavia, queste funzionalità non erano necessarie, poiché i dati archiviati erano garantiti come unici rispetto all'origine. Pertanto, 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 semplice. Dopo aver applicato questa modifica, è stata scattata un'altra istantanea dell'heap ed è stata osservata una riduzione dell'allocazione della memoria. Nonostante non siano stati ottenuti miglioramenti significativi della velocità con questa modifica, il vantaggio secondario è stato che l'applicazione si è arrestata in modo anomalo con meno frequenza.

Terzo gruppo di attività: valutazione dei compromessi della struttura dei dati
La terza sezione è particolare: nel grafico a fiamma puoi vedere che è costituita da colonne strette ma alte, che indicano chiamate di funzioni profonde e ricorsioni profonde in questo caso. In totale, questa sezione è durata circa 1,4 secondi. Dalla parte inferiore di questa sezione, era evidente che la larghezza di queste colonne era determinata dalla durata di una funzione: appendEventAtLevel
, il che suggeriva che potesse trattarsi di un collo di bottiglia.
All'interno dell'implementazione della funzione appendEventAtLevel
, una cosa si è distinta. Per ogni singola voce di dati nell'input (nota nel codice come "evento"), è stato aggiunto un elemento a una mappa che monitorava la posizione verticale delle voci della cronologia. Ciò era problematico 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ò, ad esempio, diventare costosa a causa dell'hashing. Questo costo diventa evidente quando vengono aggiunti alla mappa grandi quantità di 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 nel grafico a fiamma. Il miglioramento è stato significativo, confermando che il collo di bottiglia era effettivamente correlato al sovraccarico causato dall'aggiunta di tutti i dati alla mappa. Il tempo impiegato dal gruppo di attività è diminuito 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 duplicazioni
Se ingrandisci questa finestra, puoi notare che ci sono due blocchi quasi identici di chiamate di funzioni. Osservando il nome delle funzioni chiamate, puoi 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 inferiore del pannello. 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 delle chiamate" e "Log eventi" nel riquadro) 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 ostacolava le prestazioni del tempo di caricamento. Gli utenti non hanno sempre bisogno del suo output. Pertanto, 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 posticipando il calcolo della struttura ad albero a quando 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 questa operazione due volte è stato di circa 3,4 secondi, quindi il rinvio ha fatto una differenza significativa nel tempo di caricamento. Stiamo ancora esaminando anche la memorizzazione nella cache di questi tipi di attività.
Quinto gruppo di attività: evita gerarchie di chiamate complesse, se possibile
Esaminando attentamente questo gruppo, è stato chiaro che una particolare catena di chiamate veniva richiamata ripetutamente. Lo stesso pattern è apparso 6 volte in posizioni diverse nel grafico a 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 nella "minimappa" (la panoramica dell'attività della cronologia nella parte superiore del pannello). Non era chiaro perché si verificasse più volte, ma sicuramente non doveva succedere 6 volte. Infatti, l'output del codice deve rimanere attuale 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 in seguito alla chiamata diretta o indiretta della funzione che calcola la minimappa da parte di più parti della pipeline di caricamento. Questo perché la complessità del grafico 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 risolvere il problema 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 ottenuto questa prospettiva della sequenza temporale:

Tieni presente che l'esecuzione del rendering della minimappa avviene due volte, non una. Questo perché per ogni profilo vengono disegnate 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 che seleziona). Tuttavia, questi due hanno esattamente lo stesso contenuto, quindi uno dovrebbe poter essere riutilizzato per l'altro.
Poiché queste minimappe sono entrambe immagini disegnate su un canvas, è stato sufficiente utilizzare l'drawImage
utilità canvas e successivamente eseguire il codice una sola volta per risparmiare 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 è risultata 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 basso, poiché la maggior parte delle operazioni eseguite consisteva in correzioni rapide. Naturalmente, identificare correttamente cosa fare inizialmente è stato fondamentale e il pannello del rendimento è stato lo strumento giusto per questo.
È anche importante sottolineare che questi numeri sono specifici di un profilo utilizzato come oggetto di studio. Il profilo ci interessava 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 pannello delle prestazioni.
Concetti principali
Da questi risultati si possono trarre alcune lezioni in termini di ottimizzazione del rendimento dell'applicazione:
1. Utilizzare gli strumenti di profilazione per identificare i pattern di rendimento in fase di runtime
Gli strumenti di profilazione sono incredibilmente utili per capire cosa succede nella tua applicazione durante l'esecuzione, in particolare per identificare le opportunità di miglioramento delle prestazioni. Il pannello Prestazioni in Chrome DevTools è un'ottima opzione per le applicazioni web, in quanto è lo strumento di profilazione web nativo del browser ed è aggiornato attivamente per essere al passo con le ultime funzionalità della piattaforma web. Inoltre, ora è molto più veloce. 😉
Utilizza campioni che possono essere utilizzati come workload rappresentativi e scopri cosa puoi trovare.
2. Evita gerarchie di chiamate complesse
Se possibile, evita di rendere troppo complesso il grafico delle chiamate. Con gerarchie di chiamate complesse, è facile introdurre regressioni delle prestazioni e difficile capire perché il codice viene eseguito in un determinato modo, il che rende difficile apportare miglioramenti.
3. Identificare il lavoro non necessario
È normale che le codebase obsolete 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 comprendi anche i costi e i compromessi che ogni tipo di struttura di dati comporta quando decidi quali utilizzare. Non si tratta solo della complessità spaziale della struttura di dati stessa, ma anche della complessità temporale delle operazioni applicabili.
5. Memorizza nella cache i risultati per evitare duplicazioni di lavoro per operazioni complesse o ripetitive
Se l'operazione è costosa da eseguire, è opportuno memorizzare i risultati per la volta successiva in cui sono necessari. Ha senso farlo anche se l'operazione viene eseguita molte volte, anche se ogni volta non è particolarmente costosa.
6. Posticipare 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 posticiparla chiamandola in modo differito quando il suo output è effettivamente necessario.
7. Utilizzare algoritmi efficienti su input di grandi dimensioni
Per input di grandi dimensioni, gli algoritmi di complessità temporale ottimali diventano fondamentali. In questo esempio non abbiamo esaminato questa categoria, ma la sua importanza non può essere sopravvalutata.
8. Bonus: confronta le tue pipeline
Per assicurarti che il codice in evoluzione rimanga veloce, è consigliabile monitorarne il comportamento e confrontarlo con gli standard. In questo modo, identifichi in modo proattivo le regressioni e migliori l'affidabilità complessiva, preparandoti al successo a lungo termine.