Arquitetura do NodeJS II: V8, Ignition e TurboFan
Na postagem anterior, falamos brevemente sobre os desafios para utilização de JavaScript no backend e onde ferramentas como V8 e Libuv entram neste processo. Inicialmente, os browsers precisavam de um padrão para que todos pudessem implementar o JavaScript e obter os mesmos resultados, e foi então que em 1997, a empresa Netscape submeteu definições da linguagem para a organização ECMA International, que nomeou o padrão oficial como ECMAScript. Este padrão é mantido e evoluído por um comitê técnico de dentro da ECMA, chamado TC39, com representantes de diversas empresas como Google, Apple, Microsoft, Mozilla, etc.
A partir de então, surgiram diversas engines para interpretação de JavaScript nos browsers, como o SpiderMonkey, o JavaScriptCore e o V8. Embora todas elas implementem o padrão ECMAScript, cada uma resolve a execução do código de forma própria, e é justamente essa arquitetura interna do V8 que iremos explorar agora.
Com foco em otimização, o Google criou uma ferramenta poderosa e um processo de compilação robusto com o V8, que foi lançado na primeira versão do Chrome. Dentre as principais otimizações realizadas pelo V8, podemos destacar a implementação do JIT compilation, ou compilação Just-in-time. Essa metodologia implica que a compilação do código JavaScript seja feita em tempo de execução e para suportar esse processo, um grande mecanismo foi estruturado no V8.
O ponto de entrada deste processo é o código fonte escrito pelos desenvolvedores em JavaScript. A primeira etapa consiste na elaboração de uma estrutura chamada AST, ou Abstract Syntax Tree, a fim de criar uma estrutura semântica para identificar e destacar cada recurso do JavaScript utilizado no código fonte.
Para aprofundarmos podemos explorar o repositório do V8 no GitHub. A ingestão do JavaScript começa com a inicialização da struct “Scanner”, que iterativamente lê as linhas de código através do método “Scan()” (def: src/parsing/scanner-inl.h).

Neste processo, as palavras reservadas da linguagem, como “function”, “const” e “class”, são separadas do código, gerando partes para a AST que são chamadas de tokens. A instância de Scanner é criada e gerenciada pela função “ParseProgram()” da struct “Parser” (src/parsing/parser.cc) que, em seguida, delega todo o trabalho pesado que veremos a frente para a função “DoParseProgram()”, uma função genérica que delega o parsing para funções especializadas para cada tipo de token encontrado no código, como as funções “ParseFunction”, “ParseModuleItemList”, “ParseImportDeclaration”, etc. Nessas funções de parsing especializado temos a chamada para a construção da árvore AST, feita através da classe AstNodeFactory.

A estrutura de AST criada durante o parsing, representa como o V8 interpretou seu código JavaScript, porém, agora de forma estruturada e abstraída, pronta para compilação para linguagem de máquina. Para ilustrar como essa árvore se parece, podemos analisar um exemplo de código e sua respectiva árvore. Veja o código abaixo, onde o sistema faz uma soma simples:

Esta pequena linha de código é convertida em uma AST com pelo menos quatro componentes: um declarador de variável do tipo “const”, um operador binário de soma e dois valores literais para os números 2 e 3 que serão somados. A AST abaixo representa essa linha de código:

Na estrutura podemos notar chaves presentes em todos os campos, como “type”, que representa o tipo da estrutura criada, e “start” e “end”, que representam os índices de início e fim de determinado bloco compilado dentro do código original. Além disso, temos chaves como “value” e “raw”, que trazem os valores de dados literais, e o “name” para identificar variáveis e estruturas nomeadas. Existem outras chaves para definir recursos para compilação, mas isso pode ser explorado simulando blocos de código no AST Explorer (https://astexplorer.net/) e analisando seus resultados, como fizemos agora.
A partir da AST, um componente inserido no V8 em 2016 chamado Ignition, entra para otimizar o processo de compilação do Javascript, que até então era um processo custoso do compilador. Diferente do processo anterior, ao invés de compilar diretamente para código de máquina, o Ignition é responsável por compilar o código JavaScript para Bytecode, um tipo de código composto por um código de operação (opcode) e operandos (dados que serão processados), onde cada opcode possui o comprimento de um byte, permitindo a execução de centenas de operações. Dessa forma, o principal impacto do Ignition é a otimização da compilação e do espaço ocupado pelo código final, além disso, consegue proporcionar uma portabilidade, onde os Bytecodes passam a atuar como um intermediário universal para diferentes processadores e, a partir dele, é possível gerar código para processadores AMD, ARM, Intel, etc.
A partir do Bytecode gerado teremos várias instruções diferentes, como criação de variáveis, operações matemáticas, loops, etc. Essas operações, por sua vez, são interpretadas pelos Bytecode Handlers em tempo real, fazendo o processo de transformação para o código de máquina executável. Cada instrução possui seu Handler equivalente e especializado para o ambiente em que o código será executado, podendo variar entre browsers, para JavaScript web, ou até mesmo, entre sistemas operacionais e processadores, para a runtime do NodeJS.
Ao longo da execução de um programa é muito comum termos funções e loops que são executados múltiplas vezes. Para esses blocos de código, o Ignition insere um “contador invisível” no Bytecode capaz de computar quais blocos são frequentemente executados. Quando este contador trabalha computando o número de vezes que uma função executou, chamamos esse contador de “invocation counter”, enquanto para contagem de execução de loops, chamamos de “back-edge counter”. Com essa informação, conseguimos classificar o que chamamos de “Hot Functions”.
Essas funções que são frequentemente utilizadas passam por um processo de compilação diferente do JIT. Após serem identificadas como Hot Functions, elas são compiladas diretamente para linguagem de máquina por um outro componente do V8 de forma paralela sem travar o processo principal. O componente responsável por essa otimização é chamado de TurboFan.
Dessa forma, o processo de compilação pode ser resumido nas seguintes etapas:
- O código JavaScript é lido pelo Scanner e transformado em uma AST pelo Parser;
- O Ignition entra em ação e converte a AST em instruções com Bytecodes em tempo real (JIT);
- Funções frequentemente utilizadas são compiladas diretamente para código de máquina pelo TurboFan.

O JavaScript é uma linguagem de tipagem dinâmica, o que significa que os tipos são resolvidos em tempo de execução. Logo, uma variável declarada com um valor numérico pode posteriormente se tornar um valor do tipo texto ou booleano. O problema surge quando levamos este conceito ao compilador, pois sempre que efetuamos a troca de tipo de um parâmetro, todo aquele bloco precisa ser compilado novamente. Quando misturamos esse conceito com a otimização realizada pelo TurboFan, chegamos em um ponto crítico, onde alterar o tipo do dado faz com que o código otimizado que foi gerado anteriormente, já não funcione mais. Neste momento, o código gerado pelo TurboFan é descartado e a execução retorna para o Ignition com os Bytecodes, até que essa função seja classificada novamente como Hot Function e mais uma vez otimizada pelo Turbofan. Este processo é mais trabalhoso do que o fluxo otimizado para o compilador, por isso devemos nos atentar para evitar esse tipo de dinamismo no nosso código e assim, atingirmos maior performance no processo de compilação. Este processo interno do compilador é chamado de “deoptimization” ou “bailout”. Veja o código abaixo, nele temos um exemplo de um código que possui trocas nos tipos dos parâmetros após otimização de uma Hot Function pelo TurboFan:

Para identificar esse tipo de evento indesejado no código, podemos executar o comando “node –trace-deopt <path>.js”, aqui serão mostradas as deoptimizations que ocorreram durante a execução do código. Veja o exemplo para o código acima:

Entender esse processo de otimização é fundamental para entender o porquê a consistência de tipos no NodeJS é de extrema importância. Além disso, nos ajuda a entender também como ferramentas como TypeScript, apesar de não prevenir totalmente o deoptimization, nos auxiliam a manter nosso software dentro de algumas boas práticas para os compiladores usufruírem o máximo de seus recursos.
Na próxima postagem iremos explorar onde arrays, objetos e outras estruturas de dados vivem na memória do V8, e como o Garbage Collector gerencia esse espaço.