A (performance) quirk with JsonSerializer

2/15/2023
4 minute read

System.Text.Json.JsonSerializer has a weird quirk in regard to performance and memory management. So we will discuss what is "wrong" with this code: JsonSerializer.Serialize(myObject, new JsonSerializerOptions(...));.

JsonSerializerOptions

The JsonSerializerOptions is used to customize how the JsonSerializer serializes (your C# object to a string for example) and deserializes objects (string back to your C# object). It basically consists of a few properties that describe, for example, whether or not you want to have indentation (so whitespaces to make it easier to read for humans, but takes more space) and so on. Now what does it have to do with the code snippet above? Let's have a small benchmark:

Benchmark

[MemoryDiagnoser]
public class JsonBenchmark
{
    private const int Iterations = 100;

    private static readonly JsonSerializerOptions _options = new();

    [Benchmark(Baseline = true)]
    public void SerializeDefault()
    {
        for (var i = 0; i < Iterations; i++)
            JsonSerializer.Serialize(new Test("Steven", "Giesel"));
    }

    [Benchmark]
    public void SerializeOptionsField()
    {
        for (var i = 0; i < Iterations; i++)
            JsonSerializer.Serialize(new Test("Steven", "Giesel"), _options);
    }

    [Benchmark]
    public void SerializeOptionsNew()
    {
        for (var i = 0; i < Iterations; i++)
            JsonSerializer.Serialize(new Test("Steven", "Giesel"), new JsonSerializerOptions());
    }
}

public sealed record Test(string FirstName, string LastName);

Results:

|                Method |     Mean |    Error |   StdDev | Ratio | RatioSD |   Gen0 |   Gen1 |   Gen2 | Allocated | Alloc Ratio |
|---------------------- |---------:|---------:|---------:|------:|--------:|-------:|-------:|-------:|----------:|------------:|
|      SerializeDefault | 13.89 us | 0.083 us | 0.074 us |  1.00 |    0.00 | 2.2888 |      - |      - |  14.06 KB |        1.00 |
| SerializeOptionsField | 13.89 us | 0.032 us | 0.030 us |  1.00 |    0.01 | 2.2888 |      - |      - |  14.06 KB |        1.00 |
|   SerializeOptionsNew | 53.36 us | 0.781 us | 0.692 us |  3.84 |    0.05 | 6.7139 | 0.2441 | 0.1221 |  42.79 KB |        3.04 |

We can see that if you new up an instance in a loop, it can get costly. And you might think: Of course - the new() itself takes time and memory. Yes and no. Yes, that is true, but the no not that huge amount we can see here. Keep in mind I only used 100 iterations. No there is a complete different reason:

Caching

The way Serialize and Deserialize works are as follows:

public static string Serialize<TValue>(TValue value, JsonSerializerOptions? options = null)
{
    JsonTypeInfo<TValue> jsonTypeInfo = GetTypeInfo<TValue>(options);
    return WriteString(value, jsonTypeInfo);
}

If we dig deeper:

private static JsonTypeInfo GetTypeInfo(JsonSerializerOptions? options, Type inputType)
{
    Debug.Assert(inputType != null);

    options ??= JsonSerializerOptions.Default;

    if (!options.IsInitializedForReflectionSerializer)
    {
        options.InitializeForReflectionSerializer();
    }

If options is not set (like in our baseline case) it uses a default instance. If we pass in our static field it behaves roughly the same. So basically options.IsInitializedForReflectionSerializer this will evaluate to true in those two cases and we don't have to go into InitializeForReflectionSerializer a second time. Only in our first call.

And here is the difference to the last case (new()), because here we will call the InitializeForReflectionSerializer every single time. This function is quite expensive as it builds up the necessary information on how your object looks like via reflection. That is where the cost comes from.

Should I care?

You can basically ignore all this if you don't pass in your own JsonSerializerOptions. Also if you call that method only once or a few times you can ignore it to some extent. But you can use JsonSerializer.Serialize in combination with Entity Framework in combination with ValueConverter:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder
        .Entity<MyModel>()
        .Property(e => e.MyList)
        .HasConversion(
            v => JsonSerializer.Serialize(v, new JsonSerializerOptions(...))
            v => JsonSerializer.Deserialize<List<MyType>>(v, new JsonSerializerOptions(...)));
}

If you have thousands of rows, that can be very expensive! Better to take either the default (so pass in null) or have a static field holding that information for you.

.NET 9 LINQ Performance Edition

As with almost every edition of .NET, the team has been working on improving performance. In this blog post, we will see some improvements to the related tickets and benchmarks.

Heap, Stack, Boxing and Unboxing, Performance ... let's order things!

In this article I will shade some lights on some of the most used terms which seems very confusing especially for beginners: heap, stack and boxing and unboxing.

Furthermore we will also encounter internet wisdom like:

Value types get stored on the stack. Reference types on the heap

We discuss why this is wrong and what the hell performance has to do with it?

Memory is complicated

This is a small story about how memory operates in your .NET application. Well not only .NET but how memory does or does not get allocated.

We will see how a 1 Gigabyte big array is only a few megabytes big to some extend. Furthermore I will discuss working set and committed memory.

An error has occurred. This application may no longer respond until reloaded. Reload x