La convention d’appel Rust que nous devrions avoir
(mcyoung.xyz)- 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, entrelegacypour le comportement actuel etfastpour 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
poisonpour laisser sans coût les registres inutilisés - Des types Rust comme les struct, enum, union,
boolouResultpeuvent ê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é dansrdietrsi - C’est un cas où le chemin par défaut de Rust est encore plus conservateur que l’ABI C Linux
- Avec
-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-Zcallconvpermet de choisir la convention à utiliser-Zcallconv=legacy: comportement actuel-Zcallconv=fast: nouvelle approche centrée sur les registres-Opourrait aussi activer automatiquement-Zcallconv=fast
- La convention d’appel
fastne 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=fastpourrait ne pas être pris en charge - Dans les builds de debug sans optimisations,
fastpeut 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
- Le flag s’applique au niveau crate, mais il est difficile d’exprimer dans un pointeur de fonction quelle version de
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
ptrsupplémentaire avec l’attributsret - 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
rmetapour é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=fastavec 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
i64correspondant devient unptr - 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
poisonpour les arguments inutilisés permet d’éviter un coût supplémentaire- LLVM peut considérer
poisoncomme la valeur la plus pratique du moment - Si
poisonest 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 dansrcx, et le code qui chargepoisondans les 13 autres registres ne génère aucun code après optimisation
- LLVM peut considérer
- 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 bitsboolà 1 bitchartechniquement à 21 bits, mais traité ici comme un alias deu32pour simplifier
- Les struct contenant beaucoup de
boolpeuvent renvoyer plusieursboolvia 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
u128ouf64 - 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
boolpar registre
Exemple de fonction Rust complexe et limites actuelles de rustc
- Dans l’exemple
do_thingqui prendOption<usize>,&dyn Context,&str,[char; 6]et une structOptions, 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, i32xmm0: i32, i32, i32, i32xmm1: 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 } &strdevient{ ptr, i64 }[char; 6]devient[6 x i32]Optionsdevient{ i32, i1, i1, i1 }
- En attachant les métadonnées
!dbgaux 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
Resultoffre une marge d’optimisation particulière- Quand
?traverse plusieurs niveaux d’appels, cela peut provoquer beaucoup de moves de registres redondants - Si
Resultest 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 àIntosont délicats, mais implémentables
- Quand
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
&Tn’est pas conservé, n’est pas transformé en pointeur brut, et queTest petit avecT: 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é
- Si la clé est de type
- 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
i64particuliè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,
-Zcallconvest 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
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.
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.
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.
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.
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.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.
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.
Function AappelleFunction 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.
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.
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 mettrePoint3Ddansrax,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
Resultcontient une erreur.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
movpour 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 commemystruct.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
Foocontenant 8 champsOptionvalantNoneouSome(u8), en C on peut les représenter avec 8boolde 1 bit et 8uint8_t, soit 9 octets au totalEn Rust, cela devient 16 octets, avec un discriminateur de 1 octet et un
uint8_trépétés 8 foisLa 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&Optiondoit avoir la même forme que tous les autres&Optiondu programmeL’
Optioninterne doit donc avoir la même disposition que les autresOptiondu programme, c’est-à-dire ses bits de discrimination arrondis à l’octet, plus leu8. Même si l’on ne crée jamais réellement&Foo::some_field, la structure paie ce coûtAvec des
Optionde types plus gros, c’est encore pire. Dans une structure avec 8 champsOption, 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 octetsAvec
Option, une structure Rust peut faire 128 octets, contre 72 octets pour une structure CBien sûr, on peut obtenir la même représentation qu’en C en utilisant soi-même un
u8pour les discriminateurs compactés et 8MaybeUninit, puis en écrivant des fonctions qui mappent depuis&FooversOption<&T>et depuis&mut FooversOption<&mut T>. Mais on ne peut pas aller vers&Optionou&mut Optionhttps://play.rust-lang.org/?version=stable&mode=debug&editio...
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 lesOptioninternesLe 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 RustIl 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=legacypuis 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 fonctionMais 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 ?
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
extern "C"via CGO pour appeler des fonctions RustJ’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
extern "C", puis de les appeler depuis Go comme on appellerait du CJe ne sais pas vraiment pour le sens inverse
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
cgopermettent 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ûtCe 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
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
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 ?
.post-title:transform: skewY(-2deg) translate(-1rem, -0.4rem);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.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
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.
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.
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 ?