De nombreux programmeurs Go connaissent les octets.Buffer . L'un de ses avantages est qu'il vous permet d'éviter d'allouer de la mémoire sur le tas de la même manière que " petite mémoire tampon / optimisation de la taille ":
type Buffer struct { bootstrap [64]byte
Il n'y a qu'un seul problème. Cette optimisation ne fonctionne pas .
À la fin de cet article, vous découvrirez pourquoi cette optimisation ne fonctionne pas et ce que nous pouvons y faire.
Comme prévu, "petite optimisation du tampon"
Introduisons une définition légèrement simplifiée des bytes.Buffer
.
const smallBufSize int = 64 type Buffer struct { bootstrap [smallBufSize]byte buf []byte }
Lorsque nous effectuons des actions sur le Buffer
, par exemple, appelons la méthode Buffer.Write
, l'enregistrement est toujours effectué dans buf
, cependant, avant cet enregistrement, Buffer.grow(n)
lancé à l'intérieur de chaque méthode similaire, ce qui garantit qu'il y a suffisamment d'espace dans cette tranche pour n
octets suivants.
Grow peut ressembler à ceci:
func (b *Buffer) grow(n int) {
Hypothèses utilisées dans notre implémentation de Buffer.grow
Nous len(b.buf)
que len(b.buf)
est la longueur réelle des données dans Buffer, ce qui nécessiterait que Write
utilise des méthodes d'ajout pour ajouter de nouveaux octets à la tranche. Ce n'est pas le cas dans bytes.Buffer
de la bibliothèque standard, mais par exemple, c'est un détail d'implémentation sans importance.
Si b
alloué sur la pile, le bootstrap
intérieur est alloué sur la pile, ce qui signifie que la tranche b.buf
réutilisera la mémoire à l'intérieur de b
sans nécessiter d'allocation supplémentaire.
Lorsque grow
révèle que le tableau d' bootstrap
est déjà insuffisant, une nouvelle tranche «réelle» sera créée, où les éléments du stockage précédent (du «petit tampon») seront ensuite copiés. Après cela, Buffer.bootstrap
perdra sa pertinence. Si Buffer.Reset
est Buffer.Reset
, cap(b.buf)
restera le même et il n'y aura plus besoin d'un tableau d' bootstrap
.
La mémoire s'enfuit en tas
Il est en outre prévu que le lecteur soit au moins superficiellement familier avec ce qu'est l' analyse d'échappement dans Go.
Considérez la situation suivante:
func f() *Buffer { var b bytes.Buffer
Ici b
sera alloué sur le tas. La raison en est le pointeur qui fuit vers b
:
$ go tool compile -m leak.go leak.go:12:9: &b escapes to heap leak.go:11:6: moved to heap: b
Terminologie
Dans cet article, «fuite» et «évasions» sont utilisés de manière presque synonyme.
Il y a une certaine différence dans le compilateur lui-même, par exemple, la valeur «échappe au tas», mais les paramètres de la fonction sont «fuite x param».
Un paramètre qui fuit signifie que l'argument passé pour ce paramètre sera alloué sur le tas. En d'autres termes, le paramètre de fuite provoque l'échappement des arguments dans un tas.
Ce qui précède était un cas évident, mais qu'en est-il:
func length() int { var b bytes.Buffer b.WriteString("1") return b.Len() }
Ici, nous n'avons besoin que d'un octet, tout tient dans le bootstrap
, le tampon lui-même est local et ne "s'échappe" pas de la fonction. Vous serez peut-être surpris, mais le résultat sera le même, l'allocation b
sur le tas.

Pour être sûr, vous pouvez vérifier cela en utilisant le benchmark:
BenchmarkLength-8 20000000 90.1 ns/op 112 B/op 1 allocs/op
Liste de référence
package p import ( "bytes" "testing" ) func length() int { var b bytes.Buffer b.WriteString("1") return b.Len() } func BenchmarkLength(b *testing.B) { for i := 0; i < bN; i++ { _ = length() } }
Explication 112 B / op
Lorsque le runtime demande à l'allocateur N
octets, il n'est pas nécessaire que exactement N
octets soient alloués.
Tous les résultats ci-dessous concernent la combinaison de GOOS=linux
et GOARCH=AMD64
.
package benchmark import "testing"
Si vous exécutez go test -bench=. -benchmem
go test -bench=. -benchmem
avec ce test:
BenchmarkAlloc9-8 50000000 33.5 ns/op 16 B/op 1 allocs/op
9 octets demandés, 16 alloués. Revenons maintenant aux bytes.Buffer
:
fmt.Println(unsafe.Sizeof(bytes.Buffer{})) => 104
Regardons $ GOROOT / src / runtime / sizeclasses.go :
// class bytes/obj bytes/span objects tail waste max waste // 1 8 8192 1024 0 87.50% // 2 16 8192 512 0 43.75% // 3 32 8192 256 0 46.88% // 4 48 8192 170 32 31.52% // 5 64 8192 128 0 23.44% // 6 80 8192 102 32 19.07% // 7 96 8192 85 32 15.95% // 8 112 8192 73 16 13.56% // ...
Il ne tient pas dans 96 octets, 112 est sélectionné.
Mais pourquoi cela se produit-il?
Que se passe-t-il et pourquoi
Une analyse de la situation peut être trouvée dans le numéro mentionné au tout début.
Il y a aussi un simple reproducteur .
La place du problème se trouve juste dans l'affectation b.buf = b.bootstrap[:]
. Ce code fait que l'analyse d'échappement suppose que b.bootstrap
"s'enfuit", et puisqu'il s'agit d'un tableau, il est stocké à l'intérieur de l'objet lui-même, ce qui signifie que tous les b
doivent être alloués sur le tas.
Si le bootstrap était une tranche, pas un tableau, cela ne se produirait pas, car il existe une optimisation ad hoc pour attribuer des tranches de l'objet à l'objet lui-même:
La réponse pour laquelle cette optimisation ne fonctionne pas pour les tableaux a déjà été formulée ci-dessus, mais voici un extrait de esc.go # L835-L866 lui-même (tout le code d'optimisation est mis en évidence par référence):
Il convient d'ajouter ici que pour l'analyseur de pointeur, il existe plusieurs niveaux de "fuites", les principaux d'entre eux:
- L'objet lui-même s'échappe (b s'échappe). Dans ce cas, l'objet lui-même doit être alloué sur le tas.
- Les éléments de l'objet (b contenu s'échappent) s'échappent. Dans ce cas, les pointeurs de l'objet sont considérés comme s'échappant.
Le cas avec le tableau est spécial en ce que si le tableau fuit, l'objet qui le contient doit également fuir.
L'analyse d'échappement décide s'il est possible ou non de placer un objet sur la pile, en se basant uniquement sur les informations disponibles dans le corps de la fonction analysée. La méthode Buffer.grow
prend b
par pointeur, elle doit donc calculer un emplacement approprié à placer. Étant donné que dans le cas d'un tableau, nous ne pouvons pas distinguer "b escape"
de "b contents escape"
, nous devons être plus pessimistes et arriver à la conclusion que b
pas sûr à placer sur la pile.
Supposons le contraire, que le modèle d' self-assignment
résout la même chose pour les tableaux que pour les tranches:
package example var sink interface{} type bad struct { array [10]byte slice []byte } func (b *bad) bug() { b.slice = b.array[:]
La décision de placer b
sur la pile dans cette situation entraînera un désastre: après avoir quitté la fonction à l'intérieur de laquelle b
été créé, la mémoire à laquelle se réfèrera le récepteur ne sera rien de plus que des ordures.
Pointeurs de tableau
Imaginez que notre Buffer
été déclaré un peu différemment:
const smallBufSize int = 64 type Buffer struct { - bootstrap [smallBufSize]byte + bootstrap *[smallBufSize]byte buf []byte }
Contrairement à un tableau normal, un pointeur sur un tableau ne stockera pas tous les éléments à l'intérieur du Buffer
lui-même. Cela signifie que si l'allocation d' bootstrap
sur le tas n'entraîne pas Buffer
allocation de Buffer
sur le tas. Étant donné que l'analyse d'échappement peut allouer des champs de pointeur sur la pile lorsque cela est possible, nous pouvons supposer qu'une telle définition de Buffer
est plus efficace.
Mais c'est en théorie. En pratique, un pointeur vers un tableau n'a pas beaucoup de traitement et tombe dans la même catégorie qu'une tranche d'un tableau normal, ce qui n'est pas tout à fait correct. CL133375: cmd / compile / internal / gc: gérer la tranche de tableau auto-affectée dans esc.go vise à corriger cette situation.
Supposons que cette modification ait été acceptée dans le compilateur Go.
Zéro valeur que nous avons perdue
Malheureusement, la transition de [64]byte
à *[64]byte
a un problème: maintenant nous ne pouvons pas utiliser le bootstrap
sans l'initialiser explicitement, une valeur nulle de Buffer
cesse d'être utile, nous avons besoin d'un constructeur.
func NewBuffer() Buffer { return Buffer{bootstrap: new(*[smallBufSize]byte)} }
Nous Buffer
, pas *Buffer
, pour éviter des problèmes avec l'analyse des pointeurs (c'est très conservateur dans Go), et compte tenu du fait que NewBuffer
toujours intégré à l'endroit d'un appel, il n'y aura pas de copie inutile.
Après avoir NewBuffer
corps NewBuffer
à la place de l'appel d'échappement, l'analyse peut essayer de prouver que le new(*[smallBufSize]byte)
ne dépasse pas la durée de vie de la trame de la fonction dans laquelle il est appelé. Si c'est le cas, alors l'allocation sera sur la pile.
Intel bytebuf
L'optimisation décrite ci-dessus est appliquée dans le package intel-go / bytebuf .
Cette bibliothèque exporte le type bytebuf.Buffer
, qui duplique 99,9% bytes.Buffer
. Toutes les modifications sont réduites à l'introduction d'un constructeur ( bytebuf.New
) et d'un pointeur vers un tableau au lieu d'un tableau normal:
type Buffer struct { buf []byte // contents are the bytes buf[off : len(buf)] off int // read at &buf[off], write at &buf[len(buf)] - bootstrap [64]byte // helps small buffers avoid allocation. + bootstrap *[64]byte // helps small buffers avoid allocation. lastRead readOp // last read operation (for Unread*). }
Voici une comparaison des performances avec les bytes.Buffer
:
name old time/op new time/op delta String/empty-8 138ns ±13% 24ns ± 0% -82.94% (p=0.000 n=10+8) String/5-8 186ns ±11% 60ns ± 1% -67.82% (p=0.000 n=10+10) String/64-8 225ns ±10% 108ns ± 6% -52.26% (p=0.000 n=10+10) String/128-8 474ns ±17% 338ns ±13% -28.57% (p=0.000 n=10+10) String/1024-8 889ns ± 0% 740ns ± 1% -16.78% (p=0.000 n=9+10) name old alloc/op new alloc/op delta String/empty-8 112B ± 0% 0B -100.00% (p=0.000 n=10+10) String/5-8 117B ± 0% 5B ± 0% -95.73% (p=0.000 n=10+10) String/64-8 176B ± 0% 64B ± 0% -63.64% (p=0.000 n=10+10) String/128-8 368B ± 0% 256B ± 0% -30.43% (p=0.000 n=10+10) String/1024-8 2.16kB ± 0% 2.05kB ± 0% -5.19% (p=0.000 n=10+10) name old allocs/op new allocs/op delta String/empty-8 1.00 ± 0% 0.00 -100.00% (p=0.000 n=10+10) String/5-8 2.00 ± 0% 1.00 ± 0% -50.00% (p=0.000 n=10+10) String/64-8 2.00 ± 0% 1.00 ± 0% -50.00% (p=0.000 n=10+10) String/128-8 3.00 ± 0% 2.00 ± 0% -33.33% (p=0.000 n=10+10) String/1024-8 3.00 ± 0% 2.00 ± 0% -33.33% (p=0.000 n=10+10)
Toutes les autres informations sont disponibles dans README .
En raison de l'impossibilité d'utiliser la valeur zéro et la liaison à la fonction de construction New
, il n'est pas possible d'appliquer cette optimisation à bytes.Buffer
.
Est-ce la seule façon de faire des bytes.Buffer
plus bytes.Buffer
? La réponse est non. Mais c'est certainement une méthode qui nécessite des changements minimes dans la mise en œuvre.
Plans d'analyse d'échappement
Dans sa forme actuelle, l'analyse d'échappement dans Go est assez faible. Presque toute opération avec des valeurs de pointeur conduit à des allocations sur le tas, même si ce n'est pas une décision raisonnable.
Je vais essayer de diriger la plupart du temps que je consacre au projet golang / go pour résoudre ces problèmes, donc certaines améliorations sont possibles dans la prochaine version (1.12).
Vous pouvez lire les résultats et les détails de la structure interne de cette partie du compilateur dans l'un de mes prochains articles. J'essaierai de fournir un ensemble de recommandations qui aideront dans certains cas à structurer le code afin qu'il ait moins d'allocations de mémoire indésirables.