Skip to content

perf: index current members when filtering hidden members - #1023

Open
vb-kalei wants to merge 2 commits into
MapsterMapper:developmentfrom
vb-kalei:perf/hidden-member-dictionary-lookup
Open

vb-kalei wants to merge 2 commits into
MapsterMapper:developmentfrom
vb-kalei:perf/hidden-member-dictionary-lookup

Conversation

@vb-kalei

@vb-kalei vb-kalei commented Sep 18, 2026

Copy link
Copy Markdown

This is an AI-assisted optimization, and actually a better version of #1021. If you like this change, the other one can be declined and closed.
The documentation below is AI generated.

Summary

DropHiddenMembers repeatedly scans current members with First for each matching source name. On development it also repeatedly enumerates the lazy name intersection. This change indexes the first current member for each name once per result enumeration and performs dictionary lookups while yielding source members in their original order.

The dictionary uses ordinal, case-sensitive names and retains first-member precedence for duplicate names. It stores MemberInfo rather than eagerly reading MetadataToken; the existing token comparison stays unchanged. This removes the redundant intersection and gives expected O(current members + source members) filtering work for stable reflection collections, excluding reflection-property costs and hash collisions. No public API, package, configuration, or attribute-caching changes are included.

Relationship to existing PRs

This supersedes the HashSet/intersection approach in the still-open #1021. It is intended as the replacement implementation for that contribution, targeting development, rather than an overlapping second PR. The separate attribute-caching work in #1022 is not required or included.

Enumeration behavior and trade-offs

Initialization remains deferred until MoveNext and the lookup is rebuilt for every enumeration. The source is visited once; partial enumeration stops without scanning its remainder. All current members are indexed up front, including names absent from the source. This can cost more for empty sources or early termination. Active mutation, stateful sequences, custom reflection getters, null members/names, and exception timing are not claimed to be identical. The internal callers supply stable reflection arrays (with indexer filtering for properties).

Regression coverage

Fourteen focused MSTest/Shouldly tests cover hiding across properties and fields, private hiding, first-name precedence, case sensitivity, ordering/duplicates, empty inputs, re-enumeration, Adapt/MapToTarget, deferred execution, enumeration counts, bounded Name access, and unmatched token access. They call the internal helper directly. On isolated upstream and HashSet snapshots, ten semantic tests pass and the four operation-count tests fail; the dictionary passes all fourteen. For 64 members the Name-access test observed 12,480 accesses upstream and 4,416 with HashSet against a linear bound of 512.

Validation on Windows

Command Result
dotnet test src/Mapster.Tests/Mapster.Tests.csproj --no-restore -c Release --filter FullyQualifiedName~WhenLookingUpHiddenMembers 14 pass on each of net10.0, net9.0, net8.0, net48
dotnet test src/Mapster.Tests/Mapster.Tests.csproj --no-build --no-restore -c Release 393 pass / 6 skip per framework
dotnet build src/Mapster/Mapster.csproj --no-restore -c Release -f netstandard2.0 Pass
dotnet build src/Mapster.sln --no-restore -c Release Pass; existing warnings retained
dotnet test src/Mapster.sln --no-build --no-restore -c Release 1,684 pass / 24 skip across 30 test runs
git diff --check Pass

Performance measurements

Standalone measurements used the actual production assemblies: development 4a8aaa415d434ff86b0182617ad2062ab1794a19, that same base with #1021's HashSet method, and the dictionary patch. Attribute caching was absent from all variants. A local diagnostic harness (outside the patch) used Release/.NET 10.0.12 on Windows x64/i9-13900K, tiered compilation disabled equally, six fresh processes per variant in all six balanced order permutations, and three timed batches after warmup per scenario. Values below are medians of process means; allocations are managed bytes on the calling thread.

Workload Upstream HashSet Dictionary Bytes/op: upstream / HashSet / dictionary
Full filtering, 4 matching members 1.71 us 0.84 us 0.39 us 2,592 / 1,448 / 608
Full filtering, 64 matching members 168.06 us 15.86 us 2.71 us 231,136 / 12,760 / 4,768
Full filtering, 256 matching members 4,391.57 us 159.28 us 11.48 us 4,165,856 / 54,984 / 22,456
Empty source, 64 current members 0.10 us 1.41 us 1.51 us 296 / 3,744 / 4,736
First result, 256 matching members 7.50 us 16.60 us 6.75 us 16,496 / 32,544 / 22,456
Fresh configuration + Compile, inherited/hidden members 41.82 ms 9.19 ms 7.96 ms 32,320,430 / 7,415,719 / 6,665,970

Limitations

These measurements demonstrate an empty-source regression and increased early-stop allocations relative to upstream. Early-stop 256 timings were noisy (upstream process means 5.02-17.74 us, dictionary 6.37-6.90 us), so its median is not a reliable general speedup claim. Fresh compilation ranged 23.90-62.04 ms upstream, 9.07-9.32 ms HashSet, and 7.80-8.42 ms dictionary. The harness also measured no-overlap, mostly-nonmatching, empty-current, and mixed inherited property/field cases. It reused generated model types but created a fresh configuration and compiled both Map and MapToTarget on every compilation operation. This is a synthetic, single-machine/runtime diagnostic comparison, not a universal performance claim or a BenchmarkDotNet confidence-interval result. The eager-indexing trade-off warrants review; no pre-sizing, empty-source special case, or additional tuning is included.

Comment thread src/Mapster/Utils/ReflectionUtils.cs Outdated
Comment on lines +102 to +107
var firstMembersByName = new Dictionary<string, MemberInfo>(StringComparer.Ordinal);
foreach (var member in currentTypeMembers)
{
if (!firstMembersByName.ContainsKey(member.Name))
firstMembersByName.Add(member.Name, member);
}

@DocSvartz DocSvartz Sep 18, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

@vb-kalei
As I understand it, this is essentially an deterministic dictionary for the type (Its content will always be the same for each specific type).
Could it perhaps be stored somewhere in the context instead of being calculated from scratch every time?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

In this case, it turns out that even within a single call to GetFieldsAndProperties, this will be created at least twice.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Yes, you are right, I moved dictionary creation to GetFieldsAndProperties.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants