-
Notifications
You must be signed in to change notification settings - Fork 4.8k
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
System.Text.Json: .NET 5.0 perf regression serializing Nullable<T> #46788
Conversation
@@ -70,5 +70,7 @@ internal override void WriteNumberWithCustomHandling(Utf8JsonWriter writer, T? v | |||
_converter.WriteNumberWithCustomHandling(writer, value.Value, handling); | |||
} | |||
} | |||
|
|||
internal override bool IsNull(T? value) => !value.HasValue; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Having to do this seems a bit strange. I'll do some local prototyping to see why is going on here -- boxing issues with Nullable<T>
or otherwise. If there's an issue with Nullable<T>
then we should try to fix it in the runtime.
It appears the culprit is the When using The fastest approach for However it would be good to check if a custom value type (without readonly modifier) would be slow here; perhaps a call to Simple repro (click to expand) class Program
{
const long Iterations = 100_000_000;
public static void Main()
{
var converter = new NullableConverter<int>();
// warm-up (ignore results here)
EqualsOperator(converter);
EqualsOperator(converter);
EqualsOperator_In(converter);
IsOperator(converter);
IsOperator_In(converter);
EqualsMethod(converter);
EqualsMethod_In(converter);
VirtualMethod(converter);
VirtualMethod_In(converter);
}
public static void EqualsOperator(JsonConverter<Nullable<int>> converter)
{
int? value = 1;
var sw = new Stopwatch();
sw.Start();
for (long i = 0; i < Iterations; i++)
{
converter.EqualsOperator(value);
}
sw.Stop();
Console.WriteLine($"EqualsOperator {sw.ElapsedMilliseconds}");
}
public static void EqualsOperator_In(JsonConverter<Nullable<int>> converter)
{
int? value = 1;
var sw = new Stopwatch();
sw.Start();
for (long i = 0; i < Iterations; i++)
{
converter.EqualsOperator_In(value);
}
sw.Stop();
Console.WriteLine($"EqualsOperator_In {sw.ElapsedMilliseconds}");
}
public static void IsOperator(JsonConverter<Nullable<int>> converter)
{
int? value = 1;
var sw = new Stopwatch();
sw.Start();
for (long i = 0; i < Iterations; i++)
{
converter.IsOperator(value);
}
sw.Stop();
Console.WriteLine($"IsOperator {sw.ElapsedMilliseconds}");
}
public static void IsOperator_In(JsonConverter<Nullable<int>> converter)
{
int? value = 1;
var sw = new Stopwatch();
sw.Start();
for (long i = 0; i < Iterations; i++)
{
converter.IsOperator_In(value);
}
sw.Stop();
Console.WriteLine($"IsOperator_In {sw.ElapsedMilliseconds}");
}
public static void EqualsMethod(JsonConverter<Nullable<int>> converter)
{
int? value = 1;
var sw = new Stopwatch();
sw.Start();
for (long i = 0; i < Iterations; i++)
{
converter.EqualsMethod(value);
}
sw.Stop();
Console.WriteLine($"EqualsMethod {sw.ElapsedMilliseconds}");
}
public static void EqualsMethod_In(JsonConverter<Nullable<int>> converter)
{
int? value = 1;
var sw = new Stopwatch();
sw.Start();
for (long i = 0; i < Iterations; i++)
{
converter.EqualsMethod_In(value);
}
sw.Stop();
Console.WriteLine($"EqualsMethod_In {sw.ElapsedMilliseconds}");
}
public static void VirtualMethod(JsonConverter<Nullable<int>> converter)
{
int? value = 1;
var sw = new Stopwatch();
sw.Start();
for (long i = 0; i < Iterations; i++)
{
converter.VirtualMethod(value);
}
sw.Stop();
Console.WriteLine($"VirtualMethod {sw.ElapsedMilliseconds}");
}
public static void VirtualMethod_In(JsonConverter<Nullable<int>> converter)
{
int? value = 1;
var sw = new Stopwatch();
sw.Start();
for (long i = 0; i < Iterations; i++)
{
converter.VirtualMethod_In(value);
}
sw.Stop();
Console.WriteLine($"VirtualMethod_In {sw.ElapsedMilliseconds}");
}
public abstract class JsonConverter<T>
{
public void EqualsOperator(T value)
{
if (value == null)
{
throw new Exception("shouldn't get here");
}
}
public void EqualsOperator_In(in T value)
{
if (value == null)
{
throw new Exception("shouldn't get here");
}
}
public void IsOperator(T value)
{
if (value is null)
{
throw new Exception("shouldn't get here");
}
}
public void IsOperator_In(in T value)
{
if (value is null)
{
throw new Exception("shouldn't get here");
}
}
public void EqualsMethod(T value)
{
if (value.Equals(null))
{
throw new Exception("shouldn't get here");
}
}
public void EqualsMethod_In(in T value)
{
if (value.Equals(null))
{
throw new Exception("shouldn't get here");
}
}
public void VirtualMethod(T value)
{
if (IsNull(value))
{
throw new Exception("shouldn't get here");
}
}
public void VirtualMethod_In(in T value)
{
if (IsNull(value))
{
throw new Exception("shouldn't get here");
}
}
public virtual bool IsNull(T value) => throw new Exception("shouldn't be called");
}
public sealed class NullableConverter<T> : JsonConverter<T?> where T : struct
{
public override bool IsNull(T? value) => !value.HasValue;
}
} with results:
|
@steveharter I did some benchmarking and updated based on the results. Added the Benchmark code [MemoryDiagnoser]
public class JsonBenchmarks
{
private readonly NullableConverter<int> _NullableConverter = new NullableConverter<int>();
private readonly CustomValueTypeConverter _CustomValueTypeConverter = new CustomValueTypeConverter();
[Benchmark]
public void Nullable_IsNullAsWritten()
{
int? value = 1;
if (_NullableConverter.IsNull(value))
{
throw new InvalidOperationException();
}
}
[Benchmark]
public void Nullable_IsNullWithIn()
{
int? value = 1;
if (_NullableConverter.IsNullWithIn(value))
{
throw new InvalidOperationException();
}
}
[Benchmark]
public void Nullable_IsNullWithInAndCanBeNull()
{
int? value = 1;
if (_NullableConverter.CanBeNull && _NullableConverter.IsNullWithIn(value))
{
throw new InvalidOperationException();
}
}
[Benchmark]
public void CustomValueType_IsNullAsWritten()
{
CustomValueType value = default;
if (_CustomValueTypeConverter.IsNull(value))
{
throw new InvalidOperationException();
}
}
[Benchmark]
public void CustomValueType_IsNullWithIn()
{
CustomValueType value = default;
if (_CustomValueTypeConverter.IsNullWithIn(value))
{
throw new InvalidOperationException();
}
}
[Benchmark]
public void CustomValueType_IsNullWithInAndCanBeNull()
{
CustomValueType value = default;
if (_CustomValueTypeConverter.CanBeNull && _CustomValueTypeConverter.IsNullWithIn(value))
{
throw new InvalidOperationException();
}
}
public struct CustomValueType
{
public int Integer32 { get; set; }
public long Integer64 { get; set; }
public float Float { get; set; }
public Decimal Decimal { get; set; }
public string String { get; set; }
}
public class NullableConverter<T> : JsonConverter<T?>
where T : struct
{
public override bool IsNull(T? value)
=> !value.HasValue;
public override bool IsNullWithIn(in T? value)
=> !value.HasValue;
public NullableConverter()
: base(canBeNull: true)
{
}
}
public class CustomValueTypeConverter : JsonConverter<CustomValueType>
{
public CustomValueTypeConverter()
: base(canBeNull: false)
{
}
}
public abstract class JsonConverter<T>
{
public bool CanBeNull { get; }
protected JsonConverter(bool canBeNull)
{
CanBeNull = canBeNull;
}
public virtual bool IsNull(T value)
=> value == null;
public virtual bool IsNullWithIn(in T value)
=> value == null;
}
}
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks!
Note that I re-tested these with the latest runtime and the numbers look much different:
Basically, the "is" operator is now fast. It still emits a "box" opcode, but during JIT that goes away. I may re-address this with other perf work in #56993. |
I stumbled on the same perf issue when working on the polymorphism prototype. Note that the boxing behavior does not manifest in microbenchmarks, I could only reproduce in the context of the large |
FWIW this particular behavior did reproduce for me long after #50997 got merged. |
After running the STJ benchmarks, I came across this as well. With a So I will keep the existing |
I ran into these perf testing some other stuff.
Nullable Property
That code running on .NET 5 allocates a whole bunch of DateTimes:
Same code running on .NET 3.1 doesn't allocate any.
I tracked it down to this:
runtime/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonConverterOfT.cs
Line 345 in 9c12a1f
That null check boxes the DateTime. Interestingly it works fine for
DateTime
but notNullable<DateTime>
. A quirk of the cast operation onNullable<T>
maybe? This PR has a fix (virtual bool IsNull
), but there might be a better way to do it.Benchmarks showing the regression:
Benchmarks with fix:
Nullable Root
This code allocates on both .NET 3.1 & .NET 5.
I tracked it down to this:
runtime/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonSerializer.Write.Helpers.cs
Line 24 in 9c12a1f
Same idea here, null check is causing a box op. Easy enough to fix just reversing the clauses, included on this PR.
Benchmarks of existing perf:
Benchmarks with tweak:
Micro Benchmarks
If this PR merges I will open a PR with these new cases in perf repo:
That is what I used for the above.