Sticking with Spans for JsonConverter<bool>

Using Span<T> in custom JsonConverter

Sticking with Spans for JsonConverter<bool>

We have a lot of old APIs that rely on JSON.NET for dealing with payloads from even older systems. Most of these systems are not .NET based and so often don't comply with the type system. One of the devs has recently been trying to upgrade an API from framework to use the latest .Net 6 features including System.Text.Json, but they needed a custom converter to deal with the incoming payload using a variety of values to represent a bool.

After following the docs on Custom Converters the dev created the following:

public class BooleanConverter : JsonConverter<bool>
{
    public override bool Read(
            ref Utf8JsonReader reader,
            Type typeToConvert,
            JsonSerializerOptions options) => reader.TokenType switch
            {
                JsonTokenType.Null => false,
                JsonTokenType.True => true,
                JsonTokenType.False => false,
                _ => !(string.IsNullOrWhiteSpace(reader.GetString()) || reader.GetString() == "0" || reader.GetString()?.ToUpper() == "N")
            };

    public override void Write(Utf8JsonWriter writer, bool value, JsonSerializerOptions options)
    {
        throw new NotImplementedException();
    }
}

While this approach worked, the calls to GetString() immediately annoyed me. One of the main drivers of adopting System.Text.Json is the reduction in allocations, and given this particular API will be accepting hundreds of thousands of payloads a day, allocations matter!

Whats the Base?

With the code the dev had created and tested, I took a baseline with BenchmarkDotNet using the upper bounds of what it will likely process in a day of 1 million payloads.

Method Mean Error StdDev Ratio RatioSD Gen 0 Allocated
ConvertWithToStringsAndToUpper 504.5 ms 7.77 ms 6.49 ms 1.00 0.00 28000.0000 214 MB

ToUpper is bad

I knew I could easily improve the situation by simply replacing the usage of ToUpper with Equals and StringComparison.OrdinalIgnoreCase, which sure enough improved the situation.

Method Mean Error StdDev Ratio RatioSD Gen 0 Allocated
ConvertWithToStringsAndToUpper 504.5 ms 7.77 ms 6.49 ms 1.00 0.00 28000.0000 214 MB
ConvertWithToStringsAndEquals 468.9 ms 4.98 ms 3.89 ms 0.93 0.02 24000.0000 183 MB

Not bad for replacing a single method call, but I'm sure it could be better.

Is Span Better?

So the question is, can I make this better with Span<>?

We have a few known constraints upfront:

  • We know the systems these payloads are coming from are all our systems, and the list of things that are considered to be valid true values.
  • All of these systems are using UTF8 in english.

With that in mind, I came up with the following:

public class BoolConverter : JsonConverter<bool>
{
    private static readonly UTF8Encoding encoder = new(false);
    public override bool Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
    {
        // if we already have a bool that is true, return immediately
        if (reader.TokenType == JsonTokenType.True)
        {
            return true;
        }
        
        var result = false;

        // we don't have a bool, so lets use spans to check the value
        var value = reader.ValueSpan;
        // create a new Span<char> on the stack with the same length as the ReadOnlySpan<byte>
        Span<char> chars = stackalloc char[value.Length];
        // convert the ReadOnlySpan<byte> to a Span<char> 
        encoder.GetChars(value, chars);
        // compare to the values we accept as true
        if (
            MemoryExtensions.CompareTo(chars, "yes", StringComparison.OrdinalIgnoreCase) == 0 ||
            MemoryExtensions.CompareTo(chars, "y", StringComparison.OrdinalIgnoreCase) == 0 ||
            MemoryExtensions.CompareTo(chars, "1", StringComparison.OrdinalIgnoreCase) == 0
            )
        {
            result = true;
        }

        return result;
    }

    public override void Write(Utf8JsonWriter writer, bool value, JsonSerializerOptions options)
    {
        throw new NotImplementedException();
    }
}

Using this new converter, we get the following improvments:

Method Mean Error StdDev Ratio RatioSD Gen 0 Allocated
ConvertWithToStringsAndToUpper 504.5 ms 7.77 ms 6.49 ms 1.00 0.00 28000.0000 214 MB
ConvertWithToStringsAndEquals 468.9 ms 4.98 ms 3.89 ms 0.93 0.02 24000.0000 183 MB
ConvertWithSpan 420.5 ms 3.29 ms 2.92 ms 0.83 0.01 12000.0000 92 MB

An 80ms reduction in processing time which is nothing to celebrate, but the memory reduction is over 100MB! which magnified across all the APIs that could be updated with System.Text.Json will result in a significant memory usage improvement across our fleet.

Is that as good as it gets?

Almost certainly not, this is just what I came up with this afternoon. If you know of an even more performant way to do this, I would love to hear about it.

Update Question - is it really worth switching from Newtonsoft?

I've had a few messages asking how much better System.Text.Json is, and if its really worth the effort when Json.Net works fine. So to answer the question, I've re-run the benchmark and included the original converter for Json.Net

So yes, clearly, even with the initial implmentation of the System.Text.Json converter, it is twice as fast and uses over 90% less memory!