Escrevendo código Javascript de alto desempenho sem ser otimizado


10

Ao escrever um código sensível ao desempenho em Javascript, que opera em grandes matrizes numéricas (pense em um pacote de álgebra linear, operando em números inteiros ou de ponto flutuante), sempre se deseja que o JIT ajude o máximo possível. Aproximadamente isso significa:

  1. Sempre queremos que nossas matrizes sejam SMIs compactadas (inteiros pequenos) ou Doubles compactadas, dependendo de estarmos fazendo cálculos de números inteiros ou de ponto flutuante.
  2. Sempre queremos passar o mesmo tipo de coisa para as funções, para que elas não sejam rotuladas como "megamórficas" e desoptimizadas. Por exemplo, sempre queremos ligar vec.add(x, y)com os dois xe yreceber pacotes de SMI, ou ambos, pacotes duplos.
  3. Queremos que as funções sejam incorporadas o máximo possível.

Quando alguém se desvia desses casos, ocorre uma queda súbita e drástica no desempenho. Isso pode acontecer por vários motivos inócuos:

  1. Você pode transformar uma matriz SMI compactada em uma matriz Double compactada por meio de uma operação aparentemente inócua, como o equivalente a myArray.map(x => -x). Este é realmente o "melhor" caso ruim, pois os arrays duplos compactados ainda são muito rápidos.
  2. Você pode transformar uma matriz compactada em uma matriz in a box genérica, por exemplo, mapeando a matriz sobre uma função que (inesperadamente) retornou nullou undefined. Este caso ruim é bastante fácil de evitar.
  3. Você pode otimizar toda uma função, como vec.add()passar muitos tipos de coisas e torná-la megamórfica. Isso pode acontecer se você quiser fazer "programação genérica", onde vec.add()é usado nos casos em que você não está sendo cuidadoso com os tipos (para que muitos tipos sejam recebidos) e nos casos em que você deseja obter o desempenho máximo (só deve receber duplos em caixas, por exemplo).

Minha pergunta é mais uma pergunta suave, sobre como se escreve código Javascript de alto desempenho à luz das considerações acima, mantendo o código agradável e legível. Algumas sub-perguntas específicas para que você saiba que tipo de resposta estou buscando:

  • Existe um conjunto de diretrizes em algum lugar sobre como programar enquanto permanece no mundo de matrizes SMI compactadas (por exemplo)?
  • É possível fazer programação genérica de alto desempenho em Javascript sem usar algo como um sistema de macro para incorporar coisas como vec.add()em callites?
  • Como modularizar códigos de alto desempenho em bibliotecas à luz de coisas como sites de chamadas megamórficos e desoptimizações? Por exemplo, se estou feliz em usar o pacote Álgebra Linear Aem alta velocidade e depois importo um pacote Bque depende A, mas o Bchama com outros tipos e o desotima, de repente (sem que meu código mude), meu código fica mais lento.
  • Existem boas ferramentas de medição fáceis de usar para verificar o que o mecanismo Javascript está fazendo internamente com os tipos?

11
Esse é um tópico muito interessante e um post muito bem escrito que mostra que você fez corretamente sua parte da pesquisa. No entanto, temo que a (s) pergunta (s) seja muito ampla para o formato SO e também que inevitavelmente atraia mais opiniões do que fatos. A otimização de código é uma questão muito complicada e duas versões de um mecanismo podem não se comportar da mesma maneira. Eu acho que há uma das pessoas responsáveis ​​pelo V8 JIT que fica por aí algumas vezes, então talvez elas possam dar uma resposta adequada para o seu motor, mas mesmo para eles, acho que seria um assunto muito amplo para uma única pergunta / resposta .
Kaiido 11/03

"Minha pergunta é mais uma pergunta suave, sobre como se escreve código Javascript de alto desempenho ..." Além disso, observe que o javascript fornece a geração de processos em segundo plano (trabalhadores da Web), e também existem bibliotecas que acessam a oferta de GPU (tensorflow.js e gpu.js) significa outros meios além de depender apenas da compilação para aumentar o rendimento computacional de um aplicativo baseado em javascript ...
Jon Trent

@ JonTrent Na verdade, eu menti um pouco no meu post, não me importo muito com aplicações clássicas de álgebra linear, mas mais com álgebra computacional do que com números inteiros. Isso significa que muitos pacotes numéricos existentes são descartados imediatamente, pois (por exemplo), ao reduzir a linha de uma matriz, eles podem dividir por 2, o que "não é permitido" no mundo em que estou trabalhando desde (1/2) não é um número inteiro. Eu considerei trabalhadores da Web (especialmente para alguns cálculos de longa duração que eu quero que sejam canceláveis), mas o problema que estou abordando aqui está diminuindo a latência o suficiente para responder à interação.
Joppy 12/03

Para aritmética de número inteiro no JavaScript, você provavelmente está olhando para o código no estilo asm.js., mais ou menos "colocando |0atrás de cada operação". Não é bonito, mas o melhor que você pode fazer em um idioma que não possui números inteiros adequados. Você também pode usar BigInts, mas, atualmente, eles não são muito rápidos em nenhum dos mecanismos comuns (principalmente devido à falta de demanda).
jmrk 12/03

Respostas:


8

Desenvolvedor V8 aqui. Dada a quantidade de interesse nessa questão e a falta de outras respostas, posso dar uma chance a isso; Receio que não seja a resposta que você esperava.

Existe um conjunto de diretrizes em algum lugar sobre como programar enquanto permanece no mundo de matrizes SMI compactadas (por exemplo)?

Resposta curta: é aqui mesmo: const guidelines = ["keep your integers small enough"].

Resposta mais longa: é difícil fornecer um conjunto abrangente de diretrizes por várias razões. Em geral, nossa opinião é que os desenvolvedores de JavaScript devem escrever um código que faça sentido para eles e seus casos de uso, e os desenvolvedores de mecanismos de JavaScript devem descobrir como executar esse código rapidamente em seus mecanismos. Por outro lado, obviamente existem algumas limitações a esse ideal, no sentido de que alguns padrões de codificação sempre terão custos de desempenho mais altos que outros, independentemente das escolhas de implementação do mecanismo e dos esforços de otimização.

Quando falamos sobre conselhos de desempenho, tentamos manter isso em mente e estimar cuidadosamente quais recomendações têm uma alta probabilidade de permanecer válidas em muitos mecanismos e muitos anos, além de serem razoavelmente idiomáticas / não intrusivas.

Voltando ao exemplo em questão: usar o Smis internamente deve ser um detalhe de implementação que o código do usuário não precisa conhecer. Isso tornará alguns casos mais eficientes e não deve prejudicar em outros casos. Nem todos os mecanismos usam Smis (por exemplo, o AFAIK Firefox / Spidermonkey historicamente não o fez; ouvi dizer que, em alguns casos, eles usam Smis atualmente; mas eu não conheço nenhum detalhe e não posso falar com nenhuma autoridade sobre a matéria). Na V8, o tamanho do Smis é um detalhe interno e vem mudando com o tempo e com as versões. Nas plataformas de 32 bits, que costumavam ser o caso de uso majoritário, os Smis sempre foram números inteiros assinados de 31 bits; em plataformas de 64 bits, eles costumavam ser números inteiros assinados de 32 bits, que recentemente pareciam o caso mais comum, até que no Chrome 80 enviamos "compressão de ponteiro" para arquiteturas de 64 bits, que exigiam a redução do tamanho Smi para 31 bits conhecidos em plataformas de 32 bits. Se você baseou uma implementação na suposição de que os Smis são tipicamente 32 bits, você obteria situações infelizes comoisso .

Felizmente, como você observou, as matrizes duplas ainda são muito rápidas. Para código numérico pesado, provavelmente faz sentido assumir / segmentar matrizes duplas. Dada a prevalência de duplas no JavaScript, é razoável supor que todos os mecanismos tenham bom suporte para duplas e matrizes duplas.

É possível fazer programação genérica de alto desempenho em Javascript sem usar algo como um sistema de macro para incorporar coisas como vec.add () nos callites?

"genérico" geralmente está em desacordo com "alto desempenho". Isso não está relacionado ao JavaScript ou a implementações de mecanismo específicas.

Código "genérico" significa que as decisões precisam ser tomadas em tempo de execução. Toda vez que você executa uma função, o código precisa ser executado para determinar, digamos, "é xum número inteiro? Em caso afirmativo, siga o caminho do código. É xuma string? Então pule para cá. É um objeto? Tem .valueOf? Não? Então?" talvez .toString()? Talvez em sua cadeia de protótipos? Chame isso e reinicie desde o início com o resultado ". O código otimizado de "alto desempenho" é essencialmente construído com a idéia de eliminar todas essas verificações dinâmicas; isso só é possível quando o mecanismo / compilador tem alguma maneira de inferir tipos antecipadamente: se ele pode provar (ou assumir com probabilidade alta o suficiente) que xsempre será um número inteiro, ele precisará gerar apenas um código para esse caso ( guardado por uma verificação de tipo, se houver suposições não comprovadas).

Inlining é ortogonal a tudo isso. Uma função "genérica" ​​ainda pode ser incorporada. Em alguns casos, o compilador pode ser capaz de propagar informações de tipo para a função incorporada para reduzir o polimorfismo.

(Para comparação: o C ++, sendo uma linguagem compilada estaticamente, possui modelos para resolver um problema relacionado. Em resumo, eles permitem que o programador instrua explicitamente o compilador a criar cópias especializadas de funções (ou classes inteiras), parametrizadas em determinados tipos. boa solução para alguns casos, mas não sem seu próprio conjunto de desvantagens, por exemplo, tempos de compilação longos e binários grandes.O JavaScript, é claro, não possui modelos. Você pode usar evalpara criar um sistema um pouco parecido, mas depois teria desvantagens semelhantes: você teria que fazer o equivalente ao trabalho do compilador C ++ em tempo de execução e se preocupar com a enorme quantidade de código que está gerando.)

Como modularizar um código de alto desempenho em bibliotecas à luz de coisas como sites de chamadas megamórficos e desoptimizações? Por exemplo, se estou felizmente usando o pacote A de Álgebra Linear em alta velocidade e importando um pacote B que depende de A, mas B o chama com outros tipos e o desotima, de repente (sem que meu código seja alterado), meu código fica mais lento .

Sim, esse é um problema geral com JavaScript. A V8 costumava implementar Array.sortinternamente certos componentes internos (coisas como ) no JavaScript, e esse problema (que chamamos de "poluição por feedback de tipo") foi um dos principais motivos pelos quais nos afastamos completamente dessa técnica.

Dito isto, para código numérico, não existem tantos tipos (apenas Smis e duplos) e, como você observou, eles devem ter desempenho semelhante na prática, portanto, embora a poluição por feedback de tipo seja realmente uma preocupação teórica, e em alguns casos pode tiver um impacto significativo, também é bastante provável que, em cenários de álgebra linear, você não veja uma diferença mensurável.

Além disso, dentro do mecanismo, existem muitas outras situações além de "um tipo == rápido" e "mais de um tipo == lento". Se uma determinada operação já viu Smis e dobra, tudo bem. Carregar elementos de dois tipos de matrizes também é bom. Usamos o termo "megamórfico" para a situação em que uma carga vê tantos tipos diferentes que é abandonada por rastreá-los individualmente e, em vez disso, usa um mecanismo mais genérico que se adapta melhor a um grande número de tipos - uma função que contém essas cargas pode ainda seja otimizado. Uma "desoptimização" é o ato muito específico de ter que jogar fora o código otimizado para uma função, porque é visto um novo tipo que não foi visto anteriormente e, portanto, o código otimizado não está equipado para lidar com isso. Mas mesmo isso é bom: basta voltar ao código não otimizado para coletar mais comentários sobre o tipo e otimizar novamente mais tarde. Se isso acontecer algumas vezes, não há com que se preocupar; só se torna um problema em casos patologicamente ruins.

Portanto, o resumo de tudo o que é: não se preocupe . Basta escrever um código razoável, deixar o mecanismo lidar com isso. E por "razoável", quero dizer: o que faz sentido para o seu caso de uso, é legível, sustentável, usa algoritmos eficientes, não contém bugs como a leitura além do comprimento das matrizes. Idealmente, é tudo o que existe e você não precisa fazer mais nada. Se você se sentir melhor ao fazer alguma coisa e / ou se estiver realmente observando problemas de desempenho, posso oferecer duas idéias:

O uso do TypeScript pode ajudar. Grande aviso: os tipos do TypeScript visam a produtividade do desenvolvedor, não o desempenho da execução (e, como se vê, essas duas perspectivas têm requisitos muito diferentes de um sistema de tipos). Dito isso, há alguma sobreposição: por exemplo, se você anotar as coisas consistentemente number, o compilador TS o avisará se você acidentalmente colocar nulluma matriz ou função que deveria conter / operar apenas em números. Obviamente, ainda é necessária disciplina: uma única number_func(random_object as number)saída de escape pode minar silenciosamente tudo, porque a correção das anotações de tipo não é imposta em nenhum lugar.

O uso de TypedArrays também pode ajudar. Eles têm um pouco mais de sobrecarga (consumo de memória e velocidade de alocação) por matriz em comparação com matrizes JavaScript regulares (por isso, se você precisar de muitas matrizes pequenas, as matrizes regulares provavelmente são mais eficientes) e são menos flexíveis porque não podem crescer ou diminuem após a alocação, mas fornecem a garantia de que todos os elementos têm exatamente um tipo.

Existem boas ferramentas de medição fáceis de usar para verificar o que o mecanismo Javascript está fazendo internamente com os tipos?

Não, e isso é intencional. Como explicado acima, não queremos que você adapte seu código especificamente aos padrões que o V8 possa otimizar particularmente bem hoje, e também não acreditamos que você queira fazer isso. Esse conjunto de coisas pode mudar em qualquer direção: se houver um padrão que você gostaria de usar, poderemos otimizar isso em uma versão futura (anteriormente brincamos com a idéia de armazenar números inteiros de 32 bits sem caixa como elementos de matriz .. mas o trabalho que ainda não começou, portanto não há promessas); e, às vezes, se existe um padrão que otimizamos no passado, podemos decidir abandoná-lo se atrapalhar outras otimizações mais importantes / impactantes. Além disso, coisas como heurísticas embutidas são notoriamente difíceis de acertar, portanto, tomar a decisão correta no momento certo é uma área de pesquisa em andamento e alterações correspondentes no comportamento do mecanismo / compilador; o que torna esse outro caso em que seria lamentável para todos (vocêe nós) se você gastou muito tempo ajustando seu código até que um conjunto de versões atuais do navegador execute aproximadamente as decisões básicas que você acha (ou conhece?) são melhores, apenas para voltar meio ano depois para perceber que os navegadores atuais mudaram suas heurísticas.

Você pode, é claro, sempre medir o desempenho de seu aplicativo como um todo - isso é o que importa em última análise, não as escolhas específicas feitas pelo mecanismo internamente. Cuidado com as marcas de microbench, pois elas são enganosas: se você extrair apenas duas linhas de código e compará-las, é provável que o cenário seja suficientemente diferente (por exemplo, feedback de tipo diferente) para que o mecanismo tome decisões muito diferentes.


2
Obrigado por este excelente resposta, confirma muitas das minhas suspeitas sobre a forma como as coisas funcionam, e mais importante, como eles estão destinados ao trabalho. A propósito, existem postagens de blog etc. sobre o problema de "feedback de tipo" que você mencionou Array.sort()? Eu adoraria ler um pouco mais sobre isso.
Joppy

Não acho que tenhamos blogado sobre esse aspecto em particular. É basicamente o que você descreveu na sua pergunta: quando os buildins são implementados em JavaScript, eles são "como uma biblioteca", no sentido de que se diferentes partes de código os chamam com tipos diferentes, o desempenho pode sofrer - às vezes apenas um pouco, às vezes mais. Não era o único, e sem dúvida nem o maior problema com essa técnica; Eu só queria dizer que estou familiarizado com a questão geral.
jmrk 13/03
Ao utilizar nosso site, você reconhece que leu e compreendeu nossa Política de Cookies e nossa Política de Privacidade.
Licensed under cc by-sa 3.0 with attribution required.