Categorias

Como JavaScript consegue ser tão rápido? Uma explicação profunda

Originalmente, o JavaScript foi criado por Brendan Eich em apenas dez dias para a Netscape Communications. Era apenas um protótipo, que foi aperfeiçoado posteriormente, antes do lançamento. Com o nome provisório de LiveScript, a linguagem foi liberada para o público em setembro de 1995. Por questões de marketing, o nome foi trocado para JavaScript, para pegar carona na popularidade do Java, embora não houvesse uma relação direta entre as duas linguagens. A confusão reinou por anos, por causa do nome…

Mais de 25 anos depois, ninguém mais confunde JavaScript com Java. A linguagem que nasceu do lado do cliente, dentro dos navegadores, conquistou servidores e se tornou onipresente na internet. Uma de suas principais características, o grande motor para sua popularidade, é sua velocidade de execução (desde que o desenvolvedor tenha seguido as boas práticas, evidentemente). A velocidade do JavaScript hoje em dia é dada como certa, a tal ponto que nem nos perguntamos mais por que ela é assim.

Robin H. Hansen

Robin H. Hansen é um desenvolvedor especializado em Elm, Kotlin, Java, C#, Go e, claro, Javascript. Ele também é o criador da linguagem de programação Stabel. Em um artigo publicado na internet, ele traz uma análise profunda dos segredos embutidos no JavaScript e como a linguagem consegue obter uma performance difícil de ser igualada por outras soluções.

Com sua autorização, traduzimos e reproduzimos o artigo na íntegra:

“JavaScript é uma tecnologia impressionante. Não porque é particularmente bem projetado (não é). Não porque quase todos os dispositivos de consumo com acesso à Internet no mundo executaram um programa JavaScript. Em vez disso, o JavaScript é impressionante porque quase todos os recursos da linguagem tornam um pesadelo otimizar e, no entanto, é rápido.

Pense nisso. Não há informações de tipo. Cada objeto pode ganhar e perder propriedades ao longo da vida útil do programa. Existem seis (!) tipos diferentes de valores falsos, e cada número é um float de 64 bits. Como se isso não bastasse, espera-se que o JavaScript seja executado rapidamente, então você também não pode gastar muito tempo analisando e otimizando.

E, no entanto, o JavaScript é rápido.

Como isso pode ser?

Neste artigo, examinaremos mais de perto algumas técnicas que diferentes mecanismos JavaScript usam para obter um bom desempenho em tempo de execução. Tenha em mente que estou propositalmente deixando alguns detalhes de fora e simplificando as coisas. Não é um objetivo deste artigo que você aprenda exatamente como as coisas funcionam, mas que você entenda o suficiente para compreender a teoria por trás dos experimentos que realizaremos mais adiante nesta série.

O modelo de execução

Quando seu navegador baixa o JavaScript, sua principal prioridade é executá-lo o mais rápido possível. Ele faz isso traduzindo o código para bytecode, instruções de máquina virtual, que são então entregues a um interpretador, ou máquina virtual, que entende como executá-las.

Você pode questionar por que o navegador converteria JavaScript em instruções de máquina virtual em vez de instruções de máquina real. É uma boa pergunta. Na verdade, converter diretamente em instruções de máquina é o que o V8 (motor JavaScript do Chrome) costumava fazer até recentemente.

Uma máquina virtual para uma linguagem de programação específica geralmente é um destino de compilação mais fácil porque tem uma relação mais próxima com a linguagem de origem. Uma máquina real tem um conjunto de instruções muito mais genérico e, portanto, requer mais trabalho para traduzir a linguagem de programação para funcionar bem com essas instruções. Essa dificuldade significa que a compilação leva mais tempo, o que novamente significa que leva mais tempo para o JavaScript começar a ser executado.

Por exemplo, uma máquina virtual que entende JavaScript também provavelmente entenderá objetos JavaScript. Por causa disso, as instruções virtuais necessárias para executar uma instrução como object.x podem ser uma ou duas instruções. Uma máquina real, sem nenhuma compreensão de como os objetos JavaScript funcionam, precisará de muito mais instruções para descobrir onde .x reside na memória e como obtê-lo.

O problema com uma máquina virtual é que ela é virtual. Não existe. As instruções não podem ser executadas diretamente, mas devem ser interpretadas em tempo de execução. Interpretar o código sempre será mais lento do que executar o código diretamente.

Há uma troca aqui. Tempo de compilação mais rápido versus tempo de execução mais rápido. Em muitos casos, uma compilação mais rápida é uma boa compensação. É improvável que o usuário se importe se um único clique de botão leva 20 ou 40 milissegundos para ser executado, especialmente se o botão for pressionado apenas uma vez. Compilar o JavaScript rapidamente, mesmo que o código resultante seja mais lento para executar, permitirá que o usuário veja e interaja com a página mais rapidamente.

Existem situações que são computacionalmente caras. Coisas como jogos, destaque de sintaxe ou cálculo da string fizzbuzz de mil números. Nesses casos, o tempo combinado de compilação e execução de instruções de máquina provavelmente reduzirá o tempo total de execução. Então, como o JavaScript lida com esses tipos de situações?

Código quente

Sempre que o mecanismo JavaScript detecta que uma função está ativa (ou seja, executada muitas vezes), ele entrega essa função a um compilador de otimização. Este compilador traduz as instruções da máquina virtual em instruções da máquina real. Além disso, como a função já foi executada várias vezes, o compilador otimizador pode fazer várias suposições com base em execuções anteriores. Em outras palavras, ele pode realizar otimizações especulativas para tornar o código ainda mais rápido.

O que acontece se, mais tarde, essas especulações se revelarem erradas? O mecanismo JavaScript pode simplesmente excluir a função otimizada, mas errada, e voltar a usar a versão não otimizada. Uma vez que a função tenha sido executada várias vezes, ela pode tentar passá-la ao compilador de otimização novamente, desta vez com ainda mais informações que podem ser usadas para otimizações especulativas.

Agora que sabemos que funções executadas com frequência usam informações de execuções anteriores durante a otimização, a próxima coisa a explorar é que tipo de informação é essa.

Um problema de tradução

Quase tudo em JavaScript é um objeto. Infelizmente, objetos JavaScript são coisas complicadas para ensinar uma máquina a lidar. Vejamos o seguinte código:

function addFive(obj) {
    return obj.method() + 5;
}

Uma função é bastante simples de traduzir para instruções de máquina, assim como retornar de uma função. Mas uma máquina não sabe o que são objects, então como você traduziria acessando a propriedade method de obj?

Ajudaria saber como é o obj, mas em JavaScript nunca podemos ter certeza. Qualquer objeto pode ter uma propriedade method adicionada ou removida dele. Mesmo quando existe, não podemos realmente ter certeza se é uma função, muito menos o que a chamada retorna.

Vamos tentar traduzir o código acima para um subconjunto de JavaScript que não tenha objetos, para ter uma ideia de como pode ser a tradução para instruções de máquina.

Primeiro, precisamos de uma maneira de representar objetos. Também precisamos de uma maneira de recuperar valores de um. Arrays são triviais para suportar em código de máquina, então podemos usar uma representação como esta:

// um objeto como { method: function() {} }
// poderia ser representado assim :
// [ [ "method" ], // nome da propriedade
//   [ function() {} ] ] // valores da propriedade

function lookup(obj, name) {
  for (var i = 0; i < obj[0].length; i++) {
    if (obj[0][i] === name) return i;
  }

  return -1;
}

Com isso, podemos tentar fazer uma implementação ingênua de addFive :

function addFive(obj) {
  var propertyIndex = lookup(obj, "method");
  var property = propertyIndex < 0 
      ? undefined 
      : obj[1][propertyIndex];

  if (typeof(property) !== "function") {
      throw NotAFunction(obj, "method");
  }
  var callResult = property(/* this */ obj);
  return callResult + 5;
}

Claro, isso não funciona no caso em que obj.method() retorna algo diferente de um número, então precisamos ajustar um pouco a implementação:

function addFive(obj) {
  var propertyIndex = lookup(obj, "method");
  var property = propertyIndex < 0 
      ? undefined 
      : obj[1][propertyIndex];

  if (typeof(property) !== "function") {
      throw NotAFunction(obj, "method");
  }
  var callResult = property(/* this */ obj);
  if (typeof(callResult) === "string") {
      return stringConcat(callResult, "5");
  } else if (typeof(callResult !== "number") {
      throw NotANumber(callResult);
  }
  
  return callResult + 5;
}

Isso funcionaria, mas espero que seja aparente que esse código poderia pular algumas etapas (e, portanto, ser mais rápido) se pudéssemos saber com antecedência qual é a estrutura de obj e qual é o tipo de method.

Classes ocultas

Todos os principais mecanismos JavaScript acompanham a forma de um objeto de alguma forma. No Chrome, esse conceito é conhecido como classes ocultas. É o que chamaremos neste artigo também.

Vamos começar analisando o seguinte trecho de código:

var obj = {}; // objeto vazio
obj.x = 1; // a forma foi alterada para incluir a propriedade "x"
obj.toString = function() { return "TODO"; }; // configura mudanças
delete obj.x; // configura mudanças de novo

Se traduzíssemos isso para instruções de máquina, como acompanharíamos a forma do objeto à medida que novas propriedades fossem adicionadas e removidas? Se usarmos a ideia do exemplo anterior de representar objetos como arrays, pode ser algo assim:

var emptyObj__Class = [ 
  null, // Nenhuma classe oculta pai
  [],   // Nomes das propriedades
  []    // Tipos das propriedades
];

var obj = [ 
  emptyObj__Class, // Classe oculta de `obj`
  []               // Valores das propriedades
];

var obj_X__Class = [ 
  emptyObj__Class, // Contém as mesmas propriedades do objeto vazio
  ["x"],           // Assim como uma propriedade chamada `x`
  ["number"]       // Em que `x` é um número
];

obj[0] = obj_X__Class; // A forma muda
obj[1].push(1);        // valor de `x`

var obj_X_ToString__Class = [
  obj_X__Class, // Contém as mesmas propriedades da forma anterior
  ["toString"], // E uma propriedade chamada `toString`
  ["function"]  // Em que `toString` é uma função
];

obj[0] = obj_X_ToString__Class;             // A forma muda
obj[1].push(function() { return "TODO"; }); // valor `toString` 

var obj_ToString__Class = [
  null, // Começando do zero ao excluir `x`
  ["toString"], 
  ["function"] 
];

obj[0] = obj_ToString__Class;
obj[1] = [obj[1][1]];

Se fôssemos gerar instruções de máquina virtual como esta, agora teríamos uma maneira de rastrear a aparência de um objeto a qualquer momento. No entanto, isso por si só não nos ajuda muito. Precisamos armazenar essas informações em algum lugar onde elas sejam valiosas.

Caches embutidos

Sempre que o código JavaScript executa o acesso à propriedade em um objeto, o mecanismo JavaScript armazena a classe oculta desse objeto, bem como o resultado da pesquisa (o mapeamento do nome da propriedade para o índice) em um cache. Esses caches são conhecidos como caches embutidos e servem a dois propósitos importantes:

  • Ao executar bytecode, eles aceleram o acesso à propriedade caso o objeto envolvido tenha uma classe oculta que esteja no cache.
  • Durante a otimização, eles contêm informações sobre quais tipos de objetos foram envolvidos ao acessar uma propriedade de objeto, o que ajuda o compilador otimizador a gerar código especialmente adequado para esses tipos.

Os caches embutidos têm um limite de quantas classes ocultas armazenam informações. Isso preserva a memória, mas também garante que a execução de pesquisas no cache seja rápida. Se a recuperação de um índice do cache embutido demorar mais do que a recuperação do índice da classe oculta, o cache não serve para nada.

Pelo que posso dizer, os caches embutidos acompanharão no máximo 4 classes ocultas, pelo menos no Chrome. Depois disso, o cache embutido será desabilitado e as informações serão armazenadas em um cache global. O cache global também é limitado em tamanho e, assim que atingir seu limite, as entradas mais recentes substituirão as mais antigas.

Para melhor utilizar os caches embutidos e ajudar na otimização do compilador, deve-se tentar escrever funções que apenas executem acesso a propriedades em objetos de um único tipo. Mais do que isso e o desempenho do código gerado será abaixo do ideal.

Inlining

Um tipo separado, mas significativo, de otimização é o inlining. Em resumo, essa otimização substitui uma chamada de função pela implementação da função chamada. Um exemplo:

function map(fn, list) {
    var newList = [];
    for (var i = 0; i < list.length; i++) {
        newList.push(fn(list[i]));
    }
    
    return newList;
}

function incrementNumbers(list) {
    return map(function(n) { return n + 1; }, list);
}

incrementNumbers([1, 2, 3]); // returns [2, 3, 4]

Após o inlining, o código pode ficar assim:

function incrementNumbers(list) {
    var newList = [];
    var fn = function(n) { return n + 1; };
    for (var i = 0; i < list.length; i++) {
        newList.push(fn(list[i]));
    }
    return newList;
}

incrementNumbers([1, 2, 3]); // returns [2, 3, 4]

Um benefício disso é que uma chamada de função foi removida. Um benefício ainda maior é que o mecanismo JavaScript agora tem ainda mais informações sobre o que a função realmente faz. Com base nessa nova versão, o mecanismo JavaScript pode decidir executar o inlining novamente:

function incrementNumbers(list) {
    var newList = [];
    for (var i = 0; i < list.length; i++) {
        newList.push(list[i] + 1);
    }
	
    return newList;
}

incrementNumbers([1, 2, 3]); // returns [2, 3, 4]

Outra chamada de função foi removida. Além disso, o otimizador pode agora especular que incrementNumbers só é chamado com uma lista de números como argumento. Ele também pode decidir inline a chamada incrementNumbers([1, 2, 3]) e descobrir que list.length é 3, o que novamente pode levar a:

var list = [1, 2, 3];
var newList = [];
newList.push(list[0] + 1);
newList.push(list[1] + 1);
newList.push(list[2] + 1);
list = newList;

Resumindo, o inlining permite otimizações que não seriam possíveis de realizar além dos limites da função.

Há limites para o que pode ser embutido, no entanto. Inlining pode levar a funções maiores devido à duplicação de código, que requer memória adicional. O mecanismo JavaScript tem um limite sobre o tamanho que uma função pode obter antes de pular completamente o inlining.

Algumas chamadas de função também são difíceis de serem incorporadas. Particularmente quando uma função é passada como um argumento.

Além disso, as funções passadas como argumentos podem ser difíceis de serem incorporadas, a menos que seja sempre a mesma função. Embora isso possa parecer uma coisa estranha de se fazer, pode acabar sendo o caso por causa do inlining.

Conclusão

Os mecanismos JavaScript têm muitos truques para melhorar o desempenho do tempo de execução, muito mais do que foi abordado aqui. No entanto, as otimizações descritas neste artigo se aplicam à maioria dos navegadores e são fáceis de verificar se estão sendo aplicadas. Por causa disso, vamos nos concentrar principalmente nessas otimizações quando tentarmos melhorar o desempenho do tempo de execução do Elm.

Mas antes de começarmos a tentar otimizar qualquer coisa, precisamos de uma forma de identificar qual código pode ser melhorado. As ferramentas que nos dão esta informação são o tema do próximo artigo.

Outras referências

Não sou o primeiro a tentar explicar como os mecanismos JavaScript funcionam. Aqui estão alguns artigos mais aprofundados e que têm uma maneira diferente de explicar conceitos semelhantes:

Publicado originalmente como “How JavaScript engines achieve great performance” em 22 de novembro de 2021. Traduzido e republicado com autorização do autor.