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:
(Disclaimer: eu não sou lá mto conhecedor de máquinas virtuais, portanto podem haver informações incompletas neste post)
2 Comments
Henrique · 2013-09-28 at 19:29
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.
Maurício Szabo · 2013-10-24 at 15:28
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.
Comments are closed.