bytes.Buffer in Go: optimisations qui ne fonctionnent pas

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) { //         bytes.Buffer. l := len(b.buf) //   Buffer need := n + l have := cap(b.buf) - l if have >= need { b.buf = b.buf[:need] return } if need <= smallBufSize { //     , //   . b.buf = b.bootstrap[:] } else { // growFactor -     . //     need  need*2. newBuf := make([]byte, need, growFactor(need)) copy(newBuf, b.buf) b.buf = newBuf } } 

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 // leak.go:11:6: moved to heap: b return &b // leak.go:12:9: &b escapes to heap } 

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" //go:noinline func alloc9() []byte { return make([]byte, 9) } func BenchmarkAlloc9(b *testing.B) { for i := 0; i < bN; i++ { _ = alloc9() } } 

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:


 //   ,     , // object      . object.buf1 = object.buf2[a:b] 

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):


 // Note, this optimization does not apply to OSLICEARR, // because it does introduce a new pointer into b that was not already there // (pointer to b itself). After such assignment, if b contents escape, // b escapes as well. If we ignore such OSLICEARR, we will conclude // that b does not escape when b contents do. 

Il convient d'ajouter ici que pour l'analyseur de pointeur, il existe plusieurs niveaux de "fuites", les principaux d'entre eux:


  1. L'objet lui-même s'échappe (b s'échappe). Dans ce cas, l'objet lui-même doit être alloué sur le tas.
  2. 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[:] // ignoring self-assignment to b.slice sink = b.array // b.array escapes to heap // b does not escape } 

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.

Source: https://habr.com/ru/post/fr422447/


All Articles