Aller au contenu

La performance en Rust

Rust a la réputation d’être rapide, et il la mérite. Mais « Rust est rapide » ne veut pas dire que tout programme Rust est rapide. Un algorithme lent est lent dans n’importe quel langage, et quelques mauvaises habitudes peuvent vous coûter cher sans que vous le remarquiez.

La bonne nouvelle : la plupart des gros gains sont simples. Compilez en mode release. Mesurez avant de changer quoi que ce soit. Ne copiez pas des données que vous voulez seulement lire. Choisissez la bonne collection. Cette page passe en revue chacun de ces points, avec de vraies mesures quand elles sont utiles. C’est comme régler un vélo : vérifiez d’abord que les pneus sont gonflés (le mode release), et ensuite seulement, pensez aux pièces plus légères.

Avant de rendre quoi que ce soit plus rapide, il faut savoir à quel point c’est lent. std::time::Instant est un chronomètre intégré à la bibliothèque standard. Appelez Instant::now() avant le travail, puis .elapsed() après :

use std::time::Instant;
fn main() {
let start = Instant::now();
// Générer 5 millions de nombres pseudo-aléatoires avec un simple xorshift.
let mut x: u64 = 12345;
let mut numbers = Vec::with_capacity(5_000_000);
for _ in 0..5_000_000 {
x ^= x << 13;
x ^= x >> 7;
x ^= x << 17;
numbers.push(x % 1_000_000);
}
numbers.sort();
let sum: u64 = numbers.iter().take(1000).sum();
println!("somme des 1000 plus petits : {sum}");
println!("durée : {:?}", start.elapsed());
}

Le format {:?} affiche une Duration de façon lisible, par exemple 250.271745ms. Il n’y a volontairement pas de bouton « Essayer » ici : les temps changent à chaque exécution et sur chaque machine, on ne peut donc pas les comparer à une sortie fixe. Lancez plutôt ce programme sur votre ordinateur avec cargo run.

C’est le conseil de performance le plus important en Rust, et il tient en une option.

Terminal window
cargo run # build debug : compile vite, s'exécute lentement
cargo run --release # build release : compile plus lentement, s'exécute beaucoup plus vite

Un build debug saute la plupart des optimisations : il compile vite et reste facile à déboguer. Il vérifie aussi les dépassements d’entiers (integer overflow). Un build release, lui, active l’optimiseur à fond.

Voici un exemple de mesure obtenu en lançant trois fois le programme ci-dessus dans chaque mode, sur la machine qui nous a servi à écrire cette page (un serveur cloud avec un processeur Intel Xeon). Vos chiffres seront différents ; seul l’écart entre eux compte :

build debug : 5.02s, 4.27s, 4.83s
build release : 250ms, 297ms, 265ms

C’est environ 15 à 20 fois plus rapide, grâce à une seule option. L’écart dépend beaucoup du code (les boucles et les itérateurs y gagnent le plus), mais il n’est jamais assez petit pour être ignoré.

La règle est donc simple : ne jugez jamais la vitesse sur un build debug. Si quelqu’un dit « Rust est lent » après avoir chronométré cargo run, c’est presque toujours pour cette raison.

.clone() fait une copie complète. Pour une String ou un Vec, cela veut dire une nouvelle allocation sur le tas (heap) et la copie de chaque octet. Parfois, vous avez besoin d’une copie, mais souvent vous voulez seulement lire les données, et un emprunt (borrow) ne coûte rien.

Le cas le plus courant concerne les paramètres de fonction. Si une fonction ne fait que lire une chaîne, prenez un &str, pas une String :

fn count_long_words(text: &str, min_len: usize) -> usize {
text.split_whitespace().filter(|w| w.len() >= min_len).count()
}
fn main() {
let owned = String::from("the quick brown fox jumps over the lazy dog");
// &String devient &str automatiquement, aucune copie nécessaire.
println!("{}", count_long_words(&owned, 5));
// Une chaîne littérale est déjà un &str.
println!("{}", count_long_words("borrowing is cheap and cloning is not", 6));
// Nous n'avons jamais cédé `owned`, elle reste donc utilisable.
println!("{owned}");
}

Sortie :

3
2
the quick brown fox jumps over the lazy dog
▶ Essayez par vous-même

Les phrases sont restées en anglais exprès : w.len() compte des octets, et une lettre accentuée comme « é » en occupe deux, ce qui fausserait le comptage des mots longs.

Un paramètre &str accepte à la fois une String (empruntée avec &) et une chaîne littérale. Un paramètre String obligerait l’appelant à céder sa chaîne ou à la cloner. La même idée vaut pour les listes : prenez &[T] plutôt que Vec<T>. Voir Borrowing (emprunt) et Slices pour les détails.

Méfiez-vous aussi des clones dans les boucles. for name in names.clone() copie toute la liste juste pour la parcourir. for name in &names l’emprunte gratuitement.

Quand un Vec manque de place, il alloue un bloc plus grand et y déplace tout son contenu. Si vous connaissez la taille à l’avance, Vec::with_capacity réserve la place en une seule fois. Ce programme compte combien de fois chaque Vec a dû grandir pendant qu’on y ajoutait 1000 nombres :

fn count_growths(mut v: Vec<u32>, n: u32) -> u32 {
let mut growths = 0;
let mut cap = v.capacity();
for i in 0..n {
v.push(i);
if v.capacity() != cap {
growths += 1;
cap = v.capacity();
}
}
growths
}
fn main() {
println!("Vec::new() : a grandi {} fois", count_growths(Vec::new(), 1000));
println!("Vec::with_capacity() : a grandi {} fois", count_growths(Vec::with_capacity(1000), 1000));
}

Sortie :

Vec::new() : a grandi 9 fois
Vec::with_capacity() : a grandi 0 fois
▶ Essayez par vous-même

Neuf agrandissements, ce n’est pas une catastrophe : le Vec double de taille à chaque fois, donc cela reste peu coûteux. Mais dans une boucle critique qui construit des millions de vecteurs, cela finit par compter. (La façon exacte dont un Vec grandit est un détail interne de la bibliothèque standard et pourrait changer.)

Encore plus simple : laissez collect() et join() faire le travail. Ils connaissent souvent la taille finale et n’allouent qu’une fois :

fn main() {
// collect() construit le Vec d'un seul coup.
let squares: Vec<u64> = (1..=5).map(|n| n * n).collect();
println!("{squares:?}");
// join() calcule d'abord la longueur totale, puis copie une seule fois.
let colors = ["rouge", "vert", "bleu"];
println!("{}", colors.join(", "));
}

Sortie :

[1, 4, 9, 16, 25]
rouge, vert, bleu
▶ Essayez par vous-même

Vous pourriez craindre que .iter().filter().map().sum() soit plus lent qu’une simple boucle, car cela ressemble à beaucoup d’appels de fonctions. Ce n’est pas le cas. Dans un build release, le compilateur intègre (inline) toutes ces petites closures, et vous obtenez le même code machine compact qu’avec une boucle écrite à la main. Rust appelle cela une abstraction à coût zéro (zero-cost abstraction) : vous ne payez rien pour ce style plus agréable.

fn sum_even_squares_loop(nums: &[u64]) -> u64 {
let mut total = 0;
for i in 0..nums.len() {
if nums[i] % 2 == 0 {
total += nums[i] * nums[i];
}
}
total
}
fn sum_even_squares_iter(nums: &[u64]) -> u64 {
nums.iter().filter(|&&n| n % 2 == 0).map(|&n| n * n).sum()
}
fn main() {
let nums: Vec<u64> = (1..=10).collect();
println!("boucle : {}", sum_even_squares_loop(&nums));
println!("itérateur : {}", sum_even_squares_iter(&nums));
}

Sortie :

boucle : 220
itérateur : 220
▶ Essayez par vous-même

La version avec itérateur peut même être un peu plus rapide. Avec nums[i], Rust vérifie à chaque accès que i se trouve bien dans la slice. L’itérateur sait déjà qu’il ne peut pas sortir des limites, donc ces vérifications disparaissent. Utilisez ce qui se lit le mieux, en général l’itérateur.

Choisir la bonne structure de données rapporte souvent plus que n’importe quel réglage fin. Un exemple classique : vérifier de nombreuses fois « cet élément est-il dans ma liste ? ».

  • Vec::contains regarde chaque élément, un par un. Les recherches ralentissent à mesure que la liste grandit.
  • HashSet::contains (et HashMap::get) va presque directement à la réponse. Les recherches restent rapides, quelle que soit la taille.
use std::collections::HashSet;
fn main() {
let banned: Vec<u32> = (0..20_000).map(|n| n * 3).collect();
let queries: Vec<u32> = (0..20_000).collect();
// Lent pour les grandes listes : chaque contains() parcourt tout le Vec.
let hits_vec = queries.iter().filter(|q| banned.contains(q)).count();
// Rapide : on construit un HashSet une fois, puis chaque recherche est immédiate.
let banned_set: HashSet<u32> = banned.iter().copied().collect();
let hits_set = queries.iter().filter(|q| banned_set.contains(q)).count();
println!("Vec en a trouvé {hits_vec}, HashSet en a trouvé {hits_set}");
}

Sortie :

Vec en a trouvé 6667, HashSet en a trouvé 6667
▶ Essayez par vous-même

Même réponse, coût très différent. En ajoutant des chronomètres Instant autour de chaque partie et en lançant un build release, voici un exemple de mesure sur notre machine :

Vec : 6667 trouvés en 27.97ms
HashSet : 6667 trouvés en 551.60µs

Environ 50 fois plus rapide, en comptant le temps de construction du set. Pour une liste de 10 éléments, en revanche, un Vec convient parfaitement et peut même gagner. Pour en savoir plus, consultez les pages HashSet et HashMap.

Les programmeurs sont connus pour mal deviner où passe le temps. Alors ne devinez pas : profilez. Un profileur observe votre programme pendant qu’il tourne et vous dit quelles fonctions prennent le plus de temps. En général, il y a un ou deux points chauds, et le reste de votre code ne compte pas du tout.

Deux profileurs populaires et gratuits qui fonctionnent avec Rust :

Terminal window
# flamegraph : dessine une image de l'endroit où le temps est passé (Linux, macOS)
cargo install flamegraph
cargo flamegraph
# samply : ouvre les résultats dans le Firefox Profiler, dans votre navigateur
cargo install samply
cargo build --release
samply record ./target/release/my_app

Les profileurs ont besoin des symboles de débogage pour afficher les noms des fonctions. Ajoutez ceci à Cargo.toml pour que les builds release les conservent (le code n’est pas plus lent, seul le fichier est plus gros) :

[profile.release]
debug = true

Chronométrer une seule fois avec Instant donne des résultats bruités. Un benchmark (banc d’essai) exécute le code de nombreuses fois et utilise des statistiques pour donner un chiffre fiable. Le #[bench] intégré à Rust ne fonctionne qu’en nightly, donc la plupart des gens utilisent la crate criterion (en anglais). Configurez-la ainsi :

[dev-dependencies]
criterion = "0.8"
[[bench]]
name = "fib_bench"
harness = false

Placez le code à tester dans votre bibliothèque (src/lib.rs), puis écrivez le benchmark dans benches/fib_bench.rs :

use criterion::{criterion_group, criterion_main, Criterion};
use my_crate::fibonacci;
use std::hint::black_box;
fn bench_fib(c: &mut Criterion) {
c.bench_function("fibonacci 20", |b| b.iter(|| fibonacci(black_box(20))));
}
criterion_group!(benches, bench_fib);
criterion_main!(benches);

black_box empêche le compilateur de faire le malin en calculant la réponse à l’avance, ce qui rendrait le benchmark inutile. Lancez-le avec :

Terminal window
cargo bench

Un exemple de résultat sur notre machine (criterion affiche une estimation basse, la meilleure estimation et une estimation haute) :

fibonacci 20 time: [24.417 µs 25.079 µs 25.737 µs]

Relancez-le après une modification, et criterion vous dira si le code est devenu plus rapide ou plus lent, et si la différence est réelle ou n’est que du bruit.

Les réglages release par défaut sont bons. Quand vous avez besoin d’un petit plus, voici les leviers courants dans Cargo.toml :

[profile.release]
opt-level = 3 # la valeur par défaut en release ; "s" ou "z" optimisent plutôt la taille
lto = true # optimisation à l'édition de liens : optimise à travers toutes les crates (build plus lent)
codegen-units = 1 # une seule unité = plus d'occasions d'optimiser, mais compilation plus lente
panic = "abort" # pas de code de déroulement ; binaire plus petit, mais impossible d'attraper une panic

Ces réglages apportent en général quelques pour cent, pas des miracles, et ils ralentissent les builds. Essayez-les un par un, et mesurez chacun. lto = "thin" est un compromis populaire, plus rapide à compiler que lto = true.

Chronométrer un build debug. Nous en avons déjà parlé, mais c’est l’erreur numéro un : mesurez toujours avec --release.

Cloner pour faire plaisir au compilateur. Imaginons qu’une fonction prenne une String et que vous l’appeliez avec un emprunt :

fn shout(text: String) -> String {
text.to_uppercase()
}
fn main() {
let name = String::from("ferris");
let loud = shout(&name);
println!("{loud} {name}");
}

Le compilateur répond :

error[E0308]: mismatched types
--> src/main.rs:7:22
|
7 | let loud = shout(&name);
| ----- ^^^^^ expected `String`, found `&String`
| |
| arguments to this function are incorrect

En français : « types incompatibles : String attendu, &String trouvé ». Il est tentant d’écrire shout(name.clone()). Cela fonctionne, mais copie la chaîne à chaque appel. La meilleure correction consiste à changer le paramètre en text: &str : shout(&name) compile alors, et rien n’est copié.

println! dans une boucle critique. Chaque println! verrouille la sortie standard et écrit la ligne immédiatement. Pour quelques lignes, aucun problème. Pour un million, c’est lent. Enveloppez stdout dans un BufWriter pour qu’il écrive par gros morceaux :

use std::io::{self, BufWriter, Write};
fn main() {
let mut out = BufWriter::new(io::stdout().lock());
for i in 1..=5 {
writeln!(out, "ligne {i}").unwrap();
}
}

Sortie :

ligne 1
ligne 2
ligne 3
ligne 4
ligne 5
▶ Essayez par vous-même

Dans notre exemple de mesure, écrire un million de lignes dans un fichier prenait environ 570 à 670 ms avec println!, et environ 40 à 60 ms avec BufWriter (build release).

  • Mesurez toujours la vitesse avec cargo run --release : un build debug peut être bien plus lent.
  • Utilisez Instant::now() et .elapsed() pour un chronométrage rapide, et criterion pour des benchmarks fiables.
  • Empruntez (&str, &[T]) au lieu de cloner quand vous voulez seulement lire des données.
  • Allouez une seule fois : Vec::with_capacity, collect() et join() évitent les agrandissements répétés.
  • Les itérateurs ne coûtent rien ; choisissez un HashSet/HashMap pour de nombreuses recherches.
  • Profilez d’abord, puis optimisez les points chauds que le profileur vous montre.

Petit quiz

1. Votre programme semble lent quand vous le lancez avec cargo run. Que faut-il essayer en premier ?

2. Une fonction ne fait que lire une chaîne. Quel type de paramètre évite les copies inutiles ?

3. Vous devez vérifier si 50 000 identifiants figurent dans une liste de 50 000 identifiants bannis. Quel est le meilleur choix pour la liste des bannis ?

4. Que signifie « les itérateurs sont une abstraction à coût zéro » ?

5. Pourquoi un benchmark criterion utilise-t-il black_box ?

Score : 0 / 5