Cache reflection lookups in DynamicDataOperations#7535
Cache reflection lookups in DynamicDataOperations#7535Evangelink wants to merge 2 commits intomainfrom
Conversation
There was a problem hiding this comment.
Pull request overview
This PR improves DynamicData reflection performance in MSTest’s DynamicDataOperations by caching inheritance-walking member lookups (fields/properties/methods) so repeated calls don’t traverse the full type hierarchy every time.
Changes:
- Added per-member-kind
ConcurrentDictionarycaches keyed by(Type, memberName)(via a customTypeMemberKeystruct). - Refactored the existing hierarchy-walk implementations into
Lookup*InHierarchyhelpers and routedGet*ConsideringInheritancethrough the caches.
You can also share your feedback on Copilot code review. Take the survey.
Youssef1313
left a comment
There was a problem hiding this comment.
- Have we seen real perf issues here that warrants adding a cache?
- I feel like walking the inheritance hierarchy is already not the right approach. Instead, we should avoid
BindingFlags.DeclaredOnlyand let the .NET runtime do its thing. The .NET reflection implementation has some caching already, so if we are able to avoid walking the hierarchy ourselves and let the caching behavior of reflection do its job, I think that's the best.
…l hierarchy walk Replace the manual type hierarchy walk with DeclaredOnly + loop with a single reflection call using BindingFlags.FlattenHierarchy. This lets the .NET runtime handle inheritance search for static members with its own built-in caching, removing the need for custom ConcurrentDictionary caches and the TypeMemberKey struct.
|
@Youssef1313 Great suggestion — replaced the manual hierarchy walk + caching with a single reflection call using |
96f1ad1 to
28dac23
Compare
| internal static class DynamicDataOperations | ||
| { | ||
| private const BindingFlags DeclaredOnlyLookup = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static | BindingFlags.DeclaredOnly; | ||
| private const BindingFlags MemberLookup = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static | BindingFlags.FlattenHierarchy; |
There was a problem hiding this comment.
I guess the FlatternHierarchy flag shouldn't be needed.
There was a problem hiding this comment.
FlattenHierarchy is actually needed here. Without it, GetProperty/GetMethod/GetField with BindingFlags.Static will not return inherited static members — .NET's RuntimeType.FilterApplyBase explicitly filters out inherited static members unless FlattenHierarchy is set. Since dynamic data sources must be static (validated in GetDataFromProperty, GetDataFromMethod, GetDataFromField), removing this flag would break the case where a test class inherits a static data source from a base class.
One thing worth noting: there's a subtle behavior change vs. the old manual DeclaredOnly + loop approach — FlattenHierarchy does not return private static members from base classes, while the old loop did. This seems acceptable since accessing private members of a base class is an unusual pattern, but flagging it in case you think it matters.
There was a problem hiding this comment.
I think DynamicData isn't interested in static members anyways.
src/TestFramework/TestFramework/Attributes/DataSource/DynamicDataOperations.cs
Outdated
Show resolved
Hide resolved
Youssef1313
left a comment
There was a problem hiding this comment.
LGTM, with two small comments.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 1 out of 1 changed files in this pull request and generated 2 comments.
You can also share your feedback on Copilot code review. Take the survey.
| internal static class DynamicDataOperations | ||
| { | ||
| private const BindingFlags DeclaredOnlyLookup = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static | BindingFlags.DeclaredOnly; | ||
| private const BindingFlags MemberLookup = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static | BindingFlags.FlattenHierarchy; |
There was a problem hiding this comment.
This change switches from a most-derived-first, per-type lookup (via DeclaredOnly + manual BaseType walk) to a single reflection lookup using FlattenHierarchy. That is not behavior-equivalent: (1) BindingFlags.FlattenHierarchy will not return private static members declared on base types, whereas the previous implementation would find them; and (2) the single-call lookup can surface multiple matches across the inheritance chain and throw AmbiguousMatchException in cases where the previous implementation would have returned the derived member. If the intent is purely perf, consider keeping the previous resolution semantics (derived-first, include non-public base members) and introducing caching as described in the PR, rather than changing the BindingFlags behavior.
| internal static class DynamicDataOperations | ||
| { | ||
| private const BindingFlags DeclaredOnlyLookup = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static | BindingFlags.DeclaredOnly; | ||
| private const BindingFlags MemberLookup = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static | BindingFlags.FlattenHierarchy; |
There was a problem hiding this comment.
The PR title/description mention adding ConcurrentDictionary caches (and a TypeMemberKey struct for net462), but this diff instead removes the custom hierarchy-walk helpers and performs direct reflection lookups with FlattenHierarchy—no caching is introduced. Please update the PR description/title to reflect the actual approach, or include the intended caching changes.
The Get*ConsideringInheritance methods (for fields, properties, and methods) walk the full type hierarchy via reflection on every call. This adds a ConcurrentDictionary cache per member kind, keyed by a (Type, memberName) struct, so the hierarchy walk happens only once per unique pair.
Uses a custom TypeMemberKey struct instead of ValueTuple for net462 compatibility.
The gain is most of the time neglictible but for some code bases where the same datasource is reused many times (e.g. MTP and MSTest acceptance tests) there is some gain.
I think it's not hurting readability and maintainance so it's probably worth it to keep it but I am fine if we prefer to close.