Retour au cours

backend / rust

Performance et zero-cost abstractions

Leçon 201 exercice

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_capacity pour éviter des réallocations coûteuses
  • Distinguer Copy et move pour éviter des coûts cachés
  • Mesurer correctement la performance en mode --release plutô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.

TechniqueCoût à l'exécutionAnalogie
Générique monomorphisé (fn f<T>)Nul, code dupliqué par type à la compilationComme écrire f_i32, f_f64 à la main
dyn TraitIndirection (vtable), pas d'inlining possibleComme 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 allocationmake([]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.

rust
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
}
bash
# 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, comme make([]T, 0, n) en Go.
  • Toujours mesurer en mode --release : le mode debug n'active aucune optimisation et fausse les comparaisons.

Exercices pratiques

1 disponible
1

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.

Résoudre l’exercice →