Skip to content
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

Add support for multiple non-string TKey on dictionaries #38056

Merged
merged 18 commits into from
Jul 7, 2020

Conversation

jozkee
Copy link
Member

@jozkee jozkee commented Jun 17, 2020

Second attempt to fix #30524 (previos attempt: #32909)

Enables support for multiple non-string Dictionary key types.

For object keys the following applies:

  • Enables support on serialization if the runtime type is one of the supported types.
  • They remain unsupported on deserialization.

The DictionaryKeyPolicy defined on JsonSerializerOptions will only apply for keys of type string.

This PR also defers unsupported key validation i.e. we will throw NSE until we use the Key Converter, meaning that empty or null dictionaries won't throw even when their key is not supported.

@jozkee

This comment has been minimized.

@layomia
Copy link
Contributor

layomia commented Jun 18, 2020

Looks like we haven't added parsing/writing logic for every type listed here: https://github.com/dotnet/runtime/pull/32676/files#diff-899ea4f41d4cbaeaa272936e99afcf9aR41-R58.

@jozkee
Copy link
Member Author

jozkee commented Jun 18, 2020

I wanted to hold-off extending ReadWithQuotes/WriteWithQuotes to the other types until we confirmed that this was the correct approach. The implementation for the types left shouldn't be more than copy/paste the code from Int32Converter.


state.Current.JsonPropertyNameAsString = reader.GetString();
ReadOnlySpan<byte> propertyName = JsonSerializer.GetPropertyName(ref state, ref reader, options);
state.Current.DictionaryKeyName = propertyName.ToArray();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So this is only used for continuation cases...

@jozkee

This comment has been minimized.

@jozkee

This comment has been minimized.

@jozkee

This comment has been minimized.

@jozkee jozkee requested a review from layomia June 30, 2020 23:24
@layomia
Copy link
Contributor

layomia commented Jul 2, 2020

EDIT: There is a small regression on Utf8JsonReader.Get* methods caused by the addition of the helper *Core methods. This can be potentially mitigated by removing the helpers and just duplicate the code.

If this is still the case, let's duplicate the code for the worst regressions i.e. >= 4%.

@jozkee
Copy link
Member Author

jozkee commented Jul 4, 2020

That's no longer the case, I ran the benchmarks again and the results with TryGet*Core are more or less the same, plus I also verified that the produced assembly was getting the TryGet*Core methods inlined on the existing ones.

summary:
worse: 10, geomean: 1.013
total diff: 10

| Slower                                            | diff/base | Base Median (ns) | Diff Median (ns) | Modality|
| ------------------------------------------------- | ---------:| ----------------:| ----------------:| --------:|
| System.Text.Json.Tests.Perf_Get.GetInt32          |      1.04 |           774.68 |           801.96 |         |
| System.Text.Json.Tests.Perf_Get.GetSingle         |      1.02 |          3961.69 |          4053.06 |         |
| System.Text.Json.Tests.Perf_Get.GetString         |      1.02 |          3118.40 |          3173.07 |         |
| System.Text.Json.Tests.Perf_Get.GetDateTime       |      1.01 |          3614.62 |          3666.47 |         |
| System.Text.Json.Tests.Perf_Get.GetUInt32         |      1.01 |           727.07 |           735.23 |         |
| System.Text.Json.Tests.Perf_Get.GetSByte          |      1.01 |          1436.06 |          1447.93 |         |
| System.Text.Json.Tests.Perf_Get.GetDateTimeOffset |      1.01 |          5451.09 |          5491.71 |         |
| System.Text.Json.Tests.Perf_Get.GetInt64          |      1.01 |           847.79 |           854.10 |         |
| System.Text.Json.Tests.Perf_Get.GetGuid           |      1.01 |          5292.47 |          5324.14 |         |
| System.Text.Json.Tests.Perf_Get.GetDecimal        |      1.00 |          4744.43 |          4759.51 |         |

No Faster results for the provided threshold = 0% and noise filter = 0.3ns.

Copy link
Contributor

@layomia layomia left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, thank you!

{
protected override void Add(in TValue value, JsonSerializerOptions options, ref ReadStack state)
protected override void Add(TKey key, in TValue value, JsonSerializerOptions options, ref ReadStack state)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any measurable perf benefit for non-string keys if we make this in TKey key?

Copy link
Member Author

@jozkee jozkee Jul 6, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think it is a good idea to use in given that none of the supported types would be a readonly struct or a large struct. Theoretically there should not be benefit from doing so, on the contrary, the perf. would probably decrease given the fact that a defensive copy needs to be created every time Add(in TKey key, ) is called.

cc @GrabYourPitchforks

Comment on lines +97 to +103
JsonConverter GetEnumConverter()
=> (JsonConverter)Activator.CreateInstance(
typeof(EnumConverter<>).MakeGenericType(keyType),
BindingFlags.Instance | BindingFlags.Public,
binder: null,
new object[] { EnumConverterOptions.AllowStrings | EnumConverterOptions.AllowNumbers, this },
culture: null)!;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this code live in the EnumConverterFactory?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think is better to have it here since it is only useful in this method.
I can add a comment explaining that this is performed to support multiple enum types as dictionary key and we want to use our own defined semantics (we don't want to call a custom converter).

@@ -433,5 +431,14 @@ internal void VerifyWrite(int originalDepth, Utf8JsonWriter writer)
/// <param name="value">The value to convert.</param>
/// <param name="options">The <see cref="JsonSerializerOptions"/> being used.</param>
public abstract void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options);

internal virtual T ReadWithQuotes(ref Utf8JsonReader reader)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe we previously discussed this, but if someone creates a custom converter such as for Int32 then they will get the default semantics when using quoted keys, not the custom semantics, correct?

If\when we want to support custom converters for quoted keys, then we can add support for exposing ReadWithQuotes publicly or find some other way to do this.

where TCollection : IDictionary
{
protected override void Add(in object? value, JsonSerializerOptions options, ref ReadStack state)
protected override void Add(string key, in object? value, JsonSerializerOptions options, ref ReadStack state)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I assume IDictionary (non-generic) derived classes can have non-string keys? Do we call ToString() on the underlying object? Do we have tests for this?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IDictionary does not support desereialization of non-string keys given that we don't have a concrete type to parse to, we only use string keys in this case.

: DictionaryDefaultConverter<TCollection, TValue>
where TCollection : IDictionary<string, TValue>
internal sealed class IDictionaryOfTKeyTValueConverter<TCollection, TKey, TValue>
: DictionaryDefaultConverter<TCollection, TKey, TValue>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice job that we can use use a single class for this.

private static ConcurrentDictionary<Type, JsonConverter> GetDictionaryKeyConverters()
{
const int NumberOfConverters = 18;
var converters = new ConcurrentDictionary<Type, JsonConverter>(Environment.ProcessorCount, NumberOfConverters);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: if we keep this cache, use the parameterless ConcurrentDictionary ctor here? See #36726 (comment) for a related discussion.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The parameterless ctor internally uses the following.

private static int DefaultConcurrencyLevel => Environment.ProcessorCount;

This would achieve the same with the bonus of allowing us define a initial capacity to avoid resizing over and over.
The difference with #36726 (comment) is that here, the capacity will only grow in the Enum case, which I don't think is very common.

@jozkee jozkee merged commit 4d579de into dotnet:master Jul 7, 2020
@jozkee jozkee deleted the tkey_support branch July 8, 2020 00:59
@ghost ghost locked as resolved and limited conversation to collaborators Dec 8, 2020
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Projects
None yet
Development

Successfully merging this pull request may close these issues.

New Asp.NET Core 3.0 Json doesn't serialize Dictionary<Key,Value>
4 participants