dotnet/runtime

JsonSerializer deserialization using overloads taking Utf8JsonReader are unexpectedly slower than equivalents taking string

Open

#99.674 aperta il 13 mar 2024

Vedi su GitHub
 (5 commenti) (1 reazione) (0 assegnatari)C# (5445 fork)batch import
area-System.Text.Jsonhelp wantedtenet-performance

Metriche repository

Star
 (17.886 star)
Metriche merge PR
 (Merge medio 12g 11h) (661 PR mergiate in 30 g)

Descrizione

Description

Whilst testing JSON deserialization performance of moderately-sized (10-15KiB) UTF-8 data with the latest System.Text.Json NuGet package (8.0.3), I found that the JsonSerializer.Deserialize(ref Utf8JsonReader, ...) overloads seem to be unexpectedly much slower than those taking the data as string. The difference is such that it can be faster to convert the underlying UTF-8 bytes to a string first and then use a JsonSerializer.Deserialize(string, ...) overload instead.

The problem seems most pronounced when the JSON data contains string values with many escape sequences (escaped double-quotes in my testing, but others may also exhibit the issue).

Configuration

BenchmarkDotNet v0.13.12, Windows 10 (10.0.19044.4046/21H2/November2021Update)
13th Gen Intel Core i9-13900H, 1 CPU, 20 logical and 14 physical cores
.NET SDK 8.0.100
  [Host]     : .NET 8.0.0 (8.0.23.53103), X64 RyuJIT AVX2
  DefaultJob : .NET 8.0.0 (8.0.23.53103), X64 RyuJIT AVX2

Regression?

I don't believe so.

Data

The following code was used to perform the benchmark. I have omitted the JSON data itself from the snippet below due to its size, but can provide it separately.

[MemoryDiagnoser]
public partial class JsonDeserializeTests
{
    private static readonly JsonTypeInfo<DataRecord> DataRecordTypeInfo = DataRecordContext.Default.DataRecord;
    private static readonly string JsonString = """...[omitted for brevity]...""";
    private static readonly ReadOnlyMemory<byte> JsonUtf8 = Encoding.UTF8.GetBytes(JsonString).ToArray();

    [Benchmark(Baseline = true)]
    public DataRecord? DeserializeSpan()
    {
        return JsonSerializer.Deserialize(JsonUtf8.Span, DataRecordTypeInfo);
    }

    [Benchmark]
    public DataRecord? DeserializeStringFromUtf8()
    {
        return JsonSerializer.Deserialize(Encoding.UTF8.GetString(JsonUtf8.Span), DataRecordTypeInfo);
    }

    [Benchmark]
    public DataRecord? DeserializeJsonReader()
    {
        var reader = new Utf8JsonReader(JsonUtf8.Span);
        return JsonSerializer.Deserialize(ref reader, DataRecordTypeInfo);
    }
}

[JsonSerializable(typeof(DataRecord))]
public partial class DataRecordContext : JsonSerializerContext
{
}

public class DataRecord(string? u825587097135, string u30475139121, string c876626645106780540461092564)
{
    public string? U825587097135 { get; } = u825587097135;
    public string? U30475139121 { get; } = u30475139121;
    public string? C876626645106780540461092564 { get; } = c876626645106780540461092564;
}
Method Mean Error StdDev Ratio RatioSD Gen0 Allocated Alloc Ratio
DeserializeSpan 16.37 μs 0.155 μs 0.137 μs 1.00 0.00 0.0305 664 B 1.00
DeserializeStringFromUtf8 18.30 μs 0.152 μs 0.135 μs 1.12 0.01 2.0447 25712 B 38.72
DeserializeJsonReader 32.05 μs 0.384 μs 0.359 μs 1.96 0.03 - 664 B 1.00

Analysis

Cursory profiling suggests that the overhead of the Deserialize(ref Utf8JsonReader, ...) overloads stems from the calls to GetReaderScopedToNextValue(), which performs a reader.TrySkip() that would seem to result in essentially parsing the JSON data twice - first while skipping the current array/object in GetReaderScopedToNextValue, then again when actually deserializing the data. This perhaps explains why it's approximately twice as slow as the baseline in the above results.

Unfortunately all Deserialize(ref Utf8JsonReader, ...) overloads appear to call GetReaderScopedToNextValue, so this overhead cannot be avoided, even if the caller knows that the reader is already appropriately "scoped".

Guida contributor