A meu ver, Crystal pode se tornar a solução ideal para tornar as nossas amadas Ruby gems serem mais rápidas. Até agora, temos usado extensões baseadas em C para acelerar código de CPU-bound em Ruby. Nokogiri, por exemplo, é um encapsulamento para fornecer uma API boa em cima da libxml, que é uma enorme biblioteca em C.
Mas também há muitas oportunidades para acelerar aplicações Rails. Por exemplo, vimos recentemente o lançamento da gem “faster_path”, desta vez escrita em Rust e bridged através de FFI (Foreign Function interface). A afirmação do autor é que as Sprockets têm que calcular um monte de paths e, ao fazer esta biblioteca, nativamente compilada e otimizada com Rust, acrescentou uma enorme melhoria na tarefa de asset pipeline.
Sam Saffrom, do Discourse, também construiu uma pequena gem chamada de “fast_blank”, que é uma pequena biblioteca escrita em C que reimplementa o método String#blank? do ActiveSupport para ser até 9x mais rápido. Porque o Rails digere volumes de strings, verificando se eles estão em branco toda vez, isso adiciona algum desempenho (depende do seu aplicativo, é claro).
O Santo Graal para o desempenho de nível nativo é ser capaz de escrever código próximo do Rubi em vez de ter de ir baixo nível em C ou ter a alta curva de aprendizado de uma linguagem como Rust. Mais do que isso, eu gostaria de evitar ter que usar FFI. Eu não sou um especialista em FFI, mas me lembro de que adiciona sobrecarga para fazer as ligações.
Falando nisso, é importante dizer agora: eu não sou um especialista em C, de forma alguma, longe disso. Então, eu tenho muito pouca experiência em lidar com o desenvolvimento pesado em C. E, novamente, por isso, a possibilidade de escrever em Crystal é ainda mais atraente para mim. Então, se você é um especialista em C e vir algo bobo que estou dizendo sobre isso, por favor, me deixe saber na seção de comentários abaixo.
Meu exercício é reescrever a gem Fast Blank baseada em C em Crystal, adicionar a mesma gem para compilar sob Crystal se estiver disponível ou fallback para C, e fazer as specs passar para que isso seja uma transição transparente para o usuário.
Para conseguir isso, eu tive que:
- Estender extconf.rb da Gem para gerar Makefiles diferentes (para C e Crystal) que são capazes de compilar sob OS X ou Linux (Ubuntu, pelo menos) – OK
- Fazer as specs passarem na versão Crystal – Quase (é ok para todos os efeitos, mas um caso extremo)
- Tornar o desempenho mais rápido do que Ruby e perto de C – Não tanto ainda (sob OS X, o desempenho é muito bom, mas no Ubuntu ele não escala tão bem para strings grandes)
Você pode conferir os resultados até agora no meu fork no GitHub e seguir o Pull Request e a discussão.
Comparando C e Crystal
Só para começarmos, vamos olhar um trecho da versão original em C do Sam:
static VALUE rb_str_blank(VALUE str) { rb_encoding *enc; char *s, *e; enc = STR_ENC_GET(str); s = RSTRING_PTR(str); if (!s || RSTRING_LEN(str) == 0) return Qtrue; e = RSTRING_END(str); while (s < e) { int n; unsigned int cc = rb_enc_codepoint_len(s, e, &n, enc); if (!rb_isspace(cc) && cc != 0) return Qfalse; s += n; } return Qtrue; }
Sim, muito assustador, eu sei. Agora vamos ver a versão em Crystal:
struct Char ... # same way C Ruby implements it def is_blank self == ' ' || ('\t' <= self <= '\r') end end class String ... def blank? return true if self.nil? || self.size == 0 each_char { |char| return false if !char.is_blank } return true end end
Isso aí! Se você é um rubista, eu aposto que você pode entender 100% do trecho acima. Não é “exatamente” a mesma coisa (já que as specs não estão passando ainda na totalidade), mas está bem perto.
A busca de um Makefile para Crystal
Eu pesquisei muitos repositórios experimentais no GitHub e Gists. Mas eu não encontrei um que tivesse tudo, então decidi ajustar o que eu achei até chegar a esta versão:
Obs: de novo, eu não sou um especialista em C. Se você tem experiência com Makefiles, eu sei que isso pode ser refatorado para algo melhor. Deixe eu saber nos comentários abaixo.
ifeq "$(PLATFORM)" "" PLATFORM := $(shell uname) endif ifeq "$(PLATFORM)" "Linux" UNAME = "$(shell llvm-config --host-target)" CRYSTAL_BIN = $(shell readlink -f `which crystal`) LIBRARY_PATH = $(shell dirname $(CRYSTAL_BIN))/../embedded/lib LIBCRYSTAL = $(shell dirname $(CRYSTAL_BIN) )/../src/ext/libcrystal.a LIBRUBY = $(shell ruby -e "puts RbConfig::CONFIG['libdir']") LIBS = -lpcre -lgc -lpthread -levent -lrt -ldl LDFLAGS = -rdynamic install: all all: fast_blank.so fast_blank.so: fast_blank.o $(CC) -shared $^ -o $@ $(LIBCRYSTAL) $(LDFLAGS) $(LIBS) -L$(LIBRARY_PATH) -L$(LIBRUBY) fast_blank.o: ../../../../ext/src/fast_blank.cr crystal build --cross-compile --release --target $(UNAME) lt; .PHONY: clean clean: rm -f bc_flags rm -f *.o rm -f *.so endif ifeq "$(PLATFORM)" "Darwin" CRYSTAL_FLAGS = -dynamic -bundle -Wl,-undefined,dynamic_lookup install: all all: fast_blank.bundle fast_blank.bundle: ../../../../ext/src/fast_blank.cr crystal $^ --release --link-flags "$(CRYSTAL_FLAGS)" -o $@ clean: rm -f *.log rm -f *.o rm -f *.bundle endif
A maioria das pessoas que usa Crystal está no OS X, incluindo os criadores do Crystal. LLVM está sob o controle da Apple, e todo o seu ecossistema depende fortemente do LLVM. Eles passaram muitos anos migrando primeiro o front-end C, depois o back-end C longe do GCC padrão do GNU para Clang. E eles foram capazes de fazer tanto o Objective-C e o Swift compilar até IR do LLVM, e é assim que ambos podem interagir entre eles de forma nativa.
Em seguida, eles melhoraram o suporte para o backend ARM, e é assim que eles podem ter um iOS “Simulator” completo (não um emulador lento como o Android), onde os aplicativos iOS são originalmente compilados para rodar no processador x86_64 da Intel em desenvolvimento e, em seguida, rapidamente recompilar para ARM quando estiver pronto para empacotar para a App Store.
Dessa forma, você pode rodar nativamente, testar rapidamente, sem a lentidão de um ambiente emulado. Falando nisso, eu vou dizer uma vez: o maior erro do Google está em não suportar o LLVM como deveria e reinventar a roda. Se tivessem feito isso, Go já podia ser usado para implementar para Android e Chromebooks, bem como servidores baseados em x86 e eles poderiam tirar o fracasso Java/Oracle.
Mas eu discordo.
No OS X, você pode passar um link-flag “-bundle” para o crystal e provavelmente ele vai usar clang por baixo para gerar o pacote binário.
No Ubuntu, o crystal apenas compila para um arquivo objeto (.o), e você tem que chamar manualmente o GCC com a opção “-shared” para criar um objeto compartilhado. Para isso, temos que usar o “–cross-compile” e passar um LLVM target triplet para que ele gere o .o (isso requer a ferramenta llvm-config).
Bibliotecas compartilhadas (.so) e módulos carregáveis (.bundle) são coisas diferentes; consulte esta documentação para obter mais detalhes.
Tenha em mente que o benchmarking dos binários construídos com diferentes compiladores pode fazer diferença. Eu não sou um especialista, mas além da piada pura eu acredito que o Ruby sob RVM no OS X é compilado usando Clang padrão do OS X. No Ubuntu, ele é compilado com o GCC. Isso parece tornar o Ruby no OS X “ligeiramente” ineficiente em benchmarks sintéticos.
Por outro lado, os binários de Crystal linkados com GCC parecem “muito ligeiramente” ineficientes no Ubuntu, enquanto o Ruby no Ubuntu parece um pouco mais rápido, tendo sido compilado e linkado com o GCC.
Então, quando comparamos Fast Blank/OS X/pouco mais rápido com Ruby/OS X/mais lento contra Fast Blank/Ubuntu/pouco mais lento com Ruby/Ubuntu/pouco mais rápido, parece dar uma vantagem mais ampla para o benchmarking do OS X contra o do Ubuntu, apesar de que os tempos de computação individuais não estão tão distantes uns dos outros.
Voltarei a esse ponto na seção de benchmarks.
Finalmente, toda vez que você tem uma rubygem com uma extensão nativa, vai encontrar este ponto em seus arquivos gemspec:
Gem::Specification.new do |s| s.name = 'fast_blank' ... s.extensions = ['ext/fast_blank/extconf.rb'] ...
Quando a gem é instalada através de gem install ou bundle install, ela irá executar esse script para gerar um Makefile adequado. Em uma extensão C pura, irá usar a biblioteca interna “mkmf” para gerá-lo.
No nosso caso, se tivermos Crystal instalado, nós queremos usar a versão do Crystal, então eu refinei o extconf.rb para ser assim:
require 'mkmf' if ENV['VERSION'] != "C" && find_executable('crystal') && find_executable('llvm-config') # Very dirty patching def create_makefile(target, srcprefix = nil) mfile = open("Makefile", "wb") cr_makefile = File.join(File.dirname(__FILE__), "../src/Makefile") mfile.print File.read(cr_makefile) ensure mfile.close if mfile puts "Crystal version of the Makefile copied" end end create_makefile 'fast_blank'
Então, se ele encontra crystal e llvm-config (que no OS X você tem que adicionar o caminho adequado assim: export PATH=$(brew –prefix llvm)/bin:$PATH).
O Rakefile nesse projeto declara o padrão :compile task como a primeira a ser executada, e ele irá executar a extconf.rb, o que irá gerar o Makefile adequado e executar o comando make para compilar e vincular a biblioteca adequada no caminho lib/ apropriado.
Então vamos acabar com lib/fast_blank.bundle no OS X e lib/fast_blank.so no Ubuntu. Assim, podemos apenas ter require “fast_blank” de qualquer arquivo Ruby na gem e teremos acesso aos mapeamentos de funções C exportadas publicamente a partir da biblioteca do Crystal.
Mapeando C-Ruby no Crystal
Agora, qualquer extensão C direta – sem FFI, fiddle ou outras “bridges” – terão SEMPRE uma vantagem muito melhor.
A razão é que você literalmente tem que “Copiar” dados do C-Ruby para Crystal/Rust/Go ou qualquer outra linguagem que você está vinculando, enquanto que com uma extensão baseada em C você pode operar diretamente no espaço de memória com os dados sem ter que mover ou copiar para longe.
Por exemplo: primeiro, você tem que ligar as funções C de C-Ruby para Crystal. E nós conseguimos isso com mapeamentos Crystalized do Ruby do Paul Hoffer. É um repositório experimental que eu ajudei um pouco a limpar a fim de que, posteriormente, ele extraísse essa biblioteca de mapeamento em seu próprio Shard (shards é o mesmo que gems para Crystal). Por enquanto, eu tinha que simplesmente copiar o arquivo para o meu Fast Blank.
Alguns dos bits relevantes são assim:
lib LibRuby type VALUE = Void* type METHOD_FUNC = VALUE -> VALUE type ID = Void* ... # strings fun rb_str_to_str(value : VALUE) : VALUE fun rb_string_value_cstr(value_ptr : VALUE*) : UInt8* fun rb_str_new_cstr(str : UInt8*) : VALUE fun rb_utf8_encoding() : VALUE fun rb_enc_str_new_cstr(str : UInt8*, enc : VALUE) : VALUE ... # exception handling fun rb_rescue(func : VALUE -> UInt8*, args : VALUE, callback: VALUE -> UInt8*, value: VALUE) : UInt8* end ... class String RUBY_UTF = LibRuby.rb_utf8_encoding def to_ruby LibRuby.rb_enc_str_new_cstr(self, RUBY_UTF) end def self.from_ruby(str : LibRuby::VALUE) c_str = LibRuby.rb_rescue(->String.cr_str_from_rb_cstr, str, ->String.return_empty_string, 0.to_ruby) # FIXME there is still an unhandled problem: then we receive \u0000 from Ruby it raises "string contains null bytes" # so we catch it with rb_rescue, but then we can't generate a Pointer(UInt8) that represents the unicode 0, instead we return a plain blank string # but then the specs fail new(c_str) ensure "" end def self.cr_str_from_rb_cstr(str : LibRuby::VALUE) rb_str = LibRuby.rb_str_to_str(str) c_str = LibRuby.rb_string_value_cstr(pointerof(rb_str)) end def self.return_empty_string(arg : LibRuby::VALUE) a = 0_u8 pointerof(a) end end
Então eu posso usar esses mapeamentos e helpers para construir uma classe “Wrapper” em Crystal:
require "./lib_ruby" require "./string_extension" module StringExtensionWrapper def self.blank?(self : LibRuby::VALUE) return true.to_ruby if LibRuby.rb_str_length(self) == 0 str = String.from_ruby(self) str.blank?.to_ruby rescue true.to_ruby end def self.blank_as?(self : LibRuby::VALUE) return true.to_ruby if LibRuby.rb_str_length(self) == 0 str = String.from_ruby(self) str.blank_as?.to_ruby rescue true.to_ruby end def self.crystal_value(self : LibRuby::VALUE) str = String.from_ruby(self) str.to_ruby end end
E esse “Wrapper” depende da biblioteca “pura” do Crystal com os trechos para Char struct e extensões de classe String que eu mostrei na primeira seção do artigo.
Finalmente, eu tenho um arquivo principal “fast_blank.cr” que externa essas funções Wrapper, de modo que C-Ruby pode vê-los como métodos de String simples:
require "./string_extension_wrapper.cr" fun init = Init_fast_blank GC.init LibCrystalMain.__crystal_main(0, Pointer(Pointer(UInt8)).null) string = LibRuby.rb_define_class("String", LibRuby.rb_cObject) LibRuby.rb_define_method(string, "blank?", ->StringExtensionWrapper.blank?, 0) LibRuby.rb_define_method(string, "blank_as?", ->StringExtensionWrapper.blank_as?, 0) ... end
Isso é principalmente clichê. Mas agora veja o que eu estou tendo que fazer no wrapper, neste trecho em particular:
def self.blank?(self : LibRuby::VALUE) return true.to_ruby if LibRuby.rb_str_length(self) == 0 str = String.from_ruby(self) str.blank?.to_ruby rescue true.to_ruby end
Estou recebendo uma String C-Ruby escalada como um ponteiro (VALUE), então eu passo pelos mapeamentos lib_ruby.cr para obter os dados de string C-Ruby e os copio para uma nova instância da representação interna da String do Crystal. Então, em um dado momento, eu tenho 2 cópias da mesma string, uma no espaço de memória do C-Ruby e outra no espaço de memória do Crystal.
Isso acontece com todas as extensões do tipo FFI, mas não acontece com a implementação em C puro. Na implementação de Sam Saffrom em C, ele trabalha diretamente com o mesmo endereço no espaço de memória do C-Ruby:
static VALUE rb_str_blank(VALUE str) { rb_encoding *enc; char *s, *e; enc = STR_ENC_GET(str); s = RSTRING_PTR(str); ...
Ele recebe um ponteiro (endereço de memória direta) e vai. E isso é uma enorme vantagem para a versão em C. Quando você tem um grande volume de strings de tamanhos médios e grandes sendo copiados de C-Ruby para Crystal, ele adiciona uma sobrecarga notável que não pode ser removida.
Ressalva sobre o mapeamento de string
Eu ainda tenho um problema. Há um caso extremo que não fui capaz de superar ainda (ajuda é muito bem-vinda). Quando C-Ruby passa um unicode “\u0000“, eu sou incapaz de criar o mesmo caractere no Crystal e eu acabo passando apenas uma string vazia (“”), que não é a mesma coisa.
A maneira de lidar com isso é receber uma Ruby String (VALUE) e obter C-String a partir dele da seguinte forma:
rb_str = LibRuby.rb_str_to_str(str) c_str = LibRuby.rb_string_value_cstr(pointerof(rb_str))
Se o “str” é “\u0000” (sob o Ruby 2.2.5, pelo menos), C-Ruby levanta uma exceção “string contains null bytes”. Que é porque eu resgato essa exceção desta forma:
c_str = LibRuby.rb_rescue(->String.cr_str_from_rb_cstr, str, ->String.return_empty_string, 0.to_ruby)
Quando uma exceção é acionada, eu tenho que passar o ponteiro para outra função para salvar a partir dele:
def self.return_empty_string(arg : LibRuby::VALUE) a = 0_u8 pointerof(a) end
Mas isso não é correto, estou apenas passando o ponteiro para um caractere “0”, que é “vazio”. Portanto, as especificações não estão passando corretamente:
Failures: 1) String provides a parity with active support function Failure/Error: expect("#{i.to_s(16)} #{c.blank_as?}").to eq("#{i.to_s(16)} #{c.blank2?}") expected: "0 false" got: "0 true" (compared using ==) # ./spec/fast_blank_spec.rb:22:in `block (3 levels) in <top (required)>' # ./spec/fast_blank_spec.rb:19:in `times' # ./spec/fast_blank_spec.rb:19:in `block (2 levels) in <top (required)>' 2) String treats correctly Failure/Error: expect("\u0000".blank_as?).to be_falsey expected: falsey value got: true # ./spec/fast_blank_spec.rb:47:in `block (2 levels) in <top (required)>'
Ary deu uma dica simples depois, que eu irei adicionar à conclusão abaixo.
Os benchmarks sintéticos (cuidado como você os interpreta!)
A implementação original do Rails ActiveSupport String#blank? se parece com isto:
class String # 0x3000: fullwidth whitespace NON_WHITESPACE_REGEXP = %r![^\s#{[0x3000].pack("U")}]! # A string is blank if it's empty or contains whitespaces only: # # "".blank? # => true # " ".blank? # => true # " ".blank? # => true # " something here ".blank? # => false # def blank? # 1.8 does not takes [:space:] properly if encoding_aware? self !~ /[^[:space:]]/ else self !~ NON_WHITESPACE_REGEXP end end end
É principalmente uma comparação de expressão regular, que pode ser um pouco lenta. A versão de Sam é mais direta, um loop na string para comparar cada caractere com o que é considerado “blank”. Há muitos unicode codepoints que são considerados blank, alguns não, razão pela qual as versões C e Crystal são semelhantes, mas eles são diferentes da versão do Rails.
Na gem Fast Blank existe um script benchmark Ruby para comparar a extensão C contra a implementação baseada em Regex do Rails.
A implementação Regex é chamada de “Slow Blank”. Ela é particularmente lenta se você passar uma string realmente vazia, então no benchmark Sam acrescentou um “New Slow Blank” que verifica primeiro através de String#blank?, e essa versão é mais rápida nesse caso específico.
A versão rápida em C é chamada de “Fast Blank”, mas embora você possa considerá-la “Correta” não é compatível com todos os casos específicos do Rails. Então, ele implementou um String#blank_as?, que é compatível com Rails. Sam o chama de “Fast ActiveSupport”.
Na minha versão do Crystal, eu fiz o mesmo, tendo ambos String#blank? e String#blank_as?.
Assim, sem mais delongas, aqui está a versão C no OS X benchmark para as strings vazias, e nós usamos cada função muitas vezes dentro de alguns segundos para ter resultados mais precisos (dê uma olhada no “benchmark/ips” do Evan Phoenix para entender a metodologia “iteração por segundo”).
================== Test String Length: 0 ================== Warming up _______________________________________ Fast Blank 191.708k i/100ms Fast ActiveSupport 209.628k i/100ms Slow Blank 61.487k i/100ms New Slow Blank 203.165k i/100ms Calculating _______________________________________ Fast Blank 20.479M (± 9.3%) i/s - 101.414M in 5.001177s Fast ActiveSupport 21.883M (± 9.4%) i/s - 108.378M in 5.004350s Slow Blank 1.060M (± 4.7%) i/s - 5.288M in 5.001365s New Slow Blank 18.883M (± 6.9%) i/s - 94.065M in 5.008899s Comparison: Fast ActiveSupport: 21882711.5 i/s Fast Blank: 20478961.5 i/s - same-ish: difference falls within error New Slow Blank: 18883442.2 i/s - same-ish: difference falls within error Slow Blank: 1059692.6 i/s - 20.65x slower
É super-rápido. A versão Rails é 20x mais lenta na minha máquina.
Agora, a versão do Crystal no OS X:
================== Test String Length: 0 ================== Warming up _______________________________________ Fast Blank 174.349k i/100ms Fast ActiveSupport 174.035k i/100ms Slow Blank 64.684k i/100ms New Slow Blank 215.164k i/100ms Calculating _______________________________________ Fast Blank 8.647M (± 1.6%) i/s - 43.239M in 5.001530s Fast ActiveSupport 8.580M (± 1.3%) i/s - 42.987M in 5.010759s Slow Blank 1.047M (± 3.7%) i/s - 5.239M in 5.008907s New Slow Blank 19.090M (± 9.3%) i/s - 94.672M in 5.009057s Comparison: New Slow Blank: 19090034.8 i/s Fast Blank: 8647459.7 i/s - 2.21x slower Fast ActiveSupport: 8580487.9 i/s - 2.22x slower Slow Blank: 1047465.3 i/s - 18.22x slower
Como expliquei antes, mesmo verificando strings vazias, a versão do Crystal é mais lenta do que o Ruby verificando String#empty? (New Slow Blank), porque eu tenho a rotina de cópia da string no mapeamento do wrapper. Isso adiciona sobrecarga que é perceptível ao longo de muitas iterações. Ainda é 18x mais rápido do que Rails, mas perde para C-Ruby.
Finalmente, a versão do Crystal no Ubuntu:
================== Test String Length: 0 ================== Warming up _______________________________________ Fast Blank 255.883k i/100ms Fast ActiveSupport 260.915k i/100ms Slow Blank 105.424k i/100ms New Slow Blank 284.670k i/100ms Calculating _______________________________________ Fast Blank 8.895M (± 9.8%) i/s - 44.268M in 5.037761s Fast ActiveSupport 8.647M (± 8.2%) i/s - 43.051M in 5.020125s Slow Blank 1.736M (± 3.9%) i/s - 8.750M in 5.048253s New Slow Blank 22.170M (± 6.2%) i/s - 110.452M in 5.004909s Comparison: New Slow Blank: 22170031.0 i/s Fast Blank: 8895113.3 i/s - 2.49x slower Fast ActiveSupport: 8646940.8 i/s - 2.56x slower Slow Blank: 1736071.0 i/s - 12.77x slower
Observe que é em torno do mesmo patamar, mas a versão do Rails no Ubuntu funciona quase duas vezes mais rápido em comparação com o seu homólogo no OS X, o que faz a comparação com a biblioteca do Crystal ir de 18x para 12x.
O benchmark continua comparando strings de tamanhos cada vez maiores, de 6, a 14, a 24, até 136 caracteres de comprimento.
Vamos pegar apenas o último caso de teste, o de 136 caracteres. Primeiro com a versão C no OS X:
================== Test String Length: 136 ================== Warming up _______________________________________ Fast Blank 177.521k i/100ms Fast ActiveSupport 193.559k i/100ms Slow Blank 89.378k i/100ms New Slow Blank 60.639k i/100ms Calculating _______________________________________ Fast Blank 10.727M (± 8.7%) i/s - 53.256M in 5.006538s Fast ActiveSupport 11.600M (± 8.3%) i/s - 57.681M in 5.009692s Slow Blank 1.872M (± 5.7%) i/s - 9.385M in 5.029243s New Slow Blank 1.017M (± 5.3%) i/s - 5.094M in 5.022994s Comparison: Fast ActiveSupport: 11600112.2 i/s Fast Blank: 10726792.8 i/s - same-ish: difference falls within error Slow Blank: 1872262.5 i/s - 6.20x slower New Slow Blank: 1016926.7 i/s - 11.41x slower
A versão C é sempre muito mais rápida em todos os casos de teste e nos 136 caracteres ainda é 11x mais rápida do que Rails em Ruby puro.
Agora a versão do Crystal no OS X:
================== Test String Length: 136 ================== Warming up _______________________________________ Fast Blank 127.749k i/100ms Fast ActiveSupport 126.538k i/100ms Slow Blank 94.390k i/100ms New Slow Blank 60.594k i/100ms Calculating _______________________________________ Fast Blank 3.283M (± 1.8%) i/s - 16.480M in 5.021364s Fast ActiveSupport 3.235M (± 1.3%) i/s - 16.197M in 5.008315s Slow Blank 1.888M (± 4.4%) i/s - 9.439M in 5.009458s New Slow Blank 967.950k (± 4.7%) i/s - 4.848M in 5.018946s Comparison: Fast Blank: 3283025.1 i/s Fast ActiveSupport: 3234586.5 i/s - same-ish: difference falls within error Slow Blank: 1887800.5 i/s - 1.74x slower New Slow Blank: 967950.2 i/s - 3.39x slower
É também mais rápido, mas apenas de 2 a 3 vezes em relação ao Ruby puro, muito longe de 11x. Mas a minha hipótese é que o mapeamento e a cópia de tantas strings adicionam uma grande sobrecarga que a versão C não tem.
E a versão do Crystal no OS X:
================== Test String Length: 136 ================== Warming up _______________________________________ Fast Blank 186.810k i/100ms Fast ActiveSupport 187.306k i/100ms Slow Blank 143.439k i/100ms New Slow Blank 98.308k i/100ms Calculating _______________________________________ Fast Blank 3.517M (± 3.9%) i/s - 17.560M in 5.000791s Fast ActiveSupport 3.485M (± 3.8%) i/s - 17.419M in 5.006427s Slow Blank 2.755M (± 4.2%) i/s - 13.770M in 5.008490s New Slow Blank 1.551M (± 4.3%) i/s - 7.766M in 5.017853s Comparison: Fast Blank: 3516960.7 i/s Fast ActiveSupport: 3484575.5 i/s - same-ish: difference falls within error Slow Blank: 2754669.0 i/s - 1.28x slower New Slow Blank: 1550815.2 i/s - 2.27x slower
Mais uma vez, as versões no Ubuntu da biblioteca do Crystal, mas também o binário do Ruby, são executadas mais rapidamente, e a comparação não apresenta mais que duas vezes mais rápido. E o puro String#empty? do Ruby está no mesmo patamar que a versão do Crystal.
Conclusão
A conclusão mais óbvia é que eu provavelmente errei na escolha do Fast Blank como a minha primeira prova de conceito. O algoritmo é demasiado trivial e uma verificação simples String#empty? em Ruby puro é em ordens de grandeza mais rápido do que a sobrecarga adicional do mapeamento e cópia de string do Crystal.
Além disso, qualquer caso de uso em que você tem uma enorme quantidade de pequenos pedaços de dados que estão sendo transferidos de C-Ruby para Crystal ou qualquer extensão baseada em FFI terá a sobrecarga de cópia de dados que uma versão C pura não terá. E é por isso que Fast Blank é melhor feito em C.
Qualquer outro caso de uso em que você tem menos quantidades de dados ou dados que podem ser transferidos a granel (menos chamadas de C-Ruby para a extensão, com argumentos de um tamanho maior, e com processamento mais caro) são melhores candidatos para ter benefícios das extensões.
Mais uma vez, nem tudo fica automaticamente mais rápido, nós sempre temos que descobrir os cenários de casos de uso em primeiro lugar. Mas porque é muito mais fácil de escrever em Crystal e fazer o benchmark, podemos fazer provas de conceito mais rápidas e sucatear a ideia se as medições provarem que não terão tantos benefícios.
A documentação do Crystal recebeu recentemente um “Guia de Performance”. É muito útil para você evitar armadilhas comuns que prejudicam o desempenho geral. Mesmo que LLVM seja bastante competente na otimização pesada, ele não pode fazer tudo. Então leia para melhorar suas habilidades gerais do Crystal.
Dito isso, eu ainda acredito que este exercício foi válido. Provavelmente farei mais alguns. Eu quero agradecer ao Ary (criador do Crystal) e ao Paul Hoffer pela paciência em me ajudar através de muitos dos percalços encontrados ao longo do caminho.
Enquanto eu estava terminando este artigo, Ary apontou que eu provavelmente poderia abandonar as Strings completamente e trabalhar diretamente com um array de bytes, o que é uma boa ideia e eu provavelmente vou tentar isso. Eu acho que eu deixei claro agora que toda a cópia de String adiciona uma sobrecarga muito perceptível como vimos nos benchmarks acima. Deixa eu saber se alguém está interessado em contribuir também. Com mais alguns ajustes, acredito que podemos ter uma versão do Crystal que pode, pelo menos, competir contra a versão C, sendo também mais legível e de fácil manutenção para a maioria dos Rubistas, que é o meu objetivo.
Espero que os códigos que eu publico aqui sirvam como exemplos clichês para mais extensões de Ruby baseadas em Crystal no futuro!
***
Artigo traduzido com autorização do autor. Publicado originalmente em http://www.akitaonrails.com/2016/07/06/trying-to-match-c-based-fast-blank-with-crystal