Home Artigos Processamento de XML por streaming em Go

Processamento de XML por streaming em Go

0
0
55

Processar dados em XML estava em alta há 15 anos atrás; embora seja menos proeminente hoje em dia, ainda pode ser uma tarefa importante em alguns tipos de aplicativos. Neste post, vamos comparar a velocidade de processamento de grandes arquivos XML em Go, Python e C e terminar com um novo módulo, bem reduzido, que usa C para tornar a tarefa mais rápida em Go. Todo o código mostrado nesta postagem está disponível neste repositório do Github, o novo módulo Go está aqui.

O que significa processamento de XML por stream?

Primeiro, vamos definir o problema em questão em mais detalhes. A grosso modo, existem duas maneiras de processar dados de um arquivo:

  1. Lendo todo o arquivo e carregando na memória de uma só vez e, em seguida, processe os dados na memória.
  2. Lendo o arquivo por partes, processando cada parte sem ter tudo carregado na memória.

De muitas maneiras, (1) é mais conveniente porque podemos facilmente voltar a qualquer parte do arquivo. No entanto, em algumas situações (2) é essencial; especificamente, quando o arquivo é muito grande. É aqui que entra o processamento por stream. Se um arquivo de entrada for, por exemplo, de 500 GB, dificilmente poderemos lê-lo na memória e processá-lo em partes. Mesmo para arquivos menores que teoricamente caberiam na RAM, nem sempre é uma boa ideia lê-los completamente; Isso aumenta drasticamente o tamanho do heap ativo e pode causar problemas de desempenho em linguagens com garbage-collector.

A Tarefa

Para este benchmark, estou usando o xmlgen para criar um arquivo XML de 230 MiB [1]. Um pequeno fragmento do arquivo seria assim:

<?xml version="1.0" standalone="yes"?>
<site>
  <regions>
    <asia>
      <item id="item0">
        <location>United States</location>
        <quantity>1</quantity>
        <name>duteous nine eighteen </name>
        <payment>Creditcard</payment>
        ...
      </item>
    </asia>
  </regions>
</site>

A tarefa é descobrir quantas vezes “África” aparece nos dados da tag
<location> aparece em todo o documento.

Usando a biblioteca padrão Go

Vamos começar com uma implementação base – usando o package encoding/xml da biblioteca padrão. Embora o modo Unmarshal do pacote irá fazer o parse do arquivo inteiro de uma só vez, ele também pode ser usado para processar o XML tag por tag e fazer o parse seletivamente dos elementos que nos interessam:

package main

import (
  "encoding/xml"
  "fmt"
  "io"
  "log"
  "os"
  "strings"
)

type location struct {
  Data string `xml:",chardata"`
}

func main() {
  f, err := os.Open(os.Args[1])
  if err != nil {
    log.Fatal(err)
  }
  defer f.Close()

  d := xml.NewDecoder(f)
  count := 0
  for {
    tok, err := d.Token()
    if tok == nil || err == io.EOF {
      // EOF means we're done.
      break
    } else if err != nil {
      log.Fatalf("Error decoding token: %s", err)
    }

    switch ty := tok.(type) {
    case xml.StartElement:
      if ty.Name.Local == "location" {
        // If this is a start element named "location", parse this element
        // fully.
        var loc location
        if err = d.DecodeElement(&loc, &ty); err != nil {
          log.Fatalf("Error decoding item: %s", err)
        }
        if strings.Contains(loc.Data, "Africa") {
          count++
        }
      }
    default:
    }
  }

  fmt.Println("count =", count)
}

Me certifiquei de confirmar que o uso de memória deste programa permanece limitado e baixo durante o processamento de um arquivo grande – o máximo de RSS esteve abaixo de 7 MB durante o processamento do nosso input de 230 Mb. Verifiquei para todos os programas mostrados neste post usando /usr/bin/time -v no Linux.

Este programa leva 6,24 segundos para processar todo o arquivo e printar o resultado.

Implementação em Python

A primeira implementação em Python usa o módulo xml.etree.ElementTree da biblioteca padrão:

import sys
import xml.etree.ElementTree as ET

count = 0
for event, elem in ET.iterparse(sys.argv[1], events=("end",)):
    if event == "end":
        if elem.tag == 'location' and elem.text and 'Africa' in elem.text:
            count += 1
        elem.clear()

print('count =', count)

A chave aqui é a chamada elem.clear (). Ele garante que cada elemento seja descartado após o parse completo, de forma que o uso de memória não cresça linearmente com o tamanho do arquivo (a menos que o arquivo seja problemático). Este programa leva 3,7 segundos para processar o arquivo inteiro – muito mais rápido que o nosso programa em Go. Por que?

Enquanto o programa em Go usa 100% de código Go para a tarefa (encoding/xml é implementado inteiramente em Go), o programa Python está usando uma extensão em C (a maior parte do ElementTree é escrita em C) envolvendo um parser XML rápido em C – libexpat. A maior parte do trabalho aqui é feita em C, que é mais rápido que Go. O desempenho da encoding/xml é discutido mais adiante nesta edição, embora esse seja mais antigo e o desempenho tenha sido um pouco otimizado desde então.

Uma biblioteca para parse de XML alternativa em Python é lxml, que roda sobre o libxml. Veja uma versão em Python usando o lxml:

import sys
from lxml import etree

count = 0
for event, elem in etree.iterparse(sys.argv[1], events=("end",)):
    if event == "end":
        if elem.tag == 'location' and elem.text and 'Africa' in elem.text:
            count += 1
        elem.clear()

print('count =', count)

Se parece muito semelhante à versão anterior, e realmente é O lxml tem uma API compatível com o etree para fazer uma transição da biblioteca padrão mais suavemente. Esta versão também leva cerca de 3,7 segundos para o nosso arquivo de 230 Mb.

A razão pela qual eu estou incluindo o lxml aqui é que ele irá rodar mais rápido que o xml.etree.ElementTree ao processar o arquivo inteiro, para nosso tamanho de arquivo específico. Deixo claro que isso está fora do escopo do experimento, porque só nos interessa o processamento por streaming. A única maneira (que eu saiba) de processar com êxito um arquivo de 500 Gb com lxml seria usando iterparse.

Quão rápido pode ser executado?

Com base nas medições apresentadas aqui, em Go é cerca de 68% mais lento que em Python para fazer o parse de um arquivo XML grande por streaming. Enquanto o Go geralmente compila um código muito mais rápido que  Python puro, as implementações do Python têm suporte de bibliotecas C eficientes com as quais é difícil competir. Eu estava curioso para saber o quão rápido poderia ser, em teoria [2].

Para responder a esta questão, eu implementei o mesmo programa usando C puro com libxml, que tem uma API SAX. Eu não vou colar completamente aqui porque é bem longa, mas você pode encontrar o código-fonte completo no Github. Leva apenas 0,56 segundos para processar nosso arquivo de entrada de 230 Mb, o que é impressionante, dado os outros resultados, mas também não é muito surpreendente. É C, afinal.

Você pode se perguntar – se o lxml usa o libxml por baixo, por que ele é muito mais lento que a versão C? A resposta é a sobrecarga de chamadas do Python. A versão lxml chama de volta para o Python para cada elemento analisado, o que gera um custo significativo [3]. Outra razão é que a minha implementação em C não analisa realmente um elemento – é apenas uma simples máquina de estado baseada em eventos, então há menos trabalho extra sendo feito.

Usando libxml do Go

Para recapitular onde estamos até agora:

  1. Bibliotecas Python baseadas em C são mais rápidas que Go puro.
  2. C puro é ainda muito mais rápido.

Temos duas opções: podemos tentar otimizar o pacote encoding/xml do Go, ou podemos tentar encapsular uma biblioteca em C mais rápida com Go. Enquanto o primeiro é um objetivo válido, envolve grande esforço e deve ser um tópico para outro post. Então vamos optar pela última opção.

Pesquisando na web, encontrei alguns wrappers em torno da libxml. Dois que parecem razoavelmente populares e mantidos são https://github.com/lestrrat-go/libxml2 e https://github.com/moovweb/gokogiri. Infelizmente, nenhuma destas (ou as outros links que encontrei) estão expondo a API SAX da libxml; em vez disso, eles se concentram na API do DOM, onde é feito o parse de todo o documento pela biblioteca subjacente e uma tree é retornada. Como mencionado acima, precisamos que a interface SAX processe arquivos enormes.

gosax

É hora de criarmos nosso próprio! Eu escrevi o módulo gosax, que usa o Cgo para chamar a libxml e expõe uma interface SAX [4]. Implementá-lo foi um exercício interessante no Cgo, porque requer alguns conceitos não-triviais como registrar retornos de chamada do Go com C.

Aqui está uma versão do nosso programa usando o gosax:

package main

import (
  "fmt"
  "os"
  "strings"

  "github.com/eliben/gosax"
)

func main() {
  counter := 0
  inLocation := false

  scb := gosax.SaxCallbacks{
    StartElement: func(name string, attrs []string) {
      if name == "location" {
        inLocation = true
      } else {
        inLocation = false
      }
    },

    EndElement: func(name string) {
      inLocation = false
    },

    Characters: func(contents string) {
      if inLocation && strings.Contains(contents, "Africa") {
        counter++
      }
    },
  }

  err := gosax.ParseFile(os.Args[1], scb)
  if err != nil {
    panic(err)
  }

  fmt.Println("counter =", counter)
}

Como você pode ver, ele implementa uma máquina de estado que se lembra de estar dentro de um elemento <location>, onde os dados são verificados. Este programa leva 4,03 segundos para processar nosso arquivo de entrada. Não é ruim! Mas podemos fazer um pouco melhor e, com algumas otimizações, consegui reduzir para 3,68 segundos – aproximadamente a mesma velocidade das implementações em Python!

Os tempos de execução mais ou menos semelhantes são uma coincidência, porque os programas em Python são diferentes da minha abordagem, pois expõem uma API de nível mais alto do que o SAX puro. Lembre-se de que iterparse retorna o parse de um elemento e podemos acessar seu atributo de texto, etc. No gosax, temos que fazer isso muito mais manualmente. Como o custo das chamadas entre o Cgo e o Go é bastante alto, existe uma oportunidade de otimização aqui para o gosax. Poderíamos fazer mais trabalhos em C – fazendo o parse de um elemento completo e devolvendo-o totalmente para o Go. Isso moveria o trabalho do lado Go para o lado C, bem como reduziria o número de chamadas entre as linguagens. Mas esta é uma tarefa para outro dia.

Conclusão

Bem, foi divertido 🙂 Existem 5 implementações diferentes da mesma simples tarefa descrita aqui, em 3 linguagens de programação diferentes. Aqui está um resumo das medidas de velocidade que obtivemos:

Time Benchmark

A história do desempenho em Python sempre foi – “provavelmente é rápida o suficiente e, nos raros casos em que não é, use uma extensão C”. Em Go, a narrativa é um pouco diferente: na maioria dos casos, o compilador Go produz código bastante rápido. O código puro Go é significativamente mais rápido do que Python e geralmente mais rápido que  Java. Mesmo assim, de vez em quando, pode ser útil mergulhar em C ou C ++ para desempenho, e nesses casos o Cgo é uma boa abordagem.

É óbvio que o pacote encoding/xml precisa de algum trabalho em termos de desempenho, mas até que isso aconteça – existem boas alternativas! Aproveitar a velocidade da libxml foi possível para a API do DOM e agora também é possível a API SAX. A longo prazo, acredito que um trabalho sério desempenho do pacote encoding/xml pode torna-lo mais rápido do que os wrappers da libxml, pois elimina o alto custo das chamadas C-para-Go.

[1] Esse tamanho caberá facilmente na RAM, mas é bom o suficiente para fornecer uma duração significativa de benchmarking.
[2] Quando se trabalha com otimizações, geralmente é útil saber “a velocidade da luz” de alguns cálculos. Digamos que queremos otimizar alguma função em nosso programa. Vale a pena perguntar – quanto mais rápido será o programa se esta função demorar 0 tempo? Se a mudança geral é pequena, a função não vale a pena ser otimizada, provavelmente. Esta é apenas uma aplicação prática da lei de Amdahl.
[3] Podemos testar essa hipótese, cronometrando quanto tempo leva a API de não streaming em lxml para analisar o mesmo arquivo. Como ele analisa todo o arquivo XML em C antes de retornar a estrutura analisada para o Python, esperamos que a sobrecarga de chamadas do Python seja muito menor. De fato, para arquivos que cabem na memória, isso é mais rápido. Mas, mais uma vez, neste post, voltamos nossa atenção para as APIs de streaming – supondo que essa seja a nossa única opção para arquivos gigantescos.
[4] O gosax é muito pequeno, fornecendo apenas os retornos de chamadas SAX mais comuns. A decisão de criar um novo módulo foi apenas por conveniência e velocidade; a coisa mais correta provavelmente seria contribuir para um dos wrappers libxml existentes. Eu não vejo o gosax com qualidade de produção neste estágio – eu apenas o cortei para poder experimentar neste post.

Fonte: Eli Bendersky’s website

Deixe uma resposta

O seu endereço de e-mail não será publicado. Campos obrigatórios são marcados com *

Veja Também

Um guia de boas práticas de segurança para desenvolvimento em Go

Confira este livro on-line gratuito sobre boas práticas de segurança de desenvolvimento pa…