Les principales raisons pour lesquelles ces deux exemples sont si différents en termes de vitesse sont les suivantes :
- le plus rapide n'utilise pas de générique, donc il n'est pas confronté au boxing/unboxing.
- le plus rapide ne crée pas de collections temporaires et évite donc les copies de mémoire supplémentaires.
Considérons les ralentisseurs un par un. D'abord :
first.zip(second)
Cela crée un nouveau tableau, un tableau de Tuple2
. Il va copier tous les éléments des deux tableaux dans Tuple2
puis copier une référence à chacun de ces objets dans un troisième tableau. Maintenant, remarquez que Tuple2
est paramétrée, donc elle ne peut pas enregistrer Float
directement. Au lieu de cela, de nouvelles instances de java.lang.Float
sont créés pour chaque numéro, les numéros y sont stockés, puis une référence pour chacun d'entre eux est stockée dans le fichier Tuple2
.
map{ case (a,b) => a*b }
Maintenant, un quatrième tableau est créé. Pour calculer les valeurs de ces éléments, il faut lire la référence au tuple du troisième tableau, lire la référence à l'élément java.lang.Float
stockés, lire les nombres, les multiplier, créer un nouveau java.lang.Float
pour stocker le résultat, et ensuite renvoyer cette référence, qui sera de -référencé à nouveau pour être stocké dans le tableau (les tableaux ne sont pas effacés par type).
Mais nous n'avons pas fini. Voici la prochaine partie :
reduceLeft(_+_)
Celui-là est relativement inoffensif, sauf qu'il fait toujours le boxing/unboxing et java.lang.Float
à l'itération, puisque reduceLeft
reçoit un Function2
qui est paramétrée.
Scala 2.8 introduit une fonctionnalité appelée spécialisation qui permettra de se débarrasser d'une grande partie de ces boîtes et découpes. Mais considérons des versions alternatives plus rapides. Nous pourrions, par exemple, faire map
y reduceLeft
en une seule étape :
sum = first.zip(second).foldLeft(0f) { case (a, (b, c)) => a + b * c }
Nous pourrions utiliser view
(Scala 2.8) ou projection
(Scala 2.7) pour éviter de créer des collections intermédiaires :
sum = first.view.zip(second).map{ case (a,b) => a*b }.reduceLeft(_+_)
Cette dernière n'économise pas beaucoup, en fait, donc je pense que la non-strictivité est "perdue" assez rapidement (c'est-à-dire que l'une de ces méthodes est stricte même dans une vue). Il y a aussi une autre façon de zipper qui est non stricte (c'est-à-dire qui évite certains résultats intermédiaires) par défaut :
sum = (first,second).zipped.map{ case (a,b) => a*b }.reduceLeft(_+_)
Cette méthode donne de bien meilleurs résultats que la précédente. Mieux que la foldLeft
un, mais pas de beaucoup. Malheureusement, nous ne pouvons pas combiner zipped
con foldLeft
parce que le premier ne soutient pas le second.
Le dernier est le plus rapide que j'ai pu obtenir. Plus rapide que ça, seulement avec la spécialisation. Maintenant, Function2
se trouve être spécialisée, mais pour Int
, Long
y Double
. Les autres primitives ont été laissées de côté, car la spécialisation augmente la taille du code de manière assez spectaculaire pour chaque primitive. Sur mes tests, cependant Double
prend en fait plus de temps. Cela peut être dû au fait qu'il est deux fois plus grand, ou à quelque chose que je fais mal.
Donc, en fin de compte, le problème est une combinaison de facteurs, y compris la production de copies intermédiaires d'éléments, et la façon dont Java (JVM) gère les primitives et les génériques. Un code similaire en Haskell utilisant la supercompilation serait égal à tout ce qui n'est pas de l'assembleur. Sur la JVM, il faut être conscient des compromis et être prêt à optimiser le code critique.