web-dev-qa-db-fra.com

Pourquoi Haskell (GHC) est-il si vachement rapide?

Haskell (avec le compilateur GHC) est un beaucoup plus rapide que prév . Utilisé correctement, il peut s'approcher des langages de bas niveau. (Une chose que les Haskellers préfèrent est d'essayer de se situer à moins de 5% de C (ou même de le battre, mais cela signifie que vous utilisez un programme C inefficace, puisque GHC compile Haskell en C).) Ma question est la suivante: pourquoi?

Haskell est déclaratif et basé sur le lambda calcul. Les architectures de machines sont clairement impératives, étant basées sur les machines, en gros. En effet, Haskell n'a même pas d'ordre d'évaluation spécifique. De plus, au lieu de traiter avec les types de données machine, vous créez des types de données algébriques tout le temps.

Le plus étrange de tous est cependant des fonctions d'ordre supérieur. On pourrait penser que créer et lancer des fonctions à la volée ralentirait un programme. Mais l'utilisation de fonctions d'ordre supérieur accélère réellement Haskell. En effet, il semble que pour optimiser le code Haskell, vous devez le rendre plus élégant et abstrait au lieu d'une machine. Aucune des fonctionnalités plus avancées de Haskell ne semble même affecter ses performances , si elles ne l’améliorent pas.

Désolé si cela sonne diabolique, 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 aux machines de Turing (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 (ou numéro de ligne). , avec une bande (le bélier), de sorte que l’état et la bande en cours déterminent ce qu’il faut faire à la bande. Voir l'entrée Wikipedia, équivalents de Turing machine , pour la transition de Turing Machines vers des ordinateurs.

211
PyRulez

Je pense que celui-ci est un peu basé sur l'opinion. Mais je vais essayer de répondre.

Je suis d'accord avec Dietrich Epp: c'est une combinaison de plusieurs choses qui rendent GHC rapide.

Haskell est avant tout un très haut niveau. Cela permet au compilateur d'effectuer des optimisations agressives sans casser votre code.

Pensez à SQL. Maintenant, quand j'écris une instruction SELECT, elle pourrait ressembler à une boucle impérative, , mais elle n'est pas . Il se peut que ressemble à il parcourt 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 effectuer une recherche d'index à la place, ce qui présente des caractéristiques de performances complètement différentes. Mais parce que SQL est de très haut niveau, le "compilateur" peut substituer des algorithmes totalement différents, appliquer plusieurs processeurs ou canaux d'E/S ou des serveurs entiers de manière transparente, et plus.

Je pense que Haskell est identique. Vous pouvez penser que vous venez de demander à Haskell de mapper la liste d'entrées sur une deuxième liste, de filtrer la deuxième liste en une troisième liste, puis de compter le nombre d'éléments. abouti. Mais vous n'avez pas vu GHC appliquer des règles de réécriture de fusion de flux en coulisse, transformant le tout en une seule boucle de code machine compacte qui effectue 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. Ce 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 ne devrait pas Haskell être rapide? Qu'est-ce que ça fait qui devrait le ralentir?

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.

Contrairement à OO langages [Java, C #, JavaScript…], Haskell dispose d'un effacement complet du type [comme C, C++, Pascal…]. Toutes les vérifications de type ont lieu uniquement à la compilation. Il n'y a donc aucune vérification de type à l'exécution pour vous ralentir. (Aucune vérification du pointeur null, d'ailleurs. Dans Java, par exemple, la machine virtuelle Java doit rechercher des pointeurs nuls et émettre une exception si vous en respectez une. Haskell n'a pas à s'embêter avec cette vérification.)

Vous dites qu'il est 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 réellement. Cela pourrait ressembler à , mais ce n'est pas le cas. Si vous dites (+5), eh bien, c'est codé en dur dans votre code source. Cela ne peut pas changer au moment de l'exécution. Donc, ce n'est pas vraiment une fonction dynamique. Même les fonctions au curry ne font que sauvegarder 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 au moment de l'exécution. (Contrairement à d'autres langues qui ont une "fonction eval".)

Pensez à Pascal. Il est vieux et personne ne l'utilise vraiment plus, mais personne ne se plaint que Pascal est lent . Il y a beaucoup de choses à ne pas aimer à ce sujet, mais la lenteur n'en fait pas partie. Haskell ne fait pas tellement ce qui est différent de Pascal, si ce n’est la récupération de place plutôt que la gestion manuelle de la mémoire. Et des données immuables permettent plusieurs optimisations au moteur du GC [une évaluation compliquée complique alors quelque peu].

Je pense que la chose est que Haskell semble 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. Ou du moins, ce n'est pas comme vous le souhaiteriez. Oui, il a un système de types incroyable. Mais tu sais quoi? Tout cela se passe à la compilation. Au moment de l'exécution, il est parti. Oui, cela vous permet de construire des ADT complexes avec une ligne de code. Mais tu sais quoi? Un ADT est simplement un simple Cunion sur structs. 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 la raison pour laquelle cela se produit n’est pas évidente.

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 prenait 20 minutes à exécuter et avalé 6 gigaoctets de RAM! C'est absurde!! Mais ensuite, j'ai compris le problème, en ajoutant un motif unique et le temps d'exécution est tombé à ,02 seconde.

Ce est l'endroit où Haskell avance inopinément lentement. Et il faut un certain temps pour s’y habituer. Mais avec le temps, il devient plus facile d'écrire du code très rapide.

Qu'est-ce qui fait que Haskell est si rapide? Pureté. Types statiques. Paresse. Mais surtout, être suffisamment élevé pour que le compilateur puisse radicalement changer la mise en œuvre sans briser les attentes de votre code.

Mais je suppose que ce n'est que mon avis…

223
MathematicalOrchid

Pendant longtemps, on a pensé que les langages fonctionnels ne pouvaient pas être rapides - et particulièrement les langages fonctionnels paresseux. Mais c’est parce que leurs premières implémentations ont été, en substance, interprétées et non réellement compilées.

Une seconde vague de conceptions est apparue basée sur la réduction des graphes et a ouvert la possibilité d’une compilation beaucoup plus efficace. Simon Peyton Jones a écrit sur cette recherche dans ses deux ouvrages Implémentation de langages de programmation fonctionnels et Implémentation de langages fonctionnels: un tutoriel (le premier avec des sections de Wadler et Hancock, et le ce dernier écrit avec David Lester). (Lennart Augustsson m'a également informé que l'une des principales motivations de l'ancien livre était de décrire la manière dont son compilateur LML, qui n'était pas commenté de manière exhaustive, réalisait sa compilation).

La notion clé derrière les approches de réduction de graphes telles que décrites dans ces travaux est que nous ne considérons pas un programme comme une séquence d'instructions, mais comme un graphe de dépendance qui est évalué à travers une série de réductions locales. La deuxième idée clé est que l'évaluation d'un tel graphique ne doit pas nécessairement être interprétée mais que le graphique lui-même peut être . construit de code . En particulier, nous pouvons représenter un nœud d'un graphe non pas comme "une valeur ou un" opcode "et les valeurs sur lesquelles opérer", mais plutôt comme une fonction qui, lorsqu'elle est appelée, renvoie la valeur souhaitée. La première fois qu'il est appelé, il demande leurs valeurs aux sous-noeuds 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 décrivant les bases du fonctionnement actuel de GHC (même si de nombreuses modifications sont possibles): "Implémentation de langages fonctionnels paresseux sur du matériel standard: la G-Machine sans étiquette sans spin". . Le modèle d'exécution actuel de GHC est documenté plus en détail à l'emplacement GHC Wiki .

Il est donc clair que la distinction stricte entre "données" et "code", que nous considérons comme "fondamentale" dans 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 un code (un compilateur) qui génère du code à modification automatique (l'exécutable) et tout cela peut très bien fonctionner.

Ainsi, il s'avère que, si les architectures de machines sont impératives dans un certain sens, les langues peuvent s'y associer de manière très surprenante qui ne ressemble pas au contrôle de flux classique, mais si nous pensons que le niveau est assez bas, cela peut également être le cas. efficace.

En plus de cela, il existe de nombreuses autres optimisations ouvertes par la pureté en particulier, car elles permettent une plus grande gamme de transformations "sûres". Quand et comment appliquer ces transformations de manière à améliorer les choses et non les aggraver est bien sûr une question empirique, et sur ce choix parmi de nombreux autres petits choix, des années de travail ont été consacrées à la fois au travail théorique et au benchmarking pratique. Donc, cela joue bien sûr un rôle également. Un article qui fournit un bon exemple de ce type de recherche est " Faire un curry rapide: pousser/entrer vs évaluer/appliquer pour les langues d'ordre supérieur."

Enfin, il convient de noter que ce modèle introduit toujours une surcharge due aux indirections. Ceci peut être évité dans les cas où nous savons qu'il est "sûr" de faire les choses de manière stricte et d'éviter ainsi les indirections de graphes. Les mécanismes qui impliquent la rigueur/la demande sont à nouveau documentés de manière assez détaillée sur le GHC Wiki .

62
sclv

Eh bien, il y a beaucoup de choses à commenter ici. Je vais essayer de répondre autant que possible.

Utilisé correctement, il peut s'approcher des langages de bas niveau.

D'après mon expérience, il est généralement possible d'obtenir deux fois moins de performances que Rust dans de nombreux cas. Mais il existe également des cas d'utilisation (larges) dans lesquels les performances sont médiocres par rapport aux langages de bas niveau.

ou même le battre, mais cela signifie que vous utilisez un programme C inefficace, puisque GHC compile Haskell en C)

Ce n'est pas tout à fait correct. Haskell compile en C n sous-ensemble de C), qui est ensuite compilé via le générateur de code natif en Assembly. Le générateur de code natif génère généralement un code plus rapide que le compilateur C, car il peut appliquer certaines optimisations qu'un compilateur C ordinaire ne peut pas.

Les architectures de machines sont clairement impératives, étant basées sur les machines, en gros.

Ce n’est pas une bonne façon d’y réfléchir, d’autant plus que les processeurs modernes évalueront les instructions hors service et éventuellement en même temps.

En effet, Haskell n'a même pas d'ordre d'évaluation spécifique.

En fait, Haskell définit implicitement un ordre d'évaluation.

De plus, au lieu de traiter avec les types de données machine, vous créez des types de données algébriques tout le temps.

Ils correspondent dans de nombreux cas, à condition que vous disposiez d'un compilateur suffisamment avancé.

On pourrait penser que créer et lancer des fonctions à la volée ralentirait un programme.

Haskell est compilé et les fonctions d'ordre supérieur ne sont donc pas créées à la volée.

il semble que pour optimiser le code Haskell, vous devez le rendre plus élégant et abstrait, au lieu de davantage de machines.

En général, faire en sorte que le code ressemble davantage à celui d'une machine est un moyen non productif d'améliorer les performances en Haskell. Mais le rendre plus abstrait n’est pas toujours une bonne idée non plus. Ce qui est une bonne idée consiste à utiliser des structures de données et des fonctions communes fortement optimisées (telles que des listes chaînées).

f x = [x] et f = pure sont exactement la même chose dans Haskell, par exemple. Un bon compilateur ne donnerait pas de meilleures performances dans le premier cas.

Pourquoi Haskell (compilé avec GHC) est-il si rapide, compte tenu de sa nature abstraite et de ses différences par rapport aux machines physiques?

La réponse courte est "parce qu'il a été conçu pour faire exactement cela." GHC utilise la g-machine sans étiquette (STG). Vous pouvez lire un article à ce sujet --- (ici (c'est assez complexe). GHC fait aussi beaucoup d'autres choses, telles que l'analyse de rigueur et évaluation optimiste .

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 à Lambda Calculus) est que dans un langage impératif, vous avez un nombre fini d'états (aussi appelé numéro de ligne), ainsi que avec une bande (le bélier), de sorte que l’état et la bande en cours déterminent quoi faire pour la bande.

Le point de confusion est-il alors que la mutabilité devrait conduire à un code plus lent? La paresse de Haskell signifie en réalité que la mutabilité importe moins que ce que vous pensez, et qu'elle est de haut niveau, de sorte qu'il existe de nombreuses optimisations que le compilateur peut appliquer. Ainsi, la modification d'un enregistrement sur place sera rarement plus lente que dans une langue telle que C.

12
user8174234