Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

.net 6 aspnet minimal api not using the System.Text.Json source generator? #45064

Closed
1 task done
NYMEZIDE opened this issue Nov 13, 2022 · 17 comments
Closed
1 task done
Labels
old-area-web-frameworks-do-not-use *DEPRECATED* This label is deprecated in favor of the area-mvc and area-minimal labels

Comments

@NYMEZIDE
Copy link

Is there an existing issue for this?

  • I have searched the existing issues

Describe the bug

.NET 6
aspnet web project with minimal api

When using the .NET 6.0 System.Text.Json source generator with minimal api, it seems that source generators files (*.g.cs) never used on runtime, on HTTP request-response jobs.

I put a lot of stopping points in the source generators files (*.g.cs), but code execution does not go there.

Expected Behavior

I'm testing the performance of minimal api against System.Text.Json without source generators and with it in the context of web api projects.

I get exactly the same query results per second (RPS) on a simple POST query example. (on running Release mode of course)

I don't understand how to make the code generated through JsonSerializerContext run.

Calling
var json = JsonSerializer.Serialize(new MyJsonDto() { }, MyJsonDtoContext.Default.MyJsonDto);
manually, in console app, successfully enters the *.g.cs files on Debug mode. And the benchmark shows an increase in performance in Release mode.

Steps To Reproduce

https://github.com/NYMEZIDE/JsonSourceGeneratorsNotUsed/

using System.Text.Json.Serialization;

var builder = WebApplication.CreateBuilder(args);

// Add services to the container.

builder.Services.Configure<Microsoft.AspNetCore.Http.Json.JsonOptions>(options =>
{
    options.SerializerOptions.AddContext<MyJsonDtoContext>();
});

//builder.Services.Configure<Microsoft.AspNetCore.Mvc.JsonOptions>(options =>
//{
//    options.JsonSerializerOptions.AddContext<MyJsonDtoContext>();
//});


var app = builder.Build();

app.MapPost("/test", (MyJsonDto dto) =>
{
    return dto;
});

app.Run();



public class MyJsonDto
{
    public int Id { get; set; }

    public string Name { get; set; }
}

[JsonSerializable(typeof(MyJsonDto))]
public partial class MyJsonDtoContext : JsonSerializerContext { }

Exceptions (if any)

No response

.NET Version

6.0.400

Anything else?

.NET SDK (reflecting any global.json):
Version: 6.0.400
Commit: 7771abd614

Среда выполнения:
OS Name: Windows
OS Version: 10.0.22000
OS Platform: Windows
RID: win10-x64
Base Path: C:\Program Files\dotnet\sdk\6.0.400\

global.json file:
Not found

Host:
Version: 6.0.8
Architecture: x64
Commit: 55fb7ef977

.NET SDKs installed:
2.2.207 [C:\Program Files\dotnet\sdk]
6.0.400 [C:\Program Files\dotnet\sdk]

.NET runtimes installed:
Microsoft.AspNetCore.All 2.1.30 [C:\Program Files\dotnet\shared\Microsoft.AspNetCore.All]
Microsoft.AspNetCore.All 2.2.8 [C:\Program Files\dotnet\shared\Microsoft.AspNetCore.All]
Microsoft.AspNetCore.App 2.1.30 [C:\Program Files\dotnet\shared\Microsoft.AspNetCore.App]
Microsoft.AspNetCore.App 2.2.8 [C:\Program Files\dotnet\shared\Microsoft.AspNetCore.App]
Microsoft.AspNetCore.App 3.1.28 [C:\Program Files\dotnet\shared\Microsoft.AspNetCore.App]
Microsoft.AspNetCore.App 5.0.17 [C:\Program Files\dotnet\shared\Microsoft.AspNetCore.App]
Microsoft.AspNetCore.App 6.0.8 [C:\Program Files\dotnet\shared\Microsoft.AspNetCore.App]
Microsoft.NETCore.App 2.1.30 [C:\Program Files\dotnet\shared\Microsoft.NETCore.App]
Microsoft.NETCore.App 2.2.8 [C:\Program Files\dotnet\shared\Microsoft.NETCore.App]
Microsoft.NETCore.App 3.1.28 [C:\Program Files\dotnet\shared\Microsoft.NETCore.App]
Microsoft.NETCore.App 5.0.17 [C:\Program Files\dotnet\shared\Microsoft.NETCore.App]
Microsoft.NETCore.App 6.0.8 [C:\Program Files\dotnet\shared\Microsoft.NETCore.App]
Microsoft.WindowsDesktop.App 3.1.28 [C:\Program Files\dotnet\shared\Microsoft.WindowsDesktop.App]
Microsoft.WindowsDesktop.App 5.0.17 [C:\Program Files\dotnet\shared\Microsoft.WindowsDesktop.App]
Microsoft.WindowsDesktop.App 6.0.8 [C:\Program Files\dotnet\shared\Microsoft.WindowsDesktop.App]

Download .NET:
https://aka.ms/dotnet-download

Learn about .NET Runtimes and SDKs:
https://aka.ms/dotnet/runtimes-sdk-info

@davidfowl
Copy link
Member

Seems to hit the breakpoint for me (once):

image

You can also try setting a breakpoint in one of the property getters or settings and you'll see that it's called more often.

image

So it looks like it's working fine!

@NYMEZIDE
Copy link
Author

Breakpoints (getters or settings) is triggered only once on startup.

For example, method MyJsonDtoSerializeHandler dont calling
image

But, when i call JsonSerializer.Serialize(new MyJsonDto() { }, MyJsonDtoContext.Default.MyJsonDto);, then MyJsonDtoSerializeHandler called, and the breakpoint works.

@javiercn javiercn added the old-area-web-frameworks-do-not-use *DEPRECATED* This label is deprecated in favor of the area-mvc and area-minimal labels label Nov 14, 2022
@davidfowl
Copy link
Member

Getters and setters are used per request. I don't see the serialize handler being used though.

var opt = new JsonSerializerOptions();
opt.AddContext<MyJsonDtoContext>();
JsonSerializer.Serialize(new MyJsonDto(), opt);

cc @layomia @eiriktsarpalis

@NYMEZIDE
Copy link
Author

image

These dedicated breakpoints are only once called on the first HTTP (POST) request. Repeated calls are ignored, execution does not go here.

@eiriktsarpalis
Copy link
Member

I don't see the serialize handler being used though.

That example should result in the fast-path method being exercised, unless of course fast-path has been explicitly disabled in the source generator.

These dedicated breakpoints are only once called on the first HTTP (POST) request. Repeated calls are ignored, execution does not go here.

You've set breakpoints on the initialization method rather than the getter/setter delegates. It is expected that this should only be called once.

@davidfowl
Copy link
Member

@eiriktsarpalis can you reply to this #45064 (comment)

@davidfowl
Copy link
Member

OK I investigated this, and it comes down to the fact that this part of the JSON source generator (the serialization handlers) doesn't work with encoders. The JsonSerializerContext has this logic:

https://github.com/dotnet/runtime/blob/5d1b7e77e054f74de05d6cd34de11c55ffbd125f/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonSerializerContext.cs#L66-L87

Here's the full repro:

using System;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Serialization;

var opt = new JsonSerializerOptions(JsonSerializerDefaults.Web)
{
    Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
};

opt.AddContext<MyJsonDtoContext>();
Console.WriteLine(JsonSerializer.Serialize(new MyJsonDto(), opt));

public class MyJsonDto
{
    public int Id { get; set; }

    public string Name { get; set; }
}

[JsonSerializable(typeof(MyJsonDto))]
[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
public partial class MyJsonDtoContext : JsonSerializerContext { }

@eiriktsarpalis
Copy link
Member

can you reply to this #45064 (comment)

Cf. first section of my previous response.

Here's the full repro:

This is by design. Because fast-path serialization essentially inlines all serialization logic, configuration needs to be specified at compile time (a subset of JsonSerializerOptions can be configured at compile time using the JsonSourceGenerationOptionsAttribute). If the serializer detects a divergence of runtime configuration via the supplied JsonSerializerOptions instance, it will fall back to metadata-driven serialization.

@NYMEZIDE
Copy link
Author

I tried placing breakpoints on all *.g.cs files (methods and getters/setters). Only the initialization is executed on the first request. Subsequently, the code does not go into *.g.cs files.
Is it designed that way too? @eiriktsarpalis

I compared performance, load testing, web api without json source-generators and with it.
The performance is exactly the same. There was not even a 1% gain.

Then I went to look in Debug mode. And realized that the code from the generators is not involved in processing HTTP requests.

@eiriktsarpalis
Copy link
Member

eiriktsarpalis commented Nov 15, 2022

Only the initialization is executed on the first request. Subsequently, the code does not go into *.g.cs files.

On each subsequent request you should be able to hit breakpoints either inside the getter/setter delegates or one of the Handler suffix methods if using the fast path. If it doesn't, it could mean that a breakpoint hasn't been set in the right location or somehow aspnet is using the reflection-based serializer. FWIW there was a known issue in .NET 6 where the serializer would silently fall back to reflection under specific circumstances.

The performance is exactly the same. There was not even a 1% gain.

In most cases the source generator does not improve serialization performance. There should only be a few gains (~40%) if fast-path serialization is being used.

@NYMEZIDE
Copy link
Author

~40% - it's huge gains, i think.

I'm comparing manual serialization, where the real 40% performance in benchmarks. I expected to see similar numbers in webapi projects, api http requests, or at least 10%.

And what does fast-path serialization mean? Is it some special serialization method available only at runtime?

@eiriktsarpalis
Copy link
Member

And what does fast-path serialization mean?

It's one of the two modes available to the source generator, as exposed in the JsonSourceGenerationMode enum

  • Metadata mode, which emits JsonTypeInfo metadata similar to the reflection serializer (this is the part of the source generator that initializes JsonPropertyInfoValues<T> instances). It has the same performance characteristics as the reflection serializer but is trimmer safe, since things like constructors and properties are generated at compile time rather than using reflection at run time. It supports both serialization and deserialization.
  • Serialization (aka fast-path mode), which emits inlined serialization methods (the ones with the Handler suffix). It is faster than metadata mode (~40%) but does not support deserialization and does not support all JsonSerializerOptions configurations.

By default, the source generator will emit code for both modes and will only exercise the fast path if supported by the current JsonSerializerOptions configuration. See How to use source generation in System.Text.Json for more info.

@eerhardt
Copy link
Member

eerhardt commented Nov 15, 2022

and it comes down to the fact that this part of the JSON source generator (the serialization handlers) doesn't work with encoders

It's even worse than that, from what I can tell. ASP.NET calls the JsonSerializer.SerializeAsync<TValue>(Stream utf8Json, ...) overload:

private static async Task WriteAsJsonAsyncSlow<TValue>(
Stream body,
TValue value,
JsonSerializerOptions? options,
CancellationToken cancellationToken)
{
try
{
await JsonSerializer.SerializeAsync(body, value, options, cancellationToken);

From inspecting the code in System.Text.Json, this API will never call into the "fast path" / SerializeHandler code. No matter the Options being used.

This is because the WriteStack.SupportContinuation field gets set to true here:

https://github.com/dotnet/runtime/blob/264d7391ec9f6e698051db0621c5e090d0ae4710/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonSerializer.Write.Stream.cs#L319

And JsonMetadataServicesConverter<T> checks !state.SupportContinuation before calling the SerializeHandler:

https://github.com/dotnet/runtime/blob/264d7391ec9f6e698051db0621c5e090d0ae4710/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Converters/JsonMetadataServicesConverter.cs#L67-L78

Repro:

using System;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading;

var options = new JsonSerializerOptions(JsonSerializerDefaults.Web)
{
    //Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
};

options.AddContext<MyJsonDtoContext>();

Console.WriteLine(JsonSerializer.Serialize(new MyJsonDto(), options)); // calls the "fast path"

Stream body = Console.OpenStandardOutput();
await JsonSerializer.SerializeAsync(body, new MyJsonDto(), options, cancellationToken: default); // doesn't call the "fast path"

public class MyJsonDto
{
    public int Id { get; set; }

    public string Name { get; set; }
}

[JsonSerializable(typeof(MyJsonDto))]
[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
public partial class MyJsonDtoContext : JsonSerializerContext { }

@eerhardt
Copy link
Member

eerhardt commented Nov 15, 2022

Related: dotnet/runtime#75139 (comment)

Triage: fast path source gen is inherently incompatible with async.

Also note that the "fast path" logic doesn't get invoked when doing "sync" call passing in a Stream for the same reason above - WriteStack.SupportContinuation is set to true here:

https://github.com/dotnet/runtime/blob/264d7391ec9f6e698051db0621c5e090d0ae4710/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonSerializer.Write.Stream.cs#L395

@NYMEZIDE
Copy link
Author

It turns out that in GET requests, where only Serialization, source generators do not participate in optimization and do not speed up HTTP requests.
in POST requests on deserialization are never executed.

So json source generators optimize only manual serialization, there is no point in adding them to WebApi via AddContext<>.

Did i get it right?

@rafikiassumani-msft
Copy link
Contributor

Triage: Closing as there is a PR to fix this in the runtime repo.

@davidfowl
Copy link
Member

Reference dotnet/runtime#78646 (review)

@ghost ghost locked as resolved and limited conversation to collaborators Dec 24, 2022
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
old-area-web-frameworks-do-not-use *DEPRECATED* This label is deprecated in favor of the area-mvc and area-minimal labels
Projects
None yet
Development

No branches or pull requests

6 participants