La convention d’appel Rust que nous devrions avoir
(mcyoung.xyz)Cet article explique en détail comment améliorer la convention d’appel du langage Rust.
Problèmes de la convention d’appel actuelle de Rust
- Rust n’a pas actuellement de convention d’appel clairement définie
- En pratique, il utilise la convention d’appel C par défaut de LLVM
- Rust essaie actuellement, de manière conservatrice, de générer des signatures de fonctions LLVM semblables à ce que Clang produirait
- pour la compatibilité avec les débogueurs
- pour éviter des bugs de LLVM
- Mais cette approche est trop conservatrice et génère du mauvais code même pour des fonctions simples
fn extract(arr: [i32; 3]) -> i32 { arr[1] }
- Le code ci-dessus devrait être passé par registres, mais il est passé par pointeur
- Rust est plus conservateur que l’ABI C. Si on spécifie
extern "C", le passage se fait par registres.
Proposition d’une nouvelle convention d’appel
- Conserver la convention d’appel existante pour les fonctions
extern "Rust" - Ajouter le flag
-Zcallconvpour configurer la convention d’appel des fonctionsextern "Rust"-Zcallconv=legacycorrespond au fonctionnement actuel-Zcallconv=fastcorrespond à la nouvelle approche à concevoir
- Pourquoi conserver la convention d’appel existante ?
- pour ne pas imposer une disposition suivant l’ordre de l’ABI C, afin de faciliter le débogage
- certaines cibles, comme WASM, pourraient ne pas la prendre en charge
- cela peut ne pas avoir de sens dans les builds de debug
- Points d’attention concernant les pointeurs de fonction et les blocs
extern "Rust" {}- comme c’est un flag au niveau du crate, il n’est pas applicable aux pointeurs de fonction
- les appels via pointeur de fonction sont lents et rares, donc on utilise
-Zcallconv=legacy - si nécessaire, on génère un shim pour convertir la convention d’appel
- dans le cas d’un appel direct comme
extern "Rust" { fn my_func() -> i32; }- seuls les symboles non mangle peuvent être appelés
- les fonctions
#[no_mangle]utilisent la convention d’appel existante
Exploitation de LLVM
- Idéalement, il serait préférable de pouvoir spécifier directement la convention d’appel à LLVM, mais c’est difficile en pratique
- On peut contourner le problème avec la procédure suivante
- déterminer, pour une cible donnée, le nombre maximal de valeurs pouvant être passées par registres
- décider comment transmettre la valeur de retour : si elle tient dans les registres, on la garde telle quelle, sinon elle est passée par référence
- parmi les arguments passés par valeur, sélectionner ceux qui doivent en réalité être passés par référence
- ceux qui dépassent l’espace disponible pour le passage par registres
- sur x86, environ 176 octets
- décider quels arguments faire passer par registres afin de maximiser l’utilisation de l’espace registre
- c’est un problème NP-difficile, donc une heuristique est nécessaire
- les autres sont passés sur la pile
- générer une signature de fonction en LLVM IR
- les arguments passés par registres sont représentés sous forme de non-agrégats comme
i64,ptr,double,<2 x i64>, etc. - les arguments passés sur la pile suivent les « entrées registre »
- les arguments passés par registres sont représentés sous forme de non-agrégats comme
- générer le prologue de fonction
- décoder les arguments au niveau Rust depuis les entrées registre pour produire les mêmes valeurs
%ssaqu’avec-Zcallconv=legacy - le corps de fonction peut ainsi générer le même code indépendamment de la convention d’appel
- le code de décodage inutile est éliminé par le DCE
- décoder les arguments au niveau Rust depuis les entrées registre pour produire les mêmes valeurs
- générer le bloc de retour de fonction
- inclure des instructions phi pour le même type de retour qu’avec
-Zcallconv=legacy - encoder dans le format de sortie requis puis renvoyer avec
ret - au lieu d’un
retdirect, il faut brancher vers ce bloc
- inclure des instructions phi pour le même type de retour qu’avec
- s’il existe des fonctions non polymorphes, non inline, susceptibles d’être utilisées comme pointeurs de fonction
- lorsqu’elles sont exposées hors du crate ou transmises comme pointeurs de fonction
- générer un shim utilisant
-Zcallconv=legacyet faire un tail call vers l’implémentation réelle - cela est nécessaire pour préserver l’égalité des pointeurs de fonction
Comment vérifier les limites de passage par registres de LLVM
- Un programme LLVM permet de vérifier le nombre maximal de passages par registres autorisé par LLVM
- Sur x86, on peut avoir 6 entiers et 8 vecteurs SSE en entrée, et 3 entiers et 4 vecteurs SSE en sortie
- Sur aarch64, on a 8 entiers et 8 vecteurs, avec les mêmes limites en entrée et en sortie
- Au-delà, les valeurs sont passées sur la pile
Traitement des structures et des énumérations en Rust
- On suppose que
rustca déjà traité les agrégats de base et les unions - Traitement des valeurs de retour
- ce n’est pas la taille de la structure qui compte, mais la taille réelle des données hors padding
[(u64, u32); 2]fait 32 octets, mais en retirant les 8 octets de padding, on obtient 24 octets- définition de la taille effective d’un type
- nombre de bits définis, hors padding
[(u64, u32); 2]correspond à 192 bitsboolcorrespond à 1 bit
- si la taille effective est inférieure à l’espace disponible dans les registres de sortie, la valeur est renvoyée par valeur
- sur x86, 3 entiers + 4 SSE = 88 octets = 704 bits
- Traitement des registres pour les arguments
- c’est un problème de sac à dos, donc NP-difficile
- heuristique simple
- si la taille effective dépasse l’espace total des registres d’entrée, passage par référence
- une énumération est remplacée par une paire discriminateur-union
- une union peut toucher à des bits non initialisés, donc elle est transmise sous forme de tableau de
u8ou d’une seule variante non vide - aplatir jusqu’aux éléments les plus basiques : pointeurs, entiers, flottants, booléens, etc.
- trier par ordre croissant de taille effective
- allouer aux registres le plus grand préfixe possible, et mettre le reste sur la pile
- si une partie des entrées destinées à la pile dépasse un petit multiple de la taille d’un pointeur, les passer par pointeur sur la pile
- le reste est transmis directement sur la pile dans l’ordre avant tri
- ce qui passe par registres est alloué par ordre décroissant de taille
- les booléens sont compactés par paquets de 64 bits
Avis de GN+
- Personnellement, la convention d’appel actuelle de Rust est vraiment décevante. Elle pourrait offrir de bien meilleures performances que C++, mais ce n’est pas encore le cas
- C’est une approche que le langage Go a déjà implémentée depuis longtemps
- Pourquoi Rust ne l’a-t-il pas encore adoptée ?
- la génération de code ABI est complexe et LLVM n’aide pas beaucoup
- il n’y a pas beaucoup de personnes dans l’équipe compilateur qui maîtrisent bien LLVM
- il existe des inquiétudes concernant le temps de compilation, mais comme cela ne serait utilisé que pour les builds optimisés, ce n’est pas un gros problème
- L’auteur n’a pas le temps de corriger cela lui-même, mais il est disposé à aider l’équipe du compilateur Rust grâce à son expertise de LLVM
- Sinon, passer simplement à
extern "C"ouextern "fastcall"peut aussi être une alternative
1 commentaires
Avis Hacker News
Résumé :
Option<u8>fait 16 octets en Rust, contre 9 en C.&Option<T>ou&mut Option<T>.repr.