bytes.Buffer in Go: otimizações que não funcionam

Muitos programadores Go estão familiarizados com bytes.Buffer . Uma de suas vantagens é que ele permite que você evite alocar memória no heap da mesma maneira que a " pequena otimização de buffer / tamanho ":


type Buffer struct { bootstrap [64]byte //        // ...   } 

Existe apenas um problema. Essa otimização não funciona .


No final deste artigo, você descobrirá por que essa otimização não funciona e o que podemos fazer sobre isso.


Conforme pretendido, "otimização de buffer pequeno"


Vamos apresentar uma definição ligeiramente simplificada de bytes.Buffer .


 const smallBufSize int = 64 type Buffer struct { bootstrap [smallBufSize]byte buf []byte } 

Quando executamos ações no Buffer , por exemplo, chamamos o método Buffer.Write , o registro é sempre feito no buf ; no entanto, antes desse registro, Buffer.grow(n) iniciado dentro de cada método semelhante, o que garante que haja espaço suficiente nessa fatia para próximos n bytes.


Grow pode ser algo como isto:


 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 } } 

Pressupostos usados ​​em nossa implementação do Buffer.grow


Assumimos que len(b.buf) é o tamanho real dos dados no Buffer, o que exigiria que o Write use métodos de adição para adicionar novos bytes à fatia. Esse não é o caso em bytes.Buffer da biblioteca padrão, mas, por exemplo, este é um detalhe de implementação sem importância.




Se b alocado na pilha, o bootstrap dentro dela será alocado na pilha, o que significa que a fatia b.buf reutilizará a memória dentro de b sem exigir alocação adicional.


Quando o grow revelar que a matriz de bootstrap já é insuficiente, uma nova fatia "real" será criada, onde os elementos do armazenamento passado (do "pequeno buffer") serão copiados. Depois disso, o Buffer.bootstrap perderá sua relevância. Se Buffer.Reset for Buffer.Reset , cap(b.buf) permanecerá o mesmo e não haverá mais necessidade de uma matriz de bootstrap .


Memória fugindo na pilha


Espera-se ainda que o leitor esteja pelo menos superficialmente familiarizado com o que é a análise de escape no Go.


Considere a seguinte situação:


 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 } 

Aqui b será alocado na pilha. A razão para isso é o ponteiro vazando para b :


 $ go tool compile -m leak.go leak.go:12:9: &b escapes to heap leak.go:11:6: moved to heap: b 

Terminologia


Neste artigo, "vazamento" e "escape" são usados ​​quase como sinônimos.


Há alguma diferença no próprio compilador, por exemplo, o valor "escapa para a pilha", mas os parâmetros de função são "vazamento de parâmetro x".


Um parâmetro vazando significa que o argumento passado para esse parâmetro será alocado no heap. Em outras palavras, o parâmetro vazamento faz com que os argumentos escapem para um heap.




O acima foi um caso óbvio, mas e quanto a isso:


 func length() int { var b bytes.Buffer b.WriteString("1") return b.Len() } 

Aqui precisamos de apenas 1 byte, tudo se encaixa no bootstrap , o buffer em si é local e não "escapa" da função. Você pode se surpreender, mas o resultado será o mesmo, alocação b na pilha.



Para ter certeza, você pode verificar isso usando a referência:


 BenchmarkLength-8 20000000 90.1 ns/op 112 B/op 1 allocs/op 

Listagem de benchmark


 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() } } 



Explicação 112 B / op


Quando o tempo de execução solicita N bytes ao alocador, não é necessário que exatamente N bytes sejam alocados.


Todos os resultados abaixo são para a combinação de GOOS=linux e 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() } } 

Se você executar, go test -bench=. -benchmem go test -bench=. -benchmem com este teste:


 BenchmarkAlloc9-8 50000000 33.5 ns/op 16 B/op 1 allocs/op 

9 bytes solicitados, 16 alocados. Agora, de volta aos bytes.Buffer :


 fmt.Println(unsafe.Sizeof(bytes.Buffer{})) => 104 

Vejamos $ 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% // ...  

Não cabe em 96 bytes, 112 está selecionado.




Mas por que isso está acontecendo?


O que está acontecendo e por quê


Alguma análise da situação pode ser encontrada na questão mencionada no início.
Há também um reprodutor simples .


O local do problema está apenas na atribuição b.buf = b.bootstrap[:] . Esse código faz com que a análise de escape assuma que o b.bootstrap "fugindo" e, como é uma matriz, ele é armazenado dentro do próprio objeto, o que significa que todo o b deve ser alocado no heap.


Se o bootstrap fosse uma fatia, não uma matriz, isso não aconteceria, porque existe uma otimização adhoc para atribuir fatias do objeto ao próprio objeto:


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

A resposta por que essa otimização não funciona para matrizes já foi formulada acima, mas aqui está um aperto do próprio esc.go # L835-L866 (todo o código de otimização é destacado por referência):


 // 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. 

Vale acrescentar aqui que, para o analisador de ponteiros, existem vários níveis de "vazamentos", os principais deles:


  1. O próprio objeto escapa (b escapa). Nesse caso, o próprio objeto precisa ser alocado no heap.
  2. Os elementos do objeto (escape de conteúdo b) escapam. Nesse caso, os ponteiros no objeto são considerados escapantes.

O caso da matriz é especial, pois se a matriz vazar, o objeto que a contém também deverá vazar.


A análise de escape exibe uma decisão sobre se é possível colocar um objeto na pilha ou não, baseando-se apenas nas informações disponíveis no corpo da função analisada. O método Buffer.grow recebe b por ponteiro, portanto, ele precisa calcular um local adequado para colocar. Como no caso de uma matriz não podemos distinguir entre "b escape" e "b contents escape" , temos que ser mais pessimistas e chegar à conclusão de que b não b seguro colocar na pilha.


Suponha o contrário, que o padrão de self-assignment resolva o mesmo para matrizes e para fatias:


 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 } 

A decisão de colocar b na pilha nessa situação levará a um desastre: depois de sair da função na qual b foi criado, a memória à qual o sink se referirá será nada mais que lixo.


Ponteiros de matriz


Imagine que nosso Buffer foi declarado um pouco diferente:


 const smallBufSize int = 64 type Buffer struct { - bootstrap [smallBufSize]byte + bootstrap *[smallBufSize]byte buf []byte } 

Ao contrário de uma matriz regular, um ponteiro para uma matriz não armazena todos os elementos dentro do próprio Buffer . Isso significa que se a alocação de bootstrap no heap não envolver alocação de Buffer no heap. Como a análise de escape pode alocar campos de ponteiro na pilha quando possível, podemos assumir que essa definição de Buffer é mais bem-sucedida.


Mas isso é em teoria. Na prática, um ponteiro para uma matriz não possui muito processamento e se enquadra na mesma categoria que uma fatia de uma matriz regular, o que não está totalmente correto. CL133375: cmd / compile / internal / gc: handle auto-atribuir fatia em esc.go visa corrigir essa situação.


Suponha que essa alteração tenha sido aceita no compilador Go.


Valor zero que perdemos


Infelizmente, a transição de [64]byte para *[64]byte tem um problema: agora não podemos usar o bootstrap sem inicializá-lo explicitamente, um valor zero de Buffer deixa de ser útil, precisamos de um construtor.


 func NewBuffer() Buffer { return Buffer{bootstrap: new(*[smallBufSize]byte)} } 

Retornamos o Buffer , não o *Buffer , para evitar problemas com a análise de ponteiros (é muito conservador no Go) e, levando em consideração o fato de que o NewBuffer sempre NewBuffer embutido no local de uma chamada, não haverá cópia desnecessária.


Após incorporar o corpo NewBuffer no lugar da chamada, a análise de escape pode tentar provar que o new(*[smallBufSize]byte) não excede a vida útil do quadro da função na qual é chamado. Nesse caso, a alocação estará na pilha.


Intel bytebuf


A otimização descrita acima é aplicada no pacote intel-go / bytebuf .


Essa biblioteca exporta o tipo bytebuf.Buffer , que duplica 99,9% de bytes.Buffer . Todas as alterações são reduzidas à introdução de um construtor ( bytebuf.New ) e um ponteiro para uma matriz em vez de uma matriz regular:


 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*). } 

Aqui está uma comparação de desempenho com 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) 

Todas as outras informações estão disponíveis no README .


Devido à incapacidade de usar o valor zero e a ligação à função de construção New , não é possível aplicar essa otimização a bytes.Buffer .


Essa é a única maneira de gerar bytes.Buffer mais bytes.Buffer ? A resposta é não. Mas esse é definitivamente um método que requer mudanças mínimas na implementação.


Planos de Análise de Escape


Em sua forma atual, a análise de escape no Go é bastante fraca. Quase qualquer operação com valores de ponteiro leva a alocações no heap, mesmo que essa não seja uma decisão razoável.


Tentarei direcionar a maior parte do tempo que dedico ao projeto golang / go para resolver esses problemas, portanto, algumas melhorias são possíveis na próxima versão (1.12).


Você pode ler sobre os resultados e detalhes da estrutura interna desta parte do compilador em um dos meus próximos artigos. Tentarei fornecer um conjunto de recomendações que ajudarão em alguns casos a estruturar o código para que ele tenha menos alocações de memória indesejadas.

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


All Articles