web-dev-qa-db-fra.com

L'immutabilité nuit-elle aux performances en JavaScript?

Il semble y avoir une tendance récente dans JavaScript à traiter les structures de données comme immuables. Par exemple, si vous devez modifier une seule propriété d'un objet, mieux vaut simplement créer un tout nouvel objet avec la nouvelle propriété, et simplement copier toutes les autres propriétés de l'ancien objet et laisser l'ancien objet être récupéré. (C'est ma compréhension de toute façon.)

Ma première réaction est que cela semble être mauvais pour la performance.

Mais alors des bibliothèques comme Immutable.js et Redux.js sont écrites par des gens plus intelligents que moi, et semblent avoir un fort souci de performance, donc je me demande si mon la compréhension des déchets (et de leur impact sur les performances) est erronée.

Y a-t-il des avantages en termes de performances à l'immuabilité qui me manque, et l'emportent-ils sur les inconvénients de créer autant de déchets?

94
callum

Par exemple, si vous devez modifier une seule propriété d'un objet, mieux vaut simplement créer un tout nouvel objet avec la nouvelle propriété, et simplement copier toutes les autres propriétés de l'ancien objet et laisser l'ancien objet être récupéré.

Sans immuabilité, vous devrez peut-être faire circuler un objet entre différentes portées, et vous ne savez pas au préalable si et quand l'objet sera modifié. Donc, pour éviter les effets secondaires indésirables, vous commencez à créer une copie complète de l'objet "au cas où" et passez cette copie, même s'il s'avère qu'aucune propriété ne doit être modifiée du tout. Cela laissera beaucoup plus de déchets que dans votre cas.

Ce que cela démontre, c'est que si vous créez le bon scénario hypothétique, vous pouvez prouver n'importe quoi, surtout en ce qui concerne les performances. Mon exemple, cependant, n'est pas aussi hypothétique que cela puisse paraître. J'ai travaillé le mois dernier sur un programme où nous avons trébuché exactement sur ce problème parce que nous avions initialement décidé de ne pas utiliser une structure de données immuable, et avons hésité à refactoriser cela plus tard parce qu'il ne semblait pas en valoir la peine.

Donc, quand vous regardez des cas comme celui-ci d'un ancien SO post , la réponse à vos questions devient probablement claire - it Cela dépend . Dans certains cas, l'immutabilité nuira aux performances, pour certains l'inverse pourrait être vrai, pour de nombreux cas, cela dépendra de la façon dont votre implémentation est intelligente, et pour encore plus de cas, la différence sera négligeable.

Une dernière remarque: un problème réel que vous pourriez rencontrer est que vous devez décider tôt pour ou contre l'immuabilité pour certaines structures de données de base. Ensuite, vous construisez beaucoup de code sur cela, et plusieurs semaines ou mois plus tard, vous verrez si la décision a été bonne ou mauvaise.

Ma règle d'or personnelle pour cette situation est:

  • Si vous concevez une structure de données avec seulement quelques attributs basés sur des types primitifs ou autres types immuables, essayez d'abord l'immuabilité.
  • Si vous souhaitez concevoir un type de données dans lequel des tableaux de grande taille (ou non définis), d'accès aléatoire et de modification de contenu sont impliqués, utilisez la mutabilité.

Pour les situations entre ces deux extrêmes, utilisez votre jugement. Mais YMMV.

60
Doc Brown

Tout d'abord, votre caractérisation des structures de données immuables est imprécise. En général, la plupart d'une structure de données n'est pas copiée, mais partagée , et seules les parties modifiées sont copiées. Il est appelé structure de données persistante . La plupart des implémentations sont capables de tirer parti des structures de données persistantes la plupart du temps. Les performances sont suffisamment proches des structures de données modifiables que les programmeurs fonctionnels considèrent généralement comme négligeables.

Deuxièmement, je trouve que beaucoup de gens ont une idée assez inexacte de la durée de vie typique des objets dans les programmes impératifs typiques. Cela est peut-être dû à la popularité des langues gérées en mémoire. Asseyez-vous parfois et regardez vraiment combien d'objets temporaires et de copies défensives vous créez par rapport à des structures de données à longue durée de vie. Je pense que vous serez surpris du rapport.

J'ai fait remarquer aux gens dans les classes de programmation fonctionnelle que j'enseigne sur la quantité de déchets qu'un algorithme crée, puis je montre la version impérative typique du même algorithme qui crée tout autant. Pour une raison quelconque, les gens ne le remarquent plus.

En encourageant le partage et en décourageant la création de variables jusqu'à ce que vous ayez une valeur valide à y mettre, l'immuabilité a tendance à encourager des pratiques de codage plus propres et des structures de données à durée de vie plus longue. Cela conduit souvent à des niveaux d'ordures comparables, sinon inférieurs, selon votre algorithme.

39
Karl Bielefeldt

Tard dans ce Q&A avec déjà d'excellentes réponses, mais je voulais m'immiscer en tant qu'étranger habitué à regarder les choses du point de vue de niveau inférieur des bits et des octets en mémoire.

Je suis très enthousiasmé par les conceptions immuables, venant même d'une perspective C, et de la perspective de trouver de nouvelles façons de programmer efficacement ce matériel bestial que nous avons de nos jours.

Plus lent/plus rapide

Quant à la question de savoir si cela ralentit les choses, une réponse robotique serait yes. À ce niveau conceptuel très technique, l'immuabilité ne peut que ralentir les choses. Le matériel fait mieux quand il n'alloue pas de mémoire sporadiquement et peut simplement modifier la mémoire existante à la place (pourquoi nous avons des concepts comme la localité temporelle).

Pourtant, une réponse pratique est maybe. Les performances restent largement une mesure de productivité dans toute base de code non triviale. En général, nous ne trouvons pas les bases de code horribles à maintenir trébuchant sur les conditions de concurrence comme étant les plus efficaces, même si nous ignorons les bogues. L'efficacité est souvent fonction de l'élégance et de la simplicité. Le pic des micro-optimisations peut quelque peu entrer en conflit, mais celles-ci sont généralement réservées aux sections de code les plus petites et les plus critiques.

Transformation des bits et octets immuables

Du point de vue de bas niveau, si nous concevons des concepts de rayons X comme objects et strings et ainsi de suite, au cœur de celui-ci se trouvent juste des bits et des octets dans diverses formes de mémoire avec une vitesse différente/caractéristiques de taille (la vitesse et la taille du matériel de mémoire s'excluent généralement mutuellement).

enter image description here

La hiérarchie de la mémoire de l'ordinateur l'aime lorsque nous accédons à plusieurs reprises au même bloc de mémoire, comme dans le diagramme ci-dessus, car elle conservera ce bloc de mémoire fréquemment utilisé dans la forme de mémoire la plus rapide (cache L1, par exemple, qui est presque aussi rapide qu’un registre). Nous pourrions accéder de manière répétée à la même mémoire exacte (en la réutilisant plusieurs fois) ou accéder de manière répétée à différentes sections du bloc (par exemple: parcourir les éléments dans un bloc contigu qui accède à plusieurs reprises à différentes sections de ce bloc de mémoire).

Nous finissons par jeter une clé dans ce processus si la modification de cette mémoire finit par vouloir créer un tout nouveau bloc de mémoire sur le côté, comme ceci:

enter image description here

... dans ce cas, l'accès au nouveau bloc de mémoire peut nécessiter des erreurs de page obligatoires et des échecs de cache pour le replacer dans les formes de mémoire les plus rapides (jusqu'au fond dans un registre). Cela peut être un vrai tueur de performances.

Il existe cependant des moyens de pallier cela, en utilisant un pool de réserve de mémoire préallouée, déjà touché.

Grands agrégats

Un autre problème conceptuel qui découle d'une vue de niveau légèrement supérieur est simplement de faire des copies inutiles de très gros agrégats en vrac.

Pour éviter un diagramme trop complexe, imaginons que ce simple bloc de mémoire était en quelque sorte coûteux (peut-être des caractères UTF-32 sur un matériel incroyablement limité).

enter image description here

Dans ce cas, si nous voulions remplacer "HELP" par "KILL" et que ce bloc de mémoire était immuable, nous devions créer un tout nouveau bloc dans son intégralité pour créer un nouvel objet unique, même si seulement certaines parties de celui-ci ont changé :

enter image description here

Étirer un peu notre imagination, ce genre de copie profonde de tout le reste juste pour rendre une petite partie unique pourrait être assez cher (dans des cas réels, ce bloc de mémoire serait beaucoup, beaucoup plus gros pour poser un problème).

Cependant, malgré une telle dépense, ce type de conception aura tendance à être beaucoup moins sujet aux erreurs humaines. Quiconque a travaillé dans un langage fonctionnel avec des fonctions pures peut probablement l'apprécier, et en particulier dans les cas multithread où nous pouvons multithreader un tel code sans souci dans le monde. En général, les programmeurs humains ont tendance à trébucher sur les changements d'état, en particulier ceux qui provoquent des effets secondaires externes à des états en dehors de la portée d'une fonction actuelle. Même la récupération d'une erreur externe (exception) dans un tel cas peut être incroyablement difficile avec des changements d'état externe mutables dans le mix.

Une façon d'atténuer ce travail de copie redondant consiste à transformer ces blocs de mémoire en une collection de pointeurs (ou références) vers des caractères, comme ceci:

Excuses, je n'ai pas réalisé que nous n'avons pas besoin de rendre L unique lors de la création du diagramme.

Le bleu indique des données copiées peu profondes.

enter image description here

... malheureusement, cela coûterait incroyablement cher de payer un pointeur/coût de référence par personnage. De plus, nous pourrions disperser le contenu des caractères dans tout l'espace d'adressage et finir par le payer sous la forme d'une cargaison de défauts de page et de ratés de cache, ce qui rend cette solution encore pire que de copier le tout dans son intégralité.

Même si nous avons pris soin d'allouer ces caractères de manière contiguë, disons que la machine peut charger 8 caractères et 8 pointeurs vers un caractère dans une ligne de cache. Nous finissons par charger de la mémoire comme ceci pour parcourir la nouvelle chaîne:

enter image description here

Dans ce cas, nous finissons par avoir besoin de 7 lignes de cache différentes d'une valeur de mémoire contiguë à charger pour parcourir cette chaîne, alors que, idéalement, nous n'en avons besoin que de 3.

Chunk Up The Data

Pour atténuer le problème ci-dessus, nous pouvons appliquer la même stratégie de base mais à un niveau plus grossier de 8 caractères, par ex.

enter image description here

Le résultat nécessite le chargement de 4 lignes de cache (1 pour les 3 pointeurs et 3 pour les caractères) pour parcourir cette chaîne qui n'est qu'à 1 de moins de l'optimum théorique.

Ce n'est donc pas mal du tout. Il y a un certain gaspillage de mémoire, mais la mémoire est abondante et en utiliser plus ne ralentit pas les choses si la mémoire supplémentaire est juste des données froides qui ne sont pas fréquemment consultées. C'est uniquement pour les données chaudes et contiguës où l'utilisation et la vitesse réduites de la mémoire vont souvent de pair lorsque nous voulons insérer plus de mémoire sur une seule page ou ligne de cache et y accéder avant l'expulsion. Cette représentation est assez compatible avec le cache.

La vitesse

Ainsi, l'utilisation d'une représentation comme celle ci-dessus peut donner un bon équilibre de performances. Les utilisations les plus critiques des performances des structures de données immuables prendront probablement cette nature de modification de morceaux de données volumineux et de les rendre uniques dans le processus, tout en copiant peu profondément des morceaux non modifiés. Cela implique également une surcharge des opérations atomiques pour référencer les pièces copiées peu profondes en toute sécurité dans un contexte multithread (éventuellement avec un comptage de références atomiques en cours).

Pourtant, tant que ces gros morceaux de données sont représentés à un niveau suffisamment grossier, une grande partie de ces frais généraux diminue et est peut-être même banalisée, tout en nous offrant la sécurité et la facilité de codage et de multithreading de plus de fonctions sous une forme pure sans côté externe effets.

Conservation des données nouvelles et anciennes

Là où je considère l'immuabilité comme potentiellement la plus utile du point de vue des performances (dans un sens pratique), c'est quand nous pouvons être tentés de faire des copies entières de grandes données afin de les rendre uniques dans un contexte mutable où le but est de produire quelque chose de nouveau à partir de quelque chose qui existe déjà d'une manière où nous voulons garder à la fois le nouveau et l'ancien, alors que nous pourrions simplement en faire de petits morceaux uniques avec un design soigné et immuable.

Exemple: Annuler le système

Un exemple de ceci est un système d'annulation. Nous pouvons modifier une petite partie d'une structure de données et vouloir conserver à la fois le formulaire d'origine vers lequel nous pouvons annuler et le nouveau formulaire. Avec ce type de conception immuable qui ne rend uniques que de petites sections modifiées de la structure de données, nous pouvons simplement stocker une copie des anciennes données dans une entrée d'annulation tout en ne payant que le coût de la mémoire des données de portions uniques ajoutées. Cela fournit un équilibre très efficace de productivité (ce qui rend la mise en œuvre d'un système d'annulation un morceau de gâteau) et de performance.

Interfaces de haut niveau

Pourtant, quelque chose de gênant se pose avec le cas ci-dessus. Dans un contexte local de fonction, les données mutables sont souvent les plus faciles et les plus simples à modifier. Après tout, la façon la plus simple de modifier un tableau est souvent de le parcourir et de modifier un élément à la fois. Nous pouvons finir par augmenter les frais généraux intellectuels si nous avions un grand nombre d'algorithmes de haut niveau parmi lesquels choisir pour transformer un tableau et que nous devions choisir celui qui convient pour garantir que toutes ces grosses copies superficielles sont faites pendant que les parties modifiées sont rendu unique.

Le moyen le plus simple dans ces cas est probablement d'utiliser des tampons mutables localement dans le contexte d'une fonction (où ils ne nous déclenchent généralement pas) qui commettent des modifications atomiques dans la structure de données pour obtenir une nouvelle copie immuable (je crois que certaines langues appellent ces "transitoires") ...

... ou nous pourrions simplement modéliser des fonctions de transformation de plus en plus élevées sur les données afin de pouvoir masquer le processus de modification d'un tampon mutable et de le valider dans la structure sans logique mutable impliquée. En tout cas, ce n'est pas encore un territoire largement exploré, et nous avons du pain sur la planche si nous adoptons davantage des conceptions immuables pour trouver des interfaces significatives sur la façon de transformer ces structures de données.

Structures de données

Une autre chose qui se pose ici est que l'immuabilité utilisée dans un contexte critique pour les performances voudra probablement que les structures de données se décomposent en données volumineuses où les morceaux ne sont pas trop petits mais également pas trop gros.

Les listes liées peuvent vouloir changer un peu pour s'adapter à cela et se transformer en listes non déroulées. Les grands tableaux contigus peuvent se transformer en un tableau de pointeurs en segments contigus avec indexation modulo pour un accès aléatoire.

Cela change potentiellement la façon dont nous considérons les structures de données d'une manière intéressante, tout en poussant les fonctions de modification de ces structures de données à ressembler à une nature plus volumineuse pour masquer la complexité supplémentaire de la copie superficielle de certains bits ici et de rendre d'autres bits uniques là-bas.

Performance

Quoi qu'il en soit, c'est ma petite vue de bas niveau sur le sujet. Théoriquement, l'immuabilité peut avoir un coût allant de très grand à plus petit. Mais une approche très théorique ne fait pas toujours aller vite les applications. Cela peut les rendre évolutifs, mais la vitesse du monde réel nécessite souvent d'adopter un état d'esprit plus pratique.

D'un point de vue pratique, des qualités comme les performances, la maintenabilité et la sécurité ont tendance à se transformer en un seul gros flou, en particulier pour une très grande base de code. Bien que les performances dans un sens absolu soient dégradées par l'immuabilité, il est difficile de discuter des avantages qu'il a sur la productivité et la sécurité (y compris la sécurité des threads). Une augmentation de ceux-ci peut souvent entraîner une augmentation des performances pratiques, ne serait-ce que parce que les développeurs ont plus de temps pour régler et optimiser leur code sans être envahis par des bogues.

Donc, je pense que de ce sens pratique, les structures de données immuables pourraient en fait aide les performances dans de nombreux cas, aussi étrange que cela puisse paraître. Un monde idéal pourrait rechercher un mélange de ces deux: structures de données immuables et mutables, les mutables étant généralement très sûres à utiliser dans une portée très locale (ex: local à une fonction), tandis que les immuables peuvent éviter le côté externe affecte directement et transforme toutes les modifications apportées à une structure de données en une opération atomique produisant une nouvelle version sans risque de conditions de concurrence.

34
user204677

ImmutableJS est en fait assez efficace. Si nous prenons un exemple:

var x = {
    Foo: 1,
    Bar: { Baz: 2 }
    Qux: { AnotherVal: 3 }
}

Si l'objet ci-dessus est rendu immuable, vous modifiez la valeur de la propriété 'Baz' ce que vous obtiendrez est:

var y = x.setIn('/Bar/Baz', 3);
y !== x; // Different object instance
y.Bar !== x.Bar // As the Baz property was changed, the Bar object is a diff instance
y.Qux === y.Qux // Qux is the same object instance

Cela crée des améliorations de performances vraiment cool pour les modèles d'objets profonds, où vous n'avez qu'à copier les types de valeur sur les objets sur le chemin d'accès à la racine. Plus le modèle d'objet est grand et plus les modifications que vous effectuez sont petites, meilleures sont les performances de la mémoire et du processeur de la structure de données immuable car elles finissent par partager de nombreux objets.

Comme les autres réponses l'ont dit, si vous comparez cela à essayer de fournir les mêmes garanties en copiant défensivement x avant de le passer dans une fonction qui pourrait le manipuler, les performances sont nettement meilleures.

11
Bringer128

Pour ajouter à cette question (déjà très bien répondu):

La réponse courte est oui; cela nuira aux performances car vous ne créez que des objets au lieu de muter les objets existants, ce qui entraîne une surcharge de création d'objets.


Cependant, la réponse longue est pas vraiment.

D'un point de vue réel de l'exécution, en JavaScript, vous créez déjà pas mal d'objets d'exécution - les fonctions et les littéraux d'objet sont partout dans JavaScript et personne ne semble réfléchir à deux fois avant de les utiliser. Je dirais que la création d'objets est en fait assez bon marché, même si je n'ai aucune citation pour cela, donc je ne l'utiliserais pas comme argument autonome.

Pour moi, la plus grande augmentation des "performances" n'est pas dans les performances d'exécution mais dans les performances développeur. L'une des premières choses que j'ai apprises en travaillant sur des applications du monde réel (tm) est que la mutabilité est vraiment dangereuse et déroutante. J'ai perdu de nombreuses heures à pourchasser un thread (pas le type concurrent) d'exécution en essayant de trouver ce qui cause un bug obscur quand il s'avère être une mutation de l'autre côté de la fichue application!

L'utilisation de l'immuabilité rend les choses beaucoup plus faciles à raisonner. Vous pouvez savoir immédiatement que l'objet X est pas va changer au cours de sa vie, et la seule façon pour lui de changer est de le cloner. J'apprécie beaucoup plus cela (en particulier dans les environnements d'équipe) que toute micro-optimisation que la mutabilité pourrait apporter.

Il existe des exceptions, notamment les structures de données, comme indiqué ci-dessus. Je suis rarement tombé sur un scénario où j'ai voulu modifier une carte après sa création (bien qu'il soit vrai que je parle de cartes pseudo-objet-littérales plutôt que de cartes ES6), de même pour les tableaux. Lorsque vous traitez avec des structures de données plus grandes, la mutabilité peut être payante. N'oubliez pas que chaque objet en JavaScript est transmis comme référence plutôt que comme valeur.


Cela dit, un point soulevé ci-dessus était le GC et son incapacité à détecter les doublons. C'est une préoccupation légitime, mais à mon avis, ce n'est une préoccupation que lorsque la mémoire est une préoccupation, et il existe des moyens beaucoup plus faciles de vous coder dans un coin - par exemple, les références circulaires dans les fermetures.


En fin de compte, je préférerais avoir une base de code immuable avec très quelques sections (voire aucune) mutables et être légèrement moins performant que d'avoir une mutabilité partout. Vous pouvez toujours optimiser plus tard si l'immuabilité, pour une raison quelconque, devient un souci de performance.

5
Dan Pantry

En ligne droite, le code immuable a la surcharge de création d'objet, qui est plus lente. Cependant, il existe de nombreuses situations où le code mutable devient très difficile à gérer efficacement (ce qui entraîne de nombreuses copies défensives, ce qui est également coûteux), et il existe de nombreuses stratégies intelligentes pour atténuer le coût de la `` copie '' d'un objet. , comme mentionné par d'autres.

Si vous avez un objet tel qu'un compteur et qu'il est incrémenté plusieurs fois par seconde, le fait que ce compteur soit immuable pourrait ne pas valoir la peine de performances. Si vous avez un objet qui est lu par de nombreuses parties différentes de votre application, et que chacun d'eux veut avoir son propre clone légèrement différent de l'objet, vous aurez beaucoup plus de facilité à l'orchestrer de manière performante en utilisant un bon implémentation d'objet immuable.

4
Dogs