Lorsque vous étudiez la vitesse et l'optimisation, il est très facile d' obtenir des résultats complètement faux . En particulier, vous ne pouvez pas vraiment dire qu'une variante est plus rapide qu'une autre sans mentionner la version du compilateur et le mode d'optimisation de votre configuration d'analyse comparative. Même alors, les processeurs modernes sont si sophistiqués qu'ils comportent des prédicteurs de branche basés sur un réseau neuronal, sans parler de toutes sortes de caches, donc, même avec une configuration soignée, les résultats de l'analyse comparative seront flous.
Cela étant dit...
Le benchmarking est notre ami.
criterion
est un package qui fournit des outils d'analyse comparative avancés. J'ai rapidement rédigé une référence comme celle-ci:
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 ]
Comme vous le voyez, j'ai ajouté la variante qui correspond explicitement à deux éléments à la fois, mais sinon c'est le même code textuellement. J'exécute également les benchmarks à l'envers, afin d'être conscient du biais dû à la mise en cache. Alors, courons et voyons!
% 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)
On dirait que notre version "lente" n'est pas du tout lente! Et les subtilités de la correspondance de motifs n'ajoutent rien. (Une légère accélération se produit entre deux exécutions consécutives de match2
J'attribue les effets de la mise en cache.)
Il existe un moyen d'obtenir plus de données "scientifiques" : nous pouvons -ddump-simpl
et jeter un œil à la façon dont le compilateur voit notre code.
L'inspection des structures intermédiaires est notre amie.
"Core" est un langage interne de GHC. Chaque fichier source Haskell est simplifié dans Core avant d'être transformé en graphique fonctionnel final pour que le système d'exécution s'exécute. Si nous regardons cette étape intermédiaire, cela nous le dira myButLast
et butLast2
sont équivalents. Il faut regarder, car, au stade du changement de nom, tous nos jolis identifiants sont mutilés au hasard.
% 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)
Il semble que A1
et A4
sont les plus similaires. Une inspection approfondie montrera qu'en effet les structures de code dans A1
et A4
sont identiques. Cela A2
et le même A3
sont également raisonnables car les deux sont définis comme une composition de deux fonctions.
Si vous allez examiner la core
sortie en profondeur, il est logique de fournir également des indicateurs tels que -dsuppress-module-prefixes
et -dsuppress-uniques
. Ils le rendent tellement plus facile à lire.
Une courte liste de nos ennemis aussi.
Alors, qu'est-ce qui peut mal tourner avec l'analyse comparative et l'optimisation?
ghci
, étant conçu pour un jeu interactif et une itération rapide, compile la source Haskell avec une certaine saveur de code octet, plutôt que l'exécutable final, et évite les optimisations coûteuses en faveur d'un rechargement plus rapide.
- Le profilage semble être un bon outil pour examiner les performances de bits et de morceaux individuels d'un programme complexe, mais il peut si mal détruire les optimisations du compilateur que les résultats seront des ordres de grandeur hors de la base.
- Votre sauvegarde consiste à profiler chaque petit morceau de code comme un exécutable séparé, avec son propre exécuteur de référence.
- La collecte des ordures est réglable. Aujourd'hui encore, une nouvelle fonctionnalité majeure a été publiée. Les retards pour la collecte des ordures affecteront les performances d'une manière qui n'est pas simple à prévoir.
- Comme je l'ai mentionné, différentes versions du compilateur créeront un code différent avec des performances différentes, vous devez donc savoir quelle version l' utilisateur de votre code utilisera probablement pour le construire, et comparer avec cela, avant de faire des promesses.
Cela peut paraître triste. Mais ce n'est vraiment pas la chose qui devrait concerner un programmeur Haskell, la plupart du temps. Histoire vraie: J'ai un ami qui a récemment commencé à apprendre Haskell. Ils avaient écrit un programme d'intégration numérique, et c'était très lent. Nous nous sommes donc assis ensemble et avons écrit une description catégorielle de l'algorithme, avec des diagrammes et des trucs. Quand ils ont réécrit le code pour l'aligner sur la description abstraite, il est devenu magiquement, comme, guépard rapide et mince en mémoire aussi. Nous avons calculé π en un rien de temps. Morale de l'histoire? Structure abstraite parfaite, et votre code s'optimisera.
init
a été optimisé pour éviter de "déballer" la liste plusieurs fois.