Intégration de Rust dans Go : Une Exploration Technique
Le langage Go offre un bon support pour l’appel de code en assembleur, et une grande partie du code cryptographique rapide dans la bibliothèque standard est optimisée avec soin en assembleur, permettant des gains de performance dépassant les 20 fois. Cependant, la rédaction de code assembleur est complexe, et sa révision l’est encore plus, surtout dans le domaine de la cryptographie, qui est particulièrement exigeant. Ne serait-il pas idéal de pouvoir écrire ces fonctions critiques dans un langage de niveau supérieur ?
Ce document raconte une expérience quelque peu audacieuse visant à appeler du code Rust depuis Go, suffisamment rapidement pour remplacer l’assembleur. Il n’est pas nécessaire de connaître Rust ou les détails internes des compilateurs, mais une compréhension de ce qu’est un linker serait bénéfique.
Bonjour, lecteurs du futur ! Au cours des sept dernières années, certaines choses ont évolué, mais gardez à l’esprit que cela a toujours été une exploration technique amusante, et non un guide de production.
Si vous recherchez des méthodes sérieuses pour appeler Rust depuis Go, sachez que cgo est aujourd’hui plus rapide et continue de s’améliorer. Il existe également des alternatives expérimentales sans cgo, comme purego et wazero.
Cependant, expérimenter avec ces technologies est fascinant. J’ai longtemps envisagé d’utiliser Wasm pour une interface de fonction étrangère (FFI) multiplateforme avec une meilleure expérience développeur. J’ai même utilisé une technique similaire dans la bibliothèque standard pour invoquer le vérificateur X.509 de macOS.
Pour ma part, j’ai donné une présentation à une conférence Go en portant un t-shirt fraîchement imprimé de la Rust Evangelism Strike Force. Cela m’a permis de rejoindre l’équipe Go chez Google, que j’ai quittée pour devenir mainteneur indépendant de Go.
La lutte contre l’assembleur cryptographique se poursuit, mais maintenant avec des politiques strictes et des générateurs dédiés. Juste hier, j’ai diffusé en direct une session de benchmarking pour remplacer une partie de l’assembleur de la courbe P-256 par du code Go formellement vérifié.
Pourquoi choisir Rust ?
Pour être franc, je ne maîtrise pas Rust et je n’éprouve pas le besoin de l’utiliser pour ma programmation quotidienne. Cependant, je sais que Rust est un langage très modulable et optimisable, tout en étant plus lisible que l’assembleur. (Après tout, presque tout est plus lisible que l’assembleur !) Go s’efforce de trouver des valeurs par défaut adaptées à ses cas d’utilisation principaux et n’accepte que les fonctionnalités suffisamment rapides pour être activées par défaut, dans une lutte constante et réussie contre les réglages complexes. J’apprécie cela. Mais pour notre objectif actuel, nous avons besoin d’un langage qui ne rechigne pas à générer des fonctions uniquement sur la pile, en contournant manuellement les vérifications de sécurité.
Si un langage peut être contraint pour se comporter comme de l’assembleur et être optimisé pour être aussi utile que l’assembleur, ce pourrait être Rust. De plus, Rust est un langage sûr, en développement actif, et il existe déjà un bon écosystème de code cryptographique Rust performant à exploiter.
Pourquoi éviter cgo ?
Go dispose d’une interface de fonction étrangère, cgo, qui permet aux programmes Go d’appeler des fonctions C de la manière la plus naturelle possible, ce qui, malheureusement, n’est pas très naturel du tout. J’ai une connaissance approfondie de cgo, et je peux vous assurer que ce n’est pas une partie de plaisir.
En utilisant l’ABI C comme lingua franca des FFI, nous pouvons appeler n’importe quoi depuis n’importe quel langage : Rust peut être compilé en une bibliothèque exposant l’ABI C, et cgo peut l’utiliser. C’est maladroit, mais cela fonctionne.
Nous pouvons même utiliser un reverse-cgo pour construire Go en une bibliothèque C et l’appeler depuis d’autres langages, comme je l’ai fait avec Python pour le fun. (C’était une blague, ne me prenez pas trop au sérieux.)
Cependant, cgo effectue de nombreuses opérations pour permettre cette petite touche de naturel dans Go : il met en place une pile entière pour que C puisse y vivre, il prépare des appels defer pour anticiper un panic dans un callback Go… cela pourrait faire l’objet d’un article à part entière.
En conséquence, le coût de performance de chaque appel cgo est bien trop élevé pour notre cas d’utilisation envisagé : de petites fonctions critiques.
Lier le tout
Voici l’idée : si nous avons du code Rust aussi contraint que l’assembleur, nous devrions pouvoir l’utiliser comme de l’assembleur et y accéder directement. Peut-être avec une fine couche de liaison.
Nous n’avons pas besoin de travailler au niveau IR : le compilateur Go convertit à la fois le code et l’assembleur de haut niveau en code machine avant la liaison depuis Go 1.3.
Cela est confirmé par l’existence de la « liaison externe », où le linker système est utilisé pour assembler un programme Go. C’est ainsi que fonctionne cgo : il compile C avec le compilateur C, Go avec le compilateur Go, et lie le tout avec clang
ou gcc
. Nous pouvons même passer des options au linker avec CGO_LDFLAGS
.
En dessous de toutes les fonctionnalités de sécurité de cgo, nous trouvons sûrement un appel de fonction inter-langages, après tout.
Il serait souhaitable de découvrir comment faire cela sans patcher le compilateur. D’abord, voyons comment lier un programme Go avec une archive Rust.
Je n’ai pas trouvé de moyen satisfaisant pour lier un blob étranger avec go build
(pourquoi devrait-il y en avoir un ?) à part en utilisant des directives #cgo
. Cependant, invoquer cgo fait que les fichiers .s
sont envoyés au compilateur C au lieu du compilateur Go, et mes amis, nous aurons besoin de l’assembleur Go.
Heureusement, go/build n’est rien d’autre qu’une interface ! Go offre un ensemble d’outils de bas niveau pour compiler et lier des programmes, go build
se contente de rassembler des fichiers et d’invoquer ces outils. Nous pouvons suivre ce qu’il fait en utilisant l’option -x
.
J’ai construit ce petit Makefile en suivant une invocation de cgo avec -x -ldflags "-v -linkmode=external '-extldflags=-v'"
.
rustgo: rustgo.a
go tool link -o rustgo -extld clang -buildmode exe -buildid b01dca11ab1e -linkmode external -v rustgo.a
rustgo.a: hello.go hello.o
go tool compile -o rustgo.a -p main -buildid b01dca11ab1e -pack hello.go
go tool pack r rustgo.a hello.o
hello.o: hello.s
go tool asm -I "$(shell go env GOROOT)/pkg/include" -D GOOS_darwin -D GOARCH_amd64 -o hello.o hello.s
Cela compile un simple package principal composé d’un fichier Go (hello.go
) et d’un fichier assembleur Go (hello.s
).
Maintenant, si nous voulons lier un objet Rust, nous devons d’abord le construire en tant que bibliothèque statique…
libhello.a: hello.rs
rustc -g -O --crate-type staticlib hello.rs
… puis il suffit de dire au linker externe de lier le tout.
rustgo: rustgo.a libhello.a
go tool link -o rustgo -extld clang -buildmode exe -buildid b01dca11ab1e -linkmode external -v -extldflags='-lhello -L"$(CURDIR)"' rustgo.a
Plongée dans Rust
Nous avons réussi à établir le lien, mais les symboles ne serviront à rien s’ils restent inactifs. Il est essentiel d’appeler la fonction Rust depuis notre code Go.
Nous savons comment invoquer une fonction Go depuis Go. En langage d’assemblage, cet appel se présente sous la forme CALL hello(SB)
, où SB est un registre virtuel auquel tous les symboles globaux sont relatifs.
Pour appeler une fonction d’assemblage depuis Go, nous devons informer le compilateur de son existence, comme on le ferait avec un en-tête C, en écrivant func hello()
sans corps de fonction.
J’ai essayé toutes les combinaisons possibles pour appeler une fonction externe (Rust), mais toutes ont échoué en indiquant qu’elles ne pouvaient pas trouver le nom du symbole ou le corps de la fonction.
Cependant, cgo, qui est essentiellement un générateur de code massif, parvient finalement à invoquer cette fonction étrangère ! Comment cela fonctionne-t-il ?
J’ai découvert la réponse quelques jours plus tard.
//go:cgo_import_static _cgoPREFIX_Cfunc__Cmalloc
//go:linkname __cgofn__cgoPREFIX_Cfunc__Cmalloc _cgoPREFIX_Cfunc__Cmalloc
var __cgofn__cgoPREFIX_Cfunc__Cmalloc byte
var _cgoPREFIX_Cfunc__Cmalloc=unsafe.Pointer(&__cgofn__cgoPREFIX_Cfunc__Cmalloc)
Cela ressemble à une directive intéressante ! //go:linkname
crée simplement un alias de symbole dans le scope local (ce qui peut être utilisé pour appeler des fonctions privées !), et je suis convaincu que le truc du byte
est juste une astuce pour avoir quelque chose dont on peut prendre l’adresse, mais //go:cgo_import_static
… cela importe un symbole externe !
Note 2024 :
cgo_import_static
n’est plus utilisable, mais il existe des stratégies alternatives.
Avec cet outil en main et le Makefile ci-dessus, nous avons une chance d’invoquer cette fonction Rust (hello.rs
)
#[no_mangle]
pub extern fn hello() {
println!("Bonjour, Rust !");
}
(L’incantation no-mangle-pub-extern provient de ce tutoriel.)
depuis ce programme Go (hello.go
)
package main
//go:cgo_import_static hello
func trampoline()
func main() {
println("Bonjour, Go !");
trampoline()
}
avec l’aide de cet extrait d’assemblage. (hello.s
)
TEXT ·trampoline(SB), 0, $2048
JMP hello(SB)
RET
Le CALL
était un peu trop intelligent pour fonctionner, mais en utilisant un simple JMP
…
Bonjour, Go !
Bonjour, Rust !
panic: erreur d'exécution : adresse mémoire invalide ou déréférencement de pointeur nul
[signal SIGSEGV : violation de segmentation code=0x1 addr=0x0 pc=0x0]
💥
Eh bien, cela plante lorsqu’il essaie de retourner. De plus, cette valeur $2048
représente la taille totale de la pile que Rust est autorisé à utiliser (si elle place même la pile au bon endroit), et ne me demandez pas ce qui se passe si Rust essaie d’accéder à un tas… mais bon, je suis surpris que cela fonctionne du tout !
Conventions d’appel
Pour que cela retourne proprement et prenne des arguments, nous devons examiner de plus près les conventions d’appel de Go et de Rust. Une convention d’appel définit où se trouvent les arguments et les valeurs de retour lors des appels de fonction.
La convention d’appel de Go est décrite ici et ici. Pour Rust, nous allons nous intéresser à la convention d’appel par défaut pour l’interface de programmation d’applications (API), qui est la convention d’appel C standard.
Pour continuer, nous aurons besoin d’un débogueur. (LLDB prend en charge Go, mais les points d’arrêt semblent être défectueux sur macOS, donc j’ai dû travailler dans un conteneur Docker privilégié.)
![Zelda dangereux d’y aller seul](/content/images/2017/08/zelda-2.png)
La convention d’appel Go
La convention d’appel de Go est en grande partie non documentée, mais nous devons la comprendre pour avancer, alors voici ce que nous pouvons apprendre d’une désassemblage (spécifique à amd64). Examinons une fonction très simple.
// func foo(x, y uint64) uint64
TEXT ·foo(SB), 0, $256-24
MOVQ x+0(FP), DX
MOVQ DX, ret+16(FP)
RET
foo
dispose de 256 (0x100) octets de cadre local, 16 octets d’arguments, 8 octets de valeur de retour, et elle retourne son premier argument.
Comprendre l’Appel de Fonction en Go
Introduction à l’Appel de Fonction
Dans le langage Go, l’appel de fonction est un processus essentiel qui implique la gestion des arguments et de la pile. Cet article explore en détail comment les fonctions sont appelées, en mettant l’accent sur la gestion de la pile et les conventions d’appel.
Mécanisme d’Appel de Fonction
Lorsqu’une fonction est appelée, les arguments sont placés sur la pile dans un ordre inversé. Par exemple, dans le code suivant :
func main() {
foo(0xf0f0f0f0f0f0f0f0, 0x5555555555555555)
}
Le code assembleur correspondant montre que les arguments sont déplacés dans des emplacements spécifiques de la pile avant d’exécuter l’instruction CALL
. Cette instruction pousse l’adresse de retour sur la pile et effectue le saut vers la fonction appelée.
Gestion de la Pile
La gestion de la pile est cruciale pour le bon fonctionnement des appels de fonction. Dans l’exemple ci-dessus, la pile est ajustée pour faire de la place pour le cadre de la fonction. Cela se fait en soustrayant une valeur fixe de rsp
, ce qui permet de réserver l’espace nécessaire pour les variables locales et les arguments.
subq $0x108, %rsp
Avant de retourner, la pile est restaurée à son état précédent, garantissant ainsi que l’exécution se poursuit correctement après l’appel de fonction.
Pointeur de Cadre
Le pointeur de cadre (rbp
) est également géré de manière à faciliter le débogage. Il est mis à jour pour pointer vers le bas du cadre de la fonction appelante, permettant ainsi une traçabilité efficace des appels de fonction.
movq %rbp, 0x100(%rsp)
Registres Virtuels
Les documents de Go précisent que les registres SP
(Stack Pointer) et FP
(Frame Pointer) sont des registres virtuels. Cela signifie qu’ils sont ajustés par rapport à rsp
pour pointer vers le haut du cadre, ce qui simplifie la gestion des offsets lors de la modification de la taille du cadre.
Convention d’Appel C
La convention d’appel C, en particulier « sysv64 » sur x86-64, diffère considérablement de celle de Go. Les arguments sont passés via des registres spécifiques, et la valeur de retour est placée dans RAX
. De plus, certains registres sont sauvegardés par l’appelée, ce qui n’est pas le cas en Go où tous les registres sont sauvegardés par l’appelant.
Intégration des Conventions
Pour intégrer les conventions d’appel de Go et C, il est possible de créer un trampoline simple. Cela permet d’assurer que la fonction Rust utilise l’espace de pile de la fonction d’assemblage, garantissant ainsi une exécution fluide.
package main
//go:cgo_import_static increment
func trampoline(arg uint64) uint64
func main() {
println(trampoline(41))
}
Conclusion
La compréhension des mécanismes d’appel de fonction en Go est essentielle pour les développeurs souhaitant optimiser leurs applications. En maîtrisant la gestion de la pile, les pointeurs de cadre et les conventions d’appel, il est possible d’améliorer la performance et la fiabilité des programmes.
Introduction à l’Intégration de Rust avec Go
Dans le monde du développement logiciel, la combinaison de langages peut offrir des avantages significatifs, notamment en matière de performance. Cet article explore comment appeler des fonctions Rust depuis Go, en se concentrant sur les opérations cryptographiques. L’objectif est de démontrer que les appels Rust peuvent rivaliser avec les appels en assembleur en termes de rapidité.
Implémentation de l’Incrémentation
#[no_mangle]
pub extern fn increment(a: u64) -> u64 {
return a + 1;
}
La fonction ci-dessus, nommée increment
, prend un entier de 64 bits en entrée et retourne sa valeur incrémentée de 1. Cette fonction sera utilisée pour évaluer la performance des appels entre Go et Rust.
Appels sur macOS
Sur macOS, l’appel direct à la fonction ne fonctionne pas comme prévu. Au lieu de cela, il est redirigé vers une fonction intermédiaire, _cgo_thread_start
. Cela est dû à l’utilisation de cgo_import_static
, ce qui rend l’appel virtuel dans l’assemblage Go.
callq 0x40a27cd ; x_cgo_thread_start + 29
Pour contourner cette limitation, nous pouvons utiliser la directive //go:linkname
pour obtenir un pointeur vers la fonction, puis appeler ce pointeur.
import _ "unsafe"
//go:cgo_import_static increment
//go:linkname increment increment
var increment uintptr
var _increment=&increment
MOVQ &_increment(SB), AX
CALL AX
Performance des Appels
L’objectif principal de cette intégration est de s’assurer que les appels Rust sont presque aussi rapides que les appels en assembleur. Pour cela, nous allons effectuer des benchmarks.
Nous allons comparer l’incrémentation d’un entier uint64
en ligne, avec une fonction marquée //go:noinline
, avec l’appel Rust, et avec un appel cgo vers la même fonction Rust.
Les tests ont été réalisés sur un Mac avec un processeur Intel Core i5 à 2,9 GHz, et Rust a été compilé avec les options -g -O
.
name time/op
CallOverhead/Inline 1.72ns ± 3%
CallOverhead/Go 4.60ns ± 2%
CallOverhead/rustgo 5.11ns ± 4%
CallOverhead/cgo 73.6ns ± 0%
Les résultats montrent que rustgo
est 11% plus lent qu’un appel de fonction Go, mais presque 15 fois plus rapide qu’un appel cgo !
Sur Linux, sans le contournement du pointeur de fonction, la performance est encore meilleure, avec seulement 2% de surcharge.
name time/op
CallOverhead/Inline 1.67ns ± 2%
CallOverhead/Go 4.49ns ± 3%
CallOverhead/rustgo 4.58ns ± 3%
CallOverhead/cgo 69.4ns ± 0%
Exemple Pratique
Pour illustrer cette intégration, nous allons utiliser la bibliothèque curve25519-dalek
, en nous concentrant sur la multiplication d’un point de base de la courbe par un scalaire et en retournant sa représentation Edwards.
Les benchmarks de Cargo montrent que cette opération prend environ 22.9 µs ± 17%.
test curve::bench::basepoint_mult ... bench: 17,276 ns/iter (+/- 3,057)
test curve::bench::edwards_compress ... bench: 5,633 ns/iter (+/- 858)
Du côté de Go, nous allons exposer une API simple.
func ScalarBaseMult(dst, in *[32]byte)
Du côté Rust, la construction de l’interface est similaire à celle d’une FFI normale.
#![no_std]
extern crate curve25519_dalek;
use curve25519_dalek::scalar::Scalar;
use curve25519_dalek::constants;
#[no_mangle]
pub extern fn scalar_base_mult(dst: &mut [u8; 32], k: &[u8; 32]) {
let res = &constants::ED25519_BASEPOINT_TABLE * &Scalar(*k);
dst.clone_from(res.compress_edwards().as_bytes());
}
Pour compiler la bibliothèque, nous utilisons cargo build --release
avec un fichier Cargo.toml
qui définit les dépendances et configure curve25519-dalek
pour utiliser les mathématiques les plus efficaces sans bibliothèque standard.
[package]
name="ed25519-dalek-rustgo"
version="0.0.0"
[lib]
crate-type=["staticlib"]
[dependencies.curve25519-dalek]
version="^0.9"
default-features=false
features=["nightly"]
[profile.release]
debug=true
Enfin, nous devons ajuster le trampoline pour accepter deux arguments et ne rien retourner.
TEXT ·ScalarBaseMult(SB), 0, $16384-16
MOVQ dst+0(FP), DI
MOVQ in+8(FP), SI
MOVQ SP, BX
ADDQ $16384, SP
ANDQ $~15, SP
MOVQ ·_scalar_base_mult(SB), AX
CALL AX
MOVQ BX, SP
RET
Le résultat est un appel Go transparent avec des performances qui se rapprochent de celles du benchmark Rust pur, et qui est presque 6% plus rapide que cgo !
name old time/op new time/op delta
RustScalarBaseMult 23.7µs ± 1% 22.3µs ± 4% -5.88% (p=0.003 n=5+7)
Pour comparaison, une fonctionnalité similaire est fournie par github.com/agl/ed25519/edwards25519
, et cette bibliothèque pure Go prend presque trois fois plus de temps.
h := &edwards25519.ExtendedGroupElement{}
edwards25519.GeScalarMultBase(h, &k)
h.ToBytes(&dst)
Emballage
Nous avons maintenant confirmé que cela fonctionne, ce qui est enthousiasmant ! Cependant, pour être réellement utilisable, il devra s’agir d’un package importable, et non d’un package contraint dans package main
par un processus de construction étrange.
C’est ici qu’intervient //go:binary-only-package
! Cette annotation nous permet d’indiquer au compilateur d’ignorer la source du package et d’utiliser uniquement le fichier de bibliothèque pré-construit .a
situé dans $GOPATH/pkg
.
Note de 2024 :
binary-only-package
n’existe plus, mais l’utilisation d’un fichier .syso était probablement la bonne solution à l’époque. Quoi qu’il en soit, cela a été une parenthèse amusante dans l’outillage de liaison.
Si nous parvenons à créer un fichier .a
compatible avec le linker natif de Go (cmd/link, également appelé le linker interne), nous pourrons le redistribuer, permettant ainsi à nos utilisateurs d’importer le package comme s’il s’agissait d’un package natif, y compris pour la compilation croisée (à condition que nous ayons inclus un .a
pour cette plateforme) !
La partie Go est simple et s’associe à l’assemblage et à Rust que nous avons déjà. Nous pouvons même inclure de la documentation pour le bénéfice de go doc
.
//go:binary-only-package
// Le package edwards25519 implémente des opérations sur une courbe Edwards qui est
// isomorphe à curve25519.
//
// Les opérations cryptographiques sont réalisées en appelant directement la bibliothèque Rust
// curve25519-dalek, sans cgo.
//
// Vous ne devriez pas réellement utiliser cela.
package edwards25519
import _ "unsafe"
//go:cgo_import_static scalar_base_mult
//go:linkname scalar_base_mult scalar_base_mult
var scalar_base_mult uintptr
var _scalar_base_mult = &scalar_base_mult
// ScalarBaseMult multiplie le scalaire d'entrée par le point de base de la courbe et écrit
// la représentation Edwards compressée du point résultant dans dst.
func ScalarBaseMult(dst, in *[32]byte)
Le Makefile devra subir des modifications importantes, car nous ne construisons plus un binaire et ne pouvons donc plus utiliser go tool link
.
Une archive .a
est simplement un ensemble de fichiers objets .o
dans un format ancien avec une table des symboles. Si nous parvenons à intégrer les symboles de la bibliothèque Rust libed25519_dalek_rustgo.a
dans l’archive edwards25519.a
créée par go tool compile
, nous devrions être en bonne voie.
Les archives .a
sont gérées par l’outil UNIX ar
, ou par son équivalent interne Go, cmd/pack (comme dans go tool pack
). Les deux formats sont légèrement différents, bien sûr. Nous devrons utiliser l’ar
de la plateforme pour libed25519_dalek_rustgo.a
et cmd/pack de Go pour edwards25519.a
.
(Par exemple, l’ar
sur mon macOS utilise la convention BSD qui consiste à nommer les fichiers #1/LEN
et à intégrer le nom du fichier de longueur LEN au début du fichier, afin de dépasser la longueur maximale de 16 octets. Cela a été déroutant.)
Pour regrouper les deux bibliothèques, j’ai essayé de faire la chose la plus simple (lire : bricolage) : extraire libed25519_dalek_rustgo.a
dans un dossier temporaire, puis regrouper les objets dans edwards25519.a
.
edwards25519/edwards25519.a: edwards25519/rustgo.go edwards25519/rustgo.o target/release/libed25519_dalek_rustgo.a
go tool compile -N -l -o $@ -p main -pack edwards25519/rustgo.go
go tool pack r $@ edwards25519/rustgo.o # depuis edwards25519/rustgo.s
mkdir -p target/release/libed25519_dalek_rustgo && cd target/release/libed25519_dalek_rustgo &&
rm -f *.o && ar xv "$(CURDIR)/target/release/libed25519_dalek_rustgo.a"
go tool pack r $@ target/release/libed25519_dalek_rustgo/*.o
.PHONY: install
install: edwards25519/edwards25519.a
mkdir -p "$(shell go env GOPATH)/pkg/darwin_amd64/$(IMPORT_PATH)/"
cp edwards25519/edwards25519.a "$(shell go env GOPATH)/pkg/darwin_amd64/$(IMPORT_PATH)/"
Imaginez ma surprise lorsque cela a fonctionné !
Avec le .a
en place, il ne reste plus qu’à créer un programme simple utilisant le package.
package main
import (
"bytes"
"encoding/hex"
"fmt"
"testing"
"github.com/FiloSottile/ed25519-dalek-rustgo/edwards25519"
)
func main() {
input, _ := hex.DecodeString("39129b3f7bbd7e17a39679b940018a737fc3bf430fcbc827029e67360aab3707")
expected, _ := hex.DecodeString("1cc4789ed5ea69f84ad460941ba0491ff532c1af1fa126733d6c7b62f7ebcbcf")
var dst, k [32]byte
copy(k[:], input)
edwards25519.ScalarBaseMult(&dst, &k)
if !bytes.Equal(dst[:], expected) {
fmt.Println("rustgo produit un résultat incorrect !")
}
fmt.Printf("BenchmarkScalarBaseMult : %vn", testing.Benchmark(func(b *testing.B) {
for i := 0; i < b.N; i++ {
edwards25519.ScalarBaseMult(&dst, &k)
}
}))
}
Et exécuter go build
!
$ go build -ldflags '-linkmode external -extldflags -lresolv'
$ ./ed25519-dalek-rustgo
BenchmarkScalarBaseMult 100000 19914 ns/op
Eh bien, cela a presque fonctionné. Nous avons triché. Le binaire ne se compilerait pas à moins que nous ne le liaisons à libresolv
. Pour être juste, le compilateur Rust a essayé de nous le dire. (Mais qui écoute tout ce que le compilateur Rust nous dit, de toute façon ?)
note: lier contre les artefacts natifs suivants lors de la liaison avec cette bibliothèque statique
note: l'ordre et toute duplication peuvent être significatifs sur certaines plateformes, et doivent donc être préservés
note: bibliothèque : Système
note: bibliothèque : resolv
note: bibliothèque : c
note: bibliothèque : m
Maintenant, la liaison contre
Comprendre les Défis de la Programmation en Rust sans Standard Library
Lorsqu'on aborde le développement en Rust, l'utilisation de bibliothèques système peut poser des problèmes, surtout lorsqu'il s'agit de lier des éléments internes et de compiler croisée. Mais une question se pose : pourquoi une bibliothèque Rust, qui devrait fonctionner sans la bibliothèque standard, cherche-t-elle à résoudre des noms DNS ?
Clarification sur le Mode no_std
Le véritable souci réside dans le fait que la bibliothèque en question n'est pas réellement en mode no_std
. En examinant son contenu, on constate la présence de nombreux éléments indésirables, notamment des allocateurs.
$ ar t target/release/libed25519_dalek_rustgo.a
__.SYMDEF
ed25519_dalek_rustgo-742a1d9f1c101d86.0.o
ed25519_dalek_rustgo-742a1d9f1c101d86.crate.allocator.o
curve25519_dalek-03e3ca0f6d904d88.0.o
subtle-cd04b61500f6e56a.0.o
std-72653eb2361f5909.0.o
panic_unwind-d0b88496572d35a9.0.o
unwind-da13b913698118f9.0.o
arrayref-2be0c0ff08ae2c7d.0.o
digest-f1373d68da35ca45.0.o
generic_array-95ca86a62dc11ddc.0.o
nodrop-7df18ca19bb4fc21.0.o
odds-3bc0ea0bdf8209aa.0.o
typenum-a61a9024d805e64e.0.o
rand-e0d585156faee9eb.0.o
alloc_system-c942637a1f049140.0.o
libc-e038d130d15e5dae.0.o
alloc-0e789b712308019f.0.o
std_unicode-9735142be30abc63.0.o
compiler_builtins-8a5da980a34153c7.0.o
absvdi2.o
absvsi2.o
absvti2.o
[... snip ...]
truncsfhf2.o
ucmpdi2.o
ucmpti2.o
core-9077840c2cc91cbf.0.o
Alors, comment rendre cette bibliothèque compatible avec le mode no_std
? Cela s'est révélé être un véritable parcours du combattant, mais voici un résumé des étapes à suivre.
- Si une dépendance n'est pas en mode
no_std
, cela annule l'effet de votre drapeauno_std
. Un des problèmes provenait d'une dépendance decurve25519-dalek
, que j'ai pu résoudre en exécutantcargo update
. - Créer une bibliothèque statique en mode
no_std
(destinée à un usage externe) est comparable à la création d'un exécutable en modeno_std
, ce qui est bien plus complexe car cela doit être autonome. - Les ressources sur la création d'un exécutable en mode
no_std
sont rares. J'ai principalement consulté une ancienne version du livre Rust et j'ai finalement trouvé des informations utiles dans le chapitre sur les éléments de langage. Un article de blog a également été bénéfique. - Pour commencer, il est nécessaire de définir des fonctions "lang_items" pour gérer des fonctionnalités normalement présentes dans la bibliothèque standard, comme
panic_fmt
. - Ensuite, vous n'avez pas accès aux équivalents Rust de
compiler-rt
, donc il faut importer la bibliothèquecompiler_builtins
. - Un autre problème réside dans le fait que
rust_begin_unwind
n'est pas exporté, mais cela peut être résolu en marquantpanic_fmt
avecno_mangle
, ce qui peut poser des problèmes avec le linter. - Enfin, vous serez sans
memcpy
, mais heureusement, il existe une réimplémentation native en Rust dans la bibliothèquerlibc
. Il est très utile de savoir quenm -u
peut vous indiquer quels symboles manquent dans un objet.
Tout cela se résume à quelques lignes de code obscures au début de notre fichier lib.rs
.
#![no_std]
#![feature(lang_items, compiler_builtins_lib, core_intrinsics)]
use core::intrinsics;
#[allow(private_no_mangle_fns)] #[no_mangle] // rust-lang/rust#38281
#[lang="panic_fmt"] fn panic_fmt() -> ! { unsafe { intrinsics::abort() } }
#[lang="eh_personality"] extern fn eh_personality() {}
extern crate compiler_builtins; // rust-lang/rust#43264
extern crate rlibc;
Avec cela, la commande go build
fonctionne enfin sur macOS.
Défis sur Linux
En revanche, sur Linux, rien ne fonctionne comme prévu.
Les liaisons externes signalent des symboles manquants tels que fmax
, et il semble que ce soit justifié.
$ ld -r -o linux.o target/release/libed25519_dalek_rustgo/*.o
$ nm -u linux.o
U _GLOBAL_OFFSET_TABLE_
U abort
U fmax
U fmaxf
U fmaxl
U logb
U logbf
U logbl
U scalbn
U scalbnf
U scalbnl
Un ami m'a heureusement suggéré de m'assurer que j'utilisais --gc-sections
pour supprimer le code mort, ce qui pourrait référencer des éléments dont je n'ai pas réellement besoin. Et effectivement, cela a fonctionné.
$ go build -ldflags '-extld clang -linkmode external -extldflags -Wl,--gc-sections'
Cependant, dans le Makefile, nous n'utilisons pas de linker, alors où placer --gc-sections
? La réponse est de cesser de combiner des fichiers .a
et de lire la page de manuel du linker.
Nous pouvons créer un fichier .o
contenant un symbole donné et tous les symboles qu'il référence avec ld -r --gc-sections -u $SYMBOL
. L'option -r
rend l'objet réutilisable pour un lien ultérieur, et -u
marque un symbole comme nécessaire, sinon tout serait collecté comme inutilisé. Dans notre cas, $SYMBOL
est scalar_base_mult
.
Pourquoi cela n'a-t-il pas posé de problème sur macOS ? Cela aurait pu être le cas si nous avions lié manuellement, mais le compilateur macOS semble effectuer une suppression automatique des symboles morts par défaut.
Résolution des Problèmes de Liaison dans Rustgo
Introduction
Lors de l'utilisation de Rustgo, il est fréquent de rencontrer des erreurs de liaison, en particulier sur la plateforme macOS. Ces problèmes peuvent être frustrants, mais ils sont souvent dus à des détails techniques spécifiques à l'architecture. Cet article explore les défis rencontrés lors de la liaison de symboles et propose des solutions pratiques.
Problèmes de Symboles Non Définis
Lors de la compilation, des erreurs de symboles non définis peuvent survenir, notamment pour l'architecture x86_64. Par exemple, des symboles tels que ___assert_rtn
et _copysign
peuvent être référencés sans être correctement liés. Cela est souvent causé par le fait que macOS préfixe les noms de symboles avec un underscore, ce qui complique la liaison.
Exemples d'Erreurs
Les erreurs typiques incluent des références à des fonctions mathématiques comme _fmax
et _logb
, qui ne sont pas trouvées lors de la phase de liaison. Ces problèmes peuvent rendre le processus de compilation frustrant, car même si le code semble se compiler correctement, les instructions de liaison peuvent ne pas fonctionner comme prévu.
Solutions de Liaison
Pour résoudre ces problèmes, il est essentiel d'ajuster le Makefile afin de faciliter la liaison externe. Voici un extrait de code qui illustre comment configurer correctement le Makefile :
edwards25519/edwards25519.a: edwards25519/rustgo.go edwards25519/rustgo.o edwards25519/libed25519_dalek_rustgo.o
go tool compile -N -l -o $@ -p main -pack edwards25519/rustgo.go
go tool pack r $@ edwards25519/rustgo.o edwards25519/libed25519_dalek_rustgo.o
edwards25519/libed25519_dalek_rustgo.o: target/$(TARGET)/release/libed25519_dalek_rustgo.a
ifeq ($(shell go env GOOS),darwin)
$(LD) -r -o $@ -arch x86_64 -u "_$(SYMBOL)" $^
else
$(LD) -r -o $@ --gc-sections -u "$(SYMBOL)" $^
endif
Liaison Interne sur Linux
Un autre défi majeur est la liaison interne sur Linux. Même si la compilation réussit, il se peut que le code Rust ne soit pas correctement lié, laissant des instructions CALL
pointant vers des adresses incorrectes. Cela peut être attribué à un bug silencieux du linker.
Ajout de Directives CGO
Pour remédier à cela, il est crucial d'ajouter les directives CGO appropriées. En plus de //cgo:cgo_import_static
, il est nécessaire d'inclure //cgo:cgo_import_dynamic
pour assurer une liaison interne correcte :
//go:cgo_import_static scalar_base_mult
//go:cgo_import_dynamic scalar_base_mult
Bien que la raison pour laquelle l'absence de cette directive cause des problèmes ne soit pas claire, son ajout permet de compiler le package Rustgo avec succès, tant pour la liaison externe qu'interne, sur Linux et macOS.
Création de Packages Redistribuables
Une fois que le fichier .a
est construit, il est possible de suivre les recommandations de la spécification //go:binary-only-package
pour créer un tarball contenant les fichiers .a
pour linux_amd64
et darwin_amd64
, ainsi que le code source du package. Cela permet une installation facile dans un GOPATH.
Exemple de Contenu du Tarball
Voici un aperçu du contenu d'un tarball :
src/github.com/FiloSottile/ed25519-dalek-rustgo/
src/github.com/FiloSottile/ed25519-dalek-rustgo/main.go
pkg/linux_amd64/github.com/FiloSottile/ed25519-dalek-rustgo/edwards25519.a
pkg/darwin_amd64/github.com/FiloSottile/ed25519-dalek-rustgo/edwards25519.a
Une fois installé, le package fonctionne comme un package natif, y compris pour la compilation croisée, tant qu'un fichier .a
est disponible pour la cible.
Considérations de Performance
Il est important de noter que si Rust est construit avec l'option -Ctarget-cpu=native
, cela peut poser des problèmes de compatibilité avec les anciens processeurs. Cependant, des études de performance montrent que les différences significatives se situent principalement entre les processeurs antérieurs et postérieurs à Haswell. Ainsi, il est recommandé de créer à la fois une version universelle et une version spécifique à Haswell.
Analyse de Performance
Des benchmarks peuvent être utilisés pour évaluer les performances :
$ benchstat bench-none.txt bench-haswell.txt
name old time/op new time/op delta
ScalarBaseMult/rustgo 22.0µs ± 3% 20.2µs ± 2% -8.41% (p=0.001 n=7+6)
Ces résultats montrent une amélioration significative des performances, ce qui est un avantage considérable pour les développeurs utilisant Rustgo.
Conclusion
En surmontant les défis de liaison et en créant des packages redistribuables, les développeurs peuvent tirer pleinement parti de Rustgo. En suivant les bonnes pratiques et en utilisant les directives appropriées, il est possible d'assurer une intégration fluide et efficace de Rust dans des projets Go.
Vous avez configuré Rust pour la compilation croisée, vous pouvez même compiler le fichier .a lui-même.
Voici le résultat : github.com/FiloSottile/ed25519-dalek-rustgo/edwards25519. Il est même disponible sur godoc.
Transformer en une réalité
C'était amusant.
Cependant, il est important de préciser que rustgo n'est pas un outil à utiliser en production. Par exemple, je pense qu'il serait judicieux de sauvegarder g avant le saut, la taille de la pile est totalement arbitraire, et réduire le cadre de trampoline de cette manière risque de perturber les débogueurs. De plus, une panique dans Rust pourrait avoir des conséquences inattendues.
Pour en faire un véritable outil, je commencerais par appeler morestack manuellement depuis une fonction d'assemblage NOSPLIT afin de garantir que nous avons suffisamment d'espace de pile pour les goroutines (au lieu de revenir en arrière sur rsp) avec une taille obtenue peut-être par une analyse statique de la fonction Rust (plutôt que d'être, disons, inventée).
Tout cela pourrait être analysé, généré et construit par un outil "rustgo", au lieu d'être codé en dur dans des Makefiles et des fichiers d'assemblage. Après tout, cgo n'est guère plus qu'un outil de génération de code. Cela pourrait avoir du sens en tant que commande go:generate, mais je connais quelqu'un qui souhaite en faire une commande cargo. (Enfin, un peu de rivalité entre Rust et Go !) De plus, une collection de types FFI côté Rust, comme par exemple GoSlice, serait appréciable.
#[repr(C)]
struct GoSlice {
array: *mut u8,
len: i32,
cap: i32,
}
Ou peut-être qu'un expert en Go ou en Rust viendra nous dire d'arrêter avant que nous ne nous blessions.
En attendant, vous pourriez envisager de me suivre sur Twitter.
Note 2024 : Twitter n'est également plus utilisable. Vous pouvez plutôt me suivre sur Bluesky à @filippo.abyssdomain.expert ou sur Mastodon à @[email protected].
Mise à jour : On m'a fait remarquer que si nous nommions simplement le fichier objet Rust libed25519_dalek_rustgo.syso, nous pourrions éviter toutes les invocations de go tool et utiliser simplement go build, qui lie automatiquement les fichiers .syso trouvés dans le paquet. Mais où serait le plaisir dans cela ?
Je tiens à remercier (sans ordre particulier) David, Ian, Henry, Isis, Manish, Zaki, Anna, George, Kaylyn, Bill, David, Jess, Tony et Daniel pour avoir rendu cela possible. Ne leur reprochez pas les erreurs et les horreurs, elles sont de ma responsabilité.
P.S. Avant que quiconque ne tente de comparer cela à cgo (qui possède de nombreuses fonctionnalités de sécurité) ou à du pur Go, ce n'est pas destiné à remplacer l'un ou l'autre. Cela vise à remplacer l'assemblage écrit manuellement par quelque chose de beaucoup plus sûr et lisible, avec des performances comparables. Ou mieux encore, c'était censé être une expérience amusante.