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