TL; DR La boucle plus lente est due à l'accès au tableau `` hors limites '', ce qui oblige le moteur à recompiler la fonction avec moins ou même pas d'optimisations OU à ne pas compiler la fonction avec l'une de ces optimisations pour commencer ( si le compilateur (JIT-) a détecté / suspecté cette condition avant la première compilation 'version'), lisez ci-dessous pourquoi;
Quelqu'un vient
a à dire (tout à fait étonné que personne ne l'a déjà fait):
Il y avait un moment où l'extrait de OP serait un exemple de fait dans une programmation débutant livre destiné à grandes lignes / souligner que « tableaux » en javascript sont départ indexé à 0, pas 1, et en tant que tel être utilisé comme exemple d'une «erreur de débutant» courante (n'aimez-vous pas comment j'ai évité l'expression «erreur de programmation»
;)
):
accès au tableau hors limites .
Exemple 1:
un Dense Array
(étant contigu (signifie sans espaces entre les index) ET en fait un élément à chaque index) de 5 éléments utilisant une indexation basée sur 0 (toujours dans ES262).
var arr_five_char=['a', 'b', 'c', 'd', 'e']; // arr_five_char.length === 5
// indexes are: 0 , 1 , 2 , 3 , 4 // there is NO index number 5
Ainsi, nous ne parlons pas vraiment de différence de performance entre <
vs <=
(ou «une itération supplémentaire»), mais nous parlons:
«pourquoi l'extrait correct (b) est-il plus rapide que l'extrait erroné (a)»?
La réponse est double (bien que du point de vue d'un réalisateur de langage ES262, les deux sont des formes d'optimisation):
- Représentation de données: comment représenter / stocker le tableau en interne en mémoire (objet, hashmap, tableau numérique `` réel '', etc.)
- Code-machine fonctionnel: comment compiler le code qui accède / gère (lire / modifier) ces 'tableaux'
Le point 1 est suffisamment (et correctement IMHO) expliqué par la réponse acceptée , mais cela ne passe que 2 mots («le code») sur le point 2: compilation .
Plus précisément: JIT-Compilation et encore plus JIT- RE -Compilation!
La spécification du langage est essentiellement une description d'un ensemble d'algorithmes («étapes à effectuer pour obtenir un résultat final défini»). Ce qui, en fait, est une très belle façon de décrire une langue. Et cela laisse la méthode utilisée par un moteur pour obtenir des résultats spécifiés ouverte aux implémenteurs, ce qui donne amplement l'occasion de trouver des moyens plus efficaces pour produire des résultats définis. Un moteur conforme aux spécifications doit donner des résultats conformes aux spécifications pour toute entrée définie.
Maintenant, avec l'augmentation du code javascript / bibliothèques / utilisation et en se souvenant de la quantité de ressources (temps / mémoire / etc) qu'un `` vrai '' compilateur utilise, il est clair que nous ne pouvons pas obliger les utilisateurs visitant une page Web à attendre aussi longtemps (et à en avoir besoin d'avoir autant de ressources disponibles).
Imaginez la fonction simple suivante:
function sum(arr){
var r=0, i=0;
for(;i<arr.length;) r+=arr[i++];
return r;
}
Parfaitement clair, non? Ne nécessite AUCUNE clarification supplémentaire, non? Le type de retour est Number
, non?
Eh bien ... non, non et non ... Cela dépend de l'argument que vous passez au paramètre de fonction nommée arr
...
sum('abcde'); // String('0abcde')
sum([1,2,3]); // Number(6)
sum([1,,3]); // Number(NaN)
sum(['1',,3]); // String('01undefined3')
sum([1,,'3']); // String('NaN3')
sum([1,2,{valueOf:function(){return this.val}, val:6}]); // Number(9)
var val=5; sum([1,2,{valueOf:function(){return val}}]); // Number(8)
Vous voyez le problème? Alors considérez que c'est à peine gratter les permutations massives possibles ... Nous ne savons même pas quel type de TYPE la fonction RETURN jusqu'à ce que nous ayons terminé ...
Maintenant, imaginez que ce même code de fonction soit réellement utilisé sur différents types ou même des variations d'entrée, à la fois complètement littéralement (dans le code source) décrites et dynamiquement générées dans le programme.
Ainsi, si vous deviez compiler la fonction sum
JUSTE UNE FOIS, alors la seule façon qui renvoie toujours le résultat défini par les spécifications pour tous les types d'entrée, alors, évidemment, seulement en exécutant TOUTES les sous-étapes principales ET prescrites par les spécifications peuvent garantir des résultats conformes aux spécifications. (comme un navigateur pré-y2k sans nom). Aucune optimisation (car aucune hypothèse) et le langage de script interprété très lent reste.
JIT-Compilation (JIT comme dans Just In Time) est la solution actuellement populaire.
Ainsi, vous commencez à compiler la fonction en utilisant des hypothèses concernant ce qu'elle fait, retourne et accepte.
vous proposez des vérifications aussi simples que possible pour détecter si la fonction peut commencer à renvoyer des résultats non conformes aux spécifications (comme parce qu'elle reçoit une entrée inattendue). Ensuite, jetez le résultat compilé précédent et recompilez-le en quelque chose de plus élaboré, décidez quoi faire avec le résultat partiel que vous avez déjà (est-il valide d'être approuvé ou de calculer à nouveau pour être sûr), reliez la fonction au programme et réessayer. En fin de compte, revenir à l'interprétation de script pas à pas comme dans les spécifications
Tout cela prend du temps!
Tous les navigateurs fonctionnent sur leurs moteurs, pour chaque sous-version, vous verrez les choses s'améliorer et régresser. Les chaînes étaient à un moment donné de l'histoire des chaînes vraiment immuables (donc array.join était plus rapide que la concaténation de chaînes), maintenant nous utilisons des cordes (ou similaires) qui atténuent le problème. Les deux renvoient des résultats conformes aux spécifications et c'est ce qui compte!
Pour faire court: ce n'est pas parce que la sémantique du langage javascript nous a souvent soutenu (comme avec ce bogue silencieux dans l'exemple de l'OP) que des erreurs «stupides» augmentent nos chances que le compilateur crache du code machine rapide. Cela suppose que nous avons écrit les instructions `` généralement '' correctes: le mantra actuel que nous `` utilisateurs '' (du langage de programmation) devons avoir est: aider le compilateur, décrire ce que nous voulons, privilégier les idiomes courants (prendre des indices d'asp.js pour une compréhension de base ce que les navigateurs peuvent essayer d'optimiser et pourquoi).
Pour cette raison, parler de performance est à la fois important MAIS AUSSI un champ de mines (et à cause de ce champ de mines, je veux vraiment terminer en pointant (et en citant) des informations pertinentes:
L'accès aux propriétés d'objet inexistantes et aux éléments de tableau hors limites renvoie la undefined
valeur au lieu de déclencher une exception. Ces fonctionnalités dynamiques rendent la programmation en JavaScript pratique, mais elles rendent également difficile la compilation de JavaScript en code machine efficace.
...
Une prémisse importante pour une optimisation JIT efficace est que les programmeurs utilisent les fonctionnalités dynamiques de JavaScript de manière systématique. Par exemple, les compilateurs JIT exploitent le fait que les propriétés d'objet sont souvent ajoutées à un objet d'un type donné dans un ordre spécifique ou que les accès aux tableaux hors limites se produisent rarement. Les compilateurs JIT exploitent ces hypothèses de régularité pour générer un code machine efficace lors de l'exécution. Si un bloc de code satisfait les hypothèses, le moteur JavaScript exécute un code machine efficace et généré. Sinon, le moteur doit revenir à un code plus lent ou à interpréter le programme.
Source:
«JITProf: Pinpointing JIT-Unfriendly JavaScript Code»
, publication Berkeley, 2014, par Liang Gong, Michael Pradel, Koushik Sen.
http://software-lab.org/publications/jitprof_tr_aug3_2014.pdf
ASM.JS (n'aime pas non plus l'accès hors tableau lié):
Compilation d'avance
Comme asm.js est un sous-ensemble strict de JavaScript, cette spécification définit uniquement la logique de validation - la sémantique d'exécution est simplement celle de JavaScript. Cependant, asm.js validé peut être compilé à l'avance (AOT). De plus, le code généré par un compilateur AOT peut être assez efficace, avec:
- représentations sans boîte d'entiers et de nombres à virgule flottante;
- absence de vérification du type d'exécution;
- absence de collecte des ordures; et
- charges de tas et magasins efficaces (avec des stratégies de mise en œuvre variant selon la plate-forme).
Le code dont la validation échoue doit revenir à l'exécution par des moyens traditionnels, par exemple, interprétation et / ou compilation juste à temps (JIT).
http://asmjs.org/spec/latest/
et enfin https://blogs.windows.com/msedgedev/2015/05/07/bringing-asm-js-to-chakra-microsoft-edge/
il y a une petite sous-section sur les améliorations des performances internes du moteur lors de la suppression des limites- check (tout en soulevant simplement les limites-check en dehors de la boucle avait déjà une amélioration de 40%).
EDIT:
notez que plusieurs sources parlent de différents niveaux de JIT-Recompilation jusqu'à l'interprétation.
Exemple théorique basé sur les informations ci-dessus, concernant l'extrait de l'OP:
- Appel à isPrimeDivisible
- Compilez isPrimeDivisible en utilisant des hypothèses générales (comme aucun accès hors limites)
- Faire du travail
- BAM, tout à coup le tableau accède hors limites (juste à la fin).
- Merde, dit le moteur, recompilons ce isPrimeDivisible en utilisant différentes hypothèses (moins), et cet exemple de moteur n'essaie pas de déterminer s'il peut réutiliser le résultat partiel actuel, donc
- Recalculez tout le travail en utilisant une fonction plus lente (j'espère que cela se termine, sinon répétez et cette fois, interprétez simplement le code).
- Résultat de retour
Le temps était donc:
Première exécution (échec à la fin) + refaire tout le travail en utilisant un code machine plus lent pour chaque itération + la recompilation, etc. prend clairement> 2 fois plus de temps dans cet exemple théorique !
EDIT 2: (avertissement: conjecture basée sur les faits ci-dessous)
Plus j'y pense, plus je pense que cette réponse pourrait en fait expliquer la raison la plus dominante de cette `` pénalité '' sur l'extrait erroné a (ou bonus de performance sur l'extrait b , en fonction de la façon dont vous y pensez), précisément pourquoi j'adore l'appeler (extrait a) une erreur de programmation:
Il est assez tentant de supposer qu'il this.primes
s'agit d'un `` tableau dense '' numérique pur qui était soit
- Littéral codé en dur dans le code source (excellent candidat connu pour devenir un tableau `` réel '' car tout est déjà connu du compilateur avant la compilation) OU
- très probablement généré à l'aide d'une fonction numérique remplissant un pré-dimensionné (
new Array(/*size value*/)
) dans un ordre séquentiel croissant (un autre candidat connu depuis longtemps pour devenir un tableau «réel»).
Nous savons également que la primes
longueur du tableau est mise en cache comme prime_count
! (indiquant son intention et sa taille fixe).
Nous savons également que la plupart des moteurs transmettent initialement les tableaux en tant que copie lors de la modification (si nécessaire), ce qui rend leur gestion beaucoup plus rapide (si vous ne les modifiez pas).
Il est donc raisonnable de supposer que Array primes
est très probablement déjà un tableau optimisé en interne qui ne sera pas changé après la création (simple à savoir pour le compilateur s'il n'y a pas de code modifiant le tableau après la création) et donc déjà (si applicable à le moteur) stocké de manière optimisée, à peu près comme s'il s'agissait d'un fichier Typed Array
.
Comme j'ai essayé de le clarifier avec mon sum
exemple de fonction, le ou les arguments qui sont passés influencent fortement ce qui doit réellement se produire et, en tant que tel, comment ce code particulier est compilé en code machine. Passer un String
à la sum
fonction ne devrait pas changer la chaîne mais changer la façon dont la fonction est compilée en JIT! Passer un tableau à sum
devrait compiler une version différente (peut-être même supplémentaire pour ce type, ou «forme» comme on l'appelle, de l'objet qui a été passé) du code machine.
Comme il semble un peu bonkus de convertir le tableau de type Typed_Array primes
à la volée en quelque chose_else alors que le compilateur sait que cette fonction ne va même pas le modifier!
Sous ces hypothèses cela laisse 2 options:
- Compilez comme number-cruncher en supposant qu'il n'y a pas de hors-limites, rencontrez un problème de hors-limites à la fin, recompilez et refaites le travail (comme indiqué dans l'exemple théorique de l'édition 1 ci-dessus)
- Le compilateur a déjà détecté (ou suspecté?) Un accès lié à l'avance et la fonction a été compilée par JIT comme si l'argument passé était un objet clairsemé entraînant un code machine fonctionnel plus lent (car il aurait plus de vérifications / conversions / coercitions etc.). En d'autres termes: la fonction n'a jamais été éligible pour certaines optimisations, elle a été compilée comme si elle recevait un argument 'sparse array' (- like).
Je me demande maintenant vraiment lequel de ces 2 il s'agit!
<=
et<
est identique, à la fois en théorie et en implémentation réelle dans tous les processeurs (et interprètes) modernes.