jordan gonzález
publicado

Reduce Cold Starts en .NET para AWS Lambda

El rendimiento es una preocupación fundamental para los ingenieros, ya que impacta directamente en el costo, la experiencia del usuario, escalabilidad y confiabilidad, entre otros aspectos. El tiempo de inicialización es parte de este espectro cuando se trabaja con entornos Serverless (sin servidor).

Cuando se ejecuta por primera vez, o después de un largo período de inactividad, una carga de trabajo Serverless requiere aprovisionar recursos. A esto lo llamamos un cold start (o inicio en frío). La duración de la inicialización es parte del inicio en frío, el tiempo dedicado a inicializar código y el ambiente de ejecución.

Aquí está una guía para desarrolladores en cómo AWS define los inicios en frío para AWS Lambda.


Hace unos meses, me asignaron a reducir la sobrecarga en la inicialización en tracing para .NET en Datadog. Lo primero que vino a mi mente fue lo que mi colega Rey Abolofia hizo para Python en su blog Reducing AWS Lambda Cold Starts.

Dado que .NET es un framework que requiere compilar C#, pensé que debería haber una forma de reducir la cantidad de trabajo realizado durante la ejecución. Después de investigar más sobre cómo funciona la compilación de .NET, me propuse compilar nuestro Tracer con anticipación (ahead-of-time).

Como resultado, logré una mejora del rendimiento del 25% en los inicios fríos. Déjame explicarte cómo logré esto.

Compilando en .NET

Entender cómo funciona el framework .NET es crucial, ya que te permitirá mejorar cómo se empaqueta tu código. He visto ingenieros pasar por alto este aspecto, principalmente porque están enfocados en desarrollar y entregar primero, pero llegará el momento en que tendrán que aprender sobre esto.

Compilación Predeterminada

Las aplicaciones .NET se compilan en un Lenguaje Intermedio Común (Common Intermediate Language, CIL) independiente del lenguaje. El código compilado se almacena en ensamblados: archivos con extensión .dll o .exe.

Durante la ejecución, el Common Language Runtime (CLR) se encarga de tomar los ensamblados y usar un compilador Just-In-Time (JIT) para convertir el código de Lenguaje Intermedio en código nativo que la máquina local puede ejecutar. [1]

.NET Compilation explained

Así que, aunque las aplicaciones .NET requieren compilación, existe un paso adicional de compilación durante la ejecución, que requiere poder de cómputo y, por consiguiente, se traduce en tiempo de ejecución.

Hay dos técnicas principales para mejorar los Inicios en Frío, pero la idea principal es la compilación Ahead-Of-Time (AOT). Una es ReadyToRun, y la otra es Native AOT.


ReadyToRun

ReadyToRun (R2R) es una forma de compilación ahead-of-time (AOT). Los binarios producidos mejoran el rendimiento de inicio al reducir la cantidad de trabajo que el compilador JIT necesita hacer mientras la aplicación se carga. [2]

La principal desventaja es que los binarios R2R son mucho más grandes, porque contienen tanto código IL como la versión nativa del mismo código.

Native AOT

La compilación Native AOT produce una aplicación que ha sido compilada previamente a código nativo para una arquitectura específica. Por lo tanto, estas aplicaciones no utilizarán el compilador JIT durante el runtime. No solo tendrán un tiempo de inicio más rápido, sino también una huella de memoria más pequeña.

Otra gran ventaja es que estos binarios no requieren que la máquina local tenga instalado el ambiente de ejecución de .NET. Aunque una limitación es que no se puede compilar para múltiples plataformas. [3]


Eligiendo una Estrategia de Compilación

La elección fácil sería compilar con Native AOT todo el tiempo, ¿verdad? Porque no requiere el ambiente de ejecución de .NET, ni un compilador JIT. Desafortunadamente, habrá escenarios en los que simplemente no se podrá hacer. [4]

Por ejemplo, si estás realizando carga dinámica mediante Assembly.LoadFile, o generación de código en tiempo de ejecución usando reflexión con System.Reflection.Emit, al compilar a Native AOT, encontrará advertencias durante el proceso y la aplicación se comportará de manera inesperada.

En mi tarea específica, no pude aprovechar la compilación Native AOT porque el tracer .NET de Datadog utiliza carga dinámica y reflexión. Debido a la cantidad de cambios necesarios para que esto funcione, tuve que conformarme con R2R hasta que actualicemos el tracer.

¿Cómo hacerlo?

R2R

Para habilitar la compilación ReadyToRun, simplemente agrega la siguiente propiedad en tu .csproj:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <!-- ...otras propiedades -->
    <PublishReadyToRun>true</PublishReadyToRun>

Native AOT

Para compilar para Native AOT, incluye la siguiente propiedad en .csproj

<PublishAot>true</PublishAot>

Para asegurarte de que tu aplicación sea compatible con Native AOT, puedes agregar esta propiedad en el mismo archivo:

<IsAotCompatible>true</IsAotCompatible>

Benchmarks

Para obtener datos sobre los inicios en frío, la metodología que utilicé es simple: forzar un nuevo entorno para AWS Lambda en ciertos intervalos y emitir telemetría utilizando una herramienta de observabilidad.

Si deseas un proyecto de ejemplo para comenzar y probarlo tú mismo, visita mi repositorio de ejemplos que utiliza AWS CDK para comparar una aplicación de Hola Mundo con estas estrategias.

Hola Mundo

Para una AWS Lambda simple que serializa un paquete HTTP de API Gateway y la devuelve, no vemos ningún beneficio al usar R2R, alrededor de ~10ms menos. Pero al compilar a Native AOT, podemos ver una mejora del 75%, ahorrando alrededor de ~400ms.

Graphic comparing .NET compilation methods for Arm64 where Native AOT is considerably faster than R2R and the default.

Debido a la falta de compilación multiplataforma, no pude mostrar los datos para arquitecturas x86_64 en esta prueba.

Graphic showing .NET compilations with R2R and default only for x86_64 architectures

Tracer de Datadog

Para un código inmenso como lo es el tracer .NET de Datadog, publicar una versión con ReadyToRun habilitado mejoró positivamente el rendimiento, como mencioné antes, un recorte del 25% durante la inicialización.

Graphic showing .NET compilations with R2R and default only for the Datadog Tracer

Este código está disponible públicamente, siéntete libre de revisarlo en DataDog/dd-trace-dotnet#5962.

Resumen

En general, comprender cómo funciona el compilador te abrirá muchas puertas para convertirte en un mejor ingeniero y te proporcionará el conocimiento fundamental para pensar en formas de mejorar el rendimiento de tus aplicaciones.

El beneficio claro de aplicar esto en cargas de trabajo Serverless es que tus aplicaciones podrán servir más rápido y ahorrar dinero al mismo tiempo.

Para más mejoras, como recortar y reducir código, te recomiendo profundizar en el contenido referenciado y la guía de desarrolladores de AWS para compilar .NET en Native AOT.


Referencias

[1] Microsoft. (2024). What is .NET Framework: Architecture of .NET Framework. Microsoft Learn.

[2] davidwrighton, gewarren, & Miskelly. (2022, June). ReadyToRun Compilation. Microsoft Learn.

[3] LakshanF et al. (2024, October 15). Native AOT Deployment. Microsoft Learn.

[4] stevewhims & mattwojo (2022, October). .NET Native and compilation. Microsoft Learn.