Repository navigation
Developers can use CollectionsMarshal ref accessors for Dictionary<TKey, TValue> #27062
Description
Edited by @layomia. Original proposal by @benaadams (click to view)
They aren't "safe" operations as the backing store can change if items are added, removed, etc and the changes aren't trackednamespace System.Runtime.InteropServices
{
public partial static class CollectionsMarshal
{
public ref TValue ValueRef<TKey, TValue>(Dictionary<TKey, TValue> dict, TKey key);
}
}However it is useful to get a reference to modify struct TValue entries without a full copy and replace update.
/cc @jkotas
/cc @stephentoub is it valid for ConcurrentDictionary?
CollectionsMarshal ref accessors for Dictionary<TKey, TValue>
Attempting to obtain a value in a dictionary, and adding it if not present, is a common scenario in .NET applications. The existing mechanism for achieving this today is by using Dictionary<TKey, TValue>.TryGetValue(Tkey key, out TValue value), and adding the value to the dictionary if not present. This causes duplicate hash lookups:
if (!dictionary.TryGetValue(key, out MyType myValue)) // hash lookup
{
myValue = CreateValue();
dictionary.Add(key, myValue); // duplicate hash lookup
}Another scenario is updating struct values in dictionaries. The existing pattern for achieving this causes struct copies and duplicate hash lookups, which potentially have non-trivial performance costs for large structs:
struct LargeStruct
{
// Other members...
public int MyInt { get; set; }
// Other members...
}
LargeStruct myValue = dictionary[key]; // hash lookup, struct copy
myValue.MyInt++;
dictionary[key] = myValue; // another hash lookup, another struct copyMotivation
- Provide a mechanism which avoids duplicate lookups when obtaining dictionary values which may not be present.
- Provide a mechanism which avoids copies and duplicate lookups when mutating
structdictionary values.
API proposal
namespace System.Runtime.InteropServices
{
public static class CollectionsMarshal
{
/// <summary>
/// Gets a reference to the value associated with the specified key, or throws a KeyNotFoundException
/// </summary>
public static ref TValue GetValueRef<TKey, TValue>(Dictionary<TKey, TValue> dictionary, [NotNull] TKey key);
/// <summary>
/// Gets a reference to the value associated with the specified key, or returns Unsafe.NullRef<TValue>
/// </summary>
public static ref TValue TryGetValueRef<TKey, TValue>(Dictionary<TKey, TValue> dictionary, [NotNull] TKey key, out bool exists);
/// <summary>
/// Gets a reference to the value associated with the specified key, or inserts it with value default(TValue).
/// </summary>
public static ref TValue GetValueRefOrAddDefault<TKey, TValue>(Dictionary<TKey, TValue> dictionary, [NotNull] TKey key, out bool exists);
}
}CollectionsMarshal is an unsafe class that provides a set of methods to access the underlying data representations of collections.
API usages
Updating struct value in dictionary: KeyNotFoundException thrown when key not present in dictionary
This pattern is helpful when caller wants to optimally update a struct in a scenario where the key being absent is an error state. Creating a value and adding it to the dictionary, if not already present, is not desired.
try
{
ref MyType value = CollectionsMarshal.GetValueRef(dictionary, key);
value.MyInt++;
}
catch (KeyNotFoundException exception)
{
// Handle exception
}Unsafe.NullRef<TValue>() returned when key not present in dictionary
This pattern satisfies both the optimal struct value update and optimal "get or add" value scenarios.
ref MyType value = CollectionsMarshal.TryGetValueRef(dictionary, key, out bool exists);
if (exists)
{
value.MyInt++;
}
else
{
ref value = new MyType() { MyInt = 1 };
}default(TValue) returned when key not present in dictionary
This pattern also satisfies both the optimal struct value update and optimal "get or add" value scenarios. A struct default value always being instantiated may cause the TryGetValueRef to be preferred, depending on the perf scenario.
ref MyType value = CollectionsMarshal.GetValueRefOrDefault(dictionary, key, out bool exists);
value.MyInt++;
if (exists)
{
// Do something if I care that the key already existed.
}Alternative design
GetOrAdd methods, similar to those on ConcurrentDictionary<TKey, TValue>, were proposed in #15059.
public class Dictionary<TKey, TValue>
{
public TValue GetOrAdd([NotNull] TKey key, Func<TKey, TValue> valueFactory);
}
/* OR */
public class Dictionary<TKey, TValue>
{
public TValue GetOrAdd<TState>([NotNull] TKey key, TState state, Func<TKey, TState, TValue> valueFactory);
}Upsides
- More discoverable/friendly than the relatively advanced APIs in this proposal.
- Follows precendent in the BCL i.e.
ConcurrentDictionary<TKey, TValue>.
Downsides
- Not pay-for-play. Since the new methods would live in
System.Private.CoreLib, generic expansion issues due tostruct-based generics will affect all users ofDictionary<TKey, TValue>, not just those that use them.- An argument against this point is that the methods can live in an extensions class, but that won't provide any perf benefits since they won't be able to access non-
publicmembers.
- An argument against this point is that the methods can live in an extensions class, but that won't provide any perf benefits since they won't be able to access non-
- Doesn't address the issue of copying when mutating large
structvalues.
Open questions
Should the out bool exists parameter on TryGetValueRef be removed?
Since a call to Unsafe.IsNullRef<T>(ref value) can indicate whether the value exists in the dictionary, the second method could simply be:
public static class CollectionsMarshal
{
/// <summary>
/// Gets a reference to the value associated with the specified key, or returns Unsafe.NullRef<TValue>
/// </summary>
public static ref TValue TryGetValueRef<TKey, TValue>(Dictionary<TKey, TValue> dictionary, [NotNull] TKey key);
}Usage
ref MyType value = CollectionsMarshal.TryGetValueRef(dictionary, key);
if (!Unsafe.IsNullRef(ref value))
{
value.MyInt++;
}
else
{
ref value = new MyType() { MyInt = 1 };
}Any concerns about API bloat in the CollectionsMarshal type?
The generic expansion highlighted in the GetOrAdd-based alternative doesn't apply much here since the new methods will live in CollectionsMarshal and will be pay-for-play. However, are there concerns about bloating the type?
One answer here states as follows:
I'd think to the same extent as any other/similar type such as MemoryMarshal, Unsafe, etc.
As it is today, we have 1 method in it and this proposal is adding 2-3 more. We only have a limited number of collection types in S.P.Corelib and a more limited set where returning a ref makes sense, so I don't think we are at risk of overloading it.
Activity
[-]Add MemoryMarshal ref accessors for Dict/List[/-][+][Api Proposal] Add MemoryMarshal ref accessors for Dict/List[/+]jkotas commentedon Aug 4, 2018
Same comment https://github.com/dotnet/corefx/issues/31597#issuecomment-410452767 applies here.
[-][Api Proposal] Add MemoryMarshal ref accessors for Dict/List[/-][+][Api Proposal] Add CollectionsMarshal ref accessors for Dict/List[/+]benaadams commentedon Aug 4, 2018
Updated
vcsjones commentedon Aug 7, 2018
@benaadams
Did you mean to give the dictionary a parameter name and have two different parameters? Like
safern commentedon Mar 12, 2019
@benaadams does it make sense to create 1 issue for: #27061 and this guy, to propose adding
CollectionsMarshalclass and having some rationale and usage, to bring it over to API review?Marking as 3.0 for now to not loose attention and then we can decide if we bring them individually or together as 1 issue to API Review.
danmoseley commentedon May 30, 2019
Not required for 3.0 release
terrajobst commentedon Aug 27, 2019
@benaadams same question as on the other issue for
CollectionMarshal: what are compelling scenarios for these APIs?benaadams commentedon Aug 27, 2019
1 hash lookup, no copies to update a struct
vs
2 hash lookup, 2 copies to update a struct
terrajobst commentedon Aug 28, 2019
So if we were to expose an API that would allow you to cache the hash key, would this address your need? IOW, between struct copies and hash lookups, what hurts more?
benaadams commentedon Aug 29, 2019
The hashing isn't particularly expensive (e.g. if its an
intkey there is no cost); its then taking the mod of that and traversing the arrays while doing the lookup (i.e. the search).The struct copies are expensive as if you want to modify one property of the struct its disproportionately expensive.
I do accept its inherently unsafe if you mix it with other operations on the list or dictionary; and then try to keep using the
ref, so it would be nice to add something like a[CallerMustBeUnsafe]attribute dotnet/roslyn#8663 so it can only be done in anunsafeblockterrajobst commentedon Aug 30, 2019
That makes sense.
Other people might feel differently but the whole point is that this should be a good enough speed bump. Putting this on a type in
System.Runtime.InteropServicesthat isn't an extension method is sufficient to me. We could prefix the APIs withUnsafetoo, but I'm not sure we're helping much with this.The larger question is: where do we draw the line with escape hatches like this? In a sense, we're chasing the 1% cases here, but I'm a bit scared that we're creating a mess the more APIs like this we're exposing throughout the BCL. And I don't have a good handle on this where we hold the bar.
Do you have any real workload where this would show up? Of course, one can always fabricate a micro benchmark; I don't doubt there are cases where this API would speed things up. The point is: how much does this matter in real code?
benaadams commentedon Aug 31, 2019
Looking at these apis more holistically:
For the dictionary you can achieve a similar; but safer, effect by wrapping the struct in a class then using the class handle. Its safer because the reference will remain valid across dictionary operations; and then a "live" reference can be more easily passed around and stored.
For the list
List<T> AsSpanhttps://github.com/dotnet/corefx/issues/31597 would enable the same ability (working from the returnedSpan<T>which returnsref); but also be a more useful api.For the dictionary using a class indirection doesn't cause too much issue because the data layout isn't important to the calling code (as how its internally stored in dictionary can't be consumed).
For list; that its a straight in-order array is useful, as then a consumer if they had access to it, can do things like vectorize the processing of the data, which a class indirection would break.
So in preference I'd drop these apis and add the
List<T> AsSpanhttps://github.com/dotnet/corefx/issues/31597 one as it enables the same scenario as this api, plus other opportunities like vectoization (which this doesn't).e.g. using the other api to achieve the same
vs
tannergooding commentedon Jan 16, 2020
@benaadams, could you update this proposal to just be for
Dictionary?Seeing as we already reviewed/approved the
List<T>changes here: https://github.com/dotnet/corefx/issues/31597 (and implemented https://source.dot.net/#System.Private.CoreLib/CollectionsMarshal.cs,18)[-][Api Proposal] Add CollectionsMarshal ref accessors for Dict/List[/-][+][Api Proposal] Add CollectionsMarshal ref accessors for Dict[/+]60 remaining items
Sergio0694 commentedon Jun 23, 2021
Hey @benaadams, are you still currently working on this? I saw you've contributed #49388 which has already been merged, was wondering whether you also planned to do the other two or wanted some help in case you were busy? I have some spare time on my end so I'd be happy to help contribute with at least an implementation of
GetValueRefOrAddDefault, which I think is the most important one among the remaining two (also to finally get parity on that front withDictionarySlim<TKey, TValue>.GetOrAddValueRef). We still have some time before the deadline on July 13th, so let me know! 😄benaadams commentedon Jun 23, 2021
@Sergio0694 feel free; don't really have the time to devote to doing the benchmarking to ensure its a good change
What's this deadline?
Sergio0694 commentedon Jun 23, 2021
Alright, no worries! I'll give that one a shot myself then 😄
That's the deadline for all feature work for .NET 6, at which point they'll take a snapshot of
mainfor the final release, and then just cherry pick bug fixes and whatnot after that point, but no new features. It's all documented in the tracking issue: #54569.danmoseley commentedon Jun 23, 2021
To clarify, July 13 is our feature complete date, when we switch over entirely to bugs, Aug 17 (when we aim to get to zero bugs for 6.0) is when we branch 6.0 from main.
As last year however, we would like to continue to merge community PRs after July 13, unless they'll need more stabilizing time in which case we might pause merging until after the branching.
eiriktsarpalis commentedon Jul 15, 2021
We should definitely add a paragraph for this feature in the preview 7 article.
jkotas commentedon Jul 15, 2021
This is niche unsafe API that 99% of .NET developers should not ever use. We do not want to encourage people to use it just because of they can.