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

Implement a heuristic for using fast-path serialization in streaming JsonSerializer methods. #78646

Merged

Conversation

eiriktsarpalis
Copy link
Member

@eiriktsarpalis eiriktsarpalis commented Nov 21, 2022

This PR refactors the root-level serialization methods so that source generated fast-path serialization delegates can be utilized in more places, including object values and in the streaming serialization methods. Specifically for the streaming methods, a heuristic is implemented where fast-path serialization kicks in if the following conditions are satisfied for a given type:

  1. No serialized value has generated a JSON payload exceeding JsonSerializerOptions.DefaultBufferSize / 2 bytes AND
  2. At least 10 serialization operations have already been completed.

This is to prevent excessive buffering since the fast path serializer doesn't support streaming.

Performance

The change shows notable performance improvements in streaming serialization workloads:

WriteJson_LoginViewModel

Method Job Toolchain Mode Mean Error StdDev Median Min Max Ratio MannWhitney(3%) RatioSD Gen0 Allocated Alloc Ratio
SerializeToStream Job-KZZOPZ main SourceGen 416.3 ns 14.25 ns 15.84 ns 413.8 ns 387.5 ns 443.4 ns 1.00 Base 0.00 0.0141 152 B 1.00
SerializeToStream Job-UEGCWD PR SourceGen 344.4 ns 32.70 ns 37.65 ns 345.7 ns 294.5 ns 436.7 ns 0.83 Faster 0.09 0.0150 152 B 1.00

WriteJson_LargeStructWithProperties

Method Job Branch Mode Mean Error StdDev Median Min Max Ratio MannWhitney(3%) RatioSD Gen0 Allocated Alloc Ratio
SerializeToStream Job-KZZOPZ main SourceGen 768.1 ns 42.52 ns 48.97 ns 761.1 ns 696.2 ns 864.0 ns 1.00 Base 0.00 0.0211 232 B 1.00
SerializeToStream Job-UEGCWD PR SourceGen 393.0 ns 11.23 ns 11.53 ns 388.5 ns 383.7 ns 426.3 ns 0.52 Faster 0.02 0.0151 152 B 0.66

WriteJson_Dictionary

Method Job Toolchain Mode Mean Error StdDev Median Min Max Ratio MannWhitney(3%) RatioSD Allocated Alloc Ratio
SerializeToStream Job-KZZOPZ main SourceGen 10.260 μs 1.3123 μs 1.5112 μs 9.767 μs 8.691 μs 13.380 μs 1.00 Base 0.00 152 B 1.00
SerializeToStream Job-UEGCWD PR SourceGen 7.558 μs 0.6635 μs 0.7640 μs 7.557 μs 6.435 μs 9.027 μs 0.75 Faster 0.15 152 B 1.00

WriteJson_ImmutableDictionary

Method Job Toolchain Mode Mean Error StdDev Median Min Max Ratio MannWhitney(3%) RatioSD Allocated Alloc Ratio
SerializeToStream Job-KZZOPZ main SourceGen 26.40 μs 1.519 μs 1.688 μs 26.40 μs 22.39 μs 29.12 μs 1.00 Base 0.00 304 B 1.00
SerializeToStream Job-UEGCWD PR SourceGen 12.07 μs 1.120 μs 1.289 μs 11.52 μs 10.69 μs 15.26 μs 0.45 Faster 0.05 152 B 0.50

@ghost
Copy link

ghost commented Nov 21, 2022

Tagging subscribers to this area: @dotnet/area-system-text-json, @gregsdennis
See info in area-owners.md if you want to be subscribed.

Issue Details

This PR refactors the root-level serialization methods so that source generated fast-path serialization delegates can be utilized in more places, including object values and in the streaming serialization methods. Specifically for the streaming methods, a heuristic is implemented where fast-path serialization kicks in if the following conditions are satisfied for a given type:

  1. No serialized value has generated a JSON payload exceeding JsonSerializerOptions.DefaultBufferSize / 2 bytes AND
  2. At least 10 serialization operations have already been completed.

This is to prevent excessive buffering since the fast path serializer doesn't support streaming.

Author: eiriktsarpalis
Assignees: -
Labels:

area-System.Text.Json

Milestone: -

@eiriktsarpalis eiriktsarpalis added the tenet-performance Performance related issue label Nov 21, 2022
@eiriktsarpalis eiriktsarpalis added this to the 8.0.0 milestone Nov 21, 2022
@eiriktsarpalis
Copy link
Member Author

FYI @stephentoub @eerhardt @davidfowl

Copy link
Contributor

@layomia layomia left a comment

Choose a reason for hiding this comment

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

Minor questions but LGTM overrall.

@eiriktsarpalis
Copy link
Member Author

We just merged dotnet/performance#2740 adding source gen benchmarks to dotnet/performance. I'm holding off from merging this PR for a few days, to give our performance infrastructure the chance to properly record the performance numbers before this change.

supportAsync: true);

using var bufferWriter = new PooledByteBufferWriter(Options.DefaultBufferSize);
using var writer = new Utf8JsonWriter(bufferWriter, Options.GetWriterOptions());
Copy link
Member

Choose a reason for hiding this comment

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

Has any thought been put into making the cache use a ConcurrentQueue instead so it can be used in async paths?
Example: https://github.com/dotnet/aspnetcore/blob/main/src/Servers/Kestrel/Transport.Sockets/src/Internal/SocketSenderPool.cs

Copy link
Member Author

Choose a reason for hiding this comment

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

Given that the perf benefits are fairly modest when doing thread-local caching in the sync methods, my expectation is that any mechanism requiring more elaborate synchronization would likely offer diminishing returns.

Copy link
Member

Choose a reason for hiding this comment

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

The big gain we've seen in our JSON web benchmarks is that by reducing the allocations of the Utf8JsonWriter (aka pooling them) it greatly reduces the working set of the application, from over 400 MB to ~60 MB for a test run doing millions of RPS.

Copy link
Member

Choose a reason for hiding this comment

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

The big gain we've seen in our JSON web benchmarks is that by reducing the allocations of the Utf8JsonWriter (aka pooling them) it greatly reduces the working set of the application, from over 400 MB to ~60 MB for a test run doing millions of RPS.

Do we know how much of that would also be achieved by changing GC configuration to make it more aggressive rather than being ok expanding to the memory available?

Copy link
Member

Choose a reason for hiding this comment

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

@BrennanConroy could we do a run of the JSON middleware benchmark with Workstation GC to compare?

Copy link
Member Author

Choose a reason for hiding this comment

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

Could you share the benchmark code? If run in .NET 7, I would expect any perf issues related to Utf8JsonWriter allocations to have dissipated because of #73338.

Copy link
Member

Choose a reason for hiding this comment

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

https://github.com/aspnet/Benchmarks/blob/main/src/Benchmarks/Middleware/JsonMiddleware.cs#L56
It's using the Stream methods which weren't affected until this PR.

Copy link
Member Author

Choose a reason for hiding this comment

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

What about the variant that caches Utf8JsonWriter instances?

Copy link
Member

Choose a reason for hiding this comment

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

Still streaming API, just the synchronous one
aspnet/Benchmarks#1772

Copy link
Member Author

Choose a reason for hiding this comment

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

The Utf8JsonWriter overload is not doing streaming serialization, it will try to write everything to the destination buffer in one go. Like I said, this is not an apples-to-apples comparison, streaming mode requires more work and calls into a "slow path" for converters that do support streaming.

Using Utf8JsonWriter instead of Stream is still a valid approach, however it does trade off performance for small vs. large JSON payloads. Hopefully the changes implemented in this PR can let you have the best of both worlds by just calling into the streaming APIs (provided you're using source gen).

@eiriktsarpalis eiriktsarpalis merged commit 3559d33 into dotnet:main Dec 5, 2022
@eiriktsarpalis eiriktsarpalis deleted the root-serialization-refactoring branch December 5, 2022 16:15
vargaz added a commit to vargaz/runtime that referenced this pull request Dec 7, 2022
The recursion would happen if a gshared type would contain a recursive reference to
it. It was triggered by the JsonTypeInfo:JsonTypeInfo<Queue<T>> field added by dotnet#78646.

Fixes dotnet#79279.
vargaz added a commit that referenced this pull request Dec 7, 2022
The recursion would happen if a gshared type would contain a recursive reference to
it. It was triggered by the JsonTypeInfo:JsonTypeInfo<Queue<T>> field added by #78646.

Fixes #79279.
@dotnet dotnet locked as resolved and limited conversation to collaborators Jan 5, 2023
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

8 participants