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

Siempre es rápido,

En mis artículos anteriores, hablé sobre cómo WebAssembly te permite llevar el ecosistema de bibliotecas de C/C++ a la Web. Una app que usa ampliamente las bibliotecas C/C++ es squoosh, nuestra app web que te permite comprimir imágenes con una variedad de códecs que se compilaron 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. WebAssembly proporciona un entorno para ejecutar código que tenía la zona de pruebas y la incorporación en mente desde el principio.

Según mi experiencia, la mayoría de los problemas de rendimiento en la Web se producen por el diseño forzado y el exceso de pintura, pero de vez en cuando una app necesita realizar una tarea costosa desde el punto de vista computacional que lleva mucho tiempo. WebAssembly puede ayudarte.

El camino caliente

En squoosh, escribimos una función de JavaScript que rota un búfer de imagen múltiplos de 90 grados. Si bien OffscreenCanvas sería ideal para esto, no es compatible con todos los navegadores objetivo y presenta un error en Chrome.

Esta función itera en cada píxel de una imagen de entrada y la copia en una posición diferente de la imagen de salida para lograr la rotación. Para una imagen de 4,094 por 4,096 píxeles (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 esa gran cantidad de iteraciones, dos de cada tres navegadores que probamos finalizan 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 complicada, y los diferentes motores lo optimizan en función de diferentes aspectos. Algunos optimizan la ejecución sin procesar, otros optimizan la interacción con el DOM. En este caso, encontramos una ruta no optimizada en un navegador.

Por otro lado, WebAssembly se compiló completamente en torno a 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 ser de ayuda.

WebAssembly para un rendimiento predecible

En general, JavaScript y WebAssembly pueden lograr el mismo rendimiento máximo. Sin embargo, para JavaScript, este rendimiento solo se puede alcanzar en la "ruta rápida", y a menudo es difícil mantenerse en esa "ruta rápida". Uno de los beneficios clave que ofrece WebAssembly es el rendimiento predecible, incluso en todos los navegadores. La escritura estricta y la arquitectura de bajo nivel permiten que el compilador establezca garantías más sólidas para que el código de WebAssembly solo se deba optimizar una vez y siempre use la "ruta de acceso rápida".

Escribe para WebAssembly

Anteriormente, tomamos bibliotecas C/C++ y las compilamos en WebAssembly para usar sus funciones en la Web. No modificamos 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 teniendo en cuenta WebAssembly para poder aprovechar las ventajas de WebAssembly.

Arquitectura de WebAssembly

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

Para citar WebAssembly.org, sigue estos pasos:

Cuando compilas un fragmento de código C o Rust en WebAssembly, obtienes un archivo .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 analizó esto: la pila que convierte a WebAssembly en una "máquina virtual basada en la pila" no se almacena en el fragmento de memoria que usan los módulos de WebAssembly. La pila es completamente interna en la VM y no puede acceder a ella los desarrolladores web (excepto mediante Herramientas para desarrolladores). Por lo tanto, es posible escribir módulos de WebAssembly que no necesiten memoria adicional y que solo usen la pila interna de la 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 es WebAssembly.Memory.

Administración de la memoria

Por lo general, una vez que usas memoria adicional, encontrarás la necesidad de administrarla de alguna manera. ¿Qué partes de la memoria están en uso? ¿Cuáles son gratuitas? 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 el módulo de WebAssembly y aumentará el tamaño del archivo. El tamaño y el rendimiento de estas funciones de administración de memoria pueden variar de forma significativa según el algoritmo utilizado, por lo que muchos lenguajes ofrecen varias implementaciones para elegir ("dmalloc", "emmalloc", "wee_alloc", etc.).

En nuestro caso, conocemos las dimensiones de la imagen de entrada (y, por lo tanto, las dimensiones de la imagen de salida) antes de ejecutar el módulo WebAssembly. Aquí observamos una oportunidad: tradicionalmente, se pasaba el búfer RGBA de la imagen de entrada como parámetro a una función WebAssembly y se mostraba la imagen rotada como valor. Para generar ese valor de retorno, tendríamos que usar el asignador. Sin embargo, como sabemos la cantidad total de memoria que se necesita (el doble del tamaño de la imagen de entrada, una vez para la entrada y otra para la salida), podemos colocar la imagen de entrada en la memoria de WebAssembly con JavaScript, ejecutar el módulo de WebAssembly para generar una segunda imagen rotada y, luego, usar JavaScript para leer el resultado. Podemos escapar sin usar ninguna administración de memoria.

Pendientes de elección

Si observaste la función original de JavaScript que queremos para WebAssembly-fy, puedes ver que es un código puramente computacional sin APIs específicas de JavaScript. Por lo tanto, la portabilidad de este código a cualquier lenguaje debería ser bastante sencillo. Evaluamos 3 lenguajes diferentes que se compilan en WebAssembly: C/C++, Rust y AssemblyScript. La única pregunta que debemos responder para cada uno de los lenguajes es la siguiente: ¿Cómo podemos acceder a la memoria sin procesar sin usar las funciones de administración de memoria?

C y Emscripten

Emscripten es un compilador de C para el destino WebAssembly. El objetivo de Emscripten es funcionar como reemplazo directo de compiladores de C conocidos, como GCC o clang, y es compatible en su mayoría con marcas. Esta es una parte fundamental de la misión de Emscripten, ya que busca que la compilación de código C y C++ existente en WebAssembly sea lo más fácil posible.

Acceder a 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 firma. Esto convierte efectivamente la variable ptr en un array que comienza en la dirección de memoria 0x124 y que podemos usar como cualquier otro array, lo que nos permite acceder a bytes individuales para lectura y escritura. En nuestro caso, estamos observando un búfer RGBA de una imagen que queremos volver a ordenar para lograr la rotación. Para mover un píxel, debemos mover 4 bytes consecutivos a la vez (un byte por cada canal: R, G, B y A). Para facilitar esta tarea, 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 la imagen de salida comenzará directamente después de que termine 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 glue llamado c.js y un módulo wasm llamado c.wasm. Ten en cuenta que el módulo Wasm gzip solo tiene unos 260 bytes, mientras que el código glue pesa alrededor de 3.5 KB después de gzip. Después de algunos retoques, pudimos deshacernos del código de adhesión y crear una instancia de los módulos de WebAssembly con las APIs convencionales. A menudo, esto es posible con Emscripten, siempre que no uses nada de la biblioteca C estándar.

Rust

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

Una de estas herramientas es wasm-pack, del grupo de trabajo de rustwasm. wasm-pack toma tu código y lo convierte en un módulo optimizado para la Web que funciona de inmediato con agrupadores como Webpack. wasm-pack es una experiencia extremadamente conveniente, pero, por el momento, solo funciona para Rust. El grupo está considerando agregar compatibilidad con otros idiomas de segmentación de WebAssembly.

En Rust, las Slices son las matrices en C. Y al igual que en C, necesitamos crear trozos que usen nuestras direcciones de inicio. Esto va en contra del modelo de seguridad de memoria que Rust aplica de manera forzosa, por lo que, para nuestra manera, 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;
    }
}

Compilar los archivos de Rust con

$ wasm-pack build

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

AssemblyScript

AssemblyScript es un proyecto bastante joven que apunta a ser un compilador de TypeScript a WebAssembly. Sin embargo, es importante tener en cuenta que no consumirá TypeScript. AssemblyScript utiliza la misma sintaxis que TypeScript, pero cambia la biblioteca estándar por la suya. Su biblioteca estándar modela las capacidades de WebAssembly. Eso significa que no puedes compilar cualquier TypeScript que tengas en WebAssembly, sino que significa que no necesitas aprender un nuevo lenguaje 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 pequeña superficie de tipo que tiene nuestra función rotate(), fue bastante fácil portar 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, solo necesitamos instalar el paquete de npm AssemblyScript/assemblyscript y ejecutar

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

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

Intrusiones de WebAssembly

El tamaño de 7.6 KB de Rust es sorprendentemente grande en comparación con los otros 2 idiomas. Hay un par de herramientas en el ecosistema de WebAssembly que pueden ayudarte a analizar tus archivos de WebAssembly (sin importar el lenguaje con el que se crearon) y informarte qué sucede y, además, ayudarte a mejorar tu situación.

Melocotón

Twiggy es otra herramienta del equipo de WebAssembly de Rust que extrae una gran cantidad de datos útiles de un módulo de WebAssembly. 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 sin usar o superfluas y descubrir qué secciones contribuyen al tamaño total del archivo de tu módulo. 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 gran factor que contribuye a ello es la subsección "nombres de las funciones".

tira de onda

wasm-strip es una herramienta del kit de herramientas binarias de WebAssembly, o wabt. Contiene un par de herramientas que te permiten inspeccionar y manipular módulos de WebAssembly. wasm2wat es un desensamblador que convierte un módulo wasm binario en un formato legible por humanos. Wabt también contiene wat2wasm, que te permite volver a convertir ese formato legible por humanos en un módulo binario de wasm. Si bien usamos estas dos herramientas complementarias para inspeccionar nuestros archivos WebAssembly, descubrimos que wasm-strip era la más útil. wasm-strip quita las secciones y los metadatos innecesarios de un módulo de WebAssembly:

$ wasm-strip rotate_bg.wasm

De esta manera, se 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 WebAssembly y trata de optimizarlo para el tamaño y el rendimiento solo en función del código de bytes. Algunas herramientas como Emscripten ya ejecutan esta herramienta, otras no. Por lo general, es una buena idea intentar guardar algunos 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.

#![sin_std]

Después de investigar y consultar, reescribimos nuestro código de Rust sin usar la biblioteca estándar de este servicio con la función #![no_std]. De esta manera, también se inhabilitan por completo las asignaciones de memoria dinámicas y se quita el código del localizador de nuestro módulo. Compilar este archivo de Rust con

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

generó un módulo wasm de 1.6 KB después de wasm-opt, wasm-strip y gzip. Si bien aún es más grande que los módulos generados por C y AssemblyScript, es lo suficientemente pequeño como para considerarse ligero.

Rendimiento

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

Cómo crear 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. Dicho simplemente: La primera etapa es mucho más rápida en la compilación, pero tiende a generar un 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 lento y optimizador.

Nuestro caso de uso es interesante porque el código para rotar una imagen se usará una o dos veces. Por lo tanto, en la gran mayoría de los casos, nunca obtendremos los beneficios del compilador de optimización. Es importante tener esto en cuenta cuando realices 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 tomar decisiones basadas en los números de esa ejecución única.

Comparación del rendimiento

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

Estos dos gráficos son distintas vistas de los mismos datos. En el primer gráfico, se compara por navegador y en el segundo, se realiza una comparación por idioma usado. Ten 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 anfitrión, 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 aproximadamente 500 ms o menos. Esto confirma lo que diseñamos al principio: WebAssembly te ofrece un rendimiento predecible. Independientemente del 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 aproximadamente 400 ms, mientras que la desviación estándar de todos nuestros módulos de WebAssembly en todos los navegadores es de aproximadamente 80 ms.

Esfuerzo

Otra métrica es la cantidad de esfuerzo que tuvimos que dedicar para crear y, luego, integrar nuestro módulo de WebAssembly en squoosh. Es difícil asignar un valor numérico al esfuerzo, por lo que no crearé ningún gráfico, pero hay algunas cosas que me gustaría señalar:

AssemblyScript no tenía inconvenientes. No solo te permite usar TypeScript para escribir WebAssembly, lo que facilita a mis colegas la revisión de código, sino que también genera módulos de WebAssembly sin pegamento que son muy pequeños y con un rendimiento decente. Es probable que las herramientas del ecosistema de TypeScript, como Prettier y tslint, simplemente funcionen.

La combinación de Rust 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 de la ruta feliz para lograr un tamaño de archivo competitivo.

C y Emscripten crearon un módulo de WebAssembly muy pequeño y de alto rendimiento de forma predeterminada, pero sin el coraje de ir al código de pegamento y reducirlo lo simple que es el tamaño total (módulo de WebAssembly + código de pegamento) termina siendo bastante grande.

Conclusión

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

Gráfico comparativo

En comparación con la compensación del tamaño del módulo y el rendimiento de los diferentes lenguajes que usamos, parece que la mejor opción es C o AssemblyScript. Decidimos enviar Rust. Esta decisión tiene varios motivos: todos los códecs que se enviaron en Squoosh hasta el momento se compilan con Emscripten. Queríamos ampliar nuestro conocimiento sobre el ecosistema 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.

Si bien la diferencia en el tamaño del archivo entre Rust y el de los otros lenguajes es 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 una décima de segundo. Además, esperamos que Rust cierre la brecha en términos de tamaño de módulo pronto.

En términos de rendimiento del entorno de ejecución, Rust tiene un promedio más rápido entre navegadores que AssemblyScript. Especialmente en proyectos más grandes, es más probable que Rust produzca código más rápido sin necesidad de optimizaciones manuales de código. Sin embargo, eso no debería impedir que uses la opción que te resulte más cómoda.

Dicho esto, AssemblyScript fue un gran descubrimiento. Permite a los desarrolladores web producir módulos de WebAssembly sin tener que aprender un lenguaje nuevo. El equipo de AssemblyScript brinda una respuesta muy activa y trabaja activamente para mejorar su cadena de herramientas. Definitivamente, supervisaremos AssemblyScript en el futuro.

Actualización: Rust

Después de publicar este artículo, Nick Fitzgerald, del equipo de Rust, nos mostró su excelente libro de Rust Wasm, que contiene una sección para optimizar el tamaño de los archivos. Seguir las instrucciones que se proporcionan allí (en particular, habilitar las optimizaciones del tiempo de vinculación y el manejo manual de pánico) nos permitió escribir el código de Rust "normal" y volver a usar Cargo (el npm de Rust) sin sobredimensionar el tamaño del archivo. El módulo de Rust termina en 370B 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 viaje.