Paresse
Ce n'est pas une "optimisation du compilateur", mais c'est quelque chose de garanti par la spécification du langage, donc vous pouvez toujours compter sur ce qu'il se produise. Essentiellement, cela signifie que le travail n'est pas effectué tant que vous n'avez pas "fait quelque chose" avec le résultat. (Sauf si vous faites une ou plusieurs choses pour désactiver délibérément la paresse.)
Ceci, évidemment, est tout un sujet à part entière, et SO a déjà beaucoup de questions et de réponses à ce sujet.
D'après mon expérience limitée, rendre votre code trop paresseux ou trop strict entraîne des pénalités de performances beaucoup plus importantes (dans le temps et dans l' espace) que toutes les autres choses dont je vais parler ...
Analyse de rigueur
La paresse consiste à éviter le travail sauf si c'est nécessaire. Si le compilateur peut déterminer qu'un résultat donné sera "toujours" nécessaire, alors il ne prendra pas la peine de stocker le calcul et de l'exécuter plus tard; il l'exécutera directement, car c'est plus efficace. C'est ce qu'on appelle une «analyse de rigueur».
Le problème, évidemment, est que le compilateur ne peut pas toujours détecter quand quelque chose pourrait être rendu strict. Parfois, vous devez donner de petits conseils au compilateur. (Je ne connais aucun moyen simple de déterminer si l'analyse de rigueur a fait ce que vous pensez avoir, autre que de parcourir la sortie Core.)
Inlining
Si vous appelez une fonction, et que le compilateur peut dire quelle fonction vous appelez, il peut essayer de "incorporer" cette fonction - c'est-à-dire de remplacer l'appel de fonction par une copie de la fonction elle-même. La surcharge d'un appel de fonction est généralement assez faible, mais l'inlining permet souvent à d'autres optimisations de se produire qui ne se seraient pas produites autrement, donc l'inlining peut être une grande victoire.
Les fonctions ne sont insérées que si elles sont "assez petites" (ou si vous ajoutez un pragma demandant spécifiquement l'inlining). De plus, les fonctions ne peuvent être insérées que si le compilateur peut dire quelle fonction vous appelez. Il y a deux façons principales que le compilateur pourrait être incapable de dire:
Si la fonction que vous appelez est transmise ailleurs. Par exemple, lorsque la filter
fonction est compilée, vous ne pouvez pas insérer le prédicat de filtre, car il s'agit d'un argument fourni par l'utilisateur.
Si la fonction que vous appelez est une méthode de classe et que le compilateur ne sait pas quel type est impliqué. Par exemple, lorsque la sum
fonction est compilée, le compilateur ne peut pas intégrer la +
fonction, car il sum
fonctionne avec plusieurs types de nombres différents, chacun ayant une +
fonction différente .
Dans ce dernier cas, vous pouvez utiliser le {-# SPECIALIZE #-}
pragma pour générer des versions d'une fonction qui sont codées en dur dans un type particulier. Par exemple, {-# SPECIALIZE sum :: [Int] -> Int #-}
compilerait une version sum
codée en dur pour le Int
type, ce qui signifie que+
peut être incorporé dans cette version.
Notez, cependant, que notre nouvelle sum
fonction spéciale ne sera appelée que lorsque le compilateur pourra dire que nous travaillons avec Int
. Sinon, l'original polymorphe sum
est appelé. Encore une fois, la surcharge réelle des appels de fonction est assez faible. Ce sont les optimisations supplémentaires que l'inlining peut permettre qui sont bénéfiques.
Élimination des sous-expressions courantes
Si un certain bloc de code calcule deux fois la même valeur, le compilateur peut la remplacer par une seule instance du même calcul. Par exemple, si vous faites
(sum xs + 1) / (sum xs + 2)
alors le compilateur pourrait optimiser cela pour
let s = sum xs in (s+1)/(s+2)
Vous pouvez vous attendre à ce que le compilateur fasse toujours cela. Cependant, apparemment dans certaines situations, cela peut entraîner des performances moins bonnes, pas meilleures, donc GHC ne le fait pas toujours . Franchement, je ne comprends pas vraiment les détails derrière celui-ci. Mais l'essentiel est que si cette transformation est importante pour vous, il n'est pas difficile de la faire manuellement. (Et si ce n'est pas important, pourquoi vous en souciez-vous?)
Expressions de cas
Considérer ce qui suit:
foo (0:_ ) = "zero"
foo (1:_ ) = "one"
foo (_:xs) = foo xs
foo ( []) = "end"
Les trois premières équations vérifient toutes si la liste n'est pas vide (entre autres). Mais vérifier la même chose trois fois est un gaspillage. Heureusement, il est très facile pour le compilateur d'optimiser cela en plusieurs expressions de cas imbriquées. Dans ce cas, quelque chose comme
foo xs =
case xs of
y:ys ->
case y of
0 -> "zero"
1 -> "one"
_ -> foo ys
[] -> "end"
C'est plutôt moins intuitif, mais plus efficace. Étant donné que le compilateur peut facilement effectuer cette transformation, vous n'avez pas à vous en soucier. Écrivez simplement votre correspondance de motif de la manière la plus intuitive possible; le compilateur est très bon pour réorganiser et réorganiser cela pour le rendre aussi rapide que possible.
La fusion
L'idiome standard de Haskell pour le traitement de liste est d'enchaîner les fonctions qui prennent une liste et produisent une nouvelle liste. L'exemple canonique étant
map g . map f
Malheureusement, alors que la paresse garantit de sauter le travail inutile, toutes les allocations et désallocations pour la liste intermédiaire sapent les performances. «Fusion» ou «déforestation» est l'endroit où le compilateur essaie d'éliminer ces étapes intermédiaires.
Le problème est que la plupart de ces fonctions sont récursives. Sans la récursion, ce serait un exercice élémentaire d'inlining pour écraser toutes les fonctions dans un gros bloc de code, exécuter le simplificateur dessus et produire un code vraiment optimal sans listes intermédiaires. Mais à cause de la récursivité, cela ne fonctionnera pas.
Vous pouvez utiliser des {-# RULE #-}
pragmas pour résoudre certains de ces problèmes. Par exemple,
{-# RULES "map/map" forall f g xs. map f (map g xs) = map (f.g) xs #-}
Désormais, chaque fois que GHC voit map
appliquémap
, il l'écrase en un seul passage sur la liste, éliminant la liste intermédiaire.
Le problème est que cela ne fonctionne que pour map
suivi de map
. Il existe de nombreuses autres possibilités - map
suivies de filter
, filter
suivies demap
, etc. Plutôt que de coder manuellement une solution pour chacune d'elles, la soi-disant «fusion de flux» a été inventée. C'est une astuce plus compliquée, que je ne décrirai pas ici.
En résumé, ce sont toutes des astuces d'optimisation spéciales écrites par le programmeur . GHC lui-même ne sait rien de la fusion; tout est dans les bibliothèques de listes et autres bibliothèques de conteneurs. Ainsi, les optimisations qui se produisent dépendent de la manière dont vos bibliothèques de conteneurs sont écrites (ou, plus réaliste, des bibliothèques que vous choisissez d'utiliser).
Par exemple, si vous travaillez avec des tableaux Haskell '98, ne vous attendez à aucune fusion d'aucune sorte. Mais je comprends que la vector
bibliothèque dispose de capacités de fusion étendues. Tout tourne autour des bibliothèques; le compilateur fournit juste le RULES
pragma. (Ce qui est extrêmement puissant, au fait. En tant qu'auteur de bibliothèque, vous pouvez l'utiliser pour réécrire le code client!)
Méta:
Je suis d'accord avec les gens qui disent "coder d'abord, profil deuxième, optimiser troisième".
Je suis également d'accord avec les gens qui disent "il est utile d'avoir un modèle mental pour le coût d'une décision de conception donnée".
Équilibre en toutes choses, et tout ça ...