Strumenti per analizzare le prestazioni di un programma Haskell


104

Durante la risoluzione di alcuni problemi del progetto Eulero per imparare Haskell (quindi attualmente sono un principiante completo) sono arrivato al problema 12 . Ho scritto questa soluzione (ingenua):

--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

Questa soluzione per n=500 (sol 500)è estremamente lenta (funziona da più di 2 ore ora), quindi mi chiedevo come scoprire perché questa soluzione è così lenta. Ci sono comandi che mi dicono dove viene speso la maggior parte del tempo di calcolo in modo da sapere quale parte del mio programma haskell è lenta? Qualcosa come un semplice profiler.

Per chiarire, io non sto chiedendo per una soluzione più veloce, ma per un modo per trovare questa soluzione. Come inizieresti se non avessi alcuna conoscenza di haskell?

Ho provato a scrivere due triaListfunzioni ma non ho trovato alcun modo per testare quale fosse più veloce, quindi è qui che iniziano i miei problemi.

Grazie

Risposte:


187

come scoprire perché questa soluzione è così lenta. Ci sono comandi che mi dicono dove trascorre la maggior parte del tempo di calcolo in modo da sapere quale parte del mio programma haskell è lenta?

Precisamente! GHC fornisce molti strumenti eccellenti, tra cui:

Un tutorial sull'utilizzo del profilo temporale e spaziale fa parte di Real World Haskell .

Statistiche GC

In primo luogo, assicurati di compilare con ghc -O2. E potresti assicurarti che sia un GHC moderno (ad esempio GHC 6.12.x)

La prima cosa che possiamo fare è controllare che la raccolta dei rifiuti non sia il problema. Esegui il tuo programma con + RTS -s

$ 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

Il che ci fornisce già molte informazioni: hai solo un heap 2M e GC impiega lo 0,8% del tempo. Quindi non c'è bisogno di preoccuparsi che l'allocazione sia il problema.

Profili temporali

Ottenere un profilo temporale per il tuo programma è semplice: compila con -prof -auto-all

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

E, per N = 200:

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

che crea un file, A.prof, contenente:

    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

Indica che tutto il tuo tempo viene speso in numDivs, ed è anche la fonte di tutte le tue allocazioni.

Profili heap

Puoi anche ottenere un'analisi di queste allocazioni, eseguendo + RTS -p -hy, che crea A.hp, che puoi visualizzare convertendolo in un file postscript (hp2ps -c A.hp), generando:

testo alternativo

il che ci dice che non c'è niente di sbagliato nell'uso della memoria: sta allocando in uno spazio costante.

Quindi il tuo problema è la complessità algoritmica di numDivs:

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

Risolvilo, che è il 100% del tuo tempo di esecuzione, e tutto il resto è facile.

ottimizzazioni

Questa espressione è un buon candidato per l' ottimizzazione della fusione del flusso , quindi la riscriverò per utilizzare Data.Vector , in questo modo:

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))

Che dovrebbe fondersi in un unico ciclo senza allocazioni di heap non necessarie. Cioè, avrà una complessità migliore (per fattori costanti) rispetto alla versione elenco. È possibile utilizzare lo strumento ghc-core (per utenti avanzati) per ispezionare il codice intermedio dopo l'ottimizzazione.

Testando questo, ghc -O2 --make Z.hs

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

Quindi ha ridotto il tempo di esecuzione per N = 150 di 3,5 volte, senza modificare l'algoritmo stesso.

Conclusione

Il tuo problema è numDivs. È il 100% del tuo tempo di esecuzione e ha una complessità terribile. Pensa a numDivs e come, ad esempio, per ogni N stai generando [2 .. n div2 + 1] N volte. Prova a memorizzarlo, poiché i valori non cambiano.

Per misurare quale delle tue funzioni è più veloce, prendi in considerazione l'utilizzo di un criterio , che fornirà informazioni statisticamente affidabili sui miglioramenti inferiori al microsecondo nel tempo di esecuzione.


addenda

Poiché numDivs rappresenta il 100% del tuo tempo di esecuzione, toccare altre parti del programma non farà molta differenza, tuttavia, per scopi pedagogici, possiamo anche riscrivere quelli che utilizzano stream fusion.

Possiamo anche riscrivere trialList e fare affidamento su fusion per trasformarlo nel ciclo che scrivi a mano in trialList2, che è una funzione di "scansione dei prefissi" (aka scanl):

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

Allo stesso modo per sol:

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

Con lo stesso tempo di esecuzione complessivo, ma un codice un po 'più pulito.


Solo una nota per altri idioti come me: l' timeutilità menzionata da Don in Time Profiles è solo il timeprogramma Linux . Non è disponibile in Windows. Quindi, per la creazione di profili temporali su Windows (ovunque effettivamente), vedere questa domanda.
John Red

1
Per gli utenti futuri, -auto-allè deprecato a favore di -fprof-auto.
B. Mehta

60

La risposta di Dons è ottima senza essere uno spoiler, dando una soluzione diretta al problema.
Qui voglio suggerire un piccolo strumento che ho scritto di recente. Ti fa risparmiare il tempo di scrivere annotazioni SCC a mano quando desideri un profilo più dettagliato di quello predefinito ghc -prof -auto-all. Oltre a questo è colorato!

Ecco un esempio con il codice che hai fornito (*), il verde è OK, il rosso è lento: testo alternativo

Tutto il tempo è necessario per creare l'elenco dei divisori. Questo suggerisce alcune cose che puoi fare:
1. Rendi il filtraggio n rem x == 0più veloce, ma poiché è una funzione incorporata probabilmente è già veloce.
2. Creare un elenco più breve. Hai già fatto qualcosa in quella direzione controllando solo fino a n quot 2.
3. Gettare via completamente la generazione dell'elenco e utilizzare un po 'di matematica per ottenere una soluzione più rapida. Questo è il solito modo per i problemi del progetto Eulero.

(*) Ho ottenuto questo inserendo il tuo codice in un file chiamato eu13.hs, aggiungendo una funzione principale main = print $ sol 90. Quindi in esecuzione visual-prof -px eu13.hs eu13e il risultato è in eu13.hs.html.


3

Nota correlata a Haskell: triaList2è ovviamente più veloce rispetto al triaListfatto che quest'ultimo esegue molti calcoli non necessari. Ci vorrà del tempo quadratico per calcolare n primi elementi di triaList, ma lineare per triaList2. C'è un altro modo elegante (ed efficiente) per definire un elenco pigro infinito di numeri triangolari:

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

Nota relativa alla matematica: non è necessario controllare tutti i divisori fino a n / 2, è sufficiente controllare fino a sqrt (n).


2
Considera anche: scanl (+) 1 [2 ..]
Don Stewart

1

È possibile eseguire il programma con flag per abilitare la profilazione temporale. Qualcosa come questo:

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

Questo dovrebbe eseguire il programma e produrre un file chiamato program.stats che avrà quanto tempo è stato speso in ciascuna funzione. Puoi trovare ulteriori informazioni sulla creazione di profili con GHC nella guida per l'utente di GHC . Per il benchmarking, c'è la libreria Criterion. Ho scoperto che questo post del blog contiene un'utile introduzione.


1
Ma prima compilarlo conghc -prof -auto-all -fforce-recomp --make -O2 program.hs
Daniel
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.