Bytecodes, e JIT

Resolvi escrever este post por muitos motivos, mas o principal deles é porque vi muitos amigos e colegas meus sem saber, direito, o que é um bytecode. Muitos acham que apenas linguagens como Python (com seus arquivos .pyc) e Java (com seus arquivos .class) possuem bytecodes, justamente porque ao “compilar” o programa, um novo arquivo é criado.

Nada poderia estar tão longe da verdade.

Bytecode é um conjunto de instruções, em forma binária ou seja, em uma forma não legível por humanos, que instrui uma máquina virtual a fazer determinadas ações. Bytecode não é apenas um arquivo, ou um código intermediário entre o executável nativo e a linguagem, e na maioria das vezes nem sequer é um código otimizado, com algumas raras exceções (scala 2.8, por exemplo, pode otimizar “tail call” em recursões). É importante entender essa parte, porque nem sempre um bytecode é gravado em um arquivo, e em algumas vezes sequer é um formato simples.

Por exemplo, como trabalho de TCC, eu escrevi um interpretador. Meu interpretador rodava um dialeto absolutamente simplista de LISP, no qual eu só implementei definição de funções e operações aritméticas (+, -, * e /). Da forma como interpretei, o sistema criava um AST (Árvore Abstrata de Sintaxe) e interpretava este AST. Ou seja, um comando como: (+ (* 2 1) 3) criava uma árvore, e cada comando era um dos nós da árvore. Esta árvore era sempre interpretada, e um resultado era retornado. O mesmo processo, claro que bem mais complexo, é feito no Ruby 1.8.

Eu poderia ter montado uma máquina virtual. Talvez uma máquina virtual baseada em pilha (stack), e ao invés de montar uma AST, trazer uma série de comandos tal como, para o mesmo código:
PUSH 2
PUSH 1
INVOKE *
PUSH 3
INVOKE +

Certo, e qual a vantagem disso? Primeiro, que o bytecode é um formato portável. Segundo, que é mais rápido interpretar um bytecode (não é preciso ficar se entendendo com a sintaxe do programa: basta rodar o bytecode e ir seguindo as instruções, na certeza que está tudo certo). E por fim, com algumas técnicas que eu não sei exatamente como funcionam, é possível inferir algumas coisas e otimizar o código enquanto ele está sendo executado.

Vamos ver um exemplo: um caso claro de Hello, World, em vários bytecodes:

Java (JRuby):
const #2 = Field #13.#14; // java/lang/System.out:Ljava/io/PrintStream;
const #3 = String #15; // Hello, world!
const #4 = Method #16.#17; // java/io/PrintStream.println:(Ljava/lang/String;)V

0: getstatic #2; //Field java/lang/System.out:Ljava/io/PrintStream;
3: ldc #3; //String Hello, world!
5: invokevirtual #4; //Method java/io/PrintStream.println:(Ljava/lang/String;)V
8: return

Ele faz um caching das constantes, e depois associa-as nos comandos para a VM. Seria possível substituir assim:
0: getstatic Field java/lang/System.out:Ljava/io/PrintStream;
3: ldc String Hello, world!
5: invokevirtual Method java/io/PrintStream.println:(Ljava/lang/String;)V
8: return

O mesmo código no Ruby 1.9:
0002 putnil
0003 putstring “Hello, world!”
0005 send :puts, 1, nil, 8,
0011 leave

E o mesmo código no Rubinius
0000: push_self
0001: push_literal “Hello, world!”
0003: string_dup
0004: allow_private
0005: send_stack :puts, 1
0008: ret

E o mesmo código no Python:
1 0 LOAD_CONST 1 (‘Hello, world!’)
3 PRINT_ITEM
4 PRINT_NEWLINE
5 LOAD_CONST 0 (None)
8 RETURN_VALUE

Ou seja, tanto o Ruby 1.9 quanto o Rubinius possuem Bytecodes, a única diferença entre eles é a forma como eles são montados. Agora que vem realmente a parte interessante: JIT, ou “Just in Time Compiling”. Como vimos anteriormente, o Bytecode não é um código de máquina, e sequer contém partes de código de máquina (pois, se ele tivesse, a idéia de rodar em múltiplas plataformas seria perdida). Então, a máquina virtual faz um processo que consiste em, em tempo de execução, gerar um código de máquina e repassar ao sistema operacional a execução deste código.

É aí que a maior parte das otimizações ocorre.

Por exemplo, num código C, o seguinte código gera uma compilação otimizada:

void algo() {
  while(a < 10) {
    do_something(a);
    a++;
  }
}

Porque o processador entende que, na maior parte das vezes, o código dentro do “while” será executado mais vezes do que o código que está logo após o while, então ele deixa esse código (sequência de instruções) em cache, sabendo que na maior parte das vezes ele usará esse cache. Quando se tem uma JIT, é possível fazer essa otimização de outras formas:

void algo() {
  int a = 0;
  while(a < 1) {
    do_something(a);
    a++;
  }
}

Nesse caso, uma máquina virtual poderia, depois de passar algumas vezes por este código, perceber que ele é meio inútil porque ele só roda uma vez. Então, ao invés de gerar um código de máquina equivalente a esse loop, ele poderia descartar esse método e, em tempo de execução, gerar um código de máquina tal como:

void algo() {
  do_something(0);
}

BEM melhor, bem menos declarações, loops removidos, etc. Além disso, rodando sobre um JIT, uma linguagem não precisa necessariamente pré-compilar tudo. No caso de Java, certas coisas como a criação das classes, imports, e métodos que são rodados apenas uma vez nunca são pré-compilados pelo JIT, justamente porque o custo de pré-compilar esses fragmentos de código é mais custoso do que apenas interpretá-los. Da mesma forma, uma VM pode não liberar memória nunca (o custo de alocar e desalocar memória é meio alto, dependendo do sistema operacional, e também pode induzir fragmentação de memória) e, muitas vezes, uma VM possui um algoritmo para descartar pedaços do código pré-compilado quando esse código for alterado na execução (claro, em caso de linguagens dinâmicas tais como Ruby, Python, etc). Outras curiosidades:

  • As primeiras versões do Visual Basic não geravam código nativo, geravam um P-Code (Portable Code) que é, na verdade, um bytecode
  • O .NET gera um executável que contém um bytecode. O executável basicamente chama alguma função dentro do .NET framework e informa o bytecode que será interpretado
  • Apesar de ter começado como um projeto “Ruby sobre Ruby”, o Rubinius agora é uma VM completa, inclusive com algumas implementações de linguagens sobre ele (tal com Javascript)
  • A VM do Ruby 1.9 foi baseada na VM do Python, e houveram projetos de se tentar converter o bytecode gerado pelo Ruby 1.9.2 em bytecode python, e depois decompilar esse bytecode. Isso literalmente traduziria programas Ruby em programas Python
  • Ruby (1.9) e Lua criam um bytecode, porém não o gravam no disco. É possível gravar, no disco, com Lua usando o comando luac, porém até o momento não há um equivalente para Ruby.
  • (Disclaimer: eu não sou lá mto conhecedor de máquinas virtuais, portanto podem haver informações incompletas neste post)

    Advertisements
    This entry was posted in Linguagens de Programação, Ruby and tagged , . Bookmark the permalink.

    2 Responses to Bytecodes, e JIT

    1. Henrique says:

      Exemplos de programas em Visual Basic (todas as versões) – 100% compilado em P-code:

      http://bitshare.com/?f=dqk1qxrp

      Hoje em dia, não faz muita diferença de velocidade de execução entre um programa executável compilado em código nativo e um compilado em P-Code, porque os processadores de hoje são super-velozes, podendo ter 1 ou mais núcleos e trabalham na arquitetura de 64 BITS… Neste caso, não vai sentir a diferença.

      • Quando eu usava VB 5, o código compilado em P-Code era até mais rápido que o nativo. Acho que o VB nunca gerou um código nativo muito otimizado, na versão 5.0. Mas também, eu trabalhei muito pouco com VB, logo depois fui pra Delphi, então não sou a melhor pessoa pra comentar sobre o assunto.

    Leave a Reply

    Fill in your details below or click an icon to log in:

    WordPress.com Logo

    You are commenting using your WordPress.com account. Log Out / Change )

    Twitter picture

    You are commenting using your Twitter account. Log Out / Change )

    Facebook photo

    You are commenting using your Facebook account. Log Out / Change )

    Google+ photo

    You are commenting using your Google+ account. Log Out / Change )

    Connecting to %s