TL; DR El ciclo más lento se debe al acceso a la matriz 'fuera de límites', lo que obliga al motor a recompilar la función con menos o incluso sin optimizaciones O para no compilar la función con ninguna de estas optimizaciones para comenzar ( si el compilador (JIT-) detectó / sospechó esta condición antes de la primera 'versión' de compilación), lea a continuación por qué;
Alguien solo
tiene que decir esto (completamente sorprendido de que nadie lo haya hecho ya):
solía haber un momento en que el fragmento del OP sería un ejemplo de facto en un libro de programación para principiantes destinado a delinear / enfatizar que las 'matrices' en JavaScript están indexadas a partir en 0, no en 1, y como tal se debe usar como un ejemplo de un "error de principiante" común (¿no te gusta cómo evité la frase "error de programación"
;)
):
acceso de matriz fuera de los límites .
Ejemplo 1:
a Dense Array
(siendo contiguo (significa que no hay espacios entre los índices) Y en realidad un elemento en cada índice) de 5 elementos que usan indexación basada en 0 (siempre en 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
Por lo tanto, no estamos hablando realmente de la diferencia de rendimiento entre <
vs <=
(o 'una iteración adicional'), pero estamos hablando:
'¿por qué el fragmento correcto (b) se ejecuta más rápido que el fragmento erróneo (a)'?
La respuesta es doble (aunque desde la perspectiva del implementador del lenguaje ES262, ambas son formas de optimización):
- Representación de datos: cómo representar / almacenar la matriz internamente en la memoria (objeto, mapa hash, matriz numérica 'real', etc.)
- Código de máquina funcional: cómo compilar el código que accede / maneja (lee / modifica) estas 'matrices'
El ítem 1 se explica suficientemente (y correctamente en mi humilde opinión) por la respuesta aceptada , pero eso solo gasta 2 palabras ('el código') en el ítem 2: compilación .
Más precisamente: JIT-Compilation y aún más importante JIT- RE -Compilation!
La especificación del lenguaje es básicamente una descripción de un conjunto de algoritmos ('pasos a realizar para lograr un resultado final definido'). Lo cual, como resulta, es una forma muy hermosa de describir un idioma. Y deja el método real que utiliza un motor para lograr resultados específicos abierto a los implementadores, lo que brinda una amplia oportunidad para encontrar formas más eficientes de producir resultados definidos. Un motor de conformidad de especificaciones debería dar resultados de conformidad de especificaciones para cualquier entrada definida.
Ahora, con el código / las bibliotecas / el uso de JavaScript en aumento, y recordando cuántos recursos (tiempo / memoria / etc.) usa un compilador 'real', está claro que no podemos hacer que los usuarios que visitan una página web esperen tanto (y los requieran tener tantos recursos disponibles).
Imagine la siguiente función simple:
function sum(arr){
var r=0, i=0;
for(;i<arr.length;) r+=arr[i++];
return r;
}
Perfectamente claro, ¿verdad? No requiere NINGUNA aclaración adicional, ¿verdad? El tipo de retorno es Number
, ¿verdad?
Bueno ... no, no y no ... Depende de qué argumento pase al parámetro de la función nombrada 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)
¿Ves el problema? Entonces considere que esto apenas está raspando las permutaciones masivas posibles ... Ni siquiera sabemos qué tipo de TIPO la función RETORNO hasta que hayamos terminado ...
Ahora imagine que este mismo código de función se usa realmente en diferentes tipos o incluso variaciones de entrada, tanto completamente literalmente (en código fuente) como 'matrices' generadas dinámicamente en el programa.
Por lo tanto, si compilara la función sum
SOLO UNA VEZ, entonces la única forma que siempre devuelve el resultado definido por la especificación para todos y cada uno de los tipos de entrada, entonces, obviamente, solo realizando TODOS los pasos principales Y secundarios prescritos por la especificación puede garantizar resultados conformes a la especificación (como un navegador anterior y2k sin nombre). No hay optimizaciones (porque no hay suposiciones) y queda un lenguaje de secuencias de comandos interpretado lentamente.
JIT-Compilation (JIT como en Just In Time) es la solución popular actual.
Entonces, comienza a compilar la función utilizando suposiciones con respecto a lo que hace, devuelve y acepta.
se le ocurren comprobaciones lo más simples posible para detectar si la función puede comenzar a devolver resultados no conformes a las especificaciones (como porque recibe una entrada inesperada). Luego, deseche el resultado compilado anterior y vuelva a compilar algo más elaborado, decida qué hacer con el resultado parcial que ya tiene (¿es válido confiar en él o calcular de nuevo para estar seguro?), Vuelva a vincular la función al programa y Inténtalo de nuevo. Finalmente, volviendo a la interpretación de guiones paso a paso como en la especificación.
¡Todo esto lleva tiempo!
Todos los navegadores funcionan en sus motores, para todas y cada una de las subversiones verás que las cosas mejoran y retroceden. Las cadenas fueron en algún momento de la historia cadenas realmente inmutables (por lo tanto, array.join fue más rápido que la concatenación de cadenas), ahora usamos cuerdas (o similares) que alivian el problema. ¡Ambos devuelven resultados conformes a las especificaciones y eso es lo que importa!
En pocas palabras: el hecho de que la semántica del lenguaje javascript a menudo nos respalda (como con este error silencioso en el ejemplo del OP) no significa que los errores 'estúpidos' aumenten nuestras posibilidades de que el compilador escupe código de máquina rápido. Se supone que escribimos las instrucciones "usualmente" correctas: el mantra actual que debemos tener los "usuarios" (del lenguaje de programación) es: ayudar al compilador, describir lo que queremos, favorecer modismos comunes (tomar sugerencias de asm.js para una comprensión básica) qué navegadores pueden intentar optimizar y por qué).
Debido a esto, hablar sobre el rendimiento es importante, PERO TAMBIÉN es un campo de minas (y debido a dicho campo de minas, realmente quiero terminar señalando (y citando) material relevante:
El acceso a propiedades de objeto inexistentes y elementos de matriz fuera de límites devuelve el undefined
valor en lugar de generar una excepción. Estas características dinámicas hacen que la programación en JavaScript sea conveniente, pero también dificultan la compilación de JavaScript en un código de máquina eficiente.
...
Una premisa importante para la optimización efectiva de JIT es que los programadores usan características dinámicas de JavaScript de manera sistemática. Por ejemplo, los compiladores JIT explotan el hecho de que las propiedades del objeto a menudo se agregan a un objeto de un tipo determinado en un orden específico o que raramente se producen accesos fuera de los límites de la matriz. Los compiladores JIT explotan estos supuestos de regularidad para generar código de máquina eficiente en tiempo de ejecución. Si un bloque de código satisface los supuestos, el motor de JavaScript ejecuta un código de máquina eficiente y generado. De lo contrario, el motor debe recurrir a un código más lento o para interpretar el programa.
Fuente:
"JITProf: Pinpointing JIT-unfriendly Code JavaScript"
Publicación de Berkeley, 2014, por Liang Gong, Michael Pradel, Koushik Sen.
http://software-lab.org/publications/jitprof_tr_aug3_2014.pdf
ASM.JS (tampoco le gusta el acceso fuera de matriz):
Compilación anticipada
Como asm.js es un subconjunto estricto de JavaScript, esta especificación solo define la lógica de validación: la semántica de ejecución es simplemente la de JavaScript. Sin embargo, validado asm.js es susceptible de compilación anticipada (AOT). Además, el código generado por un compilador AOT puede ser bastante eficiente, con:
- representaciones sin caja de enteros y números de punto flotante;
- ausencia de verificaciones de tipo de tiempo de ejecución;
- ausencia de recolección de basura; y
- cargas y almacenes eficientes (con estrategias de implementación que varían según la plataforma).
El código que no se valida debe recurrir a la ejecución por medios tradicionales, por ejemplo, interpretación y / o compilación justo a tiempo (JIT).
http://asmjs.org/spec/latest/
y finalmente https://blogs.windows.com/msedgedev/2015/05/07/bringing-asm-js-to-chakra-microsoft-edge/ donde
hay una pequeña subsección sobre las mejoras de rendimiento interno del motor al eliminar límites- check (mientras solo levantaba los límites, check fuera del ciclo ya tenía una mejora del 40%).
EDITAR:
tenga en cuenta que varias fuentes hablan sobre diferentes niveles de JIT-Recompilation hasta la interpretación.
Ejemplo teórico basado en la información anterior, con respecto al fragmento del OP:
- Llamar a isPrimeDivisible
- Compile isPrimeDivisible usando suposiciones generales (como no tener acceso fuera de límites)
- Hacer trabajo
- BAM, repentinamente el acceso a la matriz está fuera de los límites (justo al final).
- Mierda, dice motor, recompilemos que esPrimeDivisible usando diferentes (menos) suposiciones, y este motor de ejemplo no intenta averiguar si puede reutilizar el resultado parcial actual, por lo que
- Vuelva a calcular todo el trabajo con una función más lenta (esperemos que termine; de lo contrario, repita y esta vez solo interprete el código).
- Resultado devuelto
Por lo tanto, el tiempo era:
primera ejecución (falló al final) + hacer todo el trabajo de nuevo usando un código de máquina más lento para cada iteración + la recompilación, etc. ¡claramente toma> 2 veces más en este ejemplo teórico !
EDIT 2: (descargo de responsabilidad: conjetura basada en los hechos a continuación)
Cuanto más lo pienso, más creo que esta respuesta podría explicar la razón más dominante de esta 'penalización' en el fragmento erróneo a (o bonificación de rendimiento en el fragmento b , dependiendo de cómo lo piense), precisamente por qué estoy convencido de llamarlo (fragmento a) un error de programación:
Es bastante tentador suponer que se this.primes
trata de una 'matriz densa' puramente numérica que era
- Literal codificado en el código fuente (candidato excelente conocido para convertirse en una matriz 'real' ya que todo lo conoce el compilador antes del tiempo de compilación) O
- Lo más probable es que se genere utilizando una función numérica que rellena un tamaño predeterminado (
new Array(/*size value*/)
) en orden secuencial ascendente (otro candidato conocido desde hace mucho tiempo para convertirse en una matriz 'real').
También sabemos que la primes
longitud de la matriz se almacena en caché como prime_count
! (indicando su intención y tamaño fijo).
También sabemos que la mayoría de los motores inicialmente pasan las matrices como copiar y modificar (cuando es necesario), lo que hace que manipularlos sea mucho más rápido (si no los cambia).
Por lo tanto, es razonable suponer que Array primes
probablemente ya sea una matriz optimizada internamente que no cambia después de la creación (simple de saber para el compilador si no hay código que modifique la matriz después de la creación) y, por lo tanto, ya lo es (si corresponde a el motor) almacenado de manera optimizada, más o menos como si fuera un Typed Array
.
Como he tratado de aclarar con mi sum
ejemplo de función, los argumentos que se pasan influyen mucho en lo que realmente tiene que suceder y, como tal, cómo se compila ese código en particular en código máquina. ¡Pasar a String
a la sum
función no debería cambiar la cadena, sino cambiar cómo se compila la función JIT! Pasar una matriz a sum
debería compilar una versión diferente (quizás incluso adicional para este tipo, o 'forma' como lo llaman, de objeto que se pasó) de código de máquina.
¡Ya que parece un poco extraño convertir el Array tipo Typed_Array sobre la marcha en algo_else primes
mientras el compilador sabe que esta función ni siquiera lo va a modificar!
Bajo estos supuestos que deja 2 opciones:
- Compile como un generador de números, suponiendo que no haya límites fuera de juego, encuentre un problema fuera de límites al final, vuelva a compilar y rehaga el trabajo (como se describe en el ejemplo teórico en la edición 1 anterior)
- El compilador ya detectó (¿o sospecha?) Fuera del acceso enlazado por adelantado y la función se compiló en JIT como si el argumento pasado fuera un objeto escaso que resulta en un código de máquina funcional más lento (ya que tendría más comprobaciones / conversiones / coacciones) etc.) En otras palabras: la función nunca fue elegible para ciertas optimizaciones, se compiló como si recibiera un argumento de 'matriz dispersa' (como).
¡Ahora realmente me pregunto cuál de estos 2 es!
<=
y<
es idéntica, tanto en teoría como en la implementación real en todos los procesadores (e intérpretes) modernos.