Mecânica da linguagem do perfil de memória

Prelúdio



Este é o terceiro de quatro artigos em uma série que fornecerá uma visão sobre a mecânica e o design de ponteiros, pilhas, pilhas, análise de escape e semântica de valor / ponteiro em Go. Esta postagem é sobre perfis de memória.



Índice do ciclo de artigos:



  1. Mecânica da linguagem em pilhas e indicadores ( tradução )
  2. Mecânica da linguagem na análise de escape ( tradução )
  3. Mecânica da linguagem no perfil de memória
  4. Filosofia de design em dados e semântica


Assista a este vídeo para ver uma demonstração deste código:

DGopherCon Singapore (2017) - Escape Analysis



Introdução



Em um post anterior, ensinei o básico da análise de escape usando um exemplo que divide um valor em uma pilha de goroutina. Eu não mostrei a você nenhum outro cenário que pode levar a valores de heap. Para ajudá-lo com isso, vou depurar um programa que faz alocações de maneiras inesperadas.



Programa



Eu queria aprender mais sobre o pacote io, então criei uma pequena tarefa para mim. Dado um fluxo de bytes, escreva uma função que possa localizar a string elvis e substituí-la pela string Elvis em maiúscula. Estamos falando de um rei, então seu nome deve sempre estar em maiúscula.



Aqui está um link para uma solução: play.golang.org/p/n_SzF4Cer4

Aqui está um link para benchmarks: play.golang.org/p/TnXrxJVfLV



A lista mostra duas funções diferentes que realizam essa tarefa. Esta postagem se concentrará na função algOne, uma vez que usa o pacote io. Use a função algTwo para fazer experiências com perfis de memória e processador você mesmo.



Aqui está a entrada que vamos usar e a saída esperada da função algOne.



Listagem 1



Input:
abcelvisaElvisabcelviseelvisaelvisaabeeeelvise l v i saa bb e l v i saa elvi
selvielviselvielvielviselvi1elvielviselvis

Output:
abcElvisaElvisabcElviseElvisaElvisaabeeeElvise l v i saa bb e l v i saa elvi
selviElviselvielviElviselvi1elviElvisElvis


Abaixo está uma lista da função algOne.



Listagem 2



 80 func algOne(data []byte, find []byte, repl []byte, output *bytes.Buffer) {
 81
 82     // Use a bytes Buffer to provide a stream to process.
 83     input := bytes.NewBuffer(data)
 84
 85     // The number of bytes we are looking for.
 86     size := len(find)
 87
 88     // Declare the buffers we need to process the stream.
 89     buf := make([]byte, size)
 90     end := size - 1
 91
 92     // Read in an initial number of bytes we need to get started.
 93     if n, err := io.ReadFull(input, buf[:end]); err != nil {
 94         output.Write(buf[:n])
 95         return
 96     }
 97
 98     for {
 99
100         // Read in one byte from the input stream.
101         if _, err := io.ReadFull(input, buf[end:]); err != nil {
102
103             // Flush the reset of the bytes we have.
104             output.Write(buf[:end])
105             return
106         }
107
108         // If we have a match, replace the bytes.
109         if bytes.Compare(buf, find) == 0 {
110             output.Write(repl)
111
112             // Read a new initial number of bytes.
113             if n, err := io.ReadFull(input, buf[:end]); err != nil {
114                 output.Write(buf[:n])
115                 return
116             }
117
118             continue
119         }
120
121         // Write the front byte since it has been compared.
122         output.WriteByte(buf[0])
123
124         // Slice that front byte out.
125         copy(buf, buf[1:])
126     }
127 }


Quero saber se essa função funciona bem e quanta pressão ela exerce sobre o heap. Para descobrir, vamos fazer um benchmark.



avaliação comparativa



Eu escrevi um benchmark que chama a função algOne para realizar o processamento no fluxo de dados.



Listagem 3



15 func BenchmarkAlgorithmOne(b *testing.B) {
16     var output bytes.Buffer
17     in := assembleInputStream()
18     find := []byte("elvis")
19     repl := []byte("Elvis")
20
21     b.ResetTimer()
22
23     for i := 0; i < b.N; i++ {
24         output.Reset()
25         algOne(in, find, repl, &output)
26     }
27 }


Podemos executar este benchmark usando go test com as opções -bench, -benchtime e -benchmem.



Listagem 4



$ go test -run none -bench AlgorithmOne -benchtime 3s -benchmem
BenchmarkAlgorithmOne-8        2000000          2522 ns/op       117 B/op            2 allocs/op


Depois de executar o benchmark, vemos que a função algOne aloca 2 valores com um custo total de 117 bytes por operação. Isso é ótimo, mas precisamos saber quais linhas de código na função estão causando essas alocações. Para descobrir, precisamos gerar dados de perfil para este teste.



Profiling



Para gerar os dados de criação de perfil, execute o benchmark novamente, mas desta vez consultamos o perfil de memória usando a opção -memprofile.



Listagem 5



$ go test -run none -bench AlgorithmOne -benchtime 3s -benchmem -memprofile mem.out
BenchmarkAlgorithmOne-8        2000000          2570 ns/op       117 B/op            2 allocs/op


Depois de concluir o benchmark, a ferramenta de teste criou dois novos arquivos.



Listagem 6



~/code/go/src/.../memcpu
$ ls -l
total 9248
-rw-r--r--  1 bill  staff      209 May 22 18:11 mem.out       (NEW)
-rwxr-xr-x  1 bill  staff  2847600 May 22 18:10 memcpu.test   (NEW)
-rw-r--r--  1 bill  staff     4761 May 22 18:01 stream.go
-rw-r--r--  1 bill  staff      880 May 22 14:49 stream_test.go


O código-fonte está na pasta memcpu na função algOne do arquivo stream.go e a função benchmark no arquivo stream_test.go. Os dois novos arquivos criados são denominados mem.out e memcpu.test. O arquivo mem.out contém os dados do perfil, e o arquivo memcpu.test, com o nome da pasta, contém o binário de teste de que precisamos para acessar os símbolos ao examinar os dados do perfil.



Com os dados do perfil e o binário de teste, podemos executar a ferramenta pprof para examinar os dados do perfil.



Listagem 7



$ go tool pprof -alloc_space memcpu.test mem.out
Entering interactive mode (type "help" for commands)
(pprof) _


Ao criar o perfil da memória e procurar por frutas mais baixas, você pode usar a opção -alloc_space em vez da opção -inuse_space padrão. Isso irá mostrar onde cada alocação ocorre, se está na memória ou não quando você pega o perfil.



Na caixa de entrada (pprof), podemos verificar a função algOne com o comando list. Este comando usa uma expressão regular como argumento para encontrar a (s) função (ões) que deseja visualizar.



Listagem 8



(pprof) list algOne
Total: 335.03MB
ROUTINE ======================== .../memcpu.algOne in code/go/src/.../memcpu/stream.go
 335.03MB   335.03MB (flat, cum)   100% of Total
        .          .     78:
        .          .     79:// algOne is one way to solve the problem.
        .          .     80:func algOne(data []byte, find []byte, repl []byte, output *bytes.Buffer) {
        .          .     81:
        .          .     82: // Use a bytes Buffer to provide a stream to process.
 318.53MB   318.53MB     83: input := bytes.NewBuffer(data)
        .          .     84:
        .          .     85: // The number of bytes we are looking for.
        .          .     86: size := len(find)
        .          .     87:
        .          .     88: // Declare the buffers we need to process the stream.
  16.50MB    16.50MB     89: buf := make([]byte, size)
        .          .     90: end := size - 1
        .          .     91:
        .          .     92: // Read in an initial number of bytes we need to get started.
        .          .     93: if n, err := io.ReadFull(input, buf[:end]); err != nil || n < end {
        .          .     94:       output.Write(buf[:n])
(pprof) _


Com base nesse perfil, agora sabemos que a entrada e o buf estão alocados no heap. Como a entrada é uma variável de ponteiro, o perfil realmente diz que o valor bytes.Buffer apontado pela entrada está alocado. Portanto, vamos primeiro nos concentrar na alocação de dados e entender por que isso acontece.



Podemos supor que a alocação está acontecendo porque a chamada para bytes.NewBuffer compartilha o valor bytes.Buffer que cria a pilha de chamadas. No entanto, a presença do valor na coluna plana (a primeira coluna na saída do pprof) me diz que o valor está sendo alocado porque a função algOne o divide de uma maneira que o faz empilhar.



Eu sei que a coluna plana representa as alocações na função, então dê uma olhada no que o comando list mostra para a função Benchmark que chama algOne.



Listagem 9



(pprof) list Benchmark
Total: 335.03MB
ROUTINE ======================== .../memcpu.BenchmarkAlgorithmOne in code/go/src/.../memcpu/stream_test.go
        0   335.03MB (flat, cum)   100% of Total
        .          .     18: find := []byte("elvis")
        .          .     19: repl := []byte("Elvis")
        .          .     20:
        .          .     21: b.ResetTimer()
        .          .     22:
        .   335.03MB     23: for i := 0; i < b.N; i++ {
        .          .     24:       output.Reset()
        .          .     25:       algOne(in, find, repl, &output)
        .          .     26: }
        .          .     27:}
        .          .     28:
(pprof) _


Como há apenas um valor na coluna cum (segunda coluna), isso me diz que o Benchmark não está alocando nada diretamente. Todas as alocações vêm de chamadas de função que são executadas dentro deste loop. Você pode ver que todos os números de alocação dessas duas chamadas à lista são todos iguais.



Ainda não sabemos por que o valor bytes.Buffer é alocado. É aqui que a opção -gcflags "-m -m" do comando go build se torna útil. O criador de perfil só pode dizer quais valores estão sendo movidos para o heap, enquanto o build pode dizer por quê.



Relatórios do compilador



Vamos perguntar ao compilador quais decisões ele toma para escapar da análise no código.



Listagem 10



$ go build -gcflags "-m -m"


Este comando produz muitos resultados. Precisamos apenas pesquisar na saída o que stream.go: 83 tem, porque stream.go é o nome do arquivo que contém esse código e a linha 83 contém a construção de valor bytes.buffer. Depois de pesquisar, encontramos 6 linhas.



Listagem 11



./stream.go:83: inlining call to bytes.NewBuffer func([]byte) *bytes.Buffer { return &bytes.Buffer literal }

./stream.go:83: &bytes.Buffer literal escapes to heap
./stream.go:83:   from ~r0 (assign-pair) at ./stream.go:83
./stream.go:83:   from input (assigned) at ./stream.go:83
./stream.go:83:   from input (interface-converted) at ./stream.go:93
./stream.go:83:   from input (passed to call[argument escapes]) at ./stream.go:93


estamos interessados ​​na primeira linha que encontramos ao pesquisar stream.go: 83.



Listagem 12



./stream.go:83: inlining call to bytes.NewBuffer func([]byte) *bytes.Buffer { return &bytes.Buffer literal }


Isso confirma que o valor bytes.Buffer não desapareceu ao ser colocado na pilha de chamadas. Isso aconteceu porque a chamada bytes.NewBuffer nunca aconteceu, o código dentro da função estava embutido.



Aqui está a linha de código em questão:



Listagem 13



83     input := bytes.NewBuffer(data)


Como o compilador decidiu embutir a chamada de função bytes.NewBuffer, o código que escrevi se converte neste:



Listagem 14



input := &bytes.Buffer{buf: data}


Isso significa que a função algOne cria o valor bytes.Buffer diretamente. Portanto, agora a questão é: o que faz com que o valor saia do frame da pilha algOne? Essa resposta está nas outras 5 linhas que encontramos no relatório.



Listagem 15



./stream.go:83: &bytes.Buffer literal escapes to heap
./stream.go:83:   from ~r0 (assign-pair) at ./stream.go:83
./stream.go:83:   from input (assigned) at ./stream.go:83
./stream.go:83:   from input (interface-converted) at ./stream.go:93
./stream.go:83:   from input (passed to call[argument escapes]) at ./stream.go:93


Essas linhas nos dizem que o escape de heap ocorre na linha 93 do código. A variável de entrada é atribuída ao valor da interface.



Interfaces



Não me lembro de ter feito atribuição de valor de interface no código. No entanto, se você olhar a linha 93, ficará claro o que está acontecendo.



Listagem 16



 93     if n, err := io.ReadFull(input, buf[:end]); err != nil {
 94         output.Write(buf[:n])
 95         return
 96     }


A chamada io.ReadFull invoca a atribuição da interface. Se você observar a definição da função io.ReadFull, verá que ela aceita uma variável de entrada por meio de um tipo de interface.



Listagem 17



type Reader interface {
      Read(p []byte) (n int, err error)
}

func ReadFull(r Reader, buf []byte) (n int, err error) {
      return ReadAtLeast(r, buf, len(buf))
}


Parece que passar o endereço bytes.Buffer para baixo na pilha de chamadas e armazená-lo no valor da interface do Reader causa um escape. Agora sabemos que o custo de usar uma interface é alto: alocação e indireção. Portanto, se não estiver claro exatamente como uma interface torna seu código melhor, provavelmente você não precisará usá-la. Aqui estão algumas diretrizes que sigo para testar o uso de interfaces em meu código.



Use a interface quando:



  • Os usuários da API devem fornecer detalhes de implementação.
  • A API possui várias implementações que devem ser suportadas internamente.
  • Foram identificadas partes da API que podem mudar e exigir separação.


Não use a interface:



  • para usar a interface.
  • para generalizar o algoritmo.
  • quando os usuários podem declarar suas próprias interfaces.


Agora podemos nos perguntar: esse algoritmo realmente precisa da função io.ReadFull? A resposta é não, porque o tipo bytes.Buffer possui um conjunto de métodos que podemos usar. Usar métodos em relação ao valor que a função possui pode evitar alocações.



Vamos mudar o código para remover o pacote io e usar o método Read diretamente na variável de entrada.



Essa mudança de código remove a necessidade de importar o pacote io, portanto, para manter todos os números de linha iguais, uso um identificador vazio para importar o pacote io. Isso manterá as importações na lista.



Listagem 18



 12 import (
 13     "bytes"
 14     "fmt"
 15     _ "io"
 16 )

 80 func algOne(data []byte, find []byte, repl []byte, output *bytes.Buffer) {
 81
 82     // Use a bytes Buffer to provide a stream to process.
 83     input := bytes.NewBuffer(data)
 84
 85     // The number of bytes we are looking for.
 86     size := len(find)
 87
 88     // Declare the buffers we need to process the stream.
 89     buf := make([]byte, size)
 90     end := size - 1
 91
 92     // Read in an initial number of bytes we need to get started.
 93     if n, err := input.Read(buf[:end]); err != nil || n < end {
 94         output.Write(buf[:n])
 95         return
 96     }
 97
 98     for {
 99
100         // Read in one byte from the input stream.
101         if _, err := input.Read(buf[end:]); err != nil {
102
103             // Flush the reset of the bytes we have.
104             output.Write(buf[:end])
105             return
106         }
107
108         // If we have a match, replace the bytes.
109         if bytes.Compare(buf, find) == 0 {
110             output.Write(repl)
111
112             // Read a new initial number of bytes.
113             if n, err := input.Read(buf[:end]); err != nil || n < end {
114                 output.Write(buf[:n])
115                 return
116             }
117
118             continue
119         }
120
121         // Write the front byte since it has been compared.
122         output.WriteByte(buf[0])
123
124         // Slice that front byte out.
125         copy(buf, buf[1:])
126     }
127 }


Quando comparamos essa mudança de código, podemos ver que não há mais alocação para o valor bytes.Buffer.



Listagem 19



$ go test -run none -bench AlgorithmOne -benchtime 3s -benchmem -memprofile mem.out
BenchmarkAlgorithmOne-8        2000000          1814 ns/op         5 B/op            1 allocs/op


Também vemos uma melhoria de desempenho de cerca de 29%. O tempo mudou de 2570 ns / op para 1814 ns / op. Agora que isso está resolvido, podemos nos concentrar na alocação de uma fatia auxiliar para buf. Se usarmos o profiler novamente para os novos dados de perfil que acabamos de criar, podemos determinar o que exatamente está causando as alocações restantes.



Listagem 20



$ go tool pprof -alloc_space memcpu.test mem.out
Entering interactive mode (type "help" for commands)
(pprof) list algOne
Total: 7.50MB
ROUTINE ======================== .../memcpu.BenchmarkAlgorithmOne in code/go/src/.../memcpu/stream_test.go
     11MB       11MB (flat, cum)   100% of Total
        .          .     84:
        .          .     85: // The number of bytes we are looking for.
        .          .     86: size := len(find)
        .          .     87:
        .          .     88: // Declare the buffers we need to process the stream.
     11MB       11MB     89: buf := make([]byte, size)
        .          .     90: end := size - 1
        .          .     91:
        .          .     92: // Read in an initial number of bytes we need to get started.
        .          .     93: if n, err := input.Read(buf[:end]); err != nil || n < end {
        .          .     94:       output.Write(buf[:n])


A única alocação restante está na linha 89, que é para criar uma fatia auxiliar.



Stack frames



Queremos saber por que a alocação está acontecendo para a fatia auxiliar para buf? Vamos executar a construção novamente com a opção -gcflags "-m -m" e procurar stream.go: 89.



Listagem 21



$ go build -gcflags "-m -m"
./stream.go:89: make([]byte, size) escapes to heap
./stream.go:89:   from make([]byte, size) (too large for stack) at ./stream.go:89


O relatório afirma que a matriz auxiliar é "muito grande para a pilha". Esta mensagem é enganosa. A questão não é que o array seja muito grande, mas que o compilador não sabe qual é o tamanho do array auxiliar no momento da compilação.



Os valores só podem ser colocados na pilha se o compilador souber o tamanho do valor no momento da compilação. Isso ocorre porque o tamanho de cada quadro de pilha para cada função é calculado em tempo de compilação. Se o compilador não souber o tamanho de um valor, ele estará sobrecarregado.



Para demonstrar isso, vamos codificar temporariamente o tamanho da fatia para 5 e executar o benchmark novamente.



Listagem 22



89     buf := make([]byte, 5)


Não há mais alocações neste momento.



Listagem 23



$ go test -run none -bench AlgorithmOne -benchtime 3s -benchmem
BenchmarkAlgorithmOne-8        3000000          1720 ns/op         0 B/op            0 allocs/op


Se você der outra olhada no relatório do compilador, verá que nada está sendo movido para o heap.



Listagem 24



$ go build -gcflags "-m -m"
./stream.go:83: algOne &bytes.Buffer literal does not escape
./stream.go:89: algOne make([]byte, 5) does not escape


Obviamente, não podemos codificar o tamanho da fatia, então teremos que viver com 1 alocação para esse algoritmo.



Alocações e desempenho



Compare os ganhos de desempenho que alcançamos com cada refatoração.



Listagem 25



Before any optimization
BenchmarkAlgorithmOne-8        2000000          2570 ns/op       117 B/op            2 allocs/op

Removing the bytes.Buffer allocation
BenchmarkAlgorithmOne-8        2000000          1814 ns/op         5 B/op            1 allocs/op

Removing the backing array allocation
BenchmarkAlgorithmOne-8        3000000          1720 ns/op         0 B/op            0 allocs/op


Obtivemos um aumento de desempenho de cerca de 29% devido ao fato de que removemos a alocação de bytes.Buffer e uma aceleração de ~ 33% após remover todas as alocações. As alocações são onde o desempenho do aplicativo pode ser afetado.



Conclusão



Go tem algumas ferramentas incríveis para ajudá-lo a entender as decisões que o compilador toma sobre a análise de escape. Com base nessas informações, você pode refatorar seu código para ajudar a manter os valores na pilha que não deveriam estar no heap. Você não deve escrever um programa com alocações zero, mas deve se esforçar para minimizar as alocações sempre que possível.



Não faça da produtividade uma prioridade ao escrever código, porque você não quer adivinhar o que deve estar funcionando. Escreva o código e otimize-o para obter desempenho para a tarefa de primeira prioridade. Isso significa focar primeiro na integridade, legibilidade e simplicidade. Depois de ter um programa funcionando, determine se ele é rápido o suficiente. Caso contrário, use as ferramentas fornecidas pelo idioma para localizar e corrigir problemas de desempenho.



All Articles