🦀 Rust pour la robotique · temps réel

Chapitre 14
Temps réel & performance

Objectifs du chapitre

1. Que veut dire « temps réel » ?

Un débutant confond souvent « temps réel » avec « très rapide ». C'est une erreur. Un système temps réel est un système qui garantit qu'une réponse sera produite avant une échéance (une deadline) donnée. Ce qui compte n'est pas la vitesse moyenne, mais le déterminisme : la certitude que le pire cas restera sous la limite. Une boucle qui répond en 50 µs 999 fois sur 1000, mais qui prend parfois 5 ms, n'est pas temps réel — même si elle est « rapide » en moyenne.

On distingue :

En robotique d'asservissement, on vise en général le temps réel dur : la boucle de contrôle doit s'exécuter à période fixe (par exemple toutes les millisecondes, soit 1 kHz) avec une gigue (jitter) minimale.

Les principaux ennemis du déterminisme sont :

Rust élimine le pire ennemi (le GC) par construction, mais il ne vous empêche pas d'allouer. Un Vec::push ou un format! caché dans la boucle de contrôle peut toujours ruiner votre déterminisme. Le langage vous donne les outils ; c'est à vous de discipliner la boucle chaude.

2. Éviter les allocations dans la boucle de contrôle

La règle d'or : zéro allocation dans la boucle chaude. Toute la mémoire dont la boucle a besoin doit être réservée avant son démarrage. Concrètement, on préfère les données de taille connue à la compilation.

En C/C++ on utiliserait un tableau C double x[6];. En Rust, l'équivalent est le tableau de taille fixe [f64; N], entièrement sur la pile, sans allocation :

// ❌ À éviter dans la boucle : Vec alloue sur le tas
let mut erreurs: Vec<f64> = Vec::new();
for i in 0..6 {
    erreurs.push(consigne[i] - mesure[i]); // réallocations possibles
}

// ✅ Tableau fixe : taille connue, pas d'allocation, tout sur la pile
let mut erreurs = [0.0_f64; 6];
for i in 0..6 {
    erreurs[i] = consigne[i] - mesure[i];
}

Pour l'algèbre linéaire, nalgebra distingue les matrices statiques (taille dans le type, allouées sur la pile) des matrices dynamiques (taille au runtime, allouées sur le tas). Pour du temps réel, on utilise toujours les versions statiques :

use nalgebra::{SMatrix, Matrix3, Matrix4, Vector3};

// ❌ Dynamique : allocation sur le tas à chaque création
use nalgebra::DMatrix;
let jacobienne = DMatrix::<f64>::zeros(6, 6); // alloue

// ✅ Statique : taille dans le type, sur la pile, pas d'allocation
let jacobienne: SMatrix<f64, 6, 6> = SMatrix::zeros();
let rotation: Matrix3<f64> = Matrix3::identity();   // 3×3
let transfo: Matrix4<f64> = Matrix4::identity();    // 4×4 (transformation homogène)
let position: Vector3<f64> = Vector3::new(0.1, 0.2, 0.3);

Quand vous ne pouvez pas éviter une collection de taille variable, réutilisez un buffer alloué une seule fois et réservez sa capacité hors de la boucle :

// Allocation UNIQUE, avant la boucle : réserve la capacité une fois pour toutes
let mut buffer: Vec<f64> = Vec::with_capacity(1024);

loop {
    buffer.clear();          // remet la longueur à 0 SANS libérer la mémoire
    for i in 0..taille {
        buffer.push(lire_capteur(i)); // ne réalloue pas tant que len ≤ capacité
    }
    traiter(&buffer);
    // ... période suivante ...
}

Attention aux allocations cachées. Elles ne ressemblent pas toujours à un Vec::new(). Se cachent notamment : format! et to_string() (construisent une String), .collect::<Vec<_>>(), .clone() sur un type possédant un tas, Box::new, la conversion &[T] → Vec<T>, ou encore println! qui touche un verrou global sur stdout. Dans la boucle de contrôle, traquez-les.

3. Compilation optimisée

Par défaut, cargo build compile en mode debug : sans optimisations, avec des vérifications de débordement. C'est utile pour développer mais catastrophique pour les performances (souvent 10 à 100× plus lent). Pour mesurer ou déployer, compilez toujours en mode release : cargo build --release.

Poussez les réglages du profil release dans le Cargo.toml :

# Cargo.toml
[profile.release]
opt-level = 3          # optimisation maximale (vitesse)
lto = true             # link-time optimization : optimise à travers les crates
codegen-units = 1      # une seule unité : plus lent à compiler, meilleur code
panic = "abort"        # pas de déroulement de pile : binaire plus petit et rapide

Pour exploiter les instructions spécifiques de votre processeur (par exemple AVX pour la vectorisation des calculs matriciels), activez target-cpu=native. Attention : le binaire produit ne sera plus portable vers d'autres CPU.

# .cargo/config.toml
[build]
rustflags = ["-C", "target-cpu=native"]

Enfin, l'attribut #[inline] suggère au compilateur d'incorporer une petite fonction chaude sur son site d'appel, évitant le coût de l'appel. À réserver aux fonctions courtes et très fréquemment appelées (avec LTO, le compilateur inline déjà beaucoup de choses tout seul) :

#[inline]
fn produit_scalaire(a: &[f64; 3], b: &[f64; 3]) -> f64 {
    a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
}

panic = "abort" supprime le déroulement de pile : un panic arrête net le programme au lieu de le dérouler. C'est souvent souhaitable en embarqué (on n'a rien à « rattraper » proprement) et cela réduit la taille du binaire. Sachez toutefois que vous perdez la possibilité de rattraper un panic avec catch_unwind.

4. Mesurer avant d'optimiser

« L'optimisation prématurée est la racine de tous les maux » (Knuth). Avant de tordre votre code, mesurez : les intuitions sur ce qui est lent sont presque toujours fausses. Pour un chronomètre rapide, std::time::Instant suffit :

use std::time::Instant;

let debut = Instant::now();
let resultat = cinematique_inverse(&pose_cible);
let ecoule = debut.elapsed();
println!("IK calculée en {:?} ({} µs)", ecoule, ecoule.as_micros());

Un chrono maison mesure bruyamment (une seule exécution, sensible au bruit du système). Pour des mesures fiables et statistiquement solides, utilisez le crate criterion : il exécute la fonction des milliers de fois, gère la chauffe (warm-up), calcule des intervalles de confiance et détecte les régressions entre deux versions.

// benches/ik.rs — exécuté avec `cargo bench`
use criterion::{black_box, criterion_group, criterion_main, Criterion};

fn bench_ik(c: &mut Criterion) {
    let pose = pose_test();
    c.bench_function("cinematique_inverse", |b| {
        // black_box empêche le compilateur d'éliminer le calcul
        b.iter(|| cinematique_inverse(black_box(&pose)));
    });
}

criterion_group!(benches, bench_ik);
criterion_main!(benches);

Ne mesurez jamais les performances en mode debug : les chiffres n'ont aucun sens. Compilez toujours vos benchmarks en release (cargo bench le fait automatiquement). Et méfiez-vous du compilateur : sans black_box, il peut constater qu'un résultat n'est pas utilisé et supprimer purement et simplement votre calcul.

5. Le problème des flottants

Les nombres à virgule flottante (f32, f64) réservent des pièges qui touchent directement l'asservissement et les filtres du chapitre précédent.

const EPSILON: f64 = 1e-9;

fn division_sure(numerateur: f64, denominateur: f64) -> f64 {
    if denominateur.abs() < EPSILON {
        0.0 // ou une valeur de repli adaptée au contexte physique
    } else {
        numerateur / denominateur
    }
}

// Détecter une valeur corrompue avant qu'elle ne se propage
fn est_valide(x: f64) -> bool {
    x.is_finite() // false pour NaN ET pour ±infini
}

6. no_std et l'embarqué

Sur un microcontrôleur (STM32, RP2040…), il n'y a pas de système d'exploitation, pas de tas généreux, parfois pas d'unité à virgule flottante matérielle. La bibliothèque standard de Rust (std) suppose un OS (fichiers, threads, allocateur…) et n'est pas disponible. On utilise alors no_std : on renonce à std pour ne garder que core, le cœur du langage sans dépendance à un système.

#![no_std]   // pas de bibliothèque standard
#![no_main]  // pas de fonction main() classique fournie par l'OS

use core::f64; // core reste disponible : types, slices, Option, Result…

En no_std, plusieurs choses disparaissent et sont remplacées par des crates dédiés :

Bonne nouvelle pour le fil rouge : nalgebra fonctionne en no_std (avec la feature adéquate et libm pour les fonctions transcendantes). Vos matrices statiques Matrix3, Matrix4 et votre code de FK/IK peuvent donc tourner tels quels sur microcontrôleur, sans allocation.

7. Concurrence sûre (survol)

Rust est célèbre pour sa « fearless concurrency » : le compilateur garantit à la compilation l'absence de data races. Deux traits marqueurs y contribuent :

Ces traits sont déduits automatiquement. Si vous tentez de partager une donnée mutable sans protection entre threads, le programme ne compile pas — l'erreur est attrapée avant l'exécution, pas dans un crash sporadique à 3 h du matin.

use std::thread;
use std::sync::{Arc, Mutex};

// Un état partagé, protégé par un Mutex, compté par référence (Arc)
let etat = Arc::new(Mutex::new([0.0_f64; 6]));

let etat_capteur = Arc::clone(&etat);
let lecteur = thread::spawn(move || {
    let mut e = etat_capteur.lock().unwrap();
    e[0] = lire_capteur();
});

lecteur.join().unwrap();

La concurrence est puissante, mais rappelez-vous la section 1 : un Mutex introduit une latence potentiellement non bornée. Pour la boucle de contrôle temps réel dur, l'approche la plus sûre reste souvent une boucle mono-thread déterministe : un seul fil d'exécution, à priorité élevée, cadencé par un timer, sans verrou ni allocation. Le multithread sert alors aux tâches périphériques (journalisation, communication, supervision), pas au cœur de l'asservissement.

8. Fil rouge : la boucle FK/IK/Kalman à 1 kHz

Rassemblons tout. Notre robot doit exécuter, à chaque milliseconde, l'enchaînement complet : lire les capteurs, filtrer l'état (Kalman), calculer la cinématique directe (FK), résoudre la cinématique inverse (IK), envoyer les commandes. Voici le squelette d'une boucle qui respecte les principes de ce chapitre : tout est préalloué, rien n'alloue dans la boucle.

use nalgebra::{SMatrix, Matrix4, Vector3};
use std::time::{Duration, Instant};

const PERIODE: Duration = Duration::from_micros(1000); // 1 ms → 1 kHz

fn boucle_controle() {
    // --- Préallocation : tout l'état vit hors de la boucle, sur la pile ---
    let mut angles = [0.0_f64; 6];            // articulations, tableau fixe
    let mut etat_kalman = Vector3::<f64>::zeros();
    let mut p: SMatrix<f64, 3, 3> = SMatrix::identity(); // covariance statique
    let mut transfo: Matrix4<f64> = Matrix4::identity();

    let mut pire_cas = Duration::ZERO;

    loop {
        let debut = Instant::now();

        // 1. Lecture capteurs (dans des buffers déjà alloués)
        lire_encodeurs(&mut angles);

        // 2. Filtrage Kalman — matrices statiques, aucune allocation
        etat_kalman = predire(&etat_kalman, &mut p);
        etat_kalman = corriger(&etat_kalman, &mut p, &angles);

        // 3. Cinématique directe : angles → pose de l'effecteur
        transfo = cinematique_directe(&angles);

        // 4. Cinématique inverse : pose cible → angles de consigne
        let consigne = cinematique_inverse(&transfo);

        // 5. Commande moteurs
        envoyer_commande(&consigne);

        // --- Surveillance de l'échéance ---
        let ecoule = debut.elapsed();
        if ecoule > pire_cas {
            pire_cas = ecoule; // on suit le PIRE cas, pas la moyenne
        }
        if ecoule > PERIODE {
            // Échéance manquée : à journaliser / traiter selon la criticité
            deadline_manquee(ecoule);
        }

        // Attendre le début de la prochaine période
        if let Some(reste) = PERIODE.checked_sub(ecoule) {
            attendre(reste);
        }
    }
}

Ce qui rend cette boucle « temps réel » : le budget de 1 ms est explicite, on mesure le pire cas, on détecte les dépassements, et surtout aucune ligne n'alloue sur le tas. En release avec LTO et target-cpu=native, les calculs matriciels statiques se vectorisent et le pire cas reste largement sous l'échéance.

Checklist « temps réel en Rust »

Exercices

Exercice 1 — Chronométrer une fonction

Écris une fonction chrono générique qui exécute une fermeture (closure), mesure sa durée avec Instant, affiche le temps écoulé en microsecondes et renvoie le résultat de la fermeture. Utilise-la pour chronométrer un calcul de ton choix.

Voir la solution
use std::time::Instant;

fn chrono<T, F: FnOnce() -> T>(nom: &str, f: F) -> T {
    let debut = Instant::now();
    let resultat = f();           // exécute le travail
    let ecoule = debut.elapsed();
    println!("{nom} : {} µs", ecoule.as_micros());
    resultat                      // on rend le résultat à l'appelant
}

fn main() {
    let somme = chrono("somme_carres", || {
        (0u64..1_000_000).map(|x| x * x).sum::<u64>()
    });
    println!("Résultat = {somme}");
}

La fonction est générique sur le type de retour T et le type de fermeture F. Le trait FnOnce suffit car on n'appelle la fermeture qu'une fois. En renvoyant T, le chrono devient transparent : on peut l'insérer autour de n'importe quel calcul sans changer le reste du code. Pense à compiler en --release pour obtenir des chiffres réalistes.

Exercice 2 — Du Vec au tableau fixe

Voici une fonction qui alloue à chaque appel. Réécris-la sans aucune allocation, sachant que le vecteur d'état fait toujours 3 éléments.

// Version à convertir
fn combiner(a: &[f64], b: &[f64], gain: f64) -> Vec<f64> {
    let mut sortie = Vec::new();
    for i in 0..a.len() {
        sortie.push(a[i] + gain * b[i]);
    }
    sortie
}
Voir la solution
// La taille (3) est désormais dans le type : ni Vec, ni allocation.
fn combiner(a: &[f64; 3], b: &[f64; 3], gain: f64) -> [f64; 3] {
    let mut sortie = [0.0_f64; 3];
    for i in 0..3 {
        sortie[i] = a[i] + gain * b[i];
    }
    sortie
}

// Variante idiomatique, sans indices explicites :
fn combiner_iter(a: &[f64; 3], b: &[f64; 3], gain: f64) -> [f64; 3] {
    let mut sortie = [0.0_f64; 3];
    for (s, (&ai, &bi)) in sortie.iter_mut().zip(a.iter().zip(b.iter())) {
        *s = ai + gain * bi;
    }
    sortie
}

En passant de &[f64] (slice de taille inconnue) à &[f64; 3] (tableau de taille fixe), la taille est vérifiée à la compilation et le résultat vit sur la pile. Aucune allocation, aucune réallocation. Le compilateur peut même dérouler et vectoriser la boucle puisqu'il connaît la longueur.

Exercice 3 — Profil release optimisé

Rédige la section [profile.release] d'un Cargo.toml pour une application temps réel : optimisation maximale, LTO activée, une seule unité de génération de code, et arrêt immédiat en cas de panic. Ajoute ensuite le .cargo/config.toml pour cibler le CPU natif.

Voir la solution
# Cargo.toml
[profile.release]
opt-level = 3          # vitesse maximale
lto = true             # optimisation au moment de l'édition de liens
codegen-units = 1      # compile en une unité → meilleur code (build plus long)
panic = "abort"        # pas de déroulement de pile : plus petit et déterministe

# On peut aussi optimiser le profil bench (basé sur release par défaut)
[profile.bench]
lto = true
codegen-units = 1
# .cargo/config.toml
[build]
rustflags = ["-C", "target-cpu=native"]

opt-level = 3 et lto = true donnent le gros des gains. codegen-units = 1 rallonge la compilation mais laisse le compilateur optimiser globalement. panic = "abort" convient au temps réel/embarqué. Enfin target-cpu=native débloque les instructions SIMD de ta machine — au prix de la portabilité du binaire.

Exercice 4 — Chasse aux allocations cachées

Ce fragment est censé tourner dans la boucle de contrôle à 1 kHz. Repère toutes les allocations (et coûts) cachés, puis propose une version sans allocation.

fn etape(angles: &[f64; 6], journal: &mut Vec<String>) -> Vec<f64> {
    let msg = format!("angles reçus : {:?}", angles);
    journal.push(msg);
    println!("étape en cours");
    let consignes: Vec<f64> = angles.iter().map(|a| a * 2.0).collect();
    consignes.clone()
}
Voir la solution

Les allocations et coûts cachés sont :

  • format!(...) construit une nouvelle String sur le tas.
  • journal.push(msg) peut réallouer le Vec<String>.
  • println! prend un verrou global sur stdout et fait une entrée/sortie — latence non bornée dans une boucle chaude.
  • .collect::<Vec<f64>>() alloue un nouveau Vec.
  • consignes.clone() alloue encore un Vec (copie inutile).
// Sortie de taille fixe, écrite en place, aucune allocation, aucune E/S.
fn etape(angles: &[f64; 6]) -> [f64; 6] {
    let mut consignes = [0.0_f64; 6];
    for i in 0..6 {
        consignes[i] = angles[i] * 2.0;
    }
    consignes
}

Le journal et les affichages ne doivent pas vivre dans la boucle temps réel : on écrit plutôt les échantillons dans un buffer circulaire préalloué, qu'un autre thread (basse priorité) vide vers le disque ou le réseau. La boucle chaude, elle, ne fait que du calcul déterministe sur des données de taille fixe.

Récapitulatif

Mot de la fin. Vous voici arrivé au bout de ce cours : des bases du langage jusqu'à une boucle de contrôle FK/IK/Kalman cadencée à 1 kHz, sans allocation et déterministe. Rust vous offre la performance du C/C++ avec les garanties de sécurité mémoire et de concurrence en prime — un compagnon idéal pour la robotique temps réel. Mais lire ne suffit pas : ouvrez un terminal, lancez cargo new, branchez un vrai capteur ou un simulateur, mesurez, cassez, corrigez. C'est en écrivant du code temps réel qu'on l'apprend. Bonne route, et que vos échéances soient toujours respectées ! 🦀