Skip to content

Use the runtime's HashSet.TryGetValue when it exists - #695

Merged
SimonCropp merged 1 commit into
mainfrom
perf/hashset-trygetvalue
Oct 12, 2026
Merged

SimonCropp merged 1 commit into
mainfrom
perf/hashset-trygetvalue

Conversation

@SimonCropp

Copy link
Copy Markdown
Owner

The HashSet<T>.TryGetValue polyfill is compiled for net461 to net471 and netstandard2.0. On a miss it already returns after a hashed Contains, but on a hit it scans the whole set to find the stored value, so hits are O(n).

HashSet<T>.TryGetValue itself exists in the runtime from .NET Framework 4.7.2 and .NET Core 2.0. Code compiled for these targets very often runs on such a runtime (a net462 app on a machine with .NET Framework 4.8, a netstandard2.0 library on modern .NET). The polyfill now looks the method up once per T, binds it to an open instance delegate held in a static generic class, and calls that. When the method is not there (runtime older than 4.7.2), or binding fails for any reason, the delegate is null and the existing Contains + scan code runs unchanged.

Soundness:

  • The lookup checks the shape of the method (public instance, returns bool, parameters (T, out T)) before binding, and is wrapped in a try/catch, so an unexpected runtime can only fall back to the scan.
  • typeof(HashSet<T>).GetMethod("TryGetValue", ...) is a pattern the trimmer understands. The code is only compiled for targets where the AOT/trim analyzers do not apply.
  • Results are the same either way: the stored value on a hit, default on a miss, null handled, no enumerator invalidation.

Measurements

Compiled for net462, run on .NET Framework 4.8, Release, x64, Stopwatch, best of 7, ns per call. The hit is a lookup of the last item added.

Set size string hit before after int hit before after string miss before after
1 66 32 33 10 68 19
10 233 22 88 11 39 29
1,000 19,083 29 2,179 7 18 13
100,000 568,970 16 194,135 7 15 10

Tests

Added to PolyfillTests_HashSet.cs (run on net462, where the polyfill is active, and against the real method everywhere else): the stored instance is returned rather than the argument; hits and a miss in a 2,000 item set with a case-insensitive comparer; value types (int, int? with null, a struct) including the default out value on a miss; after Remove, re-Add, Clear and on an empty set; calling it while enumerating the set.

Not covered: the scan fallback can not be reached on CI, because the .NET Framework there is 4.8, where the real method exists. That code is unchanged by this PR.

The TryGetValue polyfill scans the set to find the stored value on a hit. HashSet<T>.TryGetValue exists in the runtime from .NET Framework 4.7.2 and .NET Core 2.0, so call it through a cached delegate when it is there, and only scan when it is not.
@SimonCropp SimonCropp modified the milestones: 11.4.4, 11.4.5 Oct 12, 2026
@SimonCropp
SimonCropp merged commit 4707e36 into main Oct 12, 2026
7 checks passed
@SimonCropp
SimonCropp deleted the perf/hashset-trygetvalue branch October 12, 2026 12:07
This was referenced Oct 12, 2026
This was referenced Oct 14, 2026
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.

1 participant