Back-End

12 ago, 2016

Tentando combinar Fast Blank baseada em C com Crystal

Publicidade

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