Skip to content

Add Dictionary and HashSet Remove/Contains benchmarks#5178

Open
danmoseley wants to merge 4 commits intodotnet:mainfrom
danmoseley:remove-contains-benchmarks
Open

Add Dictionary and HashSet Remove/Contains benchmarks#5178
danmoseley wants to merge 4 commits intodotnet:mainfrom
danmoseley:remove-contains-benchmarks

Conversation

@danmoseley
Copy link
Copy Markdown
Member

@danmoseley danmoseley commented Mar 27, 2026

Note

This PR description was AI/Copilot-generated.

Summary

Adds microbenchmark coverage for Dictionary and HashSet Remove and Contains operations, filling gaps identified while reviewing dotnet/runtime#125884 (Dictionary.Remove value-type optimization) and dotnet/runtime#125893 (HashSet bounds check elimination).

Existing coverage gaps

The existing cross-collection benchmarks (ContainsTrue, ContainsFalse, ContainsKeyTrue, ContainsKeyFalse) cover hit/miss Contains for int and string at a single size (512). There are no existing Remove benchmarks for Dictionary or HashSet upstream. The AddRemoveSteadyState benchmark measures combined add+remove throughput but doesn't isolate Remove hit/miss paths.

This means there was no way to measure the impact of the runtime PRs above on Remove codepaths, and limited ability to detect regressions in Contains for larger-than-cache collections or Guid keys (which exercise different hash/equality paths).

Rather than adding sizes/types to the existing cross-collection benchmarks (which cover many collection types and would multiply scenario count significantly), the new Contains benchmarks are focused on just Dictionary and HashSet with the additional sizes and Guid key type.

New benchmarks

Remove (cross-collection pattern matching existing Add/Contains structure):

  • RemoveTrue Γò¼├┤Γö£├ºΓö£Γòó steady-state remove hit (remove + re-add) for HashSet and Dictionary
  • RemoveFalse Γò¼├┤Γö£├ºΓö£Γòó remove miss (absent keys) for HashSet and Dictionary

Dictionary-specific:

  • DictionaryTryRemove Γò¼├┤Γö£├ºΓö£Γòó Remove(key, out value) overload, hit and miss paths

Contains (supplements existing cross-collection Contains benchmarks):

  • HashSetContains Γò¼├┤Γö£├ºΓö£Γòó Contains hit and miss with Guid keys
  • DictionaryContainsKey Γò¼├┤Γö£├ºΓö£Γòó ContainsKey hit and miss with Guid keys

Coverage

  • Key types: int, string, Guid (Remove); int, string, Guid (Contains)
  • Sizes: 512 (in-cache) and 8192 (past L1/L2 cache boundary)
  • 54 total scenarios (27 methods x 2 sizes), plus 2 large-dictionary scenarios

Large dictionary benchmark (DictionaryContainsKeyLarge)

Also adds a DictionaryContainsKeyLarge benchmark that measures ContainsKey on a 1M-entry dictionary (~20 MB, far exceeding L1/L2 cache). It probes 8192 keys per invocation into the 1M-entry table for realistic cache-miss pressure, while keeping per-call time low enough for stable BDN statistics (~1-2% noise). Naively looping all 1M keys per call yielded ~3.4% StdDev due to accumulated DRAM latency variance. Int keys only, since the goal is to isolate cache-miss behavior rather than hash function cost.

… coverage

Add focused benchmarks for Dictionary and HashSet operations at sizes
512 and 8192 (past L1/L2 cache) with int, string, and Guid type args:

- Remove/RemoveTrue.cs: steady-state remove hit (remove + re-add)
- Remove/RemoveFalse.cs: remove miss (absent keys)
- Dictionary/DictionaryTryRemove.cs: Remove(key, out value) overload
- Contains/HashSetContains.cs: HashSet Contains hit and miss
- Contains/DictionaryContainsKey.cs: Dictionary ContainsKey hit and miss

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@danmoseley
Copy link
Copy Markdown
Member Author

Ideally existing scenarios would test 8192 eg., but that would add expense, as alluded above; and the codepath for Add is largely shared with Contains/Remove so it will be partly protected by those new ones here

@danmoseley
Copy link
Copy Markdown
Member Author

danmoseley commented Mar 27, 2026

Note

This comment was generated with AI (Copilot CLI) assistance.

Local A/B benchmark results

Ran these new benchmarks locally comparing baseline (parent of dotnet/runtime#125884, commit 9ddca1d12b9) vs diff (PR head, commit 85c82a9456 ΓÇö Dictionary.Remove inner loop splitting for value-type key optimization).

Both builds were full clr+libs Release builds from dotnet/runtime. Benchmarks ran via BDN --coreRun with both CoreRun paths. Machine: i9-14900K, 64GB, Windows 11.

Remove hit (the target scenario for the PR)

Benchmark Size Base (ns) Diff (ns) Ratio Notes
RemoveTrue<Int32>.Dictionary 512 2,339 2,151 0.920 8% faster
RemoveTrue<Int32>.Dictionary 8192 85,112 85,639 1.006 neutral
RemoveTrue<String>.Dictionary 512 7,893 7,063 0.895 10% faster*
RemoveTrue<String>.Dictionary 8192 241,976 235,112 0.972 neutral
RemoveTrue<Guid>.Dictionary 512 3,212 2,855 0.889 11% faster
RemoveTrue<Guid>.Dictionary 8192 110,278 102,067 0.926 7% faster
RemoveTrue<Int32>.HashSet 512 2,273 2,227 0.980 neutral
RemoveTrue<Int32>.HashSet 8192 85,236 84,442 0.991 neutral
RemoveTrue<String>.HashSet 512 6,667 6,707 1.006 neutral
RemoveTrue<String>.HashSet 8192 236,059 226,602 0.960 neutral
RemoveTrue<Guid>.HashSet 512 2,956 3,028 1.024 neutral
RemoveTrue<Guid>.HashSet 8192 102,944 101,971 0.991 neutral

*Assumed noise — PR #125884 only optimizes value-type key paths; string keys should be unaffected.

Remove miss

Benchmark Size Base (ns) Diff (ns) Ratio Notes
RemoveFalse<Int32>.Dictionary 512 728 760 1.044 neutral
RemoveFalse<Int32>.Dictionary 8192 15,299 15,344 1.003 neutral
RemoveFalse<String>.Dictionary 512 2,662 2,658 0.999 neutral
RemoveFalse<String>.Dictionary 8192 116,725 115,795 0.992 neutral
RemoveFalse<Guid>.Dictionary 512 908 968 1.066 neutral
RemoveFalse<Guid>.Dictionary 8192 60,068 59,837 0.996 neutral
RemoveFalse<Int32>.HashSet 512 724 756 1.045 neutral
RemoveFalse<Int32>.HashSet 8192 15,408 15,292 0.993 neutral
RemoveFalse<String>.HashSet 512 2,462 2,478 1.006 neutral
RemoveFalse<String>.HashSet 8192 113,133 114,396 1.011 neutral
RemoveFalse<Guid>.HashSet 512 899 903 1.004 neutral
RemoveFalse<Guid>.HashSet 8192 51,167 50,339 0.984 neutral

Dictionary.Remove(key, out value) overload

Benchmark Size Base (ns) Diff (ns) Ratio Notes
DictionaryTryRemove<Int32, Int32> 512 2,419 2,249 0.930 7% faster
DictionaryTryRemove<Int32, Int32> 8192 87,407 83,774 0.958 slightly faster
DictionaryTryRemove<String, String> 512 7,106 6,877 0.968 slightly faster
DictionaryTryRemove<String, String> 8192 238,045 239,973 1.008 neutral
DictionaryTryRemove<Guid, Int32> 512 3,060 2,823 0.922 8% faster
DictionaryTryRemove<Guid, Int32> 8192 109,335 105,459 0.965 slightly faster

Contains (control group ΓÇö PR doesn't change lookup paths)

Benchmark Size Base (ns) Diff (ns) Ratio Notes
DictionaryContainsKey<Int32>.True 512 876 846 0.966 neutral
DictionaryContainsKey<Int32>.False 512 748 841 1.124 noise
DictionaryContainsKey<Int32>.True 8192 35,996 35,493 0.986 neutral
DictionaryContainsKey<Int32>.False 8192 20,879 19,851 0.951 neutral
DictionaryContainsKey<String>.True 512 2,833 2,813 0.993 neutral
DictionaryContainsKey<String>.False 512 2,650 2,678 1.011 neutral
DictionaryContainsKey<String>.True 8192 132,093 121,405 0.919 noise
DictionaryContainsKey<String>.False 8192 144,081 142,977 0.992 neutral
DictionaryContainsKey<Guid>.True 512 1,209 1,205 0.997 neutral
DictionaryContainsKey<Guid>.False 512 1,151 1,114 0.968 neutral
DictionaryContainsKey<Guid>.True 8192 38,230 39,696 1.038 neutral
DictionaryContainsKey<Guid>.False 8192 57,095 59,471 1.042 neutral
HashSetContains<Int32>.True 512 852 811 0.952 neutral
HashSetContains<Int32>.False 512 729 742 1.018 neutral
HashSetContains<Int32>.True 8192 38,599 40,421 1.047 neutral
HashSetContains<Int32>.False 8192 45,983 47,639 1.036 neutral
HashSetContains<String>.True 512 2,714 2,714 1.000 neutral
HashSetContains<String>.False 512 2,531 2,521 0.996 neutral
HashSetContains<String>.True 8192 117,403 116,836 0.995 neutral
HashSetContains<String>.False 8192 121,642 123,801 1.018 neutral
HashSetContains<Guid>.True 512 1,131 1,125 0.995 neutral
HashSetContains<Guid>.False 512 977 972 0.995 neutral
HashSetContains<Guid>.True 8192 37,539 36,828 0.981 neutral
HashSetContains<Guid>.False 8192 46,331 46,358 1.001 neutral

Summary

  • 7 scenarios show clear improvement (ratio ≤ 0.93) ΓÇö all on Dictionary Remove-hit and TryRemove paths, matching what the PR optimizes
  • All HashSet scenarios neutral ΓÇö expected, the PR only changes Dictionary
  • All miss paths neutral ΓÇö expected, the PR optimizes the found-key code path
  • No regressions detected
  • Improvements are strongest at size 512 (in-cache) for all key types, with Guid showing the largest gain (~11%)

@danmoseley
Copy link
Copy Markdown
Member Author

danmoseley commented Mar 27, 2026

It's a bit of a shame that we don't test collections above a few thousand entries. We surely have users that depend on good performance at much higher counts (lookup at least). Something to think about separarately from this PR, which follows existing patterns more or less and covers what we need for the product PR's above.

Probes 512 keys into a 1M-entry dictionary per invocation to measure
lookup behavior when the hash table far exceeds CPU cache, while
keeping per-call time low enough for stable BDN statistics (~0.5% noise).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Adds targeted microbenchmarks for Dictionary<TKey, TValue> and HashSet<T> Remove/Contains scenarios (including Guid keys and larger collection sizes) to better detect performance changes/regressions in these hot paths.

Changes:

  • Add steady-state Remove hit/miss benchmarks for HashSet<T> and Dictionary<TKey, TValue> at sizes 512 and 8192.
  • Add Dictionary<TKey, TValue>.Remove(key, out value) (TryRemove-style) benchmark.
  • Add focused Contains/ContainsKey benchmarks (including a large 1M-entry dictionary case).

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
src/benchmarks/micro/libraries/System.Collections/Remove/RemoveTrue.cs New steady-state “remove hit” benchmarks for HashSet<T> / Dictionary<TKey,TValue>.
src/benchmarks/micro/libraries/System.Collections/Remove/RemoveFalse.cs New “remove miss” benchmarks for HashSet<T> / Dictionary<TKey,TValue>.
src/benchmarks/micro/libraries/System.Collections/Dictionary/DictionaryTryRemove.cs New benchmark for Dictionary.Remove(key, out value) (hit path currently).
src/benchmarks/micro/libraries/System.Collections/Contains/HashSetContains.cs New focused HashSet<T>.Contains hit/miss benchmarks (includes Guid).
src/benchmarks/micro/libraries/System.Collections/Contains/DictionaryContainsKey.cs New focused Dictionary.ContainsKey hit/miss benchmarks + 1M-entry large dictionary variant.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

danmoseley and others added 2 commits March 27, 2026 15:13
512 probes fit in L1 cache, masking the large-table effect.
8192 probes (~512 KB working set) push into L2/L3, giving
realistic cache-miss behavior with ~1-2% noise.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
After a successful Remove, use Add() instead of the indexer to
re-add the entry. Add is semantically clearer (key is known absent),
fails loudly if the remove didn't work, and avoids the indexer's
extra exists-check overhead that can dilute the Remove signal.
Matches the HashSet path which already uses Add.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@danmoseley danmoseley requested a review from stephentoub March 28, 2026 02:31
@danmoseley
Copy link
Copy Markdown
Member Author

@DrewScoggins ci broken?

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.

3 participants