Ciclo de vida y rendimiento de una instancia de Lucet

Recientemente anunciamos a Lucet, nuestro compilador y nuestro motor en tiempo de ejecución nativos para WebAssembly, y nos emociona mucho el interés que ha despertado en la comunidad. Durante el anuncio, destacamos que Lucet puede instanciar módulos de WebAssembly en menos de 50 microsegundos, lo que demuestra que esta nueva tecnología para una ejecución más rápida y segura sigue respondiendo a los desafíos de ampliar con la plataforma edge cloud de Fastly. En esta publicación, explicaremos cómo funciona el sistema de tiempo de ejecución de Lucet, compartiendo lo que sucede en cada paso del ciclo de vida de un programa de WebAssembly que se ejecuta en Lucet. También detallaremos cómo mantenemos la sobrecarga de cada paso lo más baja posible.

Compilación anticipada

En los navegadores web, el tiempo transcurrido entre la carga de WebAssembly y la ejecución del programa es parte de la demora que todo usuario experimenta al cargar cualquier página. Los motores de ejecución de los navegadores emplean compilación en tiempo de ejecución o "just-in-time" para empezar a generar rápidamente código nativo, a veces incluso antes de que el programa de WebAssembly haya terminado de cargarse. Respecto a aplicaciones del lado del servidor como Terrarium que ejecutan el mismo programa por cada solicitud de entre multitud de solicitudes, la velocidad de compilación es mucho menos importante que el tiempo de configuración para cada solicitud y que el rendimiento del código generado.

Lucet incluye un compilador anticipado, lucetc, construido a partir del generador de código Cranelift creado por Mozilla para su uso en los motores WebAssembly y JavaScript JIT de Firefox. El compilador lucetc compila programas de WebAssembly y los convierte en archivos de objetos compartidos x86-64 nativos, listos para ser posteriormente cargados y ejecutados por el motor en tiempo de ejecución de Lucet. En un servidor como Terrarium, esta fase solo se realiza una vez y el coste se amortiza durante la vida útil del servidor, lo cual libera tiempo que puede emplearse en optimizaciones en el programa compilado.

Regiones de memoria

Si bien algunas aplicaciones de Lucet, como la interfaz de línea de la comandos lucet-wasi, están diseñadas para ejecutar un solo programa de WebAssembly a la vez, el diseño de Lucet le permite ser compatible con una ejecución simultánea en términos masivos a la escala de Fastly. Cada una de las instancias de Lucet precisa una determinada cantidad de memoria para el montículo de WebAssembly y las variables globales, así como la pila de llamadas y una página de 4 KiB correspondiente a los metadatos de la instancia.

En lugar de asignar y liberar memoria del sistema operativo cada vez que creemos y destruyamos una instancia, designamos una región de memoria que posea espacio reutilizable para respaldar instancias. Estas se crean ocupando un espacio libre de la región, espacio que, tras la destrucción de la instancia, se llena de ceros y es devuelto a la región. A partir de la comparativa de patrones de las compilaciones ahead-of-time y just-in-time, escogemos el enfoque que nos permita amortizar una costosa operación de asignación de memoria a lo largo de la vida de un servidor que realice en repetidas ocasiones operaciones de reutilización de espacio más baratas.

Creación de instancias

Para crear instancias de un programa de Lucet, debemos:

  • cargar de forma dinámica el objeto compartido que haya compilado lucetc

  • ocupar un espacio libre de una región de memoria

  • configurar el montículo de la instancia con los permisos adecuados

  • copiar los valores del montículo inicial desde el objeto compartido.

En nuestro sistema de comparación de rendimiento, una herramienta como lucet-wasi tarda por término medio 52 µs en cargar un programa del tipo "Hello World" WASI, y en crear instancias de este, lo cual incluye el tiempo necesario para crear una región de memoria que tenga un solo espacio:

Graph 1

Naturalmente, en un entorno de servidor como Terrarium, la carga del objeto compartido y la creación de la región de memoria se realizan una sola vez. En nuestro sistema de comparación de rendimiento, los pasos de adquirir el espacio de memoria y rellenar el montículo requieren una media de 30 µs:

Gráfico 2

Nuestro programa "Hello World" no tiene excesivos datos del montículo inicial, de modo que la mayoría del tiempo se dedica a la contabilidad del espacio de memoria y sus permisos. Si probamos algunos programas sintéticos que presenten montículos iniciales de tamaños diferentes, constatamos que el tiempo está dominado por la copia de los valores del montículo inicial y la colocación en su lugar, mientras que el tiempo de creación de instancias crece de forma lineal en función del tamaño del montículo:

Gráfico 3

Algunos compiladores que apuntan a WebAssembly, como el backend experimental Go, producen módulos con montículos iniciales muy grandes para respaldar la recolección de elementos no utilizados. Afortunadamente, si la mayoría de los elementos de un montículo inicial tienen valores igual a cero, podemos ahorrar algo de tiempo en la creación de instancias. En esta ejecución, tenemos programas sintéticos con montículos iniciales dispersos que tienen valores distintos de cero en solo una de cada ocho páginas:

Gráfico 4

En los dos casos, el tiempo de creación de instancias se incrementa de forma lineal en función del tamaño del montículo inicial, pero los montículos dispersos únicamente tardan una octava parte del tiempo que emplean los montículos densos.

Ejecución de una instancia

Una vez que dispongamos de una instancia, podemos ejecutar las funciones de invitado de WebAssembly que esta exporta. En lugar de generar un nuevo proceso de Linux o incluso un nuevo subproceso, operamos un cambio de contexto en el subproceso de la aplicación host, de tal forma que empiece a ejecutar la función de invitado directamente. En Lucet, esto supone configurar los argumentos de la función en los registros y la pila de llamadas correspondientes al invitado, almacenar la máscara de señal del subproceso actual y, a continuación, intercambiar directamente los registros y la pila del host por los del invitado. El cambio de contexto es extremadamente rápido: se tarda por término medio 0,5 microsegundos en proceder al cambio a una función trivial y, posteriormente, volver a esta:

Gráfico 5

Se precisan llamadas del sistema adicionales para la primera instancia de Lucet que se ejecuten en un proceso con el fin de instalar el identificador de señales. Lucet utiliza un identificador de señales adaptado para condiciones excepcionales, tales como divisiones entre cero, de modo que los errores queden aislados para la instancia que los genera. Continuando con el tema de la compilación y la creación de regiones de memoria, se trata de un coste extraordinario para la mayoría de aplicaciones de servidor, pero, aunque la instalación de este identificador sea obligada, las instancias tardan por término medio 4,9 µs en ejecutarse:

Gráfico 6

Desmantelamiento

Una vez que un servidor como Terrarium haya completado una solicitud, deberá restablecer o destruir la instancia que le prestó servicios para evitar la fuga de estados entre solicitudes. Al destruir una instancia, Lucet restablece la protección de la memoria, llena con ceros la memoria en el espacio de la instancia y, por último, devuelve la memoria a la lista de espacios libres de la región de memoria en un tiempo medio de 23 µs:

Gráfico 7

Dado que Linux permite llenar a la carta páginas con ceros por medio de madvise(2), esta operación puede tardar aproximadamente 35-40 µs/MiB de montículos utilizados por un programa sintético:

Gráfico 8

Sobrecarga total del sistema de motor de tiempo en ejecución

Si combinamos las fases, podemos hacernos una idea del volumen de sobrecarga del sistema del motor en tiempo de ejecución que lleva aparejada la ejecución de programas de WebAssembly con Lucet. La sobrecarga de memoria es de 4 KiB en cuanto a los metadatos, más una cantidad configurable de memoria respecto de la pila de llamadas. La sobrecarga de la velocidad fluctúa. Depende de la cantidad de espacio del montículo que utiliza el programa y de la adecuación de la carga de trabajo para amortizar los costes de la compilación ahead-of-time y crear la región de memoria. Sin embargo, en esta entrada hemos comprobado que, para ejecutar un programa "Hello World", Lucet tarda en la actualidad:

  • 30 µs en crear instancias

  • 5 µs en el cambio de contexto

  • y 23 µs en destruirlas

La dimensión del edge cloud de Fastly exige un rendimiento extremadamente elevado en todas las fases de la gestión de solicitudes. Lucet hace posible una lógica más segura y con mayor grado de sofisticación en el edge, al tiempo que incorpora una sobrecarga por instalación y desmantelamiento inferior a 60 µs.

Epílogo de la historia del rendimiento

En esta entrada de blog, hemos explicado las fases que intervienen en la ejecución de cualquier programa de WebAssembly con Lucet y el volumen de sobrecarga que aporta el sistema de motor en tiempo de ejecución de Lucet. En entradas posteriores examinaremos con más detenimiento el rendimiento del código que haya generado lucetc, incluidas las optimizaciones que cristalizaron gracias al cuidadoso desarrollo conjunto de un sistema compuesto por un compilador y un motor en tiempo de ejecución.

Entretanto, echa un vistazo al repositorio que Lucet mantiene en GitHub y... ¡dinos qué te parece!

Observaciones sobre comparativas de rendimiento

Las cifras de rendimiento mostradas en esta entrada fueron recopiladas mediante el excelente recurso Rust port of criterion para impulsar nuestro conjunto de herramientas de comparación de rendimiento, que encontrarás en el repositorio que Lucet mantiene en GitHub. Las comparativas se efectuaron en un sistema dedicado Ubuntu 16.04 de 64 bits con un procesador de doble núcleo Intel Core i7-7567U de 3,50 GHz y habiendo desactivado Hyper-Threading y Turbo Boost con fines de coherencia; aunque estas funciones permiten mejorar el rendimiento en situaciones del mundo real, añaden bastante ruido a nuestras comparativas. Así, por ejemplo, el gráfico de densidad de probabilidad de nuestra creación de instancias del montón de 512 KiB tiene este aspecto si desactivamos dichas funciones:

Gráfico 9

Sin embargo, si las activamos, tiene este otro aspecto (observa que el eje X es diferente):

Gráfico 10
Adam Foltzer
Senior Software Engineer
Fecha de publicación:

7 min de lectura

Comparte esta entrada
Adam Foltzer
Senior Software Engineer

Adam es ingeniero de software sénior en Fastly. Adam tuvo la suerte de empezar a programar con Scheme a una edad muy temprana; desde entonces, se ha convertido en apasionado de la programación funcional y denotativa. Más recientemente, se ha centrado en programación de sistemas de nivel bajo con Rust, tras dedicarse varios años a trabajar con Haskell en Galois. Antes de cursar estudios en Informática, estudió ruso, literatura y arqueología y aprendió a pilotar avionetas.

¿List@ para empezar?

Ponte en contacto o crea una cuenta.