Bidouiller le compilateur Go pour convertir efficacement les IPv4 en IPv6

Vincent Bernat

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 switch ou 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 :

  1. 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 à v174 avec ses arguments v415 et v286 et crée une nouvelle valeur v600 :

      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
    
  2. 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 que v600 et v173 désignent la même adresse et remplace v174 par une copie de v442 :

      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
    
  3. La dernière étape annule les deux inversions d’octets : (Bswap64 (Bswap64 x)) => x. v39 devient une copie de v490 :

      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 :

  1. 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éécrit v416 pour utiliser les arguments de v286 :

      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
    
  2. 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 : x est v542, p est v22 (&a16), q est v173 (&a16[8]), et p et q ne se chevauchent pas si on les considère comme des uint64.

      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
    
  3. La troisième règle annule deux inversions d’octets : (Bswap64 (Bswap64 x)) => x. v299 devient une copie de v502 :

      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 !