Pular para o conteúdo principal

Raízes Estáticas: Objetos com Endereços Constantes em Tempo de Compilação

· Leitura de 5 minutos
Olivier Flückiger

Você já se perguntou de onde vêm undefined, true e outros objetos principais do JavaScript? Esses objetos são os átomos de qualquer objeto definido por usuário e precisam estar presentes primeiro. O V8 os chama de raízes imutáveis e imovíveis e eles vivem em seu próprio heap – o heap somente leitura. Como eles são usados constantemente, o acesso rápido é crucial. E o que poderia ser mais rápido do que adivinhar corretamente seu endereço de memória em tempo de compilação?

Como exemplo, considere a função de API extremamente comum IsUndefined API function. Em vez de precisar procurar o endereço do objeto undefined para referência, se pudéssemos simplesmente verificar se um ponteiro de objeto termina, digamos, em 0x61 para saber se é indefinido. Isso é exatamente o que o recurso raízes estáticas do V8 realiza. Este post explora os obstáculos que tivemos que superar para chegar lá. O recurso foi introduzido no Chrome 111 e trouxe benefícios de desempenho para toda a VM, especialmente acelerando o código C++ e funções integradas.

Inicialização do Heap Somente Leitura

Criar os objetos somente leitura leva algum tempo, então o V8 os cria em tempo de compilação. Para compilar o V8, primeiro um binário proto-V8 mínimo chamado mksnapshot é compilado. Ele cria todos os objetos compartilhados somente leitura, bem como o código nativo de funções integradas, e os escreve em um snapshot. Então, o binário real do V8 é compilado e incorporado ao snapshot. Para iniciar o V8, o snapshot é carregado na memória e podemos imediatamente começar a usar seu conteúdo. O diagrama a seguir mostra o processo de compilação simplificado para o binário independente d8.

Uma vez que d8 está em execução, todos os objetos somente leitura têm seu lugar fixo na memória e nunca se movem. Quando compilamos código JIT, podemos, por exemplo, referir-nos diretamente ao undefined pelo seu endereço. Contudo, ao construir o snapshot e ao compilar o C++ para libv8, o endereço ainda não é conhecido. Ele depende de duas coisas desconhecidas em tempo de formação. Primeiro, o layout binário do heap somente leitura e, segundo, onde no espaço de memória esse heap somente leitura está localizado.

Como Prever Endereços?

O V8 usa compressão de ponteiros. Em vez de endereços de 64 bits completos, nos referimos a objetos por um deslocamento de 32 bits em uma região de memória de 4GB. Para muitas operações, como carregamentos de propriedades ou comparações, o deslocamento de 32 bits dentro dessa área é tudo que é necessário para identificar exclusivamente um objeto. Portanto, nosso segundo problema — não saber onde, no espaço de memória, está localizado o heap somente leitura — não é realmente um problema. Nós simplesmente posicionamos o heap somente leitura no início de cada área de compressão de ponteiro, atribuindo-lhe um local conhecido. Por exemplo, de todos os objetos no heap do V8, undefined sempre tem o menor endereço compactado, começando em 0x61 bytes. É assim que sabemos que se os 32 bits inferiores do endereço completo de qualquer objeto JS forem 0x61, então, ele deve ser undefined.

Isso já é útil, mas queremos poder usar esse endereço no snapshot e no libv8 – um problema aparentemente circular. Contudo, se garantirmos que mksnapshot cria deterministicamente um heap somente leitura idêntico, então podemos reutilizar esses endereços em diferentes compilações. Para usá-los no próprio libv8, basicamente construímos o V8 duas vezes:

Na primeira vez em que chamamos mksnapshot, o único artefato produzido é um arquivo que contém os endereços relativos à base da área de cada objeto no heap somente leitura. Na segunda etapa da compilação, compilamos libv8 novamente e uma flag garante que sempre que nos referirmos ao undefined, usamos literalmente cage_base + StaticRoot::kUndefined; o deslocamento estático de undefined, é claro, sendo definido no arquivo static-roots.h. Em muitos casos, isso permitirá que o compilador C++ criando libv8 e o compilador de funções integradas em mksnapshot gerem código muito mais eficiente, já que a alternativa é sempre carregar o endereço de um array global de objetos raízes. Acabamos com um binário d8 onde o endereço compacto de undefined é codificado de forma rígida para ser 0x61.

Bom, moralmente é assim que tudo funciona, mas na prática só construímos o V8 uma vez – ninguém tem tempo para isso. O arquivo static-roots.h gerado é armazenado em cache no repositório de código fonte e só precisa ser recriado se alterarmos o layout do heap somente leitura.

Aplicações Adicionais

Falando de questões práticas, raízes estáticas permitem ainda mais otimizações. Por exemplo, desde então, agrupamos objetos comuns juntos, permitindo-nos implementar algumas operações como verificações de intervalo sobre seus endereços. Por exemplo, todos os mapas de strings (ou seja, os objetos meta hidden-class que descrevem o layout de diferentes tipos de strings) estão próximos uns dos outros, portanto um objeto é uma string se seu mapa tem um endereço comprimido entre 0xdd e 0x49d. Ou, objetos verdadeiros devem ter um endereço que seja no mínimo 0xc1.

Nem tudo diz respeito ao desempenho do código JITed no V8. Como este projeto mostrou, uma mudança relativamente pequena no código C++ pode ter um impacto significativo também. Por exemplo, o Speedometer 2, um benchmark que exercita a API V8 e a interação entre o V8 e seu incorporador, ganhou cerca de 1% na pontuação em uma CPU M1 graças às raízes estáticas.