Skip to content

Fix NativeAOT resource string inlining for async IL - #133970

Merged
jakobbotsch merged 1 commit into
dotnet:mainfrom
jakobbotsch:fix-133954
Sep 16, 2026
Merged

jakobbotsch merged 1 commit into
dotnet:mainfrom
jakobbotsch:fix-133954

Conversation

@jakobbotsch

@jakobbotsch jakobbotsch commented Sep 15, 2026

Copy link
Copy Markdown
Member

Preserve ECMA module identity for async IL wrappers and recognize it when injecting and resolving resource string tokens.

Fix #133954

Before fix After fix
Runtime async disabled 1,601,536 bytes 1,601,536 bytes
Runtime async enabled 2,196,480 bytes 1,610,240 bytes

The remaining size difference is coming from

[AsyncMethodBuilder(typeof(PoolingAsyncValueTaskMethodBuilder))]
[RuntimeAsyncMethodGeneration(false)]
public static async ValueTask InvokeAsync<TState>(Action<TState> action, TState state, CancellationToken cancellationToken)
which is reachable by the app and which pulls in async1 infrastructure.

Preserve ECMA module identity for async IL wrappers and recognize it when injecting and resolving resource string tokens.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: bf8623a5-a7a4-4c7d-8371-a97b53478abe
@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 3 pipeline(s).
13 pipeline(s) were filtered out due to trigger conditions.
There may be pipelines that require an authorized user to comment /azp run to run.

@dotnet-policy-service

Copy link
Copy Markdown
Contributor

Tagging subscribers to this area: @agocke, @dotnet/ilc-contrib
See info in area-owners.md if you want to be subscribed.

@jakobbotsch
jakobbotsch marked this pull request as ready for review September 16, 2026 07:17
Copilot AI lite review requested due to automatic review settings September 16, 2026 07:17
@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 3 pipeline(s).
13 pipeline(s) were filtered out due to trigger conditions.
There may be pipelines that require an authorized user to comment /azp run to run.

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.

🔵 Needs a closer look

Add regression coverage for async resource-string inlining and verify the resource is not rooted.

Pull request overview

Fixes NativeAOT resource-string inlining for runtime-async IL by preserving ECMA module identity.

Changes:

  • Adds an ECMA-aware async IL wrapper.
  • Updates resource token injection and resolution for ECMA-backed IL.
File summaries
File Summary
src/coreclr/tools/Common/TypeSystem/IL/NativeAotILProvider.cs Preserves module identity for async IL wrappers.
src/coreclr/tools/aot/ILCompiler.Compiler/Compiler/SubstitutedILProvider.cs Enables resource-string token handling for ECMA-backed async IL.
Review details

Suppressed comments (2)

src/coreclr/tools/Common/TypeSystem/IL/NativeAotILProvider.cs:374

  • Could you add a NativeAOT regression test that exercises a real runtime-async method containing an SR resource-property access? The existing FrameworkStrings coverage only exercises resource-string substitution from synchronous IL, so it would still pass if this wrapper failed to preserve its ECMA module. The assertion should verify the resource is not rooted (or inspect the resulting size/dependency output), not only that the async call returns the expected string, since the old implementation can still return the value by retaining the resource.
                return wrappedIL is EcmaMethodIL ecmaIL
                    ? new EcmaAsyncMethodIL(asyncVariantImpl, ecmaIL)
                    : new AsyncMethodIL(asyncVariantImpl, wrappedIL);

src/coreclr/tools/aot/ILCompiler.Compiler/Compiler/SubstitutedILProvider.cs:635

  • Please add a NativeAOT regression test for this path. The existing runtime-async tests do not exercise SubstitutedILProvider's resource-string inlining, so a future regression in the async wrapper's ECMA-module identity or injected-token resolution could recreate the size/rooting issue without failing tests. The test should compile an async method containing a generated SR accessor and verify that the accessor is inlined and its resource is not rooted.

[!NOTE]
This review comment was created by GitHub Copilot.

            if (hasGetResourceStringCall && method.GetMethodILDefinition() is IEcmaMethodIL ecmaMethodIL)
  • Files reviewed: 2/2 changed files
  • Comments generated: 0
  • Review effort level: Lite

@jakobbotsch

Copy link
Copy Markdown
Member Author

/backport to release/11.0

@github-actions

Copy link
Copy Markdown
Contributor

Started backporting to release/11.0 (link to workflow run)

@dotnet-milestone-bot dotnet-milestone-bot Bot added this to the 12.0-preview1 milestone Sep 17, 2026
JulieLeeMSFT pushed a commit that referenced this pull request Sep 17, 2026
…134065)

Backport of #133970 to release/11.0

/cc @jakobbotsch

## Customer Impact

- [x] Customer reported
- [ ] Found internally

NativeAOT fails to fold some string resource lookups when used from
within async methods. This leads to pulling in a large amount of
unnecessary resource machinery, bloating app sizes using string
resources for NativeAOT. Reported by customer in #133954.

## Regression

- [ ] Yes
- [X] No

## Testing

Manual testing on customer's application. NativeAOT outerloop pipeline
run.

## Risk

Low.

Co-authored-by: Jakob Botsch Nielsen <Jakob.botsch.nielsen@gmail.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: bf8623a5-a7a4-4c7d-8371-a97b53478abe
@am11

am11 commented Sep 18, 2026

Copy link
Copy Markdown
Member

I think overall effect is amotrized. e.g. dotnet new webapiaot produces 11662000 bytes sized linux-x64 binary with disabled and 11661968 bytes with enabled (-32 bytes with enabled). And if we add more async code to Program.cs:

using System.Text.Json.Serialization;
using Microsoft.AspNetCore.Http.HttpResults;

var builder = WebApplication.CreateSlimBuilder(args);

builder.Services.ConfigureHttpJsonOptions(options =>
{
    options.SerializerOptions.TypeInfoResolverChain.Insert(0, AppJsonSerializerContext.Default);
});

builder.Services.AddOpenApi();

var app = builder.Build();

if (app.Environment.IsDevelopment())
{
    app.MapOpenApi();
}

Todo[] sampleTodos =
[
    new(1, "Walk the dog"),
    new(2, "Do the dishes", DateOnly.FromDateTime(DateTime.Now)),
    new(3, "Do the laundry", DateOnly.FromDateTime(DateTime.Now.AddDays(1))),
    new(4, "Clean the bathroom"),
    new(5, "Clean the car", DateOnly.FromDateTime(DateTime.Now.AddDays(2)))
];

var todosApi = app.MapGroup("/todos");
todosApi.MapGet("/", () => sampleTodos).WithName("GetTodos");

todosApi.MapGet("/{id}", Results<Ok<Todo>, NotFound> (int id) =>
    sampleTodos.FirstOrDefault(a => a.Id == id) is { } todo
        ? TypedResults.Ok(todo)
        : TypedResults.NotFound())
    .WithName("GetTodoById");

// --- START OF AMORTIZATION TEST MIXED ASYNC BAG ---
var testApi = app.MapGroup("/performance-test");

// Generate 50 distinct async endpoints with unpredictable execution branches
for (int i = 1; i <= 50; i++)
{
    int currentId = i;
    testApi.MapGet($"/metric-{currentId}", async Task<Results<Ok<AsyncPayload>, BadRequest<string>>> (int factor) =>
    {
        // Branching state machine execution path to prevent aggressive optimization
        if (factor < 0)
        {
            await Task.Delay(1); // Forces suspension point
            return TypedResults.BadRequest("Factor must be positive");
        }

        // Simulating data crunching with real async yields
        await Task.Yield(); 
        
        long calculationResult = 0;
        for (int j = 0; j < 100; j++)
        {
            calculationResult += (currentId * factor) + j;
        }

        // Multi-stage suspension logic
        await Task.Delay(TimeSpan.FromMilliseconds(5)); 

        var payload = new AsyncPayload(
            Id: currentId,
            Hash: $"Step_{currentId}_{calculationResult}",
            Timestamp: DateTime.UtcNow,
            IsProcessed: true
        );

        return TypedResults.Ok(payload);
    }).WithName($"GetMetric-{currentId}");
}
// --- END OF AMORTIZATION TEST MIXED ASYNC BAG ---

app.Run();

public record Todo(int Id, string? Title, DateOnly? DueBy = null, bool IsComplete = false);
public record AsyncPayload(int Id, string Hash, DateTime Timestamp, bool IsProcessed);

[JsonSerializable(typeof(Todo[]))]
[JsonSerializable(typeof(AsyncPayload))] // Required for AOT Native serialization
internal partial class AppJsonSerializerContext : JsonSerializerContext
{
}

then 11736720 bytes disabled and 11728304 bytes enabled (-8.2KB with enabled). Tested with latest main https://github.com/dotnet/dotnet/commits/c45ccf8451.

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Problem with runtime async in .NET 11 RC1: unexpectedly larger binary size

4 participants