Bidouiller le compilateur Go pour convertir efficacement les IPv4 en IPv6
Vincent Bernat
30 minutes de lecture
Aussi disponible en
Classé dans
Document lié :
netip.Addr expose une méthode
Unmap() qui renvoie l’adresse IPv4 contenue dans une
adresse IPv6 représentant une IPv4 (IPv4-mapped IPv6 address). À
partir de ::ffff:203.0.113.10 ou de ::ffff:cb00:710a, elle renvoie
203.0.113.101. Il n’existe pas de méthode Map() ou To6() pour
l’opération inverse. Une telle méthode est triviale à écrire, mais les
mainteneurs de Go l’ont refusée au motif que les utilisateurs
devraient écrire netip.AddrFrom16(ip.As16()) et laisser le compilateur
l’optimiser2. Aujourd’hui, cette construction est huit fois plus lente
qu’une méthode native. Comment apprendre au compilateur à l’optimiser ?
Les solutions possibles#
Examinons trois façons d’implémenter cette conversion pour netip.Addr. Ma
préférée consiste à l’ajouter à la bibliothèque standard de Go. Les mainteneurs
de Go préfèrent une petite fonction utilitaire externe enchaînant
netip.AddrFrom16() et netip.Addr.As16(), en espérant que le compilateur
réussisse à l’optimiser. Le paquet unsafe ouvre une troisième
voie, avec des performances identiques à la première solution.
Modifier la bibliothèque standard de Go#
En interne, netip.Addr stocke les adresses IP sous
forme d’une valeur de 128 bits accompagnée d’un champ supplémentaire z qui
encode la famille et la zone :
type Addr struct {
addr uint128
z unique.Handle[addrDetail]
}
type addrDetail struct {
isV6 bool // false pour IPv4, true pour IPv6.
zoneV6 string // != "" seulement si IsV6 est vrai.
}
var (
z0 unique.Handle[addrDetail]
z4 = unique.Make(addrDetail{})
z6noz = unique.Make(addrDetail{isV6: true})
)
AddrFrom4() encode une adresse IPv4 sous la forme
d’une adresse IPv6 représentant une IPv4 et assigne le singleton z4 au champ z :
// AddrFrom4 returns the address of the IPv4 address given by the bytes in addr.
func AddrFrom4(addr [4]byte) Addr {
return Addr{
addr: uint128{
0,
0xffff00000000 |
uint64(addr[0])<<24 | uint64(addr[1])<<16 |
uint64(addr[2])<<8 | uint64(addr[3])},
z: z4,
}
}
Unmap() transforme une adresse IPv6 représentant
une IPv4 en adresse IPv4 en assignant le singleton z4 au champ :
func (ip Addr) Unmap() Addr {
if ip.Is4In6() {
ip.z = z4
}
return ip
}
Ajouter l’opération inverse dans la bibliothèque standard de Go est trivial : il
suffit d’assigner au champ z le singleton z6noz quand l’adresse est une
IPv4.
// To6 maps an IPv4 address to an IPv4-mapped IPv6 address. It returns an
// IPv6 address unmodified.
func (ip Addr) To6() Addr {
if ip.Is4() {
ip.z = z6noz
}
return ip
}
Avec une fonction utilitaire#
Le champ z n’est pas accessible en dehors du paquet net/netip. À la place,
nous écrivons une fonction utilitaire autour de la construction
netip.AddrFrom16(ip.As16()) :
// AddrTo6 maps an IPv4 address to an IPv4-mapped IPv6 address. It returns an
// IPv6 address unmodified.
func AddrTo6(ip netip.Addr) netip.Addr {
if ip.Is4() {
ip = netip.AddrFrom16(ip.As16())
}
return ip
}
Avec le paquet unsafe#
Une autre solution utilise le paquet unsafe pour modifier la structure Addr
par l’intermédiaire d’une structure avec le même schéma mémoire3 :
// addrProxy a le même schéma mémoire que netip.Addr.
type addrProxy struct {
addr [2]uint64 // netip.uint128
z unsafe.Pointer // unique.Handle[netip.addrDetail]
}
var (
anyIPv6 = netip.IPv6Unspecified()
netipZ6noz = (*addrProxy)(unsafe.Pointer(&anyIPv6)).z
)
// AddrTo6 maps an IPv4 address to an IPv4-mapped IPv6 address. It returns an
// IPv6 address unmodified.
func AddrTo6(ip netip.Addr) netip.Addr {
if !ip.Is4() {
return ip
}
(*addrProxy)(unsafe.Pointer(&ip)).z = netipZ6noz
return ip
}
Performances#
Sur mon ordinateur, avec Go 1.27.1, la solution intégrée à la bibliothèque
standard coûte 0,88 ns par opération, alors que celle privilégiée par les
mainteneurs de Go coûte 7,14 ns. La solution utilisant unsafe égale les
performances de la première.
goos: linux
goarch: amd64
pkg: github.com/vincentbernat/go-netip-addrto6
cpu: AMD Ryzen 5 5600X 6-Core Processor
│ sec/op │
AddrTo6/safe 7.137n ± 0%
AddrTo6/unsafe 0.8682n ± 2%
AddrTo6/builtin 0.8775n ± 2%
Code assembleur#
Regardons le code assembleur que le compilateur génère pour chaque solution4. La solution intégrée à la bibliothèque standard ressemble à ceci5 :
// AX = input.addr.hi, BX = input.addr.lo, CX = input.z
CMPQ net/netip·z4(SB), CX ; vérifier avec "z" s'il s'agit d'une adresse IPv4
JNE end ; sinon, s'arrêter ici
MOVQ net/netip·z6noz(SB), CX ; CX = netip.z6noz
end:
RET
// valeur de retour = Addr{hi: AX, lo: BX, z: CX}
Le langage assembleur de Go n’est pas une représentation directe du
langage machine sous-jacent : il repose sur un jeu d’instructions semi-abstrait
dérivé de l’assembleur de Plan 9. Il dispose de quatre
pseudo-registres : FP (frame pointer ou pointeur de cadre pour les arguments
des fonctions), PC (program counter ou compteur ordinal), SB (static base ou
pointeur de base pour les symboles globaux) et SP (stack pointer ou pointeur
de la pile). Il possède aussi des registres propres à l’architecture, comme
AX, CX, DX, BX, SI, DI et R8 à R15. Les instructions qui
stockent une donnée utilisent leur dernier argument comme destination. Les
instructions peuvent porter un suffixe de taille explicite : MOVB déplace un
octet, MOVW 16 bits, MOVL 32 bits et MOVQ 64 bits. Dans l’exemple
ci-dessus, la première instruction compare la valeur de 64 bits z4 avec le
registre CX.
La solution utilisant unsafe est presque identique :
// AX = input.addr.hi, BX = input.addr.lo, CX = input.z
CMPQ net/netip·z4(SB), CX ; vérifier avec "z" s'il s'agit d'une adresse IPv4
JNE end ; sinon, s'arrêter ici
MOVQ netipZ6noz(SB), CX ; CX = netip.z6noz
end:
RET
// valeur de retour = Addr{hi: AX, lo: BX, z: CX}
La solution utilitaire comporte beaucoup plus d’instructions. Pour comprendre
pourquoi, examinons le code de As16() et de
AddrFrom16(). Elles sont assez courtes pour que le
compilateur remplace leur appel par leur code.
func (ip Addr) As16() (a16 [16]byte) {
byteorder.BEPutUint64(a16[:8], ip.addr.hi)
byteorder.BEPutUint64(a16[8:], ip.addr.lo)
return a16
}
func AddrFrom16(addr [16]byte) Addr {
return Addr{
addr: uint128{
byteorder.BEUint64(addr[:8]),
byteorder.BEUint64(addr[8:]),
},
z: z6noz,
}
}
On devine déjà la construction qui sera à optimiser : le code stocke l’adresse IP dans un tableau, le copie, puis l’en extrait. Si nous développons le code Go à la main, nous obtenons :
func AddrTo6(input netip.Addr) netip.Addr {
if !input.Is4() {
return input
}
var a16 [16]byte
byteorder.BEPutUint64(a16[:8], input.addr.hi)
byteorder.BEPutUint64(a16[8:], input.addr.lo)
addr := a16
var output netip.Addr
output.addr.hi = byteorder.BEUint64(addr[:8])
output.addr.lo = byteorder.BEUint64(addr[8:])
output.z = netip.z6noz
return output
}
En tant qu’humains, nous pouvons mentalement obtenir la forme optimisée :
func AddrTo6(input netip.Addr) netip.Addr {
if !input.Is4() {
return input
}
var output netip.Addr
output.addr.hi = input.addr.hi
output.addr.lo = input.addr.lo
output.z = netip.z6noz
return output
}
Malheureusement, avec Go 1.26.8, le compilateur n’est pas assez malin pour en faire autant :
// AX = input.addr.hi, BX = input.addr.lo, CX = input.z
; Réserver de la place sur la pile (32 octets) :
; 0(SP) addr netip.uint128
; 16(SP) a16 [16]byte
PUSHQ BP
MOVQ SP, BP
SUBQ $32, SP
CMPQ net/netip·z4(SB), CX ; vérifier avec "z" s'il s'agit d'une adresse IPv4
JNE end ; sinon, s'arrêter ici
; Empaquetage : byteorder.BEPutUint64(a16[:8], input.addr.hi)
; byteorder.BEPutUint64(a16[8:], input.addr.lo)
MOVBEQ AX, net/netip·a16+16(SP)
MOVBEQ BX, net/netip·a16+24(SP)
; addr = a16, 16 octets d'un coup via le registre vectoriel X0
MOVUPS net/netip·a16+16(SP), X0
MOVUPS X0, net/netip·addr(SP)
; CX = netip.z6noz
MOVQ net/netip·z6noz(SB), CX
; Dépaquetage : output.addr.hi = byteorder.BEUint64(addr[:8])
; output.addr.lo = byteorder.BEUint64(addr[8:])
MOVBEQ net/netip·addr(SP), AX
MOVBEQ net/netip·addr+8(SP), BX
end:
ADDQ $32, SP
POPQ BP
RET
// valeur de retour = Addr{hi: AX, lo: BX, z: CX}
Le compilateur s’en sort honorablement avec la manipulation des octets : les
huit écritures d’un octet provenant de BEPutUint64() sont remplacées par un
unique MOVBEQ, qui copie un registre en inversant l’ordre de ses octets, et
les huit lectures d’un octet de BEUint64() deviennent aussi un unique MOVBEQ
dans l’autre sens6. Il reste trois groupes d’instructions : un
empaquetage, une copie et un dépaquetage.
Bidouiller le compilateur#
Le compilateur Go comporte plusieurs phases :
- Analyse syntaxique
- Le compilateur découpe le code source en lexèmes et l’analyse. Il construit un arbre syntaxique pour chaque fichier source.
- Vérification des types
- Le compilateur associe chaque identifiant à l’objet qu’il désigne, calcule les expressions constantes et infère le type de chaque expression.
- Construction de l’IR
- Le compilateur convertit l’arbre syntaxique et ses types dans une représentation intermédiaire (IR). Ce processus, appelé « noding », passe par un format de sérialisation nommé unified IR.
- Middle end
- Le compilateur effectue plusieurs passes d’optimisation sur l’IR, comme la dévirtualisation, le remplacement des appels de fonction par leurs contenus et l’analyse d’échappement.
- Walk
- Cette phase comporte deux étapes : une décomposition des instructions
complexes en instructions plus simples et la transformation des constructions
Go de haut niveau, comme
switchou les canaux, en instructions plus primitives ou en appels à des fonctions. - SSA générique
- Le compilateur convertit l’IR en forme SSA (Static Single Assignment), une représentation intermédiaire de plus bas niveau adaptée aux optimisations et règles de réécriture indépendantes de l’architecture.
- Génération du code machine
- Le compilateur réécrit la forme SSA en variantes propres à l’architecture, alloue les registres et applique d’autres passes d’optimisation. Pour finir, l’assembleur transforme les instructions générées en code machine.
Le marteau#
Ma première idée est de remplacer les occurrences de
netip.AddrFrom16(ip.As16()) par netip.Addr{addr: ip.addr, z: netip.z6noz} le
plus tôt possible, c’est-à-dire pendant le noding. Avant cela, la phase de
vérification des types nous interdit d’accéder aux champs non exportés d’une
structure.
Go 1.27 a introduit une option de débogage pratique pour afficher l’IR d’une fonction à plusieurs moments clés de la compilation :
$ GOTOOLCHAIN=go1.27.1 GOAMD64=v3 go build -a -gcflags="-d=astdump=AddrTo6Safe" .
Writing text ast output for AddrTo6Safe to AddrTo6Safe.ast
Writing html ast output for AddrTo6Safe to AddrTo6Safe.html
Writing html syntax output for AddrTo6Safe to AddrTo6Safe.syntax.html
Dans le fichier HTML, la première colonne montre l’IR telle qu’elle sort du noding :
DCLFUNC addrto6.AddrTo6Safe ABI:ABIInternal FUNC-func(netip.Addr) netip.Addr
DCLFUNC-Dcl
. NAME-addrto6.ip Class:PPARAM Offset:0 OnStack Used netip.Addr
. NAME-addrto6.~r0 Class:PPARAMOUT Offset:0 OnStack netip.Addr
DCLFUNC-body
. IF # ipv6_safe.go:11:2
. IF-Cond
. . CALLFUNC bool
. . CALLFUNC-Fun
. . . METHEXPR addrto6.Is4 FUNC-func(netip.Addr) bool
. . . . TYPE netip.Addr Class:PEXTERN Offset:0 type netip.Addr
. . CALLFUNC-Args
. . . NAME-addrto6.ip Class:PPARAM Offset:0 OnStack Used netip.Addr
. IF-Body
. . AS # ipv6_safe.go:12:6
. . . NAME-addrto6.ip Class:PPARAM Offset:0 OnStack Used netip.Addr
. . . CALLFUNC netip.Addr
. . . CALLFUNC-Fun
. . . . NAME-netip.AddrFrom16 Class:PFUNC Offset:0 Used FUNC-func([16]byte) netip.Addr
. . . CALLFUNC-Args
. . . . CALLFUNC ARRAY-[16]byte
. . . . CALLFUNC-Fun
. . . . . METHEXPR addrto6.As16 FUNC-func(netip.Addr) [16]byte
. . . . . . TYPE netip.Addr Class:PEXTERN Offset:0 type netip.Addr
. . . . CALLFUNC-Args
. . . . . NAME-addrto6.ip Class:PPARAM Offset:0 OnStack Used netip.Addr
. RETURN # ipv6_safe.go:14:2
. RETURN-Results
. . NAME-addrto6.ip Class:PPARAM Offset:0 OnStack Used netip.Addr
Dans le corps de l’instruction if, nous repérons l’appel à la méthode
netip.Addr.As16() et celui à la fonction netip.AddrFrom16(). Notre objectif
est de les remplacer par la construction littérale d’une structure :
IF-Body
. AS # ipv6_safe.go:12:6
. . NAME-addrto6.ip Class:PPARAM Offset:0 OnStack Used netip.Addr
. . STRUCTLIT netip.Addr
. . STRUCTLIT-List
. . . STRUCTKEY netip.addr
. . . . DOT netip.addr netip.uint128
. . . . . NAME-addrto6.ip Class:PPARAM Offset:0 OnStack Used netip.Addr
. . . STRUCTKEY netip.z
. . . . NAME-netip.z6noz Class:PEXTERN Offset:0 unique.Handle[net/netip.addrDetail]
Dans le fichier reader.go du noder, la méthode expr()
construit l’arbre IR d’une expression. À la fin de la branche exprCall, nous
ajoutons l’appel à une fonction rewriteAddrFrom16As16(). Elle prend en
paramètre le nœud courant et renvoie la structure construite en cas de succès ou
nil si la réécriture n’est pas possible. D’abord, nous vérifions que nous
avons le motif attendu : un appel à la fonction netip.AddrFrom16() avec, pour
unique argument, un appel à la méthode netip.Addr.As16() :
func rewriteAddrFrom16As16(n ir.Node) ir.Node {
call, ok := n.(*ir.CallExpr)
if !ok || call.Op() != ir.OCALLFUNC ||
len(call.Args) != 1 || len(call.Init()) != 0 ||
!isNetipFunc(call.Fun, "AddrFrom16") {
return nil
}
inner, ok := call.Args[0].(*ir.CallExpr)
if !ok || inner.Op() != ir.OCALLFUNC ||
len(inner.Args) != 1 || len(inner.Init()) != 0 ||
!isNetipFunc(inner.Fun, "Addr.As16") {
return nil
}
x := inner.Args[0]
// [...]
}
Ensuite, nous récupérons netip.z6noz :
z6noz, err := lookupVar(ir.StaticCalleeName(call.Fun).Sym().Pkg, "z6noz")
if err != nil {
return nil
}
Et nous construisons littéralement la structure :
typ := call.Type()
pos := call.Pos()
var list []ir.Node
for i, f := range typ.Fields() {
var value ir.Node
switch f.Sym.Name {
case "addr":
value = typecheck.DotField(pos, x, i)
case "z":
value = z6noz
default:
return nil
}
list = append(list, ir.NewStructKeyExpr(pos, f, value))
}
lit := ir.NewCompLitExpr(pos, ir.OSTRUCTLIT, typ, list)
lit.SetTypecheck(1)
return lit
Jetez un œil à la modification complète7. Nous pouvons la tester avec les commandes suivantes :
$ cd src
$ ./make.bash
Building Go cmd/dist using /usr/lib/go-1.27. (go1.27.1 linux/amd64)
Building Go toolchain1 and bootstrap cmd/go (go_bootstrap) using /usr/lib/go-1.27.
Building Go toolchain2 using go_bootstrap and Go toolchain1.
Building Go toolchain3 and commands using go_bootstrap and Go toolchain2.
Checking command staleness for linux/amd64.
---
Installed Go for linux/amd64 in /home/bernat/code/free/go
Installed commands in /home/bernat/code/free/go/bin
*** You need to add /home/bernat/code/free/go/bin to your PATH.
$ export PATH=$PWD/../bin:$PATH
$ go version
go version go1.28-devel_9834516e20 Sat Sep 12 08:23:11 2026 -0700 linux/amd64
$ go test net/netip/...
ok net/netip 0.224s
$ cd ../../go-netip-addrto6
$ go test .
ok github.com/vincentbernat/go-netip-addrto6 0.062s
Le code généré pour la fonction utilitaire est désormais le plus court possible !
// AX = input.addr.hi, BX = input.addr.lo, CX = input.z
CMPQ net/netip·z4(SB), CX ; vérifier avec "z" s'il s'agit d'une adresse IPv4
JNE end ; sinon, s'arrêter ici
MOVQ net/netip·z6noz(SB), CX ; CX = netip.z6noz
end:
RET
// valeur de retour = Addr{hi: AX, lo: BX, z: CX}
Il est peu probable que les mainteneurs de Go acceptent cette amélioration. Elle
dépend de la structure interne de net/netip.Addr. C’est une bidouille peu
élégante dans le noder, dont le rôle est de traduire fidèlement l’AST vérifié
en IR. Et elle est plus difficile à maintenir que l’ajout de la méthode To6().
Le tournevis#
Le bon endroit pour une telle optimisation est la phase SSA générique. L’une des dernières passes indépendantes de l’architecture est
memcombine. Avec l’option de débogage adéquate, le compilateur affiche la
forme SSA après cette passe8 :
$ GOTOOLCHAIN=go1.26.8 GOAMD64=v3 \
> go build -a -gcflags='-d=ssa/memcombine/dump=AddrTo6Safe' .
$ head -5 AddrTo6Safe_01__memcombine.dump
AddrTo6Safe func(netip.Addr) netip.Addr
b2:
(?) v1 = InitMem <mem>
(?) v2 = SP <uintptr>
(?) v3 = SB <uintptr>
Le résultat de la passe memcombine suit la même structure que le code
assembleur de AddrTo6Safe() examiné plus haut : deux
écritures, une copie et deux lectures que nous aimerions éliminer.
; […]
v502 = ArgIntReg <uint64> {ip+0} [0] ; input.addr.hi
v490 = ArgIntReg <uint64> {ip+8} [1] ; input.addr.lo
v466 = ArgIntReg <*netip.addrDetail> {ip+16} [2] ; input.z
; […]
v22 = LocalAddr <*[16]byte> {netip.a16} v2 v1 ; &a16
v173 = OffPtr <*byte> [8] v22 ; &a16[8]
v442 = Bswap64 <uint64> v490 ; bswap(input.addr.lo)
v542 = Bswap64 <uint64> v502 ; bswap(input.addr.hi)
v161 = Store <mem> {uint64} v22 v542 v23 ; a16[:8] = bswap(hi)
v282 = Store <mem> {uint64} v173 v442 v161 ; a16[8:] = bswap(lo)
v285 = LocalAddr <*[16]byte> {netip.addr} v2 v282 ; &addr
v286 = Move <mem> {[16]byte} [16] v285 v22 v282 ; addr = a16
v415 = OffPtr <*byte> [8] v285 ; &addr[8]
v416 = Load <uint64> v285 v286 ; addr[:8]
v299 = Bswap64 <uint64> v416 ; output.addr.hi
v174 = Load <uint64> v415 v286 ; addr[8:]
v39 = Bswap64 <uint64> v174 ; output.addr.lo
; […]
Chaque ligne comporte un identifiant de valeur (v442), une opération avec son
type (Bswap64 <uint64>) et ses arguments (v490)9. Les valeurs sont
les briques de base de la forme SSA et ne sont définies qu’une seule fois. Les
paramètres entiers sont entourés par des crochets ([8]) et les arguments
auxiliaires sont indiqués par des accolades ({netip.addr}). Les opérations qui
écrivent en mémoire produisent un nouvel état de la mémoire. Chaque opération
mémoire reprend l’état courant en dernier argument, ce qui permet de les
maintenir dans l’ordre.
Sur le papier#
Concentrons-nous sur output.addr.lo, alias v39 :
v490 = ArgIntReg <uint64> {ip+8} [1] ; input.addr.lo
v22 = LocalAddr <*[16]byte> {netip.a16} v2 v1 ; &a16
v173 = OffPtr <*byte> [8] v22 ; &a16[8]
v442 = Bswap64 <uint64> v490 ; bswap(input.addr.lo)
v282 = Store <mem> {uint64} v173 v442 v161 ; a16[8:] = bswap(lo)
v285 = LocalAddr <*[16]byte> {netip.addr} v2 v282 ; &addr
v286 = Move <mem> {[16]byte} [16] v285 v22 v282 ; addr = a16
v415 = OffPtr <*byte> [8] v285 ; &addr[8]
v174 = Load <uint64> v415 v286 ; addr[8:]
v39 = Bswap64 <uint64> v174 ; output.addr.lo
Pour simplifier ce code, nous pourrions appliquer trois règles de réécriture :
-
La première ajoute un raccourci pour une lecture à travers une copie :
(Load (OffPtr [o] p) (Move p src mem)) => (Load (OffPtr [o] src) mem). Elle s’applique àv174avec ses argumentsv415etv286et crée une nouvelle valeurv600:v490 = ArgIntReg <uint64> {ip+8} [1] ; input.addr.lo v22 = LocalAddr <*[16]byte> {netip.a16} v2 v1 ; &a16 v173 = OffPtr <*byte> [8] v22 ; &a16[8] v442 = Bswap64 <uint64> v490 ; bswap(input.addr.lo) v282 = Store <mem> {uint64} v173 v442 v161 ; a16[8:] = bswap(lo) v285 = LocalAddr <*[16]byte> {netip.addr} v2 v282 ; &addr v286 = Move <mem> {[16]byte} [16] v285 v22 v282 ; addr = a16 v415 = OffPtr <*byte> [8] v285 ; &addr[8] v600 = OffPtr <*byte> [8] v22 ; &a16[8] v174 = Load <uint64> v600 v282 ; a16[8:] v39 = Bswap64 <uint64> v174 ; output.addr.lo -
La deuxième simplifie une lecture qui suit une écriture :
(Load p (Store p x _)) => x. La lecture est transmise : la valeur écrite la remplace et il ne reste plus aucun accès mémoire. Elle s’applique àv174. Elle remarque quev600etv173désignent la même adresse et remplacev174par une copie dev442:v490 = ArgIntReg <uint64> {ip+8} [1] ; input.addr.lo v22 = LocalAddr <*[16]byte> {netip.a16} v2 v1 ; &a16 v173 = OffPtr <*byte> [8] v22 ; &a16[8] v442 = Bswap64 <uint64> v490 ; bswap(input.addr.lo) v282 = Store <mem> {uint64} v173 v442 v161 ; a16[8:] = bswap(lo) v285 = LocalAddr <*[16]byte> {netip.addr} v2 v282 ; &addr v286 = Move <mem> {[16]byte} [16] v285 v22 v282 ; addr = a16 v415 = OffPtr <*byte> [8] v285 ; &addr[8] v600 = OffPtr <*byte> [8] v22 ; &a16[8] v174 = Copy <uint64> v442 ; bswap(input.addr.lo) v39 = Bswap64 <uint64> v174 ; output.addr.lo -
La dernière étape annule les deux inversions d’octets :
(Bswap64 (Bswap64 x)) => x.v39devient une copie dev490:v490 = ArgIntReg <uint64> {ip+8} [1] ; input.addr.lo v22 = LocalAddr <*[16]byte> {netip.a16} v2 v1 ; &a16 v173 = OffPtr <*byte> [8] v22 ; &a16[8] v442 = Bswap64 <uint64> v490 ; bswap(input.addr.lo) v282 = Store <mem> {uint64} v173 v442 v161 ; a16[8:] = bswap(lo) v285 = LocalAddr <*[16]byte> {netip.addr} v2 v282 ; &addr v286 = Move <mem> {[16]byte} [16] v285 v22 v282 ; addr = a16 v415 = OffPtr <*byte> [8] v285 ; &addr[8] v600 = OffPtr <*byte> [8] v22 ; &a16[8] v174 = Copy <uint64> v442 ; bswap(input.addr.lo) v39 = Copy <uint64> v490 ; output.addr.lo = input.addr.lo
Si nous ignorons les valeurs inutiles pour calculer v39, il ne reste que
cette forme SSA :
v490 = ArgIntReg <uint64> {ip+8} [1] ; input.addr.lo
v39 = Copy <uint64> v490 ; output.addr.lo = input.addr.lo
Passons à output.addr.hi, alias v299 :
v502 = ArgIntReg <uint64> {ip+0} [0] ; input.addr.hi
v22 = LocalAddr <*[16]byte> {netip.a16} v2 v1 ; &a16
v173 = OffPtr <*byte> [8] v22 ; &a16[8]
v542 = Bswap64 <uint64> v502 ; bswap(input.addr.hi)
v161 = Store <mem> {uint64} v22 v542 v23 ; a16[:8] = bswap(hi)
v282 = Store <mem> {uint64} v173 v442 v161 ; a16[8:] = bswap(lo)
v285 = LocalAddr <*[16]byte> {netip.addr} v2 v282 ; &addr
v286 = Move <mem> {[16]byte} [16] v285 v22 v282 ; addr = a16
v416 = Load <uint64> v285 v286 ; addr[:8]
v299 = Bswap64 <uint64> v416 ; output.addr.hi
Pour l’éliminer, nous appliquons là aussi trois règles de réécriture :
-
La première raccourcit une lecture à travers une copie, mais sans appliquer un décalage :
(Load p (Move p src mem)) => (Load src mem). Elle réécritv416pour utiliser les arguments dev286:v502 = ArgIntReg <uint64> {ip+0} [0] ; input.addr.hi v22 = LocalAddr <*[16]byte> {netip.a16} v2 v1 ; &a16 v173 = OffPtr <*byte> [8] v22 ; &a16[8] v542 = Bswap64 <uint64> v502 ; bswap(input.addr.hi) v161 = Store <mem> {uint64} v22 v542 v23 ; a16[:8] = bswap(hi) v282 = Store <mem> {uint64} v173 v442 v161 ; a16[8:] = bswap(lo) v285 = LocalAddr <*[16]byte> {netip.addr} v2 v282 ; &addr v286 = Move <mem> {[16]byte} [16] v285 v22 v282 ; addr = a16 v416 = Load <uint64> v22 v282 ; a16[:8] v299 = Bswap64 <uint64> v416 ; output.addr.hi -
La deuxième règle transmet une valeur écrite une étape plus tôt, en ignorant une écriture à une autre adresse quand il n’y a pas de chevauchement :
(Load p (Store q _ (Store p x _))) => x. Elle s’applique àv416:xestv542,pestv22(&a16),qestv173(&a16[8]), etpetqne se chevauchent pas si on les considère comme desuint64.v502 = ArgIntReg <uint64> {ip+0} [0] ; input.addr.hi v22 = LocalAddr <*[16]byte> {netip.a16} v2 v1 ; &a16 v173 = OffPtr <*byte> [8] v22 ; &a16[8] v542 = Bswap64 <uint64> v502 ; bswap(input.addr.hi) v161 = Store <mem> {uint64} v22 v542 v23 ; a16[:8] = bswap(hi) v282 = Store <mem> {uint64} v173 v442 v161 ; a16[8:] = bswap(lo) v285 = LocalAddr <*[16]byte> {netip.addr} v2 v282 ; &addr v286 = Move <mem> {[16]byte} [16] v285 v22 v282 ; addr = a16 v416 = Copy <uint64> v542 ; bswap(input.addr.hi) v299 = Bswap64 <uint64> v416 ; output.addr.hi -
La troisième règle annule deux inversions d’octets :
(Bswap64 (Bswap64 x)) => x.v299devient une copie dev502:v502 = ArgIntReg <uint64> {ip+0} [0] ; input.addr.hi v22 = LocalAddr <*[16]byte> {netip.a16} v2 v1 ; &a16 v173 = OffPtr <*byte> [8] v22 ; &a16[8] v542 = Bswap64 <uint64> v502 ; bswap(input.addr.hi) v161 = Store <mem> {uint64} v22 v542 v23 ; a16[:8] = bswap(hi) v282 = Store <mem> {uint64} v173 v442 v161 ; a16[8:] = bswap(lo) v285 = LocalAddr <*[16]byte> {netip.addr} v2 v282 ; &addr v286 = Move <mem> {[16]byte} [16] v285 v22 v282 ; addr = a16 v416 = Copy <uint64> v542 ; bswap(input.addr.hi) v299 = Copy <uint64> v502 ; output.addr.hi = input.addr.hi
Si nous retirons les valeurs inutiles pour calculer v299, nous obtenons la
forme SSA suivante :
v502 = ArgIntReg <uint64> {ip+0} [0] ; input.addr.hi
v299 = Copy <uint64> v502 ; output.addr.hi = input.addr.hi
En pratique#
La plupart de ces règles existent déjà dans generic.rules.
Elles utilisent des conditions pour valider leur contexte : ssa.IsSamePtr()
pour une même adresse, ssa.Disjoint() pour des adresses qui ne se chevauchent
pas. La règle qui transmet une valeur écrite à une lecture existe déjà, avec
trois variantes qui regardent à travers plusieurs autres écritures. Voici les
deux dont nous avons besoin :
(Load <t1> p1 (Store {t2} p2 x _))
&& ssa.IsSamePtr(p1, p2)
&& copyCompatibleType(t1, x.Type)
&& t1.Size() == t2.Size()
=> x
(Load <t1> p1 (Store {t2} p2 _ (Store {t3} p3 x _)))
&& ssa.IsSamePtr(p1, p3)
&& copyCompatibleType(t1, x.Type)
&& t1.Size() == t3.Size()
&& ssa.Disjoint(p3, t3, p2, t2)
=> x
Go 1.27 a ajouté la règle de lecture à travers une copie dans CL 748200 pour corriger le ticket #77720 :
(Load <t1> op1:(OffPtr [o1] p1) move:(Move [n] p2 src mem))
&& o1 >= 0 && o1+t1.Size() <= n && ssa.IsSamePtr(p1, p2)
&& !ssa.IsVolatile(src)
=> @move.Block (Load <t1> (OffPtr <op1.Type> [o1] src) mem)
Il lui manque une variante sans application d’un décalage :
(Load <t1> p1 move:(Move [n] p2 src mem))
&& p1.Op != ssaop.OpOffPtr
&& t1.Size() <= n && ssa.IsSamePtr(p1, p2)
&& !ssa.IsVolatile(src)
=> @move.Block (Load <t1> (OffPtr <p1.Type> [0] src) mem)
Il n’existe pas de règle générique pour annuler deux inversions d’octets, mais la passe de traduction vers AMD64 comporte cette règle :
(BSWAP(Q|L) (BSWAP(Q|L) p)) => p
Après bascule sur la branche de développement de Go et ajout de la règle manquante, le code assembleur généré est pire qu’avec Go 1.26.8, même si notre règle supplémentaire améliore légèrement la situation à la fin :
// AX = input.addr.hi, BX = input.addr.lo, CX = input.z
; Réserver de la place sur la pile (16 octets) :
; 0(SP) a16 [16]byte
PUSHQ BP
MOVQ SP, BP
SUBQ $16, SP
CMPQ net/netip·z4(SB), CX ; vérifier avec "z" s'il s'agit d'une adresse IPv4
JNE end ; sinon, s'arrêter ici
; Les quatre octets transmis : la moitié basse de input.addr.lo est découpée
; puis reconstituée dans les registres
MOVQ BX, DX ; DX = input.addr.lo
SHRQ $24, BX ; BX = input.addr.lo >> 24
MOVQ DX, SI ; SI = input.addr.lo
SHRQ $16, DX ; DX = input.addr.lo >> 16
MOVQ SI, DI ; DI = input.addr.lo, gardé pour l'empaquetage
SHRQ $8, SI ; SI = input.addr.lo >> 8
MOVBLZX DIB, R8 ; R8 = byte(input.addr.lo)
MOVBLZX SIB, SI ; SI = byte(input.addr.lo >> 8)
SHLQ $8, SI
ORQ R8, SI ; SI = deux octets de poids faible de input.addr.lo
MOVBLZX DL, DX ; DX = byte(input.addr.lo >> 16)
SHLQ $16, DX
ORQ SI, DX
MOVBLZX BL, BX ; BX = byte(input.addr.lo >> 24)
SHLQ $24, BX
ORQ DX, BX ; BX = input.addr.lo & 0xffffffff
; Empaquetage : byteorder.BEPutUint64(a16[:8], input.addr.hi)
; byteorder.BEPutUint64(a16[8:], input.addr.lo)
MOVBEQ AX, net/netip·a16(SP)
MOVBEQ DI, net/netip·a16+8(SP)
; Les quatre autres octets de input.addr.lo, lus un par un dans a16
MOVBLZX net/netip·a16+11(SP), DX ; a16[11]
SHLQ $32, DX
ORQ DX, BX
MOVBLZX net/netip·a16+10(SP), DX ; a16[10]
SHLQ $40, DX
ORQ DX, BX
MOVBLZX net/netip·a16+9(SP), DX ; a16[9]
SHLQ $48, DX
ORQ DX, BX
MOVBLZX net/netip·a16+8(SP), DX ; a16[8]
SHLQ $56, DX
; output.z = netip.z6noz
MOVQ net/netip·z6noz(SB), CX
; output.addr.hi = byteorder.BEUint64(a16[:8])
MOVBEQ net/netip·a16(SP), AX
; output.addr.lo assemblé à partir des étapes précédentes
ORQ DX, BX
end:
LEAVEQ
RET
// valeur de retour = Addr{hi: AX, lo: BX, z: CX}
C’est la règle de lecture à travers une copie, ajoutée dans Go 1.27, qui a introduit cette régression.
Dans le désordre#
Ne baissons pas les bras ! En réalité, les règles de réécriture s’exécutent
avant memcombine, notamment dans la passe late opt. À ce stade, le
compilateur a remplacé les appels de BEPutUint64() et de BEUint64() par
seize écritures et seize lectures d’un octet, conformément à leur code
source :
func BEUint64(b []byte) uint64 {
_ = b[7] // bounds check hint to compiler; see golang.org/issue/14808
return uint64(b[7]) | uint64(b[6])<<8 | uint64(b[5])<<16 | uint64(b[4])<<24 |
uint64(b[3])<<32 | uint64(b[2])<<40 | uint64(b[1])<<48 | uint64(b[0])<<56
}
Suivons deux octets de output.addr.lo : addr[15] et addr[11]. Voici une
forme SSA simplifiée avant la passe late opt :
v273 = Trunc64to8 <byte> v490 ; byte(input.addr.lo)
v226 = Trunc64to8 <byte> v225 ; byte(input.addr.lo >> 32)
; […]
v235 = Store <mem> {byte} v233 v226 v223 ; a16[11] = byte(lo >> 32)
v247 = Store <mem> {byte} v245 v238 v235 ; a16[12] = …
v259 = Store <mem> {byte} v257 v250 v247 ; a16[13] = …
v271 = Store <mem> {byte} v269 v262 v259 ; a16[14] = …
v282 = Store <mem> {byte} v280 v273 v271 ; a16[15] = byte(lo)
v285 = LocalAddr <*[16]byte> {netip.addr} v2 v282 ; &addr
v286 = Move <mem> {[16]byte} [16] v285 v22 v282 ; addr = a16
; […]
v433 = OffPtr <*byte> [15] v285 ; &addr[15]
v435 = Load <byte> v433 v286 ; addr[15]
v479 = OffPtr <*byte> [11] v285 ; &addr[11]
v481 = Load <byte> v479 v286 ; addr[11]
La première règle lit à travers la copie : (Load (OffPtr [o] p) (Move p src
mem)) => (Load (OffPtr [o] src) mem). Elle s’applique aux deux lectures, qui
lisent désormais a16 avec l’état de la mémoire précédant la copie :
v600 = OffPtr <*byte> [15] v22 ; &a16[15]
v435 = Load <byte> v600 v282 ; a16[15]
v601 = OffPtr <*byte> [11] v22 ; &a16[11]
v481 = Load <byte> v601 v282 ; a16[11]
La deuxième règle raccourcit une lecture qui suit une écriture : (Load p (Store
p x _)) => x. Elle s’applique à v435, puisque v282 écrit a16[15]. Elle ne
s’applique pas à v481 : v235 écrit a16[11] quatre écritures plus tôt dans
la chaîne, alors que les variantes de cette règle ne remontent au maximum qu’à
trois écritures.
v435 = Copy <byte> v273 ; byte(input.addr.lo)
v601 = OffPtr <*byte> [11] v22 ; &a16[11]
v481 = Load <byte> v601 v282 ; a16[11]
Il en va de même pour les autres octets : la règle transmet les quatre derniers
octets écrits, de a16[12] à a16[15]. Les douze autres lectures continuent de
lire a16 au lieu de addr.
BEUint64() devient une chaîne de Or64, chacun plaçant un octet à sa place.
memcombine est une passe écrite en Go, et non un ensemble de
règles de réécriture. Elle part du dernier Or64 de la chaîne et collecte
jusqu’à huit termes. Si chaque terme est la lecture d’un octet, étendue à 64
bits et décalée, et si les huit lectures portent sur des adresses consécutives à
partir du même pointeur avec le même état de la mémoire, elle remplace toute la
chaîne par une unique lecture de 64 bits et une inversion d’octets. Sinon, elle
réessaie avec quatre termes, puis deux ainsi qu’à partir de chaque Or64
intermédiaire. Voici la boucle qui vérifie chaque terme dans une version
simplifiée de combineLoads() :
for i := int64(0); i < n; i++ {
v := a[i]
shift := int64(0)
if v.Op == shiftOp {
v, shift = peelShift(v)
}
if v.Op != extOp {
return false
}
load := v.Args[0]
if load.Op != ssaop.OpLoad {
return false
}
if load.Args[1] != mem {
return false
}
p, off := splitPtr(load.Args[0])
if p != base {
return false
}
r[i] = LoadRecord{load: load, offset: off, shift: shift}
}
Pour output.addr.hi, les huit lectures portent sur a16 avec le même état de
la mémoire, v282 :
v13 = Load <byte> v22 v282 ; a16[0]
v530 = Load <byte> v14 v282 ; a16[1]
v488 = Load <byte> v504 v282 ; a16[2]
v405 = Load <byte> v537 v282 ; a16[3]
v385 = Load <byte> v397 v282 ; a16[4]
v361 = Load <byte> v373 v282 ; a16[5]
v196 = Load <byte> v63 v282 ; a16[6]
v432 = Load <byte> v315 v282 ; a16[7]
v319 = ZeroExt8to64 <uint64> v432 ; uint64(a16[7])
v329 = ZeroExt8to64 <uint64> v196 ; uint64(a16[6])
v330 = Lsh64x64 <uint64> [true] v329 v138 ; uint64(a16[6]) << 8
v331 = Or64 <uint64> v319 v330 ; a16[7] | a16[6] << 8
; […] idem pour a16[5] à a16[1]
v401 = ZeroExt8to64 <uint64> v13 ; uint64(a16[0])
v402 = Lsh64x64 <uint64> [true] v401 v55 ; uint64(a16[0]) << 56
v403 = Or64 <uint64> v402 v391 ; | a16[0] << 56 = output.addr.hi
memcombine les fusionne en une seule lecture et une inversion :
v286 = Load <uint64> v22 v282 ; a16[:8]
v285 = Bswap64 <uint64> v286 ; output.addr.hi
Pour output.addr.lo, voici la chaîne que voit memcombine après
late opt :
v436 = ZeroExt8to64 <uint64> v273 ; addr[15], transmis
v448 = Or64 <uint64> v436 v447 ; | addr[14] << 8, transmis
v460 = Or64 <uint64> v459 v448 ; | addr[13] << 16, transmis
v472 = Or64 <uint64> v471 v460 ; | addr[12] << 24, transmis
v484 = Or64 <uint64> v483 v472 ; | a16[11] << 32, lu
v496 = Or64 <uint64> v495 v484 ; | a16[10] << 40, lu
v508 = Or64 <uint64> v507 v496 ; | a16[9] << 48, lu
v520 = Or64 <uint64> v519 v508 ; | a16[8] << 56, lu
À partir de v520, quatre des huit termes sont des octets transmis et non des
lectures en mémoire : memcombine ne peut pas les combiner. Elle ne fusionne
pas non plus les quatre lectures restantes, car elles reposent sur les octets
transmis.
Remise en ordre#
En résumé, les règles de réécriture s’exécutent trop tôt pour être efficaces. Il
existe un contournement rapide : exécuter une première passe memcombine avant
late opt. Après cette modification, le code généré pour la
fonction utilitaire redevient le plus court possible :
// AX = input.addr.hi, BX = input.addr.lo, CX = input.z
CMPQ net/netip·z4(SB), CX ; vérifier avec "z" s'il s'agit d'une adresse IPv4
JNE end ; sinon, s'arrêter ici
MOVQ net/netip·z6noz(SB), CX ; CX = netip.z6noz
end:
RET
// valeur de retour = Addr{hi: AX, lo: BX, z: CX}
Et les mesures de performance le confirment ! ✌️
goos: linux
goarch: amd64
pkg: github.com/vincentbernat/go-netip-addrto6
cpu: AMD Ryzen 5 5600X 6-Core Processor
│ Go 1.26.8 │ Our branch │
│ sec/op │ sec/op vs base │
AddrTo6/safe 6.5470n ± 0% 0.8944n ± 4% -86.34% (p=0.002 n=6)
AddrTo6/unsafe 0.9071n ± 3% 0.8682n ± 1% -4.28% (p=0.002 n=6)
AddrTo6/builtin 0.8871n ± 2% 0.8785n ± 1% ~ (p=0.310 n=6)
Prochaine étape#
Je pense que les mainteneurs de Go rejetteront cette modification à cause de la
passe memcombine supplémentaire. À la place, je compte publier cet article et
relancer le sujet à la suite du ticket #54365. Soit la complexité du
problème et la régression de Go 1.27 convainquent les mainteneurs qu’il est plus
simple et efficace d’ajouter une méthode To6(), soit ils me conseillent sur la
marche à suivre. Dans tous les cas, creuser ce sujet m’a beaucoup appris sur le
compilateur Go ! ⚙️
Mise à jour (10.2026)
J’ai ouvert le ticket #81994 pour
proposer la méthode Addr.To6(). Mettez un 👍 si vous voulez la voir dans Go !