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 tiene un tipo definido y está estructurado de tal manera que se puede compilar y optimizar para el sistema host mucho más rápido que JavaScript. WebAssembly proporciona un entorno para ejecutar código que tuvo en cuenta 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. Si bien OffscreenCanvas sería ideal para esto, no es compatible con todos los navegadores para los que segmentamos la publicidad y tiene algunos errores en Chrome.

Esta función itera por cada píxel de una imagen de entrada y lo copia a una posición diferente en la imagen de salida para lograr la 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 muy complicada, y los diferentes motores realizan optimizaciones para diferentes aspectos. Algunos optimizan la ejecución sin procesar, mientras que otros optimizan la interacción con el DOM. En este caso, encontramos una ruta no optimizada en un navegador.

Por otro lado, WebAssembly se compila por completo en función de la velocidad de ejecución sin procesar. Por lo tanto, si queremos un rendimiento rápido y predecible en todos los navegadores para un código como este, WebAssembly puede ayudar.

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. En realidad, no tocamos el código de las bibliotecas, solo escribimos pequeñas cantidades de código C/C++ para formar el 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.

Según 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 de lo que no me di cuenta hasta que investigué esto: la pila que hace que WebAssembly sea una "máquina virtual basada en pila" no se almacena en el fragmento de memoria que usan los módulos de WebAssembly. La pila es completamente interna de la VM y los desarrolladores web no pueden acceder a ella (excepto a través de las 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. Para eso 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 se debe incluir en tu módulo de WebAssembly y aumentará el tamaño del 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 hacerlo 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 C para el destino 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 fundamental de la misión de Emscripten, ya que su objetivo es hacer que la compilación del código C y C++ existente en WebAssembly sea lo más fácil posible.

El acceso a la memoria sin procesar es parte de la naturaleza de C, y los punteros existen por ese mismo motivo:

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

Aquí convertimos el número 0x124 en un puntero a números enteros (o bytes) de 8 bits sin signo. Esto convierte, de manera efectiva, la variable ptr en un array que comienza en la dirección de memoria 0x124, que podemos usar como cualquier otro array, lo que nos permite acceder a bytes individuales para leer y escribir. 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 esto, podemos crear un array de números enteros de 32 bits sin firma. Por convención, nuestra imagen de entrada comenzará en la dirección 4 y nuestra imagen de salida comenzará directamente después de que finalice la imagen de entrada:

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 portar 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 algunos ajustes, pudimos descartar el código de unión y crear instancias de los módulos de WebAssembly con las APIs de Vanilla. Esto suele ser posible con Emscripten, siempre que no uses nada de la biblioteca estándar de C.

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 extremadamente conveniente, pero, por el momento, solo funciona para 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 tenemos que 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;
    }
}

Compila los archivos de Rust con

$ wasm-pack build

genera un módulo wasm de 7.6 KB con alrededor de 100 bytes de código de unión (ambos después de gzip).

AssemblyScript

AssemblyScript es un proyecto bastante nuevo que pretende ser un compilador de TypeScript a WebAssembly. Es Sin embargo, es importante tener en cuenta que no consumirá archivos 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 TypeScript que tengas en WebAssembly, pero significa que no tienes que aprender un lenguaje de programación nuevo 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 unión. El módulo solo funciona con las APIs de WebAssembly básicas.

Intrusiones 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.

Crepúsculo

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 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ía del tamaño de nuestro archivo proviene del 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 del kit de herramientas binarias de WebAssembly, o wabt para abreviar. Contiene un un par de herramientas que te permiten inspeccionar y manipular los módulos de WebAssembly. wasm2wat es un desemsamblador que convierte un módulo wasm binario en un formato legible. Wabt también contiene wat2wasm, que te permite girar ese formato legible por humanos 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 función del tamaño y el rendimiento solo en función del código de bytes. Algunas herramientas, como Emscripten, ya se ejecutan esta herramienta, mientras que otras no. Por lo general, es recomendable intentar ahorrar algunos bytes adicionales con estas herramientas.

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

Con wasm-opt, podemos quitar otro puñado de bytes para dejar un total de 6.2 KB después de gzip.

#![no_std]

Después de algunas consultas y de investigar, reescribimos nuestro código Rust sin usar la biblioteca estándar de Rust, con la función #![no_std]. Esto también inhabilita las asignaciones de memoria dinámica por completo, lo que quita el código del asignador 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 en función del tamaño del archivo, ten en cuenta que emprendimos 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 generar 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. Una vez que el módulo comienza a ejecutarse, el navegador observa qué partes se usan con frecuencia y las envía a través de 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 nuestros módulos de WebAssembly 10,000 veces en un 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 usado. Por favor, tenga en cuenta que elegí una escala de tiempo logarítmica. También es importante que todas las comparativas usen la misma imagen de prueba de 16 megapíxeles y la misma máquina host, excepto un navegador, que no se pudo ejecutar en la misma máquina.

Sin analizar demasiado estos gráficos, está claro que resolvimos nuestro problema de rendimiento original: todos los módulos de WebAssembly se ejecutan en alrededor de 500 ms o menos. Esta confirma lo que dijimos al principio: WebAssembly brinda un servicio predecible rendimiento. Sin importar el idioma que elijamos, la variación entre los navegadores y los idiomas es mínima. Para ser exactos, la desviación estándar de JavaScript en todos los navegadores es de alrededor de 400 ms, mientras que la desviación estándar de todos nuestros módulos de WebAssembly en todos los navegadores es de alrededor de 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 fue muy sencillo. No solo te permite usar TypeScript para escribir WebAssembly, lo que facilita mucho la revisión de código para mis colegas, sino que también produce módulos de WebAssembly sin pegamento que son muy pequeños y tienen un rendimiento decente. 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 de WebAssembly más grandes en los que se necesitan vinculaciones y administración de memoria. 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 es tan maduro como el 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, en realidad no es tan importante: 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 a producir 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, seguiremos de cerca a 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 de las páginas de destino (en particular, las que permiten optimizar el tiempo de vinculación 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.