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:
- 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.
- 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 doisx
ey
receber pacotes de SMI, ou ambos, pacotes duplos. - 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:
- 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. - 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
null
ouundefined
. Este caso ruim é bastante fácil de evitar. - 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", ondevec.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
A
em alta velocidade e depois importo um pacoteB
que dependeA
, mas oB
chama 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?
javascript
v8
jit
spidermonkey
Joppy
fonte
fonte
|0
atrá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).Respostas:
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.
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.
"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, "é
x
um número inteiro? Em caso afirmativo, siga o caminho do código. Éx
uma 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) quex
sempre 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
eval
para 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.)Sim, esse é um problema geral com JavaScript. A V8 costumava implementar
Array.sort
internamente 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 colocarnull
uma matriz ou função que deveria conter / operar apenas em números. Obviamente, ainda é necessária disciplina: uma únicanumber_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.
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.
fonte
Array.sort()
? Eu adoraria ler um pouco mais sobre isso.