Sticking with Spans for JsonConverter<bool>
Using Span<T> in custom JsonConverter
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!