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.