web-dev-qa-db-fra.com

Quelles optimisations peut-on espérer que GHC fonctionne de manière fiable?

GHC a beaucoup d'optimisations qu'il peut effectuer, mais je ne sais pas ce qu'elles sont toutes, ni quelle est la probabilité qu'elles soient exécutées et dans quelles circonstances.

Ma question est: quelles transformations puis-je m'attendre à ce qu'il s'applique à chaque fois, ou presque? Si je regarde un morceau de code qui va être exécuté (évalué) fréquemment et que ma première pensée est "hmm, je devrais peut-être l'optimiser", auquel cas ma deuxième pensée devrait être, "n'y pense même pas, GHC a obtenu ceci "?

Je lisais l'article Stream Fusion: From Lists to Streams to Nothing at All , et la technique qu'ils ont utilisée pour réécrire le traitement des listes sous une forme différente que les optimisations normales de GHC optimiseraient ensuite de manière fiable en boucles simples était roman pour moi. Comment savoir quand mes propres programmes sont éligibles pour ce type d'optimisation?

Il y a quelques informations dans le manuel GHC, mais cela ne fait qu'une partie du chemin vers la réponse à la question.

EDIT: Je commence une prime. Ce que je voudrais, c'est une liste de transformations de niveau inférieur comme lambda/let/flottant sur la casse, la spécialisation des arguments de type/constructeur/fonction, l'analyse de rigueur et le déballage, travailleur/wrapper, et tout ce que GHC significatif fait que j'ai laissé de côté , ainsi que des explications et des exemples de code d'entrée et de sortie, et idéalement des illustrations de situations où l'effet total est supérieur à la somme de ses parties. Et idéalement, une mention du moment où les transformations ne se produiront pas . Je ne m'attends pas à des explications nouvelles sur chaque transformation, quelques phrases et des exemples de code à une ligne pourraient suffire (ou un lien, si ce n'est pas vers vingt pages de papier scientifique), tant que la vue d'ensemble est clair à la fin de celui-ci. Je veux pouvoir regarder un morceau de code et être en mesure de deviner s'il va se compiler en une boucle serrée, ou pourquoi pas, ou ce que je devrais changer pour le faire. (Je ne suis pas tellement intéressé ici par les grands cadres d'optimisation comme la fusion de flux (je viens de lire un article à ce sujet); plus par le type de connaissances que les gens qui écrivent ces cadres ont.)

177
glaebhoerl

Cette page GHC Trac explique aussi assez bien les passes. Cette page explique la commande d'optimisation, bien que, comme la majorité du Trac Wiki, elle soit obsolète.

Pour des détails, la meilleure chose à faire est probablement de regarder comment un programme spécifique est compilé. La meilleure façon de voir quelles optimisations sont effectuées est de compiler le programme verbalement, en utilisant le -v drapeau. Prenant comme exemple le premier morceau de Haskell que j'ai pu trouver sur mon ordinateur:

Glasgow Haskell Compiler, Version 7.4.2, stage 2 booted by GHC version 7.4.1
Using binary package database: /usr/lib/ghc-7.4.2/package.conf.d/package.cache
wired-in package ghc-prim mapped to ghc-prim-0.2.0.0-7d3c2c69a5e8257a04b2c679c40e2fa7
wired-in package integer-gmp mapped to integer-gmp-0.4.0.0-af3a28fdc4138858e0c7c5ecc2a64f43
wired-in package base mapped to base-4.5.1.0-6e4c9bdc36eeb9121f27ccbbcb62e3f3
wired-in package rts mapped to builtin_rts
wired-in package template-haskell mapped to template-haskell-2.7.0.0-2bd128e15c2d50997ec26a1eaf8b23bf
wired-in package dph-seq not found.
wired-in package dph-par not found.
Hsc static flags: -static
*** Chasing dependencies:
Chasing modules from: *SleepSort.hs
Stable obj: [Main]
Stable BCO: []
Ready for upsweep
  [NONREC
      ModSummary {
         ms_hs_date = Tue Oct 18 22:22:11 CDT 2011
         ms_mod = main:Main,
         ms_textual_imps = [import (implicit) Prelude, import Control.Monad,
                            import Control.Concurrent, import System.Environment]
         ms_srcimps = []
      }]
*** Deleting temp files:
Deleting: 
compile: input file SleepSort.hs
Created temporary directory: /tmp/ghc4784_0
*** Checking old interface for main:Main:
[1 of 1] Compiling Main             ( SleepSort.hs, SleepSort.o )
*** Parser:
*** Renamer/typechecker:
*** Desugar:
Result size of Desugar (after optimization) = 79
*** Simplifier:
Result size of Simplifier iteration=1 = 87
Result size of Simplifier iteration=2 = 93
Result size of Simplifier iteration=3 = 83
Result size of Simplifier = 83
*** Specialise:
Result size of Specialise = 83
*** Float out(FOS {Lam = Just 0, Consts = True, PAPs = False}):
Result size of Float out(FOS {Lam = Just 0,
                              Consts = True,
                              PAPs = False}) = 95
*** Float inwards:
Result size of Float inwards = 95
*** Simplifier:
Result size of Simplifier iteration=1 = 253
Result size of Simplifier iteration=2 = 229
Result size of Simplifier = 229
*** Simplifier:
Result size of Simplifier iteration=1 = 218
Result size of Simplifier = 218
*** Simplifier:
Result size of Simplifier iteration=1 = 283
Result size of Simplifier iteration=2 = 226
Result size of Simplifier iteration=3 = 202
Result size of Simplifier = 202
*** Demand analysis:
Result size of Demand analysis = 202
*** Worker Wrapper binds:
Result size of Worker Wrapper binds = 202
*** Simplifier:
Result size of Simplifier = 202
*** Float out(FOS {Lam = Just 0, Consts = True, PAPs = True}):
Result size of Float out(FOS {Lam = Just 0,
                              Consts = True,
                              PAPs = True}) = 210
*** Common sub-expression:
Result size of Common sub-expression = 210
*** Float inwards:
Result size of Float inwards = 210
*** Liberate case:
Result size of Liberate case = 210
*** Simplifier:
Result size of Simplifier iteration=1 = 206
Result size of Simplifier = 206
*** SpecConstr:
Result size of SpecConstr = 206
*** Simplifier:
Result size of Simplifier = 206
*** Tidy Core:
Result size of Tidy Core = 206
writeBinIface: 4 Names
writeBinIface: 28 dict entries
*** CorePrep:
Result size of CorePrep = 224
*** Stg2Stg:
*** CodeGen:
*** CodeOutput:
*** Assembler:
'/usr/bin/gcc' '-fno-stack-protector' '-Wl,--hash-size=31' '-Wl,--reduce-memory-overheads' '-I.' '-c' '/tmp/ghc4784_0/ghc4784_0.s' '-o' 'SleepSort.o'
Upsweep completely successful.
*** Deleting temp files:
Deleting: /tmp/ghc4784_0/ghc4784_0.c /tmp/ghc4784_0/ghc4784_0.s
Warning: deleting non-existent /tmp/ghc4784_0/ghc4784_0.c
link: linkables are ...
LinkableM (Sat Sep 29 20:21:02 CDT 2012) main:Main
   [DotO SleepSort.o]
Linking SleepSort ...
*** C Compiler:
'/usr/bin/gcc' '-fno-stack-protector' '-Wl,--hash-size=31' '-Wl,--reduce-memory-overheads' '-c' '/tmp/ghc4784_0/ghc4784_0.c' '-o' '/tmp/ghc4784_0/ghc4784_0.o' '-DTABLES_NEXT_TO_CODE' '-I/usr/lib/ghc-7.4.2/include'
*** C Compiler:
'/usr/bin/gcc' '-fno-stack-protector' '-Wl,--hash-size=31' '-Wl,--reduce-memory-overheads' '-c' '/tmp/ghc4784_0/ghc4784_0.s' '-o' '/tmp/ghc4784_0/ghc4784_1.o' '-DTABLES_NEXT_TO_CODE' '-I/usr/lib/ghc-7.4.2/include'
*** Linker:
'/usr/bin/gcc' '-fno-stack-protector' '-Wl,--hash-size=31' '-Wl,--reduce-memory-overheads' '-o' 'SleepSort' 'SleepSort.o' '-L/usr/lib/ghc-7.4.2/base-4.5.1.0' '-L/usr/lib/ghc-7.4.2/integer-gmp-0.4.0.0' '-L/usr/lib/ghc-7.4.2/ghc-prim-0.2.0.0' '-L/usr/lib/ghc-7.4.2' '/tmp/ghc4784_0/ghc4784_0.o' '/tmp/ghc4784_0/ghc4784_1.o' '-lHSbase-4.5.1.0' '-lHSinteger-gmp-0.4.0.0' '-lgmp' '-lHSghc-prim-0.2.0.0' '-lHSrts' '-lm' '-lrt' '-ldl' '-u' 'ghczmprim_GHCziTypes_Izh_static_info' '-u' 'ghczmprim_GHCziTypes_Czh_static_info' '-u' 'ghczmprim_GHCziTypes_Fzh_static_info' '-u' 'ghczmprim_GHCziTypes_Dzh_static_info' '-u' 'base_GHCziPtr_Ptr_static_info' '-u' 'base_GHCziWord_Wzh_static_info' '-u' 'base_GHCziInt_I8zh_static_info' '-u' 'base_GHCziInt_I16zh_static_info' '-u' 'base_GHCziInt_I32zh_static_info' '-u' 'base_GHCziInt_I64zh_static_info' '-u' 'base_GHCziWord_W8zh_static_info' '-u' 'base_GHCziWord_W16zh_static_info' '-u' 'base_GHCziWord_W32zh_static_info' '-u' 'base_GHCziWord_W64zh_static_info' '-u' 'base_GHCziStable_StablePtr_static_info' '-u' 'ghczmprim_GHCziTypes_Izh_con_info' '-u' 'ghczmprim_GHCziTypes_Czh_con_info' '-u' 'ghczmprim_GHCziTypes_Fzh_con_info' '-u' 'ghczmprim_GHCziTypes_Dzh_con_info' '-u' 'base_GHCziPtr_Ptr_con_info' '-u' 'base_GHCziPtr_FunPtr_con_info' '-u' 'base_GHCziStable_StablePtr_con_info' '-u' 'ghczmprim_GHCziTypes_False_closure' '-u' 'ghczmprim_GHCziTypes_True_closure' '-u' 'base_GHCziPack_unpackCString_closure' '-u' 'base_GHCziIOziException_stackOverflow_closure' '-u' 'base_GHCziIOziException_heapOverflow_closure' '-u' 'base_ControlziExceptionziBase_nonTermination_closure' '-u' 'base_GHCziIOziException_blockedIndefinitelyOnMVar_closure' '-u' 'base_GHCziIOziException_blockedIndefinitelyOnSTM_closure' '-u' 'base_ControlziExceptionziBase_nestedAtomically_closure' '-u' 'base_GHCziWeak_runFinalizzerBatch_closure' '-u' 'base_GHCziTopHandler_flushStdHandles_closure' '-u' 'base_GHCziTopHandler_runIO_closure' '-u' 'base_GHCziTopHandler_runNonIO_closure' '-u' 'base_GHCziConcziIO_ensureIOManagerIsRunning_closure' '-u' 'base_GHCziConcziSync_runSparks_closure' '-u' 'base_GHCziConcziSignal_runHandlers_closure'
link: done
*** Deleting temp files:
Deleting: /tmp/ghc4784_0/ghc4784_1.o /tmp/ghc4784_0/ghc4784_0.s /tmp/ghc4784_0/ghc4784_0.o /tmp/ghc4784_0/ghc4784_0.c
*** Deleting temp dirs:
Deleting: /tmp/ghc4784_0

À la recherche du premier *** Simplifier: au dernier, où toutes les phases d'optimisation se produisent, on en voit beaucoup.

Tout d'abord, le simplificateur fonctionne entre presque toutes les phases. Cela facilite l'écriture de nombreux passages. Par exemple, lors de l'implémentation de nombreuses optimisations, ils créent simplement des règles de réécriture pour propager les modifications au lieu de devoir le faire manuellement. Le simplificateur comprend un certain nombre d'optimisations simples, y compris l'inlining et la fusion. La principale limitation de cela que je sais est que GHC refuse d'inline les fonctions récursives, et que les choses doivent être nommées correctement pour que la fusion fonctionne.

Ensuite, nous voyons une liste complète de toutes les optimisations effectuées:

  • Spécialiser

    L'idée de base de la spécialisation est d'éliminer le polymorphisme et la surcharge en identifiant les endroits où la fonction est appelée et en créant des versions de la fonction qui ne sont pas polymorphes - elles sont spécifiques aux types avec lesquels elles sont appelées. Vous pouvez également demander au compilateur de le faire avec le pragma SPECIALISE. À titre d'exemple, prenons une fonction factorielle:

    fac :: (Num a, Eq a) => a -> a
    fac 0 = 1
    fac n = n * fac (n - 1)
    

    Comme le compilateur ne connaît aucune propriété de la multiplication à utiliser, il ne peut pas du tout l'optimiser. Si cependant, il voit qu'il est utilisé sur un Int, il peut maintenant créer une nouvelle version, ne différant que par le type:

    fac_Int :: Int -> Int
    fac_Int 0 = 1
    fac_Int n = n * fac_Int (n - 1)
    

    Ensuite, les règles mentionnées ci-dessous peuvent se déclencher, et vous vous retrouvez avec quelque chose qui fonctionne sur les Ints sans boîte, ce qui est beaucoup plus rapide que l'original. Une autre façon d'envisager la spécialisation est l'application partielle sur les dictionnaires de classe de type et les variables de type.

    Le source ici contient une charge de notes.

  • Flotter

    EDIT: J'ai apparemment mal compris cela auparavant. Mon explication a complètement changé.

    L'idée de base est de déplacer les calculs qui ne devraient pas être répétés hors des fonctions. Par exemple, supposons que nous ayons ceci:

    \x -> let y = expensive in x+y
    

    Dans le lambda ci-dessus, à chaque appel de la fonction, y est recalculé. Une meilleure fonction, qui produit flottant, est

    let y = expensive in \x -> x+y
    

    Pour faciliter le processus, d'autres transformations peuvent être appliquées. Par exemple, cela se produit:

     \x -> x + f 2
     \x -> x + let f_2 = f 2 in f_2
     \x -> let f_2 = f 2 in x + f_2
     let f_2 = f 2 in \x -> x + f_2
    

    Encore une fois, les calculs répétés sont enregistrés.

    Le source est très lisible dans ce cas.

    Pour le moment, les liaisons entre deux lambdas adjacentes ne flottent pas. Par exemple, cela ne se produit pas:

    \x y -> let t = x+x in ...
    

    aller à

     \x -> let t = x+x in \y -> ...
    
  • Flotter vers l'intérieur

    Citant le code source,

    Le but principal de floatInwards est de flotter dans les branches d'un cas, de sorte que nous n'allouons pas les choses, les enregistrons sur la pile, puis découvrons qu'elles ne sont pas nécessaires dans la branche choisie.

    Par exemple, supposons que nous avions cette expression:

    let x = big in
        case v of
            True -> x + 1
            False -> 0
    

    Si v est évalué à False, alors en allouant x, qui est probablement un gros morceau, nous avons perdu du temps et de l'espace. Flottant vers l'intérieur corrige cela, produisant ceci:

    case v of
        True -> let x = big in x + 1
        False -> let x = big in 0
    

    , qui est ensuite remplacé par le simplificateur avec

    case v of
        True -> big + 1
        False -> 0
    

    Cet article , bien qu'il couvre d'autres sujets, donne une introduction assez claire. Notez que malgré leurs noms, flotter et flotter n'entre pas dans une boucle infinie pour deux raisons:

    1. Float in floats laisse entrer les instructions case, tandis que float out traite les fonctions.
    2. Il y a un ordre fixe de passes, donc elles ne devraient pas alterner à l'infini.
  • Analyse de la demande

    L'analyse de la demande, ou analyse de rigueur, est moins une transformation et plus, comme son nom l'indique, une passe de collecte d'informations. Le compilateur trouve des fonctions qui évaluent toujours leurs arguments (ou au moins certains d'entre eux), et transmet ces arguments en utilisant appel par valeur, au lieu d'appeler par besoin. Étant donné que vous pouvez échapper aux frais généraux des thunks, cela est souvent beaucoup plus rapide. De nombreux problèmes de performances dans Haskell résultent de l'échec de cette passe ou du fait que le code n'est tout simplement pas assez strict. Un exemple simple est la différence entre l'utilisation de foldr, foldl et foldl' pour résumer une liste d'entiers - le premier provoque un débordement de pile, le second provoque un débordement de tas et le dernier s'exécute correctement, en raison de la rigueur. C'est probablement le plus facile à comprendre et le mieux documenté de tous ces éléments. Je crois que le polymorphisme et le code CPS échouent souvent.

  • Worker Wrapper se lie

    L'idée de base de la transformation de travailleur/wrapper est de faire une boucle serrée sur une structure simple, en se convertissant de et vers cette structure aux extrémités. Par exemple, prenez cette fonction, qui calcule la factorielle d'un nombre.

    factorial :: Int -> Int
    factorial 0 = 1
    factorial n = n * factorial (n - 1)
    

    En utilisant la définition de Int dans GHC, nous avons

    factorial :: Int -> Int
    factorial (I# 0#) = I# 1#
    factorial (I# n#) = I# (n# *# case factorial (I# (n# -# 1#)) of
        I# down# -> down#)
    

    Remarquez comment le code est couvert dans I#s? Nous pouvons les supprimer en procédant comme suit:

    factorial :: Int -> Int
    factorial (I# n#) = I# (factorial# n#)
    
    factorial# :: Int# -> Int#
    factorial# 0# = 1#
    factorial# n# = n# *# factorial# (n# -# 1#)
    

    Bien que cet exemple spécifique puisse également avoir été fait par SpecConstr, la transformation de travailleur/wrapper est très générale dans les choses qu'elle peut faire.

  • Sous-expression commune

    Il s'agit d'une autre optimisation vraiment simple qui est très efficace, comme l'analyse de rigueur. L'idée de base est que si vous avez deux expressions identiques, elles auront la même valeur. Par exemple, si fib est une calculatrice de nombres de Fibonacci, CSE transformera

    fib x + fib x
    

    dans

    let fib_x = fib x in fib_x + fib_x
    

    ce qui coupe le calcul de moitié. Malheureusement, cela peut occasionnellement entraver d'autres optimisations. Un autre problème est que les deux expressions doivent être au même endroit et qu'elles doivent être syntaxiquement les mêmes, pas les mêmes par valeur. Par exemple, CSE ne se déclenchera pas dans le code suivant sans un tas d'inlining:

    x = (1 + (2 + 3)) + ((1 + 2) + 3)
    y = f x
    z = g (f x) y
    

    Cependant, si vous compilez via llvm, vous pouvez obtenir une partie de cela combiné, en raison de sa passe de numérotation de valeur globale.

  • Libérer le cas

    Cela semble être une transformation terriblement documentée, en plus du fait qu'elle peut provoquer une explosion de code. Voici une version reformatée (et légèrement réécrite) de la petite documentation que j'ai trouvée:

    Ce module parcourt Core et recherche case sur les variables libres. Le critère est: s'il y a un case sur une variable libre sur la route vers l'appel récursif, alors l'appel récursif est remplacé par un dépliage. Par exemple, dans

    f = \ t -> case v of V a b -> a : f t
    

    le f intérieur est remplacé. faire

    f = \ t -> case v of V a b -> a : (letrec f = \ t -> case v of V a b -> a : f t in f) t
    

    Notez la nécessité de l'observation. Simplifiant, nous obtenons

    f = \ t -> case v of V a b -> a : (letrec f = \ t -> a : f t in f t)
    

    C'est un meilleur code, car a est libre à l'intérieur du letrec intérieur, plutôt que d'avoir besoin d'une projection à partir de v. Notez que cela concerne les variables libres , contrairement à SpecConstr, qui traite des arguments de forme connue.

    Voir ci-dessous pour plus d'informations sur SpecConstr.

  • SpecConstr - cela transforme des programmes comme

    f (Left x) y = somthingComplicated1
    f (Right x) y = somethingComplicated2
    

    dans

    f_Left x y = somethingComplicated1
    f_Right x y = somethingComplicated2
    
    {-# INLINE f #-}
    f (Left x) = f_Left x
    f (Right x) = f_Right x
    

    Comme exemple étendu, prenez cette définition de last:

    last [] = error "last: empty list"
    last (x:[]) = x
    last (x:x2:xs) = last (x2:xs)
    

    Nous le transformons d'abord en

    last_nil = error "last: empty list"
    last_cons x [] = x
    last_cons x (x2:xs) = last (x2:xs)
    
    {-# INLINE last #-}
    last [] = last_nil
    last (x : xs) = last_cons x xs
    

    Ensuite, le simplificateur s'exécute, et nous avons

    last_nil = error "last: empty list"
    last_cons x [] = x
    last_cons x (x2:xs) = last_cons x2 xs
    
    {-# INLINE last #-}
    last [] = last_nil
    last (x : xs) = last_cons x xs
    

    Notez que le programme est maintenant plus rapide, car nous ne boxons et ne déballons pas à plusieurs reprises le début de la liste. Notez également que l'inline est crucial, car il permet aux nouvelles définitions plus efficaces d'être effectivement utilisées, ainsi que d'améliorer les définitions récursives.

    SpecConstr est contrôlé par un certain nombre d'heuristiques. Celles mentionnées dans le document sont en tant que telles:

    1. Les lambdas sont explicites et l'arité est a.
    2. Le côté droit est "suffisamment petit", quelque chose contrôlé par un drapeau.
    3. La fonction est récursive et l'appel spécialisable est utilisé dans la partie droite.
    4. Tous les arguments de la fonction sont présents.
    5. Au moins l'un des arguments est une application constructeur.
    6. Cet argument est analysé par cas quelque part dans la fonction.

    Cependant, l'heuristique a presque certainement changé. En fait, l'article mentionne une sixième heuristique alternative:

    Spécialisé sur un argument x uniquement si x est uniquement examiné par un case, et est pas passé à une fonction ordinaire, ou retourné dans le cadre du résultat.

C'était un très petit fichier (12 lignes) et donc n'a peut-être pas déclenché autant d'optimisations (même si je pense que cela les a toutes fait). Cela ne vous dit pas non plus pourquoi il a choisi ces passes et pourquoi il les a mises dans cet ordre.

105
gereeter

Paresse

Ce n'est pas une "optimisation du compilateur", mais c'est quelque chose de garanti par la spécification du langage, vous pouvez donc toujours compter sur cela. Essentiellement, cela signifie que le travail n'est effectué que lorsque vous "faites quelque chose" avec le résultat. (À moins que vous ne fassiez une ou plusieurs choses pour désactiver délibérément la paresse.)

Ceci, évidemment, est un sujet entier à part entière, et SO a déjà beaucoup de questions et réponses à ce sujet.

Dans mon expérience limitée, rendre votre code trop paresseux ou trop strict a considérablement des pénalités de performances plus importantes (dans le temps et espace) que toutes les autres choses dont je vais parler ...

Analyse de rigueur

La paresse consiste à éviter le travail, sauf si cela 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 va juste l'exécuter 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 facile de déterminer si l'analyse de rigueur a fait ce que vous pensez qu'il a, à part patauger dans la sortie Core.)

en ligne

Si vous appelez une fonction et que le compilateur peut déterminer la fonction que vous appelez, il peut essayer de "mettre en ligne" cette fonction, c'est-à-dire de remplacer l'appel de fonction par une copie de la fonction elle-même. Les frais généraux d'un appel de fonction sont généralement assez petits, mais l'incrustation permet souvent à d'autres optimisations de se produire, ce qui ne serait pas arrivé autrement, donc l'incrustation peut être une grande victoire.

Les fonctions ne sont intégrées que si elles sont "suffisamment petites" (ou si vous ajoutez un pragma demandant spécifiquement l'inclusion). De plus, les fonctions ne peuvent être insérées que si le compilateur peut dire quelle fonction vous appelez. Il existe deux façons principales que le compilateur ne peut pas déterminer:

  • Si la fonction que vous appelez est transmise ailleurs. Par exemple, lorsque la fonction filter est compilée, vous ne pouvez pas incorporer 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 le compilateur ne sait pas de quel type il s'agit. Par exemple, lorsque la fonction sum est compilée, le compilateur ne peut pas aligner le +, car sum fonctionne avec plusieurs types de nombres différents, chacun ayant un + une fonction.

Dans ce dernier cas, vous pouvez utiliser le {-# SPECIALIZE #-} pragma pour générer des versions d'une fonction codées en dur pour un type particulier. Par exemple., {-# SPECIALIZE sum :: [Int] -> Int #-} compilerait une version de sum codée en dur pour le type Int, ce qui signifie que + peut être inséré dans cette version.

Notez cependant que notre nouvelle fonction spéciale -sum ne sera appelée que lorsque le compilateur pourra dire que nous travaillons avec Int. Sinon, le sum polymorphe d'origine est appelé. Encore une fois, la surcharge réelle d'appel de fonction est assez petite. Ce sont les optimisations supplémentaires que l'inline peut permettre qui sont bénéfiques.

élimination de la sous-expression commune

Si un certain bloc de code calcule deux fois la même valeur, le compilateur peut remplacer cela par une seule instance du même calcul. Par exemple, si vous le faites

(sum xs + 1) / (sum xs + 2)

alors le compilateur pourrait optimiser cela pour

let s = sum xs in (s+1)/(s+2)

Vous pourriez vous attendre à ce que le compilateur ( fasse toujours cela. Cependant, apparemment, dans certaines situations, cela peut entraîner de moins bonnes performances, 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 inquiétez-vous?)

Expressions de casse

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 casse 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. Parce que le compilateur peut facilement effectuer cette transformation, vous n'avez pas à vous en soucier. Écrivez simplement votre correspondance de motifs 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.

Fusion

L'idiome Haskell standard pour le traitement de liste est de chaîner des 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 les travaux inutiles, toutes les allocations et désallocations pour la performance de la liste intermédiaire sève. "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écursivité, ce serait un exercice élémentaire en alignement pour écraser toutes les fonctions dans un grand 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 {-# RULE #-} pragmas pour corriger 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 suivi de filter, filter suivi de map, etc. Plutôt que de coder manuellement une solution pour chacun d'eux, la soi-disant "fusion de flux" a été inventée. C'est une astuce plus compliquée, que je ne décrirai pas ici.

Le long et court de celui-ci est: Ce sont toutes des astuces d'optimisation spéciales écrites par le programmeur . Le GHC lui-même ne connaît rien à la fusion; tout est dans les bibliothèques de listes et autres bibliothèques de conteneurs. Les optimisations dépendent donc de la façon 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 bibliothèque vector possède des capacités de fusion étendues. Tout tourne autour des bibliothèques; le compilateur fournit simplement le pragma RULES. (Ce qui est d'ailleurs extrêmement puissant. En tant qu'auteur de bibliothèque, vous pouvez l'utiliser pour réécrire le code client!)


Meta:

  • Je suis d'accord avec les gens qui disent "code d'abord, profil deuxième, optimisez troisième".

  • Je suis également d'accord avec les gens qui disent "qu'il est utile d'avoir un modèle mental pour combien coûte une décision de conception donnée".

L'équilibre en toutes choses, et tout ça ...

64
MathematicalOrchid

Si une liaison let v = rhs est utilisée à un seul endroit, vous pouvez compter sur le compilateur pour l'inclure, même si rhs est grand.

L'exception (qui n'en est presque pas une dans le contexte de la question actuelle) est le lambdas qui risque de faire double emploi. Considérer:

let v = rhs
    l = \x-> v + x
in map l [1..100]

y insérer v serait dangereux car l'utilisation unique (syntaxique) se traduirait par 99 évaluations supplémentaires de rhs. Cependant, dans ce cas, il est très peu probable que vous souhaitiez l'inclure manuellement non plus. Donc, vous pouvez essentiellement utiliser la règle:

Si vous envisagez d'inclure un nom qui n'apparaît qu'une seule fois, le compilateur le fera quand même.

En corollaire heureux, l'utilisation d'une liaison let simplement pour décomposer une longue déclaration (dans l'espoir de gagner en clarté) est essentiellement gratuite.

Cela vient de community.haskell.org/~simonmar/papers/inline.pdf qui contient beaucoup plus d'informations sur l'inline.

8
Daniel