Accès au CPU
La plupart des constructions du langage Rust aspirent à une portabilité parfaite entre matériels. Si votre programme marche bien sur un CPU, il devrait marcher tout aussi bien sur un autre CPU sans qu’il y ait besoin de le modifier, ou même sans le recompiler si l’architecture CPU est identique.
Mais il peut arriver qu’on ait besoin de rendre le programme moins portable en utilisant des fonctionnalités plus spécifiques à un CPU donné. Nous en avons eu un bref aperçu quand nous avons évoqué les opérations atomiques dans le chapitre sur la mutabilité interne, il est maintenant temps de rentrer dans le détail.
Compiler avec de nouvelles instructions
Les fabricants de CPU font régulièrement évoluer leurs architectures en ajoutant de nouvelles instructions, qui ajoutent de nouvelles fonctionnalités (ex : source d’aléatoire RDRAND) ou permettent de faire de certaines tâches plus efficacement (ex : vectorisation large AVX, accélération de primitives cryptographiques comme AES et SHA).
Comme ces instructions ne sont pas disponibles sur les anciens CPUs, un programme qui aspire à la portabilité totale entre matériels ne peut pas les utiliser. Par conséquent, les compilateurs ne les utilisent pas par défaut dans le code qu’ils génèrent. Mais il est possible de les configurer pour qu’ils le fassent, au prix d’une perte de portabilité des binaires générés.
Dans le cas du compilateur rustc
, cela se fait via les options de génération de
code
target-cpu
et
target-feature
,
qui permettent respectivement de cibler une gamme spécifique de CPU cibles
(comme Intel Skylake ou
AMD Zen 4) et d’activer à grain fin des
extensions individuelles du jeu d’instruction (comme
AVX,
FMA et
AES-NI).
La façon la plus simple de tester l’effet de ces options de génération de code
est via la variable d’environnement RUSTFLAGS
. Par exemple, en exécutant la
commande shell export RUSTFLAGS="-C target-cpu=native"
, on s’assure que
toutes commandes cargo ultérieures exécutées dans le même shell compileront
des binaires spécialisés pour le CPU où cargo
s’exécute. C’est un
moyen simple d’évaluer le bénéfice en termes de performances d’une
génération de code spécialisée.
Pour rendre ce choix plus permanent, il est préférable d’utiliser un fichier de
configuration Cargo
contenant une section [build]
avec une entrée rustflags
contenant les
options choisies. Attention, ces options doivent être fournies sous une forme
pré-digérée, par exemples l’équivalent de la variable d’environnement
RUSTFLAGS
ci-dessus est…
[build]
rustflags = ["-C", "target-cpu=native"]
Fonctions intrinsèques
Parfois, on n’est pas satisfait du code généré par le compilateur, et on est forcé de l’aider un peu en lui indiquant quelle instruction CPU il doit utiliser. La façon la plus simple de le faire est d’utiliser une abstraction simple des instructions CPU, des fonctions appelée “intrinsèques CPU”.
En Rust, ces intrinsèques sont disponibles sous forme de sous-modules du module
std::arch
de la bibliothèque
standard. Par exemple, le sous-module
std::arch::x86_64
contient l’ensemble des intrinsèques associées à l’architecture x86_64
.
Si vous cliquez sur les liens ci-dessus, vous allez rapidement constater trois choises :
- Je vous parle de
std::arch::x86_64
, mais les pages de documentation vous parlent decore::arch::x86_64
. La différence entre les deux sera expliquée dans le chapitre surno_std
, pour l’heure retenez juste que les modulesstd::arch::xyz
sont des alias vers d’autres modulescore::arch::xyz
de la bibliothèque standard. - Beaucoup d’intrinsèques sont encore instables (non utilisables via la version stables du compilateur Rust, ce qui est signalé par l’avertissement “Experimental” dans la documentation) ou manquantes. Cela est lié au très grand nombre d’intrinsèques matérielles relativement à la faible main d’oeuvre disponible pour les intégrer au sein de la bibliothèque standard.
- Toutes les intrinsèques CPU sont des fonctions
unsafe
. Cela est lié au fait que si vous les utilisez sur un CPU où elles ne sont pas supportées, cela causera du comportement indéfini. Il faut l’éviter en vérifiant que le programme s’exécute bien sur un CPU qui supporte l’instruction.
Voyons donc comment on peut vérifier qu’une intrinsèque est supportée avant de l’utiliser.
Détection du support CPU
A la compilation
Lorsque vous compilez un programme avec des options de génération de code
comme target-cpu
et target-feature
, vous supposez d’ors et déjà qu’il va
s’exécuter sur un CPU supportant certaines instructions. Par conséquent, il
serait redondant de vérifier pendant l’exécution que c’est le cas.
A la place, vous pouvez utiliser des attributs cfg()
comme
#[cfg(target_feature = "avx")]
pour vérifier que le programme est bien compilé
pour des CPUs supportant l’extension du jeu d’instruction souhaitée, avant
d’utiliser les intrinsèques dans un bloc de code unsafe.
#![allow(unused)] fn main() { let i = 42i32; #[cfg(target_feature = "popcnt")] let popcnt = unsafe { std::arch::x86_64::_popcnt32(i) }; #[cfg(not(target_feature = "popcnt"))] let popcnt = i.count_ones(); println!("The population count of {i} ({i:#b}) is {popcnt}"); }
Pour beaucoup d’intrinsèques (mais pas toutes), le support de l’instruction par
le CPU est la seule précondition qui rend l’intrinsèque unsafe. La
bibliothèque safe_arch
facilite
l’utilisation de ces intrinsèques de plusieurs façons :
- Elles rend les fonctions concernées safe, mais soumet leur déclaration à une
directive
#[cfg()]
. Donc si le support n’est pas activé au niveau du compilateur, la fonction n’est pas présente au sein de la bibliothèque, et tenter de l’utiliser est une erreur de compilation. - Elle utilise une convention de nommage plus claire que celle de la bibliothèque standard (elle-même héritée d’Intel), ce qui permet de savoir plus souvent ce qu’une intrinsèque va faire sans devoir consulter la documentation toutes les 5 secondes.
- Elle fournit quelques utilitaires absents des intrinsèques standard :
implémentation de
Default
et des opérations binaires pour les types SIMD, variantes des intrinsèques qui utilisent normalement un pointeur basées sur des références…
A l’exécution
Parfois, il n’est pas acceptable de perdre en portabilité matérielle pour gagner en performance. Dans ce cas, l’approche classique est de compiler le code en plusieurs exemplaires, correspondant à différents niveaux de fonctionnalités CPU, puis de sélectionner à l’exécution le code le plus performant en fonction de ce que le matériel supporte.
Rust fournit en standard deux outils permettant cela :
- L’attribut
#[target_feature]
permet de définir des fonctions qui sont compilées séparément avec les options-C target-feature
qui vont bien. Les fonctions résultantes doivent être unsafe puisqu’on ne peut les utiliser que sur des CPUs supportant les instructions utilisées. - La famille de macros
is_xyz_feature_detected!()
permet d’interroger le CPU (si possible) ou le système d’exploitation (sinon) pour savoir quelles instructions sont supportées à l’exécution.
En voici un exemple simple : une somme de tableaux qui est vectorisée par le compilateur, mais en tirant parti de la vectorisation large AVX quand le CPU la supporte.
#![allow(unused)] fn main() { // Point d'entrée de la somme optimisée pub fn optimized_sum(in1: &[f32], in2: &[f32], out: &mut [f32]) { // Si AVX est supporté, alors on utilise une version du code d'addition // optimisée pour les CPUs avec support AVX. #[cfg(any(target_arch = "x86", target_arch = "x86_64"))] if is_x86_feature_detected!("avx") { // SAFETY: OK car on a vérifié qu'AVX est supporté ci-dessus return unsafe { sum_avx(in1, in2, out) }; } // Sinon, on fait appel à la version par défaut // (qui utilisera SSE sur tous les CPUs x86_64) sum_autovec(in1, in2, out); } // Cette fonction est compilée avec le flag -C target-feature=+avx. // Le compilateur utilisera donc AVX quand il vectorisera la boucle de // sum_autovec(), qui est inlinée à l'intérieur de cette fonction. #[target_feature(enable = "avx")] unsafe fn sum_avx(in1: &[f32], in2: &[f32], out: &mut [f32]) { println!("AVX sera utilisé pour ce calcul"); sum_autovec(in1, in2, out); } // Addition basée sur une boucle vectorisée automatiquement. #[inline] fn sum_autovec(in1: &[f32], in2: &[f32], out: &mut [f32]) { for ((x, y), z) in in1.iter().zip(in2.iter()).zip(out.iter_mut()) { *z = *x + *y; } } // Exemple d'utilisation let in1 = vec![0.0; 1024]; let in2 = vec![0.0; 1024]; let mut out = vec![0.0; 1024]; optimized_sum(&in1[..], &in2[..], &mut out[..]); }
La détection à l’exécution des jeux d’instructions supportés est relativement coûteuse, vous devez donc vous assurer que cela ne soit pas fait fréquemment. Sinon, il faut garder en cache le résultat de la requête quelque part pour garder de bonnes performances.
De plus, puisque les fonctions target_feature
sont compilées séparément, elles
ne peuvent pas être inlinées. Cela augmente un peu le coût des appels de
fonction et surtout réduit les possibilités d’optimisations, il vaut mieux que
ces fonctions ne soient appelées qu’avec une quantité de travail relativement
importante pour compenser.
Si tout ça vous semble trop manuel, la crate
multiversion
permet d’automatiser ce processus, gérer un cache des features supportées
automatiquement pour vous, et aussi améliorer la portabilité matérielle en
supportant automatiquement toutes les instructions vectorielles de toutes les
architectures CPUs courantes. Avec elle, le code ci-dessus devient :
use multiversion::multiversion;
// Définition de optimized_sum
#[multiversion(targets = "simd")]
fn optimized_sum(in1: &[f32], in2: &[f32], out: &mut [f32]) {
for ((x, y), z) in in1.iter().zip(in2.iter()).zip(out.iter_mut()) {
*z = *x + *y;
}
}
// Exemple d'utilisation
let in1 = vec![0.0; 1024];
let in2 = vec![0.0; 1024];
let mut out = vec![0.0; 1024];
optimized_sum(&in1[..], &in2[..], &mut out[..]);
Assembleur inline
Dans des cas rares, même l’utilisation de fonctions intrinsèques ne suffit pas à obtenir un contrôle suffisant sur ce que fait le matériel, et il faut écrire soi-même l’assembleur pour une partie du code. On est typiquement amené à le faire quand…
- On écrit du code manipulant des données secrètes, et on veut s’assurer que le compilateur ne fasse pas des copies de ces données à d’autres endroits de la mémoire et n’optimise pas les boucles avec des techniques de type early exit qui révèlent indirectement des informations sur la teneur de ces données secrètes (via des variations du temps d’exécution).
- On doit faire des opérations qui sortent du modèle machine de Rust (ex : premières instructions du gestionnaire d’interruption dans un système d’exploitation) ou on veut utiliser des instructions CPU qui ne sont pas encore disponibles sous formes d’intrinsèques.
- On vise la performance maximale permise par le matériel (ex : codec vidéo) et on n’arrive vraiment pas à faire générer du code de bonne qualité au compilateur.
Ayant décidé de faire de l’assembleur, on pourrait décider d’écrire un fichier de code assembleur, le compiler, et le lier à notre programme sous forme de fonction externe, mais…
- Ca complique le processus de compilation (fichiers assembleur à gérer)
- Appeler une fonction a un coût, parfois excessif quand on est très sensible aux performances.
Et c’est pour cette raison que Rust supporte l’assembleur inline, qui dépasse le cadre de ce cours introductif, mais permet d’injecter directement de l’assembleur au milieu de son code Rust en ayant la possibilité de lire et modifier la valeur des variables locales de “l’appelant”.
Les personnes ayant déjà utilisé l’assembleur inline de GCC et Clang seront ravies d’apprendre que la syntaxe proposée par Rust est beaucoup plus lisible et que les valeurs par défaut des paramètres sont beaucoup moins piégeuses. Il est donc beaucoup plus facile d’écrire de l’assembleur inline en Rust qu’en C/++, même si cela reste un outil complexe de dernier recours.