web-dev-qa-db-fra.com

La programmation fonctionnelle est-elle plus rapide en multithreading parce que j'écris les choses différemment ou parce que les choses sont compilées différemment?

Je plonge dans le monde de la programmation fonctionnelle et je continue de lire partout que les langages fonctionnels sont meilleurs pour les programmes multithreads/multicœurs. Je comprends comment les langages fonctionnels font beaucoup de choses différemment, comme récursivité , nombres aléatoires etc. mais je n'arrive pas à comprendre si le multithreading est plus rapide dans un langage fonctionnel parce que c'est compilé différemment ou parce que je écris différemment.

Par exemple, j'ai écrit un programme en Java qui implémente un certain protocole. Dans ce protocole, les deux parties s'envoient et se reçoivent des milliers de messages, elles chiffrent ces messages et les renvoient (et comme prévu, le multithreading est la clé lorsque vous traitez à l'échelle de milliers. Dans ce programme, aucun verrouillage n'est impliqué .

Si j'écris le même programme en Scala (qui utilise la JVM), cette implémentation sera-t-elle plus rapide? Si oui, pourquoi? Est-ce à cause du style d'écriture? Si c'est est en raison du style d'écriture, maintenant que Java inclut des expressions lambda, ne pourrais-je pas obtenir les mêmes résultats en utilisant Java avec lambda? Ou est-ce plus rapide parce que Scala va compiler les choses différemment?

64
Aventinus

La raison pour laquelle les gens disent que les langages fonctionnels sont meilleurs pour le traitement parallèle est due au fait qu'ils généralement évitent l'état mutable. L'état mutable est la "racine de tout mal" dans le contexte du traitement parallèle; ils facilitent vraiment l'exécution dans des conditions de concurrence quand ils sont partagés entre des processus simultanés. La solution aux conditions de concurrence implique alors des mécanismes de verrouillage et de synchronisation, comme vous l'avez mentionné, qui entraînent une surcharge d'exécution, car les processus attendent les uns des autres pour utiliser la ressource partagée, et une plus grande complexité de conception, car tous ces concepts ont tendance à être profondément imbriqué dans ces applications.

Lorsque vous évitez l'état mutable, le besoin de mécanismes de synchronisation et de verrouillage disparaît avec lui. Étant donné que les langages fonctionnels évitent généralement l'état mutable, ils sont naturellement plus efficaces et efficaces pour le traitement parallèle - vous n'aurez pas la surcharge d'exécution des ressources partagées et vous n'aurez pas la complexité de conception supplémentaire qui suit généralement.

Cependant, tout cela est accessoire. Si votre solution en Java évite également l'état mutable (spécifiquement partagé entre les threads), la convertir en un langage fonctionnel comme Scala ou Clojure ne donnerait aucun avantage dans en termes d'efficacité simultanée, car la solution d'origine est déjà exempte de la surcharge causée par les mécanismes de verrouillage et de synchronisation.

TL; DR: Si une solution dans Scala est plus efficace dans le traitement parallèle que dans Java, ce n'est pas à cause de la façon dont le code est compilé ou exécuté via la JVM, mais plutôt parce que le Java partage l'état mutable entre les threads, soit en provoquant des conditions de concurrence soit en ajoutant la surcharge de synchronisation afin de les éviter.

98
MichelHenrich

Sorte des deux. C'est plus rapide car il est plus facile d'écrire votre code d'une manière plus facile à compiler plus rapidement. Vous n'obtiendrez pas nécessairement une différence de vitesse en changeant de langue, mais si vous aviez commencé avec un langage fonctionnel, vous auriez probablement pu faire le multithreading avec beaucoup moins programmeur effort. Dans le même ordre d'idées, il est beaucoup plus facile pour un programmeur de faire des erreurs de filetage qui coûteront de la vitesse dans un langage impératif, et beaucoup plus difficile de remarquer ces erreurs.

La raison en est que les programmeurs impératifs essaient généralement de mettre tout le code fileté sans verrou dans une boîte aussi petite que possible, et de l'échapper dès que possible, de retour dans leur monde mutable et synchrone confortable. La plupart des erreurs qui vous coûtent de la vitesse sont commises sur cette interface limite. Dans un langage de programmation fonctionnel, vous n'avez pas à vous soucier autant de faire des erreurs sur cette frontière. La plupart de votre code d'appel est également "à l'intérieur de la boîte", pour ainsi dire.

8
Karl Bielefeldt

La programmation fonctionnelle ne rend pas les programmes plus rapides, en règle générale. Ce que cela rend est pour une programmation parallèle et simultanée plus facile . Il y a deux clés principales à cela:

  1. L'évitement de l'état mutable a tendance à réduire le nombre de choses qui peuvent mal tourner dans un programme, et plus encore dans un programme simultané.
  2. L'évitement des primitives de synchronisation basée sur la mémoire partagée et les verrous au profit de concepts de niveau supérieur tend à simplifier la synchronisation entre les threads de code.

Un excellent exemple du point # 2 est que dans Haskell, nous avons une nette distinction entre parallélisme déterministe vs non simultanéité déterministe . Il n'y a pas de meilleure explication que de citer l'excellent livre de Simon Marlow Programmation parallèle et simultanée dans Haskell (les citations sont de Chapitre 1 ):

Un programme parallèle est un programme qui utilise une multiplicité de matériel informatique (par exemple, plusieurs cœurs de processeur) pour effectuer un calcul plus rapidement. Le but est d'arriver plus tôt à la réponse, en déléguant différentes parties du calcul à différents processeurs qui s'exécutent en même temps.

En revanche, la concurrence est une technique de structuration de programme dans laquelle il existe plusieurs threads de contrôle. Conceptuellement, les fils de contrôle s'exécutent "en même temps"; c'est-à-dire que l'utilisateur voit ses effets entrelacés. Qu'ils s'exécutent réellement en même temps ou non est un détail d'implémentation; un programme simultané peut s'exécuter sur un seul processeur via une exécution entrelacée ou sur plusieurs processeurs physiques.

En plus de cela, Marlow mentionne également la dimension du déterminisme :

Une distinction connexe existe entre les modèles de programmation déterministes et non déterministes . Un modèle de programmation déterministe est un modèle dans lequel chaque programme ne peut donner qu'un seul résultat, tandis qu'un modèle de programmation non déterministe admet des programmes qui peuvent avoir des résultats différents, selon un aspect de l'exécution. Les modèles de programmation simultanés sont nécessairement non déterministes car ils doivent interagir avec des agents externes qui provoquent des événements à des moments imprévisibles. Le non-déterminisme présente cependant certains inconvénients notables: les programmes deviennent beaucoup plus difficiles à tester et à raisonner.

Pour la programmation parallèle, nous aimerions utiliser des modèles de programmation déterministes si possible. Étant donné que l'objectif est simplement d'arriver à la réponse plus rapidement, nous préférons ne pas rendre notre programme plus difficile à déboguer dans le processus. La programmation parallèle déterministe est le meilleur des deux mondes: les tests, le débogage et le raisonnement peuvent être effectués sur le programme séquentiel, mais le programme s'exécute plus rapidement avec l'ajout de davantage de processeurs.

Dans Haskell, les fonctionnalités de parallélisme et de concurrence sont conçues autour de ces concepts. En particulier, quelles autres langues se regroupent en un seul ensemble de fonctionnalités, Haskell se divise en deux:

  • Fonctionnalités et bibliothèques déterministes pour le parallélisme .
  • Fonctionnalités et bibliothèques non déterministes pour concurrence simultanée .

Si vous essayez simplement d'accélérer un calcul pur et déterministe, le parallélisme déterministe facilite souvent les choses. Souvent, vous faites juste quelque chose comme ça:

  1. Écrivez une fonction qui produit une liste de réponses, dont chacune est coûteuse à calculer mais ne dépend pas beaucoup les unes des autres. C'est Haskell, donc les listes sont paresseuses - les valeurs de leurs éléments ne sont pas réellement calculées jusqu'à ce qu'un consommateur les demande.
  2. Utilisez la bibliothèque Stratégies pour consommer les éléments des listes de résultats de votre fonction en parallèle sur plusieurs cœurs.

J'ai fait ça avec un de mes programmes de projets de jouets il y a quelques semaines . Il était trivial de paralléliser le programme - la chose clé que je devais faire était, en fait, d'ajouter du code qui dit "calculer les éléments de cette liste en parallèle" (ligne 90), et j'ai eu un boost de débit quasi-linéaire dans certains de mes cas de test les plus chers.

Mon programme est-il plus rapide que si j'avais opté pour des utilitaires de multithreading classiques basés sur des verrous? J'en doute fort. La chose intéressante dans mon cas était de tirer le meilleur parti de si peu d'argent - mon code est probablement très sous-optimal, mais parce qu'il est si facile à paralléliser, j'ai obtenu une grande accélération avec beaucoup moins d'efforts que le profilage et l'optimisation correctement, et aucun risque de conditions de course. Et c'est, je dirais, la principale façon dont la programmation fonctionnelle vous permet d'écrire des programmes "plus rapides".

7
sacundim

Dans Haskell, la modification est littéralement impossible sans obtenir des variables modifiables spéciales via une bibliothèque de modifications. Au lieu de cela, les fonctions créent les variables dont elles ont besoin en même temps que leurs valeurs (qui sont calculées paresseusement) et les ordures collectées lorsqu'elles ne sont plus nécessaires.

Même lorsque vous avez besoin de variables de modification, vous pouvez généralement les utiliser avec parcimonie et avec les variables non modifiables. (Une autre bonne chose dans haskell est STM, qui remplace les verrous par des opérations atomiques, mais je ne suis pas sûr que ce soit uniquement pour la programmation fonctionnelle ou non.) Habituellement, une seule partie du programme devra être parallèle pour améliorer les choses. en termes de performances.

Cela rend le parallélisme dans Haskell facile la plupart du temps, et en fait des efforts sont en cours pour le rendre automatique. Pour le code simple, le parallélisme et la logique peuvent même être séparés.

En outre, étant donné que l'ordre d'évaluation n'a pas d'importance dans Haskell, le compilateur crée simplement une file d'attente des éléments qui doivent être évalués et les envoie à tous les cœurs disponibles, de sorte que vous pouvez créer un tas de "threads" qui ne le font pas. deviennent en fait des fils jusqu'à ce que cela soit nécessaire. L'ordre d'évaluation sans importance est caractéristique de la pureté, ce qui nécessite généralement une programmation fonctionnelle.

Lectures complémentaires
Parallélisme dans Haskell (HaskellWiki)
Programmation simultanée et multicœur dans "Real-World Haskell"
Programmation parallèle et simultanée dans Haskell par Simon Marlow

2
PyRulez