194 votes

Quelles optimisations peut-on attendre de GHC qu'il effectue de manière fiable ?

GHC a beaucoup d'optimisations qu'il peut effectuer, mais je ne sais pas ce qu'elles sont toutes, ni dans quelle mesure elles sont susceptibles d'être effectuées et dans quelles circonstances.

Ma question est la suivante : quelles transformations puis-je m'attendre à ce qu'il 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, peut-être que je devrais optimiser ça", dans quels cas ma deuxième pensée devrait-elle être "n'y pense même pas, GHC s'en occupe" ?

Je lisais le journal Stream Fusion : Des listes aux flux en passant par rien du tout et la technique qu'ils ont utilisée pour réécrire le traitement des listes dans une forme différente que les optimisations normales de GHC pourraient ensuite optimiser de manière fiable en boucles simples était nouvelle pour moi. Comment puis-je savoir si mes propres programmes peuvent bénéficier de ce type d'optimisation ?

Il y a quelques informations dans le manuel GHC, mais elle ne répond qu'en partie à la question.

EDIT : Je lance un appel d'offres. Ce que je voudrais, c'est une liste des transformations de bas niveau comme lambda/let/case-floating, type/constructor/function argument specialization, strictness analysis and unboxing, worker/wrapper, et tout ce que GHC fait de significatif et que j'ai laissé de côté, avec des explications et des exemples de code en entrée et en sortie, et idéalement des illustrations de situations où l'effet total est plus que la somme de ses parties. Et idéalement, une mention de quand les transformations ne le fera pas se produire. Je n'attends pas de longues explications sur chaque transformation, quelques phrases et des exemples de code en une seule ligne peuvent suffire (ou un lien, s'il ne s'agit pas d'un article scientifique de vingt pages), du moment que la vue d'ensemble est claire à la fin. Je veux pouvoir regarder un morceau de code et être capable de deviner s'il sera compilé en une boucle serrée, ou pourquoi pas, ou ce que je devrais changer pour le faire. (Je ne suis pas tellement intéressé par les grands cadres d'optimisation comme la fusion de flux (je viens de lire un article à ce sujet), mais plutôt par le type de connaissances que les personnes qui ont des connaissances en informatique peuvent acquérir. écrire ces cadres ont).

12 votes

C'est une question très intéressante. Écrire une réponse valable est... délicat.

1 votes

Voici un très bon point de départ : aosabook.org/fr/ghc.html

8 votes

Dans n'importe quelle langue, si votre première pensée est "je devrais peut-être optimiser cela", votre deuxième pensée devrait être "je vais d'abord le profiler".

119voto

gereeter Points 2906

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

Pour plus de 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 de manière verbale, en utilisant la commande -v drapeau. Prenons 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

En regardant depuis le premier *** Simplifier: jusqu'à la dernière, où se déroulent toutes les phases d'optimisation, on voit pas mal de choses.

Tout d'abord, le simplificateur fonctionne entre presque toutes les phases. Cela rend l'écriture de nombreuses passes beaucoup plus facile. Par exemple, lors de la mise en œuvre de nombreuses optimisations, il suffit de créer des règles de réécriture pour propager les changements au lieu de devoir le faire manuellement. Le simplificateur englobe un certain nombre d'optimisations simples, notamment l'inlining et la fusion. La principale limitation que je connais est que GHC refuse d'intégrer 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écialisation

    L'idée de base de la spécialisation est de supprimer 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 l'attribut SPECIALISE pragma. À 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. Cependant, s'il voit qu'elle est utilisée sur un objet de type Int il peut maintenant créer une nouvelle version, qui ne diffère que par son 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 unboxed Int ce qui est beaucoup plus rapide que l'original. Une autre façon de considérer la spécialisation est l'application partielle sur les dictionnaires de classes de type et les variables de type.

    El source Il y a plein de notes dedans.

  • Flottement

    EDIT : J'ai apparemment mal compris tout à l'heure. Mon explication a complètement changé.

    L'idée de base est de déplacer les calculs qui ne doivent 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 fois que la fonction est appelée, y est recalculé. Une meilleure fonction, qui produit des flottants, 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

    Une fois de plus, on économise des calculs répétés.

    El source est très lisible dans ce cas.

    Pour le moment, les liaisons entre deux lambdas adjacents ne sont pas flottantes. Par exemple, cela ne se produit pas :

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

    va

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

    Citer le code source,

    L'objectif principal de floatInwards est flottant dans les branches d'un cas, afin d'éviter d'allouer des choses, de les enregistrer sur la pile, puis de découvrir qu'elles ne sont pas nécessaires dans la branche choisie.

    À titre d'exemple, supposons que nous ayons cette expression :

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

    Si v évalue à False puis en allouant x qui est vraisemblablement un gros truc, nous avons perdu du temps et de l'espace. Flotter vers l'intérieur résout ce problème et produit 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

    Ce document bien que couvrant d'autres sujets, donne une introduction assez claire. Notez qu'en dépit de leur nom, le floating in et le floating out n'entrent pas dans une boucle infinie pour deux raisons :

    1. Float in floats laisse dans case tandis que la fonction "float out" concerne les fonctions.
    2. Il y a un ordre fixe de passage, donc ils ne devraient pas alterner à l'infini.
  • Analyse de la demande

    L'analyse de la demande, ou analyse de la rigueur, est moins une transformation et plus, comme son nom l'indique, un passage de collecte d'informations. Le compilateur trouve les fonctions qui évaluent toujours leurs arguments (ou au moins certains d'entre eux), et transmet ces arguments en utilisant le call-by-value, au lieu du call-by-need. Comme vous pouvez éviter les frais généraux des thunks, cette méthode est souvent beaucoup plus rapide. De nombreux problèmes de performance en Haskell proviennent soit de l'échec de ce passage, soit d'un code qui n'est tout simplement pas assez strict. Un exemple simple est la différence entre l'utilisation de foldr , foldl y foldl' pour additionner une liste d'entiers - le premier provoque un débordement de pile, le second un débordement de tas, et le dernier fonctionne bien, en raison de la rigueur. C'est probablement le plus facile à comprendre et le mieux documenté de tous. Je pense que le polymorphisme et le code CPS vont souvent à l'encontre de cela.

  • Le travailleur Wrapper se lie

    L'idée de base de la transformation worker/wrapper est de faire une boucle serrée sur une structure simple, en convertissant vers et depuis 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 faisant ceci :

    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 aurait également pu être réalisé par SpecConstr, la transformation worker/wrapper est très générale dans les choses qu'elle peut faire.

  • Sous-expression commune

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

    fib x + fib x

    en

    let fib_x = fib x in fib_x + fib_x

    ce qui réduit le calcul de moitié. Malheureusement, cela peut parfois gêner d'autres optimisations. Un autre problème est que les deux expressions doivent se trouver au même endroit et qu'elles doivent être syntaxiquement la même, pas la même par valeur. Par exemple, le CSE ne fonctionnera 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 cette combinaison, en raison de sa passe de numérotation des valeurs globales.

  • Libérer le cas

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

    Ce module passe en revue Core et cherche case sur des variables libres. Le critère est le suivant : s'il existe un case sur une variable libre sur le chemin de 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

    l'intérieur f est remplacé. pour 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'ombrage. En simplifiant, on obtient

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

    Ce code est meilleur, car a est libre à l'intérieur du letrec plutôt que d'avoir besoin de la projection de v . Notez que cela concerne variables libres à la différence de SpecConstr, qui s'occupe de arguments qui sont de forme connue.

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

  • SpecConstr - il transforme des programmes tels que

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

    en

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

    À titre d'exemple, prenons 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 n'avons pas à répéter l'insertion et l'extraction du début de la liste. Notez également que l'inlining est crucial, car il permet aux nouvelles définitions, plus efficaces, d'être réellement utilisées, tout en améliorant les définitions récursives.

    SpecConstr est contrôlé par un certain nombre d'heuristiques. Celles qui sont mentionnées dans le document sont les suivantes :

    1. Les lambdas sont explicites et l'arité est a .
    2. Le côté droit est "suffisamment petit", ce qui est 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 un des arguments est une application de constructeur.
    6. Cet argument fait l'objet d'une analyse de cas quelque part dans la fonction.

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

    Se spécialiser sur un argument x seulement si x es sólo examinés par un case et n'est pas transmis à une fonction ordinaire, ni renvoyé comme partie du résultat.

Il s'agissait d'un très petit fichier (12 lignes) et il est possible qu'il n'ait pas déclenché beaucoup d'optimisations (même si je pense qu'il les a toutes faites). Cela ne vous dit pas non plus pourquoi il a choisi ces passages et pourquoi il les a mis dans cet ordre.

66voto

MathematicalOrchid Points 15354

Paresse

Il ne s'agit pas d'une "optimisation du compilateur", mais d'un élément garanti par les spécifications du langage, de sorte que vous pouvez toujours compter sur sa présence. Essentiellement, cela signifie que le travail n'est pas effectué tant que vous n'avez pas "fait quelque chose" avec le résultat. (À moins que vous ne fassiez l'une des nombreuses choses pour désactiver délibérément la paresse).

Il s'agit évidemment d'un sujet à part entière, qui a déjà fait l'objet de nombreuses questions et réponses de la part de SO.

Dans mon expérience limitée, rendre votre code trop paresseux ou trop strict a largement des pénalités de performance plus importantes (en temps y ) que toutes les autres choses dont je vais parler...

Analyse de la rigueur

La paresse consiste à éviter le travail à moins qu'il ne soit 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'effectuer plus tard ; il l'effectuera directement, car c'est plus efficace. C'est ce qu'on appelle "l'analyse de la 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 indices au compilateur. (Je ne connais pas de moyen facile de déterminer si l'analyse de la rigueur a fait ce que vous pensez qu'elle a fait, autre que de patauger dans la sortie du Core).

Inlining

Si vous appelez une fonction et que le compilateur est en mesure de savoir quelle fonction 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. L'overhead d'un appel de fonction est généralement assez faible, mais l'inlining permet souvent d'autres optimisations qui n'auraient pas eu lieu autrement, donc l'inlining peut être une grande victoire.

Les fonctions ne sont inlined que si elles sont "suffisamment petites" (ou si vous ajoutez un pragma demandant spécifiquement l'inlining). De plus, les fonctions ne peuvent être inlined que si le compilateur peut savoir quelle fonction vous appelez. Il y a deux façons principales pour le compilateur d'être incapable de le faire :

  • Si la fonction que vous appelez est passée depuis un autre endroit. Par exemple, lorsque la fonction filter est compilée, vous ne pouvez pas mettre en ligne le prédicat du filtre, car il s'agit d'un argument fourni par l'utilisateur.

  • Si la fonction que vous appelez est une méthode de classe y le compilateur ne sait pas quel type est impliqué. Par exemple, lorsque le sum est compilée, le compilateur ne peut pas mettre en ligne la fonction + car sum fonctionne avec plusieurs types de nombres différents, chacun d'entre eux ayant une différente + fonction.

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

Notez, cependant, que notre nouveau spécial- sum ne sera appelée que lorsque le compilateur peut dire que nous travaillons avec Int . Sinon, l'original, polymorphe sum est appelé. Là encore, la surcharge réelle des appels de fonction est relativement faible. Ce sont les optimisations supplémentaires que l'inlining peut permettre qui sont bénéfiques.

Élimination des sous-expressions communes

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

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

alors le compilateur pourrait l'optimiser en

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

On pourrait s'attendre à ce que le compilateur toujours le faire. Cependant, apparemment, dans certaines situations, cela peut entraîner une baisse des performances, et non une amélioration. toujours faire ça. Franchement, je ne comprends pas vraiment les détails de cette opération. 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 préoccupez vous ?)

Expressions de cas

Considérez 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 est non vide (entre autres choses). Mais vérifier trois fois la même chose 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"

Cette méthode est moins intuitive, mais plus efficace. Comme le compilateur peut facilement effectuer cette transformation, vous n'avez pas à vous en soucier. Il suffit d'écrire votre filtrage de la manière la plus intuitive possible ; le compilateur est très doué pour réordonner et réarranger tout cela afin de le rendre aussi rapide que possible.

Fusion

L'idiome Haskell standard pour le traitement des listes consiste à enchaîner des fonctions qui prennent une liste et en produisent une nouvelle. L'exemple canonique étant

map g . map f

Malheureusement, si la paresse permet de sauter les tâches inutiles, toutes les allocations et désallocations pour la liste intermédiaire sapent les performances. La "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 d'inlining que d'écraser toutes les fonctions en un seul gros bloc de code, de faire tourner le simplificateur dessus et de produire un code vraiment optimal sans listes intermédiaires. Mais à cause de la récursivité, cela ne fonctionnera pas.

Vous pouvez utiliser {-# RULE #-} 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 #-}

Maintenant, chaque fois que GHC voit map appliqué à map il le comprime en un seul passage sur la liste, en éliminant la liste intermédiaire.

Le problème est que cela ne fonctionne que pour map suivi par map . Il existe de nombreuses autres possibilités - map suivi par filter , filter suivi par map etc. Plutôt que de coder manuellement une solution pour chacun d'entre eux, on a inventé ce qu'on appelle la "fusion de flux". Il s'agit d'une astuce plus compliquée, que je ne décrirai pas ici.

Le long et le court de la chose est : 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 façon dont vos bibliothèques de conteneurs sont écrites (ou, de façon plus réaliste, des bibliothèques que vous choisissez d'utiliser).

Par exemple, si vous travaillez avec des tableaux Haskell '98, ne vous attendez pas à une quelconque fusion. Mais je comprends que le vector possède des capacités de fusion étendues. Tout repose sur les bibliothèques ; le compilateur ne fait que fournir la RULES pragma. (Ce qui est extrêmement puissant, soit dit en passant. En tant qu'auteur de bibliothèque, vous pouvez l'utiliser pour réécrire le code du client).


Méta :

  • Je suis d'accord avec ceux qui disent "codez d'abord, profilez ensuite, optimisez ensuite".

  • Je suis également d'accord avec les personnes qui disent "il est utile d'avoir un modèle mental du coût d'une décision de conception donnée".

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

9 votes

it's something guaranteed by the language specification ... work is not performed until you "do something" with the result. - pas exactement. La spécification du langage promet sémantique non stricte ; il ne promet rien quant à la réalisation ou non de travaux superflus.

1 votes

@DanBurton Bien sûr, mais ce n'est pas facile à expliquer en quelques phrases. De plus, puisque GHC est presque la seule implémentation Haskell existante, le fait que GHC soit paresseux est suffisant pour la plupart des gens.

0 votes

@MathematicalOrchid : les évaluations spéculatives constituent un contre-exemple intéressant, même si je suis d'accord pour dire que c'est probablement trop pour un débutant.

8voto

Daniel Points 41

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

L'exception (qui n'en est presque pas une dans le contexte de la question actuelle) est le risque de duplication du travail par les lambdas. Pensez-y :

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

là, l'inlining de v serait dangereux car la seule utilisation (syntaxique) se traduirait par 99 évaluations supplémentaires de rhs. Cependant, dans ce cas, il est très peu probable que vous souhaitiez l'intégrer manuellement. Donc, essentiellement, vous pouvez utiliser la règle :

Si vous envisagez de mettre en ligne un nom qui n'apparaît qu'une fois, le compilateur le fera de toute façon.

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

Ceci provient de communauté.haskell.org/~simonmar/papers/inline.pdf qui contient beaucoup plus d'informations sur l'inlining.

Prograide.com

Prograide est une communauté de développeurs qui cherche à élargir la connaissance de la programmation au-delà de l'anglais.
Pour cela nous avons les plus grands doutes résolus en français et vous pouvez aussi poser vos propres questions ou résoudre celles des autres.

Powered by:

X