Bortom cProfile: Välja rätt verktyg för prestandaoptimering

Note: Den här artikeln är baserad på ett föredrag som jag höll på PyGotham 2019. Du kan se videon här.

Ditt Pythonprogram är för långsamt.Kanske kan din webbapplikation inte hänga med, eller så tar vissa sökningar lång tid.Kanske har du ett batchprogram som tar timmar eller till och med dagar att köra.

Hur kan du öka hastigheten?

Den grundläggande processen som du sannolikt kommer att följa är:

  1. Välj rätt verktyg för att mäta hastigheten.
  2. Använd verktyget för att ta reda på flaskhalsen.
  3. Fixa flaskhalsen.

Denna artikel kommer att fokusera på det första steget: att välja rätt verktyg.Och i synnerhet kommer den att behandla:

  • cProfile: Pythons standardbiblioteks deterministiska profiler.
  • Pyinstrument: Pythons standardbibliotek:
  • Eliot: En profiler för sampling: Ett loggningsbibliotek.

Jag kommer inte att gå in på en stor mängd detaljer om hur man använder dessa verktyg, eftersom målet är att hjälpa dig att välja rätt verktyg. Men jag kommer att förklara vad verktygen gör och när och varför du skulle välja det ena verktyget framför det andra.

cProfile: Det är därför det är en deterministisk profiler: om du kör den med samma indata kommer den att ge samma resultat.

Som standard mäter cProfile process CPU – hur mycket CPU din process har använt hittills.Du bör alltid fråga vad din profiler mäter, eftersom olika mått kan upptäcka olika problem. att mäta CPU innebär att du inte kan upptäcka långsamhet som orsakas av andra orsaker, som att vänta på svar från en databasfråga.

Medans cProfile alltid finns tillgänglig i din Python-installation har den också vissa problem – och som du kommer att se, vill du för det mesta inte använda den.

Användning av cProfile

Användning av cProfile är ganska enkelt.Om du har ett skript som du vanligtvis kör direkt så här:

$ python benchmark.py7855 messages/sec

Då kan du bara prefixera python -m cProfile för att köra det under profileringen:

Det finns också ett Python-profilerings-API, så att du kan profilera särskilda funktioner i en Python-prompt för tolkning eller en Jupyter-notebook.

Utdataformatet är en tabell, vilket inte är idealiskt: varje rad är ett funktionsanrop som kördes under den profilerade tidsperioden, men du vet inte hur det funktionsanropet är relaterat till andra funktionsanrop.Så om du har en funktion som kan nås från flera kodvägar kan det vara svårt att ta reda på vilken kodväg som var ansvarig för anropet av den långsamma funktionen.

Vad cProfile kan berätta

Om du tittar på tabellen ovan kan du se att:

  • _output.py(__call__) anropades 50 000 gånger. Det är ett jämnt antal eftersom detta är ett benchmarkskript som kör samma kod i en slinga 10 000 gånger.Om du inte avsiktligt anropade en funktion många gånger kan detta vara användbart för att upptäcka högt antal anrop är användbart för att identifiera upptagna innerslingor.
  • _output.py(send) använde 0,618 CPU-sekunder (inklusive CPU-tid för de funktioner som anropades) och 0,045 CPU-sekunder (utan att inkludera de funktioner som anropades).

Med hjälp av den här informationen kan du upptäcka långsamma funktioner som du kan optimera – i alla fall långsamma när det gäller processorn.

Hur det fungerar

cProfile mäter varje enskilt funktionsanrop.Särskilt varje funktionsanrop i körningen blir förpackat på följande sätt:

start = process_time()try: f()finally: elapsed = process_time() - start

Profilern registrerar processorns tid i början och i slutet, och skillnaden allokeras till den funktionens konto.

Problemen med cProfile

Men cProfile finns alltid tillgänglig i alla Pythoninstallationer, men den lider också av några betydande problem.

Problem nr 1: Hög overhead och förvrängda resultat

Som du kan föreställa dig har det vissa kostnader att göra extra arbete för varje funktionsanrop:

$ python benchmark.py7855 messages/sec$ python -m cProfile benchmark.py5264 messages/sec... cProfile output ...

Märk hur mycket långsammare cProfile-körningen är.Och vad som är värre är att fördröjningen inte är jämn över hela programmet: eftersom den är knuten till antalet funktionsanrop kommer delar av koden med fler funktionsanrop att fördröjas mer.Så den här överbelastningen kan snedvrida resultaten.

Problem nr 2: För mycket information

Om du minns cProfile-utmatningen som vi såg ovan innehåller den en rad för varje enskild funktion som anropades under programkörningen.De flesta av dessa funktionsanrop är irrelevanta för vårt prestandaproblem: de körs snabbt och bara en eller två gånger.

Så när du läser cProfile-utmatningen har du att göra med en massa extra brus som maskerar signalen.

Problem nr 3: Offline-mått på prestanda

Som ofta är ditt program bara långsamt när det körs under verkliga förhållanden, med verkliga indata.Kanske är det bara vissa förfrågningar från användare som saktar ner ditt webbprogram, och du vet inte vilka förfrågningar.Kanske är ditt batchprogram bara långsamt med riktiga data.

Men cProfile som vi såg gör ditt program ganska långsamt, och därför vill du troligen inte köra det i din produktionsmiljö.Så medan långsamheten bara kan reproduceras i produktionen hjälper cProfile dig bara i din utvecklingsmiljö.

Problem nr 4: Prestanda mäts bara för funktioner

cProfile kan tala om för dig att ”slowfunc() är långsam”, där den beräknar medelvärdet av alla inmatningar till den funktionen.Och det är bra om funktionen alltid är långsam.

Men ibland har du algoritmisk kod som bara är långsam för specifika indata.Det är fullt möjligt att:

  • slowfunc(100) är snabb.
  • slowfunc(0) är långsam.

cProfile kommer inte att kunna berätta vilka indata som orsakade långsamheten, vilket kan göra det svårare att diagnostisera problemet.

cProfile: Istället kommer vi att prata om två alternativ:

  • Pyinstrument löser problemen nr 1 och 2.
  • Eliot löser problem #3 och #4.

Pyinstrument: a sampling profiler

Pyinstrument löser två av de problem som vi tog upp ovan:

  • Det har lägre overhead än cProfile, och det förvränger inte resultaten.
  • Den filtrerar bort irrelevanta funktionsanrop, så det blir mindre brus.

Pyinstrument mäter förfluten väggklockstid, inte CPU-tid, så den kan fånga upp långsamhet som orsakas av nätverksförfrågningar, diskskrivningar, låsstridigheter och så vidare.

Hur du använder det

Användning av Pyinstrument liknar cProfile; lägg bara till ett prefix i ditt skript:

$ python benchmark.py7561 messages/sec$ python -m pyinstrument benchmark.py6760 messages/sec... pyinstrument output ...

Märk att det har en viss overhead, men inte lika mycket som cProfile – och overhead är enhetlig.

Pyinstrument har också ett Python-API, så du kan använda det för att profilera särskilda kodstycken i en interaktiv Python-tolk eller en Jupyter-notebook.

Utgången

Pyinstruments utgång är ett träd av anrop, som mäter väggtid:

Till skillnad från cProfiles rad-per-funktion ger Pyinstrument dig ett träd av funktionsanrop, så att du kan se kontexten för långsamheten. en funktion kan dyka upp flera gånger om långsamheten orsakas av flera kodvägar.

Som ett resultat är Pyinstruments utdata mycket lättare att tolka och ger dig en mycket bättre förståelse för ditt programs prestandastruktur än cProfiles standardutdata.

Hur det fungerar (kattutgåvan)

Föreställ dig att du har en katt och att du vill veta hur katten spenderar sin tid.

Du skulle kunna spionera på kattens varje ögonblick, men det skulle vara mycket arbete.Så istället bestämmer du dig för att ta stickprov: var femte minut sticker du in huvudet i det rum där katten befinner sig och skriver ner vad den gör.

Till exempel:

  • 12:00: Sover 💤
  • 12:05: Sover 💤
  • 12:10: Äter 🍲
  • 12:15: Använder kattlådan 💩
  • 12:20: Sover 💤
  • 12:25: Sover 💤
  • 12:30: Sover 💤

Ett par dagar senare kan du sammanfatta dina observationer:

  • 80%: Sovande 💤
  • 10%: Äter 🍲
  • 9%: Användning av kattlådan 💩
  • 1%: Om ditt mål är att mäta var katten tillbringade den mesta av sin tid är det förmodligen korrekt, och ju mer frekventa observationer (==provtagningar) och ju fler observationer du gör, desto mer korrekt blir sammanfattningen.

    Om din katt tillbringar den mesta av sin tid med att sova, skulle du förvänta dig att de flesta observationer i urvalet skulle visa att den sover.Och ja, du kommer att missa några snabba och sällsynta aktiviteter – men för syftet ”vad tillbringade katten den mesta av sin tid med” är dessa snabba och sällsynta aktiviteter irrelevanta.

    Hur det fungerar (software edition)

    I likhet med vår katt samplar Pyinstrument ett Pythonprograms beteende i intervaller: varje 1ms kontrollerar den vilken funktion som körs för tillfället. det betyder:

    • Om en funktion är kumulativt långsam, kommer den att dyka upp ofta.
    • Om en funktion är kumulativt snabb kommer vi vanligtvis inte att se den alls.

    Det betyder att vår sammanfattning av prestanda har mindre brus: funktioner som knappt används kommer för det mesta att hoppa över.Men på det hela taget är sammanfattningen ganska korrekt när det gäller hur programmet spenderade sin tid, så länge vi tog tillräckligt många prover.

    Eliot: Det löser de två andra problemen som vi såg med cProfile:

    • Loggning kan användas i produktion.
    • Loggning kan registrera argument till funktioner.

    Som du kommer att se erbjuder Eliot några unika möjligheter som gör det bättre på att registrera prestanda än vanliga loggningsbibliotek. med detta sagt kan du med lite extra arbete få samma fördelar från andra loggningsbibliotek också.

    Lägga till loggning till befintlig kod

    Tänk på följande skiss av ett program:

    Vi kan ta den här koden och lägga till lite loggning till den:

    Specifikt gör vi två saker:

    1. Säg till Eliot var loggningsmeddelandena ska skrivas ut (i det här fallet en fil som heter ”out.log”).
    2. Vi dekorerar funktionerna med en @log_call dekorator.Detta kommer att logga det faktum att funktionen anropades, dess argument och returvärdet (eller det uppkomna undantaget).

    Eliot har andra, mer finkorniga API:er, men @log_call räcker för att demonstrera fördelarna med loggning.

    Eliots utdata

    När vi kört programmet kan vi titta på loggarna med hjälp av ett verktyg som heter eliot-tree:

    Bemärk att vi, lite som i Pyinstrument, tittar på ett träd av åtgärder.Jag har förenklat resultatet lite – ursprungligen för att det skulle få plats på en bild som jag använde i föredragsversionen av den här artikeln – men även i en prosaartikel kan vi fokusera på prestandaaspekten.

    I Eliot har varje åtgärd en start och ett slut, och kan starta andra åtgärder – därav det resulterande trädet.Eftersom vi vet när varje loggad handling börjar och slutar vet vi också hur lång tid den tog.

    I det här fallet mappar varje handling ett till ett med ett funktionsanrop.Och det finns några skillnader jämfört med Pyinstrumentets utdata:

    1. Istället för att kombinera flera funktionsanrop ser du varje enskilt anrop separat.
    2. Du kan se argumenten och returresultatet för varje anrop.
    3. Du kan se den förflutna väggklockstiden för varje åtgärd.

    Till exempel kan du se att multiplysum() tog 10 sekunder, men den stora majoriteten av tiden spenderades i multiply(), med inmatningarna 3 och 4.Så du vet omedelbart att du för prestandaoptimering vill fokusera på multiply(), och du har några startingångar (3 och 4) att leka med.

    Loggningens begränsningar

    Loggning räcker inte till på egen hand som källa till prestandainformation.

    För det första får du bara information från kod där du uttryckligen har lagt till loggningsanrop.En profiler kan köra på vilken godtycklig kod som helst utan förberedelser i förväg, men med loggning måste du göra en del arbete i förväg.

    Om du inte lagt till loggningskod får du ingen information.Eliot gör detta lite bättre, eftersom strukturen för träd av åtgärder ger dig en viss känsla för var tiden har spenderats, men det räcker fortfarande inte om loggningen är för sparsam.

    För det andra kan du inte lägga till loggning överallt eftersom det kommer att sakta ner ditt program. loggning är inte billigt – det är högre overhead än cProfile. så du måste lägga till det med omdöme, på viktiga punkter där det maximerar den information det ger utan att påverka prestandan.

    Välja rätt verktyg

    Så när ska du använda varje verktyg?

    Alltid lägga till loggning

    Varje icke-trivialt program behöver antagligen loggning, om inte annat så för att fånga upp buggar och fel.Och om du redan lägger till loggning kan du göra dig besväret att logga den information du behöver för att göra prestandadebuggning också.

    Eliot gör det lättare, eftersom loggning av åtgärder i sig ger dig förfluten tid, men du kan med lite extra arbete göra detta med vilket loggningsbibliotek som helst.

    Loggning kan hjälpa dig att upptäcka det specifika stället där ditt program är långsamt, och åtminstone några ingångar som orsakar långsamheten, men det är ofta otillräckligt.Så nästa steg är att använda en profiler, och i synnerhet en samplingsprofiler som Pyinstrument:

    • Den har låg overhead och, ännu viktigare, förvränger inte resultaten.
    • Den mäter väggtid, så den utgår inte från att CPU:n är flaskhalsen.
    • Den matar bara ut de långsammare funktionerna och utelämnar de irrelevanta snabba funktionerna.

    Använd cProfile om du behöver en anpassad kostnadsmätning

    Om du någonsin behöver skriva en anpassad profiler kan du med cProfile lägga in olika kostnadsfunktioner, vilket gör det till ett enkelt verktyg för att mäta mer ovanliga mätvärden.

    Du kan mäta:

    • Not-CPU, all den tid som spenderas på att vänta på händelser som inte är CPU.
    • Antalet frivilliga kontextbyten, det vill säga antalet systemanrop som tar lång tid.
    • Minnesallokeringar.
    • Mer allmänt, alla räknare som går upp.

    TL;DR

    Som en bra utgångspunkt för verktyg för prestandaoptimering föreslår jag att du:

    1. Loggar viktiga in- och utgångar och den förflutna tiden för viktiga åtgärder med hjälp av Eliot eller något annat loggningsbibliotek.
    2. Använd Pyinstrument – eller en annan provtagningsprofilering – som din standardprofilering.
    3. Använd cProfile när om du behöver en anpassad profilering.

Lämna ett svar

Din e-postadress kommer inte publiceras.