Cómo escalar aplicaciones de WebAssembly con subprocesos múltiples con mimalloc y WasmFS

Alon Zakai
Alon Zakai

Fecha de publicación: 30 de enero de 2025

Muchas aplicaciones de WebAssembly en la Web se benefician del procesamiento en varios subprocesos, de la misma manera que las aplicaciones nativas. Varios subprocesos permiten que se realice más trabajo en paralelo y quiten el trabajo pesado del subproceso principal para evitar problemas de latencia. Hasta hace poco, había algunos problemas comunes que podían ocurrir con tales aplicaciones de varios subprocesos, relacionados con las asignaciones y la E/S. Por suerte, las funciones recientes de Emscripten pueden ayudar mucho con esos problemas. En esta guía, se muestra cómo estas funciones pueden generar mejoras de velocidad de 10 veces o más en algunos casos.

Escalamiento

En el siguiente gráfico, se muestra una escalamiento eficiente de varios subprocesos en una carga de trabajo matemática pura (de la comparativa que usaremos en este artículo):

Un gráfico de líneas titulado Escalamiento matemático muestra la relación entre la cantidad de núcleos (eje X) y el tiempo de ejecución en milisegundos (eje Y, etiquetado

Esto mide el procesamiento puro, algo que cada núcleo de la CPU puede hacer por sí solo, por lo que el rendimiento mejora con más núcleos. Una línea descendente de rendimiento más rápido es exactamente cómo se ve una buena escala. Además, demuestra que la plataforma web puede ejecutar código nativo multiproceso muy bien, a pesar de usar trabajadores web como base para el paralelismo, usar Wasm en lugar de código nativo real y otros detalles que podrían parecer menos óptimos.

Administración del montón: malloc/free

malloc y free son funciones críticas de la biblioteca estándar en todos los lenguajes de memoria lineal (por ejemplo, C, C++, Rust y Zig) que se usan para administrar toda la memoria que no es completamente estática o está en la pila. Emscripten usa dlmalloc de forma predeterminada, que es una implementación compacta pero eficiente (también admite emmalloc, que es aún más compacta, pero más lenta en algunos casos). Sin embargo, el rendimiento de varios subprocesos de dlmalloc es limitado porque toma un bloqueo en cada malloc/free (porque hay un solo asignador global). Por lo tanto, puedes encontrarte con contención y lentitud si tienes muchas asignaciones en muchos subprocesos a la vez. Esto es lo que sucede cuando ejecutas una comparativa increíblemente pesada de malloc:

Un gráfico de líneas titulado Escalamiento de dlmalloc muestra la relación entre la cantidad de núcleos (eje X) y el tiempo de ejecución en milisegundos (eje Y, en el que se indica que los valores más bajos son mejores). La tendencia indica que aumentar la cantidad de núcleos aumenta el tiempo de ejecución, con un aumento lineal constante de 1 a 4 núcleos.

No solo el rendimiento no mejora con más núcleos, sino que empeora cada vez más, ya que cada subproceso termina esperando durante largos períodos de tiempo el bloqueo de malloc. Este es el peor caso posible, pero puede ocurrir en cargas de trabajo reales si hay suficientes asignaciones.

mimalloc

Existen versiones de dlmalloc optimizadas para varios subprocesos, como ptmalloc3, que implementa una instancia de asignador independiente por subproceso, lo que evita la contención. Existen varios otros asignadores con optimizaciones de subprocesos, como jemalloc y tcmalloc. Emscripten decidió enfocarse en el proyecto mimalloc reciente, que es un asignador de Microsoft bien diseñado con muy buena portabilidad y rendimiento. Úsalo de la siguiente manera:

emcc -sMALLOC=mimalloc

Estos son los resultados de la comparativa de malloc con mimalloc:

Un gráfico de líneas titulado Escalamiento de mimalloc muestra la relación entre la cantidad de núcleos (eje x) y el tiempo de ejecución en milisegundos (eje y, en el que se indica que los valores más bajos son mejores). La tendencia indica que aumentar la cantidad de núcleos reduce el tiempo de ejecución, con una disminución pronunciada de 1 a 2 núcleos y una disminución más gradual de 2 a 4 núcleos.

¡Perfecto! Ahora el rendimiento se escala de manera eficiente y se vuelve más rápido con cada núcleo.

Si observas con atención los datos del rendimiento de un solo núcleo en los últimos dos gráficos, verás que dlmalloc tardó 2,660 ms y mimalloc solo 1,466, una mejora de velocidad de casi 2 veces. Eso demuestra que, incluso en una aplicación de un solo subproceso, es posible que veas los beneficios de las optimizaciones más sofisticadas de mimalloc, aunque ten en cuenta que esto tiene un costo en el tamaño del código y el uso de la memoria (por ese motivo, dlmalloc sigue siendo la opción predeterminada).

Archivos y E/S

Muchas aplicaciones necesitan usar archivos por varios motivos. Por ejemplo, para cargar niveles en un juego o fuentes en un editor de imágenes. Incluso una operación como printf usa el sistema de archivos de forma interna, ya que imprime escribiendo datos en stdout.

En las aplicaciones de un solo subproceso, esto no suele ser un problema, y Emscripten evitará automáticamente la vinculación de la compatibilidad completa con el sistema de archivos si todo lo que necesitas es printf. Sin embargo, si usas archivos, el acceso al sistema de archivos con varios subprocesos es complicado, ya que el acceso a los archivos debe sincronizarse entre los subprocesos. La implementación original del sistema de archivos en Emscripten, llamada "JS FS" porque se implementó en JavaScript, usaba el modelo simple de implementar el sistema de archivos solo en el subproceso principal. Cada vez que otro subproceso quiere acceder a un archivo, el subproceso principal realiza la solicitud. Esto significa que el otro subproceso bloquea una solicitud entre subprocesos, que el subproceso principal controla con el tiempo.

Este modelo simple es óptimo si solo el subproceso principal accede a los archivos, lo que es un patrón común. Sin embargo, si otros subprocesos realizan operaciones de lectura y escritura, se producen problemas. En primer lugar, el subproceso principal termina realizando tareas para otros subprocesos, lo que genera una latencia visible para el usuario. Luego, los subprocesos en segundo plano terminan esperando que el subproceso principal esté libre para realizar el trabajo que necesitan, por lo que todo se ralentiza (o, lo que es peor, puedes terminar en un interbloqueo si el subproceso principal está esperando ese subproceso de trabajo).

WasmFS

Para solucionar este problema, Emscripten tiene una nueva implementación de sistema de archivos, WasmFS. WasmFS está escrito en C++ y se compila en Wasm, a diferencia del sistema de archivos original, que estaba en JavaScript. WasmFS admite el acceso al sistema de archivos desde varios subprocesos con una sobrecarga mínima, ya que almacena los archivos en la memoria lineal de Wasm, que se comparte entre todos los subprocesos. Ahora, todos los subprocesos pueden realizar operaciones de E/S de archivos con el mismo rendimiento y, a menudo, incluso pueden evitar bloquearse entre sí.

Una comparativa simple del sistema de archivos muestra la gran ventaja de WasmFS en comparación con el antiguo sistema de archivos de JS.

Un gráfico de barras titulado Rendimiento del sistema de archivos compara el tiempo de ejecución en milisegundos (eje Y, en el que se indica que los valores más bajos son mejores) para JS FS y WasmFS en dos categorías: subproceso principal y pthread (eje X). El sistema de archivos de JS tarda mucho más en el caso de pthread, mientras que WasmFS se mantiene bajo de forma constante en ambos casos.

Esto compara la ejecución del código del sistema de archivos directamente en el subproceso principal con la ejecución en un solo pthread. En el FS de JS anterior, cada operación del sistema de archivos debe estar proxy al subproceso principal, lo que lo hace más lento en un orden de magnitud en un pthread. Esto se debe a que, en lugar de solo leer o escribir algunos bytes, el sistema de archivos de JS realiza una comunicación entre subprocesos, lo que implica bloqueos, una cola y esperas. En cambio, WasmFS puede acceder a los archivos de cualquier subproceso por igual, por lo que el gráfico muestra que prácticamente no hay diferencia entre el subproceso principal y un pthread. Como resultado, WasmFS es 32 veces más rápido que el FS de JS cuando se usa en un pthread.

Ten en cuenta que también hay una diferencia en el subproceso principal, en el que WasmFS es 2 veces más rápido. Esto se debe a que el sistema de archivos de JS llama a JavaScript para cada operación del sistema de archivos, lo que evita WasmFS. WasmFS solo usa JavaScript cuando es necesario (por ejemplo, para usar una API web), lo que deja la mayoría de los archivos WasmFS en Wasm. Además, incluso cuando se requiere JavaScript, WasmFS puede usar un subproceso auxiliar en lugar del principal para evitar la latencia visible para el usuario. Debido a esto, es posible que veas mejoras de velocidad si usas WasmFS, incluso si tu aplicación no es multiproceso (o si lo es, pero usa archivos solo en el subproceso principal).

Usa WasmFS de la siguiente manera:

emcc -sWASMFS

WasmFS se usa en producción y se considera estable, pero aún no admite todas las funciones del FS de JS anterior. Por otro lado, incluye algunas funciones nuevas importantes, como la compatibilidad con el sistema de archivos privado de origen (OPFS, que es muy recomendable para el almacenamiento persistente). A menos que necesites una función que aún no se haya portabilizado, el equipo de Emscripten recomienda usar WasmFS.

Conclusión

Si tienes una aplicación con varios subprocesos que realiza muchas asignaciones o usa archivos, es posible que te beneficies mucho si usas WasmFS o mimalloc. Ambas son fáciles de probar en un proyecto de Emscripten. Solo tienes que volver a compilar con las marcas que se describen en esta publicación.

Incluso puedes probar esas funciones si no usas subprocesos: como se mencionó antes, las implementaciones más modernas incluyen optimizaciones que se notan incluso en un solo núcleo en algunos casos.