Rodrigo Kumpera Weblog

Meus achados sobre tecnologia

Como fazer uma linguagem dinâmica ser rápida?

May 22nd, 2008 · 2 Comments

Muito se fala em como as implementações de Ruby estão ficando rápidas, que estão evoluindo rapidamente. Porém não consigo pensar em como todas elas parecem mas preocupadas em repetir o caminho das pedras que outras linguagens dinâmicas passaram em décadas passadas.

Hoje a maioria ainda está no estágio de possuir um interpretador razoável e estar começando a investir em algum mecanismo primitivo de compilação para código nativo. Talvez a única diferença seja o uso de inline caches para resolução de nomes, que foi uma contribuição significativa do pessoal do Self no começo dos anos 90. Porém os principais resultados obtivos por pesquisas a um bom tempo, como tracing, especialização e especulação não deram as caras ainda.

Inline caching é uma optimização no qual o resultado da resolução de nomes, para dispatch de método por exemplo, é armazenado entre ativações distintas. O truque é introduzir uma clausula de guarda que verifica o tipo do objeto seletor e sua versão. Caches devem ser invalidados sempre que alguém alterar uma classe. Temos dois tipos de cache, os monomórficos que fazem caching de apenas uma invocação e os polimórficos, que armazenam uma série de resultados. Para melhor vamos exemplificar com pseudo código:


//ruby
def test (a)
a.foo
end

//pseudo-código gerado em C# para um cache monomórfico

class CallSite {
Type type;
MethodInfo method;
int version;
}

static CallSite test_site_0;

object test(object a) {
//guarda do cache monomórfico verifica tipo e versão
if (test_site_0.type == a.Type && test_site_0.version == A.Type.version)
return method (a);
else
//ResolveAndInvoke resolve o método "foo", invoca ele e atualiza o cache
return ResolveAndInvoke (a, "foo", ref test_site_0);
}

//pseudo-código gerado em C# para um cache polimórfico
delegate object InlineCacheNoArg (object this_);

static InlineCacheNoArg test_site_0;

object test(object a) {
return test_site_0 (a);
}

//pseudo-código inicial do delegate de test_site_0
object test_site_0_v0 (object _this) {
return ResolveAndInvoke (a, "foo", ref test_site_0);
}

//pseudo-código do delegate depois de chamarmos test (99)
object test_site_0_v1 (object _this) {
if (_this.Type == typeof (int) && _this.version == 1)
//o dispatch aqui pode ser via um delegate dependendo da resolução
return ((int)_this).foo ();
return ResolveAndInvoke (a, "foo", ref test_site_0);
}

//pseudo-código do delegate depois de chamarmos test ("str")
object test_site_0_v2 (object _this) {
if (_this.Type == typeof (int) && _this.version == 1)
return ((int)_this).foo ();
if (_this.Type == typeof (string) && _this.version == 1)
return ((string)_this).foo ();
return ResolveAndInvoke (a, "foo", ref test_site_0);
}

Como fica claro pelo exemplo, um cache polimórfico tem uma performance superior pois usa literais e funciona muito melhor no caso de não existir um tipo dominante entre as ativações. Aqui fica clara a limitação de uma das implementações, o JRuby não pode se dar ao luxo de gerar tantos métodos pois cada um precisa de uma classe e um classloader novos, ou seja, abusa da PermGen do Java.

Apesar de inline caches resultarem em ganho expressivo de performance, estão longe de produzirem algo razoável. O grande problema continua sendo o enorme custo de dispatch para código simples. A chave disso é fazer inferência dos tipos, de forma a conseguir eliminar por completo o overhead dos caches e resolução de nomes. As atuais implementações não implementam se quer inferência estática, ou seja, código como “123.to_s” é executado sem qualquer conhecimento prévio de 123.

O grande avanço ocorre se fizemos inferência em tempo de execução. Ou seja, instrumentamos o código para coletar os tipos que aparecem pelo código durante a execução e baseado nisso a máquina virtual gera versões mais eficientes do código. Existem duas técnicas bastante difundidas de como fazer isso, uma é via especialização parcial de métodos e a outra é via trace-based optimization.

Trace-based optimization é a tecnologia adotada pela Tamarin, a próxima VM de Javascript da Mozilla. De maneira sucinta, essa técnica consiste em gravar os trechos, e os tipos encontrados, que executam mais freqüentemente e gerar código eficiente baseado nessa informação. Os trechos gravados costumam incluir vários métodos diferentes e suas ativações juntas. Suas principais vantagens é a conseguir fazer inlining de métodos de maneira muito eficiente e normalmente gastar menos tempo no JIT. Porém existem uma enorme quantidade de problemas complexos de serem resolvidos como limitar expansão descontrolada da quantidade de código gerado.

Especialização parcial de métodos também leva em conta os métodos que executam mais freqüentemente. Os tipos dos parâmetros e valores de retorno
são armazenados e posteriormente utilizados para gerar versões especializadas dos métodos em questão. Sua principal vantagem é a maior simplicidade
e o fato de existir muito mais literatura e ferramentas para gerar código eficiente nestes casos. Para se ter uma idéia do poder dessa técnica um exemplo cai bem:


//ruby
def fun (a, b)
2 * a - b
end

//fun é sempre chamada com números como argumento, "fun (1,2)" por exemplo.

//pseudo-código C# gerado inicialmente (sem usar caches)
object fun_v0 (object a, object b) {
object tmp = Invoke (2, "*", a"); //_this, nome do método, argumentos
return Invoke (tmp, "-", b);
}

//pseudo-ćodigo C# gerado após especialização:
int fun_int_int (int a, int b) {
int tmp = 2 * a; //o método que implementava multiplicação foi inlined
return tmp - b;
}

object fun_v1 (object a, object b) {
if (a.Type == typeof (int) && b.Type == typeof (int) && typeof (int).version == 1)
return fun_int_int ((int)a, (int)b);
object tmp = Invoke (2, "*", a"); //_this, nome do método, argumentos
return Invoke (tmp, "-", b);
}

Não precisa ir muito longe para imaginar a diferença de performance entre as duas versões. Porém entre descobrir os métodos as serem especializados e gerar código de máquina existe um Just-In-Time compiler que é, surpresa, surpresa, muito trabalhoso de ser escrito para executar rapidamente e gerar código eficiente.

Possui um bom JIT é atualmente um grande dilema entre as implementações de ruby. Pois ao usar o JIT de máquinas virtuais maduras, como HotSpot ou mono, a implementação fica limitada ao que é possível a linguagens gerenciadas. Em contrapartida, ao não utilizá-las, construções de baixo nível como profiler e interpretador podem ser implementadas de forma muito mais eficiente.

Apesar de do futuro parecer muito legal, futuras VMs terão o trabalho adicional de educar a comunidade de desenvolvedores sobre como escrever código rápido em ruby. Coisas como monkey patching furam qualquer esquema de caching ou especialização pois cada objeto passa a ter uma singleton class distinta.

Não existe solução fácil e todas implementações tem muito chão pela frente até Ruby ter uma performance competitiva com outras linguagens dinâmicas. Soluções como as aqui apresentadas devem conseguir melhorias de uma ordem de magnitude tranquilamente, de acordo com os resultados já encontrados. Performance é um problema que é resolvido via muito suor, com uma contínua série de melhoras, atacando um pouco por vez.

Tags: Programming · java · language design · mono · ruby

2 responses so far ↓

  • 1 André // Jan 8, 2009 at 12:54 am

    Queria construir uma pagina onde o usuario pudesse postar suas fotos e que o mesmo ajustace o tamanho da foto, e junto a isso um comentario breve de algumas linhas, mais já casei por tudo que é lugar e não encontrei se tiver algo relacionado pronto e puder me passar fico agradecido.

  • 2 Felipe Albrecht // May 14, 2009 at 7:43 pm

    O livro Virtual machines do Smith e Nair trata bem do tema de otimização de interpretadores nos primeiros capítulos. Leitura recomendada para quem quiser entender mais o tema!

Leave a Comment