O material, cuja tradução publicamos hoje, foi preparado por Matthias Binens e Benedict Meirer. Eles estão trabalhando no mecanismo
V8 JS no Google. Este artigo é dedicado a alguns mecanismos básicos característicos não apenas para o V8, mas também para outros mecanismos. A familiaridade com a estrutura interna desses mecanismos permite que os envolvidos no desenvolvimento do JavaScript naveguem melhor pelos problemas de desempenho do código. Em particular, falaremos sobre os recursos dos pipelines de otimização de mecanismo e como acelerar o acesso às propriedades dos protótipos de objetos.

Níveis de otimização de código e trade-offs
O processo de conversão dos textos dos programas escritos em JavaScript em código adequado para execução parece aproximadamente o mesmo em diferentes mecanismos.
O processo de conversão do código JS de origem em código executávelDetalhes podem ser encontrados
aqui . Além disso, deve-se observar que, embora em um nível alto, os pipelines para converter o código-fonte em executável sejam muito semelhantes para diferentes mecanismos, seus sistemas de otimização de código geralmente diferem. Por que isso é assim? Por que alguns mecanismos têm mais níveis de otimização do que outros? Acontece que os mecanismos precisam comprometer de uma maneira ou de outra, o que consiste no fato de que eles podem gerar rapidamente um código que não é o mais eficiente, mas adequado para execução, ou gastar mais tempo criando esse código, mas, devido a isso, alcançar um desempenho ideal.
Preparação rápida de código para execução e código otimizado que leva mais tempo, mas corre mais rápidoO intérprete é capaz de gerar rapidamente código de bytes, mas esse código geralmente não é muito eficiente. O compilador de otimização, por outro lado, precisa de mais tempo para gerar o código, mas no final ele fica otimizado, com um código de máquina mais rápido.
É esse modelo de preparação de código para execução que é usado na V8. O intérprete V8 é chamado Ignition, é o mais rápido dos intérpretes existentes (em termos de execução do bytecode de origem). O compilador V8 otimizado é chamado TurboFan, responsável pela criação de código de máquina altamente otimizado.
Intérprete de ignição e compilador de otimização TurboFanA troca entre o atraso no início do programa e a velocidade de execução é o motivo pelo qual alguns mecanismos JS possuem níveis adicionais de otimização. Por exemplo, no SpiderMonkey, entre o intérprete e o compilador de otimização IonMonkey, há um nível intermediário representado pelo compilador base (é chamado de "O Compilador de Linha de Base" na
documentação do Mozilla, mas "linha de base" não é um nome adequado).
Níveis de otimização de código SpiderMonkeyO intérprete gera rapidamente o bytecode, mas esse código é executado de forma relativamente lenta. O compilador base leva mais tempo para gerar o código, mas esse código já é mais rápido. Finalmente, o compilador IonMonkey otimizado leva mais tempo para gerar o código da máquina, mas esse código pode ser executado com muita eficiência.
Vamos dar uma olhada em um exemplo específico e ver como os pipelines de vários mecanismos lidam com o código. No exemplo apresentado aqui, há um loop "quente" contendo código que se repete tantas vezes.
let result = 0; for (let i = 0; i < 4242424242; ++i) { result += i; } console.log(result);
V8 começa a executar o bytecode no intérprete do Ignition. Em algum momento, o mecanismo descobre que o código está “quente” e inicia o front-end do TurboFan, que faz parte do TurboFan trabalhando com dados de criação de perfil e criando uma representação básica da máquina do código. Os dados são então passados para o otimizador TurboFan, operando em um fluxo separado, para melhorias adicionais.
Otimização de Hot Code na V8Durante a otimização, o V8 continua executando o bytecode no Ignition. Quando o otimizador é concluído, temos um código de máquina executável que pode ser usado no futuro.
O mecanismo SpiderMonkey também começa a executar o bytecode no intérprete. Mas ele tem um nível adicional representado pelo compilador básico, o que leva ao fato de que o código "quente" chega primeiro a esse compilador. Ele gera o código base no thread principal, a transição para a execução desse código é feita quando estiver pronta.
Otimização de código quente no SpiderMonkeySe o código base for longo o suficiente, o SpiderMonkey lançará o front end e o otimizador do IonMonkey, que é muito semelhante ao que acontece na V8. O código base continua sendo executado como parte do processo de otimização de código realizado pelo IonMonkey. Como resultado, quando a otimização é concluída, o código otimizado é executado em vez do código base.
A arquitetura do mecanismo do Chakra é muito semelhante à arquitetura do SpiderMonkey, mas o Chakra busca um nível mais alto de simultaneidade para evitar o bloqueio do thread principal. Em vez de resolver qualquer tarefa de compilação no encadeamento principal, o Chakra copia e envia os dados do bytecode e de perfil que o compilador provavelmente precisará em um processo de compilação separado.
Otimização de código quente no ChakraQuando o código gerado preparado pelo SimpleJIT estiver pronto, o mecanismo o executará em vez do bytecode. Este processo é repetido para prosseguir com a execução do código preparado pelo FullJIT. A vantagem dessa abordagem é que as pausas associadas à cópia de dados geralmente são muito mais curtas que as causadas pela operação de um compilador de pleno direito (front-end). No entanto, o ponto negativo dessa abordagem é o fato de que os algoritmos de cópia heurística podem perder algumas informações que podem ser úteis para algum tipo de otimização. Aqui vemos um exemplo de compromisso entre a qualidade do código recebido e os atrasos.
No JavaScriptCore, todas as tarefas de compilação de otimização são executadas em paralelo com o encadeamento principal responsável pela execução do código JavaScript. No entanto, não há estágio de cópia. Em vez disso, o thread principal simplesmente chama tarefas de compilação em outro thread. O compilador, em seguida, usa um esquema de bloqueio complexo para acessar dados de criação de perfil do thread principal.
Otimização de código "quente" em JavaScriptCoreA vantagem dessa abordagem é que ela reduz o bloqueio forçado do encadeamento principal causado pelo fato de ele executar tarefas de otimização de código. As desvantagens dessa arquitetura são que sua implementação requer a solução de tarefas complexas de processamento de dados multiencadeados e que, no decorrer do trabalho, para executar várias operações, é necessário recorrer a bloqueios.
Acabamos de discutir as compensações que os mecanismos são obrigados a fazer, escolhendo entre a geração rápida de código usando intérpretes e a criação de código rápido usando otimizadores de compilação. No entanto, estes estão longe de todos os problemas que os motores enfrentam. A memória é outro recurso do sistema ao usar o qual você precisa recorrer para comprometer as soluções. Para demonstrar isso, considere um programa JS simples que adicione números.
function add(x, y) { return x + y; } add(1, 2);
Aqui está o bytecode da função
add
gerada pelo interpretador Ignition na V8:
StackCheck Ldar a1 Add a0, [0] Return
Você não pode entender o significado deste bytecode; de fato, seu conteúdo não é de particular interesse para nós. O principal aqui é que ele tem apenas quatro instruções.
Quando esse código está "quente", o TurboFan é utilizado, o que gera o seguinte código de máquina altamente otimizado:
leaq rcx,[rip+0x0] movq rcx,[rcx-0x37] testb [rcx+0xf],0x1 jnz CompileLazyDeoptimizedCode push rbp movq rbp,rsp push rsi push rdi cmpq rsp,[r13+0xe88] jna StackOverflow movq rax,[rbp+0x18] test al,0x1 jnz Deoptimize movq rbx,[rbp+0x10] testb rbx,0x1 jnz Deoptimize movq rdx,rbx shrq rdx, 32 movq rcx,rax shrq rcx, 32 addl rdx,rcx jo Deoptimize shlq rdx, 32 movq rax,rdx movq rsp,rbp pop rbp ret 0x18
Como você pode ver, o volume do código, em comparação com o exemplo acima de quatro instruções, é muito grande. Normalmente, o bytecode é muito mais compacto que o código da máquina e, em particular, o código da máquina otimizado. Por outro lado, é necessário um intérprete para executar o bytecode e o código otimizado pode ser executado diretamente no processador.
Essa é uma das principais razões pelas quais os mecanismos JavaScript não otimizam absolutamente todo o código. Como vimos anteriormente, a criação de código de máquina otimizado leva muito tempo e, além disso, como acabamos de descobrir, é preciso mais memória para armazenar o código de máquina otimizado.
Nível de otimização e uso de memóriaComo resultado, podemos dizer que a razão pela qual os mecanismos JS têm diferentes níveis de otimização é o problema fundamental de escolher entre geração rápida de código, por exemplo, usando um intérprete e geração rápida de código, que são executadas por meio do compilador de otimização. Se falarmos sobre os níveis de otimização de código usados nos mecanismos, quanto mais houver, mais otimizações sutis poderão ser sujeitas ao código, mas isso é alcançado devido à complexidade dos mecanismos e à carga adicional no sistema. Além disso, aqui não devemos esquecer que o nível de otimização do código afeta a quantidade de memória que esse código ocupa. É por isso que os mecanismos JS tentam otimizar apenas as funções "quentes".
Otimização do acesso às propriedades do protótipo de objeto
Os mecanismos JavaScript otimizam o acesso às propriedades do objeto através do uso dos chamados formulários de objeto (Shape) e caches inline (cache embutido, IC). Detalhes sobre isso podem ser lidos
neste material, mas, para resumir, podemos dizer que o mecanismo armazena a forma do objeto separadamente dos valores do objeto.
Objetos que têm a mesma formaO uso de formas de objetos possibilita a otimização denominada cache embutido. O uso conjunto de formulários de objetos e caches em linha permite acelerar as operações repetidas de acesso às propriedades dos objetos, realizadas no mesmo local no código.
Acelerando o acesso a uma propriedade de objetoClasses e protótipos
Agora que sabemos como acelerar o acesso às propriedades do objeto em JavaScript, dê uma olhada em uma das recentes inovações em JavaScript - classes. Aqui está a aparência da declaração de classe:
class Bar { constructor(x) { this.x = x; } getX() { return this.x; } }
Embora possa parecer com a aparência em JS de um conceito completamente novo, as classes são na verdade apenas açúcar sintático para o sistema de protótipo para construção de objetos, que sempre esteve presente no JavaScript:
function Bar(x) { this.x = x; } Bar.prototype.getX = function getX() { return this.x; };
Aqui, escrevemos a função na propriedade
getX
objeto
getX
. Essa operação funciona exatamente da mesma maneira que na criação das propriedades de qualquer outro objeto, pois os protótipos no JavaScript são objetos. Em idiomas baseados no uso de protótipos, como JavaScript, métodos que podem ser compartilhados por todos os objetos de um determinado tipo são armazenados em protótipos, e os campos de objetos individuais são armazenados em suas instâncias.
Vejamos o que acontece, por assim dizer, nos bastidores quando criamos uma nova instância do objeto
Bar
, atribuindo-o ao constante
foo
.
const foo = new Bar(true);
Após executar esse código, a instância do objeto criado aqui terá um formulário contendo uma única propriedade
x
. O protótipo do objeto
foo
é
Bar.prototype
, que pertence à classe
Bar
.
Objeto e seu protótipoBar.prototype
possui sua própria forma, contendo uma única propriedade
getX
cujo valor é uma função que, quando chamada, retorna o valor
this.x
O protótipo
Bar.prototype
é
Object.prototype
, que faz parte do idioma.
Object.prototype
é o elemento raiz da árvore de protótipos, portanto, seu protótipo é
null
.
Agora vamos ver o que acontece se você criar outro objeto do tipo
Bar
.
Vários objetos do mesmo tipoComo você pode ver, tanto o objeto
foo
quanto o objeto
qux
, que são instâncias da classe
Bar
, como já dissemos, usam a mesma forma do objeto. Ambos usam o mesmo protótipo - o objeto
Bar.prototype
.
Acessar propriedades do protótipo
Então agora sabemos o que acontece quando declaramos uma nova classe e a instanciamos. E a chamada para o método do objeto? Considere o seguinte snippet de código:
class Bar { constructor(x) { this.x = x; } getX() { return this.x; } } const foo = new Bar(true); const x = foo.getX();
Uma chamada de método pode ser entendida como uma operação que consiste em duas etapas:
const x = foo.getX(); // : const $getX = foo.getX; const x = $getX.call(foo);
Na primeira etapa, o método é carregado, que é apenas uma propriedade do protótipo (cujo valor é a função). Na segunda etapa, uma função é chamada com
this
conjunto. Considere a primeira etapa no carregamento do método
getX
partir do objeto
foo
:
Carregando o método getX do objeto fooO mecanismo analisa o objeto
foo
e descobre que não há propriedade
getX
na forma do objeto
foo
. Isso significa que o mecanismo precisa examinar a cadeia de protótipos do objeto para encontrar esse método. O mecanismo acessa o protótipo
Bar.prototype
e examina a forma do objeto desse protótipo. Lá, ele encontra a propriedade desejada no deslocamento 0. Em seguida, o valor armazenado nesse deslocamento no
Bar.prototype
, o
JSFunction
getX
é detectado lá - e é exatamente isso que estamos procurando. Isso completa a busca pelo método.
A flexibilidade do JavaScript torna possível alterar as cadeias de protótipos. Por exemplo, assim:
const foo = new Bar(true); foo.getX(); // true Object.setPrototypeOf(foo, null); foo.getX(); // Uncaught TypeError: foo.getX is not a function
Neste exemplo, chamamos o método
foo.getX()
duas vezes, mas cada uma dessas chamadas tem um significado e resultado completamente diferentes. É por isso que, embora os protótipos JavaScript sejam apenas objetos, acelerar o acesso às propriedades do protótipo é ainda mais difícil para os mecanismos JS do que acelerar o acesso às suas próprias propriedades de objetos comuns.
Se olharmos para os programas da vida real, verifica-se que o carregamento das propriedades do protótipo é uma operação muito comum. É executado toda vez que um método é chamado.
class Bar { constructor(x) { this.x = x; } getX() { return this.x; } } const foo = new Bar(true); const x = foo.getX();
Anteriormente, falamos sobre como os mecanismos otimizam o carregamento de propriedades regulares e personalizadas de objetos através do uso de formulários de objetos e caches embutidos. Como otimizar o carregamento repetido de propriedades de protótipo para objetos com a mesma forma? Acima, vimos como as propriedades são carregadas.
Carregando o método getX do objeto fooPara acelerar o acesso ao método com chamadas repetidas, no nosso caso, você precisa saber o seguinte:
- A forma do objeto
foo
não contém o método getX
e não muda. Isso significa que o objeto foo
não é modificado adicionando propriedades a ele ou excluindo-as ou alterando os atributos das propriedades. - O protótipo
foo
ainda é o protótipo Bar.prototype
. original. Isso significa que o protótipo foo
não muda usando o método Object.setPrototypeOf()
ou atribuindo um novo protótipo à propriedade _proto_
especial. - O formulário
Bar.prototype
contém getX
e não é alterado. Ou seja, Bar.prototype
não Bar.prototype
alterado excluindo propriedades, adicionando-as ou alterando seus atributos.
No caso geral, isso significa que precisamos fazer 1 verificação do próprio objeto e 2 verificações para cada protótipo até o protótipo que armazena a propriedade que estamos procurando. Ou seja, é necessário realizar verificações 1 + 2N (onde N é o número de protótipos testados), que neste caso não parece tão ruim, pois a cadeia de protótipos é bastante curta. No entanto, os motores geralmente precisam trabalhar com cadeias de protótipos muito mais longas. Por exemplo, isso é típico de elementos DOM comuns. Aqui está um exemplo:
const anchor = document.createElement('a');
Aqui temos
HTMLAnchorElement
e chamamos seu método
getAttribute()
. A cadeia de protótipos deste elemento simples que representa um link HTML inclui 6 protótipos! Os métodos DOM mais interessantes não estão em seu próprio protótipo
HTMLAnchorElement
. Eles estão em protótipos localizados mais abaixo na cadeia.
Cadeia de protótipoO método
getAttribute()
pode ser encontrado em
Element.prototype
. Isso significa que sempre que o método
anchor.getAttribute()
é
anchor.getAttribute()
, o mecanismo é forçado a executar as seguintes ações:
- Verifica o próprio objeto
anchor
quanto a getAttribute
. - Verificando se o protótipo direto do objeto é
HTMLAnchorElement.prototype
. - Descobrindo que
HTMLAnchorElement.prototype
não possui um método getAttribute
. - Verificando se o próximo protótipo é
HTMLElement.prototype
. - Descobrindo que não há método necessário aqui.
- Finalmente, descobrindo que o próximo protótipo é
Element.prototype
. - Descobrindo que existe um método
getAttribute
.
Como você pode ver, 7 verificações são realizadas aqui. Como esse código é muito comum na programação da Web, os mecanismos usam otimizações para reduzir o número de verificações necessárias para carregar as propriedades do protótipo.
Se retornarmos a um dos exemplos anteriores, podemos lembrar que, quando chamamos o método
getX
do objeto
getX
, realizamos 3 verificações:
class Bar { constructor(x) { this.x = x; } getX() { return this.x; } } const foo = new Bar(true); const $getX = foo.getX;
Para cada objeto que está na cadeia de protótipos, até o que contém a propriedade desejada, precisamos verificar a forma do objeto apenas para descobrir a ausência do que estamos procurando. Seria bom se pudéssemos reduzir o número de verificações, reduzindo a verificação do protótipo para verificar a presença ou ausência do que estamos procurando. É isso que o mecanismo faz com uma simples jogada: em vez de armazenar o link do protótipo na própria instância, o mecanismo o armazena na forma de um objeto.
Armazenamento de referência de protótipoCada formulário possui um link para um protótipo. Isso também significa que sempre que o protótipo
foo
muda, o mecanismo se move para a nova forma do objeto. Agora só precisamos verificar a forma do objeto quanto à presença de uma propriedade e cuidar da proteção do link do protótipo.
Graças a essa abordagem, podemos reduzir o número de verificações de 1 + 2N para 1 + N, o que acelerará o acesso às propriedades dos protótipos. No entanto, essas operações ainda exigem bastante recurso, pois existe uma relação linear entre o número e o comprimento da cadeia de protótipos. Os motores implementaram vários mecanismos destinados a garantir que o número de verificações não dependa do comprimento da cadeia de protótipos, expressa como constante. Isso é especialmente verdade em situações em que o carregamento da mesma propriedade é realizado várias vezes.
Propriedade ValidityCell
V8 refere-se às formas de protótipos especificamente para o propósito acima. Cada protótipo tem uma forma exclusiva que não é compartilhada com outros objetos (em particular, com outros protótipos) e cada um dos formulários de objeto de protótipo possui uma propriedade
ValidityCell
associada a eles.
Propriedade ValidityCellEsta propriedade é declarada inválida ao alterar o protótipo associado ao formulário ou qualquer protótipo sobreposto. Considere esse mecanismo em mais detalhes.
Para acelerar as operações seqüenciais de carregamento de propriedades dos protótipos, o V8 usa um cache embutido contendo quatro campos:
ValidityCell
,
Prototype
,
Shape
,
Offset
.
Campos de cache embutidoDurante o "aquecimento" do cache embutido na primeira vez em que o código é executado, o V8 lembra o deslocamento no qual a propriedade foi encontrada no protótipo, o protótipo no qual a propriedade foi encontrada (neste exemplo,
Bar.prototype
), a forma do objeto (neste caso,
foo
) e, além disso, um link para o atual parâmetro
ValidityCell
do protótipo imediato, um link para o qual está na forma de um objeto (nesse caso, também é
Bar.prototype
).
Na próxima vez que você acessar o cache embutido, o mecanismo precisará verificar a forma do objeto e do
ValidityCell
. Se o
ValidityCell
ainda for válido, o mecanismo poderá tirar vantagem direta do deslocamento salvo anteriormente no protótipo sem executar operações adicionais de pesquisa.
Quando o protótipo é alterado, um novo formulário é criado e a propriedade
ValidityCell
anterior é declarada inválida. Como resultado, na próxima vez que você tentar acessar o cache embutido, ele não trará nenhum benefício, o que leva a um desempenho ruim.
As consequências de mudar o protótipoSe retornarmos ao exemplo com o elemento DOM, isso significa que qualquer alteração, por exemplo, no protótipo de
Object.prototype
, levará não apenas a invalidar o cache embutido para o próprio
Object.prototype
, mas também para quaisquer protótipos localizados abaixo dele na cadeia de protótipos incluindo
EventTarget.prototype
,
Node.prototype
,
Element.prototype
e assim por diante, até
HTMLAnchorElement.prototype
.
Implicações da alteração do Object.prototypeDe fato, modificar o
Object.prototype
durante a execução do código significa causar sérios danos ao desempenho. Não faça isso.
Estudamos o acima com um exemplo. Suponha que tenhamos a classe
Bar
e a função
loadX
, que chama o método de objetos criados a partir da classe
Bar
. Chamamos a função
loadX
várias vezes, passando instâncias da mesma classe.
function loadX(bar) { return bar.getX(); // IC 'getX' `Bar`. } loadX(new Bar(true)); loadX(new Bar(false)); // IC `loadX` `ValidityCell` // `Bar.prototype`. Object.prototype.newMethod = y => y; // `ValidityCell` IC `loadX` // `Object.prototype` .
O cache
loadX
no
loadX
agora aponta para
ValidityCell
for
Bar.prototype
. , ,
Object.prototype
— JavaScript,
ValidityCell
, - , .
Object.prototype
— , - , . , :
Object.prototype.foo = function() { };
Object.prototype
, - , . , . - , . , « », , .
, , . .
Object.prototype
, , - .
, — , JS- - , . . , , . , , , .
Sumário
, JS- , , , -,
ValidityCell
, . JavaScript, , ( , , , ).
Caros leitores! , - , JS, ?
