Al escribir código sensible al rendimiento en Javascript que opera en matrices numéricas grandes (piense en un paquete de álgebra lineal, operando en números enteros o de coma flotante), uno siempre quiere que el JIT ayude lo más posible. Aproximadamente esto significa:
- Siempre queremos que nuestras matrices sean SMI (enteros pequeños) o Dobles empaquetados, dependiendo de si estamos haciendo cálculos enteros o de punto flotante.
- Siempre queremos pasar el mismo tipo de cosas a las funciones, para que no sean etiquetadas como "megamórficas" y desoptimizadas. Por ejemplo, siempre queremos llamar
vec.add(x, y)
con ambosx
yy
ser paquetes SMI empaquetados, o ambos paquetes dobles empaquetados. - Queremos que las funciones estén alineadas tanto como sea posible.
Cuando uno se desvía de estos casos, se produce una caída repentina y drástica del rendimiento. Esto puede suceder por varias razones inocuas:
- Puede convertir una matriz SMI empaquetada en una matriz doble empaquetada a través de una operación aparentemente inocua, como el equivalente de
myArray.map(x => -x)
. Este es en realidad el "mejor" mal caso, ya que las matrices dobles empaquetadas siguen siendo muy rápidas. - Puede convertir una matriz empaquetada en una matriz en caja genérica, por ejemplo, mapeando la matriz sobre una función que (inesperadamente) regresó
null
oundefined
. Este mal caso es bastante fácil de evitar. - Puede desestimar una función completa, como
vec.add()
pasar demasiados tipos de cosas y convertirla en megamórfica. Esto podría suceder si desea hacer una "programación genérica", dondevec.add()
se usa tanto en los casos en que no se tiene cuidado con los tipos (por lo que se ven muchos tipos) como en los casos en que desea obtener el máximo rendimiento (solo debería recibir dobles en caja, por ejemplo).
Mi pregunta es más una pregunta suave, sobre cómo se escribe un código Javascript de alto rendimiento a la luz de las consideraciones anteriores, mientras se mantiene el código agradable y legible. Algunas subpreguntas específicas para que sepa qué tipo de respuesta busco:
- ¿Existe algún conjunto de pautas en algún lugar sobre cómo programar mientras se está en el mundo de los arreglos SMI empaquetados (por ejemplo)?
- ¿Es posible hacer programación genérica de alto rendimiento en Javascript sin usar algo como un sistema macro para
vec.add()
alinear cosas como en los sitios de llamadas? - ¿Cómo modularizar el código de alto rendimiento en bibliotecas a la luz de cosas como sitios de llamadas megamórficas y desoptimizaciones? Por ejemplo, si estoy usando felizmente el paquete Linear Algebra
A
a alta velocidad, y luego importo un paqueteB
que depende de élA
, pero loB
llama con otros tipos y lo optimiza, de repente (sin que mi código cambie) mi código se ejecuta más lentamente. - ¿Hay alguna buena herramienta de medición fácil de usar para verificar qué hace el motor Javascript internamente con los tipos?
javascript
v8
jit
spidermonkey
Joppy
fuente
fuente
|0
atrás cada operación". No es bonito, pero es lo mejor que puedes hacer en un idioma que no tiene enteros adecuados. También podría usar BigInts, pero a partir de hoy no son muy rápidos en ninguno de los motores comunes (principalmente debido a la falta de demanda).Respuestas:
Desarrollador V8 aquí. Dada la cantidad de interés en esta pregunta y la falta de otras respuestas, puedo intentarlo; Sin embargo, me temo que no será la respuesta que esperabas.
Respuesta corta: es aquí:
const guidelines = ["keep your integers small enough"]
.Respuesta más larga: dar un conjunto completo de pautas es difícil por varias razones. En general, nuestra opinión es que los desarrolladores de JavaScript deberían escribir código que tenga sentido para ellos y su caso de uso, y los desarrolladores de motores de JavaScript deberían descubrir cómo ejecutar ese código rápidamente en sus motores. Por otro lado, obviamente hay algunas limitaciones a ese ideal, en el sentido de que algunos patrones de codificación siempre tendrán mayores costos de rendimiento que otros, independientemente de las opciones de implementación del motor y los esfuerzos de optimización.
Cuando hablamos de consejos de rendimiento, tratamos de tenerlo en cuenta y estimamos cuidadosamente qué recomendaciones tienen una alta probabilidad de seguir siendo válidas en muchos motores y muchos años, y también son razonablemente idiomáticas / no intrusivas.
Volviendo al ejemplo en cuestión: se supone que usar Smis internamente es un detalle de implementación que el código de usuario no necesita conocer. Hará algunos casos más eficientes, y no debería doler en otros casos. No todos los motores usan Smis (por ejemplo, AFAIK Firefox / Spidermonkey históricamente no lo ha hecho; he oído que en algunos casos sí usan Smis en estos días; pero no conozco ningún detalle y no puedo hablar con ninguna autoridad sobre el asunto) En V8, el tamaño de Smis es un detalle interno, y en realidad ha ido cambiando con el tiempo y las versiones. En las plataformas de 32 bits, que solían ser el caso de uso mayoritario, Smis siempre han sido enteros con signo de 31 bits; en plataformas de 64 bits solían ser enteros con signo de 32 bits, que recientemente parecía el caso más común, hasta que en Chrome 80 enviamos "compresión de puntero" para arquitecturas de 64 bits, que requerían reducir el tamaño de Smi a los 31 bits conocidos de las plataformas de 32 bits. Si por casualidad basó una implementación en el supuesto de que Smis son típicamente de 32 bits, obtendría situaciones desafortunadas comoesta .
Afortunadamente, como notó, las matrices dobles siguen siendo muy rápidas. Para el código numérico pesado, probablemente tenga sentido asumir / apuntar matrices dobles. Dada la prevalencia de dobles en JavaScript, es razonable suponer que todos los motores tienen un buen soporte para dobles y matrices dobles.
"genérico" generalmente está en desacuerdo con "alto rendimiento". Esto no está relacionado con JavaScript o con implementaciones específicas del motor.
El código "genérico" significa que las decisiones deben tomarse en tiempo de ejecución. Cada vez que ejecuta una función, el código debe ejecutarse para determinar, por ejemplo, "¿es
x
un número entero? Si es así, tome esa ruta de código. ¿Esx
una cadena? Luego salte aquí. ¿Es un objeto? ¿Tiene? ¿.valueOf
No? Entonces tal vez.toString()
? tal vez en su cadena de prototipo? Llame a eso, y vuelva a empezar desde el principio con su resultado". El código optimizado de "alto rendimiento" se basa esencialmente en la idea de eliminar todas estas comprobaciones dinámicas; eso solo es posible cuando el motor / compilador tiene alguna forma de inferir los tipos con anticipación: si puede probar (o suponer con una probabilidad suficientemente alta) quex
siempre será un número entero, entonces solo necesita generar código para ese caso ( protegido por una verificación de tipo si se trata de supuestos no comprobados).La alineación es ortogonal a todo esto. Una función "genérica" aún puede ser incorporada. En algunos casos, el compilador podría propagar información de tipo en la función en línea para reducir el polimorfismo allí.
(A modo de comparación: C ++, al ser un lenguaje compilado estáticamente, tiene plantillas para resolver un problema relacionado. En resumen, permiten que el programador indique explícitamente al compilador que cree copias especializadas de funciones (o clases enteras), parametrizadas en tipos dados. Buena solución para algunos casos, pero no sin su propio conjunto de inconvenientes, por ejemplo, largos tiempos de compilación y grandes binarios. JavaScript, por supuesto, no tiene plantillas, puede usarlo
eval
para construir un sistema que sea algo similar, pero luego Me encontré con inconvenientes similares: tendrías que hacer el equivalente del trabajo del compilador de C ++ en tiempo de ejecución, y tendrías que preocuparte por la gran cantidad de código que estás generando).Sí, ese es un problema general con JavaScript. V8 solía implementar ciertos componentes internos (cosas como
Array.sort
) en JavaScript internamente, y este problema (que llamamos "contaminación por retroalimentación de tipo") fue una de las razones principales por las que nos hemos alejado completamente de esa técnica.Dicho esto, para el código numérico, no hay tantos tipos (solo Smis y dobles), y como notó, deberían tener un rendimiento similar en la práctica, por lo que si bien la contaminación por retroalimentación de tipo es realmente una preocupación teórica, y en algunos casos puede tener un impacto significativo, también es bastante probable que en escenarios de álgebra lineal no veas una diferencia medible.
Además, dentro del motor hay muchas más situaciones que "un tipo == rápido" y "más de un tipo == lento". Si una operación determinada ha visto tanto Smis como dobles, está totalmente bien. Cargar elementos de dos tipos de matrices también está bien. Usamos el término "megamórfico" para la situación en la que una carga ha visto tantos tipos diferentes que se da por vencida de rastrearlos individualmente y en su lugar usa un mecanismo más genérico que se escala mejor a grandes cantidades de tipos, una función que contiene tales cargas puede Todavía se optimiza. Una "desoptimización" es el acto muy específico de tener que tirar el código optimizado para una función porque se ve un nuevo tipo que no se ha visto anteriormente, y que el código optimizado, por lo tanto, no está equipado para manejar. Pero incluso eso está bien: simplemente regrese al código no optimizado para recopilar más comentarios de tipo y optimice nuevamente más tarde. Si esto sucede un par de veces, entonces no hay nada de qué preocuparse; solo se convierte en un problema en casos patológicamente malos.
Entonces, el resumen de todo eso es: no te preocupes por eso . Simplemente escriba un código razonable, deje que el motor se encargue de ello. Y por "razonable", quiero decir: lo que tiene sentido para su caso de uso, es legible, mantenible, utiliza algoritmos eficientes, no contiene errores como leer más allá de la longitud de las matrices. Idealmente, eso es todo, y no necesitas hacer nada más. Si te hace sentir mejor hacer algo , y / o si realmente estás observando problemas de rendimiento, puedo ofrecerte dos ideas:
Usar TypeScript puede ayudar. Gran advertencia: los tipos de TypeScript están dirigidos a la productividad del desarrollador, no al rendimiento de la ejecución (y resulta que esas dos perspectivas tienen requisitos muy diferentes de un sistema de tipos). Dicho esto, hay una cierta superposición: por ejemplo, si anotas constantemente las cosas como
number
, entonces el compilador de TS te advertirá si accidentalmente colocasnull
una matriz o función que se supone que solo contiene / opera en números. Por supuesto, aún se requiere disciplina: una únicanumber_func(random_object as number)
trampilla de escape puede socavar todo silenciosamente, porque la corrección de las anotaciones de tipo no se aplica en ningún lado.Usar TypedArrays también puede ayudar. Tienen un poco más de sobrecarga (consumo de memoria y velocidad de asignación) por matriz en comparación con las matrices regulares de JavaScript (por lo que si necesita muchas matrices pequeñas, entonces las matrices regulares son probablemente más eficientes), y son menos flexibles porque no pueden crecer o reducir después de la asignación, pero proporcionan la garantía de que todos los elementos tienen exactamente un tipo.
No, y eso es intencional. Como se explicó anteriormente, no queremos que adapte específicamente su código a los patrones que V8 pueda optimizar particularmente bien hoy, y tampoco creemos que realmente quiera hacerlo. Ese conjunto de cosas puede cambiar en cualquier dirección: si hay un patrón que te encantaría usar, podríamos optimizarlo en una versión futura (previamente hemos jugado con la idea de almacenar enteros de 32 bits sin caja como elementos de matriz ... . pero el trabajo sobre eso aún no ha comenzado, así que no hay promesas); y, a veces, si hay un patrón para el que solíamos optimizar en el pasado, podríamos decidir abandonarlo si se interpone en el camino de otras optimizaciones más importantes / impactantes. Además, cosas como la heurística en línea son notoriamente difíciles de corregir, Por lo tanto, tomar la decisión correcta en el momento adecuado es un área de investigación en curso y los cambios correspondientes en el comportamiento del motor / compilador; lo que hace que este sea otro caso en el que sería desafortunado para todos (ustedy a nosotros) si pasaste mucho tiempo modificando tu código hasta que un conjunto de versiones actuales del navegador toma aproximadamente las decisiones de línea que crees (¿o sabes?) son las mejores, solo para regresar medio año después para darte cuenta de que los navegadores actuales han cambiado sus heurísticas.
Por supuesto, siempre puede medir el rendimiento de su aplicación como un todo; eso es lo que en última instancia importa, no las elecciones específicamente hechas por el motor internamente. Tenga cuidado con los microbenchmarks, ya que son engañosos: si solo extrae dos líneas de código y las compara, entonces lo más probable es que el escenario sea lo suficientemente diferente (por ejemplo, retroalimentación de tipo diferente) que el motor tomará decisiones muy diferentes.
fuente
Array.sort()
? Me encantaría leer un poco más al respecto.