Quando si studia la velocità e l'ottimizzazione, è molto facile ottenere risultati incredibilmente sbagliati . In particolare, non puoi davvero dire che una variante è più veloce di un'altra senza menzionare la versione del compilatore e la modalità di ottimizzazione della tua configurazione di benchmarking. Anche allora, i processori moderni sono così sofisticati da presentare predittori di filiali basati su reti neurali, per non parlare di tutti i tipi di cache, quindi, anche con un'attenta configurazione, i risultati del benchmarking saranno sfocati.
Detto ciò...
Il benchmarking è nostro amico.
criterion
è un pacchetto che fornisce strumenti di benchmarking avanzati. Ho rapidamente redatto un benchmark come questo:
module Main where
import Criterion
import Criterion.Main
-- slow
myButLast :: [a] -> a
myButLast [x, y] = x
myButLast (x : xs) = myButLast xs
myButLast _ = error "List too short"
-- decent
myButLast' :: [a] -> a
myButLast' = (!! 1) . reverse
-- fast
myButLast'' :: [a] -> a
myButLast'' = last . init
butLast2 :: [a] -> a
butLast2 (x : _ : [ ] ) = x
butLast2 (_ : xs@(_ : _ ) ) = butLast2 xs
butLast2 _ = error "List too short"
setupEnv = do
let xs = [1 .. 10^7] :: [Int]
return xs
benches xs =
[ bench "slow?" $ nf myButLast xs
, bench "decent?" $ nf myButLast' xs
, bench "fast?" $ nf myButLast'' xs
, bench "match2" $ nf butLast2 xs
]
main = defaultMain
[ env setupEnv $ \ xs -> bgroup "main" $ let bs = benches xs in bs ++ reverse bs ]
Come vedi, ho aggiunto la variante che corrisponde esplicitamente a due elementi contemporaneamente, ma per il resto è lo stesso codice alla lettera. Gestisco anche i parametri di riferimento al contrario, in modo da essere consapevole del pregiudizio dovuto alla memorizzazione nella cache. Quindi, corriamo e vediamo!
% ghc --version
The Glorious Glasgow Haskell Compilation System, version 8.6.5
% ghc -O2 -package criterion A.hs && ./A
benchmarking main/slow?
time 54.83 ms (54.75 ms .. 54.90 ms)
1.000 R² (1.000 R² .. 1.000 R²)
mean 54.86 ms (54.82 ms .. 54.93 ms)
std dev 94.77 μs (54.95 μs .. 146.6 μs)
benchmarking main/decent?
time 794.3 ms (32.56 ms .. 1.293 s)
0.907 R² (0.689 R² .. 1.000 R²)
mean 617.2 ms (422.7 ms .. 744.8 ms)
std dev 201.3 ms (105.5 ms .. 283.3 ms)
variance introduced by outliers: 73% (severely inflated)
benchmarking main/fast?
time 84.60 ms (84.37 ms .. 84.95 ms)
1.000 R² (1.000 R² .. 1.000 R²)
mean 84.46 ms (84.25 ms .. 84.77 ms)
std dev 435.1 μs (239.0 μs .. 681.4 μs)
benchmarking main/match2
time 54.87 ms (54.81 ms .. 54.95 ms)
1.000 R² (1.000 R² .. 1.000 R²)
mean 54.85 ms (54.81 ms .. 54.92 ms)
std dev 104.9 μs (57.03 μs .. 178.7 μs)
benchmarking main/match2
time 50.60 ms (47.17 ms .. 53.01 ms)
0.993 R² (0.981 R² .. 0.999 R²)
mean 60.74 ms (56.57 ms .. 67.03 ms)
std dev 9.362 ms (6.074 ms .. 10.95 ms)
variance introduced by outliers: 56% (severely inflated)
benchmarking main/fast?
time 69.38 ms (56.64 ms .. 78.73 ms)
0.948 R² (0.835 R² .. 0.994 R²)
mean 108.2 ms (92.40 ms .. 129.5 ms)
std dev 30.75 ms (19.08 ms .. 37.64 ms)
variance introduced by outliers: 76% (severely inflated)
benchmarking main/decent?
time 770.8 ms (345.9 ms .. 1.004 s)
0.967 R² (0.894 R² .. 1.000 R²)
mean 593.4 ms (422.8 ms .. 691.4 ms)
std dev 167.0 ms (50.32 ms .. 226.1 ms)
variance introduced by outliers: 72% (severely inflated)
benchmarking main/slow?
time 54.87 ms (54.77 ms .. 55.00 ms)
1.000 R² (1.000 R² .. 1.000 R²)
mean 54.95 ms (54.88 ms .. 55.10 ms)
std dev 185.3 μs (54.54 μs .. 251.8 μs)
Sembra che la nostra versione "lenta" non sia affatto lenta! E la complessità del pattern matching non aggiunge nulla. (Una leggera accelerazione vediamo tra due sequenze consecutive di match2
attribuire agli effetti della memorizzazione nella cache.)
C'è un modo per ottenere più dati "scientifici" : possiamo -ddump-simpl
e dare un'occhiata al modo in cui il compilatore vede il nostro codice.
Il controllo delle strutture intermedie è nostro amico.
"Core" è un linguaggio interno di GHC. Ogni file sorgente Haskell viene semplificato in Core prima di essere trasformato nel grafico funzionale finale per l'esecuzione del sistema runtime. Se guardiamo a questo stadio intermedio, che ci dirà che myButLast
e butLast2
sono equivalenti. Ci vuole guardare, dal momento che, nella fase di ridenominazione, tutti i nostri simpatici identificatori sono casualmente distrutti.
% for i in `seq 1 4`; do echo; cat A$i.hs; ghc -O2 -ddump-simpl A$i.hs > A$i.simpl; done
module A1 where
-- slow
myButLast :: [a] -> a
myButLast [x, y] = x
myButLast (x : xs) = myButLast xs
myButLast _ = error "List too short"
module A2 where
-- decent
myButLast' :: [a] -> a
myButLast' = (!! 1) . reverse
module A3 where
-- fast
myButLast'' :: [a] -> a
myButLast'' = last . init
module A4 where
butLast2 :: [a] -> a
butLast2 (x : _ : [ ] ) = x
butLast2 (_ : xs@(_ : _ ) ) = butLast2 xs
butLast2 _ = error "List too short"
% ./EditDistance.hs *.simpl
(("A1.simpl","A2.simpl"),3866)
(("A1.simpl","A3.simpl"),3794)
(("A2.simpl","A3.simpl"),663)
(("A1.simpl","A4.simpl"),607)
(("A2.simpl","A4.simpl"),4188)
(("A3.simpl","A4.simpl"),4113)
Sembra che A1
e A4
siano i più simili. Un'attenta ispezione mostrerà che le strutture del codice sono identiche A1
e A4
identiche. Anche quello A2
e A3
sono simili è ragionevole poiché entrambi sono definiti come una composizione di due funzioni.
Se hai intenzione di esaminare core
ampiamente l' output, ha senso fornire anche flag come -dsuppress-module-prefixes
e -dsuppress-uniques
. Lo rendono molto più facile da leggere.
Un breve elenco anche dei nostri nemici.
Cosa può andare storto nel benchmarking e nell'ottimizzazione?
ghci
, essendo progettato per il gioco interattivo e una rapida iterazione, compila il sorgente di Haskell con un certo tipo di codice byte, piuttosto che eseguibile finale, e evita costose ottimizzazioni a favore di un caricamento più rapido.
- La profilazione sembra uno strumento utile per esaminare le prestazioni dei singoli bit di un programma complesso, ma può rovinare così tanto le ottimizzazioni del compilatore, i risultati saranno ordini di grandezza fuori base.
- La tua protezione è quella di profilare ogni piccolo bit di codice come un eseguibile separato, con il suo runner di benchmark.
- La raccolta dei rifiuti è sintonizzabile. Proprio oggi è stata rilasciata una nuova importante funzionalità. I ritardi per la garbage collection influiranno sulle prestazioni in modi che non sono semplici da prevedere.
- Come ho già detto, diverse versioni del compilatore costruiranno codice diverso con prestazioni diverse, quindi devi sapere quale versione l' utente del tuo codice probabilmente utilizzerà per costruirlo e fare un benchmark con quello, prima di fare qualsiasi promessa.
Questo può sembrare triste. Ma non è proprio la cosa che dovrebbe riguardare un programmatore Haskell, il più delle volte. Storia vera: ho un amico che di recente ha iniziato a studiare Haskell. Avevano scritto un programma per l'integrazione numerica, e la tartaruga era lenta. Quindi ci siamo seduti insieme e abbiamo scritto una descrizione categorica dell'algoritmo, con diagrammi e cose. Quando hanno riscritto il codice per allinearlo con la descrizione astratta, è diventato magicamente, come, un ghepardo veloce e anche poco memoria. Abbiamo calcolato π in pochissimo tempo. Morale della storia? Perfetta struttura astratta e il tuo codice si ottimizzerà da solo.
init
è stato ottimizzato per evitare di "decomprimere" l'elenco più volte.