.NET on AWS Blog

Improving SnapStart Performance in .NET Lambda Functions

AWS recently added AWS Lambda SnapStart support for .NET Lambda functions to deliver faster function startup performance, from several seconds to as low as sub-second, typically with minimal or no code changes. This post explores techniques to maximize the performance gains of SnapStart for several different types of .NET workloads. For a high-level overview and introduction to SnapStart, see https://cloudwatch-portal.com/blogs/aws/aws-lambda-snapstart-for-python-and-net-functions-is-now-generally-available/ for further reading.

Introduction

When SnapStart is enabled, a Firecracker microVM snapshot is taken when a new function version is published. The snapshotting process involves warming up the .NET process,  snapshotting the memory and disk state of the initialized execution environment, encrypting the snapshot, and then caching it for low-latency access.

Diagram showing AWS Lambda request lifecycle

Figure 1: AWS Lambda request lifecycle

When you then invoke the function version for the first time, and as the invocations scale up, Lambda resumes new execution environments from the cached snapshot instead of initializing them from scratch, improving startup latency as much of the initialization code execution has already occurred.

Optimizing SnapStart Function Performance

Before a snapshot is taken, Lambda will initialize itself as well as create an instance of your application’s function handler and will invoke its constructor. This provides an opportunity to execute application initialization code before the Snapshot occurs, and the more work that can be done at this phase, the less work needs to be done during a cold start. This translates to a significant opportunity to reduce cold start times.

If expensive operations involving network or file IO, like loading configuration or secrets can done before a Snapshot, than cold start times will benefit. Initializations involving reflection, like building a Service Collection and resolving large classes, are also prime candidates for optimization. And as C# must JIT byte-code to native machine instructions at runtime, executing as much of your Lambda function’s code before Snapshot, ensures the majority of JITing has already taken place, further improving cold start times.

Hooking In

You can customize the SnapStart process and add callbacks into your application by using the Amazon.Lambda.Core.SnapshotRestore class’s static methods:

Amazon.Lambda.Core.SnapshotRestore.RegisterBeforeSnapshot(BeforeCheckpoint);
Amazon.Lambda.Core.SnapshotRestore.RegisterAfterRestore(AfterCheckpoint);

  • RegisterBeforeSnapshot: This hook allows you to execute code before the snapshot is taken. Use this to:
    • Load static configuration from disk or via network calls.
    • Initialize Dependency Injection and build the initial object graph.
    • Ensure your application code is already JITed.
    • Perform any initialization logic for your application.
  • RegisterAfterRestore: This hook allows you to execute code after the snapshot has been restored. Use this to:
    • Refresh application data or configuration that was cached during Snapshot
    • Re-establish network connections

Below is a simplified example taken from the https://github.com/aws-samples/serverless-dotnet-demo repository. The full source is available in the serverless-dotnet-demo project. The GetProduct function calls Amazon.Lambda.Core.SnapshotRestore.RegisterBeforeSnapshot in its constructor, passing a callback that invokes the FunctionHandler method with a dummy request. This ensures that the method is already JITed when the snapshot is taken.

public class Function
{
    private readonly ProductsDAO _dataAccess;
    public Function()
    {
        _dataAccess = new DynamoDbProducts();
        Amazon.Lambda.Core.SnapshotRestore.RegisterBeforeSnapshot(async () =>
        {
            for (int i = 1; i <= 10; i++)
            {
                string itemId = "12345ForWarming";

                var request = new APIGatewayHttpApiV2ProxyRequest
                {
                    PathParameters = new Dictionary<string, string> {{"id", itemId}},
                    RequestContext = new APIGatewayHttpApiV2ProxyRequest.ProxyRequestContext
                    {
                        Http = new APIGatewayHttpApiV2ProxyRequest.HttpDescription
                        {
                            Method = HttpMethod.Get.Method
                        }
                    }
                };

                // Create a dummy item in the db to find
                await _dataAccess.PutProduct(new Product(itemId, "forWarming", default));
                // Hit the path where the item is found
                await FunctionHandler(request, new FakeLambdaContext());
                // Clean up the dummy item in the db
                await _dataAccess.DeleteProduct(itemId);
            }
        });
    }

    public async Task<APIGatewayHttpApiV2ProxyResponse> FunctionHandler(APIGatewayHttpApiV2ProxyRequest apigProxyEvent,
        ILambdaContext context)
    {
        var id = apigProxyEvent.PathParameters["id"];

        var product = await _dataAccess.GetProduct(id);

        return new APIGatewayHttpApiV2ProxyResponse
        {
            StatusCode = (int)HttpStatusCode.OK,
            Body = JsonSerializer.Serialize(product),
            Headers = new Dictionary<string, string> { { "Content-Type", "application/json" } }
        };
    }
}

Multiple Executions

Observant readers will have noticed the RegisterBeforeSnapshot delegate in the example above was executed inside a for loop. Through experimentation, we have observed additional performance benefits from running warmups 10-20 times as this execution pattern signals to the .NET runtime to perform various runtime optimizations, such as tiered compilation.

You’ll need to experiment with your own applications in order to find the optimal number of invocations that results in the best cold start times. But do keep in mind that the Lambda 10 second INIT_PHASE time limit does apply.

Amazon.Lambda.Core.SnapshotRestore.RegisterBeforeSnapshot(async () =>
{
  // For max benefit, run warmup code multiple times
  for (int i = 1; i <= 10; i++)
  {
     // warmup code
  }
}

Specializing for ASP.NET Core

For Lambda fumctions running ASP.NET Core applications, we’ve added two extensions to initialize both your application code as well as the ASP.NET Core pipeline before Snapshot.

For applications that use the AddAWSLambdaHosting method to integrate your ASP.NET Core application with AWS Lambda. you can use the new AddAWSLambdaBeforeSnapshotRequest method to register an HttpRequestMessage to be executed before snapshot. The Lambda runtime will simulate executing this HttpRequestMessage in order to warm up the ASP.NET Core and Lambda pipelines as well as your application code.

The method can be called multiple times in order to register multiple routes to warm up.

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddAWSLambdaHosting(LambdaEventSource.HttpApi);

// Add warm-up requests before snapshot
builder.Services.AddAWSLambdaBeforeSnapshotRequest(
    new HttpRequestMessage(HttpMethod.Get, "/warmup"));

var app = builder.Build();

app.MapGet("/warmup", () => "Warmup successful");

app.Run(); 

For applications that have a LambdaEntryPoint class that extends one of the function base classes, like APIGatewayProxyFunction from Amazon.Lambda.AspNetCoreServer, there is a new virtual method, GetBeforeSnapshotRequests, that can return a collection of HttpRequestMessage objects:

public class LambdaEntryPoint : Amazon.Lambda.AspNetCoreServer.APIGatewayProxyFunction
{
    protected override IEnumerable<HttpRequestMessage> GetBeforeSnapshotRequests =>
        [
            new HttpRequestMessage(HttpMethod.Get, "/warmup"),
            new HttpRequestMessage(HttpMethod.Get, "/another-warmup-route")
        ];
}

The Lambda runtime will simulate executing these HttpRequestMessage objects in order to warm up the ASP.NET Core and Lambda pipelines as well as your application code.

Note: Using AddAWSLambdaBeforeSnapshotRequest requires Amazon.Lambda.AspNetCoreServer.Hostingversion .8.0 or later, while the GetBeforeSnapshotRequests method requires Amazon.Lambda.AspNetCoreServer version 9.1.0 or later.

Quantifying Performance Gains

We’ll use the following sample repository to measure the cold start times of a Lambda function with SnapStart disabled, enabled, and then optimized: https://github.com/aws-samples/serverless-dotnet-demo/tree/main/src/NET8MinimalAPISnapSnart. This Lambda function uses ASP.NET Minimal APIs to expose several endpoints. Full instructions for testing are available in the README.md file.

This repo includes configuration for the artillery.io load testing tool so that we can generate significant parallel requests to result in multiple cold starts. And we’ll measure the p50, p90 and p99 of both warmup time (logged as restoreDuration when SnapStart is enabled and initDuration otherwise) and p50, p90 and p99 of function execution duration.

The folllowing table shows the results:

 

Restore or Init Duration (milliseconds) Function Duration (milliseconds)
Function p50 p90 p99 p50 p90 p99
SnapStart disabled 767.91 809.69 954.86 816.19 870.97 955.81
SnapStart enabled 416.16 510.09 651.47 468.2 500.77 3006.89
SnapStart optimized 415.24 516.01 679.33 155.53 182.26 491.17

With SnapStart enabled, Restore duration is consistently better than the equivalent Init Duration of the non-SnapStart enabled Lambda by several hundred milliseconds. And by using the optimizations discussed in this blog, the Function duration is as low as 182 milliseconds for 90% of cold starts.

Adding the Function Duration and Restore/Init Duration, we can see a more complete picture as to how consumers will experience the cold starts. At the P90, a client will wait 1,680.66 milliseconds when invoking a Lambda without SnapStart enabled vs 698.27 milliseconds for the Lambda using SnapStart with optimizations. Note: Neither calculation includes network transfer times.

Summary

Enabling and optimizing SnapStart for .NET Lambda functions is an important tool for minimizing cold start times and reducing latency in your applications. Visit Improving startup performance with Lambda SnapStart in the Lambda Developer Guide to learn more about SnapStart.

Try the optimizations in your own application, or visit our sample repository to see examples of using SnapStart: https://github.com/aws-samples/serverless-dotnet-demo/tree/main/src/NET8MinimalAPISnapSnart

Found a bug or would like to suggest a feature? You can connect with the team on GitHub at the https://github.com/aws/aws-lambda-dotnet repository.

Philip Pittle

Philip Pittle

Philip 'pj' Pittle has been developing .NET applications since Framework 1.1. He is a Senior Software Engineer building .NET tools for AWS developers, with a focus on open source. You can find him at https://github.com/ppittle and https://twitter.com/theCloudPhil.