Como aceleramos os stack traces do Chrome DevTools em 10 vezes

Benedikt Meurer
Benedikt Meurer

Os desenvolvedores da web esperam pouco ou nenhum impacto no desempenho ao depurar seu código. No entanto, essa expectativa não é universal. Um desenvolvedor em C++ nunca esperaria que uma versão de depuração do aplicativo alcançasse o desempenho de produção e, nos primeiros anos do Chrome, simplesmente abrir o DevTools impactava significativamente o desempenho da página.

Essa queda na performance é resultado de anos de investimento em recursos de depuração do DevTools e do V8. No entanto, nunca vamos conseguir reduzir a sobrecarga de desempenho do DevTools para zero. Definir pontos de interrupção, percorrer o código, coletar stack traces, capturar um rastro de desempenho etc. afeta a velocidade de execução em níveis variados. Afinal, observar algo muda tudo.

Mas é claro que o overhead do DevTools, como qualquer depurador, é razoável. Recentemente, vimos um aumento significativo no número de relatórios que, em certos casos, o DevTools atrasava o aplicativo a ponto de não ser mais utilizável. Confira abaixo uma comparação lado a lado do relatório chromium:1069425, ilustrando a sobrecarga de desempenho de simplesmente abrir o DevTools.

Como é possível ver no vídeo, a lentidão está na ordem de 5 a 10x, o que claramente não é aceitável. A primeira etapa foi entender para onde o tempo todo vai e o que causa essa grande lentidão quando o DevTools estava aberto. O uso do Linux perf no processo do renderizador do Chrome revelou a seguinte distribuição do tempo de execução geral do renderizador:

Tempo de execução do renderizador do Chrome

Esperávamos que fosse algo relacionado à coleta de stack traces, mas não esperávamos que 90% do tempo total de execução fosse para simbolizar frames de pilha. Aqui, simbolização se refere ao ato de resolver nomes de função e posições de origem concretas (números de linha e coluna em scripts) de frames de pilha brutos.

Inferência de nome de método

O que foi ainda mais surpreendente foi o fato de que quase todo o tempo vai para a função JSStackFrame::GetMethodName() no V8. No entanto, sabíamos por investigações anteriores que JSStackFrame::GetMethodName() não é muito conhecido nos problemas de desempenho. Essa função tenta computar o nome do método para frames que são considerados invocações de método (frames que representam invocações de função da forma obj.func() em vez de func()). Uma análise rápida do código revelou que ele funciona realizando uma travessia completa do objeto e da cadeia de protótipos e procurando

  1. Propriedades de dados em que value é a interdição de func.
  2. Propriedades do acessador em que get ou set são iguais à interdição de func.

Embora isso não pareça muito barato, também não parece explicar essa desaceleração terrível. Começamos a analisar o exemplo relatado em chromium:1069425 e descobrimos que os stack traces foram coletados para tarefas assíncronas e para mensagens de registro originadas em classes.js, um arquivo JavaScript de 10 MiB. Uma análise mais detalhada revelou que era basicamente um tempo de execução Java mais código do aplicativo compilado em JavaScript. Os stack traces continham vários frames com métodos sendo invocados em um objeto A. Por isso, achamos que vale a pena entender com que tipo de objeto estamos lidando.

stack traces de um objeto

Aparentemente,o compilador Java para JavaScript gerou um único objeto com impressionantes 82.203 funções. Isso estava começando a se tornar interessante. Em seguida, voltamos ao JSStackFrame::GetMethodName() do V8 para entender se havia algum resultado fácil que poderíamos escolher.

  1. Ele procura primeiro o "name" da função como uma propriedade no objeto e, se for encontrado, verifica se o valor da propriedade corresponde à função.
  2. Se a função não tiver nome ou se o objeto não tiver uma propriedade correspondente, ela vai usar uma pesquisa reversa passando por todas as propriedades do objeto e dos protótipos dele.

No nosso exemplo, todas as funções são anônimas e têm propriedades "name" vazias.

A.SDV = function() {
   // ...
};

A primeira descoberta foi que a pesquisa reversa foi dividida em duas etapas (realizadas para o próprio objeto e cada objeto na cadeia de protótipos):

  1. Extrair os nomes de todas as propriedades enumeráveis e
  2. Faça uma pesquisa de propriedade genérica para cada nome, testando se o valor da propriedade resultante corresponde à interdição que estávamos procurando.

Isso parecia fácil, porque extrair os nomes exige já passar por todas as propriedades. Em vez de fazer as duas passagens, O(N) para a extração do nome e O(N log(N)) para os testes, poderíamos fazer tudo de uma só vez e verificar diretamente os valores das propriedades. Isso tornou toda a função de 2 a 10 vezes mais rápida.

A segunda descoberta foi ainda mais interessante. Embora as funções fossem tecnicamente anônimas, o mecanismo V8 registrava o que chamamos de nome inferido para elas. Para literais de função que aparecem no lado direito das atribuições no formato obj.foo = function() {...}, o analisador V8 memoriza "obj.foo" como o nome inferido para o literal de função. No nosso caso, isso significa que, embora não tivéssemos o nome próprio que poderíamos apenas pesquisar, tínhamos algo parecido o suficiente: para o exemplo de A.SDV = function() {...} acima, tínhamos o "A.SDV" como nome inferido, e poderíamos derivar o nome da propriedade do nome inferido procurando pelo último ponto e procurar a propriedade "SDV" no objeto. Isso funcionou em quase todos os casos, substituindo uma travessia completa cara por uma única pesquisa de propriedade. Essas duas melhorias foram feitas como parte deste CL e reduziram significativamente a lentidão do exemplo relatada em chromium:1069425.

Error.stack

Poderíamos ter chamado um dia aqui. Mas havia algo suspeito, já que o DevTools nunca usa o nome do método para os frames de pilha. Na verdade, a classe v8::StackFrame na API C++ não expõe uma maneira de chegar ao nome do método. Então parecia errado que acabamos chamando JSStackFrame::GetMethodName(). O único lugar em que usamos (e expomos) o nome do método é a API JavaScript stack trace. Para entender esse uso, considere este exemplo simples de error-methodname.js:

function foo() {
    console.log((new Error).stack);
}

var object = {bar: foo};
object.bar();

Aqui, temos uma função foo instalada com o nome "bar" em object. A execução do snippet no Chromium produz a seguinte saída:

Error
    at Object.foo [as bar] (error-methodname.js:2)
    at error-methodname.js:6

Aqui, vemos a pesquisa do nome do método em execução: o frame da pilha superior é mostrado para chamar a função foo em uma instância de Object usando o método bar. A propriedade não padrão error.stack faz uso intenso de JSStackFrame::GetMethodName(). Na verdade, nossos testes de desempenho também indicam que nossas mudanças tornaram tudo significativamente mais rápido.

Aceleração das microcomparações do StackTrace

Voltando ao Chrome DevTools, não parece certo que o nome do método seja calculado mesmo que error.stack não seja usado. Há uma história aqui que nos ajuda: tradicionalmente, o V8 tinha dois mecanismos distintos implementados para coletar e representar um stack trace para as duas APIs diferentes descritas acima (a API C++ v8::StackFrame e a API JavaScript stack trace). Ter duas maneiras diferentes de fazer (mais ou menos) o mesmo era propenso a erros e muitas vezes gerava inconsistências e bugs. Por isso, no final de 2018, iniciamos um projeto para definir um único gargalo para a captura de stack traces.

Esse projeto foi um grande sucesso e reduziu drasticamente o número de problemas relacionados à coleta de stack traces. A maioria das informações fornecidas pela propriedade error.stack não padrão também era calculada lentamente e somente quando era realmente necessária. No entanto, como parte da refatoração, aplicamos o mesmo truque a objetos v8::StackFrame. Todas as informações sobre o frame da pilha são computadas na primeira vez que um método é invocado nele.

Isso geralmente melhora o desempenho, mas, infelizmente, acabou sendo um pouco contrário à forma como esses objetos da API C++ são usados no Chromium e no DevTools. Desde que introduzimos uma nova classe v8::internal::StackFrameInfo, que continha todas as informações sobre um frame de pilha que foi exposto por v8::StackFrame ou por error.stack, sempre computamos o superconjunto das informações fornecidas pelas duas APIs, o que significava que, para usos de v8::StackFrame (e principalmente para DevTools), também calcularíamos o nome do método assim que qualquer informação sobre um frame de pilha fosse solicitada. Na verdade, o DevTools sempre solicita imediatamente as informações de origem e script.

Com base nisso, foi possível refatorar e simplificar drasticamente a representação de frames de pilha e torná-la ainda mais lenta, de modo que os usos no V8 e no Chromium agora paguem apenas o custo de computação das informações que eles pedem. Isso proporcionou um grande aumento de desempenho para o DevTools e outros casos de uso do Chromium, que só precisam de uma fração das informações sobre frames de pilha (basicamente apenas o nome do script e o local de origem na forma de deslocamento de linha e coluna) e abriu as portas para mais melhorias de desempenho.

Nomes de função

Com as refatorações mencionadas acima de fora, a sobrecarga de simbolização (o tempo gasto em v8_inspector::V8Debugger::symbolize) foi reduzida para cerca de 15% do tempo total de execução. É possível ver mais claramente onde o V8 estava gastando tempo ao coletar e simbolizar frames de pilha para consumo no DevTools.

Custo de simbolização

A primeira coisa que se destacou foi o custo cumulativo para calcular o número da linha e da coluna. A parte cara aqui é, na verdade, calcular o deslocamento de caracteres dentro do script (com base no deslocamento de bytecode que obtemos do V8). Descobrimos que, devido à refatoração acima, fizemos isso duas vezes: uma ao calcular o número da linha e outra ao computar o número da coluna. Armazenar a posição de origem em cache em instâncias de v8::internal::StackFrameInfo ajudou a resolver isso rapidamente e eliminou completamente v8::internal::StackFrameInfo::GetColumnNumber de qualquer perfil.

A constatação mais interessante para nós foi que o v8::StackFrame::GetFunctionName foi surpreendentemente alto em todos os perfis que analisamos. Ao investigar mais a fundo aqui, percebemos que era desnecessariamente caro computar o nome que mostraríamos para a função no frame da pilha no DevTools.

  1. primeiro procurando a propriedade "displayName" não padrão e, se isso gerar uma propriedade de dados com um valor de string, usaríamos isso,
  2. volte a procurar a propriedade "name" padrão e verifique novamente se isso gera uma propriedade de dados cujo valor é uma string,
  3. e, por fim, retornar a um nome de depuração interno que é inferido pelo analisador V8 e armazenado no literal da função.

A propriedade "displayName" foi adicionada como uma solução alternativa para a propriedade "name" em instâncias do Function que são somente leitura e não configuráveis em JavaScript.No entanto, ela nunca foi padronizada e não foi difundida para o uso, já que as ferramentas para desenvolvedores do navegador adicionaram uma inferência do nome da função que funciona em 99,9% dos casos. Além disso, o ES2015 tornou a propriedade "name" em instâncias Function configurável, eliminando completamente a necessidade de uma propriedade "displayName" especial. Como a pesquisa negativa de "displayName" é muito cara e não é realmente necessária (o ES2015 foi lançado há mais de cinco anos), decidimos remover o suporte à propriedade fn.displayName não padrão do V8 (e do DevTools).

Com a pesquisa negativa de "displayName" resolvida, metade do custo de v8::StackFrame::GetFunctionName foi removida. A outra metade vai para a pesquisa genérica de propriedade "name". Felizmente, já tínhamos uma lógica para evitar pesquisas custosas de propriedades "name" em instâncias de Function (inalteradas), que introduzimos no V8 há algum tempo para tornar o próprio Function.prototype.bind() mais rápido. Fizemos a portabilidade das verificações necessárias, que nos permitem pular a pesquisa genérica cara, com o resultado de que v8::StackFrame::GetFunctionName não aparece mais em nenhum perfil considerado.

Conclusão

Com as melhorias acima, reduzimos significativamente a sobrecarga do DevTools em termos de stack traces.

Sabemos que ainda há várias melhorias possíveis. Por exemplo, a sobrecarga ao usar MutationObservers ainda é perceptível, como relatado em chromium:1077657. Mas, por enquanto, abordamos os principais pontos problemáticos e podemos voltar no futuro para simplificar ainda mais o desempenho da depuração.

Fazer o download dos canais de visualização

Use o Chrome Canary, Dev ou Beta como navegador de desenvolvimento padrão. Esses canais de pré-visualização dão acesso aos recursos mais recentes do DevTools, testam as APIs de plataforma da Web modernas e encontram problemas no seu site antes que os usuários o encontrem.

Entrar em contato com a equipe do Chrome DevTools

Use as opções abaixo para discutir os novos recursos e mudanças na postagem ou qualquer outro assunto relacionado ao DevTools.

  • Envie uma sugestão ou feedback em crbug.com.
  • Informe um problema do DevTools em Mais opções   Mais   > Ajuda > Informar problemas no DevTools.
  • Publique no Twitter em @ChromeDevTools.
  • Deixe comentários nos vídeos do YouTube sobre o que há de novo ou nos vídeos do YouTube de dicas sobre o DevTools.