2 points par GN⁺ 2024-04-20 | 1 commentaires | Partager sur WhatsApp

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 -Zcallconv pour configurer la convention d’appel des fonctions extern "Rust"
    • -Zcallconv=legacy correspond au fonctionnement actuel
    • -Zcallconv=fast correspond à 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 »
    • 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 %ssa qu’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
    • 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 ret direct, il faut brancher vers ce bloc
    • 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=legacy et 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 rustc a 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 bits
      • bool correspond à 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 u8 ou 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" ou extern "fastcall" peut aussi être une alternative

1 commentaires

 
GN⁺ 2024-04-20
Avis Hacker News

Résumé :

  • Lors de la conception d’une convention d’appel optimisée (calling convention), il est important de mesurer directement les performances. Un code qui paraît étrange peut en réalité être le plus rapide.
  • Les CPU actuels optimisent les traces d’instructions générées par les compilateurs C, donc transmettre fréquemment via la pile, comme le fait un compilateur C, peut être bénéfique.
  • L’inlining fonctionne si bien que les appels deviennent des frontières rares, ce qui permet d’accepter un peu d’irrégularité à ces frontières pour simplifier le reste.
  • En Rust, les structures doivent pouvoir fournir des références à leurs champs, ce qui peut les rendre plus volumineuses qu’en C. Une structure avec 8 champs Option<u8> fait 16 octets en Rust, contre 9 en C.
  • En Rust, on peut implémenter manuellement un équivalent de C, mais il n’existe pas de correspondance possible avec &Option<T> ou &mut Option<T>.
  • Rust n’a pas encore de convention d’appel pour une sémantique au niveau Rust. Apple avait une motivation pour en construire une, mais Rust ne dispose pas de ce type de support.
  • L’interopérabilité entre Go et Rust est actuellement possible en utilisant Zig comme intermédiaire.
  • Le compilateur Rust actuel pratique un inlining agressif et optimise fortement, ce qui fait douter de l’intérêt de résoudre ce problème.
  • Pour le débogage, on peut éviter certaines inquiétudes avec des flags dans Cargo.toml. Trier les champs par taille est une optimisation simple, et on peut la désactiver avec repr.