Como aceleramos os stack traces do Chrome DevTools em 10 vezes

Benedikt Meurer
Benedikt Meurer

Os desenvolvedores Web esperam pouco ou nenhum impacto no desempenho ao depurar seu código. No entanto, essa expectativa não é universal. Um desenvolvedor de C++ nunca esperaria que um build de depuração do aplicativo alcançasse a performance de produção. Além disso, nos primeiros anos do Chrome, apenas abrir o DevTools já afetava significativamente a performance da página.

Essa degradação do desempenho não é mais percebida como resultado de anos de investimento em recursos de depuração do DevTools e do V8. No entanto, nunca será possível reduzir o overhead de desempenho do DevTools a zero. Definir pontos de interrupção, percorrer o código, coletar stack traces, capturar um rastreamento de desempenho, entre outros, afetam a velocidade de execução de maneira variada. Afinal, observar algo muda o objeto.

Mas é claro que o overhead do DevTools, assim como qualquer outro depurador, é razoável. Recentemente, notamos um aumento significativo no número de relatórios de que, em alguns casos, as Ferramentas do desenvolvedor desaceleram o aplicativo a ponto de ele não ser mais utilizável. Confira abaixo uma comparação lado a lado do relatório chromium:1069425, que ilustra o overhead de desempenho de simplesmente abrir o DevTools.

Como você pode ver no vídeo, a desaceleração é de 5 a 10 vezes, o que não é aceitável. A primeira etapa foi entender para onde todo o tempo foi gasto e o que causou essa enorme 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

Embora esperássemos algo relacionado à coleta de rastros de pilha, não esperávamos que cerca de 90% do tempo de execução geral fosse usado para simbolizar frames de pilha. Aqui, simbolização se refere ao ato de resolver nomes de funções e posições de origem concretas (números de linhas e colunas em scripts) a partir 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, embora soubéssemos de investigações anteriores que JSStackFrame::GetMethodName() não é estranho no mundo dos problemas de desempenho. Essa função tenta calcular o nome do método para frames que são considerados invocações de método (frames que representam invocações de função do formulário 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ótipo dele e procurando

  1. propriedades de dados com value que são o fechamento de func ou
  2. propriedades do acessador em que get ou set é igual ao fechamento func.

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

stack traces de um objeto

Aparentemente, o compilador Java para JavaScript gerou um único objeto com 82.203 funções. Isso estava começando a ficar interessante. Em seguida, voltamos à JSStackFrame::GetMethodName() do V8 para entender se havia alguma fruta fácil de colher.

  1. Ele funciona primeiro procurando o "name" da função como uma propriedade no objeto e, se encontrado, verifica se o valor da propriedade corresponde à função.
  2. Se a função não tiver nome ou o objeto não tiver uma propriedade correspondente, ela usará uma pesquisa reversa 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 (realizada para o objeto em si e para cada objeto na cadeia de protótipos):

  1. Extrair os nomes de todas as propriedades enumeráveis e
  2. Executamos uma pesquisa de propriedade genérica para cada nome, testando se o valor da propriedade resultante corresponde ao fechamento que estávamos procurando.

Isso parecia uma tarefa fácil, já que extrair os nomes exige percorrer todas as propriedades. Em vez de fazer as duas passagens (O(N) para a extração de nome e O(N log(N)) para os testes), podemos fazer tudo em uma única passagem e verificar diretamente os valores da propriedade. Isso tornou a função toda 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 registrou o que chamamos de nome inferido para elas. Para literais de função que aparecem no lado direito das atribuições no formulário obj.foo = function() {...}, o analisador V8 memoriza "obj.foo" como nome inferido para o literal de função. Em nosso caso, isso significa que, embora não tivéssemos o nome próprio que poderíamos apenas procurar, tínhamos algo próximo o suficiente: para o exemplo A.SDV = function() {...} acima, tínhamos "A.SDV" como nome inferido e podemos derivar o nome da propriedade a partir do nome inferido procurando o último ponto e, em seguida, 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 lançadas como parte desta CL e reduziram significativamente a lentidão do exemplo informado em chromium:1069425.

Error.stack

Poderíamos ter encerrado o 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++ nem expõe uma maneira de acessar o nome do método. Então, pareceu errado acabar chamando JSStackFrame::GetMethodName(). Em vez disso, o único lugar em que usamos (e expomos) o nome do método é na API de stack trace do JavaScript. Para entender esse uso, considere o seguinte exemplo simples 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 desse snippet no Chromium gera a seguinte saída:

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

Aqui, vemos a pesquisa de nome de método em ação: o frame de pilha mais alto é mostrado para chamar a função foo em uma instância de Object pelo método bar. A propriedade error.stack não padrão usa muito JSStackFrame::GetMethodName(), e nossos testes de performance também indicam que as mudanças aceleraram as coisas significativamente.

Aceleração nos microcomparativos de StackTrace

Mas, voltando ao tópico das Ferramentas do desenvolvedor do Chrome, o fato de o nome do método ser computado mesmo que error.stack não seja usado não parece certo. Há um histórico que nos ajuda: tradicionalmente, o V8 tinha dois mecanismos distintos 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 (aproximadamente) a mesma coisa era propenso a erros e muitas vezes levava a inconsistências e bugs. Por isso, no final de 2018, iniciamos um projeto para estabelecer um único gargalo para a captura de stack trace.

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

Isso geralmente melhora o desempenho, mas, infelizmente, é um pouco contrário à forma como esses objetos da API C++ são usados no Chromium e no DevTools. Em particular, como introduzimos uma nova classe v8::internal::StackFrameInfo, que continha todas as informações sobre um frame de pilha exposto por v8::StackFrame ou error.stack, sempre calculamos o superconjunto das informações fornecidas por ambas as APIs, o que significa que, para usos de v8::StackFrame (e, em particular, para o DevTools), também calculamos o nome do método assim que qualquer informação sobre um frame de pilha é solicitada. As Ferramentas para desenvolvedores sempre solicitam informações de origem e script imediatamente.

Com base nessa constatação, foi possível refactorizar e simplificar drasticamente a representação do frame da pilha e torná-la ainda mais lenta, para que os usos no V8 e no Chromium agora paguem apenas o custo de computação das informações solicitadas. Isso aumentou muito a performance das DevTools e de outros casos de uso do Chromium, que precisam apenas de uma fração das informações sobre frames de pilha (essencialmente apenas o nome do script e o local da fonte na forma de deslocamento de linha e coluna) e abriu caminho para mais melhorias de desempenho.

Nomes de função

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

Custo de simbolização

A primeira coisa que se destacou foi o custo cumulativo para a linha de computação e o número de colunas. A parte mais demorada aqui é calcular o deslocamento de caracteres no script (com base no deslocamento de bytecode que recebemos do V8). Devido à refatoração acima, fizemos isso duas vezes, uma ao calcular o número da linha e outra ao calcular 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 todos os perfis.

A descoberta mais interessante para nós foi que a taxa de v8::StackFrame::GetFunctionName era surpreendentemente alta em todos os perfis que analisamos. Ao analisar mais a fundo, percebemos que era desnecessário calcular o nome que seria mostrado para a função no frame da pilha no DevTools.

  1. primeiro procurando a propriedade "displayName" não padrão e, se ela gerar uma propriedade de dados com um valor de string, vamos usar essa propriedade,
  2. Caso contrário, a busca pela propriedade "name" padrão e a verificação se ela gera uma propriedade de dados com um valor de string,
  3. e, por fim, retornando a um nome de depuração interno inferido pelo analisador V8 e armazenado no literal de função.

A propriedade "displayName" foi adicionada como uma solução alternativa para a propriedade "name" em instâncias Function que são somente leitura e não podem ser configuradas no JavaScript, mas nunca foi padronizada e não teve uso generalizado, já que as ferramentas para desenvolvedores de navegadores adicionaram a inferência de nome de função que faz o trabalho 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 para "displayName" é bastante custosa e não é realmente necessária (o ES2015 foi lançado há mais de cinco anos), decidimos remover o suporte à propriedade fn.displayName fora do padrão do V8 (e das DevTools).

Com a pesquisa negativa de "displayName" fora do caminho, metade do custo de v8::StackFrame::GetFunctionName foi removido. A outra metade vai para a pesquisa de propriedade genérica "name". Felizmente, já tínhamos uma lógica para evitar pesquisas caras da propriedade "name" em instâncias Function (não modificadas), que apresentamos no V8 há algum tempo para tornar o Function.prototype.bind() mais rápido. Portamos as verificações necessárias, o que nos permite pular a pesquisa genérica cara. Como resultado, v8::StackFrame::GetFunctionName não aparece mais em nenhum perfil que consideramos.

Conclusão

Com as melhorias acima, reduzimos significativamente a sobrecarga das Ferramentas do desenvolvedor em termos de rastros de pilha.

Sabemos que ainda há várias melhorias possíveis. Por exemplo, o overhead ao usar MutationObservers ainda é perceptível, conforme informado em chromium:1077657. No momento, abordamos os principais problemas e podemos voltar no futuro para simplificar ainda mais a performance de 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 visualização dão acesso aos recursos mais recentes do DevTools, permitem testar APIs de plataforma da Web de última geração e ajudam a encontrar problemas no seu site antes que os usuários.

Entre em contato com a equipe do Chrome DevTools

Use as opções a seguir para discutir os novos recursos, atualizações ou qualquer outra coisa relacionada ao DevTools.