Construindo um parser JSON de alto desempenho
(dave.cheney.net)- A apresentação de Dave Cheney na GopherCon Singapore 2023 aborda o processo de projetar um parser JSON em streaming em Go, mantendo uma API parecida com
encoding/json, mas com maior throughput e menos alocações - JSON não tem indicação de comprimento, então é preciso ler toda a entrada até o fim; o limite inferior de desempenho é no mínimo read(N)+parse(N), e as principais restrições são reduzir revisitas a bytes e tokens, cópias, alocações e chamadas de função no hot path
encoding/json.Decoder.Tokenretorna tokens comointerface{}, o que é conveniente, mas faz com que valores concretos escapem para o heap e criem alocações proporcionais ao número de tokens; até um único token"hello"gera 3 allocs/oppkg/jsonreduz o custo do hot path comNextToken, que retorna subslices de[]byteda entrada, uma janela deslizante embyteReader, inlining manual, chamadas diretas de métodos de estado e remoção de bounds checks- No fim,
pkg/json.Scannertokeniza sem alocação quando recebe um buffer,Decoder.Tokené de 2 a 3 vezes mais rápido queencoding/json.Decoder.Token, eDecoder.NextToken, com menos alocações, é de 8 a 10 vezes mais rápido
Objetivos e restrições básicas
- O objetivo é criar um parser JSON de alto desempenho como estudo de caso de design de pacotes em Go
- Há três metas de projeto
- oferecer processamento em streaming sem carregar toda a entrada na memória
- fornecer maior throughput e menos alocações, mantendo compatibilidade razoável com a API de alto nível
json.Decoderdeencoding/json - oferecer, além da API de
encoding/json, APIs mais eficientes com zero alocação ou com limite superior de alocação
- Se toda a entrada for bufferizada antes na memória, há risco de disponibilidade quando o tamanho da entrada é desconhecido ou infinito, além de aumentar a latência antes do processamento
- A leitura em streaming processa os dados assim que chegam e permite sobrepor leitura e processamento
Complexidade temporal do parsing de JSON
- JSON não tem marcador de comprimento, então só dá para saber quanto ler consumindo toda a entrada
- Para fazer o parsing do milésimo elemento de um array JSON, também é preciso ler e processar os 999 elementos anteriores, então não há como pular o processamento da entrada
- O limite inferior de desempenho é proporcional ao tamanho da entrada e, como não basta apenas ler, mas também passar pela máquina de estados do JSON para encontrar início e fim dos tokens, o mínimo é
read(N)+parse(N) - Os critérios para reduzir custo adicional são os seguintes
- se N bytes foram lidos, cada byte deve ser processado, se possível, apenas uma vez
- o mesmo token também deve ser processado apenas uma vez
- no hot path de
ScannerouDecoder, o número de chamadas de função deve ficar emO(tokens), não emO(bytes) - reduzir cópias para diminuir quantas vezes os mesmos bytes são revisitados
- reduzir alocações para diminuir custo de alocação no heap, acesso a estruturas compartilhadas, locks, contenção de cache e GC
Tokenização e design de API
- Um decodificador JSON se divide, em linhas gerais, em duas etapas
- um scanner ou tokenizador que transforma um fluxo de bytes em um fluxo de tokens JSON
- um unmarshaller que aplica o fluxo de tokens JSON a objetos Go
encoding/json.Decoder.Tokenretorna o token comointerface{}- strings são representadas como
string, números comofloat64, booleanos comobool,nullcomonile delimitadores comojson.Delim - essa abordagem é prática porque expressa ao mesmo tempo o valor e o tipo do token
- strings são representadas como
- Essa conveniência tem custo
- Brad Fitzpatrick chamou a API Token de garbage factory
- pelo design da API
Decoder.Token, o valor concreto alocado para cada token escapa para o heap - o número de alocações fica vinculado ao número de tokens da entrada
- No benchmark de um único token
"hello",encoding/jsonmostra 355ns/op, 19.7MB/s, 37.0B/op e 3.00 allocs/op - O design da API determina as alocações, e alocações podem impactar diretamente o desempenho
Tokens []byte e informação de tipo implícita
- O tipo de um token JSON pode ser identificado apenas pelo primeiro caractere
{,}: início e fim de objeto[,]: início e fim de arrayt: truef: falsen: null": string-,0~9: número
- A API
Decoder.NextTokendepkg/jsonnão converte o[]bytede entrada em valores Go; ela retorna diretamente da entrada uma subslice com os bytes que representam o token - O primeiro byte do
[]byteretornado informa o tipo do token - Essa API tem restrições
- a saída não é uma cópia, mas uma subslice da entrada, então tem validade limitada
- isso é parecido com a API de
bufio.Scanner - para lidar com o tipo do token ou com valores reais de string e número de maneira mais conveniente, é preciso uma abstração de nível superior
Leitura eficiente: byteReader
- A abordagem tradicional com
io.Reader.Readcopia dados do reader para um buffer, e essa cópia em si tem custo io.Reader.Readdelega ao chamador o gerenciamento do buffer- se a leitura for feita um byte por vez, pode ser necessário armazenar ou voltar para bytes já lidos
- a abordagem de ler em um buffer grande e então procurar o início e o fim do token exige muito gerenciamento, cópia e expansão de buffer quando o fim do token não está no buffer
- Como alternativa, é usado
byteReader, inspirado no iopipe de Steven Schveighoffer e em ideias de Phil Pearl byteReaderfornece uma janela deslizante sobreio.Reader; ele lembrabufio.Reader, mas oferece uma API mais eficientewindow()retorna a janela atual de dados ainda não lidosrelease(n)descarta os primeiros n bytes da janelaextend()lê mais dados do reader subjacente e amplia a janela
- O benchmark de busca por espaço em branco é a linha de base que visita cada caractere apenas para verificar se é whitespace, e mostra cerca de 2.04~2.07GB/s em várias entradas
- O código de exemplo do contador de whitespace está em github.com/davecheney/whitespace
Otimizações do scanner
Scanner.Nextpula whitespace intermediário, identifica o token pelo primeiro caractere da janela e então lê até o fim do token- O desempenho inicial de
Scanner.Nextfica em cerca de 1/4 a 2/5 da linha de base de whitespace- exemplo:
Scanner/canada510MB/s,citm_catalog677MB/s,sample837MB/s
- exemplo:
- A primeira otimização é trocar atualizações do campo
s.offsetpor uma variável localoffsets.offseté 0 na entrada e na saída da função, então alterações internas não são visíveis externamente- usar variável local evita que o compilador faça escritas temporárias em memória
citm_catalogcai de 2.52ms para 1.80ms, uma redução de 28.46%, esamplecai de 828µs para 528µs, redução de 36.24%
- O motivo de o efeito variar por entrada é a diferença na quantidade de whitespace
canadatem apenas 33 espaços em brancocitmtem 1,227,563
- A segunda otimização é fazer inlining manual de
Scanner.tokendentro deScanner.Next- por causa do
fore da complexidade das funções, o compilador Go não consegue inline automático deScanner.token,parseString,parseNumbereScanner.Next - como
Scanner.NexteScanner.tokensão chamados para cada token da entrada, isso impõe o custo de duas chamadas de função por token
- por causa do
- Após o inlining manual, o throughput melhora de 9% a 24%
canadasobe de 512MB/s para 642MB/s, aumento de 24.50%citm_catalogsobe de 960MB/s para 1105MB/s, aumento de 15.16%samplesobe de 1.33GB/s para 1.46GB/s, aumento de 9.11%
- O efeito das otimizações pode ser resumido em dois pontos
- reduzir a atualização de
s.offsetde uma vez por byte para uma vez por token - evitar chamadas de função no hot path pode melhorar o desempenho
- reduzir a atualização de
Validação e Decoder.NextToken
- O scanner por si só consegue dividir tokens, mas para um processamento JSON completo é necessária validação de estado
- JSON é uma máquina de estados, e os tokens que podem vir a seguir dependem do token atual
- por exemplo, depois de ler
{e"username", apenas:é válido
- por exemplo, depois de ler
Decoder.NextTokenadiciona lógica de estado sobreScanner.Nextpara verificar se a sequência de tokens é válida- Os estados incluem valor, string de chave de objeto, dois-pontos de objeto, valor de objeto, vírgula de objeto, valor de array, vírgula de array e estado final
- Mesmo na implementação inicial de validação,
pkg/jsonjá mostra resultado de 8 a 10 vezes mais rápido queencoding/jsoncanada:pkg/json399MB/s,encoding/json34.6MB/scitm_catalog:pkg/json713MB/s,encoding/json87.1MB/ssample:pkg/json1.23GB/s,encoding/json216MB/s
Otimização de transições de estado
- No centro de
Decoder.NextTokenhá umswitch - Um
switchcomum pode acabar sendo implementado como uma sequência deif, o que divide o fluxo de instruções e sobrecarrega o preditor de branches da CPU - Também seria possível usar uma tabela para encontrar o método de estado a partir do valor do estado, mas a implementação de exemplo não compila por causa do loop de inicialização
- Em vez disso, usa-se method expression do Go para armazenar diretamente em
d.stateo método, e não um valor enumerado de estadoDecoder.NextTokenfaz a chamada direta do método do estado atual comreturn d.state(d, tok)
- Só essa abordagem de computed goto não traz grande melhora de desempenho
- em algumas entradas quase nada muda, e em
twitter,codeeexamplehá pequena piora - em
sample, há ganho de 1.15%
- em algumas entradas quase nada muda, e em
- Essa mudança, porém, viabiliza a otimização seguinte: outlining
Outlining e remoção de bounds checks
- Após o outlining,
Decoder.NextTokenpassa a fazer apenasreturn d.state(d), e cada método de estado chamad.scanner.Next()diretamente - Como
tokdeixa de ser passado como argumento para o método de estado, a pilha de chamadas economiza 3 words - Com a verificação
len(tok) < 1e oswitch tok[0]na mesma função, torna-se possível a remoção de bounds checks- antes, a checagem de
len(tok)ficava emDecoder.NextToken, e o método de estado era chamado via method expression, sem inlining - por isso,
tok[0]dentro do método de estado ainda exigia bounds check - quando a checagem de tamanho ocorre na mesma função, o compilador consegue provar que
toktem comprimento mínimo 1
- antes, a checagem de
- O próprio
Decoder.NextTokentambém fica simples o bastante para ser inline- em vez de
dec.NextToken(), o chamador passa a enxergar, na prática, uma chamada direta ao método de estado atual - o custo da chamada de função é eliminado
- em vez de
Resultados finais de benchmark
- O
pkg/json.Scannerde nível mais baixo faz tokenização em streaming sem alocação quando recebe um buffer de alguns KBcanada: 638.78MB/s, 0 B/op, 0 allocs/opcitm_catalog: 1110.51MB/s, 0 B/op, 0 allocs/opsample: 1471.01MB/s, 0 B/op, 0 allocs/op
pkg/json.Decoder.Tokené de 2 a 3 vezes mais rápido queencoding/json.Decoder.Tokencanada: 101.98MB/s vs 33.19MB/scitm_catalog: 333.23MB/s vs 82.71MB/ssample: 788.59MB/s vs 209.12MB/s
pkg/json.Decoder.NextTokentem muito menos alocações e é de 8 a 10 vezes mais rápidocanada: 466.52MB/s, 136 B/op, 3 allocs/op vs 34.42MB/s, 17,740,399 B/op, 889,106 allocs/opcitm_catalog: 798.58MB/s, 136 B/op, 3 allocs/op vs 86.08MB/s, 5,661,597 B/op, 324,692 allocs/opsample: 1346.85MB/s, 1144 B/op, 9 allocs/op vs 217.44MB/s, 723,781 B/op, 26,095 allocs/op
- Na API de nível mais alto,
pkg/jsontambém consegue fazer unmarshal para objetos Go da mesma forma queencoding/jsoncanada: 82.08MB/s vs 58.70MB/scitm_catalog: 215.66MB/s vs 104.00MB/ssample: 615.99MB/s vs 128.04MB/s
- O link da apresentação está em dave.cheney.net/paste/gophercon-sg-2023.html, e o código está em github.com/pkg/json
Temas extraídos do design
-
Alocações afetam o desempenho
- ainda que o GC consiga alocar rápido e coletar com eficiência, não alocar continua sendo sempre mais rápido
- o design da API pode eliminar alocações
- a maior parte do ganho de velocidade deste pacote vem da redução de alocações
- o tempo não gasto no caminho de alocação no heap e nos ciclos de GC passa a ser usado no scanning
- a API
encoding/json.Decoderexige alocações porque retorna valores primitivos comointerface{} - os valores escapam para o heap e, na prática, viram ponteiros para os valores
- em processamento de dados, alocação pode ser o maior custo de desempenho do algoritmo
- reduzir com cuidado o custo por byte e o custo por token foi o segundo maior fator de melhora
- é importante trocar chamadas de função por byte por chamadas por token
- o ponto de partida foi a hipótese de que
encoding/jsonpode ser mais lento por causa da API; se for aceitável adotar outra API, é possível obter ganho de 2 a 3 vezes em alguns caminhos de unmarshal e de 8 a 10 vezes na tokenização
1 comentários
Comentários do Hacker News
Parece bem bom. Ao longo da minha carreira já criei parsers JSON até demais, mas é muito bom ter um material de referência que mostra passo a passo como projetar um parser JSON razoável e rápido
Dito isso, JSON não precisa necessariamente de um tokenizador explícito. Dá para eliminar o conceito de tokens e fundir completamente parsing e tokenização. Normalmente é assim que se faz, e o conjunto fica mais simples
Em linguagens como ECMAScript isso é muito mais difícil, porque há casos que parecem ser um subconjunto da sintaxe de expressões entre parênteses, como funções de seta, e só se confirmam dependendo de aparecer ou não um
=>, o que pode exigir lookahead arbitrariamente longoÉ um texto bom de acompanhar, e o fluxo de “se você for implementar, faça assim” está bem estruturado
Se a meta em produção for desempenho puro, também vale olhar o https://github.com/simdjson/simdjson do Daniel Lemire. Também existe o port para Go da MinIO, https://github.com/minio/simdjson-go
O que aprendi criando parsers JSON rápidos mistura muitas particularidades de cada linguagem, mas, generalizando, é isto
Na tokenização, é preciso evitar alocação no heap. É melhor que o tokenizador retorne uma struct alocada na stack, ou que seja uma função que retorne um token
int64empacotando posição inicial, tamanho, offset de tipo etc. do tokenNo parsing também é preciso evitar alocação no heap, e dá para oferecer interfaces como
getString(key String)para clientes que queiram fatiar o bufferAo desserializar para objetos cujos campos são conhecidos em tempo de compilação, normalmente se gera um
switchcom base no tamanho da chave antes de comparar valores de stringEm pipelines de dados que processam muito JSON, só a escolha da biblioteca JSON podia causar uma diferença de desempenho de 3 a 10 vezes, e os principais parsers em geral tentam alocar objetos
Quando as classes a serializar/desserializar são conhecidas em tempo de compilação, o Jackson do Java se sai bem, mas com código cuidadoso e profiling dá para extrair algo como mais 2x
Por outro lado, ao processar JSON arbitrário, os parsers populares tendem a fazer muitas alocações; um parser próprio, mais intrusivo, pode evitar isso, e o ganho de desempenho fica muito grande ao processar de milhares a milhões de objetos por segundo
Com uma abordagem parecida, criei um tokenizador e parser de GraphQL, e ele também não faz alocações de memória e é bem rápido. Se quiser ver o código, confira https://github.com/wundergraph/graphql-go-tools
Também fiz uma apresentação sobre esse tema, mas infelizmente não foi gravada. Quase enlouqueci tentando extrair o máximo possível em Go :D
Ou fico curioso se isso afeta de outras formas
No n2[1] eu precisava de um tokenizador rápido e enfrentei o mesmo problema de gerador de lixo. Basicamente, era a mistura entre um conjunto de tokens constantes como
json.Delime strings que causam alocaçãoUma solução que considero bem decente é tornar o tokenizador genérico sobre algum
Te receber uma função que transforma um slice de bytes emT, usandoTno lugar de stringAssim, quando o chamador tem uma representação mais eficiente, por exemplo uma com menos alocações, ele pode fornecê-la; ao mesmo tempo, em testes unitários dá para usar confortavelmente uma função identidade para testar o tokenizador
Em certo sentido, é parecido com fundir o tokenizador e o parser em tempo de build, mas, graças aos genéricos, o tokenizador pode manter a separação de camadas sem conhecer a representação do parser
[1] https://github.com/evmar/n2
É possível melhorar em relação à biblioteca padrão com um design de API melhor, mas, ao criar um parser totalmente streaming, é praticamente difícil não sair no meio depois de já ter preenchido parcialmente uma struct antes de descobrir um erro. A biblioteca padrão parece tratar isso também como uma restrição explícita de design
Posso ter deixado passar algo, mas o autor repete que criou um parser “streaming” sem explicar o que isso realmente significa
Em especial, não há explicação sobre como chaves repetidas foram tratadas na “tabela hash”. Se aparecer uma chave repetida, fico curioso se o código sink é chamado duas vezes ou se ele espera ler a “tabela hash” inteira para então chamar o código sink
Na minha opinião, JSON é hierárquico, não se sabe o tamanho de antemão e, acima de tudo, há chaves repetidas, então ele é intrinsecamente inadequado para streaming
Dá para tornar alguns subconjuntos de JSON mais amigáveis a streaming, mas, se for para isso, não vejo motivo para consertar JSON. Se a solução é mudar JSON, acho melhor usar logo outro formato que não seja JSON
Fico feliz em ver Phil Pearl mencionado
https://github.com/bytedance/sonic também vale uma olhada
É surpreendente que não exista uma forma de dizer “faça mesmo inline desta função” para uma função grande demais para ser inlineada
Operações básicas de contar e procurar caracteres de espaço em branco parecem poder ficar muito mais rápidas se forem vetorizadas com SIMD, embora eu entenda que isso esteja fora do escopo do autor
A afirmação “é irrealista esperar que seja possível manter toda a entrada em memória” está errada para a maioria das aplicações
Dá para dizer que Go não é a ferramenta certa para esse trabalho, mas, com otimizações desse tipo, não vejo por que não poderia ser
Já empurrei dados JSON na casa dos gigabytes, então sou grato por parsers streaming. Além disso, dar suporte a streaming também é um sinal de que o autor conhece vários casos de uso e faz uma engenharia melhor
Memória só é barata e quase grátis na teoria; na prática, não é assim
mmap()também não seria uma opção possível? Casos em que streaming de verdade é necessário, por exemplo quando é preciso processar cedo dados iniciais em um único arquivo JSON, como um stream de transações ou tarefas, são outra questão