TL; DR O loop mais lento se deve ao acesso à matriz 'fora dos limites', que força o mecanismo a recompilar a função com menos ou mesmo sem otimizações OU a não compilar a função com nenhuma dessas otimizações para começar ( se o compilador (JIT-) detectou / suspeitou dessa condição antes da primeira compilação 'versão'), leia abaixo o porquê;
Alguém apenas
tem que dizer isso (absolutamente espantado que ninguém já o fez):
Costumava haver um tempo em que o trecho do OP seria um exemplo de fato em um livro de programação para iniciantes, destinado a descrever / enfatizar que 'matrizes' em javascript são indexadas a partir de em 0, não 1 e, como tal, deve ser usado como exemplo de um 'erro de iniciante' comum (você não ama como evitei a frase 'erro de programação'
;)
):
acesso à matriz fora dos limites .
Exemplo 1:
a Dense Array
(sendo contíguo (significa que não há intervalos entre os índices) E, na verdade, um elemento em cada índice) de 5 elementos usando a indexação baseada em 0 (sempre no ES262).
var arr_five_char=['a', 'b', 'c', 'd', 'e']; // arr_five_char.length === 5
// indexes are: 0 , 1 , 2 , 3 , 4 // there is NO index number 5
Portanto, não estamos realmente falando sobre a diferença de desempenho entre <
vs <=
(ou 'uma iteração extra'), mas estamos falando:
'por que o snippet correto (b) é executado mais rápido que o snippet incorreto (a)'?
A resposta é dupla (embora da perspectiva do implementador da linguagem ES262 ambas sejam formas de otimização):
- Representação de dados: como representar / armazenar a matriz internamente na memória (objeto, mapa de hash, matriz numérica 'real' etc.)
- Código-máquina funcional: como compilar o código que acessa / manipula (lê / modifica) essas 'matrizes'
O item 1 é suficientemente (e corretamente IMHO) explicado pela resposta aceita , mas isso gasta apenas 2 palavras ('o código') no item 2: compilação .
Mais precisamente: compilação JIT e, mais importante ainda, compilação JIT- RE !
A especificação da linguagem é basicamente apenas uma descrição de um conjunto de algoritmos ('etapas a serem executadas para alcançar o resultado final definido'). O que, como se vê, é uma maneira muito bonita de descrever uma linguagem. E deixa o método real que um mecanismo usa para alcançar resultados especificados aberto aos implementadores, dando ampla oportunidade para encontrar maneiras mais eficientes de produzir resultados definidos. Um mecanismo de conformidade de especificações deve fornecer resultados de conformidade de especificações para qualquer entrada definida.
Agora, com o código javascript / bibliotecas / uso aumentando e lembrando quantos recursos (tempo / memória / etc) um compilador 'real' usa, fica claro que não podemos fazer com que os usuários que visitam uma página da Web esperem tanto tempo (e os exigem ter tantos recursos disponíveis).
Imagine a seguinte função simples:
function sum(arr){
var r=0, i=0;
for(;i<arr.length;) r+=arr[i++];
return r;
}
Perfeitamente claro, certo? Não requer nenhum esclarecimento extra, certo? O tipo de retorno é Number
, certo?
Bem .. não, não e não ... Depende de qual argumento você passa para o parâmetro de função nomeado arr
...
sum('abcde'); // String('0abcde')
sum([1,2,3]); // Number(6)
sum([1,,3]); // Number(NaN)
sum(['1',,3]); // String('01undefined3')
sum([1,,'3']); // String('NaN3')
sum([1,2,{valueOf:function(){return this.val}, val:6}]); // Number(9)
var val=5; sum([1,2,{valueOf:function(){return val}}]); // Number(8)
Vê o problema? Em seguida, considere que isso apenas raspa as permutações possíveis em massa ... Nós nem sabemos que tipo de TIPO a função RETURN até terminarmos ...
Agora imagine esse mesmo código de função sendo usado em diferentes tipos ou mesmo variações de entrada, ambos completamente literalmente (no código-fonte) descritos e dinamicamente 'matrizes' geradas no programa.
Portanto, se você compilar a função sum
APENAS UMA VEZ, a única maneira de sempre retornar o resultado definido por especificação para todo e qualquer tipo de entrada, obviamente, somente executando TODAS as principais etapas E sub-prescritas por especificação, poderá garantir resultados conformes às especificações (como um navegador pré-y2k sem nome). Não há otimizações (porque não há suposições) e a linguagem de script interpretada lenta morta.
JIT-Compilation (JIT como Just In Time) é a solução popular atual.
Então, você começa a compilar a função usando suposições sobre o que ela faz, retorna e aceita.
você cria verificações o mais simples possível para detectar se a função pode começar a retornar resultados não conformes à especificação (como porque recebe entrada inesperada). Em seguida, jogue fora o resultado compilado anterior e recompile para algo mais elaborado, decida o que fazer com o resultado parcial que você já possui (é válido confiar ou computar novamente para ter certeza), vincule a função novamente ao programa e tente novamente. Em última análise, voltando à interpretação de script passo a passo, como nas especificações.
Tudo isso leva tempo!
Todos os navegadores funcionam em seus mecanismos, para cada sub-versão, você verá as coisas melhorarem e regredirem. Em algum momento da história, as cordas eram realmente imutáveis (portanto, array.join era mais rápido que a concatenação de cordas), agora usamos cordas (ou similares) que aliviam o problema. Ambos retornam resultados conformes às especificações e é isso que importa!
Para encurtar a história: apenas porque a semântica da linguagem javascript geralmente nos recupera (como com esse bug silencioso no exemplo do OP) não significa que erros 'estúpidos' aumentem nossas chances de o compilador cuspir código-máquina rápido. Ele pressupõe que escrevemos as instruções corretas 'geralmente': o mantra atual que nós 'usuários' (da linguagem de programação) devemos ter é: ajudar o compilador, descrever o que queremos, favorecer expressões comuns (use asm.js para obter um entendimento básico quais navegadores podem tentar otimizar e por quê).
Por causa disso, falar sobre desempenho é importante, mas TAMBÉM é um campo minado (e, por causa desse campo, eu realmente quero terminar apontando (e citando) algum material relevante:
O acesso a propriedades inexistentes de objetos e elementos da matriz fora dos limites retorna o undefined
valor em vez de gerar uma exceção. Esses recursos dinâmicos tornam a programação em JavaScript conveniente, mas também dificultam a compilação do JavaScript em um código de máquina eficiente.
...
Uma premissa importante para a otimização eficaz do JIT é que os programadores usam recursos dinâmicos do JavaScript de maneira sistemática. Por exemplo, os compiladores JIT exploram o fato de que as propriedades do objeto geralmente são adicionadas a um objeto de um determinado tipo em uma ordem específica ou que os acessos fora da área de matriz ocorrem raramente. Os compiladores JIT exploram essas suposições de regularidade para gerar código de máquina eficiente em tempo de execução. Se um bloco de código atender às premissas, o mecanismo JavaScript executará um código de máquina eficiente e gerado. Caso contrário, o mecanismo deve retornar ao código mais lento ou à interpretação do programa.
Fonte:
"JITProf: Identificando o código JavaScript hostil para o JIT"
publicação de Berkeley, 2014, por Liang Gong, Michael Pradel, Koushik Sen.
http://software-lab.org/publications/jitprof_tr_aug3_2014.pdf
ASM.JS (também não gosta de acessar a matriz fora do limite):
Compilação Antecipada
Como o asm.js é um subconjunto estrito de JavaScript, essa especificação define apenas a lógica de validação - a semântica de execução é simplesmente a do JavaScript. No entanto, o asm.js validado é passível de compilação antecipada (AOT). Além disso, o código gerado por um compilador AOT pode ser bastante eficiente, apresentando:
- representações sem caixa de números inteiros e de ponto flutuante;
- ausência de verificações de tipo de tempo de execução;
- ausência de coleta de lixo; e
- cargas e armazenamentos de heap eficientes (com estratégias de implementação variando por plataforma).
O código que falha na validação deve retornar à execução pelos meios tradicionais, por exemplo, interpretação e / ou compilação just-in-time (JIT).
http://asmjs.org/spec/latest/
e finalmente https://blogs.windows.com/msedgedev/2015/05/07/bringing-asm-js-to-chakra-microsoft-edge/
, houve uma pequena subseção sobre as melhorias de desempenho interno do mecanismo ao remover limites- check (enquanto apenas levantando os limites - check fora do loop já teve uma melhoria de 40%).
EDIT:
observe que várias fontes falam sobre diferentes níveis de recompilação JIT até a interpretação.
Exemplo teórico com base nas informações acima, em relação ao snippet do OP:
- Ligue para isPrimeDivisible
- Compile isPrimeDivisible usando suposições gerais (como acesso fora dos limites)
- Trabalhe
- BAM, de repente, os acessos ao array fora dos limites (logo no final).
- Porcaria, diz o mecanismo, vamos recompilar que éPrimeDivisible usando diferentes (menos) suposições, e esse mecanismo de exemplo não tenta descobrir se pode reutilizar o resultado parcial atual, portanto
- Recompute todo o trabalho usando a função mais lenta (espero que termine, caso contrário, repita e desta vez apenas interprete o código).
- Resultado de retorno
Portanto, o tempo foi então:
Primeira execução (falha no final) + fazendo todo o trabalho novamente usando código de máquina mais lento para cada iteração + a recompilação etc. .. claramente leva> 2 vezes mais neste exemplo teórico !
EDIT 2: (exoneração de responsabilidade: conjectura baseada nos fatos abaixo)
Quanto mais eu penso nisso, mais acho que essa resposta pode realmente explicar a razão mais dominante dessa 'penalidade' no trecho errado a (ou bônus de desempenho no trecho b) , dependendo de como você pensa sobre isso), precisamente por que eu sou um adágio em chamá-lo (snippet a) de erro de programação:
É bastante tentador supor que this.primes
seja um numérico puro de 'matriz densa' que fosse
- Literal codificado no código-fonte (candidato excelente conhecido para se tornar uma matriz 'real', pois tudo já é conhecido pelo compilador antes do tempo de compilação) OU
- provavelmente gerado usando uma função numérica preenchendo um pré-tamanho (
new Array(/*size value*/)
) em ordem sequencial crescente (outro candidato conhecido há muito tempo para se tornar um array 'real').
Também sabemos que o primes
comprimento da matriz é armazenado em cache como prime_count
! (indicando sua intenção e tamanho fixo).
Também sabemos que a maioria dos mecanismos passa os Arrays inicialmente como copiar na modificação (quando necessário), o que os torna muito mais rápidos (se você não os alterar).
Portanto, é razoável supor que a matriz primes
provavelmente já seja uma matriz otimizada internamente, que não será alterada após a criação (simples de saber para o compilador se não houver código que modifique a matriz após a criação) e, portanto, já seja (se aplicável a o mecanismo) armazenado de maneira otimizada, quase como se fosse um Typed Array
.
Como tentei deixar claro com meu sum
exemplo de função, os argumentos que são passados influenciam muito o que realmente precisa acontecer e, como tal, como esse código específico está sendo compilado no código de máquina. Passar um String
para a sum
função não deve alterar a string, mas alterar como a função é compilada por JIT! Passar uma matriz para sum
deve compilar uma versão diferente (talvez adicional para esse tipo, ou 'forma' como eles chamam, de objeto que foi passado) do código de máquina.
Como parece um pouco estranho converter o primes
Array do tipo Typed_Array on-the-fly para algo mais, enquanto o compilador sabe que essa função nem sequer a modifica!
Sob essas premissas, restam 2 opções:
- Compile como triturador de números assumindo que não há limites, encontre um problema fora dos limites no final, recompile e refaça o trabalho (conforme descrito no exemplo teórico na edição 1 acima)
- O compilador já detectou (ou suspeitou?) Acesso fora dos limites desde o início e a função foi compilada por JIT como se o argumento passado fosse um objeto esparso, resultando em um código-máquina funcional mais lento (pois haveria mais verificações / conversões / coerções etc.) Em outras palavras: a função nunca foi elegível para certas otimizações, foi compilada como se tivesse recebido um argumento de 'matriz esparsa' (semelhante a).
Agora eu realmente me pergunto qual desses 2 é!
<=
e<
é idêntica, tanto na teoria quanto na implementação real em todos os processadores (e intérpretes) modernos.