Golang: problemas específicos de desempenho

O idioma Go está ganhando popularidade. Tão confiante que há cada vez mais conferências, por exemplo, GolangConf , e o idioma está entre as dez tecnologias mais bem pagas. Portanto, já faz sentido falar sobre seus problemas específicos, por exemplo, desempenho. Além de problemas comuns para todos os idiomas compilados, o Go tem seus próprios. Eles estão associados ao otimizador, pilha, sistema de tipos e modelo de multitarefa. Maneiras de resolvê-los e soluções alternativas às vezes são muito específicas.

Daniel Podolsky , embora o evangelista de Go, também encontre muitas coisas estranhas nele. Tudo estranho e, mais importante, interessante, coleta e testa e depois fala sobre isso no HighLoad ++. A transcrição do relatório incluirá números, gráficos, exemplos de código, resultados de criação de perfil, uma comparação do desempenho dos mesmos algoritmos em diferentes idiomas - e tudo mais, para o qual odiamos a palavra "otimização". Não haverá revelações na transcrição - de onde elas vieram em uma linguagem tão simples - e tudo o que pode ser lido nos jornais.



Sobre os alto-falantes. Daniil Podolsky : 26 anos de experiência, 20 em operação, incluindo o líder do grupo, 5 anos de programação no Go. Kirill Danshin : criador do Gramework, Mantenedor, HTTP Rápido, Black Go-mage.

O relatório foi preparado em conjunto por Daniel Podolsky e Kirill Danshin, mas Daniel fez um relatório, e Kirill ajudou mentalmente.

Construções linguísticas


Temos um padrão de desempenho - direct . Essa é uma função que incrementa uma variável e não faz mais nada.

 //   var testInt64 int64 func BenchmarkDirect(b *testing.B) { for i := 0; i < bN; i++ { incDirect() } } func incDirect() { testInt64++ } 

O resultado da função é 1,46 ns por operação . Esta é a opção mínima. Mais rápido que 1,5 ns por operação, provavelmente não funcionará.

Adie como o amamos


Muitos sabem e gostam de usar a construção de adiamento da linguagem. Muitas vezes usamos assim.

 func BenchmarkDefer(b *testing.B) { for i := 0; i < bN; i++ { incDefer() } } func incDefer() { defer incDirect() } 

Mas você não pode usá-lo assim! Cada adiado come 40 ns por operação.

 //   BenchmarkDirect-4 2000000000 1.46 / // defer BenchmarkDefer-4 30000000 40.70 / 

Eu pensei que talvez isso seja por causa de inline? Talvez o inline seja tão rápido?

Direto está embutido e a função de adiamento não pode embutir. Portanto, compilou uma função de teste separada sem embutir.

 func BenchmarkDirectNoInline(b *testing.B) { for i := 0; i < bN; i++ { incDirectNoInline() } } //go:noinline func incDirectNoInline() { testInt64++ } 

Nada mudou, o adiamento levou os mesmos 40 ns. Adie querido, mas não catastrófico.

Onde uma função leva menos de 100 ns, você pode fazer isso sem adiar.

Mas onde a função leva mais de um microssegundo, é tudo a mesma coisa - você pode usar o adiamento.

Passando um parâmetro por referência


Considere um mito popular.

 func BenchmarkDirectByPointer(b *testing.B) { for i := 0; i < bN; i++ { incDirectByPointer(&testInt64) } } func incDirectByPointer(n *int64) { *n++ } 

Nada mudou - nada vale a pena.

 //     BenchmarkDirectByPointer-4 2000000000 1.47 / BenchmarkDeferByPointer-4 30000000 43.90 / 

Exceto 3 ns por adiamento, mas isso é baixado para flutuações.

Funções anônimas


Às vezes, os novatos perguntam: "Uma função anônima é cara?"

 func BenchmarkDirectAnonymous(b *testing.B) { for i := 0; i < bN; i++ { func() { testInt64++ }() } } 

Uma função anônima não é cara, são necessários 40,4 ns.

Interfaces


Existe uma interface e estrutura que a implementa.

 type testTypeInterface interface { Inc() } type testTypeStruct struct { n int64 } func (s *testTypeStruct) Inc() { s.n++ } 

Existem três opções para usar o método de incremento. Diretamente do Struct: var testStruct = testTypeStruct{} .

Na interface concreta correspondente: var testInterface testTypeInterface = &testStruct .

Com a conversão da interface de tempo de execução: var testInterfaceEmpty interface{} = &testStruct .

Abaixo está a conversão e o uso da interface de tempo de execução diretamente.

 func BenchmarkInterface(b *testing.B) { for i := 0; i < bN; i++ { testInterface.Inc() } } func BenchmarkInterfaceRuntime(b *testing.B) { for i := 0; i < bN; i++ { testInterfaceEmpty.(testTypeInterface).Inc() } } 

A interface, como tal, não custa nada.

 //  BenchmarkStruct-4 2000000000 1.44 / BenchmarkInterface-4 2000000000 1.88 / BenchmarkInterfaceRuntime-4 200000000 9.23 / 


A conversão da interface em tempo de execução vale a pena, mas não é cara - você não precisa se recusar especificamente. Mas tente ficar sem ele sempre que possível.

Mitos:

  • Desreferência - ponteiros de referência - grátis.
  • Recursos anônimos são gratuitos.
  • As interfaces são gratuitas.
  • Conversão da interface de tempo de execução - NÃO É GRATUITA.

Mudar, mapear e cortar


Todo iniciante no Go pergunta o que acontece se você substituir o switch pelo mapa. Será mais rápido?

O interruptor vem em tamanhos diferentes. Testei em três tamanhos: pequeno para 10 casos, médio para 100 e grande para 1000 casos. O switch para 1000 casos é encontrado no código de produção real. Claro, ninguém os escreve com as mãos. Este é um código gerado automaticamente, geralmente uma opção de tipo. Testado em dois tipos: int e string. Parecia que ficaria mais claro.

Pequeno interruptor. A opção mais rápida é a troca real. A seguir, ele passa imediatamente a fatia, onde o índice inteiro correspondente contém uma referência à função. O mapa não é um líder em int ou string.
BenchmarkSwitchIntSmall-45000000003,26 ns / op
BenchmarkMapIntSmall-4100.000.00011,70 ns / op
BenchmarkSliceIntSmall-45000000003,85 ns / op
BenchmarkSwitchStringSmall-4100.000.00012,70 ns / op
BenchmarkMapStringSmall-4100.000.00015,60 ns / op

Ativar strings é significativamente mais lento que no int. Se você pode mudar não para string, mas para int, faça-o.

Interruptor do meio. O próprio switch ainda governa int, mas o fatia ultrapassou um pouco. O mapa ainda está ruim. Mas em uma chave de cadeia, o mapa é mais rápido que o switch - como esperado.
BenchmarkSwitchIntMedium-43000000004,55 ns / op
BenchmarkMapIntMedium-4100.000.00017,10 ns / op
BenchmarkSliceIntMedium-43000000003,76 ns / op
BenchmarkSwitchStringMedium-450.000.00028,50 ns / op
BenchmarkMapStringMedium-4100.000.00020,30 ns / op

Grande mudança. Mil casos mostram a vitória incondicional do mapa na nomeação "alternar por sequência". Teoricamente, a fatia ganhou, mas na prática eu aconselho você a usar a mesma opção aqui. O mapa ainda é lento, mesmo considerando que o mapa possui chaves inteiras com uma função de hash especial. Em geral, essa função não faz nada. O próprio int tem um hash para int.
BenchmarkSwitchIntLarge-4100.000.00013,6 ns / op
BenchmarkMapIntLarge-450.000.00034,3 ns / op
BenchmarkSliceIntLarge-4100.000.00012,8 ns / op
BenchmarkSwitchStringLarge-420.000.000100,0 ns / op
BenchmarkMapStringLarge-43000000037,4 ns / op

Conclusões O mapa é melhor apenas em grandes quantidades e não em uma condição inteira. Estou certo de que em qualquer uma das condições, exceto int, ele se comportará da mesma maneira que na string. A fatia sempre orienta quando as condições são inteiras. Use-o se você quiser "acelerar" o seu programa em 2 ns.

Interação inter-rotineira


O tópico é complexo, realizei muitos testes e apresentarei os mais reveladores. Conhecemos os seguintes meios de interação interagências .

  • Atomic Esses são meios de aplicabilidade limitada - você pode substituir o ponteiro ou usar int.
  • O Mutex tem sido amplamente utilizado desde o Java.
  • O canal é exclusivo para GO.
  • Canal em buffer - canais em buffer.

Obviamente, testei em um número significativamente maior de goroutines que competem por um recurso. Mas ele escolheu três para si como indicativo: um pouco - 100, um meio - 1000 e muito - 10000.

O perfil de carregamento é diferente . Às vezes, todos os gorutins querem escrever em uma variável, mas isso é raro. Geralmente, afinal, alguns escrevem, outros leem. Da maioria dos leitores - 90% lê, daqueles que escrevem - 90% escrevem.

Este é o código usado para que a goroutine que serve o canal possa fornecer tanto a leitura quanto a gravação de uma variável.

 go func() { for { select { case n, ok := <-cw: if !ok { wgc.Done() return } testInt64 += n case cr <- testInt64: } } }() 

Se uma mensagem chega até nós através do canal através do qual escrevemos, nós a executamos. Se o canal estiver fechado, terminamos a goroutin. A qualquer momento, estamos prontos para gravar no canal usado por outras goroutines para leitura.
Benchmarkmutex-4100.000.00016,30 ns / op
Benchmarkatomic-42000000006,72 ns / op
Benchmarkcan-45.000.000239,00 ns / op

Estes são dados para uma goroutine. O teste do canal é realizado em duas goroutines: uma processa o canal e a outra grava nesse canal. E essas opções foram testadas em um.

  • Gravações diretas em uma variável.
  • O Mutex pega um log, grava em uma variável e libera um log.
  • O Atomic grava em uma variável através do Atomic. Não é gratuito, mas ainda significativamente mais barato que o Mutex em um garutin.

Com uma pequena quantidade de goroutine, o Atomic é uma maneira rápida e eficaz de sincronizar, o que não é surpreendente. O Direct não está aqui, porque precisamos de sincronização, que ele não fornece. Mas Atomic tem falhas, é claro.
BenchmarkMutexFew-43000055894 ns / op
BenchmarkAtomicFew-4100.00014585 ns / op
BenchmarkChanFew-45000323859 ns / op
BenchmarkChanBufferedFew-45000341321 ns / op
BenchmarkChanBufferedFullFew-42000070052 ns / op
BenchmarkMutexMostlyReadFew-43000056402 ns / op
BenchmarkAtomicMostlyReadFew-41.000.0002094 ns / op
BenchmarkChanMostlyReadFew-43000442689 ns / op
BenchmarkChanBufferedMostlyReadFew-43000449.666 ns / op
BenchmarkChanBufferedFullMostlyReadFew-45000442.708 ns / op
BenchmarkMutexMostlyWriteFew-42000079708 ns / op
BenchmarkAtomicMostlyWriteFew-4100.00013358 ns / op
BenchmarkChanMostlyWriteFew-43000449.556 ns / op
BenchmarkChanBufferedMostlyWriteFew-43000445423 ns / op
BenchmarkChanBufferedFullMostlyWriteFew-43000414626 ns / op

O próximo é Mutex. Eu esperava que o Channel fosse tão rápido quanto o Mutex, mas não.

Canal é uma ordem de magnitude mais cara que o Mutex.

Além disso, o canal e o canal tamponado são vendidos pelo mesmo preço. E existe o canal, no qual o buffer nunca transborda. É uma ordem de magnitude mais barata do que aquela cujo buffer transborda. Somente se o buffer no Canal não estiver cheio, ele custará o mesmo em ordens de grandeza que o Mutex. Isso é o que eu esperava do teste.

Essa imagem com a distribuição de quanto custa é repetida em qualquer perfil de carga - tanto no MostlyRead quanto no MostlyWrite. Além disso, o canal MostlyRead completo custa o mesmo que o incompleto. E o canal em buffer do MostlyWrite, no qual o buffer não está cheio, custa o mesmo que o restante. Não sei dizer por que isso acontece - ainda não estudei esse problema.

Passando parâmetros


Como passar parâmetros mais rapidamente - por referência ou por valor? Vamos conferir.

Eu verifiquei da seguinte maneira - fiz tipos aninhados de 1 a 10.

 type TP001 struct { I001 int64 } type TV002 struct { I001 int64 S001 TV001 I002 int64 S002 TV001 } 

O décimo tipo aninhado terá 10 campos int64 e os tipos aninhados do aninhamento anterior também serão 10.

Em seguida, ele escreveu funções que criam um tipo de aninhamento.

 func NewTP001() *TP001 { return &TP001{ I001: rand.Int63(), } } func NewTV002() TV002 { return TV002{ I001: rand.Int63(), S001: NewTV001(), I002: rand.Int63(), S002: NewTV001(), } } 

Para o teste, usei três opções do tipo: pequeno com aninhamento 2, médio com aninhamento 3, grande com aninhamento 5. Eu tive que fazer um teste muito grande com aninhamento 10 à noite, mas a imagem é exatamente a mesma de 5.

Nas funções, a passagem por valor é pelo menos duas vezes mais rápida que a passagem por referência . Isso se deve ao fato de que a passagem por valor não carrega a análise de escape. Assim, as variáveis ​​que alocamos estão na pilha. É substancialmente mais barato para o tempo de execução, para o coletor de lixo. Embora ele possa não ter tempo para se conectar. Esses testes continuaram por alguns segundos - o coletor de lixo provavelmente ainda estava dormindo.
BenchmarkCreateSmallByValue-4200.0008942 ns / op
BenchmarkCreateSmallByPointer-4100.00015985 ns / op
BenchmarkCreateMediuMByValue-42000862317 ns / op
BenchmarkCreateMediuMByPointer-420001228130 ns / op
BenchmarkCreateLargeByValue-43047398456 ns / op
BenchmarkCreateLargeByPointer-42061928751 ns / op

Magia negra


Você sabe o que esse programa produzirá?

 package main type A struct { a, b int32 } func main() { a := new(A) aa = 0 ab = 1 z := (*(*int64)(unsafe.Pointer(a))) fmt.Println(z) } 

O resultado do programa depende da arquitetura em que é executado. No little endian, por exemplo, AMD64, o programa exibe 232. No big endian, um. O resultado é diferente, porque no little endian essa unidade aparece no meio do número e no big endian - no final.

Ainda existem processadores no mundo em que switches endian, por exemplo, Power PC. Será necessário descobrir qual endian está configurado no seu computador na inicialização, antes de fazer inferências sobre o que fazem truques inseguros desse tipo. Por exemplo, se você escrever um código Go que será executado em algum servidor multiprocessador IBM.

Eu citei este código para explicar por que considero toda a magia negra insegura. Você não precisa usá-lo. Mas Cyril acredita que é necessário. E aqui está o porquê.

Existe uma função que faz a mesma coisa que GOB - Go Binary Marshaller. Este é o codificador, mas não é seguro.

 func encodeMut(data []uint64) (res []byte) { sz := len(data) * 8 dh := (*header)(unsafe.Pointer(&data)) rh := &header{ data: dh.data, len: sz, cap: sz, } res = *(*[]byte)(unsafe.Pointer(&rh)) return } 

De fato, é preciso um pedaço de memória e extrai uma matriz de bytes dele.

Isso nem é um pedido - esses são dois pedidos. Portanto, Cyril Danshin, quando escreve um código de alto desempenho, não hesita em entrar no âmago de seu programa e torná-lo inseguro.

Referência gob-4200.0008466 ns / op120,94 MB / s
Referência: UnsafeMut-450.000.00037 ns / op27691.06 MB / s
Discutiremos recursos mais específicos do Go em 7 de outubro no GolangConf - uma conferência para aqueles que usam o Go no desenvolvimento profissional e aqueles que consideram esse idioma como uma alternativa. Daniil Podolsky é apenas um membro do Comitê de Programa, se você quiser discutir com este artigo ou revelar questões relacionadas - envie uma inscrição para um relatório.

Para todo o resto, em relação ao alto desempenho, é claro, o HighLoad ++ . Também aceitamos inscrições lá. Assine a newsletter e fique por dentro das novidades de todas as nossas conferências para desenvolvedores da web.

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


All Articles