2 points par GN⁺ 2024-04-20 | 1 commentaires | Partager sur WhatsApp
  • La convention d’appel actuelle extern "Rust" de Rust repose sur le chemin de convention d’appel C de LLVM et, pour le passage de valeurs complexes, reste conservatrice dans l’utilisation des registres, ce qui lui fait rater de meilleures opportunités de génération de code
  • L’idée centrale est de conserver la séparation, via le flag au niveau crate -Zcallconv, entre legacy pour le comportement actuel et fast pour une nouvelle approche centrée sur les registres, avec un ABI plus agressif dans les builds optimisés
  • Même sans ajouter directement une nouvelle convention d’appel à LLVM, il est possible de contrôler le placement des arguments à l’aide de signatures de fonctions LLVM fixes et de valeurs poison pour laisser sans coût les registres inutilisés
  • Des types Rust comme les struct, enum, union, bool ou Result peuvent être transmis plus densément grâce à la taille utile hors padding, à l’aplatissement, au bit packing et à des heuristiques de découpe pile/registres
  • En faisant entrer dans la décision d’ABI le corps de la fonction, les informations du borrow checker et les données de profilage, on peut viser des optimisations plus fortes, mais la complexité de la génération de code ABI dans rustc et le manque d’expertise LLVM restent des obstacles concrets

Les optimisations de convention d’appel que Rust manque aujourd’hui

  • La convention d’appel (calling convention) fait partie de l’ABI qui détermine comment transmettre les arguments et valeurs de retour d’une fonction, quels registres utiliser, et comment gérer prologue/épilogue et l’unwinding
  • Rust définit sa propre convention d’appel non spécifiée, mais en pratique elle est abaissée vers la convention d’appel C intégrée à LLVM et dépend de LLVM pour la génération du prologue et de l’épilogue
  • rustc se comporte de manière prudente en essayant de produire des signatures de fonctions LLVM du type de celles que générerait Clang
    • Cela peut réduire le risque de casser les débogueurs
    • Cela peut aussi réduire le risque de tomber sur des bugs LLVM via des chemins de génération ABI peu utilisés par Clang
  • Sur les systèmes basés sur ELF, DWARF n’encode pas en dur l’ABI C Linux, donc dans le cadre de l’article la débogabilité n’est pas considérée comme le problème central
  • Exemple simple avec fn extract(arr: [i32; 3]) -> i32 : le tableau de 12 octets est passé par pointeur au lieu d’être passé dans des registres
    • Avec extern "C", le même [i32; 3] est transmis packé dans rdi et rsi
    • C’est un cas où le chemin par défaut de Rust est encore plus conservateur que l’ABI C Linux

-Zcallconv : séparer legacy et fast

  • La convention d’appel actuelle de extern "Rust" est conservée, mais le flag de compilation au niveau crate -Zcallconv permet de choisir la convention à utiliser
    • -Zcallconv=legacy : comportement actuel
    • -Zcallconv=fast : nouvelle approche centrée sur les registres
    • -O pourrait aussi activer automatiquement -Zcallconv=fast
  • La convention d’appel fast ne place pas les arguments dans l’ordre de l’ABI C, ce qui peut sembler déroutant pour ceux qui s’attendent à l’ordre idiomatique des registres x86
  • Sur des cibles comme WASM, où les notions de registres et de spilling n’existent pas, -Zcallconv=fast pourrait ne pas être pris en charge
  • Dans les builds de debug sans optimisations, fast peut produire un code moins bon et ne pas être pertinent à activer
  • Les pointeurs de fonction et les blocs extern "Rust" {} exigent des contraintes particulières
    • Le flag s’applique au niveau crate, mais il est difficile d’exprimer dans un pointeur de fonction quelle version de extern "Rust" il utilise
    • Les appels via pointeur de fonction peuvent être considérés comme des chemins rares et lents, et donc forcés en -Zcallconv=legacy
    • Si nécessaire, un shim de conversion de convention d’appel peut être généré
    • À cause des chemins permettant d’appeler des symboles non manglés, les symboles #[no_mangle] peuvent eux aussi être forcés à utiliser la convention legacy

Une manière de piloter LLVM indirectement

  • Dans l’idéal, on voudrait pouvoir dire directement à LLVM « cet argument va dans tel registre, cette valeur de retour dans tel autre », mais ajouter une convention d’appel à LLVM demande beaucoup de code C++
  • À la place, on peut obtenir un effet proche d’une convention d’appel maison avec la procédure suivante
    • Déterminer, pour chaque target triple, le nombre maximal de valeurs pouvant être passées en registres
    • Déterminer si la valeur de retour tient dans les registres de sortie ou si elle doit être renvoyée par référence via un argument ptr supplémentaire avec l’attribut sret
    • Abaisser les arguments by-value trop gros en passage par référence
    • Choisir quels arguments envoyer en registres afin de maximiser l’utilisation de l’espace registre
    • Laisser les autres arguments sur la pile
    • Construire la signature de fonction LLVM IR à partir d’arguments non agrégés comme i64, ptr, double, <2 x i64>
    • Dans le prologue de la fonction, décoder les entrées registre en arguments au niveau Rust
    • Dans le bloc de sortie, encoder la valeur de retour dans le format de sortie nécessaire puis faire ret
    • Pour les fonctions non polymorphes, non inline, dont l’adresse peut être prise, générer un shim legacy afin de préserver l’identité des pointeurs de fonction
  • Décider quelles valeurs mettre dans les registres revient à un problème du sac à dos (knapsack problem), donc à un problème NP-difficile ; il faut en pratique des heuristiques
  • Ces informations peuvent être stockées dans rmeta pour éviter de les recalculer trop tard
  • Comme Rust casse déjà son ABI d’une release à l’autre, l’exigence d’empêcher le linkage entre du code généré par différents compilateurs Rust correspond à la situation actuelle

Les limites du passage en registres autorisées par LLVM

  • LLVM essaie d’« exploser » autant que possible les arguments agrégés passés by-value en registres
  • Sur x86, les entrées que LLVM peut passer en registres ressemblent approximativement à ceci
    • 6 entiers
    • 8 vecteurs SSE
    • pour les retours, la moitié : 3 entiers et 4 vecteurs
  • Sur aarch64-unknown-linux, on peut avoir 8 entiers et 8 vecteurs à la fois en entrée et en sortie
  • On peut concevoir toutes les fonctions x86 en -Zcallconv=fast avec le même nombre d’arguments passés par registres
    • 6 arguments pour les registres entiers
    • 8 arguments vectoriels de xmm0 à xmm7
    • si l’on passe réellement un pointeur, le i64 correspondant devient un ptr
    • pour transmettre un double, il remplace un emplacement <2 x i64>
  • Même si la plupart des fonctions ne transmettent pas 176 octets, passer LLVM poison pour les arguments inutilisés permet d’éviter un coût supplémentaire
    • LLVM peut considérer poison comme la valeur la plus pratique du moment
    • Si poison est passé comme argument registre, cela peut être traité comme « la valeur déjà présente dans ce registre », sans avoir à le modifier
    • Dans l’exemple, load_rcx() reçoit un pointeur dans rcx, et le code qui charge poison dans les 13 autres registres ne génère aucun code après optimisation
  • Cette méthode donne un contrôle quasi complet sur le passage des arguments, mais la situation idéale où entrée et sortie utilisent les mêmes registres varie selon l’architecture
    • ARM et RISC-V sont plus proches d’un modèle où entrées et sorties partagent les mêmes registres
    • x86 ne l’est pas, mais on peut réduire les moves de registres inutiles en supposant un ordre d’allocation différent

Mieux adapter les types Rust aux registres

  • Pour traiter les struct et union Rust, on suppose que rustc a déjà représenté les types utilisateur comme des agrégats et unions de base, puis on décide quelles parties placer en registres
  • Pour les valeurs de retour, la taille utile hors padding est plus importante que la taille totale de la struct
    • [(u64, u32); 2] fait 32 octets au total, mais 8 octets ne sont que du padding
    • En l’aplatissant en (u64, u32, u64, u32) puis en le réordonnant par taille en (u64, u64, u32, u32), on retombe à 24 octets
    • Cela tient dans les 3 registres de retour entiers de x86
  • La taille utile est définie comme le nombre de bits non undef
    • [(u64, u32); 2] correspond à 192 bits
    • bool à 1 bit
    • char techniquement à 21 bits, mais traité ici comme un alias de u32 pour simplifier
  • Les struct contenant beaucoup de bool peuvent renvoyer plusieurs bool via bit packing dans un seul registre
  • Le cas des arguments est plus difficile, et on peut appliquer des heuristiques comme
    • abaisser en passage par référence tout argument dont la taille utile dépasse l’espace total d’entrée disponible en registres
    • sur x86, cet espace total d’entrée vaut 176 octets, soit 1408 bits
    • transformer un enum en paire discriminant + union
      • Option<i32> peut être vu en interne comme (union { i32, () }, i1)
      • Option<Option<i32>> comme (union { i32, (), () }, i2)
    • transmettre les union en général comme des tableaux de u8, puisqu’on peut modifier arbitrairement les bits non initialisés
    • remplacer une union ne contenant qu’une seule variante non vide par cette variante elle-même
    • aplatir les arguments transformés en primitives comme pointeurs, entiers, flottants, bool
    • découper les champs plus gros qu’un registre d’argument élémentaire, comme u128 ou f64
    • trier la liste des primitives par taille utile et choisir le plus grand préfixe tenant dans les registres
    • laisser le reste sur la pile
    • si la partie allant sur la pile dépasse un petit multiple de la taille d’un pointeur, l’abaisser en pointeur-sur-la-pile pour réduire le trafic mémoire
    • placer d’abord les valeurs les plus grosses parmi celles passées en registres, et packer jusqu’à 64 bool par registre

Exemple de fonction Rust complexe et limites actuelles de rustc

  • Dans l’exemple do_thing qui prend Option<usize>, &dyn Context, &str, [char; 6] et une struct Options, l’aplatissement et le tri permettent de faire tenir tous les arguments LLVM primitifs dans des registres
  • Les types LLVM bruts des arguments prennent alors la forme suivante
    • gprs: i64, ptr, ptr, ptr, i64, i32, i32
    • xmm0: i32, i32, i32, i32
    • xmm1: i32, i1, i1, i1, i1
  • Le prologue de la fonction extrait les primitives puis reconstruit les valeurs au niveau Rust
    • Option<usize> devient { i64, i1 }
    • l’objet de trait devient { ptr, ptr }
    • &str devient { ptr, i64 }
    • [char; 6] devient [6 x i32]
    • Options devient { i32, i1, i1, i1 }
  • En attachant les métadonnées !dbg aux instructions qui matérialisent réellement les arguments, gdb peut mieux afficher leurs valeurs
  • Aujourd’hui, rustc passe à LLVM 8 paramètres de taille pointeur pour cette même fonction, ce qui remplit les 6 registres entiers et force le passage de 2 valeurs sur la pile

Valeurs de retour et marge d’optimisation pour Result

  • Cette conception ne couvre pas toutes les optimisations possibles de convention d’appel
  • Dans certains cas, on pourrait exploiter des registres supplémentaires, comme les registres AVX sur x86
  • On peut aussi envisager de scinder le passage d’une struct entre registres et pile
  • Le retour de Result offre une marge d’optimisation particulière
    • Quand ? traverse plusieurs niveaux d’appels, cela peut provoquer beaucoup de moves de registres redondants
    • Si Result est trop gros pour tenir dans les registres, chaque ? doit charger en mémoire le bit ok depuis la pile d’appel pour le tester
    • Une autre option consiste à passer l’erreur via un pointeur de sortie, et à renvoyer la charge utile de la variante ok ainsi que le bit is-ok sous forme de Option<T>
    • Les détails du traitement lorsque ? implique aussi un appel à Into sont délicats, mais implémentables

Un ABI dépendant de l’optimisation

  • Contrairement au C, Rust peut, avec -Zcallconv=fast, voir le corps de la fonction au moment de construire l’ABI visible par l’appelant
  • Une crate peut annoncer, fonction par fonction, l’ABI exacte du point de vue du passage en registres
  • L’optimisation la plus simple consiste à supprimer de l’ABI les arguments non utilisés
    • Si une fonction n’utilise aucun de ses paramètres, aucun registre n’est alloué pour ces arguments
  • Si un argument &T n’est pas conservé, n’est pas transformé en pointeur brut, et que T est petit avec T: Freeze, on peut transmettre directement la valeur pointée en by-value plutôt que la référence
  • Des API comme HashMap::get() sont de bonnes candidates
    • Si la clé est de type i32, il faut aujourd’hui la déverser sur la pile puis en passer le pointeur
    • Ce trafic mémoire peut être évité
  • Un ABI guidé par le profilage serait encore plus agressif
    • Les arguments les plus hot peuvent être priorisés dans l’ordre d’allocation des registres
    • Même si une grosse struct est passée par référence, l’appelant peut précharger 3 champs i64 particulièrement hot et les passer à la fois par pointeur et par registres
    • Le callee devant de toute façon effectuer ces loads, cela ne lui ajoute pas de coût
    • Un profil d’instrumentation pourrait même justifier la duplication d’une fonction avec des ABI différents

Pourquoi ce n’est pas encore fait

  • Rust a moins de contraintes d’ABI que le C++, ce qui lui permet potentiellement de produire un meilleur code, et cette idée rejoint ce que fait en pratique la Go register ABI
  • Le premier obstacle est la complexité de la génération de code ABI
    • LLVM fournit très peu de leviers de contrôle utiles
    • Même dans rustc, ce n’est pas une zone particulièrement accueillante
    • Une mauvaise implémentation peut avoir de mauvaises conséquences en matière d’utilisabilité
  • Un autre obstacle est le manque d’expertise
    • Parmi les contributeurs rustc, peu de personnes comprennent suffisamment la sémantique LLVM et les caractéristiques de génération de code pour produire un bon résultat sans faire crasher LLVM
  • Le temps de compilation peut aussi devenir un poids
    • Plus la signature de fonction devient complexe, plus LLVM doit traiter de code dans les prologues et épilogues
    • Cela dit, -Zcallconv est pensé pour n’être utilisé qu’avec les optimisations activées, donc ce n’est pas vu comme un défaut rédhibitoire
  • Le code ABI de Rust est un domaine au faible bus factor, et la connaissance de LLVM peut être directement mise à profit pour aider l’équipe du compilateur Rust à produire un code mieux optimisé

1 commentaires

 
GN⁺ 2024-04-20
Avis sur Hacker News
  • Lorsqu’on optimise une convention d’appel, l’essentiel n’est pas de raisonner mentalement sur ce qui paraît bon, mais de mesurer les performances.
    Un code est bon s’il est rapide, pas s’il a l’air rapide.
    Ce que l’auteur qualifie de mauvais code peut parfois être le plus rapide pour des raisons pas du tout intuitives, et on ne peut le savoir qu’en le mesurant sur de gros benchmarks.
    L’une des raisons pour lesquelles une convention d’appel qui paraît mauvaise peut bien fonctionner est qu’elle économise des registres d’arguments, ce qui facilite un peu la tâche de l’allocateur de registres.
    De plus, les CPU actuels sont optimisés pour les flux d’instructions produits par les compilateurs C ; en particulier, si l’on génère du code dans le style d’un compilateur C comme MSVC, qui transmet étonnamment souvent via la pile, on peut tomber sur le point optimal du CPU.
    L’inlining est devenu tellement efficace que, sur les chemins chauds, les appels deviennent des frontières rares ; et si ces frontières sont un peu désordonnées mais simplifient le reste, ce n’est pas forcément un problème.
    Cela ne veut pas dire que le changement proposé ici est mauvais, mais débattre sans mesures en se fondant seulement sur du code qui paraît étrange, c’est étrange.
    J’ai travaillé sur l’optimisation des conventions d’appel dans JavaScriptCore, et dans du vrai gros code, il arrivait étonnamment souvent que du code avec passage par la pile, pourtant d’apparence mauvaise, l’emporte.

    • Je suis tout à fait d’accord pour dire qu’un code qui a l’air rapide ne l’est pas toujours réellement.
      Cela dit, je ne pense pas que les mesures de performance doivent être le seul critère.
      Dans l’expression selon laquelle les CPU « actuels » sont optimisés, le mot important est actuels : les CPU changent en permanence, donc une convention d’appel doit être une conception de long terme.
      C’est pourquoi, malheureusement, il est préférable de ne pas trop s’éloigner de la manière dont C++ procède, car les optimisations des futurs processeurs viseront probablement aussi cette direction.
      En même temps, il faut tenir compte de principes généraux peu susceptibles de changer facilement, comme l’économie des registres d’arguments, afin de rendre la convention d’appel robuste et tournée vers l’avenir.
      C’est un peu étrange pour moi de dire cela, car j’ai l’impression que Rust est devenu trop conservateur ces dernières années en matière de tolérance aux étrangetés (https://steveklabnik.com/writing/the-language-strangeness-bu...). Après tout, on ne peut pas faire mieux sans être différent.
    • Le fait que le passage par registres soit plus rapide dépend aussi du corps de la fonction.
      Si, dès le début, la fonction prend l’adresse d’un paramètre pour la passer à une fonction inconnue, il faudra de toute façon le déverser sur la pile.
      Il serait intéressant de voir une optimisation de la convention d’appel basée sur le corps de la fonction. Pour une fonction statique en C, cela devrait être sûr tant que son adresse n’est pas prise.
    • Cette expérience ne se transpose pas complètement.
      Un JIT a un avantage sur ce problème, car il a déjà collecté beaucoup d’informations sur le CPU réellement en cours d’exécution avant même de générer la moindre ligne d’assembleur.
      Dans du code compilé purement statiquement, on ne peut pas connaître l’ensemble des fonctionnalités de l’architecture à l’exécution ; on rencontre donc souvent des barrières à l’inlining précisément dans le code que l’on voudrait le plus optimiser.
    • Les performances peuvent inclure non seulement la vitesse d’exécution, mais aussi la taille du binaire.
      Rust semble actuellement faible sur ce point pour les petites plateformes, et la convention d’appel pourrait aider en ce qui concerne le retour de Result.
    • Le texte original porte surtout sur x86, et Intel a accompli pendant des décennies un travail d’ingénierie remarquable pour que du code x86 disgracieux s’exécute rapidement sur son propre silicium, celui que les gens achètent.
      Je me demande néanmoins si les avantages empiriques du passage par la pile continuent de s’appliquer sur des CPU ARMV8 dotés de nombreux registres, ou sur RISC-V.
  • C’est une ébauche raisonnable, mais elle omet la distinction entre registres sauvegardés par l’appelant et registres sauvegardés par l’appelé, et commet l’erreur courante d’affecter une partie des registres d’entrée aux sorties.
    L’idée que les débogueurs comprendront une convention d’appel différente de celle du C est également optimiste. Quoi que DWARF puisse encoder, en pratique il risque fort d’échouer lamentablement.
    Faire varier l’ABI selon les réglages d’optimisation interagit très mal avec la compilation séparée.
    Réarranger les arguments comme dans un bin packing fonctionnerait, mais augmenterait fortement la complexité du compilateur, et je ne sais pas si cela en vaut la peine par rapport à un placement « premier ajustement » de gauche à droite. Il deviendrait aussi plus difficile pour les développeurs de prédire où iront les arguments.
    La grande orientation consistant à avoir des conventions d’appel différentes pour les fonctions dont l’adresse s’échappe et celles dont ce n’est pas le cas est pertinente. La technique consistant à détacher un prologue qui fait l’adaptation d’impédance fonctionne aussi bien.
    Rust devrait être prêt à avoir une convention d’appel différente de celle du C, mais je ne sais pas si cela doit être une convention unique codée en dur utilisée par toutes les fonctions. L’intégrer au système de types semble naturel, et permettre aux développeurs de contrôler la convention d’appel ferait disparaître l’un des avantages de performance de l’assembleur.

    • Je me demande pourquoi utiliser certains registres d’entrée comme registres de sortie pose un tel problème.
      Du point de vue de l’appelant, il faut de toute façon libérer les registres de sortie entre deux appels de fonction, et c’est assez largement utilisé dans les conventions d’appel système.
      J’imagine que c’est pour permettre à l’appelé de préparer facilement les valeurs de sortie tout en conservant les valeurs d’entrée intactes. Si c’est le cas, je comprends qu’on veuille placer les registres de sortie à la fin de l’ordre des entrées pour éviter les chevauchements, mais je ne vois pas bien pourquoi il faudrait interdire totalement tout chevauchement.
    • Si l’on permet aux développeurs de contrôler la convention d’appel, on empêche en même temps une optimisation qui, dans une chaîne où Function A appelle Function B, Function C, Function D, consisterait à changer les arguments des fonctions intermédiaires vers une autre convention afin de réduire l’overhead.
      Je me demande quelle sémantique permettrait à la fois de préserver ce genre d’optimisation et d’autoriser ce contrôle, ou si ce n’est pas en pratique une illusion.
      En réalité, l’assembleur n’est pas la cible de la plupart des optimisations des compilateurs, ce qui le désavantage en performance. Il ne bénéficie souvent pas non plus d’optimisations du type « examiner le comportement, déterminer que c’est entièrement redondant et tout supprimer », et nous ne sommes plus dans les années 1990.
      Cela dit, dans les cas où l’on ne peut même pas envisager ces optimisations, je pense que le principal domaine où l’assembleur inline sera clairement distancé est la profile-guided optimization. Les développeurs d’applications connaissent parfaitement le comportement de leur code, contrairement aux développeurs de compilateurs.
      L’overhead d’appel peut être éliminé en écrivant davantage d’assembleur jusqu’à couvrir les frontières chaudes concernées.
    • DWARF n’encode actuellement absolument pas les conventions d’appel personnalisées.
    • Le bin packing risque plutôt de ralentir les choses, et peut notamment créer une chaîne de dépendances dans le cas des bool.
      Sur x64, pour des bool, il ne semble pas vraiment y avoir de meilleure méthode que de les placer d’abord dans des registres, de faire des décalages, puis de faire un OR avec le résultat.
      L’approche simple crée une chaîne de dépendances de longueur 64 et peut entraîner une pénalité de 64 cycles, même si avec un bon traitement on pourrait peut-être réduire cela à 6 cycles, ou plutôt 12 cycles de manière réaliste.
      Mais la question est aussi de savoir d’où viennent ces 64 bool. Comme il n’y a pas autant de registres, il faudra au final les relire depuis la pile.
      Si l’ABI Rust compacte déjà ainsi les bool à l’intérieur des structures, alors il faudra le faire de toute façon, mais je ne le sais pas vraiment.
      Et l’appelant devra ensuite tout décompacter.
      Il serait sans doute plus facile d’apprendre au compilateur à laisser les valeurs se déverser dans l’espace de résultat sur la pile, et cela aurait de bonnes chances d’être plus performant.
    • La plupart des processeurs modernes transmettent facilement une lecture qui suit immédiatement une écriture, et disposent de diverses astuces pour suivre l’état de la pile.
      Dans ce cas, je me demande dans quelle mesure placer les valeurs dans des registres aide réellement.
  • La convention d’appel du C n’est pas terrible.
    Il est vrai qu’on ne peut pas changer la convention d’appel du C, mais cela ne la rend pas moins regrettable.
    Tous les registres sauvegardés par l’appelant disponibles devraient être utilisés pour les arguments et les valeurs de retour, alors que l’ABI SysV traditionnelle n’utilise qu’un registre, parfois deux, pour les retours.
    Si l’on retourne struct Point3D { long x, y, z }, on pourrait mettre Point3D dans rax, rdi, rsi, mais il est déversé sur la pile.
    D’autres systèmes ont d’autres astuces. Si ma mémoire est bonne, dans SBCL, lorsqu’une fonction retourne plusieurs valeurs, elle positionne le carry flag à la fin. Par exemple, il me semblerait intéressant d’utiliser le carry flag pour indiquer qu’un Result contient une erreur.

    • « Pas terrible » est une formule forte, mais c’est vrai pour les valeurs de retour.
      La convention d’appel du C prend essentiellement en charge ce que C prend en charge, à savoir le retour d’un seul argument. Même le retour de structures n’est pas vraiment bien géré.
      En C, on est proche du « il fallait s’y attendre », et côté C++, cela devient plutôt « il suffit d’inliner ».
      En revanche, les spills mémoire se produisent réellement. Par exemple, le grand espace de registres de SPARC et ses fenêtres laissaient de nombreux registres inutilisés dans les fonctions simples, et le spill de l’anneau de registres entraînait une grosse consommation de pile qui cassait le cache.
      Sur x86, même avec beaucoup de mov pour déplacer les données « là où il faut », le résultat était souvent plus rapide.
      Si l’on ne regarde que le code de l’appelé, on a envie de dire : « cet argument ici, cette valeur de retour là, ce sera forcément plus rapide », mais on ne connaît pas l’appelant.
      On ne peut pas garantir que la préparation des arguments passera telle quelle, ni que la valeur de retour sera consommée dans un chemin chaud. Par exemple, si struct Point { x: i32, y: i32, z: i32 } est utilisé comme argument/retour et que l’appelant fait dans une boucle quelque chose comme mystruct.deepinside.point[i] = func(mystruct.deepinside.point[i]), charger et extraire via des registres peut devenir un overhead ou empêcher la vectorisation.
      L’appelé ne peut pas le savoir, sauf lorsque le compilateur peut voir les deux côtés et inliner.
      Le fruit le plus facile à cueillir côté appels semble être de supprimer l’hypothèse, ancrée dans presque toutes les ABI C, selon laquelle une fonction retourne une seule valeur primitive. Pour le reste, il faudra beaucoup de benchmarks et de statistiques de génération de code.
  • Rust comporte un autre détail regrettable qui fait que les structures deviennent plus grosses qu’on ne le souhaiterait
    Si l’on prend une structure Foo contenant 8 champs Option valant None ou Some(u8), en C on peut les représenter avec 8 bool de 1 bit et 8 uint8_t, soit 9 octets au total
    En Rust, cela devient 16 octets, avec un discriminateur de 1 octet et un uint8_t répétés 8 fois
    La raison est que la structure doit pouvoir fournir des emprunts de ses champs. Si l’on a &Foo, le compilateur doit pouvoir créer &Foo::some_field, c’est-à-dire un &Option, et ce &Option doit avoir la même forme que tous les autres &Option du programme
    L’Option interne doit donc avoir la même disposition que les autres Option du programme, c’est-à-dire ses bits de discrimination arrondis à l’octet, plus le u8. Même si l’on ne crée jamais réellement &Foo::some_field, la structure paie ce coût
    Avec des Option de types plus gros, c’est encore pire. Dans une structure avec 8 champs Option, chaque discriminateur est arrondi à 2 octets, pour un total de 32 octets, et un quart — presque la moitié si l’on inclut les bits inutilisés des discriminateurs — est gaspillé en padding intermédiaire. L’équivalent en C tiendrait en 18 octets
    Avec Option, une structure Rust peut faire 128 octets, contre 72 octets pour une structure C
    Bien sûr, on peut obtenir la même représentation qu’en C en utilisant soi-même un u8 pour les discriminateurs compactés et 8 MaybeUninit, puis en écrivant des fonctions qui mappent depuis &Foo vers Option<&T> et depuis &mut Foo vers Option<&mut T>. Mais on ne peut pas aller vers &Option ou &mut Option
    https://play.rust-lang.org/?version=stable&mode=debug&editio...

    • Comme il faut aussi implémenter soi-même la version C, ce n’est pas si étrange de devoir faire la même chose en Rust
      En pratique, on décrit un type défini par l’utilisateur contenant 8 Option, et dès qu’on commence à se soucier des performances, il faut gérer soi-même les Option internes
    • La version équivalente en C doit elle aussi être implémentée à la main
      Le fait que Rust fournisse une fonctionnalité pratique que l’on peut choisir d’utiliser quand elle correspond à l’objectif n’en fait pas vraiment un inconvénient
      Le cas d’usage décrit est relativement rare, et si c’est un vrai goulot d’étranglement de performance, passer un peu plus de temps à l’implémenter en Rust n’est pas un gros problème
      Les avantages apportés par le type Option<_> dans l’usage courant sont très importants, donc il est difficile de voir cela comme un « détail regrettable » de Rust
  • Il est dit que si l’adresse d’une fonction non polymorphe et non inline peut être prise comme pointeur de fonction, on crée un shim utilisant -Zcallconv=legacy puis on effectue immédiatement un appel terminal vers l’implémentation réelle ; je comprends l’intention de préserver l’égalité des pointeurs de fonction
    Mais si le shim legacy fait un appel terminal vers une fonction suivant la convention d’appel Rust, il ne peut pas corriger les différences de valeur de retour entre conventions d’appel, non ?

    • Exact. Les gens ont tendance à oublier la moitié « retour » des conventions d’appel, donc cela ressemble à une coquille compréhensible
  • Sujet un peu différent, mais je me demande s’il est actuellement possible d’assurer l’interopérabilité entre Go et Rust
    J’ai le souvenir d’avoir vu un cas où cela avait été fait en plaçant Zig au milieu, mais je n’arrive pas à le retrouver. J’ai du code Rust legacy et j’aimerais le migrer progressivement vers Go

    • C’est possible. On peut utiliser une FFI extern "C" via CGO pour appeler des fonctions Rust
      J’ai présenté à RustConf 2023 comment cela se faisait dans la recherche de code GitHub (https://www.youtube.com/watch?v=KYdlqhb267c), et j’ai entendu dire depuis que des acteurs comme 1Password faisaient quelque chose de similaire
      Faire passer des types à travers une frontière d’interopérabilité C est fastidieux, donc ce n’est pas très amusant, mais c’est possible et cela permet de réutiliser du code
    • Pour appeler Rust depuis Go, il suffit de déclarer les fonctions Rust en extern "C", puis de les appeler depuis Go comme on appellerait du C
      Je ne sais pas vraiment pour le sens inverse
    • Mélanger mémoire managée et non managée n’est généralement pas une bonne idée
      Le code managé doit pouvoir posséder la mémoire qu’il libère ou déplace, tandis que le code non managé doit pouvoir raisonner sur le moment où la mémoire est libérée ou déplacée
      Des choses comme cgo permettent de mélanger des appels FFI depuis le code managé de Go vers de la mémoire non managée, mais cela a un coût
      Ce problème apparaît toujours dans les implémentations où les langages qui s’appellent mutuellement ne partagent pas le même garbage collector
      Mélanger code managé et non managé est une vieille idée, mais cela reste un sujet de recherche actif
      À moins que le runtime intégré n’ait été conçu pour cela, appeler du code managé depuis du code non managé est presque toujours une mauvaise idée, et on intercale généralement une couche de sérialisation
    • Comme je dois pas mal utiliser Rust et Swift, j’ai fini par adopter une approche consistant à échanger des tableaux d’octets de protobuf sérialisés via des appels de fonction routiniers
      Si c’était mon activité principale, je trouverais peut-être ça médiocre, mais j’en avais assez de revenir au code toutes les quelques semaines sans me souvenir de ce qu’il faisait ni comment
    • Dans un exemple assez maudit, j’ai récemment appelé du code Go depuis Rust en passant par du C au milieu
      J’ai transmis à du code Go une closure Rust avec état comme callback, pour l’insérer dans une fonction de la bibliothèque standard Go, y compris avec le déroulement d’un panic à l’intérieur de la closure Rust
      https://github.com/Voultapher/sort-research-rs/commit/df6c91...
  • J’ai passé un bon moment dans l’inspecteur d’éléments à essayer de comprendre comment les titres de section étaient inclinés, mais avec les outils de Safari, je suis resté bloqué. Comment ont-ils bien pu faire ça ?

    • Le style est sur l’élément .post-title : transform: skewY(-2deg) translate(-1rem, -0.4rem);
    • À ce propos, je pensais que la minimap utilisait la fonction CSS element() (https://developer.mozilla.org/en-US/docs/Web/CSS/element), mais en réalité c’était une copie du corps de l’article réduite à une taille minuscule.
    • Sur h1, h2, h3, h4, h5, h6, les règles suivantes sont appliquées : transform:skewY(-2deg) translate(-1rem,0rem);, transform-origin:top;, font-style:italic;, text-decoration-line:underline;, text-decoration-color:goldenrod;, text-underline-offset:4%;, text-decoration-thickness:.25ex.
  • À titre de contraste, il y a l’article de 2019 « How Swift Achieved Dynamic Linking Where Rust Couldn't »
    https://faultlore.com/blah/swift-abi/
    Il est dommage que Rust n’ait pas encore de convention d’appel pour la sémantique au niveau Rust, mais en même temps, cet article montre l’énorme quantité de travail nécessaire pour y parvenir.
    Apple était profondément motivé à faire de Swift un langage système pratique sur lequel les applications puissent s’appuyer, mais Rust n’a pas un tel parrainage.
    Discussion HN : https://news.ycombinator.com/item?id=21488415

    • Il faut aussi reconnaître, par souci d’équité, que l’approche de Swift a un coût à l’exécution.
      J’aimerais que Rust offre davantage d’options pour prendre en charge ce compromis, sans nécessairement se limiter à des propositions comme https://github.com/rust-lang/rfcs/pull/3470.
  • Si le compilateur Rust actuel inline agressivement puis optimise, je me demande si cela vaut vraiment la peine.
    Si la fonction appelée est petite, elle sera inlinée ; si elle est grosse, elle passera pas mal de temps à l’intérieur de la fonction, donc l’overhead de l’appel sera faible.

    • Les fonctions appelées à l’exécution, par exemple dyn Trait, ne peuvent pas être inlinées, donc ce genre de changement aiderait.
      Si l’on peut rendre les appels peu coûteux, on n’a pas besoin d’inliner aussi agressivement, ce qui peut aussi aider pour la taille du code et les temps de compilation.
    • Ça en vaut probablement la peine.
      Une fonction complexe qui ne se prête pas à l’inlining va probablement accéder plusieurs fois à la mémoire, et ces accès ont de bonnes chances d’être le goulot d’étranglement.
      Le passage par la pile resserre encore ce goulot, car il augmente la pression sur le cache et les chargements/stockages.
      Si Rust pouvait transmettre les arguments de manière optimale dans une proportion importante des appels de fonction, cela éviterait non seulement quelques cycles d’accès au L1, mais pourrait aussi permettre au CPU d’atteindre plus vite le véritable goulot d’étranglement mémoire.
      Il y aurait peut-être quelques pourcents de gain, mais là je bois du vin et je ne fais pas les calculs.
  • Quelqu’un peut expliquer le moyen mnémotechnique « Diana’s silk dress cost $89 » mentionné dans les références x86 ?