Comparaison des performances : les boucles et les itérateurs
Pour déterminer s'il faut utiliser des boucles ou des itérateurs, nous devons
savoir quelle implémentation est la plus rapide : la version de la fonction
rechercher
avec une boucle for
explicite, ou la version avec des
itérateurs.
Nous avons lancé un benchmark en chargeant tout le contenu de The Adventures
of Sherlock Holmes de Sir Arthur Conan Doyle dans une String
et en cherchant
le mot "the" dans le contenu. Voici les résultats du benchmark sur la version
de rechercher
avec une boucle for
et avec un itérateur :
test benchmark_rechercher_for ... bench: 19,620,300 ns/iter (+/- 915,700)
test benchmark_rechercher_iter ... bench: 19,234,900 ns/iter (+/- 657,200)
La version avec l'itérateur était un peu plus rapide ! Nous n'expliquerons pas le code du benchmark ici, car il ne s'agit pas de prouver que les deux versions sont équivalentes, mais d'avoir une idée générale de la différence de performances entre les deux.
Pour un benchmark plus complet, nous vous conseillons d'utiliser des textes de
différentes tailles pour contenu
, des mots différents et de différentes
longueurs pour recherche
, ainsi que tout autre type de variation que vous
pourriez trouver. Le point important est le suivant : les itérateurs, bien qu'il
s'agisse d'une abstraction de haut niveau, sont compilés à peu près comme si
vous aviez écrit vous-même le code un niveau plus bas. Les itérateurs sont l'une
des abstractions à coût zéro de Rust, c'est-à-dire que l'utilisation de
l'abstraction n'impose aucun surcoût lors de l'exécution. C'est la même notion
que celle que Bjarne Stroustrup, le concepteur et développeur original de C++,
définit en tant que coût zéro dans “Foundations of C++” (2012) :
En général, les implémentations de C++ obéissent au principe du coût zéro : ce que vous n'utilisez pas, vous ne le payez pas. Et plus encore : ce que vous utilisez, vous ne pourrez pas le coder mieux à la main.
Comme autre exemple, le code suivant est tiré d'un décodeur audio. L'algorithme
de décodage utilise l'opération mathématique de prédiction linéaire pour
estimer les valeurs futures à partir d'une fonction linéaire des échantillons
précédents. Ce code utilise une chaîne d'itérateurs pour faire quelques calculs
sur trois variables dans la portée : une slice de données tampon
, un tableau
de 12 coefficients
et une valeur de décalage des données dans decalage
.
Nous avons déclaré les variables dans cet exemple, mais nous ne leur avons pas
donné de valeurs ; bien que ce code n'ait pas beaucoup de signification en
dehors de son contexte, c'est toutefois un exemple concis et concret de la façon
dont Rust traduit des idées de haut niveau en code de plus bas niveau.
let tampon: &mut [i32];
let coefficients: [i64; 12];
let decalage: i16;
for i in 12..tampon.len() {
let prediction = coefficients.iter()
.zip(&tampon[i - 12..i])
.map(|(&c, &s)| c * s as i64)
.sum::<i64>() >> decalage;
let delta = tampon[i];
tampon[i] = prediction as i32 + delta;
}
Pour calculer la valeur de prediction
, ce code itère sur chacune des 12
valeurs dans coefficients
et utilise la méthode zip
pour appairer la
valeur de coefficient avec les 12 valeurs précédentes, présentes dans tampon
.
Ensuite, pour chaque paire, nous multiplions les valeurs ensemble, nous
additionnons tous les résultats et nous décalons les bits de l'addition de la
valeur de decalage
vers la droite.
Les calculs dans des applications comme les décodeurs audio donnent souvent la
priorité aux performances. Ici, nous créons un itérateur à l'aide de deux
adaptateurs, puis nous en consommons la valeur. A quel code d'assemblage
ce code Rust ressemblera-t-il une fois compilé ? Et bien, à l'heure
où nous écrivons ces lignes, il donne le même code assembleur que vous
écririez à la main. Il n'y a pas du tout de boucle correspondant à l'itération
sur les valeurs dans coefficients
: Rust sait qu'il y a 12 itérations, donc il
“déroule” la boucle. Le déroulage est une optimisation qui supprime la
surcharge du code de contrôle de boucle et génère à la place du code répété pour
chaque itération de la boucle.
Tous les coefficients sont stockés dans des registres, ce qui signifie qu'il est très rapide d'accéder à ces valeurs. Il n'y a pas de vérification des bornes sur les accès au tableau à l'exécution. Toutes ces optimisations que Rust est capable d'appliquer rendent le code produit extrêmement efficace. Maintenant que vous savez cela, vous pouvez utiliser des itérateurs et des fermetures sans crainte ! Ils font en sorte que le code soit de haut niveau, mais n'entraînent pas de pénalité de performance à l'exécution.
Résumé
Les fermetures et les itérateurs sont des fonctionnalités de Rust inspirées par des idées des langages de programmation fonctionnels. Ils contribuent à la capacité de Rust d'exprimer clairement des idées de haut niveau avec des performances dignes d'un langage de bas niveau. Les implémentations des fermetures et des itérateurs sont telles que les performances à l'exécution n'en sont pas affectées. Cela fait partie de l'objectif de Rust de s'efforcer à fournir des abstractions à coût zéro.
Maintenant que nous avons amélioré l'expressivité de notre projet
d'entrée/sortie, regardons d'autres fonctionnalités fournies par cargo
qui
nous aideront à partager notre projet avec le monde entier.