Skip to content

Reduce allocations in Amazon.Lambda.RuntimeSupport - #2601

Open
martincostello wants to merge 10 commits into
aws:masterfrom
martincostello:performance-improvements
Open

martincostello wants to merge 10 commits into
aws:masterfrom
martincostello:performance-improvements

Conversation

@martincostello

@martincostello martincostello commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor

Issue #, if available:

Description of changes:

Reduce the memory allocated and CPU time used by Amazon.Lambda.RuntimeSupport to process each invocation. Each change is in its own commit:

  • Do not format the messages for the internal logger when debug logging (LAMBDA_RUNTIMESUPPORT_DEBUG) is disabled, which allocated strings that were discarded multiple times for every invocation.
  • Use the static HttpMethod instances and add the Accept and Content-Type headers to requests to the Runtime API without parsing them for every request.
  • Look up the headers for the next invocation ignoring case without copying them into a new case-insensitive dictionary for every invocation.
  • Create the ClientContext, Identity and W3C values of LambdaContext when they are first used, without allocating Lazy<T> instances and delegates for every invocation.
  • Do not copy the headers of a successful (202 Accepted) response to an invocation into a dictionary, as they are not used.
  • Cache the URI used to get the next invocation instead of building and parsing it for every invocation.
  • Create the dictionary for the headers of the next invocation with the required capacity to avoid resizing it.

Tests were added for the requests sent to the Runtime API, reading the Runtime API headers ignoring case and the values of LambdaContext. They were run against the existing code before changing it to verify that the behaviour is unchanged.

Two minor differences in behaviour:

  • If the values of ClientContext, Identity or W3C are first read concurrently, the JSON may be parsed more than once, but all callers get the same instance. An exception from parsing invalid JSON is thrown each time the value is read, rather than being cached by Lazy<T>.
  • A dictionary of headers passed to RuntimeApiHeaders containing two keys that differ only by case no longer throws an exception. This cannot happen for the headers of an HTTP response.

Benchmarks

The benchmark invokes LambdaBootstrap.InvokeOnceAsync() against an in-memory implementation of the Runtime API (an HttpMessageHandler) that returns an invocation for /next and accepts the /response, so it measures the overhead of RuntimeSupport itself. The allocations of the in-memory Runtime API are included in the results, and are the same for both.

Each comparison is a single BenchmarkDotNet run with master (de88d8b) as the baseline job, with each job building the benchmark against a different copy of the repository. The benchmarks were run with three launches per job, pinned to the performance cores of the CPU (--affinity 0xFFF).

Runtime Mean (master) Mean (branch) Ratio Allocated (master) Allocated (branch) Alloc Ratio
.NET 10 36.06 us 31.35 us 0.87 9.38 KB 5.87 KB 0.63
.NET 8 37.92 us 35.39 us 0.94 9.33 KB 6.13 KB 0.66

Note

  • The time improvement for .NET 8 is within the margin of error (RatioSD 0.08).
  • The timings were measured on Windows, rather than Linux which Lambda runs on, as they could not be measured reliably in a Linux container on this machine: the CPU has performance and efficiency cores, and the container could not be pinned to the performance cores, giving bimodal results that varied by up to 2x between runs of the same code. Reading and writing environment variables (which RuntimeSupport does for every invocation and is unchanged by these changes) is more expensive on Windows, so the absolute times are higher than they would be on Linux.

The allocations were also measured on Linux (.NET 10) in a container for each change in turn:

Change Allocated
master 9.34 KB
Avoid formatting disabled internal log messages 8.33 KB
Avoid parsing Runtime API request headers 7.80 KB
Avoid copying Runtime API headers 7.48 KB
Avoid allocating lazy values for LambdaContext 7.04 KB
Avoid copying headers of accepted responses 6.43 KB
Cache the URI for the next invocation 6.00 KB
Size headers dictionary for next invocation 5.88 KB
Full results

.NET 10

BenchmarkDotNet v0.15.4, Windows 11 (10.0.26300.9711)
13th Gen Intel Core i7-13700H 2.90GHz, 1 CPU, 20 logical and 14 physical cores
.NET SDK 11.0.100-rc.3.26522.148
  branch : .NET 10.0.14 (10.0.14, 10.0.1426.52326), X64 RyuJIT x86-64-v3
  master : .NET 10.0.14 (10.0.14, 10.0.1426.52326), X64 RyuJIT x86-64-v3

Affinity=00000000111111111111  Runtime=.NET 10.0  LaunchCount=3
Method Job Mean Error StdDev Median Ratio RatioSD Gen0 Allocated Alloc Ratio
InvokeOnce branch 31.35 us 0.539 us 2.385 us 30.79 us 0.87 0.09 0.4272 5.87 KB 0.63
InvokeOnce master 36.06 us 0.463 us 2.308 us 35.90 us 1.00 0.09 0.7324 9.38 KB 1.00

.NET 8

BenchmarkDotNet v0.15.4, Windows 11 (10.0.26300.9711)
13th Gen Intel Core i7-13700H 2.90GHz, 1 CPU, 20 logical and 14 physical cores
.NET SDK 11.0.100-rc.3.26522.148
  branch : .NET 8.0.33 (8.0.33, 8.0.3326.52219), X64 RyuJIT x86-64-v3
  master : .NET 8.0.33 (8.0.33, 8.0.3326.52219), X64 RyuJIT x86-64-v3

Affinity=00000000111111111111  Runtime=.NET 8.0  LaunchCount=3
Method Job Mean Error StdDev Ratio RatioSD Gen0 Allocated Alloc Ratio
InvokeOnce branch 35.39 us 0.788 us 2.445 us 0.94 0.08 0.4883 6.13 KB 0.66
InvokeOnce master 37.92 us 0.694 us 2.014 us 1.00 0.07 0.7324 9.33 KB 1.00
Benchmark code

RsBench.csproj

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFrameworks>net10.0;net8.0</TargetFrameworks>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
    <ManagePackageVersionsCentrally>false</ManagePackageVersionsCentrally>
    <!-- The root of the aws-lambda-dotnet repository to benchmark -->
    <RuntimeSupportRoot Condition=" '$(RuntimeSupportRoot)' == '' ">path/to/aws-lambda-dotnet</RuntimeSupportRoot>
  </PropertyGroup>
  <ItemGroup>
    <ProjectReference Include="$(RuntimeSupportRoot)/Libraries/src/Amazon.Lambda.RuntimeSupport/Amazon.Lambda.RuntimeSupport.csproj" />
    <PackageReference Include="BenchmarkDotNet" Version="0.15.4" />
  </ItemGroup>
</Project>

Program.cs

using System.Net;
using System.Reflection;
using System.Text;
using Amazon.Lambda.RuntimeSupport;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Configs;
using BenchmarkDotNet.Environments;
using BenchmarkDotNet.Jobs;
using BenchmarkDotNet.Running;

if (args is ["--compare", var runtime, var baselineRoot, var targetRoot, .. var rest])
{
    // Compare two copies of the repository in the same run, with the first as the baseline
    Runtime clr = runtime == "net8.0" ? CoreRuntime.Core80 : CoreRuntime.Core10_0;

    var config = DefaultConfig.Instance
        .AddJob(Job.Default.WithRuntime(clr).WithArguments([new MsBuildArgument($"/p:RuntimeSupportRoot={baselineRoot}")]).WithId("master").AsBaseline())
        .AddJob(Job.Default.WithRuntime(clr).WithArguments([new MsBuildArgument($"/p:RuntimeSupportRoot={targetRoot}")]).WithId("branch"));

    BenchmarkSwitcher.FromAssembly(typeof(InvokeBenchmarks).Assembly).Run(rest, config);
    return;
}

BenchmarkSwitcher.FromAssembly(typeof(InvokeBenchmarks).Assembly).Run(args);

[MemoryDiagnoser]
public class InvokeBenchmarks
{
    private static readonly byte[] Response = Encoding.UTF8.GetBytes("""{"statusCode":200,"body":"Hello World"}""");

    private HttpClient? httpClient;
    private LambdaBootstrap? bootstrap;
    private Func<CancellationToken, Task>? invokeOnce;

    [GlobalSetup]
    public void Setup()
    {
        // The Lambda runtime sets these environment variables
        Environment.SetEnvironmentVariable("AWS_LAMBDA_RUNTIME_API", "127.0.0.1:9001");
        Environment.SetEnvironmentVariable("AWS_LAMBDA_FUNCTION_NAME", "benchmark");
        Environment.SetEnvironmentVariable("AWS_LAMBDA_FUNCTION_VERSION", "$LATEST");
        Environment.SetEnvironmentVariable("AWS_LAMBDA_FUNCTION_MEMORY_SIZE", "256");
        Environment.SetEnvironmentVariable("AWS_LAMBDA_LOG_GROUP_NAME", "/aws/lambda/benchmark");
        Environment.SetEnvironmentVariable("AWS_LAMBDA_LOG_STREAM_NAME", "2026/10/26/[$LATEST]c53dd774881747b29e48cc58a46720e5");

        // Do not replace Console.Out, which BenchmarkDotNet uses
        Environment.SetEnvironmentVariable("AWS_LAMBDA_DOTNET_DISABLE_CONSOLE_CAPTURE", "true");

        this.httpClient = new HttpClient(new RuntimeApiHandler());
        this.bootstrap = new LambdaBootstrap(this.httpClient, Handler);

        // LambdaBootstrap.InvokeOnceAsync() is internal, so invoke it via reflection
        var method = typeof(LambdaBootstrap).GetMethod("InvokeOnceAsync", BindingFlags.Instance | BindingFlags.NonPublic)!;
        this.invokeOnce = method.CreateDelegate<Func<CancellationToken, Task>>(this.bootstrap);
    }

    [GlobalCleanup]
    public void Cleanup()
    {
        this.bootstrap?.Dispose();
        this.httpClient?.Dispose();
    }

    [Benchmark]
    public Task InvokeOnce() => this.invokeOnce!(CancellationToken.None);

    private static Task<InvocationResponse> Handler(InvocationRequest request)
        => Task.FromResult(new InvocationResponse(new MemoryStream(Response, writable: false)));

    // An in-memory implementation of the Lambda Runtime API for one invocation at a time
    private sealed class RuntimeApiHandler : HttpMessageHandler
    {
        private static readonly byte[] Event = Encoding.UTF8.GetBytes("""{"key1":"value1","key2":"value2","key3":"value3"}""");

        protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
        {
            var path = request.RequestUri!.AbsolutePath;

            if (request.Method == HttpMethod.Get && path == "/2018-06-01/runtime/invocation/next")
            {
                var response = new HttpResponseMessage(HttpStatusCode.OK)
                {
                    Content = new ByteArrayContent(Event),
                };

                response.Content.Headers.ContentType = new("application/json");
                response.Headers.Add("Lambda-Runtime-Aws-Request-Id", "8476a536-e9f4-11e8-9739-2dfe598c3fcd");
                response.Headers.Add("Lambda-Runtime-Deadline-Ms", "1542409706888");
                response.Headers.Add("Lambda-Runtime-Invoked-Function-Arn", "arn:aws:lambda:us-east-2:123456789012:function:benchmark");
                response.Headers.Add("Lambda-Runtime-Trace-Id", "Root=1-5bef4de7-ad49b0e87f6ef6c87fc2e700;Parent=9a9197af755a6419;Sampled=1");

                return response;
            }

            if (request.Method == HttpMethod.Post && path.EndsWith("/response", StringComparison.Ordinal))
            {
                // Read the response body as the Runtime API would
                await request.Content!.CopyToAsync(Stream.Null, cancellationToken);

                return new HttpResponseMessage(HttpStatusCode.Accepted)
                {
                    Content = new StringContent("""{"status":"OK"}""", Encoding.UTF8, "application/json"),
                };
            }

            return new HttpResponseMessage(HttpStatusCode.NotFound);
        }
    }
}

The comparisons were run with:

dotnet run -c Release -f net10.0 -- --compare <net10.0|net8.0> path/to/master path/to/branch --filter * --affinity 4095 --launchCount 3

Tests

All the unit tests in Amazon.Lambda.RuntimeSupport.UnitTests pass for each commit.

Note

This pull request description and the benchmark results were generated with Claude Code.

By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.

Do not format the messages for the internal logger when the LAMBDA_RUNTIMESUPPORT_DEBUG environment variable is not set, which allocated strings that were discarded multiple times for every invocation.

Assisted-by: Claude Code <noreply@anthropic.com>
Use the static HttpMethod instances and add the Accept and Content-Type headers to requests to the Runtime API without parsing them for every request.

Assisted-by: Claude Code <noreply@anthropic.com>
Look up the headers for the next invocation ignoring case without copying them into a new case-insensitive dictionary for every invocation.

Assisted-by: Claude Code <noreply@anthropic.com>
Create the ClientContext, Identity and W3C values of LambdaContext when they are first used, without allocating Lazy<T> instances and delegates for every invocation.

Assisted-by: Claude Code <noreply@anthropic.com>
Do not copy the headers of a successful response to an invocation into a dictionary, as they are not used.

Assisted-by: Claude Code <noreply@anthropic.com>
Cache the URI used to get the next invocation instead of building and parsing it for every invocation.

Assisted-by: Claude Code <noreply@anthropic.com>
Create the dictionary for the headers of the next invocation with the required capacity to avoid resizing it for every invocation.

Assisted-by: Claude Code <noreply@anthropic.com>
Add a change file for the performance improvements to Amazon.Lambda.RuntimeSupport.

Assisted-by: Claude Code <noreply@anthropic.com>
@martincostello
martincostello marked this pull request as ready for review October 26, 2026 18:22
@martincostello
martincostello requested review from a team as code owners October 26, 2026 18:22
@martincostello
martincostello requested review from dscpinheiro and normj and a balanced review from Copilot October 26, 2026 18:22

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Warning

Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.

Copilot review overview

5 open issues

Review effort: Lite

Comment thread Libraries/src/Amazon.Lambda.RuntimeSupport/Context/LambdaContext.cs
Read and write `NextUri` with `Volatile.Read` and `Volatile.Write` so that the instance is explicitly safely published to the concurrent processing tasks that share the client.

@normj normj left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changes look good, thanks the perf improvements.

…orTests

The RuntimeSupport changes in this PR shifted line positions in
InternalClientAdapted.cs, moving the expected CS0117 diagnostics that the
Annotations source-generator tests anchor to that file. Remap the hardcoded
WithSpan line numbers to their new positions (columns unchanged). Verified
593/593 tests pass on net8.0 and net10.0.
@normj

normj commented Oct 27, 2026

Copy link
Copy Markdown
Member

I pushed a commit back to your branch to fix some tests that started failing in CI after your change.

@martincostello

Copy link
Copy Markdown
Contributor Author

Thanks Norm - sorry I missed that.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants