Quand la mémorisation est-elle automatique dans GHC Haskell?

106

Je ne comprends pas pourquoi m1 est apparemment mémorisé alors que m2 n'est pas dans ce qui suit:

m1      = ((filter odd [1..]) !!)

m2 n    = ((filter odd [1..]) !! n)

m1 10000000 prend environ 1,5 seconde sur le premier appel, et une fraction de cela sur les appels suivants (vraisemblablement il met en cache la liste), alors que m2 10000000 prend toujours le même temps (reconstruction de la liste à chaque appel). Une idée de ce qui se passe? Existe-t-il des règles empiriques pour savoir si et quand GHC mémorisera une fonction? Merci.

Jordan
la source

Réponses:

112

GHC ne mémorise pas les fonctions.

Cependant, il calcule une expression donnée dans le code au plus une fois par fois que son expression lambda environnante est entrée, ou au plus une fois si elle est au niveau supérieur. Déterminer où se trouvent les expressions lambda peut être un peu délicat lorsque vous utilisez du sucre syntaxique comme dans votre exemple, alors convertissons-les en syntaxe désuète équivalente:

m1' = (!!) (filter odd [1..])              -- NB: See below!
m2' = \n -> (!!) (filter odd [1..]) n

(Remarque: le rapport Haskell 98 décrit en fait une section d'opérateur de gauche (a %)comme équivalente à \b -> (%) a b, mais GHC la désuète (%) a. Celles-ci sont techniquement différentes car elles peuvent être distinguées par seq. Je pense que j'ai peut-être soumis un ticket GHC Trac à ce sujet.)

Compte tenu de cela, vous pouvez voir que dans m1', l'expression filter odd [1..]n'est contenue dans aucune expression lambda, donc elle ne sera calculée qu'une fois par exécution de votre programme, tandis que dans m2', filter odd [1..]sera calculée chaque fois que l'expression lambda est entrée, c'est-à-dire, à chaque appel de m2'. Cela explique la différence de timing que vous voyez.


En fait, certaines versions de GHC, avec certaines options d'optimisation, partageront plus de valeurs que la description ci-dessus ne l'indique. Cela peut être problématique dans certaines situations. Par exemple, considérons la fonction

f = \x -> let y = [1..30000000] in foldl' (+) 0 (y ++ [x])

GHC peut remarquer que ycela ne dépend pas de xet réécrire la fonction pour

f = let y = [1..30000000] in \x -> foldl' (+) 0 (y ++ [x])

Dans ce cas, la nouvelle version est beaucoup moins efficace car elle devra lire environ 1 Go de la mémoire où yest stockée, tandis que la version d'origine fonctionnerait dans un espace constant et rentrerait dans le cache du processeur. En fait, sous GHC 6.12.1, la fonction fest presque deux fois plus rapide lorsqu'elle est compilée sans optimisations qu'avec elle -O2.

Reid Barton
la source
1
Le coût pour évaluer (filtre impair [1 ..]) expression est de toute façon proche de zéro - c'est une liste paresseuse après tout, donc le coût réel est dans l'application (x !! 10000000) lorsque la liste est réellement évaluée. En outre, m1 et m2 semblent être évalués une seule fois avec -O2 et -O1 (sur mon ghc 6.12.3) au moins dans le test suivant: (test = m1 10000000 seqm1 10000000). Il y a cependant une différence lorsqu'aucun indicateur d'optimisation n'est spécifié. Et les deux variantes de votre "f" ont une résidence maximale de 5356 octets indépendamment de l'optimisation, d'ailleurs (avec moins d'allocation totale lorsque -O2 est utilisé).
Ed'ka
1
@ Ed'ka: Essayez ce programme de test, avec la définition ci - dessus f: main = interact $ unlines . (show . map f . read) . lines; compilez avec ou sans -O2; alors echo 1 | ./main. Si vous écrivez un test comme main = print (f 5), alors ypeut être récupéré comme il est utilisé et il n'y a aucune différence entre les deux fs.
Reid Barton
euh, ça devrait être map (show . f . read), bien sûr. Et maintenant que j'ai téléchargé GHC 6.12.3, je vois les mêmes résultats que dans GHC 6.12.1. Et oui, vous avez raison sur l'original m1et m2: les versions de GHC qui effectuent ce type de levage avec les optimisations activées se transformeront m2en m1.
Reid Barton
Oui, maintenant je vois la différence (-O2 est définitivement plus lent). Merci pour cet exemple!
Ed'ka
29

m1 n'est calculé qu'une seule fois car il s'agit d'une forme applicative constante, tandis que m2 n'est pas un CAF, et est donc calculé pour chaque évaluation.

Voir le wiki du GHC sur les CAF: http://www.haskell.org/haskellwiki/Constant_applicative_form

sclv
la source
1
L'explication «m1 n'est calculée qu'une seule fois parce que c'est une forme applicative constante» n'a pas de sens pour moi. Parce que m1 et m2 sont vraisemblablement des variables de premier niveau, je pense que ces fonctions ne sont calculées qu'une seule fois, qu'il s'agisse de CAF ou non. La différence est de savoir si la liste [1 ..]n'est calculée qu'une seule fois lors de l'exécution d'un programme ou si elle est calculée une fois par application de la fonction, mais est-elle liée à CAF?
Tsuyoshi Ito
1
De la page liée: "Un CAF ... peut être compilé en un morceau de graphe qui sera partagé par toutes les utilisations ou en un code partagé qui se remplacera par un graphe la première fois qu'il sera évalué". Puisqu'il m1s'agit d'un CAF, le second s'applique et filter odd [1..](pas seulement [1..]!) N'est calculé qu'une seule fois. GHC pourrait également noter que m2fait référence filter odd [1..]et placer un lien vers le même thunk utilisé dans m1, mais ce serait une mauvaise idée: cela pourrait entraîner de grandes fuites de mémoire dans certaines situations.
Alexey Romanov
@Alexey: Merci pour la correction sur [1..]et filter odd [1..]. Pour le reste, je ne suis toujours pas convaincu. Si je ne me trompe pas, CAF n'est pertinent que lorsque nous voulons affirmer qu'un compilateur pourrait remplacer le filter odd [1..]in m2par un thunk global (qui peut même être le même thunk que celui utilisé dans m1). Mais dans la situation du demandeur, le compilateur n'a pas fait cette «optimisation» et je ne vois pas sa pertinence par rapport à la question.
Tsuyoshi Ito
2
Il est pertinent qu'il peut le remplacer dans m1 , et il le fait.
Alexey Romanov
13

Il existe une différence cruciale entre les deux formes: la restriction du monomorphisme s'applique à m1 mais pas à m2, car m2 a explicitement donné des arguments. Le type de m2 est donc général mais celui de m1 est spécifique. Les types qui leur sont attribués sont:

m1 :: Int -> Integer
m2 :: (Integral a) => Int -> a

La plupart des compilateurs et interprètes Haskell (tous que je connais en fait) ne mémorisent pas les structures polymorphes, donc la liste interne de m2 est recréée à chaque fois qu'elle est appelée, là où m1 ne l'est pas.

mokus
la source
1
Jouer avec ceux-ci dans GHCi, il semble que cela dépend également de la transformation flottante (une des passes d'optimisation de GHC qui n'est pas utilisée dans GHCi). Et bien sûr, lors de la compilation de ces fonctions simples, l'optimiseur est capable de les faire se comporter de manière identique de toute façon (selon certains tests de critères que j'ai quand même exécutés, avec les fonctions dans un module séparé et marquées de pragmas NOINLINE). C'est probablement parce que la génération et l'indexation de la liste sont de toute façon fusionnées dans une boucle très serrée.
mokus
1

Je ne suis pas sûr, car je suis moi-même assez nouveau dans Haskell, mais il semble que cela soit dû au fait que la deuxième fonction est paramétrée et que la première ne l'est pas. La nature de la fonction est que son résultat dépend de la valeur d'entrée et dans le paradigme fonctionnel, il dépend UNIQUEMENT de l'entrée. L'implication évidente est qu'une fonction sans paramètre renvoie toujours la même valeur à plusieurs reprises, quoi qu'il arrive.

Apparemment, il y a un mécanisme d'optimisation dans le compilateur GHC qui exploite ce fait pour calculer la valeur d'une telle fonction une seule fois pour l'ensemble de l'exécution du programme. Il le fait paresseusement, certes, mais le fait néanmoins. Je l'ai remarqué moi-même, lorsque j'ai écrit la fonction suivante:

primes = filter isPrime [2..]
    where isPrime n = null [factor | factor <- [2..n-1], factor `divides` n]
        where f `divides` n = (n `mod` f) == 0

Ensuite , pour le tester, je suis entré dans GHCi et écrit: primes !! 1000. Il a fallu quelques secondes, mais finalement je me suis la réponse: 7927. Ensuite, j'ai appelé primes !! 1001et j'ai obtenu la réponse instantanément. De même, en un instant, j'ai obtenu le résultat pour take 1000 primes, car Haskell devait calculer la liste des mille éléments entière pour renvoyer le 1001e élément avant.

Ainsi, si vous pouvez écrire votre fonction de telle sorte qu'elle ne prenne aucun paramètre, vous en voudrez probablement. ;)

Sventimir
la source