Back-End

30 jun, 2016

[Manga Dowloader] Melhorando o Crystal/Ruby do bursts para poll stream

Publicidade

Recentemente, escrevi como eu construi uma nova implementação de Manga Reader Downloader em Crystal, portado para Ruby, testado em JRuby e comparado com Elixir. Apenas para recapitular, estes foram os resultados para buscar capítulos, páginas e links de imagem de uma manga de exemplo:

  • MRI Ruby 2.3.1: 57 segundos;
  • JRuby 9.1.1.0: 45 segundos;
  • Crystal 0.17.0: 59 segundos;
  • Elixir: 14 segundos;

Eu também disse que como era uma comparação injusta, já que a versão do Elixir usa um diferente – e, obviamente, mais eficiente – algoritmo.

Foi o “first-version-that-worked”, então eu decidi ir em frente e melhorar as implementações. Na versão Ruby/JRuby, eu adicionei a thread gem para ter uma implementação boa o suficiente de um pool de threads que funcione para Ruby e JRuby. Eu provavelmente deveria ter usado Concurrent-Ruby, mas estava tendo alguns problemas para fazer FixedThreadPool funcionar.

Enfim, agora todas as versões terão uma constant poll de pedidos executando, e nós podemos fazer uma comparação melhor.

Outra coisa que pode ter distorcido os resultados contra Cristal é que parece ter uma implementação de resolução de DNS com defeito, então por enquanto eu só adicionei o endereço Manga Reader IP diretamente ao meu arquivo /etc/hosts para evitar exceções getaddrinfo: Name or service not known.

Para começar, vamos testar a mesma implementação Elixir, e como esperado o resultado ainda é o mesmo:

11,49s user 0,82s system 77% cpu 15,827 total

Agora, o MRI Ruby com o algoritmo “batch burst” levou 57 segundos, e essa nova aplicação usando um ThreadPool executa muito melhor:

12,67s user 0,92s system 50% cpu 27,149 total

Em Cristal, é um pouco mais complicado, pois não há nenhuma maneira de implementar o equivalente de um “Fiber Pool”. O que temos a fazer é criar um loop infinito até que o último processo sinalize uma quebra no loop. Internamente, o loop que criar um número máximo de fibras espera por cada um para sinalizar que terminou através de um canal individual e faz um loop de novo para criar uma nova fibra, e assim por diante. Em comparação com 59 segundos da minha tentativa anterior, isto é muito melhor:

5,29s user 0,33s system 26% cpu 21,166 total

A versão JRuby não é tão mais rápida, embora ainda seja melhor do que os 45 segundos do exemplo anterior, mas agora está perdendo até para MRI:

49,24s user 1,41s system 146% cpu 34,602 total

Eu tentei usar a flag –dev para iniciar mais rápido e isso melhorou um pouco as coisas, aproximando-a de MRI:

22,26s user 0,99s system 76% cpu 30,320 total

Não tenho certeza se ela pode ser melhorada ainda mais, embora algumas dicas são bem-vindas – não se esqueça de comentar abaixo.

Assim, Elixir é ainda, pelo menos, duas vezes mais rápido do que Crystal neste ponto. Mas isso também demonstra como um algoritmo diferente faz uma diferença enorme onde importa. Eu provavelmente possa ajustá-lo um pouco mais, mas isso deve ser suficiente por enquanto.

Mudando a implementação Ruby para usar ThreadPool

require "thread/pool"

module MangaDownloadr
  class Concurrency
    ...
    def fetch(collection, &block)
      pool    = Thread.pool(@config.download_batch_size)
      mutex   = Mutex.new
      results = []

      collection.each do |item|
        pool.process {
          engine  = @turn_on_engine ? @engine_klass.new(@config.domain) : nil
          reply = block.call(item, engine)&.flatten
          mutex.synchronize do
            results += ( reply || [] )
          end
        }
      end
      pool.shutdown

      results
    end
    ...

A ideia é que teremos um número fixo de threads nativas de desova (neste caso, determinadas por @config.download_batch_size). À medida que uma thread termina, ela irá mostrar um novo link a partir da array collection, trabalhando essencialmente como uma “fila” para ser esgotada.

Os resultados são acumulados na array results. Porque muitas threads podem querer modificá-lo de uma só vez, temos que sincronizar o acesso através de um Mutex.

Dessa forma, temos sempre uma quantidade fixa de workers realizando pedidos constantemente em vez de cortar a array collection e fazer bursts como na versão anterior.

Mudando a implementação Crystal para simular um Fibers Pool

A versão Crystal se tornou um pouco mais complicada, pois eu não encontrei uma biblioteca pool para puxar. Eu encontrei uma aplicação aproximada de uma pool neste texto de stackoverflow e fui capaz de implementar uma versão melhorada em um novo fragmento para que você possa aproveitá-lo em seus projetos. Confira o código-fonte em akitaonrails/fiberpool. Assim foi como eu adicionei-o ao meu projeto:

dependencies:
  ...
  fiberpool:
    github: akitaonrails/fiberpool

Foi assim que eu usei. Observe que a própria lógica é muito próxima da versão de MRI Ruby, mas usando um “Fiber Pool” em vez de um pool de threads.

require "fiberpool"

module CrMangaDownloadr
  struct Concurrency
    def initialize(@config : Config, @turn_on_engine = true); end

    def fetch(collection : Array(A)?, engine_class : E.class, &block : A, E? -> Array(B)?) : Array(B)
      results = [] of B
      if collection
        pool = Fiberpool.new(collection, @config.download_batch_size)
        pool.run do |item|
          engine = if @turn_on_engine
                     engine_class.new(@config.domain, @config.cache_http)
                   end
          if reply = block.call(item, engine)
            results.concat(reply)
          end
        end
      end
      results
    end
  end
end

Mas, novamente, isso é muito intensivo para I/O, e ambas as versões Ruby e Crystal tiram proveito do fato de que eles podem fazer mais trabalho enquanto esperam um pedido HTTP para terminar.

Reproduzindo os testes

Eu implementei um “modo de teste” em todos as 3 implementações. Você pode clonar dos meus repositórios:

E você pode executar o modo de teste como este:

# crystal:
time ./cr_manga_downloadr --test

# MRI:
time bin/manga-downloadr --test

# JRuby (you have to edit Gemfile to uncomment the JRuby engine):
time jruby --dev -S bin/manga-downloadr --test

# Elixir:
time ./ex_manga_downloadr --test

Isso irá executar apenas a busca de capítulos, páginas e links de imagem, ignorando o dowloading real das imagens, otimizado através da compilação mogrify e PDF. Aquelas partes ignoradas demoram muito e não dizem nada sobre as linguagens testadas.

E se você quiser testar apenas as partes intensivas da CPU e evitar completamente qualquer interferência da rede, você pode ativar o modo de cache HTTP e executar os testes duas vezes, então a primeira execução cacheará tudo primeiro, deste jeito:

# crystal:
time ./cr_manga_downloadr --test --cache

# MRI:
time bin/manga-downloadr --test --cache

# JRuby (you have to edit Gemfile to uncomment the JRuby engine):
time jruby --dev -S bin/manga-downloadr --test --cache

# Elixir:
time CACHE_HTTP=true ./ex_manga_downloadr --test

Assim, com todos os pedidos já colocados em cache estes, são os resultados:

Elixir:

7,13s user 0,24s system 331% cpu 2,227 total

MRI Ruby:

5.590000   0.180000   5.770000 (  5.678714)
5,87s user 0,21s system 101% cpu 5,996 total

JRuby:

10.580000   0.180000  10.760000 (  3.184472)
14,54s user 0,44s system 262% cpu 5,716 total

Crystal:

1.610000   0.050000   1.660000 (  1.344123)
1,62s user 0,06s system 124% cpu 1,350 total

As versões Ruby/JRuby/Crystal têm referências internas para tirar o tempo de inicialização (é por isso que elas têm 2 linhas de tempo).

Então, Elixir é muito rápido. Demora cerca de 2 segundos para analisar todos os 1.900 arquivos HTML para encontrar os links.

Ruby é o mais lento, obviamente. Leva quase 6 segundos.

A versão JRuby também leva quase 6 segundos, mas internamente ela processa em 3 segundos, o resto é tempo de inicialização e aquecimento da JVM por baixo.

E Crystal é o mais rápido, como seria de esperar, porque é um binário super otimizado fazendo operações vinculadas à CPU, com clock de um pouco mais de 1 segundo.

Conclusão

Mesmo que os algoritmos sejam mais ou menos semelhantes, Elixir ainda está vencendo por uma larga margem no processo total (com as solicitações HTTP externas).

Há mais do que apenas a interpreter/compiler há mais de single-thread, multi-thread e infraestrutura de fibras.

Nós também temos a maturidade das respectivas bibliotecas padrão (incluindo pilha TCP, bibliotecas de cliente HTTP, String/Array/operações Regex etc.) e as bibliotecas 3D party (LibXML, Nokogiri etc.). Portanto, há muita coisa que pode interferir nos testes. Eu acho que a biblioteca padrão de Crystal, especialmente as partes de rede, não é testadaa o suficiente neste momento (pré 1.0!).

Assim, o resumo com os novos resultados é o seguinte:

  • MRI Ruby 2.3.1: 27 segundos;
  • JRuby 9.1.1.0: 30 segundos;
  • Crystal 0.17.0: 21 segundos;
  • Elixir: 15 segundos;

Deixe-me saber se você tem ideias para torná-los ainda mais rápidos!

***

Artigo traduzido com autorização do autor. Publicado originalmente em  http://www.akitaonrails.com/2016/06/07/manga-downloadr-improving-the-crystal-ruby-from-bursts-to-pool-stream