Reducing AWS Lambda Cold Starts with NET. 7 Native AOT

Reducing AWS Lambda Cold Starts with NET. 7 Native AOT

In the AWS Lambda ecosystem, the cold start problem could be a key factor to use it or not, especially if you're developing a customer-facing application that needs to operate in real-time. AWS is continuously releasing new features to minimize the impact on our applications, such as Provisioned concurrency or Lambda SnapStart (only available for Java). NET 7 is also helping to reduce the impact of the cold start problem with Native AOT. To understand it, let's check first how JIT (Just-In-Time compilation) works:

  • A language-specific compiler converts the source code to the IL (Intermediate Language), MSIL (Microsoft Intermediate Language), or CIL (Common Intermediate Language). Those are CPU-agnostic sets of instructions that can be converted to native code.
  • IL is then converted into the native code by the JIT compiler. This native code is specific to the computer environment that the JIT compiler runs on.

On the other hand, Native AOT produces a self-contained application that has been Ahead-Of-Time (AOT) compiled into native code at the time of publishing. That improves the performance and reduces startup time as we do not have to execute the JIT compiler when the application runs. But there are some drawbacks, such as:

  • No dynamic loading (for example, Assembly.LoadFile).
  • No runtime code generation (for example, System.Reflection.Emit).
  • Requires trimming (when publishing the application, the .NET SDK analyzes the entire application and removes all unused code. However, it can be not easy to determine what is used)
  • Should be targeted for console-type applications (not ASP.NET Core).
  • Not all the runtime libraries are fully annotated to be native AOT compatible.
  • Only Linux and Windows are supported for now.

To measure the improvements in the cold start time. We build two applications, the first one with .NET 6 and the second using .NET 7 with Native AOT (following this post). Both Lambda Function are going to do the same (the source code can be found here:

  • Receives an HTTP request from API Gateway.
  • Store the request in a DynamoDb table.
  • Send a message to an SQS queue.
  • Return an HTTP response.

For both Lambda Functions, we are using 512 Mb. We run a test (three times, redeploying the applications between runs) to hit the endpoints for 5 minutes (one request per second). Let's take a look at the results:

ApplicationMinAvgMaxMedianP95P99Stddev
Native AOT18ms29ms869ms23ms46ms86ms51ms
JIT17ms34ms1417ms23ms54ms81ms83ms
ApplicationMinAvgMaxMedianP95P99Stddev
Native AOT19ms28ms772ms23ms47ms79ms46ms
JIT18ms36ms1547ms24ms65ms99ms92ms
ApplicationMinAvgMaxMedianP95P99Stddev
Native AOT18ms29ms851ms23ms50ms91ms51ms
JIT20ms37ms1644ms25ms56ms75ms97ms

The cold start time (Max) produced by .NET 7 with Native AOT is almost half of .NET 6. Another thing to notice is that the standard deviation is lower, suggesting that the response times are more stable. Checking the logs, we see another advantage of .NET 7 with Native AOT, the Lambda Function is using less memory (81Mb vs. 98Mb), and we confirm that the cold start time (354.51ms vs. 1015.76ms) is lower (almost three times from the AWS perspective):

image.png

image.png

But nothing could be perfect, and the package size of the Lambda Function produced by .NET 7 with Native AOT is almost four times larger (2.6Mb vs. 10.4Mb). Thanks, and happy coding.