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 de core::arch::x86_64. La différence entre les deux sera expliquée dans le chapitre sur no_std, pour l’heure retenez juste que les modules std::arch::xyz sont des alias vers d’autres modules core::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.