backend / rust
Performance et zero-cost abstractions
Explication
Ce que vous allez apprendre
- Comprendre le principe de "zero-cost abstraction" et pourquoi il définit la philosophie de Rust
- Expliquer la monomorphisation et sa différence avec le dispatch dynamique (
dyn Trait) - Utiliser
Vec::with_capacitypour éviter des réallocations coûteuses - Distinguer
Copyetmovepour éviter des coûts cachés - Mesurer correctement la performance en mode
--releaseplutôt qu'en mode debug
Dans quel contexte ?
Un développeur backend habitué à Python constate qu'une boucle for classique et un .iter().map().sum() produisent le même binaire optimisé une fois compilés en Rust, alors qu'en Python la version "pythonique" avec compréhension de liste est nettement plus rapide qu'une boucle manuelle interprétée. Cette différence n'est pas un hasard : elle vient d'un choix de conception fondateur de Rust, présent depuis sa création par Graydon Hoare chez Mozilla à partir de 2006.
Le principe fondateur : payer seulement ce qu'on utilise
D'abord, il faut comprendre l'idée centrale : en Rust, une abstraction de haut niveau (itérateur, générique, trait) ne doit jamais coûter plus cher à l'exécution que le code bas niveau équivalent écrit à la main. C'est le fameux "zero-cost abstraction", hérité en partie de la philosophie du C++ mais poussé beaucoup plus loin.
Concrètement, donnees.iter().map(|&x| x as i64).sum() compile en un code aussi rapide — parfois plus rapide — qu'une boucle for manuelle avec un accumulateur, grâce à l'inlining agressif du compilateur et à l'élimination des vérifications de bornes redondantes.
| Technique | Coût à l'exécution | Analogie |
|---|---|---|
Générique monomorphisé (fn f<T>) | Nul, code dupliqué par type à la compilation | Comme écrire f_i32, f_f64 à la main |
dyn Trait | Indirection (vtable), pas d'inlining possible | Comme une interface Go ou un pointeur de fonction |
Itérateur (.map().sum()) | Nul une fois optimisé | Boucle manuelle générée par le compilateur |
Vec::with_capacity(n) | Une seule allocation | make([]T, 0, n) en Go |
Prérequis
Il est utile d'avoir déjà manipulé les génériques et les traits dans une leçon précédente pour bien saisir la différence entre monomorphisation et dyn Trait.
Une fois ce principe posé, il faut comprendre comment le compilateur y arrive : la monomorphisation
Quand tu écris une fonction générique comme fn plus_grand<T: PartialOrd>(a: T, b: T) -> T, le compilateur ne génère pas une version "générique" qui ferait des vérifications de type à l'exécution. Il génère une copie distincte et spécialisée du code pour chaque type réellement utilisé — une plus_grand::<i32> et une plus_grand::<f64> séparées, chacune aussi optimisée qu'une fonction écrite spécifiquement pour ce type.
C'est l'opposé exact d'un dyn Trait, qui lui introduit une indirection via une table de méthodes virtuelles (vtable), empêchant le compilateur d'inliner l'appel. Le choix entre générique et dyn Trait est donc un vrai compromis entre taille du binaire (la monomorphisation duplique du code) et performance pure.
Piège courant
Ne jamais mesurer la performance d'un programme Rust en mode debug (cargo build sans --release). Le mode debug désactive presque toutes les optimisations et peut être 10 à 100 fois plus lent — une comparaison dans ce mode ne reflète rien de la réalité en production.
Il reste un dernier réflexe à acquérir : anticiper les allocations
Vec::with_capacity(n) réserve directement la mémoire nécessaire au lieu de laisser le vecteur se réallouer progressivement à chaque doublement de taille. De la même façon, préférer &str (un simple emprunt) à String (souvent une allocation) dans la signature d'une fonction évite des copies inutiles quand la donnée n'a pas besoin d'être possédée.
Bonne pratique
Avant d'optimiser quoi que ce soit, profile avec cargo build --release puis un outil comme cargo flamegraph. Une intuition sur ce qui est "lent" est souvent fausse ; seule une mesure réelle doit guider une optimisation.
Maintenant que tu sais que les abstractions de Rust ne coûtent rien en soi, la question suivante est plus fine : que se passe-t-il réellement en mémoire, à l'octet près, pour ces mêmes abstractions ? C'est ce qu'explore la prochaine leçon sur la gestion mémoire avancée et le memory layout.
Commandes & code
Performance et zero-cost abstractions
Le principe fondateur de Rust : les abstractions de haut niveau ne coûtent rien à l'exécution.
fn main() {
// Les iterateurs compilent en code aussi rapide qu'une boucle manuelle (voire plus rapide,
// grace a l'inlining et l'elimination des verifications de bornes redondantes)
let donnees: Vec<i32> = (0..1_000_000).collect();
// Version "boucle manuelle"
let mut somme_manuelle = 0i64;
for i in 0..donnees.len() {
somme_manuelle += donnees[i] as i64;
}
// Version iterateur : generalement compile de facon IDENTIQUE ou plus optimale
let somme_iter: i64 = donnees.iter().map(|&x| x as i64).sum();
println!("{} {}", somme_manuelle, somme_iter);
// Monomorphisation : le generique T est duplique par le compilateur pour chaque type utilise
// -> aucune indirection a l'execution, contrairement a une interface Go ou un dyn Trait
fn plus_grand<T: PartialOrd>(a: T, b: T) -> T {
if a > b { a } else { b }
}
println!("{} {}", plus_grand(3, 7), plus_grand(2.5, 1.1));
// A la compilation, le compilateur genere plus_grand::<i32> ET plus_grand::<f64> distincts
// Eviter les allocations inutiles : &str vs String
fn traiter_lecture(s: &str) -> usize { s.len() } // aucune allocation, emprunt simple
fn traiter_owned(s: String) -> usize { s.len() } // possible allocation/move selon l'appelant
println!("{}", traiter_lecture("hello"));
// Vec::with_capacity : evite les reallocations successives (comme make([]T, 0, n) en Go)
let mut v = Vec::with_capacity(1_000_000);
for i in 0..1_000_000 {
v.push(i);
}
// Inlining : #[inline] suggere au compilateur d'eviter l'appel de fonction
#[inline]
fn carre(x: i32) -> i32 { x * x }
println!("{}", carre(7));
// Copy vs move : les types Copy evitent tout risque de deplacement couteux
#[derive(Clone, Copy)]
struct Vecteur2D { x: f32, y: f32 }
let v1 = Vecteur2D { x: 1.0, y: 2.0 };
let v2 = v1; // copie triviale (2 f32), aucun cout d'allocation
println!("{} {}", v1.x, v2.x); // v1 reste valide : Copy, pas de move
// Eviter le boxing inutile : dyn Trait a un cout (indirection + pas d'inlining)
// preferer les generiques quand le type est connu a la compilation
}# Profiler et mesurer avant d'optimiser
cargo build --release # TOUJOURS mesurer en mode release, jamais debug
cargo bench # necessite criterion ou #[bench] (nightly)
cargo flamegraph # genere un flamegraph du profil CPU (outil externe)Résumé
- Les itérateurs et générateurs compilent souvent en code identique à une boucle manuelle optimisée à la main.
- La monomorphisation élimine tout coût d'indirection pour les génériques (contrairement à
dyn Trait). Vec::with_capacityévite les réallocations, commemake([]T, 0, n)en Go.- Toujours mesurer en mode
--release: le mode debug n'active aucune optimisation et fausse les comparaisons.
Exercices pratiques
Mission : un benchmark trompeur et une migration dyn Trait qui ralentit tout
Objectif : Corriger une mesure de performance faussée par le mode debug, puis expliquer le coût réel d'un remplacement générique vers dyn Trait sur un chemin critique.
Contexte
Un développeur compare deux implémentations pour remplir un vecteur de 10 millions d'entiers : l'une utilise Vec::new() suivi de push en boucle, l'autre Vec::with_capacity(10_000_000). Il lance cargo run (sans --release) et ne mesure quasiment aucune différence, il conclut donc que with_capacity ne sert à rien en pratique.
Séparément, pour réduire la taille du binaire final, une autre partie de l'équipe remplace systématiquement les fonctions génériques fn traiter<T: Traitable>(x: T) par des fn traiter(x: &dyn Traitable). Quelques semaines plus tard, un chemin critique de l'application, appelé plusieurs millions de fois par seconde, s'est mystérieusement ralenti.