Haskell (avec le GHC
compilateur) est beaucoup plus rapide que prévu . Utilisé correctement, il peut se rapprocher des langues de bas niveau. (Une des choses préférées des Haskellers est d'essayer d'obtenir jusqu'à 5% de C (ou même de le battre, mais cela signifie que vous utilisez un programme C inefficace, car GHC compile Haskell en C).) Ma question est, pourquoi?
Haskell est déclaratif et basé sur le calcul lambda. Les architectures de machines sont clairement impératives, étant basées sur des machines de turing, à peu près. En effet, Haskell n'a même pas d'ordre d'évaluation spécifique. De plus, au lieu de traiter des types de données machine, vous créez tout le temps des types de données algébriques.
Mais le plus étrange est les fonctions d'ordre supérieur. On pourrait penser que créer des fonctions à la volée et les lancer rendrait un programme plus lent. Mais l'utilisation de fonctions d'ordre supérieur accélère en fait Haskell. En effet, il semble que, pour optimiser le code Haskell, vous devez le rendre plus élégant et abstrait au lieu de plus semblable à une machine. Aucune des fonctionnalités les plus avancées de Haskell ne semble même affecter ses performances, si elles ne les améliorent pas.
Désolé si cela semble décourageant, mais voici ma question: pourquoi Haskell (compilé avec GHC) est-il si rapide, compte tenu de sa nature abstraite et de ses différences avec les machines physiques?
Remarque: La raison pour laquelle je dis que C et d'autres langages impératifs sont quelque peu similaires à Turing Machines (mais pas dans la mesure où Haskell est similaire au calcul Lambda) est que dans un langage impératif, vous avez un nombre fini d'états (aka numéro de ligne) , ainsi qu'une bande (la mémoire RAM), de telle sorte que l'état et la bande actuelle déterminent ce qu'il faut faire avec la bande. Voir l'entrée Wikipedia, équivalents de machine de Turing , pour la transition de machines de Turing aux ordinateurs.
Réponses:
Je suis d'accord avec Dietrich Epp: c'est une combinaison de plusieurs choses qui rendent le GHC rapide.
D'abord et avant tout, Haskell est de très haut niveau. Cela permet au compilateur d'effectuer des optimisations agressives sans casser votre code.
Pensez à SQL. Maintenant, quand j'écris une
SELECT
déclaration, cela peut ressembler à une boucle impérative, mais ce n'est pas le cas . Il peut sembler qu'il boucle sur toutes les lignes de cette table en essayant de trouver celle qui correspond aux conditions spécifiées, mais en fait le "compilateur" (le moteur de base de données) pourrait faire une recherche d'index à la place - qui a des caractéristiques de performances complètement différentes. Mais parce que SQL est si haut niveau, le "compilateur" peut remplacer des algorithmes totalement différents, appliquer plusieurs processeurs ou canaux d'E / S ou des serveurs entiers de manière transparente, et plus encore.Je pense que Haskell est le même. Vous pourriez penser que vous venez de demander à Haskell de mapper la liste d'entrée à une deuxième liste, de filtrer la deuxième liste en une troisième liste, puis de compter le nombre d'éléments obtenus. Mais vous n'avez pas vu GHC appliquer des règles de réécriture de fusion de flux dans les coulisses, transformant le tout en une seule boucle de code machine serrée qui fait tout le travail en un seul passage sur les données sans allocation - le genre de chose qui être fastidieux, sujet aux erreurs et non maintenable pour écrire à la main. Cela n'est vraiment possible qu'en raison du manque de détails de bas niveau dans le code.
Une autre façon de voir les choses pourrait être… pourquoi Haskell ne devrait-il pas être rapide? Que fait-il pour que ça ralentisse?
Ce n'est pas un langage interprété comme Perl ou JavaScript. Ce n'est même pas un système de machine virtuelle comme Java ou C #. Il compile jusqu'au code machine natif, donc pas de surcharge là-bas.
Contrairement aux langages OO [Java, C #, JavaScript…], Haskell a un effacement de type complet [comme C, C ++, Pascal…]. La vérification de tous les types se produit uniquement lors de la compilation. Il n'y a donc pas de vérification de type au moment de l'exécution pour vous ralentir non plus. (Aucune vérification de pointeur nul, d'ailleurs. En Java, par exemple, la JVM doit vérifier les pointeurs nuls et lever une exception si vous en déférencez une. Haskell n'a pas à s'embêter avec cette vérification.)
Vous dites qu'il semble lent de «créer des fonctions à la volée au moment de l'exécution», mais si vous regardez très attentivement, vous ne le faites pas vraiment. Cela pourrait vous ressembler , mais ce n'est pas le cas. Si vous dites
(+5)
, eh bien, c'est codé en dur dans votre code source. Il ne peut pas changer au moment de l'exécution. Ce n'est donc pas vraiment une fonction dynamique. Même les fonctions au curry ne sont vraiment que la sauvegarde des paramètres dans un bloc de données. Tout le code exécutable existe réellement au moment de la compilation; il n'y a pas d'interprétation d'exécution. (Contrairement à certaines autres langues qui ont une "fonction eval".)Pensez à Pascal. C'est vieux et personne ne l'utilise plus vraiment, mais personne ne se plaindrait que Pascal est lent . Il y a beaucoup de choses à ne pas aimer à ce sujet, mais la lenteur n'en fait pas vraiment partie. Haskell ne fait pas vraiment grand-chose de différent de Pascal, à part avoir un ramasse-miettes plutôt qu'une gestion manuelle de la mémoire. Et les données immuables permettent plusieurs optimisations au moteur GC [ce qui complique ensuite l'évaluation paresseuse].
Je pense que le truc c'est que Haskell a l' air avancé et sophistiqué et de haut niveau, et tout le monde pense "oh wow, c'est vraiment puissant, ça doit être incroyablement lent! " Mais ce n'est pas le cas. Ou du moins, ce n'est pas comme vous vous y attendez. Oui, il a un système de type incroyable. Mais tu sais quoi? Tout cela se produit au moment de la compilation. Au moment de l'exécution, c'est parti. Oui, cela vous permet de construire des ADT compliqués avec une ligne de code. Mais tu sais quoi? Un ADT n'est qu'un simple C ordinaire
union
de l'struct
art. Rien de plus.Le vrai tueur est l'évaluation paresseuse. Lorsque vous obtenez la rigueur / la paresse de votre code, vous pouvez écrire un code stupidement rapide qui est toujours élégant et beau. Mais si vous vous trompez, votre programme va des milliers de fois plus lentement , et il est vraiment difficile de comprendre pourquoi cela se produit.
Par exemple, j'ai écrit un petit programme trivial pour compter combien de fois chaque octet apparaît dans un fichier. Pour un fichier d'entrée de 25 Ko, le programme a pris 20 minutes à exécuter et a avalé 6 gigaoctets de RAM! C'est absurde!! Mais ensuite j'ai réalisé quel était le problème, j'ai ajouté un seul modèle de coup et le temps d'exécution est tombé à 0,02 seconde .
C'est là que Haskell va lentement et de façon inattendue. Et il faut certainement un certain temps pour s'y habituer. Mais au fil du temps, il devient plus facile d'écrire du code très rapide.
Qu'est-ce qui rend Haskell si rapide? Pureté. Types statiques. Paresse. Mais surtout, étant suffisamment élevé pour que le compilateur puisse changer radicalement l'implémentation sans casser les attentes de votre code.
Mais je suppose que c'est juste mon opinion ...
la source
Pendant longtemps, on a pensé que les langages fonctionnels ne pouvaient pas être rapides - et surtout les langages fonctionnels paresseux. Mais c'était parce que leurs premières implémentations étaient, en substance, interprétées et non pas véritablement compilées.
Une deuxième vague de conceptions est apparue sur la base de la réduction des graphes et a ouvert la possibilité d'une compilation beaucoup plus efficace. Simon Peyton Jones a écrit à propos de cette recherche dans ses deux livres The Implementation of Functional Programming Languages and Implementationing Functional Languages : a tutorial (the first with sections by Wadler and Hancock, and the second écrite with David Lester). (Lennart Augustsson m'a également informé qu'une des principales motivations de l'ancien livre était de décrire la manière dont son compilateur LML, qui n'a pas été largement commenté, a réalisé sa compilation).
La notion clé derrière les approches de réduction de graphe telles que décrites dans ces travaux est que nous ne pensons pas à un programme comme une séquence d'instructions, mais à un graphe de dépendance qui est évalué par une série de réductions locales. La deuxième idée clé est que l'évaluation d'un tel graphique n'a pas besoin d'être interprétée mais que le graphique lui-même peut être construit à partir de code . En particulier, nous pouvons représenter un nœud d'un graphe non pas comme "soit une valeur ou un" opcode "et les valeurs sur lesquelles opérer", mais plutôt comme une fonction qui, lorsqu'elle est invoquée, renvoie la valeur souhaitée. La première fois qu'il est invoqué, il demande aux sous-noeuds leurs valeurs, puis les opère, puis il se remplace par une nouvelle instruction qui dit simplement "retourner le résultat.
Ceci est décrit dans un article ultérieur qui expose les bases de la façon dont GHC fonctionne encore aujourd'hui (bien que modulo de nombreux ajustements divers): "Implémentation de langages fonctionnels paresseux sur le matériel de stock: la G-Machine Spineless Tagless." . Le modèle d'exécution actuel pour GHC est documenté plus en détail sur le Wiki GHC .
L'idée est donc que la stricte distinction entre «données» et «code» que nous considérons comme «fondamentales» pour le fonctionnement des machines n'est pas la manière dont elles doivent fonctionner, mais est imposée par nos compilateurs. Nous pouvons donc jeter cela et avoir du code (un compilateur) qui génère du code auto-modifiable (l'exécutable) et tout cela peut très bien fonctionner.
Ainsi, il s'avère que, bien que les architectures des machines soient impératives dans un certain sens, les langages peuvent y correspondre de manière très surprenante qui ne ressemble pas au contrôle de flux de style C conventionnel, et si nous pensons suffisamment bas, cela peut également être efficace.
En plus de cela, il existe de nombreuses autres optimisations ouvertes par la pureté en particulier, car elle permet une plus grande gamme de transformations "sûres". Quand et comment appliquer ces transformations de manière à ce qu'elles améliorent et non aggravent les choses est bien sûr une question empirique, et sur ce point et sur de nombreux autres petits choix, des années de travail ont été consacrées à la fois au travail théorique et au benchmarking pratique. Bien sûr, cela joue également un rôle. Un article qui fournit un bon exemple de ce type de recherche est « Making a Fast Curry: Push / Enter vs Eval / Apply for Higher-Order Languages».
Enfin, il convient de noter que ce modèle introduit toujours un surcoût dû aux indirections. Cela peut être évité dans les cas où nous savons qu'il est "sûr" de faire les choses strictement et donc d'éluder les indirections des graphes. Les mécanismes qui infèrent la rigueur / la demande sont à nouveau documentés en détail sur le Wiki GHC .
la source
Eh bien, il y a beaucoup à dire ici. Je vais essayer de répondre autant que possible.
D'après mon expérience, il est généralement possible d'obtenir dans les 2x les performances de Rust dans de nombreux cas. Mais il existe également des cas d'utilisation (larges) où les performances sont médiocres par rapport aux langues de bas niveau.
Ce n'est pas tout à fait exact. Haskell compile en C-- (un sous-ensemble de C), qui est ensuite compilé via le générateur de code natif vers l'assembly. Le générateur de code natif génère généralement du code plus rapide que le compilateur C, car il peut appliquer certaines optimisations qu'un compilateur C ordinaire ne peut pas.
Ce n'est pas une bonne façon d'y penser, d'autant plus que les processeurs modernes évalueront les instructions dans le désordre et éventuellement en même temps.
En fait, Haskell ne définit implicitement un ordre d'évaluation.
Ils correspondent dans de nombreux cas, à condition d'avoir un compilateur suffisamment avancé.
Haskell est compilé et les fonctions d'ordre supérieur ne sont donc pas réellement créées à la volée.
En général, rendre le code plus "semblable à une machine" est un moyen improductif d'obtenir de meilleures performances dans Haskell. Mais le rendre plus abstrait n'est pas toujours une bonne idée non plus. Ce qui est une bonne idée, c'est d'utiliser des structures de données et des fonctions communes qui ont été fortement optimisées (comme les listes chaînées).
f x = [x]
etf = pure
sont exactement la même chose à Haskell, par exemple. Un bon compilateur ne donnerait pas de meilleures performances dans le premier cas.La réponse courte est "car il a été conçu pour faire exactement cela." GHC utilise la g-machine sans étiquette sans rotation (STG). Vous pouvez lire un article à ce sujet ici (c'est assez complexe). GHC fait également beaucoup d'autres choses, telles que l'analyse de rigueur et l' évaluation optimiste .
Le point de confusion est-il alors que la mutabilité devrait conduire à un code plus lent? La paresse de Haskell signifie en fait que la mutabilité n'a pas autant d'importance que vous le pensez, en plus d'être de haut niveau, le compilateur peut appliquer de nombreuses optimisations. Ainsi, la modification d'un enregistrement sur place sera rarement plus lente que dans une langue comme C.
la source
Quelque chose a dû changer radicalement depuis la dernière fois que j'ai mesuré les performances de Haskell. Par exemple:
Alors qu'est-ce qui a changé? Je remarque que ni la question ni aucune de ses réponses actuelles ne font référence à des repères vérifiables ou même à du code.
Avez-vous des références à des résultats vérifiables où quiconque a pu s'en approcher?
la source
fmap (length &&& length . words &&& length . lines) readFile
. Si c'était plus rapide que (ou même comparable à) C, le battage médiatique ici serait tout à fait justifié alors . Nous devons encore travailler dur pour la vitesse à Haskell comme en C, c'est le point.