Een 400% sneller prestatiepaneel door perf-ceptie

Andrés Olivares
Andrés Olivares
Nancy Li
Nancy Li

Ongeacht welk type applicatie u ontwikkelt, het optimaliseren van de prestaties en het garanderen dat deze snel laadt en soepele interacties biedt, is van cruciaal belang voor de gebruikerservaring en het succes van de applicatie. Eén manier om dit te doen is door de activiteit van een applicatie te inspecteren met behulp van profileringstools om te zien wat er onder de motorkap gebeurt terwijl deze gedurende een bepaalde periode wordt uitgevoerd. Het Prestatiepaneel in DevTools is een geweldige profileringstool om de prestaties van webapplicaties te analyseren en optimaliseren. Als uw app in Chrome draait, krijgt u een gedetailleerd visueel overzicht van wat de browser doet terwijl uw applicatie wordt uitgevoerd. Als u deze activiteit begrijpt, kunt u patronen, knelpunten en prestatieproblemen identificeren waarop u actie kunt ondernemen om de prestaties te verbeteren.

Het volgende voorbeeld begeleidt u bij het gebruik van het paneel Prestaties .

Ons profileringsscenario opzetten en opnieuw creëren

Onlangs hebben we ons ten doel gesteld om het prestatiepaneel beter te laten presteren. We wilden vooral dat grote hoeveelheden prestatiegegevens sneller zouden worden geladen. Dit is bijvoorbeeld het geval bij het profileren van langlopende of complexe processen of bij het vastleggen van zeer gedetailleerde gegevens. Om dit te bereiken was eerst inzicht nodig in hoe de applicatie presteerde en waarom deze zo presteerde. Dit werd bereikt door gebruik te maken van een profileringstool.

Zoals u wellicht weet, is DevTools zelf een webapplicatie. Als zodanig kan het worden geprofileerd met behulp van het paneel Prestaties . Om dit paneel zelf te profileren, kunt u DevTools openen en vervolgens een ander DevTools-exemplaar openen dat eraan is gekoppeld. Bij Google staat deze opstelling bekend als DevTools-on-DevTools .

Als de opstelling klaar is, moet het te profileren scenario opnieuw worden gemaakt en vastgelegd. Om verwarring te voorkomen, wordt naar het oorspronkelijke DevTools-venster verwezen als de " eerste DevTools-instantie", en naar het venster dat de eerste instantie inspecteert, wordt verwezen als de " tweede DevTools-instantie".

Een screenshot van een DevTools-instantie die de elementen in DevTools zelf inspecteert.
DevTools-on-DevTools: DevTools inspecteren met DevTools.

Op de tweede DevTools-instantie observeert het Prestatiepaneel (dat vanaf nu het perf-paneel zal worden genoemd) de eerste DevTools-instantie om het scenario opnieuw te creëren, waardoor een profiel wordt geladen.

Bij de tweede instantie van DevTools wordt een live-opname gestart, terwijl bij de eerste instantie een profiel wordt geladen vanuit een bestand op schijf. Er wordt een groot bestand geladen om de prestaties bij het verwerken van grote invoer nauwkeurig te profileren. Wanneer beide instanties klaar zijn met laden, worden de prestatieprofileringsgegevens (gewoonlijk een trace genoemd) gezien in de tweede DevTools-instantie van het perf-paneel dat een profiel laadt.

De uitgangssituatie: het identificeren van mogelijkheden voor verbetering

Nadat het laden was voltooid, werd het volgende op onze tweede perf-paneelinstantie waargenomen in de volgende schermafbeelding. Concentreer u op de activiteit van de hoofdthread, die zichtbaar is onder de track met het label Main . Het is te zien dat er vijf grote activiteitsgroepen in de vlammenkaart zijn. Deze bestaan ​​uit de taken waarbij het laden de meeste tijd kost. De totale tijd van deze taken bedroeg ongeveer 10 seconden . In de volgende schermafbeelding wordt het prestatiepaneel gebruikt om zich op elk van deze activiteitsgroepen te concentreren om te zien wat er te vinden is.

Een schermafbeelding van het prestatiepaneel in DevTools dat het laden van een prestatietracering in het prestatiepaneel van een ander DevTools-exemplaar inspecteert. Het laden van het profiel duurt ongeveer 10 seconden. Deze tijd is grotendeels verdeeld over vijf hoofdgroepen activiteiten.

Eerste activiteitsgroep: onnodig werk

Het werd duidelijk dat de eerste groep activiteiten bestaande code betrof die nog steeds actief was, maar niet echt nodig was. Kortom, alles onder het groene blokje met de naam processThreadEvents was verspilde moeite. Die was een snelle overwinning. Het verwijderen van die functieaanroep bespaarde ongeveer 1,5 seconde tijd. Koel!

Tweede activiteitengroep

Bij de tweede activiteitengroep was de oplossing niet zo eenvoudig als bij de eerste. De buildProfileCalls duurden ongeveer 0,5 seconde, en die taak kon niet worden vermeden.

Een schermafbeelding van het prestatiepaneel in DevTools dat een ander exemplaar van het prestatiepaneel inspecteert. Een taak die is gekoppeld aan de functie buildProfileCalls duurt ongeveer 0,5 seconde.

Uit nieuwsgierigheid hebben we de optie Geheugen in het perf-paneel ingeschakeld om verder onderzoek te doen, en we zagen dat de buildProfileCalls activiteit ook veel geheugen gebruikte. Hier kunt u zien hoe de blauwe lijngrafiek plotseling verspringt rond de tijd dat buildProfileCalls wordt uitgevoerd, wat duidt op een mogelijk geheugenlek.

Een screenshot van de geheugenprofiler in DevTools die het geheugenverbruik van het prestatiepaneel beoordeelt. De inspecteur suggereert dat de functie buildProfileCalls verantwoordelijk is voor een geheugenlek.

Om dit vermoeden op te lossen, hebben we het Memory-paneel (een ander paneel in DevTools, anders dan de Memory-lade in het perf-paneel) gebruikt om dit te onderzoeken. Binnen het paneel Geheugen werd het profileringstype "Allocation sampling" geselecteerd, dat de heap-snapshot registreerde voor het perf-paneel dat het CPU-profiel laadde.

Een screenshot van de beginstatus van de geheugenprofiler. De optie 'allocation sampling' is gemarkeerd met een rood vakje en geeft aan dat deze optie het beste is voor JavaScript-geheugenprofilering.

De volgende schermafbeelding toont de heap-momentopname die is verzameld.

Een schermafbeelding van de geheugenprofiler, waarbij een geheugenintensieve set-gebaseerde bewerking is geselecteerd.

Uit deze heap-snapshot is gebleken dat de klasse Set veel geheugen in beslag nam. Door de handpunten te controleren, bleek dat we onnodig eigenschappen van het type Set toekenden aan objecten die in grote volumes waren gemaakt. Deze kosten liepen op en er werd veel geheugen verbruikt, tot het punt dat het gebruikelijk was dat de applicatie crashte bij grote invoer.

Sets zijn handig voor het opslaan van unieke items en bieden bewerkingen die gebruik maken van het unieke karakter van hun inhoud, zoals het ontdubbelen van datasets en het bieden van efficiëntere zoekacties. Deze functies waren echter niet nodig omdat de opgeslagen gegevens gegarandeerd uniek waren ten opzichte van de bron. Als zodanig waren sets in de eerste plaats niet nodig. Om de geheugentoewijzing te verbeteren, is het eigenschapstype gewijzigd van een Set in een gewone array. Na het toepassen van deze wijziging werd nog een heap-snapshot gemaakt en werd een verminderde geheugentoewijzing waargenomen. Ondanks dat er met deze wijziging geen aanzienlijke snelheidsverbeteringen werden bereikt, was het secundaire voordeel dat de applicatie minder vaak crashte.

Een screenshot van de geheugenprofiler. De voorheen geheugenintensieve Set-gebaseerde bewerking is gewijzigd om een ​​gewone array te gebruiken, waardoor de geheugenkosten aanzienlijk zijn verlaagd.

Derde activiteitsgroep: afwegingen tussen datastructuren

Het derde gedeelte is eigenaardig: je kunt in de vlammengrafiek zien dat het bestaat uit smalle maar hoge kolommen, die diepe functieaanroepen aanduiden, en in dit geval diepe recursies. In totaal duurde dit gedeelte ongeveer 1,4 seconden. Door naar de onderkant van deze sectie te kijken, werd het duidelijk dat de breedte van deze kolommen werd bepaald door de duur van één functie: appendEventAtLevel , wat suggereerde dat dit een knelpunt zou kunnen zijn

Bij de implementatie van de functie appendEventAtLevel viel één ding op. Voor elke afzonderlijke gegevensinvoer in de invoer (die in de code bekend staat als de "gebeurtenis") werd een item toegevoegd aan een kaart die de verticale positie van de tijdlijninvoeren bijhield. Dit was problematisch, omdat de hoeveelheid spullen die werd opgeslagen erg groot was. Kaarten zijn snel voor op sleutels gebaseerde zoekopdrachten, maar dit voordeel is niet gratis. Naarmate een kaart groter wordt, kan het toevoegen van gegevens eraan bijvoorbeeld duur worden door het opnieuw bewerken ervan. Deze kosten worden merkbaar wanneer grote hoeveelheden items achtereenvolgens aan de kaart worden toegevoegd.

/**
 * 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);

  // ...
}

We hebben met een andere aanpak geëxperimenteerd, waarbij we niet voor elk item in de vlammenkaart een item op een kaart hoefden toe te voegen. De verbetering was aanzienlijk en bevestigde dat het knelpunt inderdaad verband hield met de overhead die werd opgelopen door het toevoegen van alle gegevens aan de kaart. De tijd die de activiteitengroep nodig had, kromp van ongeveer 1,4 seconden naar ongeveer 200 milliseconden.

Voor:

Een schermafbeelding van het prestatiepaneel voordat er optimalisaties werden aangebracht aan de functie appendEventAtLevel. De totale tijd voor het uitvoeren van de functie was 1.372,51 milliseconden.

Na:

Een schermafbeelding van het prestatiepaneel nadat er optimalisaties zijn aangebracht aan de functie appendEventAtLevel. De totale tijd voor het uitvoeren van de functie was 207,2 milliseconden.

Vierde activiteitsgroep: het uitstellen van niet-kritiek werk en het cachen van gegevens om dubbel werk te voorkomen

Als u inzoomt op dit venster, ziet u dat er twee vrijwel identieke blokken met functieaanroepen zijn. Door naar de naam van de aangeroepen functies te kijken, kun je concluderen dat deze blokken bestaan ​​uit code die bomen bouwt (bijvoorbeeld met namen als refreshTree of buildChildren ). In feite is de bijbehorende code degene die de boomweergaven in de onderste la van het paneel creëert. Wat interessant is, is dat deze boomstructuurweergaven niet direct na het laden worden weergegeven. In plaats daarvan moet de gebruiker een boomstructuur selecteren (de tabbladen "Bottom-up", "Call Tree" en "Event Log" in de lade) om de bomen weer te geven. Bovendien werd, zoals u uit de schermafbeelding kunt zien, het proces voor het bouwen van de boom tweemaal uitgevoerd.

Een screenshot van het prestatiepaneel met verschillende, repetitieve taken die worden uitgevoerd, zelfs als ze niet nodig zijn. Deze taken kunnen worden uitgesteld om op verzoek te worden uitgevoerd, in plaats van van tevoren.

Er zijn twee problemen die we met deze afbeelding hebben geïdentificeerd:

  1. Een niet-kritieke taak belemmerde de uitvoering van de laadtijd. Gebruikers hebben niet altijd de uitvoer ervan nodig. Als zodanig is de taak niet kritisch voor het laden van het profiel.
  2. Het resultaat van deze taken is niet in de cache opgeslagen. Daarom zijn de bomen twee keer berekend, ondanks dat de gegevens niet veranderden.

We zijn begonnen met het uitstellen van de boomberekening tot het moment waarop de gebruiker de boomstructuur handmatig opende. Alleen dan is het de moeite waard om de prijs te betalen voor het maken van deze bomen. De totale tijd om dit twee keer uit te voeren was ongeveer 3,4 seconden, dus het uitstellen ervan maakte een aanzienlijk verschil in de laadtijd. We onderzoeken nog steeds of we dit soort taken ook in de cache kunnen opslaan.

Vijfde activiteitsgroep: vermijd waar mogelijk complexe oproephiërarchieën

Als we goed naar deze groep keken, werd het duidelijk dat een bepaalde oproepketen herhaaldelijk werd aangeroepen. Hetzelfde patroon verscheen 6 keer op verschillende plaatsen in de vlammenkaart, en de totale duur van dit venster was ongeveer 2,4 seconden!

Een screenshot van het prestatiepaneel met zes afzonderlijke functieaanroepen voor het genereren van dezelfde traceerminimap, die elk diepe call-stacks hebben.

De gerelateerde code die meerdere keren wordt aangeroepen, is het deel dat de gegevens verwerkt die moeten worden weergegeven op de "minimap" (het overzicht van de tijdlijnactiviteit bovenaan het paneel). Het was niet duidelijk waarom het meerdere keren gebeurde, maar het hoefde zeker geen 6 keer te gebeuren! In feite zou de uitvoer van de code actueel moeten blijven als er geen ander profiel wordt geladen. In theorie zou de code slechts één keer moeten worden uitgevoerd.

Bij onderzoek bleek dat de gerelateerde code werd aangeroepen als gevolg van het feit dat meerdere delen in de laadpijplijn direct of indirect de functie aanroepen die de minimap berekent. Dit komt omdat de complexiteit van de aanroepgrafiek van het programma in de loop van de tijd is geëvolueerd en er onbewust meer afhankelijkheden aan deze code zijn toegevoegd. Er bestaat geen snelle oplossing voor dit probleem. De manier om dit op te lossen hangt af van de architectuur van de codebase in kwestie. In ons geval moesten we de complexiteit van de oproephiërarchie een beetje verminderen en een controle toevoegen om te voorkomen dat de code werd uitgevoerd als de invoergegevens ongewijzigd bleven. Nadat we dit hadden geïmplementeerd, kregen we dit beeld van de tijdlijn:

Een screenshot van het prestatiepaneel met de zes afzonderlijke functieaanroepen voor het genereren van dezelfde traceerminimap, teruggebracht tot slechts twee keer.

Houd er rekening mee dat de uitvoering van de minimap-rendering twee keer plaatsvindt, en niet één keer. Dit komt omdat er voor elk profiel twee minimaps worden getekend: één voor het overzicht bovenaan het paneel, en één voor het vervolgkeuzemenu dat het momenteel zichtbare profiel uit de geschiedenis selecteert (elk item in dit menu bevat een overzicht van het profiel dat het selecteert). Niettemin hebben deze twee exact dezelfde inhoud, dus de een zou voor de ander moeten kunnen worden hergebruikt.

Omdat deze minimaps beide afbeeldingen zijn die op een canvas zijn getekend, was het een kwestie van het drawImage canvas-hulpprogramma gebruiken en vervolgens de code slechts één keer uitvoeren om wat extra tijd te besparen. Als gevolg van deze inspanning werd de duur van de groep teruggebracht van 2,4 seconden naar 140 milliseconden.

Conclusie

Nadat al deze verbeteringen waren aangebracht (en hier en daar nog een paar kleinere), zag de wijziging van de laadtijdlijn van het profiel er als volgt uit:

Voor:

Een schermafbeelding van het prestatiepaneel waarin het laden van sporen vóór optimalisaties wordt weergegeven. Het proces duurde ongeveer tien seconden.

Na:

Een schermafbeelding van het prestatiepaneel waarin het laden van sporen na optimalisaties wordt weergegeven. Het proces duurt nu ongeveer twee seconden.

De laadtijd na de verbeteringen bedroeg 2 seconden, wat betekent dat met relatief weinig inspanning een verbetering van ongeveer 80% werd bereikt, aangezien het grootste deel van wat werd gedaan uit snelle oplossingen bestond. Natuurlijk was het van cruciaal belang om in eerste instantie goed te identificeren wat te doen, en het perf-panel was hiervoor het juiste hulpmiddel.

Het is ook belangrijk om te benadrukken dat deze cijfers specifiek zijn voor een profiel dat als studieonderwerp wordt gebruikt. Het profiel was voor ons interessant omdat het bijzonder groot was. Omdat de verwerkingspijplijn echter voor elk profiel hetzelfde is, geldt de aanzienlijke verbetering die wordt bereikt voor elk profiel dat in het perf-paneel wordt geladen.

Afhaalrestaurants

Er zijn enkele lessen die u uit deze resultaten kunt trekken op het gebied van prestatie-optimalisatie van uw applicatie:

1. Maak gebruik van profileringstools om prestatiepatronen tijdens runtime te identificeren

Profileringstools zijn ongelooflijk handig om te begrijpen wat er in uw applicatie gebeurt terwijl deze actief is, vooral om mogelijkheden te identificeren om de prestaties te verbeteren. Het prestatiepaneel in Chrome DevTools is een geweldige optie voor webapplicaties, omdat het de eigen webprofileringstool in de browser is en actief wordt onderhouden om up-to-date te zijn met de nieuwste webplatformfuncties. Bovendien gaat het nu aanzienlijk sneller! 😉

Gebruik voorbeelden die kunnen worden gebruikt als representatieve werklasten en kijk wat u kunt vinden!

2. Vermijd complexe oproephiërarchieën

Zorg er indien mogelijk voor dat uw oproepgrafiek niet te ingewikkeld wordt. Met complexe oproephiërarchieën is het gemakkelijk om prestatieregressies te introduceren en moeilijk te begrijpen waarom uw code werkt zoals deze is, waardoor het moeilijk wordt om verbeteringen aan te brengen.

3. Identificeer onnodig werk

Het komt vaak voor dat verouderde codebases code bevatten die niet langer nodig is. In ons geval nam verouderde en onnodige code een aanzienlijk deel van de totale laadtijd in beslag. Het verwijderen ervan was het laaghangende fruit.

4. Gebruik datastructuren op de juiste manier

Gebruik datastructuren om de prestaties te optimaliseren, maar begrijp ook de kosten en afwegingen die elk type datastructuur met zich meebrengt bij het beslissen welke u wilt gebruiken. Dit is niet alleen de ruimtecomplexiteit van de datastructuur zelf, maar ook de tijdcomplexiteit van de toepasselijke bewerkingen.

5. Resultaten in de cache opslaan om dubbel werk voor complexe of repetitieve bewerkingen te voorkomen

Als de uitvoering kostbaar is, is het zinvol om de resultaten op te slaan voor de volgende keer dat deze nodig zijn. Het is ook zinvol om dit te doen als de operatie vele malen wordt uitgevoerd, zelfs als elke afzonderlijke keer niet bijzonder kostbaar is.

6. Stel niet-kritiek werk uit

Als de uitvoer van een taak niet onmiddellijk nodig is en de uitvoering van de taak het kritieke pad verlengt, overweeg dan om de taak uit te stellen door deze lui aan te roepen wanneer de uitvoer daadwerkelijk nodig is.

7. Gebruik efficiënte algoritmen voor grote invoer

Voor grote inputs worden algoritmen voor optimale tijdcomplexiteit cruciaal. We hebben in dit voorbeeld niet naar deze categorie gekeken, maar het belang ervan kan nauwelijks worden overschat.

8. Bonus: benchmark uw pijpleidingen

Om ervoor te zorgen dat uw evoluerende code snel blijft, is het verstandig om het gedrag te monitoren en te vergelijken met standaarden. Op deze manier identificeert u proactief regressies en verbetert u de algehele betrouwbaarheid, waardoor u succes op de lange termijn kunt behalen.