Estoy leyendo las diapositivas Rompiendo el límite de velocidad de Javascript con V8 , y hay un ejemplo como el código a continuación. No puedo entender por qué <=
es más lento que <
en este caso, ¿alguien puede explicar eso? Cualquier comentario es apreciado.
Lento:
this.isPrimeDivisible = function(candidate) {
for (var i = 1; i <= this.prime_count; ++i) {
if (candidate % this.primes[i] == 0) return true;
}
return false;
}
(Sugerencia: primos es una matriz de longitud prime_count)
Más rápido:
this.isPrimeDivisible = function(candidate) {
for (var i = 1; i < this.prime_count; ++i) {
if (candidate % this.primes[i] == 0) return true;
}
return false;
}
[Más información] la mejora de la velocidad es significativa, en mi prueba de entorno local, los resultados son los siguientes:
V8 version 7.3.0 (candidate)
Lento:
time d8 prime.js
287107
12.71 user
0.05 system
0:12.84 elapsed
Más rápido:
time d8 prime.js
287107
1.82 user
0.01 system
0:01.84 elapsed
javascript
v8
Leonardo Physh
fuente
fuente
<=
y<
es idéntica, tanto en teoría como en la implementación real en todos los procesadores (e intérpretes) modernos.main
código que llama a esa función en un bucle que ejecuta25000
tiempos, por lo que está haciendo muchas menos iteraciones en general haciendo ese cambio. Además, si una matriz tiene una longitud de 5, intentar obtenerlaarray[5]
irá fuera de su límite dando unundefined
valor ya que las matrices comienzan a indexarse0
.<=
pero que actúa de manera idéntica a la<
versión al hacerloi <= this.prime_count - 1
. Esto resuelve tanto el problema de "iteración extra" como el problema de "uno más allá del final de la matriz".Respuestas:
Trabajo en V8 en Google, y quería proporcionar información adicional sobre las respuestas y comentarios existentes.
Como referencia, aquí está el ejemplo de código completo de las diapositivas :
En primer lugar, la diferencia de rendimiento no tiene nada que ver con los operadores
<
y<=
directamente. Por lo tanto, no salte los aros solo para evitar<=
en su código porque leyó en Stack Overflow que es lento, ¡no lo es!En segundo lugar, la gente señaló que la matriz es "holey". Esto no estaba claro en el fragmento de código en la publicación de OP, pero está claro cuando miras el código que se inicializa
this.primes
:Esto da como resultado una matriz con un
HOLEY
tipo de elementos en V8, incluso si la matriz termina completamente llena / empaquetada / contigua. En general, las operaciones en matrices holey son más lentas que las operaciones en matrices empaquetadas, pero en este caso la diferencia es insignificante: equivale a 1 comprobación adicional de Smi ( entero pequeño ) (para proteger contra agujeros) cada vez que golpeamosthis.primes[i]
en el bucle internoisPrimeDivisible
. ¡No es gran cosa!TL; DR El ser de la matriz
HOLEY
no es el problema aquí.Otros señalaron que el código se lee fuera de los límites. En general, se recomienda evitar leer más allá de la longitud de las matrices , y en este caso, de hecho, habría evitado la caída masiva en el rendimiento. ¿Pero por qué? V8 puede manejar algunos de estos escenarios fuera de límites con solo un impacto menor en el rendimiento. ¿Qué tiene de especial este caso particular, entonces?
La lectura fuera de límites da como resultado
this.primes[i]
estarundefined
en esta línea:Y eso nos lleva al problema real : ¡el
%
operador ahora se está utilizando con operandos no enteros!integer % someOtherInteger
se puede calcular de manera muy eficiente; Los motores de JavaScript pueden producir código de máquina altamente optimizado para este caso.integer % undefined
por otro lado equivale a una forma menos eficienteFloat64Mod
, ya queundefined
se representa como un doble.El fragmento de código se puede mejorar cambiando el
<=
en<
esta línea:... no porque
<=
sea de alguna manera un operador superior<
, sino simplemente porque esto evita la lectura fuera de los límites en este caso particular.fuente
Otras respuestas y comentarios mencionan que la diferencia entre los dos bucles es que el primero ejecuta una iteración más que el segundo. Esto es cierto, pero en una matriz que crece a 25,000 elementos, una iteración más o menos solo haría una diferencia minúscula. Como suposición aproximada, si asumimos que la longitud promedio a medida que crece es de 12,500, entonces la diferencia que podríamos esperar debería ser de alrededor de 1 / 12,500, o solo 0.008%.
La diferencia de rendimiento aquí es mucho mayor de lo que se explicaría con esa iteración adicional, y el problema se explica cerca del final de la presentación.
this.primes
es una matriz contigua (cada elemento tiene un valor) y los elementos son todos números.Un motor de JavaScript puede optimizar dicha matriz para que sea una simple matriz de números reales, en lugar de una matriz de objetos que contienen números pero podrían contener otros valores o ningún valor. El acceso al primer formato es mucho más rápido: requiere menos código y la matriz es mucho más pequeña, por lo que se ajustará mejor en la memoria caché. Pero hay algunas condiciones que pueden evitar que se use este formato optimizado.
Una condición sería si faltan algunos de los elementos de la matriz. Por ejemplo:
Ahora, ¿cuál es el valor de
a[1]
? Que no tiene ningún valor . (Ni siquiera es correcto decir que tiene el valorundefined
: un elemento de matriz que contiene elundefined
valor es diferente de un elemento de matriz que falta por completo).No hay una manera de representar esto solo con números, por lo que el motor de JavaScript se ve obligado a usar el formato menos optimizado. Si
a[1]
contuviera un valor numérico como los otros dos elementos, la matriz podría optimizarse potencialmente en una matriz de números solamente.Otra razón por la que una matriz se ve forzada a adoptar el formato desoptimizado puede ser si intenta acceder a un elemento fuera de los límites de la matriz, como se discutió en la presentación.
El primer ciclo con
<=
intentos de leer un elemento más allá del final de la matriz. El algoritmo aún funciona correctamente, porque en la última iteración adicional:this.primes[i]
evalúaundefined
porquei
es pasado el final de la matriz.candidate % undefined
(para cualquier valor decandidate
) se evalúa comoNaN
.NaN == 0
evalúa afalse
.return true
no se ejecuta.Por lo tanto, es como si la iteración adicional nunca sucediera, no tiene ningún efecto en el resto de la lógica. El código produce el mismo resultado que lo haría sin la iteración adicional.
Pero para llegar allí, trató de leer un elemento inexistente más allá del final de la matriz. Esto obliga a la matriz a salir de la optimización, o al menos lo hizo en el momento de esta charla.
El segundo ciclo con
<
elementos de solo lectura que existen dentro de la matriz, por lo que permite una matriz y un código optimizados.El problema se describe en las páginas 90-91 de la charla, con una discusión relacionada en las páginas anteriores y posteriores.
Asistí a esta presentación de Google I / O y luego hablé con el orador (uno de los autores de V8). Había estado usando una técnica en mi propio código que implicaba leer más allá del final de una matriz como un intento equivocado (en retrospectiva) de optimizar una situación particular. Confirmó que si intentaba incluso leer más allá del final de una matriz, evitaría que se utilizara el formato optimizado simple.
Si lo que dijo el autor de V8 sigue siendo cierto, leer más allá del final de la matriz evitaría que se optimizara y tendría que volver al formato más lento.
Ahora es posible que V8 haya sido mejorado mientras tanto para manejar eficientemente este caso, o que otros motores JavaScript lo manejen de manera diferente. No sé de una forma u otra sobre eso, pero esta desoptimización es de lo que hablaba la presentación.
fuente
undefined
lugar de un número que conduce a un cálculo diferente.Object
), porque tiene que admitir cualquier combinación de tipos de datos en la matriz. Como mencioné anteriormente, el código en el bucle que se está alimentandoundefined
no afecta la exactitud del algoritmo, no cambia el cálculo en absoluto (es como si la iteración adicional nunca sucediera).Value
objetos que puede contener referencias a valores de cualquier tipo. (Me inventé el nombreValue
, pero el punto es que los elementos de la matriz no son sólo números simples pero son objetos que los números de envoltura o de otros tipos.)HOLEY
porque se creó usandonew Array(n)
(aunque esta parte del código no estaba visible en el OP).HOLEY
Las matrices permanecenHOLEY
para siempre en V8 , incluso cuando luego se llenan. Dicho esto, la matriz que es holey no es la razón del problema de rendimiento en este caso; solo significa que tenemos que hacer una verificación Smi adicional en cada iteración (para protegernos de los agujeros), lo cual no es gran cosa.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).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):
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:
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
...¿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:
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):
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:
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 eranew 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é comoprime_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 unTyped 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 aString
a lasum
función no debería cambiar la cadena, sino cambiar cómo se compila la función JIT! Pasar una matriz asum
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:
¡Ahora realmente me pregunto cuál de estos 2 es!
fuente
Para agregarle algo científico, aquí hay un jsperf
https://jsperf.com/ints-values-in-out-of-array-bounds
Prueba el caso de control de una matriz llena de entradas y bucles haciendo aritmética modular mientras se mantiene dentro de los límites. Tiene 5 casos de prueba:
new Array()
Muestra que los primeros 4 casos son realmente malos para el rendimiento. Loop fuera de los límites es un poco mejor que los otros 3, pero los 4 son aproximadamente un 98% más lentos que el mejor de los casos.
El
new Array()
caso es casi tan bueno como la matriz en bruto, solo un poco más lento.fuente