Cómo reemplazar una ruta de acceso caliente en el JavaScript de tu app con WebAssembly

Es siempre rápido,

En mi anterior artículos en los que hablé de cómo WebAssembly te permite llevar el ecosistema de bibliotecas de C/C++ a la Web. Una app que hace un uso extensivo de las bibliotecas C/C++ es squoosh, nuestra que te permite comprimir imágenes con una variedad de códecs compilada de C++ a WebAssembly.

WebAssembly es una máquina virtual de bajo nivel que ejecuta el código de bytes que se almacena en archivos .wasm. Este código de bytes está bien escrito y estructurado de tal manera que se puede compilar y optimizar para el sistema host mucho más rápido que JavaScript sí puede hacerlo. WebAssembly proporciona un entorno para ejecutar código que tiene la zona de pruebas y la incorporación desde el principio.

Según mi experiencia, la mayoría de los problemas de rendimiento en la Web se deben a y exceso de pintura; pero, de vez en cuando, una aplicación necesita realizar una es una tarea costosa desde el punto de vista informático que lleva mucho tiempo. WebAssembly puede ayudarte aquí.

El camino más popular

En Squoosh, escribimos una función de JavaScript. que rota un búfer de imagen en múltiplos de 90 grados. Mientras que OffscreenCanvas sería ideal para ya que no es compatible con los navegadores a los que orientamos los anuncios, error de Chrome.

Esta función itera en cada píxel de una imagen de entrada y la copia desde una posición diferente en la imagen de salida para lograr una rotación. Para una resolución de 4094px por Una imagen de 4096 px (16 megapíxeles) necesitaría más de 16 millones de iteraciones del bloque de código interno, que es lo que llamamos una “ruta de acceso caliente”. A pesar de que es bastante grande cantidad de iteraciones, dos de los tres navegadores que probamos terminan la tarea en 2 segundos o menos. Es una duración aceptable para este tipo de interacción.

for (let d2 = d2Start; d2 >= 0 && d2 < d2Limit; d2 += d2Advance) {
    for (let d1 = d1Start; d1 >= 0 && d1 < d1Limit; d1 += d1Advance) {
    const in_idx = ((d1 * d1Multiplier) + (d2 * d2Multiplier));
    outBuffer[i] = inBuffer[in_idx];
    i += 1;
    }
}

Sin embargo, un navegador tarda más de 8 segundos. La forma en que los navegadores optimizan JavaScript es realmente complicado, y diferentes motores se optimizan para distintas situaciones. Algunos optimizan la ejecución sin procesar y otros lo hacen para la interacción con el DOM. En este caso, encontramos una ruta de acceso no optimizada en un navegador.

WebAssembly, por otro lado, se basa completamente en la velocidad de ejecución sin procesar. De esta manera, si queremos un rendimiento rápido y predecible en todos los navegadores para un código como este, WebAssembly puede ayudarte.

WebAssembly para un rendimiento predecible

En general, JavaScript y WebAssembly pueden lograr el mismo rendimiento máximo. Sin embargo, en el caso de JavaScript, este rendimiento solo se puede alcanzar en la "ruta rápida", y, a menudo, no es fácil seguir esa "ruta rápida". Un beneficio clave que Las ofertas de WebAssembly tienen un rendimiento predecible, incluso en todos los navegadores. Estricta de escritura y la arquitectura de bajo nivel permiten que el compilador refuerce garantías para que el código de WebAssembly solo deba optimizarse una vez y utiliza siempre la “ruta rápida”.

Escribe para WebAssembly

Anteriormente, tomamos bibliotecas C/C++ y las compilamos en WebAssembly para usar sus en la Web. No tocamos el código de las bibliotecas, escribiste pequeñas cantidades de código C/C++ para formar un puente entre el navegador y la biblioteca. Esta vez, nuestra motivación es diferente: queremos escribir algo desde cero con WebAssembly en mente para poder usar el que ofrece WebAssembly.

Arquitectura de WebAssembly

Cuando escribes para WebAssembly, es útil comprender un poco más sobre qué es WebAssembly.

Cita de WebAssembly.org:

Cuando compilas un fragmento de código C o Rust en WebAssembly, obtienes un .wasm. que contiene una declaración de módulo. Esta declaración consiste en una lista de "importaciones" que el módulo espera de su entorno, una lista de exportaciones que este módulo pone a disposición del host (funciones, constantes, fragmentos de memoria) y por supuesto, las instrucciones binarias reales para las funciones que contiene.

Algo que no me di cuenta hasta que aprendí esto: la pila que hace que WebAssembly, una “máquina virtual basada en pilas” no se almacena en el bloque de que usan los módulos de WebAssembly. La pila es completamente interna de la VM y inaccesibles para los desarrolladores web (excepto a través de Herramientas para desarrolladores). De este modo, es posible para escribir módulos de WebAssembly que no necesitan memoria adicional solo debe usar la pila interna de VM.

En nuestro caso, necesitaremos usar memoria adicional para permitir el acceso arbitrario a los píxeles de nuestra imagen y generar una versión rotada de esa imagen. Este es para qué sirve WebAssembly.Memory.

Administración de la memoria

Por lo general, una vez que uses memoria adicional, tendrás la necesidad de administrar esa memoria. ¿Qué partes de la memoria están en uso? ¿Cuáles son gratuitos? En C, por ejemplo, tienes la función malloc(n) que encuentra un espacio de memoria. de n bytes consecutivos. Las funciones de este tipo también se denominan “asignadores”. Por supuesto, la implementación del asignador en uso debe incluirse en tu WebAssembly y aumentará el tamaño de tu archivo. Este tamaño y rendimiento de estas funciones de administración de memoria pueden variar bastante el algoritmo utilizado, por lo que muchos lenguajes ofrecen múltiples implementaciones para elegir ("dmalloc", "emmalloc", "wee_alloc", etc.).

En nuestro caso, conocemos las dimensiones de la imagen de entrada (y, por lo tanto, la dimensiones de la imagen de salida) antes de ejecutar el módulo de WebAssembly. Aquí viste una oportunidad: tradicionalmente, pasamos el búfer RGBA de la imagen de entrada como parámetro a una función de WebAssembly y devolver la imagen rotada como un resultado valor. Para generar ese valor de retorno, tendríamos que usar el asignador. Sin embargo, como conocemos la cantidad total de memoria necesaria (el doble del tamaño de la entrada una para la entrada y otra para la salida), podemos colocarla Memoria de WebAssembly con JavaScript, ejecuta el módulo de WebAssembly para generar un En segundo lugar, se rotó la imagen y, luego, usa JavaScript para volver a leer el resultado. Podemos obtener sin usar ninguna administración de memoria.

Nada mal a elección

Si analizaste la función original de JavaScript que queremos con WebAssembly-fy, puedes ver que es un modelo puramente código sin APIs específicas de JavaScript. Por lo tanto, debe ser bastante recto para transferir este código a cualquier lenguaje. Evaluamos 3 idiomas diferentes que compilan en WebAssembly: C/C++, Rust y AssemblyScript. La única pregunta necesitamos responder para cada uno de los lenguajes es: ¿Cómo accedemos a la memoria sin procesar sin usar funciones de administración de la memoria?

C y Emscripten

Emscripten es un compilador de C para el objetivo de WebAssembly. El objetivo de Emscripten es como reemplazo directo de compiladores C conocidos, como GCC o clang. y es mayormente compatible con marcas. Esta es una parte central de la misión del Emscripten ya que desea que la compilación de código C y C++ existente en WebAssembly sea tan fácil como como sea posible.

El acceso a la memoria sin procesar es algo propio de la naturaleza de C y los punteros existen para ese motivo:

uint8_t* ptr = (uint8_t*)0x124;
ptr[0] = 0xFF;

Aquí convertimos el número 0x124 en un puntero de 8 bits sin firma números enteros (o bytes). Esto convierte efectivamente la variable ptr en un array. comenzando por la dirección de memoria 0x124, que podemos usar como cualquier otro array, lo que nos permite acceder a bytes individuales para lectura y escritura. En nuestro caso, un búfer RGBA de una imagen que queremos reordenar para obtener y la rotación de claves. Para mover un píxel, necesitamos mover 4 bytes consecutivos a la vez (un byte para cada canal: R, G, B y A). Para facilitar este proceso, podemos crear array de enteros de 32 bits sin firma. Por convención, nuestra imagen de entrada comenzará en la dirección 4. La imagen de salida comenzará inmediatamente después de la imagen finaliza:

int bpp = 4;
int imageSize = inputWidth * inputHeight * bpp;
uint32_t* inBuffer = (uint32_t*) 4;
uint32_t* outBuffer = (uint32_t*) (inBuffer + imageSize);

for (int d2 = d2Start; d2 >= 0 && d2 < d2Limit; d2 += d2Advance) {
    for (int d1 = d1Start; d1 >= 0 && d1 < d1Limit; d1 += d1Advance) {
    int in_idx = ((d1 * d1Multiplier) + (d2 * d2Multiplier));
    outBuffer[i] = inBuffer[in_idx];
    i += 1;
    }
}

Después de transferir toda la función de JavaScript a C, podemos compilar el archivo C. con emcc:

$ emcc -O3 -s ALLOW_MEMORY_GROWTH=1 -o c.js rotate.c

Como siempre, emscripten genera un archivo de código de adhesión llamado c.js y un módulo de wasm. llamada c.wasm. Ten en cuenta que el módulo wasm se comprime en gzip a solo 260 bytes, mientras que es de 3.5 KB después de gzip. Después de jugar un poco, pudimos deshacernos de el código de adhesión y crear una instancia de los módulos de WebAssembly con las APIs básicas. Esto suele ser posible con Emscripten, siempre y cuando no uses nada de la biblioteca de C estándar.

Rust

Rust es un lenguaje de programación nuevo y moderno con un sistema de tipos enriquecidos y sin tiempo de ejecución y un modelo de propiedad que garantiza la seguridad de la memoria y de los subprocesos. Óxido también es compatible con WebAssembly como función principal y el equipo de Rust contribuyó con muchas herramientas excelentes al ecosistema de WebAssembly.

Una de estas herramientas es wasm-pack, el grupo de trabajo de Rusttwasm. wasm-pack toma tu código y lo convierte en un módulo apto para la Web que funciona listo para usar con agrupadores como Webpack. wasm-pack es una experiencia conveniente, pero actualmente solo funciona con Rust. El grupo es y considerando agregar compatibilidad con otros idiomas de segmentación de WebAssembly.

En Rust, las porciones son los arrays que están en C. Y, al igual que en C, debemos crear porciones que usan nuestras direcciones de inicio. Esto va en contra del modelo de seguridad de memoria que Rust aplica de manera forzosa, por lo que debemos usar la palabra clave unsafe, lo que nos permite escribir código que no cumple con ese modelo.

let imageSize = (inputWidth * inputHeight) as usize;
let inBuffer: &mut [u32];
let outBuffer: &mut [u32];
unsafe {
    inBuffer = slice::from_raw_parts_mut::<u32>(4 as *mut u32, imageSize);
    outBuffer = slice::from_raw_parts_mut::<u32>((imageSize * 4 + 4) as *mut u32, imageSize);
}

for d2 in 0..d2Limit {
    for d1 in 0..d1Limit {
    let in_idx = (d1Start + d1 * d1Advance) * d1Multiplier + (d2Start + d2 * d2Advance) * d2Multiplier;
    outBuffer[i as usize] = inBuffer[in_idx as usize];
    i += 1;
    }
}

Compilar los archivos de Rust con

$ wasm-pack build

produce un módulo de wasm de 7.6 KB con aproximadamente 100 bytes de código de adhesión (ambos después de gzip).

AssemblyScript

AssemblyScript es un proyecto joven que tiene como objetivo ser un compilador de TypeScript a WebAssembly. Es Sin embargo, es importante tener en cuenta que no consumirá elementos de TypeScript. AssemblyScript usa la misma sintaxis que TypeScript, pero cambia el estándar su propia biblioteca. Su biblioteca estándar modela las capacidades WebAssembly Eso significa que no puedes compilar cualquier tipo de TypeScript que tengas WebAssembly, pero significa que no tienes que aprender un nuevo de programación para escribir WebAssembly.

    for (let d2 = d2Start; d2 >= 0 && d2 < d2Limit; d2 += d2Advance) {
      for (let d1 = d1Start; d1 >= 0 && d1 < d1Limit; d1 += d1Advance) {
        let in_idx = ((d1 * d1Multiplier) + (d2 * d2Multiplier));
        store<u32>(offset + i * 4 + 4, load<u32>(in_idx * 4 + 4));
        i += 1;
      }
    }

Teniendo en cuenta la superficie de tipo pequeño que tiene nuestra función rotate(), se y es bastante fácil transferir este código a AssemblyScript. AssemblyScript proporciona las funciones load<T>(ptr: usize) y store<T>(ptr: usize, value: T) para acceder a la memoria sin procesar. Para compilar nuestro archivo AssemblyScript, haz lo siguiente: Solo necesitamos instalar el paquete npm AssemblyScript/assemblyscript y ejecutar

$ asc rotate.ts -b assemblyscript.wasm --validate -O3

AssemblyScript nos proporcionará un módulo wasm de aproximadamente 300 bytes y sin código de adhesión. El módulo simplemente funciona con las APIs convencionales de WebAssembly.

Investigación forense de WebAssembly

El tamaño de 7.6 KB de Rust es sorprendentemente grande en comparación con los otros 2 lenguajes. Hay hay un par de herramientas del ecosistema de WebAssembly que pueden ayudarte a analizar tus archivos de WebAssembly (independientemente del idioma en el que se crearon) y te dirán lo que sucede y te ayudarán a mejorar tu situación.

Twiggy

Twiggy es otra herramienta de Rust equipo de WebAssembly que extrae muchos datos útiles de un objeto WebAssembly módulo. La herramienta no es específica de Rust y te permite inspeccionar elementos como el gráfico de llamadas del módulo, determinar las secciones superfluas o sin usar qué secciones contribuyen al tamaño total de archivo de tu módulo. El Esto se puede hacer con el comando top de Twiggy:

$ twiggy top rotate_bg.wasm
Captura de pantalla de la instalación de Twiggy

En este caso, podemos ver que la mayor parte del tamaño de nuestro archivo se debe al asignador. Eso fue sorprendente, ya que nuestro código no usa asignaciones dinámicas. Otro factor contribuyente importante son los “nombres de las funciones” subsección.

tira de impermeables

wasm-strip es una herramienta de WebAssembly Binary Toolkit (o wabt). Contiene un un par de herramientas que te permiten inspeccionar y manipular los módulos de WebAssembly. wasm2wat es un desensamblador que convierte un módulo de wasm binario en un en un formato legible por humanos. Wabt también contiene wat2wasm, que te permite girar en un módulo de Wasm binario. Si bien usábamos estas dos herramientas complementarias para inspeccionar nuestros archivos de WebAssembly, descubrimos wasm-strip para que sean más útiles. wasm-strip quita secciones innecesarias. y metadatos de un módulo de WebAssembly:

$ wasm-strip rotate_bg.wasm

Esto reduce el tamaño del archivo del módulo de Rust de 7.5 KB a 6.6 KB (después de gzip).

wasm-opt

wasm-opt es una herramienta de Binaryen. Toma un módulo de WebAssembly y trata de optimizarlo en cuanto al tamaño y y mejorar el rendimiento basado únicamente en el código de bytes. Algunas herramientas, como Emscripten, ya se ejecutan esta herramienta, mientras que otras no. Por lo general, es una buena idea ahorrar bytes adicionales con estas herramientas.

wasm-opt -O3 -o rotate_bg_opt.wasm rotate_bg.wasm

Con wasm-opt, podemos quitar otros bytes para dejar un total de 6.2 KB después de gzip

#![no_std]

Después de investigar y consultar, reescribimos nuestro código Rust sin usar la biblioteca estándar de Rust #![no_std] . Esto también inhabilita por completo las asignaciones de memoria dinámica, lo que elimina asignable de nuestro módulo. Compilar este archivo de Rust con

$ rustc --target=wasm32-unknown-unknown -C opt-level=3 -o rust.wasm rotate.rs

produjo un módulo de wasm de 1.6 KB después de wasm-opt, wasm-strip y gzip. Mientras esté sigue siendo más grande que los módulos generados por C y AssemblyScript, es pequeño lo suficiente para que se considere ligero.

Rendimiento

Antes de sacar conclusiones solo basadas en el tamaño del archivo, seguimos este recorrido para optimizar el rendimiento, no el tamaño del archivo. Entonces, ¿cómo medimos el rendimiento ¿cuáles fueron los resultados?

Cómo obtener comparativas

A pesar de que WebAssembly es un formato de código de bytes de bajo nivel, aún debe enviarse a través de un compilador para generar código máquina específico del host. Al igual que JavaScript, el compilador funciona en varias etapas. En pocas palabras: la primera etapa es mucho más rápido en la compilación, pero tiende a generar código más lento. Cuando se inicie el módulo en ejecución, el navegador observa qué partes se utilizan con frecuencia y las envía con un compilador más optimizado pero más lento.

Nuestro caso de uso es interesante porque se usará el código para rotar una imagen una, tal vez dos veces. Por eso, en la gran mayoría de los casos, nunca obtendremos del compilador de optimización. Es importante tener esto en cuenta cuando comparativas. Ejecutar los módulos de WebAssembly 10,000 veces en bucle daría resultados poco realistas. Para obtener cifras realistas, debemos ejecutar el módulo una vez y y tomar decisiones basadas en las cifras de esa ejecución.

Comparación del rendimiento

Comparación de velocidad por idioma
Comparación de velocidad por navegador

Estos dos gráficos son vistas diferentes de los mismos datos. En el primer gráfico, comparamos por navegador; en el segundo gráfico, comparamos por idioma utilizado. Por favor, tenga en cuenta que elegí una escala de tiempo logarítmica. También es importante que todos comparativas usaban la misma imagen de prueba de 16 megapíxeles y el mismo host excepto por un navegador, que no pudo ejecutarse en la misma máquina.

Sin analizar demasiado estos gráficos, está claro que resolvimos la resolución original problema de rendimiento: todos los módulos de WebAssembly se ejecutan en unos 500 ms o menos. Esta confirma lo que dijimos al principio: WebAssembly brinda un servicio predecible rendimiento. Independientemente del idioma que elegimos, la variación entre los navegadores y lenguajes es mínimo. Para ser exactos: La desviación estándar de JavaScript en todos los navegadores es de ~400 ms, mientras que la desviación estándar de todos de los módulos de WebAssembly entre todos los navegadores es de unos 80 ms.

Esfuerzo

Otra métrica es el esfuerzo que tuvimos que esforzarnos para crear e integrar nuestro módulo de WebAssembly en squoosh. Es difícil asignar un valor numérico a esfuerzo, por lo que no crearé gráficos, pero hay algunas cosas que me gustaría señala:

AssemblyScript no tuvo inconvenientes. No solo te permite usar TypeScript para escribir WebAssembly, lo que hace que la revisión de código sea muy fácil para mis colegas, pero también produce módulos WebAssembly sin pegamento que son muy pequeños con rendimiento. Las herramientas del ecosistema de TypeScript, como Prettier y Tlint, es probable que simplemente funcione.

Rust en combinación con wasm-pack también es muy conveniente, pero se destaca más en proyectos más grandes de WebAssembly eran las vinculaciones y según tus necesidades. Tuvimos que desviarnos un poco del camino feliz para lograr un el tamaño del archivo.

C y Emscripten crearon un módulo de WebAssembly muy pequeño y de alto rendimiento. están listos para usar, pero sin el coraje de pasar al código lo esencial, el tamaño total (módulo de WebAssembly + código de adhesión) termina por ser bastante grande.

Conclusión

¿Qué lenguaje debes usar si tienes una ruta de acceso caliente de JS y quieres que sea más rápido o más coherente con WebAssembly. Como siempre, con el rendimiento preguntas, la respuesta es: Depende. ¿Qué enviamos?

Gráfico de comparación

Comparación de la compensación entre tamaño de módulo y rendimiento de los diferentes idiomas que usamos, parece que la mejor opción es C o AssemblyScript. Decidimos lanzar Rust. Hay hay varios motivos para tomar esta decisión: todos los códecs enviados en Squoosh se compilan con Emscripten. Queríamos ampliar nuestro conocimiento de WebAssembly y usar un lenguaje diferente en producción. AssemblyScript es una alternativa sólida, pero el proyecto es relativamente nuevo y el compilador no está tan maduro como el compilador de Rust.

Mientras que la diferencia en el tamaño del archivo entre Rust y los otros lenguajes se ve bastante drástica en el gráfico de dispersión, no es tan importante en realidad: Cargar 500 B o 1.6 KB, incluso en 2G, lleva menos de 1/10 de segundo. Y Se espera que Rust cierre la brecha en cuanto al tamaño del módulo pronto.

En cuanto al rendimiento del tiempo de ejecución, Rust tiene un promedio más rápido en los navegadores que AssemblyScript Especialmente en proyectos más grandes, es más probable que Rust producir un código más rápido sin la necesidad de optimizaciones manuales. Sin embargo, no debería impedirte usar lo que te resulte más cómodo.

Dicho esto, AssemblyScript fue un gran descubrimiento. Permite que los administradores desarrolladores para producir módulos de WebAssembly sin tener que aprender un nuevo idioma. El equipo de AssemblyScript ha sido muy receptivo y está activamente trabajando para mejorar su cadena de herramientas. Definitivamente, estaremos atentos AssemblyScript en el futuro.

Actualización: Rust

Después de publicar este artículo, Nick Fitzgerald del equipo de Rust nos dirigió a su excelente libro Rust Wasm, una sección sobre cómo optimizar el tamaño de los archivos. Luego de en cada una de las instrucciones (sobre todo, habilitar las optimizaciones del tiempo de vinculación y las actualizaciones manejo de pánico) nos permitió escribir código “normal” de Rust y volver a usar Cargo (el npm de Rust) sin sobredimensionar el tamaño del archivo El módulo Rust finaliza con 370 B después de gzip. Para obtener más información, consulta el comunicado de prensa que abrí en Squoosh.

Un agradecimiento especial a Ashley Williams, Steve Klabnik, Nick Fitzgerald y Max Graey por toda su ayuda en este recorrido.