Escribir código Javascript de alto rendimiento sin desoptimizarse


10

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:

  1. 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.
  2. 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 ambos xy yser paquetes SMI empaquetados, o ambos paquetes dobles empaquetados.
  3. 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:

  1. 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.
  2. Puede convertir una matriz empaquetada en una matriz en caja genérica, por ejemplo, mapeando la matriz sobre una función que (inesperadamente) regresó nullo undefined. Este mal caso es bastante fácil de evitar.
  3. 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", donde vec.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 Aa alta velocidad, y luego importo un paquete Bque depende de él A, pero lo Bllama 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?

1
Ese es un tema muy interesante y una publicación muy bien escrita que muestra que has realizado correctamente tu parte de la investigación. Sin embargo, me temo que la (s) pregunta (s) es demasiado amplia para el formato SO, y también que inevitablemente atraerá más opiniones que hechos. La optimización del código es un asunto muy complicado, y dos versiones de un motor pueden no comportarse igual. Creo que a veces hay una de las personas responsables de V8 JIT que se queda por ahí, así que tal vez podrían dar una respuesta adecuada para su motor, pero incluso para ellos, creo que sería un tema demasiado amplio para un solo Q / A .
Kaiido

"Mi pregunta es más una pregunta blanda, acerca de cómo se escribe un código Javascript de alto rendimiento ..." Además, tenga en cuenta que javascript proporciona la generación de procesos en segundo plano (trabajadores web), y también hay bibliotecas que aprovechan la oferta de GPU (tensorflow.js y gpu.js) significa además de depender únicamente de la compilación para aumentar el rendimiento computacional de una aplicación basada en JavaScript ...
Jon Trent

@JonTrent En realidad mentí un poco en mi publicación, no me importan tanto las aplicaciones clásicas de álgebra lineal, sino más bien el álgebra de computadora sobre los enteros. Esto significa que muchos de los paquetes numéricos existentes se descartan de inmediato, ya que (por ejemplo), mientras reducen las filas de una matriz, pueden dividirse entre 2, lo que "no está permitido" en el mundo en el que estoy trabajando desde (1/2) No es un entero. He considerado a los trabajadores web (especialmente para algunos cálculos de larga ejecución que quiero cancelar), pero el problema que estoy abordando aquí es reducir la latencia lo suficiente como para responder a la interacción.
Joppy

Para la aritmética de enteros en JavaScript, probablemente esté viendo el código de estilo asm.js, más o menos "dejando |0atrá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).
jmrk

Respuestas:


8

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.

¿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)?

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.

¿Es posible hacer programación genérica de alto rendimiento en Javascript sin usar algo como un sistema macro para alinear cosas como vec.add () en los sitios de llamadas?

"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 xun número entero? Si es así, tome esa ruta de código. ¿Es xuna cadena? Luego salte aquí. ¿Es un objeto? ¿Tiene? ¿ .valueOfNo? 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) que xsiempre 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 evalpara 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).

¿Cómo se modulariza 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 A de Álgebra Lineal a alta velocidad, y luego importo un paquete B que depende de A, pero B lo llama con otros tipos y lo optimiza, de repente (sin que mi código cambie) mi código corre más lento .

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 colocas nulluna matriz o función que se supone que solo contiene / opera en números. Por supuesto, aún se requiere disciplina: una única number_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.

¿Hay alguna buena herramienta de medición fácil de usar para verificar lo que el motor Javascript está haciendo internamente con los tipos?

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.


2
Gracias por esta excelente respuesta, confirma muchas de mis sospechas sobre cómo funcionan las cosas y, sobre todo, cómo están destinadas a funcionar. Por cierto, ¿hay alguna publicación de blog, etc., sobre el problema de "comentarios de tipo" que mencionaste Array.sort()? Me encantaría leer un poco más al respecto.
Joppy

No creo que hayamos blogueado sobre ese aspecto en particular. Es esencialmente lo que usted mismo describió en su pregunta: cuando se implementan los builtins en JavaScript, son "como una biblioteca" en el sentido de que si diferentes partes del código los llaman con diferentes tipos, el rendimiento puede verse afectado, a veces solo un poco, a veces más. No fue el único, y posiblemente ni siquiera el mayor problema con esa técnica; Principalmente solo quería decir que estoy familiarizado con el tema general.
jmrk
Al usar nuestro sitio, usted reconoce que ha leído y comprende nuestra Política de Cookies y Política de Privacidad.
Licensed under cc by-sa 3.0 with attribution required.