
Hoje vamos acelerar a ligação de linhas curtas em Go em 30%. E, para isso, não precisaremos modificar o Go, tudo isso será implementado como uma biblioteca de terceiros .
Abaixo do corte que você está esperando:
- Comparando as funções +,strings.Buildere concatenação nativa
- Ir para detalhes da linha interna
- Bastante montador
Este artigo também pode ser considerado uma desculpa para discutir CL123256: tempo de execução, cmd / compile: specialize concatstring2 . Idéias para melhorar essa lista de alterações são bem-vindas.
Resultados Imediatamente
A comparação foi feita com a versão go tip (master) do compilador. Você pode obter resultados semelhantes nas versões do Go 1.5. A última alteração significativa na função concatstrings foi CL3120: cmd / gc: aloque buffers para seqüências de caracteres não escapadas na pilha .
 BenchmarkConcat2Operator-8 20000000 83.8 ns/op BenchmarkConcat2Builder-8 20000000 70.9 ns/op BenchmarkConcat2-8 20000000 62.1 ns/op BenchmarkConcat3Operator-8 20000000 104 ns/op BenchmarkConcat3Builder-8 20000000 89.9 ns/op BenchmarkConcat3-8 20000000 82.1 ns/op 
ConcatOperator usa + .
ConcatBuilder usa strings.Builder com a pré-alocação correta.
Concat usa a função que implementamos como parte desta história.
Comparação via benchstat :
 name old time/op new time/op delta Concat2-8 84.2ns ± 1% 62.7ns ± 2% -25.49% (p=0.000 n=9+10) Concat3-8 103ns ± 3% 83ns ± 4% -19.83% (p=0.000 n=10+9) 
A implementação do assembler em GOARCH=AMD64 um pouco mais rápida e possui otimização adicional, que está presente no operador + incorporado, mas mais sobre isso abaixo:
 name old time/op new time/op delta Concat2-8 84.2ns ± 1% 57.1ns ± 3% -32.20% (p=0.000 n=9+9) 
Assumiremos a função assembler como 100% de desempenho (em relação ao restante das implementações consideradas).
Os resultados para linhas mais longas podem ser vistos em README.md . Quanto maior a string, menos pronunciada é a diferença entre implementações.
Concatenação ingênua
A solução mais fácil é usar o operador + .
A semântica desta instrução é a seguinte: pegue duas linhas e retorne uma sequência de resultados que contenha a concatenação de ambas as linhas. Não há garantia de que uma nova linha será retornada. Por exemplo, se houver uma concatenação de uma cadeia vazia e de qualquer outra, o tempo de execução poderá retornar um argumento não vazio, evitando a necessidade de alocar nova memória e copiar dados nela.
Mas, como pode ser visto nos resultados no início do artigo, esse é o caminho mais lento.
 func concat2operator(x, y string) string { return x + y } 
Avaliação de desempenho: 67,8% .
strings.Builder
Não faz muito tempo, um novo tipo foi adicionado ao Go - strings.Builder . Este é um análogo de bytes.Buffer , mas ao chamar o método String() , a memória não é realocada e os dados não são copiados.
Diferentemente de bytes.Buffer , o construtor não possui otimização de um buffer pequeno e, portanto, alocou memória anteriormente para uma sequência. Se você não usar o método Grow , o desempenho será pior do que com bytes.Buffer . Várias regressões no Go 1.11 são causadas por esse recurso específico (consulte CL113235 ).
Em nosso código, pela pureza do experimento, evitaremos esse erro.
 func concat2builder(x, y string) string { var builder strings.Builder builder.Grow(len(x) + len(y))  
Avaliação de desempenho: 80,5% (+12,7).
Geração de código para concatenação
Se você olhar para qual código o compilador gera para o operador + , veremos as chamadas para as concatstring3 , concatstring3 e assim por diante (até concatstring5 inclusive).
 func concat2codegen(x, y) string { return x + y }  
Dê uma olhada no próprio runtime / string.go :
 func concatstring2(buf *tmpBuf, a [2]string) string { return concatstrings(buf, a[:]) } func concatstring3(buf *tmpBuf, a [3]string) string { return concatstrings(buf, a[:]) } 
Portanto, resta aprender as funções concatstrings .
Uma lista completa está disponível abaixo do spoiler, mas aqui está uma descrição de alto nível:
- O parâmetro bufpode sernil. Esse buffer é alocado pelo compilador se a linha não "escapar" de sua definição. Se a string viver mais do que o quadro, esse buffer será semprenil(como acontece com mais freqüência). No entanto, se esse buffer estiver disponível, será possível evitar a alocação caso o resultado seja invadido (seu tamanho é 32 bytes).
- Se todas as linhas, exceto uma, estiverem vazias, a função retornará essa linha. Mas, ao mesmo tempo, as linhas selecionadas na pilha e deixando seu quadro ignoram essa otimização para que o chamador não receba memória já liberada.
- Além disso, todas as linhas são copiadas para a nova memória.
Lista completa da função concatstrings Aqui vemos vários lugares ao mesmo tempo que podem ser otimizados para um caso específico:
- bufgeralmente está vazio. Quando o compilador não pôde provar que a string é segura para colocar na pilha, passar um parâmetro extra e verificar se há- nildentro da função gera apenas sobrecarga.
- Para o caso especial com len(a) == 2não precisamos de um ciclo e os cálculos podem ser simplificados. E esta é a forma mais comum de concatenação.
Estatísticas de concatenaçãoAo executar ./make.bash ( ./make.bash compilador Go e stdlib), vemos 445 concatenações com dois operandos:
- 398 resultados estão fugindo. Nesse caso, nossa especialização faz sentido.
- 47 resultados não saem do seu quadro.
No total, 89% das concatenações de dois argumentos obtêm otimização do suor.
Para o utilitário go , temos:
- 501 chamadas concatstring2
- 194 chamadas concatstring3
- 55 chamadas concatstring4
 Versão para todas as arquiteturas
Para implementar a especialização, precisamos saber como as linhas são representadas no Go. A compatibilidade binária é importante para nós, enquanto unsafe.Pointer . O unsafe.Pointer pode ser substituído por *byte sem sacrifícios.
 type stringStruct struct { str *byte len int } 
A segunda conclusão importante que podemos tirar do tempo de execução: as linhas começam sua vida mutável. É alocada uma parte da memória que é referenciada por []byte , na qual o conteúdo da nova linha é gravada e somente depois que o []byte descartado, e a memória à qual ela se refere é armazenada em stringStruct .
Para quem deseja obter mais detalhes, sugere-se estudar as funções de rawstringtmp e rawstring .
Podemos iniciar o mesmo, usando o lado escuro do pacote unsafe :
 func concat2(x, y string) string { length := len(x) + len(y) if length == 0 { return "" } b := make([]byte, length) copy(b, x) copy(b[len(x):], y) return goString(&b[0], length) } 
Destacamos []byte , que usamos para formar o conteúdo de uma nova linha. Só podemos finalizar a linha trazendo-a para a representação de tempo de execução esperada. A função goString é responsável por isso:
 func goString(ptr *byte, length int) string { s := stringStruct{str: ptr, len: length} return *(*string)(unsafe.Pointer(&s)) } 
Avaliação de desempenho: 91.9% (+10.9).
Versão AMD64
Infelizmente, a versão anterior da função não possui otimização para concatenação com uma string vazia, e também realizamos vários cálculos desnecessários devido à incapacidade de alocar memória diretamente, precisamos trabalhar com a fatia de bytes.
Um dos recursos interessantes do montador Go é que ele permite chamar, por exemplo, funções de tempo de execução não exportáveis. Podemos chamar o runtime·mallocgc partir do código do assembly, mesmo que não faça parte do pacote de runtime de runtime . Usaremos essa propriedade.
Também podemos verificar a propriedade das linhas de memória da pilha, o que torna seguro otimizar o retorno de um dos argumentos como resultado.
Suponha que uma função seja chamada com argumentos concat2("", "123") . x é uma string vazia e, se y não y alocado na pilha, podemos devolvê-la como resultado da concatenação.
 //; ,  x  y   stringStruct. //; CX - y.str. //; SI - y.len. maybe_return_y: //;      . MOVQ (TLS), AX //; *g CMPQ CX, (AX) JL return_y //;  y_str < g.stack.lo CMPQ CX, 8(AX) JGE return_y //;  y_str >= g.stack.hi JMP concatenate //; y  ,    return_y: MOVQ CX, ret+32(FP) //; stringStruct.len MOVQ SI, ret+40(FP) //; stringStruct.str RET 
MOVQ (TLS), AX moverá * g para o registro do AX . A leitura no deslocamento zero dará o campo g.stack.lo e g.stack.hi começa com o 8º byte (para uma plataforma de 64 bits).
 type g struct { stack struct { lo uintptr  
O corpo concatenate aloca memória, preenche-a com as duas linhas e retorna uma nova linha.
Lista completa com comentários #include  #include  TEXT ·Strings(SB), 0, $48-48 NO_LOCAL_POINTERS //    . MOVQ x+0(FP), DX MOVQ x+8(FP), DI MOVQ y+16(FP), CX MOVQ y+24(FP), SI TESTQ DI, DI JZ maybe_return_y // x -  ,   y TESTQ SI, SI JZ maybe_return_x // y -  ,   x concatenate: LEAQ (DI)(SI*1), R8 // len(x) + len(y) //     . MOVQ R8, 0(SP) MOVQ $0, 8(SP) MOVB $0, 16(SP) CALL runtime·mallocgc(SB) MOVQ 24(SP), AX //     MOVQ AX, newstr-8(SP) //  x. MOVQ x+0(FP), DX MOVQ x+8(FP), DI MOVQ AX, 0(SP) MOVQ DX, 8(SP) MOVQ DI, 16(SP) CALL runtime·memmove(SB) //  y   len(x). MOVQ x+8(FP), DI MOVQ y+16(FP), CX MOVQ y+24(FP), SI MOVQ newstr-8(SP), AX LEAQ (AX)(DI*1), BX MOVQ BX, 0(SP) MOVQ CX, 8(SP) MOVQ SI, 16(SP) CALL runtime·memmove(SB) //   . MOVQ newstr-8(SP), AX MOVQ x+8(FP), R8 ADDQ y+24(FP), R8 MOVQ AX, ret+32(FP) MOVQ R8, ret+40(FP) RET maybe_return_y: //      . MOVQ (TLS), AX // *g CMPQ CX, (AX) JL return_y //  y_ptr < stk.lo CMPQ CX, 8(AX) JGE return_y //  y_ptr >= stk.hi JMP concatenate // y  ,    return_y: MOVQ CX, ret+32(FP) MOVQ SI, ret+40(FP) RET maybe_return_x: //      . MOVQ (TLS), AX // *g CMPQ DX, (AX) JL return_x //  x_ptr < stk.lo CMPQ DX, 8(AX) JGE return_x //  x_ptr >= stk.hi JMP concatenate // x  ,    return_x: MOVQ DX, ret+32(FP) MOVQ DI, ret+40(FP) RET 
Se você estiver interessado na natureza de NO_LOCAL_POINTERS neste código, poderá ler a função Calling a Go de asm ("erro fatal: falta de mapa de pilha") .
 Avaliação de desempenho: 100% (+8,6).
Em conclusão
Todo o código é fornecido como um pacote concat .
O mundo está pronto para uma concatenação tão rápida? Quem sabe
No início do artigo, CL123256 foi mencionado. Ele tem vários caminhos de desenvolvimento:
- Especialização variacional para o caso em que o compilador não aloca um buffer temporário. Há menos crescimento para cada caso, mas abrange mais tipos de concatenação e praticamente não aumenta o tamanho do código (máquina e código Go).
- Mais especializações para casos especiais. Ganhos mais altos, mas mais código de máquina, podem prejudicar o cache de instruções.
- Toneladas de código de máquina, para cada caso especial e memorando especializado, da maneira como isso é feito na glibc. Aqui surgem principalmente questões de conveniência.
A atual opção proposta acelera apenas o caso mais comum e mais simples de concatenação de um par de strings (arity = 2).
Se o Go não aceitar essa alteração, poderá obter uma aceleração comparável implementando operações de cadeia de caracteres na forma de uma biblioteca de terceiros. Menos conveniente, bonito e elegante, mas funciona.