Una de las características principales de Linkurious Enterprise es una interfaz de visualización de gráficos fácil de aprender y usar diseñada para legos. En 2015, frustrados con las capacidades de las bibliotecas JavaScript existentes para la visualización de gráficos, comenzamos a desarrollar nuestra propia biblioteca: Ogma.
Ogma es una biblioteca JS de renderizado y rendimiento computacional destinada a renderizar estructuras de red. Es posible que haya visto cómo se representan las estructuras de red utilizando otras herramientas de JavaScript como D3.js o Sigma.js. Carecíamos de las capacidades de estas herramientas. Para nosotros era importante que la solución que estábamos usando tuviera algunas capacidades específicas para que cumpliera con ciertos requisitos de rendimiento. No encontramos ni uno ni otro en bibliotecas de terceros. Por lo tanto, decidimos desarrollar nuestra propia biblioteca desde cero.
Tarea
Visualización de la estructura de la red
La biblioteca Ogma ha sido diseñada para utilizar los algoritmos más avanzados. Todos sus componentes, desde el motor de renderizado avanzado basado en WebGL hasta los trabajadores web utilizados en su profundidad, han tenido como objetivo proporcionar el mejor rendimiento de renderizado de red disponible. Nuestro objetivo era hacer que la biblioteca fuera muy interactiva al realizar tareas a largo plazo, y para que los principales algoritmos de visualización de gráficos implementados en ella funcionaran de manera rápida y estable.
La tecnología WebAssembly (Wasm), desde el momento en que se informó por primera vez, ha prometido a los desarrolladores web un nivel de rendimiento que se compara favorablemente con lo que antes tenían disponible. Al mismo tiempo, los propios desarrolladores no necesitaron hacer esfuerzos excesivos para utilizar la nueva tecnología. Solo tenían que escribir código en un lenguaje de alto rendimiento que conocían, que, después de la compilación, se podía ejecutar en un navegador.
Después de que la tecnología Wasm se desarrolló un poco, decidimos probarla y realizar algunas pruebas de rendimiento antes de implementarla. En general, antes de saltar al tren rápido de Wasm sin mirar atrás, decidimos ir a lo seguro.
Lo que nos atrajo de Wasm fue que esta tecnología podría resolver nuestros problemas, utilizando la memoria y los recursos del procesador de manera más eficiente que JavaScript.
Nuestra investigación
Nuestra investigación comenzó con la búsqueda de un algoritmo destinado a trabajar con gráficos que pudieran trasladarse fácilmente a diferentes idiomas utilizando estructuras de datos similares.
Elegimos el algoritmo de n cuerpos. A menudo se utiliza como base para comparar algoritmos de visualización de gráficos de fuerza. Los cálculos realizados de acuerdo con este algoritmo son la parte más intensiva en recursos de los sistemas de visualización. Si esta parte del trabajo de dichos sistemas pudiera realizarse de manera más eficiente que antes, esto tendría un efecto muy positivo en todos los algoritmos de visualización de gráficos de fuerza implementados en Ogma.
Punto de referencia
Hay mentiras, mentiras groseras y puntos de referencia.
Max De Marzi
Desarrollar un punto de referencia "honesto" es a menudo una tarea imposible. El caso es que en un entorno creado artificialmente, es difícil reproducir escenarios típicos del mundo real. Crear un entorno adecuado para sistemas complejos siempre es extremadamente difícil. De hecho, en un entorno de laboratorio es fácil controlar los factores externos, pero en realidad, muchas influencias inesperadas sobre cómo se ve el "rendimiento" de la solución.
En nuestro caso, el benchmark tenía como objetivo resolver un único problema bien definido, para estudiar el desempeño de la implementación del algoritmo de n-cuerpos.
Es un algoritmo claro y bien documentado utilizado por organizaciones respetadas para medir el desempeño.lenguajes de programación.
Al igual que con cualquier prueba justa, predefinimos algunas reglas para los diferentes idiomas que estudiamos:
- Las diferentes implementaciones del algoritmo deberían utilizar estructuras de código similares.
- Está prohibido utilizar múltiples procesos o múltiples subprocesos.
- Está prohibido el uso de SIMD .
- Solo se prueban las versiones estables de compiladores. Está prohibido utilizar versiones como nightly, beta, alpha, pre-alpha.
- Solo se utilizan las versiones más recientes del compilador para cada idioma.
Una vez que decidimos las reglas, ya podríamos proceder a la implementación del algoritmo. Pero antes de hacer esto, también tuvimos que elegir los lenguajes cuyo desempeño estaremos investigando.
Competidores JS
Wasm es un formato binario para instrucciones que se ejecutan en un navegador. El código escrito en varios lenguajes de programación se compila en este formato. Dicen que el código Wasm es un código legible por humanos, pero escribir programas directamente en Wasm no es una decisión que pueda tomar una persona en su sano juicio. Como resultado, realizamos una encuesta sobre el índice de referencia y seleccionamos a los siguientes candidatos:
El algoritmo de n-cuerpos se implementó en estos tres lenguajes. El rendimiento de varias implementaciones se comparó con el rendimiento de la implementación básica de JavaScript.
En cada una de las implementaciones del algoritmo, usamos 1000 puntos y ejecutamos el algoritmo con un número diferente de iteraciones. Medimos el tiempo que tomó completar cada sesión del programa.
Las pruebas se llevaron a cabo utilizando el siguiente software y hardware:
- NodeJS 12.9.1
- Chrome 79.0.3945.130 (compilación oficial) (64 bits)
- clang 10.0.0 - para la versión del algoritmo implementado en el lenguaje C
- emcc 1.39.6 - interfaz para llamar al compilador Emscripten desde la línea de comandos, una alternativa a gcc / clang, así como un enlazador
- carga 1.40.0
- paquete de wasm 0.8.1
- AssemblyScript 0.9.0
- MacOS 10.15.2
- Macbook Pro 2017 Retina
- Intel Dual Core i5 2,3 GHz, 8 GB DDR3, SSD de 256 GB
Como puede ver, para las pruebas no elegimos la computadora más rápida, sino que estamos probando Wasm, es decir, el código que se ejecutará en el contexto del navegador. Y el navegador, de todos modos, no suele tener acceso a todos los núcleos de procesador disponibles en el sistema y a toda la RAM.
Para hacerlo más interesante, creamos varias versiones de cada implementación del algoritmo. En un punto del sistema de n cuerpos, tenían una representación numérica de coordenadas de 64 bits, en el otro, 32 bits.
También vale la pena señalar que utilizamos una implementación "doble" del algoritmo en Rust. Primero, sin usar ninguna herramienta de Wasm, se escribió la implementación de Rust "original", "insegura". Más tarde, utilizando wasm-pack, se creó una implementación de Rust "segura" adicional. Se esperaba que esta implementación del algoritmo fuera más fácil de integrar con JS y que pudiera administrar mejor la memoria en Wasm.
Pruebas
Realizamos nuestros experimentos en dos entornos principales. Este es Node.js y el navegador (Chrome).
En ambos casos, los puntos de referencia se realizaron utilizando un guión cálido. Es decir, la recolección de basura no se inició antes de ejecutar las pruebas. Cuando ejecutamos la recolección de basura después de ejecutar cada prueba, no pareció tener mucho impacto en los resultados.
Basado en el código fuente escrito en AssemblyScript, se generó lo siguiente:
- Implementación JS básica del algoritmo.
- Módulo Wasm.
- Módulo asm.js.
Es interesante notar que el módulo asm.js no era totalmente compatible con asm.js. Intentamos agregar una directiva "use asm" en la parte superior del módulo, pero el navegador no aceptó esta optimización. Más tarde descubrimos que el compilador binaryen que usamos no estaba tratando de hacer que el código fuera totalmente compatible con asm.js. En cambio, se centró en formar una especie de versión JS eficiente de Wasm.
Primero ejecutamos pruebas en Node.js.
Ejecutando el código en el entorno Node.js
Luego medimos el rendimiento del mismo código en el navegador.
Ejecución del código en un navegador
Inmediatamente notamos que la versión asm.js del código funciona más lentamente que las otras opciones. Pero estos gráficos no nos permiten comparar claramente los resultados de varias variantes de código con la implementación JS básica del algoritmo. Por lo tanto, para comprender mejor todo, construimos algunos diagramas más.
Diferencias entre otras implementaciones del algoritmo y la implementación de JS (variante de referencia con coordenadas de puntos de 64 bits)
Las diferencias entre otras implementaciones del algoritmo y la implementación JS (versión de referencia con coordenadas de punto de 32 bits)
Las versiones de referencia con coordenadas de punto de 64 y 32 bits difieren notablemente. Esto puede llevarnos a pensar que en JavaScript, los números pueden ser ambos. El hecho es que los números en JS, en la implementación del algoritmo, tomados como base de comparación, son siempre de 64 bits, pero los compiladores que convierten código de otros lenguajes a Wasm trabajan con tales números de diferentes maneras.
En particular, esto tiene un gran impacto en la versión asm.js de la prueba. Su versión con coordenadas de puntos de 32 bits es muy inferior en rendimiento tanto a la implementación básica de JS como a la versión asm.js, que usa números de 64 bits.
En los diagramas anteriores, es difícil entender cómo se compara el rendimiento de otras variantes de código con la variante JS. Esto se debe a que las métricas de AssemblyScript son demasiado diferentes del resto. Para entender esto, construimos otro diagrama, eliminando los resultados de asm.js.
Diferencias entre otras implementaciones del algoritmo de la implementación de JS (versión de referencia con coordenadas de puntos de 64 bits, sin asm.js)
Diferencias entre otras implementaciones del algoritmo y la implementación JS (versión de referencia con coordenadas de puntos de 32 bits, sin asm.js)
Las diferentes representaciones de números parecen haber influido en otras versiones de la prueba. Pero influyeron de diferentes formas. Por ejemplo, la variante C, que usaba números de 32 bits (flotantes), se volvió más lenta que la variante C, que usaba números de 64 bits (doble). Ambas versiones de Rust de la prueba con números de 32 bits (f32) se volvieron más rápidas que sus versiones con números de 64 bits (f64).
¿Mala implementación del algoritmo?
El análisis de los datos anteriores puede sugerir la siguiente idea. Dado que todos los ensamblados de Wasm probados tienen un rendimiento muy similar al de las implementaciones de JavaScript, ¿es posible que las implementaciones de Wasm solo reflejen las características de rendimiento de las implementaciones de algoritmos nativos?
Comparación de implementaciones nativas de un algoritmo con una implementación de JavaScript
Las versiones nativas de una implementación de algoritmo son siempre más rápidas que una implementación de JavaScript.
También notamos que los ensamblados de Wasm son más lentos que las versiones nativas del código utilizado para crear dichos ensamblados. La diferencia de rendimiento es del 20 al 50%. Descubrimos esto en una versión abreviada del punto de referencia con 1000 iteraciones.
Implementación C y ensamblaje Wasm correspondiente
Implementación de Rust y construcción de Wasm correspondiente
Implementación de Rust, que se creó mediante wasm-pack, y el correspondiente ensamblado de Wasm
Al medir el tiempo de ejecución del código nativo también se tuvo en cuenta el tiempo requerido para cargar el programa, y en el caso de las variantes de Wasm, este tiempo no se tuvo en cuenta.
Salir
En promedio, la ganancia de rendimiento de las dos implementaciones de Rust del algoritmo, en comparación con su implementación JS básica, fue del 20%. Esto probablemente sea bueno para la imagen de Rust, pero es una ganancia de rendimiento muy pequeña en comparación con el esfuerzo que tomó para obtenerla.
¿Qué conclusiones podemos sacar de estas pruebas? Y aquí hay algunos: la escritura cuidadosa de código JS le permite obtener un rendimiento bastante alto y no requiere cambiar a otros lenguajes de programación.
Aprender nuevos lenguajes de programación siempre es bueno. Pero debe haber buenas razones para aprender nuevos idiomas. El rendimiento es a menudo la razón "incorrecta", ya que la arquitectura de alto nivel del proyecto afecta el rendimiento más que los compiladores o las microoptimizaciones.
Como experimento, cambiamos JavaScript a TypeScript para escribir una implementación del algoritmo de visualización de gráficos de fuerza. Como resultado, hemos mejorado la calidad del código base, pero no el rendimiento. Medimos específicamente el rendimiento y resultó que después de la transición, aumentó ligeramente, en un 5%. Probablemente la razón sea la refactorización del código.
Si está interesado en el desarrollo y el rendimiento de JavaScript, es posible que le interese ver esta charla, que tuvo resultados similares a los que obtuvimos.
¿Cómo aborda el desarrollo de las partes "pesadas" de los proyectos web?