
O código mostrado abaixo é equivalente em desempenho?
// (A). HasPrefix . return strings.HasPrefix(s, "#") // (B). HasPrefix. return len(s) >= len("#") && s[:len("#")] == "#" A resposta é não .
Para detalhes e explicações, pergunto em cat.
Bom dia, antes de abrir o tópico, eu gostaria de me apresentar.
Meu nome é Iskander e, de tempos em tempos, eu envio confirmações para o repositório golang / go .

Eu costumava fazer isso em nome da equipe Intel Go , mas nossos caminhos divergiam e agora eu sou um colaborador independente. Recentemente, tenho trabalhado em vk na equipe de infraestrutura.
Nos meus tempos livres, faço ferramentas diferentes para o Go, como go-critical e go-consistente . Eu também desenho esquilos .
Meça!
Prossiga imediatamente para a comparação e defina a referência:
package benchmark import ( "strings" "testing" ) var s = "#string" func BenchmarkHasPrefixCall(b *testing.B) { for i := 0; i < bN; i++ { _ = strings.HasPrefix(s, "#") _ = strings.HasPrefix(s, "x") } } func BenchmarkHasPrefixInlined(b *testing.B) { for i := 0; i < bN; i++ { _ = len(s) >= len("#") && s[:len("#")] == "#" _ = len(s) >= len("x") && s[:len("x")] == "x" } } Em vez de recomendar benchstat para você, mostrarei a você o benchrun .
Com um comando, podemos executar os dois benchmarks e obter uma comparação:
go-benchrun HasPrefixCall HasPrefixInlined -v -count=10 . Benchstat results: name old time/op new time/op delta HasPrefixCall-8 9.15ns ± 1% 0.36ns ± 3% -96.09% (p=0.000 n=10+9) A opção com incorporação manual é muito mais rápida que o código que foi obtido incorporando o corpo da função ao compilador. Vamos tentar descobrir por que isso está acontecendo.
strings.HasPrefix
Lembre-se da implementação de strings.HasPrefix :
// HasPrefix tests whether the string s begins with prefix. func HasPrefix(s, prefix string) bool { return len(s) >= len(prefix) && s[0:len(prefix)] == prefix } A função HasPrefix incorporada pelo compilador.
Você pode verificar isso da seguinte maneira:
go build -gcflags='-m=2' strings 2>&1 | grep 'can inline HasPrefix' Para chamar strings.HasPrefix da opção (A) , obtemos o seguinte código de máquina:
MOVQ (TLS), CX CMPQ SP, 16(CX) JLS more_stack fn_body: SUBQ $40, SP MOVQ BP, 32(SP) LEAQ 32(SP), BP XCHGL AX, AX MOVQ s+56(SP), AX CMPQ AX, $1 JGE compare_strings XORL AX, AX MOVB AL, ~ret1+64(SP) MOVQ 32(SP), BP ADDQ $40, SP return: RET compare_strings: MOVQ s+48(SP), AX MOVQ AX, (SP) LEAQ go.string."#"(SB), AX MOVQ AX, 8(SP) MOVQ $1, 16(SP) CALL runtime.memequal(SB) MOVBLZX 24(SP), AX JMP return more_stack: CALL runtime.morestack_noctxt(SB) JMP fn_body Ignore o fato de que o código se parece com macarrão.
O que você deve prestar atenção:
strings.HasPrefixrealmente foi inserido, nenhuma chamada.- Para comparar seqüências de caracteres,
runtime.memequaléruntime.memequal.
Mas o que então é gerado para a versão incorporada manualmente, o código do exemplo (B) ?
MOVQ s+16(SP), AX CMPQ AX, $1 JLT different_length MOVQ s+8(SP), AX CMPB (AX), $35 // 35 - "#" SETEQ AL return: MOVB AL, "".~ret1+24(SP) RET different_length: XORL AX, AX JMP 22 E aqui o compilador não gera uma chamada para runtime.memequal e um único caractere é comparado diretamente. Idealmente, ele deveria ter feito o mesmo para a primeira opção.
Observamos o lado fraco do otimizador Go e vamos analisá-lo.
Otimização de Expressão Constante
A razão pela qual chamar strings.HasPrefix(s, "#") pode ser otimizada é porque o argumento prefix é uma constante. Conhecemos seu comprimento e conteúdo. Não faz sentido chamar runtime.memequal para sequências curtas, é mais rápido fazer uma comparação de caracteres "no local", evitando uma chamada extra.
Como você sabe, os compiladores geralmente têm pelo menos duas partes: frontend e backend do compilador . O primeiro funciona com uma visualização de nível superior, o segundo está mais próximo da máquina e a visualização intermediária parecerá um fluxo de instruções. Várias versões do Go já usaram a representação SSA para otimizações na parte de back-end do compilador.
Dobra constante, como {10*2 => 20} , é implementada no back-end. Em geral, a maioria das operações associadas à redução do custo computacional das expressões estão localizadas nesta parte do compilador. Mas há exceções.
Uma exceção é a otimização de comparações constantes de strings. Quando o compilador vê uma comparação de cadeia (ou substring) na qual um ou ambos os operandos são constantes, um código mais eficiente é gerado do que uma chamada para runtime.memequal .
Você pode ver o código fonte responsável por isso no arquivo cmd / compile / internal / gc / walk.go: 3362 .
A incorporação de funções ocorre antes do lançamento dessas otimizações, mas também na parte frontal do compilador.
Parece que tudo a mesma coisa não permite que essa otimização funcione no nosso caso?
Como vai incorporar chamadas de função
Veja como a incorporação ocorrerá:
// : return strings.HasPrefix(s, "#") // : func HasPrefix(s, prefix string) bool // : _s, _prefix := s, "#" return len(s) >= len(prefix) && s[:len(prefix)] == prefix Ao incorporar funções, o compilador atribui argumentos a variáveis temporárias, o que interrompe as otimizações, pois o algoritmo em walk.go não vê constantes, mas argumentos com variáveis. Esse é o problema.
A propósito, isso não interfere nas otimizações de back-end que o SSA tem à sua disposição. Mas existem outros problemas, por exemplo, a incapacidade de restaurar construções de linguagem de alto nível para sua comparação efetiva (o trabalho para eliminar essa desvantagem foi “planejado” por vários anos).
Outro exemplo: análise de escape
Imagine uma função importante para alocar um buffer temporário na pilha:
func businessLogic() error { buf := make([]byte, 0, 16) // buf // . return nil } Como o buf não "foge", o compilador poderá alocar esses 16 bytes na pilha, sem alocação no heap. Novamente, tudo graças ao valor constante ao chamar make . Para alocar memória na pilha, é importante sabermos o tamanho necessário, que fará parte do quadro alocado para a chamada de função.
Suponha que no futuro desejássemos alocar buffers temporários de tamanhos diferentes e encapsular alguma lógica nos métodos. Introduzimos uma nova abstração e decidimos usar o novo tipo tmpBuf . A função de design é extremamente simples:
func newTmpBuf(sizeHint int) tmpBuf { return tmpBuf{buf: make([]byte, 0, sizeHint)} } Adaptando o exemplo original:
func businessLogic() error { - buf := make([]byte, 0, 16) + buf := newTmpBuf(16) // buf // . return nil } O construtor será incorporado, mas a alocação agora estará sempre no heap, pelo mesmo motivo que os argumentos são passados por variáveis temporárias. A análise de escape verá make([]byte, 0, _sizeHint) que não se enquadra em seus padrões de reconhecimento para make chamadas otimizadas.
Se tivéssemos “tudo é como seres humanos”, o problema não existiria, após a incorporação do newTmpBuf construtor newTmpBuf seria claro que o tamanho ainda é conhecido no estágio de compilação.
Isso incomoda quase mais do que a situação com a comparação de strings.
Horizons Go 1.13
A situação pode ser facilmente corrigida e eu já enviei a primeira parte da decisão .

Se você acha que o problema descrito no artigo realmente precisa de uma solução, dê um joinha na edição correspondente .
Minha posição é que incorporar o código com as mãos apenas porque ele funciona mais rápido na versão atual do Go está errado. É necessário corrigir esse defeito no otimizador, pelo menos até o ponto em que os exemplos descritos acima funcionam sem regressões inesperadas de desempenho.
Se tudo correr conforme o planejado, essa otimização será incluída na versão Go 1.13.
Obrigado pela atenção.
Adição: solução proposta
Esta seção é para os mais corajosos, aqueles que não estão cansados de ler.
Portanto, temos vários locais que funcionam pior ao usar variáveis em vez de seus valores diretamente. A solução proposta é introduzir uma nova função no frontend da parte do compilador, que permite obter o último valor vinculado pelo nome. Depois disso, em cada otimização que espera um valor constante, não desista quando uma variável for detectada, mas receba esse estado salvo anteriormente.
A assinatura do nosso novo recurso pode ser assim:
func getConstValue(n *Node) *Node A definição de Node pode ser encontrada no arquivo syntax.go .
Cada definição de variável possui uma tag Node com uma tag ONAME . Dentro do Node.Name.Defn maioria dessas variáveis possui um valor de inicialização.
Se o Node já Node um literal, você não precisa fazer nada e apenas retornamos n . Se for ONAME (variável), você pode tentar extrair o mesmo valor de inicialização de n.Name.Defn .
Mas e as modificações entre declarar e ler uma variável para a qual chamamos getConstValue ? Se nos restringirmos a variáveis somente leitura, não haverá problema. O front-end do Go já possui sinalizadores de nó especiais que marcam nomes semelhantes. Se a variável foi modificada, getConstValue não retornará um valor de inicialização.
Os programadores, como regra, não modificam os argumentos de entrada dos tipos numéricos e de seqüência de caracteres, e isso permite cobrir um número bastante grande de casos com esse algoritmo primitivo.
Agora estamos prontos para considerar a implementação:
func getConstValue(n *Node) *Node { // ONAME definition. if n.Op != ONAME || n.Name.Defn == nil { return n } // , . // , , // escape analysis' . maybeModified := n.Assigned() || n.Name.Defn.Assigned() || n.Addrtaken() if maybeModified { return n } // OAS - Node . // n.Name.Defn.Left - LHS. // n.Name.Defn.Right - RHS. // consttype(v) . // CTxxx, . if n.Name.Defn.Op == OAS { v := n.Name.Defn.Right if v != nil && consttype(v) != CTxxx { return v } } return n } Veja como o código é alterado, dependendo das constantes:
- i := indexconst(r) + i := indexconst(getConstValue(r)) Ótimo, e até funciona:
n := 10 xs := make([]int, n) // ! Antes dessa alteração, a análise de escape não conseguia obter o valor de 10 a n , e foi por isso que assumi a necessidade de colocar xs no heap.
O código acima é sintaticamente semelhante à situação observada durante a incorporação. n pode ser uma variável temporária que é adicionada quando o argumento é passado.
Infelizmente, existem nuances.
Resolvemos o problema para variáveis locais introduzidas através da OEA , mas o Go inicializa as variáveis para funções internas através da OEA2 . Por isso, precisamos de uma segunda alteração que estenda a função getConstValue e modifique levemente o código do próprio inliner, porque, entre outras coisas, o OAS2 não possui um campo Defn adequado.
Foram más notícias. Boas notícias: o canal #gocontributing apareceu na folga no idioma russo , onde você pode compartilhar suas idéias e planos, fazer perguntas e discutir tudo relacionado à participação no desenvolvimento do Go.