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

Avoid string allocation and improve performance of JsonProperty.WriteTo #90074

Merged
merged 13 commits into from
Sep 22, 2023

Conversation

karakasa
Copy link
Contributor

@karakasa karakasa commented Aug 6, 2023

Description

System.Text.Json.JsonProperty.WriteTo uses get_Name, then JsonElement.GetPropertyName(), a method would always allocate a string.

This PR reuses the underlying UTF8 json ROS<byte> by adding helper methods into JsonDocument & JsonElement, to avoid alloc. Allocation is further removed by inlining JsonReaderHelper.GetUnescapedSpan into JsonElement.WritePropertyNameTo, which makes the method allocation-less when property names are shorter than JsonConstants.StackallocByteThreshold, or don't need unescaping, and the underlying buffer is long enough.

Generally the change improves the performance of JsonProperty.WriteTo by ~30%.

Fix #88767

Microbenchmark

BenchmarkDotNet v0.13.7-nightly.20230717.35, Windows 10 (10.0.19045.2546/22H2/2022Update)
Intel Core i7-8700K CPU 3.70GHz (Coffee Lake), 1 CPU, 12 logical and 6 physical cores
.NET SDK 8.0.100-preview.6.23330.14
  [Host]     : .NET 8.0.0 (8.0.23.32907), X64 RyuJIT AVX2
  Job-TGMBKK : .NET 8.0.0 (42.42.42.42424), X64 RyuJIT AVX2

PowerPlanMode=00000000-0000-0000-0000-000000000000  Arguments=/p:EnableUnsafeBinaryFormatterSerialization=true  Toolchain=CoreRun  
IterationTime=250.0000 ms  MaxIterationCount=20  MinIterationCount=15  
WarmupCount=1  
Type TestCase Mean Error StdDev Median Min Max Gen0 Allocated
Original Simple 362.4 ns 4.14 ns 3.45 ns 361.4 ns 358.5 ns 370.3 ns 0.0305 200 B
Improved Simple 230.5 ns 2.31 ns 2.04 ns 231.1 ns 227.2 ns 233.8 ns -
Original WithEscapedChars 662.8 ns 2.50 ns 2.34 ns 662.4 ns 659.1 ns 666.8 ns 0.0425 272 B
Improved WithEscapedChars 458.6 ns 9.16 ns 8.57 ns 456.1 ns 450.6 ns 483.5 ns -
Original WithUnicodeChars 769.3 ns 5.15 ns 4.56 ns 768.0 ns 764.1 ns 779.1 ns 0.0462 304 B
Improved WithUnicodeChars 530.3 ns 9.35 ns 10.01 ns 526.0 ns 519.8 ns 550.5 ns -

Simple: {"normal1":0,"normal2":0,"normal3":0,"normal4":0,"normal5":0}
WithEscapedChars: {"normal1":0,"normal2":0,"normal3":0,"normal4":0,"normal5":0,"\"q\t\\m\"m\t":1,"\u6d4b\u8bd5":2}
WithUnicodeChars: {"normal1":0,"normal2":0,"normal3":0,"normal4":0,"normal5":0,"\"q\t\\m\"m\t":1,"\u6d4b\u8bd5":2,"测试":3}

Micro-benchmark code
using BenchmarkDotNet.Attributes;
using System;
using System.Linq;
using MicroBenchmarks;
using System.IO;

namespace System.Text.Json.Node.Tests
{
    [BenchmarkCategory(Categories.Libraries, Categories.JSON)]
    [MemoryDiagnoser]
    public class JsonPropertyWriteToTest
    {
        public enum TestCaseType
        {
            Simple,
            WithEscapedCharacters,
            WithUnescapedUnicodeCharacters,
        }

        [ParamsAllValues]
        public TestCaseType TestCase;


        const string JsonString1 = """
                {"normal1":0,"normal2":0,"normal3":0,"normal4":0,"normal5":0}
                """;
        const string JsonString2 = """
                {"normal1":0,"normal2":0,"normal3":0,"normal4":0,"normal5":0,"\"q\t\\m\"m\t":1,"\u6d4b\u8bd5":2}
                """;
        const string JsonString3 = """
                {"normal1":0,"normal2":0,"normal3":0,"normal4":0,"normal5":0,"\"q\t\\m\"m\t":1,"\u6d4b\u8bd5":2,"测试":3}
                """;

        private static string GetTestCase(TestCaseType type)
        {
            switch (type)
            {
                case TestCaseType.Simple:
                    return JsonString1;
                case TestCaseType.WithEscapedCharacters:
                    return JsonString2;
                case TestCaseType.WithUnescapedUnicodeCharacters:
                    return JsonString3;
            }

            return "";
        }

        const int MAX_FOLD = 10;

        private static JsonProperty[] Properties;

        private static MemoryStream Stream;
        private static Utf8JsonWriter Writer;
        [GlobalSetup]
        public void PrepareJson()
        {
            Properties = JsonDocument.Parse(Encoding.UTF8.GetBytes(GetTestCase(TestCase))).RootElement.EnumerateObject().ToArray();
            Stream = new(JsonString2.Length * MAX_FOLD);
            Writer = new Utf8JsonWriter(Stream);
        }

        [Benchmark]
        public MemoryStream WriteProperties()
        {
            Stream.Position = 0;
            Writer.Reset();

            Writer.WriteStartObject();

            foreach (var it in Properties)
                it.WriteTo(Writer);

            Writer.WriteEndObject();
            Writer.Flush();

            return Stream;
        }
    }
}

System.Text.Json.JsonProperty.WriteTo uses get_Name, calling
JsonElement.GetPropertyName() which would allocate a string.

Use ReadOnlySpan<byte> from the underlying UTF8 json, when possible,
by adding helper methods into JsonDocument & JsonElement.

Fix dotnet#88767
Current code unescapes & escapes property names and uses ToArray.
Avoid alloc by adding internal GetRaw/WriteRaw methods.
Original code doesn't handle GetRaw/WriteRaw on escaped
property names correctly.
Allocations are further avoided when the property name is shorter than
JsonConstants.StackallocByteThreshold, by inlining
JsonReaderHelper.GetUnescapedSpan.
@ghost ghost added the community-contribution Indicates that the PR has been added by a community member label Aug 6, 2023
@ghost
Copy link

ghost commented Aug 6, 2023

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

Description

System.Text.Json.JsonProperty.WriteTo uses get_Name, then JsonElement.GetPropertyName(), a method would always allocate a string.

This PR reuses the underlying UTF8 json ROS<byte>, by adding helper methods into JsonDocument & JsonElement. Allocation is further removed by inlining JsonReaderHelper.GetUnescapedSpan into JsonProperty.WriteTo, which makes the method allocation-less when property names are shorter than JsonConstants.StackallocByteThreshold.

The change also improves the performance of JsonProperty.WriteTo.

Fix #88767

Microbenchmark

BenchmarkDotNet v0.13.7-nightly.20230717.35, Windows 10 (10.0.19045.2546/22H2/2022Update)
Intel Core i7-8700K CPU 3.70GHz (Coffee Lake), 1 CPU, 12 logical and 6 physical cores
.NET SDK 8.0.100-preview.6.23330.14
  [Host]     : .NET 8.0.0 (8.0.23.32907), X64 RyuJIT AVX2
  Job-TGMBKK : .NET 8.0.0 (42.42.42.42424), X64 RyuJIT AVX2

PowerPlanMode=00000000-0000-0000-0000-000000000000  Arguments=/p:EnableUnsafeBinaryFormatterSerialization=true  Toolchain=CoreRun  
IterationTime=250.0000 ms  MaxIterationCount=20  MinIterationCount=15  
WarmupCount=1  

Original

Type TestCase Mean Error StdDev Median Min Max Gen0 Allocated
Original Simple 362.4 ns 4.14 ns 3.45 ns 361.4 ns 358.5 ns 370.3 ns 0.0305 200 B
Improved Simple 230.5 ns 2.31 ns 2.04 ns 231.1 ns 227.2 ns 233.8 ns -
Original WithEscapedChars 662.8 ns 2.50 ns 2.34 ns 662.4 ns 659.1 ns 666.8 ns 0.0425 272 B
Improved WithEscapedChars 458.6 ns 9.16 ns 8.57 ns 456.1 ns 450.6 ns 483.5 ns -
Original WithUnicodeChars 769.3 ns 5.15 ns 4.56 ns 768.0 ns 764.1 ns 779.1 ns 0.0462 304 B
Improved WithUnicodeChars 530.3 ns 9.35 ns 10.01 ns 526.0 ns 519.8 ns 550.5 ns -

Simple: {"normal1":0,"normal2":0,"normal3":0,"normal4":0,"normal5":0}
WithEscapedChars: {"normal1":0,"normal2":0,"normal3":0,"normal4":0,"normal5":0,"\"q\t\\m\"m\t":1,"\u6d4b\u8bd5":2}
WithUnicodeChars: {"normal1":0,"normal2":0,"normal3":0,"normal4":0,"normal5":0,"\"q\t\\m\"m\t":1,"\u6d4b\u8bd5":2,"测试":3}

Micro-benchmark code
using BenchmarkDotNet.Attributes;
using System;
using System.Linq;
using MicroBenchmarks;
using System.IO;

namespace System.Text.Json.Node.Tests
{
    [BenchmarkCategory(Categories.Libraries, Categories.JSON)]
    [MemoryDiagnoser]
    public class JsonPropertyWriteToTest
    {
        public enum TestCaseType
        {
            Simple,
            WithEscapedCharacters,
            WithUnescapedUnicodeCharacters,
        }

        [ParamsAllValues]
        public TestCaseType TestCase;


        const string JsonString1 = """
                {"normal1":0,"normal2":0,"normal3":0,"normal4":0,"normal5":0}
                """;
        const string JsonString2 = """
                {"normal1":0,"normal2":0,"normal3":0,"normal4":0,"normal5":0,"\"q\t\\m\"m\t":1,"\u6d4b\u8bd5":2}
                """;
        const string JsonString3 = """
                {"normal1":0,"normal2":0,"normal3":0,"normal4":0,"normal5":0,"\"q\t\\m\"m\t":1,"\u6d4b\u8bd5":2,"测试":3}
                """;

        private static string GetTestCase(TestCaseType type)
        {
            switch (type)
            {
                case TestCaseType.Simple:
                    return JsonString1;
                case TestCaseType.WithEscapedCharacters:
                    return JsonString2;
                case TestCaseType.WithUnescapedUnicodeCharacters:
                    return JsonString3;
            }

            return "";
        }

        const int MAX_FOLD = 10;

        private static JsonProperty[] Properties;

        private static MemoryStream Stream;
        private static Utf8JsonWriter Writer;
        [GlobalSetup]
        public void PrepareJson()
        {
            Properties = JsonDocument.Parse(Encoding.UTF8.GetBytes(GetTestCase(TestCase))).RootElement.EnumerateObject().ToArray();
            Stream = new(JsonString2.Length * MAX_FOLD);
            Writer = new Utf8JsonWriter(Stream);
        }

        [Benchmark]
        public MemoryStream WriteProperties()
        {
            Stream.Position = 0;
            Writer.Reset();

            Writer.WriteStartObject();

            foreach (var it in Properties)
                it.WriteTo(Writer);

            Writer.WriteEndObject();
            Writer.Flush();

            return Stream;
        }
    }
}
Author: karakasa
Assignees: -
Labels:

area-System.Text.Json

Milestone: -

@karakasa
Copy link
Contributor Author

karakasa commented Aug 6, 2023

@dotnet-policy-service agree

@karakasa karakasa marked this pull request as ready for review August 6, 2023 14:11
@eiriktsarpalis eiriktsarpalis added the tenet-performance Performance related issue label Aug 7, 2023
Shorten names of new methods;
Add a test of writing out special names.
@karakasa karakasa marked this pull request as draft August 8, 2023 08:02
@ghost ghost closed this Sep 10, 2023
@ghost
Copy link

ghost commented Sep 10, 2023

Draft Pull Request was automatically closed for 30 days of inactivity. Please let us know if you'd like to reopen it.

@eiriktsarpalis
Copy link
Member

Hi @karakasa, this was closed automatically because it was still marked as draft. Is there any pending work on this PR before it is marked for review?

@karakasa
Copy link
Contributor Author

@eiriktsarpalis

Could you please reopen the PR? I have fixed the code.

@eiriktsarpalis eiriktsarpalis marked this pull request as ready for review September 21, 2023 15:43
@eiriktsarpalis eiriktsarpalis added this to the 9.0.0 milestone Sep 21, 2023
Copy link
Member

@eiriktsarpalis eiriktsarpalis left a comment

Choose a reason for hiding this comment

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

Thank you!

@karakasa
Copy link
Contributor Author

Thank you!

You are the best! 🤩

@eiriktsarpalis
Copy link
Member

Test failures are known issues, merging away.

@eiriktsarpalis eiriktsarpalis merged commit 45fe624 into dotnet:main Sep 22, 2023
117 of 123 checks passed
@karakasa karakasa deleted the issue-88767 branch September 22, 2023 08:54
@ghost ghost locked as resolved and limited conversation to collaborators Oct 22, 2023
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
area-System.Text.Json community-contribution Indicates that the PR has been added by a community member tenet-performance Performance related issue
Projects
None yet
Development

Successfully merging this pull request may close these issues.

System.Text.Json.JsonProperty allocated a string in WriteTo()
3 participants