Tools zur Analyse der Leistung eines Haskell-Programms

103

Beim Lösen einiger Project Euler-Probleme, um Haskell zu lernen (daher bin ich derzeit ein absoluter Anfänger), bin ich auf Problem 12 gestoßen . Ich habe diese (naive) Lösung geschrieben:

--Get Number of Divisors of n
numDivs :: Integer -> Integer
numDivs n = toInteger $ length [ x | x<-[2.. ((n `quot` 2)+1)], n `rem` x == 0] + 2

--Generate a List of Triangular Values
triaList :: [Integer]
triaList =  [foldr (+) 0 [1..n] | n <- [1..]]

--The same recursive
triaList2 = go 0 1
  where go cs n = (cs+n):go (cs+n) (n+1)

--Finds the first triangular Value with more than n Divisors
sol :: Integer -> Integer
sol n = head $ filter (\x -> numDivs(x)>n) triaList2

Diese Lösung für n=500 (sol 500)ist extrem langsam (läuft seit mehr als 2 Stunden), daher habe ich mich gefragt, wie ich herausfinden kann, warum diese Lösung so langsam ist. Gibt es Befehle, die mir sagen, wo die meiste Rechenzeit verbracht wird, damit ich weiß, welcher Teil meines Haskell-Programms langsam ist? So etwas wie ein einfacher Profiler.

Um es klar, ich bitte nicht für eine schnellere Lösung , sondern für eine Art und Weise , diese Lösung zu finden. Wie würden Sie anfangen, wenn Sie keine Haskell-Kenntnisse hätten?

Ich habe versucht, zwei triaListFunktionen zu schreiben , aber keine Möglichkeit gefunden, zu testen, welche schneller ist. Hier beginnen meine Probleme.

Vielen Dank

theomega
quelle

Antworten:

187

wie man herausfindet, warum diese Lösung so langsam ist. Gibt es Befehle, die mir sagen, wo die meiste Rechenzeit verbracht wird, damit ich weiß, welcher Teil meines Haskell-Programms langsam ist?

Genau! GHC bietet viele hervorragende Tools, darunter:

Ein Tutorial zur Verwendung von Zeit- und Raumprofilen ist Teil von Real World Haskell .

GC-Statistik

Stellen Sie zunächst sicher, dass Sie mit ghc -O2 kompilieren. Und Sie können sicherstellen, dass es sich um ein modernes GHC handelt (z. B. GHC 6.12.x).

Als erstes können wir überprüfen, ob die Speicherbereinigung nicht das Problem ist. Führen Sie Ihr Programm mit + RTS -s aus

$ time ./A +RTS -s
./A +RTS -s 
749700
   9,961,432,992 bytes allocated in the heap
       2,463,072 bytes copied during GC
          29,200 bytes maximum residency (1 sample(s))
         187,336 bytes maximum slop
               **2 MB** total memory in use (0 MB lost due to fragmentation)

  Generation 0: 19002 collections,     0 parallel,  0.11s,  0.15s elapsed
  Generation 1:     1 collections,     0 parallel,  0.00s,  0.00s elapsed

  INIT  time    0.00s  (  0.00s elapsed)
  MUT   time   13.15s  ( 13.32s elapsed)
  GC    time    0.11s  (  0.15s elapsed)
  RP    time    0.00s  (  0.00s elapsed)
  PROF  time    0.00s  (  0.00s elapsed)
  EXIT  time    0.00s  (  0.00s elapsed)
  Total time   13.26s  ( 13.47s elapsed)

  %GC time       **0.8%**  (1.1% elapsed)

  Alloc rate    757,764,753 bytes per MUT second

  Productivity  99.2% of total user, 97.6% of total elapsed

./A +RTS -s  13.26s user 0.05s system 98% cpu 13.479 total

Das gibt uns bereits viele Informationen: Sie haben nur einen 2-Millionen-Haufen, und GC nimmt 0,8% der Zeit in Anspruch. Sie müssen sich also keine Sorgen machen, dass die Zuordnung das Problem ist.

Zeitprofile

Das Erstellen eines Zeitprofils für Ihr Programm ist unkompliziert: Kompilieren Sie mit -prof -auto-all

 $ ghc -O2 --make A.hs -prof -auto-all
 [1 of 1] Compiling Main             ( A.hs, A.o )
 Linking A ...

Und für N = 200:

$ time ./A +RTS -p                   
749700
./A +RTS -p  13.23s user 0.06s system 98% cpu 13.547 total

Dadurch wird eine Datei erstellt, A.prof, die Folgendes enthält:

    Sun Jul 18 10:08 2010 Time and Allocation Profiling Report  (Final)

       A +RTS -p -RTS

    total time  =     13.18 secs   (659 ticks @ 20 ms)
    total alloc = 4,904,116,696 bytes  (excludes profiling overheads)

COST CENTRE          MODULE         %time %alloc

numDivs            Main         100.0  100.0

Zeigt an, dass Ihre gesamte Zeit in numDivs verbracht wird und dass dies auch die Quelle all Ihrer Zuweisungen ist.

Heap-Profile

Sie können diese Zuordnungen auch aufschlüsseln, indem Sie mit + RTS -p -hy ausführen. Dadurch wird A.hp erstellt, das Sie anzeigen können, indem Sie es in eine Postscript-Datei (hp2ps -c A.hp) konvertieren und Folgendes generieren:

Alt-Text

Das sagt uns, dass an Ihrer Speichernutzung nichts auszusetzen ist: Sie wird in konstantem Raum zugewiesen.

Ihr Problem ist also die algorithmische Komplexität von numDivs:

toInteger $ length [ x | x<-[2.. ((n `quot` 2)+1)], n `rem` x == 0] + 2

Beheben Sie das, was 100% Ihrer Laufzeit entspricht, und alles andere ist einfach.

Optimierungen

Dieser Ausdruck ist ein guter Kandidat für die Optimierung der Stream-Fusion , daher schreibe ich ihn neu, um Data.Vector wie folgt zu verwenden :

numDivs n = fromIntegral $
    2 + (U.length $
        U.filter (\x -> fromIntegral n `rem` x == 0) $
        (U.enumFromN 2 ((fromIntegral n `div` 2) + 1) :: U.Vector Int))

Welches sollte in einer einzigen Schleife ohne unnötige Heap-Zuordnungen verschmelzen. Das heißt, es hat eine bessere Komplexität (durch konstante Faktoren) als die Listenversion. Sie können das ghc-core-Tool (für fortgeschrittene Benutzer) verwenden, um den Zwischencode nach der Optimierung zu überprüfen.

Testen Sie dies, ghc -O2 - machen Sie Z.hs.

$ time ./Z     
749700
./Z  3.73s user 0.01s system 99% cpu 3.753 total

So wurde die Laufzeit für N = 150 um das 3,5-fache reduziert, ohne den Algorithmus selbst zu ändern.

Fazit

Ihr Problem ist numDivs. Es ist 100% Ihrer Laufzeit und hat eine schreckliche Komplexität. Denken Sie an numDivs und wie Sie beispielsweise für jedes N N [2 .. n div2 + 1] N-mal generieren . Versuchen Sie, sich das zu merken, da sich die Werte nicht ändern.

Um zu messen, welche Ihrer Funktionen schneller ist, sollten Sie ein Kriterium verwenden , das statistisch belastbare Informationen über Verbesserungen der Laufzeit im Submikrosekundenbereich liefert.


Nachträge

Da numDivs 100% Ihrer Laufzeit ausmacht, macht das Berühren anderer Teile des Programms keinen großen Unterschied. Aus pädagogischen Gründen können wir jedoch auch diejenigen neu schreiben, die Stream Fusion verwenden.

Wir können trialList auch neu schreiben und uns auf Fusion verlassen, um es in die Schleife zu verwandeln, die Sie in trialList2, einer "Präfix-Scan" -Funktion (auch bekannt als scanl), von Hand schreiben:

triaList = U.scanl (+) 0 (U.enumFrom 1 top)
    where
       top = 10^6

Ähnliches gilt für sol:

sol :: Int -> Int
sol n = U.head $ U.filter (\x -> numDivs x > n) triaList

Mit der gleichen Gesamtlaufzeit, aber etwas saubererem Code.

Don Stewart
quelle
Nur eine Anmerkung für andere Idioten wie mich: Das timeDienstprogramm, das Don in Time Profiles erwähnt hat, ist nur das Linux- timeProgramm. Es ist nicht in Windows verfügbar. Informationen zur Zeitprofilerstellung unter Windows (eigentlich überall) finden Sie in dieser Frage.
John Red
Für zukünftige Benutzer -auto-allwird zugunsten von veraltet -fprof-auto.
B. Mehta
60

Dons Antwort ist großartig, ohne ein Spoiler zu sein, indem sie eine direkte Lösung für das Problem gibt.
Hier möchte ich ein kleines Tool vorschlagen , das ich kürzlich geschrieben habe. Sie sparen Zeit, SCC-Anmerkungen von Hand zu schreiben, wenn Sie ein detaillierteres Profil als das Standardprofil wünschen ghc -prof -auto-all. Außerdem ist es bunt!

Hier ist ein Beispiel mit dem von Ihnen angegebenen Code (*): Grün ist in Ordnung, Rot ist langsam: Alt-Text

Die Liste der Teiler wird ständig erstellt. Dies schlägt einige Dinge vor, die Sie tun können:
1. Beschleunigen Sie die Filterung n rem x == 0, aber da es sich um eine integrierte Funktion handelt, ist sie wahrscheinlich bereits schnell.
2. Erstellen Sie eine kürzere Liste. Sie haben bereits etwas in diese Richtung getan, indem Sie nur bis zu überprüft haben n quot 2.
3. Werfen Sie die Listengenerierung vollständig weg und verwenden Sie etwas Mathematik, um eine schnellere Lösung zu erhalten. Dies ist der übliche Weg für Projekt-Euler-Probleme.

(*) Ich habe dies erreicht, indem ich Ihren Code in eine Datei namens aufgerufen eu13.hsund eine Hauptfunktion hinzugefügt habe main = print $ sol 90. Dann läuft visual-prof -px eu13.hs eu13und das Ergebnis ist in eu13.hs.html.

Daniel Velkov
quelle
3

Haskell-Hinweis: Ist triaList2natürlich schneller als triaListweil letzterer viele unnötige Berechnungen durchführt. Es wird quadratische Zeit brauchen, um n erste Elemente von zu berechnen triaList, aber linear für triaList2. Es gibt eine andere elegante (und effiziente) Möglichkeit, eine unendlich faule Liste von Dreieckszahlen zu definieren:

triaList = 1 : zipWith (+) triaList [2..]

Hinweis zum Thema Mathematik: Es ist nicht erforderlich, alle Teiler bis zu n / 2 zu überprüfen. Es reicht aus, bis zu sqrt (n) zu prüfen.

rkhayrov
quelle
2
Beachten Sie auch: scanl (+) 1 [2 ..]
Don Stewart
1

Sie können Ihr Programm mit Flags ausführen, um die Zeitprofilerstellung zu aktivieren. Etwas wie das:

./program +RTS -P -sprogram.stats -RTS

Das sollte das Programm ausführen und eine Datei namens program.stats erzeugen, die angibt, wie viel Zeit in jeder Funktion verbracht wurde. Weitere Informationen zur Profilerstellung mit GHC finden Sie im GHC- Benutzerhandbuch . Für das Benchmarking gibt es die Criterion-Bibliothek. Ich habe festgestellt, dass dieser Blog-Beitrag eine nützliche Einführung enthält.

user394827
quelle
1
Aber kompiliere es zuerst mitghc -prof -auto-all -fforce-recomp --make -O2 program.hs
Daniel Velkov