Come abbiamo velocizzato di 10 volte le analisi dello stack di Chrome DevTools

Benedikt Meurer
Benedikt Meurer

Gli sviluppatori web si aspettano un impatto sulle prestazioni minimo o nullo per il debug del loro codice. Tuttavia, questa aspettativa non è affatto universale. Uno sviluppatore C++ non si aspettava mai che una build di debug della sua applicazione raggiunga prestazioni di produzione e, nei primi anni di Chrome, la semplice apertura di DevTools influiva in modo significativo sulle prestazioni della pagina.

Il fatto che questo peggioramento delle prestazioni non si faccia più sentire è il risultato di anni di investimenti nelle funzionalità di debug di DevTools e V8. Tuttavia, non saremo mai in grado di ridurre a zero il sovraccarico delle prestazioni di DevTools. Impostare punti di interruzione, eseguire passaggi nel codice, raccogliere analisi dello stack, acquisire un'analisi delle prestazioni e così via, tutto ha un impatto diverso sulla velocità di esecuzione. Dopotutto, osservare qualcosa lo cambia.

Tuttavia, l'overhead di DevTools, come qualsiasi debugger, dovrebbe essere ragionevole. Recentemente abbiamo riscontrato un aumento significativo nel numero di report che, in alcuni casi, DevTools rallentava l'applicazione fino a rendere impossibile l'utilizzo. Di seguito puoi vedere un confronto affiancato del report chromium:1069425, che illustra l'overhead di prestazioni dovuto letteralmente solo avere DevTools aperto.

Come puoi vedere dal video, il rallentamento è nell'ordine di 5-10x, il che chiaramente non è accettabile. Il primo passo è stato capire dove rimane tutto il tempo e cosa causa questo enorme rallentamento quando DevTools era aperto. L'utilizzo di Linux perf nel processo del renderer di Chrome ha rilevato la seguente distribuzione del tempo di esecuzione complessivo del renderer:

Tempo di esecuzione del renderer di Chrome

Anche se ci aspettavamo di vedere qualcosa di correlato alla raccolta delle analisi dello stack, non ci aspettavamo che circa il 90% del tempo di esecuzione complessivo andasse a simbolizzare gli stack frame. La simbolizzazione in questo caso si riferisce all'atto di risolvere i nomi di funzioni e le posizioni concrete dell'origine (numeri di linee e colonne negli script) dagli stack frame non elaborati.

Inferenza del nome del metodo

Quello che è stato ancora più sorprendente è stato il fatto che venga utilizzata quasi sempre la funzione JSStackFrame::GetMethodName() in V8, anche se sapevamo dalle precedenti indagini che JSStackFrame::GetMethodName() non è straniero nel mondo dei problemi di prestazioni. Questa funzione tenta di calcolare il nome del metodo per i frame considerati chiamate di metodo (frame che rappresentano chiamate di funzioni nel formato obj.func() anziché func()). Un rapido esame del codice ha rivelato che funziona eseguendo un attraversamento completo dell'oggetto e della sua catena di prototipi e cercando

  1. proprietà dei dati il cui value è la chiusura func oppure
  2. proprietà della funzione di accesso in cui get o set equivale alla chiusura func.

Anche se da sola non sembra particolarmente economico, non sembra che spiegherebbe questo terribile rallentamento. Abbiamo quindi iniziato a esaminare approfonditamente l'esempio riportato in chromium:1069425 e abbiamo scoperto che le analisi dello stack sono state raccolte per le attività asincrone e per i messaggi di log provenienti da classes.js, un file JavaScript da 10 MiB. Un'analisi più approfondita ha rivelato che si tratta essenzialmente di un runtime Java e del codice dell'applicazione compilato in JavaScript. Le analisi dello stack contenevano diversi frame con metodi richiamati su un oggetto A, quindi abbiamo pensato che fosse utile capire con quale tipo di oggetto si ha a che fare.

analisi dello stack di un oggetto

Apparentemente il compilatore da Java a JavaScript ha generato un singolo oggetto con ben 82.203 funzioni su di esso: questo stava chiaramente iniziando a diventare interessante. Poi siamo tornati sul JSStackFrame::GetMethodName() del V8 per capire se c'è qualche frutto basso da raccogliere lì.

  1. Funziona cercando il "name" della funzione come proprietà dell'oggetto e, se viene trovato, verifica che il valore della proprietà corrisponda alla funzione.
  2. Se la funzione non ha un nome o l'oggetto non ha una proprietà corrispondente, viene eseguita una ricerca inversa, valutando tutte le proprietà dell'oggetto e dei relativi prototipi.

Nel nostro esempio, tutte le funzioni sono anonime e hanno proprietà "name" vuote.

A.SDV = function() {
   // ...
};

Il primo risultato è stato che la ricerca inversa è stata suddivisa in due passaggi (eseguita per l'oggetto stesso e per ogni oggetto della sua catena di prototipi):

  1. Estrai i nomi di tutte le proprietà enumerabili
  2. Esegui una ricerca generica di proprietà per ogni nome, verificando se il valore della proprietà risultante corrisponde alla chiusura che stavamo cercando.

Sembrava un frutto piuttosto basso, dato che per estrarre i nomi è necessario già attraversare tutte le proprietà. Anziché eseguire le due operazioni, O(N) per l'estrazione del nome e O(N log(N)) per i test, potevamo fare tutto in un unico passaggio e controllare direttamente i valori delle proprietà. In questo modo l'intera funzione è stata di circa 2-10 volte più veloce.

Il secondo risultato è stato ancora più interessante. Sebbene le funzioni fossero funzioni tecnicamente anonime, il motore V8 aveva comunque registrato quello che chiamiamo nome dedotto. Per i valori letterali di funzione che appaiono sul lato destro delle assegnazioni nel formato obj.foo = function() {...}, l'analizzatore sintattico V8 memorizza "obj.foo" come nome dedotto per il valore letterale di funzione. Nel nostro caso ciò significa che, anche se non avevamo il nome proprio che potevamo semplicemente cercare, c'era qualcosa di abbastanza simile: per l'esempio A.SDV = function() {...} precedente, abbiamo avuto "A.SDV" come nome dedotto e potremmo ricavare il nome della proprietà dal nome dedotto cercando l'ultimo punto, quindi andare a cercare la proprietà "SDV" sull'oggetto. Questo si è rivelato efficace in quasi tutti i casi, sostituendo un full traversal costoso con una ricerca di proprietà singole. Questi due miglioramenti sono stati implementati come parte di questo CL e hanno ridotto in modo significativo il rallentamento per l'esempio riportato in chromium:1069425.

Error.stack

Avremmo potuto chiamarlo un giorno qui. Ma c'è stato qualcosa di sospetto, poiché DevTools non utilizza mai il nome del metodo per gli stack frame. Infatti la classe v8::StackFrame nell'API C++ non espone nemmeno un modo per ottenere il nome del metodo. Quindi sembrava sbagliato che avremmo finito per chiamare JSStackFrame::GetMethodName() in primo luogo. L'unico posto in cui utilizziamo (ed esponiamo) il nome del metodo è invece nell'API JavaScript dello stack traccia. Per comprendere questo utilizzo, considera il seguente semplice esempio error-methodname.js:

function foo() {
    console.log((new Error).stack);
}

var object = {bar: foo};
object.bar();

Qui abbiamo una funzione foo che viene installata con il nome "bar" su object. L'esecuzione di questo snippet in Chromium produce il seguente output:

Error
    at Object.foo [as bar] (error-methodname.js:2)
    at error-methodname.js:6

Qui vediamo la ricerca del nome del metodo: lo stack frame più in alto viene mostrato per chiamare la funzione foo su un'istanza di Object tramite il metodo denominato bar. Pertanto, la proprietà error.stack non standard fa un uso intensivo di JSStackFrame::GetMethodName() e, di fatto, i nostri test delle prestazioni indicano anche che le nostre modifiche hanno reso le cose molto più veloci.

Accelerazione dei micro benchmark di StackTrace

Tornando all'argomento dei Chrome DevTools, però, il fatto che il nome del metodo venga calcolato anche se non viene utilizzato error.stack non sembra corretto. C'è un po' di storia che ci aiuta: tradizionalmente V8 aveva due meccanismi distinti per raccogliere e rappresentare un'analisi dello stack per le due diverse API descritte sopra (l'API v8::StackFrame C++ e l'API per l'analisi dello stack JavaScript). Avere due modi diversi per fare (all'incirca) lo stesso era soggetto a errori e spesso causava incoerenze e bug, quindi alla fine del 2018 abbiamo avviato un progetto per stabilirsi su un singolo collo di bottiglia per l'acquisizione dell'analisi dello stack.

Il progetto ha avuto un grande successo e ha ridotto drasticamente il numero di problemi relativi alla raccolta dell'analisi dello stack. Anche la maggior parte delle informazioni fornite tramite la proprietà error.stack non standard è stata calcolata in modo pigro e solo quando era davvero necessario, ma nell'ambito del refactoring abbiamo applicato lo stesso trucco a v8::StackFrame oggetti. Tutte le informazioni sullo stack frame vengono calcolate la prima volta che viene richiamato un metodo su di esso.

In genere questo migliora le prestazioni, ma sfortunatamente si è rivelato un po' in contrasto con il modo in cui questi oggetti dell'API C++ vengono utilizzati in Chromium e DevTools. In particolare, dato che avevamo introdotto una nuova classe v8::internal::StackFrameInfo, che conteneva tutte le informazioni su uno stack frame esposto tramite v8::StackFrame o error.stack, avremmo sempre calcolato il super-set di informazioni fornite da entrambe le API, il che significava che per gli utilizzi di v8::StackFrame (e in particolare per DevTools) calcoleremo anche il nome del metodo, non appena vengono richieste informazioni su uno stack frame. Abbiamo scoperto che DevTools richiede sempre immediatamente le informazioni su origine e script.

In base a questa consapevolezza, siamo stati in grado di refactoring e semplificare drasticamente la rappresentazione dello stack frame e renderla ancora più pigra, in modo che gli utilizzi in V8 e Chromium ora paghino solo il costo del calcolo delle informazioni che richiedono. Ciò ha migliorato notevolmente le prestazioni di DevTools e di altri casi d'uso di Chromium, che hanno bisogno solo di una frazione delle informazioni sugli stack frame (essenzialmente solo il nome dello script e la posizione di origine sotto forma di offset riga e colonna) e ha aperto le porte a ulteriori miglioramenti delle prestazioni.

Nomi delle funzioni

Senza i refactoring sopra menzionati, l'overhead della simbolizzazione (il tempo speso in v8_inspector::V8Debugger::symbolize) è stato ridotto a circa il 15% del tempo di esecuzione complessivo e abbiamo potuto vedere più chiaramente dove V8 trascorreva il tempo quando (raccogliva e) simbolizzava gli stack frame per il consumo in DevTools.

Costo di simbolizzazione

La prima cosa che ha colpito è stato il costo cumulativo per la linea di calcolo e il numero di colonna. La parte costosa qui è in realtà calcolare l'offset di carattere all'interno dello script (in base all'offset di bytecode che otteniamo da V8), e si è scoperto che, a causa del nostro refactoring sopra, lo abbiamo fatto due volte, una volta per calcolare il numero di riga e un'altra volta quando si calcola il numero di colonna. La memorizzazione nella cache della posizione di origine nelle istanze v8::internal::StackFrameInfo ha contribuito a risolvere rapidamente questo problema ed ha eliminato completamente v8::internal::StackFrameInfo::GetColumnNumber da tutti i profili.

La scoperta più interessante per noi è stata che v8::StackFrame::GetFunctionName era sorprendentemente in alto in tutti i profili che abbiamo guardato. Andando più a fondo, ci siamo resi conto che calcolare il nome della funzione nello stack frame in DevTools era inutilmente costoso.

  1. Innanzitutto, cerca la proprietà "displayName" non standard e, se genera una proprietà dati con un valore stringa, la useremo,
  2. altrimenti, tornando a cercare la proprietà "name" standard e a controllare di nuovo se questa restituisce una proprietà dati il cui valore è una stringa,
  3. e infine il fallback a un nome di debug interno dedotto dall'analizzatore sintattico V8 e archiviato nel valore letterale della funzione.

La proprietà "displayName" è stata aggiunta come soluzione alternativa per la proprietà "name" delle istanze Function che sono di sola lettura e non configurabili in JavaScript, ma non è mai stata standardizzata e non è stata utilizzata su larga scala, poiché gli strumenti per sviluppatori dei browser aggiungevano l'inferenza del nome della funzione che svolge la funzione nel 99,9% dei casi. Inoltre, ES2015 ha reso configurabile la proprietà "name" su Function istanze, eliminando completamente la necessità di una proprietà "displayName" speciale. Poiché la ricerca negativa per "displayName" è piuttosto costosa e non è davvero necessaria (ES2015 è stato rilasciato più di cinque anni fa), abbiamo deciso di rimuovere il supporto per la proprietà fn.displayName non standard dalla V8 (e DevTools).

In seguito alla ricerca esclusa di "displayName", è stata rimossa la metà del costo di v8::StackFrame::GetFunctionName. L'altra metà va alla ricerca generica della proprietà "name". Fortunatamente avevamo già implementato una logica per evitare costose ricerche della proprietà "name" nelle istanze Function (non interessate), che abbiamo introdotto nella versione 8 un po' di tempo fa per rendere più veloce Function.prototype.bind(). Abbiamo trasferito i controlli necessari, che ci consentono innanzitutto di evitare la costosa ricerca generica. Con il risultato che v8::StackFrame::GetFunctionName non viene più visualizzato in nessuno dei profili che abbiamo più preso in considerazione.

Conclusione

Con i miglioramenti di cui sopra, abbiamo ridotto in modo significativo l'overhead di DevTools in termini di analisi dello stack.

Sappiamo che ci sono ancora vari possibili miglioramenti. Ad esempio, l'overhead durante l'utilizzo di MutationObserver è ancora evidente, come riportato nel sito chromium:1077657, ma per il momento abbiamo risolto i principali problemi e potremmo tornare in futuro per ottimizzare ulteriormente le prestazioni di debug.

Scaricare i canali in anteprima

Prendi in considerazione l'utilizzo di Chrome Canary, Dev o Beta come browser di sviluppo predefinito. Questi canali in anteprima ti consentono di accedere alle ultime funzionalità DevTools, testare le API delle piattaforme web all'avanguardia e individuare eventuali problemi sul tuo sito prima che lo facciano gli utenti.

Contattare il team di Chrome DevTools

Utilizza le seguenti opzioni per discutere delle nuove funzionalità e modifiche nel post o qualsiasi altra informazione relativa a DevTools.

  • Inviaci un suggerimento o un feedback tramite crbug.com.
  • Segnala un problema di DevTools utilizzando Altre opzioni   Altre   > Guida > Segnala i problemi di DevTools in DevTools.
  • Tweet all'indirizzo @ChromeDevTools.
  • Lascia commenti sui video di YouTube o sui suggerimenti di DevTools sui video di YouTube di DevTools.