Chapitre 14
Temps réel & performance
Objectifs du chapitre
- Comprendre ce que « temps réel » veut vraiment dire : déterminisme et respect des échéances, pas seulement « rapide ».
- Identifier les ennemis du déterminisme : allocations imprévisibles, verrous, pagination — et pourquoi l'absence de ramasse-miettes est un atout de Rust.
- Éviter les allocations dans la boucle de contrôle en préférant tableaux fixes et matrices statiques
nalgebra. - Configurer un profil de compilation optimisé et mesurer avant d'optimiser.
- Découvrir
no_std, l'écosystème embarqué et les bases de la concurrence sûre. - Appliquer ces principes au fil rouge : une boucle FK/IK/Kalman cadencée à fréquence fixe (1 kHz).
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 :
- Temps réel dur : rater une échéance est une défaillance grave (asservissement d'un bras robotisé, commande moteur à 1 kHz, freinage). La deadline est un contrat inviolable.
- Temps réel souple : rater occasionnellement une échéance dégrade la qualité sans être catastrophique (affichage vidéo, interface, télémétrie).
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 :
- Les allocations dynamiques : demander de la mémoire à l'allocateur
(
malloc,Vec::push…) a un coût imprévisible — parfois quelques nanosecondes, parfois beaucoup plus si l'allocateur doit réorganiser le tas. - Le ramasse-miettes (GC) : dans les langages comme Java, Go ou C#, le GC peut suspendre le programme à un instant imprévisible. Rust n'a pas de GC : la mémoire est gérée à la compilation par le système d'ownership. C'est l'un de ses plus grands atouts pour le temps réel — pas de pauses surprises.
- La pagination : si une page mémoire a été déplacée sur le disque (swap), y accéder provoque un défaut de page très lent.
- Les verrous (locks) : attendre un
Mutexdétenu par un autre thread introduit une latence non bornée.
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.
- Déterminisme. Un même calcul peut donner des résultats légèrement différents selon l'ordre des opérations, le CPU ou l'usage d'instructions fusionnées (FMA). Pour de la reproductibilité stricte (rejouer un log à l'identique), il faut y prêter attention.
- NaN et infinis.
0.0 / 0.0donneNaN; une division par un nombre minuscule explose vers l'infini. UnNaNest contagieux : toute opération qui l'implique redonneNaN, et il se comparefalseà tout (même à lui-même). Une seule valeurNaNqui s'infiltre dans l'état du robot peut le rendre incontrôlable. - Divisions par ~0. Près d'une singularité (bras tendu en IK) ou quand une matrice de covariance devient mal conditionnée (Kalman), un dénominateur frôle zéro et le résultat diverge. Protégez systématiquement ces divisions.
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 :
libm: les fonctions mathématiques (sin,cos,sqrt,atan2…) vivent normalement dansstd.libmles réimplémente en pur Rust, sans OS.heapless: des collections de capacité fixe (heapless::Vec<T, N>,heapless::String<N>) qui vivent sur la pile sans jamais allouer — parfait pour le temps réel dur, même hors embarqué.embedded-hal: une couche d'abstraction matérielle standard (GPIO, SPI, I²C, PWM…) qui rend le code portable entre microcontrôleurs.- RTIC (Real-Time Interrupt-driven Concurrency) : un framework d'ordonnancement piloté par interruptions, avec priorités, qui garantit l'absence de data race à la compilation. Idéal pour structurer une application temps réel dur.
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 :
Send: un type peut être transféré vers un autre thread.Sync: un type peut être partagé (via une référence) entre threads en sécurité.
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 »
- ☐ Compiler en
--releaseavec un profil optimisé (opt-level 3, LTO, codegen-units=1). - ☐ Zéro allocation dans la boucle chaude : tableaux fixes
[f64; N]et matrices statiquesnalgebra. - ☐ Préallouer tous les buffers avant la boucle (
Vec::with_capacity, réutilisation). - ☐ Traquer les allocations cachées :
format!,collect,clone,println!. - ☐ Mesurer avant d'optimiser (
Instant,criterion) — jamais en debug. - ☐ Protéger les divisions par ~0 et détecter les
NaN/infinis (is_finite). - ☐ Suivre le pire cas et détecter les échéances manquées, pas seulement la moyenne.
- ☐ Boucle de contrôle mono-thread déterministe ; verrous et allocations relégués aux tâches périphériques.
- ☐ Pour l'embarqué :
no_std+libm+heapless;nalgebratourne aussi en no_std.
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 nouvelleStringsur le tas.journal.push(msg)peut réallouer leVec<String>.println!prend un verrou global surstdoutet fait une entrée/sortie — latence non bornée dans une boucle chaude..collect::<Vec<f64>>()alloue un nouveauVec.consignes.clone()alloue encore unVec(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
- « Temps réel » = respect garanti des échéances (déterminisme), pas « rapide en moyenne » ; on distingue temps réel dur et souple.
- Les ennemis du déterminisme : allocations imprévisibles, pagination, verrous. Rust supprime le pire (pas de GC) mais ne vous interdit pas d'allouer.
- Boucle chaude : zéro allocation. Tableaux fixes
[f64; N], matrices statiquesnalgebra(SMatrix,Matrix3/4), buffers préalloués et réutilisés. - Compiler en
--releaseavec un profil optimisé (opt-level 3, LTO, codegen-units=1, panic=abort) ettarget-cpu=native;#[inline]sur les petites fonctions chaudes. - Mesurer avant d'optimiser :
Instantpour un chrono,criterionpour des benchs fiables — jamais en debug. - Flottants : gérer
NaN/infinis (is_finite), protéger les divisions par ~0 (IK et Kalman). - Embarqué :
no_std+libm+heapless, écosystèmeembedded-hal/RTIC ;nalgebrafonctionne aussi en no_std. - Concurrence sûre par le compilateur (
Send/Sync) ; pour le temps réel dur, garder une boucle mono-thread déterministe.
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 ! 🦀